From 4c149a146ed67d9114a79fddf6a8b82984eae066 Mon Sep 17 00:00:00 2001 From: Azeem Shaik Date: Sun, 9 Aug 2026 11:07:19 +0530 Subject: [PATCH] Release 0.2.0: percent-encode path params, add with_client, derive Clone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixed: - Path parameters are now percent-encoded (RFC 3986 unreserved set), so a value like `1/2` or `a?b` stays inside its URL segment instead of breaking out. Added: - `with_client(url, [auth], client, timeout)` constructor so a caller-supplied `reqwest::Client` — and its connection pool, TLS config, proxy, and default headers — can be shared across clients. `new` now delegates to it. - The generated client struct derives `Clone` (cheap; `reqwest::Client` is Arc-backed). Docs & tests: - README documents both constructors and Clone; CHANGELOG entry for 0.2.0. - Added tests proving path params are encoded, `with_client` uses the caller's client config, and clones remain functional. - advanced.rs demonstrates sharing one pool across two service clients. --- CHANGELOG.md | 16 +++++++ Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 13 +++++- examples/advanced.rs | 15 +++++- examples/basic.rs | 7 +++ src/expanders/method.rs | 5 +- src/expanders/mod.rs | 41 ++++++++++++++-- src/lib.rs | 2 +- tests/provider_tests.rs | 100 ++++++++++++++++++++++++++++++++++++++++ 10 files changed, 194 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b16d6a..63ea3c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,22 @@ All notable changes to this project are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.2.0] + +### Fixed + +- **Path parameters are now percent-encoded.** A value such as `1/2` or `a?b` is + encoded (`1%2F2`, `a%3Fb`) so it can no longer break out of its URL path segment. + Previously it was substituted verbatim. + +### Added + +- `with_client(url, [auth], client, timeout)` constructor, so a caller-supplied + `reqwest::Client` — and its connection pool, TLS config, proxy, or default headers — + can be shared across clients. `new` now builds on top of it. +- The generated client struct derives `Clone` (cheap: `reqwest::Client` is `Arc`-backed), + so it can be cloned into spawned tasks. + ## [0.1.1] ### Documentation diff --git a/Cargo.lock b/Cargo.lock index 17809ed..1bdff3a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -41,7 +41,7 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "beckon" -version = "0.1.1" +version = "0.2.0" dependencies = [ "heck", "proc-macro2", diff --git a/Cargo.toml b/Cargo.toml index a3310ab..325b1e6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "beckon" -version = "0.1.1" +version = "0.2.0" edition = "2021" authors = ["Azeem Shaik "] description = "Generate type-safe, async HTTP clients from endpoint definitions — a Rust proc macro." diff --git a/README.md b/README.md index 13897c0..3e33370 100644 --- a/README.md +++ b/README.md @@ -145,11 +145,22 @@ beckon!( Per-endpoint `retries` overrides the global value. Omitting `retries` entirely means no retries. +## Constructors + +- `UserApi::new(url, timeout)` — uses a default `reqwest::Client`. +- `UserApi::with_client(url, client, timeout)` — supply your own `reqwest::Client` to + share a connection pool, TLS config, proxy, or default headers. + +```rust +let http = reqwest::Client::builder().user_agent("my-app/1.0").build()?; +let client = UserApi::with_client(url, http, Some(5000)); +``` + ## Generated Code For a client named `UserApi`, the macro generates: -- A **struct** `UserApi` with a `new(url, timeout)` constructor +- A **struct** `UserApi` (derives `Clone`) with `new` and `with_client` constructors - An **async method** for each endpoint - A **trait** `UserApiTrait` for mocking in tests - An **error enum** `UserApiError` with variants for URL, request, HTTP, and deserialization errors diff --git a/examples/advanced.rs b/examples/advanced.rs index 1fe7e08..92e9c87 100644 --- a/examples/advanced.rs +++ b/examples/advanced.rs @@ -131,8 +131,21 @@ beckon!( #[tokio::main] async fn main() -> Result<(), Box> { + // Build one `reqwest::Client` and share it across API clients. It owns the + // connection pool, TLS session cache, and DNS cache, so every client built + // from it — or from a clone, since `reqwest::Client` is `Arc`-backed — + // reuses that state instead of opening fresh connections and re-doing TLS. + let http = reqwest::Client::builder() + .user_agent("beckon-example/1.0") + .pool_max_idle_per_host(16) + .build()?; + let base_url = Url::parse("https://api.example.com")?; - let client = ApiClient::new(base_url, Some(5000)); + let client = ApiClient::with_client(base_url, http.clone(), Some(5000)); + + // A client for a different service reuses the very same pool and TLS state. + let analytics_url = Url::parse("https://analytics.example.com")?; + let _analytics = ApiClient::with_client(analytics_url, http, Some(5000)); // Basic GET request let users = client.get_users().await?; diff --git a/examples/basic.rs b/examples/basic.rs index eeb5e6d..1ae0938 100644 --- a/examples/basic.rs +++ b/examples/basic.rs @@ -44,6 +44,13 @@ async fn main() -> Result<(), Box> { let base_url = Url::parse("https://api.example.com")?; let client = ApiClient::new(base_url, Some(5000)); + // Want to configure the underlying HTTP client — a shared connection pool, + // TLS, proxy, or default headers? Build your own `reqwest::Client` and pass + // it in with `with_client` instead (see `advanced.rs`): + // + // let http = reqwest::Client::builder().user_agent("my-app/1.0").build()?; + // let client = ApiClient::with_client(base_url, http, Some(5000)); + // Use the auto-generated methods let users = client.get_users().await?; println!("Found {} users", users.len()); diff --git a/src/expanders/method.rs b/src/expanders/method.rs index 9ac8a2b..f7f6917 100644 --- a/src/expanders/method.rs +++ b/src/expanders/method.rs @@ -261,7 +261,10 @@ impl<'a> UrlExpander<'a> { let param_name = &cap[1]; let ident = Ident::new(param_name, Span::call_site()); quote! { - path = path.replace(concat!("{", #param_name, "}"), &path_params.#ident.to_string()); + path = path.replace( + concat!("{", #param_name, "}"), + &Self::__beckon_encode_segment(&path_params.#ident.to_string()), + ); } }) .collect(); diff --git a/src/expanders/mod.rs b/src/expanders/mod.rs index 3ea4bf6..32cce58 100644 --- a/src/expanders/mod.rs +++ b/src/expanders/mod.rs @@ -63,26 +63,30 @@ impl ApiClientExpander { let struct_name = &self.input.struct_name; let trait_name = self.trait_name(); - let (auth_fields, auth_params, auth_inits) = match &self.input.auth { + let (auth_fields, auth_params, auth_args, auth_inits) = match &self.input.auth { Some(AuthStrategy::Bearer) => ( quote! { token: String, }, quote! { token: &str, }, + quote! { token, }, quote! { token: token.to_string(), }, ), Some(AuthStrategy::Basic) => ( quote! { username: String, password: String, }, quote! { username: &str, password: &str, }, + quote! { username, password, }, quote! { username: username.to_string(), password: password.to_string(), }, ), Some(AuthStrategy::ApiKey(_)) => ( quote! { api_key: String, }, quote! { api_key: &str, }, + quote! { api_key, }, quote! { api_key: api_key.to_string(), }, ), - None => (quote! {}, quote! {}, quote! {}), + None => (quote! {}, quote! {}, quote! {}, quote! {}), }; quote! { + #[derive(Clone)] pub struct #struct_name { url: reqwest::Url, client: reqwest::Client, @@ -92,10 +96,41 @@ impl ApiClientExpander { impl #struct_name { pub fn new(url: reqwest::Url, #auth_params timeout: Option) -> Self { - let client = reqwest::Client::new(); + Self::with_client(url, #auth_args reqwest::Client::new(), timeout) + } + + /// Build the client with a caller-supplied `reqwest::Client`, so a single + /// connection pool, TLS config, proxy, or default headers can be shared. + pub fn with_client( + url: reqwest::Url, + #auth_params + client: reqwest::Client, + timeout: Option, + ) -> Self { let timeout = std::time::Duration::from_millis(timeout.unwrap_or(5000)); Self { url, client, timeout, #auth_inits } } + + #[doc(hidden)] + #[allow(dead_code)] + fn __beckon_encode_segment(__s: &str) -> String { + // Percent-encode everything outside the RFC 3986 unreserved set so a + // path-param value like `1/2` or `a?b` can't break out of its segment. + const __HEX: &[u8; 16] = b"0123456789ABCDEF"; + let mut __out = String::with_capacity(__s.len()); + for __b in __s.bytes() { + match __b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' + | b'-' | b'_' | b'.' | b'~' => __out.push(__b as char), + _ => { + __out.push('%'); + __out.push(__HEX[(__b >> 4) as usize] as char); + __out.push(__HEX[(__b & 0x0f) as usize] as char); + } + } + } + __out + } } impl #trait_name for #struct_name { diff --git a/src/lib.rs b/src/lib.rs index d23447b..67f89d9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -37,7 +37,7 @@ //! ); //! //! # async fn example() -> Result<(), Box> { -//! let client = UserApi::new(reqwest::Url::parse("https://api.example.com")?, Some(30)); +//! let client = UserApi::new(reqwest::Url::parse("https://api.example.com")?, Some(5000)); //! let _users = client.get_users().await?; //! let _user = client.get_users_by_id(&UserPath { id: 1 }).await?; //! # Ok(()) diff --git a/tests/provider_tests.rs b/tests/provider_tests.rs index 6ce4f35..064cd35 100644 --- a/tests/provider_tests.rs +++ b/tests/provider_tests.rs @@ -674,4 +674,104 @@ mod tests { assert_eq!(result.value, "auth-retry-ok"); Ok(()) } + + // A path param value containing `/` must be percent-encoded so it stays a single + // segment. The mock only matches a single segment after `/users/`, so a raw `/` + // (two segments) would miss and the call would fail. + #[tokio::test] + async fn test_path_params_are_percent_encoded() -> Result<(), Box> { + let mock_server = MockServer::start().await; + let response = create_success_response("encoded"); + + Mock::given(method("GET")) + .and(wiremock::matchers::path_regex(r"^/users/[^/]+$")) + .respond_with(ResponseTemplate::new(200).set_body_json(response)) + .mount(&mock_server) + .await; + + let provider = HttpProvider::new(Url::from_str(&mock_server.uri())?, Some(5000)); + let result = provider + .get_users_by_id(&PathParams { + id: "1/2".to_string(), + }) + .await?; + + assert_eq!(result.value, "encoded"); + Ok(()) + } + + #[tokio::test] + async fn test_with_client_constructor() -> Result<(), Box> { + let mock_server = MockServer::start().await; + let response = create_success_response("shared-client"); + + Mock::given(method("GET")) + .and(wiremock::matchers::path("/users")) + .respond_with(ResponseTemplate::new(200).set_body_json(response)) + .mount(&mock_server) + .await; + + let client = reqwest::Client::new(); + let provider = + HttpProvider::with_client(Url::from_str(&mock_server.uri())?, client, Some(5000)); + let result = provider.get_users().await?; + + assert_eq!(result.value, "shared-client"); + Ok(()) + } + + // Proves `with_client` actually uses the caller's client (not a fresh one): a + // default header configured on the injected client must reach the server. This + // is the same mechanism by which its connection pool and TLS state are reused. + #[tokio::test] + async fn test_with_client_uses_caller_config() -> Result<(), Box> { + let mock_server = MockServer::start().await; + let response = create_success_response("configured"); + + Mock::given(method("GET")) + .and(wiremock::matchers::path("/users")) + .and(wiremock::matchers::header("x-app", "beckon")) + .respond_with(ResponseTemplate::new(200).set_body_json(response)) + .mount(&mock_server) + .await; + + let mut default_headers = HeaderMap::new(); + default_headers.insert("x-app", "beckon".parse()?); + let http = reqwest::Client::builder() + .default_headers(default_headers) + .build()?; + + let provider = + HttpProvider::with_client(Url::from_str(&mock_server.uri())?, http, Some(5000)); + // Mock only matches when the injected client's default header is present. + let result = provider.get_users().await?; + + assert_eq!(result.value, "configured"); + Ok(()) + } + + // The struct derives `Clone`; a clone must be independently functional and the + // original must remain usable afterwards. + #[tokio::test] + async fn test_cloned_client_still_works() -> Result<(), Box> { + let mock_server = MockServer::start().await; + + Mock::given(method("GET")) + .and(wiremock::matchers::path("/users")) + .respond_with( + ResponseTemplate::new(200).set_body_json(create_success_response("cloned")), + ) + .mount(&mock_server) + .await; + + let provider = HttpProvider::new(Url::from_str(&mock_server.uri())?, Some(5000)); + let cloned = provider.clone(); + + let from_clone = cloned.get_users().await?; + let from_original = provider.get_users().await?; + + assert_eq!(from_clone.value, "cloned"); + assert_eq!(from_original.value, "cloned"); + Ok(()) + } }