Skip to content

Commit d198041

Browse files
feat(server): combined build_router for multi-tag, required-param 400
Two final P5 polish items. 1. Multi-tag combined router. When the picked ops span 2+ tags, the generator now emits a top-level `build_router<T1, T2, ...>(api1: T1, api2: T2, ...) -> Router` alongside the existing per-tag factories. Each `Ti` is bound to the matching trait; the body folds the per-tag factories via `.merge(...)`. Tag iteration order is alphabetical (BTreeMap), so generic ordering is deterministic. Single-tag selections still call the per-tag factory directly — no extra noise for the common case. 2. Required-param enforcement at the HTTP boundary. Query and header parameters marked `required: true` in the spec now appear on the trait method as `T` (not `Option<T>`); the generated axum handler short-circuits with HTTP 400 and a `{"error": "missing required ..."}` JSON body before invoking the trait. Handler return type changed from `<Op>Response` to `axum::response::Response` so both the early-400 and the typed response enum (via IntoResponse) flow through the same return signature. Side fix: param-enum emitter now falls back to a positional name when PascalCase yields an identifier starting with a digit (e.g. `bucket_width=1d` → `Variant0`), which the spec surfaced via `usage-costs`. OpenAI example now picks `usage-costs` (different tag, required `start_time` query) in addition to `createResponse` and `listInputItems`. AppState now impls both `ResponsesApi` and `UsageApi`; main wires them through `build_router(state, state)`. End-to-end verified at the HTTP layer: - `GET /organization/costs` → 400 + error JSON - `GET /organization/costs?start_time=1700000000` → 200 All 4 example unit tests pass; 318 main suite tests green; clippy -D warnings clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent bec0601 commit d198041

3 files changed

Lines changed: 214 additions & 38 deletions

File tree

examples/server-openai-responses/openapi-to-rust.toml

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ enable_async_client = false
88

99
[server]
1010
framework = "axum"
11-
# createResponse exercises the body + SSE path; listInputItems
12-
# exercises path params + four query params (one of them an enum).
13-
operations = ["createResponse", "listInputItems"]
11+
# createResponse — body + SSE path
12+
# listInputItems — path + four optional query params (one is an enum)
13+
# usage-costs — required query param (start_time) + lives on a
14+
# different tag, so it also exercises the multi-tag
15+
# combined `build_router` factory.
16+
operations = ["createResponse", "listInputItems", "usage-costs"]

examples/server-openai-responses/src/main.rs

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,8 @@ use axum::response::sse::Event;
2323
use futures_util::stream;
2424
use gen::CreateResponse;
2525
use gen::server::{
26-
CreateResponseResponse, ListInputItemsResponse, ResponsesApi, responses_api_router,
27-
sse_response,
26+
CreateResponseResponse, ListInputItemsResponse, ResponsesApi, UsageApi, UsageCostsResponse,
27+
build_router, sse_response,
2828
};
2929
use std::convert::Infallible;
3030

@@ -111,9 +111,37 @@ fn sse_event(name: &str, data: &str) -> Result<Event, Infallible> {
111111
Ok(Event::default().event(name).data(data))
112112
}
113113

114+
#[axum::async_trait]
115+
impl UsageApi for AppState {
116+
/// `start_time` is `i64` (not `Option`) — the generated handler
117+
/// short-circuits with 400 if the client omits it, so by the
118+
/// time we get here the value is guaranteed present.
119+
async fn usage_costs(
120+
&self,
121+
start_time: i64,
122+
_end_time: Option<i64>,
123+
_bucket_width: Option<gen::UsageCostsBucketWidth>,
124+
_project_ids: Option<String>,
125+
_group_by: Option<String>,
126+
_limit: Option<i64>,
127+
_page: Option<String>,
128+
) -> UsageCostsResponse {
129+
let body: gen::UsageResponse = serde_json::from_value(serde_json::json!({
130+
"object": "page",
131+
"data": [],
132+
"has_more": false,
133+
"next_page": "",
134+
}))
135+
.unwrap_or_else(|e| panic!("UsageResponse must deserialize: {e}; start_time={start_time}"));
136+
UsageCostsResponse::Ok(body)
137+
}
138+
}
139+
114140
#[tokio::main]
115141
async fn main() {
116-
let app = responses_api_router(AppState);
142+
// Single combined router that takes both trait impls. Each tag's
143+
// routes get mounted on the same axum::Router via `.merge()`.
144+
let app = build_router(AppState, AppState);
117145
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
118146
println!("listening on http://{}", listener.local_addr().unwrap());
119147
axum::serve(listener, app).await.unwrap();
@@ -155,4 +183,14 @@ mod tests {
155183
.await;
156184
assert!(matches!(r, ListInputItemsResponse::Ok(_)));
157185
}
186+
187+
#[tokio::test]
188+
async fn usage_costs_required_param_arrives_as_unwrapped() {
189+
// start_time is `i64` not `Option<i64>` — the generated
190+
// handler ensures it's always present.
191+
let r = AppState
192+
.usage_costs(1_700_000_000, None, None, None, None, None, None)
193+
.await;
194+
assert!(matches!(r, UsageCostsResponse::Ok(_)));
195+
}
158196
}

src/server/codegen.rs

Lines changed: 167 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -128,8 +128,6 @@ impl<'a> ServerCodegen<'a> {
128128
}
129129

130130
fn emit_router(&self, groups: &BTreeMap<String, Vec<&OperationInfo>>) -> TokenStream {
131-
// For now we emit one Router factory per tag. Multi-tag specs
132-
// get one factory each; users `.merge()` them at the call site.
133131
let factories: Vec<TokenStream> = groups
134132
.iter()
135133
.map(|(tag, ops)| self.emit_router_for_trait(tag, ops))
@@ -142,6 +140,17 @@ impl<'a> ServerCodegen<'a> {
142140
.filter_map(|op| self.emit_query_struct(op))
143141
.collect();
144142

143+
// When the picked operations span multiple tags, emit a
144+
// top-level `build_router(impl1, impl2, ...)` that takes one
145+
// generic per trait and `.merge()`s the per-tag factories.
146+
// For a single-tag selection this is unnecessary noise — the
147+
// user calls the per-tag factory directly.
148+
let combined = if groups.len() > 1 {
149+
Some(self.emit_combined_router(groups))
150+
} else {
151+
None
152+
};
153+
145154
quote! {
146155
//! Router factories — one per trait. Each takes any
147156
//! `T: <TraitName> + Clone + Send + Sync + 'static` and
@@ -159,6 +168,71 @@ impl<'a> ServerCodegen<'a> {
159168
#(#query_structs)*
160169

161170
#(#factories)*
171+
172+
#combined
173+
}
174+
}
175+
176+
fn emit_combined_router(&self, groups: &BTreeMap<String, Vec<&OperationInfo>>) -> TokenStream {
177+
// Stable ordering: BTreeMap iteration is already alphabetical
178+
// by tag, which gives us deterministic generic ordering across
179+
// generator runs.
180+
let entries: Vec<(syn::Ident, syn::Ident, syn::Ident)> = groups
181+
.keys()
182+
.enumerate()
183+
.map(|(i, tag)| {
184+
let trait_ident = trait_ident_for_tag(tag);
185+
let factory = format_ident!("{}_router", trait_ident.to_string().to_snake_case());
186+
let generic = format_ident!("T{}", i + 1);
187+
(trait_ident, factory, generic)
188+
})
189+
.collect();
190+
191+
let generics: Vec<&syn::Ident> = entries.iter().map(|(_, _, g)| g).collect();
192+
let args: Vec<TokenStream> = entries
193+
.iter()
194+
.map(|(trait_ident, _, g)| {
195+
let arg_ident = format_ident!("{}", trait_ident.to_string().to_snake_case());
196+
quote! { #arg_ident: #g }
197+
})
198+
.collect();
199+
let bounds: Vec<TokenStream> = entries
200+
.iter()
201+
.map(|(trait_ident, _, g)| {
202+
quote! { #g: #trait_ident + Clone + Send + Sync + 'static }
203+
})
204+
.collect();
205+
206+
// Fold the factories: `factory1(arg1).merge(factory2(arg2)).merge(...)`.
207+
let first = &entries[0];
208+
let first_arg = format_ident!("{}", first.0.to_string().to_snake_case());
209+
let first_factory = &first.1;
210+
let rest = entries
211+
.iter()
212+
.skip(1)
213+
.map(|(trait_ident, factory, _)| {
214+
let arg = format_ident!("{}", trait_ident.to_string().to_snake_case());
215+
quote! { .merge(#factory(#arg)) }
216+
})
217+
.collect::<Vec<_>>();
218+
219+
let trait_names: Vec<String> = entries.iter().map(|(t, _, _)| t.to_string()).collect();
220+
let doc = format!(
221+
" Combined router spanning {} traits: {}.",
222+
entries.len(),
223+
trait_names.join(", "),
224+
);
225+
226+
quote! {
227+
#[doc = #doc]
228+
pub fn build_router<#(#generics),*>(
229+
#(#args),*
230+
) -> ::axum::Router
231+
where
232+
#(#bounds),*
233+
{
234+
#first_factory(#first_arg) #(#rest)*
235+
}
162236
}
163237
}
164238

@@ -235,42 +309,87 @@ impl<'a> ServerCodegen<'a> {
235309
}
236310

237311
// Query parameters — extract via a per-op `<Op>Query` struct
238-
// (emitted in the same router.rs above). Each query param
239-
// appears in the trait method as `Option<T>`.
312+
// (emitted in the same router.rs above). Required params are
313+
// unwrapped here (short-circuit 400 if missing) so the trait
314+
// method sees a `T` rather than `Option<T>`.
240315
let query_params: Vec<&_> = op
241316
.parameters
242317
.iter()
243318
.filter(|p| p.location == "query")
244319
.collect();
320+
let mut required_query_checks: Vec<TokenStream> = Vec::new();
245321
if !query_params.is_empty() {
246322
let query_ident = format_ident!("{}Query", op.operation_id.to_pascal_case());
247323
extractors.push(quote! {
248324
::axum::extract::Query(__q): ::axum::extract::Query<#query_ident>
249325
});
250326
for p in &query_params {
251327
let f = format_ident!("{}", p.name.to_snake_case());
252-
call_args.push(quote! { __q.#f });
328+
let wire = p.name.as_str();
329+
if p.required {
330+
let missing_msg = format!("missing required query parameter `{wire}`");
331+
required_query_checks.push(quote! {
332+
let #f = match __q.#f {
333+
Some(v) => v,
334+
None => return ::axum::response::IntoResponse::into_response(
335+
(
336+
::axum::http::StatusCode::BAD_REQUEST,
337+
::axum::Json(::serde_json::json!({
338+
"error": #missing_msg
339+
})),
340+
)
341+
),
342+
};
343+
});
344+
call_args.push(quote! { #f });
345+
} else {
346+
call_args.push(quote! { __q.#f });
347+
}
253348
}
254349
}
255350

256351
// Header parameters — extract via HeaderMap and read each
257-
// header by name. Surface as Option<String> regardless of
258-
// declared type (typed conversions can be re-added later).
352+
// header by name. Required headers short-circuit with 400 if
353+
// missing or non-UTF-8.
259354
let header_params: Vec<&_> = op
260355
.parameters
261356
.iter()
262357
.filter(|p| p.location == "header")
263358
.collect();
359+
let mut required_header_checks: Vec<TokenStream> = Vec::new();
264360
if !header_params.is_empty() {
265361
extractors.push(quote! { __headers: ::axum::http::HeaderMap });
266362
for p in &header_params {
267363
let wire = p.name.as_str();
268-
call_args.push(quote! {
269-
__headers
270-
.get(#wire)
271-
.and_then(|v| v.to_str().ok())
272-
.map(::std::string::String::from)
273-
});
364+
let ident = format_ident!("{}", header_param_ident(&p.name));
365+
if p.required {
366+
let missing_msg = format!("missing required header `{wire}`");
367+
required_header_checks.push(quote! {
368+
let #ident = match __headers
369+
.get(#wire)
370+
.and_then(|v| v.to_str().ok())
371+
.map(::std::string::String::from)
372+
{
373+
Some(v) => v,
374+
None => return ::axum::response::IntoResponse::into_response(
375+
(
376+
::axum::http::StatusCode::BAD_REQUEST,
377+
::axum::Json(::serde_json::json!({
378+
"error": #missing_msg
379+
})),
380+
)
381+
),
382+
};
383+
});
384+
call_args.push(quote! { #ident });
385+
} else {
386+
call_args.push(quote! {
387+
__headers
388+
.get(#wire)
389+
.and_then(|v| v.to_str().ok())
390+
.map(::std::string::String::from)
391+
});
392+
}
274393
}
275394
}
276395

@@ -291,21 +410,28 @@ impl<'a> ServerCodegen<'a> {
291410
}
292411
}
293412

294-
let response_ty = format_ident!("{}Response", op.operation_id.to_pascal_case());
295-
413+
let _ = format_ident!("{}Response", op.operation_id.to_pascal_case());
296414
// Keep referencing trait_ident so the where-bound name is
297415
// visible to downstream readers — clippy would otherwise flag
298416
// it as unused in some configurations.
299417
let _ = trait_ident;
300418

419+
// Handler returns `axum::response::Response` so the required-
420+
// param short-circuit (400 BadRequest) and the trait method's
421+
// typed response enum (via IntoResponse) can both flow out
422+
// through the same return type.
301423
quote! {
302424
async fn #handler_ident<T>(
303425
#(#extractors),*
304-
) -> #response_ty
426+
) -> ::axum::response::Response
305427
where
306428
T: super::api::#trait_ident + Clone + Send + Sync + 'static,
307429
{
308-
api.#trait_method(#(#call_args),*).await
430+
#(#required_query_checks)*
431+
#(#required_header_checks)*
432+
::axum::response::IntoResponse::into_response(
433+
api.#trait_method(#(#call_args),*).await,
434+
)
309435
}
310436
}
311437
}
@@ -387,22 +513,25 @@ impl<'a> ServerCodegen<'a> {
387513
if p.location == "query" {
388514
let ident = format_ident!("{}", p.name.to_snake_case());
389515
let ty = parse_type(&p.rust_type);
390-
// All query params are Option<T> on the trait — the
391-
// typed Query struct generated alongside the trait
392-
// populates None for absent keys regardless of the
393-
// spec's `required: true`. Validation moves to the
394-
// user's impl (return BadRequest if missing).
395-
params.push(quote! { #ident: ::std::option::Option<#ty> });
516+
// Required query params land as `T`; the handler
517+
// validates presence and returns 400 if absent, so
518+
// by the time the trait method sees the value it
519+
// must be Some. Optional → `Option<T>`.
520+
if p.required {
521+
params.push(quote! { #ident: #ty });
522+
} else {
523+
params.push(quote! { #ident: ::std::option::Option<#ty> });
524+
}
396525
}
397526
}
398527
for p in &op.parameters {
399528
if p.location == "header" {
400529
let ident = format_ident!("{}", header_param_ident(&p.name));
401-
// Headers are surfaced as Option<String>; required
402-
// headers are still Option here for the same reason
403-
// as query params (deserialization vs. trait
404-
// signature stability).
405-
params.push(quote! { #ident: ::std::option::Option<String> });
530+
if p.required {
531+
params.push(quote! { #ident: String });
532+
} else {
533+
params.push(quote! { #ident: ::std::option::Option<String> });
534+
}
406535
}
407536
}
408537
if let Some(body) = body_type(op) {
@@ -629,10 +758,16 @@ fn emit_param_enum(name: &str, values: &[String]) -> TokenStream {
629758
.enumerate()
630759
.map(|(i, raw)| {
631760
let pascal = raw.to_pascal_case();
632-
// Empty-string or pure-symbol values can collapse to ""
633-
// after PascalCase; backstop with a positional name so
634-
// the enum still compiles.
635-
let v_name = if pascal.is_empty() {
761+
// PascalCase can produce an empty string (pure-symbol
762+
// input) or an identifier starting with a digit
763+
// (e.g. `1d` stays `1d`) — both invalid as Rust idents.
764+
// Fall back to a positional name so the enum compiles.
765+
let starts_with_digit = pascal
766+
.chars()
767+
.next()
768+
.map(|c| c.is_ascii_digit())
769+
.unwrap_or(true);
770+
let v_name = if pascal.is_empty() || starts_with_digit {
636771
format!("Variant{i}")
637772
} else {
638773
pascal

0 commit comments

Comments
 (0)