Web adapters overview
All HTTP adapters are thin wrappers around vld-http-common: they read the request, turn it into a serde_json::Value, validate it with your schema and convert failures into the same JSON error body. Behaviour is checked by one shared conformance suite, so switching frameworks does not change what clients see.
Any type that implements VldParse works as the extractor's T: a vld::schema! struct, a #[derive(Validate)] struct, or one built with impl_rules!. No serde::Deserialize is needed.
Extractors
| Crate | JSON body | Query | Path | Form | Headers | Cookies | Extra |
|---|---|---|---|---|---|---|---|
vld-axum | VldJson | VldQuery | VldPath | VldForm | VldHeaders | VldCookie | — |
vld-actix | VldJson | VldQuery | VldPath | VldForm | VldHeaders | VldCookie | VldBodyLimit |
vld-ntex | VldJson | VldQuery | VldPath | VldForm | VldHeaders | VldCookie | VldBodyLimit |
vld-poem | VldJson | VldQuery | VldPath | VldForm | VldHeaders | VldCookie | VldBodyLimit |
vld-rama | VldJson | VldQuery | VldPath | VldForm | VldHeaders | VldCookie | ValidateJsonLayer, VldBodyLimit |
vld-rocket | VldJson | VldQuery | VldPath | VldForm | VldHeaders | VldCookie | vld_catchers() |
vld-salvo | VldJson | VldQuery | VldPath | VldForm | VldHeaders | VldCookie | — |
vld-warp | vld_json | vld_query | vld_param, vld_path | vld_form | vld_headers | vld_cookie | handle_rejection |
vld-tower | ValidateJsonLayer | — | — | — | — | — | validated() |
vld-tonic | messages: validate, validate_with | — | — | — | metadata: validate_metadata | — | metadata_interceptor |
vld-tower and ValidateJsonLayer in vld-rama are middleware: they validate the JSON body before your handler runs and store the result in the request extensions. vld-tonic works with gRPC messages and metadata instead of HTTP requests.
Status codes
| Status | When |
|---|---|
| 200 (your handler) | The value passed the schema. |
| 413 Payload Too Large | A JSON or form body is larger than the body limit. |
| 415 Unsupported Media Type | A JSON extractor got a Content-Type other than application/json or application/*+json, or a form extractor got something other than application/x-www-form-urlencoded (a missing header counts as wrong). |
| 422 Unprocessable Entity | Validation failed, or the input could not be parsed at all: malformed JSON, a form body that is not UTF-8, more than 1000 form fields. |
Media-type checks ignore case and parameters (Application/JSON; charset=utf-8 is JSON). Malformed JSON is 422, not 400: the body has the same shape as a validation error, with one parse_error issue at the root.
Framework-specific additions:
vld-towerand thevld-ramalayer pass non-JSON requests through untouched instead of answering 415;validated()returnsOption<T>.vld-warp'shandle_rejectionalso maps warp's own rejections (404, 405, 400, 411, 415) and answers 500 for anything unknown.vld-rocket'svld_catchers()adds JSON catchers for 400, 413, 415 and 422 (without them Rocket answers with its HTML error pages).vld-tonicreturnstonic::Statuswith codeINVALID_ARGUMENT.
Error body
Every 422 response has this shape (Content-Type: application/json):
{
"error": "Validation failed",
"issues": [
{ "path": ".name", "message": "String must be at least 2 characters", "code": "too_small" },
{ "path": ".items[0].qty", "message": "Number must be at least 1", "code": "too_small" }
]
}
pathis""for the root (for example malformed JSON),.fieldfor object fields and[i]for array items.messageis the issue message (default English, your custom message, or a translation if you translate the error before responding).codeis the stableIssueCode::key()— match on it in clients instead of on messages.
The other bodies:
| Status | Body |
|---|---|
| 413 | {"error": "Payload too large"} |
| 415 | {"error": "Unsupported Media Type", "message": "Expected Content-Type: application/json (or application/*+json)"} (forms: "Expected Content-Type: application/x-www-form-urlencoded") |
Each adapter's error type (VldJsonRejection, VldActixError, VldNtexError, VldPoemError, VldSalvoError, VldRejection) exposes status(), body() and error() (the underlying VldError) if you want to build your own response.
Text values and coercion
Query strings, path segments, form fields, headers and cookies are text. The adapters convert each value with a lossless rule:
| Raw value | JSON value |
|---|---|
true, false (lowercase) | boolean |
canonical integers that fit i64 / u64 (0, 42, -7) | number |
canonical decimals that round-trip through f64 (3.14, -0.5) | number |
everything else: "", null, 01234, +1555, 1e3, TRUE, 1.50, ids beyond u64 | string |
Then the value is validated with vld::schema::parse_coerced, which repairs two common mismatches and retries:
- a field whose schema expects a string but got a coerced number or boolean gets its original text back —
?q=123intovld::string()is"123",?zip=01234was never converted in the first place; - a field whose schema expects an array but got a single value is wrapped —
?tags=aintovld::array(vld::string())is["a"].
So a schema describes the real types (vld::number().int() for ?page=2, vld::boolean() for ?active=true) and string fields keep working when the text happens to look numeric. JSON bodies are not coerced: {"page": "2"} fails vld::number() unless the schema calls .coerce().
Query strings and forms
- Keys and values are form-decoded (
+is a space,%XXis decoded as UTF-8; invalid sequences becomeU+FFFD). - A repeated key becomes an array:
?tag=a&tag=b→{"tag": ["a", "b"]}. - A key ending in
[]is always an array:?tag[]=a→{"tag": ["a"]}. - A key without
=(?flag) and an empty value (?x=) are"". - Form bodies are parsed the same way and may have at most 1000 fields (more is a 422).
Path parameters
Path parameters are percent-decoded exactly once and coerced like query values. A route without parameters produces an empty object, so a schema whose fields are all optional still validates.
Headers
- Names are lowercased and
-becomes_:X-Request-Idis the fieldx_request_id. - Header names that already contain
_are ignored. Otherwise a client could sendx_user: adminand fill thex_userfield even behind a proxy that strips onlyX-User. - Repeated headers are joined with
", "before coercion. Non-UTF-8 bytes becomeU+FFFD.
Cookies
- Every
Cookieheader is read (HTTP/2 clients may send one header per cookie); they are joined before parsing. - The first occurrence of a name wins (
sid=a; sid=b→a). Browsers send the most specific cookie first; letting a later one win would allow cookie tossing from a sibling subdomain. - Names are used as-is (a cookie
session-idneeds a field namedsession-id, map it withas "session-id"inschema!). Values are unquoted, percent-decoded (+is kept) and coerced.
Body limits
VldJson and VldForm never buffer more than the limit. The default everywhere is 2 MiB (vld_http_common::DEFAULT_BODY_LIMIT, the same as axum's DefaultBodyLimit), regardless of the framework's own JSON default.
| Crate | Default | How to change |
|---|---|---|
vld-axum | axum DefaultBodyLimit (2 MiB) | .layer(axum::extract::DefaultBodyLimit::max(n)) |
vld-actix | 2 MiB | App::new().app_data(vld_actix::VldBodyLimit(n)) — actix JsonConfig / PayloadConfig do not apply |
vld-ntex | 2 MiB | ntex 4: App::new().with_config(WebAppConfig::new().set_state(VldBodyLimit(n))); ntex 3: App::new().state(VldBodyLimit(n)) |
vld-poem | 2 MiB | .data(vld_poem::VldBodyLimit(n)) on a route or endpoint |
vld-rama | 2 MiB | extractors: the vld_rama::VldBodyLimit(n) request extension (e.g. AddInputExtensionLayer); layer: ValidateJsonLayer::with_limit(n) |
vld-rocket | 2 MiB | Rocket limits.json / limits.form (Rocket.toml, ROCKET_LIMITS); a limit left at Rocket's built-in value (1 MiB / 32 KiB) counts as unset |
vld-salvo | 2 MiB | Salvo's secure_max_size (SecureMaxSize middleware, Request::set_secure_max_size, set_global_secure_max_size); Salvo's built-in 64 KiB counts as unset |
vld-tower | 2 MiB | ValidateJsonLayer::with_limit(n) |
vld-warp | 2 MiB | vld_json_limit::<T>(n) / vld_form_limit::<T>(n) |
vld-tonic | — | tonic's own max_decoding_message_size |
Compressed request bodies: vld-actix decodes Content-Encoding itself (the limit applies to the decoded size), vld-ntex does with its compress feature, the other adapters see the body as the framework delivers it.
OpenAPI
The adapters only validate. To document the same schemas, use vld-utoipa (framework-agnostic), vld-aide (aide, usually with axum) or vld-schemars. The error body structs (ValidationErrorBody, ErrorBody, ErrorWithMessage) live in vld-http-common and are vld::schema! types, so they can be registered as response schemas too.