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 diff --git a/src/generator.rs b/src/generator.rs index cdab4c6b..b683bc09 100644 --- a/src/generator.rs +++ b/src/generator.rs @@ -3512,18 +3512,26 @@ impl CodeGenerator { if let Ok(candidate) = serde_json::from_value::<#variant_type>(input.clone()) { - let preserves_complete_input = serde_json::to_value(&candidate) - .map(|encoded| encoded == input) - .unwrap_or(false); - if preserves_complete_input { - if matched.is_some() { - return Err(serde::de::Error::custom(concat!( - "ambiguous oneOf value for ", - stringify!(#enum_name), - ": more than one branch preserved the complete input", - ))); + match serde_json::to_value(&candidate) { + Ok(encoded) if encoded == input => { + if matched.is_some() { + return Err(serde::de::Error::custom(concat!( + "ambiguous oneOf value for ", + stringify!(#enum_name), + ": more than one branch preserved the complete input", + ))); + } + matched = Some(Self::#variant_name(candidate)); } - matched = Some(Self::#variant_name(candidate)); + Ok(encoded) + if preserves_complete_json_input( + &encoded, &input, true, false, + ) => + { + equivalent_matches += 1; + equivalent.get_or_insert(Self::#variant_name(candidate)); + } + _ => {} } } } @@ -3533,22 +3541,46 @@ impl CodeGenerator { if let Ok(candidate) = serde_json::from_value::<#variant_type>(input.clone()) { - let preserves_complete_input = serde_json::to_value(&candidate) - .map(|encoded| { - preserves_complete_json_input(&encoded, &input) - }) - .unwrap_or(false); - if preserves_complete_input { - return Ok(Self::#variant_name(candidate)); + match serde_json::to_value(&candidate) { + Ok(encoded) + if preserves_complete_json_input( + &encoded, &input, false, true, + ) => + { + return Ok(Self::#variant_name(candidate)); + } + Ok(encoded) + if equivalent.is_none() + && preserves_complete_json_input( + &encoded, &input, true, false, + ) => + { + equivalent = Some(Self::#variant_name(candidate)); + } + _ => {} } } } } }) .collect::>(); + // 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 +3588,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,26 +3633,44 @@ impl CodeGenerator { } } + /// `nulls_may_be_absent` also accepts an input `null` that the + /// branch omits, as a skipped `None` does. Extra encoded + /// keys are allowed only by the pre-existing anyOf match. fn preserves_complete_json_input( encoded: &serde_json::Value, input: &serde_json::Value, + nulls_may_be_absent: bool, + encoded_keys_may_be_extra: bool, ) -> bool { match (encoded, input) { ( serde_json::Value::Object(encoded), serde_json::Value::Object(input), - ) => input.iter().all(|(key, value)| { - encoded.get(key).is_some_and(|encoded_value| { - preserves_complete_json_input(encoded_value, value) - }) - }), + ) => { + (encoded_keys_may_be_extra + || encoded.keys().all(|key| input.contains_key(key))) + && input.iter().all(|(key, value)| match encoded.get(key) { + Some(encoded_value) => preserves_complete_json_input( + encoded_value, + value, + nulls_may_be_absent, + encoded_keys_may_be_extra, + ), + None => nulls_may_be_absent && value.is_null(), + }) + } ( serde_json::Value::Array(encoded), serde_json::Value::Array(input), ) => { encoded.len() == input.len() && encoded.iter().zip(input).all(|(encoded, input)| { - preserves_complete_json_input(encoded, input) + preserves_complete_json_input( + encoded, + input, + nulls_may_be_absent, + encoded_keys_may_be_extra, + ) }) } ( @@ -3623,7 +3681,7 @@ impl CodeGenerator { } } } - }); + }; return Ok(quote! { #doc_comment 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/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..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 @@ -47,8 +47,89 @@ 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. 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), + ) => { + (encoded_keys_may_be_extra + || encoded.keys().all(|key| input.contains_key(key))) + && input + .iter() + .all(|(key, value)| match encoded.get(key) { + Some(encoded_value) => { + preserves_complete_json_input( + encoded_value, + value, + nulls_may_be_absent, + encoded_keys_may_be_extra, + ) + } + None => nulls_may_be_absent && value.is_null(), + }) + } + (serde_json::Value::Array(encoded), serde_json::Value::Array(input)) => { + encoded.len() == input.len() + && encoded + .iter() + .zip(input) + .all(|(encoded, input)| { + preserves_complete_json_input( + encoded, + input, + nulls_may_be_absent, + encoded_keys_may_be_extra, + ) + }) + } + ( + 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 +143,33 @@ 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, + false, + ) => { + equivalent_matches += 1; + equivalent.get_or_insert(Self::BetaTool(candidate)); } - matched = Some(Self::BetaTool(candidate)); + _ => {} } } } @@ -98,26 +190,51 @@ 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, + false, + ) => { + 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..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 @@ -45,8 +45,89 @@ 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. 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), + ) => { + (encoded_keys_may_be_extra + || encoded.keys().all(|key| input.contains_key(key))) + && input + .iter() + .all(|(key, value)| match encoded.get(key) { + Some(encoded_value) => { + preserves_complete_json_input( + encoded_value, + value, + nulls_may_be_absent, + encoded_keys_may_be_extra, + ) + } + None => nulls_may_be_absent && value.is_null(), + }) + } + (serde_json::Value::Array(encoded), serde_json::Value::Array(input)) => { + encoded.len() == input.len() + && encoded + .iter() + .zip(input) + .all(|(encoded, input)| { + preserves_complete_json_input( + encoded, + input, + nulls_may_be_absent, + encoded_keys_may_be_extra, + ) + }) + } + ( + 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 +141,33 @@ 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, + false, + ) => { + equivalent_matches += 1; + equivalent.get_or_insert(Self::Tool(candidate)); } - matched = Some(Self::Tool(candidate)); + _ => {} } } } @@ -92,26 +184,51 @@ 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, + false, + ) => { + 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/corpus-manifest.txt b/tests/corpus-manifest.txt index 7a44d6cc..b284e590 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 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 612843 13322 8c9551b8dfd04f6e +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 1166877 30496 6c34d244c87ec882 +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 @@ -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 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 210349 5830 ecd4ce55e9bc8b1c +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 225828 5803 740d8cc143b1af41 +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 364570 9619 5dd0e36a1e3ca912 +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 16273278 378321 231c0f88c435b00c +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 -coda/types.rs 1990457 41947 788c94a3949d60b9 +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 273846 6498 1a5bef1d4bcea737 +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 3346743 85109 6233c943a806f68b +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 1492411 35105 8d22f01598c85c65 +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 -discord/types.rs 1272025 32755 de44694314113c14 +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 5862313 136588 532f95a64ec49f61 +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 7702391 207387 0019503f70f38bce +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 @@ -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 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 717309 15820 b158f06cd64572b6 +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 259668 6425 e15eeacb10637dc6 +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 -langsmith/types.rs 1161995 32610 fbf8b66781fcdab7 +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 735300 18316 37aac538ad06edd7 +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 6858612 139380 79ac34e92dacd5fb +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 1612473 39122 146638e5d90ddfa5 +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 75620 2116 b98bae10c054ef3e +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 @@ -149,23 +149,23 @@ 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 -modern-treasury/types.rs 995584 26633 ce34002029faedaa +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 6310615 142383 a39f47ec0b9c4cd0 +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 6695017 143213 ef443d0415e39591 +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 1696912 40730 4db4dd8ac98e4d1f +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 1225853 32915 436678419f67417f +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 2369613 66073 c9cbb5b2d1d6cbef +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 -snyk/types.rs 2717805 59426 5866664d242459a9 +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 884799 20120 38b50906e06a668d +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 10112467 248486 fb430bcdf91b98f7 +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 476436 13390 306a5b647643e04d +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 4496512 107989 7d19b35d8678c506 +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 51936 1629 12f624a0585e437b +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 565362 14607 c07c3f2fcae7972a +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 12196079 315218 93755a3f454f3470 +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 182479 4509 36b85cce121b9439 +writer/types.rs 198394 4888 800dd8afe5798530 # -# specs: 56 files: 224 bytes: 313754689 +# 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 new file mode 100644 index 00000000..9866087c --- /dev/null +++ b/tests/union_branch_wire_equivalence_test.rs @@ -0,0 +1,259 @@ +//! 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" } + ] + }, + "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" } + ] + }, + "NullableAnyResponse": { + "type": "object", "required": ["result"], + "properties": { "result": { + "anyOf": [ + { "type": "object", "nullable": true }, + { "type": "string", "nullable": true } + ] + } } + } + } } + }) +} + +#[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, DefaultedAny, DefaultedUnion, NullableAnyResponse, 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:?}" + ); + } + + #[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(_))); + } + + #[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); + } +} +"#, + ) + .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) + ); +}