npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@tetsujs/openapi

v0.5.3

Published

An OpenAPI 3.1 document and docs page generated from Tetsu routes

Readme

@tetsujs/openapi

An OpenAPI 3.1 document and a docs page, generated from the routes you already declared.

bun add @tetsujs/openapi

Usage

Mount the docs() controller next to your own:

import { docs } from "@tetsujs/openapi";

createApp({
  routes: [
    group("/api", { children: [usersController({ users })] }),
    docs({ info: { title: "Users API", version: "1.0.0" } }),
  ],
});

/openapi.json serves the document and /docs renders it. The document 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. Its own two routes are left out of the document.

The page runs someone else's code on your origin

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. If a session cookie lives there, a renderer that is not what it should be acts as the signed-in user. The defaults are pinned to an exact version with a Subresource Integrity hash, so a file the CDN changes is refused by the browser; that does not make the renderer yours, and what it loads in turn is not covered.

Where the origin carries a session, serve the document alone:

docs({ info, ui: false });

/openapi.json is served and there is no page. For a page anyway, host the renderer yourself or pin your own copy, with its hash:

docs({
  info,
  assets: {
    script: "https://cdn.jsdelivr.net/npm/@scalar/[email protected]/dist/browser/standalone.js",
    integrity: { script: "sha384-…" },
  },
});

curl -s <url> | openssl dgst -sha384 -binary | openssl base64 -A prints the hash, to be prefixed with sha384-.

Options

| Option | Default | | | --- | --- | --- | | info | — | the document's info: title, version, description | | servers | — | 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 | | documentSelf | false | include /openapi.json and /docs in the document | | onWarning | console.warn | receives what could not be described | | errors | the framework's envelope | an error format of your own — see below | | tags | — | what each tag is, in the order the sidebar lists them — see below |

How a route becomes an operation

Every part of an operation comes from somewhere the application already wrote, by a rule:

| In the document | Comes from | Rule | | --- | --- | --- | | the path | the route's path, under its groups' prefixes | /users/:id → /users/{id} | | operationId | docs.operationId, or the controller's name and the field | setAvatar in controller("Users", …) → usersSetAvatar — see Operation ids | | summary, description, tags, deprecated | the route's docs | as written | | parameters | schema.params, query, headers, cookies | required as the schema says | | the request body | schema.body and bodyType | media types from bodyType; required unless text or stream | | security | secured() hooks, of every level the route runs under | every hook required together; anyOf inside one hook is the alternatives | | the statuses | the route's schema.response, the hooks' documented() and secured(), the framework | one schema is 200, a map its statuses, null a status without a body; with no 2xx declared, 200; the framework's 422, 400, 413, 500 where they can happen — see Responses the framework adds | | a status's body | everything answering with that status | one flat anyOf, the route's own first, the same body once | | an error envelope | any of them | one definition per status and code in components, named after the code; the route's own kept | | discriminator | the envelopes of a status | on error, or the format's field, when every alternative is an envelope | | a status's description | the schemas' description, the hooks', the framework's | one is the description, several a list led by code, none the reason phrase | | a status's headers | the hooks' documented() headers | possible, not required | | the document's tags | the tags option | in its order, then the tags routes use and it leaves out |

docs: { hidden: true } leaves a route out of the document; it is served as before. A route with no schema.response is documented as 200 — its handler may still answer 204 by returning nothing, which only a declared 204: null says.

Tags

A route names its tags in docs: { tags }; the document says what each one is:

docs({
  info,
  tags: {
    "sign-in": "Signing in with a code sent by email, and signing out",
    me: "The signed-in user",
  },
});

The order of the keys is the order of the sections a renderer lists. A tag the routes use and tags leaves out is listed after them, in the order the routes first use it, and reported — it is usually one tag spelled two ways, sign_in next to sign-in. So is a tag described and used by no operation. Without tags the document lists none, and nothing is reported. An application of several surfaces, each with its own docs(), gives each one only the tags of its own routes: given all of them, every docs() reports the tags of the others as unused.

Operation ids

A generated client names its methods after the operationIds, so they are a contract, and 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 arriving at the same id stop the application at startup, naming both — nothing is renamed behind your back, which would change a method in someone's SDK the day a route is added. Give one of them docs.operationId, or its controller a name of its own. On a public API, state the id on every route: it is then visible, and a change to it is a change a reviewer sees.

route({
  method: "POST",
  path: "/internal/drain",
  docs: { summary: "Drain the queue", hidden: true },
  handler: () => queue.drain(),
});

Schemas are described through Standard Schema's JSON Schema support: Zod, Valibot, ArkType and @tetsujs/typebox provide it.

Responses the framework adds

Failures the framework answers by itself are documented on every route where they can happen, in the same error envelope the server sends:

| Status | error | When | | --- | --- | --- | | 422 (or the configured validation status) | VALIDATION_FAILED | a request part failed its schema | | 400 | MALFORMED_JSON / MALFORMED_FORM | the body could not be parsed | | 413 | BODY_TOO_LARGE | the body exceeded maxBodySize | | 500 | INTERNAL_SERVER_ERROR | any unhandled failure |

The error code is a constant in each schema, so a generated client can tell failures apart by it. A status the route declares itself is kept, and the framework's failures for the same status are listed next to it.

Every envelope — the framework's, a hook's, or one the route declares itself — is one definition in components per status and code, named after the code (ITEM_NOT_FOUND is ItemNotFound), so a generated client gets one type per failure. A schema is recognized as an envelope when its error is required and a single string. When the route and a hook both describe one code, the route's definition is kept; if the other one has different fields, the generator warns. A union the route declares joins the other failures of its status as one flat anyOf.

When every alternative of a status is an envelope, the union carries a discriminator on error, with the mapping from each code to its definition, and a generated client narrows on the code.

A status is described by what answers with it. The route's part is the description of the schema it declares — .describe() in Zod and ArkType, v.description() in Valibot, { description } in TypeBox — for any status, a 200 as much as a 404; a union described as a whole is one description, one described branch by branch is one per branch. A hook's part is its documented() description, the framework's its own. One description is the status's description; several are a list, each led by its code:

- `ACCOUNT_DISABLED`: this account is disabled
- `CAPTCHA_FAILED`: the captcha token is missing or did not pass

A status nothing describes keeps its reason phrase — Not Found, Successful response.

An error format of your own

Every failure reaches the application's onError hooks — a thrown HttpError, a validation or body failure, a 404, a 405, a rate limit's refusal — so one hook answers all of them in your format:

const inOurFormat = hook.onError(({ error }) => {
  if (!(error instanceof HttpError)) return undefined;

  const { status, error: code, ...rest } = error.body as ErrorBody;

  return Response.json({ code, ...rest }, { status });
});

The document is generated from the routes, not from that hook, so it is told the same format with errors:

const errors: ErrorFormat = {
  schema: ({ error, message, fields }) => ({
    type: "object",
    required: ["code", "message", ...Object.keys(fields)],
    properties: {
      code: error ? { type: "string", const: error } : { type: "string" },
      message: { type: "string", ...(message ? { examples: [message] } : {}) },
      ...fields,
    },
  }),
  discriminator: "code",
};

createApp({
  hooks: { onError: [inOurFormat] },
  routes: [api, docs({ info, errors })],
});

schema describes one failure from what the generator knows of it: its status, its code when known, an example message, and in fields what it carries besides — issues for a validation failure, retryAfter for a rate limit, a hook's own fields. It is used for the framework's failures and the hooks' refusals alike.

discriminator names the top-level field that holds the code. Statuses of envelopes are then discriminated on it, and a route's own envelope is recognized by it — a { code: "ITEM_NOT_FOUND", … } the route declares is the same definition as a hook's. A format that nests its code has no top-level field to name; it gives code instead, reading the code from a route's schema:

code: (schema) => {
  const error = schema.properties?.error;
  const code = typeof error === "object" ? error.properties?.code : undefined;

  return typeof code === "object" && typeof code.const === "string" ? code.const : undefined;
},

The hook and errors describe one format in two places — the core knows nothing about documents, and a function cannot be read for the shape it returns — so keep a test that holds them together: provoke a failure and check its body against the document, with the JSON Schema validator of your choice.

test("a validation failure is what the document says", async () => {
  const res = await request("/items", { method: "POST", body: "{}" });
  const schema = document.components?.schemas?.ValidationFailed;

  expect(validator.validate(schema, await res.json())).toBe(true);
});

Documenting hooks

A hook that answers by itself — an auth check, a limiter — can say so, and every route it guards is documented accordingly. secured() adds a security scheme, documented() adds responses:

import { documented, secured } from "@tetsujs/openapi";

export const auth = secured(
  hook.beforeParse((ctx) => {
    const user = verify(ctx.req.headers.get("authorization"));

    if (!user) throw new HttpError(401);

    return { user };
  }),
  { name: "bearerAuth", scheme: { type: "http", scheme: "bearer", bearerFormat: "JWT" } },
);

export const guard = documented(hook.beforeParse(check), {
  responses: [
    {
      status: 429,
      description: "Rate limit exceeded",
      error: "RATE_LIMITED",
      fields: { retryAfter: { type: "integer", minimum: 0 } },
      headers: { "retry-after": { schema: { type: "integer", minimum: 0 } } },
    },
  ],
});

A response with an error code is the framework's envelope, defined once in components and named after the code. fields adds what the hook puts next to status, message and error, each always present; headers, what it sets on the response. Both are JSON Schema written by hand, typed keyword by keyword (JsonSchema), so a misspelled keyword does not compile. A hook whose body is not the envelope passes a schema instead.

Hooks from @tetsujs/rate-limit are already documented this way. A scheme is identified by its name: the same one mounted twice is one requirement, and two different schemes under one name are reported.

Every hook of a route runs, so every scheme its hooks carry is required together: a route behind a CSRF check and a captcha is documented as needing both, one entry of security — in OpenAPI, separate entries mean any one of them will do. Two hooks of one scheme require the scopes of both.

"Either" lives inside one hook: a hook that accepts a session cookie or a bearer token says so with anyOf, and the document lists every combination a client may bring:

export const caller = secured(hook.beforeParse(sessionOrToken), {
  anyOf: [cookieSession, bearerToken],
});

// with a CSRF check on the same route:
// security: [{ session: [], csrf: [] }, { bearer: [], csrf: [] }]

Using the document directly

When the document itself is the output — written to a file in CI, fed to a client generator — call the generator:

import { openapi } from "@tetsujs/openapi";

const { document, warnings } = openapi(app, {
  info: { title: "Users API", version: "1.0.0" },
});

await Bun.write("openapi.json", JSON.stringify(document, null, 2));

docsPage() renders the page on its own, for serving it elsewhere.

Testing against the document

@tetsujs/openapi/testing checks a response a test provoked against the operation the document describes:

import { assertDescribed } from "@tetsujs/openapi/testing";

const { document } = openapi(app, { info });

test("a wrong code is what the document says", async () => {
  const res = await request("/session", { method: "POST", body });

  await assertDescribed(document, "POST /session", res);
});

It throws unless the status is declared and the body fits one of the alternatives its status describes, listing every problem at once:

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 operation is named as the test called it, a method and the path it requested, matched against the document's templates. Without a validator only the top level of the body is compared — required fields, and fields that are a const, which is where an envelope keeps its code. validate checks the whole body with the JSON Schema validator of your choice, given a schema that stands on its own; headers names headers the status must declare when the response carries them:

await assertDescribed(document, "POST /session", res, {
  validate: (schema, body) => ajv.validate(schema, body) || ajv.errorsText(),
  headers: ["retry-after"],
});

The core does not check a thrown error against the route's response map: a guard's refusal would be reported on every route it runs on. A test asks about the one response it provoked.

Warnings

A route is still documented when part of it cannot be described, and the gap is reported instead of failing the startup:

[openapi] POST /api/users: the body schema does not emit JSON Schema

This happens with a validator that does not emit JSON Schema, a schema whose conversion throws, and two routes that map to the same OpenAPI path (/files/* and /files/:wildcard are both /files/{wildcard}).