unitx_core/providers/
live.rs1use crate::currency::CurrencyUnit;
2use crate::providers::ExchangeRateProvider;
3use isahc::{prelude::*, HttpClient, Request};
4use quick_xml::events::Event;
5use quick_xml::Reader;
6use rust_decimal::Decimal;
7use std::collections::HashMap;
8use std::str::FromStr;
9use std::sync::{Arc, Mutex};
10use std::time::Duration;
11
12const DEFAULT_SOURCE: &str = "https://www.ecb.europa.eu/stats/eurofxref/eurofxref-daily.xml";
13
14#[derive(Clone)]
16pub struct LiveExchangeProvider {
17 client: HttpClient,
18 source_url: String,
19 cache: Arc<Mutex<HashMap<(CurrencyUnit, CurrencyUnit), Decimal>>>,
20 base_rates: Arc<Mutex<Option<HashMap<CurrencyUnit, Decimal>>>>,
21}
22
23impl LiveExchangeProvider {
24 pub fn new(_unused_access_key: Option<String>) -> Self {
26 Self::with_base_url(DEFAULT_SOURCE, None)
27 }
28
29 pub fn with_base_url(base_url: impl Into<String>, _unused_access_key: Option<String>) -> Self {
31 let client = HttpClient::builder()
32 .timeout(Duration::from_secs(5))
33 .build()
34 .expect("failed to build HTTP client");
35
36 Self {
37 client,
38 source_url: base_url.into(),
39 cache: Arc::new(Mutex::new(HashMap::new())),
40 base_rates: Arc::new(Mutex::new(None)),
41 }
42 }
43
44 fn get_base_rates(&self) -> Result<HashMap<CurrencyUnit, Decimal>, String> {
45 let mut guard = self.base_rates.lock().expect("live rates cache poisoned");
46
47 if let Some(rates) = guard.as_ref() {
48 return Ok(rates.clone());
49 }
50
51 let fetched = self.fetch_base_rates()?;
52 *guard = Some(fetched.clone());
53 Ok(fetched)
54 }
55
56 fn fetch_base_rates(&self) -> Result<HashMap<CurrencyUnit, Decimal>, String> {
57 let request = Request::get(&self.source_url)
58 .timeout(Duration::from_secs(5))
59 .header("Accept", "application/xml")
60 .body(())
61 .map_err(|err| format!("failed to build ECB request: {}", err))?;
62
63 let mut response = self
64 .client
65 .send(request)
66 .map_err(|err| format!("failed to fetch ECB rates: {}", err))?;
67
68 if !response.status().is_success() {
69 return Err(format!(
70 "ECB rates endpoint returned HTTP {}",
71 response.status()
72 ));
73 }
74
75 let body = response
76 .text()
77 .map_err(|err| format!("failed to read ECB payload: {}", err))?;
78
79 let mut reader = Reader::from_str(&body);
80 reader.trim_text(true);
81
82 let mut buf = Vec::new();
83 let mut rates = HashMap::new();
84 rates.insert(CurrencyUnit::EUR, Decimal::ONE);
85
86 loop {
87 match reader.read_event_into(&mut buf) {
88 Ok(Event::Empty(ref e)) | Ok(Event::Start(ref e)) => {
89 if e.name().as_ref() == b"Cube" {
90 let mut currency = None;
91 let mut rate = None;
92
93 for attr in e.attributes() {
94 match attr {
95 Ok(attr) => match attr.key.as_ref() {
96 b"currency" => {
97 currency =
98 Some(String::from_utf8_lossy(&attr.value).into_owned());
99 }
100 b"rate" => {
101 rate =
102 Some(String::from_utf8_lossy(&attr.value).into_owned());
103 }
104 _ => {}
105 },
106 Err(err) => {
107 return Err(format!("invalid attribute in ECB feed: {}", err));
108 }
109 }
110 }
111
112 if let (Some(code), Some(rate_str)) = (currency, rate) {
113 if let Some(unit) = CurrencyUnit::parse(&code) {
114 match Decimal::from_str(&rate_str) {
115 Ok(value) => {
116 rates.insert(unit, value);
117 }
118 Err(err) => {
119 return Err(format!(
120 "invalid rate for {} in ECB feed: {}",
121 code, err
122 ));
123 }
124 }
125 }
126 }
127 }
128 }
129 Ok(Event::Eof) => break,
130 Err(err) => {
131 return Err(format!("failed to parse ECB feed: {}", err));
132 }
133 _ => {}
134 }
135 buf.clear();
136 }
137
138 Ok(rates)
139 }
140}
141
142impl ExchangeRateProvider for LiveExchangeProvider {
143 fn get_rate(&self, from: CurrencyUnit, to: CurrencyUnit) -> Result<Decimal, String> {
144 if from == to {
145 return Ok(Decimal::ONE);
146 }
147
148 if let Some(rate) = self
149 .cache
150 .lock()
151 .expect("live rates cache poisoned")
152 .get(&(from, to))
153 .copied()
154 {
155 return Ok(rate);
156 }
157
158 let rates = self.get_base_rates()?;
159
160 let from_rate = rates
161 .get(&from)
162 .ok_or_else(|| format!("ECB feed does not provide a rate for {:?}", from))?;
163
164 let to_rate = rates
165 .get(&to)
166 .ok_or_else(|| format!("ECB feed does not provide a rate for {:?}", to))?;
167
168 let computed = if from == CurrencyUnit::EUR {
169 *to_rate
170 } else if to == CurrencyUnit::EUR {
171 Decimal::ONE
172 .checked_div(*from_rate)
173 .ok_or_else(|| format!("division overflow converting {:?} to EUR", from))?
174 } else {
175 to_rate
176 .checked_div(*from_rate)
177 .ok_or_else(|| format!("division overflow converting {:?} -> {:?}", from, to))?
178 };
179
180 self.cache
181 .lock()
182 .expect("live rates cache poisoned")
183 .insert((from, to), computed);
184
185 Ok(computed)
186 }
187}