diff --git a/MIGRATION.md b/MIGRATION.md index df83db6..0206afe 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -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 的项目重新生成文档即可看到变化;未声明的项目输出与之前完全一致。 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", diff --git a/src/lib/plugin/generate_postman/index.ts b/src/lib/plugin/generate_postman/index.ts index 47a3105..c83e789 100644 --- a/src/lib/plugin/generate_postman/index.ts +++ b/src/lib/plugin/generate_postman/index.ts @@ -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; @@ -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) { @@ -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 = {}; + 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: [] }; diff --git a/src/lib/plugin/generate_swagger/index.ts b/src/lib/plugin/generate_swagger/index.ts index a468e36..a477461 100644 --- a/src/lib/plugin/generate_swagger/index.ts +++ b/src/lib/plugin/generate_swagger/index.ts @@ -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 = {}; + 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 || ""}`); @@ -303,9 +334,7 @@ export function buildSwagger(data: IDocData) { consumes: ["application/json"], produces: ["application/json"], responses: { - 200: { - description: "请求成功", - }, + 200: response200(api), }, }; diff --git a/src/test/test-docs-plugins.ts b/src/test/test-docs-plugins.ts index fb48ca8..610ab5f 100644 --- a/src/test/test-docs-plugins.ts +++ b/src/test/test-docs-plugins.ts @@ -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) { + 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(插件实际只读少量字段) @@ -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) { + 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(); + }); +});