From 2bfbee7a967603c1619caf5250f3eb0513f3fa25 Mon Sep 17 00:00:00 2001 From: Juha Kukkonen Date: Tue, 22 Oct 2024 22:35:09 +0300 Subject: [PATCH] Add implementation for utoipa-actix-web bindings (#1158) Implements wrappers for `ServiceConfig`, `App`, `Scope` of actix-web. This allows users to create `App` with collecting `paths` and `schemas` recursively without registering them to `#[openapi(...)]` attribute. Example of new supported syntax. ```rust use actix_web::{get, App}; use utoipa_actix_web::{scope, AppExt}; #[derive(utoipa::ToSchema)] struct User { id: i32, } #[utoipa::path(responses((status = OK, body = User)))] #[get("/user")] async fn get_user() -> Json { Json(User { id: 1 }) } let (_, mut api) = App::new() .into_utoipa_app() .service(scope::scope("/api/v1").service(get_user)) .split_for_parts(); ``` Relates #283 Relates #662 Closes #121 Closes #657 --- .github/workflows/build.yaml | 5 +- .github/workflows/draft.yaml | 1 + Cargo.toml | 2 + scripts/test.sh | 4 +- utoipa-actix-web/CHANGELOG.md | 7 + utoipa-actix-web/Cargo.toml | 33 ++ utoipa-actix-web/LICENSE-APACHE | 1 + utoipa-actix-web/LICENSE-MIT | 1 + utoipa-actix-web/README.md | 54 ++ utoipa-actix-web/src/lib.rs | 481 ++++++++++++++++++ utoipa-actix-web/src/scope.rs | 239 +++++++++ utoipa-actix-web/src/service_config.rs | 96 ++++ .../testdata/app_generated_openapi | 140 +++++ utoipa-gen/CHANGELOG.md | 6 + utoipa-gen/src/path.rs | 46 +- utoipa/CHANGELOG.md | 6 + utoipa/src/openapi/path.rs | 8 +- 17 files changed, 1120 insertions(+), 10 deletions(-) create mode 100644 utoipa-actix-web/CHANGELOG.md create mode 100644 utoipa-actix-web/Cargo.toml create mode 120000 utoipa-actix-web/LICENSE-APACHE create mode 120000 utoipa-actix-web/LICENSE-MIT create mode 100644 utoipa-actix-web/README.md create mode 100644 utoipa-actix-web/src/lib.rs create mode 100644 utoipa-actix-web/src/scope.rs create mode 100644 utoipa-actix-web/src/service_config.rs create mode 100644 utoipa-actix-web/testdata/app_generated_openapi diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index a32093c8..5e441b35 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -27,6 +27,7 @@ jobs: - utoipa-scalar - utoipa-axum - utoipa-config + - utoipa-actix-web fail-fast: true runs-on: ubuntu-latest @@ -72,6 +73,8 @@ jobs: changes=true elif [[ "$change" == "utoipa-config" && "${{ matrix.crate }}" == "utoipa-config" && $changes == false ]]; then changes=true + elif [[ "$change" == "utoipa-actix-web" && "${{ matrix.crate }}" == "utoipa-actix-web" && $changes == false ]]; then + changes=true fi done < <(git diff --name-only ${{ github.sha }}~ ${{ github.sha }} | grep .rs | awk -F \/ '{print $1}') echo "${{ matrix.crate }} changes: $changes" @@ -134,7 +137,7 @@ jobs: ~/.cargo/git/db/ examples/**/target/ key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}_examples - + - name: Test that examples compile run: | ./scripts/validate-examples.sh diff --git a/.github/workflows/draft.yaml b/.github/workflows/draft.yaml index 553db5dd..092ae761 100644 --- a/.github/workflows/draft.yaml +++ b/.github/workflows/draft.yaml @@ -22,6 +22,7 @@ jobs: - utoipa-scalar - utoipa-axum - utoipa-config + - utoipa-actix-web runs-on: ubuntu-latest steps: diff --git a/Cargo.toml b/Cargo.toml index 4468b2e9..e6005022 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,7 @@ members = [ "utoipa-scalar", "utoipa-axum", "utoipa-config", + "utoipa-actix-web", ] [workspace.metadata.publish] @@ -26,4 +27,5 @@ order = [ "utoipa-rapidoc", "utoipa-scalar", "utoipa-axum", + "utoipa-actix-web", ] diff --git a/scripts/test.sh b/scripts/test.sh index b142fcc0..6f502e18 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -6,7 +6,7 @@ set -e : "${CARGO:=cargo}" : "${CARGO_COMMAND:=test}" -crates="${1:-utoipa utoipa-gen utoipa-swagger-ui utoipa-redoc utoipa-rapidoc utoipa-scalar utoipa-axum}" +crates="${1:-utoipa utoipa-gen utoipa-swagger-ui utoipa-redoc utoipa-rapidoc utoipa-scalar utoipa-axum utoipa-config utoipa-actix-web}" for crate in $crates; do echo "Testing crate: $crate..." @@ -44,5 +44,7 @@ for crate in $crates; do pushd utoipa-config/config-test-crate/ $CARGO ${CARGO_COMMAND} popd + elif [[ "$crate" == "utoipa-actix-web" ]]; then + $CARGO ${CARGO_COMMAND} -p utoipa-actix-web fi done diff --git a/utoipa-actix-web/CHANGELOG.md b/utoipa-actix-web/CHANGELOG.md new file mode 100644 index 00000000..de99bc39 --- /dev/null +++ b/utoipa-actix-web/CHANGELOG.md @@ -0,0 +1,7 @@ +# Changelog - utoipa-actix-web + +## Unreleased + +### Added + +* Add implementation for utoipa-actix-web bindings (https://github.com/juhaku/utoipa/pull/1158) diff --git a/utoipa-actix-web/Cargo.toml b/utoipa-actix-web/Cargo.toml new file mode 100644 index 00000000..8b776ff9 --- /dev/null +++ b/utoipa-actix-web/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "utoipa-actix-web" +description = "Utoipa's actix-web bindings for seamless integration of the two" +version = "0.1.0" +edition = "2021" +license = "MIT OR Apache-2.0" +readme = "README.md" +keywords = ["utoipa", "actix-web", "bindings"] +repository = "https://github.com/juhaku/utoipa" +categories = ["web-programming"] +authors = ["Juha Kukkonen "] +rust-version.workspace = true + +[dependencies] +utoipa = { path = "../utoipa", version = "5" } +actix-web = { version = "4", default-features = false } +actix-service = "2" + +[dev-dependencies] +utoipa = { path = "../utoipa", version = "5", features = [ + "actix_extras", + "macros", + "debug", +] } +actix-web = { version = "4", default-features = false, features = ["macros"] } +serde = "1" + +[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-actix-web/LICENSE-APACHE b/utoipa-actix-web/LICENSE-APACHE new file mode 120000 index 00000000..965b606f --- /dev/null +++ b/utoipa-actix-web/LICENSE-APACHE @@ -0,0 +1 @@ +../LICENSE-APACHE \ No newline at end of file diff --git a/utoipa-actix-web/LICENSE-MIT b/utoipa-actix-web/LICENSE-MIT new file mode 120000 index 00000000..76219eb7 --- /dev/null +++ b/utoipa-actix-web/LICENSE-MIT @@ -0,0 +1 @@ +../LICENSE-MIT \ No newline at end of file diff --git a/utoipa-actix-web/README.md b/utoipa-actix-web/README.md new file mode 100644 index 00000000..cce47af0 --- /dev/null +++ b/utoipa-actix-web/README.md @@ -0,0 +1,54 @@ +# utoipa-actix-web - Bindings for Actix Web and utoipa + +[![Utoipa build](https://github.com/juhaku/utoipa/actions/workflows/build.yaml/badge.svg)](https://github.com/juhaku/utoipa/actions/workflows/build.yaml) +[![crates.io](https://img.shields.io/crates/v/utoipa-actix-web.svg?label=crates.io&color=orange&logo=rust)](https://crates.io/crates/utoipa-actix-web) +[![docs.rs](https://img.shields.io/static/v1?label=docs.rs&message=utoipa-actix-web&color=blue&logo=data:image/svg+xml;base64,PHN2ZyByb2xlPSJpbWciIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgdmlld0JveD0iMCAwIDUxMiA1MTIiPjxwYXRoIGZpbGw9IiNmNWY1ZjUiIGQ9Ik00ODguNiAyNTAuMkwzOTIgMjE0VjEwNS41YzAtMTUtOS4zLTI4LjQtMjMuNC0zMy43bC0xMDAtMzcuNWMtOC4xLTMuMS0xNy4xLTMuMS0yNS4zIDBsLTEwMCAzNy41Yy0xNC4xIDUuMy0yMy40IDE4LjctMjMuNCAzMy43VjIxNGwtOTYuNiAzNi4yQzkuMyAyNTUuNSAwIDI2OC45IDAgMjgzLjlWMzk0YzAgMTMuNiA3LjcgMjYuMSAxOS45IDMyLjJsMTAwIDUwYzEwLjEgNS4xIDIyLjEgNS4xIDMyLjIgMGwxMDMuOS01MiAxMDMuOSA1MmMxMC4xIDUuMSAyMi4xIDUuMSAzMi4yIDBsMTAwLTUwYzEyLjItNi4xIDE5LjktMTguNiAxOS45LTMyLjJWMjgzLjljMC0xNS05LjMtMjguNC0yMy40LTMzLjd6TTM1OCAyMTQuOGwtODUgMzEuOXYtNjguMmw4NS0zN3Y3My4zek0xNTQgMTA0LjFsMTAyLTM4LjIgMTAyIDM4LjJ2LjZsLTEwMiA0MS40LTEwMi00MS40di0uNnptODQgMjkxLjFsLTg1IDQyLjV2LTc5LjFsODUtMzguOHY3NS40em0wLTExMmwtMTAyIDQxLjQtMTAyLTQxLjR2LS42bDEwMi0zOC4yIDEwMiAzOC4ydi42em0yNDAgMTEybC04NSA0Mi41di03OS4xbDg1LTM4Ljh2NzUuNHptMC0xMTJsLTEwMiA0MS40LTEwMi00MS40di0uNmwxMDItMzguMiAxMDIgMzguMnYuNnoiPjwvcGF0aD48L3N2Zz4K)](https://docs.rs/utoipa-actix-web/latest/) +![rustc](https://img.shields.io/static/v1?label=rustc&message=1.75&color=orange&logo=rust) + +This crate implements necessary bindings for automatically collecting `paths` and `schemas` recursively from Actix Web +`App`, `Scope` and `ServiceConfig`. It provides natural API reducing duplication and support for scopes while generating +OpenAPI specification without the need to declare `paths` and `schemas` to `#[openapi(...)]` attribute of `OpenApi` derive. + +Currently only `service(...)` calls supports automatic collection of schemas and paths. Manual routes via `route(...)` or +`Route::new().to(...)` is not supported. + +## Install + +Add dependency declaration to `Cargo.toml`. + +```toml +[dependencies] +utoipa-actix-web = "0.1" +``` + +## Examples + +Collect handlers annotated with `#[utoipa::path]` recursively from `service(...)` calls to compose OpenAPI spec. + +```rust +use actix_web::{get, App}; +use utoipa_actix_web::{scope, AppExt}; + +#[derive(utoipa::ToSchema)] +struct User { + id: i32, +} + +#[utoipa::path(responses((status = OK, body = User)))] +#[get("/user")] +async fn get_user() -> Json { + Json(User { id: 1 }) +} + +let (_, mut api) = App::new() + .into_utoipa_app() + .service(scope::scope("/api/v1").service(get_user)) + .split_for_parts(); +``` + +## 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-actix-web/src/lib.rs b/utoipa-actix-web/src/lib.rs new file mode 100644 index 00000000..8a997fef --- /dev/null +++ b/utoipa-actix-web/src/lib.rs @@ -0,0 +1,481 @@ +//! This crate implements necessary bindings for automatically collecting `paths` and `schemas` recursively from Actix Web +//! `App`, `Scope` and `ServiceConfig`. It provides natural API reducing duplication and support for scopes while generating +//! OpenAPI specification without the need to declare `paths` and `schemas` to `#[openapi(...)]` attribute of `OpenApi` derive. +//! +//! Currently only `service(...)` calls supports automatic collection of schemas and paths. Manual routes via `route(...)` or +//! `Route::new().to(...)` is not supported. +//! +//! ## Install +//! +//! Add dependency declaration to `Cargo.toml`. +//! +//! ```toml +//! [dependencies] +//! utoipa-actix-web = "0.1" +//! ``` +//! +//! ## Examples +//! +//! _**Collect handlers annotated with `#[utoipa::path]` recursively from `service(...)` calls to compose OpenAPI spec.**_ +//! +//! ```rust +//! use actix_web::web::Json; +//! use actix_web::{get, App}; +//! use utoipa_actix_web::{scope, AppExt}; +//! +//! #[derive(utoipa::ToSchema, serde::Serialize)] +//! struct User { +//! id: i32, +//! } +//! +//! #[utoipa::path(responses((status = OK, body = User)))] +//! #[get("/user")] +//! async fn get_user() -> Json { +//! Json(User { id: 1 }) +//! } +//! +//! let (_, mut api) = App::new() +//! .into_utoipa_app() +//! .service(scope::scope("/api/v1").service(get_user)) +//! .split_for_parts(); +//! ``` + +#![cfg_attr(doc_cfg, feature(doc_cfg))] +#![warn(missing_docs)] +#![warn(rustdoc::broken_intra_doc_links)] + +use core::fmt; +use std::future::Future; +use std::rc::Rc; + +use actix_service::{IntoServiceFactory, ServiceFactory}; +use actix_web::dev::{HttpServiceFactory, ServiceRequest, ServiceResponse}; +use actix_web::Error; +use utoipa::openapi::PathItem; +use utoipa::OpenApi; + +use self::service_config::ServiceConfig; + +pub mod scope; +pub mod service_config; + +pub use scope::scope; + +/// This trait is used to unify OpenAPI items collection from types implementing this trait. +pub trait OpenApiFactory { + /// Get OpenAPI paths. + fn paths(&self) -> utoipa::openapi::path::Paths; + /// Collect schema reference and append them to the _`schemas`_. + fn schemas( + &self, + schemas: &mut Vec<( + String, + utoipa::openapi::RefOr, + )>, + ); +} + +impl OpenApiFactory for T { + fn paths(&self) -> utoipa::openapi::path::Paths { + let methods = T::methods(); + + methods + .into_iter() + .fold( + utoipa::openapi::path::Paths::builder(), + |mut builder, method| { + builder = builder.path(T::path(), PathItem::new(method, T::operation())); + + builder + }, + ) + .build() + } + + fn schemas( + &self, + schemas: &mut Vec<( + String, + utoipa::openapi::RefOr, + )>, + ) { + ::schemas(schemas); + } +} + +/// Extends [`actix_web::App`] with `utoipa` related functionality. +pub trait AppExt { + /// Convert's this [`actix_web::App`] to [`UtoipaApp`]. + /// + /// See usage from [`UtoipaApp`][struct@UtoipaApp] + fn into_utoipa_app(self) -> UtoipaApp; +} + +impl AppExt for actix_web::App { + fn into_utoipa_app(self) -> UtoipaApp { + UtoipaApp::from(self) + } +} + +/// Wrapper type for [`actix_web::App`] and [`utoipa::openapi::OpenApi`]. +/// +/// [`UtoipaApp`] behaves exactly same way as [`actix_web::App`] but allows automatic _`schema`_ and +/// _`path`_ collection from `service(...)` calls directly or via [`ServiceConfig::service`]. +/// +/// It exposes typical methods from [`actix_web::App`] and provides custom [`UtoipaApp::map`] +/// method to add additional configuration options to wrapper [`actix_web::App`]. +/// +/// This struct need be instantiated from [`actix_web::App`] by calling `.into_utoipa_app()` +/// because we do not have access to _`actix_web::App`_ generic argument and the _`App`_ does +/// not provide any default implementation. +/// +/// # Examples +/// +/// _**Create new [`UtoipaApp`] instance.**_ +/// ```rust +/// # use utoipa_actix_web::{AppExt, UtoipaApp}; +/// # use actix_web::App; +/// let utoipa_app = App::new().into_utoipa_app(); +/// ``` +/// +/// _**Convert `actix_web::App` to `UtoipaApp`.**_ +/// ```rust +/// # use utoipa_actix_web::{AppExt, UtoipaApp}; +/// # use actix_web::App; +/// let a: UtoipaApp<_> = actix_web::App::new().into(); +/// ``` +pub struct UtoipaApp(actix_web::App, Rc); + +impl From> for UtoipaApp { + fn from(value: actix_web::App) -> Self { + #[derive(OpenApi)] + struct Api; + UtoipaApp(value, Rc::new(Api::openapi())) + } +} + +impl UtoipaApp +where + T: ServiceFactory, +{ + /// Replace the wrapped [`utoipa::openapi::OpenApi`] with given _`openapi`_. + /// + /// This is useful to prepend OpenAPI doc generated with [`UtoipaApp`] + /// with content that cannot be provided directly via [`UtoipaApp`]. + /// + /// # Examples + /// + /// _**Replace wrapped [`utoipa::openapi::OpenApi`] with custom one.**_ + /// ```rust + /// # use utoipa_actix_web::{AppExt, UtoipaApp}; + /// # use actix_web::App; + /// # use utoipa::OpenApi; + /// #[derive(OpenApi)] + /// #[openapi(info(title = "Api title"))] + /// struct Api; + /// + /// let _ = actix_web::App::new().into_utoipa_app().openapi(Api::openapi()); + /// ``` + pub fn openapi(mut self, openapi: utoipa::openapi::OpenApi) -> Self { + self.1 = Rc::new(openapi); + + self + } + + /// Passthrough implementation for [`actix_web::App::app_data`]. + pub fn app_data(self, data: U) -> Self { + let app = self.0.app_data(data); + Self(app, self.1) + } + + /// Passthrough implementation for [`actix_web::App::data_factory`]. + pub fn data_factory(self, data: F) -> Self + where + F: Fn() -> Out + 'static, + Out: Future> + 'static, + D: 'static, + E: std::fmt::Debug, + { + let app = self.0.data_factory(data); + + Self(app, self.1) + } + + /// Extended version of [`actix_web::App::configure`] which handles _`schema`_ and _`path`_ + /// collection from [`ServiceConfig`] into the wrapped [`utoipa::openapi::OpenApi`] instance. + /// + /// # Panics + /// + /// If [`UtoipaApp::configure`] is called after [`UtoipaApp::openapi_service`] call. This is because + /// reference count is increase on each call and configuration cannot modify + /// [`utoipa::openapi::OpenApi`] that is already served. + pub fn configure(mut self, f: F) -> Self + where + F: FnOnce(&mut ServiceConfig), + { + // TODO get OpenAPI paths???? + let api = Rc::::get_mut(&mut self.1).expect( + "OpenApi should not have more than one reference when building App with `configure`", + ); + + let app = self.0.configure(|config| { + let mut service_config = ServiceConfig::new(config); + + f(&mut service_config); + + let paths = service_config.1.take(); + api.paths.paths.extend(paths.paths); + let schemas = service_config.2.take(); + let components = api + .components + .get_or_insert(utoipa::openapi::Components::new()); + components.schemas.extend(schemas); + }); + + Self(app, self.1) + } + + /// Passthrough implementation for [`actix_web::App::route`]. + pub fn route(self, path: &str, route: actix_web::Route) -> Self { + let app = self.0.route(path, route); + + Self(app, self.1) + } + + /// Extended version of [`actix_web::App::service`] method which handles _`schema`_ and _`path`_ + /// collection from [`HttpServiceFactory`]. + /// + /// # Panics + /// + /// If [`UtoipaApp::service`] is called after [`UtoipaApp::openapi_service`] call. This is because + /// reference count is increase on each call and we should have only one instance of + /// [`utoipa::openapi::OpenApi`] that is being built. + pub fn service(mut self, factory: F) -> Self + where + F: HttpServiceFactory + OpenApiFactory + 'static, + { + let mut schemas = Vec::<( + String, + utoipa::openapi::RefOr, + )>::new(); + + factory.schemas(&mut schemas); + let paths = factory.paths(); + + // TODO should this be `make_mut`? + let api = Rc::::get_mut(&mut self.1).expect( + "OpenApi should not have more than one reference when building App with `service`", + ); + + api.paths.paths.extend(paths.paths); + let components = api + .components + .get_or_insert(utoipa::openapi::Components::new()); + components.schemas.extend(schemas); + + let app = self.0.service(factory); + + Self(app, self.1) + } + + /// Helper method to serve wrapped [`utoipa::openapi::OpenApi`] via [`HttpServiceFactory`]. + /// + /// This method functions as a convenience to serve the wrapped OpenAPI spec alternatively to + /// first call [`UtoipaApp::split_for_parts`] and then calling [`actix_web::App::service`]. + pub fn openapi_service(self, factory: F) -> Self + where + F: FnOnce(Rc) -> O, + O: HttpServiceFactory + 'static, + { + let service = factory(self.1.clone()); + let app = self.0.service(service); + Self(app, self.1) + } + + /// Passthrough implementation for [`actix_web::App::default_service`]. + pub fn default_service(self, svc: F) -> Self + where + F: IntoServiceFactory, + U: ServiceFactory + + 'static, + U::InitError: fmt::Debug, + { + Self(self.0.default_service(svc), self.1) + } + + /// Passthrough implementation for [`actix_web::App::external_resource`]. + pub fn external_resource(self, name: N, url: U) -> Self + where + N: AsRef, + U: AsRef, + { + Self(self.0.external_resource(name, url), self.1) + } + + /// Convenience method to add custom configuration to [`actix_web::App`] that is not directly + /// exposed via [`UtoipaApp`]. This could for example be adding middlewares. + /// + /// # Examples + /// + /// _**Add middleware via `map` method.**_ + /// + /// ```rust + /// # use utoipa_actix_web::{AppExt, UtoipaApp}; + /// # use actix_web::App; + /// # use actix_service::Service; + /// # use actix_web::http::header::{HeaderValue, CONTENT_TYPE}; + /// let _ = App::new() + /// .into_utoipa_app() + /// .map(|app| { + /// app.wrap_fn(|req, srv| { + /// let fut = srv.call(req); + /// async { + /// let mut res = fut.await?; + /// res.headers_mut() + /// .insert(CONTENT_TYPE, HeaderValue::from_static("text/plain")); + /// Ok(res) + /// } + /// }) + /// }); + /// ``` + pub fn map< + F: FnOnce(actix_web::App) -> actix_web::App, + NF: ServiceFactory, + >( + self, + op: F, + ) -> UtoipaApp { + let app = op(self.0); + UtoipaApp(app, self.1) + } + + /// Split this [`UtoipaApp`] into parts returning tuple of [`actix_web::App`] and + /// [`utoipa::openapi::OpenApi`] of this instance. + pub fn split_for_parts(self) -> (actix_web::App, utoipa::openapi::OpenApi) { + ( + self.0, + Rc::try_unwrap(self.1).unwrap_or_else(|rc| (*rc).clone()), + ) + } +} + +#[cfg(test)] +mod tests { + #![allow(unused)] + + use actix_service::Service; + use actix_web::guard::Guard; + use actix_web::http::header::{HeaderValue, CONTENT_TYPE}; + use actix_web::web::Data; + use actix_web::{get, App}; + use utoipa::ToSchema; + + use super::*; + + #[derive(ToSchema)] + struct Value12 { + v: String, + } + + #[derive(ToSchema)] + struct Value2(i32); + + #[derive(ToSchema)] + struct Value1 { + bar: Value2, + } + + #[derive(ToSchema)] + struct ValueValue { + value: i32, + } + + #[utoipa::path(responses( + (status = 200, body = ValueValue) + ))] + #[get("/handler2")] + async fn handler2() -> &'static str { + "this is message 2" + } + + #[utoipa::path(responses( + (status = 200, body = Value12) + ))] + #[get("/handler")] + async fn handler() -> &'static str { + "this is message" + } + + #[utoipa::path(responses( + (status = 200, body = Value1) + ))] + #[get("/handler3")] + async fn handler3() -> &'static str { + "this is message 3" + } + + mod inner { + use actix_web::get; + use actix_web::web::Data; + use utoipa::ToSchema; + + #[derive(ToSchema)] + struct Bar(i32); + + #[derive(ToSchema)] + struct Foobar { + bar: Bar, + } + + #[utoipa::path(responses( + (status = 200, body = Foobar) + ))] + #[get("/inner_handler")] + pub async fn inner_handler(_: Data) -> &'static str { + "this is message" + } + + #[utoipa::path()] + #[get("/inner_handler3")] + pub async fn inner_handler3(_: Data) -> &'static str { + "this is message 3" + } + } + + #[test] + fn test_app_generate_correct_openapi() { + fn config(cfg: &mut service_config::ServiceConfig) { + cfg.service(handler3); + } + + let (_, mut api) = App::new() + .into_utoipa_app() + .service(handler) + .configure(config) + .service(scope::scope("/path-prefix").service(handler2).map(|scope| { + let s = scope.wrap_fn(|req, srv| { + let fut = srv.call(req); + async { + let mut res = fut.await?; + res.headers_mut() + .insert(CONTENT_TYPE, HeaderValue::from_static("text/plain")); + Ok(res) + } + }); + + s + })) + .service(scope::scope("/api/v1/inner").configure(|cfg| { + cfg.service(inner::inner_handler) + .service(inner::inner_handler3) + .app_data(Data::new(String::new())); + })) + .split_for_parts(); + api.info = utoipa::openapi::info::Info::new("title", "version"); + let json = api.to_pretty_json().expect("OpenAPI is JSON serializable"); + println!("{json}"); + + let expected = include_str!("../testdata/app_generated_openapi"); + assert_eq!(json.trim(), expected.trim()); + } +} diff --git a/utoipa-actix-web/src/scope.rs b/utoipa-actix-web/src/scope.rs new file mode 100644 index 00000000..7ef5d4f5 --- /dev/null +++ b/utoipa-actix-web/src/scope.rs @@ -0,0 +1,239 @@ +//! Implement `utoipa` extended [`Scope`] for [`actix_web::Scope`]. +//! +//! See usage from [`scope`][fn@scope]. + +use core::fmt; +use std::cell::{Cell, RefCell}; + +use actix_service::{IntoServiceFactory, ServiceFactory}; +use actix_web::body::MessageBody; +use actix_web::dev::{AppService, HttpServiceFactory, ServiceRequest, ServiceResponse}; +use actix_web::guard::Guard; +use actix_web::{Error, Route}; + +use crate::service_config::ServiceConfig; +use crate::OpenApiFactory; + +/// Wrapper type for [`actix_web::Scope`] and [`utoipa::openapi::OpenApi`] with additional path +/// prefix created with `scope::scope("path-prefix")` call. +/// +/// See usage from [`scope`][fn@scope]. +pub struct Scope( + actix_web::Scope, + RefCell, + Cell, +); + +impl From> for Scope +where + T: ServiceFactory, +{ + fn from(value: actix_web::Scope) -> Self { + Self( + value, + RefCell::new(utoipa::openapi::OpenApiBuilder::new().build()), + Cell::new(String::new()), + ) + } +} + +impl<'s, T: ServiceFactory> + From<&'s str> for Scope +where + Scope: std::convert::From, +{ + fn from(value: &'s str) -> Self { + let scope = actix_web::Scope::new(value); + let s: Scope = scope.into(); + Scope(s.0, s.1, Cell::new(String::from(value))) + } +} + +/// Create a new [`Scope`] with given _`scope`_ e.g. `scope("/api/v1")`. +/// +/// This behaves exactly same way as [`actix_web::Scope`] but allows automatic _`schema`_ and +/// _`path`_ collection from `service(...)` calls directly or via [`ServiceConfig::service`]. +/// +/// # Examples +/// +/// _**Create new scoped service.**_ +/// +/// ```rust +/// # use actix_web::{get, App}; +/// # use utoipa_actix_web::{AppExt, scope}; +/// # +/// #[utoipa::path()] +/// #[get("/handler")] +/// pub async fn handler() -> &'static str { +/// "OK" +/// } +/// let _ = App::new() +/// .into_utoipa_app() +/// .service(scope::scope("/api/v1/inner").configure(|cfg| { +/// cfg.service(handler); +/// })); +/// ``` +pub fn scope< + I: Into>, + T: ServiceFactory, +>( + scope: I, +) -> Scope { + scope.into() +} + +impl Scope +where + T: ServiceFactory, +{ + /// Passthrough implementation for [`actix_web::Scope::guard`]. + pub fn guard(self, guard: G) -> Self { + let scope = self.0.guard(guard); + Self(scope, self.1, self.2) + } + + /// Passthrough implementation for [`actix_web::Scope::app_data`]. + pub fn app_data(self, data: U) -> Self { + Self(self.0.app_data(data), self.1, self.2) + } + + /// Synonymous for [`UtoipaApp::configure`][utoipa_app_configure] + /// + /// [utoipa_app_configure]: ../struct.UtoipaApp.html#method.configure + pub fn configure(self, cfg_fn: F) -> Self + where + F: FnOnce(&mut ServiceConfig), + { + let mut openapi = self.1.borrow_mut(); + + let scope = self.0.configure(|config| { + let mut service_config = ServiceConfig::new(config); + + cfg_fn(&mut service_config); + + let other_paths = service_config.1.take(); + openapi.paths.paths.extend(other_paths.paths); + let schemas = service_config.2.take(); + let components = openapi + .components + .get_or_insert(utoipa::openapi::Components::new()); + components.schemas.extend(schemas); + }); + drop(openapi); + + Self(scope, self.1, self.2) + } + + /// Synonymous for [`UtoipaApp::service`][utoipa_app_service] + /// + /// [utoipa_app_service]: ../struct.UtoipaApp.html#method.service + pub fn service(self, factory: F) -> Self + where + F: HttpServiceFactory + OpenApiFactory + 'static, + { + let mut schemas = Vec::<( + String, + utoipa::openapi::RefOr, + )>::new(); + { + let mut openapi = self.1.borrow_mut(); + let other_paths = factory.paths(); + factory.schemas(&mut schemas); + + openapi.paths.paths.extend(other_paths.paths); + let components = openapi + .components + .get_or_insert(utoipa::openapi::Components::new()); + components.schemas.extend(schemas); + } + + let app = self.0.service(factory); + + Self(app, self.1, self.2) + } + + /// Passthrough implementation for [`actix_web::Scope::route`]. + pub fn route(self, path: &str, route: Route) -> Self { + Self(self.0.route(path, route), self.1, self.2) + } + + /// Passthrough implementation for [`actix_web::Scope::default_service`]. + pub fn default_service(self, f: F) -> Self + where + F: IntoServiceFactory, + U: ServiceFactory< + ServiceRequest, + Config = (), + Response = ServiceResponse, + Error = actix_web::Error, + > + 'static, + U::InitError: fmt::Debug, + { + Self(self.0.default_service(f), self.1, self.2) + } + + /// Synonymous for [`UtoipaApp::map`][utoipa_app_map] + /// + /// [utoipa_app_map]: ../struct.UtoipaApp.html#method.map + pub fn map< + F: FnOnce(actix_web::Scope) -> actix_web::Scope, + NF: ServiceFactory, + >( + self, + op: F, + ) -> Scope { + let scope = op(self.0); + Scope(scope, self.1, self.2) + } +} + +impl HttpServiceFactory for Scope +where + T: ServiceFactory< + ServiceRequest, + Config = (), + Response = ServiceResponse, + Error = Error, + InitError = (), + > + 'static, + B: MessageBody + 'static, +{ + fn register(self, config: &mut AppService) { + let Scope(scope, ..) = self; + scope.register(config); + } +} + +impl OpenApiFactory for Scope { + fn paths(&self) -> utoipa::openapi::path::Paths { + let prefix = self.2.take(); + let mut openapi = self.1.borrow_mut(); + let mut paths = std::mem::take(&mut openapi.paths); + + let prefixed_paths = paths + .paths + .into_iter() + .map(|(path, item)| { + let path = format!("{prefix}{path}"); + + (path, item) + }) + .collect::>(); + paths.paths = prefixed_paths; + + paths + } + + fn schemas( + &self, + schemas: &mut Vec<( + String, + utoipa::openapi::RefOr, + )>, + ) { + let mut api = self.1.borrow_mut(); + if let Some(components) = &mut api.components { + schemas.extend(std::mem::take(&mut components.schemas)); + } + } +} diff --git a/utoipa-actix-web/src/service_config.rs b/utoipa-actix-web/src/service_config.rs new file mode 100644 index 00000000..a5bf57a8 --- /dev/null +++ b/utoipa-actix-web/src/service_config.rs @@ -0,0 +1,96 @@ +//! Implement `utoipa` extended [`ServiceConfig`] for [`actix_web::web::ServiceConfig`]. + +use std::cell::Cell; + +use actix_service::{IntoServiceFactory, ServiceFactory}; +use actix_web::dev::{HttpServiceFactory, ServiceRequest, ServiceResponse}; +use actix_web::{Error, Route}; + +use crate::OpenApiFactory; + +/// Wrapper type for [`actix_web::web::ServiceConfig`], [`utoipa::openapi::path::Paths`] and +/// vec of [`utoipa::openapi::schema::Schema`] references. +pub struct ServiceConfig<'s>( + pub(super) &'s mut actix_web::web::ServiceConfig, + pub(super) Cell, + pub(super) Cell< + Vec<( + String, + utoipa::openapi::RefOr, + )>, + >, +); + +impl<'s> ServiceConfig<'s> { + /// Construct a new [`ServiceConfig`] from given [`actix_web::web::ServiceConfig`]. + pub fn new(conf: &'s mut actix_web::web::ServiceConfig) -> ServiceConfig<'s> { + ServiceConfig( + conf, + Cell::new(utoipa::openapi::path::Paths::new()), + Cell::new(Vec::new()), + ) + } + + /// Passthrough implementation for [`actix_web::web::ServiceConfig::app_data`]. + pub fn app_data(&mut self, ext: U) -> &mut Self { + self.0.app_data(ext); + self + } + + /// Passthrough implementation for [`actix_web::web::ServiceConfig::default_service`]. + pub fn default_service(&mut self, f: F) -> &mut Self + where + F: IntoServiceFactory, + U: ServiceFactory + + 'static, + U::InitError: std::fmt::Debug, + { + self.0.default_service(f); + self + } + + /// Passthrough implementation for [`actix_web::web::ServiceConfig::configure`]. + pub fn configure(&mut self, f: F) -> &mut Self + where + F: FnOnce(&mut ServiceConfig), + { + f(self); + self + } + + /// Passthrough implementation for [`actix_web::web::ServiceConfig::route`]. + pub fn route(&mut self, path: &str, route: Route) -> &mut Self { + self.0.route(path, route); + self + } + + /// Counterpart for [`UtoipaApp::service`][utoipa_app_service]. + /// + /// [utoipa_app_service]: ../struct.UtoipaApp.html#method.service + pub fn service(&mut self, factory: F) -> &mut Self + where + F: HttpServiceFactory + OpenApiFactory + 'static, + { + let mut paths = self.1.take(); + let other_paths = factory.paths(); + paths.paths.extend(other_paths.paths); + let mut schemas = self.2.take(); + factory.schemas(&mut schemas); + self.2.set(schemas); + + self.0.service(factory); + self.1.set(paths); + + self + } + + /// Passthrough implementation for [`actix_web::web::ServiceConfig::external_resource`]. + pub fn external_resource(&mut self, name: N, url: U) -> &mut Self + where + N: AsRef, + U: AsRef, + { + self.0.external_resource(name, url); + self + } +} diff --git a/utoipa-actix-web/testdata/app_generated_openapi b/utoipa-actix-web/testdata/app_generated_openapi new file mode 100644 index 00000000..16373c64 --- /dev/null +++ b/utoipa-actix-web/testdata/app_generated_openapi @@ -0,0 +1,140 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "title", + "version": "version" + }, + "paths": { + "/api/v1/inner/inner_handler": { + "get": { + "operationId": "inner_handler", + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Foobar" + } + } + } + } + } + } + }, + "/api/v1/inner/inner_handler3": { + "get": { + "operationId": "inner_handler3", + "responses": {} + } + }, + "/handler": { + "get": { + "operationId": "handler", + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Value12" + } + } + } + } + } + } + }, + "/handler3": { + "get": { + "operationId": "handler3", + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Value1" + } + } + } + } + } + } + }, + "/path-prefix/handler2": { + "get": { + "operationId": "handler2", + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValueValue" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "Bar": { + "type": "integer", + "format": "int32" + }, + "Foobar": { + "type": "object", + "required": [ + "bar" + ], + "properties": { + "bar": { + "$ref": "#/components/schemas/Bar" + } + } + }, + "Value1": { + "type": "object", + "required": [ + "bar" + ], + "properties": { + "bar": { + "$ref": "#/components/schemas/Value2" + } + } + }, + "Value12": { + "type": "object", + "required": [ + "v" + ], + "properties": { + "v": { + "type": "string" + } + } + }, + "Value2": { + "type": "integer", + "format": "int32" + }, + "ValueValue": { + "type": "object", + "required": [ + "value" + ], + "properties": { + "value": { + "type": "integer", + "format": "int32" + } + } + } + } + } +} diff --git a/utoipa-gen/CHANGELOG.md b/utoipa-gen/CHANGELOG.md index fa57bc47..096478ac 100644 --- a/utoipa-gen/CHANGELOG.md +++ b/utoipa-gen/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog - utoipa-gen +## Unreleased + +### Added + +* Add implementation for utoipa-actix-web bindings (https://github.com/juhaku/utoipa/pull/1158) + ## 5.1.1 - Oct 16 2024 ### Changed diff --git a/utoipa-gen/src/path.rs b/utoipa-gen/src/path.rs index 2c233af4..cde0d88d 100644 --- a/utoipa-gen/src/path.rs +++ b/utoipa-gen/src/path.rs @@ -31,12 +31,10 @@ 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() - )) - } + Cow::Owned(quote::format_ident!( + "{PATH_STRUCT_PREFIX}{}", + fn_name.as_ref() + )) } #[derive(Default)] @@ -538,6 +536,42 @@ impl<'p> ToTokensDiagnostics for Path<'p> { #[derive(Clone)] pub struct #path_struct; }); + + #[cfg(feature = "actix_extras")] + { + // Add supporting passthrough implementations only if actix-web service config + // is implemented and no impl_for has been defined + if self.path_attr.impl_for.is_none() && !self.ext_methods.is_empty() { + let fn_ident = self.fn_ident; + tokens.extend(quote! { + impl ::actix_web::dev::HttpServiceFactory for #path_struct { + fn register(self, __config: &mut actix_web::dev::AppService) { + ::actix_web::dev::HttpServiceFactory::register(#fn_ident, __config); + } + } + impl utoipa::Path for #fn_ident { + fn path() -> String { + #path_struct::path() + } + + fn methods() -> Vec { + #path_struct::methods() + } + + fn operation() -> utoipa::openapi::path::Operation { + #path_struct::operation() + } + } + + impl utoipa::__dev::SchemaReferences for #fn_ident { + fn schemas(schemas: &mut Vec<(String, utoipa::openapi::RefOr)>) { + <#path_struct as utoipa::__dev::SchemaReferences>::schemas(schemas); + } + } + }) + } + } + path_struct }; diff --git a/utoipa/CHANGELOG.md b/utoipa/CHANGELOG.md index 57ca4bd5..4f469d42 100644 --- a/utoipa/CHANGELOG.md +++ b/utoipa/CHANGELOG.md @@ -3,6 +3,12 @@ **`utoipa`** is in direct correlation with **`utoipa-gen`** ([CHANGELOG.md](../utoipa-gen/CHANGELOG.md)). You might want to look into changes introduced to **`utoipa-gen`**. +## Unreleased + +### Added + +* Add implementation for utoipa-actix-web bindings (https://github.com/juhaku/utoipa/pull/1158) + ## 5.1.1 - Oct 16 2024 ### Changed diff --git a/utoipa/src/openapi/path.rs b/utoipa/src/openapi/path.rs index dd8651ba..85e55de2 100644 --- a/utoipa/src/openapi/path.rs +++ b/utoipa/src/openapi/path.rs @@ -15,9 +15,13 @@ use super::{ }; #[cfg(not(feature = "preserve_path_order"))] -pub(super) type PathsMap = std::collections::BTreeMap; +#[allow(missing_docs)] +#[doc(hidden)] +pub type PathsMap = std::collections::BTreeMap; #[cfg(feature = "preserve_path_order")] -pub(super) type PathsMap = indexmap::IndexMap; +#[allow(missing_docs)] +#[doc(hidden)] +pub type PathsMap = indexmap::IndexMap; builder! { PathsBuilder;