cinderblock-json-api 0.5.0

JSON REST API extension for cinderblock with Axum router, auto-registered endpoints, and OpenAPI support
Documentation
use assert2::check;
use cinderblock_core::{
    Context, resource,
    serde::{Deserialize, Serialize},
};
use http_body_util::BodyExt;
use tower::ServiceExt;
use uuid::Uuid;

#[derive(
    Debug,
    Clone,
    Default,
    PartialEq,
    Eq,
    Serialize,
    Deserialize,
    cinderblock_json_api::utoipa::ToSchema,
)]
enum Priority {
    #[default]
    Low,
    Medium,
    High,
}

cinderblock_json_api::impl_field_schema!(Priority);

resource! {
    name = Test.JsonApi.OptionItem;

    attributes {
        item_id Uuid {
            primary_key true;
            writable false;
            default || uuid::Uuid::new_v4();
        }

        title String;
        description Option<String>;
        priority Option<Priority>;
    }

    actions {
        read all;

        create add;

        update edit;
    }

    extensions {
        cinderblock_json_api {
            route = { method = GET; path = "/"; action = all; };
            route = { method = POST; path = "/"; action = add; };
            route = { method = PATCH; path = "/{primary_key}"; action = edit; };
        };
    }
}

fn build_app() -> axum::Router {
    cinderblock_json_api::router(Context::new())
}

#[tokio::test]
async fn create_with_optional_fields_set_to_null() {
    let app = build_app();

    let body = serde_json::json!({
        "title": "No description",
        "description": null,
        "priority": null,
    });

    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .method("POST")
                .uri("/test/json-api/option-item/")
                .header("content-type", "application/json")
                .body(axum::body::Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();

    check!(resp.status() == axum::http::StatusCode::CREATED);

    let bytes = resp.into_body().collect().await.unwrap().to_bytes();
    let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();

    check!(json["data"]["title"] == "No description");
    check!(json["data"]["description"] == serde_json::Value::Null);
    check!(json["data"]["priority"] == serde_json::Value::Null);
}

#[tokio::test]
async fn create_with_optional_fields_set_to_values() {
    let app = build_app();

    let body = serde_json::json!({
        "title": "Has everything",
        "description": "A real description",
        "priority": "High",
    });

    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .method("POST")
                .uri("/test/json-api/option-item/")
                .header("content-type", "application/json")
                .body(axum::body::Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();

    check!(resp.status() == axum::http::StatusCode::CREATED);

    let bytes = resp.into_body().collect().await.unwrap().to_bytes();
    let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();

    check!(json["data"]["title"] == "Has everything");
    check!(json["data"]["description"] == "A real description");
    check!(json["data"]["priority"] == "High");
}

#[tokio::test]
async fn create_with_optional_fields_omitted() {
    let app = build_app();

    let body = serde_json::json!({
        "title": "Minimal",
    });

    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .method("POST")
                .uri("/test/json-api/option-item/")
                .header("content-type", "application/json")
                .body(axum::body::Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();

    check!(resp.status() == axum::http::StatusCode::CREATED);

    let bytes = resp.into_body().collect().await.unwrap().to_bytes();
    let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();

    check!(json["data"]["title"] == "Minimal");
    check!(json["data"]["description"] == serde_json::Value::Null);
    check!(json["data"]["priority"] == serde_json::Value::Null);
}

#[tokio::test]
async fn read_returns_optional_fields() {
    let ctx = Context::new();

    cinderblock_core::create::<OptionItem, Add>(
        AddInput {
            title: "With desc".into(),
            description: Some("desc value".into()),
            priority: Some(Priority::High),
        },
        &ctx,
    )
    .await
    .expect("seed item with values");

    cinderblock_core::create::<OptionItem, Add>(
        AddInput {
            title: "Without desc".into(),
            description: None,
            priority: None,
        },
        &ctx,
    )
    .await
    .expect("seed item without values");

    let app = build_app();

    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .method("GET")
                .uri("/test/json-api/option-item/")
                .body(axum::body::Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();

    check!(resp.status() == axum::http::StatusCode::OK);

    let bytes = resp.into_body().collect().await.unwrap().to_bytes();
    let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();

    let data = json["data"].as_array().expect("data should be an array");
    check!(data.len() >= 2);

    let with_desc = data
        .iter()
        .find(|item| item["title"] == "With desc")
        .unwrap();
    check!(with_desc["description"] == "desc value");
    check!(with_desc["priority"] == "High");

    let without_desc = data
        .iter()
        .find(|item| item["title"] == "Without desc")
        .unwrap();
    check!(without_desc["description"] == serde_json::Value::Null);
    check!(without_desc["priority"] == serde_json::Value::Null);
}

#[tokio::test]
async fn openapi_schema_marks_optional_fields_as_not_required() {
    let mut merged = cinderblock_json_api::utoipa::openapi::OpenApiBuilder::new()
        .info(
            cinderblock_json_api::utoipa::openapi::InfoBuilder::new()
                .title("test")
                .version("0.0.0")
                .build(),
        )
        .build();

    for endpoint in inventory::iter::<cinderblock_json_api::ResourceEndpoint> {
        if let Some(openapi_fn) = endpoint.openapi {
            merged.merge(openapi_fn());
        }
    }

    let spec = serde_json::to_value(&merged).unwrap();

    let option_item_schema = &spec["components"]["schemas"]["OptionItem"];
    let required = option_item_schema["required"]
        .as_array()
        .expect("required should be an array");

    let required_names: Vec<&str> = required.iter().filter_map(|v| v.as_str()).collect();
    check!(required_names.contains(&"item_id"));
    check!(required_names.contains(&"title"));
    check!(!required_names.contains(&"description"));
    check!(!required_names.contains(&"priority"));
}