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/3] 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/3] 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/3] 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