Projections and routing
The 0.6.5 Chasing deliverable. A #[derive(FerraModel)] struct can expose multiple distinct read/write shapes — projections — by adding a single attribute. Each named projection auto-mounts under its own URL prefix and surfaces as a distinct path in the OpenAPI document, a distinct call in the generated SDK, and a distinct HAL _links.self.href target.
This page is the authoritative user-guide for projection-driven routing. A reader with only this page plus standard Rust knowledge produces a working projection declaration on the first attempt — no ADR and no framework source is required.
Mental model
A projection is a content variant of a resource — not an access-control mechanism. The same underlying row can be served through several projections, each one a deliberate slice of the row’s fields plus a deliberate stance on which fields a client may submit.
The defining property is that every projection is a distinct callable entity at every layer of the stack: a distinct URL path, a distinct OpenAPI operationId, a distinct generated-SDK method, a distinct HAL self link. A consumer that has the URL has, by construction, also fixed the wire shape — there is no header, no query parameter, and no out-of-band negotiation required.
Authorisation is enforced separately — by middleware on the route or by per-operation guards. A detail projection is not an admin-only projection; it is a more detailed projection that a developer may choose to gate behind an authorisation layer if they wish.
The routing contract
| Surface | URL | Wire shape |
|---|---|---|
| Default read (always emitted) | GET /{resource}/{id} and GET /{resource} | Every readable field of M |
| Default write (always emitted) | POST /{resource}, PUT /{resource}/{id} | Every writable field of M |
| Named projection — auto-derived prefix | GET /{name}/{resource}/{id} (etc.) | Exactly the fields the projection’s read/write lists declare |
Named projection — explicit path_prefix | GET /{path_prefix}/{resource}/{id} (etc.) | As above; the auto-derived /{name}/ is suspended |
Default replacement (default = true) | GET /{resource}/{id} (the bare path now serves the named projection’s shape) | The named projection’s read/write lists |
A model that declares zero named projections observes byte-identical 0.5.x / 0.6.0 routing — the bare resource path is unchanged. This is the SC-007 baseline: introducing a named projection on a model is strictly additive to its URL surface, never a silent breaking change.
A worked example
use ferra::*;
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")]
#[ferra(projection(
name = "detail",
read = [id, title, director, cost_price],
write = [title, director, cost_price],
))]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
#[sea_orm(unique)]
pub title: String,
pub director: String,
pub cost_price: Option<f64>,
}
This declaration auto-mounts two parallel route groups:
| Verb | Bare path | Detail path |
|---|---|---|
| Read item | GET /films/{id} | GET /detail/films/{id} |
| Read collection | GET /films | GET /detail/films |
| Create | POST /films | POST /detail/films |
| Update | PUT /films/{id} | PUT /detail/films/{id} |
| Delete | DELETE /films/{id} | DELETE /detail/films/{id} |
The bare-path responses carry every readable field of Model (the default-read set). The /detail/... responses are filtered to the projection’s read list — fields not in that list never appear in the response body, ever.
For the OpenAPI document, the paths map gains entries for /films/{id} and /detail/films/{id}, each operation referencing a distinct schema (Film vs FilmDetail). SDK generators driving off the document produce distinct method names per surface (client.films.get(id) vs client.films.getDetail(id)).
Overriding the URL prefix
By default the projection’s name is lower-cased and used as the URL prefix segment. To pin the URL surface to something different — for example, a versioned segment or a developer-friendly synonym — add a path_prefix:
#[ferra(projection(
name = "detail",
path_prefix = "/v2/detail",
read = [id, title, director, cost_price],
write = [title, director, cost_price],
))]
This mounts /v2/detail/films/{id} (and parallel verbs). The auto-derived /detail/... URL is NOT also registered — exactly one URL surface exists per projection.
The leading / is part of the literal; the framework appends the trailing / and the resource segment.
Naming convention — shape, not audience
Projection names MUST describe the shape of the variant — what fields it exposes — not the audience it serves.
| Good (shape) | Bad (audience) |
|---|---|
"detail" | "admin" |
"summary" | "staff" |
"compact" | "backoffice" |
"full" | "internal" |
Naming a projection after a role visually couples the projection to access-control in the developer’s mental model and in URLs that appear in access logs — even though projections enforce no authorisation. A "detail" projection that exposes cost prices is still subject to whatever auth middleware sits on its route; calling it "admin" invites the next reader to assume the projection itself enforces a role check.
Use the role-neutral shape name and gate the route at the auth layer.
Default replacement and the silent-break problem
Setting default = true on a named projection makes that projection’s shape the one served at the bare resource path. The auto-default read/write surface is suppressed; consumers requesting /films/{id} get the named projection’s shape without any URL change to signal the difference. This is a silent breaking change to the public API contract.
To force the change to be visible, the framework requires two attestation attributes alongside default = true:
#[ferra(projection(
name = "detail",
default = true,
promotes_from = "auto-derived", // attestation of the previous default
breaking_change_version = "2.0.0", // app version in which the promotion lands
read = [id, title, director, cost_price],
write = [title, director, cost_price],
))]
Missing either attestation produces compile-time FRG-316. promotes_from is purely declarative — the macro does not validate it against repository history. Its purpose is to force the developer to name what is being replaced, which is the part the silent-flip case skips entirely. breaking_change_version surfaces in the OpenAPI document as the x-ferra-promoted-in vendor extension on every operation under the bare resource path, so SDK consumers and downstream tooling can detect the silent break.
A second defence (the .ferra/projection-defaults.lock file with schema-hash drift detection) is queued for the next release; at 0.6.5 the Layer-1 attestation is the sole defence.
Worked example — promotion replaces the bare path
The default-promoting projection serves at the bare resource path and no prefixed mirror is published. A model whose v2 projection is the default has exactly one URL surface for the projection’s shape: /films / /films/{id}. The would-be auto-derived /v2/... prefix is not registered, so GET /v2/films/{id} returns 404. This is what makes the promotion a REPLACE, not an ADD.
#[derive(FerraModel, /* ... */)]
#[sea_orm(table_name = "films")]
#[ferra(projection(
name = "v2",
default = true,
promotes_from = "auto-derived",
breaking_change_version = "2.0.0",
read = [id, title],
write = [title],
))]
pub struct Film {
#[sea_orm(primary_key)]
pub id: i32,
pub title: String,
}
Wire-shape contract:
| Request | Response |
|---|---|
POST /films body {"title": "X"} | 201 Created — accepted, named projection’s write_fields |
POST /films body {"title": "X", "unknown": "y"} | 422 Unprocessable Entity — unknown field rejected |
GET /films/{id} | 200 OK body {"id": ..., "title": "X", "_links": {...}} |
GET /v2/films/{id} | 404 Not Found — the prefixed mirror is NOT published |
Worked example — x-ferra-promoted-in in the OpenAPI document
The breaking_change_version attestation surfaces as the x-ferra-promoted-in vendor extension on every operation under the bare resource path. SDK generators and tools like ferra anvil read this extension to detect the silent breaking change.
For the Film model above, fetching GET /docs/openapi.json returns a document containing:
paths:
/films:
get:
x-ferra-promoted-in: "2.0.0"
# ... rest of operation ...
post:
x-ferra-promoted-in: "2.0.0"
# ...
/films/{id}:
get:
x-ferra-promoted-in: "2.0.0"
# ...
put:
x-ferra-promoted-in: "2.0.0"
# ...
delete:
x-ferra-promoted-in: "2.0.0"
# ...
The extension is per-operation under the bare path. A non-default named projection (one with an auto-derived /admin/ prefix or an explicit path_prefix = "/...") does not emit the extension on its prefixed operations — the extension is the signal that the model’s bare-path wire shape has shifted, not a per-model badge.
Prefix collisions
Two collision classes are rejected by the framework — one at compile time, one at Foundry::build().
FRG-310 — same prefix on two projections
Two projections (on the same model or across two mounted models) that resolve to the same effective URL prefix produce ambiguous routing. The compile-time channel catches the intra-model case; the Foundry::build() channel catches the cross-model case.
// Both projections declare path_prefix = "/staff" → FRG-310 at compile time.
#[ferra(projection(name = "admin", path_prefix = "/staff", read = [id, title]))]
#[ferra(projection(name = "manager", path_prefix = "/staff", read = [id, title]))]
Resolution: rename one of the projections, or set a distinct path_prefix on one of them.
FRG-312 — projection prefix collides with another resource
A named projection’s auto-derived prefix that matches another mounted model’s resource_name is rejected at Foundry::build(). For example, model A with projection name = "admin" (auto-deriving /admin/) cannot be mounted on the same Foundry as model B with resource_name = "admin" (mounting at /admin).
Resolution: rename the projection to a shape descriptor that does not match any mounted resource name, or set an explicit path_prefix to disambiguate.
Nested resources (root-only URL surface)
When 0.6.5 ships, a single Ferra application mounts every model on a flat URL surface — there is no nested-resource mounting API. ADR-0030 §Nested resources reserves the routing semantics for a future release:
- The URL prefix derives exclusively from the root resource’s active projection and propagates uniformly to every nested segment.
- A nested model MAY declare a projection with the same name as the root’s; its read/write shapes are served for that segment. If it does not declare a matching projection, the default read/write shape of the nested model is served (additive fallback).
- A nested (non-root) model MUST NOT declare its own
path_prefix. Doing so produces compile-time FRG-315 when the nested-mounting API ships.
No URL of the form /detail/films/{id}/summary/reviews/{rid} is supported — a request reaches with one variant choice that applies coherently to the entire response graph. A developer who needs different per-segment shapes declares distinct root-level projections.
Versioning composition
Ferra takes no position on API versioning — that is an application-level concern. The framework provides two complementary mechanisms that compose with the projection surface:
-
Application-level prefix (canonical). Mount the entire API under a version segment:
let app = Foundry::new(conn).api_version("v1")?.mount::<Film>().build();When a breaking change requires bumping
/v1→/v2, the developer bumps the prefix in the application code. The framework provides the signal (the FRG-316 attestation requirement ondefault = true); the developer decides when to bump and how to communicate the bump (Deprecation:header, sunset date, parallel-run period). -
Per-projection versioning (escape hatch via
path_prefix). A single projection can be versioned independently of the application by embedding the version in itspath_prefix:#[ferra(projection(name = "detail", path_prefix = "/v2/detail", read = [...]))]This composes naturally with
.api_version("v1")— the final URL becomes/v1/v2/detail/films/{id}.
The framework deliberately does NOT introduce a separate version = N attribute; path_prefix is sufficient for the escape-hatch case and avoids growing the projection surface for what is fundamentally an application concern.
Why path-prefix routing — and what it costs
Three alternative designs were considered and rejected. The honest trade-offs:
| Alternative | Pros | Why rejected |
|---|---|---|
Accept: profile=... (RFC 6906) | Standards-track REST answer; preserves single-URL semantics | No mainstream SDK generator branches on Accept parameters; CDN caching requires Vary: Accept (fragments hit rates); HAL _links cannot encode the profile in the href; AI clients must reason about header serialisation at the call site |
Prefer: profile=... (RFC 7240 extension) | Reuses an existing header surface | RFC 7240 §2 mandates that recipients MUST silently ignore unrecognised preferences — a misconfigured intermediary could drop the variant signal without error; conflates Prefer:’s response-shape semantic with variant selection |
Query parameter (?projection=... / JSON:API sparse fieldsets / Google FieldMask / Stripe expand) | Strongest industry precedent; single canonical URL per row; arbitrary runtime field selection composes naturally | Collapses the OpenAPI paths map into a single entry per resource; SDK generator produces one method per resource with a runtime opts.projection parameter; HAL _links.self.href either encodes the query string (less clean) or drops the variant signal; diagnostics for invalid projection names move from compile-time to runtime |
The load-bearing axis is the typed-callable-per-projection invariant: every projection should be a distinct callable entity at every layer of the stack — handler, OpenAPI path, SDK method, HAL self URL, operationId. Query parameter routing collapses three of those four layers (OpenAPI path, SDK method, operationId) back into a single entity with a runtime discriminator, which weakens the typed-projection contract this feature exists to support.
Path-prefix routing is the more aggressive choice and the less-precedented one, but it is the one that propagates the type-level distinction through to the wire format. The trade-off accepted: REST-purist critique on URL-per-row, in exchange for typed-callable-per-projection through OpenAPI and SDKs.
When NOT to use projections
- The variant exists for authorisation reasons (e.g., “only authenticated admins see cost prices”). Use auth middleware on a route, not a projection.
- The variant changes the underlying row identity (e.g., joined data from another table). That is a separate resource, not a projection — declare a second
#[derive(FerraModel)]. - The variant is one-of-many runtime field selections (
?fields=id,title,cost_pricestyle). Projections are named-and-typed at compile time; arbitrary runtime field selection is a future feature on top of (or instead of) projections. - The wire shape varies by row content, not by URL. Projections are URL-keyed by design.
Reference
- ADR-0030 — Typed projection codegen / URL-prefix routing — the design rationale, the four-options trade-off matrix, and the explicit nested-resource semantics.
- ADR-0009 — Typed projections codegen — the IR-level shape that drives schema and route emission.
- Compile-time diagnostics — FRG-310 / FRG-311 / FRG-312 / FRG-315 / FRG-316 — every rejection’s worked-good and worked-bad sample.
- Ferra HTTP — Routing — the lower-level Tower stack the projection routes inherit.
- Ferra OpenAPI — Schema names — how projections map to
components/schemasentries and thex-ferra-promoted-invendor extension.