RESTful services with Axum/Actix
REST (Representational State Transfer) APIs use HTTP methods and status codes to create predictable, resource-based interfaces. Rust's type system and async capabilities make it excellent for building robust REST APIs.
Building REST APIs in Rust involves:
use axum::{
extract::{Path, Query, State, Json},
http::StatusCode,
response::IntoResponse,
routing::{get, post, put, delete},
Router,
};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
use uuid::Uuid;
// Domain models
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct User {
pub id: Uuid,
pub email: String,
pub name: String,
#[serde(skip_serializing)]
pub password_hash: String,
pub created_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, Deserialize)]
pub struct CreateUserRequest {
pub email: String,
pub name: String,
pub password: String,
}
#[derive(Debug, Deserialize)]
pub struct UpdateUserRequest {
pub email: Option<String>,
pub name: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct ListUsersQuery {
pub page: Option<u32>,
pub per_page: Option<u32>,
pub search: Option<String>,
}
// Response types
#[derive(Debug, Serialize)]
pub struct UserResponse {
pub id: Uuid,
pub email: String,
pub name: String,
pub created_at: String,
}
impl From<User> for UserResponse {
fn from(user: User) -> Self {
UserResponse {
id: user.id,
email: user.email,
name: user.name,
created_at: user.created_at.to_rfc3339(),
}
}
}
#[derive(Debug, Serialize)]
pub struct ListResponse<T> {
pub data: Vec<T>,
pub total: usize,
pub page: u32,
pub per_page: u32,
}
// Error handling
#[derive(Debug)]
pub enum ApiError {
NotFound(String),
BadRequest(String),
Conflict(String),
InternalError(String),
}
impl IntoResponse for ApiError {
fn into_response(self) -> axum::response::Response {
let (status, message) = match self {
ApiError::NotFound(msg) => (StatusCode::NOT_FOUND, msg),
ApiError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg),
ApiError::Conflict(msg) => (StatusCode::CONFLICT, msg),
ApiError::InternalError(msg) => (StatusCode::INTERNAL_SERVER_ERROR, msg),
};
let body = serde_json::json!({
"error": {
"code": status.as_u16(),
"message": message
}
});
(status, Json(body)).into_response()
}
}
// Application state
#[derive(Clone)]
pub struct AppState {
users: Arc<RwLock<HashMap<Uuid, User>>>,
}
impl AppState {
pub fn new() -> Self {
AppState {
users: Arc::new(RwLock::new(HashMap::new())),
}
}
}
impl Default for AppState {
fn default() -> Self {
Self::new()
}
}
// Handlers
async fn create_user(
State(state): State<AppState>,
Json(req): Json<CreateUserRequest>,
) -> Result<impl IntoResponse, ApiError> {
// Validate email format
if !req.email.contains('@') {
return Err(ApiError::BadRequest("Invalid email format".to_string()));
}
// Check for duplicate email
let users = state.users.read().unwrap();
if users.values().any(|u| u.email == req.email) {
return Err(ApiError::Conflict("Email already registered".to_string()));
}
drop(users);
let user = User {
id: Uuid::new_v4(),
email: req.email,
name: req.name,
password_hash: hash_password(&req.password),
created_at: chrono::Utc::now(),
};
let response = UserResponse::from(user.clone());
state.users.write().unwrap().insert(user.id, user);
Ok((StatusCode::CREATED, Json(response)))
}
async fn get_user(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> Result<Json<UserResponse>, ApiError> {
let users = state.users.read().unwrap();
users
.get(&id)
.map(|user| Json(UserResponse::from(user.clone())))
.ok_or_else(|| ApiError::NotFound(format!("User {} not found", id)))
}
async fn list_users(
State(state): State<AppState>,
Query(params): Query<ListUsersQuery>,
) -> Json<ListResponse<UserResponse>> {
let page = params.page.unwrap_or(1);
let per_page = params.per_page.unwrap_or(10).min(100);
let users = state.users.read().unwrap();
let mut filtered: Vec<_> = users
.values()
.filter(|u| {
if let Some(ref search) = params.search {
u.name.to_lowercase().contains(&search.to_lowercase())
|| u.email.to_lowercase().contains(&search.to_lowercase())
} else {
true
}
})
.collect();
filtered.sort_by(|a, b| b.created_at.cmp(&a.created_at));
let total = filtered.len();
let start = ((page - 1) * per_page) as usize;
let data: Vec<UserResponse> = filtered
.into_iter()
.skip(start)
.take(per_page as usize)
.map(|u| UserResponse::from(u.clone()))
.collect();
Json(ListResponse {
data,
total,
page,
per_page,
})
}
async fn update_user(
State(state): State<AppState>,
Path(id): Path<Uuid>,
Json(req): Json<UpdateUserRequest>,
) -> Result<Json<UserResponse>, ApiError> {
let mut users = state.users.write().unwrap();
let user = users
.get_mut(&id)
.ok_or_else(|| ApiError::NotFound(format!("User {} not found", id)))?;
if let Some(email) = req.email {
if !email.contains('@') {
return Err(ApiError::BadRequest("Invalid email format".to_string()));
}
user.email = email;
}
if let Some(name) = req.name {
user.name = name;
}
Ok(Json(UserResponse::from(user.clone())))
}
async fn delete_user(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> Result<StatusCode, ApiError> {
let mut users = state.users.write().unwrap();
users
.remove(&id)
.map(|_| StatusCode::NO_CONTENT)
.ok_or_else(|| ApiError::NotFound(format!("User {} not found", id)))
}
// Health check
async fn health_check() -> impl IntoResponse {
Json(serde_json::json!({
"status": "healthy",
"timestamp": chrono::Utc::now().to_rfc3339()
}))
}
// Router setup
pub fn create_router(state: AppState) -> Router {
Router::new()
// Health check
.route("/health", get(health_check))
// User CRUD
.route("/api/v1/users", get(list_users).post(create_user))
.route(
"/api/v1/users/:id",
get(get_user).put(update_user).delete(delete_user),
)
.with_state(state)
}
fn hash_password(password: &str) -> String {
// Use bcrypt/argon2 in production!
format!("hashed_{}", password)
}
// Simulate chrono for the example
mod chrono {
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
pub struct DateTime<Tz>(pub i64, std::marker::PhantomData<Tz>);
pub struct Utc;
impl DateTime<Utc> {
pub fn to_rfc3339(&self) -> String {
format!("2024-01-01T00:00:00Z")
}
}
impl Utc {
pub fn now() -> DateTime<Utc> {
DateTime(0, std::marker::PhantomData)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use tower::ServiceExt;
#[tokio::test]
async fn test_create_user() {
let app = create_router(AppState::new());
let request = Request::builder()
.method("POST")
.uri("/api/v1/users")
.header("Content-Type", "application/json")
.body(Body::from(r#"{"email":"test@example.com","name":"Test","password":"secret"}"#))
.unwrap();
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::CREATED);
}
#[tokio::test]
async fn test_get_nonexistent_user() {
let app = create_router(AppState::new());
let request = Request::builder()
.method("GET")
.uri("/api/v1/users/00000000-0000-0000-0000-000000000000")
.body(Body::empty())
.unwrap();
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
}
fn main() {
println!("REST API example - run with tokio runtime");
println!("See the Router setup for endpoint definitions");
}
Path, Query, Json ensure correct parsingState shares data across handlers| Principle | Implementation |
|-----------|----------------|
| Resources | /users, /users/:id |
| HTTP Verbs | GET, POST, PUT, DELETE |
| Status Codes | 200, 201, 204, 400, 404, 409 |
| Pagination | ?page=1&per_page=20 |
| Filtering | ?search=term |
| Versioning | /api/v1/ prefix |
| Operation | Success Code | Response Body |
|-----------|--------------|---------------|
| GET one | 200 | Resource |
| GET list | 200 | List with pagination |
| POST | 201 | Created resource |
| PUT | 200 | Updated resource |
| DELETE | 204 | Empty |
// DON'T: Verb in URL
// POST /api/createUser
// GET /api/getUsers
// DO: Resource-based URLs
// POST /api/users
// GET /api/users
// DON'T: Ignore HTTP semantics
async fn update_user(/* PUT */) -> StatusCode {
StatusCode::OK // Should return the updated resource
}
// DON'T: Expose internal errors
Err(ApiError::InternalError(format!("{:?}", db_error))) // Leaks info!
// DO: Generic message, log details
Err(ApiError::InternalError("Database error".to_string()))
validator crateutoipaRun this code in the official Rust Playground