From 5d96e306479fece4db0436921490c50d4c525139 Mon Sep 17 00:00:00 2001 From: Ben Pfaff Date: Mon, 13 Nov 2023 06:41:05 -0800 Subject: [PATCH] Support `#[serde(flatten)]` for maps. (#799) `utoipa` already supported `#[serde(flatten)]` on fields with structure type, by putting the fields inside those structures into the parent type. This is commonly used for factoring out frequently used keys, as documented at . `#[serde(flatten)]` has another use that utoipa does not support: to capture additional unnamed fields within a structure, as documented at . This commit adds support for that functionality as well as a pair of tests. It only makes sense to have one such field per structure, so this commit reports an error if there is more than one. --- utoipa-gen/src/component.rs | 67 ++++++++++++++++++++++++++ utoipa-gen/src/component/schema.rs | 56 ++++++++++++++++----- utoipa-gen/tests/schema_derive_test.rs | 44 +++++++++++++++++ 3 files changed, 154 insertions(+), 13 deletions(-) diff --git a/utoipa-gen/src/component.rs b/utoipa-gen/src/component.rs index 7751462b..4fd1a009 100644 --- a/utoipa-gen/src/component.rs +++ b/utoipa-gen/src/component.rs @@ -347,6 +347,11 @@ impl<'t> TypeTree<'t> { pub fn is_option(&self) -> bool { matches!(self.generic_type, Some(GenericType::Option)) } + + /// Check whether the [`TypeTree`]'s `generic_type` is [`GenericType::Map`] + pub fn is_map(&self) -> bool { + matches!(self.generic_type, Some(GenericType::Map)) + } } impl PartialEq for TypeTree<'_> { @@ -914,3 +919,65 @@ impl ToTokens for ComponentSchema { self.tokens.to_tokens(tokens) } } + +#[cfg_attr(feature = "debug", derive(Debug))] +pub struct FlattenedMapSchema { + tokens: TokenStream, +} + +impl<'c> FlattenedMapSchema { + pub fn new( + ComponentSchemaProps { + type_tree, + features, + description, + deprecated, + object_name, + }: ComponentSchemaProps, + ) -> Self { + let mut tokens = TokenStream::new(); + let mut features = features.unwrap_or(Vec::new()); + let deprecated_stream = ComponentSchema::get_deprecated(deprecated); + let description_stream = ComponentSchema::get_description(description); + + let example = features.pop_by(|feature| matches!(feature, Feature::Example(_))); + let nullable = pop_feature!(features => Feature::Nullable(_)); + let default = pop_feature!(features => Feature::Default(_)); + + // Maps are treated as generic objects with no named properties and + // additionalProperties denoting the type + // maps have 2 child schemas and we are interested the second one of them + // which is used to determine the additional properties + let schema_property = ComponentSchema::new(ComponentSchemaProps { + type_tree: type_tree + .children + .as_ref() + .expect("ComponentSchema Map type should have children") + .iter() + .nth(1) + .expect("ComponentSchema Map type should have 2 child"), + features: Some(features), + description: None, + deprecated: None, + object_name, + }); + + tokens.extend(quote! { + #schema_property + #description_stream + #deprecated_stream + #default + }); + + example.to_tokens(&mut tokens); + nullable.to_tokens(&mut tokens); + + Self { tokens } + } +} + +impl ToTokens for FlattenedMapSchema { + fn to_tokens(&self, tokens: &mut TokenStream) { + self.tokens.to_tokens(tokens) + } +} diff --git a/utoipa-gen/src/component/schema.rs b/utoipa-gen/src/component/schema.rs index e1fe1268..3bae544c 100644 --- a/utoipa-gen/src/component/schema.rs +++ b/utoipa-gen/src/component/schema.rs @@ -33,7 +33,7 @@ use super::{ RenameAll, ToTokensExt, }, serde::{self, SerdeContainer, SerdeEnumRepr, SerdeValue}, - ComponentSchema, FieldRename, TypeTree, ValueType, VariantRename, + ComponentSchema, FieldRename, FlattenedMapSchema, TypeTree, ValueType, VariantRename, }; mod enum_variant; @@ -292,6 +292,7 @@ impl NamedStructSchema<'_> { fn field_as_schema_property( &self, field: &Field, + flatten: bool, container_rules: &Option, yield_: impl FnOnce(NamedStructFieldOptions<'_>) -> R, ) -> R { @@ -364,13 +365,18 @@ impl NamedStructSchema<'_> { property: if let Some(schema_with) = schema_with { Property::SchemaWith(schema_with) } else { - Property::Schema(ComponentSchema::new(super::ComponentSchemaProps { + let cs = super::ComponentSchemaProps { type_tree, features: field_features, description: Some(&comments), deprecated: deprecated.as_ref(), object_name: self.struct_name.as_ref(), - })) + }; + if flatten && type_tree.is_map() { + Property::FlattenedMap(FlattenedMapSchema::new(cs)) + } else { + Property::Schema(ComponentSchema::new(cs)) + } }, rename_field_value: rename_field, required, @@ -383,7 +389,7 @@ impl ToTokens for NamedStructSchema<'_> { fn to_tokens(&self, tokens: &mut TokenStream) { let container_rules = serde::parse_container(self.attributes); - let object_tokens = self + let mut object_tokens = self .fields .iter() .filter_map(|field| { @@ -406,6 +412,7 @@ impl ToTokens for NamedStructSchema<'_> { self.field_as_schema_property( field, + false, &container_rules, |NamedStructFieldOptions { property, @@ -467,23 +474,44 @@ impl ToTokens for NamedStructSchema<'_> { .collect(); if !flatten_fields.is_empty() { - tokens.extend(quote! { - utoipa::openapi::AllOfBuilder::new() - }); - + let mut flattened_tokens = TokenStream::new(); + let mut flattened_map_field = None; for field in flatten_fields { self.field_as_schema_property( field, + true, &container_rules, - |NamedStructFieldOptions { property, .. }| { - tokens.extend(quote! { .item(#property) }); + |NamedStructFieldOptions { property, .. }| match property { + Property::Schema(_) | Property::SchemaWith(_) => { + flattened_tokens.extend(quote! { .item(#property) }) + } + Property::FlattenedMap(_) => match flattened_map_field { + None => { + object_tokens + .extend(quote! { .additional_properties(Some(#property)) }); + flattened_map_field = Some(field); + } + Some(flattened_map_field) => { + abort!(self.fields, + "The structure `{}` contains multiple flattened map fields.", + self.struct_name; + note = flattened_map_field.span() => "first flattened map field was declared here as `{}`", flattened_map_field.ident.as_ref().unwrap(); + note = field.span() => "second flattened map field was declared here as `{}`", field.ident.as_ref().unwrap()); + }, + }, }, ) } - tokens.extend(quote! { - .item(#object_tokens) - }) + if flattened_tokens.is_empty() { + tokens.extend(object_tokens) + } else { + tokens.extend(quote! { + utoipa::openapi::AllOfBuilder::new() + #flattened_tokens + .item(#object_tokens) + }) + } } else { tokens.extend(object_tokens) } @@ -1448,12 +1476,14 @@ struct TypeTuple<'a, T>(T, &'a Ident); enum Property { Schema(ComponentSchema), SchemaWith(Feature), + FlattenedMap(FlattenedMapSchema), } impl ToTokens for Property { fn to_tokens(&self, tokens: &mut TokenStream) { match self { Self::Schema(schema) => schema.to_tokens(tokens), + Self::FlattenedMap(schema) => schema.to_tokens(tokens), Self::SchemaWith(schema_with) => schema_with.to_tokens(tokens), } } diff --git a/utoipa-gen/tests/schema_derive_test.rs b/utoipa-gen/tests/schema_derive_test.rs index d1cb518e..73da6934 100644 --- a/utoipa-gen/tests/schema_derive_test.rs +++ b/utoipa-gen/tests/schema_derive_test.rs @@ -125,6 +125,50 @@ fn derive_map_free_form_property() { ) } +#[test] +fn derive_flattened_map_string_property() { + let map = api_doc! { + #[derive(Serialize)] + struct Map { + #[serde(flatten)] + map: HashMap, + } + }; + + assert_json_eq!( + map, + json!({ + "additionalProperties": {"type": "string"}, + "type": "object" + }) + ) +} + +#[test] +fn derive_flattened_map_ref_property() { + #[derive(Serialize, ToSchema)] + #[allow(unused)] + enum Foo { + Variant, + } + + let map = api_doc! { + #[derive(Serialize)] + struct Map { + #[serde(flatten)] + map: HashMap, + } + }; + + assert_json_eq!( + map, + json!({ + "additionalProperties": {"$ref": "#/components/schemas/Foo"}, + "type": "object" + }) + ) +} + #[test] fn derive_enum_with_additional_properties_success() { let mode = api_doc! {