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:
Arclets multiple handlers share the same dataRwLockallows 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
Related Articles
- Rust Tutorial #21: HTTP with Reqwest – previous tutorial
- Rust Tutorial #23: Database with SQLx – next tutorial
- Rust Tutorial Series – all tutorials