From abba7f04a107db0696b6f80938fc97415d464a64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=AD=E5=AE=87=E7=BF=94?= Date: Thu, 22 Oct 2026 00:46:28 +0800 Subject: [PATCH] docs: reorganize README and project guides --- AGENTS.md | 10 +- CONTRIBUTING.md | 67 ++++ MIGRATION.md | 435 ++++---------------- README.md | 757 +++-------------------------------- RELEASING.md | 7 +- examples/README.md | 176 +++----- guides/adapters.md | 152 +++++++ guides/api.md | 160 ++++++++ guides/documentation.md | 75 ++++ guides/testing.md | 81 ++++ packages/erest-gen/README.md | 67 ++-- 11 files changed, 756 insertions(+), 1231 deletions(-) create mode 100644 CONTRIBUTING.md create mode 100644 guides/adapters.md create mode 100644 guides/api.md create mode 100644 guides/documentation.md create mode 100644 guides/testing.md 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 @@ -[![NPM version](https://img.shields.io/npm/v/erest.svg?style=flat-square)](https://npmjs.org/package/erest) -[![Test](https://github.com/yourtion/node-erest/actions/workflows/test.yml/badge.svg)](https://github.com/yourtion/node-erest/actions/workflows/test.yml) -[![codecov](https://codecov.io/github/yourtion/node-erest/graph/badge.svg?token=HRY0R63I95)](https://codecov.io/github/yourtion/node-erest) -[![node version](https://img.shields.io/badge/node.js-%3E=_20-green.svg?style=flat-square)](http://nodejs.org/download/) -[![npm download](https://img.shields.io/npm/dm/erest.svg?style=flat-square)](https://npmjs.org/package/erest) -[![npm license](https://img.shields.io/npm/l/erest.svg)](https://npmjs.org/package/erest) - # ERest -基于 TypeScript 的 API 框架,支持自动文档生成、类型安全校验与测试脚手架。兼容 Express、Koa、@leizm/web 等主流框架。 - -📖 **API 文档**:(master 推送后由 CI 自动构建部署) +[![npm](https://img.shields.io/npm/v/erest.svg)](https://npmjs.org/package/erest) +[![Test](https://github.com/yourtion/node-erest/actions/workflows/test.yml/badge.svg)](https://github.com/yourtion/node-erest/actions/workflows/test.yml) +[![codecov](https://codecov.io/github/yourtion/node-erest/graph/badge.svg)](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文档-<title>.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<T, Raw, State>` 的 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 自动构建部署: +## 参与开发 -- 在线:<https://yourtion.github.io/node-erest/> -- 本地预览:`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<version>` 创建 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<State>({ 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<void>((resolve) => server.listen(0, '127.0.0.1', resolve)); + api.initTest(server); +}); +afterAll(() => new Promise<void>((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<T>()` 默认返回解析后的 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)。