- Rust 95.6%
- Shell 4.1%
- Hurl 0.3%
The `latest` matrix leg resolved to a rustc 1.96.0 image cached on the agent, which is below the declared MSRV, so every step failed with "rustc 1.96.0 is not supported". Marking the image `pull: true` makes the runner fetch the current tag instead of a stale layer. The pre-commit hook now also runs `woodpecker lint --strict` on the pipeline. A malformed config fails the build before any step runs, which no cargo command can detect. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GfExFmnvwARLHARyEh9Pga |
||
|---|---|---|
| .cargo | ||
| .woodpecker | ||
| hurl | ||
| scripts | ||
| src | ||
| tests | ||
| .gitignore | ||
| Cargo.lock | ||
| Cargo.toml | ||
| LICENSE | ||
| README.md | ||
Axum Template
A production-ready Axum web application template with comprehensive error handling, middleware, and testing.
Minimum supported Rust version: 1.98 (edition 2024). Cargo.lock is committed
and CI builds with --locked, so a fresh checkout compiles the exact dependency
versions that were verified.
Features
- ✅ Comprehensive Error Handling: Custom error types with proper HTTP status codes
- ✅ Middleware System: 404 and 500 error handlers with detailed logging
- ✅ API Documentation: Automatic OpenAPI/Swagger UI generation
- ✅ Testing: Unit, integration, exhaustive and property-based tests
- ✅ Mutation Testing:
cargo mutantsproves the suite actually catches injected bugs - ✅ Type Safety: Full Rust type safety with proper error propagation
- ✅ Logging: Structured logging with tracing
- ✅ Timeout Protection: Request timeout middleware
- ✅ CORS Support: Configurable CORS headers
Project Structure
axum-template/
├── src/
│ ├── main.rs # Thin binary entry point
│ ├── lib.rs # Library exports
│ ├── app.rs # Router, middleware stack and process lifecycle
│ ├── api/ # Route handlers (health, todos)
│ ├── errors.rs # Error types and handling
│ ├── handles.rs # Middleware handlers (404, 500, etc.)
│ ├── metrics.rs # Error metrics collection
│ ├── reporting.rs # Error reporting and aggregation
│ └── schemas.rs # Request/response schemas
├── tests/
│ ├── api.rs # End-to-end tests of the HTTP surface
│ ├── spider.rs # Exhaustive crawl of the request space (exhaust)
│ ├── properties.rs # Property-based tests (proptest)
│ ├── error_conversions.rs # `From<_> for AppError` conversions
│ ├── metrics_rates.rs # Derived rates and percentages
│ ├── reporting_reporters.rs # Reporters and the rolling-window aggregator
│ └── data/ # Fixtures used by the tests
├── scripts/
│ ├── pre-commit.sh # Full local gate: every CI step, plus mutation testing
│ ├── coverage.sh # Coverage report
│ └── mutation-test.sh # Mutation testing
├── .cargo/mutants.toml # cargo-mutants configuration
└── Cargo.toml
The router lives in src/app.rs (the library), not in src/main.rs. That is
deliberate: integration tests and mutation testing exercise the exact stack the
binary serves, instead of a re-declared copy of it.
Architecture
Error Module (errors.rs)
The error module provides a comprehensive error handling system with:
AppError Enum
All application errors are represented by the AppError enum:
pub enum AppError {
NotFound(String), // 404 - Resource not found
BadRequest(String), // 400 - Bad request
Unauthorized(String), // 401 - Unauthorized
Forbidden(String), // 403 - Forbidden
Conflict(String), // 409 - Conflict
UnprocessableEntity(String),// 422 - Unprocessable entity
InternalServer(String), // 500 - Internal server error
ServiceUnavailable(String), // 503 - Service unavailable
Database(String), // 500 - Database error
Validation(String), // 400 - Validation error
}
ErrorResponse Structure
All errors return a consistent JSON structure:
{
"status": 404,
"error": "NOT_FOUND",
"message": "Resource not found: Todo with id 999 not found"
}
Automatic Conversions
The error module provides automatic conversions from common error types:
anyhow::Error→AppError::InternalServersqlx::Error→AppError::DatabaseorAppError::NotFoundserde_json::Error→AppError::BadRequeststd::io::Error→AppError::InternalServer
Handles Module (handles.rs)
The handles module provides middleware for error handling:
404 Not Found Handler
// Basic 404 handler
handle_404()
// 404 handler with path information
handle_404_with_path(req)
Returns:
{
"status": 404,
"error": "NOT_FOUND",
"message": "The requested resource was not found",
"path": "/api/users/999"
}
500 Internal Server Error Handler
handle_500(Some("Custom error message".to_string()))
Returns:
{
"status": 500,
"error": "INTERNAL_SERVER_ERROR",
"message": "An internal server error occurred. Please try again later.",
"path": null
}
Panic Catcher Middleware
Catches panics and converts them to proper 500 responses:
.layer(from_fn(catch_panic_middleware))
API Endpoints
Health Check
GET /health
Response (200 OK):
{
"status": "ok"
}
Get Todo
GET /todos/{id}
Response (200 OK):
{
"id": 42,
"task": "Example Task",
"completed": false
}
Response (404 Not Found):
{
"status": 404,
"error": "NOT_FOUND",
"message": "Resource not found: Todo with id 999 not found"
}
Create Todo
POST /todos
Content-Type: application/json
{
"task": "Buy groceries"
}
Response (201 Created):
{
"id": 1,
"task": "Buy groceries",
"completed": false
}
Response (400 Bad Request - Validation Error):
{
"status": 400,
"error": "VALIDATION_ERROR",
"message": "Validation error: Task cannot be empty"
}
Swagger UI
GET /swagger-ui/
Interactive API documentation.
OpenAPI Specification
GET /api-docs/openapi.json
OpenAPI 3.0 JSON specification.
Usage Examples
Using AppError in Handlers
use axum::{extract::Path, Json};
use crate::errors::AppError;
async fn get_user(Path(id): Path<u64>) -> Result<Json<User>, AppError> {
let user = database::find_user(id)
.await
.ok_or_else(|| AppError::NotFound(format!("User {} not found", id)))?;
Ok(Json(user))
}
Validation Example
async fn create_user(Json(payload): Json<CreateUser>) -> Result<Json<User>, AppError> {
if payload.email.is_empty() {
return Err(AppError::Validation("Email is required".to_string()));
}
if !payload.email.contains('@') {
return Err(AppError::Validation("Invalid email format".to_string()));
}
// Process user creation...
Ok(Json(user))
}
Database Error Handling
async fn update_user(Path(id): Path<u64>) -> Result<Json<User>, AppError> {
let user = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1")
.bind(id)
.fetch_one(&pool)
.await?; // Automatically converts sqlx::Error to AppError
Ok(Json(user))
}
Running the Application
Development
cargo run
The server will start on http://0.0.0.0:3000
With Debug Logging
RUST_LOG=debug cargo run
Production Build
cargo build --release
./target/release/axum-template
Testing
Run All Tests
cargo test
Run Unit Tests Only
cargo test --lib
Run a Single Suite
cargo test --test api # end-to-end HTTP tests
cargo test --test spider # exhaustive request-space crawl
cargo test --test properties # property-based tests
cargo test --test error_conversions # From<_> for AppError
cargo test --test metrics_rates # derived rates and percentages
cargo test --test reporting_reporters # reporters and aggregation windows
cargo test --lib # unit tests inside the modules
Run with Output
cargo test -- --nocapture
Test Layers
| Layer | Location | What it does |
|---|---|---|
| Unit | src/*.rs (mod tests) |
Pins the behaviour of individual functions |
| End-to-end | tests/api.rs |
Drives the real router for each documented endpoint |
| Exhaustive spider | tests/spider.rs |
Enumerates every combination of verb × path × body × content-type |
| Property-based | tests/properties.rs |
Randomises values and asserts invariants |
| Mutation | cargo mutants |
Injects bugs and requires a test to notice |
Exhaustive spider (exhaust)
tests/spider.rs models a request as a struct of small enums (Verb,
PathShape, IdShape, BodyShape, ContentTypeShape) and derives
exhaust::Exhaust on it, so the test iterates the entire cartesian
product — 13,125 requests — through the real router in well under a second.
Every request is checked against:
- global invariants that must hold for any input at all (never a 5xx, only
known status codes, error bodies are JSON whose
statusfield matches the HTTP status,HEADandOPTIONSnever carry a body); and - a contract oracle that predicts the exact status code for the routes this crate owns, so a flipped comparison or status code fails the test even if no hand-written case covers that input.
Adding a variant to any of those enums immediately widens the crawl, and
request_space_is_fully_enumerated fails so the change is deliberate.
One behaviour the spider pinned down: the permissive
CorsLayerincreate_appanswers everyOPTIONSrequest with200 OKand an empty body, before routing runs. That is now asserted rather than assumed.
Property-based tests (proptest)
tests/properties.rs randomises values and asserts invariants, combining
proptest with exhaust: the AppError variant is enumerated exhaustively
while its payload is generated randomly. Among the properties:
- any
u64id is echoed back verbatim; anything non-numeric is a400 - any task with a non-whitespace character round-trips byte for byte
- any whitespace-only task is a
VALIDATION_ERROR - arbitrary bytes and arbitrary paths never produce a 5xx
- metrics counters, percentages and aggregation windows always add up
Mutation Testing
./scripts/mutation-test.sh
# or directly
cargo mutants
Mutation testing injects a bug (a flipped comparison, a replaced return value)
and re-runs the suite; a surviving mutant marks behaviour that no test pins
down. Configuration lives in .cargo/mutants.toml.
The sweep runs in the pre-commit hook, not in CI: a full run takes around ten
minutes and needs several GB of scratch disk per job, which is a poor fit for a
shared runner. cargo mutants has no --locked flag of its own, so the lockfile
is pinned by passing it through with --cargo-arg=--locked.
Each parallel job gets its own copy of the tree and its own target/
directory, so the run is bounded by disk rather than by CPU. mutation-test.sh
defaults to 2 jobs; raise it with MUTANTS_JOBS=4 ./scripts/mutation-test.sh
only if you have several GB per job to spare (on a tmpfs /tmp, that is RAM).
Why
cargo-mutantsand notmutagen?mutagenis the older Rust mutation tester, but it has been unmaintained since 2020, requires a nightly compiler and a#[mutate]proc-macro on every function under test, and does not build on the 2024 edition.cargo-mutantsis the maintained successor and needs no source annotations.
Configuration
Environment Variables
RUST_LOG: Set logging level (e.g.,debug,info,warn,error)
Timeout Configuration
Default timeout is 30 seconds (REQUEST_TIMEOUT). Modify in app.rs:
.layer(TimeoutLayer::with_status_code(
StatusCode::GATEWAY_TIMEOUT,
REQUEST_TIMEOUT,
))
Error Handling Best Practices
1. Use Specific Error Types
// Good
return Err(AppError::NotFound("User not found".to_string()));
// Avoid
return Err(AppError::InternalServer("User not found".to_string()));
2. Provide Descriptive Messages
// Good
AppError::Validation(format!("Email '{}' is invalid", email))
// Avoid
AppError::Validation("Invalid".to_string())
3. Use ? Operator for Propagation
async fn handler() -> Result<Json<Data>, AppError> {
let data = database::fetch().await?; // Auto-converts errors
Ok(Json(data))
}
4. Log Errors at Appropriate Levels
// Errors are automatically logged by the IntoResponse implementation
// Client errors (4xx) -> WARN
// Server errors (5xx) -> ERROR
Adding New Error Types
To add a new error type:
- Add variant to
AppErrorenum inerrors.rs:
#[error("Too many requests: {0}")]
TooManyRequests(String),
- Add status code mapping:
AppError::TooManyRequests(_) => StatusCode::TOO_MANY_REQUESTS,
- Add error type string:
AppError::TooManyRequests(_) => "TOO_MANY_REQUESTS",
- Use in handlers:
if rate_limit_exceeded {
return Err(AppError::TooManyRequests("Rate limit exceeded".to_string()));
}
Dependencies
- axum (0.8.8): Web framework
- tokio (1.43.0): Async runtime
- serde (1.0.218): Serialization
- sqlx (0.8.6): Database access
- thiserror (2.0.17): Error derivation
- tracing (0.1.44): Structured logging
- tower-http (0.6.8): HTTP middleware
- utoipa (5.4.0): OpenAPI documentation
License
[Add your license here]
Contributing
[Add contribution guidelines here]
Support
For issues and questions, please open an issue on the repository.