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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user