use crate::models::{AccountArray, TransactionArray, TransactionStore, TransactionUpdate}; use reqwest::Url; use reqwest_middleware::ClientWithMiddleware; use serde::de::DeserializeOwned; use thiserror::Error; use tracing::instrument; #[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) } }