#![forbid(unsafe_code)]
use crate::models::{DataPoint, DateSpec, Entry, IndicatorMeta, Meta};
use anyhow::{Context, Result, bail};
use percent_encoding::{AsciiSet, NON_ALPHANUMERIC};
use reqwest::blocking::Client as HttpClient;
use reqwest::redirect::Policy;
use serde_json::Value;
use std::time::Duration;
#[derive(Debug, Clone)]
pub struct Client {
pub base_url: String,
http: HttpClient,
}
impl Default for Client {
fn default() -> Self {
let http = HttpClient::builder()
.timeout(Duration::from_secs(30)) .connect_timeout(Duration::from_secs(10)) .redirect(Policy::limited(5)) .user_agent(concat!("wbi_rs/", env!("CARGO_PKG_VERSION"))) .build()
.expect("reqwest client build");
Self {
base_url: "https://api.worldbank.org/v2".into(),
http,
}
}
}
const SAFE: &AsciiSet = &NON_ALPHANUMERIC.remove(b'-').remove(b'_').remove(b'.');
fn enc_join<'a>(parts: impl IntoIterator<Item = &'a str>) -> String {
parts
.into_iter()
.map(|s| percent_encoding::utf8_percent_encode(s.trim(), SAFE).to_string())
.collect::<Vec<_>>()
.join(";")
}
impl Client {
fn get_json_with_retry(&self, url: &str) -> Result<Value> {
let mut last_err: Option<anyhow::Error> = None;
for backoff_ms in [100u64, 300, 700] {
match self.http.get(url).send() {
Ok(r) if r.status().is_success() => {
return r.json().context("decode json");
}
Ok(r) if r.status().is_server_error() => { }
Ok(r) => bail!("request failed with HTTP {}", r.status()),
Err(e) => last_err = Some(e.into()),
}
std::thread::sleep(Duration::from_millis(backoff_ms));
}
bail!("network error: {:?}", last_err);
}
pub fn fetch_indicator_units(
&self,
indicators: &[String],
) -> Result<std::collections::HashMap<String, String>> {
use std::collections::HashMap;
if indicators.is_empty() {
return Ok(HashMap::new());
}
let indicator_spec = enc_join(indicators.iter().map(String::as_str));
let url = format!(
"{}/indicator/{}?format=json&per_page=1000",
self.base_url, indicator_spec
);
let v: Value = self
.get_json_with_retry(&url)
.with_context(|| format!("GET {}", url))?;
let arr = v
.as_array()
.ok_or_else(|| anyhow::anyhow!("unexpected response shape: not a top-level array"))?;
if arr.is_empty() {
bail!("unexpected response: empty array");
}
if arr[0].get("message").is_some() {
bail!("world bank api error: {}", arr[0]);
}
let indicators_data: Vec<IndicatorMeta> = if arr.len() > 1 {
serde_json::from_value(arr[1].clone()).context("parse indicator metadata")?
} else {
vec![]
};
let mut result = HashMap::new();
for meta in indicators_data {
if let Some(unit) = meta.unit
&& !unit.trim().is_empty()
{
result.insert(meta.id, unit);
}
}
Ok(result)
}
pub fn fetch(
&self,
countries: &[String],
indicators: &[String],
date: Option<DateSpec>,
source: Option<u32>,
) -> Result<Vec<DataPoint>> {
if countries.is_empty() {
bail!("at least one country/region code required");
}
if indicators.is_empty() {
bail!("at least one indicator code required");
}
if indicators.len() > 1 && source.is_none() {
let mut all_points = Vec::new();
for indicator in indicators {
let points = self.fetch(countries, std::slice::from_ref(indicator), date, None)?;
all_points.extend(points);
}
return Ok(all_points);
}
let country_spec = enc_join(countries.iter().map(String::as_str));
let indicator_spec = enc_join(indicators.iter().map(String::as_str));
let mut url = format!(
"{}/country/{}/indicator/{}?format=json&per_page=1000",
self.base_url, country_spec, indicator_spec
);
if let Some(d) = date {
url.push_str(&format!("&date={}", d.to_query_param()));
}
if let Some(s) = source {
url.push_str(&format!("&source={}", s));
}
let max_pages = 1000u32;
let mut page = 1u32;
let mut out: Vec<DataPoint> = Vec::new();
loop {
let page_url = format!("{}&page={}", url, page);
if page > max_pages {
bail!("page limit exceeded ({})", max_pages);
}
let v: Value = self
.get_json_with_retry(&page_url)
.with_context(|| format!("GET {}", page_url))?;
let arr = v.as_array().ok_or_else(|| {
anyhow::anyhow!("unexpected response shape: not a top-level array")
})?;
if arr.is_empty() {
bail!("unexpected response: empty array");
}
if arr[0].get("message").is_some() {
bail!("world bank api error: {}", arr[0]);
}
let meta: Meta = serde_json::from_value(arr[0].clone()).context("parse meta")?;
let entries: Vec<Entry> = if arr.len() > 1 {
serde_json::from_value(arr[1].clone()).context("parse entries")?
} else {
vec![]
};
out.extend(entries.into_iter().map(DataPoint::from));
let total_pages = meta.pages;
if page >= total_pages {
break;
}
page += 1;
}
let needs_enrichment = out.iter().any(|p| {
p.unit.is_none()
|| p.unit
.as_ref()
.map(|u| u.trim().is_empty())
.unwrap_or(false)
});
if needs_enrichment {
match self.fetch_indicator_units(indicators) {
Ok(indicator_units) => {
for point in &mut out {
if (point.unit.is_none()
|| point
.unit
.as_ref()
.map(|u| u.trim().is_empty())
.unwrap_or(false))
&& let Some(unit) = indicator_units.get(&point.indicator_id)
{
point.unit = Some(unit.clone());
}
}
}
Err(_) => {
}
}
}
Ok(out)
}
}