Concept: axum — Handlers, State, Extractors, IntoResponse
Kind: Concept. New crate: axum — this chapter shows it working before you build with it.
This is the Service arc. Everything you have built so far is a library: the domain types, the ModelClient seam, the store. None of it has a front door. This arc bolts one on — an HTTP API a client can POST a run to and GET a run's status back from — and the crate that does it is axum.
You already know async, tokio, serde, and reqwest cold from the first two courses and the arcs before this one. So this chapter is not about what an HTTP request is or how JSON serializes. It is about the four moving parts axum gives you and how they compose: a handler (an async fn), the extractors that feed it typed pieces of the request, the IntoResponse trait that turns your return value back into bytes on the wire, and the Router that wires paths to handlers. Learn those four and axum is a small crate; miss how they fit and the compiler errors read like hieroglyphics.
We do all of it on a toy notes API — POST /notes, GET /notes/:id, backed by an in-memory store — so nothing here can be mistaken for the answer key. It is the coordinator's runs API with the domain filed off.
What axum is, and the one idea under all of it
axum is an HTTP framework built on tokio and hyper. You describe your API as a set of routes, each pointing at a handler function, and axum runs the server that accepts connections, parses requests, calls the right handler, and writes the response back.
The one idea that makes axum feel different from frameworks in other languages is this: a handler is just an async fn, and its argument types and return type do the work. There is no Request req, Response res pair you reach into. Instead, each parameter is an extractor — a type that knows how to pull one piece out of the incoming request (the JSON body, a path segment, the shared state) — and the return type is anything that implements IntoResponse. axum uses the types in the signature to decide what to parse and how to reply. Your job is to pick the right types; axum does the plumbing.
That is why this chapter is mostly about four types, not four hundred lines of API. Get the types right and the handler bodies are trivial.
Handlers: an async fn that returns something responseful
The simplest handler takes nothing and returns a &'static str:
// Scratch deps: axum = "0.7", tokio = { version = "1", features = ["full"] }
async fn health() -> &'static str {
"ok"
}
&'static str implements IntoResponse — axum turns it into a 200 OK with a text/plain body. So does String, StatusCode, Json<T>, a tuple like (StatusCode, Json<T>), and Result<T, E> when both T and E are responseful. The handler never touches a response object; it returns a value whose type says how to become one. Hold that — it is the whole trick, and the error mapping later is just one more IntoResponse impl.
The Router: paths in, handlers out
The Router maps a path and method to a handler:
use axum::routing::{get, post};
use axum::Router;
fn app() -> Router {
Router::new()
.route("/notes", post(create_note))
.route("/notes/:id", get(get_note))
}
post(create_note) says "a POST to this path is handled by create_note." The :id in /notes/:id is a path parameter — a wildcard segment whose value a handler can extract. .route(...) chains, so the whole API is one expression. This is the exact shape the build's app() has, one route per endpoint.
Extractors: typed pieces of the request
An extractor is a parameter type that implements axum's FromRequestParts (or FromRequest) trait. You will use three, and they are the three the build uses:
State<T>— hands the handler a clone of the shared application state (the store).Path<T>— pulls the wildcard path segment(s) and parses them intoT.Json<T>— reads the request body and deserializes it intoTwith serde.
You destructure them right in the parameter list. Here is create_note, taking shared state and a JSON body:
use axum::extract::State;
use axum::Json;
async fn create_note(
State(state): State<AppState>,
Json(body): Json<CreateNote>,
) -> Result<Response, ApiError> {
// `state` is the shared AppState; `body` is the parsed CreateNote.
// ...
}
State(state) and Json(body) are pattern matches: the extractor type is State<AppState>, and State(state) binds the inner AppState to state. axum sees State<AppState> in the signature and injects the state; it sees Json<CreateNote> and deserializes the body. No manual parsing anywhere.
Json reads the request body, and a body can only be read once. So of the parameters in a handler, how many can be body-consuming extractors like Json — and where in the argument list must that one go? Guess before you read the trap below.
IntoResponse: your types, back on the wire
The return type is where the handler decides its reply. The rich shape is a tuple: (StatusCode, Json<serde_json::Value>) says "this status, with this JSON body." create_note returns a 201 Created with the new id:
use axum::http::StatusCode;
use serde_json::json;
Ok((StatusCode::CREATED, Json(json!({ "id": id }))).into_response())
And get_note returns Json<Note> directly — a 200 OK with the serialized note — or an error. Because the return type is Result<Json<Note>, ApiError>, a handler can ?-propagate: a None from the store becomes an ApiError, and axum turns that into a response. Which brings us to the piece that makes the whole build click.
The error type: one IntoResponse impl, one place the mapping lives
This is the theme of the arc, so slow down here. Your handlers all return Result<_, ApiError>. ApiError is a newtype wrapping your domain error, and it implements IntoResponse once — that single impl is the only place the domain's error taxonomy becomes an HTTP status code:
use axum::response::{IntoResponse, Response};
use axum::http::StatusCode;
use axum::Json;
use serde_json::json;
struct ApiError(NoteError);
// A `?` on a NoteError produces an ApiError for free.
impl From<NoteError> for ApiError {
fn from(e: NoteError) -> Self {
ApiError(e)
}
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let status = match &self.0 {
NoteError::NotFound(_) => StatusCode::NOT_FOUND, // 404
NoteError::Invalid(_) => StatusCode::BAD_REQUEST, // 400
};
(status, Json(json!({ "error": self.0.to_string() }))).into_response()
}
}
Read what this buys. A handler that does state.store.get(&id).ok_or_else(|| NoteError::NotFound(...))? never mentions 404. The type carries the outcome; the match in into_response is the single dispatch table from error kind to status. Add a new error variant and there is exactly one place the compiler makes you decide its status. This is the same "make the type the source of truth" move from the error-taxonomy arc, now pointed at HTTP: the taxonomy the store speaks and the status codes the client sees are joined in one match, not scattered across every handler.
impl IntoResponse for NoteError
Rust's orphan rule blocks implementing a foreign trait (IntoResponse, from axum) on a foreign type — and even for your own error, wrapping it in an ApiError newtype keeps the HTTP concern out of the domain crate. NoteError stays a plain domain error that knows nothing about status codes; ApiError is the thin adapter that teaches it how to be an HTTP response. The build's ApiError(ControlError) is exactly this.
The whole toy, working
Here is the notes API end to end — state, the error mapping, two handlers, the router — plus a main that spawns it on a random port and hits it with reqwest, which is also how you will test it. Read it once top to bottom; every piece above is in here.
// Scratch deps: axum = "0.7", tokio = { version = "1", features = ["full"] },
// serde = { version = "1", features = ["derive"] }, serde_json = "1",
// reqwest = { version = "0.12", features = ["json"] }, uuid = { version = "1", features = ["v4"] }
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post};
use axum::{Json, Router};
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use uuid::Uuid;
#[derive(Debug)]
enum NoteError {
NotFound(String),
Invalid(String),
}
impl std::fmt::Display for NoteError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
NoteError::NotFound(s) => write!(f, "not found: {s}"),
NoteError::Invalid(s) => write!(f, "invalid request: {s}"),
}
}
}
// The store is the source of truth. Cheap to clone (an Arc), shared per request.
#[derive(Clone, Default)]
struct NoteStore {
inner: Arc<Mutex<HashMap<String, Note>>>,
}
#[derive(Clone, Serialize)]
struct Note {
id: String,
body: String,
status: NoteStatus,
}
#[derive(Clone, Copy, Serialize)]
#[serde(rename_all = "snake_case")]
enum NoteStatus {
Draft,
}
impl NoteStore {
fn insert(&self, note: Note) {
self.inner.lock().unwrap().insert(note.id.clone(), note);
}
fn get(&self, id: &str) -> Option<Note> {
self.inner.lock().unwrap().get(id).cloned()
}
}
#[derive(Clone)]
struct AppState {
store: NoteStore,
}
struct ApiError(NoteError);
impl From<NoteError> for ApiError {
fn from(e: NoteError) -> Self {
ApiError(e)
}
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let status = match &self.0 {
NoteError::NotFound(_) => StatusCode::NOT_FOUND,
NoteError::Invalid(_) => StatusCode::BAD_REQUEST,
};
(status, Json(json!({ "error": self.0.to_string() }))).into_response()
}
}
#[derive(Deserialize)]
struct CreateNote {
body: String,
}
// State first, body-consuming Json last.
async fn create_note(
State(state): State<AppState>,
Json(body): Json<CreateNote>,
) -> Result<Response, ApiError> {
if body.body.trim().is_empty() {
return Err(NoteError::Invalid("body must not be empty".into()).into());
}
let note = Note {
id: Uuid::new_v4().to_string(),
body: body.body,
status: NoteStatus::Draft,
};
let id = note.id.clone();
state.store.insert(note);
Ok((StatusCode::CREATED, Json(json!({ "id": id }))).into_response())
}
async fn get_note(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Json<Note>, ApiError> {
state
.store
.get(&id)
.map(Json)
.ok_or_else(|| NoteError::NotFound(format!("note {id}")).into())
}
fn app(state: AppState) -> Router {
Router::new()
.route("/notes", post(create_note))
.route("/notes/:id", get(get_note))
.with_state(state)
}
#[tokio::main]
async fn main() {
// Spawn the app on a random free port (port 0 = "OS, pick one").
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap(); // the real port the OS chose
let app = app(AppState { store: NoteStore::default() });
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
let base = format!("http://{addr}");
let client = reqwest::Client::new();
let resp = client.post(format!("{base}/notes"))
.json(&json!({ "body": "buy milk" })).send().await.unwrap();
println!("POST /notes status: {}", resp.status().as_u16());
let id = resp.json::<serde_json::Value>().await.unwrap()["id"].as_str().unwrap().to_string();
let resp = client.get(format!("{base}/notes/{id}")).send().await.unwrap();
println!("GET /notes/:id status: {}", resp.status().as_u16());
let note: serde_json::Value = resp.json().await.unwrap();
println!("body: {} status: {}", note["body"], note["status"]);
let resp = client.post(format!("{base}/notes"))
.json(&json!({ "body": "" })).send().await.unwrap();
println!("POST empty body status: {}", resp.status().as_u16());
let resp = client.get(format!("{base}/notes/{}", Uuid::new_v4())).send().await.unwrap();
println!("GET missing status: {}", resp.status().as_u16());
println!("error body: {}", resp.json::<serde_json::Value>().await.unwrap()["error"]);
}
Run it and every part reports in:
POST /notes status: 201
GET /notes/:id status: 200
body: "buy milk" status: "draft"
POST empty body status: 400
GET missing status: 404
error body: "not found: note 475a5139-ccf8-426b-a6d0-fd7ea2f51496"
(The UUID is random, so your run prints a different one.) Read the last four lines against the IntoResponse impl: the empty body hit the Invalid branch → 400; the missing id hit NotFound → 404, with the body coming from NoteError's Display. The status codes were never typed into a handler — the one match decided all of them.
Testing: spawn on port 0, hit it with reqwest
Look again at that main — it is the test harness. This is the pattern the build uses for every test, and it is worth naming because it is the whole reason the API is testable without mocks:
- Bind to
127.0.0.1:0. Port0tells the OS "give me any free port." You read the actual port back withlistener.local_addr(). This means tests never collide on a fixed port and never need cleanup — each test gets its own ephemeral port. tokio::spawnthe server so it runs in the background while the test body drives it. The server task and the client run concurrently on the same runtime.- Hit it with a real
reqwestclient againsthttp://{addr}. The request travels the genuine HTTP path — routing, extraction, your handler,IntoResponse— exactly as production would. Nothing is stubbed.
Because the state is injected via AppState, a test can seed the store before spawning and then assert what the API returns. The build's helper is a three-line spawn() that returns (base_url, store) so a test can do exactly that.
get_note by calling it as a function. But then you would hand-build a State and Path and never exercise routing, extraction, status codes, or JSON encoding — the parts most likely to be wrong. Spawning the real server on port 0 tests the request as the client sees it, for a handful of extra lines. It is the same "real request path, controlled environment" bet as the wiremock tests two arcs back, pointed the other direction: there you mocked the server, here you mock nothing and drive it.
The trap: a body-consuming extractor that is not last
Here is the error that eats an afternoon the first time. The HTTP body is a stream you can read exactly once, so at most one extractor may consume it — Json, Form, Bytes, String — and it must be the last parameter. Every extractor before it must be a non-consuming one (State, Path, headers). Put Json first and watch:
// WRONG: Json consumes the body, so it cannot come before State.
async fn create_note(
Json(body): Json<CreateNote>,
State(state): State<AppState>,
) -> Result<Response, ApiError> { /* ... */ }
The compiler does not say "move Json last." It says the whole function is not a handler:
error[E0277]: the trait bound `fn(Json<CreateNote>, State<AppState>) -> ... {create_note}: Handler<_, _>` is not satisfied
--> src/bin/trap.rs:24:31
|
24 | .route("/notes", post(create_note))
| ---- ^^^^^^^^^^^ unsatisfied trait bound
| |
| required by a bound introduced by this call
|
= help: the trait `Handler<_, _>` is not implemented for fn item `fn(Json<CreateNote>, State<AppState>) -> ...`
= note: Consider using `#[axum::debug_handler]` to improve the error message
That first line is the tell: when a handler "is not a Handler," suspect extractor order before anything else. The mechanical reason is that the body-consuming extractor implements FromRequest (which takes the whole request, body included) while the others implement FromRequestParts (which take only the head) — and axum's blanket Handler impls require every parameter but the last to be FromRequestParts. Move Json to the end and the impl is satisfied. Note the compiler's own hint: slap #[axum::debug_handler] on the function and the error turns from this vtable riddle into a plain-English sentence pointing at the offending argument — the first thing to reach for when a handler signature is rejected.
One-for-one: the toy ↔ the build
Everything above maps straight onto the coordinator API:
| Toy (this chapter) | Build (panoptes-control) |
|---|---|
NoteStore behind Arc<Mutex<…>> | Store (the sqlx store from Part IV) |
AppState { store } | AppState { store } |
POST /notes → create_note | POST /runs → create_run |
GET /notes/:id → get_note | GET /runs/:id → get_run (and /runs/:id/results) |
CreateNote { body }, validate non-empty | CreateRun { manifest, models, epochs }, validate models non-empty & epochs >= 1 |
ApiError(NoteError) → 404/400 | ApiError(ControlError) → 404/400/500 |
State, Path, Json extractors | the same three |
spawn on 127.0.0.1:0, hit with reqwest | the same spawn() test helper |
The domain changes from notes to eval runs; the four moving parts — handler, extractors, IntoResponse, Router — do not move. The build adds one branch to the error match (a catch-all _ => 500 for the store/worker errors) and one more route (/runs/:id/results), and that is the whole delta.
Questions to lock
- A handler is
async fn, its parameters are extractors, its return type implementsIntoResponse. Forcreate_run, name which extractor supplies the store, which supplies the request body, and why the body one must be the last parameter. - The
ApiErrorIntoResponseimpl is "the one place the taxonomy becomes a status code." What concretely goes wrong if, instead, each handler picked its own status code inline — and what does adding a newControlErrorvariant force you to do under the one-matchdesign? - The test binds to
127.0.0.1:0rather than a fixed port like:8080. What two problems does port0solve, and why does hitting the spawned server withreqwesttest more than calling the handler function directly?
Next chapter is the build: the coordinator's Router, AppState, ApiError, and the create_run / get_run / get_results handlers — starting, as always, from a failing test.