diff --git a/.gitignore b/.gitignore index 1521c8b..78173b0 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ dist +coverage.out diff --git a/go.mod b/go.mod index 2e16bea..e165a0f 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/bzimmer/activity -go 1.26.2 +go 1.26.5 require ( github.com/bzimmer/httpwares v0.1.3 @@ -17,8 +17,8 @@ require ( require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - golang.org/x/net v0.53.0 // indirect - golang.org/x/text v0.36.0 // indirect + golang.org/x/net v0.55.0 // indirect + golang.org/x/text v0.37.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 5195fcb..97afc31 100644 --- a/go.sum +++ b/go.sum @@ -40,14 +40,14 @@ github.com/twpayne/go-gpx v1.5.0 h1:HvFSJ+0r0sbhOQ8mTvd0/n0FhcgjTFsKQGG6o7PV6G4= github.com/twpayne/go-gpx v1.5.0/go.mod h1:vjvu/125399qj6k+px2v2v8dm08DM4I4dFBJmHHt2TE= github.com/twpayne/go-polyline v1.1.1 h1:/tSF1BR7rN4HWj4XKqvRUNrCiYVMCvywxTFVofvDV0w= github.com/twpayne/go-polyline v1.1.1/go.mod h1:ybd9IWWivW/rlXPXuuckeKUyF3yrIim+iqA7kSl4NFY= -golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= -golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= -golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/hammerhead/activities.go b/hammerhead/activities.go new file mode 100644 index 0000000..25cfd53 --- /dev/null +++ b/hammerhead/activities.go @@ -0,0 +1,117 @@ +package hammerhead + +import ( + "context" + "fmt" + "net/http" + "net/url" + + "github.com/bzimmer/activity" +) + +const pageSize = 100 + +// ActivitiesService provides access to Hammerhead activity endpoints +type ActivitiesService service + +type activitiesPaginator struct { + service ActivitiesService + activities []*ActivitySummary + startDate string +} + +func (p *activitiesPaginator) PageSize() int { + return pageSize +} + +func (p *activitiesPaginator) Count() int { + return len(p.activities) +} + +func (p *activitiesPaginator) Do(ctx context.Context, spec activity.Pagination) (int, error) { + v := url.Values{} + v.Set("page", fmt.Sprintf("%d", spec.Start)) + v.Set("perPage", fmt.Sprintf("%d", spec.Count)) + if p.startDate != "" { + v.Set("startDate", p.startDate) + } + req, err := p.service.client.newAPIRequest(ctx, http.MethodGet, "activities?"+v.Encode()) + if err != nil { + return 0, err + } + res := &ActivitiesPage{} + if err = p.service.client.do(req, res); err != nil { + return 0, err + } + if spec.Total > 0 && len(p.activities)+len(res.Data) > spec.Total { + res.Data = res.Data[:spec.Total-len(p.activities)] + } + p.activities = append(p.activities, res.Data...) + if res.CurrentPage >= res.TotalPages { + return 0, nil + } + return len(res.Data), nil +} + +// Activities returns a slice of activity summaries +func (s *ActivitiesService) Activities( + ctx context.Context, spec activity.Pagination, startDate string) ([]*ActivitySummary, error) { + p := &activitiesPaginator{ + service: *s, + startDate: startDate, + activities: make([]*ActivitySummary, 0), + } + if err := activity.Paginate(ctx, p, spec); err != nil { + return nil, err + } + return p.activities, nil +} + +// Activity returns a single activity by ID +func (s *ActivitiesService) Activity(ctx context.Context, activityID string) (*Activity, error) { + req, err := s.client.newAPIRequest(ctx, http.MethodGet, fmt.Sprintf("activities/%s", activityID)) + if err != nil { + return nil, err + } + res := &Activity{} + if err = s.client.do(req, res); err != nil { + return nil, err + } + return res, nil +} + +// File downloads the original FIT file for an activity +func (s *ActivitiesService) File(ctx context.Context, activityID string) (*activity.File, error) { + req, err := s.client.newAPIRequest(ctx, http.MethodGet, fmt.Sprintf("activities/%s/file", activityID)) + if err != nil { + return nil, err + } + res, err := s.client.base.HTTP.Do(req) + if err != nil { + return nil, err + } + if res.StatusCode >= http.StatusBadRequest { + defer res.Body.Close() + f := &Fault{} + f.SetDefaults(res.StatusCode, http.StatusText(res.StatusCode)) + return nil, f + } + return &activity.File{ + Reader: res.Body, + Filename: fmt.Sprintf("%s.fit", activityID), + Name: activityID, + Format: activity.FormatFIT, + }, nil +} + +// Export implements activity.Exporter by downloading the FIT file for an activity. +// The activityID is provided as an int64 but Hammerhead uses string IDs in their API; +// the int64 value is formatted as a decimal string for the request. +func (s *ActivitiesService) Export(ctx context.Context, activityID int64) (*activity.Export, error) { + id := fmt.Sprintf("%d", activityID) + f, err := s.File(ctx, id) + if err != nil { + return nil, err + } + return &activity.Export{File: f, ID: activityID}, nil +} diff --git a/hammerhead/activities_test.go b/hammerhead/activities_test.go new file mode 100644 index 0000000..43daaec --- /dev/null +++ b/hammerhead/activities_test.go @@ -0,0 +1,339 @@ +package hammerhead_test + +import ( + "encoding/json" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/bzimmer/activity" + "github.com/bzimmer/activity/hammerhead" +) + +func TestActivities(t *testing.T) { + t.Parallel() + a := assert.New(t) + + tests := []struct { + name string + before func(mux *http.ServeMux) + after func(acts []*hammerhead.ActivitySummary, err error) + }{ + { + name: "valid activities", + before: func(mux *http.ServeMux) { + mux.HandleFunc("/activities", func(w http.ResponseWriter, r *http.Request) { + http.ServeFile(w, r, "testdata/hammerhead_activities.json") + }) + }, + after: func(acts []*hammerhead.ActivitySummary, err error) { + a.NoError(err) + a.Len(acts, 2) + a.Equal("activity-001", acts[0].ID) + a.Equal("Morning Ride", acts[0].Name) + a.Equal(3600, acts[0].Duration) + }, + }, + { + name: "server error", + before: func(mux *http.ServeMux) { + mux.HandleFunc("/activities", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + }) + }, + after: func(acts []*hammerhead.ActivitySummary, err error) { + a.Error(err) + a.Nil(acts) + }, + }, + { + name: "activities with start date", + before: func(mux *http.ServeMux) { + mux.HandleFunc("/activities", func(w http.ResponseWriter, r *http.Request) { + a.Equal("2024-01-01", r.URL.Query().Get("startDate")) + enc := json.NewEncoder(w) + a.NoError(enc.Encode(&hammerhead.ActivitiesPage{ + TotalItems: 1, + TotalPages: 1, + PerPage: 100, + CurrentPage: 1, + Data: []*hammerhead.ActivitySummary{ + {ID: "activity-003", Name: "New Year Ride", Duration: 1800}, + }, + })) + }) + }, + after: func(acts []*hammerhead.ActivitySummary, err error) { + a.NoError(err) + a.Len(acts, 1) + a.Equal("activity-003", acts[0].ID) + }, + }, + { + name: "pagination across multiple pages", + before: func(mux *http.ServeMux) { + mux.HandleFunc("/activities", func(w http.ResponseWriter, r *http.Request) { + page := r.URL.Query().Get("page") + enc := json.NewEncoder(w) + if page == "1" { + a.NoError(enc.Encode(&hammerhead.ActivitiesPage{ + TotalItems: 2, + TotalPages: 2, + PerPage: 1, + CurrentPage: 1, + Data: []*hammerhead.ActivitySummary{{ID: "a1", Name: "Ride 1"}}, + })) + } else { + a.NoError(enc.Encode(&hammerhead.ActivitiesPage{ + TotalItems: 2, + TotalPages: 2, + PerPage: 1, + CurrentPage: 2, + Data: []*hammerhead.ActivitySummary{{ID: "a2", Name: "Ride 2"}}, + })) + } + }) + }, + after: func(acts []*hammerhead.ActivitySummary, err error) { + a.NoError(err) + a.Len(acts, 2) + }, + }, + } + + for i := range tests { + tt := tests[i] + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + var startDate string + if strings.Contains(tt.name, "start date") { + startDate = "2024-01-01" + } + client, svr := newClient(t, tt.before) + defer svr.Close() + acts, err := client.Activities.Activities(t.Context(), activity.Pagination{}, startDate) + tt.after(acts, err) + }) + } +} + +func TestActivity(t *testing.T) { + t.Parallel() + a := assert.New(t) + + tests := []struct { + name string + before func(mux *http.ServeMux) + after func(act *hammerhead.Activity, err error) + }{ + { + name: "valid activity", + before: func(mux *http.ServeMux) { + mux.HandleFunc("/activities/activity-001", func(w http.ResponseWriter, r *http.Request) { + http.ServeFile(w, r, "testdata/hammerhead_activity.json") + }) + }, + after: func(act *hammerhead.Activity, err error) { + a.NoError(err) + a.NotNil(act) + a.Equal("activity-001", act.ID) + a.Equal("Morning Ride", act.Name) + a.Equal(hammerhead.ActivityTypeRide, act.ActivityType) + a.Equal("A lovely morning ride", act.Description) + }, + }, + { + name: "not found", + before: func(mux *http.ServeMux) { + mux.HandleFunc("/activities/missing", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + }) + }, + after: func(act *hammerhead.Activity, err error) { + a.Error(err) + a.Nil(act) + }, + }, + } + + for i := range tests { + tt := tests[i] + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + client, svr := newClient(t, tt.before) + defer svr.Close() + var id string + if strings.Contains(tt.name, "not found") { + id = "missing" + } else { + id = "activity-001" + } + act, err := client.Activities.Activity(t.Context(), id) + tt.after(act, err) + }) + } +} + +func TestFile(t *testing.T) { + t.Parallel() + a := assert.New(t) + + tests := []struct { + name string + before func(mux *http.ServeMux) + after func(file *activity.File, err error) + }{ + { + name: "valid fit file", + before: func(mux *http.ServeMux) { + mux.HandleFunc("/activities/activity-001/file", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/vnd.ant.fit") + _, _ = w.Write([]byte("FIT file content")) + }) + }, + after: func(file *activity.File, err error) { + a.NoError(err) + a.NotNil(file) + a.Equal(activity.FormatFIT, file.Format) + a.Equal("activity-001", file.Name) + a.NoError(file.Close()) + }, + }, + { + name: "server error on file", + before: func(mux *http.ServeMux) { + mux.HandleFunc("/activities/activity-001/file", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + }) + }, + after: func(file *activity.File, err error) { + a.Error(err) + a.Nil(file) + }, + }, + } + + for i := range tests { + tt := tests[i] + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + client, svr := newClient(t, tt.before) + defer svr.Close() + file, err := client.Activities.File(t.Context(), "activity-001") + tt.after(file, err) + }) + } +} + +func TestExporter(t *testing.T) { + t.Parallel() + a := assert.New(t) + + tests := []struct { + name string + before func(mux *http.ServeMux) + after func(export *activity.Export, err error) + }{ + { + name: "valid export", + before: func(mux *http.ServeMux) { + mux.HandleFunc("/activities/12345/file", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/vnd.ant.fit") + _, _ = w.Write([]byte("FIT file content")) + }) + }, + after: func(export *activity.Export, err error) { + a.NoError(err) + a.NotNil(export) + a.Equal(int64(12345), export.ID) + a.Equal(activity.FormatFIT, export.Format) + }, + }, + { + name: "file error propagated", + before: func(mux *http.ServeMux) { + mux.HandleFunc("/activities/12345/file", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + }) + }, + after: func(export *activity.Export, err error) { + a.Error(err) + a.Nil(export) + }, + }, + } + + for i := range tests { + tt := tests[i] + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + client, svr := newClient(t, tt.before) + defer svr.Close() + exporter := client.Exporter() + export, err := exporter.Export(t.Context(), 12345) + tt.after(export, err) + }) + } +} + +func TestMissingAccessToken(t *testing.T) { + t.Parallel() + a := assert.New(t) + + client, err := hammerhead.NewClient( + hammerhead.WithClientCredentials("id", "secret"), + ) + a.NoError(err) + + _, err = client.Activities.Activities(t.Context(), activity.Pagination{}, "") + a.Error(err) + a.Contains(err.Error(), "accessToken required") + + _, err = client.Activities.Activity(t.Context(), "activity-001") + a.Error(err) + a.Contains(err.Error(), "accessToken required") + + _, err = client.Activities.File(t.Context(), "activity-001") + a.Error(err) + a.Contains(err.Error(), "accessToken required") +} + +func TestActivitiesTruncation(t *testing.T) { + t.Parallel() + a := assert.New(t) + + client, svr := newClient(t, func(mux *http.ServeMux) { + mux.HandleFunc("/activities", func(w http.ResponseWriter, _ *http.Request) { + enc := json.NewEncoder(w) + a.NoError(enc.Encode(&hammerhead.ActivitiesPage{ + TotalItems: 2, + TotalPages: 1, + PerPage: 2, + CurrentPage: 1, + Data: []*hammerhead.ActivitySummary{ + {ID: "a1", Name: "Ride 1"}, + {ID: "a2", Name: "Ride 2"}, + }, + })) + }) + }) + defer svr.Close() + + acts, err := client.Activities.Activities(t.Context(), activity.Pagination{Total: 1}, "") + a.NoError(err) + a.Len(acts, 1) + a.Equal("a1", acts[0].ID) +} + +func TestFileTransportError(t *testing.T) { + t.Parallel() + a := assert.New(t) + + client, svr := newClient(t, nil) + svr.Close() + + _, err := client.Activities.File(t.Context(), "activity-001") + a.Error(err) +} diff --git a/hammerhead/auth.go b/hammerhead/auth.go new file mode 100644 index 0000000..7bedf3c --- /dev/null +++ b/hammerhead/auth.go @@ -0,0 +1,17 @@ +package hammerhead + +import ( + "context" + + "golang.org/x/oauth2" +) + +// AuthService is the API for auth endpoints +type AuthService service + +// Refresh returns a new access token +func (s *AuthService) Refresh(ctx context.Context) (*oauth2.Token, error) { + t := s.client.base.Config.TokenSource(ctx, s.client.base.Token) + t = oauth2.ReuseTokenSource(s.client.base.Token, t) + return t.Token() +} diff --git a/hammerhead/auth_test.go b/hammerhead/auth_test.go new file mode 100644 index 0000000..6e3e9d8 --- /dev/null +++ b/hammerhead/auth_test.go @@ -0,0 +1,43 @@ +package hammerhead_test + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "golang.org/x/oauth2" +) + +func TestRefresh(t *testing.T) { + t.Parallel() + a := assert.New(t) + + tests := []struct { + name string + before func(mux *http.ServeMux) + after func(token *oauth2.Token, err error) + }{ + { + name: "valid refresh", + before: func(mux *http.ServeMux) { + mux.HandleFunc("/oauth/token", func(w http.ResponseWriter, r *http.Request) { + http.ServeFile(w, r, "testdata/refresh.json") + }) + }, + after: func(token *oauth2.Token, err error) { + a.NoError(err) + a.NotNil(token) + }, + }, + } + for i := range tests { + tt := tests[i] + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + client, svr := newClient(t, tt.before) + defer svr.Close() + token, err := client.Auth.Refresh(t.Context()) + tt.after(token, err) + }) + } +} diff --git a/hammerhead/hammerhead.go b/hammerhead/hammerhead.go new file mode 100644 index 0000000..3ed1c86 --- /dev/null +++ b/hammerhead/hammerhead.go @@ -0,0 +1,94 @@ +package hammerhead + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + + "golang.org/x/oauth2" + + "github.com/bzimmer/activity" + "github.com/bzimmer/activity/internal/httpclient" +) + +const ( + authURL = "https://api.hammerhead.io/v1/auth" + apiURL = "https://api.hammerhead.io/v1/api" +) + +// Endpoint is Hammerhead's OAuth 2.0 endpoint +func Endpoint() oauth2.Endpoint { + return oauth2.Endpoint{ + AuthURL: authURL + "/oauth/authorize", + TokenURL: authURL + "/oauth/token", + AuthStyle: oauth2.AuthStyleInParams, + } +} + +// Client for accessing Hammerhead's API +type Client struct { + base *httpclient.Client[*Fault] + authURL string + apiURL string + + Auth *AuthService + Activities *ActivitiesService +} + +// Exporter returns an Exporter for this client +func (c *Client) Exporter() activity.Exporter { + return c.Activities +} + +func withServices() Option { + return func(c *Client) error { + c.Auth = &AuthService{client: c} + c.Activities = &ActivitiesService{client: c} + if c.authURL == "" { + c.authURL = authURL + } + if c.apiURL == "" { + c.apiURL = apiURL + } + return nil + } +} + +// WithAPIURL specifies the API base url +func WithAPIURL(apiURL string) Option { + return func(c *Client) error { + c.apiURL = apiURL + return nil + } +} + +// WithAuthURL specifies the auth base url +func WithAuthURL(authURL string) Option { + return func(c *Client) error { + c.authURL = authURL + return nil + } +} + +func (c *Client) newAPIRequest(ctx context.Context, method, uri string) (*http.Request, error) { + if c.base.Token.AccessToken == "" { + return nil, errors.New("accessToken required") + } + u, err := url.Parse(fmt.Sprintf("%s/%s", c.apiURL, uri)) + if err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, method, u.String(), nil) + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", activity.UserAgent) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %v", c.base.Token.AccessToken)) + return req, nil +} + +func (c *Client) do(req *http.Request, v any) error { + return c.base.Do(req, v) +} diff --git a/hammerhead/hammerhead_test.go b/hammerhead/hammerhead_test.go new file mode 100644 index 0000000..48370c6 --- /dev/null +++ b/hammerhead/hammerhead_test.go @@ -0,0 +1,236 @@ +package hammerhead_test + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "golang.org/x/oauth2" + "golang.org/x/time/rate" + + "github.com/bzimmer/activity" + "github.com/bzimmer/activity/hammerhead" +) + +func newClient(t *testing.T, before func(*http.ServeMux)) (*hammerhead.Client, *httptest.Server) { + t.Helper() + mux := http.NewServeMux() + if before != nil { + before(mux) + } + svr := httptest.NewServer(mux) + client, err := hammerhead.NewClient( + hammerhead.WithAPIURL(svr.URL), + hammerhead.WithAuthURL(svr.URL), + hammerhead.WithHTTPTracing(false), + hammerhead.WithClientCredentials("testClientID", "testClientSecret"), + hammerhead.WithTokenCredentials("testAccessToken", "testRefreshToken", time.Time{}), + ) + if err != nil { + t.Fatal(err) + } + return client, svr +} + +func TestFault(t *testing.T) { + t.Parallel() + a := assert.New(t) + + f := &hammerhead.Fault{Message: "something went wrong", StatusCode: 400} + a.Equal("something went wrong", f.Error()) + + f2 := &hammerhead.Fault{} + f2.SetDefaults(404, "Not Found") + a.Equal(404, f2.StatusCode) + a.Equal("Not Found", f2.Message) +} + +func TestOptions(t *testing.T) { + t.Parallel() + a := assert.New(t) + + tests := []struct { + name string + before func() []hammerhead.Option + after func(client *hammerhead.Client, err error) + }{ + { + name: "no options", + before: func() []hammerhead.Option { + return nil + }, + after: func(client *hammerhead.Client, err error) { + a.NoError(err) + a.NotNil(client) + }, + }, + { + name: "with config", + before: func() []hammerhead.Option { + return []hammerhead.Option{hammerhead.WithConfig(oauth2.Config{})} + }, + after: func(client *hammerhead.Client, err error) { + a.NoError(err) + a.NotNil(client) + }, + }, + { + name: "with token", + before: func() []hammerhead.Option { + return []hammerhead.Option{hammerhead.WithToken(&oauth2.Token{})} + }, + after: func(client *hammerhead.Client, err error) { + a.NoError(err) + a.NotNil(client) + }, + }, + { + name: "with auto refresh", + before: func() []hammerhead.Option { + return []hammerhead.Option{hammerhead.WithAutoRefresh(context.Background())} + }, + after: func(client *hammerhead.Client, err error) { + a.NoError(err) + a.NotNil(client) + }, + }, + { + name: "with rate limiter", + before: func() []hammerhead.Option { + return []hammerhead.Option{hammerhead.WithRateLimiter(rate.NewLimiter(rate.Inf, 0))} + }, + after: func(client *hammerhead.Client, err error) { + a.NoError(err) + a.NotNil(client) + }, + }, + { + name: "with nil rate limiter returns error", + before: func() []hammerhead.Option { + return []hammerhead.Option{hammerhead.WithRateLimiter(nil)} + }, + after: func(client *hammerhead.Client, err error) { + a.Error(err) + a.Nil(client) + }, + }, + { + name: "with transport", + before: func() []hammerhead.Option { + return []hammerhead.Option{hammerhead.WithTransport(http.DefaultTransport)} + }, + after: func(client *hammerhead.Client, err error) { + a.NoError(err) + a.NotNil(client) + }, + }, + { + name: "with nil transport returns error", + before: func() []hammerhead.Option { + return []hammerhead.Option{hammerhead.WithTransport(nil)} + }, + after: func(client *hammerhead.Client, err error) { + a.Error(err) + a.Nil(client) + }, + }, + { + name: "with http client", + before: func() []hammerhead.Option { + return []hammerhead.Option{hammerhead.WithHTTPClient(http.DefaultClient)} + }, + after: func(client *hammerhead.Client, err error) { + a.NoError(err) + a.NotNil(client) + }, + }, + { + name: "with nil http client returns error", + before: func() []hammerhead.Option { + return []hammerhead.Option{hammerhead.WithHTTPClient(nil)} + }, + after: func(client *hammerhead.Client, err error) { + a.Error(err) + a.Nil(client) + }, + }, + { + name: "with http tracing", + before: func() []hammerhead.Option { + return []hammerhead.Option{hammerhead.WithHTTPTracing(true)} + }, + after: func(client *hammerhead.Client, err error) { + a.NoError(err) + a.NotNil(client) + }, + }, + } + + for i := range tests { + tt := tests[i] + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + client, err := hammerhead.NewClient(tt.before()...) + tt.after(client, err) + }) + } +} + +func TestFaultFromServer(t *testing.T) { + t.Parallel() + a := assert.New(t) + + tests := []struct { + name string + before func(mux *http.ServeMux) + after func(err error) + }{ + { + name: "fault with defaults from empty body", + before: func(mux *http.ServeMux) { + mux.HandleFunc("/activities", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + }) + }, + after: func(err error) { + a.Error(err) + a.Equal("Unauthorized", err.Error()) + }, + }, + } + + for i := range tests { + tt := tests[i] + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + client, svr := newClient(t, tt.before) + defer svr.Close() + _, err := client.Activities.Activities(t.Context(), activity.Pagination{}, "") + tt.after(err) + }) + } +} + +func TestEndpoint(t *testing.T) { + t.Parallel() + a := assert.New(t) + ep := hammerhead.Endpoint() + a.NotEmpty(ep.AuthURL) + a.NotEmpty(ep.TokenURL) +} + +func TestInvalidAPIURL(t *testing.T) { + t.Parallel() + a := assert.New(t) + + client, err := hammerhead.NewClient( + hammerhead.WithAPIURL("%%invalid"), + hammerhead.WithTokenCredentials("token", "refresh", time.Time{}), + ) + a.NoError(err) + _, err = client.Activities.Activity(t.Context(), "123") + a.Error(err) +} diff --git a/hammerhead/hammerhead_with.go b/hammerhead/hammerhead_with.go new file mode 100644 index 0000000..822d38f --- /dev/null +++ b/hammerhead/hammerhead_with.go @@ -0,0 +1,106 @@ +package hammerhead + +import ( + "context" + "net/http" + "time" + + "golang.org/x/oauth2" + "golang.org/x/time/rate" + + "github.com/bzimmer/activity/internal/httpclient" +) + +type service struct { + client *Client +} + +// Option provides a configuration mechanism for a Client. +type Option func(*Client) error + +// NewClient creates a new client and applies all provided Options. +func NewClient(opts ...Option) (*Client, error) { + c := &Client{ + base: httpclient.New(func() *Fault { return &Fault{} }), + } + c.base.Config.Endpoint = Endpoint() + opts = append(opts, withServices()) + for _, opt := range opts { + if err := opt(c); err != nil { + return nil, err + } + } + return c, nil +} + +// WithConfig sets the underlying oauth2.Config. +func WithConfig(config oauth2.Config) Option { + return func(c *Client) error { + c.base.Config = config + return nil + } +} + +// WithClientCredentials provides the client api credentials for the application. +func WithClientCredentials(clientID, clientSecret string) Option { + return func(c *Client) error { + c.base.Config.ClientID = clientID + c.base.Config.ClientSecret = clientSecret + return nil + } +} + +// WithAutoRefresh refreshes access tokens automatically. +// The order of this option matters because it is dependent on the client's +// config and token. Use this option after With*Credentials. +func WithAutoRefresh(ctx context.Context) Option { + return func(c *Client) error { + return httpclient.ApplyAutoRefresh(ctx, c.base) + } +} + +// WithToken sets the underlying oauth2.Token. +func WithToken(token *oauth2.Token) Option { + return func(c *Client) error { + c.base.Token = token + return nil + } +} + +// WithTokenCredentials provides the tokens for an authenticated user. +func WithTokenCredentials(accessToken, refreshToken string, expiry time.Time) Option { + return func(c *Client) error { + c.base.Token.AccessToken = accessToken + c.base.Token.RefreshToken = refreshToken + c.base.Token.Expiry = expiry + return nil + } +} + +// WithRateLimiter rate limits the client's api calls. +func WithRateLimiter(r *rate.Limiter) Option { + return func(c *Client) error { + return httpclient.ApplyRateLimiter(c.base, r) + } +} + +// WithHTTPTracing enables tracing http calls. +func WithHTTPTracing(debug bool) Option { + return func(c *Client) error { + return httpclient.ApplyHTTPTracing(c.base, debug) + } +} + +// WithTransport sets the underlying http client transport. +func WithTransport(t http.RoundTripper) Option { + return func(c *Client) error { + return httpclient.ApplyTransport(c.base, t) + } +} + +// WithHTTPClient sets the underlying http client. +func WithHTTPClient(client *http.Client) Option { + return func(c *Client) error { + return httpclient.ApplyHTTPClient(c.base, client) + } +} diff --git a/hammerhead/model.go b/hammerhead/model.go new file mode 100644 index 0000000..f331e4b --- /dev/null +++ b/hammerhead/model.go @@ -0,0 +1,66 @@ +package hammerhead + +import ( + "time" + + "github.com/martinlindhe/unit" +) + +// Fault is an error returned by the Hammerhead API +type Fault struct { //nolint:errname // convention + Message string `json:"message"` + StatusCode int `json:"statusCode"` +} + +func (f *Fault) Error() string { + return f.Message +} + +// SetDefaults populates StatusCode and Message from the HTTP response when the body does not supply them. +func (f *Fault) SetDefaults(code int, message string) { + if f.StatusCode == 0 { + f.StatusCode = code + } + if f.Message == "" { + f.Message = message + } +} + +// ActivityType is the type of activity +type ActivityType string + +const ( + ActivityTypeRide ActivityType = "RIDE" + ActivityTypeEBike ActivityType = "EBIKE" + ActivityTypeMountainBike ActivityType = "MOUNTAIN_BIKE" + ActivityTypeGravel ActivityType = "GRAVEL" + ActivityTypeEMountain ActivityType = "EMOUNTAIN_BIKE" + ActivityTypeVelomobile ActivityType = "VELOMOBILE" +) + +// ActivitySummary is a summary of an activity returned in list responses +type ActivitySummary struct { + ID string `json:"id"` + Name string `json:"name"` + CreatedAt time.Time `json:"createdAt"` + Duration int `json:"duration"` + Distance unit.Length `json:"distance" units:"m"` +} + +// Activity is a full activity with additional fields +type Activity struct { + ActivitySummary + ActivityType ActivityType `json:"activityType"` + Description string `json:"description"` + Polyline string `json:"polyline"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// ActivitiesPage is the paginated response for listing activities +type ActivitiesPage struct { + TotalItems int `json:"totalItems"` + TotalPages int `json:"totalPages"` + PerPage int `json:"perPage"` + CurrentPage int `json:"currentPage"` + Data []*ActivitySummary `json:"data"` +} diff --git a/hammerhead/testdata/hammerhead_activities.json b/hammerhead/testdata/hammerhead_activities.json new file mode 100644 index 0000000..934792e --- /dev/null +++ b/hammerhead/testdata/hammerhead_activities.json @@ -0,0 +1,22 @@ +{ + "totalItems": 2, + "totalPages": 1, + "perPage": 100, + "currentPage": 1, + "data": [ + { + "id": "activity-001", + "name": "Morning Ride", + "createdAt": "2024-03-15T08:30:00Z", + "duration": 3600, + "distance": 50000 + }, + { + "id": "activity-002", + "name": "Evening Gravel", + "createdAt": "2024-03-14T17:00:00Z", + "duration": 5400, + "distance": 75000 + } + ] +} diff --git a/hammerhead/testdata/hammerhead_activity.json b/hammerhead/testdata/hammerhead_activity.json new file mode 100644 index 0000000..72fcdc7 --- /dev/null +++ b/hammerhead/testdata/hammerhead_activity.json @@ -0,0 +1,11 @@ +{ + "id": "activity-001", + "name": "Morning Ride", + "createdAt": "2024-03-15T08:30:00Z", + "duration": 3600, + "distance": 50000, + "activityType": "RIDE", + "description": "A lovely morning ride", + "polyline": "u{~vHhzrk@", + "updatedAt": "2024-03-15T09:30:00Z" +} diff --git a/hammerhead/testdata/refresh.json b/hammerhead/testdata/refresh.json new file mode 100644 index 0000000..285246a --- /dev/null +++ b/hammerhead/testdata/refresh.json @@ -0,0 +1,7 @@ +{ + "token_type": "Bearer", + "access_token": "andthisbetheaccesstoken", + "expires_at": 1604468531, + "expires_in": 20529, + "refresh_token": "andthisbetherefreshtoken" +} diff --git a/strava/encoding.go b/strava/encoding.go index 1f45df1..a7ec853 100644 --- a/strava/encoding.go +++ b/strava/encoding.go @@ -15,6 +15,8 @@ import ( var _ activity.GPXEncoder = (*Route)(nil) var _ activity.GPXEncoder = (*Activity)(nil) +const gpxVersion = "1.1" + func polylineToLineString(polylines ...string) (*geom.LineString, error) { const n = 2 var coords []float64 @@ -58,7 +60,7 @@ func (a *Activity) GPX() (*gpx.GPX, error) { }, } x := &gpx.GPX{ - Version: "1.1", + Version: gpxVersion, Trk: []*gpx.TrkType{trk}, } return x, nil @@ -81,7 +83,7 @@ func (r *Route) GPX() (*gpx.GPX, error) { }, } x := &gpx.GPX{ - Version: "1.1", + Version: gpxVersion, Rte: []*gpx.RteType{rte}, } return x, nil @@ -106,7 +108,7 @@ func (a *Activity) toGPXFromStreams() (*gpx.GPX, error) { } } x := &gpx.GPX{ - Version: "1.1", + Version: gpxVersion, Trk: []*gpx.TrkType{ { Name: a.Name,