Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 84e7307f15 | |||
| d645311208 | |||
| 697b7e92d7 | |||
| 4a17bf39c6 | |||
| 39f0cdf380 | |||
| cc3e335112 |
33
changelog.md
33
changelog.md
@@ -1,5 +1,38 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2025-12-20 - 2.0.1 - fix(readme)
|
||||||
|
update README: rework features, add OpenAPI/Swagger, compression, request validation, examples, and runtime stats
|
||||||
|
|
||||||
|
- Reworked Features section into a markdown table and added items for OpenAPI/Swagger, Request Validation, and Auto Compression.
|
||||||
|
- Updated Quick Start example (removed Guard import) and added a Table of Contents.
|
||||||
|
- Expanded examples with a manual route handling snippet and guidance to bypass decorator routing.
|
||||||
|
- Added runtime example fields: instance.hostname and new stats() usage (uptime, requestsTotal, requestsActive).
|
||||||
|
- Minor legal/formatting updates: fixed LICENSE link casing/path and clarified trademark wording.
|
||||||
|
|
||||||
|
## 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)
|
## 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
|
Improve compression implementation (buffering and threshold), add Deno brotli support, add compression tests and dynamic route API
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@push.rocks/smartserve",
|
"name": "@push.rocks/smartserve",
|
||||||
"version": "1.3.0",
|
"version": "2.0.1",
|
||||||
"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": {
|
||||||
@@ -24,7 +24,8 @@
|
|||||||
"@types/ws": "^8.18.1"
|
"@types/ws": "^8.18.1"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@api.global/typedrequest": "^3.1.11",
|
"@api.global/typedrequest": "^3.2.5",
|
||||||
|
"@cfworker/json-schema": "^4.1.1",
|
||||||
"@push.rocks/lik": "^6.2.2",
|
"@push.rocks/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",
|
||||||
|
|||||||
28
pnpm-lock.yaml
generated
28
pnpm-lock.yaml
generated
@@ -9,8 +9,11 @@ importers:
|
|||||||
.:
|
.:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@api.global/typedrequest':
|
'@api.global/typedrequest':
|
||||||
specifier: ^3.1.11
|
specifier: ^3.2.5
|
||||||
version: 3.1.11
|
version: 3.2.5
|
||||||
|
'@cfworker/json-schema':
|
||||||
|
specifier: ^4.1.1
|
||||||
|
version: 4.1.1
|
||||||
'@push.rocks/lik':
|
'@push.rocks/lik':
|
||||||
specifier: ^6.2.2
|
specifier: ^6.2.2
|
||||||
version: 6.2.2
|
version: 6.2.2
|
||||||
@@ -57,8 +60,8 @@ packages:
|
|||||||
'@api.global/typedrequest-interfaces@3.0.19':
|
'@api.global/typedrequest-interfaces@3.0.19':
|
||||||
resolution: {integrity: sha512-uuHUXJeOy/inWSDrwD0Cwax2rovpxYllDhM2RWh+6mVpQuNmZ3uw6IVg6dA2G1rOe24Ebs+Y9SzEogo+jYN7vw==}
|
resolution: {integrity: sha512-uuHUXJeOy/inWSDrwD0Cwax2rovpxYllDhM2RWh+6mVpQuNmZ3uw6IVg6dA2G1rOe24Ebs+Y9SzEogo+jYN7vw==}
|
||||||
|
|
||||||
'@api.global/typedrequest@3.1.11':
|
'@api.global/typedrequest@3.2.5':
|
||||||
resolution: {integrity: sha512-j8EO3na0WMw8pFkAfEaEui2a4TaAL1G/dv1CYl8LEPXckSKkl1BCAS1kFOW2xuI9pwZkmSqlo3xpQ3KmkmHaGQ==}
|
resolution: {integrity: sha512-LM/sUTuYnU5xY4gNZrN6ERMiKr+SpDZuSxJkAZz1YazC7ymGfo6uQ8sCnN8eNNQNFqIOkC+BtfYRayfbGwYLLg==}
|
||||||
|
|
||||||
'@api.global/typedserver@3.0.80':
|
'@api.global/typedserver@3.0.80':
|
||||||
resolution: {integrity: sha512-dcp0oXsjBL+XdFg1wUUP08uJQid5bQ0Yv3V3Y3lnI2QCbat0FU+Tsb0TZRnZ4+P150Vj/ITBqJUgDzFsF34grA==}
|
resolution: {integrity: sha512-dcp0oXsjBL+XdFg1wUUP08uJQid5bQ0Yv3V3Y3lnI2QCbat0FU+Tsb0TZRnZ4+P150Vj/ITBqJUgDzFsF34grA==}
|
||||||
@@ -240,6 +243,9 @@ packages:
|
|||||||
'@borewit/text-codec@0.1.1':
|
'@borewit/text-codec@0.1.1':
|
||||||
resolution: {integrity: sha512-5L/uBxmjaCIX5h8Z+uu+kA9BQLkc/Wl06UGR5ajNRxu+/XjonB5i8JpgFMrPj3LXTCPA0pv8yxUvbUi+QthGGA==}
|
resolution: {integrity: sha512-5L/uBxmjaCIX5h8Z+uu+kA9BQLkc/Wl06UGR5ajNRxu+/XjonB5i8JpgFMrPj3LXTCPA0pv8yxUvbUi+QthGGA==}
|
||||||
|
|
||||||
|
'@cfworker/json-schema@4.1.1':
|
||||||
|
resolution: {integrity: sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==}
|
||||||
|
|
||||||
'@cloudflare/workers-types@4.20251128.0':
|
'@cloudflare/workers-types@4.20251128.0':
|
||||||
resolution: {integrity: sha512-gQxQvxLRsFb+mDlaBKGoJwEHWt+ox9telZZEuRMbNUAD6v78XYqZepTI4yPDdKhkRTlqYcDqDhIdAI3HrsGk7w==}
|
resolution: {integrity: sha512-gQxQvxLRsFb+mDlaBKGoJwEHWt+ox9telZZEuRMbNUAD6v78XYqZepTI4yPDdKhkRTlqYcDqDhIdAI3HrsGk7w==}
|
||||||
|
|
||||||
@@ -4276,7 +4282,7 @@ snapshots:
|
|||||||
|
|
||||||
'@api.global/typedrequest-interfaces@3.0.19': {}
|
'@api.global/typedrequest-interfaces@3.0.19': {}
|
||||||
|
|
||||||
'@api.global/typedrequest@3.1.11':
|
'@api.global/typedrequest@3.2.5':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@api.global/typedrequest-interfaces': 3.0.19
|
'@api.global/typedrequest-interfaces': 3.0.19
|
||||||
'@push.rocks/isounique': 1.0.5
|
'@push.rocks/isounique': 1.0.5
|
||||||
@@ -4290,7 +4296,7 @@ snapshots:
|
|||||||
|
|
||||||
'@api.global/typedserver@3.0.80':
|
'@api.global/typedserver@3.0.80':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@api.global/typedrequest': 3.1.11
|
'@api.global/typedrequest': 3.2.5
|
||||||
'@api.global/typedrequest-interfaces': 3.0.19
|
'@api.global/typedrequest-interfaces': 3.0.19
|
||||||
'@api.global/typedsocket': 3.0.1
|
'@api.global/typedsocket': 3.0.1
|
||||||
'@cloudflare/workers-types': 4.20251128.0
|
'@cloudflare/workers-types': 4.20251128.0
|
||||||
@@ -4337,7 +4343,7 @@ snapshots:
|
|||||||
|
|
||||||
'@api.global/typedsocket@3.0.1':
|
'@api.global/typedsocket@3.0.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@api.global/typedrequest': 3.1.11
|
'@api.global/typedrequest': 3.2.5
|
||||||
'@api.global/typedrequest-interfaces': 3.0.19
|
'@api.global/typedrequest-interfaces': 3.0.19
|
||||||
'@push.rocks/isohash': 2.0.1
|
'@push.rocks/isohash': 2.0.1
|
||||||
'@push.rocks/smartjson': 5.2.0
|
'@push.rocks/smartjson': 5.2.0
|
||||||
@@ -4844,6 +4850,8 @@ snapshots:
|
|||||||
|
|
||||||
'@borewit/text-codec@0.1.1': {}
|
'@borewit/text-codec@0.1.1': {}
|
||||||
|
|
||||||
|
'@cfworker/json-schema@4.1.1': {}
|
||||||
|
|
||||||
'@cloudflare/workers-types@4.20251128.0': {}
|
'@cloudflare/workers-types@4.20251128.0': {}
|
||||||
|
|
||||||
'@colors/colors@1.6.0': {}
|
'@colors/colors@1.6.0': {}
|
||||||
@@ -4860,14 +4868,14 @@ snapshots:
|
|||||||
|
|
||||||
'@design.estate/dees-comms@1.0.27':
|
'@design.estate/dees-comms@1.0.27':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@api.global/typedrequest': 3.1.11
|
'@api.global/typedrequest': 3.2.5
|
||||||
'@api.global/typedrequest-interfaces': 3.0.19
|
'@api.global/typedrequest-interfaces': 3.0.19
|
||||||
'@push.rocks/smartdelay': 3.0.5
|
'@push.rocks/smartdelay': 3.0.5
|
||||||
broadcast-channel: 7.2.0
|
broadcast-channel: 7.2.0
|
||||||
|
|
||||||
'@design.estate/dees-domtools@2.3.6':
|
'@design.estate/dees-domtools@2.3.6':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@api.global/typedrequest': 3.1.11
|
'@api.global/typedrequest': 3.2.5
|
||||||
'@design.estate/dees-comms': 1.0.27
|
'@design.estate/dees-comms': 1.0.27
|
||||||
'@push.rocks/lik': 6.2.2
|
'@push.rocks/lik': 6.2.2
|
||||||
'@push.rocks/smartdelay': 3.0.5
|
'@push.rocks/smartdelay': 3.0.5
|
||||||
@@ -5540,7 +5548,7 @@ snapshots:
|
|||||||
|
|
||||||
'@push.rocks/qenv@6.1.3':
|
'@push.rocks/qenv@6.1.3':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@api.global/typedrequest': 3.1.11
|
'@api.global/typedrequest': 3.2.5
|
||||||
'@configvault.io/interfaces': 1.0.17
|
'@configvault.io/interfaces': 1.0.17
|
||||||
'@push.rocks/smartfile': 11.2.7
|
'@push.rocks/smartfile': 11.2.7
|
||||||
'@push.rocks/smartlog': 3.1.10
|
'@push.rocks/smartlog': 3.1.10
|
||||||
|
|||||||
555
readme.md
555
readme.md
@@ -1,6 +1,6 @@
|
|||||||
# @push.rocks/smartserve
|
# @push.rocks/smartserve
|
||||||
|
|
||||||
A cross-platform HTTP server module for Node.js, Deno, and Bun with decorator-based routing, WebSocket support, static file serving, and WebDAV protocol. 🚀
|
A blazing-fast, cross-platform HTTP server for Node.js, Deno, and Bun with decorator-based routing, OpenAPI/Swagger integration, automatic compression, WebSocket support, static file serving, and WebDAV protocol. 🚀
|
||||||
|
|
||||||
## Issue Reporting and Security
|
## Issue Reporting and Security
|
||||||
|
|
||||||
@@ -16,21 +16,25 @@ pnpm add @push.rocks/smartserve
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
✨ **Cross-Platform** - Works seamlessly on Node.js, Deno, and Bun
|
| Feature | Description |
|
||||||
🎯 **Decorator-Based Routing** - Clean, expressive `@Route`, `@Get`, `@Post` decorators
|
|---------|-------------|
|
||||||
🛡️ **Guards & Interceptors** - Built-in `@Guard`, `@Transform`, `@Intercept` for auth and transformation
|
| ✨ **Cross-Platform** | Works seamlessly on Node.js, Deno, and Bun with zero config |
|
||||||
📁 **Static File Server** - Streaming, ETags, Range requests, directory listing
|
| 🎯 **Decorator-Based Routing** | Clean, expressive `@Route`, `@Get`, `@Post` decorators |
|
||||||
🌐 **WebDAV Support** - Mount as network drive with full RFC 4918 compliance
|
| 📖 **OpenAPI/Swagger** | Auto-generate OpenAPI 3.1 specs with built-in Swagger UI & ReDoc |
|
||||||
🔌 **WebSocket Ready** - Native WebSocket support across all runtimes
|
| ✅ **Request Validation** | Validate requests against JSON Schema with automatic coercion |
|
||||||
⚡ **Zero Overhead** - Native Web Standards API (Request/Response) on Deno/Bun
|
| 🗜️ **Auto Compression** | Brotli/gzip compression with smart content detection |
|
||||||
🔒 **HTTPS/TLS** - Built-in TLS support with certificate configuration
|
| 🛡️ **Guards & Interceptors** | Built-in `@Guard`, `@Transform`, `@Intercept` for auth & transformation |
|
||||||
|
| 📁 **Static File Server** | Streaming, ETags, Range requests, directory listing, pre-compressed files |
|
||||||
|
| 🌐 **WebDAV Support** | Mount as network drive with full RFC 4918 compliance |
|
||||||
|
| 🔌 **WebSocket Ready** | Native WebSocket support with TypedRouter for type-safe RPC |
|
||||||
|
| ⚡ **Zero Overhead** | Native Web Standards API (Request/Response) on Deno/Bun |
|
||||||
|
| 🔒 **HTTPS/TLS** | Built-in TLS support with certificate configuration |
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { SmartServe, Route, Get, Post, Guard, type IRequestContext } from '@push.rocks/smartserve';
|
import { SmartServe, Route, Get, Post, type IRequestContext } from '@push.rocks/smartserve';
|
||||||
|
|
||||||
// Define a controller with decorators
|
|
||||||
@Route('/api')
|
@Route('/api')
|
||||||
class UserController {
|
class UserController {
|
||||||
@Get('/hello')
|
@Get('/hello')
|
||||||
@@ -44,12 +48,12 @@ 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 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create and start server
|
|
||||||
const server = new SmartServe({ port: 3000 });
|
const server = new SmartServe({ port: 3000 });
|
||||||
server.register(UserController);
|
server.register(UserController);
|
||||||
await server.start();
|
await server.start();
|
||||||
@@ -57,6 +61,28 @@ await server.start();
|
|||||||
console.log('🚀 Server running at http://localhost:3000');
|
console.log('🚀 Server running at http://localhost:3000');
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
- [Decorators](#decorators)
|
||||||
|
- [Route Decorators](#route-decorators)
|
||||||
|
- [Guards](#guards-authenticationauthorization)
|
||||||
|
- [Transforms](#transforms-response-modification)
|
||||||
|
- [Intercept](#intercept-full-control)
|
||||||
|
- [OpenAPI & Swagger](#openapi--swagger)
|
||||||
|
- [Documenting APIs](#documenting-apis)
|
||||||
|
- [Request Validation](#request-validation)
|
||||||
|
- [Swagger UI & ReDoc](#swagger-ui--redoc)
|
||||||
|
- [Compression](#compression)
|
||||||
|
- [Static File Server](#static-file-server)
|
||||||
|
- [WebDAV Support](#webdav-support)
|
||||||
|
- [WebSocket Support](#websocket-support)
|
||||||
|
- [HTTPS/TLS](#httpstls)
|
||||||
|
- [Error Handling](#error-handling)
|
||||||
|
- [Request Context](#request-context)
|
||||||
|
- [Custom Request Handler](#custom-request-handler)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Decorators
|
## Decorators
|
||||||
|
|
||||||
### Route Decorators
|
### Route Decorators
|
||||||
@@ -67,26 +93,36 @@ import { Route, Get, Post, Put, Delete, Patch, All } from '@push.rocks/smartserv
|
|||||||
@Route('/api/v1') // Base path for all routes in this controller
|
@Route('/api/v1') // Base path for all routes in this controller
|
||||||
class ApiController {
|
class ApiController {
|
||||||
@Get('/items') // GET /api/v1/items
|
@Get('/items') // GET /api/v1/items
|
||||||
listItems() { ... }
|
listItems() {
|
||||||
|
return [{ id: 1, name: 'Item 1' }];
|
||||||
|
}
|
||||||
|
|
||||||
@Get('/items/:id') // GET /api/v1/items/:id - path parameters
|
@Get('/items/:id') // GET /api/v1/items/:id
|
||||||
getItem(ctx: IRequestContext) {
|
getItem(ctx: IRequestContext) {
|
||||||
return { id: ctx.params.id };
|
return { id: ctx.params.id };
|
||||||
}
|
}
|
||||||
|
|
||||||
@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
|
||||||
updateItem(ctx: IRequestContext) { ... }
|
async updateItem(ctx: IRequestContext) {
|
||||||
|
const body = await ctx.json();
|
||||||
|
return { updated: ctx.params.id, ...body };
|
||||||
|
}
|
||||||
|
|
||||||
@Delete('/items/:id') // DELETE /api/v1/items/:id
|
@Delete('/items/:id') // DELETE /api/v1/items/:id
|
||||||
deleteItem(ctx: IRequestContext) { ... }
|
deleteItem(ctx: IRequestContext) {
|
||||||
|
return { deleted: ctx.params.id };
|
||||||
|
}
|
||||||
|
|
||||||
@All('/webhook') // Matches ALL HTTP methods
|
@All('/webhook') // Matches ALL HTTP methods
|
||||||
handleWebhook(ctx: IRequestContext) { ... }
|
handleWebhook(ctx: IRequestContext) {
|
||||||
|
return { method: ctx.method };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -95,9 +131,9 @@ class ApiController {
|
|||||||
Guards protect routes by returning `true` (allow) or `false` (reject with 403):
|
Guards protect routes by returning `true` (allow) or `false` (reject with 403):
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { Route, Get, Guard, type IRequestContext } from '@push.rocks/smartserve';
|
import { Route, Get, Guard, hasBearerToken, type IRequestContext } from '@push.rocks/smartserve';
|
||||||
|
|
||||||
// Guard function
|
// Custom guard function
|
||||||
const isAuthenticated = (ctx: IRequestContext) => {
|
const isAuthenticated = (ctx: IRequestContext) => {
|
||||||
return ctx.headers.has('Authorization');
|
return ctx.headers.has('Authorization');
|
||||||
};
|
};
|
||||||
@@ -106,7 +142,6 @@ const isAdmin = (ctx: IRequestContext) => {
|
|||||||
return ctx.headers.get('X-Role') === 'admin';
|
return ctx.headers.get('X-Role') === 'admin';
|
||||||
};
|
};
|
||||||
|
|
||||||
// Apply guard to entire controller
|
|
||||||
@Route('/admin')
|
@Route('/admin')
|
||||||
@Guard(isAuthenticated)
|
@Guard(isAuthenticated)
|
||||||
@Guard(isAdmin) // Multiple guards - all must pass
|
@Guard(isAdmin) // Multiple guards - all must pass
|
||||||
@@ -123,6 +158,16 @@ class AdminController {
|
|||||||
return { level: 'super-secret' };
|
return { level: 'super-secret' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Built-in utility guards
|
||||||
|
@Route('/protected')
|
||||||
|
@Guard(hasBearerToken()) // Requires Authorization: Bearer <token>
|
||||||
|
class ProtectedController {
|
||||||
|
@Get('/data')
|
||||||
|
getData() {
|
||||||
|
return { protected: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Transforms (Response Modification)
|
### Transforms (Response Modification)
|
||||||
@@ -130,29 +175,24 @@ class AdminController {
|
|||||||
Transforms modify the response before sending:
|
Transforms modify the response before sending:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { Route, Get, Transform } from '@push.rocks/smartserve';
|
import { Route, Get, Transform, wrapSuccess, addTimestamp } from '@push.rocks/smartserve';
|
||||||
|
|
||||||
// Transform function
|
|
||||||
const wrapResponse = <T>(data: T) => ({
|
|
||||||
success: true,
|
|
||||||
data,
|
|
||||||
timestamp: Date.now(),
|
|
||||||
});
|
|
||||||
|
|
||||||
|
// Custom transform
|
||||||
const addVersion = <T extends object>(data: T) => ({
|
const addVersion = <T extends object>(data: T) => ({
|
||||||
...data,
|
...data,
|
||||||
apiVersion: '2.0',
|
apiVersion: '2.0',
|
||||||
});
|
});
|
||||||
|
|
||||||
@Route('/api')
|
@Route('/api')
|
||||||
@Transform(wrapResponse) // Applied to all routes in controller
|
@Transform(wrapSuccess) // Built-in: wraps in { success: true, data: ... }
|
||||||
class ApiController {
|
class ApiController {
|
||||||
@Get('/info')
|
@Get('/info')
|
||||||
@Transform(addVersion) // Stacks with class transform
|
@Transform(addTimestamp) // Built-in: adds timestamp field
|
||||||
|
@Transform(addVersion) // Transforms stack
|
||||||
getInfo() {
|
getInfo() {
|
||||||
return { name: 'MyAPI' };
|
return { name: 'MyAPI' };
|
||||||
}
|
}
|
||||||
// Response: { success: true, data: { name: 'MyAPI', apiVersion: '2.0' }, timestamp: ... }
|
// Response: { success: true, data: { name: 'MyAPI', timestamp: '...', apiVersion: '2.0' } }
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -165,16 +205,26 @@ import { Route, Get, Intercept, type IRequestContext } from '@push.rocks/smartse
|
|||||||
|
|
||||||
@Route('/api')
|
@Route('/api')
|
||||||
@Intercept({
|
@Intercept({
|
||||||
// Runs before handler
|
// Runs BEFORE handler
|
||||||
request: async (ctx) => {
|
request: async (ctx) => {
|
||||||
console.log(`${ctx.method} ${ctx.path}`);
|
console.log(`📥 ${ctx.method} ${ctx.path}`);
|
||||||
|
|
||||||
// Return Response to short-circuit
|
// Return Response to short-circuit
|
||||||
// Return modified context to continue
|
if (ctx.headers.get('X-Block') === 'true') {
|
||||||
// Return void to continue with original
|
return new Response('Blocked', { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add data to state for handler access
|
||||||
|
ctx.state.requestTime = Date.now();
|
||||||
|
|
||||||
|
// Return void to continue with original context
|
||||||
},
|
},
|
||||||
// Runs after handler
|
|
||||||
|
// Runs AFTER handler
|
||||||
response: async (data, ctx) => {
|
response: async (data, ctx) => {
|
||||||
return { ...data, processedAt: new Date().toISOString() };
|
const duration = Date.now() - (ctx.state.requestTime as number);
|
||||||
|
console.log(`📤 Response in ${duration}ms`);
|
||||||
|
return { ...data, processedIn: `${duration}ms` };
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
class LoggedController {
|
class LoggedController {
|
||||||
@@ -185,9 +235,242 @@ class LoggedController {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## OpenAPI & Swagger
|
||||||
|
|
||||||
|
SmartServe includes first-class OpenAPI 3.1 support with automatic spec generation, Swagger UI, ReDoc, and request validation.
|
||||||
|
|
||||||
|
### Documenting APIs
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {
|
||||||
|
SmartServe,
|
||||||
|
Route,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
ApiOperation,
|
||||||
|
ApiParam,
|
||||||
|
ApiQuery,
|
||||||
|
ApiRequestBody,
|
||||||
|
ApiResponseBody,
|
||||||
|
ApiTag,
|
||||||
|
ApiSecurity,
|
||||||
|
type IRequestContext,
|
||||||
|
} from '@push.rocks/smartserve';
|
||||||
|
|
||||||
|
// Define JSON Schemas for validation
|
||||||
|
const UserSchema = {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: { type: 'string', format: 'uuid' },
|
||||||
|
name: { type: 'string', minLength: 1 },
|
||||||
|
email: { type: 'string', format: 'email' },
|
||||||
|
},
|
||||||
|
required: ['id', 'name', 'email'],
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const CreateUserSchema = {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
name: { type: 'string', minLength: 1 },
|
||||||
|
email: { type: 'string', format: 'email' },
|
||||||
|
},
|
||||||
|
required: ['name', 'email'],
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
@Route('/api/users')
|
||||||
|
@ApiTag('Users')
|
||||||
|
class UserController {
|
||||||
|
@Get('/')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'List all users',
|
||||||
|
description: 'Returns a paginated list of users',
|
||||||
|
})
|
||||||
|
@ApiQuery('page', {
|
||||||
|
description: 'Page number',
|
||||||
|
schema: { type: 'integer', minimum: 1, default: 1 },
|
||||||
|
})
|
||||||
|
@ApiQuery('limit', {
|
||||||
|
description: 'Items per page',
|
||||||
|
schema: { type: 'integer', minimum: 1, maximum: 100, default: 20 },
|
||||||
|
})
|
||||||
|
@ApiResponseBody(200, {
|
||||||
|
description: 'List of users',
|
||||||
|
schema: { type: 'array', items: UserSchema },
|
||||||
|
})
|
||||||
|
listUsers(ctx: IRequestContext) {
|
||||||
|
const page = ctx.query.page ?? '1';
|
||||||
|
const limit = ctx.query.limit ?? '20';
|
||||||
|
return { users: [], page: parseInt(page), limit: parseInt(limit) };
|
||||||
|
}
|
||||||
|
|
||||||
|
@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: 'John Doe', email: 'john@example.com' };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('/')
|
||||||
|
@ApiOperation({ summary: 'Create a new user' })
|
||||||
|
@ApiRequestBody({
|
||||||
|
description: 'User data',
|
||||||
|
schema: CreateUserSchema,
|
||||||
|
})
|
||||||
|
@ApiResponseBody(201, { description: 'User created', schema: UserSchema })
|
||||||
|
@ApiResponseBody(400, { description: 'Validation error' })
|
||||||
|
@ApiSecurity('bearerAuth')
|
||||||
|
async createUser(ctx: IRequestContext<{ name: string; email: string }>) {
|
||||||
|
const body = await ctx.json();
|
||||||
|
return { id: 'new-uuid', name: body.name, email: body.email };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Request Validation
|
||||||
|
|
||||||
|
When you define `@ApiRequestBody`, `@ApiParam`, or `@ApiQuery` with schemas, SmartServe **automatically validates** incoming requests:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const server = new SmartServe({
|
||||||
|
port: 3000,
|
||||||
|
openapi: {
|
||||||
|
enabled: true,
|
||||||
|
info: {
|
||||||
|
title: 'My API',
|
||||||
|
version: '1.0.0',
|
||||||
|
description: 'A well-documented API',
|
||||||
|
},
|
||||||
|
validate: true, // 🔥 Enable automatic request validation
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
server.register(UserController);
|
||||||
|
await server.start();
|
||||||
|
|
||||||
|
// Invalid request → 400 Bad Request with details
|
||||||
|
// POST /api/users with { "name": "" }
|
||||||
|
// Response: { "error": "Validation failed", "source": "body", "details": [...] }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Automatic Type Coercion**: Query and path parameters are automatically coerced to their schema types:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
@Get('/items')
|
||||||
|
@ApiQuery('page', { schema: { type: 'integer', default: 1 } })
|
||||||
|
@ApiQuery('active', { schema: { type: 'boolean' } })
|
||||||
|
listItems(ctx: IRequestContext) {
|
||||||
|
// ctx.query.page is coerced to number (1)
|
||||||
|
// ctx.query.active is coerced to boolean
|
||||||
|
return { page: ctx.query.page, active: ctx.query.active };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Swagger UI & ReDoc
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const server = new SmartServe({
|
||||||
|
port: 3000,
|
||||||
|
openapi: {
|
||||||
|
enabled: true,
|
||||||
|
info: {
|
||||||
|
title: 'My Awesome API',
|
||||||
|
version: '2.0.0',
|
||||||
|
description: 'API documentation with interactive testing',
|
||||||
|
contact: {
|
||||||
|
name: 'API Support',
|
||||||
|
email: 'support@example.com',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
servers: [
|
||||||
|
{ url: 'http://localhost:3000', description: 'Development' },
|
||||||
|
{ url: 'https://api.example.com', description: 'Production' },
|
||||||
|
],
|
||||||
|
securitySchemes: {
|
||||||
|
bearerAuth: {
|
||||||
|
type: 'http',
|
||||||
|
scheme: 'bearer',
|
||||||
|
bearerFormat: 'JWT',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// Customize paths
|
||||||
|
specPath: '/openapi.json', // Default: /openapi.json
|
||||||
|
swaggerPath: '/docs', // Default: /docs
|
||||||
|
redocPath: '/redoc', // Default: /redoc
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await server.start();
|
||||||
|
|
||||||
|
// 📖 Swagger UI: http://localhost:3000/docs
|
||||||
|
// 📕 ReDoc: http://localhost:3000/redoc
|
||||||
|
// 📄 OpenAPI: http://localhost:3000/openapi.json
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Compression
|
||||||
|
|
||||||
|
SmartServe automatically compresses responses using Brotli or gzip based on client support:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const server = new SmartServe({
|
||||||
|
port: 3000,
|
||||||
|
compression: {
|
||||||
|
enabled: true, // Default: true
|
||||||
|
threshold: 1024, // Min bytes to compress (default: 1KB)
|
||||||
|
level: 6, // Compression level 1-11 for br, 1-9 for gzip
|
||||||
|
preferBrotli: true, // Prefer Brotli over gzip
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Per-Route Compression Control
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Route, Get, Compress, NoCompress } from '@push.rocks/smartserve';
|
||||||
|
|
||||||
|
@Route('/api')
|
||||||
|
class ApiController {
|
||||||
|
@Get('/large-data')
|
||||||
|
@Compress({ level: 9 }) // Force high compression
|
||||||
|
getLargeData() {
|
||||||
|
return { data: '...massive payload...' };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('/already-compressed')
|
||||||
|
@NoCompress() // Skip compression (e.g., for pre-compressed content)
|
||||||
|
getCompressed() {
|
||||||
|
return someCompressedBuffer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pre-Compressed Static Files
|
||||||
|
|
||||||
|
Serve `.br` or `.gz` files automatically when available:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const server = new SmartServe({
|
||||||
|
port: 3000,
|
||||||
|
static: {
|
||||||
|
root: './dist',
|
||||||
|
precompressed: true, // Serve main.js.br instead of main.js
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Static File Server
|
## Static File Server
|
||||||
|
|
||||||
Serve static files with streaming, ETags, and directory listing:
|
Serve static files with streaming, ETags, Range requests, and directory listing:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
const server = new SmartServe({
|
const server = new SmartServe({
|
||||||
@@ -195,29 +478,32 @@ const server = new SmartServe({
|
|||||||
static: {
|
static: {
|
||||||
root: './public',
|
root: './public',
|
||||||
index: ['index.html', 'index.htm'],
|
index: ['index.html', 'index.htm'],
|
||||||
dotFiles: 'deny', // 'allow' | 'deny' | 'ignore'
|
dotFiles: 'deny', // 'allow' | 'deny' | 'ignore'
|
||||||
etag: true, // Generate ETags
|
etag: true, // Generate ETags for caching
|
||||||
lastModified: true, // Add Last-Modified header
|
lastModified: true, // Add Last-Modified header
|
||||||
cacheControl: 'max-age=3600',
|
cacheControl: 'max-age=3600', // Or function: (path) => 'max-age=...'
|
||||||
extensions: ['.html'], // Try these extensions
|
extensions: ['.html'], // Try these extensions for extensionless URLs
|
||||||
|
precompressed: true, // Serve .br/.gz files when available
|
||||||
directoryListing: {
|
directoryListing: {
|
||||||
showHidden: false,
|
showHidden: false,
|
||||||
sortBy: 'name', // 'name' | 'size' | 'modified'
|
sortBy: 'name', // 'name' | 'size' | 'modified'
|
||||||
sortOrder: 'asc',
|
sortOrder: 'asc',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
Or simply:
|
Or use the shorthand:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
const server = new SmartServe({
|
const server = new SmartServe({
|
||||||
port: 3000,
|
port: 3000,
|
||||||
static: './public', // Shorthand - uses defaults
|
static: './public', // Uses sensible defaults
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## WebDAV Support
|
## WebDAV Support
|
||||||
|
|
||||||
Mount the server as a network drive on macOS, Windows, or Linux:
|
Mount the server as a network drive on macOS, Windows, or Linux:
|
||||||
@@ -228,75 +514,115 @@ const server = new SmartServe({
|
|||||||
webdav: {
|
webdav: {
|
||||||
root: '/path/to/files',
|
root: '/path/to/files',
|
||||||
auth: (ctx) => {
|
auth: (ctx) => {
|
||||||
// Optional Basic auth
|
// Optional: Basic authentication
|
||||||
const auth = ctx.headers.get('Authorization');
|
const auth = ctx.headers.get('Authorization');
|
||||||
if (!auth) return false;
|
if (!auth) return false;
|
||||||
const [, credentials] = auth.split(' ');
|
const [, credentials] = auth.split(' ');
|
||||||
const [user, pass] = atob(credentials).split(':');
|
const [user, pass] = atob(credentials).split(':');
|
||||||
return user === 'admin' && pass === 'secret';
|
return user === 'admin' && pass === 'secret';
|
||||||
},
|
},
|
||||||
locking: true, // Enable RFC 4918 file locking
|
locking: true, // Enable RFC 4918 exclusive write locks
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await server.start();
|
await server.start();
|
||||||
// Mount: Connect to Server → http://localhost:8080
|
// 💾 Connect: Finder → Go → Connect to Server → http://localhost:8080
|
||||||
```
|
```
|
||||||
|
|
||||||
**Supported WebDAV Methods:**
|
**Supported WebDAV Methods:**
|
||||||
- `OPTIONS` - Capability discovery
|
|
||||||
- `PROPFIND` - Directory listing and file metadata
|
| Method | Description |
|
||||||
- `MKCOL` - Create directory
|
|--------|-------------|
|
||||||
- `COPY` / `MOVE` - Copy and move operations
|
| `OPTIONS` | Capability discovery |
|
||||||
- `LOCK` / `UNLOCK` - Exclusive write locking
|
| `PROPFIND` | Directory listing and file metadata |
|
||||||
- `GET` / `PUT` / `DELETE` - File operations
|
| `MKCOL` | Create directory |
|
||||||
|
| `COPY` | Copy files/directories |
|
||||||
|
| `MOVE` | Move/rename files/directories |
|
||||||
|
| `LOCK` | Acquire exclusive write lock |
|
||||||
|
| `UNLOCK` | Release lock |
|
||||||
|
| `GET` / `PUT` / `DELETE` | File operations |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## WebSocket Support
|
## WebSocket Support
|
||||||
|
|
||||||
WebSocket connections are handled natively:
|
WebSocket connections are handled natively across all runtimes:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
const server = new SmartServe({
|
const server = new SmartServe({
|
||||||
port: 3000,
|
port: 3000,
|
||||||
websocket: {
|
websocket: {
|
||||||
onOpen: (peer) => {
|
onOpen: (peer) => {
|
||||||
console.log(`Connected: ${peer.id}`);
|
console.log(`🔗 Connected: ${peer.id}`);
|
||||||
peer.send('Welcome!');
|
peer.send('Welcome!');
|
||||||
|
peer.tags.add('authenticated'); // Tag for filtering
|
||||||
},
|
},
|
||||||
onMessage: (peer, message) => {
|
onMessage: (peer, message) => {
|
||||||
console.log(`Received: ${message.text}`);
|
console.log(`📨 ${message.text}`);
|
||||||
peer.send(`Echo: ${message.text}`);
|
peer.send(`Echo: ${message.text}`);
|
||||||
},
|
},
|
||||||
onClose: (peer, code, reason) => {
|
onClose: (peer, code, reason) => {
|
||||||
console.log(`Disconnected: ${peer.id}`);
|
console.log(`👋 Disconnected: ${peer.id}`);
|
||||||
},
|
},
|
||||||
onError: (peer, error) => {
|
onError: (peer, error) => {
|
||||||
console.error(`Error: ${error.message}`);
|
console.error(`❌ Error: ${error.message}`);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### TypedRouter for Type-Safe RPC
|
||||||
|
|
||||||
|
Use `@api.global/typedrequest` for type-safe WebSocket communication:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { TypedRouter } from '@api.global/typedrequest';
|
||||||
|
|
||||||
|
const typedRouter = new TypedRouter();
|
||||||
|
typedRouter.addTypedHandler(MyTypedRequest, async (request) => {
|
||||||
|
return { result: 'processed' };
|
||||||
|
});
|
||||||
|
|
||||||
|
const server = new SmartServe({
|
||||||
|
port: 3000,
|
||||||
|
websocket: {
|
||||||
|
typedRouter, // Handles message routing automatically
|
||||||
|
onConnectionOpen: (peer) => {
|
||||||
|
peer.tags.add('subscriber');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Broadcast to tagged connections
|
||||||
|
server.broadcast({ event: 'update' }, (peer) => peer.tags.has('subscriber'));
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## HTTPS/TLS
|
## HTTPS/TLS
|
||||||
|
|
||||||
Enable HTTPS with certificate configuration:
|
Enable HTTPS with certificate configuration:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
|
import * as fs from 'fs';
|
||||||
|
|
||||||
const server = new SmartServe({
|
const server = new SmartServe({
|
||||||
port: 443,
|
port: 443,
|
||||||
tls: {
|
tls: {
|
||||||
cert: fs.readFileSync('./cert.pem'),
|
cert: fs.readFileSync('./cert.pem'),
|
||||||
key: fs.readFileSync('./key.pem'),
|
key: fs.readFileSync('./key.pem'),
|
||||||
// Optional
|
ca: fs.readFileSync('./ca.pem'), // Optional: CA chain
|
||||||
ca: fs.readFileSync('./ca.pem'),
|
minVersion: 'TLSv1.2', // Optional: minimum TLS version
|
||||||
minVersion: 'TLSv1.2',
|
passphrase: 'optional-key-passphrase',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Error Handling
|
## Error Handling
|
||||||
|
|
||||||
Built-in HTTP error classes:
|
Built-in HTTP error classes with factory methods:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { HttpError, type IRequestContext } from '@push.rocks/smartserve';
|
import { HttpError, type IRequestContext } from '@push.rocks/smartserve';
|
||||||
@@ -324,43 +650,73 @@ HttpError.conflict(message, details); // 409
|
|||||||
HttpError.internal(message, details); // 500
|
HttpError.internal(message, details); // 500
|
||||||
```
|
```
|
||||||
|
|
||||||
Global error handling:
|
### Global Error Handler
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
const server = new SmartServe({
|
const server = new SmartServe({
|
||||||
port: 3000,
|
port: 3000,
|
||||||
onError: (error, request) => {
|
onError: (error, request) => {
|
||||||
console.error('Server error:', error);
|
console.error('💥 Server error:', error);
|
||||||
return new Response(JSON.stringify({ error: 'Something went wrong' }), {
|
|
||||||
status: 500,
|
// Return custom error response
|
||||||
headers: { 'Content-Type': 'application/json' },
|
return new Response(
|
||||||
});
|
JSON.stringify({ error: 'Something went wrong', requestId: crypto.randomUUID() }),
|
||||||
|
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Request Context
|
## Request Context
|
||||||
|
|
||||||
Every handler receives a typed request context:
|
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 Request (body never consumed by framework)
|
||||||
body: TBody; // Parsed and typed body
|
params: Record<string, string>; // URL path parameters (/users/:id → { id: '123' })
|
||||||
params: Record<string, string>; // URL path parameters
|
query: Record<string, string>; // Query string (?page=1 → { page: '1' })
|
||||||
query: Record<string, string>; // Query string parameters
|
|
||||||
headers: Headers; // Request headers
|
headers: Headers; // Request headers
|
||||||
path: string; // Matched route path
|
path: string; // Matched route path
|
||||||
method: THttpMethod; // HTTP method
|
method: THttpMethod; // GET, POST, PUT, DELETE, etc.
|
||||||
url: URL; // Full URL object
|
url: URL; // Full URL object
|
||||||
runtime: 'node' | 'deno' | 'bun'; // Current runtime
|
runtime: 'node' | 'deno' | 'bun';
|
||||||
state: Record<string, unknown>; // Per-request state bag
|
state: Record<string, unknown>; // Per-request state (share data between interceptors)
|
||||||
|
|
||||||
|
// 🔥 Lazy body parsing (cached after first call)
|
||||||
|
json(): Promise<TBody>; // Parse as JSON (typed!)
|
||||||
|
text(): Promise<string>; // Parse as text
|
||||||
|
arrayBuffer(): Promise<ArrayBuffer>;
|
||||||
|
formData(): Promise<FormData>;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Lazy Body Parsing**: The request body is only consumed when you call `json()`, `text()`, etc. This allows raw access to `ctx.request` for cases like webhook signature verification:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
@Post('/webhook')
|
||||||
|
async handleWebhook(ctx: IRequestContext) {
|
||||||
|
// Get raw body for signature verification
|
||||||
|
const rawBody = await ctx.request.text();
|
||||||
|
const signature = ctx.headers.get('X-Signature');
|
||||||
|
|
||||||
|
if (!verifyHmac(rawBody, signature)) {
|
||||||
|
throw HttpError.unauthorized('Invalid signature');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the body manually
|
||||||
|
const payload = JSON.parse(rawBody);
|
||||||
|
return { processed: true };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Custom Request Handler
|
## Custom Request Handler
|
||||||
|
|
||||||
Bypass decorator routing entirely:
|
Bypass decorator routing entirely for low-level control:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
const server = new SmartServe({ port: 3000 });
|
const server = new SmartServe({ port: 3000 });
|
||||||
@@ -372,12 +728,22 @@ server.setHandler(async (request, connectionInfo) => {
|
|||||||
return new Response('OK', { status: 200 });
|
return new Response('OK', { status: 200 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (url.pathname.startsWith('/api')) {
|
||||||
|
// Handle API routes manually
|
||||||
|
const body = await request.json();
|
||||||
|
return new Response(JSON.stringify({ received: body }), {
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return new Response('Not Found', { status: 404 });
|
return new Response('Not Found', { status: 404 });
|
||||||
});
|
});
|
||||||
|
|
||||||
await server.start();
|
await server.start();
|
||||||
```
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Runtime Detection
|
## Runtime Detection
|
||||||
|
|
||||||
SmartServe automatically detects and optimizes for the current runtime:
|
SmartServe automatically detects and optimizes for the current runtime:
|
||||||
@@ -385,26 +751,37 @@ SmartServe automatically detects and optimizes for the current runtime:
|
|||||||
```typescript
|
```typescript
|
||||||
const instance = await server.start();
|
const instance = await server.start();
|
||||||
|
|
||||||
console.log(instance.runtime); // 'node' | 'deno' | 'bun'
|
console.log(instance.runtime); // 'node' | 'deno' | 'bun'
|
||||||
console.log(instance.port); // 3000
|
console.log(instance.port); // 3000
|
||||||
console.log(instance.secure); // true if TLS enabled
|
console.log(instance.hostname); // '0.0.0.0'
|
||||||
|
console.log(instance.secure); // true if TLS enabled
|
||||||
|
|
||||||
|
// Server statistics
|
||||||
|
const stats = instance.stats();
|
||||||
|
console.log(stats.uptime); // Seconds since start
|
||||||
|
console.log(stats.requestsTotal); // Total requests handled
|
||||||
|
console.log(stats.requestsActive); // Currently processing
|
||||||
```
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## License and Legal Information
|
## License and Legal Information
|
||||||
|
|
||||||
This repository contains open-source code that is licensed under the MIT License. A copy of the MIT License can be found in the [license](license) file within this repository.
|
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](./LICENSE) file.
|
||||||
|
|
||||||
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
|
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
### Trademarks
|
### Trademarks
|
||||||
|
|
||||||
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH and are not included within the scope of the MIT license granted herein. Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines, and any usage must be approved in writing by Task Venture Capital GmbH.
|
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.
|
||||||
|
|
||||||
|
Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.
|
||||||
|
|
||||||
### Company Information
|
### Company Information
|
||||||
|
|
||||||
Task Venture Capital GmbH
|
Task Venture Capital GmbH
|
||||||
Registered at District court Bremen HRB 35230 HB, Germany
|
Registered at District Court Bremen HRB 35230 HB, Germany
|
||||||
|
|
||||||
For any legal inquiries or if you require further information, please contact us via email at hello@task.vc.
|
For any legal inquiries or further information, please contact us via email at hello@task.vc.
|
||||||
|
|
||||||
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.
|
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.
|
||||||
|
|||||||
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();
|
||||||
@@ -69,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 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@push.rocks/smartserve',
|
name: '@push.rocks/smartserve',
|
||||||
version: '1.3.0',
|
version: '2.0.1',
|
||||||
description: 'a cross platform server module for Node, Deno and Bun'
|
description: 'a cross platform server module for Node, Deno and Bun'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,12 +10,106 @@ 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, type IRouteCompressionOptions } from '../decorators/index.js';
|
import { ControllerRegistry, type ICompiledRoute, type IRouteCompressionOptions } from '../decorators/index.js';
|
||||||
@@ -28,6 +122,7 @@ import {
|
|||||||
compressResponse,
|
compressResponse,
|
||||||
type ICompressionConfig,
|
type ICompressionConfig,
|
||||||
} from '../compression/index.js';
|
} from '../compression/index.js';
|
||||||
|
import { createOpenApiHandler, createSwaggerUiHandler } from '../openapi/openapi.handlers.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SmartServe - Cross-platform HTTP server
|
* SmartServe - Cross-platform HTTP server
|
||||||
@@ -122,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;
|
||||||
|
|
||||||
@@ -252,6 +352,29 @@ export class SmartServe {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup OpenAPI documentation endpoints
|
||||||
|
*/
|
||||||
|
private setupOpenApi(): void {
|
||||||
|
const openapi = this.options.openapi!;
|
||||||
|
const specPath = openapi.specPath ?? '/openapi.json';
|
||||||
|
const docsPath = openapi.docsPath ?? '/docs';
|
||||||
|
|
||||||
|
// Create generator options
|
||||||
|
const generatorOptions = {
|
||||||
|
info: openapi.info,
|
||||||
|
servers: openapi.servers,
|
||||||
|
securitySchemes: openapi.securitySchemes,
|
||||||
|
tags: openapi.tags,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Register OpenAPI spec endpoint
|
||||||
|
ControllerRegistry.addRoute(specPath, 'GET', createOpenApiHandler(generatorOptions));
|
||||||
|
|
||||||
|
// Register Swagger UI endpoint
|
||||||
|
ControllerRegistry.addRoute(docsPath, 'GET', createSwaggerUiHandler(specPath, openapi.info.title));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create the main request handler
|
* Create the main request handler
|
||||||
*/
|
*/
|
||||||
@@ -316,8 +439,8 @@ 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
|
||||||
const response = await this.executeRoute(route, context);
|
const response = await this.executeRoute(route, context);
|
||||||
@@ -331,59 +454,32 @@ export class SmartServe {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create request context from Request object
|
* Create request context from Request object.
|
||||||
|
* Body is NOT parsed here - use ctx.json(), ctx.text(), etc. for lazy parsing.
|
||||||
*/
|
*/
|
||||||
private async createContext(
|
private createContext(
|
||||||
request: Request,
|
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: {},
|
{},
|
||||||
};
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
import type { TypedRouter } from '@api.global/typedrequest';
|
import type { TypedRouter } from '@api.global/typedrequest';
|
||||||
import type { ICompressionConfig } from '../compression/index.js';
|
import type { ICompressionConfig } from '../compression/index.js';
|
||||||
|
import type { IOpenApiOptions } from '../openapi/openapi.types.js';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// HTTP Types
|
// HTTP Types
|
||||||
@@ -23,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 */
|
||||||
@@ -43,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>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -338,6 +365,8 @@ export interface ISmartServeOptions {
|
|||||||
onError?: (error: Error, request?: Request) => Response | Promise<Response>;
|
onError?: (error: Error, request?: Request) => Response | Promise<Response>;
|
||||||
/** Compression configuration (enabled by default) */
|
/** Compression configuration (enabled by default) */
|
||||||
compression?: ICompressionConfig | boolean;
|
compression?: ICompressionConfig | boolean;
|
||||||
|
/** OpenAPI documentation and validation configuration */
|
||||||
|
openapi?: IOpenApiOptions;
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ export function combinePaths(basePath: string, routePath: string): string {
|
|||||||
const route = normalizePath(routePath);
|
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
|
||||||
@@ -92,7 +93,7 @@ export class ControllerRegistry {
|
|||||||
/**
|
/**
|
||||||
* 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;
|
||||||
}
|
}
|
||||||
@@ -105,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> => {
|
||||||
@@ -127,6 +137,7 @@ export class ControllerRegistry {
|
|||||||
handler,
|
handler,
|
||||||
interceptors,
|
interceptors,
|
||||||
compression: route.compression,
|
compression: route.compression,
|
||||||
|
openapi: route.openapi,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -214,6 +225,18 @@ export class ControllerRegistry {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if OpenAPI metadata contains validation-relevant information
|
||||||
|
*/
|
||||||
|
private static hasValidationMetadata(openapi: IOpenApiRouteMeta): boolean {
|
||||||
|
return !!(
|
||||||
|
openapi.requestBody ||
|
||||||
|
(openapi.params && openapi.params.size > 0) ||
|
||||||
|
(openapi.query && openapi.query.size > 0) ||
|
||||||
|
(openapi.headers && openapi.headers.size > 0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear all registered controllers (useful for testing)
|
* Clear all registered controllers (useful for testing)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -58,6 +60,8 @@ export interface IRouteMetadata {
|
|||||||
handler?: Function;
|
handler?: Function;
|
||||||
/** Route-specific compression settings */
|
/** Route-specific compression settings */
|
||||||
compression?: IRouteCompressionOptions;
|
compression?: IRouteCompressionOptions;
|
||||||
|
/** OpenAPI metadata for this route */
|
||||||
|
openapi?: IOpenApiRouteMeta;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -88,4 +92,138 @@ export interface ICompiledRoute {
|
|||||||
interceptors: IInterceptOptions[];
|
interceptors: IInterceptOptions[];
|
||||||
/** Route-specific compression settings */
|
/** Route-specific compression settings */
|
||||||
compression?: IRouteCompressionOptions;
|
compression?: IRouteCompressionOptions;
|
||||||
|
/** OpenAPI metadata for this route */
|
||||||
|
openapi?: IOpenApiRouteMeta;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// OpenAPI / JSON Schema Types
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON Schema type for OpenAPI schemas
|
||||||
|
* Supports JSON Schema draft 2020-12 (used by OpenAPI 3.1)
|
||||||
|
*/
|
||||||
|
export type TJsonSchema = {
|
||||||
|
type?: 'string' | 'number' | 'integer' | 'boolean' | 'array' | 'object' | 'null' | Array<'string' | 'number' | 'integer' | 'boolean' | 'array' | 'object' | 'null'>;
|
||||||
|
format?: string;
|
||||||
|
properties?: Record<string, TJsonSchema>;
|
||||||
|
items?: TJsonSchema;
|
||||||
|
required?: string[];
|
||||||
|
enum?: unknown[];
|
||||||
|
const?: unknown;
|
||||||
|
default?: unknown;
|
||||||
|
description?: string;
|
||||||
|
minimum?: number;
|
||||||
|
maximum?: number;
|
||||||
|
minLength?: number;
|
||||||
|
maxLength?: number;
|
||||||
|
pattern?: string;
|
||||||
|
minItems?: number;
|
||||||
|
maxItems?: number;
|
||||||
|
uniqueItems?: boolean;
|
||||||
|
additionalProperties?: boolean | TJsonSchema;
|
||||||
|
allOf?: TJsonSchema[];
|
||||||
|
anyOf?: TJsonSchema[];
|
||||||
|
oneOf?: TJsonSchema[];
|
||||||
|
not?: TJsonSchema;
|
||||||
|
$ref?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OpenAPI operation metadata for documentation
|
||||||
|
*/
|
||||||
|
export interface IOpenApiOperationMeta {
|
||||||
|
/** Short summary of what the operation does */
|
||||||
|
summary?: string;
|
||||||
|
/** Longer description with markdown support */
|
||||||
|
description?: string;
|
||||||
|
/** Unique operation identifier (auto-generated if omitted) */
|
||||||
|
operationId?: string;
|
||||||
|
/** Mark operation as deprecated */
|
||||||
|
deprecated?: boolean;
|
||||||
|
/** External documentation */
|
||||||
|
externalDocs?: {
|
||||||
|
url: string;
|
||||||
|
description?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OpenAPI parameter metadata (path, query, header, cookie)
|
||||||
|
*/
|
||||||
|
export interface IOpenApiParamMeta {
|
||||||
|
/** Human-readable description */
|
||||||
|
description?: string;
|
||||||
|
/** Whether parameter is required (path params always required) */
|
||||||
|
required?: boolean;
|
||||||
|
/** JSON Schema for the parameter */
|
||||||
|
schema?: TJsonSchema;
|
||||||
|
/** Example value */
|
||||||
|
example?: unknown;
|
||||||
|
/** Multiple examples */
|
||||||
|
examples?: Record<string, { value: unknown; summary?: string; description?: string }>;
|
||||||
|
/** Allow empty value */
|
||||||
|
allowEmptyValue?: boolean;
|
||||||
|
/** Deprecation flag */
|
||||||
|
deprecated?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OpenAPI request body metadata
|
||||||
|
*/
|
||||||
|
export interface IOpenApiRequestBodyMeta {
|
||||||
|
/** Human-readable description */
|
||||||
|
description?: string;
|
||||||
|
/** Whether body is required (default: true) */
|
||||||
|
required?: boolean;
|
||||||
|
/** JSON Schema for the body (assumes application/json) */
|
||||||
|
schema: TJsonSchema;
|
||||||
|
/** Example value */
|
||||||
|
example?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OpenAPI response body metadata
|
||||||
|
*/
|
||||||
|
export interface IOpenApiResponseBodyMeta {
|
||||||
|
/** Human-readable description */
|
||||||
|
description: string;
|
||||||
|
/** JSON Schema for the response (assumes application/json) */
|
||||||
|
schema?: TJsonSchema;
|
||||||
|
/** Example value */
|
||||||
|
example?: unknown;
|
||||||
|
/** Response headers */
|
||||||
|
headers?: Record<string, { description?: string; schema?: TJsonSchema }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Combined OpenAPI metadata for a route method
|
||||||
|
*/
|
||||||
|
export interface IOpenApiRouteMeta {
|
||||||
|
/** Operation details (summary, description, etc.) */
|
||||||
|
operation?: IOpenApiOperationMeta;
|
||||||
|
/** Path parameters by name */
|
||||||
|
params?: Map<string, IOpenApiParamMeta>;
|
||||||
|
/** Query parameters by name */
|
||||||
|
query?: Map<string, IOpenApiParamMeta>;
|
||||||
|
/** Header parameters by name */
|
||||||
|
headers?: Map<string, IOpenApiParamMeta>;
|
||||||
|
/** Request body schema */
|
||||||
|
requestBody?: IOpenApiRequestBodyMeta;
|
||||||
|
/** Response bodies by status code */
|
||||||
|
responses?: Map<number, IOpenApiResponseBodyMeta>;
|
||||||
|
/** Security requirements */
|
||||||
|
security?: Array<Record<string, string[]>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OpenAPI metadata for a controller class
|
||||||
|
*/
|
||||||
|
export interface IOpenApiControllerMeta {
|
||||||
|
/** Tags for grouping routes in documentation */
|
||||||
|
tags?: string[];
|
||||||
|
/** Security requirements for all routes in controller */
|
||||||
|
security?: Array<Record<string, string[]>>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,14 @@ export type {
|
|||||||
IRouteCompressionOptions,
|
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
|
||||||
@@ -49,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';
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ 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;
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user