Swagger / OpenAPI
Auto-generate an OpenAPI 3.0 spec from your routes and serve an interactive Swagger UI. No separate spec file to maintain.
Overview
@tekir/swagger registers two HTTP endpoints on your router (a Swagger UI page and a raw JSON spec endpoint) and provides a set of TypeScript decorators you can apply to your controller methods to enrich the generated spec with summaries, request body schemas, response schemas, path parameters, and security requirements. Zod schemas are automatically converted to JSON Schema, so you can reuse your existing validation schemas without duplication.
Installation
bun add @tekir/swaggerSetup
Call swagger(router, config) after your routes are registered. The function adds two GET routes to the router (the UI and the JSON spec) and returns nothing.
import type { TekirApp } from '@tekir/core'
import { swagger } from '@tekir/swagger'
export default function({ router }: TekirApp) {
// Register controllers / routes first...
// Then mount Swagger: it needs to see all registered routes
swagger(router, {
title: 'My API',
version: '1.0.0',
path: '/docs'
})
}Configuration
All fields of SwaggerConfig are optional. The only required argument to swagger() is the router instance.
swagger(router, {
title: 'My API', // Shown in the Swagger UI header and browser tab
version: '1.0.0', // OpenAPI info.version
description: 'REST API docs', // Optional long description
path: '/docs', // Base path for UI and JSON (default: '/docs')
servers: [
{ url: 'http://localhost:3000', description: 'Local' },
{ url: 'https://api.example.com', description: 'Production' }
]
})- title: The API name. Shown in the Swagger UI header and the browser tab. Defaults to
"API Documentation". - version: The OpenAPI
info.versionfield. Defaults to"1.0.0". - description: An optional longer description rendered below the title in Swagger UI.
- path: The base path for both the UI (
/docs) and the JSON spec (/docs/json). Defaults to"/docs". - servers: An array of server objects written directly into the OpenAPI spec. If omitted, no
serverskey is emitted. - enabled: Overrides the production gate. Defaults to unset, leave it off and the docs are only registered outside production or when
authis set.trueforce-registers;falseforce-disables. See Production Gating. - hidePaths: An array of path prefixes or
RegExppatterns to exclude from the spec. See Hiding Routes.
UI & JSON Endpoints
// After calling swagger(router, config):
//
// GET /docs → Swagger UI (HTML)
// GET /docs/ → same UI (trailing-slash alias)
// GET /docs/json → raw OpenAPI 3.0.3 spec (JSON)
//
// The JSON spec is generated fresh on every request, so it always reflects
// the current state of the router's route table.The Swagger UI is served from a pinned [email protected] bundle on jsDelivr. The HTML response carries a strict Content-Security-Policy with no unsafe-inline: the only inline bootstrap is allowlisted via its SHA-256 hash. Frames are denied via X-Frame-Options: DENY. The JSON spec is regenerated on every request, so it always reflects the live route table.
Self-host the bundle or pin Subresource Integrity hashes with the ui config block: cssUrl, jsUrl, cssIntegrity, and jsIntegrity are all optional. When an integrity hash is provided the rendered tag emits the matching integrity and crossorigin="anonymous" attributes so the browser refuses to execute a tampered asset.
Production Gating
Documentation is no longer exposed by default in production. When NODE_ENV is production and you have set neither auth nor enabled: true, the UI and JSON spec routes are not registered and a warning is logged, so a forgotten swagger() call cannot leak your full route map. Use the enabled flag to override the gate in either direction: true force-registers the docs (for example behind your own auth proxy) and false force-disables them.
// In production, docs are NOT registered unless you opt in.
// Without 'auth' or 'enabled', /docs and /docs/json are skipped and a
// warning is logged, so you never expose the API surface by accident.
// Force the docs on (e.g. behind your own auth proxy):
swagger(router, { title: 'My API', version: '1.0.0', enabled: true })
// Force the docs off, even in development:
swagger(router, { title: 'My API', version: '1.0.0', enabled: false })
// Setting 'auth' also satisfies the gate, see Basic Auth below.Protecting with Basic Auth
In production you may want to restrict access to the Swagger UI. Pass an auth object with username and password: the middleware will prompt for credentials before serving the UI or JSON spec.
// Protect Swagger UI with basic auth (useful in production)
swagger(router, {
title: 'My API',
version: '1.0.0',
path: '/docs',
auth: {
username: 'admin',
password: 'secret'
}
})
// Now visiting /docs prompts for credentials.
// The JSON spec at /docs/json is also protected.Hiding Routes
Internal, admin, or debug endpoints can be kept out of the spec entirely. Add the hidePaths config option with a list of path prefixes or RegExp patterns; any matching route is omitted from the generated spec.
// Hide route trees from the spec by path prefix or RegExp.
swagger(router, {
title: 'My API',
version: '1.0.0',
hidePaths: ['/admin', '/internal', /^\/debug/]
})To hide individual operations, apply the @ApiHide() decorator to a controller method, or call .apiHide() on a route builder. Hidden routes never appear in the spec or the UI.
import { ApiHide } from '@tekir/swagger-decorators'
// Decorator: hide a single controller method from the generated spec.
@Get('/internal/metrics')
@ApiHide()
async metrics(ctx) { ... }
// Fluent equivalent on a route builder:
router.get('/internal/metrics', handler).apiHide()Documenting Routes
@tekir/swagger extends RouteBuilder with chainable documentation methods. Chain .summary(), .apiBody(), .apiResponse(), etc. directly on your route definitions.
import type { TekirApp } from '@tekir/core'
import { z } from 'zod'
import * as UserController from '~/controllers/user_controller'
const CreateUserSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
role: z.enum(['admin', 'user']).default('user')
})
const UserSchema = z.object({
id: z.number().int(),
name: z.string(),
email: z.string().email(),
role: z.enum(['admin', 'user'])
})
export default function({ router }: TekirApp) {
router.get('/users', UserController.index)
.apiTag('Users')
.summary('List all users')
.apiResponse(200, z.array(UserSchema))
router.post('/users', UserController.store)
.apiTag('Users')
.summary('Create a user')
.apiBody(CreateUserSchema)
.apiResponse(201, UserSchema)
router.get('/users/:id', UserController.show)
.apiTag('Users')
.summary('Get a user by ID')
.apiParam('id', { type: 'integer', description: 'User ID' })
.apiResponse(200, UserSchema)
.bearerAuth()
router.delete('/users/:id', UserController.destroy)
.apiTag('Users')
.summary('Delete a user')
.apiParam('id', { type: 'integer', description: 'User ID' })
.apiResponse(204, z.null())
.bearerAuth()
}All available methods:
// All fluent methods are chainable on RouteBuilder:
router.get('/path', handler)
.summary('Short description') // OpenAPI summary
.apiTag('TagName') // group under a tag
.apiBody(zodSchema) // request body (Zod or JSON Schema)
.apiResponse(200, zodSchema) // response schema for status code
.apiResponse(404, errorSchema) // multiple responses supported
.apiParam('id', { type: 'integer' }) // path or query parameter
.bearerAuth() // require Bearer token
.apiHide() // exclude this route from the specDecorators
If you use decorator-based controllers, @tekir/swagger-decorators provides the same functionality as method decorators.
import { Controller, Get, Post, Put, Delete } from '@tekir/http-decorators'
import {
ApiTag,
ApiSummary,
ApiBody,
ApiResponse,
ApiParam,
ApiBearerAuth
} from '@tekir/swagger-decorators'
import { z } from 'zod'
const CreateUserSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
role: z.enum(['admin', 'user']).default('user')
})
const UserSchema = z.object({
id: z.number().int(),
name: z.string(),
email: z.string().email(),
role: z.enum(['admin', 'user'])
})
@Controller('/users')
export class UsersController {
@Get('/')
@ApiTag('Users')
@ApiSummary('List all users')
@ApiResponse(200, z.array(UserSchema))
async index({ response }: HttpContext) {
return response.ok([])
}
@Post('/')
@ApiTag('Users')
@ApiSummary('Create a user')
@ApiBody(CreateUserSchema)
@ApiResponse(201, UserSchema)
async store({ body, response }: HttpContext) {
const data = CreateUserSchema.parse(body)
return response.created(data)
}
@Get('/:id')
@ApiTag('Users')
@ApiSummary('Get a user by ID')
@ApiParam('id', { type: 'integer', description: 'User ID', example: 1 })
@ApiResponse(200, UserSchema)
@ApiBearerAuth()
async show({ params, response }: HttpContext) {
return response.ok({ id: params.id })
}
@Put('/:id')
@ApiTag('Users')
@ApiSummary('Update a user')
@ApiParam('id', { type: 'integer', description: 'User ID' })
@ApiBody(CreateUserSchema)
@ApiResponse(200, UserSchema)
@ApiBearerAuth()
async update({ params, body, response }: HttpContext) {
return response.ok({ id: params.id, ...body })
}
@Delete('/:id')
@ApiTag('Users')
@ApiSummary('Delete a user')
@ApiParam('id', { type: 'integer', description: 'User ID' })
@ApiResponse(204, z.null())
@ApiBearerAuth()
async destroy({ response }: HttpContext) {
return response.noContent()
}
}@ApiTag
Groups the endpoint under one or more named tags. Tags appear as collapsible sections in Swagger UI and as the tags array in the spec. When @ApiTag is omitted, a tag is automatically derived from the route path.
// Group one or more endpoints under a named tag.
// Tags appear as collapsible sections in Swagger UI.
@ApiTag('Users')
async index(ctx) { ... }
// Apply multiple tags to a single endpoint:
@ApiTag('Users', 'Admin')
async adminIndex(ctx) { ... }
// When @ApiTag is omitted, the tag is auto-derived from the route path.
// /api/users/:id → 'Users'
// /api/v1/orders → 'Orders'@ApiSummary
Sets the short one-line description displayed next to the HTTP method badge in Swagger UI. Corresponds to the summary field in the OpenAPI operation object.
// Sets the short one-line description shown beside the method in Swagger UI.
@ApiSummary('Retrieve a paginated list of users')
async index(ctx) { ... }@ApiBody
Declares the request body schema for POST, PUT, and PATCH endpoints. Pass either a Zod schema or a plain JSON Schema object. The body is always emitted with required: true and a content type of application/json.
import { z } from 'zod'
import { ApiBody } from '@tekir/swagger-decorators'
const CreatePostSchema = z.object({
title: z.string().min(1).max(255),
body: z.string(),
published: z.boolean().default(false)
})
// Pass a Zod schema: it is automatically converted to JSON Schema
@ApiBody(CreatePostSchema)
async store(ctx) { ... }
// Or pass a raw JSON Schema object directly:
@ApiBody({ type: 'object', properties: { title: { type: 'string' } } })
async store(ctx) { ... }@ApiResponse
Declares the schema for a specific HTTP response status code. Multiple @ApiResponse decorators can be stacked on the same method to document different outcomes. When no decorator is present a default 200: OK entry is generated without a schema.
import { z } from 'zod'
import { ApiResponse } from '@tekir/swagger-decorators'
// Declare one or more response status codes.
// The schema is converted from Zod automatically.
@ApiResponse(200, UserSchema)
@ApiResponse(404, z.object({ message: z.string() }))
async show(ctx) { ... }
// When no @ApiResponse decorator is present, a default '200: OK' entry
// is generated with no schema attached.@ApiParam
Documents a parameter for the endpoint. Path parameters that match a route segment (e.g. :id) are emitted with in: "path". Parameters whose names do not appear in the route pattern are emitted with in: "query", useful for documenting pagination or filter query strings.
import { ApiParam } from '@tekir/swagger-decorators'
// Document a path parameter declared as :id in the route pattern.
// type defaults to 'string'; override with 'integer', 'number', etc.
@ApiParam('id', { type: 'integer', description: 'User ID', example: 42 })
async show(ctx) { ... }
// Document a query parameter not present in the path pattern.
// These are added with in: 'query' in the spec.
@ApiParam('page', { type: 'integer', description: 'Page number', required: false })
@ApiParam('per_page', { type: 'integer', description: 'Items per page', required: false })
async index(ctx) { ... }@ApiBearerAuth
Marks an endpoint as requiring a Bearer (JWT) token. Swagger UI renders a lock icon on the operation, and when the global "Authorize" dialog is filled in the token is automatically included in test requests. A bearerAuth security scheme is added to components.securitySchemes in the spec the first time any endpoint uses this decorator.
import { ApiBearerAuth } from '@tekir/swagger-decorators'
// Marks the endpoint as requiring a Bearer token.
// Swagger UI will show a lock icon and include the token in test requests.
// A 'bearerAuth' security scheme (HTTP Bearer / JWT) is added to the spec automatically.
@ApiBearerAuth()
async show(ctx) { ... }Zod to JSON Schema
The zodToJsonSchema() function is exported for direct use. It handles the most common Zod types: ZodString (with email, url, uuid, min, max checks), ZodNumber (with int, min, max), ZodBoolean, ZodEnum, ZodNativeEnum, ZodArray, ZodObject, ZodOptional, ZodNullable, ZodDefault, ZodLiteral, ZodUnion, ZodIntersection, ZodRecord, and ZodTuple. Unknown types fall back to an empty schema object.
import { zodToJsonSchema } from '@tekir/swagger'
import { z } from 'zod'
// zodToJsonSchema() is exported for use outside decorators.
const schema = z.object({
name: z.string().min(1),
age: z.number().int().min(0),
email: z.string().email().optional(),
role: z.enum(['admin', 'user']),
tags: z.array(z.string())
})
const jsonSchema = zodToJsonSchema(schema)
// {
// type: 'object',
// properties: {
// name: { type: 'string', minLength: 1 },
// age: { type: 'integer', minimum: 0 },
// email: { type: 'string', format: 'email' },
// role: { type: 'string', enum: ['admin', 'user'] },
// tags: { type: 'array', items: { type: 'string' } },
// },
// required: ['name', 'age', 'role', 'tags'],
// }Auto Route Detection
The spec builder walks the router's internal trie to collect every registered route; no manual route listing required. ANY and WS method entries are excluded from the spec. Route path parameters like :id are converted to OpenAPI curly-brace syntax ({id}) automatically.
// swagger() reads the router's trie data structure at request time.
// Every route registered before the swagger() call appears in the spec.
// Routes registered AFTER the swagger() call are also included because
// the spec is built lazily on each GET /docs/json request.
//
// Auto-derived tags: the first non-API-version segment of the path is
// capitalised and used as the tag. Segments 'api', 'v1', 'v2', 'v3'
// are skipped.
//
// /users → 'Users'
// /api/users → 'Users'
// /api/v1/users/:id → 'Users'
// /health → 'Health'Building the Spec Programmatically
buildOpenApiSpec(router, config) generates the full OpenApiSpec object without registering any HTTP routes. This is useful for exporting the spec to a file as part of a CI pipeline or for asserting its shape in tests.
import { buildOpenApiSpec } from '@tekir/swagger'
// Generate the spec without registering any HTTP routes.
// Useful for exporting the spec to a file, validating it in tests, etc.
const spec = buildOpenApiSpec(router, {
title: 'My API',
version: '2.0.0'
})
await Bun.write('openapi.json', JSON.stringify(spec, null, 2))The resulting spec shape looks like this:
{
"openapi": "3.0.3",
"info": { "title": "My API", "version": "1.0.0" },
"servers": [{ "url": "http://localhost:3000", "description": "Local" }],
"tags": [{ "name": "Users" }],
"paths": {
"/users": {
"get": {
"tags": ["Users"],
"summary": "List all users",
"operationId": "get_/users",
"responses": {
"200": { "description": "OK", "content": { "application/json": { "schema": { ... } } } }
}
},
"post": { ... }
},
"/users/{id}": {
"get": {
"tags": ["Users"],
"summary": "Get a user by ID",
"parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } }],
"security": [{ "bearerAuth": [] }],
"responses": { "200": { ... } }
}
}
},
"components": {
"securitySchemes": {
"bearerAuth": { "type": "http", "scheme": "bearer", "bearerFormat": "JWT" }
}
}
}Auto-configuration via Provider
Add a config/swagger.ts file and tekir() will automatically boot SwaggerProvider, which calls swagger(router, config) after all controllers and routes have been registered. No manual wiring needed.
import type { SwaggerConfig } from '@tekir/swagger'
export default {
title: 'My API',
version: '1.0.0',
description: 'Auto-generated API documentation',
path: '/docs',
servers: [
{ url: 'http://localhost:3000', description: 'Local' }
]
} satisfies SwaggerConfig// SwaggerProvider calls swagger(router, config) during boot,
// after all controllers and routes have been registered.
// No manual swagger() call needed when using config/swagger.ts.