Skip to main content

cdk_http_client/
client.rs

1//! HTTP client wrapper
2
3use serde::de::DeserializeOwned;
4use serde::Serialize;
5
6use crate::error::HttpError;
7use crate::request::RequestBuilder;
8use crate::response::{RawResponse, Response};
9
10/// HTTP client wrapper
11#[derive(Debug, Clone)]
12pub struct HttpClient {
13    inner: reqwest::Client,
14}
15
16impl Default for HttpClient {
17    fn default() -> Self {
18        Self::new()
19    }
20}
21
22impl HttpClient {
23    /// Create a new HTTP client with default settings
24    pub fn new() -> Self {
25        Self {
26            inner: reqwest::Client::new(),
27        }
28    }
29
30    /// Create a new HTTP client builder
31    pub fn builder() -> HttpClientBuilder {
32        HttpClientBuilder::default()
33    }
34
35    /// Create an HttpClient from a reqwest::Client
36    pub fn from_reqwest(client: reqwest::Client) -> Self {
37        Self { inner: client }
38    }
39
40    // === Simple convenience methods ===
41
42    /// GET request, returns JSON deserialized to R
43    pub async fn fetch<R>(&self, url: &str) -> Response<R>
44    where
45        R: DeserializeOwned,
46    {
47        let response = self.inner.get(url).send().await?;
48        let status = response.status();
49
50        if !status.is_success() {
51            let message = response.text().await.unwrap_or_default();
52            return Err(HttpError::Status {
53                status: status.as_u16(),
54                message,
55            });
56        }
57
58        response.json().await.map_err(HttpError::from)
59    }
60
61    /// POST with JSON body, returns JSON deserialized to R
62    pub async fn post_json<B, R>(&self, url: &str, body: &B) -> Response<R>
63    where
64        B: Serialize + ?Sized,
65        R: DeserializeOwned,
66    {
67        let response = self.inner.post(url).json(body).send().await?;
68        let status = response.status();
69
70        if !status.is_success() {
71            let message = response.text().await.unwrap_or_default();
72            return Err(HttpError::Status {
73                status: status.as_u16(),
74                message,
75            });
76        }
77
78        response.json().await.map_err(HttpError::from)
79    }
80
81    /// POST with form data, returns JSON deserialized to R
82    pub async fn post_form<F, R>(&self, url: &str, form: &F) -> Response<R>
83    where
84        F: Serialize + ?Sized,
85        R: DeserializeOwned,
86    {
87        let response = self.inner.post(url).form(form).send().await?;
88        let status = response.status();
89
90        if !status.is_success() {
91            let message = response.text().await.unwrap_or_default();
92            return Err(HttpError::Status {
93                status: status.as_u16(),
94                message,
95            });
96        }
97
98        response.json().await.map_err(HttpError::from)
99    }
100
101    /// PATCH with JSON body, returns JSON deserialized to R
102    pub async fn patch_json<B, R>(&self, url: &str, body: &B) -> Response<R>
103    where
104        B: Serialize + ?Sized,
105        R: DeserializeOwned,
106    {
107        let response = self.inner.patch(url).json(body).send().await?;
108        let status = response.status();
109
110        if !status.is_success() {
111            let message = response.text().await.unwrap_or_default();
112            return Err(HttpError::Status {
113                status: status.as_u16(),
114                message,
115            });
116        }
117
118        response.json().await.map_err(HttpError::from)
119    }
120
121    // === Raw request methods ===
122
123    /// GET request returning raw response body
124    pub async fn get_raw(&self, url: &str) -> Response<RawResponse> {
125        let response = self.inner.get(url).send().await?;
126        Ok(RawResponse::new(response))
127    }
128
129    // === Request builder methods ===
130
131    /// POST request builder for complex cases (custom headers, form data, etc.)
132    pub fn post(&self, url: &str) -> RequestBuilder {
133        RequestBuilder::new(self.inner.post(url))
134    }
135
136    /// GET request builder for complex cases (custom headers, etc.)
137    pub fn get(&self, url: &str) -> RequestBuilder {
138        RequestBuilder::new(self.inner.get(url))
139    }
140
141    /// PATCH request builder for complex cases (custom headers, JSON body, etc.)
142    pub fn patch(&self, url: &str) -> RequestBuilder {
143        RequestBuilder::new(self.inner.patch(url))
144    }
145}
146
147/// HTTP client builder for configuring proxy and TLS settings
148#[derive(Debug, Default)]
149pub struct HttpClientBuilder {
150    #[cfg(not(target_arch = "wasm32"))]
151    accept_invalid_certs: bool,
152    #[cfg(not(target_arch = "wasm32"))]
153    no_redirects: bool,
154    #[cfg(not(target_arch = "wasm32"))]
155    proxy: Option<ProxyConfig>,
156}
157
158#[cfg(not(target_arch = "wasm32"))]
159#[derive(Debug)]
160struct ProxyConfig {
161    url: url::Url,
162    matcher: Option<regex::Regex>,
163}
164
165impl HttpClientBuilder {
166    /// Accept invalid TLS certificates (non-WASM only)
167    #[cfg(not(target_arch = "wasm32"))]
168    pub fn danger_accept_invalid_certs(mut self, accept: bool) -> Self {
169        self.accept_invalid_certs = accept;
170        self
171    }
172
173    /// Set a proxy URL (non-WASM only)
174    #[cfg(not(target_arch = "wasm32"))]
175    pub fn proxy(mut self, url: url::Url) -> Self {
176        self.proxy = Some(ProxyConfig { url, matcher: None });
177        self
178    }
179
180    /// Set a proxy URL with a host pattern matcher (non-WASM only)
181    #[cfg(not(target_arch = "wasm32"))]
182    pub fn proxy_with_matcher(mut self, url: url::Url, pattern: &str) -> Response<Self> {
183        let matcher = regex::Regex::new(pattern)
184            .map_err(|e| HttpError::Proxy(format!("Invalid proxy pattern: {}", e)))?;
185        self.proxy = Some(ProxyConfig {
186            url,
187            matcher: Some(matcher),
188        });
189        Ok(self)
190    }
191
192    /// Disable automatic HTTP redirect following (non-WASM only)
193    #[cfg(not(target_arch = "wasm32"))]
194    pub fn no_redirects(mut self) -> Self {
195        self.no_redirects = true;
196        self
197    }
198
199    /// Build the HTTP client
200    pub fn build(self) -> Response<HttpClient> {
201        #[cfg(not(target_arch = "wasm32"))]
202        {
203            let mut builder =
204                reqwest::Client::builder().danger_accept_invalid_certs(self.accept_invalid_certs);
205
206            if self.no_redirects {
207                builder = builder.redirect(reqwest::redirect::Policy::none());
208            }
209
210            if let Some(proxy_config) = self.proxy {
211                let proxy_url = proxy_config.url.to_string();
212                let proxy = if let Some(matcher) = proxy_config.matcher {
213                    reqwest::Proxy::custom(move |url| {
214                        if matcher.is_match(url.host_str().unwrap_or("")) {
215                            Some(proxy_url.clone())
216                        } else {
217                            None
218                        }
219                    })
220                } else {
221                    reqwest::Proxy::all(&proxy_url).map_err(|e| HttpError::Proxy(e.to_string()))?
222                };
223                builder = builder.proxy(proxy);
224            }
225
226            let client = builder.build().map_err(HttpError::from)?;
227            Ok(HttpClient { inner: client })
228        }
229
230        #[cfg(target_arch = "wasm32")]
231        {
232            Ok(HttpClient::new())
233        }
234    }
235}
236
237/// Convenience function for simple GET requests (replaces reqwest::get)
238pub async fn fetch<R: DeserializeOwned>(url: &str) -> Response<R> {
239    HttpClient::new().fetch(url).await
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245
246    #[test]
247    fn test_client_new() {
248        let client = HttpClient::new();
249        // Client should be constructable without panicking
250        let _ = format!("{:?}", client);
251    }
252
253    #[test]
254    fn test_client_default() {
255        let client = HttpClient::default();
256        // Default should produce a valid client
257        let _ = format!("{:?}", client);
258    }
259
260    #[test]
261    fn test_builder_returns_builder() {
262        let builder = HttpClient::builder();
263        let _ = format!("{:?}", builder);
264    }
265
266    #[test]
267    fn test_builder_build() {
268        let result = HttpClientBuilder::default().build();
269        assert!(result.is_ok());
270    }
271
272    #[test]
273    fn test_from_reqwest() {
274        let reqwest_client = reqwest::Client::new();
275        let client = HttpClient::from_reqwest(reqwest_client);
276        let _ = format!("{:?}", client);
277    }
278
279    #[cfg(not(target_arch = "wasm32"))]
280    mod non_wasm {
281        use super::*;
282
283        #[test]
284        fn test_builder_accept_invalid_certs() {
285            let result = HttpClientBuilder::default()
286                .danger_accept_invalid_certs(true)
287                .build();
288            assert!(result.is_ok());
289        }
290
291        #[test]
292        fn test_builder_accept_invalid_certs_false() {
293            let result = HttpClientBuilder::default()
294                .danger_accept_invalid_certs(false)
295                .build();
296            assert!(result.is_ok());
297        }
298
299        #[test]
300        fn test_builder_no_redirects() {
301            let result = HttpClientBuilder::default().no_redirects().build();
302            assert!(result.is_ok());
303        }
304
305        #[test]
306        fn test_builder_proxy() {
307            let proxy_url = url::Url::parse("http://localhost:8080").expect("Valid proxy URL");
308            let result = HttpClientBuilder::default().proxy(proxy_url).build();
309            assert!(result.is_ok());
310        }
311
312        #[test]
313        fn test_builder_proxy_with_valid_matcher() {
314            let proxy_url = url::Url::parse("http://localhost:8080").expect("Valid proxy URL");
315            let result =
316                HttpClientBuilder::default().proxy_with_matcher(proxy_url, r".*\.example\.com$");
317            assert!(result.is_ok());
318
319            let builder = result.expect("Valid matcher should succeed");
320            let client_result = builder.build();
321            assert!(client_result.is_ok());
322        }
323
324        #[test]
325        fn test_builder_proxy_with_invalid_matcher() {
326            let proxy_url = url::Url::parse("http://localhost:8080").expect("Valid proxy URL");
327            // Invalid regex pattern (unclosed bracket)
328            let result = HttpClientBuilder::default().proxy_with_matcher(proxy_url, r"[invalid");
329            assert!(result.is_err());
330
331            if let Err(HttpError::Proxy(msg)) = result {
332                assert!(msg.contains("Invalid proxy pattern"));
333            } else {
334                panic!("Expected HttpError::Proxy");
335            }
336        }
337
338        #[test]
339        fn test_builder_chained_config() {
340            let proxy_url = url::Url::parse("http://localhost:8080").expect("Valid proxy URL");
341            let result = HttpClientBuilder::default()
342                .danger_accept_invalid_certs(true)
343                .proxy(proxy_url)
344                .build();
345            assert!(result.is_ok());
346        }
347    }
348}