Skip to content

Commit

Permalink
Support #[serde(flatten)] for maps. (#799)
Browse files Browse the repository at this point in the history
`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 <https://serde.rs/attr-flatten.html#factor-out-frequently-grouped-keys>.

`#[serde(flatten)]` has another use that utoipa does not support: to
capture additional unnamed fields within a structure, as documented at
<https://serde.rs/attr-flatten.html#capture-additional-fields>.  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.
  • Loading branch information
blp authored Nov 13, 2023
1 parent 83356da commit 5d96e30
Show file tree
Hide file tree
Showing 3 changed files with 154 additions and 13 deletions.
67 changes: 67 additions & 0 deletions utoipa-gen/src/component.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<'_> {
Expand Down Expand Up @@ -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)
}
}
56 changes: 43 additions & 13 deletions utoipa-gen/src/component/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -292,6 +292,7 @@ impl NamedStructSchema<'_> {
fn field_as_schema_property<R>(
&self,
field: &Field,
flatten: bool,
container_rules: &Option<SerdeContainer>,
yield_: impl FnOnce(NamedStructFieldOptions<'_>) -> R,
) -> R {
Expand Down Expand Up @@ -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,
Expand All @@ -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| {
Expand All @@ -406,6 +412,7 @@ impl ToTokens for NamedStructSchema<'_> {

self.field_as_schema_property(
field,
false,
&container_rules,
|NamedStructFieldOptions {
property,
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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),
}
}
Expand Down
44 changes: 44 additions & 0 deletions utoipa-gen/tests/schema_derive_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, String>,
}
};

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<String, Foo>,
}
};

assert_json_eq!(
map,
json!({
"additionalProperties": {"$ref": "#/components/schemas/Foo"},
"type": "object"
})
)
}

#[test]
fn derive_enum_with_additional_properties_success() {
let mode = api_doc! {
Expand Down

0 comments on commit 5d96e30

Please sign in to comment.