ferra-forge — the #[derive(FerraModel)] derive
Status: 0.2.0 Smelting.
Reader contract. This page stands on its own. A reader with only this page plus standard Rust and Sea-ORM knowledge can produce a compiling entity on the first attempt. Cross-references to ADRs and the constitution are non-load-bearing — every correctness cue is spelled out below.
ferra-forge is the Ferra proc-macro crate. It exports one
procedural derive, #[derive(FerraModel)], which binds a Rust struct
to its static ferra_core::meta::ModelMeta description. The derive
cohabits with Sea-ORM’s DeriveEntityModel on the same struct —
neither replaces the other; both read the struct.
Canonical entity shape (sibling-derive)
The canonical 0.2.0 entity is a plain Rust struct with six derives and two container attributes:
use ferra_forge::FerraModel;
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "films")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub title: String,
pub release_year: Option<i32>,
pub rating: f64,
}
// Sea-ORM boilerplate that `DeriveEntityModel` relies on.
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
Key facts:
DeriveEntityModelis the Sea-ORM half. It owns the#[sea_orm(...)]namespace, generates theEntity,Column,PrimaryKey,Relation, andActiveModeltypes, and is required on everyFerraModelstruct.FerraModelis the Ferra half. It reads#[sea_orm(primary_key)]and#[sea_orm(table_name)], plus the#[ferra(...)]namespace, and emits a staticModelMetathat every downstream Ferra crate reads.Serialize+Deserializeare required by theFerraModelsupertrait bound.ferra-forgedoes not auto-inject them — see the next section.- Visibility must be at least
pub(crate). Private structs trip class 8 of the Troubleshooting table.
Recognised field types:
| Rust type | FieldType variant | Notes |
|---|---|---|
String | String | UTF-8 string. |
i32 | I32 | 32-bit signed integer. |
i64 | I64 | 64-bit signed integer. |
f64 | F64 | 64-bit IEEE-754 float. |
bool | Bool | Boolean. |
Uuid (or ferra_core::id::Id) | Uuid | UUID, always present. |
Option<String> | OptionString | Nullable string. |
Option<i32> | OptionI32 | Nullable 32-bit integer. |
Option<i64> | OptionI64 | Nullable 64-bit integer. |
Option<f64> | OptionF64 | Nullable float. |
Option<bool> | OptionBool | Nullable boolean. |
Option<Uuid> (or Option<Id>) | OptionUuid | Nullable UUID — added in 0.6.0 Welding (FR-014/FR-024). Surfaces in OpenAPI as "type": ["string","null"] with "format": "uuid" (the OpenAPI 3.1 nullable form). Cannot be combined with #[sea_orm(primary_key)] — that contradiction emits FRG-215. |
Explicit serde derives are required
ferra-forge does not add #[derive(Serialize, Deserialize)] for
you. If you omit them, rustc fires E0277 with the span pinned to
your struct’s name:
error[E0277]: the trait bound `Model: serde::Serialize` is not satisfied
--> src/my_module.rs:7:12
|
7 | pub struct Model {
| ^^^^^ unsatisfied trait bound
|
= note: for local types consider adding `#[derive(serde::Serialize)]` to your `Model` type
note: required by a bound in `_assert_ferra_model_bounds`
The fix is always the same: add #[derive(Serialize, Deserialize)]
to the derive list.
Why not auto-inject? A user with
use serde::Serialize as S; #[derive(S)]would trip a naive name-based detector into silent double-derive and hit E0119 conflicting-impls. Delegating to the trait-bound solver is correct by construction.
Three attribute namespaces, two policies
ferra-forge reads three attribute namespaces with deliberately
different handling:
| Namespace | Owner | Purpose | Unknown-key policy |
|---|---|---|---|
#[sea_orm(...)] | Sea-ORM | Persistence shape (table name, primary key, column type) | silently ignored by ferra-forge |
#[ferra(...)] | Ferra | Exposure flags (read_only, write_only, skip, resource) | hard compile_error! |
#[field(...)] | Ferra | Declarative validation rules (min_length, email, …) — see § Validation rules | hard compile_error! (FRG-213) |
The asymmetry is load-bearing. Consider the worked example:
// Security-critical typo. The user MEANT `write_only`.
#[ferra(writeonly)]
pub password_hash: String,
Because #[ferra(writeonly)] is not recognised, the build fails:
error: `#[ferra(writeonly)]` is not a recognized attribute
--> src/models/user.rs:12:13
|
12 | #[ferra(writeonly)]
| ^^^^^^^^^
= help: did you mean `write_only`?
= note: see docs/user-guide/ferra-forge.md for the full list of recognized keys in this phase
If Ferra had accepted the typo, password_hash would quietly serialise
into API responses — a Broken Object Property Level Authorization
(OWASP API Top 10 #3). The hard-fail rule makes this class of bug
unshippable.
Conversely, an unknown-to-Ferra but Sea-ORM-permitted key such as
#[sea_orm(rename_all = "camelCase")] passes through Ferra silently
— it is Sea-ORM’s concern, not Ferra’s, and pre-validating it inside
ferra-forge would saddle Ferra with every Sea-ORM minor-release
vocabulary change.
Recognised #[ferra(...)] keys
Container-level
| Key | Value | Semantics |
|---|---|---|
resource | non-empty string literal | Override the default resource_name in the emitted ModelMeta. |
The default resource_name is the #[sea_orm(table_name)] literal
if present, otherwise snake_case(<StructIdent>) with a simple
English plural suffix (Film → films, UserProfile → user_profiles,
Category → categories, Box → boxes).
Declarative behaviors (
#[behavior(soft_delete)],#[behavior(timestampable)], and the cross-cutting cohort surface they share) are documented separately in behaviors.md. Enrolling a model in a behavior is a container-level act — declaring the behavior on the struct opts every operation on that model into the cohort’s contract.
Field-level
| Flag | readable | writable | skip_api |
|---|---|---|---|
| (none) | true | true | false |
#[ferra(read_only)] | true | false | false |
#[ferra(write_only)] | false | true | false |
#[ferra(skip)] | false | false | true |
#[ferra(read_only)] and #[ferra(write_only)] on the same field
contradict — see class 10. #[ferra(write_only)] on a primary-key
field contradicts the id’s always-readable invariant — see class 12.
Repeated identical flags are idempotent.
#[ferra(write_only)] is structurally excluded from every read
#[ferra(write_only)] is the framework’s load-bearing primitive for
fields that must be inbound-accepting but never outbound-leaking —
canonically a password hash, an API-key plaintext, or any other
write-side credential.
#[derive(DeriveEntityModel, FerraModel, Serialize, Deserialize, Clone, Debug, PartialEq)]
#[sea_orm(table_name = "users")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub email: String,
#[ferra(write_only)]
pub password_hash: String,
}
What the framework guarantees:
POST /usersandPUT /users/{id}acceptpassword_hashin the request body, validate it, and persist it through the standard insert / update pipeline.- Every read response — single-item
GET /users/{id}, collectionGET /users, and the create / update response envelopes — omitspassword_hash. There is no header, query parameter, orAccept:variant that flips the field on. - The same exclusion applies to every named projection: the
auto-derived
{Model}{Projection}ReadProjectionstruct emitted byferra-forgedoes not have the field as a member, so the type system itself blocks any future handler from re-introducing it.
Structural-by-construction, not runtime-filter
The guarantee is enforced at the Rust type-system layer, not by a
runtime serialiser flag. The ferra-forge proc-macro emits one
pub struct {Model}ReadProjection per model whose field list does
not contain any #[ferra(write_only)] field; mutation and read
handlers serialise through that struct’s allowlist before the JSON
leaves the framework. A future custom handler that hand-writes a
response cannot inadvertently leak password_hash because the field
does not exist on the type the response carries.
Prior to 0.6.5 the exclusion lived only in the OpenAPI emitter and a runtime serialiser filter; the 0.6.5 promotion to a structural guarantee closes the OWASP API #3 mitigation gap.
There is no “opt-in” — FRG-305 rejects the obvious workaround
Listing a #[ferra(write_only)] field in any projection’s
read = [...] is rejected at compile time with diagnostic FRG-305:
error: FRG-305: projection `admin` lists field `password_hash` in `read`,
but the field is declared `#[ferra(write_only)]`
= help: remove the ident from `read = [...]`, or remove `#[ferra(write_only)]`
from the field declaration
If you genuinely need the field to appear in some read surface, the
resolution is to drop #[ferra(write_only)] from the field
declaration entirely. The field then appears in every read by
default; use #[ferra(skip)] or a per-projection read = [...]
allowlist to scope the surfaces that include it. See
projections-and-routing.md for the
projection key set and the auto-derived URL prefix contract.
The full FRG-3NN diagnostic family for write-only-related mistakes
(FRG-302 through FRG-307, FRG-314) is documented in
ferra-forge-diagnostics.md.
Computed fields
A computed field is server-derived: the framework runs the
developer’s compute function after every fetch and before
serialisation, then carries the produced value out through the read
response. The field is excluded from INSERT / UPDATE SQL and
from every write projection’s struct, so submitted values in request
bodies are silently dropped — the only path to set the field is the
compute hook the developer writes.
The substrate ships at 0.6.5 Chasing per ADR-0031 and the
FerraComputed trait surface contract.
The #[ferra(computed)] attribute
Tag the field with #[ferra(computed)] and write an
impl FerraComputed for {Model} block that overrides the method
matching your computation kind:
use ferra::*;
use ferra::compute::{FerraComputed, RequestContext};
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "invoices")]
pub struct Invoice {
#[sea_orm(primary_key)]
pub id: Id,
pub subtotal: f64,
pub tax: f64,
#[ferra(computed)]
pub total: f64,
}
impl FerraComputed for Invoice {
fn compute(&mut self) {
self.total = self.subtotal + self.tax;
}
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
#[ferra(computed)] implies writable = false and read_only = true.
The framework injects a ferra_core::diagnostics::FerraComputedDefault
bound on the field’s type — see the Compile-time rejections
sub-section below for the consequence when that bound fails.
The three tiers
FerraComputed exposes three default-implemented methods covering the
spectrum from pure derivations to N+1-eliminating batched I/O. An
implementer overrides only the method matching the computation kind.
fn compute(&mut self)— synchronous, zero I/O. The right choice fortotal = subtotal + taxand other in-row derivations. Cost shape: one function call per row, no async overhead. Default: no-op.fn compute_async(&mut self, ctx: &RequestContext)— per-row async with a request-context handle. The right choice for per-row I/O (currency-rate lookup, external HTTP enrichment). Cost shape: one I/O round-trip per row — invoke this tier only when the computation genuinely cannot be batched. At 0.6.5RequestContextis an empty marker (seecompute_async_batchbelow for the pool-threading limitation); it widens in 0.7.x. Default: returnsOk(()).fn compute_async_batch(rows: &mut [Self], ctx: &RequestContext)— collection-level batch hook. Called once per read with the full slice. The default implementation iteratescompute_asyncsequentially over every row; override this method to perform a single batched I/O call across the slice. This is the N+1 elimination path.
Read pipeline
For every read (item or collection) of a model with at least one
#[ferra(computed)] field, the framework runs the pipeline in this
order:
- The repository fetches the row(s) from the data source.
- The framework calls
compute(&mut self)on each row in sequence. - The framework calls
Self::compute_async_batch(&mut rows, ctx).await?once with the full slice. - The framework serialises each row through the projection’s read struct.
Uniform-invocation guarantee. Item reads (GET /invoices/{id})
pass a one-element slice to compute_async_batch. The developer
writes one batch implementation; it covers both the item and
collection surfaces without branching.
N+1 elimination pattern
The batch hook is where you fold a per-row I/O into a single
round-trip. The canonical shape is a WHERE id = ANY($1) query
keyed on the slice’s identifiers:
use ferra::*;
use ferra::compute::{FerraComputed, FerraComputeError, RequestContext};
use std::collections::HashMap;
impl FerraComputed for FilmWithCommentCount {
fn compute(&mut self) {
// No synchronous derivation; the batch hook supplies the value.
}
async fn compute_async_batch(
rows: &mut [Self],
_ctx: &RequestContext,
) -> Result<(), FerraComputeError> {
// Collect every row's id, then fetch the matching counts in
// a single SQL round-trip.
let ids: Vec<Id> = rows.iter().map(|r| r.id).collect();
// At 0.6.5 the pool is threaded through application state; at
// 0.7.x it will live on `RequestContext`. Replace `app_pool()`
// with the pool handle of your choice.
let counts: HashMap<Id, i64> = sqlx::query_as::<_, (Id, i64)>(
"SELECT film_id, COUNT(*) FROM comments \
WHERE film_id = ANY($1) GROUP BY film_id",
)
.bind(&ids[..])
.fetch_all(app_pool())
.await
.map_err(|e| FerraComputeError::new(e.to_string()))?
.into_iter()
.collect();
for row in rows {
row.comment_count = counts.get(&row.id).copied().unwrap_or(0);
}
Ok(())
}
}
0.6.5 limitation — explicit. RequestContext is an empty marker
at this release. Until 0.7.x widens it, thread the database pool
through your application state (a tokio::sync::OnceCell<PgPool>, a
Arc<AppState> in your handlers, or the pattern of your choice) and
reach it from inside the batch hook. The contract for the trait
method does NOT change when RequestContext widens — the parameter
shape is stable; only the values reachable through it grow.
Forward-pointer to the post-v1.0 DataLoader pattern. The
compute_async_batch contract is forward-compatible with a future
request-scoped loader registry on RequestContext: a compute_async
implementation that calls ctx.loader::<L>().load(key).await will
coalesce all loads in the request into one batch_load(keys) call.
Override-based batch hooks and loader-based per-row hooks compose
without conflict.
What the framework does for you
Tagging a field #[ferra(computed)] is one declaration with effects
on both ends of the wire:
| Side | Effect |
|---|---|
| Inbound | The field is silently dropped from request bodies. POST /invoices and PUT /invoices/{id} accept subtotal + tax and ignore any client-supplied total. |
| SQL | INSERT and UPDATE statements skip the column. The database does not have to carry it — and even if your schema persists the column, the framework will not write to it; reads ignore the persisted value and replace it with the compute output. |
| Outbound | The OpenAPI document marks the field readOnly: true on every read schema. SDK generators that consume the spec (orval, openapi-generator, kiota) surface the field as read-only on the client side. |
| Read response | The response carries the value produced by the compute hook — compute() runs first, then compute_async_batch runs once across the slice, then serialisation walks the read projection. |
Missing-impl behaviour (FR-015). A model that declares
#[ferra(computed)] on a field but does not provide an
impl FerraComputed for Model { ... } block — or that provides an
empty impl FerraComputed for Model {} block adopting every default
— is not a compile error. The framework substitutes the trait’s
default methods (no-op compute, Ok(()) async hooks), and the
field is serialised with its type’s Default::default() value. The
framework never panics on this path.
Compile-time rejections
The 0.6.5 FRG-3NN diagnostic family covers the contradictions; the
full catalogue lives in
ferra-forge-diagnostics.md. The
codes that matter for computed fields:
| Code | Trigger | Fix |
|---|---|---|
FRG-303 | #[ferra(write_only)] + #[ferra(computed)] on the same field. A computed value is server-produced — it cannot also be inbound-only. | Drop one of the two flags. Keep #[ferra(computed)] if the value is derived; keep #[ferra(write_only)] if the value is client-supplied and write-only. |
FRG-309 | #[ferra(computed)] + explicit #[ferra(read_only)] on the same field. computed already implies read_only; the second flag is redundant. | Remove the redundant #[ferra(read_only)]. |
The FerraComputedDefault #[diagnostic::on_unimplemented]
triple. The framework injects a FerraComputedDefault bound on
every #[ferra(computed)] field’s type. When the chosen type does
not implement Default, rustc fails the bound and surfaces the
framework-authored diagnostic verbatim:
error[E0277]: `MyType` is used as the type of a `#[ferra(computed)]` field, but it does not implement `Default`
--> src/models/invoice.rs:8:5
|
8 | pub total: MyType,
| ^^^^^^^^^^^^^^^^^ this type needs `impl Default for MyType`
|
= note: Ferra calls `T::default()` when a computed field's compute trait is not implemented (FR-015 — missing implementations are a no-op, not a panic). The `Default` bound is required by the computed-field contract; see `docs/user-guide/ferra-forge.md#computed-fields`. Diagnostic emitted by `ferra_core::diagnostics::FerraComputedDefault`.
Every type in the recognised-field-types table above already
implements Default (numbers default to zero, String to "",
Option<T> to None, Uuid to nil, Id to a fresh v4 UUID). The
diagnostic fires only on custom field types where you’ve forgotten
the #[derive(Default)] line.
Deferred keys and when they land
The following keys are rejected in 0.2.0 (hard compile_error!)
and will arrive in a later roadmap phase. If you reach for any of
them, you get an unknown-key diagnostic — that is intentional, so you
don’t silently compile code that looks correct but does nothing.
| Key | Planned phase | Intended use |
|---|---|---|
hypermedia | 0.3.0 Casting | Control HAL _links emission per field. |
projection / projections | 0.4.0 Refining | Typed projections (ServerFields<M>). |
exposure | 0.4.0 Refining | Exposure::Strict / Exposure::Loose. |
sortable / filterable / searchable | 0.4.0+ | Query DSL primitives. |
cascade | 0.6.0+ | Relation cascade rules. |
auth | 0.8.5 | Per-model auth scopes. |
rate_limit | 0.8.5+ | Tower rate-limit layer binding. |
required | — | Never recognised — nullability is deduced from Option<T>. |
Validation rules
Ferra’s third attribute namespace, #[field(...)], declares
validation rules that fire at the API edge — before persistence,
on every POST (create) and PUT (update) request. A payload
that violates any rule is rejected with a 422 Problem+JSON body
whose errors map names every offending field; a payload that
satisfies every rule flows on to persistence unchanged. Validation
is opt-in: a model carrying no #[field(...)] attributes
behaves identically to one written before the namespace existed.
The full 422 wire shape and consumer-side branching pattern live in Error Handling § Validation failures (422); this section pins the model-side declaration grammar.
The eight rules at a glance
| Rule | Compatible field types | Default English message on violation |
|---|---|---|
min_length = N | String, Option<String> | "must be at least N character(s)" (singular when N == 1) |
max_length = N | String, Option<String> | "must be at most N character(s)" (singular when N == 1) |
min = N | i32, i64, f64, and their Option<…> | "must be at least N" |
max = N | i32, i64, f64, and their Option<…> | "must be at most N" |
pattern = "regex" | String, Option<String> | "does not match the required format" |
email | String, Option<String> | "is not a valid email address" |
url | String, Option<String> | "is not a valid URL" |
required | Option<T> only | "is required" |
Any other rule key is a compile-time error (FRG-213) carrying
a “did you mean” hint when the typo is within Levenshtein
distance 2 of a recognised name. Repeated rules of the same
kind on the same field are FRG-206. Conflicts (min_length > max_length, min > max) are FRG-210 / FRG-211. A rule
applied to an incompatible field type is FRG-207 / FRG-208
/ FRG-209 / FRG-212 depending on the rule. A wrong-shape
literal (a string where an integer is expected, an empty regex)
is FRG-201 through FRG-205. Every code surfaces with a
span anchored on the offending token, and multiple independent
mistakes on the same struct surface in a single cargo check
round-trip rather than the first-and-stop pattern.
Combined-rule example (the most common shape)
The headline call site is a single #[field(...)] carrying
multiple rules separated by commas, on a struct that already
derives FerraModel and Sea-ORM’s DeriveEntityModel:
use ferra_forge::FerraModel;
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "users")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
#[field(min_length = 1, max_length = 255)]
pub display_name: String,
#[field(min = 0, max = 120)]
pub age: i32,
#[field(email)]
pub contact_email: String,
#[field(pattern = r"^[a-z0-9-]+$")]
pub slug: String,
#[field(url)]
pub homepage: String,
#[field(required)]
pub bio: Option<String>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
Two or more #[field(...)] attributes on the same field
accumulate in source order — #[field(min_length = 1)] #[field(max_length = 255)] is identical to #[field(min_length = 1, max_length = 255)]. Two #[field(...)] attributes on
two different fields are independent; each field’s rule list is
evaluated in isolation against that field’s value, and every
violation observed on the request is aggregated into a single
422 response (Ferra never short-circuits on the first failed
rule).
Per-rule details
min_length = N and max_length = N
Fires on String and Option<String> fields. The literal N
is a non-negative integer that fits in u32. On Option<String>
the rule applies only when the value is Some(..) — a None is
not a length violation (use required to forbid None outright).
use ferra_forge::FerraModel;
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "films")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
#[field(min_length = 1, max_length = 255)]
pub title: String,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
A POST /films with {"title": ""} returns 422 with
errors.title = ["must be at least 1 character"] (singular —
length rules pluralise only when N != 1). A 300-char title
returns 422 with errors.title = ["must be at most 255 characters"].
Length rules count Unicode scalar values, not grapheme clusters or UTF-8 byte length — see § Unicode counting below.
min = N and max = N
Fires on i32, i64, f64, and their Option<…> forms. The
literal type must match the field’s declared numeric type:
min = 1.5 on an i32 field is FRG-203. Integer literals on
f64 fields are accepted (and coerce to 0.0-style values).
use ferra_forge::FerraModel;
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "ratings")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
#[field(min = 0, max = 10)]
pub score: i32,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
A POST /ratings with {"score": 99} returns 422 with
errors.score = ["must be at most 10"].
pattern = "regex"
Fires on String and Option<String> fields. The literal is
a non-empty string carrying a regex-crate
pattern (NOT PCRE). The regex crate has no lookaround and no
backreferences — the syntactic features that drive catastrophic
backtracking in PCRE-style engines are not even expressible.
A raw-string literal (r"…") is the recommended shape so
backslashes are not double-escaped.
use ferra_forge::FerraModel;
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "posts")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
#[field(pattern = r"^[a-z0-9-]+$")]
pub slug: String,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
A POST /posts with {"slug": "Bad Slug!"} returns 422 with
errors.slug = ["does not match the required format"].
Bounded-time guarantee. Every
patternevaluation completes in time linear to the input length —O(m·n)worst-case, wheremis the (compile-time-fixed) pattern size andnis the input length, per theregexcrate’s upstream contract. Combined with Ferra’s default 1 MiB request-body cap, this forecloses ReDoS without a per-request timeout. You can write anyregex-syntax pattern you like and submit it to user input; no pattern, regardless of shape, can cause unbounded matching time.
email
Bare flag on String and Option<String> fields — no value.
Delegates to the upstream HTML5-compliant email validator.
#[field(email = "x")] is FRG-214.
use ferra_forge::FerraModel;
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "contacts")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
#[field(email)]
pub email: String,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
A POST /contacts with {"email": "not-an-email"} returns
422 with errors.email = ["is not a valid email address"].
url
Bare flag on String and Option<String> fields — no value.
Delegates to the upstream url-crate-backed validator.
#[field(url = "x")] is FRG-214.
use ferra_forge::FerraModel;
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "links")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
#[field(url)]
pub href: String,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
A POST /links with {"href": "not a url"} returns 422 with
errors.href = ["is not a valid URL"].
required
Bare flag on Option<T> fields only — no value.
#[field(required)] on a non-Option field is FRG-212
(non-Option fields are required by Rust’s type system; the
flag is meaningless there). On a POST request, an absent
field or an explicit null both fail with "is required".
On a PUT request, an absent field is “no change” (Ferra’s
existing partial-update semantics) and skips per-field rules,
but an explicit null fails with "is required" — null
on the wire is a positive declaration of “set to none”, which
the rule rejects.
use ferra_forge::FerraModel;
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "profiles")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
#[field(required)]
pub bio: Option<String>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
A POST /profiles with {"bio": null} (or {}) returns 422
with errors.bio = ["is required"].
Propagation to the OpenAPI document
Every #[field(...)] rule you declare propagates into the
emitted OpenAPI 3.1 document under the canonical JSON Schema
keyword for the rule. SDK generators that consume the spec
(orval, openapi-generator, kiota, …) and UI tooling that
drives form validation off the spec (react-jsonschema-form,
JSON-Forms, …) receive the same rules the server enforces —
no parallel maintenance, no spec/server drift.
#[field(...)] rule | OpenAPI keyword | Where it lands |
|---|---|---|
min_length = N | minLength: N | properties.<field> |
max_length = N | maxLength: N | properties.<field> |
min = N | minimum: N | properties.<field> |
max = N | maximum: N | properties.<field> |
pattern = "<regex>" | pattern: "<regex>" | properties.<field> |
email | format: "email" | properties.<field> |
url | format: "uri" | properties.<field> |
required | (field name added to schema’s required: [...]) | <schema>.required |
Per-projection coverage. Keywords appear on every
projection in which the field appears: the read projection
(Film), the create-input projection (CreateFilmInput), and
the update-input projection (UpdateFilmInput). A field
excluded from one projection (for example, a #[ferra(read_only)]
field is excluded from the create-input projection because it
is not writable) does NOT appear in that projection’s
properties map — but its keywords continue to appear on the
read projection because the rules describe the persisted state,
not only the input shape.
Note on url → format: uri. JSON Schema’s vocabulary
uses uri, not url — the rule name on the Rust side stays
as the friendlier url, but the emitted spec uses the JSON
Schema canonical name. SDK generators look up format: uri.
Combined rules merge into one property schema. A field
carrying #[field(min_length = 1, max_length = 255, pattern = "^[A-Z][a-z]+$")] produces:
"title": {
"type": "string",
"minLength": 1,
"maxLength": 255,
"pattern": "^[A-Z][a-z]+$"
}
The keyword order in the JSON output is not contractual; the keyword set is.
Required behaviour. The #[field(required)] rule on an
Option<T> field adds the field name to the create-input and
update-input projections’ required: [...] arrays — it does
NOT inject a "required": true keyword on the per-property
schema (JSON Schema does not have one; required-ness is a
parent-schema concern). The same array also includes every
non-Option field, matching Rust’s “non-Option means required”
semantics on the wire.
No-rules opt-in. A model that declares zero #[field(...)]
rules emits a spec byte-identical to its 0.5.x equivalent.
Validation surfaces only when you opt in.
Default messages and localisation
Every rule emits a hard-coded English message as its violation
text — the literal strings shown in the table above. Length rules
pluralise “character” only when N != 1: min_length = 1
produces "must be at least 1 character" (singular) while
min_length = 5 produces "must be at least 5 characters"
(plural). Ferra 0.6.0 does not support per-field, per-rule
message overrides or Accept-Language-driven locale negotiation;
both are deferred to a later release.
If you need localised error text on the consumer side, branch
on the field name in the response’s errors map and rewrite
the message client-side. The default English text is a stable
contract within the 0.6.0 line — string-matching against
"must be at least", "is required", etc. is supported until
the localisation work lands. SDK consumers that have to rewrite
the body server-side can do so in a Tower middleware layer
applied after the framework’s IntoResponse runs.
Unicode counting
Length rules count characters as the underlying validation
crate counts them: chars().count() on the underlying &str,
which counts Unicode scalar values. The framework does not
interpose a custom counting policy in 0.6.0 — there is no NFC
normalisation pass, no grapheme-cluster counting, no UTF-16
code-unit counting.
The practical consequence: a 10-emoji string may not have a
length of 10 by your reader’s intuition. The grinning-face
emoji 😀 is one scalar value (one chars() count), but
some emoji sequences (skin-tone modifiers, ZWJ-joined family
emoji) are several scalar values — a single visible glyph may
count as 2, 4, or more. If your length rule needs to count
“what the user sees”, reach for the typed-escape path below
and write a hand-rolled rule that walks
unicode-segmentation
graphemes.
When the eight rules are not enough — typed escape via ferra::garde
The eight-rule namespace is a closed contract surface for
0.6.0. For rule shapes the surface does not cover — a
custom function predicate, a locale-aware rule, a regex
syntax beyond the regex crate, a grapheme-cluster-aware
length count — Ferra re-exports the underlying validation
crate at ferra::garde so you can hand-roll a garde::Validate
impl on your model:
use ferra::garde::{self, Validate};
impl Validate for MyModel {
type Context = ();
fn validate_into(
&self,
_ctx: &Self::Context,
_parent: &mut dyn FnMut() -> garde::Path,
report: &mut garde::error::Report,
) {
// your hand-rolled checks here, appending to `report`
}
}
The escape is the documented fallback — neither encouraged nor
forbidden, used when the closed set is insufficient. It is
not part of the v1 #[field(...)] contract surface; the
rule shape and the trait surface follow the upstream garde
crate’s evolution, not Ferra’s. Use it sparingly: every model
that hand-rolls validation is a model the #[field(...)]
diagnostics no longer protect.
Hand-rolling a
Validateimpl on a model that ALSO carries#[field(...)]rules is a duplicate-impl error at compile time. The two paths are exclusive: either the framework generates the impl from your declarative rules, or you write it yourself.
Composite primary keys
A model can carry two or more #[sea_orm(primary_key)] markers.
Ferra picks them up in declaration order and threads the resulting
multi-segment shape through the route, the Location header, the
_links.self.href URL, the OpenAPI per-item parameters list, and
the find_by_id(...) lookup signature — without consumer-side
glue.
A worked example with (tenant_id: Uuid, document_id: i64):
use ferra::*;
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(
Clone, Debug, PartialEq,
DeriveEntityModel, FerraModel,
Serialize, Deserialize,
)]
#[sea_orm(table_name = "documents")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub tenant_id: Uuid,
#[sea_orm(primary_key, auto_increment = false)]
pub document_id: i64,
pub title: String,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
#[sea_orm(auto_increment = false)] is required on every PK field
in a composite key — PostgreSQL SERIAL only synthesises a value
for one column per table, so the application supplies an explicit
value on every insert.
The framework derives:
| Surface | Single-PK shape | Composite-PK shape (2 keys) |
|---|---|---|
| Per-item route | GET /films/{id} | GET /documents/{tenant_id}/{document_id} |
Location header on POST | /films/{id} | /documents/{tenant_id}/{document_id} |
_links.self.href | /films/{id} | /documents/{tenant_id}/{document_id} |
OpenAPI parameters count | 1 entry | 2 entries (in declaration order) |
repo.find_by_id(...) argument | id (scalar) | (tenant_id, document_id) (tuple) |
The repository call site is a typed tuple lookup:
let row = repo.find_by_id((tenant_id, document_id)).await?;
The tuple element order matches the #[sea_orm(primary_key)]
declaration order in the struct — Sea-ORM resolves
PrimaryKeyTrait::ValueType to (Uuid, i64) for the Document
entity above. Reordering the markers in the source struct is
therefore a wire-breaking change for any client URL-templating
against _links.self.href or against the OpenAPI path template.
The _links.self.href shape on a fetched row:
{
"_links": {
"self": { "href": "https://api.example.com/documents/<tenant>/<doc>" },
"collection": { "href": "https://api.example.com/documents" }
}
}
Ferra supports any N >= 1. A three-key composite ((tenant_id, year, sequence)) emits GET /issued_documents/{tenant_id}/{year}/{sequence}
with three OpenAPI parameters. The framework does not impose an
upper bound; in practice 2-4 keys cover the working set
(multi-tenant isolation, time-bucketed sequencing, partition keys).
The N=0 case continues to fail at compile time with the existing
“this model has no primary key” diagnostic — composite primary keys
are a widening of the legacy single-PK rule, not a relaxation.
The FerraRepository<M> surface for composite-PK models is
documented in ferra-db.md § The FerraRepository<M> surface.
Why DeriveEntityModel is required
Skipping the DeriveEntityModel sibling is a class-16 compile error:
error: FerraModel requires DeriveEntityModel on the same struct
help: add DeriveEntityModel to the `#[derive(...)]` list
note: see docs/user-guide/ferra-forge.md § Canonical entity shape — sibling-derive requirement
--> src/models/film.rs:3:12
|
3 | pub struct Film {
| ^^^^
The requirement is not decorative. Phase 0.3.0 Casting wires the
Entity, Column, PrimaryKey, and ActiveModel types that
DeriveEntityModel generates into the router and SQL query builder.
A FerraModel-only struct would compile locally but collapse the
moment ferra-http or ferra-db touches it. The rule fails loud
now so you cannot paint yourself into that corner.
Troubleshooting
Every error class 0.2.0 emits, with the exact message shape and the
fix. Numbering matches data-model.md § 4 of the Smelting spec.
About the rendered shape. Every Ferra-authored diagnostic in this section carries three logical lines — an
error:line, ahelp:line, and anote:line — becauseferra-forgebuilds them as a singlesyn::Erroron stable Rust. Thehelpandnoteappear as indented continuation lines of the error body rather than as separate= help:/= note:sub-lines (that shape requiresproc_macro::Diagnostic, which is nightly-only as of the 2026-04 toolchain). Text content is identical; grep-based CI checks and visual legibility both work.
Class 1 — no primary key
error: this model has no primary key
help: annotate exactly one field `#[sea_orm(primary_key)]`
note: see docs/user-guide/ferra-forge.md § Troubleshooting — missing primary key
Fix. Add #[sea_orm(primary_key)] to the id field. In 0.2.0
Ferra supports one-field primary keys only.
Class 2 — multiple primary keys
error: composite primary keys are not supported in Ferra 0.2.0
help: keep `#[sea_orm(primary_key)]` on exactly one field
Fix. Consolidate into a single id column. Composite keys are a later-phase feature.
Class 3 — unsupported field type
error: type `<T>` is not supported by Ferra in 0.2.0
help: use one of `String`, `i32`, `i64`, `f64`, `bool`, `Uuid`, or `Option<T>` for the first five
Fix. Model with one of the recognised types, or wait for the
phase that adds your type (Decimal, Timestamp, relations are on
the roadmap).
Class 4 — historical (Option<Uuid> rejection — retired in 0.6.0)
Option<Uuid> and Option<Id> are now first-class field types
admitted as FieldType::OptionUuid (0.6.0 Welding User Story 4 —
FR-014). The diagnostic that previously fired here (“Option<Uuid>
is not supported in Ferra 0.2.0”) no longer exists. The class
number is preserved for historical reference; the contradictory
case Option<Uuid> tagged #[sea_orm(primary_key)] is now
rejected with FRG-215 instead — shape and fix below.
error: FRG-215: primary key cannot be optional
= help: primary key fields must always have a value; remove `Option<...>` from the field type or remove `#[sea_orm(primary_key)]` from the field tag
= note: an inserted `None` would be indistinguishable from a row whose key was never assigned, so the round-trip equality semantics a PK guarantees cannot hold; see Spec §Edge Cases "Optional UUID field tagged as primary key"
--> src/film.rs:6:9
|
6 | pub id: Option<Uuid>,
| ^^
Fix. Either drop the Option<...> from the field type (so the
PK is always present), or remove #[sea_orm(primary_key)] from the
tag (so the field becomes a nullable non-key UUID).
The full FRG-2NN matrix and worked examples for every code introduced
in 0.6.0 Welding live in
Compile-time diagnostics. This page
keeps the FRG-215 details inline because the Troubleshooting section
needs to stand alone for consumers reading ferra-forge.md in
isolation.
Class 5 — not a struct with named fields
error: FerraModel must be derived on a struct with named fields (not tuple structs, unit structs, enums, or unions)
Fix. Convert to pub struct Name { ... } with named fields.
Class 6 — generic struct
error: FerraModel does not currently support generic models
help: supply a concrete type — remove the generic parameter
Class 7 — lifetime-parameterised struct
error: FerraModel does not currently support lifetime-parameterised models
help: Ferra models are owned values — remove the lifetime parameter
Class 8 — visibility below pub(crate)
error: FerraModel requires at least `pub(crate)` visibility
help: widen the struct's visibility — e.g., `pub struct ...` or `pub(crate) struct ...`
Class 9a — unknown #[ferra(...)] key (no nearest match)
error: `#[ferra(<key>)]` is not a recognized attribute
note: see docs/user-guide/ferra-forge.md for the full list of recognized keys in this phase
Fix. Consult the Recognised keys table above, or the Deferred keys table if you were trying to use a future-phase key.
Class 9b — unknown #[ferra(...)] key with a near-miss hint (release-blocker family)
error: `#[ferra(writeonly)]` is not a recognized attribute
help: did you mean `write_only`?
note: see docs/user-guide/ferra-forge.md for the full list of recognized keys in this phase
Fix. The help line quotes the intended spelling. The canonical
case is #[ferra(writeonly)] on password_hash — a write-only
bypass that would expose sensitive data. The trybuild fixture for
this diagnostic is marked a release blocker per SC-008.
Class 10 — read_only + write_only contradiction
error: `#[ferra(read_only)]` and `#[ferra(write_only)]` contradict
help: keep at most one of the two flags
Class 11 — #[ferra(required)] on Option<T>
error: `#[ferra(required)]` contradicts `Option<T>`
help: remove the attribute or change the field type
Fix. Nullability is deduced from the type, not from an attribute.
Remove #[ferra(required)] or change the field to a non-Option<T>
type.
Class 12 — #[ferra(write_only)] on the primary key
error: primary keys cannot be write-only
help: remove `#[ferra(write_only)]` from the id field
Class 13 — empty #[ferra(resource = "")]
error: `#[ferra(resource)]` must be a non-empty string
help: remove the attribute to fall back to the default
Class 14 — non-string-literal #[ferra(resource = ...)]
error: `#[ferra(resource)]` value must be a string literal
help: e.g., `#[ferra(resource = "films")]`
Class 15 — missing serde derives
Delivered by rustc E0277 on the emitted _assert_ferra_model_bounds
trait-bound assertion (see § Explicit serde derives are required
above). Span is pinned to your struct’s identifier.
Class 16 — missing DeriveEntityModel sibling
error: FerraModel requires DeriveEntityModel on the same struct
help: add DeriveEntityModel to the `#[derive(...)]` list
note: see docs/user-guide/ferra-forge.md § Canonical entity shape — sibling-derive requirement
Fix. Add DeriveEntityModel to the #[derive(...)] list. See the
Canonical entity shape example above.
- :sea_orm::ModelTrait>()
bound assertion; a failure there surfaces aserror[E0277]: the trait bound<YourModel>: sea_orm::ModelTraitis not satisfied. All three paths reduce to the same fix: addDeriveEntityModelto the#[derive(…)]` list.
Class 17 (pass case) — unknown #[sea_orm(...)] key
An unknown-to-Ferra but Sea-ORM-permitted key such as
#[sea_orm(column_name = "...")] compiles without intervention from
Ferra. This is the asymmetry documented in § Two attribute
namespaces, two policies. If Sea-ORM diagnoses the key itself, that
diagnostic surfaces — Ferra does not layer its own.