From 3180d18f38723a91f70cea8ad439dc98855a3777 Mon Sep 17 00:00:00 2001 From: Jacob Halsey Date: Mon, 10 Oct 2022 10:05:46 +0100 Subject: [PATCH] Allow custom SchemaFormats (#315) As per the OpenAPI spec, allows custom values to be used as a format. Update docs and add tests. --- utoipa-gen/src/lib.rs | 16 ++-- utoipa-gen/src/schema_type.rs | 118 +++++++++++++++---------- utoipa-gen/tests/path_derive_actix.rs | 4 +- utoipa-gen/tests/schema_derive_test.rs | 15 ++++ utoipa/src/lib.rs | 8 +- utoipa/src/openapi.rs | 3 +- utoipa/src/openapi/schema.rs | 20 +++-- 7 files changed, 120 insertions(+), 64 deletions(-) diff --git a/utoipa-gen/src/lib.rs b/utoipa-gen/src/lib.rs index 9200a127..65eb0028 100644 --- a/utoipa-gen/src/lib.rs +++ b/utoipa-gen/src/lib.rs @@ -76,8 +76,9 @@ use ext::ArgumentResolver; /// # Unnamed Field Struct Optional Configuration Options for `#[schema(...)]` /// * `example = ...` Can be literal value, method reference or _`json!(...)`_. [^json2] /// * `default = ...` Can be literal value, method reference or _`json!(...)`_. [^json2] -/// * `format = ...` Any variant of a [`SchemaFormat`][format] to use for the property. By default the format is derived from -/// the type of the property according OpenApi spec. +/// * `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. /// * `value_type = ...` Can be used to override default type derived from type of the field used in OpenAPI spec. /// This is useful in cases where the default type does not correspond to the actual type e.g. when /// any third-party types are used which are not [`ToSchema`][to_schema]s nor [`primitive` types][primitive]. @@ -86,8 +87,9 @@ use ext::ArgumentResolver; /// # Named Fields Optional Configuration Options for `#[schema(...)]` /// * `example = ...` Can be literal value, method reference or _`json!(...)`_. [^json2] /// * `default = ...` Can be literal value, method reference or _`json!(...)`_. [^json2] -/// * `format = ...` Any variant of a [`SchemaFormat`][format] to use for the property. By default the format is derived from -/// the type of the property according OpenApi spec. +/// * `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. /// * `write_only` Defines property is only used in **write** operations *POST,PUT,PATCH* but not in *GET* /// * `read_only` Defines property is only used in **read** operations *GET* but not in *POST,PUT,PATCH* /// * `xml(...)` Can be used to define [`Xml`][xml] object properties applicable to named fields. @@ -368,7 +370,7 @@ use ext::ArgumentResolver; /// ``` /// /// Enforce type being used in OpenAPI spec to [`String`] with `value_type` and set format to octet stream -/// with [`SchemaFormat::Binary`][binary]. +/// with [`SchemaFormat::KnownFormat(KnownFormat::Binary)`][binary]. /// ```rust /// # use utoipa::ToSchema; /// #[derive(ToSchema)] @@ -420,8 +422,8 @@ use ext::ArgumentResolver; /// More examples for _`value_type`_ in [`IntoParams` derive docs][into_params]. /// /// [to_schema]: trait.ToSchema.html -/// [format]: openapi/schema/enum.SchemaFormat.html -/// [binary]: openapi/schema/enum.SchemaFormat.html#variant.Binary +/// [known_format]: openapi/schema/enum.KnownFormat.html +/// [binary]: openapi/schema/enum.KnownFormat.html#variant.Binary /// [xml]: openapi/xml/struct.Xml.html /// [into_params]: derive.IntoParams.html /// [primitive]: https://doc.rust-lang.org/std/primitive/index.html diff --git a/utoipa-gen/src/schema_type.rs b/utoipa-gen/src/schema_type.rs index edb5d429..a19e1a7c 100644 --- a/utoipa-gen/src/schema_type.rs +++ b/utoipa-gen/src/schema_type.rs @@ -1,7 +1,7 @@ use proc_macro2::TokenStream; use proc_macro_error::abort_call_site; use quote::{quote, ToTokens}; -use syn::{parse::Parse, Error, Ident, Path}; +use syn::{parse::Parse, Error, Ident, LitStr, Path}; /// Tokenizes OpenAPI data type correctly according to the Rust type pub struct SchemaType<'a>(pub &'a syn::Path); @@ -239,19 +239,19 @@ impl ToTokens for Type<'_> { match name { "i8" | "i16" | "i32" | "u8" | "u16" | "u32" => { - tokens.extend(quote! { utoipa::openapi::SchemaFormat::Int32 }) + tokens.extend(quote! { utoipa::openapi::SchemaFormat::KnownFormat(utoipa::openapi::KnownFormat::Int32) }) } - "i64" | "u64" => tokens.extend(quote! { utoipa::openapi::SchemaFormat::Int64 }), - "f32" | "f64" => tokens.extend(quote! { utoipa::openapi::SchemaFormat::Float }), + "i64" | "u64" => tokens.extend(quote! { utoipa::openapi::SchemaFormat::KnownFormat(utoipa::openapi::KnownFormat::Int64) }), + "f32" | "f64" => tokens.extend(quote! { utoipa::openapi::SchemaFormat::KnownFormat(utoipa::openapi::KnownFormat::Float) }), #[cfg(feature = "chrono")] - "DateTime" => tokens.extend(quote! { utoipa::openapi::SchemaFormat::DateTime }), + "DateTime" => tokens.extend(quote! { utoipa::openapi::SchemaFormat::KnownFormat(utoipa::openapi::KnownFormat::DateTime) }), #[cfg(any(feature = "chrono", feature = "Time"))] - "Date" => tokens.extend(quote! { utoipa::openapi::SchemaFormat::Date }), + "Date" => tokens.extend(quote! { utoipa::openapi::SchemaFormat::KnownFormat(utoipa::openapi::KnownFormat::Date) }), #[cfg(feature = "uuid")] - "Uuid" => tokens.extend(quote! { utoipa::openapi::SchemaFormat::Uuid }), + "Uuid" => tokens.extend(quote! { utoipa::openapi::SchemaFormat::KnownFormat(utoipa::openapi::KnownFormat::Uuid) }), #[cfg(feature = "time")] "PrimitiveDateTime" | "OffsetDateTime" => { - tokens.extend(quote! { utoipa::openapi::SchemaFormat::DateTime }) + tokens.extend(quote! { utoipa::openapi::SchemaFormat::KnownFormat(utoipa::openapi::KnownFormat::DateTime) }) } _ => (), } @@ -272,6 +272,7 @@ pub enum Variant { Password, #[cfg(feature = "uuid")] Uuid, + Custom(String), } impl Parse for Variant { @@ -280,7 +281,7 @@ impl Parse for Variant { "Int32", "Int64", "Float", "Double", "Byte", "Binary", "Date", "DateTime", "Password", "Uuid", ]; - let allowed_formats = FORMATS + let known_formats = FORMATS .into_iter() .filter(|_format| { #[cfg(feature = "uuid")] @@ -293,29 +294,37 @@ impl Parse for Variant { } }) .collect::>(); - let expected_formats = format!( - "unexpected format, expected one of: {}", - allowed_formats.join(", ") - ); - let format = input.parse::()?; - let name = &*format.to_string(); - match name { - "Int32" => Ok(Self::Int32), - "Int64" => Ok(Self::Int64), - "Float" => Ok(Self::Float), - "Double" => Ok(Self::Double), - "Byte" => Ok(Self::Byte), - "Binary" => Ok(Self::Binary), - "Date" => Ok(Self::Date), - "DateTime" => Ok(Self::DateTime), - "Password" => Ok(Self::Password), - #[cfg(feature = "uuid")] - "Uuid" => Ok(Self::Uuid), - _ => Err(Error::new( - format.span(), - format!("unexpected format: {name}, expected one of: {expected_formats}"), - )), + let lookahead = input.lookahead1(); + if lookahead.peek(Ident) { + let format = input.parse::()?; + let name = &*format.to_string(); + + match name { + "Int32" => Ok(Self::Int32), + "Int64" => Ok(Self::Int64), + "Float" => Ok(Self::Float), + "Double" => Ok(Self::Double), + "Byte" => Ok(Self::Byte), + "Binary" => Ok(Self::Binary), + "Date" => Ok(Self::Date), + "DateTime" => Ok(Self::DateTime), + "Password" => Ok(Self::Password), + #[cfg(feature = "uuid")] + "Uuid" => Ok(Self::Uuid), + _ => Err(Error::new( + format.span(), + format!( + "unexpected format: {name}, expected one of: {}", + known_formats.join(", ") + ), + )), + } + } else if lookahead.peek(LitStr) { + let value = input.parse::()?.value(); + Ok(Self::Custom(value)) + } else { + Err(lookahead.error()) } } } @@ -323,21 +332,40 @@ impl Parse for Variant { impl ToTokens for Variant { fn to_tokens(&self, tokens: &mut TokenStream) { match self { - Self::Int32 => tokens.extend(quote!(utoipa::openapi::schema::SchemaFormat::Int32)), - Self::Int64 => tokens.extend(quote!(utoipa::openapi::schema::SchemaFormat::Int64)), - Self::Float => tokens.extend(quote!(utoipa::openapi::schema::SchemaFormat::Float)), - Self::Double => tokens.extend(quote!(utoipa::openapi::schema::SchemaFormat::Double)), - Self::Byte => tokens.extend(quote!(utoipa::openapi::schema::SchemaFormat::Byte)), - Self::Binary => tokens.extend(quote!(utoipa::openapi::schema::SchemaFormat::Binary)), - Self::Date => tokens.extend(quote!(utoipa::openapi::schema::SchemaFormat::Date)), - Self::DateTime => { - tokens.extend(quote!(utoipa::openapi::schema::SchemaFormat::DateTime)) - } - Self::Password => { - tokens.extend(quote!(utoipa::openapi::schema::SchemaFormat::Password)) - } + Self::Int32 => tokens.extend(quote!(utoipa::openapi::SchemaFormat::KnownFormat( + utoipa::openapi::KnownFormat::Int32 + ))), + Self::Int64 => tokens.extend(quote!(utoipa::openapi::SchemaFormat::KnownFormat( + utoipa::openapi::KnownFormat::Int64 + ))), + Self::Float => tokens.extend(quote!(utoipa::openapi::SchemaFormat::KnownFormat( + utoipa::openapi::KnownFormat::Float + ))), + Self::Double => tokens.extend(quote!(utoipa::openapi::SchemaFormat::KnownFormat( + utoipa::openapi::KnownFormat::Double + ))), + Self::Byte => tokens.extend(quote!(utoipa::openapi::SchemaFormat::KnownFormat( + utoipa::openapi::KnownFormat::Byte + ))), + Self::Binary => tokens.extend(quote!(utoipa::openapi::SchemaFormat::KnownFormat( + utoipa::openapi::KnownFormat::Binary + ))), + Self::Date => tokens.extend(quote!(utoipa::openapi::SchemaFormat::KnownFormat( + utoipa::openapi::KnownFormat::Date + ))), + Self::DateTime => tokens.extend(quote!(utoipa::openapi::SchemaFormat::KnownFormat( + utoipa::openapi::KnownFormat::DateTime + ))), + Self::Password => tokens.extend(quote!(utoipa::openapi::SchemaFormat::KnownFormat( + utoipa::openapi::KnownFormat::Password + ))), #[cfg(feature = "uuid")] - Self::Uuid => tokens.extend(quote!(utoipa::openapi::schema::SchemaFormat::Uuid)), + Self::Uuid => tokens.extend(quote!(utoipa::openapi::SchemaFormat::KnownFormat( + utoipa::openapi::KnownFormat::Uuid + ))), + Self::Custom(value) => tokens.extend(quote!(utoipa::openapi::SchemaFormat::Custom( + String::from(#value) + ))), }; } } diff --git a/utoipa-gen/tests/path_derive_actix.rs b/utoipa-gen/tests/path_derive_actix.rs index 2c7895a0..ea2fa658 100644 --- a/utoipa-gen/tests/path_derive_actix.rs +++ b/utoipa-gen/tests/path_derive_actix.rs @@ -6,7 +6,7 @@ use serde_json::Value; use utoipa::{ openapi::{ path::{Parameter, ParameterBuilder, ParameterIn}, - Array, ObjectBuilder, SchemaFormat, + Array, KnownFormat, ObjectBuilder, SchemaFormat, }, IntoParams, OpenApi, ToSchema, }; @@ -360,7 +360,7 @@ fn path_with_struct_variables_with_into_params() { .schema(Some( ObjectBuilder::new() .schema_type(utoipa::openapi::SchemaType::Integer) - .format(Some(SchemaFormat::Int64)), + .format(Some(SchemaFormat::KnownFormat(KnownFormat::Int64))), )) .parameter_in(ParameterIn::Path) .build(), diff --git a/utoipa-gen/tests/schema_derive_test.rs b/utoipa-gen/tests/schema_derive_test.rs index ba6d9a28..57ae22b7 100644 --- a/utoipa-gen/tests/schema_derive_test.rs +++ b/utoipa-gen/tests/schema_derive_test.rs @@ -1547,6 +1547,21 @@ fn derive_struct_component_field_type_override_with_format() { } } +#[test] +fn derive_struct_component_field_type_override_with_custom_format() { + let post = api_doc! { + struct Post { + #[schema(value_type = String, format = "uri")] + value: String, + } + }; + + assert_value! {post=> + "properties.value.type" = r#""string""#, "Post value type" + "properties.value.format" = r#""uri""#, "Post value format" + } +} + #[test] fn derive_struct_component_field_type_override_with_format_with_vec() { let post = api_doc! { diff --git a/utoipa/src/lib.rs b/utoipa/src/lib.rs index 2152a667..fd1875aa 100644 --- a/utoipa/src/lib.rs +++ b/utoipa/src/lib.rs @@ -306,7 +306,7 @@ pub trait OpenApi { /// "id", /// utoipa::openapi::ObjectBuilder::new() /// .schema_type(utoipa::openapi::SchemaType::Integer) -/// .format(Some(utoipa::openapi::SchemaFormat::Int64)), +/// .format(Some(utoipa::openapi::SchemaFormat::KnownFormat(utoipa::openapi::KnownFormat::Int64))), /// ) /// .required("id") /// .property( @@ -318,7 +318,7 @@ pub trait OpenApi { /// "age", /// utoipa::openapi::ObjectBuilder::new() /// .schema_type(utoipa::openapi::SchemaType::Integer) -/// .format(Some(utoipa::openapi::SchemaFormat::Int32)), +/// .format(Some(utoipa::openapi::SchemaFormat::KnownFormat(utoipa::openapi::KnownFormat::Int32))), /// ) /// .example(Some(serde_json::json!({ /// "name": "bob the cat", "id": 1 @@ -407,7 +407,7 @@ pub trait ToSchema { /// .schema( /// Some(utoipa::openapi::ObjectBuilder::new() /// .schema_type(utoipa::openapi::SchemaType::Integer) -/// .format(Some(utoipa::openapi::SchemaFormat::Int64))), +/// .format(Some(utoipa::openapi::SchemaFormat::KnownFormat(utoipa::openapi::KnownFormat::Int64)))), /// ), /// ) /// .tag("pet_api"), @@ -532,7 +532,7 @@ pub trait Modify { /// .schema(Some( /// utoipa::openapi::ObjectBuilder::new() /// .schema_type(utoipa::openapi::SchemaType::Integer) -/// .format(Some(utoipa::openapi::SchemaFormat::Int64)), +/// .format(Some(utoipa::openapi::SchemaFormat::KnownFormat(utoipa::openapi::KnownFormat::Int64))), /// )) /// .build(), /// utoipa::openapi::path::ParameterBuilder::new() diff --git a/utoipa/src/openapi.rs b/utoipa/src/openapi.rs index bc1fe559..291d25d1 100644 --- a/utoipa/src/openapi.rs +++ b/utoipa/src/openapi.rs @@ -11,7 +11,8 @@ pub use self::{ response::{Response, ResponseBuilder, Responses, ResponsesBuilder}, schema::{ AllOf, AllOfBuilder, Array, ArrayBuilder, Components, ComponentsBuilder, Discriminator, - Object, ObjectBuilder, OneOf, OneOfBuilder, Ref, Schema, SchemaFormat, SchemaType, ToArray, + KnownFormat, Object, ObjectBuilder, OneOf, OneOfBuilder, Ref, Schema, SchemaFormat, + SchemaType, ToArray, }, security::SecurityRequirement, server::{Server, ServerBuilder, ServerVariable, ServerVariableBuilder}, diff --git a/utoipa/src/openapi/schema.rs b/utoipa/src/openapi/schema.rs index de3d05e0..fc74f5d6 100644 --- a/utoipa/src/openapi/schema.rs +++ b/utoipa/src/openapi/schema.rs @@ -1117,10 +1117,20 @@ impl Default for SchemaType { /// Additional format for [`SchemaType`] to fine tune the data type used. If the **format** is not /// supported by the UI it may default back to [`SchemaType`] alone. +/// Format is an open value, so you can use any formats, even not those defined by the +/// OpenAPI Specification. #[derive(Serialize, Deserialize, Clone)] #[cfg_attr(feature = "debug", derive(Debug))] -#[serde(rename_all = "lowercase")] +#[serde(rename_all = "lowercase", untagged)] pub enum SchemaFormat { + KnownFormat(KnownFormat), + Custom(String), +} + +#[derive(Serialize, Deserialize, Clone)] +#[cfg_attr(feature = "debug", derive(Debug))] +#[serde(rename_all = "lowercase")] +pub enum KnownFormat { /// 32 bit integer. Int32, /// 64 bit integer. @@ -1138,7 +1148,7 @@ pub enum SchemaFormat { /// ISO-8601 full date time [FRC3339](https://xml2rfc.ietf.org/public/rfc/html/rfc3339.html#anchor14). #[serde(rename = "date-time")] DateTime, - /// Hint to UI to obsucre input. + /// Hint to UI to obscure input. Password, /// Used with [`String`] values to indicate value is in UUID format. /// @@ -1172,7 +1182,7 @@ mod tests { "id", ObjectBuilder::new() .schema_type(SchemaType::Integer) - .format(Some(SchemaFormat::Int32)) + .format(Some(SchemaFormat::KnownFormat(KnownFormat::Int32))) .description(Some("Id of credential")) .default(Some(json!(1i32))), ) @@ -1340,7 +1350,7 @@ mod tests { "id", ObjectBuilder::new() .schema_type(SchemaType::Integer) - .format(Some(SchemaFormat::Int32)) + .format(Some(SchemaFormat::KnownFormat(KnownFormat::Int32))) .description(Some("Id of credential")) .default(Some(json!(1i32))), ), @@ -1357,7 +1367,7 @@ mod tests { "id", ObjectBuilder::new() .schema_type(SchemaType::Integer) - .format(Some(SchemaFormat::Int32)) + .format(Some(SchemaFormat::KnownFormat(KnownFormat::Int32))) .description(Some("Id of credential")) .default(Some(json!(1i32))), ),