1use reqwest::Client;
19use std::time::Duration;
20use thiserror::Error;
21use url::Url;
22
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";
29
30pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
37
38fn redact_proxy_url(url: &str) -> String {
42 if let Ok(mut parsed) = Url::parse(url) {
43 if !parsed.username().is_empty() || parsed.password().is_some() {
44 let _ = parsed.set_username("[REDACTED]");
45 let _ = parsed.set_password(Some("[REDACTED]"));
46 }
47 parsed.to_string()
48 } else {
49 "[invalid proxy URL]".to_string()
50 }
51}
52
53#[derive(Debug, Error)]
55pub enum HttpError {
56 #[error("Invalid proxy URL: {0}")]
57 InvalidProxy(String),
58
59 #[error("Failed to build HTTP client: {0}")]
60 BuildError(String),
61}
62
63impl From<reqwest::Error> for HttpError {
64 fn from(e: reqwest::Error) -> Self {
65 HttpError::BuildError(e.to_string())
66 }
67}
68
69pub const DEFAULT_POOL_IDLE_TIMEOUT: Duration = Duration::from_secs(90);
78
79pub const DEFAULT_POOL_MAX_IDLE_PER_HOST: usize = 10;
104
105#[derive(Debug, Clone)]
107pub struct HttpClientConfig {
108 pub timeout: Duration,
110 pub user_agent: String,
112 pub proxy: Option<String>,
121 pub pool_idle_timeout: Duration,
123 pub pool_max_idle_per_host: usize,
125}
126
127impl Default for HttpClientConfig {
128 fn default() -> Self {
129 Self {
130 timeout: DEFAULT_TIMEOUT,
131 user_agent: DEFAULT_USER_AGENT.to_string(),
132 proxy: None,
133 pool_idle_timeout: DEFAULT_POOL_IDLE_TIMEOUT,
134 pool_max_idle_per_host: DEFAULT_POOL_MAX_IDLE_PER_HOST,
135 }
136 }
137}
138
139impl HttpClientConfig {
140 #[must_use]
142 pub fn new() -> Self {
143 Self::default()
144 }
145
146 #[must_use]
148 pub fn with_timeout(mut self, timeout: Duration) -> Self {
149 self.timeout = timeout;
150 self
151 }
152
153 #[must_use]
155 pub fn with_timeout_secs(mut self, secs: u64) -> Self {
156 self.timeout = Duration::from_secs(secs);
157 self
158 }
159
160 #[must_use]
162 pub fn with_user_agent(mut self, user_agent: impl Into<String>) -> Self {
163 self.user_agent = user_agent.into();
164 self
165 }
166
167 #[must_use]
169 pub fn with_proxy(mut self, proxy: impl Into<String>) -> Self {
170 self.proxy = Some(proxy.into());
171 self
172 }
173
174 #[must_use]
176 pub fn with_optional_proxy(mut self, proxy: Option<String>) -> Self {
177 self.proxy = proxy;
178 self
179 }
180
181 #[must_use]
183 pub fn with_pool_idle_timeout(mut self, timeout: Duration) -> Self {
184 self.pool_idle_timeout = timeout;
185 self
186 }
187
188 #[must_use]
190 pub fn with_pool_max_idle_per_host(mut self, max: usize) -> Self {
191 self.pool_max_idle_per_host = max;
192 self
193 }
194}
195
196pub fn build_client(config: &HttpClientConfig) -> Result<Client, HttpError> {
198 let mut builder = Client::builder()
199 .timeout(config.timeout)
200 .user_agent(&config.user_agent)
201 .pool_idle_timeout(config.pool_idle_timeout)
202 .pool_max_idle_per_host(config.pool_max_idle_per_host);
203
204 if let Some(ref proxy_url) = config.proxy {
205 let proxy = reqwest::Proxy::all(proxy_url)
206 .map_err(|e| {
208 HttpError::InvalidProxy(format!("{}: {}", redact_proxy_url(proxy_url), e))
209 })?;
210 builder = builder.proxy(proxy);
211 }
212
213 builder.build().map_err(|e| {
215 HttpError::BuildError(format!(
216 "Failed to build HTTP client (timeout: {:?}, pool_idle: {:?}, pool_max_idle: {}, proxy: {}): {}",
217 config.timeout,
218 config.pool_idle_timeout,
219 config.pool_max_idle_per_host,
220 config.proxy.as_ref().map_or_else(|| "none".to_string(), |p| redact_proxy_url(p)),
221 e
222 ))
223 })
224}
225
226pub fn build_default_client() -> Result<Client, HttpError> {
228 build_client(&HttpClientConfig::default())
229}
230
231pub fn build_client_with_proxy(proxy: Option<&str>) -> Result<Client, HttpError> {
233 let config = HttpClientConfig::default().with_optional_proxy(proxy.map(String::from));
234 build_client(&config)
235}
236
237#[cfg(test)]
238mod tests {
239 use super::*;
240
241 #[test]
242 fn test_default_config() {
243 let config = HttpClientConfig::default();
244 assert_eq!(config.timeout, DEFAULT_TIMEOUT);
245 assert_eq!(config.user_agent, DEFAULT_USER_AGENT);
246 assert!(config.proxy.is_none());
247 }
248
249 #[test]
250 fn test_config_builder() {
251 let config = HttpClientConfig::new()
252 .with_timeout_secs(60)
253 .with_user_agent("CustomAgent/1.0")
254 .with_proxy("http://proxy:8080");
255
256 assert_eq!(config.timeout, Duration::from_secs(60));
257 assert_eq!(config.user_agent, "CustomAgent/1.0");
258 assert_eq!(config.proxy, Some("http://proxy:8080".to_string()));
259 }
260
261 #[test]
262 fn test_build_default_client() {
263 let client = build_default_client();
264 assert!(client.is_ok());
265 }
266
267 #[test]
268 fn test_build_client_with_config() {
269 let config = HttpClientConfig::new().with_timeout_secs(45);
270 let client = build_client(&config);
271 assert!(client.is_ok());
272 }
273
274 #[test]
275 fn test_proxy_url_format() {
276 let config = HttpClientConfig::new().with_proxy("http://proxy.example.com:8080");
278 let result = build_client(&config);
279 assert!(result.is_ok());
280
281 }
284}