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
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
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.1.1"
version = "0.2.0"
edition = "2021"
authors = ["Azeem Shaik <azeemshaik025@gmail.com>"]
description = "Generate type-safe, async HTTP clients from endpoint definitions — a Rust proc macro."
Expand Down
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 14 additions & 1 deletion examples/advanced.rs
Original file line number Diff line number Diff line change
Expand Up @@ -131,8 +131,21 @@ beckon!(

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// 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?;
Expand Down
7 changes: 7 additions & 0 deletions examples/basic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,13 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
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());
Expand Down
5 changes: 4 additions & 1 deletion src/expanders/method.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
41 changes: 38 additions & 3 deletions src/expanders/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -92,10 +96,41 @@ impl ApiClientExpander {

impl #struct_name {
pub fn new(url: reqwest::Url, #auth_params timeout: Option<u64>) -> 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<u64>,
) -> 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 {
Expand Down
2 changes: 1 addition & 1 deletion src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
//! );
//!
//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
//! 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(())
Expand Down
100 changes: 100 additions & 0 deletions tests/provider_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<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("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(())
}
}
Loading