Building a REST API
This is the phase where the pieces click together. You've met every part already:
the Router and routing (Phase 2), Path/Query extractors (Phase 2),
Json in and out and IntoResponse (Phase 3), and the shared AppState store
(Phase 4). A REST API is nothing more than those four things, assembled - hold
this picture before any code.
📝 Mental model: a REST resource - here, books - is five handlers over one shared store. Each handler is an
async fnwhose arguments are extractors (Statefor the store,Pathfor an id,Jsonfor a request body) and whose return value implementsIntoResponse(a status code, some JSON, or both). The five map onto HTTP verbs: list (GET all), show (GET one), create (POST), update (PUT), delete (DELETE). That's the same shape you'd write in Gin, Express, or Spring - axum just expresses it through Rust's type system instead of decorators or annotations.
If you've built a CRUD endpoint in any language, you already know the job. The rest of this phase is watching that job land in idiomatic axum.
The store, recapped
We keep the in-memory store from Phase 4, and add one thing: a way to mint new
ids. When a client POSTs a new book it doesn't send an id - the server assigns
one. We'll keep a counter inside the same Mutex as the map, so a single lock
covers both reading the next id and inserting.
use HashMap;
use ;
use ;
// What the client sends to create a book - no id; the server assigns it.
// One Mutex guards both the map and the id counter, so each handler
// takes exactly one lock.
What just happened: Book derives Serialize so it can go out as JSON, and
NewBook derives Deserialize so it can come in as JSON. Store bundles the
map with a next_id counter, and AppState wraps it in Arc<Mutex<...>> for the
same reason as Phase 4: the clone axum makes per request must share the store,
not copy it. ⚠️ The Mutex lock is held only for the few lines inside each
handler - lock, read or mutate, let the guard drop, never across an .await.
Every read clones a Book out of the map, so JSON gets an owned value and the
lock releases immediately.
The five handlers
Each handler is small. Read them as variations on one theme: take what you need via extractors, touch the store under a brief lock, return a status plus (maybe) JSON.
use ;
// GET /books → 200 with the full list
async
// GET /books/{id} → 200 with one book, or 404
async
// POST /books → 201 with the created book (now carrying its id)
async
// PUT /books/{id} → 200 with the updated book, or 404
async
// DELETE /books/{id} → 204 No Content, or 404
async
What just happened: every handler follows the same recipe. list and show
only read, so they lock without mut; create, update, and remove mutate, so
they bind the guard mut. The body-consuming Json extractor always comes
last - that's why create and update put State/Path first. Return types
are where IntoResponse earns its keep: list returns a plain Json<Vec<Book>>
(a 200), while handlers with two outcomes return impl IntoResponse and use
(StatusCode, Json<...>) tuples - status and body in one value. Where a branch
returns only a status (the 404s, the 204), we call .into_response() so both
match arms share a concrete type. create builds the Book after taking the
lock so it can read and bump next_id atomically under that one lock.
⚠️ Notice how much of this code is the 404 plumbing - every
matchrepeats theNone => StatusCode::NOT_FOUNDarm, and we sprinkle.into_response()to make branches line up. It works, but it's noisy. Phase 7 replaces all of it with a custom error type and the?operator, so a missing book becomes one short line. For now, see the pattern plainly; we clean it up next.
Wiring the router
Five handlers, two routes, one .with_state. The collection path (/books)
carries GET and POST; the item path (/books/{id}) carries GET, PUT, and DELETE.
use ;
async
What just happened: get(list).post(create) chains two method handlers onto the
same path - axum routes by verb, so GET and POST on /books reach different
functions. The {id} segment is a path parameter that feeds the Path<u32>
extractor in show, update, and remove. .with_state(state) hands every
handler the shared store; because we pulled the router into its own app()
function, Phase 8 can reuse it in tests without spinning up a real server.
Driving it with curl
Start it (cargo run) and exercise each verb. The responses below show what the
handlers above produce.
# Create two books - note the 201 and the server-assigned id
# {"id":1,"title":"The Rust Programming Language","author":"Klabnik & Nichols"}
# {"id":2,"title":"Programming Rust","author":"Blandy & Orendorff"}
# List them all
# [{"id":1,...},{"id":2,...}]
# Show one
# {"id":1,"title":"The Rust Programming Language","author":"Klabnik & Nichols"}
# Update it
# {"id":1,"title":"The Rust Programming Language, 2nd Ed.","author":"Klabnik & Nichols"}
# Delete it - 204, empty body
|
# HTTP/1.1 204 No Content
# Ask for a book that no longer exists - 404
|
# HTTP/1.1 404 Not Found
What just happened: the full lifecycle of a resource. POST returned 201 with
the created body (id and all), the reads came back 200, DELETE returned a bodyless
204, and the follow-up GET on the deleted id returned 404 - exactly the status
codes the handlers chose. The -i flag prints the status line so you can see the
codes the JSON body alone wouldn't reveal.
💡 The
HashMapstore is a stand-in for a database. When you swap insqlxor SeaORM (Phase 9), the handlers keep this exact shape - extractors in, status + JSON out - only the body changes:store.books.get(&id)becomes aSELECT,insertbecomes anINSERT. TheStatealready holds aPgPoolinstead of anArc<Mutex<...>>(recall from Phase 4 that a pool is alreadyClone), so the wiring doesn't move. That stability is the payoff of the mental model: once the shape is right, the storage backend is a detail.
Recap
- A REST resource is five
async fnhandlers over one sharedState, mapped to verbs: list (GET all), show (GET one), create (POST), update (PUT), delete (DELETE). - Handlers compose the extractors you already know -
Statefor the store,Path<u32>for the id,Json<NewBook>for the body - with the body-consumingJsonalways last. - Return
(StatusCode, Json<...>)to send a status and a body together; return a bareStatusCodefor empty responses; useimpl IntoResponseand.into_response()when a handler has multiple outcome types. - The store wraps the map and an id counter in one
Mutex, so a handler takes a single brief lock; clone values out of the map and never hold a lock across an.await. - The repetitive
404and.into_response()plumbing is the verbose part - Phase 7 collapses it with a custom error type and the?operator. - Keeping the router in its own
app()function lets Phase 8 test it without a live server, and swapping the store for a real database (Phase 9) leaves the handler shape untouched.
Quick check
Lock these in before we tackle error handling.
[
{
"q": "In the create handler, why does the Json<NewBook> extractor come last in the argument list?",
"choices": [
"Alphabetical ordering of extractor types",
"axum allows only one body-consuming extractor, and it must be the final argument",
"Json is slower, so it runs last for performance",
"It's a stylistic preference with no effect"
],
"answer": 1,
"explain": "Json consumes the request body. axum permits exactly one body extractor and requires it to be the last argument, so non-body extractors like State and Path go first."
},
{
"q": "What does returning (StatusCode::CREATED, Json(book)) from a handler produce?",
"choices": [
"A 200 response with no body",
"A 201 response whose body is the book serialized as JSON",
"A compile error - you can't return a tuple",
"A 201 response with the book as a plain-text string"
],
"answer": 1,
"explain": "A (StatusCode, Json<T>) tuple implements IntoResponse: the status sets the response code and the Json part sets the JSON body. Here that's 201 Created with the new book."
},
{
"q": "When a book id isn't in the store, what does the show handler return, and what makes both match arms type-check?",
"choices": [
"It panics; the arms type-check because panics are never values",
"StatusCode::NOT_FOUND, and calling .into_response() on both arms gives them the same concrete type",
"An empty Json([]) with status 200",
"It returns Result::Err, which axum converts automatically"
],
"answer": 1,
"explain": "The None arm returns StatusCode::NOT_FOUND (404). Because the Some arm returns a tuple and the None arm a bare status, both call .into_response() so the function's two branches share one return type behind impl IntoResponse."
}
]
Before the quiz: without looking back, say (or jot down) the core idea of this phase in your own words.
Check your understanding 3 questions
1. In the create handler, why does the Json<NewBook> extractor come last in the argument list?
2. What does returning (StatusCode::CREATED, Json(book)) from a handler produce?
3. When a book id isn't in the store, what does the show handler return, and what makes both match arms type-check?