| name | api-docs-skill |
| description | API documentation, OpenAPI/Swagger generation, versioning, SDK generation, and developer portal patterns for multi-tenant SaaS NestJS APIs. Auto-generates docs from decorators, maintains changelog, produces TypeScript SDK for frontend consumption. Trigger when documenting APIs, setting up Swagger, versioning endpoints, generating SDKs, creating Postman collections, or building a developer portal. |
SKILL: API Documentation, OpenAPI/Swagger, Versioning & SDK Generation
Stack
NestJS + @nestjs/swagger + OpenAPI 3.1 + TypeScript SDK + Redoc
IDENTITY
You are a senior API documentation architect. You:
- Instrument every endpoint with complete Swagger decorators (not just
@ApiTags)
- Document the multi-tenant header contract so consumers never guess
- Generate typed SDKs that replace hand-written fetch calls
- Enforce a documentation checklist before any endpoint ships
- Maintain a versioned changelog so breaking changes are always communicated
CORE PRINCIPLES
1. Decorators Are the Source of Truth
The OpenAPI spec is generated FROM decorators. If a decorator is missing, the spec is wrong.
Never maintain a separate OpenAPI YAML by hand. The NestJS code IS the spec.
2. Every Endpoint Gets Full Coverage
No endpoint ships without:
@ApiTags() - module grouping
@ApiOperation() - human-readable summary + description
@ApiParam() - every path parameter documented
@ApiHeader() - required tenant headers
@ApiBody() - request DTO with examples
@ApiResponse() - every possible status code
@ApiBearerAuth() - auth requirement
@ApiQuery() - pagination and filter params
3. DTOs Are Self-Documenting
Every DTO field has @ApiProperty() with description, example, and required flag.
The DTO IS the documentation. If the DTO is incomplete, the docs are incomplete.
4. Multi-Tenant Scoping Is Visible
Every endpoint clearly states its tenant scope:
- Domain-scoped: requires
domainId path param, operates on domain data
- Workspace-scoped: requires
x-workspace-id header, cross-domain operations
- Company-scoped: requires
x-company-id header, billing/admin operations
- Public: no tenant context required (health, auth)
SWAGGER MODULE SETUP
Read openapi.md for the full module configuration, theming, and CI integration.
Minimal Bootstrap
import { NestFactory } from '@nestjs/core';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const config = new DocumentBuilder()
.setTitle('Platform API')
.setDescription('Multi-tenant SaaS API documentation')
.setVersion('1.0')
.addBearerAuth(
{ type: 'http', scheme: 'bearer', bearerFormat: 'JWT' },
'access-token',
)
.addApiKey(
{ type: 'apiKey', name: 'x-workspace-id', in: 'header' },
'workspace-id',
)
.addApiKey(
{ type: 'apiKey', : , : },
,
)
.(, )
.(, )
.(, )
.();
= .(app, config);
.(, app, , {
: {
: ,
: ,
: ,
},
});
app.();
}
();
DECORATOR PATTERNS — Complete Reference
Domain-Scoped Endpoint (CRUD — Hot Path)
import {
Controller, Get, Post, Patch, Delete, Param, Body, Query,
ParseUUIDPipe, HttpStatus,
} from '@nestjs/common';
import {
ApiTags, ApiOperation, ApiParam, ApiHeader, ApiBody,
ApiResponse, ApiBearerAuth, ApiQuery, ApiExtraModels,
} from '@nestjs/swagger';
@ApiTags('Recipients')
@ApiBearerAuth('access-token')
@Controller('domains/:domainId/recipients')
export class RecipientsController {
@Get()
@ApiOperation({
summary: 'List recipients in a domain',
description: 'Returns a paginated list of recipients scoped to the given domain. '
+ 'Supports filtering by status, tag, and custom field values. '
+ 'Results are ordered by createdAt descending.',
})
@ApiParam({
name: 'domainId',
type: 'string',
format: 'uuid',
: ,
: ,
})
({
: ,
: ,
: ,
: ,
})
({
: ,
: ,
: ,
: ,
: ,
})
({
: ,
: ,
: ,
: ,
: ,
})
({
: ,
: ,
: [, , , ],
: ,
})
({
: ,
: ,
: ,
: ,
})
({
: .,
: ,
: {
: ,
: {
: {
: ,
: { : },
},
: {
: ,
: {
: { : , : },
: { : , : },
: { : , : },
: { : , : },
},
},
},
},
})
({
: .,
: ,
})
({
: .,
: ,
})
({
: .,
: ,
})
() {
}
()
({
: ,
:
+
+ ,
})
({
: ,
: ,
: ,
: ,
})
({
: ,
: ,
: ,
})
({
: ,
: {
: {
: ,
: {
: ,
: ,
: ,
: ,
},
},
: {
: ,
: {
: ,
: ,
: ,
: ,
: {
: ,
: ,
: ,
},
},
},
},
})
({
: .,
: ,
: ,
})
({
: .,
: ,
: {
: ,
: {
: { : , : },
: { : , : },
},
},
})
({
: .,
: ,
})
() {
}
()
({
: ,
: ,
})
({ : , : , : })
({
: ,
: ,
: ,
: ,
})
({ : , : })
({ : })
({ : ., : })
({ : ., : })
() {
}
()
({
: ,
: ,
})
({ : , : , : })
({ : , : , : })
({ : , : })
({ : ., : })
({ : ., : })
() {
}
}
Workspace-Scoped Endpoint (Dashboards / Cross-Domain)
@ApiTags('Dashboards')
@ApiBearerAuth('access-token')
@Controller('workspaces/:workspaceId/dashboards')
export class DashboardsController {
@Get('recipients/stats')
@ApiOperation({
summary: 'Get recipient statistics across all domains in workspace',
description: 'Aggregates recipient counts, status breakdown, and growth trends '
+ 'across all domains in the workspace. Used for workspace-level dashboard.',
})
@ApiParam({
name: 'workspaceId',
type: 'string',
format: 'uuid',
description: 'Workspace to aggregate stats for',
})
@ApiHeader({
name: 'x-workspace-id',
required: true,
description: 'Must match the workspaceId path parameter',
})
@ApiQuery({
name: 'dateFrom',
required: false,
type: String,
description: 'Start date for trend data (ISO 8601)',
example: '2024-01-01',
})
@ApiQuery({
name: 'dateTo',
required: false,
: ,
: ,
: ,
})
({
: .,
: ,
: {
: ,
: {
: {
: ,
: {
: { : , : },
: {
: ,
: {
: { : , : },
: { : , : },
: { : , : },
: { : , : },
},
},
: {
: ,
: {
: ,
: {
: { : , : },
: { : , : },
: { : , : },
},
},
},
: {
: ,
: {
: ,
: {
: { : , : },
: { : , : },
: { : , : },
},
},
},
},
},
},
},
})
() {
}
}
Company-Scoped Endpoint (Billing / Admin)
@ApiTags('Billing')
@ApiBearerAuth('access-token')
@Controller('companies/:companyId/billing')
export class BillingController {
@Get('usage')
@ApiOperation({
summary: 'Get company usage and subscription limits',
description: 'Returns current resource usage vs plan limits. '
+ 'Includes recipient count, seat count, and storage across all workspaces.',
})
@ApiParam({
name: 'companyId',
type: 'string',
format: 'uuid',
description: 'Company to retrieve billing data for',
})
@ApiHeader({
name: 'x-company-id',
required: true,
description: 'Company context — must match companyId path param',
})
@ApiResponse({
status: HttpStatus.OK,
description: 'Current usage and limits',
schema: {
type: 'object',
properties: {
data: {
type: 'object',
properties: {
plan: { type: , : },
: { : , : },
: {
: ,
: {
: { : , : },
: { : , : },
: { : , : },
},
},
: {
: ,
: {
: { : , : },
: { : , : },
},
},
: {
: ,
: {
: { : , : },
: { : , : },
},
},
},
},
},
},
})
({
: .,
: ,
})
() {
}
}
Async / Bulk Endpoint (Job Queue Return)
@ApiTags('Recipients')
@ApiBearerAuth('access-token')
@Controller('domains/:domainId/recipients')
export class RecipientsBulkController {
@Post('bulk')
@ApiOperation({
summary: 'Bulk update recipients',
description: 'Updates multiple recipients matching the filter criteria. '
+ 'If >1000 recipients match, the operation is queued and a jobId is returned. '
+ 'Track progress via SSE at /jobs/:jobId/progress.',
})
@ApiParam({ name: 'domainId', type: 'string', format: 'uuid' })
@ApiHeader({ name: 'x-workspace-id', required: true })
@ApiBody({
type: BulkUpdateRecipientsDto,
examples: {
byFilter: {
summary: 'Update by filter (may be async)',
value: {
filter: { status: 'inactive', tags: ['churned'] },
update: { status: 'unsubscribed' },
},
},
byIds: {
summary: 'Update by explicit IDs (usually sync)',
: {
: [, , ],
: { : },
},
},
},
})
({
: .,
: ,
: {
: ,
: {
: { : , : },
: { : , : },
},
},
})
({
: .,
: ,
: {
: ,
: {
: { : , : },
: { : , : },
: { : , : },
},
},
})
({
: .,
: ,
})
() {
}
()
({
: ,
:
+
+ ,
})
({ : , : , : })
({ : , : })
({
: {
: ,
: {
: {
: ,
: ,
: ,
},
: {
: ,
: ,
: {
: ,
: ,
: ,
: ,
},
},
},
: [, ],
},
})
({
: .,
: ,
: {
: ,
: {
: { : , : },
: { : , : },
},
},
})
() {
}
}
DTO DOCUMENTATION PATTERNS
Request DTO with Full @ApiProperty Coverage
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsEmail, IsString, IsOptional, IsEnum, IsObject, MaxLength } from 'class-validator';
export class CreateRecipientDto {
@ApiProperty({
description: 'Recipient email address. Must be unique within the domain.',
example: 'john.doe@example.com',
format: 'email',
maxLength: 255,
})
@IsEmail()
@MaxLength(255)
email: string;
@ApiProperty({
description: 'First name of the recipient',
example: 'John',
maxLength: 100,
})
@IsString()
@MaxLength(100)
firstName: string;
@ApiProperty({
description: 'Last name of the recipient',
example: 'Doe',
maxLength: 100,
})
@IsString()
@MaxLength()
: ;
({
: ,
: [, ],
: ,
: ,
})
([, ])
: | = ;
({
:
+ ,
: { : , : },
: ,
: ,
})
()
()
?: <, >;
({
: ,
: [, ],
: [],
})
()
({ : })
?: [];
}
Response DTO
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class RecipientResponseDto {
@ApiProperty({
description: 'Unique identifier',
example: 'b3d7f1a2-5e8c-4b9d-a1f3-7c2e8d9b4a6f',
format: 'uuid',
})
id: string;
@ApiProperty({
description: 'Domain this recipient belongs to',
example: 'a1b2c3d4-5678-90ab-cdef-1234567890ab',
format: 'uuid',
})
domainId: string;
@ApiProperty({ example: 'john.doe@example.com' })
email: string;
@ApiProperty({ example: 'John' })
firstName: string;
@ApiProperty({ example: 'Doe' })
lastName: string;
@ApiProperty({ enum: ['active', 'inactive', 'bounced', 'unsubscribed'], example: 'active' })
status: string;
@ApiPropertyOptional({
: ,
: { : },
})
?: <, >;
({
: ,
: ,
: ,
})
: ;
({
: ,
: ,
: ,
})
: ;
}
Pagination Meta DTO (Reusable)
export class PaginationMetaDto {
@ApiProperty({ description: 'Total number of records matching the query', example: 1250 })
total: number;
@ApiProperty({ description: 'Current page number (1-indexed)', example: 1 })
page: number;
@ApiProperty({ description: 'Number of items per page', example: 25 })
pageSize: number;
@ApiProperty({ description: 'Whether more pages exist', example: true })
hasMore: boolean;
}
Error Response DTO
export class ErrorResponseDto {
@ApiProperty({ description: 'HTTP status code', example: 400 })
statusCode: number;
@ApiProperty({ description: 'Human-readable error message', example: 'Validation failed' })
message: string;
@ApiPropertyOptional({
description: 'Field-level validation errors',
example: [
{ field: 'email', message: 'Invalid email format' },
{ field: 'firstName', message: 'Must not be empty' },
],
})
errors?: { field: string; message: string }[];
}
MULTI-TENANT API DOCUMENTATION STRATEGY
Scope Decorator (Custom)
Create a custom decorator to mark endpoint scope in docs:
import { applyDecorators } from '@nestjs/common';
import { ApiHeader, ApiOperation } from '@nestjs/swagger';
export type TenantScope = 'domain' | 'workspace' | 'company' | 'public';
export function ApiTenantScope(scope: TenantScope, operation: {
summary: string;
description?: string;
}) {
const decorators = [
ApiOperation({
summary: `[${scope.toUpperCase()}] ${operation.summary}`,
description: operation.description,
}),
];
if (scope === 'domain' || scope === 'workspace') {
decorators.push(
ApiHeader({
name: 'x-workspace-id',
required: true,
description: 'Workspace context for tenant resolution',
schema: { type: 'string', format: 'uuid' },
}),
);
}
if (scope === 'company') {
decorators.(
({
: ,
: ,
: ,
: { : , : },
}),
);
}
(...decorators);
}
(, {
: ,
: ,
})
Grouping by Scope in Swagger UI
Use tag descriptions to group endpoints by scope:
const config = new DocumentBuilder()
.addTag('Domain — Recipients', 'CRUD operations scoped to a single domain')
.addTag('Domain — Campaigns', 'Campaign management within a domain')
.addTag('Workspace — Dashboards', 'Cross-domain reporting within a workspace')
.addTag('Workspace — Settings', 'Workspace-level configuration')
.addTag('Company — Billing', 'Subscription and usage for the entire company')
.addTag('Company — Admin', 'Company administration and user management')
.addTag('Public — Auth', 'Authentication endpoints (no tenant context)')
.addTag('Public — Health', 'Health check and status endpoints')
.build();
STANDARDIZED RESPONSE SHAPES
Document these consistently across all endpoints:
Single Entity
@ApiResponse({
status: 200,
schema: {
type: 'object',
properties: {
data: { $ref: '#/components/schemas/EntityDto' },
},
},
})
Paginated List
@ApiResponse({
status: 200,
schema: {
type: 'object',
properties: {
data: {
type: 'array',
items: { $ref: '#/components/schemas/EntityDto' },
},
meta: { $ref: '#/components/schemas/PaginationMetaDto' },
},
},
})
Async Job
@ApiResponse({
status: 202,
description: 'Operation queued for background processing',
schema: {
type: 'object',
properties: {
jobId: { type: 'string', example: 'bulk-op:abc123' },
status: { type: 'string', enum: ['queued', 'processing'], example: 'queued' },
estimated: { type: 'number', description: 'Estimated items to process', example: 12500 },
},
},
})
Error (All Endpoints)
Every endpoint should include these error responses:
@ApiResponse({ status: 401, description: 'Missing or invalid JWT token', type: ErrorResponseDto })
@ApiResponse({ status: 403, description: 'Insufficient permissions for this resource', type: ErrorResponseDto })
@ApiResponse({ status: 500, description: 'Internal server error', type: ErrorResponseDto })
@ApiResponse({ status: 400, description: 'Validation failed', type: ErrorResponseDto })
@ApiResponse({ status: 422, description: 'Business rule violation', type: ErrorResponseDto })
@ApiResponse({ status: 402, description: 'Subscription limit exceeded', type: ErrorResponseDto })
@ApiResponse({ status: 404, description: 'Resource not found in tenant scope', type: })
API VERSIONING STRATEGY
URL Versioning (Recommended)
app.enableVersioning({
type: VersioningType.URI,
defaultVersion: '1',
prefix: 'v',
});
@Controller({ path: 'domains/:domainId/recipients', version: '1' })
export class RecipientsV1Controller { }
@Controller({ path: 'domains/:domainId/recipients', version: '2' })
export class RecipientsV2Controller { }
Deprecation Decorators
import { ApiExtension } from '@nestjs/swagger';
@ApiOperation({
summary: 'List recipients (DEPRECATED)',
description: 'Use v2 endpoint instead. This endpoint will be removed on 2025-06-01.',
deprecated: true,
})
Version-Specific Swagger Documents
const v1Config = new DocumentBuilder()
.setTitle('Platform API v1')
.setVersion('1.0')
.build();
const v2Config = new DocumentBuilder()
.setTitle('Platform API v2')
.setVersion('2.0')
.build();
const v1Document = SwaggerModule.createDocument(app, v1Config, {
include: [RecipientsV1Module, CampaignsV1Module],
});
const v2Document = SwaggerModule.createDocument(app, v2Config, {
include: [RecipientsV2Module, CampaignsV2Module],
});
SwaggerModule.setup('api/v1/docs', app, v1Document);
SwaggerModule.setup('api/v2/docs', app, v2Document);
API CHANGELOG STRATEGY
Maintain a changelog at the API level:
Format
# API Changelog
## [v1.5.0] - 2024-03-15
### Added
- `GET /v1/domains/:domainId/recipients/export` — export recipients as CSV
- `customFields` filter support on `GET /v1/domains/:domainId/recipients`
### Changed (Non-Breaking)
- `GET /v1/workspaces/:wsId/dashboards/stats` now includes `trend` array
### Deprecated
- `GET /v1/domains/:domainId/recipients?tag=X` — use `tags[]=X` instead (removal: 2025-06-01)
## [v2.0.0] - 2024-04-01 (BREAKING)
### Breaking Changes
- Response wrapper changed: `{ items: [] }` → `{ data: [], meta: {} }`
- `GET /v1/recipients` removed — use `/v2/domains/:domainId/recipients`
- `x-tenant-id` header renamed to `x-workspace-id`
### Migration Guide
1. Update response parsing to use `data` instead of `items`
2. Add `meta.hasMore` check for pagination (replaces `nextCursor`)
3. Rename header from `x-tenant-id` to `x-workspace-id`
Breaking Change Detection in CI
- name: Detect breaking changes
run: |
npx openapi-diff ./openapi-previous.json ./openapi-current.json \
--fail-on-incompatible
MASTER CHECKLIST — Run Before Shipping ANY Endpoint
Decorator Coverage
DTO Coverage
Multi-Tenant
Response Shape
Versioning
SDK Impact
Read openapi.md for full Swagger module setup, theming, CI validation, and Postman export.
Read sdk-generation.md for TypeScript SDK generation, publishing, and frontend integration.