From a792520966de731ca0c00ca963660d2a34d61b26 Mon Sep 17 00:00:00 2001 From: Jean-Marc Le Roux Date: Fri, 1 Nov 2024 20:05:41 +0100 Subject: [PATCH] The #[schema(ignore)] attribute now accepts an optional bool value/function path (#1177) Co-authored-by: Jean-Marc Le Roux --- utoipa-gen/CHANGELOG.md | 1 + utoipa-gen/src/component/features.rs | 2 +- .../src/component/features/attributes.rs | 22 ++++-- utoipa-gen/src/component/into_params.rs | 30 +++++++- utoipa-gen/src/component/schema.rs | 51 +++++++++----- utoipa-gen/src/lib.rs | 70 +++++++++++++++++-- utoipa-gen/tests/path_derive.rs | 46 ++++++++++++ utoipa-gen/tests/schema_derive_test.rs | 63 +++++++++++++++++ 8 files changed, 255 insertions(+), 30 deletions(-) diff --git a/utoipa-gen/CHANGELOG.md b/utoipa-gen/CHANGELOG.md index 68dfa9de..fe9a12f2 100644 --- a/utoipa-gen/CHANGELOG.md +++ b/utoipa-gen/CHANGELOG.md @@ -5,6 +5,7 @@ ### Changed * Added missing formats for `KnownFormat` parsing (https://github.com/juhaku/utoipa/pull/1178) +* The `#[schema(ignore)]` attribute now accepts an optional bool value/function path (https://github.com/juhaku/utoipa/pull/1177) ## 5.1.3 - Oct 27 2024 diff --git a/utoipa-gen/src/component/features.rs b/utoipa-gen/src/component/features.rs index 661095d4..998f2bda 100644 --- a/utoipa-gen/src/component/features.rs +++ b/utoipa-gen/src/component/features.rs @@ -242,7 +242,7 @@ impl ToTokensDiagnostics for Feature { let name = ::get_name(); quote! { .#name(#required) } } - Feature::Ignore(_) => return Err(Diagnostics::new("Feature::Ignore does not support ToTokens")), + Feature::Ignore(_) => return Err(Diagnostics::new("Ignore does not support `ToTokens`")), }; tokens.extend(feature); diff --git a/utoipa-gen/src/component/features/attributes.rs b/utoipa-gen/src/component/features/attributes.rs index bd05bc8e..872b0083 100644 --- a/utoipa-gen/src/component/features/attributes.rs +++ b/utoipa-gen/src/component/features/attributes.rs @@ -9,7 +9,7 @@ use syn::{Error, LitStr, Token, TypePath, WherePredicate}; use crate::component::serde::RenameRule; use crate::component::{schema, GenericType, TypeTree}; -use crate::parse_utils::LitStrOrExpr; +use crate::parse_utils::{LitBoolOrExprPath, LitStrOrExpr}; use crate::path::parameter::{self, ParameterStyle}; use crate::schema_type::KnownFormat; use crate::{parse_utils, AnyValue, Array, Diagnostics}; @@ -983,19 +983,25 @@ impl From for Feature { } } -// Nothing to parse, it will be parsed true via `parse_features!` when defined as `ignore` impl_feature! { + /// Ignore feature parsed from macro attributes. #[derive(Clone)] #[cfg_attr(feature = "debug", derive(Debug))] - pub struct Ignore; + pub struct Ignore(pub LitBoolOrExprPath); } impl Parse for Ignore { - fn parse(_: ParseStream, _: Ident) -> syn::Result + fn parse(input: syn::parse::ParseStream, _: Ident) -> syn::Result where Self: std::marker::Sized, { - Ok(Self) + parse_utils::parse_next_literal_bool_or_call(input).map(Self) + } +} + +impl ToTokens for Ignore { + fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) { + tokens.extend(self.0.to_token_stream()) } } @@ -1005,6 +1011,12 @@ impl From for Feature { } } +impl From for Ignore { + fn from(value: bool) -> Self { + Self(value.into()) + } +} + // Nothing to parse, it is considered to be set when attribute itself is parsed via // `parse_features!`. impl_feature! { diff --git a/utoipa-gen/src/component/into_params.rs b/utoipa-gen/src/component/into_params.rs index c2e461ec..084491ca 100644 --- a/utoipa-gen/src/component/into_params.rs +++ b/utoipa-gen/src/component/into_params.rs @@ -1,7 +1,7 @@ use std::borrow::Cow; use proc_macro2::TokenStream; -use quote::{quote, ToTokens}; +use quote::{quote, quote_spanned, ToTokens}; use syn::{ parse::Parse, punctuated::Punctuated, spanned::Spanned, token::Comma, Attribute, Data, Field, Generics, Ident, @@ -25,6 +25,7 @@ use crate::{ FieldRename, }, doc_comment::CommentAttributes, + parse_utils::LitBoolOrExprPath, Array, Diagnostics, OptionExt, Required, ToTokensDiagnostics, }; @@ -122,7 +123,7 @@ impl ToTokensDiagnostics for IntoParams { .collect::, Diagnostics>>()? .into_iter() .filter_map(|(index, field, field_serde_params, field_features)| { - if field_serde_params.skip || field_features.iter().any(|feature| matches!(feature, Feature::Ignore(_))) { + if field_serde_params.skip { None } else { Some((index, field, field_serde_params, field_features)) @@ -158,7 +159,7 @@ impl ToTokensDiagnostics for IntoParams { tokens.extend(quote! { impl #impl_generics utoipa::IntoParams for #ident #ty_generics #where_clause { fn into_params(parameter_in_provider: impl Fn() -> Option) -> Vec { - #params.to_vec() + #params.into_iter().filter(Option::is_some).flatten().collect() } } }); @@ -339,6 +340,7 @@ impl Param { Param::resolve_field_features(field_features, &container_attributes) .map_err(Diagnostics::from)?; + let ignore = pop_feature!(param_features => Feature::Ignore(_)); let rename = pop_feature!(param_features => Feature::Rename(_) as Option) .map(|rename| rename.into_value()); let rename_to = field_serde_params @@ -413,6 +415,28 @@ impl Param { tokens.extend(quote! { .schema(Some(#schema_tokens)).build() }); } + let tokens = match ignore { + Some(Feature::Ignore(Ignore(LitBoolOrExprPath::LitBool(bool)))) => { + quote_spanned! { + bool.span() => if #bool { + None + } else { + Some(#tokens) + } + } + } + Some(Feature::Ignore(Ignore(LitBoolOrExprPath::ExprPath(path)))) => { + quote_spanned! { + path.span() => if #path() { + None + } else { + Some(#tokens) + } + } + } + _ => quote! { Some(#tokens) }, + }; + Ok(Self { tokens }) } diff --git a/utoipa-gen/src/component/schema.rs b/utoipa-gen/src/component/schema.rs index c72c5081..66a84a3a 100644 --- a/utoipa-gen/src/component/schema.rs +++ b/utoipa-gen/src/component/schema.rs @@ -1,7 +1,7 @@ use std::borrow::{Borrow, Cow}; use proc_macro2::{Ident, TokenStream}; -use quote::{quote, ToTokens}; +use quote::{quote, quote_spanned, ToTokens}; use syn::{ parse_quote, punctuated::Punctuated, spanned::Spanned, token::Comma, Attribute, Data, Field, Fields, FieldsNamed, FieldsUnnamed, Generics, Variant, @@ -11,6 +11,7 @@ use crate::{ as_tokens_or_diagnostics, component::features::attributes::{Rename, Title, ValueType}, doc_comment::CommentAttributes, + parse_utils::LitBoolOrExprPath, Array, AttributesExt, Diagnostics, OptionExt, ToTokensDiagnostics, }; @@ -24,7 +25,7 @@ use self::{ use super::{ features::{ - attributes::{As, Bound, Description, NoRecursion, RenameAll}, + attributes::{self, As, Bound, Description, NoRecursion, RenameAll}, parse_features, pop_feature, Feature, FeaturesExt, IntoInner, ToTokensExt, }, serde::{self, SerdeContainer, SerdeValue}, @@ -320,6 +321,7 @@ struct NamedStructFieldOptions<'a> { renamed_field: Option>, required: Option, is_option: bool, + ignore: Option, } impl NamedStructSchema { @@ -383,7 +385,7 @@ impl NamedStructSchema { .flatten() .collect::>(); - let mut object_tokens = fields_vec + let object_tokens = fields_vec .iter() .filter(|(_, field_rules, ..)| !field_rules.skip && !field_rules.flatten) .map(|(property, field_rules, field_name, field)| { @@ -398,13 +400,14 @@ impl NamedStructSchema { .collect::, Diagnostics>>()? .into_iter() .fold( - quote! { utoipa::openapi::ObjectBuilder::new() }, + quote! { let mut object = utoipa::openapi::ObjectBuilder::new(); }, |mut object_tokens, ( NamedStructFieldOptions { renamed_field, required, is_option, + ignore, .. }, field_rules, @@ -425,9 +428,9 @@ impl NamedStructSchema { super::rename::(field_name.borrow(), rename_to, rename_all) .unwrap_or(Cow::Borrowed(field_name.borrow())); - object_tokens.extend(quote! { - .property(#name, #field_schema) - }); + let mut property_tokens = quote! { + object = object.property(#name, #field_schema) + }; let component_required = !is_option && super::is_required(field_rules, &container_rules); let required = match (required, component_required) { @@ -436,15 +439,33 @@ impl NamedStructSchema { }; if required { - object_tokens.extend(quote! { + property_tokens.extend(quote! { .required(#name) }) } + object_tokens.extend(match ignore { + Some(LitBoolOrExprPath::LitBool(bool)) => quote_spanned! { + bool.span() => if !#bool { + #property_tokens; + } + }, + Some(LitBoolOrExprPath::ExprPath(path)) => quote_spanned! { + path.span() => if !#path() { + #property_tokens; + } + }, + None => quote! { #property_tokens; }, + }); + object_tokens }, ); + let mut object_tokens = quote! { + { #object_tokens; object } + }; + let flatten_fields = fields_vec .iter() .filter(|(_, field_rules, ..)| field_rules.flatten) @@ -549,14 +570,6 @@ impl NamedStructSchema { .into_inner() .unwrap_or_default(); - if field_features - .iter() - .any(|feature| matches!(feature, Feature::Ignore(_))) - { - // skip ignored field - return Ok(None); - }; - if features .iter() .any(|feature| matches!(feature, Feature::NoRecursion(_))) @@ -614,6 +627,11 @@ impl NamedStructSchema { let is_option = type_tree.is_option(); + let ignore = match pop_feature!(field_features => Feature::Ignore(_)) { + Some(Feature::Ignore(attributes::Ignore(bool_or_exp))) => Some(bool_or_exp), + _ => None, + }; + Ok(Some(NamedStructFieldOptions { property: if let Some(schema_with) = schema_with { Property::SchemaWith(schema_with) @@ -636,6 +654,7 @@ impl NamedStructSchema { renamed_field: rename_field, required, is_option, + ignore, })) } } diff --git a/utoipa-gen/src/lib.rs b/utoipa-gen/src/lib.rs index 710a4091..35b9406e 100644 --- a/utoipa-gen/src/lib.rs +++ b/utoipa-gen/src/lib.rs @@ -194,8 +194,9 @@ static CONFIG: once_cell::sync::Lazy = /// * `content_encoding = ...` Can be used to define content encoding used for underlying schema object. /// 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. +/// See [`Object::content_media_type`][schema_object_`media_type] +///* `ignore` or `ignore = ...` Can be used to skip the field from being serialized to OpenAPI schema. It accepts either a literal `bool` value +/// or a path to a function that returns `bool` (`Fn() -> bool`). ///* `no_recursion` Is used to break from recursion in case of looping schema tree e.g. `Pet` -> /// `Owner` -> `Pet`. _`no_recursion`_ attribute must be used within `Ower` type not to allow /// recurring into `Pet`. Failing to do so will cause infinite loop and runtime **panic**. @@ -2349,7 +2350,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. +/// * `ignore` or `ignore = ...` Can be used to skip the field from being serialized to OpenAPI schema. It accepts either a literal `bool` value +/// or a path to a function that returns `bool` (`Fn() -> bool`). /// /// #### Field nullability and required rules /// @@ -3632,13 +3634,14 @@ mod parse_utils { use std::fmt::Display; use proc_macro2::{Group, Ident, TokenStream}; - use quote::ToTokens; + use quote::{quote, ToTokens}; use syn::{ parenthesized, parse::{Parse, ParseStream}, punctuated::Punctuated, + spanned::Spanned, token::Comma, - Error, Expr, LitBool, LitStr, Token, + Error, Expr, ExprPath, LitBool, LitStr, Token, }; #[cfg_attr(feature = "debug", derive(Debug))] @@ -3793,4 +3796,61 @@ mod parse_utils { )) } } + + #[cfg_attr(feature = "debug", derive(Debug))] + #[derive(Clone)] + pub enum LitBoolOrExprPath { + LitBool(LitBool), + ExprPath(ExprPath), + } + + impl From for LitBoolOrExprPath { + fn from(value: bool) -> Self { + Self::LitBool(LitBool::new(value, proc_macro2::Span::call_site())) + } + } + + impl Default for LitBoolOrExprPath { + fn default() -> Self { + Self::LitBool(LitBool::new(false, proc_macro2::Span::call_site())) + } + } + + impl Parse for LitBoolOrExprPath { + fn parse(input: ParseStream) -> syn::Result { + if input.peek(LitBool) { + Ok(LitBoolOrExprPath::LitBool(input.parse::()?)) + } else { + let expr = input.parse::()?; + + match expr { + Expr::Path(expr_path) => Ok(LitBoolOrExprPath::ExprPath(expr_path)), + _ => Err(syn::Error::new( + expr.span(), + format!( + "expected literal bool or path to a function that returns bool, found: {}", + quote! {#expr} + ), + )), + } + } + } + } + + impl ToTokens for LitBoolOrExprPath { + fn to_tokens(&self, tokens: &mut TokenStream) { + match self { + Self::LitBool(bool) => bool.to_tokens(tokens), + Self::ExprPath(call) => call.to_tokens(tokens), + } + } + } + + pub fn parse_next_literal_bool_or_call(input: ParseStream) -> syn::Result { + if input.peek(Token![=]) { + parse_next(input, || LitBoolOrExprPath::parse(input)) + } else { + Ok(LitBoolOrExprPath::from(true)) + } + } } diff --git a/utoipa-gen/tests/path_derive.rs b/utoipa-gen/tests/path_derive.rs index a3380c7a..ea5f9b22 100644 --- a/utoipa-gen/tests/path_derive.rs +++ b/utoipa-gen/tests/path_derive.rs @@ -2898,6 +2898,52 @@ fn derive_into_params_with_ignored_field() { ) } +#[test] +fn derive_into_params_with_ignored_eq_false_field() { + #![allow(unused)] + + #[derive(IntoParams)] + #[into_params(parameter_in = Query)] + struct Params { + name: String, + #[param(ignore = false)] + __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" + } + }, + { + "in": "query", + "name": "__this_is_private", + "required": true, + "schema": { + "type": "string" + } + } + ]) + ) +} + #[test] fn derive_octet_stream_request_body() { #![allow(dead_code)] diff --git a/utoipa-gen/tests/schema_derive_test.rs b/utoipa-gen/tests/schema_derive_test.rs index 87132c94..ce264118 100644 --- a/utoipa-gen/tests/schema_derive_test.rs +++ b/utoipa-gen/tests/schema_derive_test.rs @@ -5726,6 +5726,69 @@ fn derive_schema_with_ignored_field() { ) } +#[test] +fn derive_schema_with_ignore_eq_false_field() { + #![allow(unused)] + let value = api_doc! { + struct SchemaIgnoredField { + value: String, + #[schema(ignore = false)] + this_is_not_private: String, + } + }; + + assert_json_eq!( + value, + json!({ + "properties": { + "value": { + "type": "string" + }, + "this_is_not_private": { + "type": "string" + } + }, + "required": [ "value", "this_is_not_private" ], + "type": "object" + }) + ) +} + +#[test] +fn derive_schema_with_ignore_eq_call_field() { + #![allow(unused)] + + let value = api_doc! { + struct SchemaIgnoredField { + value: String, + #[schema(ignore = Self::ignore)] + this_is_not_private: String, + } + + impl SchemaIgnoredField { + fn ignore() -> bool { + false + } + } + }; + + assert_json_eq!( + value, + json!({ + "properties": { + "value": { + "type": "string" + }, + "this_is_not_private": { + "type": "string" + } + }, + "required": [ "value", "this_is_not_private" ], + "type": "object" + }) + ) +} + #[test] fn derive_schema_unnamed_title() { #![allow(unused)]