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.

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.

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.

Put a plugin route in the spec

There is no extra work - the same three files that make a route reachable make it documented. What you control is how good the documentation is.

Describe the route as you build it

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:

src/api/modules/orders/routes/show.route.ts
import { z } from '@hono/zod-openapi'
import { buildRoute } from '@vitnode/core/api/lib/route'

import { CONFIG_PLUGIN } from '@/const'

export const showOrderRoute = buildRoute({
  pluginId: CONFIG_PLUGIN.pluginId,
  route: {
    method: 'get',
    path: '/{id}',
    description: 'Get one order by id', 
    request: {
      params: z.object({
        id: z.string().openapi({ example: '1' }), 
      }),
    },
    responses: {
      200: {
        content: {
          'application/json': {
            schema: z.object({ id: z.string(), total: z.number() }),
          },
        },
        description: 'The order',
      },
    },
  },
  handler: async (c) => c.json({ id: '1', total: 42 }),
})

Register it in a module

src/api/modules/orders/orders.module.ts
import { buildModule } from '@vitnode/core/api/lib/module'

import { CONFIG_PLUGIN } from '@/const'
import { showOrderRoute } from './routes/show.route'

export const ordersModule = buildModule({
  pluginId: CONFIG_PLUGIN.pluginId,
  name: 'orders', 
  routes: [showOrderRoute], 
})

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

Mount the module in the plugin

src/config.api.ts
import { buildApiPlugin } from '@vitnode/core/api/lib/plugin'

import { ordersModule } from '@/api/modules/orders/orders.module'
import { CONFIG_PLUGIN } from '@/const'

export const shopApiPlugin = () =>
  buildApiPlugin({
    pluginId: CONFIG_PLUGIN.pluginId,
    modules: [ordersModule], 
  })

Reload the UI

Restart the dev server and refresh /api/swagger. A new group is at the bottom of the list - (Shop) - Orders - with GET /api/@acme/shop/orders/{id} in it, and "Try it out" sends a real request with your cookies attached.

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

Gotchas

Swagger has no login, and it is mounted in production

/api/swagger and /api/swagger/doc are registered unconditionally, before the session middleware, and neither checks anything. Anyone who can reach your API can read every route it serves, including the admin ones. Deny both paths at your reverse proxy if that is not what you want - see Self-hosted.

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.

Next