Skip to content

Commit ffe0ffa

Browse files
authored
Merge pull request #194 from yourtion/feat/swagger-response-schema
2 parents 0e09ae8 + c98cfa9 commit ffe0ffa

4 files changed

Lines changed: 177 additions & 4 deletions

File tree

MIGRATION.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,3 +338,15 @@ instead」)。`@koa/router` 是官方维护的继任包,API 与 koa-router *
338338
### Breaking
339339

340340
- **`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,无需改动。
341+
342+
343+
## v3.2.2 — 文档展示返回结果 schema(Swagger / Postman)
344+
345+
### 新增(非 breaking)
346+
347+
- **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`,与参数提取保持一致。
348+
- **Postman 文档的 item 输出 response 示例**:此前 Postman Collection 只生成 request,不输出 response。现在当 API 声明了 response schema 时,每个 item 会带一个 `response` 数组,含按 schema 字段名 + 类型推断生成的占位示例 body(Postman 可直接用作 mock);未声明时不输出 `response` 字段。
349+
350+
### 影响范围
351+
352+
仅文档生成器(`generate_swagger` / `generate_postman`),不涉及运行时校验、路由绑定或类型推导。已声明 response schema 的项目重新生成文档即可看到变化;未声明的项目输出与之前完全一致。

src/lib/plugin/generate_postman/index.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,28 @@ import * as path from "node:path";
22
import { plugin as debug } from "../../debug.js";
33
import type { IDocData, IDocWritter } from "../../extend/docs.js";
44
import type { IDocOptions } from "../../index.js";
5+
import { extractDocFields, type DocField } from "../zod-meta.js";
56
import * as utils from "../../utils.js";
67

8+
/** 按 Zod 推断类型生成 Postman 示例占位值 */
9+
function sampleValue(f: DocField): unknown {
10+
if (f.enumValues && f.enumValues.length > 0) return f.enumValues[0];
11+
switch (f.type) {
12+
case "number":
13+
return 0;
14+
case "boolean":
15+
return false;
16+
case "array":
17+
return [];
18+
case "object":
19+
return {};
20+
case "date":
21+
return "2024-01-01T00:00:00Z";
22+
default:
23+
return "";
24+
}
25+
}
26+
727
interface IPostManHeader {
828
key: string;
929
value: string;
@@ -41,6 +61,14 @@ interface IPostManFolders {
4161
interface IPostManItem {
4262
name: string;
4363
request: IPostManRequest;
64+
response?: IPostManExampleResponse[];
65+
}
66+
67+
interface IPostManExampleResponse {
68+
name: string;
69+
status: string;
70+
code: number;
71+
body: string;
4472
}
4573

4674
export default function generatePostman(data: IDocData, dir: string, options: IDocOptions, writter: IDocWritter) {
@@ -104,6 +132,23 @@ export default function generatePostman(data: IDocData, dir: string, options: ID
104132
}
105133
}
106134

135+
// 有 responseSchema 时,生成示例响应(issue #6)
136+
const responseFields = extractDocFields(item.responseSchema, "body");
137+
if (responseFields.length > 0) {
138+
const sample: Record<string, unknown> = {};
139+
for (const f of responseFields) {
140+
sample[f.name] = sampleValue(f);
141+
}
142+
req.response = [
143+
{
144+
name: "成功",
145+
status: "OK",
146+
code: 200,
147+
body: JSON.stringify(sample, null, 2),
148+
},
149+
];
150+
}
151+
107152
// Create group if it doesn't exist
108153
if (!groups[item.group]) {
109154
groups[item.group] = { id: item.group, name: item.group, items: [] };

src/lib/plugin/generate_swagger/index.ts

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,37 @@ function convertZodFieldToSwagger(zodSchema: ZodType): {
263263
return result;
264264
}
265265

266+
/**
267+
* 构造 Swagger 2.0 的 200 响应对象。
268+
* 当 API 声明了 responseSchema(init 阶段解析后的最终 Zod schema)时,输出标准 schema;
269+
* 否则保持原有占位(仅 description)。复用 extractDocFields,与参数提取保持一致。
270+
*/
271+
function response200(api: { responseSchema?: unknown }): { description: string; schema?: unknown } {
272+
const fields = extractDocFields(api.responseSchema, "body");
273+
if (fields.length === 0) return { description: "请求成功" };
274+
const properties: Record<string, unknown> = {};
275+
const required: string[] = [];
276+
for (const f of fields) {
277+
const prop: { type: string; format?: string; description?: string; enum?: string[]; items?: unknown } = {
278+
type: f.enumValues ? "string" : f.type,
279+
...(f.comment ? { description: f.comment } : {}),
280+
...(f.format ? { format: f.format } : {}),
281+
...(f.enumValues ? { enum: f.enumValues } : {}),
282+
};
283+
if (f.type === "array") prop.items = { type: "string" };
284+
properties[f.name] = prop;
285+
if (f.required) required.push(f.name);
286+
}
287+
return {
288+
description: "请求成功",
289+
schema: {
290+
type: "object",
291+
properties,
292+
...(required.length > 0 ? { required } : {}),
293+
},
294+
};
295+
}
296+
266297
export function buildSwagger(data: IDocData) {
267298
const url = new URL(`${data.info.host || ""}${data.info.basePath || ""}`);
268299

@@ -303,9 +334,7 @@ export function buildSwagger(data: IDocData) {
303334
consumes: ["application/json"],
304335
produces: ["application/json"],
305336
responses: {
306-
200: {
307-
description: "请求成功",
308-
},
337+
200: response200(api),
309338
},
310339
};
311340

src/test/test-docs-plugins.ts

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,28 @@
77
* 内容被写入 swagger.json(与 swagger 插件冲突、互相覆盖)。
88
*/
99
import { describe, expect, test } from "vitest";
10+
import { z } from "zod";
1011
import generateAxios from "../lib/plugin/generate_axios";
1112
import generatePostman from "../lib/plugin/generate_postman";
12-
import generateSwagger from "../lib/plugin/generate_swagger";
13+
import generateSwagger, { buildSwagger } from "../lib/plugin/generate_swagger";
14+
15+
/** 从写入回调里解析 postman 输出 JSON */
16+
function capturePostman(api: Record<string, unknown>) {
17+
let captured = "";
18+
const writer = (_p: string, data: string) => (captured = data);
19+
generatePostman(
20+
{
21+
info: { title: "t", description: "d", host: "http://x", basePath: "" },
22+
group: { G: "G" },
23+
types: {},
24+
apis: { "get_/test": { method: "get", path: "/test", realPath: "/test", group: "G", title: "t", ...api } },
25+
} as any,
26+
"/out",
27+
{ postman: "postman.json" } as any,
28+
writer
29+
);
30+
return JSON.parse(captured);
31+
}
1332

1433
describe("文档插件文件名解析", () => {
1534
// 构造最小可用 docData(插件实际只读少量字段)
@@ -57,3 +76,71 @@ describe("文档插件文件名解析", () => {
5776
expect(written[0]).toContain("postman.json");
5877
});
5978
});
79+
80+
describe("swagger response schema(issue #6)", () => {
81+
const baseInfo = { title: "t", description: "d", host: "http://x", basePath: "" };
82+
const realPath = "/test";
83+
84+
function buildWithApi(api: Record<string, unknown>) {
85+
const data = {
86+
info: baseInfo,
87+
group: { G: "G" },
88+
types: {},
89+
apis: { "get_/test": { method: "get", path: "/test", realPath, group: "G", title: "t", ...api } },
90+
} as any;
91+
return buildSwagger(data);
92+
}
93+
94+
test("有 responseSchema 时,responses.200 应包含 schema(含字段与 required)", () => {
95+
const result = buildWithApi({
96+
responseSchema: z.object({ id: z.number(), name: z.string(), age: z.number().optional() }),
97+
});
98+
const op = (result.paths as any)[realPath].get;
99+
expect(op.responses[200].description).toBe("请求成功");
100+
const schema = op.responses[200].schema;
101+
expect(schema.type).toBe("object");
102+
// required 字段应包含非 optional 的字段
103+
expect(schema.required).toEqual(expect.arrayContaining(["id", "name"]));
104+
expect(schema.required).not.toContain("age");
105+
// 属性存在
106+
expect(Object.keys(schema.properties).toSorted()).toEqual(["age", "id", "name"]);
107+
expect(schema.properties.id.type).toBe("number");
108+
expect(schema.properties.name.type).toBe("string");
109+
});
110+
111+
test("无 responseSchema 时,responses.200 保持原有占位(仅 description)", () => {
112+
const result = buildWithApi({});
113+
const op = (result.paths as any)[realPath].get;
114+
expect(op.responses[200].description).toBe("请求成功");
115+
// 未定义 response 时不应输出 schema 字段
116+
expect(op.responses[200].schema).toBeUndefined();
117+
});
118+
119+
test("responseSchema 为 enum 时应输出 enum 取值", () => {
120+
const result = buildWithApi({
121+
responseSchema: z.object({ status: z.enum(["ok", "fail"]) }),
122+
});
123+
const op = (result.paths as any)[realPath].get;
124+
expect(op.responses[200].schema.properties.status.enum).toEqual(["ok", "fail"]);
125+
});
126+
});
127+
128+
describe("postman response 示例(issue #6)", () => {
129+
test("有 responseSchema 时,item 应带 response 示例(含字段名 key)", () => {
130+
const postman = capturePostman({
131+
responseSchema: z.object({ id: z.number(), name: z.string() }),
132+
});
133+
const item = postman.item[0].item[0];
134+
expect(item.response).toBeDefined();
135+
expect(item.response).toHaveLength(1);
136+
// response body 应是 JSON,包含 schema 的字段名
137+
const body = JSON.parse(item.response[0].body);
138+
expect(Object.keys(body).toSorted()).toEqual(["id", "name"]);
139+
});
140+
141+
test("无 responseSchema 时,item 不带 response 字段", () => {
142+
const postman = capturePostman({});
143+
const item = postman.item[0].item[0];
144+
expect(item.response).toBeUndefined();
145+
});
146+
});

0 commit comments

Comments
 (0)