BREAKING CHANGE(request): introduce lazy request body parsing via ctx.json()/text()/arrayBuffer()/formData and remove IRequestContext.body
This commit is contained in:
10
changelog.md
10
changelog.md
@@ -1,5 +1,15 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2025-12-20 - 2.0.0 - BREAKING CHANGE(request)
|
||||||
|
introduce lazy request body parsing via ctx.json()/text()/arrayBuffer()/formData and remove IRequestContext.body
|
||||||
|
|
||||||
|
- Add RequestContext class implementing lazy, cached body parsing methods: json(), text(), arrayBuffer(), formData.
|
||||||
|
- Remove IRequestContext.body property — handlers and interceptors must call ctx.json()/ctx.text()/... to access the request body (breaking API change).
|
||||||
|
- createContext now returns a RequestContext synchronously and no longer pre-parses or coerces the body.
|
||||||
|
- OpenAPI validator (validateRequest) made async and updated to use ctx.json() for request body validation; createValidationInterceptor now awaits validation.
|
||||||
|
- Updated README and tests to use async handlers and ctx.json() for body access.
|
||||||
|
- Updated npmextra.json: replaced gitzone key with @git.zone/cli, replaced npmci with @ship.zone/szci, and added release registries and accessLevel.
|
||||||
|
|
||||||
## 2025-12-08 - 1.4.0 - feat(openapi)
|
## 2025-12-08 - 1.4.0 - feat(openapi)
|
||||||
Add OpenAPI module: decorators, spec generator, runtime validation and Swagger UI
|
Add OpenAPI module: decorators, spec generator, runtime validation and Swagger UI
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
21
readme.md
21
readme.md
@@ -44,8 +44,9 @@ class UserController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('/users')
|
@Post('/users')
|
||||||
createUser(ctx: IRequestContext<{ name: string; email: string }>) {
|
async createUser(ctx: IRequestContext<{ name: string; email: string }>) {
|
||||||
return { id: 'new-id', ...ctx.body };
|
const body = await ctx.json();
|
||||||
|
return { id: 'new-id', ...body };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,8 +76,9 @@ class ApiController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('/items') // POST /api/v1/items
|
@Post('/items') // POST /api/v1/items
|
||||||
createItem(ctx: IRequestContext<{ name: string }>) {
|
async createItem(ctx: IRequestContext<{ name: string }>) {
|
||||||
return { created: ctx.body.name };
|
const body = await ctx.json();
|
||||||
|
return { created: body.name };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put('/items/:id') // PUT /api/v1/items/:id
|
@Put('/items/:id') // PUT /api/v1/items/:id
|
||||||
@@ -345,8 +347,7 @@ Every handler receives a typed request context:
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
interface IRequestContext<TBody = unknown> {
|
interface IRequestContext<TBody = unknown> {
|
||||||
request: Request; // Original Web Standards Request
|
request: Request; // Original Web Standards Request (body never consumed by framework)
|
||||||
body: TBody; // Parsed and typed body
|
|
||||||
params: Record<string, string>; // URL path parameters
|
params: Record<string, string>; // URL path parameters
|
||||||
query: Record<string, string>; // Query string parameters
|
query: Record<string, string>; // Query string parameters
|
||||||
headers: Headers; // Request headers
|
headers: Headers; // Request headers
|
||||||
@@ -355,9 +356,17 @@ interface IRequestContext<TBody = unknown> {
|
|||||||
url: URL; // Full URL object
|
url: URL; // Full URL object
|
||||||
runtime: 'node' | 'deno' | 'bun'; // Current runtime
|
runtime: 'node' | 'deno' | 'bun'; // Current runtime
|
||||||
state: Record<string, unknown>; // Per-request state bag
|
state: Record<string, unknown>; // Per-request state bag
|
||||||
|
|
||||||
|
// Lazy body parsing methods (cached after first call)
|
||||||
|
json(): Promise<TBody>; // Parse body as JSON (typed)
|
||||||
|
text(): Promise<string>; // Parse body as text
|
||||||
|
arrayBuffer(): Promise<ArrayBuffer>; // Parse body as binary
|
||||||
|
formData(): Promise<FormData>; // Parse body as form data
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Body parsing is lazy - the request body is only consumed when you call `json()`, `text()`, etc. This allows raw access to `ctx.request` for cases like signature verification.
|
||||||
|
|
||||||
## Custom Request Handler
|
## Custom Request Handler
|
||||||
|
|
||||||
Bypass decorator routing entirely:
|
Bypass decorator routing entirely:
|
||||||
|
|||||||
@@ -95,11 +95,12 @@ class OpenApiUserController {
|
|||||||
})
|
})
|
||||||
@ApiResponseBody(201, { description: 'User created', schema: UserSchema })
|
@ApiResponseBody(201, { description: 'User created', schema: UserSchema })
|
||||||
@ApiResponseBody(400, { description: 'Validation error' })
|
@ApiResponseBody(400, { description: 'Validation error' })
|
||||||
createUser(ctx: IRequestContext<{ name: string; email: string }>) {
|
async createUser(ctx: IRequestContext<{ name: string; email: string }>) {
|
||||||
|
const body = await ctx.json();
|
||||||
return {
|
return {
|
||||||
id: 'new-uuid',
|
id: 'new-uuid',
|
||||||
name: ctx.body.name,
|
name: body.name,
|
||||||
email: ctx.body.email,
|
email: body.email,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.4.0',
|
version: '2.0.0',
|
||||||
description: 'a cross platform server module for Node, Deno and Bun'
|
description: 'a cross platform server module for Node, Deno and Bun'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -345,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);
|
||||||
@@ -360,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: {},
|
{},
|
||||||
};
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -24,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 */
|
||||||
@@ -44,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>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|||||||
@@ -116,15 +116,15 @@ export function createValidationErrorResponse(
|
|||||||
* Validate the full request based on OpenAPI metadata
|
* Validate the full request based on OpenAPI metadata
|
||||||
* Returns a Response if validation fails, undefined if valid
|
* Returns a Response if validation fails, undefined if valid
|
||||||
*/
|
*/
|
||||||
export function validateRequest(
|
export async function validateRequest(
|
||||||
ctx: IRequestContext,
|
ctx: IRequestContext,
|
||||||
openapi: IOpenApiRouteMeta
|
openapi: IOpenApiRouteMeta
|
||||||
): {
|
): Promise<{
|
||||||
valid: boolean;
|
valid: boolean;
|
||||||
response?: Response;
|
response?: Response;
|
||||||
coercedParams?: Record<string, unknown>;
|
coercedParams?: Record<string, unknown>;
|
||||||
coercedQuery?: Record<string, unknown>;
|
coercedQuery?: Record<string, unknown>;
|
||||||
} {
|
}> {
|
||||||
const allErrors: Array<{ errors: IValidationError[]; source: string }> = [];
|
const allErrors: Array<{ errors: IValidationError[]; source: string }> = [];
|
||||||
|
|
||||||
// Coerce and validate path parameters
|
// Coerce and validate path parameters
|
||||||
@@ -164,10 +164,16 @@ export function validateRequest(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate request body
|
// Validate request body (lazy parsing via ctx.json())
|
||||||
if (openapi.requestBody) {
|
if (openapi.requestBody) {
|
||||||
const required = openapi.requestBody.required !== false;
|
const required = openapi.requestBody.required !== false;
|
||||||
const body = ctx.body;
|
let body: unknown;
|
||||||
|
|
||||||
|
try {
|
||||||
|
body = await ctx.json();
|
||||||
|
} catch {
|
||||||
|
body = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
if (required && (body === undefined || body === null)) {
|
if (required && (body === undefined || body === null)) {
|
||||||
allErrors.push({
|
allErrors.push({
|
||||||
@@ -210,7 +216,7 @@ export function validateRequest(
|
|||||||
*/
|
*/
|
||||||
export function createValidationInterceptor(openapi: IOpenApiRouteMeta) {
|
export function createValidationInterceptor(openapi: IOpenApiRouteMeta) {
|
||||||
return async (ctx: IRequestContext): Promise<IRequestContext | Response | void> => {
|
return async (ctx: IRequestContext): Promise<IRequestContext | Response | void> => {
|
||||||
const result = validateRequest(ctx, openapi);
|
const result = await validateRequest(ctx, openapi);
|
||||||
|
|
||||||
if (!result.valid && result.response) {
|
if (!result.valid && result.response) {
|
||||||
return result.response;
|
return result.response;
|
||||||
|
|||||||
Reference in New Issue
Block a user