axum template to start develop
  • Rust 95.6%
  • Shell 4.1%
  • Hurl 0.3%
Find a file Use this template
Jonatas Oliveira 62308bc3ca
All checks were successful
ci/woodpecker/push/pipeline/1 Pipeline was successful
ci/woodpecker/push/pipeline/2 Pipeline was successful
ci/woodpecker/cron/pipeline/1 Pipeline was successful
ci/woodpecker/cron/pipeline/2 Pipeline was successful
fix(ci): pull the rust image on every run
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
2026-09-05 11:27:19 +02:00
.cargo test(mutation): add cargo-mutants setup 2026-09-04 12:22:20 +02:00
.woodpecker fix(ci): pull the rust image on every run 2026-09-05 11:27:19 +02:00
hurl feat: add simple example with endpoints with axum 2026-01-01 20:02:31 +01:00
scripts fix(ci): pull the rust image on every run 2026-09-05 11:27:19 +02:00
src fix(lint): resolve clippy and rustdoc warnings 2026-09-04 12:22:33 +02:00
tests test: pin down error conversions, derived metrics and reporters 2026-09-04 12:22:10 +02:00
.gitignore test(mutation): add cargo-mutants setup 2026-09-04 12:22:20 +02:00
Cargo.lock fix(build): unbreak Rust 1.98 builds and lock dependency versions 2026-09-04 12:21:43 +02:00
Cargo.toml fix(build): unbreak Rust 1.98 builds and lock dependency versions 2026-09-04 12:21:43 +02:00
LICENSE Initial commit 2026-01-01 14:13:21 +00:00
README.md docs: record that mutation testing runs pre-commit, not in CI 2026-09-05 06:27:43 +02:00

status-badge

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 mutants proves 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::ErrorAppError::InternalServer
  • sqlx::ErrorAppError::Database or AppError::NotFound
  • serde_json::ErrorAppError::BadRequest
  • std::io::ErrorAppError::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 status field matches the HTTP status, HEAD and OPTIONS never 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 CorsLayer in create_app answers every OPTIONS request with 200 OK and 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 u64 id is echoed back verbatim; anything non-numeric is a 400
  • 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-mutants and not mutagen? mutagen is 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-mutants is 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:

  1. Add variant to AppError enum in errors.rs:
#[error("Too many requests: {0}")]
TooManyRequests(String),
  1. Add status code mapping:
AppError::TooManyRequests(_) => StatusCode::TOO_MANY_REQUESTS,
  1. Add error type string:
AppError::TooManyRequests(_) => "TOO_MANY_REQUESTS",
  1. 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.