← LOGBOOK LOG-455
COMPLETE · SOFTWARE ·
RUSTAXUMBACKENDAPIHTTPTOKIOSERDEWEB-DEVELOPMENT

Backend API in Rust with Axum

An Axum API is a typed pipeline from HTTP routes through request extractors and application state to values implementing IntoResponse.

Axum models an HTTP service as typed request extraction followed by an asynchronous handler whose result becomes a response. A route selects the handler, extractors validate and deserialize its inputs, and IntoResponse converts its output into HTTP status, headers, and body.

request → router → extractors → async handler → IntoResponse → response

The compiler checks that every handler can obtain its declared inputs from a request and that every return path can become a response. Transport details remain visible, but parsing and response construction do not need to be repeated in each endpoint.

Project and Dependencies

Create a binary package and add the runtime, HTTP framework, and serialization crates:

cargo new notes-api
cd notes-api
cargo add axum
cargo add tokio --features full
cargo add serde --features derive
cargo add serde_json

The resulting dependency section has this shape:

[dependencies]
axum = "0.8"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }

Tokio schedules asynchronous tasks and provides the TCP listener. Axum supplies routing, extraction, and response conversion on top of Hyper and Tower. Serde converts between JSON and Rust types.

Starting the Server

An Axum application is a Router. axum::serve connects it to a Tokio listener:

use axum::{routing::get, Router};
use tokio::net::TcpListener;

#[tokio::main]
async fn main() -> std::io::Result<()> {
    let app = Router::new().route("/health", get(health));
    let listener = TcpListener::bind("127.0.0.1:3000").await?;

    axum::serve(listener, app).await
}

async fn health() -> &'static str {
    "ok"
}

#[tokio::main] creates the asynchronous runtime and executes the main future. The handler is also asynchronous even though this first version does no waiting. It can later await a database or another service without blocking the runtime thread.

Routes and Handlers

Routes bind an HTTP method and path to a handler:

let app = Router::new()
    .route("/health", get(health))
    .route("/notes", get(list_notes).post(create_note))
    .route("/notes/{id}", get(get_note).delete(delete_note));

The path describes resource identity; the HTTP method describes the operation. GET reads without changing server state, POST creates a resource, and DELETE removes one. A request with an unmatched path receives 404 Not Found; an unsupported method on a matched path receives 405 Method Not Allowed.

An Axum handler is an async function with zero or more extractors and a return value implementing IntoResponse. It is ordinary Rust rather than a special macro-generated controller class.

JSON as Typed Data

Serde derives the conversion between request JSON and Rust structures:

use serde::{Deserialize, Serialize};

#[derive(Clone, Serialize)]
struct Note {
    id: u64,
    title: String,
    body: String,
}

#[derive(Deserialize)]
struct CreateNote {
    title: String,
    body: String,
}

The input type omits id because the server owns identity assignment. The response type includes it. Separating transport input from stored data prevents clients from writing server-controlled fields.

Json<T> has two roles. As a parameter it consumes and deserializes the request body. As a return value it serializes a value and sets the JSON content type:

async fn echo(Json(input): Json<CreateNote>) -> Json<CreateNote> {
    Json(input)
}

Malformed JSON, a missing JSON content type, or fields that do not match CreateNote are rejected before the handler runs. Because the body can be consumed only once, Json must be the final body-consuming extractor in a handler parameter list.

Shared Application State

Handlers need shared access to databases, clients, and configuration. This in-memory version uses a hash map behind an asynchronous read-write lock:

use std::{
    collections::HashMap,
    sync::{
        atomic::{AtomicU64, Ordering},
        Arc,
    },
};
use tokio::sync::RwLock;

#[derive(Clone)]
struct AppState {
    notes: Arc<RwLock<HashMap<u64, Note>>>,
    next_id: Arc<AtomicU64>,
}

impl AppState {
    fn new() -> Self {
        Self {
            notes: Arc::new(RwLock::new(HashMap::new())),
            next_id: Arc::new(AtomicU64::new(1)),
        }
    }
}

Arc gives tasks shared ownership. RwLock allows concurrent readers or one writer. AtomicU64 allocates IDs without locking the map. These mechanisms do not make arbitrary shared mutation safe; they state precisely how access is synchronized.

The state is attached once and extracted by type:

let state = AppState::new();

let app = Router::new()
    .route("/notes", get(list_notes).post(create_note))
    .with_state(state);

Reading and Creating Notes

The list handler takes a read lock, clones the stored notes into an owned response, and releases the lock when the guard leaves scope:

use axum::{extract::State, http::StatusCode, Json};

async fn list_notes(
    State(state): State<AppState>,
) -> Json<Vec<Note>> {
    let notes = state.notes.read().await;
    Json(notes.values().cloned().collect())
}

The create handler validates input before taking the write lock:

async fn create_note(
    State(state): State<AppState>,
    Json(input): Json<CreateNote>,
) -> Result<(StatusCode, Json<Note>), ApiError> {
    if input.title.trim().is_empty() {
        return Err(ApiError::InvalidTitle);
    }

    let id = state.next_id.fetch_add(1, Ordering::Relaxed);
    let note = Note {
        id,
        title: input.title,
        body: input.body,
    };

    state.notes.write().await.insert(id, note.clone());
    Ok((StatusCode::CREATED, Json(note)))
}

The response tuple combines a 201 Created status with a JSON body. Moving input.title and input.body into Note avoids unnecessary copies. Only the finished Note is cloned because one owned copy enters the map and another leaves in the response.

Path Extraction and Missing Resources

Path<u64> parses the {id} segment before the handler executes:

use axum::extract::Path;

async fn get_note(
    State(state): State<AppState>,
    Path(id): Path<u64>,
) -> Result<Json<Note>, ApiError> {
    let notes = state.notes.read().await;
    let note = notes.get(&id).cloned().ok_or(ApiError::NotFound)?;
    Ok(Json(note))
}

async fn delete_note(
    State(state): State<AppState>,
    Path(id): Path<u64>,
) -> Result<StatusCode, ApiError> {
    let removed = state.notes.write().await.remove(&id);

    match removed {
        Some(_) => Ok(StatusCode::NO_CONTENT),
        None => Err(ApiError::NotFound),
    }
}

A non-numeric path fails extraction. A valid number absent from the map reaches the handler and becomes the application’s NotFound error. These are distinct failures: one request has the wrong representation; the other names a resource that does not exist.

Errors Are Responses

Application errors become HTTP responses through IntoResponse:

use axum::response::{IntoResponse, Response};

enum ApiError {
    InvalidTitle,
    NotFound,
}

#[derive(Serialize)]
struct ErrorBody {
    error: &'static str,
}

impl IntoResponse for ApiError {
    fn into_response(self) -> Response {
        let (status, message) = match self {
            ApiError::InvalidTitle => {
                (StatusCode::UNPROCESSABLE_ENTITY, "title cannot be empty")
            }
            ApiError::NotFound => (StatusCode::NOT_FOUND, "note not found"),
        };

        (status, Json(ErrorBody { error: message })).into_response()
    }
}

Handlers can now return Result<T, ApiError> and use ?. Every known failure has one status and one body shape. Internal details do not leak into the response, and adding another error variant forces the conversion match to be updated.

The Complete Service

The fragments compose into src/main.rs:

use std::{
    collections::HashMap,
    sync::{
        atomic::{AtomicU64, Ordering},
        Arc,
    },
};

use axum::{
    extract::{Path, State},
    http::StatusCode,
    response::{IntoResponse, Response},
    routing::get,
    Json, Router,
};
use serde::{Deserialize, Serialize};
use tokio::{net::TcpListener, signal, sync::RwLock};

#[derive(Clone, Serialize)]
struct Note {
    id: u64,
    title: String,
    body: String,
}

#[derive(Deserialize)]
struct CreateNote {
    title: String,
    body: String,
}

#[derive(Clone)]
struct AppState {
    notes: Arc<RwLock<HashMap<u64, Note>>>,
    next_id: Arc<AtomicU64>,
}

impl AppState {
    fn new() -> Self {
        Self {
            notes: Arc::new(RwLock::new(HashMap::new())),
            next_id: Arc::new(AtomicU64::new(1)),
        }
    }
}

enum ApiError {
    InvalidTitle,
    NotFound,
}

#[derive(Serialize)]
struct ErrorBody {
    error: &'static str,
}

impl IntoResponse for ApiError {
    fn into_response(self) -> Response {
        let (status, message) = match self {
            ApiError::InvalidTitle => {
                (StatusCode::UNPROCESSABLE_ENTITY, "title cannot be empty")
            }
            ApiError::NotFound => (StatusCode::NOT_FOUND, "note not found"),
        };

        (status, Json(ErrorBody { error: message })).into_response()
    }
}

#[tokio::main]
async fn main() -> std::io::Result<()> {
    let app = Router::new()
        .route("/health", get(|| async { "ok" }))
        .route("/notes", get(list_notes).post(create_note))
        .route("/notes/{id}", get(get_note).delete(delete_note))
        .with_state(AppState::new());

    let listener = TcpListener::bind("127.0.0.1:3000").await?;
    println!("listening on http://{}", listener.local_addr()?);

    axum::serve(listener, app)
        .with_graceful_shutdown(shutdown_signal())
        .await
}

async fn list_notes(State(state): State<AppState>) -> Json<Vec<Note>> {
    let notes = state.notes.read().await;
    Json(notes.values().cloned().collect())
}

async fn create_note(
    State(state): State<AppState>,
    Json(input): Json<CreateNote>,
) -> Result<(StatusCode, Json<Note>), ApiError> {
    if input.title.trim().is_empty() {
        return Err(ApiError::InvalidTitle);
    }

    let id = state.next_id.fetch_add(1, Ordering::Relaxed);
    let note = Note {
        id,
        title: input.title,
        body: input.body,
    };

    state.notes.write().await.insert(id, note.clone());
    Ok((StatusCode::CREATED, Json(note)))
}

async fn get_note(
    State(state): State<AppState>,
    Path(id): Path<u64>,
) -> Result<Json<Note>, ApiError> {
    let notes = state.notes.read().await;
    let note = notes.get(&id).cloned().ok_or(ApiError::NotFound)?;
    Ok(Json(note))
}

async fn delete_note(
    State(state): State<AppState>,
    Path(id): Path<u64>,
) -> Result<StatusCode, ApiError> {
    match state.notes.write().await.remove(&id) {
        Some(_) => Ok(StatusCode::NO_CONTENT),
        None => Err(ApiError::NotFound),
    }
}

async fn shutdown_signal() {
    signal::ctrl_c()
        .await
        .expect("failed to install Ctrl+C handler");
}

Run and exercise it from another terminal:

cargo run

curl http://127.0.0.1:3000/health

curl -X POST http://127.0.0.1:3000/notes \
  -H 'content-type: application/json' \
  -d '{"title":"Ownership","body":"One owner, temporary borrows."}'

curl http://127.0.0.1:3000/notes
curl http://127.0.0.1:3000/notes/1
curl -X DELETE http://127.0.0.1:3000/notes/1

The in-memory map is intentionally temporary. Replacing it with a database changes AppState and the repository operations, not the route model. Production extensions belong at the same boundaries: configuration before constructing state, database transactions inside application operations, authentication as extractors or middleware, tracing through Tower, request limits before body extraction, and integration tests against the router. The typed pipeline remains the architectural centre.

Sources