use std::sync::Arc;
use std::time::Duration;
use reqwest::Client;
use tracing::debug;
use super::models::{BlsResponse, BlsSeries};
use crate::adapters::common::{check_status, keyless_http_client};
use crate::error::{FinanceError, Result};
use crate::rate_limiter::RateLimiter;
pub(super) const BLS_BASE: &str = "https://api.bls.gov/publicAPI";
const V2_YEARS: i32 = 20;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) enum Tier {
V1,
V2(String),
}
impl Tier {
pub(super) fn from_env() -> Self {
match std::env::var("BLS_API_KEY") {
Ok(key) if !key.trim().is_empty() => Self::V2(key),
_ => Self::V1,
}
}
pub(super) fn version(&self) -> &'static str {
match self {
Self::V1 => "v1",
Self::V2(_) => "v2",
}
}
}
pub(super) struct BlsClient {
http: Client,
limiter: Arc<RateLimiter>,
base_url: String,
tier: Tier,
}
impl BlsClient {
pub(super) fn new(
timeout: Duration,
limiter: Arc<RateLimiter>,
base_url: impl Into<String>,
tier: Tier,
) -> Result<Self> {
Ok(Self {
http: keyless_http_client(timeout)?,
limiter,
base_url: base_url.into(),
tier,
})
}
fn scrub(&self, message: &str) -> String {
match &self.tier {
Tier::V1 => message.to_string(),
Tier::V2(key) => crate::adapters::common::keyed::redact_key(message, key),
}
}
pub(super) async fn series(&self, series_id: &str) -> Result<BlsSeries> {
self.limiter.acquire().await;
let url = match &self.tier {
Tier::V1 => format!(
"{}/v1/timeseries/data/{}",
self.base_url,
crate::adapters::common::encode_path_segment(series_id)
),
Tier::V2(_) => format!("{}/v2/timeseries/data/", self.base_url),
};
debug!("BLS request ({}): {series_id}", self.tier.version());
let request = match &self.tier {
Tier::V1 => self.http.get(&url),
Tier::V2(key) => {
let end = chrono::Utc::now().format("%Y").to_string();
let start = (chrono::Utc::now().format("%Y").to_string())
.parse::<i32>()
.map(|y| (y - V2_YEARS + 1).to_string())
.unwrap_or_else(|_| end.clone());
self.http.post(&url).json(&serde_json::json!({
"seriesid": [series_id],
"registrationkey": key,
"startyear": start,
"endyear": end,
"catalog": true,
}))
}
};
let resp = request.send().await?;
let status = resp.status();
let bytes = resp.bytes().await?;
check_status("BLS", status)?;
let parsed: BlsResponse =
serde_json::from_slice(&bytes).map_err(|e| FinanceError::ResponseStructureError {
field: "bls.response".to_string(),
context: format!("unrecognised BLS envelope: {e}"),
})?;
if parsed.status != "REQUEST_SUCCEEDED" {
let context = self.scrub(&format!("{}: {}", parsed.status, parsed.message.join("; ")));
let normalized = context.to_ascii_lowercase();
if normalized.contains("registration key") || normalized.contains("registrationkey") {
return Err(FinanceError::AuthenticationFailed { context });
}
return Err(FinanceError::MacroDataError {
provider: "BLS".to_string(),
context,
});
}
let series = parsed
.results
.and_then(|r| r.series.into_iter().next())
.ok_or_else(|| FinanceError::ResponseStructureError {
field: "bls.Results.series".to_string(),
context: "BLS returned no series block".to_string(),
})?;
if series.data.is_empty() {
return Err(FinanceError::SymbolNotFound {
symbol: Some(series_id.to_string()),
context: if parsed.message.is_empty() {
"BLS returned no observations for this series".to_string()
} else {
self.scrub(&parsed.message.join("; "))
},
});
}
Ok(series)
}
}