HTTP Services with Axum
Goal: you can build a JSON REST API with axum: nested routes, extractors, shared
state, and one error enum rendered to status codes via IntoResponse.
The moving parts
| Piece | What it is | In the lab |
|---|---|---|
Router | path → handler table, nestable | /healthz, /notes, /notes/{id} |
| Extractor | typed argument a handler receives | State, Path, Json |
| Return value | impl IntoResponse | Json<T>, (StatusCode, Json<T>), or your error type |
with_state | wires shared state into every handler | AppState { notes: Arc<Mutex<Vec<_>>> } |
Handlers are plain functions — the same function works under the router and in a
#[tokio::test] with no HTTP at all. That is why the lab tests call create_note(...)
directly: transport-free unit tests on the exact production code path.
State: cheap clones, inner mutability
#![allow(unused)]
fn main() {
#[derive(Clone)]
struct AppState { notes: Arc<Mutex<Vec<Note>>> }
// Router::with_state(state) — every request gets a clone (Arc bump, not a copy)
}
Mutex::lock() is std (sync) and fine for short critical sections; under real
concurrency you’d reach for dashmap or a sqlx pool (M6). The rule that matters:
never hold a lock across an .await.
Errors: the M2 pattern on the big stage
#![allow(unused)]
fn main() {
enum ApiError { NotFound, BadRequest(String) }
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let (status, message) = match self {
ApiError::NotFound => (StatusCode::NOT_FOUND, "note not found".into()),
ApiError::BadRequest(m) => (StatusCode::BAD_REQUEST, m),
};
(status, Json(serde_json::json!({ "error": message }))).into_response()
}
}
type ApiResult<T> = Result<T, ApiError>; // handlers just `?` or return Err
}
Business logic returns Err(ApiError::BadRequest(...)); the boundary renders it.
Validation lives at the edge (trim, length cap) — and the 400s carry a reason.
Route syntax
axum 0.8 path params: "/notes/{id}" (curly braces replaced the old :id syntax —
old tutorials lie). One handler can serve several methods:
get(list).post(create).
Lab
labs/m5-notes-api — cargo run -p m5-notes-api, then:
curl -s localhost:3000/healthz
curl -s localhost:3000/notes
curl -s -X POST localhost:3000/notes -H 'content-type: application/json' -d '{"title":"ship it"}'
curl -s localhost:3000/notes/1
Tests (4): roundtrip with trim, monotonic ids, empty title → 400 value, missing → 404.
Done when: tests green, and you can curl the running server through all four calls — plus explain why the handler tests need no HTTP at all.