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 auth-gated
variant (Foundry::with_docs_protected(verifier)) ships in the same
release and is the recommended posture for any publicly-reachable
network surface.
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():
#![allow(unused)]
fn main() {
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. The reason: the spec describes the API surface
but does not serve resource data, and gating discovery would block
the framework’s documented zero-to-API-in-5-minutes standard for
every new consumer. The deviation is recorded in ADR-0024; the
secure-default .with_docs_protected(verifier) ships in the same
release as the discoverable opt-in so the consumer’s ability to
choose a secure posture without changing crates is preserved.
For any publicly-reachable network surface, prefer
.with_docs_protected(verifier).
Naming conventions
Schema names
For a model named Film, the spec carries four projections 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.
These names are SDK-friendly: orval, openapi-generator, kiota, and their peers produce idiomatic types in the target language without post-processing.
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).
#![allow(unused)]
fn main() {
#[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.
#[field(read_only)]
The field appears in the read projection ({Model}) marked
readOnly: true, but is excluded from CreateFilmInput and
UpdateFilmInput. Useful for server-assigned values that the
consumer can read but cannot write.
#![allow(unused)]
fn main() {
#[derive(FerraModel)]
pub struct Film {
#[id]
id: ferra::Id,
title: String,
#[field(read_only)]
indexed_at: ferra::DateTime,
}
}
indexed_at appears in Film with readOnly: true; the create /
update inputs reject the field.
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:
#![allow(unused)]
fn main() {
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.