Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ when correcting output that was wrong or incomplete on the wire.
optionality. Cloudflare's DNS record responses list `data` as required
although only some record types carry it, so A and MX records failed to
deserialize, and `name`, `content` and `ttl` were untyped.
- An `allOf` whose members repeat the same union, differing only in
annotations such as `description`, resolves to that union instead of failing
with "intersects multiple union members". The current Cloudflare spec
restates its Workers Observability filter union inline next to a `$ref` to
it, which stopped the whole spec from generating. Members that are different
unions still fail with that diagnostic.

## [0.22.0] - 2026-10-06

Expand Down
81 changes: 81 additions & 0 deletions src/analysis.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1415,6 +1415,46 @@ fn schema_is_annotation_only(schema: &crate::openapi::Schema) -> bool {
})
}

fn is_annotation_keyword(key: &str) -> bool {
matches!(
key,
"title"
| "description"
| "deprecated"
| "readOnly"
| "writeOnly"
| "examples"
| "example"
| "externalDocs"
| "xml"
| "$comment"
) || key.starts_with("x-")
}

/// Remove annotation keywords from a schema and its subschemas, leaving the
/// assertions. Member names under `properties` and similar maps are kept.
fn strip_schema_annotations(value: &mut Value) {
let Some(object) = value.as_object_mut() else {
return;
};
object.retain(|key, _| !is_annotation_keyword(key));
for (key, child) in object.iter_mut() {
match key.as_str() {
"properties" | "patternProperties" | "$defs" | "definitions" | "dependentSchemas" => {
if let Some(members) = child.as_object_mut() {
members.values_mut().for_each(strip_schema_annotations);
}
}
"enum" | "const" | "default" | "required" | "discriminator" => {}
_ => match child {
Value::Array(items) => items.iter_mut().for_each(strip_schema_annotations),
Value::Object(_) => strip_schema_annotations(child),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This fallback also recurses into dependentRequired, whose keys are property names rather than schema keywords. For example, two otherwise identical union branches with dependentRequired: {"description": ["kind"]} and dependentRequired: {"description": ["value"]} both become dependentRequired: {} after stripping. The second union then gets dropped even though {"description": "x", "kind": "y"} satisfies the first branch and fails the second.

I reproduced this with two anyOf members in an allOf: main reports the multiple-union diagnostic, but this PR succeeds and retains only the first union. Could we preserve dependentRequired as data, as we already do for required, and add a regression test? More generally, restricting recursion to schema-valued keywords would avoid interpreting other data maps as schemas.

_ => {}
},
}
}
}

/// Load an extension file and parse it into the JSON representation used by
/// the analyzer. YAML extensions follow the same conversion policy as YAML
/// OpenAPI documents; every other extension is parsed as JSON.
Expand Down Expand Up @@ -4278,6 +4318,9 @@ impl SchemaAnalyzer {
all_of_schemas: &[Schema],
dependencies: &mut HashSet<String>,
) -> Result<SchemaType> {
let without_restated = self.without_restated_unions(all_of_schemas);
let all_of_schemas = without_restated.as_deref().unwrap_or(all_of_schemas);

// A scalar/array carrier intersected only with annotation-only
// siblings keeps its wire type. This is deliberately narrower than
// "pick the first non-object": multiple carriers or assertion-bearing
Expand Down Expand Up @@ -4646,6 +4689,44 @@ impl SchemaAnalyzer {
}
}

/// Drop `allOf` members that repeat an earlier member's union, compared
/// without annotations. Returns `None` when nothing repeats.
fn without_restated_unions(&self, members: &[Schema]) -> Option<Vec<Schema>> {
let mut seen = Vec::new();
let mut kept = Vec::with_capacity(members.len());
for member in members {
if let Some(signature) = self.union_signature(member) {
if seen.contains(&signature) {
continue;
}
seen.push(signature);
}
kept.push(member.clone());
}
(kept.len() < members.len()).then_some(kept)
}

/// The assertions of a member that is only a union, following one `$ref`.
/// `type: object` is ignored so a named union matches an inline restatement
/// that omits it.
fn union_signature(&self, member: &Schema) -> Option<Value> {
let schema = match member.reference() {
Some(reference) => self.schemas.get(self.extract_schema_name(reference)?)?,
None => member,
};
let mut value = serde_json::to_value(schema).ok()?;
strip_schema_annotations(&mut value);
let object = value.as_object_mut()?;
if object.get("type").and_then(Value::as_str) == Some("object") {
object.remove("type");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removing type: object is safe for the Cloudflare example because its branches are objects, but it isn't redundant for every union. Consider allOf: [{anyOf: [{type: object}, {type: string}]}, {type: object, anyOf: [{type: object}, {type: string}]}]. The second member excludes strings, yet these signatures compare equal and it gets dropped. The resulting analyzed union still includes String.

I checked this with a regression test that first verifies the two members accept different values using jsonschema::is_valid. Main rejects the intersection; this PR silently collapses it. Could we only ignore the object type when the union branches provably imply it, or retain the distinction otherwise? A mixed object/scalar union would make a useful negative test here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just chiming in here.. So my plan was to use your work to make a flarectl cli built in rust as Cloudflare has seemingly dropped their support. The Cloudflare api could be a good test case of your work as it seems to be a monster. One warning though. Processing it used all the memory on my 32gb macbook! Good luck and thanks for the project :)

}
let is_union = object.contains_key("anyOf") || object.contains_key("oneOf");
let only_union = object
.keys()
.all(|key| matches!(key.as_str(), "anyOf" | "oneOf" | "discriminator"));
(is_union && only_union).then_some(value)
}

/// Property names declared by any object reachable through the branches
/// of an analyzed union, including nested unions and flattened variants.
fn union_declared_property_names(&mut self, target: &str) -> Result<HashSet<String>> {
Expand Down
80 changes: 80 additions & 0 deletions tests/allof_object_union_preservation_test.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
#![recursion_limit = "256"]

use openapi_to_rust::analysis::SchemaType;
use openapi_to_rust::{CodeGenerator, GeneratorConfig, SchemaAnalyzer};
use serde_json::{Value, json};
Expand Down Expand Up @@ -188,6 +190,61 @@ fn object_union_allof_spec() -> Value {
{ "$ref": "#/components/schemas/RecordWithData" }
]
},
"FilterLeaf": {
"type": "object",
"required": ["key", "operation"],
"properties": {
"key": { "type": "string" },
"operation": { "type": "string" },
"value": { "type": "string" }
}
},
"FilterNode": {
"description": "A leaf filter or a nested group.",
"type": "object",
"anyOf": [
{
"type": "object",
"required": ["kind", "filters"],
"properties": {
"kind": { "type": "string", "enum": ["group"] },
"filters": {
"type": "array",
"items": { "$ref": "#/components/schemas/FilterNode" }
}
}
},
{ "$ref": "#/components/schemas/FilterLeaf" }
]
},
"RestatedFilter": {
"allOf": [
{ "$ref": "#/components/schemas/FilterNode" },
{
"description": "Supports nested groups.",
"anyOf": [
{
"description": "A group of child filters.",
"type": "object",
"required": ["kind", "filters"],
"properties": {
"kind": {
"description": "Marks a group.",
"type": "string",
"enum": ["group"]
},
"filters": {
"description": "Child filters.",
"type": "array",
"items": { "$ref": "#/components/schemas/FilterNode" }
}
}
},
{ "$ref": "#/components/schemas/FilterLeaf" }
]
}
]
},
"GitpodOutputSpec": {
"type": "object",
"additionalProperties": false,
Expand Down Expand Up @@ -445,6 +502,17 @@ fn main() {
"boolean": {}
}));
round_trip::<generated::GitpodOutputSpec>(serde_json::json!({}));
round_trip::<generated::RestatedFilter>(serde_json::json!({
"kind": "group",
"filters": [
{ "key": "service", "operation": "eq", "value": "api" },
{ "kind": "group", "filters": [] }
]
}));
round_trip::<generated::RestatedFilter>(serde_json::json!({
"key": "status",
"operation": "exists"
}));
}
"##,
)
Expand All @@ -468,6 +536,18 @@ fn main() {
);
}

#[test]
fn allof_restating_a_union_up_to_annotations_resolves_to_that_union() {
let analysis = analyze();
let SchemaType::Reference { target } = &analysis.schemas["RestatedFilter"].schema_type else {
panic!(
"RestatedFilter should alias FilterNode, got {:?}",
analysis.schemas["RestatedFilter"].schema_type
);
};
assert_eq!(target, "FilterNode");
}

#[test]
fn multiple_union_intersections_are_reported_instead_of_silently_dropped() {
let spec = json!({
Expand Down