Skip to content

Commit 88b357f

Browse files
Merge pull request #15 from gpu-cli/feat/conformance-3.1-3.2
feat: OpenAPI 3.1/3.2 conformance — 38 of 51 beads
2 parents 77023a2 + 5a087a8 commit 88b357f

47 files changed

Lines changed: 31577 additions & 189 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,11 @@
33
*.swo
44
*~
55
.DS_Store
6+
7+
# Lazy-cloned external corpus (see tests/conformance/external/apis-guru-sync.sh)
8+
/tests/conformance/external/apis-guru/
9+
10+
# Conformance reports are regenerated; keep the .toml/.yaml inputs tracked
11+
/tests/conformance/coverage-report.md
12+
/tests/conformance/json-schema-2020-12-report.md
13+
/tests/conformance/apis-guru-report.md

.gitmodules

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
[submodule "tests/conformance/external/json-schema-test-suite"]
2+
path = tests/conformance/external/json-schema-test-suite
3+
url = https://github.com/json-schema-org/JSON-Schema-Test-Suite.git

src/analysis.rs

Lines changed: 165 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -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)]
101101
pub 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

Comments
 (0)