diff --git a/Cargo.toml b/Cargo.toml index bf5c1731..938f6730 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ members = [ "utoipa-redoc", "utoipa-rapidoc", "utoipa-scalar", + "utoipa-axum", ] [workspace.metadata.publish] @@ -22,4 +23,5 @@ order = [ "utoipa-redoc", "utoipa-rapidoc", "utoipa-scalar", + "utoipa-axum", ] diff --git a/scripts/test.sh b/scripts/test.sh index 7898ea13..7f49c3a0 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -4,7 +4,7 @@ set -e : "${CARGO:=cargo}" -crates="${1:-utoipa utoipa-gen utoipa-swagger-ui utoipa-redoc utoipa-rapidoc}" +crates="${1:-utoipa utoipa-gen utoipa-swagger-ui utoipa-redoc utoipa-rapidoc utoipa-scalar utoipa-axum}" for crate in $crates; do echo "Testing crate: $crate..." @@ -31,5 +31,7 @@ for crate in $crates; do $CARGO test -p utoipa-rapidoc --features actix-web,rocket,axum elif [[ "$crate" == "utoipa-scalar" ]]; then $CARGO test -p utoipa-scalar --features actix-web,rocket,axum + elif [[ "$crate" == "utoipa-axum" ]]; then + $CARGO test -p utoipa-axum --features debug,utoipa/debug fi done diff --git a/utoipa-axum/Cargo.toml b/utoipa-axum/Cargo.toml new file mode 100644 index 00000000..d4d142ad --- /dev/null +++ b/utoipa-axum/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "utoipa-axum" +description = "Compile time generated OpenAPI documentation for Rust" +version = "0.1.0" +edition = "2021" +license = "MIT OR Apache-2.0" +readme = "README.md" +keywords = ["utoipa", "axum", "bindings"] +repository = "https://github.com/juhaku/utoipa" +categories = ["web-programming"] +authors = ["Juha Kukkonen "] +rust-version.workspace = true + +[features] +debug = [] + +[dependencies] +axum = { version = "0.7", default-features = false } +utoipa = { version = "5.0.0-alpha", path = "../utoipa", default-features = false } +async-trait = "0.1" +tower-service = "0.3" +tower-layer = "0.3.2" +paste = "1.0" + +[package.metadata.docs.rs] +features = [] +rustdoc-args = ["--cfg", "doc_cfg"] + +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(doc_cfg)'] } diff --git a/utoipa-axum/LICENSE-APACHE b/utoipa-axum/LICENSE-APACHE new file mode 120000 index 00000000..965b606f --- /dev/null +++ b/utoipa-axum/LICENSE-APACHE @@ -0,0 +1 @@ +../LICENSE-APACHE \ No newline at end of file diff --git a/utoipa-axum/LICENSE-MIT b/utoipa-axum/LICENSE-MIT new file mode 120000 index 00000000..76219eb7 --- /dev/null +++ b/utoipa-axum/LICENSE-MIT @@ -0,0 +1 @@ +../LICENSE-MIT \ No newline at end of file diff --git a/utoipa-axum/README.md b/utoipa-axum/README.md new file mode 100644 index 00000000..41f4af3f --- /dev/null +++ b/utoipa-axum/README.md @@ -0,0 +1,47 @@ +# utoipa-axum - Bindings for Axum and utoipa + +Utoipa axum brings `utoipa` and `axum` closer together by the way of providing an ergonomic API that is extending on +the `axum` API. It gives a natural way to register handlers known to `axum` and also simultaneously generates OpenAPI +specification from the handlers. + +## Install + +Add dependency declaration to `Cargo.toml`. + +```toml +[dependencies] +utoipa_axum = "0.1" +``` + +## Examples + +Use `OpenApiRouter` to collect handlers with `#[utoipa::path]` macro to compose service and form OpenAPI spec. + +```rust +#[derive(utoipa::ToSchema)] +struct Todo { + id: i32, +} + +#[derive(utoipa::OpenApi)] +#[openapi(components(schemas(Todo)))] +struct Api; + +let mut router: OpenApiRouter = OpenApiRouter::with_openapi(Api::openapi()) + .routes(get_path(search_user)) + .routes( + get_path(get_user) + .post_path(post_user) + .delete_path(delete_user), + ); + +let api = router.to_openapi(); +let axum_router: axum::Router = router.into(); +``` + +## License + +Licensed under either of [Apache 2.0](LICENSE-APACHE) or [MIT](LICENSE-MIT) license at your option. + +Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in this crate +by you, shall be dual licensed, without any additional terms or conditions. diff --git a/utoipa-axum/src/lib.rs b/utoipa-axum/src/lib.rs new file mode 100644 index 00000000..fbfb6e25 --- /dev/null +++ b/utoipa-axum/src/lib.rs @@ -0,0 +1,320 @@ +#![cfg_attr(doc_cfg, feature(doc_cfg))] +#![warn(missing_docs)] +#![warn(rustdoc::broken_intra_doc_links)] + +//! Utoipa axum brings `utoipa` and `axum` closer together by the way of providing an ergonomic API that is extending on +//! the `axum` API. It gives a natural way to register handlers known to `axum` and also simultaneously generates OpenAPI +//! specification from the handlers. +//! +//! ## Install +//! +//! Add dependency declaration to `Cargo.toml`. +//! +//! ```toml +//! [dependencies] +//! utoipa_axum = "0.1" +//! ``` +//! +//! ## Examples +//! +//! _**Use [`OpenApiRouter`][router] to collect handlers with _`#[utoipa::path]`_ macro to compose service and form OpenAPI spec.**_ +//! +//! ```rust +//! # use utoipa::OpenApi; +//! # use utoipa_axum::{routes, PathItemExt, router::OpenApiRouter}; +//! #[derive(utoipa::ToSchema)] +//! struct Todo { +//! id: i32, +//! } +//! +//! #[derive(utoipa::OpenApi)] +//! #[openapi(components(schemas(Todo)))] +//! struct Api; +//! # #[utoipa::path(get, path = "/search")] +//! # async fn search_user() {} +//! # #[utoipa::path(get, path = "")] +//! # async fn get_user() {} +//! # #[utoipa::path(post, path = "")] +//! # async fn post_user() {} +//! # #[utoipa::path(delete, path = "")] +//! # async fn delete_user() {} +//! +//! let mut router: OpenApiRouter = OpenApiRouter::with_openapi(Api::openapi()) +//! .routes(routes!(search_user)) +//! .routes(routes!(get_user, post_user, delete_user)); +//! +//! let api = router.to_openapi(); +//! let axum_router: axum::Router = router.into(); +//! ``` +//! +//! [router]: router/struct.OpenApiRouter.html + +pub mod router; + +use core::panic; + +use axum::routing::MethodFilter; +use utoipa::openapi::PathItemType; + +/// Extends [`utoipa::openapi::path::PathItem`] by providing conversion methods to convert this +/// path item type to a [`axum::routing::MethodFilter`]. +pub trait PathItemExt { + /// Convert this path item type ot a [`axum::routing::MethodFilter`]. + /// + /// Method filter is used with handler registration on [`axum::routing::MethodRouter`]. + /// + /// # Panics + /// + /// [`utoipa::openapi::path::PathItemType::Connect`] will panic because _`axum`_ does not have + /// `CONNECT` type [`axum::routing::MethodFilter`]. + fn to_method_filter(&self) -> MethodFilter; +} + +impl PathItemExt for PathItemType { + fn to_method_filter(&self) -> MethodFilter { + match self { + PathItemType::Get => MethodFilter::GET, + PathItemType::Put => MethodFilter::PUT, + PathItemType::Post => MethodFilter::POST, + PathItemType::Head => MethodFilter::HEAD, + PathItemType::Patch => MethodFilter::PATCH, + PathItemType::Trace => MethodFilter::TRACE, + PathItemType::Delete => MethodFilter::DELETE, + PathItemType::Options => MethodFilter::OPTIONS, + PathItemType::Connect => panic!( + "`CONNECT` not supported, axum does not have `MethodFilter` for connect requests" + ), + } + } +} + +/// re-export paste so users do not need to add the dependency. +#[doc(hidden)] +pub use paste::paste; + +/// Collect axum handlers annotated with [`utoipa::path`] to [`router::UtoipaMethodRouter`]. +/// +/// [`routes`] macro will return [`router::UtoipaMethodRouter`] which contains an +/// [`axum::routing::MethodRouter`] and currenty registered paths. The output of this macro is +/// meant to be used together with [`router::OpenApiRouter`] which combines the paths and axum +/// routers to a single entity. +/// +/// Only handlers collected with [`routes`] macro will get registered to the OpenApi. +/// +/// # Panics +/// +/// Routes registered via [`routes`] macro or via `axum::routing::*` operations are bound to same +/// rules where only one one HTTP method can can be registered once per call. This means that the +/// following will produce runtime panic from axum code. +/// +/// ```rust,no_run +/// # use utoipa_axum::{routes, router::UtoipaMethodRouter}; +/// # use utoipa::path; +/// #[utoipa::path(get, path = "/search")] +/// async fn search_user() {} +/// +/// #[utoipa::path(get, path = "")] +/// async fn get_user() {} +/// +/// let _: UtoipaMethodRouter = routes!(get_user, search_user); +/// ``` +/// Since the _`axum`_ does not support method filter for `CONNECT` requests, using this macro with +/// handler having request method type `CONNET` `#[utoipa::path(connet, path = "")]` will panic at +/// runtime. +/// +/// # Examples +/// +/// _**Create new `OpenApiRouter` with `get_user` and `post_user` paths.**_ +/// ```rust +/// # use utoipa_axum::{routes, router::{OpenApiRouter, UtoipaMethodRouter}}; +/// # use utoipa::path; +/// #[utoipa::path(get, path = "")] +/// async fn get_user() {} +/// +/// #[utoipa::path(post, path = "")] +/// async fn post_user() {} +/// +/// let _: OpenApiRouter = OpenApiRouter::new().routes(routes!(get_user, post_user)); +/// ``` +#[macro_export] +macro_rules! routes { + ( $handler:ident $(, $tail:tt)* ) => { + { + use $crate::PathItemExt; + let mut paths = utoipa::openapi::path::Paths::new(); + let (path, item, types) = routes!(@resolve_types $handler); + paths.add_path(path, item); + #[allow(unused_mut)] + let mut method_router = types.into_iter().fold(axum::routing::MethodRouter::new(), |router, path_type| { + router.on(path_type.to_method_filter(), $handler) + }); + $( method_router = routes!( method_router: paths: $tail ); )* + (paths, method_router) + } + }; + ( $router:ident: $paths:ident: $handler:ident $(, $tail:tt)* ) => { + { + let (path, item, types) = routes!(@resolve_types $handler); + $paths.add_path(path, item); + types.into_iter().fold($router, |router, path_type| { + router.on(path_type.to_method_filter(), $handler) + }) + } + }; + ( @resolve_types $handler:ident ) => { + { + use utoipa::{Path, __dev::{PathItemTypes, Tags}}; + $crate::paste! { + let path = [<__path_ $handler>]::path(); + let mut path_item = [<__path_ $handler>]::path_item(); + let types = [<__path_ $handler>]::path_item_types(); + let tags = [< __path_ $handler>]::tags(); + if !tags.is_empty() { + for (_, operation) in path_item.operations.iter_mut() { + let operation_tags = operation.tags.get_or_insert(Vec::new()); + operation_tags.extend(tags.iter().map(ToString::to_string)); + } + } + (path, path_item, types) + } + } + }; + ( ) => {}; +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::extract::State; + use router::*; + + #[utoipa::path(get, path = "/")] + async fn root() {} + + // --- user + + #[utoipa::path(get, path = "/")] + async fn get_user() {} + + #[utoipa::path(post, path = "/")] + async fn post_user() {} + + #[utoipa::path(delete, path = "/")] + async fn delete_user() {} + + #[utoipa::path(get, path = "/search")] + async fn search_user() {} + + // --- customer + + #[utoipa::path(get, path = "/")] + async fn get_customer() {} + + #[utoipa::path(post, path = "/")] + async fn post_customer() {} + + #[utoipa::path(delete, path = "/")] + async fn delete_customer() {} + + // test that with state handler compiles + #[utoipa::path(get, path = "/search")] + async fn search_customer(State(_s): State) {} + + #[test] + fn axum_router_nest_openapi_routes_compile() { + let user_router: OpenApiRouter = OpenApiRouter::new() + .routes(routes!(search_user)) + .routes(routes!(get_user, post_user, delete_user)); + + let customer_router: OpenApiRouter = OpenApiRouter::new() + .routes(routes!(get_customer, post_customer, delete_customer)) + .routes(routes!(search_customer)) + .with_state(String::new()); + + let router = OpenApiRouter::new() + .nest("/api/user", user_router) + .nest("/api/customer", customer_router) + .route("/", axum::routing::get(root)); + + let _ = router.get_openapi(); + } + + #[test] + fn openapi_router_with_openapi() { + use utoipa::OpenApi; + + #[derive(utoipa::ToSchema)] + #[allow(unused)] + struct Todo { + id: i32, + } + #[derive(utoipa::OpenApi)] + #[openapi(components(schemas(Todo)))] + struct Api; + + let mut router: OpenApiRouter = OpenApiRouter::with_openapi(Api::openapi()) + .routes(routes!(search_user)) + .routes(routes!(get_user)); + + let paths = router.to_openapi().paths; + let expected_paths = utoipa::openapi::path::PathsBuilder::new() + .path( + "/", + utoipa::openapi::PathItem::new( + utoipa::openapi::path::PathItemType::Get, + utoipa::openapi::path::OperationBuilder::new().operation_id(Some("get_user")), + ), + ) + .path( + "/search", + utoipa::openapi::PathItem::new( + utoipa::openapi::path::PathItemType::Get, + utoipa::openapi::path::OperationBuilder::new() + .operation_id(Some("search_user")), + ), + ); + assert_eq!(expected_paths.build(), paths); + } + + #[test] + fn openapi_router_nest_openapi() { + use utoipa::OpenApi; + + #[derive(utoipa::ToSchema)] + #[allow(unused)] + struct Todo { + id: i32, + } + #[derive(utoipa::OpenApi)] + #[openapi(components(schemas(Todo)))] + struct Api; + + let router: router::OpenApiRouter = + router::OpenApiRouter::with_openapi(Api::openapi()).routes(routes!(search_user)); + + let customer_router: router::OpenApiRouter = router::OpenApiRouter::new() + .routes(routes!(get_customer)) + .with_state(String::new()); + + let mut router = router.nest("/api/customer", customer_router); + let paths = router.to_openapi().paths; + let expected_paths = utoipa::openapi::path::PathsBuilder::new() + .path( + "/api/customer/", + utoipa::openapi::PathItem::new( + utoipa::openapi::path::PathItemType::Get, + utoipa::openapi::path::OperationBuilder::new() + .operation_id(Some("get_customer")), + ), + ) + .path( + "/search", + utoipa::openapi::PathItem::new( + utoipa::openapi::path::PathItemType::Get, + utoipa::openapi::path::OperationBuilder::new() + .operation_id(Some("search_user")), + ), + ); + assert_eq!(expected_paths.build(), paths); + } +} diff --git a/utoipa-axum/src/router.rs b/utoipa-axum/src/router.rs new file mode 100644 index 00000000..bf42d514 --- /dev/null +++ b/utoipa-axum/src/router.rs @@ -0,0 +1,289 @@ +//! Implements Router for composing handlers and collecting OpenAPI information. +use std::collections::BTreeMap; +use std::convert::Infallible; + +use axum::extract::Request; +use axum::handler::Handler; +use axum::response::IntoResponse; +use axum::routing::{MethodRouter, Route, RouterAsService}; +use axum::Router; +use tower_layer::Layer; +use tower_service::Service; + +#[inline] +fn colonized_params>(path: S) -> String +where + String: From, +{ + String::from(path).replace('}', "").replace('{', ":") +} + +/// Wrapper type for [`utoipa::openapi::path::Paths`] and [`axum::routing::MethodRouter`]. +/// +/// This is used with [`OpenApiRouter::routes`] method to register current _`paths`_ to the +/// [`utoipa::openapi::OpenApi`] of [`OpenApiRouter`] instance. +/// +/// See [`routes`][routes] for usage. +/// +/// [routes]: ../macro.routes.html +pub type UtoipaMethodRouter = + (utoipa::openapi::path::Paths, axum::routing::MethodRouter); + +/// A wrapper struct for [`axum::Router`] and [`utoipa::openapi::OpenApi`] for composing handlers +/// and services with collecting OpenAPI information from the handlers. +/// +/// This struct provides passthrough implementation for most of the [`axum::Router`] methods and +/// extends capabilities for few to collect the OpenAPI information. Methods that are not +/// implemented can be easily called after converting this router to [`axum::Router`] by +/// [`Into::into`]. +#[derive(Clone)] +#[cfg_attr(feature = "debug", derive(Debug))] +pub struct OpenApiRouter(Router, utoipa::openapi::OpenApi); + +impl OpenApiRouter +where + S: Send + Sync + Clone + 'static, +{ + /// Instantiate a new [`OpenApiRouter`] with new empty [`utoipa::openapi::OpenApi`]. + /// + /// This is essentially same as calling + /// _`OpenApiRouter::with_openapi(utoipa::openapi::OpenApiBuilder::new().build())`_. + pub fn new() -> OpenApiRouter { + Self::with_openapi(utoipa::openapi::OpenApiBuilder::new().build()) + } + + /// Instantiates a new [`OpenApiRouter`] with given _`openapi`_ instance. + /// + /// This function allows using existing [`utoipa::openapi::OpenApi`] as source for this router. + /// + /// # Examples + /// + /// _**Use derived [`utoipa::openapi::OpenApi`] as source for [`OpenApiRouter`].**_ + /// ```rust + /// # use utoipa::OpenApi; + /// # use utoipa_axum::router::OpenApiRouter; + /// #[derive(utoipa::ToSchema)] + /// struct Todo { + /// id: i32, + /// } + /// #[derive(utoipa::OpenApi)] + /// #[openapi(components(schemas(Todo)))] + /// struct Api; + /// + /// let mut router: OpenApiRouter = OpenApiRouter::with_openapi(Api::openapi()); + /// ``` + pub fn with_openapi(openapi: utoipa::openapi::OpenApi) -> Self { + Self(Router::new(), openapi) + } + + /// Passthrough method for [`axum::Router::as_service`]. + pub fn as_service(&mut self) -> RouterAsService<'_, B, S> { + self.0.as_service() + } + + /// Passthrough method for [`axum::Router::fallback`]. + pub fn fallback(self, handler: H) -> Self + where + H: Handler, + T: 'static, + { + Self(self.0.fallback(handler), self.1) + } + + /// Passthrough method for [`axum::Router::fallback_service`]. + pub fn fallback_service(self, service: T) -> Self + where + T: Service + Clone + Send + 'static, + T::Response: IntoResponse, + T::Future: Send + 'static, + { + Self(self.0.fallback_service(service), self.1) + } + + /// Passthrough method for [`axum::Router::layer`]. + pub fn layer(self, layer: L) -> Self + where + L: Layer + Clone + Send + 'static, + L::Service: Service + Clone + Send + 'static, + >::Response: IntoResponse + 'static, + >::Error: Into + 'static, + >::Future: Send + 'static, + { + Self(self.0.layer(layer), self.1) + } + + /// Register [`UtoipaMethodRouter`] content created with [`routes`][routes] macro to `self`. + /// + /// Paths of the [`UtoipaMethodRouter`] will be extended to [`utoipa::openapi::OpenApi`] and + /// [`axum::router::MethodRouter`] will be added to the [`axum::Router`]. + /// + /// [routes]: ../macro.routes.html + pub fn routes(mut self, (mut paths, method_router): UtoipaMethodRouter) -> Self { + let router = if paths.paths.len() == 1 { + let first_entry = &paths.paths.first_entry(); + let path = first_entry.as_ref().map(|path| path.key()); + let Some(path) = path else { + unreachable!("Whoopsie, I thought there was one Path entry"); + }; + let path = if path.is_empty() { "/" } else { path }; + + self.0.route(&colonized_params(path), method_router) + } else { + paths.paths.iter().fold(self.0, |this, (path, _)| { + let path = if path.is_empty() { "/" } else { path }; + this.route(&colonized_params(path), method_router.clone()) + }) + }; + + // add current paths to the OpenApi + self.1.paths.paths.extend(paths.paths.clone()); + + Self(router, self.1) + } + + /// Passthrough method for [`axum::Router::route`]. + pub fn route(self, path: &str, method_router: MethodRouter) -> Self { + Self(self.0.route(&colonized_params(path), method_router), self.1) + } + + /// Passthrough method for [`axum::Router::route_layer`]. + pub fn route_layer(self, layer: L) -> Self + where + L: Layer + Clone + Send + 'static, + L::Service: Service + Clone + Send + 'static, + >::Response: IntoResponse + 'static, + >::Error: Into + 'static, + >::Future: Send + 'static, + { + Self(self.0.route_layer(layer), self.1) + } + + /// Passthrough method for [`axum::Router::route_service`]. + pub fn route_service(self, path: &str, service: T) -> Self + where + T: Service + Clone + Send + 'static, + T::Response: IntoResponse, + T::Future: Send + 'static, + { + Self(self.0.route_service(path, service), self.1) + } + + /// Nest `router` to `self` under given `path`. Router routes will be nestsed with + /// [`axum::Router::nest`]. + /// + /// This method expects [`OpenApiRouter`] instance in order to nest OpenApi paths and router + /// routes. If you wish to use [`axum::Router::nest`] you need to first convert this instance + /// to [`axum::Router`] _(`let _: Router = OpenApiRouter::new().into()`)_. + /// + /// # Examples + /// + /// _**Nest two routers.**_ + /// ```rust + /// # use utoipa_axum::{routes, PathItemExt, router::OpenApiRouter}; + /// #[utoipa::path(get, path = "/search")] + /// async fn search() {} + /// + /// let search_router = OpenApiRouter::new() + /// .routes(utoipa_axum::routes!(search)); + /// + /// let router: OpenApiRouter = OpenApiRouter::new() + /// .nest("/api", search_router); + /// ``` + pub fn nest(mut self, path: &str, router: OpenApiRouter) -> Self { + let nested_paths = router + .1 + .paths + .paths + .into_iter() + .map(|(item_path, item)| { + let path = format!("{path}{item_path}"); + (path, item) + }) + .collect::>(); + + self.1.paths.paths.extend(nested_paths); + + let path = if path.is_empty() { "/" } else { path }; + let router = self.0.nest(&colonized_params(path), router.0); + Self(router, self.1) + } + + /// Passthrough method for [`axum::Router::nest_service`]. _**This does nothing for OpenApi paths.**_ + pub fn nest_service(self, path: &str, service: T) -> Self + where + T: Service + Clone + Send + 'static, + T::Response: IntoResponse, + T::Future: Send + 'static, + { + Self(self.0.nest_service(path, service), self.1) + } + + /// Merge [`utoipa::openapi::path::Paths`] from `router` to `self` and merge [`Router`] routes + /// and fallback with [`axum::Router::merge`]. + /// + /// This method expects [`OpenApiRouter`] instance in order to merge OpenApi paths and router + /// routes. If you wish to use [`axum::Router::merge`] you need to first convert this instance + /// to [`axum::Router`] _(`let _: Router = OpenApiRouter::new().into()`)_. + /// + /// # Examples + /// + /// _**Merge two routers.**_ + /// ```rust + /// # use utoipa_axum::{routes, PathItemExt, router::OpenApiRouter}; + /// #[utoipa::path(get, path = "/search")] + /// async fn search() {} + /// + /// let search_router = OpenApiRouter::new() + /// .routes(utoipa_axum::routes!(search)); + /// + /// let router: OpenApiRouter = OpenApiRouter::new() + /// .merge(search_router); + /// ``` + pub fn merge(mut self, router: OpenApiRouter) -> Self { + self.1.paths.paths.extend(router.1.paths.paths); + + Self(self.0.merge(router.0), self.1) + } + + /// Passthrough method for [`axum::Router::with_state`]. + pub fn with_state(self, state: S) -> OpenApiRouter { + OpenApiRouter(self.0.with_state(state), self.1) + } + + /// Consume `self` returning the [`utoipa::openapi::OpenApi`] instance of the + /// [`OpenApiRouter`]. + pub fn into_openapi(self) -> utoipa::openapi::OpenApi { + self.1 + } + + /// Take the [`utoipa::openapi::OpenApi`] instance without consuming the [`OpenApiRouter`]. + pub fn to_openapi(&mut self) -> utoipa::openapi::OpenApi { + std::mem::take(&mut self.1) + } + + /// Get reference to the [`utoipa::openapi::OpenApi`] instance of the router. + pub fn get_openapi(&self) -> &utoipa::openapi::OpenApi { + &self.1 + } +} + +impl Default for OpenApiRouter +where + S: Send + Sync + Clone + 'static, +{ + fn default() -> Self { + Self::new() + } +} + +impl From> for Router { + fn from(value: OpenApiRouter) -> Self { + value.0 + } +} + +impl From> for OpenApiRouter { + fn from(value: Router) -> Self { + OpenApiRouter(value, utoipa::openapi::OpenApiBuilder::new().build()) + } +} diff --git a/utoipa-gen/src/ext.rs b/utoipa-gen/src/ext.rs index 78b57cef..61d49ba0 100644 --- a/utoipa-gen/src/ext.rs +++ b/utoipa-gen/src/ext.rs @@ -339,8 +339,8 @@ pub mod fn_arg { /// Http operation handler functions fn argument. #[cfg_attr(feature = "debug", derive(Debug))] pub struct FnArg<'a> { - pub(super) ty: TypeTree<'a>, - pub(super) arg_type: FnArgType<'a>, + pub ty: TypeTree<'a>, + pub arg_type: FnArgType<'a>, } #[cfg_attr(feature = "debug", derive(Debug))] diff --git a/utoipa-gen/src/lib.rs b/utoipa-gen/src/lib.rs index 6a12dcca..8acc947c 100644 --- a/utoipa-gen/src/lib.rs +++ b/utoipa-gen/src/lib.rs @@ -274,7 +274,7 @@ use self::{ /// This attribute requires that a `tag` is present, otherwise serde will trigger a compile-time /// failure. /// * `untagged` Supported at the container level. Allows [untagged -/// enum representation](https://serde.rs/enum-representations.html#untagged). +/// enum representation](https://serde.rs/enum-representations.html#untagged). /// * `default` Supported at the container level and field level according to [serde attributes]. /// * `deny_unknown_fields` Supported at the container level. /// * `flatten` Supported at the field level. @@ -806,9 +806,9 @@ pub fn derive_to_schema(input: TokenStream) -> TokenStream { /// [primitive Rust types][primitive], `application/octet-stream` for _`[u8]`_ and /// _`application/json`_ for struct and complex enum types. /// Content type can also be slice of **content_type** values if the endpoint support returning multiple -/// response content types. E.g _`["application/json", "text/xml"]`_ would indicate that endpoint can return both -/// _`json`_ and _`xml`_ formats. **The order** of the content types define the default example show first in -/// the Swagger UI. Swagger UI will use the first _`content_type`_ value as a default example. +/// response content types. E.g _`["application/json", "text/xml"]`_ would indicate that endpoint can return both +/// _`json`_ and _`xml`_ formats. **The order** of the content types define the default example show first in +/// the Swagger UI. Swagger UI will use the first _`content_type`_ value as a default example. /// /// * `headers(...)` Slice of response headers that are returned back to a caller. /// @@ -1141,7 +1141,7 @@ pub fn derive_to_schema(input: TokenStream) -> TokenStream { /// 1. It allows users to use tuple style path parameters e.g. _`Path((id, name)): Path<(i32, String)>`_ and resolves /// parameter names and types from it. /// 2. It enhances [`IntoParams` derive][into_params_derive] functionality by automatically resolving _`parameter_in`_ from -/// _`Path<...>`_ or _`Query<...>`_ handler function arguments. +/// _`Path<...>`_ or _`Query<...>`_ handler function arguments. /// /// _**Resole path argument types from tuple style handler arguments.**_ /// ```rust @@ -1378,7 +1378,6 @@ pub fn path(attr: TokenStream, item: TokenStream) -> TokenStream { Ok(ast_fn) => ast_fn, Err(error) => return error.into_compile_error().into_token_stream().into(), }; - let fn_name = &*ast_fn.sig.ident.to_string(); #[cfg(feature = "auto_into_responses")] { @@ -1413,14 +1412,14 @@ pub fn path(attr: TokenStream, item: TokenStream) -> TokenStream { { use ext::ArgumentResolver; use path::parameter::Parameter; - let args = resolved_path.as_mut().map(|path| mem::take(&mut path.args)); + let path_args = resolved_path.as_mut().map(|path| mem::take(&mut path.args)); let body = resolved_operation .as_mut() .map(|path| mem::take(&mut path.body)) .unwrap_or_default(); let (arguments, into_params_types, body) = - match PathOperations::resolve_arguments(&ast_fn.sig.inputs, args, body) { + match PathOperations::resolve_arguments(&ast_fn.sig.inputs, path_args, body) { Ok(args) => args, Err(diagnostics) => return diagnostics.into_token_stream().into(), }; @@ -1435,7 +1434,7 @@ pub fn path(attr: TokenStream, item: TokenStream) -> TokenStream { path_attribute.update_request_body(body); } - let path = Path::new(path_attribute, fn_name) + let path = Path::new(path_attribute, &ast_fn.sig.ident) .path_operation(resolved_operation.map(|operation| operation.path_operation)) .path(|| resolved_path.map(|path| path.path)) .doc_comments(CommentAttributes::from_attributes(&ast_fn.attrs).0) @@ -1449,13 +1448,11 @@ pub fn path(attr: TokenStream, item: TokenStream) -> TokenStream { } })); - let path_tokens = path.to_token_stream(); - - quote! { - #path_tokens - #ast_fn - } - .into() + let handler = path::handler::Handler { + path, + handler_fn: &ast_fn, + }; + handler.to_token_stream().into() } #[proc_macro_derive(OpenApi, attributes(openapi))] @@ -2181,9 +2178,9 @@ pub fn into_params(input: TokenStream) -> TokenStream { /// [primitive Rust types][primitive], `application/octet-stream` for _`[u8]`_ and /// _`application/json`_ for struct and complex enum types. /// Content type can also be slice of **content_type** values if the endpoint support returning multiple -/// response content types. E.g _`["application/json", "text/xml"]`_ would indicate that endpoint can return both -/// _`json`_ and _`xml`_ formats. **The order** of the content types define the default example show first in -/// the Swagger UI. Swagger UI will use the first _`content_type`_ value as a default example. +/// response content types. E.g _`["application/json", "text/xml"]`_ would indicate that endpoint can return both +/// _`json`_ and _`xml`_ formats. **The order** of the content types define the default example show first in +/// the Swagger UI. Swagger UI will use the first _`content_type`_ value as a default example. /// /// * `headers(...)` Slice of response headers that are returned back to a caller. /// @@ -2348,9 +2345,9 @@ pub fn to_response(input: TokenStream) -> TokenStream { /// [primitive Rust types][primitive], `application/octet-stream` for _`[u8]`_ and /// _`application/json`_ for struct and complex enum types. /// Content type can also be slice of **content_type** values if the endpoint support returning multiple -/// response content types. E.g _`["application/json", "text/xml"]`_ would indicate that endpoint can return both -/// _`json`_ and _`xml`_ formats. **The order** of the content types define the default example show first in -/// the Swagger UI. Swagger UI will use the first _`content_type`_ value as a default example. +/// response content types. E.g _`["application/json", "text/xml"]`_ would indicate that endpoint can return both +/// _`json`_ and _`xml`_ formats. **The order** of the content types define the default example show first in +/// the Swagger UI. Swagger UI will use the first _`content_type`_ value as a default example. /// /// * `headers(...)` Slice of response headers that are returned back to a caller. /// diff --git a/utoipa-gen/src/openapi.rs b/utoipa-gen/src/openapi.rs index 7d114119..5d1c00e2 100644 --- a/utoipa-gen/src/openapi.rs +++ b/utoipa-gen/src/openapi.rs @@ -1,3 +1,5 @@ +use std::borrow::Cow; + use proc_macro2::Ident; use syn::{ bracketed, parenthesized, @@ -11,11 +13,8 @@ use syn::{ use proc_macro2::TokenStream; use quote::{format_ident, quote, quote_spanned, ToTokens}; -use crate::parse_utils::Str; -use crate::{ - parse_utils, path::PATH_STRUCT_PREFIX, security_requirement::SecurityRequirementsAttr, Array, - ExternalDocs, -}; +use crate::{parse_utils, security_requirement::SecurityRequirementsAttr, Array, ExternalDocs}; +use crate::{parse_utils::Str, path}; use self::info::Info; @@ -606,10 +605,9 @@ fn impl_paths(handler_paths: &Punctuated) -> TokenStream { .iter() .map(|handler| { let segments = handler.path.segments.iter().collect::>(); - let handler_fn_name = &*segments.last().unwrap().ident.to_string(); - let handler_ident = format_ident!("{}{}", PATH_STRUCT_PREFIX, handler_fn_name); - let handler_ident_name = &*handler_ident.to_string(); - let handler_ident_nested = format_ident!("__{}{}", PATH_STRUCT_PREFIX, handler_fn_name); + let handler_fn = &segments.last().unwrap().ident; + let handler_ident = path::format_path_ident(Cow::Borrowed(handler_fn)); + let handler_ident_nested = format_ident!("_{}", handler_ident.as_ref()); let tag = &*segments .iter() @@ -621,7 +619,7 @@ fn impl_paths(handler_paths: &Punctuated) -> TokenStream { let usage = syn::parse_str::( &vec![ if tag.is_empty() { None } else { Some(tag) }, - Some(handler_ident_name), + Some(&*handler_ident.as_ref().to_string()), ] .into_iter() .flatten() @@ -631,6 +629,7 @@ fn impl_paths(handler_paths: &Punctuated) -> TokenStream { .unwrap(); quote! { + #[allow(non_camel_case_types)] struct #handler_ident_nested; #[allow(non_camel_case_types)] impl utoipa::__dev::PathConfig for #handler_ident_nested { @@ -653,8 +652,9 @@ fn impl_paths(handler_paths: &Punctuated) -> TokenStream { quote! { #handlers utoipa::openapi::path::PathsBuilder::new() }, |mut paths, handler| { let segments = handler.path.segments.iter().collect::>(); - let handler_fn_name = &*segments.last().unwrap().ident.to_string(); - let handler_ident_nested = format_ident!("__{}{}", PATH_STRUCT_PREFIX, handler_fn_name); + let handler_fn = &segments.last().unwrap().ident; + let handler_ident_nested = + format_ident!("_{}", path::format_path_ident(Cow::Borrowed(handler_fn))); paths.extend(quote! { .path_from::<#handler_ident_nested>() diff --git a/utoipa-gen/src/path.rs b/utoipa-gen/src/path.rs index 0e5e10c6..6629daf3 100644 --- a/utoipa-gen/src/path.rs +++ b/utoipa-gen/src/path.rs @@ -3,7 +3,7 @@ use std::ops::Deref; use std::{io::Error, str::FromStr}; use proc_macro2::{Ident, Span, TokenStream as TokenStream2}; -use quote::{format_ident, quote, quote_spanned, ToTokens}; +use quote::{quote, quote_spanned, ToTokens}; use syn::punctuated::Punctuated; use syn::spanned::Spanned; use syn::token::Paren; @@ -19,12 +19,23 @@ use self::response::Response; use self::{parameter::Parameter, request_body::RequestBodyAttr, response::Responses}; pub mod example; +pub mod handler; pub mod parameter; mod request_body; pub mod response; mod status; -pub(crate) const PATH_STRUCT_PREFIX: &str = "__path_"; +const PATH_STRUCT_PREFIX: &str = "__path_"; + +#[inline] +pub fn format_path_ident(fn_name: Cow<'_, Ident>) -> Cow<'_, Ident> { + { + Cow::Owned(quote::format_ident!( + "{PATH_STRUCT_PREFIX}{}", + fn_name.as_ref() + )) + } +} #[derive(Default)] #[cfg_attr(feature = "debug", derive(Debug))] @@ -267,7 +278,7 @@ impl ToTokens for PathOperation { } pub struct Path<'p> { path_attr: PathAttr<'p>, - fn_name: String, + fn_ident: &'p Ident, path_operation: Option, path: Option, doc_comments: Option>, @@ -275,10 +286,10 @@ pub struct Path<'p> { } impl<'p> Path<'p> { - pub fn new(path_attr: PathAttr<'p>, fn_name: &str) -> Self { + pub fn new(path_attr: PathAttr<'p>, fn_ident: &'p Ident) -> Self { Self { path_attr, - fn_name: fn_name.to_string(), + fn_ident, path_operation: None, path: None, doc_comments: None, @@ -313,6 +324,7 @@ impl<'p> Path<'p> { impl<'p> ToTokensDiagnostics for Path<'p> { fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) -> Result<(), Diagnostics> { + let fn_name = &*self.fn_ident.to_string(); let operation_id = self .path_attr .operation_id @@ -320,7 +332,7 @@ impl<'p> ToTokensDiagnostics for Path<'p> { .or(Some( ExprLit { attrs: vec![], - lit: Lit::Str(LitStr::new(&self.fn_name, Span::call_site())), + lit: Lit::Str(LitStr::new(&fn_name, Span::call_site())), } .into(), )) @@ -328,7 +340,7 @@ impl<'p> ToTokensDiagnostics for Path<'p> { Diagnostics::new("operation id is not defined for path") .help(format!( "Try to define it in #[utoipa::path(operation_id = {})]", - &self.fn_name + &fn_name )) .help("Did you define the #[utoipa::path(...)] over function?") })?; @@ -452,12 +464,14 @@ impl<'p> ToTokensDiagnostics for Path<'p> { let tags_list = tags.into_iter().collect::>(); let impl_for = if let Some(impl_for) = &self.path_attr.impl_for { - impl_for.clone() + Cow::Borrowed(impl_for) } else { - let path_struct = format_ident!("{}{}", PATH_STRUCT_PREFIX, self.fn_name); + let path_struct = format_path_ident(Cow::Borrowed(self.fn_ident)); + tokens.extend(quote! { #[allow(non_camel_case_types)] #[doc(hidden)] + #[derive(Clone)] pub struct #path_struct; }); path_struct @@ -469,6 +483,11 @@ impl<'p> ToTokensDiagnostics for Path<'p> { #tags_list.into() } } + impl utoipa::__dev::PathItemTypes for #impl_for { + fn path_item_types() -> Vec { + [#path_operation].into() + } + } impl utoipa::Path for #impl_for { fn path() -> String { #path_with_context_path diff --git a/utoipa-gen/src/path/handler.rs b/utoipa-gen/src/path/handler.rs new file mode 100644 index 00000000..30fbd1ad --- /dev/null +++ b/utoipa-gen/src/path/handler.rs @@ -0,0 +1,24 @@ +use quote::quote; +use syn::ItemFn; + +use crate::{as_tokens_or_diagnostics, ToTokensDiagnostics}; + +use super::Path; + +pub struct Handler<'p> { + pub path: Path<'p>, + pub handler_fn: &'p ItemFn, +} + +impl<'p> ToTokensDiagnostics for Handler<'p> { + fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) -> Result<(), crate::Diagnostics> { + let ast_fn = &self.handler_fn; + let path = as_tokens_or_diagnostics!(&self.path); + tokens.extend(quote! { + #path + #ast_fn + }); + + Ok(()) + } +} diff --git a/utoipa-gen/tests/path_derive.rs b/utoipa-gen/tests/path_derive.rs index 6efd9b20..bca33d8c 100644 --- a/utoipa-gen/tests/path_derive.rs +++ b/utoipa-gen/tests/path_derive.rs @@ -119,7 +119,7 @@ macro_rules! api_fn_doc_with_params { ) )] #[allow(unused)] - fn my_operation(params: MyParams) -> String { + async fn my_operation(params: MyParams) -> String { "".to_string() } @@ -2052,7 +2052,7 @@ fn derive_path_with_multiple_tags() { ), )] #[allow(unused)] - fn get_items() -> String { + async fn get_items() -> String { "".to_string() } @@ -2097,7 +2097,7 @@ split to multiple lines"; ), )] #[allow(unused)] - fn test_description_summary() -> &'static str { + async fn test_description_summary() -> &'static str { "" } @@ -2135,7 +2135,7 @@ fn derive_path_include_str_description() { ), )] #[allow(unused)] - fn test_description_summary() -> &'static str { + async fn test_description_summary() -> &'static str { "" } diff --git a/utoipa-gen/tests/path_derive_axum_test.rs b/utoipa-gen/tests/path_derive_axum_test.rs index 2a617b7c..304dcdaa 100644 --- a/utoipa-gen/tests/path_derive_axum_test.rs +++ b/utoipa-gen/tests/path_derive_axum_test.rs @@ -151,8 +151,8 @@ fn get_todo_with_extension() { } /// In-memory todo store type Store = Mutex>; - /// List all Todo items + /// List all Todo items /// /// List all Todo items from in-memory storage. #[utoipa::path( @@ -163,7 +163,7 @@ fn get_todo_with_extension() { ) )] #[allow(unused)] - fn list_todos(Extension(store): Extension>) {} + async fn list_todos(Extension(store): Extension>) {} #[derive(OpenApi)] #[openapi(paths(list_todos))] @@ -646,7 +646,7 @@ fn derive_path_with_validation_attributes_axum() { ) )] #[allow(unused)] - fn get_foo(path: Path, query: Query) {} + async fn get_foo(path: Path, query: Query) {} #[derive(OpenApi, Default)] #[openapi(paths(get_foo))] diff --git a/utoipa/src/lib.rs b/utoipa/src/lib.rs index 3d530660..85b0a4ee 100644 --- a/utoipa/src/lib.rs +++ b/utoipa/src/lib.rs @@ -940,6 +940,7 @@ pub trait ToResponse<'__r> { #[doc(hidden)] pub mod __dev { + use crate::openapi::PathItemType; use crate::{utoipa, OpenApi}; pub trait PathConfig { @@ -990,6 +991,10 @@ pub mod __dev { api } } + + pub trait PathItemTypes { + fn path_item_types() -> Vec; + } } #[cfg(test)] diff --git a/utoipa/src/openapi/path.rs b/utoipa/src/openapi/path.rs index a6a063e7..43943fc2 100644 --- a/utoipa/src/openapi/path.rs +++ b/utoipa/src/openapi/path.rs @@ -86,6 +86,21 @@ impl Paths { .get(path.as_ref()) .and_then(|path| path.operations.get(&item_type)) } + + /// Append [`PathItem`] with path to map of paths. If path already exists it will merge [`Operation`]s of + /// [`PathItem`] with already found path item operations. + /// + /// This is same operation as [`PathsBuilder::path`] but does not move. + pub fn add_path>(&mut self, path: I, item: PathItem) { + let path_string = path.into(); + if let Some(existing_item) = self.paths.get_mut(&path_string) { + existing_item + .operations + .extend(&mut item.operations.into_iter()); + } else { + self.paths.insert(path_string, item); + } + } } impl PathsBuilder {