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
[dependencies]
vld = "0.5"
vld-axum = "0.5"
axum = "0.8"
No features. Works with axum 0.8. MSRV 1.85 (see MSRV).
Example
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
| Item | Description |
|---|---|
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 |
VldJsonRejection | Rejection of every extractor: status(), body(), error(); implements IntoResponse |
prelude | The 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
VldJsonRejectionis the rejection of all extractors, not onlyVldJson(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 fromrejection.status()andrejection.error().