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:
-
types that cannot hold invalid values (a validated
Isbnnewtype), checked during deserialisation; -
declarative constraints on input structs (
validatororgarde), checked by an extractor; -
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();
}
Source: axum example: validator and validator 0.21
$ 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"
}
Source: axum example: validator and validator 0.21
-
Field errors are collected in one pass, so the client sees all problems at once — important for forms.
-
A struct-level
schemafunction 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 are422responses fromJsonitself, with a different body. Making every field anOptionand 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 |
Runtime context (e.g. limits from config) |
custom functions with |
first-class |
Error paths for nested data |
nested |
flat |
Built-in rules |
length, range, email, url, regex, credit card, |
the same set plus |
Custom messages |
|
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
409or403— 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).