minco-http 1.0.0

Axum and Tower HTTP conventions, principals, request IDs, limits, and RFC 9457 errors for Minco
Documentation
use minco_http::{
    ApiFailure, Cursor, EntityTagError, ResourceCollection, ResourceDocument, ResourceListPolicy,
    ResourceQueryError, SortDirection, StrongEntityTag, parse_if_match, parse_resource_list_query,
};
use serde::{Deserialize, Serialize};

#[test]
fn resource_list_query_is_bounded_and_allowlisted() {
    let policy = ResourceListPolicy::new(
        20,
        100,
        ["-createdAt", "-id"],
        ["createdAt", "id"],
        ["status"],
    )
    .expect("valid policy");

    let query = parse_resource_list_query(
        Some(
            "page%5Blimit%5D=2&page%5Bafter%5D=opaque-1&sort=-createdAt,id&filter%5Bstatus%5D=accepted",
        ),
        &policy,
    )
    .expect("valid list query");

    assert_eq!(query.limit(), 2);
    assert_eq!(query.after().map(Cursor::as_str), Some("opaque-1"));
    assert_eq!(query.sort()[0].field(), "createdAt");
    assert_eq!(query.sort()[0].direction(), SortDirection::Descending);
    assert_eq!(query.sort()[1].field(), "id");
    assert_eq!(query.sort()[1].direction(), SortDirection::Ascending);
    assert_eq!(
        query.filters().get("status").map(String::as_str),
        Some("accepted")
    );
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ExampleResource {
    id: String,
}

#[test]
fn resource_success_documents_have_one_stable_json_shape() {
    let item = ExampleResource { id: "one".into() };
    let document = ResourceDocument::new(item.clone());
    assert_eq!(
        serde_json::to_value(document).expect("resource document"),
        serde_json::json!({ "data": { "id": "one" } })
    );

    let collection = ResourceCollection::new(
        vec![item],
        Some(Cursor::new("next-1").expect("valid cursor")),
    );
    assert_eq!(
        serde_json::to_value(collection).expect("resource collection"),
        serde_json::json!({
            "data": [{ "id": "one" }],
            "page": {
                "hasMore": true,
                "nextCursor": "next-1"
            }
        })
    );
}

#[test]
fn strong_resource_etags_round_trip_through_if_match() {
    let tag =
        StrongEntityTag::for_resource("order", "018f-example", 3).expect("valid resource tag");
    let mut headers = http::HeaderMap::new();
    headers.insert(http::header::IF_MATCH, tag.to_header_value());

    assert_eq!(parse_if_match(&headers).expect("valid If-Match"), tag);
    assert_eq!(
        tag.resource_revision("order", "018f-example")
            .expect("matching resource tag"),
        3
    );
    assert_eq!(
        tag.resource_revision("order", "different"),
        Err(EntityTagError::InvalidIfMatch)
    );
    assert_eq!(
        parse_if_match(&http::HeaderMap::new()),
        Err(EntityTagError::PreconditionRequired)
    );
}

#[test]
fn weak_wildcard_and_multiple_if_match_values_are_rejected() {
    for value in [r#"W/"order:one:1""#, "*", r#""order:one:1", "order:one:2""#] {
        let mut headers = http::HeaderMap::new();
        headers.insert(
            http::header::IF_MATCH,
            value.parse().expect("valid header syntax"),
        );
        assert_eq!(
            parse_if_match(&headers),
            Err(EntityTagError::InvalidIfMatch),
            "{value}"
        );
    }
}

#[test]
fn resource_query_rejects_duplicates_unknown_fields_and_unbounded_limits() {
    let policy = ResourceListPolicy::new(
        20,
        100,
        ["-createdAt", "-id"],
        ["createdAt", "id"],
        ["status"],
    )
    .expect("valid policy");

    for (query, expected) in [
        (
            "page%5Blimit%5D=1&page%5Blimit%5D=2",
            ResourceQueryError::DuplicateParameter,
        ),
        (
            "filter%5BcustomerReference%5D=private",
            ResourceQueryError::InvalidFilter,
        ),
        ("page%5Blimit%5D=101", ResourceQueryError::InvalidLimit),
        ("sort=customerReference", ResourceQueryError::InvalidSort),
        ("include=lines", ResourceQueryError::UnsupportedParameter),
    ] {
        assert_eq!(
            parse_resource_list_query(Some(query), &policy),
            Err(expected),
            "{query}"
        );
    }
}

#[test]
fn resource_preconditions_have_stable_problem_codes() {
    let missing = ApiFailure::precondition_required("request-1");
    assert_eq!(missing.status, http::StatusCode::PRECONDITION_REQUIRED);
    assert_eq!(&*missing.code, "precondition_required");

    let stale = ApiFailure::precondition_failed("request-2");
    assert_eq!(stale.status, http::StatusCode::PRECONDITION_FAILED);
    assert_eq!(&*stale.code, "precondition_failed");

    let invalid = ApiFailure::invalid_if_match("request-3");
    assert_eq!(invalid.status, http::StatusCode::BAD_REQUEST);
    assert_eq!(&*invalid.code, "invalid_if_match");
}