Files
smartserve/ts/openapi/openapi.decorators.ts

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;
};
}