JSON REST APIs

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against axum 0.8.9 on Rust 1.99 (edition 2024) with serde 1.0 and serde_json 1.0, checked on 2026-10-06. It assembles the pieces from the previous pages — routing, extractors, responses, errors, state — into the /api/v1/books resource of Bookshelf. The same API is built in this site’s FastAPI, NestJS, Django and Laravel sections, so the design decisions can be compared directly. Serde itself is covered on Serialization with serde.

Where it runs in the request

In the request lifecycle (tokio accepts the connection, hyper parses the request, tower layers wrap the router, the router matches a route, middleware and extractors run, the handler runs, the response travels back out), a JSON API uses the whole chain: CORS, tracing and timeout middleware after routing, Path, Query and State extractors, then the Json body extractor (the last extractor, because it consumes the body), the handler, and Json or an AppError turned into the response. JSON rejections (415, 400, 422) are produced in the extractor step, before the handler.

Resource design

Request Meaning Success response

GET /api/v1/books?author=&q=&sort=&limit=&cursor=

list, filtered, sorted and paginated

200 + page

POST /api/v1/books

create a book

201 + Location + body

GET /api/v1/books/{isbn}

read one

200 + ETag

PUT /api/v1/books/{isbn}

replace (with If-Match)

200 + new ETag

DELETE /api/v1/books/{isbn}

remove

204

GET /api/v1/books/{isbn}/reviews

sub-collection

200

POST /api/v1/books/{isbn}/loans

an action modelled as creating a loan

201

Nouns for resources, HTTP methods for verbs, sub-resources for ownership, and "actions" (lending) modelled as the creation of a resource (a loan) keep the API predictable. Errors use RFC 9457 Problem Details (see Error Handling).

The books resource

use std::collections::BTreeMap;
use std::ops::Bound;
use std::sync::{Arc, RwLock};

use axum::{
    Json, Router,
    extract::{Path, Query, State, rejection::JsonRejection},
    http::{HeaderMap, StatusCode, header},
    response::{IntoResponse, Response},
    routing::get,
};
use serde::{Deserialize, Serialize};
use serde_json::json;

#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Book {
    isbn: String,
    title: String,
    author: String,
    year: u16,
    #[serde(skip_serializing_if = "Option::is_none")]
    subtitle: Option<String>,
    #[serde(skip)] // never part of the representation
    version: u64,
}

#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct BookInput {
    isbn: String,
    title: String,
    author: String,
    year: u16,
    subtitle: Option<String>,
}

#[derive(Clone, Default)]
struct AppState {
    books: Arc<RwLock<BTreeMap<String, Book>>>,
}

// ---- errors ----
enum ApiError {
    NotFound,
    Conflict(&'static str),
    Unprocessable(String),
    PreconditionFailed,
    BadBody(JsonRejection),
}

impl From<JsonRejection> for ApiError {
    fn from(r: JsonRejection) -> Self {
        ApiError::BadBody(r)
    }
}

impl IntoResponse for ApiError {
    fn into_response(self) -> Response {
        let (status, detail) = match self {
            ApiError::NotFound => (StatusCode::NOT_FOUND, "no such book".to_owned()),
            ApiError::Conflict(msg) => (StatusCode::CONFLICT, msg.to_owned()),
            ApiError::Unprocessable(msg) => (StatusCode::UNPROCESSABLE_ENTITY, msg),
            ApiError::PreconditionFailed => (StatusCode::PRECONDITION_FAILED, "the book was modified".to_owned()),
            ApiError::BadBody(r) => (r.status(), r.body_text()),
        };
        let body = json!({ "type": "about:blank", "title": status.canonical_reason(), "status": status.as_u16(), "detail": detail });
        (status, [(header::CONTENT_TYPE, "application/problem+json")], body.to_string()).into_response()
    }
}

// ---- listing: filtering, sorting, keyset pagination ----
#[derive(Deserialize)]
struct ListParams {
    author: Option<String>,
    q: Option<String>,
    #[serde(default)]
    sort: Sort,
    limit: Option<usize>,
    cursor: Option<String>, // the last ISBN of the previous page
}

#[derive(Deserialize, Default, PartialEq)]
#[serde(rename_all = "lowercase")]
enum Sort {
    #[default]
    Isbn,
    Year,
}

#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct Page {
    items: Vec<Book>,
    #[serde(skip_serializing_if = "Option::is_none")]
    next_cursor: Option<String>,
}

async fn list(State(state): State<AppState>, Query(p): Query<ListParams>) -> Result<Json<Page>, ApiError> {
    if p.sort == Sort::Year && p.cursor.is_some() {
        return Err(ApiError::Unprocessable("cursor pagination is only supported with sort=isbn".into()));
    }
    let limit = p.limit.unwrap_or(20).clamp(1, 100);
    let books = state.books.read().expect("lock poisoned");
    // keyset: start strictly after the cursor
    let lower = p.cursor.clone().map_or(Bound::Unbounded, Bound::Excluded);
    let mut items: Vec<Book> = books
        .range((lower, Bound::Unbounded))
        .map(|(_, b)| b)
        .filter(|b| p.author.as_ref().is_none_or(|a| b.author.eq_ignore_ascii_case(a)))
        .filter(|b| p.q.as_ref().is_none_or(|q| b.title.to_lowercase().contains(&q.to_lowercase())))
        .take(limit + 1)
        .cloned()
        .collect();
    let next_cursor = (items.len() > limit).then(|| {
        items.truncate(limit);
        items.last().map(|b| b.isbn.clone()).unwrap_or_default()
    });
    if p.sort == Sort::Year {
        items.sort_by_key(|b| b.year);
    }
    Ok(Json(Page { items, next_cursor }))
}

// ---- create: 201 + Location, 409, 422 ----
async fn create(
    State(state): State<AppState>,
    payload: Result<Json<BookInput>, JsonRejection>,
) -> Result<Response, ApiError> {
    let Json(input) = payload?;
    if input.isbn.len() != 13 || !input.isbn.bytes().all(|c| c.is_ascii_digit()) {
        return Err(ApiError::Unprocessable("isbn must be 13 digits".into()));
    }
    let mut books = state.books.write().expect("lock poisoned");
    if books.contains_key(&input.isbn) {
        return Err(ApiError::Conflict("a book with this ISBN already exists"));
    }
    let book = Book { isbn: input.isbn, title: input.title, author: input.author, year: input.year, subtitle: input.subtitle, version: 1 };
    books.insert(book.isbn.clone(), book.clone());
    let location = format!("/api/v1/books/{}", book.isbn);
    Ok((StatusCode::CREATED, [(header::LOCATION, location), (header::ETAG, etag(&book))], Json(book)).into_response())
}

fn etag(book: &Book) -> String {
    format!("\"{}-v{}\"", book.isbn, book.version)
}

// ---- read with ETag ----
async fn show(State(state): State<AppState>, Path(isbn): Path<String>) -> Result<Response, ApiError> {
    let books = state.books.read().expect("lock poisoned");
    let book = books.get(&isbn).ok_or(ApiError::NotFound)?;
    Ok(([(header::ETAG, etag(book))], Json(book.clone())).into_response())
}

// ---- replace with optimistic concurrency (If-Match) ----
async fn replace(
    State(state): State<AppState>,
    Path(isbn): Path<String>,
    headers: HeaderMap,
    payload: Result<Json<BookInput>, JsonRejection>,
) -> Result<Response, ApiError> {
    let Json(input) = payload?;
    if input.isbn != isbn {
        return Err(ApiError::Unprocessable("the body's isbn must match the URL".into()));
    }
    let mut books = state.books.write().expect("lock poisoned");
    let book = books.get_mut(&isbn).ok_or(ApiError::NotFound)?;
    match headers.get(header::IF_MATCH).and_then(|v| v.to_str().ok()) {
        Some(tag) if tag == etag(book) => {}
        Some(_) => return Err(ApiError::PreconditionFailed),
        None => return Err(ApiError::Unprocessable("If-Match is required".into())), // RFC 6585 suggests 428
    }
    *book = Book { isbn: input.isbn, title: input.title, author: input.author, year: input.year, subtitle: input.subtitle, version: book.version + 1 };
    Ok(([(header::ETAG, etag(book))], Json(book.clone())).into_response())
}

async fn remove(State(state): State<AppState>, Path(isbn): Path<String>) -> Result<StatusCode, ApiError> {
    state.books.write().expect("lock poisoned").remove(&isbn).map(|_| StatusCode::NO_CONTENT).ok_or(ApiError::NotFound)
}

fn app(state: AppState) -> Router {
    Router::new()
        .route("/api/v1/books", get(list).post(create))
        .route("/api/v1/books/{isbn}", get(show).put(replace).delete(remove))
        .with_state(state)
}

#[tokio::main]
async fn main() {
    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
    axum::serve(listener, app(AppState::default())).await.unwrap();
}
$ curl -si -H 'content-type: application/json' localhost:3000/api/v1/books \
    -d '{"isbn":"9780441013593","title":"Dune","author":"Frank Herbert","year":1965}'
HTTP/1.1 201 Created
content-type: application/json
location: /api/v1/books/9780441013593
etag: "9780441013593-v1"
...
{"isbn":"9780441013593","title":"Dune","author":"Frank Herbert","year":1965}

$ curl -s -H 'content-type: application/json' localhost:3000/api/v1/books \
    -d '{"isbn":"9780441013593","title":"Dune","author":"Frank Herbert","year":1965}'
{"detail":"a book with this ISBN already exists","status":409,"title":"Conflict","type":"about:blank"}

$ curl -s -X PUT -H 'if-match: "9780441013593-v0"' -H 'content-type: application/json' \
    localhost:3000/api/v1/books/9780441013593 \
    -d '{"isbn":"9780441013593","title":"Dune","author":"Frank Herbert","year":1965,"subtitle":"Book One"}'
{"detail":"the book was modified","status":412,"title":"Precondition Failed","type":"about:blank"}

$ curl -s 'localhost:3000/api/v1/books?author=frank%20herbert&limit=1'
{"items":[{"isbn":"9780441013593","title":"Dune","author":"Frank Herbert","year":1965}]}

Serde for API types

  • Keep separate input and output types: BookInput has no server-controlled fields (version), so clients cannot set them, and #[serde(deny_unknown_fields)] turns typos ("tittle") into 422 instead of silently ignoring them.

  • #[serde(rename_all = "camelCase")] matches the JSON conventions most JavaScript clients expect while keeping snake_case in Rust.

  • #[serde(skip_serializing_if = "Option::is_none")] omits absent optional fields; #[serde(skip)] hides internal ones. #[serde(default)] gives missing fields a default.

  • Validated newtypes such as shelf_core::Isbn (with #[serde(try_from = "String")]) reject invalid values during deserialisation; the Validation page compares that with the validator and garde crates.

  • Json serialises the whole response before sending it. For very large lists, stream NDJSON instead (see Server-Sent Events & Streaming).

JSON body rejections

Json<T> rejects with 415 for a missing or wrong Content-Type (it accepts application/json and any application/*+json), 400 for malformed JSON, 422 for a body that does not fit T, and 413 above the body limit (see Extractors). Since axum 0.8.5, a valid JSON document followed by anything other than whitespace — {"a":1} x or two concatenated documents — is rejected with 400 ("trailing characters"); older versions silently ignored the rest. Extracting Result<Json<T>, JsonRejection>, as create does above, lets the API answer in its own error format.

Status codes

Code When Bookshelf uses it

200 OK

successful reads and replacements that return the representation

201 Created

POST created a resource; Location points at it

204 No Content

successful DELETE (or an update that returns nothing)

400 Bad Request

malformed syntax: invalid JSON, unparseable query parameter

404 Not Found

unknown ISBN or path

409 Conflict

duplicate ISBN; lending a book already on loan

412 Precondition Failed

If-Match does not match the current ETag

415 Unsupported Media Type

wrong Content-Type

422 Unprocessable Content

well-formed but semantically invalid input (RFC 9110 renamed it from "Unprocessable Entity")

428 Precondition Required

a write without If-Match where one is required (RFC 6585); the example uses 422 for brevity

429 / 503

rate limited / overloaded (see Rate Limiting & Load Shedding)

Pagination

  • Offset pagination (?page=3&perPage=20, SQL LIMIT 20 OFFSET 40) is simple and allows jumping to page n, but gets slower as the offset grows and skips or repeats items when rows are inserted between requests. The Pagination extractor on Custom Extractors implements it.

  • Keyset (cursor) pagination (?cursor=<last key>, SQL WHERE isbn > $1 ORDER BY isbn LIMIT 21) is stable and fast at any depth, and is what the example uses: it fetches limit + 1 rows to know whether a next page exists and returns the last key as nextCursor. It requires a sort on a unique key (or a tie-breaker), which is why the example refuses a cursor with sort=year. Encode cursors opaquely (base64 of the key) if clients should not depend on their content.

The trade-offs, and the SQL for both, are on Pagination: Offset vs. Keyset.

Optimistic concurrency with ETag and If-Match

Two librarians editing the same book must not overwrite each other’s changes silently ("lost update"). The server sends an ETag derived from a version column with every representation; clients send it back in If-Match when writing, and the server rejects the write with 412 if the version has changed. In SQL the check and the write are one statement: UPDATE books SET ..., version = version + 1 WHERE isbn = $1 AND version = $2, and "0 rows affected" means 412 (see Migrations & Transactions). Conditional GETs with If-None-Match are on Responses.

Versioning and deprecation

Bookshelf versions in the path (/api/v1), as shown on Routing Composition. Additive changes — new optional fields, new endpoints — do not need a new version, provided clients ignore unknown fields (serde does by default unless deny_unknown_fields is set on the client). When a version is retired, announce it with the Deprecation (RFC 9745) and Sunset (RFC 8594) response headers before removing it, for example with a SetResponseHeaderLayer on the old router.

Where the books are outdated

Yoon (Beginning Axum, chapter 3) returns the stored user, including the plain-text password, from its JSON endpoints. Never store or return passwords; use separate output types that cannot contain secrets (see Password Authentication). Responses that accepted trailing data after the JSON document before axum 0.8.5 now fail with 400.