pub mod endpoint;
#[cfg(not(target_arch = "wasm32"))]
pub mod reqwest_transport;
use jules_core::errors::SDKError;
use std::future::Future;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Method {
#[default]
Get,
Post,
Put,
Delete,
Patch,
}
fn is_sensitive_header(header: &str) -> bool {
let lower = header.to_lowercase();
lower == "authorization"
|| lower == "api-key"
|| lower == "x-api-key"
|| lower == "set-cookie"
|| lower == "cookie"
|| lower.contains("token")
|| lower.contains("secret")
}
#[derive(Clone, Default)]
pub struct HttpRequest {
pub method: Method,
pub url: String,
pub headers: Vec<(String, String)>,
pub body: Option<Vec<u8>>,
}
impl HttpRequest {
#[must_use]
pub fn new(method: Method, url: impl Into<String>) -> Self {
Self {
method,
url: url.into(),
headers: Vec::new(),
body: None,
}
}
#[must_use]
pub fn with_header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
let key_str = key.into();
let value_str = value.into();
let sanitized_key = key_str.replace(['\r', '\n'], "");
let sanitized_value = value_str.replace(['\r', '\n'], "");
self.headers.push((sanitized_key, sanitized_value));
self
}
#[must_use]
pub fn with_body(mut self, body: Vec<u8>) -> Self {
self.body = Some(body);
self
}
}
struct RedactedHeaders<'a>(&'a [(String, String)]);
impl std::fmt::Debug for RedactedHeaders<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
struct RedactedHeader<'a>(&'a str, &'a str);
impl std::fmt::Debug for RedactedHeader<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if is_sensitive_header(self.0) {
f.debug_tuple("")
.field(&self.0)
.field(&"***REDACTED***")
.finish()
} else {
f.debug_tuple("").field(&self.0).field(&self.1).finish()
}
}
}
f.debug_list()
.entries(self.0.iter().map(|(k, v)| RedactedHeader(k, v)))
.finish()
}
}
impl std::fmt::Debug for HttpRequest {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("HttpRequest")
.field("method", &self.method)
.field("url", &self.url)
.field("headers", &RedactedHeaders(&self.headers))
.field("body", &self.body)
.finish()
}
}
#[derive(Clone)]
pub struct HttpResponse {
pub status: u16,
pub headers: Vec<(String, String)>,
pub body: Vec<u8>,
}
impl HttpResponse {
#[must_use]
pub fn new(status: u16, headers: Vec<(String, String)>, body: Vec<u8>) -> Self {
Self {
status,
headers,
body,
}
}
}
impl std::fmt::Debug for HttpResponse {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("HttpResponse")
.field("status", &self.status)
.field("headers", &RedactedHeaders(&self.headers))
.field("body", &self.body)
.finish()
}
}
#[cfg(not(target_arch = "wasm32"))]
pub trait Transport: Send + Sync {
fn send(
&self,
request: HttpRequest,
) -> impl Future<Output = Result<HttpResponse, SDKError>> + Send;
}
#[cfg(target_arch = "wasm32")]
pub trait Transport {
fn send(&self, request: HttpRequest) -> impl Future<Output = Result<HttpResponse, SDKError>>;
}
#[cfg(test)]
mod tests {
use super::*;
struct MockTransport {
response: std::sync::Mutex<Option<HttpResponse>>,
}
#[cfg(not(target_arch = "wasm32"))]
impl Transport for MockTransport {
fn send(
&self,
_request: HttpRequest,
) -> impl Future<Output = Result<HttpResponse, SDKError>> + Send {
let response = self.response.lock().unwrap().take().unwrap();
async move { Ok(response) }
}
}
#[cfg(target_arch = "wasm32")]
impl Transport for MockTransport {
fn send(
&self,
_request: HttpRequest,
) -> impl Future<Output = Result<HttpResponse, SDKError>> {
let response = self.response.lock().unwrap().take().unwrap();
async move { Ok(response) }
}
}
#[tokio::test]
async fn test_mock_transport() {
let transport = MockTransport {
response: std::sync::Mutex::new(Some(HttpResponse::new(
200,
vec![("Content-Type".into(), "application/json".into())],
b"{}".to_vec(),
))),
};
let request = HttpRequest::new(Method::Get, "https://api.example.com");
let response = transport.send(request).await.unwrap();
assert_eq!(response.status, 200);
assert_eq!(response.body, b"{}");
}
#[test]
fn test_http_response_debug_redaction() {
let response = HttpResponse::new(
200,
vec![
("Set-Cookie".to_string(), "session=secret_value".to_string()),
("Content-Type".to_string(), "text/plain".to_string()),
],
b"ok".to_vec(),
);
let debug_output = format!("{response:?}");
assert!(!debug_output.contains("secret_value"));
assert!(debug_output.contains("***REDACTED***"));
assert!(debug_output.contains("text/plain"));
}
#[test]
fn test_http_request_builder() {
let request = HttpRequest::new(Method::Post, "https://api.example.com")
.with_header("Content-Type", "application/json")
.with_header("Authorization", "Bearer token")
.with_body(b"{\"key\":\"value\"}".to_vec());
assert_eq!(request.method, Method::Post);
assert_eq!(request.url, "https://api.example.com");
assert_eq!(request.headers.len(), 2);
assert_eq!(
request.headers[0],
("Content-Type".into(), "application/json".into())
);
assert_eq!(
request.headers[1],
("Authorization".into(), "Bearer token".into())
);
assert_eq!(request.body, Some(b"{\"key\":\"value\"}".to_vec()));
}
#[test]
fn test_http_request_debug_redaction() {
let request = HttpRequest::new(Method::Get, "https://api.example.com")
.with_header("Content-Type", "application/json")
.with_header("Authorization", "Bearer secret-token")
.with_header("x-api-key", "secret-key")
.with_header("X-Custom-Token", "some-token");
let debug_str = format!("{request:?}");
assert!(debug_str.contains("\"Content-Type\", \"application/json\""));
assert!(!debug_str.contains("secret-token"));
assert!(debug_str.contains("\"Authorization\", \"***REDACTED***\""));
assert!(!debug_str.contains("secret-key"));
assert!(debug_str.contains("\"x-api-key\", \"***REDACTED***\""));
assert!(!debug_str.contains("some-token"));
assert!(debug_str.contains("\"X-Custom-Token\", \"***REDACTED***\""));
}
#[test]
fn test_http_request_crlf_injection() {
let request = HttpRequest::new(Method::Get, "https://api.example.com")
.with_header("Evil\r\nHeader", "value\r\nInjected-Header: secret");
assert_eq!(request.headers.len(), 1);
assert_eq!(request.headers[0].0, "EvilHeader");
assert_eq!(request.headers[0].1, "valueInjected-Header: secret");
}
}