Skip to content
Merged
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 @@ -12,6 +12,12 @@ when correcting output that was wrong or incomplete on the wire.
`else if status.is_success()` branch after its success guard, which is
`status.is_success()` itself. The branch couldn't be reached, and clippy's
deny-by-default `ifs_same_cond` rejected the generated client (#89).
- A union whose branches only list required properties is a constraint on the
object, not a variant of it, also when a branch restates `type: object`.
An `allOf` of two of them (Cloudflare's "at least one of `to`, `cc` or
`bcc`, and of `text` or `html`") no longer fails generation as intersecting
multiple union members, and one beside a real union leaves that union the
variant (#88).

## [0.19.0] - 2026-09-26

Expand Down
44 changes: 25 additions & 19 deletions src/analysis.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6392,30 +6392,36 @@ impl SchemaAnalyzer {
/// Requiredness formulas can nest through `anyOf`/`oneOf` and `not`, as
/// in protobuf-generated "at most one field" schemas. They constrain
/// presence but add no payload shape for a Rust field to carry.
///
/// A branch may restate `type: object`, as Cloudflare's "at least one of
/// `to`, `cc` or `bcc`" does: only an object has required properties, so
/// it says nothing the requiredness doesn't.
fn schema_only_constrains_requiredness(schema: &Schema) -> bool {
let keys_are_requiredness_or_annotations = serde_json::to_value(schema)
.ok()
.and_then(|value| value.as_object().cloned())
.is_some_and(|object| {
object.keys().all(|key| {
matches!(
key.as_str(),
"required"
| "not"
| "anyOf"
| "oneOf"
| "title"
| "description"
| "deprecated"
| "readOnly"
| "writeOnly"
| "examples"
| "example"
| "default"
| "externalDocs"
| "xml"
| "$comment"
) || key.starts_with("x-")
object.iter().all(|(key, value)| {
(key == "type" && value == "object")
|| matches!(
key.as_str(),
"required"
| "not"
| "anyOf"
| "oneOf"
| "title"
| "description"
| "deprecated"
| "readOnly"
| "writeOnly"
| "examples"
| "example"
| "default"
| "externalDocs"
| "xml"
| "$comment"
)
|| key.starts_with("x-")
})
});
if !keys_are_requiredness_or_annotations {
Expand Down
81 changes: 81 additions & 0 deletions tests/recoverable_typing_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,87 @@ fn a_union_that_only_alternates_requiredness_is_the_object_it_describes() {
);
}

#[test]
fn requiredness_branches_that_restate_type_object_are_still_only_requiredness() {
// Cloudflare's Email Sending: "at least one of to or cc, and at least one
// of text or html". Each branch also says `type: object`, which only
// restates what having required properties means, so the two unions are
// constraints on one object, not two variants it can't be both of.
assert_types(
spec_with_schemas(json!({
"Email": {
"type": "object",
"required": ["from"],
"properties": {
"from": { "type": "string" },
"to": { "type": "string" },
"cc": { "type": "string" },
"text": { "type": "string" },
"html": { "type": "string" }
},
"allOf": [
{ "anyOf": [
{ "type": "object", "required": ["to"] },
{ "type": "object", "required": ["cc"] }
]},
{ "anyOf": [
{ "type": "object", "required": ["text"] },
{ "type": "object", "required": ["html"] }
]}
]
}
})),
&[
"pub struct Email",
"pub from: String",
"pub to: Option<String>",
"pub html: Option<String>",
],
);
}

#[test]
fn a_requiredness_union_beside_a_real_union_leaves_that_union_the_variant() {
// Cloudflare's Magic WAN: an app is an account app or a managed app, and
// sets breakout, priority or both. Only the first is a variant.
let generated = generate(spec_with_schemas(json!({
"AppConfig": {
"type": "object",
"allOf": [
{ "oneOf": [
{ "type": "object", "required": ["account_app_id"],
"properties": { "account_app_id": { "type": "string" } } },
{ "type": "object", "required": ["managed_app_id"],
"properties": { "managed_app_id": { "type": "string" } } }
]},
{ "anyOf": [
{ "type": "object", "required": ["breakout"] },
{ "type": "object", "required": ["priority"] }
]},
{ "type": "object", "properties": {
"breakout": { "type": "boolean" },
"priority": { "type": "integer" }
}}
]
}
})));
for want in [
"pub struct AppConfig",
"pub breakout: Option<bool>",
"pub priority: Option<i64>",
"pub variant: AppConfigAllOfVariant1",
] {
assert!(
generated.contains(want),
"expected `{want}` in generated output:\n{generated}"
);
}
assert!(
!generated.contains("AppConfigAllOfVariant2"),
"the requiredness union is not a variant:\n{generated}"
);
}

#[test]
fn union_branches_that_are_deep_pointers_are_expanded() {
// A component-root prefix must not make these look like two references to
Expand Down
Loading