1use 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 expires_in: u64,
47}
48
49impl HelloAsso {
50 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 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 pub async fn refresh_token(&mut self) -> Result<&mut Self, reqwest::Error> {
108 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 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 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 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 pub async fn get_token(&mut self) -> Result<&mut Self, Error> {
188 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 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 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 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 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}