feat(openapi): Add OpenAPI module: decorators, spec generator, runtime validation and Swagger UI
This commit is contained in:
14
changelog.md
14
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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
28
pnpm-lock.yaml
generated
28
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
420
test/test.openapi.ts
Normal file
420
test/test.openapi.ts
Normal file
@@ -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();
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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<Response>;
|
||||
/** Compression configuration (enabled by default) */
|
||||
compression?: ICompressionConfig | boolean;
|
||||
/** OpenAPI documentation and validation configuration */
|
||||
openapi?: IOpenApiOptions;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
*/
|
||||
|
||||
@@ -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[]>>;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
84
ts/openapi/index.ts
Normal file
84
ts/openapi/index.ts
Normal file
@@ -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';
|
||||
110
ts/openapi/openapi.coerce.ts
Normal file
110
ts/openapi/openapi.coerce.ts
Normal file
@@ -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<string, string>,
|
||||
schemas: Map<string, IOpenApiParamMeta>
|
||||
): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = { ...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<string, string>,
|
||||
schemas: Map<string, IOpenApiParamMeta>
|
||||
): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = { ...params };
|
||||
|
||||
for (const [name, meta] of schemas) {
|
||||
if (meta.schema && params[name] !== undefined) {
|
||||
result[name] = coerceValue(params[name], meta.schema);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
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;
|
||||
};
|
||||
}
|
||||
306
ts/openapi/openapi.generator.ts
Normal file
306
ts/openapi/openapi.generator.ts
Normal file
@@ -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<string, TJsonSchema> = 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<string>();
|
||||
|
||||
// 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<string>
|
||||
): 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<string>
|
||||
): 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();
|
||||
}
|
||||
}
|
||||
138
ts/openapi/openapi.handlers.ts
Normal file
138
ts/openapi/openapi.handlers.ts
Normal file
@@ -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<Response> => {
|
||||
// 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 = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>${escapeHtml(title)}</title>
|
||||
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css" />
|
||||
<style>
|
||||
html { box-sizing: border-box; overflow-y: scroll; }
|
||||
*, *:before, *:after { box-sizing: inherit; }
|
||||
body { margin: 0; background: #fafafa; }
|
||||
.swagger-ui .topbar { display: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="swagger-ui"></div>
|
||||
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
|
||||
<script>
|
||||
window.onload = () => {
|
||||
window.ui = SwaggerUIBundle({
|
||||
url: "${escapeHtml(specUrl)}",
|
||||
dom_id: '#swagger-ui',
|
||||
deepLinking: true,
|
||||
presets: [
|
||||
SwaggerUIBundle.presets.apis,
|
||||
SwaggerUIBundle.SwaggerUIStandalonePreset
|
||||
],
|
||||
plugins: [
|
||||
SwaggerUIBundle.plugins.DownloadUrl
|
||||
],
|
||||
layout: "BaseLayout",
|
||||
validatorUrl: null,
|
||||
supportedSubmitMethods: ['get', 'post', 'put', 'delete', 'patch', 'head', 'options'],
|
||||
defaultModelsExpandDepth: 1,
|
||||
defaultModelExpandDepth: 1,
|
||||
displayRequestDuration: true,
|
||||
filter: true,
|
||||
showExtensions: true,
|
||||
showCommonExtensions: true,
|
||||
});
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
return async (ctx: IRequestContext): Promise<Response> => {
|
||||
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 = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>${escapeHtml(title)}</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
body { margin: 0; padding: 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<redoc spec-url="${escapeHtml(specUrl)}"></redoc>
|
||||
<script src="https://unpkg.com/redoc@latest/bundles/redoc.standalone.js"></script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
return async (ctx: IRequestContext): Promise<Response> => {
|
||||
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, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
488
ts/openapi/openapi.types.ts
Normal file
488
ts/openapi/openapi.types.ts
Normal file
@@ -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<string, IOpenApiPathItem>;
|
||||
/** Webhooks */
|
||||
webhooks?: Record<string, IOpenApiPathItem>;
|
||||
/** 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<string, IOpenApiServerVariable>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string, IOpenApiResponse>;
|
||||
/** Callbacks */
|
||||
callbacks?: Record<string, Record<string, IOpenApiPathItem>>;
|
||||
/** 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<string, IOpenApiExample>;
|
||||
/** 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<string, IOpenApiMediaType>;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Request Body Object
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Request body
|
||||
*/
|
||||
export interface IOpenApiRequestBody {
|
||||
/** Description */
|
||||
description?: string;
|
||||
/** Content by media type */
|
||||
content: Record<string, IOpenApiMediaType>;
|
||||
/** 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<string, IOpenApiExample>;
|
||||
/** Encoding for multipart */
|
||||
encoding?: Record<string, IOpenApiEncoding>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encoding for multipart content
|
||||
*/
|
||||
export interface IOpenApiEncoding {
|
||||
contentType?: string;
|
||||
headers?: Record<string, IOpenApiParameter>;
|
||||
style?: string;
|
||||
explode?: boolean;
|
||||
allowReserved?: boolean;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Response Object
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Operation response
|
||||
*/
|
||||
export interface IOpenApiResponse {
|
||||
/** Response description (required) */
|
||||
description: string;
|
||||
/** Response headers */
|
||||
headers?: Record<string, IOpenApiParameter>;
|
||||
/** Response content */
|
||||
content?: Record<string, IOpenApiMediaType>;
|
||||
/** Links */
|
||||
links?: Record<string, IOpenApiLink>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response link
|
||||
*/
|
||||
export interface IOpenApiLink {
|
||||
operationRef?: string;
|
||||
operationId?: string;
|
||||
parameters?: Record<string, unknown>;
|
||||
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<string, TJsonSchema>;
|
||||
/** Reusable responses */
|
||||
responses?: Record<string, IOpenApiResponse>;
|
||||
/** Reusable parameters */
|
||||
parameters?: Record<string, IOpenApiParameter>;
|
||||
/** Reusable examples */
|
||||
examples?: Record<string, IOpenApiExample>;
|
||||
/** Reusable request bodies */
|
||||
requestBodies?: Record<string, IOpenApiRequestBody>;
|
||||
/** Reusable headers */
|
||||
headers?: Record<string, IOpenApiParameter>;
|
||||
/** Security schemes */
|
||||
securitySchemes?: Record<string, IOpenApiSecurityScheme>;
|
||||
/** Reusable links */
|
||||
links?: Record<string, IOpenApiLink>;
|
||||
/** Reusable callbacks */
|
||||
callbacks?: Record<string, Record<string, IOpenApiPathItem>>;
|
||||
/** Path items */
|
||||
pathItems?: Record<string, IOpenApiPathItem>;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 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<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Security requirement
|
||||
*/
|
||||
export type IOpenApiSecurityRequirement = Record<string, string[]>;
|
||||
|
||||
// =============================================================================
|
||||
// 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<string, IOpenApiSecurityScheme>;
|
||||
/** 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<string, IOpenApiSecurityScheme>;
|
||||
/** Global tags */
|
||||
tags?: IOpenApiTag[];
|
||||
}
|
||||
246
ts/openapi/openapi.validator.ts
Normal file
246
ts/openapi/openapi.validator.ts
Normal file
@@ -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<string, unknown>;
|
||||
coercedQuery?: Record<string, unknown>;
|
||||
} {
|
||||
const allErrors: Array<{ errors: IValidationError[]; source: string }> = [];
|
||||
|
||||
// Coerce and validate path parameters
|
||||
let coercedParams: Record<string, unknown> = 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<string, unknown> = 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<IRequestContext | Response | void> => {
|
||||
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;
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user