Validation

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against axum 0.8.9 on Rust 1.99 (edition 2024) with validator 0.21.0 and garde 0.23.0, checked on 2026-10-06. axum’s extractors only check that input deserialises into a type. Rules such as "title is 1-200 characters" or "the publication year is not in the future" need another layer. There are three tools, and Bookshelf uses all of them, each where it fits best:

  1. types that cannot hold invalid values (a validated Isbn newtype), checked during deserialisation;

  2. declarative constraints on input structs (validator or garde), checked by an extractor;

  3. business rules that need state ("this ISBN already exists", "the member has five loans"), checked in services.

Validation with the validator crate

validator provides a Validate derive with attributes for common constraints — length, range, email, url, regex, contains, must_match, nested structs with nested — plus custom functions. The official validator example wraps it in an extractor; this version returns RFC 9457 Problem Details with one entry per invalid field:

use std::collections::BTreeMap;

use axum::{
    Json, Router,
    extract::{FromRequest, Request, rejection::JsonRejection},
    http::StatusCode,
    response::{IntoResponse, Response},
    routing::post,
};
use serde::{Deserialize, de::DeserializeOwned};
use serde_json::json;
use validator::{Validate, ValidationError};

/// `Json<T>` + `T::validate()`; rejects with 400/415/422 Problem Details.
pub struct ValidatedJson<T>(pub T);

pub enum ValidationRejection {
    Json(JsonRejection),
    Invalid(validator::ValidationErrors),
}

impl IntoResponse for ValidationRejection {
    fn into_response(self) -> Response {
        match self {
            ValidationRejection::Json(r) => {
                let body = json!({ "title": "Invalid request body", "status": r.status().as_u16(), "detail": r.body_text() });
                (r.status(), [("content-type", "application/problem+json")], body.to_string()).into_response()
            }
            ValidationRejection::Invalid(errors) => {
                // { "title": ["length must be between 1 and 200"], "year": ["..."] }
                let mut fields: BTreeMap<String, Vec<String>> = BTreeMap::new();
                for (field, errs) in errors.field_errors() {
                    let messages = errs.iter().map(|e| e.message.as_deref().unwrap_or(&e.code).to_owned()).collect();
                    fields.insert(field.to_string(), messages);
                }
                let body = json!({
                    "type": "https://bookshelf.example/problems/validation",
                    "title": "Validation failed",
                    "status": 422,
                    "errors": fields,
                });
                (StatusCode::UNPROCESSABLE_ENTITY, [("content-type", "application/problem+json")], body.to_string())
                    .into_response()
            }
        }
    }
}

impl<S, T> FromRequest<S> for ValidatedJson<T>
where
    S: Send + Sync,
    T: DeserializeOwned + Validate,
{
    type Rejection = ValidationRejection;

    async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
        let Json(value) = Json::<T>::from_request(req, state).await.map_err(ValidationRejection::Json)?;
        value.validate().map_err(ValidationRejection::Invalid)?;
        Ok(ValidatedJson(value))
    }
}

// ---- the input type ----
#[derive(Debug, Deserialize, Validate)]
#[validate(schema(function = "subtitle_differs_from_title"))]
struct NewBook {
    #[validate(length(min = 1, max = 200, message = "length must be between 1 and 200"))]
    title: String,
    #[validate(length(max = 200))]
    subtitle: Option<String>,
    #[validate(length(min = 1, max = 5, message = "between one and five authors"))]
    authors: Vec<String>,
    #[validate(range(min = 1450, max = 2100, message = "year must be between 1450 and 2100"))]
    year: u16,
    #[validate(url)]
    cover_url: Option<String>,
}

// cross-field rule: runs after the field rules
fn subtitle_differs_from_title(book: &NewBook) -> Result<(), ValidationError> {
    if book.subtitle.as_deref() == Some(book.title.as_str()) {
        let mut err = ValidationError::new("subtitle_same_as_title");
        err.message = Some("the subtitle must differ from the title".into());
        return Err(err);
    }
    Ok(())
}

async fn create_book(ValidatedJson(book): ValidatedJson<NewBook>) -> (StatusCode, String) {
    (StatusCode::CREATED, format!("created {book:?}\n"))
}

#[tokio::main]
async fn main() {
    let app = Router::new().route("/api/v1/books", post(create_book));
    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}
$ curl -s -H 'content-type: application/json' localhost:3000/api/v1/books \
    -d '{"title":"","authors":[],"year":3000,"cover_url":"not a url"}' | jq
{
  "errors": {
    "authors": ["between one and five authors"],
    "cover_url": ["url"],
    "title": ["length must be between 1 and 200"],
    "year": ["year must be between 1450 and 2100"]
  },
  "status": 422,
  "title": "Validation failed",
  "type": "https://bookshelf.example/problems/validation"
}
  • Field errors are collected in one pass, so the client sees all problems at once — important for forms.

  • A struct-level schema function only runs when the field rules pass, and its errors appear under the key __all__ ({"errors":{"__all__":["the subtitle must differ from the title"]}} for the example above); map that key to something friendlier if clients display it.

  • Deserialisation failures (wrong types, missing required fields) never reach validate(); they are 422 responses from Json itself, with a different body. Making every field an Option and validating presence gives uniform messages at the cost of more code.

The same with garde

garde is a newer alternative with a similar derive, mandatory rules on every field (a field without a rule is a compile error unless marked #[garde(skip)]), contexts for rules that need runtime data, and error paths for nested values (authors[2]):

use axum::{Json, Router, http::StatusCode, response::IntoResponse, routing::post};
use garde::Validate;
use serde::Deserialize;

#[derive(Debug, Deserialize, Validate)]
struct NewReview {
    #[garde(range(min = 1, max = 5))]
    rating: u8,
    #[garde(length(chars, min = 10, max = 5000))]
    text: String,
    #[garde(inner(length(min = 1, max = 30)), length(max = 10))]
    tags: Vec<String>,
    #[garde(skip)]
    spoiler: bool,
}

async fn add_review(Json(review): Json<NewReview>) -> impl IntoResponse {
    match review.validate() {
        Ok(()) => (StatusCode::CREATED, "review added\n".to_owned()),
        Err(report) => {
            let lines: Vec<String> = report.iter().map(|(path, err)| format!("{path}: {err}")).collect();
            (StatusCode::UNPROCESSABLE_ENTITY, lines.join("\n") + "\n")
        }
    }
}

#[tokio::main]
async fn main() {
    let app = Router::new().route("/api/v1/books/{isbn}/reviews", post(add_review));
    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

Source: garde 0.23

$ curl -s -H 'content-type: application/json' localhost:3000/api/v1/books/1/reviews \
    -d '{"rating":9,"text":"short","tags":["", "classic"],"spoiler":false}'
rating: greater than 5
tags[0]: length is lower than 1
text: length is lower than 10

Source: garde 0.23

validator 0.21 garde 0.23

Unvalidated fields

allowed silently

compile error unless #[garde(skip)]

Runtime context (e.g. limits from config)

custom functions with context

first-class #[garde(context(...))]

Error paths for nested data

nested ValidationErrors maps

flat (Path, Error) list such as authors[2].name

Built-in rules

length, range, email, url, regex, credit card, must_match, …​

the same set plus inner, pattern, prefix/suffix, ascii, alphanumeric, …​

Custom messages

message = "..." per rule

from the custom function’s error

axum-valid and axum_garde provide ready-made extractors for both crates if you prefer not to write your own.

Make invalid states unrepresentable

Constraint attributes check a struct after it was built; a validated type cannot be built invalid in the first place. shelf_core::Isbn validates length, characters and the check digit in Isbn::parse, and implements Deserialize through #[serde(try_from = "String")]. Any input type that uses it rejects bad ISBNs during deserialisation, and every function that receives an Isbn can rely on it — the "parse, don’t validate" principle:

use axum::{Json, Router, routing::post};
use serde::Deserialize;
use shelf_core::Isbn;

#[derive(Debug, Deserialize)]
struct NewLoan {
    isbn: Isbn, // validated while deserialising: "978-0-441-01359-3" ok, "123" rejected
    member_id: u64,
}

async fn lend(Json(loan): Json<NewLoan>) -> String {
    format!("lending {} to member {}\n", loan.isbn, loan.member_id)
}

#[tokio::main]
async fn main() {
    let app = Router::new().route("/api/v1/loans", post(lend));
    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

Source: serde: try_from

$ curl -s -H 'content-type: application/json' localhost:3000/api/v1/loans -d '{"isbn":"978-0-441-01359-3","member_id":42}'
lending 9780441013593 to member 42
$ curl -s -H 'content-type: application/json' localhost:3000/api/v1/loans -d '{"isbn":"978-0-441-01359-4","member_id":42}'
Failed to deserialize the JSON body into the target type: isbn: the check digit does not match at line 1 column 27

Source: serde: try_from

The same Isbn works in Path<Isbn> and Query<...>, and the rest of the code base never re-checks it. Newtypes are discussed on Idioms, Patterns & API Guidelines.

HTML forms

For server-rendered forms the extractor is the same with Form<T> in place of Json<T>, but a 422 page is the wrong answer: the form must be re-rendered with the user’s input and the messages next to each field. Bookshelf extracts Form<T>, calls validate() in the handler, and renders the template with the errors; see Forms & Content Negotiation and htmx for inline validation.

Where validation belongs

  • Structural and format checks (types, lengths, ranges, formats) at the boundary, in extractors.

  • Rules that need the database (uniqueness, ownership, quotas) in services, returning domain errors that become 409 or 403 — and backed by database constraints, because two concurrent requests can both pass an application check.

  • Output encoding, not input validation, prevents injection: templates escape HTML, sqlx binds parameters (see CSRF & Security Hardening).

Where the books are outdated

Yoon (Beginning Axum, chapter 3) relies on serde deserialisation alone and stores whatever passes it. The OWASP Input Validation cheat sheet recommends allow-list validation of every field at the boundary, which the crates above make cheap.