unitx_core/providers/
live.rs

1use 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/// Provider that fetches live FX rates using the European Central Bank daily feed.
15#[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    /// Create a provider that hits the default ECB endpoint.
25    pub fn new(_unused_access_key: Option<String>) -> Self {
26        Self::with_base_url(DEFAULT_SOURCE, None)
27    }
28
29    /// Create a provider pointing at a custom ECB-compatible endpoint.
30    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}