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    proxy: Option<ProxyConfig>,
154}
155
156#[cfg(not(target_arch = "wasm32"))]
157#[derive(Debug)]
158struct ProxyConfig {
159    url: url::Url,
160    matcher: Option<regex::Regex>,
161}
162
163impl HttpClientBuilder {
164    /// Accept invalid TLS certificates (non-WASM only)
165    #[cfg(not(target_arch = "wasm32"))]
166    pub fn danger_accept_invalid_certs(mut self, accept: bool) -> Self {
167        self.accept_invalid_certs = accept;
168        self
169    }
170
171    /// Set a proxy URL (non-WASM only)
172    #[cfg(not(target_arch = "wasm32"))]
173    pub fn proxy(mut self, url: url::Url) -> Self {
174        self.proxy = Some(ProxyConfig { url, matcher: None });
175        self
176    }
177
178    /// Set a proxy URL with a host pattern matcher (non-WASM only)
179    #[cfg(not(target_arch = "wasm32"))]
180    pub fn proxy_with_matcher(mut self, url: url::Url, pattern: &str) -> Response<Self> {
181        let matcher = regex::Regex::new(pattern)
182            .map_err(|e| HttpError::Proxy(format!("Invalid proxy pattern: {}", e)))?;
183        self.proxy = Some(ProxyConfig {
184            url,
185            matcher: Some(matcher),
186        });
187        Ok(self)
188    }
189
190    /// Build the HTTP client
191    pub fn build(self) -> Response<HttpClient> {
192        #[cfg(not(target_arch = "wasm32"))]
193        {
194            let mut builder =
195                reqwest::Client::builder().danger_accept_invalid_certs(self.accept_invalid_certs);
196
197            if let Some(proxy_config) = self.proxy {
198                let proxy_url = proxy_config.url.to_string();
199                let proxy = if let Some(matcher) = proxy_config.matcher {
200                    reqwest::Proxy::custom(move |url| {
201                        if matcher.is_match(url.host_str().unwrap_or("")) {
202                            Some(proxy_url.clone())
203                        } else {
204                            None
205                        }
206                    })
207                } else {
208                    reqwest::Proxy::all(&proxy_url).map_err(|e| HttpError::Proxy(e.to_string()))?
209                };
210                builder = builder.proxy(proxy);
211            }
212
213            let client = builder.build().map_err(HttpError::from)?;
214            Ok(HttpClient { inner: client })
215        }
216
217        #[cfg(target_arch = "wasm32")]
218        {
219            Ok(HttpClient::new())
220        }
221    }
222}
223
224/// Convenience function for simple GET requests (replaces reqwest::get)
225pub async fn fetch<R: DeserializeOwned>(url: &str) -> Response<R> {
226    HttpClient::new().fetch(url).await
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232
233    #[test]
234    fn test_client_new() {
235        let client = HttpClient::new();
236        // Client should be constructable without panicking
237        let _ = format!("{:?}", client);
238    }
239
240    #[test]
241    fn test_client_default() {
242        let client = HttpClient::default();
243        // Default should produce a valid client
244        let _ = format!("{:?}", client);
245    }
246
247    #[test]
248    fn test_builder_returns_builder() {
249        let builder = HttpClient::builder();
250        let _ = format!("{:?}", builder);
251    }
252
253    #[test]
254    fn test_builder_build() {
255        let result = HttpClientBuilder::default().build();
256        assert!(result.is_ok());
257    }
258
259    #[test]
260    fn test_from_reqwest() {
261        let reqwest_client = reqwest::Client::new();
262        let client = HttpClient::from_reqwest(reqwest_client);
263        let _ = format!("{:?}", client);
264    }
265
266    #[cfg(not(target_arch = "wasm32"))]
267    mod non_wasm {
268        use super::*;
269
270        #[test]
271        fn test_builder_accept_invalid_certs() {
272            let result = HttpClientBuilder::default()
273                .danger_accept_invalid_certs(true)
274                .build();
275            assert!(result.is_ok());
276        }
277
278        #[test]
279        fn test_builder_accept_invalid_certs_false() {
280            let result = HttpClientBuilder::default()
281                .danger_accept_invalid_certs(false)
282                .build();
283            assert!(result.is_ok());
284        }
285
286        #[test]
287        fn test_builder_proxy() {
288            let proxy_url = url::Url::parse("http://localhost:8080").expect("Valid proxy URL");
289            let result = HttpClientBuilder::default().proxy(proxy_url).build();
290            assert!(result.is_ok());
291        }
292
293        #[test]
294        fn test_builder_proxy_with_valid_matcher() {
295            let proxy_url = url::Url::parse("http://localhost:8080").expect("Valid proxy URL");
296            let result =
297                HttpClientBuilder::default().proxy_with_matcher(proxy_url, r".*\.example\.com$");
298            assert!(result.is_ok());
299
300            let builder = result.expect("Valid matcher should succeed");
301            let client_result = builder.build();
302            assert!(client_result.is_ok());
303        }
304
305        #[test]
306        fn test_builder_proxy_with_invalid_matcher() {
307            let proxy_url = url::Url::parse("http://localhost:8080").expect("Valid proxy URL");
308            // Invalid regex pattern (unclosed bracket)
309            let result = HttpClientBuilder::default().proxy_with_matcher(proxy_url, r"[invalid");
310            assert!(result.is_err());
311
312            if let Err(HttpError::Proxy(msg)) = result {
313                assert!(msg.contains("Invalid proxy pattern"));
314            } else {
315                panic!("Expected HttpError::Proxy");
316            }
317        }
318
319        #[test]
320        fn test_builder_chained_config() {
321            let proxy_url = url::Url::parse("http://localhost:8080").expect("Valid proxy URL");
322            let result = HttpClientBuilder::default()
323                .danger_accept_invalid_certs(true)
324                .proxy(proxy_url)
325                .build();
326            assert!(result.is_ok());
327        }
328    }
329}