navree

Validation

Validate request bodies and query strings with Valid<Json<T>> on garde, with class-validator style messages and unique / exists database rules.

On this page

Navree validates input with extractors. You describe the rules on a DTO with garde, take Valid<Json<T>> or Valid<Query<T>> in your handler, and the handler only runs when the input passes. When it doesn't, the client gets a 400 in the NestJS format, with one message per broken rule, written the way class-validator writes them.

Validation lives in the navree-validation crate (the validation feature of the navree facade, on by default). Valid and ValidAsync are in the prelude.

A first example

Defining the rules

A DTO is a struct that derives serde::Deserialize and garde::Validate. Every field gets a #[garde(...)] attribute with its rules, or #[garde(skip)] when it has none:

src/contacts/dto.rs

garde's derive refers to ::garde, so your app depends on garde directly. Apps created with navree new already do:

Cargo.toml

Add garde's url, regex or other features when you use the rules that need them.

Using the extractor

Wrap the usual Axum extractor in Valid. The pattern Valid(Json(input)) hands you the validated value:

src/contacts/controller.rs

What the client receives

Send a body that breaks several rules:

POST /api/v1/contacts

The handler doesn't run. The client gets this response (formatted here):

400 Bad Request

The error response format section below explains each member.

The extractors

ExtractorReadsRoot name for whole-input errors
Valid<Json<T>>a JSON body (application/json or any application/*+json)body
Valid<Query<T>>the query stringquery
Valid<Form<T>>an application/x-www-form-urlencoded body, or the query string for GET and HEADbody (query for GET and HEAD)
ValidAsync<Json<T>>, ValidAsync<Query<T>>, ValidAsync<Form<T>>the same, then the database rulesas above

T must implement serde::de::DeserializeOwned and garde::Validate<Context = ()>. Each extractor works in two steps:

  1. Deserialize. serde turns the input into T. A missing field, a wrong type or an unknown enum value fails here, as a field error.
  2. Validate. garde runs every rule of T and reports every failure at once.

Some failures are not validation errors and keep their own status: a JSON extractor answers 415 when the Content-Type header is not JSON, and a body larger than http.body_limit_bytes is a 413. See errors for those.

Form is Axum's: import it with use navree::axum::extract::Form;.

Available rules

garde's rules

Every garde rule works. These are the ones you'll use most, with the message Navree renders for a field called name:

RuleChecksMessage
length(min = 2, max = 50)string length (bytes)name must be longer than or equal to 2 characters, name must be shorter than or equal to 50 characters
length(chars, min = 2)string length in charactersas above
length(min = 1, max = 10) on a listnumber of itemsname must contain at least 1 elements, name must contain no more than 10 elements
range(min = 1, max = 100)numbersname must not be less than 1, name must not be greater than 100
requiredan Option is Somename should not be empty
emailan email addressname must be a valid email address
urla URL (garde feature url)name must be a URL address
pattern(r"^[A-Z]{3}$")a regular expression (garde feature regex)name must match ^[A-Z]{3}$ regular expression
ascii, alphanumericcharacter classesname must contain only ASCII characters, name must contain only letters and numbers
contains("x"), prefix("x"), suffix("x")substringsname must contain a x string, name must start with x, name must end with x
ip, ipv4, ipv6IP addressesname must be an ip address, name must be an IPv4 address, name must be an IPv6 address
matches(other_field)equals another fieldname must match other_field
dive, inner(...)nested structs, list itemssee nested data
custom(f)your own functionsee custom rules
skipnothing

Rules on an Option<T> only run when the value is Some; required is the rule that demands one. #[garde(skip)] is required on fields without rules, unless you put #[garde(allow_unvalidated)] on the struct.

navree::validation::rules adds rules garde lacks. Use them with custom(...); they accept String, &str, Cow<str> and Options of them, and None passes:

Rust
RuleChecksMessage
confirmed(&self.x_confirmation)the field equals its confirmation; the error goes on the field itselfpassword confirmation does not match
rules::uuida hyphenated UUIDteam_id must be a UUID
rules::date_ymda real date written exactly YYYY-MM-DDstarts_on must be a valid date in the format YYYY-MM-DD
rules::one_of(&["a", "b"])one of the listed stringsstatus must be one of the following values: a, b

Rules serde checks for you

Typed fields are checked while deserializing, before any garde rule runs. A Uuid field rejects "nope", an enum rejects unknown variants, and so on:

Input problemMessage
a required field is missing, or nulltitle should not be empty
a string where a number is expectedcount must be an integer number, ratio must be a number
a number where a string is expectedtitle must be a string
not true or falseactive must be a boolean value
not a list, not an objectlabels must be an array, address must be an object
not a UUIDowner_id must be a UUID
an unknown enum variantstage must be one of the following values: lead, won
an unknown field, with #[serde(deny_unknown_fields)]property extra should not exist

So an enum is often all the validation a field needs:

Rust

Required fields

serde stops at the first problem, so deserialization errors come one at a time and before the garde rules. A CreateContact body with only first_name and email is missing three fields, but gets only:

400 Bad Request

When you want every missing field reported at once, make the fields Options and use garde's required rule. The CRM's deal DTO does this (not_blank is one of its own custom rules, which rejects a whitespace-only title):

crm-deals/src/dto.rs

An empty body {} now reports all three fields:

400 Bad Request

Error responses

Every validation failure is a 400 Bad Request with Content-Type: application/json:

  • statusCode is 400 and error is "Bad Request".
  • message is a list of every message, ordered by field path (alphabetically), then in the order the rules failed.
  • errors maps each field path to its messages. Nested fields use dots and list indexes: customer.address.city, items.1.quantity.
  • The request id is in the X-Request-Id header, not the body.

Problems with the input as a whole use the root name. A malformed JSON body gives:

400 Bad Request

Query strings work the same way. GET /api/v1/contacts?search=a&per_page=500 gives:

400 Bad Request

The same format is used for every 400 with field errors, including the ones you build yourself and the ones navree-auth returns. The errors page describes the error format as a whole.

Custom messages

The built-in messages follow class-validator: the field's path, then the rule, with no capital letter and no full stop. You can replace any of them, for every field or for one field, and you can give fields display names.

Overriding messages

Message templates are keyed by rule (required, min.string) or by field and rule (email.unique). A field-specific template wins over a rule template, which wins over the built-in one:

src/app.rs

With these options a missing first_name reads Please fill in first name..

From configuration

ValidationModule::from_config(), or a plain .module(ValidationModule), reads the validation section, so you can keep messages out of your code:

config/app.toml

ValidationModule::for_root() uses the built-in messages and ignores the section. Without ValidationModule in the app, the extractors use the built-in messages.

Placeholders

PlaceholderValue
:attributethe field's display name: its path (first_name, items.0.sku) unless attributes names it
:Attributethe same, first letter in upper case
:min, :maxthe rule's bounds
:otherthe other field of matches (shown by its display name)
:valuesthe allowed values (in), or the prefix or suffix
:format, :pattern, :valuethe date format, the regular expression, the contains text

Placeholders the rule doesn't fill are left as they are.

Message keys

These are the keys you can override, with their built-in templates:

KeyTemplate
required:attribute should not be empty
min.string, max.string:attribute must be longer than or equal to :min characters, :attribute must be shorter than or equal to :max characters
min.array, max.array:attribute must contain at least :min elements, :attribute must contain no more than :max elements
min.numeric, max.numeric:attribute must not be less than :min, :attribute must not be greater than :max
email, url, ip, ipv4, ipv6, phone, credit_card:attribute must be a valid email address, :attribute must be a URL address, ...
regex:attribute must match :pattern regular expression
alpha_num, ascii:attribute must contain only letters and numbers, :attribute must contain only ASCII characters
contains, starts_with, ends_with:attribute must contain a :value string, :attribute must start with :values, :attribute must end with :values
same, confirmed:attribute must match :other, :attribute confirmation does not match
in:attribute must be one of the following values: :values
uuid, date_format, date:attribute must be a UUID, :attribute must be a valid date in the format :format, :attribute must be a valid ISO 8601 date string
string, integer, numeric, boolean, array, object, json:attribute must be a string, :attribute must be an integer number, ...
prohibitedproperty :attribute should not exist
unique, exists:attribute has already been taken, :attribute does not exist
invalid:attribute is invalid (used for any rule without a template)

The full table is navree::validation::DEFAULT_MESSAGES.

Custom rules

A garde custom rule is a function taking the value and garde's context. Return rule_error with a rule key to get a message from the table, so it can be overridden like a built-in one. Or return garde::Error::new with your own text, which may use :attribute:

src/teams/rules.rs

even has no built-in template, so it renders the invalid one (size is invalid) until you add a message for the key even, for example even = ":value is not an even number." under [validation.messages].

Database rules

Some rules need the database: an email must not be taken yet, a company_id must point at a real company. Implement AsyncRules on the DTO and take ValidAsync<...> instead of Valid<...>:

src/users/dto.rs
src/users/controller.rs

A failed rule is a field error like any other: email has already been taken, team_id does not exist.

  • AsyncRule::unique(field, table, column, value, ignore_id) passes when no row of table has value in column. When ignore_id is Some(id), the row whose id column equals it doesn't count, which is what an update needs.
  • .ignore_path_param("id") does the same with the route's {id} parameter, when it is a UUID. An explicit ignore_id wins.
  • AsyncRule::exists(field, table, column, value) passes when some row has value in column.
  • A None value passes without a query, like Laravel's rules on a nullable field. Combine with required to demand a value.
  • Bind values with the column's type: a Uuid for UUID columns, a String for text.

How they run:

  • Only after every garde rule passes, so no query runs for input that is already invalid.
  • Inside the request's unit of work, as SELECT 1 FROM <table> WHERE <column> = ? [AND id <> ?] LIMIT 1 with bound values. The app needs DatabaseModule; without it, a rule that has a value to check answers 500.
  • Table and column names must match [a-z_][a-z0-9_]*. Anything else fails with a 500 before any SQL runs, so a name can never inject SQL. Never build them from input.

Outside a request, check a rule yourself with rule.passes(conn).await, which returns Result<bool, RuleError>.

Validating nested data

Mark a field holding another struct, or a list of structs, with dive. Use inner(...) to apply rules to each item of a list of plain values:

src/orders/dto.rs

Errors name the full path, with list indexes:

400 Bad Request (errors)

serde reports nested type errors at their path too: "quantity": "two" in the first item gives items.0.quantity must be an integer number.

To override a nested field's message or name, use its full dotted path: "customer.address.city" = "city" under [validation.attributes]. List items have their index in the path, so an override for one specific index rarely makes sense; override the rule instead.

Validating by hand

Some checks only make sense in a service: a date range, a rule that depends on the stored record. Collect them in ValidationErrors and return them as the same 400:

Rust
  • add(field, message) drops duplicates; merge(other) adds another set.
  • ValidationErrors converts into AppError (.into_app_error() or ?) and implements IntoResponse.
  • To reuse the app's templates, inject Arc<Messages> (exported by ValidationModule) and call messages.render("company_id", "exists", &[]). The CRM does this to report a soft-deleted company as company_id does not exist.
  • AppError::validation(map) builds the same response from a BTreeMap<String, Vec<String>>.

Calling garde's input.validate() yourself works anywhere, but it returns garde's own report and messages. Navree's messages come from the extractors.

Testing

TestResponse has assertions for this format. assert_validation_error(field) checks for a 400 with at least one message for the field, and assert_error(400, message) checks that the message list contains that exact message:

tests/contacts.rs

See HTTP tests for the test client.

Popular pages

  • Installation
  • Your first app
  • Routing and controllers
  • Validation
  • Authentication
  • Benchmarks