use anyhow::{Context, Result, anyhow}; use url::Url; use serde::{Serialize, Deserialize}; use fully_pub::fully_pub; 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 } pub enum LoginError { TransportFailure(reqwest::Error) } #[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()); 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(";").nth(0)?.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 } // #[derive(Debug, Serialize, Deserialize)] // #[serde(rename_all = "camelCase")] // struct OrderDetails { // date: // form_ // } 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?; return 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.len() == 0 { 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(with = "date_format")] // date: DateTime<Utc> date: String } #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] #[fully_pub] struct FormAnswer { amount: u64, #[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: String) -> 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) } }