Skip to main content

exchange_rateapi/
lib.rs

1//! # Exchange Rate API SDK
2//!
3//! Official Rust SDK for [Exchange Rate API](https://exchange-rateapi.com) --
4//! real-time mid-market exchange rates for 160+ currencies.
5//!
6//! ## Quick Start
7//!
8//! ```no_run
9//! use exchange_rateapi::ExchangeRateAPI;
10//!
11//! let client = ExchangeRateAPI::new("era_live_your_api_key");
12//!
13//! // Get latest rates for USD
14//! let response = client.latest("USD", None).unwrap();
15//! println!("USD to EUR: {}", response.rates["EUR"]);
16//!
17//! // Convert 100 USD to GBP
18//! let result = client.convert("USD", "GBP", 100.0).unwrap();
19//! println!("100 USD = {} GBP", result.result);
20//! ```
21
22use std::collections::HashMap;
23use std::fmt;
24
25use reqwest::blocking::Client;
26use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION};
27use serde::{Deserialize, Serialize};
28
29/// Base URL for the Exchange Rate API.
30const BASE_URL: &str = "https://exchange-rateapi.com";
31
32// ---------------------------------------------------------------------------
33// Error types
34// ---------------------------------------------------------------------------
35
36/// Errors returned by the Exchange Rate API SDK.
37#[derive(Debug)]
38pub enum ExchangeRateAPIError {
39    /// An HTTP-level error from the underlying reqwest client.
40    HttpError(reqwest::Error),
41    /// An error returned by the Exchange Rate API itself (non-2xx response).
42    ApiError {
43        /// HTTP status code.
44        status: u16,
45        /// Human-readable error message from the API.
46        message: String,
47    },
48    /// Failed to parse the API response body.
49    ParseError(String),
50}
51
52impl fmt::Display for ExchangeRateAPIError {
53    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
54        match self {
55            ExchangeRateAPIError::HttpError(e) => write!(f, "HTTP error: {}", e),
56            ExchangeRateAPIError::ApiError { status, message } => {
57                write!(f, "API error ({}): {}", status, message)
58            }
59            ExchangeRateAPIError::ParseError(msg) => write!(f, "Parse error: {}", msg),
60        }
61    }
62}
63
64impl std::error::Error for ExchangeRateAPIError {
65    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
66        match self {
67            ExchangeRateAPIError::HttpError(e) => Some(e),
68            _ => None,
69        }
70    }
71}
72
73impl From<reqwest::Error> for ExchangeRateAPIError {
74    fn from(err: reqwest::Error) -> Self {
75        ExchangeRateAPIError::HttpError(err)
76    }
77}
78
79impl From<serde_json::Error> for ExchangeRateAPIError {
80    fn from(err: serde_json::Error) -> Self {
81        ExchangeRateAPIError::ParseError(err.to_string())
82    }
83}
84
85/// Convenience alias for results returned by this crate.
86pub type Result<T> = std::result::Result<T, ExchangeRateAPIError>;
87
88// ---------------------------------------------------------------------------
89// Response types
90// ---------------------------------------------------------------------------
91
92/// Response from the `/v1/latest` endpoint.
93#[derive(Debug, Clone, Deserialize, Serialize)]
94pub struct LatestResponse {
95    /// Whether the request was successful.
96    pub success: bool,
97    /// The base currency code (e.g. "USD").
98    pub base: String,
99    /// ISO-8601 date of the rates.
100    pub date: String,
101    /// Map of currency code to exchange rate.
102    pub rates: HashMap<String, f64>,
103}
104
105/// Response from the `/v1/convert` endpoint.
106#[derive(Debug, Clone, Deserialize, Serialize)]
107pub struct ConvertResponse {
108    /// Whether the request was successful.
109    pub success: bool,
110    /// Source currency code.
111    pub from: String,
112    /// Target currency code.
113    pub to: String,
114    /// Amount that was converted.
115    pub amount: f64,
116    /// Converted result.
117    pub result: f64,
118    /// The exchange rate applied.
119    pub rate: f64,
120}
121
122/// Response from the `/v1/history` endpoint (single date).
123#[derive(Debug, Clone, Deserialize, Serialize)]
124pub struct HistoricalResponse {
125    /// Whether the request was successful.
126    pub success: bool,
127    /// The base currency code.
128    pub base: String,
129    /// The date of the historical rates.
130    pub date: String,
131    /// Map of currency code to exchange rate.
132    pub rates: HashMap<String, f64>,
133}
134
135/// Response from the `/v1/timeseries` endpoint.
136#[derive(Debug, Clone, Deserialize, Serialize)]
137pub struct TimeSeriesResponse {
138    /// Whether the request was successful.
139    pub success: bool,
140    /// The base currency code.
141    pub base: String,
142    /// Start date of the series (inclusive).
143    pub start_date: String,
144    /// End date of the series (inclusive).
145    pub end_date: String,
146    /// Map of date string to currency-rate map.
147    pub rates: HashMap<String, HashMap<String, f64>>,
148}
149
150/// A single currency entry returned by the `/v1/symbols` endpoint.
151#[derive(Debug, Clone, Deserialize, Serialize)]
152pub struct SymbolsResponse {
153    /// Whether the request was successful.
154    pub success: bool,
155    /// Map of currency code to currency name.
156    pub symbols: HashMap<String, String>,
157}
158
159/// Response from the `/v1/latest` endpoint when requesting a single pair.
160/// Re-uses [`LatestResponse`] internally.
161pub type SingleRateResponse = LatestResponse;
162
163/// An API error body returned by the server.
164#[derive(Debug, Deserialize)]
165struct ApiErrorBody {
166    #[serde(default)]
167    message: Option<String>,
168    #[serde(default)]
169    error: Option<String>,
170}
171
172// ---------------------------------------------------------------------------
173// Preset period helper
174// ---------------------------------------------------------------------------
175
176/// Preset time periods for [`ExchangeRateAPI::get_historical_rates`].
177#[derive(Debug, Clone, Copy, PartialEq, Eq)]
178pub enum Period {
179    /// Last 1 day.
180    OneDay,
181    /// Last 7 days.
182    SevenDays,
183    /// Last 30 days.
184    ThirtyDays,
185    /// Last 365 days (1 year).
186    OneYear,
187}
188
189impl Period {
190    /// Returns the number of days this period represents.
191    fn days(self) -> i64 {
192        match self {
193            Period::OneDay => 1,
194            Period::SevenDays => 7,
195            Period::ThirtyDays => 30,
196            Period::OneYear => 365,
197        }
198    }
199
200    /// Parses a short string tag into a `Period`.
201    ///
202    /// Accepted values: `"1d"`, `"7d"`, `"30d"`, `"1y"`.
203    pub fn from_str(s: &str) -> Option<Period> {
204        match s {
205            "1d" => Some(Period::OneDay),
206            "7d" => Some(Period::SevenDays),
207            "30d" => Some(Period::ThirtyDays),
208            "1y" => Some(Period::OneYear),
209            _ => None,
210        }
211    }
212}
213
214// ---------------------------------------------------------------------------
215// Simple date helpers (avoids pulling in chrono)
216// ---------------------------------------------------------------------------
217
218/// A minimal date representation (year, month, day) used for period calculations.
219struct SimpleDate {
220    year: i32,
221    month: u32,
222    day: u32,
223}
224
225impl SimpleDate {
226    fn today() -> Self {
227        // We use the system time to derive the current UTC date.
228        let dur = std::time::SystemTime::now()
229            .duration_since(std::time::UNIX_EPOCH)
230            .expect("system clock before UNIX epoch");
231        let total_days = (dur.as_secs() / 86400) as i64;
232        Self::from_epoch_days(total_days)
233    }
234
235    fn from_epoch_days(mut days: i64) -> Self {
236        // Algorithm from Howard Hinnant's civil_from_days.
237        days += 719_468;
238        let era = if days >= 0 { days } else { days - 146_096 } / 146_097;
239        let doe = (days - era * 146_097) as u32; // day of era [0, 146096]
240        let yoe =
241            (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
242        let y = (yoe as i64 + era * 400) as i32;
243        let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
244        let mp = (5 * doy + 2) / 153;
245        let d = doy - (153 * mp + 2) / 5 + 1;
246        let m = if mp < 10 { mp + 3 } else { mp - 9 };
247        let y = if m <= 2 { y + 1 } else { y };
248        SimpleDate {
249            year: y,
250            month: m,
251            day: d,
252        }
253    }
254
255    fn subtract_days(&self, n: i64) -> Self {
256        let epoch = self.to_epoch_days() - n;
257        Self::from_epoch_days(epoch)
258    }
259
260    fn to_epoch_days(&self) -> i64 {
261        let y = if self.month <= 2 {
262            self.year as i64 - 1
263        } else {
264            self.year as i64
265        };
266        let m = if self.month <= 2 {
267            self.month as i64 + 9
268        } else {
269            self.month as i64 - 3
270        };
271        let era = if y >= 0 { y } else { y - 399 } / 400;
272        let yoe = (y - era * 400) as u64;
273        let doy = (153 * (m as u64) + 2) / 5 + self.day as u64 - 1;
274        let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
275        era * 146_097 + doe as i64 - 719_468
276    }
277
278    fn format(&self) -> String {
279        format!("{:04}-{:02}-{:02}", self.year, self.month, self.day)
280    }
281}
282
283// ---------------------------------------------------------------------------
284// Client
285// ---------------------------------------------------------------------------
286
287/// Client for the Exchange Rate API.
288///
289/// Create an instance with [`ExchangeRateAPI::new`] and call methods to
290/// interact with the API endpoints.
291///
292/// # Example
293///
294/// ```no_run
295/// use exchange_rateapi::ExchangeRateAPI;
296///
297/// let client = ExchangeRateAPI::new("era_live_your_api_key");
298/// let rates = client.latest("USD", None).unwrap();
299/// println!("{:?}", rates.rates);
300/// ```
301pub struct ExchangeRateAPI {
302    client: Client,
303    api_key: String,
304}
305
306impl ExchangeRateAPI {
307    /// Creates a new `ExchangeRateAPI` client.
308    ///
309    /// # Arguments
310    ///
311    /// * `api_key` - Your API key (format: `era_live_...`). Obtain one at
312    ///   <https://exchange-rateapi.com>.
313    pub fn new(api_key: &str) -> Self {
314        let client = Client::new();
315        ExchangeRateAPI {
316            client,
317            api_key: api_key.to_string(),
318        }
319    }
320
321    // -- internal helpers ---------------------------------------------------
322
323    fn headers(&self) -> HeaderMap {
324        let mut headers = HeaderMap::new();
325        let value = format!("Bearer {}", self.api_key);
326        headers.insert(
327            AUTHORIZATION,
328            HeaderValue::from_str(&value).expect("invalid API key characters"),
329        );
330        headers
331    }
332
333    fn get(&self, path: &str, params: &[(&str, &str)]) -> Result<String> {
334        let url = format!("{}{}", BASE_URL, path);
335        let resp = self
336            .client
337            .get(&url)
338            .headers(self.headers())
339            .query(params)
340            .send()?;
341
342        let status = resp.status();
343        let body = resp.text()?;
344
345        if !status.is_success() {
346            let message = match serde_json::from_str::<ApiErrorBody>(&body) {
347                Ok(err_body) => err_body
348                    .message
349                    .or(err_body.error)
350                    .unwrap_or_else(|| body.clone()),
351                Err(_) => body.clone(),
352            };
353            return Err(ExchangeRateAPIError::ApiError {
354                status: status.as_u16(),
355                message,
356            });
357        }
358
359        Ok(body)
360    }
361
362    // -- public endpoints ---------------------------------------------------
363
364    /// Fetches the latest exchange rates for a base currency.
365    ///
366    /// # Arguments
367    ///
368    /// * `base` - The base currency code (e.g. `"USD"`).
369    /// * `symbols` - Optional comma-separated list of target currencies
370    ///   (e.g. `Some("EUR,GBP,JPY")`). Pass `None` to get all available
371    ///   currencies.
372    ///
373    /// # Example
374    ///
375    /// ```no_run
376    /// # use exchange_rateapi::ExchangeRateAPI;
377    /// let client = ExchangeRateAPI::new("era_live_xxx");
378    /// let resp = client.latest("USD", Some("EUR,GBP")).unwrap();
379    /// println!("EUR rate: {}", resp.rates["EUR"]);
380    /// ```
381    pub fn latest(&self, base: &str, symbols: Option<&str>) -> Result<LatestResponse> {
382        let mut params: Vec<(&str, &str)> = vec![("base", base)];
383        if let Some(s) = symbols {
384            params.push(("symbols", s));
385        }
386        let body = self.get("/v1/latest", &params)?;
387        let parsed: LatestResponse = serde_json::from_str(&body)?;
388        Ok(parsed)
389    }
390
391    /// Converts an amount from one currency to another.
392    ///
393    /// # Arguments
394    ///
395    /// * `from` - Source currency code (e.g. `"USD"`).
396    /// * `to` - Target currency code (e.g. `"EUR"`).
397    /// * `amount` - The amount to convert.
398    ///
399    /// # Example
400    ///
401    /// ```no_run
402    /// # use exchange_rateapi::ExchangeRateAPI;
403    /// let client = ExchangeRateAPI::new("era_live_xxx");
404    /// let resp = client.convert("USD", "EUR", 250.0).unwrap();
405    /// println!("250 USD = {} EUR", resp.result);
406    /// ```
407    pub fn convert(&self, from: &str, to: &str, amount: f64) -> Result<ConvertResponse> {
408        let amount_str = amount.to_string();
409        let params = [("from", from), ("to", to), ("amount", &amount_str)];
410        let body = self.get("/v1/convert", &params)?;
411        let parsed: ConvertResponse = serde_json::from_str(&body)?;
412        Ok(parsed)
413    }
414
415    /// Fetches historical exchange rates for a specific date.
416    ///
417    /// # Arguments
418    ///
419    /// * `date` - The date in `YYYY-MM-DD` format.
420    /// * `base` - The base currency code (e.g. `"USD"`).
421    /// * `symbols` - Optional comma-separated list of target currencies.
422    ///
423    /// # Example
424    ///
425    /// ```no_run
426    /// # use exchange_rateapi::ExchangeRateAPI;
427    /// let client = ExchangeRateAPI::new("era_live_xxx");
428    /// let resp = client.for_date("2025-01-15", "USD", Some("EUR,GBP")).unwrap();
429    /// println!("Historical EUR rate: {}", resp.rates["EUR"]);
430    /// ```
431    pub fn for_date(
432        &self,
433        date: &str,
434        base: &str,
435        symbols: Option<&str>,
436    ) -> Result<HistoricalResponse> {
437        let mut params: Vec<(&str, &str)> = vec![("base", base)];
438        if let Some(s) = symbols {
439            params.push(("symbols", s));
440        }
441        let path = format!("/v1/history/{}", date);
442        let body = self.get(&path, &params)?;
443        let parsed: HistoricalResponse = serde_json::from_str(&body)?;
444        Ok(parsed)
445    }
446
447    /// Fetches exchange rates over a date range (time series).
448    ///
449    /// # Arguments
450    ///
451    /// * `start` - Start date in `YYYY-MM-DD` format (inclusive).
452    /// * `end` - End date in `YYYY-MM-DD` format (inclusive).
453    /// * `base` - The base currency code.
454    /// * `symbols` - Optional comma-separated list of target currencies.
455    ///
456    /// # Example
457    ///
458    /// ```no_run
459    /// # use exchange_rateapi::ExchangeRateAPI;
460    /// let client = ExchangeRateAPI::new("era_live_xxx");
461    /// let resp = client.time_series("2025-01-01", "2025-01-31", "USD", Some("EUR")).unwrap();
462    /// for (date, rates) in &resp.rates {
463    ///     println!("{}: EUR = {}", date, rates["EUR"]);
464    /// }
465    /// ```
466    pub fn time_series(
467        &self,
468        start: &str,
469        end: &str,
470        base: &str,
471        symbols: Option<&str>,
472    ) -> Result<TimeSeriesResponse> {
473        let mut params: Vec<(&str, &str)> =
474            vec![("start_date", start), ("end_date", end), ("base", base)];
475        if let Some(s) = symbols {
476            params.push(("symbols", s));
477        }
478        let body = self.get("/v1/timeseries", &params)?;
479        let parsed: TimeSeriesResponse = serde_json::from_str(&body)?;
480        Ok(parsed)
481    }
482
483    /// Lists all supported currency symbols.
484    ///
485    /// # Example
486    ///
487    /// ```no_run
488    /// # use exchange_rateapi::ExchangeRateAPI;
489    /// let client = ExchangeRateAPI::new("era_live_xxx");
490    /// let resp = client.symbols().unwrap();
491    /// for (code, name) in &resp.symbols {
492    ///     println!("{}: {}", code, name);
493    /// }
494    /// ```
495    pub fn symbols(&self) -> Result<SymbolsResponse> {
496        let body = self.get("/v1/symbols", &[])?;
497        let parsed: SymbolsResponse = serde_json::from_str(&body)?;
498        Ok(parsed)
499    }
500
501    /// Gets the exchange rate for a single currency pair.
502    ///
503    /// This is a convenience wrapper around [`latest`](Self::latest) that
504    /// returns just the rate as an `f64`.
505    ///
506    /// # Arguments
507    ///
508    /// * `from` - Source currency code (e.g. `"USD"`).
509    /// * `to` - Target currency code (e.g. `"EUR"`).
510    ///
511    /// # Example
512    ///
513    /// ```no_run
514    /// # use exchange_rateapi::ExchangeRateAPI;
515    /// let client = ExchangeRateAPI::new("era_live_xxx");
516    /// let rate = client.get_rate("USD", "EUR").unwrap();
517    /// println!("1 USD = {} EUR", rate);
518    /// ```
519    pub fn get_rate(&self, from: &str, to: &str) -> Result<f64> {
520        let resp = self.latest(from, Some(to))?;
521        resp.rates.get(to).copied().ok_or_else(|| {
522            ExchangeRateAPIError::ParseError(format!(
523                "currency '{}' not found in response",
524                to
525            ))
526        })
527    }
528
529    /// Gets historical rates for a preset time period.
530    ///
531    /// This is a convenience method that calculates the appropriate start and
532    /// end dates and calls [`time_series`](Self::time_series).
533    ///
534    /// # Arguments
535    ///
536    /// * `source` - Base currency code (e.g. `"USD"`).
537    /// * `target` - Target currency code (e.g. `"EUR"`).
538    /// * `period` - One of the preset [`Period`] values.
539    ///
540    /// # Example
541    ///
542    /// ```no_run
543    /// # use exchange_rateapi::{ExchangeRateAPI, Period};
544    /// let client = ExchangeRateAPI::new("era_live_xxx");
545    /// let resp = client.get_historical_rates("USD", "EUR", Period::SevenDays).unwrap();
546    /// for (date, rates) in &resp.rates {
547    ///     println!("{}: {}", date, rates["EUR"]);
548    /// }
549    /// ```
550    pub fn get_historical_rates(
551        &self,
552        source: &str,
553        target: &str,
554        period: Period,
555    ) -> Result<TimeSeriesResponse> {
556        let today = SimpleDate::today();
557        let start = today.subtract_days(period.days());
558        self.time_series(&start.format(), &today.format(), source, Some(target))
559    }
560}
561
562#[cfg(test)]
563mod tests {
564    use super::*;
565
566    #[test]
567    fn test_period_from_str() {
568        assert_eq!(Period::from_str("1d"), Some(Period::OneDay));
569        assert_eq!(Period::from_str("7d"), Some(Period::SevenDays));
570        assert_eq!(Period::from_str("30d"), Some(Period::ThirtyDays));
571        assert_eq!(Period::from_str("1y"), Some(Period::OneYear));
572        assert_eq!(Period::from_str("invalid"), None);
573    }
574
575    #[test]
576    fn test_period_days() {
577        assert_eq!(Period::OneDay.days(), 1);
578        assert_eq!(Period::SevenDays.days(), 7);
579        assert_eq!(Period::ThirtyDays.days(), 30);
580        assert_eq!(Period::OneYear.days(), 365);
581    }
582
583    #[test]
584    fn test_simple_date_format() {
585        let d = SimpleDate {
586            year: 2025,
587            month: 3,
588            day: 5,
589        };
590        assert_eq!(d.format(), "2025-03-05");
591    }
592
593    #[test]
594    fn test_simple_date_roundtrip() {
595        let d = SimpleDate {
596            year: 2025,
597            month: 6,
598            day: 15,
599        };
600        let epoch = d.to_epoch_days();
601        let d2 = SimpleDate::from_epoch_days(epoch);
602        assert_eq!(d2.year, 2025);
603        assert_eq!(d2.month, 6);
604        assert_eq!(d2.day, 15);
605    }
606
607    #[test]
608    fn test_subtract_days() {
609        let d = SimpleDate {
610            year: 2025,
611            month: 1,
612            day: 10,
613        };
614        let d2 = d.subtract_days(10);
615        assert_eq!(d2.format(), "2024-12-31");
616    }
617
618    #[test]
619    fn test_error_display() {
620        let err = ExchangeRateAPIError::ApiError {
621            status: 401,
622            message: "Unauthorized".to_string(),
623        };
624        assert_eq!(format!("{}", err), "API error (401): Unauthorized");
625
626        let err = ExchangeRateAPIError::ParseError("bad json".to_string());
627        assert_eq!(format!("{}", err), "Parse error: bad json");
628    }
629
630    #[test]
631    fn test_client_creation() {
632        let client = ExchangeRateAPI::new("era_live_test123");
633        assert_eq!(client.api_key, "era_live_test123");
634    }
635}