use std::collections::HashMap;
use async_trait::async_trait;
use serde::Deserialize;
use crate::price::config::ProviderConfig;
use crate::price::provider::{PriceProvider, ProviderError, ProviderId, ProviderQuotes, Quote};
#[derive(Debug, Deserialize)]
struct CurrencyApiResponse {
btc: HashMap<String, Option<f64>>,
}
#[derive(Debug)]
pub struct CurrencyApiProvider {
urls: Vec<String>,
}
impl CurrencyApiProvider {
pub fn new(cfg: &ProviderConfig) -> Self {
let urls = std::iter::once(&cfg.url)
.chain(cfg.fallback_urls.iter())
.map(|u| u.trim_end_matches('/').to_string())
.filter(|u| !u.is_empty())
.collect();
Self { urls }
}
pub(crate) fn parse(body: &str) -> Result<ProviderQuotes, ProviderError> {
let parsed: CurrencyApiResponse = serde_json::from_str(body)
.map_err(|e| ProviderError::Parse(format!("currency_api: {e}")))?;
Ok(parsed
.btc
.into_iter()
.filter_map(|(code, value)| match value {
Some(v) if v.is_finite() && v > 0.0 => {
Some((code.to_uppercase(), Quote::PerBtc(v)))
}
_ => None,
})
.collect())
}
async fn fetch_one(
&self,
http: &reqwest::Client,
base: &str,
) -> Result<ProviderQuotes, ProviderError> {
let url = format!("{base}/currencies/btc.min.json");
let res = http
.get(&url)
.send()
.await
.map_err(|e| ProviderError::Http(format!("currency_api GET {url}: {e}")))?;
if !res.status().is_success() {
return Err(ProviderError::Http(format!(
"currency_api GET {url}: status {}",
res.status()
)));
}
let body = res
.text()
.await
.map_err(|e| ProviderError::Http(format!("currency_api read body: {e}")))?;
Self::parse(&body)
}
}
#[async_trait]
impl PriceProvider for CurrencyApiProvider {
fn id(&self) -> ProviderId {
ProviderId::CurrencyApi
}
async fn fetch(&self, http: &reqwest::Client) -> Result<ProviderQuotes, ProviderError> {
let mut last_err =
ProviderError::Misconfigured("currency_api: no usable url configured".into());
for base in &self.urls {
match self.fetch_one(http, base).await {
Ok(quotes) => return Ok(quotes),
Err(e) => {
tracing::warn!("price: currency_api mirror {base} failed: {e}");
last_err = e;
}
}
}
Err(last_err)
}
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE_PAYLOAD: &str =
include_str!("../../../tests/fixtures/price/currency_api_btc.json");
fn cfg(url: &str, fallbacks: Vec<String>) -> ProviderConfig {
ProviderConfig {
enabled: true,
url: url.into(),
fallback_urls: fallbacks,
api_key: None,
token: None,
only: None,
except: None,
}
}
#[test]
fn parses_captured_payload_and_uppercases_codes() {
let quotes = CurrencyApiProvider::parse(SAMPLE_PAYLOAD).expect("fixture must parse");
assert!(quotes.contains_key("USD"));
assert!(quotes.contains_key("EUR"));
assert!(!quotes.contains_key("usd"), "no lowercase keys may leak");
assert!(quotes.contains_key("ETH"));
assert!(quotes.contains_key("CUP"));
let cup = match quotes.get("CUP").unwrap() {
Quote::PerBtc(v) => *v,
_ => unreachable!(),
};
let usd = match quotes.get("USD").unwrap() {
Quote::PerBtc(v) => *v,
_ => unreachable!(),
};
let cup_per_usd = cup / usd;
assert!(
(20.0..40.0).contains(&cup_per_usd),
"captured CUP should be the official ~26 CUP/USD rate, got {cup_per_usd}"
);
}
#[test]
fn drops_null_and_non_positive() {
let body = r#"{"date":"2026-06-11","btc":{"usd":null,"eur":-1,"gbp":47000.5}}"#;
let quotes = CurrencyApiProvider::parse(body).unwrap();
assert_eq!(quotes.len(), 1);
assert_eq!(quotes.get("GBP"), Some(&Quote::PerBtc(47_000.5)));
}
#[test]
fn parse_error_is_returned() {
assert!(matches!(
CurrencyApiProvider::parse("not json").unwrap_err(),
ProviderError::Parse(_)
));
}
#[test]
fn url_order_is_primary_then_fallbacks() {
let p = CurrencyApiProvider::new(&cfg(
"https://currency-api.pages.dev/v1/",
vec!["https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/v1".into()],
));
assert_eq!(
p.urls,
vec![
"https://currency-api.pages.dev/v1",
"https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/v1"
]
);
}
#[tokio::test]
async fn fallback_url_is_tried_before_failing() {
use axum::{routing::get, Router};
let app = Router::new().route(
"/v1/currencies/btc.min.json",
get(|| async { SAMPLE_PAYLOAD }),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
let p = CurrencyApiProvider::new(&cfg(
"http://127.0.0.1:9/v1",
vec![format!("http://{addr}/v1")],
));
let http = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(5))
.build()
.unwrap();
let quotes = p.fetch(&http).await.expect("mirror must carry the fetch");
assert!(quotes.contains_key("USD"));
}
#[tokio::test]
async fn hanging_primary_does_not_starve_the_mirror() {
use axum::{routing::get, Router};
let hang = Router::new().route(
"/v1/currencies/btc.min.json",
get(|| async {
tokio::time::sleep(std::time::Duration::from_secs(30)).await;
SAMPLE_PAYLOAD
}),
);
let hang_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let hang_addr = hang_listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(hang_listener, hang).await.unwrap();
});
let ok = Router::new().route(
"/v1/currencies/btc.min.json",
get(|| async { SAMPLE_PAYLOAD }),
);
let ok_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let ok_addr = ok_listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(ok_listener, ok).await.unwrap();
});
let p = CurrencyApiProvider::new(&cfg(
&format!("http://{hang_addr}/v1"),
vec![format!("http://{ok_addr}/v1")],
));
let http = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(1))
.build()
.unwrap();
let started = std::time::Instant::now();
let quotes = p
.fetch(&http)
.await
.expect("mirror must carry the fetch despite the hung primary");
assert!(quotes.contains_key("USD"));
assert!(
started.elapsed() < std::time::Duration::from_secs(5),
"hung primary must be cut by the per-attempt timeout, not ride forever"
);
}
#[tokio::test]
async fn all_urls_failing_is_one_provider_error() {
let p = CurrencyApiProvider::new(&cfg(
"http://127.0.0.1:9/v1",
vec!["http://127.0.0.1:9/v2".into()],
));
let http = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(5))
.build()
.unwrap();
assert!(matches!(
p.fetch(&http).await.unwrap_err(),
ProviderError::Http(_)
));
}
}