How to Build a REST API in Rust with Actix-Web
Actix-Web is a powerful, pragmatic web framework built on Rust's async ecosystem. It consistently ranks among the fastest web frameworks in benchmarks, but more importantly, it provides excellent...
Key Insights
- Actix-Web delivers exceptional performance through Rust’s async runtime and actor model, making it ideal for high-throughput APIs that need to handle thousands of concurrent connections
- The framework’s type-safe extractors and middleware system eliminate entire classes of runtime errors while providing a clean, composable architecture for building production APIs
- Shared application state requires careful handling with
Arc<Mutex<T>>or similar concurrency primitives to maintain Rust’s safety guarantees across async request handlers
Introduction & Setup
Actix-Web is a powerful, pragmatic web framework built on Rust’s async ecosystem. It consistently ranks among the fastest web frameworks in benchmarks, but more importantly, it provides excellent ergonomics for building production APIs. Unlike some frameworks that sacrifice usability for performance, Actix-Web gives you both.
Let’s start by creating a new project and setting up dependencies:
cargo new rust-api-demo
cd rust-api-demo
Your Cargo.toml should include these dependencies:
[dependencies]
actix-web = "4.4"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tokio = { version = "1", features = ["full"] }
The serde crate handles JSON serialization, while tokio provides the async runtime that Actix-Web runs on. You’re now ready to build your first API.
Creating Your First HTTP Server
The core of any Actix-Web application is the HttpServer and App structs. The server manages TCP connections and worker threads, while App defines your routes and middleware.
Here’s a minimal server that returns JSON:
use actix_web::{get, web, App, HttpResponse, HttpServer, Responder};
use serde::Serialize;
#[derive(Serialize)]
struct HealthResponse {
status: String,
version: String,
}
#[get("/health")]
async fn health_check() -> impl Responder {
let response = HealthResponse {
status: "healthy".to_string(),
version: "1.0.0".to_string(),
};
HttpResponse::Ok().json(response)
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
HttpServer::new(|| {
App::new()
.service(health_check)
})
.bind(("127.0.0.1", 8080))?
.run()
.await
}
The #[get("/health")] macro registers a route handler. The #[actix_web::main] macro sets up the Tokio runtime. When you run this with cargo run, you’ll have a server listening on port 8080 that responds to GET /health requests with JSON.
Defining Routes and Handlers
Real APIs need full CRUD operations with path parameters, query strings, and request bodies. Let’s build a user management API:
use actix_web::{delete, get, post, put, web, HttpResponse, Responder};
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize, Clone)]
struct User {
id: u32,
name: String,
email: String,
}
#[derive(Deserialize)]
struct CreateUserRequest {
name: String,
email: String,
}
#[get("/users")]
async fn get_users() -> impl Responder {
// We'll add real data storage in the next section
let users = vec![
User { id: 1, name: "Alice".to_string(), email: "alice@example.com".to_string() }
];
HttpResponse::Ok().json(users)
}
#[get("/users/{id}")]
async fn get_user(path: web::Path<u32>) -> impl Responder {
let user_id = path.into_inner();
let user = User {
id: user_id,
name: "Alice".to_string(),
email: "alice@example.com".to_string(),
};
HttpResponse::Ok().json(user)
}
#[post("/users")]
async fn create_user(user_data: web::Json<CreateUserRequest>) -> impl Responder {
let new_user = User {
id: 1, // We'll generate proper IDs with state
name: user_data.name.clone(),
email: user_data.email.clone(),
};
HttpResponse::Created().json(new_user)
}
#[put("/users/{id}")]
async fn update_user(
path: web::Path<u32>,
user_data: web::Json<CreateUserRequest>,
) -> impl Responder {
let user_id = path.into_inner();
let updated_user = User {
id: user_id,
name: user_data.name.clone(),
email: user_data.email.clone(),
};
HttpResponse::Ok().json(updated_user)
}
#[delete("/users/{id}")]
async fn delete_user(path: web::Path<u32>) -> impl Responder {
let user_id = path.into_inner();
HttpResponse::NoContent().finish()
}
Path parameters are extracted using web::Path<T>, while request bodies use web::Json<T>. Both are type-safe extractors that automatically deserialize and validate data.
Request/Response Handling with Serde
Serde’s derive macros do the heavy lifting for JSON serialization. The #[derive(Serialize, Deserialize)] attributes automatically generate the necessary code:
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
struct User {
id: u32,
name: String,
email: String,
#[serde(skip_serializing_if = "Option::is_none")]
phone: Option<String>,
}
#[derive(Deserialize)]
struct UserQuery {
#[serde(default)]
page: u32,
#[serde(default = "default_page_size")]
page_size: u32,
}
fn default_page_size() -> u32 {
20
}
#[get("/users")]
async fn get_users_paginated(query: web::Query<UserQuery>) -> impl Responder {
let page = query.page;
let page_size = query.page_size;
// Use pagination parameters
HttpResponse::Ok().json(vec![] as Vec<User>)
}
The web::Json<T> extractor automatically deserializes request bodies and returns a 400 Bad Request if the JSON is invalid. Query parameters work similarly with web::Query<T>.
Application State and Dependency Injection
APIs need shared state for database connections, caching, and configuration. Actix-Web uses web::Data<T> for dependency injection:
use actix_web::{web, App, HttpServer};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
type UserDb = Arc<Mutex<HashMap<u32, User>>>;
#[get("/users/{id}")]
async fn get_user(
path: web::Path<u32>,
db: web::Data<UserDb>,
) -> impl Responder {
let user_id = path.into_inner();
let users = db.lock().unwrap();
match users.get(&user_id) {
Some(user) => HttpResponse::Ok().json(user),
None => HttpResponse::NotFound().json(serde_json::json!({
"error": "User not found"
})),
}
}
#[post("/users")]
async fn create_user(
user_data: web::Json<CreateUserRequest>,
db: web::Data<UserDb>,
) -> impl Responder {
let mut users = db.lock().unwrap();
let new_id = users.len() as u32 + 1;
let new_user = User {
id: new_id,
name: user_data.name.clone(),
email: user_data.email.clone(),
phone: None,
};
users.insert(new_id, new_user.clone());
HttpResponse::Created().json(new_user)
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
let user_db: UserDb = Arc::new(Mutex::new(HashMap::new()));
HttpServer::new(move || {
App::new()
.app_data(web::Data::new(user_db.clone()))
.service(get_user)
.service(create_user)
})
.bind(("127.0.0.1", 8080))?
.run()
.await
}
The Arc<Mutex<T>> pattern provides thread-safe shared access. For production, replace this with a connection pool to PostgreSQL or another database.
Middleware and Error Handling
Custom error types make your API more maintainable:
use actix_web::{error::ResponseError, http::StatusCode, HttpResponse};
use std::fmt;
#[derive(Debug)]
enum ApiError {
NotFound(String),
BadRequest(String),
InternalError,
}
impl fmt::Display for ApiError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
ApiError::NotFound(msg) => write!(f, "Not found: {}", msg),
ApiError::BadRequest(msg) => write!(f, "Bad request: {}", msg),
ApiError::InternalError => write!(f, "Internal server error"),
}
}
}
impl ResponseError for ApiError {
fn status_code(&self) -> StatusCode {
match self {
ApiError::NotFound(_) => StatusCode::NOT_FOUND,
ApiError::BadRequest(_) => StatusCode::BAD_REQUEST,
ApiError::InternalError => StatusCode::INTERNAL_SERVER_ERROR,
}
}
fn error_response(&self) -> HttpResponse {
HttpResponse::build(self.status_code()).json(serde_json::json!({
"error": self.to_string()
}))
}
}
#[get("/users/{id}")]
async fn get_user_with_errors(
path: web::Path<u32>,
db: web::Data<UserDb>,
) -> Result<HttpResponse, ApiError> {
let user_id = path.into_inner();
let users = db.lock().map_err(|_| ApiError::InternalError)?;
users.get(&user_id)
.map(|user| HttpResponse::Ok().json(user))
.ok_or_else(|| ApiError::NotFound(format!("User {}", user_id)))
}
Testing Your API
Integration tests verify your endpoints work correctly:
#[cfg(test)]
mod tests {
use super::*;
use actix_web::{test, App};
#[actix_web::test]
async fn test_create_and_get_user() {
let user_db: UserDb = Arc::new(Mutex::new(HashMap::new()));
let app = test::init_service(
App::new()
.app_data(web::Data::new(user_db.clone()))
.service(create_user)
.service(get_user)
).await;
let create_req = test::TestRequest::post()
.uri("/users")
.set_json(&CreateUserRequest {
name: "Bob".to_string(),
email: "bob@example.com".to_string(),
})
.to_request();
let resp = test::call_service(&app, create_req).await;
assert!(resp.status().is_success());
let get_req = test::TestRequest::get()
.uri("/users/1")
.to_request();
let resp = test::call_service(&app, get_req).await;
assert!(resp.status().is_success());
}
}
These tests run fast and don’t require a real server, making them perfect for CI/CD pipelines.
Actix-Web gives you the tools to build production-ready REST APIs with minimal boilerplate. The type system catches errors at compile time, async handlers provide excellent performance, and the middleware system keeps cross-cutting concerns separate. Start with this foundation and add database integration, authentication, and monitoring as your API grows.