ferra-openapi
ferra-openapi is the framework’s documentation surface. It builds
an OpenAPI 3.1 specification from a model’s ModelMeta description
and serves the spec at /docs/openapi.json plus an interactive
Scalar UI at /docs. Both surfaces are derived end-to-end from the
same #[model] source of truth that drives the framework’s HTTP
routes, SQL queries, and hypermedia links.
The crate ships in 0.5.0 Rolling. The recommended call site is
Foundry::with_docs() (landing in 0.5.0 Rolling alongside this
crate); the underlying free functions in this crate stand alone for
direct use cases and for the per-resource integration test suite.
What you get for free
A Ferra #[model] mounted through Foundry::with_docs() produces:
GET /docs/openapi.json— fullOpenAPI3.1 document.GET /docs— interactive Scalar UI rendering the spec.
Both routes are publicly reachable by default — the inventory is the
feature. For deployments where the endpoint structure is itself
sensitive, the auth-gated variant
(Foundry::with_docs_protected(verifier)) ships in the same release
and gates the surface behind authentication. See
§“Public-by-default rationale” below for when to choose which.
For each mounted model M (PascalCase struct name), the spec
contains:
- Five paths (
GET /{resource},GET /{resource}/{id},POST /{resource},PUT /{resource}/{id},DELETE /{resource}/{id}). - Four projection schemas (
{Model},Create{Model}Input,Update{Model}Input,{Model}Collection). - A shared
ProblemDetailsschema referenced by every error response. - Every operation carries an
operationIdand atagsarray containing the resource name. Zero inline schemas in operation definitions; every body schema is a$refintocomponents/schemas.
Enabling the docs surface
The headline call site is Foundry::with_docs():
use ferra::Foundry;
let app = Foundry::new(conn)
.mount::<Film>()
.with_docs()
.build();
That single chain produces a runnable HTTP service mounting Film’s
five CRUD endpoints plus /docs/openapi.json and /docs with
zero boilerplate.
Variants
| Method | Effect |
|---|---|
.with_docs() | Public docs at /docs and /docs/openapi.json. |
.with_docs_at("/api-docs") | Public docs at the supplied prefix. |
.with_docs_protected(verifier) | Auth-gated docs at /docs; anonymous requests get 401 + RFC 7807 body. |
.with_docs_protected_at("/api-docs", verifier) | Auth-gated at the supplied prefix. |
Public-by-default rationale
The framework’s default-deny posture on model routes does not extend to the docs surface — and the deviation is security-positive, not a convenience trade-off.
/docs/openapi.json is generated from the same ModelMeta IR that
drives the routes. By construction, every endpoint a Ferra
application exposes is documented, with no manual schema to keep
in sync. This structurally closes OWASP API Security Top 10
API9:2023 (Improper Inventory Management) — the gap between the API
a team thinks it exposes and the API actually deployed. Salt
Security research cited in OWASP API9 measures that gap at 40% on
average in production (one cited case: 54 undocumented endpoints, 12
exposing PII). The public default makes the complete inventory
legible to any operator without an opt-in step; a deny-by-default
/docs would force every team to opt into seeing what their own API
exposes, which is the inverse of the API9 recommendation.
For deployments that must restrict who can read the inventory,
.with_docs_protected(verifier) ships in the same release and gates
the surface behind authentication — operators keep that floor
without changing crates.
Rule of thumb: prefer .with_docs() for development, internal
tools, public APIs intentionally discoverable, and AI-consumable
surfaces (the inventory is the feature). Prefer
.with_docs_protected(verifier) for any production deployment of an
internal-only API whose endpoint structure is itself sensitive.
The deviation from default-deny is recorded in ADR-0024 for the curious reader; this guide stands alone.
Naming conventions
Schema names
For a model named Film with no named projections declared, the spec
carries four entries in components/schemas:
| Projection | OpenAPI schema name | Purpose |
|---|---|---|
| Read | Film | Response shape for GET and the body of POST / PUT responses. |
| Create | CreateFilmInput | Request body of POST /films. |
| Update | UpdateFilmInput | Request body of PUT /films/{id}. |
| Collection | FilmCollection | Response shape for GET /films ({ items: [Film], page, per_page, total }). |
The schema name derives from resource_name (the URL path slug) via
PascalCase singularisation.
Per-projection schemas
A model that declares one or more #[ferra(projection(...))] blocks
emits an additional triple of schemas per named projection. For
Film with one named admin projection (#[ferra(projection(name = "admin", read = […], write = […]))]):
| Projection | OpenAPI schema name | Operations served at |
|---|---|---|
| Read | FilmAdmin | GET /admin/films/{id}, GET /admin/films |
| Create | CreateFilmAdminInput | POST /admin/films |
| Update | UpdateFilmAdminInput | PUT /admin/films/{id} |
| Collection | FilmAdminCollection | GET /admin/films (pagination envelope) |
The general rule:
- Read schema:
{Model}{Projection-PascalCase}. - Create schema:
Create{Model}{Projection-PascalCase}Input. - Update schema:
Update{Model}{Projection-PascalCase}Input.
For the default projection the {Projection-PascalCase} segment is
the empty string, so the rule produces Film, CreateFilmInput,
UpdateFilmInput.
Each schema’s properties map carries exactly the field set the
corresponding projection declares — no schema reuse via $ref from
one projection to another. A reader scanning the document sees the
per-projection shape without dereferencing chains.
Names are SDK-friendly: orval, openapi-generator, kiota, and their
peers produce idiomatic, distinguishable types in the target language
without post-processing. The framework’s internal Rust struct names
(FilmReadProjection, FilmAdminReadProjection, …) NEVER leak into
the published document — components/schemas keys, $ref values,
operation IDs, and descriptions all carry only the public names from
the tables above.
x-ferra-promoted-in vendor extension
When a named projection carries default = true with a
breaking_change_version = "X.Y.Z" attestation (per
ADR-0030
§Default-projection promotion safeguards), every operation served
under the bare resource path gains an x-ferra-promoted-in: "X.Y.Z"
vendor extension. SDK generators and downstream tooling
(ferra anvil) surface the silent breaking change to consumers who
might otherwise see only the URL stability.
operationId
Every operation carries a stable operationId:
| Method | Path | operationId |
|---|---|---|
GET | /films | listFilms |
GET | /films/{id} | getFilm |
POST | /films | createFilm |
PUT | /films/{id} | updateFilm |
DELETE | /films/{id} | deleteFilm |
Versioned chains (Foundry::api_version("v1")) prefix the
operationId with the version segment: v1.listFilms,
v1.getFilm, etc.
tags
Every operation carries a single tag — the resource name in
snake_case plural (films, actors). Scalar / Redoc / Swagger UI
group operations by tag for easier navigation.
Field type mapping
Field types map to OpenAPI 3.1 schemas as follows:
| Rust type | OpenAPI |
|---|---|
String | { "type": "string" } |
i32 | { "type": "integer", "format": "int32" } |
i64 | { "type": "integer", "format": "int64" } |
f32 / f64 | { "type": "number" } |
bool | { "type": "boolean" } |
ferra::Id / Uuid | { "type": "string", "format": "uuid" } |
Option<T> | nullable T ({ "type": ["string", "null"] }) and absent from required |
ferra::DateTime | { "type": "string", "format": "date-time" } |
ferra::Date | { "type": "string", "format": "date" } |
Option<T> uses the OpenAPI 3.1 nullable form (a type array
containing "null"), not the deprecated 3.0 nullable: true
keyword.
The two typed time newtypes (ferra::DateTime and ferra::Date)
are recognised by the framework and emit the right format. Direct
use of legacy time crates (chrono::*, time::*) is rejected at
the field-declaration site — see the §“Time vocabulary” section of
ferra-core.md.
Field annotations
Two #[field(...)] attributes shape what appears in each
projection.
#[field(skip)]
Excludes the field from every projection schema and from the wire contract. Useful for server-only state (audit timestamps, internal flags).
#[derive(FerraModel)]
pub struct Film {
#[id]
id: ferra::Id,
title: String,
#[field(skip)]
internal_note: String,
}
internal_note does not appear in Film, CreateFilmInput, or
UpdateFilmInput, and the wire shape rejects an inbound value for
that field.
#[ferra(read_only)]
The field appears in every read schema (Film and any per-projection
read schema where the field is included) marked readOnly: true, and
is structurally absent from every create / update input schema.
Useful for server-assigned values that the consumer can read but
cannot write.
#[derive(FerraModel)]
pub struct Film {
#[id]
id: ferra::Id,
title: String,
#[ferra(read_only)]
indexed_at: ferra::DateTime,
}
indexed_at appears in Film with readOnly: true; both
CreateFilmInput and UpdateFilmInput omit the property entirely.
If the model also declares a named admin projection that includes
indexed_at in its read = [...] list, the field appears in
FilmAdmin with readOnly: true and is omitted from
CreateFilmAdminInput / UpdateFilmAdminInput.
#[ferra(write_only)]
The field appears in every write schema (CreateFilmInput /
UpdateFilmInput / per-projection input schemas where the field is
included), and is structurally absent from every read schema —
not marked writeOnly: true on the read side, but simply missing.
Useful for fields the consumer must supply on write but should never
see on read (one-shot secrets, transient hints, write-side credentials).
#[derive(FerraModel)]
pub struct Film {
#[id]
id: ferra::Id,
title: String,
#[ferra(write_only)]
new_password: String,
}
new_password appears in CreateFilmInput and UpdateFilmInput;
the Film read schema omits the property entirely. The framework
guarantees the field cannot leak via a read operation — its absence
is enforced at compile time on the generated read-projection struct.
#[ferra(computed)]
The field is server-derived (e.g., a derived count, a denormalised
projection of joined rows). It appears in every read schema with
readOnly: true, and is structurally absent from every write input
schema — the framework calls FerraComputed::compute() /
compute_async_batch() on read and never accepts the field as inbound
input.
#[derive(FerraModel)]
pub struct Film {
#[id]
id: ferra::Id,
title: String,
#[ferra(computed)]
review_count: i64,
}
review_count appears in Film with readOnly: true; the create
and update inputs omit the property entirely. See
ferra-core.md § Computed fields
for the FerraComputed trait that drives the compute pipeline.
The shared ProblemDetails schema
Every error response in the spec references the same schema:
{
"$ref": "#/components/schemas/ProblemDetails"
}
The schema’s body:
{
"type": "object",
"required": ["type", "title", "status"],
"properties": {
"type": {
"type": "string",
"format": "uri",
"enum": [
"https://ferra.rs/errors/not_found",
"https://ferra.rs/errors/validation",
"https://ferra.rs/errors/conflict",
"https://ferra.rs/errors/internal",
"https://ferra.rs/errors/payload_too_large",
"https://ferra.rs/errors/rate_limited",
"https://ferra.rs/errors/unauthorized"
]
},
"title": { "type": "string" },
"status": { "type": "integer", "format": "int32" },
"detail": { "type": "string" },
"instance": { "type": "string", "format": "uri-reference" }
}
}
The closed enum on type is the load-bearing constraint: SDK
generators produce a typed union over the URI set in the target
language. An AI agent generating client code is foreclosed from
fabricating new URIs — values outside the enum fail schema
validation.
The full URI table (with HTTP status, title, and example body for every variant) lives in error handling.
Mounting an alternative UI
/docs/openapi.json is the framework’s stable spec endpoint; the
Scalar UI at /docs is the bundled default but not the only
choice. To replace Scalar with Redoc, RapiDoc, or Swagger UI, mount
the alternative on the existing axum router via Router::merge:
use ferra::Foundry;
use utoipa_redoc::{Redoc, Servable};
let app = Foundry::new(conn)
.mount::<Film>()
.build() // no .with_docs()
.merge(ferra_openapi::docs_routes( // hand-mount the JSON
ferra_openapi::build_openapi(
ferra_openapi::info("Hello", "0.5.0"),
"Film",
Film::meta(),
),
"/docs",
))
.merge(Redoc::with_url("/redoc", openapi)); // and Redoc on top
The same pattern works with utoipa-rapidoc and utoipa-swagger-ui.
Where the spec is built
build_openapi(info, model_name, meta) assembles the full document
from a model’s static ModelMeta reference. The function runs once
at process startup; the resulting OpenApi value is cached and
served unchanged on every /docs/openapi.json request — no
per-request rebuild.
For the multi-model assembly, build_openapi_for_models(info, &[ModelEntry, ...]) produces a single document covering every
mounted model. Foundry::build() calls this internally; consumers
who want to assemble a spec by hand can call it directly.
Linting your spec
The framework’s CI runs Spectral
against every reference example’s generated /docs/openapi.json,
extending the default spectral:oas ruleset with four Ferra-specific
rules: every operation declares a tags array, every operation
declares an operationId, every request body schema is a $ref into
components/schemas, and every response schema is a $ref into
components/schemas. New linter findings at the warn severity bar
block the build.
Two takeaways for consumer-side CI:
- Spectral is the recommended linter for a Ferra-served API.
The framework’s
.spectral.yaml(in the workspace root) is a reasonable starting ruleset. Copy it into a downstream repo and add deployment-specific rules (e.g., re-enableoas3-api-serversonce the deployment carries a non-emptyserversarray). - The rule selection is not arbitrary. The full
adopted / deferred / rejected matrix — including why Spectral wins
over Redocly, why Vacuum is rejected, and why
progenitor/openapi-generator/orvalsmoke-tests are deferred rather than adopted — lives in the contributor research memo atdocs/research/dropshot-openapi-conformance.md. Curious readers consult that memo; correct use of the gate does not require it.
See also
Foundry— the assembly facade that mounts the docs surface alongside resource routes (lands alongside this crate in 0.5.0).- Error handling — the closed
ERROR_TYPESURI namespace and theProblemDetailsconsumer- side branching pattern. ferra-core§Time vocabulary — the typedDateTime/Datenewtypes that emitformat: date-timeandformat: datein the spec.
For curious readers, the architectural decisions behind the docs
surface are recorded in ADR-0024 (crate scope + public-default
posture), ADR-0026 (closed ERROR_TYPES URI namespace), and
ADR-0003 v2 (Scalar over Swagger UI as the default UI). These
ADRs are not load-bearing for correct use of the feature; this
guide stands alone.