From bf7e56940585d0cc16068f22b81cb98ceb1e89bc Mon Sep 17 00:00:00 2001 From: Kaspar Lyngsie Date: Fri, 11 Sep 2026 18:25:59 +0200 Subject: [PATCH 1/4] feat: `allOf` semantics --- crates/oapi-codegen/src/coverage.rs | 18 - crates/oapi-codegen/src/lower/all_of.rs | 593 ++++++++++++++++++ crates/oapi-codegen/src/lower/mod.rs | 1 + crates/oapi-codegen/src/lower/schema.rs | 114 +--- .../tests/fixtures/allof_merge.yaml | 79 +++ crates/oapi-codegen/tests/generated.rs | 44 ++ .../tests/generated/allof_merge.rs | 359 +++++++++++ docs/design.md | 28 +- 8 files changed, 1116 insertions(+), 120 deletions(-) create mode 100644 crates/oapi-codegen/src/lower/all_of.rs diff --git a/crates/oapi-codegen/src/coverage.rs b/crates/oapi-codegen/src/coverage.rs index 96671b0..ac52e2e 100644 --- a/crates/oapi-codegen/src/coverage.rs +++ b/crates/oapi-codegen/src/coverage.rs @@ -543,20 +543,6 @@ impl Sweep<'_> { path, "Rust deserialization checks do not enforce all schema constraints, which can affect union match counts", ), - "allOf" - if value.as_sequence().is_some_and(|members| { - return match members.as_slice() { - [] => false, - [member] => member.get("$ref").is_none(), - _ => true, - }; - }) => - { - self.warn( - path, - "allOf merges properties rather than validating every member independently", - ); - } "default" if value.is_null() => { self.warn( path, @@ -902,10 +888,6 @@ security: [{arbitrary: [custom]}] ("{oneOf: [{type: string}, {type: integer}]}", "oneOf"), ("{anyOf: [{type: string}, {type: integer}]}", "anyOf"), ("{type: object, additionalProperties: false}", "additionalProperties"), - ( - "{allOf: [{type: object, properties: {name: {type: string}}, additionalProperties: false}]}", - "allOf", - ), ] { let yaml = format!("components: {{schemas: {{Widget: {schema}}}}}"); let sweep = inspect_yaml(&yaml); diff --git a/crates/oapi-codegen/src/lower/all_of.rs b/crates/oapi-codegen/src/lower/all_of.rs new file mode 100644 index 0000000..7c30360 --- /dev/null +++ b/crates/oapi-codegen/src/lower/all_of.rs @@ -0,0 +1,593 @@ +use openapiv3::AdditionalProperties; +use openapiv3::ObjectType; +use openapiv3::ReferenceOr; +use openapiv3::Schema; +use openapiv3::SchemaKind; +use openapiv3::Type; + +use crate::error::Error; +use crate::error::Result; +use crate::loader::Spec; +use crate::lower::schema::MAX_SCHEMA_DEPTH; + +fn unsupported(path: &str, reason: &str) -> Error { + return Error::UnsupportedSchema { + path: path.to_owned(), + reason: format!("allOf intersection: {reason}"), + }; +} + +pub(super) fn merge(spec: &Spec, path: &str, members: &[ReferenceOr]) -> Result { + let mut objects = Vec::new(); + collect(spec, path, members, &mut objects, 0)?; + let mut merged = ObjectType::default(); + for object in &objects { + if matches!(object.additional_properties, Some(AdditionalProperties::Any(true))) { + merged.additional_properties = Some(AdditionalProperties::Any(true)); + } + for (name, property) in &object.properties { + let property = match merged.properties.get(name) { + Some(previous) => intersect(spec, &format!("{path}.{name}"), previous, property)?, + None => property.clone(), + }; + merged.properties.insert(name.clone(), property); + } + for name in &object.required { + if !merged.required.contains(name) { + merged.required.push(name.clone()); + } + } + } + for object in &objects { + if matches!(object.additional_properties, Some(AdditionalProperties::Any(false))) { + if merged + .properties + .keys() + .any(|name| return !object.properties.contains_key(name)) + || merged + .required + .iter() + .any(|name| return !object.properties.contains_key(name)) + { + return Err(unsupported( + path, + "a closed member forbids a property from another member", + )); + } + merged.additional_properties = Some(AdditionalProperties::Any(false)); + } + } + if merged + .required + .iter() + .any(|name| return !merged.properties.contains_key(name)) + { + return Err(unsupported(path, "a required property has no declared schema")); + } + return Ok(merged); +} + +fn collect( + spec: &Spec, + path: &str, + members: &[ReferenceOr], + objects: &mut Vec, + depth: usize, +) -> Result<()> { + if depth >= MAX_SCHEMA_DEPTH { + return Err(Error::SchemaDepthExceeded { + path: path.to_owned(), + limit: MAX_SCHEMA_DEPTH, + }); + } + if members.is_empty() { + return Err(unsupported(path, "an empty composition is not supported")); + } + for member in members { + let schema = match member { + ReferenceOr::Item(schema) => schema, + ReferenceOr::Reference { reference } => spec.resolve(reference)?, + }; + let data = &schema.schema_data; + if data.nullable + || data.read_only + || data.write_only + || data.deprecated + || data.default.is_some() + || !data.extensions.is_empty() + || data.discriminator.is_some() + { + return Err(unsupported( + path, + "member nullability, access, deprecation, defaults, extensions, or discriminators cannot be flattened", + )); + } + match &schema.schema_kind { + SchemaKind::Type(Type::Object(object)) => { + if object.min_properties.is_some() || object.max_properties.is_some() { + return Err(unsupported( + path, + "object property-count constraints cannot be flattened", + )); + } + if matches!(object.additional_properties, Some(AdditionalProperties::Schema(_))) { + return Err(unsupported( + path, + "schema-valued additionalProperties cannot be flattened", + )); + } + objects.push(object.clone()); + } + SchemaKind::AllOf { all_of } => collect(spec, path, all_of, objects, depth + 1)?, + _ => return Err(unsupported(path, "members must be objects or references to objects")), + } + } + return Ok(()); +} + +fn resolve(spec: &Spec, path: &str, property: &ReferenceOr>) -> Result { + let mut schema = match property { + ReferenceOr::Item(schema) => *schema.clone(), + ReferenceOr::Reference { reference } => spec.resolve(reference)?.clone(), + }; + for _ in 0..MAX_SCHEMA_DEPTH { + let SchemaKind::AllOf { all_of } = &schema.schema_kind else { + return Ok(schema); + }; + let [member] = all_of.as_slice() else { + return Err(unsupported(path, "overlapping composed properties are not supported")); + }; + let mut target = match member { + ReferenceOr::Item(schema) => schema.clone(), + ReferenceOr::Reference { reference } => spec.resolve(reference)?.clone(), + }; + let mut data = schema.schema_data.clone(); + data.nullable = false; + if data != openapiv3::SchemaData::default() { + return Err(unsupported( + path, + "overlapping reference wrappers carry unsupported metadata", + )); + } + target.schema_data.nullable |= schema.schema_data.nullable; + schema = target; + } + return Err(Error::SchemaDepthExceeded { + path: path.to_owned(), + limit: MAX_SCHEMA_DEPTH, + }); +} + +fn intersect( + spec: &Spec, + path: &str, + left: &ReferenceOr>, + right: &ReferenceOr>, +) -> Result>> { + if left == right && matches!(left, ReferenceOr::Reference { .. }) { + return Ok(left.clone()); + } + let mut left = resolve(spec, path, left)?; + let mut right = resolve(spec, path, right)?; + let nullable = left.schema_data.nullable && right.schema_data.nullable; + left.schema_data.nullable = nullable; + right.schema_data.nullable = nullable; + if left.schema_data != right.schema_data { + return Err(unsupported( + path, + "overlapping properties have different metadata or extensions", + )); + } + if left.schema_data.extensions.contains_key("x-rust-type") && left.schema_kind != right.schema_kind { + return Err(unsupported(path, "custom-type property constraints differ")); + } + if left.schema_kind != right.schema_kind + && ["x-enum-varnames", "x-enumNames"] + .iter() + .any(|key| return left.schema_data.extensions.contains_key(*key)) + { + return Err(unsupported( + path, + "enum intersections with positional variant names are not supported", + )); + } + match (&mut left.schema_kind, &right.schema_kind) { + (SchemaKind::Type(Type::String(a)), SchemaKind::Type(Type::String(b))) => { + combine_keyword(path, "formats", &mut a.format, &b.format)?; + combine_keyword(path, "patterns", &mut a.pattern, &b.pattern)?; + a.min_length = tighter(a.min_length, b.min_length, true); + a.max_length = tighter(a.max_length, b.max_length, false); + check_range(path, a.min_length, a.max_length)?; + if nullable + && !matches!( + super::schema::string_format_type(&a.format), + crate::ir::RustType::String + ) + && (a.pattern.is_some() || a.min_length.is_some() || a.max_length.is_some()) + { + return Err(unsupported( + path, + "nullable formatted-string constraints cannot be enforced", + )); + } + narrow_enum(path, &mut a.enumeration, &b.enumeration)?; + if !a.enumeration.is_empty() + && (nullable || a.pattern.is_some() || a.min_length.is_some() || a.max_length.is_some()) + { + return Err(unsupported( + path, + "string enum intersections with nullability or string constraints are not supported", + )); + } + } + (SchemaKind::Type(Type::Integer(a)), SchemaKind::Type(Type::Integer(b))) => { + combine_keyword(path, "formats", &mut a.format, &b.format)?; + combine_keyword(path, "multipleOf", &mut a.multiple_of, &b.multiple_of)?; + (a.minimum, a.exclusive_minimum) = + bound(a.minimum, a.exclusive_minimum, b.minimum, b.exclusive_minimum, true); + (a.maximum, a.exclusive_maximum) = + bound(a.maximum, a.exclusive_maximum, b.maximum, b.exclusive_maximum, false); + check_range(path, a.minimum, a.maximum)?; + narrow_enum(path, &mut a.enumeration, &b.enumeration)?; + if !a.enumeration.is_empty() + && (nullable || a.minimum.is_some() || a.maximum.is_some() || a.multiple_of.is_some()) + { + return Err(unsupported( + path, + "integer enum intersections with nullability or numeric constraints are not supported", + )); + } + } + (SchemaKind::Type(Type::Number(a)), SchemaKind::Type(Type::Number(b))) => { + if [a.minimum, a.maximum, b.minimum, b.maximum] + .into_iter() + .flatten() + .any(|value| return !value.is_finite()) + { + return Err(unsupported(path, "numeric bounds must be finite")); + } + combine_keyword(path, "formats", &mut a.format, &b.format)?; + combine_keyword(path, "multipleOf", &mut a.multiple_of, &b.multiple_of)?; + if !a.enumeration.is_empty() || !b.enumeration.is_empty() { + return Err(unsupported(path, "number enums are not supported in this overlap")); + } + (a.minimum, a.exclusive_minimum) = + bound(a.minimum, a.exclusive_minimum, b.minimum, b.exclusive_minimum, true); + (a.maximum, a.exclusive_maximum) = + bound(a.maximum, a.exclusive_maximum, b.maximum, b.exclusive_maximum, false); + check_range(path, a.minimum, a.maximum)?; + } + (a, b) if a == b => {} + _ => return Err(unsupported(path, "property types or composite constraints differ")), + } + return Ok(ReferenceOr::Item(Box::new(left))); +} + +fn combine_keyword(path: &str, keyword: &str, left: &mut T, right: &T) -> Result<()> { + if *left == T::default() { + *left = right.clone(); + return Ok(()); + } + if *right != T::default() && left != right { + return Err(unsupported(path, &format!("specified {keyword} constraints differ"))); + } + return Ok(()); +} + +fn tighter(a: Option, b: Option, minimum: bool) -> Option { + return bound(a, false, b, false, minimum).0; +} + +fn bound( + a: Option, + a_exclusive: bool, + b: Option, + b_exclusive: bool, + minimum: bool, +) -> (Option, bool) { + return match (a, b) { + (None, _) => (b, b_exclusive), + (_, None) => (a, a_exclusive), + (Some(a), Some(b)) if a == b => (Some(a), a_exclusive || b_exclusive), + (Some(a), Some(b)) if (minimum && b > a) || (!minimum && b < a) => (Some(b), b_exclusive), + _ => (a, a_exclusive), + }; +} + +fn check_range(path: &str, minimum: Option, maximum: Option) -> Result<()> { + if let (Some(minimum), Some(maximum)) = (minimum, maximum) + && minimum > maximum + { + return Err(unsupported(path, "the bounds accept no value")); + } + return Ok(()); +} + +fn narrow_enum(path: &str, left: &mut Vec, right: &[T]) -> Result<()> { + if left.is_empty() { + left.extend_from_slice(right); + return Ok(()); + } + if !right.is_empty() { + left.retain(|value| return right.contains(value)); + if left.is_empty() { + return Err(unsupported(path, "the enum intersection accepts no value")); + } + } + return Ok(()); +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + fn spec(schemas: serde_json::Value) -> Spec { + let document = serde_json::from_value(json!({ + "openapi": "3.0.3", "info": {"title": "test", "version": "1"}, + "paths": {}, "components": {"schemas": schemas}, + })) + .expect("parse document"); + return Spec::from_parts(document, "allof.yaml".into()); + } + + fn lower(schema: serde_json::Value) -> Result { + let spec = spec(json!({"Test": schema})); + let names = crate::lower::rename::type_renames(&spec, None)?; + return crate::lower::schema::generate_models(&spec, &names); + } + + fn object(property: serde_json::Value) -> serde_json::Value { + return json!({"type": "object", "properties": {"value": property}}); + } + + #[test] + fn incompatible_properties_report_context_in_both_orders() { + for (left, right, reason) in [ + (json!({"type":"string"}), json!({"type":"integer"}), "property types"), + ( + json!({"type":"string","minLength":5_i64}), + json!({"type":"string","maxLength":2_i64}), + "bounds", + ), + ( + json!({"type":"integer","minimum":5_i64}), + json!({"type":"integer","maximum":2_i64}), + "bounds", + ), + ( + json!({"type":"string","pattern":"a"}), + json!({"type":"string","pattern":"b"}), + "patterns", + ), + ( + json!({"type":"string","format":"uuid"}), + json!({"type":"string","format":"date"}), + "formats", + ), + ( + json!({"type":"string","enum":["a"]}), + json!({"type":"string","enum":["b"]}), + "enum intersection", + ), + ( + json!({"type":"string","readOnly":true}), + json!({"type":"string"}), + "metadata", + ), + ( + json!({"type":"string","writeOnly":true}), + json!({"type":"string"}), + "metadata", + ), + ( + json!({"type":"string","default":"a"}), + json!({"type":"string","default":"b"}), + "metadata", + ), + ( + json!({"type":"string","x-rust-name":"first"}), + json!({"type":"string","x-rust-name":"second"}), + "metadata", + ), + ( + json!({"type":"integer","multipleOf":2_i64}), + json!({"type":"integer","multipleOf":3_i64}), + "multipleOf", + ), + ] { + for (left, right) in [(&left, &right), (&right, &left)] { + let composition = json!({"allOf":[object(left.clone()), object(right.clone())]}); + for schema in [composition.clone(), object(composition)] { + let error = lower(schema).expect_err("unsupported overlap").to_string(); + assert!( + error.contains("Test") && error.contains("value") && error.contains(reason), + "{error}" + ); + } + } + } + } + + #[test] + fn unsupported_member_restrictions_are_not_discarded() { + for (member, reason) in [ + ( + json!({"type":"object","additionalProperties":{"type":"string"}}), + "schema-valued additionalProperties", + ), + (json!({"type":"object","minProperties":1_i64}), "property-count"), + (json!({"type":"object","nullable":true}), "nullability"), + (json!({"type":"object","default":{}}), "defaults"), + (json!({"type":"object","x-rust-type":"serde_json::Value"}), "extensions"), + ] { + let error = lower(json!({"allOf":[member]})) + .expect_err("unsupported member") + .to_string(); + assert!(error.contains("Test") && error.contains(reason), "{error}"); + } + for required in [json!([]), json!(["value"])] { + let other = json!({"type":"object","required":required,"properties":{"value":{"type":"string"}}}); + let closed = json!({"type":"object","additionalProperties":false}); + for members in [json!([closed, other]), json!([other, closed])] { + let error = lower(json!({"allOf":members})) + .expect_err("forbidden property") + .to_string(); + assert!(error.contains("Test") && error.contains("closed member"), "{error}"); + } + } + } + + #[test] + fn scalar_intersection_is_commutative() { + let spec = spec(json!({})); + for (left, right) in [ + ( + json!({"type":"number","minimum":1.5_f64,"exclusiveMinimum":true}), + json!({"type":"number","minimum":1.5_f64,"maximum":3.5_f64}), + ), + ( + json!({"type":"integer","nullable":true,"minimum":1_i64}), + json!({"type":"integer","nullable":true,"maximum":5_i64}), + ), + ( + json!({"type":"string","nullable":true}), + json!({"type":"string","minLength":2_i64}), + ), + ( + json!({"type":"integer","enum":[1_i64,2_i64,3_i64]}), + json!({"type":"integer","enum":[2_i64,3_i64]}), + ), + ] { + let left = serde_json::from_value(left).expect("left schema"); + let right = serde_json::from_value(right).expect("right schema"); + assert_eq!( + intersect(&spec, "Test.value", &left, &right).expect("forward"), + intersect(&spec, "Test.value", &right, &left).expect("reverse"), + ); + } + } + + #[test] + fn unspecified_keywords_preserve_the_other_members_constraints() { + let spec = spec(json!({})); + for (left, right, expected) in [ + ( + json!({"type":"string","pattern":"^[a-z]+$"}), + json!({"type":"string","minLength":3_i64}), + json!({"type":"string","pattern":"^[a-z]+$","minLength":3_i64}), + ), + ( + json!({"type":"string","format":"uuid"}), + json!({"type":"string"}), + json!({"type":"string","format":"uuid"}), + ), + ( + json!({"type":"integer","format":"int32"}), + json!({"type":"integer","minimum":2_i64}), + json!({"type":"integer","format":"int32","minimum":2_i64}), + ), + ( + json!({"type":"integer","multipleOf":2_i64}), + json!({"type":"integer","minimum":0_i64}), + json!({"type":"integer","multipleOf":2_i64,"minimum":0_i64}), + ), + ( + json!({"type":"number","format":"float"}), + json!({"type":"number","maximum":10.0_f64}), + json!({"type":"number","format":"float","maximum":10.0_f64}), + ), + ( + json!({"type":"number","multipleOf":0.5_f64}), + json!({"type":"number","maximum":10.0_f64}), + json!({"type":"number","multipleOf":0.5_f64,"maximum":10.0_f64}), + ), + ] { + let left = serde_json::from_value(left).expect("left schema"); + let right = serde_json::from_value(right).expect("right schema"); + let expected = serde_json::from_value(expected).expect("intersection schema"); + for (left, right) in [(&left, &right), (&right, &left)] { + assert_eq!( + intersect(&spec, "Test.value", left, right).expect("compatible intersection"), + expected, + ); + } + } + } + + #[test] + fn nullable_formatted_string_constraints_are_not_discarded() { + for format in ["uuid", "date", "date-time", "byte", "binary"] { + for constraint in [ + json!({"pattern":"^a"}), + json!({"minLength":3_i64}), + json!({"maxLength":10_i64}), + ] { + let formatted = json!({"type":"string","nullable":true,"format":format}); + let mut constrained = constraint; + constrained["type"] = json!("string"); + constrained["nullable"] = json!(true); + for (left, right) in [(&formatted, &constrained), (&constrained, &formatted)] { + let composition = json!({"allOf":[object(left.clone()),object(right.clone())]}); + for schema in [composition.clone(), object(composition)] { + let error = lower(schema).expect_err("unsupported nullable constraints").to_string(); + assert!( + error.contains("Test") + && error.contains("value") + && error.contains("nullable formatted-string constraints"), + "{error}", + ); + } + } + } + } + } + + #[test] + fn recursive_members_stop_at_the_depth_guard() { + let spec = spec(json!({"Cycle":{"allOf":[{"$ref":"#/components/schemas/Cycle"},{"type":"object"}]}})); + let members = [ReferenceOr::Reference { + reference: "#/components/schemas/Cycle".to_owned(), + }]; + assert!(matches!( + merge(&spec, "Cycle", &members), + Err(Error::SchemaDepthExceeded { .. }) + )); + } + + #[test] + fn open_members_preserve_additional_properties_and_required_names() { + let spec = spec(json!({})); + let members = serde_json::from_value::>>(json!([ + {"type":"object","required":["value"]}, + {"type":"object","additionalProperties":true,"properties":{"value":{"type":"string"}}} + ])) + .expect("members"); + let merged = merge(&spec, "Test", &members).expect("open intersection"); + assert_eq!(merged.required, ["value"]); + assert_eq!(merged.additional_properties, Some(AdditionalProperties::Any(true))); + } + + #[test] + fn enum_metadata_and_nullable_enum_overlaps_are_explicit_errors() { + for members in [ + json!([ + object(json!({"type":"string","nullable":true,"enum":["a","b"]})), + object(json!({"type":"string","nullable":true,"enum":["b"]})) + ]), + json!([ + object(json!({"type":"string","enum":["a","b"],"x-enum-varnames":["First","Second"]})), + object(json!({"type":"string","enum":["b","c"],"x-enum-varnames":["First","Second"]})) + ]), + ] { + let error = lower(json!({"allOf":members})) + .expect_err("unsupported enum intersection") + .to_string(); + assert!( + error.contains("Test.value") && error.contains("enum intersections"), + "{error}" + ); + } + } +} diff --git a/crates/oapi-codegen/src/lower/mod.rs b/crates/oapi-codegen/src/lower/mod.rs index f6106b0..e3e3782 100644 --- a/crates/oapi-codegen/src/lower/mod.rs +++ b/crates/oapi-codegen/src/lower/mod.rs @@ -5,6 +5,7 @@ //! [`validate`] collects independent semantic problems so one run can report //! every problem it finds rather than aborting on the first. +mod all_of; /// Lowering of the validation keywords a schema declares. Used by [`schema`] and /// [`paths`] only. pub(crate) mod constraints; diff --git a/crates/oapi-codegen/src/lower/schema.rs b/crates/oapi-codegen/src/lower/schema.rs index 3bf3a96..907acbd 100644 --- a/crates/oapi-codegen/src/lower/schema.rs +++ b/crates/oapi-codegen/src/lower/schema.rs @@ -59,7 +59,7 @@ const X_ENUM_NAMES: &str = "x-enumNames"; /// Guards against stack exhaustion on pathological or hostile specs. Well above any /// realistic hand-written or generated spec, and independent of whatever /// recursion limit the YAML/JSON parser happens to enforce. -const MAX_SCHEMA_DEPTH: usize = 100; +pub(super) const MAX_SCHEMA_DEPTH: usize = 100; /// Lower every component schema in `spec` into a module of Rust items. /// @@ -148,6 +148,7 @@ impl Mapper<'_> { /// Lower a top-level named schema into a single item. fn named_to_item(&mut self, name: &str, schema: &Schema) -> Result { + check_all_of_nullable(name, schema)?; if schema.schema_data.nullable { let mut non_null = schema.clone(); non_null.schema_data.nullable = false; @@ -457,6 +458,9 @@ impl Mapper<'_> { let target = self.schema_ref_target(reference, "an allOf member")?; RustType::Named(target) } + ReferenceOr::Item(schema) if matches!(schema.schema_kind, SchemaKind::Type(Type::Object(_))) => { + return Ok(None); + } ReferenceOr::Item(schema) => self.type_from_schema(hint, schema)?, }; return Ok(Some(ty)); @@ -465,80 +469,8 @@ impl Mapper<'_> { /// Merge an `allOf` into a single flat struct, resolving `$ref` members to /// pull in their properties (matching oapi-codegen's behaviour). fn merge_all_of(&mut self, name: &str, members: &[ReferenceOr], data: &SchemaData) -> Result { - let mut merged = MergedObject::default(); - self.absorb_members(name, members, &mut merged)?; - - let mut ordered = Vec::with_capacity(merged.properties.len()); - for (wire, prop) in &merged.properties { - let required = merged.required.iter().any(|r| { - return r == wire; - }); - let order = prop_order(prop, &format!("{name}.{wire}"))?; - let field = self.field_from_prop(name, wire, prop, required)?; - ordered.push((order, field)); - } - let fields = sort_by_order(ordered); - - return Ok(Struct { - name: self.type_name_ident(name), - doc: doc_of(data), - deprecated: deprecation_of(data, name)?, - fields, - additional_properties: None, - // A merge does not read `additionalProperties` from any member. In - // JSON Schema each `allOf` member validates the whole object, so a - // member with `additionalProperties: false` rejects every property - // that a sibling member declares. A merge that honoured it would - // deny the fields it just merged in. The merge drops the key, as it - // already drops a member's `additionalProperties` schema. - deny_unknown_fields: false, - }); - } - - /// Recursively fold `allOf` members (objects, refs to objects, or nested - /// `allOf`) into a single merged object. Shares the [`MAX_SCHEMA_DEPTH`] - /// counter with [`Self::type_from_schema`] so nested `allOf` cannot exhaust - /// the stack independently of inline-type nesting. - fn absorb_members(&mut self, name: &str, members: &[ReferenceOr], merged: &mut MergedObject) -> Result<()> { - if self.depth >= MAX_SCHEMA_DEPTH { - return Err(Error::SchemaDepthExceeded { - path: name.to_owned(), - limit: MAX_SCHEMA_DEPTH, - }); - } - self.depth += 1; - let result = self.absorb_members_inner(name, members, merged); - self.depth -= 1; - return result; - } - - fn absorb_members_inner( - &mut self, - name: &str, - members: &[ReferenceOr], - merged: &mut MergedObject, - ) -> Result<()> { - for member in members { - let schema = match member { - ReferenceOr::Item(schema) => schema, - ReferenceOr::Reference { reference } => self.spec.resolve(reference)?, - }; - match &schema.schema_kind { - SchemaKind::Type(Type::Object(obj)) => merged.absorb(obj), - SchemaKind::AllOf { all_of } => self.absorb_members(name, all_of, merged)?, - SchemaKind::Type(_) - | SchemaKind::OneOf { .. } - | SchemaKind::AnyOf { .. } - | SchemaKind::Any(_) - | SchemaKind::Not { .. } => { - return Err(Error::UnsupportedSchema { - path: name.to_owned(), - reason: "allOf members must be objects or refs to objects".to_owned(), - }); - } - } - } - return Ok(()); + let merged = super::all_of::merge(self.spec, name, members)?; + return self.object_to_struct(name, &merged, data); } fn make_union( @@ -775,6 +707,7 @@ impl Mapper<'_> { /// into named items. Bounds inline nesting via [`MAX_SCHEMA_DEPTH`] so a /// pathological spec errors cleanly instead of exhausting the stack. fn type_from_schema(&mut self, hint: &str, schema: &Schema) -> Result { + check_all_of_nullable(hint, schema)?; if self.depth >= MAX_SCHEMA_DEPTH { return Err(Error::SchemaDepthExceeded { path: hint.to_owned(), @@ -890,6 +823,16 @@ impl Mapper<'_> { } } +fn check_all_of_nullable(path: &str, schema: &Schema) -> Result<()> { + if schema.schema_data.nullable && matches!(&schema.schema_kind, SchemaKind::AllOf { all_of } if all_of.len() != 1) { + return Err(Error::UnsupportedSchema { + path: path.to_owned(), + reason: "allOf intersection: nullable multi-member compositions are not supported".to_owned(), + }); + } + return Ok(()); +} + /// Add nullability once. Only local aliases reveal the nullability of a named type. pub(crate) fn nullable_type(spec: &Spec, ty: RustType) -> RustType { if matches!(ty, RustType::Nullable(_)) { @@ -928,27 +871,6 @@ pub(crate) fn nullable_type(spec: &Spec, ty: RustType) -> RustType { return RustType::Nullable(Box::new(ty)); } -/// Accumulates merged properties of an `allOf`, preserving first-seen order. -#[derive(Default)] -struct MergedObject { - properties: indexmap::IndexMap>>, - required: Vec, -} - -impl MergedObject { - /// Fold one object schema's properties and required list into the merge. - fn absorb(&mut self, obj: &ObjectType) { - for (name, prop) in &obj.properties { - self.properties.insert(name.clone(), prop.clone()); - } - for req in &obj.required { - if !self.required.contains(req) { - self.required.push(req.clone()); - } - } - } -} - /// Map a string `format` to a Rust type. pub(crate) fn string_format_type(format: &VariantOrUnknownOrEmpty) -> RustType { let ty = match format { diff --git a/crates/oapi-codegen/tests/fixtures/allof_merge.yaml b/crates/oapi-codegen/tests/fixtures/allof_merge.yaml index 504b98a..d071892 100644 --- a/crates/oapi-codegen/tests/fixtures/allof_merge.yaml +++ b/crates/oapi-codegen/tests/fixtures/allof_merge.yaml @@ -26,3 +26,82 @@ components: properties: name: type: string + Count: + type: integer + minimum: 1 + maximum: 20 + multipleOf: 2 + nullable: true + Intersection: + allOf: + - &wide + type: object + additionalProperties: false + required: [count] + properties: + count: + $ref: "#/components/schemas/Count" + label: + type: string + pattern: ^[a-z]+$ + minLength: 2 + maxLength: 8 + color: + type: string + enum: [red, green, blue] + - &narrow + allOf: + - type: object + additionalProperties: false + required: [label, color] + properties: + count: + type: integer + minimum: 3 + maximum: 10 + exclusiveMaximum: true + label: + type: string + minLength: 3 + maxLength: 6 + color: + type: string + enum: [green, blue] + Reversed: + allOf: [*narrow, *wide] + Inline: + type: object + required: [value] + properties: + value: + allOf: [*wide, *narrow] + SingleClosed: + type: object + required: [value] + properties: + value: + allOf: + - type: object + additionalProperties: false + properties: + flag: + type: boolean + NullableIntersection: + allOf: + - type: object + required: [ratio] + properties: + ratio: + type: number + nullable: true + minimum: 1.5 + maximum: 5.5 + multipleOf: 0.5 + - type: object + properties: + ratio: + type: number + nullable: true + minimum: 2.5 + exclusiveMinimum: true + maximum: 4.5 diff --git a/crates/oapi-codegen/tests/generated.rs b/crates/oapi-codegen/tests/generated.rs index 41704c5..96761c8 100644 --- a/crates/oapi-codegen/tests/generated.rs +++ b/crates/oapi-codegen/tests/generated.rs @@ -11,6 +11,50 @@ //! attribute that turns off every lint that is about first-party source, and //! `include!` cannot carry one. So this file needs no lint exceptions of its own. +#[test] +fn all_of_intersections_accept_only_common_payloads() { + use generated::allof_merge::Inline; + use generated::allof_merge::Intersection; + use generated::allof_merge::NullableIntersection; + use generated::allof_merge::Reversed; + use generated::allof_merge::SingleClosed; + + for input in [ + r#"{"count":4,"label":"abc","color":"green"}"#, + r#"{"count":8,"label":"abcdef","color":"blue"}"#, + ] { + assert!(serde_json::from_str::(input).is_ok(), "{input}"); + assert!(serde_json::from_str::(input).is_ok(), "{input}"); + assert!(serde_json::from_str::(&format!(r#"{{"value":{input}}}"#)).is_ok()); + } + for input in [ + r#"{"count":2,"label":"abc","color":"green"}"#, + r#"{"count":3,"label":"abc","color":"green"}"#, + r#"{"count":10,"label":"abc","color":"green"}"#, + r#"{"count":null,"label":"abc","color":"green"}"#, + r#"{"count":4,"label":"ab","color":"green"}"#, + r#"{"count":4,"label":"abcdefg","color":"green"}"#, + r#"{"count":4,"label":"ABC","color":"green"}"#, + r#"{"count":4,"label":"abc","color":"red"}"#, + r#"{"count":4,"label":"abc","color":"green","extra":1}"#, + r#"{"label":"abc","color":"green"}"#, + r#"{"count":4,"color":"green"}"#, + r#"{"count":4,"label":"abc"}"#, + ] { + assert!(serde_json::from_str::(input).is_err(), "{input}"); + assert!(serde_json::from_str::(input).is_err(), "{input}"); + assert!(serde_json::from_str::(&format!(r#"{{"value":{input}}}"#)).is_err()); + } + assert!(serde_json::from_str::(r#"{"value":{"flag":true}}"#).is_ok()); + assert!(serde_json::from_str::(r#"{"value":{"extra":true}}"#).is_err()); + for input in [r#"{"ratio":null}"#, r#"{"ratio":3.0}"#, r#"{"ratio":4.5}"#] { + assert!(serde_json::from_str::(input).is_ok(), "{input}"); + } + for input in [r#"{"ratio":2.5}"#, r#"{"ratio":3.25}"#, r#"{"ratio":5.0}"#, "{}"] { + assert!(serde_json::from_str::(input).is_err(), "{input}"); + } +} + #[test] fn nullable_form_values_preserve_scalar_conversion() { use generated::nullable::Nullable; diff --git a/crates/oapi-codegen/tests/generated/allof_merge.rs b/crates/oapi-codegen/tests/generated/allof_merge.rs index 01bc67b..41bfa24 100644 --- a/crates/oapi-codegen/tests/generated/allof_merge.rs +++ b/crates/oapi-codegen/tests/generated/allof_merge.rs @@ -9,6 +9,74 @@ reason = "generated code, not first-party source" )] +/// A present JSON value, including explicit null. +#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, serde::Serialize)] +#[serde(untagged)] +pub enum Nullable { + /// Explicit JSON null. + #[default] + Null, + /// A non-null value. + Value(T), +} +impl Nullable { + /// Borrow the non-null value. + pub fn as_ref(&self) -> Option<&T> { + return match self { + Self::Null => None, + Self::Value(value) => Some(value), + }; + } +} +impl<'de, T: serde::Deserialize<'de>> serde::Deserialize<'de> for Nullable { + fn deserialize(deserializer: D) -> ::core::result::Result + where + D: serde::Deserializer<'de>, + { + struct NullableVisitor(::core::marker::PhantomData); + impl<'de, T: serde::Deserialize<'de>> serde::de::Visitor<'de> + for NullableVisitor { + type Value = Nullable; + fn expecting( + &self, + formatter: &mut ::core::fmt::Formatter<'_>, + ) -> ::core::fmt::Result { + return formatter.write_str("a present value or null"); + } + fn visit_newtype_struct( + self, + deserializer: D, + ) -> ::core::result::Result + where + D: serde::Deserializer<'de>, + { + return as serde::Deserialize>::deserialize(deserializer) + .map(|value| { + return match value { + Some(value) => Nullable::Value(value), + None => Nullable::Null, + }; + }); + } + fn visit_map( + self, + map: M, + ) -> ::core::result::Result + where + M: serde::de::MapAccess<'de>, + { + return T::deserialize(serde::de::value::MapAccessDeserializer::new(map)) + .map(Nullable::Value); + } + } + return deserializer + .deserialize_newtype_struct( + "Nullable", + NullableVisitor(::core::marker::PhantomData), + ); + } +} + #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] pub struct Base { pub id: String, @@ -67,3 +135,294 @@ impl Entity { return Ok(value); } } + +pub type Count = Nullable; + +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct Intersection { + #[serde(deserialize_with = "Intersection::validate_count")] + pub count: u64, + #[serde(deserialize_with = "Intersection::validate_label")] + pub label: String, + pub color: IntersectionColor, +} +impl Intersection { + /// The rules the document gives `count`, checked on the way in. + fn validate_count<'de, D>(deserializer: D) -> ::core::result::Result + where + D: serde::Deserializer<'de>, + { + let value = ::deserialize(deserializer)?; + { + let item = &value; + if *item < 3 { + return Err(serde::de::Error::custom("`count` must be 3 or more")); + } + if *item > 9 { + return Err(serde::de::Error::custom("`count` must be 9 or less")); + } + if *item % 2 != 0 { + return Err(serde::de::Error::custom("`count` must be a multiple of 2")); + } + } + return Ok(value); + } + /// The rules the document gives `label`, checked on the way in. + fn validate_label<'de, D>( + deserializer: D, + ) -> ::core::result::Result + where + D: serde::Deserializer<'de>, + { + static PATTERN: std::sync::OnceLock = std::sync::OnceLock::new(); + let pattern = PATTERN + .get_or_init(|| { + return regex::Regex::new("^[a-z]+$") + .expect("the generator read `^[a-z]+$` at generation time"); + }); + let value = ::deserialize(deserializer)?; + { + let item = &value; + if !pattern.is_match(item) { + return Err(serde::de::Error::custom("`label` must match `^[a-z]+$`")); + } + if item.chars().nth(2usize).is_none() { + return Err( + serde::de::Error::custom("`label` must hold 3 or more characters"), + ); + } + if item.chars().nth(6usize).is_some() { + return Err( + serde::de::Error::custom("`label` must hold 6 or fewer characters"), + ); + } + } + return Ok(value); + } +} + +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct Reversed { + #[serde(deserialize_with = "Reversed::validate_count")] + pub count: u64, + #[serde(deserialize_with = "Reversed::validate_label")] + pub label: String, + pub color: ReversedColor, +} +impl Reversed { + /// The rules the document gives `count`, checked on the way in. + fn validate_count<'de, D>(deserializer: D) -> ::core::result::Result + where + D: serde::Deserializer<'de>, + { + let value = ::deserialize(deserializer)?; + { + let item = &value; + if *item < 3 { + return Err(serde::de::Error::custom("`count` must be 3 or more")); + } + if *item > 9 { + return Err(serde::de::Error::custom("`count` must be 9 or less")); + } + if *item % 2 != 0 { + return Err(serde::de::Error::custom("`count` must be a multiple of 2")); + } + } + return Ok(value); + } + /// The rules the document gives `label`, checked on the way in. + fn validate_label<'de, D>( + deserializer: D, + ) -> ::core::result::Result + where + D: serde::Deserializer<'de>, + { + static PATTERN: std::sync::OnceLock = std::sync::OnceLock::new(); + let pattern = PATTERN + .get_or_init(|| { + return regex::Regex::new("^[a-z]+$") + .expect("the generator read `^[a-z]+$` at generation time"); + }); + let value = ::deserialize(deserializer)?; + { + let item = &value; + if !pattern.is_match(item) { + return Err(serde::de::Error::custom("`label` must match `^[a-z]+$`")); + } + if item.chars().nth(2usize).is_none() { + return Err( + serde::de::Error::custom("`label` must hold 3 or more characters"), + ); + } + if item.chars().nth(6usize).is_some() { + return Err( + serde::de::Error::custom("`label` must hold 6 or fewer characters"), + ); + } + } + return Ok(value); + } +} + +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] +pub struct Inline { + pub value: InlineValue, +} + +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] +pub struct SingleClosed { + pub value: SingleClosedValue, +} + +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] +pub struct NullableIntersection { + #[serde(deserialize_with = "NullableIntersection::validate_ratio")] + pub ratio: Nullable, +} +impl NullableIntersection { + /// The rules the document gives `ratio`, checked on the way in. + fn validate_ratio<'de, D>( + deserializer: D, + ) -> ::core::result::Result, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = as serde::Deserialize>::deserialize(deserializer)?; + { + let item = &value; + if let Some(item) = item.as_ref() { + if *item <= 2.5f64 { + return Err( + serde::de::Error::custom("`ratio` must be more than 2.5"), + ); + } + if *item > 4.5f64 { + return Err(serde::de::Error::custom("`ratio` must be 4.5 or less")); + } + if { + let steps = *item / 0.5f64; + (steps - steps.round()).abs() + > f64::EPSILON * steps.abs().max(1.0) * 8.0 + } { + return Err( + serde::de::Error::custom("`ratio` must be a multiple of 0.5"), + ); + } + } + } + return Ok(value); + } +} + +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] +pub enum IntersectionColor { + #[serde(rename = "green")] + Green, + #[serde(rename = "blue")] + Blue, +} + +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] +pub enum ReversedColor { + #[serde(rename = "green")] + Green, + #[serde(rename = "blue")] + Blue, +} + +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] +pub enum InlineValueColor { + #[serde(rename = "green")] + Green, + #[serde(rename = "blue")] + Blue, +} + +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct InlineValue { + #[serde(deserialize_with = "InlineValue::validate_count")] + pub count: u64, + #[serde(deserialize_with = "InlineValue::validate_label")] + pub label: String, + pub color: InlineValueColor, +} +impl InlineValue { + /// The rules the document gives `count`, checked on the way in. + fn validate_count<'de, D>(deserializer: D) -> ::core::result::Result + where + D: serde::Deserializer<'de>, + { + let value = ::deserialize(deserializer)?; + { + let item = &value; + if *item < 3 { + return Err(serde::de::Error::custom("`count` must be 3 or more")); + } + if *item > 9 { + return Err(serde::de::Error::custom("`count` must be 9 or less")); + } + if *item % 2 != 0 { + return Err(serde::de::Error::custom("`count` must be a multiple of 2")); + } + } + return Ok(value); + } + /// The rules the document gives `label`, checked on the way in. + fn validate_label<'de, D>( + deserializer: D, + ) -> ::core::result::Result + where + D: serde::Deserializer<'de>, + { + static PATTERN: std::sync::OnceLock = std::sync::OnceLock::new(); + let pattern = PATTERN + .get_or_init(|| { + return regex::Regex::new("^[a-z]+$") + .expect("the generator read `^[a-z]+$` at generation time"); + }); + let value = ::deserialize(deserializer)?; + { + let item = &value; + if !pattern.is_match(item) { + return Err(serde::de::Error::custom("`label` must match `^[a-z]+$`")); + } + if item.chars().nth(2usize).is_none() { + return Err( + serde::de::Error::custom("`label` must hold 3 or more characters"), + ); + } + if item.chars().nth(6usize).is_some() { + return Err( + serde::de::Error::custom("`label` must hold 6 or fewer characters"), + ); + } + } + return Ok(value); + } +} + +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct SingleClosedValue { + #[serde( + skip_serializing_if = "Option::is_none", + deserialize_with = "SingleClosedValue::validate_flag", + default + )] + pub flag: Option, +} +impl SingleClosedValue { + /// The rules the document gives `flag`, checked on the way in. + fn validate_flag<'de, D>( + deserializer: D, + ) -> ::core::result::Result, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = Some(::deserialize(deserializer)?); + return Ok(value); + } +} diff --git a/docs/design.md b/docs/design.md index 9504a44..7940054 100644 --- a/docs/design.md +++ b/docs/design.md @@ -305,7 +305,7 @@ OpenAPI objects. However, it still inspects objects inside unsupported features, such as callback operations. Warnings also identify several limits of the current translation. These include -Rust union matching, merged `allOf` members, nullability, and unconstrained fallback +Rust union matching, nullability, and unconstrained fallback types. Every `oneOf` and `anyOf` produces a warning: Rust deserialization checks do not enforce all schema constraints. Body selection reports discarded media entries. The warnings expose these limits without changing the generated types. @@ -346,11 +346,27 @@ drops the payload without a message. The generator emits `deny_unknown_fields` only when the struct also derives `Deserialize`. The `Serialize` derive does not read the attribute. -Two exceptions apply. A merge of `allOf` members drops the key. In JSON Schema -each member validates the whole object, so a member with -`additionalProperties: false` rejects every property that a sibling member -declares. A merge that honored the key would deny the fields that the merge -just added. +An `allOf` object merge unions required names and intersects duplicate scalar +properties. Numeric bounds and string lengths use the tighter limits. +Compatible string and integer enums narrow to their common values. +Nullability on duplicate properties requires both definitions to permit null. + +Each closed member must declare every merged property. Otherwise, generation +fails, including when the forbidden property is optional. +The merged struct preserves `additionalProperties: false`. +Schema-valued additional properties and object property-count constraints +produce an error during a merge. + +An absent format, pattern, or `multipleOf` retains the other member's constraint. +Conflicting specified values, property types, or metadata produce an error +instead of an order-dependent override. +Composite overlaps require identical definitions. Enum intersections with +nullability, scalar constraints, or positional variant names are unsupported. +Nullable formatted-string constraints are also unsupported. +Member access flags, defaults, extensions, discriminators, and nullability +cannot be flattened. Single-reference aliases and nullable wrappers retain +their existing target behavior. +This bounded schema intersection is not a full JSON Schema validator. A query-parameter struct also drops the key. A query string commonly carries a parameter that the document does not declare, such as one that a proxy or an From 9c60cc2c0234a0342befb539212abaa8bdf7c49a Mon Sep 17 00:00:00 2001 From: Kaspar Lyngsie Date: Sun, 13 Sep 2026 20:50:13 +0200 Subject: [PATCH 2/4] fix: honor nullable allOf custom type overrides --- crates/oapi-codegen/src/lower/schema.rs | 5 ++++- crates/oapi-codegen/tests/fixtures/nullable.yaml | 4 ++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/crates/oapi-codegen/src/lower/schema.rs b/crates/oapi-codegen/src/lower/schema.rs index 907acbd..6c6a1d6 100644 --- a/crates/oapi-codegen/src/lower/schema.rs +++ b/crates/oapi-codegen/src/lower/schema.rs @@ -824,7 +824,10 @@ impl Mapper<'_> { } fn check_all_of_nullable(path: &str, schema: &Schema) -> Result<()> { - if schema.schema_data.nullable && matches!(&schema.schema_kind, SchemaKind::AllOf { all_of } if all_of.len() != 1) { + if schema.schema_data.nullable + && !schema.schema_data.extensions.contains_key(X_RUST_TYPE) + && matches!(&schema.schema_kind, SchemaKind::AllOf { all_of } if all_of.len() != 1) + { return Err(Error::UnsupportedSchema { path: path.to_owned(), reason: "allOf intersection: nullable multi-member compositions are not supported".to_owned(), diff --git a/crates/oapi-codegen/tests/fixtures/nullable.yaml b/crates/oapi-codegen/tests/fixtures/nullable.yaml index 3df610f..0fd8747 100644 --- a/crates/oapi-codegen/tests/fixtures/nullable.yaml +++ b/crates/oapi-codegen/tests/fixtures/nullable.yaml @@ -206,6 +206,8 @@ components: x-rust-type: i64 allOf: - $ref: "#/components/schemas/NullableText" + - type: string + minLength: 5 custom_chain: $ref: "#/components/schemas/CustomChain" fallback: @@ -238,6 +240,8 @@ components: x-rust-type: i64 allOf: - $ref: "#/components/schemas/NullableText" + - type: string + minLength: 5 NullableText: type: string nullable: true From bf207482b501ef284d142bdc8ea05b75169ed555 Mon Sep 17 00:00:00 2001 From: Kaspar Lyngsie Date: Sun, 13 Sep 2026 21:22:51 +0200 Subject: [PATCH 3/4] fix: preserve allOf composites and reject enum formats --- crates/oapi-codegen/src/lower/all_of.rs | 39 +++++++++- .../tests/fixtures/allof_merge.yaml | 10 +++ crates/oapi-codegen/tests/generated.rs | 3 + .../tests/generated/allof_merge.rs | 77 +++++++++++++++++++ docs/design.md | 2 +- 5 files changed, 127 insertions(+), 4 deletions(-) diff --git a/crates/oapi-codegen/src/lower/all_of.rs b/crates/oapi-codegen/src/lower/all_of.rs index 7c30360..92b110f 100644 --- a/crates/oapi-codegen/src/lower/all_of.rs +++ b/crates/oapi-codegen/src/lower/all_of.rs @@ -4,6 +4,7 @@ use openapiv3::ReferenceOr; use openapiv3::Schema; use openapiv3::SchemaKind; use openapiv3::Type; +use openapiv3::VariantOrUnknownOrEmpty; use crate::error::Error; use crate::error::Result; @@ -164,7 +165,12 @@ fn intersect( left: &ReferenceOr>, right: &ReferenceOr>, ) -> Result>> { - if left == right && matches!(left, ReferenceOr::Reference { .. }) { + if left == right + && match left { + ReferenceOr::Reference { .. } => true, + ReferenceOr::Item(schema) => matches!(schema.schema_kind, SchemaKind::AllOf { .. }), + } + { return Ok(left.clone()); } let mut left = resolve(spec, path, left)?; @@ -212,11 +218,15 @@ fn intersect( } narrow_enum(path, &mut a.enumeration, &b.enumeration)?; if !a.enumeration.is_empty() - && (nullable || a.pattern.is_some() || a.min_length.is_some() || a.max_length.is_some()) + && (nullable + || !matches!(a.format, VariantOrUnknownOrEmpty::Empty) + || a.pattern.is_some() + || a.min_length.is_some() + || a.max_length.is_some()) { return Err(unsupported( path, - "string enum intersections with nullability or string constraints are not supported", + "string enum intersections with nullability, formats, or string constraints are not supported", )); } } @@ -590,4 +600,27 @@ mod tests { ); } } + + #[test] + fn string_enum_formats_are_not_discarded() { + for format in ["uuid", "date", "date-time", "byte", "binary", "password"] { + let enumeration = json!({"type":"string","enum":["not-a-uuid"]}); + let formatted = json!({"type":"string","format":format}); + let combined = json!({"type":"string","format":format,"enum":["not-a-uuid"]}); + for (left, right) in [ + (&enumeration, &formatted), + (&formatted, &enumeration), + (&combined, &combined), + ] { + let composition = json!({"allOf":[object(left.clone()),object(right.clone())]}); + for schema in [composition.clone(), object(composition)] { + let error = lower(schema).expect_err("unsupported enum format").to_string(); + assert!( + error.contains("Test") && error.contains("value") && error.contains("formats"), + "{error}", + ); + } + } + } + } } diff --git a/crates/oapi-codegen/tests/fixtures/allof_merge.yaml b/crates/oapi-codegen/tests/fixtures/allof_merge.yaml index d071892..f4691be 100644 --- a/crates/oapi-codegen/tests/fixtures/allof_merge.yaml +++ b/crates/oapi-codegen/tests/fixtures/allof_merge.yaml @@ -75,6 +75,16 @@ components: properties: value: allOf: [*wide, *narrow] + IdenticalComposites: + allOf: + - type: object + required: [value] + properties: + value: &composed + allOf: [*wide, *narrow] + - type: object + properties: + value: *composed SingleClosed: type: object required: [value] diff --git a/crates/oapi-codegen/tests/generated.rs b/crates/oapi-codegen/tests/generated.rs index 96761c8..dba0152 100644 --- a/crates/oapi-codegen/tests/generated.rs +++ b/crates/oapi-codegen/tests/generated.rs @@ -13,6 +13,7 @@ #[test] fn all_of_intersections_accept_only_common_payloads() { + use generated::allof_merge::IdenticalComposites; use generated::allof_merge::Inline; use generated::allof_merge::Intersection; use generated::allof_merge::NullableIntersection; @@ -26,6 +27,7 @@ fn all_of_intersections_accept_only_common_payloads() { assert!(serde_json::from_str::(input).is_ok(), "{input}"); assert!(serde_json::from_str::(input).is_ok(), "{input}"); assert!(serde_json::from_str::(&format!(r#"{{"value":{input}}}"#)).is_ok()); + assert!(serde_json::from_str::(&format!(r#"{{"value":{input}}}"#)).is_ok()); } for input in [ r#"{"count":2,"label":"abc","color":"green"}"#, @@ -44,6 +46,7 @@ fn all_of_intersections_accept_only_common_payloads() { assert!(serde_json::from_str::(input).is_err(), "{input}"); assert!(serde_json::from_str::(input).is_err(), "{input}"); assert!(serde_json::from_str::(&format!(r#"{{"value":{input}}}"#)).is_err()); + assert!(serde_json::from_str::(&format!(r#"{{"value":{input}}}"#)).is_err()); } assert!(serde_json::from_str::(r#"{"value":{"flag":true}}"#).is_ok()); assert!(serde_json::from_str::(r#"{"value":{"extra":true}}"#).is_err()); diff --git a/crates/oapi-codegen/tests/generated/allof_merge.rs b/crates/oapi-codegen/tests/generated/allof_merge.rs index 41bfa24..cdd4213 100644 --- a/crates/oapi-codegen/tests/generated/allof_merge.rs +++ b/crates/oapi-codegen/tests/generated/allof_merge.rs @@ -271,6 +271,11 @@ pub struct Inline { pub value: InlineValue, } +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] +pub struct IdenticalComposites { + pub value: IdenticalCompositesValue, +} + #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] pub struct SingleClosed { pub value: SingleClosedValue, @@ -404,6 +409,78 @@ impl InlineValue { } } +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] +pub enum IdenticalCompositesValueColor { + #[serde(rename = "green")] + Green, + #[serde(rename = "blue")] + Blue, +} + +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct IdenticalCompositesValue { + #[serde(deserialize_with = "IdenticalCompositesValue::validate_count")] + pub count: u64, + #[serde(deserialize_with = "IdenticalCompositesValue::validate_label")] + pub label: String, + pub color: IdenticalCompositesValueColor, +} +impl IdenticalCompositesValue { + /// The rules the document gives `count`, checked on the way in. + fn validate_count<'de, D>(deserializer: D) -> ::core::result::Result + where + D: serde::Deserializer<'de>, + { + let value = ::deserialize(deserializer)?; + { + let item = &value; + if *item < 3 { + return Err(serde::de::Error::custom("`count` must be 3 or more")); + } + if *item > 9 { + return Err(serde::de::Error::custom("`count` must be 9 or less")); + } + if *item % 2 != 0 { + return Err(serde::de::Error::custom("`count` must be a multiple of 2")); + } + } + return Ok(value); + } + /// The rules the document gives `label`, checked on the way in. + fn validate_label<'de, D>( + deserializer: D, + ) -> ::core::result::Result + where + D: serde::Deserializer<'de>, + { + static PATTERN: std::sync::OnceLock = std::sync::OnceLock::new(); + let pattern = PATTERN + .get_or_init(|| { + return regex::Regex::new("^[a-z]+$") + .expect("the generator read `^[a-z]+$` at generation time"); + }); + let value = ::deserialize(deserializer)?; + { + let item = &value; + if !pattern.is_match(item) { + return Err(serde::de::Error::custom("`label` must match `^[a-z]+$`")); + } + if item.chars().nth(2usize).is_none() { + return Err( + serde::de::Error::custom("`label` must hold 3 or more characters"), + ); + } + if item.chars().nth(6usize).is_some() { + return Err( + serde::de::Error::custom("`label` must hold 6 or fewer characters"), + ); + } + } + return Ok(value); + } +} + #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] #[serde(deny_unknown_fields)] pub struct SingleClosedValue { diff --git a/docs/design.md b/docs/design.md index 7940054..f53676f 100644 --- a/docs/design.md +++ b/docs/design.md @@ -361,7 +361,7 @@ An absent format, pattern, or `multipleOf` retains the other member's constraint Conflicting specified values, property types, or metadata produce an error instead of an order-dependent override. Composite overlaps require identical definitions. Enum intersections with -nullability, scalar constraints, or positional variant names are unsupported. +nullability, formats, scalar constraints, or positional variant names are unsupported. Nullable formatted-string constraints are also unsupported. Member access flags, defaults, extensions, discriminators, and nullability cannot be flattened. Single-reference aliases and nullable wrappers retain From 15227430463ca0afa6763d7cdfcb517c28bf6c62 Mon Sep 17 00:00:00 2001 From: Kaspar Lyngsie Date: Sun, 13 Sep 2026 21:57:56 +0200 Subject: [PATCH 4/4] fix: share allOf depth limits and stabilize enum intersections --- crates/oapi-codegen/src/lower/all_of.rs | 128 +++++++++++++++--- crates/oapi-codegen/src/lower/schema.rs | 58 +++++++- .../tests/fixtures/allof_merge.yaml | 25 ++++ crates/oapi-codegen/tests/generated.rs | 26 ++++ .../tests/generated/allof_merge.rs | 104 ++++++++++++-- docs/design.md | 4 +- 6 files changed, 318 insertions(+), 27 deletions(-) diff --git a/crates/oapi-codegen/src/lower/all_of.rs b/crates/oapi-codegen/src/lower/all_of.rs index 92b110f..85f6f12 100644 --- a/crates/oapi-codegen/src/lower/all_of.rs +++ b/crates/oapi-codegen/src/lower/all_of.rs @@ -18,9 +18,9 @@ fn unsupported(path: &str, reason: &str) -> Error { }; } -pub(super) fn merge(spec: &Spec, path: &str, members: &[ReferenceOr]) -> Result { +pub(super) fn merge(spec: &Spec, path: &str, members: &[ReferenceOr], depth: usize) -> Result { let mut objects = Vec::new(); - collect(spec, path, members, &mut objects, 0)?; + collect(spec, path, members, &mut objects, depth)?; let mut merged = ObjectType::default(); for object in &objects { if matches!(object.additional_properties, Some(AdditionalProperties::Any(true))) { @@ -28,7 +28,7 @@ pub(super) fn merge(spec: &Spec, path: &str, members: &[ReferenceOr]) -> } for (name, property) in &object.properties { let property = match merged.properties.get(name) { - Some(previous) => intersect(spec, &format!("{path}.{name}"), previous, property)?, + Some(previous) => intersect(spec, &format!("{path}.{name}"), previous, property, depth)?, None => property.clone(), }; merged.properties.insert(name.clone(), property); @@ -126,12 +126,12 @@ fn collect( return Ok(()); } -fn resolve(spec: &Spec, path: &str, property: &ReferenceOr>) -> Result { +fn resolve(spec: &Spec, path: &str, property: &ReferenceOr>, depth: usize) -> Result { let mut schema = match property { ReferenceOr::Item(schema) => *schema.clone(), ReferenceOr::Reference { reference } => spec.resolve(reference)?.clone(), }; - for _ in 0..MAX_SCHEMA_DEPTH { + for _ in depth..MAX_SCHEMA_DEPTH { let SchemaKind::AllOf { all_of } = &schema.schema_kind else { return Ok(schema); }; @@ -164,6 +164,7 @@ fn intersect( path: &str, left: &ReferenceOr>, right: &ReferenceOr>, + depth: usize, ) -> Result>> { if left == right && match left { @@ -173,8 +174,8 @@ fn intersect( { return Ok(left.clone()); } - let mut left = resolve(spec, path, left)?; - let mut right = resolve(spec, path, right)?; + let mut left = resolve(spec, path, left, depth)?; + let mut right = resolve(spec, path, right, depth)?; let nullable = left.schema_data.nullable && right.schema_data.nullable; left.schema_data.nullable = nullable; right.schema_data.nullable = nullable; @@ -184,8 +185,11 @@ fn intersect( "overlapping properties have different metadata or extensions", )); } - if left.schema_data.extensions.contains_key("x-rust-type") && left.schema_kind != right.schema_kind { - return Err(unsupported(path, "custom-type property constraints differ")); + if left.schema_data.extensions.contains_key("x-rust-type") { + if left.schema_kind != right.schema_kind { + return Err(unsupported(path, "custom-type property constraints differ")); + } + return Ok(ReferenceOr::Item(Box::new(left))); } if left.schema_kind != right.schema_kind && ["x-enum-varnames", "x-enumNames"] @@ -231,6 +235,20 @@ fn intersect( } } (SchemaKind::Type(Type::Integer(a)), SchemaKind::Type(Type::Integer(b))) => { + for integer in [&*a, b] { + let repr = super::schema::integer_type(integer); + if integer + .enumeration + .iter() + .flatten() + .any(|value| return !super::schema::fits_repr(*value, &repr)) + { + return Err(unsupported( + path, + "an integer enum value exceeds its representation limits", + )); + } + } combine_keyword(path, "formats", &mut a.format, &b.format)?; combine_keyword(path, "multipleOf", &mut a.multiple_of, &b.multiple_of)?; (a.minimum, a.exclusive_minimum) = @@ -313,10 +331,18 @@ fn check_range(path: &str, minimum: Option, maximum: Option return Ok(()); } -fn narrow_enum(path: &str, left: &mut Vec, right: &[T]) -> Result<()> { +fn narrow_enum(path: &str, left: &mut Vec, right: &[T]) -> Result<()> { + for values in [left.as_slice(), right] { + let mut seen = std::collections::BTreeSet::new(); + if values.iter().any(|value| return !seen.insert(value)) { + return Err(unsupported(path, "an enum contains duplicate values")); + } + } + if left == right { + return Ok(()); + } if left.is_empty() { left.extend_from_slice(right); - return Ok(()); } if !right.is_empty() { left.retain(|value| return right.contains(value)); @@ -324,6 +350,7 @@ fn narrow_enum(path: &str, left: &mut Vec, right: &[T]) return Err(unsupported(path, "the enum intersection accepts no value")); } } + left.sort(); return Ok(()); } @@ -467,14 +494,26 @@ mod tests { ), ( json!({"type":"integer","enum":[1_i64,2_i64,3_i64]}), + json!({"type":"integer","enum":[3_i64,2_i64]}), + ), + ( + json!({"type":"string","enum":["a_b","a-b","other"]}), + json!({"type":"string","enum":["a-b","a_b"]}), + ), + ( + json!({"type":"string","enum":["a_b","a-b"]}), + json!({"type":"string","enum":["a-b","a_b"]}), + ), + ( + json!({"type":"integer","enum":[3_i64,2_i64]}), json!({"type":"integer","enum":[2_i64,3_i64]}), ), ] { let left = serde_json::from_value(left).expect("left schema"); let right = serde_json::from_value(right).expect("right schema"); assert_eq!( - intersect(&spec, "Test.value", &left, &right).expect("forward"), - intersect(&spec, "Test.value", &right, &left).expect("reverse"), + intersect(&spec, "Test.value", &left, &right, 0).expect("forward"), + intersect(&spec, "Test.value", &right, &left, 0).expect("reverse"), ); } } @@ -519,7 +558,7 @@ mod tests { let expected = serde_json::from_value(expected).expect("intersection schema"); for (left, right) in [(&left, &right), (&right, &left)] { assert_eq!( - intersect(&spec, "Test.value", left, right).expect("compatible intersection"), + intersect(&spec, "Test.value", left, right, 0).expect("compatible intersection"), expected, ); } @@ -561,7 +600,7 @@ mod tests { reference: "#/components/schemas/Cycle".to_owned(), }]; assert!(matches!( - merge(&spec, "Cycle", &members), + merge(&spec, "Cycle", &members, 0), Err(Error::SchemaDepthExceeded { .. }) )); } @@ -574,7 +613,7 @@ mod tests { {"type":"object","additionalProperties":true,"properties":{"value":{"type":"string"}}} ])) .expect("members"); - let merged = merge(&spec, "Test", &members).expect("open intersection"); + let merged = merge(&spec, "Test", &members, 0).expect("open intersection"); assert_eq!(merged.required, ["value"]); assert_eq!(merged.additional_properties, Some(AdditionalProperties::Any(true))); } @@ -623,4 +662,61 @@ mod tests { } } } + + #[test] + fn invalid_source_enums_are_rejected_before_intersection() { + for (invalid, valid, reason) in [ + ( + json!({"type":"string","enum":["a","a","b"]}), + json!({"type":"string","enum":["a","b"]}), + "duplicate", + ), + ( + json!({"type":"string","enum":["removed","removed","b"]}), + json!({"type":"string","enum":["b"]}), + "duplicate", + ), + ( + json!({"type":"integer","enum":[1_i64,1_i64,2_i64]}), + json!({"type":"integer","enum":[1_i64,2_i64]}), + "duplicate", + ), + ( + json!({"type":"integer","enum":[1_i64,1_i64,2_i64]}), + json!({"type":"integer","enum":[2_i64]}), + "duplicate", + ), + ( + json!({"type":"integer","format":"int32","enum":[2_147_483_648_i64,1_i64]}), + json!({"type":"integer","format":"int32","enum":[1_i64]}), + "representation limits", + ), + ( + json!({"type":"integer","format":"int32","enum":[-2_147_483_649_i64,1_i64]}), + json!({"type":"integer","format":"int32","enum":[1_i64]}), + "representation limits", + ), + ] { + for (left, right) in [(&invalid, &valid), (&valid, &invalid)] { + let error = lower(json!({"allOf":[object(left.clone()),object(right.clone())]})) + .expect_err("invalid source enum") + .to_string(); + assert!(error.contains("Test.value") && error.contains(reason), "{error}"); + } + } + } + + #[test] + fn identical_custom_types_keep_control_of_enum_validation() { + for values in [json!([2_147_483_648_i64]), json!([1_i64, 1_i64])] { + let property = object(json!({ + "type": "integer", + "format": "int32", + "enum": values, + "x-rust-type": "i64", + })); + lower(json!({"allOf": [property.clone(), property]})) + .expect("custom type replaces the declared integer representation"); + } + } } diff --git a/crates/oapi-codegen/src/lower/schema.rs b/crates/oapi-codegen/src/lower/schema.rs index 6c6a1d6..986fe4c 100644 --- a/crates/oapi-codegen/src/lower/schema.rs +++ b/crates/oapi-codegen/src/lower/schema.rs @@ -469,7 +469,7 @@ impl Mapper<'_> { /// Merge an `allOf` into a single flat struct, resolving `$ref` members to /// pull in their properties (matching oapi-codegen's behaviour). fn merge_all_of(&mut self, name: &str, members: &[ReferenceOr], data: &SchemaData) -> Result { - let merged = super::all_of::merge(self.spec, name, members)?; + let merged = super::all_of::merge(self.spec, name, members, self.depth)?; return self.object_to_struct(name, &merged, data); } @@ -893,7 +893,7 @@ pub(crate) fn string_format_type(format: &VariantOrUnknownOrEmpty) /// /// An unsigned `repr` holds no negative value, so a `minimum` of zero with a /// negative `enum` value is a document that disagrees with itself. -fn fits_repr(value: i64, repr: &RustType) -> bool { +pub(super) fn fits_repr(value: i64, repr: &RustType) -> bool { return match *repr { RustType::I32 => i32::try_from(value).is_ok(), RustType::U32 => u32::try_from(value).is_ok(), @@ -1307,6 +1307,60 @@ components: lower_models(&spec).expect("just under the limit should lower cleanly"); } + #[test] + fn all_of_shares_the_inline_depth_budget() { + let make_schema = |schema_kind| { + return Schema { + schema_data: SchemaData::default(), + schema_kind, + }; + }; + let compose = |schemas: Vec| { + return make_schema(SchemaKind::AllOf { + all_of: schemas.into_iter().map(ReferenceOr::Item).collect(), + }); + }; + for overlap in [false, true] { + for excess in [false, true] { + let scalar = make_schema(SchemaKind::Type(Type::String(Default::default()))); + let empty = make_schema(SchemaKind::Type(Type::Object(Default::default()))); + let mut schema = if overlap { scalar.clone() } else { empty.clone() }; + for _ in 0..(MAX_SCHEMA_DEPTH / 2 - 2 + usize::from(excess)) { + schema = compose(vec![schema]); + } + if overlap { + let members = [schema, scalar] + .into_iter() + .map(|property| { + let mut object = ObjectType::default(); + object + .properties + .insert("value".to_owned(), ReferenceOr::Item(Box::new(property))); + return make_schema(SchemaKind::Type(Type::Object(object))); + }) + .collect(); + schema = compose(members); + } else { + schema = compose(vec![schema, empty]); + } + for _ in 0..MAX_SCHEMA_DEPTH / 2 { + schema = make_schema(SchemaKind::Type(Type::Array(openapiv3::ArrayType { + items: Some(ReferenceOr::Item(Box::new(schema))), + min_items: None, + max_items: None, + unique_items: false, + }))); + } + let result = lower_models(&spec_with_schema("Deep", schema)); + if excess { + assert!(matches!(result, Err(Error::SchemaDepthExceeded { .. })), "{result:?}"); + } else { + result.expect("combined depth below the limit"); + } + } + } + } + /// Lower an inline document and return the error it gives. fn lower_error(yaml: &str) -> Error { let doc: openapiv3::OpenAPI = serde_yaml::from_str(yaml).expect("parse spec"); diff --git a/crates/oapi-codegen/tests/fixtures/allof_merge.yaml b/crates/oapi-codegen/tests/fixtures/allof_merge.yaml index f4691be..c636309 100644 --- a/crates/oapi-codegen/tests/fixtures/allof_merge.yaml +++ b/crates/oapi-codegen/tests/fixtures/allof_merge.yaml @@ -115,3 +115,28 @@ components: minimum: 2.5 exclusiveMinimum: true maximum: 4.5 + EnumIntersection: + allOf: + - &enum_wide + type: object + required: [label, count] + properties: + label: + type: string + enum: [a_b, a-b, other] + count: + type: integer + format: int32 + enum: [3, 2, 1] + - &enum_narrow + type: object + properties: + label: + type: string + enum: [a-b, a_b] + count: + type: integer + format: int32 + enum: [2, 3] + EnumReversed: + allOf: [*enum_narrow, *enum_wide] diff --git a/crates/oapi-codegen/tests/generated.rs b/crates/oapi-codegen/tests/generated.rs index dba0152..c764e66 100644 --- a/crates/oapi-codegen/tests/generated.rs +++ b/crates/oapi-codegen/tests/generated.rs @@ -11,6 +11,32 @@ //! attribute that turns off every lint that is about first-party source, and //! `include!` cannot carry one. So this file needs no lint exceptions of its own. +#[test] +fn all_of_enum_intersections_have_stable_variants() { + use generated::allof_merge::EnumIntersection; + use generated::allof_merge::EnumReversed; + + for label in ["a-b", "a_b"] { + for count in [2_i32, 3_i32] { + let input = serde_json::json!({"label": label, "count": count}); + let forward: EnumIntersection = serde_json::from_value(input.clone()).expect("forward enum"); + let reverse: EnumReversed = serde_json::from_value(input.clone()).expect("reverse enum"); + assert_eq!(format!("{:?}", forward.label), format!("{:?}", reverse.label)); + assert_eq!(format!("{:?}", forward.count), format!("{:?}", reverse.count)); + assert_eq!(serde_json::to_value(forward).expect("forward wire values"), input); + assert_eq!(serde_json::to_value(reverse).expect("reverse wire values"), input); + } + } + for input in [ + serde_json::json!({"label":"other","count":2_i32}), + serde_json::json!({"label":"a-b","count":1_i32}), + serde_json::json!({"label":"a-b","count":2_147_483_648_i64}), + ] { + assert!(serde_json::from_value::(input.clone()).is_err()); + assert!(serde_json::from_value::(input).is_err()); + } +} + #[test] fn all_of_intersections_accept_only_common_payloads() { use generated::allof_merge::IdenticalComposites; diff --git a/crates/oapi-codegen/tests/generated/allof_merge.rs b/crates/oapi-codegen/tests/generated/allof_merge.rs index cdd4213..e8316b9 100644 --- a/crates/oapi-codegen/tests/generated/allof_merge.rs +++ b/crates/oapi-codegen/tests/generated/allof_merge.rs @@ -321,28 +321,40 @@ impl NullableIntersection { } } +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] +pub struct EnumIntersection { + pub label: EnumIntersectionLabel, + pub count: EnumIntersectionCount, +} + +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] +pub struct EnumReversed { + pub label: EnumReversedLabel, + pub count: EnumReversedCount, +} + #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] pub enum IntersectionColor { - #[serde(rename = "green")] - Green, #[serde(rename = "blue")] Blue, + #[serde(rename = "green")] + Green, } #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] pub enum ReversedColor { - #[serde(rename = "green")] - Green, #[serde(rename = "blue")] Blue, + #[serde(rename = "green")] + Green, } #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] pub enum InlineValueColor { - #[serde(rename = "green")] - Green, #[serde(rename = "blue")] Blue, + #[serde(rename = "green")] + Green, } #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] @@ -411,10 +423,10 @@ impl InlineValue { #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] pub enum IdenticalCompositesValueColor { - #[serde(rename = "green")] - Green, #[serde(rename = "blue")] Blue, + #[serde(rename = "green")] + Green, } #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] @@ -503,3 +515,79 @@ impl SingleClosedValue { return Ok(value); } } + +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] +pub enum EnumIntersectionLabel { + #[serde(rename = "a-b")] + AB, + #[serde(rename = "a_b")] + Ab2, +} + +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] +#[serde(try_from = "i32", into = "i32")] +#[repr(i32)] +pub enum EnumIntersectionCount { + Value2 = 2, + Value3 = 3, +} +impl From for i32 { + fn from(value: EnumIntersectionCount) -> Self { + return match value { + EnumIntersectionCount::Value2 => 2, + EnumIntersectionCount::Value3 => 3, + }; + } +} +impl TryFrom for EnumIntersectionCount { + type Error = String; + fn try_from(value: i32) -> Result { + return match value { + 2 => Ok(EnumIntersectionCount::Value2), + 3 => Ok(EnumIntersectionCount::Value3), + other => { + Err( + format!( + "`{}` is not a value of `{}`", other, "EnumIntersectionCount" + ), + ) + } + }; + } +} + +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] +pub enum EnumReversedLabel { + #[serde(rename = "a-b")] + AB, + #[serde(rename = "a_b")] + Ab2, +} + +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] +#[serde(try_from = "i32", into = "i32")] +#[repr(i32)] +pub enum EnumReversedCount { + Value2 = 2, + Value3 = 3, +} +impl From for i32 { + fn from(value: EnumReversedCount) -> Self { + return match value { + EnumReversedCount::Value2 => 2, + EnumReversedCount::Value3 => 3, + }; + } +} +impl TryFrom for EnumReversedCount { + type Error = String; + fn try_from(value: i32) -> Result { + return match value { + 2 => Ok(EnumReversedCount::Value2), + 3 => Ok(EnumReversedCount::Value3), + other => { + Err(format!("`{}` is not a value of `{}`", other, "EnumReversedCount")) + } + }; + } +} diff --git a/docs/design.md b/docs/design.md index f53676f..0b8e646 100644 --- a/docs/design.md +++ b/docs/design.md @@ -361,7 +361,9 @@ An absent format, pattern, or `multipleOf` retains the other member's constraint Conflicting specified values, property types, or metadata produce an error instead of an order-dependent override. Composite overlaps require identical definitions. Enum intersections with -nullability, formats, scalar constraints, or positional variant names are unsupported. +nullability, scalar constraints, or positional variant names are unsupported. +String enum intersections with formats are unsupported. +Integer enum formats retain their representation limits. Nullable formatted-string constraints are also unsupported. Member access flags, defaults, extensions, discriminators, and nullability cannot be flattened. Single-reference aliases and nullable wrappers retain