Skip to Content
Course content

82: Building a Simple Web API with Axum

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I see this a lot when developers move from Node.js or Python to Rust: they assume that because Rust is a "systems language," building a web API means they'll be spending their time manually managing TCP sockets, parsing raw HTTP byte streams, or fighting a losing battle with the borrow checker in every single route handler. They expect it to feel like writing a driver for a network card.

The myth: "Web APIs in Rust are manual labor"

If you think you have to manually decode JSON strings or manage the lifecycle of an HTTP connection, you're thinking about Rust in 2015. Modern Rust web development, and Axum specifically, is built on the "extractor" pattern. You don't "pull" data out of a request; you declare what you need in your function signature, and Axum provides it to you. If the data isn't there or is malformed, Axum rejects the request before your code even runs.

Let's look at a real scenario. Imagine we're building a small API for a Book Inventory system. We aren't just returning "Hello World"—we need to handle state, paths, and JSON payloads.

use axum::{
    routing::{get, post},
    extract::{Path, State},
    Json, Router,
};
use serde::{Deserialize, Serialize};
use std::sync::{Arc, Mutex};
use std::collections::HashMap;

#[derive(Serialize, Deserialize, Clone)]
struct Book {
    title: String,
    author: String,
}

// Our shared application state
type SharedState = Arc<Mutex<HashMap<u32, Book>>>;

#[tokio::main]
async fn main() {
    let shared_state = Arc::new(Mutex::new(HashMap::new()));

    let app = Router::new()
        .route("/books/:id", get(get_book))
        .route("/books", post(add_book))
        .with_state(shared_state);

    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

The reality: Declarative handlers via Extractors

Look at how we'd implement the handlers for the code above. Notice that I'm not calling request.get_param("id") or json.parse(body). I'm just naming the types I want in the arguments.

async fn get_book(
    Path(id): Path<u32>, 
    State(state): State<SharedState>
) -> Result<Json<Book>, axum::http::StatusCode> {
    let books = state.lock().unwrap();
    
    books.get(&id)
        .cloned()
        .map(Json)
        .ok_or(axum::http::StatusCode::NOT_FOUND)
}

async fn add_book(
    State(state): State<SharedState>, 
    Json(payload): Json<Book>
) -> axum::http::StatusCode {
    let mut books = state.lock().unwrap();
    let id = books.len() as u32 + 1;
    books.insert(id, payload);
    axum::http::StatusCode::CREATED
}

The Path<u32> and Json<Book> parts are the extractors. Axum looks at the request, sees it's a GET request with a variable in the URL, and attempts to parse that variable into a u32. If the user sends /books/abc, Axum will automatically return a 400 Bad Request. Your handler logic stays clean because the "plumbing" is handled by the type system.

Handling the "State Struggle" with Arc and Mutex

Here is where most people actually trip up: sharing data across threads. Because Axum handles requests concurrently using tokio, your state must be Send and Sync. You can't just have a global HashMap; the compiler will scream at you.

I used Arc<Mutex<...>> here. The Arc (Atomic Reference Counted) pointer lets multiple threads own a reference to the data, and the Mutex ensures only one thread can modify the map at a time. In a production app, you'd likely use a database pool (like sqlx), but for in-memory stores, this pattern is the gold standard. Just be careful: keep your lock guards short. Don't hold a MutexGuard across an .await point, or you'll risk deadlocking your entire server.




📋 Practical Task

Build a Vintage Synth Inventory Manager

Your task is to expand the concepts from this lesson to create a specialized API for a vintage synthesizer shop. You need to implement a system that tracks synths, their brand, and their price.

Requirements:

  • Data Model: Create a Synth struct with fields for name (String), brand (String), and price (f64).
  • The Store: Use a HashMap<u32, Synth> wrapped in Arc<Mutex> as your application state.
  • Endpoint 1: POST /synths - Accepts a JSON body to add a new synth to the inventory. Return 201 Created.
  • Endpoint 2: GET /synths/:id - Returns the details of a specific synth. Return 404 Not Found if the ID doesn't exist.
  • Endpoint 3: GET /synths - Return a list (Vec) of all synths currently in the inventory as JSON.

Constraints: Ensure you use Axum's State and Json extractors. Your API should be able to handle multiple concurrent requests without crashing or failing borrow-checker checks.

Rating
0 0

There are no comments for now.

to be the first to leave a comment.