@tetsujs/openapi reads an application’s routes and produces an OpenAPI 3.1
document and a page that renders it. Paths, parameters, bodies and responses
come from the schemas and response maps the routes already have, so nothing
is written twice. To turn the document into a typed client, see
Typed client from OpenAPI.
Title, version and the rest of the document's info block.
info: {
title: string
title: "Users API",
version: string
version: "1.0.0" } }),
],
});
/openapi.json serves the document and /docs renders it. The document
covers the whole application docs() is mounted in, except its own two
routes. It is built once, at startup, so anything that cannot be described
is reported before the first request.
docs() is an ordinary controller: put it in a group to move it under a
prefix, guard it with hooks, or leave it out in production. A group does not
narrow the document, so two documents, such as a public API and an admin
one, need two applications, each with its own docs().
A route adds what the schemas cannot say in docs:
constlist=route({
method: "GET"
The method this route answers.
Kept as a literal rather than widened to
Method
: the method is
half of a route's identity, and an application that remembers its
routes — for a generated client, for tooling — needs to know which one
this is.
method: "GET",
path: "/users"
Route path with :param segments, e.g. "/orders/:id/cancel".
Must start with /, contain no empty segments and no trailing slash;
a malformed literal is a compile error.
path: "/users",
docs?: RouteDocs |undefined
Documentation metadata for OpenAPI generation.
docs: {
summary?: string |undefined
summary: "List users",
tags?: readonly string[] |undefined
tags: ["users"],
operationId?: string |undefined
The operation's id in the generated document, stated rather than
derived.
Without it the id is the controller's name joined with the route's
field — authRequestCode — which is stable as long as those two are.
State it where the id is a contract of its own: on a public API, a
generated SDK names its methods after these, and an id written here
is one a reviewer sees change.
operationId: "listUsers" },
handler: (ctx: {
readonlyreq:Request& {
readonlycookies?:Bun.CookieMap;
};
readonlyserver:Bun.Server<unknown>;
readonlyout:Outgoing;
readonlyroute:RouteInfo;
readonlystartedAt:number;
readonlyparams: {};
}) => never[]
The endpoint logic; ctx is fully inferred, never annotate it.
The return type is inferred rather than demanded, and checked twice
over. Against the route's own contract, by the
HandlerResult
bound on R: answering with something response never declared is a
compile error that says so, instead of a structural diff against
Response. And against what the framework can serialize at all, by
the intersected
ValidateResult
: a stream handed over bare is
refused whether or not the route declared anything, because that is
the case no contract covers — without a response schema
HandlerResult is unknown and accepts every value there is.
handler: () => [],
});
docs also takes description, deprecated, and hidden: true, which
leaves the route out of the document while it is still served.
hidden: false keeps a route in when its handler would hide it, see
Documenting a handler. WebSocket endpoints are
never in the document: OpenAPI cannot describe what happens after the
handshake.
secured() hooks on the route, its groups and the application
Schemas are converted through Standard Schema’s JSON Schema support, which
Zod, ArkType and @tetsujs/typebox provide;
Valibot needs toStandardJsonSchema from @valibot/to-json-schema.
Responses follow the route’s response map (see
Responses):
One schema is a 200. A map gives its statuses. null is a status
without a body, and so is an entry with neither body nor a
contentType of its own.
A route with no schema.response is documented as 200. If its handler
can answer 204, declare 204: null.
An entry { body, headers, cookies } documents its headers, required as
its schema says, and its cookies as one set-cookie header.
An entry with contentType documents its body under that media type
instead of application/json, with body as its schema, or by the type
alone without one: a CSV export, a file, "text/event-stream" for
sse(). See
Responses. OpenAPI
3.1 cannot describe the events of a stream one by one, so a stream is
documented by its type alone.
When several sources answer with one status, the JSON body is one flat
anyOf, the route’s own schema first. A body of another type is listed
next to it under its own media type.
A generated client names its methods after the operationIds, so every one
comes from a name you wrote:
Route
operationId
with docs: { operationId }
as written
in controller("Users", …) as setAvatar
usersSetAvatar
in a class UsersController as setAvatar
usersSetAvatar
in an object literal as setAvatar
setAvatar
mounted on its own, POST /auth/code
postAuthCode
Two routes with the same id are refused when the document is built, naming
both: docs() stops the application at startup, and openapi() throws.
Nothing is renamed for you, since that would change a method in a generated
client the day a route is added. Give one of them docs.operationId, or its
controller another name. On a public API, write the id on every route, so a
change to it shows up in review.
Title, version and the rest of the document's info block.
info,
tags?: Readonly<Record<string, string>>|undefined
What each tag is — see tags on
openapi
. The controller's own
tag, docs, is described for you when its routes are in the document
(documentSelf) and you did not describe it.
tags: {
"sign-in": "Signing in with a code sent by email, and signing out",
me: string
me: "The signed-in user",
},
});
Renderers list the sections in the order of the keys. A tag the routes use
but tags leaves out comes after them and is reported as a warning. It is
usually one tag spelled two ways, sign_in next to sign-in. A tag
described but never used is reported too. Without tags, the document lists
none and nothing is reported.
The framework answers some failures by itself. They are documented on every
route where they can happen, in the envelope the server sends:
Status
error
When
422, or validation.status of createApp
VALIDATION_FAILED
the route has a request schema, and a part failed it
400
MALFORMED_JSON / MALFORMED_FORM
the route reads a JSON or form body, and it could not be parsed
413
BODY_TOO_LARGE
the route reads a body, and it exceeded maxBodySize
500
INTERNAL_SERVER_ERROR
every route: an unhandled failure
See Framework error codes. A status the route
declares itself is kept, and the framework’s failures for that status are
listed next to it.
Every envelope, whether from the framework, a hook or the route, becomes one
definition in components, named after its code: ITEM_NOT_FOUND becomes
ItemNotFound. A generated client gets one type per failure. A schema counts
as an envelope when its error is required and a single string. When every
alternative of a status is an envelope, the status gets a discriminator on
error, so a client can narrow on the code.
A status’s description comes from what answers with it: the description
of the route’s schema (.describe() in Zod and ArkType, v.description()
in Valibot, { description } in TypeBox), a hook’s or a handler’s
documented() description, or the framework’s own. Several descriptions
become a list led by each code:
-`ACCOUNT_DISABLED`: this account is disabled
-`CAPTCHA_FAILED`: the captcha token is missing or did not pass
A status with no description keeps its reason phrase, such as Not Found.
A hook that answers by itself, such as an auth check or a limiter, can say
so. Every route it guards is then documented with it. secured() adds a
security scheme, documented() adds responses:
The raw incoming request, always available as an escape hatch.
On a request that matched a route, Bun's router delivers its own
request object carrying cookies — a Bun.CookieMap whose mutations
are applied to the response as Set-Cookie automatically, with Bun's
defaults (Path=/; SameSite=Lax). The field is optional because it
does not exist where Bun's router was not involved: the 404 fallback
and unit-tested handlers. ctx.out.headers.append("set-cookie", ...)
is the fallback that works everywhere.
req.
headers: Headers
The headers read-only property of the with the request.
What the handler answers with, as the route's own responses: a status
the route's response map declares too is described by both, and a
route that says nothing gets no placeholder 200 in their place.
responses: [
{
status: number
status: 429,
description: string
description: "Rate limit exceeded",
error?: string |undefined
The error code of the failure envelope, when the hook answers with
the framework's shape — "RATE_LIMITED", "SESSION_EXPIRED".
Documented as a const, so a client can discriminate on it. Only the
hook knows its own code; without one the document says the field is a
string and no more.
Fields the hook adds to the envelope, as JSON Schema by name — the
retryAfter of { ...errorBody(429, "RATE_LIMITED"), retryAfter }.
Each is documented as always present. The envelope's own status,
message and error are not redefined by a field of the same name:
error is what a client discriminates on, and it stays the code the
hook declared. Ignored when the response has a schema, which says
everything about the body itself.
Headers the hook sets on this response, by name — the retry-after
of a refusal.
Documented as possible rather than required: a status several sources
answer with is one response in the document, and a header one of them
sets is not on the others'.
Both return a copy of the hook with the same type, so it goes into a stack
like any other. A hook mounted on a group or the application documents every
route under it. Hooks from @tetsujs/rate-limit
come documented already.
A response in documented() takes:
contentType: the media type of a body that is not JSON, such as
"text/html", without parameters. documented() throws on anything but
a bare type or a range.
error: the envelope’s code, documented as a const.
fields: what the hook adds next to status, message and error,
always present.
headers: what it sets on the response, documented as possible, not
required.
message: an example of the envelope’s message.
schema: the body, for a hook that does not answer with the envelope.
Without schema or contentType, an error status is the envelope and any
other status, such as a redirect, has no body.
fields and headers are JSON Schema typed keyword by keyword
(JsonSchema), so a misspelled keyword does not compile.
secured() takes:
Field
Default
name
required
the name the scheme is registered under in components
scheme
required
the OpenAPI security scheme; its type is http, apiKey, oauth2, openIdConnect or mutualTLS
scopes
none
OAuth2 scopes
status
401
the status a refused request gets
description
none
how the refusal is described
error
none
the refusal’s error code
message
none
an example of the refusal’s message
Every hook of a route runs, so the schemes of all its hooks are required
together: a route behind a CSRF check and a captcha needs both. To accept
one credential or another, such as a session cookie or a bearer token, check
both in one hook and pass anyOf:
A handler a package hands out, such as one serving a directory of files,
can describe every route it is mounted on, the way a hook describes the
routes it guards. documented() takes the handler and returns a copy that
answers the same way:
import { documented } from"@tetsujs/openapi";
exportconstfiles=documented(readFile, {
hidden?: boolean |undefined
Keeps the route out of the document unless the route says
docs: { hidden: false }.
For a handler whose routes are rarely anyone's API — the files of a
site — and which a generated client could not call anyway. The route's
own word wins either way: docs: { hidden: true } hides it whatever
the handler says.
What the handler answers with, as the route's own responses: a status
the route's response map declares too is described by both, and a
route that says nothing gets no placeholder 200 in their place.
responses: [
{
status: number
status: 200,
description: string
description: "The file",
contentType?: string |undefined
The media type of the body when it is not JSON — "text/html", or a
range such as "image/*" or the range of every type for a file whose
type is not known in advance. Without a schema, the body is
described by its type alone.
Two responses of one status with different types are both in the
document, each under its own: the site's 404.html next to the
envelope an API client gets.
Headers the hook sets on this response, by name — the retry-after
of a refusal.
Documented as possible rather than required: a status several sources
answer with is one response in the document, and a header one of them
sets is not on the others'.
The error code of the failure envelope, when the hook answers with
the framework's shape — "RATE_LIMITED", "SESSION_EXPIRED".
Documented as a const, so a client can discriminate on it. Only the
hook knows its own code; without one the document says the field is a
string and no more.
error: "NOT_FOUND" },
],
});
Its responses are the route’s own: a route that mounts it needs no
schema.response, and gets no placeholder 200.
hidden: true keeps every route that mounts it out of the document,
unless the route says docs: { hidden: false }. The route’s own word
wins either way.
An arrow that wraps the handler is what the route then mounts, and it
says nothing. Hide such a route, or describe it, on the route itself.
Annotate a handler whose type is already settled, such as a package’s. An
arrow written inside documented() on a route does not get the route’s
context and does not compile; a route of your own describes its statuses
in its response map.
An application can answer every failure in a format of its own with one
onError hook, as Errors shows. The document cannot
read that hook, so describe the same format with errors. Here the format is
{ code, message }:
import { docs, type ErrorFormat } from"@tetsujs/openapi";
An example of the message; wording, never a contract.
message? {
examples?: readonly unknown[] |undefined
examples: [
message: string
An example of the message; wording, never a contract.
message] } : {}) },
...
fields: Readonly<Record<string, JsonSchema>>
What the failure carries besides, as JSON Schema by name.
fields,
},
}),
discriminator?: string |undefined
The top-level field that holds the code, for a client to discriminate
on — "code" in { code, message }. A status whose alternatives are
all envelopes then carries a discriminator on it.
None unless named: OpenAPI discriminates on a top-level field only, and
a format that nests its code — { error: { code } } — has none to
name.
discriminator: "code",
};
createApp({
hooks: { onError: [inOurFormat] },
routes: [api, docs({
info: DocumentInfo
Title, version and the rest of the document's info block.
info,
errors?: ErrorFormat |undefined
How the application's errors look, when not like the framework's
envelope — see errors on
openapi
.
errors })],
});
errors field
schema
builds the JSON Schema of one failure from { status, error, message, fields }. error is the code when known; fields holds what the failure carries besides, such as issues or retryAfter
discriminator
the top-level field that holds the code. Statuses are discriminated on it, and a route’s own envelope is recognized by it. None unless named
code
reads the code from a schema a route or hook declared, for a format that nests its code. By default, the const of the discriminator’s field
The hook and errors describe one format in two places. Keep a test that
provokes each kind of failure, the unexpected one included, and checks it
with assertDescribed.
It throws unless the operation declares the status and the body fits one of
that status’s alternatives, and lists every problem:
POST /session answered 403, which the document does not describe:
- error "CAPTCHA_FAILED", which its 403 does not list: ACCOUNT_DISABLED, CSRF_HEADER_REQUIRED
The second argument is the method and the path the test requested, such as
"GET /users/42"; it is matched against the document’s path templates. The
body is read from a clone, so the test can still read the response. Its
media type is compared without regard to case, and a range in the document,
such as image/* or */*, takes every type in it. The body is parsed and
checked only under application/json or a +json type; under any other
type, an empty body passes, as an export with no rows does.
Option
Default
validate
none
(schema, body) => true | string: checks the whole body with a JSON Schema validator of your choice, such as (schema, body) => ajv.validate(schema, body) || ajv.errorsText(). Without it, only the top level is compared: required fields and const fields
headers
none
headers the status must declare when the response carries them, such as ["retry-after"]
A header the status declares as required, such as location or
set-cookie, must be on the response even without headers. See
Testing.
To write the document to a file in CI or feed it to a client generator, call
openapi(). It takes info, servers, errors and tags, and mounts
nothing:
import { openapi } from"@tetsujs/openapi";
const {
constdocument:OpenApiDocument
document,
constwarnings:readonlyGeneratorWarning[]
warnings } =openapi(
constapp:App<RoutesOf<readonly []>>
app, {
info: DocumentInfo
Title, version and the rest of the document's info block.
info: {
title: string
title: "Users API",
version: string
version: "1.0.0" },
});
await Bun.write("openapi.json", JSON.stringify(
constdocument:OpenApiDocument
document, null, 2));
Each warning has a route, such as GET /path (empty for the document as a
whole), and a message.
docsPage({ ui, documentUrl, title?, assets? }) returns the page’s HTML, to
serve it from a route of your own. documentUrl is where the page fetches
the document from, and title defaults to "API documentation".
A route that cannot be fully described is still documented, and the gap is
reported instead of failing startup:
[openapi] POST /api/users: the body schema does not emit JSON Schema
Warnings are raised for:
a validator that does not emit JSON Schema, or a conversion that throws;
two routes that map to the same OpenAPI path, such as /files/* and
/files/:wildcard; the second replaces the first in the document;
a reference that leads nowhere. Recursive and named schemas emit
references (#, #/$defs/…) that no longer resolve once embedded in the
document: Zod’s recursive getters and .meta({ id }), ArkType’s scopes,
Valibot’s lazy. TypeBox’s Type.Cyclic is described correctly;
a tag used but not described in tags, or described and not used;
two different security schemes under one name. The first is kept.
docs() prints each warning with console.warn, or passes it to
onWarning.
The page loads its renderer (Scalar, Swagger UI or Redoc) from jsDelivr and
runs it on the application’s origin, with that origin’s cookies. The default
files are pinned to an exact version with a Subresource Integrity hash, so
the browser refuses a file the CDN changed. That still makes it someone
else’s code running next to your users’ sessions.
Where the origin carries a session, serve the document alone:
Title, version and the rest of the document's info block.
info,
ui?:false|undefined
Which renderer the page bootstraps. Defaults to "scalar".
false serves the document without a page — what production wants
on an origin that carries a session. The page runs its renderer on the
application's origin, so a renderer that is not what it should be acts
in the signed-in user's name there; the document alone runs nothing.
uiPath, title and assets are then unused.
ui: false });
Or host the renderer yourself, or pin your own copy with its hash:
Subresource Integrity hashes of the two files, for a copy that is not
on the application's own origin: the browser runs the file only if it
hashes to this. Absent, the files are loaded as they are.
One line computes one:
curl -s <url> | openssl dgst -sha384 -binary | openssl base64 -A,
prefixed with sha384-.
integrity: {
script?: string |undefined
script: "sha384-…" },
},
});
curl -s <url> | openssl dgst -sha384 -binary | openssl base64 -A prints the
hash; prefix it with sha384-. Swagger UI also needs style and
integrity.style.
A content-security-policy that denies scripts from other origins, such as
apiPolicy of @tetsujs/secure-headers,
leaves the page blank. That page shows how to lift the policy for this route.
openapi, with OpenApiOptions, GeneratorResult and GeneratorWarning.
docsPage, with DocsPageOptions, DocsUi and DocsAssets.
documented and secured, with HookDocs, HandlerDocs,
DocumentedResponse, DocumentedHeader, SecurityRequirement,
SecurityAlternatives, SecurityScheme and HookContributions.
ErrorFormat and DocumentedFailure, for an error format of your own.
JsonSchema, JsonSchemaKeywords and JsonSchemaType, for JSON Schema
written by hand.
The document’s types: OpenApiDocument, DocumentInfo, DocumentServer,
PathItemObject, OperationObject, ParameterObject, ResponseObject,
HeaderObject, ContentMap and TagObject.
From @tetsujs/openapi/testing: assertDescribed, with AssertDescribedOptions.