use std::collections::HashMap;
use std::fmt;
use reqwest::blocking::Client;
use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION};
use serde::{Deserialize, Serialize};
const BASE_URL: &str = "https://exchange-rateapi.com";
#[derive(Debug)]
pub enum ExchangeRateAPIError {
HttpError(reqwest::Error),
ApiError {
status: u16,
message: String,
},
ParseError(String),
}
impl fmt::Display for ExchangeRateAPIError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ExchangeRateAPIError::HttpError(e) => write!(f, "HTTP error: {}", e),
ExchangeRateAPIError::ApiError { status, message } => {
write!(f, "API error ({}): {}", status, message)
}
ExchangeRateAPIError::ParseError(msg) => write!(f, "Parse error: {}", msg),
}
}
}
impl std::error::Error for ExchangeRateAPIError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
ExchangeRateAPIError::HttpError(e) => Some(e),
_ => None,
}
}
}
impl From<reqwest::Error> for ExchangeRateAPIError {
fn from(err: reqwest::Error) -> Self {
ExchangeRateAPIError::HttpError(err)
}
}
impl From<serde_json::Error> for ExchangeRateAPIError {
fn from(err: serde_json::Error) -> Self {
ExchangeRateAPIError::ParseError(err.to_string())
}
}
pub type Result<T> = std::result::Result<T, ExchangeRateAPIError>;
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct LatestResponse {
pub success: bool,
pub base: String,
pub date: String,
pub rates: HashMap<String, f64>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ConvertResponse {
pub success: bool,
pub from: String,
pub to: String,
pub amount: f64,
pub result: f64,
pub rate: f64,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct HistoricalResponse {
pub success: bool,
pub base: String,
pub date: String,
pub rates: HashMap<String, f64>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct TimeSeriesResponse {
pub success: bool,
pub base: String,
pub start_date: String,
pub end_date: String,
pub rates: HashMap<String, HashMap<String, f64>>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct SymbolsResponse {
pub success: bool,
pub symbols: HashMap<String, String>,
}
pub type SingleRateResponse = LatestResponse;
#[derive(Debug, Deserialize)]
struct ApiErrorBody {
#[serde(default)]
message: Option<String>,
#[serde(default)]
error: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Period {
OneDay,
SevenDays,
ThirtyDays,
OneYear,
}
impl Period {
fn days(self) -> i64 {
match self {
Period::OneDay => 1,
Period::SevenDays => 7,
Period::ThirtyDays => 30,
Period::OneYear => 365,
}
}
pub fn from_str(s: &str) -> Option<Period> {
match s {
"1d" => Some(Period::OneDay),
"7d" => Some(Period::SevenDays),
"30d" => Some(Period::ThirtyDays),
"1y" => Some(Period::OneYear),
_ => None,
}
}
}
struct SimpleDate {
year: i32,
month: u32,
day: u32,
}
impl SimpleDate {
fn today() -> Self {
let dur = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system clock before UNIX epoch");
let total_days = (dur.as_secs() / 86400) as i64;
Self::from_epoch_days(total_days)
}
fn from_epoch_days(mut days: i64) -> Self {
days += 719_468;
let era = if days >= 0 { days } else { days - 146_096 } / 146_097;
let doe = (days - era * 146_097) as u32; let yoe =
(doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
let y = (yoe as i64 + era * 400) as i32;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
SimpleDate {
year: y,
month: m,
day: d,
}
}
fn subtract_days(&self, n: i64) -> Self {
let epoch = self.to_epoch_days() - n;
Self::from_epoch_days(epoch)
}
fn to_epoch_days(&self) -> i64 {
let y = if self.month <= 2 {
self.year as i64 - 1
} else {
self.year as i64
};
let m = if self.month <= 2 {
self.month as i64 + 9
} else {
self.month as i64 - 3
};
let era = if y >= 0 { y } else { y - 399 } / 400;
let yoe = (y - era * 400) as u64;
let doy = (153 * (m as u64) + 2) / 5 + self.day as u64 - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
era * 146_097 + doe as i64 - 719_468
}
fn format(&self) -> String {
format!("{:04}-{:02}-{:02}", self.year, self.month, self.day)
}
}
pub struct ExchangeRateAPI {
client: Client,
api_key: String,
}
impl ExchangeRateAPI {
pub fn new(api_key: &str) -> Self {
let client = Client::new();
ExchangeRateAPI {
client,
api_key: api_key.to_string(),
}
}
fn headers(&self) -> HeaderMap {
let mut headers = HeaderMap::new();
let value = format!("Bearer {}", self.api_key);
headers.insert(
AUTHORIZATION,
HeaderValue::from_str(&value).expect("invalid API key characters"),
);
headers
}
fn get(&self, path: &str, params: &[(&str, &str)]) -> Result<String> {
let url = format!("{}{}", BASE_URL, path);
let resp = self
.client
.get(&url)
.headers(self.headers())
.query(params)
.send()?;
let status = resp.status();
let body = resp.text()?;
if !status.is_success() {
let message = match serde_json::from_str::<ApiErrorBody>(&body) {
Ok(err_body) => err_body
.message
.or(err_body.error)
.unwrap_or_else(|| body.clone()),
Err(_) => body.clone(),
};
return Err(ExchangeRateAPIError::ApiError {
status: status.as_u16(),
message,
});
}
Ok(body)
}
pub fn latest(&self, base: &str, symbols: Option<&str>) -> Result<LatestResponse> {
let mut params: Vec<(&str, &str)> = vec![("base", base)];
if let Some(s) = symbols {
params.push(("symbols", s));
}
let body = self.get("/v1/latest", ¶ms)?;
let parsed: LatestResponse = serde_json::from_str(&body)?;
Ok(parsed)
}
pub fn convert(&self, from: &str, to: &str, amount: f64) -> Result<ConvertResponse> {
let amount_str = amount.to_string();
let params = [("from", from), ("to", to), ("amount", &amount_str)];
let body = self.get("/v1/convert", ¶ms)?;
let parsed: ConvertResponse = serde_json::from_str(&body)?;
Ok(parsed)
}
pub fn for_date(
&self,
date: &str,
base: &str,
symbols: Option<&str>,
) -> Result<HistoricalResponse> {
let mut params: Vec<(&str, &str)> = vec![("base", base)];
if let Some(s) = symbols {
params.push(("symbols", s));
}
let path = format!("/v1/history/{}", date);
let body = self.get(&path, ¶ms)?;
let parsed: HistoricalResponse = serde_json::from_str(&body)?;
Ok(parsed)
}
pub fn time_series(
&self,
start: &str,
end: &str,
base: &str,
symbols: Option<&str>,
) -> Result<TimeSeriesResponse> {
let mut params: Vec<(&str, &str)> =
vec![("start_date", start), ("end_date", end), ("base", base)];
if let Some(s) = symbols {
params.push(("symbols", s));
}
let body = self.get("/v1/timeseries", ¶ms)?;
let parsed: TimeSeriesResponse = serde_json::from_str(&body)?;
Ok(parsed)
}
pub fn symbols(&self) -> Result<SymbolsResponse> {
let body = self.get("/v1/symbols", &[])?;
let parsed: SymbolsResponse = serde_json::from_str(&body)?;
Ok(parsed)
}
pub fn get_rate(&self, from: &str, to: &str) -> Result<f64> {
let resp = self.latest(from, Some(to))?;
resp.rates.get(to).copied().ok_or_else(|| {
ExchangeRateAPIError::ParseError(format!(
"currency '{}' not found in response",
to
))
})
}
pub fn get_historical_rates(
&self,
source: &str,
target: &str,
period: Period,
) -> Result<TimeSeriesResponse> {
let today = SimpleDate::today();
let start = today.subtract_days(period.days());
self.time_series(&start.format(), &today.format(), source, Some(target))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_period_from_str() {
assert_eq!(Period::from_str("1d"), Some(Period::OneDay));
assert_eq!(Period::from_str("7d"), Some(Period::SevenDays));
assert_eq!(Period::from_str("30d"), Some(Period::ThirtyDays));
assert_eq!(Period::from_str("1y"), Some(Period::OneYear));
assert_eq!(Period::from_str("invalid"), None);
}
#[test]
fn test_period_days() {
assert_eq!(Period::OneDay.days(), 1);
assert_eq!(Period::SevenDays.days(), 7);
assert_eq!(Period::ThirtyDays.days(), 30);
assert_eq!(Period::OneYear.days(), 365);
}
#[test]
fn test_simple_date_format() {
let d = SimpleDate {
year: 2025,
month: 3,
day: 5,
};
assert_eq!(d.format(), "2025-03-05");
}
#[test]
fn test_simple_date_roundtrip() {
let d = SimpleDate {
year: 2025,
month: 6,
day: 15,
};
let epoch = d.to_epoch_days();
let d2 = SimpleDate::from_epoch_days(epoch);
assert_eq!(d2.year, 2025);
assert_eq!(d2.month, 6);
assert_eq!(d2.day, 15);
}
#[test]
fn test_subtract_days() {
let d = SimpleDate {
year: 2025,
month: 1,
day: 10,
};
let d2 = d.subtract_days(10);
assert_eq!(d2.format(), "2024-12-31");
}
#[test]
fn test_error_display() {
let err = ExchangeRateAPIError::ApiError {
status: 401,
message: "Unauthorized".to_string(),
};
assert_eq!(format!("{}", err), "API error (401): Unauthorized");
let err = ExchangeRateAPIError::ParseError("bad json".to_string());
assert_eq!(format!("{}", err), "Parse error: bad json");
}
#[test]
fn test_client_creation() {
let client = ExchangeRateAPI::new("era_live_test123");
assert_eq!(client.api_key, "era_live_test123");
}
}