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, skipGlobalPrefix: true }, so /docs/json and the UI stay reachable even when the app has a global AuthGuard via app.useGuard(), and stay at their configured paths under a globalPrefix. 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.
::
::warning Apps behind a global prefix
new Miia({ globalPrefix: '/api' }) serves the routes from /api/users, but paths still documents /users. The global prefix is applied inside the router and never reaches the metadata the builder reads, so the spec describes the app independently of where it is mounted. @Module({ prefix }) behaves the opposite way: it is part of the resolved controller path and does appear in paths.
Write the base URL, prefix included, into servers - { url: 'http://localhost:3000/api' }. Without it, "Try it out" sends the request to /users and gets a 404. The swagger endpoints themselves are not moved by the prefix; they stay at the configured path and uiPath. See Routing → Global prefix.
::
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 success response (
@Status(code), otherwise 201 forPOSTand 200 for everything else) - 422 response when validation decorators are present
- Guard rejection responses from the guards on the route
Default success response
The default is emitted only when the route declares no successful response of its own. Any @ApiResponse with a 2xx or 3xx status replaces it - @ApiResponse(200) on a POST gives you a single 200, not 200 plus the implicit 201.
Its description comes from the status text (204 -> No Content), and a JSON body is attached only for 2xx codes other than 204 and 205. Redirect statuses are documented without a response body.
Guard responses
Guard classes declare the statuses they reject with through the GUARD_RESPONSES symbol from @miiajs/core, and the spec builder copies those declarations onto every route the guard protects:
import { GUARD_RESPONSES } from '@miiajs/core'
class JwtAuthGuard implements CanActivate {
static [GUARD_RESPONSES] = [401]
canActivate(ctx: RequestContext) { ... }
}
- Global (
app.useGuard()), controller, and method guards are all taken into account. AuthGuardfrom@miiajs/authdeclares401, the guards in@miiajs/rate-limitdeclare429- no wiring on your side.- A guard without the marker adds no responses. There is no blanket
403anymore. @SkipGuard(...)and@SkipRateLimit()remove the guard's responses along with the guard.- An explicit
@ApiResponsefor the same status wins.
See Guards for the full declaration syntax, including per-status descriptions.
onReady(), so a guard registered with app.useGuard() after app.init() runs at request time but is not documented. And the perimeter rateLimit() middleware never appears in the spec at all - middleware carries no metadata to read, so only the guard layer (RateLimitGuard, @RateLimit) is documented.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 })
When a schema can convert itself - toJSONSchema() in Zod 4, toJsonSchema() in other libraries - the package uses that output instead of walking the schema internals. Detection is duck-typed: @miiajs/swagger has no dependency on Zod, and Zod 3 schemas plus raw JSON Schema objects keep working through the built-in converter.
Native conversion gives more accurate output: .pipe() and .transform() are described properly instead of collapsing to { type: 'object' }, z.literal() works on Zod 4, and nullable fields are emitted as anyOf rather than type: [..., 'null'].
A string that has a format is documented by that format alone - the regex Zod ships alongside format: email or format: uuid is dropped. Swagger UI builds its example value from pattern whenever one is present, which would turn the email field into a screenful of generated noise instead of user@example.com. A pattern you wrote yourself with .regex() has no format next to it and is kept.
Input and output sides
Request bodies and parameters are converted from the input side of the schema (io: 'input'), responses from the output side. A field with a .default() stays optional in the request body, and a transformed field is documented in the shape the client actually sends.
Formats survive the transform chain. z.string().trim().toLowerCase().pipe(z.email()) is documented as { type: 'string', format: 'email' }: the input side supplies the structure, and the format is carried over from the output side.
Named and recursive schemas
Anything the conversion factors out into $defs - typically a nested schema tagged with .meta({ id }) - is moved into components.schemas and referenced by $ref. The id becomes the component name, so a shared schema is described once and reused by every route that mentions it:
const Address = z.object({ city: z.string() }).meta({ id: 'Address' })
@ValidateBody(z.object({ address: Address }))
// -> requestBody schema:
// { properties: { address: { $ref: '#/components/schemas/Address' } }, ... }
A self-referencing schema becomes a component as well. Without an id it is named after the controller, the handler, and the slot it fills - TreeController_create_Body, TreeController_search_Query. Raw JSON Schema objects carrying their own $defs go through the same lifting, and the object you passed to the decorator is never mutated.
The emitted document contains no dangling #/$defs/... or # pointers. If one name is claimed by two different shapes - usually the input and the output side of the same schema - the second one is suffixed with _Input or _Output.
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/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/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'