Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

MethodDefaultOverride
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-DD HTTP header on every response under the version (RFC 8594);
  • deprecated: true + x-sunset: YYYY-MM-DD on every operation in the emitted spec, plus an info.x-sunset mirror 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-built FerraState<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 /docs mount point. Useful when a reverse proxy serves Ferra under a sub-path.
  • Direct ferra-openapi use — for advanced docs UIs (Redoc, RapiDoc, Swagger UI), call ferra_openapi::build_openapi_for_models(...) directly and merge the resulting axum::Router into the value Foundry returns. See ferra-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-openapi free functions stand alone.
  • Apps with a non-Sea-ORM persistence layer. Foundry takes a sea_orm::DatabaseConnection; alternative backends compose through mount_with::<M>(state) using a FerraState<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-openapi crate scope, public-default docs deviation, Foundry → ferra-openapi edge.
  • ADR-0025 — Foundry typestate strategy (set-membership-via-trait + #[diagnostic::on_unimplemented]).
  • ADR-0026 — closed ERROR_TYPES URI namespace; the https://ferra.rs/errors/unauthorized URI used by .with_docs_protected(...).
  • ADR-0027 — x-sunset vendor extension + deprecated: true mirror.
  • ADR-0002 — Axum 0.8 framework choice and the Foundry::build() -> axum::Router single-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.