6 Commits

Author SHA1 Message Date
697b7e92d7 v2.0.0
Some checks failed
Default (tags) / security (push) Successful in 33s
Default (tags) / test (push) Failing after 35s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-12-20 06:55:47 +00:00
4a17bf39c6 BREAKING CHANGE(request): introduce lazy request body parsing via ctx.json()/text()/arrayBuffer()/formData and remove IRequestContext.body 2025-12-20 06:55:47 +00:00
39f0cdf380 v1.4.0
Some checks failed
Default (tags) / security (push) Successful in 38s
Default (tags) / test (push) Failing after 37s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-12-08 17:43:51 +00:00
cc3e335112 feat(openapi): Add OpenAPI module: decorators, spec generator, runtime validation and Swagger UI 2025-12-08 17:43:51 +00:00
15848b9c9c v1.3.0
Some checks failed
Default (tags) / security (push) Successful in 39s
Default (tags) / test (push) Failing after 36s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-12-05 15:38:43 +00:00
fec0770d55 feat(compression): Improve compression implementation (buffering and threshold), add Deno brotli support, add compression tests and dynamic route API 2025-12-05 15:38:43 +00:00
24 changed files with 2956 additions and 149 deletions

View File

@@ -1,5 +1,40 @@
# Changelog
## 2025-12-20 - 2.0.0 - BREAKING CHANGE(request)
introduce lazy request body parsing via ctx.json()/text()/arrayBuffer()/formData and remove IRequestContext.body
- Add RequestContext class implementing lazy, cached body parsing methods: json(), text(), arrayBuffer(), formData.
- Remove IRequestContext.body property — handlers and interceptors must call ctx.json()/ctx.text()/... to access the request body (breaking API change).
- createContext now returns a RequestContext synchronously and no longer pre-parses or coerces the body.
- OpenAPI validator (validateRequest) made async and updated to use ctx.json() for request body validation; createValidationInterceptor now awaits validation.
- Updated README and tests to use async handlers and ctx.json() for body access.
- Updated npmextra.json: replaced gitzone key with @git.zone/cli, replaced npmci with @ship.zone/szci, and added release registries and accessLevel.
## 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
- Buffer response bodies before compressing and perform size threshold check after buffering; return uncompressed responses when below threshold.
- Set Content-Length to the compressed size and use provider.compress to produce full compressed payloads instead of streaming compression from the middleware.
- Add Deno-native brotli support via Deno.compress and use CompressionStream for gzip/deflate; brotli streaming is not attempted in web runtime.
- Pass compression threshold from SmartServe configuration into compressResponse so route/global thresholds are honored.
- Expose ControllerRegistry.addRoute and dynamicRoutes to allow adding dynamic routes without controller classes.
- Add comprehensive compression tests (gzip and brotli) using raw HTTP requests to avoid Node fetch auto-decompression; tests cover large/small responses, @Compress/@NoCompress behavior, and global compression disable.
- Change test runner invocation to use verbose mode.
## 2025-12-05 - 1.2.0 - feat(compression)
Add cross-runtime response compression (Brotli/gzip), per-route decorators, and pre-compressed static file support

View File

@@ -1,5 +1,5 @@
{
"gitzone": {
"@git.zone/cli": {
"projectType": "npm",
"module": {
"githost": "code.foss.global",
@@ -9,10 +9,16 @@
"npmPackagename": "@push.rocks/smartserve",
"license": "MIT",
"projectDomain": "push.rocks"
},
"release": {
"registries": [
"https://verdaccio.lossless.digital",
"https://registry.npmjs.org"
],
"accessLevel": "public"
}
},
"npmci": {
"npmGlobalTools": [],
"npmAccessLevel": "public"
"@ship.zone/szci": {
"npmGlobalTools": []
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@push.rocks/smartserve",
"version": "1.2.0",
"version": "2.0.0",
"private": false,
"description": "a cross platform server module for Node, Deno and Bun",
"exports": {
@@ -10,7 +10,7 @@
"author": "Task Venture Capital GmbH",
"license": "MIT",
"scripts": {
"test": "(tstest test/ --web)",
"test": "(tstest test/ --verbose)",
"build": "(tsbuild --web --allowimplicitany)",
"buildDocs": "(tsdoc)"
},
@@ -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
View File

@@ -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

View File

@@ -44,8 +44,9 @@ class UserController {
}
@Post('/users')
createUser(ctx: IRequestContext<{ name: string; email: string }>) {
return { id: 'new-id', ...ctx.body };
async createUser(ctx: IRequestContext<{ name: string; email: string }>) {
const body = await ctx.json();
return { id: 'new-id', ...body };
}
}
@@ -75,8 +76,9 @@ class ApiController {
}
@Post('/items') // POST /api/v1/items
createItem(ctx: IRequestContext<{ name: string }>) {
return { created: ctx.body.name };
async createItem(ctx: IRequestContext<{ name: string }>) {
const body = await ctx.json();
return { created: body.name };
}
@Put('/items/:id') // PUT /api/v1/items/:id
@@ -345,8 +347,7 @@ Every handler receives a typed request context:
```typescript
interface IRequestContext<TBody = unknown> {
request: Request; // Original Web Standards Request
body: TBody; // Parsed and typed body
request: Request; // Original Web Standards Request (body never consumed by framework)
params: Record<string, string>; // URL path parameters
query: Record<string, string>; // Query string parameters
headers: Headers; // Request headers
@@ -355,9 +356,17 @@ interface IRequestContext<TBody = unknown> {
url: URL; // Full URL object
runtime: 'node' | 'deno' | 'bun'; // Current runtime
state: Record<string, unknown>; // Per-request state bag
// Lazy body parsing methods (cached after first call)
json(): Promise<TBody>; // Parse body as JSON (typed)
text(): Promise<string>; // Parse body as text
arrayBuffer(): Promise<ArrayBuffer>; // Parse body as binary
formData(): Promise<FormData>; // Parse body as form data
}
```
Body parsing is lazy - the request body is only consumed when you call `json()`, `text()`, etc. This allows raw access to `ctx.request` for cases like signature verification.
## Custom Request Handler
Bypass decorator routing entirely:

421
test/test.openapi.ts Normal file
View File

@@ -0,0 +1,421 @@
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' })
async createUser(ctx: IRequestContext<{ name: string; email: string }>) {
const body = await ctx.json();
return {
id: 'new-uuid',
name: body.name,
email: 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();

View File

@@ -7,9 +7,53 @@ import {
Guard,
Transform,
Intercept,
Compress,
NoCompress,
HttpError,
type IRequestContext,
} from '../ts/index.js';
import * as zlib from 'zlib';
import * as http from 'http';
import { promisify } from 'util';
const gunzip = promisify(zlib.gunzip);
const brotliDecompress = promisify(zlib.brotliDecompress);
/**
* Make a raw HTTP request without automatic decompression
* (Node.js fetch auto-decompresses, which breaks our tests)
*/
function rawRequest(options: {
port: number;
path: string;
headers?: Record<string, string>;
}): Promise<{ status: number; headers: http.IncomingHttpHeaders; body: Buffer }> {
return new Promise((resolve, reject) => {
const req = http.request(
{
hostname: 'localhost',
port: options.port,
path: options.path,
method: 'GET',
headers: options.headers,
},
(res) => {
const chunks: Buffer[] = [];
res.on('data', (chunk) => chunks.push(chunk));
res.on('end', () => {
resolve({
status: res.statusCode ?? 200,
headers: res.headers,
body: Buffer.concat(chunks),
});
});
res.on('error', reject);
}
);
req.on('error', reject);
req.end();
});
}
// Test controller
@Route('/api')
@@ -25,8 +69,9 @@ class TestController {
}
@Post('/echo')
echo(ctx: IRequestContext<{ text: string }>) {
return { echo: ctx.body?.text };
async echo(ctx: IRequestContext<{ text: string }>) {
const body = await ctx.json();
return { echo: body?.text };
}
}
@@ -56,6 +101,48 @@ class WrappedController {
}
}
// Controller with compression decorators
@Route('/compression')
class CompressionController {
@Get('/large')
getLargeData() {
// Return data larger than default threshold (1024 bytes)
return {
data: 'x'.repeat(2000),
items: Array.from({ length: 50 }, (_, i) => ({
id: i,
name: `Item ${i}`,
description: `This is a description for item ${i} with some extra text to make it longer`,
})),
};
}
@Get('/small')
getSmallData() {
// Return data smaller than threshold
return { ok: true };
}
@Get('/no-compress')
@NoCompress()
getNoCompress() {
// Should never be compressed
return {
data: 'x'.repeat(2000),
compressed: false,
};
}
@Get('/force-compress')
@Compress({ level: 9 })
getForceCompress() {
return {
data: 'y'.repeat(2000),
compressed: true,
};
}
}
tap.test('SmartServe should create server instance', async () => {
const server = new SmartServe({ port: 3456 });
expect(server).toBeInstanceOf(SmartServe);
@@ -205,4 +292,152 @@ tap.test('HttpError should create proper responses', async () => {
expect(body.details.id).toEqual('123');
});
// ============================================================================
// Compression Tests
// ============================================================================
tap.test('Compression should apply gzip when Accept-Encoding is sent', async () => {
const server = new SmartServe({ port: 3470 });
server.register(CompressionController);
await server.start();
try {
const response = await rawRequest({
port: 3470,
path: '/compression/large',
headers: { 'Accept-Encoding': 'gzip' },
});
expect(response.status).toEqual(200);
expect(response.headers['content-encoding']).toEqual('gzip');
expect(response.headers['vary']).toInclude('Accept-Encoding');
// Decompress and verify content
const decompressed = await gunzip(response.body);
const data = JSON.parse(decompressed.toString());
expect(data.data).toStartWith('xxx');
expect(data.items.length).toEqual(50);
} finally {
await server.stop();
}
});
tap.test('Compression should apply brotli when preferred', async () => {
const server = new SmartServe({ port: 3471 });
server.register(CompressionController);
await server.start();
try {
const response = await rawRequest({
port: 3471,
path: '/compression/large',
headers: { 'Accept-Encoding': 'br, gzip' },
});
expect(response.status).toEqual(200);
expect(response.headers['content-encoding']).toEqual('br');
// Decompress and verify content
const decompressed = await brotliDecompress(response.body);
const data = JSON.parse(decompressed.toString());
expect(data.data).toStartWith('xxx');
} finally {
await server.stop();
}
});
tap.test('Compression should skip small responses', async () => {
const server = new SmartServe({ port: 3472 });
server.register(CompressionController);
await server.start();
try {
const response = await rawRequest({
port: 3472,
path: '/compression/small',
headers: { 'Accept-Encoding': 'gzip, br' },
});
expect(response.status).toEqual(200);
// Small response should NOT be compressed
expect(response.headers['content-encoding']).toBeUndefined();
const data = JSON.parse(response.body.toString());
expect(data.ok).toBeTrue();
} finally {
await server.stop();
}
});
tap.test('Compression should respect @NoCompress decorator', async () => {
const server = new SmartServe({ port: 3473 });
server.register(CompressionController);
await server.start();
try {
const response = await rawRequest({
port: 3473,
path: '/compression/no-compress',
headers: { 'Accept-Encoding': 'gzip, br' },
});
expect(response.status).toEqual(200);
// Should NOT be compressed due to @NoCompress
expect(response.headers['content-encoding']).toBeUndefined();
const data = JSON.parse(response.body.toString());
expect(data.compressed).toBeFalse();
} finally {
await server.stop();
}
});
tap.test('Compression should skip when Accept-Encoding not sent', async () => {
const server = new SmartServe({ port: 3474 });
server.register(CompressionController);
await server.start();
try {
const response = await rawRequest({
port: 3474,
path: '/compression/large',
// No Accept-Encoding header
});
expect(response.status).toEqual(200);
// Should NOT be compressed when client doesn't accept it
expect(response.headers['content-encoding']).toBeUndefined();
const data = JSON.parse(response.body.toString());
expect(data.data).toStartWith('xxx');
} finally {
await server.stop();
}
});
tap.test('Compression can be disabled globally', async () => {
const server = new SmartServe({
port: 3475,
compression: false,
});
server.register(CompressionController);
await server.start();
try {
const response = await rawRequest({
port: 3475,
path: '/compression/large',
headers: { 'Accept-Encoding': 'gzip, br' },
});
expect(response.status).toEqual(200);
// Should NOT be compressed when globally disabled
expect(response.headers['content-encoding']).toBeUndefined();
} finally {
await server.stop();
}
});
export default tap.start();

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@push.rocks/smartserve',
version: '1.2.0',
version: '2.0.0',
description: 'a cross platform server module for Node, Deno and Bun'
}

View File

@@ -68,7 +68,8 @@ export function normalizeCompressionConfig(
// =============================================================================
/**
* Check if response should be compressed
* Check if response should be compressed (preliminary check)
* Note: Final threshold check happens in compressResponse after buffering
*/
export function shouldCompressResponse(
response: Response,
@@ -97,15 +98,6 @@ export function shouldCompressResponse(
return false;
}
// Check size threshold
const contentLength = response.headers.get('Content-Length');
if (contentLength) {
const size = parseInt(contentLength, 10);
if (size < (config.threshold ?? DEFAULT_COMPRESSION_CONFIG.threshold)) {
return false;
}
}
// Check excluded paths
if (config.exclude?.length) {
const url = new URL(request.url);
@@ -166,28 +158,44 @@ export function selectCompressionAlgorithm(
/**
* Compress a Response object
* Uses buffered compression for reliability (streaming can have flushing issues)
*/
export async function compressResponse(
response: Response,
algorithm: TCompressionAlgorithm,
level?: number
level?: number,
threshold?: number
): Promise<Response> {
if (algorithm === 'identity' || !response.body) {
return response;
}
// Read the entire body first (required for proper compression)
const originalBody = new Uint8Array(await response.arrayBuffer());
// Check threshold - if body is too small, return uncompressed
const effectiveThreshold = threshold ?? DEFAULT_COMPRESSION_CONFIG.threshold;
if (originalBody.byteLength < effectiveThreshold) {
// Return original response with the body we read
return new Response(originalBody as unknown as BodyInit, {
status: response.status,
statusText: response.statusText,
headers: response.headers,
});
}
const provider = getCompressionProvider();
// Compress the body
const compressedBody = await provider.compress(originalBody, algorithm, level);
// Clone headers and modify
const headers = new Headers(response.headers);
headers.set('Content-Encoding', algorithm);
headers.set('Vary', appendVaryHeader(headers.get('Vary'), 'Accept-Encoding'));
headers.delete('Content-Length'); // Size changes after compression
headers.set('Content-Length', compressedBody.byteLength.toString());
// Compress the body stream
const compressedBody = provider.compressStream(response.body, algorithm, level);
return new Response(compressedBody, {
return new Response(compressedBody as unknown as BodyInit, {
status: response.status,
statusText: response.statusText,
headers,

View File

@@ -164,29 +164,38 @@ class NodeCompressionProvider implements ICompressionProvider {
// =============================================================================
class WebStandardCompressionProvider implements ICompressionProvider {
private brotliSupported: boolean | null = null;
private _brotliSupported: boolean | null = null;
private _isDeno: boolean;
private checkBrotliSupport(): boolean {
if (this.brotliSupported === null) {
try {
// Try to create a brotli stream - not all runtimes support it
new CompressionStream('deflate');
// Note: CompressionStream doesn't support 'br' in most runtimes yet
this.brotliSupported = false;
} catch {
this.brotliSupported = false;
constructor() {
this._isDeno = typeof (globalThis as any).Deno !== 'undefined';
}
/**
* Check if brotli is supported via Deno.compress API
*/
private hasDenoBrotli(): boolean {
if (this._brotliSupported === null) {
if (this._isDeno) {
// Deno 1.37+ has Deno.compress/decompress with brotli support
const Deno = (globalThis as any).Deno;
this._brotliSupported = typeof Deno?.compress === 'function';
} else {
this._brotliSupported = false;
}
}
return this.brotliSupported;
return this._brotliSupported;
}
getSupportedAlgorithms(): TCompressionAlgorithm[] {
// CompressionStream supports gzip and deflate in most runtimes
// Brotli support is limited
// CompressionStream supports gzip and deflate
const algorithms: TCompressionAlgorithm[] = ['gzip', 'deflate'];
if (this.checkBrotliSupport()) {
// Deno has native brotli via Deno.compress
if (this.hasDenoBrotli()) {
algorithms.unshift('br');
}
return algorithms;
}
@@ -199,21 +208,25 @@ class WebStandardCompressionProvider implements ICompressionProvider {
return data;
}
// Map algorithm to CompressionStream format
// Brotli falls back to gzip if not supported
let format: CompressionFormat;
if (algorithm === 'br') {
format = this.checkBrotliSupport() ? ('br' as CompressionFormat) : 'gzip';
} else {
format = algorithm as CompressionFormat;
// Use Deno's native brotli if available
if (algorithm === 'br' && this.hasDenoBrotli()) {
try {
const Deno = (globalThis as any).Deno;
return await Deno.compress(data, 'br');
} catch {
// Fall through to return original
return data;
}
}
// Use CompressionStream for gzip/deflate
if (algorithm === 'gzip' || algorithm === 'deflate') {
try {
const stream = new CompressionStream(format);
const stream = new CompressionStream(algorithm);
const writer = stream.writable.getWriter();
const reader = stream.readable.getReader();
// Write data and close (cast for type compatibility)
// Write data and close
await writer.write(data as unknown as BufferSource);
await writer.close();
@@ -241,6 +254,10 @@ class WebStandardCompressionProvider implements ICompressionProvider {
}
}
// Unsupported algorithm
return data;
}
compressStream(
stream: ReadableStream<Uint8Array>,
algorithm: TCompressionAlgorithm,
@@ -250,17 +267,14 @@ class WebStandardCompressionProvider implements ICompressionProvider {
return stream;
}
// Map algorithm to CompressionStream format
let format: CompressionFormat;
if (algorithm === 'br') {
format = this.checkBrotliSupport() ? ('br' as CompressionFormat) : 'gzip';
} else {
format = algorithm as CompressionFormat;
// Brotli streaming not supported in Web Standard (Deno.compress is not streaming)
// Only gzip/deflate work with CompressionStream
if (algorithm !== 'gzip' && algorithm !== 'deflate') {
return stream;
}
try {
const compressionStream = new CompressionStream(format);
// Use type assertion for cross-runtime compatibility
const compressionStream = new CompressionStream(algorithm);
return stream.pipeThrough(compressionStream as unknown as TransformStream<Uint8Array, Uint8Array>);
} catch {
// Compression not supported, return original stream

View File

@@ -10,12 +10,106 @@ import type {
IRequestContext,
IConnectionInfo,
THttpMethod,
TRuntime,
IInterceptOptions,
TRequestInterceptor,
TResponseInterceptor,
IWebSocketPeer,
IWebSocketConnectionCallbacks,
} from './smartserve.interfaces.js';
// =============================================================================
// RequestContext - Implements lazy body parsing
// =============================================================================
interface IBodyCache<T> {
done: boolean;
value?: T;
error?: Error;
}
/**
* Request context implementation with lazy body parsing.
* Body is never consumed automatically - use json(), text(), etc. when needed.
*/
class RequestContext<TBody = unknown> implements IRequestContext<TBody> {
private _jsonCache: IBodyCache<TBody> = { done: false };
private _textCache: IBodyCache<string> = { done: false };
private _arrayBufferCache: IBodyCache<ArrayBuffer> = { done: false };
private _formDataCache: IBodyCache<FormData> = { done: false };
constructor(
public readonly request: Request,
public readonly params: Record<string, string>,
public readonly query: Record<string, string>,
public readonly headers: Headers,
public readonly path: string,
public readonly method: THttpMethod,
public readonly url: URL,
public readonly runtime: TRuntime,
public state: Record<string, unknown> = {},
) {}
async json(): Promise<TBody> {
if (this._jsonCache.done) {
if (this._jsonCache.error) throw this._jsonCache.error;
return this._jsonCache.value!;
}
try {
const value = await this.request.json() as TBody;
this._jsonCache = { done: true, value };
return value;
} catch (e) {
this._jsonCache = { done: true, error: e as Error };
throw e;
}
}
async text(): Promise<string> {
if (this._textCache.done) {
if (this._textCache.error) throw this._textCache.error;
return this._textCache.value!;
}
try {
const value = await this.request.text();
this._textCache = { done: true, value };
return value;
} catch (e) {
this._textCache = { done: true, error: e as Error };
throw e;
}
}
async arrayBuffer(): Promise<ArrayBuffer> {
if (this._arrayBufferCache.done) {
if (this._arrayBufferCache.error) throw this._arrayBufferCache.error;
return this._arrayBufferCache.value!;
}
try {
const value = await this.request.arrayBuffer();
this._arrayBufferCache = { done: true, value };
return value;
} catch (e) {
this._arrayBufferCache = { done: true, error: e as Error };
throw e;
}
}
async formData(): Promise<FormData> {
if (this._formDataCache.done) {
if (this._formDataCache.error) throw this._formDataCache.error;
return this._formDataCache.value!;
}
try {
const value = await this.request.formData();
this._formDataCache = { done: true, value };
return value;
} catch (e) {
this._formDataCache = { done: true, error: e as Error };
throw e;
}
}
}
import { HttpError, RouteNotFoundError, ServerAlreadyRunningError, WebSocketConfigError } from './smartserve.errors.js';
import { AdapterFactory, type BaseAdapter, type TRequestHandler } from '../adapters/index.js';
import { ControllerRegistry, type ICompiledRoute, type IRouteCompressionOptions } from '../decorators/index.js';
@@ -28,6 +122,7 @@ import {
compressResponse,
type ICompressionConfig,
} from '../compression/index.js';
import { createOpenApiHandler, createSwaggerUiHandler } from '../openapi/openapi.handlers.js';
/**
* SmartServe - Cross-platform HTTP server
@@ -122,6 +217,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 +352,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
*/
@@ -316,8 +439,8 @@ export class SmartServe {
const { route, params } = match;
try {
// Create request context
const context = await this.createContext(request, url, params, connectionInfo);
// Create request context (body parsing is lazy via ctx.json(), ctx.text(), etc.)
const context = this.createContext(request, url, params, connectionInfo);
// Run interceptors and handler
const response = await this.executeRoute(route, context);
@@ -331,59 +454,32 @@ export class SmartServe {
}
/**
* Create request context from Request object
* Create request context from Request object.
* Body is NOT parsed here - use ctx.json(), ctx.text(), etc. for lazy parsing.
*/
private async createContext(
private createContext(
request: Request,
url: URL,
params: Record<string, string>,
connectionInfo: IConnectionInfo
): Promise<IRequestContext> {
): IRequestContext {
// Parse query params
const query: Record<string, string> = {};
url.searchParams.forEach((value, key) => {
query[key] = value;
});
// Parse body (lazy)
let body: any = undefined;
const contentType = request.headers.get('content-type');
if (request.method !== 'GET' && request.method !== 'HEAD') {
if (contentType?.includes('application/json')) {
try {
body = await request.json();
} catch {
body = null;
}
} else if (contentType?.includes('application/x-www-form-urlencoded')) {
try {
const text = await request.text();
body = Object.fromEntries(new URLSearchParams(text));
} catch {
body = null;
}
} else if (contentType?.includes('text/')) {
try {
body = await request.text();
} catch {
body = null;
}
}
}
return {
return new RequestContext(
request,
body,
params,
query,
headers: request.headers,
path: url.pathname,
method: request.method.toUpperCase() as THttpMethod,
request.headers,
url.pathname,
request.method.toUpperCase() as THttpMethod,
url,
runtime: this.adapter?.name ?? 'node',
state: {},
};
this.adapter?.name ?? 'node',
{},
);
}
/**
@@ -516,7 +612,7 @@ export class SmartServe {
}
// Apply compression
return compressResponse(response, algorithm, effectiveConfig.level);
return compressResponse(response, algorithm, effectiveConfig.level, effectiveConfig.threshold);
}
/**

View File

@@ -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
@@ -23,10 +24,8 @@ export type TRuntime = 'node' | 'deno' | 'bun';
* Wraps Web Standard Request with additional utilities
*/
export interface IRequestContext<TBody = unknown> {
/** Original Web Standards Request */
/** Original Web Standards Request - body stream is never consumed by framework */
readonly request: Request;
/** Parsed request body (typed) */
readonly body: TBody;
/** URL path parameters extracted from route */
readonly params: Record<string, string>;
/** URL query parameters */
@@ -43,6 +42,34 @@ export interface IRequestContext<TBody = unknown> {
readonly runtime: TRuntime;
/** Route-specific state bag for passing data between interceptors */
state: Record<string, unknown>;
/**
* Lazily parse request body as JSON.
* Result is cached after first call.
* @returns Typed body parsed from JSON
*/
json(): Promise<TBody>;
/**
* Lazily parse request body as text.
* Result is cached after first call.
* @returns Body as string
*/
text(): Promise<string>;
/**
* Lazily parse request body as ArrayBuffer.
* Result is cached after first call.
* @returns Body as ArrayBuffer
*/
arrayBuffer(): Promise<ArrayBuffer>;
/**
* Lazily parse request body as FormData.
* Result is cached after first call.
* @returns Body as FormData
*/
formData(): Promise<FormData>;
}
// =============================================================================
@@ -338,6 +365,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;
}
// =============================================================================

View File

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

View File

@@ -2,9 +2,10 @@
* Controller registry - stores all registered controllers
*/
import type { IControllerMetadata, IRegisteredController, ICompiledRoute } from './decorators.types.js';
import type { IControllerMetadata, IRegisteredController, ICompiledRoute, IOpenApiRouteMeta } from './decorators.types.js';
import type { IRequestContext, IInterceptOptions, THttpMethod } from '../core/smartserve.interfaces.js';
import { getControllerMetadata, combinePaths } from './decorators.metadata.js';
import { createValidationInterceptor } from '../openapi/openapi.validator.js';
/**
* Global registry of all controllers
@@ -13,6 +14,7 @@ export class ControllerRegistry {
private static controllers: Map<Function, IControllerMetadata> = new Map();
private static instances: Map<Function, any> = new Map();
private static compiledRoutes: ICompiledRoute[] = [];
private static dynamicRoutes: ICompiledRoute[] = [];
private static routesCompiled = false;
/**
@@ -65,10 +67,33 @@ export class ControllerRegistry {
return result;
}
/**
* Add a dynamic route without needing a controller class
*/
static addRoute(
path: string,
method: THttpMethod,
handler: (ctx: IRequestContext) => Promise<Response | any>
): void {
const { regex, paramNames } = this.pathToRegex(path);
this.dynamicRoutes.push({
pattern: path,
regex,
paramNames,
method,
handler: async (ctx: IRequestContext) => handler(ctx),
interceptors: [],
compression: undefined,
});
this.routesCompiled = false;
}
/**
* Compile all routes for fast matching
*/
static compileRoutes(): ICompiledRoute[] {
static compileRoutes(enableValidation = true): ICompiledRoute[] {
if (this.routesCompiled) {
return this.compiledRoutes;
}
@@ -81,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> => {
@@ -103,10 +137,14 @@ export class ControllerRegistry {
handler,
interceptors,
compression: route.compression,
openapi: route.openapi,
});
}
}
// Add dynamic routes
this.compiledRoutes.push(...this.dynamicRoutes);
// Sort routes by specificity (more specific paths first)
this.compiledRoutes.sort((a, b) => {
// Routes without wildcards come first
@@ -187,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)
*/
@@ -194,6 +244,7 @@ export class ControllerRegistry {
this.controllers.clear();
this.instances.clear();
this.compiledRoutes = [];
this.dynamicRoutes = [];
this.routesCompiled = false;
}
}

View File

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

View File

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

View File

@@ -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
View 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';

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

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

View 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();
}
}

View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}

488
ts/openapi/openapi.types.ts Normal file
View 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[];
}

View File

@@ -0,0 +1,252 @@
/**
* 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 async function validateRequest(
ctx: IRequestContext,
openapi: IOpenApiRouteMeta
): Promise<{
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 (lazy parsing via ctx.json())
if (openapi.requestBody) {
const required = openapi.requestBody.required !== false;
let body: unknown;
try {
body = await ctx.json();
} catch {
body = undefined;
}
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 = await 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;
};
}