OpenAPI
Generate an OpenAPI 3.1 spec + Swagger-UI docs from your defineRestRoute descriptors and (opt-in) your rpc procedures. Your routes ARE the API docs — nothing hand-maintained.
@voltro/plugin-openapi turns your defineRestRoute descriptors — and, opt-in, your rpc procedures (queries / mutations / actions / streams) — into a live OpenAPI 3.1 document + a Swagger-UI page. The descriptors already carry method, path, input/output Schemas, summary and deprecation; the rpc procedures carry input/output/error Schemas. The plugin reads them, so there is zero hand-written API documentation to drift.
Wiring
// app.config.ts
import { openapiPlugin } from '@voltro/plugin-openapi'
import listCustomers from './routes/v1/customers.list.route'
import createCharge from './routes/v1/charge.route'
export default {
type: 'api' as const, name: 'api',
restRoutes: [listCustomers, createCharge],
plugins: [
openapiPlugin({
routes: [listCustomers, createCharge], // the same descriptors
info: { title: 'Payments API', version: '1.0.0' },
// specPath: '/openapi.json', docsPath: '/docs' (defaults; docsPath:false disables the UI)
// token: process.env.OPENAPI_DOCS_TOKEN, // opt-in Bearer gate (see Permissions)
}),
],
}Mounts GET /openapi.json (the spec) and GET /docs (Swagger UI).
What it maps
- Each route → an OpenAPI path + operation, keyed by method.
- The input Schema's
queryfields →parameters(in: query);params→ path params;body→ a JSONrequestBody. - The output Schema → the
200JSON response. summary/description→ operation metadata;deprecated→deprecated: true;example.request/example.response→ request-body / 200-response examples;sunset→ thex-sunsetextension.- The serve pipeline's real error statuses are documented per operation —
400(input decode, when the route has aninputschema),401/403(when it hasguards),410(whensunsetis set),500(always) — all referencing one sharedcomponents.schemas.VoltroHttpErrorenvelope. - When any route declares
guards, the document publishescomponents.securitySchemes.bearerAuthand guarded operations carrysecurity: [{ bearerAuth: [] }].
Schemas are converted with effect's JSONSchema.make, so your effect/Schema definitions become JSON Schema verbatim. Pure: generateOpenApiSpec(routes, info) is exported + unit-tested if you want the spec without serving it.
rpc procedures in the spec
Pass your rpc procedure descriptors via the procedures option and each query / mutation / action / stream is projected into the same spec. The framework's rpc surface is a batched JSON-RPC protocol over one endpoint (POST /rpc) — not a REST-per-resource API — so the projection is honest about that: every procedure becomes a POST /rpc/<name> operation whose body is the rpc payload (the input Schema), tagged rpc and flagged with an x-voltro-rpc extension. The operation description states the real transport; the path is a documentation projection, not a REST route you call directly.
import { openapiPlugin } from '@voltro/plugin-openapi'
import listOrders from './orders.list.query'
import createOrder from './orders.create.mutation'
import charge from './billing.charge.action'
import runAgent from './agent.run.stream'
openapiPlugin({
routes: [listCustomers], // REST routes (optional)
procedures: [listOrders, createOrder, charge, runAgent], // rpc procedures
info: { title: 'API', version: '1.0.0' },
})Per procedure kind:
- query →
POST /rpc/<name>;input→ requestBody,output→ the200response (documented as the first snapshot; the WebSocket rpc channel then pushes reactive updates). - mutation / action →
input→ requestBody,output→200. - stream → the
elementSchema is documented as a streaming response (application/x-ndjson, one element per line) — not faked as a single JSON value. - The procedure's typed error channel (its
Schema.TaggedErrorunion) becomes a422response referencing the error schema; a procedure with no declared error documents only200+500.
Mixing rpc + REST in one spec is fine — the rpc operations are grouped under the rpc tag and flagged x-voltro-rpc, so a deployment can tell them apart from real REST routes. procedures is opt-in: omit it to keep the spec REST-only.
The app's own surface — publicApi projections included
The routes the framework mounts for the app — every restRoutes descriptor and every publicApi projection — are documented without listing them: the framework tells the plugin the mounted surface once it exists, on voltro dev and voltro serve alike (includeAppRoutes, default true). A route also passed in routes is documented once. Do not import your descriptor modules into app.config.ts to build that list yourself — evaluating them before discovery is how a boot has hung. includeAppRoutes: false documents routes and procedures only.
The same holds for the rpc procedures: includeAppProcedures: true documents every query, mutation, action and stream discovery found — internal ones excluded, they are not on the wire — as POST /rpc/<name> operations, told to the plugin once discovery has run on both boot paths (onProcedureSurface). It is opt-in, so the spec stays REST-only unless asked; a procedure also listed in procedures is documented once. Filling procedures by importing the descriptor modules into app.config.ts evaluates every one of them before discovery on every load of the config, and voltro serve loads no TypeScript at runtime — this option is the way around both.
What a projection carries into the document, from the descriptor alone: tags from the rpc tag's prefix — teams.list → teams, the group a consumer looks for and an SDK generator turns into a client class — with a top-level tags[] listing every group (tagDescriptions: { teams: 'Teams and membership' } on the plugin adds the descriptions); an operationId from the tag (teamsList) so generators name methods instead of mangling paths; security on EVERY operation — required, or optional under openAccess, written as [{ bearerAuth: [] }, {}] so Swagger UI sends the token after "Authorize" and a generated client sets the bearer — with securitySchemes.bearerAuth published once any projection exists; one response per errorStatus entry with the error's own schema, 422 for the union's other tags, 429 for a mounted rate limiter; publicApi.example as the request/response example; path parameters in: path, query parameters typed from the input. Under a REST profile the document follows it: format: date-time where the descriptor says timestampMs, the ProblemDetails component in every error response with each typed error's type, Link and ETag as documented headers. A RestFormat of the app's own can reshape each operation with describe.
Several API versions — one document each, one dropdown
publicApi.version (and a route's version) mounts v1 and v2 as separate paths, and the unversioned spec carries them all, each operation marked x-voltro-api-version. Beside it every mounted version has a document of its own — /openapi/v2.json next to /openapi.json (a specPath without the .json suffix gets /versions/v2.json), or /openapi.json?version=v2 — the same spec filtered to that version, tags, operationId, security, errorStatus responses and examples included; an unknown version answers 404 naming the mounted ones. The rpc procedures, unversioned, appear in the whole document only. With more than one version mounted the docs page shows Swagger UI's "Select a definition" dropdown: one entry per version, newest first and preselected, an expiring version named with the earliest sunset among its routes (v1 — sunset 2027-03-01), and the whole document last as "all versions". A consumer integrating v2 never sees v1, and a client generated from /openapi/v2.json contains one version.
The head of the document
What a consumer looks for first is in info and beside it: openapiPlugin({ info: { title, version, description, contact, license, termsOfService, logo } }) writes info.contact, info.license, info.termsOfService and x-logo (what ReDoc renders); externalDocs links the handbook on the document and tagExternalDocs per tag group. servers names where the API is reachable — one entry per environment, each tagged x-voltro-environment — and the first entry is the default base URL of a generated client and of a collection. Omitted, the plugin writes one entry from VOLTRO_PUBLIC_API_ORIGIN (the public origin of this api; VOLTRO_API_ORIGIN is the web app's server-side route to it and VOLTRO_PUBLIC_URL the storage plugin's) tagged with environment — VOLTRO_ENVIRONMENT, else production under NODE_ENV=production and dev otherwise. The document carries the same environment as x-voltro-environment.
openapiPlugin({
routes: [],
info: { title: 'Acme API', version: '2.0.0', contact: { email: 'api@example.com' }, license: { name: 'Proprietary' } },
servers: [
{ url: 'https://api.example.com', environment: 'production' },
{ url: 'https://stage.example.com', environment: 'staging' },
],
externalDocs: { url: 'https://docs.example.com' },
tagExternalDocs: { teams: { url: 'https://docs.example.com/teams' } },
})Examples and the generic refusals
Every request body and every 200 carries an example. An author's publicApi.example is written in the form of the schema (Date, number) and served in the wire form of the profile — one example is right under standard and jsonapi alike; an example already written in wire form passes through. An operation without one gets a body derived from its wire schema, deterministically, marked x-voltro-example: derived so a collection does not present it as the author's word; voltro doctor reports an example that decodes against neither form. The generic 400, 401 and 500 show the URN they actually answer with (urn:voltro:error:InvalidRequest, …:Unauthorized, …:InternalServerError) and a detail shaped like the server's. The Link header is documented only on a route whose output carries the profile's cursor field — the one that can answer with a next page.
Access control
Both routes publish your full API contract (every path, param, and schema), and both are unauthenticated by default — mount them only where that's intended. Two levers narrow the surface:
docsPath: falsehides only the Swagger-UI viewer; the spec at/openapi.jsonstays served.token(or theOPENAPI_DOCS_TOKENenv var) requiresAuthorization: Bearer <token>on both routes — a missing or mismatched header gets a401. The same shape as@voltro/plugin-prometheus's gate. Leave it unset behind a trusted network boundary; set it for a public-internet deployment. A path guard at that boundary can name the two paths as written: both routes answer their exact spelling only —/OPENAPI.JSON,/openapi.json/and/Docsare404— so there is no second spelling the guard would have to cover.
openapiPlugin({
routes: [listCustomers, createCharge],
token: process.env.OPENAPI_DOCS_TOKEN, // unset → both routes are public
})Permissions
The plugin declares no runtime permissions — it only mounts the two GET routes built from descriptors you already declared.
Find the mounted documentation
The Plugins page in both dashboards lists the configured app HTTP routes.
Expand the OpenAPI plugin to open its actual specPath or docsPath, including
custom paths. When docsPath: false, only the JSON spec route is listed. Links
open on the inspected app in a new tab and do not forward inspect credentials;
the route's own authorization still applies. Parameterized routes and write
methods are shown as metadata rather than invoked by a link.