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;