/** * OpenAPI decorators for documentation and validation * * These decorators serve dual purposes: * 1. Generate OpenAPI 3.1 specification * 2. Validate requests at runtime */ import { getControllerMetadata } from '../decorators/decorators.metadata.js'; import type { TJsonSchema, IOpenApiOperationMeta, IOpenApiParamMeta, IOpenApiRequestBodyMeta, IOpenApiResponseBodyMeta, IOpenApiRouteMeta, } from '../decorators/decorators.types.js'; // ============================================================================= // Helper Functions // ============================================================================= /** * Get or create OpenAPI metadata for a route */ function getRouteOpenApi(target: any, methodName: string | symbol): IOpenApiRouteMeta { const metadata = getControllerMetadata(target.constructor); let route = metadata.routes.get(methodName); if (!route) { // Create placeholder route (will be completed by @Get/@Post/etc.) route = { method: 'GET', path: '', methodName, interceptors: [], options: {}, }; metadata.routes.set(methodName, route); } if (!route.openapi) { route.openapi = {}; } return route.openapi; } /** * Get or create OpenAPI metadata for a controller */ function getControllerOpenApi(target: any) { const metadata = getControllerMetadata(target); if (!metadata.openapi) { metadata.openapi = {}; } return metadata.openapi; } // ============================================================================= // Method Decorators // ============================================================================= /** * @ApiOperation - Document the operation with summary and description * * @example * ```typescript * @Get('/:id') * @ApiOperation({ * summary: 'Get user by ID', * description: 'Retrieves a single user by their unique identifier', * operationId: 'getUserById', * }) * getUser(ctx: IRequestContext) { ... } * ``` */ export function ApiOperation(options: IOpenApiOperationMeta) { return function ( target: (this: This, ...args: Args) => Return, context: ClassMethodDecoratorContext Return> ) { context.addInitializer(function (this: This) { const openapi = getRouteOpenApi(this, context.name); openapi.operation = { ...openapi.operation, ...options }; }); return target; }; } /** * @ApiParam - Document and validate a path parameter * * @example * ```typescript * @Get('/:id') * @ApiParam('id', { * description: 'User UUID', * schema: { type: 'string', format: 'uuid' } * }) * getUser(ctx: IRequestContext) { ... } * ``` */ export function ApiParam(name: string, options: IOpenApiParamMeta = {}) { return function ( target: (this: This, ...args: Args) => Return, context: ClassMethodDecoratorContext Return> ) { context.addInitializer(function (this: This) { const openapi = getRouteOpenApi(this, context.name); if (!openapi.params) { openapi.params = new Map(); } // Path params are always required openapi.params.set(name, { ...options, required: true, }); }); return target; }; } /** * @ApiQuery - Document and validate a query parameter * * Supports type coercion: string query values are converted to the * appropriate type based on the schema (integer, number, boolean, array). * * @example * ```typescript * @Get('/') * @ApiQuery('limit', { * description: 'Maximum number of results', * schema: { type: 'integer', default: 10, minimum: 1, maximum: 100 } * }) * @ApiQuery('active', { * description: 'Filter by active status', * schema: { type: 'boolean' } * }) * listUsers(ctx: IRequestContext) { * // ctx.query.limit is coerced to number * // ctx.query.active is coerced to boolean * } * ``` */ export function ApiQuery(name: string, options: IOpenApiParamMeta = {}) { return function ( target: (this: This, ...args: Args) => Return, context: ClassMethodDecoratorContext Return> ) { context.addInitializer(function (this: This) { const openapi = getRouteOpenApi(this, context.name); if (!openapi.query) { openapi.query = new Map(); } openapi.query.set(name, options); }); return target; }; } /** * @ApiHeader - Document and validate a header parameter * * @example * ```typescript * @Get('/') * @ApiHeader('X-Request-ID', { * description: 'Unique request identifier', * schema: { type: 'string', format: 'uuid' } * }) * getData(ctx: IRequestContext) { ... } * ``` */ export function ApiHeader(name: string, options: IOpenApiParamMeta = {}) { return function ( target: (this: This, ...args: Args) => Return, context: ClassMethodDecoratorContext Return> ) { context.addInitializer(function (this: This) { const openapi = getRouteOpenApi(this, context.name); if (!openapi.headers) { openapi.headers = new Map(); } openapi.headers.set(name, options); }); return target; }; } /** * @ApiRequestBody - Document and validate the request body * * The request body is validated against the provided JSON Schema. * Invalid requests receive a 400 response before the handler runs. * * @example * ```typescript * const CreateUserSchema = { * type: 'object', * properties: { * name: { type: 'string', minLength: 1 }, * email: { type: 'string', format: 'email' }, * }, * required: ['name', 'email'], * }; * * @Post('/') * @ApiRequestBody({ * description: 'User creation payload', * schema: CreateUserSchema * }) * createUser(ctx: IRequestContext<{ name: string; email: string }>) { * // ctx.body is guaranteed to match the schema * } * ``` */ export function ApiRequestBody(options: IOpenApiRequestBodyMeta) { return function ( target: (this: This, ...args: Args) => Return, context: ClassMethodDecoratorContext Return> ) { context.addInitializer(function (this: This) { const openapi = getRouteOpenApi(this, context.name); openapi.requestBody = options; }); return target; }; } /** * @ApiResponseBody - Document a response for a specific status code * * Multiple @ApiResponseBody decorators can be used for different status codes. * * @example * ```typescript * @Get('/:id') * @ApiResponseBody(200, { * description: 'User found', * schema: UserSchema * }) * @ApiResponseBody(404, { * description: 'User not found' * }) * getUser(ctx: IRequestContext) { ... } * ``` */ export function ApiResponseBody(status: number, options: Omit) { return function ( target: (this: This, ...args: Args) => Return, context: ClassMethodDecoratorContext Return> ) { context.addInitializer(function (this: This) { const openapi = getRouteOpenApi(this, context.name); if (!openapi.responses) { openapi.responses = new Map(); } openapi.responses.set(status, options as IOpenApiResponseBodyMeta); }); return target; }; } /** * @ApiSecurity - Specify security requirements for a route * * Can be used on classes (applies to all routes) or methods. * * @example * ```typescript * // Method-level security * @Post('/') * @ApiSecurity('bearerAuth') * createUser(ctx: IRequestContext) { ... } * * // With OAuth scopes * @Delete('/:id') * @ApiSecurity('oauth2', ['users:delete']) * deleteUser(ctx: IRequestContext) { ... } * ``` */ export function ApiSecurity(name: string, scopes: string[] = []) { // Can be both class and method decorator return function any) | ((this: any, ...args: any[]) => any)>( target: T, context: ClassDecoratorContext | ClassMethodDecoratorContext ): T { if (context.kind === 'class') { // Class decorator const controllerOpenApi = getControllerOpenApi(target); if (!controllerOpenApi.security) { controllerOpenApi.security = []; } controllerOpenApi.security.push({ [name]: scopes }); } else if (context.kind === 'method') { // Method decorator context.addInitializer(function (this: any) { const openapi = getRouteOpenApi(this, context.name); if (!openapi.security) { openapi.security = []; } openapi.security.push({ [name]: scopes }); }); } return target; }; } // ============================================================================= // Class Decorators // ============================================================================= /** * @ApiTag - Group routes under a tag in the documentation * * Multiple tags can be specified. Applied to all routes in the controller. * * @example * ```typescript * @Route('/api/users') * @ApiTag('Users') * @ApiTag('Admin') * class UserController { ... } * ``` */ export function ApiTag(...tags: string[]) { return function any>( target: TClass, context: ClassDecoratorContext ): TClass { if (context.kind !== 'class') { throw new Error('@ApiTag can only decorate classes'); } const controllerOpenApi = getControllerOpenApi(target); if (!controllerOpenApi.tags) { controllerOpenApi.tags = []; } controllerOpenApi.tags.push(...tags); return target; }; }