@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.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,6 +214,7 @@ 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
|
|
@@ -217,6 +269,9 @@ import { readFileSync } from "fs";
|
|
|
217
269
|
import { dirname, join } from "path";
|
|
218
270
|
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
219
271
|
|
|
272
|
+
// src/router/templates/about.helpers.ts
|
|
273
|
+
init_esm_shims();
|
|
274
|
+
|
|
220
275
|
// src/utils/interpolate.ts
|
|
221
276
|
init_esm_shims();
|
|
222
277
|
import { randomUUID } from "crypto";
|
|
@@ -264,16 +319,7 @@ function hasTemplates(value) {
|
|
|
264
319
|
return typeof value === "string" ? value.includes("{{") : JSON.stringify(value).includes("{{");
|
|
265
320
|
}
|
|
266
321
|
|
|
267
|
-
// src/router/templates/about.
|
|
268
|
-
var _dir = dirname(fileURLToPath2(import.meta.url));
|
|
269
|
-
var LOGO_SRC = (() => {
|
|
270
|
-
try {
|
|
271
|
-
const buf = readFileSync(join(_dir, "../../assets/logo-color.png"));
|
|
272
|
-
return `data:image/png;base64,${buf.toString("base64")}`;
|
|
273
|
-
} catch {
|
|
274
|
-
return "";
|
|
275
|
-
}
|
|
276
|
-
})();
|
|
322
|
+
// src/router/templates/about.helpers.ts
|
|
277
323
|
var METHOD_COLOR = {
|
|
278
324
|
GET: "#3fb950",
|
|
279
325
|
POST: "#58a6ff",
|
|
@@ -338,6 +384,139 @@ function resourceAccordion(name, base, isOpen) {
|
|
|
338
384
|
</table>
|
|
339
385
|
</details>`;
|
|
340
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
|
+
}
|
|
341
520
|
function examplesBlock(collections, relations, base, host, options, firstCustomRoute) {
|
|
342
521
|
const examples = [];
|
|
343
522
|
const firstCol = collections[0];
|
|
@@ -368,35 +547,36 @@ curl -X DELETE ${p}/1`
|
|
|
368
547
|
const firstRel = Object.entries(relations)[0];
|
|
369
548
|
if (firstRel) {
|
|
370
549
|
const [child, fields] = firstRel;
|
|
371
|
-
const
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
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
|
+
}
|
|
385
565
|
}
|
|
386
566
|
}
|
|
387
567
|
if (options.pageable.enabled && firstCol) {
|
|
388
568
|
examples.push(`# Pageable envelope
|
|
389
569
|
curl "${host}${base}/${firstCol}?_page=2"`);
|
|
390
570
|
}
|
|
391
|
-
const firstParentRel = Object.entries(relations).find(
|
|
392
|
-
([, fields]) => Object.values(fields).includes(firstCol ?? "")
|
|
393
|
-
);
|
|
394
571
|
if (firstCol) {
|
|
395
572
|
examples.push(
|
|
396
573
|
`# Project fields with ?_fields
|
|
397
574
|
curl "${host}${base}/${firstCol}?_fields=id,name"`
|
|
398
575
|
);
|
|
399
576
|
}
|
|
577
|
+
const firstParentRel = Object.entries(relations).find(
|
|
578
|
+
([, fields]) => Object.values(fields).some((def) => def.type !== "many2many" && def.target === firstCol)
|
|
579
|
+
);
|
|
400
580
|
if (firstParentRel && firstCol) {
|
|
401
581
|
const [childName] = firstParentRel;
|
|
402
582
|
examples.push(
|
|
@@ -422,9 +602,21 @@ curl ${curlFlag}${fullPath}`);
|
|
|
422
602
|
const highlighted = examples.map((e) => e.replace(/^(#.+)$/gm, '<span class="cm">$1</span>')).join("\n\n");
|
|
423
603
|
return `<pre>${highlighted}</pre>`;
|
|
424
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
|
+
})();
|
|
425
616
|
function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map()) {
|
|
426
617
|
const collections = Object.keys(storage.getData());
|
|
427
618
|
const relations = storage.getRelations();
|
|
619
|
+
const customRoutes = storage.getRoutes();
|
|
428
620
|
const base = options.base;
|
|
429
621
|
const host = `http://${options.host}:${options.port}`;
|
|
430
622
|
const modes = [];
|
|
@@ -438,105 +630,6 @@ function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map(
|
|
|
438
630
|
if (options.idStrategy !== "increment")
|
|
439
631
|
modes.push(badge(`id \xB7 ${options.idStrategy}`, "#a371f7", "#a371f718"));
|
|
440
632
|
const accordions = collections.map((col, i) => resourceAccordion(col, base, i === 0)).join("");
|
|
441
|
-
const nestedRows = [];
|
|
442
|
-
for (const [child, fields] of Object.entries(relations)) {
|
|
443
|
-
for (const [, parent] of Object.entries(fields)) {
|
|
444
|
-
const nestedPath = `${base}/${parent}/:id/${child}`;
|
|
445
|
-
const parentSingular = parent.endsWith("s") ? parent.slice(0, -1) : parent;
|
|
446
|
-
nestedRows.push(
|
|
447
|
-
endpointRow("GET", nestedPath, `List ${child} belonging to a ${parentSingular}.`)
|
|
448
|
-
);
|
|
449
|
-
}
|
|
450
|
-
}
|
|
451
|
-
const nestedAccordion = nestedRows.length ? `
|
|
452
|
-
<details class="resource-card nested-card">
|
|
453
|
-
<summary>
|
|
454
|
-
<span class="resource-name">Nested routes</span>
|
|
455
|
-
<span class="route-count">${nestedRows.length} route${nestedRows.length !== 1 ? "s" : ""}</span>
|
|
456
|
-
</summary>
|
|
457
|
-
<table><tbody>${nestedRows.join("")}</tbody></table>
|
|
458
|
-
</details>` : "";
|
|
459
|
-
const snapshotAccordion = options.snapshot ? `
|
|
460
|
-
<details class="resource-card nested-card">
|
|
461
|
-
<summary>
|
|
462
|
-
<span class="resource-name">/_snapshot</span>
|
|
463
|
-
<span class="route-count">3 routes</span>
|
|
464
|
-
</summary>
|
|
465
|
-
<table><tbody>
|
|
466
|
-
${endpointRow("GET", "/_snapshot", "Returns metadata of the current snapshot: <code>savedAt</code> and item counts per collection.")}
|
|
467
|
-
${endpointRow("POST", "/_snapshot/save", "Replaces the stored snapshot with the current database state.")}
|
|
468
|
-
${endpointRow("POST", "/_snapshot/reset", "Restores the database to the last saved snapshot and persists to disk.")}
|
|
469
|
-
</tbody></table>
|
|
470
|
-
</details>` : "";
|
|
471
|
-
const customRoutes = storage.getRoutes();
|
|
472
|
-
const customRoutesAccordion = customRoutes.length ? `
|
|
473
|
-
<details class="resource-card nested-card">
|
|
474
|
-
<summary>
|
|
475
|
-
<span class="resource-name">Custom routes</span>
|
|
476
|
-
<span class="route-count">${customRoutes.length} route${customRoutes.length !== 1 ? "s" : ""}</span>
|
|
477
|
-
</summary>
|
|
478
|
-
<table><tbody>
|
|
479
|
-
${customRoutes.map((r) => {
|
|
480
|
-
const fullPath = `${base}${r.path}`;
|
|
481
|
-
const tags = [];
|
|
482
|
-
if (r.error) {
|
|
483
|
-
tags.push(`<span style="color:#f85149;font-size:11px">error\xB7${r.error}</span>`);
|
|
484
|
-
}
|
|
485
|
-
if (r.delay && r.delay > 0) {
|
|
486
|
-
tags.push(`<span style="color:#fb923c;font-size:11px">delay\xB7${r.delay}ms</span>`);
|
|
487
|
-
}
|
|
488
|
-
if (r.scenarios?.length) {
|
|
489
|
-
const hasOr = r.scenarios.some((s) => Array.isArray(s.when));
|
|
490
|
-
tags.push(
|
|
491
|
-
`<span style="color:#a371f7;font-size:11px">scenarios\xB7${r.scenarios.length}${hasOr ? " (OR)" : ""}</span>`
|
|
492
|
-
);
|
|
493
|
-
}
|
|
494
|
-
if (r.otherwise) {
|
|
495
|
-
tags.push(`<span style="color:var(--text-muted);font-size:11px">otherwise</span>`);
|
|
496
|
-
}
|
|
497
|
-
let desc;
|
|
498
|
-
if (r.error) {
|
|
499
|
-
desc = `Error injection \u2014 <code>${r.error}</code>`;
|
|
500
|
-
} else if (r.handler) {
|
|
501
|
-
const found = handlers.has(r.handler);
|
|
502
|
-
const handlerName = escapeHtml(r.handler);
|
|
503
|
-
desc = found ? `Handler \u2014 <code>${handlerName}()</code>` : `Handler \u2014 <code>${handlerName}()</code> <span style="color:#f85149">(not loaded)</span>`;
|
|
504
|
-
} else if (r.scenarios?.length) {
|
|
505
|
-
const hasTemplateInScenarios = r.scenarios.some((s) => s.response.body != null && hasTemplates(s.response.body)) || r.otherwise?.body != null && hasTemplates(r.otherwise.body);
|
|
506
|
-
desc = hasTemplateInScenarios ? `Scenarios \u2014 <code>{{\u2026}}</code>` : `Scenarios`;
|
|
507
|
-
} else if (r.response?.body != null && hasTemplates(r.response.body)) {
|
|
508
|
-
desc = `Dynamic \u2014 <code>{{\u2026}}</code>`;
|
|
509
|
-
} else {
|
|
510
|
-
const status = r.response?.status ?? 200;
|
|
511
|
-
desc = `Static \u2014 <code>${status}</code>${r.response?.headers ? ` + headers` : ""}`;
|
|
512
|
-
}
|
|
513
|
-
if (tags.length) desc += ` ${tags.join(" ")}`;
|
|
514
|
-
return endpointRow(r.method?.toUpperCase() ?? "GET", fullPath, desc);
|
|
515
|
-
}).join("")}
|
|
516
|
-
</tbody></table>
|
|
517
|
-
</details>` : "";
|
|
518
|
-
const routesByHandler = /* @__PURE__ */ new Map();
|
|
519
|
-
for (const r of customRoutes) {
|
|
520
|
-
if (r.handler) {
|
|
521
|
-
const list = routesByHandler.get(r.handler) ?? [];
|
|
522
|
-
list.push({ method: (r.method ?? "GET").toUpperCase(), path: `${base}${r.path}` });
|
|
523
|
-
routesByHandler.set(r.handler, list);
|
|
524
|
-
}
|
|
525
|
-
}
|
|
526
|
-
const handlersAccordion = handlers.size > 0 ? `
|
|
527
|
-
<details class="resource-card nested-card">
|
|
528
|
-
<summary>
|
|
529
|
-
<span class="resource-name">Handlers</span>
|
|
530
|
-
<span class="route-count">${handlers.size} function${handlers.size !== 1 ? "s" : ""}</span>
|
|
531
|
-
</summary>
|
|
532
|
-
<table><tbody>
|
|
533
|
-
${[...handlers.keys()].map((name) => {
|
|
534
|
-
const routes = routesByHandler.get(name);
|
|
535
|
-
const routeDesc = routes ? routes.map((r) => `<code>${r.method} ${r.path}</code>`).join(", ") : `<span style="color:var(--text-muted)">not referenced in _routes</span>`;
|
|
536
|
-
return endpointRow("fn", name + "()", routeDesc);
|
|
537
|
-
}).join("")}
|
|
538
|
-
</tbody></table>
|
|
539
|
-
</details>` : "";
|
|
540
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.`;
|
|
541
634
|
return `<!DOCTYPE html>
|
|
542
635
|
<html lang="en">
|
|
@@ -684,10 +777,10 @@ function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map(
|
|
|
684
777
|
<h2>Endpoints</h2>
|
|
685
778
|
<div class="endpoints-grid">
|
|
686
779
|
${accordions}
|
|
687
|
-
${
|
|
688
|
-
${snapshotAccordion}
|
|
689
|
-
${customRoutesAccordion}
|
|
690
|
-
${handlersAccordion}
|
|
780
|
+
${nestedRoutesAccordion(relations, base)}
|
|
781
|
+
${options.snapshot ? snapshotAccordion() : ""}
|
|
782
|
+
${customRoutesAccordion(customRoutes, base, handlers)}
|
|
783
|
+
${handlersAccordion(handlers, customRoutes, base)}
|
|
691
784
|
</div>
|
|
692
785
|
|
|
693
786
|
<h2>Query Parameters</h2>
|
|
@@ -894,10 +987,11 @@ function expandItems(input, query, resource, storage) {
|
|
|
894
987
|
const resourceRelations = storage.getRelations()[resource] ?? {};
|
|
895
988
|
const expansions = /* @__PURE__ */ new Map();
|
|
896
989
|
for (const expandKey of keys) {
|
|
897
|
-
for (const [field,
|
|
990
|
+
for (const [field, def] of Object.entries(resourceRelations)) {
|
|
991
|
+
if (def.type === "many2many") continue;
|
|
898
992
|
const derivedKey = field.replace(/Id$/i, "");
|
|
899
|
-
if (derivedKey === expandKey ||
|
|
900
|
-
expansions.set(expandKey, { field, parentCollection });
|
|
993
|
+
if (derivedKey === expandKey || def.target === expandKey || def.target === `${expandKey}s`) {
|
|
994
|
+
expansions.set(expandKey, { field, parentCollection: def.target });
|
|
901
995
|
break;
|
|
902
996
|
}
|
|
903
997
|
}
|
|
@@ -926,10 +1020,24 @@ function embedItems(input, query, resource, storage) {
|
|
|
926
1020
|
const relations = storage.getRelations();
|
|
927
1021
|
const embeds = /* @__PURE__ */ new Map();
|
|
928
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
|
+
}
|
|
929
1037
|
outer: for (const [childCollection, fields] of Object.entries(relations)) {
|
|
930
|
-
for (const [fkField,
|
|
931
|
-
if (
|
|
932
|
-
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 });
|
|
933
1041
|
break outer;
|
|
934
1042
|
}
|
|
935
1043
|
}
|
|
@@ -938,10 +1046,57 @@ function embedItems(input, query, resource, storage) {
|
|
|
938
1046
|
if (embeds.size === 0) return isArray ? items : input;
|
|
939
1047
|
const result = items.map((item) => {
|
|
940
1048
|
const out = { ...item };
|
|
941
|
-
for (const [embedKey,
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
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
|
+
}
|
|
945
1100
|
}
|
|
946
1101
|
return out;
|
|
947
1102
|
});
|
|
@@ -981,7 +1136,12 @@ var CollectionRouteCommand = class {
|
|
|
981
1136
|
const totalPages = Math.ceil(totalItems / limit) || 1;
|
|
982
1137
|
const data = projectFields(
|
|
983
1138
|
embedItems(
|
|
984
|
-
expandItems(
|
|
1139
|
+
expandItems(
|
|
1140
|
+
applyNested(paginate(sorted, page, limit), this.resource, this.storage),
|
|
1141
|
+
req.query,
|
|
1142
|
+
this.resource,
|
|
1143
|
+
this.storage
|
|
1144
|
+
),
|
|
985
1145
|
req.query,
|
|
986
1146
|
this.resource,
|
|
987
1147
|
this.storage
|
|
@@ -1013,7 +1173,12 @@ var CollectionRouteCommand = class {
|
|
|
1013
1173
|
}
|
|
1014
1174
|
return projectFields(
|
|
1015
1175
|
embedItems(
|
|
1016
|
-
expandItems(
|
|
1176
|
+
expandItems(
|
|
1177
|
+
applyNested(result, this.resource, this.storage),
|
|
1178
|
+
req.query,
|
|
1179
|
+
this.resource,
|
|
1180
|
+
this.storage
|
|
1181
|
+
),
|
|
1017
1182
|
req.query,
|
|
1018
1183
|
this.resource,
|
|
1019
1184
|
this.storage
|
|
@@ -1199,7 +1364,12 @@ var ItemRouteCommand = class {
|
|
|
1199
1364
|
const fields = (req.query["_fields"] ?? "").split(",").map((f) => f.trim()).filter(Boolean);
|
|
1200
1365
|
return projectFields(
|
|
1201
1366
|
embedItems(
|
|
1202
|
-
expandItems(
|
|
1367
|
+
expandItems(
|
|
1368
|
+
applyNested(item, this.resource, this.storage),
|
|
1369
|
+
req.query,
|
|
1370
|
+
this.resource,
|
|
1371
|
+
this.storage
|
|
1372
|
+
),
|
|
1203
1373
|
req.query,
|
|
1204
1374
|
this.resource,
|
|
1205
1375
|
this.storage
|
|
@@ -1243,32 +1413,68 @@ var NestedRouteCommand = class {
|
|
|
1243
1413
|
relations;
|
|
1244
1414
|
base;
|
|
1245
1415
|
register(server) {
|
|
1246
|
-
for (const [
|
|
1247
|
-
for (const [
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
if (!parentItem) return reply.status(404).send({ error: "Not found" });
|
|
1254
|
-
const children = (this.storage.getCollection(child) ?? []).filter(
|
|
1255
|
-
(item) => String(item[field]) === req.params.id
|
|
1256
|
-
);
|
|
1257
|
-
return children;
|
|
1258
|
-
});
|
|
1259
|
-
server.get(itemPath, (req, reply) => {
|
|
1260
|
-
const parentCollection = this.storage.getCollection(parent) ?? [];
|
|
1261
|
-
const parentItem = findById(parentCollection, req.params.id);
|
|
1262
|
-
if (!parentItem) return reply.status(404).send({ error: "Not found" });
|
|
1263
|
-
const childItem = (this.storage.getCollection(child) ?? []).find(
|
|
1264
|
-
(item) => String(item[field]) === req.params.id && String(item["id"]) === req.params.childId
|
|
1265
|
-
);
|
|
1266
|
-
if (!childItem) return reply.status(404).send({ error: "Not found" });
|
|
1267
|
-
return childItem;
|
|
1268
|
-
});
|
|
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
|
+
}
|
|
1269
1423
|
}
|
|
1270
1424
|
}
|
|
1271
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
|
+
}
|
|
1272
1478
|
};
|
|
1273
1479
|
|
|
1274
1480
|
// src/router/routes/snapshot.routes.ts
|
|
@@ -1431,7 +1637,7 @@ function buildOptions(opts) {
|
|
|
1431
1637
|
}
|
|
1432
1638
|
function createInMemoryStorage(data) {
|
|
1433
1639
|
const raw = data;
|
|
1434
|
-
const relations = raw["_rel"]
|
|
1640
|
+
const relations = parseRelations(raw["_rel"]);
|
|
1435
1641
|
const routes = Array.isArray(raw["_routes"]) ? raw["_routes"] : [];
|
|
1436
1642
|
const collections = Object.fromEntries(
|
|
1437
1643
|
Object.entries(raw).filter(([k]) => k !== "_rel" && k !== "_routes")
|