@yrest/cli 0.8.1 → 0.10.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 +195 -45
- package/dist/cli/index.js +1241 -209
- package/dist/cli/index.mjs +1238 -206
- package/dist/index.d.mts +91 -5
- package/dist/index.d.ts +91 -5
- package/dist/index.js +814 -188
- package/dist/index.mjs +811 -185
- package/package.json +1 -1
package/dist/index.mjs
CHANGED
|
@@ -17,6 +17,92 @@ 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
|
+
|
|
70
|
+
// src/storage/parseSchema.ts
|
|
71
|
+
function parseSchema(raw) {
|
|
72
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
|
|
73
|
+
const result = {};
|
|
74
|
+
for (const [collection, fields] of Object.entries(raw)) {
|
|
75
|
+
if (!fields || typeof fields !== "object" || Array.isArray(fields)) continue;
|
|
76
|
+
result[collection] = {};
|
|
77
|
+
for (const [field, value] of Object.entries(fields)) {
|
|
78
|
+
const def = normaliseFieldDef(value);
|
|
79
|
+
if (def) result[collection][field] = def;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return result;
|
|
83
|
+
}
|
|
84
|
+
function normaliseFieldDef(value) {
|
|
85
|
+
if (value === "required") return { required: true };
|
|
86
|
+
if (value === "optional") return { required: false };
|
|
87
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
88
|
+
const v = value;
|
|
89
|
+
const def = {};
|
|
90
|
+
if (v["required"] === true || v["required"] === false) def.required = v["required"];
|
|
91
|
+
if (typeof v["type"] === "string" && ["string", "integer", "number", "boolean", "object", "array"].includes(v["type"]))
|
|
92
|
+
def.type = v["type"];
|
|
93
|
+
if (typeof v["format"] === "string") def.format = v["format"];
|
|
94
|
+
if (Array.isArray(v["enum"])) def.enum = v["enum"];
|
|
95
|
+
if (typeof v["description"] === "string") def.description = v["description"];
|
|
96
|
+
if (v["default"] !== void 0) def.default = v["default"];
|
|
97
|
+
return def;
|
|
98
|
+
}
|
|
99
|
+
var init_parseSchema = __esm({
|
|
100
|
+
"src/storage/parseSchema.ts"() {
|
|
101
|
+
"use strict";
|
|
102
|
+
init_esm_shims();
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
20
106
|
// src/utils/deepCopy.ts
|
|
21
107
|
function deepCopyData(source) {
|
|
22
108
|
return Object.fromEntries(
|
|
@@ -38,14 +124,16 @@ __export(yrestStorage_exports, {
|
|
|
38
124
|
import { readFileSync as readFileSync2, writeFileSync, renameSync } from "fs";
|
|
39
125
|
import { resolve, dirname as dirname2 } from "path";
|
|
40
126
|
import { randomUUID as randomUUID2 } from "crypto";
|
|
41
|
-
import { parse as parse2, stringify } from "yaml";
|
|
127
|
+
import { parse as parse2, stringify as stringify2 } from "yaml";
|
|
42
128
|
function createYrestStorage(filePath) {
|
|
43
129
|
const absPath = resolve(filePath);
|
|
44
130
|
const raw = parse2(readFileSync2(absPath, "utf8")) ?? {};
|
|
45
|
-
const
|
|
131
|
+
const RESERVED = /* @__PURE__ */ new Set(["_rel", "_routes", "_schema"]);
|
|
132
|
+
const relations = parseRelations(raw["_rel"]);
|
|
46
133
|
const routes = Array.isArray(raw["_routes"]) ? raw["_routes"] : [];
|
|
134
|
+
const schema = parseSchema(raw["_schema"]);
|
|
47
135
|
const data = Object.fromEntries(
|
|
48
|
-
Object.entries(raw).filter(([key]) => key
|
|
136
|
+
Object.entries(raw).filter(([key]) => !RESERVED.has(key))
|
|
49
137
|
);
|
|
50
138
|
let snapshot = {
|
|
51
139
|
data: deepCopyData(data),
|
|
@@ -59,6 +147,9 @@ function createYrestStorage(filePath) {
|
|
|
59
147
|
getRelations() {
|
|
60
148
|
return relations;
|
|
61
149
|
},
|
|
150
|
+
getSchema() {
|
|
151
|
+
return schema;
|
|
152
|
+
},
|
|
62
153
|
getRoutes() {
|
|
63
154
|
return routes;
|
|
64
155
|
},
|
|
@@ -74,14 +165,14 @@ function createYrestStorage(filePath) {
|
|
|
74
165
|
if (routes.length > 0) payload._routes = routes;
|
|
75
166
|
Object.assign(payload, data);
|
|
76
167
|
const tmp = resolve(dirname2(absPath), `.yrest-${randomUUID2()}.tmp`);
|
|
77
|
-
writeFileSync(tmp,
|
|
168
|
+
writeFileSync(tmp, stringify2(payload), "utf8");
|
|
78
169
|
renameSync(tmp, absPath);
|
|
79
170
|
},
|
|
80
171
|
reload() {
|
|
81
172
|
const fresh = parse2(readFileSync2(absPath, "utf8")) ?? {};
|
|
82
|
-
const freshRelations = fresh["_rel"]
|
|
173
|
+
const freshRelations = parseRelations(fresh["_rel"]);
|
|
83
174
|
const freshData = Object.fromEntries(
|
|
84
|
-
Object.entries(fresh).filter(([key]) => key
|
|
175
|
+
Object.entries(fresh).filter(([key]) => !RESERVED.has(key))
|
|
85
176
|
);
|
|
86
177
|
for (const key of Object.keys(data)) delete data[key];
|
|
87
178
|
Object.assign(data, freshData);
|
|
@@ -112,6 +203,8 @@ var init_yrestStorage = __esm({
|
|
|
112
203
|
"src/storage/yrestStorage.ts"() {
|
|
113
204
|
"use strict";
|
|
114
205
|
init_esm_shims();
|
|
206
|
+
init_parseRelations();
|
|
207
|
+
init_parseSchema();
|
|
115
208
|
init_deepCopy();
|
|
116
209
|
}
|
|
117
210
|
});
|
|
@@ -163,6 +256,8 @@ function dedent(str) {
|
|
|
163
256
|
|
|
164
257
|
// src/api/yrestServer.ts
|
|
165
258
|
init_esm_shims();
|
|
259
|
+
init_parseRelations();
|
|
260
|
+
init_parseSchema();
|
|
166
261
|
import { resolve as resolve2 } from "path";
|
|
167
262
|
|
|
168
263
|
// src/utils/handlers.ts
|
|
@@ -217,6 +312,9 @@ import { readFileSync } from "fs";
|
|
|
217
312
|
import { dirname, join } from "path";
|
|
218
313
|
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
219
314
|
|
|
315
|
+
// src/router/templates/about.helpers.ts
|
|
316
|
+
init_esm_shims();
|
|
317
|
+
|
|
220
318
|
// src/utils/interpolate.ts
|
|
221
319
|
init_esm_shims();
|
|
222
320
|
import { randomUUID } from "crypto";
|
|
@@ -264,16 +362,7 @@ function hasTemplates(value) {
|
|
|
264
362
|
return typeof value === "string" ? value.includes("{{") : JSON.stringify(value).includes("{{");
|
|
265
363
|
}
|
|
266
364
|
|
|
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
|
-
})();
|
|
365
|
+
// src/router/templates/about.helpers.ts
|
|
277
366
|
var METHOD_COLOR = {
|
|
278
367
|
GET: "#3fb950",
|
|
279
368
|
POST: "#58a6ff",
|
|
@@ -302,7 +391,7 @@ function endpointRow(method, path2, desc) {
|
|
|
302
391
|
}
|
|
303
392
|
function resourceAccordion(name, base, isOpen) {
|
|
304
393
|
const p = `${base}/${name}`;
|
|
305
|
-
const
|
|
394
|
+
const singular2 = name.endsWith("s") ? name.slice(0, -1) : name;
|
|
306
395
|
const rows = [
|
|
307
396
|
endpointRow(
|
|
308
397
|
"GET",
|
|
@@ -312,20 +401,20 @@ function resourceAccordion(name, base, isOpen) {
|
|
|
312
401
|
endpointRow(
|
|
313
402
|
"POST",
|
|
314
403
|
p,
|
|
315
|
-
`Create a new ${
|
|
404
|
+
`Create a new ${singular2}. Auto-assigns <code>id</code> if not provided.`
|
|
316
405
|
),
|
|
317
|
-
endpointRow("GET", `${p}/:id`, `Get a single ${
|
|
406
|
+
endpointRow("GET", `${p}/:id`, `Get a single ${singular2} by id.`),
|
|
318
407
|
endpointRow(
|
|
319
408
|
"PUT",
|
|
320
409
|
`${p}/:id`,
|
|
321
|
-
`Fully replace a ${
|
|
410
|
+
`Fully replace a ${singular2}. Original <code>id</code> is always preserved.`
|
|
322
411
|
),
|
|
323
412
|
endpointRow(
|
|
324
413
|
"PATCH",
|
|
325
414
|
`${p}/:id`,
|
|
326
|
-
`Partially update a ${
|
|
415
|
+
`Partially update a ${singular2} \u2014 only provided fields change.`
|
|
327
416
|
),
|
|
328
|
-
endpointRow("DELETE", `${p}/:id`, `Delete a ${
|
|
417
|
+
endpointRow("DELETE", `${p}/:id`, `Delete a ${singular2} and return it as confirmation.`)
|
|
329
418
|
].join("");
|
|
330
419
|
return `
|
|
331
420
|
<details class="resource-card" ${isOpen ? "open" : ""}>
|
|
@@ -338,12 +427,145 @@ function resourceAccordion(name, base, isOpen) {
|
|
|
338
427
|
</table>
|
|
339
428
|
</details>`;
|
|
340
429
|
}
|
|
430
|
+
function nestedRoutesAccordion(relations, base) {
|
|
431
|
+
const rows = [];
|
|
432
|
+
for (const [source, fields] of Object.entries(relations)) {
|
|
433
|
+
for (const [key, def] of Object.entries(fields)) {
|
|
434
|
+
const nestedBadge = def.nested ? ` ${badge("nested", "#facc15", "#facc1518")}` : "";
|
|
435
|
+
if (def.type === "many2many") {
|
|
436
|
+
const singular2 = source.endsWith("s") ? source.slice(0, -1) : source;
|
|
437
|
+
const m2mBadge = badge("many2many", "#818cf8", "#818cf818");
|
|
438
|
+
rows.push(
|
|
439
|
+
endpointRow(
|
|
440
|
+
"GET",
|
|
441
|
+
`${base}/${source}/:id/${key}`,
|
|
442
|
+
`List ${def.target} linked to a ${singular2} via ${def.through}. ${m2mBadge}${nestedBadge}`
|
|
443
|
+
)
|
|
444
|
+
);
|
|
445
|
+
const targetSingular = def.target.endsWith("s") ? def.target.slice(0, -1) : def.target;
|
|
446
|
+
rows.push(
|
|
447
|
+
endpointRow(
|
|
448
|
+
"GET",
|
|
449
|
+
`${base}/${def.target}/:id/${source}`,
|
|
450
|
+
`List ${source} linked to a ${targetSingular} via ${def.through} (inverse). ${m2mBadge}`
|
|
451
|
+
)
|
|
452
|
+
);
|
|
453
|
+
} else {
|
|
454
|
+
const path2 = `${base}/${def.target}/:id/${source}`;
|
|
455
|
+
const parentSingular = def.target.endsWith("s") ? def.target.slice(0, -1) : def.target;
|
|
456
|
+
const typeBadge = def.type === "one2one" ? ` ${badge("one2one", "#34d399", "#34d39918")}` : "";
|
|
457
|
+
rows.push(
|
|
458
|
+
endpointRow(
|
|
459
|
+
"GET",
|
|
460
|
+
path2,
|
|
461
|
+
`${def.type === "one2one" ? "Get" : "List"} ${source} belonging to a ${parentSingular}.${typeBadge}${nestedBadge}`
|
|
462
|
+
)
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
if (!rows.length) return "";
|
|
468
|
+
return `
|
|
469
|
+
<details class="resource-card nested-card">
|
|
470
|
+
<summary>
|
|
471
|
+
<span class="resource-name">Nested routes</span>
|
|
472
|
+
<span class="route-count">${rows.length} route${rows.length !== 1 ? "s" : ""}</span>
|
|
473
|
+
</summary>
|
|
474
|
+
<table><tbody>${rows.join("")}</tbody></table>
|
|
475
|
+
</details>`;
|
|
476
|
+
}
|
|
477
|
+
function snapshotAccordion() {
|
|
478
|
+
return `
|
|
479
|
+
<details class="resource-card nested-card">
|
|
480
|
+
<summary>
|
|
481
|
+
<span class="resource-name">/_snapshot</span>
|
|
482
|
+
<span class="route-count">3 routes</span>
|
|
483
|
+
</summary>
|
|
484
|
+
<table><tbody>
|
|
485
|
+
${endpointRow("GET", "/_snapshot", "Returns metadata of the current snapshot: <code>savedAt</code> and item counts per collection.")}
|
|
486
|
+
${endpointRow("POST", "/_snapshot/save", "Replaces the stored snapshot with the current database state.")}
|
|
487
|
+
${endpointRow("POST", "/_snapshot/reset", "Restores the database to the last saved snapshot and persists to disk.")}
|
|
488
|
+
</tbody></table>
|
|
489
|
+
</details>`;
|
|
490
|
+
}
|
|
491
|
+
function customRoutesAccordion(routes, base, handlers) {
|
|
492
|
+
if (!routes.length) return "";
|
|
493
|
+
const rows = routes.map((r) => {
|
|
494
|
+
const fullPath = `${base}${r.path}`;
|
|
495
|
+
const tags = [];
|
|
496
|
+
if (r.error) tags.push(`<span style="color:#f85149;font-size:11px">error\xB7${r.error}</span>`);
|
|
497
|
+
if (r.delay && r.delay > 0)
|
|
498
|
+
tags.push(`<span style="color:#fb923c;font-size:11px">delay\xB7${r.delay}ms</span>`);
|
|
499
|
+
if (r.scenarios?.length) {
|
|
500
|
+
const hasOr = r.scenarios.some((s) => Array.isArray(s.when));
|
|
501
|
+
tags.push(
|
|
502
|
+
`<span style="color:#a371f7;font-size:11px">scenarios\xB7${r.scenarios.length}${hasOr ? " (OR)" : ""}</span>`
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
if (r.otherwise)
|
|
506
|
+
tags.push(`<span style="color:var(--text-muted);font-size:11px">otherwise</span>`);
|
|
507
|
+
let desc;
|
|
508
|
+
if (r.error) {
|
|
509
|
+
desc = `Error injection \u2014 <code>${r.error}</code>`;
|
|
510
|
+
} else if (r.handler) {
|
|
511
|
+
const found = handlers.has(r.handler);
|
|
512
|
+
const handlerName = escapeHtml(r.handler);
|
|
513
|
+
desc = found ? `Handler \u2014 <code>${handlerName}()</code>` : `Handler \u2014 <code>${handlerName}()</code> <span style="color:#f85149">(not loaded)</span>`;
|
|
514
|
+
} else if (r.scenarios?.length) {
|
|
515
|
+
const hasTemplateInScenarios = r.scenarios.some((s) => s.response.body != null && hasTemplates(s.response.body)) || r.otherwise?.body != null && hasTemplates(r.otherwise.body);
|
|
516
|
+
desc = hasTemplateInScenarios ? `Scenarios \u2014 <code>{{\u2026}}</code>` : `Scenarios`;
|
|
517
|
+
} else if (r.response?.body != null && hasTemplates(r.response.body)) {
|
|
518
|
+
desc = `Dynamic \u2014 <code>{{\u2026}}</code>`;
|
|
519
|
+
} else {
|
|
520
|
+
const status = r.response?.status ?? 200;
|
|
521
|
+
desc = `Static \u2014 <code>${status}</code>${r.response?.headers ? ` + headers` : ""}`;
|
|
522
|
+
}
|
|
523
|
+
if (tags.length) desc += ` ${tags.join(" ")}`;
|
|
524
|
+
return endpointRow(r.method?.toUpperCase() ?? "GET", fullPath, desc);
|
|
525
|
+
});
|
|
526
|
+
return `
|
|
527
|
+
<details class="resource-card nested-card">
|
|
528
|
+
<summary>
|
|
529
|
+
<span class="resource-name">Custom routes</span>
|
|
530
|
+
<span class="route-count">${routes.length} route${routes.length !== 1 ? "s" : ""}</span>
|
|
531
|
+
</summary>
|
|
532
|
+
<table><tbody>
|
|
533
|
+
${rows.join("")}
|
|
534
|
+
</tbody></table>
|
|
535
|
+
</details>`;
|
|
536
|
+
}
|
|
537
|
+
function handlersAccordion(handlers, routes, base) {
|
|
538
|
+
if (!handlers.size) return "";
|
|
539
|
+
const routesByHandler = /* @__PURE__ */ new Map();
|
|
540
|
+
for (const r of routes) {
|
|
541
|
+
if (r.handler) {
|
|
542
|
+
const list = routesByHandler.get(r.handler) ?? [];
|
|
543
|
+
list.push({ method: (r.method ?? "GET").toUpperCase(), path: `${base}${r.path}` });
|
|
544
|
+
routesByHandler.set(r.handler, list);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
const rows = [...handlers.keys()].map((name) => {
|
|
548
|
+
const linked = routesByHandler.get(name);
|
|
549
|
+
const routeDesc = linked ? linked.map((r) => `<code>${r.method} ${r.path}</code>`).join(", ") : `<span style="color:var(--text-muted)">not referenced in _routes</span>`;
|
|
550
|
+
return endpointRow("fn", name + "()", routeDesc);
|
|
551
|
+
});
|
|
552
|
+
return `
|
|
553
|
+
<details class="resource-card nested-card">
|
|
554
|
+
<summary>
|
|
555
|
+
<span class="resource-name">Handlers</span>
|
|
556
|
+
<span class="route-count">${handlers.size} function${handlers.size !== 1 ? "s" : ""}</span>
|
|
557
|
+
</summary>
|
|
558
|
+
<table><tbody>
|
|
559
|
+
${rows.join("")}
|
|
560
|
+
</tbody></table>
|
|
561
|
+
</details>`;
|
|
562
|
+
}
|
|
341
563
|
function examplesBlock(collections, relations, base, host, options, firstCustomRoute) {
|
|
342
564
|
const examples = [];
|
|
343
565
|
const firstCol = collections[0];
|
|
344
566
|
if (firstCol) {
|
|
345
567
|
const p = `${host}${base}/${firstCol}`;
|
|
346
|
-
const
|
|
568
|
+
const singular2 = firstCol.endsWith("s") ? firstCol.slice(0, -1) : firstCol;
|
|
347
569
|
examples.push(
|
|
348
570
|
`# List all ${firstCol}
|
|
349
571
|
curl ${p}`,
|
|
@@ -351,52 +573,53 @@ curl ${p}`,
|
|
|
351
573
|
curl "${p}?name=value"`,
|
|
352
574
|
`# Sort and paginate
|
|
353
575
|
curl "${p}?_sort=id&_order=desc&_page=1&_limit=5"`,
|
|
354
|
-
`# Get single ${
|
|
576
|
+
`# Get single ${singular2}
|
|
355
577
|
curl ${p}/1`,
|
|
356
|
-
`# Create ${
|
|
578
|
+
`# Create ${singular2}
|
|
357
579
|
curl -X POST ${p} \\
|
|
358
580
|
-H "Content-Type: application/json" \\
|
|
359
581
|
-d '{"name":"example"}'`,
|
|
360
|
-
`# Partially update ${
|
|
582
|
+
`# Partially update ${singular2}
|
|
361
583
|
curl -X PATCH ${p}/1 \\
|
|
362
584
|
-H "Content-Type: application/json" \\
|
|
363
585
|
-d '{"name":"updated"}'`,
|
|
364
|
-
`# Delete ${
|
|
586
|
+
`# Delete ${singular2}
|
|
365
587
|
curl -X DELETE ${p}/1`
|
|
366
588
|
);
|
|
367
589
|
}
|
|
368
590
|
const firstRel = Object.entries(relations)[0];
|
|
369
591
|
if (firstRel) {
|
|
370
592
|
const [child, fields] = firstRel;
|
|
371
|
-
const
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
curl ${host}${base}/${
|
|
593
|
+
const firstField = Object.entries(fields)[0];
|
|
594
|
+
if (firstField) {
|
|
595
|
+
const [fk, def] = firstField;
|
|
596
|
+
if (def.type !== "many2many") {
|
|
597
|
+
const expandKey = fk.replace(/Id$/i, "");
|
|
598
|
+
examples.push(
|
|
599
|
+
`# Embed parent with ?_expand
|
|
600
|
+
curl "${host}${base}/${child}/1?_expand=${expandKey}"`,
|
|
601
|
+
`# Nested resource
|
|
602
|
+
curl ${host}${base}/${def.target}/1/${child}`
|
|
603
|
+
);
|
|
604
|
+
} else {
|
|
605
|
+
examples.push(`# Many-to-many embed
|
|
606
|
+
curl "${host}${base}/${child}/1/${fk}"`);
|
|
607
|
+
}
|
|
385
608
|
}
|
|
386
609
|
}
|
|
387
610
|
if (options.pageable.enabled && firstCol) {
|
|
388
611
|
examples.push(`# Pageable envelope
|
|
389
612
|
curl "${host}${base}/${firstCol}?_page=2"`);
|
|
390
613
|
}
|
|
391
|
-
const firstParentRel = Object.entries(relations).find(
|
|
392
|
-
([, fields]) => Object.values(fields).includes(firstCol ?? "")
|
|
393
|
-
);
|
|
394
614
|
if (firstCol) {
|
|
395
615
|
examples.push(
|
|
396
616
|
`# Project fields with ?_fields
|
|
397
617
|
curl "${host}${base}/${firstCol}?_fields=id,name"`
|
|
398
618
|
);
|
|
399
619
|
}
|
|
620
|
+
const firstParentRel = Object.entries(relations).find(
|
|
621
|
+
([, fields]) => Object.values(fields).some((def) => def.type !== "many2many" && def.target === firstCol)
|
|
622
|
+
);
|
|
400
623
|
if (firstParentRel && firstCol) {
|
|
401
624
|
const [childName] = firstParentRel;
|
|
402
625
|
examples.push(
|
|
@@ -422,9 +645,21 @@ curl ${curlFlag}${fullPath}`);
|
|
|
422
645
|
const highlighted = examples.map((e) => e.replace(/^(#.+)$/gm, '<span class="cm">$1</span>')).join("\n\n");
|
|
423
646
|
return `<pre>${highlighted}</pre>`;
|
|
424
647
|
}
|
|
648
|
+
|
|
649
|
+
// src/router/templates/about.template.ts
|
|
650
|
+
var _dir = dirname(fileURLToPath2(import.meta.url));
|
|
651
|
+
var LOGO_SRC = (() => {
|
|
652
|
+
try {
|
|
653
|
+
const buf = readFileSync(join(_dir, "../../assets/logo-color.png"));
|
|
654
|
+
return `data:image/png;base64,${buf.toString("base64")}`;
|
|
655
|
+
} catch {
|
|
656
|
+
return "";
|
|
657
|
+
}
|
|
658
|
+
})();
|
|
425
659
|
function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map()) {
|
|
426
660
|
const collections = Object.keys(storage.getData());
|
|
427
661
|
const relations = storage.getRelations();
|
|
662
|
+
const customRoutes = storage.getRoutes();
|
|
428
663
|
const base = options.base;
|
|
429
664
|
const host = `http://${options.host}:${options.port}`;
|
|
430
665
|
const modes = [];
|
|
@@ -438,105 +673,6 @@ function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map(
|
|
|
438
673
|
if (options.idStrategy !== "increment")
|
|
439
674
|
modes.push(badge(`id \xB7 ${options.idStrategy}`, "#a371f7", "#a371f718"));
|
|
440
675
|
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
676
|
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
677
|
return `<!DOCTYPE html>
|
|
542
678
|
<html lang="en">
|
|
@@ -684,10 +820,10 @@ function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map(
|
|
|
684
820
|
<h2>Endpoints</h2>
|
|
685
821
|
<div class="endpoints-grid">
|
|
686
822
|
${accordions}
|
|
687
|
-
${
|
|
688
|
-
${snapshotAccordion}
|
|
689
|
-
${customRoutesAccordion}
|
|
690
|
-
${handlersAccordion}
|
|
823
|
+
${nestedRoutesAccordion(relations, base)}
|
|
824
|
+
${options.snapshot ? snapshotAccordion() : ""}
|
|
825
|
+
${customRoutesAccordion(customRoutes, base, handlers)}
|
|
826
|
+
${handlersAccordion(handlers, customRoutes, base)}
|
|
691
827
|
</div>
|
|
692
828
|
|
|
693
829
|
<h2>Query Parameters</h2>
|
|
@@ -894,10 +1030,11 @@ function expandItems(input, query, resource, storage) {
|
|
|
894
1030
|
const resourceRelations = storage.getRelations()[resource] ?? {};
|
|
895
1031
|
const expansions = /* @__PURE__ */ new Map();
|
|
896
1032
|
for (const expandKey of keys) {
|
|
897
|
-
for (const [field,
|
|
1033
|
+
for (const [field, def] of Object.entries(resourceRelations)) {
|
|
1034
|
+
if (def.type === "many2many") continue;
|
|
898
1035
|
const derivedKey = field.replace(/Id$/i, "");
|
|
899
|
-
if (derivedKey === expandKey ||
|
|
900
|
-
expansions.set(expandKey, { field, parentCollection });
|
|
1036
|
+
if (derivedKey === expandKey || def.target === expandKey || def.target === `${expandKey}s`) {
|
|
1037
|
+
expansions.set(expandKey, { field, parentCollection: def.target });
|
|
901
1038
|
break;
|
|
902
1039
|
}
|
|
903
1040
|
}
|
|
@@ -926,10 +1063,24 @@ function embedItems(input, query, resource, storage) {
|
|
|
926
1063
|
const relations = storage.getRelations();
|
|
927
1064
|
const embeds = /* @__PURE__ */ new Map();
|
|
928
1065
|
for (const embedKey of keys) {
|
|
1066
|
+
const ownRelations = relations[resource] ?? {};
|
|
1067
|
+
if (embedKey in ownRelations) {
|
|
1068
|
+
const def = ownRelations[embedKey];
|
|
1069
|
+
if (def.type === "many2many") {
|
|
1070
|
+
embeds.set(embedKey, {
|
|
1071
|
+
kind: "many2many",
|
|
1072
|
+
target: def.target,
|
|
1073
|
+
through: def.through,
|
|
1074
|
+
foreignKey: def.foreignKey,
|
|
1075
|
+
otherKey: def.otherKey
|
|
1076
|
+
});
|
|
1077
|
+
continue;
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
929
1080
|
outer: for (const [childCollection, fields] of Object.entries(relations)) {
|
|
930
|
-
for (const [fkField,
|
|
931
|
-
if (
|
|
932
|
-
embeds.set(embedKey, { childCollection, fkField });
|
|
1081
|
+
for (const [fkField, def] of Object.entries(fields)) {
|
|
1082
|
+
if ((def.type === "many2one" || def.type === "one2one") && def.target === resource && childCollection === embedKey) {
|
|
1083
|
+
embeds.set(embedKey, { kind: def.type, childCollection, fkField });
|
|
933
1084
|
break outer;
|
|
934
1085
|
}
|
|
935
1086
|
}
|
|
@@ -938,10 +1089,57 @@ function embedItems(input, query, resource, storage) {
|
|
|
938
1089
|
if (embeds.size === 0) return isArray ? items : input;
|
|
939
1090
|
const result = items.map((item) => {
|
|
940
1091
|
const out = { ...item };
|
|
941
|
-
for (const [embedKey,
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
1092
|
+
for (const [embedKey, spec] of embeds) {
|
|
1093
|
+
if (spec.kind === "many2many") {
|
|
1094
|
+
const pivot = storage.getCollection(spec.through) ?? [];
|
|
1095
|
+
const matchingIds = new Set(
|
|
1096
|
+
pivot.filter((row) => String(row[spec.foreignKey]) === String(item["id"])).map((row) => String(row[spec.otherKey]))
|
|
1097
|
+
);
|
|
1098
|
+
out[embedKey] = (storage.getCollection(spec.target) ?? []).filter(
|
|
1099
|
+
(t) => matchingIds.has(String(t["id"]))
|
|
1100
|
+
);
|
|
1101
|
+
} else if (spec.kind === "one2one") {
|
|
1102
|
+
out[embedKey] = (storage.getCollection(spec.childCollection) ?? []).find(
|
|
1103
|
+
(child) => String(child[spec.fkField]) === String(item["id"])
|
|
1104
|
+
) ?? null;
|
|
1105
|
+
} else {
|
|
1106
|
+
out[embedKey] = (storage.getCollection(spec.childCollection) ?? []).filter(
|
|
1107
|
+
(child) => String(child[spec.fkField]) === String(item["id"])
|
|
1108
|
+
);
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
return out;
|
|
1112
|
+
});
|
|
1113
|
+
return isArray ? result : result[0];
|
|
1114
|
+
}
|
|
1115
|
+
function applyNested(input, resource, storage) {
|
|
1116
|
+
const isArray = Array.isArray(input);
|
|
1117
|
+
const items = isArray ? input : [input];
|
|
1118
|
+
const resourceRelations = storage.getRelations()[resource] ?? {};
|
|
1119
|
+
const nestedDefs = Object.entries(resourceRelations).filter(([, def]) => def.nested === true);
|
|
1120
|
+
if (nestedDefs.length === 0) return input;
|
|
1121
|
+
const result = items.map((item) => {
|
|
1122
|
+
const out = { ...item };
|
|
1123
|
+
for (const [key, def] of nestedDefs) {
|
|
1124
|
+
if (def.type === "many2many") {
|
|
1125
|
+
const pivot = storage.getCollection(def.through) ?? [];
|
|
1126
|
+
const matchingIds = new Set(
|
|
1127
|
+
pivot.filter((row) => String(row[def.foreignKey]) === String(item["id"])).map((row) => String(row[def.otherKey]))
|
|
1128
|
+
);
|
|
1129
|
+
out[key] = (storage.getCollection(def.target) ?? []).filter(
|
|
1130
|
+
(t) => matchingIds.has(String(t["id"]))
|
|
1131
|
+
);
|
|
1132
|
+
} else {
|
|
1133
|
+
const foreignKeyValue = item[key];
|
|
1134
|
+
if (foreignKeyValue === void 0) continue;
|
|
1135
|
+
const parent = (storage.getCollection(def.target) ?? []).find(
|
|
1136
|
+
(p) => String(p["id"]) === String(foreignKeyValue)
|
|
1137
|
+
);
|
|
1138
|
+
if (parent !== void 0) {
|
|
1139
|
+
const embedKey = key.replace(/Id$/i, "");
|
|
1140
|
+
out[embedKey] = parent;
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
945
1143
|
}
|
|
946
1144
|
return out;
|
|
947
1145
|
});
|
|
@@ -981,7 +1179,12 @@ var CollectionRouteCommand = class {
|
|
|
981
1179
|
const totalPages = Math.ceil(totalItems / limit) || 1;
|
|
982
1180
|
const data = projectFields(
|
|
983
1181
|
embedItems(
|
|
984
|
-
expandItems(
|
|
1182
|
+
expandItems(
|
|
1183
|
+
applyNested(paginate(sorted, page, limit), this.resource, this.storage),
|
|
1184
|
+
req.query,
|
|
1185
|
+
this.resource,
|
|
1186
|
+
this.storage
|
|
1187
|
+
),
|
|
985
1188
|
req.query,
|
|
986
1189
|
this.resource,
|
|
987
1190
|
this.storage
|
|
@@ -1013,7 +1216,12 @@ var CollectionRouteCommand = class {
|
|
|
1013
1216
|
}
|
|
1014
1217
|
return projectFields(
|
|
1015
1218
|
embedItems(
|
|
1016
|
-
expandItems(
|
|
1219
|
+
expandItems(
|
|
1220
|
+
applyNested(result, this.resource, this.storage),
|
|
1221
|
+
req.query,
|
|
1222
|
+
this.resource,
|
|
1223
|
+
this.storage
|
|
1224
|
+
),
|
|
1017
1225
|
req.query,
|
|
1018
1226
|
this.resource,
|
|
1019
1227
|
this.storage
|
|
@@ -1199,7 +1407,12 @@ var ItemRouteCommand = class {
|
|
|
1199
1407
|
const fields = (req.query["_fields"] ?? "").split(",").map((f) => f.trim()).filter(Boolean);
|
|
1200
1408
|
return projectFields(
|
|
1201
1409
|
embedItems(
|
|
1202
|
-
expandItems(
|
|
1410
|
+
expandItems(
|
|
1411
|
+
applyNested(item, this.resource, this.storage),
|
|
1412
|
+
req.query,
|
|
1413
|
+
this.resource,
|
|
1414
|
+
this.storage
|
|
1415
|
+
),
|
|
1203
1416
|
req.query,
|
|
1204
1417
|
this.resource,
|
|
1205
1418
|
this.storage
|
|
@@ -1243,32 +1456,441 @@ var NestedRouteCommand = class {
|
|
|
1243
1456
|
relations;
|
|
1244
1457
|
base;
|
|
1245
1458
|
register(server) {
|
|
1246
|
-
for (const [
|
|
1247
|
-
for (const [
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1459
|
+
for (const [source, fields] of Object.entries(this.relations)) {
|
|
1460
|
+
for (const [key, def] of Object.entries(fields)) {
|
|
1461
|
+
if (def.type === "many2many") {
|
|
1462
|
+
this.registerMany2Many(server, source, key, def);
|
|
1463
|
+
} else {
|
|
1464
|
+
this.registerFkRelation(server, source, key, def.target, def.type);
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
registerFkRelation(server, child, fkField, parent, type) {
|
|
1470
|
+
const collectionPath = `${this.base}/${parent}/:id/${child}`;
|
|
1471
|
+
const itemPath = `${this.base}/${parent}/:id/${child}/:childId`;
|
|
1472
|
+
server.get(collectionPath, (req, reply) => {
|
|
1473
|
+
const parentCollection = this.storage.getCollection(parent) ?? [];
|
|
1474
|
+
const parentItem = findById(parentCollection, req.params.id);
|
|
1475
|
+
if (!parentItem) return reply.status(404).send({ error: "Not found" });
|
|
1476
|
+
const all = (this.storage.getCollection(child) ?? []).filter(
|
|
1477
|
+
(item) => String(item[fkField]) === req.params.id
|
|
1478
|
+
);
|
|
1479
|
+
if (type === "one2one") return all[0] ?? reply.status(404).send({ error: "Not found" });
|
|
1480
|
+
return all;
|
|
1481
|
+
});
|
|
1482
|
+
if (type === "many2one") {
|
|
1483
|
+
server.get(itemPath, (req, reply) => {
|
|
1484
|
+
const parentCollection = this.storage.getCollection(parent) ?? [];
|
|
1485
|
+
const parentItem = findById(parentCollection, req.params.id);
|
|
1486
|
+
if (!parentItem) return reply.status(404).send({ error: "Not found" });
|
|
1487
|
+
const childItem = (this.storage.getCollection(child) ?? []).find(
|
|
1488
|
+
(item) => String(item[fkField]) === req.params.id && String(item["id"]) === req.params.childId
|
|
1489
|
+
);
|
|
1490
|
+
if (!childItem) return reply.status(404).send({ error: "Not found" });
|
|
1491
|
+
return childItem;
|
|
1492
|
+
});
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
registerMany2Many(server, source, alias, def) {
|
|
1496
|
+
server.get(`${this.base}/${source}/:id/${alias}`, (req, reply) => {
|
|
1497
|
+
const sourceCollection = this.storage.getCollection(source) ?? [];
|
|
1498
|
+
const sourceItem = findById(sourceCollection, req.params.id);
|
|
1499
|
+
if (!sourceItem) return reply.status(404).send({ error: "Not found" });
|
|
1500
|
+
const pivot = this.storage.getCollection(def.through) ?? [];
|
|
1501
|
+
const matchingIds = new Set(
|
|
1502
|
+
pivot.filter((row) => String(row[def.foreignKey]) === req.params.id).map((row) => String(row[def.otherKey]))
|
|
1503
|
+
);
|
|
1504
|
+
return (this.storage.getCollection(def.target) ?? []).filter(
|
|
1505
|
+
(t) => matchingIds.has(String(t["id"]))
|
|
1506
|
+
);
|
|
1507
|
+
});
|
|
1508
|
+
server.get(`${this.base}/${def.target}/:id/${source}`, (req, reply) => {
|
|
1509
|
+
const targetCollection = this.storage.getCollection(def.target) ?? [];
|
|
1510
|
+
const targetItem = findById(targetCollection, req.params.id);
|
|
1511
|
+
if (!targetItem) return reply.status(404).send({ error: "Not found" });
|
|
1512
|
+
const pivot = this.storage.getCollection(def.through) ?? [];
|
|
1513
|
+
const matchingIds = new Set(
|
|
1514
|
+
pivot.filter((row) => String(row[def.otherKey]) === req.params.id).map((row) => String(row[def.foreignKey]))
|
|
1515
|
+
);
|
|
1516
|
+
return (this.storage.getCollection(source) ?? []).filter(
|
|
1517
|
+
(t) => matchingIds.has(String(t["id"]))
|
|
1518
|
+
);
|
|
1519
|
+
});
|
|
1520
|
+
}
|
|
1521
|
+
};
|
|
1522
|
+
|
|
1523
|
+
// src/router/routes/openapi.routes.ts
|
|
1524
|
+
init_esm_shims();
|
|
1525
|
+
|
|
1526
|
+
// src/openapi/generateOpenApi.ts
|
|
1527
|
+
init_esm_shims();
|
|
1528
|
+
|
|
1529
|
+
// src/openapi/inferSchema.ts
|
|
1530
|
+
init_esm_shims();
|
|
1531
|
+
function buildCollectionSchema(items, fieldDefs = {}) {
|
|
1532
|
+
const sample = items.slice(0, 10);
|
|
1533
|
+
const inferredTypes = /* @__PURE__ */ new Map();
|
|
1534
|
+
for (const item of sample) {
|
|
1535
|
+
for (const [key, value] of Object.entries(item)) {
|
|
1536
|
+
if (!inferredTypes.has(key)) {
|
|
1537
|
+
inferredTypes.set(key, jsToOpenApiType(value));
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
const allFields = /* @__PURE__ */ new Set([...inferredTypes.keys(), ...Object.keys(fieldDefs)]);
|
|
1542
|
+
const properties = {};
|
|
1543
|
+
const required = [];
|
|
1544
|
+
for (const field of allFields) {
|
|
1545
|
+
const def = fieldDefs[field];
|
|
1546
|
+
const inferred = inferredTypes.get(field) ?? "string";
|
|
1547
|
+
const prop = {
|
|
1548
|
+
type: def?.type ?? inferred
|
|
1549
|
+
};
|
|
1550
|
+
if (def?.format) prop.format = def.format;
|
|
1551
|
+
if (def?.description) prop.description = def.description;
|
|
1552
|
+
if (def?.enum) prop.enum = def.enum;
|
|
1553
|
+
if (def?.default !== void 0) prop.default = def.default;
|
|
1554
|
+
properties[field] = prop;
|
|
1555
|
+
if (def?.required === true) required.push(field);
|
|
1556
|
+
}
|
|
1557
|
+
const schema = { type: "object", properties };
|
|
1558
|
+
if (required.length > 0) schema.required = required;
|
|
1559
|
+
return schema;
|
|
1560
|
+
}
|
|
1561
|
+
function jsToOpenApiType(value) {
|
|
1562
|
+
if (value === null || value === void 0) return "string";
|
|
1563
|
+
if (typeof value === "boolean") return "boolean";
|
|
1564
|
+
if (typeof value === "number") return Number.isInteger(value) ? "integer" : "number";
|
|
1565
|
+
if (Array.isArray(value)) return "array";
|
|
1566
|
+
if (typeof value === "object") return "object";
|
|
1567
|
+
return "string";
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
// src/openapi/buildPaths.ts
|
|
1571
|
+
init_esm_shims();
|
|
1572
|
+
var COLLECTION_QUERY_PARAMS = [
|
|
1573
|
+
{ name: "_page", in: "query", schema: { type: "integer" }, description: "Page number (1-based)" },
|
|
1574
|
+
{ name: "_limit", in: "query", schema: { type: "integer" }, description: "Items per page" },
|
|
1575
|
+
{ name: "_sort", in: "query", schema: { type: "string" }, description: "Field name to sort by" },
|
|
1576
|
+
{
|
|
1577
|
+
name: "_order",
|
|
1578
|
+
in: "query",
|
|
1579
|
+
schema: { type: "string", enum: ["asc", "desc"] },
|
|
1580
|
+
description: "Sort direction"
|
|
1581
|
+
},
|
|
1582
|
+
{
|
|
1583
|
+
name: "_q",
|
|
1584
|
+
in: "query",
|
|
1585
|
+
schema: { type: "string" },
|
|
1586
|
+
description: "Full-text search across all scalar fields (case-insensitive)"
|
|
1587
|
+
},
|
|
1588
|
+
{
|
|
1589
|
+
name: "_expand",
|
|
1590
|
+
in: "query",
|
|
1591
|
+
schema: { type: "string" },
|
|
1592
|
+
description: "Embed related parent object inline (e.g. ?_expand=user)"
|
|
1593
|
+
},
|
|
1594
|
+
{
|
|
1595
|
+
name: "_embed",
|
|
1596
|
+
in: "query",
|
|
1597
|
+
schema: { type: "string" },
|
|
1598
|
+
description: "Embed child collection into each item (e.g. ?_embed=posts)"
|
|
1599
|
+
},
|
|
1600
|
+
{
|
|
1601
|
+
name: "_fields",
|
|
1602
|
+
in: "query",
|
|
1603
|
+
schema: { type: "string" },
|
|
1604
|
+
description: "Comma-separated field projection (e.g. ?_fields=id,name)"
|
|
1605
|
+
}
|
|
1606
|
+
];
|
|
1607
|
+
var ID_PATH_PARAM = {
|
|
1608
|
+
name: "id",
|
|
1609
|
+
in: "path",
|
|
1610
|
+
required: true,
|
|
1611
|
+
schema: { type: "string" },
|
|
1612
|
+
description: "Item id"
|
|
1613
|
+
};
|
|
1614
|
+
function toOpenApiPath(fastifyPath) {
|
|
1615
|
+
return fastifyPath.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, "{$1}");
|
|
1616
|
+
}
|
|
1617
|
+
function extractPathParams(fastifyPath) {
|
|
1618
|
+
const matches = fastifyPath.match(/:([a-zA-Z_][a-zA-Z0-9_]*)/g) ?? [];
|
|
1619
|
+
return matches.map((m) => ({
|
|
1620
|
+
name: m.slice(1),
|
|
1621
|
+
in: "path",
|
|
1622
|
+
required: true,
|
|
1623
|
+
schema: { type: "string" }
|
|
1624
|
+
}));
|
|
1625
|
+
}
|
|
1626
|
+
function singular(name) {
|
|
1627
|
+
return name.endsWith("s") ? name.slice(0, -1) : name;
|
|
1628
|
+
}
|
|
1629
|
+
function schemaRef(name) {
|
|
1630
|
+
return { $ref: `#/components/schemas/${name}` };
|
|
1631
|
+
}
|
|
1632
|
+
function jsonContent(schema) {
|
|
1633
|
+
return { "application/json": { schema } };
|
|
1634
|
+
}
|
|
1635
|
+
function ok(schema, description = "OK") {
|
|
1636
|
+
return { description, content: jsonContent(schema) };
|
|
1637
|
+
}
|
|
1638
|
+
function buildCrudPaths(collection, base, schemaName) {
|
|
1639
|
+
const ref = schemaRef(schemaName);
|
|
1640
|
+
const tag = collection;
|
|
1641
|
+
const sing = singular(collection);
|
|
1642
|
+
const collPath = `${base}/${collection}`;
|
|
1643
|
+
const itemPath = `${base}/${collection}/{id}`;
|
|
1644
|
+
return {
|
|
1645
|
+
[collPath]: {
|
|
1646
|
+
get: {
|
|
1647
|
+
summary: `List ${collection}`,
|
|
1648
|
+
tags: [tag],
|
|
1649
|
+
parameters: COLLECTION_QUERY_PARAMS,
|
|
1650
|
+
responses: {
|
|
1651
|
+
"200": {
|
|
1652
|
+
description: "OK",
|
|
1653
|
+
content: jsonContent({ type: "array", items: ref }),
|
|
1654
|
+
headers: {
|
|
1655
|
+
"X-Total-Count": {
|
|
1656
|
+
description: "Total items (when using ?_page / ?_limit)",
|
|
1657
|
+
schema: { type: "integer" }
|
|
1658
|
+
}
|
|
1659
|
+
}
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
},
|
|
1663
|
+
post: {
|
|
1664
|
+
summary: `Create ${sing}`,
|
|
1665
|
+
tags: [tag],
|
|
1666
|
+
requestBody: { required: true, content: jsonContent(ref) },
|
|
1667
|
+
responses: { "201": ok(ref, "Created") }
|
|
1668
|
+
}
|
|
1669
|
+
},
|
|
1670
|
+
[itemPath]: {
|
|
1671
|
+
get: {
|
|
1672
|
+
summary: `Get ${sing}`,
|
|
1673
|
+
tags: [tag],
|
|
1674
|
+
parameters: [
|
|
1675
|
+
ID_PATH_PARAM,
|
|
1676
|
+
...COLLECTION_QUERY_PARAMS.filter(
|
|
1677
|
+
(p) => ["_expand", "_embed", "_fields"].includes(p.name)
|
|
1678
|
+
)
|
|
1679
|
+
],
|
|
1680
|
+
responses: { "200": ok(ref), "404": { description: "Not found" } }
|
|
1681
|
+
},
|
|
1682
|
+
put: {
|
|
1683
|
+
summary: `Replace ${sing}`,
|
|
1684
|
+
tags: [tag],
|
|
1685
|
+
parameters: [ID_PATH_PARAM],
|
|
1686
|
+
requestBody: { required: true, content: jsonContent(ref) },
|
|
1687
|
+
responses: { "200": ok(ref), "404": { description: "Not found" } }
|
|
1688
|
+
},
|
|
1689
|
+
patch: {
|
|
1690
|
+
summary: `Update ${sing}`,
|
|
1691
|
+
tags: [tag],
|
|
1692
|
+
parameters: [ID_PATH_PARAM],
|
|
1693
|
+
requestBody: { required: false, content: jsonContent(ref) },
|
|
1694
|
+
responses: { "200": ok(ref), "404": { description: "Not found" } }
|
|
1695
|
+
},
|
|
1696
|
+
delete: {
|
|
1697
|
+
summary: `Delete ${sing}`,
|
|
1698
|
+
tags: [tag],
|
|
1699
|
+
parameters: [ID_PATH_PARAM],
|
|
1700
|
+
responses: { "200": ok(ref, "Deleted item returned"), "404": { description: "Not found" } }
|
|
1701
|
+
}
|
|
1702
|
+
}
|
|
1703
|
+
};
|
|
1704
|
+
}
|
|
1705
|
+
function buildRelationPaths(relations, base) {
|
|
1706
|
+
const paths = {};
|
|
1707
|
+
for (const [source, fields] of Object.entries(relations)) {
|
|
1708
|
+
for (const [key, def] of Object.entries(fields)) {
|
|
1709
|
+
if (def.type === "many2many") {
|
|
1710
|
+
const forwardPath = `${base}/${source}/{id}/${key}`;
|
|
1711
|
+
const inversePath = `${base}/${def.target}/{id}/${source}`;
|
|
1712
|
+
paths[forwardPath] = {
|
|
1713
|
+
get: {
|
|
1714
|
+
summary: `List ${def.target} linked to ${singular(source)} via ${def.through}`,
|
|
1715
|
+
tags: [source],
|
|
1716
|
+
parameters: [ID_PATH_PARAM],
|
|
1717
|
+
responses: {
|
|
1718
|
+
"200": {
|
|
1719
|
+
description: "OK",
|
|
1720
|
+
content: jsonContent({ type: "array", items: { type: "object" } })
|
|
1721
|
+
},
|
|
1722
|
+
"404": { description: `${singular(source)} not found` }
|
|
1723
|
+
}
|
|
1724
|
+
}
|
|
1725
|
+
};
|
|
1726
|
+
paths[inversePath] = {
|
|
1727
|
+
get: {
|
|
1728
|
+
summary: `List ${source} linked to ${singular(def.target)} via ${def.through} (inverse)`,
|
|
1729
|
+
tags: [def.target],
|
|
1730
|
+
parameters: [ID_PATH_PARAM],
|
|
1731
|
+
responses: {
|
|
1732
|
+
"200": {
|
|
1733
|
+
description: "OK",
|
|
1734
|
+
content: jsonContent({ type: "array", items: { type: "object" } })
|
|
1735
|
+
},
|
|
1736
|
+
"404": { description: `${singular(def.target)} not found` }
|
|
1737
|
+
}
|
|
1738
|
+
}
|
|
1739
|
+
};
|
|
1740
|
+
} else {
|
|
1741
|
+
const parentSing = singular(def.target);
|
|
1742
|
+
const collPath = `${base}/${def.target}/{id}/${source}`;
|
|
1743
|
+
const isOne2One = def.type === "one2one";
|
|
1744
|
+
const responseSchema = isOne2One ? { type: "object" } : { type: "array", items: { type: "object" } };
|
|
1745
|
+
paths[collPath] = {
|
|
1746
|
+
get: {
|
|
1747
|
+
summary: isOne2One ? `Get ${singular(source)} belonging to ${parentSing}` : `List ${source} belonging to ${parentSing}`,
|
|
1748
|
+
tags: [def.target],
|
|
1749
|
+
parameters: [ID_PATH_PARAM],
|
|
1750
|
+
responses: {
|
|
1751
|
+
"200": { description: "OK", content: jsonContent(responseSchema) },
|
|
1752
|
+
"404": { description: `${parentSing} not found` }
|
|
1753
|
+
}
|
|
1754
|
+
}
|
|
1755
|
+
};
|
|
1756
|
+
if (!isOne2One) {
|
|
1757
|
+
const itemPath = `${base}/${def.target}/{id}/${source}/{childId}`;
|
|
1758
|
+
paths[itemPath] = {
|
|
1759
|
+
get: {
|
|
1760
|
+
summary: `Get single ${singular(source)} scoped to ${parentSing}`,
|
|
1761
|
+
tags: [def.target],
|
|
1762
|
+
parameters: [
|
|
1763
|
+
ID_PATH_PARAM,
|
|
1764
|
+
{ name: "childId", in: "path", required: true, schema: { type: "string" } }
|
|
1765
|
+
],
|
|
1766
|
+
responses: {
|
|
1767
|
+
"200": { description: "OK", content: jsonContent({ type: "object" }) },
|
|
1768
|
+
"404": { description: "Not found" }
|
|
1769
|
+
}
|
|
1770
|
+
}
|
|
1771
|
+
};
|
|
1772
|
+
}
|
|
1269
1773
|
}
|
|
1270
1774
|
}
|
|
1271
1775
|
}
|
|
1776
|
+
return paths;
|
|
1777
|
+
}
|
|
1778
|
+
function buildCustomRoutePaths(routes, base) {
|
|
1779
|
+
const paths = {};
|
|
1780
|
+
for (const route of routes) {
|
|
1781
|
+
const openApiPath = toOpenApiPath(`${base}${route.path}`);
|
|
1782
|
+
const method = route.method.toLowerCase();
|
|
1783
|
+
const pathParams = extractPathParams(route.path);
|
|
1784
|
+
const responses = {};
|
|
1785
|
+
if (route.error) {
|
|
1786
|
+
responses[String(route.error)] = { description: `Forced error ${route.error}` };
|
|
1787
|
+
} else {
|
|
1788
|
+
const statuses = /* @__PURE__ */ new Set();
|
|
1789
|
+
for (const s of route.scenarios ?? []) statuses.add(s.response.status ?? 200);
|
|
1790
|
+
if (route.otherwise) statuses.add(route.otherwise.status ?? 200);
|
|
1791
|
+
if (route.response) statuses.add(route.response.status ?? 200);
|
|
1792
|
+
if (statuses.size === 0) statuses.add(200);
|
|
1793
|
+
for (const status of statuses) {
|
|
1794
|
+
const bodySource = (route.scenarios ?? []).find((s) => (s.response.status ?? 200) === status)?.response.body ?? (route.otherwise?.status ?? 200) === status ? route.otherwise?.body : route.response?.body;
|
|
1795
|
+
responses[String(status)] = {
|
|
1796
|
+
description: status < 400 ? "OK" : "Error",
|
|
1797
|
+
...bodySource != null ? { content: jsonContent(inferResponseSchema(bodySource)) } : {}
|
|
1798
|
+
};
|
|
1799
|
+
}
|
|
1800
|
+
}
|
|
1801
|
+
const desc = route.handler ? `Handler: ${route.handler}()` : route.scenarios?.length ? `Conditional scenarios (${route.scenarios.length})` : "Custom static route";
|
|
1802
|
+
const operation = {
|
|
1803
|
+
summary: `${route.method.toUpperCase()} ${route.path}`,
|
|
1804
|
+
description: desc,
|
|
1805
|
+
tags: ["custom"],
|
|
1806
|
+
...pathParams.length > 0 ? { parameters: pathParams } : {},
|
|
1807
|
+
responses
|
|
1808
|
+
};
|
|
1809
|
+
if (!paths[openApiPath]) paths[openApiPath] = {};
|
|
1810
|
+
paths[openApiPath][method] = operation;
|
|
1811
|
+
}
|
|
1812
|
+
return paths;
|
|
1813
|
+
}
|
|
1814
|
+
function inferResponseSchema(body) {
|
|
1815
|
+
if (body === null || body === void 0) return {};
|
|
1816
|
+
if (typeof body !== "object" || Array.isArray(body)) return { type: "object" };
|
|
1817
|
+
const properties = {};
|
|
1818
|
+
for (const [key, value] of Object.entries(body)) {
|
|
1819
|
+
properties[key] = { type: jsToOpenApiType2(value) };
|
|
1820
|
+
}
|
|
1821
|
+
return { type: "object", properties };
|
|
1822
|
+
}
|
|
1823
|
+
function jsToOpenApiType2(value) {
|
|
1824
|
+
if (value === null || value === void 0) return "string";
|
|
1825
|
+
if (typeof value === "boolean") return "boolean";
|
|
1826
|
+
if (typeof value === "number") return Number.isInteger(value) ? "integer" : "number";
|
|
1827
|
+
if (Array.isArray(value)) return "array";
|
|
1828
|
+
if (typeof value === "object") return "object";
|
|
1829
|
+
return "string";
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
// src/openapi/generateOpenApi.ts
|
|
1833
|
+
function generateOpenApi(storage, options, title = "yRest API") {
|
|
1834
|
+
const collections = Object.keys(storage.getData());
|
|
1835
|
+
const relations = storage.getRelations();
|
|
1836
|
+
const schemaBlock = storage.getSchema();
|
|
1837
|
+
const customRoutes = storage.getRoutes();
|
|
1838
|
+
const base = options.base ?? "";
|
|
1839
|
+
const schemas = {};
|
|
1840
|
+
for (const collection of collections) {
|
|
1841
|
+
const items = storage.getCollection(collection) ?? [];
|
|
1842
|
+
const fieldDefs = schemaBlock[collection] ?? {};
|
|
1843
|
+
const schemaName = toSchemaName(collection);
|
|
1844
|
+
schemas[schemaName] = buildCollectionSchema(items, fieldDefs);
|
|
1845
|
+
}
|
|
1846
|
+
const paths = {};
|
|
1847
|
+
for (const collection of collections) {
|
|
1848
|
+
const schemaName = toSchemaName(collection);
|
|
1849
|
+
Object.assign(paths, buildCrudPaths(collection, base, schemaName));
|
|
1850
|
+
}
|
|
1851
|
+
Object.assign(paths, buildRelationPaths(relations, base));
|
|
1852
|
+
Object.assign(paths, buildCustomRoutePaths(customRoutes, base));
|
|
1853
|
+
return {
|
|
1854
|
+
openapi: "3.0.3",
|
|
1855
|
+
info: {
|
|
1856
|
+
title,
|
|
1857
|
+
version: "1.0.0",
|
|
1858
|
+
description: "Generated by yRest from db.yml"
|
|
1859
|
+
},
|
|
1860
|
+
servers: [
|
|
1861
|
+
{
|
|
1862
|
+
url: `http://${options.host}:${options.port}${base}`,
|
|
1863
|
+
description: "yRest mock server"
|
|
1864
|
+
}
|
|
1865
|
+
],
|
|
1866
|
+
paths,
|
|
1867
|
+
components: { schemas }
|
|
1868
|
+
};
|
|
1869
|
+
}
|
|
1870
|
+
function toSchemaName(collection) {
|
|
1871
|
+
const withoutS = collection.endsWith("s") ? collection.slice(0, -1) : collection;
|
|
1872
|
+
return withoutS.charAt(0).toUpperCase() + withoutS.slice(1);
|
|
1873
|
+
}
|
|
1874
|
+
|
|
1875
|
+
// src/router/routes/openapi.routes.ts
|
|
1876
|
+
import { stringify } from "yaml";
|
|
1877
|
+
var OpenApiRouteCommand = class {
|
|
1878
|
+
constructor(storage, options) {
|
|
1879
|
+
this.storage = storage;
|
|
1880
|
+
this.options = options;
|
|
1881
|
+
}
|
|
1882
|
+
storage;
|
|
1883
|
+
options;
|
|
1884
|
+
register(server) {
|
|
1885
|
+
server.get("/_openapi", (_req, reply) => {
|
|
1886
|
+
const doc = generateOpenApi(this.storage, this.options);
|
|
1887
|
+
reply.header("Content-Type", "text/yaml; charset=utf-8");
|
|
1888
|
+
return reply.send(stringify(doc, { lineWidth: 0, aliasDuplicateObjects: false }));
|
|
1889
|
+
});
|
|
1890
|
+
server.get("/_openapi.json", (_req, reply) => {
|
|
1891
|
+
return reply.send(generateOpenApi(this.storage, this.options));
|
|
1892
|
+
});
|
|
1893
|
+
}
|
|
1272
1894
|
};
|
|
1273
1895
|
|
|
1274
1896
|
// src/router/routes/snapshot.routes.ts
|
|
@@ -1352,6 +1974,7 @@ async function createServer(storage, options, handlers = /* @__PURE__ */ new Map
|
|
|
1352
1974
|
}
|
|
1353
1975
|
const commands = [
|
|
1354
1976
|
new AboutRouteCommand(storage, options, handlers),
|
|
1977
|
+
new OpenApiRouteCommand(storage, options),
|
|
1355
1978
|
...options.snapshot ? [new SnapshotRouteCommand(storage)] : [],
|
|
1356
1979
|
new CustomRouteCommand(storage, options.base, handlers),
|
|
1357
1980
|
...buildResourceRouteCommands(storage, options)
|
|
@@ -1431,10 +2054,12 @@ function buildOptions(opts) {
|
|
|
1431
2054
|
}
|
|
1432
2055
|
function createInMemoryStorage(data) {
|
|
1433
2056
|
const raw = data;
|
|
1434
|
-
const
|
|
2057
|
+
const RESERVED = /* @__PURE__ */ new Set(["_rel", "_routes", "_schema"]);
|
|
2058
|
+
const relations = parseRelations(raw["_rel"]);
|
|
1435
2059
|
const routes = Array.isArray(raw["_routes"]) ? raw["_routes"] : [];
|
|
2060
|
+
const schema = parseSchema(raw["_schema"]);
|
|
1436
2061
|
const collections = Object.fromEntries(
|
|
1437
|
-
Object.entries(raw).filter(([k]) => k
|
|
2062
|
+
Object.entries(raw).filter(([k]) => !RESERVED.has(k))
|
|
1438
2063
|
);
|
|
1439
2064
|
let snapshot = {
|
|
1440
2065
|
data: deepCopyData(collections),
|
|
@@ -1444,6 +2069,7 @@ function createInMemoryStorage(data) {
|
|
|
1444
2069
|
return {
|
|
1445
2070
|
getData: () => collections,
|
|
1446
2071
|
getRelations: () => relations,
|
|
2072
|
+
getSchema: () => schema,
|
|
1447
2073
|
getRoutes: () => routes,
|
|
1448
2074
|
getCollection: (name) => collections[name],
|
|
1449
2075
|
setCollection: (name, items) => {
|