mudra-cli 0.1.0

A robust, high-performance currency converter with caching and CLI interface
Documentation
//! HTTP client for currency API operations

use crate::{CurrencyError, Result, config::Config};
use reqwest::{Client, Response, StatusCode};
use serde::de::DeserializeOwned;
use std::time::Duration;
use tokio::time::sleep;

/// HTTP client wrapper for currency API operations
#[derive(Debug, Clone)]
pub struct CurrencyClient {
    /// The underlying HTTP client
    client: Client,
    /// Configuration settings
    config: Config,
}

impl CurrencyClient {
    /// Create a new currency client with default configuration
    pub fn new() -> Result<Self> {
        let config = Config::new();
        Self::with_config(config)
    }

    /// Create a new currency client with custom configuration
    pub fn with_config(config: Config) -> Result<Self> {
        // Build the HTTP client with our configuration
        let client = Client::builder()
            .timeout(config.timeout)
            .user_agent(&config.user_agent)
            .build()
            .map_err(|e| {
                CurrencyError::configuration(format!("Failed to create HTTP client: {}", e))
            })?;

        Ok(CurrencyClient { client, config })
    }

    /// Create a client from environment variables
    pub fn from_env() -> Result<Self> {
        let config = Config::from_env()?;
        Self::with_config(config)
    }

    /// Make a GET request to the specified endpoint
    pub async fn get<T>(&self, endpoint: &str) -> Result<T>
    where
        T: DeserializeOwned,
    {
        let url = format!(
            "{}/{}",
            self.config.base_url,
            endpoint.trim_start_matches('/')
        );

        // Retry logic for failed requests
        let mut last_error = None;

        for attempt in 1..=self.config.max_retries {
            match self.make_request(&url).await {
                Ok(response) => {
                    return self.handle_response(response).await;
                }
                Err(e) => {
                    last_error = Some(e);

                    if attempt < self.config.max_retries {
                        // Exponential backoff: wait longer between each retry
                        let delay = Duration::from_millis(1000 * 2_u64.pow(attempt - 1));
                        sleep(delay).await;
                    }
                }
            }
        }

        // If we get here, all retries failed
        Err(last_error.unwrap_or_else(|| CurrencyError::api("All retries exhausted")))
    }

    /// Make the actual HTTP request
    async fn make_request(&self, url: &str) -> Result<Response> {
        let response = self.client.get(url).send().await?; // The ? operator converts reqwest::Error to our CurrencyError

        Ok(response)
    }

    /// Handle the HTTP response, checking status codes and parsing JSON
    async fn handle_response<T>(&self, response: Response) -> Result<T>
    where
        T: DeserializeOwned,
    {
        let status = response.status();

        // Check for HTTP error status codes
        match status {
            StatusCode::OK => {
                // Success - parse the JSON response
                let json_text = response.text().await?;

                // Parse JSON and provide better error context
                serde_json::from_str(&json_text).map_err(|e| CurrencyError::from(e))
            }
            StatusCode::UNAUTHORIZED => {
                Err(CurrencyError::api("Invalid API key or unauthorized access"))
            }
            StatusCode::TOO_MANY_REQUESTS => Err(CurrencyError::api(
                "Rate limit exceeded. Please try again later",
            )),
            StatusCode::NOT_FOUND => Err(CurrencyError::api("API endpoint not found")),
            StatusCode::BAD_REQUEST => {
                // Try to get error details from response body
                let error_text = response
                    .text()
                    .await
                    .unwrap_or_else(|_| "Bad request".to_string());
                Err(CurrencyError::api(format!("Bad request: {}", error_text)))
            }
            _ => {
                let error_text = response
                    .text()
                    .await
                    .unwrap_or_else(|_| "Unknown error".to_string());
                Err(CurrencyError::api(format!(
                    "HTTP error {}: {}",
                    status.as_u16(),
                    error_text
                )))
            }
        }
    }

    /// Get the base URL being used
    pub fn base_url(&self) -> &str {
        &self.config.base_url
    }

    /// Check if the client is configured with an API key
    pub fn has_api_key(&self) -> bool {
        self.config.api_key.is_some()
    }
}