Building Type-Safe REST APIs with Axum in Rust
Axum is a web framework for Rust that makes building APIs a pleasure. It’s built on top of Tokio and Tower, giving you excellent performance and composability. In this post, we’ll build a simple but complete REST API for managing a todo list.
Project Setup
Create a new project and add the dependencies:
cargo new todo-api
cd todo-api
Update your Cargo.toml:
[package]
name = "todo-api"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = "0.8"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
uuid = { version = "1", features = ["v4", "serde"] }
Defining Our Data Types
Let’s start with the data structures. Axum works beautifully with Serde for JSON serialization:
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Todo {
pub id: Uuid,
pub title: String,
pub completed: bool,
}
#[derive(Debug, Deserialize)]
pub struct CreateTodo {
pub title: String,
}
#[derive(Debug, Deserialize)]
pub struct UpdateTodo {
pub title: Option<String>,
pub completed: Option<bool>,
}
Notice we have separate types for creating and updating todos. This is a common pattern that gives you precise control over what fields are required for each operation.
Setting Up Shared State
We’ll use an in-memory store with Arc<Mutex<>> for simplicity. In a real app, you’d use a database:
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::Mutex;
type TodoStore = Arc<Mutex<HashMap<Uuid, Todo>>>;
fn create_store() -> TodoStore {
Arc::new(Mutex::new(HashMap::new()))
}
Building the Router
Axum’s routing is intuitive and type-safe. Here’s our complete router setup:
use axum::{
extract::{Path, State},
http::StatusCode,
routing::{get, post},
Json, Router,
};
#[tokio::main]
async fn main() {
let store = create_store();
let app = Router::new()
.route("/todos", get(list_todos).post(create_todo))
.route("/todos/:id", get(get_todo).put(update_todo).delete(delete_todo))
.with_state(store);
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
.await
.unwrap();
println!("Server running on http://localhost:3000");
axum::serve(listener, app).await.unwrap();
}
Implementing the Handlers
List All Todos
async fn list_todos(
State(store): State<TodoStore>,
) -> Json<Vec<Todo>> {
let todos = store.lock().await;
let todos: Vec<Todo> = todos.values().cloned().collect();
Json(todos)
}
Create a Todo
async fn create_todo(
State(store): State<TodoStore>,
Json(input): Json<CreateTodo>,
) -> (StatusCode, Json<Todo>) {
let todo = Todo {
id: Uuid::new_v4(),
title: input.title,
completed: false,
};
store.lock().await.insert(todo.id, todo.clone());
(StatusCode::CREATED, Json(todo))
}
Get a Single Todo
async fn get_todo(
State(store): State<TodoStore>,
Path(id): Path<Uuid>,
) -> Result<Json<Todo>, StatusCode> {
let todos = store.lock().await;
todos
.get(&id)
.cloned()
.map(Json)
.ok_or(StatusCode::NOT_FOUND)
}
Update a Todo
async fn update_todo(
State(store): State<TodoStore>,
Path(id): Path<Uuid>,
Json(input): Json<UpdateTodo>,
) -> Result<Json<Todo>, StatusCode> {
let mut todos = store.lock().await;
let todo = todos.get_mut(&id).ok_or(StatusCode::NOT_FOUND)?;
if let Some(title) = input.title {
todo.title = title;
}
if let Some(completed) = input.completed {
todo.completed = completed;
}
Ok(Json(todo.clone()))
}
Delete a Todo
async fn delete_todo(
State(store): State<TodoStore>,
Path(id): Path<Uuid>,
) -> StatusCode {
let mut todos = store.lock().await;
if todos.remove(&id).is_some() {
StatusCode::NO_CONTENT
} else {
StatusCode::NOT_FOUND
}
}
Better Error Handling
Returning raw StatusCode works, but let’s create a proper error type for more informative responses:
use axum::response::{IntoResponse, Response};
#[derive(Debug)]
pub enum ApiError {
NotFound,
InvalidInput(String),
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let (status, message) = match self {
ApiError::NotFound => (StatusCode::NOT_FOUND, "Resource not found"),
ApiError::InvalidInput(msg) => (StatusCode::BAD_REQUEST, msg.as_str()),
};
let body = serde_json::json!({
"error": message
});
(status, Json(body)).into_response()
}
}
Now update the handlers to use our custom error:
async fn get_todo(
State(store): State<TodoStore>,
Path(id): Path<Uuid>,
) -> Result<Json<Todo>, ApiError> {
let todos = store.lock().await;
todos
.get(&id)
.cloned()
.map(Json)
.ok_or(ApiError::NotFound)
}
Complete Example
Here’s everything together in a single file:
use axum::{
extract::{Path, State},
http::StatusCode,
response::{IntoResponse, Response},
routing::{get, post},
Json, Router,
};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::Mutex;
use uuid::Uuid;
// Types
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Todo {
pub id: Uuid,
pub title: String,
pub completed: bool,
}
#[derive(Debug, Deserialize)]
pub struct CreateTodo {
pub title: String,
}
#[derive(Debug, Deserialize)]
pub struct UpdateTodo {
pub title: Option<String>,
pub completed: Option<bool>,
}
// State
type TodoStore = Arc<Mutex<HashMap<Uuid, Todo>>>;
// Error handling
#[derive(Debug)]
pub enum ApiError {
NotFound,
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let (status, message) = match self {
ApiError::NotFound => (StatusCode::NOT_FOUND, "Resource not found"),
};
let body = serde_json::json!({ "error": message });
(status, Json(body)).into_response()
}
}
// Handlers
async fn list_todos(State(store): State<TodoStore>) -> Json<Vec<Todo>> {
let todos = store.lock().await;
Json(todos.values().cloned().collect())
}
async fn create_todo(
State(store): State<TodoStore>,
Json(input): Json<CreateTodo>,
) -> (StatusCode, Json<Todo>) {
let todo = Todo {
id: Uuid::new_v4(),
title: input.title,
completed: false,
};
store.lock().await.insert(todo.id, todo.clone());
(StatusCode::CREATED, Json(todo))
}
async fn get_todo(
State(store): State<TodoStore>,
Path(id): Path<Uuid>,
) -> Result<Json<Todo>, ApiError> {
let todos = store.lock().await;
todos.get(&id).cloned().map(Json).ok_or(ApiError::NotFound)
}
async fn update_todo(
State(store): State<TodoStore>,
Path(id): Path<Uuid>,
Json(input): Json<UpdateTodo>,
) -> Result<Json<Todo>, ApiError> {
let mut todos = store.lock().await;
let todo = todos.get_mut(&id).ok_or(ApiError::NotFound)?;
if let Some(title) = input.title {
todo.title = title;
}
if let Some(completed) = input.completed {
todo.completed = completed;
}
Ok(Json(todo.clone()))
}
async fn delete_todo(
State(store): State<TodoStore>,
Path(id): Path<Uuid>,
) -> Result<StatusCode, ApiError> {
let mut todos = store.lock().await;
todos.remove(&id).map(|_| StatusCode::NO_CONTENT).ok_or(ApiError::NotFound)
}
#[tokio::main]
async fn main() {
let store: TodoStore = Arc::new(Mutex::new(HashMap::new()));
let app = Router::new()
.route("/todos", get(list_todos).post(create_todo))
.route("/todos/{id}", get(get_todo).put(update_todo).delete(delete_todo))
.with_state(store);
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
.await
.unwrap();
println!("Server running on http://localhost:3000");
axum::serve(listener, app).await.unwrap();
}
Testing the API
Run the server with cargo run and test it:
# Create a todo
curl -X POST http://localhost:3000/todos \
-H "Content-Type: application/json" \
-d '{"title": "Learn Axum"}'
# List all todos
curl http://localhost:3000/todos
# Get a specific todo (replace with actual UUID)
curl http://localhost:3000/todos/your-uuid-here
# Update a todo
curl -X PUT http://localhost:3000/todos/your-uuid-here \
-H "Content-Type: application/json" \
-d '{"completed": true}'
# Delete a todo
curl -X DELETE http://localhost:3000/todos/your-uuid-here
What’s Next?
This is a solid foundation, but real applications need more:
- Database integration: Use
sqlxordieselfor persistence - Validation: Add input validation with
validatorcrate - Authentication: Implement JWT or session-based auth
- Middleware: Add logging, CORS, and rate limiting
Axum’s modular design makes adding these features straightforward. The type system ensures that if your code compiles, it’s likely correct.
Final Thoughts
Axum brings the best of Rust to web development: type safety, performance, and ergonomics. The extractors make handling requests intuitive, and the integration with the Tower ecosystem gives you access to a wealth of middleware.
If you’re coming from frameworks like Express or Flask, Axum might feel different at first. But once you get used to the patterns, you’ll appreciate how the compiler catches bugs before they reach production.