diff --git a/MIGRATION.md b/MIGRATION.md index 986b27a..df83db6 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -319,3 +319,22 @@ instead」)。`@koa/router` 是官方维护的继任包,API 与 koa-router * - **`z.anyObject()`**:等价 `z.object({}).catchall(z.unknown())`,挂在 erest 导出的 `z` 上(也具名导出 `zAnyObject` 常量)。供「动态字段 body」场景使用。 - **`success()`**:TestAgent 测试方法泛型化,返回类型可从 response schema 推导(`.get(path).success()`);默认 `unknown` 向后兼容。 - **`setResponseEnvelopers({ success, error, testUnwrapper })`**:注册后 registerTyped handler 进入「return 模式」——handler 只 `return data`,框架用 success enveloper 自动包装成响应体;抛错用 error enveloper 包装成 `{body, status}`。未注册时维持 v3.1 行为(handler 调 `ctx.reply` 写响应)。`testUnwrapper` 供测试脚手架拆信封(便捷入口,等价 `setFormatOutput`)。 + + +## v3.2.1 — enveloper 逃生舱 + @leizm/web send 修复 + 测试文档 + +### 新增(非 breaking) + +- **`Reply.markSent()`**:enveloper 模式下,`registerTyped` handler 若需返回非 JSON 响应(CSV / 文件下载 / 流式)并经 `ctx.reply.raw` 手动写完响应,调用 `ctx.reply.markSent()` 告知 enveloper 跳过自动包装。否则 enveloper 会在 handler return 后再用 `successEnveloper` 包一层 `reply.json()`,可能覆盖已发送响应或触发「headers already sent」(各框架行为不一)。三 adapter(express/koa/leizmweb)的 `reply` 实现统一维护 `__sent` 标记:`json()`/`send()`/`markSent()` 均置位,`wrapWithEnvelope` 综合判断。不调 `markSent` 的存量 handler 行为不变。 + +### 修复 + +- **`@erest/leizmweb` `reply.send` 映射错误导致纯文本响应 404**:`createLeiReply.send` 此前调 `ctx.response.send()`,但 `@leizm/web` 的 response 没有 `send` 方法(仅有 `end/json/file`)。可选链 `leiRes.send?.()` 静默跳过 → 响应体未写 → 框架 final handler 返回 404。修正为映射到 `end(body)` + 补 `text/plain` content-type。**影响面**:`@leizm/web` adapter 下任何用 `ctx.reply.send(文本)` 的路由此前都会 404(用 `reply.json` 的路由不受影响)。 + +### 文档 + +- **测试引擎 `.input()` 的方法归属约定显式化**:GET/DELETE 归 query、POST/PUT/PATCH 归 body。注册 DELETE 路由时 schema 必须从 `query` 读参数(HTTP DELETE body 有争议,fetch/浏览器/代理普遍不传递)。README 与 `agent.ts` JSDoc 同步补充。 + +### 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,无需改动。 diff --git a/README.md b/README.md index e415c7b..ac5827c 100644 --- a/README.md +++ b/README.md @@ -370,6 +370,19 @@ api.genDocs('./docs/'); > `.input(data)` 对 **GET/DELETE** 把 `data` 转为 query string,对 **POST/PUT/PATCH** 作为 JSON body。 > 用 `.query(data)` / `.json(data)` 可显式指定来源。 +> **⚠️ DELETE 参数从 query 读**:HTTP DELETE 带 body 有争议(fetch/浏览器/代理普遍不保证传递), +> 所以测试引擎把 DELETE 的 `.input()` 归为 query,**生产侧三 adapter 也只从 query 读 DELETE 参数**。 +> 因此注册 DELETE 路由时,schema 必须用 `query`(不是 `body`): + +```typescript +// ✅ DELETE 的确认参数走 query +api.delete('/items/:id').registerTyped( + { params: z.object({ id: z.string() }), query: z.object({ confirm: z.boolean() }) }, + (req) => { if (!req.query.confirm) throw new Error('需确认'); /* ... */ }, +); +// 测试侧对应:api.test.delete('/items/1').input({ confirm: true }) +``` + ```typescript // initTest 接收 http.Server 或框架的 app/server/callback api.initTest(app); @@ -471,6 +484,28 @@ api.api.post('/login').group('auth').title('登录').registerTyped( > ⚠️ `raw` 的头部/cookie 操作应在 `ctx.reply.json()`/`ctx.reply.send()` **之前**调用(HTTP 头先于体发送)。`reply.raw` 是逃生舱,不参与框架无关复用——用了 raw 的 handler 即与具体框架耦合。 +#### `ctx.reply.markSent()`:enveloper 模式下的非 JSON 响应逃生舱 + +当注册了 `setResponseEnvelopers` 且 `registerTyped` handler 需要返回**非 JSON 响应**(CSV / 文件下载 / 流式)时,handler 必须经 `reply.raw` 手动写完整响应,并调用 **`ctx.reply.markSent()`** 告知 enveloper 跳过自动包装。 + +否则 handler return 后,enveloper 会用 `successEnveloper` 包一层 `reply.json()`,可能覆盖已发送的响应或触发「headers already sent」类错误(各框架行为不一)。 + +```typescript +api.group('admin').get('/tables/:id/export').registerTyped( + { params: z.object({ id: z.string() }) }, + (req, ctx) => { + // 经 raw 手动写 CSV(非 JSON),三框架原生写法不同 + const res = ctx.reply.raw.res; // Express;Koa 用 ctx.reply.raw.body,@leizm/web 用 ctx.reply.raw.response + res.setHeader('Content-Type', 'text/csv'); + res.end('a,b\n1,2'); + ctx.reply.markSent(); // ← 关键:跳过 enveloper 的自动包装 + }, +); +``` + +> `markSent()` 只在「enveloper + registerTyped + 手动 raw 响应」三者同时成立时需要。 +> 用 `register()`(非 typed)注册的 handler 本就不触发 enveloper,无需调用。 + ## 参数读取:`$params` 与分层访问器 adapter 的 checker 把校验后的参数注入到 erest 的标准化 `ctx` 上,handler 通过它们读取。 diff --git a/package.json b/package.json index 6e2d496..358af8f 100644 --- a/package.json +++ b/package.json @@ -58,11 +58,16 @@ "homepage": "https://github.com/yourtion/node-erest#readme", "dependencies": { "debug": "^4.4.1", - "path-to-regexp": "^6.3.0", - "zod": "^4.0.5" + "path-to-regexp": "^6.3.0" }, "peerDependencies": { - "@types/node": "*" + "@types/node": "*", + "zod": "^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": false + } }, "devDependencies": { "@leizm/web": "^2.7.3", @@ -85,7 +90,8 @@ "oxlint": "1.71.0", "typedoc": "^0.28.19", "typescript": "^5.8.3", - "vitest": "^3.2.4" + "vitest": "^3.2.4", + "zod": "^4.4.3" }, "pnpm": { "onlyBuiltDependencies": [ diff --git a/packages/erest-express/src/index.ts b/packages/erest-express/src/index.ts index c97dbff..4fb66e8 100644 --- a/packages/erest-express/src/index.ts +++ b/packages/erest-express/src/index.ts @@ -137,19 +137,25 @@ export class ExpressAdapter implements FrameworkAdapter { +function createExpressReply(req: unknown, res: unknown): Reply & { __sent: boolean } { const expressRes = res as { status?: (c: number) => unknown; json?: (b: unknown) => void; end?: (b: string) => void }; - const reply: Reply = { + const reply: Reply & { __sent: boolean } = { + __sent: false, status(code: number) { expressRes.status?.(code); return reply; }, json(body: unknown) { + reply.__sent = true; expressRes.json?.(body); }, send(body: string) { + reply.__sent = true; expressRes.end?.(body); }, + markSent() { + reply.__sent = true; + }, raw: { req, res } as ExpressRaw, }; return reply; diff --git a/packages/erest-koa/src/index.ts b/packages/erest-koa/src/index.ts index 186bbce..d2f366c 100644 --- a/packages/erest-koa/src/index.ts +++ b/packages/erest-koa/src/index.ts @@ -136,20 +136,26 @@ export class KoaAdapter implements FrameworkAdapter { } /** 构造 Koa 的 Reply 封装(写 ctx.status / ctx.body;含 raw 逃生舱:原生 ctx) */ -function createKoaReply(ctx: Record): Reply { +function createKoaReply(ctx: Record): Reply & { __sent: boolean } { const koaCtx = ctx as { status?: number; body?: unknown; type?: string }; - const reply: Reply = { + const reply: Reply & { __sent: boolean } = { + __sent: false, status(code: number) { koaCtx.status = code; return reply; }, json(body: unknown) { + reply.__sent = true; koaCtx.type = "application/json"; koaCtx.body = JSON.stringify(body); }, send(body: string) { + reply.__sent = true; koaCtx.body = body; }, + markSent() { + reply.__sent = true; + }, raw: ctx as KoaRaw, }; return reply; diff --git a/packages/erest-leizmweb/src/index.ts b/packages/erest-leizmweb/src/index.ts index 6c24b59..bc8617b 100644 --- a/packages/erest-leizmweb/src/index.ts +++ b/packages/erest-leizmweb/src/index.ts @@ -133,23 +133,34 @@ export class LeizmWebAdapter implements FrameworkAdapter): Reply { +/** 构造 @leizm/web 的 Reply 封装(写 ctx.response.json/status;send→end;含 raw 逃生舱:原生 ctx) */ +function createLeiReply(ctx: Record): Reply & { __sent: boolean } { const leiRes = (ctx.response ?? {}) as { status?: (c: number) => unknown; json?: (b: unknown) => void; - send?: (b: string) => void; + end?: (b?: string) => void; + setHeader?: (n: string, v: string) => void; }; - const reply: Reply = { + const reply: Reply & { __sent: boolean } = { + __sent: false, status(code: number) { leiRes.status?.(code); return reply; }, json(body: unknown) { + reply.__sent = true; leiRes.json?.(body); }, + // @leizm/web 的 response 没有 send() 方法(仅有 end/json/file),纯文本响应用 end()。 + // 此前误调不存在的 send()(可选链静默跳过)→ 响应体未写 → 框架 final handler 返回 404。 + // 修正:send 映射到 end(),并补 text/plain content-type,与 json() 的 application/json 对齐。 send(body: string) { - leiRes.send?.(body); + reply.__sent = true; + leiRes.setHeader?.("Content-Type", "text/plain; charset=utf-8"); + leiRes.end?.(body); + }, + markSent() { + reply.__sent = true; }, // LeizmWebRaw 是 @leizm/web 的 Context(结构含 request/response/session 等), // 与此处的 Record 不充分重叠,需经 unknown 双重断言(同运行时桥接) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 65ff81e..618e9c7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,9 +14,6 @@ importers: path-to-regexp: specifier: ^6.3.0 version: 6.3.0 - zod: - specifier: ^4.0.5 - version: 4.0.5 devDependencies: '@leizm/web': specifier: ^2.7.3 @@ -81,6 +78,9 @@ importers: vitest: specifier: ^3.2.4 version: 3.2.4(@types/debug@4.1.12)(@types/node@24.0.15)(@vitest/ui@3.2.4)(yaml@2.9.0) + zod: + specifier: ^4.4.3 + version: 4.4.3 examples: dependencies: @@ -2051,6 +2051,9 @@ packages: zod@4.0.5: resolution: {integrity: sha512-/5UuuRPStvHXu7RS+gmvRf4NXrNxpSllGwDnCBcJZtQsKrviYXm54yDGV2KYNLT5kq0lHGcl7lqWJLgSaG+tgA==} + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + snapshots: '@ampproject/remapping@2.3.0': @@ -3851,3 +3854,5 @@ snapshots: ylru@1.4.0: {} zod@4.0.5: {} + + zod@4.4.3: {} diff --git a/src/lib/adapters/types.ts b/src/lib/adapters/types.ts index 8b06b32..cba6c26 100644 --- a/src/lib/adapters/types.ts +++ b/src/lib/adapters/types.ts @@ -85,6 +85,11 @@ export type CheckerFunction = (erest: ERest, schema: API) => T; * `raw` 是逃生舱:当需要框架特有能力(setCookie/redirect/stream/文件下载等)时, * 通过 `reply.raw` 访问框架原生对象。类型由 `ERest` 的 Raw 泛型驱动, * 经子包 `createERest()` 工厂在构造时锁定。 + * + * `markSent()` 是 enveloper 逃生舱:当 registerTyped handler 需要返回非 JSON 响应 + * (CSV/文件下载/流式)并已通过 raw 手动写完响应时,调用它告知 enveloper 跳过自动 + * 包装。否则 enveloper 会在 handler return 后再用 successEnveloper 包一层 json, + * 可能覆盖已发送的响应或抛错(各框架行为不一)。 */ export interface Reply { /** 设置 HTTP 状态码并返回自身,支持链式调用 */ @@ -93,6 +98,11 @@ export interface Reply { json(body: unknown): void; /** 以纯文本写入响应体 */ send(body: string): void; + /** + * 标记响应已由 handler 手动发送(如经 raw 写 CSV/文件流),enveloper 将跳过自动包装。 + * 仅在注册了 enveloper 且 handler 已自行写完整响应时需要调用。 + */ + markSent(): void; /** 框架原生对象逃生舱(setCookie/redirect/stream/文件下载等) */ readonly raw: Raw; } diff --git a/src/lib/adapters/utils.ts b/src/lib/adapters/utils.ts index 494010a..3d4d992 100644 --- a/src/lib/adapters/utils.ts +++ b/src/lib/adapters/utils.ts @@ -96,7 +96,11 @@ export function wrapWithEnvelope( // 成功:handler 执行完无抛错。enveloper 模式下用 successEnveloper 包装 return 值。 // __returned 标记由 wrappedHandler 在 handler return 后置位(含 return undefined); // 未置位说明 handler 自行调 ctx.reply 写了响应(非 enveloper 用法),不再二次包装。 - if (successEnveloper && (ctx as Context & { __returned?: boolean }).__returned) { + // 额外检查 reply.__sent:handler 若调用了 reply.markSent()(或 json/send), + // 说明响应已手动发送(如经 raw 写 CSV/文件流),enveloper 不再二次包装, + // 避免覆盖已发送的响应或触发「headers already sent」类错误(各框架行为不一)。 + const reply = ctx.reply as { __sent?: boolean }; + if (successEnveloper && (ctx as Context & { __returned?: boolean }).__returned && !reply.__sent) { const returnValue = (ctx as Context & { __returnValue?: unknown }).__returnValue; const wrapped = successEnveloper(returnValue, ctx); ctx.reply.json(wrapped); diff --git a/src/lib/agent.ts b/src/lib/agent.ts index d24002d..42c470f 100644 --- a/src/lib/agent.ts +++ b/src/lib/agent.ts @@ -152,7 +152,17 @@ export class TestAgent { return this; } - /** 添加输入参数 */ + /** + * 添加输入参数。 + * + * 方法归属约定(HTTP 语义 + 浏览器/fetch 兼容): + * - GET / DELETE:归为 query(URL ?k=v)。HTTP DELETE 带 body 有争议, + * fetch/浏览器/代理普遍不保证传递 DELETE body,故统一走 query。 + * - POST / PUT / PATCH:归为 body(JSON)。 + * + * 因此注册 DELETE 路由时,schema 应从 query 读取参数(非 body): + * api.delete('/items/:id').registerTyped({ query: z.object({ confirm: z.boolean() }) }, ...) + */ public input(data: Record) { this.debug("input: %j", data); Object.assign(this.options.agentInput, data); diff --git a/src/test/test-envelope.ts b/src/test/test-envelope.ts index 22e1a97..bec3b3c 100644 --- a/src/test/test-envelope.ts +++ b/src/test/test-envelope.ts @@ -2,8 +2,11 @@ * @file 全局 response envelope 集成测试 * 验证 setResponseEnvelopers 后 registerTyped handler return data 被自动包装 */ +import { Application, component, Router } from "@leizm/web"; import express from "express"; -import { expressAdapter } from "./adapters"; +import Koa from "koa"; +import KoaRouter from "koa-router"; +import { expressAdapter, koaAdapter, leizmwebAdapter } from "./adapters"; import { httpReq as request } from "./http-req"; import { afterAll, describe, expect, it, vi } from "vitest"; import { z } from "zod"; @@ -11,7 +14,7 @@ import { wrapWithEnvelope } from "../lib/adapters/utils.js"; import type { Context } from "../lib/adapters/types.js"; import lib from "./lib"; -/** 构造最小 Context mock(state 可读写,reply 记录调用) */ +/** 构造最小 Context mock(state 可读写,reply 记录调用;__sent 模拟手动响应标记) */ function mockCtx(): Context & { __returned?: boolean; __returnValue?: unknown; @@ -19,14 +22,24 @@ function mockCtx(): Context & { status: ReturnType; json: ReturnType; send: ReturnType; + markSent: ReturnType; raw: unknown; + __sent?: boolean; }; } { const reply = { status: vi.fn(() => reply), - json: vi.fn(), - send: vi.fn(), + json: vi.fn(() => { + reply.__sent = true; + }), + send: vi.fn(() => { + reply.__sent = true; + }), + markSent: vi.fn(() => { + reply.__sent = true; + }), raw: {}, + __sent: false, }; return { method: "GET", @@ -220,6 +233,117 @@ describe("wrapWithEnvelope 单元测试", () => { expect(success).toHaveBeenCalledWith(undefined, ctx); expect(ctx.reply.json).toHaveBeenCalled(); }); + + it("reply.__sent=true(handler 调 markSent 手动响应)→ enveloper 跳过,不二次包装", async () => { + const ctx = mockCtx(); + ctx.__returned = true; + ctx.__returnValue = { raw: "csv" }; + ctx.reply.__sent = true; // 模拟 handler 已调 reply.markSent() + const success = vi.fn(); + + await wrapWithEnvelope(async () => Promise.resolve(), { success })(ctx); + + expect(success).not.toHaveBeenCalled(); + expect(ctx.reply.json).not.toHaveBeenCalled(); + }); +}); + +// 三框架集成:enveloper 模式下 registerTyped handler 经 raw 手动写 CSV + reply.markSent(), +// enveloper 应跳过自动包装,响应体保持原始 CSV(而非被 json 覆盖/报错)。 +const csvEnvelopers = { + success: (data: unknown) => ({ success: true, data }), + error: (err: unknown) => { + const e = err as { statusCode?: number; message?: string }; + return { body: { error: e.message ?? "fail" }, status: e.statusCode ?? 500 }; + }, +}; + +describe("enveloper + markSent 逃生舱 - Express", () => { + const apiService = lib({ + forceGroup: true, + info: { basePath: "" }, + groups: { api: { name: "api", prefix: "/api" } }, + }); + apiService.setResponseEnvelopers(csvEnvelopers); + apiService + .group("api") + .get("/csv") + .registerTyped({}, (req, ctx) => { + // 经 raw 手动写非 JSON 响应(CSV),调 markSent 告知 enveloper 跳过 + const res = (ctx.reply.raw as { res: { setHeader: (n: string, v: string) => void; end: (b: string) => void } }) + .res; + res.setHeader("Content-Type", "text/csv"); + res.end("a,b\n1,2"); + ctx.reply.markSent(); + }); + const app = express(); + app.use(express.json()); + apiService.bind({ adapter: expressAdapter, app, router: express.Router }); + + it("raw 写 CSV + markSent → 响应体是原始 CSV,非信封", async () => { + const res = await request(app).get("/api/csv"); + expect(res.status).toBe(200); + expect(res.headers["content-type"]).toContain("text/csv"); + expect(res.text).toBe("a,b\n1,2"); + }); +}); + +describe("enveloper + markSent 逃生舱 - @leizm/web", () => { + const apiService = lib({ + forceGroup: true, + info: { basePath: "" }, + groups: { api: { name: "api", prefix: "/api" } }, + }); + apiService.setResponseEnvelopers(csvEnvelopers); + apiService + .group("api") + .get("/csv") + .registerTyped({}, (req, ctx) => { + const raw = ctx.reply.raw as { + response: { setHeader: (n: string, v: string) => void; end: (b: string) => void }; + }; + raw.response.setHeader("Content-Type", "text/csv"); + raw.response.end("a,b\n1,2"); + ctx.reply.markSent(); + }); + const app = new Application(); + app.use("/", component.bodyParser.json()); + apiService.bind({ adapter: leizmwebAdapter, app, router: Router }); + + it("raw 写 CSV + markSent → 响应体是原始 CSV,非信封", async () => { + const res = await request(app.server).get("/api/csv"); + expect(res.status).toBe(200); + expect(res.headers["content-type"]).toContain("text/csv"); + expect(res.text).toBe("a,b\n1,2"); + }); +}); + +describe("enveloper + markSent 逃生舱 - Koa", () => { + const apiService = lib({ + forceGroup: true, + info: { basePath: "" }, + groups: { api: { name: "api", prefix: "/api" } }, + }); + apiService.setResponseEnvelopers(csvEnvelopers); + apiService + .group("api") + .get("/csv") + .registerTyped({}, (req, ctx) => { + // Koa:用原生 ctx 赋值写响应(Koa 惯例:ctx.type + ctx.body 均为 setter),markSent 阻止 enveloper 覆盖 + const raw = ctx.reply.raw as { type: string; body: unknown }; + raw.type = "text/csv"; + raw.body = "a,b\n1,2"; + ctx.reply.markSent(); + }); + const app = new Koa(); + apiService.bind({ adapter: koaAdapter, app, router: KoaRouter }); + + it("raw 写 CSV + markSent → 响应体是原始 CSV,非信封", async () => { + const res = await request(app.callback()).get("/api/csv"); + expect(res.status).toBe(200); + expect(res.headers["content-type"]).toContain("text/csv"); + expect(res.text).toBe("a,b\n1,2"); + }); }); afterAll(() => {}); diff --git a/src/test/test-multilevel-route.ts b/src/test/test-multilevel-route.ts new file mode 100644 index 0000000..46d2e66 --- /dev/null +++ b/src/test/test-multilevel-route.ts @@ -0,0 +1,140 @@ +/** + * @file 三框架对齐:forceGroup 模式下 `:param` 多层路径 + 同前缀多路由的路由匹配回归。 + * + * 背景:one-api 在 leizm/web adapter 下,同 group 注册 `/tables/:id/data`、 + * `/tables/:id/export`、`/tables/:id/data/:pk` 后,`/tables/:id/export`(非 typed .register()) + * 返回 404,而同路径改 .registerTyped() 后 200。erest 自身 leizmweb 集成测试只覆盖静态路径, + * 未覆盖 `:param` + 多层字面段 + 同前缀多路由的形态。本文件三框架对齐验证该场景。 + */ +import { describe, expect, it } from "vitest"; +import { Application, component, Router } from "@leizm/web"; +import express from "express"; +import Koa from "koa"; +import KoaRouter from "koa-router"; +import { expressAdapter, koaAdapter, leizmwebAdapter } from "./adapters"; +import { httpReq } from "./http-req"; +import lib from "./lib"; + +/** + * 在给定 apiService 上注册一组「`:param` 多层路径 + 同前缀多路由」的 API。 + * 模拟 one-api admin-data 路由形态: + * GET /items/:id/data (registerTyped) + * GET /items/:id/export (register 非typed) + * GET /items/:id/data/:pk (registerTyped) + * GET /items/:id/refresh (registerTyped,固定字面段) + */ +function registerMultilevelRoutes(apiService: ReturnType): void { + const g = apiService.group("api"); + g.get("/items/:id/data").registerTyped({ params: z.object({ id: z.string() }) }, (req, ctx) => { + ctx.reply.json({ data: req.params.id }); + }); + g.get("/items/:id/export").register((ctx) => { + ctx.reply.send("csv-content"); + }); + g.get("/items/:id/data/:pk").registerTyped({ params: z.object({ id: z.string(), pk: z.string() }) }, (req, ctx) => { + ctx.reply.json({ pk: req.params.pk }); + }); + g.get("/items/:id/refresh").registerTyped({ params: z.object({ id: z.string() }) }, (req, ctx) => { + ctx.reply.json({ refreshed: req.params.id }); + }); +} + +import { z } from "zod"; + +describe("多层级路径对齐 - Express", () => { + const apiService = lib({ + forceGroup: true, + info: { basePath: "" }, + groups: { api: { name: "api", prefix: "/api" } }, + }); + registerMultilevelRoutes(apiService); + const app = express(); + app.use(express.json()); + apiService.bind({ adapter: expressAdapter, app, router: express.Router }); + + it("GET /api/items/1/data → 200", async () => { + const res = await httpReq(app).get("/api/items/1/data"); + expect(res.status).toBe(200); + }); + it("GET /api/items/1/export(register 非typed)→ 200", async () => { + const res = await httpReq(app).get("/api/items/1/export"); + expect(res.status).toBe(200); + expect(res.text).toBe("csv-content"); + }); + it("GET /api/items/1/data/99 → 200", async () => { + const res = await httpReq(app).get("/api/items/1/data/99"); + expect(res.status).toBe(200); + }); + it("GET /api/items/1/refresh → 200", async () => { + const res = await httpReq(app).get("/api/items/1/refresh"); + expect(res.status).toBe(200); + }); +}); + +describe("多层级路径对齐 - @leizm/web", () => { + const apiService = lib({ + forceGroup: true, + info: { basePath: "" }, + groups: { api: { name: "api", prefix: "/api" } }, + }); + registerMultilevelRoutes(apiService); + const app = new Application(); + app.use("/", component.bodyParser.json()); + apiService.bind({ adapter: leizmwebAdapter, app, router: Router }); + + it("GET /api/items/1/data → 200", async () => { + const res = await httpReq(app.server).get("/api/items/1/data"); + expect(res.status).toBe(200); + }); + it("GET /api/items/1/export(register 非typed)→ 200", async () => { + const res = await httpReq(app.server).get("/api/items/1/export"); + expect(res.status).toBe(200); + expect(res.text).toBe("csv-content"); + }); + it("GET /api/items/1/data/99 → 200", async () => { + const res = await httpReq(app.server).get("/api/items/1/data/99"); + expect(res.status).toBe(200); + }); + it("GET /api/items/1/refresh → 200", async () => { + const res = await httpReq(app.server).get("/api/items/1/refresh"); + expect(res.status).toBe(200); + }); +}); + +describe("多层级路径对齐 - Koa", () => { + const apiService = lib({ + forceGroup: true, + info: { basePath: "" }, + groups: { api: { name: "api", prefix: "/api" } }, + }); + registerMultilevelRoutes(apiService); + const app = new Koa(); + app.use(async (ctx, next) => { + try { + await next(); + } catch (err: unknown) { + ctx.status = (err as { statusCode?: number }).statusCode ?? 500; + ctx.type = "application/json"; + ctx.body = JSON.stringify({ message: (err as Error).message }); + } + }); + apiService.bind({ adapter: koaAdapter, app, router: KoaRouter }); + + it("GET /api/items/1/data → 200", async () => { + const res = await httpReq(app.callback()).get("/api/items/1/data"); + expect(res.status).toBe(200); + }); + it("GET /api/items/1/export(register 非typed)→ 200", async () => { + const res = await httpReq(app.callback()).get("/api/items/1/export"); + expect(res.status).toBe(200); + expect(res.text).toBe("csv-content"); + }); + it("GET /api/items/1/data/99 → 200", async () => { + const res = await httpReq(app.callback()).get("/api/items/1/data/99"); + expect(res.status).toBe(200); + }); + it("GET /api/items/1/refresh → 200", async () => { + const res = await httpReq(app.callback()).get("/api/items/1/refresh"); + expect(res.status).toBe(200); + }); +});