From 8d3e4435f836c081777069156ed2a6a05e8df83e Mon Sep 17 00:00:00 2001 From: Juha Kukkonen Date: Tue, 8 Nov 2022 23:48:47 +0200 Subject: [PATCH] Refactor IntoParams attribute parsing functionality (#339) Refactor IntoParams field attribute parsing functionality to use the new Feature based parsing. Add new features for `Rename`, `RenameAll`, `Style`, `AllowReserved` and `Explode`. Add support for serde `default`, `rename` and `rename_all` attributes for container and field level similar to `ToSchema` implementation. Update docs and add tests for renaming and supported serde attributes. Resolves #309, fixes #313 --- utoipa-gen/src/component.rs | 37 +- utoipa-gen/src/component/features.rs | 274 ++++++++++++- utoipa-gen/src/component/into_params.rs | 342 +++++++--------- utoipa-gen/src/component/schema.rs | 61 ++- utoipa-gen/src/component/schema/features.rs | 24 +- utoipa-gen/src/component/serde.rs | 43 +- utoipa-gen/src/lib.rs | 18 +- utoipa-gen/src/path/parameter.rs | 14 +- utoipa-gen/tests/path_derive.rs | 413 ++++++++++++++++++++ 9 files changed, 918 insertions(+), 308 deletions(-) diff --git a/utoipa-gen/src/component.rs b/utoipa-gen/src/component.rs index bc3f37b3..e12f4ddb 100644 --- a/utoipa-gen/src/component.rs +++ b/utoipa-gen/src/component.rs @@ -12,7 +12,20 @@ pub mod into_params; mod features; pub mod schema; -mod serde; +pub mod serde; + +/// Check whether either serde `container_rule` or `field_rule` has _`default`_ attribute set. +#[inline] +fn is_default(container_rules: &Option<&SerdeContainer>, field_rule: &Option<&SerdeValue>) -> bool { + container_rules + .as_ref() + .map(|rule| rule.default) + .unwrap_or(false) + || field_rule + .as_ref() + .map(|rule| rule.default) + .unwrap_or(false) +} /// Find `#[deprecated]` attribute from given attributes. Typically derive type attributes /// or field attributes of struct. @@ -293,29 +306,25 @@ trait Rename { /// /// Method accepts 3 arguments. /// * `value` to rename. -/// * `field_rule` which is used for renaming fields with _`rename`_ property. +/// * `to` Optional rename to value for fields with _`rename`_ property. /// * `container_rule` which is used to rename containers with _`rename_all`_ property. fn rename<'r, R: Rename>( value: &'r str, - field_rule: &'r Option, - container_rule: &'r Option, + to: Option<&'r str>, + container_rule: Option<&'r RenameRule>, ) -> Option> { - let rename = field_rule.as_ref().and_then(|field_rule| { - if !field_rule.rename.is_empty() { - Some(Cow::Borrowed(&*field_rule.rename)) + let rename = to.as_ref().and_then(|to| { + if !to.is_empty() { + Some(Cow::Borrowed(*to)) } else { None } }); rename.or_else(|| { - container_rule.as_ref().and_then(|container_rule| { - container_rule - .rename_all - .as_ref() - .map(|rule| R::rename(rule, value)) - .map(Cow::Owned) - }) + container_rule + .as_ref() + .map(|container_rule| Cow::Owned(R::rename(container_rule, value))) }) } diff --git a/utoipa-gen/src/component/features.rs b/utoipa-gen/src/component/features.rs index 7ee53411..df1a5746 100644 --- a/utoipa-gen/src/component/features.rs +++ b/utoipa-gen/src/component/features.rs @@ -3,11 +3,16 @@ use std::mem; use proc_macro2::{Ident, Span, TokenStream}; use proc_macro_error::abort; use quote::{quote, ToTokens}; -use syn::{parenthesized, parse::Parse}; +use syn::{parenthesized, parse::Parse, LitStr}; -use crate::{parse_utils, schema_type::SchemaFormat, AnyValue}; +use crate::{ + parse_utils, + path::parameter::{self, ParameterStyle}, + schema_type::SchemaFormat, + AnyValue, +}; -use super::{schema, GenericType, TypeTree}; +use super::{schema, serde::RenameRule, GenericType, TypeTree}; pub trait Name { fn get_name() -> &'static str; @@ -36,6 +41,13 @@ pub enum Feature { ReadOnly(ReadOnly), Title(Title), Nullable(Nullable), + Rename(Rename), + RenameAll(RenameAll), + Style(Style), + AllowReserve(AllowReserved), + Explode(Explode), + ParameterIn(ParameterIn), + IntoParamsNames(Names), } impl Feature { @@ -52,6 +64,13 @@ impl Feature { "read_only" => ReadOnly::parse(input).map(Self::ReadOnly), "title" => Title::parse(input).map(Self::Title), "nullable" => Nullable::parse(input).map(Self::Nullable), + "rename" => Rename::parse(input).map(Self::Rename), + "rename_all" => RenameAll::parse(input).map(Self::RenameAll), + "style" => Style::parse(input).map(Self::Style), + "allow_reserved" => AllowReserved::parse(input).map(Self::AllowReserve), + "explode" => Explode::parse(input).map(Self::Explode), + "parameter_in" => ParameterIn::parse(input).map(Self::ParameterIn), + "names" => Names::parse(input).map(Self::IntoParamsNames), _unexpected => Err(syn::Error::new(ident.span(), format!("unexpected name: {}, expected one of: default, example, inline, xml, format, value_type, write_only, read_only, title", _unexpected))), } } @@ -62,13 +81,25 @@ impl ToTokens for Feature { let feature = match &self { Feature::Default(default) => quote! { .default(Some(#default)) }, Feature::Example(example) => quote! { .example(Some(#example)) }, - Feature::Inline(inline) => quote! { .inline(Some(#inline)) }, Feature::XmlAttr(xml) => quote! { .xml(Some(#xml)) }, Feature::Format(format) => quote! { .format(Some(#format)) }, Feature::WriteOnly(write_only) => quote! { .write_only(Some(#write_only)) }, Feature::ReadOnly(read_only) => quote! { .read_only(Some(#read_only)) }, Feature::Title(title) => quote! { .title(Some(#title)) }, Feature::Nullable(nullable) => quote! { .nullable(#nullable) }, + Feature::Rename(rename) => rename.to_token_stream(), + Feature::Style(style) => quote! { .style(Some(#style)) }, + Feature::ParameterIn(parameter_in) => quote! { .parameter_in(#parameter_in) }, + Feature::AllowReserve(allow_reserved) => { + quote! { .allow_reserved(Some(#allow_reserved)) } + } + Feature::Explode(explode) => quote! { .explode(Some(#explode)) }, + Feature::RenameAll(_) => { + abort! { + Span::call_site(), + "RenameAll feature does not support `ToTokens`" + } + } Feature::ValueType(_) => { abort! { Span::call_site(), @@ -76,6 +107,19 @@ impl ToTokens for Feature { help = "ValueType is supposed to be used with `TypeTree` in same manner as a resolved struct/field type."; } } + Feature::Inline(_) => { + abort! { + Span::call_site(), + "Inline feature does not support `ToTokens`" + } + } + Feature::IntoParamsNames(_) => { + abort! { + Span::call_site(), + "Names feature does not support `ToTokens`"; + help = "Names is only used with IntoParams to artificially give names for unnamed struct type `IntoParams`." + } + } }; tokens.extend(feature) @@ -128,12 +172,6 @@ impl Parse for Inline { } } -impl ToTokens for Inline { - fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) { - tokens.extend(self.0.to_token_stream()) - } -} - name!(Inline = "inline"); #[derive(Default, Clone)] @@ -294,6 +332,156 @@ impl ToTokens for Nullable { name!(Nullable = "nullable"); +#[cfg_attr(feature = "debug", derive(Debug))] +#[derive(Clone)] +pub struct Rename(String); + +impl Rename { + pub fn into_value(self) -> String { + self.0 + } +} + +impl Parse for Rename { + fn parse(input: syn::parse::ParseStream) -> syn::Result { + parse_utils::parse_next_literal_str(input).map(Self) + } +} + +impl ToTokens for Rename { + fn to_tokens(&self, tokens: &mut TokenStream) { + tokens.extend(self.0.to_token_stream()) + } +} + +name!(Rename = "rename"); + +#[cfg_attr(feature = "debug", derive(Debug))] +#[derive(Clone)] +pub struct RenameAll(RenameRule); + +impl RenameAll { + pub fn as_rename_rule(&self) -> &RenameRule { + &self.0 + } +} + +impl Parse for RenameAll { + fn parse(input: syn::parse::ParseStream) -> syn::Result { + let litstr = parse_utils::parse_next(input, || input.parse::())?; + + litstr + .value() + .parse::() + .map_err(|error| syn::Error::new(litstr.span(), error.to_string())) + .map(Self) + } +} + +name!(RenameAll = "rename_all"); + +#[cfg_attr(feature = "debug", derive(Debug))] +#[derive(Clone)] +pub struct Style(ParameterStyle); + +impl From for Style { + fn from(style: ParameterStyle) -> Self { + Self(style) + } +} + +impl Parse for Style { + fn parse(input: syn::parse::ParseStream) -> syn::Result { + parse_utils::parse_next(input, || input.parse::().map(Self)) + } +} + +impl ToTokens for Style { + fn to_tokens(&self, tokens: &mut TokenStream) { + self.0.to_tokens(tokens) + } +} + +name!(Style = "style"); + +#[cfg_attr(feature = "debug", derive(Debug))] +#[derive(Clone)] +pub struct AllowReserved(bool); + +impl Parse for AllowReserved { + fn parse(input: syn::parse::ParseStream) -> syn::Result { + parse_utils::parse_bool_or_true(input).map(Self) + } +} + +impl ToTokens for AllowReserved { + fn to_tokens(&self, tokens: &mut TokenStream) { + self.0.to_tokens(tokens) + } +} + +name!(AllowReserved = "allow_reserved"); + +#[cfg_attr(feature = "debug", derive(Debug))] +#[derive(Clone)] +pub struct Explode(bool); + +impl Parse for Explode { + fn parse(input: syn::parse::ParseStream) -> syn::Result { + parse_utils::parse_bool_or_true(input).map(Self) + } +} + +impl ToTokens for Explode { + fn to_tokens(&self, tokens: &mut TokenStream) { + self.0.to_tokens(tokens) + } +} + +name!(Explode = "explode"); + +#[cfg_attr(feature = "debug", derive(Debug))] +#[derive(Clone)] +pub struct ParameterIn(parameter::ParameterIn); + +impl Parse for ParameterIn { + fn parse(input: syn::parse::ParseStream) -> syn::Result { + parse_utils::parse_next(input, || input.parse::().map(Self)) + } +} + +impl ToTokens for ParameterIn { + fn to_tokens(&self, tokens: &mut TokenStream) { + self.0.to_tokens(tokens); + } +} + +name!(ParameterIn = "parameter_in"); + +/// Specify names of unnamed fields with `names(...) attribute for `IntoParams` derive. +#[cfg_attr(feature = "debug", derive(Debug))] +#[derive(Clone)] +pub struct Names(Vec); + +impl Names { + pub fn into_values(self) -> Vec { + self.0 + } +} + +impl Parse for Names { + fn parse(input: syn::parse::ParseStream) -> syn::Result { + Ok(Self( + parse_utils::parse_punctuated_within_parenthesis::(input)? + .iter() + .map(LitStr::value) + .collect(), + )) + } +} + +name!(Names = "names"); + macro_rules! parse_features { ($ident:ident as $( $feature:path ),*) => { { @@ -369,6 +557,10 @@ impl ToTokensExt for Vec { pub trait FeaturesExt { fn pop_by(&mut self, op: impl FnMut(&Feature) -> bool) -> Option; + + fn find_value_type_feature_as_value_type(&mut self) -> Option; + + fn find_rename_feature_as_rename(&mut self) -> Option; } impl FeaturesExt for Vec { @@ -377,4 +569,66 @@ impl FeaturesExt for Vec { .position(op) .map(|index| self.swap_remove(index)) } + + fn find_value_type_feature_as_value_type(&mut self) -> Option { + self.pop_by(|feature| matches!(feature, Feature::ValueType(_))) + .and_then(|feature| match feature { + Feature::ValueType(value_type) => Some(value_type), + _ => None, + }) + } + + fn find_rename_feature_as_rename(&mut self) -> Option { + self.pop_by(|feature| matches!(feature, Feature::Rename(_))) + .and_then(|feature| match feature { + Feature::Rename(rename) => Some(rename), + _ => None, + }) + } +} + +impl FeaturesExt for Option> { + fn pop_by(&mut self, op: impl FnMut(&Feature) -> bool) -> Option { + self.as_mut().and_then(|features| features.pop_by(op)) + } + + fn find_value_type_feature_as_value_type(&mut self) -> Option { + self.as_mut() + .and_then(|features| features.find_value_type_feature_as_value_type()) + } + + fn find_rename_feature_as_rename(&mut self) -> Option { + self.as_mut() + .and_then(|features| features.find_rename_feature_as_rename()) + } +} + +macro_rules! pop_feature { + ($features:ident => $value:pat_param) => {{ + $features.pop_by(|feature| matches!(feature, $value)) + }}; } + +pub(crate) use pop_feature; + +pub trait IntoInner { + fn into_inner(self) -> T; +} + +macro_rules! impl_into_inner { + ($ident:ident) => { + impl crate::component::features::IntoInner> for $ident { + fn into_inner(self) -> Vec { + self.0 + } + } + + impl crate::component::features::IntoInner>> for Option<$ident> { + fn into_inner(self) -> Option> { + self.map(crate::component::features::IntoInner::into_inner) + } + } + }; +} + +pub(crate) use impl_into_inner; diff --git a/utoipa-gen/src/component/into_params.rs b/utoipa-gen/src/component/into_params.rs index 28048eaf..f1d0b164 100644 --- a/utoipa-gen/src/component/into_params.rs +++ b/utoipa-gen/src/component/into_params.rs @@ -1,101 +1,50 @@ +use std::borrow::Cow; + use proc_macro2::TokenStream; use proc_macro_error::{abort, ResultExt}; use quote::{quote, quote_spanned, ToTokens}; use syn::{ - parse::Parse, punctuated::Punctuated, spanned::Spanned, token::Comma, Attribute, Data, Error, - Field, Generics, Ident, LitStr, Token, + parse::Parse, punctuated::Punctuated, spanned::Spanned, token::Comma, Attribute, Data, Field, + Generics, Ident, }; use crate::{ + component::{ + self, + features::{ + self, AllowReserved, Example, Explode, Inline, Names, Rename, RenameAll, Style, + }, + FieldRename, + }, doc_comment::CommentAttributes, - parse_utils, - path::parameter::{ParameterExt, ParameterIn, ParameterStyle}, schema_type::{SchemaFormat, SchemaType}, Array, Required, }; -use super::{GenericType, TypeTree, ValueType}; +use super::{ + features::{ + impl_into_inner, parse_features, pop_feature, Feature, FeaturesExt, IntoInner, ToTokensExt, + }, + serde::{self, SerdeContainer}, + GenericType, TypeTree, ValueType, +}; /// Container attribute `#[into_params(...)]`. -#[derive(Default)] -#[cfg_attr(feature = "debug", derive(Debug))] -pub struct IntoParamsAttr { - /// See [`ParameterStyle`]. - style: Option, - /// Specify names of unnamed fields with `names(...) attribute.` - names: Option>, - /// See [`ParameterIn`]. - parameter_in: Option, -} - -impl IntoParamsAttr { - fn merge(mut self, other: Self) -> Self { - if other.style.is_some() { - self.style = other.style; - } - - if other.names.is_some() { - self.names = other.names; - } - - if other.parameter_in.is_some() { - self.parameter_in = other.parameter_in; - } +pub struct IntoParamsFeatures(Vec); - self - } -} - -impl Parse for IntoParamsAttr { +impl Parse for IntoParamsFeatures { fn parse(input: syn::parse::ParseStream) -> syn::Result { - const EXPECTED_ATTRIBUTE: &str = - "unexpected token, expected any of: names, style, parameter_in"; - - let punctuated = - Punctuated::::parse_terminated_with(input, |input| { - let ident: Ident = input.parse::().map_err(|error| { - Error::new(error.span(), format!("{EXPECTED_ATTRIBUTE}, {error}")) - })?; - - Ok(match ident.to_string().as_str() { - "names" => IntoParamsAttr { - names: Some( - parse_utils::parse_punctuated_within_parenthesis::(input)? - .into_iter() - .map(|name| name.value()) - .collect(), - ), - ..IntoParamsAttr::default() - }, - "style" => { - let style: ParameterStyle = - parse_utils::parse_next(input, || input.parse::())?; - IntoParamsAttr { - style: Some(style), - ..IntoParamsAttr::default() - } - } - "parameter_in" => { - let parameter_in: ParameterIn = - parse_utils::parse_next(input, || input.parse::())?; - - IntoParamsAttr { - parameter_in: Some(parameter_in), - ..IntoParamsAttr::default() - } - } - _ => return Err(Error::new(ident.span(), EXPECTED_ATTRIBUTE)), - }) - })?; - - let attributes: IntoParamsAttr = punctuated - .into_iter() - .fold(IntoParamsAttr::default(), |acc, next| acc.merge(next)); - - Ok(attributes) + Ok(Self(parse_features!( + input as Style, + features::ParameterIn, + Names, + RenameAll + ))) } } +impl_into_inner!(IntoParamsFeatures); + #[cfg_attr(feature = "debug", derive(Debug))] pub struct IntoParams { /// Attributes tagged on the whole struct or enum. @@ -113,13 +62,19 @@ impl ToTokens for IntoParams { let ident = &self.ident; let (impl_generics, ty_generics, where_clause) = self.generics.split_for_impl(); - let into_params_attrs: Option = self + let mut into_params_features = self .attrs .iter() .find(|attr| attr.path.is_ident("into_params")) - .map(|attribute| attribute.parse_args::().unwrap_or_abort()); + .map(|attribute| { + attribute + .parse_args::() + .unwrap_or_abort() + .into_inner() + }); + let serde_container = serde::parse_container(&self.attrs); - // #[params] is only supported over fields + // #[param] is only supported over fields if self.attrs.iter().any(|attr| attr.path.is_ident("param")) { abort! { ident, @@ -128,28 +83,42 @@ impl ToTokens for IntoParams { } } + let names = into_params_features.as_mut().and_then(|features| { + features + .pop_by(|feature| matches!(feature, Feature::IntoParamsNames(_))) + .and_then(|feature| match feature { + Feature::IntoParamsNames(names) => Some(names.into_values()), + _ => None, + }) + }); + + let style = pop_feature!(into_params_features => Feature::Style(_)); + let parameter_in = pop_feature!(into_params_features => Feature::ParameterIn(_)); + let rename_all = pop_feature!(into_params_features => Feature::RenameAll(_)); + let params = self - .get_struct_fields( - &into_params_attrs - .as_ref() - .and_then(|params| params.names.as_ref()), - ) + .get_struct_fields(&names.as_ref()) .enumerate() .map(|(index, field)| { Param { field, container_attributes: FieldParamContainerAttributes { - style: into_params_attrs.as_ref().and_then(|attrs| attrs.style), - name: into_params_attrs - .as_ref() - .and_then(|attrs| attrs.names.as_ref()) + rename_all: rename_all.as_ref().and_then(|feature| { + match feature { + Feature::RenameAll(rename_all) => Some(rename_all), + _ => None + } + }), + style: &style, + parameter_in: ¶meter_in, + name: names.as_ref() .map(|names| names.get(index).unwrap_or_else(|| abort!( ident, "There is no name specified in the names(...) container attribute for tuple struct field {}", index ))), - parameter_in: into_params_attrs.as_ref().and_then(|attrs| attrs.parameter_in), }, + serde_container: serde_container.as_ref(), } }) .collect::>(); @@ -230,11 +199,13 @@ impl IntoParams { #[cfg_attr(feature = "debug", derive(Debug))] pub struct FieldParamContainerAttributes<'a> { /// See [`IntoParamsAttr::style`]. - pub style: Option, + style: &'a Option, /// See [`IntoParamsAttr::names`]. The name that applies to this field. name: Option<&'a String>, /// See [`IntoParamsAttr::parameter_in`]. - parameter_in: Option, + parameter_in: &'a Option, + /// Custom rename all if serde attribute is not present. + rename_all: Option<&'a RenameAll>, } #[cfg_attr(feature = "debug", derive(Debug))] @@ -243,6 +214,8 @@ struct Param<'a> { field: &'a Field, /// Attributes on the container which are relevant for this macro. container_attributes: FieldParamContainerAttributes<'a>, + /// Either serde rename all rule or into_params rename all rule if provided. + serde_container: Option<&'a SerdeContainer>, } impl ToTokens for Param<'_> { @@ -262,36 +235,64 @@ impl ToTokens for Param<'_> { name = &name[2..]; } - let type_tree = TypeTree::from_type(&field.ty); - let field_param_attrs = field + let field_param_serde = serde::parse_value(&field.attrs); + + let mut field_features = field .attrs .iter() .find(|attribute| attribute.path.is_ident("param")) .map(|attribute| { attribute - .parse_args::() + .parse_args::() .unwrap_or_abort() - }); - - if let Some(IntoParamsFieldParamsAttr { - rename: Some(ref name), - .. - }) = field_param_attrs - { - tokens.extend(quote! { utoipa::openapi::path::ParameterBuilder::new() - .name(#name) - }); - } else { - tokens.extend(quote! { utoipa::openapi::path::ParameterBuilder::new() - .name(#name) - }); + .into_inner() + }) + .unwrap_or_default(); + + if let Some(ref style) = self.container_attributes.style { + if !field_features + .iter() + .any(|feature| matches!(&feature, Feature::Style(_))) + { + field_features.push(style.clone()); // could try to use cow to avoid cloning + }; } + let value_type = field_features.find_value_type_feature_as_value_type(); + let is_inline = field_features + .pop_by(|feature| matches!(feature, Feature::Inline(_))) + .is_some(); + let rename = field_features + .find_rename_feature_as_rename() + .map(|rename| rename.into_value()); + let rename = field_param_serde + .as_ref() + .and_then(|field_param_serde| { + if !field_param_serde.rename.is_empty() { + Some(Cow::Borrowed(field_param_serde.rename.as_str())) + } else { + None + } + }) + .or_else(|| rename.map(Cow::Owned)); + let rename_all = self + .serde_container + .as_ref() + .and_then(|serde_container| serde_container.rename_all.as_ref()) + .or_else(|| { + self.container_attributes + .rename_all + .map(|rename_all| rename_all.as_rename_rule()) + }); + let name = super::rename::(name, rename.as_deref(), rename_all) + .unwrap_or(Cow::Borrowed(name)); + let type_tree = TypeTree::from_type(&field.ty); + tokens.extend(quote! { utoipa::openapi::path::ParameterBuilder::new() + .name(#name) + }); tokens.extend( - if let Some(parameter_in) = self.container_attributes.parameter_in { - quote! { - .parameter_in(#parameter_in) - } + if let Some(ref parameter_in) = self.container_attributes.parameter_in { + parameter_in.into_token_stream() } else { quote! { .parameter_in(parameter_in_provider().unwrap_or_default()) @@ -302,106 +303,56 @@ impl ToTokens for Param<'_> { if let Some(deprecated) = super::get_deprecated(&field.attrs) { tokens.extend(quote! { .deprecated(Some(#deprecated)) }); } - if let Some(comment) = CommentAttributes::from_attributes(&field.attrs).first() { tokens.extend(quote! { .description(Some(#comment)) }) } - let mut parameter_ext = ParameterExt::from(&self.container_attributes); - - // Apply the field attributes if they exist. - if let Some(field_params_attrs) = field - .attrs - .iter() - .find(|attribute| attribute.path.is_ident("param")) - .map(|attribute| { - attribute - .parse_args::() - .unwrap_or_abort() - }) - { - parameter_ext.merge(field_params_attrs.parameter_ext.unwrap_or_default()) - } - - if let Some(ref style) = parameter_ext.style { - tokens.extend(quote! { .style(Some(#style)) }); - } - if let Some(ref explode) = parameter_ext.explode { - tokens.extend(quote! { .explode(Some(#explode)) }); - } - if let Some(ref allow_reserved) = parameter_ext.allow_reserved { - tokens.extend(quote! { .allow_reserved(Some(#allow_reserved)) }); - } - if let Some(ref example) = parameter_ext.example { - tokens.extend(quote! { .example(Some(#example)) }); - } - let component = &field_param_attrs + let component = value_type .as_ref() - .and_then(|field_params| field_params.value_type.as_ref().map(TypeTree::from_type)) + .map(|value_type| value_type.as_type_tree()) .unwrap_or(type_tree); - let required: Required = - (!matches!(&component.generic_type, Some(GenericType::Option))).into(); + let is_default = super::is_default(&self.serde_container, &field_param_serde.as_ref()); + let required: Required = + (!(matches!(&component.generic_type, Some(GenericType::Option)) || is_default)).into(); tokens.extend(quote! { .required(#required) }); + tokens.extend(field_features.to_token_stream()); let schema = ParamType { - component, - field_param_attrs: &field_param_attrs, + component: &component, + field_features: &field_features, + is_inline, }; tokens.extend(quote! { .schema(Some(#schema)).build() }); } } -#[derive(Default)] -#[cfg_attr(feature = "debug", derive(Debug))] -struct IntoParamsFieldParamsAttr { - inline: bool, - value_type: Option, - parameter_ext: Option, - rename: Option, -} +struct FieldFeatures(Vec); -impl Parse for IntoParamsFieldParamsAttr { - fn parse(input: syn::parse::ParseStream) -> syn::Result { - const EXPECTED_ATTRIBUTE_MESSAGE: &str = "unexpected attribute, expected any of: style, explode, allow_reserved, example, inline, value_type, rename"; - let mut param = IntoParamsFieldParamsAttr::default(); +impl_into_inner!(FieldFeatures); - while !input.is_empty() { - if ParameterExt::is_parameter_ext(input) { - let param_ext = param.parameter_ext.get_or_insert(ParameterExt::default()); - param_ext.merge(input.call(ParameterExt::parse_once)?); - } else { - let ident = input.parse::()?; - let name = &*ident.to_string(); - - match name { - "inline" => param.inline = parse_utils::parse_bool_or_true(input)?, - "value_type" => { - param.value_type = Some(parse_utils::parse_next(input, || { - input.parse::() - })?) - } - "rename" => param.rename = Some(parse_utils::parse_next_literal_str(input)?), - _ => return Err(Error::new(ident.span(), EXPECTED_ATTRIBUTE_MESSAGE)), - } - - if !input.is_empty() { - input.parse::()?; - } - } - } - - Ok(param) +impl Parse for FieldFeatures { + fn parse(input: syn::parse::ParseStream) -> syn::Result { + Ok(Self(parse_features!( + input as component::features::ValueType, + Inline, + Rename, + Style, + AllowReserved, + Example, + Explode + ))) } } struct ParamType<'a> { component: &'a TypeTree<'a>, - field_param_attrs: &'a Option, + field_features: &'a Vec, + is_inline: bool, } impl ToTokens for ParamType<'_> { @@ -418,7 +369,8 @@ impl ToTokens for ParamType<'_> { .iter() .next() .expect("Vec ParamType should have 1 child"), - field_param_attrs: self.field_param_attrs, + field_features: self.field_features, + is_inline: self.is_inline, }; tokens.extend(quote! { @@ -439,7 +391,8 @@ impl ToTokens for ParamType<'_> { .iter() .next() .expect("Generic container ParamType should have 1 child"), - field_param_attrs: self.field_param_attrs, + field_features: self.field_features, + is_inline: self.is_inline, }; tokens.extend(param_type.into_token_stream()) @@ -456,7 +409,8 @@ impl ToTokens for ParamType<'_> { .iter() .nth(1) .expect("Map Param type should have 2 child"), - field_param_attrs: self.field_param_attrs, + field_features: self.field_features, + is_inline: self.is_inline, }; tokens.extend(quote! { @@ -464,8 +418,6 @@ impl ToTokens for ParamType<'_> { }); } None => { - let inline = matches!(self.field_param_attrs, Some(params) if params.inline); - match component.value_type { ValueType::Primitive => { let type_path = &**component.path.as_ref().unwrap(); @@ -487,7 +439,7 @@ impl ToTokens for ParamType<'_> { .path .as_ref() .expect("component should have a path"); - if inline { + if self.is_inline { tokens.extend(quote_spanned! {component_path.span()=> <#component_path as utoipa::ToSchema>::schema() }) diff --git a/utoipa-gen/src/component/schema.rs b/utoipa-gen/src/component/schema.rs index 2d21ec74..111de801 100644 --- a/utoipa-gen/src/component/schema.rs +++ b/utoipa-gen/src/component/schema.rs @@ -16,12 +16,12 @@ use crate::{ }; use self::features::{ - EnumFeatures, FromAttributes, IntoInner, NamedFieldFeatures, NamedFieldStructFeatures, + EnumFeatures, FromAttributes, NamedFieldFeatures, NamedFieldStructFeatures, UnnamedFieldStructFeatures, }; use super::{ - features::{parse_features, Feature, FeaturesExt, IsInline, ToTokensExt}, + features::{parse_features, Feature, FeaturesExt, IntoInner, IsInline, ToTokensExt}, serde::{self, SerdeContainer, SerdeValue}, FieldRename, GenericType, TypeTree, ValueType, VariantRename, }; @@ -238,15 +238,25 @@ impl ToTokens for NamedStructSchema<'_> { field_name = &field_name[2..]; } - let name = super::rename::(field_name, &field_rule, &container_rules) - .unwrap_or(Cow::Borrowed(field_name)); + let name = super::rename::( + field_name, + field_rule + .as_ref() + .map(|field_rule| field_rule.rename.as_str()), + container_rules + .as_ref() + .and_then(|container_rule| container_rule.rename_all.as_ref()), + ) + .unwrap_or(Cow::Borrowed(field_name)); with_field_as_schema_property(self, field, |schema_property| { object_tokens.extend(quote! { .property(#name, #schema_property) }); - if !schema_property.is_option() && !is_default(&container_rules, &field_rule) { + if !schema_property.is_option() + && !super::is_default(&container_rules.as_ref(), &field_rule.as_ref()) + { object_tokens.extend(quote! { .required(#name) }) @@ -329,18 +339,6 @@ fn with_field_as_schema_property( )) } -#[inline] -fn is_default(container_rules: &Option, field_rule: &Option) -> bool { - container_rules - .as_ref() - .map(|rule| rule.default) - .unwrap_or(false) - || field_rule - .as_ref() - .map(|rule| rule.default) - .unwrap_or(false) -} - #[cfg_attr(feature = "debug", derive(Debug))] struct UnnamedStructSchema<'a> { fields: &'a Punctuated, @@ -551,8 +549,15 @@ impl<'e> EnumTokens<'e> for SimpleEnum<'e> { if is_not_skipped(&variant_rules) { let name = &*variant.ident.to_string(); - let variant_name = - super::rename::(name, &variant_rules, container_rules); + let variant_name = super::rename::( + name, + variant_rules + .as_ref() + .map(|field_rule| field_rule.rename.as_str()), + container_rules + .as_ref() + .and_then(|container_rule| container_rule.rename_all.as_ref()), + ); if let Some(renamed_name) = variant_name { Some(quote! { #renamed_name }) @@ -860,8 +865,12 @@ impl ToTokens for ComplexEnum<'_> { let name = super::rename::( variant_name, - &variant_serde_rules, - container_rules, + variant_serde_rules + .as_ref() + .map(|field_rule| field_rule.rename.as_str()), + container_rules + .as_ref() + .and_then(|container_rule| container_rule.rename_all.as_ref()), ) .unwrap_or(Cow::Borrowed(variant_name)); @@ -1215,8 +1224,6 @@ impl ToTokens for SchemaProperty<'_> { trait SchemaFeatureExt { fn split_for_title(self) -> (Vec, Vec); - fn find_value_type_feature_as_value_type(&mut self) -> Option; - fn extract_vec_xml_feature(&mut self, type_tree: &TypeTree) -> Option; } @@ -1241,14 +1248,6 @@ impl SchemaFeatureExt for Vec { _ => None, }) } - - fn find_value_type_feature_as_value_type(&mut self) -> Option { - self.pop_by(|feature| matches!(feature, Feature::ValueType(_))) - .and_then(|feature| match feature { - Feature::ValueType(value_type) => Some(value_type), - _ => None, - }) - } } /// Reformat a path reference string that was generated using [`quote`] to be used as a nice compact schema reference, diff --git a/utoipa-gen/src/component/schema/features.rs b/utoipa-gen/src/component/schema/features.rs index 839788ec..9e20c1ce 100644 --- a/utoipa-gen/src/component/schema/features.rs +++ b/utoipa-gen/src/component/schema/features.rs @@ -5,30 +5,10 @@ use syn::{ }; use crate::component::features::{ - parse_features, Default, Example, Feature, Format, Inline, Nullable, ReadOnly, Title, - ValueType, WriteOnly, XmlAttr, + impl_into_inner, parse_features, Default, Example, Feature, Format, Inline, Nullable, ReadOnly, + Title, ValueType, WriteOnly, XmlAttr, }; -pub trait IntoInner { - fn into_inner(self) -> T; -} - -macro_rules! impl_into_inner { - ($ident:ident) => { - impl IntoInner> for $ident { - fn into_inner(self) -> Vec { - self.0 - } - } - - impl IntoInner>> for Option<$ident> { - fn into_inner(self) -> Option> { - self.map(IntoInner::into_inner) - } - } - }; -} - #[cfg_attr(feature = "debug", derive(Debug))] pub struct NamedFieldStructFeatures(Vec); diff --git a/utoipa-gen/src/component/serde.rs b/utoipa-gen/src/component/serde.rs index 4f269f56..61080a31 100644 --- a/utoipa-gen/src/component/serde.rs +++ b/utoipa-gen/src/component/serde.rs @@ -135,6 +135,7 @@ pub fn parse_container(attributes: &[Attribute]) -> Option { }) } +#[derive(Clone)] #[cfg_attr(feature = "debug", derive(Debug))] pub enum RenameRule { Lower, @@ -215,26 +216,33 @@ impl RenameRule { } } +const RENAME_RULE_NAME_MAPPING: [(&str, RenameRule); 8] = [ + ("lowercase", RenameRule::Lower), + ("UPPERCASE", RenameRule::Upper), + ("PascalCase", RenameRule::Pascal), + ("camelCase", RenameRule::Camel), + ("snake_case", RenameRule::Snake), + ("SCREAMING_SNAKE_CASE", RenameRule::ScreamingSnake), + ("kebab-case", RenameRule::Kebab), + ("SCREAMING-KEBAB-CASE", RenameRule::ScreamingKebab), +]; + impl FromStr for RenameRule { type Err = Error; fn from_str(s: &str) -> Result { - [ - ("lowercase", RenameRule::Lower), - ("UPPERCASE", RenameRule::Upper), - ("PascalCase", RenameRule::Pascal), - ("camelCase", RenameRule::Camel), - ("snake_case", RenameRule::Snake), - ("SCREAMING_SNAKE_CASE", RenameRule::ScreamingSnake), - ("kebab-case", RenameRule::Kebab), - ("SCREAMING-KEBAB-CASE", RenameRule::ScreamingKebab), - ] + let expected_one_of = RENAME_RULE_NAME_MAPPING + .into_iter() + .map(|(name, _)| format!(r#""{name}""#)) + .collect::>() + .join(", "); + RENAME_RULE_NAME_MAPPING .into_iter() .find_map(|(case, rule)| if case == s { Some(rule) } else { None }) .ok_or_else(|| { Error::new( Span::call_site(), - r#"unexpected rename rule, expected one of: "lowercase", "UPPERCASE", "PascalCase", "camelCase", "snake_case", "SCREAMING_SNAKE_CASE", "kebab-case", "SCREAMING-KEBAB-CASE""#, + format!(r#"unexpected rename rule, expected one of: {expected_one_of}"#), ) }) } @@ -242,7 +250,7 @@ impl FromStr for RenameRule { #[cfg(test)] mod tests { - use super::RenameRule; + use super::{RenameRule, RENAME_RULE_NAME_MAPPING}; macro_rules! test_rename_rule { ( $($case:expr=> $value:literal = $expected:literal)* ) => { @@ -310,16 +318,7 @@ mod tests { #[test] fn test_serde_rename_rule_from_str() { - for s in [ - "lowercase", - "UPPERCASE", - "PascalCase", - "camelCase", - "snake_case", - "SCREAMING_SNAKE_CASE", - "kebab-case", - "SCREAMING-KEBAB-CASE", - ] { + for (s, _) in RENAME_RULE_NAME_MAPPING { s.parse::().unwrap(); } } diff --git a/utoipa-gen/src/lib.rs b/utoipa-gen/src/lib.rs index c1d49d33..86b47290 100644 --- a/utoipa-gen/src/lib.rs +++ b/utoipa-gen/src/lib.rs @@ -1081,12 +1081,14 @@ pub fn openapi(input: TokenStream) -> TokenStream { /// deriving `IntoParams`: /// /// * `names(...)` Define comma seprated list of names for unnamed fields of struct used as a path parameter. +/// __Only__ supported on __unnamed structs__. /// * `style = ...` Defines how all parameters are serialized by [`ParameterStyle`][style]. Default /// values are based on _`parameter_in`_ attribute. /// * `parameter_in = ...` = Defines where the parameters of this field are used with a value from /// [`openapi::path::ParameterIn`][in_enum]. There is no default value, if this attribute is not /// supplied, then the value is determined by the `parameter_in_provider` in /// [`IntoParams::into_params()`](trait.IntoParams.html#tymethod.into_params). +/// * `rename_all = ...` Can be provided to alternatively to the serde's `rename_all` attribute. Effectively provides same functionality. /// /// # IntoParams Field Attributes for `#[param(...)]` /// @@ -1104,6 +1106,7 @@ pub fn openapi(input: TokenStream) -> TokenStream { /// _`Object`_ will be rendered as generic OpenAPI object. /// * `inline` If set, the schema for this field's type needs to be a [`ToSchema`][to_schema], and /// the schema definition will be inlined. +/// * `rename = ...` Can be provided to alternatively to the serde's `rename` attribute. Effectively provides same functionality. /// /// **Note!** `#[into_params(...)]` is only supported on unnamed struct types to declare names for the arguments. /// @@ -1124,6 +1127,18 @@ pub fn openapi(input: TokenStream) -> TokenStream { /// #[into_params(names("id", "name"))] /// struct IdAndName(u64, String); /// ``` +/// +/// # Partial `#[serde(...)]` attributes support +/// +/// IntoParams derive has partial support for [serde attributes]. These supported attributes will reflect to the +/// generated OpenAPI doc. For example the _`rename`_ and _`rename_all`_ will reflect to the generated OpenAPI doc. +/// +/// * `rename_all = "..."` Supported in container level. +/// * `rename = "..."` Supported **only** in field. +/// * `default` Supported in container level and field level according to [serde attributes]. +/// +/// Other _`serde`_ attributes works as is but does not have any effect on the generated OpenAPI doc. +/// /// # Examples /// /// Demonstrate [`IntoParams`][into_params] usage with resolving `Path` and `Query` parameters @@ -1273,6 +1288,7 @@ pub fn openapi(input: TokenStream) -> TokenStream { /// [style]: openapi/path/enum.ParameterStyle.html /// [in_enum]: utoipa/openapi/path/enum.ParameterIn.html /// [primitive]: https://doc.rust-lang.org/std/primitive/index.html +/// [serde attributes]: https://serde.rs/attributes.html /// /// [^actix]: Feature **actix_extras** need to be enabled /// @@ -1327,7 +1343,7 @@ where fn deref(&self) -> &Self::Target { match self { Self::Owned(vec) => vec.as_slice(), - Self::Borrowed(slice) => *slice, + Self::Borrowed(slice) => slice, } } } diff --git a/utoipa-gen/src/path/parameter.rs b/utoipa-gen/src/path/parameter.rs index fd7c212c..8b94da09 100644 --- a/utoipa-gen/src/path/parameter.rs +++ b/utoipa-gen/src/path/parameter.rs @@ -14,10 +14,7 @@ use syn::{ feature = "axum_extras" ))] use crate::ext::{ArgumentIn, ValueArgument}; -use crate::{ - component::into_params::FieldParamContainerAttributes, parse_utils, AnyValue, Deprecated, - Required, Type, -}; +use crate::{parse_utils, AnyValue, Deprecated, Required, Type}; use super::property::Property; @@ -345,15 +342,6 @@ pub struct ParameterExt { pub(crate) example: Option, } -impl From<&'_ FieldParamContainerAttributes<'_>> for ParameterExt { - fn from(attributes: &FieldParamContainerAttributes) -> Self { - Self { - style: attributes.style, - ..ParameterExt::default() - } - } -} - impl ParameterExt { pub fn merge(&mut self, from: ParameterExt) { if from.style.is_some() { diff --git a/utoipa-gen/tests/path_derive.rs b/utoipa-gen/tests/path_derive.rs index 4883ba14..97bb50d1 100644 --- a/utoipa-gen/tests/path_derive.rs +++ b/utoipa-gen/tests/path_derive.rs @@ -466,6 +466,419 @@ fn derive_path_params_map() { } } +#[test] +fn derive_required_path_params() { + #[derive(serde::Deserialize, IntoParams)] + #[into_params(parameter_in = Query)] + #[allow(dead_code)] + struct MyParams { + #[serde(default)] + vec_default: Option>, + + #[serde(default)] + string_default: Option, + + #[serde(default)] + vec_default_required: Vec, + + #[serde(default)] + string_default_required: String, + + vec_option: Option>, + + string_option: Option, + + vec: Vec, + + string: String, + } + + #[utoipa::path( + get, + path = "/list/{id}", + responses( + (status = 200, description = "success response") + ), + params( + MyParams, + ) + )] + #[allow(unused)] + fn list(id: i64) -> String { + "".to_string() + } + + #[derive(utoipa::OpenApi, Default)] + #[openapi(paths(list))] + struct ApiDoc; + + let operation: Value = test_api_fn_doc! { + list, + operation: get, + path: "/list/{id}" + }; + + let parameters = operation.get("parameters").unwrap(); + + assert_json_eq!( + parameters, + json!([ + { + "in": "query", + "name": "vec_default", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + }, + { + "in": "query", + "name": "string_default", + "required": false, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "vec_default_required", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + }, + { + "in": "query", + "name": "string_default_required", + "required": false, + "schema": { + "type": "string" + }, + }, + { + "in": "query", + "name": "vec_option", + "required": false, + "schema": { + "items": { + "type": "string" + }, + "type": "array", + }, + }, + { + "in": "query", + "name": "string_option", + "required": false, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "vec", + "required": true, + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "in": "query", + "name": "string", + "required": true, + "schema": { + "type": "string" + } + } + ]) + ) +} + +#[test] +fn derive_path_params_with_serde_and_custom_rename() { + #[derive(serde::Deserialize, IntoParams)] + #[into_params(parameter_in = Query)] + #[serde(rename_all = "camelCase")] + #[allow(dead_code)] + struct MyParams { + vec_default: Option>, + + #[serde(default, rename = "STRING")] + string_default: Option, + + #[serde(default, rename = "VEC")] + #[param(rename = "vec2")] + vec_default_required: Vec, + + #[serde(default)] + #[param(rename = "string_r2")] + string_default_required: String, + + string: String, + } + + #[utoipa::path( + get, + path = "/list/{id}", + responses( + (status = 200, description = "success response") + ), + params( + MyParams, + ) + )] + #[allow(unused)] + fn list(id: i64) -> String { + "".to_string() + } + + #[derive(utoipa::OpenApi, Default)] + #[openapi(paths(list))] + struct ApiDoc; + + let operation: Value = test_api_fn_doc! { + list, + operation: get, + path: "/list/{id}" + }; + + let parameters = operation.get("parameters").unwrap(); + + assert_json_eq!( + parameters, + json!([ + { + "in": "query", + "name": "vecDefault", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + }, + { + "in": "query", + "name": "STRING", + "required": false, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "VEC", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + }, + { + "in": "query", + "name": "string_r2", + "required": false, + "schema": { + "type": "string" + }, + }, + { + "in": "query", + "name": "string", + "required": true, + "schema": { + "type": "string" + } + } + ]) + ) +} + +#[test] +fn derive_path_params_custom_rename_all() { + #[derive(serde::Deserialize, IntoParams)] + #[into_params(rename_all = "camelCase", parameter_in = Query)] + #[allow(dead_code)] + struct MyParams { + vec_default: Option>, + } + + #[utoipa::path( + get, + path = "/list/{id}", + responses( + (status = 200, description = "success response") + ), + params( + MyParams, + ) + )] + #[allow(unused)] + fn list(id: i64) -> String { + "".to_string() + } + + #[derive(utoipa::OpenApi, Default)] + #[openapi(paths(list))] + struct ApiDoc; + + let operation: Value = test_api_fn_doc! { + list, + operation: get, + path: "/list/{id}" + }; + + let parameters = operation.get("parameters").unwrap(); + + assert_json_eq!( + parameters, + json!([ + { + "in": "query", + "name": "vecDefault", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + }, + ]) + ) +} + +#[test] +fn derive_path_params_custom_rename_all_serde_will_override() { + #[derive(serde::Deserialize, IntoParams)] + #[into_params(rename_all = "camelCase", parameter_in = Query)] + #[serde(rename_all = "UPPERCASE")] + #[allow(dead_code)] + struct MyParams { + vec_default: Option>, + } + + #[utoipa::path( + get, + path = "/list/{id}", + responses( + (status = 200, description = "success response") + ), + params( + MyParams, + ) + )] + #[allow(unused)] + fn list(id: i64) -> String { + "".to_string() + } + + #[derive(utoipa::OpenApi, Default)] + #[openapi(paths(list))] + struct ApiDoc; + + let operation: Value = test_api_fn_doc! { + list, + operation: get, + path: "/list/{id}" + }; + + let parameters = operation.get("parameters").unwrap(); + + assert_json_eq!( + parameters, + json!([ + { + "in": "query", + "name": "VEC_DEFAULT", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + }, + ]) + ) +} + +#[test] +fn derive_path_parameters_container_level_default() { + #[derive(serde::Deserialize, IntoParams, Default)] + #[into_params(parameter_in = Query)] + #[serde(default)] + #[allow(dead_code)] + struct MyParams { + vec_default: Vec, + string: String, + } + + #[utoipa::path( + get, + path = "/list/{id}", + responses( + (status = 200, description = "success response") + ), + params( + MyParams, + ) + )] + #[allow(unused)] + fn list(id: i64) -> String { + "".to_string() + } + + #[derive(utoipa::OpenApi, Default)] + #[openapi(paths(list))] + struct ApiDoc; + + let operation: Value = test_api_fn_doc! { + list, + operation: get, + path: "/list/{id}" + }; + + let parameters = operation.get("parameters").unwrap(); + + assert_json_eq!( + parameters, + json!([ + { + "in": "query", + "name": "vec_default", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + }, + { + "in": "query", + "name": "string", + "required": false, + "schema": { + "type": "string" + }, + } + ]) + ) +} + #[test] fn derive_path_params_intoparams() { #[derive(serde::Deserialize, ToSchema)]