Skip to main content

jules_api/http/
mod.rs

1//! Http module defining transport layer abstractions.
2
3pub mod endpoint;
4
5#[cfg(not(target_arch = "wasm32"))]
6pub mod reqwest_transport;
7
8use jules_core::errors::SDKError;
9use std::future::Future;
10
11/// Represents an HTTP method.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
13pub enum Method {
14    /// GET method
15    #[default]
16    Get,
17    /// POST method
18    Post,
19    /// PUT method
20    Put,
21    /// DELETE method
22    Delete,
23    /// PATCH method
24    Patch,
25}
26
27fn is_sensitive_header(header: &str) -> bool {
28    let lower = header.to_lowercase();
29    lower == "authorization"
30        || lower == "api-key"
31        || lower == "x-api-key"
32        || lower == "set-cookie"
33        || lower == "cookie"
34        || lower.contains("token")
35        || lower.contains("secret")
36}
37
38/// A generic HTTP request abstraction.
39#[derive(Clone, Default)]
40pub struct HttpRequest {
41    /// The HTTP method.
42    pub method: Method,
43    /// The URL to send the request to.
44    pub url: String,
45    /// The HTTP headers.
46    pub headers: Vec<(String, String)>,
47    /// The request body, if any.
48    pub body: Option<Vec<u8>>,
49}
50
51impl HttpRequest {
52    /// Creates a new `HttpRequest` with the given method and URL.
53    #[must_use]
54    pub fn new(method: Method, url: impl Into<String>) -> Self {
55        Self {
56            method,
57            url: url.into(),
58            headers: Vec::new(),
59            body: None,
60        }
61    }
62
63    /// Adds a header to the request.
64    #[must_use]
65    pub fn with_header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
66        let key_str = key.into();
67        let value_str = value.into();
68        // Sanitize CRLF to prevent HTTP Header Injection
69        let sanitized_key = key_str.replace(['\r', '\n'], "");
70        let sanitized_value = value_str.replace(['\r', '\n'], "");
71        self.headers.push((sanitized_key, sanitized_value));
72        self
73    }
74
75    /// Sets the request body.
76    #[must_use]
77    pub fn with_body(mut self, body: Vec<u8>) -> Self {
78        self.body = Some(body);
79        self
80    }
81}
82
83struct RedactedHeaders<'a>(&'a [(String, String)]);
84
85impl std::fmt::Debug for RedactedHeaders<'_> {
86    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
87        struct RedactedHeader<'a>(&'a str, &'a str);
88
89        impl std::fmt::Debug for RedactedHeader<'_> {
90            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
91                if is_sensitive_header(self.0) {
92                    f.debug_tuple("")
93                        .field(&self.0)
94                        .field(&"***REDACTED***")
95                        .finish()
96                } else {
97                    f.debug_tuple("").field(&self.0).field(&self.1).finish()
98                }
99            }
100        }
101
102        f.debug_list()
103            .entries(self.0.iter().map(|(k, v)| RedactedHeader(k, v)))
104            .finish()
105    }
106}
107
108impl std::fmt::Debug for HttpRequest {
109    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
110        f.debug_struct("HttpRequest")
111            .field("method", &self.method)
112            .field("url", &self.url)
113            .field("headers", &RedactedHeaders(&self.headers))
114            .field("body", &self.body)
115            .finish()
116    }
117}
118
119/// A generic HTTP response abstraction.
120#[derive(Clone)]
121pub struct HttpResponse {
122    /// The HTTP status code.
123    pub status: u16,
124    /// The HTTP headers.
125    pub headers: Vec<(String, String)>,
126    /// The response body.
127    pub body: Vec<u8>,
128}
129
130impl HttpResponse {
131    /// Creates a new `HttpResponse`.
132    #[must_use]
133    pub fn new(status: u16, headers: Vec<(String, String)>, body: Vec<u8>) -> Self {
134        Self {
135            status,
136            headers,
137            body,
138        }
139    }
140}
141
142impl std::fmt::Debug for HttpResponse {
143    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
144        f.debug_struct("HttpResponse")
145            .field("status", &self.status)
146            .field("headers", &RedactedHeaders(&self.headers))
147            .field("body", &self.body)
148            .finish()
149    }
150}
151
152/// The transport layer abstraction for executing HTTP requests.
153///
154/// On native targets the returned future must be [`Send`] (and the implementor `Send + Sync`)
155/// so transports can be used from multi-threaded async runtimes. On `wasm32`, browser types
156/// reached through the Fetch API (e.g. `JsValue`, `web_sys::Response`, the `JsFuture` returned
157/// by `wasm_bindgen_futures`) are not `Send`/`Sync` — wasm is single-threaded, so the bound is
158/// dropped there rather than required.
159#[cfg(not(target_arch = "wasm32"))]
160pub trait Transport: Send + Sync {
161    /// Sends an HTTP request and returns the response asynchronously.
162    fn send(
163        &self,
164        request: HttpRequest,
165    ) -> impl Future<Output = Result<HttpResponse, SDKError>> + Send;
166}
167
168/// The transport layer abstraction for executing HTTP requests.
169///
170/// See the native definition of this trait for details on why the `Send` bound is dropped on
171/// `wasm32`.
172#[cfg(target_arch = "wasm32")]
173pub trait Transport {
174    /// Sends an HTTP request and returns the response asynchronously.
175    fn send(&self, request: HttpRequest) -> impl Future<Output = Result<HttpResponse, SDKError>>;
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181
182    struct MockTransport {
183        response: std::sync::Mutex<Option<HttpResponse>>,
184    }
185
186    #[cfg(not(target_arch = "wasm32"))]
187    impl Transport for MockTransport {
188        fn send(
189            &self,
190            _request: HttpRequest,
191        ) -> impl Future<Output = Result<HttpResponse, SDKError>> + Send {
192            let response = self.response.lock().unwrap().take().unwrap();
193            async move { Ok(response) }
194        }
195    }
196
197    #[cfg(target_arch = "wasm32")]
198    impl Transport for MockTransport {
199        fn send(
200            &self,
201            _request: HttpRequest,
202        ) -> impl Future<Output = Result<HttpResponse, SDKError>> {
203            let response = self.response.lock().unwrap().take().unwrap();
204            async move { Ok(response) }
205        }
206    }
207
208    #[tokio::test]
209    async fn test_mock_transport() {
210        let transport = MockTransport {
211            response: std::sync::Mutex::new(Some(HttpResponse::new(
212                200,
213                vec![("Content-Type".into(), "application/json".into())],
214                b"{}".to_vec(),
215            ))),
216        };
217        let request = HttpRequest::new(Method::Get, "https://api.example.com");
218        let response = transport.send(request).await.unwrap();
219        assert_eq!(response.status, 200);
220        assert_eq!(response.body, b"{}");
221    }
222
223    #[test]
224    fn test_http_response_debug_redaction() {
225        let response = HttpResponse::new(
226            200,
227            vec![
228                ("Set-Cookie".to_string(), "session=secret_value".to_string()),
229                ("Content-Type".to_string(), "text/plain".to_string()),
230            ],
231            b"ok".to_vec(),
232        );
233
234        let debug_output = format!("{response:?}");
235        assert!(!debug_output.contains("secret_value"));
236        assert!(debug_output.contains("***REDACTED***"));
237        assert!(debug_output.contains("text/plain"));
238    }
239
240    #[test]
241    fn test_http_request_builder() {
242        let request = HttpRequest::new(Method::Post, "https://api.example.com")
243            .with_header("Content-Type", "application/json")
244            .with_header("Authorization", "Bearer token")
245            .with_body(b"{\"key\":\"value\"}".to_vec());
246
247        assert_eq!(request.method, Method::Post);
248        assert_eq!(request.url, "https://api.example.com");
249        assert_eq!(request.headers.len(), 2);
250        assert_eq!(
251            request.headers[0],
252            ("Content-Type".into(), "application/json".into())
253        );
254        assert_eq!(
255            request.headers[1],
256            ("Authorization".into(), "Bearer token".into())
257        );
258        assert_eq!(request.body, Some(b"{\"key\":\"value\"}".to_vec()));
259    }
260
261    #[test]
262    fn test_http_request_debug_redaction() {
263        let request = HttpRequest::new(Method::Get, "https://api.example.com")
264            .with_header("Content-Type", "application/json")
265            .with_header("Authorization", "Bearer secret-token")
266            .with_header("x-api-key", "secret-key")
267            .with_header("X-Custom-Token", "some-token");
268
269        let debug_str = format!("{request:?}");
270
271        // Assert non-sensitive headers are visible
272        assert!(debug_str.contains("\"Content-Type\", \"application/json\""));
273
274        // Assert sensitive headers are redacted
275        assert!(!debug_str.contains("secret-token"));
276        assert!(debug_str.contains("\"Authorization\", \"***REDACTED***\""));
277
278        assert!(!debug_str.contains("secret-key"));
279        assert!(debug_str.contains("\"x-api-key\", \"***REDACTED***\""));
280
281        assert!(!debug_str.contains("some-token"));
282        assert!(debug_str.contains("\"X-Custom-Token\", \"***REDACTED***\""));
283    }
284
285    #[test]
286    fn test_http_request_crlf_injection() {
287        let request = HttpRequest::new(Method::Get, "https://api.example.com")
288            .with_header("Evil\r\nHeader", "value\r\nInjected-Header: secret");
289
290        assert_eq!(request.headers.len(), 1);
291        assert_eq!(request.headers[0].0, "EvilHeader");
292        assert_eq!(request.headers[0].1, "valueInjected-Header: secret");
293    }
294}