From cc3e335112aa27d073fc5abf0514338e8defcb03 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Mon, 8 Dec 2025 17:43:51 +0000 Subject: [PATCH] feat(openapi): Add OpenAPI module: decorators, spec generator, runtime validation and Swagger UI --- changelog.md | 14 + package.json | 3 +- pnpm-lock.yaml | 28 +- test/test.openapi.ts | 420 +++++++++++++++++++ ts/00_commitinfo_data.ts | 2 +- ts/core/smartserve.classes.smartserve.ts | 29 ++ ts/core/smartserve.interfaces.ts | 3 + ts/decorators/decorators.metadata.ts | 2 +- ts/decorators/decorators.registry.ts | 35 +- ts/decorators/decorators.types.ts | 138 +++++++ ts/decorators/index.ts | 20 + ts/index.ts | 3 + ts/openapi/index.ts | 84 ++++ ts/openapi/openapi.coerce.ts | 110 +++++ ts/openapi/openapi.decorators.ts | 355 +++++++++++++++++ ts/openapi/openapi.generator.ts | 306 ++++++++++++++ ts/openapi/openapi.handlers.ts | 138 +++++++ ts/openapi/openapi.types.ts | 488 +++++++++++++++++++++++ ts/openapi/openapi.validator.ts | 246 ++++++++++++ 19 files changed, 2405 insertions(+), 19 deletions(-) create mode 100644 test/test.openapi.ts create mode 100644 ts/openapi/index.ts create mode 100644 ts/openapi/openapi.coerce.ts create mode 100644 ts/openapi/openapi.decorators.ts create mode 100644 ts/openapi/openapi.generator.ts create mode 100644 ts/openapi/openapi.handlers.ts create mode 100644 ts/openapi/openapi.types.ts create mode 100644 ts/openapi/openapi.validator.ts diff --git a/changelog.md b/changelog.md index 8443e15..caa1927 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,19 @@ # Changelog +## 2025-12-08 - 1.4.0 - feat(openapi) +Add OpenAPI module: decorators, spec generator, runtime validation and Swagger UI + +- Introduce a new OpenAPI module providing decorators, types, spec generator, handlers and validation utilities +- Add decorators: ApiOperation, ApiParam, ApiQuery, ApiHeader, ApiRequestBody, ApiResponseBody, ApiSecurity, ApiTag +- Add OpenApiGenerator to produce OpenAPI 3.1 JSON from registered controllers and route metadata +- Add runtime request validation and coercion using @cfworker/json-schema (validate request body, params, query, headers) +- Register OpenAPI endpoints and Swagger UI (and ReDoc) handlers when SmartServe.openapi is enabled +- Integrate validation interceptor into controller registry compilation so validation runs before other interceptors +- Expose openapi exports from the public API (ts/index.ts and decorators index) +- Add extensive types (openapi.types.ts and decorator types) and coercion utilities for query/path params +- Add tests for OpenAPI functionality (test/test.openapi.ts) +- Bump dependencies: @api.global/typedrequest to ^3.2.5 and add @cfworker/json-schema ^4.1.1 + ## 2025-12-05 - 1.3.0 - feat(compression) Improve compression implementation (buffering and threshold), add Deno brotli support, add compression tests and dynamic route API diff --git a/package.json b/package.json index bc5bbd5..3ab939c 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,8 @@ "@types/ws": "^8.18.1" }, "dependencies": { - "@api.global/typedrequest": "^3.1.11", + "@api.global/typedrequest": "^3.2.5", + "@cfworker/json-schema": "^4.1.1", "@push.rocks/lik": "^6.2.2", "@push.rocks/smartenv": "^6.0.0", "@push.rocks/smartlog": "^3.1.10", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fbccc2a..21cc44d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,8 +9,11 @@ importers: .: dependencies: '@api.global/typedrequest': - specifier: ^3.1.11 - version: 3.1.11 + specifier: ^3.2.5 + version: 3.2.5 + '@cfworker/json-schema': + specifier: ^4.1.1 + version: 4.1.1 '@push.rocks/lik': specifier: ^6.2.2 version: 6.2.2 @@ -57,8 +60,8 @@ packages: '@api.global/typedrequest-interfaces@3.0.19': resolution: {integrity: sha512-uuHUXJeOy/inWSDrwD0Cwax2rovpxYllDhM2RWh+6mVpQuNmZ3uw6IVg6dA2G1rOe24Ebs+Y9SzEogo+jYN7vw==} - '@api.global/typedrequest@3.1.11': - resolution: {integrity: sha512-j8EO3na0WMw8pFkAfEaEui2a4TaAL1G/dv1CYl8LEPXckSKkl1BCAS1kFOW2xuI9pwZkmSqlo3xpQ3KmkmHaGQ==} + '@api.global/typedrequest@3.2.5': + resolution: {integrity: sha512-LM/sUTuYnU5xY4gNZrN6ERMiKr+SpDZuSxJkAZz1YazC7ymGfo6uQ8sCnN8eNNQNFqIOkC+BtfYRayfbGwYLLg==} '@api.global/typedserver@3.0.80': resolution: {integrity: sha512-dcp0oXsjBL+XdFg1wUUP08uJQid5bQ0Yv3V3Y3lnI2QCbat0FU+Tsb0TZRnZ4+P150Vj/ITBqJUgDzFsF34grA==} @@ -240,6 +243,9 @@ packages: '@borewit/text-codec@0.1.1': resolution: {integrity: sha512-5L/uBxmjaCIX5h8Z+uu+kA9BQLkc/Wl06UGR5ajNRxu+/XjonB5i8JpgFMrPj3LXTCPA0pv8yxUvbUi+QthGGA==} + '@cfworker/json-schema@4.1.1': + resolution: {integrity: sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==} + '@cloudflare/workers-types@4.20251128.0': resolution: {integrity: sha512-gQxQvxLRsFb+mDlaBKGoJwEHWt+ox9telZZEuRMbNUAD6v78XYqZepTI4yPDdKhkRTlqYcDqDhIdAI3HrsGk7w==} @@ -4276,7 +4282,7 @@ snapshots: '@api.global/typedrequest-interfaces@3.0.19': {} - '@api.global/typedrequest@3.1.11': + '@api.global/typedrequest@3.2.5': dependencies: '@api.global/typedrequest-interfaces': 3.0.19 '@push.rocks/isounique': 1.0.5 @@ -4290,7 +4296,7 @@ snapshots: '@api.global/typedserver@3.0.80': dependencies: - '@api.global/typedrequest': 3.1.11 + '@api.global/typedrequest': 3.2.5 '@api.global/typedrequest-interfaces': 3.0.19 '@api.global/typedsocket': 3.0.1 '@cloudflare/workers-types': 4.20251128.0 @@ -4337,7 +4343,7 @@ snapshots: '@api.global/typedsocket@3.0.1': dependencies: - '@api.global/typedrequest': 3.1.11 + '@api.global/typedrequest': 3.2.5 '@api.global/typedrequest-interfaces': 3.0.19 '@push.rocks/isohash': 2.0.1 '@push.rocks/smartjson': 5.2.0 @@ -4844,6 +4850,8 @@ snapshots: '@borewit/text-codec@0.1.1': {} + '@cfworker/json-schema@4.1.1': {} + '@cloudflare/workers-types@4.20251128.0': {} '@colors/colors@1.6.0': {} @@ -4860,14 +4868,14 @@ snapshots: '@design.estate/dees-comms@1.0.27': dependencies: - '@api.global/typedrequest': 3.1.11 + '@api.global/typedrequest': 3.2.5 '@api.global/typedrequest-interfaces': 3.0.19 '@push.rocks/smartdelay': 3.0.5 broadcast-channel: 7.2.0 '@design.estate/dees-domtools@2.3.6': dependencies: - '@api.global/typedrequest': 3.1.11 + '@api.global/typedrequest': 3.2.5 '@design.estate/dees-comms': 1.0.27 '@push.rocks/lik': 6.2.2 '@push.rocks/smartdelay': 3.0.5 @@ -5540,7 +5548,7 @@ snapshots: '@push.rocks/qenv@6.1.3': dependencies: - '@api.global/typedrequest': 3.1.11 + '@api.global/typedrequest': 3.2.5 '@configvault.io/interfaces': 1.0.17 '@push.rocks/smartfile': 11.2.7 '@push.rocks/smartlog': 3.1.10 diff --git a/test/test.openapi.ts b/test/test.openapi.ts new file mode 100644 index 0000000..c950a28 --- /dev/null +++ b/test/test.openapi.ts @@ -0,0 +1,420 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { + SmartServe, + Route, + Get, + Post, + Delete, + ApiTag, + ApiOperation, + ApiParam, + ApiQuery, + ApiRequestBody, + ApiResponseBody, + OpenApiGenerator, + ControllerRegistry, + type IRequestContext, +} from '../ts/index.js'; + +// Clean up registry before tests +tap.test('setup - clear controller registry', async () => { + ControllerRegistry.clear(); +}); + +// ============================================================================= +// OpenAPI Test Controllers +// ============================================================================= + +const UserSchema = { + type: 'object' as const, + properties: { + id: { type: 'string' as const, format: 'uuid' }, + name: { type: 'string' as const, minLength: 1 }, + email: { type: 'string' as const, format: 'email' }, + }, + required: ['id', 'name', 'email'], +}; + +const CreateUserSchema = { + type: 'object' as const, + properties: { + name: { type: 'string' as const, minLength: 1 }, + email: { type: 'string' as const, format: 'email' }, + }, + required: ['name', 'email'], +}; + +@Route('/api/users') +@ApiTag('Users') +class OpenApiUserController { + @Get('/') + @ApiOperation({ summary: 'List all users' }) + @ApiQuery('limit', { + description: 'Maximum number of results', + schema: { type: 'integer', default: 10, minimum: 1, maximum: 100 }, + }) + @ApiQuery('offset', { + description: 'Offset for pagination', + schema: { type: 'integer', default: 0 }, + }) + @ApiResponseBody(200, { + description: 'List of users', + schema: { type: 'array', items: UserSchema }, + }) + listUsers(ctx: IRequestContext) { + // Query params should be coerced to numbers + const limit = ctx.query.limit as unknown as number; + const offset = ctx.query.offset as unknown as number; + return { + users: [], + pagination: { limit, offset }, + }; + } + + @Get('/:id') + @ApiOperation({ summary: 'Get user by ID' }) + @ApiParam('id', { + description: 'User UUID', + schema: { type: 'string', format: 'uuid' }, + }) + @ApiResponseBody(200, { description: 'User found', schema: UserSchema }) + @ApiResponseBody(404, { description: 'User not found' }) + getUser(ctx: IRequestContext) { + return { + id: ctx.params.id, + name: 'Test User', + email: 'test@example.com', + }; + } + + @Post('/') + @ApiOperation({ summary: 'Create a new user' }) + @ApiRequestBody({ + description: 'User creation payload', + schema: CreateUserSchema, + }) + @ApiResponseBody(201, { description: 'User created', schema: UserSchema }) + @ApiResponseBody(400, { description: 'Validation error' }) + createUser(ctx: IRequestContext<{ name: string; email: string }>) { + return { + id: 'new-uuid', + name: ctx.body.name, + email: ctx.body.email, + }; + } + + @Delete('/:id') + @ApiOperation({ summary: 'Delete user', deprecated: true }) + @ApiParam('id', { description: 'User UUID' }) + @ApiResponseBody(204, { description: 'User deleted' }) + deleteUser(ctx: IRequestContext) { + return null; + } +} + +// ============================================================================= +// OpenAPI Spec Generation Tests +// ============================================================================= + +tap.test('OpenAPI spec should be generated from controllers', async () => { + // Register controller + ControllerRegistry.clear(); + const instance = new OpenApiUserController(); + ControllerRegistry.registerInstance(instance); + + // Generate spec + const generator = new OpenApiGenerator({ + info: { + title: 'Test API', + version: '1.0.0', + description: 'A test API', + }, + servers: [{ url: 'http://localhost:3000' }], + }); + + const spec = generator.generate(); + + // Check basic structure + expect(spec.openapi).toEqual('3.1.0'); + expect(spec.info.title).toEqual('Test API'); + expect(spec.info.version).toEqual('1.0.0'); + + // Check paths + expect(spec.paths['/api/users']).toBeDefined(); + expect(spec.paths['/api/users/{id}']).toBeDefined(); + + // Check GET /api/users operation + const listUsersOp = spec.paths['/api/users'].get; + expect(listUsersOp).toBeDefined(); + expect(listUsersOp!.summary).toEqual('List all users'); + expect(listUsersOp!.tags).toInclude('Users'); + + // Check query parameters + const queryParams = listUsersOp!.parameters?.filter((p: any) => p.in === 'query'); + expect(queryParams?.length).toEqual(2); + + const limitParam = queryParams?.find((p: any) => p.name === 'limit'); + expect(limitParam).toBeDefined(); + expect(limitParam?.schema?.type).toEqual('integer'); + expect(limitParam?.schema?.default).toEqual(10); + + // Check GET /api/users/{id} operation + const getUserOp = spec.paths['/api/users/{id}'].get; + expect(getUserOp).toBeDefined(); + expect(getUserOp!.summary).toEqual('Get user by ID'); + + // Check path parameters + const pathParams = getUserOp!.parameters?.filter((p: any) => p.in === 'path'); + expect(pathParams?.length).toEqual(1); + expect(pathParams?.[0].name).toEqual('id'); + expect(pathParams?.[0].required).toBeTrue(); + + // Check POST /api/users operation + const createUserOp = spec.paths['/api/users'].post; + expect(createUserOp).toBeDefined(); + expect(createUserOp!.requestBody).toBeDefined(); + expect(createUserOp!.requestBody?.content['application/json']).toBeDefined(); + + // Check responses + expect(createUserOp!.responses['201']).toBeDefined(); + expect(createUserOp!.responses['400']).toBeDefined(); + + // Check DELETE operation deprecation + const deleteUserOp = spec.paths['/api/users/{id}'].delete; + expect(deleteUserOp).toBeDefined(); + expect(deleteUserOp!.deprecated).toBeTrue(); +}); + +// ============================================================================= +// Server Integration Tests +// ============================================================================= + +tap.test('SmartServe should serve OpenAPI spec endpoint', async () => { + ControllerRegistry.clear(); + + const server = new SmartServe({ + port: 3500, + openapi: { + info: { + title: 'OpenAPI Test Server', + version: '1.0.0', + }, + }, + }); + + server.register(OpenApiUserController); + await server.start(); + + try { + // Fetch the OpenAPI spec + const response = await fetch('http://localhost:3500/openapi.json'); + expect(response.status).toEqual(200); + expect(response.headers.get('content-type')).toInclude('application/json'); + + const spec = await response.json(); + expect(spec.openapi).toEqual('3.1.0'); + expect(spec.info.title).toEqual('OpenAPI Test Server'); + expect(spec.paths['/api/users']).toBeDefined(); + } finally { + await server.stop(); + } +}); + +tap.test('SmartServe should serve Swagger UI', async () => { + ControllerRegistry.clear(); + + const server = new SmartServe({ + port: 3501, + openapi: { + info: { + title: 'Swagger UI Test', + version: '1.0.0', + }, + }, + }); + + server.register(OpenApiUserController); + await server.start(); + + try { + // Fetch the Swagger UI + const response = await fetch('http://localhost:3501/docs'); + expect(response.status).toEqual(200); + expect(response.headers.get('content-type')).toInclude('text/html'); + + const html = await response.text(); + expect(html).toInclude('swagger-ui'); + expect(html).toInclude('/openapi.json'); + } finally { + await server.stop(); + } +}); + +// ============================================================================= +// Validation Tests +// ============================================================================= + +tap.test('OpenAPI validation should reject invalid request body', async () => { + ControllerRegistry.clear(); + + const server = new SmartServe({ + port: 3502, + openapi: { + info: { + title: 'Validation Test', + version: '1.0.0', + }, + }, + }); + + server.register(OpenApiUserController); + await server.start(); + + try { + // Send invalid body (missing required fields) + const response = await fetch('http://localhost:3502/api/users', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Test' }), // Missing email + }); + + expect(response.status).toEqual(400); + const error = await response.json(); + expect(error.error).toEqual('Validation failed'); + expect(error.details).toBeDefined(); + } finally { + await server.stop(); + } +}); + +tap.test('OpenAPI validation should accept valid request body', async () => { + ControllerRegistry.clear(); + + const server = new SmartServe({ + port: 3503, + openapi: { + info: { + title: 'Validation Test', + version: '1.0.0', + }, + }, + }); + + server.register(OpenApiUserController); + await server.start(); + + try { + // Send valid body + const response = await fetch('http://localhost:3503/api/users', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: 'John Doe', + email: 'john@example.com', + }), + }); + + expect(response.status).toEqual(200); + const data = await response.json(); + expect(data.name).toEqual('John Doe'); + expect(data.email).toEqual('john@example.com'); + } finally { + await server.stop(); + } +}); + +tap.test('OpenAPI should coerce query parameters', async () => { + ControllerRegistry.clear(); + + const server = new SmartServe({ + port: 3504, + openapi: { + info: { + title: 'Coercion Test', + version: '1.0.0', + }, + }, + }); + + server.register(OpenApiUserController); + await server.start(); + + try { + // Query params should be coerced from strings to numbers + const response = await fetch('http://localhost:3504/api/users?limit=25&offset=50'); + expect(response.status).toEqual(200); + + const data = await response.json(); + // The handler returns the limit/offset values + expect(data.pagination.limit).toEqual(25); + expect(data.pagination.offset).toEqual(50); + // They should be numbers, not strings + expect(typeof data.pagination.limit).toEqual('number'); + expect(typeof data.pagination.offset).toEqual('number'); + } finally { + await server.stop(); + } +}); + +tap.test('OpenAPI should apply default values to query parameters', async () => { + ControllerRegistry.clear(); + + const server = new SmartServe({ + port: 3505, + openapi: { + info: { + title: 'Default Values Test', + version: '1.0.0', + }, + }, + }); + + server.register(OpenApiUserController); + await server.start(); + + try { + // No query params - should get defaults + const response = await fetch('http://localhost:3505/api/users'); + expect(response.status).toEqual(200); + + const data = await response.json(); + // Default values from schema + expect(data.pagination.limit).toEqual(10); + expect(data.pagination.offset).toEqual(0); + } finally { + await server.stop(); + } +}); + +tap.test('OpenAPI can be disabled', async () => { + ControllerRegistry.clear(); + + const server = new SmartServe({ + port: 3506, + openapi: { + enabled: false, + info: { + title: 'Disabled Test', + version: '1.0.0', + }, + }, + }); + + server.register(OpenApiUserController); + await server.start(); + + try { + // OpenAPI endpoints should not be registered + const response = await fetch('http://localhost:3506/openapi.json'); + expect(response.status).toEqual(404); + } finally { + await server.stop(); + } +}); + +// Cleanup +tap.test('cleanup - clear controller registry', async () => { + ControllerRegistry.clear(); +}); + +export default tap.start(); diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index c56669a..ad34bb2 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@push.rocks/smartserve', - version: '1.3.0', + version: '1.4.0', description: 'a cross platform server module for Node, Deno and Bun' } diff --git a/ts/core/smartserve.classes.smartserve.ts b/ts/core/smartserve.classes.smartserve.ts index 5162a72..e663d6e 100644 --- a/ts/core/smartserve.classes.smartserve.ts +++ b/ts/core/smartserve.classes.smartserve.ts @@ -28,6 +28,7 @@ import { compressResponse, type ICompressionConfig, } from '../compression/index.js'; +import { createOpenApiHandler, createSwaggerUiHandler } from '../openapi/openapi.handlers.js'; /** * SmartServe - Cross-platform HTTP server @@ -122,6 +123,11 @@ export class SmartServe { throw new ServerAlreadyRunningError(); } + // Register OpenAPI endpoints if configured + if (this.options.openapi && this.options.openapi.enabled !== false) { + this.setupOpenApi(); + } + // Prepare options with internal callbacks if typedRouter is configured let adapterOptions = this.options; @@ -252,6 +258,29 @@ export class SmartServe { } } + /** + * Setup OpenAPI documentation endpoints + */ + private setupOpenApi(): void { + const openapi = this.options.openapi!; + const specPath = openapi.specPath ?? '/openapi.json'; + const docsPath = openapi.docsPath ?? '/docs'; + + // Create generator options + const generatorOptions = { + info: openapi.info, + servers: openapi.servers, + securitySchemes: openapi.securitySchemes, + tags: openapi.tags, + }; + + // Register OpenAPI spec endpoint + ControllerRegistry.addRoute(specPath, 'GET', createOpenApiHandler(generatorOptions)); + + // Register Swagger UI endpoint + ControllerRegistry.addRoute(docsPath, 'GET', createSwaggerUiHandler(specPath, openapi.info.title)); + } + /** * Create the main request handler */ diff --git a/ts/core/smartserve.interfaces.ts b/ts/core/smartserve.interfaces.ts index e57f4c1..6bcb168 100644 --- a/ts/core/smartserve.interfaces.ts +++ b/ts/core/smartserve.interfaces.ts @@ -5,6 +5,7 @@ import type { TypedRouter } from '@api.global/typedrequest'; import type { ICompressionConfig } from '../compression/index.js'; +import type { IOpenApiOptions } from '../openapi/openapi.types.js'; // ============================================================================= // HTTP Types @@ -338,6 +339,8 @@ export interface ISmartServeOptions { onError?: (error: Error, request?: Request) => Response | Promise; /** Compression configuration (enabled by default) */ compression?: ICompressionConfig | boolean; + /** OpenAPI documentation and validation configuration */ + openapi?: IOpenApiOptions; } // ============================================================================= diff --git a/ts/decorators/decorators.metadata.ts b/ts/decorators/decorators.metadata.ts index 982a4d0..7e025a6 100644 --- a/ts/decorators/decorators.metadata.ts +++ b/ts/decorators/decorators.metadata.ts @@ -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}`; } diff --git a/ts/decorators/decorators.registry.ts b/ts/decorators/decorators.registry.ts index 76651af..b53b6f0 100644 --- a/ts/decorators/decorators.registry.ts +++ b/ts/decorators/decorators.registry.ts @@ -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 => { @@ -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) */ diff --git a/ts/decorators/decorators.types.ts b/ts/decorators/decorators.types.ts index f2160b1..eb68ce3 100644 --- a/ts/decorators/decorators.types.ts +++ b/ts/decorators/decorators.types.ts @@ -28,6 +28,8 @@ export interface IControllerMetadata { routes: Map; /** 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; + 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; + /** 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; +} + +/** + * Combined OpenAPI metadata for a route method + */ +export interface IOpenApiRouteMeta { + /** Operation details (summary, description, etc.) */ + operation?: IOpenApiOperationMeta; + /** Path parameters by name */ + params?: Map; + /** Query parameters by name */ + query?: Map; + /** Header parameters by name */ + headers?: Map; + /** Request body schema */ + requestBody?: IOpenApiRequestBodyMeta; + /** Response bodies by status code */ + responses?: Map; + /** Security requirements */ + security?: Array>; +} + +/** + * 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>; } diff --git a/ts/decorators/index.ts b/ts/decorators/index.ts index 772cef3..c04a50b 100644 --- a/ts/decorators/index.ts +++ b/ts/decorators/index.ts @@ -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'; diff --git a/ts/index.ts b/ts/index.ts index 55c881b..b69fd63 100644 --- a/ts/index.ts +++ b/ts/index.ts @@ -15,6 +15,9 @@ export * from './files/index.js'; // Protocol exports (WebDAV, etc.) export * from './protocols/index.js'; +// OpenAPI exports +export * from './openapi/index.js'; + // Utility exports export * from './utils/index.js'; diff --git a/ts/openapi/index.ts b/ts/openapi/index.ts new file mode 100644 index 0000000..dfa06c9 --- /dev/null +++ b/ts/openapi/index.ts @@ -0,0 +1,84 @@ +/** + * OpenAPI Module for SmartServe + * + * Provides: + * - Decorators for API documentation and validation + * - OpenAPI 3.1 specification generation + * - Swagger UI / ReDoc handlers + * - Request validation using JSON Schema + */ + +// Type exports +export type { + // OpenAPI Spec types + IOpenApiSpec, + IOpenApiInfo, + IOpenApiContact, + IOpenApiLicense, + IOpenApiServer, + IOpenApiServerVariable, + IOpenApiPathItem, + IOpenApiOperation, + IOpenApiParameter, + IOpenApiRequestBody, + IOpenApiMediaType, + IOpenApiResponse, + IOpenApiExample, + IOpenApiComponents, + IOpenApiSecurityScheme, + IOpenApiSecuritySchemeApiKey, + IOpenApiSecuritySchemeHttp, + IOpenApiSecuritySchemeOAuth2, + IOpenApiSecuritySchemeOpenIdConnect, + IOpenApiOAuthFlows, + IOpenApiOAuthFlow, + IOpenApiSecurityRequirement, + IOpenApiTag, + IOpenApiExternalDocs, + // Options types + IOpenApiGeneratorOptions, + IOpenApiOptions, +} from './openapi.types.js'; + +export type { + IValidationError, + IValidationResult, +} from './openapi.validator.js'; + +// Decorators +export { + ApiOperation, + ApiParam, + ApiQuery, + ApiHeader, + ApiRequestBody, + ApiResponseBody, + ApiSecurity, + ApiTag, +} from './openapi.decorators.js'; + +// Generator +export { OpenApiGenerator } from './openapi.generator.js'; + +// Handlers +export { + createOpenApiHandler, + createSwaggerUiHandler, + createReDocHandler, +} from './openapi.handlers.js'; + +// Validation +export { + validateSchema, + validateParam, + validateRequest, + createValidationInterceptor, + createValidationErrorResponse, +} from './openapi.validator.js'; + +// Coercion +export { + coerceValue, + coerceQueryParams, + coercePathParams, +} from './openapi.coerce.js'; diff --git a/ts/openapi/openapi.coerce.ts b/ts/openapi/openapi.coerce.ts new file mode 100644 index 0000000..587c6d6 --- /dev/null +++ b/ts/openapi/openapi.coerce.ts @@ -0,0 +1,110 @@ +/** + * Type coercion utilities for query parameters + * Converts string values from query params to proper types based on JSON Schema + */ + +import type { TJsonSchema, IOpenApiParamMeta } from '../decorators/decorators.types.js'; + +/** + * Coerce a single value based on JSON Schema type + */ +export function coerceValue(value: string | undefined, schema: TJsonSchema): unknown { + if (value === undefined || value === '') { + // Return default if available + if (schema.default !== undefined) { + return schema.default; + } + return undefined; + } + + const type = Array.isArray(schema.type) ? schema.type[0] : schema.type; + + switch (type) { + case 'integer': { + const num = parseInt(value, 10); + return isNaN(num) ? value : num; + } + + case 'number': { + const num = parseFloat(value); + return isNaN(num) ? value : num; + } + + case 'boolean': { + if (value === 'true' || value === '1') return true; + if (value === 'false' || value === '0' || value === '') return false; + return value; + } + + case 'array': { + // Handle comma-separated values + const items = value.split(',').map(item => item.trim()); + if (schema.items) { + return items.map(item => coerceValue(item, schema.items as TJsonSchema)); + } + return items; + } + + case 'null': { + if (value === 'null' || value === '') return null; + return value; + } + + case 'object': { + // Attempt to parse JSON + try { + return JSON.parse(value); + } catch { + return value; + } + } + + case 'string': + default: + return value; + } +} + +/** + * Coerce all query parameters based on their schemas + */ +export function coerceQueryParams( + query: Record, + schemas: Map +): Record { + const result: Record = { ...query }; + + for (const [name, meta] of schemas) { + if (meta.schema) { + const rawValue = query[name]; + const coercedValue = coerceValue(rawValue, meta.schema); + + if (coercedValue !== undefined) { + result[name] = coercedValue; + } else if (meta.schema.default !== undefined) { + // Apply default when value is missing + result[name] = meta.schema.default; + } + } + } + + return result; +} + +/** + * Coerce path parameters based on their schemas + */ +export function coercePathParams( + params: Record, + schemas: Map +): Record { + const result: Record = { ...params }; + + for (const [name, meta] of schemas) { + if (meta.schema && params[name] !== undefined) { + result[name] = coerceValue(params[name], meta.schema); + } + } + + return result; +} diff --git a/ts/openapi/openapi.decorators.ts b/ts/openapi/openapi.decorators.ts new file mode 100644 index 0000000..ede6142 --- /dev/null +++ b/ts/openapi/openapi.decorators.ts @@ -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 ( + 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; + }; +} diff --git a/ts/openapi/openapi.generator.ts b/ts/openapi/openapi.generator.ts new file mode 100644 index 0000000..e6305f7 --- /dev/null +++ b/ts/openapi/openapi.generator.ts @@ -0,0 +1,306 @@ +/** + * OpenAPI Specification Generator + * + * Generates OpenAPI 3.1 specification from registered controllers and their metadata + */ + +import { ControllerRegistry } from '../decorators/decorators.registry.js'; +import { combinePaths } from '../decorators/decorators.metadata.js'; +import type { + IControllerMetadata, + IRouteMetadata, + IOpenApiRouteMeta, + IOpenApiControllerMeta, + TJsonSchema, +} from '../decorators/decorators.types.js'; +import type { + IOpenApiSpec, + IOpenApiPathItem, + IOpenApiOperation, + IOpenApiParameter, + IOpenApiRequestBody, + IOpenApiResponse, + IOpenApiGeneratorOptions, + IOpenApiSecurityScheme, + IOpenApiTag, +} from './openapi.types.js'; + +/** + * OpenAPI Specification Generator + */ +export class OpenApiGenerator { + private options: IOpenApiGeneratorOptions; + private schemas: Map = new Map(); + + constructor(options: IOpenApiGeneratorOptions) { + this.options = options; + } + + /** + * Register a reusable schema in components/schemas + */ + addSchema(name: string, schema: TJsonSchema): this { + this.schemas.set(name, schema); + return this; + } + + /** + * Generate the complete OpenAPI specification + */ + generate(): IOpenApiSpec { + const spec: IOpenApiSpec = { + openapi: '3.1.0', + info: this.options.info, + servers: this.options.servers ?? [], + paths: {}, + components: { + schemas: Object.fromEntries(this.schemas), + securitySchemes: this.options.securitySchemes ?? {}, + }, + tags: this.options.tags ?? [], + }; + + if (this.options.externalDocs) { + spec.externalDocs = this.options.externalDocs; + } + + // Collect all unique tags + const collectedTags = new Set(); + + // Get all registered controllers + const controllers = ControllerRegistry.getControllers(); + + for (const { metadata } of controllers) { + this.processController(spec, metadata, collectedTags); + } + + // Add collected tags that aren't already in spec.tags + const existingTagNames = new Set(spec.tags?.map(t => t.name) ?? []); + for (const tag of collectedTags) { + if (!existingTagNames.has(tag)) { + spec.tags!.push({ name: tag }); + } + } + + return spec; + } + + /** + * Generate and return as JSON string + */ + toJSON(pretty = true): string { + return JSON.stringify(this.generate(), null, pretty ? 2 : 0); + } + + /** + * Process a single controller and add its routes to the spec + */ + private processController( + spec: IOpenApiSpec, + metadata: IControllerMetadata, + collectedTags: Set + ): void { + const controllerOpenApi = metadata.openapi ?? {}; + + // Collect controller tags + if (controllerOpenApi.tags) { + for (const tag of controllerOpenApi.tags) { + collectedTags.add(tag); + } + } + + // Process each route + for (const [, route] of metadata.routes) { + const fullPath = combinePaths(metadata.basePath, route.path); + const openApiPath = this.convertPathToOpenApi(fullPath); + + // Initialize path if not exists + if (!spec.paths[openApiPath]) { + spec.paths[openApiPath] = {}; + } + + // Build operation + const operation = this.buildOperation(route, controllerOpenApi, collectedTags); + + // Add to appropriate HTTP method + const method = route.method.toLowerCase(); + + if (method === 'all') { + // 'ALL' applies to all standard methods + const methods = ['get', 'post', 'put', 'delete', 'patch'] as const; + for (const m of methods) { + (spec.paths[openApiPath] as any)[m] = { ...operation }; + } + } else { + (spec.paths[openApiPath] as any)[method] = operation; + } + } + } + + /** + * Build an OpenAPI operation from route metadata + */ + private buildOperation( + route: IRouteMetadata, + controllerMeta: IOpenApiControllerMeta, + collectedTags: Set + ): IOpenApiOperation { + const routeOpenApi = route.openapi ?? {}; + const operationMeta = routeOpenApi.operation ?? {}; + + const operation: IOpenApiOperation = { + summary: operationMeta.summary ?? `${route.method} ${route.path}`, + description: operationMeta.description, + operationId: operationMeta.operationId ?? this.generateOperationId(route), + deprecated: operationMeta.deprecated, + tags: controllerMeta.tags ? [...controllerMeta.tags] : [], + parameters: [], + responses: {}, + }; + + if (operationMeta.externalDocs) { + operation.externalDocs = operationMeta.externalDocs; + } + + // Collect tags + for (const tag of operation.tags ?? []) { + collectedTags.add(tag); + } + + // Add path parameters (auto-detect from path pattern) + const pathParams = this.extractPathParams(route.path); + for (const paramName of pathParams) { + const paramMeta = routeOpenApi.params?.get(paramName); + operation.parameters!.push({ + name: paramName, + in: 'path', + required: true, + description: paramMeta?.description ?? `Path parameter: ${paramName}`, + schema: paramMeta?.schema ?? { type: 'string' }, + example: paramMeta?.example, + deprecated: paramMeta?.deprecated, + }); + } + + // Add query parameters + if (routeOpenApi.query) { + for (const [name, meta] of routeOpenApi.query) { + operation.parameters!.push({ + name, + in: 'query', + required: meta.required ?? false, + description: meta.description, + schema: meta.schema ?? { type: 'string' }, + example: meta.example, + allowEmptyValue: meta.allowEmptyValue, + deprecated: meta.deprecated, + }); + } + } + + // Add header parameters + if (routeOpenApi.headers) { + for (const [name, meta] of routeOpenApi.headers) { + operation.parameters!.push({ + name, + in: 'header', + required: meta.required ?? false, + description: meta.description, + schema: meta.schema ?? { type: 'string' }, + example: meta.example, + deprecated: meta.deprecated, + }); + } + } + + // Remove empty parameters array + if (operation.parameters!.length === 0) { + delete operation.parameters; + } + + // Add request body + if (routeOpenApi.requestBody) { + operation.requestBody = { + description: routeOpenApi.requestBody.description, + required: routeOpenApi.requestBody.required !== false, + content: { + 'application/json': { + schema: routeOpenApi.requestBody.schema, + example: routeOpenApi.requestBody.example, + }, + }, + }; + } + + // Add responses + if (routeOpenApi.responses && routeOpenApi.responses.size > 0) { + for (const [status, responseMeta] of routeOpenApi.responses) { + const response: IOpenApiResponse = { + description: responseMeta.description, + }; + + if (responseMeta.schema) { + response.content = { + 'application/json': { + schema: responseMeta.schema, + example: responseMeta.example, + }, + }; + } + + if (responseMeta.headers) { + response.headers = {}; + for (const [headerName, headerMeta] of Object.entries(responseMeta.headers)) { + response.headers[headerName] = { + description: headerMeta.description, + schema: headerMeta.schema, + } as IOpenApiParameter; + } + } + + operation.responses[status.toString()] = response; + } + } else { + // Default response + operation.responses['200'] = { + description: 'Successful response', + }; + } + + // Add security requirements + const security = routeOpenApi.security ?? controllerMeta.security; + if (security && security.length > 0) { + operation.security = security; + } + + return operation; + } + + /** + * Convert Express-style path to OpenAPI path format + * :id -> {id} + */ + private convertPathToOpenApi(path: string): string { + return path.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, '{$1}'); + } + + /** + * Extract parameter names from path pattern + */ + private extractPathParams(path: string): string[] { + const matches = path.matchAll(/:([a-zA-Z_][a-zA-Z0-9_]*)/g); + return Array.from(matches, m => m[1]); + } + + /** + * Generate a unique operation ID from route metadata + */ + private generateOperationId(route: IRouteMetadata): string { + const methodName = String(route.methodName); + // Convert camelCase to snake_case and sanitize + return methodName + .replace(/[^a-zA-Z0-9]/g, '_') + .replace(/([a-z])([A-Z])/g, '$1_$2') + .toLowerCase(); + } +} diff --git a/ts/openapi/openapi.handlers.ts b/ts/openapi/openapi.handlers.ts new file mode 100644 index 0000000..42a4c3e --- /dev/null +++ b/ts/openapi/openapi.handlers.ts @@ -0,0 +1,138 @@ +/** + * Request handlers for OpenAPI specification and Swagger UI + */ + +import type { IRequestContext } from '../core/smartserve.interfaces.js'; +import { OpenApiGenerator } from './openapi.generator.js'; +import type { IOpenApiGeneratorOptions } from './openapi.types.js'; + +/** + * Create a handler that serves the OpenAPI JSON specification + */ +export function createOpenApiHandler(options: IOpenApiGeneratorOptions) { + let cachedSpec: string | null = null; + + return async (ctx: IRequestContext): Promise => { + // Generate spec on first request (lazy loading) + if (!cachedSpec) { + const generator = new OpenApiGenerator(options); + cachedSpec = generator.toJSON(); + } + + return new Response(cachedSpec, { + status: 200, + headers: { + 'Content-Type': 'application/json', + 'Cache-Control': 'public, max-age=3600', + }, + }); + }; +} + +/** + * Create a handler that serves Swagger UI + * + * Loads Swagger UI from unpkg CDN - no bundled assets needed + */ +export function createSwaggerUiHandler(specUrl: string = '/openapi.json', title: string = 'API Documentation') { + const html = ` + + + + + ${escapeHtml(title)} + + + + +
+ + + +`; + + return async (ctx: IRequestContext): Promise => { + return new Response(html, { + status: 200, + headers: { + 'Content-Type': 'text/html; charset=utf-8', + 'Cache-Control': 'public, max-age=86400', + }, + }); + }; +} + +/** + * Create a handler that serves ReDoc UI (alternative to Swagger UI) + * + * ReDoc provides a clean, responsive documentation layout + */ +export function createReDocHandler(specUrl: string = '/openapi.json', title: string = 'API Documentation') { + const html = ` + + + + + ${escapeHtml(title)} + + + + + + + +`; + + return async (ctx: IRequestContext): Promise => { + return new Response(html, { + status: 200, + headers: { + 'Content-Type': 'text/html; charset=utf-8', + 'Cache-Control': 'public, max-age=86400', + }, + }); + }; +} + +/** + * Escape HTML special characters to prevent XSS + */ +function escapeHtml(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} diff --git a/ts/openapi/openapi.types.ts b/ts/openapi/openapi.types.ts new file mode 100644 index 0000000..3d5b0b4 --- /dev/null +++ b/ts/openapi/openapi.types.ts @@ -0,0 +1,488 @@ +/** + * OpenAPI 3.1 Specification Types + * Based on https://spec.openapis.org/oas/v3.1.0 + */ + +import type { TJsonSchema } from '../decorators/decorators.types.js'; + +// ============================================================================= +// OpenAPI Specification Root +// ============================================================================= + +/** + * OpenAPI 3.1 Specification + */ +export interface IOpenApiSpec { + /** OpenAPI specification version */ + openapi: '3.1.0'; + /** API metadata */ + info: IOpenApiInfo; + /** JSON Schema dialect (OpenAPI 3.1 default) */ + jsonSchemaDialect?: string; + /** Server information */ + servers?: IOpenApiServer[]; + /** Available paths and operations */ + paths: Record; + /** Webhooks */ + webhooks?: Record; + /** Reusable components */ + components?: IOpenApiComponents; + /** Security requirements */ + security?: IOpenApiSecurityRequirement[]; + /** Tags for organization */ + tags?: IOpenApiTag[]; + /** External documentation */ + externalDocs?: IOpenApiExternalDocs; +} + +// ============================================================================= +// Info Object +// ============================================================================= + +/** + * API metadata + */ +export interface IOpenApiInfo { + /** API title */ + title: string; + /** API version */ + version: string; + /** API description (markdown supported) */ + description?: string; + /** Terms of service URL */ + termsOfService?: string; + /** Contact information */ + contact?: IOpenApiContact; + /** License information */ + license?: IOpenApiLicense; + /** Short summary */ + summary?: string; +} + +/** + * Contact information + */ +export interface IOpenApiContact { + name?: string; + url?: string; + email?: string; +} + +/** + * License information + */ +export interface IOpenApiLicense { + name: string; + url?: string; + identifier?: string; +} + +// ============================================================================= +// Server Object +// ============================================================================= + +/** + * Server information + */ +export interface IOpenApiServer { + /** Server URL */ + url: string; + /** Server description */ + description?: string; + /** Server variables */ + variables?: Record; +} + +/** + * Server variable + */ +export interface IOpenApiServerVariable { + default: string; + description?: string; + enum?: string[]; +} + +// ============================================================================= +// Path & Operation Objects +// ============================================================================= + +/** + * Path item with operations + */ +export interface IOpenApiPathItem { + /** Reference to another path item */ + $ref?: string; + /** Summary */ + summary?: string; + /** Description */ + description?: string; + /** GET operation */ + get?: IOpenApiOperation; + /** PUT operation */ + put?: IOpenApiOperation; + /** POST operation */ + post?: IOpenApiOperation; + /** DELETE operation */ + delete?: IOpenApiOperation; + /** OPTIONS operation */ + options?: IOpenApiOperation; + /** HEAD operation */ + head?: IOpenApiOperation; + /** PATCH operation */ + patch?: IOpenApiOperation; + /** TRACE operation */ + trace?: IOpenApiOperation; + /** Servers for this path */ + servers?: IOpenApiServer[]; + /** Parameters for all operations */ + parameters?: IOpenApiParameter[]; +} + +/** + * API operation + */ +export interface IOpenApiOperation { + /** Tags for grouping */ + tags?: string[]; + /** Short summary */ + summary?: string; + /** Detailed description */ + description?: string; + /** External documentation */ + externalDocs?: IOpenApiExternalDocs; + /** Unique operation ID */ + operationId?: string; + /** Operation parameters */ + parameters?: IOpenApiParameter[]; + /** Request body */ + requestBody?: IOpenApiRequestBody; + /** Responses */ + responses: Record; + /** Callbacks */ + callbacks?: Record>; + /** Deprecation flag */ + deprecated?: boolean; + /** Security requirements */ + security?: IOpenApiSecurityRequirement[]; + /** Servers for this operation */ + servers?: IOpenApiServer[]; +} + +// ============================================================================= +// Parameter Object +// ============================================================================= + +/** + * Operation parameter + */ +export interface IOpenApiParameter { + /** Parameter name */ + name: string; + /** Parameter location */ + in: 'query' | 'header' | 'path' | 'cookie'; + /** Description */ + description?: string; + /** Required flag (path params always required) */ + required?: boolean; + /** Deprecation flag */ + deprecated?: boolean; + /** Allow empty value */ + allowEmptyValue?: boolean; + /** Parameter schema */ + schema?: TJsonSchema; + /** Example value */ + example?: unknown; + /** Multiple examples */ + examples?: Record; + /** Serialization style */ + style?: 'matrix' | 'label' | 'form' | 'simple' | 'spaceDelimited' | 'pipeDelimited' | 'deepObject'; + /** Explode arrays/objects */ + explode?: boolean; + /** Allow reserved characters */ + allowReserved?: boolean; + /** Content type mapping */ + content?: Record; +} + +// ============================================================================= +// Request Body Object +// ============================================================================= + +/** + * Request body + */ +export interface IOpenApiRequestBody { + /** Description */ + description?: string; + /** Content by media type */ + content: Record; + /** Required flag */ + required?: boolean; +} + +// ============================================================================= +// Media Type Object +// ============================================================================= + +/** + * Media type content + */ +export interface IOpenApiMediaType { + /** Content schema */ + schema?: TJsonSchema; + /** Example value */ + example?: unknown; + /** Multiple examples */ + examples?: Record; + /** Encoding for multipart */ + encoding?: Record; +} + +/** + * Encoding for multipart content + */ +export interface IOpenApiEncoding { + contentType?: string; + headers?: Record; + style?: string; + explode?: boolean; + allowReserved?: boolean; +} + +// ============================================================================= +// Response Object +// ============================================================================= + +/** + * Operation response + */ +export interface IOpenApiResponse { + /** Response description (required) */ + description: string; + /** Response headers */ + headers?: Record; + /** Response content */ + content?: Record; + /** Links */ + links?: Record; +} + +/** + * Response link + */ +export interface IOpenApiLink { + operationRef?: string; + operationId?: string; + parameters?: Record; + requestBody?: unknown; + description?: string; + server?: IOpenApiServer; +} + +// ============================================================================= +// Example Object +// ============================================================================= + +/** + * Example value + */ +export interface IOpenApiExample { + /** Short summary */ + summary?: string; + /** Description */ + description?: string; + /** Example value */ + value?: unknown; + /** External URL */ + externalValue?: string; +} + +// ============================================================================= +// Components Object +// ============================================================================= + +/** + * Reusable components + */ +export interface IOpenApiComponents { + /** Reusable schemas */ + schemas?: Record; + /** Reusable responses */ + responses?: Record; + /** Reusable parameters */ + parameters?: Record; + /** Reusable examples */ + examples?: Record; + /** Reusable request bodies */ + requestBodies?: Record; + /** Reusable headers */ + headers?: Record; + /** Security schemes */ + securitySchemes?: Record; + /** Reusable links */ + links?: Record; + /** Reusable callbacks */ + callbacks?: Record>; + /** Path items */ + pathItems?: Record; +} + +// ============================================================================= +// Security Scheme Object +// ============================================================================= + +/** + * Security scheme definition + */ +export type IOpenApiSecurityScheme = + | IOpenApiSecuritySchemeApiKey + | IOpenApiSecuritySchemeHttp + | IOpenApiSecuritySchemeOAuth2 + | IOpenApiSecuritySchemeOpenIdConnect + | IOpenApiSecuritySchemeMutualTLS; + +/** + * API key security scheme + */ +export interface IOpenApiSecuritySchemeApiKey { + type: 'apiKey'; + description?: string; + name: string; + in: 'query' | 'header' | 'cookie'; +} + +/** + * HTTP security scheme (bearer, basic, etc.) + */ +export interface IOpenApiSecuritySchemeHttp { + type: 'http'; + description?: string; + scheme: string; + bearerFormat?: string; +} + +/** + * OAuth2 security scheme + */ +export interface IOpenApiSecuritySchemeOAuth2 { + type: 'oauth2'; + description?: string; + flows: IOpenApiOAuthFlows; +} + +/** + * OpenID Connect security scheme + */ +export interface IOpenApiSecuritySchemeOpenIdConnect { + type: 'openIdConnect'; + description?: string; + openIdConnectUrl: string; +} + +/** + * Mutual TLS security scheme + */ +export interface IOpenApiSecuritySchemeMutualTLS { + type: 'mutualTLS'; + description?: string; +} + +/** + * OAuth2 flows + */ +export interface IOpenApiOAuthFlows { + implicit?: IOpenApiOAuthFlow; + password?: IOpenApiOAuthFlow; + clientCredentials?: IOpenApiOAuthFlow; + authorizationCode?: IOpenApiOAuthFlow; +} + +/** + * OAuth2 flow + */ +export interface IOpenApiOAuthFlow { + authorizationUrl?: string; + tokenUrl?: string; + refreshUrl?: string; + scopes: Record; +} + +/** + * Security requirement + */ +export type IOpenApiSecurityRequirement = Record; + +// ============================================================================= +// Tag & External Docs Objects +// ============================================================================= + +/** + * Tag for grouping operations + */ +export interface IOpenApiTag { + /** Tag name */ + name: string; + /** Tag description */ + description?: string; + /** External documentation */ + externalDocs?: IOpenApiExternalDocs; +} + +/** + * External documentation + */ +export interface IOpenApiExternalDocs { + /** Documentation URL */ + url: string; + /** Description */ + description?: string; +} + +// ============================================================================= +// Generator Options +// ============================================================================= + +/** + * Options for OpenAPI spec generation + */ +export interface IOpenApiGeneratorOptions { + /** API info (required) */ + info: IOpenApiInfo; + /** Server URLs */ + servers?: IOpenApiServer[]; + /** Security scheme definitions */ + securitySchemes?: Record; + /** Global tags */ + tags?: IOpenApiTag[]; + /** External documentation */ + externalDocs?: IOpenApiExternalDocs; +} + +/** + * Options for OpenAPI in SmartServe + */ +export interface IOpenApiOptions { + /** Enable OpenAPI (default: true when options provided) */ + enabled?: boolean; + /** Path for OpenAPI JSON spec (default: /openapi.json) */ + specPath?: string; + /** Path for Swagger UI (default: /docs) */ + docsPath?: string; + /** Enable runtime validation (default: true) */ + validation?: boolean; + /** API info */ + info: { + title: string; + version: string; + description?: string; + termsOfService?: string; + contact?: IOpenApiContact; + license?: IOpenApiLicense; + }; + /** Server URLs */ + servers?: IOpenApiServer[]; + /** Security schemes */ + securitySchemes?: Record; + /** Global tags */ + tags?: IOpenApiTag[]; +} diff --git a/ts/openapi/openapi.validator.ts b/ts/openapi/openapi.validator.ts new file mode 100644 index 0000000..1ea84e9 --- /dev/null +++ b/ts/openapi/openapi.validator.ts @@ -0,0 +1,246 @@ +/** + * JSON Schema validation utilities using @cfworker/json-schema + */ + +import { Validator } from '@cfworker/json-schema'; +import type { TJsonSchema, IOpenApiRouteMeta, IOpenApiParamMeta } from '../decorators/decorators.types.js'; +import type { IRequestContext } from '../core/smartserve.interfaces.js'; +import { coerceQueryParams, coercePathParams } from './openapi.coerce.js'; + +/** + * Validation error detail + */ +export interface IValidationError { + /** JSON pointer path to the error location */ + path: string; + /** Human-readable error message */ + message: string; + /** JSON Schema keyword that failed */ + keyword?: string; +} + +/** + * Validation result + */ +export interface IValidationResult { + /** Whether validation passed */ + valid: boolean; + /** Array of validation errors */ + errors: IValidationError[]; +} + +/** + * Validate data against a JSON Schema + */ +export function validateSchema(data: unknown, schema: TJsonSchema): IValidationResult { + const validator = new Validator(schema as any, '2020-12', false); + const result = validator.validate(data); + + return { + valid: result.valid, + errors: result.errors.map(err => ({ + path: err.instanceLocation || '/', + message: err.error, + keyword: err.keyword, + })), + }; +} + +/** + * Validate a single parameter value + */ +export function validateParam( + name: string, + value: unknown, + meta: IOpenApiParamMeta, + location: 'path' | 'query' | 'header' +): IValidationResult { + // Check required + if (meta.required && (value === undefined || value === '')) { + return { + valid: false, + errors: [{ + path: `/${name}`, + message: `${location} parameter "${name}" is required`, + keyword: 'required', + }], + }; + } + + // Skip validation if no value and not required + if (value === undefined || value === '') { + return { valid: true, errors: [] }; + } + + // Validate against schema + if (meta.schema) { + const result = validateSchema(value, meta.schema); + // Prefix errors with parameter name + return { + valid: result.valid, + errors: result.errors.map(err => ({ + ...err, + path: `/${name}${err.path === '/' ? '' : err.path}`, + })), + }; + } + + return { valid: true, errors: [] }; +} + +/** + * Create a 400 Bad Request response for validation errors + */ +export function createValidationErrorResponse( + errors: IValidationError[], + source: 'body' | 'params' | 'query' | 'headers' +): Response { + const body = { + error: 'Validation failed', + source, + details: errors.map(e => ({ + path: e.path, + message: e.message, + })), + }; + + return new Response(JSON.stringify(body), { + status: 400, + headers: { + 'Content-Type': 'application/json', + }, + }); +} + +/** + * Validate the full request based on OpenAPI metadata + * Returns a Response if validation fails, undefined if valid + */ +export function validateRequest( + ctx: IRequestContext, + openapi: IOpenApiRouteMeta +): { + valid: boolean; + response?: Response; + coercedParams?: Record; + coercedQuery?: Record; +} { + const allErrors: Array<{ errors: IValidationError[]; source: string }> = []; + + // Coerce and validate path parameters + let coercedParams: Record = ctx.params; + if (openapi.params && openapi.params.size > 0) { + coercedParams = coercePathParams(ctx.params, openapi.params); + + for (const [name, meta] of openapi.params) { + const result = validateParam(name, coercedParams[name], meta, 'path'); + if (!result.valid) { + allErrors.push({ errors: result.errors, source: 'params' }); + } + } + } + + // Coerce and validate query parameters + let coercedQuery: Record = ctx.query; + if (openapi.query && openapi.query.size > 0) { + coercedQuery = coerceQueryParams(ctx.query, openapi.query); + + for (const [name, meta] of openapi.query) { + const result = validateParam(name, coercedQuery[name], meta, 'query'); + if (!result.valid) { + allErrors.push({ errors: result.errors, source: 'query' }); + } + } + } + + // Validate header parameters + if (openapi.headers && openapi.headers.size > 0) { + for (const [name, meta] of openapi.headers) { + const value = ctx.headers.get(name); + const result = validateParam(name, value, meta, 'header'); + if (!result.valid) { + allErrors.push({ errors: result.errors, source: 'headers' }); + } + } + } + + // Validate request body + if (openapi.requestBody) { + const required = openapi.requestBody.required !== false; + const body = ctx.body; + + if (required && (body === undefined || body === null)) { + allErrors.push({ + errors: [{ + path: '/', + message: 'Request body is required', + keyword: 'required', + }], + source: 'body', + }); + } else if (body !== undefined && body !== null) { + const result = validateSchema(body, openapi.requestBody.schema); + if (!result.valid) { + allErrors.push({ errors: result.errors, source: 'body' }); + } + } + } + + // Return first error source as response + if (allErrors.length > 0) { + const firstError = allErrors[0]; + return { + valid: false, + response: createValidationErrorResponse( + firstError.errors, + firstError.source as 'body' | 'params' | 'query' | 'headers' + ), + }; + } + + return { + valid: true, + coercedParams, + coercedQuery, + }; +} + +/** + * Create a validation request interceptor for a route + */ +export function createValidationInterceptor(openapi: IOpenApiRouteMeta) { + return async (ctx: IRequestContext): Promise => { + const result = validateRequest(ctx, openapi); + + if (!result.valid && result.response) { + return result.response; + } + + // Return modified context with coerced values + if (result.coercedParams || result.coercedQuery) { + // Create a new context with coerced values + // We use Object.defineProperty to update readonly properties + const newCtx = Object.create(ctx); + + if (result.coercedParams) { + Object.defineProperty(newCtx, 'params', { + value: result.coercedParams, + writable: false, + enumerable: true, + }); + } + + if (result.coercedQuery) { + Object.defineProperty(newCtx, 'query', { + value: result.coercedQuery, + writable: false, + enumerable: true, + }); + } + + return newCtx; + } + + return; + }; +}