In the previous tutorial, we learned to make HTTP requests. Now we build the other side — a REST API server with Axum.

Axum is a web framework built on top of Tokio and Tower. It is fast, type-safe, and ergonomic. Unlike some other frameworks, Axum uses standard Rust types and traits. There are no macros on your handler functions. Everything is just regular async functions.

Setting Up

Add these dependencies to your Cargo.toml:

[dependencies]
tokio = { version = "1", features = ["full"] }
axum = "0.8"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tower-http = { version = "0.6", features = ["cors"] }

Your First Axum Server

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

async fn hello() -> &'static str {
    "Hello, World!"
}

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

    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
        .await
        .unwrap();

    println!("Server running on http://127.0.0.1:3000");
    axum::serve(listener, app).await.unwrap();
}

The handler function hello is a plain async function that returns a string. Axum converts it to an HTTP response automatically. Any type that implements IntoResponse can be returned from a handler.

Data Models

For our API, we need these types:

use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize)]
struct Todo {
    id: u64,
    title: String,
    completed: bool,
}

#[derive(Debug, Deserialize)]
struct CreateTodo {
    title: String,
}

#[derive(Debug, Deserialize)]
struct UpdateTodo {
    title: Option<String>,
    completed: Option<bool>,
}

We use separate types for creating and updating. CreateTodo only needs a title. UpdateTodo has optional fields — you can update just the title, just the status, or both.

Application State

REST APIs need shared state. Axum uses the State extractor to share data across handlers:

use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;

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

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

We use Arc<RwLock<>> because:

  • Arc lets multiple handlers share the same data
  • RwLock allows multiple readers OR one writer at a time
  • Multiple GET requests can read at the same time
  • POST/PUT/DELETE get exclusive write access

Extractors

Extractors parse incoming requests. You add them as function parameters, and Axum fills them in automatically.

Json Extractor — Request Body

use axum::extract::{Json, State};
use axum::response::IntoResponse;

async fn create_todo(
    State(state): State<AppState>,
    Json(input): Json<CreateTodo>,
) -> impl IntoResponse {
    // input.title is available here
}

Path Extractor — URL Parameters

use axum::extract::Path;

async fn get_todo(
    State(state): State<AppState>,
    Path(id): Path<u64>,
) -> impl IntoResponse {
    // id is parsed from /todos/123
}

Query Extractor — Query Parameters

use axum::extract::Query;

#[derive(Deserialize)]
struct PaginationParams {
    #[serde(default = "default_page")]
    page: u64,
    #[serde(default = "default_limit")]
    limit: u64,
}

fn default_page() -> u64 { 1 }
fn default_limit() -> u64 { 10 }

async fn list_todos(
    State(state): State<AppState>,
    Query(params): Query<PaginationParams>,
) -> impl IntoResponse {
    // params.page and params.limit are available
}

Building CRUD Handlers

List All Todos (GET /todos)

use axum::http::StatusCode;

#[derive(Serialize)]
struct ListResponse {
    data: Vec<Todo>,
    total: usize,
}

async fn list_todos(
    State(state): State<AppState>,
    Query(params): Query<PaginationParams>,
) -> impl IntoResponse {
    let todos = state.todos.read().await;
    let mut all_todos: Vec<Todo> = todos.values().cloned().collect();
    all_todos.sort_by_key(|t| t.id);

    let total = all_todos.len();
    let start = ((params.page - 1) * params.limit) as usize;
    let end = (start + params.limit as usize).min(total);

    let page_todos = if start < total {
        all_todos[start..end].to_vec()
    } else {
        vec![]
    };

    Json(ListResponse {
        data: page_todos,
        total,
    })
}

Create a Todo (POST /todos)

async fn create_todo(
    State(state): State<AppState>,
    Json(input): Json<CreateTodo>,
) -> impl IntoResponse {
    if input.title.trim().is_empty() {
        return (
            StatusCode::BAD_REQUEST,
            Json(serde_json::json!({"error": "Title cannot be empty"})),
        );
    }

    let mut next_id = state.next_id.write().await;
    let id = *next_id;
    *next_id += 1;

    let todo = Todo {
        id,
        title: input.title.trim().to_string(),
        completed: false,
    };

    state.todos.write().await.insert(id, todo.clone());

    (
        StatusCode::CREATED,
        Json(serde_json::json!({"data": todo})),
    )
}

Key patterns:

  • Validate the input before processing
  • Return StatusCode::BAD_REQUEST (400) for invalid input
  • Return StatusCode::CREATED (201) on success
  • Trim whitespace from user input

Get a Single Todo (GET /todos/:id)

async fn get_todo(
    State(state): State<AppState>,
    Path(id): Path<u64>,
) -> impl IntoResponse {
    let todos = state.todos.read().await;

    match todos.get(&id) {
        Some(todo) => (
            StatusCode::OK,
            Json(serde_json::json!({"data": todo})),
        ),
        None => (
            StatusCode::NOT_FOUND,
            Json(serde_json::json!({"error": "Todo not found"})),
        ),
    }
}

Update a Todo (PUT /todos/:id)

async fn update_todo(
    State(state): State<AppState>,
    Path(id): Path<u64>,
    Json(input): Json<UpdateTodo>,
) -> impl IntoResponse {
    let mut todos = state.todos.write().await;

    match todos.get_mut(&id) {
        Some(todo) => {
            if let Some(title) = input.title {
                if title.trim().is_empty() {
                    return (
                        StatusCode::BAD_REQUEST,
                        Json(serde_json::json!({"error": "Title cannot be empty"})),
                    );
                }
                todo.title = title.trim().to_string();
            }
            if let Some(completed) = input.completed {
                todo.completed = completed;
            }
            let updated = todo.clone();
            (
                StatusCode::OK,
                Json(serde_json::json!({"data": updated})),
            )
        }
        None => (
            StatusCode::NOT_FOUND,
            Json(serde_json::json!({"error": "Todo not found"})),
        ),
    }
}

Delete a Todo (DELETE /todos/:id)

async fn delete_todo(
    State(state): State<AppState>,
    Path(id): Path<u64>,
) -> impl IntoResponse {
    let mut todos = state.todos.write().await;

    match todos.remove(&id) {
        Some(_) => (
            StatusCode::OK,
            Json(serde_json::json!({"message": "Todo deleted"})),
        ),
        None => (
            StatusCode::NOT_FOUND,
            Json(serde_json::json!({"error": "Todo not found"})),
        ),
    }
}

Setting Up Routes

Connect handlers to URL paths:

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

fn create_router(state: AppState) -> Router {
    Router::new()
        .route("/", get(hello))
        .route("/health", get(health_check))
        .route("/todos", get(list_todos).post(create_todo))
        .route("/todos/{id}", get(get_todo).put(update_todo).delete(delete_todo))
        .with_state(state)
}

Each .route() maps a path to one or more HTTP methods. You can chain methods like .get(handler).post(handler) on the same route. In Axum 0.8, path parameters use curly braces: /todos/{id}.

Health Check Endpoint

Every API should have a health check:

async fn health_check() -> impl IntoResponse {
    (StatusCode::OK, Json(serde_json::json!({"status": "ok"})))
}

Load balancers and monitoring tools use this to check if your server is running.

CORS Middleware

If your API will be called from a web browser, you need CORS headers:

use tower_http::cors::{Any, CorsLayer};

fn create_router(state: AppState) -> Router {
    let cors = CorsLayer::new()
        .allow_origin(Any)
        .allow_methods(Any)
        .allow_headers(Any);

    Router::new()
        .route("/", get(hello))
        .route("/health", get(health_check))
        .route("/todos", get(list_todos).post(create_todo))
        .route("/todos/{id}", get(get_todo).put(update_todo).delete(delete_todo))
        .layer(cors)
        .with_state(state)
}

For production, replace Any with specific origins:

let cors = CorsLayer::new()
    .allow_origin("https://myapp.com".parse::<http::HeaderValue>().unwrap())
    .allow_methods([http::Method::GET, http::Method::POST])
    .allow_headers([http::header::CONTENT_TYPE]);

Response Types

Axum handlers can return any type that implements IntoResponse:

// Return a string
async fn text() -> &'static str {
    "Hello"
}

// Return JSON
async fn json_response() -> Json<serde_json::Value> {
    Json(serde_json::json!({"hello": "world"}))
}

// Return status code + JSON
async fn with_status() -> (StatusCode, Json<serde_json::Value>) {
    (StatusCode::CREATED, Json(serde_json::json!({"id": 1})))
}

When you return a tuple of (StatusCode, Json<T>), Axum sets the status code and serializes the JSON body.

Error Handling

For consistent error responses, use a dedicated error type:

#[derive(Serialize)]
struct ErrorResponse {
    error: String,
}

async fn handler_with_error() -> Result<Json<Todo>, (StatusCode, Json<ErrorResponse>)> {
    let todo = find_todo(1).ok_or_else(|| {
        (
            StatusCode::NOT_FOUND,
            Json(ErrorResponse {
                error: "Todo not found".to_string(),
            }),
        )
    })?;
    Ok(Json(todo))
}

Testing Your Handlers

Test the state and business logic directly:

#[cfg(test)]
mod tests {
    use super::*;

    #[tokio::test]
    async fn test_create_and_get() {
        let state = AppState::new();

        // Create a todo
        let mut next_id = state.next_id.write().await;
        let id = *next_id;
        *next_id += 1;
        drop(next_id);

        let todo = Todo {
            id,
            title: "Buy groceries".to_string(),
            completed: false,
        };
        state.todos.write().await.insert(id, todo);

        // Verify it exists
        let todos = state.todos.read().await;
        let found = todos.get(&id).unwrap();
        assert_eq!(found.title, "Buy groceries");
        assert!(!found.completed);
    }

    #[tokio::test]
    async fn test_delete() {
        let state = AppState::new();

        let todo = Todo { id: 1, title: "Test".to_string(), completed: false };
        state.todos.write().await.insert(1, todo);

        let removed = state.todos.write().await.remove(&1);
        assert!(removed.is_some());
        assert!(state.todos.read().await.get(&1).is_none());
    }
}

Running and Testing the API

Start the server:

cargo run

Test with curl:

# Health check
curl http://127.0.0.1:3000/health

# Create a todo
curl -X POST http://127.0.0.1:3000/todos \
  -H "Content-Type: application/json" \
  -d '{"title":"Buy groceries"}'

# List todos
curl http://127.0.0.1:3000/todos

# Get one todo
curl http://127.0.0.1:3000/todos/1

# Update a todo
curl -X PUT http://127.0.0.1:3000/todos/1 \
  -H "Content-Type: application/json" \
  -d '{"completed":true}'

# Delete a todo
curl -X DELETE http://127.0.0.1:3000/todos/1

Common Mistakes

Mistake 1: Holding Write Locks Across Await Points

// BAD: write lock held across .await
async fn bad_handler(State(state): State<AppState>) -> impl IntoResponse {
    let mut todos = state.todos.write().await;
    some_slow_operation().await;  // Lock blocks ALL other handlers
    todos.insert(1, todo);
}

// GOOD: release lock before await
async fn good_handler(State(state): State<AppState>) -> impl IntoResponse {
    let data = some_slow_operation().await;  // No lock held
    let mut todos = state.todos.write().await;
    todos.insert(1, todo);  // Lock released at end of scope
}

Mistake 2: Not Validating Input

// BAD: trusts user input
async fn bad_create(Json(input): Json<CreateTodo>) -> impl IntoResponse {
    let todo = Todo { id: 1, title: input.title, completed: false };
}

// GOOD: validate before using
async fn good_create(Json(input): Json<CreateTodo>) -> impl IntoResponse {
    if input.title.trim().is_empty() {
        return (StatusCode::BAD_REQUEST, Json(serde_json::json!({"error": "Title required"})));
    }
    // Use the validated title
}

Mistake 3: Using Mutex When RwLock Is Better

// Simple case: Mutex is fine
let counter = Arc::new(tokio::sync::Mutex::new(0u64));

// Many readers, few writers: RwLock is better
let todos = Arc::new(tokio::sync::RwLock::new(HashMap::new()));

Use RwLock when you have many more reads than writes.

A Note on Logging

For production servers, use the tracing crate instead of println!:

[dependencies]
tracing = "0.1"
tracing-subscriber = "0.3"
use tracing::info;

#[tokio::main]
async fn main() {
    tracing_subscriber::fmt::init();

    let app = create_router(AppState::new());
    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
        .await
        .unwrap();

    info!("Server running on http://127.0.0.1:3000");
    axum::serve(listener, app).await.unwrap();
}

Source Code

Find the complete code on GitHub: tutorial-22-axum

What’s Next?

In this tutorial, we built a complete REST API with Axum. We covered routing, extractors, shared state, CORS, and error handling.

In the next tutorial, we will add a real database with SQLx — connecting to SQLite, running queries, and replacing our in-memory storage.

Next: Rust Tutorial #23: Database with SQLx