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/utoipa-actix-web/Cargo.toml b/utoipa-actix-web/Cargo.toml new file mode 100644 index 00000000..d0eacda4 --- /dev/null +++ b/utoipa-actix-web/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "utoipa-actix-web" +version = "0.1.0" +edition = "2021" +rust-version.workspace = true + +[dependencies] +utoipa = { path = "../utoipa", version = "5" } +actix-web = { version = "4", default-features = false } +actix-service = "2" +once_cell = "1" + +[dev-dependencies] +utoipa = { path = "../utoipa", version = "5", features = [ + "actix_extras", + "macros", + "debug", +] } +actix-web = { version = "4", default-features = false, features = ["macros"] } diff --git a/utoipa-actix-web/src/lib.rs b/utoipa-actix-web/src/lib.rs new file mode 100644 index 00000000..41b0daeb --- /dev/null +++ b/utoipa-actix-web/src/lib.rs @@ -0,0 +1,233 @@ +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 trait PathsFactory { + fn paths(&self) -> utoipa::openapi::path::Paths; +} + +impl PathsFactory 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() + } +} + +pub trait AppExt { + fn into_utoipa_app(self) -> UtoipaApp; +} + +impl AppExt for actix_web::App { + fn into_utoipa_app(self) -> UtoipaApp { + UtoipaApp::from(self) + } +} + +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, +{ + /// 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) + } + + /// Passthrough implementation for [`actix_web::App::configure`]. + 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 ServiceConfig(_, paths) = service_config; + api.paths.paths.extend(paths.take().paths); + }); + + 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) + } + + /// Passthrough implementation for [`actix_web::App::service`]. + pub fn service(mut self, factory: F) -> Self + where + F: HttpServiceFactory + PathsFactory + 'static, + { + 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 app = self.0.service(factory); + + Self(app, self.1) + } + + 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) + } + + 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) + } + + 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 { + use actix_service::Service; + use actix_web::http::header::{HeaderValue, CONTENT_TYPE}; + use actix_web::{get, App}; + + use super::*; + + #[utoipa::path(impl_for = handler2)] + #[get("/handler2")] + async fn handler2() -> &'static str { + "this is message 2" + } + + #[utoipa::path(impl_for = handler)] + #[get("/handler")] + async fn handler() -> &'static str { + "this is message" + } + + #[utoipa::path(impl_for = handler3)] + #[get("/handler3")] + async fn handler3() -> &'static str { + "this is message 3" + } + + #[test] + fn test_app() { + fn config(cfg: &mut service_config::ServiceConfig) { + cfg.service(handler3); + } + + let (_, 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 + })) + .split_for_parts(); + + dbg!(api); + // let app = app.service(scope::scope("prefix").service(handler)); + + // app + } +} diff --git a/utoipa-actix-web/src/scope.rs b/utoipa-actix-web/src/scope.rs new file mode 100644 index 00000000..353932e5 --- /dev/null +++ b/utoipa-actix-web/src/scope.rs @@ -0,0 +1,171 @@ +use core::fmt; +use std::cell::Cell; + +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::PathsFactory; + +pub struct Scope( + actix_web::Scope, + Cell, + Cell, +); + +impl From> for Scope +where + T: ServiceFactory, +{ + fn from(value: actix_web::Scope) -> Self { + Self( + value, + Cell::new(utoipa::openapi::path::Paths::new()), + 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))) + } +} + +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) + } + + /// Passthrough implementation for [`actix_web::Scope::configure`]. + pub fn configure(self, cfg_fn: F) -> Self + where + F: FnOnce(&mut ServiceConfig), + { + let mut paths = self.1.take(); + + let scope = self.0.configure(|config| { + let mut service_config = ServiceConfig::new(config); + + cfg_fn(&mut service_config); + + let ServiceConfig(_, other_paths) = service_config; + paths.paths.extend(other_paths.take().paths); + }); + Self(scope, Cell::new(paths), self.2) + } + + /// Passthrough implementation for [`actix_web::Scope::service`]. + pub fn service(self, factory: F) -> Self + where + F: HttpServiceFactory + PathsFactory + 'static, + { + let mut paths = self.1.take(); + let other_paths = factory.paths(); + + paths.paths.extend(other_paths.paths); + + let app = self.0.service(factory); + + Self(app, Cell::new(paths), 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) + } + + 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 PathsFactory for Scope { + fn paths(&self) -> utoipa::openapi::path::Paths { + let prefix = self.2.take(); + let mut paths = self.1.take(); + + let paths_map = paths + .paths + .into_iter() + .map(|(path, item)| { + let path = format!("{prefix}{path}"); + + (path, item) + }) + .collect::>(); + + paths.paths = paths_map; + + dbg!(&prefix, &paths); + + paths + } +} diff --git a/utoipa-actix-web/src/service_config.rs b/utoipa-actix-web/src/service_config.rs new file mode 100644 index 00000000..8e521a95 --- /dev/null +++ b/utoipa-actix-web/src/service_config.rs @@ -0,0 +1,30 @@ +use std::cell::Cell; + +use actix_web::dev::HttpServiceFactory; + +use crate::PathsFactory; + +pub struct ServiceConfig<'s>( + pub(super) &'s mut actix_web::web::ServiceConfig, + pub(super) Cell, +); + +impl<'s> ServiceConfig<'s> { + pub fn new(conf: &'s mut actix_web::web::ServiceConfig) -> ServiceConfig<'s> { + ServiceConfig(conf, Cell::new(utoipa::openapi::path::Paths::new())) + } + + pub fn service(&mut self, factory: F) -> &mut Self + where + F: HttpServiceFactory + PathsFactory + 'static, + { + let mut paths = self.1.take(); + let other_paths = factory.paths(); + paths.paths.extend(other_paths.paths); + + self.0.service(factory); + self.1 = Cell::new(paths); + + self + } +} 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;