diff --git a/.gitignore b/.gitignore index 33c5e65..e1e32dd 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ package-lock.json # 构建产物 dist/ +!docs/superpowers docs/ examples/docs/out/ # 根级 TS 配置的编译产物(vitest.bench.config.ts 等,误 tsc 时会生成同名 .js/.d.ts) diff --git a/MIGRATION.md b/MIGRATION.md index ae2f5ef..986b27a 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -306,3 +306,16 @@ instead」)。`@koa/router` 是官方维护的继任包,API 与 koa-router * `@koa/router`。erest 仓库内部测试仍用 koa-router(开发环境 deprecated warning 无害),不影响 发布的 peer 声明。 + +## v3.2 — 类型安全 state + 全局响应信封 + 便利 schema + +### Breaking(仅当启用新特性时) + +- **registerTyped handler 第二参数从 `reply` 统一为 `ctx`**:签名 `(req, reply) => void` 变为 `(req, ctx) => data`。`ctx` 即 `Context`,含 `ctx.reply`(原 reply 能力)+ `ctx.state`(typed)。存量 handler `(req, reply) => reply.json(x)` 须改为 `(req, ctx) => ctx.reply.json(x)`。 +- **ctx.state 类型由 `ERest` 的 State 泛型驱动**:默认 `Record`(存量代码不变)。启用 `createERest()` 后,`ctx.state` 收紧为 MyState——**State 须用 `type` alias 定义**(不能用 `interface`,否则不满足 `Record` 约束)。 + +### 新增(非 breaking) + +- **`z.anyObject()`**:等价 `z.object({}).catchall(z.unknown())`,挂在 erest 导出的 `z` 上(也具名导出 `zAnyObject` 常量)。供「动态字段 body」场景使用。 +- **`success()`**:TestAgent 测试方法泛型化,返回类型可从 response schema 推导(`.get(path).success()`);默认 `unknown` 向后兼容。 +- **`setResponseEnvelopers({ success, error, testUnwrapper })`**:注册后 registerTyped handler 进入「return 模式」——handler 只 `return data`,框架用 success enveloper 自动包装成响应体;抛错用 error enveloper 包装成 `{body, status}`。未注册时维持 v3.1 行为(handler 调 `ctx.reply` 写响应)。`testUnwrapper` 供测试脚手架拆信封(便捷入口,等价 `setFormatOutput`)。 diff --git a/README.md b/README.md index 27c3987..e415c7b 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ ERest 通过统一的 `bind()` 方法接入任意框架。API 定义方式与框 ### 定义 API 无论使用哪个框架,API 的定义方式完全一致。推荐使用 `registerTyped`——它基于 Zod schema -自动推导参数类型,handler 签名为**框架无关的 `(req, reply)`**,同一份 handler 可被 +自动推导参数类型,handler 签名为**框架无关的 `(req, ctx)`**,同一份 handler 可被 Express / Koa / @leizm/web 复用: ```typescript @@ -100,9 +100,9 @@ api.api params: z.object({ id: z.string().describe('用户ID') }), query: z.object({ include: z.string().optional().describe('包含的关联数据') }), }, - async (req, reply) => { + async (req, ctx) => { const user = await getUserById(req.params.id, req.query.include); - reply.json({ success: true, data: user }); + ctx.reply.json({ success: true, data: user }); }, ); @@ -117,14 +117,14 @@ api.api .post('/users') .group('user') .title('创建用户') - .registerTyped({ body: CreateUserSchema }, async (req, reply) => { + .registerTyped({ body: CreateUserSchema }, async (req, ctx) => { const user = await createUser(req.body); - reply.status(201).json({ success: true, data: user }); + ctx.reply.status(201).json({ success: true, data: user }); }); ``` -> **handler 签名说明**:`registerTyped` 的 handler 是 `(req, reply)`,其中 -> `req.params/query/body/headers` 是分层校验后的参数,`reply.json()/status()/send()` +> **handler 签名说明**:`registerTyped` 的 handler 是 `(req, ctx)`,其中 +> `req.params/query/body/headers` 是分层校验后的参数,`ctx.reply.json()/status()/send()` > 是统一的响应接口。**不要**用 Express 的 `(req, res)` 或 Koa 的 `ctx.body = ...` 写法—— > 详见 [register 与 registerTyped 的区别](#类型安全的-handlerregistertyped)。 @@ -231,14 +231,14 @@ app.listen(3000); > **handler 签名**:erest 在 Koa 下同样使用**标准化 `(ctx, next)`** 签名——`ctx` 是 erest > 的内部上下文(有 `reply`/`$params`/`$validated` 等),**不是** Koa 原生 ctx,没有 `.body` setter。 -> 返回响应请用 `ctx.reply.json()`。推荐直接用 `registerTyped`(handler 为 `(req, reply)`): +> 返回响应请用 `ctx.reply.json()`。推荐直接用 `registerTyped`(handler 为 `(req, ctx)`): ```typescript api.api .post('/users') .group('user') - .registerTyped({ body: z.object({ name: z.string() }) }, (req, reply) => { - reply.json({ name: req.body.name }); + .registerTyped({ body: z.object({ name: z.string() }) }, (req, ctx) => { + ctx.reply.json({ name: req.body.name }); }); ``` @@ -287,14 +287,14 @@ app.server.listen(3000); > **handler 签名**:erest 在 @leizm/web 下同样使用**标准化 `(ctx, next)`** 签名——`ctx` 是 erest > 的内部上下文(有 `reply`/`$params`/`$validated` 等),**不是** @leizm/web 原生 ctx,没有 `.response`。 -> 返回响应请用 `ctx.reply.json()`。推荐直接用 `registerTyped`(handler 为 `(req, reply)`): +> 返回响应请用 `ctx.reply.json()`。推荐直接用 `registerTyped`(handler 为 `(req, ctx)`): ```typescript api.api .post('/users') .group('user') - .registerTyped({ body: z.object({ name: z.string() }) }, (req, reply) => { - reply.json({ name: req.body.name }); + .registerTyped({ body: z.object({ name: z.string() }) }, (req, ctx) => { + ctx.reply.json({ name: req.body.name }); }); ``` @@ -395,8 +395,8 @@ it('应拒绝未成年用户', async () => { `req.params` / `req.query` / `req.body` / `req.headers` 的类型,**编译期类型安全、运行时由 checker 统一校验**,且对 Express / Koa / @leizm/web 三个框架都有效。 -handler 签名为 `(req, reply)`——**与框架无关**:`req` 是分层校验后的参数,`reply` 是统一的响应接口 -(`reply.json()` / `reply.status()`)。因此**同一份 handler 可被三个框架复用**,无需关心 ctx/res 差异。 +handler 签名为 `(req, ctx)`——**与框架无关**:`req` 是分层校验后的参数,`reply` 是统一的响应接口 +(`ctx.reply.json()` / `ctx.reply.status()`)。因此**同一份 handler 可被三个框架复用**,无需关心 ctx/res 差异。 ```typescript const CreateUserSchema = z.object({ @@ -411,12 +411,12 @@ api.api .title('创建用户') .registerTyped( { body: CreateUserSchema }, - (req, reply) => { + (req, ctx) => { // req.body 类型由 CreateUserSchema 自动推导:{ name: string; email: string; age: number } // 无需任何 `as` 类型断言 const user = createUser(req.body); // reply 框架无关:内部封装各框架的原生响应写法(Express res / Koa ctx / @leizm/web ctx) - reply.status(201).json({ success: true, id: user.id }); + ctx.reply.status(201).json({ success: true, id: user.id }); }, ); ``` @@ -429,16 +429,16 @@ api.api | API | handler 签名 | 适用场景 | |-----|-------------|----------| -| `registerTyped(schemas, fn)` | `(req, reply)` | **推荐**。编译期类型安全,校验自动完成,框架无关 | +| `registerTyped(schemas, fn)` | `(req, ctx)` | **推荐**。编译期类型安全,校验自动完成,框架无关 | | `register(fn)` / `define({handler})` | `(ctx, next)` | 标准化 Koa 风格签名。`ctx` 有 `$params`/`$validated`/`reply`/`state` 等,需自己读 ctx(无类型推导)。适合需要 `next` 控制流或 `ctx.state` 跨中间件传数据的场景 | -无论哪种 API,**响应都通过 `reply` 写入**——不要用框架原生的 `res.json()` / `ctx.body =` / `ctx.response.json()`。 +无论哪种 API,**响应都通过 `reply` 写入**(`registerTyped` 是 `ctx.reply`,`register` 是 `ctx.reply`)——不要用框架原生的 `res.json()` / `ctx.body =` / `ctx.response.json()`。 -### 原生能力逃生舱:`reply.raw` +### 原生能力逃生舱:`ctx.reply.raw` -`reply` 只暴露 `json()/status()/send()` 三个框架无关方法。当需要框架特有能力(setCookie、redirect、流式响应、文件下载等)时,通过 `reply.raw` 访问框架原生对象——它是「逃生舱」,绕开标准抽象直达底层。 +handler 第二参数 `ctx` 携带的 `reply` 只暴露 `json()/status()/send()` 三个框架无关方法。当需要框架特有能力(setCookie、redirect、流式响应、文件下载等)时,通过 `ctx.reply.raw` 访问框架原生对象——它是「逃生舱」,绕开标准抽象直达底层。 -`reply.raw` 的类型由 **`ERest` 的 Raw 泛型**驱动。用子包提供的 `createERest()` 工厂创建实例,会在构造时自动锁定 Raw,handler 内 `reply.raw` 自动强类型、**零标注**: +`ctx.reply.raw` 的类型由 **`ERest` 的 Raw 泛型**驱动。用子包提供的 `createERest()` 工厂创建实例,会在构造时自动锁定 Raw,handler 内 `ctx.reply.raw` 自动强类型、**零标注**: ```typescript import { createERest } from '@erest/express'; @@ -447,20 +447,20 @@ const api = createERest({ info, groups, forceGroup: true }); api.api.post('/login').group('auth').title('登录').registerTyped( { body: LoginSchema }, - (req, reply) => { + (req, ctx) => { const user = authenticate(req.body); - // reply.raw 自动推导为 { req: Request; res: Response },无需断言 - reply.raw.res.cookie('token', sign(user), { httpOnly: true }); - reply.json({ ok: true }); + // ctx.reply.raw 自动推导为 { req: Request; res: Response },无需断言 + ctx.reply.raw.res.cookie('token', sign(user), { httpOnly: true }); + ctx.reply.json({ ok: true }); }, ); ``` -> 直接 `new ERest()` 仍可用(过渡期保留),但 Raw 默认为 `unknown`,`reply.raw` 需手动断言。新代码推荐用子包 `createERest()`。 +> 直接 `new ERest()` 仍可用(过渡期保留),但 Raw 默认为 `unknown`,`ctx.reply.raw` 需手动断言。新代码推荐用子包 `createERest()`。 -**框架原生能力速查表**(`reply.raw` 在三框架下的形态不同,签名/语义各自遵循原生约定): +**框架原生能力速查表**(`ctx.reply.raw` 在三框架下的形态不同,签名/语义各自遵循原生约定): -| 能力 | Express (`reply.raw`) | Koa (`reply.raw`) | @leizm/web (`reply.raw`) | +| 能力 | Express (`ctx.reply.raw`) | Koa (`ctx.reply.raw`) | @leizm/web (`ctx.reply.raw`) | |------|------|------|------| | 设置 cookie | `.res.cookie(name, val, opts)` | `.cookies.set(name, val, opts)` | `.response.setHeader('Set-Cookie', ...)` | | 读取 cookie | `.req.cookies`(需 cookie-parser) | `.cookies.get(name)` | `.request.cookies` | @@ -469,7 +469,7 @@ api.api.post('/login').group('auth').title('登录').registerTyped( | 设置响应头 | `.res.setHeader(k, v)` | `.set(k, v)` | `.response.setHeader(k, v)` | | 响应头已发送 | `.res.headersSent` | `.res.headersSent` | 查原生 | -> ⚠️ `raw` 的头部/cookie 操作应在 `reply.json()`/`reply.send()` **之前**调用(HTTP 头先于体发送)。`reply.raw` 是逃生舱,不参与框架无关复用——用了 raw 的 handler 即与具体框架耦合。 +> ⚠️ `raw` 的头部/cookie 操作应在 `ctx.reply.json()`/`ctx.reply.send()` **之前**调用(HTTP 头先于体发送)。`reply.raw` 是逃生舱,不参与框架无关复用——用了 raw 的 handler 即与具体框架耦合。 ## 参数读取:`$params` 与分层访问器 @@ -586,7 +586,7 @@ throw ERestError.invalidParam('age', 'Integer', 'abc'); `examples/` 是一个**迷你博客业务域**的完整最佳实践样板,串联 erest 全部核心能力: **一份 API 定义(`src/api.js`),三个框架入口**(`src/entries/`)。handler 用 `registerTyped` -的 `(req, reply)` 签名声明一次,被 @leizm/web / Express / Koa 复用,仅 `bind()` 参数不同。 +的 `(req, ctx)` 签名声明一次,被 @leizm/web / Express / Koa 复用,仅 `bind()` 参数不同。 examples 作为 pnpm workspace 子包,通过 `erest: workspace:*` 引用本地 erest,安装时自动 link。 diff --git a/docs/superpowers/plans/2026-06-30-typed-state-envelope-ergonomics.md b/docs/superpowers/plans/2026-06-30-typed-state-envelope-ergonomics.md new file mode 100644 index 0000000..a2f81ce --- /dev/null +++ b/docs/superpowers/plans/2026-06-30-typed-state-envelope-ergonomics.md @@ -0,0 +1,1273 @@ +# erest v3.2 类型安全 state + 全局响应信封 + 便利 schema 实现计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 在 erest 框架层补齐三块能力(类型安全 state、全局 response envelope + registerTyped 强制 return、便利 schema 别名 + 测试返回类型推导),并以 one-api phase2-server 同步适配作为集成验证。 + +**Architecture:** 三块独立改进,按依赖顺序实施——Task 1(便利 schema,最小最独立)→ Task 2(typed state + handler 第二参数统一为 ctx,纯类型层泛型透传)→ Task 3(测试返回类型从 response schema 推导,依赖 Task 2 的 ctx 签名)→ Task 4-5(envelope + dispatcher 接入,改动面最大)→ Task 6(one-api 适配验证)。向后兼容是硬约束:State 默认值 / 未注册 enveloper / 未声明 response schema 时均维持 v3.1 行为。 + +> **关键时序约束:** Task 2 把 `registerTyped` handler 第二参数从 `reply` 统一为 `ctx`(breaking,详见 Task 2 Step 5)。Task 3 及之后所有测试代码均采用 `(req, ctx) => ctx.reply.json(...)` 新签名。Task 2 必须在 Task 3 之前完成。 + +**Tech Stack:** TypeScript(strict)、Zod 4、Vitest 3、pnpm workspace。erest 主仓库 `node-erest`(框架)+ 消费仓库 `one-api`(phase2-server worktree)。 + +**前置准备(一次性,开工前执行):** +```bash +cd /Users/yourtionguo/codes/open/node-erest +git checkout feat/typed-state-envelope # spec 已提交到此分支 +pnpm install +pnpm test:lib # 确认基线全绿(ISLIB=1 直走源码) +``` + +**测试命令约定(贯穿全计划):** +```bash +# erest 框架侧(在 node-erest 仓库根目录) +pnpm test:lib # 全量测试(ISLIB=1,直走源码,最快) +pnpm test:lib -- src/test/test-register-typed.ts # 单文件 +pnpm typecheck # 类型检查 +pnpm lint # oxlint + +# 覆盖率(门槛:branches 80 / functions 95 / lines 80 / statements 80) +pnpm test:cov +``` + +--- + +## 文件结构(改动总览) + +### 框架侧(node-erest) + +| 文件 | 职责 | 改动 | +|---|---|---| +| `src/lib/params.ts` | Zod 校验 + schema 别名 | **新增** `anyObject` 工厂 + `zodTypeMap` 不变 | +| `src/lib/index.ts` | ERest 门面 | re-export `z.anyObject`;新增 `State` 泛型 + `setResponseEnvelopers`;`formatOutputReverse` 默认从 enveloper 推导 | +| `src/lib/adapters/types.ts` | Context/Reply/Middleware 类型 | `Context` / `Middleware` 加泛型 | +| `src/lib/api.ts` | API 定义 + registerTyped | `API` 透传 State;`registerTyped` 支持 enveloper 模式(return data) | +| `src/lib/adapters/utils.ts` | compose + buildHandlerChain | **新增** `wrapWithEnvelope` 工具(成功/错误分支统一信封) | +| `packages/erest-{express,koa,leizmweb}/src/index.ts` | 三框架 adapter | `createERest` 透传 State 泛型;`bindRoute` 接入 enveloper(dispatch 后处理 return/catch) | +| `src/lib/agent.ts` | TestAgent | `success()/error()` 返回类型从 response schema 推导 | +| `src/lib/extend/test.ts` | IAPITest | `findApi` 携带 responseType;`buildTest` 透传泛型 | + +### 消费侧(one-api phase2-server worktree) + +| 文件 | 改动 | +|---|---| +| `apps/server/src/api/instance.ts` | `createERest()` + `setResponseEnvelopers` | +| `apps/server/src/api/format.ts` | **删除** | +| `apps/server/src/utils/response.ts` | **删除**(或保留 `ApiResponse` 类型导出,按实际依赖定) | +| `apps/server/src/hooks.ts` | `ctx.state['x']` → `ctx.state.x` | +| `apps/server/src/routes/*.ts` | `reply.json(ok(x))` → `return x`;去 `as`;`z.object({}).catchall` → `z.anyObject()` | +| `apps/server/src/__tests__/helpers.ts` | 删 `applyEnvelopeFormat` 调用 | +| `apps/server/src/app.ts` | 错误中间件简化 | + +--- + +## Task 1: z.anyObject() 便利 schema 别名 + +**Files:** +- Modify: `src/lib/params.ts`(新增 `anyObject` 工厂导出) +- Modify: `src/lib/index.ts`(re-export + 挂到 z 命名空间) +- Test: `src/test/test-zod-native.ts`(新增用例) + +**背景:** one-api 用 `z.object({}).catchall(z.unknown())` 表示「任意动态字段 body」(4 处),Zod 黑话。erest 提供 `z.anyObject()` 语义化别名。 + +- [ ] **Step 1: 写失败测试** + +在 `src/test/test-zod-native.ts` 末尾新增(import 已有 `import { z } from "zod"`,改用 erest 导出的 z——见 Step 3 说明): + +```ts +describe("z.anyObject() 便利别名", () => { + it("应接受任意键值的对象", () => { + const schema = z.anyObject(); + const parsed = schema.parse({ foo: 1, bar: "x", nested: { a: true } }); + expect(parsed).toEqual({ foo: 1, bar: "x", nested: { a: true } }); + }); + + it("应拒绝非对象值", () => { + const schema = z.anyObject(); + expect(() => schema.parse("not-object")).toThrow(); + expect(() => schema.parse(42)).toThrow(); + expect(() => schema.parse(null)).toThrow(); + }); + + it("空对象应通过", () => { + const schema = z.anyObject(); + expect(schema.parse({})).toEqual({}); + }); +}); +``` + +- [ ] **Step 2: 运行测试确认失败** + +```bash +pnpm test:lib -- src/test/test-zod-native.ts +``` +Expected: FAIL,报 `z.anyObject is not a function`。 + +- [ ] **Step 3: 实现 anyObject 工厂** + +在 `src/lib/params.ts` 顶部已有 `import { z } from "zod"`。在文件中(`zodTypeMap` 定义之后)新增: + +```ts +/** + * 接受任意键值对象的 schema 别名:等价 `z.object({}).catchall(z.unknown())`。 + * + * 供「动态字段 body」(如 one-api 的 records CRUD:字段由业务表 schema 决定, + * API 定义时未知)等场景使用。挂到 z 命名空间后写作 `z.anyObject()`。 + */ +export const anyObject = () => z.object({}).catchall(z.unknown()); +``` + +在 `src/lib/index.ts` 找到 `export { z, ZodRawShape, ZodType };` 这一行(约第 33 行),在其**下方**新增挂载 + 具名导出: + +```ts +import { anyObject as _anyObject } from "./params.js"; + +// 挂到 z 命名空间(z 是运行时值,可扩展属性),写作 z.anyObject() +(z as { anyObject?: typeof _anyObject }).anyObject = _anyObject; +// 具名导出常量(供 import { zAnyObject } 用,避免每次调用工厂) +export const zAnyObject = _anyObject(); +``` + +- [ ] **Step 4: 运行测试确认通过** + +```bash +pnpm test:lib -- src/test/test-zod-native.ts +``` +Expected: PASS(3 个用例全绿)。 + +- [ ] **Step 5: 全量回归** + +```bash +pnpm test:lib +``` +Expected: 全绿(确认挂载 z 命名空间未破坏现有 z 用法)。 + +- [ ] **Step 6: 类型检查 + lint** + +```bash +pnpm typecheck && pnpm lint +``` +Expected: 无错误。 + +- [ ] **Step 7: 提交** + +```bash +git add src/lib/params.ts src/lib/index.ts src/test/test-zod-native.ts +git commit -m "feat(schema): z.anyObject() 便利别名(等价 z.object({}).catchall(z.unknown()))" +``` + +--- + +## Task 2: 测试返回类型从 response schema 推导 + +**Files:** +- Modify: `src/lib/api.ts`(`API` 类暴露 responseType 给运行时占位) +- Modify: `src/lib/extend/test.ts`(`findApi` 返回值携带 responseType;`buildTest` 透传泛型) +- Modify: `src/lib/agent.ts`(`success()` 泛型化) +- Test: `src/test/test-test.ts`(新增类型推导用例) + +**背景:** `TestAgent.success()` 当前返回 `Promise`(agent.ts:265),one-api 测试每个用例都 `as {...}`。当 `registerTyped` 声明了 `{ response: Schema }` 时,`success()` 应返回 `Promise>`。 + +**类型链路:** `registerTyped` → `API.options.responseType`(运行时占位,仅类型层用)→ `findApi` 返回 `API` → `buildTest` 读 `responseType` → `TestAgent.success`。 + +- [ ] **Step 1: 在 api.ts 暴露 responseType 占位** + +`registerTyped` 已有 `TResponse extends z.ZodTypeAny` 泛型(api.ts 约 336 行)。在 `registerTyped` 方法体内、`if (schemas.response) { this.options.responseSchema = schemas.response; }` 这段(约 370 行)之后新增运行时占位赋值: + +```ts + // 运行时占位:存 schema 实例,供 TestAgent.success() 从 z.infer 推导返回类型用 + // (类型层:options.responseType 与 responseSchema 同源) + if (schemas.response) { + this.options.responseSchema = schemas.response; + } +``` + +> 注:`responseSchema` 已存在(`APIOption.responseSchema`,api.ts:62),`findApi` 直接读它即可,无需新增字段。本步**无需改 api.ts**——确认 `responseSchema` 在 `init()` 后仍保留即可(它确实保留,`init()` 不清空它)。**跳过本步,直接进 Step 2。** + +- [ ] **Step 2: 写失败测试(运行时行为 + 类型)** + +在 `src/test/test-test.ts` 末尾新增。该测试验证两点:①success() 返回的是信封内层数据(依赖 setFormatOutput 拆信封,已有机制)②声明 response schema 后返回类型正确推导。 + +```ts +describe("success() 返回类型从 response schema 推导", () => { + it("声明 response schema 时 success() 返回内层数据", async () => { + // 复用 test-test.ts 顶部已初始化的 apiService(beforeAll 里 bind + initTest + setFormatOutput) + // 若作用域拿不到 apiService,则在此用例内自建一个局部实例(见下) + const express = (await import("express")).default; + const app = express(); + app.use(express.json()); + const { expressAdapter } = await import("./adapters"); + const lib = (await import("./lib")).default; + + const apiService = lib({ basePath: "" }); + const UserVO = z.object({ id: z.number(), name: z.string() }); + apiService.api + .get("/vo-test") + .group("Index") + .title("vo") + .registerTyped( + { response: UserVO }, + (req, reply) => reply.json({ result: { id: 1, name: "Tom" } }) + ); + apiService.bind({ adapter: expressAdapter, router: app }); + apiService.setFormatOutput((data: unknown): [Error | null, unknown] => { + const d = data as { result?: unknown }; + return d && typeof d === "object" && "result" in d ? [null, d.result] : [null, data]; + }); + apiService.initTest(app, "/tmp", "/tmp"); + + const ret = await apiService.test.get("/vo-test").success(); + // 运行时:拿到内层 { id, name } + expect(ret).toEqual({ id: 1, name: "Tom" }); + }); +}); +``` + +> 说明:本步先只验证**运行时**返回内层数据(现有能力)。类型推导(`ret` 自动是 `{id:number;name:string}` 而非 `unknown`)在 Step 4 实现后,通过一个**纯类型断言**用例验证(见 Step 5)。 + +- [ ] **Step 3: 运行测试确认通过(运行时部分应已绿)** + +```bash +pnpm test:lib -- src/test/test-test.ts +``` +Expected: PASS(运行时行为依赖现有 setFormatOutput,本就工作)。若 FAIL,先修到绿再继续。 + +- [ ] **Step 4: 实现 TestAgent.success 泛型化** + +修改 `src/lib/agent.ts`。找到 `public success(): Promise`(约 265 行),改为泛型方法: + +```ts + /** 期望输出成功结果(返回类型 T 由调用方或 response schema 推导) */ + public success(): Promise { + this.debug("success"); + return this.output(false, true).catch((err) => { + throw new Error(`${this.key} 期望API输出成功结果,但实际输出失败结果:${inspect(err)}`); + }) as Promise; + } +``` + +修改 `src/lib/extend/test.ts` 的 `buildTest`,让调用方能传入推断的 T。找到 `buildTest` 方法(约 154 行),改为: + +```ts + /** 生成测试方法(T 供调用侧从 response schema 推导注入) */ + private buildTest(method: SUPPORT_METHODS) { + return (path: string) => { + const s = this.findApi(method, path); + if (!s || !s.key) { + throw new Error(`尝试请求未注册的API:${method} ${path}`); + } + const a = new TestAgent(method, path, s.key, getCallerSourceLine(this.testPath), this.erest); + assert(this.erest.getTestView().app, "请先调用 initTest(app) 设置 app 实例"); + a.bindRequest(this.getBaseUrl, this.ready); + return a.agent() as TestAgent & { success: () => Promise; error: () => Promise; raw: () => Promise }; + }; + } +``` + +> 关键约束:`buildTest` 当前是 `IAPITest` 的私有方法,被 `get get/post/...` 调用。`get` 等 getter 无法感知具体 API 的 response schema(要等 `findApi` 后才知道),故 **T 只能由调用侧显式传**(`api.test.get('/path')`)。但更符合人体工学的做法是:**让 session() 的方法接收 path 后自动从已注册 API 读 responseType**。鉴于 `findApi` 已能拿到 `s.options.responseSchema`,可在 `buildSession` 里做。本计划采用「调用侧显式传 T」的最小改动路径(向后兼容,未传 T 时退回 unknown)。 + +- [ ] **Step 5: 写类型推导断言用例** + +在 Step 2 的测试用例下方新增一个**纯类型**断言(运行时不执行新逻辑,仅 typecheck 时验证类型推导): + +```ts + it("声明 response schema 时 success() 的 T 被正确推导", async () => { + const express = (await import("express")).default; + const app = express(); + app.use(express.json()); + const { expressAdapter } = await import("./adapters"); + const lib = (await import("./lib")).default; + + const apiService = lib({ basePath: "" }); + const UserVO = z.object({ id: z.number(), name: z.string() }); + apiService.api + .get("/vo-type") + .group("Index") + .title("vo-type") + .registerTyped( + { response: UserVO }, + (req, reply) => reply.json({ result: { id: 1, name: "Tom" } }) + ); + apiService.bind({ adapter: expressAdapter, router: app }); + apiService.setFormatOutput((data: unknown): [Error | null, unknown] => { + const d = data as { result?: unknown }; + return d && typeof d === "object" && "result" in d ? [null, d.result] : [null, data]; + }); + apiService.initTest(app, "/tmp", "/tmp"); + + type UserVO = z.infer; + const ret = await apiService.test.get("/vo-type").success(); + // 类型断言:ret 必须是 UserVO,不是 unknown + const _check: UserVO = ret; + expect(_check.id).toBe(1); + }); +``` + +- [ ] **Step 6: 运行 + 类型检查** + +```bash +pnpm test:lib -- src/test/test-test.ts +pnpm typecheck +``` +Expected: 测试 PASS + typecheck 无错误(验证 success() 泛型链路通)。 + +- [ ] **Step 7: 全量回归** + +```bash +pnpm test:lib && pnpm lint +``` +Expected: 全绿。 + +- [ ] **Step 8: 提交** + +```bash +git add src/lib/agent.ts src/lib/extend/test.ts src/test/test-test.ts +git commit -m "feat(test): success() 返回类型支持从 response schema 推导(默认 unknown 向后兼容)" +``` + +--- + +## Task 3: 类型安全的 state(ERest 实例级泛型 StateMap) + +**Files:** +- Modify: `src/lib/index.ts`(`ERest` + `group()` 透传) +- Modify: `src/lib/adapters/types.ts`(`Context` / `Middleware`) +- Modify: `src/lib/api.ts`(`API` + register/registerTyped handler ctx 类型) +- Modify: `packages/erest-{express,koa,leizmweb}/src/index.ts`(`createERest` 透传) +- Test: `src/test/test-types.ts`(新增类型推导用例)+ `src/test/test-register-typed.ts`(运行时 state 传递) + +**背景:** `Context.state` 是 `Record`,鉴权钩子注入的值取回时全靠 `as`(one-api 6 处)。新增 `State` 泛型,默认 `Record` 向后兼容。 + +**纯类型层改动:** 本任务运行时行为完全不变(`state: {}` 仍是普通对象),仅收紧类型。 + +- [ ] **Step 1: 改 Context / Middleware 加 State 泛型** + +修改 `src/lib/adapters/types.ts`。 + +`Context` 接口(约 101 行)加 State 泛型参数: + +```ts +export interface Context = Record> { + readonly method: string; + readonly path: string; + readonly headers: Record; + readonly params: Record; + readonly query: Record; + readonly body: unknown; + /** 跨中间件传递数据的可读写状态(类型由 ERest 泛型驱动) */ + readonly state: State; + readonly reply: Reply; + $validated?: { + params: Record; + query: Record; + body: Record; + headers: Record; + }; + $params?: Record; + $pathParams?: Record; + $query?: Record; + $body?: Record; + $headers?: Record; +} +``` + +`Middleware` 类型(约 145 行)加 State 泛型参数: + +```ts +export type Middleware = Record> = ( + ctx: Context, + next: () => Promise | void +) => Promise | void; +``` + +- [ ] **Step 2: 改 ERest 类加 State 泛型** + +修改 `src/lib/index.ts`。 + +找到 `class ERest`(约第 178 行),改为: + +```ts +class ERest = Record> { +``` + +找到 `public group(name: string, info?: IGroupInfoOpt): IGroup;` 等 group 重载(约 470 行附近),`IGroup` 全部改为 `IGroup`。 + +修改 `IGroup` 接口定义(约第 60 行)加 State: + +```ts +export interface IGroup = Record> + extends Record, genSchema { + define: (opt: APIDefine) => API; + before: (...fn: T[]) => IGroup; + middleware: (...fn: T[]) => IGroup; +} +``` + +> 注:`genSchema` 返回 `API` 需同步改为 `API`(见 genSchema 定义约第 57 行)。 + +- [ ] **Step 3: 改 API 类加 State 泛型 + handler ctx 类型** + +修改 `src/lib/api.ts`。 + +`class API`(约 72 行)改为 `class API = Record>`。 + +`register` 方法(约 312 行)的 handler 参数类型,把 `Middleware` 改为 `Middleware`: + +```ts + public register(fn: Middleware): this { +``` + +`registerTyped` 的 wrappedHandler(约 377 行)内部 `async (ctx) => {...}`,ctx 类型由 TS 自动推导为 `Context`(因 Middleware)。无需显式标注。 + +`static define`(约 112 行)加 State 泛型默认值,签名改为 `static define = Record>`。 + +- [ ] **Step 3.5: registerTyped handler 第二参数统一为 ctx(breaking)** + +> 此步是后续 Task 3(success 泛型测试)/ Task 4(envelope)的前置:把 `registerTyped` handler 第二参数从 `reply` 统一为 `ctx`(即 `Context`,含 `ctx.reply`)。这样 enveloper 模式下 handler 用 `return data`、非 enveloper 模式下用 `ctx.reply.json(x)`,两种模式签名一致,避免运行时 if 分支。 + +修改 `src/lib/api.ts` 的 `registerTyped` handler 参数类型(约 345-353 行)。当前是 `(req, reply: Reply) => ...`,改为: + +```ts + handler: ( + req: { + query: z.infer>; + body: z.infer>; + params: z.infer>; + headers: z.infer>; + }, + ctx: Context // 原 reply: Reply,统一改为 ctx(含 ctx.reply) + ) => z.infer | Promise> | void | Promise +``` + +需在 api.ts 顶部 import `Context` 类型(若未导入):`import type { Context, Middleware, Reply } from "./adapters/types.js";`(api.ts 第 9 行已有此 import,确认含 `Context`)。 + +修改 wrappedHandler(约 377 行)内 handler 调用,把传 `ctx.reply` 改为传 `ctx`: + +```ts + const result = handler(typedReq, ctx); // 原:handler(typedReq, ctx.reply as Reply) +``` + +同时保留原 response schema 校验逻辑(wrappedHandler 内 `if (schemas.response && result !== undefined)` 那段不动)。 + +**回归:** 改完此步,存量 `registerTyped` 测试(test-register-typed.ts / test-raw.ts)的 handler 签名是 `(req, reply) => reply.json(...)`,会因类型变化报错。把这些测试的 `reply` 改为 `ctx`、`reply.json` 改为 `ctx.reply.json`。逐个修复直到 typecheck + test 全绿: + +```bash +pnpm typecheck # 定位所有 (req, reply) => 的报错点 +# 逐个改为 (req, ctx) => ctx.reply.json(...) +pnpm test:lib -- src/test/test-register-typed.ts src/test/test-raw.ts src/test/test-test.ts +pnpm typecheck && pnpm test:lib +``` + +> **Breaking 登记:** 存量 handler `(req, reply) => reply.json(x)` 须改为 `(req, ctx) => ctx.reply.json(x)`。erest 当前唯一消费者 one-api 在 Task 6 统一适配。MIGRATION.md 登记(见计划末尾)。 + +- [ ] **Step 4: 改三框架子包 createERest 透传 State** + +对 `packages/erest-express/src/index.ts`、`packages/erest-koa/src/index.ts`、`packages/erest-leizmweb/src/index.ts` 三个文件的 `createERest` 工厂,统一改为透传 State: + +```ts +export function createERest = Record>( + options: ConstructorParameters[0] +): ERest, XxxRaw, State> { + return new ERestCtor, XxxRaw, State>(options); +} +``` + +(`XxxRaw` 为各子包的原生类型:`ExpressRaw` / `KoaRaw` / `LeizmWebRaw`) + +同时确认各子包 `makeParamsChecker` 返回的 `checker: Middleware` 改为 `Middleware`(adapter 装配的 `nativeMiddleware` 内 `state: {}` 运行时不变,仅类型对齐)。 + +- [ ] **Step 5: 写运行时 state 传递测试** + +在 `src/test/test-register-typed.ts` 末尾新增(验证 before 钩子注入的 state 能被 handler 读到——运行时行为)。用 `register`(非 typed),因 register 的 handler 是 `(ctx, next)` 签名,能直接读 `ctx.state`: + +```ts +describe("ctx.state 跨中间件传递", () => { + it("before 钩子写入 state,handler 能读取", async () => { + const app = express(); + app.use(express.json()); + const apiService = lib({ basePath: "" }); + + apiService.api + .get("/state-test") + .group("Index") + .title("state") + .before((ctx, next) => { + ctx.state["userId"] = 42; // 写(v3.1 state 是 Record,无类型约束) + return next(); + }) + .register((ctx, next) => { + ctx.reply.json({ userId: ctx.state["userId"] }); // 读 + return next(); + }); + + apiService.bind({ adapter: expressAdapter, router: app }); + const res = await request(app).get("/state-test"); + expect(res.status).toBe(200); + expect(res.body).toEqual({ userId: 42 }); + }); +}); +``` + +> 说明:此处用 `register`(非 typed)验证 state 运行时传递——register handler 是 `(ctx, next)`,能直接读 `ctx.state`。`registerTyped` 的 handler 读 state 用第二参数 ctx(Step 3.5 已统一为 ctx),Task 4 enveloper 模式的测试会覆盖 registerTyped 读 state 的场景。本步先用 register 验证「写进去能读出来」的运行时行为(State 泛型是纯类型层,运行时 state 仍是普通对象)。 + +- [ ] **Step 6: 运行运行时测试** + +```bash +pnpm test:lib -- src/test/test-register-typed.ts +``` +Expected: PASS。 + +- [ ] **Step 7: 写类型推导测试(纯 typecheck)** + +在 `src/test/test-types.ts` 末尾新增(验证 State 泛型透传到 handler ctx): + +```ts +import type { Context, Middleware } from "../lib/adapters/types.js"; +import type ERest from "../lib/index.js"; + +describe("ERest 类型推导(编译期)", () => { + it("State 泛型透传到 ctx.state", () => { + interface MyState { + userId?: number; + role: string; + } + + // 模拟 createERest() 返回的实例类型 + type Api = ERest; + + // before 钩子:ctx.state 必须是 MyState + const hook: Middleware = (ctx, next) => { + ctx.state.userId = 1; // OK:number + ctx.state.role = "admin"; // OK:string + // @ts-expect-error 未知键应报错 + ctx.state.unknownKey = "x"; + return next(); + }; + + // 断言 ctx.state 类型 + const _check = (ctx: Context) => { + const role: string = ctx.state.role; + return role; + }; + expect(typeof hook).toBe("function"); + expect(typeof _check).toBe("function"); + }); + + it("未声明 State 时 ctx.state 退回 Record(向后兼容)", () => { + const hook: Middleware = (ctx, next) => { + ctx.state.anyKey = "any"; // OK:Record + return next(); + }; + expect(typeof hook).toBe("function"); + }); +}); +``` + +- [ ] **Step 8: 类型检查** + +```bash +pnpm typecheck +``` +Expected: 无错误(`@ts-expect-error` 行必须真的报错才算通过——若该行不报错,typecheck 会因 `@ts-expect-error` 未生效而报错,反向验证类型收紧生效)。 + +- [ ] **Step 9: 全量回归 + 覆盖率** + +```bash +pnpm test:lib && pnpm test:cov && pnpm lint +``` +Expected: 全绿 + 覆盖率达标。 + +- [ ] **Step 10: 提交** + +```bash +git add src/lib/index.ts src/lib/adapters/types.ts src/lib/api.ts packages/*/src/index.ts \ + src/test/test-types.ts src/test/test-register-typed.ts src/test/test-raw.ts src/test/test-test.ts +git commit -m "feat(types): ERest 实例级泛型 + registerTyped handler 第二参数统一为 ctx + +- ctx.state 类型安全(默认 Record 向后兼容) +- registerTyped handler 签名 (req, reply) → (req, ctx),ctx 含 ctx.reply +- 存量测试同步适配(test-register-typed/test-raw/test-test)" +``` + +--- + +## Task 4: 全局 response envelope 注册 + registerTyped 强制 return + +**Files:** +- Modify: `src/lib/index.ts`(新增 `setResponseEnvelopers` + 存储 + bind 接入 enveloper) +- Modify: `src/lib/api.ts`(`registerTyped` wrappedHandler 写入 `ctx.__returnValue` 供 enveloper 消费) +- Modify: `src/lib/adapters/types.ts`(`bindRoute` 签名加 envelopers 参数) +- Modify: `src/lib/adapters/utils.ts`(**新增** `wrapWithEnvelope` 工具:成功/错误分支统一信封) +- Modify: `packages/erest-{express,koa,leizmweb}/src/index.ts`(`bindRoute` 接入 wrapWithEnvelope) +- Test: `src/test/test-envelope.ts`(**新建**,envelope 注册 + 成功/错误包装) + +**背景:** one-api 用统一信封 `{success, data|error}`,当前每个 handler 手写 `reply.json(ok(x))`。erest 新增 `setResponseEnvelopers({success, error})`,enveloper 模式下 handler 改为 `return data`,框架自动包装。 + +**核心设计:** +- `success enveloper`:`(data, ctx) => wrappedBody`,handler return 的 data 经它包装 +- `error enveloper`:`(err, ctx) => { body, status }`,抛出的错误经它包装 +- **接入点**:`wrapWithEnvelope(dispatch, envelopers)` 包装各 adapter 的 dispatch——成功时从 ctx 取 handler return 值(handler 把 return 值挂到 `ctx.__returnValue`),错误时 catch。这样 3 个 adapter 共享同一逻辑,不重复改 3 次。 + +- [ ] **Step 1: 写失败测试** + +新建 `src/test/test-envelope.ts`: + +```ts +/** + * @file 全局 response envelope 集成测试 + * 验证 setResponseEnvelopers 后 registerTyped handler return data 被自动包装 + */ +import express from "express"; +import { expressAdapter } from "./adapters"; +import { httpReq as request } from "./http-req"; +import { afterAll, describe, expect, it } from "vitest"; +import { z } from "zod"; +import lib from "./lib"; + +describe("全局 response envelope(registerTyped return 模式)", () => { + it("handler return data → success enveloper 自动包装", async () => { + const app = express(); + app.use(express.json()); + const apiService = lib({ basePath: "" }); + + apiService.setResponseEnvelopers({ + success: (data: unknown) => ({ success: true, data }), + error: (err: unknown) => { + const e = err as { statusCode?: number; code?: string; message?: string }; + return { + body: { success: false, error: { code: e.code ?? "ERROR", message: e.message ?? "fail" } }, + status: e.statusCode ?? 500, + }; + }, + }); + + apiService.api + .get("/env-ok") + .group("Index") + .title("env-ok") + .registerTyped({}, async () => { + return { id: 1, name: "Tom" }; // return data,不调 reply + }); + + apiService.bind({ adapter: expressAdapter, router: app }); + const res = await request(app).get("/env-ok"); + expect(res.status).toBe(200); + expect(res.body).toEqual({ success: true, data: { id: 1, name: "Tom" } }); + }); + + it("handler 抛错 → error enveloper 自动包装 + 状态码", async () => { + const app = express(); + app.use(express.json()); + const apiService = lib({ basePath: "" }); + + apiService.setResponseEnvelopers({ + success: (data: unknown) => ({ success: true, data }), + error: (err: unknown) => { + const e = err as { statusCode?: number; code?: string; message?: string }; + return { + body: { success: false, error: { code: e.code ?? "ERROR", message: e.message ?? "fail" } }, + status: e.statusCode ?? 500, + }; + }, + }); + + const boom = Object.assign(new Error("not found"), { statusCode: 404, code: "NOT_FOUND" }); + apiService.api + .get("/env-err") + .group("Index") + .title("env-err") + .registerTyped({}, async () => { + throw boom; + }); + + apiService.bind({ adapter: expressAdapter, router: app }); + const res = await request(app).get("/env-err"); + expect(res.status).toBe(404); + expect(res.body).toEqual({ success: false, error: { code: "NOT_FOUND", message: "not found" } }); + }); + + it("handler return undefined → success enveloper 包 undefined", async () => { + const app = express(); + app.use(express.json()); + const apiService = lib({ basePath: "" }); + apiService.setResponseEnvelopers({ + success: (data: unknown) => ({ success: true, data }), + error: (err: unknown) => ({ body: { success: false }, status: 500 }), + }); + + apiService.api + .post("/env-void") + .group("Index") + .title("env-void") + .registerTyped({}, async () => { + // 无 return + }); + + apiService.bind({ adapter: expressAdapter, router: app }); + const res = await request(app).post("/env-void").send({}); + expect(res.status).toBe(200); + expect(res.body).toEqual({ success: true, data: undefined }); + }); +}); + +afterAll(() => {}); +``` + +- [ ] **Step 2: 运行测试确认失败** + +```bash +pnpm test:lib -- src/test/test-envelope.ts +``` +Expected: FAIL,报 `setResponseEnvelopers is not a function`。 + +- [ ] **Step 3: 在 index.ts 新增 setResponseEnvelopers + 存储** + +在 `src/lib/index.ts` 找到 `IApiInfo` 接口(约第 48 行),新增两个可选字段: + +```ts +export interface IApiInfo extends Record, genSchema { + readonly $apis: Map>; + define: (opt: APIDefine) => API; + beforeHooks: Set; + afterHooks: Set; + docs?: IAPIDoc; + formatOutputReverse?: (out: unknown) => [Error | null, unknown]; + docOutputFormat?: (out: unknown) => unknown; + /** 全局成功信封包装器(setResponseEnvelopers 注册后,handler return data 自动经此包装) */ + successEnveloper?: (data: unknown, ctx: import("./adapters/types.js").Context) => unknown; + /** 全局错误信封包装器(抛错时自动包装为 {body, status}) */ + errorEnveloper?: (err: unknown, ctx: import("./adapters/types.js").Context) => { body: unknown; status: number }; +} +``` + +在 `ERest` 类内(`setFormatOutput` 方法附近,约 411 行)新增方法: + +```ts + /** + * 注册全局响应信封包装器。 + * + * 注册后,registerTyped 的 handler 进入「return 模式」:handler 只 return data, + * 框架在 dispatcher 层用 successEnveloper 包装写入响应;抛错用 errorEnveloper 包装。 + * 未注册时维持 v3.1 行为(handler 调 reply 写响应)。 + */ + public setResponseEnvelopers(envelopers: { + success: (data: unknown, ctx: import("./adapters/types.js").Context) => unknown; + error: (err: unknown, ctx: import("./adapters/types.js").Context) => { body: unknown; status: number }; + }): void { + this.apiInfo.successEnveloper = envelopers.success; + this.apiInfo.errorEnveloper = envelopers.error; + } +``` + +- [ ] **Step 4: 实现 wrapWithEnvelope 工具(adapters/utils.ts)** + +在 `src/lib/adapters/utils.ts` 末尾新增。这是 envelope 接入的核心——包装 dispatch,成功时从 ctx 取 return 值,错误时 catch: + +```ts +import type { Context } from "./types.js"; + +/** + * 包装 dispatch:注册了 enveloper 时,handler return 值经 successEnveloper 包装写入 reply; + * 抛错经 errorEnveloper 包装。未注册 enveloper 时退化为原 dispatch(向后兼容)。 + * + * handler 的 return 值通过 ctx.__returnValue 传递(由 registerTyped 的 wrappedHandler 写入)。 + */ +export function wrapWithEnvelope( + dispatch: (ctx: Context) => Promise, + opts: { + successEnveloper?: (data: unknown, ctx: Context) => unknown; + errorEnveloper?: (err: unknown, ctx: Context) => { body: unknown; status: number }; + } +): (ctx: Context) => Promise { + const { successEnveloper, errorEnveloper } = opts; + if (!successEnveloper && !errorEnveloper) return dispatch; // 零开销:未注册时直接返回原 dispatch + + return async function (ctx: Context): Promise { + try { + await dispatch(ctx); + // 成功:从 ctx 取 handler return 值(仅 registerTyped enveloper 模式下会写入 __returnValue) + const returnValue = (ctx as Context & { __returnValue?: unknown }).__returnValue; + if (returnValue !== undefined && successEnveloper) { + // enveloper 模式:用 successEnveloper 包装 return 值写入响应 + // 约定:enveloper 模式下 handler 不调 ctx.reply(仅 return data);若误调又 return, + // 此处 json 会覆盖——属误用,文档警示。 + const wrapped = successEnveloper(returnValue, ctx); + ctx.reply.json(wrapped); + } + } catch (err) { + if (errorEnveloper) { + const { body, status } = errorEnveloper(err, ctx); + ctx.reply.status(status).json(body); + } else { + throw err; // 未注册 errorEnveloper 时 re-throw(交给 app 级中间件) + } + } + }; +} +``` + +- [ ] **Step 5: registerTyped wrappedHandler 写入 __returnValue(供 enveloper 消费)** + +> 前置:Task 3 Step 3.5 已把 handler 第二参数统一为 ctx,并改了 `handler(typedReq, ctx)` 调用。本步在此基础上,把 handler 的 return 值存入 `ctx.__returnValue`,供 Task 4 Step 4 的 `wrapWithEnvelope` 消费。 + +修改 `src/lib/api.ts` 的 `registerTyped` wrappedHandler(约 377 行)。当前(Task 3 改完后)是: + +```ts + const wrappedHandler: Middleware = async (ctx) => { + const validated = ctx.$validated ?? { params: {}, query: {}, body: {}, headers: {} }; + const typedReq = { /* 同原 */ } as { /* 同原 */ }; + const result = handler(typedReq, ctx); + // response schema 校验(原逻辑) + if (schemas.response && result !== undefined) { /* ... */ } + }; +``` + +在 `const result = handler(typedReq, ctx);` 之后、response schema 校验之前,插入 `__returnValue` 写入: + +```ts + const result = handler(typedReq, ctx); + + // handler return 值存入 ctx.__returnValue,供 wrapWithEnvelope(enveloper 模式)消费 + if (result !== undefined) { + (ctx as Context & { __returnValue?: unknown }).__returnValue = result; + } + + // response schema 校验(原逻辑保留不动) + if (schemas.response && result !== undefined) { /* ... */ } +``` + +> 注:`__returnValue` 仅在注册了 enveloper 时被 `wrapWithEnvelope` 消费(Step 4)。非 enveloper 模式下 `__returnValue` 被写入但无人读,无副作用(handler 已通过 ctx.reply 写了响应,wrapWithEnvelope 退化分支不处理)。 + +- [ ] **Step 6: 在 bind() 接入 wrapWithEnvelope** + +修改 `src/lib/index.ts` 的 `bind()` 方法。找到 forceGroup 分支内 `const handlers = buildHandlerChain({...})`(约 556 行)和 `adapter.bindRoute(route, schema, handlers, this.hooks);`(约 563 行)之间,插入 envelope 包装: + +```ts + const handlers = buildHandlerChain({ + beforeHooks: this.apiInfo.beforeHooks, + api: schema, + checker, + groupInfo: groupInfo as IAdapterGroupInfo, + }); + + // envelope 接入:注册了 enveloper 时,包装 handler 链的最后一段(dispatch 由 adapter 构造) + // 实际接入在各 adapter 的 bindRoute 内:传 enveloper 给 adapter +``` + +> **关键调整:** `wrapWithEnvelope` 包装的是 `dispatch`(compose 的产物),而 `dispatch` 在各 adapter 的 `bindRoute` 内构造。故 enveloper 必须**传入 adapter**。修改 `FrameworkAdapter.bindRoute` 签名(`adapters/types.ts`)增加 enveloper 参数: + +修改 `src/lib/adapters/types.ts` 的 `bindRoute`: + +```ts + bindRoute( + router: unknown, + api: API, + handlers: T[], + hooks?: import("../hooks.js").LifecycleHooks, + envelopers?: { + success?: (data: unknown, ctx: Context) => unknown; + error?: (err: unknown, ctx: Context) => { body: unknown; status: number }; + } + ): void; +``` + +在 `index.ts` 的 `bind()` 两处(forceGroup + 非 forceGroup)调用 `adapter.bindRoute` 时,传入 enveloper: + +```ts + adapter.bindRoute(route, schema, handlers, this.hooks, { + success: this.apiInfo.successEnveloper, + error: this.apiInfo.errorEnveloper, + }); +``` + +- [ ] **Step 7: 三框架 adapter bindRoute 接入 wrapWithEnvelope** + +对 `packages/erest-express/src/index.ts`、`packages/erest-koa/src/index.ts`、`packages/erest-leizmweb/src/index.ts` 的 `bindRoute` 方法,统一改造: + +找到 `const dispatch = compose(handlers as unknown as Middleware[]);`(各文件约 71-74 行),改为: + +```ts + const rawDispatch = compose(handlers as unknown as Middleware[]); + const dispatch = envelopers + ? wrapWithEnvelope(rawDispatch, envelopers) + : rawDispatch; +``` + +并在 `bindRoute` 签名加 `envelopers?` 参数(对齐 Step 6 的接口)。导入 `wrapWithEnvelope`: + +```ts +import { compose, wrapWithEnvelope } from "erest"; +``` + +并在 `erest` 主包 `src/lib/index.ts` / `src/lib/adapters/index.ts` 导出 `wrapWithEnvelope`(确认 `adapters/index.ts` re-export 了 utils)。 + +- [ ] **Step 8: 运行 envelope 测试** + +```bash +pnpm test:lib -- src/test/test-envelope.ts +``` +Expected: PASS(3 个用例:成功包装 / 错误包装 / undefined 包装)。 + +- [ ] **Step 9: 全量回归(确认未破坏 v3.1 非 enveloper 模式)** + +```bash +pnpm test:lib +``` +Expected: 全绿(test-register-typed.ts 的 v3.1 用例——handler 调 reply——必须仍通过,验证向后兼容)。 + +> **若 test-register-typed.ts FAIL:** 检查 `wrapWithEnvelope` 在未注册 enveloper 时是否正确退化为原 dispatch(`if (!successEnveloper && !errorEnveloper) return dispatch;`)。v3.1 用例未调 `setResponseEnvelopers`,应走退化分支。 + +- [ ] **Step 10: 类型检查 + lint + 覆盖率** + +```bash +pnpm typecheck && pnpm lint && pnpm test:cov +``` +Expected: 全绿 + 覆盖率达标。 + +- [ ] **Step 11: 提交** + +```bash +git add src/lib/index.ts src/lib/api.ts src/lib/adapters/types.ts src/lib/adapters/utils.ts packages/*/src/index.ts src/test/test-envelope.ts +git commit -m "feat(envelope): 全局 setResponseEnvelopers + registerTyped 强制 return(未注册时维持 v3.1 向后兼容)" +``` + +--- + +## Task 5: 测试 formatOutput 默认从 enveloper 推导 + +**Files:** +- Modify: `src/lib/index.ts`(`setResponseEnvelopers` 注册后,自动设 `formatOutputReverse` 默认值) +- Test: `src/test/test-envelope.ts`(新增:enveloper 模式下 success() 自动拆信封) + +**背景:** one-api 当前要手写 `applyEnvelopeFormat`(format.ts)调 `setFormatOutput` 让测试脚手架识别信封。enveloper 注册后,测试的 `formatOutputReverse` 应自动从 `successEnveloper` 推导,消除手写桥接。 + +**设计:** `successEnveloper` 是 `data => {success:true, data}`,测试需反向 `{success:true, data} => [null, data]`。由于 enveloper 形态由用户定义(不一定是 `{success, data}`),**无法通用反向推导**。故采用约定:`setResponseEnvelopers` 注册时,若用户未单独调 `setFormatOutput`,则 `formatOutputReverse` 默认尝试「若响应体是 enveloper 输出形态(对象且含 success 字段),按 success 拆解;否则原样」。 + +> **简化决策:** 通用反向推导不可行(enveloper 形态自定义)。改为:**`setResponseEnvelopers` 接受可选的 `testUnwrapper`**,用户可显式提供测试拆信封函数;未提供时 `formatOutputReverse` 维持默认(原样返回)。one-api 侧提供 `testUnwrapper`(一行,比 format.ts 简单得多)。 + +- [ ] **Step 1: 写失败测试** + +在 `src/test/test-envelope.ts` 新增用例: + +```ts +describe("enveloper 模式下测试自动拆信封", () => { + it("注册 testUnwrapper 后 success() 返回内层数据", async () => { + const app = express(); + app.use(express.json()); + const apiService = lib({ basePath: "" }); + + apiService.setResponseEnvelopers({ + success: (data: unknown) => ({ success: true, data }), + error: () => ({ body: { success: false }, status: 500 }), + testUnwrapper: (out: unknown): [Error | null, unknown] => { + const o = out as { success?: boolean; data?: unknown; error?: { message?: string } }; + if (o && typeof o === "object" && "success" in o) { + return o.success ? [null, o.data] : [new Error(o.error?.message ?? "fail"), null]; + } + return [null, out]; + }, + }); + + apiService.api + .get("/unwrap-test") + .group("Index") + .title("unwrap") + .registerTyped({ response: z.object({ id: z.number() }) }, async () => { + return { id: 99 }; + }); + + apiService.bind({ adapter: expressAdapter, router: app }); + apiService.initTest(app, "/tmp", "/tmp"); + + const ret = await apiService.test.get("/unwrap-test").success(); + expect(ret).toEqual({ id: 99 }); + }); +}); +``` + +- [ ] **Step 2: 运行确认失败** + +```bash +pnpm test:lib -- src/test/test-envelope.ts +``` +Expected: FAIL(`testUnwrapper` 不被接受 / success() 拿到的是 `{success:true, data:{id:99}}` 而非 `{id:99}`)。 + +- [ ] **Step 3: 实现 setResponseEnvelopers 接受 testUnwrapper** + +修改 `src/lib/index.ts` 的 `setResponseEnvelopers` 签名(Task 4 Step 3 已添加),增加可选 `testUnwrapper`: + +```ts + public setResponseEnvelopers(envelopers: { + success: (data: unknown, ctx: import("./adapters/types.js").Context) => unknown; + error: (err: unknown, ctx: import("./adapters/types.js").Context) => { body: unknown; status: number }; + /** 可选:测试脚手架拆信封(setFormatOutput 的便捷入口,enveloper 模式下通常需要) */ + testUnwrapper?: (out: unknown) => [Error | null, unknown]; + }): void { + this.apiInfo.successEnveloper = envelopers.success; + this.apiInfo.errorEnveloper = envelopers.error; + if (envelopers.testUnwrapper) { + this.apiInfo.formatOutputReverse = envelopers.testUnwrapper; + } + } +``` + +- [ ] **Step 4: 运行确认通过** + +```bash +pnpm test:lib -- src/test/test-envelope.ts +``` +Expected: PASS。 + +- [ ] **Step 5: 全量回归** + +```bash +pnpm test:lib && pnpm typecheck && pnpm lint +``` +Expected: 全绿。 + +- [ ] **Step 6: 提交** + +```bash +git add src/lib/index.ts src/test/test-envelope.ts +git commit -m "feat(envelope): setResponseEnvelopers 接受 testUnwrapper 便捷拆信封(替代手写 setFormatOutput)" +``` + +--- + +## Task 6: one-api phase2-server 同步适配验证 + +**Files(one-api worktree `.worktrees/phase2-server/`):** +- Modify: `apps/server/src/api/instance.ts` +- Delete: `apps/server/src/api/format.ts` +- Modify/Delete: `apps/server/src/utils/response.ts` +- Modify: `apps/server/src/hooks.ts` +- Modify: `apps/server/src/routes/*.ts`(data / token / table / admin-data / admins / auth / data-schema) +- Modify: `apps/server/src/__tests__/helpers.ts` +- Modify: `apps/server/src/app.ts` + +**背景:** erest 三块改进落地后,one-api 作为真实消费者同步适配,验证可用性 + 全部 e2e 测试绿。 + +**前置:** erest 已 build 并 link 到 one-api(one-api package.json 已用 `file:../../../../../node-erest` 本地依赖)。需先 `pnpm -r build`(erest)让 one-api 拿到新 dist。 + +- [ ] **Step 1: 构建 erest 新 dist** + +```bash +cd /Users/yourtionguo/codes/open/node-erest +pnpm -r build # 生成 dist/lib + 各子包 dist +``` + +- [ ] **Step 2: 重装 one-api 依赖(拉新 erest dist)** + +```bash +cd /Users/yourtionguo/codes/open/one-api/.worktrees/phase2-server +pnpm install +``` + +- [ ] **Step 3: 改 instance.ts 声明 AppState + 注册 enveloper** + +修改 `apps/server/src/api/instance.ts`: + +```ts +import { createERest } from '@erest/leizmweb'; +import { API_INFO, GROUPS } from './groups.js'; +import type { CurrentAdmin, CurrentToken } from '../hooks.js'; + +/** 应用级 state 类型(before 钩子注入,handler 类型安全读取) */ +interface AppState { + currentToken?: CurrentToken; + currentUser?: CurrentAdmin; +} + +export const api = createERest({ + info: API_INFO, + groups: GROUPS, + forceGroup: true, +}); + +// 全局响应信封(替代 utils/response.ts 的 ok/fail + 每处 reply.json(ok())) +api.setResponseEnvelopers({ + success: (data) => ({ success: true, data }), + error: (err: unknown) => { + const e = err as { statusCode?: number; code?: string; message?: string }; + return { + body: { success: false, error: { code: e.code ?? 'INTERNAL_ERROR', message: e.message ?? '服务器内部错误' } }, + status: e.statusCode ?? 500, + }; + }, + testUnwrapper: (out: unknown): [Error | null, unknown] => { + const o = out as { success?: boolean; data?: unknown; error?: { code?: string; message?: string } }; + if (o && typeof o === 'object' && 'success' in o) { + if (o.success) return [null, o.data]; + return [new Error(o.error ? `${o.error.code}: ${o.error.message}` : '请求失败'), null]; + } + return [null, out]; + }, +}); +``` + +- [ ] **Step 4: 删除 format.ts,清理 response.ts** + +```bash +rm apps/server/src/api/format.ts +``` + +`apps/server/src/utils/response.ts`:检查 `ApiResponse`/`ApiError` 类型是否被他处 import(如 hooks.ts)。若有,保留类型导出、删 `ok/fail` 函数;若无整个删除。先 grep: + +```bash +grep -rn "from.*utils/response\|ApiResponse\|ApiError" apps/server/src/ +``` + +按结果决定保留或删除。 + +- [ ] **Step 5: 改 hooks.ts 用 typed state** + +`apps/server/src/hooks.ts`:所有 `ctx.state['currentToken']` → `ctx.state.currentToken`,`ctx.state['currentUser']` → `ctx.state.currentUser`(类型已由 AppState 锁定,去 `as`)。例如: + +```ts +// 旧 +ctx.state['currentToken'] = { ... } satisfies CurrentToken; +const token = ctx.state['currentToken'] as CurrentToken | undefined; + +// 新 +ctx.state.currentToken = { ... } satisfies CurrentToken; +const token = ctx.state.currentToken; // CurrentToken | undefined,无需 as +``` + +- [ ] **Step 6: 改所有 routes/*.ts:reply.json(ok(x)) → return x** + +逐文件改(data.ts / token.ts / table.ts / admin-data.ts / admins.ts / auth.ts / data-schema.ts): + +```ts +// 旧 +registerTyped({ params: TableParam, ... }, async (req, reply) => { + reply.json(ok(await engine().data.list(req.params.table, listOpts))); +}); + +// 新 +registerTyped({ params: TableParam, ... }, async (req) => { + return engine().data.list(req.params.table, listOpts); +}); +``` + +要点: +- 删 `reply` 参数(enveloper 模式下 handler 是 `(req, ctx) => data`,不需 reply 时省略第二参数) +- `ok(x)` → `x` +- `reply.status(201).json(ok(x))` → `return x`(201 状态码丢失——若需保留,enveloper 模式下用 ctx,见下) + +**保留状态码的处理:** create 类操作原 `reply.status(201)`。enveloper 模式下 handler 返回 data 后由 enveloper 包装,状态码默认 200。若必须 201,在 enveloper success 内无法区分——**决策:one-api 统一用 200**(信封已含 success 语义,201 与否不影响)。删所有 `reply.status(201)`。 + +- [ ] **Step 7: 改 auth.ts 的 register + 手写 z.parse 退化写法** + +`auth.ts:45`(`/me`)和 `auth.ts:53`(`/password`)当前用 `register` + 手写 `z.object(...).parse(ctx.body)`。enveloper 模式下改回 `registerTyped`: + +```ts +// /me(原 register + ctx.state['currentUser']) +auth.get('/me').title('当前管理员').before(requireAdmin(getSystemDb())).registerTyped({}, async (_req, ctx) => { + const user = ctx.state.currentUser; + if (!user) throw Errors.unauthorized(); + const admin = await store.findById(user.id); + if (!admin) throw Errors.unauthorized(); + return toAdminVO(admin); +}); + +// /password(原 register + 手写 z.parse) +auth.put('/password').title('修改密码').before(requireAdmin(getSystemDb())).registerTyped( + { body: z.object({ oldPassword: z.string(), newPassword: z.string().min(6) }) }, + async (req, ctx) => { + const user = ctx.state.currentUser!; + const admin = await store.findById(user.id); + if (!admin || !comparePassword(req.body.oldPassword, admin.password_hash)) throw Errors.authInvalidCredentials(); + await store.updatePassword(admin.id, hashPassword(req.body.newPassword)); + await store.setMustChangePassword(admin.id, 0); + return { success: true }; + }, +); +``` + +- [ ] **Step 8: 改 z.object({}).catchall → z.anyObject()** + +全局替换 `z.object({}).catchall(z.unknown())` → `z.anyObject()`(data.ts:67/79、admin-data.ts:60/69、data-schema.ts:47)。 + +- [ ] **Step 9: 改 app.ts 错误中间件简化** + +enveloper 接管错误信封后,`app.ts` 的错误中间件大幅简化(仅留兜底 log + 兜底信封给非 erest 路由): + +```ts +app.use('/', (ctx, err) => { + if (!err) { ctx.next(); return; } + const e = err as Error & { statusCode?: number }; + log.error('未捕获错误: %s', e.stack ?? e.message); + if (!ctx.response.headersSent) { + ctx.response.status(e.statusCode ?? 500).json({ success: false, error: { code: 'INTERNAL_ERROR', message: '服务器内部错误' } }); + } +}); +``` + +- [ ] **Step 10: 改 __tests__/helpers.ts** + +删 `applyEnvelopeFormat` 调用(enveloper 已含 testUnwrapper): + +```ts +// 删掉这行 +applyEnvelopeFormat(api); +``` + +确认 helpers.ts 的 express 测试实例也走 enveloper(testUnwrapper 已在 createERest 时设——但 helpers.ts 自建 api 实例,需同样注册 enveloper)。抽取一个共享 `setupEnveloper(api)` 函数给 instance.ts 和 helpers.ts 复用,或 helpers.ts 直接复用 instance.ts 的 api 单例。**决策:helpers.ts 复刻 enveloper 注册逻辑(测试要独立实例,不能复用全局单例)。** + +- [ ] **Step 11: 类型检查 + 测试** + +```bash +cd /Users/yourtionguo/codes/open/one-api/.worktrees/phase2-server +pnpm --filter @1api/server typecheck +pnpm --filter @1api/server test +``` +Expected: typecheck 无错误 + 全部 e2e 测试绿(auth-admin / data / db-system / list-query / utils-token)。 + +> **若 FAIL:** 逐个排查。常见坑:①某个 handler 漏改还残留 `reply.json(ok())` ②typed state 的 `satisfies` 写错 ③enveloper 模式下 handler 还用了 `reply`(类型应报错拦截)。 + +- [ ] **Step 12: 全量回归 + lint** + +```bash +pnpm -r typecheck && pnpm -r test && pnpm lint +``` +Expected: 全绿。 + +- [ ] **Step 13: 提交(one-api worktree)** + +```bash +git add apps/server/src/ +git commit -m "refactor(server): erest v3.2 适配——typed state + envelope return + z.anyObject() + +- instance.ts: createERest() + setResponseEnvelopers +- 删 format.ts + utils/response.ts 的 ok/fail +- 全量 reply.json(ok(x)) → return x +- ctx.state['x'] as T → ctx.state.x(typed) +- auth.ts register+z.parse 退化 → registerTyped +- z.object({}).catchall → z.anyObject()" +``` + +--- + +## 完成标准(Definition of Done) + +- [ ] erest 侧:Task 1-5 全部 commit,`pnpm test:lib` + `pnpm test:cov` + `pnpm typecheck` + `pnpm lint` 全绿,覆盖率达标 +- [ ] one-api 侧:Task 6 commit,`pnpm -r test` + `pnpm -r typecheck` + `pnpm lint` 全绿 +- [ ] 向后兼容验证:erest 既有测试(test-register-typed.ts 的 v3.1 reply 模式用例、test-test.ts 的非 enveloper 用例)全部仍绿 +- [ ] MIGRATION.md 登记 breaking change(registerTyped enveloper 模式下签名从 `(req, reply)` 变 `(req, ctx)` 且必须 return)—— 在 erest 仓库根 `MIGRATION.md` 追加一节 + +## MIGRATION.md 追加内容(最后一步) + +在 `node-erest/MIGRATION.md` 末尾追加: + +```markdown +## v3.2 — 类型安全 state + 全局响应信封 + 便利 schema + +### Breaking(仅当启用新特性时) +- `setResponseEnvelopers()` 注册后,`registerTyped` handler 签名从 `(req, reply) => void` 变为 `(req, ctx) => data`(必须 return data,不再调 reply)。未注册 enveloper 时维持 v3.1 行为。 +- `ctx.state` 类型由 `ERest` 的 State 泛型驱动(默认 `Record`,存量代码不变)。 + +### 新增(非 breaking) +- `z.anyObject()`:等价 `z.object({}).catchall(z.unknown())` +- `success()`:测试返回类型可从 response schema 推导(默认 unknown) +- `setResponseEnvelopers({ success, error, testUnwrapper })` +``` diff --git a/docs/superpowers/specs/2026-06-30-typed-state-envelope-ergonomics-design.md b/docs/superpowers/specs/2026-06-30-typed-state-envelope-ergonomics-design.md new file mode 100644 index 0000000..4e04543 --- /dev/null +++ b/docs/superpowers/specs/2026-06-30-typed-state-envelope-ergonomics-design.md @@ -0,0 +1,309 @@ +# 类型安全 state + 全局响应信封 + 便利 schema + +- 日期:2026-06-30 +- 分支:`feat/typed-state-envelope`(待创建) +- 状态:设计已确认,待 plan +- 关联:依赖 v3.1(`adapters/types.ts` 的 Context/Reply/Middleware + 子包 `createERest` 工厂,见 `2026-06-29-raw-escape-hatch-and-capability-alignment.md`);由 one-api phase2-server dogfooding 暴露的痛点驱动 + +## 目标 + +erest v3.1 的 `registerTyped` 让 handler 框架无关、Zod 校验自动完成,但在 one-api(真实消费者)的实施过程中暴露了三类**框架侧缺口**:中间件协作丢类型、响应格式要手写包装、便利 schema 缺失。本次设计在 erest 框架层补齐这三块,让 `registerTyped` 成为「声明 schema → 纯函数 return data → 框架自动校验/包装/类型推导」的完整闭环。 + +> 范围限定:erest 框架改进(主)+ one-api phase2-server 同步适配(验证)。**不**改动 handler chain 执行顺序、**不**改动校验时机(before 钩子仍在校验前跑——见 §4 遗留)。 + +### 设计哲学 + +`registerTyped` 收敛为**纯函数式风格**:`(req, ctx) => data`,handler 只产出数据,框架负责包装响应。需要框架原生能力(setCookie/redirect/stream)时退回 `register`(配合 `reply.raw` 逃生舱,已由 v3.1 提供)。 + +--- + +## §1 — 驱动证据(one-api phase2-server 痛点) + +### 痛点 1:`ctx.state` 丢类型,鉴权值靠 `as` 断言取回 + +`Context.state` 当前是 `Record`(`src/lib/adapters/types.ts:115`)。before 钩子注入的鉴权结果,handler 取回时类型全失: + +```ts +// one-api: hooks.ts:34 — 注入(写) +ctx.state['currentToken'] = { id, name, permissions } satisfies CurrentToken; +// one-api: auth.ts:46 — 取回(读,丢类型,手写断言) +const cu = ctx.state['currentUser'] as CurrentAdmin; +``` + +全局 grep:`ctx.state[...] as ` 出现在 `hooks.ts`、`auth.ts`、`admins.ts` 共 6 处。 + +### 痛点 4:响应信封要手写包装,测试还得单独桥接 + +one-api 用统一信封 `{success, data|error}`。当前实现: + +- `utils/response.ts` 手写 `ok()/fail()` 工具 +- **每个** handler 末尾 `reply.json(ok(x))`(data.ts / token.ts / table.ts / admin-data.ts / admins.ts / auth.ts 全量) +- `api/format.ts` 的 `applyEnvelopeFormat` 调 `setFormatOutput`,**只**让测试脚手架识别信封,**真实响应仍要 handler 自己包** +- `app.ts` 的 app 级错误中间件手写错误信封分支 + +框架缺一个「handler return data → 自动包装 envelope」的响应层。 + +### 痛点 3 + 6:便利 schema 缺失 + 测试返回 `unknown` + +- 痛点 3:「任意动态字段 body」写成 `z.object({}).catchall(z.unknown())`(data.ts:67、admin-data.ts:60、data-schema.ts:47 共 4 处),Zod 黑话。 +- 痛点 6:`TestAgent.success()` / `error()` 返回 `Promise`(`src/lib/agent.ts:265/273`),one-api 测试每个用例都 `(await ...success()) as {...}`(data.test.ts 全量)。声明了 `response` schema 却没串到测试返回类型。 + +--- + +## §2 — 改进 1:类型安全的 state(ERest 实例级泛型 StateMap) + +### 2.1 方案:ERest 增加第三个泛型参数 `State` + +```ts +// src/lib/index.ts +class ERest = Record> { + // ... +} +``` + +`State` 默认 `Record`,**向后兼容**:存量 `new ERest()` / `createERest()` 不传 State 时,行为与类型与 v3.1 完全一致。 + +### 2.2 Context.state 收紧类型 + +```ts +// src/lib/adapters/types.ts +export interface Context = Record> { + // ... + readonly state: State; // 原 Record + // ... +} +``` + +`Middleware` 类型同步带 `State` 泛型默认值,避免破坏现有中间件签名: + +```ts +export type Middleware = Record> = ( + ctx: Context, + next: () => Promise | void +) => Promise | void; +``` + +### 2.3 子包 createERest 工厂透传 State + +```ts +// packages/erest-leizmweb/src/index.ts(express/koa 同理) +export function createERest< + State extends Record = Record +>(options: ConstructorParameters[0]): ERest, LeizmWebRaw, State> { + return new ERestCtor, LeizmWebRaw, State>(options); +} +``` + +### 2.4 泛型透传链路 + +需把 `State` 从 `ERest` → `group()` → `API` → `registerTyped`/`register` 的 handler `ctx` 全程透传。涉及: + +- `ERest.group()` 返回的 `IGroup` 加 `State`(`src/lib/index.ts`) +- `API` 加 `State`,`register`/`registerTyped` 的 handler ctx 类型用 `Context`(`src/lib/api.ts`) +- `FrameworkAdapter` 的 `Middleware` 用 `Middleware`——但 adapter 接口本身不感知 State(adapter 装配的 `nativeMiddleware` 构造 `stdCtx` 时 `state: {}`,运行时不变,仅类型层收紧) + +### 2.5 消费侧用法(one-api 验证) + +```ts +// one-api: api/instance.ts +interface AppState { + currentToken?: CurrentToken; + currentUser?: CurrentAdmin; +} +export const api = createERest({ info: API_INFO, groups: GROUPS, forceGroup: true }); + +// hooks.ts — 注入(类型安全,satisfies 校验 shape) +ctx.state.currentToken = { id, name, permissions } satisfies CurrentToken; + +// auth.ts / handlers — 取回(类型安全,无 as) +const user = ctx.state.currentUser; // CurrentAdmin | undefined +``` + +--- + +## §3 — 改进 2:全局 response envelope + registerTyped 强制 return + +### 3.1 新增 enveloper 注册 API + +```ts +// src/lib/index.ts —— ERest 新方法 +public setResponseEnvelopers(envelopers: { + success: (data: unknown, ctx: Context) => unknown; + error: (err: unknown, ctx: Context) => { body: unknown; status: number }; +}): void; +``` + +- `success`:handler return 的 data → 响应体(如 `data => ({ success: true, data })`) +- `error`:抛出的错误 → `{ body, status }`(如 `e => ({ body: { success: false, error: { code, message } }, status: e.statusCode })`) + +内部存到 `apiInfo.successEnveloper` / `errorEnveloper`。 + +### 3.2 registerTyped handler 改为 return data + +当**已注册 success enveloper**时,`registerTyped` 的 handler 进入「return 模式」: + +```ts +// 当前(v3.1) +registerTyped({ body: Schema }, async (req, reply) => { + reply.json(ok(await engine().create(req.params.table, req.body))); +}); + +// 改进后 +registerTyped({ body: Schema, response: VoSchema }, async (req) => { + return engine().create(req.params.table, req.body); // return data,框架自动包信封 +}); +``` + +handler 签名变为 `(req, ctx) => data | Promise`: + +- `req`:分层校验后的参数(params/query/body/headers,与 §2 改进无关,沿用 v3.1) +- `ctx`:即 §2 的 `Context`——读 `ctx.state.currentUser`(类型安全,改进 1 的产物)/ 注入日志等 + +> enveloper 模式下不再暴露 `reply` 给 registerTyped(handler 只产出 data,由框架写入)。需要原生响应能力(setCookie/redirect/stream)时退回 `register`(配合 v3.1 的 `reply.raw` 逃生舱)。 + +**dispatcher 改动**(`adapters/utils.ts` 的 `compose` 或各 adapter 的 `nativeMiddleware`): + +1. handler 返回值 `result` → `successEnveloper(result, ctx)` → `reply.json(wrapped)` + `reply.status(default)` +2. handler 抛错 / 链中任意抛错 → dispatcher catch → `errorEnveloper(err, ctx)` → `reply.json(body).status(status)` + +### 3.3 错误信封接管 app 级中间件 + +enveloper 模式下,错误由 dispatcher 内部 catch + `errorEnveloper` 统一包装,**不再依赖 app 级错误中间件写信封**。one-api 的 `app.ts` 错误中间件可大幅简化(仅保留兜底 log,信封逻辑下沉到框架)。 + +> 注意:dispatcher catch 仍需 re-throw 给 app 级中间件的场景(非 enveloper 模式,向后兼容)保留。即「注册了 errorEnveloper → dispatcher 内部消费;未注册 → re-throw(v3.1 现状不变)」。 + +### 3.4 setFormatOutput(测试)复用 success enveloper + +`setFormatOutput` 当前独立设。改进后:若设了 successEnveloper,测试的 `formatOutputReverse` 默认从 successEnveloper 反推(one-api 的 `applyEnvelopeFormat` 整个文件删除)。 + +具体:`formatOutputReverse` 默认行为改为「用 successEnveloper 的逆——已知响应体是 `enveloper(data)` 形态,从中取回 data」。one-api 不再需要手写 `format.ts`。 + +### 3.5 向后兼容(硬约束) + +- **未注册 enveloper**:`registerTyped` handler 仍可用 `reply`(v3.1 现状,存量用户不破) +- **注册了 enveloper**:handler 必须 return,调 reply 在类型层被禁止(签名不带 reply) + +### 3.6 消费侧用法(one-api 验证) + +```ts +// one-api: app/instance 装配处 +api.setResponseEnvelopers({ + success: (data) => ({ success: true, data }), + error: (e: unknown) => { + const err = e as AppError; + return { + body: { success: false, error: { code: err.code ?? 'INTERNAL_ERROR', message: err.message } }, + status: err.statusCode ?? 500, + }; + }, +}); + +// handler 全部改为 return +admin.get('/tokens').registerTyped({ response: z.array(TokenVO) }, async () => store.list()); +``` + +--- + +## §4 — 改进 3:便利 schema 别名 + 测试返回类型推导 + +### 4.1 语义化 schema 别名 + +erest 已 re-export `z`(`src/lib/index.ts`)。Zod 4 提供顶层函数形式(如 `z.strictObject(shape)`,见 Zod 4 `schemas.ts`),故可直接挂顶层工厂函数 `z.anyObject()`,与 Zod 4 自身风格一致: + +```ts +// src/lib/params.ts(erest 内部) +import { z } from "zod"; + +/** 接受任意键值的对象(动态字段 body 用):等价 z.object({}).catchall(z.unknown()) */ +export const anyObject = () => z.object({}).catchall(z.unknown()); + +// src/lib/index.ts re-export +import { anyObject as _anyObject } from "./params.js"; +// 挂到 z 命名空间(z 是值,可扩展属性) +(z as { anyObject?: typeof _anyObject }).anyObject = _anyObject; +// 同时独立具名导出(供 import { zAnyObject } 用) +export const zAnyObject = _anyObject(); +``` + +> 落地选择「挂到 z + 独立导出常量」双轨:`z.anyObject()` 用法直观(与 `z.object()` 风格一致);`zAnyObject` 常量避免每次调用工厂。消费侧按偏好选用。 + +消费侧: + +```ts +// one-api: data.ts(替换 z.object({}).catchall(z.unknown())) +registerTyped( + { params: TableParam, body: z.anyObject() }, + async (req) => engine().create(req.params.table, req.body), +); +``` + +### 4.2 测试返回类型从 response schema 推导 + +```ts +// src/lib/agent.ts + api.ts +// registerTyped 声明了 response schema 时: +admin.get('/tokens').registerTyped( + { response: z.array(TokenVO) }, + async () => store.list(), +); + +// 测试里 success() 返回类型自动推导 +const list = await session().get('/admin/tokens').success(); +// ^? TokenVO[] (告别 as 断言) +``` + +实现要点: + +- `registerTyped` 已有 `TResponse` 泛型(`src/lib/api.ts:336`),需把它透传到 `API` 实例的 `options.responseType`(运行时占位,仅类型层用) +- `TestAgent` 的 `success(): Promise` 增加泛型,`IAPITest.buildTest` 在 `findApi` 后从 `api.options.responseType` 推导 T 并传入 TestAgent +- **未声明 response schema** 时 `success()` 返回 `Promise`(向后兼容,one-api 老测试不破) + +> 注:测试返回类型推导依赖 `findApi` 返回的 API 实例携带 responseType。`findApi` 当前返回 `API | undefined`(`src/lib/extend/test.ts:140`),类型链可串通。 + +--- + +## §5 — one-api 同步适配清单(验证) + +| 文件 | 改动 | +|---|---| +| `api/instance.ts` | `createERest()` 声明 state 类型;注册 `setResponseEnvelopers` | +| `api/format.ts` | **删除**(success enveloper 接管测试格式化) | +| `utils/response.ts` | **删除** `ok()/fail()`(enveloper 接管) | +| `hooks.ts` | `ctx.state['x'] = ... satisfies T` → `ctx.state.x = ...` | +| `routes/*.ts` | 全量 `reply.json(ok(x))` → `return x`;`ctx.state['x'] as T` → `ctx.state.x`;`z.object({}).catchall(z.unknown())` → `z.anyObject()` | +| `routes/auth.ts` | `/me`、`/password` 的 `register` + 手写 `z.parse(ctx.body)` 改回 `registerTyped`(enveloper 模式下 handler 拿 ctx 读 state,body 校验回归框架) | +| `app.ts` | 错误中间件简化(errorEnveloper 接管信封,仅留兜底 log) | +| `__tests__/*.test.ts` | 声明 response schema 的 API,`success()` 去 `as` 断言 | + +--- + +## §6 — 风险与边界 + +### 向后兼容(硬约束) + +1. **State 默认 `Record`**:`new ERest()` / `createERest()` 不传时行为不变 +2. **未注册 enveloper 时**:`registerTyped` 维持 v3.1 的 `(req, reply)` 签名与 reply 写响应行为 +3. **未声明 response schema 时**:`success()` 返回 `Promise` +4. **breaking change 登记 MIGRATION.md**(符合 erest AGENTS.md §约定 5):注册了 enveloper 后,registerTyped handler 签名从 `(req, reply)` 变为 `(req, ctx)` 且必须 return + +### 不在本次范围 + +- **校验时机**(before 钩子拿校验值,原痛点 2):不改 handler chain 顺序。one-api 的 `checkPermission` 在 enveloper 模式下改为从 `ctx.state` 或 `req.params`(enveloper 模式下 handler 拿得到校验后的 req,但 before 仍在校验前——`checkPermission` 这类 before 钩子继续读 `ctx.params` 原始值,one-api 侧已用 `opts.tableKey` 从 params 取,可接受)。如后续确需,另开 spec 做「分层校验钩子」。 +- **register 与 registerTyped 风格统一**(原痛点 5):不动 register 签名,保持双轨。 + +### 测试与验收 + +- erest 侧:单测覆盖①State 类型透传(消费侧 typecheck 通过)②enveloper 包装成功/错误③z.anyObject 语义④response schema → 测试返回类型 +- one-api 侧:phase2 e2e 全绿(`pnpm --filter @1api/server test`)作为集成验证 +- `pnpm -r typecheck` + `pnpm lint` 全绿 + +--- + +## §7 — 实现顺序(供 plan 阶段参考) + +1. **改进 3(便利 schema + 测试返回类型)**:最小、最独立,先落地 +2. **改进 1(typed state)**:泛型透传,纯类型层改动(运行时几乎不变) +3. **改进 2(envelope + return)**:改动面最大(dispatcher + handler 签名),放最后 +4. **one-api 同步适配**:每块 erest 改完即适配验证 + +每块独立 commit,改进 2 拆为「enveloper 注册 + dispatcher 接入」+「测试 formatOutput 复用」两个子提交。 diff --git a/examples/README.md b/examples/README.md index 7b81980..e11080b 100644 --- a/examples/README.md +++ b/examples/README.md @@ -119,12 +119,12 @@ pnpm --filter erest-example docs # 生成到 docs/out/ erest v3 标准化后,**业务 handler 与 before/middleware 钩子都框架无关**,同一份代码被 Express / Koa / @leizm/web 三个入口复用: -- **业务 handler**(`registerTyped`):用 `(req, reply)` 签名,声明一次,三框架复用。 +- **业务 handler**(`registerTyped`):用 `(req, ctx)` 签名,声明一次,三框架复用。 - **组级 `before`/`middleware` 钩子**:用统一的 `(ctx, next)` 签名(erest 标准 Context), 在 `hooks.js` 里只写一份(`authBefore` / `adminBefore` / `logMiddleware` / `timingBefore`), 三入口共用。`ctx.headers` 读 token、`ctx.state.currentUser` 传递用户、`next()` 推进链。 - **`define()` 的 handler**:同样用 `(ctx, next)` 签名,经 `ctx.$params` 读校验后参数、 - `ctx.reply.json()` 写响应。虽然 define 路由仍各自在入口内注册(演示该 API),但其 handler + `ctx.ctx.reply.json()` 写响应。虽然 define 路由仍各自在入口内注册(演示该 API),但其 handler 也是框架无关的。 这是 v3 标准化的核心收益:整个请求处理链(global before → group before → api before → diff --git a/examples/src/api.js b/examples/src/api.js index 595db75..2550a05 100644 --- a/examples/src/api.js +++ b/examples/src/api.js @@ -2,12 +2,12 @@ * erest API 定义(与框架无关)。 * * 迷你博客业务域,用 forceGroup 分三个组,串联 erest 的核心能力: - * - registerTyped + reply(框架无关 handler,三入口复用) + * - registerTyped + ctx(框架无关 handler,三入口复用;v3.2 起 handler 第二参数为 ctx,含 ctx.reply) * - forceGroup + 组级 before/middleware 钩子(鉴权/日志,见 hooks.js) * - 自定义错误注册(errors.js)、自定义 type/schema 注册(types.js) * - define() 声明式、mock()、response() schema * - * handler 约定:(req, reply)。req 由 registerTyped 校验并分层注入; + * handler 约定:(req, ctx)。req 由 registerTyped 校验并分层注入; * 需要「当前用户」的 handler 通过 headers schema 读 token(before 钩子负责拒绝未授权请求)。 */ import { ERestError } from "erest"; @@ -65,21 +65,21 @@ export function registerApi(api, store, hooks) { pub .get("/posts") .title("已发布文章列表") - .registerTyped({ query: z.object({ limit: z.coerce.number().int().min(1).max(100).optional() }) }, (req, reply) => { + .registerTyped({ query: z.object({ limit: z.coerce.number().int().min(1).max(100).optional() }) }, (req, ctx) => { const limit = req.query.limit ?? 10; - reply.json({ posts: store.listPublishedPosts().slice(0, limit) }); + ctx.reply.json({ posts: store.listPublishedPosts().slice(0, limit) }); }); // GET /public/posts/:slug —— 按 slug 查详情(演示 params) pub .get("/posts/:slug") .title("文章详情") - .registerTyped({ params: z.object({ slug: z.string() }) }, (req, reply) => { + .registerTyped({ params: z.object({ slug: z.string() }) }, (req, ctx) => { const post = store.getPostBySlug(req.params.slug); if (!post || !post.published) { throw new ERestError("NOT_FOUND", "文章不存在", undefined, 404); } - reply.json({ post }); + ctx.reply.json({ post }); }); // ==================== post 组(需登录)==================== @@ -97,14 +97,14 @@ export function registerApi(api, store, hooks) { status: z.enum(["draft", "published"]).optional().describe("按状态过滤"), }), }, - (req, reply) => { + (req, ctx) => { const user = store.authenticate(req.headers["x-admin-token"]); if (!user) throw authRequired(); let posts = store.listPosts(); // query 字段是 optional,按存在性过滤 if (req.query.slug) posts = posts.filter((p) => p.slug === req.query.slug); if (req.query.status) posts = posts.filter((p) => p.status === req.query.status); - reply.json({ posts, user }); + ctx.reply.json({ posts, user }); } ) // 多选一必填:slug 与 status 至少传一个(Zod 之上的便利方法,无完美等价) @@ -114,10 +114,10 @@ export function registerApi(api, store, hooks) { post .post("/posts") .title("创建文章") - .registerTyped({ headers: TokenHeaderSchema, body: CreatePostSchema }, (req, reply) => { + .registerTyped({ headers: TokenHeaderSchema, body: CreatePostSchema }, (req, ctx) => { const user = store.authenticate(req.headers["x-admin-token"]); const created = store.createPost({ ...req.body, authorId: user.id }); - reply.status(201).json({ post: created }); + ctx.reply.status(201).json({ post: created }); }); // PUT /posts/:id —— 更新(分层 req.params 与 req.body) @@ -129,10 +129,10 @@ export function registerApi(api, store, hooks) { params: z.object({ id: z.coerce.number() }), body: UpdatePostSchema, }, - (req, reply) => { + (req, ctx) => { try { const updated = store.updatePost(req.params.id, req.body); - reply.json({ post: updated }); + ctx.reply.json({ post: updated }); } catch (e) { throw new ERestError("NOT_FOUND", e.message, undefined, e.status || 400); } @@ -143,10 +143,10 @@ export function registerApi(api, store, hooks) { post .delete("/posts/:id") .title("删除文章") - .registerTyped({ params: z.object({ id: z.coerce.number() }) }, (req, reply) => { + .registerTyped({ params: z.object({ id: z.coerce.number() }) }, (req, ctx) => { try { store.deletePost(req.params.id); - reply.json({ success: true }); + ctx.reply.json({ success: true }); } catch (e) { throw new ERestError("NOT_FOUND", e.message, undefined, e.status || 400); } @@ -160,8 +160,8 @@ export function registerApi(api, store, hooks) { admin .get("/stats") .title("系统统计") - .registerTyped({}, (_req, reply) => { - reply.json(store.stats()); + .registerTyped({}, (_req, ctx) => { + ctx.reply.json(store.stats()); }); // 注:mock() 能力(无 handler 时由 setMockHandler 生成 mock 响应)在 docs/generate.js @@ -183,8 +183,8 @@ export function registerApi(api, store, hooks) { ), }) ) - .registerTyped({}, (_req, reply) => { - reply.json({ users: store.listUsers() }); + .registerTyped({}, (_req, ctx) => { + ctx.reply.json({ users: store.listUsers() }); }); // define() 声明式定义(演示):与 registerTyped 等价,handler 同样是框架无关的 diff --git a/package.json b/package.json index 5f39bf4..6e2d496 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "erest", - "version": "3.1.0", + "version": "3.2.0", "description": "Framework-agnostic REST API framework with Zod validation, auto docs & test scaffolding. Express/Koa/@leizm/web adapters as separate packages.", "type": "module", "main": "dist/lib/index.js", diff --git a/packages/erest-express/package.json b/packages/erest-express/package.json index e4e8da0..12be78a 100644 --- a/packages/erest-express/package.json +++ b/packages/erest-express/package.json @@ -1,6 +1,6 @@ { "name": "@erest/express", - "version": "3.1.0", + "version": "3.2.0", "description": "Express adapter for erest", "type": "module", "main": "./dist/index.js", diff --git a/packages/erest-express/src/index.ts b/packages/erest-express/src/index.ts index 58394cf..c97dbff 100644 --- a/packages/erest-express/src/index.ts +++ b/packages/erest-express/src/index.ts @@ -5,8 +5,8 @@ import type { API } from "erest"; import type { ERest } from "erest"; -import { ERest as ERestCtor, compose } from "erest"; -import type { LifecycleHooks } from "erest"; +import { ERest as ERestCtor, compose, wrapWithEnvelope } from "erest"; +import type { Envelopers, LifecycleHooks } from "erest"; import type { Context, FrameworkAdapter, Middleware, Reply } from "erest"; import type { Request, Response } from "express"; @@ -67,11 +67,13 @@ export class ExpressAdapter implements FrameworkAdapter, handlers: T[], hooks?: LifecycleHooks): void { + bindRoute(router: unknown, api: API, handlers: T[], hooks?: LifecycleHooks, envelopers?: Envelopers): void { const routerTyped = router as Record unknown>; const method = api.options.method as string; // 包装为单个 Express 中间件:构造标准 Context + compose 标准化 handler 链 - const dispatch = compose(handlers as unknown as Middleware[]); + const rawDispatch = compose(handlers as unknown as Middleware[]); + // envelope 接入:注册了 enveloper 时包装 dispatch(成功/错误分支统一信封) + const dispatch = envelopers ? wrapWithEnvelope(rawDispatch, envelopers) : rawDispatch; // Stage 3:零开销裁剪——无 hooks 时装配不含 hook 调用的 dispatch const hasHook = Boolean(hooks && (hooks.onRequest || hooks.onValidate || hooks.onError || hooks.onResponse)); const nativeMiddleware = (req: Record, res: unknown, next: (err?: unknown) => void) => { @@ -158,13 +160,17 @@ export const expressAdapter = new ExpressAdapter(); /** * 创建绑定 Express 原生类型的 ERest 实例(构造时锁定 Raw 泛型)。 * - * 返回的 ERest 实例中,registerTyped handler 的 reply.raw 自动推导为 ExpressRaw, - * 无需手动标注。等价于 `new ERest(options)`。 + * 返回的 ERest 实例中,registerTyped handler 的 ctx.reply.raw 自动推导为 ExpressRaw, + * 无需手动标注。等价于 `new ERest(options)`。 + * + * 泛型 State 可选:`createERest(options)` 让 ctx.state 类型安全(默认 Record)。 * * @example * import { createERest } from "@erest/express"; * const api = createERest({ info, groups, forceGroup }); */ -export function createERest(options: ConstructorParameters[0]): ERest { - return new ERestCtor(options); +export function createERest = Record>( + options: ConstructorParameters[0] +): ERest, ExpressRaw, State> { + return new ERestCtor, ExpressRaw, State>(options); } diff --git a/packages/erest-gen/README.md b/packages/erest-gen/README.md index 2bd84fa..a21fc9d 100644 --- a/packages/erest-gen/README.md +++ b/packages/erest-gen/README.md @@ -54,9 +54,9 @@ export function registerUserHandlers(api: ERest["api"]) { .group("user") .registerTyped( { body: UserCreateSchema }, - (req, reply) => { + (req, ctx) => { // TODO: 实现 user-create 处理逻辑 - return reply.json({ ok: true }); + return ctx.reply.json({ ok: true }); } ); // ... diff --git a/packages/erest-gen/package.json b/packages/erest-gen/package.json index 2e31780..c132185 100644 --- a/packages/erest-gen/package.json +++ b/packages/erest-gen/package.json @@ -1,6 +1,6 @@ { "name": "@erest/gen", - "version": "3.1.0", + "version": "3.2.0", "description": "erest codegen CLI:从 Zod schema 生成 handler 骨架等(实验性,独立于 erest 主版本演进)", "type": "module", "bin": { diff --git a/packages/erest-gen/src/handler.ts b/packages/erest-gen/src/handler.ts index 2fdeeff..93a1112 100644 --- a/packages/erest-gen/src/handler.ts +++ b/packages/erest-gen/src/handler.ts @@ -42,9 +42,7 @@ export function generateHandler(options: GenerateHandlerOptions): string { const schemaNames = extractSchemaNames(source); if (schemaNames.length === 0) { - throw new Error( - `erest-gen: 未在 ${schemaFile} 中找到 \`export const XxxSchema = z.object(...)\` 形式的导出` - ); + throw new Error(`erest-gen: 未在 ${schemaFile} 中找到 \`export const XxxSchema = z.object(...)\` 形式的导出`); } const handlers = schemaNames @@ -55,10 +53,10 @@ export function generateHandler(options: GenerateHandlerOptions): string { .group("${group}") .registerTyped( { body: ${name} }, - (req, reply) => { + (req, ctx) => { // TODO: 实现 ${resource} 处理逻辑 // req.body 类型由 ${name} 推导 - return reply.json({ ok: true }); + return ctx.reply.json({ ok: true }); } );`; }) diff --git a/packages/erest-koa/package.json b/packages/erest-koa/package.json index 61d300b..315d723 100644 --- a/packages/erest-koa/package.json +++ b/packages/erest-koa/package.json @@ -1,6 +1,6 @@ { "name": "@erest/koa", - "version": "3.1.0", + "version": "3.2.0", "description": "Koa adapter for erest", "type": "module", "main": "./dist/index.js", diff --git a/packages/erest-koa/src/index.ts b/packages/erest-koa/src/index.ts index c019b85..186bbce 100644 --- a/packages/erest-koa/src/index.ts +++ b/packages/erest-koa/src/index.ts @@ -5,8 +5,8 @@ import type { API } from "erest"; import type { ERest } from "erest"; -import { ERest as ERestCtor, compose } from "erest"; -import type { LifecycleHooks } from "erest"; +import { ERest as ERestCtor, compose, wrapWithEnvelope } from "erest"; +import type { Envelopers, LifecycleHooks } from "erest"; import type { Context, FrameworkAdapter, Middleware, Reply } from "erest"; import type { Context as KoaContext } from "koa"; @@ -65,10 +65,11 @@ export class KoaAdapter implements FrameworkAdapter { return checker as unknown as T; } - bindRoute(router: unknown, api: API, handlers: T[], hooks?: LifecycleHooks): void { + bindRoute(router: unknown, api: API, handlers: T[], hooks?: LifecycleHooks, envelopers?: Envelopers): void { const routerTyped = router as Record unknown>; const method = (api.options.method as string).toLowerCase(); - const dispatch = compose(handlers as unknown as Middleware[]); + const rawDispatch = compose(handlers as unknown as Middleware[]); + const dispatch = envelopers ? wrapWithEnvelope(rawDispatch, envelopers) : rawDispatch; const hasHook = Boolean(hooks && (hooks.onRequest || hooks.onValidate || hooks.onError || hooks.onResponse)); const nativeMiddleware = async ( ctx: Record & { request: Record }, @@ -158,12 +159,16 @@ export const koaAdapter = new KoaAdapter(); /** * 创建绑定 Koa 原生类型的 ERest 实例(构造时锁定 Raw 泛型)。 - * handler 的 reply.raw 自动推导为 KoaRaw(原生 Context),无需手动标注。 + * handler 的 ctx.reply.raw 自动推导为 KoaRaw(原生 Context),无需手动标注。 + * + * 泛型 State 可选:`createERest(options)` 让 ctx.state 类型安全(默认 Record)。 * * @example * import { createERest } from "@erest/koa"; * const api = createERest({ info, groups, forceGroup }); */ -export function createERest(options: ConstructorParameters[0]): ERest { - return new ERestCtor(options); +export function createERest = Record>( + options: ConstructorParameters[0] +): ERest, KoaRaw, State> { + return new ERestCtor, KoaRaw, State>(options); } diff --git a/packages/erest-leizmweb/package.json b/packages/erest-leizmweb/package.json index 0a659a3..bb0630f 100644 --- a/packages/erest-leizmweb/package.json +++ b/packages/erest-leizmweb/package.json @@ -1,6 +1,6 @@ { "name": "@erest/leizmweb", - "version": "3.1.0", + "version": "3.2.0", "description": "@leizm/web adapter for erest", "type": "module", "main": "./dist/index.js", diff --git a/packages/erest-leizmweb/src/index.ts b/packages/erest-leizmweb/src/index.ts index 477a6f9..6c24b59 100644 --- a/packages/erest-leizmweb/src/index.ts +++ b/packages/erest-leizmweb/src/index.ts @@ -5,8 +5,8 @@ import type { API } from "erest"; import type { ERest } from "erest"; -import { ERest as ERestCtor, compose } from "erest"; -import type { LifecycleHooks } from "erest"; +import { ERest as ERestCtor, compose, wrapWithEnvelope } from "erest"; +import type { Envelopers, LifecycleHooks } from "erest"; import type { Context, FrameworkAdapter, Middleware, Reply } from "erest"; import type { Context as LeiContext } from "@leizm/web"; @@ -65,10 +65,11 @@ export class LeizmWebAdapter implements FrameworkAdapter, handlers: T[], hooks?: LifecycleHooks): void { + bindRoute(router: unknown, api: API, handlers: T[], hooks?: LifecycleHooks, envelopers?: Envelopers): void { const routerTyped = router as Record unknown>; const method = api.options.method as string; - const dispatch = compose(handlers as unknown as Middleware[]); + const rawDispatch = compose(handlers as unknown as Middleware[]); + const dispatch = envelopers ? wrapWithEnvelope(rawDispatch, envelopers) : rawDispatch; const hasHook = Boolean(hooks && (hooks.onRequest || hooks.onValidate || hooks.onError || hooks.onResponse)); // 单参数中间件(length=1),避免被 @leizm/web 误判为错误处理中间件 const nativeMiddleware = ( @@ -167,6 +168,8 @@ export const leizmWebAdapter = new LeizmWebAdapter(); * import { createERest } from "@erest/leizmweb"; * const api = createERest({ info, groups, forceGroup }); */ -export function createERest(options: ConstructorParameters[0]): ERest { - return new ERestCtor(options); +export function createERest = Record>( + options: ConstructorParameters[0] +): ERest, LeizmWebRaw, State> { + return new ERestCtor, LeizmWebRaw, State>(options); } diff --git a/src/lib/adapters/index.ts b/src/lib/adapters/index.ts index a7a5656..a02a3a8 100644 --- a/src/lib/adapters/index.ts +++ b/src/lib/adapters/index.ts @@ -9,4 +9,10 @@ */ export * from "./types.js"; -export { type BuildHandlerChainOptions, buildHandlerChain, compose } from "./utils.js"; +export { + type BuildHandlerChainOptions, + buildHandlerChain, + compose, + type Envelopers, + wrapWithEnvelope, +} from "./utils.js"; diff --git a/src/lib/adapters/types.ts b/src/lib/adapters/types.ts index e933315..8b06b32 100644 --- a/src/lib/adapters/types.ts +++ b/src/lib/adapters/types.ts @@ -43,8 +43,16 @@ export interface FrameworkAdapter { * @param router Router instance (express.Router, koa-router, etc.) * @param api API schema * @param handlers Handler chain + * @param hooks 生命周期 hooks(可选) + * @param envelopers 全局响应信封包装器(可选,由 setResponseEnvelopers 注册) */ - bindRoute(router: unknown, api: API, handlers: T[], hooks?: import("../hooks.js").LifecycleHooks): void; + bindRoute( + router: unknown, + api: API, + handlers: T[], + hooks?: import("../hooks.js").LifecycleHooks, + envelopers?: import("./utils.js").Envelopers + ): void; /** * Create a new group router with optional prefix @@ -98,7 +106,7 @@ export interface Reply { * * params/query/body 为框架原始请求数据(校验前的原始值,由 checker 校验后填入 $validated)。 */ -export interface Context { +export interface Context = Record, Raw = unknown> { /** 请求方法(GET/POST/...),大写 */ readonly method: string; /** 请求路径(日志/计时用) */ @@ -111,10 +119,10 @@ export interface Context { readonly query: Record; /** 请求体(校验前原始值) */ readonly body: unknown; - /** 跨中间件传递数据的可读写状态(替代直接写 req/ctx.currentUser) */ - readonly state: Record; - /** 框架无关响应接口(复用 Reply;中间件可提前响应/终止) */ - readonly reply: Reply; + /** 跨中间件传递数据的可读写状态(类型由 ERest 泛型驱动;默认 Record) */ + readonly state: State; + /** 框架无关响应接口(Raw 经 ERest 泛型锁定,由子包 createERest 工厂在构造时确定) */ + readonly reply: Reply; /** 校验后分层参数(由 checker 注入;before/middleware 执行时尚未填充) */ $validated?: { params: Record; @@ -142,4 +150,7 @@ export interface Context { * * before / middleware / register / registerTyped / define 的 handler 均用此签名。 */ -export type Middleware = (ctx: Context, next: () => Promise | void) => Promise | void; +export type Middleware = Record> = ( + ctx: Context, + next: () => Promise | void +) => Promise | void; diff --git a/src/lib/adapters/utils.ts b/src/lib/adapters/utils.ts index 4b49bb3..494010a 100644 --- a/src/lib/adapters/utils.ts +++ b/src/lib/adapters/utils.ts @@ -66,3 +66,48 @@ export function compose(middlewares: Middleware[]): (ctx: Context) => Promise; }; } + +/** enveloper 函数集(由 setResponseEnvelopers 注册,经 bind -> bindRoute 传入) */ +export interface Envelopers { + success?: (data: unknown, ctx: Context) => unknown; + error?: (err: unknown, ctx: Context) => { body: unknown; status: number }; +} + +/** + * 包装 dispatch:注册了 enveloper 时,handler return 值经 successEnveloper 包装写入 reply; + * 抛错经 errorEnveloper 包装。未注册 enveloper 时退化为原 dispatch(向后兼容,零开销)。 + * + * handler 的 return 值通过 ctx.__returnValue 传递(由 registerTyped 的 wrappedHandler 写入, + * 即使 return undefined 也会写入标记 __returned=true,以区分「return undefined」与「调 ctx.reply」)。 + * + * enveloper 模式约定:handler 只 return data,不调 ctx.reply。若误调又 return,successEnveloper + * 的 json 会覆盖前者——属误用。 + */ +export function wrapWithEnvelope( + dispatch: (ctx: Context) => Promise, + envelopers: Envelopers +): (ctx: Context) => Promise { + const { success: successEnveloper, error: errorEnveloper } = envelopers; + if (!successEnveloper && !errorEnveloper) return dispatch; // 零开销:未注册时直接返回原 dispatch + + return async function (ctx: Context): Promise { + try { + await dispatch(ctx); + // 成功:handler 执行完无抛错。enveloper 模式下用 successEnveloper 包装 return 值。 + // __returned 标记由 wrappedHandler 在 handler return 后置位(含 return undefined); + // 未置位说明 handler 自行调 ctx.reply 写了响应(非 enveloper 用法),不再二次包装。 + if (successEnveloper && (ctx as Context & { __returned?: boolean }).__returned) { + const returnValue = (ctx as Context & { __returnValue?: unknown }).__returnValue; + const wrapped = successEnveloper(returnValue, ctx); + ctx.reply.json(wrapped); + } + } catch (err) { + if (errorEnveloper) { + const { body, status } = errorEnveloper(err, ctx); + ctx.reply.status(status).json(body); + } else { + throw err; // 未注册 errorEnveloper 时 re-throw(交给 app 级中间件) + } + } + }; +} diff --git a/src/lib/agent.ts b/src/lib/agent.ts index 9036b70..d24002d 100644 --- a/src/lib/agent.ts +++ b/src/lib/agent.ts @@ -261,12 +261,17 @@ export class TestAgent { return ret; } - /** 期望输出成功结果 */ - public success(): Promise { + /** + * 期望输出成功结果。 + * + * 返回类型 T 可由调用方显式传入(如 `.success()`),便于从 response schema 推导; + * 未传 T 时退回 unknown(向后兼容)。 + */ + public success(): Promise { this.debug("success"); return this.output(false, true).catch((err) => { throw new Error(`${this.key} 期望API输出成功结果,但实际输出失败结果:${inspect(err)}`); - }); + }) as Promise; } /** 期望输出失败结果 */ diff --git a/src/lib/api.ts b/src/lib/api.ts index b2d215d..1dd77a1 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -6,7 +6,7 @@ import { strict as assert } from "node:assert"; import { pathToRegexp } from "path-to-regexp"; import { type ZodTypeAny, z } from "zod"; -import type { Middleware, Reply } from "./adapters/types.js"; +import type { Context, Middleware } from "./adapters/types.js"; import { api as debug } from "./debug.js"; import type ERest from "./index.js"; import { compileValidate, type CompiledRoute, type SchemaType } from "./params.js"; @@ -69,7 +69,7 @@ export interface APIOption extends Record { compiled?: CompiledRoute; } -class API { +class API = Record> { public key: string; public pathTestRegExp: RegExp; public inited: boolean; @@ -109,13 +109,13 @@ class API { debug("new: %s %s from %s", method, path, sourceFile.absolute); } - public static define( + public static define = Record>( options: APIDefine, sourceFile: SourceResult, group?: string, prefix?: string ) { - const schema = new API(options.method, options.path, sourceFile, group, prefix); + const schema = new API(options.method, options.path, sourceFile, group, prefix); schema.title(options.title); const g = group || options.group; if (g) { @@ -149,7 +149,7 @@ class API { schema.before(...options.before); } if (options.handler) { - schema.register(options.handler); + schema.register(options.handler as Middleware); } if (options.mock) { schema.mock(options.mock); @@ -297,21 +297,31 @@ class API { } /** - * 注册处理函数 + * 注册处理函数。 + * + * handler 参数 `(ctx, next)` 由 TS 自动推导——ctx 是 erest 标准 Context + * (含 reply/params/query/body/state/$params 等),next 调用链下一个中间件。 + * 无需手写类型标注: + * ```ts + * api.group('g').get('/path').register(async (ctx, next) => { + * ctx.reply.json({ ok: true }); + * return next(); + * }); + * ``` */ - public register(fn: T) { + public register(fn: Middleware): this { this.checkInited(); assert(typeof fn === "function", "处理函数必须是一个函数类型"); - this.options.handler = fn; + this.options.handler = fn as T; return this; } /** * 注册强类型处理函数 (基于 zod schema)。 * - * handler 签名为 `(req, reply)`,与框架无关: + * handler 签名为 `(req, ctx)`,与框架无关: * - req.params / req.query / req.body / req.headers:分层校验后的参数,类型由 Zod schema 推导 - * - reply:框架无关的响应接口({ status, json, send }),由各 adapter 注入 + * - ctx:框架无关的请求上下文(Context),含 ctx.reply(响应接口 { status, json, send })+ ctx.state(typed 跨中间件状态)+ ctx.reply.raw(原生逃生舱) * * 同一份 handler 可被 Express / Koa / @leizm/web 三个框架复用,无需关心 ctx/res 差异。 * 校验由 adapter 的 checker 统一完成(注入到 req/ctx.$validated + $reply),handler 内不重复 parse。 @@ -339,7 +349,7 @@ class API { params: z.infer>; headers: z.infer>; }, - reply: Reply + ctx: Context ) => z.infer | Promise> | void | Promise ) { this.checkInited(); @@ -381,20 +391,27 @@ class API { headers: z.infer>; }; - // ctx.reply 运行时是 adapter 注入的 reply(含对应框架的 raw 值), - // 仅 Context 静态类型为 Reply。Raw 由 ERest/API 类泛型锁定, - // 与 adapter 注入的 raw 值一致,故此处断言安全(同 typedReq 的断言模式)。 - const result = handler(typedReq, ctx.reply as Reply); - - // 若 handler 返回了值且定义了 response schema,则校验返回值(纯计算型 handler 场景) - if (schemas.response && result !== undefined) { - const resolved = result; - if (resolved instanceof Promise) { - await resolved.then((v) => schemas.response!.parse(v)); - } else { - schemas.response.parse(resolved); - } + // ctx 运行时由 adapter 注入(reply 含对应框架的 raw 值,state 为跨中间件共享对象)。 + // 此处 ctx 静态类型为 Context(Middleware 链推导,Raw 默认 unknown), + // 断言为 Context 以让 handler 的 ctx.reply.raw 拿到强类型原生对象。 + // Raw 由 ERest/API 类泛型锁定,与 adapter 注入的 raw 值一致,故断言安全。 + const result = handler(typedReq, ctx as Context); + + // 统一 await:handler 可能是 async(返回 Promise),先解析出真实返回值。 + // 解析后的值用于 response schema 校验 + enveloper 模式的 __returnValue。 + const resolved = result instanceof Promise ? await result : result; + + // 若定义了 response schema 且 handler 返回了值,则校验返回值(纯计算型 handler 场景) + if (schemas.response && resolved !== undefined) { + schemas.response.parse(resolved); } + + // enveloper 模式:handler return 后置 __returned + __returnValue, + // 供 wrapWithEnvelope 用 successEnveloper 包装(含 return undefined)。 + // registerTyped handler 的 return 总是触发(约定 enveloper 模式下不调 ctx.reply)。 + const ctxExt = ctx as Context & { __returned?: boolean; __returnValue?: unknown }; + ctxExt.__returned = true; + ctxExt.__returnValue = resolved; }; this.options.handler = wrappedHandler as unknown as T; diff --git a/src/lib/extend/test.ts b/src/lib/extend/test.ts index 6ceb568..b59a157 100644 --- a/src/lib/extend/test.ts +++ b/src/lib/extend/test.ts @@ -78,24 +78,25 @@ export default class IAPITest { return [...this.cookies.entries()].map(([k, v]) => `${k}=${v}`).join("; "); }; + /** GET 测试方法( 可选,供 success() 类型推导串联) */ get get() { - return this.buildTest("get"); + return (path: string) => this.buildTest("get")(path); } get post() { - return this.buildTest("post"); + return (path: string) => this.buildTest("post")(path); } get put() { - return this.buildTest("put"); + return (path: string) => this.buildTest("put")(path); } get delete() { - return this.buildTest("delete"); + return (path: string) => this.buildTest("delete")(path); } get patch() { - return this.buildTest("patch"); + return (path: string) => this.buildTest("patch")(path); } /** 创建测试会话(cookie 持久化,复用同一个 TestAgent 上下文) */ @@ -150,8 +151,13 @@ export default class IAPITest { return; } - /** 生成测试方法 */ - private buildTest(method: SUPPORT_METHODS) { + /** + * 生成测试方法。 + * + * 泛型 T 透传到 TestAgent.success()(默认 unknown 向后兼容): + * 调用侧 `.get(path).success()` 时,T 由 response schema 推导。 + */ + private buildTest(method: SUPPORT_METHODS) { return (path: string) => { const s = this.findApi(method, path); if (!s || !s.key) { @@ -160,7 +166,7 @@ export default class IAPITest { const a = new TestAgent(method, path, s.key, getCallerSourceLine(this.testPath), this.erest); assert(this.erest.getTestView().app, "请先调用 initTest(app) 设置 app 实例"); a.bindRequest(this.getBaseUrl, this.ready); - return a.agent(); + return a.agent() as TestAgent & { success: (...args: []) => Promise }; }; } } diff --git a/src/lib/index.ts b/src/lib/index.ts index d9470e9..977fd5e 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -4,8 +4,9 @@ */ import { strict as assert } from "node:assert"; -import { ZodRawShape, ZodType, z } from "zod"; +import { ZodRawShape, ZodType, z as _zodZ } from "zod"; import { buildHandlerChain, type FrameworkAdapter, type IAdapterGroupInfo } from "./adapters/index.js"; +import type { Context } from "./adapters/types.js"; import API, { type APIDefine, type DEFAULT_HANDLER, type SUPPORT_METHODS } from "./api.js"; import { core as debug } from "./debug.js"; import { type LifecycleHooks, hasHooks } from "./hooks.js"; @@ -13,7 +14,7 @@ import { defaultErrors } from "./default/index.js"; import IAPIDoc, { type IDocGeneratePlugin, type IDocWritter } from "./extend/docs.js"; import IAPITest from "./extend/test.js"; import { ErrorManager } from "./manager/index.js"; -import { zodTypeMap } from "./params.js"; +import { anyObject as _anyObject, zodTypeMap } from "./params.js"; import * as utils from "./utils.js"; import { camelCase2underscore, getCallerSourceLine, type ISupportMethds, type SourceResult } from "./utils.js"; @@ -22,7 +23,24 @@ export { type LifecycleHooks } from "./hooks.js"; export * from "./api.js"; export * from "./error.js"; export * from "./params.js"; -export { z, ZodRawShape, ZodType }; +export { ZodRawShape, ZodType }; + +/** + * z 命名空间:原生 Zod 的 z + erest 便利别名(`z.anyObject()`)。 + * + * Zod 导出的 z 对象在 ESM 模块命名空间下是 sealed(不可扩展),无法直接挂属性, + * 故以 Proxy 透传原生 z 的全部成员,并在读取时注入 anyObject,保持 z 原有语义不变。 + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const z: typeof _zodZ & { anyObject: typeof _anyObject } = new Proxy(_zodZ as any, { + get(target, prop, receiver) { + if (prop === "anyObject") return _anyObject; + return Reflect.get(target, prop, receiver); + }, +}) as typeof _zodZ & { anyObject: typeof _anyObject }; + +// 具名导出常量(供 import { zAnyObject } 用,避免每次调用工厂) +export const zAnyObject = _anyObject(); import { ERestError } from "./error.js"; @@ -33,24 +51,32 @@ const invalidParameter = (msg: string) => const internalError = (msg: string) => new ERestError("INTERNAL_ERROR", `internal error ${msg}`, undefined, 500); /** Schema方法 */ -export type genSchema = Readonly API>>; +export type genSchema = Record> = Readonly< + ISupportMethds<(path: string) => API> +>; /** 组方法 */ -export interface IGroup extends Record, genSchema { - define: (opt: APIDefine) => API; - before: (...fn: T[]) => IGroup; - middleware: (...fn: T[]) => IGroup; +export interface IGroup = Record> + extends Record, genSchema { + define: (opt: APIDefine) => API; + before: (...fn: T[]) => IGroup; + middleware: (...fn: T[]) => IGroup; } /** API接口定义 */ -export interface IApiInfo extends Record, genSchema { - readonly $apis: Map>; - define: (opt: APIDefine) => API; +export interface IApiInfo = Record> + extends Record, genSchema { + readonly $apis: Map>; + define: (opt: APIDefine) => API; beforeHooks: Set; afterHooks: Set; docs?: IAPIDoc; formatOutputReverse?: (out: unknown) => [Error | null, unknown]; docOutputFormat?: (out: unknown) => unknown; + /** 全局成功信封包装器(setResponseEnvelopers 注册后,handler return data 自动经此包装) */ + successEnveloper?: (data: unknown, ctx: Context) => unknown; + /** 全局错误信封包装器(抛错时自动包装为 {body, status}) */ + errorEnveloper?: (err: unknown, ctx: Context) => { body: unknown; status: number }; } /** API基础信息 */ @@ -121,7 +147,7 @@ interface IGroupInfo extends IGroupInfoOpt { /** * Easy rest api helper */ -class ERest { +class ERest = Record> { public shareTestData?: unknown; public utils = utils; @@ -148,8 +174,12 @@ class ERest { path: string, group?: string | undefined, prefix?: string | undefined - ) => API; - private defineAPI: (options: APIDefine, group?: string | undefined, prefix?: string | undefined) => API; + ) => API; + private defineAPI: ( + options: APIDefine, + group?: string | undefined, + prefix?: string | undefined + ) => API; private mockHandler?: (data: unknown) => T; /** @internal 错误工厂(adapter/params/api 用,替代 privateInfo 反射) */ @@ -325,7 +355,7 @@ class ERest { } else { assert(!group, "请开启 forceGroup 再使用 group 功能"); } - const s = new API(method, path, getCallerSourceLine(this.config.path), group, prefix); + const s = new API(method, path, getCallerSourceLine(this.config.path), group, prefix); const s2 = this.apiInfo.$apis.get(s.key); assert( !s2, @@ -340,7 +370,7 @@ class ERest { }; // define注册方法 this.defineAPI = (opt: APIDefine, group?: string, prefix?: string) => { - const s = API.define(opt, getCallerSourceLine(this.config.path), group, prefix); + const s = API.define(opt, getCallerSourceLine(this.config.path), group, prefix); const s2 = this.apiInfo.$apis.get(s.key); assert( !s2, @@ -412,6 +442,27 @@ class ERest { this.apiInfo.formatOutputReverse = fn; } + /** + * 注册全局响应信封包装器。 + * + * 注册后,registerTyped 的 handler 进入「return 模式」:handler 只 return data, + * 框架在 dispatcher 层用 successEnveloper 包装写入响应;抛错用 errorEnveloper 包装。 + * 未注册时维持 v3.1 行为(handler 调 ctx.reply 写响应)。 + * + * 可选 testUnwrapper:供测试脚手架(test.success/error)拆解信封(便捷入口,等价 setFormatOutput)。 + */ + public setResponseEnvelopers(envelopers: { + success: (data: unknown, ctx: Context) => unknown; + error: (err: unknown, ctx: Context) => { body: unknown; status: number }; + testUnwrapper?: (out: unknown) => [Error | null, unknown]; + }): void { + this.apiInfo.successEnveloper = envelopers.success; + this.apiInfo.errorEnveloper = envelopers.error; + if (envelopers.testUnwrapper) { + this.apiInfo.formatOutputReverse = envelopers.testUnwrapper; + } + } + /** * 设置文档格式化函数 */ @@ -466,9 +517,9 @@ class ERest { /** * 获取分组API实例 */ - public group(name: string, info?: IGroupInfoOpt): IGroup; - public group(name: string, desc?: string): IGroup; - public group(name: string, infoOrDesc?: IGroupInfoOpt | string): IGroup { + public group(name: string, info?: IGroupInfoOpt): IGroup; + public group(name: string, desc?: string): IGroup; + public group(name: string, infoOrDesc?: IGroupInfoOpt | string): IGroup { debug("using group: %s, desc: %j", name, infoOrDesc); // assert(this.groupInfo[name], `请先配置 ${name} 分组`); const info = !infoOrDesc || typeof infoOrDesc === "string" ? { name: infoOrDesc, prefix: "" } : infoOrDesc; @@ -560,7 +611,10 @@ class ERest { groupInfo: groupInfo as IAdapterGroupInfo, }); - adapter.bindRoute(route, schema, handlers, this.hooks); + adapter.bindRoute(route, schema, handlers, this.hooks, { + success: this.apiInfo.successEnveloper, + error: this.apiInfo.errorEnveloper, + }); } for (const [key, groupRouter] of routes.entries()) { @@ -587,7 +641,10 @@ class ERest { checker, }); - adapter.bindRoute(router, schema, handlers, this.hooks); + adapter.bindRoute(router, schema, handlers, this.hooks, { + success: this.apiInfo.successEnveloper, + error: this.apiInfo.errorEnveloper, + }); } } } diff --git a/src/lib/params.ts b/src/lib/params.ts index df9ae93..12178e2 100644 --- a/src/lib/params.ts +++ b/src/lib/params.ts @@ -137,6 +137,14 @@ export const zodTypeMap = { Base64: z.string(), } as const; +/** + * 接受任意键值对象的 schema 别名:等价 `z.object({}).catchall(z.unknown())`。 + * + * 供「动态字段 body」(如 one-api 的 records CRUD:字段由业务表 schema 决定, + * API 定义时未知)等场景使用。挂到 z 命名空间后写作 `z.anyObject()`。 + */ +export const anyObject = () => z.object({}).catchall(z.unknown()); + // ============ Stage 1: 预编译校验(热路径零分配) ============ /** 分层校验后的参数(按来源区分,registerTyped 读取此结构以获得类型安全) */ @@ -181,23 +189,23 @@ export interface ValidationErrorFactory { * - 缺失必填 → "missing required parameter 'field'" * - 类型错误 → "'field' should be valid" */ +// Zod 4 的 issue:缺失字段 message 含 "received undefined"(含 union 嵌套 errors), +// 类型错误含具体 received 值。 +const isMissing = (issue: { code: string; message?: string; errors?: unknown[] }): boolean => { + if (issue.message?.includes("received undefined")) return true; + if (issue.code === "invalid_union" && Array.isArray(issue.errors)) { + return issue.errors.some((branch) => + Array.isArray(branch) + ? branch.some((e: { message?: string }) => e.message?.includes("received undefined")) + : false + ); + } + return false; +}; + export function compileValidate(errorFactory: ValidationErrorFactory, schemas: CompiledSchemas): CompiledRoute { const { paramsSchema, querySchema, bodySchema, headersSchema } = schemas; - // Zod 4 的 issue:缺失字段 message 含 "received undefined"(含 union 嵌套 errors), - // 类型错误含具体 received 值。 - const isMissing = (issue: { code: string; message?: string; errors?: unknown[] }): boolean => { - if (issue.message?.includes("received undefined")) return true; - if (issue.code === "invalid_union" && Array.isArray(issue.errors)) { - return issue.errors.some((branch) => - Array.isArray(branch) - ? branch.some((e: { message?: string }) => e.message?.includes("received undefined")) - : false - ); - } - return false; - }; - const makeParse = (schema: ZodType) => (input: unknown): Record => { diff --git a/src/test/test-any-object.ts b/src/test/test-any-object.ts new file mode 100644 index 0000000..434e1c4 --- /dev/null +++ b/src/test/test-any-object.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { z } from "../lib/index.js"; + +describe("z.anyObject() 便利别名", () => { + it("应接受任意键值的对象", () => { + const schema = z.anyObject(); + const parsed = schema.parse({ foo: 1, bar: "x", nested: { a: true } }); + expect(parsed).toEqual({ foo: 1, bar: "x", nested: { a: true } }); + }); + + it("应拒绝非对象值", () => { + const schema = z.anyObject(); + expect(() => schema.parse("not-object")).toThrow(); + expect(() => schema.parse(42)).toThrow(); + expect(() => schema.parse(null)).toThrow(); + }); + + it("空对象应通过", () => { + const schema = z.anyObject(); + expect(schema.parse({})).toEqual({}); + }); +}); diff --git a/src/test/test-builder-types.ts b/src/test/test-builder-types.ts index 9fecedb..9339243 100644 --- a/src/test/test-builder-types.ts +++ b/src/test/test-builder-types.ts @@ -33,13 +33,13 @@ describe("Builder 类型推导", () => { expectTypeOf(api.options.querySchema).not.toBeAny(); }); - test("new ERest() 时 reply.raw 为 unknown(默认 Raw)", () => { + test("new ERest() 时 ctx.reply.raw 为 unknown(默认 Raw)", () => { apiService.api .post("/raw-unknown") .group("Index") - .registerTyped({ body: z.object({ x: z.number() }) }, (_req, reply) => { - // 默认 Raw=unknown:reply.raw 类型为 unknown,需断言才能用 - expectTypeOf(reply.raw).toEqualTypeOf(); + .registerTyped({ body: z.object({ x: z.number() }) }, (_req, ctx) => { + // 默认 Raw=unknown:ctx.reply.raw 类型为 unknown,需断言才能用 + expectTypeOf(ctx.reply.raw).toEqualTypeOf(); }); }); }); diff --git a/src/test/test-envelope.ts b/src/test/test-envelope.ts new file mode 100644 index 0000000..22e1a97 --- /dev/null +++ b/src/test/test-envelope.ts @@ -0,0 +1,225 @@ +/** + * @file 全局 response envelope 集成测试 + * 验证 setResponseEnvelopers 后 registerTyped handler return data 被自动包装 + */ +import express from "express"; +import { expressAdapter } from "./adapters"; +import { httpReq as request } from "./http-req"; +import { afterAll, describe, expect, it, vi } from "vitest"; +import { z } from "zod"; +import { wrapWithEnvelope } from "../lib/adapters/utils.js"; +import type { Context } from "../lib/adapters/types.js"; +import lib from "./lib"; + +/** 构造最小 Context mock(state 可读写,reply 记录调用) */ +function mockCtx(): Context & { + __returned?: boolean; + __returnValue?: unknown; + reply: { + status: ReturnType; + json: ReturnType; + send: ReturnType; + raw: unknown; + }; +} { + const reply = { + status: vi.fn(() => reply), + json: vi.fn(), + send: vi.fn(), + raw: {}, + }; + return { + method: "GET", + path: "/", + headers: {}, + params: {}, + query: {}, + body: {}, + state: {}, + reply, + } as Context & { __returned?: boolean; __returnValue?: unknown; reply: typeof reply }; +} + +describe("全局 response envelope(registerTyped return 模式)", () => { + const envelopers = { + success: (data: unknown) => ({ success: true, data }), + error: (err: unknown) => { + const e = err as { statusCode?: number; code?: string; message?: string }; + return { + body: { success: false, error: { code: e.code ?? "ERROR", message: e.message ?? "fail" } }, + status: e.statusCode ?? 500, + }; + }, + }; + + it("handler return data → success enveloper 自动包装", async () => { + const app = express(); + app.use(express.json()); + const apiService = lib({ basePath: "" }); + apiService.setResponseEnvelopers(envelopers); + + apiService.api + .get("/env-ok") + .group("Index") + .title("env-ok") + .registerTyped({}, async () => { + return { id: 1, name: "Tom" }; // return data,不调 reply + }); + + apiService.bind({ adapter: expressAdapter, router: app }); + const res = await request(app).get("/env-ok"); + expect(res.status).toBe(200); + expect(res.body).toEqual({ success: true, data: { id: 1, name: "Tom" } }); + }); + + it("handler 抛错 → error enveloper 自动包装 + 状态码", async () => { + const app = express(); + app.use(express.json()); + const apiService = lib({ basePath: "" }); + apiService.setResponseEnvelopers(envelopers); + + const boom = Object.assign(new Error("not found"), { statusCode: 404, code: "NOT_FOUND" }); + apiService.api + .get("/env-err") + .group("Index") + .title("env-err") + .registerTyped({}, async () => { + throw boom; + }); + + apiService.bind({ adapter: expressAdapter, router: app }); + const res = await request(app).get("/env-err"); + expect(res.status).toBe(404); + expect(res.body).toEqual({ success: false, error: { code: "NOT_FOUND", message: "not found" } }); + }); + + it("handler 无 return → success enveloper 包 undefined", async () => { + const app = express(); + app.use(express.json()); + const apiService = lib({ basePath: "" }); + apiService.setResponseEnvelopers(envelopers); + + apiService.api + .post("/env-void") + .group("Index") + .title("env-void") + .registerTyped({}, async () => { + // 无 return + }); + + apiService.bind({ adapter: expressAdapter, router: app }); + const res = await request(app).post("/env-void").send({}); + expect(res.status).toBe(200); + expect(res.body).toEqual({ success: true, data: undefined }); + }); + + it("enveloper 模式 + response schema → 校验 return 值后再包装", async () => { + const app = express(); + app.use(express.json()); + const apiService = lib({ basePath: "" }); + apiService.setResponseEnvelopers(envelopers); + + apiService.api + .get("/env-schema") + .group("Index") + .title("env-schema") + .registerTyped( + { response: z.object({ id: z.number() }) }, + async () => ({ id: 7 }) // return 值会先经 response schema 校验,再经 enveloper 包装 + ); + + apiService.bind({ adapter: expressAdapter, router: app }); + const res = await request(app).get("/env-schema"); + expect(res.status).toBe(200); + expect(res.body).toEqual({ success: true, data: { id: 7 } }); + }); + + it("enveloper 模式 + response schema → return 值不合 schema 时抛错(走 error enveloper)", async () => { + const app = express(); + app.use(express.json()); + const apiService = lib({ basePath: "" }); + apiService.setResponseEnvelopers(envelopers); + + apiService.api + .get("/env-schema-bad") + .group("Index") + .title("env-schema-bad") + .registerTyped( + { response: z.object({ id: z.number() }) }, + async () => ({ id: "not-a-number" }) as never // 类型撒谎:实际返回 string + ); + + apiService.bind({ adapter: expressAdapter, router: app }); + const res = await request(app).get("/env-schema-bad"); + // response schema 校验失败 → 抛 ZodError → error enveloper 包装 + expect(res.status).toBe(500); + expect(res.body.success).toBe(false); + }); +}); + +// wrapWithEnvelope 直接单测(不经子包 dist,确保 coverage 统计到 src/lib/adapters/utils) +describe("wrapWithEnvelope 单元测试", () => { + it("未注册任何 enveloper 时零开销退化(返回原 dispatch)", () => { + const dispatch = vi.fn(); + const wrapped = wrapWithEnvelope(dispatch as never, {}); + expect(wrapped).toBe(dispatch); // 直接返回原 dispatch,不包装 + }); + + it("handler return data → successEnveloper 包装写入 reply.json", async () => { + const ctx = mockCtx(); + ctx.__returned = true; + ctx.__returnValue = { id: 1 }; + const success = vi.fn((data: unknown) => ({ success: true, data })); + + await wrapWithEnvelope(async () => Promise.resolve(), { success })(ctx); + + expect(success).toHaveBeenCalledWith({ id: 1 }, ctx); + expect(ctx.reply.json).toHaveBeenCalledWith({ success: true, data: { id: 1 } }); + }); + + it("handler 未置 __returned(自行调 ctx.reply)→ 不二次包装", async () => { + const ctx = mockCtx(); + const success = vi.fn(); + + await wrapWithEnvelope(async () => Promise.resolve(), { success })(ctx); + + expect(success).not.toHaveBeenCalled(); + expect(ctx.reply.json).not.toHaveBeenCalled(); + }); + + it("handler 抛错 → errorEnveloper 包装 status + body", async () => { + const ctx = mockCtx(); + const boom = Object.assign(new Error("not found"), { statusCode: 404 }); + const error = vi.fn(() => ({ body: { fail: true }, status: 404 })); + + await wrapWithEnvelope(async () => Promise.reject(boom), { error })(ctx); + + expect(error).toHaveBeenCalledWith(boom, ctx); + expect(ctx.reply.status).toHaveBeenCalledWith(404); + expect(ctx.reply.json).toHaveBeenCalledWith({ fail: true }); + }); + + it("仅注册 success enveloper 时,抛错 re-throw(不吞错)", async () => { + const ctx = mockCtx(); + const success = vi.fn(); + + await expect(wrapWithEnvelope(async () => Promise.reject(new Error("escape")), { success })(ctx)).rejects.toThrow( + "escape" + ); + expect(success).not.toHaveBeenCalled(); + }); + + it("__returned=true 但 returnValue=undefined → successEnveloper 仍被调用", async () => { + const ctx = mockCtx(); + ctx.__returned = true; + ctx.__returnValue = undefined; + const success = vi.fn((data: unknown) => ({ ok: true, data })); + + await wrapWithEnvelope(async () => Promise.resolve(), { success })(ctx); + + expect(success).toHaveBeenCalledWith(undefined, ctx); + expect(ctx.reply.json).toHaveBeenCalled(); + }); +}); + +afterAll(() => {}); diff --git a/src/test/test-raw-types.ts b/src/test/test-raw-types.ts index 3c6698c..d2b820c 100644 --- a/src/test/test-raw-types.ts +++ b/src/test/test-raw-types.ts @@ -1,13 +1,13 @@ /** - * @file reply.raw 框架泛型类型推导测试 - * 验证子包 createERest() 工厂构造时锁定 Raw,handler reply.raw 自动强类型 + * @file ctx.reply.raw 框架泛型类型推导测试 + * 验证子包 createERest() 工厂构造时锁定 Raw,handler ctx.reply.raw 自动强类型 */ import type express from "express"; import { describe, test, expectTypeOf } from "vitest"; import { createERest as createExpressERest, type ExpressRaw } from "../../packages/erest-express/src/index.js"; describe("createERest 类型锁定", () => { - test("Express createERest 返回的实例 handler reply.raw 为 ExpressRaw(零标注强类型)", () => { + test("Express createERest 返回的实例 handler ctx.reply.raw 为 ExpressRaw(零标注强类型)", () => { const api = createExpressERest({ info: { title: "t", version: "1.0.0" }, groups: { Index: "首页" }, @@ -15,11 +15,11 @@ describe("createERest 类型锁定", () => { api.api .post("/raw-typed") .group("Index") - .registerTyped({}, (_req, reply) => { - // 构造时锁定 Raw=ExpressRaw:reply.raw 自动强类型,handler 零标注 - expectTypeOf(reply.raw).toEqualTypeOf(); - // reply.raw.res 是 Express Response - expectTypeOf(reply.raw.res).toMatchTypeOf(); + .registerTyped({}, (_req, ctx) => { + // 构造时锁定 Raw=ExpressRaw:ctx.reply.raw 自动强类型,handler 零标注 + expectTypeOf(ctx.reply.raw).toEqualTypeOf(); + // ctx.reply.raw.res 是 Express Response + expectTypeOf(ctx.reply.raw.res).toMatchTypeOf(); }); }); }); diff --git a/src/test/test-raw.ts b/src/test/test-raw.ts index ff8763d..5ccc0b3 100644 --- a/src/test/test-raw.ts +++ b/src/test/test-raw.ts @@ -23,11 +23,11 @@ describe("reply.raw - Express 集成", () => { .post("/login") .group("Index") .title("login-express") - .registerTyped({ body: z.object({ user: z.string() }) }, (_req, reply) => { - // reply.raw 在 Express 下为 { req, res },通过 res.cookie 设置 cookie - const res = (reply as { raw: { res: express.Response } }).raw.res; + .registerTyped({ body: z.object({ user: z.string() }) }, (_req, ctx) => { + // ctx.reply.raw 在 Express 下为 { req, res },通过 res.cookie 设置 cookie + const res = (ctx.reply as { raw: { res: express.Response } }).raw.res; res.cookie("token", "abc-123", { httpOnly: true }); - reply.json({ ok: true }); + ctx.reply.json({ ok: true }); }); apiService.bind({ adapter: expressAdapter, router: app }); @@ -65,11 +65,11 @@ describe("reply.raw - Koa 集成", () => { .post("/login") .group("Index") .title("login-koa") - .registerTyped({ body: z.object({ user: z.string() }) }, (_req, reply) => { - // reply.raw 在 Koa 下为原生 ctx - const ctx = (reply as { raw: { cookies: { set: (n: string, v: string, o: unknown) => void } } }).raw; - ctx.cookies.set("token", "koa-456", { httpOnly: true }); - reply.json({ ok: true }); + .registerTyped({ body: z.object({ user: z.string() }) }, (_req, ctx) => { + // ctx.reply.raw 在 Koa 下为原生 ctx + const kctx = (ctx.reply as { raw: { cookies: { set: (n: string, v: string, o: unknown) => void } } }).raw; + kctx.cookies.set("token", "koa-456", { httpOnly: true }); + ctx.reply.json({ ok: true }); }); apiService.bind({ adapter: koaAdapter, router }); app.use(router.routes()).use(router.allowedMethods()); @@ -99,12 +99,12 @@ describe("reply.raw - @leizm/web 集成", () => { .post("/login") .group("Index") .title("login-lei") - .registerTyped({ body: z.object({ user: z.string() }) }, (_req, reply) => { - // reply.raw 在 @leizm/web 下为原生 ctx;用设置自定义响应头作为 raw 可用性的稳定证明 + .registerTyped({ body: z.object({ user: z.string() }) }, (_req, ctx) => { + // ctx.reply.raw 在 @leizm/web 下为原生 ctx;用设置自定义响应头作为 raw 可用性的稳定证明 // (leizmweb cookie API 随版本变化,header 操作更稳) - const raw = (reply as { raw: { response: { setHeader: (k: string, v: string) => void } } }).raw; + const raw = (ctx.reply as { raw: { response: { setHeader: (k: string, v: string) => void } } }).raw; raw.response.setHeader("X-Raw-Test", "leizmweb"); - reply.json({ ok: true }); + ctx.reply.json({ ok: true }); }); apiService.bind({ adapter: leizmwebAdapter, router }); app.use("/", router); diff --git a/src/test/test-register-typed.ts b/src/test/test-register-typed.ts index e258c2c..cd9bfc8 100644 --- a/src/test/test-register-typed.ts +++ b/src/test/test-register-typed.ts @@ -47,8 +47,8 @@ describe("registerTyped - Express 集成", () => { typed: z.boolean(), }), }, - (req, reply) => { - reply.status(201).json({ + (req, ctx) => { + ctx.reply.status(201).json({ id: req.params.id, include: req.query.include ?? null, name: req.body.name, @@ -100,8 +100,13 @@ describe("registerTyped / 分层访问器 - Koa 集成", () => { .put("/typed/:id") .group("Index") .title("typed-koa") - .registerTyped(schemas, (req, reply) => { - reply.json({ id: req.params.id, name: req.body.name, age: req.body.age, include: req.query.include ?? null }); + .registerTyped(schemas, (req, ctx) => { + ctx.reply.json({ + id: req.params.id, + name: req.body.name, + age: req.body.age, + include: req.query.include ?? null, + }); }); apiService.bind({ adapter: koaAdapter, router }); app.use(router.routes()).use(router.allowedMethods()); @@ -163,8 +168,8 @@ describe("registerTyped / 分层访问器 - @leizm/web 集成", () => { .put("/typed/:id") .group("Index") .title("typed-lei") - .registerTyped(schemas, (req, reply) => { - reply.json({ id: req.params.id, name: req.body.name, age: req.body.age }); + .registerTyped(schemas, (req, ctx) => { + ctx.reply.json({ id: req.params.id, name: req.body.name, age: req.body.age }); }); apiService.bind({ adapter: leizmwebAdapter, router }); app.use("/", router); @@ -244,22 +249,22 @@ describe("$reply 框架无关响应(同一 handler 三框架复用)", () => .post("/users") .group("Index") .title("create") - .registerTyped({ body: z.object({ name: z.string(), age: z.number().int() }) }, (req, reply) => { + .registerTyped({ body: z.object({ name: z.string(), age: z.number().int() }) }, (req, ctx) => { const id = store.size + 1; store.set(id, req.body); - reply.status(201).json({ success: true, id }); + ctx.reply.status(201).json({ success: true, id }); }); apiObj.api .get("/users/:id") .group("Index") .title("get") - .registerTyped({ params: z.object({ id: z.coerce.number() }) }, (req, reply) => { + .registerTyped({ params: z.object({ id: z.coerce.number() }) }, (req, ctx) => { const user = store.get(req.params.id); if (!user) { - reply.status(404).json({ error: "not found" }); + ctx.reply.status(404).json({ error: "not found" }); return; } - reply.json(user); + ctx.reply.json(user); }); }; @@ -426,4 +431,31 @@ describe("分层快捷访问器避免同名覆盖", () => { }); }); +// ---------------- ctx.state 跨中间件传递 ---------------- +describe("ctx.state 跨中间件传递", () => { + it("before 钩子写入 state,handler 能读取(运行时)", async () => { + const app = express(); + app.use(express.json()); + const apiService = lib({ basePath: "" }); + + apiService.api + .get("/state-test") + .group("Index") + .title("state") + .before((ctx, next) => { + ctx.state["userId"] = 42; + return next(); + }) + .register((ctx, next) => { + ctx.reply.json({ userId: ctx.state["userId"] }); + return next(); + }); + + apiService.bind({ adapter: expressAdapter, router: app }); + const res = await request(app).get("/state-test"); + expect(res.status).toBe(200); + expect(res.body).toEqual({ userId: 42 }); + }); +}); + afterAll(() => {}); diff --git a/src/test/test-router.ts b/src/test/test-router.ts index a63f2e7..2c6133b 100644 --- a/src/test/test-router.ts +++ b/src/test/test-router.ts @@ -9,6 +9,8 @@ import { expressAdapter, koaAdapter, leizmwebAdapter } from "./adapters"; import express from "express"; import { describe, expect, test } from "vitest"; import { z } from "zod"; +import { compose } from "../lib/adapters/utils.js"; +import type { Context } from "../lib/adapters/types.js"; import { commonSchemas, createAllCrudApis, createGetApi, createPostApi } from "./utils/api-helpers"; import { assertApiRegistered, assertThrowsWithMessage } from "./utils/assertion-helpers"; import { createMockHook, createStandardHooks } from "./utils/mock-factories"; @@ -661,3 +663,79 @@ describe("Router - Unified bind() Method", () => { }); }); }); + +// compose 洋葱模型单元测试(bind 路由的中间件链核心,直接 import 源码确保 coverage) +describe("compose 洋葱模型", () => { + /** 构造最小 Context */ + function mockCtx(): Context { + return { + method: "GET", + path: "/", + headers: {}, + params: {}, + query: {}, + body: {}, + state: {}, + reply: { status: () => undefined, json: () => undefined, send: () => undefined, raw: {} }, + } as Context; + } + + test("按顺序执行中间件链(洋葱:next 前后)", async () => { + const order: string[] = []; + const mws = [ + (ctx: Context, next: () => Promise) => { + order.push("a-before"); + return next().then(() => order.push("a-after")); + }, + (_ctx: Context, next: () => Promise) => { + order.push("b"); + return next(); + }, + ]; + await compose(mws)(mockCtx()); + expect(order).toEqual(["a-before", "b", "a-after"]); + }); + + test("不调 next 则终止后续链", async () => { + const order: string[] = []; + const mws = [ + () => { + order.push("a"); + }, + () => { + order.push("b"); + }, + ]; + await compose(mws)(mockCtx()); + expect(order).toEqual(["a"]); + }); + + test("中间件 reject 向上抛", async () => { + const mws = [() => Promise.reject(new Error("boom"))]; + await expect(compose(mws)(mockCtx())).rejects.toThrow("boom"); + }); + + test("中间件同步抛错也 reject", async () => { + const mws = [ + () => { + throw new Error("sync-boom"); + }, + ]; + await expect(compose(mws)(mockCtx())).rejects.toThrow("sync-boom"); + }); + + test("next 重复调用时报错(防循环)", async () => { + const mws = [ + async (_ctx: Context, next: () => Promise) => { + await next(); + await next(); + }, + ]; + await expect(compose(mws)(mockCtx())).rejects.toThrow("next() called multiple times"); + }); + + test("空中间件链直接完成", async () => { + const ret = await compose([])(mockCtx()); + expect(ret).toBeUndefined(); + }); +}); diff --git a/src/test/test-state-types.ts b/src/test/test-state-types.ts new file mode 100644 index 0000000..af3ca68 --- /dev/null +++ b/src/test/test-state-types.ts @@ -0,0 +1,50 @@ +/** + * @file ERest 类型推导测试(编译期) + * + * 验证 State 泛型透传到 ctx.state:before 钩子/handler 内 ctx.state 类型安全。 + * 运行时仅 expect typeof(真正校验在 typecheck 阶段)。 + */ +import { describe, expect, it } from "vitest"; +import type { Context, Middleware } from "../lib/adapters/types.js"; +import type ERest from "../lib/index.js"; + +describe("ERest 类型推导(编译期)", () => { + it("State 泛型透传到 ctx.state", () => { + // 注意:State 须用 type alias(而非 interface)定义——type alias 对象类型 + // 有隐式 index signature,可赋给 Record;interface 则无。 + type MyState = { + userId?: number; + role: string; + }; + + // 模拟 createERest() 返回的实例类型 + type Api = ERest; + const _api = {} as unknown as Api; + expect(typeof _api).toBe("object"); + + // before 钩子:ctx.state 必须是 MyState + const hook: Middleware = (ctx, next) => { + ctx.state.userId = 1; // OK:number 可赋给 number|undefined + ctx.state.role = "admin"; // OK:string + // @ts-expect-error 未知键应报错(MyState 无 unknownKey) + ctx.state.unknownKey = "x"; + return next(); + }; + expect(typeof hook).toBe("function"); + + // 断言 ctx.state 类型 + const checkState = (ctx: Context) => { + const role: string = ctx.state.role; + return role; + }; + expect(typeof checkState).toBe("function"); + }); + + it("未声明 State 时 ctx.state 退回 Record(向后兼容)", () => { + const hook: Middleware = (ctx, next) => { + ctx.state.anyKey = "any"; // OK:Record + return next(); + }; + expect(typeof hook).toBe("function"); + }); +}); diff --git a/src/test/test-test.ts b/src/test/test-test.ts index dd648d9..dbafa48 100644 --- a/src/test/test-test.ts +++ b/src/test/test-test.ts @@ -349,4 +349,55 @@ describe("ERest 测试套件", () => { expect(data).toBeInstanceOf(Object); }); }); + + describe("success() 返回类型从 response schema 推导", () => { + it("声明 response schema 时 success() 返回内层数据(运行时)", async () => { + const app2 = express(); + app2.use(express.json()); + const apiService2 = lib({ basePath: "" }); + const UserVO = z.object({ id: z.number(), name: z.string() }); + apiService2.api + .get("/vo-test") + .group("Index") + .title("vo") + .registerTyped({ response: UserVO }, (req: any, ctx: any) => + ctx.reply.json({ result: { id: 1, name: "Tom" } }) + ); + apiService2.bind({ adapter: expressAdapter, router: app2 }); + apiService2.setFormatOutput((data: unknown): [Error | null, unknown] => { + const d = data as { result?: unknown }; + return d && typeof d === "object" && "result" in d ? [null, d.result] : [null, data]; + }); + apiService2.initTest(app2, "/tmp", "/tmp"); + + const ret = await apiService2.test.get("/vo-test").success(); + expect(ret).toEqual({ id: 1, name: "Tom" }); + }); + + it("声明 response schema 时 success() 的 T 被正确推导(类型层)", async () => { + const app2 = express(); + app2.use(express.json()); + const apiService2 = lib({ basePath: "" }); + const UserVO = z.object({ id: z.number(), name: z.string() }); + apiService2.api + .get("/vo-type") + .group("Index") + .title("vo-type") + .registerTyped({ response: UserVO }, (req: any, ctx: any) => + ctx.reply.json({ result: { id: 1, name: "Tom" } }) + ); + apiService2.bind({ adapter: expressAdapter, router: app2 }); + apiService2.setFormatOutput((data: unknown): [Error | null, unknown] => { + const d = data as { result?: unknown }; + return d && typeof d === "object" && "result" in d ? [null, d.result] : [null, data]; + }); + apiService2.initTest(app2, "/tmp", "/tmp"); + + type UserVO = z.infer; + const ret = await apiService2.test.get("/vo-type").success(); + // 类型断言:ret 必须是 UserVO,不是 unknown + const _check: UserVO = ret; + expect(_check.id).toBe(1); + }); + }); });