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}