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 |
|---|---|---|
|
list, filtered, sorted and paginated |
|
|
create a book |
|
|
read one |
|
|
replace (with |
|
|
remove |
|
|
sub-collection |
|
|
an action modelled as creating a loan |
|
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();
}
Source: axum example: todos and axum: Json
$ 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}]}
Source: axum example: todos and axum: Json
Serde for API types
-
Keep separate input and output types:
BookInputhas no server-controlled fields (version), so clients cannot set them, and#[serde(deny_unknown_fields)]turns typos ("tittle") into422instead of silently ignoring them. -
#[serde(rename_all = "camelCase")]matches the JSON conventions most JavaScript clients expect while keepingsnake_casein 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 thevalidatorandgardecrates. -
Jsonserialises 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 |
|---|---|
|
successful reads and replacements that return the representation |
|
|
|
successful |
|
malformed syntax: invalid JSON, unparseable query parameter |
|
unknown ISBN or path |
|
duplicate ISBN; lending a book already on loan |
|
|
|
wrong |
|
well-formed but semantically invalid input (RFC 9110 renamed it from "Unprocessable Entity") |
|
a write without |
|
rate limited / overloaded (see Rate Limiting & Load Shedding) |
Pagination
-
Offset pagination (
?page=3&perPage=20, SQLLIMIT 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. ThePaginationextractor on Custom Extractors implements it. -
Keyset (cursor) pagination (
?cursor=<last key>, SQLWHERE isbn > $1 ORDER BY isbn LIMIT 21) is stable and fast at any depth, and is what the example uses: it fetcheslimit + 1rows to know whether a next page exists and returns the last key asnextCursor. It requires a sort on a unique key (or a tie-breaker), which is why the example refuses a cursor withsort=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.
References
-
RFC 9110: HTTP Semantics (methods, status codes, conditional requests)
-
RFC 6585: Additional HTTP Status Codes, RFC 8594: The Sunset HTTP Header Field and RFC 9745: The Deprecation HTTP Response Header Field