trackme_backends/strava.rs
1// Copyright (C) Robin Krahl <robin.krahl@ireas.org>
2// SPDX-License-Identifier: GPL-3.0-or-later
3
4//! Provides access to [Strava][].
5//!
6//! Accessing the Strava API requires client and user authentication. For the client
7//! authentication, [create an API application][] and provide the client ID and secret in
8//! [`Config`][]. For the user configuration, point the user to the URL returned by
9//! [`Config::login_url`][]. After logging in to Strava and authorizing the application, the user
10//! is redirected. Extract the authorization token from the redirect URL and pass it to
11//! [`Api::login`][]. This will request an access token and store it in a [`State`][] instances.
12//! Afterwards, use [`Api::authorize`][] to use or refresh the obtained access token.
13//!
14//! As the access token expires and has to be renewed regularly, make sure that you save the
15//! [`State`][] after using the API as the access token may have changed.
16//!
17//! [Strava]: https://strava.com
18//! [create an API application]: https://www.strava.com/settings/api
19
20mod context;
21mod oauth;
22mod uploads;
23
24use std::path::Path;
25
26use chrono::{DateTime, Duration, Local};
27use secrecy::{ExposeSecret as _, SecretString};
28use serde::{Deserialize, Serialize};
29use ureq::Agent;
30use url::Url;
31
32use crate::{Error, ErrorKind, Result};
33use context::{AccessToken, Authorization, Context};
34use uploads::UploadResponse;
35
36pub use uploads::UploadOptions;
37
38/// The configuration for the Strava API client.
39#[derive(Clone, Debug)]
40pub struct Config<'a> {
41 /// The client ID, assigned by Strava in *Settings* → *My API Application*.
42 pub client_id: i64,
43 /// The client secret, assigned by Strava in *Settings* → *My API Application*.
44 pub client_secret: &'a SecretString,
45}
46
47impl Config<'_> {
48 /// Returns the URL that can be used to obtain an authorization token.
49 ///
50 /// Users have to open the URL in their web browser, login to Strava and authorize the
51 /// application. They are then redirected to the given URL, e. g.
52 /// `http://localhost/exchange_token`, and have to extract the authroization token from the
53 /// `code` query field of the redirected URL. This token can then be used for the initial API
54 /// login with [`Api::login`][].
55 pub fn login_url(&self, redirect: &str) -> Result<Url> {
56 let url = "https://strava.com/oauth/authorize";
57 let mut url = Url::parse(url).map_err(|err| {
58 Error::new(
59 ErrorKind::UrlParsingFailed {
60 url: url.to_owned(),
61 },
62 err,
63 )
64 })?;
65 {
66 let mut query_pairs = url.query_pairs_mut();
67 query_pairs.append_pair("client_id", &self.client_id.to_string());
68 query_pairs.append_pair("response_type", "code");
69 query_pairs.append_pair("redirect_uri", redirect);
70 query_pairs.append_pair("approval_prompt", "force");
71 query_pairs.append_pair("scope", "activity:write");
72 }
73 Ok(url)
74 }
75}
76
77/// Provides access to [Strava][].
78///
79/// API operations require authorization. See [`login`][`Api::login`] and
80/// [`authorize`][`Api::authorize`] for more information. The authorization state is stored in the
81/// [`State`][] struct. Users of this API client must persist this state between invocations so
82/// that authorization works, for example by saving it on the filesystem. The `State` is unique
83/// for a user, so multi-user applications should have one `State` instance per user.
84///
85/// The access tokens used for authorization expire. Currently, the expiration is only checked
86/// in the authorization methods. Operations may fail if the access token expires afterwards. In
87/// this case, authorization has to be repeated.
88///
89/// [Strava]: https://strava.com
90#[derive(Debug)]
91pub struct Api {
92 context: Context<AccessToken>,
93}
94
95impl Api {
96 /// Logs in to the Strava API using the given authorization code.
97 ///
98 /// This operation only has to be performed once per user. The authorization code can be
99 /// retrieved from the Strava web interface, see [`Config::login_url`][]. This method uses it
100 /// to request an access token that can be used for future operations.
101 pub fn login(agent: Agent, config: &Config<'_>, code: &SecretString) -> Result<(Api, State)> {
102 let context = Context::new(agent)?;
103 let token = oauth::request(
104 &context,
105 config.client_id,
106 config.client_secret.expose_secret(),
107 code.expose_secret(),
108 )?;
109 let state = State {
110 refresh_token: token.refresh_token,
111 access_token: token.access_token,
112 expiration: Local::now() + Duration::seconds(token.expires_in),
113 };
114 let context = context.with_access_token(&state.access_token);
115 Ok((Self { context }, state))
116 }
117
118 /// Authorizes this `Api` instance, either by using the cached access token from `State` or by
119 /// requesting a new access token if that token expired.
120 ///
121 /// Use [`login`][`Api::login`] for the initial authorization. The caller must persist the
122 /// state between invocations of this method.
123 pub fn authorize(agent: Agent, config: &Config, state: &mut State) -> Result<Api> {
124 let context = Context::new(agent)?;
125 let access_token = state.access_token(&context, config)?;
126 let context = context.with_access_token(access_token);
127 Ok(Self { context })
128 }
129
130 /// Revokes the access token used for this `Api` instance.
131 ///
132 /// Callers of this method must make sure that the application’s [`State`][] is cleared if this
133 /// command is successful.
134 // We want to be able to return Self to make it possible to repeat the deauthorization if it
135 // fails.
136 #[allow(clippy::result_large_err)]
137 pub fn deauthorize(self) -> Result<(), (Error, Self)> {
138 oauth::deauthorize(&self.context, self.context.authorization().token())
139 .map_err(|err| (err, self))
140 }
141
142 /// Uploads the file at the given path using the given options.
143 ///
144 /// The returned [`Upload`][] instance can be used to query the progress of the upload until it
145 /// is finished.
146 pub fn upload(&self, path: &Path, options: UploadOptions<'_>) -> Result<Upload> {
147 uploads::create(&self.context, path, options)?.try_into()
148 }
149}
150
151/// The status of an upload.
152#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
153pub enum Upload {
154 /// The upload is still pending.
155 Pending {
156 /// The upload ID.
157 upload_id: i64,
158 },
159 /// The upload is finished.
160 Finished {
161 /// The ID of the activity that was created from this upload.
162 activity_id: i64,
163 },
164}
165
166impl Upload {
167 /// Updates the status of this upload.
168 ///
169 /// If the upload is still pending, this function requests an update from the Strava API. If
170 /// the upload is finished, this is a no-op.
171 ///
172 /// A Strava upload typically takes several seconds, so calling this function once per second
173 /// until the upload is finished is a reasonable default.
174 pub fn update(&self, api: &Api) -> Result<Self> {
175 if let Self::Pending { upload_id } = self {
176 uploads::get(&api.context, *upload_id)?.try_into()
177 } else {
178 Ok(*self)
179 }
180 }
181}
182
183impl TryFrom<UploadResponse> for Upload {
184 type Error = Error;
185
186 fn try_from(response: UploadResponse) -> Result<Self> {
187 if let Some(error) = response.error {
188 Err(Error::new(ErrorKind::RequestFailed, error))
189 } else if let Some(activity_id) = response.activity_id {
190 Ok(Self::Finished { activity_id })
191 } else if let Some(upload_id) = response.id {
192 Ok(Self::Pending { upload_id })
193 } else {
194 Err(Error::new(
195 ErrorKind::ResponseFailed,
196 "missing upload ID in response",
197 ))
198 }
199 }
200}
201
202/// The state of the Strava API client.
203///
204/// Callers should persist this state between API invocations to make sure that authorized access
205/// is working properly. The state is unique for a user, so multi-user applications should
206/// maintain one instance per user.
207#[derive(Debug, Deserialize, Serialize)]
208pub struct State {
209 /// The last access token received from the Strava API.
210 pub access_token: String,
211 /// The refresh token that was received with the stored access token.
212 pub refresh_token: String,
213 /// The expiration time of the stored access token.
214 pub expiration: DateTime<Local>,
215}
216
217impl State {
218 fn access_token<A: Authorization>(
219 &mut self,
220 context: &Context<A>,
221 config: &Config,
222 ) -> Result<&str> {
223 if Local::now() >= self.expiration {
224 log::info!(
225 "Access token expired on {}, performing refresh",
226 self.expiration
227 );
228 self.refresh(context, config)?;
229 }
230 Ok(&self.access_token)
231 }
232
233 fn refresh<A: Authorization>(&mut self, context: &Context<A>, config: &Config) -> Result<()> {
234 let token = oauth::refresh(
235 context,
236 config.client_id,
237 config.client_secret.expose_secret(),
238 &self.refresh_token,
239 )?;
240 log::debug!("Received refresh response: {:?}", token);
241 self.refresh_token = token.refresh_token;
242 self.access_token = token.access_token;
243 self.expiration = Local::now() + Duration::seconds(token.expires_in);
244 Ok(())
245 }
246}