summaryrefslogtreecommitdiff
path: root/strava/strava.go
blob: c7a84d0f3dc9fac071db74b4437cd44c5225ed42 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
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 {
	return AuthorizeURLWithState(clientID, redirectURI, "")
}

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 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
}