diff --git a/tests/component_derive_test.rs b/tests/component_derive_test.rs index 8efc8500..a161c8df 100644 --- a/tests/component_derive_test.rs +++ b/tests/component_derive_test.rs @@ -1,9 +1,10 @@ #![cfg(feature = "serde_json")] -use std::{borrow::Cow, cell::RefCell, collections::HashMap, vec}; +use std::{borrow::Cow, cell::RefCell, collections::HashMap, marker::PhantomData, vec}; #[cfg(any(feature = "chrono_types", feature = "chrono_types_with_format"))] use chrono::{Date, DateTime, Duration, Utc}; +use serde::Serialize; use serde_json::Value; use utoipa::{Component, OpenApi}; @@ -796,3 +797,77 @@ fn derive_struct_with_uuid_type() { "properties.id.format" = r#""uuid""#, "Post id format" } } + +#[test] +fn derive_parse_serde_field_attributes() { + struct S; + let post = api_doc! { + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + struct Post { + #[serde(rename = "uuid")] + id: String, + #[serde(skip)] + _p: PhantomData, + long_field_num: i64, + } + }; + + assert_value! {post=> + "properties.uuid.type" = r#""string""#, "Post id type" + "properties.longFieldNum.type" = r#""integer""#, "Post long_field_num type" + "properties.longFieldNum.format" = r#""int64""#, "Post logn_field_num format" + } +} + +#[test] +fn derive_parse_serde_simple_enum_attributes() { + let value = api_doc! { + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + enum Value { + A, + B, + #[serde(skip)] + C, + } + }; + + assert_value! {value=> + "enum" = r#"["a","b"]"#, "Value enum variants" + } +} + +#[test] +fn derive_parse_serde_complex_enum() { + #[derive(Serialize)] + struct Foo; + let complex_enum = api_doc! { + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + enum Bar { + UnitValue, + #[serde(rename_all = "camelCase")] + NamedFields { + #[serde(rename = "id")] + named_id: &'static str, + name_list: Option> + }, + UnnamedFields(Foo), + #[serde(skip)] + Random, + } + }; + + assert_value! {complex_enum=> + "oneOf.[0].enum" = r#"["unitValue"]"#, "Unit value enum" + "oneOf.[0].type" = r#""string""#, "Unit value type" + + "oneOf.[1].properties.namedFields.properties.id.type" = r#""string""#, "Named fields id type" + "oneOf.[1].properties.namedFields.properties.nameList.type" = r#""array""#, "Named fields nameList type" + "oneOf.[1].properties.namedFields.properties.nameList.items.type" = r#""string""#, "Named fields nameList items type" + "oneOf.[1].properties.namedFields.required" = r#"["id"]"#, "Named fields required" + + "oneOf.[2].properties.unnamedFields.$ref" = r###""#/components/schemas/Foo""###, "Unnamed fields ref" + } +} diff --git a/utoipa-gen/src/doc_comment.rs b/utoipa-gen/src/doc_comment.rs index ac1c9ce2..0c5cb496 100644 --- a/utoipa-gen/src/doc_comment.rs +++ b/utoipa-gen/src/doc_comment.rs @@ -1,3 +1,5 @@ +use std::ops::Deref; + use proc_macro2::{Ident, Span}; use proc_macro_error::{abort_call_site, emit_warning, ResultExt}; use syn::{Attribute, Lit, Meta}; @@ -54,3 +56,11 @@ impl CommentAttributes { } } } + +impl Deref for CommentAttributes { + type Target = Vec; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} diff --git a/utoipa-gen/src/lib.rs b/utoipa-gen/src/lib.rs index 3b5f0eb7..d24f4dc3 100644 --- a/utoipa-gen/src/lib.rs +++ b/utoipa-gen/src/lib.rs @@ -7,7 +7,7 @@ #![warn(missing_docs)] #![warn(rustdoc::broken_intra_doc_links)] -use std::{borrow::Cow, mem}; +use std::{borrow::Cow, mem, ops::Deref}; use doc_comment::CommentAttributes; use schema::component::Component; @@ -825,6 +825,19 @@ where } } +impl Deref for Array +where + T: Sized + ToTokens, +{ + type Target = Vec; + + fn deref(&self) -> &Self::Target { + match self { + Self::Owned(vec) => vec, + } + } +} + impl ToTokens for Array where T: Sized + ToTokens, diff --git a/utoipa-gen/src/schema.rs b/utoipa-gen/src/schema.rs index be0d5a6c..47ec0109 100644 --- a/utoipa-gen/src/schema.rs +++ b/utoipa-gen/src/schema.rs @@ -165,3 +165,301 @@ enum GenericType { Box, RefCell, } + +pub mod serde { + //! Provides serde related features parsing serde attributes from types. + + use std::str::FromStr; + + use proc_macro2::{Span, TokenTree}; + use proc_macro_error::ResultExt; + use syn::{buffer::Cursor, Attribute, Error}; + + #[cfg_attr(feature = "debug", derive(Debug))] + pub enum Serde { + Container(SerdeContainer), + Value(SerdeValue), + } + + impl Serde { + #[inline] + fn parse_next_lit_str(next: Cursor) -> Option<(String, Span)> { + match next.token_tree() { + Some((tt, next)) => match tt { + TokenTree::Punct(punct) if punct.as_char() == '=' => { + Serde::parse_next_lit_str(next) + } + TokenTree::Literal(literal) => { + Some((literal.to_string().replace('\"', ""), literal.span())) + } + _ => None, + }, + _ => None, + } + } + + fn parse_container(input: syn::parse::ParseStream) -> syn::Result { + let mut container = SerdeContainer::default(); + + input.step(|cursor| { + let mut rest = *cursor; + while let Some((tt, next)) = rest.token_tree() { + match tt { + TokenTree::Ident(ident) if ident == "rename_all" => { + if let Some((literal, span)) = Serde::parse_next_lit_str(next) { + container.rename_all = Some( + literal + .parse::() + .map_err(|error| Error::new(span, error.to_string()))?, + ); + }; + } + _ => (), + } + + rest = next; + } + Ok(((), rest)) + })?; + + Ok(Serde::Container(container)) + } + + fn parse_value(input: syn::parse::ParseStream) -> syn::Result { + let mut value = SerdeValue::default(); + + input.step(|cursor| { + let mut rest = *cursor; + while let Some((tt, next)) = rest.token_tree() { + match tt { + TokenTree::Ident(ident) if ident == "skip" => value.skip = Some(true), + TokenTree::Ident(ident) if ident == "rename" => { + if let Some((literal, _)) = Serde::parse_next_lit_str(next) { + value.rename = Some(literal) + }; + } + _ => (), + } + + rest = next; + } + Ok(((), rest)) + })?; + + Ok(Serde::Value(value)) + } + } + + #[derive(Default)] + #[cfg_attr(feature = "debug", derive(Debug))] + pub struct SerdeValue { + pub skip: Option, + pub rename: Option, + } + + #[derive(Default)] + #[cfg_attr(feature = "debug", derive(Debug))] + pub struct SerdeContainer { + pub rename_all: Option, + } + + pub fn parse_value(attributes: &[Attribute]) -> Option { + attributes + .iter() + .find(|attribute| attribute.path.is_ident("serde")) + .map(|serde_attribute| { + serde_attribute + .parse_args_with(Serde::parse_value) + .unwrap_or_abort() + }) + } + + pub fn parse_container(attributes: &[Attribute]) -> Option { + attributes + .iter() + .find(|attribute| attribute.path.is_ident("serde")) + .map(|serde_attribute| { + serde_attribute + .parse_args_with(Serde::parse_container) + .unwrap_or_abort() + }) + } + + #[cfg_attr(feature = "debug", derive(Debug))] + pub enum RenameRule { + Lower, + Upper, + Camel, + Snake, + ScreamingSnake, + Pascal, + Kebab, + ScreamingKebab, + } + + impl RenameRule { + pub fn rename(&self, value: &str) -> String { + match self { + RenameRule::Lower => value.to_ascii_lowercase(), + RenameRule::Upper => value.to_ascii_uppercase(), + RenameRule::Camel => { + let mut camel_case = String::new(); + + let mut upper = false; + for letter in value.chars() { + if letter == '_' { + upper = true; + continue; + } + + if upper { + camel_case.push(letter.to_ascii_uppercase()); + upper = false; + } else { + camel_case.push(letter) + } + } + + camel_case + } + RenameRule::Snake => value.to_string(), + RenameRule::ScreamingSnake => Self::Snake.rename(value).to_ascii_uppercase(), + RenameRule::Pascal => { + let mut pascal_case = String::from(&value[..1].to_ascii_uppercase()); + pascal_case.push_str(&Self::Camel.rename(&value[1..])); + + pascal_case + } + RenameRule::Kebab => Self::Snake.rename(value).replace('_', "-"), + RenameRule::ScreamingKebab => Self::Kebab.rename(value).to_ascii_uppercase(), + } + } + + pub fn rename_variant(&self, variant: &str) -> String { + match self { + RenameRule::Lower => variant.to_ascii_lowercase(), + RenameRule::Upper => variant.to_ascii_uppercase(), + RenameRule::Camel => { + let mut snake_case = String::from(&variant[..1].to_ascii_lowercase()); + snake_case.push_str(&variant[1..]); + + snake_case + } + RenameRule::Snake => { + let mut snake_case = String::new(); + + for (index, letter) in variant.char_indices() { + if index > 0 && letter.is_uppercase() { + snake_case.push('_'); + } + snake_case.push(letter); + } + + snake_case.to_ascii_lowercase() + } + RenameRule::ScreamingSnake => { + Self::Snake.rename_variant(variant).to_ascii_uppercase() + } + RenameRule::Pascal => variant.to_string(), + RenameRule::Kebab => Self::Snake.rename_variant(variant).replace('_', "-"), + RenameRule::ScreamingKebab => { + Self::Kebab.rename_variant(variant).to_ascii_uppercase() + } + } + } + } + + impl FromStr for RenameRule { + type Err = Error; + + fn from_str(s: &str) -> Result { + [ + ("lowecase", RenameRule::Lower), + ("UPPERCASE", RenameRule::Upper), + ("Pascal", RenameRule::Pascal), + ("camelCase", RenameRule::Camel), + ("snake_case", RenameRule::Snake), + ("SCREAMING_SNAKE_CASE", RenameRule::ScreamingSnake), + ("kebab-case", RenameRule::Kebab), + ("SCREAMING-KEBAB-CASE", RenameRule::ScreamingKebab), + ] + .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", "Pascal", "camelCase", "snake_case", "SCREAMING_SNAKE_CASE", "kebab-case", "SCREAMING-KEBAB-CASE""#, + ) + }) + } + } +} + +#[cfg(test)] +mod tests { + use super::serde::RenameRule; + + macro_rules! test_rename_rule { + ( $($case:expr=> $value:literal = $expected:literal)* ) => { + #[test] + fn rename_all_rename_rules() { + $( + let value = $case.rename($value); + assert_eq!(value, $expected, "expected case: {} => {} != {}", stringify!($case), $value, $expected); + )* + } + }; + } + + macro_rules! test_rename_variant_rule { + ( $($case:expr=> $value:literal = $expected:literal)* ) => { + #[test] + fn rename_all_rename_variant_rules() { + $( + let value = $case.rename_variant($value); + assert_eq!(value, $expected, "expected case: {} => {} != {}", stringify!($case), $value, $expected); + )* + } + }; + } + + test_rename_rule! { + RenameRule::Lower=> "single" = "single" + RenameRule::Upper=> "single" = "SINGLE" + RenameRule::Pascal=> "single" = "Single" + RenameRule::Camel=> "single" = "single" + RenameRule::Snake=> "single" = "single" + RenameRule::ScreamingSnake=> "single" = "SINGLE" + RenameRule::Kebab=> "single" = "single" + RenameRule::ScreamingKebab=> "single" = "SINGLE" + + RenameRule::Lower=> "multi_value" = "multi_value" + RenameRule::Upper=> "multi_value" = "MULTI_VALUE" + RenameRule::Pascal=> "multi_value" = "MultiValue" + RenameRule::Camel=> "multi_value" = "multiValue" + RenameRule::Snake=> "multi_value" = "multi_value" + RenameRule::ScreamingSnake=> "multi_value" = "MULTI_VALUE" + RenameRule::Kebab=> "multi_value" = "multi-value" + RenameRule::ScreamingKebab=> "multi_value" = "MULTI-VALUE" + } + + test_rename_variant_rule! { + RenameRule::Lower=> "Single" = "single" + RenameRule::Upper=> "Single" = "SINGLE" + RenameRule::Pascal=> "Single" = "Single" + RenameRule::Camel=> "Single" = "single" + RenameRule::Snake=> "Single" = "single" + RenameRule::ScreamingSnake=> "Single" = "SINGLE" + RenameRule::Kebab=> "Single" = "single" + RenameRule::ScreamingKebab=> "Single" = "SINGLE" + + RenameRule::Lower=> "MultiValue" = "multivalue" + RenameRule::Upper=> "MultiValue" = "MULTIVALUE" + RenameRule::Pascal=> "MultiValue" = "MultiValue" + RenameRule::Camel=> "MultiValue" = "multiValue" + RenameRule::Snake=> "MultiValue" = "multi_value" + RenameRule::ScreamingSnake=> "MultiValue" = "MULTI_VALUE" + RenameRule::Kebab=> "MultiValue" = "multi-value" + RenameRule::ScreamingKebab=> "MultiValue" = "MULTI-VALUE" + } +} diff --git a/utoipa-gen/src/schema/component.rs b/utoipa-gen/src/schema/component.rs index e115bbfb..8211cbc5 100644 --- a/utoipa-gen/src/schema/component.rs +++ b/utoipa-gen/src/schema/component.rs @@ -1,3 +1,5 @@ +use std::mem; + use proc_macro2::{Ident, TokenStream as TokenStream2}; use proc_macro_error::abort; use quote::{quote, ToTokens}; @@ -17,7 +19,10 @@ use self::{ xml::Xml, }; -use super::{ComponentPart, GenericType, ValueType}; +use super::{ + serde::{self, RenameRule, Serde}, + ComponentPart, GenericType, ValueType, +}; mod attr; mod xml; @@ -122,46 +127,61 @@ struct NamedStructComponent<'a> { impl ToTokens for NamedStructComponent<'_> { fn to_tokens(&self, tokens: &mut TokenStream2) { + let mut container_rules = serde::parse_container(self.attributes); + tokens.extend(quote! { utoipa::openapi::ObjectBuilder::new() }); - self.fields.iter().for_each(|field| { - let field_name = &*field.ident.as_ref().unwrap().to_string(); + self.fields + .iter() + .filter_map(|field| { + let field_rule = serde::parse_value(&field.attrs); - let component_part = &ComponentPart::from_type(&field.ty); - let deprecated = super::get_deprecated(&field.attrs); - let attrs = ComponentAttr::::from_attributes_validated( - &field.attrs, - component_part, - ); + if is_not_skipped(&field_rule) { + Some((field, field_rule)) + } else { + None + } + }) + .for_each(|(field, mut field_rule)| { + let field_name = &*field.ident.as_ref().unwrap().to_string(); + let name = &rename_field(&mut container_rules, &mut field_rule, field_name) + .unwrap_or_else(|| String::from(field_name)); - let type_override = attrs - .as_ref() - .and_then(|field| field.as_ref().ty.as_ref()) - .map(ComponentPart::from_ident); - let xml_value = attrs - .as_ref() - .and_then(|named_field| named_field.as_ref().xml.as_ref()); - let comments = CommentAttributes::from_attributes(&field.attrs); - - let component = ComponentProperty::new( - component_part, - Some(&comments), - attrs.as_ref(), - deprecated.as_ref(), - xml_value, - type_override.as_ref(), - ); + let component_part = &ComponentPart::from_type(&field.ty); + let deprecated = super::get_deprecated(&field.attrs); + let attrs = ComponentAttr::::from_attributes_validated( + &field.attrs, + component_part, + ); - tokens.extend(quote! { - .property(#field_name, #component) - }); + let type_override = attrs + .as_ref() + .and_then(|field| field.as_ref().ty.as_ref()) + .map(ComponentPart::from_ident); + let xml_value = attrs + .as_ref() + .and_then(|named_field| named_field.as_ref().xml.as_ref()); + let comments = CommentAttributes::from_attributes(&field.attrs); + + let component = ComponentProperty::new( + component_part, + Some(&comments), + attrs.as_ref(), + deprecated.as_ref(), + xml_value, + type_override.as_ref(), + ); - if !component.is_option() { tokens.extend(quote! { - .required(#field_name) - }) - } - }); + .property(#name, #component) + }); + + if !component.is_option() { + tokens.extend(quote! { + .required(#name) + }) + } + }); if let Some(deprecated) = super::get_deprecated(self.attributes) { tokens.extend(quote! { .deprecated(Some(#deprecated)) }); @@ -172,10 +192,7 @@ impl ToTokens for NamedStructComponent<'_> { tokens.extend(attrs.to_token_stream()); } - if let Some(comment) = CommentAttributes::from_attributes(self.attributes) - .0 - .first() - { + if let Some(comment) = CommentAttributes::from_attributes(self.attributes).first() { tokens.extend(quote! { .description(Some(#comment)) }) @@ -239,10 +256,7 @@ impl ToTokens for UnnamedStructComponent<'_> { } }; - if let Some(comment) = CommentAttributes::from_attributes(self.attributes) - .0 - .first() - { + if let Some(comment) = CommentAttributes::from_attributes(self.attributes).first() { tokens.extend(quote! { .description(Some(#comment)) }) @@ -296,17 +310,30 @@ struct SimpleEnum<'a> { impl ToTokens for SimpleEnum<'_> { fn to_tokens(&self, tokens: &mut TokenStream2) { + let mut container_rules = serde::parse_container(self.attributes); + let enum_values = self .variants .iter() - .filter(|variant| matches!(variant.fields, Fields::Unit)) - .map(|variant| variant.ident.to_string()) + .filter_map(|variant| { + let mut variant_rules = serde::parse_value(&variant.attrs); + + if is_not_skipped(&variant_rules) { + let name = &*variant.ident.to_string(); + let renamed = rename_variant(&mut container_rules, &mut variant_rules, name); + + renamed.or_else(|| Some(String::from(name))) + } else { + None + } + }) .collect::>(); + let len = enum_values.len(); tokens.extend(quote! { utoipa::openapi::PropertyBuilder::new() .component_type(utoipa::openapi::ComponentType::String) - .enum_values(Some(#enum_values)) + .enum_values::<[&str; #len], &str>(Some(#enum_values)) }); let attrs = attr::parse_component_attr::>(self.attributes); @@ -318,10 +345,7 @@ impl ToTokens for SimpleEnum<'_> { tokens.extend(quote! { .deprecated(Some(#deprecated)) }); } - if let Some(comment) = CommentAttributes::from_attributes(self.attributes) - .0 - .first() - { + if let Some(comment) = CommentAttributes::from_attributes(self.attributes).first() { tokens.extend(quote! { .description(Some(#comment)) }) @@ -354,10 +378,20 @@ impl ToTokens for ComplexEnum<'_> { Into::::into(utoipa::openapi::OneOf::with_capacity(#capasity)) }); + let mut container_rule = serde::parse_container(self.attributes); + // serde, externally tagged format supported by now self.variants .iter() - .map(|variant| match &variant.fields { + .filter_map(|variant| { + let variant_rules = serde::parse_value(&variant.attrs); + if is_not_skipped(&variant_rules) { + Some((variant, variant_rules)) + } else { + None + } + }) + .map(|(variant, mut variant_rule)| match &variant.fields { Fields::Named(named_fields) => { let named_enum = NamedStructComponent { attributes: &variant.attrs, @@ -365,9 +399,12 @@ impl ToTokens for ComplexEnum<'_> { }; let name = &*variant.ident.to_string(); + let renamed = rename_variant(&mut container_rule, &mut variant_rule, name) + .unwrap_or_else(|| String::from(name)); + quote! { utoipa::openapi::schema::ObjectBuilder::new() - .property(#name, #named_enum) + .property(#renamed, #named_enum) } } Fields::Unnamed(unnamed_fields) => { @@ -376,10 +413,12 @@ impl ToTokens for ComplexEnum<'_> { fields: &unnamed_fields.unnamed, }; let name = &*variant.ident.to_string(); + let renamed = rename_variant(&mut container_rule, &mut variant_rule, name) + .unwrap_or_else(|| String::from(name)); quote! { utoipa::openapi::schema::ObjectBuilder::new() - .property(#name, #unnamed_enum) + .property(#renamed, #unnamed_enum) } } Fields::Unit => { @@ -387,7 +426,7 @@ impl ToTokens for ComplexEnum<'_> { enum_values.push(variant.clone()); SimpleEnum { - attributes: &variant.attrs, + attributes: self.attributes, variants: &enum_values, } .to_token_stream() @@ -399,10 +438,7 @@ impl ToTokens for ComplexEnum<'_> { }) }); - if let Some(comment) = CommentAttributes::from_attributes(self.attributes) - .0 - .first() - { + if let Some(comment) = CommentAttributes::from_attributes(self.attributes).first() { tokens.extend(quote! { .description(Some(#comment)) }) @@ -567,3 +603,47 @@ where } } } + +#[inline] +fn is_not_skipped(rule: &Option) -> bool { + rule.as_ref() + .map(|rule| matches!(rule, Serde::Value(value) if value.skip == None)) + .unwrap_or(true) +} + +#[inline] +fn rename_field<'a>( + container_rule: &'a mut Option, + field_rule: &'a mut Option, + field: &str, +) -> Option { + rename(container_rule, field_rule, &|rule| rule.rename(field)) +} + +#[inline] +fn rename_variant<'a>( + container_rule: &'a mut Option, + field_rule: &'a mut Option, + field: &str, +) -> Option { + rename(container_rule, field_rule, &|rule| { + rule.rename_variant(field) + }) +} + +#[inline] +fn rename<'a>( + container_rule: &'a mut Option, + field_rule: &'a mut Option, + rename_op: &impl Fn(&RenameRule) -> String, +) -> Option { + let rename = |rule: &mut Serde| match rule { + Serde::Container(container) => container.rename_all.as_ref().map(rename_op), + Serde::Value(ref mut value) => mem::take(&mut value.rename), + }; + + field_rule + .as_mut() + .and_then(rename) + .or_else(|| container_rule.as_mut().and_then(rename)) +} diff --git a/utoipa-gen/src/schema/into_params.rs b/utoipa-gen/src/schema/into_params.rs index 6a230fa9..6de82c64 100644 --- a/utoipa-gen/src/schema/into_params.rs +++ b/utoipa-gen/src/schema/into_params.rs @@ -85,7 +85,7 @@ impl ToTokens for Param<'_> { tokens.extend(quote! { .deprecated(Some(#deprecated)) }); } - if let Some(comment) = CommentAttributes::from_attributes(&field.attrs).0.first() { + if let Some(comment) = CommentAttributes::from_attributes(&field.attrs).first() { tokens.extend(quote! { .description(Some(#comment)) })