356 lines
9.6 KiB
TypeScript
356 lines
9.6 KiB
TypeScript
/**
|
|
* 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 <This, Args extends any[], Return>(
|
|
target: (this: This, ...args: Args) => Return,
|
|
context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => 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 <This, Args extends any[], Return>(
|
|
target: (this: This, ...args: Args) => Return,
|
|
context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => 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 <This, Args extends any[], Return>(
|
|
target: (this: This, ...args: Args) => Return,
|
|
context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => 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 <This, Args extends any[], Return>(
|
|
target: (this: This, ...args: Args) => Return,
|
|
context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => 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 <This, Args extends any[], Return>(
|
|
target: (this: This, ...args: Args) => Return,
|
|
context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => 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<IOpenApiResponseBodyMeta, 'status'>) {
|
|
return function <This, Args extends any[], Return>(
|
|
target: (this: This, ...args: Args) => Return,
|
|
context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => 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 <T extends (new (...args: any[]) => 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 <TClass extends new (...args: any[]) => any>(
|
|
target: TClass,
|
|
context: ClassDecoratorContext<TClass>
|
|
): 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;
|
|
};
|
|
}
|