noema-actix-webapi 0.1.0

Actix-web backend runtime on Noema (modules, sqlx, UoW, swagger, WebSocket dispatch)
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;

    /// Gate this module’s OpenAPI JSON and the global Swagger UI (UI requires **every**
    /// module to allow). Default: deny.
    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);
    }
}