wbi_rs/
models.rs

1#![forbid(unsafe_code)]
2
3use serde::{Deserialize, Serialize};
4
5/// Data model and (de)serialization helpers for World Bank API responses.
6///
7/// The World Bank API returns arrays like: `[Meta, [Entry, ...]]`.
8/// We convert each `Entry` into a tidy `DataPoint` row suitable for analysis.
9///
10/// How to specify the `date` query parameter.
11///
12/// * `Year(y)` becomes `"YYYY"`
13/// * `Range { start, end }` becomes `"YYYY:YYYY"`
14#[doc = "Convert to API query string (e.g., `2010:2020`)."]
15/// ```
16/// use wbi_rs::models::DateSpec;
17/// assert_eq!(DateSpec::Year(2020).to_query_param(), "2020");
18/// assert_eq!(DateSpec::Range{start: 2010, end: 2020}.to_query_param(), "2010:2020");
19/// ```
20///
21/// Metadata returned in position **0** of the API response.
22///
23/// The API sometimes encodes `per_page` as a **string**; we accept both string/number.
24#[doc = "Fields: `page`, `pages`, `per_page`, `total`."]
25///
26/// Raw entry (position **1**) as returned by the API.
27/// This mirrors the remote schema and is transformed into `DataPoint`.
28#[doc = "Prefer using `DataPoint` in downstream code."]
29///
30/// A tidy, analysis-friendly row: one observation per (country, indicator, year).
31///
32/// - `country_iso3`: ISO-3166 alpha-3 code (e.g., `DEU`).
33/// - `indicator_id`: e.g., `SP.POP.TOTL`.
34/// - `value`: numeric value (nullable).
35/// - `year`: parsed integer. If parse fails, 0 is used (and filtered out by plotting).
36///
37/// Created from `Entry` via `impl From<Entry> for DataPoint`.
38///
39/// Grouping key used in stats and plots: `(indicator_id, country_iso3)`.
40#[doc = "Derives `Eq`, `Ord` so it can be used as a `BTreeMap` key."]
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
42pub enum DateSpec {
43    /// Single year like 2020
44    Year(i32),
45    /// Inclusive range like 2000..=2020
46    Range { start: i32, end: i32 },
47}
48
49impl DateSpec {
50    pub fn to_query_param(&self) -> String {
51        match *self {
52            DateSpec::Year(y) => y.to_string(),
53            DateSpec::Range { start, end } => format!("{}:{}", start, end),
54        }
55    }
56}
57
58/// Metadata section returned by the API (position 0).
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct Meta {
61    pub page: u32,
62    pub pages: u32,
63    /// Some responses encode `per_page` as a string, others as a number.
64    /// Accept both and normalize to `u32`.
65    #[serde(deserialize_with = "de_u32_from_string_or_number")]
66    pub per_page: u32,
67    pub total: u32,
68}
69
70/// Serde helper: parse `u32` from either a JSON number or a string.
71fn de_u32_from_string_or_number<'de, D>(deserializer: D) -> Result<u32, D::Error>
72where
73    D: serde::Deserializer<'de>,
74{
75    use serde::de::{self, Visitor};
76    struct U32Visitor;
77
78    impl<'de> Visitor<'de> for U32Visitor {
79        type Value = u32;
80
81        fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
82            write!(f, "a string or integer representing a non-negative number")
83        }
84
85        fn visit_u64<E>(self, v: u64) -> Result<Self::Value, E>
86        where
87            E: de::Error,
88        {
89            Ok(v as u32)
90        }
91
92        fn visit_i64<E>(self, v: i64) -> Result<Self::Value, E>
93        where
94            E: de::Error,
95        {
96            if v < 0 {
97                return Err(E::custom("negative value for u32"));
98            }
99            Ok(v as u32)
100        }
101
102        fn visit_str<E>(self, s: &str) -> Result<Self::Value, E>
103        where
104            E: de::Error,
105        {
106            s.parse::<u32>().map_err(E::custom)
107        }
108    }
109
110    deserializer.deserialize_any(U32Visitor)
111}
112
113#[derive(Debug, Clone, Serialize, Deserialize)]
114pub struct CodeName {
115    pub id: String,
116    pub value: String,
117}
118
119/// Minimal indicator metadata from the indicator endpoint response.
120/// Used to fetch units for enriching DataPoint.unit when observation rows lack a unit.
121#[derive(Debug, Clone, Serialize, Deserialize)]
122pub struct IndicatorMeta {
123    pub id: String,
124    #[serde(alias = "value")]
125    pub name: String,
126    pub unit: Option<String>,
127}
128
129/// Raw entry from the API (position 1 array).
130#[derive(Debug, Clone, Serialize, Deserialize)]
131pub struct Entry {
132    pub indicator: CodeName,
133    pub country: CodeName,
134    pub countryiso3code: String,
135    pub date: String,
136    pub value: Option<f64>,
137    pub unit: Option<String>,
138    #[serde(rename = "obs_status")]
139    pub obs_status: Option<String>,
140    pub decimal: Option<i32>,
141}
142
143/// Tidy structure used by this crate (one row = one observation).
144#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
145pub struct DataPoint {
146    pub indicator_id: String,
147    pub indicator_name: String,
148    pub country_id: String, // typically ISO2
149    pub country_name: String,
150    pub country_iso3: String,
151    pub year: i32,
152    pub value: Option<f64>,
153    pub unit: Option<String>,
154    pub obs_status: Option<String>,
155    pub decimal: Option<i32>,
156}
157
158impl From<Entry> for DataPoint {
159    fn from(e: Entry) -> Self {
160        let year = e.date.parse::<i32>().unwrap_or(0);
161        Self {
162            indicator_id: e.indicator.id,
163            indicator_name: e.indicator.value,
164            country_id: e.country.id,
165            country_name: e.country.value,
166            country_iso3: e.countryiso3code,
167            year,
168            value: e.value,
169            unit: e.unit,
170            obs_status: e.obs_status,
171            decimal: e.decimal,
172        }
173    }
174}
175
176/// Grouping key used in stats and plotting.
177#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
178pub struct GroupKey {
179    pub indicator_id: String,
180    pub country_iso3: String,
181}