Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 697b7e92d7 | |||
| 4a17bf39c6 | |||
| 39f0cdf380 | |||
| cc3e335112 | |||
| 15848b9c9c | |||
| fec0770d55 | |||
| 59ccff3453 | |||
| 57d7fd6483 | |||
| cef6ce750e | |||
| d341fc270d | |||
| a9972ad0ce | |||
| b3c77eb675 |
59
changelog.md
59
changelog.md
@@ -1,5 +1,64 @@
|
|||||||
# Changelog
|
# 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
|
||||||
|
|
||||||
|
- Introduce a cross-runtime compression provider (Node zlib + Web CompressionStream fallback) with create/get provider APIs (ts/compression/compression.runtime.ts).
|
||||||
|
- Add compression middleware utilities (normalize config, shouldCompressResponse, algorithm selection, streaming/full-body compression) and default configuration (ts/compression/compression.middleware.ts).
|
||||||
|
- Implement Accept-Encoding parsing, encoding selection, and compressibility checks (ts/utils/utils.encoding.ts) and export types/utilities from utils/index.ts.
|
||||||
|
- Add @Compress and @NoCompress decorators and route-level compression metadata support (ts/decorators/decorators.compress.ts, decorators.types.ts, registry updates, and exports).
|
||||||
|
- Integrate compression into SmartServe core: global compression config, applyCompression for custom handlers, WebDAV, static files, and route responses (ts/core/smartserve.classes.smartserve.ts, smartserve.interfaces.ts).
|
||||||
|
- Enhance FileServer to serve pre-compressed variants (.br/.gz) when available, adjust headers/ETag/Length, and avoid using pre-compressed files for range requests (ts/files/file.server.ts).
|
||||||
|
- Expose compression APIs from package entry point and export zlib via plugins for Node provider; update readme.hints.md with configuration examples and notes.
|
||||||
|
|
||||||
|
## 2025-12-03 - 1.1.2 - fix(deps)
|
||||||
|
Bump dependency versions for build and runtime tools
|
||||||
|
|
||||||
|
- Update devDependency @git.zone/tsbundle from ^2.0.5 to ^2.6.3
|
||||||
|
- Update devDependency @types/node from ^20.8.7 to ^24.10.1
|
||||||
|
- Update dependency @api.global/typedrequest from ^3.0.0 to ^3.1.11
|
||||||
|
|
||||||
|
## 2025-12-03 - 1.1.1 - fix(adapters)
|
||||||
|
Attach WebSocket peer to typedRouter request localData and add ws dependency
|
||||||
|
|
||||||
|
- When routing incoming WebSocket messages through TypedRouter (node/deno/bun), the connection peer is now attached to requestObj.localData so typed handlers can access the active connection.
|
||||||
|
- Add runtime dependency on "ws" to enable WebSocket support in the Node adapter (used by dynamic import in the adapter).
|
||||||
|
|
||||||
## 2025-12-02 - 1.1.0 - feat(websocket)
|
## 2025-12-02 - 1.1.0 - feat(websocket)
|
||||||
Add TypedRouter WebSocket integration, connection registry, peer tagging and broadcast APIs
|
Add TypedRouter WebSocket integration, connection registry, peer tagging and broadcast APIs
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"gitzone": {
|
"@git.zone/cli": {
|
||||||
"projectType": "npm",
|
"projectType": "npm",
|
||||||
"module": {
|
"module": {
|
||||||
"githost": "code.foss.global",
|
"githost": "code.foss.global",
|
||||||
@@ -9,10 +9,16 @@
|
|||||||
"npmPackagename": "@push.rocks/smartserve",
|
"npmPackagename": "@push.rocks/smartserve",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"projectDomain": "push.rocks"
|
"projectDomain": "push.rocks"
|
||||||
|
},
|
||||||
|
"release": {
|
||||||
|
"registries": [
|
||||||
|
"https://verdaccio.lossless.digital",
|
||||||
|
"https://registry.npmjs.org"
|
||||||
|
],
|
||||||
|
"accessLevel": "public"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"npmci": {
|
"@ship.zone/szci": {
|
||||||
"npmGlobalTools": [],
|
"npmGlobalTools": []
|
||||||
"npmAccessLevel": "public"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
14
package.json
14
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@push.rocks/smartserve",
|
"name": "@push.rocks/smartserve",
|
||||||
"version": "1.1.0",
|
"version": "2.0.0",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "a cross platform server module for Node, Deno and Bun",
|
"description": "a cross platform server module for Node, Deno and Bun",
|
||||||
"exports": {
|
"exports": {
|
||||||
@@ -10,25 +10,27 @@
|
|||||||
"author": "Task Venture Capital GmbH",
|
"author": "Task Venture Capital GmbH",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "(tstest test/ --web)",
|
"test": "(tstest test/ --verbose)",
|
||||||
"build": "(tsbuild --web --allowimplicitany)",
|
"build": "(tsbuild --web --allowimplicitany)",
|
||||||
"buildDocs": "(tsdoc)"
|
"buildDocs": "(tsdoc)"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@git.zone/tsbuild": "^3.1.2",
|
"@git.zone/tsbuild": "^3.1.2",
|
||||||
"@git.zone/tsbundle": "^2.0.5",
|
"@git.zone/tsbundle": "^2.6.3",
|
||||||
"@git.zone/tsrun": "^2.0.0",
|
"@git.zone/tsrun": "^2.0.0",
|
||||||
"@git.zone/tstest": "^3.1.3",
|
"@git.zone/tstest": "^3.1.3",
|
||||||
"@push.rocks/tapbundle": "^6.0.3",
|
"@push.rocks/tapbundle": "^6.0.3",
|
||||||
"@types/node": "^20.8.7",
|
"@types/node": "^24.10.1",
|
||||||
"@types/ws": "^8.18.1"
|
"@types/ws": "^8.18.1"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@api.global/typedrequest": "^3.0.0",
|
"@api.global/typedrequest": "^3.2.5",
|
||||||
|
"@cfworker/json-schema": "^4.1.1",
|
||||||
"@push.rocks/lik": "^6.2.2",
|
"@push.rocks/lik": "^6.2.2",
|
||||||
"@push.rocks/smartenv": "^6.0.0",
|
"@push.rocks/smartenv": "^6.0.0",
|
||||||
"@push.rocks/smartlog": "^3.1.10",
|
"@push.rocks/smartlog": "^3.1.10",
|
||||||
"@push.rocks/smartpath": "^6.0.0"
|
"@push.rocks/smartpath": "^6.0.0",
|
||||||
|
"ws": "^8.18.0"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.18.1+sha512.77a884a165cbba2d8d1c19e3b4880eee6d2fcabd0d879121e282196b80042351d5eb3ca0935fa599da1dc51265cc68816ad2bddd2a2de5ea9fdf92adbec7cd34",
|
"packageManager": "pnpm@10.18.1+sha512.77a884a165cbba2d8d1c19e3b4880eee6d2fcabd0d879121e282196b80042351d5eb3ca0935fa599da1dc51265cc68816ad2bddd2a2de5ea9fdf92adbec7cd34",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
704
pnpm-lock.yaml
generated
704
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -31,6 +31,12 @@ SmartServe is a cross-platform HTTP server for Node.js, Deno, and Bun using:
|
|||||||
- `ts/utils/utils.mime.ts` - MIME type detection
|
- `ts/utils/utils.mime.ts` - MIME type detection
|
||||||
- `ts/utils/utils.etag.ts` - ETag generation
|
- `ts/utils/utils.etag.ts` - ETag generation
|
||||||
|
|
||||||
|
### Compression
|
||||||
|
- `ts/compression/compression.runtime.ts` - Cross-runtime compression (Node.js zlib, Web CompressionStream)
|
||||||
|
- `ts/compression/compression.middleware.ts` - Compression config and helpers
|
||||||
|
- `ts/utils/utils.encoding.ts` - Accept-Encoding parsing
|
||||||
|
- `ts/decorators/decorators.compress.ts` - @Compress and @NoCompress decorators
|
||||||
|
|
||||||
### Protocols
|
### Protocols
|
||||||
- `ts/protocols/webdav/webdav.handler.ts` - WebDAV RFC 4918 handler
|
- `ts/protocols/webdav/webdav.handler.ts` - WebDAV RFC 4918 handler
|
||||||
- `ts/protocols/webdav/webdav.xml.ts` - XML generation (multistatus, lock responses)
|
- `ts/protocols/webdav/webdav.xml.ts` - XML generation (multistatus, lock responses)
|
||||||
@@ -149,9 +155,81 @@ const connections = server.getWebSocketConnections();
|
|||||||
- Bun adapter stores peer ID/tags in `ws.data` for persistence across events
|
- Bun adapter stores peer ID/tags in `ws.data` for persistence across events
|
||||||
- Internal `_connectionCallbacks` passed to adapters for registry communication
|
- Internal `_connectionCallbacks` passed to adapters for registry communication
|
||||||
|
|
||||||
|
## Compression
|
||||||
|
|
||||||
|
SmartServe supports automatic response compression with Brotli and gzip.
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const server = new SmartServe({
|
||||||
|
port: 3000,
|
||||||
|
|
||||||
|
// Simple: enable with defaults (compression is ON by default)
|
||||||
|
compression: true,
|
||||||
|
|
||||||
|
// Detailed configuration
|
||||||
|
compression: {
|
||||||
|
enabled: true,
|
||||||
|
algorithms: ['br', 'gzip'], // Brotli preferred, gzip fallback
|
||||||
|
threshold: 1024, // Don't compress < 1KB
|
||||||
|
level: 4, // Compression level
|
||||||
|
compressibleTypes: [ // Custom MIME types
|
||||||
|
'text/',
|
||||||
|
'application/json',
|
||||||
|
'application/javascript',
|
||||||
|
],
|
||||||
|
exclude: ['/api/stream/*'], // Skip certain paths
|
||||||
|
},
|
||||||
|
|
||||||
|
// Pre-compressed static files
|
||||||
|
static: {
|
||||||
|
root: './public',
|
||||||
|
precompressed: true, // Serve index.html.br, index.html.gz if available
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Per-Route Control
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
@Controller('/api')
|
||||||
|
class ApiController {
|
||||||
|
@Get('/data')
|
||||||
|
getData() {
|
||||||
|
return { large: 'json' }; // Compressed by default
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('/stream')
|
||||||
|
@NoCompress() // Skip compression for SSE/streaming
|
||||||
|
getStream() {
|
||||||
|
return new Response(eventStream, {
|
||||||
|
headers: { 'Content-Type': 'text/event-stream' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('/heavy')
|
||||||
|
@Compress({ level: 11 }) // Force max brotli compression
|
||||||
|
getHeavy() {
|
||||||
|
return massiveData;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cross-Runtime Support
|
||||||
|
|
||||||
|
| Runtime | Brotli | gzip | Implementation |
|
||||||
|
|---------|--------|------|----------------|
|
||||||
|
| Node.js | ✅ | ✅ | Native zlib module |
|
||||||
|
| Deno | ⚠️ | ✅ | CompressionStream API |
|
||||||
|
| Bun | ⚠️ | ✅ | CompressionStream API |
|
||||||
|
|
||||||
|
Note: Brotli in Deno/Bun falls back to gzip if CompressionStream doesn't support it.
|
||||||
|
|
||||||
## TODO
|
## TODO
|
||||||
|
|
||||||
- [x] WebDAV protocol support (PROPFIND, MKCOL, COPY, MOVE, LOCK, UNLOCK)
|
- [x] WebDAV protocol support (PROPFIND, MKCOL, COPY, MOVE, LOCK, UNLOCK)
|
||||||
- [x] TypedRouter WebSocket integration
|
- [x] TypedRouter WebSocket integration
|
||||||
|
- [x] Brotli/gzip compression with per-route control
|
||||||
- [ ] HTTP/2 support investigation
|
- [ ] HTTP/2 support investigation
|
||||||
- [ ] Performance benchmarks
|
- [ ] Performance benchmarks
|
||||||
|
|||||||
21
readme.md
21
readme.md
@@ -44,8 +44,9 @@ class UserController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('/users')
|
@Post('/users')
|
||||||
createUser(ctx: IRequestContext<{ name: string; email: string }>) {
|
async createUser(ctx: IRequestContext<{ name: string; email: string }>) {
|
||||||
return { id: 'new-id', ...ctx.body };
|
const body = await ctx.json();
|
||||||
|
return { id: 'new-id', ...body };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,8 +76,9 @@ class ApiController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('/items') // POST /api/v1/items
|
@Post('/items') // POST /api/v1/items
|
||||||
createItem(ctx: IRequestContext<{ name: string }>) {
|
async createItem(ctx: IRequestContext<{ name: string }>) {
|
||||||
return { created: ctx.body.name };
|
const body = await ctx.json();
|
||||||
|
return { created: body.name };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put('/items/:id') // PUT /api/v1/items/:id
|
@Put('/items/:id') // PUT /api/v1/items/:id
|
||||||
@@ -345,8 +347,7 @@ Every handler receives a typed request context:
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
interface IRequestContext<TBody = unknown> {
|
interface IRequestContext<TBody = unknown> {
|
||||||
request: Request; // Original Web Standards Request
|
request: Request; // Original Web Standards Request (body never consumed by framework)
|
||||||
body: TBody; // Parsed and typed body
|
|
||||||
params: Record<string, string>; // URL path parameters
|
params: Record<string, string>; // URL path parameters
|
||||||
query: Record<string, string>; // Query string parameters
|
query: Record<string, string>; // Query string parameters
|
||||||
headers: Headers; // Request headers
|
headers: Headers; // Request headers
|
||||||
@@ -355,9 +356,17 @@ interface IRequestContext<TBody = unknown> {
|
|||||||
url: URL; // Full URL object
|
url: URL; // Full URL object
|
||||||
runtime: 'node' | 'deno' | 'bun'; // Current runtime
|
runtime: 'node' | 'deno' | 'bun'; // Current runtime
|
||||||
state: Record<string, unknown>; // Per-request state bag
|
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
|
## Custom Request Handler
|
||||||
|
|
||||||
Bypass decorator routing entirely:
|
Bypass decorator routing entirely:
|
||||||
|
|||||||
421
test/test.openapi.ts
Normal file
421
test/test.openapi.ts
Normal 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();
|
||||||
239
test/test.ts
239
test/test.ts
@@ -7,9 +7,53 @@ import {
|
|||||||
Guard,
|
Guard,
|
||||||
Transform,
|
Transform,
|
||||||
Intercept,
|
Intercept,
|
||||||
|
Compress,
|
||||||
|
NoCompress,
|
||||||
HttpError,
|
HttpError,
|
||||||
type IRequestContext,
|
type IRequestContext,
|
||||||
} from '../ts/index.js';
|
} 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
|
// Test controller
|
||||||
@Route('/api')
|
@Route('/api')
|
||||||
@@ -25,8 +69,9 @@ class TestController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('/echo')
|
@Post('/echo')
|
||||||
echo(ctx: IRequestContext<{ text: string }>) {
|
async echo(ctx: IRequestContext<{ text: string }>) {
|
||||||
return { echo: ctx.body?.text };
|
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 () => {
|
tap.test('SmartServe should create server instance', async () => {
|
||||||
const server = new SmartServe({ port: 3456 });
|
const server = new SmartServe({ port: 3456 });
|
||||||
expect(server).toBeInstanceOf(SmartServe);
|
expect(server).toBeInstanceOf(SmartServe);
|
||||||
@@ -205,4 +292,152 @@ tap.test('HttpError should create proper responses', async () => {
|
|||||||
expect(body.details.id).toEqual('123');
|
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();
|
export default tap.start();
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@push.rocks/smartserve',
|
name: '@push.rocks/smartserve',
|
||||||
version: '1.1.0',
|
version: '2.0.0',
|
||||||
description: 'a cross platform server module for Node, Deno and Bun'
|
description: 'a cross platform server module for Node, Deno and Bun'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -137,6 +137,8 @@ export class BunAdapter extends BaseAdapter {
|
|||||||
if (typedRouter && typeof message === 'string') {
|
if (typedRouter && typeof message === 'string') {
|
||||||
try {
|
try {
|
||||||
const requestObj = JSON.parse(message);
|
const requestObj = JSON.parse(message);
|
||||||
|
// Attach peer to localData so TypedHandlers can access the connection
|
||||||
|
requestObj.localData = { ...requestObj.localData, peer };
|
||||||
const response = await typedRouter.routeAndAddResponse(requestObj);
|
const response = await typedRouter.routeAndAddResponse(requestObj);
|
||||||
if (response) {
|
if (response) {
|
||||||
peer.send(JSON.stringify(response));
|
peer.send(JSON.stringify(response));
|
||||||
|
|||||||
@@ -115,6 +115,8 @@ export class DenoAdapter extends BaseAdapter {
|
|||||||
if (typedRouter && typeof event.data === 'string') {
|
if (typedRouter && typeof event.data === 'string') {
|
||||||
try {
|
try {
|
||||||
const requestObj = JSON.parse(event.data);
|
const requestObj = JSON.parse(event.data);
|
||||||
|
// Attach peer to localData so TypedHandlers can access the connection
|
||||||
|
requestObj.localData = { ...requestObj.localData, peer };
|
||||||
const response = await typedRouter.routeAndAddResponse(requestObj);
|
const response = await typedRouter.routeAndAddResponse(requestObj);
|
||||||
if (response) {
|
if (response) {
|
||||||
peer.send(JSON.stringify(response));
|
peer.send(JSON.stringify(response));
|
||||||
|
|||||||
@@ -283,6 +283,8 @@ export class NodeAdapter extends BaseAdapter {
|
|||||||
try {
|
try {
|
||||||
const messageText = typeof data === 'string' ? data : data.toString('utf8');
|
const messageText = typeof data === 'string' ? data : data.toString('utf8');
|
||||||
const requestObj = JSON.parse(messageText);
|
const requestObj = JSON.parse(messageText);
|
||||||
|
// Attach peer to localData so TypedHandlers can access the connection
|
||||||
|
requestObj.localData = { ...requestObj.localData, peer };
|
||||||
const response = await typedRouter.routeAndAddResponse(requestObj);
|
const response = await typedRouter.routeAndAddResponse(requestObj);
|
||||||
if (response) {
|
if (response) {
|
||||||
peer.send(JSON.stringify(response));
|
peer.send(JSON.stringify(response));
|
||||||
|
|||||||
237
ts/compression/compression.middleware.ts
Normal file
237
ts/compression/compression.middleware.ts
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
/**
|
||||||
|
* Compression middleware for HTTP responses
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { TCompressionAlgorithm } from '../utils/index.js';
|
||||||
|
import { selectEncoding, isCompressible } from '../utils/index.js';
|
||||||
|
import { getCompressionProvider } from './compression.runtime.js';
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Configuration Interface
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compression configuration
|
||||||
|
*/
|
||||||
|
export interface ICompressionConfig {
|
||||||
|
/** Enable compression (default: true) */
|
||||||
|
enabled?: boolean;
|
||||||
|
|
||||||
|
/** Preferred algorithms in order (default: ['br', 'gzip']) */
|
||||||
|
algorithms?: TCompressionAlgorithm[];
|
||||||
|
|
||||||
|
/** Minimum size in bytes to compress (default: 1024) */
|
||||||
|
threshold?: number;
|
||||||
|
|
||||||
|
/** Compression level (1-11 for brotli, 1-9 for gzip, default: 4 for brotli, 6 for gzip) */
|
||||||
|
level?: number;
|
||||||
|
|
||||||
|
/** MIME types to compress (default: text/*, application/json, etc.) */
|
||||||
|
compressibleTypes?: string[];
|
||||||
|
|
||||||
|
/** Skip compression for these paths (glob patterns) */
|
||||||
|
exclude?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default compression configuration
|
||||||
|
*/
|
||||||
|
export const DEFAULT_COMPRESSION_CONFIG: Required<ICompressionConfig> = {
|
||||||
|
enabled: true,
|
||||||
|
algorithms: ['br', 'gzip'],
|
||||||
|
threshold: 1024,
|
||||||
|
level: 4,
|
||||||
|
compressibleTypes: [], // Empty means use defaults from isCompressible
|
||||||
|
exclude: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize compression config from boolean or partial config
|
||||||
|
*/
|
||||||
|
export function normalizeCompressionConfig(
|
||||||
|
config: ICompressionConfig | boolean | undefined
|
||||||
|
): ICompressionConfig {
|
||||||
|
if (config === false) {
|
||||||
|
return { enabled: false };
|
||||||
|
}
|
||||||
|
if (config === true || config === undefined) {
|
||||||
|
return { ...DEFAULT_COMPRESSION_CONFIG };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...DEFAULT_COMPRESSION_CONFIG,
|
||||||
|
...config,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Compression Helpers
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if response should be compressed (preliminary check)
|
||||||
|
* Note: Final threshold check happens in compressResponse after buffering
|
||||||
|
*/
|
||||||
|
export function shouldCompressResponse(
|
||||||
|
response: Response,
|
||||||
|
request: Request,
|
||||||
|
config: ICompressionConfig
|
||||||
|
): boolean {
|
||||||
|
// Disabled
|
||||||
|
if (config.enabled === false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Already compressed
|
||||||
|
if (response.headers.has('Content-Encoding')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No body to compress
|
||||||
|
if (!response.body) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check content type
|
||||||
|
const contentType = response.headers.get('Content-Type');
|
||||||
|
const customTypes = config.compressibleTypes?.length ? config.compressibleTypes : undefined;
|
||||||
|
if (!isCompressible(contentType, customTypes)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check excluded paths
|
||||||
|
if (config.exclude?.length) {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const pathname = url.pathname;
|
||||||
|
for (const pattern of config.exclude) {
|
||||||
|
if (matchGlobPattern(pattern, pathname)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check client accepts compression
|
||||||
|
const acceptEncoding = request.headers.get('Accept-Encoding');
|
||||||
|
if (!acceptEncoding) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple glob pattern matching (supports * and **)
|
||||||
|
*/
|
||||||
|
function matchGlobPattern(pattern: string, path: string): boolean {
|
||||||
|
const regexPattern = pattern
|
||||||
|
.replace(/\*\*/g, '.*')
|
||||||
|
.replace(/\*/g, '[^/]*')
|
||||||
|
.replace(/\?/g, '.');
|
||||||
|
const regex = new RegExp(`^${regexPattern}$`);
|
||||||
|
return regex.test(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select the best compression algorithm based on client and server support
|
||||||
|
*/
|
||||||
|
export function selectCompressionAlgorithm(
|
||||||
|
request: Request,
|
||||||
|
config: ICompressionConfig
|
||||||
|
): TCompressionAlgorithm {
|
||||||
|
const acceptEncoding = request.headers.get('Accept-Encoding');
|
||||||
|
const serverAlgorithms = config.algorithms ?? DEFAULT_COMPRESSION_CONFIG.algorithms;
|
||||||
|
|
||||||
|
// Get runtime-supported algorithms
|
||||||
|
const provider = getCompressionProvider();
|
||||||
|
const runtimeAlgorithms = provider.getSupportedAlgorithms();
|
||||||
|
|
||||||
|
// Filter server config to only include runtime-supported algorithms
|
||||||
|
const supported = serverAlgorithms.filter((alg) =>
|
||||||
|
runtimeAlgorithms.includes(alg)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (supported.length === 0) {
|
||||||
|
return 'identity';
|
||||||
|
}
|
||||||
|
|
||||||
|
return selectEncoding(acceptEncoding, supported);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compress a Response object
|
||||||
|
* Uses buffered compression for reliability (streaming can have flushing issues)
|
||||||
|
*/
|
||||||
|
export async function compressResponse(
|
||||||
|
response: Response,
|
||||||
|
algorithm: TCompressionAlgorithm,
|
||||||
|
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.set('Content-Length', compressedBody.byteLength.toString());
|
||||||
|
|
||||||
|
return new Response(compressedBody as unknown as BodyInit, {
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append value to Vary header
|
||||||
|
*/
|
||||||
|
function appendVaryHeader(existing: string | null, value: string): string {
|
||||||
|
if (!existing) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
const values = existing.split(',').map((v) => v.trim().toLowerCase());
|
||||||
|
if (values.includes(value.toLowerCase())) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
return `${existing}, ${value}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Full-Body Compression (for small responses)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compress entire response body at once (for small/known-size responses)
|
||||||
|
*/
|
||||||
|
export async function compressResponseBody(
|
||||||
|
body: Uint8Array,
|
||||||
|
algorithm: TCompressionAlgorithm,
|
||||||
|
level?: number
|
||||||
|
): Promise<Uint8Array> {
|
||||||
|
if (algorithm === 'identity') {
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
const provider = getCompressionProvider();
|
||||||
|
return provider.compress(body, algorithm, level);
|
||||||
|
}
|
||||||
323
ts/compression/compression.runtime.ts
Normal file
323
ts/compression/compression.runtime.ts
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
/**
|
||||||
|
* Cross-runtime compression abstraction
|
||||||
|
*
|
||||||
|
* Uses:
|
||||||
|
* - Node.js: zlib module (native, full brotli support)
|
||||||
|
* - Deno/Bun: CompressionStream API (Web Standard)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import type { TCompressionAlgorithm } from '../utils/index.js';
|
||||||
|
import type { Transform } from 'stream';
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Compression Provider Interface
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export interface ICompressionProvider {
|
||||||
|
/**
|
||||||
|
* Compress data to Uint8Array
|
||||||
|
*/
|
||||||
|
compress(
|
||||||
|
data: Uint8Array,
|
||||||
|
algorithm: TCompressionAlgorithm,
|
||||||
|
level?: number
|
||||||
|
): Promise<Uint8Array>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compress a ReadableStream
|
||||||
|
*/
|
||||||
|
compressStream(
|
||||||
|
stream: ReadableStream<Uint8Array>,
|
||||||
|
algorithm: TCompressionAlgorithm,
|
||||||
|
level?: number
|
||||||
|
): ReadableStream<Uint8Array>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get supported algorithms for this runtime
|
||||||
|
*/
|
||||||
|
getSupportedAlgorithms(): TCompressionAlgorithm[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Node.js Compression Provider (using zlib)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
class NodeCompressionProvider implements ICompressionProvider {
|
||||||
|
getSupportedAlgorithms(): TCompressionAlgorithm[] {
|
||||||
|
return ['br', 'gzip', 'deflate'];
|
||||||
|
}
|
||||||
|
|
||||||
|
async compress(
|
||||||
|
data: Uint8Array,
|
||||||
|
algorithm: TCompressionAlgorithm,
|
||||||
|
level?: number
|
||||||
|
): Promise<Uint8Array> {
|
||||||
|
if (algorithm === 'identity') {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const buffer = Buffer.from(data);
|
||||||
|
|
||||||
|
const callback = (err: Error | null, result: Buffer) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(new Uint8Array(result));
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (algorithm) {
|
||||||
|
case 'br':
|
||||||
|
plugins.zlib.brotliCompress(
|
||||||
|
buffer,
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
[plugins.zlib.constants.BROTLI_PARAM_QUALITY]: level ?? 4,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
callback
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'gzip':
|
||||||
|
plugins.zlib.gzip(buffer, { level: level ?? 6 }, callback);
|
||||||
|
break;
|
||||||
|
case 'deflate':
|
||||||
|
plugins.zlib.deflate(buffer, { level: level ?? 6 }, callback);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
resolve(data);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
compressStream(
|
||||||
|
stream: ReadableStream<Uint8Array>,
|
||||||
|
algorithm: TCompressionAlgorithm,
|
||||||
|
level?: number
|
||||||
|
): ReadableStream<Uint8Array> {
|
||||||
|
if (algorithm === 'identity') {
|
||||||
|
return stream;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create zlib transform stream
|
||||||
|
let zlibStream: Transform;
|
||||||
|
switch (algorithm) {
|
||||||
|
case 'br':
|
||||||
|
zlibStream = plugins.zlib.createBrotliCompress({
|
||||||
|
params: {
|
||||||
|
[plugins.zlib.constants.BROTLI_PARAM_QUALITY]: level ?? 4,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'gzip':
|
||||||
|
zlibStream = plugins.zlib.createGzip({ level: level ?? 6 });
|
||||||
|
break;
|
||||||
|
case 'deflate':
|
||||||
|
zlibStream = plugins.zlib.createDeflate({ level: level ?? 6 });
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return stream;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert Web ReadableStream to Node stream, compress, and back
|
||||||
|
const reader = stream.getReader();
|
||||||
|
|
||||||
|
return new ReadableStream<Uint8Array>({
|
||||||
|
async start(controller) {
|
||||||
|
// Pipe data through zlib
|
||||||
|
zlibStream.on('data', (chunk: Buffer) => {
|
||||||
|
controller.enqueue(new Uint8Array(chunk));
|
||||||
|
});
|
||||||
|
|
||||||
|
zlibStream.on('end', () => {
|
||||||
|
controller.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
zlibStream.on('error', (err) => {
|
||||||
|
controller.error(err);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Read from source and write to zlib
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) {
|
||||||
|
zlibStream.end();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
zlibStream.write(Buffer.from(value));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
controller.error(err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
cancel() {
|
||||||
|
reader.cancel();
|
||||||
|
zlibStream.destroy();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Web Standard Compression Provider (Deno/Bun/Browser)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
class WebStandardCompressionProvider implements ICompressionProvider {
|
||||||
|
private _brotliSupported: boolean | null = null;
|
||||||
|
private _isDeno: boolean;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
getSupportedAlgorithms(): TCompressionAlgorithm[] {
|
||||||
|
// CompressionStream supports gzip and deflate
|
||||||
|
const algorithms: TCompressionAlgorithm[] = ['gzip', 'deflate'];
|
||||||
|
|
||||||
|
// Deno has native brotli via Deno.compress
|
||||||
|
if (this.hasDenoBrotli()) {
|
||||||
|
algorithms.unshift('br');
|
||||||
|
}
|
||||||
|
|
||||||
|
return algorithms;
|
||||||
|
}
|
||||||
|
|
||||||
|
async compress(
|
||||||
|
data: Uint8Array,
|
||||||
|
algorithm: TCompressionAlgorithm,
|
||||||
|
_level?: number
|
||||||
|
): Promise<Uint8Array> {
|
||||||
|
if (algorithm === 'identity') {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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(algorithm);
|
||||||
|
const writer = stream.writable.getWriter();
|
||||||
|
const reader = stream.readable.getReader();
|
||||||
|
|
||||||
|
// Write data and close
|
||||||
|
await writer.write(data as unknown as BufferSource);
|
||||||
|
await writer.close();
|
||||||
|
|
||||||
|
// Collect compressed chunks
|
||||||
|
const chunks: Uint8Array[] = [];
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
chunks.push(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Concatenate chunks
|
||||||
|
const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
||||||
|
const result = new Uint8Array(totalLength);
|
||||||
|
let offset = 0;
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
result.set(chunk, offset);
|
||||||
|
offset += chunk.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch {
|
||||||
|
// Compression failed, return original
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unsupported algorithm
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
compressStream(
|
||||||
|
stream: ReadableStream<Uint8Array>,
|
||||||
|
algorithm: TCompressionAlgorithm,
|
||||||
|
_level?: number
|
||||||
|
): ReadableStream<Uint8Array> {
|
||||||
|
if (algorithm === 'identity') {
|
||||||
|
return stream;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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(algorithm);
|
||||||
|
return stream.pipeThrough(compressionStream as unknown as TransformStream<Uint8Array, Uint8Array>);
|
||||||
|
} catch {
|
||||||
|
// Compression not supported, return original stream
|
||||||
|
return stream;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Factory & Singleton
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
let compressionProvider: ICompressionProvider | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create appropriate compression provider for the current runtime
|
||||||
|
*/
|
||||||
|
export function createCompressionProvider(): ICompressionProvider {
|
||||||
|
// Check for Node.js (has zlib module)
|
||||||
|
if (typeof process !== 'undefined' && process.versions?.node) {
|
||||||
|
return new NodeCompressionProvider();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for Deno
|
||||||
|
if (typeof (globalThis as any).Deno !== 'undefined') {
|
||||||
|
return new WebStandardCompressionProvider();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for Bun
|
||||||
|
if (typeof (globalThis as any).Bun !== 'undefined') {
|
||||||
|
return new WebStandardCompressionProvider();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to Web Standard
|
||||||
|
return new WebStandardCompressionProvider();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the singleton compression provider
|
||||||
|
*/
|
||||||
|
export function getCompressionProvider(): ICompressionProvider {
|
||||||
|
if (!compressionProvider) {
|
||||||
|
compressionProvider = createCompressionProvider();
|
||||||
|
}
|
||||||
|
return compressionProvider;
|
||||||
|
}
|
||||||
19
ts/compression/index.ts
Normal file
19
ts/compression/index.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* Compression module exports
|
||||||
|
*/
|
||||||
|
|
||||||
|
export {
|
||||||
|
getCompressionProvider,
|
||||||
|
createCompressionProvider,
|
||||||
|
type ICompressionProvider,
|
||||||
|
} from './compression.runtime.js';
|
||||||
|
|
||||||
|
export {
|
||||||
|
shouldCompressResponse,
|
||||||
|
selectCompressionAlgorithm,
|
||||||
|
compressResponse,
|
||||||
|
compressResponseBody,
|
||||||
|
normalizeCompressionConfig,
|
||||||
|
DEFAULT_COMPRESSION_CONFIG,
|
||||||
|
type ICompressionConfig,
|
||||||
|
} from './compression.middleware.js';
|
||||||
@@ -10,17 +10,119 @@ import type {
|
|||||||
IRequestContext,
|
IRequestContext,
|
||||||
IConnectionInfo,
|
IConnectionInfo,
|
||||||
THttpMethod,
|
THttpMethod,
|
||||||
|
TRuntime,
|
||||||
IInterceptOptions,
|
IInterceptOptions,
|
||||||
TRequestInterceptor,
|
TRequestInterceptor,
|
||||||
TResponseInterceptor,
|
TResponseInterceptor,
|
||||||
IWebSocketPeer,
|
IWebSocketPeer,
|
||||||
IWebSocketConnectionCallbacks,
|
IWebSocketConnectionCallbacks,
|
||||||
} from './smartserve.interfaces.js';
|
} 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 { HttpError, RouteNotFoundError, ServerAlreadyRunningError, WebSocketConfigError } from './smartserve.errors.js';
|
||||||
import { AdapterFactory, type BaseAdapter, type TRequestHandler } from '../adapters/index.js';
|
import { AdapterFactory, type BaseAdapter, type TRequestHandler } from '../adapters/index.js';
|
||||||
import { ControllerRegistry, type ICompiledRoute } from '../decorators/index.js';
|
import { ControllerRegistry, type ICompiledRoute, type IRouteCompressionOptions } from '../decorators/index.js';
|
||||||
import { FileServer } from '../files/index.js';
|
import { FileServer } from '../files/index.js';
|
||||||
import { WebDAVHandler } from '../protocols/index.js';
|
import { WebDAVHandler } from '../protocols/index.js';
|
||||||
|
import {
|
||||||
|
normalizeCompressionConfig,
|
||||||
|
shouldCompressResponse,
|
||||||
|
selectCompressionAlgorithm,
|
||||||
|
compressResponse,
|
||||||
|
type ICompressionConfig,
|
||||||
|
} from '../compression/index.js';
|
||||||
|
import { createOpenApiHandler, createSwaggerUiHandler } from '../openapi/openapi.handlers.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SmartServe - Cross-platform HTTP server
|
* SmartServe - Cross-platform HTTP server
|
||||||
@@ -43,6 +145,7 @@ export class SmartServe {
|
|||||||
private customHandler: TRequestHandler | null = null;
|
private customHandler: TRequestHandler | null = null;
|
||||||
private fileServer: FileServer | null = null;
|
private fileServer: FileServer | null = null;
|
||||||
private webdavHandler: WebDAVHandler | null = null;
|
private webdavHandler: WebDAVHandler | null = null;
|
||||||
|
private compressionConfig: ICompressionConfig;
|
||||||
|
|
||||||
/** WebSocket connection registry (only active when typedRouter is set) */
|
/** WebSocket connection registry (only active when typedRouter is set) */
|
||||||
private wsConnections: Map<string, IWebSocketPeer> | null = null;
|
private wsConnections: Map<string, IWebSocketPeer> | null = null;
|
||||||
@@ -64,6 +167,9 @@ export class SmartServe {
|
|||||||
...options,
|
...options,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Initialize compression config (enabled by default)
|
||||||
|
this.compressionConfig = normalizeCompressionConfig(options.compression);
|
||||||
|
|
||||||
// Initialize connection registry only when typedRouter is configured
|
// Initialize connection registry only when typedRouter is configured
|
||||||
if (this.options.websocket?.typedRouter) {
|
if (this.options.websocket?.typedRouter) {
|
||||||
this.wsConnections = new Map();
|
this.wsConnections = new Map();
|
||||||
@@ -111,6 +217,11 @@ export class SmartServe {
|
|||||||
throw new ServerAlreadyRunningError();
|
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
|
// Prepare options with internal callbacks if typedRouter is configured
|
||||||
let adapterOptions = this.options;
|
let adapterOptions = this.options;
|
||||||
|
|
||||||
@@ -241,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
|
* Create the main request handler
|
||||||
*/
|
*/
|
||||||
@@ -248,7 +382,9 @@ export class SmartServe {
|
|||||||
return async (request: Request, connectionInfo: IConnectionInfo): Promise<Response> => {
|
return async (request: Request, connectionInfo: IConnectionInfo): Promise<Response> => {
|
||||||
// Use custom handler if set
|
// Use custom handler if set
|
||||||
if (this.customHandler) {
|
if (this.customHandler) {
|
||||||
return this.customHandler(request, connectionInfo);
|
const response = await this.customHandler(request, connectionInfo);
|
||||||
|
// Apply compression to custom handler responses
|
||||||
|
return this.applyCompression(response, request);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse URL and method
|
// Parse URL and method
|
||||||
@@ -258,7 +394,8 @@ export class SmartServe {
|
|||||||
// Handle WebDAV requests first if handler is configured
|
// Handle WebDAV requests first if handler is configured
|
||||||
if (this.webdavHandler && this.webdavHandler.isWebDAVRequest(request)) {
|
if (this.webdavHandler && this.webdavHandler.isWebDAVRequest(request)) {
|
||||||
try {
|
try {
|
||||||
return await this.webdavHandler.handle(request);
|
const response = await this.webdavHandler.handle(request);
|
||||||
|
return this.applyCompression(response, request);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return this.handleError(error as Error, request);
|
return this.handleError(error as Error, request);
|
||||||
}
|
}
|
||||||
@@ -271,7 +408,8 @@ export class SmartServe {
|
|||||||
// No route found, try WebDAV for GET/PUT/DELETE/HEAD (standard HTTP methods WebDAV also handles)
|
// No route found, try WebDAV for GET/PUT/DELETE/HEAD (standard HTTP methods WebDAV also handles)
|
||||||
if (this.webdavHandler) {
|
if (this.webdavHandler) {
|
||||||
try {
|
try {
|
||||||
return await this.webdavHandler.handle(request);
|
const response = await this.webdavHandler.handle(request);
|
||||||
|
return this.applyCompression(response, request);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return this.handleError(error as Error, request);
|
return this.handleError(error as Error, request);
|
||||||
}
|
}
|
||||||
@@ -282,7 +420,8 @@ export class SmartServe {
|
|||||||
try {
|
try {
|
||||||
const staticResponse = await this.fileServer.serve(request);
|
const staticResponse = await this.fileServer.serve(request);
|
||||||
if (staticResponse) {
|
if (staticResponse) {
|
||||||
return staticResponse;
|
// Apply compression to static file responses
|
||||||
|
return this.applyCompression(staticResponse, request);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return this.handleError(error as Error, request);
|
return this.handleError(error as Error, request);
|
||||||
@@ -300,11 +439,14 @@ export class SmartServe {
|
|||||||
const { route, params } = match;
|
const { route, params } = match;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Create request context
|
// Create request context (body parsing is lazy via ctx.json(), ctx.text(), etc.)
|
||||||
const context = await this.createContext(request, url, params, connectionInfo);
|
const context = this.createContext(request, url, params, connectionInfo);
|
||||||
|
|
||||||
// Run interceptors and handler
|
// Run interceptors and handler
|
||||||
return await this.executeRoute(route, context);
|
const response = await this.executeRoute(route, context);
|
||||||
|
|
||||||
|
// Apply compression with route-specific settings
|
||||||
|
return this.applyCompression(response, request, route.compression);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return this.handleError(error as Error, request);
|
return this.handleError(error as Error, request);
|
||||||
}
|
}
|
||||||
@@ -312,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,
|
request: Request,
|
||||||
url: URL,
|
url: URL,
|
||||||
params: Record<string, string>,
|
params: Record<string, string>,
|
||||||
connectionInfo: IConnectionInfo
|
connectionInfo: IConnectionInfo
|
||||||
): Promise<IRequestContext> {
|
): IRequestContext {
|
||||||
// Parse query params
|
// Parse query params
|
||||||
const query: Record<string, string> = {};
|
const query: Record<string, string> = {};
|
||||||
url.searchParams.forEach((value, key) => {
|
url.searchParams.forEach((value, key) => {
|
||||||
query[key] = value;
|
query[key] = value;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Parse body (lazy)
|
return new RequestContext(
|
||||||
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 {
|
|
||||||
request,
|
request,
|
||||||
body,
|
|
||||||
params,
|
params,
|
||||||
query,
|
query,
|
||||||
headers: request.headers,
|
request.headers,
|
||||||
path: url.pathname,
|
url.pathname,
|
||||||
method: request.method.toUpperCase() as THttpMethod,
|
request.method.toUpperCase() as THttpMethod,
|
||||||
url,
|
url,
|
||||||
runtime: this.adapter?.name ?? 'node',
|
this.adapter?.name ?? 'node',
|
||||||
state: {},
|
{},
|
||||||
};
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -456,6 +571,50 @@ export class SmartServe {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply compression to response if applicable
|
||||||
|
*/
|
||||||
|
private async applyCompression(
|
||||||
|
response: Response,
|
||||||
|
request: Request,
|
||||||
|
routeCompression?: IRouteCompressionOptions
|
||||||
|
): Promise<Response> {
|
||||||
|
// Check route-level override first
|
||||||
|
if (routeCompression?.enabled === false) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build effective config (merge route settings with global)
|
||||||
|
const effectiveConfig: ICompressionConfig = {
|
||||||
|
...this.compressionConfig,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Route-level compression settings override global
|
||||||
|
if (routeCompression?.level !== undefined) {
|
||||||
|
effectiveConfig.level = routeCompression.level;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If route forces compression, ensure it's enabled
|
||||||
|
if (routeCompression?.enabled === true) {
|
||||||
|
effectiveConfig.enabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if compression should be applied
|
||||||
|
if (!shouldCompressResponse(response, request, effectiveConfig)) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select best algorithm
|
||||||
|
const algorithm = selectCompressionAlgorithm(request, effectiveConfig);
|
||||||
|
|
||||||
|
if (algorithm === 'identity') {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply compression
|
||||||
|
return compressResponse(response, algorithm, effectiveConfig.level, effectiveConfig.threshold);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle errors
|
* Handle errors
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { TypedRouter } from '@api.global/typedrequest';
|
import type { TypedRouter } from '@api.global/typedrequest';
|
||||||
|
import type { ICompressionConfig } from '../compression/index.js';
|
||||||
|
import type { IOpenApiOptions } from '../openapi/openapi.types.js';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// HTTP Types
|
// HTTP Types
|
||||||
@@ -22,10 +24,8 @@ export type TRuntime = 'node' | 'deno' | 'bun';
|
|||||||
* Wraps Web Standard Request with additional utilities
|
* Wraps Web Standard Request with additional utilities
|
||||||
*/
|
*/
|
||||||
export interface IRequestContext<TBody = unknown> {
|
export interface IRequestContext<TBody = unknown> {
|
||||||
/** Original Web Standards Request */
|
/** Original Web Standards Request - body stream is never consumed by framework */
|
||||||
readonly request: Request;
|
readonly request: Request;
|
||||||
/** Parsed request body (typed) */
|
|
||||||
readonly body: TBody;
|
|
||||||
/** URL path parameters extracted from route */
|
/** URL path parameters extracted from route */
|
||||||
readonly params: Record<string, string>;
|
readonly params: Record<string, string>;
|
||||||
/** URL query parameters */
|
/** URL query parameters */
|
||||||
@@ -42,6 +42,34 @@ export interface IRequestContext<TBody = unknown> {
|
|||||||
readonly runtime: TRuntime;
|
readonly runtime: TRuntime;
|
||||||
/** Route-specific state bag for passing data between interceptors */
|
/** Route-specific state bag for passing data between interceptors */
|
||||||
state: Record<string, unknown>;
|
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>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -272,6 +300,8 @@ export interface IStaticOptions {
|
|||||||
extensions?: string[];
|
extensions?: string[];
|
||||||
/** Enable directory listing */
|
/** Enable directory listing */
|
||||||
directoryListing?: boolean | IDirectoryListingOptions;
|
directoryListing?: boolean | IDirectoryListingOptions;
|
||||||
|
/** Serve pre-compressed files (.br, .gz) when available */
|
||||||
|
precompressed?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -333,6 +363,10 @@ export interface ISmartServeOptions {
|
|||||||
keepAlive?: IKeepAliveConfig;
|
keepAlive?: IKeepAliveConfig;
|
||||||
/** Global error handler */
|
/** Global error handler */
|
||||||
onError?: (error: Error, request?: Request) => Response | Promise<Response>;
|
onError?: (error: Error, request?: Request) => Response | Promise<Response>;
|
||||||
|
/** Compression configuration (enabled by default) */
|
||||||
|
compression?: ICompressionConfig | boolean;
|
||||||
|
/** OpenAPI documentation and validation configuration */
|
||||||
|
openapi?: IOpenApiOptions;
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|||||||
115
ts/decorators/decorators.compress.ts
Normal file
115
ts/decorators/decorators.compress.ts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
/**
|
||||||
|
* Compression control decorators (@Compress, @NoCompress)
|
||||||
|
*
|
||||||
|
* These decorators allow per-route control over compression:
|
||||||
|
* - @Compress(options?) - Force compression with optional settings
|
||||||
|
* - @NoCompress() - Disable compression for this route (useful for SSE, streaming)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getControllerMetadata } from './decorators.metadata.js';
|
||||||
|
import type { IRouteCompressionOptions } from './decorators.types.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set compression options for a route
|
||||||
|
*/
|
||||||
|
function setRouteCompression(
|
||||||
|
target: any,
|
||||||
|
methodName: string | symbol,
|
||||||
|
options: IRouteCompressionOptions
|
||||||
|
): void {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
route.compression = options;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @Compress decorator - Force compression on a route with optional settings
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* @Controller('/api')
|
||||||
|
* class ApiController {
|
||||||
|
* @Get('/heavy-data')
|
||||||
|
* @Compress({ level: 11 }) // Max brotli compression
|
||||||
|
* getHeavyData() {
|
||||||
|
* return massiveJsonData;
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* @Get('/data')
|
||||||
|
* @Compress() // Use default settings
|
||||||
|
* getData() {
|
||||||
|
* return jsonData;
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function Compress(options?: { level?: number }) {
|
||||||
|
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) {
|
||||||
|
setRouteCompression(this, context.name, {
|
||||||
|
enabled: true,
|
||||||
|
level: options?.level,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return target;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @NoCompress decorator - Disable compression for a route
|
||||||
|
*
|
||||||
|
* Useful for:
|
||||||
|
* - Server-Sent Events (SSE)
|
||||||
|
* - Streaming responses
|
||||||
|
* - Already-compressed content
|
||||||
|
* - Real-time data where latency is critical
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* @Controller('/api')
|
||||||
|
* class EventController {
|
||||||
|
* @Get('/events')
|
||||||
|
* @NoCompress() // SSE should not be compressed
|
||||||
|
* getEvents() {
|
||||||
|
* return new Response(eventStream, {
|
||||||
|
* headers: { 'Content-Type': 'text/event-stream' }
|
||||||
|
* });
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* @Get('/video/:id')
|
||||||
|
* @NoCompress() // Already compressed media
|
||||||
|
* getVideo() {
|
||||||
|
* return videoStream;
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function NoCompress() {
|
||||||
|
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) {
|
||||||
|
setRouteCompression(this, context.name, {
|
||||||
|
enabled: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return target;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -135,7 +135,7 @@ export function combinePaths(basePath: string, routePath: string): string {
|
|||||||
const route = normalizePath(routePath);
|
const route = normalizePath(routePath);
|
||||||
|
|
||||||
if (!base) return route || '/';
|
if (!base) return route || '/';
|
||||||
if (!route) return base;
|
if (!route || route === '/') return base || '/';
|
||||||
|
|
||||||
return `${base}${route}`;
|
return `${base}${route}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,10 @@
|
|||||||
* Controller registry - stores all registered controllers
|
* 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 type { IRequestContext, IInterceptOptions, THttpMethod } from '../core/smartserve.interfaces.js';
|
||||||
import { getControllerMetadata, combinePaths } from './decorators.metadata.js';
|
import { getControllerMetadata, combinePaths } from './decorators.metadata.js';
|
||||||
|
import { createValidationInterceptor } from '../openapi/openapi.validator.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Global registry of all controllers
|
* Global registry of all controllers
|
||||||
@@ -13,6 +14,7 @@ export class ControllerRegistry {
|
|||||||
private static controllers: Map<Function, IControllerMetadata> = new Map();
|
private static controllers: Map<Function, IControllerMetadata> = new Map();
|
||||||
private static instances: Map<Function, any> = new Map();
|
private static instances: Map<Function, any> = new Map();
|
||||||
private static compiledRoutes: ICompiledRoute[] = [];
|
private static compiledRoutes: ICompiledRoute[] = [];
|
||||||
|
private static dynamicRoutes: ICompiledRoute[] = [];
|
||||||
private static routesCompiled = false;
|
private static routesCompiled = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -65,10 +67,33 @@ export class ControllerRegistry {
|
|||||||
return result;
|
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
|
* Compile all routes for fast matching
|
||||||
*/
|
*/
|
||||||
static compileRoutes(): ICompiledRoute[] {
|
static compileRoutes(enableValidation = true): ICompiledRoute[] {
|
||||||
if (this.routesCompiled) {
|
if (this.routesCompiled) {
|
||||||
return this.compiledRoutes;
|
return this.compiledRoutes;
|
||||||
}
|
}
|
||||||
@@ -81,10 +106,19 @@ export class ControllerRegistry {
|
|||||||
const { regex, paramNames } = this.pathToRegex(fullPath);
|
const { regex, paramNames } = this.pathToRegex(fullPath);
|
||||||
|
|
||||||
// Combine class and method interceptors
|
// Combine class and method interceptors
|
||||||
const interceptors: IInterceptOptions[] = [
|
const interceptors: IInterceptOptions[] = [];
|
||||||
...metadata.classInterceptors,
|
|
||||||
...route.interceptors,
|
// 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
|
// Create bound handler
|
||||||
const handler = async (ctx: IRequestContext): Promise<any> => {
|
const handler = async (ctx: IRequestContext): Promise<any> => {
|
||||||
@@ -102,10 +136,15 @@ export class ControllerRegistry {
|
|||||||
method: route.method,
|
method: route.method,
|
||||||
handler,
|
handler,
|
||||||
interceptors,
|
interceptors,
|
||||||
|
compression: route.compression,
|
||||||
|
openapi: route.openapi,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add dynamic routes
|
||||||
|
this.compiledRoutes.push(...this.dynamicRoutes);
|
||||||
|
|
||||||
// Sort routes by specificity (more specific paths first)
|
// Sort routes by specificity (more specific paths first)
|
||||||
this.compiledRoutes.sort((a, b) => {
|
this.compiledRoutes.sort((a, b) => {
|
||||||
// Routes without wildcards come first
|
// Routes without wildcards come first
|
||||||
@@ -186,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)
|
* Clear all registered controllers (useful for testing)
|
||||||
*/
|
*/
|
||||||
@@ -193,6 +244,7 @@ export class ControllerRegistry {
|
|||||||
this.controllers.clear();
|
this.controllers.clear();
|
||||||
this.instances.clear();
|
this.instances.clear();
|
||||||
this.compiledRoutes = [];
|
this.compiledRoutes = [];
|
||||||
|
this.dynamicRoutes = [];
|
||||||
this.routesCompiled = false;
|
this.routesCompiled = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type {
|
|||||||
IMethodOptions,
|
IMethodOptions,
|
||||||
IRouteOptions,
|
IRouteOptions,
|
||||||
} from '../core/smartserve.interfaces.js';
|
} from '../core/smartserve.interfaces.js';
|
||||||
|
import type { ICompressionConfig } from '../compression/index.js';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Metadata Types
|
// Metadata Types
|
||||||
@@ -27,6 +28,18 @@ export interface IControllerMetadata {
|
|||||||
routes: Map<string | symbol, IRouteMetadata>;
|
routes: Map<string | symbol, IRouteMetadata>;
|
||||||
/** Controller class reference */
|
/** Controller class reference */
|
||||||
target?: new (...args: any[]) => any;
|
target?: new (...args: any[]) => any;
|
||||||
|
/** OpenAPI metadata for controller */
|
||||||
|
openapi?: IOpenApiControllerMeta;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Route compression options
|
||||||
|
*/
|
||||||
|
export interface IRouteCompressionOptions {
|
||||||
|
/** Whether compression is enabled for this route (undefined = use default) */
|
||||||
|
enabled?: boolean;
|
||||||
|
/** Override compression level */
|
||||||
|
level?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -45,6 +58,10 @@ export interface IRouteMetadata {
|
|||||||
methodName: string | symbol;
|
methodName: string | symbol;
|
||||||
/** Handler function reference */
|
/** Handler function reference */
|
||||||
handler?: Function;
|
handler?: Function;
|
||||||
|
/** Route-specific compression settings */
|
||||||
|
compression?: IRouteCompressionOptions;
|
||||||
|
/** OpenAPI metadata for this route */
|
||||||
|
openapi?: IOpenApiRouteMeta;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -73,4 +90,140 @@ export interface ICompiledRoute {
|
|||||||
handler: (ctx: IRequestContext) => Promise<any>;
|
handler: (ctx: IRequestContext) => Promise<any>;
|
||||||
/** Combined interceptors (class + method) */
|
/** Combined interceptors (class + method) */
|
||||||
interceptors: IInterceptOptions[];
|
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[]>>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,17 @@
|
|||||||
export type {
|
export type {
|
||||||
IControllerMetadata,
|
IControllerMetadata,
|
||||||
IRouteMetadata,
|
IRouteMetadata,
|
||||||
|
IRouteCompressionOptions,
|
||||||
IRegisteredController,
|
IRegisteredController,
|
||||||
ICompiledRoute,
|
ICompiledRoute,
|
||||||
|
// OpenAPI types
|
||||||
|
TJsonSchema,
|
||||||
|
IOpenApiOperationMeta,
|
||||||
|
IOpenApiParamMeta,
|
||||||
|
IOpenApiRequestBodyMeta,
|
||||||
|
IOpenApiResponseBodyMeta,
|
||||||
|
IOpenApiRouteMeta,
|
||||||
|
IOpenApiControllerMeta,
|
||||||
} from './decorators.types.js';
|
} from './decorators.types.js';
|
||||||
|
|
||||||
// Route decorator
|
// Route decorator
|
||||||
@@ -35,6 +44,9 @@ export {
|
|||||||
addTimestamp,
|
addTimestamp,
|
||||||
} from './decorators.interceptors.js';
|
} from './decorators.interceptors.js';
|
||||||
|
|
||||||
|
// Compression decorators
|
||||||
|
export { Compress, NoCompress } from './decorators.compress.js';
|
||||||
|
|
||||||
// Registry
|
// Registry
|
||||||
export { ControllerRegistry } from './decorators.registry.js';
|
export { ControllerRegistry } from './decorators.registry.js';
|
||||||
|
|
||||||
@@ -45,3 +57,15 @@ export {
|
|||||||
normalizePath,
|
normalizePath,
|
||||||
combinePaths,
|
combinePaths,
|
||||||
} from './decorators.metadata.js';
|
} from './decorators.metadata.js';
|
||||||
|
|
||||||
|
// OpenAPI decorators
|
||||||
|
export {
|
||||||
|
ApiOperation,
|
||||||
|
ApiParam,
|
||||||
|
ApiQuery,
|
||||||
|
ApiHeader,
|
||||||
|
ApiRequestBody,
|
||||||
|
ApiResponseBody,
|
||||||
|
ApiSecurity,
|
||||||
|
ApiTag,
|
||||||
|
} from '../openapi/openapi.decorators.js';
|
||||||
|
|||||||
@@ -10,6 +10,16 @@ import type {
|
|||||||
} from '../core/smartserve.interfaces.js';
|
} from '../core/smartserve.interfaces.js';
|
||||||
import { getMimeType } from '../utils/utils.mime.js';
|
import { getMimeType } from '../utils/utils.mime.js';
|
||||||
import { generateETag } from '../utils/utils.etag.js';
|
import { generateETag } from '../utils/utils.etag.js';
|
||||||
|
import { parseAcceptEncoding } from '../utils/utils.encoding.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pre-compressed file variant info
|
||||||
|
*/
|
||||||
|
interface IPrecompressedVariant {
|
||||||
|
path: string;
|
||||||
|
stat: plugins.fs.Stats;
|
||||||
|
encoding: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Static file server
|
* Static file server
|
||||||
@@ -112,22 +122,43 @@ export class FileServer {
|
|||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
const headers = new Headers();
|
const headers = new Headers();
|
||||||
|
|
||||||
// Content-Type
|
// Check for pre-compressed variants
|
||||||
|
let actualFilePath = filePath;
|
||||||
|
let actualStat = stat;
|
||||||
|
let contentEncoding: string | undefined;
|
||||||
|
|
||||||
|
if (this.options.precompressed) {
|
||||||
|
const variant = await this.findPrecompressedVariant(filePath, request);
|
||||||
|
if (variant) {
|
||||||
|
actualFilePath = variant.path;
|
||||||
|
actualStat = variant.stat;
|
||||||
|
contentEncoding = variant.encoding;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Content-Type (always use original file's MIME type)
|
||||||
const mimeType = getMimeType(filePath);
|
const mimeType = getMimeType(filePath);
|
||||||
headers.set('Content-Type', mimeType);
|
headers.set('Content-Type', mimeType);
|
||||||
|
|
||||||
// Content-Length
|
// Content-Encoding (if serving pre-compressed)
|
||||||
headers.set('Content-Length', stat.size.toString());
|
if (contentEncoding) {
|
||||||
|
headers.set('Content-Encoding', contentEncoding);
|
||||||
|
headers.set('Vary', 'Accept-Encoding');
|
||||||
|
}
|
||||||
|
|
||||||
// Last-Modified
|
// Content-Length (use actual file size, which may differ for compressed)
|
||||||
|
headers.set('Content-Length', actualStat.size.toString());
|
||||||
|
|
||||||
|
// Last-Modified (use original file's time for consistency)
|
||||||
if (this.options.lastModified) {
|
if (this.options.lastModified) {
|
||||||
headers.set('Last-Modified', stat.mtime.toUTCString());
|
headers.set('Last-Modified', stat.mtime.toUTCString());
|
||||||
}
|
}
|
||||||
|
|
||||||
// ETag
|
// ETag (include encoding in ETag if compressed)
|
||||||
let etag: string | undefined;
|
let etag: string | undefined;
|
||||||
if (this.options.etag) {
|
if (this.options.etag) {
|
||||||
etag = generateETag(stat);
|
const baseEtag = generateETag(stat);
|
||||||
|
etag = contentEncoding ? `${baseEtag}-${contentEncoding}` : baseEtag;
|
||||||
headers.set('ETag', etag);
|
headers.set('ETag', etag);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,9 +184,9 @@ export class FileServer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle Range requests
|
// Handle Range requests (don't use pre-compressed for range requests)
|
||||||
const rangeHeader = request.headers.get('Range');
|
const rangeHeader = request.headers.get('Range');
|
||||||
if (rangeHeader) {
|
if (rangeHeader && !contentEncoding) {
|
||||||
return this.servePartial(filePath, stat, rangeHeader, headers);
|
return this.servePartial(filePath, stat, rangeHeader, headers);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,8 +195,8 @@ export class FileServer {
|
|||||||
return new Response(null, { status: 200, headers });
|
return new Response(null, { status: 200, headers });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stream the file
|
// Stream the file (use actualFilePath for pre-compressed)
|
||||||
const stream = plugins.fs.createReadStream(filePath);
|
const stream = plugins.fs.createReadStream(actualFilePath);
|
||||||
const readableStream = this.nodeStreamToWebStream(stream);
|
const readableStream = this.nodeStreamToWebStream(stream);
|
||||||
|
|
||||||
return new Response(readableStream, { status: 200, headers });
|
return new Response(readableStream, { status: 200, headers });
|
||||||
@@ -361,6 +392,46 @@ export class FileServer {
|
|||||||
</html>`;
|
</html>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find pre-compressed variant of a file if it exists
|
||||||
|
* Checks for .br and .gz variants based on client Accept-Encoding
|
||||||
|
*/
|
||||||
|
private async findPrecompressedVariant(
|
||||||
|
filePath: string,
|
||||||
|
request: Request
|
||||||
|
): Promise<IPrecompressedVariant | null> {
|
||||||
|
const acceptEncoding = request.headers.get('Accept-Encoding');
|
||||||
|
const preferences = parseAcceptEncoding(acceptEncoding);
|
||||||
|
|
||||||
|
// Supported pre-compressed variants in preference order
|
||||||
|
const variants: Array<{ encoding: string; extension: string }> = [
|
||||||
|
{ encoding: 'br', extension: '.br' },
|
||||||
|
{ encoding: 'gzip', extension: '.gz' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Check variants in client preference order
|
||||||
|
for (const pref of preferences) {
|
||||||
|
const variant = variants.find((v) => v.encoding === pref.encoding);
|
||||||
|
if (!variant) continue;
|
||||||
|
|
||||||
|
const variantPath = filePath + variant.extension;
|
||||||
|
try {
|
||||||
|
const variantStat = await plugins.fs.promises.stat(variantPath);
|
||||||
|
if (variantStat.isFile()) {
|
||||||
|
return {
|
||||||
|
path: variantPath,
|
||||||
|
stat: variantStat,
|
||||||
|
encoding: variant.encoding,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Variant doesn't exist, continue to next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert Node.js stream to Web ReadableStream
|
* Convert Node.js stream to Web ReadableStream
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -6,12 +6,18 @@ export * from './core/index.js';
|
|||||||
// Decorator exports
|
// Decorator exports
|
||||||
export * from './decorators/index.js';
|
export * from './decorators/index.js';
|
||||||
|
|
||||||
|
// Compression exports
|
||||||
|
export * from './compression/index.js';
|
||||||
|
|
||||||
// File server exports
|
// File server exports
|
||||||
export * from './files/index.js';
|
export * from './files/index.js';
|
||||||
|
|
||||||
// Protocol exports (WebDAV, etc.)
|
// Protocol exports (WebDAV, etc.)
|
||||||
export * from './protocols/index.js';
|
export * from './protocols/index.js';
|
||||||
|
|
||||||
|
// OpenAPI exports
|
||||||
|
export * from './openapi/index.js';
|
||||||
|
|
||||||
// Utility exports
|
// Utility exports
|
||||||
export * from './utils/index.js';
|
export * from './utils/index.js';
|
||||||
|
|
||||||
|
|||||||
84
ts/openapi/index.ts
Normal file
84
ts/openapi/index.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
/**
|
||||||
|
* OpenAPI Module for SmartServe
|
||||||
|
*
|
||||||
|
* Provides:
|
||||||
|
* - Decorators for API documentation and validation
|
||||||
|
* - OpenAPI 3.1 specification generation
|
||||||
|
* - Swagger UI / ReDoc handlers
|
||||||
|
* - Request validation using JSON Schema
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Type exports
|
||||||
|
export type {
|
||||||
|
// OpenAPI Spec types
|
||||||
|
IOpenApiSpec,
|
||||||
|
IOpenApiInfo,
|
||||||
|
IOpenApiContact,
|
||||||
|
IOpenApiLicense,
|
||||||
|
IOpenApiServer,
|
||||||
|
IOpenApiServerVariable,
|
||||||
|
IOpenApiPathItem,
|
||||||
|
IOpenApiOperation,
|
||||||
|
IOpenApiParameter,
|
||||||
|
IOpenApiRequestBody,
|
||||||
|
IOpenApiMediaType,
|
||||||
|
IOpenApiResponse,
|
||||||
|
IOpenApiExample,
|
||||||
|
IOpenApiComponents,
|
||||||
|
IOpenApiSecurityScheme,
|
||||||
|
IOpenApiSecuritySchemeApiKey,
|
||||||
|
IOpenApiSecuritySchemeHttp,
|
||||||
|
IOpenApiSecuritySchemeOAuth2,
|
||||||
|
IOpenApiSecuritySchemeOpenIdConnect,
|
||||||
|
IOpenApiOAuthFlows,
|
||||||
|
IOpenApiOAuthFlow,
|
||||||
|
IOpenApiSecurityRequirement,
|
||||||
|
IOpenApiTag,
|
||||||
|
IOpenApiExternalDocs,
|
||||||
|
// Options types
|
||||||
|
IOpenApiGeneratorOptions,
|
||||||
|
IOpenApiOptions,
|
||||||
|
} from './openapi.types.js';
|
||||||
|
|
||||||
|
export type {
|
||||||
|
IValidationError,
|
||||||
|
IValidationResult,
|
||||||
|
} from './openapi.validator.js';
|
||||||
|
|
||||||
|
// Decorators
|
||||||
|
export {
|
||||||
|
ApiOperation,
|
||||||
|
ApiParam,
|
||||||
|
ApiQuery,
|
||||||
|
ApiHeader,
|
||||||
|
ApiRequestBody,
|
||||||
|
ApiResponseBody,
|
||||||
|
ApiSecurity,
|
||||||
|
ApiTag,
|
||||||
|
} from './openapi.decorators.js';
|
||||||
|
|
||||||
|
// Generator
|
||||||
|
export { OpenApiGenerator } from './openapi.generator.js';
|
||||||
|
|
||||||
|
// Handlers
|
||||||
|
export {
|
||||||
|
createOpenApiHandler,
|
||||||
|
createSwaggerUiHandler,
|
||||||
|
createReDocHandler,
|
||||||
|
} from './openapi.handlers.js';
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
export {
|
||||||
|
validateSchema,
|
||||||
|
validateParam,
|
||||||
|
validateRequest,
|
||||||
|
createValidationInterceptor,
|
||||||
|
createValidationErrorResponse,
|
||||||
|
} from './openapi.validator.js';
|
||||||
|
|
||||||
|
// Coercion
|
||||||
|
export {
|
||||||
|
coerceValue,
|
||||||
|
coerceQueryParams,
|
||||||
|
coercePathParams,
|
||||||
|
} from './openapi.coerce.js';
|
||||||
110
ts/openapi/openapi.coerce.ts
Normal file
110
ts/openapi/openapi.coerce.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
/**
|
||||||
|
* Type coercion utilities for query parameters
|
||||||
|
* Converts string values from query params to proper types based on JSON Schema
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { TJsonSchema, IOpenApiParamMeta } from '../decorators/decorators.types.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Coerce a single value based on JSON Schema type
|
||||||
|
*/
|
||||||
|
export function coerceValue(value: string | undefined, schema: TJsonSchema): unknown {
|
||||||
|
if (value === undefined || value === '') {
|
||||||
|
// Return default if available
|
||||||
|
if (schema.default !== undefined) {
|
||||||
|
return schema.default;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const type = Array.isArray(schema.type) ? schema.type[0] : schema.type;
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'integer': {
|
||||||
|
const num = parseInt(value, 10);
|
||||||
|
return isNaN(num) ? value : num;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'number': {
|
||||||
|
const num = parseFloat(value);
|
||||||
|
return isNaN(num) ? value : num;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'boolean': {
|
||||||
|
if (value === 'true' || value === '1') return true;
|
||||||
|
if (value === 'false' || value === '0' || value === '') return false;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'array': {
|
||||||
|
// Handle comma-separated values
|
||||||
|
const items = value.split(',').map(item => item.trim());
|
||||||
|
if (schema.items) {
|
||||||
|
return items.map(item => coerceValue(item, schema.items as TJsonSchema));
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'null': {
|
||||||
|
if (value === 'null' || value === '') return null;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'object': {
|
||||||
|
// Attempt to parse JSON
|
||||||
|
try {
|
||||||
|
return JSON.parse(value);
|
||||||
|
} catch {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'string':
|
||||||
|
default:
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Coerce all query parameters based on their schemas
|
||||||
|
*/
|
||||||
|
export function coerceQueryParams(
|
||||||
|
query: Record<string, string>,
|
||||||
|
schemas: Map<string, IOpenApiParamMeta>
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const result: Record<string, unknown> = { ...query };
|
||||||
|
|
||||||
|
for (const [name, meta] of schemas) {
|
||||||
|
if (meta.schema) {
|
||||||
|
const rawValue = query[name];
|
||||||
|
const coercedValue = coerceValue(rawValue, meta.schema);
|
||||||
|
|
||||||
|
if (coercedValue !== undefined) {
|
||||||
|
result[name] = coercedValue;
|
||||||
|
} else if (meta.schema.default !== undefined) {
|
||||||
|
// Apply default when value is missing
|
||||||
|
result[name] = meta.schema.default;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Coerce path parameters based on their schemas
|
||||||
|
*/
|
||||||
|
export function coercePathParams(
|
||||||
|
params: Record<string, string>,
|
||||||
|
schemas: Map<string, IOpenApiParamMeta>
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const result: Record<string, unknown> = { ...params };
|
||||||
|
|
||||||
|
for (const [name, meta] of schemas) {
|
||||||
|
if (meta.schema && params[name] !== undefined) {
|
||||||
|
result[name] = coerceValue(params[name], meta.schema);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
355
ts/openapi/openapi.decorators.ts
Normal file
355
ts/openapi/openapi.decorators.ts
Normal file
@@ -0,0 +1,355 @@
|
|||||||
|
/**
|
||||||
|
* OpenAPI decorators for documentation and validation
|
||||||
|
*
|
||||||
|
* These decorators serve dual purposes:
|
||||||
|
* 1. Generate OpenAPI 3.1 specification
|
||||||
|
* 2. Validate requests at runtime
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getControllerMetadata } from '../decorators/decorators.metadata.js';
|
||||||
|
import type {
|
||||||
|
TJsonSchema,
|
||||||
|
IOpenApiOperationMeta,
|
||||||
|
IOpenApiParamMeta,
|
||||||
|
IOpenApiRequestBodyMeta,
|
||||||
|
IOpenApiResponseBodyMeta,
|
||||||
|
IOpenApiRouteMeta,
|
||||||
|
} from '../decorators/decorators.types.js';
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Helper Functions
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get or create OpenAPI metadata for a route
|
||||||
|
*/
|
||||||
|
function getRouteOpenApi(target: any, methodName: string | symbol): IOpenApiRouteMeta {
|
||||||
|
const metadata = getControllerMetadata(target.constructor);
|
||||||
|
|
||||||
|
let route = metadata.routes.get(methodName);
|
||||||
|
if (!route) {
|
||||||
|
// Create placeholder route (will be completed by @Get/@Post/etc.)
|
||||||
|
route = {
|
||||||
|
method: 'GET',
|
||||||
|
path: '',
|
||||||
|
methodName,
|
||||||
|
interceptors: [],
|
||||||
|
options: {},
|
||||||
|
};
|
||||||
|
metadata.routes.set(methodName, route);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!route.openapi) {
|
||||||
|
route.openapi = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return route.openapi;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get or create OpenAPI metadata for a controller
|
||||||
|
*/
|
||||||
|
function getControllerOpenApi(target: any) {
|
||||||
|
const metadata = getControllerMetadata(target);
|
||||||
|
|
||||||
|
if (!metadata.openapi) {
|
||||||
|
metadata.openapi = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return metadata.openapi;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Method Decorators
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @ApiOperation - Document the operation with summary and description
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* @Get('/:id')
|
||||||
|
* @ApiOperation({
|
||||||
|
* summary: 'Get user by ID',
|
||||||
|
* description: 'Retrieves a single user by their unique identifier',
|
||||||
|
* operationId: 'getUserById',
|
||||||
|
* })
|
||||||
|
* getUser(ctx: IRequestContext) { ... }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function ApiOperation(options: IOpenApiOperationMeta) {
|
||||||
|
return function <This, Args extends any[], Return>(
|
||||||
|
target: (this: This, ...args: Args) => Return,
|
||||||
|
context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Return>
|
||||||
|
) {
|
||||||
|
context.addInitializer(function (this: This) {
|
||||||
|
const openapi = getRouteOpenApi(this, context.name);
|
||||||
|
openapi.operation = { ...openapi.operation, ...options };
|
||||||
|
});
|
||||||
|
return target;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @ApiParam - Document and validate a path parameter
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* @Get('/:id')
|
||||||
|
* @ApiParam('id', {
|
||||||
|
* description: 'User UUID',
|
||||||
|
* schema: { type: 'string', format: 'uuid' }
|
||||||
|
* })
|
||||||
|
* getUser(ctx: IRequestContext) { ... }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function ApiParam(name: string, options: IOpenApiParamMeta = {}) {
|
||||||
|
return function <This, Args extends any[], Return>(
|
||||||
|
target: (this: This, ...args: Args) => Return,
|
||||||
|
context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Return>
|
||||||
|
) {
|
||||||
|
context.addInitializer(function (this: This) {
|
||||||
|
const openapi = getRouteOpenApi(this, context.name);
|
||||||
|
|
||||||
|
if (!openapi.params) {
|
||||||
|
openapi.params = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Path params are always required
|
||||||
|
openapi.params.set(name, {
|
||||||
|
...options,
|
||||||
|
required: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return target;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @ApiQuery - Document and validate a query parameter
|
||||||
|
*
|
||||||
|
* Supports type coercion: string query values are converted to the
|
||||||
|
* appropriate type based on the schema (integer, number, boolean, array).
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* @Get('/')
|
||||||
|
* @ApiQuery('limit', {
|
||||||
|
* description: 'Maximum number of results',
|
||||||
|
* schema: { type: 'integer', default: 10, minimum: 1, maximum: 100 }
|
||||||
|
* })
|
||||||
|
* @ApiQuery('active', {
|
||||||
|
* description: 'Filter by active status',
|
||||||
|
* schema: { type: 'boolean' }
|
||||||
|
* })
|
||||||
|
* listUsers(ctx: IRequestContext) {
|
||||||
|
* // ctx.query.limit is coerced to number
|
||||||
|
* // ctx.query.active is coerced to boolean
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function ApiQuery(name: string, options: IOpenApiParamMeta = {}) {
|
||||||
|
return function <This, Args extends any[], Return>(
|
||||||
|
target: (this: This, ...args: Args) => Return,
|
||||||
|
context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Return>
|
||||||
|
) {
|
||||||
|
context.addInitializer(function (this: This) {
|
||||||
|
const openapi = getRouteOpenApi(this, context.name);
|
||||||
|
|
||||||
|
if (!openapi.query) {
|
||||||
|
openapi.query = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
openapi.query.set(name, options);
|
||||||
|
});
|
||||||
|
return target;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @ApiHeader - Document and validate a header parameter
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* @Get('/')
|
||||||
|
* @ApiHeader('X-Request-ID', {
|
||||||
|
* description: 'Unique request identifier',
|
||||||
|
* schema: { type: 'string', format: 'uuid' }
|
||||||
|
* })
|
||||||
|
* getData(ctx: IRequestContext) { ... }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function ApiHeader(name: string, options: IOpenApiParamMeta = {}) {
|
||||||
|
return function <This, Args extends any[], Return>(
|
||||||
|
target: (this: This, ...args: Args) => Return,
|
||||||
|
context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Return>
|
||||||
|
) {
|
||||||
|
context.addInitializer(function (this: This) {
|
||||||
|
const openapi = getRouteOpenApi(this, context.name);
|
||||||
|
|
||||||
|
if (!openapi.headers) {
|
||||||
|
openapi.headers = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
openapi.headers.set(name, options);
|
||||||
|
});
|
||||||
|
return target;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @ApiRequestBody - Document and validate the request body
|
||||||
|
*
|
||||||
|
* The request body is validated against the provided JSON Schema.
|
||||||
|
* Invalid requests receive a 400 response before the handler runs.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* const CreateUserSchema = {
|
||||||
|
* type: 'object',
|
||||||
|
* properties: {
|
||||||
|
* name: { type: 'string', minLength: 1 },
|
||||||
|
* email: { type: 'string', format: 'email' },
|
||||||
|
* },
|
||||||
|
* required: ['name', 'email'],
|
||||||
|
* };
|
||||||
|
*
|
||||||
|
* @Post('/')
|
||||||
|
* @ApiRequestBody({
|
||||||
|
* description: 'User creation payload',
|
||||||
|
* schema: CreateUserSchema
|
||||||
|
* })
|
||||||
|
* createUser(ctx: IRequestContext<{ name: string; email: string }>) {
|
||||||
|
* // ctx.body is guaranteed to match the schema
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function ApiRequestBody(options: IOpenApiRequestBodyMeta) {
|
||||||
|
return function <This, Args extends any[], Return>(
|
||||||
|
target: (this: This, ...args: Args) => Return,
|
||||||
|
context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Return>
|
||||||
|
) {
|
||||||
|
context.addInitializer(function (this: This) {
|
||||||
|
const openapi = getRouteOpenApi(this, context.name);
|
||||||
|
openapi.requestBody = options;
|
||||||
|
});
|
||||||
|
return target;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @ApiResponseBody - Document a response for a specific status code
|
||||||
|
*
|
||||||
|
* Multiple @ApiResponseBody decorators can be used for different status codes.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* @Get('/:id')
|
||||||
|
* @ApiResponseBody(200, {
|
||||||
|
* description: 'User found',
|
||||||
|
* schema: UserSchema
|
||||||
|
* })
|
||||||
|
* @ApiResponseBody(404, {
|
||||||
|
* description: 'User not found'
|
||||||
|
* })
|
||||||
|
* getUser(ctx: IRequestContext) { ... }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function ApiResponseBody(status: number, options: Omit<IOpenApiResponseBodyMeta, 'status'>) {
|
||||||
|
return function <This, Args extends any[], Return>(
|
||||||
|
target: (this: This, ...args: Args) => Return,
|
||||||
|
context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Return>
|
||||||
|
) {
|
||||||
|
context.addInitializer(function (this: This) {
|
||||||
|
const openapi = getRouteOpenApi(this, context.name);
|
||||||
|
|
||||||
|
if (!openapi.responses) {
|
||||||
|
openapi.responses = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
openapi.responses.set(status, options as IOpenApiResponseBodyMeta);
|
||||||
|
});
|
||||||
|
return target;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @ApiSecurity - Specify security requirements for a route
|
||||||
|
*
|
||||||
|
* Can be used on classes (applies to all routes) or methods.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* // Method-level security
|
||||||
|
* @Post('/')
|
||||||
|
* @ApiSecurity('bearerAuth')
|
||||||
|
* createUser(ctx: IRequestContext) { ... }
|
||||||
|
*
|
||||||
|
* // With OAuth scopes
|
||||||
|
* @Delete('/:id')
|
||||||
|
* @ApiSecurity('oauth2', ['users:delete'])
|
||||||
|
* deleteUser(ctx: IRequestContext) { ... }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function ApiSecurity(name: string, scopes: string[] = []) {
|
||||||
|
// Can be both class and method decorator
|
||||||
|
return function <T extends (new (...args: any[]) => any) | ((this: any, ...args: any[]) => any)>(
|
||||||
|
target: T,
|
||||||
|
context: ClassDecoratorContext | ClassMethodDecoratorContext
|
||||||
|
): T {
|
||||||
|
if (context.kind === 'class') {
|
||||||
|
// Class decorator
|
||||||
|
const controllerOpenApi = getControllerOpenApi(target);
|
||||||
|
if (!controllerOpenApi.security) {
|
||||||
|
controllerOpenApi.security = [];
|
||||||
|
}
|
||||||
|
controllerOpenApi.security.push({ [name]: scopes });
|
||||||
|
} else if (context.kind === 'method') {
|
||||||
|
// Method decorator
|
||||||
|
context.addInitializer(function (this: any) {
|
||||||
|
const openapi = getRouteOpenApi(this, context.name);
|
||||||
|
if (!openapi.security) {
|
||||||
|
openapi.security = [];
|
||||||
|
}
|
||||||
|
openapi.security.push({ [name]: scopes });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return target;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Class Decorators
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @ApiTag - Group routes under a tag in the documentation
|
||||||
|
*
|
||||||
|
* Multiple tags can be specified. Applied to all routes in the controller.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* @Route('/api/users')
|
||||||
|
* @ApiTag('Users')
|
||||||
|
* @ApiTag('Admin')
|
||||||
|
* class UserController { ... }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function ApiTag(...tags: string[]) {
|
||||||
|
return function <TClass extends new (...args: any[]) => any>(
|
||||||
|
target: TClass,
|
||||||
|
context: ClassDecoratorContext<TClass>
|
||||||
|
): TClass {
|
||||||
|
if (context.kind !== 'class') {
|
||||||
|
throw new Error('@ApiTag can only decorate classes');
|
||||||
|
}
|
||||||
|
|
||||||
|
const controllerOpenApi = getControllerOpenApi(target);
|
||||||
|
if (!controllerOpenApi.tags) {
|
||||||
|
controllerOpenApi.tags = [];
|
||||||
|
}
|
||||||
|
controllerOpenApi.tags.push(...tags);
|
||||||
|
|
||||||
|
return target;
|
||||||
|
};
|
||||||
|
}
|
||||||
306
ts/openapi/openapi.generator.ts
Normal file
306
ts/openapi/openapi.generator.ts
Normal file
@@ -0,0 +1,306 @@
|
|||||||
|
/**
|
||||||
|
* OpenAPI Specification Generator
|
||||||
|
*
|
||||||
|
* Generates OpenAPI 3.1 specification from registered controllers and their metadata
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ControllerRegistry } from '../decorators/decorators.registry.js';
|
||||||
|
import { combinePaths } from '../decorators/decorators.metadata.js';
|
||||||
|
import type {
|
||||||
|
IControllerMetadata,
|
||||||
|
IRouteMetadata,
|
||||||
|
IOpenApiRouteMeta,
|
||||||
|
IOpenApiControllerMeta,
|
||||||
|
TJsonSchema,
|
||||||
|
} from '../decorators/decorators.types.js';
|
||||||
|
import type {
|
||||||
|
IOpenApiSpec,
|
||||||
|
IOpenApiPathItem,
|
||||||
|
IOpenApiOperation,
|
||||||
|
IOpenApiParameter,
|
||||||
|
IOpenApiRequestBody,
|
||||||
|
IOpenApiResponse,
|
||||||
|
IOpenApiGeneratorOptions,
|
||||||
|
IOpenApiSecurityScheme,
|
||||||
|
IOpenApiTag,
|
||||||
|
} from './openapi.types.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OpenAPI Specification Generator
|
||||||
|
*/
|
||||||
|
export class OpenApiGenerator {
|
||||||
|
private options: IOpenApiGeneratorOptions;
|
||||||
|
private schemas: Map<string, TJsonSchema> = new Map();
|
||||||
|
|
||||||
|
constructor(options: IOpenApiGeneratorOptions) {
|
||||||
|
this.options = options;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a reusable schema in components/schemas
|
||||||
|
*/
|
||||||
|
addSchema(name: string, schema: TJsonSchema): this {
|
||||||
|
this.schemas.set(name, schema);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate the complete OpenAPI specification
|
||||||
|
*/
|
||||||
|
generate(): IOpenApiSpec {
|
||||||
|
const spec: IOpenApiSpec = {
|
||||||
|
openapi: '3.1.0',
|
||||||
|
info: this.options.info,
|
||||||
|
servers: this.options.servers ?? [],
|
||||||
|
paths: {},
|
||||||
|
components: {
|
||||||
|
schemas: Object.fromEntries(this.schemas),
|
||||||
|
securitySchemes: this.options.securitySchemes ?? {},
|
||||||
|
},
|
||||||
|
tags: this.options.tags ?? [],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.options.externalDocs) {
|
||||||
|
spec.externalDocs = this.options.externalDocs;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect all unique tags
|
||||||
|
const collectedTags = new Set<string>();
|
||||||
|
|
||||||
|
// Get all registered controllers
|
||||||
|
const controllers = ControllerRegistry.getControllers();
|
||||||
|
|
||||||
|
for (const { metadata } of controllers) {
|
||||||
|
this.processController(spec, metadata, collectedTags);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add collected tags that aren't already in spec.tags
|
||||||
|
const existingTagNames = new Set(spec.tags?.map(t => t.name) ?? []);
|
||||||
|
for (const tag of collectedTags) {
|
||||||
|
if (!existingTagNames.has(tag)) {
|
||||||
|
spec.tags!.push({ name: tag });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return spec;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate and return as JSON string
|
||||||
|
*/
|
||||||
|
toJSON(pretty = true): string {
|
||||||
|
return JSON.stringify(this.generate(), null, pretty ? 2 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process a single controller and add its routes to the spec
|
||||||
|
*/
|
||||||
|
private processController(
|
||||||
|
spec: IOpenApiSpec,
|
||||||
|
metadata: IControllerMetadata,
|
||||||
|
collectedTags: Set<string>
|
||||||
|
): void {
|
||||||
|
const controllerOpenApi = metadata.openapi ?? {};
|
||||||
|
|
||||||
|
// Collect controller tags
|
||||||
|
if (controllerOpenApi.tags) {
|
||||||
|
for (const tag of controllerOpenApi.tags) {
|
||||||
|
collectedTags.add(tag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process each route
|
||||||
|
for (const [, route] of metadata.routes) {
|
||||||
|
const fullPath = combinePaths(metadata.basePath, route.path);
|
||||||
|
const openApiPath = this.convertPathToOpenApi(fullPath);
|
||||||
|
|
||||||
|
// Initialize path if not exists
|
||||||
|
if (!spec.paths[openApiPath]) {
|
||||||
|
spec.paths[openApiPath] = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build operation
|
||||||
|
const operation = this.buildOperation(route, controllerOpenApi, collectedTags);
|
||||||
|
|
||||||
|
// Add to appropriate HTTP method
|
||||||
|
const method = route.method.toLowerCase();
|
||||||
|
|
||||||
|
if (method === 'all') {
|
||||||
|
// 'ALL' applies to all standard methods
|
||||||
|
const methods = ['get', 'post', 'put', 'delete', 'patch'] as const;
|
||||||
|
for (const m of methods) {
|
||||||
|
(spec.paths[openApiPath] as any)[m] = { ...operation };
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
(spec.paths[openApiPath] as any)[method] = operation;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build an OpenAPI operation from route metadata
|
||||||
|
*/
|
||||||
|
private buildOperation(
|
||||||
|
route: IRouteMetadata,
|
||||||
|
controllerMeta: IOpenApiControllerMeta,
|
||||||
|
collectedTags: Set<string>
|
||||||
|
): IOpenApiOperation {
|
||||||
|
const routeOpenApi = route.openapi ?? {};
|
||||||
|
const operationMeta = routeOpenApi.operation ?? {};
|
||||||
|
|
||||||
|
const operation: IOpenApiOperation = {
|
||||||
|
summary: operationMeta.summary ?? `${route.method} ${route.path}`,
|
||||||
|
description: operationMeta.description,
|
||||||
|
operationId: operationMeta.operationId ?? this.generateOperationId(route),
|
||||||
|
deprecated: operationMeta.deprecated,
|
||||||
|
tags: controllerMeta.tags ? [...controllerMeta.tags] : [],
|
||||||
|
parameters: [],
|
||||||
|
responses: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (operationMeta.externalDocs) {
|
||||||
|
operation.externalDocs = operationMeta.externalDocs;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect tags
|
||||||
|
for (const tag of operation.tags ?? []) {
|
||||||
|
collectedTags.add(tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add path parameters (auto-detect from path pattern)
|
||||||
|
const pathParams = this.extractPathParams(route.path);
|
||||||
|
for (const paramName of pathParams) {
|
||||||
|
const paramMeta = routeOpenApi.params?.get(paramName);
|
||||||
|
operation.parameters!.push({
|
||||||
|
name: paramName,
|
||||||
|
in: 'path',
|
||||||
|
required: true,
|
||||||
|
description: paramMeta?.description ?? `Path parameter: ${paramName}`,
|
||||||
|
schema: paramMeta?.schema ?? { type: 'string' },
|
||||||
|
example: paramMeta?.example,
|
||||||
|
deprecated: paramMeta?.deprecated,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add query parameters
|
||||||
|
if (routeOpenApi.query) {
|
||||||
|
for (const [name, meta] of routeOpenApi.query) {
|
||||||
|
operation.parameters!.push({
|
||||||
|
name,
|
||||||
|
in: 'query',
|
||||||
|
required: meta.required ?? false,
|
||||||
|
description: meta.description,
|
||||||
|
schema: meta.schema ?? { type: 'string' },
|
||||||
|
example: meta.example,
|
||||||
|
allowEmptyValue: meta.allowEmptyValue,
|
||||||
|
deprecated: meta.deprecated,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add header parameters
|
||||||
|
if (routeOpenApi.headers) {
|
||||||
|
for (const [name, meta] of routeOpenApi.headers) {
|
||||||
|
operation.parameters!.push({
|
||||||
|
name,
|
||||||
|
in: 'header',
|
||||||
|
required: meta.required ?? false,
|
||||||
|
description: meta.description,
|
||||||
|
schema: meta.schema ?? { type: 'string' },
|
||||||
|
example: meta.example,
|
||||||
|
deprecated: meta.deprecated,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove empty parameters array
|
||||||
|
if (operation.parameters!.length === 0) {
|
||||||
|
delete operation.parameters;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add request body
|
||||||
|
if (routeOpenApi.requestBody) {
|
||||||
|
operation.requestBody = {
|
||||||
|
description: routeOpenApi.requestBody.description,
|
||||||
|
required: routeOpenApi.requestBody.required !== false,
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: routeOpenApi.requestBody.schema,
|
||||||
|
example: routeOpenApi.requestBody.example,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add responses
|
||||||
|
if (routeOpenApi.responses && routeOpenApi.responses.size > 0) {
|
||||||
|
for (const [status, responseMeta] of routeOpenApi.responses) {
|
||||||
|
const response: IOpenApiResponse = {
|
||||||
|
description: responseMeta.description,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (responseMeta.schema) {
|
||||||
|
response.content = {
|
||||||
|
'application/json': {
|
||||||
|
schema: responseMeta.schema,
|
||||||
|
example: responseMeta.example,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (responseMeta.headers) {
|
||||||
|
response.headers = {};
|
||||||
|
for (const [headerName, headerMeta] of Object.entries(responseMeta.headers)) {
|
||||||
|
response.headers[headerName] = {
|
||||||
|
description: headerMeta.description,
|
||||||
|
schema: headerMeta.schema,
|
||||||
|
} as IOpenApiParameter;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
operation.responses[status.toString()] = response;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Default response
|
||||||
|
operation.responses['200'] = {
|
||||||
|
description: 'Successful response',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add security requirements
|
||||||
|
const security = routeOpenApi.security ?? controllerMeta.security;
|
||||||
|
if (security && security.length > 0) {
|
||||||
|
operation.security = security;
|
||||||
|
}
|
||||||
|
|
||||||
|
return operation;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert Express-style path to OpenAPI path format
|
||||||
|
* :id -> {id}
|
||||||
|
*/
|
||||||
|
private convertPathToOpenApi(path: string): string {
|
||||||
|
return path.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, '{$1}');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract parameter names from path pattern
|
||||||
|
*/
|
||||||
|
private extractPathParams(path: string): string[] {
|
||||||
|
const matches = path.matchAll(/:([a-zA-Z_][a-zA-Z0-9_]*)/g);
|
||||||
|
return Array.from(matches, m => m[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a unique operation ID from route metadata
|
||||||
|
*/
|
||||||
|
private generateOperationId(route: IRouteMetadata): string {
|
||||||
|
const methodName = String(route.methodName);
|
||||||
|
// Convert camelCase to snake_case and sanitize
|
||||||
|
return methodName
|
||||||
|
.replace(/[^a-zA-Z0-9]/g, '_')
|
||||||
|
.replace(/([a-z])([A-Z])/g, '$1_$2')
|
||||||
|
.toLowerCase();
|
||||||
|
}
|
||||||
|
}
|
||||||
138
ts/openapi/openapi.handlers.ts
Normal file
138
ts/openapi/openapi.handlers.ts
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
/**
|
||||||
|
* Request handlers for OpenAPI specification and Swagger UI
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { IRequestContext } from '../core/smartserve.interfaces.js';
|
||||||
|
import { OpenApiGenerator } from './openapi.generator.js';
|
||||||
|
import type { IOpenApiGeneratorOptions } from './openapi.types.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a handler that serves the OpenAPI JSON specification
|
||||||
|
*/
|
||||||
|
export function createOpenApiHandler(options: IOpenApiGeneratorOptions) {
|
||||||
|
let cachedSpec: string | null = null;
|
||||||
|
|
||||||
|
return async (ctx: IRequestContext): Promise<Response> => {
|
||||||
|
// Generate spec on first request (lazy loading)
|
||||||
|
if (!cachedSpec) {
|
||||||
|
const generator = new OpenApiGenerator(options);
|
||||||
|
cachedSpec = generator.toJSON();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(cachedSpec, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Cache-Control': 'public, max-age=3600',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a handler that serves Swagger UI
|
||||||
|
*
|
||||||
|
* Loads Swagger UI from unpkg CDN - no bundled assets needed
|
||||||
|
*/
|
||||||
|
export function createSwaggerUiHandler(specUrl: string = '/openapi.json', title: string = 'API Documentation') {
|
||||||
|
const html = `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>${escapeHtml(title)}</title>
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css" />
|
||||||
|
<style>
|
||||||
|
html { box-sizing: border-box; overflow-y: scroll; }
|
||||||
|
*, *:before, *:after { box-sizing: inherit; }
|
||||||
|
body { margin: 0; background: #fafafa; }
|
||||||
|
.swagger-ui .topbar { display: none; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="swagger-ui"></div>
|
||||||
|
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
|
||||||
|
<script>
|
||||||
|
window.onload = () => {
|
||||||
|
window.ui = SwaggerUIBundle({
|
||||||
|
url: "${escapeHtml(specUrl)}",
|
||||||
|
dom_id: '#swagger-ui',
|
||||||
|
deepLinking: true,
|
||||||
|
presets: [
|
||||||
|
SwaggerUIBundle.presets.apis,
|
||||||
|
SwaggerUIBundle.SwaggerUIStandalonePreset
|
||||||
|
],
|
||||||
|
plugins: [
|
||||||
|
SwaggerUIBundle.plugins.DownloadUrl
|
||||||
|
],
|
||||||
|
layout: "BaseLayout",
|
||||||
|
validatorUrl: null,
|
||||||
|
supportedSubmitMethods: ['get', 'post', 'put', 'delete', 'patch', 'head', 'options'],
|
||||||
|
defaultModelsExpandDepth: 1,
|
||||||
|
defaultModelExpandDepth: 1,
|
||||||
|
displayRequestDuration: true,
|
||||||
|
filter: true,
|
||||||
|
showExtensions: true,
|
||||||
|
showCommonExtensions: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
|
||||||
|
return async (ctx: IRequestContext): Promise<Response> => {
|
||||||
|
return new Response(html, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/html; charset=utf-8',
|
||||||
|
'Cache-Control': 'public, max-age=86400',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a handler that serves ReDoc UI (alternative to Swagger UI)
|
||||||
|
*
|
||||||
|
* ReDoc provides a clean, responsive documentation layout
|
||||||
|
*/
|
||||||
|
export function createReDocHandler(specUrl: string = '/openapi.json', title: string = 'API Documentation') {
|
||||||
|
const html = `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>${escapeHtml(title)}</title>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
body { margin: 0; padding: 0; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<redoc spec-url="${escapeHtml(specUrl)}"></redoc>
|
||||||
|
<script src="https://unpkg.com/redoc@latest/bundles/redoc.standalone.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
|
||||||
|
return async (ctx: IRequestContext): Promise<Response> => {
|
||||||
|
return new Response(html, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/html; charset=utf-8',
|
||||||
|
'Cache-Control': 'public, max-age=86400',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escape HTML special characters to prevent XSS
|
||||||
|
*/
|
||||||
|
function escapeHtml(str: string): string {
|
||||||
|
return str
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
488
ts/openapi/openapi.types.ts
Normal file
488
ts/openapi/openapi.types.ts
Normal file
@@ -0,0 +1,488 @@
|
|||||||
|
/**
|
||||||
|
* OpenAPI 3.1 Specification Types
|
||||||
|
* Based on https://spec.openapis.org/oas/v3.1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { TJsonSchema } from '../decorators/decorators.types.js';
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// OpenAPI Specification Root
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OpenAPI 3.1 Specification
|
||||||
|
*/
|
||||||
|
export interface IOpenApiSpec {
|
||||||
|
/** OpenAPI specification version */
|
||||||
|
openapi: '3.1.0';
|
||||||
|
/** API metadata */
|
||||||
|
info: IOpenApiInfo;
|
||||||
|
/** JSON Schema dialect (OpenAPI 3.1 default) */
|
||||||
|
jsonSchemaDialect?: string;
|
||||||
|
/** Server information */
|
||||||
|
servers?: IOpenApiServer[];
|
||||||
|
/** Available paths and operations */
|
||||||
|
paths: Record<string, IOpenApiPathItem>;
|
||||||
|
/** Webhooks */
|
||||||
|
webhooks?: Record<string, IOpenApiPathItem>;
|
||||||
|
/** Reusable components */
|
||||||
|
components?: IOpenApiComponents;
|
||||||
|
/** Security requirements */
|
||||||
|
security?: IOpenApiSecurityRequirement[];
|
||||||
|
/** Tags for organization */
|
||||||
|
tags?: IOpenApiTag[];
|
||||||
|
/** External documentation */
|
||||||
|
externalDocs?: IOpenApiExternalDocs;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Info Object
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API metadata
|
||||||
|
*/
|
||||||
|
export interface IOpenApiInfo {
|
||||||
|
/** API title */
|
||||||
|
title: string;
|
||||||
|
/** API version */
|
||||||
|
version: string;
|
||||||
|
/** API description (markdown supported) */
|
||||||
|
description?: string;
|
||||||
|
/** Terms of service URL */
|
||||||
|
termsOfService?: string;
|
||||||
|
/** Contact information */
|
||||||
|
contact?: IOpenApiContact;
|
||||||
|
/** License information */
|
||||||
|
license?: IOpenApiLicense;
|
||||||
|
/** Short summary */
|
||||||
|
summary?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contact information
|
||||||
|
*/
|
||||||
|
export interface IOpenApiContact {
|
||||||
|
name?: string;
|
||||||
|
url?: string;
|
||||||
|
email?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* License information
|
||||||
|
*/
|
||||||
|
export interface IOpenApiLicense {
|
||||||
|
name: string;
|
||||||
|
url?: string;
|
||||||
|
identifier?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Server Object
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server information
|
||||||
|
*/
|
||||||
|
export interface IOpenApiServer {
|
||||||
|
/** Server URL */
|
||||||
|
url: string;
|
||||||
|
/** Server description */
|
||||||
|
description?: string;
|
||||||
|
/** Server variables */
|
||||||
|
variables?: Record<string, IOpenApiServerVariable>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server variable
|
||||||
|
*/
|
||||||
|
export interface IOpenApiServerVariable {
|
||||||
|
default: string;
|
||||||
|
description?: string;
|
||||||
|
enum?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Path & Operation Objects
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Path item with operations
|
||||||
|
*/
|
||||||
|
export interface IOpenApiPathItem {
|
||||||
|
/** Reference to another path item */
|
||||||
|
$ref?: string;
|
||||||
|
/** Summary */
|
||||||
|
summary?: string;
|
||||||
|
/** Description */
|
||||||
|
description?: string;
|
||||||
|
/** GET operation */
|
||||||
|
get?: IOpenApiOperation;
|
||||||
|
/** PUT operation */
|
||||||
|
put?: IOpenApiOperation;
|
||||||
|
/** POST operation */
|
||||||
|
post?: IOpenApiOperation;
|
||||||
|
/** DELETE operation */
|
||||||
|
delete?: IOpenApiOperation;
|
||||||
|
/** OPTIONS operation */
|
||||||
|
options?: IOpenApiOperation;
|
||||||
|
/** HEAD operation */
|
||||||
|
head?: IOpenApiOperation;
|
||||||
|
/** PATCH operation */
|
||||||
|
patch?: IOpenApiOperation;
|
||||||
|
/** TRACE operation */
|
||||||
|
trace?: IOpenApiOperation;
|
||||||
|
/** Servers for this path */
|
||||||
|
servers?: IOpenApiServer[];
|
||||||
|
/** Parameters for all operations */
|
||||||
|
parameters?: IOpenApiParameter[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API operation
|
||||||
|
*/
|
||||||
|
export interface IOpenApiOperation {
|
||||||
|
/** Tags for grouping */
|
||||||
|
tags?: string[];
|
||||||
|
/** Short summary */
|
||||||
|
summary?: string;
|
||||||
|
/** Detailed description */
|
||||||
|
description?: string;
|
||||||
|
/** External documentation */
|
||||||
|
externalDocs?: IOpenApiExternalDocs;
|
||||||
|
/** Unique operation ID */
|
||||||
|
operationId?: string;
|
||||||
|
/** Operation parameters */
|
||||||
|
parameters?: IOpenApiParameter[];
|
||||||
|
/** Request body */
|
||||||
|
requestBody?: IOpenApiRequestBody;
|
||||||
|
/** Responses */
|
||||||
|
responses: Record<string, IOpenApiResponse>;
|
||||||
|
/** Callbacks */
|
||||||
|
callbacks?: Record<string, Record<string, IOpenApiPathItem>>;
|
||||||
|
/** Deprecation flag */
|
||||||
|
deprecated?: boolean;
|
||||||
|
/** Security requirements */
|
||||||
|
security?: IOpenApiSecurityRequirement[];
|
||||||
|
/** Servers for this operation */
|
||||||
|
servers?: IOpenApiServer[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Parameter Object
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Operation parameter
|
||||||
|
*/
|
||||||
|
export interface IOpenApiParameter {
|
||||||
|
/** Parameter name */
|
||||||
|
name: string;
|
||||||
|
/** Parameter location */
|
||||||
|
in: 'query' | 'header' | 'path' | 'cookie';
|
||||||
|
/** Description */
|
||||||
|
description?: string;
|
||||||
|
/** Required flag (path params always required) */
|
||||||
|
required?: boolean;
|
||||||
|
/** Deprecation flag */
|
||||||
|
deprecated?: boolean;
|
||||||
|
/** Allow empty value */
|
||||||
|
allowEmptyValue?: boolean;
|
||||||
|
/** Parameter schema */
|
||||||
|
schema?: TJsonSchema;
|
||||||
|
/** Example value */
|
||||||
|
example?: unknown;
|
||||||
|
/** Multiple examples */
|
||||||
|
examples?: Record<string, IOpenApiExample>;
|
||||||
|
/** Serialization style */
|
||||||
|
style?: 'matrix' | 'label' | 'form' | 'simple' | 'spaceDelimited' | 'pipeDelimited' | 'deepObject';
|
||||||
|
/** Explode arrays/objects */
|
||||||
|
explode?: boolean;
|
||||||
|
/** Allow reserved characters */
|
||||||
|
allowReserved?: boolean;
|
||||||
|
/** Content type mapping */
|
||||||
|
content?: Record<string, IOpenApiMediaType>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Request Body Object
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request body
|
||||||
|
*/
|
||||||
|
export interface IOpenApiRequestBody {
|
||||||
|
/** Description */
|
||||||
|
description?: string;
|
||||||
|
/** Content by media type */
|
||||||
|
content: Record<string, IOpenApiMediaType>;
|
||||||
|
/** Required flag */
|
||||||
|
required?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Media Type Object
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Media type content
|
||||||
|
*/
|
||||||
|
export interface IOpenApiMediaType {
|
||||||
|
/** Content schema */
|
||||||
|
schema?: TJsonSchema;
|
||||||
|
/** Example value */
|
||||||
|
example?: unknown;
|
||||||
|
/** Multiple examples */
|
||||||
|
examples?: Record<string, IOpenApiExample>;
|
||||||
|
/** Encoding for multipart */
|
||||||
|
encoding?: Record<string, IOpenApiEncoding>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encoding for multipart content
|
||||||
|
*/
|
||||||
|
export interface IOpenApiEncoding {
|
||||||
|
contentType?: string;
|
||||||
|
headers?: Record<string, IOpenApiParameter>;
|
||||||
|
style?: string;
|
||||||
|
explode?: boolean;
|
||||||
|
allowReserved?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Response Object
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Operation response
|
||||||
|
*/
|
||||||
|
export interface IOpenApiResponse {
|
||||||
|
/** Response description (required) */
|
||||||
|
description: string;
|
||||||
|
/** Response headers */
|
||||||
|
headers?: Record<string, IOpenApiParameter>;
|
||||||
|
/** Response content */
|
||||||
|
content?: Record<string, IOpenApiMediaType>;
|
||||||
|
/** Links */
|
||||||
|
links?: Record<string, IOpenApiLink>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response link
|
||||||
|
*/
|
||||||
|
export interface IOpenApiLink {
|
||||||
|
operationRef?: string;
|
||||||
|
operationId?: string;
|
||||||
|
parameters?: Record<string, unknown>;
|
||||||
|
requestBody?: unknown;
|
||||||
|
description?: string;
|
||||||
|
server?: IOpenApiServer;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Example Object
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example value
|
||||||
|
*/
|
||||||
|
export interface IOpenApiExample {
|
||||||
|
/** Short summary */
|
||||||
|
summary?: string;
|
||||||
|
/** Description */
|
||||||
|
description?: string;
|
||||||
|
/** Example value */
|
||||||
|
value?: unknown;
|
||||||
|
/** External URL */
|
||||||
|
externalValue?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Components Object
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reusable components
|
||||||
|
*/
|
||||||
|
export interface IOpenApiComponents {
|
||||||
|
/** Reusable schemas */
|
||||||
|
schemas?: Record<string, TJsonSchema>;
|
||||||
|
/** Reusable responses */
|
||||||
|
responses?: Record<string, IOpenApiResponse>;
|
||||||
|
/** Reusable parameters */
|
||||||
|
parameters?: Record<string, IOpenApiParameter>;
|
||||||
|
/** Reusable examples */
|
||||||
|
examples?: Record<string, IOpenApiExample>;
|
||||||
|
/** Reusable request bodies */
|
||||||
|
requestBodies?: Record<string, IOpenApiRequestBody>;
|
||||||
|
/** Reusable headers */
|
||||||
|
headers?: Record<string, IOpenApiParameter>;
|
||||||
|
/** Security schemes */
|
||||||
|
securitySchemes?: Record<string, IOpenApiSecurityScheme>;
|
||||||
|
/** Reusable links */
|
||||||
|
links?: Record<string, IOpenApiLink>;
|
||||||
|
/** Reusable callbacks */
|
||||||
|
callbacks?: Record<string, Record<string, IOpenApiPathItem>>;
|
||||||
|
/** Path items */
|
||||||
|
pathItems?: Record<string, IOpenApiPathItem>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Security Scheme Object
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Security scheme definition
|
||||||
|
*/
|
||||||
|
export type IOpenApiSecurityScheme =
|
||||||
|
| IOpenApiSecuritySchemeApiKey
|
||||||
|
| IOpenApiSecuritySchemeHttp
|
||||||
|
| IOpenApiSecuritySchemeOAuth2
|
||||||
|
| IOpenApiSecuritySchemeOpenIdConnect
|
||||||
|
| IOpenApiSecuritySchemeMutualTLS;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API key security scheme
|
||||||
|
*/
|
||||||
|
export interface IOpenApiSecuritySchemeApiKey {
|
||||||
|
type: 'apiKey';
|
||||||
|
description?: string;
|
||||||
|
name: string;
|
||||||
|
in: 'query' | 'header' | 'cookie';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP security scheme (bearer, basic, etc.)
|
||||||
|
*/
|
||||||
|
export interface IOpenApiSecuritySchemeHttp {
|
||||||
|
type: 'http';
|
||||||
|
description?: string;
|
||||||
|
scheme: string;
|
||||||
|
bearerFormat?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OAuth2 security scheme
|
||||||
|
*/
|
||||||
|
export interface IOpenApiSecuritySchemeOAuth2 {
|
||||||
|
type: 'oauth2';
|
||||||
|
description?: string;
|
||||||
|
flows: IOpenApiOAuthFlows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OpenID Connect security scheme
|
||||||
|
*/
|
||||||
|
export interface IOpenApiSecuritySchemeOpenIdConnect {
|
||||||
|
type: 'openIdConnect';
|
||||||
|
description?: string;
|
||||||
|
openIdConnectUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mutual TLS security scheme
|
||||||
|
*/
|
||||||
|
export interface IOpenApiSecuritySchemeMutualTLS {
|
||||||
|
type: 'mutualTLS';
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OAuth2 flows
|
||||||
|
*/
|
||||||
|
export interface IOpenApiOAuthFlows {
|
||||||
|
implicit?: IOpenApiOAuthFlow;
|
||||||
|
password?: IOpenApiOAuthFlow;
|
||||||
|
clientCredentials?: IOpenApiOAuthFlow;
|
||||||
|
authorizationCode?: IOpenApiOAuthFlow;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OAuth2 flow
|
||||||
|
*/
|
||||||
|
export interface IOpenApiOAuthFlow {
|
||||||
|
authorizationUrl?: string;
|
||||||
|
tokenUrl?: string;
|
||||||
|
refreshUrl?: string;
|
||||||
|
scopes: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Security requirement
|
||||||
|
*/
|
||||||
|
export type IOpenApiSecurityRequirement = Record<string, string[]>;
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Tag & External Docs Objects
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tag for grouping operations
|
||||||
|
*/
|
||||||
|
export interface IOpenApiTag {
|
||||||
|
/** Tag name */
|
||||||
|
name: string;
|
||||||
|
/** Tag description */
|
||||||
|
description?: string;
|
||||||
|
/** External documentation */
|
||||||
|
externalDocs?: IOpenApiExternalDocs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* External documentation
|
||||||
|
*/
|
||||||
|
export interface IOpenApiExternalDocs {
|
||||||
|
/** Documentation URL */
|
||||||
|
url: string;
|
||||||
|
/** Description */
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Generator Options
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options for OpenAPI spec generation
|
||||||
|
*/
|
||||||
|
export interface IOpenApiGeneratorOptions {
|
||||||
|
/** API info (required) */
|
||||||
|
info: IOpenApiInfo;
|
||||||
|
/** Server URLs */
|
||||||
|
servers?: IOpenApiServer[];
|
||||||
|
/** Security scheme definitions */
|
||||||
|
securitySchemes?: Record<string, IOpenApiSecurityScheme>;
|
||||||
|
/** Global tags */
|
||||||
|
tags?: IOpenApiTag[];
|
||||||
|
/** External documentation */
|
||||||
|
externalDocs?: IOpenApiExternalDocs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options for OpenAPI in SmartServe
|
||||||
|
*/
|
||||||
|
export interface IOpenApiOptions {
|
||||||
|
/** Enable OpenAPI (default: true when options provided) */
|
||||||
|
enabled?: boolean;
|
||||||
|
/** Path for OpenAPI JSON spec (default: /openapi.json) */
|
||||||
|
specPath?: string;
|
||||||
|
/** Path for Swagger UI (default: /docs) */
|
||||||
|
docsPath?: string;
|
||||||
|
/** Enable runtime validation (default: true) */
|
||||||
|
validation?: boolean;
|
||||||
|
/** API info */
|
||||||
|
info: {
|
||||||
|
title: string;
|
||||||
|
version: string;
|
||||||
|
description?: string;
|
||||||
|
termsOfService?: string;
|
||||||
|
contact?: IOpenApiContact;
|
||||||
|
license?: IOpenApiLicense;
|
||||||
|
};
|
||||||
|
/** Server URLs */
|
||||||
|
servers?: IOpenApiServer[];
|
||||||
|
/** Security schemes */
|
||||||
|
securitySchemes?: Record<string, IOpenApiSecurityScheme>;
|
||||||
|
/** Global tags */
|
||||||
|
tags?: IOpenApiTag[];
|
||||||
|
}
|
||||||
252
ts/openapi/openapi.validator.ts
Normal file
252
ts/openapi/openapi.validator.ts
Normal 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;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -3,8 +3,9 @@ import * as path from 'path';
|
|||||||
import * as http from 'http';
|
import * as http from 'http';
|
||||||
import * as https from 'https';
|
import * as https from 'https';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
|
import * as zlib from 'zlib';
|
||||||
|
|
||||||
export { path, http, https, fs };
|
export { path, http, https, fs, zlib };
|
||||||
|
|
||||||
// @push.rocks scope
|
// @push.rocks scope
|
||||||
import * as smartpath from '@push.rocks/smartpath';
|
import * as smartpath from '@push.rocks/smartpath';
|
||||||
|
|||||||
@@ -1,2 +1,10 @@
|
|||||||
export { getMimeType, isTextMimeType } from './utils.mime.js';
|
export { getMimeType, isTextMimeType } from './utils.mime.js';
|
||||||
export { generateETag, generateStrongETag, matchesETag } from './utils.etag.js';
|
export { generateETag, generateStrongETag, matchesETag } from './utils.etag.js';
|
||||||
|
export {
|
||||||
|
parseAcceptEncoding,
|
||||||
|
selectEncoding,
|
||||||
|
isCompressible,
|
||||||
|
getDefaultCompressibleTypes,
|
||||||
|
type TCompressionAlgorithm,
|
||||||
|
type IEncodingPreference,
|
||||||
|
} from './utils.encoding.js';
|
||||||
|
|||||||
166
ts/utils/utils.encoding.ts
Normal file
166
ts/utils/utils.encoding.ts
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
/**
|
||||||
|
* HTTP encoding utilities for Accept-Encoding parsing and compression algorithm selection
|
||||||
|
*/
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Types
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supported compression algorithms
|
||||||
|
*/
|
||||||
|
export type TCompressionAlgorithm = 'br' | 'gzip' | 'deflate' | 'identity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parsed encoding preference with quality value
|
||||||
|
*/
|
||||||
|
export interface IEncodingPreference {
|
||||||
|
encoding: string;
|
||||||
|
q: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Accept-Encoding Parsing
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse Accept-Encoding header with quality values
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* parseAcceptEncoding('gzip, deflate, br;q=1.0, identity;q=0.5')
|
||||||
|
* // Returns: [{ encoding: 'br', q: 1.0 }, { encoding: 'gzip', q: 1.0 }, { encoding: 'deflate', q: 1.0 }, { encoding: 'identity', q: 0.5 }]
|
||||||
|
*/
|
||||||
|
export function parseAcceptEncoding(header: string | null): IEncodingPreference[] {
|
||||||
|
if (!header) {
|
||||||
|
return [{ encoding: 'identity', q: 1.0 }];
|
||||||
|
}
|
||||||
|
|
||||||
|
const preferences: IEncodingPreference[] = [];
|
||||||
|
|
||||||
|
for (const part of header.split(',')) {
|
||||||
|
const trimmed = part.trim();
|
||||||
|
if (!trimmed) continue;
|
||||||
|
|
||||||
|
const [encoding, ...params] = trimmed.split(';');
|
||||||
|
let q = 1.0;
|
||||||
|
|
||||||
|
for (const param of params) {
|
||||||
|
const [key, value] = param.trim().split('=');
|
||||||
|
if (key?.toLowerCase() === 'q' && value) {
|
||||||
|
q = parseFloat(value) || 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (q > 0) {
|
||||||
|
preferences.push({ encoding: encoding.trim().toLowerCase(), q });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by quality (descending)
|
||||||
|
return preferences.sort((a, b) => b.q - a.q);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select best encoding from client preferences and server support
|
||||||
|
*/
|
||||||
|
export function selectEncoding(
|
||||||
|
acceptEncoding: string | null,
|
||||||
|
supported: TCompressionAlgorithm[]
|
||||||
|
): TCompressionAlgorithm {
|
||||||
|
const preferences = parseAcceptEncoding(acceptEncoding);
|
||||||
|
|
||||||
|
for (const pref of preferences) {
|
||||||
|
if (pref.encoding === '*') {
|
||||||
|
// Wildcard - use first supported (brotli preferred)
|
||||||
|
return supported[0] ?? 'identity';
|
||||||
|
}
|
||||||
|
if (supported.includes(pref.encoding as TCompressionAlgorithm)) {
|
||||||
|
return pref.encoding as TCompressionAlgorithm;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'identity';
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Compressibility Check
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default MIME type patterns that should be compressed
|
||||||
|
*/
|
||||||
|
const DEFAULT_COMPRESSIBLE_TYPES = [
|
||||||
|
'text/',
|
||||||
|
'application/json',
|
||||||
|
'application/javascript',
|
||||||
|
'application/xml',
|
||||||
|
'application/xhtml+xml',
|
||||||
|
'application/rss+xml',
|
||||||
|
'application/atom+xml',
|
||||||
|
'application/x-javascript',
|
||||||
|
'application/ld+json',
|
||||||
|
'application/manifest+json',
|
||||||
|
'application/vnd.api+json',
|
||||||
|
'image/svg+xml',
|
||||||
|
'image/x-icon',
|
||||||
|
'font/ttf',
|
||||||
|
'font/otf',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MIME types that should never be compressed (already compressed)
|
||||||
|
*/
|
||||||
|
const NEVER_COMPRESS = [
|
||||||
|
'image/png',
|
||||||
|
'image/jpeg',
|
||||||
|
'image/gif',
|
||||||
|
'image/webp',
|
||||||
|
'image/avif',
|
||||||
|
'video/',
|
||||||
|
'audio/',
|
||||||
|
'application/zip',
|
||||||
|
'application/gzip',
|
||||||
|
'application/x-gzip',
|
||||||
|
'application/x-bzip2',
|
||||||
|
'application/x-7z-compressed',
|
||||||
|
'application/x-rar-compressed',
|
||||||
|
'application/wasm',
|
||||||
|
'font/woff',
|
||||||
|
'font/woff2',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if content type should be compressed
|
||||||
|
*/
|
||||||
|
export function isCompressible(
|
||||||
|
contentType: string | null,
|
||||||
|
customTypes?: string[]
|
||||||
|
): boolean {
|
||||||
|
if (!contentType) return false;
|
||||||
|
|
||||||
|
const lowerType = contentType.toLowerCase().split(';')[0].trim();
|
||||||
|
|
||||||
|
// Never compress already-compressed formats
|
||||||
|
for (const pattern of NEVER_COMPRESS) {
|
||||||
|
if (pattern.endsWith('/')) {
|
||||||
|
if (lowerType.startsWith(pattern)) return false;
|
||||||
|
} else {
|
||||||
|
if (lowerType === pattern) return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const types = customTypes ?? DEFAULT_COMPRESSIBLE_TYPES;
|
||||||
|
|
||||||
|
return types.some(pattern =>
|
||||||
|
pattern.endsWith('/')
|
||||||
|
? lowerType.startsWith(pattern)
|
||||||
|
: lowerType.includes(pattern)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get default compressible types (for configuration reference)
|
||||||
|
*/
|
||||||
|
export function getDefaultCompressibleTypes(): string[] {
|
||||||
|
return [...DEFAULT_COMPRESSIBLE_TYPES];
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user