backend-rust/axum-project-starter/SKILL.md
Scaffold a production-ready Axum 0.8+ API with Rust 2024 edition, Tower middleware, SQLx database integration, structured error handling, tracing, and shared state patterns.
npx skillsauth add achreftlili/deep-dev-skills axum-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 Axum 0.8+ API with Rust 2024 edition, Tower middleware, SQLx database integration, structured error handling, tracing, and shared state patterns.
sqlx-cli (cargo install sqlx-cli --features postgres)cargo init <project-name>
cd <project-name>
# Core dependencies
cargo add [email protected] --features macros
cargo add tokio --features 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 tower --features timeout,limit
cargo add tower-http --features cors,trace,compression-gzip
cargo add tracing tracing-subscriber
cargo add thiserror
cargo add dotenvy
# Dev dependencies
cargo add --dev reqwest --features json
cargo add --dev tokio-test
src/
main.rs # Server bootstrap, router assembly
config.rs # Typed configuration from env
db.rs # SQLx pool initialization
errors.rs # AppError implementing IntoResponse
state.rs # AppState struct (pool, config, etc.)
routes/
mod.rs # Router composition
health.rs # Health check
users.rs # User CRUD handlers
models/
mod.rs
user.rs # User struct, request/response types
middleware/
mod.rs
auth.rs # Auth layer via Tower middleware
extractors/
mod.rs
auth.rs # Custom extractor for authenticated user
migrations/
YYYYMMDD_initial.sql
Cargo.toml
.env
.env.example # Template for required env vars (commit this)
State(AppState) extractor — AppState wraps an Arc internally or is cloned cheaplyResult<impl IntoResponse, AppError>Router::new().route("/path", get(handler)) — one router per module, merged in routes/mod.rsState and Path before Json (body can only be consumed once, must be last)ServiceBuilder, Layer, and Service traitsIntoResponse on error types for clean error propagation — no .unwrap() in handlersCargo.toml: edition = "2024"#[axum::debug_handler] on handlers during development for better compile error messagesmain.rsuse std::net::SocketAddr;
use tokio::net::TcpListener;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
mod config;
mod db;
mod errors;
mod extractors;
mod middleware;
mod models;
mod routes;
mod state;
use state::AppState;
#[tokio::main]
async fn main() {
dotenvy::dotenv().ok();
tracing_subscriber::registry()
.with(tracing_subscriber::fmt::layer())
.with(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "info,tower_http=debug".into()),
)
.init();
let state = AppState::new().await;
sqlx::migrate!("./migrations")
.run(&state.pool)
.await
.expect("Failed to run migrations");
let app = routes::create_router(state);
let port: u16 = std::env::var("PORT")
.unwrap_or_else(|_| "8080".into())
.parse()
.expect("PORT must be a number");
let addr = SocketAddr::from(([0, 0, 0, 0], port));
let listener = TcpListener::bind(addr).await.unwrap();
tracing::info!("Listening on {addr}");
axum::serve(listener, app).await.unwrap();
}
state.rsuse sqlx::postgres::PgPoolOptions;
use sqlx::PgPool;
#[derive(Clone)]
pub struct AppState {
pub pool: PgPool,
}
impl AppState {
pub async fn new() -> Self {
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 connect to database");
Self { pool }
}
}
routes/mod.rsuse axum::Router;
use tower_http::{
cors::CorsLayer,
trace::TraceLayer,
compression::CompressionLayer,
};
use crate::state::AppState;
mod health;
mod users;
pub fn create_router(state: AppState) -> Router {
Router::new()
.merge(health::router())
.nest("/api/users", users::router())
.layer(CompressionLayer::new())
.layer(TraceLayer::new_for_http())
.layer(CorsLayer::permissive())
.with_state(state)
}
routes/users.rsuse axum::{
extract::{Path, Query, State},
http::StatusCode,
routing::{get, post},
Json, Router,
};
use uuid::Uuid;
use crate::errors::AppError;
use crate::models::user::{CreateUserRequest, User, UserQuery};
use crate::state::AppState;
pub fn router() -> Router<AppState> {
Router::new()
.route("/", post(create_user).get(list_users))
.route("/{id}", get(get_user).put(update_user).delete(delete_user))
}
// IMPORTANT: Extractors must be ordered: State first, then Path/Query, then Json last.
// Json consumes the request body — placing it before Path causes "body already consumed" errors.
#[axum::debug_handler]
async fn create_user(
State(state): State<AppState>,
Json(body): Json<CreateUserRequest>,
) -> Result<(StatusCode, Json<User>), 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(&state.pool)
.await?;
Ok((StatusCode::CREATED, Json(user)))
}
async fn get_user(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> Result<Json<User>, AppError> {
let user = sqlx::query_as!(
User,
"SELECT id, email, name, created_at, updated_at FROM users WHERE id = $1",
id,
)
.fetch_optional(&state.pool)
.await?
.ok_or(AppError::NotFound(format!("User {id} not found")))?;
Ok(Json(user))
}
async fn list_users(
State(state): State<AppState>,
Query(params): Query<UserQuery>,
) -> Result<Json<Vec<User>>, AppError> {
let limit = params.limit.unwrap_or(20).min(100);
let offset = params.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(&state.pool)
.await?;
Ok(Json(users))
}
async fn update_user(
State(state): State<AppState>,
Path(id): Path<Uuid>,
Json(body): Json<CreateUserRequest>,
) -> Result<Json<User>, AppError> {
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(&state.pool)
.await?
.ok_or(AppError::NotFound(format!("User {id} not found")))?;
Ok(Json(user))
}
async fn delete_user(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> Result<StatusCode, AppError> {
let rows = sqlx::query!("DELETE FROM users WHERE id = $1", id)
.execute(&state.pool)
.await?
.rows_affected();
if rows == 0 {
return Err(AppError::NotFound(format!("User {id} not found")));
}
Ok(StatusCode::NO_CONTENT)
}
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 axum::{
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
#[derive(Debug, thiserror::Error)]
pub enum AppError {
#[error("Not found: {0}")]
NotFound(String),
#[error("Bad request: {0}")]
BadRequest(String),
#[error("Unauthorized")]
Unauthorized,
#[error("Database error: {0}")]
Database(#[from] sqlx::Error),
#[error("Internal error: {0}")]
Internal(String),
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, message) = match &self {
AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()),
AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg.clone()),
AppError::Unauthorized => {
(StatusCode::UNAUTHORIZED, "Unauthorized".to_string())
}
AppError::Database(e) => {
tracing::error!("Database error: {e}");
(
StatusCode::INTERNAL_SERVER_ERROR,
"Internal server error".to_string(),
)
}
AppError::Internal(msg) => {
tracing::error!("Internal error: {msg}");
(
StatusCode::INTERNAL_SERVER_ERROR,
"Internal server error".to_string(),
)
}
};
(status, Json(serde_json::json!({ "error": message }))).into_response()
}
}
extractors/auth.rsuse axum::{
async_trait,
extract::FromRequestParts,
http::request::Parts,
};
use uuid::Uuid;
use crate::errors::AppError;
use crate::state::AppState;
pub struct AuthenticatedUser {
pub user_id: Uuid,
}
#[async_trait]
impl FromRequestParts<AppState> for AuthenticatedUser {
type Rejection = AppError;
async fn from_request_parts(
parts: &mut Parts,
_state: &AppState,
) -> Result<Self, Self::Rejection> {
let auth_header = parts
.headers
.get("Authorization")
.and_then(|v| v.to_str().ok())
.ok_or(AppError::Unauthorized)?;
let token = auth_header
.strip_prefix("Bearer ")
.ok_or(AppError::Unauthorized)?;
// Validate token and extract user_id here
let user_id = validate_token(token)?;
Ok(AuthenticatedUser { user_id })
}
}
fn validate_token(token: &str) -> Result<Uuid, AppError> {
// Replace with actual JWT validation using `jsonwebtoken` crate
let _ = token;
Err(AppError::Unauthorized)
}
middleware/auth.rsuse axum::{
extract::Request,
http::StatusCode,
middleware::Next,
response::Response,
};
/// Use with `axum::middleware::from_fn`
pub async fn require_auth(
request: Request,
next: Next,
) -> Result<Response, StatusCode> {
let auth_header = request
.headers()
.get("Authorization")
.and_then(|v| v.to_str().ok());
match auth_header {
Some(header) if header.starts_with("Bearer ") => {
// Validate token here
Ok(next.run(request).await)
}
_ => Err(StatusCode::UNAUTHORIZED),
}
}
// Apply to specific routes:
// Router::new()
// .route("/protected", get(handler))
// .layer(axum::middleware::from_fn(require_auth))
.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
# Lint
cargo clippy -- -D warnings
# Format
cargo fmt
# Create migration
sqlx migrate add <name>
# Run migrations
sqlx migrate run
# Prepare offline query data (for CI without DB)
cargo sqlx prepare
# Check (faster than build — type checking only)
cargo check
cargo sqlx prepare to generate .sqlx/ query cache for CI builds without a live database.jsonwebtoken crate for JWT. Implement as a custom extractor (FromRequestParts) or as a from_fn middleware layer. Extractors are more ergonomic when you need the user in the handler.ServiceBuilder composes layers. Order matters — layers wrap from bottom to top. Place TraceLayer outermost, auth layers on specific route groups.axum::extract::ws::WebSocket. Upgrade with WebSocketUpgrade extractor.axum::body::Body and tower::ServiceExt (oneshot) to test handlers without starting a server. Build the router with a test database pool.tokio::signal with axum::serve(...).with_graceful_shutdown(signal).rust:1.85-slim for build, debian:bookworm-slim for runtime with only the compiled binary.axum::body::Body::from_stream() for streaming responses (SSE, large file downloads).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.