nidus-openapi 1.0.4

OpenAPI route metadata collection and document rendering helpers for Nidus applications.
Documentation
use nidus_openapi::{OpenApiDocument, OpenApiDocumentError, OpenApiRoute};
use nidus_testing::TestApp;

#[derive(utoipa::ToSchema)]
#[allow(dead_code)]
struct UserDto {
    id: i32,
    email: String,
}

#[derive(utoipa::ToSchema)]
#[allow(dead_code)]
struct CreateUserDto {
    email: String,
}

#[test]
fn openapi_document_records_routes_and_serves_json() {
    let document = OpenApiDocument::new("Nidus API", "1.0.0")
        .route(OpenApiRoute::get("/users/{id}").summary("Find user by ID"));

    let json = document.to_json_value();

    assert_eq!(json["info"]["title"], "Nidus API");
    assert_eq!(
        json["paths"]["/users/{id}"]["get"]["summary"],
        "Find user by ID"
    );
    assert_eq!(
        json["paths"]["/users/{id}"]["get"]["operationId"],
        "get_users_by_id"
    );
    assert_eq!(
        json["paths"]["/users/{id}"]["get"]["parameters"],
        serde_json::json!([
            {
                "name": "id",
                "in": "path",
                "required": true,
                "schema": {
                    "type": "string"
                }
            }
        ])
    );
}

#[test]
fn openapi_route_builders_normalize_nidus_params() {
    let document = OpenApiDocument::new("Nidus API", "1.0.0")
        .route(OpenApiRoute::get("/users/:id").summary("Find user by ID"));

    let json = document.to_json_value();
    assert_eq!(
        json["paths"]["/users/{id}"]["get"]["summary"],
        "Find user by ID"
    );
    assert_eq!(
        json["paths"]["/users/{id}"]["get"]["operationId"],
        "get_users_by_id"
    );
}

#[test]
fn openapi_route_try_builder_rejects_empty_parameter_name() {
    let error = match OpenApiRoute::try_get("/:") {
        Ok(_) => panic!("empty route parameter should fail"),
        Err(error) => error,
    };

    assert_eq!(error.path(), "/:");
}

#[test]
fn openapi_document_rejects_duplicate_operations() {
    let document = OpenApiDocument::new("Nidus API", "1.0.0")
        .try_route(OpenApiRoute::get("/users/:id"))
        .unwrap();

    let error = document
        .try_route(OpenApiRoute::get("/users/{id}"))
        .unwrap_err();

    assert_eq!(
        error,
        OpenApiDocumentError::DuplicateOperation {
            method: "get".to_owned(),
            path: "/users/{id}".to_owned()
        }
    );
}

#[test]
fn openapi_document_registers_utoipa_schemas() {
    let document = OpenApiDocument::new("Nidus API", "1.0.0").schema::<UserDto>();

    let json = document.to_json_value();
    assert!(json["components"]["schemas"]["UserDto"].is_object());
    assert_eq!(
        json["components"]["schemas"]["UserDto"]["properties"]["email"]["type"],
        "string"
    );
}

#[test]
fn openapi_route_builders_cover_mutation_methods() {
    let document = OpenApiDocument::new("Nidus API", "1.0.0")
        .route(OpenApiRoute::put("/users/{id}").summary("Replace user"))
        .route(OpenApiRoute::patch("/users/{id}").summary("Update user"))
        .route(OpenApiRoute::delete("/users/{id}").summary("Delete user"));

    let json = document.to_json_value();
    assert_eq!(
        json["paths"]["/users/{id}"]["put"]["summary"],
        "Replace user"
    );
    assert_eq!(
        json["paths"]["/users/{id}"]["patch"]["summary"],
        "Update user"
    );
    assert_eq!(
        json["paths"]["/users/{id}"]["delete"]["summary"],
        "Delete user"
    );
}

#[test]
fn openapi_route_can_reference_registered_response_schema() {
    let document = OpenApiDocument::new("Nidus API", "1.0.0")
        .schema::<UserDto>()
        .route(OpenApiRoute::get("/users/{id}").response_schema::<UserDto>());

    let json = document.to_json_value();
    assert_eq!(
        json["paths"]["/users/{id}"]["get"]["responses"]["200"]["content"]["application/json"]["schema"]
            ["$ref"],
        "#/components/schemas/UserDto"
    );
}

#[test]
fn openapi_route_can_set_success_response_status() {
    let document = OpenApiDocument::new("Nidus API", "1.0.0")
        .schema::<UserDto>()
        .route(
            OpenApiRoute::post("/users")
                .response_status(http::StatusCode::CREATED)
                .response_schema::<UserDto>(),
        );

    let json = document.to_json_value();
    assert_eq!(
        json["paths"]["/users"]["post"]["responses"]["201"]["content"]["application/json"]["schema"]
            ["$ref"],
        "#/components/schemas/UserDto"
    );
    assert!(json["paths"]["/users"]["post"]["responses"]["200"].is_null());
}

#[test]
fn openapi_route_can_reference_registered_request_schema() {
    let document = OpenApiDocument::new("Nidus API", "1.0.0")
        .schema::<CreateUserDto>()
        .route(OpenApiRoute::post("/users").request_schema::<CreateUserDto>());

    let json = document.to_json_value();
    assert_eq!(
        json["paths"]["/users"]["post"]["requestBody"]["content"]["application/json"]["schema"]["$ref"],
        "#/components/schemas/CreateUserDto"
    );
    assert_eq!(
        json["paths"]["/users"]["post"]["requestBody"]["required"],
        true
    );
    assert!(json["components"]["schemas"]["CreateUserDto"].is_object());
}

#[test]
fn openapi_route_records_operation_tags() {
    let document = OpenApiDocument::new("Nidus API", "1.0.0").route(
        OpenApiRoute::get("/users/{id}")
            .summary("Find user by ID")
            .tag("users")
            .tag("public"),
    );

    let json = document.to_json_value();
    assert_eq!(
        json["paths"]["/users/{id}"]["get"]["tags"],
        serde_json::json!(["users", "public"])
    );
}

#[test]
fn openapi_route_omits_absent_optional_operation_metadata() {
    let document = OpenApiDocument::new("Nidus API", "1.0.0").route(OpenApiRoute::get("/health"));

    let json = document.to_json_value();
    assert!(json["paths"]["/health"]["get"]["summary"].is_null());
    assert_eq!(json["paths"]["/health"]["get"]["operationId"], "get_health");
    assert!(json["paths"]["/health"]["get"]["tags"].is_null());
    assert!(json["paths"]["/health"]["get"]["requestBody"].is_null());
    assert!(json["paths"]["/health"]["get"]["parameters"].is_null());
    assert!(json["paths"]["/health"]["get"]["x-nidus-guards"].is_null());
    assert!(json["paths"]["/health"]["get"]["x-nidus-pipes"].is_null());
    assert!(json["paths"]["/health"]["get"]["x-nidus-validates"].is_null());
}

#[tokio::test]
async fn openapi_document_serves_json_and_docs_routes() {
    let router = OpenApiDocument::new("Nidus API", "1.0.0")
        .route(OpenApiRoute::get("/users/{id}").summary("Find user by ID"))
        .into_router();
    let app = TestApp::from_router(router);

    let json = app.get("/openapi.json").send().await;
    json.assert_status(http::StatusCode::OK);
    json.assert_json(serde_json::json!({
        "info": {
            "title": "Nidus API",
            "version": "1.0.0"
        },
        "openapi": "3.1.0",
        "paths": {
            "/users/{id}": {
                "get": {
                    "responses": {
                        "200": {
                            "description": "Success"
                        }
                    },
                    "operationId": "get_users_by_id",
                    "parameters": [
                        {
                            "name": "id",
                            "in": "path",
                            "required": true,
                            "schema": {
                                "type": "string"
                            }
                        }
                    ],
                    "summary": "Find user by ID"
                }
            }
        }
    }));

    let docs = app.get("/docs").send().await;
    docs.assert_status(http::StatusCode::OK);
    let html = String::from_utf8(docs.body().to_vec()).unwrap();
    assert!(html.contains("<!doctype html>"));
    assert!(html.contains("<title>Nidus API Documentation</title>"));
    assert!(html.contains("https://cdn.jsdelivr.net/npm/swagger-ui-dist/"));
    assert!(html.contains("url: \"/openapi.json\""));
}