feat(openapi): Add OpenAPI module: decorators, spec generator, runtime validation and Swagger UI
This commit is contained in:
355
ts/openapi/openapi.decorators.ts
Normal file
355
ts/openapi/openapi.decorators.ts
Normal file
@@ -0,0 +1,355 @@
|
||||
/**
|
||||
* 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;
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user