helloasso-paheko-adapter/src/helloasso.rs

326 lines
9.4 KiB
Rust

use anyhow::{Context, Result, anyhow};
use url::Url;
use serde::{Serialize, Deserialize};
use fully_pub::fully_pub;
use chrono::prelude::{DateTime, Utc};
use crate::utils::deserialize_datetime;
use thiserror::Error;
#[derive(Error, Debug)]
enum APIClientError {
#[error("Received non-normal status code from API")]
InvalidStatusCode
}
#[fully_pub]
#[derive(Clone, Serialize, Deserialize, Debug)]
struct WebSession {
jwt: String
}
#[derive(Debug)]
#[fully_pub]
struct Client {
client: reqwest::Client,
base_url: Url,
}
#[derive(Serialize, Debug)]
#[fully_pub]
struct LoginPayload {
email: String,
password: String
}
impl Default for Client {
fn default() -> Self {
Client {
client: Client::get_base_client_builder()
.build()
.expect("reqwest client to be built"),
base_url: Url::parse("https://api.helloasso.com/v5/")
.expect("Valid helloasso API base URL")
}
}
}
impl Client {
fn get_base_client_builder() -> reqwest::ClientBuilder {
let mut default_headers = reqwest::header::HeaderMap::new();
default_headers.insert("Accept", "application/json".parse().unwrap());
// decoy user agent
default_headers.insert("User-Agent", "Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/112.0".parse().unwrap());
// TODO: configurable proxy
// let proxy = reqwest::Proxy::https("https://localhost:8999").unwrap();
reqwest::Client::builder()
// .proxy(proxy)
.default_headers(default_headers)
}
pub async fn login(&mut self, payload: LoginPayload) -> Result<AuthentifiedClient> {
let mut login_commons_headers = reqwest::header::HeaderMap::new();
login_commons_headers.insert(
"Origin",
"https://auth.helloasso.com".parse().expect("Header value to be OK")
);
let res = self.client.get(self.base_url.join("auth/antiforgerytoken")?)
.headers(login_commons_headers.clone())
.send().await?;
let antiforgerytoken: String = res.json().await?;
let res = self.client.post(self.base_url.join("auth/login")?)
.json(&payload)
.headers(login_commons_headers.clone())
.header("x-csrf-token", antiforgerytoken)
.send()
.await?;
if res.status() != 200 {
return Err(anyhow!("Unexpected status code from login"));
}
fn get_jwt_from_cookies_headers(headers: &reqwest::header::HeaderMap) -> Option<String> {
for (name_opt, value_raw) in headers {
let name = String::from(name_opt.as_str());
if name.to_lowercase() != "set-cookie" {
continue
}
let value = String::from(value_raw.to_str().unwrap());
if value.starts_with("tm5-HelloAsso") {
let jwt = value.split("tm5-HelloAsso=").nth(1)?.split(';').next()?.trim().to_string();
return Some(jwt);
}
}
None
}
let jwt = get_jwt_from_cookies_headers(res.headers())
.context("Failed to find or parse JWT from login response")?;
let session = WebSession { jwt };
Ok(self.authentified_client(session))
}
pub fn authentified_client(&self, session: WebSession) -> AuthentifiedClient {
AuthentifiedClient::new(self.base_url.clone(), session)
}
}
#[derive(Debug, Clone)]
#[fully_pub]
struct AuthentifiedClient {
session: WebSession,
client: reqwest::Client,
base_url: Url
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[fully_pub]
struct PaginationMeta {
continuation_token: String,
page_index: u64,
page_size: u64,
total_count: u64,
total_pages: u64
}
#[derive(Debug, Serialize, Deserialize)]
struct PaginationCapsule {
data: serde_json::Value,
pagination: PaginationMeta
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[fully_pub]
struct CustomFieldAnswer {
answer: String,
id: u64,
name: String
// missing type, it's probably always TextInput, if not, serde will fail to parse
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[fully_pub]
struct PayerUserDetails {
country: String,
email: String,
first_name: String,
last_name: String
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[fully_pub]
struct UserDetails {
first_name: String,
last_name: String
}
impl AuthentifiedClient {
/// each time we need to change the token, we will need to rebuild the client
pub fn new(base_url: Url, session: WebSession) -> Self {
let mut auth_headers = reqwest::header::HeaderMap::new();
auth_headers.insert("Authorization", format!("Bearer {}", session.jwt).parse().unwrap());
AuthentifiedClient {
base_url,
session,
client: Client::get_base_client_builder()
.default_headers(auth_headers)
.build()
.expect("reqwest client to be built")
}
}
pub async fn verify_auth(&self) -> Result<bool> {
let res = self.client
.get(self.base_url.join("agg/user")?)
.send().await?;
Ok(res.status() == 200)
}
pub async fn get_user_details(&self) -> Result<serde_json::Value> {
let res = self.client
.get(self.base_url.join("agg/user")?)
.send().await?;
if res.status() != 200 {
return Err(APIClientError::InvalidStatusCode.into());
}
let user_details: serde_json::Value = res.json().await?;
Ok(user_details)
}
async fn simple_fetch(&self, path: String) -> Result<serde_json::Value> {
let res = self.client
.get(self.base_url.join(path.as_str())?)
.send().await?;
if res.status() != 200 {
return Err(APIClientError::InvalidStatusCode.into());
}
let details: serde_json::Value = res.json().await?;
Ok(details)
}
pub async fn fetch_with_pagination(&self, path: String) -> Result<Vec<serde_json::Value>> {
let mut data: Vec<serde_json::Value> = vec![];
let mut continuation_token: Option<String> = None;
loop {
let mut url = self.base_url.join(path.as_str())?;
if let Some(token) = &continuation_token {
url.query_pairs_mut().append_pair("continuationToken", token);
}
let res = self.client
.get(url)
.send().await?;
if res.status() != 200 {
return Err(APIClientError::InvalidStatusCode.into());
}
let capsule: PaginationCapsule = res.json().await?;
// handle pagination
// merge into "data", "pagination" is the key that hold details
let page_items = match capsule.data {
serde_json::Value::Array(inner) => inner,
_ => {
return Err(anyhow!("Unexpected json value in data bundle"));
}
};
if page_items.is_empty() {
return Ok(data);
}
data.extend(page_items);
if capsule.pagination.page_index == capsule.pagination.total_pages {
return Ok(data);
}
continuation_token = Some(capsule.pagination.continuation_token);
}
}
pub fn organization(&self, slug: &str) -> Organization {
Organization { client: self.clone(), slug: slug.to_string() }
}
}
#[derive(Debug, Clone)]
#[fully_pub]
struct Organization {
client: AuthentifiedClient,
slug: String
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
#[fully_pub]
enum MembershipMode {
#[serde(rename = "Individuel")]
Individual,
#[serde(rename = "Couple")]
Couple,
#[serde(rename = "Individuel bienfaiteur")]
BenefactorIndividual,
#[serde(rename = "Couple bienfaiteur")]
BenefactorCouple,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[fully_pub]
struct OrderDetails {
id: u64,
#[serde(deserialize_with = "deserialize_datetime", rename="date")]
inception_time: DateTime<Utc>
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[fully_pub]
struct FormAnswer {
amount: u32,
#[serde(rename = "name")]
mode: MembershipMode,
#[serde(rename = "payer")]
payer_user: PayerUserDetails,
order: OrderDetails,
#[serde(rename = "user")]
user: UserDetails,
id: u64,
custom_fields: Vec<CustomFieldAnswer>
}
impl Organization {
pub async fn get_details(&self) -> Result<serde_json::Value> {
let details = self.client.simple_fetch(format!("organizations/{}", self.slug)).await?;
Ok(details)
}
pub async fn get_form_answers(&self, form_slug: &str) -> Result<Vec<FormAnswer>> {
let data = self.client.fetch_with_pagination(
format!("organizations/{}/forms/Membership/{}/participants?withDetails=true", self.slug, form_slug)
).await?;
let mut answers: Vec<FormAnswer> = vec![];
for entry in data {
answers.push(serde_json::from_value(entry).context("Cannot parse FormAnswer")?)
}
Ok(answers)
}
}