Skill: anderskev/axum-code-review (version 1.0.2)

--- name: axum-code-review description: Reviews axum web framework code for routing patterns, extractor usage, middleware, state management, and error handling. Use when reviewing Rust code that uses axum, tower, or hyper for HTTP services. Covers axum 0.7+ patterns including State, Path, Query, Json extractors. --- # Axum Code Review ## Review Workflow 1. **Check Cargo.toml** — Note axum version (0.6 vs 0.7+ have different patterns), Rust edition (2021 vs 2024), tower, tower-http features. Edition 2024 changes RPIT lifetime capture in handler return types and removes the need for `async-trait` in custom extractors. 2. **Check routing** — Route organization, method routing, nested routers 3. **Check extractors** — Order matters (body extractors must be last), correct types 4. **Check state** — Shared state via `State<T>`, not global mutable state 5. **Check error handling** — `IntoResponse` implementations, error types ## Gates (before reporting findings) Run **in order**. Do not write a finding until the step that applies has passed. 1. **Version and edition on disk** — **Pass when:** You have read the relevant `Cargo.toml` (crate or workspace root) and can state `axum` (and related tower/tower-http) versions and Rust `edition`. **Then** apply 0.6 vs 0.7+ or Edition 2024–specific checklist items only when that file supports them. 2. **Per-finding evidence** — **Pass when:** Each issue cites `[FILE:LINE]` from the **current** tree for the handler, router, layer, or type under review (not from memory, docs-only, or another branch). 3. **Category check vs protocol** — **Pass when:** For the finding type (routing conflict, extractor order, error leak, middleware order, etc.), you ran the matching checks from `beagle-rust:review-verification-protocol` (e.g. full handler signature for extractor order; surrounding error mapping before “raw error to client”). **Then** add the finding. 4. **Output shape** — **Pass when:** The report lines match **Output Format** below (severity + description). ## Output Format Report findings as: ```text [FILE:LINE] ISSUE_TITLE Severity: Critical | Major | Minor | Informational Description of the issue and why it matters. ``` ## Quick Reference | Issue Type | Reference | |------------|-----------| | Route definitions, nesting, method routing | [references/routing.md](references/routing.md) | | State, Path, Query, Json, body extractors | [references/extractors.md](references/extractors.md) | | Tower middleware, layers, error handling | [references/middleware.md](references/middleware.md) | ## Review Checklist ### Routing - [ ] Routes organized by domain (nested routers for `/api/users`, `/api/orders`) - [ ] Fallback handlers defined for 404s - [ ] Method routing explicit (`.get()`, `.post()`, not `.route()` with manual method matching) - [ ] No route conflicts (overlapping paths with different extractors) ### Extractors - [ ] Body-consuming extractors (`Json`, `Form`, `Bytes`) are the LAST parameter - [ ] `State<T>` requires `T: Clone` — typically `Arc<AppState>` or direct `Clone` derive - [ ] `Path<T>` parameter types match the route definition - [ ] `Query<T>` fields are `Option` for optional query params with `#[serde(default)]` - [ ] Custom extractors implement `FromRequestParts` (not body) or `FromRequest` (body) - [ ] **Edition 2024**: Custom extractors use native `async fn` in trait impls (no `#[async_trait]` needed for `FromRequest`/`FromRequestParts`) ### State Management - [ ] Application state shared via `State<T>`, not global mutable statics - [ ] Database pool in state (not created per-request) - [ ] State contains only shared resources (pool, config, channels), not request-specific data - [ ] `Clone` derived or manually implemented on state type - [ ] **Edition 2024**: Shared static state uses `LazyLock` from std (not `once_cell::sync::Lazy` or `lazy_static!`) ### Error Handling - [ ] Handler errors implement `IntoResponse` for proper HTTP error codes - [ ] Internal errors don't leak to clients (no raw error messages in 500 responses) - [ ] Error responses use consistent format (JSON error body with code/message) - [ ] `Result<impl IntoResponse, AppError>` pattern used for handlers - [ ] **Edition 2024**: Handler return types `-> impl IntoResponse` capture all in-scope lifetimes by default; use `+ use<>` to opt out of capturing request lifetimes when returning owned data ### Middleware - [ ] Tower layers applied in correct order (outer runs first on request, last on response) - [ ] `tower-http` used for common concerns (CORS, compression, tracing, timeout) - [ ] Request-scoped data passed via extensions, not global state - [ ] Middleware errors don't panic — they return error responses - [ ] **Edition 2024**: Middleware using `#[async_trait]` can migrate to native `async fn` in trait impls ## Severity Calibration ### Critical - Body extractor not last in handler parameters (silently consumes body, later extractors fail) - SQL injection via path/query parameters passed directly to queries - Internal error details leaked to clients (stack traces, database errors) - Missing authentication middleware on protected routes ### Major - Global mutable state instead of `State<T>` (race conditions) - Missing error type conversion (raw `sqlx::Error` returned to client) - Missing request timeout (handlers can hang indefinitely) - Route conflicts causing unexpected 405s - **Edition 2024**: `async-trait` still used for `FromRequest`/`FromRequestParts` when native async fn works ### Minor - Manual route method matching instead of `.get()`, `.post()` - Missing fallback handler (default 404 is plain text, not JSON) - Middleware applied per-route when it should be global (or vice versa) - Missing `tower-http::trace` for request logging - **Edition 2024**: `once_cell::sync::Lazy` or `lazy_static!` used where `std::sync::LazyLock` works ### Informational - Suggestions to use `tower-http` layers for common concerns - Router organization improvements - Suggestions to add OpenAPI documentation via `utoipa` or `aide` ## Valid Patterns (Do NOT Flag) - **`#[axum::debug_handler]` on handlers** — Debugging aid that improves compile error messages - **`Extension<T>` for middleware-injected data** — Valid pattern for request-scoped values - **Returning `impl IntoResponse` from handlers** — More flexible than concrete types - **`Router::new()` per module, merged in main** — Standard organization pattern - **`ServiceBuilder` for layer composition** — Tower pattern, not over-engineering - **`axum::serve` with `TcpListener`** — Standard axum 0.7+ server setup - **Native `async fn` in `FromRequest`/`FromRequestParts` impls** — `async-trait` crate no longer needed (stable since Rust 1.75) - **`+ use<'a>` on handler return types** — Edition 2024 precise capture syntax for RPIT - **`std::sync::LazyLock` for shared static state** — Replaces `once_cell`/`lazy_static` (stable since Rust 1.80) ## Before Submitting Findings Complete **Gates (before reporting findings)** and load `beagle-rust:review-verification-protocol` for category-specific checks before any issue is final.

path: references/extractors.md
content: # Extractors

## Extractor Ordering

Body-consuming extractors (`Json`, `Form`, `Bytes`, `String`) must be the LAST parameter. The HTTP body can only be consumed once.

```rust
// BAD - Json consumes body before Path can extract
async fn handler(Json(body): Json<CreateUser>, Path(id): Path<u64>) { ... }

// GOOD - non-body extractors first, body extractor last
async fn handler(Path(id): Path<u64>, Json(body): Json<CreateUser>) { ... }
```

## Common Extractors

### State

Shared application state. The type must implement `Clone`.

```rust
#[derive(Clone)]
struct AppState {
    pool: PgPool,        // PgPool is Clone (it's an Arc internally)
    config: Arc<Config>, // wrap non-Clone types in Arc
}

async fn handler(State(state): State<AppState>) -> impl IntoResponse {
    let users = query_as!(User, "SELECT ...").fetch_all(&state.pool).await?;
    Json(users)
}
```

### Path

Extract path parameters. Type must implement `Deserialize`.

```rust
// Single parameter
async fn get_user(Path(id): Path<Uuid>) -> impl IntoResponse { ... }

// Multiple parameters
async fn get_comment(
    Path((post_id, comment_id)): Path<(Uuid, Uuid)>,
) -> impl IntoResponse { ... }

// Named parameters via struct
#[derive(Deserialize)]
struct CommentPath {
    post_id: Uuid,
    comment_id: Uuid,
}
async fn get_comment(Path(path): Path<CommentPath>) -> impl IntoResponse { ... }
```

### Query

Extract query string parameters.

```rust
#[derive(Deserialize)]
struct ListParams {
    #[serde(default = "default_page")]
    page: u32,
    #[serde(default = "default_per_page")]
    per_page: u32,
    search: Option<String>,
}

fn default_page() -> u32 { 1 }
fn default_per_page() -> u32 { 20 }

async fn list_users(Query(params): Query<ListParams>) -> impl IntoResponse { ... }
```

### Json

Deserializes JSON request body. Must be the last extractor.

```rust
#[derive(Deserialize)]
struct CreateUser {
    name: String,
    email: String,
}

async fn create_user(
    State(state): State<AppState>,
    Json(input): Json<CreateUser>,
) -> Result<impl IntoResponse, AppError> {
    let user = insert_user(&state.pool, &input).await?;
    Ok((StatusCode::CREATED, Json(user)))
}
```

### Extension

For request-scoped data injected by middleware (e.g., authenticated user).

```rust
// Middleware injects
req.extensions_mut().insert(AuthUser { id: user_id });

// Handler extracts
async fn handler(Extension(user): Extension<AuthUser>) -> impl IntoResponse { ... }
```

## Custom Extractors and `async fn` in Traits (Edition 2024)

Since Rust 1.75, `async fn` is stable in trait definitions and implementations. Custom extractors no longer need `#[async_trait]`.

```rust
// BAD (pre-1.75 / unnecessary dependency)
use async_trait::async_trait;

#[async_trait]
impl<S> FromRequestParts<S> for AuthUser
where
    S: Send + Sync,
{
    type Rejection = AppError;

    async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
        // ...
    }
}

// GOOD (Rust 1.75+ / edition 2024)
impl<S> FromRequestParts<S> for AuthUser
where
    S: Send + Sync,
{
    type Rejection = AppError;

    async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
        let [REDACTED_SECRET]
            .get("authorization")
            .and_then(|v| v.to_str().ok())
            .ok_or(AppError::Unauthorized)?;
        // validate token...
        Ok(AuthUser { id: user_id })
    }
}
```

Note: `#[async_trait]` is still needed when using `dyn Trait` with async extractors (trait objects require the indirection).

## RPIT Lifetime Capture in Handlers (Edition 2024)

In edition 2024, `-> impl Trait` captures ALL in-scope lifetimes by default. For axum handlers returning owned data, this usually doesn't matter. But when a helper function returns `impl IntoResponse` and has lifetime parameters, it may over-capture:

```rust
// Edition 2024: this captures 'a even though the return is fully owned
fn render_page<'a>(title: &'a str) -> impl IntoResponse {
    Html(format!("<h1>{title}</h1>"))
}

// If over-capture causes lifetime issues, use precise capture syntax
fn render_page<'a>(title: &'a str) -> impl IntoResponse + use<> {
    Html(format!("<h1>{title}</h1>"))
}
```

Most axum handlers take owned extractors and return owned data, so RPIT capture changes are low-impact. Watch for helper functions with reference parameters returning `impl IntoResponse`.

## Error Handling Pattern

Handlers should return `Result<impl IntoResponse, AppError>` where `AppError` implements `IntoResponse`.

```rust
#[derive(Debug)]
enum AppError {
    NotFound(String),
    Internal(anyhow::Error),
    Validation(String),
}

impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        let (status, message) = match self {
            Self::NotFound(msg) => (StatusCode::NOT_FOUND, msg),
            Self::Internal(err) => {
                tracing::error!(error = %err, "internal error");
                (StatusCode::INTERNAL_SERVER_ERROR, "internal error".to_string())
            }
            Self::Validation(msg) => (StatusCode::UNPROCESSABLE_ENTITY, msg),
        };
        (status, Json(json!({"error": message}))).into_response()
    }
}

// Automatic conversion from sqlx errors
impl From<sqlx::Error> for AppError {
    fn from(err: sqlx::Error) -> Self {
        match err {
            sqlx::Error::RowNotFound => Self::NotFound("resource not found".into()),
            other => Self::Internal(other.into()),
        }
    }
}
```

## Review Questions

1. Are body-consuming extractors the last parameter?
2. Is `State<T>` used for shared resources (not per-request creation)?
3. Do handler errors implement `IntoResponse` with appropriate status codes?
4. Are internal error details hidden from clients?
5. Are `Path` types aligned with route parameter definitions?
6. Are `Query` fields optional where the query param is optional?
7. Are custom `FromRequest`/`FromRequestParts` impls using native `async fn` instead of `#[async_trait]`?
8. Do helper functions returning `impl IntoResponse` with lifetime params need `+ use<>` precise capture?


path: references/middleware.md
content: # Middleware

## Tower Layer Pattern

Axum uses Tower for middleware. Layers wrap services to add cross-cutting concerns.

```rust
use tower_http::{
    cors::CorsLayer,
    compression::CompressionLayer,
    timeout::TimeoutLayer,
    trace::TraceLayer,
};

let app = Router::new()
    .route("/api/health", get(health))
    .nest("/api/users", users::router())
    .layer(
        ServiceBuilder::new()
            .layer(TraceLayer::new_for_http())
            .layer(TimeoutLayer::new(Duration::from_secs(30)))
            .layer(CompressionLayer::new())
            .layer(CorsLayer::permissive()) // configure properly for production
    )
    .with_state(state);
```

### Layer Ordering

Layers execute in **reverse order** of how they're added. The last `.layer()` call runs first on the request and last on the response.

```rust
Router::new()
    .layer(A)  // runs 3rd on request, 1st on response
    .layer(B)  // runs 2nd on request, 2nd on response
    .layer(C)  // runs 1st on request, 3rd on response
```

With `ServiceBuilder`, the order is top-to-bottom (more intuitive):

```rust
ServiceBuilder::new()
    .layer(C)  // runs 1st on request
    .layer(B)  // runs 2nd on request
    .layer(A)  // runs 3rd on request
```

## Common tower-http Layers

| Layer | Purpose |
|-------|---------|
| `TraceLayer` | Request/response logging with tracing spans |
| `TimeoutLayer` | Request timeout (returns 408 on timeout) |
| `CorsLayer` | CORS headers |
| `CompressionLayer` | Response compression (gzip, br, etc.) |
| `RequestBodyLimitLayer` | Limit request body size |
| `SetRequestHeaderLayer` | Add/override request headers |
| `PropagateHeaderLayer` | Copy request headers to response |

## Custom Middleware with axum::middleware

For request/response transformation with access to axum extractors:

```rust
use axum::middleware::{self, Next};

async fn auth_middleware(
    State(state): State<AppState>,
    mut req: Request,
    next: Next,
) -> Result<Response, AppError> {
    let [REDACTED_SECRET])
        .get("authorization")
        .and_then(|v| v.to_str().ok())
        .ok_or(AppError::Unauthorized)?;

    let user = validate_token(&state.pool, token).await?;
    req.extensions_mut().insert(user);

    Ok(next.run(req).await)
}

// Apply to specific routes
let protected = Router::new()
    .route("/profile", get(profile))
    .route_layer(middleware::from_fn_with_state(state.clone(), auth_middleware));

let app = Router::new()
    .route("/health", get(health))  // unprotected
    .merge(protected)
    .with_state(state);
```

## Tower Service Trait and `async fn` in Traits (Edition 2024)

Custom Tower `Service` implementations that previously required `#[async_trait]` can now use native `async fn`. However, the Tower `Service` trait itself uses `poll_ready`/`call` (not async fn), so this primarily applies to higher-level abstractions built on top of Tower.

For axum middleware specifically, `axum::middleware::from_fn` already uses plain async functions and is unaffected. The benefit appears when implementing custom `FromRequestParts` extractors used within middleware:

```rust
// GOOD (edition 2024) - no #[async_trait] needed
impl<S> FromRequestParts<S> for RateLimitInfo
where
    S: Send + Sync,
{
    type Rejection = AppError;

    async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
        let ip = parts.headers
            .get("x-forwarded-for")
            .and_then(|v| v.to_str().ok())
            .unwrap_or("unknown");
        Ok(RateLimitInfo { client_ip: ip.to_string() })
    }
}
```

## FFI and `unsafe extern` (Edition 2024)

If middleware integrates with C libraries (e.g., custom TLS, hardware security modules), edition 2024 requires `unsafe extern`:

```rust
// BAD (edition 2024 — won't compile)
extern "C" {
    fn custom_tls_init() -> i32;
}

// GOOD (edition 2024)
unsafe extern "C" {
    fn custom_tls_init() -> i32;
}
```

Also, `#[no_mangle]` on exported FFI functions must become `#[unsafe(no_mangle)]`.

## Graceful Shutdown

```rust
let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await?;

axum::serve(listener, app)
    .with_graceful_shutdown(shutdown_signal())
    .await?;

async fn shutdown_signal() {
    tokio::signal::ctrl_c().await.expect("failed to listen for ctrl+c");
    tracing::info!("shutdown signal received");
}
```

## Review Questions

1. Are layers ordered correctly (especially auth before business logic)?
2. Is `tower-http` used for standard concerns (CORS, compression, tracing)?
3. Is request timeout configured for production?
4. Does custom middleware use `from_fn_with_state` for state access?
5. Is graceful shutdown implemented?
6. Are extractors used in middleware using native `async fn` instead of `#[async_trait]`?
7. Are FFI blocks in middleware written as `unsafe extern "C"` (edition 2024)?
8. Are `#[no_mangle]` attributes on exported functions written as `#[unsafe(no_mangle)]` (edition 2024)?


path: references/routing.md
content: # Routing

## Basic Routing

```rust
use axum::{routing::{get, post, put, delete}, Router};

let app = Router::new()
    .route("/users", get(list_users).post(create_user))
    .route("/users/{id}", get(get_user).put(update_user).delete(delete_user))
    .with_state(state);
```

Note: axum 0.7.x uses `{id}` path syntax. Earlier versions used `:id`.

## Nested Routers

Organize routes by domain. Each module can define its own router.

```rust
// users.rs
pub fn router() -> Router<AppState> {
    Router::new()
        .route("/", get(list).post(create))
        .route("/{id}", get(show).put(update).delete(destroy))
}

// main.rs
let app = Router::new()
    .nest("/api/users", users::router())
    .nest("/api/orders", orders::router())
    .fallback(handle_404)
    .with_state(state);
```

### Nested Router State

Nested routers must use the same state type or a subset. Use `Router::with_state()` to provide different state to nested routers:

```rust
// Sub-router with different state
let admin_router = Router::new()
    .route("/stats", get(admin_stats))
    .with_state(admin_state); // different state type

let app = Router::new()
    .nest("/admin", admin_router)  // already has its state
    .with_state(app_state);        // main app state
```

## Fallback Handlers

```rust
async fn handle_404() -> impl IntoResponse {
    (StatusCode::NOT_FOUND, Json(json!({"error": "not found"})))
}

let app = Router::new()
    .route("/api/health", get(health))
    .fallback(handle_404);
```

## Route Conflicts

Routes conflict when two patterns can match the same path. axum panics at startup when this happens.

```rust
// CONFLICT - both match /users/123
.route("/users/{id}", get(get_user))
.route("/users/{name}", get(get_user_by_name))

// SOLUTION - differentiate by prefix or use query params
.route("/users/by-id/{id}", get(get_user))
.route("/users/by-name/{name}", get(get_user_by_name))
```

## Method Routing

```rust
// Explicit per-method routing (preferred)
.route("/items", get(list_items).post(create_item))

// Method router for custom handling
use axum::routing::MethodRouter;

fn items_router() -> MethodRouter<AppState> {
    get(list_items)
        .post(create_item)
        .options(preflight)
}
```

## LazyLock for Static Route Configuration (Edition 2024)

Static route tables or regex patterns previously initialized with `once_cell::sync::Lazy` or `lazy_static!` should use `std::sync::LazyLock` (stable since Rust 1.80):

```rust
// BAD (unnecessary dependency)
use once_cell::sync::Lazy;
static ROUTE_REGEX: Lazy<Regex> = Lazy::new(|| Regex::new(r"^/api/v\d+/").unwrap());

// GOOD (std library, no extra dependency)
use std::sync::LazyLock;
static ROUTE_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^/api/v\d+/").unwrap());
```

## RPIT Capture in Router Functions (Edition 2024)

Functions returning `Router` are unaffected (concrete type). But helper functions returning `impl IntoResponse` used as fallback handlers or utility responses may over-capture lifetimes in edition 2024:

```rust
// Edition 2024: captures 'a even though response is owned
fn not_found_body<'a>(path: &'a str) -> impl IntoResponse {
    Json(json!({"error": format!("not found: {path}")}))
}

// Fix with precise capture if needed
fn not_found_body<'a>(path: &'a str) -> impl IntoResponse + use<> {
    Json(json!({"error": format!("not found: {path}")}))
}
```

## Review Questions

1. Are routes organized by domain using nested routers?
2. Is there a fallback handler for unmatched routes?
3. Are route methods explicit (`.get()`, `.post()`)?
4. Are there any route conflicts (overlapping path patterns)?
5. Is `with_state` called with the correct state type?
6. Are static route patterns using `std::sync::LazyLock` instead of `once_cell`/`lazy_static`?
7. Do helper functions returning `impl IntoResponse` with lifetime params need `+ use<>` precise capture?


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: clean (LOW)