@oj-module/cli 0.1.17 → 0.1.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/devkit/README.md +11 -10
  2. package/devkit/SKILL.md +32 -18
  3. package/devkit/manual.md +332 -137
  4. package/package.json +2 -2
  5. package/shell-dist/assets/contract.js +0 -55
  6. package/shell-dist/assets/runtime.js +229 -213
  7. package/shell-dist/index.html +2 -2
  8. package/shell-dist/versions.json +5 -5
  9. package/src/build.ts +3 -0
  10. package/src/contract/__tests__/check-api.test.ts +74 -0
  11. package/src/contract/__tests__/emit-client.test.ts +132 -0
  12. package/src/contract/__tests__/emit-jsonschema-zod.test.ts +93 -0
  13. package/src/contract/__tests__/mock.test.ts +56 -0
  14. package/src/contract/__tests__/openapi-ir.test.ts +137 -0
  15. package/src/contract/__tests__/run-api.test.ts +67 -0
  16. package/src/contract/check.ts +67 -294
  17. package/src/contract/emit-client.ts +23 -15
  18. package/src/contract/emit-jsonschema-zod.ts +209 -0
  19. package/src/contract/emit-oj-schema.ts +108 -161
  20. package/src/contract/emit-stub.ts +17 -57
  21. package/src/contract/handler-scan.ts +263 -0
  22. package/src/contract/ir.ts +11 -179
  23. package/src/contract/json-schema-sample.ts +72 -0
  24. package/src/contract/mock.ts +28 -100
  25. package/src/contract/openapi-ir.ts +145 -0
  26. package/src/contract/run.ts +71 -120
  27. package/src/dev.ts +20 -18
  28. package/src/init.ts +18 -3
  29. package/src/oj.ts +6 -2
  30. package/src/vendor.ts +8 -4
  31. package/templates/api/config.yaml +1 -0
  32. package/templates/api/src/auth/api.ts +66 -0
  33. package/templates/api/src/notification/_shared/notify.ts +199 -0
  34. package/templates/api/src/notification/api.ts +231 -0
  35. package/templates/api/src/notification/del/api.ts +47 -0
  36. package/templates/api/src/notification/manifest.yaml +3 -1
  37. package/templates/api/src/notification/migrations/0001__create_notifications.sql +1 -1
  38. package/templates/api/src/notification/migrations/0002__notifications_user.sql +9 -0
  39. package/templates/api/src/notification/migrations/0003__notifications_broadcast.sql +22 -0
  40. package/templates/api/src/notification/schema.yaml +23 -1
  41. package/templates/api/src/notification/seed.sql +6 -4
  42. package/templates/api/src/personal-center/upload/api.ts +12 -1
  43. package/templates/env.d.ts +1 -1
  44. package/templates/openapi.json +471 -0
  45. package/templates/web/src/auth/__client/api.schemas.ts +8 -0
  46. package/templates/web/src/auth/__client/api.ts +110 -0
  47. package/templates/web/src/notification/__client/api.schemas.ts +50 -0
  48. package/templates/web/src/notification/{client → __client}/api.ts +29 -29
  49. package/templates/web/src/notification/entry.ts +11 -9
  50. package/templates/web/src/notifications/__client/api.schemas.ts +8 -0
  51. package/templates/web/src/notifications/__client/api.ts +80 -0
  52. package/templates/web/src/personal-center/__client/api.schemas.ts +10 -0
  53. package/templates/web/src/personal-center/__client/api.ts +88 -0
  54. package/templates/web/src/web/__client/api.schemas.ts +8 -0
  55. package/templates/web/src/web/__client/api.ts +110 -0
  56. package/vendor/host-versions.json +5 -5
  57. package/src/contract/emit-meta.ts +0 -175
  58. package/src/contract/emit-schema.ts +0 -133
  59. package/src/contract/evaluate.ts +0 -88
  60. package/templates/api/src/auth/login/api.ts +0 -27
  61. package/templates/api/src/auth/logout/api.ts +0 -10
  62. package/templates/api/src/auth/refresh/api.ts +0 -26
  63. package/templates/api/src/notification/contract.ts +0 -98
  64. package/templates/api/src/notification/notifications/api.ts +0 -24
  65. package/templates/api/src/notification/notifications/clear/api.ts +0 -13
  66. package/templates/api/src/notification/notifications/read/api.ts +0 -19
  67. package/templates/api/src/notification/notifications/read-all/api.ts +0 -13
  68. package/templates/api/src/personal-center/contract.ts +0 -16
  69. package/templates/web/src/notification/client/api.schemas.ts +0 -50
@@ -0,0 +1,74 @@
1
+ import type { OpenApiDoc } from "../openapi-ir";
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
6
+ import { checkApi } from "../check";
7
+ import { runApi } from "../run";
8
+
9
+ let dir: string;
10
+ beforeEach(() => {
11
+ dir = mkdtempSync(join(tmpdir(), "ojm-check-"));
12
+ });
13
+ afterEach(() => {
14
+ rmSync(dir, { recursive: true, force: true });
15
+ });
16
+
17
+ const DOC: OpenApiDoc = {
18
+ openapi: "3.1.0",
19
+ paths: {
20
+ "/v1/api/wiki/collections/{id}": {
21
+ get: {
22
+ operationId: "wiki_collections__id__get",
23
+ tags: ["wiki"],
24
+ parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
25
+ responses: {
26
+ 200: {
27
+ "description": "OK",
28
+ "x-oj-response-enforced": false,
29
+ "content": { "application/json": { schema: { type: "object", required: ["code", "msg", "data"], properties: { code: { type: "integer" }, msg: { type: "string" }, data: { type: "string" } } } } },
30
+ },
31
+ },
32
+ },
33
+ },
34
+ },
35
+ };
36
+
37
+ describe("checkApi 消费 openapi.json(新口径 Task #5)", () => {
38
+ it("__client 缺失 → artifact-stale 报错", async () => {
39
+ writeFileSync(join(dir, "openapi.json"), JSON.stringify(DOC));
40
+ const { violations } = await checkApi({ cwd: dir });
41
+ expect(violations.some(v => v.kind === "artifact-stale")).toBe(true);
42
+ });
43
+
44
+ it("ojm api 生成后重跑 --check → 无 artifact-stale", async () => {
45
+ writeFileSync(join(dir, "openapi.json"), JSON.stringify(DOC));
46
+ mkdirSync(join(dir, "web/src/wiki/__client"), { recursive: true });
47
+ await runApi({ cwd: dir, openapi: "openapi.json" });
48
+ const { violations } = await checkApi({ cwd: dir });
49
+ expect(violations.filter(v => v.kind === "artifact-stale").length).toBe(0);
50
+ });
51
+ });
52
+
53
+ describe("checkApi 豁免清单(D12/F19)覆盖契约→handler 侧", () => {
54
+ it("豁免模块的根级路由(route=\"/\" 但 handler .route 为模块名)不误报 route-params-mismatch", async () => {
55
+ // 模拟 api/.ojm-api-exempt.json 豁免 notifications 模块:oj 为根级兜底端点
56
+ // 生成 /api/notifications(route 推导为 "/"),而 handler 落在 api/src/notifications
57
+ // 模块根(实际 .route 即模块名 notifications)——两侧表示不同但线径一致,历史上误报
58
+ // route-params-mismatch;修复后「契约→handler」侧同样按豁免跳过。
59
+ writeFileSync(join(dir, "openapi.json"), JSON.stringify({
60
+ openapi: "3.1.0",
61
+ paths: {
62
+ "/v1/api/notifications": {
63
+ get: { operationId: "notifications_get", tags: ["notifications"], responses: { 200: { description: "OK" } } },
64
+ },
65
+ },
66
+ } satisfies OpenApiDoc));
67
+ mkdirSync(join(dir, "api/src/notifications"), { recursive: true });
68
+ writeFileSync(join(dir, "api/src/notifications/api.ts"), "export default { async get() { json.ok([]); } };\n");
69
+ mkdirSync(join(dir, "api"), { recursive: true });
70
+ writeFileSync(join(dir, "api/.ojm-api-exempt.json"), JSON.stringify({ modules: ["notifications"], paths: [] }));
71
+ const { violations } = await checkApi({ cwd: dir });
72
+ expect(violations.some(v => v.kind === "route-params-mismatch")).toBe(false);
73
+ });
74
+ });
@@ -0,0 +1,132 @@
1
+ import type { OpenApiDoc } from "../openapi-ir";
2
+ import { describe, expect, it } from "vitest";
3
+ import { emitClient } from "../emit-client";
4
+ import { irFromOpenapi } from "../openapi-ir";
5
+
6
+ const DOC: OpenApiDoc = {
7
+ openapi: "3.1.0",
8
+ paths: {
9
+ "/v1/api/wiki/collections/{id}": {
10
+ get: {
11
+ "operationId": "wiki_collections__id__get",
12
+ "tags": ["wiki"],
13
+ "x-oj-file": "wiki/collections/_id_/api.ts",
14
+ "parameters": [
15
+ { name: "id", in: "path", required: true, schema: { type: "integer", minimum: 1 } },
16
+ { name: "page", in: "query", required: false, schema: { type: "integer", minimum: 1 } },
17
+ ],
18
+ "responses": {
19
+ 200: {
20
+ "description": "OK",
21
+ "x-oj-response-enforced": false,
22
+ "content": {
23
+ "application/json": {
24
+ schema: {
25
+ type: "object",
26
+ required: ["code", "msg", "data"],
27
+ properties: {
28
+ code: { type: "integer" },
29
+ msg: { type: "string" },
30
+ data: {
31
+ type: "object",
32
+ required: ["list", "total"],
33
+ properties: {
34
+ list: { type: "array", items: { type: "object", properties: { id: { type: "integer" } } } },
35
+ total: { type: "integer", minimum: 0 },
36
+ },
37
+ },
38
+ },
39
+ },
40
+ },
41
+ },
42
+ },
43
+ },
44
+ },
45
+ },
46
+ "/v1/api/wiki/collections": {
47
+ post: {
48
+ operationId: "wiki_collections_post",
49
+ tags: ["wiki"],
50
+ requestBody: {
51
+ required: true,
52
+ content: {
53
+ "application/json": {
54
+ schema: {
55
+ type: "object",
56
+ required: ["title"],
57
+ additionalProperties: false,
58
+ properties: { title: { type: "string", maxLength: 80 }, tag: { type: ["string", "null"] } },
59
+ },
60
+ },
61
+ },
62
+ },
63
+ responses: { 200: { description: "OK" } },
64
+ },
65
+ },
66
+ },
67
+ };
68
+
69
+ function emit() {
70
+ const ir = irFromOpenapi(DOC, { base: "/v1/api" });
71
+ return emitClient(ir, { target: "module", module: "wiki" });
72
+ }
73
+
74
+ describe("emitClient (module target)", () => {
75
+ const { "api.ts": apiTs, "api.schemas.ts": schemasTs } = emit();
76
+
77
+ it("aPI_PREFIX / MODULE_PREFIX 用 JSON 字符串字面量(注入安全)", () => {
78
+ expect(apiTs).toContain("export const API_PREFIX = \"/wiki\";");
79
+ expect(apiTs).toContain("export const MODULE_PREFIX = \"/wiki\";");
80
+ });
81
+
82
+ it("端点函数名 camelCase", () => {
83
+ expect(apiTs).toContain("export async function wikiCollectionsIdGet(");
84
+ });
85
+
86
+ it("信封解包到强类型 data(PascalCase 返回类型名)", () => {
87
+ expect(apiTs).toContain("env.data as WikiCollectionsIdGetData");
88
+ expect(apiTs).toContain("Promise<WikiCollectionsIdGetData>");
89
+ });
90
+
91
+ it("dEV 下对 data 做 safeParse 校验(契约违例兜底)", () => {
92
+ expect(apiTs).toContain("schemas[\"wikiCollectionsIdGet\"].data.safeParse(data)");
93
+ });
94
+
95
+ it("客户端不含 z.regex(pattern 不进运行时)", () => {
96
+ expect(apiTs).not.toContain("z.regex");
97
+ });
98
+
99
+ it("api.schemas.ts 用 JSON 字符串字面量作端点键", () => {
100
+ expect(schemasTs).toContain("\"wikiCollectionsIdGet\":");
101
+ expect(schemasTs).toContain("\"wikiCollectionsPost\":");
102
+ });
103
+
104
+ it("params id → z.number().int().min(1);body tag 类型数组 → .nullable()", () => {
105
+ expect(schemasTs).toContain("\"id\": z.number().int().min(1)");
106
+ expect(schemasTs).toContain("\"tag\": z.string().nullable().optional()");
107
+ });
108
+
109
+ it("body additionalProperties:false → .strict()", () => {
110
+ expect(schemasTs).toContain("}).strict()");
111
+ });
112
+ });
113
+
114
+ describe("emitClient 注入安全:模块名含特殊字符不破坏源码", () => {
115
+ it("module 名含 -/$ 时 MODULE_PREFIX/工厂名仍走字符串字面量(API_PREFIX 取 IR wirePrefix)", () => {
116
+ const ir = irFromOpenapi(DOC, { base: "/v1/api" });
117
+ const { "api.ts": apiTs } = emitClient(ir, { target: "module", module: "weird-name" });
118
+ // module 选项只影响 MODULE_PREFIX 与 create<Module>Client 名
119
+ expect(apiTs).toContain("export const MODULE_PREFIX = \"/weird-name\";");
120
+ expect(apiTs).toContain("export function createWeirdNameClient(");
121
+ // 前缀均为字符串字面量,无裸插值
122
+ expect(apiTs).not.toContain("MODULE_PREFIX = /weird-name;");
123
+ // API_PREFIX 取自 IR 的 wirePrefix(/wiki),与 module 选项无关
124
+ expect(apiTs).toContain("export const API_PREFIX = \"/wiki\";");
125
+ });
126
+ });
127
+
128
+ describe("emitClient 空 IR 报错", () => {
129
+ it("iR 为空抛错(无端点无需生成)", () => {
130
+ expect(() => emitClient([], { target: "module", module: "x" })).toThrow();
131
+ });
132
+ });
@@ -0,0 +1,93 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { emitZodFromJsonSchema } from "../emit-jsonschema-zod";
3
+
4
+ describe("emitZodFromJsonSchema", () => {
5
+ it("object:required 无 .optional(),其余 .optional(),additionalProperties:false→.strict()", () => {
6
+ expect(emitZodFromJsonSchema({
7
+ type: "object",
8
+ required: ["title"],
9
+ additionalProperties: false,
10
+ properties: { title: { type: "string", maxLength: 80 }, remark: { type: "string", nullable: true } },
11
+ }, "x")).toBe(
12
+ "z.object({\n\t\"title\": z.string().max(80),\n\t\"remark\": z.string().nullable().optional()\n}).strict()",
13
+ );
14
+ });
15
+
16
+ it("类型数组 [\"string\",\"null\"] → .nullable()", () => {
17
+ expect(emitZodFromJsonSchema({ type: ["string", "null"] }, "x")).toBe("z.string().nullable()");
18
+ });
19
+
20
+ it("类型数组 [\"string\",\"number\"](oj 翻 z.union)→ z.union([z.string(), z.number()])", () => {
21
+ // oj 把 z.union([z.string(), z.number()]) 翻成类型数组,codegen 必须兜住(映射表补充)
22
+ expect(emitZodFromJsonSchema({ type: ["string", "number"] }, "x")).toBe("z.union([z.string(), z.number()])");
23
+ });
24
+
25
+ it("类型数组多实类型 + null → z.union(...).nullable()", () => {
26
+ expect(emitZodFromJsonSchema({ type: ["string", "number", "null"] }, "x"))
27
+ .toBe("z.union([z.string(), z.number()]).nullable()");
28
+ });
29
+
30
+ it("pattern 不进 z.regex,仅发 // @oj-warn 注释(ReDoS + 跨语言漂移防护)", () => {
31
+ const out = emitZodFromJsonSchema({ type: "string", pattern: "^[a-z]+$" }, "x");
32
+ expect(out).toContain("// @oj-warn: pattern=");
33
+ expect(out).not.toContain("z.regex");
34
+ });
35
+
36
+ it("default 不射 .default(),仅发 // @oj-note 注释", () => {
37
+ const out = emitZodFromJsonSchema({ type: "integer", default: 0 }, "x");
38
+ expect(out).toContain("@oj-note: default=0");
39
+ expect(out).not.toContain(".default(");
40
+ });
41
+
42
+ it("数值 enum → z.union([z.literal...]);字符串 enum → z.enum", () => {
43
+ // enum 是独立基础类型(zod v4 无 z.number().enum()/z.string().enum(),
44
+ // 挂在 typed base 后是运行期 TypeError 根因)——故作为 base 整体返回
45
+ expect(emitZodFromJsonSchema({ type: "integer", enum: [0, 1, 2] }, "x"))
46
+ .toBe("z.union([z.literal(0), z.literal(1), z.literal(2)])");
47
+ expect(emitZodFromJsonSchema({ type: "string", enum: ["a", "b"] }, "x"))
48
+ .toBe("z.enum([\"a\", \"b\"])");
49
+ });
50
+
51
+ it("required 项不在 properties 中 → 不抛错、跳过缺失键", () => {
52
+ expect(emitZodFromJsonSchema({ type: "object", required: ["ghost"], properties: { a: { type: "string" } } }, "x"))
53
+ .toBe("z.object({\n\t\"a\": z.string().optional()\n})");
54
+ });
55
+
56
+ it("注入安全:属性名经 JSON.stringify 成字符串字面量键,无裸模板插值", () => {
57
+ // 拼接构造含 ${
58
+ // 的键名:字面量里写 ${ 会触发 no-template-curly-in-string
59
+ const nastyKey = ["a", "$", "{b}"].join("");
60
+ const out = emitZodFromJsonSchema({ type: "object", properties: { "`": { type: "string" }, [nastyKey]: { type: "string" }, "*/": { type: "string" } } }, "x");
61
+ expect(out).toMatch(/"a\$\{b\}":/);
62
+ expect(out).toMatch(/"`":/);
63
+ });
64
+
65
+ it("白名单外关键字 fail-fast", () => {
66
+ expect(() => emitZodFromJsonSchema({ type: "string", format: "email" } as never, "x")).toThrow();
67
+ });
68
+
69
+ it("array:items + minItems/maxItems", () => {
70
+ expect(emitZodFromJsonSchema({ type: "array", minItems: 1, maxItems: 20, items: { type: "string", pattern: "^[a-z]+$" } }, "x"))
71
+ .toBe("z.array(// @oj-warn: pattern=\"^[a-z]+$\" 为 Rust regex,未在前端执行(跨语言语义漂移/ReDoS 风险)\n\tz.string()).min(1).max(20)");
72
+ });
73
+
74
+ it("object nullable → .strict().nullable()(补齐标量外的 nullable 缺口)", () => {
75
+ expect(emitZodFromJsonSchema({ type: "object", nullable: true, additionalProperties: false, properties: { a: { type: "string" } } }, "x"))
76
+ .toBe("z.object({\n\t\"a\": z.string().optional()\n}).strict().nullable()");
77
+ });
78
+
79
+ it("array nullable → z.array(...).nullable()", () => {
80
+ expect(emitZodFromJsonSchema({ type: "array", nullable: true, items: { type: "integer" } }, "x"))
81
+ .toBe("z.array(z.number().int()).nullable()");
82
+ });
83
+
84
+ it("additionalProperties:true → .catchall(z.unknown())(任意额外键放行)", () => {
85
+ expect(emitZodFromJsonSchema({ type: "object", additionalProperties: true, properties: { a: { type: "string" } } }, "x"))
86
+ .toBe("z.object({\n\t\"a\": z.string().optional()\n}).catchall(z.unknown())");
87
+ });
88
+
89
+ it("additionalProperties:schema → .catchall(emitZod(schema))(额外键须符合子 schema)", () => {
90
+ expect(emitZodFromJsonSchema({ type: "object", additionalProperties: { type: "string", maxLength: 8 }, properties: { a: { type: "string" } } }, "x"))
91
+ .toBe("z.object({\n\t\"a\": z.string().optional()\n}).catchall(z.string().max(8))");
92
+ });
93
+ });
@@ -0,0 +1,56 @@
1
+ import type { OpenApiDoc } from "../openapi-ir";
2
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
6
+ import { loadContractMocks, resolveMock } from "../mock";
7
+
8
+ let dir: string;
9
+ beforeEach(() => {
10
+ dir = mkdtempSync(join(tmpdir(), "ojm-mock-"));
11
+ });
12
+ afterEach(() => {
13
+ rmSync(dir, { recursive: true, force: true });
14
+ });
15
+
16
+ const DOC: OpenApiDoc = {
17
+ openapi: "3.1.0",
18
+ paths: {
19
+ "/v1/api/wiki/collections/{id}": {
20
+ get: {
21
+ operationId: "wiki_collections__id__get",
22
+ tags: ["wiki"],
23
+ parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
24
+ responses: {
25
+ 200: {
26
+ "description": "OK",
27
+ "x-oj-response-enforced": false,
28
+ "content": { "application/json": { schema: { type: "object", required: ["code", "msg", "data"], properties: { code: { type: "integer" }, msg: { type: "string" }, data: { type: "object", required: ["title"], properties: { title: { type: "string", maxLength: 10 }, count: { type: "integer", minimum: 1 } } } } } } },
29
+ },
30
+ },
31
+ },
32
+ },
33
+ },
34
+ };
35
+
36
+ describe("dev mock 消费 openapi.json(新口径 Task #6)", () => {
37
+ it("loadContractMocks 由 openapi.json 装载路由,data 为 JSON Schema", async () => {
38
+ writeFileSync(join(dir, "openapi.json"), JSON.stringify(DOC));
39
+ const routes = await loadContractMocks(dir);
40
+ expect(routes.length).toBe(1);
41
+ expect(routes[0].method).toBe("GET");
42
+ expect(routes[0].pattern).toBe("/wiki/collections/{id}");
43
+ expect((routes[0].data as Record<string, unknown>).type).toBe("object");
44
+ });
45
+
46
+ it("resolveMock 用 JSON Schema 生成示例 data(满足 maxLength/minimum 约束)", async () => {
47
+ writeFileSync(join(dir, "openapi.json"), JSON.stringify(DOC));
48
+ const routes = await loadContractMocks(dir);
49
+ const fn = resolveMock([], routes, "GET", "/wiki/collections/5");
50
+ expect(fn).toBeTypeOf("function");
51
+ const res = fn!() as { code: number, msg: string, data: { title: string, count: number } };
52
+ expect(res.code).toBe(0);
53
+ expect(res.data.title.length).toBeLessThanOrEqual(10);
54
+ expect(res.data.count).toBeGreaterThanOrEqual(1);
55
+ });
56
+ });
@@ -0,0 +1,137 @@
1
+ import type { OpenApiDoc } from "../openapi-ir";
2
+ import { describe, expect, it } from "vitest";
3
+ import { camelCaseOperationId, irFromOpenapi } from "../openapi-ir";
4
+
5
+ const DOC: OpenApiDoc = {
6
+ openapi: "3.1.0",
7
+ paths: {
8
+ "/v1/api/wiki/collections/{id}": {
9
+ get: {
10
+ "operationId": "wiki_collections__id__get",
11
+ "tags": ["wiki"],
12
+ "x-oj-file": "wiki/collections/_id_/api.ts",
13
+ "parameters": [
14
+ { name: "id", in: "path", required: true, schema: { type: "integer", minimum: 1 } },
15
+ { name: "page", in: "query", required: false, schema: { type: "integer", minimum: 1 } },
16
+ ],
17
+ "responses": {
18
+ 200: {
19
+ "description": "OK",
20
+ "x-oj-response-enforced": false,
21
+ "content": {
22
+ "application/json": {
23
+ schema: {
24
+ type: "object",
25
+ required: ["code", "msg", "data"],
26
+ properties: {
27
+ code: { type: "integer" },
28
+ msg: { type: "string" },
29
+ data: {
30
+ type: "object",
31
+ required: ["list", "total"],
32
+ properties: {
33
+ list: { type: "array", items: { type: "object", properties: { id: { type: "integer" } } } },
34
+ total: { type: "integer", minimum: 0 },
35
+ },
36
+ },
37
+ },
38
+ },
39
+ },
40
+ },
41
+ },
42
+ },
43
+ },
44
+ },
45
+ "/v1/api/wiki/collections": {
46
+ post: {
47
+ operationId: "wiki_collections_post",
48
+ tags: ["wiki"],
49
+ requestBody: {
50
+ required: true,
51
+ content: {
52
+ "application/json": {
53
+ schema: {
54
+ type: "object",
55
+ required: ["title"],
56
+ additionalProperties: false,
57
+ properties: { title: { type: "string", maxLength: 80 } },
58
+ },
59
+ },
60
+ },
61
+ },
62
+ responses: { 200: { description: "OK" } },
63
+ },
64
+ },
65
+ "/v1/api/wiki/plain": {
66
+ get: {
67
+ operationId: "wiki_plain_get",
68
+ tags: ["wiki"],
69
+ responses: { 200: { description: "OK" } },
70
+ },
71
+ },
72
+ },
73
+ };
74
+
75
+ describe("camelCaseOperationId", () => {
76
+ it("折叠 __ 路径参数边界为单一 camelCase", () => {
77
+ expect(camelCaseOperationId("wiki_collections__id__get")).toBe("wikiCollectionsIdGet");
78
+ expect(camelCaseOperationId("notification_notifications_get")).toBe("notificationNotificationsGet");
79
+ });
80
+ });
81
+
82
+ describe("irFromOpenapi", () => {
83
+ const ir = irFromOpenapi(DOC, { base: "/v1/api" });
84
+
85
+ it("剥 base 前缀、取模块、camelCase 命名", () => {
86
+ const ep = ir.find(e => e.name === "wikiCollectionsIdGet")!;
87
+ expect(ep.route).toBe("/collections/{id}");
88
+ expect(ep.apiPrefix).toBe("/wiki");
89
+ expect(ir.find(e => e.name === "wikiCollectionsPost")).toBeDefined();
90
+ });
91
+
92
+ it("params/query 按 in 通道合并,required 收集到数组", () => {
93
+ const ep = ir.find(e => e.name === "wikiCollectionsIdGet")!;
94
+ expect(ep.paramsSchema).toEqual({
95
+ type: "object",
96
+ required: ["id"],
97
+ properties: { id: { type: "integer", minimum: 1 } },
98
+ });
99
+ expect(ep.querySchema).toEqual({
100
+ type: "object",
101
+ required: [],
102
+ properties: { page: { type: "integer", minimum: 1 } },
103
+ });
104
+ expect(ep.paramNames).toEqual(["id"]);
105
+ });
106
+
107
+ it("requestBody → bodySchema", () => {
108
+ const ep = ir.find(e => e.name === "wikiCollectionsPost")!;
109
+ expect(ep.bodySchema).toEqual({
110
+ type: "object",
111
+ required: ["title"],
112
+ additionalProperties: false,
113
+ properties: { title: { type: "string", maxLength: 80 } },
114
+ });
115
+ });
116
+
117
+ it("带 x-oj-response-enforced 的端点解包 data,无 response 的端点 dataSchema=undefined", () => {
118
+ const withResp = ir.find(e => e.name === "wikiCollectionsIdGet")!;
119
+ expect(withResp.dataSchema).toEqual({
120
+ type: "object",
121
+ required: ["list", "total"],
122
+ properties: {
123
+ list: { type: "array", items: { type: "object", properties: { id: { type: "integer" } } } },
124
+ total: { type: "integer", minimum: 0 },
125
+ },
126
+ });
127
+ const plain = ir.find(e => e.name === "wikiPlainGet")!;
128
+ expect(plain.dataSchema).toBeUndefined();
129
+ expect(plain.raw).toBe(false);
130
+ });
131
+
132
+ it("base 缺省时从 path + module 反推,route 仍为模块相对", () => {
133
+ const ir2 = irFromOpenapi(DOC);
134
+ const ep = ir2.find(e => e.name === "wikiCollectionsIdGet")!;
135
+ expect(ep.route).toBe("/collections/{id}");
136
+ });
137
+ });
@@ -0,0 +1,67 @@
1
+ import type { OpenApiDoc } from "../openapi-ir";
2
+ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
6
+ import { runApi } from "../run";
7
+
8
+ let dir: string;
9
+ beforeEach(() => {
10
+ dir = mkdtempSync(join(tmpdir(), "ojm-run-"));
11
+ });
12
+ afterEach(() => {
13
+ rmSync(dir, { recursive: true, force: true });
14
+ });
15
+
16
+ const DOC: OpenApiDoc = {
17
+ openapi: "3.1.0",
18
+ paths: {
19
+ "/v1/api/wiki/collections/{id}": {
20
+ get: {
21
+ operationId: "wiki_collections__id__get",
22
+ tags: ["wiki"],
23
+ parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
24
+ responses: {
25
+ 200: {
26
+ "description": "OK",
27
+ "x-oj-response-enforced": false,
28
+ "content": { "application/json": { schema: { type: "object", required: ["code", "msg", "data"], properties: { code: { type: "integer" }, msg: { type: "string" }, data: { type: "string" } } } } },
29
+ },
30
+ },
31
+ },
32
+ },
33
+ "/v1/api/user/profile": {
34
+ get: {
35
+ operationId: "user_profile_get",
36
+ tags: ["user"],
37
+ responses: { 200: { description: "OK" } },
38
+ },
39
+ },
40
+ },
41
+ };
42
+
43
+ describe("runApi 消费 openapi.json(新口径 Task #4)", () => {
44
+ it("按模块分组生成 web/src/<模块>/__client/{api.ts,api.schemas.ts}", async () => {
45
+ writeFileSync(join(dir, "openapi.json"), JSON.stringify(DOC));
46
+ const result = await runApi({ cwd: dir, openapi: "openapi.json" });
47
+ expect(result.contracts).toBe(2);
48
+
49
+ const wikiApi = join(dir, "web/src/wiki/__client/api.ts");
50
+ const wikiSchemas = join(dir, "web/src/wiki/__client/api.schemas.ts");
51
+ const userApi = join(dir, "web/src/user/__client/api.ts");
52
+ expect(existsSync(wikiApi)).toBe(true);
53
+ expect(existsSync(wikiSchemas)).toBe(true);
54
+ expect(existsSync(userApi)).toBe(true);
55
+
56
+ expect(readFileSync(wikiApi, "utf8")).toContain("export async function wikiCollectionsIdGet(");
57
+ expect(readFileSync(wikiSchemas, "utf8")).toContain("\"wikiCollectionsIdGet\":");
58
+ // 幂等:第二次无变化应跳过
59
+ const result2 = await runApi({ cwd: dir, openapi: "openapi.json" });
60
+ expect(result2.written.length).toBe(0);
61
+ expect(result2.skipped.length).toBeGreaterThan(0);
62
+ });
63
+
64
+ it("无 openapi.json 且无 contract.ts → 报错指引", async () => {
65
+ await expect(runApi({ cwd: dir })).rejects.toThrow(/openapi\.json/);
66
+ });
67
+ });