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 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 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():

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 — 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:

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.

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 = […]))]):

ProjectionOpenAPI schema nameOperations served at
ReadFilmAdminGET /admin/films/{id}, GET /admin/films
CreateCreateFilmAdminInputPOST /admin/films
UpdateUpdateFilmAdminInputPUT /admin/films/{id}
CollectionFilmAdminCollectionGET /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:

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).

#[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-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.