a3s-boot 0.1.0

Adapter-first modular Rust web framework for A3S inspired by Nest.js
Documentation
use a3s_boot::{BootApplication, BootError, BoxFuture, HttpAdapter, Module, ModuleRef, Result};
use std::sync::Arc;

struct LifecycleModule {
    name: &'static str,
    imports: Vec<Arc<dyn Module>>,
    log: Arc<std::sync::Mutex<Vec<String>>>,
}

impl LifecycleModule {
    fn new(name: &'static str, log: Arc<std::sync::Mutex<Vec<String>>>) -> Self {
        Self {
            name,
            imports: Vec::new(),
            log,
        }
    }

    fn with_import(mut self, module: Arc<dyn Module>) -> Self {
        self.imports.push(module);
        self
    }
}

impl Module for LifecycleModule {
    fn name(&self) -> &'static str {
        self.name
    }

    fn imports(&self) -> Vec<Arc<dyn Module>> {
        self.imports.clone()
    }

    fn on_module_init(&self, _module_ref: &ModuleRef) -> Result<()> {
        self.log.lock().unwrap().push(format!("init:{}", self.name));
        Ok(())
    }

    fn on_application_bootstrap(&self, _module_ref: ModuleRef) -> BoxFuture<'static, Result<()>> {
        let name = self.name;
        let log = Arc::clone(&self.log);
        Box::pin(async move {
            log.lock().unwrap().push(format!("bootstrap:{name}"));
            Ok(())
        })
    }

    fn on_application_shutdown(&self, _module_ref: ModuleRef) -> BoxFuture<'static, Result<()>> {
        let name = self.name;
        let log = Arc::clone(&self.log);
        Box::pin(async move {
            log.lock().unwrap().push(format!("shutdown:{name}"));
            Ok(())
        })
    }
}

struct FailingBootstrapModule {
    log: Arc<std::sync::Mutex<Vec<String>>>,
    fail_shutdown: bool,
}

impl Module for FailingBootstrapModule {
    fn name(&self) -> &'static str {
        "failing-bootstrap"
    }

    fn on_application_bootstrap(&self, _module_ref: ModuleRef) -> BoxFuture<'static, Result<()>> {
        let log = Arc::clone(&self.log);
        Box::pin(async move {
            log.lock().unwrap().push("bootstrap".to_string());
            Err(BootError::Internal("bootstrap failed".to_string()))
        })
    }

    fn on_application_shutdown(&self, _module_ref: ModuleRef) -> BoxFuture<'static, Result<()>> {
        let log = Arc::clone(&self.log);
        let fail_shutdown = self.fail_shutdown;
        Box::pin(async move {
            log.lock().unwrap().push("shutdown".to_string());
            if fail_shutdown {
                return Err(BootError::Internal("shutdown failed".to_string()));
            }
            Ok(())
        })
    }
}

#[tokio::test]
async fn lifecycle_hooks_run_in_dependency_order_and_shutdown_reverse_order() {
    let log = Arc::new(std::sync::Mutex::new(Vec::new()));
    let child = Arc::new(LifecycleModule::new("child", Arc::clone(&log)));
    let root = LifecycleModule::new("root", Arc::clone(&log)).with_import(child);

    let app = BootApplication::builder().import(root).build().unwrap();

    app.bootstrap().await.unwrap();
    app.shutdown().await.unwrap();

    assert_eq!(
        log.lock().unwrap().as_slice(),
        [
            "init:child",
            "init:root",
            "bootstrap:child",
            "bootstrap:root",
            "shutdown:root",
            "shutdown:child"
        ]
    );
}

#[tokio::test]
async fn serve_with_runs_shutdown_when_bootstrap_fails() {
    let log = Arc::new(std::sync::Mutex::new(Vec::new()));
    let app = BootApplication::builder()
        .import(FailingBootstrapModule {
            log: Arc::clone(&log),
            fail_shutdown: false,
        })
        .build()
        .unwrap();

    let result = app
        .serve_with(
            &LifecycleAdapter::new(Arc::clone(&log)),
            ([127, 0, 0, 1], 0).into(),
        )
        .await;

    assert!(matches!(
        result,
        Err(BootError::Internal(message)) if message == "bootstrap failed"
    ));
    assert_eq!(log.lock().unwrap().as_slice(), ["bootstrap", "shutdown"]);
}

#[tokio::test]
async fn serve_with_preserves_bootstrap_error_when_shutdown_also_fails() {
    let log = Arc::new(std::sync::Mutex::new(Vec::new()));
    let app = BootApplication::builder()
        .import(FailingBootstrapModule {
            log: Arc::clone(&log),
            fail_shutdown: true,
        })
        .build()
        .unwrap();

    let result = app
        .serve_with(
            &LifecycleAdapter::new(Arc::clone(&log)),
            ([127, 0, 0, 1], 0).into(),
        )
        .await;

    assert!(matches!(
        result,
        Err(BootError::Internal(message)) if message == "bootstrap failed"
    ));
    assert_eq!(log.lock().unwrap().as_slice(), ["bootstrap", "shutdown"]);
}

struct FailingShutdownModule {
    log: Arc<std::sync::Mutex<Vec<String>>>,
}

impl Module for FailingShutdownModule {
    fn name(&self) -> &'static str {
        "failing-shutdown"
    }

    fn on_application_bootstrap(&self, _module_ref: ModuleRef) -> BoxFuture<'static, Result<()>> {
        let log = Arc::clone(&self.log);
        Box::pin(async move {
            log.lock().unwrap().push("bootstrap".to_string());
            Ok(())
        })
    }

    fn on_application_shutdown(&self, _module_ref: ModuleRef) -> BoxFuture<'static, Result<()>> {
        let log = Arc::clone(&self.log);
        Box::pin(async move {
            log.lock().unwrap().push("shutdown".to_string());
            Err(BootError::Internal("shutdown failed".to_string()))
        })
    }
}

struct LifecycleAdapter {
    log: Arc<std::sync::Mutex<Vec<String>>>,
    fail: bool,
}

impl LifecycleAdapter {
    fn new(log: Arc<std::sync::Mutex<Vec<String>>>) -> Self {
        Self { log, fail: false }
    }

    fn failing(log: Arc<std::sync::Mutex<Vec<String>>>) -> Self {
        Self { log, fail: true }
    }
}

impl HttpAdapter for LifecycleAdapter {
    type Output = ();

    fn build(&self, _app: BootApplication) -> Result<Self::Output> {
        Ok(())
    }

    fn serve(
        &self,
        _app: BootApplication,
        _addr: std::net::SocketAddr,
    ) -> BoxFuture<'static, Result<()>> {
        let fail = self.fail;
        let log = Arc::clone(&self.log);

        Box::pin(async move {
            log.lock().unwrap().push("serve".to_string());
            if fail {
                return Err(BootError::Adapter("serve failed".to_string()));
            }
            Ok(())
        })
    }
}

#[tokio::test]
async fn serve_with_runs_bootstrap_and_shutdown_around_adapter() {
    let log = Arc::new(std::sync::Mutex::new(Vec::new()));
    let app = BootApplication::builder()
        .import(LifecycleModule::new("app", Arc::clone(&log)))
        .build()
        .unwrap();

    app.serve_with(
        &LifecycleAdapter::new(Arc::clone(&log)),
        ([127, 0, 0, 1], 0).into(),
    )
    .await
    .unwrap();

    assert_eq!(
        log.lock().unwrap().as_slice(),
        ["init:app", "bootstrap:app", "serve", "shutdown:app"]
    );
}

#[tokio::test]
async fn serve_with_runs_shutdown_when_adapter_fails() {
    let log = Arc::new(std::sync::Mutex::new(Vec::new()));
    let app = BootApplication::builder()
        .import(LifecycleModule::new("app", Arc::clone(&log)))
        .build()
        .unwrap();

    let result = app
        .serve_with(
            &LifecycleAdapter::failing(Arc::clone(&log)),
            ([127, 0, 0, 1], 0).into(),
        )
        .await;

    assert!(matches!(
        result,
        Err(BootError::Adapter(message)) if message == "serve failed"
    ));
    assert_eq!(
        log.lock().unwrap().as_slice(),
        ["init:app", "bootstrap:app", "serve", "shutdown:app"]
    );
}

#[tokio::test]
async fn serve_with_preserves_adapter_error_when_shutdown_also_fails() {
    let log = Arc::new(std::sync::Mutex::new(Vec::new()));
    let app = BootApplication::builder()
        .import(FailingShutdownModule {
            log: Arc::clone(&log),
        })
        .build()
        .unwrap();

    let result = app
        .serve_with(
            &LifecycleAdapter::failing(Arc::clone(&log)),
            ([127, 0, 0, 1], 0).into(),
        )
        .await;

    assert!(matches!(
        result,
        Err(BootError::Adapter(message)) if message == "serve failed"
    ));
    assert_eq!(
        log.lock().unwrap().as_slice(),
        ["bootstrap", "serve", "shutdown"]
    );
}