Home/API Design Patterns/REST API Design

REST API Design

RESTful services with Axum/Actix

intermediate
restaxumactixhttp
🎮 Interactive Playground

What is REST API Design?

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.

The Problem

Building REST APIs in Rust involves:

  • Framework choice: Axum, Actix-web, Rocket
  • Type-safe routing: Extracting parameters and body
  • Error handling: Consistent error responses
  • Serialization: JSON, form data, multipart

Example Code

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");
}

Why This Works

  1. Type-safe extractors: Path, Query, Json ensure correct parsing
  2. State management: State shares data across handlers
  3. Error types: Custom errors convert to proper HTTP responses
  4. Separation of concerns: Request/Response types separate from domain

REST Design Principles

| 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 |

Response Conventions

| 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 |

⚠️ Anti-patterns

// 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()))

Exercises

  1. Add authentication middleware with JWT
  2. Implement HATEOAS links in responses
  3. Add request validation with validator crate
  4. Create OpenAPI documentation with utoipa

🎮 Try it Yourself

🎮

REST API Design - Playground

Run this code in the official Rust Playground