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.
84
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/axum-project-starter/SKILL.mdScaffold 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 axum@0.8 --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-testsrc/
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 checkcargo 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).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.