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
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"gitzone": {
|
||||
"@git.zone/cli": {
|
||||
"projectType": "npm",
|
||||
"module": {
|
||||
"githost": "code.foss.global",
|
||||
@@ -9,10 +9,16 @@
|
||||
"npmPackagename": "@push.rocks/smartserve",
|
||||
"license": "MIT",
|
||||
"projectDomain": "push.rocks"
|
||||
},
|
||||
"release": {
|
||||
"registries": [
|
||||
"https://verdaccio.lossless.digital",
|
||||
"https://registry.npmjs.org"
|
||||
],
|
||||
"accessLevel": "public"
|
||||
}
|
||||
},
|
||||
"npmci": {
|
||||
"npmGlobalTools": [],
|
||||
"npmAccessLevel": "public"
|
||||
"@ship.zone/szci": {
|
||||
"npmGlobalTools": []
|
||||
}
|
||||
}
|
||||
21
readme.md
21
readme.md
@@ -44,8 +44,9 @@ class UserController {
|
||||
}
|
||||
|
||||
@Post('/users')
|
||||
createUser(ctx: IRequestContext<{ name: string; email: string }>) {
|
||||
return { id: 'new-id', ...ctx.body };
|
||||
async createUser(ctx: IRequestContext<{ name: string; email: string }>) {
|
||||
const body = await ctx.json();
|
||||
return { id: 'new-id', ...body };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,8 +76,9 @@ class ApiController {
|
||||
}
|
||||
|
||||
@Post('/items') // POST /api/v1/items
|
||||
createItem(ctx: IRequestContext<{ name: string }>) {
|
||||
return { created: ctx.body.name };
|
||||
async createItem(ctx: IRequestContext<{ name: string }>) {
|
||||
const body = await ctx.json();
|
||||
return { created: body.name };
|
||||
}
|
||||
|
||||
@Put('/items/:id') // PUT /api/v1/items/:id
|
||||
@@ -345,8 +347,7 @@ Every handler receives a typed request context:
|
||||
|
||||
```typescript
|
||||
interface IRequestContext<TBody = unknown> {
|
||||
request: Request; // Original Web Standards Request
|
||||
body: TBody; // Parsed and typed body
|
||||
request: Request; // Original Web Standards Request (body never consumed by framework)
|
||||
params: Record<string, string>; // URL path parameters
|
||||
query: Record<string, string>; // Query string parameters
|
||||
headers: Headers; // Request headers
|
||||
@@ -355,9 +356,17 @@ interface IRequestContext<TBody = unknown> {
|
||||
url: URL; // Full URL object
|
||||
runtime: 'node' | 'deno' | 'bun'; // Current runtime
|
||||
state: Record<string, unknown>; // Per-request state bag
|
||||
|
||||
// Lazy body parsing methods (cached after first call)
|
||||
json(): Promise<TBody>; // Parse body as JSON (typed)
|
||||
text(): Promise<string>; // Parse body as text
|
||||
arrayBuffer(): Promise<ArrayBuffer>; // Parse body as binary
|
||||
formData(): Promise<FormData>; // Parse body as form data
|
||||
}
|
||||
```
|
||||
|
||||
Body parsing is lazy - the request body is only consumed when you call `json()`, `text()`, etc. This allows raw access to `ctx.request` for cases like signature verification.
|
||||
|
||||
## Custom Request Handler
|
||||
|
||||
Bypass decorator routing entirely:
|
||||
|
||||
@@ -95,11 +95,12 @@ class OpenApiUserController {
|
||||
})
|
||||
@ApiResponseBody(201, { description: 'User created', schema: UserSchema })
|
||||
@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 {
|
||||
id: 'new-uuid',
|
||||
name: ctx.body.name,
|
||||
email: ctx.body.email,
|
||||
name: body.name,
|
||||
email: body.email,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -69,8 +69,9 @@ class TestController {
|
||||
}
|
||||
|
||||
@Post('/echo')
|
||||
echo(ctx: IRequestContext<{ text: string }>) {
|
||||
return { echo: ctx.body?.text };
|
||||
async echo(ctx: IRequestContext<{ text: string }>) {
|
||||
const body = await ctx.json();
|
||||
return { echo: body?.text };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@push.rocks/smartserve',
|
||||
version: '1.4.0',
|
||||
version: '2.0.0',
|
||||
description: 'a cross platform server module for Node, Deno and Bun'
|
||||
}
|
||||
|
||||
@@ -10,12 +10,106 @@ import type {
|
||||
IRequestContext,
|
||||
IConnectionInfo,
|
||||
THttpMethod,
|
||||
TRuntime,
|
||||
IInterceptOptions,
|
||||
TRequestInterceptor,
|
||||
TResponseInterceptor,
|
||||
IWebSocketPeer,
|
||||
IWebSocketConnectionCallbacks,
|
||||
} from './smartserve.interfaces.js';
|
||||
|
||||
// =============================================================================
|
||||
// RequestContext - Implements lazy body parsing
|
||||
// =============================================================================
|
||||
|
||||
interface IBodyCache<T> {
|
||||
done: boolean;
|
||||
value?: T;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request context implementation with lazy body parsing.
|
||||
* Body is never consumed automatically - use json(), text(), etc. when needed.
|
||||
*/
|
||||
class RequestContext<TBody = unknown> implements IRequestContext<TBody> {
|
||||
private _jsonCache: IBodyCache<TBody> = { done: false };
|
||||
private _textCache: IBodyCache<string> = { done: false };
|
||||
private _arrayBufferCache: IBodyCache<ArrayBuffer> = { done: false };
|
||||
private _formDataCache: IBodyCache<FormData> = { done: false };
|
||||
|
||||
constructor(
|
||||
public readonly request: Request,
|
||||
public readonly params: Record<string, string>,
|
||||
public readonly query: Record<string, string>,
|
||||
public readonly headers: Headers,
|
||||
public readonly path: string,
|
||||
public readonly method: THttpMethod,
|
||||
public readonly url: URL,
|
||||
public readonly runtime: TRuntime,
|
||||
public state: Record<string, unknown> = {},
|
||||
) {}
|
||||
|
||||
async json(): Promise<TBody> {
|
||||
if (this._jsonCache.done) {
|
||||
if (this._jsonCache.error) throw this._jsonCache.error;
|
||||
return this._jsonCache.value!;
|
||||
}
|
||||
try {
|
||||
const value = await this.request.json() as TBody;
|
||||
this._jsonCache = { done: true, value };
|
||||
return value;
|
||||
} catch (e) {
|
||||
this._jsonCache = { done: true, error: e as Error };
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async text(): Promise<string> {
|
||||
if (this._textCache.done) {
|
||||
if (this._textCache.error) throw this._textCache.error;
|
||||
return this._textCache.value!;
|
||||
}
|
||||
try {
|
||||
const value = await this.request.text();
|
||||
this._textCache = { done: true, value };
|
||||
return value;
|
||||
} catch (e) {
|
||||
this._textCache = { done: true, error: e as Error };
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async arrayBuffer(): Promise<ArrayBuffer> {
|
||||
if (this._arrayBufferCache.done) {
|
||||
if (this._arrayBufferCache.error) throw this._arrayBufferCache.error;
|
||||
return this._arrayBufferCache.value!;
|
||||
}
|
||||
try {
|
||||
const value = await this.request.arrayBuffer();
|
||||
this._arrayBufferCache = { done: true, value };
|
||||
return value;
|
||||
} catch (e) {
|
||||
this._arrayBufferCache = { done: true, error: e as Error };
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async formData(): Promise<FormData> {
|
||||
if (this._formDataCache.done) {
|
||||
if (this._formDataCache.error) throw this._formDataCache.error;
|
||||
return this._formDataCache.value!;
|
||||
}
|
||||
try {
|
||||
const value = await this.request.formData();
|
||||
this._formDataCache = { done: true, value };
|
||||
return value;
|
||||
} catch (e) {
|
||||
this._formDataCache = { done: true, error: e as Error };
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
import { HttpError, RouteNotFoundError, ServerAlreadyRunningError, WebSocketConfigError } from './smartserve.errors.js';
|
||||
import { AdapterFactory, type BaseAdapter, type TRequestHandler } from '../adapters/index.js';
|
||||
import { ControllerRegistry, type ICompiledRoute, type IRouteCompressionOptions } from '../decorators/index.js';
|
||||
@@ -345,8 +439,8 @@ export class SmartServe {
|
||||
const { route, params } = match;
|
||||
|
||||
try {
|
||||
// Create request context
|
||||
const context = await this.createContext(request, url, params, connectionInfo);
|
||||
// Create request context (body parsing is lazy via ctx.json(), ctx.text(), etc.)
|
||||
const context = this.createContext(request, url, params, connectionInfo);
|
||||
|
||||
// Run interceptors and handler
|
||||
const response = await this.executeRoute(route, context);
|
||||
@@ -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,
|
||||
url: URL,
|
||||
params: Record<string, string>,
|
||||
connectionInfo: IConnectionInfo
|
||||
): Promise<IRequestContext> {
|
||||
): IRequestContext {
|
||||
// Parse query params
|
||||
const query: Record<string, string> = {};
|
||||
url.searchParams.forEach((value, key) => {
|
||||
query[key] = value;
|
||||
});
|
||||
|
||||
// Parse body (lazy)
|
||||
let body: any = undefined;
|
||||
const contentType = request.headers.get('content-type');
|
||||
|
||||
if (request.method !== 'GET' && request.method !== 'HEAD') {
|
||||
if (contentType?.includes('application/json')) {
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
body = null;
|
||||
}
|
||||
} else if (contentType?.includes('application/x-www-form-urlencoded')) {
|
||||
try {
|
||||
const text = await request.text();
|
||||
body = Object.fromEntries(new URLSearchParams(text));
|
||||
} catch {
|
||||
body = null;
|
||||
}
|
||||
} else if (contentType?.includes('text/')) {
|
||||
try {
|
||||
body = await request.text();
|
||||
} catch {
|
||||
body = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
return new RequestContext(
|
||||
request,
|
||||
body,
|
||||
params,
|
||||
query,
|
||||
headers: request.headers,
|
||||
path: url.pathname,
|
||||
method: request.method.toUpperCase() as THttpMethod,
|
||||
request.headers,
|
||||
url.pathname,
|
||||
request.method.toUpperCase() as THttpMethod,
|
||||
url,
|
||||
runtime: this.adapter?.name ?? 'node',
|
||||
state: {},
|
||||
};
|
||||
this.adapter?.name ?? 'node',
|
||||
{},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -24,10 +24,8 @@ export type TRuntime = 'node' | 'deno' | 'bun';
|
||||
* Wraps Web Standard Request with additional utilities
|
||||
*/
|
||||
export interface IRequestContext<TBody = unknown> {
|
||||
/** Original Web Standards Request */
|
||||
/** Original Web Standards Request - body stream is never consumed by framework */
|
||||
readonly request: Request;
|
||||
/** Parsed request body (typed) */
|
||||
readonly body: TBody;
|
||||
/** URL path parameters extracted from route */
|
||||
readonly params: Record<string, string>;
|
||||
/** URL query parameters */
|
||||
@@ -44,6 +42,34 @@ export interface IRequestContext<TBody = unknown> {
|
||||
readonly runtime: TRuntime;
|
||||
/** Route-specific state bag for passing data between interceptors */
|
||||
state: Record<string, unknown>;
|
||||
|
||||
/**
|
||||
* Lazily parse request body as JSON.
|
||||
* Result is cached after first call.
|
||||
* @returns Typed body parsed from JSON
|
||||
*/
|
||||
json(): Promise<TBody>;
|
||||
|
||||
/**
|
||||
* Lazily parse request body as text.
|
||||
* Result is cached after first call.
|
||||
* @returns Body as string
|
||||
*/
|
||||
text(): Promise<string>;
|
||||
|
||||
/**
|
||||
* Lazily parse request body as ArrayBuffer.
|
||||
* Result is cached after first call.
|
||||
* @returns Body as ArrayBuffer
|
||||
*/
|
||||
arrayBuffer(): Promise<ArrayBuffer>;
|
||||
|
||||
/**
|
||||
* Lazily parse request body as FormData.
|
||||
* Result is cached after first call.
|
||||
* @returns Body as FormData
|
||||
*/
|
||||
formData(): Promise<FormData>;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
||||
@@ -116,15 +116,15 @@ export function createValidationErrorResponse(
|
||||
* Validate the full request based on OpenAPI metadata
|
||||
* Returns a Response if validation fails, undefined if valid
|
||||
*/
|
||||
export function validateRequest(
|
||||
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
|
||||
@@ -164,10 +164,16 @@ export function validateRequest(
|
||||
}
|
||||
}
|
||||
|
||||
// Validate request body
|
||||
// Validate request body (lazy parsing via ctx.json())
|
||||
if (openapi.requestBody) {
|
||||
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)) {
|
||||
allErrors.push({
|
||||
@@ -210,7 +216,7 @@ export function validateRequest(
|
||||
*/
|
||||
export function createValidationInterceptor(openapi: IOpenApiRouteMeta) {
|
||||
return async (ctx: IRequestContext): Promise<IRequestContext | Response | void> => {
|
||||
const result = validateRequest(ctx, openapi);
|
||||
const result = await validateRequest(ctx, openapi);
|
||||
|
||||
if (!result.valid && result.response) {
|
||||
return result.response;
|
||||
|
||||
Reference in New Issue
Block a user