Step 7 of 7
The same API in axum
Everything so far ran without a network, so you could see every moving part. In production you let a framework do the HTTP work. The most popular one in Rust is axum, built by the Tokio team on top of hyper (HTTP) and tower (middleware). Here's this module's inventory API in axum. It isn't compiled on this site (no network, no axum crate), but read it closely: you already know every idea in it.
[dependencies]
axum = "0.8"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
use axum::{
extract::{Path, State},
http::StatusCode,
response::{IntoResponse, Response},
routing::get,
Json, Router,
};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::sync::{Arc, Mutex};
#[derive(Clone, Serialize)]
struct Item {
id: u32,
name: String,
qty: u32,
}
#[derive(Deserialize)]
struct NewItem {
name: String,
qty: u32,
}
#[derive(Default)]
struct Store {
items: BTreeMap<u32, Item>,
next_id: u32,
}
type AppState = Arc<Mutex<Store>>;
enum ApiError {
NotFound(u32),
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
match self {
ApiError::NotFound(id) => {
let body = Json(serde_json::json!({ "error": format!("item {id} not found") }));
(StatusCode::NOT_FOUND, body).into_response()
}
}
}
}
async fn list_items(State(state): State<AppState>) -> Json<Vec<Item>> {
let store = state.lock().unwrap();
Json(store.items.values().cloned().collect())
}
async fn get_item(State(state): State<AppState>, Path(id): Path<u32>) -> Result<Json<Item>, ApiError> {
let store = state.lock().unwrap();
store.items.get(&id).cloned().map(Json).ok_or(ApiError::NotFound(id))
}
async fn create_item(State(state): State<AppState>, Json(new): Json<NewItem>) -> (StatusCode, Json<Item>) {
let mut store = state.lock().unwrap();
store.next_id += 1;
let item = Item { id: store.next_id, name: new.name, qty: new.qty };
store.items.insert(item.id, item.clone());
(StatusCode::CREATED, Json(item))
}
async fn delete_item(State(state): State<AppState>, Path(id): Path<u32>) -> Result<StatusCode, ApiError> {
let mut store = state.lock().unwrap();
store.items.remove(&id).map(|_| StatusCode::NO_CONTENT).ok_or(ApiError::NotFound(id))
}
fn app(state: AppState) -> Router {
Router::new()
.route("/items", get(list_items).post(create_item))
.route("/items/{id}", get(get_item).delete(delete_item))
.with_state(state)
}
#[tokio::main]
async fn main() {
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
axum::serve(listener, app(AppState::default())).await.unwrap();
}
What maps to what
| What you built in this module | axum |
|---|---|
match (method, segments.as_slice()) |
Router::new().route("/items/{id}", get(get_item).delete(delete_item)), with 404 and 405 answered for you |
parsing id from a segment, 400 if bad |
the extractor Path(id): Path<u32> (rejects with 400 automatically) |
serde_json::from_str(&req.body), 400 if bad |
the extractor Json(new): Json<NewItem> (400/422 and a 415 for a wrong Content-Type) |
handlers taking &AppState |
the extractor State(state): State<AppState>, given once by with_state |
json_response(201, &item) |
returning (StatusCode::CREATED, Json(item)): anything that implements IntoResponse |
ApiError::into_response |
impl IntoResponse for ApiError, so handlers return Result<_, ApiError> and use ? |
calling handle(&state, &req) in tests |
app(state).oneshot(request).await |
Extractors are axum's big idea: a handler's parameters say what it needs, and axum pulls each one out of the request before calling it (parameters like State(state) are patterns destructuring the extractor). If extraction fails, the handler isn't called at all and the client gets the error response. One rule: the body can only be read once, so Json<T> must be the last parameter.
Testing it
#[cfg(test)]
mod tests {
use super::*;
use axum::body::Body;
use axum::http::Request;
use tower::ServiceExt; // for `oneshot`
#[tokio::test]
async fn missing_item_is_404() {
let response = app(AppState::default())
.oneshot(Request::get("/items/7").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
}
Things that bite in async servers
- Handlers are
async fnrunning on Tokio. Astd::sync::Mutexis fine as long as you don't hold the guard across an.await: the handler would block a runtime thread, and axum refuses it at compile time anyway (the error says your function doesn't implementHandler; adding#[axum::debug_handler]to it explains why:future cannot be sent between threads safely). If you must hold a lock across.await, usetokio::sync::Mutex. - Production servers add middleware with
tower-httplayers: request logging (TraceLayer, with thetracingcrate), timeouts, CORS, compression. A real store is a database pool (sqlx) in the state instead of aMutex<BTreeMap>.
The Pro Track builds this service for real: axum, a database, configuration, logging, integration tests and deployment. Your turn first, though: axum's response system is just a trait, and you can build its core yourself.
Your task
Build axum's IntoResponse idea. Implement IntoResponse for:
&'static str: status 200, content typetext/plain, the text as the body(u16, &'static str): that status,text/plainJson<T>whereT: Serialize: status 200,application/json, the value serialized(u16, Json<T>): that status,application/jsonResult<T, E>where bothTandEimplementIntoResponse: whichever one it holds
Then any handler that returns one of these, like fn score() -> Result<Json<Score>, (u16, &'static str)>, can be turned into a Response, exactly like in axum.
Challenge 2
Build axum's Router idea with closures. Router::new() starts empty; .route(method, path, handler) adds a route and returns the router (so calls chain); handle(&self, req) calls the handler whose method and path match exactly. If some route has that path but none has that method, answer 405 with body method not allowed; if no route has the path, 404 with not found. Handlers are any Fn(&Request) -> Response + 'static, stored as Box<dyn Fn(&Request) -> Response>.
Challenge 3
Now axum's extractors. A type implementing FromRequest can be built from a request, or fails with a ready-made error response. Implement it for Json<T> where T: DeserializeOwned (parse the body; on failure answer 400 with body invalid JSON) and for String (the raw body, never fails). Then write fn call<A: FromRequest>(req: &Request, handler: impl Fn(A) -> Response) -> Response, which extracts the argument and calls the handler, or returns the extractor's error without calling it.