Validation¶
Introduction¶
Arvel provides several approaches to validate your application's incoming data. The most common is to declare validation requirements on a "form request" class and let the framework run them before your handler ever executes — invalid input produces a structured 422 response automatically, and your handler only runs against data you trust.
Validation in Two Layers¶
Validation in Arvel happens in two complementary layers, and understanding the split is the key to using it well:
- The Pydantic layer handles shape and type: which fields exist, their types, lengths, ranges, patterns, required-ness at the structural level. This runs when FastAPI parses the request body into your payload model.
- The rules layer handles checks Pydantic can't do on its own — primarily database checks (does this email already exist?) and file checks (is this an image of the right dimensions?).
A form request ties both layers together and adds an authorization check.
Warning
The rules layer implements a small, specific set of rules — see Available Validation Rules. Type and shape checks (string, integer, length, email format, min/max) belong on the Pydantic payload model, not in rules(). Writing string, numeric, min:, or max: in rules() produces an "Unknown validation rule" error rather than a check. Express those with Pydantic Field(...) instead.
Form Request Validation¶
For more complex validation scenarios, you may wish to create a "form request". Form requests are custom classes that encapsulate their own validation and authorization logic. A FormRequest[T] wraps a parsed Pydantic payload of type T.
Creating Form Requests¶
Declare the payload as a Pydantic model, subclass FormRequest, and use the form request as a handler parameter:
from typing import Any
from pydantic import BaseModel, Field
from arvel.http.requests import FormRequest
from arvel.routing import Route
class StoreUserPayload(BaseModel):
email: str = Field(min_length=3, max_length=254)
code: str
class StoreUserRequest(FormRequest[StoreUserPayload]):
async def authorize(self, request: Any) -> bool:
return True
def rules(self) -> dict[str, str | list[str]]:
return {
"email": "required|unique:users,email",
"code": "required|digits:6",
}
@Route.post("/api/users")
async def store(form: StoreUserRequest) -> dict:
data = form.validated() # the typed StoreUserPayload
return data.model_dump()
That's all that's needed. When the handler is called, the framework has already parsed, validated, and authorized the request. There's no validation code in the handler at all.
The Form Request Lifecycle¶
When a route declares a FormRequest parameter, the framework runs these steps in order before invoking your handler:
- Pydantic validation. FastAPI parses the request body into the payload model. A malformed body fails here and returns
422before anything else runs. - Rules.
rules()runs against the parsed payload. Any failure raisesValidationException(422). - Authorization.
authorize()runs. ReturningFalseraisesAuthorizationException(403). - Handler. Your handler receives the fully validated form request.
Because rules run before authorization, your authorize() method can assume the payload is structurally valid.
Authorizing Form Requests¶
The form request also contains an authorize method. Within this method, you may check whether the authenticated user actually has authority to perform the action. The raw request is passed in so you can read the authenticated user from it:
class UpdatePostRequest(FormRequest[UpdatePostPayload]):
async def authorize(self, request: Any) -> bool:
user = getattr(request.state, "user", None)
return user is not None and user.is_admin
Warning
authorize() defaults to False — deny by default. You must override it to let requests through. This is deliberate (a forgotten override fails closed, not open), but it's the single most common surprise when writing your first form request.
If authorize returns False, a 403 response is returned automatically and your handler does not execute.
Accessing the Validated Data¶
Once the request passes validation, retrieve the typed payload with validated():
@Route.post("/api/users")
async def store(form: StoreUserRequest) -> dict:
payload = form.validated() # an instance of StoreUserPayload
return {"email": payload.email}
To access the raw Request inside your handler (for headers, the authenticated user, etc.), add a separate request: Request parameter alongside the form request:
The Pydantic Layer¶
Put type and shape constraints on the payload model with Pydantic's Field. These run before any rule and cover the vast majority of "validation" most people reach for:
from pydantic import BaseModel, Field
class StoreUserPayload(BaseModel):
email: str = Field(min_length=3, max_length=254, pattern=r".+@.+")
name: str = Field(min_length=1, max_length=120)
age: int = Field(ge=0, le=150)
Reserve the rules layer for what Pydantic genuinely can't express — database lookups and file inspection.
Available Validation Rules¶
The following rules are available in the rules() layer. Rules are written as pipe-delimited strings ("required|digits:6") or as a list (["required", "digits:6"]). Parameters follow a colon and are comma-separated.
| Rule | Form | Purpose |
|---|---|---|
required | required | The field must be present and non-empty. |
digits | digits:n | A string of exactly n digits. |
exists | exists:table,column | The value must exist in a database column. |
unique | unique:table,column,... | The value must not already exist. |
mimes | mimes:ext,... | An uploaded file of an allowed type. |
dimensions | dimensions:key=n,... | An image meeting size constraints. |
Warning
An unknown rule name doesn't raise — it adds a "Unknown validation rule '<name>'." detail to the error response. If you see that message, you've used a rule that isn't in this table (likely a Laravel rule that belongs on the Pydantic model).
required¶
The field under validation must be present and not "empty". A value is empty when it's None, an empty string, an empty list, or an empty dict.
digits:n¶
The field under validation must be numeric and have an exact length of n digit characters. The length parameter is required.
A None value is skipped (combine with required to forbid it).
exists:table,column¶
The field under validation must exist in the given database table and column. Both the table and column are required:
This runs an async query against the active database session. A None value is skipped.
Note
exists (and unique) need an active database session, which the normal request path provides. They check a single column == value equality — there's no support for additional WHERE conditions.
unique:table,column,except,except_column¶
The field under validation must not exist in the given table and column. This is the classic "is this email already taken?" check:
When updating a record, you'll want to ignore the row being updated. Pass the value to ignore (and, optionally, the column to compare it against — defaults to id):
A None value is skipped.
mimes:ext,...¶
The uploaded file under validation must have a MIME type corresponding to one of the listed extensions. Provide at least one extension:
The rule matches by file extension or by a known MIME type. Recognized image types are jpg/jpeg → image/jpeg, png → image/png, gif → image/gif, and webp → image/webp. A None value is skipped.
dimensions:key=n,...¶
The image file under validation must satisfy the listed dimension constraints. Constraints are written as key=value pairs:
Supported keys: min_width, max_width, min_height, max_height, width (exact), and height (exact). The rule reads the image bytes and parses PNG and JPEG headers. A non-image value fails with "must be an image"; a None value is skipped.
Rules combine naturally:
def rules(self) -> dict[str, str | list[str]]:
return {
"email": "required|unique:users,email",
"avatar": "mimes:png,jpg|dimensions:max_width=1024,max_height=1024",
}
Conditional Rules¶
Sometimes you want to run validation checks against a field only if that field is present, or only when another field has a certain value. Add conditional rules from the with_validator hook on your form request. The validator's sometimes method takes the field, the rules to apply, and a callback that receives the full data and returns whether the rules should apply:
from arvel.validation.validator import Validator
class StorePaymentRequest(FormRequest[StorePaymentPayload]):
def with_validator(self, validator: Validator) -> None:
validator.sometimes(
"card_number",
"required|digits:16",
lambda data: data.get("payment_method") == "card",
)
The card_number rules only run when payment_method is "card".
Customizing Messages and Attributes¶
Override messages() to customize the error text for a specific field/rule pair, keyed as field.rule. Override attributes() to provide human-friendly field labels that get substituted into messages:
class StoreUserRequest(FormRequest[StoreUserPayload]):
def rules(self) -> dict[str, str | list[str]]:
return {"email": "required|unique:users,email"}
def messages(self) -> dict[str, str]:
return {"email.unique": "That email is already registered."}
def attributes(self) -> dict[str, str]:
return {"email": "email address"}
Manual Validation¶
To validate data outside a form request, construct a Validator directly. Its validate method returns a list of error details — an empty list means the data is valid:
from arvel.validation.validator import Validator
details = await Validator({"email": "[email protected]"}).validate({"email": "unique:users,email"})
if details:
# [{"field": "email", "issue": "The email has already been taken."}]
...
The Validation Error Response¶
A failed validation produces a ValidationException (422) whose body follows the framework's standard error envelope:
{
"error": {
"code": "VALIDATION_FAILED",
"message": "Validation failed.",
"details": [
{"field": "email", "issue": "The email has already been taken."}
]
}
}
FastAPI's body-parsing errors (the Pydantic layer) are normalized into the same shape, so the client sees one consistent error format regardless of which layer rejected the input.
Generating Form Requests¶
Scaffold a form request with: