feat(openapi): Add OpenAPI module: decorators, spec generator, runtime validation and Swagger UI

This commit is contained in:
2025-12-08 17:43:51 +00:00
parent 15848b9c9c
commit cc3e335112
19 changed files with 2405 additions and 19 deletions

View File

@@ -135,7 +135,7 @@ export function combinePaths(basePath: string, routePath: string): string {
const route = normalizePath(routePath);
if (!base) return route || '/';
if (!route) return base;
if (!route || route === '/') return base || '/';
return `${base}${route}`;
}

View File

@@ -2,9 +2,10 @@
* Controller registry - stores all registered controllers
*/
import type { IControllerMetadata, IRegisteredController, ICompiledRoute } from './decorators.types.js';
import type { IControllerMetadata, IRegisteredController, ICompiledRoute, IOpenApiRouteMeta } from './decorators.types.js';
import type { IRequestContext, IInterceptOptions, THttpMethod } from '../core/smartserve.interfaces.js';
import { getControllerMetadata, combinePaths } from './decorators.metadata.js';
import { createValidationInterceptor } from '../openapi/openapi.validator.js';
/**
* Global registry of all controllers
@@ -92,7 +93,7 @@ export class ControllerRegistry {
/**
* Compile all routes for fast matching
*/
static compileRoutes(): ICompiledRoute[] {
static compileRoutes(enableValidation = true): ICompiledRoute[] {
if (this.routesCompiled) {
return this.compiledRoutes;
}
@@ -105,10 +106,19 @@ export class ControllerRegistry {
const { regex, paramNames } = this.pathToRegex(fullPath);
// Combine class and method interceptors
const interceptors: IInterceptOptions[] = [
...metadata.classInterceptors,
...route.interceptors,
];
const interceptors: IInterceptOptions[] = [];
// Add OpenAPI validation interceptor first (before other interceptors)
// This ensures validation happens before any other processing
if (enableValidation && route.openapi && this.hasValidationMetadata(route.openapi)) {
interceptors.push({
request: createValidationInterceptor(route.openapi),
});
}
// Then add class-level and method-level interceptors
interceptors.push(...metadata.classInterceptors);
interceptors.push(...route.interceptors);
// Create bound handler
const handler = async (ctx: IRequestContext): Promise<any> => {
@@ -127,6 +137,7 @@ export class ControllerRegistry {
handler,
interceptors,
compression: route.compression,
openapi: route.openapi,
});
}
}
@@ -214,6 +225,18 @@ export class ControllerRegistry {
};
}
/**
* Check if OpenAPI metadata contains validation-relevant information
*/
private static hasValidationMetadata(openapi: IOpenApiRouteMeta): boolean {
return !!(
openapi.requestBody ||
(openapi.params && openapi.params.size > 0) ||
(openapi.query && openapi.query.size > 0) ||
(openapi.headers && openapi.headers.size > 0)
);
}
/**
* Clear all registered controllers (useful for testing)
*/

View File

@@ -28,6 +28,8 @@ export interface IControllerMetadata {
routes: Map<string | symbol, IRouteMetadata>;
/** Controller class reference */
target?: new (...args: any[]) => any;
/** OpenAPI metadata for controller */
openapi?: IOpenApiControllerMeta;
}
/**
@@ -58,6 +60,8 @@ export interface IRouteMetadata {
handler?: Function;
/** Route-specific compression settings */
compression?: IRouteCompressionOptions;
/** OpenAPI metadata for this route */
openapi?: IOpenApiRouteMeta;
}
/**
@@ -88,4 +92,138 @@ export interface ICompiledRoute {
interceptors: IInterceptOptions[];
/** Route-specific compression settings */
compression?: IRouteCompressionOptions;
/** OpenAPI metadata for this route */
openapi?: IOpenApiRouteMeta;
}
// =============================================================================
// OpenAPI / JSON Schema Types
// =============================================================================
/**
* JSON Schema type for OpenAPI schemas
* Supports JSON Schema draft 2020-12 (used by OpenAPI 3.1)
*/
export type TJsonSchema = {
type?: 'string' | 'number' | 'integer' | 'boolean' | 'array' | 'object' | 'null' | Array<'string' | 'number' | 'integer' | 'boolean' | 'array' | 'object' | 'null'>;
format?: string;
properties?: Record<string, TJsonSchema>;
items?: TJsonSchema;
required?: string[];
enum?: unknown[];
const?: unknown;
default?: unknown;
description?: string;
minimum?: number;
maximum?: number;
minLength?: number;
maxLength?: number;
pattern?: string;
minItems?: number;
maxItems?: number;
uniqueItems?: boolean;
additionalProperties?: boolean | TJsonSchema;
allOf?: TJsonSchema[];
anyOf?: TJsonSchema[];
oneOf?: TJsonSchema[];
not?: TJsonSchema;
$ref?: string;
[key: string]: unknown;
};
/**
* OpenAPI operation metadata for documentation
*/
export interface IOpenApiOperationMeta {
/** Short summary of what the operation does */
summary?: string;
/** Longer description with markdown support */
description?: string;
/** Unique operation identifier (auto-generated if omitted) */
operationId?: string;
/** Mark operation as deprecated */
deprecated?: boolean;
/** External documentation */
externalDocs?: {
url: string;
description?: string;
};
}
/**
* OpenAPI parameter metadata (path, query, header, cookie)
*/
export interface IOpenApiParamMeta {
/** Human-readable description */
description?: string;
/** Whether parameter is required (path params always required) */
required?: boolean;
/** JSON Schema for the parameter */
schema?: TJsonSchema;
/** Example value */
example?: unknown;
/** Multiple examples */
examples?: Record<string, { value: unknown; summary?: string; description?: string }>;
/** Allow empty value */
allowEmptyValue?: boolean;
/** Deprecation flag */
deprecated?: boolean;
}
/**
* OpenAPI request body metadata
*/
export interface IOpenApiRequestBodyMeta {
/** Human-readable description */
description?: string;
/** Whether body is required (default: true) */
required?: boolean;
/** JSON Schema for the body (assumes application/json) */
schema: TJsonSchema;
/** Example value */
example?: unknown;
}
/**
* OpenAPI response body metadata
*/
export interface IOpenApiResponseBodyMeta {
/** Human-readable description */
description: string;
/** JSON Schema for the response (assumes application/json) */
schema?: TJsonSchema;
/** Example value */
example?: unknown;
/** Response headers */
headers?: Record<string, { description?: string; schema?: TJsonSchema }>;
}
/**
* Combined OpenAPI metadata for a route method
*/
export interface IOpenApiRouteMeta {
/** Operation details (summary, description, etc.) */
operation?: IOpenApiOperationMeta;
/** Path parameters by name */
params?: Map<string, IOpenApiParamMeta>;
/** Query parameters by name */
query?: Map<string, IOpenApiParamMeta>;
/** Header parameters by name */
headers?: Map<string, IOpenApiParamMeta>;
/** Request body schema */
requestBody?: IOpenApiRequestBodyMeta;
/** Response bodies by status code */
responses?: Map<number, IOpenApiResponseBodyMeta>;
/** Security requirements */
security?: Array<Record<string, string[]>>;
}
/**
* OpenAPI metadata for a controller class
*/
export interface IOpenApiControllerMeta {
/** Tags for grouping routes in documentation */
tags?: string[];
/** Security requirements for all routes in controller */
security?: Array<Record<string, string[]>>;
}

View File

@@ -5,6 +5,14 @@ export type {
IRouteCompressionOptions,
IRegisteredController,
ICompiledRoute,
// OpenAPI types
TJsonSchema,
IOpenApiOperationMeta,
IOpenApiParamMeta,
IOpenApiRequestBodyMeta,
IOpenApiResponseBodyMeta,
IOpenApiRouteMeta,
IOpenApiControllerMeta,
} from './decorators.types.js';
// Route decorator
@@ -49,3 +57,15 @@ export {
normalizePath,
combinePaths,
} from './decorators.metadata.js';
// OpenAPI decorators
export {
ApiOperation,
ApiParam,
ApiQuery,
ApiHeader,
ApiRequestBody,
ApiResponseBody,
ApiSecurity,
ApiTag,
} from '../openapi/openapi.decorators.js';