diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index f3e97ef8..272855bc 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -69,7 +69,7 @@ jobs: cargo test --test component_derive_test --features chrono_types_with_format cargo test --test path_derive_rocket --features rocket_extras,json elif [[ "${{ matrix.testset }}" == "utoipa-gen" ]] && [[ ${{ steps.changes.outputs.gen_changed }} == true ]]; then - cargo test -p utoipa-gen --features actix_extras + cargo test -p utoipa-gen --features utoipa/actix_extras elif [[ "${{ matrix.testset }}" == "utoipa-swagger-ui" ]] && [[ ${{ steps.changes.outputs.swagger_changed }} == true ]]; then cargo test -p utoipa-swagger-ui --features actix-web,rocket fi diff --git a/src/lib.rs b/src/lib.rs index e2075f7b..c3efd45d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -196,7 +196,6 @@ pub mod openapi; -use openapi::path::{Parameter, ParameterBuilder}; pub use utoipa_gen::*; /// Trait for implementing OpenAPI specification in Rust. @@ -463,11 +462,82 @@ pub trait Modify { fn modify(&self, openapi: &mut openapi::OpenApi); } +/// Trait used to convert implementing type to OpenAPI parameters for **actix-web** framework. +/// +/// This trait is [deriveable][derive] for structs which are used to describe `path` or `query` parameters. +/// For more details of `#[derive(IntoParams)]` refer to [derive documentation][derive]. +/// +/// # Examples +/// +/// Derive [`IntoParams`] implementation. This example will fail to compile because [`IntoParams`] cannot +/// be used alone and it need to be used together with endpoint using the params as well. See +/// [derive documentation][derive] for more details. +/// ```compile_fail +/// use utoipa::{IntoParams}; +/// +/// #[derive(IntoParams)] +/// struct PetParams { +/// /// Id of pet +/// id: i64, +/// /// Name of pet +/// name: String, +/// } +/// ``` +/// +/// Roughly equal manual implementation of [`IntoParams`] trait. +/// ```rust +/// # struct PetParams { +/// # /// Id of pet +/// # id: i64, +/// # /// Name of pet +/// # name: String, +/// # } +/// impl utoipa::IntoParams for PetParams { +/// fn into_params() -> Vec { +/// vec![ +/// utoipa::openapi::path::ParameterBuilder::new() +/// .name("id") +/// .required(utoipa::openapi::Required::True) +/// .parameter_in(utoipa::openapi::path::ParameterIn::Path) +/// .description(Some("Id of pet")) +/// .schema(Some( +/// utoipa::openapi::PropertyBuilder::new() +/// .component_type(utoipa::openapi::ComponentType::Integer) +/// .format(Some(utoipa::openapi::ComponentFormat::Int64)), +/// )) +/// .build(), +/// utoipa::openapi::path::ParameterBuilder::new() +/// .name("name") +/// .required(utoipa::openapi::Required::True) +/// .parameter_in(utoipa::openapi::path::ParameterIn::Path) +/// .description(Some("Name of pet")) +/// .schema(Some( +/// utoipa::openapi::PropertyBuilder::new() +/// .component_type(utoipa::openapi::ComponentType::String), +/// )) +/// .build(), +/// ] +/// } +/// } +/// ``` +/// [derive]: derive.IntoParams.html #[cfg(feature = "actix_extras")] pub trait IntoParams { - fn default_parameters_in() -> Option { + /// Provide [`Vec`] of [`openapi::path::Parameter`]s to caller. The result is used in `utoipa-gen` library to + /// provide OpenAPI parameter information for the endpoint using the parameters. + fn into_params() -> Vec; +} + +/// Internal trait used to provide [`ParameterIn`] definition for implementer type. +/// +/// This is typically used in tandem with [`IntoParams`] trait and only from `utoipa-gen` library. +/// In manual implementation there is typially never a need to implement this trait since +/// manual implementations can directly define the [`ParameterIn`] definition they see fit to the purpose. +#[cfg(feature = "actix_extras")] +#[doc(hidden)] +pub trait ParameterIn { + /// Provide [`ParameterIn`] declaration for caller. Default implementation returns [`None`]. + fn parameter_in() -> Option { None } - - fn into_params() -> Vec; } diff --git a/tests/path_derive_actix.rs b/tests/path_derive_actix.rs index 9bc5e642..afd144ce 100644 --- a/tests/path_derive_actix.rs +++ b/tests/path_derive_actix.rs @@ -319,8 +319,6 @@ fn path_with_struct_variables_with_into_params() { impl IntoParams for Person { fn into_params() -> Vec { - let parameter_in = Self::default_parameters_in().unwrap_or_default(); - vec![ ParameterBuilder::new() .name("name") @@ -328,7 +326,7 @@ fn path_with_struct_variables_with_into_params() { PropertyBuilder::new() .component_type(utoipa::openapi::ComponentType::String), )) - .parameter_in(parameter_in.clone()) + .parameter_in(ParameterIn::Path) .build(), ParameterBuilder::new() .name("id") @@ -337,7 +335,7 @@ fn path_with_struct_variables_with_into_params() { .component_type(utoipa::openapi::ComponentType::Integer) .format(Some(ComponentFormat::Int64)), )) - .parameter_in(parameter_in) + .parameter_in(ParameterIn::Path) .build(), ] } @@ -350,19 +348,13 @@ fn path_with_struct_variables_with_into_params() { } impl IntoParams for Filter { - fn default_parameters_in() -> Option { - Some(ParameterIn::Query) - } - fn into_params() -> Vec { - let parameter_in = Self::default_parameters_in().unwrap_or_default(); - vec![ParameterBuilder::new() .name("age") .schema(Some(Array::new( PropertyBuilder::new().component_type(utoipa::openapi::ComponentType::String), ))) - .parameter_in(parameter_in) + .parameter_in(ParameterIn::Query) .build()] } } @@ -407,6 +399,74 @@ fn path_with_struct_variables_with_into_params() { } } +#[test] +fn derive_path_with_struct_variables_with_into_params() { + use actix_web::{get, HttpResponse, Responder}; + use serde_json::json; + + #[derive(Deserialize, IntoParams)] + #[allow(unused)] + struct Person { + /// Id of person + id: i64, + /// Name of person + name: String, + } + + #[derive(Deserialize, IntoParams)] + #[allow(unused)] + struct Filter { + /// Age filter for user + #[deprecated] + age: Option>, + } + + #[utoipa::path( + responses( + (status = 200, description = "success response") + ) + )] + #[get("/foo/{id}/{name}")] + #[allow(unused)] + async fn get_foo(person: Path, query: Query) -> impl Responder { + HttpResponse::Ok().json(json!({ "id": "foo" })) + } + + #[derive(OpenApi, Default)] + #[openapi(handlers(get_foo))] + struct ApiDoc; + + let doc = serde_json::to_value(ApiDoc::openapi()).unwrap(); + let parameters = common::get_json_path(&doc, "paths./foo/{id}/{name}.get.parameters"); + + common::assert_json_array_len(parameters, 3); + assert_value! {parameters=> + "[0].in" = r#""path""#, "Parameter in" + "[0].name" = r#""id""#, "Parameter name" + "[0].description" = r#""Id of person""#, "Parameter description" + "[0].required" = r#"true"#, "Parameter required" + "[0].deprecated" = r#"null"#, "Parameter deprecated" + "[0].schema.type" = r#""integer""#, "Parameter schema type" + "[0].schema.format" = r#""int64""#, "Parameter schema format" + + "[1].in" = r#""path""#, "Parameter in" + "[1].name" = r#""name""#, "Parameter name" + "[1].description" = r#""Name of person""#, "Parameter description" + "[1].required" = r#"true"#, "Parameter required" + "[1].deprecated" = r#"null"#, "Parameter deprecated" + "[1].schema.type" = r#""string""#, "Parameter schema type" + "[1].schema.format" = r#"null"#, "Parameter schema format" + + "[2].in" = r#""query""#, "Parameter in" + "[2].name" = r#""age""#, "Parameter name" + "[2].description" = r#""Age filter for user""#, "Parameter description" + "[2].required" = r#"false"#, "Parameter required" + "[2].deprecated" = r#"true"#, "Parameter deprecated" + "[2].schema.type" = r#""array""#, "Parameter schema type" + "[2].schema.items.type" = r#""string""#, "Parameter items schema type" + } +} + macro_rules! test_derive_path_operations { ( $( $name:ident, $mod:ident: $operation:ident)* ) => { $( diff --git a/utoipa-gen/Cargo.toml b/utoipa-gen/Cargo.toml index c4ebb9fa..71c8a6d4 100644 --- a/utoipa-gen/Cargo.toml +++ b/utoipa-gen/Cargo.toml @@ -25,6 +25,7 @@ lazy_static = { version = "1.4", optional = true } [dev-dependencies] utoipa = { path = ".." } serde_json = "1" +serde = "1" actix-web = { version = "4" } [features] diff --git a/utoipa-gen/src/component.rs b/utoipa-gen/src/component.rs index 1c3dc3cb..97218b2f 100644 --- a/utoipa-gen/src/component.rs +++ b/utoipa-gen/src/component.rs @@ -410,7 +410,7 @@ impl ToTokens for ComplexEnum<'_> { } } -fn get_deprecated(attributes: &[Attribute]) -> Option { +pub(crate) fn get_deprecated(attributes: &[Attribute]) -> Option { attributes.iter().find_map(|attribute| { if *attribute.path.get_ident().unwrap() == "deprecated" { Some(Deprecated::True) @@ -423,15 +423,15 @@ fn get_deprecated(attributes: &[Attribute]) -> Option { #[derive(PartialEq)] #[cfg_attr(feature = "debug", derive(Debug))] /// Linked list of implementing types of a field in a struct. -struct ComponentPart<'a> { - ident: &'a Ident, - value_type: ValueType, - generic_type: Option, - child: Option>>, +pub struct ComponentPart<'a> { + pub ident: &'a Ident, + pub value_type: ValueType, + pub generic_type: Option, + pub child: Option>>, } impl<'a> ComponentPart<'a> { - fn from_type(ty: &'a Type) -> ComponentPart<'a> { + pub fn from_type(ty: &'a Type) -> ComponentPart<'a> { ComponentPart::from_type_path( match ty { Type::Path(path) => path, @@ -544,14 +544,14 @@ impl<'a> ComponentPart<'a> { #[cfg_attr(feature = "debug", derive(Debug))] #[derive(Clone, Copy, PartialEq)] -enum ValueType { +pub enum ValueType { Primitive, Object, } #[cfg_attr(feature = "debug", derive(Debug))] #[derive(PartialEq, Clone, Copy)] -enum GenericType { +pub enum GenericType { Vec, Map, Option, diff --git a/utoipa-gen/src/ext/actix.rs b/utoipa-gen/src/ext/actix.rs index ac747571..55f5623d 100644 --- a/utoipa-gen/src/ext/actix.rs +++ b/utoipa-gen/src/ext/actix.rs @@ -55,9 +55,9 @@ impl PathOperations { arguments: I, ) -> impl Iterator> { arguments.into_iter().map(|path_arg| { - let ty = match path_arg { - Arg::Path(arg) => arg, - Arg::Query(arg) => arg, + let (ty, parameter_in) = match path_arg { + Arg::Path(arg) => (arg, quote! { utoipa::openapi::path::ParameterIn::Path }), + Arg::Query(arg) => (arg, quote! { utoipa::openapi::path::ParameterIn::Query }), }; let assert_ty = format_ident!("_Assert{}", &ty); @@ -65,6 +65,12 @@ impl PathOperations { { struct #assert_ty where #ty : utoipa::IntoParams; + impl utoipa::ParameterIn for #ty { + fn parameter_in() -> Option { + Some(#parameter_in) + } + } + <#ty>::into_params() } }) diff --git a/utoipa-gen/src/into_params.rs b/utoipa-gen/src/into_params.rs new file mode 100644 index 00000000..a9ccb25b --- /dev/null +++ b/utoipa-gen/src/into_params.rs @@ -0,0 +1,150 @@ +use proc_macro_error::abort; +use quote::{quote, ToTokens}; +use syn::{Data, Field, Generics, Ident}; + +use crate::{ + component::{self, ComponentPart, GenericType, ValueType}, + component_type::{ComponentFormat, ComponentType}, + doc_comment::CommentAttributes, + Array, Required, +}; + +pub struct IntoParams { + pub generics: Generics, + pub data: Data, + pub ident: Ident, +} + +impl ToTokens for IntoParams { + fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) { + let ident = &self.ident; + let (impl_generics, ty_generics, where_clause) = self.generics.split_for_impl(); + + let params = self + .get_struct_fields() + .map(Param) + .collect::>(); + + tokens.extend(quote! { + impl #impl_generics utoipa::IntoParams for #ident #ty_generics #where_clause { + + fn into_params() -> Vec { + #params.to_vec() + } + + } + }); + } +} + +impl IntoParams { + fn get_struct_fields(&self) -> impl Iterator { + let ident = &self.ident; + let abort = |note: &str| { + abort! { + ident, + "unsupported data type, expected struct with named fields `struct {} {{...}}`", + ident.to_string(); + note = note + } + }; + + match &self.data { + Data::Struct(data_struct) => match &data_struct.fields { + syn::Fields::Named(named_fields) => named_fields.named.iter(), + _ => abort("Only struct with named fields is supported"), + }, + _ => abort("Only struct type is supported"), + } + } +} + +struct Param<'a>(&'a Field); + +impl ToTokens for Param<'_> { + fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) { + let field = self.0; + let ident = &field.ident; + let name = ident + .as_ref() + .map(|ident| ident.to_string()) + .unwrap_or_else(String::new); + let component_part = ComponentPart::from_type(&field.ty); + let required: Required = + (!matches!(&component_part.generic_type, Some(GenericType::Option))).into(); + + tokens.extend(quote! { utoipa::openapi::path::ParameterBuilder::new() + .name(#name) + .parameter_in(::parameter_in().unwrap_or_default()) + .required(#required) + }); + + if let Some(deprecated) = component::get_deprecated(&field.attrs) { + tokens.extend(quote! { .deprecated(Some(#deprecated)) }); + } + + if let Some(comment) = CommentAttributes::from_attributes(&field.attrs).0.first() { + tokens.extend(quote! { + .description(Some(#comment)) + }) + } + + let param_type = ParamType { + ty: &component_part, + }; + + tokens.extend(quote! { .schema(Some(#param_type)).build() }); + } +} + +struct ParamType<'a> { + ty: &'a ComponentPart<'a>, +} + +impl ToTokens for ParamType<'_> { + fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) { + match &self.ty.generic_type { + Some(GenericType::Vec) => { + let param_type = ParamType { + ty: self.ty.child.as_ref().unwrap(), + }; + + tokens.extend(quote! { #param_type.to_array_builder() }); + } + None => match self.ty.value_type { + ValueType::Primitive => { + let component_type = ComponentType(self.ty.ident); + + tokens.extend(quote! { + utoipa::openapi::PropertyBuilder::new().component_type(#component_type) + }); + + let format = ComponentFormat(self.ty.ident); + if format.is_known_format() { + tokens.extend(quote! { + .format(Some(#format)) + }) + } + } + ValueType::Object => abort!( + self.ty.ident, + "unsupported type, only primitive and String types are supported" + ), + }, + Some(GenericType::Option) + | Some(GenericType::Cow) + | Some(GenericType::Box) + | Some(GenericType::RefCell) => { + let param_type = ParamType { + ty: self.ty.child.as_ref().unwrap(), + }; + + tokens.extend(param_type.into_token_stream()) + } + Some(GenericType::Map) => abort!( + self.ty.ident, + "maps are not supported parameter receiver types" + ), + }; + } +} diff --git a/utoipa-gen/src/lib.rs b/utoipa-gen/src/lib.rs index 85d1e272..0e8ca200 100644 --- a/utoipa-gen/src/lib.rs +++ b/utoipa-gen/src/lib.rs @@ -13,6 +13,8 @@ use component::Component; use doc_comment::CommentAttributes; use ext::{PathOperationResolver, PathOperations, PathResolver}; +#[cfg(feature = "actix_extras")] +use into_params::IntoParams; use openapi::OpenApi; use proc_macro::TokenStream; use proc_macro_error::{proc_macro_error, OptionExt, ResultExt}; @@ -31,6 +33,8 @@ mod component; mod component_type; mod doc_comment; mod ext; +#[cfg(feature = "actix_extras")] +mod into_params; mod openapi; mod path; mod security_requirement; @@ -202,7 +206,7 @@ use ext::ArgumentResolver; /// } /// ``` /// -/// Enforce type being used in OpenAPI spec to String with `value_type` and set format to octect stream +/// Enforce type being used in OpenAPI spec to [`String`] with `value_type` and set format to octect stream /// with [`ComponentFormat::Binary`][binary]. /// ```rust /// # use utoipa::Component; @@ -214,7 +218,7 @@ use ext::ArgumentResolver; /// } /// ``` /// -/// Enforce type being used in OpenAPI spec to String with `value_type` option. +/// Enforce type being used in OpenAPI spec to [`String`] with `value_type` option. /// ```rust /// # use utoipa::Component; /// #[derive(Component)] @@ -703,6 +707,85 @@ pub fn openapi(input: TokenStream) -> TokenStream { openapi.to_token_stream().into() } +#[cfg(feature = "actix_extras")] +#[proc_macro_error] +#[proc_macro_derive(IntoParams)] +/// IntoParams derive macro for **actix-web** only. +/// +/// This is `#[derive]` implementation for [`IntoParams`][into_params] trait. +/// +/// 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 +/// 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 +/// deprecated and it will reflect to the generated OpenAPI spec. +/// +/// `#[deprecated]` attribute supports adding addtional details such as a reason and or since version +/// but this is is not supported in OpenAPI. OpenAPI has only a boolean flag to determine deprecation. +/// While it is totally okay to declare deprecated with reason +/// `#[deprecated = "There is better way to do this"]` the reason would not render in OpenAPI spec. +/// +/// # Examples +/// +/// Demonstrate [`IntoParams`][into_params] usage with resolving `path` and `query` parameters +/// for `get_pet` endpoint. [^actix] +/// ```rust +/// use actix_web::{get, HttpResponse, Responder}; +/// use actix_web::web::{Path, Query}; +/// use serde::Deserialize; +/// use serde_json::json; +/// use utoipa::IntoParams; +/// +/// #[derive(Deserialize, IntoParams)] +/// struct PetPathArgs { +/// /// Id of pet +/// id: i64, +/// /// Name of pet +/// name: String, +/// } +/// +/// #[derive(Deserialize, IntoParams)] +/// struct Filter { +/// /// Age filter for pets +/// #[deprecated] +/// age: Option>, +/// } +/// +/// #[utoipa::path( +/// responses( +/// (status = 200, description = "success response") +/// ) +/// )] +/// #[get("/pet/{id}/{name}")] +/// async fn get_pet(person: Path, query: Query) -> impl Responder { +/// HttpResponse::Ok().json(json!({ "id": "id" })) +/// } +/// ``` +/// +/// [into_params]: trait.IntoParams.html +/// [path_params]: attr.path.html#params-attributes +/// [struct]: https://doc.rust-lang.org/std/keyword.struct.html +/// +/// [^actix]: Feature **actix_extras** need to be enabled +pub fn into_params(input: TokenStream) -> TokenStream { + let DeriveInput { + ident, + generics, + data, + .. + } = syn::parse_macro_input!(input); + + let into_params = IntoParams { + generics, + data, + ident, + }; + + into_params.to_token_stream().into() +} + /// Tokenizes slice or Vec of tokenizable items as array either with reference (`&[...]`) /// or without correctly to OpenAPI JSON. #[cfg_attr(feature = "debug", derive(Debug))] @@ -771,6 +854,7 @@ impl ToTokens for Deprecated { } } +#[cfg_attr(feature = "debug", derive(Debug))] enum Required { True, False,