From 07925da32e973b13aa5c86d9494807e331914835 Mon Sep 17 00:00:00 2001 From: Juha Kukkonen Date: Fri, 17 May 2024 19:35:59 +0300 Subject: [PATCH] Add nest `OpenApi` support This PR implements API nesting support to enable modular OpenAPI specification declaration. This functionality is implemented at OpenApi type itself as well as at OpenApi derive trait. Prior to this PR there was no simple way to modularize the OpenApi definition and one way to do this was to use `context_path` attribute on `#[utoipa::path]` macro but this functionality undoes the need for `context_path` in most cases. Add `nest(...)` method to `OpenApi` to allow nesting of other `OpenApi` instances to the current `OpenApi` instance. ```rust let api = OpenApiBuider::new().build(); let nested = api.nest("/nest/path", OpenApiBuilder::new().build()); ``` Add `nest` attribute to `OpenApi` derive macro. ```rust #[derive(OpenApi)] #[openapi( paths(...), nest( ("path/to/nest", NestableApi) ) )] struct RootApi; ``` Resolves #445 Resolves #872 --- Cargo.toml | 3 + utoipa-gen/Cargo.toml | 1 + utoipa-gen/src/component/serde.rs | 9 +- utoipa-gen/src/lib.rs | 42 ++++++++- utoipa-gen/src/openapi.rs | 55 ++++++++++- utoipa-gen/tests/openapi_derive.rs | 75 ++++++++++++++- utoipa-rapidoc/Cargo.toml | 1 + utoipa-redoc/Cargo.toml | 1 + utoipa-scalar/Cargo.toml | 1 + utoipa-swagger-ui/Cargo.toml | 1 + utoipa/Cargo.toml | 1 + utoipa/src/openapi.rs | 145 ++++++++++++++++++++++++++--- utoipa/src/openapi/path.rs | 4 +- 13 files changed, 312 insertions(+), 27 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 91083abb..002e99de 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,3 +1,6 @@ +[workspace.package] +rust-version = "1.75" + [workspace] resolver = "2" members = [ diff --git a/utoipa-gen/Cargo.toml b/utoipa-gen/Cargo.toml index 4a396935..8fbd8761 100644 --- a/utoipa-gen/Cargo.toml +++ b/utoipa-gen/Cargo.toml @@ -8,6 +8,7 @@ readme = "README.md" keywords = ["openapi", "codegen", "proc-macro", "documentation", "compile-time"] repository = "https://github.com/juhaku/utoipa" authors = ["Juha Kukkonen "] +rust-version.workspace = true [lib] proc-macro = true diff --git a/utoipa-gen/src/component/serde.rs b/utoipa-gen/src/component/serde.rs index 70ca1f0e..fadc8f75 100644 --- a/utoipa-gen/src/component/serde.rs +++ b/utoipa-gen/src/component/serde.rs @@ -87,10 +87,11 @@ impl SerdeValue { /// The [Serde Enum representation](https://serde.rs/enum-representations.html) being used /// The default case (when no serde attributes are present) is `ExternallyTagged`. -#[derive(Clone)] +#[derive(Clone, Default)] #[cfg_attr(feature = "debug", derive(Debug))] #[cfg_attr(test, derive(PartialEq, Eq))] pub enum SerdeEnumRepr { + #[default] ExternallyTagged, InternallyTagged { tag: String, @@ -108,12 +109,6 @@ pub enum SerdeEnumRepr { }, } -impl Default for SerdeEnumRepr { - fn default() -> SerdeEnumRepr { - SerdeEnumRepr::ExternallyTagged - } -} - /// Attributes defined within a `#[serde(...)]` container attribute. #[derive(Default)] #[cfg_attr(feature = "debug", derive(Debug))] diff --git a/utoipa-gen/src/lib.rs b/utoipa-gen/src/lib.rs index c44a6877..2ddde468 100644 --- a/utoipa-gen/src/lib.rs +++ b/utoipa-gen/src/lib.rs @@ -98,7 +98,7 @@ use self::{ /// the OpenAPI. E.g _`as = path::to::Pet`_. This would make the schema appear in the generated /// OpenAPI spec as _`path.to.Pet`_. /// * `default` Can be used to populate default values on all fields using the struct's -/// [`Default`](std::default::Default) implementation. +/// [`Default`] implementation. /// * `deprecated` Can be used to mark all fields as deprecated in the generated OpenAPI spec but /// not in the code. If you'd like to mark the fields as deprecated in the code as well use /// Rust's own `#[deprecated]` attribute instead. @@ -131,7 +131,7 @@ use self::{ /// * `example = ...` Can be method reference or _`json!(...)`_. /// * `default = ...` Can be method reference or _`json!(...)`_. If no value is specified, and the struct has /// only one field, the field's default value in the schema will be set from the struct's -/// [`Default`](std::default::Default) implementation. +/// [`Default`] implementation. /// * `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. @@ -1456,6 +1456,18 @@ pub fn path(attr: TokenStream, item: TokenStream) -> TokenStream { /// whole attribute from generated values of Cargo environment variables. E.g. defining /// `contact(name = ...)` will ultimately override whole contact of info and not just partially /// the name. +/// * `nest(...)` Allows nesting [`OpenApi`][openapi_struct]s to this _`OpenApi`_ instance. Nest +/// takes comma separated list of tuples that define comma separated key values of nest path and +/// _`OpenApi`_ instance to nest. _`OpenApi`_ instance must implement [`OpenApi`][openapi] trait. +/// +/// _**Nest syntax example.**_ +/// +/// ```text +/// nest( +/// ("/path/to/nest", ApiToNest), +/// ("/another", AnotherApi) +/// ) +/// ``` /// /// OpenApi derive macro will also derive [`Info`][info] for OpenApi specification using Cargo /// environment variables. @@ -1610,6 +1622,32 @@ pub fn path(attr: TokenStream, item: TokenStream) -> TokenStream { /// struct ApiDoc; /// ``` /// +/// _**Nest _`UserApi`_ to the current api doc instance.**_ +/// ```rust +/// # use utoipa::OpenApi; +/// # +/// #[utoipa::path(get, path = "/api/v1/status")] +/// fn test_path_status() {} +/// +/// #[utoipa::path(get, path = "/test")] +/// fn user_test_path() {} +/// +/// #[derive(OpenApi)] +/// #[openapi(paths(user_test_path))] +/// struct UserApi; +/// +/// #[derive(OpenApi)] +/// #[openapi( +/// paths( +/// test_path_status +/// ), +/// nest( +/// ("/api/v1/user", UserApi), +/// ) +/// )] +/// struct ApiDoc; +/// ``` +/// /// [openapi]: trait.OpenApi.html /// [openapi_struct]: openapi/struct.OpenApi.html /// [to_schema]: derive.ToSchema.html diff --git a/utoipa-gen/src/openapi.rs b/utoipa-gen/src/openapi.rs index d6b4f90a..0ce280a7 100644 --- a/utoipa-gen/src/openapi.rs +++ b/utoipa-gen/src/openapi.rs @@ -32,6 +32,7 @@ pub struct OpenApiAttr<'o> { tags: Option>, external_docs: Option, servers: Punctuated, + nested: Vec, } impl<'o> OpenApiAttr<'o> { @@ -77,7 +78,7 @@ pub fn parse_openapi_attrs(attrs: &[Attribute]) -> Result, E impl Parse for OpenApiAttr<'_> { fn parse(input: syn::parse::ParseStream) -> syn::Result { const EXPECTED_ATTRIBUTE: &str = - "unexpected attribute, expected any of: handlers, components, modifiers, security, tags, external_docs, servers"; + "unexpected attribute, expected any of: handlers, components, modifiers, security, tags, external_docs, servers, nest"; let mut openapi = OpenApiAttr::default(); while !input.is_empty() { @@ -119,6 +120,11 @@ impl Parse for OpenApiAttr<'_> { "servers" => { openapi.servers = parse_utils::parse_punctuated_within_parenthesis(input)?; } + "nest" => { + let nest; + parenthesized!(nest in input); + openapi.nested = parse_utils::parse_groups(&nest)?; + } _ => { return Err(Error::new(ident.span(), EXPECTED_ATTRIBUTE)); } @@ -387,6 +393,31 @@ impl Parse for ServerVariable { pub(crate) struct OpenApi<'o>(pub OpenApiAttr<'o>, pub Ident); +impl OpenApi<'_> { + fn nested_tokens(&self) -> Option { + if self.0.nested.is_empty() { + None + } else { + let nest_tokens = self + .0 + .nested + .iter() + .map(|item| { + let path = &item.path; + let nest_api = &item.open_api; + + let span = nest_api.span(); + quote_spanned! {span=> + .nest(#path, <#nest_api as utoipa::OpenApi>::openapi()) + } + }) + .collect::(); + + Some(nest_tokens) + } + } +} + impl ToTokens for OpenApi<'_> { fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) { let OpenApi(attributes, ident) = self; @@ -428,6 +459,9 @@ impl ToTokens for OpenApi<'_> { None }; + let nested_tokens = self + .nested_tokens() + .map(|tokens| quote! {openapi = openapi #tokens;}); tokens.extend(quote! { impl utoipa::OpenApi for #ident { fn openapi() -> utoipa::openapi::OpenApi { @@ -441,6 +475,7 @@ impl ToTokens for OpenApi<'_> { #servers #external_docs .build(); + #nested_tokens let _mods: [&dyn utoipa::Modify; #modifiers_len] = [#modifiers]; _mods.iter().for_each(|modifier| modifier.modify(&mut openapi)); @@ -570,3 +605,21 @@ fn impl_paths(handler_paths: &Punctuated) -> TokenStream { }, ) } + +#[cfg_attr(feature = "debug", derive(Debug))] +struct NestOpenApi { + path: String, + open_api: TypePath, +} + +impl Parse for NestOpenApi { + fn parse(input: ParseStream) -> syn::Result { + let path = input.parse::()?; + input.parse::()?; + let api = input.parse::()?; + Ok(Self { + path: path.value(), + open_api: api, + }) + } +} diff --git a/utoipa-gen/tests/openapi_derive.rs b/utoipa-gen/tests/openapi_derive.rs index 25b4edac..32fba550 100644 --- a/utoipa-gen/tests/openapi_derive.rs +++ b/utoipa-gen/tests/openapi_derive.rs @@ -5,7 +5,7 @@ use serde::Serialize; use serde_json::{json, Value}; use utoipa::{ openapi::{RefOr, Response, ResponseBuilder}, - OpenApi, ToResponse, + Modify, OpenApi, ToResponse, }; use utoipa_gen::ToSchema; @@ -378,3 +378,76 @@ fn derive_openapi_with_generic_schema_with_as() { }) ) } + +#[test] +fn derive_nest_openapi_with_modifier() { + struct MyModifier; + impl Modify for MyModifier { + fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) { + openapi.info.description = Some("this is description".to_string()) + } + } + + #[utoipa::path(get, path = "/api/v1/status")] + #[allow(dead_code)] + fn test_path_status() {} + + #[utoipa::path(get, path = "/test")] + #[allow(dead_code)] + fn user_test_path() {} + + #[derive(OpenApi)] + #[openapi(paths(user_test_path))] + struct UserApi; + + #[utoipa::path(get, path = "/")] + #[allow(dead_code)] + fn foobar() {} + + #[derive(OpenApi)] + #[openapi(paths(foobar))] + struct FooBarApi; + + #[derive(OpenApi)] + #[openapi( + paths( + test_path_status + ), + modifiers(&MyModifier), + nest( + ("/api/v1/user", UserApi), + ("/api/v1/foobar", FooBarApi) + ) + )] + struct ApiDoc; + + let api = serde_json::to_value(&ApiDoc::openapi()).expect("should serialize to value"); + let paths = api.pointer("/paths"); + + assert_json_eq!( + paths, + json!({ + "/api/v1/foobar/": { + "get": { + "operationId": "foobar", + "responses": {}, + "tags": [ "crate" ] + } + }, + "/api/v1/status": { + "get": { + "operationId": "test_path_status", + "responses": {}, + "tags": [ "crate" ] + } + }, + "/api/v1/user/test": { + "get": { + "operationId": "user_test_path", + "responses": {}, + "tags": [ "crate" ] + } + } + }) + ) +} diff --git a/utoipa-rapidoc/Cargo.toml b/utoipa-rapidoc/Cargo.toml index 6b71fd16..e571825f 100644 --- a/utoipa-rapidoc/Cargo.toml +++ b/utoipa-rapidoc/Cargo.toml @@ -9,6 +9,7 @@ keywords = ["rapidoc", "openapi", "documentation"] repository = "https://github.com/juhaku/utoipa" categories = ["web-programming"] authors = ["Juha Kukkonen "] +rust-version.workspace = true [package.metadata.docs.rs] features = ["actix-web", "axum", "rocket"] diff --git a/utoipa-redoc/Cargo.toml b/utoipa-redoc/Cargo.toml index 1569fea1..4aa7463f 100644 --- a/utoipa-redoc/Cargo.toml +++ b/utoipa-redoc/Cargo.toml @@ -9,6 +9,7 @@ keywords = ["redoc", "openapi", "documentation"] repository = "https://github.com/juhaku/utoipa" categories = ["web-programming"] authors = ["Juha Kukkonen "] +rust-version.workspace = true [package.metadata.docs.rs] features = ["actix-web", "axum", "rocket"] diff --git a/utoipa-scalar/Cargo.toml b/utoipa-scalar/Cargo.toml index 3b58b63a..01d7c175 100644 --- a/utoipa-scalar/Cargo.toml +++ b/utoipa-scalar/Cargo.toml @@ -9,6 +9,7 @@ keywords = ["scalar", "openapi", "documentation"] repository = "https://github.com/juhaku/utoipa" categories = ["web-programming"] authors = ["Juha Kukkonen "] +rust-version.workspace = true [package.metadata.docs.rs] features = ["actix-web", "axum", "rocket"] diff --git a/utoipa-swagger-ui/Cargo.toml b/utoipa-swagger-ui/Cargo.toml index cdc47b76..c21c720b 100644 --- a/utoipa-swagger-ui/Cargo.toml +++ b/utoipa-swagger-ui/Cargo.toml @@ -9,6 +9,7 @@ keywords = ["swagger-ui", "openapi", "documentation"] repository = "https://github.com/juhaku/utoipa" categories = ["web-programming"] authors = ["Juha Kukkonen "] +rust-version.workspace = true [features] debug = [] diff --git a/utoipa/Cargo.toml b/utoipa/Cargo.toml index f8eaff30..ad0983f3 100644 --- a/utoipa/Cargo.toml +++ b/utoipa/Cargo.toml @@ -17,6 +17,7 @@ keywords = [ repository = "https://github.com/juhaku/utoipa" categories = ["web-programming"] authors = ["Juha Kukkonen "] +rust-version.workspace = true [features] # See README.md for list and explanations of features diff --git a/utoipa/src/openapi.rs b/utoipa/src/openapi.rs index 09f2b36b..2b881fb6 100644 --- a/utoipa/src/openapi.rs +++ b/utoipa/src/openapi.rs @@ -3,6 +3,7 @@ use serde::{de::Error, de::Visitor, Deserialize, Deserializer, Serialize, Serializer}; use std::fmt::Formatter; +use self::path::PathsMap; pub use self::{ content::{Content, ContentBuilder}, external_docs::ExternalDocs, @@ -226,6 +227,66 @@ impl OpenApi { tags.append(other_tags); } } + + /// Nest `other` [`OpenApi`] to this [`OpenApi`]. + /// + /// Nesting performs custom [`OpenApi::merge`] where `other` [`OpenApi`] paths are prepended with given + /// `path` and then appended to _`paths`_ of this [`OpenApi`] instance. Rest of the `other` + /// [`OpenApi`] instance is merged to this [`OpenApi`] with [`OpenApi::merge_from`] method. + /// + /// Method accpets two arguments, first is the path to prepend .e.g. _`/user`_. Second argument + /// is the [`OpenApi`] to prepend paths for. + /// + /// # Examples + /// + /// _**Merge `user_api` to `api` nesting `user_api` paths under `/api/v1/user`**_ + /// ```rust + /// # use utoipa::openapi::{OpenApi, OpenApiBuilder}; + /// # use utoipa::openapi::path::{PathsBuilder, PathItemBuilder, PathItem, + /// # PathItemType, OperationBuilder}; + /// let api = OpenApiBuilder::new() + /// .paths( + /// PathsBuilder::new().path( + /// "/api/v1/status", + /// PathItem::new( + /// PathItemType::Get, + /// OperationBuilder::new() + /// .description(Some("Get status")) + /// .build(), + /// ), + /// ), + /// ) + /// .build(); + /// let user_api = OpenApiBuilder::new() + /// .paths( + /// PathsBuilder::new().path( + /// "/", + /// PathItem::new(PathItemType::Post, OperationBuilder::new().build()), + /// ) + /// ) + /// .build(); + /// let nested = api.nest("/api/v1/user", user_api); + /// ``` + pub fn nest, O: Into>(mut self, path: P, other: O) -> Self { + let path: String = path.into(); + let mut other_api: OpenApi = other.into(); + + let nested_paths = other_api + .paths + .paths + .into_iter() + .map(|(item_path, item)| { + let path = format!("{path}{item_path}"); + (path, item) + }) + .collect::>(); + + self.paths.paths.extend(nested_paths); + + // paths are already merged, thus we can ignore them + other_api.paths.paths = PathsMap::new(); + self.merge_from(other_api) + } } impl OpenApiBuilder { @@ -332,10 +393,11 @@ impl<'de> Deserialize<'de> for OpenApiVersion { /// Value used to indicate whether reusable schema, parameter or operation is deprecated. /// /// The value will serialize to boolean. -#[derive(PartialEq, Eq, Clone)] +#[derive(PartialEq, Eq, Clone, Default)] #[cfg_attr(feature = "debug", derive(Debug))] pub enum Deprecated { True, + #[default] False, } @@ -375,19 +437,14 @@ impl<'de> Deserialize<'de> for Deprecated { } } -impl Default for Deprecated { - fn default() -> Self { - Deprecated::False - } -} - /// Value used to indicate whether parameter or property is required. /// /// The value will serialize to boolean. -#[derive(PartialEq, Eq, Clone)] +#[derive(PartialEq, Eq, Clone, Default)] #[cfg_attr(feature = "debug", derive(Debug))] pub enum Required { True, + #[default] False, } @@ -427,12 +484,6 @@ impl<'de> Deserialize<'de> for Required { } } -impl Default for Required { - fn default() -> Self { - Required::False - } -} - /// A [`Ref`] or some other type `T`. /// /// Typically used in combination with [`Components`] and is an union type between [`Ref`] and any @@ -547,6 +598,7 @@ pub(crate) use builder; #[cfg(test)] mod tests { + use assert_json_diff::assert_json_eq; use serde_json::json; use crate::openapi::{ @@ -887,4 +939,69 @@ mod tests { )); }); } + + #[test] + fn test_nest_open_apis() { + let api = OpenApiBuilder::new() + .paths( + PathsBuilder::new().path( + "/api/v1/status", + PathItem::new( + PathItemType::Get, + OperationBuilder::new() + .description(Some("Get status")) + .build(), + ), + ), + ) + .build(); + + let user_api = OpenApiBuilder::new() + .paths( + PathsBuilder::new() + .path( + "/", + PathItem::new( + PathItemType::Get, + OperationBuilder::new() + .description(Some("Get user details")) + .build(), + ), + ) + .path( + "/foo", + PathItem::new(PathItemType::Post, OperationBuilder::new().build()), + ), + ) + .build(); + + let nest_merged = api.nest("/api/v1/user", user_api); + let value = serde_json::to_value(nest_merged).expect("should serialize as json"); + let paths = value + .pointer("/paths") + .expect("paths should exits in openapi"); + + assert_json_eq!( + paths, + json!({ + "/api/v1/status": { + "get": { + "description": "Get status", + "responses": {} + } + }, + "/api/v1/user/": { + "get": { + "description": "Get user details", + "responses": {} + } + }, + "/api/v1/user/foo": { + "post": { + "responses": {} + } + } + }) + ) + } } diff --git a/utoipa/src/openapi/path.rs b/utoipa/src/openapi/path.rs index 821b61f7..aad167cf 100644 --- a/utoipa/src/openapi/path.rs +++ b/utoipa/src/openapi/path.rs @@ -16,9 +16,9 @@ use super::{ }; #[cfg(not(feature = "preserve_path_order"))] -type PathsMap = std::collections::BTreeMap; +pub(super) type PathsMap = std::collections::BTreeMap; #[cfg(feature = "preserve_path_order")] -type PathsMap = indexmap::IndexMap; +pub(super) type PathsMap = indexmap::IndexMap; builder! { PathsBuilder;