Skip to main content

mudra_cli/api/
exchange_service.rs

1//! Enhanced exchange rate service with caching and historical data support
2
3use crate::{
4    CurrencyError, Result,
5    api::{
6        client::CurrencyClient,
7        types::{
8            ApiErrorResponse, ConversionResponse, ExchangeRateResponse,
9            HistoricalConversionRequest, HistoricalRateResponse, SupportedCurrenciesResponse,
10            is_valid_currency_code, is_valid_date_format,
11        },
12    },
13    cache::{CacheKey, ExchangeRateCache},
14};
15
16/// Enhanced service for exchange rate operations with caching
17#[derive(Debug, Clone)]
18pub struct ExchangeRateService {
19    client: CurrencyClient,
20    cache: ExchangeRateCache,
21}
22
23impl ExchangeRateService {
24    /// Create a new exchange rate service with caching
25    pub fn new(client: CurrencyClient) -> Self {
26        Self {
27            client,
28            cache: ExchangeRateCache::new(),
29        }
30    }
31
32    /// Create a service from environment variables
33    pub fn from_env() -> Result<Self> {
34        let client = CurrencyClient::from_env()?;
35        Ok(Self::new(client))
36    }
37
38    /// Create a service with custom cache configuration
39    pub fn with_cache_config(
40        client: CurrencyClient,
41        cache_config: crate::cache::CacheConfig,
42    ) -> Self {
43        Self {
44            client,
45            cache: ExchangeRateCache::with_config(cache_config),
46        }
47    }
48
49    /// Fetch latest exchange rates for a base currency (with caching)
50    pub async fn get_latest_rates(&self, base_currency: &str) -> Result<ExchangeRateResponse> {
51        // Validate currency code format
52        self.validate_currency_code(base_currency)?;
53
54        let cache_key = CacheKey::latest(base_currency);
55
56        // Try cache first
57        if let Some(cached_rates) = self.cache.get(&cache_key).await {
58            return Ok(cached_rates);
59        }
60
61        // Cache miss - fetch from API
62
63        // Get API key for the request
64        let api_key = self.get_api_key()?;
65
66        // Build endpoint URL
67        let endpoint = format!("{}/latest/{}", api_key, base_currency.to_uppercase());
68
69        // Make the API request
70        match self.client.get::<ExchangeRateResponse>(&endpoint).await {
71            Ok(response) => {
72                if response.is_success() {
73                    // Cache the response
74                    self.cache.put(cache_key, response.clone()).await;
75                    Ok(response)
76                } else {
77                    Err(CurrencyError::api(format!(
78                        "API returned unsuccessful result: {}",
79                        response.result
80                    )))
81                }
82            }
83            Err(e) => {
84                // Try to parse as an error response for better error messages
85                if let Ok(error_response) = self.try_parse_error_response(&endpoint).await {
86                    return Err(CurrencyError::api(format!(
87                        "API error: {} - {}",
88                        error_response.error_type,
89                        error_response.extra_info.unwrap_or_default()
90                    )));
91                }
92                Err(e)
93            }
94        }
95    }
96
97    /// Fetch historical exchange rates for a specific date
98    pub async fn get_historical_rates(
99        &self,
100        base_currency: &str,
101        date: &str,
102    ) -> Result<HistoricalRateResponse> {
103        // Validate inputs
104        self.validate_currency_code(base_currency)?;
105        self.validate_date_format(date)?;
106
107        let cache_key = CacheKey::historical(base_currency, date);
108
109        // Try cache first - convert standard response to historical if cached
110        if let Some(cached_rates) = self.cache.get(&cache_key).await {
111            println!(
112                "๐Ÿ“‹ Using cached historical rates for {} on {}",
113                base_currency.to_uppercase(),
114                date
115            );
116            return Ok(cached_rates.to_historical(date));
117        }
118
119        // Cache miss - fetch from API
120        println!(
121            "๐Ÿ“Š Fetching historical rates for {} on {}",
122            base_currency.to_uppercase(),
123            date
124        );
125
126        let api_key = self.get_api_key()?;
127        let endpoint = format!(
128            "history/{}/{}/{}",
129            api_key,
130            base_currency.to_uppercase(),
131            date
132        );
133
134        // Try to fetch as historical response, fallback to standard response
135        match self.client.get::<HistoricalRateResponse>(&endpoint).await {
136            Ok(response) => {
137                if response.is_success() {
138                    println!("โœ… Successfully fetched historical rates for {}", date);
139
140                    // Cache as standard response
141                    let standard_response = response.to_standard();
142                    self.cache.put(cache_key, standard_response).await;
143
144                    Ok(response)
145                } else {
146                    Err(CurrencyError::api(format!(
147                        "Historical data request failed: {}",
148                        response.result
149                    )))
150                }
151            }
152            Err(_) => {
153                // Fallback: try to get current rates and convert to historical format
154                match self.get_latest_rates(base_currency).await {
155                    Ok(current_rates) => {
156                        println!("โš ๏ธ Using current rates as historical fallback for {}", date);
157                        Ok(current_rates.to_historical(date))
158                    }
159                    Err(e) => Err(e),
160                }
161            }
162        }
163    }
164
165    /// Convert currency with historical data support
166    pub async fn convert_historical(
167        &self,
168        request: HistoricalConversionRequest,
169    ) -> Result<ConversionResponse> {
170        // Validate inputs
171        self.validate_currency_code(&request.from)?;
172        self.validate_currency_code(&request.to)?;
173        self.validate_amount(request.amount)?;
174        self.validate_date_format(&request.date)?;
175
176        println!(
177            "๐Ÿ’ฑ Historical conversion: {} {} to {} on {}",
178            request.amount,
179            request.from.to_uppercase(),
180            request.to.to_uppercase(),
181            request.date
182        );
183
184        // Get historical rates for the base currency
185        let rates = self
186            .get_historical_rates(&request.from, &request.date)
187            .await?;
188
189        // Find the target currency rate
190        let rate = rates.get_rate(&request.to.to_uppercase()).ok_or_else(|| {
191            CurrencyError::api(format!(
192                "Historical rate not available for {} on {}",
193                request.to.to_uppercase(),
194                request.date
195            ))
196        })?;
197
198        let converted_amount = request.amount * rate;
199
200        println!(
201            "โœ… Historical conversion: {} {} = {:.6} {} (rate: {:.6})",
202            request.amount,
203            request.from.to_uppercase(),
204            converted_amount,
205            request.to.to_uppercase(),
206            rate
207        );
208
209        Ok(ConversionResponse {
210            result: "success".to_string(),
211            base_code: request.from.to_uppercase(),
212            target_code: request.to.to_uppercase(),
213            conversion_rate: rate,
214            conversion_result: converted_amount,
215        })
216    }
217
218    /// Convert a specific amount between two currencies (current rates)
219    pub async fn convert_currency(
220        &self,
221        from: &str,
222        to: &str,
223        amount: f64,
224    ) -> Result<ConversionResponse> {
225        // Validate inputs
226        self.validate_currency_code(from)?;
227        self.validate_currency_code(to)?;
228        self.validate_amount(amount)?;
229
230        let api_key = self.get_api_key()?;
231
232        // Build endpoint URL for pair conversion
233        let endpoint = format!(
234            "pair/{}/{}/{}/{}",
235            api_key,
236            from.to_uppercase(),
237            to.to_uppercase(),
238            amount
239        );
240
241        println!(
242            "๐Ÿ’ฑ Converting {} {} to {}",
243            amount,
244            from.to_uppercase(),
245            to.to_uppercase()
246        );
247
248        // Make the API request
249        match self.client.get::<ConversionResponse>(&endpoint).await {
250            Ok(response) => {
251                if response.is_success() {
252                    println!(
253                        "โœ… Conversion successful: {} {} = {} {}",
254                        amount,
255                        from.to_uppercase(),
256                        response.conversion_result,
257                        to.to_uppercase()
258                    );
259                    Ok(response)
260                } else {
261                    Err(CurrencyError::api(format!(
262                        "Conversion failed: {}",
263                        response.result
264                    )))
265                }
266            }
267            Err(e) => {
268                if let Ok(error_response) = self.try_parse_error_response(&endpoint).await {
269                    return Err(CurrencyError::api(format!(
270                        "Conversion error: {} - {}",
271                        error_response.error_type,
272                        error_response.extra_info.unwrap_or_default()
273                    )));
274                }
275                Err(e)
276            }
277        }
278    }
279
280    /// Get list of supported currencies (cached)
281    pub async fn get_supported_currencies(&self) -> Result<SupportedCurrenciesResponse> {
282        let api_key = self.get_api_key()?;
283        let endpoint = format!("codes/{}", api_key);
284
285        println!("๐Ÿ“‹ Fetching supported currencies");
286
287        let response = self
288            .client
289            .get::<SupportedCurrenciesResponse>(&endpoint)
290            .await?;
291
292        if response.result == "success" {
293            println!(
294                "โœ… Found {} supported currencies",
295                response.supported_codes.len()
296            );
297            Ok(response)
298        } else {
299            Err(CurrencyError::api(format!(
300                "Failed to fetch supported currencies: {}",
301                response.result
302            )))
303        }
304    }
305
306    /// Check if a currency is supported by fetching a small set of rates
307    pub async fn is_currency_supported(&self, currency: &str) -> Result<bool> {
308        match self.get_latest_rates(currency).await {
309            Ok(_) => Ok(true),
310            Err(CurrencyError::Api { message }) if message.contains("unsupported-code") => {
311                Ok(false)
312            }
313            Err(e) => Err(e),
314        }
315    }
316
317    /// Batch convert multiple currency pairs efficiently
318    pub async fn batch_convert(
319        &self,
320        base_currency: &str,
321        target_currencies: &[String],
322        amount: f64,
323    ) -> Result<Vec<Result<ConversionResponse>>> {
324        // Get rates for the base currency once
325        let rates = self.get_latest_rates(base_currency).await?;
326
327        println!(
328            "๐Ÿ”„ Batch converting {} {} to {} currencies",
329            amount,
330            base_currency.to_uppercase(),
331            target_currencies.len()
332        );
333
334        let mut results = Vec::new();
335
336        for target in target_currencies {
337            let result = if let Some(rate) = rates.get_rate(&target.to_uppercase()) {
338                let converted_amount = amount * rate;
339                Ok(ConversionResponse {
340                    result: "success".to_string(),
341                    base_code: base_currency.to_uppercase(),
342                    target_code: target.to_uppercase(),
343                    conversion_rate: rate,
344                    conversion_result: converted_amount,
345                })
346            } else {
347                Err(CurrencyError::api(format!(
348                    "Exchange rate not available for {}",
349                    target.to_uppercase()
350                )))
351            };
352            results.push(result);
353        }
354
355        println!("โœ… Completed batch conversion");
356        Ok(results)
357    }
358
359    /// Get cache statistics
360    pub fn get_cache_stats(&self) -> crate::cache::CacheStats {
361        self.cache.get_stats()
362    }
363
364    /// Clear cache manually
365    pub async fn clear_cache(&self) {
366        self.cache.clear().await;
367        println!("๐Ÿ—‘๏ธ Cache cleared");
368    }
369
370    /// Force cache cleanup of expired entries
371    pub async fn cleanup_cache(&self) {
372        self.cache.cleanup_expired().await;
373        println!("๐Ÿงน Cache cleanup completed");
374    }
375
376    /// Validate currency code format
377    fn validate_currency_code(&self, code: &str) -> Result<()> {
378        if !is_valid_currency_code(code) {
379            return Err(CurrencyError::invalid_currency(format!(
380                "{} (must be 3 uppercase letters, e.g., USD, EUR, GBP)",
381                code
382            )));
383        }
384        Ok(())
385    }
386
387    /// Validate date format (YYYY-MM-DD)
388    fn validate_date_format(&self, date: &str) -> Result<()> {
389        if !is_valid_date_format(date) {
390            return Err(CurrencyError::conversion(format!(
391                "Invalid date format: '{}'. Use YYYY-MM-DD format (e.g., 2024-01-15)",
392                date
393            )));
394        }
395        Ok(())
396    }
397
398    /// Validate amount for conversion
399    fn validate_amount(&self, amount: f64) -> Result<()> {
400        if amount <= 0.0 {
401            return Err(CurrencyError::invalid_amount(amount));
402        }
403        if amount.is_nan() || amount.is_infinite() {
404            return Err(CurrencyError::invalid_amount(amount));
405        }
406        // Reasonable upper limit to prevent abuse
407        if amount > 1_000_000_000.0 {
408            return Err(CurrencyError::invalid_amount(amount));
409        }
410        Ok(())
411    }
412
413    /// Get API key from client configuration
414    fn get_api_key(&self) -> Result<String> {
415        if !self.client.has_api_key() {
416            return Err(CurrencyError::configuration(
417                "API key required. Set EXCHANGE_API_KEY environment variable",
418            ));
419        }
420        // For now, we'll assume the API key is available
421        // In a real implementation, you'd extract it from the client
422        std::env::var("EXCHANGE_API_KEY")
423            .map_err(|_| CurrencyError::configuration("EXCHANGE_API_KEY not found"))
424    }
425
426    /// Try to parse an error response for better error messages
427    async fn try_parse_error_response(&self, endpoint: &str) -> Result<ApiErrorResponse> {
428        // This is a simplified approach - in practice you might want to
429        // capture the original response body in the error
430        self.client.get::<ApiErrorResponse>(endpoint).await
431    }
432}