Foundry
Foundry is Ferra’s router-assembly facade. One chain — beginning
with a Sea-ORM DatabaseConnection and ending with .build() —
declares every resource on your API, mounts the OpenAPI 3.1 docs
surface, applies version prefixes, and produces the final
axum::Router.
use ferra::*;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let conn = sea_orm::Database::connect(&std::env::var("DATABASE_URL")?).await?;
let app = Foundry::new(conn)
.mount::<Film>()
.mount::<Actor>()
.with_docs()
.build();
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
axum::serve(listener, app).await?;
Ok(())
}
The chain replaces the per-resource
conn.clone() + FerraState::new + ferra_router::<M> + Router::merge
boilerplate. Two compile-time invariants are enforced by the type
system: no duplicate .mount::<M>() calls, and no mixing of
un-versioned with versioned mounts on a single chain.
Foundry::build() -> axum::Router is the sole method whose return
type names an axum::* type. Every intermediate value is a
Foundry-defined builder. Consumer-added .layer() calls on the
returned router land outside the framework’s Tower stack.
The chain at a glance
| Method | Default | Override |
|---|---|---|
Foundry::new(conn) | begins the chain | — |
.mount::<M>() | mounts the model’s CRUD on /{resource} | .mount_with::<M>(state) (custom FerraState) |
.with_docs() | publishes /docs/openapi.json + /docs (Scalar UI) | .with_docs_at(path) |
.with_docs_protected(verifier) | gates docs behind the verifier (RFC 7807 401 on rejection) | .with_docs_protected_at(path, verifier) |
.api_version("v1")? | transitions to a versioned chain (paths under /v1/) | — |
.deprecated(date) | adds Sunset: header + deprecated: true + x-sunset to the spec | — |
.build() | finalizes the chain into axum::Router | — |
api_version returns Result<VersionedFoundryBuilder, ApiVersionError>
— prefixes that are empty, contain /, contain whitespace, or
contain characters unsafe in a URL path segment are rejected at
construction time.
The no-duplicate-mount rule
Mounting the same model twice is a compile error. The
typestate-tracked Set: NotMounted<M> bound forecloses route
shadowing at the type level (FR-012a, ADR-0025). The
#[diagnostic::on_unimplemented] message names the duplicated type:
error[E0277]: the model `Film` is already mounted on this Foundry chain
--> src/main.rs:5:33
|
5 | .mount::<Film>().mount::<Film>().build();
| ^^^^^^^^^^^^^ duplicate `.mount::<Film>()` —
| remove this call or rename the model
|
= note: see https://ferra.rs/guide/foundry#duplicate-mount for the supported pattern
If you genuinely need two different shapes for the same underlying type, define two distinct Rust types — Ferra’s metadata travels with the type, not with the resource name.
The no-mixed-mode rule
.api_version(...) is callable only on an empty chain. Mounting an
un-versioned model first and then calling .api_version("v1") is a
compile error (FR-014a, ADR-0025):
error[E0277]: `.api_version(...)` cannot be called after un-versioned `.mount(...)` calls
--> src/main.rs:6:14
|
6 | .api_version("v1")?
| ^^^^^^^^^^^^^^^^ this builder already has un-versioned mounts
|
= note: to ship mixed-mode (legacy + versioned) routes, build two
assemblies and merge them via `axum::Router::merge`. See
https://ferra.rs/guide/foundry#mixed-mode for a worked example.
Mixed-mode pattern: two assemblies merged
If you want a top-level /films and a /v1/films, build two
Foundry chains and merge them with axum::Router::merge:
use ferra::*;
let conn = /* … */;
let legacy = Foundry::new(conn.clone())
.mount::<Film>()
.with_docs() // serves /docs/openapi.json
.build();
let v1 = Foundry::new(conn)
.api_version("v1")?
.mount::<Film>()
.deprecated(date!(2027-01-05))
.with_docs() // serves /docs/v1/openapi.json
.build();
let app: axum::Router = legacy.merge(v1);
Each Foundry::build() produces its own axum::Router; merging
them is the supported way to expose multiple-version surfaces from
a single binary. The OpenAPI documents stay separate — one per
build.
Versioning + deprecation
.api_version("v1")? nests every mounted resource under /v1/,
prefixes every operationId with v1., and serves the spec at
/docs/v1/openapi.json.
.deprecated(date) activates two parallel signals:
- a
Sunset: YYYY-MM-DDHTTP header on every response under the version (RFC 8594); deprecated: true+x-sunset: YYYY-MM-DDon every operation in the emitted spec, plus aninfo.x-sunsetmirror at the document root (ADR-0027).
use ferra::*;
let v1 = Foundry::new(conn)
.api_version("v1")?
.mount::<Film>()
.deprecated(date!(2027-01-05))
.with_docs()
.build();
The date! macro validates the calendar date at compile time; an
invalid month or day fails cargo build rather than panicking at
startup. See ferra-core.md §“Time vocabulary”.
Post-sunset warn-event
When today_utc > sunset (strict greater-than — equality does not
trigger; the sunset day is the last day of the grace window), every
request under the deprecated version causes the framework to emit a
structured warning at tracing::Level::WARN. The event is layered
into the same middleware that emits the Sunset: response header, so
the two signals always travel together.
Verbatim target string (filterable via tracing-subscriber’s
EnvFilter):
ferra_http::sunset
Field set on the event:
| Field name | Type | Source |
|---|---|---|
version | string | the version prefix ("v1") |
sunset | string | the declared sunset date in YYYY-MM-DD form |
days_overdue | i64 | whole days from sunset to today_utc |
http.method | string | the inbound request method, upper-cased |
http.target | string | the inbound path?query (includes the version prefix) |
Message body (verbatim):
deprecated API version reached after declared sunset date
Once-per-process de-duplication: the framework fires the warning
exactly once per (version, sunset) pair within a single running
process. Two sequential requests on the same pair produce one
warning; two distinct pairs (e.g., v1/2026-01-01 and
v2/2026-06-01) each fire once independently. The dedup state is
process-local — a process restart re-creates the registry and the
first post-restart hit fires the warning again. This is the right
default for operational paging: a single human-readable signal per
incident, repeated only when a release rolls the world.
Operator filtering. An operator who wants only the post-sunset warnings (and nothing else from the framework) sets:
RUST_LOG=ferra_http::sunset=warn
An operator who wants every framework-emitted event at warn level:
RUST_LOG=ferra_http=warn
The convention follows the <snake-cased crate name>::<event segment>
pattern; the rate-limit event lives at ferra_http::rate_limit and
is configured the same way.
Metrics counter (gated by the observability Cargo feature).
When the observability feature is enabled:
[dependencies]
ferra = { package = "ferra-rs", version = "0.7", features = ["observability"] }
the framework also advances a metrics::counter! named
ferra.sunset.post_sunset_hits on every post-sunset request,
labelled with version and sunset. Unlike the warn-event, this
counter advances on every hit — its purpose is rate-of-arrival
visibility, not signal-per-incident paging. With the feature
off (default), the framework takes zero dependency on the
metrics crate; the constitutional 0.4.0 secure defaults are
byte-identical for non-opting-in consumers.
No-deny on post-sunset: the comparator is observability-only.
The framework continues to serve every request past sunset; if you
want denial after a date, add your own middleware. A first-class
deprecated_with_enforcement(...) opt-in is on the roadmap but not
shipped at 0.7.0.
Per-request budget: the comparator’s common path runs in well
under 200 ns on the CI reference runner and is informational-tier
under 100 ns. The framework’s first build-gating performance
contract enforces this: a regression past 200 ns fails the build
(scripts/bench/check_sunset_ceiling.sh against
cargo bench --bench sunset_comparator). The two-tier discipline is
documented in ADR-0039 as the pattern for future hot-path additions.
Public-default docs and the protected variant
.with_docs() exposes the documentation surface unauthenticated by
default. This is a deliberate, documented departure from Ferra’s
model-route default-deny posture (Q1 in the 0.5.0 specification,
arbitrated in ADR-0024). The reasoning: the docs surface describes
the API but does not serve resource data; gating it would block the
constitutional zero-to-documented-API standard for every new
consumer.
For any publicly-reachable network surface, switch to
.with_docs_protected(verifier). The verifier closure receives the
inbound request and returns bool; a false produces an RFC 7807
401 Unauthorized response with
type: "https://ferra.rs/errors/unauthorized":
use ferra::*;
use axum::http::header::AUTHORIZATION;
let app = Foundry::new(conn)
.mount::<Film>()
.with_docs_protected(|req: &axum::extract::Request| {
req.headers()
.get(AUTHORIZATION)
.and_then(|v| v.to_str().ok())
== Some("Bearer secret")
})
.build();
The verifier runs synchronously and MUST NOT perform I/O — the
underlying primitive does not yet integrate with the
ferra-auth provider chain (that landing is scheduled for 0.8.5
beta). The current API accepts any Fn(&Request) -> bool + Send + Sync + 'static; the future Provider arm of DocsAuthVerifier
will be additive.
Layer-ordering invariance
Every resource mounted through Foundry carries the per-resource
Tower stack ferra_router::<M> builds: CORS (restrictive default),
the 413-mapping middleware, the 1 MiB body limit, tracing with body
sampling off, and per-route rate limiting on mutation endpoints.
Foundry does not reorder, replace, or remove any of these layers.
Consumer-added .layer(...) calls on the value Foundry::build()
returns land outside the framework’s stack — the standard Tower
composition rule. If you need a layer to wrap framework layers, add
it on the returned router; if you need it to sit beneath the
framework layers, fork to mount_with::<M>(state) and pre-compose
on the state’s ferra_router::<M> output.
Cross-reference: ferra-http.md §“Tower layer-ordering notation”
documents the exact stack order.
Transport-layer overrides
Reader contract. This section stands on its own. A reader with only this page plus standard Rust knowledge produces a correct per-app or per-model override on the first attempt. Every knob, default, fallback, and validation rule is spelled out below — no ADR and no framework source is required.
The framework’s secure Tower stack (CORS deny-by-default, 1 MiB body
cap, { per_second: 2, burst_size: 5 } mutation rate-limit, tracing
with body sampling off) is the right default for the vast majority of
APIs. Some shapes — file uploads, high-volume mutation endpoints,
sub-path deployments behind a reverse proxy — need narrow overrides.
Use the FerraLayer builder for those.
FerraLayer is a single value carrying overrides for five knobs:
| Knob | Default | Override surface |
|---|---|---|
| CORS | CorsLayer::new() (deny by default) | .cors(cors_layer) |
| Body cap | 1 MiB | .body_limit(bytes) |
| Rate-limit (mutations only) | RateLimitRule::new(2, 5) (2/sec, burst 5) | .rate_limit(RateLimitRule::new(per_second, burst_size)) |
| Tracing | TraceLayer::new_for_http() with body sampling off | .trace_layer(layer) |
Base path for _links | none | .base_path("/api") |
A FerraLayer whose every knob is left at its None default
(FerraLayer::new().build()) reproduces the framework stack
byte-for-byte — the override surface is silent when unused.
RateLimitRuleconstruction. Always use theRateLimitRule::new(per_second, burst_size)constructor. The struct carries#[non_exhaustive]to accommodate future additive knobs (e.g., per-key extractor selection, per-method overrides) without breaking downstream construction sites; direct struct-literal construction (RateLimitRule { per_second: 2, burst_size: 5 }) is rejected by the compiler from outside the crate.
Per-app vs per-model — REPLACE not merge
FerraLayer applies at two scopes:
- Per-application default —
Foundry::new(conn).layer(global)— every model mounted afterwards inheritsglobal’s settings. - Per-model override —
Foundry::new(conn).mount_with_layer::<M>(M_layer)— onlyM’s routes useM_layer.
The per-model layer REPLACES (does NOT merge with) the per-app
layer for the affected model. Critically, a knob left at None on
the per-model layer falls back to the framework default — NOT to
the per-app setting. The intent is per-model auditability in
isolation: every mount_with_layer::<M>(...) site is fully
inspectable on its own, without chasing a per-app default the reader
may not have on screen, and without the security surprise of inheriting
a permissive per-app override the per-model author did not intend.
Security posture. The REPLACE-not-merge rule means that an unset knob on a per-model override cannot silently inherit a permissive per-app value. If the per-app layer raises the body cap to 5 MiB and the per-model layer for
Documentdoes NOT explicitly call.body_limit(...),Documentroutes use the 1 MiB framework default, not the 5 MiB per-app value. The rule is auditable bygrep: every override is explicit at its declaration site.
let global = FerraLayer::new()
.body_limit(5 * 1024 * 1024) // 5 MiB
.build();
let document = FerraLayer::new()
.cors(permissive_cors)
.build();
let app = Foundry::new(conn)
.layer(global) // 5 MiB on every model …
.mount_with_layer::<Document>(document) // … EXCEPT Document
.mount::<Film>()
.build();
Observable behaviour:
Filmroutes: body limit 5 MiB (fromglobal), CORS deny-by-default (globaldid not set.cors(...)).Documentroutes: body limit 1 MiB (the framework default —documentdid not set.body_limit(...), so the per-model layer does NOT inheritglobal’s 5 MiB), permissive CORS (the per-model setting).
Body-limit override + verbatim 413 detail
When the configured cap is exceeded, the framework’s standard 413
application/problem+json response carries the verbatim configured
limit in the detail field:
{
"type": "https://ferra.rs/errors/payload_too_large",
"title": "Payload Too Large",
"status": 413,
"detail": "request body exceeds 5242880 bytes"
}
Operators tracing 413s in logs can map the byte cap in the response
back to the originating FerraLayer::body_limit(...) call site
without consulting source code.
Sub-path mount — base_path
A common deployment shape: serve Ferra at /api/ behind a reverse
proxy that strips the prefix before forwarding. Configure the
framework’s hypermedia links to include the publicly visible prefix
via .base_path("/api"):
Static prefix only.
base_pathis a static&'static strdeclared at framework-assembly time. Ferra deliberately does NOT consume theX-Forwarded-Prefixrequest header (the dynamic-prefix convention used by FastAPI’sroot_path_in_servers+ Traefik). Reasons: (a) request-time prefix discovery breaks_linksURL stability — two requests through different proxies could yield different_links.self.hreffor the same row; (b) the closed,const-evaluable contract enables the validation grammar below to fire at compile time, not at proc-macro time. If your deployment needs multiple prefixes simultaneously, declare a separateFerraLayerper-deployment-target.
let app = Foundry::new(conn)
.layer(FerraLayer::new().base_path("/api").build())
.mount::<Film>()
.build();
Every _links.*.href (self, collection, restore, named-operation
links) now carries the /api prefix:
_links.self.href→https://api.example.com/api/films/42_links.collection.href→https://api.example.com/api/films
.base_path is validated at proc-macro-equivalent time: the prefix
MUST start with / and MUST NOT end with /. Malformed
literals produce a compile error at the call site when the value
is reachable in const context:
.base_path("api") // compile error — no leading `/`
.base_path("/api/") // compile error — trailing `/`
.base_path("") // compile error — empty
The base_path declaration applies only to hypermedia link
generation. The framework does not assume the prefix is present in
the inbound request URI — if the reverse proxy strips /api before
forwarding, no further configuration is needed. To mount under the
prefix internally as well, compose with axum::Router::nest("/api", foundry.build()).
Rate-limit override
use ferra::{FerraLayer, RateLimitRule};
let layer = FerraLayer::new()
.rate_limit(RateLimitRule::new(10, 20)) // 10/sec, burst 20
.build();
The constructor signature is RateLimitRule::new(per_second: u32, burst_size: u32) -> Self. burst_size is the bucket depth (peak
concurrent mutations); per_second is the steady-state refill rate.
At RateLimitRule::new(10, 20) a client may issue 20 mutations in
burst, then 10/second steady-state. The key extractor at 0.7.0 is
fixed to peer socket IP; richer extractor selection (proxy-aware
keying, custom keying) composes on top in a later release. The
#[non_exhaustive] attribute on RateLimitRule ensures additive
knobs ship without breaking downstream construction sites — always
use the ::new(...) constructor rather than struct-literal
construction.
Custom CORS + tracing layers
.cors(cors_layer) and .trace_layer(trace_layer) accept the
underlying [tower_http] types verbatim — the framework does not
wrap them. To configure CORS for a specific allowed origin:
use tower_http::cors::{AllowOrigin, CorsLayer};
let cors = CorsLayer::new()
.allow_origin(AllowOrigin::list(["https://app.example.com".parse().unwrap()]))
.allow_methods(tower_http::cors::AllowMethods::list([
axum::http::Method::GET,
axum::http::Method::POST,
]));
let layer = FerraLayer::new().cors(cors).build();
The accepted TraceLayer type at 0.7.0 is the default
TraceLayer<HttpMakeClassifier> shape returned by
TraceLayer::new_for_http(). Consumers who need a custom classifier
or per-handler customisation that changes the layer’s generic
parameters compose on top of Foundry::build() with a regular
.layer(...) call.
Composition order — preserved
The constitutional Tower stack order is preserved regardless of which knobs are overridden:
CorsLayer (outermost)
body_limit_mapper (413 → problem+json with verbatim detail)
RequestBodyLimitLayer({bytes})
TraceLayer
DefaultBodyLimit::disable
[on mutation sub-router only:]
rate_limit_mapper (429 → problem+json)
GovernorLayer(per_second, burst_size)
<handlers>
[on read sub-router:]
<handlers>
FerraLayer does not reorder, add, or remove layers — only swaps
the parameters / values that flow into the existing slots.
Typestate guard — .layer(...) before .mount(...)
.layer(...) carries the same Set: IsEmpty typestate bound as
.api_version(...). Calling it after a model has been mounted
produces a compile error pointing at the merge-two-assemblies
pattern (the same diagnostic shape documented in §“The
no-mixed-mode rule” above).
.mount_with_layer::<M>(layer) follows the same no-duplicate-mount
rule as .mount::<M>().
Escape hatches
mount_with::<M>(state)— supply a caller-builtFerraState<M>rather than letting Foundry derive one from the connection. Useful for shared state across builders, instrumentation, or test fixtures where the repository needs to be substituted.with_docs_at(path)/with_docs_protected_at(path, verifier)— override the/docsmount point. Useful when a reverse proxy serves Ferra under a sub-path.- Direct
ferra-openapiuse — for advanced docs UIs (Redoc, RapiDoc, Swagger UI), callferra_openapi::build_openapi_for_models(...)directly and merge the resultingaxum::Routerinto the value Foundry returns. Seeferra-openapi.md§“Alternative UIs”.
When Foundry is the wrong tool
- Apps where every resource needs a hand-tuned middleware stack
(e.g. per-resource auth schemes, per-resource body-limit overrides).
Use
ferra_router::<M>(state)per resource and compose by hand. - Apps that mount the docs surface from a different process than
the API itself. The
ferra-openapifree functions stand alone. - Apps with a non-Sea-ORM persistence layer. Foundry takes a
sea_orm::DatabaseConnection; alternative backends compose throughmount_with::<M>(state)using aFerraState<M>bound to the alternative.
For the bare CRUD case — the framework’s zero-to-documented-API
target — Foundry::new(conn).mount::<M>().with_docs().build() is
the right shape.
Cross-references (for the curious reader)
- ADR-0024 —
ferra-openapicrate scope, public-default docs deviation,Foundry → ferra-openapiedge. - ADR-0025 —
Foundrytypestate strategy (set-membership-via-trait +#[diagnostic::on_unimplemented]). - ADR-0026 — closed
ERROR_TYPESURI namespace; thehttps://ferra.rs/errors/unauthorizedURI used by.with_docs_protected(...). - ADR-0027 —
x-sunsetvendor extension +deprecated: truemirror. - ADR-0002 — Axum 0.8 framework choice and the
Foundry::build() -> axum::Routersingle-leak rule.
These are pointers for readers tracing decisions back to their arbitration; they are not required reading for correct use of the APIs above.