Generate REST API endpoints with class-validator DTOs, routing-controllers decorators, and complete Swagger docs. Use when creating API endpoints for existing use cases, adding routes, or building custom API actions (e.g., "Create user API", "Generate product endpoints").
Generate presentation layer components using routing-controllers with NestJS-style decorators, class-validator for validation, and automatic Swagger documentation.
Creates production-ready REST API endpoints:
@IsString, @IsEmail, etc.)@JSONSchema decorators for Swagger docs@JsonController, @Get, @Post, etc.@OpenAPI and @ResponseSchemaUse when you need to:
Examples:
All controllers MUST be prefixed with /v1/:
@JsonController('/v1/users')
export class UserController { }
/v1/users not /v1/user/v1/sms-messages/v1/users not /v1/getUsers@HttpCode(201))ResponseInterceptor middleware wraps all responses:
{
"success": true,
"data": {...},
"timestamp": "2024-01-15T10:30:00.000Z"
}
Create request DTOs in dto/requests/ with class-validator decorators:
// dto/requests/create-entity.dto.ts
import { IsString, IsEmail, IsOptional, IsNumber, IsEnum, Length, Min, Max } from 'class-validator';
import { JSONSchema } from 'class-validator-jsonschema';
export class CreateEntityDto {
@IsString()
@Length(1, 100)
@JSONSchema({
description: 'Entity name (1-100 characters)',
minLength: 1,
maxLength: 100,
example: 'My Entity',
})
name!: string;
@IsEmail()
@JSONSchema({
description: 'Valid email address',
format: 'email',
example: 'user@example.com',
})
email!: string;
@IsOptional()
@IsNumber()
@Min(0)
@Max(150)
@JSONSchema({
description: 'Age in years (optional)',
minimum: 0,
maximum: 150,
example: 30,
})
age?: number;
@IsEnum(['admin', 'user', 'guest'])
@JSONSchema({
description: 'User role',
enum: ['admin', 'user', 'guest'],
example: 'user',
})
role!: 'admin' | 'user' | 'guest';
}
Create response serializers in dto/responses/ with BOTH class-validator decorators AND @JSONSchema decorators:
⚠️ CRITICAL: Response serializers MUST include class-validator decorators (@IsString(), @IsBoolean(), etc.) for Swagger schema generation. Without these decorators, validationMetadatasToSchemas() cannot generate proper OpenAPI schemas, resulting in generic ["string"] appearing in Swagger instead of the actual response structure.
// dto/responses/entity-response.serializer.ts
import { JSONSchema } from 'class-validator-jsonschema';
import { IsString, IsBoolean, IsDate, IsArray, IsOptional } from 'class-validator';
export class EntityResponseSerializer {
@IsString()
@JSONSchema({
description: 'Entity unique identifier',
format: 'uuid',
example: '550e8400-e29b-41d4-a716-446655440000',
})
id!: string;
@IsString()
@JSONSchema({
description: 'Entity name',
example: 'My Entity',
})
name!: string;
@IsString()
@JSONSchema({
description: 'Email address',
format: 'email',
example: 'user@example.com',
})
email!: string;
@IsBoolean()
@JSONSchema({
description: 'Whether entity is active',
example: true,
})
isActive!: boolean;
@IsArray()
@JSONSchema({
description: 'List of tags',
type: 'array',
items: { type: 'string' },
example: ['tag1', 'tag2'],
})
tags!: string[];
@IsString()
@IsOptional()
@JSONSchema({
description: 'Optional description',
nullable: true,
example: 'Some description',
})
description?: string | null;
@IsDate()
@JSONSchema({
description: 'Creation timestamp',
format: 'date-time',
example: '2024-01-15T10:30:00.000Z',
})
createdAt!: Date;
@IsDate()
@JSONSchema({
description: 'Last update timestamp',
format: 'date-time',
example: '2024-01-15T10:30:00.000Z',
})
updatedAt!: Date;
}
Required class-validator decorators for response serializers:
@IsString() - for string fields@IsBoolean() - for boolean fields@IsNumber() - for number fields@IsDate() - for Date fields@IsArray() - for array fields@IsOptional() - for optional/nullable fields// entity.controller.ts
import { JsonController, Get, Post, Patch, Delete, Body, Param, Query, HttpCode } from 'routing-controllers';
import { ResponseSchema, OpenAPI } from 'routing-controllers-openapi';
import { injectable, inject } from 'tsyringe';
import { CurrentUser, RequirePermissions } from '@/global/decorators';
import { Permission } from '@/global/types';
import type { AuthenticatedUser } from '@/global/types/auth.types';
import {
CreateEntityUseCase,
FindEntityUseCase,
UpdateEntityUseCase,
DeleteEntityUseCase,
ListEntitiesUseCase,
} from '../application';
import { CreateEntityDto } from './dto/requests/create-entity.dto';
import { UpdateEntityDto } from './dto/requests/update-entity.dto';
import { QueryEntityDto } from './dto/requests/query-entity.dto';
import { EntityResponseSerializer } from './dto/responses/entity-response.serializer';
import { EntityListResponseSerializer } from './dto/responses/entity-list-response.serializer';
@injectable()
@JsonController('/v1/entities')
export class EntityController {
constructor(
@inject(CreateEntityUseCase)
private readonly createUseCase: CreateEntityUseCase,
@inject(FindEntityUseCase)
private readonly findUseCase: FindEntityUseCase,
@inject(UpdateEntityUseCase)
private readonly updateUseCase: UpdateEntityUseCase,
@inject(DeleteEntityUseCase)
private readonly deleteUseCase: DeleteEntityUseCase,
@inject(ListEntitiesUseCase)
private readonly listUseCase: ListEntitiesUseCase
) {}
@Post('/')
@HttpCode(201)
@ResponseSchema(EntityResponseSerializer, { statusCode: 201 })
@OpenAPI({
summary: 'Create entity',
description: 'Creates a new entity with the provided data',
tags: ['Entities'],
security: [{ bearerAuth: [] }],
responses: {
'201': { description: 'Entity created successfully' },
'400': { description: 'Invalid input data' },
'401': { description: 'Unauthorized' },
'403': { description: 'Forbidden - insufficient permissions' },
'409': { description: 'Entity already exists' },
},
})
@RequirePermissions(Permission.ENTITIES_WRITE)
async create(
@CurrentUser() user: AuthenticatedUser,
@Body() body: CreateEntityDto
): Promise<EntityResponseSerializer> {
const result = await this.createUseCase.execute({
...body,
tenantId: user.tenantId,
});
return {
id: result.id,
name: result.name,
email: result.email,
createdAt: result.createdAt,
updatedAt: result.updatedAt,
};
}
@Get('/:id')
@ResponseSchema(EntityResponseSerializer)
@OpenAPI({
summary: 'Get entity by ID',
description: 'Retrieves a single entity by its unique identifier',
tags: ['Entities'],
security: [{ bearerAuth: [] }],
responses: {
'200': { description: 'Entity found' },
'401': { description: 'Unauthorized' },
'403': { description: 'Forbidden - insufficient permissions' },
'404': { description: 'Entity not found' },
},
})
@RequirePermissions(Permission.ENTITIES_READ)
async findById(
@Param('id') id: string,
@CurrentUser() user: AuthenticatedUser
): Promise<EntityResponseSerializer> {
const entity = await this.findUseCase.execute(id);
return {
id: entity.id,
name: entity.name,
email: entity.email,
createdAt: entity.createdAt,
updatedAt: entity.updatedAt,
};
}
@Get('/')
@ResponseSchema(EntityListResponseSerializer)
@OpenAPI({
summary: 'List entities',
description: 'Retrieves a paginated list of entities',
tags: ['Entities'],
security: [{ bearerAuth: [] }],
responses: {
'200': { description: 'Entities retrieved successfully' },
'401': { description: 'Unauthorized' },
'403': { description: 'Forbidden - insufficient permissions' },
},
})
@RequirePermissions(Permission.ENTITIES_READ)
async list(
@Query() query: QueryEntityDto,
@CurrentUser() user: AuthenticatedUser
): Promise<EntityListResponseSerializer> {
const result = await this.listUseCase.execute({
...query,
tenantId: user.tenantId,
});
return {
items: result.items.map((entity) => ({
id: entity.id,
name: entity.name,
email: entity.email,
createdAt: entity.createdAt,
updatedAt: entity.updatedAt,
})),
total: result.total,
limit: result.limit,
offset: result.offset,
};
}
@Patch('/:id')
@ResponseSchema(EntityResponseSerializer)
@OpenAPI({
summary: 'Update entity',
description: 'Updates an existing entity with partial data',
tags: ['Entities'],
security: [{ bearerAuth: [] }],
responses: {
'200': { description: 'Entity updated successfully' },
'400': { description: 'Invalid input data' },
'401': { description: 'Unauthorized' },
'403': { description: 'Forbidden - insufficient permissions' },
'404': { description: 'Entity not found' },
},
})
@RequirePermissions(Permission.ENTITIES_WRITE)
async update(
@Param('id') id: string,
@Body() body: UpdateEntityDto,
@CurrentUser() user: AuthenticatedUser
): Promise<EntityResponseSerializer> {
const result = await this.updateUseCase.execute({
id,
...body,
});
return {
id: result.id,
name: result.name,
email: result.email,
createdAt: result.createdAt,
updatedAt: result.updatedAt,
};
}
@Delete('/:id')
@HttpCode(200)
@ResponseSchema(EntityResponseSerializer)
@OpenAPI({
summary: 'Delete entity',
description: 'Permanently deletes an entity',
tags: ['Entities'],
security: [{ bearerAuth: [] }],
responses: {
'200': { description: 'Entity deleted successfully' },
'401': { description: 'Unauthorized' },
'403': { description: 'Forbidden - insufficient permissions' },
'404': { description: 'Entity not found' },
},
})
@RequirePermissions(Permission.ENTITIES_DELETE)
async delete(
@Param('id') id: string,
@CurrentUser() user: AuthenticatedUser
): Promise<{ success: boolean }> {
await this.deleteUseCase.execute(id);
return { success: true };
}
}
Domain errors are automatically mapped to HTTP status codes by GlobalErrorHandler. No explicit try-catch needed in controllers - just let errors bubble up.
The middleware handles:
NotFoundError → 404ConflictError → 409ValidationError → 400UnauthorizedError → 401ForbiddenError → 403DomainError → 422// dto/requests/query-entity.dto.ts
import { IsOptional, IsNumber, IsString, IsEnum, Min } from 'class-validator';
import { JSONSchema } from 'class-validator-jsonschema';
import { Type } from 'class-transformer';
export class QueryEntityDto {
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(1)
@JSONSchema({
description: 'Number of items per page',
minimum: 1,
example: 20,
})
limit?: number = 20;
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0)
@JSONSchema({
description: 'Number of items to skip',
minimum: 0,
example: 0,
})
offset?: number = 0;
@IsOptional()
@IsEnum(['name', 'createdAt'])
@JSONSchema({
description: 'Field to sort by',
enum: ['name', 'createdAt'],
example: 'createdAt',
})
sortBy?: 'name' | 'createdAt' = 'createdAt';
@IsOptional()
@IsEnum(['asc', 'desc'])
@JSONSchema({
description: 'Sort order',
enum: ['asc', 'desc'],
example: 'desc',
})
order?: 'asc' | 'desc' = 'desc';
@IsOptional()
@IsString()
@JSONSchema({
description: 'Search term',
example: 'john',
})
search?: string;
}
// dto/responses/entity-list-response.serializer.ts
import { JSONSchema } from 'class-validator-jsonschema';
import { EntityResponseSerializer } from './entity-response.serializer';
export class EntityListResponseSerializer {
@JSONSchema({
description: 'List of entities',
type: 'array',
items: { $ref: '#/components/schemas/EntityResponseSerializer' },
})
items!: EntityResponseSerializer[];
@JSONSchema({
description: 'Total number of entities',
example: 100,
})
total!: number;
@JSONSchema({
description: 'Number of items per page',
example: 20,
})
limit!: number;
@JSONSchema({
description: 'Number of items skipped',
example: 0,
})
offset!: number;
}
MUST DO:
/v1/@JSONSchema decorators@JsonController for routing@Get, @Post, @Patch, @Delete@CurrentUser() to inject authenticated user@RequirePermissions() for authorization@OpenAPI() and @ResponseSchema() to all endpoints@HttpCode(201) for POST endpointsMUST NOT:
@OpenAPI() or @ResponseSchema() decorators@JSONSchema() on DTO/Serializer fields/src/contexts/{Context}/presentation/
├── dto/
│ ├── requests/
│ │ ├── create-{entity}.dto.ts
│ │ ├── update-{entity}.dto.ts
│ │ └── query-{entity}.dto.ts
│ └── responses/
│ ├── {entity}-response.serializer.ts
│ └── {entity}-list-response.serializer.ts
└── {context}.controller.ts
Add controller to /src/main.ts:
import { EntityController } from './contexts/entity/presentation/entity.controller';
const routingControllersOptions = {
controllers: [UserController, TenantController, EntityController],
// ...
};
After generation, verify:
/v1/@JSONSchema decorators@injectable() and @JsonController()@Get, @Post, @Patch, @Delete@OpenAPI() metadata complete@ResponseSchema() applied@RequirePermissions() added where neededSearch for places (restaurants, cafes, etc.) via Google Places API proxy on localhost.
Interact with GitHub using the `gh` CLI. Use `gh issue`, `gh pr`, `gh run`, and `gh api` for issues, PRs, CI runs, and advanced queries.
Create or update AgentSkills. Use when designing, structuring, or packaging skills with scripts, references, and assets.
Start voice calls via the OpenClaw voice-call plugin.
Notion API for creating and managing pages, databases, and blocks.
Gemini CLI for one-shot Q&A, summaries, and generation.
Category:developer