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.
This commit is contained in:
2026-03-02 00:14:50 +02:00
parent a3ffc531b9
commit e342272dbe
2 changed files with 285 additions and 0 deletions

View File

@@ -387,6 +387,39 @@ impl Database {
rows.collect() rows.collect()
} }
// -- Exchange Rates --
pub fn get_cached_rate(&self, base: &str, target: &str) -> SqlResult<Option<ExchangeRate>> {
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<String, f64>) -> 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 -- // -- Settings --
pub fn get_setting(&self, key: &str) -> SqlResult<Option<String>> { pub fn get_setting(&self, key: &str) -> SqlResult<Option<String>> {

View File

@@ -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<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);
}
}