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:
| App shape | Swagger UI |
|---|---|
| Single App | http://localhost:3000/api/swagger |
| Monorepo App | http://localhost:8000/api/swagger |
| Only API | http://localhost:8000/api/swagger |
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
- The
site-notesplugin with itsnotesmodule, from API modules and Database.
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:
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:
export const notesModule = buildModule({
name: 'notes',
routes: [listNotesRoute, showNoteRoute],
// ...
})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:
| Step | What it contributes |
|---|---|
buildRoute | The operation: method, path, request and responses schemas, description. |
buildModule | Registers it on the module's Hono app, and names the group it lands in. |
buildApiPlugin | Mounts each module under its name and collects the group list for the document. |
VitNodeAPI | Mounts 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.
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:
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
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 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.