@@ -550,6 +550,15 @@ pub enum ArrayItemType {
550550 Scalar ( String ) ,
551551 /// The schema name of a referenced scalar alias or string enum.
552552 SchemaRef ( String ) ,
553+ /// The schema name of a referenced *flat* structure — every property is
554+ /// scalar. Serialized AWS query-protocol style as
555+ /// `param.N.Prop=value` per item (e.g. `Tags.1.Key=k&Tags.1.Value=v`).
556+ /// Carries the wire property names so client and server emit identical
557+ /// keys without re-resolving the schema.
558+ FlatStructRef {
559+ schema_name : String ,
560+ property_names : Vec < String > ,
561+ } ,
553562}
554563
555564impl Default for DependencyGraph {
@@ -705,6 +714,63 @@ pub fn merge_schema_extensions(
705714 Ok ( result)
706715}
707716
717+ /// AWS-style specs append query markers to their path templates
718+ /// (`/tags/{resourceArn}#tagKeys`, `/2015-02-01/resource-tags/{ResourceId}#tagKeys`).
719+ /// The fragment is not part of the route — those values are declared as
720+ /// ordinary query parameters on the operation — so strip it before the path
721+ /// reaches route generation. Axum (and every HTTP router) matches on the path
722+ /// component only.
723+ fn normalize_operation_path ( path : & str ) -> String {
724+ match path. split_once ( '#' ) {
725+ Some ( ( route, _fragment) ) if route. starts_with ( '/' ) => route. to_string ( ) ,
726+ _ => path. to_string ( ) ,
727+ }
728+ }
729+
730+ /// See through an `allOf: [$ref, {annotation}]` wrapper around a schema, the
731+ /// same shape `analyze_all_of` treats as a type alias. Returns the sole
732+ /// reference target's schema when every other member is annotation-only;
733+ /// otherwise the schema itself.
734+ fn unwrap_annotation_allof ( schema : & crate :: openapi:: Schema ) -> & crate :: openapi:: Schema {
735+ let crate :: openapi:: Schema :: AllOf { all_of, .. } = schema else {
736+ return schema;
737+ } ;
738+ let mut references = all_of. iter ( ) . filter ( |s| s. reference ( ) . is_some ( ) ) ;
739+ let ( Some ( first) , None ) = ( references. next ( ) , references. next ( ) ) else {
740+ return schema;
741+ } ;
742+ let others_annotation_only = all_of. iter ( ) . all ( |member| {
743+ if member. reference ( ) . is_some ( ) {
744+ return true ;
745+ }
746+ serde_json:: to_value ( member)
747+ . ok ( )
748+ . and_then ( |value| value. as_object ( ) . cloned ( ) )
749+ . is_some_and ( |object| {
750+ object. keys ( ) . all ( |key| {
751+ matches ! (
752+ key. as_str( ) ,
753+ "title"
754+ | "description"
755+ | "deprecated"
756+ | "readOnly"
757+ | "writeOnly"
758+ | "examples"
759+ | "example"
760+ | "externalDocs"
761+ | "xml"
762+ | "$comment"
763+ ) || key. starts_with ( "x-" )
764+ } )
765+ } )
766+ } ) ;
767+ if others_annotation_only {
768+ first
769+ } else {
770+ schema
771+ }
772+ }
773+
708774/// Load an extension file and parse it into the JSON representation used by
709775/// the analyzer. YAML extensions follow the same conversion policy as YAML
710776/// OpenAPI documents; every other extension is parsed as JSON.
@@ -2395,17 +2461,46 @@ impl SchemaAnalyzer {
23952461 all_of_schemas : & [ Schema ] ,
23962462 dependencies : & mut HashSet < String > ,
23972463 ) -> Result < SchemaType > {
2398- // Special case: if allOf contains only a single reference, treat it as a direct type alias
2399- // This handles patterns like: "allOf": [{"$ref": "#/components/schemas/Usage"}]
2400- if all_of_schemas. len ( ) == 1 {
2401- if let Schema :: Reference { reference, .. } = & all_of_schemas[ 0 ] {
2402- if let Some ( target) = self . extract_schema_name ( reference) {
2403- dependencies. insert ( target. to_string ( ) ) ;
2404- return Ok ( SchemaType :: Reference {
2405- target : target. to_string ( ) ,
2406- } ) ;
2407- }
2464+ // A reference plus annotation-only siblings is still a direct type
2465+ // alias. AWS-style specs frequently encode property descriptions as
2466+ // `allOf: [$ref, { description: ... }]`; recursively expanding a
2467+ // self-reference in that shape can otherwise recurse forever.
2468+ let referenced_targets = all_of_schemas
2469+ . iter ( )
2470+ . filter_map ( |schema| schema. reference ( ) )
2471+ . filter_map ( |reference| self . extract_schema_name ( reference) )
2472+ . collect :: < Vec < _ > > ( ) ;
2473+ let only_reference_and_annotations = all_of_schemas. iter ( ) . all ( |schema| {
2474+ if schema. reference ( ) . is_some ( ) {
2475+ return true ;
24082476 }
2477+ serde_json:: to_value ( schema)
2478+ . ok ( )
2479+ . and_then ( |value| value. as_object ( ) . cloned ( ) )
2480+ . is_some_and ( |object| {
2481+ object. keys ( ) . all ( |key| {
2482+ matches ! (
2483+ key. as_str( ) ,
2484+ "title"
2485+ | "description"
2486+ | "deprecated"
2487+ | "readOnly"
2488+ | "writeOnly"
2489+ | "examples"
2490+ | "example"
2491+ | "externalDocs"
2492+ | "xml"
2493+ | "$comment"
2494+ ) || key. starts_with ( "x-" )
2495+ } )
2496+ } )
2497+ } ) ;
2498+ if referenced_targets. len ( ) == 1 && only_reference_and_annotations {
2499+ let target = referenced_targets[ 0 ] ;
2500+ dependencies. insert ( target. to_string ( ) ) ;
2501+ return Ok ( SchemaType :: Reference {
2502+ target : target. to_string ( ) ,
2503+ } ) ;
24092504 }
24102505
24112506 // AllOf represents schema composition - merge all schemas into one
@@ -4335,7 +4430,7 @@ impl SchemaAnalyzer {
43354430 // dispatcher.
43364431 if let Some ( webhooks) = & spec. webhooks {
43374432 for ( name, path_item) in webhooks {
4338- let synthetic_path = format ! ( "__webhook__/{name}" ) ;
4433+ let synthetic_path = format ! ( "/ __webhook__/{name}" ) ;
43394434 self . ingest_path_item_operations (
43404435 & synthetic_path,
43414436 path_item,
@@ -4514,7 +4609,7 @@ impl SchemaAnalyzer {
45144609 let mut op_info = OperationInfo {
45154610 operation_id : operation_id. to_string ( ) ,
45164611 method : method. to_uppercase ( ) ,
4517- path : path . to_string ( ) ,
4612+ path : normalize_operation_path ( path ) ,
45184613 summary : operation. summary . clone ( ) ,
45194614 description : operation. description . clone ( ) ,
45204615 request_body : None ,
@@ -4596,7 +4691,11 @@ impl SchemaAnalyzer {
45964691 media_type : content_type. to_string ( ) ,
45974692 } )
45984693 }
4599- } else if media_type_essence ( content_type) . eq_ignore_ascii_case ( "text/plain" ) {
4694+ } else if crate :: openapi:: is_text_media_type ( content_type) {
4695+ // Any character-data media type (text/plain, text/xml,
4696+ // application/xml, +xml suffixed) is buffered and handed
4697+ // to the handler as a lossless UTF-8 String; the server
4698+ // never parses the payload.
46004699 Some ( RequestBodyContent :: TextPlain {
46014700 media_type : content_type. to_string ( ) ,
46024701 } )
@@ -5384,12 +5483,18 @@ impl SchemaAnalyzer {
53845483 /// is wired for scalar params only.
53855484 fn array_param_item_type ( & self , schema : & crate :: openapi:: Schema ) -> Option < ArrayItemType > {
53865485 let items = schema. details ( ) . items . as_deref ( ) ?;
5387- if let Some ( ref_str) = items. reference ( ) {
5486+ // AWS query-protocol specs wrap item refs in an annotation-only allOf
5487+ // (`items: {allOf: [$ref, {xml: ...}]}`). See through the wrapper when
5488+ // every sibling is annotation-only, mirroring the type-alias rule.
5489+ let unwrapped = unwrap_annotation_allof ( items) ;
5490+ if let Some ( ref_str) = unwrapped. reference ( ) {
53885491 let name = self . extract_schema_name ( ref_str) ?;
5389- return self . referenced_array_scalar_item_type ( name) ;
5492+ return self
5493+ . referenced_array_scalar_item_type ( name)
5494+ . or_else ( || self . referenced_array_flat_struct_item_type ( name) ) ;
53905495 }
5391- let format = items . details ( ) . format . clone ( ) ;
5392- let scalar = match items . schema_type ( ) ? {
5496+ let format = unwrapped . details ( ) . format . clone ( ) ;
5497+ let scalar = match unwrapped . schema_type ( ) ? {
53935498 crate :: openapi:: SchemaType :: String => "String" . to_string ( ) ,
53945499 crate :: openapi:: SchemaType :: Integer => {
53955500 self . type_mapper . integer_format ( format. as_deref ( ) ) . rust_type
@@ -5418,11 +5523,41 @@ impl SchemaAnalyzer {
54185523 SchemaType :: Primitive { rust_type, .. } => {
54195524 Some ( ArrayItemType :: Scalar ( rust_type. clone ( ) ) )
54205525 }
5421- SchemaType :: Reference { target } => self . referenced_array_scalar_item_type ( target) ,
5526+ SchemaType :: Reference { target } => self
5527+ . referenced_array_scalar_item_type ( target)
5528+ . or_else ( || self . referenced_array_flat_struct_item_type ( target) ) ,
54225529 _ => None ,
54235530 }
54245531 }
54255532
5533+ /// Accept a referenced structure as a form-style array item when every
5534+ /// property is scalar (AWS query-protocol flat structures such as
5535+ /// `Tag { Key, Value }`). Nested objects, arrays, and maps are rejected
5536+ /// because the wire shape below one level is service-specific.
5537+ fn referenced_array_flat_struct_item_type ( & self , name : & str ) -> Option < ArrayItemType > {
5538+ let resolved = self . resolve_cached_schema ( name) ?;
5539+ let SchemaType :: Object { properties, .. } = & resolved. schema_type else {
5540+ return None ;
5541+ } ;
5542+ if properties. is_empty ( ) {
5543+ return None ;
5544+ }
5545+ let all_scalar = properties
5546+ . values ( )
5547+ . all ( |property| match & property. schema_type {
5548+ SchemaType :: Primitive { .. } => true ,
5549+ SchemaType :: StringEnum { .. } | SchemaType :: ExtensibleEnum { .. } => true ,
5550+ SchemaType :: Reference { target } => {
5551+ self . referenced_array_scalar_item_type ( target) . is_some ( )
5552+ }
5553+ _ => false ,
5554+ } ) ;
5555+ all_scalar. then ( || ArrayItemType :: FlatStructRef {
5556+ schema_name : name. to_string ( ) ,
5557+ property_names : properties. keys ( ) . cloned ( ) . collect ( ) ,
5558+ } )
5559+ }
5560+
54265561 /// Resolve a referenced array item through any alias chain while
54275562 /// preserving the outer schema name used by the public `Vec<T>` type.
54285563 ///
0 commit comments