Building a CRUD (Create, Read, Update, Delete) server in Rust is an excellent way to learn both Rust’s powerful type system and modern web development patterns. In this tutorial, we’ll create a robust REST API using Axum, one of Rust’s most popular web frameworks.

What We’ll Build

We’ll create a simple task management API with the following endpoints:

  • GET /tasks - List all tasks
  • POST /tasks - Create a new task
  • GET /tasks/{id} - Get a specific task
  • PUT /tasks/{id} - Update a task
  • DELETE /tasks/{id} - Delete a task

Prerequisites

  • Rust installed (rustup.rs)
  • Basic understanding of Rust syntax
  • Familiarity with REST APIs

Setting Up the Project

First, create a new Rust project:

cargo new rust-crud-server
cd rust-crud-server

Add the necessary dependencies to your Cargo.toml:

[dependencies]
axum = "0.7"
tokio = { version = "1.0", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
uuid = { version = "1.0", features = ["v4", "serde"] }

Defining Our Data Model

Create a Task struct that represents our data:

use serde::{Deserialize, Serialize};
use uuid::Uuid;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Task {
    pub id: Uuid,
    pub title: String,
    pub description: Option<String>,
    pub completed: bool,
}

#[derive(Debug, Deserialize)]
pub struct CreateTask {
    pub title: String,
    pub description: Option<String>,
}

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

Creating the In-Memory Store

For simplicity, we’ll use an in-memory store. In production, you’d use a database:

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

pub type TaskStore = Arc<RwLock<HashMap<Uuid, Task>>>;

pub fn create_store() -> TaskStore {
    Arc::new(RwLock::new(HashMap::new()))
}

Implementing CRUD Handlers

Now let’s implement our CRUD operations:

use axum::{
    extract::{Path, State},
    http::StatusCode,
    response::Json,
    routing::{delete, get, post, put},
    Router,
};

// Create a new task
async fn create_task(
    State(store): State<TaskStore>,
    Json(payload): Json<CreateTask>,
) -> Result<Json<Task>, StatusCode> {
    let task = Task {
        id: Uuid::new_v4(),
        title: payload.title,
        description: payload.description,
        completed: false,
    };

    let mut tasks = store.write().await;
    tasks.insert(task.id, task.clone());
    
    Ok(Json(task))
}

// Get all tasks
async fn get_tasks(State(store): State<TaskStore>) -> Json<Vec<Task>> {
    let tasks = store.read().await;
    let task_list: Vec<Task> = tasks.values().cloned().collect();
    Json(task_list)
}

// Get a specific task
async fn get_task(
    Path(id): Path<Uuid>,
    State(store): State<TaskStore>,
) -> Result<Json<Task>, StatusCode> {
    let tasks = store.read().await;
    match tasks.get(&id) {
        Some(task) => Ok(Json(task.clone())),
        None => Err(StatusCode::NOT_FOUND),
    }
}

// Update a task
async fn update_task(
    Path(id): Path<Uuid>,
    State(store): State<TaskStore>,
    Json(payload): Json<UpdateTask>,
) -> Result<Json<Task>, StatusCode> {
    let mut tasks = store.write().await;
    match tasks.get_mut(&id) {
        Some(task) => {
            if let Some(title) = payload.title {
                task.title = title;
            }
            if let Some(description) = payload.description {
                task.description = Some(description);
            }
            if let Some(completed) = payload.completed {
                task.completed = completed;
            }
            Ok(Json(task.clone()))
        }
        None => Err(StatusCode::NOT_FOUND),
    }
}

// Delete a task
async fn delete_task(
    Path(id): Path<Uuid>,
    State(store): State<TaskStore>,
) -> StatusCode {
    let mut tasks = store.write().await;
    match tasks.remove(&id) {
        Some(_) => StatusCode::NO_CONTENT,
        None => StatusCode::NOT_FOUND,
    }
}

Setting Up the Router and Server

Finally, let’s wire everything together:

#[tokio::main]
async fn main() {
    let store = create_store();

    let app = Router::new()
        .route("/tasks", get(get_tasks).post(create_task))
        .route("/tasks/:id", get(get_task).put(update_task).delete(delete_task))
        .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 Your API

Start your server:

cargo run

Test with curl:

# Create a task
curl -X POST http://localhost:3000/tasks \
  -H "Content-Type: application/json" \
  -d '{"title": "Learn Rust", "description": "Build a CRUD API"}'

# Get all tasks
curl http://localhost:3000/tasks

# Update a task (replace {id} with actual UUID)
curl -X PUT http://localhost:3000/tasks/{id} \
  -H "Content-Type: application/json" \
  -d '{"completed": true}'

Next Steps

This basic CRUD server can be enhanced with:

  • Database integration (PostgreSQL, SQLite)
  • Authentication and authorization
  • Input validation
  • Error handling middleware
  • Logging
  • Testing

Conclusion

Rust’s type system and performance make it an excellent choice for building robust web APIs. The combination of Axum’s ergonomic API and Rust’s safety guarantees provides a solid foundation for scalable backend services.

The complete code for this tutorial is available as a reference for building more complex applications.