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:

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).
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 nameTypeSource
versionstringthe version prefix ("v1")
sunsetstringthe declared sunset date in YYYY-MM-DD form
days_overduei64whole days from sunset to today_utc
http.methodstringthe inbound request method, upper-cased
http.targetstringthe 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:

KnobDefaultOverride surface
CORSCorsLayer::new() (deny by default).cors(cors_layer)
Body cap1 MiB.body_limit(bytes)
Rate-limit (mutations only)RateLimitRule::new(2, 5) (2/sec, burst 5).rate_limit(RateLimitRule::new(per_second, burst_size))
TracingTraceLayer::new_for_http() with body sampling off.trace_layer(layer)
Base path for _linksnone.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.

RateLimitRule construction. Always use the RateLimitRule::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 defaultFoundry::new(conn).layer(global) — every model mounted afterwards inherits global’s settings.
  • Per-model overrideFoundry::new(conn).mount_with_layer::<M>(M_layer) — only M’s routes use M_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 Document does NOT explicitly call .body_limit(...), Document routes use the 1 MiB framework default, not the 5 MiB per-app value. The rule is auditable by grep: 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:

  • Film routes: body limit 5 MiB (from global), CORS deny-by-default (global did not set .cors(...)).
  • Document routes: body limit 1 MiB (the framework defaultdocument did not set .body_limit(...), so the per-model layer does NOT inherit global’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_path is a static &'static str declared at framework-assembly time. Ferra deliberately does NOT consume the X-Forwarded-Prefix request header (the dynamic-prefix convention used by FastAPI’s root_path_in_servers + Traefik). Reasons: (a) request-time prefix discovery breaks _links URL stability — two requests through different proxies could yield different _links.self.href for 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 separate FerraLayer per-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.hrefhttps://api.example.com/api/films/42
  • _links.collection.hrefhttps://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-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.