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
12 changes: 12 additions & 0 deletions MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -338,3 +338,15 @@ instead」)。`@koa/router` 是官方维护的继任包,API 与 koa-router *
### 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.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` 字段。

### 影响范围

仅文档生成器(`generate_swagger` / `generate_postman`),不涉及运行时校验、路由绑定或类型推导。已声明 response schema 的项目重新生成文档即可看到变化;未声明的项目输出与之前完全一致。
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
45 changes: 45 additions & 0 deletions src/lib/plugin/generate_postman/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,28 @@ import * as path from "node:path";
import { plugin as debug } from "../../debug.js";
import type { IDocData, IDocWritter } from "../../extend/docs.js";
import type { IDocOptions } from "../../index.js";
import { extractDocFields, type DocField } from "../zod-meta.js";
import * as utils from "../../utils.js";

/** 按 Zod 推断类型生成 Postman 示例占位值 */
function sampleValue(f: DocField): unknown {
if (f.enumValues && f.enumValues.length > 0) return f.enumValues[0];
switch (f.type) {
case "number":
return 0;
case "boolean":
return false;
case "array":
return [];
case "object":
return {};
case "date":
return "2024-01-01T00:00:00Z";
default:
return "";
}
}

interface IPostManHeader {
key: string;
value: string;
Expand Down Expand Up @@ -41,6 +61,14 @@ interface IPostManFolders {
interface IPostManItem {
name: string;
request: IPostManRequest;
response?: IPostManExampleResponse[];
}

interface IPostManExampleResponse {
name: string;
status: string;
code: number;
body: string;
}

export default function generatePostman(data: IDocData, dir: string, options: IDocOptions, writter: IDocWritter) {
Expand Down Expand Up @@ -104,6 +132,23 @@ export default function generatePostman(data: IDocData, dir: string, options: ID
}
}

// 有 responseSchema 时,生成示例响应(issue #6)
const responseFields = extractDocFields(item.responseSchema, "body");
if (responseFields.length > 0) {
const sample: Record<string, unknown> = {};
for (const f of responseFields) {
sample[f.name] = sampleValue(f);
}
req.response = [
{
name: "成功",
status: "OK",
code: 200,
body: JSON.stringify(sample, null, 2),
},
];
}

// Create group if it doesn't exist
if (!groups[item.group]) {
groups[item.group] = { id: item.group, name: item.group, items: [] };
Expand Down
35 changes: 32 additions & 3 deletions src/lib/plugin/generate_swagger/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,37 @@ function convertZodFieldToSwagger(zodSchema: ZodType): {
return result;
}

/**
* 构造 Swagger 2.0 的 200 响应对象。
* 当 API 声明了 responseSchema(init 阶段解析后的最终 Zod schema)时,输出标准 schema;
* 否则保持原有占位(仅 description)。复用 extractDocFields,与参数提取保持一致。
*/
function response200(api: { responseSchema?: unknown }): { description: string; schema?: unknown } {
const fields = extractDocFields(api.responseSchema, "body");
if (fields.length === 0) return { description: "请求成功" };
const properties: Record<string, unknown> = {};
const required: string[] = [];
for (const f of fields) {
const prop: { type: string; format?: string; description?: string; enum?: string[]; items?: unknown } = {
type: f.enumValues ? "string" : f.type,
...(f.comment ? { description: f.comment } : {}),
...(f.format ? { format: f.format } : {}),
...(f.enumValues ? { enum: f.enumValues } : {}),
};
if (f.type === "array") prop.items = { type: "string" };
properties[f.name] = prop;
if (f.required) required.push(f.name);
}
return {
description: "请求成功",
schema: {
type: "object",
properties,
...(required.length > 0 ? { required } : {}),
},
};
}

export function buildSwagger(data: IDocData) {
const url = new URL(`${data.info.host || ""}${data.info.basePath || ""}`);

Expand Down Expand Up @@ -303,9 +334,7 @@ export function buildSwagger(data: IDocData) {
consumes: ["application/json"],
produces: ["application/json"],
responses: {
200: {
description: "请求成功",
},
200: response200(api),
},
};

Expand Down
89 changes: 88 additions & 1 deletion src/test/test-docs-plugins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,28 @@
* 内容被写入 swagger.json(与 swagger 插件冲突、互相覆盖)。
*/
import { describe, expect, test } from "vitest";
import { z } from "zod";
import generateAxios from "../lib/plugin/generate_axios";
import generatePostman from "../lib/plugin/generate_postman";
import generateSwagger from "../lib/plugin/generate_swagger";
import generateSwagger, { buildSwagger } from "../lib/plugin/generate_swagger";

/** 从写入回调里解析 postman 输出 JSON */
function capturePostman(api: Record<string, unknown>) {
let captured = "";
const writer = (_p: string, data: string) => (captured = data);
generatePostman(
{
info: { title: "t", description: "d", host: "http://x", basePath: "" },
group: { G: "G" },
types: {},
apis: { "get_/test": { method: "get", path: "/test", realPath: "/test", group: "G", title: "t", ...api } },
} as any,
"/out",
{ postman: "postman.json" } as any,
writer
);
return JSON.parse(captured);
}

describe("文档插件文件名解析", () => {
// 构造最小可用 docData(插件实际只读少量字段)
Expand Down Expand Up @@ -57,3 +76,71 @@ describe("文档插件文件名解析", () => {
expect(written[0]).toContain("postman.json");
});
});

describe("swagger response schema(issue #6)", () => {
const baseInfo = { title: "t", description: "d", host: "http://x", basePath: "" };
const realPath = "/test";

function buildWithApi(api: Record<string, unknown>) {
const data = {
info: baseInfo,
group: { G: "G" },
types: {},
apis: { "get_/test": { method: "get", path: "/test", realPath, group: "G", title: "t", ...api } },
} as any;
return buildSwagger(data);
}

test("有 responseSchema 时,responses.200 应包含 schema(含字段与 required)", () => {
const result = buildWithApi({
responseSchema: z.object({ id: z.number(), name: z.string(), age: z.number().optional() }),
});
const op = (result.paths as any)[realPath].get;
expect(op.responses[200].description).toBe("请求成功");
const schema = op.responses[200].schema;
expect(schema.type).toBe("object");
// required 字段应包含非 optional 的字段
expect(schema.required).toEqual(expect.arrayContaining(["id", "name"]));
expect(schema.required).not.toContain("age");
// 属性存在
expect(Object.keys(schema.properties).toSorted()).toEqual(["age", "id", "name"]);
expect(schema.properties.id.type).toBe("number");
expect(schema.properties.name.type).toBe("string");
});

test("无 responseSchema 时,responses.200 保持原有占位(仅 description)", () => {
const result = buildWithApi({});
const op = (result.paths as any)[realPath].get;
expect(op.responses[200].description).toBe("请求成功");
// 未定义 response 时不应输出 schema 字段
expect(op.responses[200].schema).toBeUndefined();
});

test("responseSchema 为 enum 时应输出 enum 取值", () => {
const result = buildWithApi({
responseSchema: z.object({ status: z.enum(["ok", "fail"]) }),
});
const op = (result.paths as any)[realPath].get;
expect(op.responses[200].schema.properties.status.enum).toEqual(["ok", "fail"]);
});
});

describe("postman response 示例(issue #6)", () => {
test("有 responseSchema 时,item 应带 response 示例(含字段名 key)", () => {
const postman = capturePostman({
responseSchema: z.object({ id: z.number(), name: z.string() }),
});
const item = postman.item[0].item[0];
expect(item.response).toBeDefined();
expect(item.response).toHaveLength(1);
// response body 应是 JSON,包含 schema 的字段名
const body = JSON.parse(item.response[0].body);
expect(Object.keys(body).toSorted()).toEqual(["id", "name"]);
});

test("无 responseSchema 时,item 不带 response 字段", () => {
const postman = capturePostman({});
const item = postman.item[0].item[0];
expect(item.response).toBeUndefined();
});
});
Loading