From 4a07e9d8cbbf2484000cb1d5818a746b512cf7e1 Mon Sep 17 00:00:00 2001 From: Giles Westwood <201443+gilesw@users.noreply.github.com> Date: Fri, 25 Sep 2026 10:47:04 +0100 Subject: [PATCH 1/7] fix(generator): accept wire-equivalent encodings when selecting a union branch Union deserializers keep a branch only if re-serializing it reproduces the input. An integer in a `number` field (re-encoded as `1.0`) and an explicit `null` for an optional field (skipped on output) both failed that check, so every Cloudflare DNS record was rejected. oneOf compared with strict equality, and anyOf treated an input null as lost. Both now fall back to a comparison that treats those encodings as equal, but only when no branch reproduces the input exactly, so inputs that decoded before keep their branch and ambiguity is still reported. --- src/generator.rs | 100 ++++++--- ..._helpers__beta_tools_array_union_test.snap | 159 +++++++++++--- ..._test_helpers__tools_array_union_test.snap | 159 +++++++++++--- tests/union_branch_wire_equivalence_test.rs | 197 ++++++++++++++++++ 4 files changed, 529 insertions(+), 86 deletions(-) create mode 100644 tests/union_branch_wire_equivalence_test.rs diff --git a/src/generator.rs b/src/generator.rs index cdab4c6b..dfed403c 100644 --- a/src/generator.rs +++ b/src/generator.rs @@ -3512,18 +3512,24 @@ 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) => + { + equivalent_matches += 1; + equivalent.get_or_insert(Self::#variant_name(candidate)); + } + _ => {} } } } @@ -3533,22 +3539,42 @@ 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) => + { + return Ok(Self::#variant_name(candidate)); + } + Ok(encoded) + if equivalent.is_none() + && preserves_complete_json_input(&encoded, &input, true) => + { + equivalent = Some(Self::#variant_name(candidate)); + } + _ => {} } } } } }) .collect::>(); + // 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", @@ -3556,15 +3582,23 @@ impl CodeGenerator { } } 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 { number @@ -3593,18 +3627,24 @@ impl CodeGenerator { } } + /// `nulls_may_be_absent` also accepts an input `null` that the + /// branch omits, as a skipped `None` does. fn preserves_complete_json_input( encoded: &serde_json::Value, input: &serde_json::Value, + nulls_may_be_absent: 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) - }) + ) => input.iter().all(|(key, value)| match encoded.get(key) { + Some(encoded_value) => preserves_complete_json_input( + encoded_value, + value, + nulls_may_be_absent, + ), + None => nulls_may_be_absent && value.is_null(), }), ( serde_json::Value::Array(encoded), @@ -3612,7 +3652,11 @@ impl CodeGenerator { ) => { 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, + ) }) } ( @@ -3623,7 +3667,7 @@ impl CodeGenerator { } } } - }); + }; return Ok(quote! { #doc_comment diff --git a/src/snapshots/openapi_to_rust__test_helpers__beta_tools_array_union_test.snap b/src/snapshots/openapi_to_rust__test_helpers__beta_tools_array_union_test.snap index 0df04eeb..8d3a3bce 100644 --- a/src/snapshots/openapi_to_rust__test_helpers__beta_tools_array_union_test.snap +++ b/src/snapshots/openapi_to_rust__test_helpers__beta_tools_array_union_test.snap @@ -47,8 +47,83 @@ impl<'de> Deserialize<'de> for BetaCreateMessageParamsToolsItemUnion { where D: serde::Deserializer<'de>, { + fn exact_json_integer(number: &serde_json::Number) -> Option { + number.as_i64().map(i128::from).or_else(|| number.as_u64().map(i128::from)) + } + fn json_numbers_have_same_value( + encoded: &serde_json::Number, + input: &serde_json::Number, + ) -> bool { + match (exact_json_integer(encoded), exact_json_integer(input)) { + (Some(encoded), Some(input)) => encoded == input, + (Some(encoded), None) => { + input + .as_f64() + .is_some_and(|input| { + input.is_finite() && input.fract() == 0.0 + && input as i128 == encoded + }) + } + (None, Some(input)) => { + encoded + .as_f64() + .is_some_and(|encoded| { + encoded.is_finite() && encoded.fract() == 0.0 + && encoded as i128 == input + }) + } + (None, None) => encoded.as_f64() == input.as_f64(), + } + } + /// `nulls_may_be_absent` also accepts an input `null` that the + /// branch omits, as a skipped `None` does. + fn preserves_complete_json_input( + encoded: &serde_json::Value, + input: &serde_json::Value, + nulls_may_be_absent: bool, + ) -> bool { + match (encoded, input) { + ( + serde_json::Value::Object(encoded), + serde_json::Value::Object(input), + ) => { + input + .iter() + .all(|(key, value)| match encoded.get(key) { + Some(encoded_value) => { + preserves_complete_json_input( + encoded_value, + value, + nulls_may_be_absent, + ) + } + 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, + nulls_may_be_absent, + ) + }) + } + ( + serde_json::Value::Number(encoded), + serde_json::Value::Number(input), + ) => json_numbers_have_same_value(encoded, input), + _ => encoded == input, + } + } let input = ::deserialize(deserializer)?; let mut matched = None; + let mut equivalent = None; + let mut equivalent_matches = 0usize; if input .as_object() .is_some_and(|object| { @@ -62,22 +137,28 @@ impl<'de> Deserialize<'de> for BetaCreateMessageParamsToolsItemUnion { }) { if let Ok(candidate) = serde_json::from_value::(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!(BetaCreateMessageParamsToolsItemUnion), - ": 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!(BetaCreateMessageParamsToolsItemUnion), + ": more than one branch preserved the complete input", + ), ), - ), - ); + ); + } + matched = Some(Self::BetaTool(candidate)); + } + Ok( + encoded, + ) if preserves_complete_json_input(&encoded, &input, true) => { + equivalent_matches += 1; + equivalent.get_or_insert(Self::BetaTool(candidate)); } - matched = Some(Self::BetaTool(candidate)); + _ => {} } } } @@ -98,26 +179,46 @@ impl<'de> Deserialize<'de> for BetaCreateMessageParamsToolsItemUnion { if let Ok(candidate) = serde_json::from_value::< BetaComputerUseTool, >(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!(BetaCreateMessageParamsToolsItemUnion), - ": 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!(BetaCreateMessageParamsToolsItemUnion), + ": more than one branch preserved the complete input", + ), ), - ), - ); + ); + } + matched = Some(Self::BetaComputerUseTool(candidate)); } - matched = Some(Self::BetaComputerUseTool(candidate)); + Ok( + encoded, + ) if preserves_complete_json_input(&encoded, &input, true) => { + equivalent_matches += 1; + equivalent.get_or_insert(Self::BetaComputerUseTool(candidate)); + } + _ => {} } } } - matched + if let Some(matched) = matched { + return Ok(matched); + } + if equivalent_matches > 1 { + return Err( + serde::de::Error::custom( + concat!( + "ambiguous oneOf value for ", + stringify!(BetaCreateMessageParamsToolsItemUnion), + ": more than one branch preserved an equivalent input", + ), + ), + ); + } + equivalent .ok_or_else(|| serde::de::Error::custom( concat!( "no oneOf branch for ", diff --git a/src/snapshots/openapi_to_rust__test_helpers__tools_array_union_test.snap b/src/snapshots/openapi_to_rust__test_helpers__tools_array_union_test.snap index 457901cc..20008095 100644 --- a/src/snapshots/openapi_to_rust__test_helpers__tools_array_union_test.snap +++ b/src/snapshots/openapi_to_rust__test_helpers__tools_array_union_test.snap @@ -45,8 +45,83 @@ impl<'de> Deserialize<'de> for CreateMessageParamsToolsItemUnion { where D: serde::Deserializer<'de>, { + fn exact_json_integer(number: &serde_json::Number) -> Option { + number.as_i64().map(i128::from).or_else(|| number.as_u64().map(i128::from)) + } + fn json_numbers_have_same_value( + encoded: &serde_json::Number, + input: &serde_json::Number, + ) -> bool { + match (exact_json_integer(encoded), exact_json_integer(input)) { + (Some(encoded), Some(input)) => encoded == input, + (Some(encoded), None) => { + input + .as_f64() + .is_some_and(|input| { + input.is_finite() && input.fract() == 0.0 + && input as i128 == encoded + }) + } + (None, Some(input)) => { + encoded + .as_f64() + .is_some_and(|encoded| { + encoded.is_finite() && encoded.fract() == 0.0 + && encoded as i128 == input + }) + } + (None, None) => encoded.as_f64() == input.as_f64(), + } + } + /// `nulls_may_be_absent` also accepts an input `null` that the + /// branch omits, as a skipped `None` does. + fn preserves_complete_json_input( + encoded: &serde_json::Value, + input: &serde_json::Value, + nulls_may_be_absent: bool, + ) -> bool { + match (encoded, input) { + ( + serde_json::Value::Object(encoded), + serde_json::Value::Object(input), + ) => { + input + .iter() + .all(|(key, value)| match encoded.get(key) { + Some(encoded_value) => { + preserves_complete_json_input( + encoded_value, + value, + nulls_may_be_absent, + ) + } + 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, + nulls_may_be_absent, + ) + }) + } + ( + serde_json::Value::Number(encoded), + serde_json::Value::Number(input), + ) => json_numbers_have_same_value(encoded, input), + _ => encoded == input, + } + } let input = ::deserialize(deserializer)?; let mut matched = None; + let mut equivalent = None; + let mut equivalent_matches = 0usize; if input .as_object() .is_some_and(|object| { @@ -60,22 +135,28 @@ impl<'de> Deserialize<'de> for CreateMessageParamsToolsItemUnion { }) { if let Ok(candidate) = serde_json::from_value::(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!(CreateMessageParamsToolsItemUnion), - ": 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!(CreateMessageParamsToolsItemUnion), + ": more than one branch preserved the complete input", + ), ), - ), - ); + ); + } + matched = Some(Self::Tool(candidate)); + } + Ok( + encoded, + ) if preserves_complete_json_input(&encoded, &input, true) => { + equivalent_matches += 1; + equivalent.get_or_insert(Self::Tool(candidate)); } - matched = Some(Self::Tool(candidate)); + _ => {} } } } @@ -92,26 +173,46 @@ impl<'de> Deserialize<'de> for CreateMessageParamsToolsItemUnion { }) { if let Ok(candidate) = serde_json::from_value::(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!(CreateMessageParamsToolsItemUnion), - ": 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!(CreateMessageParamsToolsItemUnion), + ": more than one branch preserved the complete input", + ), ), - ), - ); + ); + } + matched = Some(Self::BashTool(candidate)); } - matched = Some(Self::BashTool(candidate)); + Ok( + encoded, + ) if preserves_complete_json_input(&encoded, &input, true) => { + equivalent_matches += 1; + equivalent.get_or_insert(Self::BashTool(candidate)); + } + _ => {} } } } - matched + if let Some(matched) = matched { + return Ok(matched); + } + if equivalent_matches > 1 { + return Err( + serde::de::Error::custom( + concat!( + "ambiguous oneOf value for ", + stringify!(CreateMessageParamsToolsItemUnion), + ": more than one branch preserved an equivalent input", + ), + ), + ); + } + equivalent .ok_or_else(|| serde::de::Error::custom( concat!( "no oneOf branch for ", diff --git a/tests/union_branch_wire_equivalence_test.rs b/tests/union_branch_wire_equivalence_test.rs new file mode 100644 index 00000000..3b9fe4f7 --- /dev/null +++ b/tests/union_branch_wire_equivalence_test.rs @@ -0,0 +1,197 @@ +//! Union deserializers keep a branch only when re-serializing it reproduces +//! the input. Two encodings of the same value failed that check: +//! +//! - `"ttl": 1` decoded into an `f64` field re-serializes as `1.0`, and +//! - `"comment": null` decoded into an `Option` field is skipped on output. +//! +//! Neither loses information, but together they rejected every record of the +//! Cloudflare DNS API, which types integers as `number` and returns explicit +//! nulls for optional fields. This fixture is that shape, reduced: a `oneOf` +//! of record types keyed by an enum `type`, wrapped in an `allOf` with the +//! shared response fields. +//! +//! The negative cases matter as much: a branch that states `integer` must +//! still beat one that states `number` for `1`, and `1.5` must still reach the +//! `number` branch, so the looser comparison is only a fallback when no branch +//! reproduces the input exactly. + +use openapi_to_rust::{CodeGenerator, GeneratorConfig, SchemaAnalyzer}; +use serde_json::{Value, json}; +use std::process::Command; + +fn spec() -> Value { + json!({ + "openapi": "3.0.3", + "info": { "title": "wire equivalence", "version": "1.0.0" }, + "paths": {}, + "components": { "schemas": { + "ARecord": { + "type": "object", + "properties": { + "type": { "type": "string", "enum": ["A"] }, + "content": { "type": "string" }, + "ttl": { "type": "number" }, + "comment": { "type": "string" } + } + }, + "MXRecord": { + "type": "object", + "properties": { + "type": { "type": "string", "enum": ["MX"] }, + "content": { "type": "string" }, + "ttl": { "type": "number" }, + "priority": { "type": "number" }, + "comment": { "type": "string" } + } + }, + "Record": { + "oneOf": [ + { "$ref": "#/components/schemas/ARecord" }, + { "$ref": "#/components/schemas/MXRecord" } + ] + }, + "RecordResponse": { + "type": "object", + "allOf": [ + { "anyOf": [ { "$ref": "#/components/schemas/Record" } ] }, + { + "type": "object", + "required": ["id"], + "properties": { "id": { "type": "string" } } + } + ] + }, + "IntegerCount": { + "type": "object", "required": ["count"], + "properties": { "count": { "type": "integer" } } + }, + "NumberCount": { + "type": "object", "required": ["count"], + "properties": { "count": { "type": "number" } } + }, + "Count": { + "oneOf": [ + { "$ref": "#/components/schemas/IntegerCount" }, + { "$ref": "#/components/schemas/NumberCount" } + ] + } + } } + }) +} + +#[test] +fn equivalent_wire_encodings_select_a_union_branch() { + let temp = tempfile::TempDir::new().expect("temporary scratch crate"); + let mut analyzer = SchemaAnalyzer::new(spec()).expect("parse wire equivalence spec"); + let mut analysis = analyzer.analyze().expect("analyze wire equivalence spec"); + let generator = CodeGenerator::new(GeneratorConfig { + output_dir: temp.path().join("src/generated"), + module_name: "wire_equivalence".into(), + enable_async_client: false, + enable_sse_client: false, + tracing_enabled: false, + ..Default::default() + }); + let result = generator + .generate_all(&mut analysis) + .expect("generate wire equivalence models"); + generator.write_files(&result).expect("write models"); + + let dependency_fragment = + std::fs::read_to_string(temp.path().join("src/generated/REQUIRED_DEPS.toml")) + .expect("generated dependency fragment"); + std::fs::write( + temp.path().join("Cargo.toml"), + format!( + r#"[package] +name = "wire-equivalence-smoke" +version = "0.0.0" +edition = "2024" +publish = false + +{dependency_fragment} +"# + ), + ) + .expect("write scratch manifest"); + std::fs::write( + temp.path().join("src/lib.rs"), + r#"pub mod generated; + +#[cfg(test)] +mod tests { + use super::generated::{Count, Record, RecordResponse}; + use serde_json::json; + + #[test] + fn an_integer_in_a_number_field_selects_its_branch() { + let record: Record = serde_json::from_value( + json!({"type": "A", "content": "192.0.2.1", "ttl": 1}), + ) + .expect("`ttl: 1` must not be rejected because `f64` re-encodes it as `1.0`"); + assert!(matches!(record, Record::ARecord(_))); + + let record: Record = serde_json::from_value( + json!({"type": "MX", "content": "mx.example.com", "ttl": 3600, "priority": 10}), + ) + .expect("integer-valued numbers must select the MX branch"); + assert!(matches!(record, Record::MXRecord(_))); + } + + #[test] + fn an_explicit_null_for_an_optional_field_selects_its_branch() { + let record: Record = serde_json::from_value( + json!({"type": "A", "content": "192.0.2.1", "ttl": 3600, "comment": null}), + ) + .expect("`comment: null` must not be rejected because `None` is skipped on output"); + assert!(matches!(record, Record::ARecord(_))); + } + + #[test] + fn a_full_response_with_both_encodings_decodes() { + let response: RecordResponse = serde_json::from_value(json!({ + "id": "372e67954025e0ba6aaa6d586b9e0b59", + "type": "MX", "content": "mx.example.com", "ttl": 1, "priority": 10, + "comment": null + })) + .expect("the Cloudflare-shaped response must decode"); + assert_eq!(response.id, "372e67954025e0ba6aaa6d586b9e0b59"); + } + + #[test] + fn an_exact_match_still_beats_an_equivalent_one() { + let count: Count = serde_json::from_value(json!({"count": 1})) + .expect("an integer count must still decode"); + assert!( + matches!(count, Count::IntegerCount(_)), + "the `integer` branch reproduces `1` exactly and must win: {count:?}" + ); + + let count: Count = serde_json::from_value(json!({"count": 1.5})) + .expect("a fractional count must still decode"); + assert!( + matches!(count, Count::NumberCount(_)), + "only the `number` branch can hold `1.5`: {count:?}" + ); + } +} +"#, + ) + .expect("write scratch tests"); + + let output = Command::new("cargo") + .args(["test", "--quiet", "--offline"]) + .current_dir(temp.path()) + .env( + "CARGO_TARGET_DIR", + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("target/wire-equivalence-smoke"), + ) + .output() + .expect("run generated wire equivalence tests"); + assert!( + output.status.success(), + "generated unions must accept equivalent wire encodings:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} From dc7cac47c4d5fb8b220f3d729e3b50fc1253af24 Mon Sep 17 00:00:00 2001 From: Giles Westwood <201443+gilesw@users.noreply.github.com> Date: Fri, 25 Sep 2026 10:51:51 +0100 Subject: [PATCH 2/7] test: refresh corpus manifest for union branch selection --- tests/corpus-manifest.txt | 78 +++++++++++++++++++-------------------- 1 file changed, 39 insertions(+), 39 deletions(-) diff --git a/tests/corpus-manifest.txt b/tests/corpus-manifest.txt index 7a44d6cc..34170b46 100644 --- a/tests/corpus-manifest.txt +++ b/tests/corpus-manifest.txt @@ -13,7 +13,7 @@ anthropic/REQUIRED_DEPS.toml 669 16 a7f60b37a7334009 anthropic/client.rs 231071 6019 5e61b6d56d71c190 anthropic/mod.rs 444 17 5214bdbc37b918db -anthropic/types.rs 4151195 85587 fccedf56680b4aa4 +anthropic/types.rs 4199162 86655 3951fb7693cb7e7c arcade/REQUIRED_DEPS.toml 528 13 d6f1ac00e2426e4d arcade/client.rs 213784 5711 5b3e2de35fcec738 arcade/mod.rs 438 17 b55cb3acf5fdbd02 @@ -21,11 +21,11 @@ arcade/types.rs 105169 2609 7717f64 asana/REQUIRED_DEPS.toml 699 16 4ad61bc1ea5c636a asana/client.rs 1947805 49122 5c448775f672432b asana/mod.rs 436 17 2390079a5e4e9f74 -asana/types.rs 612843 13322 8c9551b8dfd04f6e +asana/types.rs 617712 13450 dcd63de341ad6d9a box/REQUIRED_DEPS.toml 771 18 5dbdfe553bf9dbeb box/client.rs 1634689 42619 1af7286b76706f61 box/mod.rs 432 17 b40589a066c4b98b -box/types.rs 1166877 30496 6c34d244c87ec882 +box/types.rs 1263021 32757 2141d91cf746026d browserbase/REQUIRED_DEPS.toml 699 16 4ad61bc1ea5c636a browserbase/client.rs 89262 2448 5889d5faaecdea5f browserbase/mod.rs 448 17 7e721aa1bb802e26 @@ -33,51 +33,51 @@ browserbase/types.rs 50190 1258 8a533e9 cal-com/REQUIRED_DEPS.toml 625 15 0e0871569054a9ee cal-com/client.rs 1281022 33372 557d0f7f541c4d26 cal-com/mod.rs 440 17 05fb649563c82805 -cal-com/types.rs 1395931 35605 63c9dea82070f9f4 +cal-com/types.rs 1793874 44912 cac7460c9438bc01 cartesia/REQUIRED_DEPS.toml 669 16 a7f60b37a7334009 cartesia/client.rs 250746 6884 b0055cd12698d427 cartesia/mod.rs 442 17 3c5001b7641597a2 -cartesia/types.rs 210349 5830 ecd4ce55e9bc8b1c +cartesia/types.rs 229108 6270 1c5ac461f686b0a6 cerebras/REQUIRED_DEPS.toml 528 13 d6f1ac00e2426e4d cerebras/client.rs 41549 1112 2412d7e10c4deb73 cerebras/mod.rs 442 17 71f9d1b20905ad16 -cerebras/types.rs 225828 5803 740d8cc143b1af41 +cerebras/types.rs 229803 5905 6c66ff309c6c0acb circleci/REQUIRED_DEPS.toml 626 15 082a47f05075776b circleci/client.rs 610986 15850 37c84ac5ec04028b circleci/mod.rs 442 17 7a8b5691342a9d62 -circleci/types.rs 364570 9619 5dd0e36a1e3ca912 +circleci/types.rs 380498 10004 de2bd2d0d4afa641 cloudflare/REQUIRED_DEPS.toml 787 19 09cccc57db93029b cloudflare/client.rs 13273986 347587 7aab352c5f1cc4f6 cloudflare/mod.rs 446 17 ce0e733f646147da -cloudflare/types.rs 16273278 378321 231c0f88c435b00c +cloudflare/types.rs 16661076 388469 71a3394c8ec3fd6b coda/REQUIRED_DEPS.toml 672 16 9e666f39c7301c02 coda/client.rs 847692 21836 2d23862bfc0441d3 coda/mod.rs 434 17 4e8b99caec52b623 -coda/types.rs 1990457 41947 788c94a3949d60b9 +coda/types.rs 2003954 42267 0696607b4c06192f coingecko/REQUIRED_DEPS.toml 579 14 9277be96334b3809 coingecko/client.rs 446994 12325 acceac4d0311c48d coingecko/mod.rs 444 17 0059623eabdef914 -coingecko/types.rs 273846 6498 1a5bef1d4bcea737 +coingecko/types.rs 278084 6598 4c1ff89652d0ed22 datadog-v2/REQUIRED_DEPS.toml 700 16 e697f0e5afafb53b datadog-v2/client.rs 5195253 134571 5a762162882dc603 datadog-v2/mod.rs 446 17 56e45841c9753096 -datadog-v2/types.rs 3346743 85109 6233c943a806f68b +datadog-v2/types.rs 3813671 96042 a2abeb1cf6983e46 digitalocean/REQUIRED_DEPS.toml 736 18 21727a31b033ca36 digitalocean/client.rs 3795542 97732 0b73de52eb6426cf digitalocean/mod.rs 450 17 3b99e8d4d3161f03 -digitalocean/types.rs 1492411 35105 8d22f01598c85c65 +digitalocean/types.rs 1539079 36250 161277440070aa17 discord/REQUIRED_DEPS.toml 699 16 4ad61bc1ea5c636a discord/client.rs 1051791 27494 53240372d9aa872b discord/mod.rs 440 17 1d9f7eee6c834b1c -discord/types.rs 1272025 32755 de44694314113c14 +discord/types.rs 1753307 44066 185795506f8e6d0a gcore/REQUIRED_DEPS.toml 746 17 7f467fcbfe949464 gcore/client.rs 5111572 134940 841b98a2c23ff598 gcore/mod.rs 436 17 d5c05d8821f23bcb -gcore/types.rs 5862313 136588 532f95a64ec49f61 +gcore/types.rs 5981948 139780 baf529c2bd2f725e github/REQUIRED_DEPS.toml 625 15 0e0871569054a9ee github/client.rs 5743897 145449 0c8c194850179046 github/mod.rs 438 17 ac4d40f51fb06aa3 -github/types.rs 7702391 207387 0019503f70f38bce +github/types.rs 8016770 214825 a18ec1777da11b93 gitpod/REQUIRED_DEPS.toml 736 18 21727a31b033ca36 gitpod/client.rs 1591035 42786 9e006b8683cf2cce gitpod/mod.rs 438 17 516880570ce5294f @@ -109,11 +109,11 @@ grafana/types.rs 391878 9248 e82c5cd groq/REQUIRED_DEPS.toml 630 15 7935666e65c0812e groq/client.rs 92406 2483 d5928b065aa9994d groq/mod.rs 434 17 72fd6d54577d5597 -groq/types.rs 372951 9387 e3c08712828ff708 +groq/types.rs 387393 9726 f67150f59e2296bc imagekit/REQUIRED_DEPS.toml 673 16 b888661e2699f921 imagekit/client.rs 335861 8650 9c69f4ef8efa79bd imagekit/mod.rs 442 17 40484a363f5391c3 -imagekit/types.rs 717309 15820 b158f06cd64572b6 +imagekit/types.rs 721621 15922 f28adeb7b482f7e2 increase/REQUIRED_DEPS.toml 653 15 715b930adfa76491 increase/client.rs 1284242 32563 07d535545ac049fc increase/mod.rs 442 17 f6131a8548853a65 @@ -121,27 +121,27 @@ increase/types.rs 2544062 56883 63883f4 knocklabs/REQUIRED_DEPS.toml 672 16 9e666f39c7301c02 knocklabs/client.rs 476515 12531 83d28a7a3cd9fe00 knocklabs/mod.rs 444 17 7822049ab73a5ed0 -knocklabs/types.rs 259668 6425 e15eeacb10637dc6 +knocklabs/types.rs 310181 7613 ed61358acbeaf527 langsmith/REQUIRED_DEPS.toml 746 17 7f467fcbfe949464 langsmith/client.rs 2201458 57231 afa9d3fc9aadb2ab langsmith/mod.rs 444 17 eb5007d340d34a8e -langsmith/types.rs 1161995 32610 fbf8b66781fcdab7 +langsmith/types.rs 1181008 33067 5e793aca6feef190 launchdarkly/REQUIRED_DEPS.toml 700 16 e697f0e5afafb53b launchdarkly/client.rs 2523958 64225 7531d5cd5b144114 launchdarkly/mod.rs 450 17 d4ed5579304ccd39 -launchdarkly/types.rs 735300 18316 37aac538ad06edd7 +launchdarkly/types.rs 742114 18479 2e2fb75dbc69e91c letta/REQUIRED_DEPS.toml 746 17 7f467fcbfe949464 letta/client.rs 1413756 38041 7d7998bf850bd64e letta/mod.rs 436 17 5cc3226a7c317d2a -letta/types.rs 6858612 139380 79ac34e92dacd5fb +letta/types.rs 6937642 141281 30f77c6fb40480f3 lithic/REQUIRED_DEPS.toml 688 17 9c8fe0ad1f161983 lithic/client.rs 1351876 35356 60aebed1624e10a4 lithic/mod.rs 438 17 eba70abf1f172ff1 -lithic/types.rs 1612473 39122 146638e5d90ddfa5 +lithic/types.rs 1670502 40511 020b606a94213fdd luma/REQUIRED_DEPS.toml 672 16 9e666f39c7301c02 luma/client.rs 59518 1615 5f1fe1b8e87ef5ea luma/mod.rs 434 17 d85018b8cdd8f625 -luma/types.rs 75620 2116 b98bae10c054ef3e +luma/types.rs 81534 2252 c82ae0a26d76cbc2 meta-llama/REQUIRED_DEPS.toml 630 15 7935666e65c0812e meta-llama/client.rs 45990 1233 bafec8b0572aae0d meta-llama/mod.rs 446 17 6e485725d643b7c2 @@ -153,19 +153,19 @@ microsoft-graph/types.rs 14189944 351429 700bb06 modern-treasury/REQUIRED_DEPS.toml 746 17 7f467fcbfe949464 modern-treasury/client.rs 955625 25673 fcda40112812f7c6 modern-treasury/mod.rs 456 17 5f336a50419a709b -modern-treasury/types.rs 995584 26633 ce34002029faedaa +modern-treasury/types.rs 1003025 26835 d6f2c3608bea3356 openai/REQUIRED_DEPS.toml 648 15 a3db003e7f3781e2 openai/client.rs 1045856 28439 318fe79140e5b91a openai/mod.rs 438 17 59c0207149335235 -openai/types.rs 6310615 142383 a39f47ec0b9c4cd0 +openai/types.rs 6324034 142727 81cdb39c3be00ba2 opencode/REQUIRED_DEPS.toml 576 14 9228e95d38f5078e opencode/client.rs 943362 25095 d0a641e0a3bb0d27 opencode/mod.rs 442 17 98c109eb94b95dbf -opencode/types.rs 6695017 143213 ef443d0415e39591 +opencode/types.rs 6794849 145877 edc66fcabc58ecdd pagerduty/REQUIRED_DEPS.toml 672 16 9e666f39c7301c02 pagerduty/client.rs 2168498 58187 0a0bf3f7f6db74a0 pagerduty/mod.rs 444 17 2edbc04dfe892a50 -pagerduty/types.rs 1696912 40730 4db4dd8ac98e4d1f +pagerduty/types.rs 2001228 47833 ae59d803e5fdea52 perplexity/REQUIRED_DEPS.toml 575 14 1315fbc31599edf0 perplexity/client.rs 53097 1404 2d19a33e7b5739ec perplexity/mod.rs 446 17 1f9103fe36426530 @@ -177,7 +177,7 @@ resend/types.rs 133568 3604 b889f47 retell/REQUIRED_DEPS.toml 528 13 d6f1ac00e2426e4d retell/client.rs 484380 12607 680baab3afcc5495 retell/mod.rs 438 17 e5b430243537a3be -retell/types.rs 1225853 32915 436678419f67417f +retell/types.rs 1402564 37064 bf38d0cc6a8c17e6 runway/REQUIRED_DEPS.toml 672 16 9e666f39c7301c02 runway/client.rs 193999 5310 a6305fb6adc0b2e2 runway/mod.rs 438 17 67ec5d69926d7742 @@ -185,11 +185,11 @@ runway/types.rs 963739 22398 587121e sentry/REQUIRED_DEPS.toml 699 16 4ad61bc1ea5c636a sentry/client.rs 967476 25036 b45ce40a92781f4a sentry/mod.rs 438 17 2bdd2d5164ca444f -sentry/types.rs 2369613 66073 c9cbb5b2d1d6cbef +sentry/types.rs 2416727 67197 4b53aece4b1ec0d1 snyk/REQUIRED_DEPS.toml 688 17 9c8fe0ad1f161983 snyk/client.rs 1858897 48185 59bf01d1ea33bcf7 snyk/mod.rs 434 17 eec62ea6f01f40f6 -snyk/types.rs 2717805 59426 5866664d242459a9 +snyk/types.rs 2765397 60549 b184d38d2143e5c1 spotify/REQUIRED_DEPS.toml 579 14 9277be96334b3809 spotify/client.rs 563501 14797 7303567c6cedfe91 spotify/mod.rs 440 17 954184fb7fd84586 @@ -197,27 +197,27 @@ spotify/types.rs 261914 6374 ec3db89 storyden/REQUIRED_DEPS.toml 698 17 569479aa8197c912 storyden/client.rs 967706 25558 410cd3614dbf2385 storyden/mod.rs 442 17 ff7a510276d50e59 -storyden/types.rs 884799 20120 38b50906e06a668d +storyden/types.rs 886596 20170 d076e244ea989fe9 stripe/REQUIRED_DEPS.toml 601 15 de8d722a8a5cb828 stripe/client.rs 2892965 75106 7ccd0ae049071746 stripe/mod.rs 438 17 cafa363545854cd3 -stripe/types.rs 10112467 248486 fb430bcdf91b98f7 +stripe/types.rs 10203287 250794 9109873b6464e23b supabase/REQUIRED_DEPS.toml 697 17 f2fa59b2eb1837a1 supabase/client.rs 602597 16288 dbc12101b4ae678e supabase/mod.rs 442 17 331ec75d430bc0a8 -supabase/types.rs 476436 13390 306a5b647643e04d +supabase/types.rs 497274 13877 216de877efeaa523 telnyx/REQUIRED_DEPS.toml 787 19 09cccc57db93029b telnyx/client.rs 5180064 136398 704fd2528e41469b telnyx/mod.rs 438 17 f3a44d93d532e621 -telnyx/types.rs 4496512 107989 7d19b35d8678c506 +telnyx/types.rs 4500790 108090 60fb71d96f75bb3a terminal-shop/REQUIRED_DEPS.toml 529 14 241dda1403a4b0ba terminal-shop/client.rs 208120 5561 e896ba15ff57c29a terminal-shop/mod.rs 452 17 7014abd4e66e4e4e -terminal-shop/types.rs 51936 1629 12f624a0585e437b +terminal-shop/types.rs 60865 1840 fd101124906acebd together/REQUIRED_DEPS.toml 762 18 2357560c3a71c3c6 together/client.rs 449115 12000 d6550b6874f460af together/mod.rs 442 17 69fe71a3ff6db06e -together/types.rs 565362 14607 c07c3f2fcae7972a +together/types.rs 666162 16984 b4c354fd6de5fdff twilio/REQUIRED_DEPS.toml 650 16 79c204ac31d0efe3 twilio/client.rs 844086 22156 b1ce00b1b35715a7 twilio/mod.rs 438 17 1e85ee5c08c46793 @@ -229,10 +229,10 @@ val-town/types.rs 68086 1978 78ac613 vercel/REQUIRED_DEPS.toml 673 16 b888661e2699f921 vercel/client.rs 1465596 38567 cc6cfcff17bc5097 vercel/mod.rs 438 17 6e4cb11d3c843a23 -vercel/types.rs 12196079 315218 93755a3f454f3470 +vercel/types.rs 14594591 371124 72e4f1a65ee76ffe writer/REQUIRED_DEPS.toml 720 17 4d0f659a2898dcf4 writer/client.rs 141382 3800 79eabc8c4a13ad16 writer/mod.rs 438 17 0435bf1552e493d1 -writer/types.rs 182479 4509 36b85cce121b9439 +writer/types.rs 196090 4830 84f71e4784ec8728 # -# specs: 56 files: 224 bytes: 313754689 +# specs: 56 files: 224 bytes: 319748776 From ec14c4d981d4e23ef87ceeea8c72f893e32c1183 Mon Sep 17 00:00:00 2001 From: Giles Westwood <201443+gilesw@users.noreply.github.com> Date: Fri, 25 Sep 2026 11:44:54 +0100 Subject: [PATCH 3/7] docs: changelog entry for wire-equivalent union branch selection --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b904701a..da76eac4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 From aee13c576ce70c6f1818245458cf9451071d028a Mon Sep 17 00:00:00 2001 From: James Lal Date: Fri, 25 Sep 2026 06:42:34 -0600 Subject: [PATCH 4/7] fix(generator): reject added keys in union equivalence fallback --- src/generator.rs | 38 ++++++--- ..._helpers__beta_tools_array_union_test.snap | 46 +++++++---- ..._test_helpers__tools_array_union_test.snap | 46 +++++++---- tests/corpus-manifest.txt | 78 +++++++++---------- tests/union_branch_wire_equivalence_test.rs | 46 ++++++++++- 5 files changed, 172 insertions(+), 82 deletions(-) diff --git a/src/generator.rs b/src/generator.rs index dfed403c..b683bc09 100644 --- a/src/generator.rs +++ b/src/generator.rs @@ -3524,7 +3524,9 @@ impl CodeGenerator { matched = Some(Self::#variant_name(candidate)); } Ok(encoded) - if preserves_complete_json_input(&encoded, &input, true) => + if preserves_complete_json_input( + &encoded, &input, true, false, + ) => { equivalent_matches += 1; equivalent.get_or_insert(Self::#variant_name(candidate)); @@ -3541,13 +3543,17 @@ impl CodeGenerator { { match serde_json::to_value(&candidate) { Ok(encoded) - if preserves_complete_json_input(&encoded, &input, false) => + 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) => + && preserves_complete_json_input( + &encoded, &input, true, false, + ) => { equivalent = Some(Self::#variant_name(candidate)); } @@ -3628,24 +3634,31 @@ impl CodeGenerator { } /// `nulls_may_be_absent` also accepts an input `null` that the - /// branch omits, as a skipped `None` does. + /// 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)| match encoded.get(key) { - Some(encoded_value) => preserves_complete_json_input( - encoded_value, - value, - nulls_may_be_absent, - ), - None => nulls_may_be_absent && value.is_null(), - }), + ) => { + (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), @@ -3656,6 +3669,7 @@ impl CodeGenerator { encoded, input, nulls_may_be_absent, + encoded_keys_may_be_extra, ) }) } diff --git a/src/snapshots/openapi_to_rust__test_helpers__beta_tools_array_union_test.snap b/src/snapshots/openapi_to_rust__test_helpers__beta_tools_array_union_test.snap index 8d3a3bce..2a773863 100644 --- a/src/snapshots/openapi_to_rust__test_helpers__beta_tools_array_union_test.snap +++ b/src/snapshots/openapi_to_rust__test_helpers__beta_tools_array_union_test.snap @@ -76,29 +76,34 @@ impl<'de> Deserialize<'de> for BetaCreateMessageParamsToolsItemUnion { } } /// `nulls_may_be_absent` also accepts an input `null` that the - /// branch omits, as a skipped `None` does. + /// 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)| match encoded.get(key) { - Some(encoded_value) => { - preserves_complete_json_input( - encoded_value, - value, - nulls_may_be_absent, - ) - } - None => nulls_may_be_absent && value.is_null(), - }) + (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() @@ -110,6 +115,7 @@ impl<'de> Deserialize<'de> for BetaCreateMessageParamsToolsItemUnion { encoded, input, nulls_may_be_absent, + encoded_keys_may_be_extra, ) }) } @@ -154,7 +160,12 @@ impl<'de> Deserialize<'de> for BetaCreateMessageParamsToolsItemUnion { } Ok( encoded, - ) if preserves_complete_json_input(&encoded, &input, true) => { + ) if preserves_complete_json_input( + &encoded, + &input, + true, + false, + ) => { equivalent_matches += 1; equivalent.get_or_insert(Self::BetaTool(candidate)); } @@ -196,7 +207,12 @@ impl<'de> Deserialize<'de> for BetaCreateMessageParamsToolsItemUnion { } Ok( encoded, - ) if preserves_complete_json_input(&encoded, &input, true) => { + ) if preserves_complete_json_input( + &encoded, + &input, + true, + false, + ) => { equivalent_matches += 1; equivalent.get_or_insert(Self::BetaComputerUseTool(candidate)); } diff --git a/src/snapshots/openapi_to_rust__test_helpers__tools_array_union_test.snap b/src/snapshots/openapi_to_rust__test_helpers__tools_array_union_test.snap index 20008095..f1d02d0d 100644 --- a/src/snapshots/openapi_to_rust__test_helpers__tools_array_union_test.snap +++ b/src/snapshots/openapi_to_rust__test_helpers__tools_array_union_test.snap @@ -74,29 +74,34 @@ impl<'de> Deserialize<'de> for CreateMessageParamsToolsItemUnion { } } /// `nulls_may_be_absent` also accepts an input `null` that the - /// branch omits, as a skipped `None` does. + /// 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)| match encoded.get(key) { - Some(encoded_value) => { - preserves_complete_json_input( - encoded_value, - value, - nulls_may_be_absent, - ) - } - None => nulls_may_be_absent && value.is_null(), - }) + (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() @@ -108,6 +113,7 @@ impl<'de> Deserialize<'de> for CreateMessageParamsToolsItemUnion { encoded, input, nulls_may_be_absent, + encoded_keys_may_be_extra, ) }) } @@ -152,7 +158,12 @@ impl<'de> Deserialize<'de> for CreateMessageParamsToolsItemUnion { } Ok( encoded, - ) if preserves_complete_json_input(&encoded, &input, true) => { + ) if preserves_complete_json_input( + &encoded, + &input, + true, + false, + ) => { equivalent_matches += 1; equivalent.get_or_insert(Self::Tool(candidate)); } @@ -190,7 +201,12 @@ impl<'de> Deserialize<'de> for CreateMessageParamsToolsItemUnion { } Ok( encoded, - ) if preserves_complete_json_input(&encoded, &input, true) => { + ) if preserves_complete_json_input( + &encoded, + &input, + true, + false, + ) => { equivalent_matches += 1; equivalent.get_or_insert(Self::BashTool(candidate)); } diff --git a/tests/corpus-manifest.txt b/tests/corpus-manifest.txt index 34170b46..f602c2a9 100644 --- a/tests/corpus-manifest.txt +++ b/tests/corpus-manifest.txt @@ -13,7 +13,7 @@ anthropic/REQUIRED_DEPS.toml 669 16 a7f60b37a7334009 anthropic/client.rs 231071 6019 5e61b6d56d71c190 anthropic/mod.rs 444 17 5214bdbc37b918db -anthropic/types.rs 4199162 86655 3951fb7693cb7e7c +anthropic/types.rs 4212914 87145 fab1ac6f632f1101 arcade/REQUIRED_DEPS.toml 528 13 d6f1ac00e2426e4d arcade/client.rs 213784 5711 5b3e2de35fcec738 arcade/mod.rs 438 17 b55cb3acf5fdbd02 @@ -21,11 +21,11 @@ arcade/types.rs 105169 2609 7717f64 asana/REQUIRED_DEPS.toml 699 16 4ad61bc1ea5c636a asana/client.rs 1947805 49122 5c448775f672432b asana/mod.rs 436 17 2390079a5e4e9f74 -asana/types.rs 617712 13450 dcd63de341ad6d9a +asana/types.rs 619164 13468 f5722207ddaa92d4 box/REQUIRED_DEPS.toml 771 18 5dbdfe553bf9dbeb box/client.rs 1634689 42619 1af7286b76706f61 box/mod.rs 432 17 b40589a066c4b98b -box/types.rs 1263021 32757 2141d91cf746026d +box/types.rs 1280767 33214 50cca14a0563c164 browserbase/REQUIRED_DEPS.toml 699 16 4ad61bc1ea5c636a browserbase/client.rs 89262 2448 5889d5faaecdea5f browserbase/mod.rs 448 17 7e721aa1bb802e26 @@ -33,51 +33,51 @@ browserbase/types.rs 50190 1258 8a533e9 cal-com/REQUIRED_DEPS.toml 625 15 0e0871569054a9ee cal-com/client.rs 1281022 33372 557d0f7f541c4d26 cal-com/mod.rs 440 17 05fb649563c82805 -cal-com/types.rs 1793874 44912 cac7460c9438bc01 +cal-com/types.rs 1869078 47094 afddc8676879915b cartesia/REQUIRED_DEPS.toml 669 16 a7f60b37a7334009 cartesia/client.rs 250746 6884 b0055cd12698d427 cartesia/mod.rs 442 17 3c5001b7641597a2 -cartesia/types.rs 229108 6270 1c5ac461f686b0a6 +cartesia/types.rs 232348 6354 99d9eeaf841fc4de cerebras/REQUIRED_DEPS.toml 528 13 d6f1ac00e2426e4d cerebras/client.rs 41549 1112 2412d7e10c4deb73 cerebras/mod.rs 442 17 71f9d1b20905ad16 -cerebras/types.rs 229803 5905 6c66ff309c6c0acb +cerebras/types.rs 231216 5923 aad1193fbff6a57a circleci/REQUIRED_DEPS.toml 626 15 082a47f05075776b circleci/client.rs 610986 15850 37c84ac5ec04028b circleci/mod.rs 442 17 7a8b5691342a9d62 -circleci/types.rs 380498 10004 de2bd2d0d4afa641 +circleci/types.rs 384614 10072 6f277085e1ad9894 cloudflare/REQUIRED_DEPS.toml 787 19 09cccc57db93029b cloudflare/client.rs 13273986 347587 7aab352c5f1cc4f6 cloudflare/mod.rs 446 17 ce0e733f646147da -cloudflare/types.rs 16661076 388469 71a3394c8ec3fd6b +cloudflare/types.rs 16790812 390125 6bd50717aaf4be4f coda/REQUIRED_DEPS.toml 672 16 9e666f39c7301c02 coda/client.rs 847692 21836 2d23862bfc0441d3 coda/mod.rs 434 17 4e8b99caec52b623 -coda/types.rs 2003954 42267 0696607b4c06192f +coda/types.rs 2006132 42320 45d0edc024fb479c coingecko/REQUIRED_DEPS.toml 579 14 9277be96334b3809 coingecko/client.rs 446994 12325 acceac4d0311c48d coingecko/mod.rs 444 17 0059623eabdef914 -coingecko/types.rs 278084 6598 4c1ff89652d0ed22 +coingecko/types.rs 278768 6614 eb3ec8a86a40b025 datadog-v2/REQUIRED_DEPS.toml 700 16 e697f0e5afafb53b datadog-v2/client.rs 5195253 134571 5a762162882dc603 datadog-v2/mod.rs 446 17 56e45841c9753096 -datadog-v2/types.rs 3813671 96042 a2abeb1cf6983e46 +datadog-v2/types.rs 3897209 98343 90bb9c59aa8f6c03 digitalocean/REQUIRED_DEPS.toml 736 18 21727a31b033ca36 digitalocean/client.rs 3795542 97732 0b73de52eb6426cf digitalocean/mod.rs 450 17 3b99e8d4d3161f03 -digitalocean/types.rs 1539079 36250 161277440070aa17 +digitalocean/types.rs 1547978 36420 4a11b33ae19ef2bb discord/REQUIRED_DEPS.toml 699 16 4ad61bc1ea5c636a discord/client.rs 1051791 27494 53240372d9aa872b discord/mod.rs 440 17 1d9f7eee6c834b1c -discord/types.rs 1753307 44066 185795506f8e6d0a +discord/types.rs 1843460 46571 63bbe5e6b4fc30d4 gcore/REQUIRED_DEPS.toml 746 17 7f467fcbfe949464 gcore/client.rs 5111572 134940 841b98a2c23ff598 gcore/mod.rs 436 17 d5c05d8821f23bcb -gcore/types.rs 5981948 139780 baf529c2bd2f725e +gcore/types.rs 6016429 140206 f72406948cf03e54 github/REQUIRED_DEPS.toml 625 15 0e0871569054a9ee github/client.rs 5743897 145449 0c8c194850179046 github/mod.rs 438 17 ac4d40f51fb06aa3 -github/types.rs 8016770 214825 a18ec1777da11b93 +github/types.rs 8074296 216309 7d88c9976ac1c061 gitpod/REQUIRED_DEPS.toml 736 18 21727a31b033ca36 gitpod/client.rs 1591035 42786 9e006b8683cf2cce gitpod/mod.rs 438 17 516880570ce5294f @@ -109,11 +109,11 @@ grafana/types.rs 391878 9248 e82c5cd groq/REQUIRED_DEPS.toml 630 15 7935666e65c0812e groq/client.rs 92406 2483 d5928b065aa9994d groq/mod.rs 434 17 72fd6d54577d5597 -groq/types.rs 387393 9726 f67150f59e2296bc +groq/types.rs 389823 9789 a01c6e96836c8b5a imagekit/REQUIRED_DEPS.toml 673 16 b888661e2699f921 imagekit/client.rs 335861 8650 9c69f4ef8efa79bd imagekit/mod.rs 442 17 40484a363f5391c3 -imagekit/types.rs 721621 15922 f28adeb7b482f7e2 +imagekit/types.rs 722305 15938 42c68227a7c26c12 increase/REQUIRED_DEPS.toml 653 15 715b930adfa76491 increase/client.rs 1284242 32563 07d535545ac049fc increase/mod.rs 442 17 f6131a8548853a65 @@ -121,27 +121,27 @@ increase/types.rs 2544062 56883 63883f4 knocklabs/REQUIRED_DEPS.toml 672 16 9e666f39c7301c02 knocklabs/client.rs 476515 12531 83d28a7a3cd9fe00 knocklabs/mod.rs 444 17 7822049ab73a5ed0 -knocklabs/types.rs 310181 7613 ed61358acbeaf527 +knocklabs/types.rs 319415 7868 9d40ef33a7a9d3c0 langsmith/REQUIRED_DEPS.toml 746 17 7f467fcbfe949464 langsmith/client.rs 2201458 57231 afa9d3fc9aadb2ab langsmith/mod.rs 444 17 eb5007d340d34a8e -langsmith/types.rs 1181008 33067 5e793aca6feef190 +langsmith/types.rs 1187990 33199 e544041330c4acc6 launchdarkly/REQUIRED_DEPS.toml 700 16 e697f0e5afafb53b launchdarkly/client.rs 2523958 64225 7531d5cd5b144114 launchdarkly/mod.rs 450 17 d4ed5579304ccd39 -launchdarkly/types.rs 742114 18479 2e2fb75dbc69e91c +launchdarkly/types.rs 743840 18512 0c2344469fafd51e letta/REQUIRED_DEPS.toml 746 17 7f467fcbfe949464 letta/client.rs 1413756 38041 7d7998bf850bd64e letta/mod.rs 436 17 5cc3226a7c317d2a -letta/types.rs 6937642 141281 30f77c6fb40480f3 +letta/types.rs 6955920 141632 ef6251b6ee3d7f91 lithic/REQUIRED_DEPS.toml 688 17 9c8fe0ad1f161983 lithic/client.rs 1351876 35356 60aebed1624e10a4 lithic/mod.rs 438 17 eba70abf1f172ff1 -lithic/types.rs 1670502 40511 020b606a94213fdd +lithic/types.rs 1681235 40761 2b07c4477b1d8d8a luma/REQUIRED_DEPS.toml 672 16 9e666f39c7301c02 luma/client.rs 59518 1615 5f1fe1b8e87ef5ea luma/mod.rs 434 17 d85018b8cdd8f625 -luma/types.rs 81534 2252 c82ae0a26d76cbc2 +luma/types.rs 82848 2293 4dd743ed513956f8 meta-llama/REQUIRED_DEPS.toml 630 15 7935666e65c0812e meta-llama/client.rs 45990 1233 bafec8b0572aae0d meta-llama/mod.rs 446 17 6e485725d643b7c2 @@ -153,19 +153,19 @@ microsoft-graph/types.rs 14189944 351429 700bb06 modern-treasury/REQUIRED_DEPS.toml 746 17 7f467fcbfe949464 modern-treasury/client.rs 955625 25673 fcda40112812f7c6 modern-treasury/mod.rs 456 17 5f336a50419a709b -modern-treasury/types.rs 1003025 26835 d6f2c3608bea3356 +modern-treasury/types.rs 1005013 26859 4617c87b9896c411 openai/REQUIRED_DEPS.toml 648 15 a3db003e7f3781e2 openai/client.rs 1045856 28439 318fe79140e5b91a openai/mod.rs 438 17 59c0207149335235 -openai/types.rs 6324034 142727 81cdb39c3be00ba2 +openai/types.rs 6329163 142793 e9466bc79da37e1f opencode/REQUIRED_DEPS.toml 576 14 9228e95d38f5078e opencode/client.rs 943362 25095 d0a641e0a3bb0d27 opencode/mod.rs 442 17 98c109eb94b95dbf -opencode/types.rs 6794849 145877 edc66fcabc58ecdd +opencode/types.rs 6820664 146287 4e38f9d8cbd1024e pagerduty/REQUIRED_DEPS.toml 672 16 9e666f39c7301c02 pagerduty/client.rs 2168498 58187 0a0bf3f7f6db74a0 pagerduty/mod.rs 444 17 2edbc04dfe892a50 -pagerduty/types.rs 2001228 47833 ae59d803e5fdea52 +pagerduty/types.rs 2058127 49376 e7800d4fc1435ac1 perplexity/REQUIRED_DEPS.toml 575 14 1315fbc31599edf0 perplexity/client.rs 53097 1404 2d19a33e7b5739ec perplexity/mod.rs 446 17 1f9103fe36426530 @@ -177,7 +177,7 @@ resend/types.rs 133568 3604 b889f47 retell/REQUIRED_DEPS.toml 528 13 d6f1ac00e2426e4d retell/client.rs 484380 12607 680baab3afcc5495 retell/mod.rs 438 17 e5b430243537a3be -retell/types.rs 1402564 37064 bf38d0cc6a8c17e6 +retell/types.rs 1433974 37887 a806dd555441d377 runway/REQUIRED_DEPS.toml 672 16 9e666f39c7301c02 runway/client.rs 193999 5310 a6305fb6adc0b2e2 runway/mod.rs 438 17 67ec5d69926d7742 @@ -185,11 +185,11 @@ runway/types.rs 963739 22398 587121e sentry/REQUIRED_DEPS.toml 699 16 4ad61bc1ea5c636a sentry/client.rs 967476 25036 b45ce40a92781f4a sentry/mod.rs 438 17 2bdd2d5164ca444f -sentry/types.rs 2416727 67197 4b53aece4b1ec0d1 +sentry/types.rs 2425755 67391 52e3ff5da30d5d72 snyk/REQUIRED_DEPS.toml 688 17 9c8fe0ad1f161983 snyk/client.rs 1858897 48185 59bf01d1ea33bcf7 snyk/mod.rs 434 17 eec62ea6f01f40f6 -snyk/types.rs 2765397 60549 b184d38d2143e5c1 +snyk/types.rs 2774048 60756 f61ac8673b6cdcef spotify/REQUIRED_DEPS.toml 579 14 9277be96334b3809 spotify/client.rs 563501 14797 7303567c6cedfe91 spotify/mod.rs 440 17 954184fb7fd84586 @@ -197,27 +197,27 @@ spotify/types.rs 261914 6374 ec3db89 storyden/REQUIRED_DEPS.toml 698 17 569479aa8197c912 storyden/client.rs 967706 25558 410cd3614dbf2385 storyden/mod.rs 442 17 ff7a510276d50e59 -storyden/types.rs 886596 20170 d076e244ea989fe9 +storyden/types.rs 887093 20176 96f969322e485cda stripe/REQUIRED_DEPS.toml 601 15 de8d722a8a5cb828 stripe/client.rs 2892965 75106 7ccd0ae049071746 stripe/mod.rs 438 17 cafa363545854cd3 -stripe/types.rs 10203287 250794 9109873b6464e23b +stripe/types.rs 10236263 251226 b873858074f50db2 supabase/REQUIRED_DEPS.toml 697 17 f2fa59b2eb1837a1 supabase/client.rs 602597 16288 dbc12101b4ae678e supabase/mod.rs 442 17 331ec75d430bc0a8 -supabase/types.rs 497274 13877 216de877efeaa523 +supabase/types.rs 501018 13981 a90e47b6926d92a1 telnyx/REQUIRED_DEPS.toml 787 19 09cccc57db93029b telnyx/client.rs 5180064 136398 704fd2528e41469b telnyx/mod.rs 438 17 f3a44d93d532e621 -telnyx/types.rs 4500790 108090 60fb71d96f75bb3a +telnyx/types.rs 4501474 108106 c72e842eb8b25f1c terminal-shop/REQUIRED_DEPS.toml 529 14 241dda1403a4b0ba terminal-shop/client.rs 208120 5561 e896ba15ff57c29a terminal-shop/mod.rs 452 17 7014abd4e66e4e4e -terminal-shop/types.rs 60865 1840 fd101124906acebd +terminal-shop/types.rs 62233 1872 5c0e2ce07125c290 together/REQUIRED_DEPS.toml 762 18 2357560c3a71c3c6 together/client.rs 449115 12000 d6550b6874f460af together/mod.rs 442 17 69fe71a3ff6db06e -together/types.rs 666162 16984 b4c354fd6de5fdff +together/types.rs 682848 17401 7f831820a1cb4aae twilio/REQUIRED_DEPS.toml 650 16 79c204ac31d0efe3 twilio/client.rs 844086 22156 b1ce00b1b35715a7 twilio/mod.rs 438 17 1e85ee5c08c46793 @@ -229,10 +229,10 @@ val-town/types.rs 68086 1978 78ac613 vercel/REQUIRED_DEPS.toml 673 16 b888661e2699f921 vercel/client.rs 1465596 38567 cc6cfcff17bc5097 vercel/mod.rs 438 17 6e4cb11d3c843a23 -vercel/types.rs 14594591 371124 72e4f1a65ee76ffe +vercel/types.rs 15015299 382685 9f681e13cfe35bd3 writer/REQUIRED_DEPS.toml 720 17 4d0f659a2898dcf4 writer/client.rs 141382 3800 79eabc8c4a13ad16 writer/mod.rs 438 17 0435bf1552e493d1 -writer/types.rs 196090 4830 84f71e4784ec8728 +writer/types.rs 198394 4888 800dd8afe5798530 # -# specs: 56 files: 224 bytes: 319748776 +# specs: 56 files: 224 bytes: 320942162 diff --git a/tests/union_branch_wire_equivalence_test.rs b/tests/union_branch_wire_equivalence_test.rs index 3b9fe4f7..5eead90c 100644 --- a/tests/union_branch_wire_equivalence_test.rs +++ b/tests/union_branch_wire_equivalence_test.rs @@ -74,6 +74,26 @@ fn spec() -> Value { { "$ref": "#/components/schemas/IntegerCount" }, { "$ref": "#/components/schemas/NumberCount" } ] + }, + "DefaultedBranch": { + "type": "object", "required": ["mode"], + "properties": { "mode": { "type": "string", "default": "active" } } + }, + "OtherBranch": { + "type": "object", "required": ["id"], + "properties": { "id": { "type": "string" } } + }, + "DefaultedUnion": { + "oneOf": [ + { "$ref": "#/components/schemas/DefaultedBranch" }, + { "$ref": "#/components/schemas/OtherBranch" } + ] + }, + "DefaultedAny": { + "anyOf": [ + { "$ref": "#/components/schemas/DefaultedBranch" }, + { "$ref": "#/components/schemas/OtherBranch" } + ] } } } }) @@ -120,7 +140,7 @@ publish = false #[cfg(test)] mod tests { - use super::generated::{Count, Record, RecordResponse}; + use super::generated::{Count, DefaultedAny, DefaultedUnion, Record, RecordResponse}; use serde_json::json; #[test] @@ -174,6 +194,30 @@ mod tests { "only the `number` branch can hold `1.5`: {count:?}" ); } + + #[test] + fn an_added_output_key_is_not_wire_equivalent() { + // A required property with a schema default gets #[serde(default)]. + // It can deserialize an absent property, then serializes it back as + // an added key. That is neither of the wire differences allowed by + // the oneOf fallback. + let result = serde_json::from_value::(json!({})); + assert!(result.is_err(), "a branch must not add a missing required key: {result:?}"); + + let input = json!({"comment": null}); + assert!( + serde_json::from_value::(input.clone()).is_err(), + "a null input field must not hide an added output key in oneOf" + ); + assert!( + serde_json::from_value::(input).is_err(), + "a null input field must not hide an added output key in anyOf" + ); + + let exact: DefaultedUnion = serde_json::from_value(json!({"mode": "active"})) + .expect("an exact defaulted branch still decodes"); + assert!(matches!(exact, DefaultedUnion::DefaultedBranch(_))); + } } "#, ) From d557aa07ee4e01a759643e4945aeb64df9d6358e Mon Sep 17 00:00:00 2001 From: James Lal Date: Fri, 25 Sep 2026 11:21:01 -0600 Subject: [PATCH 5/7] fix(openapi): preserve nullable union branch properties Treat nullable anyOf branches as admitting null and respect oneOf uniqueness when inferring property nullability. Cover Cloudflare-style required null results and refresh generated corpus expectations. --- src/openapi.rs | 40 +++++++++++++++++++-- tests/corpus-manifest.txt | 18 +++++----- tests/cu5_22_explicit_null_union_tests.rs | 8 ++--- tests/recoverable_typing_test.rs | 6 ++-- tests/union_branch_wire_equivalence_test.rs | 20 ++++++++++- 5 files changed, 73 insertions(+), 19 deletions(-) diff --git a/src/openapi.rs b/src/openapi.rs index a4208282..91a92ea3 100644 --- a/src/openapi.rs +++ b/src/openapi.rs @@ -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 @@ -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 @@ -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})] { diff --git a/tests/corpus-manifest.txt b/tests/corpus-manifest.txt index f602c2a9..b284e590 100644 --- a/tests/corpus-manifest.txt +++ b/tests/corpus-manifest.txt @@ -25,7 +25,7 @@ asana/types.rs 619164 13468 f572220 box/REQUIRED_DEPS.toml 771 18 5dbdfe553bf9dbeb box/client.rs 1634689 42619 1af7286b76706f61 box/mod.rs 432 17 b40589a066c4b98b -box/types.rs 1280767 33214 50cca14a0563c164 +box/types.rs 1281818 33255 9ab3c662ec355a13 browserbase/REQUIRED_DEPS.toml 699 16 4ad61bc1ea5c636a browserbase/client.rs 89262 2448 5889d5faaecdea5f browserbase/mod.rs 448 17 7e721aa1bb802e26 @@ -49,7 +49,7 @@ circleci/types.rs 384614 10072 6f27708 cloudflare/REQUIRED_DEPS.toml 787 19 09cccc57db93029b cloudflare/client.rs 13273986 347587 7aab352c5f1cc4f6 cloudflare/mod.rs 446 17 ce0e733f646147da -cloudflare/types.rs 16790812 390125 6bd50717aaf4be4f +cloudflare/types.rs 16792851 390215 d20ffe23076f5949 coda/REQUIRED_DEPS.toml 672 16 9e666f39c7301c02 coda/client.rs 847692 21836 2d23862bfc0441d3 coda/mod.rs 434 17 4e8b99caec52b623 @@ -65,7 +65,7 @@ datadog-v2/types.rs 3897209 98343 90bb9c5 digitalocean/REQUIRED_DEPS.toml 736 18 21727a31b033ca36 digitalocean/client.rs 3795542 97732 0b73de52eb6426cf digitalocean/mod.rs 450 17 3b99e8d4d3161f03 -digitalocean/types.rs 1547978 36420 4a11b33ae19ef2bb +digitalocean/types.rs 1548370 36436 59bc59352b81284a discord/REQUIRED_DEPS.toml 699 16 4ad61bc1ea5c636a discord/client.rs 1051791 27494 53240372d9aa872b discord/mod.rs 440 17 1d9f7eee6c834b1c @@ -77,7 +77,7 @@ gcore/types.rs 6016429 140206 f724069 github/REQUIRED_DEPS.toml 625 15 0e0871569054a9ee github/client.rs 5743897 145449 0c8c194850179046 github/mod.rs 438 17 ac4d40f51fb06aa3 -github/types.rs 8074296 216309 7d88c9976ac1c061 +github/types.rs 8074811 216335 e5581b80f37b94e4 gitpod/REQUIRED_DEPS.toml 736 18 21727a31b033ca36 gitpod/client.rs 1591035 42786 9e006b8683cf2cce gitpod/mod.rs 438 17 516880570ce5294f @@ -121,7 +121,7 @@ increase/types.rs 2544062 56883 63883f4 knocklabs/REQUIRED_DEPS.toml 672 16 9e666f39c7301c02 knocklabs/client.rs 476515 12531 83d28a7a3cd9fe00 knocklabs/mod.rs 444 17 7822049ab73a5ed0 -knocklabs/types.rs 319415 7868 9d40ef33a7a9d3c0 +knocklabs/types.rs 325347 8096 d474ca0437a7852e langsmith/REQUIRED_DEPS.toml 746 17 7f467fcbfe949464 langsmith/client.rs 2201458 57231 afa9d3fc9aadb2ab langsmith/mod.rs 444 17 eb5007d340d34a8e @@ -149,7 +149,7 @@ meta-llama/types.rs 97541 2408 2bd9da2 microsoft-graph/REQUIRED_DEPS.toml 674 16 e864ec8321bc90dc microsoft-graph/client.rs 104447173 2552572 d70926e24f6f6016 microsoft-graph/mod.rs 456 17 b99d7781b4460b5d -microsoft-graph/types.rs 14189944 351429 700bb06df11c5302 +microsoft-graph/types.rs 15241507 390543 ee7db4cd80762807 modern-treasury/REQUIRED_DEPS.toml 746 17 7f467fcbfe949464 modern-treasury/client.rs 955625 25673 fcda40112812f7c6 modern-treasury/mod.rs 456 17 5f336a50419a709b @@ -185,7 +185,7 @@ runway/types.rs 963739 22398 587121e sentry/REQUIRED_DEPS.toml 699 16 4ad61bc1ea5c636a sentry/client.rs 967476 25036 b45ce40a92781f4a sentry/mod.rs 438 17 2bdd2d5164ca444f -sentry/types.rs 2425755 67391 52e3ff5da30d5d72 +sentry/types.rs 2425940 67408 f2618f6b9437854c snyk/REQUIRED_DEPS.toml 688 17 9c8fe0ad1f161983 snyk/client.rs 1858897 48185 59bf01d1ea33bcf7 snyk/mod.rs 434 17 eec62ea6f01f40f6 @@ -229,10 +229,10 @@ val-town/types.rs 68086 1978 78ac613 vercel/REQUIRED_DEPS.toml 673 16 b888661e2699f921 vercel/client.rs 1465596 38567 cc6cfcff17bc5097 vercel/mod.rs 438 17 6e4cb11d3c843a23 -vercel/types.rs 15015299 382685 9f681e13cfe35bd3 +vercel/types.rs 15015397 382689 370b8af3b9279921 writer/REQUIRED_DEPS.toml 720 17 4d0f659a2898dcf4 writer/client.rs 141382 3800 79eabc8c4a13ad16 writer/mod.rs 438 17 0435bf1552e493d1 writer/types.rs 198394 4888 800dd8afe5798530 # -# specs: 56 files: 224 bytes: 320942162 +# specs: 56 files: 224 bytes: 322003937 diff --git a/tests/cu5_22_explicit_null_union_tests.rs b/tests/cu5_22_explicit_null_union_tests.rs index ec378ee0..a65163f6 100644 --- a/tests/cu5_22_explicit_null_union_tests.rs +++ b/tests/cu5_22_explicit_null_union_tests.rs @@ -93,10 +93,10 @@ fn nullable_true_branches_remain_real_union_alternatives() { let (_, generated) = generate(union_spec(), temp.path().join("generated")); for expected in [ - "pub struct AnyRequired {\n pub value: AnyRequiredValue,", - "pub value: Option", - "pub struct OneRequired {\n pub value: OneRequiredValue,", - "pub value: Option", + "pub struct AnyRequired {\n pub value: Option,", + "pub value: Option>", + "pub struct OneRequired {\n pub value: Option,", + "pub value: Option>", ] { assert!( generated.contains(expected), diff --git a/tests/recoverable_typing_test.rs b/tests/recoverable_typing_test.rs index 2f674ccf..27067655 100644 --- a/tests/recoverable_typing_test.rs +++ b/tests/recoverable_typing_test.rs @@ -75,8 +75,8 @@ fn odata_nullable_reference_union_keeps_its_literal_object_branch() { // The branch declares `type: object`, so its carrier is a map rather than // `serde_json::Value`: an untagged branch typed as `Value` also matches // strings, numbers and arrays that belong to a different branch. The - // explicit `null` the branch admits is still carried by the field's own - // `Option`. + // explicit `null` the branch admits is still carried by the field's + // `Option>`, separately from an absent property. assert_types( spec_with_schemas(json!({ "User": { "type": "object", "additionalProperties": false, @@ -89,7 +89,7 @@ fn odata_nullable_reference_union_keeps_its_literal_object_branch() { }} })), &[ - "pub user: Option", + "pub user: Option>", "pub enum MemberUser", "pub type MemberVariant2 = std::collections::BTreeMap<", ], diff --git a/tests/union_branch_wire_equivalence_test.rs b/tests/union_branch_wire_equivalence_test.rs index 5eead90c..9866087c 100644 --- a/tests/union_branch_wire_equivalence_test.rs +++ b/tests/union_branch_wire_equivalence_test.rs @@ -94,6 +94,15 @@ fn spec() -> Value { { "$ref": "#/components/schemas/DefaultedBranch" }, { "$ref": "#/components/schemas/OtherBranch" } ] + }, + "NullableAnyResponse": { + "type": "object", "required": ["result"], + "properties": { "result": { + "anyOf": [ + { "type": "object", "nullable": true }, + { "type": "string", "nullable": true } + ] + } } } } } }) @@ -140,7 +149,7 @@ publish = false #[cfg(test)] mod tests { - use super::generated::{Count, DefaultedAny, DefaultedUnion, Record, RecordResponse}; + use super::generated::{Count, DefaultedAny, DefaultedUnion, NullableAnyResponse, Record, RecordResponse}; use serde_json::json; #[test] @@ -218,6 +227,15 @@ mod tests { .expect("an exact defaulted branch still decodes"); assert!(matches!(exact, DefaultedUnion::DefaultedBranch(_))); } + + #[test] + fn a_required_nullable_anyof_property_roundtrips_null() { + let input = json!({"result": null}); + let response: NullableAnyResponse = serde_json::from_value(input.clone()) + .expect("a nullable anyOf branch admits null"); + assert!(response.result.is_none()); + assert_eq!(serde_json::to_value(response).unwrap(), input); + } } "#, ) From 27a215ea2d4a31b0c923071df0b852849a98fd3c Mon Sep 17 00:00:00 2001 From: James Lal Date: Fri, 25 Sep 2026 11:23:21 -0600 Subject: [PATCH 6/7] ci: reduce debug memory in full spec compile Compile generated scratch crates without debug symbols or incremental artifacts so Cloudflare can fit on a standard GitHub runner. --- .github/workflows/ci.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ad91f67b..53c58b5f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -223,6 +223,11 @@ jobs: timeout-minutes: 240 env: SPEC_COMPILE_TARGET_DIR: ${{ github.workspace }}/target/spec-compile + # Cloudflare's generated roundtrip test exceeds runner memory with + # default debug artifacts; these checks do not need debug symbols. + CARGO_PROFILE_DEV_DEBUG: 0 + CARGO_PROFILE_TEST_DEBUG: 0 + CARGO_INCREMENTAL: 0 steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable From 91cf25e71948413bddc82293e8bbccc75dbb720b Mon Sep 17 00:00:00 2001 From: James Lal Date: Fri, 25 Sep 2026 12:02:44 -0600 Subject: [PATCH 7/7] Revert "ci: reduce debug memory in full spec compile" This reverts commit 27a215ea2d4a31b0c923071df0b852849a98fd3c. --- .github/workflows/ci.yml | 5 ----- 1 file changed, 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 53c58b5f..ad91f67b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -223,11 +223,6 @@ jobs: timeout-minutes: 240 env: SPEC_COMPILE_TARGET_DIR: ${{ github.workspace }}/target/spec-compile - # Cloudflare's generated roundtrip test exceeds runner memory with - # default debug artifacts; these checks do not need debug symbols. - CARGO_PROFILE_DEV_DEBUG: 0 - CARGO_PROFILE_TEST_DEBUG: 0 - CARGO_INCREMENTAL: 0 steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable