Web frameworks
Axum extractors that validate JSON bodies, query strings, path parameters, forms, headers and cookies with vld schemas.

Drop-in replacements for axum's Json, Query, Path and Form, plus header and cookie extractors. Behaviour shared with the other adapters (status codes, error body, coercion, header/cookie rules) is described in the web adapters overview.

Install

Cargo.toml
[dependencies]
vld = "0.5"
vld-axum = "0.5"
axum = "0.8"

No features. Works with axum 0.8. MSRV 1.85 (see MSRV).

Example

main.rs
use axum::{routing::{get, post}, Router};
use vld_axum::{VldJson, VldPath, VldQuery};

vld::schema! {
    #[derive(Debug)]
    pub struct CreateUser {
        pub name: String => vld::string().min(2).max(50),
        pub email: String => vld::string().email(),
        pub age: Option<i64> => vld::number().int().min(0).max(150).optional(),
    }
}

vld::schema! {
    #[derive(Debug)]
    pub struct UserPath {
        pub id: i64 => vld::number().int().min(1),
    }
}

vld::schema! {
    #[derive(Debug)]
    pub struct Search {
        pub q: String => vld::string().min(1).max(200),
        pub page: Option<i64> => vld::number().int().min(1).optional(),
    }
}

async fn create_user(VldJson(user): VldJson<CreateUser>) -> String {
    format!("created {}", user.name)
}

async fn get_user(VldPath(path): VldPath<UserPath>) -> String {
    format!("user #{}", path.id)
}

async fn search(VldQuery(q): VldQuery<Search>) -> String {
    format!("{} (page {})", q.q, q.page.unwrap_or(1))
}

#[tokio::main]
async fn main() {
    let app = Router::new()
        .route("/users", post(create_user))
        .route("/users/{id}", get(get_user))
        .route("/search", get(search));

    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}
curl -X POST localhost:3000/users -H 'content-type: application/json' -d '{"name":"A","email":"bad"}'
# 422 {"error":"Validation failed","issues":[{"path":".name",...,"code":"too_small"},{"path":".email",...,"code":"invalid_email"}]}

The full example with all six extractors is crates/vld-axum/examples/axum_basic.rs (cargo run -p vld-axum --example axum_basic); axum_openapi.rs adds vld-utoipa.

API

ItemDescription
VldJson<T>JSON body (requires a JSON Content-Type); replaces axum::Json<T>
VldQuery<T>Query string; replaces axum::extract::Query<T>
VldPath<T>Path parameters (/users/{id}, wildcards {*rest}); replaces axum::extract::Path<T>
VldForm<T>application/x-www-form-urlencoded body; replaces axum::extract::Form<T>
VldHeaders<T>Request headers (X-Request-Id → x_request_id)
VldCookie<T>Cookies from all Cookie headers
VldJsonRejectionRejection of every extractor: status(), body(), error(); implements IntoResponse
preludeThe extractors, VldJsonRejection and vld::prelude::*

All extractors are tuple structs (VldJson(pub T)) and implement Deref / DerefMut to T. VldJson and VldForm consume the body, so put them last in the handler's arguments, as with axum's own extractors.

Body limit

VldJson and VldForm read the body through axum's Bytes extractor, so axum's DefaultBodyLimit applies (2 MiB by default):

use axum::extract::DefaultBodyLimit;

let app = Router::new()
    .route("/upload", post(create_user))
    .layer(DefaultBodyLimit::max(8 * 1024 * 1024));

Gotchas

  • VldJsonRejection is the rejection of all extractors, not only VldJson (the name is historical).
  • Path parameters that are not valid UTF-8 after percent-decoding are decoded lossily (U+FFFD) instead of failing the request with axum's 400.
  • Custom error responses: take Result<VldJson<T>, VldJsonRejection> as the argument and build your own response from rejection.status() and rejection.error().