Skip to content

Commit 48ec56b

Browse files
authored
feat: blocking reqwest client generator (#20)
1 parent 9c50969 commit 48ec56b

16 files changed

Lines changed: 2088 additions & 10 deletions

Cargo.lock

Lines changed: 391 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

README.md

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@ package: apimodel # informational
2323
output: models.rs # output path (overridden by -o)
2424
generate:
2525
models: true
26-
std-http-server: true # also emit an axum server interface
26+
std-http-server: true # emit an axum server interface, or…
27+
client: true # …a blocking reqwest client (server wins if both are set)
2728
```
2829
2930
`import-mapping` maps a referenced spec file to the Rust module its schemas are
@@ -215,6 +216,62 @@ faithfully rather than emit subtly wrong code.
215216
the same operation (multipart needs its own extractor and cannot join the
216217
`Content-Type` dispatch).
217218

219+
## Client generation
220+
221+
Setting `generate.client` emits a blocking [`reqwest`] client alongside the
222+
models. Like the server, the output is typed-only and driven by the same
223+
internal representation, but it depends solely on `reqwest`, `serde`, and the
224+
generated models — never `axum`:
225+
226+
- a `struct Client` holding a `base_url` and a `reqwest::blocking::Client`, with
227+
`Client::new(base_url)` (builds a default HTTP client) and
228+
`Client::with_client(base_url, http)` (accepts a preconfigured client, e.g.
229+
with timeouts);
230+
- one method per operation returning `Result<<Op>Response, ClientError>`, whose
231+
arguments are the path parameters, a generated query-parameter struct, a
232+
generated header-parameter struct, a generated cookie-parameter struct, and the
233+
request body (each present only when the operation declares it);
234+
- a response `enum` per operation with one variant per documented status code
235+
(mirroring the server's shape but carrying `reqwest::StatusCode` for
236+
`default`/range variants), plus a `ClientError` enum (`Http(reqwest::Error)`
237+
for transport/decoding failures, `UnexpectedStatus(reqwest::StatusCode)` for a
238+
status the operation does not declare).
239+
240+
Because the client cannot assume the server honoured the contract, response
241+
header fields are always `Option<T>` and parsed best-effort, even for headers the
242+
spec marks required.
243+
244+
A client crate needs `reqwest = { version = "0.12", features = ["blocking",
245+
"json"] }`. The `json` feature is required when an operation sends a JSON request
246+
body and/or decodes a JSON response body.
247+
248+
**Supported**
249+
250+
- **Path, query, header, and cookie parameters** — the same scalar/array rules as
251+
the server. Query scalars and arrays (repeated keys, `style: form`,
252+
`explode: true`) are appended per field; headers and cookies are set from the
253+
generated input structs.
254+
- **Request bodies** — JSON (via `reqwest`'s `.json()`), form
255+
(`application/x-www-form-urlencoded`, via `.form()`), and `text/plain` (a raw
256+
string body with an explicit `Content-Type`).
257+
- **Responses** — fixed status codes, `default`, and ranges (`5XX`), decoding a
258+
JSON or `text/plain` body into the matching enum variant, along with declared
259+
response headers.
260+
261+
**Rejected / deferred** (an error, never mis-generated)
262+
263+
- A `multipart/form-data` request body (no multipart client encoder yet).
264+
- A request body that declares two or more content types (negotiated request
265+
bodies are server-only for now).
266+
- A response that declares two or more content types (negotiated responses are
267+
server-only for now).
268+
- A form (`application/x-www-form-urlencoded`) _response_ body.
269+
270+
Enabling both `std-http-server` and `client` emits the server (the client is a
271+
follow-up once multipart and negotiated bodies are supported on the client side).
272+
Path parameters are substituted verbatim without percent-encoding, which is safe
273+
for the scalar values the generator accepts.
274+
218275
## Coverage
219276

220277
Every OpenAPI 3 schema element is deliberately catalogued — supported, ignored,
@@ -239,3 +296,4 @@ the unknown.
239296
[`quote`]: https://crates.io/crates/quote
240297
[`syn`]: https://crates.io/crates/syn
241298
[`prettyplease`]: https://crates.io/crates/prettyplease
299+
[`reqwest`]: https://crates.io/crates/reqwest

crates/oapi-codegen/Cargo.toml

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,14 @@ syn = { version = "2.0.118", features = ["full"] }
2626

2727
[dev-dependencies]
2828
# chrono/uuid only type-check the generated model outputs (which reference
29-
# them); axum type-checks the generated server output; openapiv3 backs the
30-
# coverage-matrix anchor in tests/coverage.rs. None are needed by the generator
31-
# itself.
29+
# them); axum type-checks the generated server output; reqwest type-checks the
30+
# generated client output; openapiv3 backs the coverage-matrix anchor in
31+
# tests/coverage.rs. None are needed by the generator itself.
3232
axum = { version = "0.8.9", features = ["multipart"] }
3333
axum-extra = { version = "0.12.6", features = ["query", "cookie"] }
3434
chrono = { version = "0.4.45", features = ["serde"] }
3535
openapiv3 = "2.2.0"
36+
reqwest = { version = "0.12.28", default-features = false, features = ["blocking", "json"] }
3637
serde = { version = "1.0.228", features = ["derive"] }
3738
serde_json = "1.0.150"
3839
uuid = { version = "1.23.4", features = ["serde"] }

crates/oapi-codegen/src/config.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@ pub struct Generate {
4141
/// Generate an axum server interface from the spec's paths.
4242
#[serde(default)]
4343
pub std_http_server: bool,
44+
/// Generate a blocking `reqwest` client from the spec's paths.
45+
#[serde(default)]
46+
pub client: bool,
4447
/// Embed the spec into the generated code (not yet implemented).
4548
#[serde(default)]
4649
pub embedded_spec: bool,

crates/oapi-codegen/src/emit/mod.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
77
mod axum;
88
mod models;
9+
mod reqwest;
910

1011
use proc_macro2::TokenStream;
1112
use quote::quote;
@@ -26,6 +27,16 @@ pub trait ServerEmitter {
2627
fn emit(&self, service: &Service) -> Result<Vec<TokenStream>>;
2728
}
2829

30+
/// Emits a client for a lowered [`Service`] as top-level token items.
31+
///
32+
/// One implementation per target HTTP library; `ReqwestClient` is the only one
33+
/// today.
34+
pub trait ClientEmitter {
35+
/// Emit the client items (error type, `Client` struct, per-operation methods,
36+
/// and the response types they return).
37+
fn emit(&self, service: &Service) -> Result<Vec<TokenStream>>;
38+
}
39+
2940
/// Header prepended to every generated file.
3041
const HEADER: &str = "// Code generated by oapi-codegen-rust. DO NOT EDIT.\n\n";
3142

@@ -46,6 +57,13 @@ pub fn emit_with_service(module: &Module, service: &Service) -> Result<String> {
4657
return render(&items);
4758
}
4859

60+
/// Render a module's models followed by the blocking `reqwest` client.
61+
pub fn emit_with_client(module: &Module, service: &Service) -> Result<String> {
62+
let mut items = module_items(module)?;
63+
items.extend(reqwest::ReqwestClient.emit(service)?);
64+
return render(&items);
65+
}
66+
4967
/// Lower every IR item in a module into its token stream.
5068
fn module_items(module: &Module) -> Result<Vec<TokenStream>> {
5169
let mut items = Vec::with_capacity(module.items.len());

0 commit comments

Comments
 (0)