Files
outlay/outlay-core/src/exchange.rs
lashman e342272dbe 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.
2026-03-02 00:14:50 +02:00

253 lines
8.0 KiB
Rust

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<f64, ExchangeError> {
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<HashMap<String, f64>, 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<HashMap<String, f64>, 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<HashMap<String, f64>> {
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<HashMap<String, f64>> {
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);
}
}