Skip to main content

clawft_platform/
http.rs

1//! HTTP client abstraction and native implementation.
2//!
3//! Provides a platform-agnostic [`HttpClient`] trait and a native implementation
4//! backed by [`reqwest`]. A WASM implementation would use the browser's fetch API.
5
6use async_trait::async_trait;
7use std::collections::HashMap;
8
9/// HTTP response from a request.
10#[derive(Debug, Clone)]
11pub struct HttpResponse {
12    /// HTTP status code (e.g., 200, 404, 500).
13    pub status: u16,
14    /// Response headers as key-value pairs.
15    pub headers: HashMap<String, String>,
16    /// Raw response body bytes.
17    pub body: Vec<u8>,
18}
19
20impl HttpResponse {
21    /// Parse body as UTF-8 text.
22    pub fn text(&self) -> Result<String, std::string::FromUtf8Error> {
23        String::from_utf8(self.body.clone())
24    }
25
26    /// Parse body as JSON.
27    pub fn json<T: serde::de::DeserializeOwned>(&self) -> Result<T, serde_json::Error> {
28        serde_json::from_slice(&self.body)
29    }
30
31    /// Check if status is success (2xx).
32    pub fn is_success(&self) -> bool {
33        (200..300).contains(&self.status)
34    }
35}
36
37/// Platform-agnostic HTTP client.
38///
39/// Implementors provide transport-level HTTP operations. The native
40/// implementation uses [`reqwest`]; a WASM implementation would use fetch.
41#[cfg_attr(not(feature = "browser"), async_trait)]
42#[cfg_attr(feature = "browser", async_trait(?Send))]
43pub trait HttpClient: Send + Sync {
44    /// Send an HTTP request with the given method, URL, headers, and optional body.
45    async fn request(
46        &self,
47        method: &str,
48        url: &str,
49        headers: &HashMap<String, String>,
50        body: Option<&[u8]>,
51    ) -> Result<HttpResponse, Box<dyn std::error::Error + Send + Sync>>;
52
53    /// Send an HTTP GET request.
54    async fn get(
55        &self,
56        url: &str,
57        headers: &HashMap<String, String>,
58    ) -> Result<HttpResponse, Box<dyn std::error::Error + Send + Sync>> {
59        self.request("GET", url, headers, None).await
60    }
61
62    /// Send an HTTP POST request with a body.
63    async fn post(
64        &self,
65        url: &str,
66        headers: &HashMap<String, String>,
67        body: &[u8],
68    ) -> Result<HttpResponse, Box<dyn std::error::Error + Send + Sync>> {
69        self.request("POST", url, headers, Some(body)).await
70    }
71}
72
73/// Native HTTP client using [`reqwest`].
74#[cfg(feature = "native")]
75pub struct NativeHttpClient {
76    client: reqwest::Client,
77}
78
79#[cfg(feature = "native")]
80impl NativeHttpClient {
81    /// Create a new native HTTP client with sensible defaults.
82    ///
83    /// Uses a 60-second timeout and 30-second idle connection timeout
84    /// with connection pooling enabled.
85    pub fn new() -> Self {
86        Self {
87            client: reqwest::Client::builder()
88                .timeout(std::time::Duration::from_secs(60))
89                .pool_idle_timeout(std::time::Duration::from_secs(30))
90                .build()
91                .expect("failed to build reqwest client"),
92        }
93    }
94}
95
96#[cfg(feature = "native")]
97impl Default for NativeHttpClient {
98    fn default() -> Self {
99        Self::new()
100    }
101}
102
103#[cfg(feature = "native")]
104#[async_trait]
105impl HttpClient for NativeHttpClient {
106    async fn request(
107        &self,
108        method: &str,
109        url: &str,
110        headers: &HashMap<String, String>,
111        body: Option<&[u8]>,
112    ) -> Result<HttpResponse, Box<dyn std::error::Error + Send + Sync>> {
113        let reqwest_method = method.parse::<reqwest::Method>()?;
114        let mut builder = self.client.request(reqwest_method, url);
115
116        for (key, value) in headers {
117            builder = builder.header(key.as_str(), value.as_str());
118        }
119
120        if let Some(body_bytes) = body {
121            builder = builder.body(body_bytes.to_vec());
122        }
123
124        let response = builder.send().await?;
125
126        let status = response.status().as_u16();
127        let mut resp_headers = HashMap::new();
128        for (key, value) in response.headers() {
129            if let Ok(v) = value.to_str() {
130                resp_headers.insert(key.as_str().to_string(), v.to_string());
131            }
132        }
133        let resp_body = response.bytes().await?.to_vec();
134
135        Ok(HttpResponse {
136            status,
137            headers: resp_headers,
138            body: resp_body,
139        })
140    }
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146
147    #[test]
148    fn test_http_response_text() {
149        let response = HttpResponse {
150            status: 200,
151            headers: HashMap::new(),
152            body: b"hello world".to_vec(),
153        };
154        assert_eq!(response.text().unwrap(), "hello world");
155    }
156
157    #[test]
158    fn test_http_response_text_invalid_utf8() {
159        let response = HttpResponse {
160            status: 200,
161            headers: HashMap::new(),
162            body: vec![0xFF, 0xFE],
163        };
164        assert!(response.text().is_err());
165    }
166
167    #[test]
168    fn test_http_response_json() {
169        let response = HttpResponse {
170            status: 200,
171            headers: HashMap::new(),
172            body: br#"{"key": "value"}"#.to_vec(),
173        };
174        let parsed: HashMap<String, String> = response.json().unwrap();
175        assert_eq!(parsed.get("key").unwrap(), "value");
176    }
177
178    #[test]
179    fn test_http_response_json_invalid() {
180        let response = HttpResponse {
181            status: 200,
182            headers: HashMap::new(),
183            body: b"not json".to_vec(),
184        };
185        let result: Result<HashMap<String, String>, _> = response.json();
186        assert!(result.is_err());
187    }
188
189    #[test]
190    fn test_http_response_is_success() {
191        for status in [200, 201, 204, 299] {
192            let response = HttpResponse {
193                status,
194                headers: HashMap::new(),
195                body: vec![],
196            };
197            assert!(response.is_success(), "status {status} should be success");
198        }
199    }
200
201    #[test]
202    fn test_http_response_is_not_success() {
203        for status in [100, 301, 400, 404, 500] {
204            let response = HttpResponse {
205                status,
206                headers: HashMap::new(),
207                body: vec![],
208            };
209            assert!(
210                !response.is_success(),
211                "status {status} should not be success"
212            );
213        }
214    }
215
216    #[test]
217    fn test_native_http_client_default() {
218        let _client = NativeHttpClient::default();
219    }
220}