Introduction
I recently embarked on an exciting journey to build a production-ready REST API using Rust and the Axum framework. What started as a simple “Hello World” example evolved into a fully functional CRUD (Create, Read, Update, Delete) API with proper error handling and HTTP status codes. In this blog post, I’ll walk through every step of the process, explaining why I made certain decisions and how everything fits together.
Why Rust and Axum?
I chose Rust because of its performance, memory safety, and excellent async support. Axum is a web framework built on top of Tokio, Hyper, and Tower that provides:
- Type-safe routing with compile-time checks
- Extractors for clean request handling
- Shared state management
- Middleware support via Tower
- Excellent error handling with custom responses
Step-by-Step Implementation
1. Setting Up the Project
I started by creating a new Rust project with Cargo:
bash
cargo new axum-server cd axum-server
2. Adding Dependencies
In Cargo.toml, I added the necessary dependencies:
toml
[dependencies]
axum = "0.7"
tokio = { version = "1", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tower = "0.4"
tower-http = { version = "0.5", features = ["cors", "trace"] }
Why these dependencies?
- Axum: The web framework
- Tokio: Async runtime for handling concurrent requests
- Serde/Serde-Json: Serialization/deserialization of JSON data
- Tower/Tower-HTTP: Middleware for CORS, logging, and more
3. Defining the Application State
I created a shared state structure that would hold our in-memory todo data:
rust
#[derive(Clone)]
struct AppState {
todos: Arc<Mutex<HashMap<u32, Todo>>>,
next_id: Arc<Mutex<u32>>,
}
Why this approach?
- Arc (Atomic Reference Counting): Allows multiple threads to share ownership of the data
- Mutex (Mutual Exclusion): Ensures thread-safe access to the hashmap
- HashMap: Provides O(1) lookups for our todos
- next_id: Auto-incrementing ID generator for new todos
4. Creating Todo Models
rust
#[derive(Debug, Clone, Serialize, Deserialize)]
struct Todo {
id: u32,
title: String,
completed: bool,
}
#[derive(Debug, Deserialize)]
struct CreateTodo {
title: String,
}
#[derive(Debug, Deserialize)]
struct UpdateTodo {
title: Option<String>,
completed: Option<bool>,
}
Why separate structs?
- Todo: The full data model
- CreateTodo: Only requires the title for creation (completed starts as false)
- UpdateTodo: Optional fields for partial updates (allows updating just title OR just completed status)
5. Building the Router
The router is where I defined all endpoints:
rust
let app = Router::new()
.route("/", get(root))
.route("/health", get(health_check))
.route("/todos", get(get_todos).post(create_todo))
.route("/todos/:id", get(get_todo).put(update_todo).delete(delete_todo))
.layer(CorsLayer::new().allow_origin(Any).allow_methods(Any).allow_headers(Any))
.with_state(state);
Why this structure?
- Chainable routes: Clean and readable API definition
- Path parameters:Â
:id allows dynamic routing - Method chaining: Multiple HTTP methods on the same route
- State injection:Â
.with_state(state)Â makes the AppState available to all handlers - CORS layer: Allows cross-origin requests (important for future frontend integration)
6. Implementing Handlers
Here’s how I implemented each CRUD operation:
GET / (Root)
rust
async fn root() -> &'static str {
"Hello, World! 🦀"
}
Simple welcome message to test the server.
GET /health
rust
async fn health_check() -> Json<serde_json::Value> {
Json(serde_json::json!({
"status": "healthy",
"message": "Server is running!"
}))
}
Health check endpoint for monitoring and load balancers.
GET /todos (List all todos)
rust
async fn get_todos(State(state): State<AppState>) -> Json<Vec<Todo>> {
let todos = state.todos.lock().await;
Json(todos.values().cloned().collect())
}
Why this works: Locks the Mutex, clones all todos, and returns as JSON.
GET /todos/:id (Get single todo)
rust
async fn get_todo(
State(state): State<AppState>,
Path(id): Path<u32>,
) -> impl IntoResponse {
let todos = state.todos.lock().await;
match todos.get(&id) {
Some(todo) => Json(todo.clone()).into_response(),
None => (StatusCode::NOT_FOUND, Json(serde_json::json!({
"error": "Todo not found"
}))).into_response(),
}
}
Why error handling matters: Returns a proper 404 status with a descriptive JSON error message.
POST /todos (Create todo)
rust
async fn create_todo(
State(state): State<AppState>,
Json(payload): Json<CreateTodo>,
) -> (StatusCode, Json<Todo>) {
let mut todos = state.todos.lock().await;
let mut next_id = state.next_id.lock().await;
let id = *next_id;
*next_id += 1;
let todo = Todo {
id,
title: payload.title,
completed: false,
};
todos.insert(id, todo.clone());
(StatusCode::CREATED, Json(todo))
}
Why this approach:
- Two-step locking: Prevents deadlocks by locking one mutex at a time
- Auto-increment ID: Simple but effective for in-memory storage
- 201 Created: Proper HTTP status code for successful creation
- Returns the created todo: Allows client to see the full object with its ID
PUT /todos/:id (Update todo)
rust
async fn update_todo(
State(state): State<AppState>,
Path(id): Path<u32>,
Json(payload): Json<UpdateTodo>,
) -> impl IntoResponse {
let mut todos = state.todos.lock().await;
if let Some(todo) = todos.get_mut(&id) {
if let Some(title) = payload.title {
todo.title = title;
}
if let Some(completed) = payload.completed {
todo.completed = completed;
}
Json(todo.clone()).into_response()
} else {
(StatusCode::NOT_FOUND, Json(serde_json::json!({
"error": "Todo not found"
}))).into_response()
}
}
Why this works:
- Partial updates: Optional fields allow updating just title or just status
- get_mut(): Gets mutable reference to update in-place
- 404 handling: Proper error response if todo doesn’t exist
DELETE /todos/:id (Delete todo)
rust
async fn delete_todo(
State(state): State<AppState>,
Path(id): Path<u32>,
) -> impl IntoResponse {
let mut todos = state.todos.lock().await;
if todos.remove(&id).is_some() {
StatusCode::NO_CONTENT.into_response()
} else {
(StatusCode::NOT_FOUND, Json(serde_json::json!({
"error": "Todo not found"
}))).into_response()
}
}
Why this approach:
- 204 No Content: Standard response for successful deletion
- remove() returns Option: Check if item existed before deletion
- Clean error handling: Returns 404 if todo doesn’t exist
7. Running the Server
rust
#[tokio::main]
async fn main() {
let state = AppState { ... };
let app = Router::new()...
let listener = tokio::net::TcpListener::bind("0.0.0.0:3002").await.unwrap();
println!("🚀 Server running on http://localhost:3002");
println!("📝 Complete Todo API endpoints:");
println!(" GET /todos - List all todos");
println!(" POST /todos - Create a todo");
println!(" GET /todos/:id - Get a todo by ID");
println!(" PUT /todos/:id - Update a todo");
println!(" DELETE /todos/:id - Delete a todo");
axum::serve(listener, app).await.unwrap();
}
Why use #[tokio::main]: Enables the async runtime for the entire application.
Key Technical Decisions
1. Why Arc<Mutex<T>>?
- Arc: Allows sharing state across threads
- Mutex: Ensures thread-safe access
- Alternative: Could useÂ
RwLock for read-heavy workloads
2. Why impl IntoResponse?
- Allows flexible return types
- Can return Json, StatusCode, or custom types
- Enables proper error handling with different response types
3. Why Separate Request/Response Structs?
- Separation of concerns: Request validation vs. response formatting
- Better validation: Can add validation to CreateTodo/UpdateTodo
- Future-proof: Easy to extend without breaking existing code
4. Why Tower Middleware?
- Modular: Easy to add/remove features
- Composable: Stack multiple layers (CORS, logging, compression)
- Reusable: Can use the same middleware in different projects
Testing the API
I tested each endpoint with curl:
Create a todo:
bash
curl -X POST http://localhost:3002/todos -H "Content-Type: application/json" -d '{"title":"Learn Rust"}'
Response: {"id":1,"title":"Learn Rust","completed":false}
Get all todos:
bash
curl http://localhost:3002/todos
Get a specific todo:
bash
curl http://localhost:3002/todos/1
Update a todo:
bash
curl -X PUT http://localhost:3002/todos/1 -H "Content-Type: application/json" -d '{"completed":true}'
Delete a todo:
bash
curl -X DELETE http://localhost:3002/todos/1
What I Learned
- Async in Rust: Handling async/await with Tokio
- Type Safety: Rust’s type system prevents many runtime errors
- Error Handling: Proper error responses with appropriate HTTP status codes
- State Management: Sharing state across multiple requests
- Middleware: Adding cross-cutting concerns like CORS
- Testing: Using curl to test API endpoints
- JSON Serialization: Using Serde for seamless JSON handling
- Thread Safety: Using Arc and Mutex for shared mutable state
Next Steps
This is just the beginning. Here’s what I want to explore next:
- Add a database: PostgreSQL with SQLx for persistent storage
- Authentication: JWT tokens for secure endpoints
- Logging: Add structured logging with tracing
- Testing: Unit tests and integration tests
- Deployment: Deploy to Docker, then to a cloud provider
- OpenAPI/Swagger: Generate API documentation
- Frontend: Build a React or Vue.js frontend
- WebSocket: Add real-time updates
- Pagination: Handle large datasets efficiently
- Rate Limiting: Prevent API abuse
Conclusion
Building this REST API with Rust and Axum was an incredible learning experience. The combination of Rust’s performance, Axum’s simplicity, and Tower’s middleware ecosystem makes for a powerful development experience.
The final code achieved:
- âś… Complete CRUD operations
- âś… Proper HTTP status codes
- âś… JSON request/response handling
- âś… Thread-safe state management
- âś… Error handling with 404 responses
- âś… CORS support for frontend integration
Resources
Blog Post Metadata
Title: Building a Complete REST API with Axum in Rust: A Step-by-Step Journey
Subtitle: From “Hello World” to a Production-Ready CRUD API
Create todo
curl -X POST http://localhost:3002/todos -H “Content-Type: application/json” -d ‘{“title”:”Learn Rust”}’
Get all todos
curl http://localhost:3002/todos
Get specific todo
curl http://localhost:3002/todos/1
Update todo
curl -X PUT http://localhost:3002/todos/1 -H “Content-Type: application/json” -d ‘{“completed”:true}’
Delete todo
curl -X DELETE http://localhost:3002/todos/1