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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 自动推导
Expand Down
18 changes: 17 additions & 1 deletion examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

## 目录结构
Expand All @@ -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/
Expand Down Expand Up @@ -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 钩子都框架无关**,同一份代码被
Expand Down
109 changes: 109 additions & 0 deletions examples/docs/generate-from-test.js
Original file line number Diff line number Diff line change
@@ -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 文档(全部 ❌、无示例)");
3 changes: 2 additions & 1 deletion examples/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion packages/erest-express/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion packages/erest-gen/package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
2 changes: 1 addition & 1 deletion packages/erest-koa/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion packages/erest-leizmweb/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
Loading