use reqwest::Url; use reqwest_middleware::ClientWithMiddleware; use serde::de::DeserializeOwned; use thiserror::Error; use tracing::instrument; use crate::models::{AccountArray, TransactionStore, TransactionArray, TransactionUpdate}; #[derive(Error, Debug)] pub enum FireflyError { #[error("Request failed: {0}")] RequestFailed(#[from] reqwest::Error), #[error("Middleware error: {0}")] MiddlewareError(#[from] reqwest_middleware::Error), #[error("API Error: {0}")] ApiError(String), #[error("URL Parse Error: {0}")] UrlParseError(#[from] url::ParseError), } pub struct FireflyClient { base_url: Url, client: ClientWithMiddleware, access_token: String, } impl FireflyClient { pub fn new(base_url: &str, access_token: &str) -> Result { Self::with_client(base_url, access_token, None) } pub fn with_client(base_url: &str, access_token: &str, client: Option) -> Result { Ok(Self { base_url: Url::parse(base_url)?, client: client.unwrap_or_else(|| reqwest_middleware::ClientBuilder::new(reqwest::Client::new()).build()), access_token: access_token.to_string(), }) } #[instrument(skip(self))] pub async fn get_accounts(&self, _iban: &str) -> Result { let mut url = self.base_url.join("/api/v1/accounts")?; url.query_pairs_mut() .append_pair("type", "asset"); self.get_authenticated(url).await } #[instrument(skip(self))] pub async fn search_accounts(&self, query: &str) -> Result { let mut url = self.base_url.join("/api/v1/search/accounts")?; url.query_pairs_mut() .append_pair("query", query) .append_pair("type", "asset") .append_pair("field", "all"); self.get_authenticated(url).await } #[instrument(skip(self, transaction))] pub async fn store_transaction(&self, transaction: TransactionStore) -> Result<(), FireflyError> { let url = self.base_url.join("/api/v1/transactions")?; let response = self.client.post(url) .bearer_auth(&self.access_token) .header("accept", "application/json") .json(&transaction) .send() .await?; if !response.status().is_success() { let status = response.status(); let text = response.text().await?; return Err(FireflyError::ApiError(format!("Store Transaction Failed {}: {}", status, text))); } Ok(()) } #[instrument(skip(self))] pub async fn list_account_transactions(&self, account_id: &str, start: Option<&str>, end: Option<&str>) -> Result { let mut url = self.base_url.join(&format!("/api/v1/accounts/{}/transactions", account_id))?; { let mut pairs = url.query_pairs_mut(); if let Some(s) = start { pairs.append_pair("start", s); } if let Some(e) = end { pairs.append_pair("end", e); } // Limit to 50, could be higher but safer to page if needed. For heuristic checks 50 is usually plenty per day range. pairs.append_pair("limit", "50"); } self.get_authenticated(url).await } #[instrument(skip(self, update))] pub async fn update_transaction(&self, id: &str, update: TransactionUpdate) -> Result<(), FireflyError> { let url = self.base_url.join(&format!("/api/v1/transactions/{}", id))?; let response = self.client.put(url) .bearer_auth(&self.access_token) .header("accept", "application/json") .json(&update) .send() .await?; if !response.status().is_success() { let status = response.status(); let text = response.text().await?; return Err(FireflyError::ApiError(format!("Update Transaction Failed {}: {}", status, text))); } Ok(()) } async fn get_authenticated(&self, url: Url) -> Result { let response = self.client.get(url) .bearer_auth(&self.access_token) .header("accept", "application/json") .send() .await?; if !response.status().is_success() { let status = response.status(); let text = response.text().await?; return Err(FireflyError::ApiError(format!("API request failed {}: {}", status, text))); } let data = response.json().await?; Ok(data) } }