diff --git a/utoipa-config/src/lib.rs b/utoipa-config/src/lib.rs index f282d91f..ce7a138f 100644 --- a/utoipa-config/src/lib.rs +++ b/utoipa-config/src/lib.rs @@ -69,6 +69,9 @@ pub struct Config<'c> { pub aliases: HashMap, Cow<'c, str>>, /// Schema collect mode for `utoipa`. By default only non inlined schemas are collected. pub schema_collect: SchemaCollect, + /// Automatically include parameters from extractor types that implement `IntoParams`. + /// This acts as a global default; individual paths can still override via `auto_params`. + pub auto_into_params: bool, } /// Configures schema collect mode. By default only non explicitly inlined schemas are collected. @@ -190,6 +193,16 @@ impl<'c> Config<'c> { self } + /// Define default behavior for automatically including `IntoParams` implementations. + /// + /// When enabled, `utoipa::path` will include parameters from extractors that implement + /// `IntoParams` unless the path explicitly disables it with `auto_params = false`. + pub fn auto_into_params(mut self, enabled: bool) -> Self { + self.auto_into_params = enabled; + + self + } + fn get_out_dir() -> Option { match std::env::var("OUT_DIR") { Ok(out_dir) => Some(out_dir), diff --git a/utoipa-gen/Cargo.toml b/utoipa-gen/Cargo.toml index 96328ac8..3a9ac1a6 100644 --- a/utoipa-gen/Cargo.toml +++ b/utoipa-gen/Cargo.toml @@ -78,7 +78,7 @@ rc_schema = [] config = ["dep:utoipa-config", "dep:once_cell"] # EXPERIEMENTAL! use with cauntion -auto_into_responses = [] +auto_into_responses = ["utoipa/auto_into_responses"] [lints.rust] unexpected_cfgs = { level = "warn", check-cfg = ['cfg(doc_cfg)'] } diff --git a/utoipa-gen/src/path.rs b/utoipa-gen/src/path.rs index 2b72a525..55f79273 100644 --- a/utoipa-gen/src/path.rs +++ b/utoipa-gen/src/path.rs @@ -47,6 +47,7 @@ pub struct PathAttr<'p> { operation_id: Option, tag: Option, tags: Vec, + auto_params: Option, params: Vec>, security: Option>, context_path: Option, @@ -98,11 +99,21 @@ impl<'p> PathAttr<'p> { } } - self.params.extend( - new_params - .into_iter() - .filter(|param| !matches!(param, Parameter::IntoParamsIdent(_))), - ); + let mut default_auto_params = false; + #[cfg(feature = "config")] + { + default_auto_params = crate::CONFIG.auto_into_params; + } + + if self.auto_params.unwrap_or(default_auto_params) { + self.params.extend(new_params); + } else { + self.params.extend( + new_params + .into_iter() + .filter(|param| !matches!(param, Parameter::IntoParamsIdent(_))), + ); + } } } @@ -144,6 +155,9 @@ impl Parse for PathAttr<'_> { Punctuated::::parse_terminated(&responses) .map(|punctuated| punctuated.into_iter().collect::>())?; } + "auto_params" => { + path_attr.auto_params = Some(parse_utils::parse_bool_or_true(input)?); + } "params" => { let params; parenthesized!(params in input); @@ -514,6 +528,18 @@ impl<'p> ToTokensDiagnostics for Path<'p> { .flatten() .fold(TokenStream2::new(), to_schema_references); + let into_response_schemas = self + .path_attr + .responses + .iter() + .filter_map(|response| match response { + Response::IntoResponses(path) => Some(quote_spanned! {path.span()=> + <#path as utoipa::IntoResponses>::schemas(schemas); + }), + _ => None, + }) + .collect::(); + let schemas = self .path_attr .request_body @@ -611,6 +637,7 @@ impl<'p> ToTokensDiagnostics for Path<'p> { fn schemas(schemas: &mut Vec<(String, utoipa::openapi::RefOr)>) { #schemas #response_schemas + #into_response_schemas } } diff --git a/utoipa-gen/src/path/response.rs b/utoipa-gen/src/path/response.rs index 06f9a089..6f142c17 100644 --- a/utoipa-gen/src/path/response.rs +++ b/utoipa-gen/src/path/response.rs @@ -155,6 +155,32 @@ impl<'r> ResponseTuple<'r> { } Ok(()) } + + pub(crate) fn get_component_schemas( + &self, + ) -> Result, Diagnostics> { + match &self.inner { + Some(ResponseTupleInner::Value(value)) => { + Ok(ResponseComponentSchemaIter::Iter(Box::new( + value + .content + .iter() + .map( + |media_type| match media_type.schema.get_component_schema() { + Ok(component_schema) => { + Ok(Some(media_type.schema.is_inline()).zip(component_schema)) + } + Err(error) => Err(error), + }, + ) + .collect::, Diagnostics>>()? + .into_iter() + .flatten(), + ))) + } + _ => Ok(ResponseComponentSchemaIter::Empty), + } + } } #[cfg_attr(feature = "debug", derive(Debug))] diff --git a/utoipa-gen/src/path/response/derive.rs b/utoipa-gen/src/path/response/derive.rs index 05e3e250..c07ce5d2 100644 --- a/utoipa-gen/src/path/response/derive.rs +++ b/utoipa-gen/src/path/response/derive.rs @@ -13,6 +13,7 @@ use syn::{ }; use crate::component::schema::{EnumSchema, NamedStructSchema, Root}; +use crate::component::ComponentSchema; use crate::doc_comment::CommentAttributes; use crate::path::media_type::{DefaultSchema, MediaTypeAttr, ParsedType, Schema}; use crate::{ @@ -116,6 +117,40 @@ pub struct IntoResponses { impl ToTokensDiagnostics for IntoResponses { fn to_tokens(&self, tokens: &mut TokenStream) -> Result<(), Diagnostics> { + let enum_response_tuples = match &self.data { + Data::Enum(enum_value) => Some( + enum_value + .variants + .iter() + .map(|variant| match &variant.fields { + Fields::Named(fields) => Ok(NamedStructResponse::new( + &variant.attrs, + &variant.ident, + &fields.named, + )? + .0), + Fields::Unnamed(fields) => { + let field = fields + .unnamed + .iter() + .next() + .expect("Unnamed enum variant must have 1 field"); + match UnnamedStructResponse::new( + &variant.attrs, + &field.ty, + &field.attrs, + ) { + Ok(response) => Ok(response.0), + Err(diagnostics) => Err(diagnostics), + } + } + Fields::Unit => Ok(UnitStructResponse::new(&variant.attrs)?.0), + }) + .collect::, Diagnostics>>()?, + ), + _ => None, + }; + let responses = match &self.data { Data::Struct(struct_value) => match &struct_value.fields { Fields::Named(fields) => { @@ -148,30 +183,9 @@ impl ToTokensDiagnostics for IntoResponses { Array::from_iter(iter::once(quote!((#status, #response_tokens)))) } }, - Data::Enum(enum_value) => enum_value - .variants - .iter() - .map(|variant| match &variant.fields { - Fields::Named(fields) => Ok(NamedStructResponse::new( - &variant.attrs, - &variant.ident, - &fields.named, - )? - .0), - Fields::Unnamed(fields) => { - let field = fields - .unnamed - .iter() - .next() - .expect("Unnamed enum variant must have 1 field"); - match UnnamedStructResponse::new(&variant.attrs, &field.ty, &field.attrs) { - Ok(response) => Ok(response.0), - Err(diagnostics) => Err(diagnostics), - } - } - Fields::Unit => Ok(UnitStructResponse::new(&variant.attrs)?.0), - }) - .collect::, Diagnostics>>()? + Data::Enum(_) => enum_response_tuples + .as_ref() + .expect("enum response tuples must be set for enum data") .iter() .map(|response| { let status = &response.status_code; @@ -187,6 +201,50 @@ impl ToTokensDiagnostics for IntoResponses { } }; + fn to_schema_references( + mut schemas: TokenStream, + (is_inline, component_schema): (bool, ComponentSchema), + ) -> TokenStream { + for reference in component_schema.schema_references { + let name = &reference.name; + let tokens = &reference.tokens; + let references = &reference.references; + + #[cfg(feature = "config")] + let should_collect_schema = (matches!( + crate::CONFIG.schema_collect, + utoipa_config::SchemaCollect::NonInlined + ) && !is_inline) + || matches!( + crate::CONFIG.schema_collect, + utoipa_config::SchemaCollect::All + ); + #[cfg(not(feature = "config"))] + let should_collect_schema = !is_inline; + if should_collect_schema { + schemas.extend(quote!( schemas.push((#name, #tokens)); )); + } + schemas.extend(quote!( #references; )); + } + + schemas + } + + let response_schemas = enum_response_tuples + .as_ref() + .map(|responses| { + let schemas = responses + .iter() + .map(|response| response.get_component_schemas()) + .collect::, Diagnostics>>()? + .into_iter() + .flatten() + .fold(TokenStream::new(), to_schema_references); + Ok::<_, Diagnostics>(schemas) + }) + .transpose()? + .unwrap_or_else(TokenStream::new); + let ident = &self.ident; let (impl_generics, ty_generics, where_clause) = self.generics.split_for_impl(); @@ -203,6 +261,10 @@ impl ToTokensDiagnostics for IntoResponses { .build() .into() } + + fn schemas(schemas: &mut Vec<(String, utoipa::openapi::RefOr)>) { + #response_schemas + } } }); diff --git a/utoipa-gen/tests/path_derive_auto_into_responses.rs b/utoipa-gen/tests/path_derive_auto_into_responses.rs index e6cac2fb..c8de7aeb 100644 --- a/utoipa-gen/tests/path_derive_auto_into_responses.rs +++ b/utoipa-gen/tests/path_derive_auto_into_responses.rs @@ -36,6 +36,11 @@ fn path_operation_auto_types_responses() { let value = serde_json::to_value(&doc).unwrap(); let path = value.pointer("/paths/~1item/get").unwrap(); + let schema = value + .pointer("/components/schemas/Item") + .expect("Item schema should be collected from IntoResponses"); + assert!(schema.is_object()); + assert_json_snapshot!(&path.pointer("/responses").unwrap()) } @@ -55,3 +60,49 @@ fn path_operation_auto_types_default_response_type() { assert_json_snapshot!(&path.pointer("/responses").unwrap()) } + +#[test] +fn path_operation_auto_types_result_responses_schema_collect() { + /// Test item to to return + #[derive(serde::Serialize, serde::Deserialize, utoipa::ToSchema)] + struct Item<'s> { + value: &'s str, + } + + #[derive(utoipa::IntoResponses)] + #[allow(unused)] + enum ItemResponse<'s> { + /// Item found + #[response(status = 200)] + Success(Item<'s>), + /// No item found + #[response(status = NOT_FOUND)] + NotFound, + } + + #[derive(utoipa::IntoResponses)] + #[allow(unused)] + enum ErrorResponse { + /// Something went wrong + #[response(status = INTERNAL_SERVER_ERROR)] + InternalError, + } + + #[utoipa::path(get, path = "/item")] + #[allow(unused)] + async fn get_item() -> Result, ErrorResponse> { + Ok(ItemResponse::Success(Item { value: "super" })) + } + + #[derive(OpenApi)] + #[openapi(paths(get_item))] + struct ApiDoc; + + let doc = ApiDoc::openapi(); + let value = serde_json::to_value(&doc).unwrap(); + + let schema = value + .pointer("/components/schemas/Item") + .expect("Item schema should be collected from Result IntoResponses"); + assert!(schema.is_object()); +} diff --git a/utoipa-gen/tests/path_derive_auto_into_responses_axum.rs b/utoipa-gen/tests/path_derive_auto_into_responses_axum.rs index 5c7f0dcf..271ca693 100644 --- a/utoipa-gen/tests/path_derive_auto_into_responses_axum.rs +++ b/utoipa-gen/tests/path_derive_auto_into_responses_axum.rs @@ -38,3 +38,57 @@ fn path_operation_auto_types_responses() { assert_json_snapshot!(&path.pointer("/responses").unwrap()) } + +#[test] +fn path_operation_auto_params_into_params_query() { + use axum::extract::Query; + use serde::Deserialize; + + #[derive(Deserialize, utoipa::IntoParams)] + struct FindParams { + id: i32, + } + + #[utoipa::path(get, path = "/items", auto_params)] + #[allow(unused)] + async fn get_items(Query(params): Query) {} + + #[derive(OpenApi)] + #[openapi(paths(get_items))] + struct ApiDoc; + + let doc = ApiDoc::openapi(); + let value = serde_json::to_value(&doc).unwrap(); + let operation = value.pointer("/paths/~1items/get").unwrap(); + let parameters = operation.pointer("/parameters").unwrap(); + + assert!(parameters.is_array()); +} + +#[test] +fn path_operation_auto_params_disabled() { + use axum::extract::Query; + use serde::Deserialize; + + #[derive(Deserialize, utoipa::IntoParams)] + struct FindParams { + id: i32, + } + + #[utoipa::path(get, path = "/items", auto_params = false)] + #[allow(unused)] + async fn get_items(Query(params): Query) {} + + #[derive(OpenApi)] + #[openapi(paths(get_items))] + struct ApiDoc; + + let doc = ApiDoc::openapi(); + let value = serde_json::to_value(&doc).unwrap(); + let operation = value.pointer("/paths/~1items/get").unwrap(); + let parameters = operation + .pointer("/parameters") + .unwrap_or(&serde_json::Value::Null); + + assert!(parameters.is_null()); +} diff --git a/utoipa/src/lib.rs b/utoipa/src/lib.rs index dbaa6a1d..d629c4fd 100644 --- a/utoipa/src/lib.rs +++ b/utoipa/src/lib.rs @@ -1143,6 +1143,10 @@ pub trait IntoParams { pub trait IntoResponses { /// Returns an ordered map of response codes to responses. fn responses() -> BTreeMap>; + #[doc(hidden)] + /// Allows `IntoResponses` implementations to contribute component schemas for their response + /// payloads without needing to list them manually in `#[openapi(components(...))]`. + fn schemas(_schemas: &mut Vec<(String, openapi::RefOr)>) {} } #[cfg(feature = "auto_into_responses")] @@ -1153,6 +1157,11 @@ impl IntoResponses for Result { responses } + + fn schemas(schemas: &mut Vec<(String, openapi::RefOr)>) { + T::schemas(schemas); + E::schemas(schemas); + } } #[cfg(feature = "auto_into_responses")]