mod error;
mod request;
mod response;
mod ssrf;
pub use error::{Error, Result, map_wasi_error};
pub use request::{
ClientRequest, Method, Scheme, delete, get, head, options, patch, post, put, request,
};
pub use response::Response;
pub use ssrf::is_private_address;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_response_status_checks() {
let ok = Response::new(200, vec![], vec![]);
assert!(ok.is_success());
assert!(!ok.is_client_error());
assert!(!ok.is_server_error());
let created = Response::new(201, vec![], vec![]);
assert!(created.is_success());
let not_found = Response::new(404, vec![], vec![]);
assert!(!not_found.is_success());
assert!(not_found.is_client_error());
assert!(!not_found.is_server_error());
let server_error = Response::new(500, vec![], vec![]);
assert!(!server_error.is_success());
assert!(!server_error.is_client_error());
assert!(server_error.is_server_error());
}
#[test]
fn test_response_headers() {
let response = Response::new(
200,
vec![
("Content-Type".to_string(), "application/json".to_string()),
("X-Request-Id".to_string(), "abc123".to_string()),
("Set-Cookie".to_string(), "a=1".to_string()),
("Set-Cookie".to_string(), "b=2".to_string()),
],
vec![],
);
assert_eq!(response.header("content-type"), Some("application/json"));
assert_eq!(response.header("Content-Type"), Some("application/json"));
assert_eq!(response.header("CONTENT-TYPE"), Some("application/json"));
let cookies = response.header_all("set-cookie");
assert_eq!(cookies, vec!["a=1", "b=2"]);
assert_eq!(response.header("x-missing"), None);
}
#[test]
fn test_response_body() {
let response = Response::new(200, vec![], b"Hello, World!".to_vec());
assert_eq!(response.bytes(), b"Hello, World!");
assert_eq!(response.text(), Some("Hello, World!"));
let binary = Response::new(200, vec![], vec![0xFF, 0xFE]);
assert!(binary.text().is_none());
}
#[test]
fn test_response_json() {
let response = Response::new(200, vec![], br#"{"name":"Alice","age":30}"#.to_vec());
let json = response.json().expect("should parse JSON");
assert_eq!(json.path_str(&["name"]), Some("Alice".to_string()));
assert_eq!(json.path_int(&["age"]), Some(30));
}
#[test]
fn test_response_json_empty_body() {
let response = Response::new(200, vec![], vec![]);
assert!(response.json().is_none());
}
#[test]
fn test_response_json_invalid() {
let response = Response::new(200, vec![], b"not json".to_vec());
assert!(response.json().is_none());
}
#[test]
fn test_response_json_with() {
let response = Response::new(200, vec![], br#"{"count":42}"#.to_vec());
let count = response
.json_with(|bytes| crate::json::try_parse(bytes).and_then(|j| j.path_int(&["count"])));
assert_eq!(count, Some(42));
}
#[test]
fn test_request_builder() {
let req = get("https://api.example.com/users")
.header("Authorization", "Bearer token")
.header("Accept", "application/json");
assert_eq!(req.method(), Method::Get);
assert_eq!(req.url(), "https://api.example.com/users");
assert_eq!(req.headers().len(), 2);
}
#[test]
fn test_request_with_json_body() {
let req = post("https://api.example.com/users").json(b"{\"name\":\"Alice\"}");
assert_eq!(req.method(), Method::Post);
assert!(
req.headers()
.iter()
.any(|(k, v)| k == "Content-Type" && v == "application/json")
);
assert_eq!(req.body_bytes(), Some(b"{\"name\":\"Alice\"}".as_slice()));
}
#[test]
fn test_request_timeout() {
let req = get("https://api.example.com/data").timeout_ms(5000);
assert_eq!(req.timeout(), Some(5_000_000_000));
let req2 = get("https://api.example.com/data").timeout_ns(1_000_000);
assert_eq!(req2.timeout(), Some(1_000_000));
}
#[test]
fn test_url_parsing() {
let req = get("https://api.example.com/users?page=1");
let (scheme, authority, path) = req.parse_url().unwrap();
assert_eq!(scheme, Scheme::Https);
assert_eq!(authority, "api.example.com");
assert_eq!(path, "/users?page=1");
let req2 = get("http://localhost:8080/api/v1");
let (scheme2, authority2, path2) = req2.parse_url().unwrap();
assert_eq!(scheme2, Scheme::Http);
assert_eq!(authority2, "localhost:8080");
assert_eq!(path2, "/api/v1");
let req3 = get("https://example.com");
let (_, _, path3) = req3.parse_url().unwrap();
assert_eq!(path3, "/");
}
#[test]
fn test_url_parsing_errors() {
let req = get("ftp://example.com/file");
assert!(matches!(req.parse_url(), Err(Error::InvalidUrl(_))));
let req2 = get("https:///no-host");
assert!(matches!(req2.parse_url(), Err(Error::InvalidUrl(_))));
}
#[test]
fn test_method_as_str() {
assert_eq!(Method::Get.as_str(), "GET");
assert_eq!(Method::Post.as_str(), "POST");
assert_eq!(Method::Put.as_str(), "PUT");
assert_eq!(Method::Delete.as_str(), "DELETE");
assert_eq!(Method::Patch.as_str(), "PATCH");
assert_eq!(Method::Head.as_str(), "HEAD");
assert_eq!(Method::Options.as_str(), "OPTIONS");
}
#[test]
fn test_error_display() {
assert_eq!(
Error::DnsError("failed".to_string()).to_string(),
"dns error: failed"
);
assert_eq!(
Error::Timeout { timeout_ms: None }.to_string(),
"request timeout"
);
assert_eq!(
Error::Timeout {
timeout_ms: Some(5000)
}
.to_string(),
"request timeout after 5000ms"
);
assert_eq!(
Error::InvalidUrl("bad url".to_string()).to_string(),
"invalid url: bad url"
);
}
#[test]
fn test_all_http_methods_request() {
assert_eq!(get("http://x.com").method(), Method::Get);
assert_eq!(post("http://x.com").method(), Method::Post);
assert_eq!(put("http://x.com").method(), Method::Put);
assert_eq!(delete("http://x.com").method(), Method::Delete);
assert_eq!(patch("http://x.com").method(), Method::Patch);
assert_eq!(head("http://x.com").method(), Method::Head);
assert_eq!(options("http://x.com").method(), Method::Options);
}
#[test]
fn test_timeout_zero() {
let req = get("https://example.com").timeout_ms(0);
assert_eq!(req.timeout(), Some(0));
}
#[test]
fn test_timeout_large() {
let req = get("https://example.com").timeout_ms(u64::MAX / 1_000_000);
assert!(req.timeout().is_some());
}
#[test]
fn test_url_with_port() {
let req = get("https://api.example.com:8443/users");
let (scheme, authority, path) = req.parse_url().unwrap();
assert_eq!(scheme, Scheme::Https);
assert_eq!(authority, "api.example.com:8443");
assert_eq!(path, "/users");
}
#[test]
fn test_url_with_fragment() {
let req = get("https://example.com/page#section");
let (_, _, path) = req.parse_url().unwrap();
assert_eq!(path, "/page#section");
}
#[test]
fn test_url_with_query_and_fragment() {
let req = get("https://example.com/page?foo=bar#section");
let (_, _, path) = req.parse_url().unwrap();
assert_eq!(path, "/page?foo=bar#section");
}
#[test]
fn test_url_root_path() {
let req = get("https://example.com");
let (_, _, path) = req.parse_url().unwrap();
assert_eq!(path, "/");
let req2 = get("https://example.com/");
let (_, _, path2) = req2.parse_url().unwrap();
assert_eq!(path2, "/");
}
#[test]
fn test_duplicate_headers() {
let req = get("https://example.com")
.header("Accept", "text/html")
.header("Accept", "application/json");
let headers = req.headers();
let accept_count = headers.iter().filter(|(k, _)| k == "Accept").count();
assert_eq!(accept_count, 2);
}
#[test]
fn test_empty_header_value() {
let req = get("https://example.com").header("X-Empty", "");
let headers = req.headers();
assert!(headers.iter().any(|(k, v)| k == "X-Empty" && v.is_empty()));
}
#[test]
fn test_header_special_chars() {
let req = get("https://example.com")
.header("Authorization", "Bearer abc123==")
.header("X-Custom", "value; param=test");
let headers = req.headers();
assert!(
headers
.iter()
.any(|(k, v)| k == "Authorization" && v == "Bearer abc123==")
);
assert!(
headers
.iter()
.any(|(k, v)| k == "X-Custom" && v == "value; param=test")
);
}
#[test]
fn test_body_empty() {
let req = post("https://example.com").body(b"");
assert_eq!(req.body_bytes(), Some(&[][..]));
}
#[test]
fn test_body_binary() {
let binary_data = vec![0x00, 0xFF, 0x7F, 0x80];
let req = post("https://example.com").body(&binary_data);
assert_eq!(req.body_bytes(), Some(binary_data.as_slice()));
}
#[test]
fn test_response_empty_body() {
let response = Response::new(204, vec![], vec![]);
assert!(response.bytes().is_empty());
assert_eq!(response.text(), Some(""));
}
#[test]
fn test_response_header_empty_value() {
let response = Response::new(200, vec![("X-Empty".to_string(), String::new())], vec![]);
assert_eq!(response.header("x-empty"), Some(""));
}
#[test]
fn test_response_all_status_codes() {
assert!(!Response::new(100, vec![], vec![]).is_success());
assert!(Response::new(200, vec![], vec![]).is_success());
assert!(Response::new(201, vec![], vec![]).is_success());
assert!(Response::new(204, vec![], vec![]).is_success());
assert!(Response::new(299, vec![], vec![]).is_success());
assert!(!Response::new(301, vec![], vec![]).is_success());
assert!(!Response::new(301, vec![], vec![]).is_client_error());
assert!(Response::new(400, vec![], vec![]).is_client_error());
assert!(Response::new(404, vec![], vec![]).is_client_error());
assert!(Response::new(499, vec![], vec![]).is_client_error());
assert!(Response::new(500, vec![], vec![]).is_server_error());
assert!(Response::new(503, vec![], vec![]).is_server_error());
}
#[test]
fn test_error_variants() {
let errors = vec![
Error::DnsError("lookup failed".to_string()),
Error::ConnectionError("refused".to_string()),
Error::Timeout { timeout_ms: None },
Error::TlsError("cert invalid".to_string()),
Error::InvalidUrl("bad".to_string()),
Error::InvalidRequest("bad method".to_string()),
Error::ResponseError("stream failed".to_string()),
Error::Other("mysterious".to_string()),
];
for error in errors {
let s = error.to_string();
assert!(!s.is_empty());
}
}
#[test]
fn test_url_parsing_edge_cases() {
let req = get("https://user:pass@example.com/path");
let result = req.parse_url();
let _ = result;
let req2 = get("http://192.168.1.1:8080/api");
let (scheme, authority, path) = req2.parse_url().unwrap();
assert_eq!(scheme, Scheme::Http);
assert_eq!(authority, "192.168.1.1:8080");
assert_eq!(path, "/api");
let req3 = get("http://localhost/test");
let (_, authority3, _) = req3.parse_url().unwrap();
assert_eq!(authority3, "localhost");
}
#[test]
fn test_status_boundaries() {
assert!(!Response::new(300, vec![], vec![]).is_success());
assert!(Response::new(299, vec![], vec![]).is_success());
assert!(Response::new(500, vec![], vec![]).is_server_error());
assert!(!Response::new(499, vec![], vec![]).is_server_error());
assert!(!Response::new(600, vec![], vec![]).is_server_error());
assert!(Response::new(599, vec![], vec![]).is_server_error());
}
#[test]
fn test_put_method_specific() {
let req = put("https://example.com/resource").body(b"data");
assert_eq!(req.method(), Method::Put);
}
#[test]
fn test_delete_method_specific() {
let req = delete("https://example.com/resource/123");
assert_eq!(req.method(), Method::Delete);
}
#[test]
fn test_patch_method_specific() {
let req = patch("https://example.com/resource").body(b"partial");
assert_eq!(req.method(), Method::Patch);
}
#[test]
fn test_head_method_specific() {
let req = head("https://example.com/resource");
assert_eq!(req.method(), Method::Head);
}
#[test]
fn test_options_method_specific() {
let req = options("https://example.com/resource");
assert_eq!(req.method(), Method::Options);
}
#[test]
fn test_map_wasi_error_dns() {
assert!(matches!(
map_wasi_error("DNS lookup failed"),
Error::DnsError(_)
));
assert!(matches!(
map_wasi_error("dns error: NXDOMAIN"),
Error::DnsError(_)
));
assert!(matches!(
map_wasi_error("NXDOMAIN for api.invalid.xyz"),
Error::DnsError(_)
));
assert!(matches!(
map_wasi_error("no such host: example.invalid"),
Error::DnsError(_)
));
assert!(matches!(
map_wasi_error("name resolution failed"),
Error::DnsError(_)
));
assert!(matches!(
map_wasi_error("Failed to resolve hostname"),
Error::DnsError(_)
));
assert!(matches!(
map_wasi_error("getaddrinfo failed: EAI_NONAME"),
Error::DnsError(_)
));
assert!(matches!(
map_wasi_error("could not resolve host"),
Error::DnsError(_)
));
}
#[test]
fn test_map_wasi_error_timeout() {
assert!(matches!(
map_wasi_error("request timeout"),
Error::Timeout { timeout_ms: None }
));
assert!(matches!(
map_wasi_error("operation timed out"),
Error::Timeout { timeout_ms: None }
));
assert!(matches!(
map_wasi_error("deadline exceeded after 5000ms"),
Error::Timeout { timeout_ms: None }
));
assert!(matches!(
map_wasi_error("ETIMEDOUT"),
Error::Timeout { timeout_ms: None }
));
assert!(matches!(
map_wasi_error("Request timed out after 30 seconds"),
Error::Timeout { timeout_ms: None }
));
}
#[test]
fn test_map_wasi_error_tls() {
assert!(matches!(
map_wasi_error("certificate has expired"),
Error::TlsError(_)
));
assert!(matches!(
map_wasi_error("invalid cert chain"),
Error::TlsError(_)
));
assert!(matches!(
map_wasi_error("SSL handshake failed"),
Error::TlsError(_)
));
assert!(matches!(
map_wasi_error("TLS error: unknown CA"),
Error::TlsError(_)
));
assert!(matches!(
map_wasi_error("handshake failed: protocol version"),
Error::TlsError(_)
));
assert!(matches!(
map_wasi_error("self signed certificate in chain"),
Error::TlsError(_)
));
assert!(matches!(
map_wasi_error("CERTIFICATE_VERIFY_FAILED"),
Error::TlsError(_)
));
}
#[test]
fn test_map_wasi_error_connection() {
assert!(matches!(
map_wasi_error("connection refused"),
Error::ConnectionError(_)
));
assert!(matches!(
map_wasi_error("ECONNREFUSED: 127.0.0.1:8080"),
Error::ConnectionError(_)
));
assert!(matches!(
map_wasi_error("connection reset by peer"),
Error::ConnectionError(_)
));
assert!(matches!(
map_wasi_error("ECONNRESET"),
Error::ConnectionError(_)
));
assert!(matches!(
map_wasi_error("network unreachable"),
Error::ConnectionError(_)
));
assert!(matches!(
map_wasi_error("ENETUNREACH"),
Error::ConnectionError(_)
));
assert!(matches!(
map_wasi_error("host unreachable"),
Error::ConnectionError(_)
));
assert!(matches!(
map_wasi_error("EHOSTUNREACH"),
Error::ConnectionError(_)
));
assert!(matches!(
map_wasi_error("failed to connect to server"),
Error::ConnectionError(_)
));
assert!(matches!(
map_wasi_error("socket error: broken pipe"),
Error::ConnectionError(_)
));
assert!(matches!(
map_wasi_error("I/O error during connection"),
Error::ConnectionError(_)
));
assert!(matches!(
map_wasi_error("IO error: unexpected EOF"),
Error::ConnectionError(_)
));
assert!(matches!(
map_wasi_error("connect error: no route"),
Error::ConnectionError(_)
));
}
#[test]
fn test_map_wasi_error_invalid_request() {
assert!(matches!(
map_wasi_error("invalid header name"),
Error::InvalidRequest(_)
));
assert!(matches!(
map_wasi_error("bad header value"),
Error::InvalidRequest(_)
));
assert!(matches!(
map_wasi_error("invalid method: TRACE"),
Error::InvalidRequest(_)
));
assert!(matches!(
map_wasi_error("unsupported method"),
Error::InvalidRequest(_)
));
assert!(matches!(
map_wasi_error("request too large: 10MB limit"),
Error::InvalidRequest(_)
));
assert!(matches!(
map_wasi_error("body too large"),
Error::InvalidRequest(_)
));
}
#[test]
fn test_map_wasi_error_response() {
assert!(matches!(
map_wasi_error("response error: malformed"),
Error::ResponseError(_)
));
assert!(matches!(
map_wasi_error("body error: truncated"),
Error::ResponseError(_)
));
assert!(matches!(
map_wasi_error("stream error: closed"),
Error::ResponseError(_)
));
assert!(matches!(
map_wasi_error("read error: unexpected EOF"),
Error::ResponseError(_)
));
assert!(matches!(
map_wasi_error("payload too large: 100MB"),
Error::ResponseError(_)
));
assert!(matches!(
map_wasi_error("content too large"),
Error::ResponseError(_)
));
assert!(matches!(
map_wasi_error("response failed to complete"),
Error::ResponseError(_)
));
}
#[test]
fn test_map_wasi_error_other() {
assert!(matches!(
map_wasi_error("something unexpected happened"),
Error::Other(_)
));
assert!(matches!(
map_wasi_error("unknown error code 42"),
Error::Other(_)
));
assert!(matches!(
map_wasi_error("internal server error"),
Error::Other(_)
));
}
#[test]
fn test_map_wasi_error_case_insensitive() {
assert!(matches!(
map_wasi_error("DNS LOOKUP FAILED"),
Error::DnsError(_)
));
assert!(matches!(map_wasi_error("TIMEOUT"), Error::Timeout { .. }));
assert!(matches!(
map_wasi_error("Certificate Error"),
Error::TlsError(_)
));
assert!(matches!(
map_wasi_error("CONNECTION REFUSED"),
Error::ConnectionError(_)
));
}
#[test]
fn test_map_wasi_error_preserves_message() {
let msg = "DNS lookup failed for api.example.com: NXDOMAIN";
if let Error::DnsError(s) = map_wasi_error(msg) {
assert_eq!(s, msg);
} else {
panic!("Expected DnsError");
}
let msg2 = "TLS handshake failed: certificate expired";
if let Error::TlsError(s) = map_wasi_error(msg2) {
assert_eq!(s, msg2);
} else {
panic!("Expected TlsError");
}
}
#[test]
fn test_map_wasi_error_priority() {
assert!(matches!(
map_wasi_error("connection timeout"),
Error::Timeout { .. }
));
assert!(matches!(
map_wasi_error("TLS handshake error"),
Error::TlsError(_)
));
assert!(matches!(
map_wasi_error("DNS resolve timeout"),
Error::DnsError(_)
));
}
#[test]
fn test_map_wasi_error_empty_string() {
assert!(matches!(map_wasi_error(""), Error::Other(_)));
}
#[test]
fn test_with_trace_id_some() {
let req = ClientRequest::new(Method::Get, "https://api.example.com/data")
.with_trace_id(Some("abc123"));
let headers = req.headers();
assert!(
headers
.iter()
.any(|(k, v)| k == "X-Trace-Id" && v == "abc123")
);
}
#[test]
fn test_with_trace_id_none() {
let req =
ClientRequest::new(Method::Get, "https://api.example.com/data").with_trace_id(None);
let headers = req.headers();
assert!(!headers.iter().any(|(k, _)| k == "X-Trace-Id"));
}
#[test]
fn test_with_trace_id_chaining() {
let req = ClientRequest::new(Method::Post, "https://api.example.com/data")
.header("Authorization", "Bearer token")
.with_trace_id(Some("trace-xyz"))
.header("Accept", "application/json");
let headers = req.headers();
assert!(
headers
.iter()
.any(|(k, v)| k == "Authorization" && v == "Bearer token")
);
assert!(
headers
.iter()
.any(|(k, v)| k == "X-Trace-Id" && v == "trace-xyz")
);
assert!(
headers
.iter()
.any(|(k, v)| k == "Accept" && v == "application/json")
);
}
#[test]
fn test_deny_private_ips_localhost() {
let req = get("http://localhost/api").deny_private_ips();
assert!(req.denies_private_ips());
let result = req.parse_url();
assert!(matches!(result, Err(Error::InvalidUrl(_))));
if let Err(Error::InvalidUrl(msg)) = result {
assert!(msg.contains("private"));
}
}
#[test]
fn test_deny_private_ips_127() {
let req = get("http://127.0.0.1:8080/api").deny_private_ips();
let result = req.parse_url();
assert!(matches!(result, Err(Error::InvalidUrl(_))));
}
#[test]
fn test_deny_private_ips_10_range() {
let req = get("http://10.0.0.1/internal").deny_private_ips();
assert!(matches!(req.parse_url(), Err(Error::InvalidUrl(_))));
let req2 = get("http://10.255.255.255/internal").deny_private_ips();
assert!(matches!(req2.parse_url(), Err(Error::InvalidUrl(_))));
}
#[test]
fn test_deny_private_ips_172_range() {
let req = get("http://172.16.0.1/internal").deny_private_ips();
assert!(matches!(req.parse_url(), Err(Error::InvalidUrl(_))));
let req2 = get("http://172.31.255.255/internal").deny_private_ips();
assert!(matches!(req2.parse_url(), Err(Error::InvalidUrl(_))));
let req3 = get("http://172.15.0.1/external").deny_private_ips();
assert!(req3.parse_url().is_ok());
let req4 = get("http://172.32.0.1/external").deny_private_ips();
assert!(req4.parse_url().is_ok());
}
#[test]
fn test_deny_private_ips_192_168() {
let req = get("http://192.168.1.1/router").deny_private_ips();
assert!(matches!(req.parse_url(), Err(Error::InvalidUrl(_))));
let req2 = get("http://192.169.1.1/external").deny_private_ips();
assert!(req2.parse_url().is_ok());
}
#[test]
fn test_deny_private_ips_169_254() {
let req = get("http://169.254.1.1/link-local").deny_private_ips();
assert!(matches!(req.parse_url(), Err(Error::InvalidUrl(_))));
}
#[test]
fn test_deny_private_ips_ipv6_loopback() {
let req = get("http://[::1]/api").deny_private_ips();
assert!(matches!(req.parse_url(), Err(Error::InvalidUrl(_))));
}
#[test]
fn test_deny_private_ips_ipv6_link_local() {
let req = get("http://[fe80::1]/api").deny_private_ips();
assert!(matches!(req.parse_url(), Err(Error::InvalidUrl(_))));
}
#[test]
fn test_deny_private_ips_public_allowed() {
let req = get("https://8.8.8.8/dns").deny_private_ips();
assert!(req.parse_url().is_ok());
let req2 = get("https://api.example.com/data").deny_private_ips();
assert!(req2.parse_url().is_ok());
}
#[test]
fn test_deny_private_ips_disabled_by_default() {
let req = get("http://localhost/api");
assert!(!req.denies_private_ips());
assert!(req.parse_url().is_ok());
}
#[test]
fn test_is_private_address_with_port() {
assert!(is_private_address("localhost:8080"));
assert!(is_private_address("127.0.0.1:3000"));
assert!(is_private_address("192.168.1.1:443"));
assert!(!is_private_address("example.com:443"));
}
#[test]
fn test_is_private_address_subdomain_localhost() {
assert!(is_private_address("api.localhost"));
assert!(is_private_address("sub.api.localhost"));
}
#[test]
#[should_panic(expected = "CR or LF")]
fn test_header_injection_cr() {
let _ = get("https://example.com").header("X-Custom", "value\rinjected");
}
#[test]
#[should_panic(expected = "CR or LF")]
fn test_header_injection_lf() {
let _ = get("https://example.com").header("X-Custom", "value\nX-Injected: bad");
}
#[test]
#[should_panic(expected = "CR or LF")]
fn test_header_injection_crlf() {
let _ = get("https://example.com").header("X-Custom", "value\r\nX-Injected: bad");
}
#[test]
fn test_header_valid_values() {
let req = get("https://example.com")
.header("Authorization", "Bearer token123")
.header("Accept", "application/json")
.header("X-Custom", "value with spaces and special: chars!");
assert_eq!(req.headers().len(), 3);
}
}