Skip to content

Actix-web Framework Guide

Applies to: Actix-web 4+, Rust Web APIs, High-Performance Services Complements: .claude/skills/rust-guide/SKILL.md


Overview

Actix-web is a powerful, pragmatic, and extremely fast web framework for Rust. It's built on top of the Actix actor framework and provides excellent performance with a rich feature set.

Key Features

  • Extremely high performance (one of the fastest web frameworks)
  • Type-safe request handling
  • Actor-based architecture
  • WebSocket support
  • HTTP/2 support
  • Middleware system
  • Built-in testing utilities
  • Flexible extractors

Project Structure

myproject/
├── Cargo.toml
├── src/
│   ├── main.rs
│   ├── config.rs
│   ├── routes.rs
│   ├── handlers/
│   │   ├── mod.rs
│   │   ├── users.rs
│   │   └── health.rs
│   ├── models/
│   │   ├── mod.rs
│   │   └── user.rs
│   ├── services/
│   │   ├── mod.rs
│   │   └── user_service.rs
│   ├── repositories/
│   │   ├── mod.rs
│   │   └── user_repository.rs
│   ├── middleware/
│   │   ├── mod.rs
│   │   └── auth.rs
│   └── errors/
│       ├── mod.rs
│       └── app_error.rs
├── tests/
│   └── integration_tests.rs
└── migrations/

Dependencies (Cargo.toml)

[package]
name = "myproject"
version = "0.1.0"
edition = "2021"

[dependencies]
# Web framework
actix-web = "4"
actix-rt = "2"
actix-service = "2"
actix-http = "3"
actix-cors = "0.7"

# Serialization
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"

# Database
sqlx = { version = "0.7", features = ["runtime-tokio", "postgres", "uuid", "chrono"] }

# Validation
validator = { version = "0.16", features = ["derive"] }

# Authentication
jsonwebtoken = "9.0"
bcrypt = "0.15"

# Error handling
thiserror = "1.0"
anyhow = "1.0"

# Logging
log = "0.4"
env_logger = "0.10"
tracing = "0.1"
tracing-actix-web = "0.7"

# Configuration
config = "0.14"
dotenvy = "0.15"

# Utilities
uuid = { version = "1.0", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
futures = "0.3"

[dev-dependencies]
actix-rt = "2"

Application Entry Point

// src/main.rs
use actix_cors::Cors;
use actix_web::{middleware, web, App, HttpServer};
use sqlx::postgres::PgPoolOptions;
use std::sync::Arc;

mod config;
mod errors;
mod handlers;
mod middleware as app_middleware;
mod models;
mod repositories;
mod routes;
mod services;

pub struct AppState {
    pub pool: sqlx::PgPool,
    pub config: config::Config,
}

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    // Load environment variables
    dotenvy::dotenv().ok();

    // Initialize logging
    env_logger::init_from_env(env_logger::Env::new().default_filter_or("info"));

    // Load configuration
    let config = config::Config::load().expect("Failed to load configuration");

    // Create database pool
    let pool = PgPoolOptions::new()
        .max_connections(10)
        .connect(&config.database_url)
        .await
        .expect("Failed to create database pool");

    // Run migrations
    sqlx::migrate!("./migrations")
        .run(&pool)
        .await
        .expect("Failed to run migrations");

    let app_state = web::Data::new(AppState {
        pool,
        config: config.clone(),
    });

    let server_address = format!("{}:{}", config.host, config.port);
    log::info!("Starting server at http://{}", server_address);

    HttpServer::new(move || {
        let cors = Cors::default()
            .allow_any_origin()
            .allow_any_method()
            .allow_any_header()
            .max_age(3600);

        App::new()
            .app_data(app_state.clone())
            .wrap(cors)
            .wrap(middleware::Logger::default())
            .wrap(middleware::Compress::default())
            .configure(routes::configure)
    })
    .bind(&server_address)?
    .run()
    .await
}

Configuration

// src/config.rs
use serde::Deserialize;

#[derive(Clone, Deserialize)]
pub struct Config {
    pub host: String,
    pub port: u16,
    pub database_url: String,
    pub jwt_secret: String,
    pub jwt_expiration_hours: i64,
}

impl Config {
    pub fn load() -> anyhow::Result<Self> {
        let config = config::Config::builder()
            .add_source(
                config::Environment::default()
                    .separator("__")
                    .try_parsing(true),
            )
            .set_default("host", "127.0.0.1")?
            .set_default("port", 8080)?
            .build()?;

        Ok(config.try_deserialize()?)
    }
}

Routes Configuration

// src/routes.rs
use actix_web::web;

use crate::handlers;

pub fn configure(cfg: &mut web::ServiceConfig) {
    cfg.service(
        web::scope("/api/v1")
            // Health check
            .route("/health", web::get().to(handlers::health::health_check))
            // Auth routes (public)
            .service(
                web::scope("/auth")
                    .route("/register", web::post().to(handlers::users::register))
                    .route("/login", web::post().to(handlers::users::login)),
            )
            // User routes (protected)
            .service(
                web::scope("/users")
                    .wrap(crate::middleware::auth::AuthMiddleware)
                    .route("", web::get().to(handlers::users::list_users))
                    .route("/me", web::get().to(handlers::users::get_current_user))
                    .route("/{id}", web::get().to(handlers::users::get_user))
                    .route("/{id}", web::put().to(handlers::users::update_user))
                    .route("/{id}", web::delete().to(handlers::users::delete_user)),
            ),
    );
}

Error Handling

// src/errors/app_error.rs
use actix_web::{http::StatusCode, HttpResponse, ResponseError};
use serde::Serialize;
use std::fmt;

#[derive(Debug)]
pub enum AppError {
    NotFound(String),
    BadRequest(String),
    Unauthorized(String),
    Forbidden(String),
    Conflict(String),
    Validation(String),
    Internal(String),
    Database(sqlx::Error),
}

#[derive(Serialize)]
struct ErrorResponse {
    success: bool,
    error: ErrorDetail,
}

#[derive(Serialize)]
struct ErrorDetail {
    code: u16,
    message: String,
}

impl fmt::Display for AppError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            AppError::NotFound(msg) => write!(f, "Not found: {}", msg),
            AppError::BadRequest(msg) => write!(f, "Bad request: {}", msg),
            AppError::Unauthorized(msg) => write!(f, "Unauthorized: {}", msg),
            AppError::Forbidden(msg) => write!(f, "Forbidden: {}", msg),
            AppError::Conflict(msg) => write!(f, "Conflict: {}", msg),
            AppError::Validation(msg) => write!(f, "Validation error: {}", msg),
            AppError::Internal(msg) => write!(f, "Internal error: {}", msg),
            AppError::Database(err) => write!(f, "Database error: {}", err),
        }
    }
}

impl ResponseError for AppError {
    fn error_response(&self) -> HttpResponse {
        let (status, message) = match self {
            AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()),
            AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg.clone()),
            AppError::Unauthorized(msg) => (StatusCode::UNAUTHORIZED, msg.clone()),
            AppError::Forbidden(msg) => (StatusCode::FORBIDDEN, msg.clone()),
            AppError::Conflict(msg) => (StatusCode::CONFLICT, msg.clone()),
            AppError::Validation(msg) => (StatusCode::UNPROCESSABLE_ENTITY, msg.clone()),
            AppError::Internal(msg) => {
                log::error!("Internal error: {}", msg);
                (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error".to_string())
            }
            AppError::Database(err) => {
                log::error!("Database error: {:?}", err);
                (StatusCode::INTERNAL_SERVER_ERROR, "Database error".to_string())
            }
        };

        HttpResponse::build(status).json(ErrorResponse {
            success: false,
            error: ErrorDetail {
                code: status.as_u16(),
                message,
            },
        })
    }
}

impl From<sqlx::Error> for AppError {
    fn from(err: sqlx::Error) -> Self {
        AppError::Database(err)
    }
}

impl From<anyhow::Error> for AppError {
    fn from(err: anyhow::Error) -> Self {
        AppError::Internal(err.to_string())
    }
}

impl From<validator::ValidationErrors> for AppError {
    fn from(err: validator::ValidationErrors) -> Self {
        AppError::Validation(err.to_string())
    }
}

pub type AppResult<T> = Result<T, AppError>;

Models

// src/models/user.rs
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
use uuid::Uuid;
use validator::Validate;

#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct User {
    pub id: Uuid,
    pub email: String,
    #[serde(skip_serializing)]
    pub password_hash: String,
    pub name: String,
    pub role: String,
    pub is_active: bool,
    pub created_at: DateTime<Utc>,
    pub updated_at: DateTime<Utc>,
}

#[derive(Debug, Deserialize, Validate)]
pub struct CreateUserDto {
    #[validate(email(message = "Invalid email format"))]
    pub email: String,

    #[validate(length(min = 8, message = "Password must be at least 8 characters"))]
    pub password: String,

    #[validate(length(min = 1, max = 100, message = "Name must be 1-100 characters"))]
    pub name: String,
}

#[derive(Debug, Deserialize, Validate)]
pub struct UpdateUserDto {
    #[validate(length(min = 1, max = 100, message = "Name must be 1-100 characters"))]
    pub name: Option<String>,

    pub is_active: Option<bool>,
}

#[derive(Debug, Deserialize, Validate)]
pub struct LoginDto {
    #[validate(email(message = "Invalid email format"))]
    pub email: String,

    #[validate(length(min = 1, message = "Password is required"))]
    pub password: String,
}

#[derive(Debug, Serialize)]
pub struct UserResponse {
    pub id: Uuid,
    pub email: String,
    pub name: String,
    pub role: String,
    pub is_active: bool,
    pub created_at: DateTime<Utc>,
}

impl From<User> for UserResponse {
    fn from(user: User) -> Self {
        Self {
            id: user.id,
            email: user.email,
            name: user.name,
            role: user.role,
            is_active: user.is_active,
            created_at: user.created_at,
        }
    }
}

#[derive(Debug, Serialize)]
pub struct AuthResponse {
    pub user: UserResponse,
    pub token: String,
}

Middleware

// src/middleware/auth.rs
use actix_web::{
    dev::{forward_ready, Service, ServiceRequest, ServiceResponse, Transform},
    http::header::AUTHORIZATION,
    web, Error, HttpMessage,
};
use futures::future::{ok, LocalBoxFuture, Ready};
use std::rc::Rc;

use crate::{errors::AppError, AppState};

// JWT Claims structure
#[derive(Debug, Clone)]
pub struct AuthenticatedUser {
    pub user_id: uuid::Uuid,
    pub email: String,
    pub role: String,
}

pub struct AuthMiddleware;

impl<S, B> Transform<S, ServiceRequest> for AuthMiddleware
where
    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
    S::Future: 'static,
    B: 'static,
{
    type Response = ServiceResponse<B>;
    type Error = Error;
    type Transform = AuthMiddlewareService<S>;
    type InitError = ();
    type Future = Ready<Result<Self::Transform, Self::InitError>>;

    fn new_transform(&self, service: S) -> Self::Future {
        ok(AuthMiddlewareService {
            service: Rc::new(service),
        })
    }
}

pub struct AuthMiddlewareService<S> {
    service: Rc<S>,
}

impl<S, B> Service<ServiceRequest> for AuthMiddlewareService<S>
where
    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
    S::Future: 'static,
    B: 'static,
{
    type Response = ServiceResponse<B>;
    type Error = Error;
    type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;

    forward_ready!(service);

    fn call(&self, req: ServiceRequest) -> Self::Future {
        let service = Rc::clone(&self.service);

        Box::pin(async move {
            // Extract authorization header
            let auth_header = req
                .headers()
                .get(AUTHORIZATION)
                .and_then(|h| h.to_str().ok())
                .ok_or_else(|| AppError::Unauthorized("Missing authorization header".to_string()))?;

            // Extract token
            let token = auth_header
                .strip_prefix("Bearer ")
                .ok_or_else(|| AppError::Unauthorized("Invalid authorization format".to_string()))?;

            // Get app state
            let state = req
                .app_data::<web::Data<AppState>>()
                .ok_or_else(|| AppError::Internal("App state not found".to_string()))?;

            // Verify token
            let claims = verify_token(token, &state.config.jwt_secret)?;

            // Insert authenticated user into request extensions
            req.extensions_mut().insert(AuthenticatedUser {
                user_id: claims.sub,
                email: claims.email,
                role: claims.role,
            });

            service.call(req).await
        })
    }
}

// JWT utilities
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize};

#[derive(Debug, Serialize, Deserialize)]
pub struct Claims {
    pub sub: uuid::Uuid,
    pub email: String,
    pub role: String,
    pub exp: i64,
    pub iat: i64,
}

impl Claims {
    pub fn new(user_id: uuid::Uuid, email: String, role: String, expiration_hours: i64) -> Self {
        let now = chrono::Utc::now();
        Self {
            sub: user_id,
            email,
            role,
            exp: (now + chrono::Duration::hours(expiration_hours)).timestamp(),
            iat: now.timestamp(),
        }
    }
}

pub fn create_token(claims: &Claims, secret: &str) -> Result<String, AppError> {
    encode(
        &Header::default(),
        claims,
        &EncodingKey::from_secret(secret.as_bytes()),
    )
    .map_err(|e| AppError::Internal(format!("Failed to create token: {}", e)))
}

pub fn verify_token(token: &str, secret: &str) -> Result<Claims, AppError> {
    decode::<Claims>(
        token,
        &DecodingKey::from_secret(secret.as_bytes()),
        &Validation::default(),
    )
    .map(|data| data.claims)
    .map_err(|_| AppError::Unauthorized("Invalid token".to_string()))
}

Repository

// src/repositories/user_repository.rs
use sqlx::PgPool;
use uuid::Uuid;

use crate::{errors::AppError, models::user::User};

pub struct UserRepository {
    pool: PgPool,
}

impl UserRepository {
    pub fn new(pool: PgPool) -> Self {
        Self { pool }
    }

    pub async fn find_by_id(&self, id: Uuid) -> Result<Option<User>, AppError> {
        let user = sqlx::query_as::<_, User>(
            "SELECT * FROM users WHERE id = $1"
        )
        .bind(id)
        .fetch_optional(&self.pool)
        .await?;

        Ok(user)
    }

    pub async fn find_by_email(&self, email: &str) -> Result<Option<User>, AppError> {
        let user = sqlx::query_as::<_, User>(
            "SELECT * FROM users WHERE email = $1"
        )
        .bind(email)
        .fetch_optional(&self.pool)
        .await?;

        Ok(user)
    }

    pub async fn find_all(&self, limit: i64, offset: i64) -> Result<Vec<User>, AppError> {
        let users = sqlx::query_as::<_, User>(
            "SELECT * FROM users ORDER BY created_at DESC LIMIT $1 OFFSET $2"
        )
        .bind(limit)
        .bind(offset)
        .fetch_all(&self.pool)
        .await?;

        Ok(users)
    }

    pub async fn create(
        &self,
        email: &str,
        password_hash: &str,
        name: &str,
    ) -> Result<User, AppError> {
        let user = sqlx::query_as::<_, User>(
            r#"
            INSERT INTO users (email, password_hash, name)
            VALUES ($1, $2, $3)
            RETURNING *
            "#,
        )
        .bind(email)
        .bind(password_hash)
        .bind(name)
        .fetch_one(&self.pool)
        .await?;

        Ok(user)
    }

    pub async fn update(
        &self,
        id: Uuid,
        name: Option<&str>,
        is_active: Option<bool>,
    ) -> Result<User, AppError> {
        let user = sqlx::query_as::<_, User>(
            r#"
            UPDATE users
            SET
                name = COALESCE($2, name),
                is_active = COALESCE($3, is_active),
                updated_at = NOW()
            WHERE id = $1
            RETURNING *
            "#,
        )
        .bind(id)
        .bind(name)
        .bind(is_active)
        .fetch_one(&self.pool)
        .await?;

        Ok(user)
    }

    pub async fn delete(&self, id: Uuid) -> Result<(), AppError> {
        sqlx::query("DELETE FROM users WHERE id = $1")
            .bind(id)
            .execute(&self.pool)
            .await?;

        Ok(())
    }
}

Service Layer

// src/services/user_service.rs
use sqlx::PgPool;
use uuid::Uuid;
use validator::Validate;

use crate::{
    config::Config,
    errors::AppError,
    middleware::auth::{create_token, Claims},
    models::user::{AuthResponse, CreateUserDto, LoginDto, UpdateUserDto, UserResponse},
    repositories::user_repository::UserRepository,
};

pub struct UserService {
    repository: UserRepository,
    config: Config,
}

impl UserService {
    pub fn new(pool: PgPool, config: Config) -> Self {
        Self {
            repository: UserRepository::new(pool),
            config,
        }
    }

    pub async fn register(&self, dto: CreateUserDto) -> Result<AuthResponse, AppError> {
        dto.validate()?;

        // Check if email exists
        if self.repository.find_by_email(&dto.email).await?.is_some() {
            return Err(AppError::Conflict("Email already registered".to_string()));
        }

        // Hash password
        let password_hash = bcrypt::hash(&dto.password, bcrypt::DEFAULT_COST)
            .map_err(|e| AppError::Internal(format!("Failed to hash password: {}", e)))?;

        // Create user
        let user = self
            .repository
            .create(&dto.email, &password_hash, &dto.name)
            .await?;

        // Generate token
        let claims = Claims::new(
            user.id,
            user.email.clone(),
            user.role.clone(),
            self.config.jwt_expiration_hours,
        );
        let token = create_token(&claims, &self.config.jwt_secret)?;

        Ok(AuthResponse {
            user: user.into(),
            token,
        })
    }

    pub async fn login(&self, dto: LoginDto) -> Result<AuthResponse, AppError> {
        dto.validate()?;

        let user = self
            .repository
            .find_by_email(&dto.email)
            .await?
            .ok_or_else(|| AppError::Unauthorized("Invalid credentials".to_string()))?;

        if !user.is_active {
            return Err(AppError::Forbidden("Account is deactivated".to_string()));
        }

        let password_valid = bcrypt::verify(&dto.password, &user.password_hash)
            .map_err(|e| AppError::Internal(format!("Failed to verify password: {}", e)))?;

        if !password_valid {
            return Err(AppError::Unauthorized("Invalid credentials".to_string()));
        }

        let claims = Claims::new(
            user.id,
            user.email.clone(),
            user.role.clone(),
            self.config.jwt_expiration_hours,
        );
        let token = create_token(&claims, &self.config.jwt_secret)?;

        Ok(AuthResponse {
            user: user.into(),
            token,
        })
    }

    pub async fn get_user(&self, id: Uuid) -> Result<UserResponse, AppError> {
        let user = self
            .repository
            .find_by_id(id)
            .await?
            .ok_or_else(|| AppError::NotFound(format!("User {} not found", id)))?;

        Ok(user.into())
    }

    pub async fn list_users(&self, page: i64, per_page: i64) -> Result<Vec<UserResponse>, AppError> {
        let offset = (page - 1) * per_page;
        let users = self.repository.find_all(per_page, offset).await?;
        Ok(users.into_iter().map(|u| u.into()).collect())
    }

    pub async fn update_user(&self, id: Uuid, dto: UpdateUserDto) -> Result<UserResponse, AppError> {
        dto.validate()?;

        self.repository
            .find_by_id(id)
            .await?
            .ok_or_else(|| AppError::NotFound(format!("User {} not found", id)))?;

        let user = self
            .repository
            .update(id, dto.name.as_deref(), dto.is_active)
            .await?;

        Ok(user.into())
    }

    pub async fn delete_user(&self, id: Uuid) -> Result<(), AppError> {
        self.repository
            .find_by_id(id)
            .await?
            .ok_or_else(|| AppError::NotFound(format!("User {} not found", id)))?;

        self.repository.delete(id).await?;
        Ok(())
    }
}

Handlers

// src/handlers/users.rs
use actix_web::{web, HttpMessage, HttpRequest, HttpResponse};
use serde::Deserialize;
use uuid::Uuid;

use crate::{
    errors::{AppError, AppResult},
    middleware::auth::AuthenticatedUser,
    models::user::{CreateUserDto, LoginDto, UpdateUserDto},
    services::user_service::UserService,
    AppState,
};

#[derive(Deserialize)]
pub struct PaginationQuery {
    #[serde(default = "default_page")]
    pub page: i64,
    #[serde(default = "default_per_page")]
    pub per_page: i64,
}

fn default_page() -> i64 { 1 }
fn default_per_page() -> i64 { 20 }

pub async fn register(
    state: web::Data<AppState>,
    body: web::Json<CreateUserDto>,
) -> AppResult<HttpResponse> {
    let service = UserService::new(state.pool.clone(), state.config.clone());
    let response = service.register(body.into_inner()).await?;
    Ok(HttpResponse::Created().json(response))
}

pub async fn login(
    state: web::Data<AppState>,
    body: web::Json<LoginDto>,
) -> AppResult<HttpResponse> {
    let service = UserService::new(state.pool.clone(), state.config.clone());
    let response = service.login(body.into_inner()).await?;
    Ok(HttpResponse::Ok().json(response))
}

pub async fn get_current_user(
    state: web::Data<AppState>,
    req: HttpRequest,
) -> AppResult<HttpResponse> {
    let auth_user = req
        .extensions()
        .get::<AuthenticatedUser>()
        .cloned()
        .ok_or_else(|| AppError::Unauthorized("Not authenticated".to_string()))?;

    let service = UserService::new(state.pool.clone(), state.config.clone());
    let user = service.get_user(auth_user.user_id).await?;
    Ok(HttpResponse::Ok().json(user))
}

pub async fn list_users(
    state: web::Data<AppState>,
    query: web::Query<PaginationQuery>,
) -> AppResult<HttpResponse> {
    let service = UserService::new(state.pool.clone(), state.config.clone());
    let users = service.list_users(query.page, query.per_page).await?;
    Ok(HttpResponse::Ok().json(users))
}

pub async fn get_user(
    state: web::Data<AppState>,
    path: web::Path<Uuid>,
) -> AppResult<HttpResponse> {
    let service = UserService::new(state.pool.clone(), state.config.clone());
    let user = service.get_user(path.into_inner()).await?;
    Ok(HttpResponse::Ok().json(user))
}

pub async fn update_user(
    state: web::Data<AppState>,
    path: web::Path<Uuid>,
    body: web::Json<UpdateUserDto>,
) -> AppResult<HttpResponse> {
    let service = UserService::new(state.pool.clone(), state.config.clone());
    let user = service.update_user(path.into_inner(), body.into_inner()).await?;
    Ok(HttpResponse::Ok().json(user))
}

pub async fn delete_user(
    state: web::Data<AppState>,
    path: web::Path<Uuid>,
) -> AppResult<HttpResponse> {
    let service = UserService::new(state.pool.clone(), state.config.clone());
    service.delete_user(path.into_inner()).await?;
    Ok(HttpResponse::NoContent().finish())
}
// src/handlers/health.rs
use actix_web::HttpResponse;
use serde_json::json;

pub async fn health_check() -> HttpResponse {
    HttpResponse::Ok().json(json!({
        "status": "healthy",
        "timestamp": chrono::Utc::now().to_rfc3339()
    }))
}

Testing

// tests/integration_tests.rs
use actix_web::{test, web, App};
use serde_json::json;

use myproject::{handlers, routes, AppState};

async fn setup_test_app() -> impl actix_web::dev::Service<
    actix_http::Request,
    Response = actix_web::dev::ServiceResponse,
    Error = actix_web::Error,
> {
    let pool = sqlx::PgPool::connect("postgres://test:test@localhost/test_db")
        .await
        .unwrap();

    let config = myproject::config::Config {
        host: "127.0.0.1".to_string(),
        port: 8080,
        database_url: String::new(),
        jwt_secret: "test_secret".to_string(),
        jwt_expiration_hours: 24,
    };

    let state = web::Data::new(AppState { pool, config });

    test::init_service(
        App::new()
            .app_data(state)
            .configure(routes::configure),
    )
    .await
}

#[actix_rt::test]
async fn test_health_check() {
    let app = setup_test_app().await;

    let req = test::TestRequest::get().uri("/api/v1/health").to_request();
    let resp = test::call_service(&app, req).await;

    assert!(resp.status().is_success());
}

#[actix_rt::test]
async fn test_register_user() {
    let app = setup_test_app().await;

    let body = json!({
        "email": "test@example.com",
        "password": "password123",
        "name": "Test User"
    });

    let req = test::TestRequest::post()
        .uri("/api/v1/auth/register")
        .set_json(&body)
        .to_request();

    let resp = test::call_service(&app, req).await;
    assert_eq!(resp.status(), actix_web::http::StatusCode::CREATED);
}

#[actix_rt::test]
async fn test_login_invalid_credentials() {
    let app = setup_test_app().await;

    let body = json!({
        "email": "nonexistent@example.com",
        "password": "wrongpassword"
    });

    let req = test::TestRequest::post()
        .uri("/api/v1/auth/login")
        .set_json(&body)
        .to_request();

    let resp = test::call_service(&app, req).await;
    assert_eq!(resp.status(), actix_web::http::StatusCode::UNAUTHORIZED);
}

#[actix_rt::test]
async fn test_protected_route_without_token() {
    let app = setup_test_app().await;

    let req = test::TestRequest::get()
        .uri("/api/v1/users")
        .to_request();

    let resp = test::call_service(&app, req).await;
    assert_eq!(resp.status(), actix_web::http::StatusCode::UNAUTHORIZED);
}

Database Migration

-- migrations/001_create_users.sql
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";

CREATE TABLE users (
    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    email VARCHAR(255) NOT NULL UNIQUE,
    password_hash VARCHAR(255) NOT NULL,
    name VARCHAR(100) NOT NULL,
    role VARCHAR(50) NOT NULL DEFAULT 'user',
    is_active BOOLEAN NOT NULL DEFAULT true,
    created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_users_email ON users(email);

Commands

# Development
cargo run

# Watch mode (with cargo-watch)
cargo watch -x run

# Build release
cargo build --release

# Run tests
cargo test

# Run with specific log level
RUST_LOG=actix_web=debug cargo run

# Check code
cargo check
cargo clippy

# Format code
cargo fmt

# Run migrations
sqlx migrate run

Best Practices

Application Structure

  • Use web::Data for shared application state
  • Organize routes in separate modules
  • Use services for business logic
  • Keep handlers thin

Error Handling

  • Implement ResponseError for custom errors
  • Use thiserror for error definitions
  • Return appropriate HTTP status codes
  • Log errors with context

Performance

  • Use connection pooling
  • Enable compression middleware
  • Use async/await throughout
  • Consider worker count for production

Security

  • Validate all inputs
  • Use HTTPS in production
  • Implement rate limiting
  • Sanitize error messages in production

Comparison: Actix-web vs Axum vs Rocket

Feature Actix-web Axum Rocket
Performance Fastest Very Fast Fast
Async Yes Yes Yes
Type Safety Good Excellent Excellent
Middleware Custom Tower Fairings
Maturity Very Mature Growing Mature
Documentation Excellent Good Excellent
Community Large Growing Large

References