@@ -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