backend-rust/actix-project-starter/SKILL.md
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.
npx skillsauth add achreftlili/deep-dev-skills actix-project-starterInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
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.
sqlx-cli (cargo install sqlx-cli --features postgres)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
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)
web::Data<T> for shared application state (DB pool, config)Result<impl Responder, AppError>web::scope and configurePath, Json, Query, web::Data) as function parameters — order does not matter#[derive(Deserialize)] on all request types, #[derive(Serialize)] on all response typessqlx::query_as! macro for compile-time checked queries (requires DATABASE_URL at build time)AppError enum implementing ResponseErrorCargo.toml: edition = "2024"main.rsuse 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
}
routes/mod.rsuse 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),
);
}
routes/users.rsuse 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())
}
models/user.rsuse 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>,
}
errors.rsuse 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/auth.rsuse 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())
})
}
}
}
migrations/20240101000000_initial.sqlCREATE 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()
);
.env.example to .env and fill in DATABASE_URL and PORTcargo build to fetch and compile all dependenciescreatedb myapp_devsqlx migrate runcargo watch -x run (install cargo-watch first if needed)curl http://localhost:8080/api/health# 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
query_as!. Set DATABASE_URL in .env for development. Use sqlx prepare for CI builds without a live database.jsonwebtoken crate for token parsing. Store user claims in request extensions via req.extensions_mut().insert().actix-cors crate and configure via Cors::default() in the app factory.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.rust:1.85-slim for build, debian:bookworm-slim for runtime with only the compiled binary.actix actors (Actor, Handler, Message traits) alongside the web server. Actors run in the same Actix runtime.actix-web-actors for WebSocket support via ws::start and implementing StreamHandler.testing
Set up Vitest 2.x with TypeScript for unit and component testing using test/describe/it, vi.fn/vi.mock/vi.spyOn, component testing with Testing Library, coverage (v8/istanbul), workspace config, and snapshot testing.
testing
Set up pytest 8.x with Python for unit and integration testing using fixtures (scope, autouse, parametrize), async tests (pytest-asyncio), mocking (unittest.mock, pytest-mock), coverage (pytest-cov), conftest.py patterns, and markers.
testing
Set up Playwright 1.49+ with TypeScript for E2E testing using page object model, fixtures, test.describe/test blocks, assertions, selectors, network mocking, CI configuration, and trace viewer.
testing
Set up Jest 30+ with TypeScript for unit tests, integration tests, mocking (jest.fn, jest.mock, jest.spyOn), coverage configuration, custom matchers, snapshot testing, and setup/teardown patterns.