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:
- more configuration options
- configurable proxy
- summary of the operations at the end of run
- how many users were added, muted?
- conjoined user: add attached member to paheko
- "Membre lié"
- 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
}
#[derive(Debug)]
#[derive(Debug, Clone)]
#[fully_pub]
struct Client {
client: reqwest::Client,
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://api.helloasso.com/v5/") // the traling slash is important
.expect("Expected valid helloasso API base URL"),
user_agent: "".to_string()
}
}
}
#[derive(Serialize, Debug)]
@ -33,31 +45,47 @@ struct LoginPayload {
password: String
}
#[derive(Debug)]
#[fully_pub]
struct Client {
client: reqwest::Client,
config: ClientConfig
}
impl Default for Client {
fn default() -> Self {
let base_config: ClientConfig = Default::default();
Client {
client: Client::get_base_client_builder()
client: Client::get_base_client_builder(&base_config)
.build()
.expect("reqwest client to be built"),
base_url: Url::parse("https://api.helloasso.com/v5/")
.expect("Valid helloasso API base URL")
.expect("Expected reqwest client to be built"),
config: base_config
}
}
}
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();
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());
default_headers.insert("User-Agent", config.user_agent.parse().unwrap());
// TODO: configurable proxy
// let proxy = reqwest::Proxy::https("https://localhost:8999").unwrap();
reqwest::Client::builder()
// .proxy(proxy)
.default_headers(default_headers)
let mut builder = reqwest::Client::builder()
.default_headers(default_headers);
if let Some(proxy) = &config.proxy {
builder = builder.proxy(proxy.clone());
}
builder
}
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")
);
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())
.send().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)
.headers(login_commons_headers.clone())
.header("x-csrf-token", antiforgerytoken)
@ -107,7 +135,7 @@ impl Client {
}
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 {
session: WebSession,
client: reqwest::Client,
base_url: Url
config: ClientConfig
}
#[derive(Debug, Serialize, Deserialize)]
@ -167,30 +195,30 @@ struct UserDetails {
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 {
pub fn new(config: ClientConfig, 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()
client: Client::get_base_client_builder(&config)
.default_headers(auth_headers)
.build()
.expect("reqwest client to be built")
.expect("reqwest client to be built"),
config
}
}
pub async fn verify_auth(&self) -> Result<bool> {
let res = self.client
.get(self.base_url.join("agg/user")?)
.get(self.config.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")?)
.get(self.config.base_url.join("agg/user")?)
.send().await?;
if res.status() != 200 {
return Err(APIClientError::InvalidStatusCode.into());
@ -202,7 +230,7 @@ impl AuthentifiedClient {
async fn simple_fetch(&self, path: String) -> Result<serde_json::Value> {
let res = self.client
.get(self.base_url.join(path.as_str())?)
.get(self.config.base_url.join(path.as_str())?)
.send().await?;
if res.status() != 200 {
return Err(APIClientError::InvalidStatusCode.into());
@ -217,7 +245,7 @@ impl AuthentifiedClient {
let mut continuation_token: Option<String> = None;
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 {
url.query_pairs_mut().append_pair("continuationToken", token);
}

View file

@ -3,22 +3,26 @@ mod paheko;
mod helloasso;
use thiserror::Error;
use anyhow::{Context, Result};
use anyhow::{Context, Result, anyhow};
use chrono::prelude::{NaiveDate, DateTime, Utc, Datelike};
use strum::Display;
use serde::{Serialize, Deserialize};
use utils::generate_id;
use url::Url;
/// permanent config to store long-term config
/// used to ingest env settings
/// config loaded from env variables
#[derive(Deserialize, Serialize, Debug)]
struct Config {
helloasso_proxy: Option<String>,
helloasso_email: String,
helloasso_password: String,
paheko_proxy: Option<String>,
paheko_base_url: String,
paheko_client_id: String,
paheko_client_secret: String,
paheko_accounting_year_id: u64,
}
// start user cache management
@ -40,6 +44,7 @@ enum LoadError {
FailedToWrite
}
const APP_USER_AGENT: &str = "helloasso_paheko_adapter";
fn write_user_cache(cache: &UserCache) -> Result<(), LoadError> {
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()
}
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<()> {
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 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 {
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 mut ha_client: helloasso::Client = Default::default();
let mut ha_client: helloasso::Client = helloasso::Client::new(helloasso::ClientConfig {
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 {
email: config.helloasso_email,
@ -378,6 +402,7 @@ async fn launch_adapter() -> Result<()> {
// add transaction
let transaction = paheko::SimpleTransaction {
accounting_year: utils::Id(config.paheko_accounting_year_id),
// TODO: make the label template configurable
label: format!("Adhésion {:?} via HelloAsso", pk_membership.mode_name),
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()
};
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");
pk_memberships.push(pk_membership);

View file

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