Skip to main content

soar_dl/
http_client.rs

1use std::{
2    sync::{Arc, LazyLock, RwLock},
3    time::Duration,
4};
5
6use ureq::{
7    config::IpFamily,
8    http::{self, HeaderMap, Uri},
9    typestate::{WithBody, WithoutBody},
10    Agent, Proxy, RequestBuilder,
11};
12
13/// Bounds TCP connect and TLS handshake, so an unroutable address fails over to the next one
14/// instead of stalling forever.
15const CONNECT_TIMEOUT: Duration = Duration::from_secs(30);
16
17#[derive(Clone, Debug)]
18pub struct ClientConfig {
19    pub user_agent: Option<String>,
20    pub headers: Option<HeaderMap>,
21    pub proxy: Option<Proxy>,
22    pub timeout: Option<Duration>,
23    pub ip_family: IpFamily,
24}
25
26impl Default for ClientConfig {
27    /// Creates a default ClientConfig populated with sensible defaults for HTTP requests.
28    ///
29    /// The default sets a user agent of "pkgforge/soar", allows both IPv4 and IPv6, and leaves
30    /// proxy, headers, and timeout unset.
31    ///
32    /// # Examples
33    ///
34    /// ```
35    /// use soar_dl::http_client::ClientConfig;
36    /// use ureq::config::IpFamily;
37    ///
38    /// let cfg = ClientConfig::default();
39    /// assert_eq!(cfg.user_agent.as_deref(), Some("pkgforge/soar"));
40    /// assert!(cfg.proxy.is_none());
41    /// assert!(cfg.headers.is_none());
42    /// assert!(cfg.timeout.is_none());
43    /// assert_eq!(cfg.ip_family, IpFamily::Any);
44    /// ```
45    fn default() -> Self {
46        Self {
47            user_agent: Some("pkgforge/soar".into()),
48            proxy: None,
49            headers: None,
50            timeout: None,
51            ip_family: IpFamily::Any,
52        }
53    }
54}
55
56impl ClientConfig {
57    /// Builds an HTTP `Agent` configured from this `ClientConfig`.
58    ///
59    /// The returned `Agent` will incorporate the configured proxy, global timeout,
60    /// IP family, and user agent header (if present).
61    ///
62    /// When no proxy is set, the `ALL_PROXY`, `HTTPS_PROXY`, `HTTP_PROXY` and `NO_PROXY`
63    /// environment variables are honored.
64    ///
65    /// # Examples
66    ///
67    /// ```
68    /// use soar_dl::http_client::ClientConfig;
69    ///
70    /// let config = ClientConfig::default();
71    /// let agent = config.build();
72    /// // create a request builder using the configured agent
73    /// let _req = agent.get("http://example.com");
74    /// ```
75    pub fn build(&self) -> Agent {
76        let mut config = ureq::Agent::config_builder()
77            .timeout_global(self.timeout)
78            .timeout_connect(Some(CONNECT_TIMEOUT))
79            .ip_family(self.ip_family);
80
81        if self.proxy.is_some() {
82            config = config.proxy(self.proxy.clone());
83        }
84
85        if let Some(user_agent) = &self.user_agent {
86            config = config.user_agent(user_agent);
87        }
88
89        config.build().into()
90    }
91}
92
93struct SharedClient {
94    agent: Agent,
95    config: ClientConfig,
96}
97
98static SHARED_CLIENT_STATE: LazyLock<Arc<RwLock<SharedClient>>> = LazyLock::new(|| {
99    let config = ClientConfig::default();
100    let agent = config.build();
101
102    Arc::new(RwLock::new(SharedClient {
103        agent,
104        config,
105    }))
106});
107
108#[derive(Clone, Default)]
109pub struct SharedAgent;
110
111impl SharedAgent {
112    /// Create a new `SharedAgent` instance.
113    ///
114    /// # Examples
115    ///
116    /// ```
117    /// use soar_dl::http_client::SharedAgent;
118    ///
119    /// let _agent = SharedAgent::new();
120    /// ```
121    pub fn new() -> Self {
122        Self
123    }
124
125    pub fn head<T>(&self, uri: T) -> RequestBuilder<WithoutBody>
126    where
127        Uri: TryFrom<T>,
128        <Uri as TryFrom<T>>::Error: Into<http::Error>,
129    {
130        let state = SHARED_CLIENT_STATE.read().unwrap();
131        let req = state.agent.head(uri);
132        apply_headers(req, &state.config.headers)
133    }
134
135    /// Create a GET request builder for the given URI using the shared agent.
136    ///
137    /// The returned `RequestBuilder` does not contain a body; any global headers
138    /// configured in the shared client are applied to the request.
139    ///
140    /// # Examples
141    ///
142    /// ```no_run
143    /// use soar_dl::http_client::SHARED_AGENT;
144    ///
145    /// // Create and send a GET request to the specified URI.
146    /// let response = SHARED_AGENT.get("https://example.com").call();
147    /// ```
148    pub fn get<T>(&self, uri: T) -> RequestBuilder<WithoutBody>
149    where
150        Uri: TryFrom<T>,
151        <Uri as TryFrom<T>>::Error: Into<http::Error>,
152    {
153        let state = SHARED_CLIENT_STATE.read().unwrap();
154        let req = state.agent.get(uri);
155        apply_headers(req, &state.config.headers)
156    }
157
158    /// Starts a POST request to the given URI using the shared agent and applies any globally configured headers.
159    ///
160    /// The returned `RequestBuilder<WithBody>` is ready to accept a request body and further per-request modifications.
161    ///
162    /// # Examples
163    ///
164    /// ```no_run
165    /// use soar_dl::http_client::SHARED_AGENT;
166    ///
167    /// let req = SHARED_AGENT.post("https://example.com/");
168    /// ```
169    pub fn post<T>(&self, uri: T) -> RequestBuilder<WithBody>
170    where
171        Uri: TryFrom<T>,
172        <Uri as TryFrom<T>>::Error: Into<http::Error>,
173    {
174        let state = SHARED_CLIENT_STATE.read().unwrap();
175        let req = state.agent.post(uri);
176        apply_headers(req, &state.config.headers)
177    }
178
179    /// Creates a PUT request builder for the specified URI using the shared agent and applies any configured global headers.
180    ///
181    /// # Examples
182    ///
183    /// ```
184    /// use soar_dl::http_client::SHARED_AGENT;
185    ///
186    /// let req = SHARED_AGENT.put("https://example.com/resource");
187    /// ```
188    pub fn put<T>(&self, uri: T) -> RequestBuilder<WithBody>
189    where
190        Uri: TryFrom<T>,
191        <Uri as TryFrom<T>>::Error: Into<http::Error>,
192    {
193        let state = SHARED_CLIENT_STATE.read().unwrap();
194        let req = state.agent.put(uri);
195        apply_headers(req, &state.config.headers)
196    }
197
198    /// Creates a DELETE request for the given URI using the shared agent and applies configured global headers.
199    ///
200    /// # Returns
201    ///
202    /// A `RequestBuilder<WithoutBody>` for the DELETE request with any configured global headers applied.
203    ///
204    /// # Examples
205    ///
206    /// ```
207    /// use soar_dl::http_client::SharedAgent;
208    ///
209    /// let agent = SharedAgent::new();
210    /// let _req = agent.delete("https://example.com/resource");
211    /// ```
212    pub fn delete<T>(&self, uri: T) -> RequestBuilder<WithoutBody>
213    where
214        Uri: TryFrom<T>,
215        <Uri as TryFrom<T>>::Error: Into<http::Error>,
216    {
217        let state = SHARED_CLIENT_STATE.read().unwrap();
218        let req = state.agent.delete(uri);
219        apply_headers(req, &state.config.headers)
220    }
221}
222
223/// Apply headers from an optional `HeaderMap` to a `RequestBuilder`.
224///
225/// If `headers` is `Some`, each header key/value pair is added to the provided request
226/// and the modified `RequestBuilder` is returned. If `headers` is `None`, the original
227/// request is returned unchanged.
228fn apply_headers<B>(mut req: RequestBuilder<B>, headers: &Option<HeaderMap>) -> RequestBuilder<B> {
229    if let Some(headers) = headers {
230        for (key, value) in headers.iter() {
231            req = req.header(key, value);
232        }
233    }
234    req
235}
236
237pub static SHARED_AGENT: LazyLock<SharedAgent> = LazyLock::new(SharedAgent::new);
238
239/// Updates the global shared HTTP client configuration by applying the provided updater and rebuilding the shared Agent.
240///
241/// The `updater` closure receives a mutable reference to a `ClientConfig` that will replace the current shared configuration.
242/// After the updater runs, a new `Agent` is built from the updated config and atomically replaces the shared agent and config.
243///
244/// # Examples
245///
246/// ```
247/// use soar_dl::http_client::configure_http_client;
248///
249/// // Change the global user agent string used by the shared HTTP client.
250/// configure_http_client(|cfg| {
251///     cfg.user_agent = Some("my-app/1.0".to_string());
252/// });
253/// ```
254pub fn configure_http_client<F>(updater: F)
255where
256    F: FnOnce(&mut ClientConfig),
257{
258    let mut state = SHARED_CLIENT_STATE.write().unwrap();
259    let mut new_config = state.config.clone();
260    updater(&mut new_config);
261    let new_agent = new_config.build();
262    state.agent = new_agent;
263    state.config = new_config;
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269
270    #[test]
271    fn test_client_config_default() {
272        let config = ClientConfig::default();
273        assert_eq!(config.user_agent, Some("pkgforge/soar".to_string()));
274        assert!(config.proxy.is_none());
275        assert!(config.headers.is_none());
276        assert!(config.timeout.is_none());
277        assert_eq!(config.ip_family, IpFamily::Any);
278    }
279
280    #[test]
281    fn test_client_config_build() {
282        let config = ClientConfig::default();
283        let agent = config.build();
284        // Just verify it builds without panicking
285        let _ = agent;
286    }
287
288    #[test]
289    fn test_client_config_with_timeout() {
290        let config = ClientConfig {
291            user_agent: Some("test-agent".to_string()),
292            proxy: None,
293            headers: None,
294            timeout: Some(Duration::from_secs(30)),
295            ip_family: IpFamily::Any,
296        };
297        let agent = config.build();
298        let _ = agent;
299    }
300
301    #[test]
302    fn test_client_config_sets_connect_timeout() {
303        let agent = ClientConfig::default().build();
304        assert_eq!(agent.config().timeouts().connect, Some(CONNECT_TIMEOUT));
305    }
306
307    #[test]
308    fn test_client_config_without_proxy_falls_back_to_env() {
309        let agent = ClientConfig::default().build();
310        assert_eq!(
311            agent.config().proxy().is_some(),
312            Proxy::try_from_env().is_some()
313        );
314    }
315
316    #[test]
317    fn test_client_config_explicit_proxy_overrides_env() {
318        let config = ClientConfig {
319            proxy: Some(Proxy::new("http://127.0.0.1:8080").unwrap()),
320            ..Default::default()
321        };
322        let agent = config.build();
323        assert_eq!(agent.config().proxy().unwrap().port(), 8080);
324    }
325
326    #[test]
327    fn test_client_config_ip_family() {
328        for family in [IpFamily::Any, IpFamily::Ipv4Only, IpFamily::Ipv6Only] {
329            let config = ClientConfig {
330                ip_family: family,
331                ..Default::default()
332            };
333            let agent = config.build();
334            assert_eq!(agent.config().ip_family(), family);
335        }
336    }
337
338    #[test]
339    fn test_shared_agent_new() {
340        let agent = SharedAgent::new();
341        let _ = agent;
342    }
343
344    #[test]
345    fn test_shared_agent_get() {
346        let agent = SharedAgent::new();
347        let req = agent.get("https://example.com");
348        // Verify the request builder was created
349        let _ = req;
350    }
351
352    #[test]
353    fn test_shared_agent_post() {
354        let agent = SharedAgent::new();
355        let req = agent.post("https://example.com");
356        let _ = req;
357    }
358
359    #[test]
360    fn test_shared_agent_put() {
361        let agent = SharedAgent::new();
362        let req = agent.put("https://example.com");
363        let _ = req;
364    }
365
366    #[test]
367    fn test_shared_agent_delete() {
368        let agent = SharedAgent::new();
369        let req = agent.delete("https://example.com");
370        let _ = req;
371    }
372
373    #[test]
374    fn test_shared_agent_head() {
375        let agent = SharedAgent::new();
376        let req = agent.head("https://example.com");
377        let _ = req;
378    }
379
380    #[test]
381    fn test_configure_http_client() {
382        configure_http_client(|cfg| {
383            cfg.user_agent = Some("custom-agent/1.0".to_string());
384        });
385
386        // Verify configuration was applied by checking we can still create requests
387        let agent = SharedAgent::new();
388        let _ = agent.get("https://example.com");
389    }
390
391    #[test]
392    fn test_configure_http_client_timeout() {
393        configure_http_client(|cfg| {
394            cfg.timeout = Some(Duration::from_secs(10));
395        });
396
397        let agent = SharedAgent::new();
398        let _ = agent.get("https://example.com");
399    }
400
401    #[test]
402    fn test_shared_agent_clone() {
403        let agent1 = SharedAgent::new();
404        let agent2 = agent1.clone();
405
406        // Both should work
407        let _ = agent1.get("https://example.com");
408        let _ = agent2.get("https://example.com");
409    }
410
411    #[test]
412    fn test_shared_agent_default() {
413        let agent = SharedAgent;
414        let _ = agent.get("https://example.com");
415    }
416
417    #[test]
418    fn test_apply_headers_none() {
419        let agent: ureq::Agent = ureq::Agent::config_builder().build().into();
420        let req = agent.get("https://example.com");
421        let req = apply_headers(req, &None);
422        let _ = req;
423    }
424
425    #[test]
426    fn test_apply_headers_some() {
427        let agent: ureq::Agent = ureq::Agent::config_builder().build().into();
428        let req = agent.get("https://example.com");
429
430        let mut headers = ureq::http::HeaderMap::new();
431        headers.insert(
432            ureq::http::header::USER_AGENT,
433            ureq::http::HeaderValue::from_static("test-agent"),
434        );
435
436        let req = apply_headers(req, &Some(headers));
437        let _ = req;
438    }
439
440    #[test]
441    fn test_client_config_clone() {
442        let config1 = ClientConfig::default();
443        let config2 = config1.clone();
444
445        assert_eq!(config1.user_agent, config2.user_agent);
446    }
447
448    #[test]
449    fn test_client_config_debug() {
450        let config = ClientConfig::default();
451        let debug = format!("{:?}", config);
452        assert!(debug.contains("ClientConfig"));
453    }
454}