feat(config): proxy

Make HTTP client proxy configurable for both paheko and helloasso
This commit is contained in:
Matthieu Bessat 2023-12-30 23:48:23 +01:00
parent 19117e2a19
commit 40c41fd930
4 changed files with 148 additions and 69 deletions

View file

@ -20,9 +20,14 @@ we should have created our own platform so people can register and pay later (ei
TODO: TODO:
- more configuration options - more configuration options
- configurable proxy
- summary of the operations at the end of run - summary of the operations at the end of run
- how many users were added, muted? - how many users were added, muted?
- conjoined user: add attached member to paheko - conjoined user: add attached member to paheko
- "Membre lié" - "Membre lié"
- is this kind of thing even accessible on the API-level ? - is this kind of thing even accessible on the API-level ?
- better error handling & report to the user
- handle import error
- handle name of the service or service fee not found
- BUG: quand l'utilisateur est déjà créé, ya un problème d'ID, le user summary n'a pas le bon id, il faut le populer depuis ce qu'on a déjà fetch

View file

@ -19,11 +19,23 @@ struct WebSession {
jwt: String jwt: String
} }
#[derive(Debug)] #[derive(Debug, Clone)]
#[fully_pub] #[fully_pub]
struct Client { struct ClientConfig {
client: reqwest::Client,
base_url: Url, base_url: Url,
proxy: Option<reqwest::Proxy>,
user_agent: String
}
impl Default for ClientConfig {
fn default() -> Self {
ClientConfig {
proxy: None,
base_url: Url::parse("https://api.helloasso.com/v5/") // the traling slash is important
.expect("Expected valid helloasso API base URL"),
user_agent: "".to_string()
}
}
} }
#[derive(Serialize, Debug)] #[derive(Serialize, Debug)]
@ -33,31 +45,47 @@ struct LoginPayload {
password: String password: String
} }
#[derive(Debug)]
#[fully_pub]
struct Client {
client: reqwest::Client,
config: ClientConfig
}
impl Default for Client { impl Default for Client {
fn default() -> Self { fn default() -> Self {
let base_config: ClientConfig = Default::default();
Client { Client {
client: Client::get_base_client_builder() client: Client::get_base_client_builder(&base_config)
.build() .build()
.expect("reqwest client to be built"), .expect("Expected reqwest client to be built"),
base_url: Url::parse("https://api.helloasso.com/v5/") config: base_config
.expect("Valid helloasso API base URL")
} }
} }
} }
impl Client {
fn get_base_client_builder() -> reqwest::ClientBuilder { impl Client {
pub fn new(config: ClientConfig) -> Client {
Client {
client: Client::get_base_client_builder(&config)
.build()
.expect("Expected reqwest client to be built"),
config
}
}
fn get_base_client_builder(config: &ClientConfig) -> reqwest::ClientBuilder {
let mut default_headers = reqwest::header::HeaderMap::new(); let mut default_headers = reqwest::header::HeaderMap::new();
default_headers.insert("Accept", "application/json".parse().unwrap()); default_headers.insert("Accept", "application/json".parse().unwrap());
// decoy user agent default_headers.insert("User-Agent", config.user_agent.parse().unwrap());
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 mut builder = reqwest::Client::builder()
// let proxy = reqwest::Proxy::https("https://localhost:8999").unwrap(); .default_headers(default_headers);
reqwest::Client::builder() if let Some(proxy) = &config.proxy {
// .proxy(proxy) builder = builder.proxy(proxy.clone());
.default_headers(default_headers) }
builder
} }
pub async fn login(&mut self, payload: LoginPayload) -> Result<AuthentifiedClient> { pub async fn login(&mut self, payload: LoginPayload) -> Result<AuthentifiedClient> {
@ -67,12 +95,12 @@ impl Client {
"https://auth.helloasso.com".parse().expect("Header value to be OK") "https://auth.helloasso.com".parse().expect("Header value to be OK")
); );
let res = self.client.get(self.base_url.join("auth/antiforgerytoken")?) let res = self.client.get(self.config.base_url.join("auth/antiforgerytoken")?)
.headers(login_commons_headers.clone()) .headers(login_commons_headers.clone())
.send().await?; .send().await?;
let antiforgerytoken: String = res.json().await?; let antiforgerytoken: String = res.json().await?;
let res = self.client.post(self.base_url.join("auth/login")?) let res = self.client.post(self.config.base_url.join("auth/login")?)
.json(&payload) .json(&payload)
.headers(login_commons_headers.clone()) .headers(login_commons_headers.clone())
.header("x-csrf-token", antiforgerytoken) .header("x-csrf-token", antiforgerytoken)
@ -107,7 +135,7 @@ impl Client {
} }
pub fn authentified_client(&self, session: WebSession) -> AuthentifiedClient { pub fn authentified_client(&self, session: WebSession) -> AuthentifiedClient {
AuthentifiedClient::new(self.base_url.clone(), session) AuthentifiedClient::new(self.config.clone(), session)
} }
} }
@ -116,7 +144,7 @@ impl Client {
struct AuthentifiedClient { struct AuthentifiedClient {
session: WebSession, session: WebSession,
client: reqwest::Client, client: reqwest::Client,
base_url: Url config: ClientConfig
} }
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
@ -167,30 +195,30 @@ struct UserDetails {
impl AuthentifiedClient { impl AuthentifiedClient {
/// each time we need to change the token, we will need to rebuild the client /// each time we need to change the token, we will need to rebuild the client
pub fn new(base_url: Url, session: WebSession) -> Self { pub fn new(config: ClientConfig, session: WebSession) -> Self {
let mut auth_headers = reqwest::header::HeaderMap::new(); let mut auth_headers = reqwest::header::HeaderMap::new();
auth_headers.insert("Authorization", format!("Bearer {}", session.jwt).parse().unwrap()); auth_headers.insert("Authorization", format!("Bearer {}", session.jwt).parse().unwrap());
AuthentifiedClient { AuthentifiedClient {
base_url,
session, session,
client: Client::get_base_client_builder() client: Client::get_base_client_builder(&config)
.default_headers(auth_headers) .default_headers(auth_headers)
.build() .build()
.expect("reqwest client to be built") .expect("reqwest client to be built"),
config
} }
} }
pub async fn verify_auth(&self) -> Result<bool> { pub async fn verify_auth(&self) -> Result<bool> {
let res = self.client let res = self.client
.get(self.base_url.join("agg/user")?) .get(self.config.base_url.join("agg/user")?)
.send().await?; .send().await?;
Ok(res.status() == 200) Ok(res.status() == 200)
} }
pub async fn get_user_details(&self) -> Result<serde_json::Value> { pub async fn get_user_details(&self) -> Result<serde_json::Value> {
let res = self.client let res = self.client
.get(self.base_url.join("agg/user")?) .get(self.config.base_url.join("agg/user")?)
.send().await?; .send().await?;
if res.status() != 200 { if res.status() != 200 {
return Err(APIClientError::InvalidStatusCode.into()); return Err(APIClientError::InvalidStatusCode.into());
@ -202,7 +230,7 @@ impl AuthentifiedClient {
async fn simple_fetch(&self, path: String) -> Result<serde_json::Value> { async fn simple_fetch(&self, path: String) -> Result<serde_json::Value> {
let res = self.client let res = self.client
.get(self.base_url.join(path.as_str())?) .get(self.config.base_url.join(path.as_str())?)
.send().await?; .send().await?;
if res.status() != 200 { if res.status() != 200 {
return Err(APIClientError::InvalidStatusCode.into()); return Err(APIClientError::InvalidStatusCode.into());
@ -217,7 +245,7 @@ impl AuthentifiedClient {
let mut continuation_token: Option<String> = None; let mut continuation_token: Option<String> = None;
loop { loop {
let mut url = self.base_url.join(path.as_str())?; let mut url = self.config.base_url.join(path.as_str())?;
if let Some(token) = &continuation_token { if let Some(token) = &continuation_token {
url.query_pairs_mut().append_pair("continuationToken", token); url.query_pairs_mut().append_pair("continuationToken", token);
} }

View file

@ -3,22 +3,26 @@ mod paheko;
mod helloasso; mod helloasso;
use thiserror::Error; use thiserror::Error;
use anyhow::{Context, Result}; use anyhow::{Context, Result, anyhow};
use chrono::prelude::{NaiveDate, DateTime, Utc, Datelike}; use chrono::prelude::{NaiveDate, DateTime, Utc, Datelike};
use strum::Display; use strum::Display;
use serde::{Serialize, Deserialize}; use serde::{Serialize, Deserialize};
use utils::generate_id; use utils::generate_id;
use url::Url;
/// permanent config to store long-term config /// permanent config to store long-term config
/// used to ingest env settings /// used to ingest env settings
/// config loaded from env variables /// config loaded from env variables
#[derive(Deserialize, Serialize, Debug)] #[derive(Deserialize, Serialize, Debug)]
struct Config { struct Config {
helloasso_proxy: Option<String>,
helloasso_email: String, helloasso_email: String,
helloasso_password: String, helloasso_password: String,
paheko_proxy: Option<String>,
paheko_base_url: String, paheko_base_url: String,
paheko_client_id: String, paheko_client_id: String,
paheko_client_secret: String, paheko_client_secret: String,
paheko_accounting_year_id: u64,
} }
// start user cache management // start user cache management
@ -40,6 +44,7 @@ enum LoadError {
FailedToWrite FailedToWrite
} }
const APP_USER_AGENT: &str = "helloasso_paheko_adapter";
fn write_user_cache(cache: &UserCache) -> Result<(), LoadError> { fn write_user_cache(cache: &UserCache) -> Result<(), LoadError> {
let xdg_dirs = xdg::BaseDirectories::with_prefix(env!("CARGO_PKG_NAME")) let xdg_dirs = xdg::BaseDirectories::with_prefix(env!("CARGO_PKG_NAME"))
@ -175,6 +180,14 @@ fn parse_and_get_birthday_year(raw_date: String) -> Option<u32> {
d.year().try_into().ok() d.year().try_into().ok()
} }
fn get_proxy_from_url(proxy_url: Option<String>) -> Result<Option<reqwest::Proxy>> {
Ok(match proxy_url {
Some(p) => Some(reqwest::Proxy::all(&p)
.context("Expected to build Proxy from paheko_proxy config value")?),
None => None
})
}
async fn launch_adapter() -> Result<()> { async fn launch_adapter() -> Result<()> {
dotenvy::dotenv()?; dotenvy::dotenv()?;
@ -182,7 +195,14 @@ async fn launch_adapter() -> Result<()> {
let mut user_cache = load_user_cache().context("Failed to load user cache")?; let mut user_cache = load_user_cache().context("Failed to load user cache")?;
let mut paheko_client: paheko::Client = paheko::Client::new(config.paheko_base_url); if !&config.paheko_base_url.ends_with("/") {
return Err(anyhow!("Invalid paheko base_url, it must end with a slash"))
}
let mut paheko_client: paheko::Client = paheko::Client::new(paheko::ClientConfig {
base_url: Url::parse(&config.paheko_base_url).expect("Expected paheko base url to be a valid URL"),
proxy: get_proxy_from_url(config.paheko_proxy)?,
user_agent: APP_USER_AGENT.to_string()
});
let paheko_credentials = paheko::Credentials { let paheko_credentials = paheko::Credentials {
client_id: config.paheko_client_id, client_id: config.paheko_client_id,
@ -190,8 +210,12 @@ async fn launch_adapter() -> Result<()> {
}; };
let paheko_client: paheko::AuthentifiedClient = paheko_client.login(paheko_credentials).await?; let paheko_client: paheko::AuthentifiedClient = paheko_client.login(paheko_credentials).await?;
let mut ha_client: helloasso::Client = helloasso::Client::new(helloasso::ClientConfig {
let mut ha_client: helloasso::Client = Default::default(); base_url: Url::parse("https://api.helloasso.com/v5/")
.expect("Expected valid helloasso API base URL"),
proxy: get_proxy_from_url(config.helloasso_proxy)?,
user_agent: "Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/112.0".to_string()
});
let login_payload = helloasso::LoginPayload { let login_payload = helloasso::LoginPayload {
email: config.helloasso_email, email: config.helloasso_email,
@ -378,6 +402,7 @@ async fn launch_adapter() -> Result<()> {
// add transaction // add transaction
let transaction = paheko::SimpleTransaction { let transaction = paheko::SimpleTransaction {
accounting_year: utils::Id(config.paheko_accounting_year_id),
// TODO: make the label template configurable // TODO: make the label template configurable
label: format!("Adhésion {:?} via HelloAsso", pk_membership.mode_name), label: format!("Adhésion {:?} via HelloAsso", pk_membership.mode_name),
amount: pk_membership.payed_amount, amount: pk_membership.payed_amount,
@ -392,7 +417,7 @@ async fn launch_adapter() -> Result<()> {
linked_services: pk_user_service_registrations.iter().map(|x| x.id.clone()).collect() linked_services: pk_user_service_registrations.iter().map(|x| x.id.clone()).collect()
}; };
let _ = paheko_client.register_transaction(transaction) let _ = paheko_client.register_transaction(transaction)
.await.context("Expected to create new paheko transaction"); .await.context("Expected to create new paheko transaction")?;
eprintln!(" Created paheko transaction"); eprintln!(" Created paheko transaction");
pk_memberships.push(pk_membership); pk_memberships.push(pk_membership);

View file

@ -92,17 +92,10 @@ struct SimpleTransaction {
debit_account_code: String, debit_account_code: String,
reference: String, reference: String,
linked_users: Vec<Id>, linked_users: Vec<Id>,
linked_services: Vec<Id> linked_services: Vec<Id>,
accounting_year: Id
} }
#[derive(Debug)]
#[fully_pub]
struct Client {
client: reqwest::Client,
base_url: Url,
}
#[derive(Error, Debug)] #[derive(Error, Debug)]
enum APIClientError { enum APIClientError {
#[error("Received non-normal status code from API")] #[error("Received non-normal status code from API")]
@ -116,15 +109,40 @@ struct Credentials {
client_secret: String client_secret: String
} }
#[derive(Debug, Clone)]
#[fully_pub]
struct ClientConfig {
base_url: Url,
proxy: Option<reqwest::Proxy>,
user_agent: String
}
impl Default for ClientConfig {
fn default() -> Self {
ClientConfig {
proxy: None,
base_url: Url::parse("https://paheko.example.org/api/") // the traling slash is important
.expect("Expected valid paheko API base URL"),
user_agent: "".to_string()
}
}
}
#[derive(Debug)]
#[fully_pub]
struct Client {
client: reqwest::Client,
config: ClientConfig
}
impl Default for Client { impl Default for Client {
fn default() -> Self { fn default() -> Self {
let base_config: ClientConfig = Default::default();
Client { Client {
client: Client::get_base_client_builder() client: Client::get_base_client_builder(&base_config)
.build() .build()
.expect("Expected reqwest client to be built"), .expect("Expected reqwest client to be built"),
base_url: Url::parse("https://paheko.etoiledebethleem.fr/api/") // the traling slash is important config: base_config
.expect("Expected valid paheko API base URL")
} }
} }
} }
@ -143,25 +161,26 @@ fn build_auth_headers(credentials: &Credentials) -> reqwest::header::HeaderMap {
} }
impl Client { impl Client {
pub fn new(base_url: String) -> Client { pub fn new(config: ClientConfig) -> Client {
Client { Client {
client: Client::get_base_client_builder() client: Client::get_base_client_builder(&config)
.build() .build()
.expect("Expected reqwest client to be built"), .expect("Expected reqwest client to be built"),
base_url: Url::parse(&base_url) // the traling slash is important config
.expect("Expected valid paheko API base URL")
} }
} }
fn get_base_client_builder() -> reqwest::ClientBuilder { fn get_base_client_builder(config: &ClientConfig) -> reqwest::ClientBuilder {
let mut default_headers = reqwest::header::HeaderMap::new(); let mut default_headers = reqwest::header::HeaderMap::new();
default_headers.insert("Accept", "application/json".parse().unwrap()); default_headers.insert("Accept", "application/json".parse().unwrap());
default_headers.insert("User-Agent", "helloasso_paheko_adapter".parse().unwrap()); default_headers.insert("User-Agent", config.user_agent.parse().unwrap());
// let proxy = reqwest::Proxy::http("http://localhost:8998").unwrap(); let mut builder = reqwest::Client::builder()
reqwest::Client::builder() .default_headers(default_headers);
// .proxy(proxy) if let Some(proxy) = &config.proxy {
.default_headers(default_headers) builder = builder.proxy(proxy.clone());
}
builder
} }
pub async fn login(&mut self, credentials: Credentials) -> Result<AuthentifiedClient> { pub async fn login(&mut self, credentials: Credentials) -> Result<AuthentifiedClient> {
@ -181,16 +200,16 @@ impl Client {
} }
pub fn authentified_client(&self, credentials: Credentials) -> AuthentifiedClient { pub fn authentified_client(&self, credentials: Credentials) -> AuthentifiedClient {
AuthentifiedClient::new(self.base_url.clone(), credentials) AuthentifiedClient::new(self.config.clone(), credentials)
} }
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct AuthentifiedClient { pub struct AuthentifiedClient {
credentials: Credentials, _credentials: Credentials,
client: reqwest::Client, client: reqwest::Client,
base_url: Url config: ClientConfig
} }
// SELECT id,nom AS first_name,last_name,email,external_custom_data FROM users LIMIT 5; // SELECT id,nom AS first_name,last_name,email,external_custom_data FROM users LIMIT 5;
@ -225,14 +244,14 @@ struct UserServiceRegistration {
} }
impl AuthentifiedClient { impl AuthentifiedClient {
pub fn new(base_url: Url, credentials: Credentials) -> Self { pub fn new(config: ClientConfig, credentials: Credentials) -> Self {
AuthentifiedClient { AuthentifiedClient {
client: Client::get_base_client_builder() client: Client::get_base_client_builder(&config)
.default_headers(build_auth_headers(&credentials)) .default_headers(build_auth_headers(&credentials))
.build() .build()
.expect("Expect client to be built"), .expect("Expect client to be built"),
credentials, _credentials: credentials,
base_url config
} }
} }
@ -242,7 +261,7 @@ impl AuthentifiedClient {
sql: String sql: String
} }
let payload = Payload { sql: query }; let payload = Payload { sql: query };
let path = self.base_url.join("sql")?; let path = self.config.base_url.join("sql")?;
let res = self.client let res = self.client
.post(path) .post(path)
.json(&payload) .json(&payload)
@ -329,7 +348,7 @@ impl AuthentifiedClient {
.part("file", part); .part("file", part);
let res = self.client let res = self.client
.post(self.base_url.join("user/import/")?) .post(self.config.base_url.join("user/import/")?)
.multipart(form) .multipart(form)
.send().await?; .send().await?;
@ -377,7 +396,7 @@ impl AuthentifiedClient {
.part("file", part); .part("file", part);
let res = self.client let res = self.client
.post(self.base_url.join("services/subscriptions/import")?) .post(self.config.base_url.join("services/subscriptions/import")?)
.multipart(form) .multipart(form)
.send().await?; .send().await?;
@ -395,25 +414,27 @@ impl AuthentifiedClient {
use reqwest::multipart::Form; use reqwest::multipart::Form;
let mut form = Form::new() let mut form = Form::new()
.text("id_year", "1") .text("id_year", transaction.accounting_year.to_string())
.text("label", transaction.label) .text("label", transaction.label)
.text("date", transaction.inception_time.format("%d/%m/%Y").to_string()) .text("date", transaction.inception_time.format("%d/%m/%Y").to_string())
.text("type", Into::<String>::into(transaction.kind)) .text("type", Into::<String>::into(transaction.kind))
.text("amount", format!("{}", transaction.amount)) .text("amount", format!("{}", transaction.amount))
.text("debit", transaction.debit_account_code) .text("debit", transaction.debit_account_code)
.text("credit", transaction.credit_account_code) .text("credit", transaction.credit_account_code)
.text("reference", transaction.reference) // "Numéro pièce comptable" enregistré au niveau de la transaction
.text("reference", transaction.reference)
; ;
for linked_id in transaction.linked_users { for linked_id in transaction.linked_users {
form = form.text("linked_users[]", format!("{}", linked_id.0)); form = form.text("linked_users[]", format!("{}", linked_id.0));
} }
// only possible with paheko fork
for linked_id in transaction.linked_services { for linked_id in transaction.linked_services {
form = form.text("linked_services[]", format!("{}", linked_id.0)); form = form.text("linked_services[]", format!("{}", linked_id.0));
} }
let res = self.client let res = self.client
.post(self.base_url.join("accounting/transaction")?) .post(self.config.base_url.join("accounting/transaction")?)
.multipart(form) .multipart(form)
.send().await?; .send().await?;