@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
package/src/contract/mock.ts
CHANGED
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
import type { MockRoute, MockRouteContext } from "../dev-mock";
|
|
2
|
-
import {
|
|
2
|
+
import type { OpenApiDoc } from "./openapi-ir";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
3
4
|
import { matchMockRoute } from "../dev-mock";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
5
|
+
import { jsonSchemaToSample } from "./json-schema-sample";
|
|
6
|
+
import { irFromOpenapi } from "./openapi-ir";
|
|
7
|
+
import { findOpenapiJson } from "./run";
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
|
-
* AC-D14 纯前端契约 mock
|
|
10
|
+
* AC-D14 纯前端契约 mock:openapi.json(oj v0.1.73 产物)驱动示例值响应。
|
|
11
|
+
*
|
|
12
|
+
* 翻译层退役后,事实源即 openapi.json——其 data schema 是 JSON Schema 对象,
|
|
13
|
+
* 由 `jsonSchemaToSample` 生成示例值(确定性、零依赖)。
|
|
10
14
|
*
|
|
11
15
|
* - 段级 matcher 自研极简实现(零新依赖):字面段 > 参数段 > catch-all
|
|
12
|
-
* - 示例值语义启发:string format 命中 faker 对应器,enum 取首值,number 取 min
|
|
13
16
|
* - 手写 mock 精确匹配优先,契约 pattern 兜底(resolveMock 收敛选择序)
|
|
14
17
|
* - raw 端点不进 mock 生成(二进制/流无信封可造)
|
|
15
18
|
*/
|
|
@@ -18,90 +21,13 @@ export interface ContractMockRoute<T = unknown> {
|
|
|
18
21
|
method: string
|
|
19
22
|
/** 含参数段的完整 pattern,如 "/order/item/{id}" */
|
|
20
23
|
pattern: string
|
|
21
|
-
/** 负载:
|
|
24
|
+
/** 负载:openapi.json 解出的 data JSON Schema(由 jsonSchemaToSample 造示例值) */
|
|
22
25
|
data: T
|
|
23
26
|
}
|
|
24
27
|
|
|
25
|
-
/**
|
|
26
|
-
export function
|
|
27
|
-
|
|
28
|
-
faker.seed(opts.seed);
|
|
29
|
-
return gen(schema);
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
function gen(schema: unknown): unknown {
|
|
33
|
-
const def = (schema as { _zod?: { def?: Record<string, unknown> } })?._zod?.def;
|
|
34
|
-
if (!def)
|
|
35
|
-
return null;
|
|
36
|
-
switch (def.type) {
|
|
37
|
-
case "string": {
|
|
38
|
-
let min = 0;
|
|
39
|
-
let max = Number.POSITIVE_INFINITY;
|
|
40
|
-
for (const check of (def.checks as { _zod?: { def?: Record<string, unknown> } }[]) ?? []) {
|
|
41
|
-
const cd = check._zod?.def;
|
|
42
|
-
// 评审 F4:示例值必须满足自家约束,否则 dev 校验对 mock 误报「契约违例」
|
|
43
|
-
if (cd?.check === "min_length")
|
|
44
|
-
min = Math.max(min, cd.minimum as number);
|
|
45
|
-
if (cd?.check === "max_length")
|
|
46
|
-
max = Math.min(max, cd.maximum as number);
|
|
47
|
-
const format = cd?.format as string | undefined;
|
|
48
|
-
if (format === "email")
|
|
49
|
-
return faker.internet.email();
|
|
50
|
-
if (format === "uuid")
|
|
51
|
-
return faker.string.uuid();
|
|
52
|
-
if (format === "datetime")
|
|
53
|
-
return faker.date.recent().toISOString();
|
|
54
|
-
if (format === "date")
|
|
55
|
-
return faker.date.recent().toISOString().slice(0, 10);
|
|
56
|
-
if (format === "url")
|
|
57
|
-
return faker.internet.url();
|
|
58
|
-
}
|
|
59
|
-
let word = faker.lorem.word();
|
|
60
|
-
if (word.length < min)
|
|
61
|
-
word = faker.string.alpha(Number.isFinite(min) && min > 0 ? min : 1);
|
|
62
|
-
if (word.length > max)
|
|
63
|
-
word = word.slice(0, max);
|
|
64
|
-
return word;
|
|
65
|
-
}
|
|
66
|
-
case "number": {
|
|
67
|
-
let min: number | undefined;
|
|
68
|
-
let max: number | undefined;
|
|
69
|
-
for (const check of (def.checks as { _zod?: { def?: Record<string, unknown> } }[]) ?? []) {
|
|
70
|
-
const cd = check._zod?.def;
|
|
71
|
-
if (cd?.check === "greater_than")
|
|
72
|
-
min = cd.inclusive === false ? (cd.value as number) + 1 : (cd.value as number);
|
|
73
|
-
if (cd?.check === "less_than")
|
|
74
|
-
max = cd.inclusive === false ? (cd.value as number) - 1 : (cd.value as number);
|
|
75
|
-
}
|
|
76
|
-
return faker.number.int({ min: min ?? 1, max: max ?? (min !== undefined ? min + 1000 : 1000) });
|
|
77
|
-
}
|
|
78
|
-
case "boolean":
|
|
79
|
-
return true;
|
|
80
|
-
case "date":
|
|
81
|
-
return faker.date.recent().toISOString(); // 线上传输本就是 ISO 串
|
|
82
|
-
case "literal":
|
|
83
|
-
return (def.values as unknown[])[0];
|
|
84
|
-
case "enum":
|
|
85
|
-
return Object.keys(def.entries as object)[0];
|
|
86
|
-
case "union":
|
|
87
|
-
return gen((def.options as unknown[])[0]);
|
|
88
|
-
case "array":
|
|
89
|
-
return [gen(def.element)];
|
|
90
|
-
case "object": {
|
|
91
|
-
const out: Record<string, unknown> = {};
|
|
92
|
-
for (const [k, v] of Object.entries(def.shape as Record<string, unknown>))
|
|
93
|
-
out[k] = gen(v);
|
|
94
|
-
return out;
|
|
95
|
-
}
|
|
96
|
-
case "optional":
|
|
97
|
-
return gen(def.innerType);
|
|
98
|
-
case "nullable":
|
|
99
|
-
return null;
|
|
100
|
-
case "default":
|
|
101
|
-
return def.defaultValue;
|
|
102
|
-
default:
|
|
103
|
-
return null;
|
|
104
|
-
}
|
|
28
|
+
/** JSON Schema → 示例值(确定性,无外部依赖);data 为 undefined 时返回 null */
|
|
29
|
+
export function exampleFromJsonSchema(schema: unknown): unknown {
|
|
30
|
+
return jsonSchemaToSample(schema);
|
|
105
31
|
}
|
|
106
32
|
|
|
107
33
|
interface Seg { kind: "literal" | "param" | "catchAll", text: string }
|
|
@@ -166,21 +92,23 @@ export function resolveMock(
|
|
|
166
92
|
if (hit)
|
|
167
93
|
return ctx => hit.response(ctx ?? { body: {}, query: new URLSearchParams() });
|
|
168
94
|
const fallback = matchContractRoute(contract, method, apiPath);
|
|
169
|
-
if (fallback)
|
|
170
|
-
return () => ({
|
|
95
|
+
if (fallback) {
|
|
96
|
+
return () => ({
|
|
97
|
+
code: 0,
|
|
98
|
+
msg: "ok",
|
|
99
|
+
data: exampleFromJsonSchema(fallback.data),
|
|
100
|
+
});
|
|
101
|
+
}
|
|
171
102
|
return undefined;
|
|
172
103
|
}
|
|
173
104
|
|
|
174
|
-
/** dev
|
|
105
|
+
/** dev 启动装载:openapi.json 为事实源(data schema 是 JSON Schema 对象),raw 端点跳过 */
|
|
175
106
|
export async function loadContractMocks(cwd: string): Promise<ContractMockRoute[]> {
|
|
176
|
-
const
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
return routes;
|
|
107
|
+
const openapiPath = findOpenapiJson(cwd);
|
|
108
|
+
if (!openapiPath)
|
|
109
|
+
return [];
|
|
110
|
+
const doc = JSON.parse(readFileSync(openapiPath, "utf8")) as OpenApiDoc;
|
|
111
|
+
return irFromOpenapi(doc)
|
|
112
|
+
.filter(ep => !ep.raw)
|
|
113
|
+
.map(ep => ({ method: ep.method, pattern: ep.fullPath, data: ep.dataSchema }));
|
|
186
114
|
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import type { IrEndpoint } from "./ir";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* openapi.json(oj v0.1.73 `oj openapi` 产物,OpenAPI 3.1)→ IrEndpoint[]。
|
|
5
|
+
*
|
|
6
|
+
* 单一职责(SRP):只做「openapi.json 结构 → 统一 IR」的解析,不含任何 zod / 代码发射逻辑。
|
|
7
|
+
* schema 字段在此承载 **JSON Schema 对象**(白名单子集),下游 emit-jsonschema-zod 负责转 zod。
|
|
8
|
+
*
|
|
9
|
+
* 注入安全:本模块只读取数据、产出数据,不拼任何源码字符串,故无注入面。
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** JSON Schema 白名单子集(透传 openapi.json 里 oj 已校验过的契约,下游再转 zod) */
|
|
13
|
+
export type JsonSchema = Record<string, unknown>;
|
|
14
|
+
|
|
15
|
+
export interface OpenApiDoc {
|
|
16
|
+
openapi?: string
|
|
17
|
+
paths?: Record<string, Record<string, OpenApiOperation>>
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface OpenApiOperation {
|
|
21
|
+
"operationId"?: string
|
|
22
|
+
"tags"?: string[]
|
|
23
|
+
"x-oj-file"?: string
|
|
24
|
+
"parameters"?: OpenApiParameter[]
|
|
25
|
+
"requestBody"?: { required?: boolean, content?: Record<string, { schema?: JsonSchema }> }
|
|
26
|
+
"responses"?: Record<string, { "description"?: string, "x-oj-response-enforced"?: boolean, "content"?: Record<string, { schema?: JsonSchema }> }>
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface OpenApiParameter {
|
|
30
|
+
name: string
|
|
31
|
+
in: "path" | "query" | "header" | "cookie"
|
|
32
|
+
required?: boolean
|
|
33
|
+
schema?: JsonSchema
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface IrFromOpenapiOptions {
|
|
37
|
+
/** wire 基础前缀(如 /v1/api);提供则直接剥离,否则从 path + module 反推 */
|
|
38
|
+
base?: string
|
|
39
|
+
/** 已知 raw 端点路由(相对路径集合):openapi.json 不携带 raw 标记,由 handler-scan 补充 */
|
|
40
|
+
rawPaths?: Set<string>
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** operationId(去 base 子路径 + 方法,非字母数字转 _)→ camelCase 端点方法名 */
|
|
44
|
+
export function camelCaseOperationId(operationId: string): string {
|
|
45
|
+
return operationId
|
|
46
|
+
.split("_")
|
|
47
|
+
.filter(Boolean)
|
|
48
|
+
.map((seg, i) => (i === 0 ? seg.toLowerCase() : seg.charAt(0).toUpperCase() + seg.slice(1).toLowerCase()))
|
|
49
|
+
.join("");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** 剥 base + 模块前缀,得到模块相对路径(如 /collections/{id});客户端用 apiPrefix + route 拼回全路径 */
|
|
53
|
+
function stripBase(path: string, base: string | undefined, module: string): string {
|
|
54
|
+
let rel = path;
|
|
55
|
+
// base 缺省:从 path 反推(path 中 "/<module>" 之前的部分即 base)
|
|
56
|
+
if (!base) {
|
|
57
|
+
const idx = path.indexOf(`/${module}`);
|
|
58
|
+
if (idx >= 0)
|
|
59
|
+
base = path.slice(0, idx);
|
|
60
|
+
}
|
|
61
|
+
if (base && rel.startsWith(base))
|
|
62
|
+
rel = rel.slice(base.length);
|
|
63
|
+
const modPrefix = `/${module}`;
|
|
64
|
+
if (rel.startsWith(modPrefix))
|
|
65
|
+
rel = rel.slice(modPrefix.length);
|
|
66
|
+
return rel || "/";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** 从路径模板抽取参数段名(catch-all {*path} → path) */
|
|
70
|
+
function paramNamesOf(route: string): string[] {
|
|
71
|
+
return [...route.matchAll(/\{([^}]+)\}/g)].map(m => m[1].replace(/^\*/, ""));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** parameters[] 按 in 通道合并为 params / query 两个 object schema(required 收集到数组) */
|
|
75
|
+
function collectParams(parameters: OpenApiParameter[] | undefined): { params?: JsonSchema, query?: JsonSchema } {
|
|
76
|
+
const paramsProps: Record<string, JsonSchema> = {};
|
|
77
|
+
const paramsReq: string[] = [];
|
|
78
|
+
const queryProps: Record<string, JsonSchema> = {};
|
|
79
|
+
const queryReq: string[] = [];
|
|
80
|
+
for (const p of parameters ?? []) {
|
|
81
|
+
if (p.in === "path") {
|
|
82
|
+
paramsProps[p.name] = p.schema ?? { type: "string" };
|
|
83
|
+
if (p.required)
|
|
84
|
+
paramsReq.push(p.name);
|
|
85
|
+
}
|
|
86
|
+
else if (p.in === "query") {
|
|
87
|
+
queryProps[p.name] = p.schema ?? { type: "string" };
|
|
88
|
+
if (p.required)
|
|
89
|
+
queryReq.push(p.name);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
const params = Object.keys(paramsProps).length
|
|
93
|
+
? { type: "object", required: paramsReq, properties: paramsProps }
|
|
94
|
+
: undefined;
|
|
95
|
+
const query = Object.keys(queryProps).length
|
|
96
|
+
? { type: "object", required: queryReq, properties: queryProps }
|
|
97
|
+
: undefined;
|
|
98
|
+
return { params, query };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* responses.200 解包 data:仅当带 x-oj-response-enforced:false(= 信封 {code,msg,data})才取
|
|
103
|
+
* properties.data 作为返回 payload;未声明 response 的端点(仅有 description:OK)返回 undefined。
|
|
104
|
+
*/
|
|
105
|
+
function extractDataSchema(op: OpenApiOperation): JsonSchema | undefined {
|
|
106
|
+
const r200 = op.responses?.["200"];
|
|
107
|
+
if (!r200 || r200["x-oj-response-enforced"] !== false)
|
|
108
|
+
return undefined;
|
|
109
|
+
const schema = r200.content?.["application/json"]?.schema as JsonSchema | undefined;
|
|
110
|
+
const data = (schema?.properties as Record<string, JsonSchema> | undefined)?.data;
|
|
111
|
+
return data as JsonSchema | undefined;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** openapi.json → IrEndpoint[] */
|
|
115
|
+
export function irFromOpenapi(doc: OpenApiDoc, opts: IrFromOpenapiOptions = {}): IrEndpoint[] {
|
|
116
|
+
const out: IrEndpoint[] = [];
|
|
117
|
+
for (const [path, methods] of Object.entries(doc.paths ?? {})) {
|
|
118
|
+
for (const [method, op] of Object.entries(methods)) {
|
|
119
|
+
const module = op.tags?.[0] ?? path.split("/").filter(Boolean)[0] ?? "";
|
|
120
|
+
const route = stripBase(path, opts.base, module);
|
|
121
|
+
const name = camelCaseOperationId(op.operationId ?? `${route}_${method}`);
|
|
122
|
+
const { params, query } = collectParams(op.parameters);
|
|
123
|
+
const body = op.requestBody?.content?.["application/json"]?.schema;
|
|
124
|
+
const dataSchema = extractDataSchema(op);
|
|
125
|
+
const apiPrefix = `/${module}`;
|
|
126
|
+
out.push({
|
|
127
|
+
name,
|
|
128
|
+
apiPrefix,
|
|
129
|
+
route,
|
|
130
|
+
"method": method.toUpperCase() as IrEndpoint["method"],
|
|
131
|
+
// fullPath = 模块前缀 + 模块相对 route(不含 oj 全局 base,base 由 oj 处理);
|
|
132
|
+
// 与旧 IR(buildIr 的 wirePrefix+route)口径一致,供 client URL / mock / route 对账复用
|
|
133
|
+
"fullPath": apiPrefix === "/" ? route : `${apiPrefix}${route}`,
|
|
134
|
+
"paramNames": paramNamesOf(route),
|
|
135
|
+
"paramsSchema": params,
|
|
136
|
+
"querySchema": query,
|
|
137
|
+
"bodySchema": body,
|
|
138
|
+
dataSchema,
|
|
139
|
+
"raw": opts.rawPaths?.has(route) ?? false,
|
|
140
|
+
"x-oj-file": op["x-oj-file"],
|
|
141
|
+
} as IrEndpoint);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return out;
|
|
145
|
+
}
|
package/src/contract/run.ts
CHANGED
|
@@ -1,25 +1,24 @@
|
|
|
1
1
|
import type { OjSchemaDiag } from "./emit-oj-schema";
|
|
2
|
+
import type { IrEndpoint } from "./ir";
|
|
3
|
+
import type { OpenApiDoc } from "./openapi-ir";
|
|
2
4
|
import { execFile as execFileCb } from "node:child_process";
|
|
3
|
-
import { existsSync, mkdirSync,
|
|
5
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
6
|
import { createRequire } from "node:module";
|
|
5
7
|
import { dirname, join } from "node:path";
|
|
6
8
|
import process from "node:process";
|
|
7
9
|
import { promisify } from "node:util";
|
|
8
|
-
import {
|
|
10
|
+
import { stringify as stringifyYaml } from "yaml";
|
|
9
11
|
import { emitClient } from "./emit-client";
|
|
10
|
-
import { emitOpenapiYaml, emitRoutesJson } from "./emit-meta";
|
|
11
12
|
import { applyStubWrites, planStubWrites } from "./emit-stub";
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
13
|
+
import { implementedRouteKeys } from "./handler-scan";
|
|
14
|
+
import { irFromOpenapi } from "./openapi-ir";
|
|
14
15
|
|
|
15
16
|
/**
|
|
16
|
-
* AC-D7:`ojm api`
|
|
17
|
+
* AC-D7:`ojm api` 编排——发现 openapi.json → IR → 逐模块发射 __client → 写盘;
|
|
18
|
+
* 并为未实现的 handler 生成 stub(人碰过的文件永不写)。
|
|
17
19
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* `web/src/<模块>/client/`(模块名 = apiPrefix 去首斜杠,AC-D9 字面相等,
|
|
21
|
-
* 不符即人话报错);routes.json/openapi.yaml 写契约旁;stub 写 oj 目录镜像树。
|
|
22
|
-
* - 纯前端:`web/src/<模块>/client/contract.ts` → 四产物全部落契约同目录,无 stub。
|
|
20
|
+
* 单一事实源即 `openapi.json`(oj v0.1.73 `oj openapi` 产物,OpenAPI 3.1):
|
|
21
|
+
* 项目级单文件,含全部模块。翻译层退役后不再消费旧 `contract.ts`。
|
|
23
22
|
*
|
|
24
23
|
* 幂等:内容无变化不写盘(读旧比对),RunResult 报告 written/skipped。
|
|
25
24
|
*/
|
|
@@ -31,38 +30,18 @@ export interface RunResult {
|
|
|
31
30
|
stubs: { created: number, updated: number, skipped: number }
|
|
32
31
|
}
|
|
33
32
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
path: string
|
|
37
|
-
/** 模块名(uni-dev = api/src 下一级目录名;纯前端 = web/src 下一级目录名) */
|
|
38
|
-
module: string
|
|
39
|
-
kind: "uni-dev" | "frontend"
|
|
40
|
-
}
|
|
33
|
+
/** 发现 openapi.json(oj v0.1.73 `oj openapi` 产物)。候选位置:显式传入 > `<cwd>/openapi.json` > `<cwd>/api/openapi.json` */
|
|
34
|
+
const OPENAPI_CANDIDATES = ["openapi.json", "api/openapi.json"];
|
|
41
35
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
continue;
|
|
50
|
-
const p = join(apiSrc, entry.name, "contract.ts");
|
|
51
|
-
if (existsSync(p))
|
|
52
|
-
found.push({ path: p, module: entry.name, kind: "uni-dev" });
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
const modulesSrc = join(cwd, "web/src");
|
|
56
|
-
if (existsSync(modulesSrc)) {
|
|
57
|
-
for (const entry of readdirSync(modulesSrc, { withFileTypes: true })) {
|
|
58
|
-
if (!entry.isDirectory())
|
|
59
|
-
continue;
|
|
60
|
-
const p = join(modulesSrc, entry.name, "client", "contract.ts");
|
|
61
|
-
if (existsSync(p))
|
|
62
|
-
found.push({ path: p, module: entry.name, kind: "frontend" });
|
|
63
|
-
}
|
|
36
|
+
function findOpenapiJson(cwd: string, explicit?: string): string | undefined {
|
|
37
|
+
if (explicit)
|
|
38
|
+
return join(cwd, explicit);
|
|
39
|
+
for (const rel of OPENAPI_CANDIDATES) {
|
|
40
|
+
const p = join(cwd, rel);
|
|
41
|
+
if (existsSync(p))
|
|
42
|
+
return p;
|
|
64
43
|
}
|
|
65
|
-
return
|
|
44
|
+
return undefined;
|
|
66
45
|
}
|
|
67
46
|
|
|
68
47
|
/** 内容无变化不写盘;返回是否写入 */
|
|
@@ -76,108 +55,80 @@ function writeIfChanged(path: string, content: string, result: RunResult): void
|
|
|
76
55
|
result.written.push(path);
|
|
77
56
|
}
|
|
78
57
|
|
|
79
|
-
/**
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
}
|
|
90
|
-
const dir = dirname(found.path);
|
|
91
|
-
return {
|
|
92
|
-
client: join(dir, "api.ts"),
|
|
93
|
-
schemas: join(dir, "api.schemas.ts"),
|
|
94
|
-
routes: join(dir, "routes.json"),
|
|
95
|
-
openapi: join(dir, "openapi.yaml"),
|
|
96
|
-
};
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/** 契约 → IR(含 uni-dev 的 AC-D9 字面相等校验);run 与 --check 共用 */
|
|
100
|
-
export async function irOf(found: DiscoveredContract, cwd: string) {
|
|
101
|
-
const exports = await evaluateContract(found.path, cwd);
|
|
102
|
-
const ir = buildIr(exports);
|
|
103
|
-
if (ir.length === 0)
|
|
104
|
-
throw new Error(`[ojm-api] ${found.path} 没有 defineApi 端点——契约文件至少导出一个端点,否则请删除该文件。`);
|
|
105
|
-
if (found.kind === "uni-dev") {
|
|
106
|
-
// AC-D9:uni-dev 形态 apiPrefix 字面等于 oj 模块段(= 目录名)
|
|
107
|
-
for (const ep of ir) {
|
|
108
|
-
if (ep.apiPrefix !== `/${found.module}`) {
|
|
109
|
-
throw new Error(`[ojm-api] ${found.path} 端点 "${ep.name}" apiPrefix "${ep.apiPrefix}" 与目录名 "${found.module}" 不符——uni-dev 形态要求字面相等(AC-D9),请改 apiPrefix 或移动契约目录。`);
|
|
110
|
-
}
|
|
111
|
-
}
|
|
58
|
+
/** IR 按模块(apiPrefix 去首斜杠)分组,供逐模块 emitClient 落 __client */
|
|
59
|
+
function groupIrByModule(ir: IrEndpoint[]): Map<string, IrEndpoint[]> {
|
|
60
|
+
const byModule = new Map<string, IrEndpoint[]>();
|
|
61
|
+
for (const ep of ir) {
|
|
62
|
+
const module = ep.apiPrefix.replace(/^\//, "");
|
|
63
|
+
const list = byModule.get(module);
|
|
64
|
+
if (list)
|
|
65
|
+
list.push(ep);
|
|
66
|
+
else
|
|
67
|
+
byModule.set(module, [ep]);
|
|
112
68
|
}
|
|
113
|
-
return
|
|
69
|
+
return byModule;
|
|
114
70
|
}
|
|
115
71
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
const client = emitClient(ir, { target: "module", module: found.module });
|
|
120
|
-
const routesJson = emitRoutesJson(ir);
|
|
121
|
-
const openapi = emitOpenapiYaml(ir, { title: `${found.module} api`, version: "0.0.0" });
|
|
72
|
+
/** 暴露给 check.ts 复用(同源 openapi.json 发现逻辑,DRY) */
|
|
73
|
+
export { findOpenapiJson, groupIrByModule };
|
|
122
74
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
75
|
+
/**
|
|
76
|
+
* openapi.json → irFromOpenapi → 逐模块 emitClient → 写 `web/src/<模块>/__client/`;
|
|
77
|
+
* 并为未实现的 handler 生成 stub(oj 目录镜像树,指纹幂等)。
|
|
78
|
+
* raw 信号 openapi.json 不携带,由 opts.rawPaths(handler-scan 补充)标记;未提供则按非 raw。
|
|
79
|
+
*/
|
|
80
|
+
async function runFromOpenapi(openapiPath: string, cwd: string, result: RunResult, rawPaths?: Set<string>): Promise<void> {
|
|
81
|
+
const doc = JSON.parse(readFileSync(openapiPath, "utf8")) as OpenApiDoc;
|
|
82
|
+
const ir = irFromOpenapi(doc, { rawPaths });
|
|
83
|
+
const byModule = groupIrByModule(ir);
|
|
84
|
+
result.contracts += byModule.size;
|
|
85
|
+
for (const [module, eps] of byModule) {
|
|
86
|
+
const client = emitClient(eps, { target: "module", module });
|
|
87
|
+
const clientDir = join(cwd, "web/src", module, "__client");
|
|
88
|
+
writeIfChanged(join(clientDir, "api.ts"), client["api.ts"], result);
|
|
89
|
+
writeIfChanged(join(clientDir, "api.schemas.ts"), client["api.schemas.ts"], result);
|
|
90
|
+
}
|
|
91
|
+
// stub:oj 目录镜像树(人碰过的文件永不写——plan 阶段已按指纹判好)。
|
|
92
|
+
// 已实现键全量扫一遍传入:嵌套组(模块根单文件)实现的端点不再生成镜像 stub。
|
|
93
|
+
const apiSrcDir = join(cwd, "api/src");
|
|
94
|
+
if (existsSync(apiSrcDir)) {
|
|
130
95
|
const diag: OjSchemaDiag = { warnings: [] };
|
|
131
|
-
const writes = await planStubWrites(ir, { apiSrcDir:
|
|
96
|
+
const writes = await planStubWrites(ir, { apiSrcDir, diag, implemented: implementedRouteKeys(apiSrcDir) });
|
|
132
97
|
const counts = applyStubWrites(writes);
|
|
133
98
|
result.stubs.created += counts.created;
|
|
134
99
|
result.stubs.updated += counts.updated;
|
|
135
100
|
result.stubs.skipped += counts.skipped;
|
|
136
|
-
// .schema 降级必须说出来:oj 关键字是白名单,表达不了的约束后端就不校验了
|
|
137
101
|
for (const w of diag.warnings)
|
|
138
102
|
console.warn(`[ojm-api] ⚠️ ${w}`);
|
|
139
103
|
}
|
|
140
104
|
}
|
|
141
105
|
|
|
142
|
-
/** ojm api
|
|
143
|
-
export async function runApi(opts: { cwd: string }): Promise<RunResult> {
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
|
|
106
|
+
/** ojm api 主入口:消费 openapi.json(oj v0.1.73 `oj openapi` 产物)生成 __client + stub */
|
|
107
|
+
export async function runApi(opts: { cwd: string, openapi?: string, rawPaths?: Set<string> }): Promise<RunResult> {
|
|
108
|
+
const result: RunResult = { contracts: 0, written: [], skipped: [], stubs: { created: 0, updated: 0, skipped: 0 } };
|
|
109
|
+
const openapiPath = findOpenapiJson(opts.cwd, opts.openapi);
|
|
110
|
+
if (!openapiPath) {
|
|
111
|
+
throw new Error(`[ojm-api] ${opts.cwd} 下未发现 openapi.json——请先运行 oj v0.1.73 的 \`oj openapi -c config -d api/src -b /api -o openapi.json\` 生成(OpenAPI 3.1,含全部模块)。\`ojm api\` 的单一事实源即 openapi.json,不再消费旧 contract.ts。`);
|
|
147
112
|
}
|
|
148
|
-
|
|
149
|
-
for (const found of contracts)
|
|
150
|
-
await runOne(found, opts.cwd, result);
|
|
113
|
+
await runFromOpenapi(openapiPath, opts.cwd, result, opts.rawPaths);
|
|
151
114
|
return result;
|
|
152
115
|
}
|
|
153
116
|
|
|
154
117
|
/**
|
|
155
|
-
* R5:`ojm api --docs
|
|
156
|
-
* uni-dev 形态(存在 api/src
|
|
157
|
-
*
|
|
118
|
+
* R5:`ojm api --docs`——聚合 openapi.json 的 paths → redoc 渲染静态站。
|
|
119
|
+
* uni-dev 形态(存在 api/src)落 `api/docs/index.html`,纯前端落 `docs/api/index.html`。
|
|
120
|
+
* redoc CLI 缺失时人话报错。
|
|
158
121
|
*/
|
|
159
122
|
export async function runApiDocs(cwd: string, opts: { redocBin?: string, fetchJs?: (url: string) => Promise<string> } = {}): Promise<string> {
|
|
160
|
-
const
|
|
161
|
-
if (
|
|
162
|
-
throw new Error(`[ojm-api] ${cwd}
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
// 聚合:逐契约 IR → openapi doc → paths 合并(operation 打模块 tag)
|
|
166
|
-
const paths: Record<string, Record<string, unknown>> = {};
|
|
167
|
-
for (const found of contracts) {
|
|
168
|
-
const ir = await irOf(found, cwd);
|
|
169
|
-
const doc = parseYaml(emitOpenapiYaml(ir, { title: `${found.module} api`, version: "0.0.0" })) as { paths: Record<string, Record<string, { tags?: string[] }>> };
|
|
170
|
-
for (const [p, methods] of Object.entries(doc.paths)) {
|
|
171
|
-
for (const [method, op] of Object.entries(methods)) {
|
|
172
|
-
if (paths[p]?.[method])
|
|
173
|
-
throw new Error(`[ojm-api] 聚合冲突:${method.toUpperCase()} ${p} 被多个契约声明——请检查各模块 apiPrefix 是否撞车。`);
|
|
174
|
-
op.tags = [found.module];
|
|
175
|
-
(paths[p] ??= {})[method] = op;
|
|
176
|
-
}
|
|
177
|
-
}
|
|
123
|
+
const openapiPath = findOpenapiJson(cwd);
|
|
124
|
+
if (!openapiPath) {
|
|
125
|
+
throw new Error(`[ojm-api] ${cwd} 下未发现 openapi.json——请先运行 \`oj openapi\` 生成(\`ojm api --docs\` 聚合其 paths 渲染文档)。`);
|
|
178
126
|
}
|
|
127
|
+
const doc = JSON.parse(readFileSync(openapiPath, "utf8")) as { paths?: Record<string, Record<string, unknown>> };
|
|
128
|
+
const paths = doc.paths ?? {};
|
|
129
|
+
const isUniDev = existsSync(join(cwd, "api/src"));
|
|
179
130
|
|
|
180
|
-
const outDir =
|
|
131
|
+
const outDir = isUniDev ? join(cwd, "api/docs") : join(cwd, "docs/api");
|
|
181
132
|
mkdirSync(outDir, { recursive: true });
|
|
182
133
|
const specPath = join(outDir, "openapi.yaml");
|
|
183
134
|
writeFileSync(specPath, stringifyYaml({ openapi: "3.1.0", info: { title: "API 文档", version: "0.0.0" }, paths }));
|
package/src/dev.ts
CHANGED
|
@@ -25,7 +25,7 @@ import { spawn } from "node:child_process";
|
|
|
25
25
|
import { existsSync, watch } from "node:fs";
|
|
26
26
|
import http from "node:http";
|
|
27
27
|
|
|
28
|
-
import { resolve } from "node:path";
|
|
28
|
+
import { join, resolve } from "node:path";
|
|
29
29
|
import process from "node:process";
|
|
30
30
|
import readline from "node:readline";
|
|
31
31
|
import { buildModules } from "./build";
|
|
@@ -244,30 +244,25 @@ export async function devServer(projectRoot: string, opts: DevOptions = {}): Pro
|
|
|
244
244
|
}, 300);
|
|
245
245
|
};
|
|
246
246
|
try {
|
|
247
|
-
watch(layout.watchTarget, { recursive: true }, (
|
|
248
|
-
|
|
249
|
-
return;
|
|
250
|
-
// 契约文件变更:先 runApi 重生成 client(落 web 树 → 二次触发本 watch 走重建)
|
|
251
|
-
if (filename.endsWith("contract.ts"))
|
|
252
|
-
regenContracts();
|
|
247
|
+
watch(layout.watchTarget, { recursive: true }, () => {
|
|
248
|
+
// 契约产物(__client)由 regenContracts 落 web 树 → 二次触发本 watch 走重建
|
|
253
249
|
trigger();
|
|
254
250
|
});
|
|
255
251
|
}
|
|
256
252
|
catch {
|
|
257
253
|
// 某些平台不支持 recursive,退化为不自动重建(手动 ojm build 仍可用)
|
|
258
254
|
}
|
|
259
|
-
// uni-dev
|
|
255
|
+
// uni-dev 契约源在 api/src(不在模块 watch 范围内)。新流水线(oj v0.1.73):
|
|
256
|
+
// contract.ts 已退役,dev 改 handler `.schema`/`.response` 后按约定手动重跑
|
|
257
|
+
// `oj openapi -o openapi.json`——监听工程根该文件变化触发 __client 重生成
|
|
258
|
+
// (落 web 树 → 二次触发上方 watch 走重建)。
|
|
260
259
|
try {
|
|
261
|
-
const
|
|
262
|
-
if (existsSync(
|
|
263
|
-
watch(
|
|
264
|
-
if (filename?.endsWith("contract.ts"))
|
|
265
|
-
regenContracts();
|
|
266
|
-
});
|
|
267
|
-
}
|
|
260
|
+
const specPath = join(projectRoot, "openapi.json");
|
|
261
|
+
if (existsSync(specPath))
|
|
262
|
+
watch(specPath, () => regenContracts());
|
|
268
263
|
}
|
|
269
264
|
catch {
|
|
270
|
-
//
|
|
265
|
+
// 同上:平台不支持时退化为手动 ojm api
|
|
271
266
|
}
|
|
272
267
|
|
|
273
268
|
// Vite 风格快捷键:r 热更新 / h 帮助 / c 清屏 / o 打开浏览器 / q 退出。
|
|
@@ -329,7 +324,12 @@ export interface DevKeymapDeps {
|
|
|
329
324
|
oj: OjProcess | undefined
|
|
330
325
|
hub: { close: () => void }
|
|
331
326
|
port: number
|
|
332
|
-
|
|
327
|
+
/**
|
|
328
|
+
* 键盘输入流:默认 `process.stdin`(TTY);测试可注入任意 Readable(如 PassThrough)。
|
|
329
|
+
* 宽松成 ReadableStream + 可选 setRawMode:keypress 监听与非 TTY 路径都不依赖 TTY 方法,
|
|
330
|
+
* 而 `NodeJS.ReadStream` 强求 isTTY/setRawMode 等成员,注入流无法满足(TS2740)。
|
|
331
|
+
*/
|
|
332
|
+
input?: NodeJS.ReadableStream & { setRawMode?: (mode: boolean) => unknown }
|
|
333
333
|
openBrowser?: (url: string) => void
|
|
334
334
|
onReload: () => void
|
|
335
335
|
onQuit: () => void
|
|
@@ -346,7 +346,9 @@ function defaultOpenBrowser(url: string): void {
|
|
|
346
346
|
}
|
|
347
347
|
|
|
348
348
|
export function setupDevKeymap(deps: DevKeymapDeps): void {
|
|
349
|
-
|
|
349
|
+
// 显式标注收敛 `??` 的联合类型(ReadStream ∩ 可选 setRawMode 的并集会让 .on 签名不可调和)
|
|
350
|
+
type KeymapInput = NodeJS.ReadableStream & { setRawMode?: (mode: boolean) => unknown };
|
|
351
|
+
const input: KeymapInput = deps.input ?? process.stdin;
|
|
350
352
|
const log = deps.log ?? ((msg: string) => console.log(msg));
|
|
351
353
|
const open = deps.openBrowser ?? defaultOpenBrowser;
|
|
352
354
|
const url = `http://localhost:${deps.port}`;
|