RustArena

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

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:

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.

Previous: Testing handlers