a3s-boot 0.1.3

Adapter-first modular Rust web framework for A3S inspired by Nest.js
Documentation
#![cfg(all(feature = "macros", feature = "session"))]

use std::sync::Arc;
use std::time::Duration;

use a3s_boot::{
    controller, injectable, BootApplication, BootRequest, ControllerDefinition, HttpMethod, Module,
    Result, Session, SessionManager, SessionModule, SessionOptions,
};
#[allow(unused_imports)]
use a3s_boot::{get, session};

#[injectable]
#[derive(Debug)]
struct SessionController;

#[controller("/sessions")]
impl SessionController {
    #[get("/login")]
    async fn login(&self, #[session] session: Session) -> Result<String> {
        session.set("user_id", &"u1")?;
        Ok(session.id().to_string())
    }

    #[get("/profile")]
    async fn profile(&self, #[session] session: Session) -> Result<String> {
        let user_id = session
            .get::<String>("user_id")?
            .unwrap_or_else(|| "anonymous".to_string());
        Ok(user_id)
    }

    #[get("/optional")]
    async fn optional(&self, #[session] session: Option<Session>) -> Result<String> {
        let label = if session.is_some() { "session" } else { "none" };
        Ok(label.to_string())
    }

    #[get("/logout")]
    async fn logout(&self, #[session] session: Session) -> Result<String> {
        session.destroy()?;
        Ok("logged out".to_string())
    }
}

#[derive(Debug)]
struct SessionFeatureModule;

impl Module for SessionFeatureModule {
    fn name(&self) -> &'static str {
        "session-feature"
    }

    fn controllers(&self, _module_ref: &a3s_boot::ModuleRef) -> Result<Vec<ControllerDefinition>> {
        Ok(vec![Arc::new(SessionController).controller()?])
    }
}

#[tokio::test]
async fn session_macro_extracts_request_bound_session_handles() {
    let manager = SessionManager::in_memory(
        SessionOptions::new()
            .with_cookie_name("sid")
            .with_ttl(Duration::from_secs(60)),
    );
    let app = BootApplication::builder()
        .use_global_session_module(SessionModule::from_manager("sessions", manager))
        .import(SessionFeatureModule)
        .build()
        .unwrap();

    let login = app
        .call(BootRequest::new(HttpMethod::Get, "/sessions/login"))
        .await
        .unwrap();
    let cookie = cookie_pair(login.header_values("set-cookie")[0]);
    let profile = app
        .call(BootRequest::new(HttpMethod::Get, "/sessions/profile").with_header("cookie", cookie))
        .await
        .unwrap();

    assert_eq!(profile.body_json::<String>().unwrap(), "u1");
    assert!(login.header_values("set-cookie")[0].starts_with("sid="));
}

#[tokio::test]
async fn session_macro_supports_optional_sessions() {
    let app_without_sessions = BootApplication::builder()
        .import(SessionFeatureModule)
        .build()
        .unwrap();
    let missing = app_without_sessions
        .call(BootRequest::new(HttpMethod::Get, "/sessions/optional"))
        .await
        .unwrap();
    assert_eq!(missing.body_json::<String>().unwrap(), "none");

    let app_with_sessions = BootApplication::builder()
        .use_global_session_module(SessionModule::in_memory("sessions"))
        .import(SessionFeatureModule)
        .build()
        .unwrap();
    let present = app_with_sessions
        .call(BootRequest::new(HttpMethod::Get, "/sessions/optional"))
        .await
        .unwrap();
    assert_eq!(present.body_json::<String>().unwrap(), "session");
}

#[tokio::test]
async fn session_macro_destroy_clears_session_cookie() {
    let app = BootApplication::builder()
        .use_global_session_module(SessionModule::in_memory_with_options(
            "sessions",
            SessionOptions::new().with_cookie_name("sid"),
        ))
        .import(SessionFeatureModule)
        .build()
        .unwrap();

    let login = app
        .call(BootRequest::new(HttpMethod::Get, "/sessions/login"))
        .await
        .unwrap();
    let cookie = cookie_pair(login.header_values("set-cookie")[0]);
    let logout = app
        .call(BootRequest::new(HttpMethod::Get, "/sessions/logout").with_header("cookie", cookie))
        .await
        .unwrap();

    assert_eq!(logout.body_json::<String>().unwrap(), "logged out");
    assert!(logout.header_values("set-cookie")[0].starts_with("sid=;"));
    assert!(logout.header_values("set-cookie")[0].contains("Max-Age=0"));
}

fn cookie_pair(set_cookie: &str) -> String {
    set_cookie
        .split(';')
        .next()
        .expect("set-cookie header should contain a cookie pair")
        .to_string()
}