Skip to content

Commit

Permalink
Add support for auto-populating field default values (#533)
Browse files Browse the repository at this point in the history
* Add support for auto-populating field default values

Add support for `#[schema(default)]` and `#[serde(default)]` attributes
on named field structs and unnamed field structs to use 
`<struct_name>::default().<field_name>` as the default value
for all of its fields.

In the example below the `default` value generated to the `value` field in 
OpenAPI schema would become 10.0.
```rust
 #[derive(ToSchema)]
 #[schema(default)]
 struct Value {
  value: f64
}

impl Default for Value {
  fn default() -> Self {
    Self {
      value: 10.0
    }
  }
}
```
  • Loading branch information
cwatson-blackrock authored Mar 22, 2023
1 parent 39ef61f commit 9d483a3
Show file tree
Hide file tree
Showing 5 changed files with 188 additions and 9 deletions.
21 changes: 17 additions & 4 deletions utoipa-gen/src/component/features.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)) },
Expand Down Expand Up @@ -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<AnyValue>);

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<Self> {
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}),
}
}
}

Expand Down
43 changes: 42 additions & 1 deletion utoipa-gen/src/component/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -269,6 +269,7 @@ impl NamedStructSchema<'_> {
fn field_as_schema_property<R>(
&self,
field: &Field,
container_rules: &Option<SerdeContainer>,
yield_: impl FnOnce(NamedStructFieldOptions<'_>) -> R,
) -> R {
let type_tree = &mut TypeTree::from_type(&field.ty);
Expand All @@ -278,6 +279,30 @@ impl NamedStructSchema<'_> {
.parse_features::<NamedFieldFeatures>()
.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())),
Expand Down Expand Up @@ -362,6 +387,7 @@ impl ToTokens for NamedStructSchema<'_> {

self.field_as_schema_property(
field,
&container_rules,
|NamedStructFieldOptions {
property,
rename_field_value,
Expand Down Expand Up @@ -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) });
},
Expand Down Expand Up @@ -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),
Expand Down
3 changes: 2 additions & 1 deletion utoipa-gen/src/component/schema/features.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ impl Parse for NamedFieldStructFeatures {
RenameAll,
MaxProperties,
MinProperties,
As
As,
Default
)))
}
}
Expand Down
25 changes: 23 additions & 2 deletions utoipa-gen/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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!(...)`_.
Expand All @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -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()},
),
}
}
}
Expand Down
105 changes: 104 additions & 1 deletion utoipa-gen/tests/schema_derive_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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! {
Expand Down Expand Up @@ -3279,7 +3381,8 @@ fn derive_schema_with_default_struct() {
json!({
"properties": {
"field": {
"type": "string"
"type": "string",
"default": ""
}
},
"type": "object"
Expand Down

0 comments on commit 9d483a3

Please sign in to comment.