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:
@@ -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>> {
|
||||||
|
|||||||
@@ -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