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:
#![allow(unused)]
fn main() {
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).
#![allow(unused)]
fn main() {
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”.
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":
#![allow(unused)]
fn main() {
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.
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.