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
80%
Does it follow best practices?
Impact
Pending
No eval scenarios have been run
Passed
No known issues
Optimize this skill with Tessl
npx tessl skill review --optimize ./backend-rust/actix-project-starter/SKILL.mdScaffold 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 jsonsrc/
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 preparequery_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.181fcbc
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.