Swagger
@miiajs/swagger scans your controllers for decorator metadata and generates an OpenAPI 3.1 spec with Swagger UI.
Installation
bun add @miiajs/swagger swagger-ui-dist
npm install @miiajs/swagger swagger-ui-dist
pnpm add @miiajs/swagger swagger-ui-dist
yarn add @miiajs/swagger swagger-ui-dist
Setup
SwaggerModule.configure() returns a configured module you drop into your root @Module({ imports: [...] }). The service inside it runs in onReady() - after all controllers have been discovered but before the HTTP server starts - so the spec is always generated with the full, live routing tree.
import { Module } from '@miiajs/core'
import { SwaggerModule } from '@miiajs/swagger'
import { UsersModule } from './users/users.module.js'
@Module({
imports: [
UsersModule,
SwaggerModule.configure({
title: 'My API',
version: '1.0.0',
description: 'API documentation',
servers: [
{ url: 'http://localhost:3000', description: 'Development' },
],
securitySchemes: {
bearer: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' },
},
}),
],
})
export class AppModule {}
import { Miia } from '@miiajs/core'
import { AppModule } from './app.module.js'
const app = new Miia()
app.register(AppModule)
await app.listen(3000)
Endpoints:
GET /docs- Swagger UIGET /docs/json- OpenAPI JSON spec
SwaggerModule.configure()last in your root module's imports array. onReady() hooks run in registration order, and putting swagger last guarantees every controller module has already been processed by the time the spec is built.::note Swagger under a global auth guard
Swagger registers its routes with { skipGlobalGuards: true }, so /docs/json and the UI stay reachable even when the app has a global AuthGuard via app.useGuard(). Global middleware (CORS, request logging, request-id) still applies to swagger endpoints - only guards are opted out. If you want the UI to be authenticated anyway, add an @UseGuard()-protected reverse-proxy or wrap the /docs path at the HTTP-server level.
::
Setup options
interface SwaggerSetupOptions {
title: string // Required
version: string // Required
description?: string
servers?: Array<{ url: string; description?: string }>
securitySchemes?: Record<string, any>
globalSecurity?: Array<Record<string, string[]>>
path?: string // Spec path (default: '/docs/json')
uiPath?: string // UI path (default: '/docs')
ui?: boolean // Serve UI (default: true)
swaggerOptions?: Record<string, any>
}
Decorators
@ApiTag
Group routes under tags (class-level):
@ApiTag('Users')
@Controller('/users')
class UserController {}
@ApiOperation
Describe an endpoint (method-level):
@ApiOperation({
summary: 'Create a new user',
description: 'Creates a user and returns the created object',
operationId: 'createUser',
deprecated: false,
})
@Post('/')
create(ctx: RequestContext) {}
@ApiResponse
Define response schemas (method-level, stackable):
@ApiResponse(200, {
description: 'User found',
schema: UserResponseSchema, // Zod schema or JSON Schema
})
@ApiResponse(404, { description: 'User not found' })
@Get('/:id')
findOne(ctx: RequestContext) {}
@ApiBody
Declare the request body schema without running runtime validation (method-level):
import { ApiBody } from '@miiajs/swagger'
@ApiBody(LoginSchema)
@Post('/login')
@UseGuard(AuthGuard(LocalAuth))
login(ctx: RequestContext) {}
Use this when the body is validated elsewhere - inside an auth provider, a custom middleware, or a strategy that reads ctx.json() directly - but you still want Swagger UI to show the expected shape. It writes to the same BODY_SCHEMAS key as @ValidateBody, so the spec builder picks it up automatically.
@ValidateBody on a route, you don't need @ApiBody - the schema is auto-detected from the validator. Reach for @ApiBody only when validation happens outside the route-level middleware chain.@ApiParam
Document path parameters (method-level, stackable):
@ApiParam('id', {
description: 'User ID',
schema: { type: 'string', format: 'uuid' },
})
@Get('/:id')
findOne(ctx: RequestContext) {}
Path parameters from :paramName patterns are auto-detected.
@ApiQuery
Document query parameters (method-level, stackable):
@ApiQuery('limit', { description: 'Max results', required: false })
@ApiQuery('offset', { description: 'Pagination offset', required: false })
@Get('/')
list(ctx: RequestContext) {}
Query parameters from @ValidateQuery schemas are auto-detected.
@ApiHeader
Document request headers (class or method-level, stackable):
@ApiHeader('X-Api-Key', { description: 'API key', required: true })
@Controller('/admin')
class AdminController {}
@ApiSecurity
Declare security requirements (class or method-level):
@ApiSecurity('bearer')
@Controller('/api')
class ApiController {}
// With scopes
@ApiSecurity('oauth2', ['write:users'])
@Delete('/:id')
remove(ctx: RequestContext) {}
@ApiExclude
Hide routes or controllers from the spec:
@ApiExclude()
@Controller('/internal')
class InternalController {}
// Or exclude a single route
@ApiExclude()
@Get('/debug')
debug() {}
Auto-detection
The spec builder automatically detects:
- Path parameters from route patterns (
:id->{id}) - Query parameters from
@ValidateQueryschemas - Request body from
@ValidateBodyschemas (or@ApiBodyfor doc-only declarations) - Default responses (200/201 based on method)
- 422 response when validation decorators are present
- 403 response when guards are present
Schema support
Decorators accept any ZodLike schema (Zod v3, v4, or custom) and raw JSON Schema objects:
import { z } from 'zod'
const UserSchema = z.object({
id: z.string().uuid(),
name: z.string().min(1).max(100),
email: z.string().email(),
role: z.enum(['user', 'admin']),
age: z.number().int().optional(),
})
@ApiResponse(200, { schema: UserSchema })
Zod types are converted to JSON Schema automatically.
Complete example
@ApiTag('Users')
@ApiSecurity('bearer')
@Controller('/users')
@UseGuard(AuthGuard())
class UserController {
@Get('/')
@SkipGuard(AuthGuard)
@ApiOperation({ summary: 'List all users' })
@ApiResponse(200, { schema: z.array(UserSchema) })
list() {}
@Get('/:id')
@ApiOperation({ summary: 'Get user by ID' })
@ApiParam('id', { description: 'User ObjectId' })
@ApiResponse(200, { schema: UserSchema })
@ApiResponse(404, { description: 'Not found' })
findOne(ctx: RequestContext) {}
@Post('/')
@Status(201)
@ValidateBody(CreateUserSchema)
@ApiOperation({ summary: 'Create user' })
@ApiResponse(201, { schema: UserSchema })
create(ctx: RequestContext) {}
@Delete('/:id')
@ApiOperation({ summary: 'Delete user' })
@ApiParam('id')
@ApiResponse(204, { description: 'Deleted' })
remove(ctx: RequestContext) {}
}
Testing
Use TestApp from @miiajs/core/testing. compile() runs the full app lifecycle, including onReady(), so swagger routes are wired up before any request() call.
import { describe, it, expect } from 'bun:test'
import { Module } from '@miiajs/core'
import { TestApp } from '@miiajs/core/testing'
import { SwaggerModule } from '@miiajs/swagger'
@Module({
imports: [
UsersModule,
SwaggerModule.configure({ title: 'Test API', version: '1.0.0' }),
],
})
class AppModule {}
describe('swagger spec', () => {
it('lists every controller route', async () => {
const app = await TestApp.create(AppModule).compile()
const res = await app.request('GET', '/docs/json')
const spec = await res.json()
expect(spec.paths['/users']).toBeDefined()
expect(spec.paths['/users/{id}']).toBeDefined()
await app.close()
})
})
Exports
import {
SwaggerModule,
SwaggerService,
SWAGGER_OPTIONS,
ApiTag,
ApiOperation,
ApiResponse,
ApiBody,
ApiParam,
ApiQuery,
ApiSecurity,
ApiHeader,
ApiExclude,
SpecBuilder,
convertSchema,
} from '@miiajs/swagger'