summaryrefslogtreecommitdiff
path: root/strava
diff options
context:
space:
mode:
authorMartin Kagamino Lehoux <martin@lehoux.net>2026-08-03 21:53:04 +0200
committerMartin Kagamino Lehoux <martin@lehoux.net>2026-08-03 21:53:04 +0200
commit69f317d8cb51b911732712a9fcc8ac49563326c6 (patch)
treef8afcada62a43326c7d3fda34e4cd4627b7b0d1c /strava
parent9707f9ad09db935af7d06b5de08bc52de13ba6a7 (diff)
feat: Sync Strava rides as GPX files
Diffstat (limited to 'strava')
-rw-r--r--strava/strava.go146
-rw-r--r--strava/strava_test.go108
-rw-r--r--strava/sync.go110
-rw-r--r--strava/sync_test.go100
4 files changed, 464 insertions, 0 deletions
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)
+}