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.
253 lines
8.0 KiB
Rust
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);
|
|
}
|
|
}
|