a3s-boot 0.1.2

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

use a3s_boot::{
    BootApplication, BootRequest, BootResponse, HttpMethod, InMemoryLogSink, LogFields, LogLevel,
    Logger, LoggingModule, RequestLoggingInterceptor, RequestLoggingMiddleware, RouteDefinition,
};
use serde_json::Value;
use std::sync::Arc;

#[test]
fn logger_writes_structured_records_to_in_memory_sink() {
    let sink = InMemoryLogSink::new();
    let logger = Logger::new(sink.clone())
        .with_target("cats")
        .with_default_field("service", "catalog")
        .unwrap();

    logger
        .log_with_fields(
            LogLevel::Info,
            "cat created",
            LogFields::new().with("cat_id", "42").unwrap(),
        )
        .unwrap();

    let records = sink.records().unwrap();
    assert_eq!(records.len(), 1);
    assert_eq!(records[0].level, LogLevel::Info);
    assert_eq!(records[0].target, "cats");
    assert_eq!(records[0].message, "cat created");
    assert_eq!(records[0].field("service"), Some(&Value::from("catalog")));
    assert_eq!(records[0].field("cat_id"), Some(&Value::from("42")));
}

#[test]
fn logging_module_exports_named_and_global_logger_providers() {
    let named = BootApplication::builder()
        .import(LoggingModule::noop("named-logging").named("app-logger"))
        .build()
        .unwrap();
    assert!(named.get_named::<Logger>("app-logger").is_ok());
    assert!(named.get_optional::<Logger>().unwrap().is_none());

    let global = BootApplication::builder()
        .import(LoggingModule::noop("global-logging").global())
        .build()
        .unwrap();
    assert!(global.get::<Logger>().is_ok());
}

#[tokio::test]
async fn request_logging_interceptor_records_request_and_response() {
    let sink = InMemoryLogSink::new();
    let logger = Arc::new(Logger::new(sink.clone()).with_target("http"));
    let app = BootApplication::builder()
        .use_global_interceptor(RequestLoggingInterceptor::new(Arc::clone(&logger)))
        .route(
            RouteDefinition::post("/cats", |_| async {
                BootResponse::json_with_status(201, &serde_json::json!({ "id": "42" }))
            })
            .unwrap(),
        )
        .build()
        .unwrap();

    let response = app
        .call(BootRequest::new(HttpMethod::Post, "/cats?debug=true"))
        .await
        .unwrap();
    assert_eq!(response.status(), 201);

    let records = sink.records().unwrap();
    assert_eq!(records.len(), 2);
    assert_eq!(records[0].message, "request started");
    assert_eq!(records[0].field("method"), Some(&Value::from("POST")));
    assert_eq!(
        records[0].field("request_path"),
        Some(&Value::from("/cats"))
    );
    assert_eq!(records[0].field("route_path"), Some(&Value::from("/cats")));

    assert_eq!(records[1].message, "request completed");
    assert_eq!(records[1].field("status"), Some(&Value::from(201)));
}

#[tokio::test]
async fn request_logging_middleware_records_requests_before_handlers() {
    let sink = InMemoryLogSink::new();
    let logger = Arc::new(Logger::new(sink.clone()).with_target("http"));
    let app = BootApplication::builder()
        .use_global_middleware(RequestLoggingMiddleware::new(logger))
        .route(RouteDefinition::get("/health", |_| async { Ok(BootResponse::text("ok")) }).unwrap())
        .build()
        .unwrap();

    let response = app
        .call(BootRequest::new(HttpMethod::Get, "/health").with_text("ping"))
        .await
        .unwrap();
    assert_eq!(response.status(), 200);

    let records = sink.records().unwrap();
    assert_eq!(records.len(), 1);
    assert_eq!(records[0].message, "request received");
    assert_eq!(records[0].field("method"), Some(&Value::from("GET")));
    assert_eq!(records[0].field("path"), Some(&Value::from("/health")));
    assert_eq!(records[0].field("body_bytes"), Some(&Value::from(4)));
}