diff --git a/utoipa-gen/src/component/features.rs b/utoipa-gen/src/component/features.rs index 6d4f0a7f..3f0a4e93 100644 --- a/utoipa-gen/src/component/features.rs +++ b/utoipa-gen/src/component/features.rs @@ -174,7 +174,7 @@ impl Feature { impl ToTokens for Feature { fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) { let feature = match &self { - Feature::Default(default) => quote! { .default(Some(#default)) }, + Feature::Default(default) => quote! { .default(#default) }, Feature::Example(example) => quote! { .example(Some(#example)) }, Feature::XmlAttr(xml) => quote! { .xml(Some(#xml)) }, Feature::Format(format) => quote! { .format(Some(#format)) }, @@ -414,17 +414,30 @@ name!(Example = "example"); #[derive(Clone)] #[cfg_attr(feature = "debug", derive(Debug))] -pub struct Default(AnyValue); +pub struct Default(pub(crate) Option); + +impl Default { + pub fn new_default_trait(struct_ident: Ident, field_ident: syn::Member) -> Self { + Self(Some(AnyValue::new_default_trait(struct_ident, field_ident))) + } +} impl Parse for Default { fn parse(input: syn::parse::ParseStream, _: Ident) -> syn::Result { - parse_utils::parse_next(input, || AnyValue::parse_any(input)).map(Self) + if input.peek(syn::Token![=]) { + parse_utils::parse_next(input, || AnyValue::parse_any(input)).map(|any| Self(Some(any))) + } else { + Ok(Self(None)) + } } } impl ToTokens for Default { fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) { - tokens.extend(self.0.to_token_stream()) + match &self.0 { + Some(inner) => tokens.extend(quote! {Some(#inner)}), + None => tokens.extend(quote! {None}), + } } } diff --git a/utoipa-gen/src/component/schema.rs b/utoipa-gen/src/component/schema.rs index 16707190..812544cb 100644 --- a/utoipa-gen/src/component/schema.rs +++ b/utoipa-gen/src/component/schema.rs @@ -2,7 +2,7 @@ use std::borrow::Cow; use proc_macro2::{Ident, Span, TokenStream}; use proc_macro_error::{abort, ResultExt}; -use quote::{quote, ToTokens}; +use quote::{format_ident, quote, ToTokens}; use syn::{ parse::Parse, punctuated::Punctuated, token::Comma, Attribute, Data, Field, Fields, FieldsNamed, FieldsUnnamed, GenericParam, Generics, Lifetime, LifetimeDef, Path, PathArguments, @@ -269,6 +269,7 @@ impl NamedStructSchema<'_> { fn field_as_schema_property( &self, field: &Field, + container_rules: &Option, yield_: impl FnOnce(NamedStructFieldOptions<'_>) -> R, ) -> R { let type_tree = &mut TypeTree::from_type(&field.ty); @@ -278,6 +279,30 @@ impl NamedStructSchema<'_> { .parse_features::() .into_inner(); + let schema_default = self + .features + .as_ref() + .map(|features| features.iter().any(|f| matches!(f, Feature::Default(_)))) + .unwrap_or(false); + let serde_default = container_rules + .as_ref() + .map(|rules| rules.default) + .unwrap_or(false); + + if schema_default || serde_default { + let features_inner = field_features.get_or_insert(vec![]); + if !features_inner + .iter() + .any(|f| matches!(f, Feature::Default(_))) + { + let field_ident = field.ident.as_ref().unwrap().to_owned(); + let struct_ident = format_ident!("{}", &self.struct_name); + features_inner.push(Feature::Default( + crate::features::Default::new_default_trait(struct_ident, field_ident.into()), + )); + } + } + let rename_field = pop_feature!(field_features => Feature::Rename(_)).and_then(|feature| match feature { Feature::Rename(rename) => Some(Cow::Owned(rename.into_value())), @@ -362,6 +387,7 @@ impl ToTokens for NamedStructSchema<'_> { self.field_as_schema_property( field, + &container_rules, |NamedStructFieldOptions { property, rename_field_value, @@ -431,6 +457,7 @@ impl ToTokens for NamedStructSchema<'_> { for field in flatten_fields { self.field_as_schema_property( field, + &container_rules, |NamedStructFieldOptions { property, .. }| { tokens.extend(quote! { .item(#property) }); }, @@ -502,6 +529,20 @@ impl ToTokens for UnnamedStructSchema<'_> { .unwrap_or_default(); } + if fields_len == 1 { + if let Some(ref mut features) = unnamed_struct_features { + if pop_feature!(features => Feature::Default(crate::features::Default(None))) + .is_some() + { + let struct_ident = format_ident!("{}", &self.struct_name); + let index: syn::Index = 0.into(); + features.push(Feature::Default( + crate::features::Default::new_default_trait(struct_ident, index.into()), + )); + } + } + } + tokens.extend( ComponentSchema::new(super::ComponentSchemaProps { type_tree: override_type_tree.as_ref().unwrap_or(first_part), diff --git a/utoipa-gen/src/component/schema/features.rs b/utoipa-gen/src/component/schema/features.rs index 117e4e2d..a92948d3 100644 --- a/utoipa-gen/src/component/schema/features.rs +++ b/utoipa-gen/src/component/schema/features.rs @@ -24,7 +24,8 @@ impl Parse for NamedFieldStructFeatures { RenameAll, MaxProperties, MinProperties, - As + As, + Default ))) } } diff --git a/utoipa-gen/src/lib.rs b/utoipa-gen/src/lib.rs index 7cb07461..8881c508 100644 --- a/utoipa-gen/src/lib.rs +++ b/utoipa-gen/src/lib.rs @@ -23,7 +23,7 @@ use proc_macro2::{Group, Ident, Punct, TokenStream as TokenStream2}; use syn::{ parse::{Parse, ParseStream}, punctuated::Punctuated, - DeriveInput, ExprPath, ItemFn, Lit, LitStr, Token, + DeriveInput, ExprPath, ItemFn, Lit, LitStr, Member, Token, }; mod component; @@ -84,6 +84,8 @@ use self::{ /// * `as = ...` Can be used to define alternative path and name for the schema what will be used in /// the OpenAPI. E.g _`as = path::to::Pet`_. This would make the schema appear in the generated /// OpenAPI spec as _`path.to.Pet`_. +/// * `default` Can be used to populate default values on all fields using the struct's +/// [`Default`](std::default::Default) implementation. /// /// # Enum Optional Configuration Options for `#[schema(...)]` /// * `example = ...` Can be method reference or _`json!(...)`_. @@ -108,7 +110,9 @@ use self::{ /// /// # Unnamed Field Struct Optional Configuration Options for `#[schema(...)]` /// * `example = ...` Can be method reference or _`json!(...)`_. -/// * `default = ...` Can be method reference or _`json!(...)`_. +/// * `default = ...` Can be method reference or _`json!(...)`_. If no value is specified, and the struct has +/// only one field, the field's default value in the schema will be set from the struct's +/// [`Default`](std::default::Default) implementation. /// * `format = ...` May either be variant of the [`KnownFormat`][known_format] enum, or otherwise /// an open value as a string. By default the format is derived from the type of the property /// according OpenApi spec. @@ -2496,6 +2500,10 @@ impl ToTokens for ExternalDocs { pub(self) enum AnyValue { String(TokenStream2), Json(TokenStream2), + DefaultTrait { + struct_ident: Ident, + field_ident: Member, + }, } impl AnyValue { @@ -2550,6 +2558,13 @@ impl AnyValue { Ok(AnyValue::Json(parse_utils::parse_json_token_stream(input)?)) } } + + fn new_default_trait(struct_ident: Ident, field_ident: Member) -> Self { + Self::DefaultTrait { + struct_ident, + field_ident, + } + } } impl ToTokens for AnyValue { @@ -2559,6 +2574,12 @@ impl ToTokens for AnyValue { serde_json::json!(#json) }), Self::String(string) => string.to_tokens(tokens), + Self::DefaultTrait { + struct_ident, + field_ident, + } => tokens.extend( + quote! {::serde_json::to_value(#struct_ident::default().#field_ident).unwrap()}, + ), } } } diff --git a/utoipa-gen/tests/schema_derive_test.rs b/utoipa-gen/tests/schema_derive_test.rs index 5589f579..91c3cd87 100644 --- a/utoipa-gen/tests/schema_derive_test.rs +++ b/utoipa-gen/tests/schema_derive_test.rs @@ -229,6 +229,71 @@ fn derive_struct_with_custom_properties_success() { }; } +#[test] +fn derive_struct_with_default_attr() { + let book = api_doc! { + #[schema(default)] + struct Book { + name: String, + #[schema(default = 0)] + id: u64, + year: u64, + hash: String, + } + + impl Default for Book { + fn default() -> Self { + Self { + name: "No name".to_string(), + id: 999, + year: 2020, + hash: "Test hash".to_string(), + } + } + } + }; + + assert_value! { book => + "properties.name.default" = r#""No name""#, "Book name default" + "properties.id.default" = r#"0"#, "Book id default" + "properties.year.default" = r#"2020"#, "Book year default" + "properties.hash.default" = r#""Test hash""#, "Book hash default" + }; +} + +#[test] +fn derive_struct_with_serde_default_attr() { + let book = api_doc! { + #[derive(serde::Deserialize)] + #[serde(default)] + struct Book { + name: String, + #[schema(default = 0)] + id: u64, + year: u64, + hash: String, + } + + impl Default for Book { + fn default() -> Self { + Self { + name: "No name".to_string(), + id: 999, + year: 2020, + hash: "Test hash".to_string(), + } + } + } + }; + + assert_value! { book => + "properties.name.default" = r#""No name""#, "Book name default" + "properties.id.default" = r#"0"#, "Book id default" + "properties.year.default" = r#"2020"#, "Book year default" + "properties.hash.default" = r#""Test hash""#, "Book hash default" + }; +} + #[test] fn derive_struct_with_optional_properties() { struct Book; @@ -471,6 +536,43 @@ fn derive_struct_unnamed_field_vec_type_success() { } } +#[test] +fn derive_struct_unnamed_field_single_value_default_success() { + let point = api_doc! { + #[schema(default)] + struct Point(f32); + + impl Default for Point { + fn default() -> Self { + Self(3.5) + } + } + }; + + assert_value! {point=> + "type" = r#""number""#, "Point type" + "format" = r#""float""#, "Point format" + "default" = r#"3.5"#, "Point default" + } +} + +#[test] +fn derive_struct_unnamed_field_multiple_value_default_ignored() { + let point = api_doc! { + #[schema(default)] + struct Point(f32, f32); + + impl Default for Point { + fn default() -> Self { + Self(3.5, 6.4) + } + } + }; + // Default values shouldn't be assigned as the struct is represented + // as an array + assert!(!point.to_string().contains("default")) +} + #[test] fn derive_struct_nested_vec_success() { let vecs = api_doc! { @@ -3279,7 +3381,8 @@ fn derive_schema_with_default_struct() { json!({ "properties": { "field": { - "type": "string" + "type": "string", + "default": "" } }, "type": "object"