SuperAPI AI Directory

axum-impl-validation

Office & productivity

View repository

Updated 10-15 22:17

About this skill

axum-impl-validation

Overview

Axum has no built-in request-body validation. A handler argument of Json<T> guarantees only that the body was syntactically valid JSON and deserialized into T. It does NOT guarantee the values make sense: an empty name, an email with no @, an age of 9000 all deserialize cleanly.

The idiomatic solution is the validator crate plus a small custom extractor. validator supplies a #[derive(Validate)] macro and a .validate() method; the custom ValidatedJson<T> extractor deserializes the body, runs .validate(), and returns 422 Unprocessable Entity on failure. Putting the check in the extractor makes "this value is validated" a type-level guarantee: if the handler argument is ValidatedJson<T>, the data is already correct.

The validator crate is version 0.20. The garde crate is a context-aware alternative covered in the decision tree below. The ValidatedJson extractor diverges between Axum 0.7 and 0.8 by exactly one line (#[async_trait]).

Quick Reference

Cargo dependency (the derive feature re-exports the Validate macro):

# validator 0.20 - the derive feature re-exports the Validate derive macro
validator = { version = "0.20", features = ["derive"] }

#[validate(...)] attributes, all verified against the validator 0.20 docs:

Attribute Syntax Checks
email #[validate(email)] HTML5-spec email format
url #[validate(url)] a parseable URL
length #[validate(length(min = 1, max = 100))] string / collection length
range #[validate(range(min = 18, max = 65))] numeric bounds
must_match #[validate(must_match(other = "password2"))] two fields are equal
contains #[validate(contains(pattern = "x"))] substring / element present
does_not_contain #[validate(does_not_contain(pattern = "x"))] substring / element absent
regex #[validate(regex(path = *RE))] matches a compiled Regex
custom #[validate(custom(function = "validate_fn"))] a user function
nested #[validate(nested)] recursively validates a sub-struct
required #[validate(required)] an Option field is Some
credit_card #[validate(credit_card)] (needs card feature) a card number
non_control_character needs unic feature no control chars

Core rules:

  • ALWAYS validate a request body through a custom extractor, NEVER inside the handler body.
  • ALWAYS return 422 Unprocessable Entity for a validation failure, NEVER 400. 400 is for malformed JSON; 422 is for well-formed-but-invalid.
  • ALWAYS make a body extractor (ValidatedJson<T>, Json<T>) the LAST handler argument. It consumes the single-use body stream.
  • ALWAYS point a regex validator at a LazyLock<Regex> static, NEVER compile the regex inline per request.
  • ALWAYS add #[async_trait] to the FromRequest impl on Axum 0.7; NEVER add it on Axum 0.8.
  • NEVER call .validate() and ignore the Result. It returns Result<(), ValidationErrors> and must be propagated.

Decision Trees

Which validation crate to use

Does a validator need runtime state (a DB handle, a tenant, a feature flag)?
  NO  -> validator 0.20    (the widely-adopted default; every example here)
  YES -> garde             (first-class context: validate(&ctx) takes a context)

validator can pass context via the optional use_context parameter on a custom validator, but garde makes a validation context a required argument of validate, so a check can depend on runtime state without global statics. For a stateless API, ALWAYS pick validator: it is the default the rest of this skill builds on.

Where to run validation

Where should .validate() be called?
  inside each handler body    -> WRONG: logic is duplicated and easily forgotten
  inside a ValidatedJson<T>   -> CORRECT: validation is a type-level guarantee
      custom FromRequest extractor

A handler taking ValidatedJson<SignupData> cannot run with unvalidated data: the extractor rejected the request before the handler was ever called. A handler taking Json<SignupData> and calling data.validate()? works, but the check is invisible in the signature and one forgotten call ships bad data.

Which status code for a validation failure

What stage failed?
  body is not valid JSON, wrong Content-Type, missing body
      -> JsonRejection keeps its own 4xx (a 400-class status)
  body parsed into T fine, but a value breaks a domain rule
      -> 422 Unprocessable Entity

400 Bad Request means the server could not parse the request. 422 means the request parsed but its content is semantically wrong. The ValidatedJson extractor MUST keep the two apart: a JsonRejection passes through with its own status, a ValidationErrors maps to 422.

Patterns

Pattern: a struct with #[derive(Validate)]

#[derive(Validate)] implements the Validate trait. Stack multiple validators on one field by comma-separating them inside one #[validate(...)]. Derive Deserialize on the same struct so it doubles as the request body.

// validator 0.20 - verified from docs.rs
use serde::Deserialize;
use validator::{Validate, ValidationError};

#[derive(Debug, Validate, Deserialize)]
struct SignupData {
    #[validate(email)]
    mail: String,
    #[validate(url)]
    site: String,
    #[validate(length(min = 1), custom(function = "validate_unique_username"))]
    #[serde(rename = "firstName")]
    first_name: String,
    #[validate(range(min = 18, max = 20))]
    age: u32,
}

Pattern: a custom validator function

A custom validator is a free function returning Result<(), ValidationError>. Construct the error with ValidationError::new("code"). The string code identifies the failure to the client.

// validator 0.20 - verified from docs.rs
fn validate_unique_username(username: &str) -> Result<(), ValidationError> {
    if username == "xXxShad0wxXx" {
        return Err(ValidationError::new("terrible_username"));
    }
    Ok(())
}

Pattern: the ValidatedJson extractor (Axum 0.8)

Axum exposes no validation hook, so wrap Json in a custom FromRequest extractor. It delegates body parsing to the built-in Json extractor (and forwards any JsonRejection with its own status), then runs .validate() and maps a ValidationErrors to 422. Because it consumes the body it implements FromRequest, NOT FromRequestParts, so it must be the last handler argument.

// axum 0.8 + validator 0.20 - native async fn in trait, NO #[async_trait]
use axum::extract::{FromRequest, Request};
use axum::extract::rejection::JsonRejection;
use axum::http::StatusCode;
use axum::Json;
use serde_json::{json, Value};

pub struct ValidatedJson<T>(pub T);

impl<S, T> FromRequest<S> for ValidatedJson<T>
where
    S: Send + Sync,
    T: serde::de::DeserializeOwned + validator::Validate,
    Json<T>: FromRequest<S, Rejection = JsonRejection>,
{
    type Rejection = (StatusCode, Json<Value>);

    async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
        // 1. delegate JSON deserialization; a JsonRejection keeps its own status
        let Json(value) = Json::<T>::from_request(req, state)
            .await
            .map_err(|rej| (rej.status(), Json(json!({ "error": rej.body_text() }))))?;

        // 2. run the validator; a ValidationErrors maps to 422
        value.validate().map_err(|e| {
            (
                StatusCode::UNPROCESSABLE_ENTITY,
                Json(json!({ "error": e.to_string() })),
            )
        })?;

        Ok(ValidatedJson(value))
    }
}

Pattern: the ValidatedJson extractor (Axum 0.7)

Axum 0.7's FromRequest trait uses #[async_trait]; Axum 0.8 removed it in favour of native return-position impl Trait in traits. The ONLY difference is the attribute on the impl block. The body of from_request is byte-for-byte identical.

// axum 0.7 + validator 0.20 - identical body, plus #[async_trait]
use async_trait::async_trait;

#[async_trait]
impl<S, T> FromRequest<S> for ValidatedJson<T>
where
    S: Send + Sync,
    T: serde::de::DeserializeOwned + validator::Validate,
    Json<T>: FromRequest<S, Rejection = JsonRejection>,
{
    type Rejection = (StatusCode, Json<Value>);

    async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
        let Json(value) = Json::<T>::from_request(req, state)
            .await
            .map_err(|rej| (rej.status(), Json(json!({ "error": rej.body_text() }))))?;
        value.validate().map_err(|e| {
            (StatusCode::UNPROCESSABLE_ENTITY, Json(json!({ "error": e.to_string() })))
        })?;
        Ok(ValidatedJson(value))
    }
}

Migration rule: porting ValidatedJson<T> from 0.7 to 0.8 means deleting the #[async_trait] line and dropping the async-trait dependency. Nothing else changes.

Pattern: using the extractor in a handler

data is guaranteed deserialized AND validated inside the handler. Because ValidatedJson is a body extractor it MUST come last; a FromRequestParts extractor such as State comes before it.

// axum 0.7 / 0.8 - the handler signature is version-stable
use axum::response::IntoResponse;
use axum::http::StatusCode;

async fn signup(ValidatedJson(data): ValidatedJson<SignupData>) -> impl IntoResponse {
    // `data` is already deserialized and validated here
    StatusCode::CREATED
}

// State first (FromRequestParts), ValidatedJson last (FromRequest):
async fn signup_with_state(
    State(db): State<Db>,
    ValidatedJson(data): ValidatedJson<SignupData>,
) -> impl IntoResponse {
    StatusCode::CREATED
}

Placing ValidatedJson before State fails to compile: only the last argument may be a FromRequest extractor.

Pattern: a structured 422 body

ValidationErrors implements Serialize, so returning it directly produces a per-field error map instead of one flat string. This is far more actionable for an API client. Replace step 2's map_err with:

// axum 0.7 / 0.8 - structured 422 body keyed by field name
value.validate().map_err(|e: validator::ValidationErrors| {
    (StatusCode::UNPROCESSABLE_ENTITY, Json(e))
})?;

The response JSON is an object whose keys are field names and whose values list each failed validation code.

Pattern: nested struct validation

#[validate(nested)] recursively validates a sub-struct field. The annotated field's type MUST itself derive Validate; its errors are folded into the parent ValidationErrors.

// validator 0.20 - the `nested` flag recurses into a sub-struct
#[derive(Validate, serde::Deserialize)]
struct Address {
    #[validate(length(min = 1, max = 80))]
    street: String,
}

#[derive(Validate, serde::Deserialize)]
struct Order {
    #[validate(length(min = 1))]
    sku: String,
    #[validate(nested)]
    address: Address,
}

Pattern: a regex validator with a static Regex

A regex validator takes path = ... pointing at a compiled Regex, NOT an inline string. ALWAYS store it in a LazyLock<Regex> static so it is compiled once, never per request.

// validator 0.20 - regex points at a LazyLock<Regex> static
use std::sync::LazyLock;          // Rust 1.80+
use regex::Regex;

static POSTCODE_RE: LazyLock<Regex> =
    LazyLock::new(|| Regex::new(r"^\d{4}\s?[A-Z]{2}$").unwrap());

#[derive(Validate, serde::Deserialize)]
struct Delivery {
    #[validate(regex(path = *POSTCODE_RE))]
    postcode: String,
}

Reference Links

  • references/methods.md: the Validate trait and .validate() signature, ValidationError and ValidationErrors, every #[validate(...)] attribute with its full parameter syntax, and the FromRequest trait shape the ValidatedJson extractor implements.
  • references/examples.md: a complete Cargo.toml, a full multi-rule struct, custom validators, nested and regex validation, both versions of the ValidatedJson extractor end to end, the structured 422 body, the handler wiring, and the garde context-aware alternative.
  • references/anti-patterns.md: real mistakes with root-cause analysis, including returning 400 for a validation failure, validating inside the handler, compiling a regex per request, putting ValidatedJson before State, omitting the derive feature, and nested on a non-Validate type.

Related skills: axum-syntax-custom-extractors (the FromRequest versus FromRequestParts split and writing custom extractors), axum-errors-extractor-rejections (JsonRejection and remapping rejection types), axum-core-version-migration (the 0.7-to-0.8 #[async_trait] removal).