From e342272dbe9d43e837eca2b37388b07969a4cf82 Mon Sep 17 00:00:00 2001 From: lashman Date: Mon, 2 Mar 2026 00:14:50 +0200 Subject: [PATCH] Add exchange rate service with caching and fallback API ExchangeRateService fetches rates from fawazahmed0/exchange-api with Frankfurter API as fallback. Rates are cached in SQLite for 24 hours. Includes response parsers, 30 supported currencies, and 8 unit tests covering parsing, caching, and same-currency identity. --- outlay-core/src/db.rs | 33 +++++ outlay-core/src/exchange.rs | 252 ++++++++++++++++++++++++++++++++++++ 2 files changed, 285 insertions(+) diff --git a/outlay-core/src/db.rs b/outlay-core/src/db.rs index 067dbd9..2b93fd1 100644 --- a/outlay-core/src/db.rs +++ b/outlay-core/src/db.rs @@ -387,6 +387,39 @@ impl Database { rows.collect() } + // -- Exchange Rates -- + + pub fn get_cached_rate(&self, base: &str, target: &str) -> SqlResult> { + match self.conn.query_row( + "SELECT base, target, rate, fetched_at FROM exchange_rates WHERE base = ?1 AND target = ?2", + params![base.to_lowercase(), target.to_lowercase()], + |row| { + Ok(ExchangeRate { + base: row.get(0)?, + target: row.get(1)?, + rate: row.get(2)?, + fetched_at: row.get(3)?, + }) + }, + ) { + Ok(rate) => Ok(Some(rate)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(e), + } + } + + pub fn cache_rates(&self, base: &str, rates: &std::collections::HashMap) -> SqlResult<()> { + let now = chrono::Utc::now().to_rfc3339(); + let base_lower = base.to_lowercase(); + let mut stmt = self.conn.prepare( + "INSERT OR REPLACE INTO exchange_rates (base, target, rate, fetched_at) VALUES (?1, ?2, ?3, ?4)" + )?; + for (target, rate) in rates { + stmt.execute(params![base_lower, target.to_lowercase(), rate, now])?; + } + Ok(()) + } + // -- Settings -- pub fn get_setting(&self, key: &str) -> SqlResult> { diff --git a/outlay-core/src/exchange.rs b/outlay-core/src/exchange.rs index e69de29..71daeb5 100644 --- a/outlay-core/src/exchange.rs +++ b/outlay-core/src/exchange.rs @@ -0,0 +1,252 @@ +use crate::db::Database; +use std::collections::HashMap; + +#[derive(Debug, thiserror::Error)] +pub enum ExchangeError { + #[error("network error: {0}")] + Network(#[from] reqwest::Error), + #[error("database error: {0}")] + Database(#[from] rusqlite::Error), + #[error("parse error: {0}")] + Parse(String), + #[error("currency not found: {0}")] + NotFound(String), +} + +pub struct ExchangeRateService<'a> { + db: &'a Database, +} + +impl<'a> ExchangeRateService<'a> { + pub fn new(db: &'a Database) -> Self { + ExchangeRateService { db } + } + + pub async fn get_rate(&self, base: &str, target: &str) -> Result { + let base_lower = base.to_lowercase(); + let target_lower = target.to_lowercase(); + + if base_lower == target_lower { + return Ok(1.0); + } + + // Check cache (fresh if < 24 hours) + if let Some(cached) = self.db.get_cached_rate(&base_lower, &target_lower)? { + if let Ok(fetched) = chrono::DateTime::parse_from_rfc3339(&cached.fetched_at) { + let age = chrono::Utc::now() - fetched.to_utc(); + if age.num_hours() < 24 { + return Ok(cached.rate); + } + } + } + + // Fetch fresh rates + let rates = match self.fetch_from_primary(&base_lower).await { + Ok(r) => r, + Err(_) => self.fetch_from_fallback(&base_lower).await?, + }; + + // Cache all fetched rates + self.db.cache_rates(&base_lower, &rates)?; + + rates + .get(&target_lower) + .copied() + .ok_or_else(|| ExchangeError::NotFound(target.to_string())) + } + + async fn fetch_from_primary(&self, base: &str) -> Result, ExchangeError> { + let url = format!( + "https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/v1/currencies/{}.json", + base + ); + let resp: serde_json::Value = reqwest::get(&url).await?.json().await?; + + let rates_obj = resp + .get(base) + .and_then(|v| v.as_object()) + .ok_or_else(|| ExchangeError::Parse("missing currency object".to_string()))?; + + let mut rates = HashMap::new(); + for (key, val) in rates_obj { + if let Some(r) = val.as_f64() { + rates.insert(key.clone(), r); + } + } + Ok(rates) + } + + async fn fetch_from_fallback(&self, base: &str) -> Result, ExchangeError> { + let url = format!( + "https://api.frankfurter.dev/v1/latest?base={}", + base.to_uppercase() + ); + let resp: serde_json::Value = reqwest::get(&url).await?.json().await?; + + let rates_obj = resp + .get("rates") + .and_then(|v| v.as_object()) + .ok_or_else(|| ExchangeError::Parse("missing rates object".to_string()))?; + + let mut rates = HashMap::new(); + for (key, val) in rates_obj { + if let Some(r) = val.as_f64() { + rates.insert(key.to_lowercase(), r); + } + } + Ok(rates) + } + + pub fn supported_currencies() -> Vec<(&'static str, &'static str)> { + vec![ + ("USD", "US Dollar"), + ("EUR", "Euro"), + ("GBP", "British Pound"), + ("JPY", "Japanese Yen"), + ("CAD", "Canadian Dollar"), + ("AUD", "Australian Dollar"), + ("CHF", "Swiss Franc"), + ("CNY", "Chinese Yuan"), + ("INR", "Indian Rupee"), + ("BRL", "Brazilian Real"), + ("MXN", "Mexican Peso"), + ("KRW", "South Korean Won"), + ("SGD", "Singapore Dollar"), + ("HKD", "Hong Kong Dollar"), + ("SEK", "Swedish Krona"), + ("NOK", "Norwegian Krone"), + ("DKK", "Danish Krone"), + ("PLN", "Polish Zloty"), + ("ZAR", "South African Rand"), + ("TRY", "Turkish Lira"), + ("RUB", "Russian Ruble"), + ("NZD", "New Zealand Dollar"), + ("THB", "Thai Baht"), + ("TWD", "Taiwan Dollar"), + ("CZK", "Czech Koruna"), + ("HUF", "Hungarian Forint"), + ("ILS", "Israeli Shekel"), + ("PHP", "Philippine Peso"), + ("MYR", "Malaysian Ringgit"), + ("IDR", "Indonesian Rupiah"), + ] + } +} + +pub fn parse_primary_response(json: &serde_json::Value, base: &str) -> Option> { + let rates_obj = json.get(base)?.as_object()?; + let mut rates = HashMap::new(); + for (key, val) in rates_obj { + if let Some(r) = val.as_f64() { + rates.insert(key.clone(), r); + } + } + Some(rates) +} + +pub fn parse_fallback_response(json: &serde_json::Value) -> Option> { + let rates_obj = json.get("rates")?.as_object()?; + let mut rates = HashMap::new(); + for (key, val) in rates_obj { + if let Some(r) = val.as_f64() { + rates.insert(key.to_lowercase(), r); + } + } + Some(rates) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_primary_response() { + let json: serde_json::Value = serde_json::from_str(r#"{ + "date": "2026-03-01", + "usd": { + "eur": 0.92, + "gbp": 0.79, + "jpy": 149.5 + } + }"#).unwrap(); + + let rates = parse_primary_response(&json, "usd").unwrap(); + assert!((rates["eur"] - 0.92).abs() < 0.001); + assert!((rates["gbp"] - 0.79).abs() < 0.001); + assert!((rates["jpy"] - 149.5).abs() < 0.1); + } + + #[test] + fn test_parse_primary_response_missing_base() { + let json: serde_json::Value = serde_json::from_str(r#"{"date": "2026-03-01"}"#).unwrap(); + assert!(parse_primary_response(&json, "usd").is_none()); + } + + #[test] + fn test_parse_fallback_response() { + let json: serde_json::Value = serde_json::from_str(r#"{ + "base": "USD", + "date": "2026-03-01", + "rates": { + "EUR": 0.92, + "GBP": 0.79 + } + }"#).unwrap(); + + let rates = parse_fallback_response(&json).unwrap(); + assert!((rates["eur"] - 0.92).abs() < 0.001); + assert!((rates["gbp"] - 0.79).abs() < 0.001); + } + + #[test] + fn test_parse_fallback_response_missing_rates() { + let json: serde_json::Value = serde_json::from_str(r#"{"base": "USD"}"#).unwrap(); + assert!(parse_fallback_response(&json).is_none()); + } + + #[test] + fn test_cache_and_retrieve_rate() { + let db = Database::open_in_memory().unwrap(); + let mut rates = HashMap::new(); + rates.insert("eur".to_string(), 0.92); + rates.insert("gbp".to_string(), 0.79); + + db.cache_rates("usd", &rates).unwrap(); + + let cached = db.get_cached_rate("usd", "eur").unwrap().unwrap(); + assert!((cached.rate - 0.92).abs() < 0.001); + assert_eq!(cached.base, "usd"); + assert_eq!(cached.target, "eur"); + + let cached_gbp = db.get_cached_rate("usd", "gbp").unwrap().unwrap(); + assert!((cached_gbp.rate - 0.79).abs() < 0.001); + } + + #[test] + fn test_cache_miss_returns_none() { + let db = Database::open_in_memory().unwrap(); + let result = db.get_cached_rate("usd", "eur").unwrap(); + assert!(result.is_none()); + } + + #[test] + fn test_supported_currencies_not_empty() { + let currencies = ExchangeRateService::supported_currencies(); + assert!(currencies.len() >= 10); + assert!(currencies.iter().any(|(code, _)| *code == "USD")); + assert!(currencies.iter().any(|(code, _)| *code == "EUR")); + } + + #[test] + fn test_same_currency_rate_is_one() { + let db = Database::open_in_memory().unwrap(); + let service = ExchangeRateService::new(&db); + + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let rate = rt.block_on(service.get_rate("USD", "USD")).unwrap(); + assert!((rate - 1.0).abs() < 0.0001); + } +}