333 lines
9.3 KiB
Rust
333 lines
9.3 KiB
Rust
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)
|
|
}
|
|
}
|
|
|
|
|