@yrest/cli 0.8.0 → 0.9.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 +96 -20
- package/dist/cli/index.js +808 -193
- package/dist/cli/index.mjs +808 -193
- package/dist/index.d.mts +47 -5
- package/dist/index.d.ts +47 -5
- package/dist/index.js +390 -172
- package/dist/index.mjs +390 -172
- package/package.json +4 -3
package/dist/index.mjs
CHANGED
|
@@ -17,6 +17,56 @@ var init_esm_shims = __esm({
|
|
|
17
17
|
}
|
|
18
18
|
});
|
|
19
19
|
|
|
20
|
+
// src/storage/parseRelations.ts
|
|
21
|
+
function parseRelations(raw) {
|
|
22
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
|
|
23
|
+
const result = {};
|
|
24
|
+
for (const [collection, fields] of Object.entries(raw)) {
|
|
25
|
+
if (!fields || typeof fields !== "object" || Array.isArray(fields)) continue;
|
|
26
|
+
result[collection] = {};
|
|
27
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
28
|
+
const def = normaliseRelationDef(key, value);
|
|
29
|
+
if (def) result[collection][key] = def;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return result;
|
|
33
|
+
}
|
|
34
|
+
function normaliseRelationDef(key, value) {
|
|
35
|
+
if (typeof value === "string") {
|
|
36
|
+
return { type: "many2one", target: value };
|
|
37
|
+
}
|
|
38
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
39
|
+
const v = value;
|
|
40
|
+
const type = v["type"];
|
|
41
|
+
const nested = v["nested"] === true ? true : void 0;
|
|
42
|
+
if (type === "many2one" || type === void 0) {
|
|
43
|
+
const target = v["target"];
|
|
44
|
+
if (typeof target !== "string") return null;
|
|
45
|
+
return nested ? { type: "many2one", target, nested } : { type: "many2one", target };
|
|
46
|
+
}
|
|
47
|
+
if (type === "one2one") {
|
|
48
|
+
const target = v["target"];
|
|
49
|
+
if (typeof target !== "string") return null;
|
|
50
|
+
return nested ? { type: "one2one", target, nested } : { type: "one2one", target };
|
|
51
|
+
}
|
|
52
|
+
if (type === "many2many") {
|
|
53
|
+
const target = typeof v["target"] === "string" ? v["target"] : key;
|
|
54
|
+
const through = v["through"];
|
|
55
|
+
const foreignKey = v["foreignKey"];
|
|
56
|
+
const otherKey = v["otherKey"];
|
|
57
|
+
if (typeof through !== "string" || typeof foreignKey !== "string" || typeof otherKey !== "string")
|
|
58
|
+
return null;
|
|
59
|
+
return nested ? { type: "many2many", target, through, foreignKey, otherKey, nested } : { type: "many2many", target, through, foreignKey, otherKey };
|
|
60
|
+
}
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
var init_parseRelations = __esm({
|
|
64
|
+
"src/storage/parseRelations.ts"() {
|
|
65
|
+
"use strict";
|
|
66
|
+
init_esm_shims();
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
20
70
|
// src/utils/deepCopy.ts
|
|
21
71
|
function deepCopyData(source) {
|
|
22
72
|
return Object.fromEntries(
|
|
@@ -42,7 +92,7 @@ import { parse as parse2, stringify } from "yaml";
|
|
|
42
92
|
function createYrestStorage(filePath) {
|
|
43
93
|
const absPath = resolve(filePath);
|
|
44
94
|
const raw = parse2(readFileSync2(absPath, "utf8")) ?? {};
|
|
45
|
-
const relations = raw["_rel"]
|
|
95
|
+
const relations = parseRelations(raw["_rel"]);
|
|
46
96
|
const routes = Array.isArray(raw["_routes"]) ? raw["_routes"] : [];
|
|
47
97
|
const data = Object.fromEntries(
|
|
48
98
|
Object.entries(raw).filter(([key]) => key !== "_rel" && key !== "_routes")
|
|
@@ -79,7 +129,7 @@ function createYrestStorage(filePath) {
|
|
|
79
129
|
},
|
|
80
130
|
reload() {
|
|
81
131
|
const fresh = parse2(readFileSync2(absPath, "utf8")) ?? {};
|
|
82
|
-
const freshRelations = fresh["_rel"]
|
|
132
|
+
const freshRelations = parseRelations(fresh["_rel"]);
|
|
83
133
|
const freshData = Object.fromEntries(
|
|
84
134
|
Object.entries(fresh).filter(([key]) => key !== "_rel" && key !== "_routes")
|
|
85
135
|
);
|
|
@@ -112,6 +162,7 @@ var init_yrestStorage = __esm({
|
|
|
112
162
|
"src/storage/yrestStorage.ts"() {
|
|
113
163
|
"use strict";
|
|
114
164
|
init_esm_shims();
|
|
165
|
+
init_parseRelations();
|
|
115
166
|
init_deepCopy();
|
|
116
167
|
}
|
|
117
168
|
});
|
|
@@ -163,13 +214,21 @@ function dedent(str) {
|
|
|
163
214
|
|
|
164
215
|
// src/api/yrestServer.ts
|
|
165
216
|
init_esm_shims();
|
|
217
|
+
init_parseRelations();
|
|
166
218
|
import { resolve as resolve2 } from "path";
|
|
167
219
|
|
|
168
220
|
// src/utils/handlers.ts
|
|
169
221
|
init_esm_shims();
|
|
170
222
|
import { existsSync } from "fs";
|
|
223
|
+
var ALLOWED_EXTENSIONS = [".js", ".mjs", ".cjs"];
|
|
171
224
|
async function loadHandlers(filePath) {
|
|
172
225
|
if (!existsSync(filePath)) return /* @__PURE__ */ new Map();
|
|
226
|
+
if (!ALLOWED_EXTENSIONS.some((ext) => filePath.endsWith(ext))) {
|
|
227
|
+
console.error(
|
|
228
|
+
` \x1B[31m[handlers] ${filePath} \u2014 only .js, .mjs and .cjs files are allowed\x1B[0m`
|
|
229
|
+
);
|
|
230
|
+
return /* @__PURE__ */ new Map();
|
|
231
|
+
}
|
|
173
232
|
try {
|
|
174
233
|
const mod = await import(filePath);
|
|
175
234
|
const map = /* @__PURE__ */ new Map();
|
|
@@ -210,6 +269,9 @@ import { readFileSync } from "fs";
|
|
|
210
269
|
import { dirname, join } from "path";
|
|
211
270
|
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
212
271
|
|
|
272
|
+
// src/router/templates/about.helpers.ts
|
|
273
|
+
init_esm_shims();
|
|
274
|
+
|
|
213
275
|
// src/utils/interpolate.ts
|
|
214
276
|
init_esm_shims();
|
|
215
277
|
import { randomUUID } from "crypto";
|
|
@@ -257,16 +319,7 @@ function hasTemplates(value) {
|
|
|
257
319
|
return typeof value === "string" ? value.includes("{{") : JSON.stringify(value).includes("{{");
|
|
258
320
|
}
|
|
259
321
|
|
|
260
|
-
// src/router/templates/about.
|
|
261
|
-
var _dir = dirname(fileURLToPath2(import.meta.url));
|
|
262
|
-
var LOGO_SRC = (() => {
|
|
263
|
-
try {
|
|
264
|
-
const buf = readFileSync(join(_dir, "../../assets/logo-color.png"));
|
|
265
|
-
return `data:image/png;base64,${buf.toString("base64")}`;
|
|
266
|
-
} catch {
|
|
267
|
-
return "";
|
|
268
|
-
}
|
|
269
|
-
})();
|
|
322
|
+
// src/router/templates/about.helpers.ts
|
|
270
323
|
var METHOD_COLOR = {
|
|
271
324
|
GET: "#3fb950",
|
|
272
325
|
POST: "#58a6ff",
|
|
@@ -275,8 +328,11 @@ var METHOD_COLOR = {
|
|
|
275
328
|
DELETE: "#f85149",
|
|
276
329
|
fn: "#f0883e"
|
|
277
330
|
};
|
|
331
|
+
function escapeHtml(str) {
|
|
332
|
+
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
333
|
+
}
|
|
278
334
|
function badge(label, color, bg) {
|
|
279
|
-
return `<span class="badge" style="background:${bg};color:${color};border:1px solid ${color}40">${label}</span>`;
|
|
335
|
+
return `<span class="badge" style="background:${bg};color:${color};border:1px solid ${color}40">${escapeHtml(label)}</span>`;
|
|
280
336
|
}
|
|
281
337
|
function methodBadge(method) {
|
|
282
338
|
const color = METHOD_COLOR[method] ?? "#7d8590";
|
|
@@ -286,7 +342,7 @@ function endpointRow(method, path2, desc) {
|
|
|
286
342
|
return `
|
|
287
343
|
<tr>
|
|
288
344
|
<td class="method-cell">${methodBadge(method)}</td>
|
|
289
|
-
<td class="path-cell"><code>${path2}</code></td>
|
|
345
|
+
<td class="path-cell"><code>${escapeHtml(path2)}</code></td>
|
|
290
346
|
<td class="desc-cell">${desc}</td>
|
|
291
347
|
</tr>`;
|
|
292
348
|
}
|
|
@@ -320,7 +376,7 @@ function resourceAccordion(name, base, isOpen) {
|
|
|
320
376
|
return `
|
|
321
377
|
<details class="resource-card" ${isOpen ? "open" : ""}>
|
|
322
378
|
<summary>
|
|
323
|
-
<span class="resource-name">/${name}</span>
|
|
379
|
+
<span class="resource-name">/${escapeHtml(name)}</span>
|
|
324
380
|
<span class="route-count">6 routes</span>
|
|
325
381
|
</summary>
|
|
326
382
|
<table>
|
|
@@ -328,6 +384,139 @@ function resourceAccordion(name, base, isOpen) {
|
|
|
328
384
|
</table>
|
|
329
385
|
</details>`;
|
|
330
386
|
}
|
|
387
|
+
function nestedRoutesAccordion(relations, base) {
|
|
388
|
+
const rows = [];
|
|
389
|
+
for (const [source, fields] of Object.entries(relations)) {
|
|
390
|
+
for (const [key, def] of Object.entries(fields)) {
|
|
391
|
+
const nestedBadge = def.nested ? ` ${badge("nested", "#facc15", "#facc1518")}` : "";
|
|
392
|
+
if (def.type === "many2many") {
|
|
393
|
+
const singular = source.endsWith("s") ? source.slice(0, -1) : source;
|
|
394
|
+
const m2mBadge = badge("many2many", "#818cf8", "#818cf818");
|
|
395
|
+
rows.push(
|
|
396
|
+
endpointRow(
|
|
397
|
+
"GET",
|
|
398
|
+
`${base}/${source}/:id/${key}`,
|
|
399
|
+
`List ${def.target} linked to a ${singular} via ${def.through}. ${m2mBadge}${nestedBadge}`
|
|
400
|
+
)
|
|
401
|
+
);
|
|
402
|
+
const targetSingular = def.target.endsWith("s") ? def.target.slice(0, -1) : def.target;
|
|
403
|
+
rows.push(
|
|
404
|
+
endpointRow(
|
|
405
|
+
"GET",
|
|
406
|
+
`${base}/${def.target}/:id/${source}`,
|
|
407
|
+
`List ${source} linked to a ${targetSingular} via ${def.through} (inverse). ${m2mBadge}`
|
|
408
|
+
)
|
|
409
|
+
);
|
|
410
|
+
} else {
|
|
411
|
+
const path2 = `${base}/${def.target}/:id/${source}`;
|
|
412
|
+
const parentSingular = def.target.endsWith("s") ? def.target.slice(0, -1) : def.target;
|
|
413
|
+
const typeBadge = def.type === "one2one" ? ` ${badge("one2one", "#34d399", "#34d39918")}` : "";
|
|
414
|
+
rows.push(
|
|
415
|
+
endpointRow(
|
|
416
|
+
"GET",
|
|
417
|
+
path2,
|
|
418
|
+
`${def.type === "one2one" ? "Get" : "List"} ${source} belonging to a ${parentSingular}.${typeBadge}${nestedBadge}`
|
|
419
|
+
)
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
if (!rows.length) return "";
|
|
425
|
+
return `
|
|
426
|
+
<details class="resource-card nested-card">
|
|
427
|
+
<summary>
|
|
428
|
+
<span class="resource-name">Nested routes</span>
|
|
429
|
+
<span class="route-count">${rows.length} route${rows.length !== 1 ? "s" : ""}</span>
|
|
430
|
+
</summary>
|
|
431
|
+
<table><tbody>${rows.join("")}</tbody></table>
|
|
432
|
+
</details>`;
|
|
433
|
+
}
|
|
434
|
+
function snapshotAccordion() {
|
|
435
|
+
return `
|
|
436
|
+
<details class="resource-card nested-card">
|
|
437
|
+
<summary>
|
|
438
|
+
<span class="resource-name">/_snapshot</span>
|
|
439
|
+
<span class="route-count">3 routes</span>
|
|
440
|
+
</summary>
|
|
441
|
+
<table><tbody>
|
|
442
|
+
${endpointRow("GET", "/_snapshot", "Returns metadata of the current snapshot: <code>savedAt</code> and item counts per collection.")}
|
|
443
|
+
${endpointRow("POST", "/_snapshot/save", "Replaces the stored snapshot with the current database state.")}
|
|
444
|
+
${endpointRow("POST", "/_snapshot/reset", "Restores the database to the last saved snapshot and persists to disk.")}
|
|
445
|
+
</tbody></table>
|
|
446
|
+
</details>`;
|
|
447
|
+
}
|
|
448
|
+
function customRoutesAccordion(routes, base, handlers) {
|
|
449
|
+
if (!routes.length) return "";
|
|
450
|
+
const rows = routes.map((r) => {
|
|
451
|
+
const fullPath = `${base}${r.path}`;
|
|
452
|
+
const tags = [];
|
|
453
|
+
if (r.error) tags.push(`<span style="color:#f85149;font-size:11px">error\xB7${r.error}</span>`);
|
|
454
|
+
if (r.delay && r.delay > 0)
|
|
455
|
+
tags.push(`<span style="color:#fb923c;font-size:11px">delay\xB7${r.delay}ms</span>`);
|
|
456
|
+
if (r.scenarios?.length) {
|
|
457
|
+
const hasOr = r.scenarios.some((s) => Array.isArray(s.when));
|
|
458
|
+
tags.push(
|
|
459
|
+
`<span style="color:#a371f7;font-size:11px">scenarios\xB7${r.scenarios.length}${hasOr ? " (OR)" : ""}</span>`
|
|
460
|
+
);
|
|
461
|
+
}
|
|
462
|
+
if (r.otherwise)
|
|
463
|
+
tags.push(`<span style="color:var(--text-muted);font-size:11px">otherwise</span>`);
|
|
464
|
+
let desc;
|
|
465
|
+
if (r.error) {
|
|
466
|
+
desc = `Error injection \u2014 <code>${r.error}</code>`;
|
|
467
|
+
} else if (r.handler) {
|
|
468
|
+
const found = handlers.has(r.handler);
|
|
469
|
+
const handlerName = escapeHtml(r.handler);
|
|
470
|
+
desc = found ? `Handler \u2014 <code>${handlerName}()</code>` : `Handler \u2014 <code>${handlerName}()</code> <span style="color:#f85149">(not loaded)</span>`;
|
|
471
|
+
} else if (r.scenarios?.length) {
|
|
472
|
+
const hasTemplateInScenarios = r.scenarios.some((s) => s.response.body != null && hasTemplates(s.response.body)) || r.otherwise?.body != null && hasTemplates(r.otherwise.body);
|
|
473
|
+
desc = hasTemplateInScenarios ? `Scenarios \u2014 <code>{{\u2026}}</code>` : `Scenarios`;
|
|
474
|
+
} else if (r.response?.body != null && hasTemplates(r.response.body)) {
|
|
475
|
+
desc = `Dynamic \u2014 <code>{{\u2026}}</code>`;
|
|
476
|
+
} else {
|
|
477
|
+
const status = r.response?.status ?? 200;
|
|
478
|
+
desc = `Static \u2014 <code>${status}</code>${r.response?.headers ? ` + headers` : ""}`;
|
|
479
|
+
}
|
|
480
|
+
if (tags.length) desc += ` ${tags.join(" ")}`;
|
|
481
|
+
return endpointRow(r.method?.toUpperCase() ?? "GET", fullPath, desc);
|
|
482
|
+
});
|
|
483
|
+
return `
|
|
484
|
+
<details class="resource-card nested-card">
|
|
485
|
+
<summary>
|
|
486
|
+
<span class="resource-name">Custom routes</span>
|
|
487
|
+
<span class="route-count">${routes.length} route${routes.length !== 1 ? "s" : ""}</span>
|
|
488
|
+
</summary>
|
|
489
|
+
<table><tbody>
|
|
490
|
+
${rows.join("")}
|
|
491
|
+
</tbody></table>
|
|
492
|
+
</details>`;
|
|
493
|
+
}
|
|
494
|
+
function handlersAccordion(handlers, routes, base) {
|
|
495
|
+
if (!handlers.size) return "";
|
|
496
|
+
const routesByHandler = /* @__PURE__ */ new Map();
|
|
497
|
+
for (const r of routes) {
|
|
498
|
+
if (r.handler) {
|
|
499
|
+
const list = routesByHandler.get(r.handler) ?? [];
|
|
500
|
+
list.push({ method: (r.method ?? "GET").toUpperCase(), path: `${base}${r.path}` });
|
|
501
|
+
routesByHandler.set(r.handler, list);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
const rows = [...handlers.keys()].map((name) => {
|
|
505
|
+
const linked = routesByHandler.get(name);
|
|
506
|
+
const routeDesc = linked ? linked.map((r) => `<code>${r.method} ${r.path}</code>`).join(", ") : `<span style="color:var(--text-muted)">not referenced in _routes</span>`;
|
|
507
|
+
return endpointRow("fn", name + "()", routeDesc);
|
|
508
|
+
});
|
|
509
|
+
return `
|
|
510
|
+
<details class="resource-card nested-card">
|
|
511
|
+
<summary>
|
|
512
|
+
<span class="resource-name">Handlers</span>
|
|
513
|
+
<span class="route-count">${handlers.size} function${handlers.size !== 1 ? "s" : ""}</span>
|
|
514
|
+
</summary>
|
|
515
|
+
<table><tbody>
|
|
516
|
+
${rows.join("")}
|
|
517
|
+
</tbody></table>
|
|
518
|
+
</details>`;
|
|
519
|
+
}
|
|
331
520
|
function examplesBlock(collections, relations, base, host, options, firstCustomRoute) {
|
|
332
521
|
const examples = [];
|
|
333
522
|
const firstCol = collections[0];
|
|
@@ -358,35 +547,36 @@ curl -X DELETE ${p}/1`
|
|
|
358
547
|
const firstRel = Object.entries(relations)[0];
|
|
359
548
|
if (firstRel) {
|
|
360
549
|
const [child, fields] = firstRel;
|
|
361
|
-
const
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
curl ${host}${base}/${
|
|
550
|
+
const firstField = Object.entries(fields)[0];
|
|
551
|
+
if (firstField) {
|
|
552
|
+
const [fk, def] = firstField;
|
|
553
|
+
if (def.type !== "many2many") {
|
|
554
|
+
const expandKey = fk.replace(/Id$/i, "");
|
|
555
|
+
examples.push(
|
|
556
|
+
`# Embed parent with ?_expand
|
|
557
|
+
curl "${host}${base}/${child}/1?_expand=${expandKey}"`,
|
|
558
|
+
`# Nested resource
|
|
559
|
+
curl ${host}${base}/${def.target}/1/${child}`
|
|
560
|
+
);
|
|
561
|
+
} else {
|
|
562
|
+
examples.push(`# Many-to-many embed
|
|
563
|
+
curl "${host}${base}/${child}/1/${fk}"`);
|
|
564
|
+
}
|
|
375
565
|
}
|
|
376
566
|
}
|
|
377
567
|
if (options.pageable.enabled && firstCol) {
|
|
378
568
|
examples.push(`# Pageable envelope
|
|
379
569
|
curl "${host}${base}/${firstCol}?_page=2"`);
|
|
380
570
|
}
|
|
381
|
-
const firstParentRel = Object.entries(relations).find(
|
|
382
|
-
([, fields]) => Object.values(fields).includes(firstCol ?? "")
|
|
383
|
-
);
|
|
384
571
|
if (firstCol) {
|
|
385
572
|
examples.push(
|
|
386
573
|
`# Project fields with ?_fields
|
|
387
574
|
curl "${host}${base}/${firstCol}?_fields=id,name"`
|
|
388
575
|
);
|
|
389
576
|
}
|
|
577
|
+
const firstParentRel = Object.entries(relations).find(
|
|
578
|
+
([, fields]) => Object.values(fields).some((def) => def.type !== "many2many" && def.target === firstCol)
|
|
579
|
+
);
|
|
390
580
|
if (firstParentRel && firstCol) {
|
|
391
581
|
const [childName] = firstParentRel;
|
|
392
582
|
examples.push(
|
|
@@ -404,7 +594,7 @@ curl -X POST ${host}/_snapshot/reset`
|
|
|
404
594
|
}
|
|
405
595
|
if (firstCustomRoute) {
|
|
406
596
|
const method = firstCustomRoute.method?.toUpperCase() ?? "GET";
|
|
407
|
-
const fullPath = `${host}${base}${firstCustomRoute.path}`;
|
|
597
|
+
const fullPath = `${host}${base}${escapeHtml(firstCustomRoute.path ?? "")}`;
|
|
408
598
|
const curlFlag = method === "GET" ? "" : `-X ${method} `;
|
|
409
599
|
examples.push(`# Custom route
|
|
410
600
|
curl ${curlFlag}${fullPath}`);
|
|
@@ -412,9 +602,21 @@ curl ${curlFlag}${fullPath}`);
|
|
|
412
602
|
const highlighted = examples.map((e) => e.replace(/^(#.+)$/gm, '<span class="cm">$1</span>')).join("\n\n");
|
|
413
603
|
return `<pre>${highlighted}</pre>`;
|
|
414
604
|
}
|
|
605
|
+
|
|
606
|
+
// src/router/templates/about.template.ts
|
|
607
|
+
var _dir = dirname(fileURLToPath2(import.meta.url));
|
|
608
|
+
var LOGO_SRC = (() => {
|
|
609
|
+
try {
|
|
610
|
+
const buf = readFileSync(join(_dir, "../../assets/logo-color.png"));
|
|
611
|
+
return `data:image/png;base64,${buf.toString("base64")}`;
|
|
612
|
+
} catch {
|
|
613
|
+
return "";
|
|
614
|
+
}
|
|
615
|
+
})();
|
|
415
616
|
function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map()) {
|
|
416
617
|
const collections = Object.keys(storage.getData());
|
|
417
618
|
const relations = storage.getRelations();
|
|
619
|
+
const customRoutes = storage.getRoutes();
|
|
418
620
|
const base = options.base;
|
|
419
621
|
const host = `http://${options.host}:${options.port}`;
|
|
420
622
|
const modes = [];
|
|
@@ -428,104 +630,6 @@ function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map(
|
|
|
428
630
|
if (options.idStrategy !== "increment")
|
|
429
631
|
modes.push(badge(`id \xB7 ${options.idStrategy}`, "#a371f7", "#a371f718"));
|
|
430
632
|
const accordions = collections.map((col, i) => resourceAccordion(col, base, i === 0)).join("");
|
|
431
|
-
const nestedRows = [];
|
|
432
|
-
for (const [child, fields] of Object.entries(relations)) {
|
|
433
|
-
for (const [, parent] of Object.entries(fields)) {
|
|
434
|
-
const nestedPath = `${base}/${parent}/:id/${child}`;
|
|
435
|
-
const parentSingular = parent.endsWith("s") ? parent.slice(0, -1) : parent;
|
|
436
|
-
nestedRows.push(
|
|
437
|
-
endpointRow("GET", nestedPath, `List ${child} belonging to a ${parentSingular}.`)
|
|
438
|
-
);
|
|
439
|
-
}
|
|
440
|
-
}
|
|
441
|
-
const nestedAccordion = nestedRows.length ? `
|
|
442
|
-
<details class="resource-card nested-card">
|
|
443
|
-
<summary>
|
|
444
|
-
<span class="resource-name">Nested routes</span>
|
|
445
|
-
<span class="route-count">${nestedRows.length} route${nestedRows.length !== 1 ? "s" : ""}</span>
|
|
446
|
-
</summary>
|
|
447
|
-
<table><tbody>${nestedRows.join("")}</tbody></table>
|
|
448
|
-
</details>` : "";
|
|
449
|
-
const snapshotAccordion = options.snapshot ? `
|
|
450
|
-
<details class="resource-card nested-card">
|
|
451
|
-
<summary>
|
|
452
|
-
<span class="resource-name">/_snapshot</span>
|
|
453
|
-
<span class="route-count">3 routes</span>
|
|
454
|
-
</summary>
|
|
455
|
-
<table><tbody>
|
|
456
|
-
${endpointRow("GET", "/_snapshot", "Returns metadata of the current snapshot: <code>savedAt</code> and item counts per collection.")}
|
|
457
|
-
${endpointRow("POST", "/_snapshot/save", "Replaces the stored snapshot with the current database state.")}
|
|
458
|
-
${endpointRow("POST", "/_snapshot/reset", "Restores the database to the last saved snapshot and persists to disk.")}
|
|
459
|
-
</tbody></table>
|
|
460
|
-
</details>` : "";
|
|
461
|
-
const customRoutes = storage.getRoutes();
|
|
462
|
-
const customRoutesAccordion = customRoutes.length ? `
|
|
463
|
-
<details class="resource-card nested-card">
|
|
464
|
-
<summary>
|
|
465
|
-
<span class="resource-name">Custom routes</span>
|
|
466
|
-
<span class="route-count">${customRoutes.length} route${customRoutes.length !== 1 ? "s" : ""}</span>
|
|
467
|
-
</summary>
|
|
468
|
-
<table><tbody>
|
|
469
|
-
${customRoutes.map((r) => {
|
|
470
|
-
const fullPath = `${base}${r.path}`;
|
|
471
|
-
const tags = [];
|
|
472
|
-
if (r.error) {
|
|
473
|
-
tags.push(`<span style="color:#f85149;font-size:11px">error\xB7${r.error}</span>`);
|
|
474
|
-
}
|
|
475
|
-
if (r.delay && r.delay > 0) {
|
|
476
|
-
tags.push(`<span style="color:#fb923c;font-size:11px">delay\xB7${r.delay}ms</span>`);
|
|
477
|
-
}
|
|
478
|
-
if (r.scenarios?.length) {
|
|
479
|
-
const hasOr = r.scenarios.some((s) => Array.isArray(s.when));
|
|
480
|
-
tags.push(
|
|
481
|
-
`<span style="color:#a371f7;font-size:11px">scenarios\xB7${r.scenarios.length}${hasOr ? " (OR)" : ""}</span>`
|
|
482
|
-
);
|
|
483
|
-
}
|
|
484
|
-
if (r.otherwise) {
|
|
485
|
-
tags.push(`<span style="color:var(--text-muted);font-size:11px">otherwise</span>`);
|
|
486
|
-
}
|
|
487
|
-
let desc;
|
|
488
|
-
if (r.error) {
|
|
489
|
-
desc = `Error injection \u2014 <code>${r.error}</code>`;
|
|
490
|
-
} else if (r.handler) {
|
|
491
|
-
const found = handlers.has(r.handler);
|
|
492
|
-
desc = found ? `Handler \u2014 <code>${r.handler}()</code>` : `Handler \u2014 <code>${r.handler}()</code> <span style="color:#f85149">(not loaded)</span>`;
|
|
493
|
-
} else if (r.scenarios?.length) {
|
|
494
|
-
const hasTemplateInScenarios = r.scenarios.some((s) => s.response.body != null && hasTemplates(s.response.body)) || r.otherwise?.body != null && hasTemplates(r.otherwise.body);
|
|
495
|
-
desc = hasTemplateInScenarios ? `Scenarios \u2014 <code>{{\u2026}}</code>` : `Scenarios`;
|
|
496
|
-
} else if (r.response?.body != null && hasTemplates(r.response.body)) {
|
|
497
|
-
desc = `Dynamic \u2014 <code>{{\u2026}}</code>`;
|
|
498
|
-
} else {
|
|
499
|
-
const status = r.response?.status ?? 200;
|
|
500
|
-
desc = `Static \u2014 <code>${status}</code>${r.response?.headers ? ` + headers` : ""}`;
|
|
501
|
-
}
|
|
502
|
-
if (tags.length) desc += ` ${tags.join(" ")}`;
|
|
503
|
-
return endpointRow(r.method?.toUpperCase() ?? "GET", fullPath, desc);
|
|
504
|
-
}).join("")}
|
|
505
|
-
</tbody></table>
|
|
506
|
-
</details>` : "";
|
|
507
|
-
const routesByHandler = /* @__PURE__ */ new Map();
|
|
508
|
-
for (const r of customRoutes) {
|
|
509
|
-
if (r.handler) {
|
|
510
|
-
const list = routesByHandler.get(r.handler) ?? [];
|
|
511
|
-
list.push({ method: (r.method ?? "GET").toUpperCase(), path: `${base}${r.path}` });
|
|
512
|
-
routesByHandler.set(r.handler, list);
|
|
513
|
-
}
|
|
514
|
-
}
|
|
515
|
-
const handlersAccordion = handlers.size > 0 ? `
|
|
516
|
-
<details class="resource-card nested-card">
|
|
517
|
-
<summary>
|
|
518
|
-
<span class="resource-name">Handlers</span>
|
|
519
|
-
<span class="route-count">${handlers.size} function${handlers.size !== 1 ? "s" : ""}</span>
|
|
520
|
-
</summary>
|
|
521
|
-
<table><tbody>
|
|
522
|
-
${[...handlers.keys()].map((name) => {
|
|
523
|
-
const routes = routesByHandler.get(name);
|
|
524
|
-
const routeDesc = routes ? routes.map((r) => `<code>${r.method} ${r.path}</code>`).join(", ") : `<span style="color:var(--text-muted)">not referenced in _routes</span>`;
|
|
525
|
-
return endpointRow("fn", name + "()", routeDesc);
|
|
526
|
-
}).join("")}
|
|
527
|
-
</tbody></table>
|
|
528
|
-
</details>` : "";
|
|
529
633
|
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.`;
|
|
530
634
|
return `<!DOCTYPE html>
|
|
531
635
|
<html lang="en">
|
|
@@ -673,10 +777,10 @@ function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map(
|
|
|
673
777
|
<h2>Endpoints</h2>
|
|
674
778
|
<div class="endpoints-grid">
|
|
675
779
|
${accordions}
|
|
676
|
-
${
|
|
677
|
-
${snapshotAccordion}
|
|
678
|
-
${customRoutesAccordion}
|
|
679
|
-
${handlersAccordion}
|
|
780
|
+
${nestedRoutesAccordion(relations, base)}
|
|
781
|
+
${options.snapshot ? snapshotAccordion() : ""}
|
|
782
|
+
${customRoutesAccordion(customRoutes, base, handlers)}
|
|
783
|
+
${handlersAccordion(handlers, customRoutes, base)}
|
|
680
784
|
</div>
|
|
681
785
|
|
|
682
786
|
<h2>Query Parameters</h2>
|
|
@@ -767,6 +871,7 @@ function applyOperator(itemValue, op, filterValue) {
|
|
|
767
871
|
case "_start":
|
|
768
872
|
return strItem.toLowerCase().startsWith(filterValue.toLowerCase());
|
|
769
873
|
case "_regex": {
|
|
874
|
+
if (filterValue.length > 200) return false;
|
|
770
875
|
try {
|
|
771
876
|
return new RegExp(filterValue, "i").test(strItem);
|
|
772
877
|
} catch {
|
|
@@ -882,10 +987,11 @@ function expandItems(input, query, resource, storage) {
|
|
|
882
987
|
const resourceRelations = storage.getRelations()[resource] ?? {};
|
|
883
988
|
const expansions = /* @__PURE__ */ new Map();
|
|
884
989
|
for (const expandKey of keys) {
|
|
885
|
-
for (const [field,
|
|
990
|
+
for (const [field, def] of Object.entries(resourceRelations)) {
|
|
991
|
+
if (def.type === "many2many") continue;
|
|
886
992
|
const derivedKey = field.replace(/Id$/i, "");
|
|
887
|
-
if (derivedKey === expandKey ||
|
|
888
|
-
expansions.set(expandKey, { field, parentCollection });
|
|
993
|
+
if (derivedKey === expandKey || def.target === expandKey || def.target === `${expandKey}s`) {
|
|
994
|
+
expansions.set(expandKey, { field, parentCollection: def.target });
|
|
889
995
|
break;
|
|
890
996
|
}
|
|
891
997
|
}
|
|
@@ -914,10 +1020,24 @@ function embedItems(input, query, resource, storage) {
|
|
|
914
1020
|
const relations = storage.getRelations();
|
|
915
1021
|
const embeds = /* @__PURE__ */ new Map();
|
|
916
1022
|
for (const embedKey of keys) {
|
|
1023
|
+
const ownRelations = relations[resource] ?? {};
|
|
1024
|
+
if (embedKey in ownRelations) {
|
|
1025
|
+
const def = ownRelations[embedKey];
|
|
1026
|
+
if (def.type === "many2many") {
|
|
1027
|
+
embeds.set(embedKey, {
|
|
1028
|
+
kind: "many2many",
|
|
1029
|
+
target: def.target,
|
|
1030
|
+
through: def.through,
|
|
1031
|
+
foreignKey: def.foreignKey,
|
|
1032
|
+
otherKey: def.otherKey
|
|
1033
|
+
});
|
|
1034
|
+
continue;
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
917
1037
|
outer: for (const [childCollection, fields] of Object.entries(relations)) {
|
|
918
|
-
for (const [fkField,
|
|
919
|
-
if (
|
|
920
|
-
embeds.set(embedKey, { childCollection, fkField });
|
|
1038
|
+
for (const [fkField, def] of Object.entries(fields)) {
|
|
1039
|
+
if ((def.type === "many2one" || def.type === "one2one") && def.target === resource && childCollection === embedKey) {
|
|
1040
|
+
embeds.set(embedKey, { kind: def.type, childCollection, fkField });
|
|
921
1041
|
break outer;
|
|
922
1042
|
}
|
|
923
1043
|
}
|
|
@@ -926,10 +1046,57 @@ function embedItems(input, query, resource, storage) {
|
|
|
926
1046
|
if (embeds.size === 0) return isArray ? items : input;
|
|
927
1047
|
const result = items.map((item) => {
|
|
928
1048
|
const out = { ...item };
|
|
929
|
-
for (const [embedKey,
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
1049
|
+
for (const [embedKey, spec] of embeds) {
|
|
1050
|
+
if (spec.kind === "many2many") {
|
|
1051
|
+
const pivot = storage.getCollection(spec.through) ?? [];
|
|
1052
|
+
const matchingIds = new Set(
|
|
1053
|
+
pivot.filter((row) => String(row[spec.foreignKey]) === String(item["id"])).map((row) => String(row[spec.otherKey]))
|
|
1054
|
+
);
|
|
1055
|
+
out[embedKey] = (storage.getCollection(spec.target) ?? []).filter(
|
|
1056
|
+
(t) => matchingIds.has(String(t["id"]))
|
|
1057
|
+
);
|
|
1058
|
+
} else if (spec.kind === "one2one") {
|
|
1059
|
+
out[embedKey] = (storage.getCollection(spec.childCollection) ?? []).find(
|
|
1060
|
+
(child) => String(child[spec.fkField]) === String(item["id"])
|
|
1061
|
+
) ?? null;
|
|
1062
|
+
} else {
|
|
1063
|
+
out[embedKey] = (storage.getCollection(spec.childCollection) ?? []).filter(
|
|
1064
|
+
(child) => String(child[spec.fkField]) === String(item["id"])
|
|
1065
|
+
);
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
return out;
|
|
1069
|
+
});
|
|
1070
|
+
return isArray ? result : result[0];
|
|
1071
|
+
}
|
|
1072
|
+
function applyNested(input, resource, storage) {
|
|
1073
|
+
const isArray = Array.isArray(input);
|
|
1074
|
+
const items = isArray ? input : [input];
|
|
1075
|
+
const resourceRelations = storage.getRelations()[resource] ?? {};
|
|
1076
|
+
const nestedDefs = Object.entries(resourceRelations).filter(([, def]) => def.nested === true);
|
|
1077
|
+
if (nestedDefs.length === 0) return input;
|
|
1078
|
+
const result = items.map((item) => {
|
|
1079
|
+
const out = { ...item };
|
|
1080
|
+
for (const [key, def] of nestedDefs) {
|
|
1081
|
+
if (def.type === "many2many") {
|
|
1082
|
+
const pivot = storage.getCollection(def.through) ?? [];
|
|
1083
|
+
const matchingIds = new Set(
|
|
1084
|
+
pivot.filter((row) => String(row[def.foreignKey]) === String(item["id"])).map((row) => String(row[def.otherKey]))
|
|
1085
|
+
);
|
|
1086
|
+
out[key] = (storage.getCollection(def.target) ?? []).filter(
|
|
1087
|
+
(t) => matchingIds.has(String(t["id"]))
|
|
1088
|
+
);
|
|
1089
|
+
} else {
|
|
1090
|
+
const foreignKeyValue = item[key];
|
|
1091
|
+
if (foreignKeyValue === void 0) continue;
|
|
1092
|
+
const parent = (storage.getCollection(def.target) ?? []).find(
|
|
1093
|
+
(p) => String(p["id"]) === String(foreignKeyValue)
|
|
1094
|
+
);
|
|
1095
|
+
if (parent !== void 0) {
|
|
1096
|
+
const embedKey = key.replace(/Id$/i, "");
|
|
1097
|
+
out[embedKey] = parent;
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
933
1100
|
}
|
|
934
1101
|
return out;
|
|
935
1102
|
});
|
|
@@ -969,7 +1136,12 @@ var CollectionRouteCommand = class {
|
|
|
969
1136
|
const totalPages = Math.ceil(totalItems / limit) || 1;
|
|
970
1137
|
const data = projectFields(
|
|
971
1138
|
embedItems(
|
|
972
|
-
expandItems(
|
|
1139
|
+
expandItems(
|
|
1140
|
+
applyNested(paginate(sorted, page, limit), this.resource, this.storage),
|
|
1141
|
+
req.query,
|
|
1142
|
+
this.resource,
|
|
1143
|
+
this.storage
|
|
1144
|
+
),
|
|
973
1145
|
req.query,
|
|
974
1146
|
this.resource,
|
|
975
1147
|
this.storage
|
|
@@ -1001,7 +1173,12 @@ var CollectionRouteCommand = class {
|
|
|
1001
1173
|
}
|
|
1002
1174
|
return projectFields(
|
|
1003
1175
|
embedItems(
|
|
1004
|
-
expandItems(
|
|
1176
|
+
expandItems(
|
|
1177
|
+
applyNested(result, this.resource, this.storage),
|
|
1178
|
+
req.query,
|
|
1179
|
+
this.resource,
|
|
1180
|
+
this.storage
|
|
1181
|
+
),
|
|
1005
1182
|
req.query,
|
|
1006
1183
|
this.resource,
|
|
1007
1184
|
this.storage
|
|
@@ -1187,7 +1364,12 @@ var ItemRouteCommand = class {
|
|
|
1187
1364
|
const fields = (req.query["_fields"] ?? "").split(",").map((f) => f.trim()).filter(Boolean);
|
|
1188
1365
|
return projectFields(
|
|
1189
1366
|
embedItems(
|
|
1190
|
-
expandItems(
|
|
1367
|
+
expandItems(
|
|
1368
|
+
applyNested(item, this.resource, this.storage),
|
|
1369
|
+
req.query,
|
|
1370
|
+
this.resource,
|
|
1371
|
+
this.storage
|
|
1372
|
+
),
|
|
1191
1373
|
req.query,
|
|
1192
1374
|
this.resource,
|
|
1193
1375
|
this.storage
|
|
@@ -1231,32 +1413,68 @@ var NestedRouteCommand = class {
|
|
|
1231
1413
|
relations;
|
|
1232
1414
|
base;
|
|
1233
1415
|
register(server) {
|
|
1234
|
-
for (const [
|
|
1235
|
-
for (const [
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
if (!parentItem) return reply.status(404).send({ error: "Not found" });
|
|
1242
|
-
const children = (this.storage.getCollection(child) ?? []).filter(
|
|
1243
|
-
(item) => String(item[field]) === req.params.id
|
|
1244
|
-
);
|
|
1245
|
-
return children;
|
|
1246
|
-
});
|
|
1247
|
-
server.get(itemPath, (req, reply) => {
|
|
1248
|
-
const parentCollection = this.storage.getCollection(parent) ?? [];
|
|
1249
|
-
const parentItem = findById(parentCollection, req.params.id);
|
|
1250
|
-
if (!parentItem) return reply.status(404).send({ error: "Not found" });
|
|
1251
|
-
const childItem = (this.storage.getCollection(child) ?? []).find(
|
|
1252
|
-
(item) => String(item[field]) === req.params.id && String(item["id"]) === req.params.childId
|
|
1253
|
-
);
|
|
1254
|
-
if (!childItem) return reply.status(404).send({ error: "Not found" });
|
|
1255
|
-
return childItem;
|
|
1256
|
-
});
|
|
1416
|
+
for (const [source, fields] of Object.entries(this.relations)) {
|
|
1417
|
+
for (const [key, def] of Object.entries(fields)) {
|
|
1418
|
+
if (def.type === "many2many") {
|
|
1419
|
+
this.registerMany2Many(server, source, key, def);
|
|
1420
|
+
} else {
|
|
1421
|
+
this.registerFkRelation(server, source, key, def.target, def.type);
|
|
1422
|
+
}
|
|
1257
1423
|
}
|
|
1258
1424
|
}
|
|
1259
1425
|
}
|
|
1426
|
+
registerFkRelation(server, child, fkField, parent, type) {
|
|
1427
|
+
const collectionPath = `${this.base}/${parent}/:id/${child}`;
|
|
1428
|
+
const itemPath = `${this.base}/${parent}/:id/${child}/:childId`;
|
|
1429
|
+
server.get(collectionPath, (req, reply) => {
|
|
1430
|
+
const parentCollection = this.storage.getCollection(parent) ?? [];
|
|
1431
|
+
const parentItem = findById(parentCollection, req.params.id);
|
|
1432
|
+
if (!parentItem) return reply.status(404).send({ error: "Not found" });
|
|
1433
|
+
const all = (this.storage.getCollection(child) ?? []).filter(
|
|
1434
|
+
(item) => String(item[fkField]) === req.params.id
|
|
1435
|
+
);
|
|
1436
|
+
if (type === "one2one") return all[0] ?? reply.status(404).send({ error: "Not found" });
|
|
1437
|
+
return all;
|
|
1438
|
+
});
|
|
1439
|
+
if (type === "many2one") {
|
|
1440
|
+
server.get(itemPath, (req, reply) => {
|
|
1441
|
+
const parentCollection = this.storage.getCollection(parent) ?? [];
|
|
1442
|
+
const parentItem = findById(parentCollection, req.params.id);
|
|
1443
|
+
if (!parentItem) return reply.status(404).send({ error: "Not found" });
|
|
1444
|
+
const childItem = (this.storage.getCollection(child) ?? []).find(
|
|
1445
|
+
(item) => String(item[fkField]) === req.params.id && String(item["id"]) === req.params.childId
|
|
1446
|
+
);
|
|
1447
|
+
if (!childItem) return reply.status(404).send({ error: "Not found" });
|
|
1448
|
+
return childItem;
|
|
1449
|
+
});
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1452
|
+
registerMany2Many(server, source, alias, def) {
|
|
1453
|
+
server.get(`${this.base}/${source}/:id/${alias}`, (req, reply) => {
|
|
1454
|
+
const sourceCollection = this.storage.getCollection(source) ?? [];
|
|
1455
|
+
const sourceItem = findById(sourceCollection, req.params.id);
|
|
1456
|
+
if (!sourceItem) return reply.status(404).send({ error: "Not found" });
|
|
1457
|
+
const pivot = this.storage.getCollection(def.through) ?? [];
|
|
1458
|
+
const matchingIds = new Set(
|
|
1459
|
+
pivot.filter((row) => String(row[def.foreignKey]) === req.params.id).map((row) => String(row[def.otherKey]))
|
|
1460
|
+
);
|
|
1461
|
+
return (this.storage.getCollection(def.target) ?? []).filter(
|
|
1462
|
+
(t) => matchingIds.has(String(t["id"]))
|
|
1463
|
+
);
|
|
1464
|
+
});
|
|
1465
|
+
server.get(`${this.base}/${def.target}/:id/${source}`, (req, reply) => {
|
|
1466
|
+
const targetCollection = this.storage.getCollection(def.target) ?? [];
|
|
1467
|
+
const targetItem = findById(targetCollection, req.params.id);
|
|
1468
|
+
if (!targetItem) return reply.status(404).send({ error: "Not found" });
|
|
1469
|
+
const pivot = this.storage.getCollection(def.through) ?? [];
|
|
1470
|
+
const matchingIds = new Set(
|
|
1471
|
+
pivot.filter((row) => String(row[def.otherKey]) === req.params.id).map((row) => String(row[def.foreignKey]))
|
|
1472
|
+
);
|
|
1473
|
+
return (this.storage.getCollection(source) ?? []).filter(
|
|
1474
|
+
(t) => matchingIds.has(String(t["id"]))
|
|
1475
|
+
);
|
|
1476
|
+
});
|
|
1477
|
+
}
|
|
1260
1478
|
};
|
|
1261
1479
|
|
|
1262
1480
|
// src/router/routes/snapshot.routes.ts
|
|
@@ -1419,7 +1637,7 @@ function buildOptions(opts) {
|
|
|
1419
1637
|
}
|
|
1420
1638
|
function createInMemoryStorage(data) {
|
|
1421
1639
|
const raw = data;
|
|
1422
|
-
const relations = raw["_rel"]
|
|
1640
|
+
const relations = parseRelations(raw["_rel"]);
|
|
1423
1641
|
const routes = Array.isArray(raw["_routes"]) ? raw["_routes"] : [];
|
|
1424
1642
|
const collections = Object.fromEntries(
|
|
1425
1643
|
Object.entries(raw).filter(([k]) => k !== "_rel" && k !== "_routes")
|