Documentation

Swagger

Browse every endpoint your VitNode API serves at /api/swagger - an OpenAPI document generated from the Zod schemas your routes already declare.

VitNode's API is built on @hono/zod-openapi, so the Zod schemas a route declares are its documentation. Nothing is written twice and nothing can drift: if the schema changes, the spec changes, and the request that no longer matches it is rejected.

Swagger is on in development and off in production unless you turn it on (see Swagger in production).

Quick start

Start the dev server and open Swagger UI in a browser:

Whichever origin serves /api/* serves the UI, because it is a route on the same Hono app. The raw document is one path deeper, at /api/swagger/doc - OpenAPI 3.0.0, titled VitNode API, versioned with the @vitnode/core release you have installed. Point any OpenAPI tool at that URL.

Every path reads /api/{pluginId}/{module}/{route}, which is why core's session endpoint is /api/@vitnode/core/users/session. That is not a Swagger convention - it is how the app is mounted, and Architecture walks the whole request path.

Put a plugin route in the spec

There is no extra work. The route, module and plugin files that make a route reachable also make it documented. What you control is how good the documentation is. The steps below improve the GET /api/site-notes/notes/{id} route of the site-notes plugin.

Before you begin

Describe the route

In src/api/modules/notes/show.route.ts, add a description and an example for the parameter. description becomes the operation's summary line, and every schema field can carry an example. This is the whole difference between a spec people use and a spec people ignore:

plugins/site-notes/src/api/modules/notes/show.route.ts
export const showNoteRoute = buildRoute({
  route: {
    method: 'get',
    path: '/{id}',
    description: 'Get one note by id', 
    request: {
      params: z.object({
        id: z.coerce.number().int().positive().openapi({ example: 1 }), 
      }),
    },
    // ...responses and handler stay as they are

.openapi() comes from the z that @hono/zod-openapi exports, which the route file already imports.

Check the module and the plugin

Nothing to add here, but this is where the group in Swagger UI gets its name. The route sits in the notes module, and the module sits in the plugin:

plugins/site-notes/src/api/modules/notes/notes.module.ts
export const notesModule = buildModule({
  name: 'notes',
  routes: [listNotesRoute, showNoteRoute],
  // ...
})
plugins/site-notes/src/config.api.ts
export const siteNotesApiPlugin = () =>
  buildApiPlugin({
    manifest,
    modules: [helloModule, notesModule],
    // ...
  })

The module name is the second half of the group heading, so pick the word you want to read in the sidebar.

Check the result

Let the plugin rebuild, restart the dev server and refresh /api/swagger. The restart matters, because the API builds the document on its first request and serves that copy from then on. The (Site Notes) - Notes group lists GET /api/site-notes/notes/{id} with the summary "Get one note by id" and 1 filled in as the example id. "Try it out" sends a real request with your cookies attached.

Where the spec comes from

You never register anything with Swagger. Four steps in the API's own build produce the document as a side effect:

StepWhat it contributes
buildRouteThe operation: method, path, request and responses schemas, description.
buildModuleRegisters it on the module's Hono app, and names the group it lands in.
buildApiPluginMounts each module under its name and collects the group list for the document.
VitNodeAPIMounts each plugin under its pluginId and serves /swagger plus /swagger/doc.

Grouping

Swagger has exactly one grouping mechanism - the tag - so the tag carries both halves of "where does this endpoint live". The plugin comes from pluginId with its scope dropped and title-cased (@vitnode/core reads Core, site-notes reads Site Notes), the module from the name you gave buildModule:

buildModule({
  pluginId: '@vitnode/core', // (Core)
  name: 'users', // - Users
  routes: [sessionRoute],
})

A nested module is named after its whole chain, so (Core) - Admin / Users stays a separate group from the top-level (Core) - Users:

buildModule({
  pluginId: '@vitnode/core',
  name: 'admin',
  routes: [],
  modules: [usersAdminModule], // (Core) - Admin / Users
})

The chain matters because module names repeat across the tree - core has a users module and an admin/users one, plus cron, queue and files twice over. A leaf-only tag would merge the public and admin halves of each into one group.

Groups appear in the order the plugins declared their modules, with core first.

Your own tags are kept

A route may add tags of its own. They survive, after the generated one - so the same operation can also show up under a tag you name yourself.

Swagger in production

VitNode serves /api/swagger and /api/swagger/doc when NODE_ENV is development and switches them off otherwise. The document names every route, parameter and response shape of the install, the admin tree included, and neither path checks a session. To publish it in production anyway, turn it on in the API config:

src/vitnode.api.config.ts
export const vitNodeApiConfig = buildApiConfig({
  // ...
  docs: { enabled: true }, 
})

docs: { enabled: false } turns it off in development too. If you enable it on a public install, put something in front of both paths, such as an allowlist at your reverse proxy. See Self-hosted.

Gotchas

A response with no content widens res.json() to unknown

The fetcher infers its return type from the same responses block Swagger reads. Declare a status with no content - a bare 401: { description: 'Unauthorized' } - and there is no response format to infer from, so await res.json() resolves to unknown at every call site. Give every documented response a content schema, or leave the status undeclared and let HTTPException produce it.

The spec is per-installation, not per-package

The document lists the plugins this app has in its vitnode.api.config.ts. Install a plugin and its groups appear; remove it and they are gone. So /api/swagger is a truthful inventory of one deployment rather than a catalogue of what VitNode can do.