package strava import ( "encoding/json" "fmt" "io" "net/http" "net/url" "strconv" "strings" "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" ) var ( tokenEndpoint = TokenURL apiEndpoint = APIURL ) type Token struct { AccessToken string RefreshToken string ExpiresAt time.Time } func AuthorizeURLWithState(clientID, redirectURI, state 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") if state != "" { q.Set("state", state) } 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 RateLimit struct { Limit15 int `json:"limit15"` Usage15 int `json:"usage15"` LimitDaily int `json:"limitDaily"` UsageDaily int `json:"usageDaily"` } type RateLimitState struct { Read RateLimit `json:"read"` Overall RateLimit `json:"overall"` } type Client struct { ClientID string ClientSecret string HTTP *http.Client tokens Token lastRateLimit RateLimitState } 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) RateLimitState() RateLimitState { return c.lastRateLimit } 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 } type RateLimitError struct { Path string StatusCode int Body string RetryAfter time.Time IsDaily bool State RateLimitState } func (e *RateLimitError) Error() string { if !e.RetryAfter.IsZero() { until := max(time.Until(e.RetryAfter).Truncate(time.Second), 0) scope := "15-minute" if e.IsDaily { scope = "daily" } return fmt.Sprintf("strava API %s rate limited (HTTP 429): %s; %s limit resets at %s (in %s)", e.Path, e.Body, scope, e.RetryAfter.UTC().Format(time.RFC3339), until) } return fmt.Sprintf("strava API %s rate limited (HTTP %d): %s", e.Path, e.StatusCode, e.Body) } 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") } state := parseRateLimitState(resp) if !isZeroRateLimitState(state) { c.lastRateLimit = state } if resp.StatusCode == http.StatusTooManyRequests { return &RateLimitError{ Path: path, StatusCode: resp.StatusCode, Body: string(body), RetryAfter: rateLimitRetryAfter(resp), IsDaily: isDailyRateLimited(resp), State: state, } } 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 } func rateLimitRetryAfter(resp *http.Response) time.Time { if retryAfter := resp.Header.Get("Retry-After"); retryAfter != "" { if secs, err := strconv.Atoi(strings.TrimSpace(retryAfter)); err == nil { return time.Now().UTC().Add(time.Duration(secs) * time.Second) } if t, err := http.ParseTime(retryAfter); err == nil { return t.UTC() } } responseTime := time.Now().UTC() if date := resp.Header.Get("Date"); date != "" { if t, err := http.ParseTime(date); err == nil { responseTime = t.UTC() } } if isDailyRateLimited(resp) { return nextMidnightUTC(responseTime) } return nextQuarterHour(responseTime) } func isDailyRateLimited(resp *http.Response) bool { for _, pair := range [][2]string{ {resp.Header.Get("X-ReadRateLimit-Limit"), resp.Header.Get("X-ReadRateLimit-Usage")}, {resp.Header.Get("X-RateLimit-Limit"), resp.Header.Get("X-RateLimit-Usage")}, } { limitPair := pair[0] usagePair := pair[1] if limitPair == "" || usagePair == "" { continue } _, dailyLimit, ok1 := parseRateLimitPair(limitPair) _, dailyUsage, ok2 := parseRateLimitPair(usagePair) if !ok1 || !ok2 { continue } if dailyLimit > 0 && dailyUsage >= dailyLimit { return true } } return false } func parseRateLimitPair(value string) (quarter int, daily int, ok bool) { parts := strings.Split(value, ",") if len(parts) != 2 { return 0, 0, false } q, err1 := strconv.Atoi(strings.TrimSpace(parts[0])) d, err2 := strconv.Atoi(strings.TrimSpace(parts[1])) if err1 != nil || err2 != nil { return 0, 0, false } return q, d, true } func nextQuarterHour(t time.Time) time.Time { t = t.UTC().Truncate(time.Second) minute := t.Minute() var nextMinute int switch { case minute < 15: nextMinute = 15 case minute < 30: nextMinute = 30 case minute < 45: nextMinute = 45 default: t = t.Add(time.Hour) nextMinute = 0 } return time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), nextMinute, 0, 0, time.UTC) } func nextMidnightUTC(t time.Time) time.Time { t = t.UTC() return time.Date(t.Year(), t.Month(), t.Day()+1, 0, 0, 0, 0, time.UTC) } func parseRateLimitState(resp *http.Response) RateLimitState { var state RateLimitState if limit, usage, ok := parseRateLimitHeaders(resp.Header.Get("X-ReadRateLimit-Limit"), resp.Header.Get("X-ReadRateLimit-Usage")); ok { state.Read = RateLimit{Limit15: limit[0], LimitDaily: limit[1], Usage15: usage[0], UsageDaily: usage[1]} } if limit, usage, ok := parseRateLimitHeaders(resp.Header.Get("X-RateLimit-Limit"), resp.Header.Get("X-RateLimit-Usage")); ok { state.Overall = RateLimit{Limit15: limit[0], LimitDaily: limit[1], Usage15: usage[0], UsageDaily: usage[1]} } return state } func parseRateLimitHeaders(limitValue, usageValue string) ([2]int, [2]int, bool) { if limitValue == "" || usageValue == "" { return [2]int{}, [2]int{}, false } l0, l1, ok1 := parseRateLimitPair(limitValue) u0, u1, ok2 := parseRateLimitPair(usageValue) if !ok1 || !ok2 { return [2]int{}, [2]int{}, false } return [2]int{l0, l1}, [2]int{u0, u1}, true } func isZeroRateLimitState(state RateLimitState) bool { return state.Read.Limit15 == 0 && state.Read.LimitDaily == 0 && state.Overall.Limit15 == 0 && state.Overall.LimitDaily == 0 }