From bd5a84fe95453d6c9575d3d6b0f5f25482f9b6cc Mon Sep 17 00:00:00 2001 From: geoffsee <> Date: Thu, 28 Aug 2025 14:46:07 -0400 Subject: [PATCH] Add `RequestOptions` for per-request customization with headers and timeouts - Introduced the `RequestOptions` struct for flexible HTTP request configurations. - Added `request_with_options` and `post_with_options` methods. - Deprecated `request` and `post` in favor of the new methods. - Updated examples and tests to reflect the new API. --- Cargo.lock | 2 +- crates/hyper-custom-cert/Cargo.toml | 2 +- crates/hyper-custom-cert/src/lib.rs | 277 +++++++++++++++++- .../tests/example_server_integration.rs | 32 +- 4 files changed, 303 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 46460ee..3356a14 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -489,7 +489,7 @@ dependencies = [ [[package]] name = "hyper-custom-cert" -version = "0.3.4" +version = "0.3.5" dependencies = [ "bytes", "http-body-util", diff --git a/crates/hyper-custom-cert/Cargo.toml b/crates/hyper-custom-cert/Cargo.toml index e3de66c..2f7503d 100644 --- a/crates/hyper-custom-cert/Cargo.toml +++ b/crates/hyper-custom-cert/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "hyper-custom-cert" -version = "0.3.4" +version = "0.3.5" edition = "2024" description = "A small, ergonomic HTTP client wrapper around hyper with optional support for custom Root CAs and a dev-only insecure mode for self-signed certificates." license = "MIT OR Apache-2.0" diff --git a/crates/hyper-custom-cert/src/lib.rs b/crates/hyper-custom-cert/src/lib.rs index 089038f..3bfeb24 100644 --- a/crates/hyper-custom-cert/src/lib.rs +++ b/crates/hyper-custom-cert/src/lib.rs @@ -41,6 +41,60 @@ use hyper_util::client::legacy::Client; use hyper_util::rt::TokioExecutor; use http_body_util::BodyExt; +/// Options for controlling HTTP requests. +/// +/// This struct provides a flexible interface for configuring individual +/// HTTP requests without modifying the client's default settings. +/// +/// # Examples +/// +/// Adding custom headers to a specific request: +/// +/// ``` +/// use hyper_custom_cert::{HttpClient, RequestOptions}; +/// use std::collections::HashMap; +/// +/// // Create request-specific headers +/// let mut headers = HashMap::new(); +/// headers.insert("x-request-id".to_string(), "123456".to_string()); +/// +/// // Create request options with these headers +/// let options = RequestOptions::new() +/// .with_headers(headers); +/// +/// // Make request with custom options +/// # async { +/// let client = HttpClient::new(); +/// let _response = client.request_with_options("https://example.com", Some(options)).await; +/// # }; +/// ``` +#[derive(Default, Clone)] +pub struct RequestOptions { + /// Headers to add to this specific request + pub headers: Option>, + /// Override the client's default timeout for this request + pub timeout: Option, +} + +impl RequestOptions { + /// Create a new empty RequestOptions with default values. + pub fn new() -> Self { + RequestOptions::default() + } + + /// Add custom headers to this request. + pub fn with_headers(mut self, headers: HashMap) -> Self { + self.headers = Some(headers); + self + } + + /// Override the client's default timeout for this request. + pub fn with_timeout(mut self, timeout: Duration) -> Self { + self.timeout = Some(timeout); + self + } +} + /// HTTP response with raw body data exposed as bytes. #[derive(Debug, Clone)] pub struct HttpResponse { @@ -131,7 +185,7 @@ impl From for ClientError { /// Build a client with a custom timeout and default headers: /// /// ``` -/// use hyper_custom_cert::HttpClient; +/// use hyper_custom_cert::{HttpClient, RequestOptions}; /// use std::time::Duration; /// use std::collections::HashMap; /// @@ -144,7 +198,7 @@ impl From for ClientError { /// .build(); /// /// // Placeholder call; does not perform I/O in this crate. -/// let _ = client.request("https://example.com"); +/// let _ = client.request_with_options("https://example.com", None); /// ``` pub struct HttpClient { timeout: Duration, @@ -234,7 +288,68 @@ impl HttpClient { /// This method constructs a `hyper::Request` with the GET method and any /// default headers configured on the client, then dispatches it via `perform_request`. /// Returns HttpResponse with raw body data exposed without any permutations. + /// + /// # Arguments + /// + /// * `url` - The URL to request + /// * `options` - Optional request options to customize this specific request + /// + /// # Examples + /// + /// ``` + /// # async { + /// use hyper_custom_cert::{HttpClient, RequestOptions}; + /// use std::collections::HashMap; + /// + /// let client = HttpClient::new(); + /// + /// // Basic request with no custom options + /// let response1 = client.request_with_options("https://example.com", None).await?; + /// + /// // Request with custom options + /// let mut headers = HashMap::new(); + /// headers.insert("x-request-id".into(), "abc123".into()); + /// let options = RequestOptions::new().with_headers(headers); + /// let response2 = client.request_with_options("https://example.com", Some(options)).await?; + /// # Ok::<(), hyper_custom_cert::ClientError>(()) + /// # }; + /// ``` + #[deprecated(since = "0.4.0", note = "Use request(url, Some(options)) instead")] pub async fn request(&self, url: &str) -> Result { + self.request_with_options(url, None).await + } + + /// Performs a GET request and returns the raw response body. + /// This method constructs a `hyper::Request` with the GET method and any + /// default headers configured on the client, then dispatches it via `perform_request`. + /// Returns HttpResponse with raw body data exposed without any permutations. + /// + /// # Arguments + /// + /// * `url` - The URL to request + /// * `options` - Optional request options to customize this specific request + /// + /// # Examples + /// + /// ``` + /// # async { + /// use hyper_custom_cert::{HttpClient, RequestOptions}; + /// use std::collections::HashMap; + /// + /// let client = HttpClient::new(); + /// + /// // Basic request with no custom options + /// let response1 = client.request_with_options("https://example.com", None).await?; + /// + /// // Request with custom options + /// let mut headers = HashMap::new(); + /// headers.insert("x-request-id".into(), "abc123".into()); + /// let options = RequestOptions::new().with_headers(headers); + /// let response2 = client.request_with_options("https://example.com", Some(options)).await?; + /// # Ok::<(), hyper_custom_cert::ClientError>(()) + /// # }; + /// ``` + pub async fn request_with_options(&self, url: &str, options: Option) -> Result { let uri: Uri = url.parse()?; let req = Request::builder() @@ -249,9 +364,44 @@ impl HttpClient { req = req.header(key, value); } + // Add any request-specific headers from options + if let Some(options) = &options { + if let Some(headers) = &options.headers { + for (key, value) in headers { + req = req.header(key, value); + } + } + } + let req = req.body(http_body_util::Empty::::new())?; - self.perform_request(req).await + // If options contain a timeout, temporarily modify self to use it + // This is a bit of a hack since we can't modify perform_request easily + let result = if let Some(opts) = &options { + if let Some(timeout) = opts.timeout { + // Create a copy of self with the new timeout + let client_copy = HttpClient { + timeout, + default_headers: self.default_headers.clone(), + #[cfg(feature = "insecure-dangerous")] + accept_invalid_certs: self.accept_invalid_certs, + root_ca_pem: self.root_ca_pem.clone(), + #[cfg(feature = "rustls")] + pinned_cert_sha256: self.pinned_cert_sha256.clone(), + }; + + // Use the modified client for this request only + client_copy.perform_request(req).await + } else { + // No timeout override, use normal client + self.perform_request(req).await + } + } else { + // No options, use normal client + self.perform_request(req).await + }; + + result } /// Performs a POST request with the given body and returns the raw response. @@ -259,7 +409,77 @@ impl HttpClient { /// operation, handles the request body conversion to `Bytes`, and applies /// default headers before calling `perform_request`. /// Returns HttpResponse with raw body data exposed without any permutations. + /// + /// # Arguments + /// + /// * `url` - The URL to request + /// * `body` - The body content to send with the POST request + /// * `options` - Optional request options to customize this specific request + /// + /// # Examples + /// + /// ``` + /// # async { + /// use hyper_custom_cert::{HttpClient, RequestOptions}; + /// use std::collections::HashMap; + /// use std::time::Duration; + /// + /// let client = HttpClient::new(); + /// + /// // Basic POST request with no custom options + /// let response1 = client.post_with_options("https://example.com/api", b"{\"key\":\"value\"}", None).await?; + /// + /// // POST request with custom options + /// let mut headers = HashMap::new(); + /// headers.insert("Content-Type".into(), "application/json".into()); + /// let options = RequestOptions::new() + /// .with_headers(headers) + /// .with_timeout(Duration::from_secs(5)); + /// let response2 = client.post_with_options("https://example.com/api", b"{\"key\":\"value\"}", Some(options)).await?; + /// # Ok::<(), hyper_custom_cert::ClientError>(()) + /// # }; + /// ``` + #[deprecated(since = "0.4.0", note = "Use post_with_options(url, body, Some(options)) instead")] pub async fn post>(&self, url: &str, body: B) -> Result { + self.post_with_options(url, body, None).await + } + + /// Performs a POST request with the given body and returns the raw response. + /// Similar to `request`, this method builds a `hyper::Request` for a POST + /// operation, handles the request body conversion to `Bytes`, and applies + /// default headers before calling `perform_request`. + /// Returns HttpResponse with raw body data exposed without any permutations. + /// + /// # Arguments + /// + /// * `url` - The URL to request + /// * `body` - The body content to send with the POST request + /// * `options` - Optional request options to customize this specific request + /// + /// # Examples + /// + /// ``` + /// # async { + /// use hyper_custom_cert::{HttpClient, RequestOptions}; + /// use std::collections::HashMap; + /// use std::time::Duration; + /// + /// let client = HttpClient::new(); + /// + /// // Basic POST request with no custom options + /// let response1 = client.post_with_options("https://example.com/api", b"{\"key\":\"value\"}", None).await?; + /// + /// // POST request with custom options + /// let mut headers = HashMap::new(); + /// headers.insert("Content-Type".into(), "application/json".into()); + /// let options = RequestOptions::new() + /// .with_headers(headers) + /// .with_timeout(Duration::from_secs(5)); + /// let response2 = client.post_with_options("https://example.com/api", b"{\"key\":\"value\"}", Some(options)).await?; + /// # Ok::<(), hyper_custom_cert::ClientError>(()) + /// # }; + /// ``` + pub async fn post_with_options>(&self, url: &str, body: B, options: Option) -> Result { let uri: Uri = url.parse()?; let req = Request::builder() @@ -272,10 +492,45 @@ impl HttpClient { req = req.header(key, value); } + // Add any request-specific headers from options + if let Some(options) = &options { + if let Some(headers) = &options.headers { + for (key, value) in headers { + req = req.header(key, value); + } + } + } + let body_bytes = Bytes::copy_from_slice(body.as_ref()); let req = req.body(http_body_util::Full::new(body_bytes))?; - self.perform_request(req).await + // If options contain a timeout, temporarily modify self to use it + // This is a bit of a hack since we can't modify perform_request easily + let result = if let Some(opts) = &options { + if let Some(timeout) = opts.timeout { + // Create a copy of self with the new timeout + let client_copy = HttpClient { + timeout, + default_headers: self.default_headers.clone(), + #[cfg(feature = "insecure-dangerous")] + accept_invalid_certs: self.accept_invalid_certs, + root_ca_pem: self.root_ca_pem.clone(), + #[cfg(feature = "rustls")] + pinned_cert_sha256: self.pinned_cert_sha256.clone(), + }; + + // Use the modified client for this request only + client_copy.perform_request(req).await + } else { + // No timeout override, use normal client + self.perform_request(req).await + } + } else { + // No options, use normal client + self.perform_request(req).await + }; + + result } /// Helper method to perform HTTP requests using the configured settings. @@ -610,14 +865,28 @@ impl HttpClient { /// On wasm32 targets, runtime methods are stubbed and return /// `ClientError::WasmNotImplemented` because browsers do not allow /// programmatic installation/trust of custom CAs. + #[deprecated(since = "0.4.0", note = "Use request_with_options(url, Some(options)) instead")] pub fn request(&self, _url: &str) -> Result<(), ClientError> { Err(ClientError::WasmNotImplemented) } + + /// On wasm32 targets, runtime methods are stubbed and return + /// `ClientError::WasmNotImplemented` because browsers do not allow + /// programmatic installation/trust of custom CAs. + pub fn request_with_options(&self, _url: &str, _options: Option) -> Result<(), ClientError> { + Err(ClientError::WasmNotImplemented) + } /// POST is also not implemented on wasm32 targets for the same reason. + #[deprecated(since = "0.4.0", note = "Use post_with_options(url, body, Some(options)) instead")] pub fn post>(&self, _url: &str, _body: B) -> Result<(), ClientError> { Err(ClientError::WasmNotImplemented) } + + /// POST is also not implemented on wasm32 targets for the same reason. + pub fn post_with_options>(&self, _url: &str, _body: B, _options: Option) -> Result<(), ClientError> { + Err(ClientError::WasmNotImplemented) + } } /// Builder for configuring and creating an [`HttpClient`]. diff --git a/crates/hyper-custom-cert/tests/example_server_integration.rs b/crates/hyper-custom-cert/tests/example_server_integration.rs index 3b07444..c980c4e 100644 --- a/crates/hyper-custom-cert/tests/example_server_integration.rs +++ b/crates/hyper-custom-cert/tests/example_server_integration.rs @@ -166,13 +166,37 @@ fn test_self_signed_convenience_constructor() { // HTTP METHOD TESTS - Test different HTTP methods // ============================================================================ +#[tokio::test] +async fn test_request_options() { + use hyper_custom_cert::RequestOptions; + use std::collections::HashMap; + use std::time::Duration; + + // Test RequestOptions functionality + let client = HttpClient::new(); + + // Create request options + let mut headers = HashMap::new(); + headers.insert("X-Custom-Header".to_string(), "test-value".to_string()); + + let options = RequestOptions::new() + .with_headers(headers) + .with_timeout(Duration::from_secs(15)); + + // Smoke test - verify request options can be used with both GET and POST + // In real usage: + // let _get_resp = client.request("https://example.com", Some(options.clone())).await.unwrap(); + // let _post_resp = client.post("https://example.com", b"{}", Some(options)).await.unwrap(); + let _ = (client, options); +} + #[tokio::test] async fn test_get_requests_to_example_server() { // Test GET requests let client = HttpClient::new(); // Smoke test - verify GET method API exists - // In real usage: let _response = client.request("http://localhost:8080/test/methods/get").await.unwrap(); + // In real usage: let _response = client.request("http://localhost:8080/test/methods/get", None).await.unwrap(); let _ = client; } @@ -184,8 +208,8 @@ async fn test_post_requests_to_example_server() { // Smoke test - verify POST method API exists // In real usage: // let json_payload = r#"{"name": "test", "value": "integration-test"}"#; - // let _response = client.post("http://localhost:8080/test/methods/post", json_payload.as_bytes()).await.unwrap(); - // let _response = client.post("http://localhost:8080/test/methods/post", b"").await.unwrap(); + // let _response = client.post("http://localhost:8080/test/methods/post", json_payload.as_bytes(), None).await.unwrap(); + // let _response = client.post("http://localhost:8080/test/methods/post", b"", None).await.unwrap(); let _ = client; } @@ -212,7 +236,7 @@ async fn test_invalid_url_handling() { // Smoke test - verify client creation // In real usage, this would test actual URL validation: - // let result = client.request("invalid-url").await; + // let result = client.request("invalid-url", None).await; // assert!(result.is_err()); // Should fail with invalid URI error let _ = client; }