Skip to content

Commit

Permalink
Add support for free form additional properties (#495)
Browse files Browse the repository at this point in the history
Add support for free form additional properties which are used to define
map field value types. This commit adds free form support for `ToSchema`
and `IntoParams` with following syntax.
```rust
 #[derive(ToSchema)]
 struct Map {
     #[schema(additional_properties)]
     map: HashMap<String, String>,
 }

 #[derive(IntoParams)]
 struct Map {
     #[param(additional_properties)]
     map: HashMap<String, String>,
 }
```

The above declarations  would render in OpenAPI as seen below.
```yaml
 type: object
 additionalProperties: true
```
  • Loading branch information
juhaku authored Feb 20, 2023
1 parent b869a9a commit 746431d
Show file tree
Hide file tree
Showing 8 changed files with 290 additions and 51 deletions.
43 changes: 42 additions & 1 deletion utoipa-gen/src/component/features.rs
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ pub enum Feature {
Description(Description),
Deprecated(Deprecated),
As(As),
AdditionalProperties(AdditionalProperites),
}

impl Feature {
Expand Down Expand Up @@ -210,6 +211,9 @@ impl ToTokens for Feature {
Feature::SchemaWith(with_schema) => with_schema.to_token_stream(),
Feature::Description(description) => quote! { .description(Some(#description)) },
Feature::Deprecated(deprecated) => quote! { .deprecated(Some(#deprecated)) },
Feature::AdditionalProperties(additional_properties) => {
quote! { .additional_properties(Some(#additional_properties)) }
}
Feature::RenameAll(_) => {
abort! {
Span::call_site(),
Expand Down Expand Up @@ -279,6 +283,7 @@ impl Display for Feature {
Feature::Description(description) => description.fmt(f),
Feature::Deprecated(deprecated) => deprecated.fmt(f),
Feature::As(as_feature) => as_feature.fmt(f),
Feature::AdditionalProperties(additional_properties) => additional_properties.fmt(f),
}
}
}
Expand Down Expand Up @@ -319,6 +324,9 @@ impl Validatable for Feature {
Feature::Description(description) => description.is_validatable(),
Feature::Deprecated(deprecated) => deprecated.is_validatable(),
Feature::As(as_feature) => as_feature.is_validatable(),
Feature::AdditionalProperties(additional_properites) => {
additional_properites.is_validatable()
}
}
}
}
Expand Down Expand Up @@ -368,7 +376,8 @@ is_validatable! {
SchemaWith => false,
Description => false,
Deprecated => false,
As => false
As => false,
AdditionalProperites => false
}

#[derive(Clone)]
Expand Down Expand Up @@ -1364,6 +1373,38 @@ impl From<As> for Feature {

name!(As = "as");

#[cfg_attr(feature = "debug", derive(Debug))]
#[derive(Clone)]
pub struct AdditionalProperites(bool);

impl Parse for AdditionalProperites {
fn parse(input: ParseStream, _: Ident) -> syn::Result<Self>
where
Self: std::marker::Sized,
{
parse_utils::parse_bool_or_true(input).map(Self)
}
}

impl ToTokens for AdditionalProperites {
fn to_tokens(&self, tokens: &mut TokenStream) {
let additional_properties = &self.0;
tokens.extend(quote!(
utoipa::openapi::schema::AdditionalProperties::FreeForm(
#additional_properties
)
))
}
}

name!(AdditionalProperites = "additional_properties");

impl From<AdditionalProperites> for Feature {
fn from(value: AdditionalProperites) -> Self {
Self::AdditionalProperties(value)
}
}

pub trait Validator {
fn is_valid(&self) -> Result<(), &'static str>;
}
Expand Down
49 changes: 31 additions & 18 deletions utoipa-gen/src/component/into_params.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,10 @@ use crate::{
component::{
self,
features::{
self, AllowReserved, Example, ExclusiveMaximum, ExclusiveMinimum, Explode, Format,
Inline, MaxItems, MaxLength, Maximum, MinItems, MinLength, Minimum, MultipleOf, Names,
Nullable, Pattern, ReadOnly, Rename, RenameAll, SchemaWith, Style, WriteOnly, XmlAttr,
self, AdditionalProperites, AllowReserved, Example, ExclusiveMaximum, ExclusiveMinimum,
Explode, Format, Inline, MaxItems, MaxLength, Maximum, MinItems, MinLength, Minimum,
MultipleOf, Names, Nullable, Pattern, ReadOnly, Rename, RenameAll, SchemaWith, Style,
WriteOnly, XmlAttr,
},
FieldRename,
},
Expand Down Expand Up @@ -246,7 +247,8 @@ impl Parse for FieldFeatures {
MinLength,
Pattern,
MaxItems,
MinItems
MinItems,
AdditionalProperites
)))
}
}
Expand Down Expand Up @@ -310,7 +312,8 @@ impl Param<'_> {
| Feature::MinLength(_)
| Feature::Pattern(_)
| Feature::MaxItems(_)
| Feature::MinItems(_) => {
| Feature::MinItems(_)
| Feature::AdditionalProperties(_) => {
schema_features.push(feature);
}
_ => {
Expand Down Expand Up @@ -490,22 +493,32 @@ impl ToTokens for ParamSchema<'_> {
tokens.extend(param_type.into_token_stream())
}
Some(GenericType::Map) => {
// Maps are treated as generic objects with no named properties and
// additionalProperties denoting the type
let mut features = self.schema_features.clone();
let additional_properties =
pop_feature!(features => Feature::AdditionalProperties(_));

let additional_properties = additional_properties
.as_ref()
.map(ToTokens::to_token_stream)
.unwrap_or_else(|| {
// Maps are treated as generic objects with no named properties and
let schema_type = ParamSchema {
component: component
.children
.as_ref()
.expect("Map ParamType should have children")
.iter()
.nth(1)
.expect("Map Param type should have 2 child"),
schema_features: features.as_ref(),
};

let component_property = ParamSchema {
component: component
.children
.as_ref()
.expect("Map ParamType should have children")
.iter()
.nth(1)
.expect("Map Param type should have 2 child"),
schema_features: self.schema_features,
};
quote! { .additional_properties(Some(#schema_type)) }
});

tokens.extend(quote! {
utoipa::openapi::ObjectBuilder::new().additional_properties(Some(#component_property))
utoipa::openapi::ObjectBuilder::new()
#additional_properties
});
}
None => {
Expand Down
48 changes: 29 additions & 19 deletions utoipa-gen/src/component/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1437,31 +1437,41 @@ impl ToTokens for SchemaProperty<'_> {
let empty_features = Vec::new();
let mut features = self.features.unwrap_or(&empty_features).clone();
let example = features.pop_by(|feature| matches!(feature, Feature::Example(_)));
let additional_properties =
pop_feature!(features => Feature::AdditionalProperties(_));

let deprecated = self.get_deprecated();
let description = self.get_description();
// Maps are treated as generic objects with no named properties and
// additionalProperties denoting the type
// maps have 2 child schemas and we are interested the second one of them
// which is used to determine the additional properties
let schema_property = SchemaProperty {
type_tree: self
.type_tree
.children
.as_ref()
.expect("SchemaProperty Map type should have children")
.iter()
.nth(1)
.expect("SchemaProperty Map type should have 2 child"),
comments: None,
features: Some(&features),
deprecated: None,
object_name: self.object_name,
};

let additional_properties = additional_properties
.as_ref()
.map(ToTokens::to_token_stream)
.unwrap_or_else(|| {
// Maps are treated as generic objects with no named properties and
// additionalProperties denoting the type
// maps have 2 child schemas and we are interested the second one of them
// which is used to determine the additional properties
let schema_property = SchemaProperty {
type_tree: self
.type_tree
.children
.as_ref()
.expect("SchemaProperty Map type should have children")
.iter()
.nth(1)
.expect("SchemaProperty Map type should have 2 child"),
comments: None,
features: Some(&features),
deprecated: None,
object_name: self.object_name,
};

quote! { .additional_properties(Some(#schema_property)) }
});

tokens.extend(quote! {
utoipa::openapi::ObjectBuilder::new()
.additional_properties(Some(#schema_property))
#additional_properties
#description
#deprecated
});
Expand Down
12 changes: 7 additions & 5 deletions utoipa-gen/src/component/schema/features.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@ use syn::{
};

use crate::component::features::{
impl_into_inner, impl_merge, parse_features, As, Default, Example, ExclusiveMaximum,
ExclusiveMinimum, Feature, Format, Inline, IntoInner, MaxItems, MaxLength, MaxProperties,
Maximum, Merge, MinItems, MinLength, MinProperties, Minimum, MultipleOf, Nullable, Pattern,
ReadOnly, Rename, RenameAll, SchemaWith, Title, ValueType, WriteOnly, XmlAttr,
impl_into_inner, impl_merge, parse_features, AdditionalProperites, As, Default, Example,
ExclusiveMaximum, ExclusiveMinimum, Feature, Format, Inline, IntoInner, MaxItems, MaxLength,
MaxProperties, Maximum, Merge, MinItems, MinLength, MinProperties, Minimum, MultipleOf,
Nullable, Pattern, ReadOnly, Rename, RenameAll, SchemaWith, Title, ValueType, WriteOnly,
XmlAttr,
};

#[cfg_attr(feature = "debug", derive(Debug))]
Expand Down Expand Up @@ -103,7 +104,8 @@ impl Parse for NamedFieldFeatures {
Pattern,
MaxItems,
MinItems,
SchemaWith
SchemaWith,
AdditionalProperites
)))
}
}
Expand Down
13 changes: 11 additions & 2 deletions utoipa-gen/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,10 @@ use self::path::response::derive::{IntoResponses, ToResponse};
/// * `with_schema = ...` Use _`schema`_ created by provided function reference instead of the
/// default derived _`schema`_. The function must match to `fn() -> Into<RefOr<Schema>>`. It does
/// not accept arguments and must return anything that can be converted into `RefOr<Schema>`.
/// * `additional_properties = ...` Can be used to define free form types for maps such as
/// [`HashMap`](std::collections::HashMap) and [`BTreeMap`](std::collections::BTreeMap).
/// Free form type enables use of arbitrary types within map values.
/// Supports formats _`additional_properties`_ and _`additional_properties = true`_.
///
/// # Xml attribute Configuration Options
///
Expand Down Expand Up @@ -301,7 +305,7 @@ use self::path::response::derive::{IntoResponses, ToResponse};
/// where super type declares common code for type aliases.
///
/// In this example we have common `Status` type which accepts one generic type. It is then defined
/// with `#[aliases(...)]` that it is going to be used with [`std::string::String`] and [`i32`] values.
/// with `#[aliases(...)]` that it is going to be used with [`String`](std::string::String) and [`i32`] values.
/// The generic argument could also be another [`ToSchema`][to_schema] as well.
/// ```rust
/// # use utoipa::{ToSchema, OpenApi};
Expand Down Expand Up @@ -1532,7 +1536,7 @@ pub fn openapi(input: TokenStream) -> TokenStream {
///
/// Typically path parameters need to be defined within [`#[utoipa::path(...params(...))]`][path_params] section
/// for the endpoint. But this trait eliminates the need for that when [`struct`][struct]s are used to define parameters.
/// Still [`std::primitive`] and [`String`] path parameters or [`tuple`] style path parameters need to be defined
/// Still [`std::primitive`] and [`String`](std::string::String) path parameters or [`tuple`] style path parameters need to be defined
/// within `params(...)` section if description or other than default configuration need to be given.
///
/// You can use the Rust's own `#[deprecated]` attribute on field to mark it as
Expand Down Expand Up @@ -1633,6 +1637,11 @@ pub fn openapi(input: TokenStream) -> TokenStream {
/// default derived _`schema`_. The function must match to `fn() -> Into<RefOr<Schema>>`. It does
/// not accept arguments and must return anything that can be converted into `RefOr<Schema>`.
///
/// * `additional_properties = ...` Can be used to define free form types for maps such as
/// [`HashMap`](std::collections::HashMap) and [`BTreeMap`](std::collections::BTreeMap).
/// Free form type enables use of arbitrary types within map values.
/// Supports formats _`additional_properties`_ and _`additional_properties = true`_.
///
/// **Note!** `#[into_params(...)]` is only supported on unnamed struct types to declare names for the arguments.
///
/// Use `names` to define name for single unnamed argument.
Expand Down
26 changes: 26 additions & 0 deletions utoipa-gen/tests/path_derive.rs
Original file line number Diff line number Diff line change
Expand Up @@ -534,6 +534,32 @@ fn derive_path_params_with_examples() {
}
}

#[test]
fn path_parameters_with_free_form_properties() {
let operation = api_fn_doc_with_params! {get: "/foo" =>
struct MyParams {
#[param(additional_properties)]
map: HashMap<String, String>,
}
};
let parameters = operation.get("parameters").unwrap();

assert_json_eq! {
parameters,
json!{[
{
"in": "path",
"name": "map",
"required": true,
"schema": {
"additionalProperties": true,
"type": "object"
}
}
]}
}
}

#[test]
fn derive_path_query_params_with_schema_features() {
let operation = api_fn_doc_with_params! {get: "/foo" =>
Expand Down
59 changes: 55 additions & 4 deletions utoipa-gen/tests/schema_derive_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,17 +63,68 @@ fn derive_map_type() {

#[test]
fn derive_map_ref() {
enum Foo {}
#[derive(ToSchema)]
#[allow(unused)]
enum Foo {
Variant,
}

let map = api_doc! {
struct Map {
map: HashMap<String, Foo>
map: HashMap<String, Foo>,
#[schema(inline)]
map2: HashMap<String, Foo>
}
};

assert_value! { map=>
"properties.map.additionalProperties.$ref" = r##""#/components/schemas/Foo""##, "Additional Property reference"
assert_json_eq!(
map,
json!({
"properties": {
"map": {
"additionalProperties": {
"$ref": "#/components/schemas/Foo"
},
"type": "object",
},
"map2": {
"additionalProperties": {
"type": "string",
"enum": ["Variant"]
},
"type": "object"
}
},
"required": ["map", "map2"],
"type": "object"
})
)
}

#[test]
fn derive_map_free_form_property() {
let map = api_doc! {
struct Map {
#[schema(additional_properties)]
map: HashMap<String, String>,
}
};

dbg!(&map);

assert_json_eq!(
map,
json!({
"properties": {
"map": {
"additionalProperties": true,
"type": "object",
},
},
"required": ["map"],
"type": "object"
})
)
}

#[test]
Expand Down
Loading

0 comments on commit 746431d

Please sign in to comment.