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

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 — full OpenAPI 3.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 ProblemDetails schema referenced by every error response.
  • Every operation carries an operationId and a tags array containing the resource name. Zero inline schemas in operation definitions; every body schema is a $ref into components/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

MethodEffect
.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:

ProjectionOpenAPI schema namePurpose
ReadFilmResponse shape for GET and the body of POST / PUT responses.
CreateCreateFilmInputRequest body of POST /films.
UpdateUpdateFilmInputRequest body of PUT /films/{id}.
CollectionFilmCollectionResponse 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:

MethodPathoperationId
GET/filmslistFilms
GET/films/{id}getFilm
POST/filmscreateFilm
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 typeOpenAPI
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-enable oas3-api-servers once the deployment carries a non-empty servers array).
  • 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 / orval smoke-tests are deferred rather than adopted — lives in the contributor research memo at docs/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_TYPES URI namespace and the ProblemDetails consumer- side branching pattern.
  • ferra-core §Time vocabulary — the typed DateTime / Date newtypes that emit format: date-time and format: date in 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.