yldfi_common/
api.rs

1//! Generic API client infrastructure
2//!
3//! This module provides shared types for API clients, reducing boilerplate
4//! across the DEX aggregator crates.
5//!
6//! # Error Types
7//!
8//! ```
9//! use yldfi_common::api::{ApiError, ApiResult};
10//!
11//! // Use with no domain-specific errors
12//! type MyResult<T> = ApiResult<T>;
13//!
14//! // Or with domain-specific errors
15//! #[derive(Debug, thiserror::Error)]
16//! enum MyDomainError {
17//!     #[error("No route found")]
18//!     NoRouteFound,
19//! }
20//!
21//! type MyError = ApiError<MyDomainError>;
22//! ```
23//!
24//! # Config Types
25//!
26//! ```no_run
27//! use yldfi_common::api::ApiConfig;
28//!
29//! let config = ApiConfig::new("https://api.example.com")
30//!     .api_key("your-key")
31//!     .with_timeout_secs(30);
32//! ```
33
34use crate::http::{HttpClientConfig, HttpError};
35use crate::RetryableError;
36use reqwest::Client;
37use std::fmt;
38use std::time::Duration;
39use thiserror::Error;
40
41/// Marker type for API errors with no domain-specific variants
42///
43/// This type is used as the default for `ApiError<E>` when no domain-specific
44/// errors are needed. It can never be constructed, so the `Domain` variant
45/// is effectively unused.
46#[derive(Debug, Clone, Copy)]
47pub enum NoDomainError {}
48
49impl fmt::Display for NoDomainError {
50    fn fmt(&self, _f: &mut fmt::Formatter<'_>) -> fmt::Result {
51        match *self {}
52    }
53}
54
55impl std::error::Error for NoDomainError {}
56
57/// Generic API error type with support for domain-specific errors
58///
59/// This error type covers the common error cases across all API clients:
60/// - HTTP transport errors
61/// - JSON parsing errors
62/// - API response errors (4xx)
63/// - Rate limiting (429)
64/// - Server errors (5xx)
65///
66/// Domain-specific errors can be added via the generic parameter `E`.
67/// Use `ApiError` (without a type parameter) when no domain-specific errors
68/// are needed.
69#[derive(Error, Debug)]
70#[non_exhaustive]
71pub enum ApiError<E: std::error::Error = NoDomainError> {
72    /// HTTP request error
73    #[error("HTTP error: {0}")]
74    Http(#[from] reqwest::Error),
75
76    /// HTTP client build error
77    #[error("HTTP client error: {0}")]
78    HttpBuild(#[source] HttpError),
79
80    /// JSON parsing error
81    #[error("JSON error: {0}")]
82    Json(#[from] serde_json::Error),
83
84    /// API returned an error response (4xx, excluding 429)
85    #[error("API error: {status} - {message}")]
86    Api {
87        /// HTTP status code
88        status: u16,
89        /// Error message from API
90        message: String,
91    },
92
93    /// Rate limit exceeded (429)
94    #[error("Rate limited{}", .retry_after.map(|s| format!(" (retry after {}s)", s)).unwrap_or_default())]
95    RateLimited {
96        /// Seconds to wait before retrying
97        retry_after: Option<u64>,
98    },
99
100    /// Server error (5xx)
101    #[error("Server error ({status}): {message}")]
102    ServerError {
103        /// HTTP status code
104        status: u16,
105        /// Error message
106        message: String,
107    },
108
109    /// URL parsing error
110    #[error("URL error: {0}")]
111    Url(#[from] url::ParseError),
112
113    /// Domain-specific error
114    #[error(transparent)]
115    Domain(E),
116}
117
118// Manual From impl for HttpError since we can't use #[from] with the generic
119impl<E: std::error::Error> From<HttpError> for ApiError<E> {
120    fn from(e: HttpError) -> Self {
121        ApiError::HttpBuild(e)
122    }
123}
124
125impl<E: std::error::Error> ApiError<E> {
126    /// Create an API error
127    pub fn api(status: u16, message: impl Into<String>) -> Self {
128        Self::Api {
129            status,
130            message: message.into(),
131        }
132    }
133
134    /// Create a rate limited error
135    pub fn rate_limited(retry_after: Option<u64>) -> Self {
136        Self::RateLimited { retry_after }
137    }
138
139    /// Create a server error
140    pub fn server_error(status: u16, message: impl Into<String>) -> Self {
141        Self::ServerError {
142            status,
143            message: message.into(),
144        }
145    }
146
147    /// Create a domain-specific error
148    pub fn domain(error: E) -> Self {
149        Self::Domain(error)
150    }
151
152    /// Create from HTTP response status and body
153    ///
154    /// Automatically categorizes the error based on status code:
155    /// - 429 -> RateLimited
156    /// - 500-599 -> ServerError
157    /// - Other -> Api
158    pub fn from_response(status: u16, body: &str, retry_after: Option<u64>) -> Self {
159        match status {
160            429 => Self::RateLimited { retry_after },
161            500..=599 => Self::ServerError {
162                status,
163                message: body.to_string(),
164            },
165            _ => Self::Api {
166                status,
167                message: body.to_string(),
168            },
169        }
170    }
171
172    /// Check if this error is retryable
173    ///
174    /// Returns true for:
175    /// - Rate limited errors
176    /// - Server errors (5xx)
177    /// - HTTP transport errors
178    pub fn is_retryable(&self) -> bool {
179        matches!(
180            self,
181            Self::RateLimited { .. } | Self::ServerError { .. } | Self::Http(_)
182        )
183    }
184
185    /// Get retry-after duration if available
186    pub fn retry_after(&self) -> Option<Duration> {
187        if let Self::RateLimited {
188            retry_after: Some(secs),
189        } = self
190        {
191            Some(Duration::from_secs(*secs))
192        } else {
193            None
194        }
195    }
196
197    /// Get the HTTP status code if this is an API or server error
198    pub fn status_code(&self) -> Option<u16> {
199        match self {
200            Self::Api { status, .. } => Some(*status),
201            Self::ServerError { status, .. } => Some(*status),
202            Self::RateLimited { .. } => Some(429),
203            _ => None,
204        }
205    }
206}
207
208impl<E: std::error::Error> RetryableError for ApiError<E> {
209    fn is_retryable(&self) -> bool {
210        ApiError::is_retryable(self)
211    }
212
213    fn retry_after(&self) -> Option<Duration> {
214        ApiError::retry_after(self)
215    }
216}
217
218/// Result type alias for API operations
219pub type ApiResult<T, E = NoDomainError> = std::result::Result<T, ApiError<E>>;
220
221// ============================================================================
222// Secret API Key Wrapper
223// ============================================================================
224
225/// A wrapper for API keys that redacts the value in Debug output.
226///
227/// This prevents accidental logging of API keys in debug output.
228///
229/// # Example
230///
231/// ```
232/// use yldfi_common::api::SecretApiKey;
233///
234/// let key = SecretApiKey::new("sk-secret-key-12345");
235/// let debug_str = format!("{:?}", key);
236/// assert!(debug_str.contains("REDACTED"));
237/// assert!(!debug_str.contains("sk-secret"));
238/// assert_eq!(key.expose(), "sk-secret-key-12345");
239/// ```
240#[derive(Clone)]
241pub struct SecretApiKey(String);
242
243impl SecretApiKey {
244    /// Create a new secret API key.
245    pub fn new(key: impl Into<String>) -> Self {
246        Self(key.into())
247    }
248
249    /// Expose the secret value.
250    ///
251    /// Use this when you need to include the key in an HTTP header.
252    #[must_use]
253    pub fn expose(&self) -> &str {
254        &self.0
255    }
256
257    /// Check if the key is empty.
258    #[must_use]
259    pub fn is_empty(&self) -> bool {
260        self.0.is_empty()
261    }
262}
263
264impl fmt::Debug for SecretApiKey {
265    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
266        f.debug_tuple("SecretApiKey").field(&"[REDACTED]").finish()
267    }
268}
269
270impl From<String> for SecretApiKey {
271    fn from(s: String) -> Self {
272        Self::new(s)
273    }
274}
275
276impl From<&str> for SecretApiKey {
277    fn from(s: &str) -> Self {
278        Self::new(s)
279    }
280}
281
282// ============================================================================
283// API Configuration
284// ============================================================================
285
286/// Generic API configuration
287///
288/// Provides common configuration options for all API clients:
289/// - Base URL (validated to be HTTPS in production)
290/// - API key (optional, redacted in Debug)
291/// - HTTP client settings (timeout, proxy, user-agent)
292///
293/// # Security
294///
295/// - API keys are wrapped in `SecretApiKey` to prevent accidental logging
296/// - Use `validate()` to check that the base URL uses HTTPS
297#[derive(Clone)]
298pub struct ApiConfig {
299    /// Base URL for the API
300    pub base_url: String,
301    /// API key for authentication (optional, redacted in Debug)
302    pub api_key: Option<SecretApiKey>,
303    /// HTTP client configuration
304    pub http: HttpClientConfig,
305}
306
307impl fmt::Debug for ApiConfig {
308    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
309        f.debug_struct("ApiConfig")
310            .field("base_url", &self.base_url)
311            .field("api_key", &self.api_key)
312            .field("http", &self.http)
313            .finish()
314    }
315}
316
317impl ApiConfig {
318    /// Create a new config with a base URL
319    pub fn new(base_url: impl Into<String>) -> Self {
320        Self {
321            base_url: base_url.into(),
322            api_key: None,
323            http: HttpClientConfig::default(),
324        }
325    }
326
327    /// Create a new config with base URL and API key
328    pub fn with_api_key(base_url: impl Into<String>, api_key: impl Into<String>) -> Self {
329        Self {
330            base_url: base_url.into(),
331            api_key: Some(SecretApiKey::new(api_key)),
332            http: HttpClientConfig::default(),
333        }
334    }
335
336    /// Set the API key
337    #[must_use]
338    pub fn api_key(mut self, key: impl Into<String>) -> Self {
339        self.api_key = Some(SecretApiKey::new(key));
340        self
341    }
342
343    /// Set optional API key
344    #[must_use]
345    pub fn optional_api_key(mut self, key: Option<String>) -> Self {
346        self.api_key = key.map(SecretApiKey::new);
347        self
348    }
349
350    /// Set request timeout
351    #[must_use]
352    pub fn timeout(mut self, timeout: Duration) -> Self {
353        self.http.timeout = timeout;
354        self
355    }
356
357    /// Set request timeout in seconds
358    #[must_use]
359    pub fn with_timeout_secs(mut self, secs: u64) -> Self {
360        self.http.timeout = Duration::from_secs(secs);
361        self
362    }
363
364    /// Set proxy URL
365    #[must_use]
366    pub fn proxy(mut self, proxy: impl Into<String>) -> Self {
367        self.http.proxy = Some(proxy.into());
368        self
369    }
370
371    /// Set optional proxy URL
372    #[must_use]
373    pub fn optional_proxy(mut self, proxy: Option<String>) -> Self {
374        self.http.proxy = proxy;
375        self
376    }
377
378    /// Build an HTTP client from this configuration
379    pub fn build_client(&self) -> Result<Client, HttpError> {
380        crate::http::build_client(&self.http)
381    }
382
383    /// Validate the configuration for security.
384    ///
385    /// Returns an error if:
386    /// - The base URL uses HTTP instead of HTTPS (security risk)
387    /// - The base URL is malformed
388    ///
389    /// # Example
390    ///
391    /// ```
392    /// use yldfi_common::api::ApiConfig;
393    ///
394    /// // HTTPS URLs are valid
395    /// let config = ApiConfig::new("https://api.example.com");
396    /// assert!(config.validate().is_ok());
397    ///
398    /// // HTTP URLs are rejected
399    /// let config = ApiConfig::new("http://api.example.com");
400    /// assert!(config.validate().is_err());
401    /// ```
402    pub fn validate(&self) -> Result<(), ConfigValidationError> {
403        // Parse the URL
404        let url = url::Url::parse(&self.base_url)
405            .map_err(|e| ConfigValidationError::InvalidUrl(e.to_string()))?;
406
407        // Check scheme
408        match url.scheme() {
409            "https" => Ok(()),
410            "http" => {
411                // Allow localhost for development
412                if let Some(host) = url.host_str() {
413                    if host == "localhost" || host == "127.0.0.1" || host == "::1" {
414                        return Ok(());
415                    }
416                }
417                Err(ConfigValidationError::InsecureScheme)
418            }
419            scheme => Err(ConfigValidationError::InvalidUrl(format!(
420                "Unsupported URL scheme: {}",
421                scheme
422            ))),
423        }
424    }
425
426    /// Check if the base URL uses HTTPS.
427    #[must_use]
428    pub fn is_https(&self) -> bool {
429        self.base_url.starts_with("https://")
430    }
431
432    /// Get the exposed API key, if set.
433    #[must_use]
434    pub fn get_api_key(&self) -> Option<&str> {
435        self.api_key.as_ref().map(|k| k.expose())
436    }
437}
438
439/// Configuration validation errors
440#[derive(Debug, Clone, PartialEq, Eq)]
441pub enum ConfigValidationError {
442    /// The URL scheme is HTTP instead of HTTPS
443    InsecureScheme,
444    /// The URL is malformed
445    InvalidUrl(String),
446}
447
448impl fmt::Display for ConfigValidationError {
449    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
450        match self {
451            Self::InsecureScheme => write!(
452                f,
453                "Insecure URL scheme: use HTTPS instead of HTTP to protect API keys"
454            ),
455            Self::InvalidUrl(msg) => write!(f, "Invalid URL: {}", msg),
456        }
457    }
458}
459
460impl std::error::Error for ConfigValidationError {}
461
462// ============================================================================
463// Base Client
464// ============================================================================
465
466/// A base HTTP client that handles common request/response patterns.
467///
468/// This client provides reusable methods for making GET and POST requests
469/// with proper error handling, reducing boilerplate across API crates.
470///
471/// # Example
472///
473/// ```
474/// use yldfi_common::api::{ApiConfig, BaseClient};
475///
476/// // Create a client with configuration
477/// let config = ApiConfig::new("https://api.example.com")
478///     .api_key("your-api-key");
479/// let client = BaseClient::new(config).unwrap();
480///
481/// // Build URLs
482/// assert_eq!(client.url("/quote"), "https://api.example.com/quote");
483///
484/// // Access config
485/// assert_eq!(client.config().get_api_key(), Some("your-api-key"));
486/// ```
487#[derive(Debug, Clone)]
488pub struct BaseClient {
489    http: Client,
490    config: ApiConfig,
491}
492
493impl BaseClient {
494    /// Create a new base client from configuration.
495    ///
496    /// # Errors
497    ///
498    /// Returns an error if the HTTP client cannot be built.
499    pub fn new(config: ApiConfig) -> Result<Self, HttpError> {
500        let http = config.build_client()?;
501        Ok(Self { http, config })
502    }
503
504    /// Get the underlying HTTP client.
505    #[must_use]
506    pub fn http(&self) -> &Client {
507        &self.http
508    }
509
510    /// Get the configuration.
511    #[must_use]
512    pub fn config(&self) -> &ApiConfig {
513        &self.config
514    }
515
516    /// Get the base URL.
517    #[must_use]
518    pub fn base_url(&self) -> &str {
519        &self.config.base_url
520    }
521
522    /// Build the full URL for a path.
523    #[must_use]
524    pub fn url(&self, path: &str) -> String {
525        if path.starts_with('/') {
526            format!("{}{}", self.config.base_url.trim_end_matches('/'), path)
527        } else {
528            format!("{}/{}", self.config.base_url.trim_end_matches('/'), path)
529        }
530    }
531
532    /// Build default headers with API key (if present).
533    ///
534    /// Override this in your client to add custom headers.
535    pub fn default_headers(&self) -> reqwest::header::HeaderMap {
536        let mut headers = reqwest::header::HeaderMap::new();
537
538        // Add Authorization header if API key is set
539        if let Some(key) = self.config.get_api_key() {
540            if let Ok(value) = reqwest::header::HeaderValue::from_str(&format!("Bearer {}", key)) {
541                headers.insert(reqwest::header::AUTHORIZATION, value);
542            }
543        }
544
545        headers
546    }
547
548    /// Make a GET request with query parameters.
549    ///
550    /// # Type Parameters
551    ///
552    /// * `T` - The response type (must implement `DeserializeOwned`)
553    /// * `E` - Domain-specific error type (default: `NoDomainError`)
554    ///
555    /// # Arguments
556    ///
557    /// * `path` - The API path (will be joined with base_url)
558    /// * `params` - Query parameters as key-value pairs
559    pub async fn get<T, E>(
560        &self,
561        path: &str,
562        params: &[(&str, impl AsRef<str>)],
563    ) -> Result<T, ApiError<E>>
564    where
565        T: serde::de::DeserializeOwned,
566        E: std::error::Error,
567    {
568        self.get_with_headers(path, params, self.default_headers())
569            .await
570    }
571
572    /// Make a GET request with custom headers.
573    ///
574    /// Use this when you need to add API-specific headers beyond the default
575    /// Authorization header.
576    pub async fn get_with_headers<T, E>(
577        &self,
578        path: &str,
579        params: &[(&str, impl AsRef<str>)],
580        headers: reqwest::header::HeaderMap,
581    ) -> Result<T, ApiError<E>>
582    where
583        T: serde::de::DeserializeOwned,
584        E: std::error::Error,
585    {
586        let url = self.url(path);
587        let query: Vec<(&str, &str)> = params.iter().map(|(k, v)| (*k, v.as_ref())).collect();
588
589        let response = self
590            .http
591            .get(&url)
592            .headers(headers)
593            .query(&query)
594            .send()
595            .await?;
596
597        self.handle_response(response).await
598    }
599
600    /// Make a POST request with JSON body.
601    ///
602    /// # Type Parameters
603    ///
604    /// * `T` - The response type
605    /// * `B` - The request body type (must implement `Serialize`)
606    /// * `E` - Domain-specific error type
607    pub async fn post_json<T, B, E>(&self, path: &str, body: &B) -> Result<T, ApiError<E>>
608    where
609        T: serde::de::DeserializeOwned,
610        B: serde::Serialize,
611        E: std::error::Error,
612    {
613        self.post_json_with_headers(path, body, self.default_headers())
614            .await
615    }
616
617    /// Make a POST request with JSON body and custom headers.
618    pub async fn post_json_with_headers<T, B, E>(
619        &self,
620        path: &str,
621        body: &B,
622        headers: reqwest::header::HeaderMap,
623    ) -> Result<T, ApiError<E>>
624    where
625        T: serde::de::DeserializeOwned,
626        B: serde::Serialize,
627        E: std::error::Error,
628    {
629        let url = self.url(path);
630
631        let response = self
632            .http
633            .post(&url)
634            .headers(headers)
635            .json(body)
636            .send()
637            .await?;
638
639        self.handle_response(response).await
640    }
641
642    /// Make a POST request with form data.
643    pub async fn post_form<T, E>(
644        &self,
645        path: &str,
646        form: &[(&str, impl AsRef<str>)],
647    ) -> Result<T, ApiError<E>>
648    where
649        T: serde::de::DeserializeOwned,
650        E: std::error::Error,
651    {
652        let url = self.url(path);
653        let form_data: Vec<(&str, &str)> = form.iter().map(|(k, v)| (*k, v.as_ref())).collect();
654
655        let response = self
656            .http
657            .post(&url)
658            .headers(self.default_headers())
659            .form(&form_data)
660            .send()
661            .await?;
662
663        self.handle_response(response).await
664    }
665
666    /// Handle a response, extracting the body or converting to error.
667    async fn handle_response<T, E>(&self, response: reqwest::Response) -> Result<T, ApiError<E>>
668    where
669        T: serde::de::DeserializeOwned,
670        E: std::error::Error,
671    {
672        if response.status().is_success() {
673            Ok(response.json().await?)
674        } else {
675            Err(handle_error_response(response).await)
676        }
677    }
678}
679
680// ============================================================================
681// Response Handling Helper
682// ============================================================================
683
684/// Extract retry-after header value from a response
685pub fn extract_retry_after(headers: &reqwest::header::HeaderMap) -> Option<u64> {
686    headers
687        .get("retry-after")
688        .and_then(|v| v.to_str().ok())
689        .and_then(|v| v.parse().ok())
690}
691
692/// Handle an HTTP response, converting errors appropriately
693///
694/// This helper extracts the retry-after header and creates the appropriate
695/// error type based on the response status code.
696pub async fn handle_error_response<E: std::error::Error>(
697    response: reqwest::Response,
698) -> ApiError<E> {
699    let status = response.status().as_u16();
700    let retry_after = extract_retry_after(response.headers());
701    let body = response.text().await.unwrap_or_default();
702    ApiError::from_response(status, &body, retry_after)
703}
704
705#[cfg(test)]
706mod tests {
707    use super::*;
708
709    #[test]
710    fn test_api_error_display() {
711        let err: ApiError = ApiError::api(400, "Bad request");
712        assert!(err.to_string().contains("400"));
713        assert!(err.to_string().contains("Bad request"));
714    }
715
716    #[test]
717    fn test_rate_limited_display() {
718        let err: ApiError = ApiError::rate_limited(Some(60));
719        assert!(err.to_string().contains("60"));
720    }
721
722    #[test]
723    fn test_rate_limited_no_retry() {
724        let err: ApiError = ApiError::rate_limited(None);
725        assert!(err.to_string().contains("Rate limited"));
726        assert!(!err.to_string().contains("retry"));
727    }
728
729    #[test]
730    fn test_is_retryable() {
731        let err: ApiError = ApiError::rate_limited(Some(10));
732        assert!(err.is_retryable());
733        let err: ApiError = ApiError::server_error(500, "error");
734        assert!(err.is_retryable());
735        let err: ApiError = ApiError::api(400, "bad request");
736        assert!(!err.is_retryable());
737    }
738
739    #[test]
740    fn test_from_response() {
741        let err: ApiError = ApiError::from_response(429, "rate limited", Some(30));
742        assert!(matches!(
743            err,
744            ApiError::RateLimited {
745                retry_after: Some(30)
746            }
747        ));
748
749        let err: ApiError = ApiError::from_response(503, "service unavailable", None);
750        assert!(matches!(err, ApiError::ServerError { status: 503, .. }));
751
752        let err: ApiError = ApiError::from_response(400, "bad request", None);
753        assert!(matches!(err, ApiError::Api { status: 400, .. }));
754    }
755
756    #[test]
757    fn test_retry_after() {
758        let err: ApiError = ApiError::rate_limited(Some(30));
759        assert_eq!(err.retry_after(), Some(Duration::from_secs(30)));
760
761        let err: ApiError = ApiError::api(400, "bad");
762        assert_eq!(err.retry_after(), None);
763    }
764
765    #[test]
766    fn test_status_code() {
767        let err: ApiError = ApiError::api(400, "bad");
768        assert_eq!(err.status_code(), Some(400));
769        let err: ApiError = ApiError::server_error(503, "down");
770        assert_eq!(err.status_code(), Some(503));
771        let err: ApiError = ApiError::rate_limited(None);
772        assert_eq!(err.status_code(), Some(429));
773        let err: ApiError = ApiError::Json(serde_json::from_str::<()>("invalid").unwrap_err());
774        assert_eq!(err.status_code(), None);
775    }
776
777    #[test]
778    fn test_api_config() {
779        let config = ApiConfig::new("https://api.example.com")
780            .api_key("test-key")
781            .with_timeout_secs(60)
782            .proxy("http://proxy:8080");
783
784        assert_eq!(config.base_url, "https://api.example.com");
785        assert_eq!(config.get_api_key(), Some("test-key"));
786        assert_eq!(config.http.timeout, Duration::from_secs(60));
787        assert_eq!(config.http.proxy, Some("http://proxy:8080".to_string()));
788    }
789
790    #[test]
791    fn test_api_config_build_client() {
792        let config = ApiConfig::new("https://api.example.com");
793        let client = config.build_client();
794        assert!(client.is_ok());
795    }
796
797    #[test]
798    fn test_secret_api_key_redacted() {
799        let key = SecretApiKey::new("sk-secret-key-12345");
800        let debug_output = format!("{:?}", key);
801        assert!(debug_output.contains("REDACTED"));
802        assert!(!debug_output.contains("sk-secret"));
803        assert_eq!(key.expose(), "sk-secret-key-12345");
804    }
805
806    #[test]
807    fn test_api_config_debug_redacts_key() {
808        let config = ApiConfig::with_api_key("https://api.example.com", "super-secret-key");
809        let debug_output = format!("{:?}", config);
810        assert!(debug_output.contains("REDACTED"));
811        assert!(!debug_output.contains("super-secret-key"));
812    }
813
814    #[test]
815    fn test_config_validation_https() {
816        // HTTPS is valid
817        let config = ApiConfig::new("https://api.example.com");
818        assert!(config.validate().is_ok());
819        assert!(config.is_https());
820
821        // HTTP is rejected
822        let config = ApiConfig::new("http://api.example.com");
823        assert!(config.validate().is_err());
824        assert!(!config.is_https());
825        assert_eq!(
826            config.validate().unwrap_err(),
827            ConfigValidationError::InsecureScheme
828        );
829    }
830
831    #[test]
832    fn test_config_validation_localhost() {
833        // HTTP to localhost is allowed for development
834        let config = ApiConfig::new("http://localhost:8080");
835        assert!(config.validate().is_ok());
836
837        let config = ApiConfig::new("http://127.0.0.1:8080");
838        assert!(config.validate().is_ok());
839    }
840
841    #[test]
842    fn test_config_validation_invalid_url() {
843        let config = ApiConfig::new("not a url");
844        let result = config.validate();
845        assert!(matches!(result, Err(ConfigValidationError::InvalidUrl(_))));
846    }
847
848    // Test with domain-specific errors
849    #[derive(Debug, thiserror::Error)]
850    enum TestDomainError {
851        #[error("No route found")]
852        NoRouteFound,
853        #[error("Insufficient liquidity")]
854        InsufficientLiquidity,
855    }
856
857    #[test]
858    fn test_domain_error() {
859        let err: ApiError<TestDomainError> = ApiError::domain(TestDomainError::NoRouteFound);
860        assert!(err.to_string().contains("No route found"));
861        assert!(!err.is_retryable());
862    }
863
864    // BaseClient tests
865    #[test]
866    fn test_base_client_creation() {
867        let config = ApiConfig::new("https://api.example.com");
868        let client = BaseClient::new(config);
869        assert!(client.is_ok());
870    }
871
872    #[test]
873    fn test_base_client_url_building() {
874        let config = ApiConfig::new("https://api.example.com");
875        let client = BaseClient::new(config).unwrap();
876
877        // With leading slash
878        assert_eq!(client.url("/quote"), "https://api.example.com/quote");
879
880        // Without leading slash
881        assert_eq!(client.url("quote"), "https://api.example.com/quote");
882
883        // With path
884        assert_eq!(
885            client.url("/v1/swap/quote"),
886            "https://api.example.com/v1/swap/quote"
887        );
888    }
889
890    #[test]
891    fn test_base_client_url_building_trailing_slash() {
892        // Base URL with trailing slash
893        let config = ApiConfig::new("https://api.example.com/");
894        let client = BaseClient::new(config).unwrap();
895
896        assert_eq!(client.url("/quote"), "https://api.example.com/quote");
897        assert_eq!(client.url("quote"), "https://api.example.com/quote");
898    }
899
900    #[test]
901    fn test_base_client_default_headers_no_key() {
902        let config = ApiConfig::new("https://api.example.com");
903        let client = BaseClient::new(config).unwrap();
904        let headers = client.default_headers();
905
906        // No Authorization header without API key
907        assert!(!headers.contains_key(reqwest::header::AUTHORIZATION));
908    }
909
910    #[test]
911    fn test_base_client_default_headers_with_key() {
912        let config = ApiConfig::new("https://api.example.com").api_key("test-key");
913        let client = BaseClient::new(config).unwrap();
914        let headers = client.default_headers();
915
916        // Authorization header present with Bearer token
917        assert!(headers.contains_key(reqwest::header::AUTHORIZATION));
918        assert_eq!(
919            headers.get(reqwest::header::AUTHORIZATION).unwrap(),
920            "Bearer test-key"
921        );
922    }
923
924    #[test]
925    fn test_base_client_accessors() {
926        let config = ApiConfig::new("https://api.example.com").api_key("my-key");
927        let client = BaseClient::new(config).unwrap();
928
929        assert_eq!(client.base_url(), "https://api.example.com");
930        assert_eq!(client.config().get_api_key(), Some("my-key"));
931    }
932}