xecute 0.1.8

Modules and dependency injection for Axum
Documentation
//! Verify middleware scopes, ordering, early returns, and validation boundaries.

use axum::{
    Extension,
    body::{Body, to_bytes},
    http::{Request as HttpRequest, StatusCode},
};
use std::sync::{
    Arc, Mutex,
    atomic::{AtomicUsize, Ordering},
};
use tower::ServiceExt;
use xecute::{
    middleware::{Next, Request, Response},
    prelude::*,
};

#[derive(Clone, Default)]
struct Trace(Arc<Mutex<Vec<&'static str>>>);

async fn record(request: Request, next: Next, enter: &'static str, exit: &'static str) -> Response {
    let trace = request.extensions().get::<Trace>().unwrap().clone();
    trace.0.lock().unwrap().push(enter);
    let response = next.run(request).await;
    trace.0.lock().unwrap().push(exit);
    response
}
async fn app_first(request: Request, next: Next) -> Response {
    record(request, next, "app1 in", "app1 out").await
}
async fn app_second(request: Request, next: Next) -> Response {
    record(request, next, "app2 in", "app2 out").await
}
async fn controller_first(request: Request, next: Next) -> Response {
    record(request, next, "controller1 in", "controller1 out").await
}
async fn controller_second(request: Request, next: Next) -> Response {
    record(request, next, "controller2 in", "controller2 out").await
}
async fn route_first(request: Request, next: Next) -> Response {
    record(request, next, "route1 in", "route1 out").await
}
async fn route_second(request: Request, next: Next) -> Response {
    record(request, next, "route2 in", "route2 out").await
}
async fn deny(_: Request, _: Next) -> StatusCode {
    StatusCode::FORBIDDEN
}

#[derive(serde::Deserialize, validator::Validate, schemars::JsonSchema)]
struct Payload {
    #[validate(length(min = 3))]
    name: String,
}

#[controller("/pipeline")]
#[middleware(controller_first, controller_second)]
#[derive(Injectable)]
struct PipelineController {
    #[inject(default)]
    calls: AtomicUsize,
}

#[routes]
impl PipelineController {
    #[get("")]
    #[middleware(route_first, route_second)]
    async fn get(&self, trace: Extension<Trace>) -> &'static str {
        self.calls.fetch_add(1, Ordering::SeqCst);
        trace.0.0.lock().unwrap().push("handler");
        "ok"
    }

    #[post("")]
    async fn create(&self, #[body] payload: Payload) -> String {
        self.calls.fetch_add(1, Ordering::SeqCst);
        payload.name
    }

    #[get("/denied")]
    #[middleware(deny)]
    async fn denied(&self) -> &'static str {
        self.calls.fetch_add(1, Ordering::SeqCst);
        "must never run"
    }
}

fn app(trace: Trace) -> (axum::Router, Arc<PipelineController>) {
    let controller = xecute::Container::builder()
        .injectable::<PipelineController>()
        .build()
        .unwrap()
        .resolve::<PipelineController>()
        .unwrap();
    let app = Application::new(PipelineController::router(controller.clone()))
        .use_middleware(app_first)
        .use_middleware(app_second)
        .into_router()
        .layer(Extension(trace));
    (app, controller)
}

#[tokio::test]
async fn scopes_run_in_declaration_order_and_unwind_in_reverse() {
    let trace = Trace::default();
    let (app, controller) = app(trace.clone());
    let response = app
        .oneshot(
            HttpRequest::builder()
                .uri("/pipeline")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(response.status(), StatusCode::OK);
    assert_eq!(to_bytes(response.into_body(), 1024).await.unwrap(), "ok");
    assert_eq!(controller.calls.load(Ordering::SeqCst), 1);
    assert_eq!(
        *trace.0.lock().unwrap(),
        vec![
            "app1 in",
            "app2 in",
            "controller1 in",
            "controller2 in",
            "route1 in",
            "route2 in",
            "handler",
            "route2 out",
            "route1 out",
            "controller2 out",
            "controller1 out",
            "app2 out",
            "app1 out",
        ]
    );
}

#[tokio::test]
async fn route_scope_is_method_specific_and_validation_still_short_circuits() {
    let trace = Trace::default();
    let (app, controller) = app(trace.clone());
    for (body, expected) in [
        (r#"{"name":"valid"}"#, 200),
        (r#"{"name":"a"}"#, 422),
        ("broken", 400),
    ] {
        let response = app
            .clone()
            .oneshot(
                HttpRequest::builder()
                    .method("POST")
                    .uri("/pipeline")
                    .header("content-type", "application/json")
                    .body(Body::from(body))
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(response.status().as_u16(), expected);
    }
    assert_eq!(controller.calls.load(Ordering::SeqCst), 1);
    assert!(!trace.0.lock().unwrap().contains(&"route1 in"));
    assert_eq!(
        trace
            .0
            .lock()
            .unwrap()
            .iter()
            .filter(|s| **s == "controller1 in")
            .count(),
        3
    );
}

#[tokio::test]
async fn early_response_skips_handler_and_unrelated_routes_skip_controller_middleware() {
    let trace = Trace::default();
    let (app, controller) = app(trace.clone());
    let response = app
        .clone()
        .oneshot(
            HttpRequest::builder()
                .uri("/pipeline/denied")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(response.status(), StatusCode::FORBIDDEN);
    assert_eq!(controller.calls.load(Ordering::SeqCst), 0);
    trace.0.lock().unwrap().clear();
    let response = app
        .oneshot(
            HttpRequest::builder()
                .uri("/missing")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(response.status(), StatusCode::NOT_FOUND);
    assert_eq!(
        *trace.0.lock().unwrap(),
        ["app1 in", "app2 in", "app2 out", "app1 out"]
    );
}

#[tokio::test]
async fn app_middleware_accepts_captured_state_and_can_return_early() {
    let (router, controller) = app(Trace::default());
    let calls = Arc::new(AtomicUsize::new(0));
    let counter = calls.clone();
    let router = Application::new(router)
        .use_middleware(move |_: Request, _: Next| {
            let counter = counter.clone();
            async move {
                counter.fetch_add(1, Ordering::SeqCst);
                StatusCode::UNAUTHORIZED
            }
        })
        .into_router();
    let response = router
        .oneshot(
            HttpRequest::builder()
                .uri("/pipeline")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
    assert_eq!(calls.load(Ordering::SeqCst), 1);
    assert_eq!(controller.calls.load(Ordering::SeqCst), 0);
}

#[controller("/impl")]
#[derive(Injectable)]
struct ImplController {}

#[routes]
#[middleware(controller_first)]
impl ImplController {
    #[get("")]
    #[middleware(route_first)]
    #[middleware(route_second)]
    async fn get(&self) -> &'static str {
        "ok"
    }
}

#[derive(Injectable)]
struct LegacyPipelineController {}

#[controller("/legacy-pipeline")]
#[middleware(controller_first)]
impl LegacyPipelineController {
    #[get("")]
    #[middleware(route_first)]
    async fn get() -> &'static str {
        "ok"
    }
}

#[tokio::test]
async fn impl_annotations_and_legacy_controller_syntax_preserve_order() {
    for (router, uri, expected) in [
        (
            ImplController::router(Arc::new(ImplController {})),
            "/impl",
            vec![
                "controller1 in",
                "route1 in",
                "route2 in",
                "route2 out",
                "route1 out",
                "controller1 out",
            ],
        ),
        (
            LegacyPipelineController::router(Arc::new(LegacyPipelineController {})),
            "/legacy-pipeline",
            vec![
                "controller1 in",
                "route1 in",
                "route1 out",
                "controller1 out",
            ],
        ),
    ] {
        let trace = Trace::default();
        let response = router
            .layer(Extension(trace.clone()))
            .oneshot(HttpRequest::builder().uri(uri).body(Body::empty()).unwrap())
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::OK);
        assert_eq!(*trace.0.lock().unwrap(), expected);
    }
}