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
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,26 @@ 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.3.0]

This release contains breaking changes to the constructor signature and the generated
error enum.

### Added

- **The `Http` error variant now carries the response body.** A non-2xx response keeps the
raw payload the server returned (`Http { status, reason, body }`), so the reason a request
was rejected is no longer discarded. `Display` includes the body when present.

### Changed

- **Breaking:** `new` and `with_client` now take the timeout as
`impl Into<Option<std::time::Duration>>` instead of `Option<u64>` milliseconds. Pass a
`Duration` (e.g. `Duration::from_secs(5)`) or `None` for the 5-second default. This removes
the footgun where `Some(30)` meant 30 **milliseconds**, not 30 seconds.
- **Breaking:** the generated error enum is now `#[non_exhaustive]` and the `Http` variant has
a new `body` field. Exhaustive `match`es on it (from another crate) need a `_` arm.

## [0.2.0]

### Fixed
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.2.0"
version = "0.3.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
32 changes: 27 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ cargo add tokio --features full
```rust
use beckon::beckon;
use serde::{Deserialize, Serialize};
use std::time::Duration;

#[derive(Serialize, Deserialize)]
struct User {
Expand Down Expand Up @@ -69,7 +70,7 @@ beckon!(
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let client = UserApi::new(
reqwest::Url::parse("https://api.example.com")?,
Some(5000),
Duration::from_secs(5),
);

let users = client.get_users().await?;
Expand Down Expand Up @@ -103,15 +104,15 @@ Add automatic authentication to every request. Three strategies are supported:
```rust
// Bearer token — injects `Authorization: Bearer <token>`
beckon!(GithubApi, auth: Bearer, { /* ... */ });
let client = GithubApi::new(url, "ghp_xxxx", Some(5000));
let client = GithubApi::new(url, "ghp_xxxx", Duration::from_secs(5));

// Basic auth — injects `Authorization: Basic <base64>`
beckon!(DbApi, auth: Basic, { /* ... */ });
let client = DbApi::new(url, "admin", "secret", Some(5000));
let client = DbApi::new(url, "admin", "secret", Duration::from_secs(5));

// API key — injects a custom header
beckon!(StripeApi, auth: ApiKey("X-Api-Key"), { /* ... */ });
let client = StripeApi::new(url, "sk_live_xxxx", Some(5000));
let client = StripeApi::new(url, "sk_live_xxxx", Duration::from_secs(5));
```

Omitting `auth` keeps the plain `new(url, timeout)` constructor. Auth composes with every
Expand Down Expand Up @@ -151,11 +152,31 @@ Per-endpoint `retries` overrides the global value. Omitting `retries` entirely m
- `UserApi::with_client(url, client, timeout)` — supply your own `reqwest::Client` to
share a connection pool, TLS config, proxy, or default headers.

`timeout` accepts a `std::time::Duration`, or `None` for the 5-second default.

```rust
let http = reqwest::Client::builder().user_agent("my-app/1.0").build()?;
let client = UserApi::with_client(url, http, Some(5000));
let client = UserApi::with_client(url, http, Duration::from_secs(5));
```

## Errors

Every method returns `Result<Res, UserApiError>`. On a non-2xx response you get the
`Http` variant, which carries the server's response body so you can see *why* a request
was rejected:

```rust
match client.get_users_by_id(&UserPath { id: 999 }).await {
Ok(user) => { /* ... */ }
Err(UserApiError::Http { status, reason, body }) => {
eprintln!("HTTP {status} {reason}: {body}"); // body = the server's error payload
}
Err(other) => eprintln!("{other}"),
}
```

The enum is `#[non_exhaustive]`, so matching downstream should keep a `_` arm.

## Generated Code

For a client named `UserApi`, the macro generates:
Expand All @@ -174,6 +195,7 @@ See the [`examples/`](examples/) directory:
- [`advanced.rs`](examples/advanced.rs) — all features
- [`mocking.rs`](examples/mocking.rs) — testing with the generated trait
- [`multiple_path_params.rs`](examples/multiple_path_params.rs) — nested resources
- [`error_handling.rs`](examples/error_handling.rs) — inspecting a failed request's body

## License

Expand Down
5 changes: 3 additions & 2 deletions examples/advanced.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
use beckon::beckon;
use reqwest::{header::HeaderMap, Url};
use serde::{Deserialize, Serialize};
use std::time::Duration;

// Response types
#[derive(Deserialize, Debug)]
Expand Down Expand Up @@ -141,11 +142,11 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
.build()?;

let base_url = Url::parse("https://api.example.com")?;
let client = ApiClient::with_client(base_url, http.clone(), Some(5000));
let client = ApiClient::with_client(base_url, http.clone(), Duration::from_secs(5));

// 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));
let _analytics = ApiClient::with_client(analytics_url, http, Duration::from_secs(5));

// Basic GET request
let users = client.get_users().await?;
Expand Down
7 changes: 5 additions & 2 deletions examples/basic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
use beckon::beckon;
use reqwest::Url;
use serde::Deserialize;
use std::time::Duration;

// Define your response types
#[derive(Deserialize, Debug)]
Expand Down Expand Up @@ -42,14 +43,16 @@ beckon!(
#[tokio::main]
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));
// `timeout` takes a `Duration` (or `None` for the 5s default) — no ambiguous
// millisecond integers.
let client = ApiClient::new(base_url, Duration::from_secs(5));

// 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));
// let client = ApiClient::with_client(base_url, http, Duration::from_secs(5));

// Use the auto-generated methods
let users = client.get_users().await?;
Expand Down
60 changes: 60 additions & 0 deletions examples/error_handling.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
//! Example demonstrating how to inspect a failed request.
//!
//! On any non-2xx response, the generated error's `Http` variant carries the raw
//! response body the server returned — usually a JSON error object — so you can see
//! *why* a request was rejected, not just the status code.

use beckon::beckon;
use serde::{Deserialize, Serialize};
use std::time::Duration;

#[derive(Deserialize, Debug)]
#[allow(dead_code)]
pub struct User {
id: u32,
name: String,
}

#[derive(Serialize)]
pub struct UserPath {
id: u32,
}

beckon!(
ApiClient,
{
{
path: "/users/{id}",
method: GET,
path_params: UserPath,
res: User,
},
}
);

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let client = ApiClient::new(
reqwest::Url::parse("https://api.example.com")?,
Duration::from_secs(5),
);

match client.get_users_by_id(&UserPath { id: 999 }).await {
Ok(user) => println!("got user: {user:?}"),

// The server explained the failure in the response body — now it's yours to
// read, log, or parse, instead of being discarded behind a bare status code.
Err(ApiClientError::Http {
status,
reason,
body,
}) => {
eprintln!("request failed: HTTP {status} {reason}");
eprintln!("server said: {body}"); // e.g. {"error":"user not found"}
}

Err(other) => eprintln!("transport or decode error: {other}"),
}

Ok(())
}
2 changes: 1 addition & 1 deletion examples/mocking.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {

// The same function works with the real client too:
// let base_url = reqwest::Url::parse("https://api.example.com")?;
// let real_client = ApiClient::new(base_url, Some(5000));
// let real_client = ApiClient::new(base_url, std::time::Duration::from_secs(5));
// let name = get_user_name(&real_client, 42).await?;

Ok(())
Expand Down
3 changes: 2 additions & 1 deletion examples/multiple_path_params.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
use beckon::beckon;
use reqwest::Url;
use serde::{Deserialize, Serialize};
use std::time::Duration;

#[derive(Deserialize, Debug)]
#[allow(dead_code)]
Expand Down Expand Up @@ -55,7 +56,7 @@ pub struct CommentReplyPathParams {
#[tokio::main]
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));
let client = ApiClient::new(base_url, Duration::from_secs(5));

// Use the generated method with multiple path parameters
// Function name: get_users_posts_by_user_id_and_post_id
Expand Down
3 changes: 2 additions & 1 deletion examples/params.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
use beckon::beckon;
use reqwest::Url;
use serde::{Deserialize, Serialize};
use std::time::Duration;

// Response types
#[derive(Deserialize, Debug)]
Expand Down Expand Up @@ -78,7 +79,7 @@ pub struct Post {
#[tokio::main]
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));
let client = ApiClient::new(base_url, Duration::from_secs(5));

// Use path parameters - the `{id}` in the path will be replaced with the value
let user = client.get_users_by_id(&UserPathParams { id: 42 }).await?;
Expand Down
11 changes: 9 additions & 2 deletions src/expanders/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,11 @@ impl<'a> ErrorExpander<'a> {

quote! {
#[derive(Debug)]
#[non_exhaustive]
pub enum #error_name {
UrlConstruction(String),
Request(reqwest::Error),
Http { status: u16, reason: String },
Http { status: u16, reason: String, body: String },
Deserialization(String),
}

Expand All @@ -28,7 +29,13 @@ impl<'a> ErrorExpander<'a> {
match self {
Self::UrlConstruction(msg) => write!(f, "Failed to construct URL: {}", msg),
Self::Request(err) => write!(f, "Request failed: {}", err),
Self::Http { status, reason } => write!(f, "HTTP {} {}", status, reason),
Self::Http { status, reason, body } => {
if body.is_empty() {
write!(f, "HTTP {} {}", status, reason)
} else {
write!(f, "HTTP {} {}: {}", status, reason, body)
}
}
Self::Deserialization(msg) => write!(f, "Failed to deserialize: {}", msg),
}
}
Expand Down
4 changes: 4 additions & 0 deletions src/expanders/method.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,9 +90,11 @@ impl<'a> MethodExpander<'a> {
.canonical_reason()
.unwrap_or("Unknown")
.to_string();
let body = response.text().await.unwrap_or_default();
return Err(#error_name::Http {
status: status.as_u16(),
reason,
body,
});
}
break #deserialize;
Expand All @@ -111,9 +113,11 @@ impl<'a> MethodExpander<'a> {
.canonical_reason()
.unwrap_or("Unknown")
.to_string();
let body = response.text().await.unwrap_or_default();
return Err(#error_name::Http {
status: status.as_u16(),
reason,
body,
});
}
#deserialize
Expand Down
13 changes: 10 additions & 3 deletions src/expanders/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -95,19 +95,26 @@ impl ApiClientExpander {
}

impl #struct_name {
pub fn new(url: reqwest::Url, #auth_params timeout: Option<u64>) -> Self {
/// `timeout` accepts a `std::time::Duration`, or `None` for the 5s default.
pub fn new(
url: reqwest::Url,
#auth_params
timeout: impl Into<Option<std::time::Duration>>,
) -> Self {
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.
///
/// `timeout` accepts a `std::time::Duration`, or `None` for the 5s default.
pub fn with_client(
url: reqwest::Url,
#auth_params
client: reqwest::Client,
timeout: Option<u64>,
timeout: impl Into<Option<std::time::Duration>>,
) -> Self {
let timeout = std::time::Duration::from_millis(timeout.unwrap_or(5000));
let timeout = timeout.into().unwrap_or(std::time::Duration::from_secs(5));
Self { url, client, timeout, #auth_inits }
}

Expand Down
3 changes: 2 additions & 1 deletion src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
//! ```
//! use beckon::beckon;
//! use serde::{Deserialize, Serialize};
//! use std::time::Duration;
//!
//! #[derive(Serialize, Deserialize)]
//! pub struct User {
Expand Down Expand Up @@ -37,7 +38,7 @@
//! );
//!
//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
//! let client = UserApi::new(reqwest::Url::parse("https://api.example.com")?, Some(5000));
//! let client = UserApi::new(reqwest::Url::parse("https://api.example.com")?, Duration::from_secs(5));
//! let _users = client.get_users().await?;
//! let _user = client.get_users_by_id(&UserPath { id: 1 }).await?;
//! # Ok(())
Expand Down
Loading
Loading