#![feature(slice_group_by)] mod utils; mod paheko; mod helloasso; mod sync_helloasso; mod sync_csv; mod sync_paheko; #[cfg(test)] mod test_utils; use thiserror::Error; use anyhow::{Context, Result, anyhow}; use strum::Display; use serde::{Serialize, Deserialize}; use url::Url; use fully_pub::fully_pub; use argh::FromArgs; /// permanent config to store long-term config /// used to ingest env settings /// config loaded from env variables #[derive(Deserialize, Serialize, Debug)] #[fully_pub] struct Config { helloasso_proxy: Option<String>, helloasso_email: String, helloasso_password: String, helloasso_organization_slug: String, helloasso_form_name: String, paheko_proxy: Option<String>, paheko_base_url: String, paheko_client_id: String, paheko_client_secret: String, paheko_target_activity_name: String, paheko_accounting_years_ids: Vec<u32>, } // start user cache management use std::fs; #[derive(Serialize, Deserialize, Debug)] #[fully_pub] struct UserCache { helloasso_session: Option<helloasso::WebSession> } #[derive(Display, Debug, Error)] #[strum(serialize_all = "snake_case")] enum LoadError { XDG, Fs, FailedToParse, FailedToEncode, FailedToCreate, 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")) .map_err(|_e| { LoadError::XDG })?; let user_cache_path = xdg_dirs.place_cache_file("session.json").map_err(|_e| { LoadError::FailedToCreate })?; let encoded_cache = serde_json::to_string(&cache).map_err(|_e| { LoadError::FailedToEncode })?; fs::write(user_cache_path, encoded_cache.as_str()).map_err(|_e| { LoadError::FailedToWrite })?; Ok(()) } fn load_user_cache() -> Result<UserCache, LoadError> { let xdg_dirs = xdg::BaseDirectories::with_prefix(env!("CARGO_PKG_NAME")) .map_err(|_e| { LoadError::XDG })?; let user_cache_path = xdg_dirs.get_cache_file("session.json"); if !user_cache_path.exists() { let default_cache = UserCache { helloasso_session: None }; write_user_cache(&default_cache)?; } let session_content = fs::read_to_string(user_cache_path).map_err(|_e| { LoadError::Fs })?; let cache: UserCache = serde_json::from_str(&session_content).map_err(|_e| { LoadError::FailedToParse })?; Ok(cache) } 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(source: SourceType, config: &Config) -> Result<()> { let mut user_cache = load_user_cache().context("Failed to load user cache")?; 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.clone(), client_secret: config.paheko_client_secret.clone() }; let paheko_client: paheko::AuthentifiedClient = paheko_client.login(paheko_credentials).await?; match source { SourceType::Csv => sync_csv::sync_csv(&paheko_client, config, &mut user_cache).await?, SourceType::Helloasso => sync_helloasso::sync_helloasso(&paheko_client, config, &mut user_cache).await? } Ok(()) } #[derive(FromArgs)] /// Members and Membership sync adaper for paheko (support Hellosso and CSV) struct App { /// the source of sync (CSV or helloasso) #[argh(option, short = 'm')] source: Option<String>, /// output debug info #[argh(switch, short = 'i')] info: bool } enum SourceType { Helloasso, Csv } #[tokio::main] async fn main() { let app: App = argh::from_env(); dotenvy::dotenv().expect("Could not load dot env file"); let config: Config = envy::from_env().expect("Failed to load env vars"); if app.info { dbg!(config); return; } let source = match app.source.unwrap().as_ref() { "helloasso" => SourceType::Helloasso, "csv" => SourceType::Csv, _ => { eprintln!("Must provide a valid source argument."); return; } }; let res = launch_adapter(source, &config).await; match res { Err(err) => { eprintln!("Program failed, details bellow"); eprintln!("{:?}", err); }, Ok(()) => { eprintln!("Program done"); } } }