From 69f317d8cb51b911732712a9fcc8ac49563326c6 Mon Sep 17 00:00:00 2001 From: Martin Kagamino Lehoux Date: Mon, 3 Aug 2026 21:53:04 +0200 Subject: feat: Sync Strava rides as GPX files --- strava/strava.go | 146 ++++++++++++++++++++++++++++++++++++++++++++++++++ strava/strava_test.go | 108 +++++++++++++++++++++++++++++++++++++ strava/sync.go | 110 +++++++++++++++++++++++++++++++++++++ strava/sync_test.go | 100 ++++++++++++++++++++++++++++++++++ 4 files changed, 464 insertions(+) create mode 100644 strava/strava.go create mode 100644 strava/strava_test.go create mode 100644 strava/sync.go create mode 100644 strava/sync_test.go (limited to 'strava') diff --git a/strava/strava.go b/strava/strava.go new file mode 100644 index 0000000..d394791 --- /dev/null +++ b/strava/strava.go @@ -0,0 +1,146 @@ +package strava + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "time" + + "github.com/martinlehoux/kagamigo/kcore" +) + +const ( + AuthURL = "https://www.strava.com/oauth/authorize" + TokenURL = "https://www.strava.com/oauth/token" + APIURL = "https://www.strava.com/api/v3" + LoginPort = 8787 +) + +var ( + tokenEndpoint = TokenURL + apiEndpoint = APIURL +) + +type Token struct { + AccessToken string + RefreshToken string + ExpiresAt time.Time +} + +func AuthorizeURL(clientID, redirectURI string) string { + q := url.Values{} + q.Set("client_id", clientID) + q.Set("response_type", "code") + q.Set("redirect_uri", redirectURI) + q.Set("approval_prompt", "auto") + q.Set("scope", "activity:read_all") + return AuthURL + "?" + q.Encode() +} + +type tokenResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresAt int64 `json:"expires_at"` +} + +func ExchangeCode(clientID, clientSecret, code, redirectURI string) (Token, error) { + form := url.Values{} + form.Set("client_id", clientID) + form.Set("client_secret", clientSecret) + form.Set("code", code) + form.Set("grant_type", "authorization_code") + form.Set("redirect_uri", redirectURI) + return requestToken(form) +} + +func Refresh(clientID, clientSecret, refreshToken string) (Token, error) { + form := url.Values{} + form.Set("client_id", clientID) + form.Set("client_secret", clientSecret) + form.Set("grant_type", "refresh_token") + form.Set("refresh_token", refreshToken) + return requestToken(form) +} + +func requestToken(form url.Values) (Token, error) { + resp, err := http.PostForm(tokenEndpoint, form) + if err != nil { + return Token{}, kcore.Wrap(err, "failed to request Strava token") + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return Token{}, kcore.Wrap(err, "failed to read Strava token response") + } + if resp.StatusCode != http.StatusOK { + return Token{}, fmt.Errorf("strava token request failed (HTTP %d): %s", resp.StatusCode, body) + } + var parsed tokenResponse + if err := json.Unmarshal(body, &parsed); err != nil { + return Token{}, kcore.Wrap(err, "failed to decode Strava token response") + } + if parsed.AccessToken == "" { + return Token{}, fmt.Errorf("strava token response missing access_token: %s", body) + } + return Token{ + AccessToken: parsed.AccessToken, + RefreshToken: parsed.RefreshToken, + ExpiresAt: time.Unix(parsed.ExpiresAt, 0), + }, nil +} + +type Client struct { + ClientID string + ClientSecret string + HTTP *http.Client + tokens Token +} + +func NewClient(clientID, clientSecret string, tokens Token) *Client { + return &Client{ClientID: clientID, ClientSecret: clientSecret, HTTP: http.DefaultClient, tokens: tokens} +} + +func (c *Client) Tokens() Token { + return c.tokens +} + +func (c *Client) RefreshIfNeeded() (bool, error) { + if time.Until(c.tokens.ExpiresAt) > 5*time.Minute { + return false, nil + } + refreshed, err := Refresh(c.ClientID, c.ClientSecret, c.tokens.RefreshToken) + if err != nil { + return false, kcore.Wrap(err, "failed to refresh Strava access token") + } + c.tokens = refreshed + return true, nil +} + +func (c *Client) GetJSON(path string, out any) error { + if _, err := c.RefreshIfNeeded(); err != nil { + return err + } + req, err := http.NewRequest(http.MethodGet, apiEndpoint+path, nil) + if err != nil { + return kcore.Wrap(err, "failed to build Strava request") + } + req.Header.Set("Authorization", "Bearer "+c.tokens.AccessToken) + resp, err := c.HTTP.Do(req) + if err != nil { + return kcore.Wrap(err, "failed to call Strava API") + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return kcore.Wrap(err, "failed to read Strava API response") + } + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("strava API %s failed (HTTP %d): %s", path, resp.StatusCode, body) + } + if err := json.Unmarshal(body, out); err != nil { + return kcore.Wrap(err, "failed to decode Strava API response") + } + return nil +} diff --git a/strava/strava_test.go b/strava/strava_test.go new file mode 100644 index 0000000..7120434 --- /dev/null +++ b/strava/strava_test.go @@ -0,0 +1,108 @@ +package strava + +import ( + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAuthorizeURL(t *testing.T) { + u := AuthorizeURL("12345", "http://localhost:8787/callback") + parsed, err := url.Parse(u) + require.NoError(t, err) + assert.Equal(t, "https", parsed.Scheme) + assert.Equal(t, "www.strava.com", parsed.Host) + assert.Equal(t, "/oauth/authorize", parsed.Path) + q := parsed.Query() + assert.Equal(t, "12345", q.Get("client_id")) + assert.Equal(t, "http://localhost:8787/callback", q.Get("redirect_uri")) + assert.Equal(t, "code", q.Get("response_type")) + assert.Equal(t, "activity:read_all", q.Get("scope")) +} + +func TestExchangeCode(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + err := r.ParseForm() + require.NoError(t, err) + assert.Equal(t, "authorization_code", r.Form.Get("grant_type")) + assert.Equal(t, "secret", r.Form.Get("client_secret")) + assert.Equal(t, "the-code", r.Form.Get("code")) + w.Header().Set("Content-Type", "application/json") + _, err = w.Write([]byte(`{"access_token":"access-1","refresh_token":"refresh-1","expires_at":2000000000}`)) + require.NoError(t, err) + })) + defer server.Close() + tokenEndpoint = server.URL + + token, err := ExchangeCode("12345", "secret", "the-code", "http://localhost:8787/callback") + require.NoError(t, err) + assert.Equal(t, "access-1", token.AccessToken) + assert.Equal(t, "refresh-1", token.RefreshToken) + assert.Equal(t, time.Unix(2000000000, 0), token.ExpiresAt) +} + +func TestRequestTokenError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, `{"message":"bad request"}`, http.StatusBadRequest) + })) + defer server.Close() + tokenEndpoint = server.URL + + _, err := ExchangeCode("12345", "secret", "the-code", "http://localhost:8787/callback") + require.Error(t, err) + assert.Contains(t, err.Error(), "400") +} + +func TestRefreshIfNeeded(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + err := r.ParseForm() + require.NoError(t, err) + assert.Equal(t, "refresh_token", r.Form.Get("grant_type")) + assert.Equal(t, "old-refresh", r.Form.Get("refresh_token")) + w.Header().Set("Content-Type", "application/json") + _, err = w.Write([]byte(`{"access_token":"new-access","refresh_token":"new-refresh","expires_at":2000000000}`)) + require.NoError(t, err) + })) + defer server.Close() + tokenEndpoint = server.URL + + client := NewClient("12345", "secret", Token{ + AccessToken: "expired-access", + RefreshToken: "old-refresh", + ExpiresAt: time.Now().Add(-time.Hour), + }) + refreshed, err := client.RefreshIfNeeded() + require.NoError(t, err) + assert.True(t, refreshed) + assert.Equal(t, "new-access", client.Tokens().AccessToken) + assert.Equal(t, "new-refresh", client.Tokens().RefreshToken) +} + +func TestGetJSON(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "Bearer fresh-access", r.Header.Get("Authorization")) + assert.Equal(t, "/athlete", r.URL.Path) + w.Header().Set("Content-Type", "application/json") + _, err := w.Write([]byte(`{"firstname":"Martin","lastname":"Lehoux"}`)) + require.NoError(t, err) + })) + defer server.Close() + apiEndpoint = server.URL + + client := NewClient("12345", "secret", Token{ + AccessToken: "fresh-access", + RefreshToken: "refresh", + ExpiresAt: time.Now().Add(time.Hour), + }) + var athlete struct { + Firstname string `json:"firstname"` + } + err := client.GetJSON("/athlete", &athlete) + require.NoError(t, err) + assert.Equal(t, "Martin", athlete.Firstname) +} diff --git a/strava/sync.go b/strava/sync.go new file mode 100644 index 0000000..f104585 --- /dev/null +++ b/strava/sync.go @@ -0,0 +1,110 @@ +package strava + +import ( + "fmt" + "net/url" + "os" + "strconv" + "time" + + "github.com/martinlehoux/kagamigo/kcore" + "github.com/tkrajina/gpxgo/gpx" +) + +type Activity struct { + ID int64 `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + SportType string `json:"sport_type"` + StartDate time.Time `json:"start_date"` +} + +func (c *Client) ListActivities(after time.Time, types ...string) ([]Activity, error) { + allowed := map[string]bool{} + for _, t := range types { + allowed[t] = true + } + var all []Activity + for page := 1; ; page++ { + q := url.Values{} + q.Set("per_page", "200") + q.Set("page", strconv.Itoa(page)) + q.Set("after", strconv.FormatInt(after.Unix(), 10)) + var batch []Activity + err := c.GetJSON("/athlete/activities?"+q.Encode(), &batch) + if err != nil { + return nil, kcore.Wrap(err, "failed to list Strava activities") + } + if len(batch) == 0 { + break + } + for _, activity := range batch { + if len(allowed) == 0 || allowed[activity.Type] || allowed[activity.SportType] { + all = append(all, activity) + } + } + if len(batch) < 200 { + break + } + } + return all, nil +} + +func (c *Client) ActivityStreams(id int64) (latlng [][2]float64, altitude []float64, seconds []float64, err error) { + path := fmt.Sprintf("/activities/%d/streams?keys=latlng,altitude,time&key_by_type=true", id) + var streams struct { + LatLng *struct { + Data [][2]float64 `json:"data"` + } `json:"latlng"` + Altitude *struct { + Data []float64 `json:"data"` + } `json:"altitude"` + Time *struct { + Data []float64 `json:"data"` + } `json:"time"` + } + err = c.GetJSON(path, &streams) + if err != nil { + return nil, nil, nil, kcore.Wrap(err, "failed to fetch Strava streams") + } + if streams.LatLng != nil { + latlng = streams.LatLng.Data + } + if streams.Altitude != nil { + altitude = streams.Altitude.Data + } + if streams.Time != nil { + seconds = streams.Time.Data + } + return latlng, altitude, seconds, nil +} + +func WriteActivityGPX(file string, activity Activity, latlng [][2]float64, altitude, seconds []float64) error { + points := make([]gpx.GPXPoint, len(latlng)) + for i := range latlng { + point := gpx.GPXPoint{} + point.Latitude = latlng[i][0] + point.Longitude = latlng[i][1] + if i < len(altitude) { + point.Elevation = *gpx.NewNullableFloat64(altitude[i]) + } + if i < len(seconds) { + point.Timestamp = activity.StartDate.Add(time.Duration(seconds[i]) * time.Second) + } + points[i] = point + } + doc := gpx.GPX{ + Version: "1.1", + Creator: "biking_home", + Name: activity.Name, + Tracks: []gpx.GPXTrack{{ + Name: activity.Name, + Segments: []gpx.GPXTrackSegment{{Points: points}}, + }}, + } + xmlData, err := doc.ToXml(gpx.ToXmlParams{}) + if err != nil { + return kcore.Wrap(err, "failed to build GPX XML") + } + return os.WriteFile(file, xmlData, 0o644) +} diff --git a/strava/sync_test.go b/strava/sync_test.go new file mode 100644 index 0000000..b74d1ab --- /dev/null +++ b/strava/sync_test.go @@ -0,0 +1,100 @@ +package strava + +import ( + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tkrajina/gpxgo/gpx" +) + +func newTestClient(t *testing.T, handler http.HandlerFunc) *Client { + t.Helper() + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + client := NewClient("12345", "secret", Token{ + AccessToken: "fresh-access", + RefreshToken: "refresh", + ExpiresAt: time.Now().Add(time.Hour), + }) + apiEndpoint = server.URL + return client +} + +func TestListActivities(t *testing.T) { + calls := 0 + client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + calls++ + require.Equal(t, "/athlete/activities", r.URL.Path) + q := r.URL.Query() + assert.Equal(t, "200", q.Get("per_page")) + assert.Equal(t, fmt.Sprint(calls), q.Get("page")) + assert.Equal(t, "1600000000", q.Get("after")) + w.Header().Set("Content-Type", "application/json") + body := `[{"id":1,"name":"Ride A","type":"Ride","sport_type":"Ride","start_date":"2026-01-01T10:00:00Z"},{"id":2,"name":"Run","type":"Run","sport_type":"Run","start_date":"2026-01-01T11:00:00Z"}]` + if calls == 2 { + body = `[]` + } + _, err := w.Write([]byte(body)) + require.NoError(t, err) + }) + + activities, err := client.ListActivities(time.Unix(1600000000, 0), "Ride") + require.NoError(t, err) + require.Len(t, activities, 1) + assert.Equal(t, int64(1), activities[0].ID) + assert.Equal(t, "Ride A", activities[0].Name) + assert.Equal(t, time.Date(2026, 1, 1, 10, 0, 0, 0, time.UTC), activities[0].StartDate) +} + +func TestActivityStreams(t *testing.T) { + client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/activities/42/streams", r.URL.Path) + assert.Equal(t, "latlng,altitude,time", r.URL.Query().Get("keys")) + w.Header().Set("Content-Type", "application/json") + _, err := w.Write([]byte(`{ + "latlng":{"type":"latlng","data":[[43.0,5.0],[43.1,5.1],[43.2,5.2]]}, + "altitude":{"type":"altitude","data":[100,120,140]}, + "time":{"type":"time","data":[0,10,25]} + }`)) + require.NoError(t, err) + }) + + latlng, altitude, seconds, err := client.ActivityStreams(42) + require.NoError(t, err) + require.Len(t, latlng, 3) + assert.Equal(t, [2]float64{43.0, 5.0}, latlng[0]) + assert.Equal(t, []float64{100, 120, 140}, altitude) + assert.Equal(t, []float64{0, 10, 25}, seconds) +} + +func TestWriteActivityGPX(t *testing.T) { + activity := Activity{ID: 42, Name: "Ride A", StartDate: time.Date(2026, 1, 1, 10, 0, 0, 0, time.UTC)} + latlng := [][2]float64{{43.0, 5.0}, {43.1, 5.1}} + altitude := []float64{100, 120} + seconds := []float64{0, 10} + + file := filepath.Join(t.TempDir(), "activity_42.gpx") + err := WriteActivityGPX(file, activity, latlng, altitude, seconds) + require.NoError(t, err) + + data, err := os.ReadFile(file) + require.NoError(t, err) + parsed, err := gpx.ParseBytes(data) + require.NoError(t, err) + require.Len(t, parsed.Tracks, 1) + require.Len(t, parsed.Tracks[0].Segments, 1) + points := parsed.Tracks[0].Segments[0].Points + require.Len(t, points, 2) + assert.Equal(t, 43.0, points[0].Latitude) + assert.Equal(t, 5.1, points[1].Longitude) + assert.Equal(t, 100.0, points[0].Elevation.Value()) + assert.Equal(t, activity.StartDate.Add(10*time.Second), points[1].Timestamp) + assert.Equal(t, "Ride A", parsed.Name) +} -- cgit v1.2.3