diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..3419f57 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,23 @@ +name: Continuous Integration + +on: [push] + +jobs: + ci: + name: CI + runs-on: ubuntu-latest + env: + DOCKER_BUILDKIT: "1" + steps: + - name: Checkout code + uses: actions/checkout@v2 + - name: Run linter + run: make lint + - name: Run unit tests + run: make unit-test + - name: Get unit test coverage + run: make unit-test-coverage + - name: Build Linux binary + run: make PLATFORM=linux/amd64 + - name: Build Windows binary + run: make PLATFORM=windows/amd64 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..230567d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,71 @@ +# Containerized-go dockerfile + +FROM --platform=${BUILDPLATFORM} golang:1.24.3-alpine AS base +WORKDIR /src +ENV CGO_ENABLED=1 +RUN apk update +RUN apk add gcc g++ +RUN mkdir /out + +FROM base AS buildbase +COPY go.* . +RUN --mount=type=cache,target=/go/pkg/mod \ + go mod download + +FROM buildbase AS build +ARG TARGETOS +ARG TARGETARCH +RUN --mount=target=. \ + --mount=type=cache,target=/go/pkg/mod \ + --mount=type=cache,target=/root/.cache/go-build \ + GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -o /out/example . + +FROM base AS unit-test +RUN --mount=target=. \ + --mount=type=cache,target=/go/pkg/mod \ + --mount=type=cache,target=/root/.cache/go-build \ + go test -v -coverprofile=/out/cover.out ./... + +FROM golangci/golangci-lint:v2.1.6-alpine AS lint-base + +FROM base AS lint +RUN --mount=target=. \ + --mount=from=lint-base,src=/usr/bin/golangci-lint,target=/usr/bin/golangci-lint \ + --mount=type=cache,target=/go/pkg/mod \ + --mount=type=cache,target=/root/.cache/go-build \ + --mount=type=cache,target=/root/.cache/golangci-lint \ + golangci-lint run --timeout 10m0s ./... + +FROM base AS mod-init-base +WORKDIR /out +COPY . . +ENV MODNAME tastytrade +RUN go mod init "${MODNAME}" && go mod tidy + +FROM base AS mod-tidy +WORKDIR /out +COPY . . +RUN go mod tidy + +FROM scratch AS mod-init +COPY --from=mod-init-base /out/go.mod /go.mod +COPY --from=mod-init-base /out/go.sum /go.sum + +FROM scratch AS tidy +COPY --from=mod-tidy /out/go.mod /go.mod +COPY --from=mod-tidy /out/go.sum /go.sum +COPY --from=mod-tidy /out/go.mod /go.mod + +FROM scratch AS unit-test-coverage +COPY --from=unit-test /out/cover.out /cover.out + +FROM scratch AS bin-unix +COPY --from=build /out/example / + +FROM bin-unix AS bin-linux +FROM bin-unix AS bin-darwin + +FROM scratch AS bin-windows +COPY --from=build /out/example /example.exe + +FROM bin-${TARGETOS} AS bin diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..ca4d7ab --- /dev/null +++ b/Makefile @@ -0,0 +1,37 @@ +all: bin/example +test: lint unit-test + +PLATFORM=local +UTIL_TAG=1.24.3-alpine + +export DOCKER_BUILDKIT=1 + +.PHONY: bin/example +bin/example: + @docker build . --target bin \ + --output bin/ \ + --platform ${PLATFORM} + +.PHONY: unit-test +unit-test: + @docker build . --target unit-test + +.PHONY: unit-test-coverage +unit-test-coverage: + @docker build . --target unit-test-coverage \ + --output coverage/ + cat coverage/cover.out + +.PHONY: lint +lint: + @docker build . --target lint + +.PHONY: tidy +tidy: + export DOCKER_BUILDKIT=1 + @docker build . --target tidy --output . + +.PHONY: init +init: + export DOCKER_BUILDKIT=1 + @docker build . --target mod-init --output . diff --git a/README.md b/README.md index 7fb1e45..554c033 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Go Tastytrade Open API Wrapper -![Build](https://sancus.carpanet.net/sjc/tastytrade/actions/workflows/build.yaml/badge.svg) +![Build](https://sancus.carpanet.net/sjc/tastytrade/actions/workflows/ci.yaml/badge.svg) Go API wrapper for the Tastytrade Open API diff --git a/api.go b/api.go index 03ed7c5..fde3605 100644 --- a/api.go +++ b/api.go @@ -14,7 +14,9 @@ const ( // TastytradeAPI represents the Tastytrade API client type TastytradeAPI struct { httpClient *http.Client + user string authToken string + authExpire time.Time remToken string host string } @@ -31,6 +33,11 @@ func NewTastytradeAPI(hosts ...string) *TastytradeAPI { } } +// get logged in User +func (api *TastytradeAPI) User() string { + return api.user +} + // fetchData sends a GET request to the specified URL with authorization func (api *TastytradeAPI) fetchData(url string) (map[string]interface{}, error) { req, err := http.NewRequest("GET", url, nil) diff --git a/go.mod b/go.mod index 66d843f..2bfca4d 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,5 @@ -module sancus.carpanet.net/sjc/tastytrade +module tastytrade -go 1.23 +go 1.24.3 + +require sancus.carpanet.net/sjc/tastytrade v0.0.0-20250507051921-46dbff053071 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..100d331 --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +sancus.carpanet.net/sjc/tastytrade v0.0.0-20250507051921-46dbff053071 h1:tkqr+I0vRU0f1VHW/rwZQ6Iv9viIgo1ThpLXxo/CoBI= +sancus.carpanet.net/sjc/tastytrade v0.0.0-20250507051921-46dbff053071/go.mod h1:w85F/rGX4Lt1UrvVtRE+muoc4wI4A0GSMqXIj2mN8FI= diff --git a/sessions.go b/sessions.go index 3301652..b562693 100644 --- a/sessions.go +++ b/sessions.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "net/http" + "time" ) type User struct { @@ -15,8 +16,9 @@ type User struct { } type AuthData struct { - User User `json:"user"` - SessionToken string `json:"session-token"` + User User `json:"user"` + SessionToken string `json:"session-token"` + SessionExpire time.Time `json:"session-expiration"` RememberToken string `json:"remember-token"` } @@ -59,9 +61,10 @@ func (api *TastytradeAPI) Authenticate2(username, password string, remember bool return err } - api.authToken = authResponse.Data.SessionToken - if remember { - api.remToken = authResponse.Data.RememberToken + api.authToken = authResponse.Data.SessionToken + api.authExpire = authResponse.Data.SessionExpire + if (remember) { + api.remToken = authResponse.Data.RememberToken } return nil } @@ -69,3 +72,48 @@ func (api *TastytradeAPI) Authenticate2(username, password string, remember bool func (api *TastytradeAPI) Authenticate(username, password string) error { return api.Authenticate2(username, password, false) } + +// Redeem a remember token to get a session + +func (api *TastytradeAPI) AuthRemember(remember bool) error { + var remStr string + if remember { + remStr = "true" + } else { + remStr = "false" + } + authURL := fmt.Sprintf("%s/sessions", api.host) + authData := map[string]string{ + "login": api.user, + "remember-token": api.remToken, + "remember-me": remStr, + } + authBody, err := json.Marshal(authData) + if err != nil { + return err + } + + resp, err := api.httpClient.Post(authURL, "application/json", bytes.NewReader(authBody)) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { + return fmt.Errorf("authentication failed: status code %d", resp.StatusCode) + } + + authResponse := AuthResponse{} + if err := json.NewDecoder(resp.Body).Decode(&authResponse); err != nil { + return err + } + + api.authToken = authResponse.Data.SessionToken + if (remember) { + api.remToken = authResponse.Data.RememberToken + } else { + api.remToken = "" + } + return nil +} + diff --git a/sessions_test.go b/sessions_test.go index c711b81..c59ee8d 100644 --- a/sessions_test.go +++ b/sessions_test.go @@ -3,6 +3,7 @@ package tastytrade import ( "net/http" "net/http/httptest" + "time" "testing" ) @@ -30,7 +31,7 @@ func TestAuthenticate(t *testing.T) { func TestAuthenticate2(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { - rw.Write([]byte(`{"context": "test", "data": {"user": {"email": "test@example.com", "username": "testuser", "external-id": "123", "is-confirmed": true}, "session-token": "testtoken", "remember-token": "remember"}}`)) + rw.Write([]byte(`{"context": "test", "data": {"user": {"email": "test@example.com", "username": "testuser", "external-id": "123", "is-confirmed": true}, "session-token": "testtoken", "remember-token": "remember", "session-expiration": "2024-09-12T20:25:32.440Z"}}`)) })) defer server.Close() @@ -48,4 +49,41 @@ func TestAuthenticate2(t *testing.T) { if (api.remToken != "remember") { t.Errorf("Expected remember token remember, got remember token %s", api.remToken) } + + et, _ := time.Parse(time.RFC3339, "2024-09-12T20:25:32.440Z") + if (api.authExpire != et) { + t.Errorf("Expected expire %s, got %s", et, api.authExpire) + } +} + +func TestAuthRemember(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + rw.Write([]byte(`{"context": "test", "data": {"user": {"email": "test@example.com", "username": "testuser", "external-id": "123", "is-confirmed": true}, "session-token": "testtoken", "remember-token": "remember-new"}}`)) + })) + var err error + defer server.Close() + api := NewTastytradeAPI(server.URL) + err = api.Authenticate2("testuser", "testpassword", true) + if err != nil { + t.Errorf("expected nil, got %v", err) + } + + api.remToken = "XXBADVALUEXX" + err = api.AuthRemember(true) + if err != nil { + t.Errorf("expected nil, got %v", err) + } + + if (api.remToken != "remember-new") { + t.Errorf("Expected remember token remember, got remember token %s", api.remToken) + } + + err = api.AuthRemember(false) + if err != nil { + t.Errorf("expected nil, got %v", err) + } + + if (len(api.remToken) > 0) { + t.Errorf("Expected empty remember token, got remember token %s", api.remToken) + } }