1pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
13pub enum Method {
14 #[default]
16 Get,
17 Post,
19 Put,
21 Delete,
23 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#[derive(Clone, Default)]
40pub struct HttpRequest {
41 pub method: Method,
43 pub url: String,
45 pub headers: Vec<(String, String)>,
47 pub body: Option<Vec<u8>>,
49}
50
51impl HttpRequest {
52 #[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 #[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 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 #[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#[derive(Clone)]
121pub struct HttpResponse {
122 pub status: u16,
124 pub headers: Vec<(String, String)>,
126 pub body: Vec<u8>,
128}
129
130impl HttpResponse {
131 #[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#[cfg(not(target_arch = "wasm32"))]
160pub trait Transport: Send + Sync {
161 fn send(
163 &self,
164 request: HttpRequest,
165 ) -> impl Future<Output = Result<HttpResponse, SDKError>> + Send;
166}
167
168#[cfg(target_arch = "wasm32")]
173pub trait Transport {
174 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!(debug_str.contains("\"Content-Type\", \"application/json\""));
273
274 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}