helloasso/
client.rs

1//! `client` the client and client builder
2
3use std::{
4    collections::HashMap,
5    str::FromStr,
6    time::{Duration, SystemTime},
7};
8
9use derivative::Derivative;
10#[cfg(feature = "log")]
11use log::{error, info};
12use reqwest::{header, StatusCode};
13use serde::Deserialize;
14use url::Url;
15
16use crate::{error::Error, AuthenticationError};
17
18#[cfg(not(test))]
19const URL: &str = "https://api.helloasso.com/v5";
20#[cfg(not(test))]
21const OAUTH2_TOKEN_URL: &str = "https://api.helloasso.com/oauth2/token";
22#[cfg(test)]
23const URL: &str = "https://api.helloasso-sandbox.com/v5";
24#[cfg(test)]
25const OAUTH2_TOKEN_URL: &str = "https://api.helloasso-sandbox.com/oauth2/token";
26
27#[derive(Clone, Derivative)]
28#[derivative(Debug, PartialEq)]
29pub struct HelloAsso {
30    pub client_id: String,
31    client_secret: String,
32    pub url: Url,
33    token_url: Url,
34    access_token: String,
35    refresh_token: String,
36    token_outdated_after: SystemTime,
37    #[derivative(PartialEq = "ignore")]
38    client: reqwest::Client,
39}
40
41#[derive(Debug, Deserialize)]
42struct RefreshToken {
43    access_token: String,
44    refresh_token: String,
45    /* token_type: String, */
46    expires_in: u64,
47}
48
49impl HelloAsso {
50    /// Create a new client to interact with the api
51    pub async fn new(client_id: String, client_secret: String) -> Result<Self, Error> {
52        let client = HelloAsso::builder(client_id, client_secret)
53            .get_token()
54            .await?
55            .config_client()?
56            .build();
57
58        #[cfg(feature = "log")]
59        info!("New client created");
60
61        Ok(client)
62    }
63
64    /// Create a client builder that can be configure
65    ///
66    /// The helloasso client can be created ether by calling the `new` method
67    /// or by using the builder pattern for a higher flexibility.
68    ///
69    /// ```rust
70    /// # use helloasso::{HelloAsso, Error};
71    /// # use dotenv::dotenv;
72    /// # use std::env;
73    /// #
74    /// # #[tokio::main(flavor = "current_thread")]
75    /// # async fn main() -> Result<(), Error> {
76    /// # dotenv();
77    /// # let client_id = env::var("CLIENT_ID").unwrap();
78    /// # let client_secret = env::var("CLIENT_SECRET").unwrap();
79    /// #
80    /// let client = HelloAsso::builder(client_id, client_secret)
81    /// #   .set_url("https://api.helloasso-sandbox.com/v5", "https://api.helloasso-sandbox.com/oauth2/token")?
82    ///     .get_token()
83    ///     .await?
84    ///     .config_client()?
85    ///     .build();
86    /// # Ok(())
87    /// # }
88    /// ```
89    pub fn builder(client_id: String, client_secret: String) -> HelloAssoBuilder {
90        HelloAssoBuilder {
91            client_id,
92            client_secret,
93            url: Url::from_str(URL).expect("Config url is always valid"),
94            token_url: Url::from_str(OAUTH2_TOKEN_URL).expect("Config url is always valid"),
95            access_token: None,
96            refresh_token: None,
97            token_type: None,
98            token_outdated_after: None,
99            client: None,
100        }
101    }
102
103    /// Refresh the access_token of the client
104    ///
105    /// By default access token are only valid for 30 min,
106    /// we can use this function to reset this timer
107    pub async fn refresh_token(&mut self) -> Result<&mut Self, reqwest::Error> {
108        // Prepare request body
109        let mut tokens = HashMap::new();
110        tokens.insert("client_id", self.client_id.clone());
111        tokens.insert("refresh_token", self.refresh_token.clone());
112        tokens.insert("grant_type", "refresh_token".to_string());
113
114        // Get access and refresh token
115        let answer_client = reqwest::Client::new();
116        let token = answer_client
117            .post(self.token_url.as_ref())
118            .form(&tokens)
119            .send()
120            .await
121            .map_err(|err| {
122                #[cfg(feature = "log")]
123                error!("Can't fetch refresh token from the api");
124                err
125            })?
126            .json::<RefreshToken>()
127            .await
128            .map_err(|err| {
129                #[cfg(feature = "log")]
130                error!("Can't deserialize refresh token response");
131                err
132            })?;
133
134        // Fill data
135        self.access_token = token.access_token;
136        self.refresh_token = token.refresh_token;
137        self.token_outdated_after = SystemTime::now() + Duration::from_secs(token.expires_in);
138
139        #[cfg(feature = "log")]
140        info!("Access token refreshed");
141        Ok(self)
142    }
143}
144
145#[derive(Debug, Deserialize)]
146pub struct HelloAssoBuilder {
147    pub client_id: String,
148    client_secret: String,
149    pub url: Url,
150    token_url: Url,
151    access_token: Option<String>,
152    refresh_token: Option<String>,
153    token_type: Option<String>,
154    token_outdated_after: Option<SystemTime>,
155    #[serde(skip)]
156    client: Option<reqwest::Client>,
157}
158
159#[derive(Debug, Deserialize)]
160struct AccessTokenResponse {
161    access_token: String,
162    refresh_token: String,
163    token_type: String,
164    expires_in: u64,
165}
166
167impl HelloAssoBuilder {
168    /// Set the client url. You need to call this methode before get_token and config_client
169    pub fn set_url(&mut self, url: &str, token_url: &str) -> Result<&mut Self, Error> {
170        self.url = Url::from_str(url).map_err(|err| {
171            #[cfg(feature = "log")]
172            error!("Can't parse url {}", url);
173            Error::ParseUrlErr(err)
174        })?;
175        self.token_url = Url::from_str(token_url).map_err(|err| {
176            #[cfg(feature = "log")]
177            error!("Can't parse token_url {}", token_url);
178            Error::ParseUrlErr(err)
179        })?;
180
181        #[cfg(feature = "log")]
182        info!("Client urls set to {} {}", self.url, self.token_url);
183        Ok(self)
184    }
185
186    /// Get the access token using the client id an secret
187    pub async fn get_token(&mut self) -> Result<&mut Self, Error> {
188        // Prepare request body
189        let mut tokens = HashMap::new();
190        tokens.insert("client_id", self.client_id.clone());
191        tokens.insert("client_secret", self.client_secret.clone());
192        tokens.insert("grant_type", "client_credentials".to_string());
193
194        // Get access and refresh token
195        let answer_client = reqwest::Client::new();
196        let response = answer_client
197            .post(self.token_url.as_ref())
198            .form(&tokens)
199            .send()
200            .await
201            .map_err(|err| {
202                #[cfg(feature = "log")]
203                error!("Can't fetch access token");
204                Error::ReqwestErr(err)
205            })?;
206
207        match response.status() {
208            StatusCode::OK => {
209                let token = response
210                    .json::<AccessTokenResponse>()
211                    .await
212                    .map_err(|err| {
213                        #[cfg(feature = "log")]
214                        error!("Can't decode access token");
215                        Error::DecodeErr(err)
216                    })?;
217
218                // Fill data
219                self.access_token = Some(token.access_token);
220                self.refresh_token = Some(token.refresh_token);
221                self.token_type = Some(token.token_type);
222                self.token_outdated_after =
223                    Some(SystemTime::now() + Duration::from_secs(token.expires_in));
224
225                #[cfg(feature = "log")]
226                info!("Access token fetched");
227
228                Ok(self)
229            }
230            StatusCode::BAD_REQUEST => {
231                let error = response
232                    .json::<AuthenticationError>()
233                    .await
234                    .map_err(|err| {
235                        #[cfg(feature = "log")]
236                        error!("Can't decode authentication error");
237                        Error::DecodeErr(err)
238                    })?;
239
240                #[cfg(feature = "log")]
241                error!("An authentication error as occur, wrong client_id or credential");
242
243                Err(Error::AuthErr(error))
244            }
245            status => {
246                unimplemented!(
247                    "Unknown status code while fetching the access_token, {}",
248                    status
249                )
250            }
251        }
252    }
253
254    /// Create a new client using a previously set access_token, see `get_token`
255    pub fn config_client(&mut self) -> Result<&mut Self, Error> {
256        let mut headers = header::HeaderMap::new();
257        headers.insert(
258            header::AUTHORIZATION,
259            format!(
260                "Bearer {}",
261                self.access_token
262                    .clone()
263                    .expect("Can't get the access_token, use get_token")
264            )
265            .parse()
266            .expect("Can't parse formatted token into a HeaderName"),
267        );
268        self.client = Some(
269            reqwest::Client::builder()
270                .default_headers(headers)
271                .build()
272                .map_err(Error::ReqwestErr)?,
273        );
274
275        #[cfg(feature = "log")]
276        info!("Client configured");
277        Ok(self)
278    }
279
280    /// Build the client
281    pub fn build(&mut self) -> HelloAsso {
282        HelloAsso {
283            client_id: self.client_id.clone(),
284            client_secret: self.client_secret.clone(),
285            url: self.url.clone(),
286            token_url: self.token_url.clone(),
287            access_token: self.access_token.clone().unwrap_or_default(),
288            refresh_token: self.refresh_token.clone().unwrap_or_default(),
289            token_outdated_after: self.token_outdated_after.unwrap_or(SystemTime::UNIX_EPOCH),
290            client: self.client.clone().unwrap_or_default(),
291        }
292    }
293}
294
295#[cfg(test)]
296mod tests {
297    use crate::{Error, HelloAsso};
298    use dotenv::dotenv;
299    #[cfg(feature = "log")]
300    use log::{info, warn};
301    use std::env;
302
303    pub fn get_env_variables() -> (String, String) {
304        if let Err(err) = dotenv() {
305            #[cfg(feature = "log")]
306            warn!("Can't load .env file, {}", err);
307        } else {
308            #[cfg(feature = "log")]
309            info!(".env file loaded");
310        }
311
312        let client_id = env::var("CLIENT_ID").unwrap();
313        let client_secret = env::var("CLIENT_SECRET").unwrap();
314
315        (client_id, client_secret)
316    }
317
318    #[tokio::test]
319    async fn new_client() {
320        let (client_id, client_secret) = get_env_variables();
321
322        HelloAsso::new(client_id, client_secret)
323            .await
324            .expect("Test failed");
325    }
326
327    #[tokio::test]
328    async fn invalid_client_id() {
329        let (_, client_secret) = get_env_variables();
330        let client_id = "abc".to_string();
331
332        let client = HelloAsso::new(client_id, client_secret).await;
333
334        assert!(matches!(client, Err(Error::AuthErr(_))))
335    }
336
337    #[tokio::test]
338    async fn invalid_client_secret() {
339        let (client_id, _) = get_env_variables();
340        let client_secret = "abc".to_string();
341
342        let client = HelloAsso::new(client_id, client_secret).await;
343
344        assert!(matches!(client, Err(Error::AuthErr(_))))
345    }
346
347    #[tokio::test]
348    async fn refresh_token() {
349        let (client_id, client_secret) = get_env_variables();
350
351        let mut client = HelloAsso::new(client_id, client_secret)
352            .await
353            .expect("Can't create the client");
354
355        client
356            .refresh_token()
357            .await
358            .expect("Could not refresh token");
359    }
360}