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", ¶ms)?;
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", ¶ms)?;
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, ¶ms)?;
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", ¶ms)?;
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}