Table of Contents
- Prerequisites
- Setting Up Your Environment
- Choosing a Web Framework: Why Actix-web?
- Hello World: Your First Rust API
- Building a CRUD Todo API
- Error Handling
- Adding Middleware
- Testing the API
- Deployment Considerations
- Next Steps
- References
Prerequisites
Before diving in, ensure you have the following:
- Basic familiarity with Rust syntax and concepts (e.g., ownership, structs, async/await).
- Rust and Cargo installed (see Rustup for setup).
- A code editor (VS Code with the rust-analyzer extension is recommended).
- Basic understanding of HTTP (methods like GET/POST, status codes like 200/404).
Setting Up Your Environment
First, confirm Rust is installed:
rustc --version # Should print Rust version (e.g., rustc 1.75.0)
cargo --version # Should print Cargo version (e.g., cargo 1.75.0)
If not installed, run:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
Restart your terminal, then create a new Rust project for your API:
cargo new rust-todo-api
cd rust-todo-api
This creates a basic Cargo.toml (dependencies) and src/main.rs (entry point).
Choosing a Web Framework: Why Actix-web?
Rust has several web frameworks (e.g., Rocket, Tide, Warp), but we’ll use Actix-web for this guide. Here’s why:
- Async-first: Leverages Rust’s async/await for non-blocking I/O, critical for high-performance APIs.
- Mature ecosystem: Well-documented, with robust middleware, routing, and error-handling tools.
- Type safety: Integrates seamlessly with Rust’s type system to catch errors at compile time.
- Performance: One of the fastest Rust web frameworks (benchmarks often rival Node.js and Go).
Add Actix-web to your Cargo.toml dependencies:
[package]
name = "rust-todo-api"
version = "0.1.0"
edition = "2021"
[dependencies]
actix-web = "4" # Web framework
serde = { version = "1.0", features = ["derive"] } # JSON serialization/deserialization
Hello World: Your First Rust API
Let’s start with a simple “Hello World” API to verify our setup. Replace the contents of src/main.rs with:
use actix_web::{get, App, HttpServer, Responder};
// Define a handler for the root route (GET /)
#[get("/")]
async fn hello() -> impl Responder {
"Hello, World! 👋 This is your first Rust API!"
}
#[actix_web::main] // Async entry point (replaces `main`)
async fn main() -> std::io::Result<()> {
// Create an HTTP server with the hello handler
HttpServer::new(|| {
App::new()
.service(hello) // Register the hello handler
})
.bind(("127.0.0.1", 8080))? // Bind to localhost:8080
.run() // Start the server
.await
}
Explanation:
#[get("/")]: A macro that registershelloas a handler forGET /.async fn hello() -> impl Responder: Async handler returning a type that implementsResponder(e.g., strings, JSON).HttpServer::new(...): Creates a server with anApp(Actix-web’s application builder)..bind(("127.0.0.1", 8080)): Binds the server tolocalhost:8080.
Run the Server:
cargo run
You should see output like:
Starting server on 127.0.0.1:8080
Test it with curl or a browser:
curl http://localhost:8080
# Output: Hello, World! 👋 This is your first Rust API!
Great! You’ve built your first Rust API. Now let’s expand it into a full CRUD Todo API.
Building a CRUD Todo API
We’ll build a Todo API with the following endpoints:
GET /todos: List all todos.GET /todos/{id}: Get a single todo by ID.POST /todos: Create a new todo.PUT /todos/{id}: Update an existing todo.DELETE /todos/{id}: Delete a todo.
Define the Todo Struct
First, define a Todo struct to represent our data. We’ll use serde to serialize/deserialize JSON. Update src/main.rs:
use actix_web::{get, post, put, delete, App, HttpServer, Responder, web, HttpResponse, Error};
use serde::{Serialize, Deserialize};
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::Mutex; // For thread-safe in-memory storage
// Todo struct with serde derive for JSON handling
#[derive(Debug, Serialize, Deserialize, Clone)]
struct Todo {
id: Option<u64>, // ID is optional for creation (server generates it)
title: String,
completed: bool,
}
#[derive(Serialize, Deserialize)]: AllowsTodoto be converted to/from JSON.id: Option<u64>:Nonewhen creating a todo (server assigns it),Some(u64)when retrieving/updating.
In-Memory Storage
For simplicity, we’ll use an in-memory HashMap to store todos. Since Actix-web uses async and multi-threaded servers, we need thread-safe storage. We’ll wrap the HashMap in Arc<Mutex<...>>:
Arc: Allows shared ownership across threads.Mutex: Ensures exclusive access to the HashMap (prevents race conditions).
Add this to src/main.rs:
// Type alias for our thread-safe storage
type TodoStore = Arc<Mutex<HashMap<u64, Todo>>>;
Initialize the store in main:
#[actix_web::main]
async fn main() -> std::io::Result<()> {
// Initialize in-memory storage with a Mutex inside an Arc
let todo_store: TodoStore = Arc::new(Mutex::new(HashMap::new()));
HttpServer::new(move || {
App::new()
.app_data(web::Data::new(todo_store.clone())) // Make store available to handlers
.service(hello)
// Register CRUD endpoints here later
})
.bind(("127.0.0.1", 8080))?
.run()
.await
}
web::Data::new(...): Makes thetodo_storeavailable to handlers via dependency injection.
CRUD Endpoints
Let’s implement each endpoint one by one.
1. List All Todos (GET /todos)
Add a handler to fetch all todos from the store:
#[get("/todos")]
async fn get_todos(data: web::Data<TodoStore>) -> impl Responder {
let store = data.lock().await; // Lock the Mutex to access the HashMap
let todos: Vec<&Todo> = store.values().collect(); // Convert HashMap values to Vec
HttpResponse::Ok().json(todos) // Return 200 OK with JSON body
}
web::Data<TodoStore>: Injects thetodo_storeinto the handler.store.lock().await: Acquires the Mutex lock (async, non-blocking).HttpResponse::Ok().json(todos): Returns a 200 OK response with todos serialized to JSON.
2. Create a Todo (POST /todos)
Add a handler to create a new todo. We’ll generate a unique ID using a counter (simplified for this example):
// Helper to generate unique IDs (simplified)
async fn generate_id(store: &TodoStore) -> u64 {
let store = store.lock().await;
store.keys().max().copied().unwrap_or(0) + 1
}
#[post("/todos")]
async fn create_todo(data: web::Data<TodoStore>, new_todo: web::Json<Todo>) -> Result<HttpResponse, Error> {
let mut store = data.lock().await;
let id = generate_id(&data).await; // Generate a unique ID
let todo = Todo {
id: Some(id),
title: new_todo.title.clone(),
completed: new_todo.completed,
};
store.insert(id, todo.clone()); // Save to store
Ok(HttpResponse::Created().json(todo)) // Return 201 Created with the new todo
}
web::Json<Todo>: Extracts and deserializes JSON from the request body into aTodo.HttpResponse::Created(): Returns a 201 Created status (standard for resource creation).
3. Get a Todo by ID (GET /todos/{id})
Add a handler to fetch a single todo by ID:
#[get("/todos/{id}")]
async fn get_todo(data: web::Data<TodoStore>, id: web::Path<u64>) -> Result<HttpResponse, Error> {
let store = data.lock().await;
let todo_id = *id;
match store.get(&todo_id) {
Some(todo) => Ok(HttpResponse::Ok().json(todo)), // 200 OK if found
None => Ok(HttpResponse::NotFound().body("Todo not found")), // 404 Not Found if missing
}
}
web::Path<u64>: Extracts theidparameter from the URL path.
4. Update a Todo (PUT /todos/{id})
Add a handler to update an existing todo:
#[put("/todos/{id}")]
async fn update_todo(
data: web::Data<TodoStore>,
id: web::Path<u64>,
updated_todo: web::Json<Todo>,
) -> Result<HttpResponse, Error> {
let mut store = data.lock().await;
let todo_id = *id;
match store.get_mut(&todo_id) {
Some(todo) => {
todo.title = updated_todo.title.clone();
todo.completed = updated_todo.completed;
Ok(HttpResponse::Ok().json(todo)) // 200 OK with updated todo
}
None => Ok(HttpResponse::NotFound().body("Todo not found")), // 404 Not Found
}
}
5. Delete a Todo (DELETE /todos/{id})
Add a handler to delete a todo:
#[delete("/todos/{id}")]
async fn delete_todo(data: web::Data<TodoStore>, id: web::Path<u64>) -> Result<HttpResponse, Error> {
let mut store = data.lock().await;
let todo_id = *id;
if store.remove(&todo_id).is_some() {
Ok(HttpResponse::NoContent()) // 204 No Content on success
} else {
Ok(HttpResponse::NotFound().body("Todo not found")) // 404 Not Found
}
}
HttpResponse::NoContent(): 204 status (standard for successful deletion with no body).
Register Endpoints in main
Update the App in main to include all CRUD endpoints:
App::new()
.app_data(web::Data::new(todo_store.clone()))
.service(hello)
.service(get_todos)
.service(get_todo)
.service(create_todo)
.service(update_todo)
.service(delete_todo)
Error Handling
Our current handlers return basic errors, but we can improve this with structured error responses. Let’s define a custom error type and use Actix-web’s ResponseError trait.
Add this to src/main.rs:
use actix_web::ResponseError;
use std::fmt;
// Custom error type
#[derive(Debug)]
enum TodoError {
NotFound,
BadRequest(String),
}
// Implement Display for TodoError (required for ResponseError)
impl fmt::Display for TodoError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
TodoError::NotFound => write!(f, "Todo not found"),
TodoError::BadRequest(msg) => write!(f, "Bad request: {}", msg),
}
}
}
// Implement ResponseError to convert TodoError to HTTP responses
impl ResponseError for TodoError {
fn error_response(&self) -> HttpResponse {
match self {
TodoError::NotFound => HttpResponse::NotFound().json({
serde_json::json!({ "error": "Not Found", "message": "Todo not found" })
}),
TodoError::BadRequest(msg) => HttpResponse::BadRequest().json({
serde_json::json!({ "error": "Bad Request", "message": msg })
}),
}
}
}
Now update get_todo to use TodoError:
#[get("/todos/{id}")]
async fn get_todo(data: web::Data<TodoStore>, id: web::Path<u64>) -> Result<HttpResponse, TodoError> {
let store = data.lock().await;
let todo_id = *id;
store.get(&todo_id)
.cloned()
.map(|todo| HttpResponse::Ok().json(todo))
.ok_or(TodoError::NotFound)
}
Now, a missing todo returns a JSON error:
{ "error": "Not Found", "message": "Todo not found" }
Adding Middleware
Middleware adds cross-cutting functionality to your API (e.g., logging, CORS). Let’s add logging and CORS support.
Logging
Add env_logger to Cargo.toml:
[dependencies]
# ... existing dependencies
env_logger = "0.10" # Logging middleware
Update main to initialize logging and add the Actix-web logger middleware:
use actix_web::middleware::Logger;
#[actix_web::main]
async fn main() -> std::io::Result<()> {
env_logger::init_from_env(env_logger::Env::new().default_filter_or("info")); // Initialize logger
let todo_store: TodoStore = Arc::new(Mutex::new(HashMap::new()));
HttpServer::new(move || {
App::new()
.wrap(Logger::default()) // Add logging middleware
.app_data(web::Data::new(todo_store.clone()))
.service(hello)
.service(get_todos)
.service(get_todo)
.service(create_todo)
.service(update_todo)
.service(delete_todo)
})
.bind(("127.0.0.1", 8080))?
.run()
.await
}
Now, when you run the server, you’ll see logs for incoming requests:
INFO:actix_web::middleware::logger: 127.0.0.1:53452 "GET /todos HTTP/1.1" 200 21 "-" "curl/7.68.0" 0.000230
CORS
If your API is accessed from a frontend (e.g., React), you’ll need CORS (Cross-Origin Resource Sharing). Add actix-cors to Cargo.toml:
[dependencies]
# ... existing dependencies
actix-cors = "0.6" # CORS middleware
Update main to enable CORS:
use actix_cors::Cors;
#[actix_web::main]
async fn main() -> std::io::Result<()> {
env_logger::init_from_env(env_logger::Env::new().default_filter_or("info"));
let todo_store: TodoStore = Arc::new(Mutex::new(HashMap::new()));
HttpServer::new(move || {
App::new()
.wrap(Logger::default())
.wrap(Cors::permissive()) // Allow all origins (for development only!)
.app_data(web::Data::new(todo_store.clone()))
.service(hello)
.service(get_todos)
.service(get_todo)
.service(create_todo)
.service(update_todo)
.service(delete_todo)
})
.bind(("127.0.0.1", 8080))?
.run()
.await
}
Note: Cors::permissive() is for development. For production, restrict origins with .allowed_origin("https://your-frontend.com").
Testing the API
Let’s test all endpoints using curl commands.
Create a Todo
curl -X POST http://localhost:8080/todos \
-H "Content-Type: application/json" \
-d '{"title": "Learn Rust API", "completed": false}'
Response (201 Created):
{"id":1,"title":"Learn Rust API","completed":false}
List All Todos
curl http://localhost:8080/todos
Response (200 OK):
[{"id":1,"title":"Learn Rust API","completed":false}]
Get a Todo by ID
curl http://localhost:8080/todos/1
Response (200 OK):
{"id":1,"title":"Learn Rust API","completed":false}
Update a Todo
curl -X PUT http://localhost:8080/todos/1 \
-H "Content-Type: application/json" \
-d '{"title": "Learn Rust API (Updated)", "completed": true}'
Response (200 OK):
{"id":1,"title":"Learn Rust API (Updated)","completed":true}
Delete a Todo
curl -X DELETE http://localhost:8080/todos/1
Response (204 No Content)
Deployment Considerations
To deploy your API:
-
Build a Release Binary:
cargo build --releaseThis generates a optimized binary at
target/release/rust-todo-api. -
Run the Binary:
./target/release/rust-todo-api -
Docker (Optional):
Create aDockerfile:FROM rust:1.75 as builder WORKDIR /app COPY . . RUN cargo build --release FROM debian:bullseye-slim COPY --from=builder /app/target/release/rust-todo-api /usr/local/bin/ CMD ["rust-todo-api"]Build and run with:
docker build -t rust-todo-api . docker run -p 8080:8080 rust-todo-api
Next Steps
You now have a functional RESTful API! To take it further:
- Add a Database: Use
SQLx(async, type-safe) orDiesel(ORM) to replace in-memory storage with PostgreSQL/MySQL. - Authentication: Add JWT or OAuth2 with
jsonwebtokenoractix-web-auth. - Rate Limiting: Prevent abuse with
actix-web-ratelimit. - Documentation: Generate OpenAPI docs with
utoipa.
References
- Actix-web Documentation
- Rust Official Documentation
- serde (JSON Serialization)
- SQLx (Database Access)
- Actix-web Examples
Happy coding! 🦀