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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,15 @@ when correcting output that was wrong or incomplete on the wire.

## [Unreleased]

### Fixed

- Generated union deserializers no longer reject a branch whose re-encoding
differs from the input only by wire-equivalent forms: an integer in a
`number` field (`1` re-encoded as `1.0`) or an explicit `null` for an
optional field that is skipped on output. Exact matches still take
precedence, so values that decoded before keep their branch. This
unblocks every record of the Cloudflare DNS API.

## [0.17.0] - 2026-09-14

### Breaking changes
Expand Down
116 changes: 87 additions & 29 deletions src/generator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3512,18 +3512,26 @@ impl CodeGenerator {
if let Ok(candidate) =
serde_json::from_value::<#variant_type>(input.clone())
{
let preserves_complete_input = serde_json::to_value(&candidate)
.map(|encoded| encoded == input)
.unwrap_or(false);
if preserves_complete_input {
if matched.is_some() {
return Err(serde::de::Error::custom(concat!(
"ambiguous oneOf value for ",
stringify!(#enum_name),
": more than one branch preserved the complete input",
)));
match serde_json::to_value(&candidate) {
Ok(encoded) if encoded == input => {
if matched.is_some() {
return Err(serde::de::Error::custom(concat!(
"ambiguous oneOf value for ",
stringify!(#enum_name),
": more than one branch preserved the complete input",
)));
}
matched = Some(Self::#variant_name(candidate));
}
matched = Some(Self::#variant_name(candidate));
Ok(encoded)
if preserves_complete_json_input(
&encoded, &input, true, false,
) =>
{
equivalent_matches += 1;
equivalent.get_or_insert(Self::#variant_name(candidate));
}
_ => {}
}
}
}
Expand All @@ -3533,38 +3541,70 @@ impl CodeGenerator {
if let Ok(candidate) =
serde_json::from_value::<#variant_type>(input.clone())
{
let preserves_complete_input = serde_json::to_value(&candidate)
.map(|encoded| {
preserves_complete_json_input(&encoded, &input)
})
.unwrap_or(false);
if preserves_complete_input {
return Ok(Self::#variant_name(candidate));
match serde_json::to_value(&candidate) {
Ok(encoded)
if preserves_complete_json_input(
&encoded, &input, false, true,
) =>
{
return Ok(Self::#variant_name(candidate));
}
Ok(encoded)
if equivalent.is_none()
&& preserves_complete_json_input(
&encoded, &input, true, false,
) =>
{
equivalent = Some(Self::#variant_name(candidate));
}
_ => {}
}
}
}
}
})
.collect::<Vec<_>>();
// A branch that reproduces the input exactly always wins. Only when
// none does is a branch accepted whose encoding differs solely by
// wire-equivalent forms: `1.0` for `1`, or an omitted key for an
// input `null`. Inputs that decoded before keep their branch.
let no_match = if exclusive {
quote! {
matched.ok_or_else(|| serde::de::Error::custom(concat!(
if let Some(matched) = matched {
return Ok(matched);
}
if equivalent_matches > 1 {
return Err(serde::de::Error::custom(concat!(
"ambiguous oneOf value for ",
stringify!(#enum_name),
": more than one branch preserved an equivalent input",
)));
}
equivalent.ok_or_else(|| serde::de::Error::custom(concat!(
"no oneOf branch for ",
stringify!(#enum_name),
" preserved the complete input",
)))
}
} else {
quote! {
Err(serde::de::Error::custom(concat!(
equivalent.ok_or_else(|| serde::de::Error::custom(concat!(
"no anyOf branch for ",
stringify!(#enum_name),
" preserved the complete input",
)))
}
};
let matched_declaration = exclusive.then(|| quote! { let mut matched = None; });
let preservation_helper = (!exclusive).then(|| {
let matched_declaration = if exclusive {
quote! {
let mut matched = None;
let mut equivalent = None;
let mut equivalent_matches = 0usize;
}
} else {
quote! { let mut equivalent = None; }
};
let preservation_helper = {
quote! {
fn exact_json_integer(number: &serde_json::Number) -> Option<i128> {
number
Expand Down Expand Up @@ -3593,26 +3633,44 @@ impl CodeGenerator {
}
}

/// `nulls_may_be_absent` also accepts an input `null` that the
/// branch omits, as a skipped `None` does. Extra encoded
/// keys are allowed only by the pre-existing anyOf match.
fn preserves_complete_json_input(
encoded: &serde_json::Value,
input: &serde_json::Value,
nulls_may_be_absent: bool,
encoded_keys_may_be_extra: bool,
) -> bool {
match (encoded, input) {
(
serde_json::Value::Object(encoded),
serde_json::Value::Object(input),
) => input.iter().all(|(key, value)| {
encoded.get(key).is_some_and(|encoded_value| {
preserves_complete_json_input(encoded_value, value)
})
}),
) => {
(encoded_keys_may_be_extra
|| encoded.keys().all(|key| input.contains_key(key)))
&& input.iter().all(|(key, value)| match encoded.get(key) {
Some(encoded_value) => preserves_complete_json_input(
encoded_value,
value,
nulls_may_be_absent,
encoded_keys_may_be_extra,
),
None => nulls_may_be_absent && value.is_null(),
})
}
(
serde_json::Value::Array(encoded),
serde_json::Value::Array(input),
) => {
encoded.len() == input.len()
&& encoded.iter().zip(input).all(|(encoded, input)| {
preserves_complete_json_input(encoded, input)
preserves_complete_json_input(
encoded,
input,
nulls_may_be_absent,
encoded_keys_may_be_extra,
)
})
}
(
Expand All @@ -3623,7 +3681,7 @@ impl CodeGenerator {
}
}
}
});
};

return Ok(quote! {
#doc_comment
Expand Down
40 changes: 38 additions & 2 deletions src/openapi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -831,7 +831,7 @@ impl Schema {

/// True when the schema is nullable in any form OpenAPI allows:
/// 3.0's `nullable: true`, 3.1's `type: ["X", "null"]`, or an
/// `anyOf`/`oneOf` carrying a `null` branch.
/// `anyOf`/`oneOf` carrying a `null` branch or a nullable branch.
///
/// Property nullability must be decided through this, not through any
/// single one of the three checks. Each form was added separately and each
Expand All @@ -844,7 +844,22 @@ impl Schema {
self.reference_siblings_are_nullable()
|| self.details().is_nullable()
|| self.type_array_contains_null()
|| self.has_explicit_null_variant()
|| match self {
// A nullable branch also admits null even when the union has
// no separate null-only branch (common in OpenAPI 3.0).
Schema::AnyOf { any_of, .. } => any_of
.iter()
.any(|branch| branch.is_explicit_null_only() || branch.is_nullable_any()),
// `oneOf` admits null only when exactly one branch does.
Schema::OneOf { one_of, .. } => {
one_of
.iter()
.filter(|branch| branch.is_explicit_null_only() || branch.is_nullable_any())
.count()
== 1
}
_ => false,
}
}

/// Reference nodes retain siblings in `extra` because OpenAPI 3.0-era
Expand Down Expand Up @@ -1852,6 +1867,27 @@ mod tests {
}
}

#[test]
fn nullable_union_branches_preserve_union_nullability() {
for (schema, expected) in [
(
json!({"anyOf": [{"type": "object", "nullable": true}, {"type": "string", "nullable": true}]}),
true,
),
(
json!({"oneOf": [{"type": "object", "nullable": true}, {"type": "string"}]}),
true,
),
(
json!({"oneOf": [{"type": "object", "nullable": true}, {"type": "string", "nullable": true}]}),
false,
),
] {
let schema: Schema = serde_json::from_value(schema.clone()).unwrap();
assert_eq!(schema.is_nullable_any(), expected, "{schema:?}");
}
}

#[test]
fn null_only_enum_and_const_infer_null_instead_of_string() {
for source in [json!({"enum": [null]}), json!({"const": null})] {
Expand Down
Loading
Loading