From 476c41beb6d51ab57e581859e5cb80e4ec909365 Mon Sep 17 00:00:00 2001 From: Azeem Shaik Date: Mon, 24 Aug 2026 09:51:09 +0530 Subject: [PATCH] Release 0.4.0: preserve base-URL path prefixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixed: - A base URL with a path prefix (e.g. https://api.example.com/v1) had its prefix dropped when an endpoint path was joined, because an absolute path replaces the base path per RFC 3986. The base is now normalized to a trailing `/` and endpoint paths are joined relative to it, so `/v1` + `/users` → `/v1/users`. Bases without a path prefix are unaffected. Uses existing primitives only (Url::set_path, Url::join, str::trim_start_matches, and the existing segment encoder) — no new dependency. Tests: base {no prefix, /v1, /v1/} x path {params, no params, param with `/`} plus a no-prefix regression test. --- CHANGELOG.md | 11 +++++ Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 3 ++ src/expanders/method.rs | 6 ++- src/expanders/mod.rs | 7 +++ tests/provider_tests.rs | 107 ++++++++++++++++++++++++++++++++++++++++ 7 files changed, 134 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7fccd69..2cc9511 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ 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.4.0] + +### Fixed + +- **Base-URL path prefixes are now preserved.** A base like `https://api.example.com/v1` + used to have its `/v1` dropped when an endpoint path (e.g. `/users`) was joined, because + an absolute path replaces the base path per RFC 3986. beckon now normalizes the base to a + trailing `/` and joins endpoint paths relative to it, so `/v1` + `/users` → + `https://api.example.com/v1/users`. Clients using a base without a path prefix are + unaffected. + ## [0.3.1] Documentation and metadata only — no code or behavior changes. diff --git a/Cargo.lock b/Cargo.lock index c0dd27c..d170959 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -41,7 +41,7 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "beckon" -version = "0.3.1" +version = "0.4.0" dependencies = [ "heck", "proc-macro2", diff --git a/Cargo.toml b/Cargo.toml index e5834b7..d61796f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "beckon" -version = "0.3.1" +version = "0.4.0" edition = "2021" rust-version = "1.75" authors = ["Azeem Shaik "] diff --git a/README.md b/README.md index b632175..eb659c3 100644 --- a/README.md +++ b/README.md @@ -164,6 +164,9 @@ let http = reqwest::Client::builder().user_agent("my-app/1.0").build()?; let client = UserApi::with_client(url, http, Duration::from_secs(5)); ``` +A base URL with a path prefix is preserved — `https://api.example.com/v1` plus an +endpoint path `/users` requests `https://api.example.com/v1/users`. + ## Errors Every method returns `Result`. On a non-2xx response you get the diff --git a/src/expanders/method.rs b/src/expanders/method.rs index 80114cc..75302af 100644 --- a/src/expanders/method.rs +++ b/src/expanders/method.rs @@ -277,7 +277,8 @@ impl<'a> UrlExpander<'a> { quote! { let mut path = #path.to_string(); #(#replacements)* - let url = self.url.join(&path) + // Join relative to the base so a base path prefix (e.g. `/v1`) is kept. + let url = self.url.join(path.trim_start_matches('/')) .map_err(|e| #error_name::UrlConstruction(e.to_string()))?; } } @@ -285,7 +286,8 @@ impl<'a> UrlExpander<'a> { fn expand_without_path_params(&self, path: &syn::LitStr) -> TokenStream { let error_name = self.error_name; quote! { - let url = self.url.join(#path) + // Join relative to the base so a base path prefix (e.g. `/v1`) is kept. + let url = self.url.join(#path.trim_start_matches('/')) .map_err(|e| #error_name::UrlConstruction(e.to_string()))?; } } diff --git a/src/expanders/mod.rs b/src/expanders/mod.rs index a53d788..fab83ca 100644 --- a/src/expanders/mod.rs +++ b/src/expanders/mod.rs @@ -114,6 +114,13 @@ impl ApiClientExpander { client: reqwest::Client, timeout: impl Into>, ) -> Self { + let mut url = url; + // Ensure the base ends in `/` so it's treated as a directory and any + // path prefix (e.g. `/v1`) is preserved when endpoint paths are joined. + if !url.path().ends_with('/') { + let __p = format!("{}/", url.path()); + url.set_path(&__p); + } let timeout = timeout.into().unwrap_or(std::time::Duration::from_secs(5)); Self { url, client, timeout, #auth_inits } } diff --git a/tests/provider_tests.rs b/tests/provider_tests.rs index a434047..841e176 100644 --- a/tests/provider_tests.rs +++ b/tests/provider_tests.rs @@ -951,4 +951,111 @@ mod tests { assert_eq!(from_original.value, "cloned"); Ok(()) } + + // --- Base-URL path-prefix tests --- + + // A base URL with a path prefix (e.g. `/v1`) must be preserved, not clobbered by + // the endpoint path. + #[tokio::test] + async fn test_base_url_prefix_preserved() -> Result<(), Box> { + let mock_server = MockServer::start().await; + + Mock::given(method("GET")) + .and(wiremock::matchers::path("/v1/users")) + .respond_with(ResponseTemplate::new(200).set_body_json(create_success_response("v1"))) + .mount(&mock_server) + .await; + + let base = Url::from_str(&format!("{}/v1", mock_server.uri()))?; + let provider = HttpProvider::new(base, Duration::from_secs(5)); + let result = provider.get_users().await?; + + assert_eq!(result.value, "v1"); + Ok(()) + } + + // Same, when the base already carries a trailing slash. + #[tokio::test] + async fn test_base_url_prefix_trailing_slash() -> Result<(), Box> { + let mock_server = MockServer::start().await; + + Mock::given(method("GET")) + .and(wiremock::matchers::path("/v1/users")) + .respond_with(ResponseTemplate::new(200).set_body_json(create_success_response("v1"))) + .mount(&mock_server) + .await; + + let base = Url::from_str(&format!("{}/v1/", mock_server.uri()))?; + let provider = HttpProvider::new(base, Duration::from_secs(5)); + let result = provider.get_users().await?; + + assert_eq!(result.value, "v1"); + Ok(()) + } + + // The prefix must survive alongside path-param substitution. + #[tokio::test] + async fn test_base_url_prefix_with_path_params() -> Result<(), Box> { + let mock_server = MockServer::start().await; + + Mock::given(method("GET")) + .and(wiremock::matchers::path("/v1/users/123")) + .respond_with(ResponseTemplate::new(200).set_body_json(create_success_response("u123"))) + .mount(&mock_server) + .await; + + let base = Url::from_str(&format!("{}/v1", mock_server.uri()))?; + let provider = HttpProvider::new(base, Duration::from_secs(5)); + let result = provider + .get_users_by_id(&PathParams { + id: "123".to_string(), + }) + .await?; + + assert_eq!(result.value, "u123"); + Ok(()) + } + + // Prefix preserved and a `/` in a path param still stays inside its own segment. + #[tokio::test] + async fn test_base_url_prefix_preserves_param_encoding( + ) -> Result<(), Box> { + let mock_server = MockServer::start().await; + + Mock::given(method("GET")) + .and(wiremock::matchers::path_regex(r"^/v1/users/[^/]+$")) + .respond_with(ResponseTemplate::new(200).set_body_json(create_success_response("enc"))) + .mount(&mock_server) + .await; + + let base = Url::from_str(&format!("{}/v1", mock_server.uri()))?; + let provider = HttpProvider::new(base, Duration::from_secs(5)); + let result = provider + .get_users_by_id(&PathParams { + id: "1/2".to_string(), + }) + .await?; + + assert_eq!(result.value, "enc"); + Ok(()) + } + + // Regression: a base with no path prefix behaves exactly as before. + #[tokio::test] + async fn test_no_base_prefix_unchanged() -> 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("root"))) + .mount(&mock_server) + .await; + + let provider = + HttpProvider::new(Url::from_str(&mock_server.uri())?, Duration::from_secs(5)); + let result = provider.get_users().await?; + + assert_eq!(result.value, "root"); + Ok(()) + } }