@oj-module/cli 0.1.16 → 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 +35 -18
- package/devkit/manual.md +359 -131
- package/package.json +2 -2
- package/shell-dist/assets/contract.js +0 -55
- package/shell-dist/assets/runtime.js +238 -213
- 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/api.ts +186 -0
- package/templates/web/src/notification/entry.ts +20 -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/{notification/client → personal-center/__client}/api.schemas.ts +2 -14
- 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/{notification/client → web/__client}/api.ts +12 -37
- 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 -52
- 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
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import type { IrEndpoint } from "./ir";
|
|
2
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join, relative, sep } from "node:path";
|
|
4
|
+
import ts from "typescript";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* oj handler 文件的 AST 扫描(scan 职责独立成模块,供 check 对账与 emit-stub
|
|
8
|
+
* 已实现判定共用——check → run/emit-stub 存在反向依赖,扫描器放哪边都会循环)。
|
|
9
|
+
*
|
|
10
|
+
* 扫描口径与 oj v0.1.64 内省同规则:default 导出对象的动词键 = 端点;非动词键 +
|
|
11
|
+
* 对象值 = 嵌套组(组名 = 路径段,字符串字面量键与标识符键同等);语句起始的
|
|
12
|
+
* `.route = "..."` 为路由覆写(绝对 / 开头挂 base 根下,否则相对本层前缀)。
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* oj 方法名 → HTTP 动词(emit-stub 的反向映射)。
|
|
17
|
+
* 注意**不含 options**:契约运行时故意不表达 OPTIONS(预检请求由 fetch/CORS 层处理,
|
|
18
|
+
* 见 runtime/contract 的方法白名单)——oj 树里的 options handler 因此永不进对账表
|
|
19
|
+
* (不会被误报「未登记」);routes.js 的 options 行经 parseRoutesJs 的
|
|
20
|
+
* 大写回落归一,同样不误报 drift。
|
|
21
|
+
*/
|
|
22
|
+
export const VERB_OF: Record<string, IrEndpoint["method"]> = {
|
|
23
|
+
get: "GET",
|
|
24
|
+
post: "POST",
|
|
25
|
+
put: "PUT",
|
|
26
|
+
del: "DELETE",
|
|
27
|
+
patch: "PATCH",
|
|
28
|
+
head: "HEAD",
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** oj v0.1.64 嵌套上限(与 oj 内省一致);超出即 oj 启动报错,checker 不深追 */
|
|
32
|
+
const MAX_GROUP_DEPTH = 4;
|
|
33
|
+
|
|
34
|
+
export interface HandlerRow {
|
|
35
|
+
/** HTTP 动词 */
|
|
36
|
+
method: IrEndpoint["method"]
|
|
37
|
+
/** 目录镜像相对 api/src 的路径段(含模块段),如 "order/item" */
|
|
38
|
+
dir: string
|
|
39
|
+
/** 语句起始 `.route = "..."` 字面量(无则目录镜像直达) */
|
|
40
|
+
tail?: string
|
|
41
|
+
/** v0.1.64:点分 handler 链("cccc.get";顶层动词即方法名) */
|
|
42
|
+
chain: string
|
|
43
|
+
/** v0.1.64:组前缀解析后的有效路由全路径(base 相对、无前导 /;绝对 .route 即其自身) */
|
|
44
|
+
full: string
|
|
45
|
+
filePath: string
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** `a` / `a.b` 形式的属性访问 → 点分名;其余(调用/元素访问)→ undefined */
|
|
49
|
+
function dottedNameOf(node: ts.Expression): string | undefined {
|
|
50
|
+
if (ts.isIdentifier(node))
|
|
51
|
+
return node.text;
|
|
52
|
+
if (ts.isPropertyAccessExpression(node)) {
|
|
53
|
+
const base = dottedNameOf(node.expression);
|
|
54
|
+
return base ? `${base}.${node.name.text}` : undefined;
|
|
55
|
+
}
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 语句起始的 `<路径>.route = "字面量"`(路径可为点分:`cccc.post.route`,运行时即
|
|
61
|
+
* `cccc.post` 函数对象的 `.route`——与 oj 内省读 `fn.route` 同源)。空串视同未挂
|
|
62
|
+
* (oj 口径)。oj build 只认顶层标准赋值,这里同口径且放宽到点分左值。
|
|
63
|
+
*/
|
|
64
|
+
function collectRouteDecls(sf: ts.SourceFile): Map<string, string> {
|
|
65
|
+
const routes = new Map<string, string>();
|
|
66
|
+
for (const stmt of sf.statements) {
|
|
67
|
+
if (!ts.isExpressionStatement(stmt) || !ts.isBinaryExpression(stmt.expression))
|
|
68
|
+
continue;
|
|
69
|
+
const expr = stmt.expression;
|
|
70
|
+
if (expr.operatorToken.kind !== ts.SyntaxKind.EqualsToken)
|
|
71
|
+
continue;
|
|
72
|
+
if (!ts.isPropertyAccessExpression(expr.left) || expr.left.name.text !== "route")
|
|
73
|
+
continue;
|
|
74
|
+
if (!ts.isStringLiteral(expr.right) || expr.right.text === "")
|
|
75
|
+
continue;
|
|
76
|
+
const path = dottedNameOf(expr.left.expression);
|
|
77
|
+
if (path)
|
|
78
|
+
routes.set(path, expr.right.text);
|
|
79
|
+
}
|
|
80
|
+
return routes;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** 顶层 `const x = { ... }` 对象字面量表:嵌套组经标识符引用时的解析依据 */
|
|
84
|
+
function collectObjectDecls(sf: ts.SourceFile): Map<string, ts.ObjectLiteralExpression> {
|
|
85
|
+
const decls = new Map<string, ts.ObjectLiteralExpression>();
|
|
86
|
+
for (const stmt of sf.statements) {
|
|
87
|
+
if (!ts.isVariableStatement(stmt))
|
|
88
|
+
continue;
|
|
89
|
+
for (const decl of stmt.declarationList.declarations) {
|
|
90
|
+
if (ts.isIdentifier(decl.name) && decl.initializer && ts.isObjectLiteralExpression(decl.initializer))
|
|
91
|
+
decls.set(decl.name.text, decl.initializer);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return decls;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** verb 键 → 实现函数名(供 `.route` 语句按函数名查找;内联写法回落键名) */
|
|
98
|
+
function implNameOf(prop: ts.ObjectLiteralElementLike): string | undefined {
|
|
99
|
+
if (ts.isShorthandPropertyAssignment(prop))
|
|
100
|
+
return prop.name.text;
|
|
101
|
+
if (ts.isMethodDeclaration(prop) && prop.name)
|
|
102
|
+
return prop.name.getText();
|
|
103
|
+
if (ts.isPropertyAssignment(prop)) {
|
|
104
|
+
if (ts.isIdentifier(prop.name) && ts.isIdentifier(prop.initializer))
|
|
105
|
+
return prop.initializer.text;
|
|
106
|
+
if (prop.name)
|
|
107
|
+
return prop.name.getText();
|
|
108
|
+
}
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** 非 verb 键 → 子组对象字面量(内联或经标识符解析);函数/数组/基元 → undefined */
|
|
113
|
+
function groupValueOf(prop: ts.ObjectLiteralElementLike, decls: Map<string, ts.ObjectLiteralExpression>): ts.ObjectLiteralExpression | undefined {
|
|
114
|
+
if (ts.isPropertyAssignment(prop)) {
|
|
115
|
+
if (ts.isObjectLiteralExpression(prop.initializer))
|
|
116
|
+
return prop.initializer;
|
|
117
|
+
if (ts.isIdentifier(prop.initializer))
|
|
118
|
+
return decls.get(prop.initializer.text);
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
if (ts.isShorthandPropertyAssignment(prop))
|
|
122
|
+
return decls.get(prop.name.text);
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** default 导出对象:内联字面量或标识符(`export default api`) */
|
|
127
|
+
function defaultExportObject(sf: ts.SourceFile, decls: Map<string, ts.ObjectLiteralExpression>): ts.ObjectLiteralExpression | undefined {
|
|
128
|
+
for (const stmt of sf.statements) {
|
|
129
|
+
if (!ts.isExportAssignment(stmt) || stmt.isExportEquals)
|
|
130
|
+
continue;
|
|
131
|
+
if (ts.isObjectLiteralExpression(stmt.expression))
|
|
132
|
+
return stmt.expression;
|
|
133
|
+
if (ts.isIdentifier(stmt.expression))
|
|
134
|
+
return decls.get(stmt.expression.text);
|
|
135
|
+
}
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** 相对路由按层前缀解析(绝对 `/` 开头即根级);与 oj 内省 resolve 同规则 */
|
|
140
|
+
function resolveRoute(own: string, prefix: string): string {
|
|
141
|
+
return own.startsWith("/") ? own : (prefix ? `${prefix}/${own}` : own);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** 有效路由全路径(base 相对、无前导 /):绝对 .route 取其自身,否则目录镜像 + 相对尾 */
|
|
145
|
+
function fullPathOf(dir: string, tail: string | undefined): string {
|
|
146
|
+
if (tail?.startsWith("/"))
|
|
147
|
+
return tail.replace(/^\//, "");
|
|
148
|
+
return [dir, tail].filter(Boolean).join("/");
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** 对账键(方法 + 全路径) */
|
|
152
|
+
export function pathKey(method: string, path: string): string {
|
|
153
|
+
return `${method} ${path}`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* handler 的有效 wire 全路径(带前导 /):绝对 `.route` 即其自身;相对尾巴 = `/` + full。
|
|
158
|
+
* 用于与契约 contractAbs(wirePrefix + route)对齐比对。
|
|
159
|
+
*/
|
|
160
|
+
export function handlerAbs(h: HandlerRow): string {
|
|
161
|
+
return h.tail?.startsWith("/") ? h.tail : `/${h.full}`;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function scanHandlerFile(filePath: string, apiSrcDir: string): HandlerRow[] {
|
|
165
|
+
const text = readFileSync(filePath, "utf8");
|
|
166
|
+
const sf = ts.createSourceFile(filePath, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
|
|
167
|
+
const routes = collectRouteDecls(sf);
|
|
168
|
+
const decls = collectObjectDecls(sf);
|
|
169
|
+
const relDir = relative(apiSrcDir, dirname(filePath)).split(sep).join("/");
|
|
170
|
+
const rows: HandlerRow[] = [];
|
|
171
|
+
const seen = new Set<ts.Node>();
|
|
172
|
+
|
|
173
|
+
const walk = (obj: ts.ObjectLiteralExpression, chainPrefix: string, routePrefix: string, depth: number): void => {
|
|
174
|
+
if (depth > MAX_GROUP_DEPTH || seen.has(obj))
|
|
175
|
+
return;
|
|
176
|
+
seen.add(obj);
|
|
177
|
+
for (const prop of obj.properties) {
|
|
178
|
+
// oj v0.1.64:组名可以是连字符等非标识符("audit-log"),字符串字面量键与
|
|
179
|
+
// 标识符键同等解析。保留限制:元素访问式元数据(api["x-y"].route = ...)
|
|
180
|
+
// 仍不支持——reshape 授权形态为字符串字面量键 + 组内函数挂 .route。
|
|
181
|
+
const name = (prop.name && (ts.isIdentifier(prop.name) || ts.isStringLiteral(prop.name)))
|
|
182
|
+
? prop.name.text
|
|
183
|
+
: undefined;
|
|
184
|
+
if (!name)
|
|
185
|
+
continue;
|
|
186
|
+
const chain = chainPrefix ? `${chainPrefix}.${name}` : name;
|
|
187
|
+
if (VERB_OF[name]) {
|
|
188
|
+
const fnName = implNameOf(prop) ?? name;
|
|
189
|
+
const own = routes.get(chain) ?? routes.get(fnName);
|
|
190
|
+
// 无自身 .route → 继承本层前缀(根层即目录镜像);与 oj 内省同规则
|
|
191
|
+
const tail = own === undefined
|
|
192
|
+
? (routePrefix || undefined)
|
|
193
|
+
: resolveRoute(own, routePrefix);
|
|
194
|
+
rows.push({
|
|
195
|
+
method: VERB_OF[name],
|
|
196
|
+
dir: relDir,
|
|
197
|
+
tail,
|
|
198
|
+
chain,
|
|
199
|
+
full: fullPathOf(relDir, tail),
|
|
200
|
+
filePath,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
else {
|
|
204
|
+
const child = groupValueOf(prop, decls);
|
|
205
|
+
if (!child)
|
|
206
|
+
continue; // 函数/数组/基元:oj 红线,checker 不当路由
|
|
207
|
+
const own = routes.get(chain);
|
|
208
|
+
const childPrefix = own === undefined
|
|
209
|
+
? (routePrefix ? `${routePrefix}/${name}` : name)
|
|
210
|
+
: resolveRoute(own, routePrefix);
|
|
211
|
+
walk(child, chain, childPrefix, depth + 1);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
seen.delete(obj);
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
const root = defaultExportObject(sf, decls);
|
|
218
|
+
if (root) {
|
|
219
|
+
// 文件层 .route:default 导出为标识符时,该标识符的 .route 语句即整文件默认前缀
|
|
220
|
+
let filePrefix = "";
|
|
221
|
+
for (const stmt of sf.statements) {
|
|
222
|
+
if (ts.isExportAssignment(stmt) && !stmt.isExportEquals && ts.isIdentifier(stmt.expression)) {
|
|
223
|
+
const own = routes.get(stmt.expression.text);
|
|
224
|
+
if (own !== undefined)
|
|
225
|
+
filePrefix = resolveRoute(own, "");
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
walk(root, "", filePrefix, 1);
|
|
229
|
+
}
|
|
230
|
+
return rows;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** 递归收全部 api.ts / api.js(目录形态与模块根单文件同扫) */
|
|
234
|
+
export function walkApiFiles(dir: string, out: string[] = []): string[] {
|
|
235
|
+
if (!existsSync(dir))
|
|
236
|
+
return out;
|
|
237
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
238
|
+
const p = join(dir, entry.name);
|
|
239
|
+
if (entry.isDirectory())
|
|
240
|
+
walkApiFiles(p, out);
|
|
241
|
+
else if (entry.name === "api.ts" || entry.name === "api.js")
|
|
242
|
+
out.push(p);
|
|
243
|
+
}
|
|
244
|
+
return out;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* 已实现路由键集合(`"METHOD /abs/path"`,含绝对 .route 形态)。
|
|
249
|
+
* emit-stub 据此跳过「已在别处(模块根嵌套组 / 其他文件)实现」的端点——否则 DDD
|
|
250
|
+
* reshape 后镜像 stub 与真实 handler 双写同路由,oj 路由表出现重复注册。
|
|
251
|
+
*/
|
|
252
|
+
export function implementedRouteKeys(apiSrcDir: string): Set<string> {
|
|
253
|
+
const keys = new Set<string>();
|
|
254
|
+
for (const f of walkApiFiles(apiSrcDir)) {
|
|
255
|
+
const text = readFileSync(f, "utf8");
|
|
256
|
+
// stub 镜像文件不算实现——它们覆盖的路由需照常报「stub 缺失/待更新」
|
|
257
|
+
if (/^\/\/ (?:ojm|ram)-api:stub /.test(text))
|
|
258
|
+
continue;
|
|
259
|
+
for (const row of scanHandlerFile(f, apiSrcDir))
|
|
260
|
+
keys.add(pathKey(row.method, handlerAbs(row)));
|
|
261
|
+
}
|
|
262
|
+
return keys;
|
|
263
|
+
}
|
package/src/contract/ir.ts
CHANGED
|
@@ -1,48 +1,12 @@
|
|
|
1
|
-
import type { ApiDefinitionInput } from "@oj-module/runtime/contract";
|
|
2
|
-
import { API_DEF } from "@oj-module/runtime/contract";
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* 旧端点品牌标记(`ram.api.def`):存量工程若仍装着旧 runtime 产物,其端点打旧符号。
|
|
6
|
-
* 只读兼容识别,故意不进 runtime 公共出口(兼容专用、且下个 major 会随之移除)。
|
|
7
|
-
*/
|
|
8
|
-
const API_DEF_LEGACY = Symbol.for("ram.api.def");
|
|
9
|
-
|
|
10
1
|
/**
|
|
11
|
-
*
|
|
2
|
+
* 统一中间表示(IR)。
|
|
12
3
|
*
|
|
13
|
-
* IR
|
|
14
|
-
*
|
|
15
|
-
*
|
|
4
|
+
* 翻译层退役后,IR 由 `openapi.json`(oj v0.1.73 `oj openapi` 产物)经
|
|
5
|
+
* `openapi-ir.ts` 构造——本模块只承载 IR 的**类型定义**与少量与路由拼接/切分
|
|
6
|
+
* 相关的纯函数(`wirePrefixOf` / `splitRoute`),供 emit-client、emit-stub、check
|
|
7
|
+
* 复用,不再包含任何 zod 求值/白名单逻辑。
|
|
16
8
|
*/
|
|
17
9
|
|
|
18
|
-
/** schema 白名单 def.type(zod v4 `_zod.def.type`) */
|
|
19
|
-
const ALLOWED_TYPES = new Set([
|
|
20
|
-
"string",
|
|
21
|
-
"number",
|
|
22
|
-
"boolean",
|
|
23
|
-
"date",
|
|
24
|
-
"object",
|
|
25
|
-
"array",
|
|
26
|
-
"enum",
|
|
27
|
-
"literal",
|
|
28
|
-
"union",
|
|
29
|
-
"optional",
|
|
30
|
-
"nullable",
|
|
31
|
-
"default",
|
|
32
|
-
]);
|
|
33
|
-
|
|
34
|
-
/** 白名单内 string/number 约束 check 名(zod v4 check 实例 `_zod.def.check`) */
|
|
35
|
-
const ALLOWED_CHECKS = new Set([
|
|
36
|
-
"min_length",
|
|
37
|
-
"max_length",
|
|
38
|
-
"length_equals",
|
|
39
|
-
"greater_than",
|
|
40
|
-
"less_than",
|
|
41
|
-
"multiple_of",
|
|
42
|
-
"number_format",
|
|
43
|
-
"string_format",
|
|
44
|
-
]);
|
|
45
|
-
|
|
46
10
|
/** multipart 文件部件(DSL 声明归一:required/multiple 显式化,发射器不再看 undefined) */
|
|
47
11
|
export interface IrFormFile {
|
|
48
12
|
name: string
|
|
@@ -51,7 +15,7 @@ export interface IrFormFile {
|
|
|
51
15
|
}
|
|
52
16
|
|
|
53
17
|
/**
|
|
54
|
-
* multipart 请求体:文本部件保留 schema
|
|
18
|
+
* multipart 请求体:文本部件保留 schema(JSON Schema,转 api.schemas 用),
|
|
55
19
|
* 文件部件只有名字与形态——binary 刻意不进 schema(FormData 由生成 client 组装)。
|
|
56
20
|
*/
|
|
57
21
|
export interface IrForm {
|
|
@@ -62,7 +26,7 @@ export interface IrForm {
|
|
|
62
26
|
}
|
|
63
27
|
|
|
64
28
|
export interface IrEndpoint {
|
|
65
|
-
/**
|
|
29
|
+
/** 端点方法名(来自 operationId 的 camelCase 推导) */
|
|
66
30
|
name: string
|
|
67
31
|
apiPrefix: string
|
|
68
32
|
/** 线上前缀(D-M8):缺省 = apiPrefix */
|
|
@@ -77,7 +41,7 @@ export interface IrEndpoint {
|
|
|
77
41
|
querySchema?: unknown
|
|
78
42
|
paramsSchema?: unknown
|
|
79
43
|
bodySchema?: unknown
|
|
80
|
-
/** multipart/form-data 请求体(与 bodySchema
|
|
44
|
+
/** multipart/form-data 请求体(与 bodySchema 互斥) */
|
|
81
45
|
form?: IrForm
|
|
82
46
|
dataSchema?: unknown
|
|
83
47
|
raw: boolean
|
|
@@ -85,60 +49,15 @@ export interface IrEndpoint {
|
|
|
85
49
|
description?: string
|
|
86
50
|
}
|
|
87
51
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
const def = (schema as ZodLike)?._zod?.def;
|
|
92
|
-
if (!def || typeof def.type !== "string") {
|
|
93
|
-
throw new Error(`[ojm-api] 契约 schema 白名单校验失败(${where}):不是 zod schema——契约只接受 @oj-module/runtime/contract 导出的 z 构建的 schema。`);
|
|
94
|
-
}
|
|
95
|
-
return def;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
/**
|
|
99
|
-
* 递归白名单校验:拒绝 pipe/transform(def.type "pipe")、refine(custom check)、
|
|
100
|
-
* coerce(def.coerce)、lazy 等——它们无法保真反向发射为源码 / 无法转 OpenAPI。
|
|
101
|
-
*/
|
|
102
|
-
export function assertWhitelisted(schema: unknown, where: string): void {
|
|
103
|
-
const def = defOf(schema, where);
|
|
104
|
-
const type = def.type as string;
|
|
105
|
-
if (!ALLOWED_TYPES.has(type)) {
|
|
106
|
-
throw new Error(`[ojm-api] 契约 schema 超出白名单(${where}):类型 "${type}" 不支持。白名单:${[...ALLOWED_TYPES].join("/")};transform/refine/coerce/pipe/lazy 无法进入契约(无法转 OpenAPI 与保真发射),请改用纯数据结构约束。`);
|
|
107
|
-
}
|
|
108
|
-
if (def.coerce === true) {
|
|
109
|
-
throw new Error(`[ojm-api] 契约 schema 超出白名单(${where}):z.coerce.* 不支持——params/query 请直接声明语义类型(如 z.number()),URL 序列化由生成物负责。`);
|
|
110
|
-
}
|
|
111
|
-
for (const check of (def.checks as unknown[]) ?? []) {
|
|
112
|
-
const checkName = (check as ZodLike)?._zod?.def?.check as string | undefined;
|
|
113
|
-
if (checkName && !ALLOWED_CHECKS.has(checkName)) {
|
|
114
|
-
throw new Error(`[ojm-api] 契约 schema 超出白名单(${where}):约束 "${checkName}" 不支持(refine/superRefine 属 custom 校验,无法转 OpenAPI)。`);
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
if (type === "object") {
|
|
118
|
-
for (const [key, child] of Object.entries(def.shape as Record<string, unknown>)) {
|
|
119
|
-
assertWhitelisted(child, `${where}.${key}`);
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
else if (type === "array") {
|
|
123
|
-
assertWhitelisted(def.element, `${where}[]`);
|
|
124
|
-
}
|
|
125
|
-
else if (type === "union") {
|
|
126
|
-
(def.options as unknown[]).forEach((opt, i) => assertWhitelisted(opt, `${where}<${i}>`));
|
|
127
|
-
}
|
|
128
|
-
else if (type === "optional" || type === "nullable" || type === "default") {
|
|
129
|
-
assertWhitelisted(def.innerType, where);
|
|
130
|
-
}
|
|
52
|
+
/** 线上前缀(D-M8):urlPrefix 声明时取之(URL 命名空间 ≠ 目录名),否则回落 apiPrefix */
|
|
53
|
+
export function wirePrefixOf(api: { apiPrefix: string, urlPrefix?: string }): string {
|
|
54
|
+
return api.urlPrefix ?? api.apiPrefix;
|
|
131
55
|
}
|
|
132
56
|
|
|
133
57
|
/**
|
|
134
58
|
* route →(目录镜像 + `.route` 尾巴)切分(AC-D10 §4 算法,stub 生成与
|
|
135
59
|
* --check 对账共用同一实现):静态前缀段 → 目录;自首个参数段起的尾巴 → `.route`。
|
|
136
60
|
*/
|
|
137
|
-
/** 线上前缀(D-M8):urlPrefix 声明时取之(URL 命名空间 ≠ 目录名),否则回落 apiPrefix */
|
|
138
|
-
export function wirePrefixOf(api: { apiPrefix: string, urlPrefix?: string }): string {
|
|
139
|
-
return api.urlPrefix ?? api.apiPrefix;
|
|
140
|
-
}
|
|
141
|
-
|
|
142
61
|
export function splitRoute(route: string): { dir: string, tail?: string } {
|
|
143
62
|
const segs = route.split("/").filter(Boolean);
|
|
144
63
|
const firstParam = segs.findIndex(seg => seg.startsWith("{"));
|
|
@@ -149,90 +68,3 @@ export function splitRoute(route: string): { dir: string, tail?: string } {
|
|
|
149
68
|
tail: segs.slice(firstParam).join("/"),
|
|
150
69
|
};
|
|
151
70
|
}
|
|
152
|
-
|
|
153
|
-
/** 提取 route 参数段名(catch-all 去 * 前缀) */
|
|
154
|
-
function paramNamesOf(route: string): string[] {
|
|
155
|
-
return route.split("/")
|
|
156
|
-
.filter(seg => seg.startsWith("{"))
|
|
157
|
-
.map(seg => seg.replace(/^\{\*?/, "").replace(/\}$/, ""));
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
/** evaluate 出的模块导出 → IR 端点数组 */
|
|
161
|
-
export function buildIr(exports: Record<string, unknown>): IrEndpoint[] {
|
|
162
|
-
const endpoints: IrEndpoint[] = [];
|
|
163
|
-
for (const [name, value] of Object.entries(exports)) {
|
|
164
|
-
// 双符号识别(R3):新契约打 ojm.api.def,存量(旧 runtime 产物)打 ram.api.def
|
|
165
|
-
const brand = value as Record<PropertyKey, unknown> | undefined;
|
|
166
|
-
if (brand?.[API_DEF] !== true && brand?.[API_DEF_LEGACY] !== true)
|
|
167
|
-
continue;
|
|
168
|
-
const def = value as ApiDefinitionInput;
|
|
169
|
-
// schema 白名单(报错文案含端点名 + 字段路径)
|
|
170
|
-
for (const slot of ["query", "params", "body", "data"] as const) {
|
|
171
|
-
const schema = def[slot];
|
|
172
|
-
if (schema)
|
|
173
|
-
assertWhitelisted(schema, `${name}.${slot}`);
|
|
174
|
-
}
|
|
175
|
-
// multipart/form-data(与 body 互斥,定义期已拦截):文本部件走同一套 schema 白名单;
|
|
176
|
-
// 文件部件只有名字/形态,二进制刻意不进 schema(JSON Schema 无 binary、FormData 由 client 组装)
|
|
177
|
-
let form: IrForm | undefined;
|
|
178
|
-
if (def.form) {
|
|
179
|
-
let fieldNames: string[] = [];
|
|
180
|
-
if (def.form.fields) {
|
|
181
|
-
assertWhitelisted(def.form.fields, `${name}.form.fields`);
|
|
182
|
-
const fieldsDef = defOf(def.form.fields, `${name}.form.fields`);
|
|
183
|
-
if (fieldsDef.type !== "object") {
|
|
184
|
-
throw new Error(`[ojm-api] 契约端点 "${name}":form.fields 必须是 z.object(收到类型 "${String(fieldsDef.type)}")——文本部件按字段名进 multipart 表单(OpenAPI 同形);单个非对象值请改用 body(JSON)。`);
|
|
185
|
-
}
|
|
186
|
-
fieldNames = Object.keys(fieldsDef.shape as Record<string, unknown>);
|
|
187
|
-
}
|
|
188
|
-
form = {
|
|
189
|
-
fieldsSchema: def.form.fields,
|
|
190
|
-
fieldNames,
|
|
191
|
-
files: (def.form.files ?? []).map(f => ({ name: f.name, required: f.required === true, multiple: f.multiple === true })),
|
|
192
|
-
};
|
|
193
|
-
}
|
|
194
|
-
const paramNames = paramNamesOf(def.route);
|
|
195
|
-
// 评审 F8:params schema 键必须覆盖 route 参数段——否则生成物 URL 插值出 undefined
|
|
196
|
-
if (def.params) {
|
|
197
|
-
const shape = (def.params as unknown as ZodLike)?._zod?.def?.shape as Record<string, unknown> | undefined;
|
|
198
|
-
if (!shape) {
|
|
199
|
-
throw new Error(`[ojm-api] 契约端点 "${name}":params 必须是 z.object(收到非 object schema)——params 与 route 参数段一一对应。`);
|
|
200
|
-
}
|
|
201
|
-
const missing = paramNames.filter(p => !(p in shape));
|
|
202
|
-
if (missing.length) {
|
|
203
|
-
throw new Error(`[ojm-api] 契约端点 "${name}":params schema 缺 route 参数段 ${missing.map(p => `{${p}}`).join("、")} 的键——两者须一一对应(缺省 params 则参数段全按 string 声明,二选一)。`);
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
endpoints.push({
|
|
207
|
-
name,
|
|
208
|
-
apiPrefix: def.apiPrefix,
|
|
209
|
-
urlPrefix: def.urlPrefix,
|
|
210
|
-
route: def.route,
|
|
211
|
-
method: def.method ?? "GET",
|
|
212
|
-
// apiPrefix "/" 为框架内部根级端点(如 runtime 自带 role-list)——拼出 "//" 即错
|
|
213
|
-
fullPath: wirePrefixOf(def) === "/" ? def.route : `${wirePrefixOf(def)}${def.route}`,
|
|
214
|
-
paramNames,
|
|
215
|
-
querySchema: def.query,
|
|
216
|
-
paramsSchema: def.params,
|
|
217
|
-
bodySchema: def.body,
|
|
218
|
-
form,
|
|
219
|
-
dataSchema: def.data,
|
|
220
|
-
raw: def.response === "raw",
|
|
221
|
-
ignoreLoading: def.ignoreLoading,
|
|
222
|
-
description: def.description,
|
|
223
|
-
});
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
// 同(目录, method)冲突:oj 中同一 api.ts 文件同一方法名只能有一个 handler
|
|
227
|
-
const seen = new Map<string, string>();
|
|
228
|
-
for (const ep of endpoints) {
|
|
229
|
-
const { dir } = splitRoute(ep.route);
|
|
230
|
-
const key = `${ep.apiPrefix}|${dir}|${ep.method}`;
|
|
231
|
-
const existing = seen.get(key);
|
|
232
|
-
if (existing) {
|
|
233
|
-
throw new Error(`[ojm-api] 契约路由冲突:"${existing}" 与 "${ep.name}" 都映射到 ${ep.apiPrefix}/${dir}/api.ts 的 ${ep.method}——同一目录同一方法只能有一个端点(oj 一文件一方法一 handler),请调整 route 拆目录。`);
|
|
234
|
-
}
|
|
235
|
-
seen.set(key, ep.name);
|
|
236
|
-
}
|
|
237
|
-
return endpoints;
|
|
238
|
-
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSON Schema(oj `openapi.json` 白名单子集)→ 示例值。
|
|
3
|
+
*
|
|
4
|
+
* 零依赖(不引入 faker,保证 emit-stub 复用时不与 mock 形成循环依赖):
|
|
5
|
+
* 示例值**确定性**生成,便于 stub / mock 字节稳定。仅覆盖 oj `.schema` 表达力
|
|
6
|
+
* (type / properties / items / enum / min / max / minLength / maxLength);
|
|
7
|
+
* `pattern` 是 Rust regex,无法表达,忽略(示例值不强制满足 pattern)。
|
|
8
|
+
*
|
|
9
|
+
* 语义与 emit-stub 的示例源码生成共用同一套(emit-stub 用 JSON.stringify 把返回值
|
|
10
|
+
* 包成 TS 字面量)。
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
function numOf(value: unknown): number | undefined {
|
|
14
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** 类型数组 ["string","null"] → 取非 null 实类型;其余返回 undefined(无法取样本) */
|
|
18
|
+
function singleTypeOf(type: unknown): string | undefined {
|
|
19
|
+
if (typeof type === "string")
|
|
20
|
+
return type;
|
|
21
|
+
if (Array.isArray(type)) {
|
|
22
|
+
const nonNull = (type as unknown[]).filter(t => t !== "null");
|
|
23
|
+
return nonNull.length === 1 ? (nonNull[0] as string) : undefined;
|
|
24
|
+
}
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function jsonSchemaToSample(schema: unknown): unknown {
|
|
29
|
+
if (!schema || typeof schema !== "object")
|
|
30
|
+
return null;
|
|
31
|
+
const s = schema as Record<string, unknown>;
|
|
32
|
+
// enum 优先:oj 把 zod enum 翻成 {type, enum},示例值应取枚举首值(而非「示例」占位),
|
|
33
|
+
// 否则 mock/client 示例值永远偏离真实取值。
|
|
34
|
+
if (Array.isArray(s.enum) && s.enum.length > 0)
|
|
35
|
+
return s.enum[0];
|
|
36
|
+
const type = singleTypeOf(s.type);
|
|
37
|
+
|
|
38
|
+
switch (type) {
|
|
39
|
+
case "string": {
|
|
40
|
+
const min = numOf(s.minLength) ?? 0;
|
|
41
|
+
const max = numOf(s.maxLength) ?? Number.POSITIVE_INFINITY;
|
|
42
|
+
// 默认「示例」恰好 2 字:低于 min 则补足,高于 max 则截断(保约束成立)
|
|
43
|
+
let word = "示例";
|
|
44
|
+
if (word.length < min)
|
|
45
|
+
word = "示".repeat(Math.max(min, 1));
|
|
46
|
+
if (Number.isFinite(max) && word.length > max)
|
|
47
|
+
word = word.slice(0, max);
|
|
48
|
+
return word;
|
|
49
|
+
}
|
|
50
|
+
case "integer":
|
|
51
|
+
case "number": {
|
|
52
|
+
const min = numOf(s.minimum) ?? 1;
|
|
53
|
+
const lo = Number.isFinite(min) ? Math.ceil(min) : 1;
|
|
54
|
+
return lo;
|
|
55
|
+
}
|
|
56
|
+
case "boolean":
|
|
57
|
+
return true;
|
|
58
|
+
case "array":
|
|
59
|
+
return [jsonSchemaToSample(s.items)];
|
|
60
|
+
case "object": {
|
|
61
|
+
const out: Record<string, unknown> = {};
|
|
62
|
+
const props = (s.properties as Record<string, unknown>) ?? {};
|
|
63
|
+
for (const [k, v] of Object.entries(props))
|
|
64
|
+
out[k] = jsonSchemaToSample(v);
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
default:
|
|
68
|
+
if (Array.isArray(s.enum) && s.enum.length > 0)
|
|
69
|
+
return s.enum[0];
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
}
|