Skip to main content

soar_dl/
http_client.rs

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