beta

Swagger

Generate OpenAPI 3.1 specs and serve Swagger UI from controller metadata.

@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

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 UI
  • GET /docs/json - OpenAPI JSON spec
Place 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.

If you're already using @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 @ValidateQuery schemas
  • Request body from @ValidateBody schemas (or @ApiBody for 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'