Skip to main content

unirate_api/
endpoints.rs

1//! Endpoint implementations for [`Client`].
2
3use std::collections::HashMap;
4
5use reqwest::header::{ACCEPT, USER_AGENT};
6use serde::{de::DeserializeOwned, Deserialize, Deserializer};
7use serde_json::Value;
8
9use crate::{
10    client::Client,
11    error::UniRateError,
12    models::{HistoricalLimitsResponse, VatRateResponse, VatRatesResponse},
13    VERSION,
14};
15
16// ---------------------------------------------------------------------------
17// Public endpoint surface
18// ---------------------------------------------------------------------------
19
20impl Client {
21    /// Fetch the current exchange rate between two currencies.
22    ///
23    /// Corresponds to `GET /api/rates?from=<FROM>&to=<TO>`.
24    pub async fn get_rate(
25        &self,
26        from: impl AsRef<str>,
27        to: impl AsRef<str>,
28    ) -> Result<f64, UniRateError> {
29        let query = vec![("from", upper(from)), ("to", upper(to))];
30        let body: RateResponse = self.request("/api/rates", &query).await?;
31        Ok(body.rate)
32    }
33
34    /// Fetch **all** current exchange rates for a given base currency.
35    pub async fn get_all_rates(
36        &self,
37        from: impl AsRef<str>,
38    ) -> Result<HashMap<String, f64>, UniRateError> {
39        let query = vec![("from", upper(from))];
40        let body: RatesResponse = self.request("/api/rates", &query).await?;
41        Ok(body.rates)
42    }
43
44    /// Convert an amount from one currency to another using the current rate.
45    pub async fn convert(
46        &self,
47        amount: f64,
48        from: impl AsRef<str>,
49        to: impl AsRef<str>,
50    ) -> Result<f64, UniRateError> {
51        let query = vec![
52            ("from", upper(from)),
53            ("to", upper(to)),
54            ("amount", amount.to_string()),
55        ];
56        let body: ConvertResponse = self.request("/api/convert", &query).await?;
57        Ok(body.result)
58    }
59
60    /// Fetch the full list of supported currency codes.
61    pub async fn get_supported_currencies(&self) -> Result<Vec<String>, UniRateError> {
62        let body: CurrenciesResponse = self.request("/api/currencies", &[]).await?;
63        Ok(body.currencies)
64    }
65
66    /// Fetch a historical exchange rate for a specific date (`YYYY-MM-DD`).
67    ///
68    /// **Pro-gated** — returns [`UniRateError::Api`] with `status = 403` on free-tier keys.
69    pub async fn get_historical_rate(
70        &self,
71        date: impl Into<String>,
72        from: impl AsRef<str>,
73        to: impl AsRef<str>,
74    ) -> Result<f64, UniRateError> {
75        let query = vec![
76            ("date", date.into()),
77            ("amount", "1".to_string()),
78            ("from", upper(from)),
79            ("to", upper(to)),
80        ];
81        let body: RateResponse = self.request("/api/historical/rates", &query).await?;
82        Ok(body.rate)
83    }
84
85    /// Fetch **all** historical exchange rates for a base currency on a given date.
86    ///
87    /// **Pro-gated** — returns [`UniRateError::Api`] with `status = 403` on free-tier keys.
88    pub async fn get_historical_rates(
89        &self,
90        date: impl Into<String>,
91        from: impl AsRef<str>,
92    ) -> Result<HashMap<String, f64>, UniRateError> {
93        let query = vec![
94            ("date", date.into()),
95            ("amount", "1".to_string()),
96            ("from", upper(from)),
97        ];
98        let body: RatesResponse = self.request("/api/historical/rates", &query).await?;
99        Ok(body.rates)
100    }
101
102    /// Convert an amount using a historical exchange rate.
103    ///
104    /// **Pro-gated** — returns [`UniRateError::Api`] with `status = 403` on free-tier keys.
105    pub async fn convert_historical(
106        &self,
107        amount: f64,
108        from: impl AsRef<str>,
109        to: impl AsRef<str>,
110        date: impl Into<String>,
111    ) -> Result<f64, UniRateError> {
112        let query = vec![
113            ("date", date.into()),
114            ("amount", amount.to_string()),
115            ("from", upper(from)),
116            ("to", upper(to)),
117        ];
118        let body: ConvertResponse = self.request("/api/historical/rates", &query).await?;
119        Ok(body.result)
120    }
121
122    /// Fetch a time series of exchange rates between two dates (max 5-year span).
123    ///
124    /// **Pro-gated** — returns [`UniRateError::Api`] with `status = 403` on free-tier keys.
125    pub async fn get_time_series(
126        &self,
127        start_date: impl Into<String>,
128        end_date: impl Into<String>,
129        base: impl AsRef<str>,
130        currencies: Option<&[&str]>,
131        amount: f64,
132    ) -> Result<HashMap<String, HashMap<String, f64>>, UniRateError> {
133        let mut query = vec![
134            ("start_date", start_date.into()),
135            ("end_date", end_date.into()),
136            ("amount", amount.to_string()),
137            ("base", upper(base)),
138        ];
139        if let Some(list) = currencies {
140            if !list.is_empty() {
141                let joined = list
142                    .iter()
143                    .map(|c| c.to_ascii_uppercase())
144                    .collect::<Vec<_>>()
145                    .join(",");
146                query.push(("currencies", joined));
147            }
148        }
149        let body: TimeSeriesResponse = self.request("/api/historical/timeseries", &query).await?;
150        Ok(body.data)
151    }
152
153    /// Fetch the available historical-data coverage per currency.
154    ///
155    /// **Pro-gated** — returns [`UniRateError::Api`] with `status = 403` on free-tier keys.
156    pub async fn get_historical_limits(&self) -> Result<HistoricalLimitsResponse, UniRateError> {
157        self.request("/api/historical/limits", &[]).await
158    }
159
160    /// Fetch VAT rates for all supported countries.
161    pub async fn get_vat_rates(&self) -> Result<VatRatesResponse, UniRateError> {
162        self.request("/api/vat/rates", &[]).await
163    }
164
165    /// Fetch the VAT rate for a specific country (ISO-3166 alpha-2 code, e.g. `"DE"`).
166    pub async fn get_vat_rate(
167        &self,
168        country: impl AsRef<str>,
169    ) -> Result<VatRateResponse, UniRateError> {
170        let query = vec![("country", upper(country))];
171        self.request("/api/vat/rates", &query).await
172    }
173}
174
175// ---------------------------------------------------------------------------
176// Internal transport
177// ---------------------------------------------------------------------------
178
179impl Client {
180    async fn request<T: DeserializeOwned>(
181        &self,
182        path: &str,
183        query: &[(&str, String)],
184    ) -> Result<T, UniRateError> {
185        let url = format!("{}{}", self.base_url, path);
186        // api_key is appended as the final query parameter — the Swift client
187        // does the same so it shows up last in the URL.
188        let mut params: Vec<(&str, &str)> = query.iter().map(|(k, v)| (*k, v.as_str())).collect();
189        params.push(("api_key", self.api_key.as_str()));
190
191        let request = self
192            .http
193            .get(&url)
194            // These headers are set as defaults on the built-in client, but a
195            // user-supplied `reqwest::Client` (e.g. in tests) may not carry them
196            // — set them per-request to guarantee spec compliance.
197            .header(ACCEPT, "application/json")
198            .header(USER_AGENT, format!("unirate-rust/{VERSION}"))
199            .query(&params);
200
201        let response = request.send().await?;
202        let status = response.status();
203        let body = response.text().await?;
204
205        if status.is_success() {
206            if body.is_empty() {
207                return Err(UniRateError::Api {
208                    status: status.as_u16(),
209                    body: "empty response body".into(),
210                });
211            }
212            return serde_json::from_str::<T>(&body).map_err(UniRateError::from);
213        }
214
215        Err(match status.as_u16() {
216            400 => UniRateError::InvalidDate,
217            401 => UniRateError::Authentication,
218            404 => UniRateError::InvalidCurrency,
219            429 => UniRateError::RateLimit,
220            other => UniRateError::Api {
221                status: other,
222                body,
223            },
224        })
225    }
226}
227
228// ---------------------------------------------------------------------------
229// Private response DTOs
230// ---------------------------------------------------------------------------
231
232#[derive(Debug, Deserialize)]
233struct RateResponse {
234    #[serde(deserialize_with = "deserialize_number")]
235    rate: f64,
236}
237
238#[derive(Debug, Deserialize)]
239struct RatesResponse {
240    #[serde(deserialize_with = "deserialize_number_map")]
241    rates: HashMap<String, f64>,
242}
243
244#[derive(Debug, Deserialize)]
245struct ConvertResponse {
246    #[serde(deserialize_with = "deserialize_number")]
247    result: f64,
248}
249
250#[derive(Debug, Deserialize)]
251struct CurrenciesResponse {
252    currencies: Vec<String>,
253}
254
255#[derive(Debug, Deserialize)]
256struct TimeSeriesResponse {
257    #[serde(deserialize_with = "deserialize_nested_number_map")]
258    data: HashMap<String, HashMap<String, f64>>,
259}
260
261// ---------------------------------------------------------------------------
262// Numeric coercion helpers — the UniRate API sometimes returns rates as JSON
263// numbers and sometimes as numeric strings (e.g. `"0.92"`). Accept both.
264// ---------------------------------------------------------------------------
265
266/// Deserialize a `f64` that may arrive as a JSON number **or** numeric string.
267pub(crate) fn deserialize_number<'de, D>(deserializer: D) -> Result<f64, D::Error>
268where
269    D: Deserializer<'de>,
270{
271    let value = Value::deserialize(deserializer)?;
272    value_to_f64(&value).map_err(serde::de::Error::custom)
273}
274
275fn deserialize_number_map<'de, D>(deserializer: D) -> Result<HashMap<String, f64>, D::Error>
276where
277    D: Deserializer<'de>,
278{
279    let raw: HashMap<String, Value> = HashMap::deserialize(deserializer)?;
280    let mut out = HashMap::with_capacity(raw.len());
281    for (k, v) in raw {
282        out.insert(k, value_to_f64(&v).map_err(serde::de::Error::custom)?);
283    }
284    Ok(out)
285}
286
287fn deserialize_nested_number_map<'de, D>(
288    deserializer: D,
289) -> Result<HashMap<String, HashMap<String, f64>>, D::Error>
290where
291    D: Deserializer<'de>,
292{
293    let raw: HashMap<String, HashMap<String, Value>> = HashMap::deserialize(deserializer)?;
294    let mut out = HashMap::with_capacity(raw.len());
295    for (date, inner) in raw {
296        let mut converted = HashMap::with_capacity(inner.len());
297        for (code, value) in inner {
298            converted.insert(
299                code,
300                value_to_f64(&value).map_err(serde::de::Error::custom)?,
301            );
302        }
303        out.insert(date, converted);
304    }
305    Ok(out)
306}
307
308fn value_to_f64(value: &Value) -> Result<f64, String> {
309    match value {
310        Value::Number(n) => n
311            .as_f64()
312            .ok_or_else(|| format!("cannot represent {n} as f64")),
313        Value::String(s) => s
314            .parse::<f64>()
315            .map_err(|e| format!("cannot parse {s:?} as f64: {e}")),
316        other => Err(format!("expected number or numeric string, got {other:?}")),
317    }
318}
319
320// ---------------------------------------------------------------------------
321// Helpers
322// ---------------------------------------------------------------------------
323
324fn upper(s: impl AsRef<str>) -> String {
325    s.as_ref().to_ascii_uppercase()
326}