use std::sync::Arc;
use std::time::Duration;
use reqwest::{Client, StatusCode};
use tracing::debug;
use super::models::{FrankfurterError, FrankfurterTimeSeries};
use crate::adapters::common::{keyless_http_client, status_error};
use crate::error::{FinanceError, Result};
use crate::rate_limiter::RateLimiter;
pub(super) const FRANKFURTER_BASE: &str = "https://api.frankfurter.dev/v1";
const LOOKBACK_DAYS: i64 = 10;
pub(super) struct FrankfurterClient {
http: Client,
limiter: Arc<RateLimiter>,
base_url: String,
}
impl FrankfurterClient {
pub(super) fn new(
timeout: Duration,
limiter: Arc<RateLimiter>,
base_url: impl Into<String>,
) -> Result<Self> {
Ok(Self {
http: keyless_http_client(timeout)?,
limiter,
base_url: base_url.into(),
})
}
pub(super) async fn recent_rates(&self, base: &str, quote: &str) -> Result<Vec<(String, f64)>> {
self.limiter.acquire().await;
let start = (chrono::Utc::now() - chrono::Duration::days(LOOKBACK_DAYS))
.format("%Y-%m-%d")
.to_string();
let url = format!("{}/{start}..", self.base_url);
debug!("Frankfurter request: {url} ({base}->{quote})");
let resp = self
.http
.get(&url)
.query(&[("base", base), ("symbols", quote)])
.send()
.await?;
let status = resp.status();
let bytes = resp.bytes().await?;
if !status.is_success() {
return Err(Self::map_error(status, &bytes, base, quote));
}
let parsed: FrankfurterTimeSeries =
serde_json::from_slice(&bytes).map_err(|e| FinanceError::ResponseStructureError {
field: "frankfurter.rates".to_string(),
context: format!("unrecognised Frankfurter envelope: {e}"),
})?;
let series: Vec<(String, f64)> = parsed
.rates
.into_iter()
.filter_map(|(date, day)| day.get(quote).map(|rate| (date, *rate)))
.collect();
if series.is_empty() {
return Err(FinanceError::SymbolNotFound {
symbol: Some(format!("{base}{quote}")),
context: format!(
"Frankfurter published no {} rate against {} in the last {LOOKBACK_DAYS} days",
quote, parsed.base
),
});
}
Ok(series)
}
fn map_error(status: StatusCode, body: &[u8], base: &str, quote: &str) -> FinanceError {
let detail = serde_json::from_slice::<FrankfurterError>(body)
.ok()
.and_then(|e| e.message);
match status {
StatusCode::NOT_FOUND => FinanceError::SymbolNotFound {
symbol: Some(format!("{base}{quote}")),
context: detail.unwrap_or_else(|| {
"Frankfurter does not publish one of these currencies".to_string()
}),
},
StatusCode::UNPROCESSABLE_ENTITY => FinanceError::InvalidParameter {
param: "currency pair".to_string(),
reason: detail.unwrap_or_else(|| format!("{base}/{quote} is not a valid pair")),
},
s => status_error("Frankfurter", s),
}
}
}