@yrest/cli 0.9.0 → 0.11.0
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/README.md +158 -53
- package/assets/yrest-banner.png +0 -0
- package/assets/yrest-lockup.png +0 -0
- package/assets/yrest-lockup.svg +20 -0
- package/assets/yrest-logo.png +0 -0
- package/assets/yrest-monochrome.png +0 -0
- package/dist/cli/index.js +1273 -145
- package/dist/cli/index.mjs +1270 -142
- package/dist/index.d.mts +52 -10
- package/dist/index.d.ts +52 -10
- package/dist/index.js +1223 -94
- package/dist/index.mjs +1220 -91
- package/package.json +1 -1
- package/assets/logo-color.png +0 -0
- package/assets/logo-figure.png +0 -0
- package/assets/logo-text.png +0 -0
- package/assets/logo-white.png +0 -0
- package/assets/yRest-banner.png +0 -0
package/dist/index.mjs
CHANGED
|
@@ -33,37 +33,136 @@ function parseRelations(raw) {
|
|
|
33
33
|
}
|
|
34
34
|
function normaliseRelationDef(key, value) {
|
|
35
35
|
if (typeof value === "string") {
|
|
36
|
-
return { type: "many2one", target: value };
|
|
36
|
+
return value.includes(":") ? parseDslString(value) : { type: "many2one", target: value };
|
|
37
37
|
}
|
|
38
38
|
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
39
39
|
const v = value;
|
|
40
|
-
const type = v["
|
|
41
|
-
const nested = v["
|
|
40
|
+
const type = v["_type"];
|
|
41
|
+
const nested = v["_nested"] === true ? true : void 0;
|
|
42
|
+
const carDirect = typeof v["_car-direct"] === "string" ? v["_car-direct"] : void 0;
|
|
43
|
+
const carInverse = typeof v["_car-inverse"] === "string" ? v["_car-inverse"] : void 0;
|
|
42
44
|
if (type === "many2one" || type === void 0) {
|
|
43
|
-
const target = v["
|
|
45
|
+
const target = v["_target"];
|
|
44
46
|
if (typeof target !== "string") return null;
|
|
45
|
-
|
|
47
|
+
const foreignKey = typeof v["_foreignKey"] === "string" ? v["_foreignKey"] : void 0;
|
|
48
|
+
return compact({ type: "many2one", target, foreignKey, nested, carDirect, carInverse });
|
|
46
49
|
}
|
|
47
50
|
if (type === "one2one") {
|
|
48
|
-
const target = v["
|
|
51
|
+
const target = v["_target"];
|
|
49
52
|
if (typeof target !== "string") return null;
|
|
50
|
-
|
|
53
|
+
const foreignKey = typeof v["_foreignKey"] === "string" ? v["_foreignKey"] : void 0;
|
|
54
|
+
return compact({ type: "one2one", target, foreignKey, nested, carDirect, carInverse });
|
|
51
55
|
}
|
|
52
56
|
if (type === "many2many") {
|
|
53
|
-
const target = typeof v["
|
|
54
|
-
const through = v["
|
|
55
|
-
const foreignKey = v["
|
|
56
|
-
const otherKey = v["
|
|
57
|
+
const target = typeof v["_target"] === "string" ? v["_target"] : key;
|
|
58
|
+
const through = v["_through"];
|
|
59
|
+
const foreignKey = v["_foreignKey"];
|
|
60
|
+
const otherKey = v["_otherKey"];
|
|
57
61
|
if (typeof through !== "string" || typeof foreignKey !== "string" || typeof otherKey !== "string")
|
|
58
62
|
return null;
|
|
59
|
-
return
|
|
63
|
+
return compact({
|
|
64
|
+
type: "many2many",
|
|
65
|
+
target,
|
|
66
|
+
through,
|
|
67
|
+
foreignKey,
|
|
68
|
+
otherKey,
|
|
69
|
+
nested,
|
|
70
|
+
carDirect,
|
|
71
|
+
carInverse
|
|
72
|
+
});
|
|
60
73
|
}
|
|
61
74
|
return null;
|
|
62
75
|
}
|
|
76
|
+
function parseDslString(raw) {
|
|
77
|
+
const nested = raw.endsWith("+nested") ? true : void 0;
|
|
78
|
+
let s = nested ? raw.slice(0, -7) : raw;
|
|
79
|
+
let carDirect;
|
|
80
|
+
let carInverse;
|
|
81
|
+
const carMatch = s.match(/\[([^[\]]+)->([^[\]]+)\]$/);
|
|
82
|
+
if (carMatch) {
|
|
83
|
+
carDirect = carMatch[1];
|
|
84
|
+
carInverse = carMatch[2];
|
|
85
|
+
s = s.slice(0, s.length - carMatch[0].length);
|
|
86
|
+
}
|
|
87
|
+
const colonIdx = s.indexOf(":");
|
|
88
|
+
if (colonIdx === -1) return null;
|
|
89
|
+
const typeAlias = s.slice(0, colonIdx);
|
|
90
|
+
const body = s.slice(colonIdx + 1);
|
|
91
|
+
const type = TYPE_ALIASES[typeAlias];
|
|
92
|
+
if (!type) return null;
|
|
93
|
+
if (type === "many2many") {
|
|
94
|
+
const m = body.match(/^([^@()[\]]+)@([^@()[\]]+)\(([^,)]+),\s*([^,)]+)\)$/);
|
|
95
|
+
if (!m) return null;
|
|
96
|
+
const [, target2, through, foreignKey2, otherKey] = m;
|
|
97
|
+
return compact({
|
|
98
|
+
type: "many2many",
|
|
99
|
+
target: target2,
|
|
100
|
+
through,
|
|
101
|
+
foreignKey: foreignKey2,
|
|
102
|
+
otherKey,
|
|
103
|
+
nested,
|
|
104
|
+
carDirect,
|
|
105
|
+
carInverse
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
const atIdx = body.indexOf("@");
|
|
109
|
+
const target = atIdx === -1 ? body : body.slice(0, atIdx);
|
|
110
|
+
const foreignKey = atIdx === -1 ? void 0 : body.slice(atIdx + 1);
|
|
111
|
+
if (!target) return null;
|
|
112
|
+
return compact({ type, target, foreignKey, nested, carDirect, carInverse });
|
|
113
|
+
}
|
|
114
|
+
function compact(obj) {
|
|
115
|
+
return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== void 0));
|
|
116
|
+
}
|
|
117
|
+
var TYPE_ALIASES;
|
|
63
118
|
var init_parseRelations = __esm({
|
|
64
119
|
"src/storage/parseRelations.ts"() {
|
|
65
120
|
"use strict";
|
|
66
121
|
init_esm_shims();
|
|
122
|
+
TYPE_ALIASES = {
|
|
123
|
+
many2one: "many2one",
|
|
124
|
+
m2o: "many2one",
|
|
125
|
+
one2one: "one2one",
|
|
126
|
+
o2o: "one2one",
|
|
127
|
+
many2many: "many2many",
|
|
128
|
+
m2m: "many2many"
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// src/storage/parseSchema.ts
|
|
134
|
+
function parseSchema(raw) {
|
|
135
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
|
|
136
|
+
const result = {};
|
|
137
|
+
for (const [collection, fields] of Object.entries(raw)) {
|
|
138
|
+
if (!fields || typeof fields !== "object" || Array.isArray(fields)) continue;
|
|
139
|
+
result[collection] = {};
|
|
140
|
+
for (const [field, value] of Object.entries(fields)) {
|
|
141
|
+
const def = normaliseFieldDef(value);
|
|
142
|
+
if (def) result[collection][field] = def;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return result;
|
|
146
|
+
}
|
|
147
|
+
function normaliseFieldDef(value) {
|
|
148
|
+
if (value === "required") return { required: true };
|
|
149
|
+
if (value === "optional") return { required: false };
|
|
150
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
151
|
+
const v = value;
|
|
152
|
+
const def = {};
|
|
153
|
+
if (v["_required"] === true || v["_required"] === false) def.required = v["_required"];
|
|
154
|
+
if (typeof v["_type"] === "string" && ["string", "integer", "number", "boolean", "object", "array"].includes(v["_type"]))
|
|
155
|
+
def.type = v["_type"];
|
|
156
|
+
if (typeof v["_format"] === "string") def.format = v["_format"];
|
|
157
|
+
if (Array.isArray(v["_enum"])) def.enum = v["_enum"];
|
|
158
|
+
if (typeof v["_description"] === "string") def.description = v["_description"];
|
|
159
|
+
if (v["_default"] !== void 0) def.default = v["_default"];
|
|
160
|
+
return def;
|
|
161
|
+
}
|
|
162
|
+
var init_parseSchema = __esm({
|
|
163
|
+
"src/storage/parseSchema.ts"() {
|
|
164
|
+
"use strict";
|
|
165
|
+
init_esm_shims();
|
|
67
166
|
}
|
|
68
167
|
});
|
|
69
168
|
|
|
@@ -80,6 +179,60 @@ var init_deepCopy = __esm({
|
|
|
80
179
|
}
|
|
81
180
|
});
|
|
82
181
|
|
|
182
|
+
// src/storage/parseRoutes.ts
|
|
183
|
+
function parseRoutes(raw) {
|
|
184
|
+
if (!Array.isArray(raw)) return [];
|
|
185
|
+
return raw.map(normaliseRoute).filter((r) => r !== null);
|
|
186
|
+
}
|
|
187
|
+
function normaliseRoute(raw) {
|
|
188
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
|
189
|
+
const v = raw;
|
|
190
|
+
const method = v["_method"];
|
|
191
|
+
const path2 = v["_path"];
|
|
192
|
+
if (typeof method !== "string" || typeof path2 !== "string") return null;
|
|
193
|
+
const route = { method, path: path2 };
|
|
194
|
+
if (typeof v["_handler"] === "string") route.handler = v["_handler"];
|
|
195
|
+
if (typeof v["_delay"] === "number") route.delay = v["_delay"];
|
|
196
|
+
if (typeof v["_error"] === "number") route.error = v["_error"];
|
|
197
|
+
if (v["_errorBody"] !== void 0) route.errorBody = v["_errorBody"];
|
|
198
|
+
const response = normaliseResponse(v["_response"]);
|
|
199
|
+
if (response) route.response = response;
|
|
200
|
+
const otherwise = normaliseResponse(v["_otherwise"]);
|
|
201
|
+
if (otherwise) route.otherwise = otherwise;
|
|
202
|
+
const scenarios = normaliseScenarios(v["_scenarios"]);
|
|
203
|
+
if (scenarios.length > 0) route.scenarios = scenarios;
|
|
204
|
+
return route;
|
|
205
|
+
}
|
|
206
|
+
function normaliseResponse(raw) {
|
|
207
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return void 0;
|
|
208
|
+
const v = raw;
|
|
209
|
+
const response = {};
|
|
210
|
+
if (typeof v["_status"] === "number") response.status = v["_status"];
|
|
211
|
+
if (v["_body"] !== void 0) response.body = v["_body"];
|
|
212
|
+
if (v["_headers"] && typeof v["_headers"] === "object" && !Array.isArray(v["_headers"]))
|
|
213
|
+
response.headers = v["_headers"];
|
|
214
|
+
return Object.keys(response).length > 0 ? response : void 0;
|
|
215
|
+
}
|
|
216
|
+
function normaliseScenarios(raw) {
|
|
217
|
+
if (!Array.isArray(raw)) return [];
|
|
218
|
+
return raw.map(normaliseScenario).filter((s) => s !== null);
|
|
219
|
+
}
|
|
220
|
+
function normaliseScenario(raw) {
|
|
221
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
|
222
|
+
const v = raw;
|
|
223
|
+
const when = v["_when"];
|
|
224
|
+
if (!when || typeof when !== "object") return null;
|
|
225
|
+
const response = normaliseResponse(v["_response"]);
|
|
226
|
+
if (!response) return null;
|
|
227
|
+
return { when, response };
|
|
228
|
+
}
|
|
229
|
+
var init_parseRoutes = __esm({
|
|
230
|
+
"src/storage/parseRoutes.ts"() {
|
|
231
|
+
"use strict";
|
|
232
|
+
init_esm_shims();
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
|
|
83
236
|
// src/storage/yrestStorage.ts
|
|
84
237
|
var yrestStorage_exports = {};
|
|
85
238
|
__export(yrestStorage_exports, {
|
|
@@ -88,14 +241,70 @@ __export(yrestStorage_exports, {
|
|
|
88
241
|
import { readFileSync as readFileSync2, writeFileSync, renameSync } from "fs";
|
|
89
242
|
import { resolve, dirname as dirname2 } from "path";
|
|
90
243
|
import { randomUUID as randomUUID2 } from "crypto";
|
|
91
|
-
import { parse as parse2, stringify } from "yaml";
|
|
244
|
+
import { parse as parse2, stringify as stringify2 } from "yaml";
|
|
245
|
+
function serializeRelations(relations) {
|
|
246
|
+
const out = {};
|
|
247
|
+
for (const [col, fields] of Object.entries(relations)) {
|
|
248
|
+
out[col] = {};
|
|
249
|
+
for (const [key, def] of Object.entries(fields)) {
|
|
250
|
+
out[col][key] = serializeRelationDef(def);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return out;
|
|
254
|
+
}
|
|
255
|
+
function serializeRelationDef(def) {
|
|
256
|
+
if (def.type === "many2many") {
|
|
257
|
+
const entry2 = {
|
|
258
|
+
_type: def.type,
|
|
259
|
+
_target: def.target,
|
|
260
|
+
_through: def.through,
|
|
261
|
+
_foreignKey: def.foreignKey,
|
|
262
|
+
_otherKey: def.otherKey
|
|
263
|
+
};
|
|
264
|
+
if (def.nested) entry2._nested = true;
|
|
265
|
+
if (def.carDirect) entry2["_car-direct"] = def.carDirect;
|
|
266
|
+
if (def.carInverse) entry2["_car-inverse"] = def.carInverse;
|
|
267
|
+
return entry2;
|
|
268
|
+
}
|
|
269
|
+
const entry = { _type: def.type, _target: def.target };
|
|
270
|
+
if (def.foreignKey) entry._foreignKey = def.foreignKey;
|
|
271
|
+
if (def.nested) entry._nested = true;
|
|
272
|
+
if (def.carDirect) entry["_car-direct"] = def.carDirect;
|
|
273
|
+
if (def.carInverse) entry["_car-inverse"] = def.carInverse;
|
|
274
|
+
return entry;
|
|
275
|
+
}
|
|
276
|
+
function serializeRoutes(routes) {
|
|
277
|
+
return routes.map((r) => {
|
|
278
|
+
const entry = { _method: r.method, _path: r.path };
|
|
279
|
+
if (r.handler) entry._handler = r.handler;
|
|
280
|
+
if (r.delay !== void 0) entry._delay = r.delay;
|
|
281
|
+
if (r.error !== void 0) entry._error = r.error;
|
|
282
|
+
if (r.errorBody !== void 0) entry._errorBody = r.errorBody;
|
|
283
|
+
if (r.response) entry._response = serializeResponse(r.response);
|
|
284
|
+
if (r.otherwise) entry._otherwise = serializeResponse(r.otherwise);
|
|
285
|
+
if (r.scenarios?.length) entry._scenarios = r.scenarios.map(serializeScenario);
|
|
286
|
+
return entry;
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
function serializeResponse(res) {
|
|
290
|
+
const out = {};
|
|
291
|
+
if (res.status !== void 0) out._status = res.status;
|
|
292
|
+
if (res.body !== void 0) out._body = res.body;
|
|
293
|
+
if (res.headers) out._headers = res.headers;
|
|
294
|
+
return out;
|
|
295
|
+
}
|
|
296
|
+
function serializeScenario(s) {
|
|
297
|
+
return { _when: s.when, _response: serializeResponse(s.response) };
|
|
298
|
+
}
|
|
92
299
|
function createYrestStorage(filePath) {
|
|
93
300
|
const absPath = resolve(filePath);
|
|
94
301
|
const raw = parse2(readFileSync2(absPath, "utf8")) ?? {};
|
|
302
|
+
const RESERVED = /* @__PURE__ */ new Set(["_rel", "_routes", "_schema"]);
|
|
95
303
|
const relations = parseRelations(raw["_rel"]);
|
|
96
|
-
const routes =
|
|
304
|
+
const routes = parseRoutes(raw["_routes"]);
|
|
305
|
+
const schema = parseSchema(raw["_schema"]);
|
|
97
306
|
const data = Object.fromEntries(
|
|
98
|
-
Object.entries(raw).filter(([key]) => key
|
|
307
|
+
Object.entries(raw).filter(([key]) => !RESERVED.has(key))
|
|
99
308
|
);
|
|
100
309
|
let snapshot = {
|
|
101
310
|
data: deepCopyData(data),
|
|
@@ -109,6 +318,9 @@ function createYrestStorage(filePath) {
|
|
|
109
318
|
getRelations() {
|
|
110
319
|
return relations;
|
|
111
320
|
},
|
|
321
|
+
getSchema() {
|
|
322
|
+
return schema;
|
|
323
|
+
},
|
|
112
324
|
getRoutes() {
|
|
113
325
|
return routes;
|
|
114
326
|
},
|
|
@@ -120,18 +332,18 @@ function createYrestStorage(filePath) {
|
|
|
120
332
|
},
|
|
121
333
|
persist() {
|
|
122
334
|
const payload = {};
|
|
123
|
-
if (Object.keys(relations).length > 0) payload._rel = relations;
|
|
124
|
-
if (routes.length > 0) payload._routes = routes;
|
|
335
|
+
if (Object.keys(relations).length > 0) payload._rel = serializeRelations(relations);
|
|
336
|
+
if (routes.length > 0) payload._routes = serializeRoutes(routes);
|
|
125
337
|
Object.assign(payload, data);
|
|
126
338
|
const tmp = resolve(dirname2(absPath), `.yrest-${randomUUID2()}.tmp`);
|
|
127
|
-
writeFileSync(tmp,
|
|
339
|
+
writeFileSync(tmp, stringify2(payload), "utf8");
|
|
128
340
|
renameSync(tmp, absPath);
|
|
129
341
|
},
|
|
130
342
|
reload() {
|
|
131
343
|
const fresh = parse2(readFileSync2(absPath, "utf8")) ?? {};
|
|
132
344
|
const freshRelations = parseRelations(fresh["_rel"]);
|
|
133
345
|
const freshData = Object.fromEntries(
|
|
134
|
-
Object.entries(fresh).filter(([key]) => key
|
|
346
|
+
Object.entries(fresh).filter(([key]) => !RESERVED.has(key))
|
|
135
347
|
);
|
|
136
348
|
for (const key of Object.keys(data)) delete data[key];
|
|
137
349
|
Object.assign(data, freshData);
|
|
@@ -163,6 +375,8 @@ var init_yrestStorage = __esm({
|
|
|
163
375
|
"use strict";
|
|
164
376
|
init_esm_shims();
|
|
165
377
|
init_parseRelations();
|
|
378
|
+
init_parseRoutes();
|
|
379
|
+
init_parseSchema();
|
|
166
380
|
init_deepCopy();
|
|
167
381
|
}
|
|
168
382
|
});
|
|
@@ -215,6 +429,7 @@ function dedent(str) {
|
|
|
215
429
|
// src/api/yrestServer.ts
|
|
216
430
|
init_esm_shims();
|
|
217
431
|
init_parseRelations();
|
|
432
|
+
init_parseSchema();
|
|
218
433
|
import { resolve as resolve2 } from "path";
|
|
219
434
|
|
|
220
435
|
// src/utils/handlers.ts
|
|
@@ -348,7 +563,7 @@ function endpointRow(method, path2, desc) {
|
|
|
348
563
|
}
|
|
349
564
|
function resourceAccordion(name, base, isOpen) {
|
|
350
565
|
const p = `${base}/${name}`;
|
|
351
|
-
const
|
|
566
|
+
const singular2 = name.endsWith("s") ? name.slice(0, -1) : name;
|
|
352
567
|
const rows = [
|
|
353
568
|
endpointRow(
|
|
354
569
|
"GET",
|
|
@@ -358,20 +573,20 @@ function resourceAccordion(name, base, isOpen) {
|
|
|
358
573
|
endpointRow(
|
|
359
574
|
"POST",
|
|
360
575
|
p,
|
|
361
|
-
`Create a new ${
|
|
576
|
+
`Create a new ${singular2}. Auto-assigns <code>id</code> if not provided.`
|
|
362
577
|
),
|
|
363
|
-
endpointRow("GET", `${p}/:id`, `Get a single ${
|
|
578
|
+
endpointRow("GET", `${p}/:id`, `Get a single ${singular2} by id.`),
|
|
364
579
|
endpointRow(
|
|
365
580
|
"PUT",
|
|
366
581
|
`${p}/:id`,
|
|
367
|
-
`Fully replace a ${
|
|
582
|
+
`Fully replace a ${singular2}. Original <code>id</code> is always preserved.`
|
|
368
583
|
),
|
|
369
584
|
endpointRow(
|
|
370
585
|
"PATCH",
|
|
371
586
|
`${p}/:id`,
|
|
372
|
-
`Partially update a ${
|
|
587
|
+
`Partially update a ${singular2} \u2014 only provided fields change.`
|
|
373
588
|
),
|
|
374
|
-
endpointRow("DELETE", `${p}/:id`, `Delete a ${
|
|
589
|
+
endpointRow("DELETE", `${p}/:id`, `Delete a ${singular2} and return it as confirmation.`)
|
|
375
590
|
].join("");
|
|
376
591
|
return `
|
|
377
592
|
<details class="resource-card" ${isOpen ? "open" : ""}>
|
|
@@ -390,13 +605,13 @@ function nestedRoutesAccordion(relations, base) {
|
|
|
390
605
|
for (const [key, def] of Object.entries(fields)) {
|
|
391
606
|
const nestedBadge = def.nested ? ` ${badge("nested", "#facc15", "#facc1518")}` : "";
|
|
392
607
|
if (def.type === "many2many") {
|
|
393
|
-
const
|
|
608
|
+
const singular2 = source.endsWith("s") ? source.slice(0, -1) : source;
|
|
394
609
|
const m2mBadge = badge("many2many", "#818cf8", "#818cf818");
|
|
395
610
|
rows.push(
|
|
396
611
|
endpointRow(
|
|
397
612
|
"GET",
|
|
398
613
|
`${base}/${source}/:id/${key}`,
|
|
399
|
-
`List ${def.target} linked to a ${
|
|
614
|
+
`List ${def.target} linked to a ${singular2} via ${def.through}. ${m2mBadge}${nestedBadge}`
|
|
400
615
|
)
|
|
401
616
|
);
|
|
402
617
|
const targetSingular = def.target.endsWith("s") ? def.target.slice(0, -1) : def.target;
|
|
@@ -522,7 +737,7 @@ function examplesBlock(collections, relations, base, host, options, firstCustomR
|
|
|
522
737
|
const firstCol = collections[0];
|
|
523
738
|
if (firstCol) {
|
|
524
739
|
const p = `${host}${base}/${firstCol}`;
|
|
525
|
-
const
|
|
740
|
+
const singular2 = firstCol.endsWith("s") ? firstCol.slice(0, -1) : firstCol;
|
|
526
741
|
examples.push(
|
|
527
742
|
`# List all ${firstCol}
|
|
528
743
|
curl ${p}`,
|
|
@@ -530,17 +745,17 @@ curl ${p}`,
|
|
|
530
745
|
curl "${p}?name=value"`,
|
|
531
746
|
`# Sort and paginate
|
|
532
747
|
curl "${p}?_sort=id&_order=desc&_page=1&_limit=5"`,
|
|
533
|
-
`# Get single ${
|
|
748
|
+
`# Get single ${singular2}
|
|
534
749
|
curl ${p}/1`,
|
|
535
|
-
`# Create ${
|
|
750
|
+
`# Create ${singular2}
|
|
536
751
|
curl -X POST ${p} \\
|
|
537
752
|
-H "Content-Type: application/json" \\
|
|
538
753
|
-d '{"name":"example"}'`,
|
|
539
|
-
`# Partially update ${
|
|
754
|
+
`# Partially update ${singular2}
|
|
540
755
|
curl -X PATCH ${p}/1 \\
|
|
541
756
|
-H "Content-Type: application/json" \\
|
|
542
757
|
-d '{"name":"updated"}'`,
|
|
543
|
-
`# Delete ${
|
|
758
|
+
`# Delete ${singular2}
|
|
544
759
|
curl -X DELETE ${p}/1`
|
|
545
760
|
);
|
|
546
761
|
}
|
|
@@ -603,12 +818,510 @@ curl ${curlFlag}${fullPath}`);
|
|
|
603
818
|
return `<pre>${highlighted}</pre>`;
|
|
604
819
|
}
|
|
605
820
|
|
|
821
|
+
// src/router/templates/about.diagram.ts
|
|
822
|
+
init_esm_shims();
|
|
823
|
+
function inferType(value) {
|
|
824
|
+
if (value === null || value === void 0) return "string";
|
|
825
|
+
if (typeof value === "boolean") return "boolean";
|
|
826
|
+
if (typeof value === "number") return Number.isInteger(value) ? "integer" : "number";
|
|
827
|
+
if (Array.isArray(value)) return "array";
|
|
828
|
+
if (typeof value === "object") return "object";
|
|
829
|
+
return "string";
|
|
830
|
+
}
|
|
831
|
+
function generateERData(storage) {
|
|
832
|
+
const data = storage.getData();
|
|
833
|
+
const relations = storage.getRelations();
|
|
834
|
+
const schema = storage.getSchema();
|
|
835
|
+
const pivotTables = /* @__PURE__ */ new Set();
|
|
836
|
+
for (const fields of Object.values(relations)) {
|
|
837
|
+
for (const def of Object.values(fields)) {
|
|
838
|
+
if (def.type === "many2many") pivotTables.add(def.through);
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
const fkFields = /* @__PURE__ */ new Map();
|
|
842
|
+
for (const [source, fields] of Object.entries(relations)) {
|
|
843
|
+
for (const [key, def] of Object.entries(fields)) {
|
|
844
|
+
if (def.type !== "many2many") {
|
|
845
|
+
if (!fkFields.has(source)) fkFields.set(source, /* @__PURE__ */ new Set());
|
|
846
|
+
fkFields.get(source).add(key);
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
const entities = Object.entries(data).map(([name, items]) => {
|
|
851
|
+
const sample = items[0] ?? {};
|
|
852
|
+
const schemaFields = schema[name] ?? {};
|
|
853
|
+
const allFields = /* @__PURE__ */ new Set([...Object.keys(sample), ...Object.keys(schemaFields)]);
|
|
854
|
+
const fields = [...allFields].map((fieldName) => ({
|
|
855
|
+
name: fieldName,
|
|
856
|
+
type: schemaFields[fieldName]?.type ?? inferType(sample[fieldName]),
|
|
857
|
+
pk: fieldName === "id",
|
|
858
|
+
fk: fkFields.get(name)?.has(fieldName) ?? false,
|
|
859
|
+
required: schemaFields[fieldName]?.required ?? false
|
|
860
|
+
}));
|
|
861
|
+
fields.sort((a, b) => {
|
|
862
|
+
if (a.pk) return -1;
|
|
863
|
+
if (b.pk) return 1;
|
|
864
|
+
if (a.fk && !b.fk) return 1;
|
|
865
|
+
if (!a.fk && b.fk) return -1;
|
|
866
|
+
return 0;
|
|
867
|
+
});
|
|
868
|
+
return { name, fields, isPivot: pivotTables.has(name) };
|
|
869
|
+
});
|
|
870
|
+
const erRelations = [];
|
|
871
|
+
for (const [source, fields] of Object.entries(relations)) {
|
|
872
|
+
for (const [key, def] of Object.entries(fields)) {
|
|
873
|
+
if (def.type === "many2many") {
|
|
874
|
+
erRelations.push({
|
|
875
|
+
source,
|
|
876
|
+
target: def.target,
|
|
877
|
+
type: "many2many",
|
|
878
|
+
label: def.through,
|
|
879
|
+
through: def.through,
|
|
880
|
+
foreignKey: def.foreignKey,
|
|
881
|
+
otherKey: def.otherKey
|
|
882
|
+
});
|
|
883
|
+
} else {
|
|
884
|
+
erRelations.push({
|
|
885
|
+
source,
|
|
886
|
+
target: def.target,
|
|
887
|
+
type: def.type,
|
|
888
|
+
label: key,
|
|
889
|
+
carDirect: def.carDirect,
|
|
890
|
+
carInverse: def.carInverse
|
|
891
|
+
});
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
return { entities, relations: erRelations };
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
// src/router/templates/about.diagram.canvas.ts
|
|
899
|
+
init_esm_shims();
|
|
900
|
+
function erDiagramSection(data) {
|
|
901
|
+
if (!data.entities.length) return "";
|
|
902
|
+
const json = JSON.stringify(data);
|
|
903
|
+
return (
|
|
904
|
+
/*html*/
|
|
905
|
+
`
|
|
906
|
+
<h2>Data Model</h2>
|
|
907
|
+
|
|
908
|
+
<div class="card" style="padding:0;overflow:hidden;position:relative;">
|
|
909
|
+
<canvas id="er-canvas" style="width:100%;display:block;cursor:grab;"></canvas>
|
|
910
|
+
<button id="er-dl" title="Download PNG" style="position:absolute;top:10px;right:14px;background:#161b22;border:1px solid #30363d;color:#7d8590;padding:4px 10px;border-radius:6px;font-size:11px;cursor:pointer;line-height:1.4;">\u2193 PNG</button>
|
|
911
|
+
</div>
|
|
912
|
+
|
|
913
|
+
<script>${/*js*/
|
|
914
|
+
`
|
|
915
|
+
(function(){
|
|
916
|
+
|
|
917
|
+
var D=${json};
|
|
918
|
+
|
|
919
|
+
// \u2500\u2500 Constants \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
920
|
+
var NW=178,HH=34,FH=22,FP=10,ROW_GAP=44,COL_GAP=100,BR=8,BO=14,LGH=56;
|
|
921
|
+
|
|
922
|
+
var C={bg:'#0d1117',nb:'#161b22',nh:'#1c2128',pb:'#0d1117',ph:'#161b22',
|
|
923
|
+
bo:'#30363d',bs:'#58a6ff',tx:'#e6edf3',mu:'#7d8590',
|
|
924
|
+
pk:'#e3b341',fk:'#79c0ff',m2o:'#58a6ff',o2o:'#34d399',m2m:'#818cf8'};
|
|
925
|
+
|
|
926
|
+
// \u2500\u2500 Canvas \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
927
|
+
var cv = document.getElementById('er-canvas');
|
|
928
|
+
var dpr = window.devicePixelRatio||1;
|
|
929
|
+
var ctx = cv.getContext('2d');
|
|
930
|
+
|
|
931
|
+
function resize(h){
|
|
932
|
+
if(h)cv.style.height = h + 'px';
|
|
933
|
+
var r = cv.getBoundingClientRect();
|
|
934
|
+
cv.width = Math.round(r.width*dpr);
|
|
935
|
+
cv.height = Math.round(r.height*dpr);
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
// \u2500\u2500 Nodes & adjacency \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
939
|
+
var nodes = D.entities.map(function(e){
|
|
940
|
+
return{e:e,x:0,y:0,w:NW,h:HH+e.fields.length*FH+6};
|
|
941
|
+
});
|
|
942
|
+
|
|
943
|
+
var nIdx={};D.entities.forEach(function(e,i){nIdx[e.name]=i;});
|
|
944
|
+
|
|
945
|
+
var adj=nodes.map(function(){return[];}); // forward
|
|
946
|
+
var radj=nodes.map(function(){return[];}); // reverse
|
|
947
|
+
|
|
948
|
+
D.relations.forEach(function(r){
|
|
949
|
+
if(r.type==='many2many'&&r.through&&nIdx[r.through]!=null){
|
|
950
|
+
var s=nIdx[r.source],p=nIdx[r.through],t=nIdx[r.target];
|
|
951
|
+
if(s!=null&&p!=null&&s!==p){adj[s].push(p);radj[p].push(s);}
|
|
952
|
+
if(t!=null&&p!=null&&t!==p){adj[t].push(p);radj[p].push(t);}
|
|
953
|
+
}else{
|
|
954
|
+
var s=nIdx[r.source],t=nIdx[r.target];
|
|
955
|
+
if(s==null||t==null||s===t)return;
|
|
956
|
+
adj[s].push(t);radj[t].push(s);
|
|
957
|
+
}
|
|
958
|
+
});
|
|
959
|
+
|
|
960
|
+
// \u2500\u2500 Layered layout (simplified Sugiyama) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
961
|
+
var groups=[];
|
|
962
|
+
|
|
963
|
+
function median(arr){
|
|
964
|
+
if(!arr.length)return 0;
|
|
965
|
+
var s=[].concat(arr).sort(function(a,b){return a-b;});
|
|
966
|
+
return s[Math.floor(s.length/2)];
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
function computeLayout(W,H){
|
|
970
|
+
var layer=nodes.map(function(){return 0;});
|
|
971
|
+
var inDeg=nodes.map(function(){return 0;});
|
|
972
|
+
adj.forEach(function(ts){ts.forEach(function(t){inDeg[t]++;});});
|
|
973
|
+
var q=[],qi=0;
|
|
974
|
+
for(var i=0;i<nodes.length;i++)if(inDeg[i]===0)q.push(i);
|
|
975
|
+
if(!q.length)for(var i=0;i<nodes.length;i++)q.push(i);
|
|
976
|
+
while(qi<q.length){
|
|
977
|
+
var u=q[qi++];
|
|
978
|
+
adj[u].forEach(function(v){if(layer[v]<=layer[u]){layer[v]=layer[u]+1;q.push(v);}});
|
|
979
|
+
}
|
|
980
|
+
var maxL=layer.reduce(function(m,l){return Math.max(m,l);},0);
|
|
981
|
+
groups=[];
|
|
982
|
+
for(var l=0;l<=maxL;l++)groups.push([]);
|
|
983
|
+
for(var i=0;i<nodes.length;i++)groups[layer[i]].push(i);
|
|
984
|
+
for(var l=1;l<=maxL;l++){
|
|
985
|
+
var pp={};groups[l-1].forEach(function(i,p){pp[i]=p;});
|
|
986
|
+
groups[l].sort(function(a,b){
|
|
987
|
+
return median(radj[a].map(function(x){return pp[x]!=null?pp[x]:0;}))-
|
|
988
|
+
median(radj[b].map(function(x){return pp[x]!=null?pp[x]:0;}));
|
|
989
|
+
});
|
|
990
|
+
}
|
|
991
|
+
for(var l=maxL-1;l>=0;l--){
|
|
992
|
+
var np={};groups[l+1].forEach(function(i,p){np[i]=p;});
|
|
993
|
+
groups[l].sort(function(a,b){
|
|
994
|
+
return median(adj[a].map(function(x){return np[x]!=null?np[x]:0;}))-
|
|
995
|
+
median(adj[b].map(function(x){return np[x]!=null?np[x]:0;}));
|
|
996
|
+
});
|
|
997
|
+
}
|
|
998
|
+
var colW=NW+COL_GAP;
|
|
999
|
+
var totalW=(maxL+1)*colW-COL_GAP;
|
|
1000
|
+
var startX=Math.max(20,(W-totalW)/2);
|
|
1001
|
+
for(var l=0;l<=maxL;l++){
|
|
1002
|
+
var g=groups[l];
|
|
1003
|
+
var totalH=g.reduce(function(s,i){return s+nodes[i].h;},0)+(g.length-1)*ROW_GAP;
|
|
1004
|
+
var y=Math.max(20,(H-totalH)/2);
|
|
1005
|
+
g.forEach(function(i){nodes[i].x=startX+l*colW;nodes[i].y=y;y+=nodes[i].h+ROW_GAP;});
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
function requiredH(){
|
|
1010
|
+
return Math.max(340,Math.min(720,groups.reduce(function(m,g){
|
|
1011
|
+
return Math.max(m,g.reduce(function(s,i){return s+nodes[i].h;},0)+(g.length-1)*ROW_GAP+LGH+70);
|
|
1012
|
+
},300)));
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
// \u2500\u2500 View \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
1016
|
+
var pan={x:0,y:0},zm=1;
|
|
1017
|
+
var origPos=[];
|
|
1018
|
+
|
|
1019
|
+
function savePos(){origPos=nodes.map(function(n){return{x:n.x,y:n.y};});}
|
|
1020
|
+
|
|
1021
|
+
function fitView(W,H){
|
|
1022
|
+
var x0=1e9,y0=1e9,x1=-1e9,y1=-1e9;
|
|
1023
|
+
nodes.forEach(function(n){x0=Math.min(x0,n.x);y0=Math.min(y0,n.y);x1=Math.max(x1,n.x+n.w);y1=Math.max(y1,n.y+n.h);});
|
|
1024
|
+
var pad=28,gW=x1-x0+pad*2,gH=y1-y0+pad*2;
|
|
1025
|
+
zm=Math.min(W/gW,(H-LGH)/gH,1);
|
|
1026
|
+
pan.x=(W-gW*zm)/2+pad*zm-x0*zm;
|
|
1027
|
+
pan.y=((H-LGH)-gH*zm)/2+pad*zm-y0*zm;
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
// \u2500\u2500 Rendering \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
1031
|
+
function rr(x,y,w,h,r){
|
|
1032
|
+
if(ctx.roundRect){ctx.beginPath();ctx.roundRect(x,y,w,h,r);return;}
|
|
1033
|
+
ctx.beginPath();ctx.moveTo(x+r,y);ctx.arcTo(x+w,y,x+w,y+h,r);
|
|
1034
|
+
ctx.arcTo(x+w,y+h,x,y+h,r);ctx.arcTo(x,y+h,x,y,r);ctx.arcTo(x,y,x+w,y,r);ctx.closePath();
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
function edgePt(n,tx,ty){
|
|
1038
|
+
var cx=n.x+n.w/2,cy=n.y+n.h/2,dx=tx-cx,dy=ty-cy;
|
|
1039
|
+
if(!dx&&!dy)return{x:cx,y:cy};
|
|
1040
|
+
var s=Math.min((n.w/2)/Math.abs(dx||.001),(n.h/2)/Math.abs(dy||.001));
|
|
1041
|
+
return{x:cx+dx*s,y:cy+dy*s};
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
var badges=[];
|
|
1045
|
+
|
|
1046
|
+
function pushBadge(x,y,ux,uy,many,col){
|
|
1047
|
+
badges.push({x:x,y:y,ux:ux,uy:uy,many:many,col:col});
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
// Badge: solid colored circle; crow's foot (many) or double-bar (one) in dark ink inside.
|
|
1051
|
+
// ux,uy points outward from the node \u2014 badge is offset BO px into the gap.
|
|
1052
|
+
// Crow's foot tines open toward the node (-x in rotated frame), tip toward line (+x).
|
|
1053
|
+
function drawBadge(b){
|
|
1054
|
+
var bx=b.x+b.ux*BO,by=b.y+b.uy*BO;
|
|
1055
|
+
ctx.fillStyle=b.col;ctx.strokeStyle=b.col;ctx.lineWidth=1;
|
|
1056
|
+
ctx.beginPath();ctx.arc(bx,by,BR,0,Math.PI*2);ctx.fill();ctx.stroke();
|
|
1057
|
+
ctx.save();ctx.translate(bx,by);ctx.rotate(Math.atan2(b.uy,b.ux));
|
|
1058
|
+
ctx.strokeStyle=C.bg;ctx.lineWidth=1.4;ctx.lineCap='round';
|
|
1059
|
+
ctx.beginPath();
|
|
1060
|
+
if(b.many){
|
|
1061
|
+
ctx.moveTo(-5,-4);ctx.lineTo(5,0); // upper tine \u2192 tip
|
|
1062
|
+
ctx.moveTo(-5,4);ctx.lineTo(5,0); // lower tine \u2192 tip
|
|
1063
|
+
ctx.moveTo(-5,0);ctx.lineTo(5,0); // center line
|
|
1064
|
+
ctx.moveTo(-5,-4);ctx.lineTo(-5,4); // base bar (near node)
|
|
1065
|
+
}else{
|
|
1066
|
+
ctx.moveTo(-3,-5);ctx.lineTo(-3,5); // near bar
|
|
1067
|
+
ctx.moveTo(2,-5);ctx.lineTo(2,5); // far bar
|
|
1068
|
+
}
|
|
1069
|
+
ctx.stroke();
|
|
1070
|
+
ctx.restore();
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
function ecol(t){return t==='one2one'?C.o2o:t==='many2many'?C.m2m:C.m2o;}
|
|
1074
|
+
|
|
1075
|
+
// offset: pixels to shift perpendicular to the edge direction (for parallel edges)
|
|
1076
|
+
function drawEdgeLine(a,b,col,dashed,label,manyA,manyB,off){
|
|
1077
|
+
var p1=edgePt(a,b.x+b.w/2,b.y+b.h/2);
|
|
1078
|
+
var p2=edgePt(b,a.x+a.w/2,a.y+a.h/2);
|
|
1079
|
+
var dx=p2.x-p1.x,dy=p2.y-p1.y,d=Math.sqrt(dx*dx+dy*dy)||1;
|
|
1080
|
+
var ux=dx/d,uy=dy/d;
|
|
1081
|
+
var px=-uy*off,py=ux*off; // perpendicular shift
|
|
1082
|
+
var q1x=p1.x+px,q1y=p1.y+py,q2x=p2.x+px,q2y=p2.y+py;
|
|
1083
|
+
ctx.strokeStyle=col+'55';ctx.lineWidth=1.5;
|
|
1084
|
+
ctx.setLineDash(dashed?[5,4]:[]);
|
|
1085
|
+
ctx.beginPath();ctx.moveTo(q1x,q1y);ctx.lineTo(q2x,q2y);ctx.stroke();
|
|
1086
|
+
ctx.setLineDash([]);
|
|
1087
|
+
pushBadge(q1x,q1y,ux,uy,manyA,col);
|
|
1088
|
+
pushBadge(q2x,q2y,-ux,-uy,manyB,col);
|
|
1089
|
+
if(label){
|
|
1090
|
+
var mx=(q1x+q2x)/2,my=(q1y+q2y)/2;
|
|
1091
|
+
ctx.fillStyle=C.mu;ctx.font='10px monospace';ctx.textAlign='center';ctx.textBaseline='middle';
|
|
1092
|
+
ctx.fillText(label,mx-uy*14,my+ux*14);
|
|
1093
|
+
ctx.textBaseline='alphabetic';
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
// canonical key for a node pair regardless of direction
|
|
1098
|
+
function pairKey(a,b){
|
|
1099
|
+
var ai=nodes.indexOf(a),bi=nodes.indexOf(b);
|
|
1100
|
+
return ai<bi?ai+'-'+bi:bi+'-'+ai;
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
function collectEdges(){
|
|
1104
|
+
var edges=[];
|
|
1105
|
+
D.relations.forEach(function(r){
|
|
1106
|
+
var col=ecol(r.type);
|
|
1107
|
+
if(r.type==='many2many'&&r.through&&nIdx[r.through]!=null){
|
|
1108
|
+
var sn=nodes[nIdx[r.source]],pn=nodes[nIdx[r.through]],tn=nodes[nIdx[r.target]];
|
|
1109
|
+
if(sn&&pn)edges.push({a:sn,b:pn,col:col,dashed:true,label:r.foreignKey||'',manyA:false,manyB:true,off:0});
|
|
1110
|
+
if(tn&&pn)edges.push({a:tn,b:pn,col:col,dashed:true,label:r.otherKey||'',manyA:false,manyB:true,off:0});
|
|
1111
|
+
}else{
|
|
1112
|
+
var sn=nodes[nIdx[r.source]],tn=nodes[nIdx[r.target]];
|
|
1113
|
+
if(sn&&tn&&sn!==tn){
|
|
1114
|
+
var manyA=r.carInverse?r.carInverse.endsWith('n'):r.type!=='one2one';
|
|
1115
|
+
var manyB=r.carDirect?r.carDirect.endsWith('n'):r.type==='many2many';
|
|
1116
|
+
edges.push({a:sn,b:tn,col:col,dashed:false,label:r.label,manyA:manyA,manyB:manyB,off:0});
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
});
|
|
1120
|
+
// assign perpendicular offsets to parallel edges (same node pair)
|
|
1121
|
+
var cnt={},idx={};
|
|
1122
|
+
edges.forEach(function(e){var k=pairKey(e.a,e.b);cnt[k]=(cnt[k]||0)+1;});
|
|
1123
|
+
edges.forEach(function(e){
|
|
1124
|
+
var k=pairKey(e.a,e.b),n=cnt[k];
|
|
1125
|
+
var i=idx[k]=(idx[k]||0);
|
|
1126
|
+
e.off=n>1?(i-(n-1)/2)*16:0;
|
|
1127
|
+
idx[k]++;
|
|
1128
|
+
});
|
|
1129
|
+
return edges;
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
function drawNode(n){
|
|
1133
|
+
var x=n.x,y=n.y,w=n.w,h=n.h,e=n.e;
|
|
1134
|
+
var bg=e.isPivot?C.pb:C.nb,hd=e.isPivot?C.ph:C.nh;
|
|
1135
|
+
ctx.save();ctx.shadowColor='rgba(0,0,0,0.45)';ctx.shadowBlur=14;ctx.shadowOffsetY=4;
|
|
1136
|
+
ctx.fillStyle=bg;rr(x,y,w,h,7);ctx.fill();ctx.restore();
|
|
1137
|
+
ctx.fillStyle=bg;rr(x,y,w,h,7);ctx.fill();
|
|
1138
|
+
ctx.strokeStyle=n===drag?C.bs:C.bo;ctx.lineWidth=1.2;rr(x,y,w,h,7);ctx.stroke();
|
|
1139
|
+
ctx.fillStyle=hd;rr(x,y,w,HH,7);ctx.fill();ctx.fillRect(x,y+HH-7,w,7);
|
|
1140
|
+
ctx.strokeStyle=C.bo;ctx.lineWidth=1;
|
|
1141
|
+
ctx.beginPath();ctx.moveTo(x,y+HH);ctx.lineTo(x+w,y+HH);ctx.stroke();
|
|
1142
|
+
ctx.fillStyle=e.isPivot?C.mu:C.tx;
|
|
1143
|
+
ctx.font='600 12px -apple-system,BlinkMacSystemFont,sans-serif';
|
|
1144
|
+
ctx.textAlign='left';ctx.textBaseline='middle';
|
|
1145
|
+
ctx.fillText(e.name,x+FP,y+HH/2);
|
|
1146
|
+
if(e.isPivot){
|
|
1147
|
+
ctx.fillStyle='#3d444d';ctx.font='9px monospace';ctx.textAlign='right';
|
|
1148
|
+
ctx.fillText('pivot',x+w-FP,y+HH/2);
|
|
1149
|
+
}
|
|
1150
|
+
e.fields.forEach(function(f,fi){
|
|
1151
|
+
var fy=y+HH+fi*FH+FH/2+3;
|
|
1152
|
+
ctx.fillStyle=C.mu;ctx.font='10px monospace';ctx.textAlign='right';ctx.textBaseline='middle';
|
|
1153
|
+
ctx.fillText(f.type,x+w-FP-(f.required&&!f.pk?14:0),fy);
|
|
1154
|
+
if(f.pk||f.fk){
|
|
1155
|
+
var tag=f.pk?'PK':'FK',tc=f.pk?C.pk:C.fk;
|
|
1156
|
+
ctx.fillStyle=tc+'22';ctx.strokeStyle=tc+'66';ctx.lineWidth=1;
|
|
1157
|
+
rr(x+FP,fy-6,12,12,2);ctx.fill();ctx.stroke();
|
|
1158
|
+
ctx.fillStyle=tc;ctx.font='7px monospace';ctx.textAlign='center';
|
|
1159
|
+
ctx.fillText(tag,x+FP+6,fy+0.5);
|
|
1160
|
+
}
|
|
1161
|
+
ctx.fillStyle=f.pk?C.pk:f.fk?C.fk:C.tx;
|
|
1162
|
+
ctx.font=(f.required&&!f.pk?'600 ':'')+'11px monospace';
|
|
1163
|
+
ctx.textAlign='left';ctx.textBaseline='middle';
|
|
1164
|
+
ctx.fillText(f.name,x+FP+(f.pk||f.fk?16:0),fy);
|
|
1165
|
+
if(f.required&&!f.pk){
|
|
1166
|
+
ctx.fillStyle='#f85149';ctx.beginPath();
|
|
1167
|
+
ctx.arc(x+w-FP-6,fy,2.5,0,Math.PI*2);ctx.fill();
|
|
1168
|
+
}
|
|
1169
|
+
});
|
|
1170
|
+
ctx.textBaseline='alphabetic';
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
function drawLegend(){
|
|
1174
|
+
var cvW=cv.width/dpr,cvH=cv.height/dpr;
|
|
1175
|
+
var y0=cvH-LGH,y1=cvH-LGH+18,y2=cvH-LGH+38;
|
|
1176
|
+
|
|
1177
|
+
ctx.fillStyle='#161b22';
|
|
1178
|
+
ctx.fillRect(0,y0,cvW,LGH);
|
|
1179
|
+
ctx.strokeStyle='#30363d';ctx.lineWidth=1;
|
|
1180
|
+
ctx.beginPath();ctx.moveTo(0,y0);ctx.lineTo(cvW,y0);ctx.stroke();
|
|
1181
|
+
|
|
1182
|
+
// Row 1: relation line types
|
|
1183
|
+
var types=[
|
|
1184
|
+
{c:C.m2o,l:'many2one',d:false},
|
|
1185
|
+
{c:C.o2o,l:'one2one',d:false},
|
|
1186
|
+
{c:C.m2m,l:'many2many',d:true}
|
|
1187
|
+
];
|
|
1188
|
+
ctx.textAlign='left';ctx.textBaseline='middle';ctx.font='10px monospace';
|
|
1189
|
+
var x=14;
|
|
1190
|
+
types.forEach(function(it){
|
|
1191
|
+
ctx.strokeStyle=it.c;ctx.lineWidth=1.5;ctx.setLineDash(it.d?[4,3]:[]);
|
|
1192
|
+
ctx.beginPath();ctx.moveTo(x,y1);ctx.lineTo(x+14,y1);ctx.stroke();
|
|
1193
|
+
ctx.setLineDash([]);
|
|
1194
|
+
ctx.fillStyle=C.mu;
|
|
1195
|
+
ctx.fillText(it.l,x+18,y1);
|
|
1196
|
+
x+=18+ctx.measureText(it.l).width+22;
|
|
1197
|
+
});
|
|
1198
|
+
|
|
1199
|
+
// Row 2: cardinality symbols
|
|
1200
|
+
function badge2(cx,cy,many,col){
|
|
1201
|
+
ctx.fillStyle=col;ctx.strokeStyle=col;ctx.lineWidth=1;
|
|
1202
|
+
ctx.beginPath();ctx.arc(cx,cy,6,0,Math.PI*2);ctx.fill();
|
|
1203
|
+
ctx.save();ctx.translate(cx,cy);
|
|
1204
|
+
ctx.strokeStyle=C.bg;ctx.lineWidth=1.2;ctx.lineCap='round';
|
|
1205
|
+
ctx.beginPath();
|
|
1206
|
+
if(many){
|
|
1207
|
+
ctx.moveTo(-4,-3);ctx.lineTo(4,0);ctx.moveTo(-4,3);ctx.lineTo(4,0);
|
|
1208
|
+
ctx.moveTo(-4,0);ctx.lineTo(4,0);ctx.moveTo(-4,-3);ctx.lineTo(-4,3);
|
|
1209
|
+
}else{
|
|
1210
|
+
ctx.moveTo(-2,-3);ctx.lineTo(-2,3);ctx.moveTo(2,-3);ctx.lineTo(2,3);
|
|
1211
|
+
}
|
|
1212
|
+
ctx.stroke();ctx.restore();
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
ctx.font='10px monospace';ctx.textAlign='left';ctx.textBaseline='middle';
|
|
1216
|
+
x=14;
|
|
1217
|
+
var pairs=[
|
|
1218
|
+
{a:false,b:true,col:C.m2o,l:'many2one (N:1)'},
|
|
1219
|
+
{a:false,b:false,col:C.o2o,l:'one2one (1:1)'},
|
|
1220
|
+
{a:false,b:true,col:C.m2m,l:'pivot edge (1:N)'}
|
|
1221
|
+
];
|
|
1222
|
+
pairs.forEach(function(p){
|
|
1223
|
+
ctx.strokeStyle=p.col+'55';ctx.lineWidth=1.2;ctx.setLineDash(p.col===C.m2m?[4,3]:[]);
|
|
1224
|
+
ctx.beginPath();ctx.moveTo(x+8,y2);ctx.lineTo(x+56,y2);ctx.stroke();
|
|
1225
|
+
ctx.setLineDash([]);
|
|
1226
|
+
badge2(x+8,y2,p.a,p.col);
|
|
1227
|
+
badge2(x+56,y2,p.b,p.col);
|
|
1228
|
+
ctx.fillStyle=C.mu;ctx.font='10px monospace';ctx.textAlign='left';ctx.textBaseline='middle';
|
|
1229
|
+
ctx.fillText(p.l,x+66,y2);
|
|
1230
|
+
x+=66+ctx.measureText(p.l).width+24;
|
|
1231
|
+
});
|
|
1232
|
+
|
|
1233
|
+
ctx.fillStyle='#3d444d';ctx.font='10px monospace';ctx.textAlign='right';ctx.textBaseline='middle';
|
|
1234
|
+
ctx.fillText('drag \xB7 scroll to zoom \xB7 dbl-click to reset',cvW-14,y2);
|
|
1235
|
+
ctx.textBaseline='alphabetic';
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
function draw(){
|
|
1239
|
+
var W=cv.width/dpr,H=cv.height/dpr;
|
|
1240
|
+
ctx.setTransform(dpr,0,0,dpr,0,0);
|
|
1241
|
+
ctx.clearRect(0,0,W,H);
|
|
1242
|
+
ctx.fillStyle=C.bg;ctx.fillRect(0,0,W,H);
|
|
1243
|
+
ctx.save();ctx.translate(pan.x,pan.y);ctx.scale(zm,zm);
|
|
1244
|
+
badges=[];
|
|
1245
|
+
var edges=collectEdges();
|
|
1246
|
+
edges.forEach(function(e){drawEdgeLine(e.a,e.b,e.col,e.dashed,e.label,e.manyA,e.manyB,e.off);});
|
|
1247
|
+
nodes.forEach(function(n){drawNode(n);});
|
|
1248
|
+
badges.forEach(function(b){drawBadge(b);});
|
|
1249
|
+
ctx.restore();
|
|
1250
|
+
drawLegend();
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
// \u2500\u2500 Interaction \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
1254
|
+
var drag=null,dox=0,doy=0,isPan=false,ps={x:0,y:0};
|
|
1255
|
+
|
|
1256
|
+
function toW(mx,my){return{x:(mx-pan.x)/zm,y:(my-pan.y)/zm};}
|
|
1257
|
+
function hitTest(wx,wy){
|
|
1258
|
+
for(var i=nodes.length-1;i>=0;i--){var n=nodes[i];if(wx>=n.x&&wx<=n.x+n.w&&wy>=n.y&&wy<=n.y+n.h)return n;}
|
|
1259
|
+
return null;
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
cv.addEventListener('mousedown',function(ev){
|
|
1263
|
+
var r=cv.getBoundingClientRect(),mx=ev.clientX-r.left,my=ev.clientY-r.top;
|
|
1264
|
+
var w=toW(mx,my),h=hitTest(w.x,w.y);
|
|
1265
|
+
if(h){drag=h;dox=w.x-h.x;doy=w.y-h.y;}else{isPan=true;ps={x:mx-pan.x,y:my-pan.y};}
|
|
1266
|
+
cv.style.cursor='grabbing';
|
|
1267
|
+
});
|
|
1268
|
+
window.addEventListener('mousemove',function(ev){
|
|
1269
|
+
if(!drag&&!isPan)return;
|
|
1270
|
+
var r=cv.getBoundingClientRect(),mx=ev.clientX-r.left,my=ev.clientY-r.top;
|
|
1271
|
+
if(drag){var w=toW(mx,my);drag.x=w.x-dox;drag.y=w.y-doy;}
|
|
1272
|
+
else{pan.x=mx-ps.x;pan.y=my-ps.y;}
|
|
1273
|
+
draw();
|
|
1274
|
+
});
|
|
1275
|
+
window.addEventListener('mouseup',function(){drag=null;isPan=false;cv.style.cursor='grab';});
|
|
1276
|
+
cv.addEventListener('wheel',function(ev){
|
|
1277
|
+
ev.preventDefault();
|
|
1278
|
+
var r=cv.getBoundingClientRect(),mx=ev.clientX-r.left,my=ev.clientY-r.top;
|
|
1279
|
+
var d=ev.deltaY<0?1.1:0.91,nz=Math.max(0.25,Math.min(3,zm*d));
|
|
1280
|
+
pan.x=mx-(mx-pan.x)*(nz/zm);pan.y=my-(my-pan.y)*(nz/zm);zm=nz;draw();
|
|
1281
|
+
},{passive:false});
|
|
1282
|
+
cv.addEventListener('dblclick',function(){
|
|
1283
|
+
nodes.forEach(function(n,i){n.x=origPos[i].x;n.y=origPos[i].y;});
|
|
1284
|
+
var r=cv.getBoundingClientRect();fitView(r.width,r.height);draw();
|
|
1285
|
+
});
|
|
1286
|
+
window.addEventListener('resize',function(){
|
|
1287
|
+
resize();var r=cv.getBoundingClientRect();
|
|
1288
|
+
computeLayout(r.width,r.height);savePos();fitView(r.width,r.height);draw();
|
|
1289
|
+
});
|
|
1290
|
+
|
|
1291
|
+
document.getElementById('er-dl').addEventListener('click',function(){
|
|
1292
|
+
var SCALE=3;
|
|
1293
|
+
var cw=cv.width/dpr,ch=cv.height/dpr;
|
|
1294
|
+
var oc=document.createElement('canvas');
|
|
1295
|
+
oc.width=Math.round(cw*SCALE);oc.height=Math.round(ch*SCALE);
|
|
1296
|
+
var sav={cv:cv,ctx:ctx,dpr:dpr};
|
|
1297
|
+
cv=oc;ctx=oc.getContext('2d');dpr=SCALE;
|
|
1298
|
+
draw();
|
|
1299
|
+
cv=sav.cv;ctx=sav.ctx;dpr=sav.dpr;
|
|
1300
|
+
var a=document.createElement('a');
|
|
1301
|
+
a.download='er-diagram.png';a.href=oc.toDataURL('image/png');a.click();
|
|
1302
|
+
});
|
|
1303
|
+
|
|
1304
|
+
// \u2500\u2500 Init \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
1305
|
+
requestAnimationFrame(function(){
|
|
1306
|
+
resize();
|
|
1307
|
+
var r=cv.getBoundingClientRect();
|
|
1308
|
+
computeLayout(r.width,r.height);
|
|
1309
|
+
var h=requiredH();
|
|
1310
|
+
resize(h);r=cv.getBoundingClientRect();
|
|
1311
|
+
computeLayout(r.width,r.height);
|
|
1312
|
+
savePos();fitView(r.width,r.height);draw();
|
|
1313
|
+
});
|
|
1314
|
+
})();
|
|
1315
|
+
`}</script>`
|
|
1316
|
+
);
|
|
1317
|
+
}
|
|
1318
|
+
|
|
606
1319
|
// src/router/templates/about.template.ts
|
|
607
1320
|
var _dir = dirname(fileURLToPath2(import.meta.url));
|
|
608
1321
|
var LOGO_SRC = (() => {
|
|
609
1322
|
try {
|
|
610
|
-
const buf = readFileSync(join(_dir, "../../assets/
|
|
611
|
-
return `data:image/
|
|
1323
|
+
const buf = readFileSync(join(_dir, "../../assets/yrest-lockup.svg"));
|
|
1324
|
+
return `data:image/svg+xml;base64,${buf.toString("base64")}`;
|
|
612
1325
|
} catch {
|
|
613
1326
|
return "";
|
|
614
1327
|
}
|
|
@@ -619,6 +1332,7 @@ function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map(
|
|
|
619
1332
|
const customRoutes = storage.getRoutes();
|
|
620
1333
|
const base = options.base;
|
|
621
1334
|
const host = `http://${options.host}:${options.port}`;
|
|
1335
|
+
const swaggerUrl = `https://petstore.swagger.io/?url=${encodeURIComponent(`${host}/_openapi.json`)}`;
|
|
622
1336
|
const modes = [];
|
|
623
1337
|
if (options.watch) modes.push(badge("watch", "#38bdf8", "#38bdf818"));
|
|
624
1338
|
if (options.readonly) modes.push(badge("readonly", "#94a3b8", "#94a3b818"));
|
|
@@ -629,9 +1343,12 @@ function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map(
|
|
|
629
1343
|
if (handlers.size > 0) modes.push(badge(`handlers \xB7 ${handlers.size}`, "#f0883e", "#f0883e18"));
|
|
630
1344
|
if (options.idStrategy !== "increment")
|
|
631
1345
|
modes.push(badge(`id \xB7 ${options.idStrategy}`, "#a371f7", "#a371f718"));
|
|
1346
|
+
const erDiagram = erDiagramSection(generateERData(storage));
|
|
632
1347
|
const accordions = collections.map((col, i) => resourceAccordion(col, base, i === 0)).join("");
|
|
633
1348
|
const paginationDesc = options.pageable.enabled ? `Pageable mode active \u2014 default limit <code>${options.pageable.limit}</code>. Response wrapped in <code>{ data, pagination }</code>.` : `Returns the requested slice. <code>X-Total-Count</code> header reflects the total before pagination.`;
|
|
634
|
-
return
|
|
1349
|
+
return (
|
|
1350
|
+
/*html*/
|
|
1351
|
+
`<!DOCTYPE html>
|
|
635
1352
|
<html lang="en">
|
|
636
1353
|
<head>
|
|
637
1354
|
<meta charset="UTF-8">
|
|
@@ -658,20 +1375,30 @@ function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map(
|
|
|
658
1375
|
width: 100%;
|
|
659
1376
|
background: linear-gradient(135deg, #0d1117 0%, #161b22 40%, #1a2332 100%);
|
|
660
1377
|
border-bottom: 1px solid var(--border);
|
|
661
|
-
padding:
|
|
1378
|
+
padding: 20px 32px 16px;
|
|
662
1379
|
}
|
|
663
|
-
.banner-inner {
|
|
664
|
-
.banner
|
|
1380
|
+
.banner-inner { margin: 0 auto; display:flex; align-items:center; justify-content:space-between; gap:16px; flex-wrap:wrap; }
|
|
1381
|
+
.banner-left { flex:1; min-width:0; }
|
|
1382
|
+
.banner h1 { font-size: clamp(24px, 4vw, 40px); font-weight: 800; letter-spacing: -1.5px; line-height: 1; }
|
|
665
1383
|
.banner h1 .y { color: var(--text); }
|
|
666
1384
|
.banner h1 .rest { color: var(--accent); }
|
|
667
|
-
.banner p { color: var(--text-muted); margin-top:
|
|
668
|
-
.banner-meta { display: flex; gap:
|
|
669
|
-
.banner-meta span { color: var(--text-muted); font-size:
|
|
1385
|
+
.banner p { color: var(--text-muted); margin-top: 6px; font-size: 14px; }
|
|
1386
|
+
.banner-meta { display: flex; gap: 20px; margin-top: 10px; flex-wrap: wrap; }
|
|
1387
|
+
.banner-meta span { color: var(--text-muted); font-size: 12px; }
|
|
670
1388
|
.banner-meta span strong { color: var(--text); font-family: monospace; }
|
|
1389
|
+
.api-links { display:flex; gap:8px; flex-wrap:wrap; align-items:center; }
|
|
1390
|
+
.btn-api { display:inline-block; padding:5px 12px; border-radius:6px; border:1px solid var(--border); color:var(--text-muted); text-decoration:none; font-size:12px; font-weight:500; background:var(--bg-card); white-space:nowrap; transition:border-color .15s,color .15s; }
|
|
1391
|
+
.btn-api:hover { border-color:var(--border-hi); color:var(--text); }
|
|
1392
|
+
.btn-swagger { border-color:var(--accent); color:var(--accent); }
|
|
1393
|
+
.btn-swagger:hover { background:#58a6ff18; }
|
|
671
1394
|
|
|
672
1395
|
/* \u2500\u2500 Layout \u2500\u2500 */
|
|
673
|
-
.wrap {
|
|
1396
|
+
.wrap { padding: 24px 32px 48px; }
|
|
674
1397
|
h2 { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .08em; color: var(--text-muted); margin: 32px 0 12px; }
|
|
1398
|
+
.about-grid { display:grid; grid-template-columns:5fr 7fr; gap:28px; align-items:start; }
|
|
1399
|
+
@media(max-width:960px) { .about-grid { grid-template-columns:1fr; } }
|
|
1400
|
+
.col-left, .col-right { min-width:0; }
|
|
1401
|
+
.col-left > h2:first-child, .col-right > h2:first-child { margin-top:0; }
|
|
675
1402
|
|
|
676
1403
|
/* \u2500\u2500 Cards \u2500\u2500 */
|
|
677
1404
|
.card { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 20px 24px; }
|
|
@@ -687,10 +1414,8 @@ function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map(
|
|
|
687
1414
|
.modes { display: flex; gap: 8px; flex-wrap: wrap; min-height: 28px; align-items: center; }
|
|
688
1415
|
.badge { display: inline-block; padding: 3px 10px; border-radius: 20px; font-size: 12px; font-weight: 600; white-space: nowrap; }
|
|
689
1416
|
|
|
690
|
-
/* \u2500\u2500 Endpoints
|
|
691
|
-
.endpoints-grid { display:
|
|
692
|
-
@media (max-width: 860px) { .endpoints-grid { grid-template-columns: 1fr; } }
|
|
693
|
-
.nested-card { grid-column: 1 / -1; }
|
|
1417
|
+
/* \u2500\u2500 Endpoints list \u2500\u2500 */
|
|
1418
|
+
.endpoints-grid { display: flex; flex-direction: column; gap: 10px; }
|
|
694
1419
|
|
|
695
1420
|
/* \u2500\u2500 Accordion (details/summary) \u2500\u2500 */
|
|
696
1421
|
.resource-card {
|
|
@@ -755,56 +1480,71 @@ function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map(
|
|
|
755
1480
|
|
|
756
1481
|
<div class="banner">
|
|
757
1482
|
<div class="banner-inner">
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
<
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
1483
|
+
<div class="banner-left">
|
|
1484
|
+
${LOGO_SRC ? `<img src="${LOGO_SRC}" alt="yRest" height="36" style="display:block;margin-bottom:4px" />` : `<h1><span class="y">y</span><span class="rest">Rest</span></h1>`}
|
|
1485
|
+
<p>Zero-config REST API mock server</p>
|
|
1486
|
+
<div class="banner-meta">
|
|
1487
|
+
<span>URL <strong>${host}</strong></span>
|
|
1488
|
+
<span>Base <strong>${base || "/"}</strong></span>
|
|
1489
|
+
<span>File <strong>${options.file}</strong></span>
|
|
1490
|
+
<span>Collections <strong>${collections.length}</strong></span>
|
|
1491
|
+
</div>
|
|
1492
|
+
</div>
|
|
1493
|
+
<div class="api-links">
|
|
1494
|
+
<a href="/_openapi" target="_blank" class="btn-api">OpenAPI YAML</a>
|
|
1495
|
+
<a href="/_openapi.json" target="_blank" class="btn-api">OpenAPI JSON</a>
|
|
1496
|
+
<a href="${swaggerUrl}" target="_blank" class="btn-api btn-swagger">Swagger UI \u2197</a>
|
|
765
1497
|
</div>
|
|
766
1498
|
</div>
|
|
767
1499
|
</div>
|
|
768
1500
|
|
|
769
1501
|
<div class="wrap">
|
|
1502
|
+
<div class="about-grid">
|
|
770
1503
|
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
1504
|
+
<div class="col-left">
|
|
1505
|
+
<h2>Active Modes</h2>
|
|
1506
|
+
<div class="card">
|
|
1507
|
+
<div class="modes">${modes.length ? modes.join(" ") : `<span style="color:var(--text-muted);font-size:13px">none</span>`}</div>
|
|
1508
|
+
${options.watch ? `<div class="warn">\u26A0 <strong>Watch mode:</strong> data changes in existing collections reload automatically. Adding or removing entire collections requires a server restart.</div>` : ""}
|
|
1509
|
+
</div>
|
|
776
1510
|
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
1511
|
+
<h2>Endpoints</h2>
|
|
1512
|
+
<div class="endpoints-grid">
|
|
1513
|
+
${accordions}
|
|
1514
|
+
${nestedRoutesAccordion(relations, base)}
|
|
1515
|
+
${options.snapshot ? snapshotAccordion() : ""}
|
|
1516
|
+
${customRoutesAccordion(customRoutes, base, handlers)}
|
|
1517
|
+
${handlersAccordion(handlers, customRoutes, base)}
|
|
1518
|
+
</div>
|
|
785
1519
|
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
1520
|
+
<h2>Query Parameters</h2>
|
|
1521
|
+
<div class="card">
|
|
1522
|
+
<table class="param-table">
|
|
1523
|
+
<thead><tr><th>Param</th><th>Example</th><th>Description</th></tr></thead>
|
|
1524
|
+
<tbody>
|
|
1525
|
+
<tr><td><code>?field=value</code></td><td><code>?name=Ana&role=admin</code></td><td>Filter by any field. Multiple params are ANDed.</td></tr>
|
|
1526
|
+
<tr><td><code>?field_gte / _lte</code></td><td><code>?price_gte=10&price_lte=50</code></td><td>Numeric or lexicographic range. Works with any comparable field.</td></tr>
|
|
1527
|
+
<tr><td><code>?field_ne</code></td><td><code>?status_ne=inactive</code></td><td>Exclude items where the field equals the value.</td></tr>
|
|
1528
|
+
<tr><td><code>?field_like</code></td><td><code>?name_like=ana</code></td><td>Case-insensitive substring match.</td></tr>
|
|
1529
|
+
<tr><td><code>?field_start</code></td><td><code>?name_start=A</code></td><td>Case-insensitive prefix match.</td></tr>
|
|
1530
|
+
<tr><td><code>?field_regex</code></td><td><code>?email_regex=gmail</code></td><td>Case-insensitive regular expression match.</td></tr>
|
|
1531
|
+
<tr><td><code>?_q</code></td><td><code>?_q=ana</code></td><td>Full-text search across all scalar fields (case-insensitive substring).</td></tr>
|
|
1532
|
+
<tr><td><code>?_sort & ?_order</code></td><td><code>?_sort=name&_order=desc</code></td><td>Sort by field. <code>_order</code>: <code>asc</code> (default) or <code>desc</code>.</td></tr>
|
|
1533
|
+
<tr><td><code>?_page & ?_limit</code></td><td><code>?_page=2&_limit=10</code></td><td>${paginationDesc}</td></tr>
|
|
1534
|
+
<tr><td><code>?_expand</code></td><td><code>?_expand=user</code></td><td>Embed a related parent object inline. Requires <code>_rel</code> in the YAML file.</td></tr>
|
|
1535
|
+
<tr><td><code>?_embed</code></td><td><code>?_embed=posts</code></td><td>Embed child collections into each parent item. Requires <code>_rel</code> in the YAML file.</td></tr>
|
|
1536
|
+
<tr><td><code>?_fields</code></td><td><code>?_fields=id,name</code></td><td>Return only the specified fields. Applied last \u2014 can include embedded/expanded keys.</td></tr>
|
|
1537
|
+
</tbody>
|
|
1538
|
+
</table>
|
|
1539
|
+
</div>
|
|
1540
|
+
</div>
|
|
1541
|
+
|
|
1542
|
+
<div class="col-right">
|
|
1543
|
+
${erDiagram}
|
|
1544
|
+
${collections.length ? `<h2>Examples</h2><div class="card">${examplesBlock(collections, relations, base, host, options, customRoutes[0])}</div>` : ""}
|
|
1545
|
+
</div>
|
|
806
1546
|
|
|
807
|
-
|
|
1547
|
+
</div>
|
|
808
1548
|
|
|
809
1549
|
<footer>
|
|
810
1550
|
Powered by <a href="https://github.com/aggiovato/yRest" target="_blank">@yrest/cli</a> \xB7 <a href="/_about">/_about</a>
|
|
@@ -812,7 +1552,8 @@ function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map(
|
|
|
812
1552
|
|
|
813
1553
|
</div>
|
|
814
1554
|
</body>
|
|
815
|
-
</html
|
|
1555
|
+
</html>`
|
|
1556
|
+
);
|
|
816
1557
|
}
|
|
817
1558
|
|
|
818
1559
|
// src/router/routes/about.routes.ts
|
|
@@ -1413,12 +2154,23 @@ var NestedRouteCommand = class {
|
|
|
1413
2154
|
relations;
|
|
1414
2155
|
base;
|
|
1415
2156
|
register(server) {
|
|
2157
|
+
const registered = /* @__PURE__ */ new Set();
|
|
1416
2158
|
for (const [source, fields] of Object.entries(this.relations)) {
|
|
1417
2159
|
for (const [key, def] of Object.entries(fields)) {
|
|
1418
2160
|
if (def.type === "many2many") {
|
|
1419
|
-
this.
|
|
2161
|
+
const fwd = `${this.base}/${source}/:id/${key}`;
|
|
2162
|
+
const inv = `${this.base}/${def.target}/:id/${source}`;
|
|
2163
|
+
if (!registered.has(fwd) && !registered.has(inv)) {
|
|
2164
|
+
registered.add(fwd);
|
|
2165
|
+
registered.add(inv);
|
|
2166
|
+
this.registerMany2Many(server, source, key, def);
|
|
2167
|
+
}
|
|
1420
2168
|
} else {
|
|
1421
|
-
|
|
2169
|
+
const collPath = `${this.base}/${def.target}/:id/${source}`;
|
|
2170
|
+
if (!registered.has(collPath)) {
|
|
2171
|
+
registered.add(collPath);
|
|
2172
|
+
this.registerFkRelation(server, source, key, def.target, def.type);
|
|
2173
|
+
}
|
|
1422
2174
|
}
|
|
1423
2175
|
}
|
|
1424
2176
|
}
|
|
@@ -1477,6 +2229,379 @@ var NestedRouteCommand = class {
|
|
|
1477
2229
|
}
|
|
1478
2230
|
};
|
|
1479
2231
|
|
|
2232
|
+
// src/router/routes/openapi.routes.ts
|
|
2233
|
+
init_esm_shims();
|
|
2234
|
+
|
|
2235
|
+
// src/openapi/generateOpenApi.ts
|
|
2236
|
+
init_esm_shims();
|
|
2237
|
+
|
|
2238
|
+
// src/openapi/inferSchema.ts
|
|
2239
|
+
init_esm_shims();
|
|
2240
|
+
function buildCollectionSchema(items, fieldDefs = {}) {
|
|
2241
|
+
const sample = items.slice(0, 10);
|
|
2242
|
+
const inferredTypes = /* @__PURE__ */ new Map();
|
|
2243
|
+
for (const item of sample) {
|
|
2244
|
+
for (const [key, value] of Object.entries(item)) {
|
|
2245
|
+
if (!inferredTypes.has(key)) {
|
|
2246
|
+
inferredTypes.set(key, jsToOpenApiType(value));
|
|
2247
|
+
}
|
|
2248
|
+
}
|
|
2249
|
+
}
|
|
2250
|
+
const allFields = /* @__PURE__ */ new Set([...inferredTypes.keys(), ...Object.keys(fieldDefs)]);
|
|
2251
|
+
const properties = {};
|
|
2252
|
+
const required = [];
|
|
2253
|
+
for (const field of allFields) {
|
|
2254
|
+
const def = fieldDefs[field];
|
|
2255
|
+
const inferred = inferredTypes.get(field) ?? "string";
|
|
2256
|
+
const prop = {
|
|
2257
|
+
type: def?.type ?? inferred
|
|
2258
|
+
};
|
|
2259
|
+
if (def?.format) prop.format = def.format;
|
|
2260
|
+
if (def?.description) prop.description = def.description;
|
|
2261
|
+
if (def?.enum) prop.enum = def.enum;
|
|
2262
|
+
if (def?.default !== void 0) prop.default = def.default;
|
|
2263
|
+
properties[field] = prop;
|
|
2264
|
+
if (def?.required === true) required.push(field);
|
|
2265
|
+
}
|
|
2266
|
+
const schema = { type: "object", properties };
|
|
2267
|
+
if (required.length > 0) schema.required = required;
|
|
2268
|
+
return schema;
|
|
2269
|
+
}
|
|
2270
|
+
function jsToOpenApiType(value) {
|
|
2271
|
+
if (value === null || value === void 0) return "string";
|
|
2272
|
+
if (typeof value === "boolean") return "boolean";
|
|
2273
|
+
if (typeof value === "number") return Number.isInteger(value) ? "integer" : "number";
|
|
2274
|
+
if (Array.isArray(value)) return "array";
|
|
2275
|
+
if (typeof value === "object") return "object";
|
|
2276
|
+
return "string";
|
|
2277
|
+
}
|
|
2278
|
+
|
|
2279
|
+
// src/openapi/buildPaths.ts
|
|
2280
|
+
init_esm_shims();
|
|
2281
|
+
var COLLECTION_QUERY_PARAMS = [
|
|
2282
|
+
{ name: "_page", in: "query", schema: { type: "integer" }, description: "Page number (1-based)" },
|
|
2283
|
+
{ name: "_limit", in: "query", schema: { type: "integer" }, description: "Items per page" },
|
|
2284
|
+
{ name: "_sort", in: "query", schema: { type: "string" }, description: "Field name to sort by" },
|
|
2285
|
+
{
|
|
2286
|
+
name: "_order",
|
|
2287
|
+
in: "query",
|
|
2288
|
+
schema: { type: "string", enum: ["asc", "desc"] },
|
|
2289
|
+
description: "Sort direction"
|
|
2290
|
+
},
|
|
2291
|
+
{
|
|
2292
|
+
name: "_q",
|
|
2293
|
+
in: "query",
|
|
2294
|
+
schema: { type: "string" },
|
|
2295
|
+
description: "Full-text search across all scalar fields (case-insensitive)"
|
|
2296
|
+
},
|
|
2297
|
+
{
|
|
2298
|
+
name: "_expand",
|
|
2299
|
+
in: "query",
|
|
2300
|
+
schema: { type: "string" },
|
|
2301
|
+
description: "Embed related parent object inline (e.g. ?_expand=user)"
|
|
2302
|
+
},
|
|
2303
|
+
{
|
|
2304
|
+
name: "_embed",
|
|
2305
|
+
in: "query",
|
|
2306
|
+
schema: { type: "string" },
|
|
2307
|
+
description: "Embed child collection into each item (e.g. ?_embed=posts)"
|
|
2308
|
+
},
|
|
2309
|
+
{
|
|
2310
|
+
name: "_fields",
|
|
2311
|
+
in: "query",
|
|
2312
|
+
schema: { type: "string" },
|
|
2313
|
+
description: "Comma-separated field projection (e.g. ?_fields=id,name)"
|
|
2314
|
+
}
|
|
2315
|
+
];
|
|
2316
|
+
var ID_PATH_PARAM = {
|
|
2317
|
+
name: "id",
|
|
2318
|
+
in: "path",
|
|
2319
|
+
required: true,
|
|
2320
|
+
schema: { type: "string" },
|
|
2321
|
+
description: "Item id"
|
|
2322
|
+
};
|
|
2323
|
+
function toOpenApiPath(fastifyPath) {
|
|
2324
|
+
return fastifyPath.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, "{$1}");
|
|
2325
|
+
}
|
|
2326
|
+
function extractPathParams(fastifyPath) {
|
|
2327
|
+
const matches = fastifyPath.match(/:([a-zA-Z_][a-zA-Z0-9_]*)/g) ?? [];
|
|
2328
|
+
return matches.map((m) => ({
|
|
2329
|
+
name: m.slice(1),
|
|
2330
|
+
in: "path",
|
|
2331
|
+
required: true,
|
|
2332
|
+
schema: { type: "string" }
|
|
2333
|
+
}));
|
|
2334
|
+
}
|
|
2335
|
+
function singular(name) {
|
|
2336
|
+
return name.endsWith("s") ? name.slice(0, -1) : name;
|
|
2337
|
+
}
|
|
2338
|
+
function schemaRef(name) {
|
|
2339
|
+
return { $ref: `#/components/schemas/${name}` };
|
|
2340
|
+
}
|
|
2341
|
+
function jsonContent(schema) {
|
|
2342
|
+
return { "application/json": { schema } };
|
|
2343
|
+
}
|
|
2344
|
+
function ok(schema, description = "OK") {
|
|
2345
|
+
return { description, content: jsonContent(schema) };
|
|
2346
|
+
}
|
|
2347
|
+
function buildCrudPaths(collection, base, schemaName) {
|
|
2348
|
+
const ref = schemaRef(schemaName);
|
|
2349
|
+
const tag = collection;
|
|
2350
|
+
const sing = singular(collection);
|
|
2351
|
+
const collPath = `${base}/${collection}`;
|
|
2352
|
+
const itemPath = `${base}/${collection}/{id}`;
|
|
2353
|
+
return {
|
|
2354
|
+
[collPath]: {
|
|
2355
|
+
get: {
|
|
2356
|
+
summary: `List ${collection}`,
|
|
2357
|
+
tags: [tag],
|
|
2358
|
+
parameters: COLLECTION_QUERY_PARAMS,
|
|
2359
|
+
responses: {
|
|
2360
|
+
"200": {
|
|
2361
|
+
description: "OK",
|
|
2362
|
+
content: jsonContent({ type: "array", items: ref }),
|
|
2363
|
+
headers: {
|
|
2364
|
+
"X-Total-Count": {
|
|
2365
|
+
description: "Total items (when using ?_page / ?_limit)",
|
|
2366
|
+
schema: { type: "integer" }
|
|
2367
|
+
}
|
|
2368
|
+
}
|
|
2369
|
+
}
|
|
2370
|
+
}
|
|
2371
|
+
},
|
|
2372
|
+
post: {
|
|
2373
|
+
summary: `Create ${sing}`,
|
|
2374
|
+
tags: [tag],
|
|
2375
|
+
requestBody: { required: true, content: jsonContent(ref) },
|
|
2376
|
+
responses: { "201": ok(ref, "Created") }
|
|
2377
|
+
}
|
|
2378
|
+
},
|
|
2379
|
+
[itemPath]: {
|
|
2380
|
+
get: {
|
|
2381
|
+
summary: `Get ${sing}`,
|
|
2382
|
+
tags: [tag],
|
|
2383
|
+
parameters: [
|
|
2384
|
+
ID_PATH_PARAM,
|
|
2385
|
+
...COLLECTION_QUERY_PARAMS.filter(
|
|
2386
|
+
(p) => ["_expand", "_embed", "_fields"].includes(p.name)
|
|
2387
|
+
)
|
|
2388
|
+
],
|
|
2389
|
+
responses: { "200": ok(ref), "404": { description: "Not found" } }
|
|
2390
|
+
},
|
|
2391
|
+
put: {
|
|
2392
|
+
summary: `Replace ${sing}`,
|
|
2393
|
+
tags: [tag],
|
|
2394
|
+
parameters: [ID_PATH_PARAM],
|
|
2395
|
+
requestBody: { required: true, content: jsonContent(ref) },
|
|
2396
|
+
responses: { "200": ok(ref), "404": { description: "Not found" } }
|
|
2397
|
+
},
|
|
2398
|
+
patch: {
|
|
2399
|
+
summary: `Update ${sing}`,
|
|
2400
|
+
tags: [tag],
|
|
2401
|
+
parameters: [ID_PATH_PARAM],
|
|
2402
|
+
requestBody: { required: false, content: jsonContent(ref) },
|
|
2403
|
+
responses: { "200": ok(ref), "404": { description: "Not found" } }
|
|
2404
|
+
},
|
|
2405
|
+
delete: {
|
|
2406
|
+
summary: `Delete ${sing}`,
|
|
2407
|
+
tags: [tag],
|
|
2408
|
+
parameters: [ID_PATH_PARAM],
|
|
2409
|
+
responses: { "200": ok(ref, "Deleted item returned"), "404": { description: "Not found" } }
|
|
2410
|
+
}
|
|
2411
|
+
}
|
|
2412
|
+
};
|
|
2413
|
+
}
|
|
2414
|
+
function buildRelationPaths(relations, base) {
|
|
2415
|
+
const paths = {};
|
|
2416
|
+
for (const [source, fields] of Object.entries(relations)) {
|
|
2417
|
+
for (const [key, def] of Object.entries(fields)) {
|
|
2418
|
+
if (def.type === "many2many") {
|
|
2419
|
+
const forwardPath = `${base}/${source}/{id}/${key}`;
|
|
2420
|
+
const inversePath = `${base}/${def.target}/{id}/${source}`;
|
|
2421
|
+
paths[forwardPath] = {
|
|
2422
|
+
get: {
|
|
2423
|
+
summary: `List ${def.target} linked to ${singular(source)} via ${def.through}`,
|
|
2424
|
+
tags: [source],
|
|
2425
|
+
parameters: [ID_PATH_PARAM],
|
|
2426
|
+
responses: {
|
|
2427
|
+
"200": {
|
|
2428
|
+
description: "OK",
|
|
2429
|
+
content: jsonContent({ type: "array", items: { type: "object" } })
|
|
2430
|
+
},
|
|
2431
|
+
"404": { description: `${singular(source)} not found` }
|
|
2432
|
+
}
|
|
2433
|
+
}
|
|
2434
|
+
};
|
|
2435
|
+
paths[inversePath] = {
|
|
2436
|
+
get: {
|
|
2437
|
+
summary: `List ${source} linked to ${singular(def.target)} via ${def.through} (inverse)`,
|
|
2438
|
+
tags: [def.target],
|
|
2439
|
+
parameters: [ID_PATH_PARAM],
|
|
2440
|
+
responses: {
|
|
2441
|
+
"200": {
|
|
2442
|
+
description: "OK",
|
|
2443
|
+
content: jsonContent({ type: "array", items: { type: "object" } })
|
|
2444
|
+
},
|
|
2445
|
+
"404": { description: `${singular(def.target)} not found` }
|
|
2446
|
+
}
|
|
2447
|
+
}
|
|
2448
|
+
};
|
|
2449
|
+
} else {
|
|
2450
|
+
const parentSing = singular(def.target);
|
|
2451
|
+
const collPath = `${base}/${def.target}/{id}/${source}`;
|
|
2452
|
+
const isOne2One = def.type === "one2one";
|
|
2453
|
+
const responseSchema = isOne2One ? { type: "object" } : { type: "array", items: { type: "object" } };
|
|
2454
|
+
paths[collPath] = {
|
|
2455
|
+
get: {
|
|
2456
|
+
summary: isOne2One ? `Get ${singular(source)} belonging to ${parentSing}` : `List ${source} belonging to ${parentSing}`,
|
|
2457
|
+
tags: [def.target],
|
|
2458
|
+
parameters: [ID_PATH_PARAM],
|
|
2459
|
+
responses: {
|
|
2460
|
+
"200": { description: "OK", content: jsonContent(responseSchema) },
|
|
2461
|
+
"404": { description: `${parentSing} not found` }
|
|
2462
|
+
}
|
|
2463
|
+
}
|
|
2464
|
+
};
|
|
2465
|
+
if (!isOne2One) {
|
|
2466
|
+
const itemPath = `${base}/${def.target}/{id}/${source}/{childId}`;
|
|
2467
|
+
paths[itemPath] = {
|
|
2468
|
+
get: {
|
|
2469
|
+
summary: `Get single ${singular(source)} scoped to ${parentSing}`,
|
|
2470
|
+
tags: [def.target],
|
|
2471
|
+
parameters: [
|
|
2472
|
+
ID_PATH_PARAM,
|
|
2473
|
+
{ name: "childId", in: "path", required: true, schema: { type: "string" } }
|
|
2474
|
+
],
|
|
2475
|
+
responses: {
|
|
2476
|
+
"200": { description: "OK", content: jsonContent({ type: "object" }) },
|
|
2477
|
+
"404": { description: "Not found" }
|
|
2478
|
+
}
|
|
2479
|
+
}
|
|
2480
|
+
};
|
|
2481
|
+
}
|
|
2482
|
+
}
|
|
2483
|
+
}
|
|
2484
|
+
}
|
|
2485
|
+
return paths;
|
|
2486
|
+
}
|
|
2487
|
+
function buildCustomRoutePaths(routes, base) {
|
|
2488
|
+
const paths = {};
|
|
2489
|
+
for (const route of routes) {
|
|
2490
|
+
const openApiPath = toOpenApiPath(`${base}${route.path}`);
|
|
2491
|
+
const method = route.method.toLowerCase();
|
|
2492
|
+
const pathParams = extractPathParams(route.path);
|
|
2493
|
+
const responses = {};
|
|
2494
|
+
if (route.error) {
|
|
2495
|
+
responses[String(route.error)] = { description: `Forced error ${route.error}` };
|
|
2496
|
+
} else {
|
|
2497
|
+
const statuses = /* @__PURE__ */ new Set();
|
|
2498
|
+
for (const s of route.scenarios ?? []) statuses.add(s.response.status ?? 200);
|
|
2499
|
+
if (route.otherwise) statuses.add(route.otherwise.status ?? 200);
|
|
2500
|
+
if (route.response) statuses.add(route.response.status ?? 200);
|
|
2501
|
+
if (statuses.size === 0) statuses.add(200);
|
|
2502
|
+
for (const status of statuses) {
|
|
2503
|
+
const bodySource = (route.scenarios ?? []).find((s) => (s.response.status ?? 200) === status)?.response.body ?? (route.otherwise?.status ?? 200) === status ? route.otherwise?.body : route.response?.body;
|
|
2504
|
+
responses[String(status)] = {
|
|
2505
|
+
description: status < 400 ? "OK" : "Error",
|
|
2506
|
+
...bodySource != null ? { content: jsonContent(inferResponseSchema(bodySource)) } : {}
|
|
2507
|
+
};
|
|
2508
|
+
}
|
|
2509
|
+
}
|
|
2510
|
+
const desc = route.handler ? `Handler: ${route.handler}()` : route.scenarios?.length ? `Conditional scenarios (${route.scenarios.length})` : "Custom static route";
|
|
2511
|
+
const operation = {
|
|
2512
|
+
summary: `${route.method.toUpperCase()} ${route.path}`,
|
|
2513
|
+
description: desc,
|
|
2514
|
+
tags: ["custom"],
|
|
2515
|
+
...pathParams.length > 0 ? { parameters: pathParams } : {},
|
|
2516
|
+
responses
|
|
2517
|
+
};
|
|
2518
|
+
if (!paths[openApiPath]) paths[openApiPath] = {};
|
|
2519
|
+
paths[openApiPath][method] = operation;
|
|
2520
|
+
}
|
|
2521
|
+
return paths;
|
|
2522
|
+
}
|
|
2523
|
+
function inferResponseSchema(body) {
|
|
2524
|
+
if (body === null || body === void 0) return {};
|
|
2525
|
+
if (typeof body !== "object" || Array.isArray(body)) return { type: "object" };
|
|
2526
|
+
const properties = {};
|
|
2527
|
+
for (const [key, value] of Object.entries(body)) {
|
|
2528
|
+
properties[key] = { type: jsToOpenApiType2(value) };
|
|
2529
|
+
}
|
|
2530
|
+
return { type: "object", properties };
|
|
2531
|
+
}
|
|
2532
|
+
function jsToOpenApiType2(value) {
|
|
2533
|
+
if (value === null || value === void 0) return "string";
|
|
2534
|
+
if (typeof value === "boolean") return "boolean";
|
|
2535
|
+
if (typeof value === "number") return Number.isInteger(value) ? "integer" : "number";
|
|
2536
|
+
if (Array.isArray(value)) return "array";
|
|
2537
|
+
if (typeof value === "object") return "object";
|
|
2538
|
+
return "string";
|
|
2539
|
+
}
|
|
2540
|
+
|
|
2541
|
+
// src/openapi/generateOpenApi.ts
|
|
2542
|
+
function generateOpenApi(storage, options, title = "yRest API") {
|
|
2543
|
+
const collections = Object.keys(storage.getData());
|
|
2544
|
+
const relations = storage.getRelations();
|
|
2545
|
+
const schemaBlock = storage.getSchema();
|
|
2546
|
+
const customRoutes = storage.getRoutes();
|
|
2547
|
+
const base = options.base ?? "";
|
|
2548
|
+
const schemas = {};
|
|
2549
|
+
for (const collection of collections) {
|
|
2550
|
+
const items = storage.getCollection(collection) ?? [];
|
|
2551
|
+
const fieldDefs = schemaBlock[collection] ?? {};
|
|
2552
|
+
const schemaName = toSchemaName(collection);
|
|
2553
|
+
schemas[schemaName] = buildCollectionSchema(items, fieldDefs);
|
|
2554
|
+
}
|
|
2555
|
+
const paths = {};
|
|
2556
|
+
for (const collection of collections) {
|
|
2557
|
+
const schemaName = toSchemaName(collection);
|
|
2558
|
+
Object.assign(paths, buildCrudPaths(collection, base, schemaName));
|
|
2559
|
+
}
|
|
2560
|
+
Object.assign(paths, buildRelationPaths(relations, base));
|
|
2561
|
+
Object.assign(paths, buildCustomRoutePaths(customRoutes, base));
|
|
2562
|
+
return {
|
|
2563
|
+
openapi: "3.0.3",
|
|
2564
|
+
info: {
|
|
2565
|
+
title,
|
|
2566
|
+
version: "1.0.0",
|
|
2567
|
+
description: "Generated by yRest from db.yml"
|
|
2568
|
+
},
|
|
2569
|
+
servers: [
|
|
2570
|
+
{
|
|
2571
|
+
url: `http://${options.host}:${options.port}${base}`,
|
|
2572
|
+
description: "yRest mock server"
|
|
2573
|
+
}
|
|
2574
|
+
],
|
|
2575
|
+
paths,
|
|
2576
|
+
components: { schemas }
|
|
2577
|
+
};
|
|
2578
|
+
}
|
|
2579
|
+
function toSchemaName(collection) {
|
|
2580
|
+
const withoutS = collection.endsWith("s") ? collection.slice(0, -1) : collection;
|
|
2581
|
+
return withoutS.charAt(0).toUpperCase() + withoutS.slice(1);
|
|
2582
|
+
}
|
|
2583
|
+
|
|
2584
|
+
// src/router/routes/openapi.routes.ts
|
|
2585
|
+
import { stringify } from "yaml";
|
|
2586
|
+
var OpenApiRouteCommand = class {
|
|
2587
|
+
constructor(storage, options) {
|
|
2588
|
+
this.storage = storage;
|
|
2589
|
+
this.options = options;
|
|
2590
|
+
}
|
|
2591
|
+
storage;
|
|
2592
|
+
options;
|
|
2593
|
+
register(server) {
|
|
2594
|
+
server.get("/_openapi", (_req, reply) => {
|
|
2595
|
+
const doc = generateOpenApi(this.storage, this.options);
|
|
2596
|
+
reply.header("Content-Type", "text/yaml; charset=utf-8");
|
|
2597
|
+
return reply.send(stringify(doc, { lineWidth: 0, aliasDuplicateObjects: false }));
|
|
2598
|
+
});
|
|
2599
|
+
server.get("/_openapi.json", (_req, reply) => {
|
|
2600
|
+
return reply.send(generateOpenApi(this.storage, this.options));
|
|
2601
|
+
});
|
|
2602
|
+
}
|
|
2603
|
+
};
|
|
2604
|
+
|
|
1480
2605
|
// src/router/routes/snapshot.routes.ts
|
|
1481
2606
|
init_esm_shims();
|
|
1482
2607
|
var SnapshotRouteCommand = class {
|
|
@@ -1558,6 +2683,7 @@ async function createServer(storage, options, handlers = /* @__PURE__ */ new Map
|
|
|
1558
2683
|
}
|
|
1559
2684
|
const commands = [
|
|
1560
2685
|
new AboutRouteCommand(storage, options, handlers),
|
|
2686
|
+
new OpenApiRouteCommand(storage, options),
|
|
1561
2687
|
...options.snapshot ? [new SnapshotRouteCommand(storage)] : [],
|
|
1562
2688
|
new CustomRouteCommand(storage, options.base, handlers),
|
|
1563
2689
|
...buildResourceRouteCommands(storage, options)
|
|
@@ -1637,10 +2763,12 @@ function buildOptions(opts) {
|
|
|
1637
2763
|
}
|
|
1638
2764
|
function createInMemoryStorage(data) {
|
|
1639
2765
|
const raw = data;
|
|
2766
|
+
const RESERVED = /* @__PURE__ */ new Set(["_rel", "_routes", "_schema"]);
|
|
1640
2767
|
const relations = parseRelations(raw["_rel"]);
|
|
1641
2768
|
const routes = Array.isArray(raw["_routes"]) ? raw["_routes"] : [];
|
|
2769
|
+
const schema = parseSchema(raw["_schema"]);
|
|
1642
2770
|
const collections = Object.fromEntries(
|
|
1643
|
-
Object.entries(raw).filter(([k]) => k
|
|
2771
|
+
Object.entries(raw).filter(([k]) => !RESERVED.has(k))
|
|
1644
2772
|
);
|
|
1645
2773
|
let snapshot = {
|
|
1646
2774
|
data: deepCopyData(collections),
|
|
@@ -1650,6 +2778,7 @@ function createInMemoryStorage(data) {
|
|
|
1650
2778
|
return {
|
|
1651
2779
|
getData: () => collections,
|
|
1652
2780
|
getRelations: () => relations,
|
|
2781
|
+
getSchema: () => schema,
|
|
1653
2782
|
getRoutes: () => routes,
|
|
1654
2783
|
getCollection: (name) => collections[name],
|
|
1655
2784
|
setCollection: (name, items) => {
|