@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.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,12 +249,20 @@ 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();
|
|
204
256
|
var import_node_fs = require("fs");
|
|
257
|
+
var ALLOWED_EXTENSIONS = [".js", ".mjs", ".cjs"];
|
|
205
258
|
async function loadHandlers(filePath) {
|
|
206
259
|
if (!(0, import_node_fs.existsSync)(filePath)) return /* @__PURE__ */ new Map();
|
|
260
|
+
if (!ALLOWED_EXTENSIONS.some((ext) => filePath.endsWith(ext))) {
|
|
261
|
+
console.error(
|
|
262
|
+
` \x1B[31m[handlers] ${filePath} \u2014 only .js, .mjs and .cjs files are allowed\x1B[0m`
|
|
263
|
+
);
|
|
264
|
+
return /* @__PURE__ */ new Map();
|
|
265
|
+
}
|
|
207
266
|
try {
|
|
208
267
|
const mod = await import(filePath);
|
|
209
268
|
const map = /* @__PURE__ */ new Map();
|
|
@@ -244,6 +303,9 @@ var import_node_fs2 = require("fs");
|
|
|
244
303
|
var import_node_path = require("path");
|
|
245
304
|
var import_node_url = require("url");
|
|
246
305
|
|
|
306
|
+
// src/router/templates/about.helpers.ts
|
|
307
|
+
init_cjs_shims();
|
|
308
|
+
|
|
247
309
|
// src/utils/interpolate.ts
|
|
248
310
|
init_cjs_shims();
|
|
249
311
|
var import_node_crypto = require("crypto");
|
|
@@ -291,16 +353,7 @@ function hasTemplates(value) {
|
|
|
291
353
|
return typeof value === "string" ? value.includes("{{") : JSON.stringify(value).includes("{{");
|
|
292
354
|
}
|
|
293
355
|
|
|
294
|
-
// src/router/templates/about.
|
|
295
|
-
var _dir = (0, import_node_path.dirname)((0, import_node_url.fileURLToPath)(importMetaUrl));
|
|
296
|
-
var LOGO_SRC = (() => {
|
|
297
|
-
try {
|
|
298
|
-
const buf = (0, import_node_fs2.readFileSync)((0, import_node_path.join)(_dir, "../../assets/logo-color.png"));
|
|
299
|
-
return `data:image/png;base64,${buf.toString("base64")}`;
|
|
300
|
-
} catch {
|
|
301
|
-
return "";
|
|
302
|
-
}
|
|
303
|
-
})();
|
|
356
|
+
// src/router/templates/about.helpers.ts
|
|
304
357
|
var METHOD_COLOR = {
|
|
305
358
|
GET: "#3fb950",
|
|
306
359
|
POST: "#58a6ff",
|
|
@@ -309,8 +362,11 @@ var METHOD_COLOR = {
|
|
|
309
362
|
DELETE: "#f85149",
|
|
310
363
|
fn: "#f0883e"
|
|
311
364
|
};
|
|
365
|
+
function escapeHtml(str) {
|
|
366
|
+
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
367
|
+
}
|
|
312
368
|
function badge(label, color, bg) {
|
|
313
|
-
return `<span class="badge" style="background:${bg};color:${color};border:1px solid ${color}40">${label}</span>`;
|
|
369
|
+
return `<span class="badge" style="background:${bg};color:${color};border:1px solid ${color}40">${escapeHtml(label)}</span>`;
|
|
314
370
|
}
|
|
315
371
|
function methodBadge(method) {
|
|
316
372
|
const color = METHOD_COLOR[method] ?? "#7d8590";
|
|
@@ -320,7 +376,7 @@ function endpointRow(method, path, desc) {
|
|
|
320
376
|
return `
|
|
321
377
|
<tr>
|
|
322
378
|
<td class="method-cell">${methodBadge(method)}</td>
|
|
323
|
-
<td class="path-cell"><code>${path}</code></td>
|
|
379
|
+
<td class="path-cell"><code>${escapeHtml(path)}</code></td>
|
|
324
380
|
<td class="desc-cell">${desc}</td>
|
|
325
381
|
</tr>`;
|
|
326
382
|
}
|
|
@@ -354,7 +410,7 @@ function resourceAccordion(name, base, isOpen) {
|
|
|
354
410
|
return `
|
|
355
411
|
<details class="resource-card" ${isOpen ? "open" : ""}>
|
|
356
412
|
<summary>
|
|
357
|
-
<span class="resource-name">/${name}</span>
|
|
413
|
+
<span class="resource-name">/${escapeHtml(name)}</span>
|
|
358
414
|
<span class="route-count">6 routes</span>
|
|
359
415
|
</summary>
|
|
360
416
|
<table>
|
|
@@ -362,6 +418,139 @@ function resourceAccordion(name, base, isOpen) {
|
|
|
362
418
|
</table>
|
|
363
419
|
</details>`;
|
|
364
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
|
+
}
|
|
365
554
|
function examplesBlock(collections, relations, base, host, options, firstCustomRoute) {
|
|
366
555
|
const examples = [];
|
|
367
556
|
const firstCol = collections[0];
|
|
@@ -392,35 +581,36 @@ curl -X DELETE ${p}/1`
|
|
|
392
581
|
const firstRel = Object.entries(relations)[0];
|
|
393
582
|
if (firstRel) {
|
|
394
583
|
const [child, fields] = firstRel;
|
|
395
|
-
const
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
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
|
+
}
|
|
409
599
|
}
|
|
410
600
|
}
|
|
411
601
|
if (options.pageable.enabled && firstCol) {
|
|
412
602
|
examples.push(`# Pageable envelope
|
|
413
603
|
curl "${host}${base}/${firstCol}?_page=2"`);
|
|
414
604
|
}
|
|
415
|
-
const firstParentRel = Object.entries(relations).find(
|
|
416
|
-
([, fields]) => Object.values(fields).includes(firstCol ?? "")
|
|
417
|
-
);
|
|
418
605
|
if (firstCol) {
|
|
419
606
|
examples.push(
|
|
420
607
|
`# Project fields with ?_fields
|
|
421
608
|
curl "${host}${base}/${firstCol}?_fields=id,name"`
|
|
422
609
|
);
|
|
423
610
|
}
|
|
611
|
+
const firstParentRel = Object.entries(relations).find(
|
|
612
|
+
([, fields]) => Object.values(fields).some((def) => def.type !== "many2many" && def.target === firstCol)
|
|
613
|
+
);
|
|
424
614
|
if (firstParentRel && firstCol) {
|
|
425
615
|
const [childName] = firstParentRel;
|
|
426
616
|
examples.push(
|
|
@@ -438,7 +628,7 @@ curl -X POST ${host}/_snapshot/reset`
|
|
|
438
628
|
}
|
|
439
629
|
if (firstCustomRoute) {
|
|
440
630
|
const method = firstCustomRoute.method?.toUpperCase() ?? "GET";
|
|
441
|
-
const fullPath = `${host}${base}${firstCustomRoute.path}`;
|
|
631
|
+
const fullPath = `${host}${base}${escapeHtml(firstCustomRoute.path ?? "")}`;
|
|
442
632
|
const curlFlag = method === "GET" ? "" : `-X ${method} `;
|
|
443
633
|
examples.push(`# Custom route
|
|
444
634
|
curl ${curlFlag}${fullPath}`);
|
|
@@ -446,9 +636,21 @@ curl ${curlFlag}${fullPath}`);
|
|
|
446
636
|
const highlighted = examples.map((e) => e.replace(/^(#.+)$/gm, '<span class="cm">$1</span>')).join("\n\n");
|
|
447
637
|
return `<pre>${highlighted}</pre>`;
|
|
448
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
|
+
})();
|
|
449
650
|
function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map()) {
|
|
450
651
|
const collections = Object.keys(storage.getData());
|
|
451
652
|
const relations = storage.getRelations();
|
|
653
|
+
const customRoutes = storage.getRoutes();
|
|
452
654
|
const base = options.base;
|
|
453
655
|
const host = `http://${options.host}:${options.port}`;
|
|
454
656
|
const modes = [];
|
|
@@ -462,104 +664,6 @@ function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map(
|
|
|
462
664
|
if (options.idStrategy !== "increment")
|
|
463
665
|
modes.push(badge(`id \xB7 ${options.idStrategy}`, "#a371f7", "#a371f718"));
|
|
464
666
|
const accordions = collections.map((col, i) => resourceAccordion(col, base, i === 0)).join("");
|
|
465
|
-
const nestedRows = [];
|
|
466
|
-
for (const [child, fields] of Object.entries(relations)) {
|
|
467
|
-
for (const [, parent] of Object.entries(fields)) {
|
|
468
|
-
const nestedPath = `${base}/${parent}/:id/${child}`;
|
|
469
|
-
const parentSingular = parent.endsWith("s") ? parent.slice(0, -1) : parent;
|
|
470
|
-
nestedRows.push(
|
|
471
|
-
endpointRow("GET", nestedPath, `List ${child} belonging to a ${parentSingular}.`)
|
|
472
|
-
);
|
|
473
|
-
}
|
|
474
|
-
}
|
|
475
|
-
const nestedAccordion = nestedRows.length ? `
|
|
476
|
-
<details class="resource-card nested-card">
|
|
477
|
-
<summary>
|
|
478
|
-
<span class="resource-name">Nested routes</span>
|
|
479
|
-
<span class="route-count">${nestedRows.length} route${nestedRows.length !== 1 ? "s" : ""}</span>
|
|
480
|
-
</summary>
|
|
481
|
-
<table><tbody>${nestedRows.join("")}</tbody></table>
|
|
482
|
-
</details>` : "";
|
|
483
|
-
const snapshotAccordion = options.snapshot ? `
|
|
484
|
-
<details class="resource-card nested-card">
|
|
485
|
-
<summary>
|
|
486
|
-
<span class="resource-name">/_snapshot</span>
|
|
487
|
-
<span class="route-count">3 routes</span>
|
|
488
|
-
</summary>
|
|
489
|
-
<table><tbody>
|
|
490
|
-
${endpointRow("GET", "/_snapshot", "Returns metadata of the current snapshot: <code>savedAt</code> and item counts per collection.")}
|
|
491
|
-
${endpointRow("POST", "/_snapshot/save", "Replaces the stored snapshot with the current database state.")}
|
|
492
|
-
${endpointRow("POST", "/_snapshot/reset", "Restores the database to the last saved snapshot and persists to disk.")}
|
|
493
|
-
</tbody></table>
|
|
494
|
-
</details>` : "";
|
|
495
|
-
const customRoutes = storage.getRoutes();
|
|
496
|
-
const customRoutesAccordion = customRoutes.length ? `
|
|
497
|
-
<details class="resource-card nested-card">
|
|
498
|
-
<summary>
|
|
499
|
-
<span class="resource-name">Custom routes</span>
|
|
500
|
-
<span class="route-count">${customRoutes.length} route${customRoutes.length !== 1 ? "s" : ""}</span>
|
|
501
|
-
</summary>
|
|
502
|
-
<table><tbody>
|
|
503
|
-
${customRoutes.map((r) => {
|
|
504
|
-
const fullPath = `${base}${r.path}`;
|
|
505
|
-
const tags = [];
|
|
506
|
-
if (r.error) {
|
|
507
|
-
tags.push(`<span style="color:#f85149;font-size:11px">error\xB7${r.error}</span>`);
|
|
508
|
-
}
|
|
509
|
-
if (r.delay && r.delay > 0) {
|
|
510
|
-
tags.push(`<span style="color:#fb923c;font-size:11px">delay\xB7${r.delay}ms</span>`);
|
|
511
|
-
}
|
|
512
|
-
if (r.scenarios?.length) {
|
|
513
|
-
const hasOr = r.scenarios.some((s) => Array.isArray(s.when));
|
|
514
|
-
tags.push(
|
|
515
|
-
`<span style="color:#a371f7;font-size:11px">scenarios\xB7${r.scenarios.length}${hasOr ? " (OR)" : ""}</span>`
|
|
516
|
-
);
|
|
517
|
-
}
|
|
518
|
-
if (r.otherwise) {
|
|
519
|
-
tags.push(`<span style="color:var(--text-muted);font-size:11px">otherwise</span>`);
|
|
520
|
-
}
|
|
521
|
-
let desc;
|
|
522
|
-
if (r.error) {
|
|
523
|
-
desc = `Error injection \u2014 <code>${r.error}</code>`;
|
|
524
|
-
} else if (r.handler) {
|
|
525
|
-
const found = handlers.has(r.handler);
|
|
526
|
-
desc = found ? `Handler \u2014 <code>${r.handler}()</code>` : `Handler \u2014 <code>${r.handler}()</code> <span style="color:#f85149">(not loaded)</span>`;
|
|
527
|
-
} else if (r.scenarios?.length) {
|
|
528
|
-
const hasTemplateInScenarios = r.scenarios.some((s) => s.response.body != null && hasTemplates(s.response.body)) || r.otherwise?.body != null && hasTemplates(r.otherwise.body);
|
|
529
|
-
desc = hasTemplateInScenarios ? `Scenarios \u2014 <code>{{\u2026}}</code>` : `Scenarios`;
|
|
530
|
-
} else if (r.response?.body != null && hasTemplates(r.response.body)) {
|
|
531
|
-
desc = `Dynamic \u2014 <code>{{\u2026}}</code>`;
|
|
532
|
-
} else {
|
|
533
|
-
const status = r.response?.status ?? 200;
|
|
534
|
-
desc = `Static \u2014 <code>${status}</code>${r.response?.headers ? ` + headers` : ""}`;
|
|
535
|
-
}
|
|
536
|
-
if (tags.length) desc += ` ${tags.join(" ")}`;
|
|
537
|
-
return endpointRow(r.method?.toUpperCase() ?? "GET", fullPath, desc);
|
|
538
|
-
}).join("")}
|
|
539
|
-
</tbody></table>
|
|
540
|
-
</details>` : "";
|
|
541
|
-
const routesByHandler = /* @__PURE__ */ new Map();
|
|
542
|
-
for (const r of customRoutes) {
|
|
543
|
-
if (r.handler) {
|
|
544
|
-
const list = routesByHandler.get(r.handler) ?? [];
|
|
545
|
-
list.push({ method: (r.method ?? "GET").toUpperCase(), path: `${base}${r.path}` });
|
|
546
|
-
routesByHandler.set(r.handler, list);
|
|
547
|
-
}
|
|
548
|
-
}
|
|
549
|
-
const handlersAccordion = handlers.size > 0 ? `
|
|
550
|
-
<details class="resource-card nested-card">
|
|
551
|
-
<summary>
|
|
552
|
-
<span class="resource-name">Handlers</span>
|
|
553
|
-
<span class="route-count">${handlers.size} function${handlers.size !== 1 ? "s" : ""}</span>
|
|
554
|
-
</summary>
|
|
555
|
-
<table><tbody>
|
|
556
|
-
${[...handlers.keys()].map((name) => {
|
|
557
|
-
const routes = routesByHandler.get(name);
|
|
558
|
-
const routeDesc = routes ? routes.map((r) => `<code>${r.method} ${r.path}</code>`).join(", ") : `<span style="color:var(--text-muted)">not referenced in _routes</span>`;
|
|
559
|
-
return endpointRow("fn", name + "()", routeDesc);
|
|
560
|
-
}).join("")}
|
|
561
|
-
</tbody></table>
|
|
562
|
-
</details>` : "";
|
|
563
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.`;
|
|
564
668
|
return `<!DOCTYPE html>
|
|
565
669
|
<html lang="en">
|
|
@@ -707,10 +811,10 @@ function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map(
|
|
|
707
811
|
<h2>Endpoints</h2>
|
|
708
812
|
<div class="endpoints-grid">
|
|
709
813
|
${accordions}
|
|
710
|
-
${
|
|
711
|
-
${snapshotAccordion}
|
|
712
|
-
${customRoutesAccordion}
|
|
713
|
-
${handlersAccordion}
|
|
814
|
+
${nestedRoutesAccordion(relations, base)}
|
|
815
|
+
${options.snapshot ? snapshotAccordion() : ""}
|
|
816
|
+
${customRoutesAccordion(customRoutes, base, handlers)}
|
|
817
|
+
${handlersAccordion(handlers, customRoutes, base)}
|
|
714
818
|
</div>
|
|
715
819
|
|
|
716
820
|
<h2>Query Parameters</h2>
|
|
@@ -801,6 +905,7 @@ function applyOperator(itemValue, op, filterValue) {
|
|
|
801
905
|
case "_start":
|
|
802
906
|
return strItem.toLowerCase().startsWith(filterValue.toLowerCase());
|
|
803
907
|
case "_regex": {
|
|
908
|
+
if (filterValue.length > 200) return false;
|
|
804
909
|
try {
|
|
805
910
|
return new RegExp(filterValue, "i").test(strItem);
|
|
806
911
|
} catch {
|
|
@@ -916,10 +1021,11 @@ function expandItems(input, query, resource, storage) {
|
|
|
916
1021
|
const resourceRelations = storage.getRelations()[resource] ?? {};
|
|
917
1022
|
const expansions = /* @__PURE__ */ new Map();
|
|
918
1023
|
for (const expandKey of keys) {
|
|
919
|
-
for (const [field,
|
|
1024
|
+
for (const [field, def] of Object.entries(resourceRelations)) {
|
|
1025
|
+
if (def.type === "many2many") continue;
|
|
920
1026
|
const derivedKey = field.replace(/Id$/i, "");
|
|
921
|
-
if (derivedKey === expandKey ||
|
|
922
|
-
expansions.set(expandKey, { field, parentCollection });
|
|
1027
|
+
if (derivedKey === expandKey || def.target === expandKey || def.target === `${expandKey}s`) {
|
|
1028
|
+
expansions.set(expandKey, { field, parentCollection: def.target });
|
|
923
1029
|
break;
|
|
924
1030
|
}
|
|
925
1031
|
}
|
|
@@ -948,10 +1054,24 @@ function embedItems(input, query, resource, storage) {
|
|
|
948
1054
|
const relations = storage.getRelations();
|
|
949
1055
|
const embeds = /* @__PURE__ */ new Map();
|
|
950
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
|
+
}
|
|
951
1071
|
outer: for (const [childCollection, fields] of Object.entries(relations)) {
|
|
952
|
-
for (const [fkField,
|
|
953
|
-
if (
|
|
954
|
-
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 });
|
|
955
1075
|
break outer;
|
|
956
1076
|
}
|
|
957
1077
|
}
|
|
@@ -960,10 +1080,57 @@ function embedItems(input, query, resource, storage) {
|
|
|
960
1080
|
if (embeds.size === 0) return isArray ? items : input;
|
|
961
1081
|
const result = items.map((item) => {
|
|
962
1082
|
const out = { ...item };
|
|
963
|
-
for (const [embedKey,
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
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
|
+
}
|
|
967
1134
|
}
|
|
968
1135
|
return out;
|
|
969
1136
|
});
|
|
@@ -1003,7 +1170,12 @@ var CollectionRouteCommand = class {
|
|
|
1003
1170
|
const totalPages = Math.ceil(totalItems / limit) || 1;
|
|
1004
1171
|
const data = projectFields(
|
|
1005
1172
|
embedItems(
|
|
1006
|
-
expandItems(
|
|
1173
|
+
expandItems(
|
|
1174
|
+
applyNested(paginate(sorted, page, limit), this.resource, this.storage),
|
|
1175
|
+
req.query,
|
|
1176
|
+
this.resource,
|
|
1177
|
+
this.storage
|
|
1178
|
+
),
|
|
1007
1179
|
req.query,
|
|
1008
1180
|
this.resource,
|
|
1009
1181
|
this.storage
|
|
@@ -1035,7 +1207,12 @@ var CollectionRouteCommand = class {
|
|
|
1035
1207
|
}
|
|
1036
1208
|
return projectFields(
|
|
1037
1209
|
embedItems(
|
|
1038
|
-
expandItems(
|
|
1210
|
+
expandItems(
|
|
1211
|
+
applyNested(result, this.resource, this.storage),
|
|
1212
|
+
req.query,
|
|
1213
|
+
this.resource,
|
|
1214
|
+
this.storage
|
|
1215
|
+
),
|
|
1039
1216
|
req.query,
|
|
1040
1217
|
this.resource,
|
|
1041
1218
|
this.storage
|
|
@@ -1221,7 +1398,12 @@ var ItemRouteCommand = class {
|
|
|
1221
1398
|
const fields = (req.query["_fields"] ?? "").split(",").map((f) => f.trim()).filter(Boolean);
|
|
1222
1399
|
return projectFields(
|
|
1223
1400
|
embedItems(
|
|
1224
|
-
expandItems(
|
|
1401
|
+
expandItems(
|
|
1402
|
+
applyNested(item, this.resource, this.storage),
|
|
1403
|
+
req.query,
|
|
1404
|
+
this.resource,
|
|
1405
|
+
this.storage
|
|
1406
|
+
),
|
|
1225
1407
|
req.query,
|
|
1226
1408
|
this.resource,
|
|
1227
1409
|
this.storage
|
|
@@ -1265,32 +1447,68 @@ var NestedRouteCommand = class {
|
|
|
1265
1447
|
relations;
|
|
1266
1448
|
base;
|
|
1267
1449
|
register(server) {
|
|
1268
|
-
for (const [
|
|
1269
|
-
for (const [
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
if (!parentItem) return reply.status(404).send({ error: "Not found" });
|
|
1276
|
-
const children = (this.storage.getCollection(child) ?? []).filter(
|
|
1277
|
-
(item) => String(item[field]) === req.params.id
|
|
1278
|
-
);
|
|
1279
|
-
return children;
|
|
1280
|
-
});
|
|
1281
|
-
server.get(itemPath, (req, reply) => {
|
|
1282
|
-
const parentCollection = this.storage.getCollection(parent) ?? [];
|
|
1283
|
-
const parentItem = findById(parentCollection, req.params.id);
|
|
1284
|
-
if (!parentItem) return reply.status(404).send({ error: "Not found" });
|
|
1285
|
-
const childItem = (this.storage.getCollection(child) ?? []).find(
|
|
1286
|
-
(item) => String(item[field]) === req.params.id && String(item["id"]) === req.params.childId
|
|
1287
|
-
);
|
|
1288
|
-
if (!childItem) return reply.status(404).send({ error: "Not found" });
|
|
1289
|
-
return childItem;
|
|
1290
|
-
});
|
|
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
|
+
}
|
|
1291
1457
|
}
|
|
1292
1458
|
}
|
|
1293
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
|
+
}
|
|
1294
1512
|
};
|
|
1295
1513
|
|
|
1296
1514
|
// src/router/routes/snapshot.routes.ts
|
|
@@ -1453,7 +1671,7 @@ function buildOptions(opts) {
|
|
|
1453
1671
|
}
|
|
1454
1672
|
function createInMemoryStorage(data) {
|
|
1455
1673
|
const raw = data;
|
|
1456
|
-
const relations = raw["_rel"]
|
|
1674
|
+
const relations = parseRelations(raw["_rel"]);
|
|
1457
1675
|
const routes = Array.isArray(raw["_routes"]) ? raw["_routes"] : [];
|
|
1458
1676
|
const collections = Object.fromEntries(
|
|
1459
1677
|
Object.entries(raw).filter(([k]) => k !== "_rel" && k !== "_routes")
|