Skip to content

Commit

Permalink
Add nest OpenApi support
Browse files Browse the repository at this point in the history
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
  • Loading branch information
juhaku committed May 17, 2024
1 parent 28cf85c commit 07925da
Show file tree
Hide file tree
Showing 13 changed files with 312 additions and 27 deletions.
3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
[workspace.package]
rust-version = "1.75"

[workspace]
resolver = "2"
members = [
Expand Down
1 change: 1 addition & 0 deletions utoipa-gen/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ readme = "README.md"
keywords = ["openapi", "codegen", "proc-macro", "documentation", "compile-time"]
repository = "https://github.com/juhaku/utoipa"
authors = ["Juha Kukkonen <[email protected]>"]
rust-version.workspace = true

[lib]
proc-macro = true
Expand Down
9 changes: 2 additions & 7 deletions utoipa-gen/src/component/serde.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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))]
Expand Down
42 changes: 40 additions & 2 deletions utoipa-gen/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
55 changes: 54 additions & 1 deletion utoipa-gen/src/openapi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ pub struct OpenApiAttr<'o> {
tags: Option<Array<'static, Tag>>,
external_docs: Option<ExternalDocs>,
servers: Punctuated<Server, Comma>,
nested: Vec<NestOpenApi>,
}

impl<'o> OpenApiAttr<'o> {
Expand Down Expand Up @@ -77,7 +78,7 @@ pub fn parse_openapi_attrs(attrs: &[Attribute]) -> Result<Option<OpenApiAttr>, E
impl Parse for OpenApiAttr<'_> {
fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
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() {
Expand Down Expand Up @@ -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));
}
Expand Down Expand Up @@ -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<TokenStream> {
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::<TokenStream>();

Some(nest_tokens)
}
}
}

impl ToTokens for OpenApi<'_> {
fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) {
let OpenApi(attributes, ident) = self;
Expand Down Expand Up @@ -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 {
Expand All @@ -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));
Expand Down Expand Up @@ -570,3 +605,21 @@ fn impl_paths(handler_paths: &Punctuated<ExprPath, Comma>) -> TokenStream {
},
)
}

#[cfg_attr(feature = "debug", derive(Debug))]
struct NestOpenApi {
path: String,
open_api: TypePath,
}

impl Parse for NestOpenApi {
fn parse(input: ParseStream) -> syn::Result<Self> {
let path = input.parse::<LitStr>()?;
input.parse::<Comma>()?;
let api = input.parse::<TypePath>()?;
Ok(Self {
path: path.value(),
open_api: api,
})
}
}
75 changes: 74 additions & 1 deletion utoipa-gen/tests/openapi_derive.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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" ]
}
}
})
)
}
1 change: 1 addition & 0 deletions utoipa-rapidoc/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ keywords = ["rapidoc", "openapi", "documentation"]
repository = "https://github.com/juhaku/utoipa"
categories = ["web-programming"]
authors = ["Juha Kukkonen <[email protected]>"]
rust-version.workspace = true

[package.metadata.docs.rs]
features = ["actix-web", "axum", "rocket"]
Expand Down
1 change: 1 addition & 0 deletions utoipa-redoc/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ keywords = ["redoc", "openapi", "documentation"]
repository = "https://github.com/juhaku/utoipa"
categories = ["web-programming"]
authors = ["Juha Kukkonen <[email protected]>"]
rust-version.workspace = true

[package.metadata.docs.rs]
features = ["actix-web", "axum", "rocket"]
Expand Down
1 change: 1 addition & 0 deletions utoipa-scalar/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ keywords = ["scalar", "openapi", "documentation"]
repository = "https://github.com/juhaku/utoipa"
categories = ["web-programming"]
authors = ["Juha Kukkonen <[email protected]>"]
rust-version.workspace = true

[package.metadata.docs.rs]
features = ["actix-web", "axum", "rocket"]
Expand Down
1 change: 1 addition & 0 deletions utoipa-swagger-ui/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ keywords = ["swagger-ui", "openapi", "documentation"]
repository = "https://github.com/juhaku/utoipa"
categories = ["web-programming"]
authors = ["Juha Kukkonen <[email protected]>"]
rust-version.workspace = true

[features]
debug = []
Expand Down
1 change: 1 addition & 0 deletions utoipa/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ keywords = [
repository = "https://github.com/juhaku/utoipa"
categories = ["web-programming"]
authors = ["Juha Kukkonen <[email protected]>"]
rust-version.workspace = true

[features]
# See README.md for list and explanations of features
Expand Down
Loading

0 comments on commit 07925da

Please sign in to comment.