Building a Complete REST API with Axum in Rust: My Journey 🦀

A Hello World in Axum turned into a CRUD REST API in Rust—routes, state, and the pieces that make it shippable.

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

  1. Async in Rust: Handling async/await with Tokio
  2. Type Safety: Rust’s type system prevents many runtime errors
  3. Error Handling: Proper error responses with appropriate HTTP status codes
  4. State Management: Sharing state across multiple requests
  5. Middleware: Adding cross-cutting concerns like CORS
  6. Testing: Using curl to test API endpoints
  7. JSON Serialization: Using Serde for seamless JSON handling
  8. 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:

  1. Add a database: PostgreSQL with SQLx for persistent storage
  2. Authentication: JWT tokens for secure endpoints
  3. Logging: Add structured logging with tracing
  4. Testing: Unit tests and integration tests
  5. Deployment: Deploy to Docker, then to a cloud provider
  6. OpenAPI/Swagger: Generate API documentation
  7. Frontend: Build a React or Vue.js frontend
  8. WebSocket: Add real-time updates
  9. Pagination: Handle large datasets efficiently
  10. 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