@oj-module/cli 0.1.17 → 0.1.20
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.
- package/devkit/README.md +11 -10
- package/devkit/SKILL.md +32 -18
- package/devkit/manual.md +318 -131
- package/package.json +2 -2
- package/shell-dist/assets/contract.js +0 -55
- package/shell-dist/assets/runtime.js +206 -212
- package/shell-dist/index.html +2 -2
- package/shell-dist/versions.json +5 -5
- package/src/build.ts +1 -0
- package/src/contract/__tests__/check-api.test.ts +74 -0
- package/src/contract/__tests__/emit-client.test.ts +132 -0
- package/src/contract/__tests__/emit-jsonschema-zod.test.ts +93 -0
- package/src/contract/__tests__/mock.test.ts +56 -0
- package/src/contract/__tests__/openapi-ir.test.ts +137 -0
- package/src/contract/__tests__/run-api.test.ts +67 -0
- package/src/contract/check.ts +67 -294
- package/src/contract/emit-client.ts +23 -15
- package/src/contract/emit-jsonschema-zod.ts +209 -0
- package/src/contract/emit-oj-schema.ts +108 -161
- package/src/contract/emit-stub.ts +17 -57
- package/src/contract/handler-scan.ts +263 -0
- package/src/contract/ir.ts +11 -179
- package/src/contract/json-schema-sample.ts +72 -0
- package/src/contract/mock.ts +28 -100
- package/src/contract/openapi-ir.ts +145 -0
- package/src/contract/run.ts +71 -120
- package/src/dev.ts +20 -18
- package/src/init.ts +18 -3
- package/src/oj.ts +6 -2
- package/src/vendor.ts +8 -4
- package/templates/api/config.yaml +1 -0
- package/templates/api/src/auth/api.ts +66 -0
- package/templates/api/src/notification/_shared/notify.ts +199 -0
- package/templates/api/src/notification/api.ts +231 -0
- package/templates/api/src/notification/del/api.ts +47 -0
- package/templates/api/src/notification/manifest.yaml +3 -1
- package/templates/api/src/notification/migrations/0001__create_notifications.sql +1 -1
- package/templates/api/src/notification/migrations/0002__notifications_user.sql +9 -0
- package/templates/api/src/notification/migrations/0003__notifications_broadcast.sql +22 -0
- package/templates/api/src/notification/schema.yaml +23 -1
- package/templates/api/src/notification/seed.sql +6 -4
- package/templates/api/src/personal-center/upload/api.ts +12 -1
- package/templates/env.d.ts +1 -1
- package/templates/openapi.json +471 -0
- package/templates/web/src/auth/__client/api.schemas.ts +8 -0
- package/templates/web/src/auth/__client/api.ts +110 -0
- package/templates/web/src/notification/__client/api.schemas.ts +50 -0
- package/templates/web/src/notification/{client → __client}/api.ts +29 -29
- package/templates/web/src/notification/entry.ts +11 -9
- package/templates/web/src/notifications/__client/api.schemas.ts +8 -0
- package/templates/web/src/notifications/__client/api.ts +80 -0
- package/templates/web/src/personal-center/__client/api.schemas.ts +10 -0
- package/templates/web/src/personal-center/__client/api.ts +88 -0
- package/templates/web/src/web/__client/api.schemas.ts +8 -0
- package/templates/web/src/web/__client/api.ts +110 -0
- package/vendor/host-versions.json +5 -5
- package/src/contract/emit-meta.ts +0 -175
- package/src/contract/emit-schema.ts +0 -133
- package/src/contract/evaluate.ts +0 -88
- package/templates/api/src/auth/login/api.ts +0 -27
- package/templates/api/src/auth/logout/api.ts +0 -10
- package/templates/api/src/auth/refresh/api.ts +0 -26
- package/templates/api/src/notification/contract.ts +0 -98
- package/templates/api/src/notification/notifications/api.ts +0 -24
- package/templates/api/src/notification/notifications/clear/api.ts +0 -13
- package/templates/api/src/notification/notifications/read/api.ts +0 -19
- package/templates/api/src/notification/notifications/read-all/api.ts +0 -13
- package/templates/api/src/personal-center/contract.ts +0 -16
- package/templates/web/src/notification/client/api.schemas.ts +0 -50
|
@@ -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
|
+
});
|