From 8bbc5d988a5e943d3fe12bf5d895dbba766a4b1f Mon Sep 17 00:00:00 2001 From: Juha Kukkonen Date: Mon, 7 Oct 2024 21:58:28 +0300 Subject: [PATCH] Adds support for `prefixItems` on `Array` (#1103) This PR adds support for `prefixItems` as defined in JSON schema specification https://json-schema.org/understanding-json-schema/reference/array#tupleValidation. The `prefixItems` are used to correctly represent tuple values Rust tuples. This will remove the old `allOf` behavior known from OpenAPI 3.0 which fails to correctly represent a tuple. Prefix items are conforming OpenAPI 3.1 and is coming from JSON Schema. Add new type `ArrayItems` what represent [`Array::items`] supported values. ### Breaking This commit removes `allOf` tuple behavior, replacing with with all new `prefixItems` while setting `items` to false. Closes #901 --- utoipa-gen/CHANGELOG.md | 1 + utoipa-gen/src/component.rs | 22 ++-- utoipa-gen/tests/schema_derive_test.rs | 59 +++++----- utoipa/CHANGELOG.md | 1 + utoipa/src/openapi/schema.rs | 151 +++++++++++++++++++++++-- 5 files changed, 186 insertions(+), 48 deletions(-) diff --git a/utoipa-gen/CHANGELOG.md b/utoipa-gen/CHANGELOG.md index 5cedeb8d..32ef963b 100644 --- a/utoipa-gen/CHANGELOG.md +++ b/utoipa-gen/CHANGELOG.md @@ -74,6 +74,7 @@ ### Breaking +* Adds support for `prefixItems` on `Array` (https://github.com/juhaku/utoipa/pull/1103) * Auto collect tuple responses schema references (https://github.com/juhaku/utoipa/pull/1071) * Implement automatic schema collection for requests (https://github.com/juhaku/utoipa/pull/1066) * Refactor enums processing (https://github.com/juhaku/utoipa/pull/1059) diff --git a/utoipa-gen/src/component.rs b/utoipa-gen/src/component.rs index d71f4dff..4d1118ff 100644 --- a/utoipa-gen/src/component.rs +++ b/utoipa-gen/src/component.rs @@ -1199,14 +1199,16 @@ impl ComponentSchema { .children .as_ref() .map_try(|children| { - let all_of = children + let prefix_items = children .iter() .map(|child| { - let features = if child.is_option() { + let mut features = if child.is_option() { vec![Feature::Nullable(Nullable::new())] } else { Vec::new() }; + // Prefix item is always inlined + features.push(Feature::Inline(true.into())); match ComponentSchema::new(ComponentSchemaProps { container, @@ -1214,20 +1216,15 @@ impl ComponentSchema { features, description: None, }) { - Ok(child) => Ok(child.to_token_stream()), + Ok(child) => Ok(quote! { + Into::::into(#child) + }), Err(diagnostics) => Err(diagnostics), } }) .collect::, Diagnostics>>()? .into_iter() - .fold( - quote! { utoipa::openapi::schema::AllOfBuilder::new() }, - |mut all_of, child_tokens| { - all_of.extend(quote!( .item(#child_tokens) )); - - all_of - }, - ); + .collect::>(); let nullable_schema_type = ComponentSchema::get_schema_type_override( nullable_feat, @@ -1236,7 +1233,8 @@ impl ComponentSchema { Result::::Ok(quote! { utoipa::openapi::schema::ArrayBuilder::new() #nullable_schema_type - .items(#all_of) + .items(utoipa::openapi::schema::ArrayItems::False) + .prefix_items(#prefix_items.to_vec()) #description_stream #deprecated }) diff --git a/utoipa-gen/tests/schema_derive_test.rs b/utoipa-gen/tests/schema_derive_test.rs index 74982e23..c914a60e 100644 --- a/utoipa-gen/tests/schema_derive_test.rs +++ b/utoipa-gen/tests/schema_derive_test.rs @@ -4920,28 +4920,34 @@ fn derive_tuple_named_struct_field() { info: (String, i64, bool, Person) } }; + assert_json_eq!( value, json!({ "properties": { "info": { - "items": { - "allOf": [ - { - "type": "string" - }, - { - "type": "integer", - "format": "int64", - }, - { - "type": "boolean", + "prefixItems": [ + { + "type": "string" + }, + { + "type": "integer", + "format": "int64", + }, + { + "type": "boolean", + }, + { + "properties": { + "name": { + "type": "string" + } }, - { - "$ref": "#/components/schemas/Person" - } - ] - }, + "required": ["name"], + "type": "object" + } + ], + "items": false, "type": "array" } }, @@ -4966,17 +4972,16 @@ fn derive_nullable_tuple() { json!({ "properties": { "info": { - "items": { - "allOf": [ - { - "type": "string" - }, - { - "type": "integer", - "format": "int64", - }, - ] - }, + "prefixItems": [ + { + "type": "string" + }, + { + "type": "integer", + "format": "int64", + }, + ], + "items": false, "type": ["array", "null"], "deprecated": true, "description": "This is description", diff --git a/utoipa/CHANGELOG.md b/utoipa/CHANGELOG.md index 84edc5bc..7f339e1c 100644 --- a/utoipa/CHANGELOG.md +++ b/utoipa/CHANGELOG.md @@ -54,6 +54,7 @@ to look into changes introduced to **`utoipa-gen`**. ### Breaking +* Adds support for `prefixItems` on `Array` (https://github.com/juhaku/utoipa/pull/1103) * Implement automatic schema collection for requests (https://github.com/juhaku/utoipa/pull/1066) * Refactor enums processing (https://github.com/juhaku/utoipa/pull/1059) * Feature openapi 31 (https://github.com/juhaku/utoipa/pull/981) diff --git a/utoipa/src/openapi/schema.rs b/utoipa/src/openapi/schema.rs index 419d767d..27829f34 100644 --- a/utoipa/src/openapi/schema.rs +++ b/utoipa/src/openapi/schema.rs @@ -536,6 +536,12 @@ impl From for RefOr { } } +impl From for ArrayItems { + fn from(value: OneOfBuilder) -> Self { + Self::RefOrSchema(Box::new(value.into())) + } +} + component_from_builder!(OneOfBuilder); builder! { @@ -707,6 +713,12 @@ impl From for RefOr { } } +impl From for ArrayItems { + fn from(value: AllOfBuilder) -> Self { + Self::RefOrSchema(Box::new(value.into())) + } +} + component_from_builder!(AllOfBuilder); builder! { @@ -868,6 +880,12 @@ impl From for RefOr { } } +impl From for ArrayItems { + fn from(value: AnyOfBuilder) -> Self { + Self::RefOrSchema(Box::new(value.into())) + } +} + component_from_builder!(AnyOfBuilder); #[cfg(not(feature = "preserve_order"))] @@ -1071,6 +1089,12 @@ impl From for Schema { } } +impl From for ArrayItems { + fn from(value: Object) -> Self { + Self::RefOrSchema(Box::new(value.into())) + } +} + impl ToArray for Object {} impl ObjectBuilder { @@ -1272,6 +1296,12 @@ impl From> for Schema { } } +impl From for ArrayItems { + fn from(value: ObjectBuilder) -> Self { + Self::RefOrSchema(Box::new(value.into())) + } +} + /// AdditionalProperties is used to define values of map fields of the [`Schema`]. /// /// The value can either be [`RefOr`] or _`bool`_. @@ -1414,12 +1444,24 @@ impl From for RefOr { } } +impl From for ArrayItems { + fn from(value: RefBuilder) -> Self { + Self::RefOrSchema(Box::new(value.into())) + } +} + impl From for RefOr { fn from(r: Ref) -> Self { Self::Ref(r) } } +impl From for ArrayItems { + fn from(value: Ref) -> Self { + Self::RefOrSchema(Box::new(value.into())) + } +} + impl From for RefOr { fn from(t: T) -> Self { Self::T(t) @@ -1467,6 +1509,70 @@ where } } +/// Represents [`Array`] items in [JSON Schema Array][json_schema_array]. +/// +/// [json_schema_array]: +#[derive(Serialize, Deserialize, Clone, PartialEq)] +#[cfg_attr(feature = "debug", derive(Debug))] +#[serde(untagged)] +pub enum ArrayItems { + /// Defines [`Array::items`] as [`RefOr::T(Schema)`]. This is the default for [`Array`]. + RefOrSchema(Box>), + /// Defines [`Array::items`] as `false` indicating that no extra items are allowed to the + /// [`Array`]. This can be used together with [`Array::prefix_items`] to disallow [additional + /// items][additional_items] in [`Array`]. + /// + /// [additional_items]: + #[serde(with = "array_items_false")] + False, +} + +mod array_items_false { + use serde::de::Visitor; + + pub fn serialize(serializer: S) -> Result { + serializer.serialize_bool(false) + } + + pub fn deserialize<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result<(), D::Error> { + struct ItemsFalseVisitor; + + impl<'de> Visitor<'de> for ItemsFalseVisitor { + type Value = (); + fn visit_bool(self, v: bool) -> Result + where + E: serde::de::Error, + { + if !v { + Ok(()) + } else { + Err(serde::de::Error::custom(format!( + "invalid boolean value: {v}, expected false" + ))) + } + } + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("expected boolean false") + } + } + + deserializer.deserialize_bool(ItemsFalseVisitor) + } +} + +impl Default for ArrayItems { + fn default() -> Self { + Self::RefOrSchema(Box::new(Object::with_type(SchemaType::AnyValue).into())) + } +} + +impl From> for ArrayItems { + fn from(value: RefOr) -> Self { + Self::RefOrSchema(Box::new(value)) + } +} + builder! { ArrayBuilder; @@ -1486,8 +1592,15 @@ builder! { #[serde(skip_serializing_if = "Option::is_none")] pub title: Option, - /// Schema representing the array items type. - pub items: Box>, + /// Items of the [`Array`]. + pub items: ArrayItems, + + /// Prefix items of [`Array`] is used to define item validation of tuples according [JSON schema + /// item validation][item_validation]. + /// + /// [item_validation]: + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub prefix_items: Vec, /// Description of the [`Array`]. Markdown syntax is supported. #[serde(skip_serializing_if = "Option::is_none")] @@ -1541,6 +1654,7 @@ impl Default for Array { schema_type: Type::Array.into(), unique_items: bool::default(), items: Default::default(), + prefix_items: Vec::default(), description: Default::default(), deprecated: Default::default(), example: Default::default(), @@ -1566,7 +1680,7 @@ impl Array { /// ``` pub fn new>>(component: I) -> Self { Self { - items: Box::new(component.into()), + items: ArrayItems::RefOrSchema(Box::new(component.into())), ..Default::default() } } @@ -1582,7 +1696,7 @@ impl Array { /// ``` pub fn new_nullable>>(component: I) -> Self { Self { - items: Box::new(component.into()), + items: ArrayItems::RefOrSchema(Box::new(component.into())), schema_type: SchemaType::from_iter([Type::Array, Type::Null]), ..Default::default() } @@ -1591,8 +1705,21 @@ impl Array { impl ArrayBuilder { /// Set [`Schema`] type for the [`Array`]. - pub fn items>>(mut self, component: I) -> Self { - set_value!(self items Box::new(component.into())) + pub fn items>(mut self, items: I) -> Self { + set_value!(self items items.into()) + } + + /// Add prefix items of [`Array`] to define item validation of tuples according [JSON schema + /// item validation][item_validation]. + /// + /// [item_validation]: + pub fn prefix_items, S: Into>(mut self, items: I) -> Self { + self.prefix_items = items + .into_iter() + .map(|item| item.into()) + .collect::>(); + + self } /// Change type of the array e.g. to change type to _`string`_ @@ -1681,6 +1808,12 @@ impl From for Schema { } } +impl From for ArrayItems { + fn from(value: ArrayBuilder) -> Self { + Self::RefOrSchema(Box::new(value.into())) + } +} + impl From for RefOr { fn from(array: ArrayBuilder) -> Self { Self::T(Schema::Array(array.build())) @@ -2128,9 +2261,9 @@ mod tests { ); let json_value = ObjectBuilder::new() - .additional_properties(Some( - ArrayBuilder::new().items(ObjectBuilder::new().schema_type(Type::Number)), - )) + .additional_properties(Some(ArrayBuilder::new().items(ArrayItems::RefOrSchema( + Box::new(ObjectBuilder::new().schema_type(Type::Number).into()), + )))) .build(); assert_json_eq!( json_value,