@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,209 @@
|
|
|
1
|
+
import type { JsonSchema } from "./openapi-ir";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* JSON Schema(oj `.schema` 白名单子集)→ zod 源码表达式。
|
|
5
|
+
*
|
|
6
|
+
* 单一职责(SRP):只把 openapi.json 里的 JSON Schema 翻成 zod 源码字符串,不碰 IR / 文件 IO。
|
|
7
|
+
*
|
|
8
|
+
* 安全铁律(来自安全评审):
|
|
9
|
+
* 1. 所有动态字符串(属性名 / enum 值 / default 值 / pattern / 路径)一律 `jsonLit`(JSON.stringify)
|
|
10
|
+
* 嵌入**普通字符串字面量**,绝不走模板插值——openapi.json 值是任意 JSON 字符串,可含反引号、
|
|
11
|
+
* 斜杠星、换行、模板插值符,模板插值会破坏产物或注入。
|
|
12
|
+
* 2. `pattern` 不进运行时 `z.regex`:oj 的 pattern 是 Rust regex(线性引擎),JS `RegExp` 是回溯
|
|
13
|
+
* 引擎——原样执行既有 ReDoS 灾难性回溯风险,又有 Rust 拒/JS 放行的语义漂移。仅发 `// @oj-warn`
|
|
14
|
+
* 注释(用 `//` 行注释,pattern 经 JSON.stringify 已是单行转义串,安全)。
|
|
15
|
+
* 3. `default` 不射 `.default()`:oj 运行期不为缺参补默认值,前端若补会让可选字段变必填、掩盖契约
|
|
16
|
+
* 漂移。仅发 `// @oj-note` 注释。
|
|
17
|
+
* 4. 白名单外关键字 fail-fast(绝不静默降级);`required` 项可不在 `properties` 中(JSON Schema 允许),
|
|
18
|
+
* 取值前先判存在(缺失键直接跳过)。
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** oj `.schema` 白名单关键字(openapi-guide §2.6),其余 fail-fast */
|
|
22
|
+
const ALLOWED_KEYWORDS = new Set([
|
|
23
|
+
"type",
|
|
24
|
+
"required",
|
|
25
|
+
"properties",
|
|
26
|
+
"items",
|
|
27
|
+
"additionalProperties",
|
|
28
|
+
"minimum",
|
|
29
|
+
"maximum",
|
|
30
|
+
"minLength",
|
|
31
|
+
"maxLength",
|
|
32
|
+
"pattern",
|
|
33
|
+
"enum",
|
|
34
|
+
"nullable",
|
|
35
|
+
"minItems",
|
|
36
|
+
"maxItems",
|
|
37
|
+
"default",
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
/** 任意 JSON 值 → 源码安全字面量(字符串/数字/布尔/null 一律 JSON.stringify) */
|
|
41
|
+
function jsonLit(v: unknown): string {
|
|
42
|
+
return JSON.stringify(v);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function fail(where: string, msg: string): never {
|
|
46
|
+
throw new Error(`[ojm-api] openapi→zod 失败(${where}):${msg}`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* enum → zod 基础表达式:全字符串用 `z.enum([...])`,否则(数值/布尔/混合)用
|
|
51
|
+
* `z.union([z.literal(...)])`。注意 enum 是**独立基础类型**,绝不能挂在 `z.string()`/
|
|
52
|
+
* `z.number()` 之后(`z.string().enum()` 在 zod 里不存在)——故此处返回完整表达式,
|
|
53
|
+
* 由 emitScalar 作为 base 使用,而非作为检查链追加。
|
|
54
|
+
*/
|
|
55
|
+
function emitEnum(values: unknown[], _where: string): string {
|
|
56
|
+
const allString = values.every(v => typeof v === "string");
|
|
57
|
+
if (allString)
|
|
58
|
+
return `z.enum([${values.map(jsonLit).join(", ")}])`;
|
|
59
|
+
return `z.union([${values.map(v => `z.literal(${jsonLit(v)})`).join(", ")}])`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* JSON Schema type → zod 基础表达式,并**统一**叠加标量约束(min/max)与
|
|
64
|
+
* nullable(借鉴 json-schema-to-zod 的「基础类型 + 修饰符」结构)。
|
|
65
|
+
*
|
|
66
|
+
* 关键修正(对照 json-schema-to-zod 映射表补齐口径内缺口):
|
|
67
|
+
* - enum 优先作为 base(见 emitEnum):挂在 `z.string()` 后(`z.string().enum()`)
|
|
68
|
+
* 在 zod 里不存在,是运行期 TypeError 根因。
|
|
69
|
+
* - object/array 也走 base + checks 合并,故 `nullable:true` 对对象/数组同样生效
|
|
70
|
+
* (旧实现 object/array 提前 return,导致对象/数组的 nullable 被静默丢弃)。
|
|
71
|
+
* - pattern/default 仍只发注释(Rust regex 不进运行时 / oj 运行期不补值),不射进 zod。
|
|
72
|
+
*/
|
|
73
|
+
function emitScalar(type: string, schema: JsonSchema, where: string): string {
|
|
74
|
+
// enum 是独立基础类型(z.enum / z.union),不得追加到 typed base 之后
|
|
75
|
+
if (Array.isArray(schema.enum))
|
|
76
|
+
return emitEnumWithNullable(schema.enum, schema, where);
|
|
77
|
+
|
|
78
|
+
let base: string;
|
|
79
|
+
switch (type) {
|
|
80
|
+
case "string":
|
|
81
|
+
base = "z.string()";
|
|
82
|
+
break;
|
|
83
|
+
case "number":
|
|
84
|
+
base = "z.number()";
|
|
85
|
+
break;
|
|
86
|
+
case "integer":
|
|
87
|
+
base = "z.number().int()";
|
|
88
|
+
break;
|
|
89
|
+
case "boolean":
|
|
90
|
+
base = "z.boolean()";
|
|
91
|
+
break;
|
|
92
|
+
case "null":
|
|
93
|
+
base = "z.null()";
|
|
94
|
+
break;
|
|
95
|
+
case "object":
|
|
96
|
+
base = emitObject(schema, where);
|
|
97
|
+
break;
|
|
98
|
+
case "array":
|
|
99
|
+
base = emitArray(schema, where);
|
|
100
|
+
break;
|
|
101
|
+
default:
|
|
102
|
+
fail(where, `未知 type "${type}"`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const checks: string[] = [];
|
|
106
|
+
if (type === "string") {
|
|
107
|
+
if (typeof schema.minLength === "number")
|
|
108
|
+
checks.push(`.min(${schema.minLength})`);
|
|
109
|
+
if (typeof schema.maxLength === "number")
|
|
110
|
+
checks.push(`.max(${schema.maxLength})`);
|
|
111
|
+
}
|
|
112
|
+
else if (type === "number" || type === "integer") {
|
|
113
|
+
if (typeof schema.minimum === "number")
|
|
114
|
+
checks.push(`.min(${schema.minimum})`);
|
|
115
|
+
if (typeof schema.maximum === "number")
|
|
116
|
+
checks.push(`.max(${schema.maximum})`);
|
|
117
|
+
}
|
|
118
|
+
if (schema.nullable === true)
|
|
119
|
+
checks.push(".nullable()");
|
|
120
|
+
|
|
121
|
+
let out = `${base}${checks.join("")}`;
|
|
122
|
+
// pattern:Rust regex,不进运行时(ReDoS + 语义漂移),仅注释
|
|
123
|
+
if (typeof schema.pattern === "string")
|
|
124
|
+
out = `// @oj-warn: pattern=${jsonLit(schema.pattern)} 为 Rust regex,未在前端执行(跨语言语义漂移/ReDoS 风险)\n\t${out}`;
|
|
125
|
+
// default:oj 运行期不补值,仅文档注释
|
|
126
|
+
if (schema.default !== undefined)
|
|
127
|
+
out = `// @oj-note: default=${jsonLit(schema.default)}(oj 不补值,仅文档)\n\t${out}`;
|
|
128
|
+
return out;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** enum 基础表达式 + nullable(enum 无法叠加 min/max,仅 nullable 有意义) */
|
|
132
|
+
function emitEnumWithNullable(values: unknown[], schema: JsonSchema, where: string): string {
|
|
133
|
+
let out = emitEnum(values, where);
|
|
134
|
+
if (schema.nullable === true)
|
|
135
|
+
out += ".nullable()";
|
|
136
|
+
if (typeof schema.pattern === "string")
|
|
137
|
+
out = `// @oj-warn: pattern=${jsonLit(schema.pattern)} 为 Rust regex,未在前端执行(跨语言语义漂移/ReDoS 风险)\n\t${out}`;
|
|
138
|
+
if (schema.default !== undefined)
|
|
139
|
+
out = `// @oj-note: default=${jsonLit(schema.default)}(oj 不补值,仅文档)\n\t${out}`;
|
|
140
|
+
return out;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* object → zod。除 properties/required 外,处理 additionalProperties 三态
|
|
145
|
+
* (借鉴 json-schema-to-zod 映射):
|
|
146
|
+
* - `false` → `.strict()`(仅声明属性,拒额外键)
|
|
147
|
+
* - `true` → `.catchall(z.unknown())`(任意额外键放行;对应 oj 未限制额外字段)
|
|
148
|
+
* - schema → `.catchall(emitZod(schema))`(额外键须符合该子 schema)
|
|
149
|
+
* `.strict()` 与 `.catchall()` 在 zod 互斥,三者择一。
|
|
150
|
+
*/
|
|
151
|
+
function emitObject(schema: JsonSchema, where: string): string {
|
|
152
|
+
const props = (schema.properties ?? {}) as Record<string, JsonSchema>;
|
|
153
|
+
const required: string[] = Array.isArray(schema.required) ? schema.required : [];
|
|
154
|
+
const members = Object.entries(props).map(([name, sub]) => {
|
|
155
|
+
const expr = emitZodFromJsonSchema(sub, `${where}.${name}`);
|
|
156
|
+
const opt = required.includes(name) ? "" : ".optional()";
|
|
157
|
+
return `\t${jsonLit(name)}: ${expr}${opt}`;
|
|
158
|
+
});
|
|
159
|
+
let out = `z.object({\n${members.join(",\n")}\n})`;
|
|
160
|
+
const ap = schema.additionalProperties;
|
|
161
|
+
if (ap === false)
|
|
162
|
+
out += ".strict()";
|
|
163
|
+
else if (ap === true)
|
|
164
|
+
out += ".catchall(z.unknown())";
|
|
165
|
+
else if (ap && typeof ap === "object")
|
|
166
|
+
out += `.catchall(${emitZodFromJsonSchema(ap as JsonSchema, `${where}<additionalProperties>`)})`;
|
|
167
|
+
return out;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function emitArray(schema: JsonSchema, where: string): string {
|
|
171
|
+
const items = schema.items as JsonSchema | undefined;
|
|
172
|
+
const itemExpr = items ? emitZodFromJsonSchema(items, `${where}[]`) : "z.unknown()";
|
|
173
|
+
let out = `z.array(${itemExpr})`;
|
|
174
|
+
if (typeof schema.minItems === "number")
|
|
175
|
+
out += `.min(${schema.minItems})`;
|
|
176
|
+
if (typeof schema.maxItems === "number")
|
|
177
|
+
out += `.max(${schema.maxItems})`;
|
|
178
|
+
return out;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** JSON Schema → zod 源码表达式(如 `z.object({...})`) */
|
|
182
|
+
export function emitZodFromJsonSchema(schema: JsonSchema, where: string): string {
|
|
183
|
+
if (!schema || typeof schema !== "object")
|
|
184
|
+
return "z.unknown()";
|
|
185
|
+
for (const k of Object.keys(schema)) {
|
|
186
|
+
if (!ALLOWED_KEYWORDS.has(k))
|
|
187
|
+
fail(where, `不支持的关键字 "${k}"(oj .schema 白名单之外,绝不静默降级)`);
|
|
188
|
+
}
|
|
189
|
+
const type = schema.type;
|
|
190
|
+
if (Array.isArray(type)) {
|
|
191
|
+
const nonNull = type.filter((t): t is string => t !== "null");
|
|
192
|
+
// 单实类型 + null = nullable(["string","null"] 标准写法)
|
|
193
|
+
if (nonNull.length === 1) {
|
|
194
|
+
const expr = emitScalar(nonNull[0], schema, where);
|
|
195
|
+
return type.includes("null") ? `${expr}.nullable()` : expr;
|
|
196
|
+
}
|
|
197
|
+
// 多实类型(如 oj 把 z.union([z.string(),z.number()]) 翻成 ["string","number"]):
|
|
198
|
+
// 映射为 z.union,nullable 叠加在外层。oj 产物确有此形状,codegen 必须兜住。
|
|
199
|
+
const members = nonNull.map(t => emitScalar(t, schema, where));
|
|
200
|
+
let out = `z.union([${members.join(", ")}])`;
|
|
201
|
+
if (type.includes("null"))
|
|
202
|
+
out += ".nullable()";
|
|
203
|
+
return out;
|
|
204
|
+
}
|
|
205
|
+
if (typeof type === "string")
|
|
206
|
+
return emitScalar(type, schema, where);
|
|
207
|
+
// 无 type(仅 enum 等少见情形):oj 产物总有 type,保守退回 unknown
|
|
208
|
+
return "z.unknown()";
|
|
209
|
+
}
|
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
import type { IrEndpoint } from "./ir";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
4
|
+
* IR 的 JSON Schema(oj `openapi.json` 白名单子集)→ oj 的 `.schema` 入参契约
|
|
5
|
+
* (oj v0.1.44,手册 §4)。
|
|
5
6
|
*
|
|
6
|
-
*
|
|
7
|
+
* 这是「openapi.json → 前端 client + 后端入参校验」的最后一环:契约里声明的
|
|
7
8
|
* query/params/body 不只生成前端类型与校验,也落成后端 handler 的 `.schema`,
|
|
8
9
|
* 由 oj 在 **JS 之前**校验(违反即 400,handler 不执行)。
|
|
9
10
|
*
|
|
10
11
|
* 关键字白名单(oj 侧,白名单外装配期 fail-fast):
|
|
11
12
|
* type / required / properties / items / additionalProperties / minimum /
|
|
12
13
|
* maximum / minLength / maxLength / pattern / enum / nullable / minItems / maxItems
|
|
13
|
-
*
|
|
14
|
-
*
|
|
14
|
+
* 所以发射时必须**主动裁剪**到这个子集。
|
|
15
|
+
*
|
|
16
|
+
* 输入是 IR 里的 JSON Schema 对象(来自 openapi.json),而非 zod——翻译层退役后
|
|
17
|
+
* 单一事实源即 openapi.json,本模块只做「JSON Schema 白名单裁剪」这一件事。
|
|
15
18
|
*/
|
|
16
19
|
|
|
17
20
|
/** oj `.schema` 的入参通道(对应 http.params / http.query / http.body) */
|
|
@@ -20,189 +23,133 @@ export type OjSchemaChannel = "params" | "query" | "body";
|
|
|
20
23
|
/** params/query 在 HTTP 里只有字符串形态:只允许扁平标量,array/object 是死契约 */
|
|
21
24
|
const FLAT_SCALARS = new Set(["string", "number", "integer", "boolean", "null"]);
|
|
22
25
|
|
|
23
|
-
interface ZodDefLike {
|
|
24
|
-
type?: string
|
|
25
|
-
shape?: Record<string, unknown>
|
|
26
|
-
element?: unknown
|
|
27
|
-
options?: unknown[]
|
|
28
|
-
innerType?: unknown
|
|
29
|
-
entries?: Record<string, unknown>
|
|
30
|
-
values?: unknown[]
|
|
31
|
-
checks?: { _zod?: { def?: Record<string, unknown> } }[]
|
|
32
|
-
pattern?: unknown
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function defOf(schema: unknown): ZodDefLike | undefined {
|
|
36
|
-
return (schema as { _zod?: { def?: ZodDefLike } })?._zod?.def;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
26
|
/** 发射期收集的诊断:降级(约束无法表达)必须说出来,不能静默丢 */
|
|
40
27
|
export interface OjSchemaDiag {
|
|
41
28
|
warnings: string[]
|
|
42
29
|
}
|
|
43
30
|
|
|
44
|
-
function
|
|
45
|
-
|
|
46
|
-
return "string";
|
|
47
|
-
if (typeof value === "number")
|
|
48
|
-
return "number";
|
|
49
|
-
if (typeof value === "boolean")
|
|
50
|
-
return "boolean";
|
|
51
|
-
return "null";
|
|
31
|
+
function numOf(value: unknown): number | undefined {
|
|
32
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
52
33
|
}
|
|
53
34
|
|
|
54
35
|
/**
|
|
55
|
-
* Rust regex(oj 用)不是
|
|
36
|
+
* Rust regex(oj 用)不是 ECMAScript:不支持 lookahead / lookbehind / 反向引用。
|
|
37
|
+
* openapi.json 来自 oj 自身时本就合规;但用户手写 openapi.json 可能带 JS 专属写法——
|
|
56
38
|
* 这里在**生成期**拦下,避免把服务拖到装配期才 fail-fast。
|
|
57
39
|
*/
|
|
58
|
-
function assertRustCompatiblePattern(pattern: string, where: string): void {
|
|
40
|
+
function assertRustCompatiblePattern(pattern: string, where: string, diag: OjSchemaDiag): void {
|
|
59
41
|
if (/\(\?[=!]|\(\?<[=!]/.test(pattern)) {
|
|
60
|
-
|
|
61
|
-
`[ojm-api] 契约 ${where} 的 pattern 用了零宽断言(lookahead/lookbehind):oj 的 pattern 是 Rust regex,不支持——请改写成等价的非断言表达式,或去掉该约束。`,
|
|
62
|
-
);
|
|
42
|
+
diag.warnings.push(`${where}:pattern 用了零宽断言(lookahead/lookbehind),oj 的 pattern 是 Rust regex 不支持——已跳过该约束(前端 zod 校验不受影响)。`);
|
|
63
43
|
}
|
|
64
|
-
if (
|
|
65
|
-
|
|
66
|
-
`[ojm-api] 契约 ${where} 的 pattern 用了反向引用(\\1…\\9):oj 的 pattern 是 Rust regex,不支持——请改写。`,
|
|
67
|
-
);
|
|
44
|
+
if (/\\\{[1-9]/.test(pattern)) {
|
|
45
|
+
diag.warnings.push(`${where}:pattern 用了反向引用(\\1…\\9),oj 的 pattern 是 Rust regex 不支持——已跳过。`);
|
|
68
46
|
}
|
|
69
47
|
}
|
|
70
48
|
|
|
71
|
-
/**
|
|
72
|
-
function
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
break;
|
|
83
|
-
case "greater_than":
|
|
84
|
-
out.minimum = cd.inclusive === false ? (cd.value as number) : cd.value;
|
|
85
|
-
break;
|
|
86
|
-
case "less_than":
|
|
87
|
-
out.maximum = cd.inclusive === false ? (cd.value as number) : cd.value;
|
|
88
|
-
break;
|
|
89
|
-
case "string_format": {
|
|
90
|
-
// 两种情况都挂在 string_format 下,必须分开:
|
|
91
|
-
// · 内置格式(email/uuid/url…,有 format):format 不在 oj 白名单(写了服务起不来),
|
|
92
|
-
// 且其自带 pattern 含零宽断言,Rust regex 也拒——整条跳过。
|
|
93
|
-
// · 用户 .regex(re)(无 format、只有 pattern):可发射,但须是 Rust 兼容写法。
|
|
94
|
-
// · 用户 .regex(re):zod v4 也挂在 string_format 下,但 format 值是字面 "regex"
|
|
95
|
-
const isUserRegex = cd.format === "regex";
|
|
96
|
-
if (isUserRegex) {
|
|
97
|
-
const source = cd.pattern instanceof RegExp ? cd.pattern.source : typeof cd.pattern === "string" ? cd.pattern : undefined;
|
|
98
|
-
if (source) {
|
|
99
|
-
assertRustCompatiblePattern(source, where);
|
|
100
|
-
out.pattern = source;
|
|
101
|
-
}
|
|
102
|
-
break;
|
|
103
|
-
}
|
|
104
|
-
if (cd.format) {
|
|
105
|
-
diag.warnings.push(`${where}:z.string().${String(cd.format)}() 的格式约束无法进 oj .schema(白名单无 format),已在后端跳过——需要后端强校验请改用 .regex()(Rust regex 写法)。`);
|
|
106
|
-
break;
|
|
107
|
-
}
|
|
108
|
-
break;
|
|
109
|
-
}
|
|
110
|
-
case "number_format":
|
|
111
|
-
// z.int() / z.number().int() → safeint:oj 侧对应 integer(影响 params/query 的字符串强转)
|
|
112
|
-
if (cd.format === "safeint" || cd.format === "int")
|
|
113
|
-
out.type = "integer";
|
|
114
|
-
else
|
|
115
|
-
diag.warnings.push(`${where}:number_format "${String(cd.format)}" 无法进 oj .schema,已跳过。`);
|
|
116
|
-
break;
|
|
117
|
-
case "multiple_of":
|
|
118
|
-
diag.warnings.push(`${where}:multipleOf 约束不在 oj .schema 白名单,已跳过。`);
|
|
119
|
-
break;
|
|
120
|
-
default:
|
|
121
|
-
break;
|
|
49
|
+
/** params/query 通道:只允许扁平标量,array/object 是永远无法满足的死契约 */
|
|
50
|
+
function assertFlatScalars(schema: Record<string, unknown> | undefined, where: string): void {
|
|
51
|
+
if (!schema || schema.type !== "object" || typeof schema.properties !== "object")
|
|
52
|
+
return;
|
|
53
|
+
for (const [key, child] of Object.entries(schema.properties as Record<string, unknown>)) {
|
|
54
|
+
const type = (child as { type?: string }).type;
|
|
55
|
+
if (type && !FLAT_SCALARS.has(type)) {
|
|
56
|
+
throw new Error(
|
|
57
|
+
`[ojm-api] 契约 ${where}.${key}:params/query 通道只允许扁平标量(string/number/integer/boolean/null),收到 "${type}"——`
|
|
58
|
+
+ "params/query 在 HTTP 里只有字符串形态,声明 array/object 是永远无法满足的死契约(oj 装配期即拒)。",
|
|
59
|
+
);
|
|
122
60
|
}
|
|
123
61
|
}
|
|
124
|
-
return out;
|
|
125
62
|
}
|
|
126
63
|
|
|
127
|
-
/**
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
64
|
+
/**
|
|
65
|
+
* JSON Schema → oj `.schema` 裁剪(递归)。
|
|
66
|
+
* 返回裁剪后的对象;若整个 schema 无可表达约束返回 undefined。
|
|
67
|
+
* 类型数组 ["x","null"] → {type:"x", nullable:true};非单实类型联合(["string","number"])
|
|
68
|
+
* → 告警并丢弃该字段(oj 白名单无 oneOf/anyOf,后端不校验,前端 zod 不受影响)。
|
|
69
|
+
*/
|
|
70
|
+
function convert(schema: unknown, where: string, diag: OjSchemaDiag): Record<string, unknown> | undefined {
|
|
71
|
+
if (!schema || typeof schema !== "object")
|
|
72
|
+
return undefined;
|
|
73
|
+
const s = schema as Record<string, unknown>;
|
|
74
|
+
const out: Record<string, unknown> = {};
|
|
132
75
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
required.push(key);
|
|
143
|
-
}
|
|
144
|
-
const out: Record<string, unknown> = { type: "object", properties };
|
|
145
|
-
if (required.length)
|
|
146
|
-
out.required = required;
|
|
147
|
-
return out;
|
|
148
|
-
}
|
|
149
|
-
case "array": {
|
|
150
|
-
const out: Record<string, unknown> = { type: "array", items: convert(def.element, `${where}[]`, diag) };
|
|
151
|
-
return { ...out, ...constraintsOf(def, where, diag) };
|
|
152
|
-
}
|
|
153
|
-
case "enum": {
|
|
154
|
-
const values = Object.keys(def.entries ?? {});
|
|
155
|
-
return { type: typeNameOf((def.entries ?? {})[values[0]]), enum: values };
|
|
156
|
-
}
|
|
157
|
-
case "literal": {
|
|
158
|
-
const value = (def.values ?? [])[0];
|
|
159
|
-
return { type: typeNameOf(value), enum: [value] };
|
|
160
|
-
}
|
|
161
|
-
case "union": {
|
|
162
|
-
// 白名单无 oneOf/anyOf:全字面量联合 → enum 等价表达;否则降级为空约束
|
|
163
|
-
const options = def.options ?? [];
|
|
164
|
-
const literals = options.map(opt => defOf(opt)?.values?.[0]).filter(v => v !== undefined);
|
|
165
|
-
if (literals.length === options.length && literals.length > 0) {
|
|
166
|
-
return { type: typeNameOf(literals[0]), enum: literals };
|
|
167
|
-
}
|
|
168
|
-
diag.warnings.push(`${where}:union 无法进 oj .schema(白名单无 oneOf/anyOf),该字段在后端不做入参校验——前端 zod 校验不受影响。`);
|
|
169
|
-
return {};
|
|
76
|
+
// 类型:数组形态 = union / nullable 展开
|
|
77
|
+
const rawType = s.type;
|
|
78
|
+
let type: string | undefined;
|
|
79
|
+
let nullable = false;
|
|
80
|
+
if (Array.isArray(rawType)) {
|
|
81
|
+
const nonNull = rawType.filter((t): t is string => t !== "null");
|
|
82
|
+
if (nonNull.length === 1) {
|
|
83
|
+
type = nonNull[0];
|
|
84
|
+
nullable = rawType.includes("null");
|
|
170
85
|
}
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
return
|
|
86
|
+
else {
|
|
87
|
+
diag.warnings.push(`${where}:联合类型 ${JSON.stringify(rawType)} 无法进 oj .schema(白名单无 oneOf/anyOf),该字段在后端不做入参校验;前端 zod 校验不受影响。`);
|
|
88
|
+
return undefined;
|
|
174
89
|
}
|
|
175
|
-
case "optional":
|
|
176
|
-
case "default":
|
|
177
|
-
return convert(def.innerType, where, diag);
|
|
178
|
-
case "date":
|
|
179
|
-
// JSON 无日期类型:线上是 ISO 串,后端按 string 校验
|
|
180
|
-
return { type: "string" };
|
|
181
|
-
case "string":
|
|
182
|
-
case "number":
|
|
183
|
-
case "boolean": {
|
|
184
|
-
const out: Record<string, unknown> = { type: def.type };
|
|
185
|
-
return { ...out, ...constraintsOf(def, where, diag) };
|
|
186
|
-
}
|
|
187
|
-
default:
|
|
188
|
-
diag.warnings.push(`${where}:zod 类型 "${def.type}" 无法转 oj .schema,该字段在后端不做入参校验。`);
|
|
189
|
-
return {};
|
|
190
90
|
}
|
|
191
|
-
|
|
91
|
+
else if (typeof rawType === "string") {
|
|
92
|
+
type = rawType;
|
|
93
|
+
}
|
|
192
94
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
if (
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
95
|
+
if (type)
|
|
96
|
+
out.type = type;
|
|
97
|
+
if (nullable)
|
|
98
|
+
out.nullable = true;
|
|
99
|
+
|
|
100
|
+
// format 是 JSON Schema 专有键,oj 白名单无——静默跳过会削弱校验,故告警
|
|
101
|
+
if (typeof s.format === "string") {
|
|
102
|
+
diag.warnings.push(`${where}:format "${s.format}" 不在 oj .schema 白名单,已跳过(后端不校验该格式约束;需要后端强校验请改用 .regex(),Rust regex 写法)。`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// 对象:递归裁剪每个属性;required 仅保留「确有可表达约束」的键(避免空 required)
|
|
106
|
+
if (type === "object" || (type === undefined && s.properties)) {
|
|
107
|
+
const props = s.properties as Record<string, unknown> | undefined;
|
|
108
|
+
if (props) {
|
|
109
|
+
const properties: Record<string, unknown> = {};
|
|
110
|
+
for (const [k, v] of Object.entries(props)) {
|
|
111
|
+
const child = convert(v, `${where}.${k}`, diag);
|
|
112
|
+
if (child !== undefined)
|
|
113
|
+
properties[k] = child;
|
|
114
|
+
}
|
|
115
|
+
if (Object.keys(properties).length) {
|
|
116
|
+
out.properties = properties;
|
|
117
|
+
const required = ((s.required as string[]) ?? []).filter(k => k in properties);
|
|
118
|
+
if (required.length)
|
|
119
|
+
out.required = required;
|
|
203
120
|
}
|
|
204
121
|
}
|
|
205
122
|
}
|
|
123
|
+
|
|
124
|
+
// 数组:递归裁剪 items
|
|
125
|
+
if (type === "array" && s.items) {
|
|
126
|
+
const items = convert(s.items, `${where}[]`, diag);
|
|
127
|
+
if (items !== undefined)
|
|
128
|
+
out.items = items;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// 字面量联合已转 enum(oj 支持)
|
|
132
|
+
if (Array.isArray(s.enum))
|
|
133
|
+
out.enum = s.enum;
|
|
134
|
+
|
|
135
|
+
// 数值 / 字符串 / 数组 边界约束(白名单内)
|
|
136
|
+
for (const key of ["minimum", "maximum", "minLength", "maxLength", "minItems", "maxItems"] as const) {
|
|
137
|
+
const v = numOf(s[key]);
|
|
138
|
+
if (v !== undefined)
|
|
139
|
+
out[key] = v;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (typeof s.pattern === "string") {
|
|
143
|
+
assertRustCompatiblePattern(s.pattern, where, diag);
|
|
144
|
+
out.pattern = s.pattern;
|
|
145
|
+
}
|
|
146
|
+
if (typeof s.additionalProperties === "boolean")
|
|
147
|
+
out.additionalProperties = s.additionalProperties;
|
|
148
|
+
// default 纯注解:oj 不补值,但保留做文档/后端语义提示无害
|
|
149
|
+
if (s.default !== undefined)
|
|
150
|
+
out.default = s.default;
|
|
151
|
+
|
|
152
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
206
153
|
}
|
|
207
154
|
|
|
208
155
|
/** 端点 → oj `.schema` 三通道(无声明的通道不出现);附带降级诊断 */
|
|
@@ -221,7 +168,7 @@ export function ojSchemaOfEndpoint(ep: IrEndpoint, diag: OjSchemaDiag = { warnin
|
|
|
221
168
|
if (channel !== "body")
|
|
222
169
|
assertFlatScalars(converted, where);
|
|
223
170
|
// 空对象 = 该通道无可表达约束,不写(写了 oj 会当「任意对象」收下,反而弱于不声明)
|
|
224
|
-
if (Object.keys(converted).length > 0)
|
|
171
|
+
if (converted && Object.keys(converted).length > 0)
|
|
225
172
|
out[channel] = converted;
|
|
226
173
|
}
|
|
227
174
|
return Object.keys(out).length > 0 ? out : undefined;
|
|
@@ -4,8 +4,8 @@ import { createHash } from "node:crypto";
|
|
|
4
4
|
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
5
5
|
import { dirname } from "node:path";
|
|
6
6
|
import { emitOjSchemaLine } from "./emit-oj-schema";
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
7
|
+
import { splitRoute, wirePrefixOf } from "./ir";
|
|
8
|
+
import { jsonSchemaToSample } from "./json-schema-sample";
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* AC-D10 §9.1:handler stub 发射器 + 指纹幂等。
|
|
@@ -40,6 +40,12 @@ export interface PlanStubOptions {
|
|
|
40
40
|
eslintFix?: (code: string, filePath: string) => Promise<string>
|
|
41
41
|
/** 收集 .schema 降级诊断(契约里 oj 表达不了的约束会记在这里,不静默丢) */
|
|
42
42
|
diag?: OjSchemaDiag
|
|
43
|
+
/**
|
|
44
|
+
* 已实现路由键(`"METHOD /abs/path"`,handler-scan 的 implementedRouteKeys 产出)。
|
|
45
|
+
* DDD reshape(oj v0.1.64 嵌套组)后 handler 可住在模块根单文件等非镜像位置——
|
|
46
|
+
* 这些端点跳过 stub 生成,否则镜像 stub 与真实 handler 双写同路由。
|
|
47
|
+
*/
|
|
48
|
+
implemented?: Set<string>
|
|
43
49
|
}
|
|
44
50
|
|
|
45
51
|
/** oj 方法名映射(DELETE → del)。OPTIONS 不在契约表达范围(runtime 白名单拒绝) */
|
|
@@ -67,62 +73,9 @@ export function hashContent(body: string): string {
|
|
|
67
73
|
return createHash("sha256").update(norm).digest("hex");
|
|
68
74
|
}
|
|
69
75
|
|
|
70
|
-
/**
|
|
76
|
+
/** 最小示例值源码生成:JSON Schema → 确定性示例值 → TS 字面量(JSON.stringify 即合法源码) */
|
|
71
77
|
function exampleSourceFromSchema(schema: unknown): string {
|
|
72
|
-
|
|
73
|
-
if (!def)
|
|
74
|
-
return "null";
|
|
75
|
-
switch (def.type) {
|
|
76
|
-
case "string": {
|
|
77
|
-
for (const check of (def.checks as { _zod?: { def?: Record<string, unknown> } }[]) ?? []) {
|
|
78
|
-
const format = check._zod?.def?.format as string | undefined;
|
|
79
|
-
if (format === "email")
|
|
80
|
-
return "\"user@example.com\"";
|
|
81
|
-
if (format === "uuid")
|
|
82
|
-
return "\"00000000-0000-0000-0000-000000000000\"";
|
|
83
|
-
if (format === "datetime")
|
|
84
|
-
return "\"2026-01-01T00:00:00.000Z\"";
|
|
85
|
-
if (format === "date")
|
|
86
|
-
return "\"2026-01-01\"";
|
|
87
|
-
if (format === "url")
|
|
88
|
-
return "\"https://example.com\"";
|
|
89
|
-
}
|
|
90
|
-
return "\"示例\"";
|
|
91
|
-
}
|
|
92
|
-
case "number": {
|
|
93
|
-
for (const check of (def.checks as { _zod?: { def?: Record<string, unknown> } }[]) ?? []) {
|
|
94
|
-
const cd = check._zod?.def;
|
|
95
|
-
if (cd?.check === "greater_than")
|
|
96
|
-
return String(cd.inclusive === false ? (cd.value as number) + 1 : cd.value);
|
|
97
|
-
}
|
|
98
|
-
return "1";
|
|
99
|
-
}
|
|
100
|
-
case "boolean":
|
|
101
|
-
return "true";
|
|
102
|
-
case "date":
|
|
103
|
-
return "\"2026-01-01T00:00:00.000Z\"";
|
|
104
|
-
case "literal":
|
|
105
|
-
return JSON.stringify((def.values as unknown[])[0]);
|
|
106
|
-
case "enum":
|
|
107
|
-
return JSON.stringify(Object.keys(def.entries as object)[0]);
|
|
108
|
-
case "union":
|
|
109
|
-
return exampleSourceFromSchema((def.options as unknown[])[0]);
|
|
110
|
-
case "array":
|
|
111
|
-
return `[${exampleSourceFromSchema(def.element)}]`;
|
|
112
|
-
case "object": {
|
|
113
|
-
const entries = Object.entries(def.shape as Record<string, unknown>)
|
|
114
|
-
.map(([k, v]) => `\t\t${keyOf(k)}: ${exampleSourceFromSchema(v).replaceAll("\n", "\n\t\t")},`);
|
|
115
|
-
return `{\n${entries.join("\n")}\n\t}`;
|
|
116
|
-
}
|
|
117
|
-
case "optional":
|
|
118
|
-
return exampleSourceFromSchema(def.innerType);
|
|
119
|
-
case "nullable":
|
|
120
|
-
return "null";
|
|
121
|
-
case "default":
|
|
122
|
-
return JSON.stringify(def.defaultValue);
|
|
123
|
-
default:
|
|
124
|
-
return "null";
|
|
125
|
-
}
|
|
78
|
+
return JSON.stringify(jsonSchemaToSample(schema));
|
|
126
79
|
}
|
|
127
80
|
|
|
128
81
|
function emitHandler(ep: IrEndpoint, diag?: OjSchemaDiag): string {
|
|
@@ -165,8 +118,15 @@ export async function planStubWrites(ir: IrEndpoint[], opts: PlanStubOptions): P
|
|
|
165
118
|
const fix = opts.eslintFix ?? ((code: string) => Promise.resolve(code));
|
|
166
119
|
|
|
167
120
|
// 按(模块段 + 目录镜像)分组 → 一个 api.ts
|
|
121
|
+
// 已在别处(模块根嵌套组 / 其他文件)实现的端点不进 stub 计划。
|
|
168
122
|
const groups = new Map<string, IrEndpoint[]>();
|
|
169
123
|
for (const ep of ir) {
|
|
124
|
+
if (opts.implemented) {
|
|
125
|
+
const ojFullAbs = (ep.apiPrefix + ep.route).replace(/\/{2,}/g, "/");
|
|
126
|
+
const contractAbs = `${wirePrefixOf(ep)}${ep.route}`;
|
|
127
|
+
if ([ojFullAbs, contractAbs].some(a => opts.implemented!.has(`${ep.method} ${a}`)))
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
170
130
|
const { dir } = splitRoute(ep.route);
|
|
171
131
|
const rel = [ep.apiPrefix.replace(/^\//, ""), dir, "api.ts"].filter(Boolean).join("/");
|
|
172
132
|
const list = groups.get(rel) ?? [];
|