@yrest/cli 0.8.1 → 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 +793 -190
- package/dist/cli/index.mjs +793 -190
- package/dist/index.d.mts +47 -5
- package/dist/index.d.ts +47 -5
- package/dist/index.js +375 -169
- package/dist/index.mjs +375 -169
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -40,6 +40,56 @@ var init_cjs_shims = __esm({
|
|
|
40
40
|
}
|
|
41
41
|
});
|
|
42
42
|
|
|
43
|
+
// src/storage/parseRelations.ts
|
|
44
|
+
function parseRelations(raw) {
|
|
45
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
|
|
46
|
+
const result = {};
|
|
47
|
+
for (const [collection, fields] of Object.entries(raw)) {
|
|
48
|
+
if (!fields || typeof fields !== "object" || Array.isArray(fields)) continue;
|
|
49
|
+
result[collection] = {};
|
|
50
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
51
|
+
const def = normaliseRelationDef(key, value);
|
|
52
|
+
if (def) result[collection][key] = def;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return result;
|
|
56
|
+
}
|
|
57
|
+
function normaliseRelationDef(key, value) {
|
|
58
|
+
if (typeof value === "string") {
|
|
59
|
+
return { type: "many2one", target: value };
|
|
60
|
+
}
|
|
61
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
62
|
+
const v = value;
|
|
63
|
+
const type = v["type"];
|
|
64
|
+
const nested = v["nested"] === true ? true : void 0;
|
|
65
|
+
if (type === "many2one" || type === void 0) {
|
|
66
|
+
const target = v["target"];
|
|
67
|
+
if (typeof target !== "string") return null;
|
|
68
|
+
return nested ? { type: "many2one", target, nested } : { type: "many2one", target };
|
|
69
|
+
}
|
|
70
|
+
if (type === "one2one") {
|
|
71
|
+
const target = v["target"];
|
|
72
|
+
if (typeof target !== "string") return null;
|
|
73
|
+
return nested ? { type: "one2one", target, nested } : { type: "one2one", target };
|
|
74
|
+
}
|
|
75
|
+
if (type === "many2many") {
|
|
76
|
+
const target = typeof v["target"] === "string" ? v["target"] : key;
|
|
77
|
+
const through = v["through"];
|
|
78
|
+
const foreignKey = v["foreignKey"];
|
|
79
|
+
const otherKey = v["otherKey"];
|
|
80
|
+
if (typeof through !== "string" || typeof foreignKey !== "string" || typeof otherKey !== "string")
|
|
81
|
+
return null;
|
|
82
|
+
return nested ? { type: "many2many", target, through, foreignKey, otherKey, nested } : { type: "many2many", target, through, foreignKey, otherKey };
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
var init_parseRelations = __esm({
|
|
87
|
+
"src/storage/parseRelations.ts"() {
|
|
88
|
+
"use strict";
|
|
89
|
+
init_cjs_shims();
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
43
93
|
// src/utils/deepCopy.ts
|
|
44
94
|
function deepCopyData(source) {
|
|
45
95
|
return Object.fromEntries(
|
|
@@ -61,7 +111,7 @@ __export(yrestStorage_exports, {
|
|
|
61
111
|
function createYrestStorage(filePath) {
|
|
62
112
|
const absPath = (0, import_node_path2.resolve)(filePath);
|
|
63
113
|
const raw = (0, import_yaml2.parse)((0, import_node_fs3.readFileSync)(absPath, "utf8")) ?? {};
|
|
64
|
-
const relations = raw["_rel"]
|
|
114
|
+
const relations = parseRelations(raw["_rel"]);
|
|
65
115
|
const routes = Array.isArray(raw["_routes"]) ? raw["_routes"] : [];
|
|
66
116
|
const data = Object.fromEntries(
|
|
67
117
|
Object.entries(raw).filter(([key]) => key !== "_rel" && key !== "_routes")
|
|
@@ -98,7 +148,7 @@ function createYrestStorage(filePath) {
|
|
|
98
148
|
},
|
|
99
149
|
reload() {
|
|
100
150
|
const fresh = (0, import_yaml2.parse)((0, import_node_fs3.readFileSync)(absPath, "utf8")) ?? {};
|
|
101
|
-
const freshRelations = fresh["_rel"]
|
|
151
|
+
const freshRelations = parseRelations(fresh["_rel"]);
|
|
102
152
|
const freshData = Object.fromEntries(
|
|
103
153
|
Object.entries(fresh).filter(([key]) => key !== "_rel" && key !== "_routes")
|
|
104
154
|
);
|
|
@@ -136,6 +186,7 @@ var init_yrestStorage = __esm({
|
|
|
136
186
|
import_node_path2 = require("path");
|
|
137
187
|
import_node_crypto2 = require("crypto");
|
|
138
188
|
import_yaml2 = require("yaml");
|
|
189
|
+
init_parseRelations();
|
|
139
190
|
init_deepCopy();
|
|
140
191
|
}
|
|
141
192
|
});
|
|
@@ -198,6 +249,7 @@ function dedent(str) {
|
|
|
198
249
|
// src/api/yrestServer.ts
|
|
199
250
|
init_cjs_shims();
|
|
200
251
|
var import_node_path3 = require("path");
|
|
252
|
+
init_parseRelations();
|
|
201
253
|
|
|
202
254
|
// src/utils/handlers.ts
|
|
203
255
|
init_cjs_shims();
|
|
@@ -251,6 +303,9 @@ var import_node_fs2 = require("fs");
|
|
|
251
303
|
var import_node_path = require("path");
|
|
252
304
|
var import_node_url = require("url");
|
|
253
305
|
|
|
306
|
+
// src/router/templates/about.helpers.ts
|
|
307
|
+
init_cjs_shims();
|
|
308
|
+
|
|
254
309
|
// src/utils/interpolate.ts
|
|
255
310
|
init_cjs_shims();
|
|
256
311
|
var import_node_crypto = require("crypto");
|
|
@@ -298,16 +353,7 @@ function hasTemplates(value) {
|
|
|
298
353
|
return typeof value === "string" ? value.includes("{{") : JSON.stringify(value).includes("{{");
|
|
299
354
|
}
|
|
300
355
|
|
|
301
|
-
// src/router/templates/about.
|
|
302
|
-
var _dir = (0, import_node_path.dirname)((0, import_node_url.fileURLToPath)(importMetaUrl));
|
|
303
|
-
var LOGO_SRC = (() => {
|
|
304
|
-
try {
|
|
305
|
-
const buf = (0, import_node_fs2.readFileSync)((0, import_node_path.join)(_dir, "../../assets/logo-color.png"));
|
|
306
|
-
return `data:image/png;base64,${buf.toString("base64")}`;
|
|
307
|
-
} catch {
|
|
308
|
-
return "";
|
|
309
|
-
}
|
|
310
|
-
})();
|
|
356
|
+
// src/router/templates/about.helpers.ts
|
|
311
357
|
var METHOD_COLOR = {
|
|
312
358
|
GET: "#3fb950",
|
|
313
359
|
POST: "#58a6ff",
|
|
@@ -372,6 +418,139 @@ function resourceAccordion(name, base, isOpen) {
|
|
|
372
418
|
</table>
|
|
373
419
|
</details>`;
|
|
374
420
|
}
|
|
421
|
+
function nestedRoutesAccordion(relations, base) {
|
|
422
|
+
const rows = [];
|
|
423
|
+
for (const [source, fields] of Object.entries(relations)) {
|
|
424
|
+
for (const [key, def] of Object.entries(fields)) {
|
|
425
|
+
const nestedBadge = def.nested ? ` ${badge("nested", "#facc15", "#facc1518")}` : "";
|
|
426
|
+
if (def.type === "many2many") {
|
|
427
|
+
const singular = source.endsWith("s") ? source.slice(0, -1) : source;
|
|
428
|
+
const m2mBadge = badge("many2many", "#818cf8", "#818cf818");
|
|
429
|
+
rows.push(
|
|
430
|
+
endpointRow(
|
|
431
|
+
"GET",
|
|
432
|
+
`${base}/${source}/:id/${key}`,
|
|
433
|
+
`List ${def.target} linked to a ${singular} via ${def.through}. ${m2mBadge}${nestedBadge}`
|
|
434
|
+
)
|
|
435
|
+
);
|
|
436
|
+
const targetSingular = def.target.endsWith("s") ? def.target.slice(0, -1) : def.target;
|
|
437
|
+
rows.push(
|
|
438
|
+
endpointRow(
|
|
439
|
+
"GET",
|
|
440
|
+
`${base}/${def.target}/:id/${source}`,
|
|
441
|
+
`List ${source} linked to a ${targetSingular} via ${def.through} (inverse). ${m2mBadge}`
|
|
442
|
+
)
|
|
443
|
+
);
|
|
444
|
+
} else {
|
|
445
|
+
const path = `${base}/${def.target}/:id/${source}`;
|
|
446
|
+
const parentSingular = def.target.endsWith("s") ? def.target.slice(0, -1) : def.target;
|
|
447
|
+
const typeBadge = def.type === "one2one" ? ` ${badge("one2one", "#34d399", "#34d39918")}` : "";
|
|
448
|
+
rows.push(
|
|
449
|
+
endpointRow(
|
|
450
|
+
"GET",
|
|
451
|
+
path,
|
|
452
|
+
`${def.type === "one2one" ? "Get" : "List"} ${source} belonging to a ${parentSingular}.${typeBadge}${nestedBadge}`
|
|
453
|
+
)
|
|
454
|
+
);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
if (!rows.length) return "";
|
|
459
|
+
return `
|
|
460
|
+
<details class="resource-card nested-card">
|
|
461
|
+
<summary>
|
|
462
|
+
<span class="resource-name">Nested routes</span>
|
|
463
|
+
<span class="route-count">${rows.length} route${rows.length !== 1 ? "s" : ""}</span>
|
|
464
|
+
</summary>
|
|
465
|
+
<table><tbody>${rows.join("")}</tbody></table>
|
|
466
|
+
</details>`;
|
|
467
|
+
}
|
|
468
|
+
function snapshotAccordion() {
|
|
469
|
+
return `
|
|
470
|
+
<details class="resource-card nested-card">
|
|
471
|
+
<summary>
|
|
472
|
+
<span class="resource-name">/_snapshot</span>
|
|
473
|
+
<span class="route-count">3 routes</span>
|
|
474
|
+
</summary>
|
|
475
|
+
<table><tbody>
|
|
476
|
+
${endpointRow("GET", "/_snapshot", "Returns metadata of the current snapshot: <code>savedAt</code> and item counts per collection.")}
|
|
477
|
+
${endpointRow("POST", "/_snapshot/save", "Replaces the stored snapshot with the current database state.")}
|
|
478
|
+
${endpointRow("POST", "/_snapshot/reset", "Restores the database to the last saved snapshot and persists to disk.")}
|
|
479
|
+
</tbody></table>
|
|
480
|
+
</details>`;
|
|
481
|
+
}
|
|
482
|
+
function customRoutesAccordion(routes, base, handlers) {
|
|
483
|
+
if (!routes.length) return "";
|
|
484
|
+
const rows = routes.map((r) => {
|
|
485
|
+
const fullPath = `${base}${r.path}`;
|
|
486
|
+
const tags = [];
|
|
487
|
+
if (r.error) tags.push(`<span style="color:#f85149;font-size:11px">error\xB7${r.error}</span>`);
|
|
488
|
+
if (r.delay && r.delay > 0)
|
|
489
|
+
tags.push(`<span style="color:#fb923c;font-size:11px">delay\xB7${r.delay}ms</span>`);
|
|
490
|
+
if (r.scenarios?.length) {
|
|
491
|
+
const hasOr = r.scenarios.some((s) => Array.isArray(s.when));
|
|
492
|
+
tags.push(
|
|
493
|
+
`<span style="color:#a371f7;font-size:11px">scenarios\xB7${r.scenarios.length}${hasOr ? " (OR)" : ""}</span>`
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
if (r.otherwise)
|
|
497
|
+
tags.push(`<span style="color:var(--text-muted);font-size:11px">otherwise</span>`);
|
|
498
|
+
let desc;
|
|
499
|
+
if (r.error) {
|
|
500
|
+
desc = `Error injection \u2014 <code>${r.error}</code>`;
|
|
501
|
+
} else if (r.handler) {
|
|
502
|
+
const found = handlers.has(r.handler);
|
|
503
|
+
const handlerName = escapeHtml(r.handler);
|
|
504
|
+
desc = found ? `Handler \u2014 <code>${handlerName}()</code>` : `Handler \u2014 <code>${handlerName}()</code> <span style="color:#f85149">(not loaded)</span>`;
|
|
505
|
+
} else if (r.scenarios?.length) {
|
|
506
|
+
const hasTemplateInScenarios = r.scenarios.some((s) => s.response.body != null && hasTemplates(s.response.body)) || r.otherwise?.body != null && hasTemplates(r.otherwise.body);
|
|
507
|
+
desc = hasTemplateInScenarios ? `Scenarios \u2014 <code>{{\u2026}}</code>` : `Scenarios`;
|
|
508
|
+
} else if (r.response?.body != null && hasTemplates(r.response.body)) {
|
|
509
|
+
desc = `Dynamic \u2014 <code>{{\u2026}}</code>`;
|
|
510
|
+
} else {
|
|
511
|
+
const status = r.response?.status ?? 200;
|
|
512
|
+
desc = `Static \u2014 <code>${status}</code>${r.response?.headers ? ` + headers` : ""}`;
|
|
513
|
+
}
|
|
514
|
+
if (tags.length) desc += ` ${tags.join(" ")}`;
|
|
515
|
+
return endpointRow(r.method?.toUpperCase() ?? "GET", fullPath, desc);
|
|
516
|
+
});
|
|
517
|
+
return `
|
|
518
|
+
<details class="resource-card nested-card">
|
|
519
|
+
<summary>
|
|
520
|
+
<span class="resource-name">Custom routes</span>
|
|
521
|
+
<span class="route-count">${routes.length} route${routes.length !== 1 ? "s" : ""}</span>
|
|
522
|
+
</summary>
|
|
523
|
+
<table><tbody>
|
|
524
|
+
${rows.join("")}
|
|
525
|
+
</tbody></table>
|
|
526
|
+
</details>`;
|
|
527
|
+
}
|
|
528
|
+
function handlersAccordion(handlers, routes, base) {
|
|
529
|
+
if (!handlers.size) return "";
|
|
530
|
+
const routesByHandler = /* @__PURE__ */ new Map();
|
|
531
|
+
for (const r of routes) {
|
|
532
|
+
if (r.handler) {
|
|
533
|
+
const list = routesByHandler.get(r.handler) ?? [];
|
|
534
|
+
list.push({ method: (r.method ?? "GET").toUpperCase(), path: `${base}${r.path}` });
|
|
535
|
+
routesByHandler.set(r.handler, list);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
const rows = [...handlers.keys()].map((name) => {
|
|
539
|
+
const linked = routesByHandler.get(name);
|
|
540
|
+
const routeDesc = linked ? linked.map((r) => `<code>${r.method} ${r.path}</code>`).join(", ") : `<span style="color:var(--text-muted)">not referenced in _routes</span>`;
|
|
541
|
+
return endpointRow("fn", name + "()", routeDesc);
|
|
542
|
+
});
|
|
543
|
+
return `
|
|
544
|
+
<details class="resource-card nested-card">
|
|
545
|
+
<summary>
|
|
546
|
+
<span class="resource-name">Handlers</span>
|
|
547
|
+
<span class="route-count">${handlers.size} function${handlers.size !== 1 ? "s" : ""}</span>
|
|
548
|
+
</summary>
|
|
549
|
+
<table><tbody>
|
|
550
|
+
${rows.join("")}
|
|
551
|
+
</tbody></table>
|
|
552
|
+
</details>`;
|
|
553
|
+
}
|
|
375
554
|
function examplesBlock(collections, relations, base, host, options, firstCustomRoute) {
|
|
376
555
|
const examples = [];
|
|
377
556
|
const firstCol = collections[0];
|
|
@@ -402,35 +581,36 @@ curl -X DELETE ${p}/1`
|
|
|
402
581
|
const firstRel = Object.entries(relations)[0];
|
|
403
582
|
if (firstRel) {
|
|
404
583
|
const [child, fields] = firstRel;
|
|
405
|
-
const
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
curl ${host}${base}/${
|
|
584
|
+
const firstField = Object.entries(fields)[0];
|
|
585
|
+
if (firstField) {
|
|
586
|
+
const [fk, def] = firstField;
|
|
587
|
+
if (def.type !== "many2many") {
|
|
588
|
+
const expandKey = fk.replace(/Id$/i, "");
|
|
589
|
+
examples.push(
|
|
590
|
+
`# Embed parent with ?_expand
|
|
591
|
+
curl "${host}${base}/${child}/1?_expand=${expandKey}"`,
|
|
592
|
+
`# Nested resource
|
|
593
|
+
curl ${host}${base}/${def.target}/1/${child}`
|
|
594
|
+
);
|
|
595
|
+
} else {
|
|
596
|
+
examples.push(`# Many-to-many embed
|
|
597
|
+
curl "${host}${base}/${child}/1/${fk}"`);
|
|
598
|
+
}
|
|
419
599
|
}
|
|
420
600
|
}
|
|
421
601
|
if (options.pageable.enabled && firstCol) {
|
|
422
602
|
examples.push(`# Pageable envelope
|
|
423
603
|
curl "${host}${base}/${firstCol}?_page=2"`);
|
|
424
604
|
}
|
|
425
|
-
const firstParentRel = Object.entries(relations).find(
|
|
426
|
-
([, fields]) => Object.values(fields).includes(firstCol ?? "")
|
|
427
|
-
);
|
|
428
605
|
if (firstCol) {
|
|
429
606
|
examples.push(
|
|
430
607
|
`# Project fields with ?_fields
|
|
431
608
|
curl "${host}${base}/${firstCol}?_fields=id,name"`
|
|
432
609
|
);
|
|
433
610
|
}
|
|
611
|
+
const firstParentRel = Object.entries(relations).find(
|
|
612
|
+
([, fields]) => Object.values(fields).some((def) => def.type !== "many2many" && def.target === firstCol)
|
|
613
|
+
);
|
|
434
614
|
if (firstParentRel && firstCol) {
|
|
435
615
|
const [childName] = firstParentRel;
|
|
436
616
|
examples.push(
|
|
@@ -456,9 +636,21 @@ curl ${curlFlag}${fullPath}`);
|
|
|
456
636
|
const highlighted = examples.map((e) => e.replace(/^(#.+)$/gm, '<span class="cm">$1</span>')).join("\n\n");
|
|
457
637
|
return `<pre>${highlighted}</pre>`;
|
|
458
638
|
}
|
|
639
|
+
|
|
640
|
+
// src/router/templates/about.template.ts
|
|
641
|
+
var _dir = (0, import_node_path.dirname)((0, import_node_url.fileURLToPath)(importMetaUrl));
|
|
642
|
+
var LOGO_SRC = (() => {
|
|
643
|
+
try {
|
|
644
|
+
const buf = (0, import_node_fs2.readFileSync)((0, import_node_path.join)(_dir, "../../assets/logo-color.png"));
|
|
645
|
+
return `data:image/png;base64,${buf.toString("base64")}`;
|
|
646
|
+
} catch {
|
|
647
|
+
return "";
|
|
648
|
+
}
|
|
649
|
+
})();
|
|
459
650
|
function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map()) {
|
|
460
651
|
const collections = Object.keys(storage.getData());
|
|
461
652
|
const relations = storage.getRelations();
|
|
653
|
+
const customRoutes = storage.getRoutes();
|
|
462
654
|
const base = options.base;
|
|
463
655
|
const host = `http://${options.host}:${options.port}`;
|
|
464
656
|
const modes = [];
|
|
@@ -472,105 +664,6 @@ function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map(
|
|
|
472
664
|
if (options.idStrategy !== "increment")
|
|
473
665
|
modes.push(badge(`id \xB7 ${options.idStrategy}`, "#a371f7", "#a371f718"));
|
|
474
666
|
const accordions = collections.map((col, i) => resourceAccordion(col, base, i === 0)).join("");
|
|
475
|
-
const nestedRows = [];
|
|
476
|
-
for (const [child, fields] of Object.entries(relations)) {
|
|
477
|
-
for (const [, parent] of Object.entries(fields)) {
|
|
478
|
-
const nestedPath = `${base}/${parent}/:id/${child}`;
|
|
479
|
-
const parentSingular = parent.endsWith("s") ? parent.slice(0, -1) : parent;
|
|
480
|
-
nestedRows.push(
|
|
481
|
-
endpointRow("GET", nestedPath, `List ${child} belonging to a ${parentSingular}.`)
|
|
482
|
-
);
|
|
483
|
-
}
|
|
484
|
-
}
|
|
485
|
-
const nestedAccordion = nestedRows.length ? `
|
|
486
|
-
<details class="resource-card nested-card">
|
|
487
|
-
<summary>
|
|
488
|
-
<span class="resource-name">Nested routes</span>
|
|
489
|
-
<span class="route-count">${nestedRows.length} route${nestedRows.length !== 1 ? "s" : ""}</span>
|
|
490
|
-
</summary>
|
|
491
|
-
<table><tbody>${nestedRows.join("")}</tbody></table>
|
|
492
|
-
</details>` : "";
|
|
493
|
-
const snapshotAccordion = options.snapshot ? `
|
|
494
|
-
<details class="resource-card nested-card">
|
|
495
|
-
<summary>
|
|
496
|
-
<span class="resource-name">/_snapshot</span>
|
|
497
|
-
<span class="route-count">3 routes</span>
|
|
498
|
-
</summary>
|
|
499
|
-
<table><tbody>
|
|
500
|
-
${endpointRow("GET", "/_snapshot", "Returns metadata of the current snapshot: <code>savedAt</code> and item counts per collection.")}
|
|
501
|
-
${endpointRow("POST", "/_snapshot/save", "Replaces the stored snapshot with the current database state.")}
|
|
502
|
-
${endpointRow("POST", "/_snapshot/reset", "Restores the database to the last saved snapshot and persists to disk.")}
|
|
503
|
-
</tbody></table>
|
|
504
|
-
</details>` : "";
|
|
505
|
-
const customRoutes = storage.getRoutes();
|
|
506
|
-
const customRoutesAccordion = customRoutes.length ? `
|
|
507
|
-
<details class="resource-card nested-card">
|
|
508
|
-
<summary>
|
|
509
|
-
<span class="resource-name">Custom routes</span>
|
|
510
|
-
<span class="route-count">${customRoutes.length} route${customRoutes.length !== 1 ? "s" : ""}</span>
|
|
511
|
-
</summary>
|
|
512
|
-
<table><tbody>
|
|
513
|
-
${customRoutes.map((r) => {
|
|
514
|
-
const fullPath = `${base}${r.path}`;
|
|
515
|
-
const tags = [];
|
|
516
|
-
if (r.error) {
|
|
517
|
-
tags.push(`<span style="color:#f85149;font-size:11px">error\xB7${r.error}</span>`);
|
|
518
|
-
}
|
|
519
|
-
if (r.delay && r.delay > 0) {
|
|
520
|
-
tags.push(`<span style="color:#fb923c;font-size:11px">delay\xB7${r.delay}ms</span>`);
|
|
521
|
-
}
|
|
522
|
-
if (r.scenarios?.length) {
|
|
523
|
-
const hasOr = r.scenarios.some((s) => Array.isArray(s.when));
|
|
524
|
-
tags.push(
|
|
525
|
-
`<span style="color:#a371f7;font-size:11px">scenarios\xB7${r.scenarios.length}${hasOr ? " (OR)" : ""}</span>`
|
|
526
|
-
);
|
|
527
|
-
}
|
|
528
|
-
if (r.otherwise) {
|
|
529
|
-
tags.push(`<span style="color:var(--text-muted);font-size:11px">otherwise</span>`);
|
|
530
|
-
}
|
|
531
|
-
let desc;
|
|
532
|
-
if (r.error) {
|
|
533
|
-
desc = `Error injection \u2014 <code>${r.error}</code>`;
|
|
534
|
-
} else if (r.handler) {
|
|
535
|
-
const found = handlers.has(r.handler);
|
|
536
|
-
const handlerName = escapeHtml(r.handler);
|
|
537
|
-
desc = found ? `Handler \u2014 <code>${handlerName}()</code>` : `Handler \u2014 <code>${handlerName}()</code> <span style="color:#f85149">(not loaded)</span>`;
|
|
538
|
-
} else if (r.scenarios?.length) {
|
|
539
|
-
const hasTemplateInScenarios = r.scenarios.some((s) => s.response.body != null && hasTemplates(s.response.body)) || r.otherwise?.body != null && hasTemplates(r.otherwise.body);
|
|
540
|
-
desc = hasTemplateInScenarios ? `Scenarios \u2014 <code>{{\u2026}}</code>` : `Scenarios`;
|
|
541
|
-
} else if (r.response?.body != null && hasTemplates(r.response.body)) {
|
|
542
|
-
desc = `Dynamic \u2014 <code>{{\u2026}}</code>`;
|
|
543
|
-
} else {
|
|
544
|
-
const status = r.response?.status ?? 200;
|
|
545
|
-
desc = `Static \u2014 <code>${status}</code>${r.response?.headers ? ` + headers` : ""}`;
|
|
546
|
-
}
|
|
547
|
-
if (tags.length) desc += ` ${tags.join(" ")}`;
|
|
548
|
-
return endpointRow(r.method?.toUpperCase() ?? "GET", fullPath, desc);
|
|
549
|
-
}).join("")}
|
|
550
|
-
</tbody></table>
|
|
551
|
-
</details>` : "";
|
|
552
|
-
const routesByHandler = /* @__PURE__ */ new Map();
|
|
553
|
-
for (const r of customRoutes) {
|
|
554
|
-
if (r.handler) {
|
|
555
|
-
const list = routesByHandler.get(r.handler) ?? [];
|
|
556
|
-
list.push({ method: (r.method ?? "GET").toUpperCase(), path: `${base}${r.path}` });
|
|
557
|
-
routesByHandler.set(r.handler, list);
|
|
558
|
-
}
|
|
559
|
-
}
|
|
560
|
-
const handlersAccordion = handlers.size > 0 ? `
|
|
561
|
-
<details class="resource-card nested-card">
|
|
562
|
-
<summary>
|
|
563
|
-
<span class="resource-name">Handlers</span>
|
|
564
|
-
<span class="route-count">${handlers.size} function${handlers.size !== 1 ? "s" : ""}</span>
|
|
565
|
-
</summary>
|
|
566
|
-
<table><tbody>
|
|
567
|
-
${[...handlers.keys()].map((name) => {
|
|
568
|
-
const routes = routesByHandler.get(name);
|
|
569
|
-
const routeDesc = routes ? routes.map((r) => `<code>${r.method} ${r.path}</code>`).join(", ") : `<span style="color:var(--text-muted)">not referenced in _routes</span>`;
|
|
570
|
-
return endpointRow("fn", name + "()", routeDesc);
|
|
571
|
-
}).join("")}
|
|
572
|
-
</tbody></table>
|
|
573
|
-
</details>` : "";
|
|
574
667
|
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.`;
|
|
575
668
|
return `<!DOCTYPE html>
|
|
576
669
|
<html lang="en">
|
|
@@ -718,10 +811,10 @@ function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map(
|
|
|
718
811
|
<h2>Endpoints</h2>
|
|
719
812
|
<div class="endpoints-grid">
|
|
720
813
|
${accordions}
|
|
721
|
-
${
|
|
722
|
-
${snapshotAccordion}
|
|
723
|
-
${customRoutesAccordion}
|
|
724
|
-
${handlersAccordion}
|
|
814
|
+
${nestedRoutesAccordion(relations, base)}
|
|
815
|
+
${options.snapshot ? snapshotAccordion() : ""}
|
|
816
|
+
${customRoutesAccordion(customRoutes, base, handlers)}
|
|
817
|
+
${handlersAccordion(handlers, customRoutes, base)}
|
|
725
818
|
</div>
|
|
726
819
|
|
|
727
820
|
<h2>Query Parameters</h2>
|
|
@@ -928,10 +1021,11 @@ function expandItems(input, query, resource, storage) {
|
|
|
928
1021
|
const resourceRelations = storage.getRelations()[resource] ?? {};
|
|
929
1022
|
const expansions = /* @__PURE__ */ new Map();
|
|
930
1023
|
for (const expandKey of keys) {
|
|
931
|
-
for (const [field,
|
|
1024
|
+
for (const [field, def] of Object.entries(resourceRelations)) {
|
|
1025
|
+
if (def.type === "many2many") continue;
|
|
932
1026
|
const derivedKey = field.replace(/Id$/i, "");
|
|
933
|
-
if (derivedKey === expandKey ||
|
|
934
|
-
expansions.set(expandKey, { field, parentCollection });
|
|
1027
|
+
if (derivedKey === expandKey || def.target === expandKey || def.target === `${expandKey}s`) {
|
|
1028
|
+
expansions.set(expandKey, { field, parentCollection: def.target });
|
|
935
1029
|
break;
|
|
936
1030
|
}
|
|
937
1031
|
}
|
|
@@ -960,10 +1054,24 @@ function embedItems(input, query, resource, storage) {
|
|
|
960
1054
|
const relations = storage.getRelations();
|
|
961
1055
|
const embeds = /* @__PURE__ */ new Map();
|
|
962
1056
|
for (const embedKey of keys) {
|
|
1057
|
+
const ownRelations = relations[resource] ?? {};
|
|
1058
|
+
if (embedKey in ownRelations) {
|
|
1059
|
+
const def = ownRelations[embedKey];
|
|
1060
|
+
if (def.type === "many2many") {
|
|
1061
|
+
embeds.set(embedKey, {
|
|
1062
|
+
kind: "many2many",
|
|
1063
|
+
target: def.target,
|
|
1064
|
+
through: def.through,
|
|
1065
|
+
foreignKey: def.foreignKey,
|
|
1066
|
+
otherKey: def.otherKey
|
|
1067
|
+
});
|
|
1068
|
+
continue;
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
963
1071
|
outer: for (const [childCollection, fields] of Object.entries(relations)) {
|
|
964
|
-
for (const [fkField,
|
|
965
|
-
if (
|
|
966
|
-
embeds.set(embedKey, { childCollection, fkField });
|
|
1072
|
+
for (const [fkField, def] of Object.entries(fields)) {
|
|
1073
|
+
if ((def.type === "many2one" || def.type === "one2one") && def.target === resource && childCollection === embedKey) {
|
|
1074
|
+
embeds.set(embedKey, { kind: def.type, childCollection, fkField });
|
|
967
1075
|
break outer;
|
|
968
1076
|
}
|
|
969
1077
|
}
|
|
@@ -972,10 +1080,57 @@ function embedItems(input, query, resource, storage) {
|
|
|
972
1080
|
if (embeds.size === 0) return isArray ? items : input;
|
|
973
1081
|
const result = items.map((item) => {
|
|
974
1082
|
const out = { ...item };
|
|
975
|
-
for (const [embedKey,
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
1083
|
+
for (const [embedKey, spec] of embeds) {
|
|
1084
|
+
if (spec.kind === "many2many") {
|
|
1085
|
+
const pivot = storage.getCollection(spec.through) ?? [];
|
|
1086
|
+
const matchingIds = new Set(
|
|
1087
|
+
pivot.filter((row) => String(row[spec.foreignKey]) === String(item["id"])).map((row) => String(row[spec.otherKey]))
|
|
1088
|
+
);
|
|
1089
|
+
out[embedKey] = (storage.getCollection(spec.target) ?? []).filter(
|
|
1090
|
+
(t) => matchingIds.has(String(t["id"]))
|
|
1091
|
+
);
|
|
1092
|
+
} else if (spec.kind === "one2one") {
|
|
1093
|
+
out[embedKey] = (storage.getCollection(spec.childCollection) ?? []).find(
|
|
1094
|
+
(child) => String(child[spec.fkField]) === String(item["id"])
|
|
1095
|
+
) ?? null;
|
|
1096
|
+
} else {
|
|
1097
|
+
out[embedKey] = (storage.getCollection(spec.childCollection) ?? []).filter(
|
|
1098
|
+
(child) => String(child[spec.fkField]) === String(item["id"])
|
|
1099
|
+
);
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
return out;
|
|
1103
|
+
});
|
|
1104
|
+
return isArray ? result : result[0];
|
|
1105
|
+
}
|
|
1106
|
+
function applyNested(input, resource, storage) {
|
|
1107
|
+
const isArray = Array.isArray(input);
|
|
1108
|
+
const items = isArray ? input : [input];
|
|
1109
|
+
const resourceRelations = storage.getRelations()[resource] ?? {};
|
|
1110
|
+
const nestedDefs = Object.entries(resourceRelations).filter(([, def]) => def.nested === true);
|
|
1111
|
+
if (nestedDefs.length === 0) return input;
|
|
1112
|
+
const result = items.map((item) => {
|
|
1113
|
+
const out = { ...item };
|
|
1114
|
+
for (const [key, def] of nestedDefs) {
|
|
1115
|
+
if (def.type === "many2many") {
|
|
1116
|
+
const pivot = storage.getCollection(def.through) ?? [];
|
|
1117
|
+
const matchingIds = new Set(
|
|
1118
|
+
pivot.filter((row) => String(row[def.foreignKey]) === String(item["id"])).map((row) => String(row[def.otherKey]))
|
|
1119
|
+
);
|
|
1120
|
+
out[key] = (storage.getCollection(def.target) ?? []).filter(
|
|
1121
|
+
(t) => matchingIds.has(String(t["id"]))
|
|
1122
|
+
);
|
|
1123
|
+
} else {
|
|
1124
|
+
const foreignKeyValue = item[key];
|
|
1125
|
+
if (foreignKeyValue === void 0) continue;
|
|
1126
|
+
const parent = (storage.getCollection(def.target) ?? []).find(
|
|
1127
|
+
(p) => String(p["id"]) === String(foreignKeyValue)
|
|
1128
|
+
);
|
|
1129
|
+
if (parent !== void 0) {
|
|
1130
|
+
const embedKey = key.replace(/Id$/i, "");
|
|
1131
|
+
out[embedKey] = parent;
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
979
1134
|
}
|
|
980
1135
|
return out;
|
|
981
1136
|
});
|
|
@@ -1015,7 +1170,12 @@ var CollectionRouteCommand = class {
|
|
|
1015
1170
|
const totalPages = Math.ceil(totalItems / limit) || 1;
|
|
1016
1171
|
const data = projectFields(
|
|
1017
1172
|
embedItems(
|
|
1018
|
-
expandItems(
|
|
1173
|
+
expandItems(
|
|
1174
|
+
applyNested(paginate(sorted, page, limit), this.resource, this.storage),
|
|
1175
|
+
req.query,
|
|
1176
|
+
this.resource,
|
|
1177
|
+
this.storage
|
|
1178
|
+
),
|
|
1019
1179
|
req.query,
|
|
1020
1180
|
this.resource,
|
|
1021
1181
|
this.storage
|
|
@@ -1047,7 +1207,12 @@ var CollectionRouteCommand = class {
|
|
|
1047
1207
|
}
|
|
1048
1208
|
return projectFields(
|
|
1049
1209
|
embedItems(
|
|
1050
|
-
expandItems(
|
|
1210
|
+
expandItems(
|
|
1211
|
+
applyNested(result, this.resource, this.storage),
|
|
1212
|
+
req.query,
|
|
1213
|
+
this.resource,
|
|
1214
|
+
this.storage
|
|
1215
|
+
),
|
|
1051
1216
|
req.query,
|
|
1052
1217
|
this.resource,
|
|
1053
1218
|
this.storage
|
|
@@ -1233,7 +1398,12 @@ var ItemRouteCommand = class {
|
|
|
1233
1398
|
const fields = (req.query["_fields"] ?? "").split(",").map((f) => f.trim()).filter(Boolean);
|
|
1234
1399
|
return projectFields(
|
|
1235
1400
|
embedItems(
|
|
1236
|
-
expandItems(
|
|
1401
|
+
expandItems(
|
|
1402
|
+
applyNested(item, this.resource, this.storage),
|
|
1403
|
+
req.query,
|
|
1404
|
+
this.resource,
|
|
1405
|
+
this.storage
|
|
1406
|
+
),
|
|
1237
1407
|
req.query,
|
|
1238
1408
|
this.resource,
|
|
1239
1409
|
this.storage
|
|
@@ -1277,32 +1447,68 @@ var NestedRouteCommand = class {
|
|
|
1277
1447
|
relations;
|
|
1278
1448
|
base;
|
|
1279
1449
|
register(server) {
|
|
1280
|
-
for (const [
|
|
1281
|
-
for (const [
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
if (!parentItem) return reply.status(404).send({ error: "Not found" });
|
|
1288
|
-
const children = (this.storage.getCollection(child) ?? []).filter(
|
|
1289
|
-
(item) => String(item[field]) === req.params.id
|
|
1290
|
-
);
|
|
1291
|
-
return children;
|
|
1292
|
-
});
|
|
1293
|
-
server.get(itemPath, (req, reply) => {
|
|
1294
|
-
const parentCollection = this.storage.getCollection(parent) ?? [];
|
|
1295
|
-
const parentItem = findById(parentCollection, req.params.id);
|
|
1296
|
-
if (!parentItem) return reply.status(404).send({ error: "Not found" });
|
|
1297
|
-
const childItem = (this.storage.getCollection(child) ?? []).find(
|
|
1298
|
-
(item) => String(item[field]) === req.params.id && String(item["id"]) === req.params.childId
|
|
1299
|
-
);
|
|
1300
|
-
if (!childItem) return reply.status(404).send({ error: "Not found" });
|
|
1301
|
-
return childItem;
|
|
1302
|
-
});
|
|
1450
|
+
for (const [source, fields] of Object.entries(this.relations)) {
|
|
1451
|
+
for (const [key, def] of Object.entries(fields)) {
|
|
1452
|
+
if (def.type === "many2many") {
|
|
1453
|
+
this.registerMany2Many(server, source, key, def);
|
|
1454
|
+
} else {
|
|
1455
|
+
this.registerFkRelation(server, source, key, def.target, def.type);
|
|
1456
|
+
}
|
|
1303
1457
|
}
|
|
1304
1458
|
}
|
|
1305
1459
|
}
|
|
1460
|
+
registerFkRelation(server, child, fkField, parent, type) {
|
|
1461
|
+
const collectionPath = `${this.base}/${parent}/:id/${child}`;
|
|
1462
|
+
const itemPath = `${this.base}/${parent}/:id/${child}/:childId`;
|
|
1463
|
+
server.get(collectionPath, (req, reply) => {
|
|
1464
|
+
const parentCollection = this.storage.getCollection(parent) ?? [];
|
|
1465
|
+
const parentItem = findById(parentCollection, req.params.id);
|
|
1466
|
+
if (!parentItem) return reply.status(404).send({ error: "Not found" });
|
|
1467
|
+
const all = (this.storage.getCollection(child) ?? []).filter(
|
|
1468
|
+
(item) => String(item[fkField]) === req.params.id
|
|
1469
|
+
);
|
|
1470
|
+
if (type === "one2one") return all[0] ?? reply.status(404).send({ error: "Not found" });
|
|
1471
|
+
return all;
|
|
1472
|
+
});
|
|
1473
|
+
if (type === "many2one") {
|
|
1474
|
+
server.get(itemPath, (req, reply) => {
|
|
1475
|
+
const parentCollection = this.storage.getCollection(parent) ?? [];
|
|
1476
|
+
const parentItem = findById(parentCollection, req.params.id);
|
|
1477
|
+
if (!parentItem) return reply.status(404).send({ error: "Not found" });
|
|
1478
|
+
const childItem = (this.storage.getCollection(child) ?? []).find(
|
|
1479
|
+
(item) => String(item[fkField]) === req.params.id && String(item["id"]) === req.params.childId
|
|
1480
|
+
);
|
|
1481
|
+
if (!childItem) return reply.status(404).send({ error: "Not found" });
|
|
1482
|
+
return childItem;
|
|
1483
|
+
});
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
registerMany2Many(server, source, alias, def) {
|
|
1487
|
+
server.get(`${this.base}/${source}/:id/${alias}`, (req, reply) => {
|
|
1488
|
+
const sourceCollection = this.storage.getCollection(source) ?? [];
|
|
1489
|
+
const sourceItem = findById(sourceCollection, req.params.id);
|
|
1490
|
+
if (!sourceItem) return reply.status(404).send({ error: "Not found" });
|
|
1491
|
+
const pivot = this.storage.getCollection(def.through) ?? [];
|
|
1492
|
+
const matchingIds = new Set(
|
|
1493
|
+
pivot.filter((row) => String(row[def.foreignKey]) === req.params.id).map((row) => String(row[def.otherKey]))
|
|
1494
|
+
);
|
|
1495
|
+
return (this.storage.getCollection(def.target) ?? []).filter(
|
|
1496
|
+
(t) => matchingIds.has(String(t["id"]))
|
|
1497
|
+
);
|
|
1498
|
+
});
|
|
1499
|
+
server.get(`${this.base}/${def.target}/:id/${source}`, (req, reply) => {
|
|
1500
|
+
const targetCollection = this.storage.getCollection(def.target) ?? [];
|
|
1501
|
+
const targetItem = findById(targetCollection, req.params.id);
|
|
1502
|
+
if (!targetItem) return reply.status(404).send({ error: "Not found" });
|
|
1503
|
+
const pivot = this.storage.getCollection(def.through) ?? [];
|
|
1504
|
+
const matchingIds = new Set(
|
|
1505
|
+
pivot.filter((row) => String(row[def.otherKey]) === req.params.id).map((row) => String(row[def.foreignKey]))
|
|
1506
|
+
);
|
|
1507
|
+
return (this.storage.getCollection(source) ?? []).filter(
|
|
1508
|
+
(t) => matchingIds.has(String(t["id"]))
|
|
1509
|
+
);
|
|
1510
|
+
});
|
|
1511
|
+
}
|
|
1306
1512
|
};
|
|
1307
1513
|
|
|
1308
1514
|
// src/router/routes/snapshot.routes.ts
|
|
@@ -1465,7 +1671,7 @@ function buildOptions(opts) {
|
|
|
1465
1671
|
}
|
|
1466
1672
|
function createInMemoryStorage(data) {
|
|
1467
1673
|
const raw = data;
|
|
1468
|
-
const relations = raw["_rel"]
|
|
1674
|
+
const relations = parseRelations(raw["_rel"]);
|
|
1469
1675
|
const routes = Array.isArray(raw["_routes"]) ? raw["_routes"] : [];
|
|
1470
1676
|
const collections = Object.fromEntries(
|
|
1471
1677
|
Object.entries(raw).filter(([k]) => k !== "_rel" && k !== "_routes")
|