diff --git a/.golangci.yml b/.golangci.yml index 0753303..3c9cbd4 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -23,7 +23,7 @@ linters: - gocritic - gocyclo - gomoddirectives - - gomodguard + - gomodguard_v2 - goprintffuncname - gosec - govet @@ -101,21 +101,20 @@ linters: paramsOnly: false underef: skipRecvDeref: false - gomodguard: + gomodguard_v2: blocked: - modules: - - github.com/golang/protobuf: - recommendations: - - google.golang.org/protobuf - reason: see https://developers.google.com/protocol-buffers/docs/reference/go/faq#modules - - github.com/satori/go.uuid: - recommendations: - - github.com/google/uuid - reason: satori's package is not maintained - - github.com/gofrs/uuid: - recommendations: - - github.com/google/uuid - reason: gofrs' package is not go module + - module: github.com/golang/protobuf + recommendations: + - google.golang.org/protobuf + reason: see https://developers.google.com/protocol-buffers/docs/reference/go/faq#modules + - module: github.com/satori/go.uuid + recommendations: + - github.com/google/uuid + reason: satori's package is not maintained + - module: github.com/gofrs/uuid + recommendations: + - github.com/google/uuid + reason: gofrs' package is not go module govet: disable: - fieldalignment diff --git a/go.mod b/go.mod index 2e16bea..21c9556 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/bzimmer/activity -go 1.26.2 +go 1.26.3 require ( github.com/bzimmer/httpwares v0.1.3 diff --git a/strava/activity.go b/strava/activity.go index 9231e3e..a3bdd5f 100644 --- a/strava/activity.go +++ b/strava/activity.go @@ -12,7 +12,6 @@ import ( "net/url" "regexp" "strings" - "time" "golang.org/x/sync/errgroup" @@ -28,24 +27,6 @@ type ActivityIterFunc func(*Activity) (bool, error) // fileNameRE allowable characters var fileNameRE = regexp.MustCompile("[A-Za-z0-9-]+") -// WithDateRange sets the before and after date range -func WithDateRange(before, after time.Time) APIOption { - return func(v url.Values) error { - if !before.IsZero() && !after.IsZero() { - if after.After(before) { - return errors.New("invalid date range") - } - } - if !before.IsZero() { - v.Set("before", fmt.Sprintf("%d", before.Unix())) - } - if !after.IsZero() { - v.Set("after", fmt.Sprintf("%d", after.Unix())) - } - return nil - } -} - type channelPaginator struct { count int options []APIOption 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, diff --git a/strava/segment.go b/strava/segment.go new file mode 100644 index 0000000..2b09f31 --- /dev/null +++ b/strava/segment.go @@ -0,0 +1,101 @@ +package strava + +import ( + "context" + "fmt" + "net/http" + "net/url" + + "github.com/bzimmer/activity" +) + +// SegmentService is the API for segment effort endpoints. +type SegmentService service + +// SegmentEffortIterFunc is called for each segment effort in the results. +type SegmentEffortIterFunc func(*SegmentEffort) (bool, error) + +type segmentPaginator struct { + segmentEfforts []*SegmentEffort + service SegmentService + options []APIOption +} + +func (p *segmentPaginator) PageSize() int { + return PageSize +} + +func (p *segmentPaginator) Count() int { + return len(p.segmentEfforts) +} + +func (p *segmentPaginator) Do(ctx context.Context, spec activity.Pagination) (int, error) { + v := make(url.Values) + v.Set("page", fmt.Sprintf("%d", spec.Start)) + v.Set("per_page", fmt.Sprintf("%d", spec.Count)) + for _, opt := range p.options { + if opt == nil { + continue + } + if err := opt(v); err != nil { + return 0, err + } + } + uri := fmt.Sprintf("segment_efforts?%s", v.Encode()) + req, err := p.service.client.newAPIRequest(ctx, http.MethodGet, uri, nil) + if err != nil { + return 0, err + } + var segs []*SegmentEffort + err = p.service.client.do(req, &segs) + if err != nil { + return 0, err + } + if spec.Total > 0 && len(p.segmentEfforts)+len(segs) > spec.Total { + segs = segs[:spec.Total-len(p.segmentEfforts)] + } + p.segmentEfforts = append(p.segmentEfforts, segs...) + return len(segs), nil +} + +// SegmentEfforts returns a page of segment efforts for the authenticated athlete. +// +// The returned segment efforts can be filtered by date using WithDateRange(before, after). +func (s *SegmentService) SegmentEfforts( + ctx context.Context, spec activity.Pagination, opts ...APIOption) ([]*SegmentEffort, error) { + p := &segmentPaginator{service: *s, segmentEfforts: make([]*SegmentEffort, 0), options: opts} + err := activity.Paginate(ctx, p, spec) + if err != nil { + return nil, err + } + return p.segmentEfforts, nil +} + +// SegmentEffort returns a segment effort. +func (s *SegmentService) SegmentEffort(ctx context.Context, segmentEffortID int64) (*SegmentEffort, error) { + uri := fmt.Sprintf("segment_efforts/%d", segmentEffortID) + req, err := s.client.newAPIRequest(ctx, http.MethodGet, uri, nil) + if err != nil { + return nil, err + } + seg := &SegmentEffort{} + err = s.client.do(req, &seg) + if err != nil { + return nil, err + } + return seg, nil +} + +// SegmentEffortsIter executes the iter function over segment effort results. +func SegmentEffortsIter(segmentEfforts []*SegmentEffort, iter SegmentEffortIterFunc) error { + for _, segmentEffort := range segmentEfforts { + ok, err := iter(segmentEffort) + if err != nil { + return err + } + if !ok { + return nil + } + } + return nil +} diff --git a/strava/segment_test.go b/strava/segment_test.go new file mode 100644 index 0000000..d4f4a35 --- /dev/null +++ b/strava/segment_test.go @@ -0,0 +1,235 @@ +package strava_test + +import ( + "context" + "errors" + "net/http" + "net/url" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/bzimmer/activity" + "github.com/bzimmer/activity/strava" +) + +func TestSegmentEffort(t *testing.T) { + t.Parallel() + a := assert.New(t) + + tests := []struct { + name string + before func(mux *http.ServeMux) + opts []strava.Option + after func(segmentEffort *strava.SegmentEffort, err error) + }{ + { + name: "valid segment effort", + before: func(mux *http.ServeMux) { + mux.HandleFunc("/segment_efforts/229781", func(w http.ResponseWriter, r *http.Request) { + http.ServeFile(w, r, "testdata/segment_effort.json") + }) + }, + after: func(segmentEffort *strava.SegmentEffort, err error) { + a.NoError(err) + a.NotNil(segmentEffort) + a.Equal(int64(229781), segmentEffort.ID) + a.Equal("Hawk Hill Effort", segmentEffort.Name) + a.NotNil(segmentEffort.Segment) + a.Equal(229781, segmentEffort.Segment.ID) + }, + }, + { + name: "invalid segment effort", + before: func(_ *http.ServeMux) {}, + after: func(_ *strava.SegmentEffort, err error) { + a.Error(err) + }, + }, + { + name: "invalid base url", + before: func(_ *http.ServeMux) {}, + opts: []strava.Option{strava.WithBaseURL("://bad-url")}, + after: func(_ *strava.SegmentEffort, err error) { + a.Error(err) + }, + }, + } + for i := range tests { + tt := tests[i] + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + client, svr := newClientMust(tt.before, tt.opts...) + defer svr.Close() + tt.after(client.Segment.SegmentEffort(context.TODO(), 229781)) + }) + } +} + +func TestSegmentEfforts(t *testing.T) { + t.Parallel() + a := assert.New(t) + + tests := []struct { + name string + pagination activity.Pagination + opts []strava.Option + opt strava.APIOption + after func(segmentEfforts []*strava.SegmentEffort, err error) + }{ + { + name: "test total, start, and count", + pagination: activity.Pagination{Total: 127, Start: 0, Count: 1}, + after: func(segmentEfforts []*strava.SegmentEffort, err error) { + a.NoError(err) + a.NotNil(segmentEfforts) + a.Equal(127, len(segmentEfforts)) + }, + }, + { + name: "test total and start", + pagination: activity.Pagination{Total: 234, Start: 0}, + after: func(segmentEfforts []*strava.SegmentEffort, err error) { + a.NoError(err) + a.NotNil(segmentEfforts) + a.Equal(234, len(segmentEfforts)) + }, + }, + { + name: "test total and start less than PageSize", + pagination: activity.Pagination{Total: 27, Start: 0}, + after: func(segmentEfforts []*strava.SegmentEffort, err error) { + a.NoError(err) + a.NotNil(segmentEfforts) + a.Equal(27, len(segmentEfforts)) + }, + }, + { + name: "negative test", + pagination: activity.Pagination{Total: -1}, + after: func(segmentEfforts []*strava.SegmentEffort, err error) { + a.Error(err) + a.Nil(segmentEfforts) + }, + }, + { + name: "invalid base url", + pagination: activity.Pagination{Total: 1}, + opts: []strava.Option{strava.WithBaseURL("://bad-url")}, + after: func(segmentEfforts []*strava.SegmentEffort, err error) { + a.Error(err) + a.Nil(segmentEfforts) + }, + }, + { + name: "invalid response", + pagination: activity.Pagination{Total: 1}, + after: func(segmentEfforts []*strava.SegmentEffort, err error) { + a.Error(err) + a.Nil(segmentEfforts) + }, + }, + { + name: "zero dates", + opt: strava.WithDateRange(time.Time{}, time.Time{}), + pagination: activity.Pagination{Total: 2}, + after: func(segmentEfforts []*strava.SegmentEffort, err error) { + a.NoError(err) + a.NotNil(segmentEfforts) + a.Equal(2, len(segmentEfforts)) + }, + }, + { + name: "before and after", + opt: func() strava.APIOption { + before := time.Now() + after := before.Add(time.Hour * time.Duration(-24*7)) + return strava.WithDateRange(before, after) + }(), + pagination: activity.Pagination{Total: 2}, + after: func(segmentEfforts []*strava.SegmentEffort, err error) { + a.NoError(err) + a.NotNil(segmentEfforts) + a.Equal(2, len(segmentEfforts)) + }, + }, + { + name: "error in option", + opt: func(url.Values) error { + return errors.New("error in option") + }, + pagination: activity.Pagination{Total: 2}, + after: func(segmentEfforts []*strava.SegmentEffort, err error) { + a.Error(err) + a.Nil(segmentEfforts) + a.Contains(err.Error(), "error in option") + }, + }, + } + for i := range tests { + tt := tests[i] + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + client, svr := newClientMust(func(mux *http.ServeMux) { + if tt.name == "invalid response" { + mux.HandleFunc("/segment_efforts", func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("{")) + }) + return + } + mux.Handle("/segment_efforts", &ManyHandler{ + Filename: "testdata/segment_effort.json", + }) + }, tt.opts...) + defer svr.Close() + tt.after(client.Segment.SegmentEfforts(context.TODO(), tt.pagination, tt.opt)) + }) + } +} + +func TestSegmentEffortsIter(t *testing.T) { + t.Parallel() + + t.Run("all", func(t *testing.T) { + t.Parallel() + a := assert.New(t) + segmentEfforts := []*strava.SegmentEffort{{ID: 1}, {ID: 2}} + var ids []int64 + + err := strava.SegmentEffortsIter(segmentEfforts, func(segmentEffort *strava.SegmentEffort) (bool, error) { + ids = append(ids, segmentEffort.ID) + return true, nil + }) + + a.NoError(err) + a.Equal([]int64{1, 2}, ids) + }) + + t.Run("stop early", func(t *testing.T) { + t.Parallel() + a := assert.New(t) + segmentEfforts := []*strava.SegmentEffort{{ID: 1}, {ID: 2}} + count := 0 + + err := strava.SegmentEffortsIter(segmentEfforts, func(_ *strava.SegmentEffort) (bool, error) { + count++ + return false, nil + }) + + a.NoError(err) + a.Equal(1, count) + }) + + t.Run("iter error", func(t *testing.T) { + t.Parallel() + a := assert.New(t) + want := errors.New("iter error") + + err := strava.SegmentEffortsIter([]*strava.SegmentEffort{{ID: 1}}, func(_ *strava.SegmentEffort) (bool, error) { + return true, want + }) + + a.ErrorIs(err, want) + }) +} diff --git a/strava/strava.go b/strava/strava.go index 92f270a..327cf79 100644 --- a/strava/strava.go +++ b/strava/strava.go @@ -8,6 +8,7 @@ import ( "io" "net/http" "net/url" + "time" "golang.org/x/oauth2" @@ -24,6 +25,24 @@ const ( // APIOption for configuring API requests type APIOption func(url.Values) error +// WithDateRange sets the before and after date range. +func WithDateRange(before, after time.Time) APIOption { + return func(v url.Values) error { + if !before.IsZero() && !after.IsZero() { + if after.After(before) { + return errors.New("invalid date range") + } + } + if !before.IsZero() { + v.Set("before", fmt.Sprintf("%d", before.Unix())) + } + if !after.IsZero() { + v.Set("after", fmt.Sprintf("%d", after.Unix())) + } + return nil + } +} + // Endpoint is Strava's OAuth 2.0 endpoint func Endpoint() oauth2.Endpoint { return oauth2.Endpoint{ //nolint:gosec // not a secret @@ -40,6 +59,7 @@ type Client struct { Auth *AuthService Route *RouteService + Segment *SegmentService Webhook *WebhookService Athlete *AthleteService Activity *ActivityService @@ -67,6 +87,7 @@ func withServices() Option { return func(c *Client) error { c.Auth = &AuthService{client: c} c.Route = &RouteService{client: c} + c.Segment = &SegmentService{client: c} c.Webhook = &WebhookService{client: c} c.Athlete = &AthleteService{client: c} c.Activity = &ActivityService{client: c} diff --git a/strava/testdata/segment_effort.json b/strava/testdata/segment_effort.json new file mode 100644 index 0000000..e95c01d --- /dev/null +++ b/strava/testdata/segment_effort.json @@ -0,0 +1,70 @@ +{ + "id": 229781, + "resource_state": 2, + "name": "Hawk Hill Effort", + "activity": { + "id": 123456789, + "resource_state": 1 + }, + "athlete": { + "id": 1122, + "resource_state": 1 + }, + "elapsed_time": 360, + "moving_time": 355, + "start_date": "2018-06-01T14:00:00Z", + "start_date_local": "2018-06-01T07:00:00Z", + "distance": 1674.0, + "start_index": 42, + "end_index": 314, + "average_cadence": 87.5, + "device_watts": false, + "average_watts": 278.4, + "segment": { + "id": 229781, + "resource_state": 3, + "name": "Hawk Hill", + "activity_type": "Ride", + "distance": 1674.0, + "average_grade": 5.7, + "maximum_grade": 8.6, + "elevation_high": 139.5, + "elevation_low": 44.0, + "start_latlng": [ + 37.833111, + -122.483435 + ], + "end_latlng": [ + 37.840369, + -122.484489 + ], + "climb_category": 1, + "city": "Sausalito", + "state": "California", + "country": "United States", + "private": false, + "hazardous": false, + "starred": true, + "created_at": "2012-01-01T00:00:00Z", + "updated_at": "2012-01-01T00:00:00Z", + "total_elevation_gain": 95.5, + "map": { + "id": "s229781", + "summary_polyline": "a~l~Fjk~uOwHJy@P", + "resource_state": 3 + }, + "effort_count": 123456, + "athlete_count": 78910, + "star_count": 4567 + }, + "kom_rank": 0, + "pr_rank": 1, + "achievements": [ + { + "rank": 1, + "type": "pr", + "type_id": 3 + } + ], + "hidden": false +}