Skip to main content

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/// Maximum length for error message bodies (SEC-005 fix)
58const MAX_ERROR_BODY_LENGTH: usize = 500;
59
60/// Join a base URL with a path, handling edge cases properly (LOW-003 fix).
61///
62/// This is more robust than simple string concatenation:
63/// - Handles trailing slashes in base URL
64/// - Handles leading slashes in path
65/// - Handles query strings properly
66///
67/// Falls back to simple concatenation if URL parsing fails.
68#[must_use]
69pub fn join_url(base: &str, path: &str) -> String {
70    // Try to use url::Url::parse for validation and proper handling
71    if let Ok(base_url) = url::Url::parse(base) {
72        // url::Url::join treats paths starting with / as absolute, which would
73        // replace the entire path. We want to append instead.
74        let path_to_join = path.trim_start_matches('/');
75
76        // For proper joining, the base needs a trailing slash
77        let base_path = base_url.path();
78        if base_path.ends_with('/') {
79            if let Ok(joined) = base_url.join(path_to_join) {
80                return joined.to_string();
81            }
82        } else {
83            // Manually append path with proper separator
84            let base_str = base.trim_end_matches('/');
85            return format!("{base_str}/{path_to_join}");
86        }
87    }
88
89    // Fallback to simple concatenation
90    let base_str = base.trim_end_matches('/');
91    if path.starts_with('/') {
92        format!("{base_str}{path}")
93    } else {
94        format!("{base_str}/{path}")
95    }
96}
97
98/// Sanitize an API error body to remove potential secrets and truncate if too long.
99///
100/// This function:
101/// 1. Truncates bodies longer than 500 characters
102/// 2. Redacts common API key patterns (query params, bearer tokens, headers)
103/// 3. Redacts JSON key-value pairs with sensitive keys (MED-001 fix)
104///
105/// This is a security measure to prevent accidental exposure of sensitive data
106/// in error messages and logs.
107/// Sanitize an API error body to remove potential secrets.
108///
109/// This function:
110/// 1. Truncates long error messages
111/// 2. Redacts common API key patterns (query params, bearer tokens, headers, JSON)
112/// 3. Redacts hex-encoded private keys
113///
114/// Use this to sanitize error messages before logging or displaying them.
115#[must_use]
116pub fn sanitize_error_body(body: &str) -> String {
117    // Truncate if too long
118    let truncated = if body.len() > MAX_ERROR_BODY_LENGTH {
119        format!("{}... (truncated)", &body[..MAX_ERROR_BODY_LENGTH])
120    } else {
121        body.to_string()
122    };
123
124    // Simple regex-free sanitization for common patterns
125    // Redact query params that look like keys/tokens
126    let mut result = truncated;
127
128    // API-002 fix: Extended list of sensitive query parameter patterns
129    // Includes private_key, client_secret, client_id variants
130    for pattern in [
131        "key=",
132        "apikey=",
133        "api_key=",
134        "token=",
135        "secret=",
136        "auth=",
137        "password=",
138        "private_key=",
139        "privatekey=",
140        "pk=",
141        "client_secret=",
142        "client_id=",
143        "access_key=",
144        "secret_key=",
145    ] {
146        // Track search position to avoid infinite loop
147        let mut search_from = 0;
148        loop {
149            let lowercase = result.to_lowercase();
150            // Search only from current position forward
151            if let Some(relative_pos) = lowercase[search_from..].find(pattern) {
152                let start = search_from + relative_pos;
153                // Find the end of the value (next & or end of string/whitespace)
154                let value_start = start + pattern.len();
155                let value_end = result[value_start..]
156                    .find(|c: char| c == '&' || c.is_whitespace())
157                    .map_or(result.len(), |i| value_start + i);
158
159                // Only redact if there's actual content and it's not already [REDACTED]
160                let value = &result[value_start..value_end];
161                if value_end > value_start && value != "[REDACTED]" {
162                    // Preserve original case of the pattern key
163                    let original_pattern = &result[start..start + pattern.len()];
164                    result = format!(
165                        "{}{}[REDACTED]{}",
166                        &result[..start],
167                        original_pattern,
168                        &result[value_end..]
169                    );
170                    // Move search position past redaction
171                    search_from = start + pattern.len() + "[REDACTED]".len();
172                } else {
173                    // Move past this occurrence
174                    search_from = value_start;
175                }
176            } else {
177                break; // Pattern not found, move to next pattern
178            }
179        }
180    }
181
182    // Redact ALL Bearer tokens (LOW-002 fix: loop for multiple occurrences)
183    let mut search_from = 0;
184    loop {
185        let lowercase = result.to_lowercase();
186        if let Some(relative_pos) = lowercase[search_from..].find("bearer ") {
187            let start = search_from + relative_pos;
188            let token_start = start + 7;
189            let token_end = result[token_start..]
190                .find(|c: char| c.is_whitespace())
191                .map_or(result.len(), |i| token_start + i);
192            let token = &result[token_start..token_end];
193            if token_end > token_start && token != "[REDACTED]" {
194                result = format!(
195                    "{}Bearer [REDACTED]{}",
196                    &result[..start],
197                    &result[token_end..]
198                );
199                search_from = start + "Bearer [REDACTED]".len();
200            } else {
201                search_from = token_start;
202            }
203        } else {
204            break;
205        }
206    }
207
208    // LOW-004 fix: Redact HTTP header patterns (e.g., "X-API-Key: value")
209    for header_pattern in [
210        "x-api-key:",
211        "x-auth-token:",
212        "authorization:",
213        "x-secret:",
214        "api-key:",
215    ] {
216        let mut search_from = 0;
217        loop {
218            let lowercase = result.to_lowercase();
219            if let Some(relative_pos) = lowercase[search_from..].find(header_pattern) {
220                let start = search_from + relative_pos;
221                let value_start = start + header_pattern.len();
222                // Skip whitespace after colon
223                let trimmed_start = result[value_start..]
224                    .find(|c: char| !c.is_whitespace())
225                    .map_or(value_start, |i| value_start + i);
226                // Find end of header value (newline or end of string)
227                let value_end = result[trimmed_start..]
228                    .find(['\n', '\r'])
229                    .map_or(result.len(), |i| trimmed_start + i);
230                let value = &result[trimmed_start..value_end];
231                if value_end > trimmed_start && value != "[REDACTED]" {
232                    let original_header = &result[start..start + header_pattern.len()];
233                    result = format!(
234                        "{}{} [REDACTED]{}",
235                        &result[..start],
236                        original_header,
237                        &result[value_end..]
238                    );
239                    search_from = start + header_pattern.len() + " [REDACTED]".len();
240                } else {
241                    search_from = value_start;
242                }
243            } else {
244                break;
245            }
246        }
247    }
248
249    // MED-001/API-002 fix: Redact JSON key-value pairs with sensitive keys
250    // Patterns like: "key": "value" or "api_key": "secret123"
251    // Extended list includes private_key, client_secret variants
252    for json_key in [
253        "\"key\"",
254        "\"apikey\"",
255        "\"api_key\"",
256        "\"apiKey\"",
257        "\"token\"",
258        "\"secret\"",
259        "\"password\"",
260        "\"auth\"",
261        "\"access_token\"",
262        "\"accessToken\"",
263        "\"refresh_token\"",
264        "\"refreshToken\"",
265        "\"api-key\"",
266        "\"private_key\"",
267        "\"privateKey\"",
268        "\"client_secret\"",
269        "\"clientSecret\"",
270        "\"client_id\"",
271        "\"clientId\"",
272        "\"secret_key\"",
273        "\"secretKey\"",
274    ] {
275        // Track search position to avoid infinite loop
276        let mut search_from = 0;
277        loop {
278            let lowercase = result.to_lowercase();
279            // Search only from the current position forward
280            if let Some(relative_pos) = lowercase[search_from..].find(json_key) {
281                let key_start = search_from + relative_pos;
282                // Look for ": followed by a string value
283                let after_key = key_start + json_key.len();
284                let remaining = &result[after_key..];
285
286                // Find the colon and opening quote
287                if let Some(colon_offset) = remaining.find(':') {
288                    let after_colon = &remaining[colon_offset + 1..];
289                    // Skip whitespace
290                    let quote_offset = after_colon.find('"');
291                    if let Some(qo) = quote_offset {
292                        let value_start_abs = after_key + colon_offset + 1 + qo + 1;
293                        // Find closing quote (handle escaped quotes)
294                        let value_content = &result[value_start_abs..];
295                        let mut end_quote = 0;
296                        let mut chars = value_content.chars().peekable();
297                        while let Some(c) = chars.next() {
298                            if c == '\\' {
299                                // Skip escaped char
300                                chars.next();
301                                end_quote += 2;
302                            } else if c == '"' {
303                                break;
304                            } else {
305                                end_quote += c.len_utf8();
306                            }
307                        }
308                        if end_quote > 0 {
309                            let value_end_abs = value_start_abs + end_quote;
310                            // Preserve the key and structure, just redact the value
311                            result = format!(
312                                "{}[REDACTED]{}",
313                                &result[..value_start_abs],
314                                &result[value_end_abs..]
315                            );
316                            // Move search position past this redaction
317                            search_from = value_start_abs + "[REDACTED]".len();
318                        } else {
319                            // Move past this key to avoid infinite loop on malformed JSON
320                            search_from = after_key;
321                        }
322                    } else {
323                        // No opening quote found, move past this key
324                        search_from = after_key;
325                    }
326                } else {
327                    // No colon found, move past this key
328                    search_from = after_key;
329                }
330            } else {
331                break;
332            }
333        }
334    }
335
336    // API-002 fix: Redact hex-encoded private keys (0x followed by 64 hex chars)
337    // These are Ethereum private keys and should never appear in logs
338    let mut search_from = 0;
339    while let Some(pos) = result[search_from..].find("0x") {
340        let abs_pos = search_from + pos;
341        let after_0x = abs_pos + 2;
342
343        // Check if followed by exactly 64 hex characters
344        if after_0x + 64 <= result.len() {
345            let potential_key = &result[after_0x..after_0x + 64];
346            if potential_key.chars().all(|c| c.is_ascii_hexdigit()) {
347                // Check it's not followed by more hex (could be longer hash)
348                let is_exact_64 = after_0x + 64 >= result.len()
349                    || !result[after_0x + 64..]
350                        .chars()
351                        .next()
352                        .is_some_and(|c| c.is_ascii_hexdigit());
353
354                if is_exact_64 {
355                    result = format!(
356                        "{}0x[REDACTED_KEY]{}",
357                        &result[..abs_pos],
358                        &result[after_0x + 64..]
359                    );
360                    search_from = abs_pos + "0x[REDACTED_KEY]".len();
361                    continue;
362                }
363            }
364        }
365        search_from = after_0x;
366    }
367
368    result
369}
370
371/// Generic API error type with support for domain-specific errors
372///
373/// This error type covers the common error cases across all API clients:
374/// - HTTP transport errors
375/// - JSON parsing errors
376/// - API response errors (4xx)
377/// - Rate limiting (429)
378/// - Server errors (5xx)
379///
380/// Domain-specific errors can be added via the generic parameter `E`.
381/// Use `ApiError` (without a type parameter) when no domain-specific errors
382/// are needed.
383#[derive(Error, Debug)]
384#[non_exhaustive]
385pub enum ApiError<E: std::error::Error = NoDomainError> {
386    /// HTTP request error
387    #[error("HTTP error: {0}")]
388    Http(#[from] reqwest::Error),
389
390    /// HTTP client build error
391    #[error("HTTP client error: {0}")]
392    HttpBuild(#[source] HttpError),
393
394    /// JSON parsing error
395    #[error("JSON error: {0}")]
396    Json(#[from] serde_json::Error),
397
398    /// API returned an error response (4xx, excluding 429)
399    #[error("API error: {status} - {message}")]
400    Api {
401        /// HTTP status code
402        status: u16,
403        /// Error message from API
404        message: String,
405    },
406
407    /// Rate limit exceeded (429)
408    #[error("Rate limited{}", .retry_after.map(|s| format!(" (retry after {s}s)")).unwrap_or_default())]
409    RateLimited {
410        /// Seconds to wait before retrying
411        retry_after: Option<u64>,
412    },
413
414    /// Server error (5xx)
415    #[error("Server error ({status}): {message}")]
416    ServerError {
417        /// HTTP status code
418        status: u16,
419        /// Error message
420        message: String,
421    },
422
423    /// URL parsing error
424    #[error("URL error: {0}")]
425    Url(#[from] url::ParseError),
426
427    /// Domain-specific error
428    #[error(transparent)]
429    Domain(E),
430}
431
432// Manual From impl for HttpError since we can't use #[from] with the generic
433impl<E: std::error::Error> From<HttpError> for ApiError<E> {
434    fn from(e: HttpError) -> Self {
435        ApiError::HttpBuild(e)
436    }
437}
438
439impl<E: std::error::Error> ApiError<E> {
440    /// Create an API error
441    pub fn api(status: u16, message: impl Into<String>) -> Self {
442        Self::Api {
443            status,
444            message: message.into(),
445        }
446    }
447
448    /// Create a rate limited error
449    #[must_use]
450    pub fn rate_limited(retry_after: Option<u64>) -> Self {
451        Self::RateLimited { retry_after }
452    }
453
454    /// Create a server error
455    pub fn server_error(status: u16, message: impl Into<String>) -> Self {
456        Self::ServerError {
457            status,
458            message: message.into(),
459        }
460    }
461
462    /// Create a domain-specific error
463    pub fn domain(error: E) -> Self {
464        Self::Domain(error)
465    }
466
467    /// Create from HTTP response status and body
468    ///
469    /// Automatically categorizes the error based on status code:
470    /// - 429 -> `RateLimited`
471    /// - 500-599 -> `ServerError`
472    /// - Other -> Api
473    ///
474    /// Note: The body is sanitized to remove potential secrets and truncated
475    /// if too long (SEC-005 fix).
476    #[must_use]
477    pub fn from_response(status: u16, body: &str, retry_after: Option<u64>) -> Self {
478        let sanitized = sanitize_error_body(body);
479        match status {
480            429 => Self::RateLimited { retry_after },
481            500..=599 => Self::ServerError {
482                status,
483                message: sanitized,
484            },
485            _ => Self::Api {
486                status,
487                message: sanitized,
488            },
489        }
490    }
491
492    /// Check if this error is retryable
493    ///
494    /// Returns true for:
495    /// - Rate limited errors
496    /// - Server errors (5xx)
497    /// - HTTP transport errors
498    pub fn is_retryable(&self) -> bool {
499        matches!(
500            self,
501            Self::RateLimited { .. } | Self::ServerError { .. } | Self::Http(_)
502        )
503    }
504
505    /// Get retry-after duration if available
506    pub fn retry_after(&self) -> Option<Duration> {
507        if let Self::RateLimited {
508            retry_after: Some(secs),
509        } = self
510        {
511            Some(Duration::from_secs(*secs))
512        } else {
513            None
514        }
515    }
516
517    /// Get the HTTP status code if this is an API or server error
518    pub fn status_code(&self) -> Option<u16> {
519        match self {
520            Self::Api { status, .. } => Some(*status),
521            Self::ServerError { status, .. } => Some(*status),
522            Self::RateLimited { .. } => Some(429),
523            _ => None,
524        }
525    }
526}
527
528impl<E: std::error::Error> RetryableError for ApiError<E> {
529    fn is_retryable(&self) -> bool {
530        ApiError::is_retryable(self)
531    }
532
533    fn retry_after(&self) -> Option<Duration> {
534        ApiError::retry_after(self)
535    }
536}
537
538/// Result type alias for API operations
539pub type ApiResult<T, E = NoDomainError> = std::result::Result<T, ApiError<E>>;
540
541// ============================================================================
542// Secret API Key Wrapper
543// ============================================================================
544
545/// A wrapper for API keys that redacts the value in Debug output.
546///
547/// This prevents accidental logging of API keys in debug output.
548///
549/// # Example
550///
551/// ```
552/// use yldfi_common::api::SecretApiKey;
553///
554/// let key = SecretApiKey::new("sk-secret-key-12345");
555/// let debug_str = format!("{:?}", key);
556/// assert!(debug_str.contains("REDACTED"));
557/// assert!(!debug_str.contains("sk-secret"));
558/// assert_eq!(key.expose(), "sk-secret-key-12345");
559/// ```
560#[derive(Clone)]
561pub struct SecretApiKey(String);
562
563impl SecretApiKey {
564    /// Create a new secret API key.
565    pub fn new(key: impl Into<String>) -> Self {
566        Self(key.into())
567    }
568
569    /// Expose the secret value.
570    ///
571    /// Use this when you need to include the key in an HTTP header.
572    #[must_use]
573    pub fn expose(&self) -> &str {
574        &self.0
575    }
576
577    /// Check if the key is empty.
578    #[must_use]
579    pub fn is_empty(&self) -> bool {
580        self.0.is_empty()
581    }
582}
583
584impl fmt::Debug for SecretApiKey {
585    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
586        f.debug_tuple("SecretApiKey").field(&"[REDACTED]").finish()
587    }
588}
589
590impl From<String> for SecretApiKey {
591    fn from(s: String) -> Self {
592        Self::new(s)
593    }
594}
595
596impl From<&str> for SecretApiKey {
597    fn from(s: &str) -> Self {
598        Self::new(s)
599    }
600}
601
602// ============================================================================
603// API Configuration
604// ============================================================================
605
606/// Generic API configuration
607///
608/// Provides common configuration options for all API clients:
609/// - Base URL (validated to be HTTPS in production)
610/// - API key (optional, redacted in Debug)
611/// - HTTP client settings (timeout, proxy, user-agent)
612///
613/// # Security
614///
615/// - API keys are wrapped in `SecretApiKey` to prevent accidental logging
616/// - Use `validate()` to check that the base URL uses HTTPS
617#[derive(Clone)]
618pub struct ApiConfig {
619    /// Base URL for the API
620    pub base_url: String,
621    /// API key for authentication (optional, redacted in Debug)
622    pub api_key: Option<SecretApiKey>,
623    /// HTTP client configuration
624    pub http: HttpClientConfig,
625}
626
627impl fmt::Debug for ApiConfig {
628    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
629        f.debug_struct("ApiConfig")
630            .field("base_url", &self.base_url)
631            .field("api_key", &self.api_key)
632            .field("http", &self.http)
633            .finish()
634    }
635}
636
637impl ApiConfig {
638    /// Create a new config with a base URL
639    pub fn new(base_url: impl Into<String>) -> Self {
640        Self {
641            base_url: base_url.into(),
642            api_key: None,
643            http: HttpClientConfig::default(),
644        }
645    }
646
647    /// Create a new config with base URL and API key
648    pub fn with_api_key(base_url: impl Into<String>, api_key: impl Into<String>) -> Self {
649        Self {
650            base_url: base_url.into(),
651            api_key: Some(SecretApiKey::new(api_key)),
652            http: HttpClientConfig::default(),
653        }
654    }
655
656    /// Set the API key
657    #[must_use]
658    pub fn api_key(mut self, key: impl Into<String>) -> Self {
659        self.api_key = Some(SecretApiKey::new(key));
660        self
661    }
662
663    /// Set optional API key
664    #[must_use]
665    pub fn optional_api_key(mut self, key: Option<String>) -> Self {
666        self.api_key = key.map(SecretApiKey::new);
667        self
668    }
669
670    /// Set request timeout
671    #[must_use]
672    pub fn timeout(mut self, timeout: Duration) -> Self {
673        self.http.timeout = timeout;
674        self
675    }
676
677    /// Set request timeout in seconds
678    #[must_use]
679    pub fn with_timeout_secs(mut self, secs: u64) -> Self {
680        self.http.timeout = Duration::from_secs(secs);
681        self
682    }
683
684    /// Set proxy URL
685    #[must_use]
686    pub fn proxy(mut self, proxy: impl Into<String>) -> Self {
687        self.http.proxy = Some(proxy.into());
688        self
689    }
690
691    /// Set optional proxy URL
692    #[must_use]
693    pub fn optional_proxy(mut self, proxy: Option<String>) -> Self {
694        self.http.proxy = proxy;
695        self
696    }
697
698    /// Build an HTTP client from this configuration
699    pub fn build_client(&self) -> Result<Client, HttpError> {
700        crate::http::build_client(&self.http)
701    }
702
703    /// Validate the configuration for security.
704    ///
705    /// Returns an error if:
706    /// - The base URL uses HTTP instead of HTTPS (security risk)
707    /// - The base URL is malformed
708    ///
709    /// # Example
710    ///
711    /// ```
712    /// use yldfi_common::api::ApiConfig;
713    ///
714    /// // HTTPS URLs are valid
715    /// let config = ApiConfig::new("https://api.example.com");
716    /// assert!(config.validate().is_ok());
717    ///
718    /// // HTTP URLs are rejected
719    /// let config = ApiConfig::new("http://api.example.com");
720    /// assert!(config.validate().is_err());
721    /// ```
722    pub fn validate(&self) -> Result<(), ConfigValidationError> {
723        // Parse the URL
724        let url = url::Url::parse(&self.base_url)
725            .map_err(|e| ConfigValidationError::InvalidUrl(e.to_string()))?;
726
727        // Check scheme
728        match url.scheme() {
729            "https" => Ok(()),
730            "http" => {
731                // Allow localhost for development
732                if let Some(host) = url.host_str() {
733                    if host == "localhost" || host == "127.0.0.1" || host == "::1" {
734                        return Ok(());
735                    }
736                }
737                Err(ConfigValidationError::InsecureScheme)
738            }
739            scheme => Err(ConfigValidationError::InvalidUrl(format!(
740                "Unsupported URL scheme: {scheme}"
741            ))),
742        }
743    }
744
745    /// Check if the base URL uses HTTPS.
746    #[must_use]
747    pub fn is_https(&self) -> bool {
748        self.base_url.starts_with("https://")
749    }
750
751    /// Get the exposed API key, if set.
752    #[must_use]
753    pub fn get_api_key(&self) -> Option<&str> {
754        self.api_key.as_ref().map(SecretApiKey::expose)
755    }
756}
757
758/// Configuration validation errors
759#[derive(Debug, Clone, PartialEq, Eq)]
760pub enum ConfigValidationError {
761    /// The URL scheme is HTTP instead of HTTPS
762    InsecureScheme,
763    /// The URL is malformed
764    InvalidUrl(String),
765}
766
767impl fmt::Display for ConfigValidationError {
768    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
769        match self {
770            Self::InsecureScheme => write!(
771                f,
772                "Insecure URL scheme: use HTTPS instead of HTTP to protect API keys"
773            ),
774            Self::InvalidUrl(msg) => write!(f, "Invalid URL: {msg}"),
775        }
776    }
777}
778
779impl std::error::Error for ConfigValidationError {}
780
781// ============================================================================
782// Base Client
783// ============================================================================
784
785/// A base HTTP client that handles common request/response patterns.
786///
787/// This client provides reusable methods for making GET and POST requests
788/// with proper error handling, reducing boilerplate across API crates.
789///
790/// # Example
791///
792/// ```
793/// use yldfi_common::api::{ApiConfig, BaseClient};
794///
795/// // Create a client with configuration
796/// let config = ApiConfig::new("https://api.example.com")
797///     .api_key("your-api-key");
798/// let client = BaseClient::new(config).unwrap();
799///
800/// // Build URLs
801/// assert_eq!(client.url("/quote"), "https://api.example.com/quote");
802///
803/// // Access config
804/// assert_eq!(client.config().get_api_key(), Some("your-api-key"));
805/// ```
806#[derive(Debug, Clone)]
807pub struct BaseClient {
808    http: Client,
809    config: ApiConfig,
810    /// Cached normalized base URL (trailing slash removed) to avoid allocation in `url()`
811    normalized_base_url: String,
812}
813
814impl BaseClient {
815    /// Create a new base client from configuration.
816    ///
817    /// This constructor validates the configuration, enforcing HTTPS for security
818    /// (SEC-003 fix). HTTP is only allowed for localhost development URLs.
819    ///
820    /// # Errors
821    ///
822    /// Returns an error if:
823    /// - The URL uses HTTP (except localhost)
824    /// - The HTTP client cannot be built
825    pub fn new(config: ApiConfig) -> Result<Self, HttpError> {
826        // Validate HTTPS requirement (SEC-003 fix)
827        if let Err(e) = config.validate() {
828            return Err(HttpError::BuildError(format!(
829                "Configuration validation failed: {e}. Use HTTPS URLs to protect API keys."
830            )));
831        }
832
833        let http = config.build_client()?;
834        // Cache normalized base URL to avoid repeated allocation in url()
835        let normalized_base_url = config.base_url.trim_end_matches('/').to_string();
836        Ok(Self {
837            http,
838            config,
839            normalized_base_url,
840        })
841    }
842
843    /// Get the underlying HTTP client.
844    #[must_use]
845    pub fn http(&self) -> &Client {
846        &self.http
847    }
848
849    /// Get the configuration.
850    #[must_use]
851    pub fn config(&self) -> &ApiConfig {
852        &self.config
853    }
854
855    /// Get the base URL.
856    #[must_use]
857    pub fn base_url(&self) -> &str {
858        &self.config.base_url
859    }
860
861    /// Build the full URL for a path.
862    ///
863    /// Uses cached normalized base URL to avoid repeated string allocation.
864    /// LOW-003 fix: Uses proper URL joining for robustness.
865    #[must_use]
866    pub fn url(&self, path: &str) -> String {
867        // For simple relative paths, use the optimized string format
868        // This covers the common case where base_url is just a host + fixed path
869        if !path.contains('?') && !self.normalized_base_url.contains('?') {
870            if path.starts_with('/') {
871                format!("{}{}", self.normalized_base_url, path)
872            } else {
873                format!("{}/{}", self.normalized_base_url, path)
874            }
875        } else {
876            // For complex cases (query strings), use proper URL parsing
877            join_url(&self.normalized_base_url, path)
878        }
879    }
880
881    /// Build default headers with API key (if present).
882    ///
883    /// Override this in your client to add custom headers.
884    pub fn default_headers(&self) -> reqwest::header::HeaderMap {
885        let mut headers = reqwest::header::HeaderMap::new();
886
887        // Add Authorization header if API key is set
888        if let Some(key) = self.config.get_api_key() {
889            if let Ok(value) = reqwest::header::HeaderValue::from_str(&format!("Bearer {key}")) {
890                headers.insert(reqwest::header::AUTHORIZATION, value);
891            }
892        }
893
894        headers
895    }
896
897    /// Make a GET request with query parameters.
898    ///
899    /// # Type Parameters
900    ///
901    /// * `T` - The response type (must implement `DeserializeOwned`)
902    /// * `E` - Domain-specific error type (default: `NoDomainError`)
903    ///
904    /// # Arguments
905    ///
906    /// * `path` - The API path (will be joined with `base_url`)
907    /// * `params` - Query parameters as key-value pairs
908    ///
909    /// # Errors
910    ///
911    /// Returns an error if:
912    /// - Network request fails
913    /// - Response status indicates an error (4xx/5xx)
914    /// - Response body cannot be deserialized to type `T`
915    pub async fn get<T, E>(
916        &self,
917        path: &str,
918        params: &[(&str, impl AsRef<str>)],
919    ) -> Result<T, ApiError<E>>
920    where
921        T: serde::de::DeserializeOwned,
922        E: std::error::Error,
923    {
924        self.get_with_headers(path, params, self.default_headers())
925            .await
926    }
927
928    /// Make a GET request with custom headers.
929    ///
930    /// Use this when you need to add API-specific headers beyond the default
931    /// Authorization header.
932    pub async fn get_with_headers<T, E>(
933        &self,
934        path: &str,
935        params: &[(&str, impl AsRef<str>)],
936        headers: reqwest::header::HeaderMap,
937    ) -> Result<T, ApiError<E>>
938    where
939        T: serde::de::DeserializeOwned,
940        E: std::error::Error,
941    {
942        let url = self.url(path);
943        let query: Vec<(&str, &str)> = params.iter().map(|(k, v)| (*k, v.as_ref())).collect();
944
945        let response = self
946            .http
947            .get(&url)
948            .headers(headers)
949            .query(&query)
950            .send()
951            .await?;
952
953        self.handle_response(response).await
954    }
955
956    /// Make a POST request with JSON body.
957    ///
958    /// # Type Parameters
959    ///
960    /// * `T` - The response type
961    /// * `B` - The request body type (must implement `Serialize`)
962    /// * `E` - Domain-specific error type
963    ///
964    /// # Errors
965    ///
966    /// Returns an error if:
967    /// - Network request fails
968    /// - Response status indicates an error (4xx/5xx)
969    /// - Response body cannot be deserialized to type `T`
970    pub async fn post_json<T, B, E>(&self, path: &str, body: &B) -> Result<T, ApiError<E>>
971    where
972        T: serde::de::DeserializeOwned,
973        B: serde::Serialize,
974        E: std::error::Error,
975    {
976        self.post_json_with_headers(path, body, self.default_headers())
977            .await
978    }
979
980    /// Make a POST request with JSON body and custom headers.
981    pub async fn post_json_with_headers<T, B, E>(
982        &self,
983        path: &str,
984        body: &B,
985        headers: reqwest::header::HeaderMap,
986    ) -> Result<T, ApiError<E>>
987    where
988        T: serde::de::DeserializeOwned,
989        B: serde::Serialize,
990        E: std::error::Error,
991    {
992        let url = self.url(path);
993
994        let response = self
995            .http
996            .post(&url)
997            .headers(headers)
998            .json(body)
999            .send()
1000            .await?;
1001
1002        self.handle_response(response).await
1003    }
1004
1005    /// Make a POST request with form data.
1006    pub async fn post_form<T, E>(
1007        &self,
1008        path: &str,
1009        form: &[(&str, impl AsRef<str>)],
1010    ) -> Result<T, ApiError<E>>
1011    where
1012        T: serde::de::DeserializeOwned,
1013        E: std::error::Error,
1014    {
1015        let url = self.url(path);
1016        let form_data: Vec<(&str, &str)> = form.iter().map(|(k, v)| (*k, v.as_ref())).collect();
1017
1018        let response = self
1019            .http
1020            .post(&url)
1021            .headers(self.default_headers())
1022            .form(&form_data)
1023            .send()
1024            .await?;
1025
1026        self.handle_response(response).await
1027    }
1028
1029    /// Handle a response, extracting the body or converting to error.
1030    async fn handle_response<T, E>(&self, response: reqwest::Response) -> Result<T, ApiError<E>>
1031    where
1032        T: serde::de::DeserializeOwned,
1033        E: std::error::Error,
1034    {
1035        if response.status().is_success() {
1036            Ok(response.json().await?)
1037        } else {
1038            Err(handle_error_response(response).await)
1039        }
1040    }
1041}
1042
1043// ============================================================================
1044// Response Handling Helper
1045// ============================================================================
1046
1047/// Extract retry-after header value from a response.
1048///
1049/// Parses the `Retry-After` header as seconds (delay-seconds format).
1050/// HTTP-date format (e.g., "Wed, 21 Oct 2015 07:28:00 GMT") is not supported
1051/// and will return `None`.
1052///
1053/// # Bounds
1054///
1055/// - Returns `None` for values that can't be parsed as a positive integer
1056/// - Caps the result at 3600 seconds (1 hour) to prevent unreasonably long waits
1057///   from malformed or malicious servers
1058///
1059/// # Example
1060///
1061/// ```
1062/// use reqwest::header::{HeaderMap, HeaderValue};
1063/// use yldfi_common::api::extract_retry_after;
1064///
1065/// let mut headers = HeaderMap::new();
1066/// headers.insert("retry-after", HeaderValue::from_static("30"));
1067/// assert_eq!(extract_retry_after(&headers), Some(30));
1068///
1069/// // Values over 3600 are capped
1070/// headers.insert("retry-after", HeaderValue::from_static("9999"));
1071/// assert_eq!(extract_retry_after(&headers), Some(3600));
1072/// ```
1073#[must_use]
1074pub fn extract_retry_after(headers: &reqwest::header::HeaderMap) -> Option<u64> {
1075    const MAX_RETRY_AFTER_SECS: u64 = 3600; // 1 hour max
1076
1077    headers
1078        .get("retry-after")
1079        .and_then(|v| v.to_str().ok())
1080        .and_then(|v| v.trim().parse::<u64>().ok())
1081        .map(|secs| secs.min(MAX_RETRY_AFTER_SECS))
1082}
1083
1084/// Handle an HTTP response, converting errors appropriately
1085///
1086/// This helper extracts the retry-after header and creates the appropriate
1087/// error type based on the response status code.
1088pub async fn handle_error_response<E: std::error::Error>(
1089    response: reqwest::Response,
1090) -> ApiError<E> {
1091    let status = response.status().as_u16();
1092    let retry_after = extract_retry_after(response.headers());
1093    let body = response.text().await.unwrap_or_default();
1094    ApiError::from_response(status, &body, retry_after)
1095}
1096
1097#[cfg(test)]
1098mod tests {
1099    use super::*;
1100
1101    #[test]
1102    fn test_api_error_display() {
1103        let err: ApiError = ApiError::api(400, "Bad request");
1104        assert!(err.to_string().contains("400"));
1105        assert!(err.to_string().contains("Bad request"));
1106    }
1107
1108    #[test]
1109    fn test_rate_limited_display() {
1110        let err: ApiError = ApiError::rate_limited(Some(60));
1111        assert!(err.to_string().contains("60"));
1112    }
1113
1114    #[test]
1115    fn test_rate_limited_no_retry() {
1116        let err: ApiError = ApiError::rate_limited(None);
1117        assert!(err.to_string().contains("Rate limited"));
1118        assert!(!err.to_string().contains("retry"));
1119    }
1120
1121    #[test]
1122    fn test_is_retryable() {
1123        let err: ApiError = ApiError::rate_limited(Some(10));
1124        assert!(err.is_retryable());
1125        let err: ApiError = ApiError::server_error(500, "error");
1126        assert!(err.is_retryable());
1127        let err: ApiError = ApiError::api(400, "bad request");
1128        assert!(!err.is_retryable());
1129    }
1130
1131    #[test]
1132    fn test_from_response() {
1133        let err: ApiError = ApiError::from_response(429, "rate limited", Some(30));
1134        assert!(matches!(
1135            err,
1136            ApiError::RateLimited {
1137                retry_after: Some(30)
1138            }
1139        ));
1140
1141        let err: ApiError = ApiError::from_response(503, "service unavailable", None);
1142        assert!(matches!(err, ApiError::ServerError { status: 503, .. }));
1143
1144        let err: ApiError = ApiError::from_response(400, "bad request", None);
1145        assert!(matches!(err, ApiError::Api { status: 400, .. }));
1146    }
1147
1148    #[test]
1149    fn test_retry_after() {
1150        let err: ApiError = ApiError::rate_limited(Some(30));
1151        assert_eq!(err.retry_after(), Some(Duration::from_secs(30)));
1152
1153        let err: ApiError = ApiError::api(400, "bad");
1154        assert_eq!(err.retry_after(), None);
1155    }
1156
1157    #[test]
1158    fn test_status_code() {
1159        let err: ApiError = ApiError::api(400, "bad");
1160        assert_eq!(err.status_code(), Some(400));
1161        let err: ApiError = ApiError::server_error(503, "down");
1162        assert_eq!(err.status_code(), Some(503));
1163        let err: ApiError = ApiError::rate_limited(None);
1164        assert_eq!(err.status_code(), Some(429));
1165        let err: ApiError = ApiError::Json(serde_json::from_str::<()>("invalid").unwrap_err());
1166        assert_eq!(err.status_code(), None);
1167    }
1168
1169    #[test]
1170    fn test_api_config() {
1171        let config = ApiConfig::new("https://api.example.com")
1172            .api_key("test-key")
1173            .with_timeout_secs(60)
1174            .proxy("http://proxy:8080");
1175
1176        assert_eq!(config.base_url, "https://api.example.com");
1177        assert_eq!(config.get_api_key(), Some("test-key"));
1178        assert_eq!(config.http.timeout, Duration::from_secs(60));
1179        assert_eq!(config.http.proxy, Some("http://proxy:8080".to_string()));
1180    }
1181
1182    #[test]
1183    fn test_api_config_build_client() {
1184        let config = ApiConfig::new("https://api.example.com");
1185        let client = config.build_client();
1186        assert!(client.is_ok());
1187    }
1188
1189    #[test]
1190    fn test_secret_api_key_redacted() {
1191        let key = SecretApiKey::new("sk-secret-key-12345");
1192        let debug_output = format!("{:?}", key);
1193        assert!(debug_output.contains("REDACTED"));
1194        assert!(!debug_output.contains("sk-secret"));
1195        assert_eq!(key.expose(), "sk-secret-key-12345");
1196    }
1197
1198    #[test]
1199    fn test_api_config_debug_redacts_key() {
1200        let config = ApiConfig::with_api_key("https://api.example.com", "super-secret-key");
1201        let debug_output = format!("{:?}", config);
1202        assert!(debug_output.contains("REDACTED"));
1203        assert!(!debug_output.contains("super-secret-key"));
1204    }
1205
1206    #[test]
1207    fn test_config_validation_https() {
1208        // HTTPS is valid
1209        let config = ApiConfig::new("https://api.example.com");
1210        assert!(config.validate().is_ok());
1211        assert!(config.is_https());
1212
1213        // HTTP is rejected
1214        let config = ApiConfig::new("http://api.example.com");
1215        assert!(config.validate().is_err());
1216        assert!(!config.is_https());
1217        assert_eq!(
1218            config.validate().unwrap_err(),
1219            ConfigValidationError::InsecureScheme
1220        );
1221    }
1222
1223    #[test]
1224    fn test_config_validation_localhost() {
1225        // HTTP to localhost is allowed for development
1226        let config = ApiConfig::new("http://localhost:8080");
1227        assert!(config.validate().is_ok());
1228
1229        let config = ApiConfig::new("http://127.0.0.1:8080");
1230        assert!(config.validate().is_ok());
1231    }
1232
1233    #[test]
1234    fn test_config_validation_invalid_url() {
1235        let config = ApiConfig::new("not a url");
1236        let result = config.validate();
1237        assert!(matches!(result, Err(ConfigValidationError::InvalidUrl(_))));
1238    }
1239
1240    // Test with domain-specific errors
1241    #[derive(Debug, thiserror::Error)]
1242    enum TestDomainError {
1243        #[error("No route found")]
1244        NoRouteFound,
1245    }
1246
1247    #[test]
1248    fn test_domain_error() {
1249        let err: ApiError<TestDomainError> = ApiError::domain(TestDomainError::NoRouteFound);
1250        assert!(err.to_string().contains("No route found"));
1251        assert!(!err.is_retryable());
1252    }
1253
1254    // BaseClient tests
1255    #[test]
1256    fn test_base_client_creation() {
1257        let config = ApiConfig::new("https://api.example.com");
1258        let client = BaseClient::new(config);
1259        assert!(client.is_ok());
1260    }
1261
1262    #[test]
1263    fn test_base_client_url_building() {
1264        let config = ApiConfig::new("https://api.example.com");
1265        let client = BaseClient::new(config).unwrap();
1266
1267        // With leading slash
1268        assert_eq!(client.url("/quote"), "https://api.example.com/quote");
1269
1270        // Without leading slash
1271        assert_eq!(client.url("quote"), "https://api.example.com/quote");
1272
1273        // With path
1274        assert_eq!(
1275            client.url("/v1/swap/quote"),
1276            "https://api.example.com/v1/swap/quote"
1277        );
1278    }
1279
1280    #[test]
1281    fn test_base_client_url_building_trailing_slash() {
1282        // Base URL with trailing slash
1283        let config = ApiConfig::new("https://api.example.com/");
1284        let client = BaseClient::new(config).unwrap();
1285
1286        assert_eq!(client.url("/quote"), "https://api.example.com/quote");
1287        assert_eq!(client.url("quote"), "https://api.example.com/quote");
1288    }
1289
1290    #[test]
1291    fn test_base_client_default_headers_no_key() {
1292        let config = ApiConfig::new("https://api.example.com");
1293        let client = BaseClient::new(config).unwrap();
1294        let headers = client.default_headers();
1295
1296        // No Authorization header without API key
1297        assert!(!headers.contains_key(reqwest::header::AUTHORIZATION));
1298    }
1299
1300    #[test]
1301    fn test_base_client_default_headers_with_key() {
1302        let config = ApiConfig::new("https://api.example.com").api_key("test-key");
1303        let client = BaseClient::new(config).unwrap();
1304        let headers = client.default_headers();
1305
1306        // Authorization header present with Bearer token
1307        assert!(headers.contains_key(reqwest::header::AUTHORIZATION));
1308        assert_eq!(
1309            headers.get(reqwest::header::AUTHORIZATION).unwrap(),
1310            "Bearer test-key"
1311        );
1312    }
1313
1314    #[test]
1315    fn test_base_client_accessors() {
1316        let config = ApiConfig::new("https://api.example.com").api_key("my-key");
1317        let client = BaseClient::new(config).unwrap();
1318
1319        assert_eq!(client.base_url(), "https://api.example.com");
1320        assert_eq!(client.config().get_api_key(), Some("my-key"));
1321    }
1322
1323    #[test]
1324    fn test_sanitize_error_body_truncation() {
1325        let long_body = "a".repeat(1000);
1326        let sanitized = super::sanitize_error_body(&long_body);
1327        assert!(sanitized.len() < 600); // Should be truncated with "... (truncated)"
1328        assert!(sanitized.ends_with("... (truncated)"));
1329    }
1330
1331    #[test]
1332    fn test_sanitize_error_body_key_redaction() {
1333        let body = "Error: ?api_key=secret123&foo=bar";
1334        let sanitized = super::sanitize_error_body(body);
1335        assert!(sanitized.contains("[REDACTED]"));
1336        assert!(!sanitized.contains("secret123"));
1337    }
1338
1339    #[test]
1340    fn test_sanitize_error_body_bearer_redaction() {
1341        let body = "Authorization: Bearer mysecrettoken123";
1342        let sanitized = super::sanitize_error_body(body);
1343        assert!(sanitized.contains("[REDACTED]"));
1344        assert!(!sanitized.contains("mysecrettoken123"));
1345    }
1346
1347    #[test]
1348    fn test_sanitize_error_body_no_redaction_needed() {
1349        let body = "Simple error message";
1350        let sanitized = super::sanitize_error_body(body);
1351        assert_eq!(sanitized, body);
1352    }
1353
1354    #[test]
1355    fn test_sanitize_error_body_json_key_redaction() {
1356        // MED-001 fix: Test JSON key-value redaction
1357        let body = r#"{"error": "Invalid API Key", "key": "sk_live_secret123", "status": 401}"#;
1358        let sanitized = super::sanitize_error_body(body);
1359        assert!(sanitized.contains("[REDACTED]"));
1360        assert!(!sanitized.contains("sk_live_secret123"));
1361        // Should preserve structure
1362        assert!(sanitized.contains("\"error\""));
1363        assert!(sanitized.contains("\"status\""));
1364    }
1365
1366    #[test]
1367    fn test_sanitize_error_body_json_api_key_redaction() {
1368        let body = r#"{"api_key": "test_api_key_12345", "message": "unauthorized"}"#;
1369        let sanitized = super::sanitize_error_body(body);
1370        assert!(sanitized.contains("[REDACTED]"));
1371        assert!(!sanitized.contains("test_api_key_12345"));
1372    }
1373
1374    #[test]
1375    fn test_sanitize_error_body_header_redaction() {
1376        // LOW-004 fix: Test HTTP header pattern redaction
1377        let body = "Request failed\nX-API-Key: my_secret_key_here\nContent-Type: application/json";
1378        let sanitized = super::sanitize_error_body(body);
1379        assert!(sanitized.contains("[REDACTED]"));
1380        assert!(!sanitized.contains("my_secret_key_here"));
1381        // Should preserve non-sensitive headers
1382        assert!(sanitized.contains("Content-Type"));
1383    }
1384
1385    #[test]
1386    fn test_sanitize_error_body_authorization_header() {
1387        let body = "Error: Authorization: Basic dXNlcjpwYXNz";
1388        let sanitized = super::sanitize_error_body(body);
1389        assert!(sanitized.contains("[REDACTED]"));
1390        assert!(!sanitized.contains("dXNlcjpwYXNz"));
1391    }
1392
1393    #[test]
1394    fn test_sanitize_error_body_multiple_json_keys() {
1395        // Test multiple sensitive JSON keys in same body
1396        let body = r#"{"key": "key1", "token": "token1", "api_key": "api1"}"#;
1397        let sanitized = super::sanitize_error_body(body);
1398        assert!(!sanitized.contains("key1"));
1399        assert!(!sanitized.contains("token1"));
1400        assert!(!sanitized.contains("api1"));
1401    }
1402
1403    #[test]
1404    fn test_sanitize_error_body_hex_private_key() {
1405        // API-002 fix: Test hex-encoded private key redaction (64 hex chars)
1406        let body = "Error: Invalid key 0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef in request";
1407        let sanitized = super::sanitize_error_body(body);
1408        assert!(sanitized.contains("0x[REDACTED_KEY]"));
1409        assert!(!sanitized.contains("1234567890abcdef"));
1410    }
1411
1412    #[test]
1413    fn test_sanitize_error_body_private_key_param() {
1414        // API-002 fix: Test private_key query param redaction
1415        let body = "Error: ?private_key=secretkey123&foo=bar";
1416        let sanitized = super::sanitize_error_body(body);
1417        assert!(sanitized.contains("[REDACTED]"));
1418        assert!(!sanitized.contains("secretkey123"));
1419    }
1420
1421    #[test]
1422    fn test_sanitize_error_body_client_secret() {
1423        // API-002 fix: Test client_secret JSON key redaction
1424        let body = r#"{"client_secret": "my_secret_value", "client_id": "my_client_id"}"#;
1425        let sanitized = super::sanitize_error_body(body);
1426        assert!(!sanitized.contains("my_secret_value"));
1427        assert!(!sanitized.contains("my_client_id"));
1428    }
1429}