diff --git a/AGENTS.md b/AGENTS.md
index 3d6b0ff..88506c8 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,7 +1,7 @@
# erest 架构导航(给 AI 与新人)
-> 面向**修改 erest 代码**的开发者(人与 AI)的架构地图与红线。面向使用者的内容见 README.md。
-> 最后更新:v3.0 重构完成后(Stage 1-3)。
+> 面向**修改 erest 代码**的开发者(人与 AI)的架构地图与红线。面向使用者的入口见 [README.md](README.md),详细用法见 [guides/](guides/)。
+> 架构涵盖 Zod、插件化适配器、文档生成与测试系统;开发流程见 [贡献指南](CONTRIBUTING.md)。
## 一图看懂
@@ -23,13 +23,13 @@ src/lib/
│ └── test.ts # 测试引擎(fetch 驱动)
└── plugin/ # 文档/SDK 生成插件
├── zod-meta.ts # ★ Zod→文档元信息统一提取(extractDocFields 等,兼容 Zod 3/4)
- ├── generate_swagger/ # OpenAPI/Swagger
+ ├── generate_swagger/ # Swagger 2.0
├── generate_markdown/ # Markdown
├── generate_postman/ # Postman Collection
└── generate_axios/ # Axios SDK
packages/ # v3.0:框架适配器实现独立子包(npm scope @erest/*,目录名仍为 erest-*)
-├── erest-express/ # @erest/express — ExpressAdapter(Express 4)
+├── erest-express/ # @erest/express — ExpressAdapter(Express 4/5)
├── erest-koa/ # @erest/koa — KoaAdapter(Koa 3)
├── erest-leizmweb/ # @erest/leizmweb — LeizmWebAdapter(@leizm/web 2)
└── erest-gen/ # @erest/gen — codegen CLI(命令名 erest-gen)
@@ -67,4 +67,4 @@ packages/ # v3.0:框架适配器实现独立子包(npm scope @er
- 接入或修改 CI 不代表获准实际发版;只有用户明确授权后才推送发布 tag 或调用真实 publish。
- 反例:只改了 `Reply.markSent()` 实现 + MIGRATION 登记,却忘了把 `3.2.0` 改成 `3.2.1` —— 这种「代码动了版本没动」会让下游无法通过版本号判断是否包含修复,必须避免。
-> API 用法(registerTyped/bind/hooks/schema.register 等)与测试/构建命令见 README.md,此处不再重复。
+> API 用法见 [API 定义](guides/api.md)、[框架接入](guides/adapters.md)和[文档与 SDK](guides/documentation.md);测试/构建命令见 [贡献指南](CONTRIBUTING.md)。
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..82147cf
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,67 @@
+# 贡献指南
+
+[返回首页](README.md) · [架构与修改约定](AGENTS.md) · [发布流程](RELEASING.md)
+
+## 环境与构建
+
+建议使用 Node.js 24、pnpm 9。仓库是 pnpm workspace,核心包和四个公开子包一起构建。
+
+```bash
+pnpm install --frozen-lockfile
+pnpm run build
+pnpm run build:packages
+```
+
+源码修改位置、Zod 内部结构访问和版本约定见 [AGENTS.md](AGENTS.md)。
+涉及公开 API/行为/依赖的修改,同步更新五个包的版本和 [MIGRATION.md](MIGRATION.md)。
+
+## 常用检查
+
+| 命令 | 用途 |
+| --- | --- |
+| `pnpm run check` | lint 和格式检查 |
+| `pnpm run format` | 格式化源码与示例 |
+| `pnpm run test:lib` | 源码测试,自动设置 ISLIB,无需先构建 |
+| `pnpm run test:cov` | 源码测试与覆盖率门槛 |
+| `pnpm test` | 构建核心/子包,验证发布产物并运行示例测试 |
+| `pnpm run test:release` | 发布工具的校验、模拟发布及失败重跑测试 |
+| `pnpm run bench` | 性能基准 |
+
+完整 PR 检查的定义以 [test.yml](.github/workflows/test.yml) 为准。
+文档修改重点检查命令、示例和相对链接;无需为纯文字改动重复运行全部运行时测试。
+
+## 变异测试
+
+[StrykerJS](https://stryker-mutator.io/) 用于发现测试覆盖或断言的缺口,需要 Node.js 22+。
+配置见 [stryker.config.json](stryker.config.json),范围为 `src/lib/**/*.ts`。
+
+```bash
+pnpm run mutation # 默认增量运行;无基线时全量运行
+pnpm run mutation:incremental # 显式增量,与上面的命令等价
+pnpm run mutation:full # 忽略已有结果,重建基线
+```
+
+首次全量运行可能较慢。HTML 报告位于 `reports/mutation/mutation.html`,增量基线位于
+`reports/stryker-incremental.json`;这些生成物不提交。分数用于定位存活变异,不要求追求 100%。
+基线只有在语义输入与运行环境兼容时才复用;不兼容时自动全量运行。可用 `mutation:full` 强制重建本地基线。
+
+[Mutation Testing](.github/workflows/mutation.yml) 工作流使用 Node.js 24 和 pnpm 9:
+面向 main 的 PR 只读取兼容的主分支基线,main 成功运行后才写入共享基线。
+兼容性规则由 [mutation-cache-inputs.json](.github/mutation-cache-inputs.json) 和
+[baseline-inputs.mjs](.github/scripts/baseline-inputs.mjs) 定义:默认哈希所有 Git 跟踪文件,
+只有经过审查并登记的非语义文件或字段可豁免。当前仅排除 README 内容及根包 version 字段;
+依赖声明、lockfile、其他文档、测试、适配器、配置和运行环境仍参与兼容性检查。
+本地包装器也使用同一清单并验证伴随的 `.inputs.json`;缺少或不兼容的元数据会触发全量运行。
+手动触发并选择 `full` 可强制重建,只有 main 的成功运行会更新共享基线。
+报告作为 `mutation-reports-*` artifact 保留 14 天。当前未设置最低变异分数门槛。
+
+## 维护文档
+
+- [README](README.md) 保留安装、可运行的快速开始和导航。
+- `guides/` 放使用说明;[examples/README.md](examples/README.md) 说明示例工程。
+- 本文件记录开发检查;发布维护操作集中在 [RELEASING.md](RELEASING.md)。
+- [MIGRATION.md](MIGRATION.md) 按版本记录升级动作和可见行为变化。
+
+类型参考由 `pnpm run doc` 生成到 `docs/`;该目录是被忽略的构建产物目录,手写指南放在 `guides/`。
+[文档工作流](.github/workflows/docs.yml) 当前在 master 推送或手动触发时构建和部署 TypeDoc。
+线上参考:。
diff --git a/MIGRATION.md b/MIGRATION.md
index a31ddb3..b46cedd 100644
--- a/MIGRATION.md
+++ b/MIGRATION.md
@@ -1,397 +1,130 @@
-# 迁移指南:erest v3.0
+# 迁移指南
-本版本对参数 schema 体系做了破坏性重构,并把参数校验改为 `bind()` 阶段预编译。
+[返回首页](README.md) · [使用指南](guides/api.md) · [贡献指南](CONTRIBUTING.md)
-## Stage 1 — Schema 统一(Zod 唯一)
+按版本倒序列出升级动作与可见行为变化。使用说明见 `guides/`,开发检查和发布操作分别见
+[贡献指南](CONTRIBUTING.md)与[发布流程](RELEASING.md)。
-把参数 schema 体系从双轨(ISchemaType + 原生 Zod)收敛为 **Zod 唯一**。
+## v3.2.3 — 生成物、测试工具与发布修复
-### 1. ISchemaType Record 定义方式移除
+无新增迁移要求。使用生成物的项目应重新生成 SDK 与文档:
-```diff
-- api.body({ name: { type: "String", required: true }, age: { type: "Integer" } })
-+ api.body(z.object({ name: z.string(), age: z.number().int() }))
-```
-
-`type` 字符串到 Zod 的映射见 `zodTypeMap`(仍导出)。常用对照:
-
-| ISchemaType | Zod 等价 |
-|-------------|---------|
-| `{ type: "String" }` | `z.string()` |
-| `{ type: "Integer" }` | `z.number().int()` |
-| `{ type: "Number", params: { min: 0, max: 100 } }` | `z.number().min(0).max(100)` |
-| `{ type: "Boolean" }` | `z.boolean()` |
-| `{ type: "ENUM", params: ["a","b"] }` | `z.enum(["a","b"])` |
-| `{ type: "Array", params: "String" }` | `z.array(z.string())` |
-| `{ type: "TrimString" }` | `z.string().trim()` |
-| `{ type: "Email" }` | `z.string().email()` |
-| `{ type: "Date" }` | `z.coerce.date()` |
+- Axios 路径参数恢复模板插值;Swagger 转换全部路径参数并保留 POST body 的必填字段。
+- Markdown 正确显示 Zod 4 数组、枚举及 `0` / `false` 默认值,省略缺失的 input 示例。
+- TestAgent 保留多个 Set-Cookie,支持流式附件内容;GET/DELETE 携带附件时提前报错。
+- 修复 ESM 下文档源码位置解析;修改错误 code 后,旧 code 可复用,新 code 冲突会立即报错。
+- 五个发布包统一为 `3.2.3`,子包补齐发布元数据;自动发布流程见 [RELEASING.md](RELEASING.md)。
-> Query string 中的值是字符串,若希望自动转数字/日期,用 `z.coerce.number()` / `z.coerce.date()`。
->
-> 上表为**语义近似**对照。`zodTypeMap` 的实际实现(见 `params.ts`)为兼容 query 字符串,
-> `Integer`/`Number`/`Boolean` 等用 `z.union` + `transform`(接受字符串并自动转换),
-> 且不再处理旧 ISchemaType 的 `params: { min, max }` 等选项——需在 Zod schema 里显式写
-> `.min()` / `.max()`。文档生成时仍可用这些别名名(`erest.type.register`)。
+## v3.2.2 — 返回结果文档
-### 2. required() 移除
+Swagger 的 `responses.200.schema` 展示声明的 response;Postman 为声明了 response 的接口
+生成占位响应示例。重新生成文档即可,不影响运行时路由与校验。
-必填由 Zod schema 是否 `.optional()` 表达:
-
-```diff
-- api.body({ name: { type: "String" } }).required(["name"])
-+ api.body(z.object({ name: z.string() })) // 非 optional 即必填
-```
+## v3.2.1 — Zod peer dependency 与原生响应
-### 3. requiredOneOf() 保留
+**必须由宿主项目安装 Zod 4**,它已从硬依赖移到 peer dependency:
-多选一必填语义保留(无完美 Zod 等价):
-
-```typescript
-api.requiredOneOf(["email", "phone"]); // email/phone 至少一个
+```bash
+npm install zod@^4
```
-### 4. 公开校验方法移除
-
-以下内部方法不再公开(校验已由 `registerTyped` / `bind()` 内部预编译完成):
-
-- `erest.createSchema()`
-- `erest.paramsChecker()`
-- `erest.schemaChecker()`
-- `erest.responseChecker()`
-- `erest.apiParamsCheck()`
-- `erest.schema.createZodSchema()`
-
-如需独立校验,直接用 Zod 的 `schema.parse(input)`。
-
-### 5. 错误消息简化
+避免在宿主和 erest 间使用不同 Zod 副本;适配器子包不直接依赖 Zod。
-校验失败消息从 ISchemaType 专属分支统一为 Zod issue 派生:
+- 新增 `ctx.reply.markSent()`:在成功响应包装器启用时,手动通过 raw 写完响应后调用它,跳过自动包装。
+- 修复 LeizmWeb 的 `ctx.reply.send(text)`,使用原生 `end()` 输出纯文本。
+- 测试工具 `.input()` 的归属为 GET/DELETE → query,POST/PUT/PATCH → body;DELETE 输入建议声明在 query 中。
-- 缺失必填:`missing required parameter 'field'`(不变)
-- 类型错误:`incorrect parameter 'field' should be valid`(去掉了类型后缀,如原 `should be valid Integer`)
+## v3.2.0 — Handler 签名、State 与响应包装
-### 6. 性能提升
-
-参数校验在 `bind()` 阶段预编译为热路径零分配闭包,基准测试显示校验吞吐约 **2x 提升**。
-
-## Stage 2 — 架构重组
-
-拆解 ERest 上帝类,删除 deprecated 方法,修复封装边界。
-
-### 1. bind() 成为唯一绑定入口(插件化 adapter)
-
-核心包不再内置三框架适配器实现,改为独立子包提供。`bind()` 接收 `adapter` 实例(插件形式):
+**所有 `registerTyped` handler 的第二参数由 reply 改为 ctx**,已有 handler 也需调整:
```diff
-- apiService.bindRouter(router, apiService.checkerExpress)
-+ import { ExpressAdapter } from "@erest/express";
-+ apiService.bind({ adapter: new ExpressAdapter(), router })
-
-- apiService.bindRouterToApp(app, express.Router, apiService.checkerExpress) // forceGroup
-+ apiService.bind({ adapter: new ExpressAdapter(), app, router: express.Router })
-
-- apiService.bindKoaRouterToApp(app, KoaRouter, apiService.checkerKoa)
-+ import { KoaAdapter } from "@erest/koa";
-+ apiService.bind({ adapter: new KoaAdapter(), app, router: KoaRouter })
-
-- apiService.bindRouterToApp(app, Router, apiService.checkerLeiWeb)
-+ import { LeizmWebAdapter } from "@erest/leizmweb";
-+ apiService.bind({ adapter: new LeizmWebAdapter(), app, router: Router })
+- (req, reply) => reply.json(data)
++ (req, ctx) => ctx.reply.json(data)
```
-| 子包 | 安装 | 适用框架 |
-|------|------|---------|
-| `@erest/express` | `pnpm add erest @erest/express express` | Express 4 |
-| `@erest/koa` | `pnpm add erest @erest/koa koa koa-router` | Koa 3 |
-| `@erest/leizmweb` | `pnpm add erest @erest/leizmweb @leizm/web` | @leizm/web 2 |
-
-自定义/第三方适配器:实现 `FrameworkAdapter` 接口(从 `erest` 导入),`name` 为任意字符串。
-
-`checkerExpress` / `checkerKoa` / `checkerLeiWeb` 属性与内置 `framework` 字符串分发同步移除。
-
-### 2. privateInfo 移除
-
-`erest.privateInfo` 不再暴露。改用受控访问器:
+- `ctx.state` 可通过 `createERest()` 推导。`State` 使用 `type` 别名,满足 `Record` 约束。
+- `setResponseEnvelopers({ success, error, testUnwrapper })` 启用 return 模式;未配置时继续手动调用 `ctx.reply`。
+- 新增 `z.anyObject()` / `zAnyObject`,接受动态字段对象。
+- TestAgent 的 `success()` 可标注返回类型。
-| 旧(privateInfo) | 新(受控访问器) |
-|-------------------|-----------------|
-| `.error` | `.getError()` |
-| `.groups` | `.getDocsView().groups` |
-| `.groupInfo` | `.getDocsView().groupInfo` |
-| `.info` | `.getTestView().info` |
-| `.app` | `.getTestView().app` |
-| `.mockHandler` | `.getMockHandler()` |
+用法见 [Handler 与响应](guides/api.md#handler-与响应)和 [API 测试](guides/testing.md)。
-Stage 3 新增的 `@internal` 访问器(非 privateInfo 替代,供 hook 装配用):
+## v3.1.0 — Raw 与适配器工厂
-| 访问器 | 用途 |
-|--------|------|
-| `.getHooks()` | 获取生命周期 hooks 配置(adapter 装配 dispatch 时读) |
-| `.hasHooks()` | hooks 是否非空(零开销裁剪判断) |
+建议将裸构造器改为适配器的工厂,以推导原生上下文类型:
-> 这些访问器标记 `@internal`,仅供 adapter/docs/test 内部使用,非公开 API。
-
-## Stage 3 — 可观测性 Hook + AI 友好
-
-### 1. 生命周期 Hook
-
-`new ERest({ hooks })` 支持注册同步观察者,不参与控制流:
-
-```typescript
-const api = new ERest({
- groups: { user: "用户" },
- hooks: {
- onRequest: (ctx) => { /* 注入 traceId、开始计时 */ },
- onValidate: (ctx, result) => { /* 记录校验耗时 */ },
- onError: (ctx, err) => { /* 结构化错误日志(保留 ERestError code) */ },
- onResponse: (ctx) => { /* 结束计时、状态码 */ },
- },
-});
-```
-
-- 无订阅者时 `bind()` 装配的 dispatch 裁剪掉 hook 调用,**热路径零开销**。
-- hook 异常被吞掉(观察者语义,不影响主流程)。
-
-### 2. AGENTS.md 架构导航
-
-新增 `AGENTS.md`,面向修改者(人与 AI):目录树、"改 X 去 Y"决策树、约定、常见任务套路。
-
-### 3. erest-gen codegen
-
-独立子包 `@erest/gen`(CLI 命令名 `erest-gen`),从 Zod schema 生成 handler 骨架:
-
-```bash
-npx erest-gen handler --from ./schemas/user.ts --group user --out ./handlers/user.ts
+```diff
+- import ERest from 'erest';
+- const api = new ERest(options);
++ import { createERest } from '@erest/express';
++ const api = createERest(options);
```
----
+Koa、LeizmWeb 使用各自子包的同名工厂。裸 `new ERest()` 仍保留,但 Raw 默认为 `unknown`。
-## 3.1.0 — reply.raw 逃生舱 + 框架能力对齐
+- `ctx.reply.raw` 提供框架原生对象,cookie、重定向和流式响应遵循宿主语义。
+- `Reply.status()` 返回类型收紧为 `this`;自定义 Reply 实现需保持链式返回。
+- 新增可选的 `defaultErrorFormatter()`,在应用级错误中间件中调用,不自动改变错误响应。
-3.1.0 是 **3.0 的 minor 版本**,引入框架原生能力逃生舱与统一错误格式器,**非破坏性变更**
-(现有 `new ERest()` 代码零改动,examples 测试全绿)。
+原生对象形态和错误处理示例见[框架接入](guides/adapters.md)。
-### 1. `reply.raw` 逃生舱
+## v3.0.1 — 安装与框架兼容
-`Reply` 接口新增 `readonly raw: Raw` 字段(`status()` 返回类型从 `Reply` 收紧为 `this`)。
-registerTyped 的 handler 通过 `reply.raw` 访问框架原生对象,解决 setCookie / redirect /
-stream / 文件下载等底层能力此前完全无法访问的问题。
+- 修复发布子包中泄漏的 `workspace:^` peer 协议;遇到 `EUNSUPPORTEDPROTOCOL` 时升级子包。
+- `info.version` 类型由 `Date` 修正为 `string`。
+- Express 支持 4/5,并归一化缺失 body;错误处理中间件应放在 app 级、路由之后。
+- Koa 可选 `@koa/router` 13 或 `koa-router` 13;新项目推荐前者。
+- 文档入口为 `api.genDocs(dir, false)`,第二参数表示立即生成,应用须先创建目录。
+- `info.basePath` 只影响文档与 SDK URL,实际挂载前缀由应用配置。
-`Context.reply` 仍保持 `Reply`——before/middleware/hook 不暴露 raw,仅 handler 拿到
-(避免 onError hook 与 re-throw 语义冲突的双重写入)。
+## v3.0.0 — Zod 与插件化适配器
-### 2. 全自动 Raw 泛型 + `createERest()` 工厂
+### Schema 改用 Zod
-`ERest` / `API` / `IGroup` / `genSchema` 全链路透传 Raw,
-registerTyped handler 的 reply 类型随之用 `Reply`。子包工厂在**构造时**锁定 Raw:
+旧 ISchemaType Record 定义方式和 `.required()` 移除,字段默认必填,可选字段显式 `.optional()`:
```diff
-- const api = new ERest({ info, groups, forceGroup: true });
-+ import { createERest } from "@erest/express"; // 或 @erest/koa / @erest/leizmweb
-+ const api = createERest({ info, groups, forceGroup: true }); // reply.raw 零标注强类型
-```
-
-- `createERest()` 由三子包分别导出(`@erest/express|koa|leizmweb`),各自返回
- `ERest`,handler 内 `reply.raw` 自动推导。
-- 裸 `new ERest()` 已标记 `@deprecated`(过渡期保留,Raw 默认 `unknown`,`reply.raw` 需断言)。
-- 三框架 adapter 在 `createXxxReply` 时把闭包已有的原生对象挂到 `raw`(热路径零额外分配,
- Koa/leizmweb 仅多挂引用,Express 多一个 `{req,res}` 字面量)。
-
-### 3. 可选统一错误格式器 `defaultErrorFormatter`
-
-新增工具函数(不改 adapter、不破坏 commit 727b2c0 的 re-throw 对齐),用户在 app 级错误中间件
-调用以统一三框架错误响应体:
-
-```typescript
-import { defaultErrorFormatter } from "erest";
-// Express
-app.use((err, _req, res, _next) => {
- const { status, body } = defaultErrorFormatter(err); // { status, body: { error, code } }
- res.status(status).json(body);
-});
+- api.body({ name: { type: 'String' }, age: { type: 'Integer' } }).required(['name'])
++ api.body(z.object({ name: z.string(), age: z.number().int().optional() }))
```
-### 4. 能力对齐:仅 `raw` + 文档速查表
-
-cookie/stream/redirect/headersSent 等原生能力差异不再逐个补 Reply 方法,统一通过 `reply.raw`
-暴露,并在 README 提供「三框架原生能力速查表」标注各框架语义差异。错误处理(re-throw 语义)
-已在 3.0.x 对齐,本次不动。
-
-> raw 的头部/cookie 操作应在 `reply.json()`/`reply.send()` 之前调用(HTTP 头先于体发送)。
-
----
-
-## 3.0.1 — 文档与发布修复
-
-3.0.1 是 **3.0 的补丁版本**,无运行时 breaking change,修复发布缺陷与文档错误:
-
-### 1. 发布包 peerDependencies 的 workspace 协议泄漏(P1,阻塞)
+| 旧声明 | Zod 写法 |
+| --- | --- |
+| String / Integer / Number | `z.string()` / `z.number().int()` / `z.number()` |
+| Boolean | `z.boolean()` |
+| ENUM | `z.enum(['a', 'b'])` |
+| Array of String | `z.array(z.string())` |
+| TrimString / Email | `z.string().trim()` / `z.string().email()` |
+| Date | `z.coerce.date()` |
-3.0.0 发布到 npm 的子包(`@erest/express|koa|leizmweb`)的 `peerDependencies.erest` 带着未替换的
-`"workspace:^"`,导致 `npm install` 报 `EUNSUPPORTEDPROTOCOL`。
+query 通常是字符串,数字使用 `z.coerce.number()`;范围等约束显式写 `.min()` / `.max()`。
+`zodTypeMap` 仍导出,其部分别名带字符串转换,与上表并非严格等价。
+`requiredOneOf(['email', 'phone'])` 保留。
-根因是 3.0.0 发布时未走 `pnpm publish`(或用了会跳过协议替换的方式)。`pnpm publish -r` 本身会
-**自动替换所有依赖类型(含 peerDependencies)的 workspace 协议**——已在 pnpm 9.15.9 实测确认:
-`pnpm pack` 产出的子包 manifest 中 `peerDependencies.erest` 被正确替换为 `^3.0.1`。
+以下公开校验入口已移除,独立校验直接调用 `schema.parse(input)`:
+`createSchema`、`paramsChecker`、`schemaChecker`、`responseChecker`、`apiParamsCheck`、`schema.createZodSchema`。
+错误信息统一由 Zod issues 生成,不再保证旧类型名后缀。
-3.0.1 回归标准发布流程:新增 `npm run publish`(= `pnpm publish -r --no-git-checks --access public`),
-由 `prepublishOnly` 钩子保证发布前完成 format + 全量测试(含 build)。`npm run publish:dry`
-可预览将要发布的包与 manifest,不实际发布。
+### 绑定改用适配器
-### 2. README handler 签名修正(P2/P7)
+安装对应的 `@erest/express`、`@erest/koa` 或 `@erest/leizmweb`,使用唯一绑定入口 `bind()`:
-3.0.0 的 README 在 Express / Koa / @leizm/web 三处接入示例中使用了错误的 handler 签名
-(Express 风格 `(req, res)`、Koa `ctx.body = ...`、@leizm/web `ctx.response.json()`)。
-实际所有框架下 `register`/`define` 的 handler 均为标准化 `(ctx, next)`,`ctx` 是 erest 内部
-上下文(有 `reply`/`$params`/`$validated`),响应统一走 `ctx.reply.json()`。
-3.0.1 README 全面改为**主推 `registerTyped`(`(req, reply)` 框架无关签名)**,并补充
-`register` 与 `registerTyped` 的区别表。
-
-### 3. 文档生成方法名修正(P3)
-
-README 原写的 `api.docs.generateDocs({...})` 方法不存在。正确入口是构造 ERest 时的
-`docs` 配置开关 + `api.genDocs(savePath)`。`genDocs()` 默认 `onExit=true`(进程退出时写盘),
-需立即产出传 `genDocs('./docs/', false)`。
-
-### 4. `version` 字段类型修正(P4)
-
-`IApiOptionInfo.version` 从 `Date` 改为 `string`(语义版本号字符串,如 `"1.0.0"`)。
-此前类型定义与所有示例(README / examples)均用字符串不一致,导致 TS 编译报错。
-
-### 5. Express 5 兼容(P5)
-
-`@erest/express` 的 peer dep 从 `express@^4.0.0` 扩展为 `^4.0.0 || ^5.0.0`,Express 5 设为
-推荐版本,仓库 devDep 与测试套件已升级到 Express 5.2.1(测试直接跑在 Express 5 上)。
-
-**修复的兼容 bug**:Express 5 对未匹配的 content-type(如 multipart/form-data)不再初始化
-`req.body = {}`,而是保持 `undefined`;Express 4 会初始化为 `{}`。erest 的 `compileValidate`
-对 `body === undefined` 会跳过 body 校验——这导致 Express 5 下带 multipart 上传、又定义了
-body schema 的接口,body 校验被**静默跳过**(缺字段不报错)。修复方式:Express adapter 构造
-Context 时把 `req.body ?? {}` 归一化为 `{}`,恢复 Express 4 行为,确保 body schema 校验照常执行。
-
-**注意**:Express 5 改变了子 router 内部错误的传播语义,router 级错误中间件可能捕获不到
-erest 抛出的校验错误,请改用 **app 级**错误中间件兜底。
-
-### 6. 其他文档/配置修正
-
-- `tsconfig.json` 移除 `baseUrl`(NodeNext 下已废弃,触发 TS5101 警告)。
-- README 补充 `@types/node` 安装说明与 `tsconfig.json` 示例(P9)。
-- README 补充 @leizm/web `component.bodyParser.urlencoded({ extended: true })`(P12,消除
- body-parser deprecated 警告)。
-- README 补充 `basePath` 仅用于文档生成、不作为路由前缀的说明(P6)。
-
-### 7. Koa 路由库兼容(P11)
-
-`koa-router` 整包已停止维护并标记 deprecated(npm 安装时 warning:「Please use @koa/router
-instead」)。`@koa/router` 是官方维护的继任包,API 与 koa-router **完全一致**(实测
-`@koa/router@15 + koa@3.2 + erest` 全功能正常,含 forceGroup 的 `new Router({prefix})` 构造)。
-
-`@erest/koa` 的 peer dep 从 `koa-router@^13.0.0` 扩展为**同时声明两者**并标记 optional:
+```diff
+- api.bindRouter(router, api.checkerExpress)
++ api.bind({ adapter: new ExpressAdapter(), router })
-```jsonc
-{
- "peerDependencies": {
- "koa-router": "^13.0.0", // 旧,optional
- "@koa/router": "^13.0.0" // 新,推荐,optional
- },
- "peerDependenciesMeta": {
- "koa-router": { "optional": true },
- "@koa/router": { "optional": true }
- }
-}
+- api.bindRouterToApp(app, express.Router, api.checkerExpress)
++ api.bind({ adapter: new ExpressAdapter(), app, router: express.Router })
```
-用户装 `koa-router` 或 `@koa/router` 任一即可,均无 missing peer warning。新项目建议直接用
-`@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`)。
-
-
-## v3.2.1 — enveloper 逃生舱 + @leizm/web send 修复 + 测试文档
-
-### 新增(非 breaking)
-
-- **`Reply.markSent()`**:enveloper 模式下,`registerTyped` handler 若需返回非 JSON 响应(CSV / 文件下载 / 流式)并经 `ctx.reply.raw` 手动写完响应,调用 `ctx.reply.markSent()` 告知 enveloper 跳过自动包装。否则 enveloper 会在 handler return 后再用 `successEnveloper` 包一层 `reply.json()`,可能覆盖已发送响应或触发「headers already sent」(各框架行为不一)。三 adapter(express/koa/leizmweb)的 `reply` 实现统一维护 `__sent` 标记:`json()`/`send()`/`markSent()` 均置位,`wrapWithEnvelope` 综合判断。不调 `markSent` 的存量 handler 行为不变。
-
-### 修复
-
-- **`@erest/leizmweb` `reply.send` 映射错误导致纯文本响应 404**:`createLeiReply.send` 此前调 `ctx.response.send()`,但 `@leizm/web` 的 response 没有 `send` 方法(仅有 `end/json/file`)。可选链 `leiRes.send?.()` 静默跳过 → 响应体未写 → 框架 final handler 返回 404。修正为映射到 `end(body)` + 补 `text/plain` content-type。**影响面**:`@leizm/web` adapter 下任何用 `ctx.reply.send(文本)` 的路由此前都会 404(用 `reply.json` 的路由不受影响)。
-
-### 文档
-
-- **测试引擎 `.input()` 的方法归属约定显式化**:GET/DELETE 归 query、POST/PUT/PATCH 归 body。注册 DELETE 路由时 schema 必须从 `query` 读参数(HTTP DELETE body 有争议,fetch/浏览器/代理普遍不传递)。README 与 `agent.ts` JSDoc 同步补充。
-
-### Breaking
-
-- **`zod` 从 `dependencies` 改为 `peerDependencies`**:作为 schema 校验库,erest 不应硬钉死 zod 版本,应由宿主项目统一提供。此前 erest 把 `zod@^4.0.5` 列为 hard dependency,导致任何 link/依赖 erest 的项目出现两份 zod 副本(erest 自带的 + 宿主的),TS 因 zod 版本字面量标记不同而报类型不兼容。**迁移**:宿主项目须在自身 `dependencies` 显式声明 `zod`(版本 `^4.0.0`)。adapter 子包(`@erest/express` 等)不直接依赖 zod,无需改动。
-
-
-## v3.2.3 — 变异测试驱动的缺陷修复(文档生成器 / TestAgent / 工具函数)
-
-接入 StrykerJS 变异测试(`pnpm run mutation`)后,补测过程中发现并修复以下问题:
-
-### 修复(非 breaking)
-
-**文档生成器(生成物正确性):**
-
-- **`generate_axios`:路径参数模板转义错误** —— 生成的 SDK 中 `` `/user/\$\{id\}` `` 因 `\$` 转义使插值退化为字面量文本,**所有路径参数方法的 URL 参数实际不生效**。修复后生成 `` `/user/${id}` ``,参数正常插值。
-- **`generate_swagger`:多参数路径只转换第一个占位符** —— `/:a/:b` 类路径的 `replace` 缺 `/g` 标志,只有 `:a` 变成 `{a}`。修复后全部转换。
-- **`generate_swagger`:POST body 字段级 required 信息丢失** —— 此前字段必填信息被 `delete` 丢弃(相关收集代码为死代码)。修复后聚合进 body 参数的 `schema.required`(JSON Schema 语义)。
-- **`generate_markdown`(schema):zod 4 下数组元素恒显示 `unknown[]`、枚举取值恒为空** —— 直接读 `_def.type`/`_def.values`(zod 3 形态)导致。已改走 `zod-meta.ts` 双轨访问器(`_def.element` / `entries`),Zod 3 与 Zod 4 下均正确渲染 `string[]` 与枚举值列表。
-- **`generate_markdown`(schema):falsy 默认值渲染为空** —— `z.number().default(0)` / `z.boolean().default(false)` 的默认值列此前显示为空,现在如实展示 `0` / `false`。
-- **`generate_markdown`(apis):无 input 的示例渲染出字面量 `undefined` 文本** —— 已修复为不渲染该行。
-
-**TestAgent(测试代理):**
-
-- **多个 `Set-Cookie` 响应头相互覆盖** —— `headers.forEach` 逐条回调使后值覆盖前值,session cookie 丢失。修复后用 `getSetCookie()` 取完整数组再合并(逗号拼接,与 cookie jar 解析约定一致)。
-- **`attach()` 传流时内容不传输** —— Readable 直接 `form.append` 会被 undici FormData 隐式 `String()` 成 `[object Object]`。修复后流在请求时读尽为 Blob 再传输(multipart 文件名取字段名)。
-- **GET/DELETE + `attach()` 直接抛 fetch 底层错误** —— 现在提前断言并给出中文提示(GET 不能携带 body,建议改用 POST/PUT/PATCH 或 `input()` 传 query)。
-
-**工具函数:**
-
-- **`getCallerSourceLine` 在 ESM 下恒返回空** —— 栈帧正则只匹配 CJS 括号风格,ESM/现代 V8 无括号帧(`at /path/file.ts:1:1`,且为 `file://` URL)永不匹配,API 文档的 sourceFile 字段静默失效。修复后双风格兼容并统一去掉 `file://` 前缀。
-- **`jsonStringify` 的 replacer 为死代码** —— 防循环引用逻辑早已被注释,两分支行为恒同,已简化为直接 `JSON.stringify`。
-- **`ErrorManager.modify()` 换 code 后旧 code 永久占用** —— 修复后旧 code 释放(可被再次注册),且新 code 若与其他错误冲突会立即报错(此前静默成功、错误暴露点错位)。
-
-### 约定收敛
-
-- **`_def` 读取全部集中到 `plugin/zod-meta.ts`**(AGENTS.md 约定 6):`generate_axios` / `generate_swagger` / `generate_postman` 此前的直读 `_def.typeName` / `_def.shape` / `_def.innerType` 已迁移到统一双轨访问器,并新增 `resolveZodShape` / `getZodElement` / `getZodEnumValues` / `getZodOptions` / `getZodDefaultValue` / `getZodLazyGetter` 导出。
-
-### 变异测试基础设施
-
-- 新增 `stryker.config.json` 与 `pnpm run mutation` / `mutation:incremental` 脚本(vitest runner + perTest 覆盖分析)。
-- 变异分数从 44.93% 提升至 89.7%+(新增约 270 个内容级断言用例,全部融入现有测试文件)。
-
-### 发布与验证
-
-- 根包 `erest` 与 `@erest/express`、`@erest/koa`、`@erest/leizmweb`、`@erest/gen` 统一为 `3.2.3`,按仓库约定整体发布;adapter 的 `workspace:^` peer 在 pnpm 打包时转换为 `erest: ^3.2.3`。
-- 新增增量变异测试 CI:PR 读取兼容主分支基线,主分支成功后保存;`pnpm run mutation:full` 可强制全量重建。
-- 源码测试在 `ISLIB` 模式将 adapter 的 `erest` 导入解析到源码,支持无 `dist` 的洁净环境。
-- 已合入代码通过 604 个库测试、13 个示例测试和覆盖率门槛;完整 3,070 个变异的分数为 90.10%,无超时或运行错误。
-- 新增 tag 触发的 npm OIDC 自动发布:校验统一版本及 `main` 归属,验证五个 tarball 和消费端导入,按依赖顺序发布,全部成功后完成 GitHub Release;重跑跳过已发布的精确版本。
-- 四个子包补齐仓库元数据以支持 provenance。移除 `postpublish` 自动推送分支/tag 的副作用,维护者按 `RELEASING.md` 配置每个包的 Trusted Publisher 后显式推送发版 tag。
-
-## v3.2.2 — 文档展示返回结果 schema(Swagger / Postman)
-
-### 新增(非 breaking)
-
-- **Swagger 文档的 `responses.200` 输出 response schema**:此前 Swagger 的 responses 写死为 `{ 200: { description: "请求成功" } }`,完全忽略 builder 声明的 `.response()` / `registerTyped({ response })`。现在当 API 声明了 response schema 时,`responses.200.schema` 会输出标准的 Swagger 2.0 schema(含 `type/properties/required`,支持 string/number/boolean/array/enum/date 等 Zod 类型推断);未声明时保持原有占位行为不变。复用 `extractDocFields`,与参数提取保持一致。
-- **Postman 文档的 item 输出 response 示例**:此前 Postman Collection 只生成 request,不输出 response。现在当 API 声明了 response schema 时,每个 item 会带一个 `response` 数组,含按 schema 字段名 + 类型推断生成的占位示例 body(Postman 可直接用作 mock);未声明时不输出 `response` 字段。
+`bindKoaRouterToApp`、旧 checker 属性和 `framework` 字符串分发一并移除。
+`register()` / `define()` handler 使用标准 `(ctx, next)`;`registerTyped()` 的当前签名见 v3.2.0。
+完整绑定代码见[框架接入](guides/adapters.md)。
-### 影响范围
+### 扩展与内部访问
-仅文档生成器(`generate_swagger` / `generate_postman`),不涉及运行时校验、路由绑定或类型推导。已声明 response schema 的项目重新生成文档即可看到变化;未声明的项目输出与之前完全一致。
+`privateInfo` 移除。适配器、文档和测试扩展使用 `getError()`、`getDocsView()`、`getTestView()`、
+`getMockHandler()` 等受控访问器;这些 `@internal` 入口不作为应用公开 API。
+新增 hooks 为同步观察者,异常不影响主流程;鉴权等控制流使用 before/middleware。
+架构约定见 [AGENTS.md](AGENTS.md),handler 骨架工具见 [@erest/gen](packages/erest-gen/README.md)。
diff --git a/README.md b/README.md
index 565fd7f..a0c0738 100644
--- a/README.md
+++ b/README.md
@@ -1,746 +1,97 @@
-[](https://npmjs.org/package/erest)
-[](https://github.com/yourtion/node-erest/actions/workflows/test.yml)
-[](https://codecov.io/github/yourtion/node-erest)
-[](http://nodejs.org/download/)
-[](https://npmjs.org/package/erest)
-[](https://npmjs.org/package/erest)
-
# ERest
-基于 TypeScript 的 API 框架,支持自动文档生成、类型安全校验与测试脚手架。兼容 Express、Koa、@leizm/web 等主流框架。
-
-📖 **API 文档**:(master 推送后由 CI 自动构建部署)
+[](https://npmjs.org/package/erest)
+[](https://github.com/yourtion/node-erest/actions/workflows/test.yml)
+[](https://codecov.io/github/yourtion/node-erest)
-## 特性
+用一份 Zod schema 定义和校验 API,为 Express、Koa、@leizm/web 生成文档与 Axios SDK。
+TypeScript handler 的参数类型由 schema 推导。
-- TypeScript 原生支持,完整的类型推导与类型安全
-- 原生 Zod 集成,高性能参数校验与类型推导
-- 自动文档生成,支持 Swagger、Postman、Markdown 等格式
-- 内置测试脚手架,可像调用本地方法一样编写 API 测试
-- 多框架支持:Express、Koa、@leizm/web
-- 自动生成基于 axios 的客户端 SDK
+- **统一 API 定义**:支持分组、中间件、分层参数和响应校验。
+- **文档与 SDK**:Swagger 2、Markdown、Postman、Axios。
+- **测试工具**:通过真实 HTTP 调用 API,将请求与响应保存为文档示例。
## 安装
-核心包 + 所用框架的 adapter 子包:
+需要 Node.js 20+,使用 ESM。安装核心包、Zod 和所用框架的适配器:
```bash
-# Express
-npm install erest @erest/express express
+# Express 4 / 5
+npm install erest zod @erest/express express
-# Koa(推荐 @koa/router;也兼容 koa-router)
-npm install erest @erest/koa koa @koa/router koa-bodyparser
-# 注:koa-router 已停止维护并标记 deprecated,建议用 @koa/router(API 完全兼容,零改动替换)。
-# erest 的 peer dep 同时接受二者,装哪个都不会有 missing peer warning。
+# Koa 3(也兼容 koa-router 13)
+npm install erest zod @erest/koa koa @koa/router@^13 koa-bodyparser
-# @leizm/web
-npm install erest @erest/leizmweb @leizm/web
+# @leizm/web 2
+npm install erest zod @erest/leizmweb @leizm/web
```
-ERest 核心框架无关,三框架适配器作为独立子包(`@erest/express` / `@erest/koa` / `@erest/leizmweb`)按需安装。
-
-> **TypeScript 工程**:erest 是 ESM-only(`type: "module"`)。若用 TypeScript,请确保安装
-> `@types/node`(erest 的 `peerDependencies` 声明了 `@types/node`),并使用如下 `tsconfig.json`
-> 关键项(注意:**不要**设置 `baseUrl`,它在 `module: NodeNext` 下已废弃且会触发 TS5101 警告):
->
-> ```jsonc
-> {
-> "compilerOptions": {
-> "module": "NodeNext",
-> "moduleResolution": "NodeNext",
-> "target": "es2022",
-> "lib": ["es2022"], // Node 类型经 @types/node 注入,无需加 "dom"
-> "strict": true,
-> "esModuleInterop": true,
-> "types": ["node"] // 显式包含 Node 全局类型(console/Buffer 等)
-> }
-> }
-> ```
->
-> 编译/运行 TypeScript 用本地安装的 `typescript`(`npx tsc` 在未装 typescript 时会解析到 npm
-> 上一个废弃的同名包)。推荐:`npm install -D typescript @types/node`,再 `npx tsc` 或
-> `node --import tsx src/index.ts`。
-
## 快速开始
-ERest 通过统一的 `bind()` 方法接入任意框架。API 定义方式与框架无关,接入方式按框架区分。
-
-### 定义 API
+下面是完整的 Express 示例,保存为 `app.mjs`:
-无论使用哪个框架,API 的定义方式完全一致。推荐使用 `registerTyped`——它基于 Zod schema
-自动推导参数类型,handler 签名为**框架无关的 `(req, ctx)`**,同一份 handler 可被
-Express / Koa / @leizm/web 复用:
-
-```typescript
-import ERest, { z } from 'erest';
-
-const api = new ERest({
- info: {
- title: 'My API',
- description: 'A powerful API built with ERest',
- version: '1.0.0', // 字符串:语义版本号,用于文档生成
- host: 'http://localhost:3000',
- // basePath 仅用于文档生成(swagger/postman 的 URL 拼接),不作为路由前缀。
- // 路由前缀由各框架的 mount 决定,详见下文「框架接入」。
- basePath: '/api',
- },
- groups: {
- user: '用户管理',
- post: '文章管理',
- },
-});
-
-// GET /users/:id —— path + query 参数(req.params / req.query 类型由 schema 推导)
-api.api
- .get('/users/:id')
- .group('user')
- .title('获取用户信息')
- .registerTyped(
- {
- params: z.object({ id: z.string().describe('用户ID') }),
- query: z.object({ include: z.string().optional().describe('包含的关联数据') }),
- },
- async (req, ctx) => {
- const user = await getUserById(req.params.id, req.query.include);
- ctx.reply.json({ success: true, data: user });
- },
- );
-
-// POST /users —— 请求体校验(req.body 类型由 schema 推导,无需 as 断言)
-const CreateUserSchema = z.object({
- name: z.string().min(1).max(50),
- email: z.string().email(),
- age: z.number().int().min(18).max(120),
-});
-
-api.api
- .post('/users')
- .group('user')
- .title('创建用户')
- .registerTyped({ body: CreateUserSchema }, async (req, ctx) => {
- const user = await createUser(req.body);
- ctx.reply.status(201).json({ success: true, data: user });
- });
-```
-
-> **handler 签名说明**:`registerTyped` 的 handler 是 `(req, ctx)`,其中
-> `req.params/query/body/headers` 是分层校验后的参数,`ctx.reply.json()/status()/send()`
-> 是统一的响应接口。**不要**用 Express 的 `(req, res)` 或 Koa 的 `ctx.body = ...` 写法——
-> 详见 [register 与 registerTyped 的区别](#类型安全的-handlerregistertyped)。
-
-根据所选框架,参考下文对应的接入方式。
-
-## 框架接入
-
-`bind()` 接收一个 adapter 实例(由对应框架的子包提供),支持两种模式:
-
-| 模式 | 适用场景 | 调用方式 |
-|------|----------|----------|
-| 非 forceGroup | API 直接挂载到指定 router,路径即定义的 path | `bind({ adapter, router })` |
-| forceGroup | 按分组自动挂载到 app,每个分组有独立前缀 | `bind({ adapter, app, router: RouterCtor })` |
-
-### Express
-
-```typescript
+```javascript
+import { mkdirSync } from 'node:fs';
import express from 'express';
-import { ExpressAdapter } from '@erest/express';
-// import { api } from './api'; // 上文定义的 ERest 实例
+import { z, defaultErrorFormatter } from 'erest';
+import { createERest, ExpressAdapter } from '@erest/express';
const app = express();
-const router = express.Router();
app.use(express.json());
-app.use(express.urlencoded({ extended: true }));
-app.use('/api', router); // 路由前缀由 Express mount 决定(与 erest 的 basePath 无关)
-
-// 将 ERest 的所有 API 绑定到 Express router
-api.bind({ adapter: new ExpressAdapter(), router });
-// 错误处理中间件:必须挂在 app 级别(不是 router 级别)。
-// Express 5 改变了子 router 内部抛出错误的传播语义——router 级错误中间件
-// 可能捕获不到 erest 抛出的校验错误,因此推荐用 app 级兜底。
-app.use((err, _req, res, _next) => {
- res.status(err.statusCode || 400).json({ message: err.message });
+const api = createERest({
+ info: { title: 'Users API', version: '1.0.0', host: 'http://localhost:3000' },
+ groups: { users: '用户' },
+ docs: { swagger: true, axios: true },
});
-app.listen(3000);
-```
-
-> **Express 版本**:`@erest/express` 同时支持 Express 4(`express@^4`)和 Express 5(`express@^5`)。
-> 二者均经测试通过,5 为当前推荐版本。Express 5 下请使用 **app 级**错误中间件(见上)。
-
-forceGroup 模式下,`router` 参数传入 `express.Router` 构造函数:
-
-```typescript
-const app = express();
-app.use(express.json());
-
-const api = new ERest({
- forceGroup: true,
- groups: {
- user: { name: '用户管理', prefix: '/users' },
- post: { name: '文章管理' }, // 未指定 prefix 时按分组名自动生成(驼峰转下划线小写:post→/post)
- },
-});
+api.api.post('/users').group('users').title('创建用户').registerTyped(
+ { body: z.object({ name: z.string().min(1) }) },
+ (req, ctx) => ctx.reply.json({ name: req.body.name }),
+);
-api.bind({ adapter: new ExpressAdapter(), app, router: express.Router });
-// forceGroup 下错误中间件同样建议挂 app 级
+api.bind({ adapter: new ExpressAdapter(), router: app });
app.use((err, _req, res, _next) => {
- res.status(err.statusCode || 400).json({ message: err.message });
-});
-app.listen(3000);
-```
-
-### Koa
-
-需额外安装路由库(推荐 `@koa/router`,也兼容已停维护的 `koa-router`)和 `koa-bodyparser`:
-
-```bash
-npm install koa @koa/router koa-bodyparser
-```
-
-```typescript
-import Koa from 'koa';
-import KoaRouter from '@koa/router'; // 或 from 'koa-router'(API 完全一致)
-import bodyParser from 'koa-bodyparser';
-import { KoaAdapter } from '@erest/koa';
-// import { api } from './api';
-
-const app = new Koa();
-
-// 1. body 解析(必须在路由之前)
-app.use(bodyParser());
-
-// 2. 统一错误处理:参数校验失败时返回错误信息
-app.use(async (ctx, next) => {
- try {
- await next();
- } catch (err) {
- ctx.status = err.statusCode || err.status || 400;
- ctx.body = { message: err.message };
- }
+ const { status, body } = defaultErrorFormatter(err);
+ res.status(status).json(body);
});
-// 3. 创建路由并绑定 ERest
-const router = new KoaRouter();
-api.bind({ adapter: new KoaAdapter(), router });
-
-app.use(router.routes()).use(router.allowedMethods());
-
+mkdirSync('./docs', { recursive: true });
+api.genDocs('./docs', false);
app.listen(3000);
```
-> **handler 签名**:erest 在 Koa 下同样使用**标准化 `(ctx, next)`** 签名——`ctx` 是 erest
-> 的内部上下文(有 `reply`/`$params`/`$validated` 等),**不是** Koa 原生 ctx,没有 `.body` setter。
-> 返回响应请用 `ctx.reply.json()`。推荐直接用 `registerTyped`(handler 为 `(req, ctx)`):
-
-```typescript
-api.api
- .post('/users')
- .group('user')
- .registerTyped({ body: z.object({ name: z.string() }) }, (req, ctx) => {
- ctx.reply.json({ name: req.body.name });
- });
-```
-
-forceGroup 模式下,`router` 参数传入 `KoaRouter` 构造函数:
-
-```typescript
-const app = new Koa();
-app.use(bodyParser());
-
-const api = new ERest({
- forceGroup: true,
- groups: {
- v1: { name: 'Version 1', prefix: '/v1' },
- user: { name: 'User Group' },
- },
-});
-
-api.bind({ adapter: new KoaAdapter(), app, router: KoaRouter });
-app.listen(3000);
-// v1 分组的路由挂载到 /v1/*,user 分组挂载到 /user/*
-```
-
-### @leizm/web
-
-@leizm/web 内置 `component.bodyParser` 中间件用于解析请求体(基于 body-parser)。
-
-```typescript
-import { Application, Router, component } from '@leizm/web';
-import { LeizmWebAdapter } from '@erest/leizmweb';
-// import { api } from './api';
-
-const app = new Application();
-
-// 1. body 解析(内置中间件,基于 body-parser)
-// urlencoded 必须传 { extended: true },否则触发 body-parser deprecated 警告
-app.use('/', component.bodyParser.json());
-app.use('/', component.bodyParser.urlencoded({ extended: true }));
-
-// 2. 创建路由并绑定 ERest
-const router = new Router();
-api.bind({ adapter: new LeizmWebAdapter(), router });
-app.use('/', router);
-
-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, ctx)`):
-
-```typescript
-api.api
- .post('/users')
- .group('user')
- .registerTyped({ body: z.object({ name: z.string() }) }, (req, ctx) => {
- ctx.reply.json({ name: req.body.name });
- });
-```
-
-forceGroup 模式下,`router` 参数传入 @leizm/web 的 `Router` 构造函数:
-
-```typescript
-const app = new Application();
-app.use('/', component.bodyParser.json());
-
-const api = new ERest({
- forceGroup: true,
- groups: {
- v1: { name: 'Version 1', prefix: '/v1' },
- user: { name: 'User Group' },
- },
-});
-
-api.bind({ adapter: new LeizmWebAdapter(), app, router: Router });
-app.server.listen(3000);
-```
-
-## 自动文档生成
-
-根据已定义的 API 自动生成多种格式的文档和 SDK。有两种触发方式:
-
-**方式一:构造 ERest 时声明文档开关**(推荐)。在 `docs` 配置里指定要生成的格式,
-然后调用 `api.genDocs(savePath)`——默认在**进程退出时**写盘(`onExit=true`):
-
-```typescript
-const api = new ERest({
- info: { /* ... */ },
- groups: { /* ... */ },
- docs: {
- swagger: true, // 生成 swagger.json
- postman: true, // 生成 postman.json
- axios: true, // 生成 jssdk.js(基于 axios)
- // markdown 输出细化开关(见下表):
- wiki: true, // 生成 Home.md + 各分组 .md(默认开启)
- index: true, // 生成 index.md
- all: true, // 生成汇总文件 API文档-.md
- },
-});
-
-// 注册 API 后调用:默认 onExit=true,在 process exit 时写文件
-api.genDocs('./docs/');
-// 如需立即写盘(不等待退出):api.genDocs('./docs/', false);
-```
-
-> 注:`swagger`/`postman`/`axios` 这类开关既可填 `true`(用默认文件名),也可填字符串指定文件名;
-> `markdown` 是总开关(控制是否加载 markdown 插件),其具体产物文件由 `wiki`/`index`/`all` 决定。
-
-**方式二:运行时挂插件**。用 `api.addDocPlugin(name, plugin)` 注册自定义生成器,
-再调用 `api.genDocs('./docs/')` 触发。
-
-| 配置键 | 默认 | 说明 |
-|--------|------|------|
-| `markdown` | `true` | 是否加载 markdown 插件(总开关,不决定具体文件名) |
-| `wiki` | `"./"` | markdown 插件写 `Home.md` + 每个分组的 `.md`(默认开启) |
-| `index` | `false` | markdown 插件写 `index.md` 目录页 |
-| `all` | `false` | markdown 插件写汇总文件 `API文档-.md` |
-| `swagger` | `false` | 生成 `swagger.json`(OpenAPI,可导入 Apifox / Swagger UI) |
-| `postman` | `false` | 生成 `postman.json`(Postman Collection) |
-| `axios` | `false` | 生成 `jssdk.js`(基于 axios 的前端 SDK) |
-| `json` | `false` | 生成 `doc.json`(完整文档数据快照) |
-
-> **注意**:`genDocs()` 默认 `onExit=true`(注册 `process.on('exit', ...)`),调用后立即查看
-> 输出目录会是空的——文件在进程退出时才写入。测试或需要立即产出的场景传第二个参数 `false`。
-
-## 测试脚手架
-
-内置测试工具,可像调用本地方法一样测试 API:
-
-> `.input(data)` 对 **GET/DELETE** 把 `data` 转为 query string,对 **POST/PUT/PATCH** 作为 JSON body。
-> 用 `.query(data)` / `.json(data)` 可显式指定来源。
-
-> **⚠️ DELETE 参数从 query 读**:HTTP DELETE 带 body 有争议(fetch/浏览器/代理普遍不保证传递),
-> 所以测试引擎把 DELETE 的 `.input()` 归为 query,**生产侧三 adapter 也只从 query 读 DELETE 参数**。
-> 因此注册 DELETE 路由时,schema 必须用 `query`(不是 `body`):
-
-```typescript
-// ✅ DELETE 的确认参数走 query
-api.delete('/items/:id').registerTyped(
- { params: z.object({ id: z.string() }), query: z.object({ confirm: z.boolean() }) },
- (req) => { if (!req.query.confirm) throw new Error('需确认'); /* ... */ },
-);
-// 测试侧对应:api.test.delete('/items/1').input({ confirm: true })
-```
-
-```typescript
-// initTest 接收 http.Server 或框架的 app/server/callback
-api.initTest(app);
-
-it('应成功创建用户', async () => {
- const ret = await api.test.post('/api/users').input({
- name: 'Tom',
- email: 'tom@example.com',
- age: 20,
- }).success();
- expect(ret).toStrictEqual({ success: true, data: { id: 1 } });
-});
-
-it('应拒绝未成年用户', async () => {
- const err = await api.test.post('/api/users').input({ age: 15 }).error();
- expect(err).toBeInstanceOf(Error);
-});
-```
-
-`.takeExample(name)` 在 `.success()` 前调用,会把这次请求的真实 `input`/`headers`/`output` 回填为该路由的文档示例(见下节「测试驱动的文档」):
-
-```typescript
-it('创建用户并记录示例', async () => {
- await api.test.post('/api/users')
- .input({ name: 'Tom', email: 'tom@example.com', age: 20 })
- .takeExample('正常创建') // 真实响应被存为该路由的文档示例
- .success();
-});
-```
-
-## 测试驱动的文档
-
-测试脚手架与文档生成是**联动**的——这是 erest「测试即文档」的设计:用 test-agent 跑过的路由,
-生成的 markdown 文档里会显示 ✅(而非 ❌),`.takeExample()` 记录的真实请求/响应会成为文档示例。
-
-**两个标记的来源:**
-
-| 行为 | 效果 | 对应 API |
-|------|------|----------|
-| `.success()` / `.error()` / `.raw()` 读取响应 | 该路由 `tested = true` → markdown 标题显示 ✅ | `agent.output()` 内部设置 |
-| `.takeExample(name)`(`.success()` 前) | 真实 input/headers/output 存为该路由 example → 文档「使用示例」段落 | `agent.saveExample()` |
-
-不跑任何测试、直接 `genDocs()` 时,所有路由是 ❌、无示例(`generate.js` 的 mock 模式);
-跑过 test-agent 后,被测路由变 ✅ 且带真实数据。
-
-**完整示例**([examples/docs/generate-from-test.js](./examples/docs/generate-from-test.js)):
-
-```typescript
-const api = new ERest({
- info: { /* ... */ },
- groups: { /* ... */ },
- docs: { markdown: true, wiki: true, index: true }, // 开启 markdown 文档生成
-});
-registerApi(api, store, { /* hooks */ });
-api.bind({ adapter: new ExpressAdapter(), app, router: express.Router });
-api.initTest(app);
-
-// 用 test-agent 跑真实请求:翻转 tested(✅) + 回填真实示例
-await api.test.get('/public/posts').takeExample('文章列表').success();
-await api.test.post('/posts/posts')
- .headers({ 'X-Admin-Token': 'user-token' })
- .input({ slug: 'new', title: '新文章', content: '内容' })
- .takeExample('创建文章')
- .success();
-
-// 落盘:✅ 标记与真实示例一并写入 markdown
-api.genDocs('./docs/out-from-test/', false);
-```
-
-**运行:**
-
-```bash
-pnpm --filter erest-example docs:test # 测试驱动文档(✅ + 真实示例)
-pnpm --filter erest-example docs # 对比:mock 文档(全 ❌、无示例)
-```
-
-
-## 类型安全的 Handler:`registerTyped`
-
-`register()` 的 handler 入参无类型,需要手动断言。`registerTyped()` 基于 Zod schema 自动推导
-`req.params` / `req.query` / `req.body` / `req.headers` 的类型,**编译期类型安全、运行时由
-checker 统一校验**,且对 Express / Koa / @leizm/web 三个框架都有效。
-
-handler 签名为 `(req, ctx)`——**与框架无关**:`req` 是分层校验后的参数,`reply` 是统一的响应接口
-(`ctx.reply.json()` / `ctx.reply.status()`)。因此**同一份 handler 可被三个框架复用**,无需关心 ctx/res 差异。
-
-```typescript
-const CreateUserSchema = z.object({
- name: z.string().min(1).max(50),
- email: z.string().email(),
- age: z.number().int().min(18),
-});
-
-api.api
- .post('/users')
- .group('user')
- .title('创建用户')
- .registerTyped(
- { body: CreateUserSchema },
- (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)
- ctx.reply.status(201).json({ success: true, id: user.id });
- },
- );
-```
-
-> `registerTyped` 的校验由 adapter 的 checker 完成(参数注入到 `ctx.$validated`,handler 包装层
-> 从中读取并组装成 `req`),handler 内不重复 parse。若提供 `response` schema 且 handler 有返回值,
-> 返回值会经 schema 校验(适合只读/纯计算型 handler,此时可不调用 `reply`)。
-
-### `register` / `define` 与 `registerTyped` 的区别
-
-| API | handler 签名 | 适用场景 |
-|-----|-------------|----------|
-| `registerTyped(schemas, fn)` | `(req, ctx)` | **推荐**。编译期类型安全,校验自动完成,框架无关 |
-| `register(fn)` / `define({handler})` | `(ctx, next)` | 标准化 Koa 风格签名。`ctx` 有 `$params`/`$validated`/`reply`/`state` 等,需自己读 ctx(无类型推导)。适合需要 `next` 控制流或 `ctx.state` 跨中间件传数据的场景 |
-
-无论哪种 API,**响应都通过 `reply` 写入**(`registerTyped` 是 `ctx.reply`,`register` 是 `ctx.reply`)——不要用框架原生的 `res.json()` / `ctx.body =` / `ctx.response.json()`。
-
-### 原生能力逃生舱:`ctx.reply.raw`
-
-handler 第二参数 `ctx` 携带的 `reply` 只暴露 `json()/status()/send()` 三个框架无关方法。当需要框架特有能力(setCookie、redirect、流式响应、文件下载等)时,通过 `ctx.reply.raw` 访问框架原生对象——它是「逃生舱」,绕开标准抽象直达底层。
-
-`ctx.reply.raw` 的类型由 **`ERest` 的 Raw 泛型**驱动。用子包提供的 `createERest()` 工厂创建实例,会在构造时自动锁定 Raw,handler 内 `ctx.reply.raw` 自动强类型、**零标注**:
-
-```typescript
-import { createERest } from '@erest/express';
-// 用 createERest 代替 new ERest:构造时锁定 Raw = ExpressRaw({ req, res })
-const api = createERest({ info, groups, forceGroup: true });
-
-api.api.post('/login').group('auth').title('登录').registerTyped(
- { body: LoginSchema },
- (req, ctx) => {
- const user = authenticate(req.body);
- // 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`,`ctx.reply.raw` 需手动断言。新代码推荐用子包 `createERest()`。
-
-**框架原生能力速查表**(`ctx.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` |
-| 重定向 | `.res.redirect(code, url)` | `.redirect(url)` | `.response.redirect(...)` |
-| 流式响应 | `.res.write()` / `.res.end()` | `.body = stream` | `.response.send()` |
-| 设置响应头 | `.res.setHeader(k, v)` | `.set(k, v)` | `.response.setHeader(k, v)` |
-| 响应头已发送 | `.res.headersSent` | `.res.headersSent` | 查原生 |
-
-> ⚠️ `raw` 的头部/cookie 操作应在 `ctx.reply.json()`/`ctx.reply.send()` **之前**调用(HTTP 头先于体发送)。`reply.raw` 是逃生舱,不参与框架无关复用——用了 raw 的 handler 即与具体框架耦合。
-
-#### `ctx.reply.markSent()`:enveloper 模式下的非 JSON 响应逃生舱
-
-当注册了 `setResponseEnvelopers` 且 `registerTyped` handler 需要返回**非 JSON 响应**(CSV / 文件下载 / 流式)时,handler 必须经 `reply.raw` 手动写完整响应,并调用 **`ctx.reply.markSent()`** 告知 enveloper 跳过自动包装。
-
-否则 handler return 后,enveloper 会用 `successEnveloper` 包一层 `reply.json()`,可能覆盖已发送的响应或触发「headers already sent」类错误(各框架行为不一)。
-
-```typescript
-api.group('admin').get('/tables/:id/export').registerTyped(
- { params: z.object({ id: z.string() }) },
- (req, ctx) => {
- // 经 raw 手动写 CSV(非 JSON),三框架原生写法不同
- const res = ctx.reply.raw.res; // Express;Koa 用 ctx.reply.raw.body,@leizm/web 用 ctx.reply.raw.response
- res.setHeader('Content-Type', 'text/csv');
- res.end('a,b\n1,2');
- ctx.reply.markSent(); // ← 关键:跳过 enveloper 的自动包装
- },
-);
-```
-
-> `markSent()` 只在「enveloper + registerTyped + 手动 raw 响应」三者同时成立时需要。
-> 用 `register()`(非 typed)注册的 handler 本就不触发 enveloper,无需调用。
-
-## 参数读取:`$params` 与分层访问器
-
-adapter 的 checker 把校验后的参数注入到 erest 的标准化 `ctx` 上,handler 通过它们读取。
-注意:**所有框架下的 handler 收到的都是同一个标准化 `ctx`**(不是 Express req / Koa ctx):
-
-| 访问器 | 位置 | 含义 |
-|--------|------|------|
-| `$params` | `ctx.$params` | 扁平合并(params+query+body+headers),便捷读取 |
-| `$validated` | `ctx.$validated` | 分层聚合对象 `{ params, query, body, headers }` |
-| `$pathParams` `$query` `$body` `$headers` | `ctx.$pathParams` 等 | 按来源分层,**互不覆盖** |
-
-> `registerTyped` 已通过 `req.params/query/body` 提供分层读取,**无需**再访问 `$validated`。
-> 下面的分层访问器主要供 `register()` 的 handler 使用(其入参是标准化 `ctx`,无统一 req)。
-
-**何时用分层访问器**:当路径参数与请求体存在同名字段时,扁平 `$params` 会发生静默覆盖
-(后合并的来源覆盖前者)。例如 `PUT /users/:id` 同时定义了 path 的 `id` 与 body 的 `id`:
-
-```typescript
-// 用分层访问器避免同名覆盖(三框架通用,ctx 为 erest 标准化上下文)
-api.api
- .put('/users/:id')
- .group('user')
- .params(z.object({ id: z.coerce.number() }))
- .body(z.object({ id: z.string(), name: z.string() }))
- .register((ctx) => {
- // 扁平 ctx.$params.id 被 body.id 覆盖("body-id"),不推荐
- const pathId = ctx.$pathParams.id; // 42 —— 保留路径来源
- const bodyId = ctx.$body.id; // "body-id" —— 保留请求体来源
- const name = ctx.$body.name;
- ctx.reply.json({ pathId, bodyId, name }); // 响应统一走 ctx.reply
- });
-```
-
-若仅需聚合读取、无同名冲突,`$params`(扁平)与 `$validated`(分层)均可。
-
-## ESM 集成
-
-> **BREAKING(3.0)**:erest 3.0 起为 **ESM-only**,仅发布 ESM 产物。`require('erest')` 的 CommonJS
-> 工程需迁移为 ESM(`import`),或改用动态 `import()` 加载。这是 3.0 major 的破坏性变更。
-
-```typescript
-// ESM 工程(package.json "type": "module",或 .mjs 文件):直接 import
-import ERest, { z } from 'erest';
-const api = new ERest({ groups: { user: '用户管理' } });
-```
-
-```javascript
-// CommonJS 工程:用动态 import() 加载(顶层 require 不再支持)
-const { default: ERest } = await import('erest');
-const api = new ERest({ groups: { user: '用户管理' } });
-```
-
-erest 3.0 仅发布一套 ESM 产物(`dist/lib`)。在 `module: nodenext` + `verbatimModuleSyntax` 的
-最严格 ESM 工程下,`import ERest from 'erest'` 既有正确运行时(可直接 `new`)又有正确类型推导。
-
-## 高级用法
-
-### 分组与中间件
-
-```typescript
-const api = new ERest({
- forceGroup: true,
- groups: {
- admin: { name: '管理后台', prefix: '/admin' },
- },
-});
-
-// 为分组添加统一的 before 钩子和中间件(如鉴权)
-api.group('admin').before(authMiddleware).middleware(logMiddleware);
-
-api.group('admin').get('/dashboard').register((ctx) => {
- // 执行顺序:globalBefore -> authMiddleware(before) -> checker -> logMiddleware -> handler
- ctx.reply.json({ ok: true });
-});
-```
-
-### 错误处理
-
-ERest 提供统一的 `ERestError` 错误类与可自定义的错误工厂函数:
-
-```typescript
-import { ERestError } from 'erest';
-
-const api = new ERest({
- missingParameterError: (msg) => new ERestError('MISSING_PARAM', `缺少参数: ${msg}`),
- invalidParameterError: (msg) => new ERestError('INVALID_PARAM', `参数无效: ${msg}`),
-});
-
-throw ERestError.missingParam('username');
-throw ERestError.invalidParam('age', 'Integer', 'abc');
-```
-
-### 可用适配器
-
-三框架适配器作为独立子包提供,自定义适配器实现 `FrameworkAdapter` 接口(从 `erest` 导入)即可:
-
-| 子包 | 适配器类 | 适用框架 |
-|------|----------|---------|
-| `@erest/express` | `ExpressAdapter` | Express 4 |
-| `@erest/koa` | `KoaAdapter` | Koa 3 |
-| `@erest/leizmweb` | `LeizmWebAdapter` | @leizm/web 2 |
-
-#### 已废弃方法
-
-以下方法已废弃,请使用 `bind({ adapter, ... })` 替代:
-
-- `bindRouter(router, checker)` → `bind({ adapter: new ExpressAdapter(), router })`
-- `bindRouterToApp(app, Router, checker)` → `bind({ adapter: new ExpressAdapter(), app, router: Router })`
-- `bindKoaRouterToApp(app, KoaRouter, checker)` → `bind({ adapter: new KoaAdapter(), app, router: KoaRouter })`
-- `checkerExpress` / `checkerKoa` / `checkerLeiWeb` → 内置于各适配器中
-
-## 示例
-
-`examples/` 是一个**迷你博客业务域**的完整最佳实践样板,串联 erest 全部核心能力:
-**一份 API 定义(`src/api.js`),三个框架入口**(`src/entries/`)。handler 用 `registerTyped`
-的 `(req, ctx)` 签名声明一次,被 @leizm/web / Express / Koa 复用,仅 `bind()` 参数不同。
-
-examples 作为 pnpm workspace 子包,通过 `erest: workspace:*` 引用本地 erest,安装时自动 link。
-
-| 命令 | 说明 |
-|------|------|
-| `pnpm --filter erest-example start:{leizmweb,express,koa}` | 三框架入口(监听 3100) |
-| `pnpm --filter erest-example test` | vitest 测试套件(initTest + success/error/takeExample) |
-| `pnpm --filter erest-example docs` | 生成 swagger / postman / markdown / axios SDK |
-
-覆盖能力:forceGroup 分组、组级 before/middleware 钩子(鉴权/日志)、全局 beforeHooks、
-自定义错误注册、自定义 type/schema 注册、`define()` 声明式、`mock()`、`response()` schema、
-分层参数、文档生成、测试集成。详见 `examples/README.md`。
-
-## 开发
+启动并请求:
```bash
-pnpm install
-pnpm run test:lib # 直接跑源码测试(ISLIB=1,无需先 build)
-pnpm run test:cov # 覆盖率报告
-pnpm run mutation # 变异测试(Stryker + vitest),兼容输入才复用;首次全量约 20-40 分钟
-pnpm run mutation:incremental # 保留的增量命令,与 mutation 相同
-pnpm run mutation:full # 忽略历史结果,强制全量重建基线
+node app.mjs
+curl -X POST http://localhost:3000/users \
+ -H 'Content-Type: application/json' -d '{"name":"Tom"}'
```
-变异测试基于 [StrykerJS](https://stryker-mutator.io/)(配置见 `stryker.config.json`,变异范围为
-`src/lib/**`),HTML 报告输出到 `reports/mutation/mutation.html`。变异分数衡量「测试集能否杀死
-被注入的缺陷」,用于发现覆盖漏网与断言不足;分数参考即可,不必追求 100%。
-
-变异测试需要 Node.js >=22.20(推荐 Node 24),本地和 CI 都通过同一兼容性 guard。
-本地基线为 `reports/stryker-incremental.json`,必须配合对应元数据;缺少元数据或报告被修改时
-自动重建。源码、测试、依赖、锁文件、配置、辅助文件和运行时变化均保守全量运行。
-Stryker 原生增量检测不能保证任意源码修改后的旧结果都安全,因此不依赖它跨语义变更复用。
-
-GitHub Actions 的 `Mutation Testing` 工作流使用 Node.js 24 和 pnpm 9。面向 `main` 的普通 PR
-只读兼容基线,只有 `main` 成功运行才保存报告与元数据。缓存使用 `v3` 命名空间,旧缓存不迁入;
-首次运行、缓存丢失或输入变化时全量执行当前配置范围。主分支运行不会被后续提交主动取消。
-仅经审核的精确文档路径可以排除;依赖声明与所有未审核输入保持入键。
-根包 `package.json` 的 version 经审核不被当前 ISLIB 源码测试读取,因此仅规范化这一字段;
-指向该根 manifest 的已安装 workspace 链接同样规范化,其他依赖字段和子包版本仍进入摘要。
-策略见 `.github/mutation-cache-inputs.json`;用
-`node .github/scripts/baseline-inputs.mjs .github/mutation-cache-inputs.json --list` 检查清单。
-常规入口仅接受 `--force`;局部实验需通过 `run-mutation.mjs` 使用独立配置与报告。
+响应为 `{"name":"Tom"}`;空字符串会触发参数校验错误。文档与 SDK 写入
+`docs/swagger.json`、`docs/jssdk.js`,同时生成默认开启的 Markdown 文档。
-需要强制重建时运行 `pnpm run mutation:full`,或手动触发工作流并勾选 `full`。
-外部服务或未纳入配置的环境状态变化后也应强制重跑。成功或失败时的可用报告均上传为
-`mutation-reports-*` artifact,保留 14 天。当前未设置最低变异分数门禁,也不会自动配置分支保护。
+`registerTyped` 的 handler 为 `(req, ctx)`:`req.params/query/body/headers` 是校验后的
+参数,`ctx.reply` 用于响应。使用适配器的 `createERest()` 可获得原生上下文的类型推导。
-## 自动发布
+## 使用指南
-推送与五个包版本一致、且指向 `main` 提交的 `vX.Y.Z` tag 后,GitHub Actions 会检查、构建、
-打包并通过 npm Trusted Publishing(OIDC)发布,全部 npm 包成功后创建 GitHub Release。
-正式版走 `latest`,预发布走 `next`;普通 PR 不发布,不需要 `NPM_TOKEN`。
-维护者需先为每个 npm 包绑定 `yourtion/node-erest` 的 `release.yml`,environment 留空。
-一次性配置、发版命令、产物校验和失败重跑见 [RELEASING.md](RELEASING.md)。
+| 需要做什么 | 文档 |
+| --- | --- |
+| 定义 schema、handler、响应、分组与中间件 | [API 定义](guides/api.md) |
+| 接入三个框架、配置路由前缀和 TypeScript | [框架与 ESM](guides/adapters.md) |
+| 生成 Swagger、其他文档和 Axios SDK | [文档与 SDK](guides/documentation.md) |
+| 测试业务 API、保存真实示例 | [API 测试](guides/testing.md) |
+| 运行完整博客示例 | [示例工程](examples/README.md) |
+| 生成 handler 骨架 | [@erest/gen](packages/erest-gen/README.md) |
+| 升级已有项目 | [迁移指南](MIGRATION.md) |
-## API 文档
+完整类型与方法参考:[API Reference](https://yourtion.github.io/node-erest/)。
-线上文档站(typedoc 产物)由 CI 自动构建部署:
+## 参与开发
-- 在线:
-- 本地预览:`pnpm install && pnpm run doc`,产物在 `docs/`(已 gitignore,不进版本库)
+环境准备、检查命令和维护流程见 [贡献指南](CONTRIBUTING.md)。
## License
diff --git a/RELEASING.md b/RELEASING.md
index 4328379..fb44739 100644
--- a/RELEASING.md
+++ b/RELEASING.md
@@ -1,5 +1,7 @@
# npm 自动发布
+[返回首页](README.md) · [贡献指南](CONTRIBUTING.md)
+
仓库通过 GitHub 托管 runner 的 `.github/workflows/release.yml` 使用 npm Trusted Publishing(OIDC)。
不需要 `NPM_TOKEN`,也不在 GitHub 保存 npm 长期凭据。workflow 使用 Node 24,并检查实际 npm
版本至少为 11.5.1(OIDC 要求 Node 至少 22.14.0)。公开仓库的公开包通过 OIDC 发布时,npm 自动生成 provenance。
@@ -35,10 +37,11 @@ workflow 名称填写文件名 `release.yml`,不要填写显示名称 `Release
git switch main
git pull --ff-only
pnpm run tag
- git push origin v3.2.3
+ RELEASE_VERSION="$(node --print "require('./package.json').version")"
+ git push origin "v${RELEASE_VERSION}"
```
- 示例用于 `3.2.3`;以后将推送的 tag 替换为五个 manifest 中的实际版本。`pnpm run tag`
+ `RELEASE_VERSION` 读取当前 manifest 版本;推送前核对五个包的版本一致。`pnpm run tag`
只创建本地 tag,不执行 npm 发布。也可用 `git tag v` 创建 tag。
3. 在 Actions → Release 查看结果。只有推送 tag 触发发布,普通 PR/main push 不发布。
diff --git a/examples/README.md b/examples/README.md
index fddfe3f..9073bbd 100644
--- a/examples/README.md
+++ b/examples/README.md
@@ -1,154 +1,78 @@
-# erest 端到端示例
-
-一个**迷你博客业务域**,串联 erest 的**全部核心能力**:一份 API 定义,三个框架入口。
-
-## 演示的 erest 能力
-
-| 能力 | 位置 | 说明 |
-|------|------|------|
-| `registerTyped` + `reply` | `src/api.js` | 框架无关 handler,类型自动推导,三入口复用 |
-| `forceGroup` + 分组前缀 | `src/api.js` | public / post / admin 三组,各有前缀 |
-| 组级 `before()` 钩子 | `src/hooks.js` | post/admin 组的鉴权(拒绝未授权请求),框架无关 |
-| 组级 `middleware()` | `src/hooks.js` | admin 组的请求日志,框架无关 |
-| 全局 `beforeHooks` | `src/hooks.js` | 计时 hook,框架无关 |
-| 自定义错误注册 `errors.register` | `src/errors.js` | 注册 PERMISSION_DENIED / NOT_FOUND(与运行时 code 对应) |
-| `ERestError` 工厂 | `src/errors.js` | `ERestError` 静态构造(带 code/statusCode) |
-| 自定义 type 注册 | `src/types.js` | 注册 `Slug` 类型(`z.string().regex(...)`) |
-| 自定义 schema 注册 | `src/types.js` | 注册 `CreatePost` / `UpdatePost` |
-| `define()` 声明式定义 | `src/api.js` | 声明式注册 `DELETE /admin/users/:id`(handler 框架无关,三入口复用) |
-| `requiredOneOf` 多选一必填 | `src/api.js` | `GET /posts` 的 slug/status 至少传一个 |
-| `mock()` | `docs/generate.js` | 无 handler 时由 setMockHandler 生成 mock 响应 |
-| `response()` schema | `src/api.js` | 用户列表 API 声明响应 schema |
-| 分层参数 | `src/api.js` | `PUT /posts/:id` 的 path id 与 body 互不覆盖 |
-| 文档生成 `genDocs` | `docs/generate.js` | swagger / postman / markdown / axios SDK |
-| 测试驱动文档 `takeExample` + `genDocs` | `docs/generate-from-test.js` | test-agent 跑真实请求 → 文档 ✅ + 真实示例 |
-| 测试集成 `initTest` + `api.test` | `test/api.test.js` | vitest 写 success / error / takeExample |
-
-## 目录结构
+# 博客示例
-```
-examples/
-├── src/
-│ ├── api.js # 核心:forceGroup 三组 + 所有业务 API(handler 框架无关)
-│ ├── store.js # 内存存储(用户 + 文章 + token 鉴权)
-│ ├── hooks.js # 鉴权/日志/计时钩子(框架无关,v3 标准化签名)
-│ ├── errors.js # 自定义错误注册 + ERestError 工厂
-│ ├── types.js # 自定义 Slug type + PostSchema 注册
-│ └── entries/
-│ ├── leizmweb.js # @leizm/web 入口
-│ ├── express.js # Express 入口
-│ └── koa.js # Koa 入口
-├── docs/
-│ ├── generate.js # 文档生成脚本(npm run docs,mock 模式)
-│ └── generate-from-test.js # 测试驱动文档(npm run docs:test,✅ + 真实示例)
-├── test/
-│ └── api.test.js # vitest 测试套件(npm test)
-├── types/
-│ └── erest.d.ts # NodeNext 模块增强声明(可选,仅 TS 工程)
-└── vitest.config.js
-```
-
-## 三组业务 API
+[返回首页](../README.md) · [框架接入](../guides/adapters.md) · [API 测试](../guides/testing.md)
-| 组 | 前缀 | 鉴权 | 端点 |
-|----|------|------|------|
-| `public` | `/public` | 无 | `GET /public/posts`、`GET /public/posts/:slug` |
-| `post` | `/posts` | 需登录 | `GET/POST/PUT/DELETE /posts/posts[/:id]` |
-| `admin` | `/admin` | 需管理员 | `GET /admin/stats`、`GET /admin/users`、`DELETE /admin/users/:id`(define) |
-
-鉴权用 `X-Admin-Token` header:`user-token`(普通用户)、`admin-token`(管理员)。
+一份迷你博客 API 定义,分别接入 Express、Koa、LeizmWeb。数据存储在内存中,重启后重置。
## 运行
-examples 是 pnpm workspace 子包,在仓库根目录安装依赖即可(自动 link 本地 erest):
+在仓库根目录安装并构建核心包和适配器,再选择一个入口启动(端口均为 3100):
```bash
-# 在仓库根目录
-pnpm install
-pnpm run build # 构建 erest 产物(examples 通过 workspace 引用 dist/)
-
-# 任选一个框架启动(都监听 3100)
-pnpm --filter erest-example start:express # 或 start:leizmweb / start:koa
+pnpm install --frozen-lockfile
+pnpm run build
+pnpm run build:packages
+pnpm --filter erest-example start:express
+# 也可使用 start:koa 或 start:leizmweb
```
-### 示例请求(三框架行为一致)
+| 组 | 完整路径 | 鉴权 |
+| --- | --- | --- |
+| public | `GET /public/posts`、`GET /public/posts/:slug` | 无 |
+| post | `GET/POST /posts/posts`、`PUT/DELETE /posts/posts/:id` | 登录 |
+| admin | `GET /admin/stats`、`GET /admin/users`、`DELETE /admin/users/:id` | 管理员 |
-```bash
-# 公开:已发布文章
-curl http://localhost:3100/public/posts
-curl http://localhost:3100/public/posts/hello-erest
-
-# 需登录(header X-Admin-Token: user-token)
-curl -H "X-Admin-Token: user-token" http://localhost:3100/posts/posts
-curl -X POST -H "X-Admin-Token: user-token" -H "Content-Type: application/json" \
- -d '{"slug":"new-post","title":"新文章","content":"内容"}' http://localhost:3100/posts/posts
-
-# 需管理员(header X-Admin-Token: admin-token)
-curl -H "X-Admin-Token: admin-token" http://localhost:3100/admin/users
-curl -H "X-Admin-Token: admin-token" http://localhost:3100/admin/stats
-
-# 鉴权失败示例
-curl -i http://localhost:3100/posts/posts # 401 无 token
-curl -i -H "X-Admin-Token: user-token" http://localhost:3100/admin/users # 403 权限不足
-# 校验失败示例
-curl -i -X POST -H "X-Admin-Token: user-token" -H "Content-Type: application/json" \
- -d '{"slug":"Bad Slug","title":"x","content":"y"}' http://localhost:3100/posts/posts # 400
-```
-
-## 测试
+通过 `X-Admin-Token` 传入 `user-token`(普通用户)或 `admin-token`(管理员):
```bash
-pnpm --filter erest-example test # vitest 跑 initTest 套件:success / error / takeExample
+curl http://localhost:3100/public/posts
+curl -H 'X-Admin-Token: user-token' 'http://localhost:3100/posts/posts?status=published'
+curl -X POST http://localhost:3100/posts/posts \
+ -H 'X-Admin-Token: user-token' -H 'Content-Type: application/json' \
+ -d '{"slug":"new-post","title":"新文章","content":"内容"}'
+curl -H 'X-Admin-Token: admin-token' http://localhost:3100/admin/stats
```
-测试用 Express 作为载体(`initTest` 对三框架均支持,这里选最通用的)。
-覆盖:公开接口、鉴权(401/403)、参数校验(400)、define 路由、测试结果回填文档示例。
+缺少 token 返回 401,普通用户访问管理接口返回 403,非法字段返回 400。
-## 文档生成
+## 代码入口
-```bash
-pnpm --filter erest-example docs # 生成到 docs/out/
-```
+| 文件 | 内容 |
+| --- | --- |
+| [src/api.js](src/api.js) | 分组、registerTyped、分层参数、response、requiredOneOf 与 define |
+| [src/entries/](src/entries/) | 三个框架的解析器、适配器绑定与错误中间件 |
+| [src/hooks.js](src/hooks.js) | before 鉴权、middleware 日志、全局计时 |
+| [src/types.js](src/types.js) / [src/errors.js](src/errors.js) | Zod 类型/schema 与业务错误注册 |
+| [src/store.js](src/store.js) | 内存数据和 token |
+| [test/api.test.js](test/api.test.js) | 使用 TestAgent 验证业务 API |
-产出:
-- `swagger.json` — OpenAPI,可导入 Swagger UI / Apifox
-- `postman.json` — Postman Collection
-- `Home.md` + 各组 `.md` + `errors.md` / `schema.md` / `types.md` — Markdown 文档
-- `sdk.js` — 基于 axios 的前端 SDK
+handler 和 before/middleware 使用 erest 标准上下文,在三个入口间共享。
+`registerTyped` 使用 `(req, ctx)`,`register` / `define` 使用 `(ctx, next)`,均通过 `ctx.reply` 响应。
+TypeScript 与 CommonJS 集成见[框架与 ESM](../guides/adapters.md#typescript-与-esm)。
-> 上面的 `npm run docs` 不跑测试,markdown 里所有 API 显示 ❌、无示例(mock 模式)。
+## 测试
-### 测试驱动文档(✅ + 真实示例)
+构建完成后运行:
```bash
-pnpm --filter erest-example docs:test # 生成到 docs/out-from-test/
+pnpm --filter erest-example test
```
-用 test-agent 跑真实请求(`.success().takeExample()`),让文档通过测试变绿:
+测试使用 Express,覆盖公开接口、鉴权、校验、define 和真实示例记录。
-- 被测路由显示 **✅**(`tested` 标记翻转),未测的保持 ❌
-- 「使用示例」段落的 `output` 是 **真实响应**(非 mock)
-- 详见 [docs/generate-from-test.js](./docs/generate-from-test.js),根 README 的「测试驱动的文档」章节有原理说明
-
-## 设计说明:handler 与 hooks 均框架无关
-
-erest v3 标准化后,**业务 handler 与 before/middleware 钩子都框架无关**,同一份代码被
-Express / Koa / @leizm/web 三个入口复用:
+## 文档生成
-- **业务 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.ctx.reply.json()` 写响应。虽然 define 路由仍各自在入口内注册(演示该 API),但其 handler
- 也是框架无关的。
+```bash
+pnpm --filter erest-example docs # examples/docs/out/
+pnpm --filter erest-example docs:test # examples/docs/out-from-test/
+```
-这是 v3 标准化的核心收益:整个请求处理链(global before → group before → api before →
-checker → group middleware → api middleware → handler)全部框架无关,框架适配完全下沉到
-adapter 层。
+[docs/generate.js](docs/generate.js) 直接生成 `swagger.json`(Swagger 2.0)、`postman.json`、
+`sdk.js` 和 Markdown 分组页面。
+未执行请求的路由显示未测试标记,无真实请求示例。
-## TypeScript 集成(NodeNext)
+[docs/generate-from-test.js](docs/generate-from-test.js) 启动临时测试服务,发出真实请求,
+通过 `.takeExample(name).success()` 记录请求与响应,再生成 Markdown。
+执行过的路由显示已测试标记;该标记不替代业务断言。
-erest 3.0 起为 **ESM-only**(仅发布 ESM 产物,无 CJS)。在 `module: nodenext` +
-`verbatimModuleSyntax` 下,`import ERest from 'erest'` 直接可用(构造器与类型均正确),
-无需任何 workaround。CommonJS 工程请用动态 `import()` 加载(详见根 README 的 ESM 集成章节)。
+格式选项见[文档与 SDK](../guides/documentation.md),示例记录见[API 测试](../guides/testing.md#保存真实示例)。
diff --git a/guides/adapters.md b/guides/adapters.md
new file mode 100644
index 0000000..6697fe6
--- /dev/null
+++ b/guides/adapters.md
@@ -0,0 +1,152 @@
+# 框架与 ESM
+
+[返回首页](../README.md) · [API 定义](api.md)
+
+## 适配器
+
+| 包 | 工厂与适配器 | 框架 |
+| --- | --- | --- |
+| `@erest/express` | `createERest` / `ExpressAdapter` | Express 4、5 |
+| `@erest/koa` | `createERest` / `KoaAdapter` | Koa 3,`@koa/router` 13 或 `koa-router` 13 |
+| `@erest/leizmweb` | `createERest` / `LeizmWebAdapter` | @leizm/web 2 |
+
+每个工厂创建的实例都使用相同的 [API 定义方式](api.md),并推导该框架的 `ctx.reply.raw` 类型。
+以下绑定片段假定 `api` 已创建并注册路由,通用错误格式器从 `erest` 导入。
+
+## Express
+
+```javascript
+import express from 'express';
+import { ExpressAdapter } from '@erest/express';
+import { defaultErrorFormatter } from 'erest';
+
+const app = express();
+app.use(express.json());
+api.bind({ adapter: new ExpressAdapter(), router: app });
+app.use((err, _req, res, _next) => {
+ const { status, body } = defaultErrorFormatter(err);
+ res.status(status).json(body);
+});
+app.listen(3000);
+```
+
+错误处理中间件放在 app 级、路由绑定之后。需要表单时,再添加
+`express.urlencoded({ extended: true })`。
+
+## Koa
+
+```javascript
+import Koa from 'koa';
+import Router from '@koa/router';
+import bodyParser from 'koa-bodyparser';
+import { KoaAdapter } from '@erest/koa';
+import { defaultErrorFormatter } from 'erest';
+
+const app = new Koa();
+app.use(async (ctx, next) => {
+ try { await next(); }
+ catch (err) {
+ const { status, body } = defaultErrorFormatter(err);
+ ctx.status = status;
+ ctx.body = body;
+ }
+});
+app.use(bodyParser());
+const router = new Router();
+api.bind({ adapter: new KoaAdapter(), router });
+app.use(router.routes()).use(router.allowedMethods());
+app.listen(3000);
+```
+
+也可使用 `koa-router` 13。框架路由库和 body parser 由应用安装,见首页[安装命令](../README.md#安装)。
+
+## @leizm/web
+
+```javascript
+import { Application, Router, component } from '@leizm/web';
+import { LeizmWebAdapter } from '@erest/leizmweb';
+import { defaultErrorFormatter } from 'erest';
+
+const app = new Application();
+app.use('/', component.bodyParser.json());
+const router = new Router();
+api.bind({ adapter: new LeizmWebAdapter(), router });
+app.use('/', router);
+app.use('/', (ctx, err) => {
+ if (!err) return ctx.next();
+ const { status, body } = defaultErrorFormatter(err);
+ ctx.response.status(status).json(body);
+});
+app.server.listen(3000);
+```
+
+需要表单时,添加 `component.bodyParser.urlencoded({ extended: true })`。
+
+## 路由前缀与分组
+
+`info.basePath` 用于文档和 SDK URL,不会替应用挂载路由。
+例如配置 `basePath: '/api'` 后,Express 仍需通过 `app.use('/api', router)` 挂载相应 router。
+
+| 模式 | 注册 | 绑定 |
+| --- | --- | --- |
+| 默认 | `api.api.get('/users').group('users')` | `bind({ adapter, router })` |
+| `forceGroup: true` | `api.group('users').get('/list')` | `bind({ adapter, app, router: RouterCtor })` |
+
+分组模式在创建实例时声明:
+
+```typescript
+const api = createERest({
+ forceGroup: true,
+ groups: {
+ users: { name: '用户', prefix: '/users' },
+ admin: { name: '管理' },
+ },
+});
+// users 分组使用 /users;admin 默认使用 /admin
+api.bind({ adapter: new ExpressAdapter(), app, router: express.Router });
+```
+
+Koa 对应 `router: Router`,LeizmWeb 对应其 `Router` 构造函数;它们使用各自的适配器。
+
+## 原生上下文
+
+| 能力 | Express `reply.raw` | Koa `reply.raw` | LeizmWeb `reply.raw` |
+| --- | --- | --- | --- |
+| 原生对象 | `{ req, res }` | Koa Context | LeizmWeb Context |
+| Cookie | `.res.cookie(...)` | `.cookies.set(...)` | `.response.setHeader('Set-Cookie', ...)` |
+| 重定向 | `.res.redirect(...)` | `.redirect(...)` | `.response.redirect(...)` |
+| 响应头 | `.res.setHeader(...)` | `.set(...)` | `.response.setHeader(...)` |
+
+使用 raw 的代码遵循宿主框架的语义,不再通用于三个框架。响应头应在发送响应体之前设置。
+return 模式下手动发送响应,记得调用 [markSent()](api.md#原生响应)。
+
+自定义适配器实现 `erest` 导出的 `FrameworkAdapter` 接口;实现约定见 [架构导航](../AGENTS.md)。
+
+## TypeScript 与 ESM
+
+erest 仅发布 ESM。使用 `package.json` 的 `"type": "module"` 或 `.mjs` 文件;
+TypeScript 工程安装 `typescript`、`@types/node` 及宿主框架所需的类型包。
+
+```json
+{
+ "compilerOptions": {
+ "module": "NodeNext",
+ "moduleResolution": "NodeNext",
+ "target": "es2022",
+ "strict": true,
+ "esModuleInterop": true,
+ "types": ["node"]
+ }
+}
+```
+
+CommonJS 调用方可在异步函数中动态加载:
+
+```javascript
+async function main() {
+ const { default: ERest } = await import('erest');
+ const api = new ERest({ groups: { users: '用户' } });
+ // 注册路由并绑定框架
+}
+main();
+```
diff --git a/guides/api.md b/guides/api.md
new file mode 100644
index 0000000..0abc56b
--- /dev/null
+++ b/guides/api.md
@@ -0,0 +1,160 @@
+# API 定义
+
+[返回首页](../README.md) · [框架接入](adapters.md) · [文档与 SDK](documentation.md)
+
+以下片段中的 `api` 是适配器 `createERest()` 创建的实例,`z` 从 `erest` 或宿主项目的 `zod` 导入。
+完整运行方式见[快速开始](../README.md#快速开始)。
+
+## Schema 与参数来源
+
+`registerTyped` 根据 Zod schema 推导 `req.params/query/body/headers`,在 handler 执行前校验参数。
+请求体的 JSON 解析由宿主框架中间件完成,见[框架接入](adapters.md)。
+
+```typescript
+api.api.put('/users/:id').group('users').title('更新用户').registerTyped(
+ {
+ params: z.object({ id: z.string() }),
+ query: z.object({ revision: z.coerce.number().int().optional() }),
+ body: z.object({ name: z.string().min(1), age: z.number().int().min(18) }),
+ },
+ (req, ctx) => {
+ ctx.reply.json({ id: req.params.id, ...req.body, revision: req.query.revision });
+ },
+);
+```
+
+- `.optional()` 表示可选,`.default(value)` 提供默认值。
+- query 和路径中的值通常是字符串;数字使用 `z.coerce.number()` 等显式转换。
+- DELETE 的输入建议声明为 `query`,与测试工具和 SDK 的默认约定一致。
+- 相同字段名可出现在不同来源,使用 `req.params.id`、`req.body.id` 分别读取。
+
+动态对象可用 `z.anyObject()`,等价于 `z.object({}).catchall(z.unknown())`;这是
+`erest` 导出的 `z` 上的扩展,也可具名导入 `zAnyObject`。
+
+## Handler 与响应
+
+| 注册方式 | Handler 签名 | 用途 |
+| --- | --- | --- |
+| `registerTyped(schemas, handler)` | `(req, ctx)` | 按 schema 推导分层参数类型 |
+| `register(handler)` | `(ctx, next)` | 手动读取参数,或控制中间件链 |
+| `define({ handler, ... })` | `(ctx, next)` | 声明式注册完整路由配置 |
+
+这些 handler 接收 erest 的标准上下文。通用响应方法为
+`ctx.reply.json(data)`、`ctx.reply.status(code)`、`ctx.reply.send(text)`。
+
+### Return 模式与响应校验
+
+需要让 handler 直接返回数据时,在绑定路由前设置成功和错误响应包装器。原样返回可使用:
+
+```typescript
+api.setResponseEnvelopers({
+ success: (data) => data,
+ error: (err) => defaultErrorFormatter(err as Error),
+});
+
+api.api.get('/users/:id').group('users').title('用户详情').registerTyped(
+ {
+ params: z.object({ id: z.string() }),
+ response: z.object({ id: z.string(), name: z.string() }),
+ },
+ (req) => ({ id: req.params.id, name: 'Tom' }),
+);
+```
+
+`registerTyped({ response }, handler)` 声明响应后,非 `undefined` 的 handler 返回值会经
+`response.parse()` 校验,再交给
+成功包装器。运行时不会用 parse 的返回值替换原数据;响应 schema 的 transform/default 不会
+自动改写返回值。手动调用 `ctx.reply.json()` 的内容不经过这项返回值校验。
+
+业务可定义自己的信封:
+
+```typescript
+api.setResponseEnvelopers({
+ success: (data) => ({ success: true, data }),
+ error: (err) => {
+ const { status, body } = defaultErrorFormatter(err as Error);
+ return { status, body: { success: false, error: body } };
+ },
+});
+```
+
+`defaultErrorFormatter` 从 `erest` 导入。未启用响应包装时,异常交给宿主框架的错误中间件。
+测试信封的解析见 [API 测试](testing.md#响应与错误),生成文档的响应边界见
+[文档与 SDK](documentation.md#表达范围)。
+
+### 原生响应
+
+`ctx.reply.raw` 可访问 cookie、重定向、流等宿主能力,具体形态见[原生上下文](adapters.md#原生上下文)。
+在 return 模式下,如果经 raw 手动写完响应,需要调用 `ctx.reply.markSent()`,跳过自动包装。
+
+```typescript
+// Express;api 由 @erest/express 的 createERest() 创建
+api.api.get('/export').group('users').title('导出').registerTyped({}, (_req, ctx) => {
+ ctx.reply.raw.res.setHeader('Content-Type', 'text/csv');
+ ctx.reply.raw.res.end('id,name\n1,Tom');
+ ctx.reply.markSent();
+});
+```
+
+## 注册与复用 Schema
+
+```typescript
+const User = z.object({ id: z.string(), name: z.string() });
+api.schema.register('User', User);
+api.type.register('UserName', z.string().min(1));
+
+api.api.get('/current-user').group('users').title('当前用户')
+ .response('User')
+ .registerTyped({}, (_req, ctx) => ctx.reply.json({ id: '1', name: 'Tom' }));
+```
+
+`response()` 接受命名 schema 或 Zod schema;注册表也用于生成文档组件。
+`requiredOneOf(['email', 'phone'])` 是多选一必填的便利方法;字段本身应声明为 optional。
+自定义规则可直接写在 Zod schema 中,其文档映射边界见[表达范围](documentation.md#表达范围)。
+
+## 分组与中间件
+
+启用 `forceGroup: true` 后,通过 `api.group(name)` 注册路由;分组前缀见[框架接入](adapters.md#路由前缀与分组)。
+
+```typescript
+api.group('admin').before(async (ctx, next) => {
+ if (ctx.headers.authorization !== 'demo-token') {
+ throw new ERestError('PERMISSION_DENIED', '需要登录', undefined, 401);
+ }
+ await next();
+});
+```
+
+`ERestError` 从 `erest` 导入。执行顺序为:全局 before → 分组 before → 路由 before →
+参数校验 → 分组 middleware → 路由 middleware → handler。`before` 适合鉴权,
+`middleware` 可读取校验后的参数;二者都通过 `await next()` 继续执行。
+
+## State 与生命周期观察
+
+使用适配器工厂的泛型声明共享 state:
+
+```typescript
+import { createERest } from '@erest/express';
+
+type State = { userId?: string };
+const api = createERest({ groups: { users: '用户' } });
+api.beforeHooks(async (ctx, next) => {
+ ctx.state.userId = 'demo-user';
+ await next();
+});
+```
+
+构造选项 `hooks` 提供 `onRequest`、`onValidate`、`onError`、`onResponse`。
+这些是同步观察回调,异常会被忽略,适合日志与计时;鉴权等控制流使用 before/middleware。
+
+## Register 的参数访问
+
+手动 `register()` 时,校验结果在标准上下文上:
+
+| 访问器 | 内容 |
+| --- | --- |
+| `ctx.$validated` | 分层的 `{ params, query, body, headers }` |
+| `ctx.$pathParams` / `$query` / `$body` / `$headers` | 单个来源 |
+| `ctx.$params` | 按 params → query → body → headers 合并的扁平对象,同名字段会覆盖 |
+
+`registerTyped` 已提供类型化的 `req`,通常无需再使用这些访问器。
diff --git a/guides/documentation.md b/guides/documentation.md
new file mode 100644
index 0000000..4799f12
--- /dev/null
+++ b/guides/documentation.md
@@ -0,0 +1,75 @@
+# 文档与 SDK
+
+[返回首页](../README.md) · [API 定义](api.md) · [API 测试](testing.md)
+
+## 生成文件
+
+在创建实例时选择格式,注册 API 后保存:
+
+```typescript
+import { mkdirSync } from 'node:fs';
+import { createERest } from '@erest/express';
+
+const api = createERest({
+ info: { title: 'My API', version: '1.0.0', host: 'https://example.com', basePath: '/api' },
+ groups: { users: '用户' },
+ docs: { swagger: true, axios: true, index: true },
+});
+// 在此注册 API
+mkdirSync('./docs', { recursive: true });
+api.genDocs('./docs', false);
+```
+
+第二个参数 `false` 表示立即写入;省略时默认在进程退出时写入。
+生成器不创建输出目录。`info.basePath` 需与应用实际挂载路径一致,见[路由前缀](adapters.md#路由前缀与分组)。
+
+| 选项 | 默认值 | 输出 |
+| --- | --- | --- |
+| `swagger` | `false` | `swagger.json`,Swagger 2.0 |
+| `axios` | `false` | `jssdk.js`,Axios ESM SDK |
+| `postman` | `false` | `postman.json`,Postman Collection |
+| `json` | `false` | `doc.json`,文档数据快照 |
+| `markdown` | `true` | Markdown 插件总开关 |
+| `wiki` | `'./'` | `Home.md`、分组文档及 schema/type/error 页面 |
+| `index` | `false` | `index.md` |
+| `all` | `false` | Markdown 汇总及完整 JSON 数据 |
+
+`swagger`、`axios`、`postman`、`json` 可指定字符串文件名。
+要关闭 Markdown,设置 `markdown: false`。
+Markdown 索引中的 Swagger、SDK、Postman 链接使用默认文件名;使用自定义文件名时需核对链接。
+
+生成内存中的文档可调用 `api.buildSwagger()`。
+按需添加自定义生成器可用 `api.addDocPlugin(name, plugin)`;先调用一次 `buildSwagger()` 或
+`genDocs()` 初始化文档协调器,再注册插件。插件接收文档数据、输出目录、选项和 writer。
+
+## Axios SDK
+
+在客户端安装 Axios,再导入生成的 ESM 文件:
+
+```javascript
+import sdk from './docs/jssdk.js';
+
+sdk.instance.defaults.baseURL = 'https://example.com/api';
+await sdk.postUsers({ name: 'Tom' });
+await sdk.getUsersId('42');
+```
+
+上例分别对应 `/users`、`/users/:id`,需先注册对应路由及 schema。
+命名规则为 HTTP 方法加路径驼峰形式;路径参数须声明 params schema,并按位置传入。
+GET 的主输入取自 query,POST/PUT/PATCH 的主输入取自 body。
+需要额外请求头等设置可通过 `sdk.instance.defaults` 或 Axios 拦截器配置。
+
+当前生成器对 DELETE 输入和写请求的额外 query 支持有限,建议用 Axios 显式设置 `params`,
+并检查实际请求。数组序列化需与宿主 query parser 配合;数字 query 应显式使用 `z.coerce.number()`。
+动态字段等复杂 schema 的参数也需自行适配,不应假设生成物能覆盖所有运行时用法。
+
+## 表达范围
+
+当前输出格式是 Swagger 2.0。生成器仅为 POST 自动描述请求体,复杂数组、union、nullable
+及 Zod 函数规则不能保证完整映射;使用 PUT/PATCH 请求体或复杂 schema 时,应核对并补充生成文档。
+
+文档中的响应 schema 描述声明的 `response`,使用 `200` 成功响应;应用自定义的其他状态码、
+错误响应及成功信封包装,不会自动合成为完整的线上响应 schema,可通过自定义插件扩展。
+
+记录真实请求与响应的方法见[API 测试](testing.md#保存真实示例),完整脚本见
+[示例工程](../examples/README.md#文档生成)。
diff --git a/guides/testing.md b/guides/testing.md
new file mode 100644
index 0000000..35ad803
--- /dev/null
+++ b/guides/testing.md
@@ -0,0 +1,81 @@
+# API 测试
+
+[返回首页](../README.md) · [API 定义](api.md) · [文档与 SDK](documentation.md)
+
+本文介绍应用如何使用 erest 的测试工具;仓库自身的质量检查见[贡献指南](../CONTRIBUTING.md)。
+
+## 初始化
+
+先注册 API 并绑定宿主框架,再调用 `api.initTest(target)`。可传入 Express app、Koa app/callback、
+LeizmWeb 的 `app.server` 或 Node HTTP Server;测试工具使用 fetch 发出真实请求。
+使用已监听的 server 时,测试结束由调用方关闭。
+
+以下 Vitest 片段假定 `app` 和 `api` 已装配完成,测试不再启动固定端口服务:
+
+```typescript
+import { createServer } from 'node:http';
+import { beforeAll, afterAll, expect, it } from 'vitest';
+
+const server = createServer(app); // Express;Koa 使用 app.callback()
+beforeAll(async () => {
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
+ api.initTest(server);
+});
+afterAll(() => new Promise((resolve) => server.close(() => resolve())));
+
+it('创建用户', async () => {
+ const result = await api.test.post('/users').input({ name: 'Tom' }).success();
+ expect(result).toEqual({ name: 'Tom' });
+});
+
+it('拒绝空名称', async () => {
+ const response = await api.test.post('/users').input({ name: '' }).raw();
+ expect(response.status).toBe(400);
+});
+```
+
+请求路径使用服务实际挂载的完整路径,包括应用和分组前缀。
+
+## 输入参数
+
+| 方法 | 说明 |
+| --- | --- |
+| `.input(data)` | GET/DELETE 走 query;POST/PUT/PATCH 走 JSON body |
+| `.query(data)` | 显式添加 query,也可与写请求的 input 配合 |
+| `.headers(data)` | 添加请求头 |
+| `.attach(data)` | multipart 字段或文件,用于 POST/PUT/PATCH |
+
+因此 DELETE 的确认字段应声明为 query,例如 `z.object({ confirm: z.literal('yes') })`,
+测试中使用 `.input({ confirm: 'yes' })`。query 的转换与数组边界见[SDK 说明](documentation.md#axios-sdk)。
+
+## 响应与错误
+
+`.success()` 默认返回解析后的 JSON;类型参数用于调用方标注,不会额外执行运行时校验。
+`.raw()` 返回 `{ status, headers, body, text }`,适合明确断言 HTTP 状态和响应头。
+
+业务信封可用 `api.setFormatOutput()` 转换为 `[Error | null, data]`;配置
+`setResponseEnvelopers` 时也可提供等价的 `testUnwrapper`。
+`.success()` 使用该解析结果识别业务错误;需要验证 HTTP 状态码时使用 `.raw()`。
+
+```typescript
+api.setFormatOutput((out) => {
+ const result = out as { success: boolean; data?: unknown; error?: string };
+ return result.success ? [null, result.data] : [new Error(result.error ?? '请求失败'), null];
+});
+```
+
+## 保存真实示例
+
+在发出请求前调用 `.takeExample(name)`:
+
+```typescript
+await api.test.post('/users')
+ .input({ name: 'Tom' })
+ .takeExample('创建用户')
+ .success();
+api.genDocs('./docs', false); // 先创建输出目录
+```
+
+读取响应会标记该路由已测试;`takeExample` 记录请求与响应,生成的 Markdown 会展示示例和测试标记。
+它们反映实际执行情况,不替代业务断言。可运行[示例工程的 docs:test](../examples/README.md#文档生成)
+查看生成结果。
diff --git a/packages/erest-gen/README.md b/packages/erest-gen/README.md
index a21fc9d..1655f27 100644
--- a/packages/erest-gen/README.md
+++ b/packages/erest-gen/README.md
@@ -1,69 +1,48 @@
# @erest/gen
-erest codegen CLI:从 Zod schema 生成 handler 骨架等。
+从 Zod schema 源码生成 `registerTyped` handler 骨架。当前只支持 `handler` 命令。
-> npm 包名为 `@erest/gen`,安装后通过 `erest-gen` 命令调用。
+## 安装与运行
-## 安装
+npm 包名是 `@erest/gen`,命令名是 `erest-gen`:
```bash
pnpm add -D @erest/gen
+pnpm exec erest-gen handler --from schemas/user.ts --group user
+pnpm exec erest-gen handler --from schemas/user.ts --group user --out handlers/user.ts
```
-## 用法
+省略 `--out` 时输出到 stdout;输出目录须预先创建。省略 `--group` 时分组为 `Index`。
-### handler 命令
+## 输入与结果
-从 Zod schema 文件生成 `registerTyped` handler 骨架。
-
-**约定**:schema 文件中以 `export const XxxSchema = z.object({...})` 形式导出的 schema 会被识别,每个生成一个 handler 骨架。
-
-```bash
-# 输出到 stdout
-npx erest-gen handler --from ./schemas/user.ts --group user
-
-# 输出到文件
-npx erest-gen handler --from ./schemas/user.ts --group user --out ./handlers/user.ts
-```
-
-**示例输入** `schemas/user.ts`:
+输入文件采用以下导出形式:
```typescript
-import { z } from "zod";
+import { z } from 'zod';
export const UserCreateSchema = z.object({
name: z.string(),
age: z.number().int(),
});
-
-export const UserUpdateSchema = z.object({
- name: z.string().optional(),
-});
```
-**生成输出**:
+每个识别到的 schema 会生成一个 POST handler,如 `UserCreateSchema` → `/user-create`:
```typescript
-import type ERest from "erest";
-import { UserCreateSchema } from "./schemas/user.js";
-import { UserUpdateSchema } from "./schemas/user.js";
-
-export function registerUserHandlers(api: ERest["api"]) {
- api
- .post("/user-create")
- .group("user")
- .registerTyped(
- { body: UserCreateSchema },
- (req, ctx) => {
- // TODO: 实现 user-create 处理逻辑
- return ctx.reply.json({ ok: true });
- }
- );
- // ...
-}
+api.post('/user-create').group('user').registerTyped(
+ { body: UserCreateSchema },
+ (req, ctx) => {
+ // req.body 的类型由 schema 推导
+ return ctx.reply.json({ ok: true });
+ },
+);
```
-## 后续计划
+生成物是待编辑的骨架:补充路由标题和业务逻辑,将 schema import 调整为相对输出文件的路径。
+例如输出到 `handlers/user.ts` 时,schema import 应指向 `../schemas/user.js`。
+
+识别使用源码正则,要求名称以大写字母开头、以 `Schema` 结尾,并直接赋值为 `z.object(...)`。
+它不会执行输入文件,也不解析重导出或任意 schema 工厂调用。
-- `test` 命令:为已注册 API 生成测试样板
-- `docs` 命令:从 ERest 实例导出 markdown
+完整 API 写法见[API 定义](../../guides/api.md),仓库开发流程见[贡献指南](../../CONTRIBUTING.md)。