Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "beckon"
version = "0.3.1"
version = "0.4.0"
edition = "2021"
rust-version = "1.75"
authors = ["Azeem Shaik <azeemshaik025@gmail.com>"]
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<Res, UserApiError>`. On a non-2xx response you get the
Expand Down
6 changes: 4 additions & 2 deletions src/expanders/method.rs
Original file line number Diff line number Diff line change
Expand Up @@ -277,15 +277,17 @@ 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()))?;
}
}

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()))?;
}
}
Expand Down
7 changes: 7 additions & 0 deletions src/expanders/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,13 @@ impl ApiClientExpander {
client: reqwest::Client,
timeout: impl Into<Option<std::time::Duration>>,
) -> 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 }
}
Expand Down
107 changes: 107 additions & 0 deletions tests/provider_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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(())
}
}
Loading