From 32cf2ff828770f52676c2bf32bdc6aefc29fc731 Mon Sep 17 00:00:00 2001 From: Juha Kukkonen Date: Mon, 12 Jun 2023 23:28:13 +0300 Subject: [PATCH] Wip experiemental actix_web auto-repsonses Experiemental actix_web auto-responses from handler return type. There are noticeable drawbacks with the automatic response resolution. And the biggest one is question "How to override response a type that is resolved automatically? Or even ignore one?" Currently I have no idea on how to proceed with this one. And as the implementation is now, is too limiting, meaning it does not cooperate well with the tuple style responses. Any ideas are welcome. --- scripts/test.sh | 4 +- utoipa-gen/Cargo.toml | 2 + utoipa-gen/src/ext.rs | 141 +++++++++++++- utoipa-gen/src/ext/auto_types.rs | 61 ++++++- utoipa-gen/src/lib.rs | 9 +- utoipa-gen/src/path.rs | 9 +- utoipa-gen/src/path/response.rs | 15 +- .../tests/path_derive_actix_auto_responses.rs | 172 ++++++++++++++++++ utoipa-gen/tests/path_derive_auto_types.rs | 2 +- .../tests/path_derive_auto_types_actix.rs | 9 +- .../tests/path_derive_auto_types_axum.rs | 6 +- utoipa/Cargo.toml | 14 +- utoipa/src/lib.rs | 106 +++++++++++ 13 files changed, 528 insertions(+), 22 deletions(-) create mode 100644 utoipa-gen/tests/path_derive_actix_auto_responses.rs diff --git a/scripts/test.sh b/scripts/test.sh index a08d0aa8..2c66a717 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -13,12 +13,12 @@ elif [[ "$crate" == "utoipa-gen" ]]; then cargo test -p utoipa-gen --test path_derive_auto_types --features auto_types cargo test -p utoipa-gen --test path_derive_actix --test path_parameter_derive_actix --features actix_extras - cargo test -p utoipa-gen --test path_derive_auto_types_actix --features actix_extras,auto_types + cargo test -p utoipa-gen --test path_derive_auto_types_actix --features actix_extras,auto_types,auto_into_responses cargo test -p utoipa-gen --test path_derive_rocket --features rocket_extras cargo test -p utoipa-gen --test path_derive_axum_test --features axum_extras - cargo test -p utoipa-gen --test path_derive_auto_types_axum --features axum_extras,auto_types + cargo test -p utoipa-gen --test path_derive_auto_types_axum --features axum_extras,auto_types,auto_into_responses elif [[ "$crate" == "utoipa-swagger-ui" ]]; then cargo test -p utoipa-swagger-ui --features actix-web,rocket,axum fi diff --git a/utoipa-gen/Cargo.toml b/utoipa-gen/Cargo.toml index e1e0fb61..1067a2f8 100644 --- a/utoipa-gen/Cargo.toml +++ b/utoipa-gen/Cargo.toml @@ -52,3 +52,5 @@ smallvec = [] repr = [] indexmap = [] auto_types = [] +auto_into_responses = [] +actix_auto_responses = [] diff --git a/utoipa-gen/src/ext.rs b/utoipa-gen/src/ext.rs index abf93f31..14fe37aa 100644 --- a/utoipa-gen/src/ext.rs +++ b/utoipa-gen/src/ext.rs @@ -126,18 +126,35 @@ impl ToTokens for RequestBody<'_> { }) }; + let content_type = TypeTreeExt::get_default_content_type(&actual_body); if self.ty.is("Bytes") { let bytes_as_bytes_vec = parse_quote!(Vec); let ty = TypeTree::from_type(&bytes_as_bytes_vec); - create_body_tokens("application/octet-stream", &ty); - } else if self.ty.is("Form") { - create_body_tokens("application/x-www-form-urlencoded", &actual_body); + create_body_tokens(content_type, &ty); } else { - create_body_tokens(actual_body.get_default_content_type(), &actual_body); + create_body_tokens(content_type, &actual_body); }; } } +#[cfg(feature = "actix_auto_responses")] +trait TypeTreeExt<'t> { + fn get_default_content_type(&'t self) -> &'t str; +} + +#[cfg(feature = "actix_auto_responses")] +impl<'t> TypeTreeExt<'t> for TypeTree<'t> { + fn get_default_content_type(&self) -> &str { + if self.is("Bytes") || self.is("ByteString") { + "application/octet-stream" + } else if self.is("Form") { + "application/x-www-form-urlencoded" + } else { + ::get_default_content_type(self) + } + } +} + fn get_actual_body_type<'t>(ty: &'t TypeTree<'t>) -> Option<&'t TypeTree<'t>> { ty.path .as_deref() @@ -171,6 +188,122 @@ fn get_actual_body_type<'t>(ty: &'t TypeTree<'t>) -> Option<&'t TypeTree<'t>> { }) } +fn get_actual_type(ty: TypeTree<'_>) -> Option<(Option>, Option>)> { + ty.path.as_ref().and_then(|path| { + // TODO + path.segments + .iter() + .find_map(|segment| match &*segment.ident.to_string() { + "Json" => Some(( + Some( + ty.children + .as_deref() + .expect("Json must have children") + .first() + .expect("Json must have one child") + .clone(), + ), + None, + )), + "Form" => Some(( + Some( + ty.children + .as_deref() + .expect("Form must have children") + .first() + .expect("Form must have one child") + .clone(), + ), + None, + )), + "Option" => get_actual_type( + ty.children + .as_deref() + .expect("Option must have children") + .first() + .expect("Option must have one child") + .clone(), + ), + "Bytes" | "ByteString" => Some((Some(ty.clone()), None)), + "Result" => { + let children = ty.children.as_deref().expect("Result must have children"); + + Some(( + get_actual_type( + children + .first() + .expect("Result children must have at least one child") + .clone(), + ) + .unwrap() + .0, + children + .get(1) + .and_then(|other_type| get_actual_type(other_type.clone()).unwrap().0), + )) + } + _ => Some((Some(ty.clone()), None)), + }) + }) + + // ty.path + // .as_deref() + // .expect("get_actual_type TypeTree must have syn::Path") + // .segments + // .iter() + // .find_map(|segment| match &*segment.ident.to_string() { + // "Json" => Some(( + // Some( + // ty.children + // .as_deref() + // .expect("Json must have children") + // .first() + // .expect("Json must have one child") + // .clone(), + // ), + // None, + // )), + // "Form" => Some(( + // Some( + // ty.children + // .as_deref() + // .expect("Form must have children") + // .first() + // .expect("Form must have one child") + // .clone(), + // ), + // None, + // )), + // "Option" => get_actual_type( + // ty.children + // .as_deref() + // .expect("Option must have children") + // .first() + // .expect("Option must have one child") + // .clone(), + // ), + // "Bytes" | "ByteString" => Some((Some(ty), None)), + // "Result" => { + // let children = ty.children.as_deref().expect("Result must have children"); + + // Some(( + // get_actual_type( + // children + // .first() + // .expect("Result children must have at least one child") + // .clone(), + // ) + // .unwrap() + // .0, + // children + // .get(1) + // .and_then(|other_type| get_actual_type(other_type.clone()).unwrap().0), + // )) + // } + // _ => Some((Some(ty), None)), + // }) +} + fn find_option_type_tree<'t>(ty: &'t TypeTree) -> Option<&'t TypeTree<'t>> { let eq = ty.generic_type == Some(crate::component::GenericType::Option); diff --git a/utoipa-gen/src/ext/auto_types.rs b/utoipa-gen/src/ext/auto_types.rs index 84f2ec57..58d339f8 100644 --- a/utoipa-gen/src/ext/auto_types.rs +++ b/utoipa-gen/src/ext/auto_types.rs @@ -1,15 +1,68 @@ +use std::borrow::Cow; + use syn::{ItemFn, TypePath}; pub fn parse_fn_operation_responses(fn_op: &ItemFn) -> Option<&TypePath> { - match &fn_op.sig.output { - syn::ReturnType::Type(_, item) => get_type_path(item.as_ref()), - syn::ReturnType::Default => None, // default return type () should result no responses - } + get_response_type(fn_op).and_then(get_type_path) } +#[inline] fn get_type_path(ty: &syn::Type) -> Option<&TypePath> { match ty { syn::Type::Path(ty_path) => Some(ty_path), _ => None, } } + +#[inline] +fn get_response_type(fn_op: &ItemFn) -> Option<&syn::Type> { + match &fn_op.sig.output { + syn::ReturnType::Type(_, item) => Some(item.as_ref()), + syn::ReturnType::Default => None, // default return type () should result no responses + } +} + +#[cfg(all(feature = "actix_extras", feature = "actix_auto_responses"))] +fn to_response( + type_tree: crate::component::TypeTree<'_>, + status: crate::path::response::ResponseStatus, +) -> crate::path::response::Response { + use crate::ext::TypeTreeExt; + use crate::path::response::{Response, ResponseTuple, ResponseValue}; + + dbg!(&type_tree); + let type_path = TypePath { + path: type_tree + .path + .as_deref() + .expect("Response should have a type") + .clone(), + qself: None, + }; + let content_type = type_tree.get_default_content_type(); + let path = syn::Type::Path(type_path); + let response_value = ResponseValue::from((Cow::Owned(path), content_type)); + let response: ResponseTuple = (status, response_value).into(); + + dbg!(&response); + + Response::Tuple(response) +} + +#[cfg(all(feature = "actix_extras", feature = "actix_auto_responses"))] +pub fn parse_actix_web_response(fn_op: &ItemFn) -> Vec> { + get_response_type(fn_op) + .map(crate::component::TypeTree::from_type) + .and_then(super::get_actual_type) + .map(|(first, second)| { + let mut responses = Vec::::with_capacity(2); + if let Some(first) = first { + responses.push(to_response(first, syn::parse_quote!(200))); + }; + if let Some(second) = second { + responses.push(to_response(second, syn::parse_quote!("default"))); + }; + responses + }) + .unwrap_or_else(Vec::new) +} diff --git a/utoipa-gen/src/lib.rs b/utoipa-gen/src/lib.rs index db1415bb..bd7b112f 100644 --- a/utoipa-gen/src/lib.rs +++ b/utoipa-gen/src/lib.rs @@ -1296,19 +1296,24 @@ pub fn path(attr: TokenStream, item: TokenStream) -> TokenStream { feature = "actix_extras", feature = "rocket_extras", feature = "axum_extras", - feature = "auto_types" + feature = "auto_into_responses" ))] let mut path_attribute = path_attribute; let ast_fn = syn::parse::(item).unwrap_or_abort(); let fn_name = &*ast_fn.sig.ident.to_string(); - #[cfg(feature = "auto_types")] + #[cfg(feature = "auto_into_responses")] { if let Some(responses) = ext::auto_types::parse_fn_operation_responses(&ast_fn) { path_attribute.responses_from_into_responses(responses); }; } + #[cfg(feature = "actix_auto_responses")] + { + let responses = ext::auto_types::parse_actix_web_response(&ast_fn); + path_attribute.responses_from_vec(responses); + } let mut resolved_operation = PathOperations::resolve_operation(&ast_fn); diff --git a/utoipa-gen/src/path.rs b/utoipa-gen/src/path.rs index 94ef75a5..09f2ae96 100644 --- a/utoipa-gen/src/path.rs +++ b/utoipa-gen/src/path.rs @@ -105,12 +105,17 @@ impl<'p> PathAttr<'p> { } } - #[cfg(feature = "auto_types")] + #[cfg(feature = "auto_into_responses")] pub fn responses_from_into_responses(&mut self, ty: &'p syn::TypePath) { self.responses .push(Response::IntoResponses(Cow::Borrowed(ty))) } + #[cfg(feature = "actix_auto_responses")] + pub fn responses_from_vec(&mut self, mut responses: Vec>) { + self.responses.append(&mut responses) + } + #[cfg(feature = "auto_types")] pub fn update_request_body(&mut self, request_body: Option>) { self.request_body = request_body.map(RequestBody::Ext); @@ -603,7 +608,7 @@ pub trait PathTypeTree { impl PathTypeTree for TypeTree<'_> { /// Resolve default content type based on current [`Type`]. - fn get_default_content_type(&self) -> &'static str { + fn get_default_content_type(&self) -> &str { if self.is_array() && self .children diff --git a/utoipa-gen/src/path/response.rs b/utoipa-gen/src/path/response.rs index 02a93b04..067fb84e 100644 --- a/utoipa-gen/src/path/response.rs +++ b/utoipa-gen/src/path/response.rs @@ -195,6 +195,19 @@ impl<'r> From>> for Resp } } +impl<'t, 'c> From<(Cow<'t, syn::Type>, &'c str)> for ResponseValue<'t> { + fn from((value, content_type): (Cow<'t, syn::Type>, &'c str)) -> Self { + Self { + response_type: Some(PathType::MediaType(InlineType { + ty: value, + is_inline: false, + })), + content_type: Some(vec![content_type.to_string()]), + ..Default::default() + } + } +} + #[derive(Default)] #[cfg_attr(feature = "debug", derive(Debug))] pub struct ResponseValue<'r> { @@ -561,7 +574,7 @@ impl Parse for DeriveIntoResponsesValue { #[derive(Default)] #[cfg_attr(feature = "debug", derive(Debug))] -struct ResponseStatus(TokenStream2); +pub struct ResponseStatus(TokenStream2); impl Parse for ResponseStatus { fn parse(input: ParseStream) -> syn::Result { diff --git a/utoipa-gen/tests/path_derive_actix_auto_responses.rs b/utoipa-gen/tests/path_derive_actix_auto_responses.rs new file mode 100644 index 00000000..6c043582 --- /dev/null +++ b/utoipa-gen/tests/path_derive_actix_auto_responses.rs @@ -0,0 +1,172 @@ +#![cfg(all( + feature = "auto_types", + feature = "actix_auto_responses", + feature = "actix_extras" +))] + +use std::fmt::Display; + +use actix_web::web::Json; +use actix_web::{get, ResponseError}; +use assert_json_diff::assert_json_eq; +use utoipa::OpenApi; +use utoipa_gen::ToSchema; + +#[test] +fn path_operation_auto_types_responses() { + /// Test item to to return + #[derive(serde::Serialize, serde::Deserialize, ToSchema)] + struct Item<'s> { + value: &'s str, + } + + /// Error + #[derive(Debug, ToSchema)] + struct Error; + + impl Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "Error") + } + } + + impl ResponseError for Error {} + + #[utoipa::path] + #[get("/item")] + async fn get_item() -> Result>, Error> { + Ok(Json(Item { value: "super" })) + } + + #[derive(OpenApi)] + #[openapi(paths(get_item))] + struct ApiDoc; + + let doc = ApiDoc::openapi(); + let value = serde_json::to_value(&doc).unwrap(); + let path = value.pointer("/paths/~1item/get").unwrap(); + + assert_json_eq!( + &path.pointer("/responses").unwrap(), + serde_json::json!({ + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Item" + } + } + }, + "description": "", + }, + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + }, + "description": "" + } + }) + ) +} + +#[test] +fn path_derive_auto_types_override_responses() { + /// Test item to to return + #[derive(serde::Serialize, serde::Deserialize, ToSchema)] + struct Item<'s> { + value: &'s str, + } + + /// Error + #[derive(Debug, ToSchema)] + struct Error; + + impl Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "Error") + } + } + + impl ResponseError for Error {} + + #[utoipa::path( + responses( + (status = 201, body = Item, description = "Item Created"), + (status = NOT_FOUND, body = Error, description = "Not Found"), + (status = 500, body = Error, description = "Server Error"), + ) + )] + #[get("/item")] + async fn get_item() -> Result>, Error> { + Ok(Json(Item { value: "super" })) + } + + #[derive(OpenApi)] + #[openapi(paths(get_item))] + struct ApiDoc; + + let doc = ApiDoc::openapi(); + let value = serde_json::to_value(&doc).unwrap(); + let path = value.pointer("/paths/~1item/get").unwrap(); + + let responses = path.pointer("/responses").unwrap(); + assert_json_eq!( + responses, + serde_json::json!({ + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Item" + } + } + }, + "description": "", + }, + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Item" + } + } + }, + "description": "Item Created", + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + }, + "description": "Not Found" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + }, + "description": "Server Error" + }, + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + }, + "description": "" + } + }) + ) +} diff --git a/utoipa-gen/tests/path_derive_auto_types.rs b/utoipa-gen/tests/path_derive_auto_types.rs index 4e55e218..106f3a6f 100644 --- a/utoipa-gen/tests/path_derive_auto_types.rs +++ b/utoipa-gen/tests/path_derive_auto_types.rs @@ -1,4 +1,4 @@ -#![cfg(feature = "auto_types")] +#![cfg(feature = "auto_into_responses")] use assert_json_diff::assert_json_eq; use utoipa::OpenApi; diff --git a/utoipa-gen/tests/path_derive_auto_types_actix.rs b/utoipa-gen/tests/path_derive_auto_types_actix.rs index 012962a7..bc5a4484 100644 --- a/utoipa-gen/tests/path_derive_auto_types_actix.rs +++ b/utoipa-gen/tests/path_derive_auto_types_actix.rs @@ -1,7 +1,10 @@ -#![cfg(all(feature = "auto_types", feature = "actix_extras"))] +#![cfg(all( + feature = "auto_types", + feature = "auto_into_responses", + feature = "actix_extras" +))] -use actix_web::web::{Form, Json}; -use std::fmt::Display; +use actix_web::web::{Form, Json}; use std::fmt::Display; use utoipa::OpenApi; use actix_web::body::BoxBody; diff --git a/utoipa-gen/tests/path_derive_auto_types_axum.rs b/utoipa-gen/tests/path_derive_auto_types_axum.rs index 1e05fe1e..3f74ca8d 100644 --- a/utoipa-gen/tests/path_derive_auto_types_axum.rs +++ b/utoipa-gen/tests/path_derive_auto_types_axum.rs @@ -1,4 +1,8 @@ -#![cfg(all(feature = "auto_types", feature = "axum_extras"))] +#![cfg(all( + feature = "auto_types", + feature = "auto_into_responses", + feature = "axum_extras" +))] use assert_json_diff::assert_json_eq; use utoipa::OpenApi; diff --git a/utoipa/Cargo.toml b/utoipa/Cargo.toml index 0d673694..e0f68f25 100644 --- a/utoipa/Cargo.toml +++ b/utoipa/Cargo.toml @@ -36,8 +36,11 @@ indexmap = ["utoipa-gen/indexmap"] openapi_extensions = [] repr = ["utoipa-gen/repr"] preserve_order = [] -auto_types = ["utoipa-gen/auto_types"] preserve_path_order = [] +auto_types = ["utoipa-gen/auto_types", "__actix_auto_types"] +auto_into_responses = ["utoipa-gen/auto_into_responses"] +actix_auto_responses = ["utoipa-gen/actix_auto_responses"] +__actix_auto_types = ["dep:actix-web"] [dependencies] serde = { version = "1.0", features = ["derive"] } @@ -45,10 +48,17 @@ serde_json = { version = "1.0" } serde_yaml = { version = "0.9", optional = true } utoipa-gen = { version = "3.3.0", path = "../utoipa-gen" } indexmap = { version = "1", features = ["serde"] } +actix-web = { version = "4", optional = true } [dev-dependencies] assert-json-diff = "2" [package.metadata.docs.rs] -features = ["actix_extras", "non_strict_integers", "openapi_extensions", "uuid", "yaml"] +features = [ + "actix_extras", + "non_strict_integers", + "openapi_extensions", + "uuid", + "yaml", +] rustdoc-args = ["--cfg", "doc_cfg"] diff --git a/utoipa/src/lib.rs b/utoipa/src/lib.rs index 742f7b21..fb2116ec 100644 --- a/utoipa/src/lib.rs +++ b/utoipa/src/lib.rs @@ -261,6 +261,7 @@ use std::collections::{BTreeMap, HashMap}; pub use utoipa_gen::*; + /// Trait for implementing OpenAPI specification in Rust. /// /// This trait is derivable and can be used with `#[derive]` attribute. The derived implementation @@ -893,6 +894,111 @@ impl IntoResponses for () { } } +// #[cfg(all(feature = "actix_extras", feature = "auto_types"))] +// fn default_response_for_partial_schema( +// content_type: &str, +// ) -> BTreeMap> { +// BTreeMap::from_iter(std::iter::once(( +// content_type.to_string(), +// openapi::response::ResponseBuilder::new() +// .content( +// "text/plain", +// openapi::content::ContentBuilder::new() +// .schema(S::schema()) +// .build(), +// ) +// .build() +// .into(), +// ))) +// } + +// #[cfg(all(feature = "actix_extras", feature = "auto_types"))] +// fn default_schema_for_schema( +// content_type: &str, +// schema_provider: impl FnOnce() -> openapi::RefOr, +// ) -> BTreeMap> { +// BTreeMap::from_iter(std::iter::once(( +// content_type.to_string(), +// openapi::response::ResponseBuilder::new() +// .content( +// "text/plain", +// openapi::content::ContentBuilder::new() +// .schema(schema_provider()) +// .build(), +// ) +// .build() +// .into(), +// ))) +// } + +// macro_rules! impl_into_responses_primitive { +// ( $path:path ) => { +// impl IntoResponses for $path { +// fn responses() -> BTreeMap> { +// default_response_for_partial_schema::<$path>("text/plain") +// } +// } +// }; +// ( & $( $life:lifetime )? $path:path ) => { +// impl IntoResponses for & $( $life )* $path { +// fn responses() -> BTreeMap> { +// default_response_for_partial_schema::<& $($life)* $path>("text/plain") +// } +// } +// }; +// } + +// #[cfg(all(feature = "actix_extras", feature = "auto_types"))] +// impl_into_responses_primitive!(String); + +// #[cfg(all(feature = "actix_extras", feature = "auto_types"))] +// impl_into_responses_primitive!(&str); + +// // #[cfg(all(feature = "actix_extras", feature = "auto_types"))] +// // impl_into_responses_primitive!(Vec); +// impl IntoResponses for &'static [u8] { +// fn responses() -> BTreeMap> { +// default_schema_for_schema("application/octet-stream", || { +// schema!( +// #[inline] +// [u8] +// ) +// .into() +// }) +// } +// } + +// impl IntoResponses for Vec { +// fn responses() -> BTreeMap> { +// default_schema_for_schema("application/octet-stream", || { +// schema!(#[inline] Vec).into() +// }) +// } +// } + +// #[cfg(all(feature = "actix_extras", feature = "auto_types"))] +// impl<'__r, R: actix_web::Responder + ToResponse<'__r>> IntoResponses +// for (R, actix_web::http::StatusCode) +// { +// fn responses() -> BTreeMap> { +// let (_, response) = R::response(); +// BTreeMap::from_iter(std::iter::once(("default".to_string(), response))) +// } +// } + +// impl<'__s, T: ToSchema<'__s>> PartialSchema for T { +// fn schema() -> openapi::RefOr { +// ::schema().1 +// } +// } + +// #[cfg(all(feature = "actix_extras", feature = "auto_types"))] +// impl IntoResponses for actix_web::web::Json { +// fn responses() -> BTreeMap> { +// default_schema_for_schema("application/json", || T::schema()) +// } +// } + /// This trait is implemented to document a type which represents a single response which can be /// referenced or reused as a component in multiple operations. ///