jsonapi_axum 1.0.0-rc.1

axum adapter for jsonapi_core: JSON:API extractors, responders, and tower layers built on jsonapi_http
Documentation

jsonapi_axum

axum adapter for jsonapi_core — build JSON:API v1.1 servers in Rust with typed extractors, responders, and content-negotiation middleware.

It is a thin binding: all JSON:API logic lives in the framework-agnostic jsonapi_http crate, and jsonapi_axum only provides axum's FromRequest / FromRequestParts / IntoResponse glue. The same building blocks can back adapters for other frameworks.

What you get

Type Role
JsonApi<T> Extract and deserialize a typed request document (415 / 400 / 409 / 422 on failure, with source.pointer).
JsonApiQuery Parse sort / page / filter / fields / include into a typed Query. Needs no application state.
JsonApiQueryValidated<T> Like JsonApiQuery, but also validates include paths against a TypeRegistry in state. Opt-in.
JsonApiResponse<P, I> An IntoResponse that serializes a document with the JSON:API media type. Supports custom status and ext/profile.
JsonApiError The shared rejection + error responder — every failure renders as a spec-shaped error document.
ContentTypeLayer / AcceptLayer / JsonApiLayer tower layers that enforce Content-Type (415) and Accept (406) for a whole router.

Install

[dependencies]
jsonapi_axum = "1.0.0-rc.1"
# Required to derive JsonApi on your resource types (the derive macro expands to
# ::jsonapi_core paths, so the crate must be a direct dependency):
jsonapi_core = "1.0.0-rc.1"
axum = "0.8"

The most-used jsonapi_core runtime types (Document, DocumentBuilder, Query, TypeRegistry, ApiError, Resource, Relationship, JsonApiMediaType, cursor-pagination helpers) are re-exported from jsonapi_axum, so handler code can import from one crate. The whole crate is also available as jsonapi_axum::jsonapi_core.

Quick start

use axum::routing::get;
use axum::Router;
use jsonapi_axum::{DocumentBuilder, JsonApi, JsonApiLayer, JsonApiResponse};

#[derive(Clone, jsonapi_core::JsonApi)]
#[jsonapi(type = "articles")]
struct Article {
    #[jsonapi(id)]
    id: String,
    title: String,
}

// POST /articles — deserialize a typed JSON:API document from the request body.
async fn create(JsonApi(document): JsonApi<Article>) -> JsonApiResponse<Article> {
    let article = document.into_single().expect("single resource");
    JsonApiResponse::new(DocumentBuilder::single(article).build())
}

#[tokio::main]
async fn main() {
    let app: Router = Router::new()
        .route("/articles", get(|| async { "" }).post(create))
        // One layer enforces JSON:API content negotiation for the whole API:
        // 415 for a bad request Content-Type, 406 for an unacceptable Accept.
        .layer(JsonApiLayer::new());

    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

A complete, runnable CRUD server (list / get / create / update / delete with an in-memory store) lives in examples/crud_server.rs:

cargo run -p jsonapi_axum --example crud_server

Content negotiation

ContentTypeLayer / JsonApiLayer only enforce Content-Type on body-bearing methods (POST / PUT / PATCH) — a GET or DELETE without a body is never rejected with a 415. AcceptLayer / JsonApiLayer negotiate the Accept header (returning 406 when nothing is acceptable) and store the negotiated JsonApiMediaType in the request extensions. A handler that wants the response Content-Type to carry negotiated ext/profile reads it (e.g. axum::Extension<JsonApiMediaType>) and passes it to JsonApiResponse::media_type — a responder's IntoResponse cannot see the request, so it cannot apply it automatically.

Validating includes

JsonApiQueryValidated<T> validates include paths against a TypeRegistry, rooted at T's resource type. Provide the registry through state as Arc<TypeRegistry> (via axum's FromRef):

async fn list(query: JsonApiQueryValidated<Article>) -> JsonApiResponse<Article> {
    // `query.query` is parsed and its include paths are known-valid for `articles`.
    # unimplemented!()
}

// Router::new().route("/articles", get(list)).with_state(Arc::new(registry))

Apps that don't need include validation use JsonApiQuery, which requires no state.

Compound documents / includes

resolve_includes assembles the deduped included array of a compound document from the requested include paths. You implement IncludeResolver — a batch loader keyed by identity — and the library walks the linkage (including transitive paths like comments.author), dedups by (type, id), and calls your loader once per type per level (no N+1). It never touches a datastore and never applies sort/filter/page — fetching and those concerns stay yours.

use std::future::Future;
use jsonapi_axum::{resolve_includes, IncludeResolver, DocumentBuilder, JsonApiResponse};
use jsonapi_core::Resource;

struct Store { /* your data */ }

impl IncludeResolver for Store {
    type Error = MyError; // mapped to JsonApiError at the edge (see below)

    fn load(&self, type_name: &str, ids: &[String])
        -> impl Future<Output = Result<Vec<Resource>, Self::Error>> + Send
    {
        // Fetch the resources of `type_name` with these ids and return them as
        // dynamic `Resource`s (e.g. via `Resource::from_typed`).
        # unimplemented!()
    }
}

async fn list(State(store): State<Store>, JsonApiQuery(query): JsonApiQuery)
    -> Result<impl IntoResponse, JsonApiError>
{
    let primary: Vec<Resource> = /* your primaries as dynamic resources */;
    let paths: Vec<&str> = query.include.iter().map(String::as_str).collect();
    let included = resolve_includes(&primary, &paths, &store).await.or_json_api()?;
    let doc = DocumentBuilder::collection(primary).include_many(included).build();
    Ok(JsonApiResponse::new(doc))
}

IncludeResolver::Error is your own type, not JsonApiError (the resolver lives in the framework-agnostic jsonapi_http); map it at the handler edge with .or_json_api() (see Mapping domain errors). fields[type] filtering of included resources happens later at serialization (JsonApiResponse::fields), so the two compose. See examples/compound_document.rs for a runnable server.

Errors

Every error a handler returns is a JsonApiError, which renders as a JSON:API error document. Build one-off errors fluently with with_status + the ApiErrorExt setters, or use the convenience constructors (not_found, forbidden, conflict, internal):

use jsonapi_axum::{with_status, ApiErrorExt, JsonApiError};
use http::StatusCode;

let err = JsonApiError::from_api_error(
    with_status(StatusCode::UNPROCESSABLE_ENTITY)
        .pointer("/data/attributes/title")
        .detail("must not be empty"),
);

internal(detail) returns a 500 whose body never echoes the raw detail (so internal messages don't leak); log the raw text in your app. Enable the debug-errors feature to include it in the response for local debugging.

Mapping domain errors with ?

Implement IntoJsonApiError for your own error type and use .or_json_api() so ? stays clean in handlers:

use jsonapi_axum::{IntoJsonApiError, JsonApiError, ResultExt};

struct NotAuthorized;
impl IntoJsonApiError for NotAuthorized {
    fn into_json_api_error(self) -> JsonApiError {
        JsonApiError::forbidden("not your resource")
    }
}

async fn handler() -> Result<(), JsonApiError> {
    authorize().or_json_api()?; // NotAuthorized -> 403 JSON:API document
    Ok(())
}
# fn authorize() -> Result<(), NotAuthorized> { Ok(()) }

There is deliberately no blanket From<E: IntoJsonApiError> impl — it would collide with the concrete From<jsonapi_core::Error> impl under coherence. Use .or_json_api() / .into_json_api_error() instead.

Aggregating field errors

Collect one error per invalid attribute into an ApiErrors accumulator; converting it to a JsonApiError yields a single document with one member per problem (the shared status becomes the top-level HTTP status):

use jsonapi_axum::{with_status, ApiErrorExt, ApiErrors, JsonApiError};
use http::StatusCode;

let mut errors = ApiErrors::new();
let unprocessable = StatusCode::UNPROCESSABLE_ENTITY;
errors.push(with_status(unprocessable).pointer("/data/attributes/title").detail("required"));
errors.push(with_status(unprocessable).pointer("/data/attributes/body").detail("required"));
let response: JsonApiError = errors.into(); // one 422 document, two members

Correlation / request ids

RequestIdLayer makes every error response traceable: it resolves a correlation id, echoes it as an x-request-id response header, and stamps it onto each errors[].id of a JSON:API error document (never overwriting an id a handler already set). Only error responses (>= 400) are buffered; success responses pass through untouched.

Don't reinvent id generation — compose with tower-http's SetRequestIdLayer (which mints / propagates x-request-id). Install RequestIdLayer outermost relative to NormalizeErrorsLayer so it also stamps documents the normalize layer synthesizes (e.g. a 404):

use jsonapi_axum::{JsonApiLayer, NormalizeErrorsLayer, RequestIdLayer};
use tower_http::request_id::{MakeRequestUuid, SetRequestIdLayer};

let app = router
    .layer(JsonApiLayer::new())
    .layer(NormalizeErrorsLayer::new())
    .layer(RequestIdLayer::new())                                   // reads x-request-id, stamps
    .layer(SetRequestIdLayer::x_request_id(MakeRequestUuid));       // mints x-request-id (outermost)

The id is resolved from (1) the configured header (default x-request-id), (2) a RequestId request extension, then (3) a generated UUID — the last only with the uuid feature and RequestIdLayer::new().generate(). With no source and generation off, the layer is a no-op. A handler can read the resolved id with the RequestId extractor.

Optional integrations (off by default)

Feature Effect
validator from_validation_errors(&ValidationErrors) -> Vec<ApiError> — one 422 per field, source.pointer = /data/attributes/<field>.
anyhow From<anyhow::Error> for JsonApiError = 500.
sqlx From<sqlx::Error> for JsonApiError: RowNotFound -> 404, else 500.
debug-errors Include the raw detail in an internal 500 (for local debugging only).
uuid Enable RequestIdLayer::generate() to mint a UUID when no upstream request id is present.
testing In-process test utilities (jsonapi_axum::testing) — request builder, Router::send, fluent response assertions.
jsonapi_axum = { version = "1.0.0-rc.1", features = ["validator", "anyhow", "sqlx", "uuid"] }

Testing

Enable the testing feature in your [dev-dependencies] for in-process helpers that cut the oneshot + build-request + collect-body + parse-JSON boilerplate:

[dev-dependencies]
jsonapi_axum = { version = "1.0.0-rc.1", features = ["testing"] }
use jsonapi_axum::testing::{RouterTestExt, TestRequest};
use http::StatusCode;

#[tokio::test]
async fn create_rejects_blank_title() {
    let request = TestRequest::post("/articles").body_json(&body).build(); // JSON:API Content-Type defaulted
    app().send(request).await
        .assert_status(StatusCode::UNPROCESSABLE_ENTITY)
        .assert_error(422)
        .assert_error_pointer("/data/attributes/title");
}

Helpers are async (bring your own runtime); drop to .json() / .data() / .errors() for anything the assert_* set doesn't cover. raw_body(..) plus an explicit Content-Type header lets you construct deliberately-malformed requests.

Scope

This crate parses and represents sort / filter / page; applying them to a datastore is the consumer's job (it is ORM-specific and a deliberate non-goal). Request body-size limits are the adapter's responsibility — apply axum's DefaultBodyLimit / RequestBodyLimitLayer as usual.

License

MIT OR Apache-2.0, matching the workspace.