a3s-boot 0.1.2

Adapter-first modular Rust web framework for A3S inspired by Nest.js
Documentation
use a3s_boot::{
    ApiVersioning, BootApplication, BootError, BootRequest, BootResponse, ControllerDefinition,
    HttpMethod, RouteDefinition,
};

#[tokio::test]
async fn uri_versioning_dispatches_to_versioned_routes_and_decodes_params() {
    let app = BootApplication::builder()
        .enable_api_versioning(ApiVersioning::uri())
        .route(
            RouteDefinition::get("/cats/{id}", |request: BootRequest| async move {
                Ok(BootResponse::text(format!(
                    "{}:{}",
                    request.path(),
                    request.param("id").unwrap_or("missing")
                )))
            })
            .unwrap()
            .with_version("1"),
        )
        .build()
        .unwrap();

    let route_match = app
        .route_match(HttpMethod::Get, "/v1/cats/milo%2Egray")
        .unwrap()
        .unwrap();
    let response = app
        .call(BootRequest::new(HttpMethod::Get, "/v1/cats/milo%2Egray"))
        .await
        .unwrap();

    assert_eq!(route_match.route().path(), "/cats/{id}");
    assert_eq!(route_match.param("id"), Some("milo.gray"));
    assert_eq!(
        app.allowed_methods("/v1/cats/milo.gray"),
        vec![HttpMethod::Get]
    );
    assert_eq!(response.body_text().unwrap(), "/cats/milo%2Egray:milo.gray");
}

#[tokio::test]
async fn header_versioning_selects_routes_with_the_same_method_and_path() {
    let app = BootApplication::builder()
        .enable_api_versioning(ApiVersioning::header("x-api-version"))
        .route(
            RouteDefinition::get("/cats", |_| async { Ok(BootResponse::text("v1")) })
                .unwrap()
                .with_version("1"),
        )
        .route(
            RouteDefinition::get("/cats", |_| async { Ok(BootResponse::text("v2")) })
                .unwrap()
                .with_version("2"),
        )
        .build()
        .unwrap();

    let v1 = app
        .call(BootRequest::new(HttpMethod::Get, "/cats").with_header("x-api-version", "1"))
        .await
        .unwrap();
    let v2 = app
        .call(BootRequest::new(HttpMethod::Get, "/cats").with_header("x-api-version", "2"))
        .await
        .unwrap();
    let missing = app
        .call(BootRequest::new(HttpMethod::Get, "/cats"))
        .await
        .unwrap_err();

    assert_eq!(v1.body_text().unwrap(), "v1");
    assert_eq!(v2.body_text().unwrap(), "v2");
    assert!(matches!(missing, BootError::NotFound(_)));
}

#[tokio::test]
async fn media_type_versioning_reads_accept_parameters() {
    let app = BootApplication::builder()
        .enable_api_versioning(ApiVersioning::media_type())
        .route(
            RouteDefinition::get("/cats", |_| async { Ok(BootResponse::text("v1")) })
                .unwrap()
                .with_version("1"),
        )
        .route(
            RouteDefinition::get("/cats", |_| async { Ok(BootResponse::text("v2")) })
                .unwrap()
                .with_version("2"),
        )
        .build()
        .unwrap();

    let response = app
        .call(
            BootRequest::new(HttpMethod::Get, "/cats")
                .with_header("accept", "application/json; v=2"),
        )
        .await
        .unwrap();

    assert_eq!(response.body_text().unwrap(), "v2");
}

#[tokio::test]
async fn default_versions_and_neutral_routes_match_expected_requests() {
    let app = BootApplication::builder()
        .enable_api_versioning(ApiVersioning::header("x-api-version").with_default_version("1"))
        .route(
            RouteDefinition::get("/legacy", |_| async { Ok(BootResponse::text("legacy")) })
                .unwrap(),
        )
        .route(
            RouteDefinition::get("/cats", |_| async { Ok(BootResponse::text("v1")) })
                .unwrap()
                .with_version("1"),
        )
        .route(
            RouteDefinition::get("/health", |_| async { Ok(BootResponse::text("ok")) })
                .unwrap()
                .version_neutral(),
        )
        .build()
        .unwrap();

    let defaulted = app
        .call(BootRequest::new(HttpMethod::Get, "/cats"))
        .await
        .unwrap();
    let legacy_default = app
        .call(BootRequest::new(HttpMethod::Get, "/legacy").with_header("x-api-version", "1"))
        .await
        .unwrap();
    let neutral = app
        .call(BootRequest::new(HttpMethod::Get, "/health").with_header("x-api-version", "99"))
        .await
        .unwrap();
    let legacy_wrong_version = app
        .call(BootRequest::new(HttpMethod::Get, "/legacy").with_header("x-api-version", "2"))
        .await
        .unwrap_err();

    assert_eq!(defaulted.body_text().unwrap(), "v1");
    assert_eq!(legacy_default.body_text().unwrap(), "legacy");
    assert_eq!(neutral.body_text().unwrap(), "ok");
    assert!(matches!(legacy_wrong_version, BootError::NotFound(_)));
}

#[tokio::test]
async fn controller_versions_are_inherited_by_routes() {
    let controller = ControllerDefinition::new("/cats")
        .unwrap()
        .with_version("2")
        .get("/{id}", |request: BootRequest| async move {
            Ok(BootResponse::text(
                request.param("id").unwrap_or("missing").to_string(),
            ))
        })
        .unwrap();

    let app = BootApplication::builder()
        .enable_api_versioning(ApiVersioning::uri())
        .route(controller.routes()[0].clone())
        .build()
        .unwrap();

    let response = app
        .call(BootRequest::new(HttpMethod::Get, "/v2/cats/luna"))
        .await
        .unwrap();

    assert_eq!(response.body_text().unwrap(), "luna");
}

#[test]
fn versioned_route_duplicates_are_checked_by_version_overlap() {
    let app = BootApplication::builder()
        .enable_api_versioning(ApiVersioning::header("x-api-version"))
        .route(
            RouteDefinition::get("/cats", |_| async { Ok(BootResponse::text("v1")) })
                .unwrap()
                .with_version("1"),
        )
        .route(
            RouteDefinition::get("/cats", |_| async { Ok(BootResponse::text("v2")) })
                .unwrap()
                .with_version("2"),
        )
        .build()
        .unwrap();

    let duplicate = BootApplication::builder()
        .enable_api_versioning(ApiVersioning::header("x-api-version"))
        .route(
            RouteDefinition::get("/cats", |_| async { Ok(BootResponse::text("v1")) })
                .unwrap()
                .with_version("1"),
        )
        .route(
            RouteDefinition::get("/cats", |_| async { Ok(BootResponse::text("also v1")) })
                .unwrap()
                .with_versions(["1", "2"]),
        )
        .build();

    assert_eq!(app.routes().len(), 2);
    assert!(matches!(duplicate, Err(BootError::DuplicateRoute(_))));
}