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}