yldfi_common/
http.rs

1//! HTTP client utilities with proxy support
2//!
3//! This module provides shared HTTP client configuration and building
4//! functionality that can be used by all API crates in yldfi-rs.
5//!
6//! # Example
7//!
8//! ```no_run
9//! use yldfi_common::http::{HttpClientConfig, build_client};
10//!
11//! let config = HttpClientConfig::default()
12//!     .with_proxy("http://user:pass@proxy:8080")
13//!     .with_timeout_secs(60);
14//!
15//! let client = build_client(&config).unwrap();
16//! ```
17
18use reqwest::Client;
19use std::time::Duration;
20use thiserror::Error;
21
22/// Default User-Agent to avoid Cloudflare blocks
23pub const DEFAULT_USER_AGENT: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
24
25/// Default request timeout
26pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
27
28/// HTTP client configuration errors
29#[derive(Debug, Error)]
30pub enum HttpError {
31    #[error("Invalid proxy URL: {0}")]
32    InvalidProxy(String),
33
34    #[error("Failed to build HTTP client: {0}")]
35    BuildError(String),
36}
37
38impl From<reqwest::Error> for HttpError {
39    fn from(e: reqwest::Error) -> Self {
40        HttpError::BuildError(e.to_string())
41    }
42}
43
44/// HTTP client configuration
45#[derive(Debug, Clone)]
46pub struct HttpClientConfig {
47    /// Request timeout
48    pub timeout: Duration,
49    /// User-Agent header
50    pub user_agent: String,
51    /// Optional proxy URL (e.g., "http://user:pass@proxy:port")
52    pub proxy: Option<String>,
53}
54
55impl Default for HttpClientConfig {
56    fn default() -> Self {
57        Self {
58            timeout: DEFAULT_TIMEOUT,
59            user_agent: DEFAULT_USER_AGENT.to_string(),
60            proxy: None,
61        }
62    }
63}
64
65impl HttpClientConfig {
66    /// Create a new config with default settings
67    pub fn new() -> Self {
68        Self::default()
69    }
70
71    /// Set request timeout
72    #[must_use]
73    pub fn with_timeout(mut self, timeout: Duration) -> Self {
74        self.timeout = timeout;
75        self
76    }
77
78    /// Set request timeout in seconds
79    #[must_use]
80    pub fn with_timeout_secs(mut self, secs: u64) -> Self {
81        self.timeout = Duration::from_secs(secs);
82        self
83    }
84
85    /// Set User-Agent header
86    #[must_use]
87    pub fn with_user_agent(mut self, user_agent: impl Into<String>) -> Self {
88        self.user_agent = user_agent.into();
89        self
90    }
91
92    /// Set proxy URL
93    #[must_use]
94    pub fn with_proxy(mut self, proxy: impl Into<String>) -> Self {
95        self.proxy = Some(proxy.into());
96        self
97    }
98
99    /// Set optional proxy URL
100    #[must_use]
101    pub fn with_optional_proxy(mut self, proxy: Option<String>) -> Self {
102        self.proxy = proxy;
103        self
104    }
105}
106
107/// Build a reqwest Client with the given configuration
108pub fn build_client(config: &HttpClientConfig) -> Result<Client, HttpError> {
109    let mut builder = Client::builder()
110        .timeout(config.timeout)
111        .user_agent(&config.user_agent);
112
113    if let Some(ref proxy_url) = config.proxy {
114        let proxy = reqwest::Proxy::all(proxy_url)
115            .map_err(|e| HttpError::InvalidProxy(format!("{}: {}", proxy_url, e)))?;
116        builder = builder.proxy(proxy);
117    }
118
119    builder.build().map_err(HttpError::from)
120}
121
122/// Build a reqwest Client with default configuration
123pub fn build_default_client() -> Result<Client, HttpError> {
124    build_client(&HttpClientConfig::default())
125}
126
127/// Build a reqwest Client with just a proxy URL
128pub fn build_client_with_proxy(proxy: Option<&str>) -> Result<Client, HttpError> {
129    let config = HttpClientConfig::default().with_optional_proxy(proxy.map(String::from));
130    build_client(&config)
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136
137    #[test]
138    fn test_default_config() {
139        let config = HttpClientConfig::default();
140        assert_eq!(config.timeout, DEFAULT_TIMEOUT);
141        assert_eq!(config.user_agent, DEFAULT_USER_AGENT);
142        assert!(config.proxy.is_none());
143    }
144
145    #[test]
146    fn test_config_builder() {
147        let config = HttpClientConfig::new()
148            .with_timeout_secs(60)
149            .with_user_agent("CustomAgent/1.0")
150            .with_proxy("http://proxy:8080");
151
152        assert_eq!(config.timeout, Duration::from_secs(60));
153        assert_eq!(config.user_agent, "CustomAgent/1.0");
154        assert_eq!(config.proxy, Some("http://proxy:8080".to_string()));
155    }
156
157    #[test]
158    fn test_build_default_client() {
159        let client = build_default_client();
160        assert!(client.is_ok());
161    }
162
163    #[test]
164    fn test_build_client_with_config() {
165        let config = HttpClientConfig::new().with_timeout_secs(45);
166        let client = build_client(&config);
167        assert!(client.is_ok());
168    }
169
170    #[test]
171    fn test_proxy_url_format() {
172        // Valid proxy URLs work
173        let config = HttpClientConfig::new().with_proxy("http://proxy.example.com:8080");
174        let result = build_client(&config);
175        assert!(result.is_ok());
176
177        // Note: reqwest is lenient with proxy URL formats - most strings are accepted
178        // and errors only occur when the proxy is actually used for a connection.
179    }
180}