use std::sync::Arc;
use std::time::Duration;
use reqwest::{Client, StatusCode};
use tracing::debug;
use super::models::{MacroObservation, MacroSeries, ReleaseDate};
use crate::adapters::common::keyed::{is_auth_error, redact_key, transport_error};
use crate::error::{FinanceError, Result};
use crate::rate_limiter::RateLimiter;
const FRED_BASE: &str = "https://api.stlouisfed.org/fred";
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
pub(crate) struct FredClientBuilder {
api_key: String,
timeout: Duration,
base_url: Option<String>,
}
impl FredClientBuilder {
pub fn new(api_key: impl Into<String>) -> Self {
Self {
api_key: api_key.into(),
timeout: DEFAULT_TIMEOUT,
base_url: None,
}
}
pub fn timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
#[cfg(test)]
pub fn base_url(mut self, url: impl Into<String>) -> Self {
self.base_url = Some(url.into());
self
}
pub(super) fn build_with_limiter(self, limiter: Arc<RateLimiter>) -> Result<FredClient> {
let http = Client::builder()
.timeout(self.timeout)
.user_agent(format!(
"finance-query/{} (https://github.com/Verdenroz/finance-query)",
env!("CARGO_PKG_VERSION")
))
.build()?;
Ok(FredClient {
api_key: self.api_key,
http,
limiter,
timeout: self.timeout,
base_url: self.base_url.unwrap_or_else(|| FRED_BASE.to_string()),
})
}
}
pub(crate) struct FredClient {
api_key: String,
http: Client,
limiter: Arc<RateLimiter>,
timeout: Duration,
base_url: String,
}
impl FredClient {
fn map_transport_error(&self, error: &reqwest::Error) -> FinanceError {
transport_error("FRED", self.timeout, error)
}
fn response_error(status: StatusCode, body: &[u8], api_key: &str) -> FinanceError {
let message = serde_json::from_slice::<FredErrorEnvelope>(body)
.ok()
.and_then(|error| error.error_message)
.map(|message| redact_key(&message, api_key))
.unwrap_or_else(|| format!("FRED returned HTTP {status}"));
if is_auth_error(&message.to_ascii_lowercase()) {
return FinanceError::AuthenticationFailed { context: message };
}
match status {
StatusCode::BAD_REQUEST => FinanceError::InvalidParameter {
param: "request".to_string(),
reason: message,
},
StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => {
FinanceError::AuthenticationFailed { context: message }
}
StatusCode::TOO_MANY_REQUESTS | StatusCode::LOCKED => FinanceError::RateLimited {
retry_after: Some(60),
},
s if s.is_server_error() => FinanceError::ServerError {
status: s.as_u16(),
context: message,
},
s => FinanceError::ExternalApiError {
api: "FRED".to_string(),
status: s.as_u16(),
},
}
}
async fn decode_json<T: serde::de::DeserializeOwned>(
&self,
response: reqwest::Response,
field: &str,
) -> Result<T> {
let status = response.status();
let bytes = response
.bytes()
.await
.map_err(|error| self.map_transport_error(&error))?;
if !status.is_success() {
return Err(Self::response_error(status, &bytes, &self.api_key));
}
serde_json::from_slice(&bytes).map_err(|error| FinanceError::ResponseStructureError {
field: field.to_string(),
context: format!("Failed to deserialize FRED response: {error}"),
})
}
pub async fn get_json<T: serde::de::DeserializeOwned>(
&self,
path: &str,
params: &[(&str, &str)],
) -> Result<T> {
self.limiter.acquire().await;
let url = format!("{}/{path}", self.base_url);
let mut query: Vec<(&str, &str)> = vec![("api_key", &self.api_key), ("file_type", "json")];
query.extend_from_slice(params);
debug!("FRED request: {path}");
let response = self
.http
.get(&url)
.query(&query)
.send()
.await
.map_err(|error| self.map_transport_error(&error))?;
self.decode_json(response, path).await
}
pub async fn series(&self, series_id: &str) -> Result<MacroSeries> {
self.observations(series_id, &[]).await
}
pub async fn latest_observation(&self, series_id: &str) -> Result<Option<MacroObservation>> {
let series = self
.observations(series_id, &[("sort_order", "desc"), ("limit", "1")])
.await?;
Ok(series.observations.into_iter().next())
}
async fn observations(&self, series_id: &str, extra: &[(&str, &str)]) -> Result<MacroSeries> {
let mut params = vec![("series_id", series_id)];
params.extend_from_slice(extra);
let json: serde_json::Value = self.get_json("series/observations", ¶ms).await?;
let observations = json
.get("observations")
.and_then(|v| v.as_array())
.ok_or_else(|| FinanceError::ResponseStructureError {
field: "observations".to_string(),
context: "FRED response missing observations array".to_string(),
})?
.iter()
.filter_map(|obs| {
let date = obs.get("date")?.as_str()?.to_string();
let raw = obs.get("value")?.as_str()?;
let value = if raw == "." {
None
} else {
raw.parse::<f64>().ok()
};
Some(MacroObservation { date, value })
})
.collect();
Ok(MacroSeries {
id: series_id.to_string(),
observations,
})
}
pub async fn release_dates(&self, from: &str, to: &str) -> Result<Vec<ReleaseDate>> {
let json: serde_json::Value = self
.get_json(
"releases/dates",
&[
("include_release_dates_with_no_data", "true"),
("sort_order", "asc"),
("realtime_start", from),
("realtime_end", to),
],
)
.await?;
let dates = json
.get("release_dates")
.and_then(|v| v.as_array())
.ok_or_else(|| FinanceError::ResponseStructureError {
field: "release_dates".to_string(),
context: "FRED response missing release_dates array".to_string(),
})?
.iter()
.filter_map(|rd| {
Some(ReleaseDate {
release_id: rd.get("release_id")?.as_u64()?,
release_name: rd.get("release_name")?.as_str()?.to_string(),
date: rd.get("date")?.as_str()?.to_string(),
})
})
.collect();
Ok(dates)
}
}
#[derive(serde::Deserialize)]
struct FredErrorEnvelope {
error_message: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
fn client(api_key: &str, base_url: &str) -> FredClient {
FredClientBuilder::new(api_key)
.timeout(Duration::from_secs(5))
.base_url(base_url)
.build_with_limiter(Arc::new(RateLimiter::new(100.0)))
.unwrap()
}
#[tokio::test]
async fn errors_never_render_the_api_key() {
const KEY: &str = "SUPERSECRETKEY123";
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("GET", "/series/observations")
.match_query(mockito::Matcher::Any)
.with_status(400)
.with_header("content-type", "application/json")
.with_body(format!(
r#"{{"error_code":400,"error_message":"api_key {KEY} is not registered"}}"#
))
.create_async()
.await;
let echoed = client(KEY, &server.url()).series("GDP").await.unwrap_err();
let unreachable = client(KEY, "http://127.0.0.1:1")
.series("GDP")
.await
.unwrap_err();
for err in [echoed, unreachable] {
assert!(!format!("{err}").contains(KEY), "{err}");
assert!(!format!("{err:?}").contains(KEY), "{err:?}");
}
}
}