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. ///