diff --git a/utoipa-gen/Cargo.toml b/utoipa-gen/Cargo.toml index ef8ad82a..5f51c520 100644 --- a/utoipa-gen/Cargo.toml +++ b/utoipa-gen/Cargo.toml @@ -23,7 +23,7 @@ ulid = { version = "1", optional = true, default-features = false } url = { version = "2", optional = true } [dev-dependencies] -utoipa = { path = "../utoipa", features = ["uuid"], default-features = false } +utoipa = { path = "../utoipa", features = ["debug", "uuid"], default-features = false } serde_json = "1" serde = "1" actix-web = { version = "4", features = ["macros"], default-features = false } diff --git a/utoipa-gen/src/component/schema.rs b/utoipa-gen/src/component/schema.rs index 3bae544c..490d12da 100644 --- a/utoipa-gen/src/component/schema.rs +++ b/utoipa-gen/src/component/schema.rs @@ -473,7 +473,7 @@ impl ToTokens for NamedStructSchema<'_> { }) .collect(); - if !flatten_fields.is_empty() { + let all_of = if !flatten_fields.is_empty() { let mut flattened_tokens = TokenStream::new(); let mut flattened_map_field = None; for field in flatten_fields { @@ -504,16 +504,30 @@ impl ToTokens for NamedStructSchema<'_> { } if flattened_tokens.is_empty() { - tokens.extend(object_tokens) + tokens.extend(object_tokens); + false } else { tokens.extend(quote! { utoipa::openapi::AllOfBuilder::new() #flattened_tokens .item(#object_tokens) - }) + }); + true } } else { - tokens.extend(object_tokens) + tokens.extend(object_tokens); + false + }; + + if !all_of + && container_rules + .as_ref() + .and_then(|container_rule| Some(container_rule.deny_unknown_fields)) + .unwrap_or(false) + { + tokens.extend(quote! { + .additional_properties(Some(utoipa::openapi::schema::AdditionalProperties::FreeForm(false))) + }); } if let Some(deprecated) = super::get_deprecated(self.attributes) { diff --git a/utoipa-gen/src/component/serde.rs b/utoipa-gen/src/component/serde.rs index ff928bf1..4f5efb6c 100644 --- a/utoipa-gen/src/component/serde.rs +++ b/utoipa-gen/src/component/serde.rs @@ -24,6 +24,7 @@ fn parse_next_lit_str(next: Cursor) -> Option<(String, Span)> { #[derive(Default)] #[cfg_attr(feature = "debug", derive(Debug))] +#[cfg_attr(test, derive(PartialEq, Eq))] pub struct SerdeValue { pub skip: bool, pub rename: Option, @@ -89,6 +90,7 @@ impl SerdeValue { /// The default case (when no serde attributes are present) is `ExternallyTagged`. #[derive(Clone)] #[cfg_attr(feature = "debug", derive(Debug))] +#[cfg_attr(test, derive(PartialEq, Eq))] pub enum SerdeEnumRepr { ExternallyTagged, InternallyTagged { @@ -116,10 +118,12 @@ impl Default for SerdeEnumRepr { /// Attributes defined within a `#[serde(...)]` container attribute. #[derive(Default)] #[cfg_attr(feature = "debug", derive(Debug))] +#[cfg_attr(test, derive(PartialEq, Eq))] pub struct SerdeContainer { pub rename_all: Option, pub enum_repr: SerdeEnumRepr, pub default: bool, + pub deny_unknown_fields: bool, } impl SerdeContainer { @@ -129,6 +133,7 @@ impl SerdeContainer { /// * `content = ...` /// * `untagged = ...` /// * `default = ...` + /// * `deny_unknown_fields` fn parse_attribute(&mut self, ident: Ident, next: Cursor) -> syn::Result<()> { match ident.to_string().as_str() { "rename_all" => { @@ -188,6 +193,9 @@ impl SerdeContainer { "default" => { self.default = true; } + "deny_unknown_fields" => { + self.deny_unknown_fields = true; + } _ => {} } Ok(()) @@ -262,6 +270,9 @@ pub fn parse_container(attributes: &[Attribute]) -> Option { if value.default { acc.default = value.default; } + if value.deny_unknown_fields { + acc.deny_unknown_fields = value.deny_unknown_fields; + } match value.enum_repr { SerdeEnumRepr::ExternallyTagged => {} SerdeEnumRepr::Untagged @@ -282,6 +293,7 @@ pub fn parse_container(attributes: &[Attribute]) -> Option { #[derive(Clone)] #[cfg_attr(feature = "debug", derive(Debug))] +#[cfg_attr(test, derive(PartialEq, Eq))] pub enum RenameRule { Lower, Upper, @@ -395,7 +407,8 @@ impl FromStr for RenameRule { #[cfg(test)] mod tests { - use super::{RenameRule, RENAME_RULE_NAME_MAPPING}; + use super::{parse_container, RenameRule, SerdeContainer, RENAME_RULE_NAME_MAPPING}; + use syn::{parse_quote, Attribute}; macro_rules! test_rename_rule { ( $($case:expr=> $value:literal = $expected:literal)* ) => { @@ -467,4 +480,35 @@ mod tests { s.parse::().unwrap(); } } + + #[test] + fn test_serde_parse_container() { + let default_attribute_1: syn::Attribute = parse_quote! { + #[serde(default)] + }; + let default_attribute_2: syn::Attribute = parse_quote! { + #[serde(default)] + }; + let deny_unknown_fields_attribute: syn::Attribute = parse_quote! { + #[serde(deny_unknown_fields)] + }; + let unsupported_attribute: syn::Attribute = parse_quote! { + #[serde(expecting = "...")] + }; + let attributes: &[Attribute] = &[ + default_attribute_1, + default_attribute_2, + deny_unknown_fields_attribute, + unsupported_attribute, + ]; + + let expected = SerdeContainer { + default: true, + deny_unknown_fields: true, + ..Default::default() + }; + + let result = parse_container(attributes).unwrap(); + assert_eq!(expected, result); + } } diff --git a/utoipa-gen/src/lib.rs b/utoipa-gen/src/lib.rs index ff6eaf59..aede1e90 100644 --- a/utoipa-gen/src/lib.rs +++ b/utoipa-gen/src/lib.rs @@ -238,6 +238,7 @@ use self::{ /// * `untagged` Supported at the container level. Allows [untagged /// enum representation](https://serde.rs/enum-representations.html#untagged). /// * `default` Supported at the container level and field level according to [serde attributes]. +/// * `deny_unknown_fields` Supported at the container level. /// * `flatten` Supported at the field level. /// /// Other _`serde`_ attributes works as is but does not have any effect on the generated OpenAPI doc. diff --git a/utoipa-gen/tests/schema_derive_test.rs b/utoipa-gen/tests/schema_derive_test.rs index 73da6934..2972020e 100644 --- a/utoipa-gen/tests/schema_derive_test.rs +++ b/utoipa-gen/tests/schema_derive_test.rs @@ -3771,6 +3771,31 @@ fn derive_schema_with_default_struct() { ) } +#[test] +fn derive_struct_with_no_additional_properties() { + let value = api_doc! { + #[derive(serde::Deserialize, Default)] + #[serde(deny_unknown_fields)] + struct MyValue { + field: String + } + }; + + assert_json_eq!( + value, + json!({ + "properties": { + "field": { + "type": "string", + } + }, + "required": ["field"], + "additionalProperties": false, + "type": "object" + }) + ) +} + #[test] #[cfg(feature = "repr")] fn derive_schema_for_repr_enum() {