Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -319,3 +319,22 @@ instead」)。`@koa/router` 是官方维护的继任包,API 与 koa-router *
- **`z.anyObject()`**:等价 `z.object({}).catchall(z.unknown())`,挂在 erest 导出的 `z` 上(也具名导出 `zAnyObject` 常量)。供「动态字段 body」场景使用。
- **`success<T>()`**:TestAgent 测试方法泛型化,返回类型可从 response schema 推导(`.get<UserVO>(path).success<UserVO>()`);默认 `unknown` 向后兼容。
- **`setResponseEnvelopers({ success, error, testUnwrapper })`**:注册后 registerTyped handler 进入「return 模式」——handler 只 `return data`,框架用 success enveloper 自动包装成响应体;抛错用 error enveloper 包装成 `{body, status}`。未注册时维持 v3.1 行为(handler 调 `ctx.reply` 写响应)。`testUnwrapper` 供测试脚手架拆信封(便捷入口,等价 `setFormatOutput`)。


## v3.2.1 — enveloper 逃生舱 + @leizm/web send 修复 + 测试文档

### 新增(非 breaking)

- **`Reply.markSent()`**:enveloper 模式下,`registerTyped` handler 若需返回非 JSON 响应(CSV / 文件下载 / 流式)并经 `ctx.reply.raw` 手动写完响应,调用 `ctx.reply.markSent()` 告知 enveloper 跳过自动包装。否则 enveloper 会在 handler return 后再用 `successEnveloper` 包一层 `reply.json()`,可能覆盖已发送响应或触发「headers already sent」(各框架行为不一)。三 adapter(express/koa/leizmweb)的 `reply` 实现统一维护 `__sent` 标记:`json()`/`send()`/`markSent()` 均置位,`wrapWithEnvelope` 综合判断。不调 `markSent` 的存量 handler 行为不变。

### 修复

- **`@erest/leizmweb` `reply.send` 映射错误导致纯文本响应 404**:`createLeiReply.send` 此前调 `ctx.response.send()`,但 `@leizm/web` 的 response 没有 `send` 方法(仅有 `end/json/file`)。可选链 `leiRes.send?.()` 静默跳过 → 响应体未写 → 框架 final handler 返回 404。修正为映射到 `end(body)` + 补 `text/plain` content-type。**影响面**:`@leizm/web` adapter 下任何用 `ctx.reply.send(文本)` 的路由此前都会 404(用 `reply.json` 的路由不受影响)。

### 文档

- **测试引擎 `.input()` 的方法归属约定显式化**:GET/DELETE 归 query、POST/PUT/PATCH 归 body。注册 DELETE 路由时 schema 必须从 `query` 读参数(HTTP DELETE body 有争议,fetch/浏览器/代理普遍不传递)。README 与 `agent.ts` JSDoc 同步补充。

### Breaking

- **`zod` 从 `dependencies` 改为 `peerDependencies`**:作为 schema 校验库,erest 不应硬钉死 zod 版本,应由宿主项目统一提供。此前 erest 把 `zod@^4.0.5` 列为 hard dependency,导致任何 link/依赖 erest 的项目出现两份 zod 副本(erest 自带的 + 宿主的),TS 因 zod 版本字面量标记不同而报类型不兼容。**迁移**:宿主项目须在自身 `dependencies` 显式声明 `zod`(版本 `^4.0.0`)。adapter 子包(`@erest/express` 等)不直接依赖 zod,无需改动。
35 changes: 35 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,19 @@ api.genDocs('./docs/');
> `.input(data)` 对 **GET/DELETE** 把 `data` 转为 query string,对 **POST/PUT/PATCH** 作为 JSON body。
> 用 `.query(data)` / `.json(data)` 可显式指定来源。

> **⚠️ DELETE 参数从 query 读**:HTTP DELETE 带 body 有争议(fetch/浏览器/代理普遍不保证传递),
> 所以测试引擎把 DELETE 的 `.input()` 归为 query,**生产侧三 adapter 也只从 query 读 DELETE 参数**。
> 因此注册 DELETE 路由时,schema 必须用 `query`(不是 `body`):

```typescript
// ✅ DELETE 的确认参数走 query
api.delete('/items/:id').registerTyped(
{ params: z.object({ id: z.string() }), query: z.object({ confirm: z.boolean() }) },
(req) => { if (!req.query.confirm) throw new Error('需确认'); /* ... */ },
);
// 测试侧对应:api.test.delete('/items/1').input({ confirm: true })
```

```typescript
// initTest 接收 http.Server 或框架的 app/server/callback
api.initTest(app);
Expand Down Expand Up @@ -471,6 +484,28 @@ api.api.post('/login').group('auth').title('登录').registerTyped(

> ⚠️ `raw` 的头部/cookie 操作应在 `ctx.reply.json()`/`ctx.reply.send()` **之前**调用(HTTP 头先于体发送)。`reply.raw` 是逃生舱,不参与框架无关复用——用了 raw 的 handler 即与具体框架耦合。

#### `ctx.reply.markSent()`:enveloper 模式下的非 JSON 响应逃生舱

当注册了 `setResponseEnvelopers` 且 `registerTyped` handler 需要返回**非 JSON 响应**(CSV / 文件下载 / 流式)时,handler 必须经 `reply.raw` 手动写完整响应,并调用 **`ctx.reply.markSent()`** 告知 enveloper 跳过自动包装。

否则 handler return 后,enveloper 会用 `successEnveloper` 包一层 `reply.json()`,可能覆盖已发送的响应或触发「headers already sent」类错误(各框架行为不一)。

```typescript
api.group('admin').get('/tables/:id/export').registerTyped(
{ params: z.object({ id: z.string() }) },
(req, ctx) => {
// 经 raw 手动写 CSV(非 JSON),三框架原生写法不同
const res = ctx.reply.raw.res; // Express;Koa 用 ctx.reply.raw.body,@leizm/web 用 ctx.reply.raw.response
res.setHeader('Content-Type', 'text/csv');
res.end('a,b\n1,2');
ctx.reply.markSent(); // ← 关键:跳过 enveloper 的自动包装
},
);
```

> `markSent()` 只在「enveloper + registerTyped + 手动 raw 响应」三者同时成立时需要。
> 用 `register()`(非 typed)注册的 handler 本就不触发 enveloper,无需调用。

## 参数读取:`$params` 与分层访问器

adapter 的 checker 把校验后的参数注入到 erest 的标准化 `ctx` 上,handler 通过它们读取。
Expand Down
14 changes: 10 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,11 +58,16 @@
"homepage": "https://github.com/yourtion/node-erest#readme",
"dependencies": {
"debug": "^4.4.1",
"path-to-regexp": "^6.3.0",
"zod": "^4.0.5"
"path-to-regexp": "^6.3.0"
},
"peerDependencies": {
"@types/node": "*"
"@types/node": "*",
"zod": "^4.0.0"
},
"peerDependenciesMeta": {
"zod": {
"optional": false
}
},
"devDependencies": {
"@leizm/web": "^2.7.3",
Expand All @@ -85,7 +90,8 @@
"oxlint": "1.71.0",
"typedoc": "^0.28.19",
"typescript": "^5.8.3",
"vitest": "^3.2.4"
"vitest": "^3.2.4",
"zod": "^4.4.3"
},
"pnpm": {
"onlyBuiltDependencies": [
Expand Down
10 changes: 8 additions & 2 deletions packages/erest-express/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,19 +137,25 @@ export class ExpressAdapter<T = unknown> implements FrameworkAdapter<T, ExpressR
}

/** 构造 Express 的 Reply 封装(含 raw 逃生舱:原生 { req, res }) */
function createExpressReply(req: unknown, res: unknown): Reply<ExpressRaw> {
function createExpressReply(req: unknown, res: unknown): Reply<ExpressRaw> & { __sent: boolean } {
const expressRes = res as { status?: (c: number) => unknown; json?: (b: unknown) => void; end?: (b: string) => void };
const reply: Reply<ExpressRaw> = {
const reply: Reply<ExpressRaw> & { __sent: boolean } = {
__sent: false,
status(code: number) {
expressRes.status?.(code);
return reply;
},
json(body: unknown) {
reply.__sent = true;
expressRes.json?.(body);
},
send(body: string) {
reply.__sent = true;
expressRes.end?.(body);
},
markSent() {
reply.__sent = true;
},
raw: { req, res } as ExpressRaw,
};
return reply;
Expand Down
10 changes: 8 additions & 2 deletions packages/erest-koa/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,20 +136,26 @@ export class KoaAdapter<T = unknown> implements FrameworkAdapter<T, KoaRaw> {
}

/** 构造 Koa 的 Reply 封装(写 ctx.status / ctx.body;含 raw 逃生舱:原生 ctx) */
function createKoaReply(ctx: Record<string, unknown>): Reply<KoaRaw> {
function createKoaReply(ctx: Record<string, unknown>): Reply<KoaRaw> & { __sent: boolean } {
const koaCtx = ctx as { status?: number; body?: unknown; type?: string };
const reply: Reply<KoaRaw> = {
const reply: Reply<KoaRaw> & { __sent: boolean } = {
__sent: false,
status(code: number) {
koaCtx.status = code;
return reply;
},
json(body: unknown) {
reply.__sent = true;
koaCtx.type = "application/json";
koaCtx.body = JSON.stringify(body);
},
send(body: string) {
reply.__sent = true;
koaCtx.body = body;
},
markSent() {
reply.__sent = true;
},
raw: ctx as KoaRaw,
};
return reply;
Expand Down
21 changes: 16 additions & 5 deletions packages/erest-leizmweb/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,23 +133,34 @@ export class LeizmWebAdapter<T = unknown> implements FrameworkAdapter<T, LeizmWe
}
}

/** 构造 @leizm/web 的 Reply 封装(写 ctx.response.json/status/send;含 raw 逃生舱:原生 ctx) */
function createLeiReply(ctx: Record<string, unknown>): Reply<LeizmWebRaw> {
/** 构造 @leizm/web 的 Reply 封装(写 ctx.response.json/statussend→end;含 raw 逃生舱:原生 ctx) */
function createLeiReply(ctx: Record<string, unknown>): Reply<LeizmWebRaw> & { __sent: boolean } {
const leiRes = (ctx.response ?? {}) as {
status?: (c: number) => unknown;
json?: (b: unknown) => void;
send?: (b: string) => void;
end?: (b?: string) => void;
setHeader?: (n: string, v: string) => void;
};
const reply: Reply<LeizmWebRaw> = {
const reply: Reply<LeizmWebRaw> & { __sent: boolean } = {
__sent: false,
status(code: number) {
leiRes.status?.(code);
return reply;
},
json(body: unknown) {
reply.__sent = true;
leiRes.json?.(body);
},
// @leizm/web 的 response 没有 send() 方法(仅有 end/json/file),纯文本响应用 end()。
// 此前误调不存在的 send()(可选链静默跳过)→ 响应体未写 → 框架 final handler 返回 404。
// 修正:send 映射到 end(),并补 text/plain content-type,与 json() 的 application/json 对齐。
send(body: string) {
leiRes.send?.(body);
reply.__sent = true;
leiRes.setHeader?.("Content-Type", "text/plain; charset=utf-8");
leiRes.end?.(body);
},
markSent() {
reply.__sent = true;
},
// LeizmWebRaw 是 @leizm/web 的 Context(结构含 request/response/session 等),
// 与此处的 Record<string, unknown> 不充分重叠,需经 unknown 双重断言(同运行时桥接)
Expand Down
11 changes: 8 additions & 3 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 10 additions & 0 deletions src/lib/adapters/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,11 @@ export type CheckerFunction<T> = (erest: ERest<T>, schema: API<T>) => T;
* `raw` 是逃生舱:当需要框架特有能力(setCookie/redirect/stream/文件下载等)时,
* 通过 `reply.raw` 访问框架原生对象。类型由 `ERest<T, Raw>` 的 Raw 泛型驱动,
* 经子包 `createERest()` 工厂在构造时锁定。
*
* `markSent()` 是 enveloper 逃生舱:当 registerTyped handler 需要返回非 JSON 响应
* (CSV/文件下载/流式)并已通过 raw 手动写完响应时,调用它告知 enveloper 跳过自动
* 包装。否则 enveloper 会在 handler return 后再用 successEnveloper 包一层 json,
* 可能覆盖已发送的响应或抛错(各框架行为不一)。
*/
export interface Reply<Raw = unknown> {
/** 设置 HTTP 状态码并返回自身,支持链式调用 */
Expand All @@ -93,6 +98,11 @@ export interface Reply<Raw = unknown> {
json(body: unknown): void;
/** 以纯文本写入响应体 */
send(body: string): void;
/**
* 标记响应已由 handler 手动发送(如经 raw 写 CSV/文件流),enveloper 将跳过自动包装。
* 仅在注册了 enveloper 且 handler 已自行写完整响应时需要调用。
*/
markSent(): void;
/** 框架原生对象逃生舱(setCookie/redirect/stream/文件下载等) */
readonly raw: Raw;
}
Expand Down
6 changes: 5 additions & 1 deletion src/lib/adapters/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,11 @@ export function wrapWithEnvelope(
// 成功:handler 执行完无抛错。enveloper 模式下用 successEnveloper 包装 return 值。
// __returned 标记由 wrappedHandler 在 handler return 后置位(含 return undefined);
// 未置位说明 handler 自行调 ctx.reply 写了响应(非 enveloper 用法),不再二次包装。
if (successEnveloper && (ctx as Context & { __returned?: boolean }).__returned) {
// 额外检查 reply.__sent:handler 若调用了 reply.markSent()(或 json/send),
// 说明响应已手动发送(如经 raw 写 CSV/文件流),enveloper 不再二次包装,
// 避免覆盖已发送的响应或触发「headers already sent」类错误(各框架行为不一)。
const reply = ctx.reply as { __sent?: boolean };
if (successEnveloper && (ctx as Context & { __returned?: boolean }).__returned && !reply.__sent) {
const returnValue = (ctx as Context & { __returnValue?: unknown }).__returnValue;
const wrapped = successEnveloper(returnValue, ctx);
ctx.reply.json(wrapped);
Expand Down
12 changes: 11 additions & 1 deletion src/lib/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,17 @@ export class TestAgent {
return this;
}

/** 添加输入参数 */
/**
* 添加输入参数。
*
* 方法归属约定(HTTP 语义 + 浏览器/fetch 兼容):
* - GET / DELETE:归为 query(URL ?k=v)。HTTP DELETE 带 body 有争议,
* fetch/浏览器/代理普遍不保证传递 DELETE body,故统一走 query。
* - POST / PUT / PATCH:归为 body(JSON)。
*
* 因此注册 DELETE 路由时,schema 应从 query 读取参数(非 body):
* api.delete('/items/:id').registerTyped({ query: z.object({ confirm: z.boolean() }) }, ...)
*/
public input(data: Record<string, unknown>) {
this.debug("input: %j", data);
Object.assign(this.options.agentInput, data);
Expand Down
Loading
Loading