Skip to content

@tetsujs/openapi

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

Terminal window
bun add @tetsujs/openapi

Mount the docs() controller next to your own:

import { docs } from "@tetsujs/openapi";
createApp({
routes: [
group("/api", { children: [usersController()] }),
docs({
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" } }),
],
});

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

const list = 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: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {};
}) => 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.

Option Default
info required the document’s info: title, version, description
servers none where the API is reachable
path /openapi.json where the document is served
uiPath /docs where the page is served
ui "scalar" "scalar", "swagger-ui", "redoc", or false for no page
title the document’s title the page’s title
assets the renderer on jsDelivr, pinned your own renderer URLs, with integrity hashes, see The page and your origin
documentSelf false include the two docs routes in the document, under the tag docs
onWarning console.warn receives what could not be described, see Warnings
errors the framework’s envelope an error format of your own, see below
tags none a description for each tag, in sidebar order, see Tags

path and uiPath go under the prefix of any group docs() is mounted in, and the page finds the document there.

In the document Comes from
the path the route’s path under its groups’ prefixes: /users/:id becomes /users/{id}
operationId docs.operationId, or the controller and field name, see Operation ids
summary, description, tags, deprecated the route’s docs
parameters schema.params, query, headers and cookies, required as the schema says
the request body schema.body and bodyType; required unless bodyType is text or stream
responses schema.response, hooks annotated with documented() and secured(), a handler annotated with documented(), and the failures the framework adds
security 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.

A route names its tags in docs: { tags }. The tags option says what each one is:

const
const documentation: DocsController<"/openapi.json", "/docs", true>
documentation
= docs({
info: DocumentInfo

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:

import { documented, secured } from "@tetsujs/openapi";
export const auth = secured(
hook.beforeParse((ctx) => {
const
const user: {
id: string;
} | undefined
user
= verify(ctx.
req: Request & {
readonly cookies?: Bun.CookieMap;
}

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.

MDN Reference

headers
.get("authorization"));
if (!
const user: {
id: string;
} | undefined
user
) throw new HttpError(401);
return {
user: {
id: string;
}
user
};
}),
{
name: string

The name the scheme is registered under in components.

name
: "bearerAuth",
scheme: SecurityScheme

The scheme itself, registered once per name.

scheme
: {
type: "http" | "apiKey" | "oauth2" | "openIdConnect" | "mutualTLS"
type
: "http",
scheme: string
scheme
: "bearer",
bearerFormat: string
bearerFormat
: "JWT" } },
);
export const guard = documented(hook.beforeParse((ctx) => check(ctx)), {
responses?: readonly DocumentedResponse[] | undefined

What the hook can answer with.

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.

error
: "RATE_LIMITED",
fields?: Readonly<Record<string, JsonSchema>> | undefined

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.

fields
: {
retryAfter: {
type: "integer";
minimum: number;
}
retryAfter
: {
type?: JsonSchemaType | readonly JsonSchemaType[] | undefined
type
: "integer",
minimum?: number | undefined
minimum
: 0 } },
headers?: Readonly<Record<string, DocumentedHeader>> | undefined

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

headers
: { "retry-after": {
schema: JsonSchema
schema
: {
type?: JsonSchemaType | readonly JsonSchemaType[] | undefined
type
: "integer",
minimum?: number | undefined
minimum
: 0 } } },
},
],
});

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:

import { secured } from "@tetsujs/openapi";
export const caller = secured(hook.beforeParse((ctx) => ({
user: {
id: string;
}
user
: sessionOrToken(ctx) })), {
anyOf: readonly SecurityRequirement[]
anyOf
: [
const cookieSession: SecurityRequirement
cookieSession
,
const bearerToken: SecurityRequirement
bearerToken
],
});

With a CSRF check on the same route, the document says security: [{ session: [], csrf: [] }, { bearer: [], csrf: [] }].

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";
export const files = 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.

hidden
: true,
responses?: readonly DocumentedResponse[] | undefined

What the hook can answer with.

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.

contentType
: "*/*",
headers?: Readonly<Record<string, DocumentedHeader>> | undefined

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

headers
: {
etag: {
schema: {
type: "string";
};
}
etag
: {
schema: JsonSchema
schema
: {
type?: JsonSchemaType | readonly JsonSchemaType[] | undefined
type
: "string" } } },
},
{
status: number
status
: 304,
description: string
description
: "Not modified" },
{
status: number
status
: 404,
description: string
description
: "No such file",
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.

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";
const
const errors: ErrorFormat
errors
: ErrorFormat = {
schema: (failure: DocumentedFailure) => JsonSchemaKeywords

The body of one failure.

schema
: ({
error: string | undefined

The code a client branches on, when it is known.

error
,
message: string | undefined

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
}) => ({
type?: JsonSchemaType | readonly JsonSchemaType[] | undefined
type
: "object",
required?: readonly string[] | undefined
required
: ["code", "message", ...Object.keys(
fields: Readonly<Record<string, JsonSchema>>

What the failure carries besides, as JSON Schema by name.

fields
)],
properties?: Readonly<Record<string, JsonSchema>> | undefined
properties
: {
code: {
type: "string";
const: string;
} | {
type: "string";
}
code
:
error: string | undefined

The code a client branches on, when it is known.

error
? {
type?: JsonSchemaType | readonly JsonSchemaType[] | undefined
type
: "string",
const?: unknown
const
:
error: string

The code a client branches on, when it is known.

error
} : {
type?: JsonSchemaType | readonly JsonSchemaType[] | undefined
type
: "string" },
message: {
examples?: string[] | undefined;
type: "string";
}
message
: {
type?: JsonSchemaType | readonly JsonSchemaType[] | undefined
type
: "string", ...(
message: string | undefined

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.

@tetsujs/openapi/testing checks a response a test provoked against what the document says:

import { openapi } from "@tetsujs/openapi";
import { assertDescribed } from "@tetsujs/openapi/testing";
const {
const document: OpenApiDocument
document
} = openapi(app, {
info: DocumentInfo

Title, version and the rest of the document's info block.

info
});
test("a wrong code is what the document says", async () => {
const
const res: Response
res
= await
const request: RequestFn
(path: string, init?: RequestInit) => Promise<Response>
request
("/session", {
method?: string | undefined

A string to set request's method.

method
: "POST",
body?: BodyInit | null | undefined

A BodyInit object or null to set request's body.

body
});
await assertDescribed(
const document: OpenApiDocument
document
, "POST /session",
const res: Response
res
);
});

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 {
const document: OpenApiDocument
document
,
const warnings: readonly GeneratorWarning[]
warnings
} = openapi(
const app: 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(
const document: 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:

const
const documentOnly: DocsController<"/openapi.json", "/docs", false>
documentOnly
= docs({
info: DocumentInfo

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:

const
const pinned: DocsController<"/openapi.json", "/docs", true>
pinned
= docs({
info: DocumentInfo

Title, version and the rest of the document's info block.

info
,
assets?: DocsAssets | undefined

Overrides the renderer's CDN URLs, for a pinned or self-hosted copy.

assets
: {
script: string
script
: "https://cdn.jsdelivr.net/npm/@scalar/api-reference@1.72.1/dist/browser/standalone.js",
integrity?: {
readonly script?: string;
readonly style?: string;
} | undefined

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.

  • docs and DocsController, with DocsOptions.
  • 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.