diff --git a/README.md b/README.md index ac5827c..2088436 100644 --- a/README.md +++ b/README.md @@ -402,6 +402,64 @@ it('应拒绝未成年用户', async () => { }); ``` +`.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 自动推导 diff --git a/examples/README.md b/examples/README.md index e11080b..fddfe3f 100644 --- a/examples/README.md +++ b/examples/README.md @@ -21,6 +21,7 @@ | `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 | ## 目录结构 @@ -38,7 +39,8 @@ examples/ │ ├── express.js # Express 入口 │ └── koa.js # Koa 入口 ├── docs/ -│ └── generate.js # 文档生成脚本(npm run docs) +│ ├── generate.js # 文档生成脚本(npm run docs,mock 模式) +│ └── generate-from-test.js # 测试驱动文档(npm run docs:test,✅ + 真实示例) ├── test/ │ └── api.test.js # vitest 测试套件(npm test) ├── types/ @@ -114,6 +116,20 @@ pnpm --filter erest-example docs # 生成到 docs/out/ - `Home.md` + 各组 `.md` + `errors.md` / `schema.md` / `types.md` — Markdown 文档 - `sdk.js` — 基于 axios 的前端 SDK +> 上面的 `npm run docs` 不跑测试,markdown 里所有 API 显示 ❌、无示例(mock 模式)。 + +### 测试驱动文档(✅ + 真实示例) + +```bash +pnpm --filter erest-example docs:test # 生成到 docs/out-from-test/ +``` + +用 test-agent 跑真实请求(`.success().takeExample()`),让文档通过测试变绿: + +- 被测路由显示 **✅**(`tested` 标记翻转),未测的保持 ❌ +- 「使用示例」段落的 `output` 是 **真实响应**(非 mock) +- 详见 [docs/generate-from-test.js](./docs/generate-from-test.js),根 README 的「测试驱动的文档」章节有原理说明 + ## 设计说明:handler 与 hooks 均框架无关 erest v3 标准化后,**业务 handler 与 before/middleware 钩子都框架无关**,同一份代码被 diff --git a/examples/docs/generate-from-test.js b/examples/docs/generate-from-test.js new file mode 100644 index 0000000..ea212d2 --- /dev/null +++ b/examples/docs/generate-from-test.js @@ -0,0 +1,109 @@ +/** + * 测试驱动文档生成脚本(演示 erest「测试即文档」能力)。 + * + * 与 generate.js 的区别: + * - generate.js:只装配实例 + genDocs,不跑请求 → 所有 API 在文档里显示 ❌、无真实示例 + * - 本脚本:用 test-agent 跑真实请求(.success().takeExample())→ 文档显示 ✅ + 真实示例 + * + * 原理(同一 ERest 实例上完成全链路): + * 1. .success() / .error() / .raw() 读取响应输出 → 翻转对应路由的 tested 标记 → markdown 标题显示 ✅ + * 2. .takeExample(name)(在 .success() 前调用)→ 把真实 input/headers/output 回填为该路由的示例 + * 3. genDocs() 落盘时,✅ 标记与示例数据一并写入 markdown + * + * 运行:npm run docs:test + */ +import express from "express"; +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; +import { mkdirSync, readdirSync, readFileSync } from "node:fs"; +import ERest from "erest"; +import { ExpressAdapter } from "@erest/express"; +import { API_INFO, GROUPS, registerApi } from "../src/api.js"; +import { createStore } from "../src/store.js"; +import { authBefore, adminBefore, logMiddleware, timingBefore } from "../src/hooks.js"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const outDir = resolve(__dirname, "out-from-test"); +mkdirSync(outDir, { recursive: true }); + +// 装配 ERest 实例(开启 markdown 文档生成) +const store = createStore(); +const app = express(); +app.use(express.json()); +app.use(express.urlencoded({ extended: true })); + +const api = new ERest({ + info: API_INFO, + groups: GROUPS, + forceGroup: true, + // wiki: 每个分组生成独立 .md(public.md/post.md/admin.md);index: 生成目录页 + docs: { markdown: true, wiki: true, index: true }, +}); + +registerApi(api, store, { + authBefore: authBefore(store), + adminBefore: adminBefore(), + logMiddleware: logMiddleware(), + timingBefore: timingBefore(), +}); + +api.bind({ adapter: new ExpressAdapter(), app, router: express.Router }); + +// 错误处理中间件(initTest 用 fetch 驱动测试,需错误以 JSON 响应体返回) +app.use((err, _req, res, _next) => { + res.status(err.statusCode || err.status || 400).json({ error: err.message }); +}); + +// 初始化测试系统(接收 express app,内部 lazy listen 随机端口) +api.initTest(app); + +// —— 用 test-agent 跑代表性请求,让文档通过测试变绿(✅)+ 回填真实示例 —— +// 路径用完整 group 前缀(public/ /posts/ /admin/),鉴权用 X-Admin-Token header +const examples = [ + // public 组(无鉴权) + () => api.test.get("/public/posts").takeExample("已发布文章列表").success(), + () => api.test.get("/public/posts/hello-erest").takeExample("文章详情").success(), + // post 组(需 user-token) + () => + api.test + .get("/posts/posts") + .headers({ "X-Admin-Token": "user-token" }) + .input({ status: "published" }) + .takeExample("我的文章列表") + .success(), + () => + api.test + .post("/posts/posts") + .headers({ "X-Admin-Token": "user-token" }) + .input({ slug: "test-driven-docs", title: "测试驱动文档", content: "测试即文档" }) + .takeExample("创建文章") + .success(), + // admin 组(需 admin-token) + () => api.test.get("/admin/users").headers({ "X-Admin-Token": "admin-token" }).takeExample("用户列表").success(), + () => api.test.get("/admin/stats").headers({ "X-Admin-Token": "admin-token" }).takeExample("统计信息").success(), +]; + +const results = await Promise.allSettled(examples.map((fn) => fn())); +const failures = results.filter((r) => r.status === "rejected"); +if (failures.length > 0) { + for (const f of failures) console.error(" ✗ 请求失败:", f.reason?.message || f.reason); + process.exit(1); +} + +// 生成并保存文档(onExit=false 同步落盘) +api.genDocs(outDir, false); + +// 统计 ✅ 覆盖率(遍历分组 md 文件) +let testedCount = 0; +let totalApis = 0; +for (const f of readdirSync(outDir)) { + if (!f.endsWith(".md")) continue; + const md = readFileSync(resolve(outDir, f), "utf8"); + testedCount += (md.match(/^## .+ ✅/gm) || []).length; + totalApis += (md.match(/^## .+ [✅❌]/gm) || []).length; +} + +console.log(`测试驱动文档已生成到 ${outDir}/`); +console.log(` - ${testedCount}/${totalApis} 个路由通过测试变绿 ✅`); +console.log(" - 示例数据来自 test-agent 真实响应(非 mock)"); +console.log("\n对比:npm run docs 生成 mock 文档(全部 ❌、无示例)"); diff --git a/examples/package.json b/examples/package.json index 9b9b62d..d90d812 100644 --- a/examples/package.json +++ b/examples/package.json @@ -12,7 +12,8 @@ "dev:express": "node --watch src/entries/express.js", "dev:koa": "node --watch src/entries/koa.js", "test": "vitest run", - "docs": "node docs/generate.js" + "docs": "node docs/generate.js", + "docs:test": "node docs/generate-from-test.js" }, "dependencies": { "@leizm/web": "^2.7.0", diff --git a/package.json b/package.json index 30698e4..0582519 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "erest", - "version": "3.2.1", + "version": "3.2.2", "description": "Framework-agnostic REST API framework with Zod validation, auto docs & test scaffolding. Express/Koa/@leizm/web adapters as separate packages.", "type": "module", "main": "dist/lib/index.js", diff --git a/packages/erest-express/package.json b/packages/erest-express/package.json index 3cf49c2..0a48c3f 100644 --- a/packages/erest-express/package.json +++ b/packages/erest-express/package.json @@ -1,6 +1,6 @@ { "name": "@erest/express", - "version": "3.2.1", + "version": "3.2.2", "description": "Express adapter for erest", "type": "module", "main": "./dist/index.js", diff --git a/packages/erest-gen/package.json b/packages/erest-gen/package.json index 80cf844..a4593cf 100644 --- a/packages/erest-gen/package.json +++ b/packages/erest-gen/package.json @@ -1,6 +1,6 @@ { "name": "@erest/gen", - "version": "3.2.1", + "version": "3.2.2", "description": "erest codegen CLI:从 Zod schema 生成 handler 骨架等(实验性,独立于 erest 主版本演进)", "type": "module", "bin": { diff --git a/packages/erest-koa/package.json b/packages/erest-koa/package.json index aa4c547..d633e95 100644 --- a/packages/erest-koa/package.json +++ b/packages/erest-koa/package.json @@ -1,6 +1,6 @@ { "name": "@erest/koa", - "version": "3.2.1", + "version": "3.2.2", "description": "Koa adapter for erest", "type": "module", "main": "./dist/index.js", diff --git a/packages/erest-leizmweb/package.json b/packages/erest-leizmweb/package.json index a0226ca..ec8256e 100644 --- a/packages/erest-leizmweb/package.json +++ b/packages/erest-leizmweb/package.json @@ -1,6 +1,6 @@ { "name": "@erest/leizmweb", - "version": "3.2.1", + "version": "3.2.2", "description": "@leizm/web adapter for erest", "type": "module", "main": "./dist/index.js",