From b4afa7e9ff7d6642307a496d963d4d438cc789f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 16:41:55 +0000 Subject: [PATCH 01/10] Add Hammerhead provider Implements a new provider for the Hammerhead Karoo cycling computer API (https://api.hammerhead.io/v1/docs). The provider supports OAuth 2.0 authorization code flow with rotating refresh tokens and exposes activity listing, single activity lookup, and FIT file download via the Exporter interface. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01DJeFw5WPdEe5Fvtr27Q67f --- hammerhead/activities.go | 118 ++++++++ hammerhead/activities_test.go | 280 ++++++++++++++++++ hammerhead/hammerhead.go | 92 ++++++ hammerhead/hammerhead_test.go | 223 ++++++++++++++ hammerhead/hammerhead_with.go | 107 +++++++ hammerhead/model.go | 66 +++++ .../testdata/hammerhead_activities.json | 22 ++ hammerhead/testdata/hammerhead_activity.json | 11 + 8 files changed, 919 insertions(+) create mode 100644 hammerhead/activities.go create mode 100644 hammerhead/activities_test.go create mode 100644 hammerhead/hammerhead.go create mode 100644 hammerhead/hammerhead_test.go create mode 100644 hammerhead/hammerhead_with.go create mode 100644 hammerhead/model.go create mode 100644 hammerhead/testdata/hammerhead_activities.json create mode 100644 hammerhead/testdata/hammerhead_activity.json diff --git a/hammerhead/activities.go b/hammerhead/activities.go new file mode 100644 index 0000000..d881d55 --- /dev/null +++ b/hammerhead/activities.go @@ -0,0 +1,118 @@ +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..746a7c3 --- /dev/null +++ b/hammerhead/activities_test.go @@ -0,0 +1,280 @@ +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) + }, + }, + } + + 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") +} diff --git a/hammerhead/hammerhead.go b/hammerhead/hammerhead.go new file mode 100644 index 0000000..465f434 --- /dev/null +++ b/hammerhead/hammerhead.go @@ -0,0 +1,92 @@ +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{ //nolint:gosec // not a secret + 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 + + 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.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..caa00f4 --- /dev/null +++ b/hammerhead/hammerhead_test.go @@ -0,0 +1,223 @@ +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) +} diff --git a/hammerhead/hammerhead_with.go b/hammerhead/hammerhead_with.go new file mode 100644 index 0000000..4a94986 --- /dev/null +++ b/hammerhead/hammerhead_with.go @@ -0,0 +1,107 @@ +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..b8b7c36 --- /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" +} From e73a4bd0d30c2ffee86dff0730dbd03de0392ab5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 19:33:54 +0000 Subject: [PATCH 02/10] fix: goimports formatting and remove unused nolint directive Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01DJeFw5WPdEe5Fvtr27Q67f --- hammerhead/activities.go | 1 - hammerhead/hammerhead.go | 2 +- hammerhead/hammerhead_with.go | 1 - 3 files changed, 1 insertion(+), 3 deletions(-) diff --git a/hammerhead/activities.go b/hammerhead/activities.go index d881d55..25cfd53 100644 --- a/hammerhead/activities.go +++ b/hammerhead/activities.go @@ -115,4 +115,3 @@ func (s *ActivitiesService) Export(ctx context.Context, activityID int64) (*acti } return &activity.Export{File: f, ID: activityID}, nil } - diff --git a/hammerhead/hammerhead.go b/hammerhead/hammerhead.go index 465f434..ffa3da6 100644 --- a/hammerhead/hammerhead.go +++ b/hammerhead/hammerhead.go @@ -20,7 +20,7 @@ const ( // Endpoint is Hammerhead's OAuth 2.0 endpoint func Endpoint() oauth2.Endpoint { - return oauth2.Endpoint{ //nolint:gosec // not a secret + return oauth2.Endpoint{ AuthURL: _authURL + "/oauth/authorize", TokenURL: _authURL + "/oauth/token", AuthStyle: oauth2.AuthStyleInParams, diff --git a/hammerhead/hammerhead_with.go b/hammerhead/hammerhead_with.go index 4a94986..822d38f 100644 --- a/hammerhead/hammerhead_with.go +++ b/hammerhead/hammerhead_with.go @@ -104,4 +104,3 @@ func WithHTTPClient(client *http.Client) Option { return httpclient.ApplyHTTPClient(c.base, client) } } - From cdc7e6bd9dcb48022da9d5b266c88f1c60fedd28 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 19:38:02 +0000 Subject: [PATCH 03/10] fix: extract gpx version string as constant in strava encoding Resolves goconst lint error for repeated "1.1" string literal. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01DJeFw5WPdEe5Fvtr27Q67f --- strava/encoding.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) 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, From d504c21c4279437decf6392924569f85078c3d21 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 19:41:01 +0000 Subject: [PATCH 04/10] fix: bump go version to 1.26.5 to resolve stdlib CVEs Fixes GO-2026-4971, GO-2026-4918 (go1.26.3), GO-2026-5039, GO-2026-5037 (go1.26.4), and GO-2026-5856 (go1.26.5) reachable via zwift and strava code. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01DJeFw5WPdEe5Fvtr27Q67f --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 2e16bea..43fc166 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 From 4641b0301a738a9b935e77953768f542cdebca81 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 04:23:57 +0000 Subject: [PATCH 05/10] refactor: remove underscore prefix from package-level constants Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01DJeFw5WPdEe5Fvtr27Q67f --- hammerhead/hammerhead.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/hammerhead/hammerhead.go b/hammerhead/hammerhead.go index ffa3da6..3092b0f 100644 --- a/hammerhead/hammerhead.go +++ b/hammerhead/hammerhead.go @@ -14,15 +14,15 @@ import ( ) const ( - _authURL = "https://api.hammerhead.io/v1/auth" - _apiURL = "https://api.hammerhead.io/v1/api" + 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", + AuthURL: authURL + "/oauth/authorize", + TokenURL: authURL + "/oauth/token", AuthStyle: oauth2.AuthStyleInParams, } } @@ -45,10 +45,10 @@ func withServices() Option { return func(c *Client) error { c.Activities = &ActivitiesService{client: c} if c.authURL == "" { - c.authURL = _authURL + c.authURL = authURL } if c.apiURL == "" { - c.apiURL = _apiURL + c.apiURL = apiURL } return nil } From 42e4ec4314e8d901829cd2ddb6f574fe1a08771b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 04:35:58 +0000 Subject: [PATCH 06/10] test: improve hammerhead coverage to 99.1% Add tests for: pagination truncation when spec.Total is exceeded, missing access token errors on Activity and File, File HTTP transport error, Export error propagation from File, and url.Parse failure via invalid API URL. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01DJeFw5WPdEe5Fvtr27Q67f --- hammerhead/activities_test.go | 59 +++++++++++++++++++++++++++++++++++ hammerhead/hammerhead_test.go | 13 ++++++++ 2 files changed, 72 insertions(+) diff --git a/hammerhead/activities_test.go b/hammerhead/activities_test.go index 746a7c3..43daaec 100644 --- a/hammerhead/activities_test.go +++ b/hammerhead/activities_test.go @@ -251,6 +251,18 @@ func TestExporter(t *testing.T) { 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 { @@ -274,7 +286,54 @@ func TestMissingAccessToken(t *testing.T) { 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/hammerhead_test.go b/hammerhead/hammerhead_test.go index caa00f4..48370c6 100644 --- a/hammerhead/hammerhead_test.go +++ b/hammerhead/hammerhead_test.go @@ -221,3 +221,16 @@ func TestEndpoint(t *testing.T) { 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) +} From 46588100635a1bfc5a42b00ec90cb44f2630eaef Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 04:36:22 +0000 Subject: [PATCH 07/10] chore: add coverage.out to .gitignore Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01DJeFw5WPdEe5Fvtr27Q67f --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 1521c8b..78173b0 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ dist +coverage.out From 7e749563220b3dfe906b3ef10454f23401ca176f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 3 Jul 2026 19:33:20 +0000 Subject: [PATCH 08/10] Bump golang.org/x/net in the go_modules group across 1 directory Bumps the go_modules group with 1 update in the / directory: [golang.org/x/net](https://github.com/golang/net). Updates `golang.org/x/net` from 0.53.0 to 0.55.0 - [Commits](https://github.com/golang/net/compare/v0.53.0...v0.55.0) --- updated-dependencies: - dependency-name: golang.org/x/net dependency-version: 0.55.0 dependency-type: indirect dependency-group: go_modules ... Signed-off-by: dependabot[bot] --- go.mod | 4 ++-- go.sum | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index 43fc166..e165a0f 100644 --- a/go.mod +++ b/go.mod @@ -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= From 9ee1924b2bca9bdac7b8b23d1bc48f9c2b015475 Mon Sep 17 00:00:00 2001 From: Brian Zimmer Date: Mon, 10 Aug 2026 19:12:35 +0200 Subject: [PATCH 09/10] feat: add AuthService.Refresh to hammerhead client Mirrors the Strava client's Auth.Refresh, allowing callers to exchange an existing refresh token for a new access/refresh token pair. --- hammerhead/auth.go | 17 +++++++++++++ hammerhead/auth_test.go | 43 ++++++++++++++++++++++++++++++++ hammerhead/hammerhead.go | 2 ++ hammerhead/testdata/refresh.json | 7 ++++++ 4 files changed, 69 insertions(+) create mode 100644 hammerhead/auth.go create mode 100644 hammerhead/auth_test.go create mode 100644 hammerhead/testdata/refresh.json 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 index 3092b0f..3ed1c86 100644 --- a/hammerhead/hammerhead.go +++ b/hammerhead/hammerhead.go @@ -33,6 +33,7 @@ type Client struct { authURL string apiURL string + Auth *AuthService Activities *ActivitiesService } @@ -43,6 +44,7 @@ func (c *Client) Exporter() activity.Exporter { func withServices() Option { return func(c *Client) error { + c.Auth = &AuthService{client: c} c.Activities = &ActivitiesService{client: c} if c.authURL == "" { c.authURL = authURL 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" +} From b9f5e11188e25b11b3e2bd1a16fb06b59e8fc8c3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 04:09:18 +0000 Subject: [PATCH 10/10] fix(hammerhead): rename ActivityTypeEbike to ActivityTypeEBike MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consistent with ActivityTypeEMountainBike — E (electric) prefix followed by a capitalized noun. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01DJeFw5WPdEe5Fvtr27Q67f --- hammerhead/model.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hammerhead/model.go b/hammerhead/model.go index b8b7c36..f331e4b 100644 --- a/hammerhead/model.go +++ b/hammerhead/model.go @@ -31,7 +31,7 @@ type ActivityType string const ( ActivityTypeRide ActivityType = "RIDE" - ActivityTypeEbike ActivityType = "EBIKE" + ActivityTypeEBike ActivityType = "EBIKE" ActivityTypeMountainBike ActivityType = "MOUNTAIN_BIKE" ActivityTypeGravel ActivityType = "GRAVEL" ActivityTypeEMountain ActivityType = "EMOUNTAIN_BIKE"