use std::{
collections::HashMap,
str::FromStr,
time::{Duration, SystemTime},
};
use derivative::Derivative;
#[cfg(feature = "log")]
use log::{error, info};
use reqwest::{header, StatusCode};
use serde::Deserialize;
use url::Url;
use crate::{error::Error, AuthenticationError};
const URL: &str = "https://api.helloasso.com/v5";
const OAUTH2_TOKEN_URL: &str = "https://api.helloasso.com/oauth2/token";
#[derive(Clone, Derivative)]
#[derivative(Debug, PartialEq)]
pub struct HelloAsso {
pub client_id: String,
client_secret: String,
pub url: Url,
token_url: Url,
access_token: String,
refresh_token: String,
token_outdated_after: SystemTime,
#[derivative(PartialEq = "ignore")]
client: reqwest::Client,
}
#[derive(Debug, Deserialize)]
struct RefreshToken {
access_token: String,
refresh_token: String,
expires_in: u64,
}
impl HelloAsso {
pub async fn new(client_id: String, client_secret: String) -> Result<Self, Error> {
let client = HelloAsso::builder(client_id, client_secret)
.get_token()
.await?
.config_client()?
.build();
#[cfg(feature = "log")]
info!("New client created");
Ok(client)
}
pub fn builder(client_id: String, client_secret: String) -> HelloAssoBuilder {
HelloAssoBuilder {
client_id,
client_secret,
url: Url::from_str(URL).expect("Config url is always valid"),
token_url: Url::from_str(OAUTH2_TOKEN_URL).expect("Config url is always valid"),
access_token: None,
refresh_token: None,
token_type: None,
token_outdated_after: None,
client: None,
}
}
pub async fn refresh_token(&mut self) -> Result<&mut Self, reqwest::Error> {
let mut tokens = HashMap::new();
tokens.insert("client_id", self.client_id.clone());
tokens.insert("refresh_token", self.refresh_token.clone());
tokens.insert("grant_type", "refresh_token".to_string());
let answer_client = reqwest::Client::new();
let token = answer_client
.post(self.token_url.as_ref())
.form(&tokens)
.send()
.await
.map_err(|err| {
#[cfg(feature = "log")]
error!("Can't fetch refresh token from the api");
err
})?
.json::<RefreshToken>()
.await
.map_err(|err| {
#[cfg(feature = "log")]
error!("Can't deserialize refresh token response");
err
})?;
self.access_token = token.access_token;
self.refresh_token = token.refresh_token;
self.token_outdated_after = SystemTime::now() + Duration::from_secs(token.expires_in);
#[cfg(feature = "log")]
info!("Access token refreshed");
Ok(self)
}
}
#[derive(Debug, Deserialize)]
pub struct HelloAssoBuilder {
pub client_id: String,
client_secret: String,
pub url: Url,
token_url: Url,
access_token: Option<String>,
refresh_token: Option<String>,
token_type: Option<String>,
token_outdated_after: Option<SystemTime>,
#[serde(skip)]
client: Option<reqwest::Client>,
}
#[derive(Debug, Deserialize)]
struct AccessTokenResponse {
access_token: String,
refresh_token: String,
token_type: String,
expires_in: u64,
}
impl HelloAssoBuilder {
pub fn set_url(&mut self, url: &str, token_url: &str) -> Result<&mut Self, url::ParseError> {
self.url = Url::from_str(url)?;
self.token_url = Url::from_str(token_url)?;
#[cfg(feature = "log")]
info!("Client urls set to {} {}", self.url, self.token_url);
Ok(self)
}
pub async fn get_token(&mut self) -> Result<&mut Self, Error> {
let mut tokens = HashMap::new();
tokens.insert("client_id", self.client_id.clone());
tokens.insert("client_secret", self.client_secret.clone());
tokens.insert("grant_type", "client_credentials".to_string());
let answer_client = reqwest::Client::new();
let response = answer_client
.post(self.token_url.as_ref())
.form(&tokens)
.send()
.await
.map_err(|err| {
#[cfg(feature = "log")]
error!("Can't fetch access token");
Error::ReqwestErr(err)
})?;
match response.status() {
StatusCode::OK => {
let token = response
.json::<AccessTokenResponse>()
.await
.map_err(|err| {
#[cfg(feature = "log")]
error!("Can't decode access token");
Error::DecodeErr(err)
})?;
self.access_token = Some(token.access_token);
self.refresh_token = Some(token.refresh_token);
self.token_type = Some(token.token_type);
self.token_outdated_after =
Some(SystemTime::now() + Duration::from_secs(token.expires_in));
#[cfg(feature = "log")]
info!("Access token fetched");
Ok(self)
}
StatusCode::BAD_REQUEST => {
let error = response
.json::<AuthenticationError>()
.await
.map_err(|err| {
#[cfg(feature = "log")]
error!("Can't decode authentication error");
Error::DecodeErr(err)
})?;
#[cfg(feature = "log")]
error!("An authentication error as occur, wrong client_id or credential");
Err(Error::AuthErr(error))
}
status => {
unimplemented!(
"Unknown status code while fetching the access_token, {}",
status
)
}
}
}
pub fn config_client(&mut self) -> Result<&mut Self, Error> {
let mut headers = header::HeaderMap::new();
headers.insert(
header::AUTHORIZATION,
format!(
"Bearer {}",
self.access_token
.clone()
.expect("Can't get the access_token, use get_token")
)
.parse()
.expect("Can't parse formatted token into a HeaderName"),
);
self.client = Some(
reqwest::Client::builder()
.default_headers(headers)
.build()
.map_err(Error::ReqwestErr)?,
);
#[cfg(feature = "log")]
info!("Client configured");
Ok(self)
}
pub fn build(&mut self) -> HelloAsso {
HelloAsso {
client_id: self.client_id.clone(),
client_secret: self.client_secret.clone(),
url: self.url.clone(),
token_url: self.token_url.clone(),
access_token: self.access_token.clone().unwrap_or_default(),
refresh_token: self.refresh_token.clone().unwrap_or_default(),
token_outdated_after: self.token_outdated_after.unwrap_or(SystemTime::UNIX_EPOCH),
client: self.client.clone().unwrap_or_default(),
}
}
}
#[cfg(test)]
mod tests {
use crate::{Error, HelloAsso};
use dotenv::dotenv;
#[cfg(feature = "log")]
use log::{info, warn};
use std::env;
pub fn get_env_variables() -> (String, String) {
if let Err(err) = dotenv() {
#[cfg(feature = "log")]
warn!("Can't load .env file, {}", err);
} else {
#[cfg(feature = "log")]
info!(".env file loaded");
}
let client_id = env::var("CLIENT_ID").unwrap();
let client_secret = env::var("CLIENT_SECRET").unwrap();
(client_id, client_secret)
}
#[tokio::test]
async fn new_client() {
let (client_id, client_secret) = get_env_variables();
HelloAsso::new(client_id, client_secret)
.await
.expect("Test failed");
}
#[tokio::test]
async fn invalid_client_id() {
let (_, client_secret) = get_env_variables();
let client_id = "abc".to_string();
let client = HelloAsso::new(client_id, client_secret).await;
assert!(matches!(client, Err(Error::AuthErr(_))))
}
#[tokio::test]
async fn invalid_client_secret() {
let (client_id, _) = get_env_variables();
let client_secret = "abc".to_string();
let client = HelloAsso::new(client_id, client_secret).await;
assert!(matches!(client, Err(Error::AuthErr(_))))
}
#[tokio::test]
async fn refresh_token() {
let (client_id, client_secret) = get_env_variables();
let mut client = HelloAsso::new(client_id, client_secret)
.await
.expect("Can't create the client");
client
.refresh_token()
.await
.expect("Could not refresh token");
}
}