diff --git a/utoipa-gen/CHANGELOG.md b/utoipa-gen/CHANGELOG.md index e974314e..39003c88 100644 --- a/utoipa-gen/CHANGELOG.md +++ b/utoipa-gen/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +* Add support for `schema(ignore)` and `param(ignore)` (https://github.com/juhaku/utoipa/pull/1090) * Add support for `property_names` for object (https://github.com/juhaku/utoipa/pull/1084) * Add `bound` attribute for customizing generic impl bounds. (https://github.com/juhaku/utoipa/pull/1079) * Add auto collect schemas for utoipa-axum (https://github.com/juhaku/utoipa/pull/1072) diff --git a/utoipa-gen/src/component/features.rs b/utoipa-gen/src/component/features.rs index ffab439e..ac3d5ab9 100644 --- a/utoipa-gen/src/component/features.rs +++ b/utoipa-gen/src/component/features.rs @@ -102,6 +102,7 @@ pub enum Feature { ContentMediaType(attributes::ContentMediaType), Discriminator(attributes::Discriminator), Bound(attributes::Bound), + Ignore(attributes::Ignore), MultipleOf(validation::MultipleOf), Maximum(validation::Maximum), Minimum(validation::Minimum), @@ -239,6 +240,7 @@ impl ToTokensDiagnostics for Feature { let name = ::get_name(); quote! { .#name(#required) } } + Feature::Ignore(_) => return Err(Diagnostics::new("Feature::Ignore does not support ToTokens")), }; tokens.extend(feature); @@ -300,6 +302,7 @@ impl Display for Feature { Feature::ContentMediaType(content_media_type) => content_media_type.fmt(f), Feature::Discriminator(discriminator) => discriminator.fmt(f), Feature::Bound(bound) => bound.fmt(f), + Feature::Ignore(ignore) => ignore.fmt(f), } } } @@ -349,6 +352,7 @@ impl Validatable for Feature { Feature::ContentMediaType(content_media_type) => content_media_type.is_validatable(), Feature::Discriminator(discriminator) => discriminator.is_validatable(), Feature::Bound(bound) => bound.is_validatable(), + Feature::Ignore(ignore) => ignore.is_validatable(), } } } @@ -396,6 +400,7 @@ is_validatable! { attributes::ContentMediaType, attributes::Discriminator, attributes::Bound, + attributes::Ignore, validation::MultipleOf = true, validation::Maximum = true, validation::Minimum = true, @@ -624,6 +629,7 @@ impl_feature_into_inner! { attributes::AdditionalProperties, attributes::Discriminator, attributes::Bound, + attributes::Ignore, validation::MultipleOf, validation::Maximum, validation::Minimum, diff --git a/utoipa-gen/src/component/features/attributes.rs b/utoipa-gen/src/component/features/attributes.rs index ae17aa03..d67568e0 100644 --- a/utoipa-gen/src/component/features/attributes.rs +++ b/utoipa-gen/src/component/features/attributes.rs @@ -982,3 +982,25 @@ impl From for Feature { Feature::Bound(value) } } + +// Nothing to parse, it will be parsed true via `parse_features!` when defined as `ignore` +impl_feature! { + #[derive(Clone)] + #[cfg_attr(feature = "debug", derive(Debug))] + pub struct Ignore; +} + +impl Parse for Ignore { + fn parse(_: ParseStream, _: Ident) -> syn::Result + where + Self: std::marker::Sized, + { + Ok(Self) + } +} + +impl From for Feature { + fn from(value: Ignore) -> Self { + Self::Ignore(value) + } +} diff --git a/utoipa-gen/src/component/into_params.rs b/utoipa-gen/src/component/into_params.rs index 3a8dfb6b..c2e461ec 100644 --- a/utoipa-gen/src/component/into_params.rs +++ b/utoipa-gen/src/component/into_params.rs @@ -13,7 +13,7 @@ use crate::{ features::{ self, attributes::{ - AdditionalProperties, AllowReserved, Example, Explode, Format, Inline, + AdditionalProperties, AllowReserved, Example, Explode, Format, Ignore, Inline, IntoParamsNames, Nullable, ReadOnly, Rename, RenameAll, SchemaWith, Style, WriteOnly, XmlAttr, }, @@ -109,20 +109,26 @@ impl ToTokensDiagnostics for IntoParams { let params = self .get_struct_fields(&names.as_ref())? .enumerate() - .map(|(index, field)| match serde::parse_value(&field.attrs) { - Ok(serde_value) => Ok((index, field, serde_value)), - Err(diagnostics) => Err(diagnostics) + .map(|(index, field)| { + let field_features = match parse_field_features(field) { + Ok(features) => features, + Err(error) => return Err(error), + }; + match serde::parse_value(&field.attrs) { + Ok(serde_value) => Ok((index, field, serde_value, field_features)), + Err(diagnostics) => Err(diagnostics) + } }) .collect::, Diagnostics>>()? .into_iter() - .filter_map(|(index, field, field_serde_params)| { - if !field_serde_params.skip { - Some((index, field, field_serde_params)) - } else { + .filter_map(|(index, field, field_serde_params, field_features)| { + if field_serde_params.skip || field_features.iter().any(|feature| matches!(feature, Feature::Ignore(_))) { None + } else { + Some((index, field, field_serde_params, field_features)) } }) - .map(|(index, field, field_serde_params)| { + .map(|(index, field, field_serde_params, field_features)| { let name = names.as_ref() .map_try(|names| names.get(index).ok_or_else(|| Diagnostics::with_span( ident.span(), @@ -132,10 +138,7 @@ impl ToTokensDiagnostics for IntoParams { Ok(name) => name, Err(diagnostics) => return Err(diagnostics) }; - let param = Param { - field, - field_serde_params, - container_attributes: FieldParamContainerAttributes { + let param = Param::new(field, field_serde_params, field_features, FieldParamContainerAttributes { rename_all: rename_all.as_ref().and_then(|feature| { match feature { Feature::RenameAll(rename_all) => Some(rename_all), @@ -145,16 +148,10 @@ impl ToTokensDiagnostics for IntoParams { style: &style, parameter_in: ¶meter_in, name, - }, - serde_container: &serde_container, - generics: &self.generics - }; + }, &serde_container, &self.generics)?; - let mut param_tokens = TokenStream::new(); - match ToTokensDiagnostics::to_tokens(¶m, &mut param_tokens) { - Ok(_) => Ok(param_tokens), - Err(diagnostics) => Err(diagnostics) - } + + Ok(param.to_token_stream()) }) .collect::, Diagnostics>>()?; @@ -170,6 +167,22 @@ impl ToTokensDiagnostics for IntoParams { } } +fn parse_field_features(field: &Field) -> Result, Diagnostics> { + Ok(field + .attrs + .iter() + .filter(|attribute| attribute.path().is_ident("param")) + .map(|attribute| { + attribute + .parse_args::() + .map(FieldFeatures::into_inner) + }) + .collect::, syn::Error>>()? + .into_iter() + .reduce(|acc, item| acc.merge(item)) + .unwrap_or_default()) +} + impl IntoParams { fn get_struct_fields( &self, @@ -286,99 +299,33 @@ impl Parse for FieldFeatures { Pattern, MaxItems, MinItems, - AdditionalProperties + AdditionalProperties, + Ignore ))) } } #[cfg_attr(feature = "debug", derive(Debug))] -struct Param<'a> { - /// Field in the container used to create a single parameter. - field: &'a Field, - //// Field serde params parsed from field attributes. - field_serde_params: SerdeValue, - /// 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: &'a SerdeContainer, - /// Container gnerics - generics: &'a Generics, +struct Param { + tokens: TokenStream, } -impl Param<'_> { - /// Resolve [`Param`] features and split features into two [`Vec`]s. Features are split by - /// whether they should be rendered in [`Param`] itself or in [`Param`]s schema. - /// - /// Method returns a tuple containing two [`Vec`]s of [`Feature`]. - fn resolve_field_features(&self) -> Result<(Vec, Vec), syn::Error> { - let mut field_features = self - .field - .attrs - .iter() - .filter(|attribute| attribute.path().is_ident("param")) - .map(|attribute| { - attribute - .parse_args::() - .map(FieldFeatures::into_inner) - }) - .collect::, syn::Error>>()? - .into_iter() - .reduce(|acc, item| acc.merge(item)) - .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 - }; - } - - Ok(field_features.into_iter().fold( - (Vec::::new(), Vec::::new()), - |(mut schema_features, mut param_features), feature| { - match feature { - Feature::Inline(_) - | Feature::Format(_) - | Feature::Default(_) - | Feature::WriteOnly(_) - | Feature::ReadOnly(_) - | Feature::Nullable(_) - | Feature::XmlAttr(_) - | Feature::MultipleOf(_) - | Feature::Maximum(_) - | Feature::Minimum(_) - | Feature::ExclusiveMaximum(_) - | Feature::ExclusiveMinimum(_) - | Feature::MaxLength(_) - | Feature::MinLength(_) - | Feature::Pattern(_) - | Feature::MaxItems(_) - | Feature::MinItems(_) - | Feature::AdditionalProperties(_) => { - schema_features.push(feature); - } - _ => { - param_features.push(feature); - } - }; - - (schema_features, param_features) - }, - )) - } -} - -impl ToTokensDiagnostics for Param<'_> { - fn to_tokens(&self, tokens: &mut TokenStream) -> Result<(), Diagnostics> { - let field = self.field; - let field_serde_params = &self.field_serde_params; +impl Param { + fn new( + field: &Field, + field_serde_params: SerdeValue, + field_features: Vec, + container_attributes: FieldParamContainerAttributes<'_>, + serde_container: &SerdeContainer, + generics: &Generics, + ) -> Result { + let mut tokens = TokenStream::new(); + let field_serde_params = &field_serde_params; let ident = &field.ident; let mut name = &*ident .as_ref() .map(|ident| ident.to_string()) - .or_else(|| self.container_attributes.name.cloned()) + .or_else(|| container_attributes.name.cloned()) .ok_or_else(|| Diagnostics::with_span(field.span(), "No name specified for unnamed field.") .help("Try adding #[into_params(names(...))] container attribute to specify the name for this field") @@ -389,7 +336,8 @@ impl ToTokensDiagnostics for Param<'_> { } let (schema_features, mut param_features) = - self.resolve_field_features().map_err(Diagnostics::from)?; + Param::resolve_field_features(field_features, &container_attributes) + .map_err(Diagnostics::from)?; let rename = pop_feature!(param_features => Feature::Rename(_) as Option) .map(|rename| rename.into_value()); @@ -398,8 +346,7 @@ impl ToTokensDiagnostics for Param<'_> { .as_deref() .map(Cow::Borrowed) .or(rename.map(Cow::Owned)); - let rename_all = self.serde_container.rename_all.as_ref().or(self - .container_attributes + let rename_all = serde_container.rename_all.as_ref().or(container_attributes .rename_all .map(|rename_all| rename_all.as_rename_rule())); let name = super::rename::(name, rename_to, rename_all) @@ -410,7 +357,7 @@ impl ToTokensDiagnostics for Param<'_> { .name(#name) }); tokens.extend( - if let Some(ref parameter_in) = self.container_attributes.parameter_in { + if let Some(ref parameter_in) = &container_attributes.parameter_in { parameter_in.to_token_stream() } else { quote! { @@ -442,8 +389,8 @@ impl ToTokensDiagnostics for Param<'_> { let required: Option = pop_feature!(param_features => Feature::Required(_)).into_inner(); - let component_required = !component.is_option() - && super::is_required(field_serde_params, self.serde_container); + let component_required = + !component.is_option() && super::is_required(field_serde_params, serde_container); let required = match (required, component_required) { (Some(required_feature), _) => Into::::into(required_feature.is_true()), @@ -459,15 +406,70 @@ impl ToTokensDiagnostics for Param<'_> { type_tree: &component, features: schema_features, description: None, - container: &Container { - generics: self.generics, - }, + container: &Container { generics }, })?; let schema_tokens = schema.to_token_stream(); tokens.extend(quote! { .schema(Some(#schema_tokens)).build() }); } - Ok(()) + Ok(Self { tokens }) + } + + /// Resolve [`Param`] features and split features into two [`Vec`]s. Features are split by + /// whether they should be rendered in [`Param`] itself or in [`Param`]s schema. + /// + /// Method returns a tuple containing two [`Vec`]s of [`Feature`]. + fn resolve_field_features( + mut field_features: Vec, + container_attributes: &FieldParamContainerAttributes<'_>, + ) -> Result<(Vec, Vec), syn::Error> { + if let Some(ref style) = 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 + }; + } + + Ok(field_features.into_iter().fold( + (Vec::::new(), Vec::::new()), + |(mut schema_features, mut param_features), feature| { + match feature { + Feature::Inline(_) + | Feature::Format(_) + | Feature::Default(_) + | Feature::WriteOnly(_) + | Feature::ReadOnly(_) + | Feature::Nullable(_) + | Feature::XmlAttr(_) + | Feature::MultipleOf(_) + | Feature::Maximum(_) + | Feature::Minimum(_) + | Feature::ExclusiveMaximum(_) + | Feature::ExclusiveMinimum(_) + | Feature::MaxLength(_) + | Feature::MinLength(_) + | Feature::Pattern(_) + | Feature::MaxItems(_) + | Feature::MinItems(_) + | Feature::AdditionalProperties(_) => { + schema_features.push(feature); + } + _ => { + param_features.push(feature); + } + }; + + (schema_features, param_features) + }, + )) + } +} + +impl ToTokens for Param { + 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 a9362935..d942b951 100644 --- a/utoipa-gen/src/component/schema.rs +++ b/utoipa-gen/src/component/schema.rs @@ -285,7 +285,7 @@ impl NamedStructSchema { let mut fields_vec = fields .iter() - .map(|field| { + .filter_map(|field| { let mut field_name = Cow::Owned(field.ident.as_ref().unwrap().to_string()); if Borrow::::borrow(&field_name).starts_with("r#") { @@ -295,7 +295,7 @@ impl NamedStructSchema { let field_rules = serde::parse_value(&field.attrs); let field_rules = match field_rules { Ok(field_rules) => field_rules, - Err(diagnostics) => return Err(diagnostics), + Err(diagnostics) => return Some(Err(diagnostics)), }; let field_options = Self::get_named_struct_field_options( root, @@ -306,8 +306,11 @@ impl NamedStructSchema { ); match field_options { - Ok(field_options) => Ok((field_options, field_rules, field_name, field)), - Err(options_diagnostics) => Err(options_diagnostics), + Ok(Some(field_options)) => { + Some(Ok((field_options, field_rules, field_name, field))) + } + Ok(_) => None, + Err(options_diagnostics) => Some(Err(options_diagnostics)), } }) .collect::, Diagnostics>>()?; @@ -481,7 +484,7 @@ impl NamedStructSchema { features: &[Feature], field_rules: &SerdeValue, container_rules: &SerdeContainer, - ) -> Result, Diagnostics> { + ) -> Result>, Diagnostics> { let type_tree = &mut TypeTree::from_type(&field.ty)?; let mut field_features = field @@ -490,6 +493,14 @@ impl NamedStructSchema { .into_inner() .unwrap_or_default(); + if field_features + .iter() + .any(|feature| matches!(feature, Feature::Ignore(_))) + { + // skip ignored field + return Ok(None); + }; + let schema_default = features.iter().any(|f| matches!(f, Feature::Default(_))); let serde_default = container_rules.default; @@ -540,7 +551,7 @@ impl NamedStructSchema { let is_option = type_tree.is_option(); - Ok(NamedStructFieldOptions { + Ok(Some(NamedStructFieldOptions { property: if let Some(schema_with) = schema_with { Property::SchemaWith(schema_with) } else { @@ -562,7 +573,7 @@ impl NamedStructSchema { renamed_field: rename_field, required, is_option, - }) + })) } } diff --git a/utoipa-gen/src/component/schema/features.rs b/utoipa-gen/src/component/schema/features.rs index 4e49a2fb..cf9d5796 100644 --- a/utoipa-gen/src/component/schema/features.rs +++ b/utoipa-gen/src/component/schema/features.rs @@ -7,8 +7,9 @@ use crate::{ component::features::{ attributes::{ AdditionalProperties, As, Bound, ContentEncoding, ContentMediaType, Deprecated, - Description, Discriminator, Example, Examples, Format, Inline, Nullable, ReadOnly, - Rename, RenameAll, Required, SchemaWith, Title, ValueType, WriteOnly, XmlAttr, + Description, Discriminator, Example, Examples, Format, Ignore, Inline, Nullable, + ReadOnly, Rename, RenameAll, Required, SchemaWith, Title, ValueType, WriteOnly, + XmlAttr, }, impl_into_inner, impl_merge, parse_features, validation::{ @@ -137,7 +138,8 @@ impl Parse for NamedFieldFeatures { Required, Deprecated, ContentEncoding, - ContentMediaType + ContentMediaType, + Ignore ))) } } diff --git a/utoipa-gen/src/lib.rs b/utoipa-gen/src/lib.rs index c1bf3b5f..a91d5b24 100644 --- a/utoipa-gen/src/lib.rs +++ b/utoipa-gen/src/lib.rs @@ -187,6 +187,7 @@ static CONFIG: once_cell::sync::Lazy = /// See [`Object::content_encoding`][schema_object_encoding] /// * `content_media_type = ...` Can be used to define MIME type of a string for underlying schema object. /// See [`Object::content_media_type`][schema_object_media_type] +///* `ignore` Can be used to skip the field from being serialized to OpenAPI schema. /// /// #### Field nullability and required rules /// @@ -2228,6 +2229,8 @@ pub fn openapi(input: TokenStream) -> TokenStream { /// Free form type enables use of arbitrary types within map values. /// Supports formats _`additional_properties`_ and _`additional_properties = true`_. /// +/// * `ignore` Can be used to skip the field from being serialized to OpenAPI schema. +/// /// #### Field nullability and required rules /// /// Same rules for nullability and required status apply for _`IntoParams`_ field attributes as for diff --git a/utoipa-gen/tests/path_derive.rs b/utoipa-gen/tests/path_derive.rs index 16822638..ad9f3cb0 100644 --- a/utoipa-gen/tests/path_derive.rs +++ b/utoipa-gen/tests/path_derive.rs @@ -2751,3 +2751,41 @@ fn path_derive_with_body_ref_using_as_attribute_schema() { }) ); } + +#[test] +fn derive_into_params_with_ignored_field() { + #![allow(unused)] + + #[derive(IntoParams)] + #[into_params(parameter_in = Query)] + struct Params { + name: String, + #[param(ignore)] + __this_is_private: String, + } + + #[utoipa::path(get, path = "/params", params(Params))] + #[allow(unused)] + fn get_params() {} + let operation = test_api_fn_doc! { + get_params, + operation: get, + path: "/params" + }; + + let value = operation.pointer("/parameters"); + + assert_json_eq!( + value, + json!([ + { + "in": "query", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ]) + ) +} diff --git a/utoipa-gen/tests/schema_derive_test.rs b/utoipa-gen/tests/schema_derive_test.rs index 9018b194..ba0e3cb3 100644 --- a/utoipa-gen/tests/schema_derive_test.rs +++ b/utoipa-gen/tests/schema_derive_test.rs @@ -5681,3 +5681,29 @@ fn derive_map_with_property_names() { }) ) } + +#[test] +fn derive_schema_with_ignored_field() { + #![allow(unused)] + + let value = api_doc! { + struct SchemaIgnoredField { + value: String, + #[schema(ignore)] + __this_is_private: String, + } + }; + + assert_json_eq!( + value, + json!({ + "properties": { + "value": { + "type": "string" + } + }, + "required": [ "value" ], + "type": "object" + }) + ) +}