Repository navigation
fix(generator): resolve allOf members that restate the same union #104
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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), | ||
| _ => {} | ||
| }, | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /// 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. | ||
|
|
@@ -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 | ||
|
|
@@ -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"); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Removing I checked this with a regression test that first verifies the two members accept different values using
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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>> { | ||
|
|
||
There was a problem hiding this comment.
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 withdependentRequired: {"description": ["kind"]}anddependentRequired: {"description": ["value"]}both becomedependentRequired: {}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
anyOfmembers in anallOf: main reports the multiple-union diagnostic, but this PR succeeds and retains only the first union. Could we preservedependentRequiredas data, as we already do forrequired, and add a regression test? More generally, restricting recursion to schema-valued keywords would avoid interpreting other data maps as schemas.