CtrlK
BlogDocsLog inGet started
Tessl Logo

actix-project-starter

Scaffold a production-ready Actix-web 4.x API with Rust 2024 edition, async handlers, extractors, middleware, SQLx for database access, and structured error handling.

85

Quality

80%

Does it follow best practices?

Impact

Pending

No eval scenarios have been run

SecuritybySnyk

Passed

No known issues

Optimize this skill with Tessl

npx tessl skill review --optimize ./backend-rust/actix-project-starter/SKILL.md
SKILL.md
Quality
Evals
Security

Actix-Web Project Starter

Scaffold a production-ready Actix-web 4.x API with Rust 2024 edition, async handlers, extractors, middleware, SQLx for database access, and structured error handling.

Prerequisites

  • Rust >= 1.85 (2024 edition support)
  • Cargo
  • PostgreSQL (or SQLite for development)
  • sqlx-cli (cargo install sqlx-cli --features postgres)

Scaffold Command

cargo init <project-name>
cd <project-name>

# Add core dependencies
cargo add actix-web@4 actix-rt tokio --features tokio/full
cargo add serde --features derive
cargo add serde_json
cargo add sqlx --features runtime-tokio,tls-rustls,postgres,uuid,chrono,migrate
cargo add uuid --features v4,serde
cargo add chrono --features serde
cargo add dotenvy
cargo add tracing tracing-subscriber tracing-actix-web
cargo add thiserror
cargo add config --features toml

# Dev dependencies
cargo add --dev actix-rt
cargo add --dev reqwest --features json

Project Structure

src/
  main.rs                  # Server bootstrap, app factory, middleware registration
  config.rs                # Typed configuration from env/files
  db.rs                    # SQLx pool initialization
  errors.rs                # AppError enum implementing ResponseError
  routes/
    mod.rs                 # Route registration — configure(cfg)
    health.rs              # Health check endpoint
    users.rs               # User CRUD handlers
  models/
    mod.rs
    user.rs                # User struct with sqlx::FromRow
  middleware/
    mod.rs
    auth.rs                # Authentication middleware (Transform + Service)
  extractors/
    mod.rs
    authenticated.rs       # Custom extractor for current user
migrations/
  YYYYMMDD_initial.sql     # SQLx migrations
Cargo.toml
.env
.env.example                 # Template for required env vars (commit this)

Key Conventions

  • Use web::Data<T> for shared application state (DB pool, config)
  • Handlers are async functions returning Result<impl Responder, AppError>
  • One file per route group, registered via web::scope and configure
  • Extractors (Path, Json, Query, web::Data) as function parameters — order does not matter
  • #[derive(Deserialize)] on all request types, #[derive(Serialize)] on all response types
  • Keep handlers thin — business logic in separate service functions
  • Use sqlx::query_as! macro for compile-time checked queries (requires DATABASE_URL at build time)
  • All errors flow through a single AppError enum implementing ResponseError
  • Rust 2024 edition in Cargo.toml: edition = "2024"

Essential Patterns

Application Bootstrap — main.rs

use actix_web::{web, App, HttpServer, middleware::Logger};
use sqlx::postgres::PgPoolOptions;
use tracing_actix_web::TracingLogger;

mod config;
mod db;
mod errors;
mod extractors;
mod middleware;
mod models;
mod routes;

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    dotenvy::dotenv().ok();
    tracing_subscriber::fmt::init();

    let database_url = std::env::var("DATABASE_URL")
        .expect("DATABASE_URL must be set");

    let pool = PgPoolOptions::new()
        .max_connections(5)
        .connect(&database_url)
        .await
        .expect("Failed to create pool");

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

    let port = std::env::var("PORT")
        .unwrap_or_else(|_| "8080".to_string())
        .parse::<u16>()
        .expect("PORT must be a number");

    HttpServer::new(move || {
        App::new()
            .wrap(TracingLogger::default())
            .wrap(Logger::default())
            .app_data(web::Data::new(pool.clone()))
            .configure(routes::configure)
    })
    .bind(("0.0.0.0", port))?
    .run()
    .await
}

Route Registration — routes/mod.rs

use actix_web::web;

mod health;
mod users;

pub fn configure(cfg: &mut web::ServiceConfig) {
    cfg.service(
        web::scope("/api")
            .configure(health::configure)
            .configure(users::configure),
    );
}

Handlers with Extractors — routes/users.rs

use actix_web::{web, HttpResponse};
use sqlx::PgPool;
use uuid::Uuid;

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

pub fn configure(cfg: &mut web::ServiceConfig) {
    cfg.service(
        web::scope("/users")
            .route("", web::post().to(create_user))
            .route("", web::get().to(list_users))
            .route("/{id}", web::get().to(get_user))
            .route("/{id}", web::put().to(update_user))
            .route("/{id}", web::delete().to(delete_user)),
    );
}

async fn create_user(
    pool: web::Data<PgPool>,
    body: web::Json<CreateUserRequest>,
) -> Result<HttpResponse, AppError> {
    let user = sqlx::query_as!(
        User,
        r#"INSERT INTO users (id, email, name)
           VALUES ($1, $2, $3)
           RETURNING id, email, name, created_at, updated_at"#,
        Uuid::new_v4(),
        body.email,
        body.name,
    )
    .fetch_one(pool.get_ref())
    .await?;

    Ok(HttpResponse::Created().json(user))
}

async fn get_user(
    pool: web::Data<PgPool>,
    path: web::Path<Uuid>,
) -> Result<HttpResponse, AppError> {
    let id = path.into_inner();
    let user = sqlx::query_as!(
        User,
        "SELECT id, email, name, created_at, updated_at FROM users WHERE id = $1",
        id,
    )
    .fetch_optional(pool.get_ref())
    .await?
    .ok_or(AppError::NotFound(format!("User {id} not found")))?;

    Ok(HttpResponse::Ok().json(user))
}

async fn list_users(
    pool: web::Data<PgPool>,
    query: web::Query<UserQuery>,
) -> Result<HttpResponse, AppError> {
    let limit = query.limit.unwrap_or(20).min(100);
    let offset = query.offset.unwrap_or(0);

    let users = sqlx::query_as!(
        User,
        "SELECT id, email, name, created_at, updated_at FROM users LIMIT $1 OFFSET $2",
        limit as i64,
        offset as i64,
    )
    .fetch_all(pool.get_ref())
    .await?;

    Ok(HttpResponse::Ok().json(users))
}

async fn update_user(
    pool: web::Data<PgPool>,
    path: web::Path<Uuid>,
    body: web::Json<CreateUserRequest>,
) -> Result<HttpResponse, AppError> {
    let id = path.into_inner();
    let user = sqlx::query_as!(
        User,
        r#"UPDATE users SET email = $1, name = $2, updated_at = NOW()
           WHERE id = $3
           RETURNING id, email, name, created_at, updated_at"#,
        body.email,
        body.name,
        id,
    )
    .fetch_optional(pool.get_ref())
    .await?
    .ok_or(AppError::NotFound(format!("User {id} not found")))?;

    Ok(HttpResponse::Ok().json(user))
}

async fn delete_user(
    pool: web::Data<PgPool>,
    path: web::Path<Uuid>,
) -> Result<HttpResponse, AppError> {
    let id = path.into_inner();
    let rows = sqlx::query!("DELETE FROM users WHERE id = $1", id)
        .execute(pool.get_ref())
        .await?
        .rows_affected();

    if rows == 0 {
        return Err(AppError::NotFound(format!("User {id} not found")));
    }

    Ok(HttpResponse::NoContent().finish())
}

Model — models/user.rs

use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
use uuid::Uuid;

#[derive(Debug, Serialize, FromRow)]
pub struct User {
    pub id: Uuid,
    pub email: String,
    pub name: String,
    pub created_at: DateTime<Utc>,
    pub updated_at: DateTime<Utc>,
}

#[derive(Debug, Deserialize)]
pub struct CreateUserRequest {
    pub email: String,
    pub name: String,
}

#[derive(Debug, Deserialize)]
pub struct UserQuery {
    pub limit: Option<u32>,
    pub offset: Option<u32>,
}

Error Handling — errors.rs

use actix_web::{HttpResponse, ResponseError};
use std::fmt;

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

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::Internal(msg) => write!(f, "Internal error: {msg}"),
            AppError::Database(e) => write!(f, "Database error: {e}"),
        }
    }
}

impl ResponseError for AppError {
    fn error_response(&self) -> HttpResponse {
        match self {
            AppError::NotFound(msg) => {
                HttpResponse::NotFound().json(serde_json::json!({ "error": msg }))
            }
            AppError::BadRequest(msg) => {
                HttpResponse::BadRequest().json(serde_json::json!({ "error": msg }))
            }
            AppError::Internal(_) | AppError::Database(_) => {
                tracing::error!("{self}");
                HttpResponse::InternalServerError()
                    .json(serde_json::json!({ "error": "Internal server error" }))
            }
        }
    }
}

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

Middleware — middleware/auth.rs

use actix_web::{
    dev::{ServiceRequest, ServiceResponse, Transform, Service},
    Error, HttpMessage,
    body::EitherBody,
};
use std::future::{Future, Ready, ready};
use std::pin::Pin;

pub struct AuthMiddleware;

impl<S, B> Transform<S, ServiceRequest> for AuthMiddleware
where
    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error>,
    S::Future: 'static,
    B: 'static,
{
    type Response = ServiceResponse<EitherBody<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 {
        ready(Ok(AuthMiddlewareService { service }))
    }
}

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

impl<S, B> Service<ServiceRequest> for AuthMiddlewareService<S>
where
    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error>,
    S::Future: 'static,
    B: 'static,
{
    type Response = ServiceResponse<EitherBody<B>>;
    type Error = Error;
    type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>>>>;

    fn poll_ready(
        &self,
        ctx: &mut core::task::Context<'_>,
    ) -> std::task::Poll<Result<(), Self::Error>> {
        self.service.poll_ready(ctx)
    }

    fn call(&self, req: ServiceRequest) -> Self::Future {
        // Extract and validate token from Authorization header here
        let auth_header = req.headers().get("Authorization").cloned();

        if let Some(_header) = auth_header {
            // Validate token, extract user ID, insert into request extensions
            // req.extensions_mut().insert(UserId(uuid));
            let fut = self.service.call(req);
            Box::pin(async move {
                let res = fut.await?;
                Ok(res.map_into_left_body())
            })
        } else {
            Box::pin(async move {
                let res = req.into_response(
                    actix_web::HttpResponse::Unauthorized().finish(),
                );
                Ok(res.map_into_right_body())
            })
        }
    }
}

Migration — migrations/20240101000000_initial.sql

CREATE EXTENSION IF NOT EXISTS "uuid-ossp";

CREATE TABLE users (
    id         UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    email      TEXT NOT NULL UNIQUE,
    name       TEXT NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

First Steps After Scaffold

  1. Copy .env.example to .env and fill in DATABASE_URL and PORT
  2. Run cargo build to fetch and compile all dependencies
  3. Ensure PostgreSQL is running and create the database: createdb myapp_dev
  4. Run migrations: sqlx migrate run
  5. Start the dev server: cargo watch -x run (install cargo-watch first if needed)
  6. Verify the health endpoint: curl http://localhost:8080/api/health

Common Commands

# Development with auto-reload
cargo install cargo-watch
cargo watch -x run

# Run
cargo run

# Build release
cargo build --release

# Run tests
cargo test

# Run with specific test
cargo test test_name -- --nocapture

# Lint
cargo clippy -- -D warnings

# Format
cargo fmt

# Create migration
sqlx migrate add <name>

# Run migrations
sqlx migrate run

# Revert last migration
sqlx migrate revert

# Prepare offline query data (for CI without DB)
cargo sqlx prepare

Integration Notes

  • Database: SQLx provides compile-time checked queries with query_as!. Set DATABASE_URL in .env for development. Use sqlx prepare for CI builds without a live database.
  • Auth: Implement JWT validation in the auth middleware. Use jsonwebtoken crate for token parsing. Store user claims in request extensions via req.extensions_mut().insert().
  • CORS: Add actix-cors crate and configure via Cors::default() in the app factory.
  • Testing: Use actix_web::test module with test::init_service and test::call_service for integration tests. Create a separate test database and run migrations in test setup.
  • Docker: Multi-stage build — rust:1.85-slim for build, debian:bookworm-slim for runtime with only the compiled binary.
  • Actors: For stateful concurrent work, use actix actors (Actor, Handler, Message traits) alongside the web server. Actors run in the same Actix runtime.
  • WebSockets: Use actix-web-actors for WebSocket support via ws::start and implementing StreamHandler.
Repository
achreftlili/deep-dev-skills
Last updated
Created

Is this your skill?

If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.