M Logo
📁 Rust Backend with Axum · Part 16 of 42

Rust Web Server with Axum 0.8: Validation with the validator Crate and 422

November 3, 2026

Episode 16 of the Rust Backend series, where we build the backend of a blog step by step. After episode 15 we can search posts and restore deleted ones. But our only check is "the title must not be empty". A title of ten thousand characters? Fifty tags? Today: real validation, with one clear 422 that lists every bad field. All the code below is the final, tested code from the video, ready to copy.

We continue in the same blog_api project from episode 15. One new crate today: validator.

1. Our API saves garbage

A post with a 300-character title and an empty body:

bash
cargo run
curl -i localhost:8080/posts -H 'content-type: application/json' -d "{\"title\":\"$(printf 'a%.0s' {1..300})\",\"body\":\"\"}"

201 Created. Our API saved it.

2. The validator crate

bash
cargo add validator --features derive
text
      Adding validator v0.21.0

validator checks a struct with attributes. NewPost derives Validate, and each field gets its rules, with a message for humans:

rust
use validator::Validate;

#[derive(Deserialize, Validate)]
pub struct NewPost {
    #[validate(length(min = 1, max = 200, message = "1 to 200 characters"))]
    pub title: String,
    #[validate(length(min = 1, max = 20000, message = "must not be empty"))]
    pub body: String,
    #[serde(default)]
    #[validate(length(max = 5, message = "at most 5 tags"))]
    pub tags: Vec<String>,
}

length on a String counts characters; on a Vec it counts items.

3. Our own extractor: ValidJson

Where do we call validate()? In every handler? Better: our own extractor. ValidJson works like Json, but it also validates. New file src/extract.rs:

rust
use axum::extract::{FromRequest, Request};
use axum::Json;
use serde::de::DeserializeOwned;
use validator::Validate;

use crate::error::ApiError;

pub struct ValidJson<T>(pub T);

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

    async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
        let Json(value) = Json::<T>::from_request(req, state).await?;
        value.validate()?;
        Ok(ValidJson(value))
    }
}
  • FromRequest is the trait every body extractor implements. In axum 0.8 it is a plain async fn, no #[async_trait] needed.
  • First it runs the normal Json extractor. Its JsonRejection becomes our ApiError::BadJson through ? (the #[from] from episode 5).
  • Then validate(), also with ?.
  • type Rejection = ApiError, so errors from both steps come out as our JSON.

Add mod extract; to main.rs.

4. A 422 that names every field

ApiError gets a Validation variant, made from ValidationErrors:

rust
#[error("validation failed")]
Validation(#[from] validator::ValidationErrors),

Its response is 422 Unprocessable Entity, and the body lists each field with its messages, so a form can show the error next to the right input box:

rust
ApiError::Validation(errors) => {
    let fields: BTreeMap<_, Vec<String>> = errors
        .field_errors()
        .iter()
        .map(|(field, errs)| {
            let msgs = errs.iter().map(|e| e.message.clone().unwrap_or(e.code.clone()).to_string()).collect();
            (field.to_string(), msgs)
        })
        .collect();
    return (StatusCode::UNPROCESSABLE_ENTITY, Json(json!({ "error": "validation failed", "fields": fields })))
        .into_response();
}

A BTreeMap (use std::collections::BTreeMap;) keeps the fields in alphabetical order, so the answer is the same every time. The old EmptyTitle variant goes away.

5. The handlers get simpler

create_post and update_post take ValidJson<NewPost>. The Result<Json<…>, JsonRejection> and our check function are gone:

rust
#[tracing::instrument(skip_all)]
pub async fn create_post<R: PostRepo>(
    State(repo): State<R>,
    ValidJson(new): ValidJson<NewPost>,
) -> Result<(StatusCode, Json<PostWithTags>), ApiError> {
    let post = repo.create(new).await.map_err(|err| match err.as_database_error() {
        Some(e) if e.is_unique_violation() => ApiError::Conflict("a post with this title already exists".into()),
        _ => ApiError::Database(err),
    })?;
    tracing::debug!(id = post.post.id, "created");
    Ok((StatusCode::CREATED, Json(post)))
}

Run, and send the bad post again:

bash
cargo run
text
HTTP/1.1 422 Unprocessable Entity
content-type: application/json

{"error":"validation failed","fields":{"body":["must not be empty"],"title":["1 to 200 characters"]}}

Both fields, each with its message.

6. Three spaces pass

Now a title made of three spaces:

bash
curl -i localhost:8080/posts -H 'content-type: application/json' -d '{"title":"   ","body":"Hi"}'
text
HTTP/1.1 201 Created

Three space characters have length 3. Our old check used trim(), and we lost that.

7. A custom rule: not blank

A custom rule is a function that gets the value and returns a ValidationError when it is bad:

rust
use validator::{Validate, ValidationError};

pub fn not_blank(value: &str) -> Result<(), ValidationError> {
    if value.trim().is_empty() {
        return Err(ValidationError::new("blank").with_message("must not be blank".into()));
    }
    Ok(())
}

and the title uses it next to the length rule:

rust
#[validate(length(min = 1, max = 200, message = "1 to 200 characters"), custom(function = "not_blank"))]
pub title: String,
bash
cargo run
curl -i localhost:8080/posts -H 'content-type: application/json' -d '{"title":"   ","body":"Hi"}'
text
HTTP/1.1 422 Unprocessable Entity

{"error":"validation failed","fields":{"title":["must not be blank"]}}

8. Comments too

NewComment gets the same treatment:

rust
#[derive(Deserialize, Validate)]
pub struct NewComment {
    #[validate(length(min = 1, max = 2000, message = "1 to 2000 characters"), custom(function = "not_blank"))]
    pub body: String,
}

and add_comment takes ValidJson(new): ValidJson<NewComment>. A blank comment now gets:

text
{"error":"validation failed","fields":{"body":["must not be blank"]}}

A good post still works:

bash
curl -i localhost:8080/posts -H 'content-type: application/json' -d '{"title":"A good post","body":"All fine here."}'
text
HTTP/1.1 201 Created

The final code

src/error.rs

rust
use axum::extract::rejection::{JsonRejection, QueryRejection};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::Json;
use serde_json::json;
use thiserror::Error;
use std::collections::BTreeMap;

#[derive(Debug, Error)]
pub enum ApiError {
    #[error("post {0} not found")]
    NotFound(i64),
    #[error(transparent)]
    BadJson(#[from] JsonRejection),
    #[error(transparent)]
    BadQuery(#[from] QueryRejection),
    #[error("database error")]
    Database(#[from] sqlx::Error),
    #[error("{0}")]
    Conflict(String),
    #[error("validation failed")]
    Validation(#[from] validator::ValidationErrors),
}

impl IntoResponse for ApiError {
    fn into_response(self) -> Response {
        tracing::warn!(error = %self, "request failed");
        let status = match &self {
            ApiError::NotFound(_) => StatusCode::NOT_FOUND,
            ApiError::Conflict(_) => StatusCode::CONFLICT,
            ApiError::BadJson(rejection) => rejection.status(),
            ApiError::BadQuery(rejection) => rejection.status(),
            ApiError::Validation(errors) => {
                let fields: BTreeMap<_, Vec<String>> = errors
                    .field_errors()
                    .iter()
                    .map(|(field, errs)| {
                        let msgs = errs.iter().map(|e| e.message.clone().unwrap_or(e.code.clone()).to_string()).collect();
                        (field.to_string(), msgs)
                    })
                    .collect();
                return (StatusCode::UNPROCESSABLE_ENTITY, Json(json!({ "error": "validation failed", "fields": fields })))
                    .into_response();
            }
            ApiError::Database(err) => {
                tracing::error!(?err, "database error");
                StatusCode::INTERNAL_SERVER_ERROR
            }
        };
        let message = match &self {
            ApiError::BadJson(rejection) => rejection.body_text(),
            ApiError::BadQuery(rejection) => rejection.body_text(),
            other => other.to_string(),
        };
        (status, Json(json!({ "error": message }))).into_response()
    }
}

src/extract.rs

rust
use axum::extract::{FromRequest, Request};
use axum::Json;
use serde::de::DeserializeOwned;
use validator::Validate;

use crate::error::ApiError;

pub struct ValidJson<T>(pub T);

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

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

src/handlers.rs

rust
use axum::extract::{rejection::QueryRejection, Path, Query, State};
use axum::{http::StatusCode, Json};

use crate::error::ApiError;
use crate::extract::ValidJson;
use crate::models::{Comment, Hit, ListFilter, ListParams, NewComment, NewPost, Page, Post, PostWithTags, SearchParams};
use crate::repo::PostRepo;

#[tracing::instrument(skip_all)]
pub async fn create_post<R: PostRepo>(
    State(repo): State<R>,
    ValidJson(new): ValidJson<NewPost>,
) -> Result<(StatusCode, Json<PostWithTags>), ApiError> {
    let post = repo.create(new).await.map_err(|err| match err.as_database_error() {
        Some(e) if e.is_unique_violation() => ApiError::Conflict("a post with this title already exists".into()),
        _ => ApiError::Database(err),
    })?;
    tracing::debug!(id = post.post.id, "created");
    Ok((StatusCode::CREATED, Json(post)))
}

pub async fn get_post<R: PostRepo>(State(repo): State<R>, Path(id): Path<i64>) -> Result<Json<Post>, ApiError> {
    let post = repo.get(id).await?.ok_or(ApiError::NotFound(id))?;
    Ok(Json(post))
}

pub async fn list_posts<R: PostRepo>(
    State(repo): State<R>,
    params: Result<Query<ListParams>, QueryRejection>,
) -> Result<Json<Page<Post>>, ApiError> {
    let Query(p) = params?;
    let page = p.page.unwrap_or(1).max(1);
    let per_page = p.per_page.unwrap_or(10).clamp(1, 100);
    let (items, total) = repo.list(ListFilter { page, per_page, tag: p.tag, sort: p.sort }).await?;
    Ok(Json(Page { items, page, per_page, total }))
}

pub async fn update_post<R: PostRepo>(
    State(repo): State<R>,
    Path(id): Path<i64>,
    ValidJson(new): ValidJson<NewPost>,
) -> Result<Json<Post>, ApiError> {
    let post = repo.update(id, new).await?.ok_or(ApiError::NotFound(id))?;
    Ok(Json(post))
}

pub async fn delete_post<R: PostRepo>(State(repo): State<R>, Path(id): Path<i64>) -> Result<StatusCode, ApiError> {
    if !repo.delete(id).await? {
        return Err(ApiError::NotFound(id));
    }
    Ok(StatusCode::NO_CONTENT)
}

pub async fn list_comments<R: PostRepo>(
    State(repo): State<R>,
    Path(id): Path<i64>,
) -> Result<Json<Vec<Comment>>, ApiError> {
    repo.get(id).await?.ok_or(ApiError::NotFound(id))?;
    Ok(Json(repo.list_comments(id).await?))
}

pub async fn add_comment<R: PostRepo>(
    State(repo): State<R>,
    Path(id): Path<i64>,
    ValidJson(new): ValidJson<NewComment>,
) -> Result<(StatusCode, Json<Comment>), ApiError> {
    let comment = repo.add_comment(id, new).await.map_err(|err| {
        match err.as_database_error() {
            Some(db_err) if db_err.is_foreign_key_violation() => ApiError::NotFound(id),
            _ => ApiError::Database(err),
        }
    })?;
    Ok((StatusCode::CREATED, Json(comment)))
}

pub async fn search_posts<R: PostRepo>(
    State(repo): State<R>,
    Query(p): Query<SearchParams>,
) -> Result<Json<Vec<Hit>>, ApiError> {
    Ok(Json(repo.search(&p.q).await?))
}

pub async fn restore_post<R: PostRepo>(State(repo): State<R>, Path(id): Path<i64>) -> Result<StatusCode, ApiError> {
    if !repo.restore(id).await? {
        return Err(ApiError::NotFound(id));
    }
    Ok(StatusCode::OK)
}

src/main.rs

rust
mod config;
mod error;
mod extract;
mod handlers;
mod models;
mod repo;

use axum::{routing::{get, post}, Router};
use repo::{PostRepo, SqlitePostRepo};
use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions};
use std::str::FromStr;
use tower_http::trace::TraceLayer;
use tracing_subscriber::EnvFilter;

pub fn app<R: PostRepo>(repo: R) -> Router {
    Router::new()
        .route("/posts", get(handlers::list_posts::<R>).post(handlers::create_post::<R>))
        .route(
            "/posts/{id}",
            get(handlers::get_post::<R>).put(handlers::update_post::<R>).delete(handlers::delete_post::<R>),
        )
        .route("/posts/{id}/comments", get(handlers::list_comments::<R>).post(handlers::add_comment::<R>))
        .route("/search", get(handlers::search_posts::<R>))
        .route("/posts/{id}/restore", post(handlers::restore_post::<R>))
        .with_state(repo)
        .layer(TraceLayer::new_for_http())
}

#[tokio::main]
async fn main() {
    let cfg = config::config();
    tracing_subscriber::fmt()
        .with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")))
        .init();
    let addr = format!("{}:{}", cfg.host, cfg.port);
    let options = SqliteConnectOptions::from_str(&cfg.database_url)
        .unwrap()
        .create_if_missing(true)
        .journal_mode(SqliteJournalMode::Wal);
    let pool = SqlitePoolOptions::new()
        .max_connections(5)
        .connect_with(options)
        .await
        .unwrap();
    sqlx::migrate!().run(&pool).await.unwrap();
    let app = app(SqlitePostRepo { pool });

    let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
    tracing::info!(%addr, "listening");
    axum::serve(listener, app).await.unwrap();
}

src/models.rs

rust
use serde::{Deserialize, Serialize};
use validator::{Validate, ValidationError};

#[derive(Serialize, Clone, sqlx::FromRow)]
pub struct Post {
    pub id: i64,
    pub title: String,
    pub body: String,
    pub slug: String,
    pub created_at: String,
}

#[derive(Deserialize, Validate)]
pub struct NewPost {
    #[validate(length(min = 1, max = 200, message = "1 to 200 characters"), custom(function = "not_blank"))]
    pub title: String,
    #[validate(length(min = 1, max = 20000, message = "must not be empty"))]
    pub body: String,
    #[serde(default)]
    #[validate(length(max = 5, message = "at most 5 tags"))]
    pub tags: Vec<String>,
}

#[derive(Serialize, Clone)]
pub struct Comment {
    pub id: i64,
    pub post_id: i64,
    pub body: String,
    pub created_at: String,
}

#[derive(Deserialize, Validate)]
pub struct NewComment {
    #[validate(length(min = 1, max = 2000, message = "1 to 2000 characters"), custom(function = "not_blank"))]
    pub body: String,
}

pub fn slugify(title: &str) -> String {
    let mut slug = String::new();
    for c in title.trim().to_lowercase().chars() {
        if c.is_alphanumeric() {
            slug.push(c);
        } else if !slug.ends_with('-') {
            slug.push('-');
        }
    }
    slug.trim_end_matches('-').to_string()
}

#[derive(Serialize)]
pub struct PostWithTags {
    #[serde(flatten)]
    pub post: Post,
    pub tags: Vec<String>,
}

#[derive(Deserialize)]
pub struct ListParams {
    pub page: Option<u32>,
    pub per_page: Option<u32>,
    pub tag: Option<String>,
    #[serde(default)]
    pub sort: Sort,
}

#[derive(Deserialize, Clone, Copy, Default)]
#[serde(rename_all = "lowercase")]
pub enum Sort {
    #[default]
    Newest,
    Oldest,
    Title,
}

#[derive(Serialize)]
pub struct Page<T> {
    pub items: Vec<T>,
    pub page: u32,
    pub per_page: u32,
    pub total: i64,
}

pub struct ListFilter {
    pub page: u32,
    pub per_page: u32,
    pub tag: Option<String>,
    pub sort: Sort,
}

#[derive(Serialize)]
pub struct Hit {
    pub id: i64,
    pub title: String,
    pub snippet: String,
}

#[derive(Deserialize)]
pub struct SearchParams {
    pub q: String,
}

pub fn not_blank(value: &str) -> Result<(), ValidationError> {
    if value.trim().is_empty() {
        return Err(ValidationError::new("blank").with_message("must not be blank".into()));
    }
    Ok(())
}

Recap

  • #[derive(Validate)] with length(min, max, message) checks fields; length counts characters or items
  • custom(function = "...") runs our own rule, like not_blank
  • Our ValidJson<T> implements FromRequest: parse with Json, then validate(), both errors become ApiError
  • 422 Unprocessable Entity with a fields map tells the client exactly what to fix

Next episode: users. Sign up, with passwords stored safely using argon2. Watch the series on YouTube.

© 2026 All Rights Reserved By Mahmudul Alam