use std::future::{Ready, ready};
use std::rc::Rc;
use std::sync::Arc;
use std::task::{Context, Poll};
use actix_web::body::{BoxBody, MessageBody};
use actix_web::dev::{Service, ServiceRequest, ServiceResponse, Transform};
use actix_web::{Error as ActixError, HttpRequest, HttpResponse, ResponseError, Scope, web};
use futures::future::LocalBoxFuture;
use noema::core::{Container, Injectable};
use utoipa::openapi::OpenApi;
use utoipa_swagger_ui::{SwaggerUi, Url};
use crate::error::{HttpError, MappedError};
const DOCS_SUFFIX: &str = "/docs/openapi.json";
#[async_trait::async_trait]
pub trait Infrastructure: Send + Sync {
async fn configure(&self);
fn migrator(&self) -> Option<sqlx::migrate::Migrator> {
None
}
}
pub trait Presentation: Send + Sync {
fn scope_name(&self) -> &'static str;
fn openapi(&self) -> OpenApi;
fn configure_scope(&self, scope: Scope) -> Scope;
fn authorize_docs(&self, req: &HttpRequest) -> Result<(), HttpError> {
let _ = req;
Err(MappedError::unauthorized("Documentation is not available").into())
}
}
#[async_trait::async_trait]
pub trait Module: Send + Sync {
fn name(&self) -> &'static str {
self.get_presentation().scope_name()
}
fn get_infrastructure(&self) -> &dyn Infrastructure;
fn get_presentation(&self) -> &dyn Presentation;
async fn configure_infrastructure(&self) {
self.get_infrastructure().configure().await;
}
}
pub struct Modules {
modules: Vec<Arc<dyn Module>>,
}
impl Modules {
pub fn new() -> Self {
Self {
modules: Vec::new(),
}
}
pub fn add<T>(mut self) -> Self
where
T: Module + Injectable<Container> + 'static,
{
self.modules.push(Arc::new(T::inject(&Container)));
self
}
pub async fn configure_infrastructure(&self) {
for module in &self.modules {
module.configure_infrastructure().await;
}
}
pub async fn run_migrators(&self, pool: &sqlx::PgPool) -> Result<(), sqlx::Error> {
for module in &self.modules {
if let Some(migrator) = module.get_infrastructure().migrator() {
migrator.run(pool).await?;
}
}
Ok(())
}
pub fn authorize_docs(&self, req: &HttpRequest) -> Result<(), HttpError> {
for module in &self.modules {
module.get_presentation().authorize_docs(req)?;
}
Ok(())
}
pub fn configure_presentation(&self, cfg: &mut web::ServiceConfig) {
for module in &self.modules {
let presentation = module.get_presentation();
let name = normalize_scope(presentation.scope_name());
let docs_path = DOCS_SUFFIX;
let doc = Arc::new(presentation.openapi());
let module = Arc::clone(module);
let scope = Scope::new(&name);
let scope = scope.route(
docs_path,
web::get().to(move |req: HttpRequest| {
let module = Arc::clone(&module);
let doc = Arc::clone(&doc);
async move {
module.get_presentation().authorize_docs(&req)?;
Ok::<_, HttpError>(HttpResponse::Ok().json(&*doc))
}
}),
);
let scope = presentation.configure_scope(scope);
cfg.service(scope);
}
}
pub fn swagger(&self) -> SwaggerUi {
let mut urls = Vec::new();
for module in &self.modules {
let presentation = module.get_presentation();
let name = normalize_scope(presentation.scope_name());
let docs = format!("{name}{DOCS_SUFFIX}");
let docs: &'static str = Box::leak(docs.into_boxed_str());
urls.push((
Url::new(presentation.scope_name(), docs),
presentation.openapi(),
));
}
SwaggerUi::new("/{_:.*}").urls(urls)
}
pub(crate) fn mount_swagger(&self, cfg: &mut web::ServiceConfig, modules: Arc<Modules>) {
cfg.service(
web::scope("/swagger-ui")
.wrap(DocsAuth(modules))
.service(self.swagger()),
);
}
}
impl Default for Modules {
fn default() -> Self {
Self::new()
}
}
fn normalize_scope(name: &str) -> String {
if name.starts_with('/') {
name.to_string()
} else {
format!("/{name}")
}
}
struct DocsAuth(Arc<Modules>);
impl<S, B> Transform<S, ServiceRequest> for DocsAuth
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
S::Future: 'static,
B: MessageBody + 'static,
{
type Response = ServiceResponse<BoxBody>;
type Error = ActixError;
type InitError = ();
type Transform = DocsAuthMiddleware<S>;
type Future = Ready<Result<Self::Transform, Self::InitError>>;
fn new_transform(&self, service: S) -> Self::Future {
ready(Ok(DocsAuthMiddleware {
service: Rc::new(service),
modules: Arc::clone(&self.0),
}))
}
}
struct DocsAuthMiddleware<S> {
service: Rc<S>,
modules: Arc<Modules>,
}
impl<S, B> Service<ServiceRequest> for DocsAuthMiddleware<S>
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
S::Future: 'static,
B: MessageBody + 'static,
{
type Response = ServiceResponse<BoxBody>;
type Error = ActixError;
type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
fn poll_ready(&self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
self.service.poll_ready(cx)
}
fn call(&self, req: ServiceRequest) -> Self::Future {
if let Err(err) = self.modules.authorize_docs(req.request()) {
let res = req.into_response(err.error_response());
return Box::pin(async move { Ok(res.map_into_boxed_body()) });
}
let service = Rc::clone(&self.service);
Box::pin(async move { Ok(service.call(req).await?.map_into_boxed_body()) })
}
}
#[cfg(test)]
mod tests {
use super::*;
use actix_web::http::StatusCode;
use actix_web::{App, test};
struct Closed;
struct Open;
#[async_trait::async_trait]
impl Infrastructure for Closed {
async fn configure(&self) {}
}
impl Presentation for Closed {
fn scope_name(&self) -> &'static str {
"closed"
}
fn openapi(&self) -> OpenApi {
OpenApi::default()
}
fn configure_scope(&self, scope: Scope) -> Scope {
scope
}
}
impl Module for Closed {
fn get_infrastructure(&self) -> &dyn Infrastructure {
self
}
fn get_presentation(&self) -> &dyn Presentation {
self
}
}
impl Injectable<Container> for Closed {
fn inject(_: &Container) -> Self {
Self
}
}
#[async_trait::async_trait]
impl Infrastructure for Open {
async fn configure(&self) {}
}
impl Presentation for Open {
fn scope_name(&self) -> &'static str {
"open"
}
fn openapi(&self) -> OpenApi {
OpenApi::default()
}
fn configure_scope(&self, scope: Scope) -> Scope {
scope
}
fn authorize_docs(&self, _req: &HttpRequest) -> Result<(), HttpError> {
Ok(())
}
}
impl Module for Open {
fn get_infrastructure(&self) -> &dyn Infrastructure {
self
}
fn get_presentation(&self) -> &dyn Presentation {
self
}
}
impl Injectable<Container> for Open {
fn inject(_: &Container) -> Self {
Self
}
}
#[actix_web::test]
async fn docs_json_denied_by_default() {
let app =
crate::Application::start(
Modules::new().add::<Closed>(),
crate::bootstrap::test_source(),
crate::ApplicationConfig::default(),
)
.await
.unwrap();
let srv = test::init_service(App::new().configure(|c| app.configure(c))).await;
let resp = test::call_service(
&srv,
test::TestRequest::get()
.uri("/closed/docs/openapi.json")
.to_request(),
)
.await;
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[actix_web::test]
async fn docs_json_allowed_when_authorize_ok() {
let app = crate::Application::start(
Modules::new().add::<Open>(),
crate::bootstrap::test_source(),
crate::ApplicationConfig::default(),
)
.await
.unwrap();
let srv = test::init_service(App::new().configure(|c| app.configure(c))).await;
let resp = test::call_service(
&srv,
test::TestRequest::get()
.uri("/open/docs/openapi.json")
.to_request(),
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
}
#[actix_web::test]
async fn swagger_ui_denied_if_any_module_denies() {
let app = crate::Application::start(
Modules::new().add::<Open>().add::<Closed>(),
crate::bootstrap::test_source(),
crate::ApplicationConfig::default(),
)
.await
.unwrap();
let srv = test::init_service(App::new().configure(|c| app.configure(c))).await;
let resp = test::call_service(
&srv,
test::TestRequest::get().uri("/swagger-ui/").to_request(),
)
.await;
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[actix_web::test]
async fn swagger_ui_allowed_when_all_modules_allow() {
let app = crate::Application::start(
Modules::new().add::<Open>(),
crate::bootstrap::test_source(),
crate::ApplicationConfig::default(),
)
.await
.unwrap();
let srv = test::init_service(App::new().configure(|c| app.configure(c))).await;
let resp = test::call_service(
&srv,
test::TestRequest::get().uri("/swagger-ui/").to_request(),
)
.await;
assert_ne!(resp.status(), StatusCode::UNAUTHORIZED);
assert_ne!(resp.status(), StatusCode::NOT_FOUND);
}
}