wbi_rs/api.rs
1#![forbid(unsafe_code)]
2/// Synchronous client for the **World Bank Indicators API (v2)**.
3///
4/// This module focuses on the `country/{codes}/indicator/{codes}` endpoint and returns
5/// results as tidy `models::DataPoint` rows. Pagination is handled automatically.
6///
7/// ### Notes
8/// - The API sometimes serializes `per_page` as a **string**; we accept both string/number.
9/// - When requesting **multiple indicators** at once, the API requires a `source` parameter
10/// (e.g., `source=2` for WDI). Pass it via `Client::fetch(..., Some(2))`.
11/// - Network timeouts use a sane default (30s) and can be adjusted by editing the client builder.
12///
13///
14/// Typical usage:
15/// ```no_run
16/// # use wbi_rs::{Client, DateSpec};
17/// let client = Client::default();
18/// let rows = client.fetch(
19/// &["DEU".into()],
20/// &["SP.POP.TOTL".into()],
21/// Some(DateSpec::Year(2020)),
22/// None,
23/// )?;
24/// # Ok::<(), anyhow::Error>(())
25/// ```
26use crate::models::{DataPoint, DateSpec, Entry, IndicatorMeta, Meta};
27use anyhow::{Context, Result, bail};
28use percent_encoding::{AsciiSet, NON_ALPHANUMERIC};
29use reqwest::blocking::Client as HttpClient;
30use reqwest::redirect::Policy;
31use serde_json::Value;
32use std::time::Duration;
33
34/// Fetch indicator observations.
35///
36/// ### Arguments
37/// - `countries`: ISO2/ISO3 country codes or aggregate codes (`"DEU"`, `"USA"`, `"EUU"`…).
38/// Multiple codes are allowed; they are joined for the API (e.g., `"DEU;USA"`).
39/// - `indicators`: Indicator IDs (`"SP.POP.TOTL"`, …). Multiple allowed.
40/// - `date`: A single year (`Year(2020)`) or an inclusive range (`Range { start, end }`).
41/// - `source`: Optional numeric source id (e.g., `2` for WDI). **Required** by the API when
42/// requesting multiple indicators.
43///
44/// ### Returns
45/// A `Vec<models::DataPoint>` where one row equals one observation (country, indicator, year).
46///
47/// ### Errors
48/// - Network/HTTP error
49/// - JSON decoding error
50/// - API-level error payload (surfaced as an error)
51///
52/// ### Example
53/// ```no_run
54/// # use wbi_rs::{Client, DateSpec};
55/// let cli = Client::default();
56/// let data = cli.fetch(
57/// &["DEU".into(), "USA".into()],
58/// &["SP.POP.TOTL".into()],
59/// Some(DateSpec::Range { start: 2015, end: 2020 }),
60/// None,
61/// )?;
62/// # Ok::<(), anyhow::Error>(())
63/// ```
64
65#[derive(Debug, Clone)]
66pub struct Client {
67 pub base_url: String,
68 http: HttpClient,
69}
70
71impl Default for Client {
72 fn default() -> Self {
73 let http = HttpClient::builder()
74 .timeout(Duration::from_secs(30)) // total request timeout
75 .connect_timeout(Duration::from_secs(10)) // connect timeout
76 .redirect(Policy::limited(5)) // cap redirects
77 .user_agent(concat!("wbi_rs/", env!("CARGO_PKG_VERSION"))) // set user agent
78 .build()
79 .expect("reqwest client build");
80 Self {
81 base_url: "https://api.worldbank.org/v2".into(),
82 http,
83 }
84 }
85}
86
87// Allow -, _, . unescaped in codes (common for indicator ids)
88const SAFE: &AsciiSet = &NON_ALPHANUMERIC.remove(b'-').remove(b'_').remove(b'.');
89
90fn enc_join<'a>(parts: impl IntoIterator<Item = &'a str>) -> String {
91 parts
92 .into_iter()
93 .map(|s| percent_encoding::utf8_percent_encode(s.trim(), SAFE).to_string())
94 .collect::<Vec<_>>()
95 .join(";")
96}
97
98impl Client {
99 fn get_json_with_retry(&self, url: &str) -> Result<Value> {
100 let mut last_err: Option<anyhow::Error> = None;
101 for backoff_ms in [100u64, 300, 700] {
102 match self.http.get(url).send() {
103 Ok(r) if r.status().is_success() => {
104 return r.json().context("decode json");
105 }
106 Ok(r) if r.status().is_server_error() => { /* retry */ }
107 Ok(r) => bail!("request failed with HTTP {}", r.status()),
108 Err(e) => last_err = Some(e.into()),
109 }
110 std::thread::sleep(Duration::from_millis(backoff_ms));
111 }
112 bail!("network error: {:?}", last_err);
113 }
114
115 /// Fetch units from the World Bank indicator endpoint for the given indicators.
116 ///
117 /// Returns a map from indicator ID to unit string. Missing indicators or those
118 /// without units will not be present in the returned `HashMap`.
119 ///
120 /// # Arguments
121 /// - `indicators`: Indicator IDs to fetch metadata for (e.g., `["SP.POP.TOTL"]`).
122 ///
123 /// # Errors
124 ///
125 /// Returns an error if:
126 /// - Network request fails or times out
127 /// - HTTP response status is not successful (non-2xx)
128 /// - Response body cannot be parsed as JSON
129 /// - API returns an error message in the response
130 ///
131 /// # Example
132 /// ```no_run
133 /// # use wbi_rs::Client;
134 /// let cli = Client::default();
135 /// let units = cli.fetch_indicator_units(&["SP.POP.TOTL".into()])?;
136 /// # Ok::<(), anyhow::Error>(())
137 /// ```
138 pub fn fetch_indicator_units(
139 &self,
140 indicators: &[String],
141 ) -> Result<std::collections::HashMap<String, String>> {
142 use std::collections::HashMap;
143
144 if indicators.is_empty() {
145 return Ok(HashMap::new());
146 }
147
148 let indicator_spec = enc_join(indicators.iter().map(String::as_str));
149 let url = format!(
150 "{}/indicator/{}?format=json&per_page=1000",
151 self.base_url, indicator_spec
152 );
153
154 let v: Value = self
155 .get_json_with_retry(&url)
156 .with_context(|| format!("GET {}", url))?;
157
158 // Parse the response (same structure as data endpoint: [Meta, [IndicatorMeta, ...]])
159 let arr = v
160 .as_array()
161 .ok_or_else(|| anyhow::anyhow!("unexpected response shape: not a top-level array"))?;
162 if arr.is_empty() {
163 bail!("unexpected response: empty array");
164 }
165
166 // Check for API error
167 if arr[0].get("message").is_some() {
168 bail!("world bank api error: {}", arr[0]);
169 }
170
171 let indicators_data: Vec<IndicatorMeta> = if arr.len() > 1 {
172 serde_json::from_value(arr[1].clone()).context("parse indicator metadata")?
173 } else {
174 vec![]
175 };
176
177 // Build map from ID to unit
178 let mut result = HashMap::new();
179 for meta in indicators_data {
180 if let Some(unit) = meta.unit
181 && !unit.trim().is_empty()
182 {
183 result.insert(meta.id, unit);
184 }
185 }
186
187 Ok(result)
188 }
189
190 /// Fetch indicator observations.
191 ///
192 /// # Arguments
193 /// - `countries`: ISO2 (e.g., "DE") or ISO3 (e.g., "DEU") or aggregates (e.g., "EUU"). Multiple accepted.
194 /// - `indicators`: e.g., "SP.POP.TOTL". Multiple accepted.
195 /// - `date`: A single year or inclusive range.
196 /// - `source`: Optional numeric source id (e.g., 2 for WDI). Required by the World Bank API
197 /// for efficient single-call multi-indicator requests. When `source` is `None` and multiple
198 /// indicators are requested, this method automatically falls back to individual requests
199 /// per indicator and merges the results.
200 ///
201 /// # Errors
202 ///
203 /// Returns an error if:
204 /// - No countries provided (empty slice)
205 /// - No indicators provided (empty slice)
206 /// - Network request fails or times out
207 /// - HTTP response status is not successful (non-2xx)
208 /// - Response body cannot be parsed as JSON
209 /// - API returns an error message in the response
210 /// - Page limit exceeded (safety limit: 1000 pages)
211 pub fn fetch(
212 &self,
213 countries: &[String],
214 indicators: &[String],
215 date: Option<DateSpec>,
216 source: Option<u32>,
217 ) -> Result<Vec<DataPoint>> {
218 if countries.is_empty() {
219 bail!("at least one country/region code required");
220 }
221 if indicators.is_empty() {
222 bail!("at least one indicator code required");
223 }
224
225 // Multi-indicator fallback: if multiple indicators without source,
226 // fetch each indicator separately and merge results
227 if indicators.len() > 1 && source.is_none() {
228 let mut all_points = Vec::new();
229 for indicator in indicators {
230 let points = self.fetch(countries, std::slice::from_ref(indicator), date, None)?;
231 all_points.extend(points);
232 }
233 return Ok(all_points);
234 }
235
236 let country_spec = enc_join(countries.iter().map(String::as_str));
237 let indicator_spec = enc_join(indicators.iter().map(String::as_str));
238
239 let mut url = format!(
240 "{}/country/{}/indicator/{}?format=json&per_page=1000",
241 self.base_url, country_spec, indicator_spec
242 );
243 if let Some(d) = date {
244 url.push_str(&format!("&date={}", d.to_query_param()));
245 }
246 if let Some(s) = source {
247 url.push_str(&format!("&source={}", s));
248 }
249
250 // Safety cap to avoid pathological jobs
251 let max_pages = 1000u32;
252
253 // Paginate until we retrieved all pages.
254 let mut page = 1u32;
255 let mut out: Vec<DataPoint> = Vec::new();
256 loop {
257 let page_url = format!("{}&page={}", url, page);
258 if page > max_pages {
259 bail!("page limit exceeded ({})", max_pages);
260 }
261 let v: Value = self
262 .get_json_with_retry(&page_url)
263 .with_context(|| format!("GET {}", page_url))?;
264
265 // The API returns an array: [Meta, [Entry, ...]] or a "message" object in position 0 on error.
266 let arr = v.as_array().ok_or_else(|| {
267 anyhow::anyhow!("unexpected response shape: not a top-level array")
268 })?;
269 if arr.is_empty() {
270 bail!("unexpected response: empty array");
271 }
272
273 // If first element has "message", surface API error.
274 if arr[0].get("message").is_some() {
275 bail!("world bank api error: {}", arr[0]);
276 }
277
278 let meta: Meta = serde_json::from_value(arr[0].clone()).context("parse meta")?;
279 let entries: Vec<Entry> = if arr.len() > 1 {
280 serde_json::from_value(arr[1].clone()).context("parse entries")?
281 } else {
282 vec![]
283 };
284
285 out.extend(entries.into_iter().map(DataPoint::from));
286
287 let total_pages = meta.pages;
288 if page >= total_pages {
289 break;
290 }
291 page += 1;
292 }
293
294 // Unit enrichment: if any DataPoints lack units, try to fetch from indicator metadata
295 let needs_enrichment = out.iter().any(|p| {
296 p.unit.is_none()
297 || p.unit
298 .as_ref()
299 .map(|u| u.trim().is_empty())
300 .unwrap_or(false)
301 });
302
303 if needs_enrichment {
304 // Fetch indicator metadata to get units
305 match self.fetch_indicator_units(indicators) {
306 Ok(indicator_units) => {
307 // Enrich DataPoints that lack units
308 for point in &mut out {
309 if (point.unit.is_none()
310 || point
311 .unit
312 .as_ref()
313 .map(|u| u.trim().is_empty())
314 .unwrap_or(false))
315 && let Some(unit) = indicator_units.get(&point.indicator_id)
316 {
317 point.unit = Some(unit.clone());
318 }
319 }
320 }
321 Err(_) => {
322 // If indicator metadata fetch fails, continue without enrichment
323 // This ensures that the main data fetch doesn't fail due to metadata issues
324 }
325 }
326 }
327
328 Ok(out)
329 }
330}