@@ -97,7 +97,7 @@ pub struct DetectedPatterns {
9797}
9898
9999/// Information about an OpenAPI operation
100- #[ derive( Debug , Clone , serde:: Serialize ) ]
100+ #[ derive( Debug , Clone , Default , serde:: Serialize ) ]
101101pub struct OperationInfo {
102102 /// Operation ID
103103 pub operation_id : String ,
@@ -111,6 +111,9 @@ pub struct OperationInfo {
111111 pub description : Option < String > ,
112112 /// Request body content type and schema (if any)
113113 pub request_body : Option < RequestBodyContent > ,
114+ /// Whether `requestBody.required` was true. Drives whether the generated
115+ /// method takes a `Body` argument or `Option<Body>` (T11).
116+ pub request_body_required : bool ,
114117 /// Response schemas by status code
115118 pub response_schemas : BTreeMap < String , String > ,
116119 /// Parameters (path, query, header)
@@ -639,19 +642,18 @@ impl SchemaAnalyzer {
639642 }
640643
641644 fn extract_schemas ( spec : & OpenApiSpec ) -> Result < BTreeMap < String , Schema > > {
642- let schemas = spec
643- . components
644- . as_ref ( )
645- . and_then ( |c| c. schemas . as_ref ( ) )
646- . ok_or_else ( || {
647- GeneratorError :: InvalidSchema ( "No schemas found in OpenAPI spec" . to_string ( ) )
648- } ) ?;
649-
650- // Convert BTreeMap to BTreeMap for deterministic iteration order
645+ // OAS 3.1+ requires only one of `paths`, `webhooks`, or `components`.
646+ // A document may legitimately have no `components.schemas` (e.g. a
647+ // webhooks-only or paths-only spec). Return an empty map in that case
648+ // and let downstream codegen handle "no types to emit" gracefully.
649+ let schemas = spec. components . as_ref ( ) . and_then ( |c| c. schemas . as_ref ( ) ) ;
651650 Ok ( schemas
652- . iter ( )
653- . map ( |( k, v) | ( k. clone ( ) , v. clone ( ) ) )
654- . collect ( ) )
651+ . map ( |m| {
652+ m. iter ( )
653+ . map ( |( k, v) | ( k. clone ( ) , v. clone ( ) ) )
654+ . collect :: < BTreeMap < _ , _ > > ( )
655+ } )
656+ . unwrap_or_default ( ) )
655657 }
656658
657659 pub fn analyze ( & mut self ) -> Result < SchemaAnalysis > {
@@ -1041,7 +1043,8 @@ impl SchemaAnalyzer {
10411043 ) -> Result < AnalyzedSchema > {
10421044 let details = schema. details ( ) ;
10431045 let description = details. description . clone ( ) ;
1044- let nullable = details. is_nullable ( ) ;
1046+ // Combine 3.0-style `nullable: true` with 3.1's `type: ["X", "null"]`.
1047+ let nullable = details. is_nullable ( ) || schema. type_array_contains_null ( ) ;
10451048 let mut dependencies = HashSet :: new ( ) ;
10461049
10471050 let schema_type = match schema {
@@ -1053,16 +1056,22 @@ impl SchemaAnalyzer {
10531056 dependencies. insert ( target. clone ( ) ) ;
10541057 SchemaType :: Reference { target }
10551058 }
1056- Schema :: RecursiveRef { recursive_ref, .. } => {
1057- // Handle recursive references
1059+ Schema :: RecursiveRef { recursive_ref, .. }
1060+ | Schema :: DynamicRef {
1061+ dynamic_ref : recursive_ref,
1062+ ..
1063+ } => {
1064+ // Handle recursive / dynamic references. J1: full $dynamicRef
1065+ // resolution against $dynamicAnchor scopes is a follow-up; for
1066+ // now we treat them like recursive refs (self-reference when
1067+ // it's a fragment to the same schema, otherwise resolve via
1068+ // schema name).
10581069 if recursive_ref == "#" {
1059- // Self-reference to the current schema
10601070 dependencies. insert ( schema_name. to_string ( ) ) ;
10611071 SchemaType :: Reference {
10621072 target : schema_name. to_string ( ) ,
10631073 }
10641074 } else {
1065- // Handle other recursive reference patterns
10661075 let target = self
10671076 . extract_schema_name ( recursive_ref)
10681077 . unwrap_or ( schema_name)
@@ -1071,8 +1080,12 @@ impl SchemaAnalyzer {
10711080 SchemaType :: Reference { target }
10721081 }
10731082 }
1074- Schema :: Typed { schema_type, .. } => {
1075- match schema_type {
1083+ Schema :: Typed { .. } | Schema :: TypedMulti { .. } => {
1084+ let primary = schema
1085+ . schema_type ( )
1086+ . cloned ( )
1087+ . unwrap_or ( OpenApiSchemaType :: Object ) ;
1088+ match primary {
10761089 OpenApiSchemaType :: String => {
10771090 if let Some ( values) = details. string_enum_values ( ) {
10781091 SchemaType :: StringEnum { values }
@@ -3181,7 +3194,8 @@ impl SchemaAnalyzer {
31813194 Some ( & Discriminator {
31823195 property_name : disc_field,
31833196 mapping : None ,
3184- extra : BTreeMap :: new ( ) ,
3197+ default_mapping : None ,
3198+ extensions : crate :: extensions:: Extensions :: default ( ) ,
31853199 } ) ,
31863200 context_name,
31873201 dependencies,
@@ -3451,25 +3465,22 @@ impl SchemaAnalyzer {
34513465 . unwrap_or ( true ) ;
34523466
34533467 if no_properties {
3454- // Check for constraints that would make this a structured type
3455- let has_structural_constraints =
3456- // Has required fields (other than just 'type')
3457- details. required . as_ref ( )
3458- . map ( |req| req. iter ( ) . any ( |r| r != "type" ) )
3459- . unwrap_or ( false )
3460- // Has pattern-based property definitions
3461- || details. extra . contains_key ( "patternProperties" )
3462- // Has property name schema
3463- || details. extra . contains_key ( "propertyNames" )
3464- // Has min/max property constraints
3465- || details. extra . contains_key ( "minProperties" )
3466- || details. extra . contains_key ( "maxProperties" )
3467- // Has specific property dependencies
3468- || details. extra . contains_key ( "dependencies" )
3469- // Has conditional schemas
3470- || details. extra . contains_key ( "if" )
3471- || details. extra . contains_key ( "then" )
3472- || details. extra . contains_key ( "else" ) ;
3468+ // Check for constraints that would make this a structured type.
3469+ // After J5–J8, these are typed fields rather than `extra` lookups.
3470+ let has_structural_constraints = details
3471+ . required
3472+ . as_ref ( )
3473+ . map ( |req| req. iter ( ) . any ( |r| r != "type" ) )
3474+ . unwrap_or ( false )
3475+ || details. pattern_properties . is_some ( )
3476+ || details. property_names . is_some ( )
3477+ || details. min_properties . is_some ( )
3478+ || details. max_properties . is_some ( )
3479+ || details. dependent_required . is_some ( )
3480+ || details. dependent_schemas . is_some ( )
3481+ || details. if_schema . is_some ( )
3482+ || details. then_schema . is_some ( )
3483+ || details. else_schema . is_some ( ) ;
34733484
34743485 return !has_structural_constraints;
34753486 }
@@ -3496,28 +3507,92 @@ impl SchemaAnalyzer {
34963507
34973508 if let Some ( paths) = & spec. paths {
34983509 for ( path, path_item) in paths {
3499- for ( method, operation) in path_item. operations ( ) {
3500- // Generate operation ID if missing
3501- let operation_id = operation
3502- . operation_id
3503- . clone ( )
3504- . unwrap_or_else ( || Self :: generate_operation_id ( method, path) ) ;
3505-
3506- let op_info = self . analyze_single_operation (
3507- & operation_id,
3508- method,
3509- path,
3510- operation,
3511- path_item. parameters . as_ref ( ) ,
3512- analysis,
3513- ) ?;
3514- analysis. operations . insert ( operation_id, op_info) ;
3515- }
3510+ // H11: Path Item may be a $ref to components/pathItems. Resolve here.
3511+ let resolved = self . resolve_path_item ( path_item, & spec) ?;
3512+ let pi: & crate :: openapi:: PathItem = resolved. as_ref ( ) . unwrap_or ( path_item) ;
3513+ self . ingest_path_item_operations ( path, pi, analysis) ?;
3514+ }
3515+ }
3516+ // T4: walk webhooks the same way as paths. Per OAS 3.1+, webhooks are
3517+ // server→consumer callbacks: their request bodies describe payloads
3518+ // the *server* sends *to* the consumer. We currently emit them as
3519+ // ordinary operations so their request/response types land in the
3520+ // generated client; a future bead may add a typed Webhook enum and
3521+ // dispatcher.
3522+ if let Some ( webhooks) = & spec. webhooks {
3523+ for ( name, path_item) in webhooks {
3524+ let synthetic_path = format ! ( "__webhook__/{name}" ) ;
3525+ self . ingest_path_item_operations ( & synthetic_path, path_item, analysis) ?;
35163526 }
35173527 }
35183528 Ok ( ( ) )
35193529 }
35203530
3531+ /// H11: Resolve a Path Item's `$ref` (3.1+ allows them) against
3532+ /// `components/pathItems`. Returns Some(resolved) when a ref was followed,
3533+ /// or None when the input is already inline.
3534+ fn resolve_path_item (
3535+ & self ,
3536+ path_item : & crate :: openapi:: PathItem ,
3537+ spec : & crate :: openapi:: OpenApiSpec ,
3538+ ) -> Result < Option < crate :: openapi:: PathItem > > {
3539+ let Some ( reference) = & path_item. reference else {
3540+ return Ok ( None ) ;
3541+ } ;
3542+ let target_name = reference
3543+ . strip_prefix ( "#/components/pathItems/" )
3544+ . ok_or_else ( || {
3545+ GeneratorError :: UnresolvedReference ( format ! (
3546+ "Path Item $ref must point at #/components/pathItems/{{name}}, got {reference}"
3547+ ) )
3548+ } ) ?;
3549+ let pi = spec
3550+ . components
3551+ . as_ref ( )
3552+ . and_then ( |c| c. path_items . as_ref ( ) )
3553+ . and_then ( |map| map. get ( target_name) )
3554+ . ok_or_else ( || {
3555+ GeneratorError :: UnresolvedReference ( format ! (
3556+ "Path Item ref {reference} not found in components/pathItems"
3557+ ) )
3558+ } ) ?;
3559+ Ok ( Some ( pi. clone ( ) ) )
3560+ }
3561+
3562+ fn ingest_path_item_operations (
3563+ & mut self ,
3564+ path : & str ,
3565+ path_item : & crate :: openapi:: PathItem ,
3566+ analysis : & mut SchemaAnalysis ,
3567+ ) -> Result < ( ) > {
3568+ for ( method, operation) in path_item. operations ( ) {
3569+ // Generate operation ID if missing
3570+ let operation_id = operation
3571+ . operation_id
3572+ . clone ( )
3573+ . unwrap_or_else ( || Self :: generate_operation_id ( method, path) ) ;
3574+
3575+ let op_info = self . analyze_single_operation (
3576+ & operation_id,
3577+ method,
3578+ path,
3579+ operation,
3580+ path_item. parameters . as_ref ( ) ,
3581+ analysis,
3582+ ) ?;
3583+ // T6: detect operationId collisions instead of silently overwriting.
3584+ if let Some ( existing) = analysis. operations . get ( & operation_id) {
3585+ return Err ( GeneratorError :: InvalidSchema ( format ! (
3586+ "duplicate operationId `{}` — first at `{} {}`, then at `{} {}`. \
3587+ OpenAPI requires operationId to be unique across the document.",
3588+ operation_id, existing. method, existing. path, method, path
3589+ ) ) ) ;
3590+ }
3591+ analysis. operations . insert ( operation_id, op_info) ;
3592+ }
3593+ Ok ( ( ) )
3594+ }
3595+
35213596 /// Generate an operation ID from method and path when not provided
35223597 /// Converts paths like "/v0/servers/{serverId}" + "get" to "getV0ServersServerId"
35233598 fn generate_operation_id ( method : & str , path : & str ) -> String {
@@ -3574,6 +3649,12 @@ impl SchemaAnalyzer {
35743649 summary : operation. summary . clone ( ) ,
35753650 description : operation. description . clone ( ) ,
35763651 request_body : None ,
3652+ // Per OAS 3.x §"Request Body Object", `required` defaults to false.
3653+ request_body_required : operation
3654+ . request_body
3655+ . as_ref ( )
3656+ . and_then ( |rb| rb. required )
3657+ . unwrap_or ( false ) ,
35773658 response_schemas : BTreeMap :: new ( ) ,
35783659 parameters : Vec :: new ( ) ,
35793660 supports_streaming : false , // Will be determined by StreamingConfig, not spec
@@ -3612,6 +3693,17 @@ impl SchemaAnalyzer {
36123693 // Extract response schemas
36133694 if let Some ( responses) = & operation. responses {
36143695 for ( status_code, response) in responses {
3696+ // T15: SSE auto-detection. If any response declares
3697+ // `text/event-stream`, mark the operation as streaming. The
3698+ // user can still override via config; here we lift the spec
3699+ // signal so a `stream: true` parameter and an event-stream
3700+ // content type produce a streaming variant by default.
3701+ if let Some ( content) = response. content . as_ref ( ) {
3702+ if content. keys ( ) . any ( |ct| ct. starts_with ( "text/event-stream" ) ) {
3703+ op_info. supports_streaming = true ;
3704+ }
3705+ }
3706+
36153707 if let Some ( schema) = response. json_schema ( ) {
36163708 if let Some ( schema_ref) = schema. reference ( ) {
36173709 // Named schema reference
@@ -3637,6 +3729,21 @@ impl SchemaAnalyzer {
36373729 }
36383730 }
36393731
3732+ // T15: detect a `stream` boolean parameter on the operation; pair it
3733+ // with the SSE response signal above to populate stream_parameter.
3734+ if op_info. supports_streaming
3735+ && let Some ( parameters) = & operation. parameters
3736+ {
3737+ for param in parameters {
3738+ if let Some ( name) = param. name . as_deref ( ) {
3739+ if name. eq_ignore_ascii_case ( "stream" ) {
3740+ op_info. stream_parameter = Some ( name. to_string ( ) ) ;
3741+ break ;
3742+ }
3743+ }
3744+ }
3745+ }
3746+
36403747 // Extract parameters (operation-level first, then merge path-item-level)
36413748 if let Some ( parameters) = & operation. parameters {
36423749 for param in parameters {
@@ -3737,7 +3844,7 @@ impl SchemaAnalyzer {
37373844 & ' a self ,
37383845 param : & ' a crate :: openapi:: Parameter ,
37393846 ) -> std:: borrow:: Cow < ' a , crate :: openapi:: Parameter > {
3740- if let Some ( ref_str) = param. extra . get ( "$ref" ) . and_then ( |v| v . as_str ( ) ) {
3847+ if let Some ( ref_str) = param. reference . as_deref ( ) {
37413848 if let Some ( param_name) = ref_str. strip_prefix ( "#/components/parameters/" ) {
37423849 if let Some ( resolved) = self . component_parameters . get ( param_name) {
37433850 return std:: borrow:: Cow :: Borrowed ( resolved) ;
0 commit comments