Suprnova validates request input on two complementary tracks:
- Derive validation -
#[validate(...)]attributes on aFormRequeststruct, run automatically byextract(). This is the everyday path and is covered in Requests. It handles per-field rules (email,length,range, …) declaratively. - Rule objects + the
validate!macro - plain values implementingRule/ContextualRule/AsyncRule, composed imperatively. Reach for these when you need cross-field logic, rules that touch the database, or rules you want to store and pass around.
The two tracks accumulate into the same
ValidationErrors bag and render the same
Laravel/Inertia { "message", "errors": { field: [...] } } shape (HTTP
422).
Rule objects
A rule is a value implementing one of three traits:
| Trait | Shape | Use |
|---|---|---|
Rule |
passes(&self, value: &str) |
pure check on one value |
ContextualRule |
passes(&self, value, ctx) |
check that reads sibling fields |
AsyncRule |
async passes(&self, value) |
check that .awaits (DB, HTTP) |
Built-in Rules: Required, Email, Min, Max, Between, In,
NotIn, Integer, Numeric, Boolean, Alpha, AlphaNum, Url,
HttpUrl, Uuid. Built-in ContextualRules: RequiredIf,
RequiredWith, RequiredUnless, Same, Different, Confirmed.
Built-in AsyncRule: Unique.
use ;
Email.passes?; // Ok(())
Note:
Numericaccepts a finite number -NaN,inf, and magnitudes that overflow to infinity are rejected, even though Rust's parser would accept the strings. UseHttpUrl(notUrl) for callback/webhook/avatar inputs:Urlparses any schemeurl::Urlaccepts (file:,javascript:, custom URIs), whileHttpUrlrequireshttp/https.
Writing your own rule
A custom rule is a unit (or data-carrying) struct with one impl. The
trait gives you check() for free - it pushes any failure message onto
a ValidationErrors bag under the named field - so the rule plugs
into validate! and the after_validation hooks unchanged:
use ;
;
// Now usable everywhere:
StartsWith.passes?;
// or, in a validate! row:
// stripe_id => Required, StartsWith("acct_");
A String converts into a ValidationMessage that renders verbatim,
which is all a single-language app needs. To have the message translated
per locale, return a keyed message instead -
ValidationMessage::keyed("validation-starts-with").arg("prefix", self.0).fallback(…) -
and define the id in lang/<locale>/validation.ftl. See
Localization, which also covers overriding the
built-in rules' messages and the field-<name> naming convention.
For cross-field logic, implement [ContextualRule] instead - the
passes method gets a &FormContext (a HashMap<String, String> of
sibling field values) alongside the value under test. For
database-backed checks, implement [AsyncRule] and use it from
after_validation_async.
The validate! macro
validate! runs a chain of rules over the fields of a struct, accumulating
every failure into one ValidationErrors. It's the idiomatic home for the
synchronous cross-field hook, after_validation.
use ;
Each row is one of three shapes:
field => Rule1, Rule2;- required-shape. Rules run on&self.fielddirectly (forString,i64, or anything that derefs to the rule's expected borrow).field ?: Rule1, Rule2;- optional. The field isOption<T>; rules run only when it isSome, and are skipped entirely onNone. This is Laravel's "if present, validate" (sometimes) semantics.field ?=> Rule1, Rule2;- conditional-presence. Also for anOption<String>field, but rules run even whenNone(absence is treated as the empty string). This is the row for presence-conditional rules likeRequiredIfthat must be able to fail an absent field - the case?:cannot express because it skips onNone.
A contextual rule is followed by => with $ctx (an
&HashMap<String, String> of sibling values). The macro is synchronous -
for async rules use the hook below.
Warning: A common trap: writing
card_number ?: RequiredIf {...} => with ctx;. On a?:row,Noneskips all rules, soRequiredIfcan never fail an absent field. Use?=>for any rule that must fire on absence.
Cross-field hooks
FormRequest runs two cross-field hooks after the derived per-field rules,
both in the normal and Precognition flows. extract() runs the stages in
order - derived validate(), then after_validation, then
after_validation_async - and bails at the first failing stage.
use ;
use Deserialize;
use Validate;
Note: Override hooks need a hand-written
impl FormRequest- the#[request]attribute and#[derive(FormRequest)]generate their own (empty) impl, so they're for the common no-override case only.
Async rules in requests
The validate! macro can't weave in .await, so database-backed rules run
in after_validation_async - the final validation stage, which extract()
calls automatically. This is where Unique and any
custom AsyncRule participate in automatic request validation; no
per-handler plumbing required.
use ;
use Deserialize;
use Validate;
Because the async stage runs only after the synchronous stages pass, a
malformed value (a syntactically invalid email) never reaches the database
Unique query.
The Unique rule
Unique checks that a value does not already exist in a table. Build it
with Unique::new(table, column) and refine with the fluent API:
use Unique;
// email must be unique, ignoring the row currently being edited
new.ignore
// email unique *per tenant*, compared case-insensitively
new
.where_eq
.case_insensitive
| Builder method | Effect |
|---|---|
.ignore(id) |
exclude the row whose id equals id (edit-self case) |
.ignore_with_column(col, id) |
exclude on a non-id key column |
.where_eq(col, value) |
scope the check to rows where col = value; multiple calls AND together |
.case_insensitive() |
compare with LOWER(col) = LOWER(?) |
Table, column, the exclusion key, and every where_eq column are validated
against an identifier allowlist before they reach the SQL string; the value
under test and all scope values are bound parameters.
Unique is advisory - the database constraint is the guarantee
Unique runs a SELECT COUNT(*) before the write, so it carries an
unavoidable time-of-check/time-of-use race: two concurrent requests can
both pass the check and then both insert. Laravel's unique rule has the
identical property. The only real guarantee is a UNIQUE constraint
(or unique index) on the column in your migration.
Use the three together:
- The advisory rule - a fast, friendly "that email is taken" message before submit (and so Precognition can validate the field).
- The
UNIQUEconstraint - the authoritative guard against the race. FrameworkError::from_unique_violation- at the write site, map the constraint violation the loser of a race receives back to the same clean 422, instead of leaking a 500:
use FrameworkError;
// `users.email` has a UNIQUE constraint in the migration.
let user = new_user
.insert
.await
.map_err?;
from_unique_violation returns a 422 Validation error when the database
error is a unique-constraint violation, and passes any other error through
unchanged (MySQL, Postgres, and SQLite are all recognized).
Async authorization
FormRequest::authorize(&Request) -> bool runs before the body is
parsed, so it can reject unauthorized requests without reading the payload.
It is synchronous by design: at that point the request still holds the
streaming body, so the hook cannot .await. Authorization that needs to
hit the database or an async policy belongs in one of these places, not in
authorize:
- Middleware - runs before
extract(), isasync, and short-circuits by returningErr(response)(see Middleware). The right place for "is this user allowed to reach this route at all". - The Gate - call
Gate::allows_async/Gate::authorize_asyncin the handler once you have the authenticated user and the resource (see Authorization). after_validation_async- for an authorization check that depends on the parsed request body, run it in the async hook alongside your other async rules.
Design notes
- Partial validation. A
FormRequestdeserializes into a typed struct before validation runs, so the struct is the schema: a field that may be absent must beOption<T>. This is also what lets Precognition validate a partial payload - make the fields a draft can omit optional. - Rule messages. Built-in rules return keyed messages
(
validation-minplus its arguments and an English fallback), resolved through the catalog at the serialization boundary. Translate or reword any of them by defining the same id inlang/<locale>/validation.ftl- no rule wrapping. See Localization. Min/Max/Betweenare string-length rules (counted in Unicode scalar values). For numeric bounds, validate with#[validate(range(...))]on the derive or a custom rule - the length rules are not value comparisons.
Summary
| Task | API |
|---|---|
| Per-field rules | #[validate(...)] on the FormRequest (see Requests) |
| Composed / cross-field rules | validate! { self => ... } |
| Optional "if present" | field ?: Rule; |
| Conditionally-required optional | field ?=> Rule => with ctx; |
| Async / DB-backed rule | after_validation_async + AsyncRule::check_async |
| Uniqueness | Unique::new(t, c) + UNIQUE constraint + from_unique_violation |
| Async authorization | middleware / Gate::*_async / after_validation_async |
Next
- Requests - the
#[request]/#[derive(FormRequest)]surface, the everyday derived-validation path - Data Objects -
#[derive(Data, Validate)]for one struct that's both an inbound request and an outbound DTO - Error Model - how
ValidationErrorsbecomes the 422 JSON body, alongside every other error path - Localization - translating rule messages, the
field-<name>convention, and keyedValidationMessages - Authorization -
Gate,Policy, and where authorization belongs relative to validation - Middleware - the right place for "is this request
even allowed through" checks that need
.await
