Validation
siderite validation is modelled on Pydantic v2, built from Rust traits and derives rather than runtime type inspection.
use siderite::prelude::*;
#[derive(Deserialize, Validate, Schema)]#[serde(rename_all = "camelCase")]#[model_config(extra = "forbid", str_strip_whitespace)]struct CreateUser { #[field(min_length = 2, max_length = 50)] user_name: String, #[field(email)] email: String, #[field(ge = 13)] age: Option<u8>,}Json<CreateUser> as a handler argument runs the whole pipeline. When the
input is invalid, the handler is not called. The client gets a 422
problem document that lists every error, not just the first:
{ "type": "about:blank", "title": "Unprocessable Entity", "status": 422, "detail": "The request could not be processed; see `errors` for details.", "errors": [ {"location": ["body", "userName"], "code": "too_short", "message": "should have at least 2 items/characters"}, {"location": ["body", "email"], "code": "invalid_email", "message": "invalid email address"}, {"location": ["body", "role"], "code": "extra_forbidden", "message": "extra inputs are not permitted"} ]}Error locations use the key the client actually sent (an alias if one was
used). Codes follow Pydantic names where one exists (int_parsing,
missing, extra_forbidden).
Pipeline
Section titled “Pipeline”- Model before-validators on the raw JSON / query / form value.
- Field walk — aliases, missing keys, extra keys.
- Field before-validators, then type-driven
prepare: each type checks, and in lax mode coerces, its own part of the input (integers accept"42"and3.0;boolaccepts"yes","on",1; strings apply the model’s trim and case settings; constrained types check bounds). Coercion works through newtypes and aliases. All errors are collected before anything is deserialized. - Field constraints on the raw value (
min_length,ge, …). - Serde deserialize. A property test checks that when
prepareaccepts an input, Serde accepts it too. - Field after-validators, then model after-validators.
Query strings, forms, and path segments count as text input:
string-to-number coercion is always allowed for them, even in strict mode.
A key that is repeated in a query string fills a Vec field.
#[model_config(...)]
Section titled “#[model_config(...)]”| Key | Meaning |
|---|---|
strict |
No lax coercion: "1" is not an integer, and 1 is not a bool |
extra = "ignore" | "forbid" | "allow" |
Unknown keys. Rust structs cannot store extras, so allow accepts them and then drops them. #[serde(deny_unknown_fields)] implies forbid, and forbid sets additionalProperties: false |
populate_by_name |
For aliased fields, the Rust field name is also accepted |
str_strip_whitespace, str_to_lower, str_to_upper |
Applied to every string field of this model |
hooks |
Required on generic models that have #[model_hooks] |
Settings apply only to the model they are declared on. Nested models use their own settings.
#[field(...)] validation keys
Section titled “#[field(...)] validation keys”The same attribute is read by Validate and Schema, so the constraints
you check are the constraints that get documented. Key names always come
from Serde (rename, rename_all).
| Key | Validation | Schema |
|---|---|---|
min_length, max_length |
Characters in a string, or items in a list or map | minLength / maxLength, or minItems / maxItems |
pattern = "regex" |
Whole-value match. The regex is checked when the macro expands | pattern |
email |
Email format | format: email |
gt, ge, lt, le |
Numeric bounds | exclusiveMinimum, minimum, exclusiveMaximum, maximum |
multiple_of |
Divisibility | multipleOf |
validation_alias = "x" |
Extra accepted input key | — |
default = expr, default_factory = path |
Field becomes optional; Serde must also default it | Left out of required |
strict |
Strict mode for this field only | — |
exclude |
Left out of dump output |
— |
validator = path |
Reusable after-validator, fn(&T) -> Result<(), FieldError> |
— |
title, description, examples(...) |
— | Annotations |
The combined Validate + Model + Schema key list is in Field attributes.
#[model_hooks]impl CreateUser { #[field_validator("user_name", mode = "after")] fn not_reserved(value: &str) -> Result<(), FieldError> { if value == "admin" { Err(FieldError::new("reserved", "name is reserved")) } else { Ok(()) } }
#[model_validator(mode = "after")] fn adult_email(&self) -> Result<(), FieldError> { Ok(()) }
#[computed_field] fn display_name(&self) -> String { format!("@{}", self.user_name) }}Order:
- Model before-validators.
- For each field, in declaration order: before-validators, type
prepare, constraints. - Serde deserialization.
- Field after-validators, in declaration order.
- Model after-validators.
The derives find #[model_hooks] on concrete types without being told. A
generic model such as Page<T> with hooks must declare
#[model_config(hooks)]. Field validators and serializers take
(value: &FieldTy). A reusable #[field(validator = path)] runs right
after the field’s own checks, before the #[model_hooks] validators.
Serialization: Dump
Section titled “Serialization: Dump”Json<T> responses serialize through Dump, not directly with Serde.
#[derive(Schema)]implementsDumpfor any type that also derivesSerialize. Input-only types still compile.#[schema(no_dump)]opts out.- Computed fields, field serializers, and model serializers are included,
including those of nested models inside
Vec,Option, or maps. JsonDump(value, DumpOptions::new().exclude_none())controls output per response. Options:exclude_none,exclude_defaults,include(FieldSet),exclude(FieldSet).- A hand-written type opts in with
impl Dump for T {}, which uses plain Serde output.
Computed fields appear in OpenAPI as readOnly properties.
exclude_unset is not available: Serde does not record which fields were
explicitly set.
Ordinary derived models and lists now write response JSON directly without
building an intermediate JSON tree. The encoded body remains buffered until
serialization succeeds. Computed fields, serializer hooks, and
explicit dump options retain their existing behavior. Hand-written Dump
implementations remain compatible: the default serialize_dump method calls
dump, so custom output is never silently replaced by plain Serde output.
DumpSerialize(&value, &options) in validation::dump exposes the same
serialization path for other Serde consumers.
Partial updates: Change<T>
Section titled “Partial updates: Change<T>”A PATCH body needs to tell a field that was left out from one sent as null. Change<T> does: mark the field #[serde(default)], and an absent field is Change::Unchanged while a present one is Change::Set(value), with the value read as T reads it. So Change<Option<T>> is Set(None) for null (clear a nullable column), and for a T that does not accept null (String, numbers, bool) the body fails with 422. prepare, validate, #[field(...)] constraints and the schema are T’s; the field is not required in the schema because of #[serde(default)]. change.apply(&mut row.field) stores a set value and leaves the field alone otherwise.
#[derive(Deserialize, Validate, Schema)]#[serde(deny_unknown_fields)]pub struct BookPatch { #[field(min_length = 1)] #[serde(default)] pub title: Change<String>, #[serde(default)] pub pages: Change<Option<i32>>,}Constrained types
Section titled “Constrained types”| Type | Accepts |
|---|---|
ConstrainedString<MIN, MAX> |
String with MIN..=MAX characters |
Email |
Email-format string |
SecretString |
Redacted in Debug, Display, and serialized output |
BoundedI64<MIN, MAX> |
Integer in MIN..=MAX |
PositiveInt, NonNegativeInt, NegativeInt |
> 0, >= 0, < 0 |
Url, HttpUrl |
url crate; HttpUrl only http / https |
IpAddress, Ipv4Address, Ipv6Address |
IP address strings |
Uuid |
Hyphenated or simple UUID, any version |
Decimal<MAX_DIGITS, DECIMAL_PLACES> |
Exact decimals. Strict mode accepts only strings |
BoundedFloat<B: FloatBounds> |
Finite floats (UnitInterval, NonNegative, Positive, or your own) |
ConstrainedVec<T, MIN, MAX> |
List with MIN..=MAX items |
#[field(max_digits, decimal_places)] on a plain field only documents the
limits (as x- schema extensions). Use the Decimal type to enforce them.
Hand-written implementations
Section titled “Hand-written implementations”impl Validate for T {} opts a type in without checks. For text input
(query strings, forms), implement prepare as well; without it,
?flag=true remains a string. siderite::validation::model::{prepare_object, FieldSpec, check} are the building blocks the derive uses.