Building REST APIs with Axum in Rust

1. Overview

Axum is a web framework built by the Tokio team. It’s built on top of hyper and tower, using Rust’s type system to validate handlers at compile time.

In this article, you’ll build a REST API for managing orders (Order) with all CRUD operations. The article also covers how to share state between handlers, return structured JSON errors, and write tests for the router without opening a network port.

The code in this article uses Axum 0.8 and Rust edition 2021 or later.

2. Project Setup

Create a new project:

cargo new order-api
cd order-api

Add dependencies to Cargo.toml:

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

[dev-dependencies]
tower = { version = "0.5", features = ["util"] }
http-body-util = "0.1"

The role of each crate:

  • axum: router, extractors, and response types.
  • tokio: async runtime. Axum requires running on Tokio.
  • serde and serde_json: convert structs to and from JSON.
  • tower and http-body-util: used only in tests. They help send requests directly to the router and read response bodies.

3. Your First Axum Application

Start with a health check endpoint:

use axum::{routing::get, Router};

#[tokio::main]
async fn main() {
    let app = Router::new().route("/health", get(|| async { "OK" }));

    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    println!("Listening on {}", listener.local_addr().unwrap());
    axum::serve(listener, app).await.unwrap();
}

This code does three things:

  • #[tokio::main] turns main into an async function that runs on the Tokio runtime.
  • Router::new().route(...) binds the /health path to the GET method. The handler is an async closure that returns &'static str. Axum automatically converts this string to a 200 OK response with Content-Type: text/plain.
  • axum::serve takes a TcpListener and starts serving requests.

Try it out:

cargo run
curl -i http://localhost:3000/health
HTTP/1.1 200 OK
content-type: text/plain; charset=utf-8
content-length: 2

OK

4. Models and Shared State

4.1. Define the Model

use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Order {
    pub id: u64,
    pub customer: String,
    pub amount: u64,
}

#[derive(Debug, Deserialize)]
pub struct CreateOrder {
    pub customer: String,
    pub amount: u64,
}

Separating CreateOrder from Order is intentional. Clients shouldn’t provide their own id, because the server generates it. If you use a single struct, you’d have to handle the case where the client provides an arbitrary id.

4.2. Shared State

In this article, data is stored in memory. In practice, this is usually a connection pool like sqlx::PgPool.

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

#[derive(Clone, Default)]
pub struct AppState {
    orders: Arc<RwLock<HashMap<u64, Order>>>,
    next_id: Arc<AtomicU64>,
}

Axum clones state for each request. So AppState must implement Clone, and cloning must be cheap. Wrapping data in Arc satisfies both requirements: cloning only increments a reference counter, and all handlers point to the same data.

RwLock allows multiple threads to read in parallel, but only one thread can write at a time. AtomicU64 is used to generate ids without needing a lock.

Note: We use std::sync::RwLock here because there’s no .await between acquiring and releasing the lock. If you need to hold the lock across an .await, switch to tokio::sync::RwLock. Otherwise, the future won’t be Send, and your handler won’t compile.

5. Error Handling with IntoResponse

Before writing handlers, define a common error type. Any type that implements IntoResponse can be returned by a handler, including inside a Result.

use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::Json;
use serde_json::json;

#[derive(Debug)]
pub enum AppError {
    NotFound(u64),
    Validation(String),
}

impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        let (status, message) = match self {
            AppError::NotFound(id) => (StatusCode::NOT_FOUND, format!("Order {id} not found")),
            AppError::Validation(msg) => (StatusCode::UNPROCESSABLE_ENTITY, msg),
        };
        (status, Json(json!({ "error": message }))).into_response()
    }
}

A tuple of (StatusCode, Json<_>) also implements IntoResponse. This lets you set both the status code and body at once.

From now on, a handler just needs to return Err(AppError::NotFound(id)). The client will receive a response like this:

HTTP/1.1 404 Not Found
content-type: application/json

{"error":"Order 42 not found"}

6. Writing CRUD Handlers

6.1. Create an Order

use axum::extract::State;
use std::sync::atomic::Ordering;

async fn create_order(
    State(state): State<AppState>,
    Json(input): Json<CreateOrder>,
) -> Result<(StatusCode, Json<Order>), AppError> {
    if input.customer.trim().is_empty() {
        return Err(AppError::Validation("customer must not be empty".into()));
    }
    let id = state.next_id.fetch_add(1, Ordering::Relaxed) + 1;
    let order = Order { id, customer: input.customer, amount: input.amount };
    state.orders.write().unwrap().insert(id, order.clone());
    Ok((StatusCode::CREATED, Json(order)))
}

Each handler parameter is an extractor:

  • State(state) gets the AppState attached to the router.
  • Json(input) reads the body and deserializes it to CreateOrder. If the body is malformed, Axum returns an error before your function runs.

fetch_add returns the old value, so we add 1 to make ids start from 1. The handler returns 201 Created with the newly created order.

Note: Extractors that read the body, like Json, String, or Bytes, must be the last parameter. The body can only be read once. Putting Json before State will cause a compile error saying “the trait Handler<_, _> is not implemented”, which is confusing if you don’t know the reason.

6.2. Get an Order by ID

use axum::extract::Path;

async fn get_order(
    State(state): State<AppState>,
    Path(id): Path<u64>,
) -> Result<Json<Order>, AppError> {
    state
        .orders
        .read()
        .unwrap()
        .get(&id)
        .cloned()
        .map(Json)
        .ok_or(AppError::NotFound(id))
}

Path<u64> parses the {id} segment from the URL into u64. If a client calls /orders/abc, Axum returns 400 Bad Request without extra code. When an order isn’t found, ok_or converts None to a NotFound error.

6.3. List with Pagination

use axum::extract::Query;

#[derive(Debug, Deserialize)]
pub struct Pagination {
    #[serde(default)]
    offset: usize,
    #[serde(default = "default_limit")]
    limit: usize,
}

fn default_limit() -> usize {
    20
}

async fn list_orders(
    State(state): State<AppState>,
    Query(page): Query<Pagination>,
) -> Json<Vec<Order>> {
    let orders = state.orders.read().unwrap();
    let mut result: Vec<Order> = orders.values().cloned().collect();
    result.sort_by_key(|o| o.id);
    let result = result.into_iter().skip(page.offset).take(page.limit).collect();
    Json(result)
}

Query<Pagination> reads the query string like ?offset=10&limit=5. Thanks to #[serde(default)], clients can omit both parameters and still get a valid result.

The code must sort by id because HashMap doesn’t guarantee order. Without this, pagination results would change between calls.

6.4. Delete an Order

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

StatusCode itself implements IntoResponse. Returning 204 No Content is the standard way to signal a successful deletion.

7. Assembling the Router

pub fn app(state: AppState) -> Router {
    Router::new()
        .route("/health", get(|| async { "OK" }))
        .route("/orders", get(list_orders).post(create_order))
        .route("/orders/{id}", get(get_order).delete(delete_order))
        .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();
}

This router is organized as follows:

  • A single path can have multiple methods by chaining: get(...).post(...).
  • with_state attaches state to the router. After this, the router has type Router<()> and is ready to serve requests.
  • The app function is separated so tests can build the router without opening a port.

Try creating and fetching an order:

curl -i -X POST http://localhost:3000/orders \
  -H "Content-Type: application/json" \
  -d '{"customer":"Nguyen Van An","amount":150000}'

curl http://localhost:3000/orders/1
HTTP/1.1 201 Created
content-type: application/json

{"id":1,"customer":"Nguyen Van An","amount":150000}

{"id":1,"customer":"Nguyen Van An","amount":150000}

Note: Starting with Axum 0.8, path parameters use {id} syntax instead of /:id from earlier versions. If you copy old code using /:id, the app will panic when building the router. Wildcard syntax also changed from *rest to {*rest}.

8. Testing the Router

Router implements the Service trait from tower. This means you can send requests directly to the router using oneshot, without running a real server. Add a test module at the end of main.rs:

#[cfg(test)]
mod tests {
    use super::*;
    use axum::body::Body;
    use axum::http::Request;
    use http_body_util::BodyExt;
    use tower::ServiceExt;

    fn post_order(json: &str) -> Request<Body> {
        Request::builder()
            .method("POST")
            .uri("/orders")
            .header("content-type", "application/json")
            .body(Body::from(json.to_owned()))
            .unwrap()
    }

    #[tokio::test]
    async fn create_then_get_order() {
        let app = app(AppState::default());

        let response = app
            .clone()
            .oneshot(post_order(r#"{"customer":"An","amount":150000}"#))
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::CREATED);

        let response = app
            .oneshot(Request::get("/orders/1").body(Body::empty()).unwrap())
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::OK);

        let bytes = response.into_body().collect().await.unwrap().to_bytes();
        let order: Order = serde_json::from_slice(&bytes).unwrap();
        assert_eq!(order, Order { id: 1, customer: "An".into(), amount: 150000 });
    }

    #[tokio::test]
    async fn empty_customer_is_rejected() {
        let response = app(AppState::default())
            .oneshot(post_order(r#"{"customer":"  ","amount":1}"#))
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
    }

    #[tokio::test]
    async fn unknown_order_returns_404() {
        let response = app(AppState::default())
            .oneshot(Request::get("/orders/42").body(Body::empty()).unwrap())
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::NOT_FOUND);
    }
}

Three things to note in the test code:

  • oneshot consumes the router. To send multiple requests to the same state, you need to clone() the router before each call. State is still shared because it’s wrapped in Arc.
  • BodyExt::collect gathers the entire body into Bytes, then serde_json parses it back into Order.
  • Each test creates a new AppState, so tests are independent and can run in parallel.

Run the tests:

cargo test
running 3 tests
test tests::unknown_order_returns_404 ... ok
test tests::empty_customer_is_rejected ... ok
test tests::create_then_get_order ... ok

test result: ok. 3 passed; 0 failed

9. Common Pitfalls

Missing Content-Type header. The Json extractor requires the application/json header. Without it, the client gets 415 Unsupported Media Type even if the body is valid. This is the most common mistake when testing with curl.

Distinguishing JSON error codes. Invalid JSON syntax returns 400. Valid JSON but missing fields or wrong types returns 422. By default, these responses have plain text bodies, not JSON. If your API needs consistent error formatting, wrap Json in a custom extractor and convert JsonRejection to AppError.

Using unwrap() on locks in production. If a thread panics while holding a lock, the lock becomes “poisoned”, and all subsequent unwrap() calls panic too. In real code, handle this or use an actual database.

Putting everything into a giant AppState. As your app grows, you can use FromRef to have handlers take only the state they need, like State<PgPool>, instead of the entire AppState.

When shouldn’t you use Axum? If you need a “batteries included” framework with built-in ORM, templating, admin, and migrations, Axum will require you to assemble many things yourself. If your team is new to async Rust, the trait bound error messages on handlers will also be a significant barrier initially.

10. Conclusion

You’ve just built a complete CRUD REST API with Axum 0.8. The main concepts are:

  • Router: binds paths and methods to handlers.
  • Extractors: State, Path, Query, and Json extract data from requests in a type-safe way.
  • Shared state: shared data wrapped in Arc.
  • Error handling: implement IntoResponse to return structured JSON errors.
  • Testing: send requests directly to the router using tower::ServiceExt::oneshot.

The logical next step is to replace HashMap with sqlx connecting to PostgreSQL. You should also add middleware from tower-http, like TraceLayer for request logging and CorsLayer for CORS configuration. Most handlers won’t need changes, because HTTP logic is already separated from the storage layer.

rustaxumrest-apitokiobackend