@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.js
CHANGED
|
@@ -40,6 +40,92 @@ 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
|
+
|
|
93
|
+
// src/storage/parseSchema.ts
|
|
94
|
+
function parseSchema(raw) {
|
|
95
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
|
|
96
|
+
const result = {};
|
|
97
|
+
for (const [collection, fields] of Object.entries(raw)) {
|
|
98
|
+
if (!fields || typeof fields !== "object" || Array.isArray(fields)) continue;
|
|
99
|
+
result[collection] = {};
|
|
100
|
+
for (const [field, value] of Object.entries(fields)) {
|
|
101
|
+
const def = normaliseFieldDef(value);
|
|
102
|
+
if (def) result[collection][field] = def;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return result;
|
|
106
|
+
}
|
|
107
|
+
function normaliseFieldDef(value) {
|
|
108
|
+
if (value === "required") return { required: true };
|
|
109
|
+
if (value === "optional") return { required: false };
|
|
110
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
111
|
+
const v = value;
|
|
112
|
+
const def = {};
|
|
113
|
+
if (v["required"] === true || v["required"] === false) def.required = v["required"];
|
|
114
|
+
if (typeof v["type"] === "string" && ["string", "integer", "number", "boolean", "object", "array"].includes(v["type"]))
|
|
115
|
+
def.type = v["type"];
|
|
116
|
+
if (typeof v["format"] === "string") def.format = v["format"];
|
|
117
|
+
if (Array.isArray(v["enum"])) def.enum = v["enum"];
|
|
118
|
+
if (typeof v["description"] === "string") def.description = v["description"];
|
|
119
|
+
if (v["default"] !== void 0) def.default = v["default"];
|
|
120
|
+
return def;
|
|
121
|
+
}
|
|
122
|
+
var init_parseSchema = __esm({
|
|
123
|
+
"src/storage/parseSchema.ts"() {
|
|
124
|
+
"use strict";
|
|
125
|
+
init_cjs_shims();
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
|
|
43
129
|
// src/utils/deepCopy.ts
|
|
44
130
|
function deepCopyData(source) {
|
|
45
131
|
return Object.fromEntries(
|
|
@@ -60,11 +146,13 @@ __export(yrestStorage_exports, {
|
|
|
60
146
|
});
|
|
61
147
|
function createYrestStorage(filePath) {
|
|
62
148
|
const absPath = (0, import_node_path2.resolve)(filePath);
|
|
63
|
-
const raw = (0,
|
|
64
|
-
const
|
|
149
|
+
const raw = (0, import_yaml3.parse)((0, import_node_fs3.readFileSync)(absPath, "utf8")) ?? {};
|
|
150
|
+
const RESERVED = /* @__PURE__ */ new Set(["_rel", "_routes", "_schema"]);
|
|
151
|
+
const relations = parseRelations(raw["_rel"]);
|
|
65
152
|
const routes = Array.isArray(raw["_routes"]) ? raw["_routes"] : [];
|
|
153
|
+
const schema = parseSchema(raw["_schema"]);
|
|
66
154
|
const data = Object.fromEntries(
|
|
67
|
-
Object.entries(raw).filter(([key]) => key
|
|
155
|
+
Object.entries(raw).filter(([key]) => !RESERVED.has(key))
|
|
68
156
|
);
|
|
69
157
|
let snapshot = {
|
|
70
158
|
data: deepCopyData(data),
|
|
@@ -78,6 +166,9 @@ function createYrestStorage(filePath) {
|
|
|
78
166
|
getRelations() {
|
|
79
167
|
return relations;
|
|
80
168
|
},
|
|
169
|
+
getSchema() {
|
|
170
|
+
return schema;
|
|
171
|
+
},
|
|
81
172
|
getRoutes() {
|
|
82
173
|
return routes;
|
|
83
174
|
},
|
|
@@ -93,14 +184,14 @@ function createYrestStorage(filePath) {
|
|
|
93
184
|
if (routes.length > 0) payload._routes = routes;
|
|
94
185
|
Object.assign(payload, data);
|
|
95
186
|
const tmp = (0, import_node_path2.resolve)((0, import_node_path2.dirname)(absPath), `.yrest-${(0, import_node_crypto2.randomUUID)()}.tmp`);
|
|
96
|
-
(0, import_node_fs3.writeFileSync)(tmp, (0,
|
|
187
|
+
(0, import_node_fs3.writeFileSync)(tmp, (0, import_yaml3.stringify)(payload), "utf8");
|
|
97
188
|
(0, import_node_fs3.renameSync)(tmp, absPath);
|
|
98
189
|
},
|
|
99
190
|
reload() {
|
|
100
|
-
const fresh = (0,
|
|
101
|
-
const freshRelations = fresh["_rel"]
|
|
191
|
+
const fresh = (0, import_yaml3.parse)((0, import_node_fs3.readFileSync)(absPath, "utf8")) ?? {};
|
|
192
|
+
const freshRelations = parseRelations(fresh["_rel"]);
|
|
102
193
|
const freshData = Object.fromEntries(
|
|
103
|
-
Object.entries(fresh).filter(([key]) => key
|
|
194
|
+
Object.entries(fresh).filter(([key]) => !RESERVED.has(key))
|
|
104
195
|
);
|
|
105
196
|
for (const key of Object.keys(data)) delete data[key];
|
|
106
197
|
Object.assign(data, freshData);
|
|
@@ -127,7 +218,7 @@ function createYrestStorage(filePath) {
|
|
|
127
218
|
}
|
|
128
219
|
};
|
|
129
220
|
}
|
|
130
|
-
var import_node_fs3, import_node_path2, import_node_crypto2,
|
|
221
|
+
var import_node_fs3, import_node_path2, import_node_crypto2, import_yaml3;
|
|
131
222
|
var init_yrestStorage = __esm({
|
|
132
223
|
"src/storage/yrestStorage.ts"() {
|
|
133
224
|
"use strict";
|
|
@@ -135,7 +226,9 @@ var init_yrestStorage = __esm({
|
|
|
135
226
|
import_node_fs3 = require("fs");
|
|
136
227
|
import_node_path2 = require("path");
|
|
137
228
|
import_node_crypto2 = require("crypto");
|
|
138
|
-
|
|
229
|
+
import_yaml3 = require("yaml");
|
|
230
|
+
init_parseRelations();
|
|
231
|
+
init_parseSchema();
|
|
139
232
|
init_deepCopy();
|
|
140
233
|
}
|
|
141
234
|
});
|
|
@@ -198,6 +291,8 @@ function dedent(str) {
|
|
|
198
291
|
// src/api/yrestServer.ts
|
|
199
292
|
init_cjs_shims();
|
|
200
293
|
var import_node_path3 = require("path");
|
|
294
|
+
init_parseRelations();
|
|
295
|
+
init_parseSchema();
|
|
201
296
|
|
|
202
297
|
// src/utils/handlers.ts
|
|
203
298
|
init_cjs_shims();
|
|
@@ -251,6 +346,9 @@ var import_node_fs2 = require("fs");
|
|
|
251
346
|
var import_node_path = require("path");
|
|
252
347
|
var import_node_url = require("url");
|
|
253
348
|
|
|
349
|
+
// src/router/templates/about.helpers.ts
|
|
350
|
+
init_cjs_shims();
|
|
351
|
+
|
|
254
352
|
// src/utils/interpolate.ts
|
|
255
353
|
init_cjs_shims();
|
|
256
354
|
var import_node_crypto = require("crypto");
|
|
@@ -298,16 +396,7 @@ function hasTemplates(value) {
|
|
|
298
396
|
return typeof value === "string" ? value.includes("{{") : JSON.stringify(value).includes("{{");
|
|
299
397
|
}
|
|
300
398
|
|
|
301
|
-
// src/router/templates/about.
|
|
302
|
-
var _dir = (0, import_node_path.dirname)((0, import_node_url.fileURLToPath)(importMetaUrl));
|
|
303
|
-
var LOGO_SRC = (() => {
|
|
304
|
-
try {
|
|
305
|
-
const buf = (0, import_node_fs2.readFileSync)((0, import_node_path.join)(_dir, "../../assets/logo-color.png"));
|
|
306
|
-
return `data:image/png;base64,${buf.toString("base64")}`;
|
|
307
|
-
} catch {
|
|
308
|
-
return "";
|
|
309
|
-
}
|
|
310
|
-
})();
|
|
399
|
+
// src/router/templates/about.helpers.ts
|
|
311
400
|
var METHOD_COLOR = {
|
|
312
401
|
GET: "#3fb950",
|
|
313
402
|
POST: "#58a6ff",
|
|
@@ -336,7 +425,7 @@ function endpointRow(method, path, desc) {
|
|
|
336
425
|
}
|
|
337
426
|
function resourceAccordion(name, base, isOpen) {
|
|
338
427
|
const p = `${base}/${name}`;
|
|
339
|
-
const
|
|
428
|
+
const singular2 = name.endsWith("s") ? name.slice(0, -1) : name;
|
|
340
429
|
const rows = [
|
|
341
430
|
endpointRow(
|
|
342
431
|
"GET",
|
|
@@ -346,20 +435,20 @@ function resourceAccordion(name, base, isOpen) {
|
|
|
346
435
|
endpointRow(
|
|
347
436
|
"POST",
|
|
348
437
|
p,
|
|
349
|
-
`Create a new ${
|
|
438
|
+
`Create a new ${singular2}. Auto-assigns <code>id</code> if not provided.`
|
|
350
439
|
),
|
|
351
|
-
endpointRow("GET", `${p}/:id`, `Get a single ${
|
|
440
|
+
endpointRow("GET", `${p}/:id`, `Get a single ${singular2} by id.`),
|
|
352
441
|
endpointRow(
|
|
353
442
|
"PUT",
|
|
354
443
|
`${p}/:id`,
|
|
355
|
-
`Fully replace a ${
|
|
444
|
+
`Fully replace a ${singular2}. Original <code>id</code> is always preserved.`
|
|
356
445
|
),
|
|
357
446
|
endpointRow(
|
|
358
447
|
"PATCH",
|
|
359
448
|
`${p}/:id`,
|
|
360
|
-
`Partially update a ${
|
|
449
|
+
`Partially update a ${singular2} \u2014 only provided fields change.`
|
|
361
450
|
),
|
|
362
|
-
endpointRow("DELETE", `${p}/:id`, `Delete a ${
|
|
451
|
+
endpointRow("DELETE", `${p}/:id`, `Delete a ${singular2} and return it as confirmation.`)
|
|
363
452
|
].join("");
|
|
364
453
|
return `
|
|
365
454
|
<details class="resource-card" ${isOpen ? "open" : ""}>
|
|
@@ -372,12 +461,145 @@ function resourceAccordion(name, base, isOpen) {
|
|
|
372
461
|
</table>
|
|
373
462
|
</details>`;
|
|
374
463
|
}
|
|
464
|
+
function nestedRoutesAccordion(relations, base) {
|
|
465
|
+
const rows = [];
|
|
466
|
+
for (const [source, fields] of Object.entries(relations)) {
|
|
467
|
+
for (const [key, def] of Object.entries(fields)) {
|
|
468
|
+
const nestedBadge = def.nested ? ` ${badge("nested", "#facc15", "#facc1518")}` : "";
|
|
469
|
+
if (def.type === "many2many") {
|
|
470
|
+
const singular2 = source.endsWith("s") ? source.slice(0, -1) : source;
|
|
471
|
+
const m2mBadge = badge("many2many", "#818cf8", "#818cf818");
|
|
472
|
+
rows.push(
|
|
473
|
+
endpointRow(
|
|
474
|
+
"GET",
|
|
475
|
+
`${base}/${source}/:id/${key}`,
|
|
476
|
+
`List ${def.target} linked to a ${singular2} via ${def.through}. ${m2mBadge}${nestedBadge}`
|
|
477
|
+
)
|
|
478
|
+
);
|
|
479
|
+
const targetSingular = def.target.endsWith("s") ? def.target.slice(0, -1) : def.target;
|
|
480
|
+
rows.push(
|
|
481
|
+
endpointRow(
|
|
482
|
+
"GET",
|
|
483
|
+
`${base}/${def.target}/:id/${source}`,
|
|
484
|
+
`List ${source} linked to a ${targetSingular} via ${def.through} (inverse). ${m2mBadge}`
|
|
485
|
+
)
|
|
486
|
+
);
|
|
487
|
+
} else {
|
|
488
|
+
const path = `${base}/${def.target}/:id/${source}`;
|
|
489
|
+
const parentSingular = def.target.endsWith("s") ? def.target.slice(0, -1) : def.target;
|
|
490
|
+
const typeBadge = def.type === "one2one" ? ` ${badge("one2one", "#34d399", "#34d39918")}` : "";
|
|
491
|
+
rows.push(
|
|
492
|
+
endpointRow(
|
|
493
|
+
"GET",
|
|
494
|
+
path,
|
|
495
|
+
`${def.type === "one2one" ? "Get" : "List"} ${source} belonging to a ${parentSingular}.${typeBadge}${nestedBadge}`
|
|
496
|
+
)
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
if (!rows.length) return "";
|
|
502
|
+
return `
|
|
503
|
+
<details class="resource-card nested-card">
|
|
504
|
+
<summary>
|
|
505
|
+
<span class="resource-name">Nested routes</span>
|
|
506
|
+
<span class="route-count">${rows.length} route${rows.length !== 1 ? "s" : ""}</span>
|
|
507
|
+
</summary>
|
|
508
|
+
<table><tbody>${rows.join("")}</tbody></table>
|
|
509
|
+
</details>`;
|
|
510
|
+
}
|
|
511
|
+
function snapshotAccordion() {
|
|
512
|
+
return `
|
|
513
|
+
<details class="resource-card nested-card">
|
|
514
|
+
<summary>
|
|
515
|
+
<span class="resource-name">/_snapshot</span>
|
|
516
|
+
<span class="route-count">3 routes</span>
|
|
517
|
+
</summary>
|
|
518
|
+
<table><tbody>
|
|
519
|
+
${endpointRow("GET", "/_snapshot", "Returns metadata of the current snapshot: <code>savedAt</code> and item counts per collection.")}
|
|
520
|
+
${endpointRow("POST", "/_snapshot/save", "Replaces the stored snapshot with the current database state.")}
|
|
521
|
+
${endpointRow("POST", "/_snapshot/reset", "Restores the database to the last saved snapshot and persists to disk.")}
|
|
522
|
+
</tbody></table>
|
|
523
|
+
</details>`;
|
|
524
|
+
}
|
|
525
|
+
function customRoutesAccordion(routes, base, handlers) {
|
|
526
|
+
if (!routes.length) return "";
|
|
527
|
+
const rows = routes.map((r) => {
|
|
528
|
+
const fullPath = `${base}${r.path}`;
|
|
529
|
+
const tags = [];
|
|
530
|
+
if (r.error) tags.push(`<span style="color:#f85149;font-size:11px">error\xB7${r.error}</span>`);
|
|
531
|
+
if (r.delay && r.delay > 0)
|
|
532
|
+
tags.push(`<span style="color:#fb923c;font-size:11px">delay\xB7${r.delay}ms</span>`);
|
|
533
|
+
if (r.scenarios?.length) {
|
|
534
|
+
const hasOr = r.scenarios.some((s) => Array.isArray(s.when));
|
|
535
|
+
tags.push(
|
|
536
|
+
`<span style="color:#a371f7;font-size:11px">scenarios\xB7${r.scenarios.length}${hasOr ? " (OR)" : ""}</span>`
|
|
537
|
+
);
|
|
538
|
+
}
|
|
539
|
+
if (r.otherwise)
|
|
540
|
+
tags.push(`<span style="color:var(--text-muted);font-size:11px">otherwise</span>`);
|
|
541
|
+
let desc;
|
|
542
|
+
if (r.error) {
|
|
543
|
+
desc = `Error injection \u2014 <code>${r.error}</code>`;
|
|
544
|
+
} else if (r.handler) {
|
|
545
|
+
const found = handlers.has(r.handler);
|
|
546
|
+
const handlerName = escapeHtml(r.handler);
|
|
547
|
+
desc = found ? `Handler \u2014 <code>${handlerName}()</code>` : `Handler \u2014 <code>${handlerName}()</code> <span style="color:#f85149">(not loaded)</span>`;
|
|
548
|
+
} else if (r.scenarios?.length) {
|
|
549
|
+
const hasTemplateInScenarios = r.scenarios.some((s) => s.response.body != null && hasTemplates(s.response.body)) || r.otherwise?.body != null && hasTemplates(r.otherwise.body);
|
|
550
|
+
desc = hasTemplateInScenarios ? `Scenarios \u2014 <code>{{\u2026}}</code>` : `Scenarios`;
|
|
551
|
+
} else if (r.response?.body != null && hasTemplates(r.response.body)) {
|
|
552
|
+
desc = `Dynamic \u2014 <code>{{\u2026}}</code>`;
|
|
553
|
+
} else {
|
|
554
|
+
const status = r.response?.status ?? 200;
|
|
555
|
+
desc = `Static \u2014 <code>${status}</code>${r.response?.headers ? ` + headers` : ""}`;
|
|
556
|
+
}
|
|
557
|
+
if (tags.length) desc += ` ${tags.join(" ")}`;
|
|
558
|
+
return endpointRow(r.method?.toUpperCase() ?? "GET", fullPath, desc);
|
|
559
|
+
});
|
|
560
|
+
return `
|
|
561
|
+
<details class="resource-card nested-card">
|
|
562
|
+
<summary>
|
|
563
|
+
<span class="resource-name">Custom routes</span>
|
|
564
|
+
<span class="route-count">${routes.length} route${routes.length !== 1 ? "s" : ""}</span>
|
|
565
|
+
</summary>
|
|
566
|
+
<table><tbody>
|
|
567
|
+
${rows.join("")}
|
|
568
|
+
</tbody></table>
|
|
569
|
+
</details>`;
|
|
570
|
+
}
|
|
571
|
+
function handlersAccordion(handlers, routes, base) {
|
|
572
|
+
if (!handlers.size) return "";
|
|
573
|
+
const routesByHandler = /* @__PURE__ */ new Map();
|
|
574
|
+
for (const r of routes) {
|
|
575
|
+
if (r.handler) {
|
|
576
|
+
const list = routesByHandler.get(r.handler) ?? [];
|
|
577
|
+
list.push({ method: (r.method ?? "GET").toUpperCase(), path: `${base}${r.path}` });
|
|
578
|
+
routesByHandler.set(r.handler, list);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
const rows = [...handlers.keys()].map((name) => {
|
|
582
|
+
const linked = routesByHandler.get(name);
|
|
583
|
+
const routeDesc = linked ? linked.map((r) => `<code>${r.method} ${r.path}</code>`).join(", ") : `<span style="color:var(--text-muted)">not referenced in _routes</span>`;
|
|
584
|
+
return endpointRow("fn", name + "()", routeDesc);
|
|
585
|
+
});
|
|
586
|
+
return `
|
|
587
|
+
<details class="resource-card nested-card">
|
|
588
|
+
<summary>
|
|
589
|
+
<span class="resource-name">Handlers</span>
|
|
590
|
+
<span class="route-count">${handlers.size} function${handlers.size !== 1 ? "s" : ""}</span>
|
|
591
|
+
</summary>
|
|
592
|
+
<table><tbody>
|
|
593
|
+
${rows.join("")}
|
|
594
|
+
</tbody></table>
|
|
595
|
+
</details>`;
|
|
596
|
+
}
|
|
375
597
|
function examplesBlock(collections, relations, base, host, options, firstCustomRoute) {
|
|
376
598
|
const examples = [];
|
|
377
599
|
const firstCol = collections[0];
|
|
378
600
|
if (firstCol) {
|
|
379
601
|
const p = `${host}${base}/${firstCol}`;
|
|
380
|
-
const
|
|
602
|
+
const singular2 = firstCol.endsWith("s") ? firstCol.slice(0, -1) : firstCol;
|
|
381
603
|
examples.push(
|
|
382
604
|
`# List all ${firstCol}
|
|
383
605
|
curl ${p}`,
|
|
@@ -385,52 +607,53 @@ curl ${p}`,
|
|
|
385
607
|
curl "${p}?name=value"`,
|
|
386
608
|
`# Sort and paginate
|
|
387
609
|
curl "${p}?_sort=id&_order=desc&_page=1&_limit=5"`,
|
|
388
|
-
`# Get single ${
|
|
610
|
+
`# Get single ${singular2}
|
|
389
611
|
curl ${p}/1`,
|
|
390
|
-
`# Create ${
|
|
612
|
+
`# Create ${singular2}
|
|
391
613
|
curl -X POST ${p} \\
|
|
392
614
|
-H "Content-Type: application/json" \\
|
|
393
615
|
-d '{"name":"example"}'`,
|
|
394
|
-
`# Partially update ${
|
|
616
|
+
`# Partially update ${singular2}
|
|
395
617
|
curl -X PATCH ${p}/1 \\
|
|
396
618
|
-H "Content-Type: application/json" \\
|
|
397
619
|
-d '{"name":"updated"}'`,
|
|
398
|
-
`# Delete ${
|
|
620
|
+
`# Delete ${singular2}
|
|
399
621
|
curl -X DELETE ${p}/1`
|
|
400
622
|
);
|
|
401
623
|
}
|
|
402
624
|
const firstRel = Object.entries(relations)[0];
|
|
403
625
|
if (firstRel) {
|
|
404
626
|
const [child, fields] = firstRel;
|
|
405
|
-
const
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
curl ${host}${base}/${
|
|
627
|
+
const firstField = Object.entries(fields)[0];
|
|
628
|
+
if (firstField) {
|
|
629
|
+
const [fk, def] = firstField;
|
|
630
|
+
if (def.type !== "many2many") {
|
|
631
|
+
const expandKey = fk.replace(/Id$/i, "");
|
|
632
|
+
examples.push(
|
|
633
|
+
`# Embed parent with ?_expand
|
|
634
|
+
curl "${host}${base}/${child}/1?_expand=${expandKey}"`,
|
|
635
|
+
`# Nested resource
|
|
636
|
+
curl ${host}${base}/${def.target}/1/${child}`
|
|
637
|
+
);
|
|
638
|
+
} else {
|
|
639
|
+
examples.push(`# Many-to-many embed
|
|
640
|
+
curl "${host}${base}/${child}/1/${fk}"`);
|
|
641
|
+
}
|
|
419
642
|
}
|
|
420
643
|
}
|
|
421
644
|
if (options.pageable.enabled && firstCol) {
|
|
422
645
|
examples.push(`# Pageable envelope
|
|
423
646
|
curl "${host}${base}/${firstCol}?_page=2"`);
|
|
424
647
|
}
|
|
425
|
-
const firstParentRel = Object.entries(relations).find(
|
|
426
|
-
([, fields]) => Object.values(fields).includes(firstCol ?? "")
|
|
427
|
-
);
|
|
428
648
|
if (firstCol) {
|
|
429
649
|
examples.push(
|
|
430
650
|
`# Project fields with ?_fields
|
|
431
651
|
curl "${host}${base}/${firstCol}?_fields=id,name"`
|
|
432
652
|
);
|
|
433
653
|
}
|
|
654
|
+
const firstParentRel = Object.entries(relations).find(
|
|
655
|
+
([, fields]) => Object.values(fields).some((def) => def.type !== "many2many" && def.target === firstCol)
|
|
656
|
+
);
|
|
434
657
|
if (firstParentRel && firstCol) {
|
|
435
658
|
const [childName] = firstParentRel;
|
|
436
659
|
examples.push(
|
|
@@ -456,9 +679,21 @@ curl ${curlFlag}${fullPath}`);
|
|
|
456
679
|
const highlighted = examples.map((e) => e.replace(/^(#.+)$/gm, '<span class="cm">$1</span>')).join("\n\n");
|
|
457
680
|
return `<pre>${highlighted}</pre>`;
|
|
458
681
|
}
|
|
682
|
+
|
|
683
|
+
// src/router/templates/about.template.ts
|
|
684
|
+
var _dir = (0, import_node_path.dirname)((0, import_node_url.fileURLToPath)(importMetaUrl));
|
|
685
|
+
var LOGO_SRC = (() => {
|
|
686
|
+
try {
|
|
687
|
+
const buf = (0, import_node_fs2.readFileSync)((0, import_node_path.join)(_dir, "../../assets/logo-color.png"));
|
|
688
|
+
return `data:image/png;base64,${buf.toString("base64")}`;
|
|
689
|
+
} catch {
|
|
690
|
+
return "";
|
|
691
|
+
}
|
|
692
|
+
})();
|
|
459
693
|
function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map()) {
|
|
460
694
|
const collections = Object.keys(storage.getData());
|
|
461
695
|
const relations = storage.getRelations();
|
|
696
|
+
const customRoutes = storage.getRoutes();
|
|
462
697
|
const base = options.base;
|
|
463
698
|
const host = `http://${options.host}:${options.port}`;
|
|
464
699
|
const modes = [];
|
|
@@ -472,105 +707,6 @@ function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map(
|
|
|
472
707
|
if (options.idStrategy !== "increment")
|
|
473
708
|
modes.push(badge(`id \xB7 ${options.idStrategy}`, "#a371f7", "#a371f718"));
|
|
474
709
|
const accordions = collections.map((col, i) => resourceAccordion(col, base, i === 0)).join("");
|
|
475
|
-
const nestedRows = [];
|
|
476
|
-
for (const [child, fields] of Object.entries(relations)) {
|
|
477
|
-
for (const [, parent] of Object.entries(fields)) {
|
|
478
|
-
const nestedPath = `${base}/${parent}/:id/${child}`;
|
|
479
|
-
const parentSingular = parent.endsWith("s") ? parent.slice(0, -1) : parent;
|
|
480
|
-
nestedRows.push(
|
|
481
|
-
endpointRow("GET", nestedPath, `List ${child} belonging to a ${parentSingular}.`)
|
|
482
|
-
);
|
|
483
|
-
}
|
|
484
|
-
}
|
|
485
|
-
const nestedAccordion = nestedRows.length ? `
|
|
486
|
-
<details class="resource-card nested-card">
|
|
487
|
-
<summary>
|
|
488
|
-
<span class="resource-name">Nested routes</span>
|
|
489
|
-
<span class="route-count">${nestedRows.length} route${nestedRows.length !== 1 ? "s" : ""}</span>
|
|
490
|
-
</summary>
|
|
491
|
-
<table><tbody>${nestedRows.join("")}</tbody></table>
|
|
492
|
-
</details>` : "";
|
|
493
|
-
const snapshotAccordion = options.snapshot ? `
|
|
494
|
-
<details class="resource-card nested-card">
|
|
495
|
-
<summary>
|
|
496
|
-
<span class="resource-name">/_snapshot</span>
|
|
497
|
-
<span class="route-count">3 routes</span>
|
|
498
|
-
</summary>
|
|
499
|
-
<table><tbody>
|
|
500
|
-
${endpointRow("GET", "/_snapshot", "Returns metadata of the current snapshot: <code>savedAt</code> and item counts per collection.")}
|
|
501
|
-
${endpointRow("POST", "/_snapshot/save", "Replaces the stored snapshot with the current database state.")}
|
|
502
|
-
${endpointRow("POST", "/_snapshot/reset", "Restores the database to the last saved snapshot and persists to disk.")}
|
|
503
|
-
</tbody></table>
|
|
504
|
-
</details>` : "";
|
|
505
|
-
const customRoutes = storage.getRoutes();
|
|
506
|
-
const customRoutesAccordion = customRoutes.length ? `
|
|
507
|
-
<details class="resource-card nested-card">
|
|
508
|
-
<summary>
|
|
509
|
-
<span class="resource-name">Custom routes</span>
|
|
510
|
-
<span class="route-count">${customRoutes.length} route${customRoutes.length !== 1 ? "s" : ""}</span>
|
|
511
|
-
</summary>
|
|
512
|
-
<table><tbody>
|
|
513
|
-
${customRoutes.map((r) => {
|
|
514
|
-
const fullPath = `${base}${r.path}`;
|
|
515
|
-
const tags = [];
|
|
516
|
-
if (r.error) {
|
|
517
|
-
tags.push(`<span style="color:#f85149;font-size:11px">error\xB7${r.error}</span>`);
|
|
518
|
-
}
|
|
519
|
-
if (r.delay && r.delay > 0) {
|
|
520
|
-
tags.push(`<span style="color:#fb923c;font-size:11px">delay\xB7${r.delay}ms</span>`);
|
|
521
|
-
}
|
|
522
|
-
if (r.scenarios?.length) {
|
|
523
|
-
const hasOr = r.scenarios.some((s) => Array.isArray(s.when));
|
|
524
|
-
tags.push(
|
|
525
|
-
`<span style="color:#a371f7;font-size:11px">scenarios\xB7${r.scenarios.length}${hasOr ? " (OR)" : ""}</span>`
|
|
526
|
-
);
|
|
527
|
-
}
|
|
528
|
-
if (r.otherwise) {
|
|
529
|
-
tags.push(`<span style="color:var(--text-muted);font-size:11px">otherwise</span>`);
|
|
530
|
-
}
|
|
531
|
-
let desc;
|
|
532
|
-
if (r.error) {
|
|
533
|
-
desc = `Error injection \u2014 <code>${r.error}</code>`;
|
|
534
|
-
} else if (r.handler) {
|
|
535
|
-
const found = handlers.has(r.handler);
|
|
536
|
-
const handlerName = escapeHtml(r.handler);
|
|
537
|
-
desc = found ? `Handler \u2014 <code>${handlerName}()</code>` : `Handler \u2014 <code>${handlerName}()</code> <span style="color:#f85149">(not loaded)</span>`;
|
|
538
|
-
} else if (r.scenarios?.length) {
|
|
539
|
-
const hasTemplateInScenarios = r.scenarios.some((s) => s.response.body != null && hasTemplates(s.response.body)) || r.otherwise?.body != null && hasTemplates(r.otherwise.body);
|
|
540
|
-
desc = hasTemplateInScenarios ? `Scenarios \u2014 <code>{{\u2026}}</code>` : `Scenarios`;
|
|
541
|
-
} else if (r.response?.body != null && hasTemplates(r.response.body)) {
|
|
542
|
-
desc = `Dynamic \u2014 <code>{{\u2026}}</code>`;
|
|
543
|
-
} else {
|
|
544
|
-
const status = r.response?.status ?? 200;
|
|
545
|
-
desc = `Static \u2014 <code>${status}</code>${r.response?.headers ? ` + headers` : ""}`;
|
|
546
|
-
}
|
|
547
|
-
if (tags.length) desc += ` ${tags.join(" ")}`;
|
|
548
|
-
return endpointRow(r.method?.toUpperCase() ?? "GET", fullPath, desc);
|
|
549
|
-
}).join("")}
|
|
550
|
-
</tbody></table>
|
|
551
|
-
</details>` : "";
|
|
552
|
-
const routesByHandler = /* @__PURE__ */ new Map();
|
|
553
|
-
for (const r of customRoutes) {
|
|
554
|
-
if (r.handler) {
|
|
555
|
-
const list = routesByHandler.get(r.handler) ?? [];
|
|
556
|
-
list.push({ method: (r.method ?? "GET").toUpperCase(), path: `${base}${r.path}` });
|
|
557
|
-
routesByHandler.set(r.handler, list);
|
|
558
|
-
}
|
|
559
|
-
}
|
|
560
|
-
const handlersAccordion = handlers.size > 0 ? `
|
|
561
|
-
<details class="resource-card nested-card">
|
|
562
|
-
<summary>
|
|
563
|
-
<span class="resource-name">Handlers</span>
|
|
564
|
-
<span class="route-count">${handlers.size} function${handlers.size !== 1 ? "s" : ""}</span>
|
|
565
|
-
</summary>
|
|
566
|
-
<table><tbody>
|
|
567
|
-
${[...handlers.keys()].map((name) => {
|
|
568
|
-
const routes = routesByHandler.get(name);
|
|
569
|
-
const routeDesc = routes ? routes.map((r) => `<code>${r.method} ${r.path}</code>`).join(", ") : `<span style="color:var(--text-muted)">not referenced in _routes</span>`;
|
|
570
|
-
return endpointRow("fn", name + "()", routeDesc);
|
|
571
|
-
}).join("")}
|
|
572
|
-
</tbody></table>
|
|
573
|
-
</details>` : "";
|
|
574
710
|
const paginationDesc = options.pageable.enabled ? `Pageable mode active \u2014 default limit <code>${options.pageable.limit}</code>. Response wrapped in <code>{ data, pagination }</code>.` : `Returns the requested slice. <code>X-Total-Count</code> header reflects the total before pagination.`;
|
|
575
711
|
return `<!DOCTYPE html>
|
|
576
712
|
<html lang="en">
|
|
@@ -718,10 +854,10 @@ function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map(
|
|
|
718
854
|
<h2>Endpoints</h2>
|
|
719
855
|
<div class="endpoints-grid">
|
|
720
856
|
${accordions}
|
|
721
|
-
${
|
|
722
|
-
${snapshotAccordion}
|
|
723
|
-
${customRoutesAccordion}
|
|
724
|
-
${handlersAccordion}
|
|
857
|
+
${nestedRoutesAccordion(relations, base)}
|
|
858
|
+
${options.snapshot ? snapshotAccordion() : ""}
|
|
859
|
+
${customRoutesAccordion(customRoutes, base, handlers)}
|
|
860
|
+
${handlersAccordion(handlers, customRoutes, base)}
|
|
725
861
|
</div>
|
|
726
862
|
|
|
727
863
|
<h2>Query Parameters</h2>
|
|
@@ -928,10 +1064,11 @@ function expandItems(input, query, resource, storage) {
|
|
|
928
1064
|
const resourceRelations = storage.getRelations()[resource] ?? {};
|
|
929
1065
|
const expansions = /* @__PURE__ */ new Map();
|
|
930
1066
|
for (const expandKey of keys) {
|
|
931
|
-
for (const [field,
|
|
1067
|
+
for (const [field, def] of Object.entries(resourceRelations)) {
|
|
1068
|
+
if (def.type === "many2many") continue;
|
|
932
1069
|
const derivedKey = field.replace(/Id$/i, "");
|
|
933
|
-
if (derivedKey === expandKey ||
|
|
934
|
-
expansions.set(expandKey, { field, parentCollection });
|
|
1070
|
+
if (derivedKey === expandKey || def.target === expandKey || def.target === `${expandKey}s`) {
|
|
1071
|
+
expansions.set(expandKey, { field, parentCollection: def.target });
|
|
935
1072
|
break;
|
|
936
1073
|
}
|
|
937
1074
|
}
|
|
@@ -960,10 +1097,24 @@ function embedItems(input, query, resource, storage) {
|
|
|
960
1097
|
const relations = storage.getRelations();
|
|
961
1098
|
const embeds = /* @__PURE__ */ new Map();
|
|
962
1099
|
for (const embedKey of keys) {
|
|
1100
|
+
const ownRelations = relations[resource] ?? {};
|
|
1101
|
+
if (embedKey in ownRelations) {
|
|
1102
|
+
const def = ownRelations[embedKey];
|
|
1103
|
+
if (def.type === "many2many") {
|
|
1104
|
+
embeds.set(embedKey, {
|
|
1105
|
+
kind: "many2many",
|
|
1106
|
+
target: def.target,
|
|
1107
|
+
through: def.through,
|
|
1108
|
+
foreignKey: def.foreignKey,
|
|
1109
|
+
otherKey: def.otherKey
|
|
1110
|
+
});
|
|
1111
|
+
continue;
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
963
1114
|
outer: for (const [childCollection, fields] of Object.entries(relations)) {
|
|
964
|
-
for (const [fkField,
|
|
965
|
-
if (
|
|
966
|
-
embeds.set(embedKey, { childCollection, fkField });
|
|
1115
|
+
for (const [fkField, def] of Object.entries(fields)) {
|
|
1116
|
+
if ((def.type === "many2one" || def.type === "one2one") && def.target === resource && childCollection === embedKey) {
|
|
1117
|
+
embeds.set(embedKey, { kind: def.type, childCollection, fkField });
|
|
967
1118
|
break outer;
|
|
968
1119
|
}
|
|
969
1120
|
}
|
|
@@ -972,10 +1123,57 @@ function embedItems(input, query, resource, storage) {
|
|
|
972
1123
|
if (embeds.size === 0) return isArray ? items : input;
|
|
973
1124
|
const result = items.map((item) => {
|
|
974
1125
|
const out = { ...item };
|
|
975
|
-
for (const [embedKey,
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
1126
|
+
for (const [embedKey, spec] of embeds) {
|
|
1127
|
+
if (spec.kind === "many2many") {
|
|
1128
|
+
const pivot = storage.getCollection(spec.through) ?? [];
|
|
1129
|
+
const matchingIds = new Set(
|
|
1130
|
+
pivot.filter((row) => String(row[spec.foreignKey]) === String(item["id"])).map((row) => String(row[spec.otherKey]))
|
|
1131
|
+
);
|
|
1132
|
+
out[embedKey] = (storage.getCollection(spec.target) ?? []).filter(
|
|
1133
|
+
(t) => matchingIds.has(String(t["id"]))
|
|
1134
|
+
);
|
|
1135
|
+
} else if (spec.kind === "one2one") {
|
|
1136
|
+
out[embedKey] = (storage.getCollection(spec.childCollection) ?? []).find(
|
|
1137
|
+
(child) => String(child[spec.fkField]) === String(item["id"])
|
|
1138
|
+
) ?? null;
|
|
1139
|
+
} else {
|
|
1140
|
+
out[embedKey] = (storage.getCollection(spec.childCollection) ?? []).filter(
|
|
1141
|
+
(child) => String(child[spec.fkField]) === String(item["id"])
|
|
1142
|
+
);
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
return out;
|
|
1146
|
+
});
|
|
1147
|
+
return isArray ? result : result[0];
|
|
1148
|
+
}
|
|
1149
|
+
function applyNested(input, resource, storage) {
|
|
1150
|
+
const isArray = Array.isArray(input);
|
|
1151
|
+
const items = isArray ? input : [input];
|
|
1152
|
+
const resourceRelations = storage.getRelations()[resource] ?? {};
|
|
1153
|
+
const nestedDefs = Object.entries(resourceRelations).filter(([, def]) => def.nested === true);
|
|
1154
|
+
if (nestedDefs.length === 0) return input;
|
|
1155
|
+
const result = items.map((item) => {
|
|
1156
|
+
const out = { ...item };
|
|
1157
|
+
for (const [key, def] of nestedDefs) {
|
|
1158
|
+
if (def.type === "many2many") {
|
|
1159
|
+
const pivot = storage.getCollection(def.through) ?? [];
|
|
1160
|
+
const matchingIds = new Set(
|
|
1161
|
+
pivot.filter((row) => String(row[def.foreignKey]) === String(item["id"])).map((row) => String(row[def.otherKey]))
|
|
1162
|
+
);
|
|
1163
|
+
out[key] = (storage.getCollection(def.target) ?? []).filter(
|
|
1164
|
+
(t) => matchingIds.has(String(t["id"]))
|
|
1165
|
+
);
|
|
1166
|
+
} else {
|
|
1167
|
+
const foreignKeyValue = item[key];
|
|
1168
|
+
if (foreignKeyValue === void 0) continue;
|
|
1169
|
+
const parent = (storage.getCollection(def.target) ?? []).find(
|
|
1170
|
+
(p) => String(p["id"]) === String(foreignKeyValue)
|
|
1171
|
+
);
|
|
1172
|
+
if (parent !== void 0) {
|
|
1173
|
+
const embedKey = key.replace(/Id$/i, "");
|
|
1174
|
+
out[embedKey] = parent;
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
979
1177
|
}
|
|
980
1178
|
return out;
|
|
981
1179
|
});
|
|
@@ -1015,7 +1213,12 @@ var CollectionRouteCommand = class {
|
|
|
1015
1213
|
const totalPages = Math.ceil(totalItems / limit) || 1;
|
|
1016
1214
|
const data = projectFields(
|
|
1017
1215
|
embedItems(
|
|
1018
|
-
expandItems(
|
|
1216
|
+
expandItems(
|
|
1217
|
+
applyNested(paginate(sorted, page, limit), this.resource, this.storage),
|
|
1218
|
+
req.query,
|
|
1219
|
+
this.resource,
|
|
1220
|
+
this.storage
|
|
1221
|
+
),
|
|
1019
1222
|
req.query,
|
|
1020
1223
|
this.resource,
|
|
1021
1224
|
this.storage
|
|
@@ -1047,7 +1250,12 @@ var CollectionRouteCommand = class {
|
|
|
1047
1250
|
}
|
|
1048
1251
|
return projectFields(
|
|
1049
1252
|
embedItems(
|
|
1050
|
-
expandItems(
|
|
1253
|
+
expandItems(
|
|
1254
|
+
applyNested(result, this.resource, this.storage),
|
|
1255
|
+
req.query,
|
|
1256
|
+
this.resource,
|
|
1257
|
+
this.storage
|
|
1258
|
+
),
|
|
1051
1259
|
req.query,
|
|
1052
1260
|
this.resource,
|
|
1053
1261
|
this.storage
|
|
@@ -1233,7 +1441,12 @@ var ItemRouteCommand = class {
|
|
|
1233
1441
|
const fields = (req.query["_fields"] ?? "").split(",").map((f) => f.trim()).filter(Boolean);
|
|
1234
1442
|
return projectFields(
|
|
1235
1443
|
embedItems(
|
|
1236
|
-
expandItems(
|
|
1444
|
+
expandItems(
|
|
1445
|
+
applyNested(item, this.resource, this.storage),
|
|
1446
|
+
req.query,
|
|
1447
|
+
this.resource,
|
|
1448
|
+
this.storage
|
|
1449
|
+
),
|
|
1237
1450
|
req.query,
|
|
1238
1451
|
this.resource,
|
|
1239
1452
|
this.storage
|
|
@@ -1277,32 +1490,441 @@ var NestedRouteCommand = class {
|
|
|
1277
1490
|
relations;
|
|
1278
1491
|
base;
|
|
1279
1492
|
register(server) {
|
|
1280
|
-
for (const [
|
|
1281
|
-
for (const [
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1493
|
+
for (const [source, fields] of Object.entries(this.relations)) {
|
|
1494
|
+
for (const [key, def] of Object.entries(fields)) {
|
|
1495
|
+
if (def.type === "many2many") {
|
|
1496
|
+
this.registerMany2Many(server, source, key, def);
|
|
1497
|
+
} else {
|
|
1498
|
+
this.registerFkRelation(server, source, key, def.target, def.type);
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
registerFkRelation(server, child, fkField, parent, type) {
|
|
1504
|
+
const collectionPath = `${this.base}/${parent}/:id/${child}`;
|
|
1505
|
+
const itemPath = `${this.base}/${parent}/:id/${child}/:childId`;
|
|
1506
|
+
server.get(collectionPath, (req, reply) => {
|
|
1507
|
+
const parentCollection = this.storage.getCollection(parent) ?? [];
|
|
1508
|
+
const parentItem = findById(parentCollection, req.params.id);
|
|
1509
|
+
if (!parentItem) return reply.status(404).send({ error: "Not found" });
|
|
1510
|
+
const all = (this.storage.getCollection(child) ?? []).filter(
|
|
1511
|
+
(item) => String(item[fkField]) === req.params.id
|
|
1512
|
+
);
|
|
1513
|
+
if (type === "one2one") return all[0] ?? reply.status(404).send({ error: "Not found" });
|
|
1514
|
+
return all;
|
|
1515
|
+
});
|
|
1516
|
+
if (type === "many2one") {
|
|
1517
|
+
server.get(itemPath, (req, reply) => {
|
|
1518
|
+
const parentCollection = this.storage.getCollection(parent) ?? [];
|
|
1519
|
+
const parentItem = findById(parentCollection, req.params.id);
|
|
1520
|
+
if (!parentItem) return reply.status(404).send({ error: "Not found" });
|
|
1521
|
+
const childItem = (this.storage.getCollection(child) ?? []).find(
|
|
1522
|
+
(item) => String(item[fkField]) === req.params.id && String(item["id"]) === req.params.childId
|
|
1523
|
+
);
|
|
1524
|
+
if (!childItem) return reply.status(404).send({ error: "Not found" });
|
|
1525
|
+
return childItem;
|
|
1526
|
+
});
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
registerMany2Many(server, source, alias, def) {
|
|
1530
|
+
server.get(`${this.base}/${source}/:id/${alias}`, (req, reply) => {
|
|
1531
|
+
const sourceCollection = this.storage.getCollection(source) ?? [];
|
|
1532
|
+
const sourceItem = findById(sourceCollection, req.params.id);
|
|
1533
|
+
if (!sourceItem) return reply.status(404).send({ error: "Not found" });
|
|
1534
|
+
const pivot = this.storage.getCollection(def.through) ?? [];
|
|
1535
|
+
const matchingIds = new Set(
|
|
1536
|
+
pivot.filter((row) => String(row[def.foreignKey]) === req.params.id).map((row) => String(row[def.otherKey]))
|
|
1537
|
+
);
|
|
1538
|
+
return (this.storage.getCollection(def.target) ?? []).filter(
|
|
1539
|
+
(t) => matchingIds.has(String(t["id"]))
|
|
1540
|
+
);
|
|
1541
|
+
});
|
|
1542
|
+
server.get(`${this.base}/${def.target}/:id/${source}`, (req, reply) => {
|
|
1543
|
+
const targetCollection = this.storage.getCollection(def.target) ?? [];
|
|
1544
|
+
const targetItem = findById(targetCollection, req.params.id);
|
|
1545
|
+
if (!targetItem) return reply.status(404).send({ error: "Not found" });
|
|
1546
|
+
const pivot = this.storage.getCollection(def.through) ?? [];
|
|
1547
|
+
const matchingIds = new Set(
|
|
1548
|
+
pivot.filter((row) => String(row[def.otherKey]) === req.params.id).map((row) => String(row[def.foreignKey]))
|
|
1549
|
+
);
|
|
1550
|
+
return (this.storage.getCollection(source) ?? []).filter(
|
|
1551
|
+
(t) => matchingIds.has(String(t["id"]))
|
|
1552
|
+
);
|
|
1553
|
+
});
|
|
1554
|
+
}
|
|
1555
|
+
};
|
|
1556
|
+
|
|
1557
|
+
// src/router/routes/openapi.routes.ts
|
|
1558
|
+
init_cjs_shims();
|
|
1559
|
+
|
|
1560
|
+
// src/openapi/generateOpenApi.ts
|
|
1561
|
+
init_cjs_shims();
|
|
1562
|
+
|
|
1563
|
+
// src/openapi/inferSchema.ts
|
|
1564
|
+
init_cjs_shims();
|
|
1565
|
+
function buildCollectionSchema(items, fieldDefs = {}) {
|
|
1566
|
+
const sample = items.slice(0, 10);
|
|
1567
|
+
const inferredTypes = /* @__PURE__ */ new Map();
|
|
1568
|
+
for (const item of sample) {
|
|
1569
|
+
for (const [key, value] of Object.entries(item)) {
|
|
1570
|
+
if (!inferredTypes.has(key)) {
|
|
1571
|
+
inferredTypes.set(key, jsToOpenApiType(value));
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
const allFields = /* @__PURE__ */ new Set([...inferredTypes.keys(), ...Object.keys(fieldDefs)]);
|
|
1576
|
+
const properties = {};
|
|
1577
|
+
const required = [];
|
|
1578
|
+
for (const field of allFields) {
|
|
1579
|
+
const def = fieldDefs[field];
|
|
1580
|
+
const inferred = inferredTypes.get(field) ?? "string";
|
|
1581
|
+
const prop = {
|
|
1582
|
+
type: def?.type ?? inferred
|
|
1583
|
+
};
|
|
1584
|
+
if (def?.format) prop.format = def.format;
|
|
1585
|
+
if (def?.description) prop.description = def.description;
|
|
1586
|
+
if (def?.enum) prop.enum = def.enum;
|
|
1587
|
+
if (def?.default !== void 0) prop.default = def.default;
|
|
1588
|
+
properties[field] = prop;
|
|
1589
|
+
if (def?.required === true) required.push(field);
|
|
1590
|
+
}
|
|
1591
|
+
const schema = { type: "object", properties };
|
|
1592
|
+
if (required.length > 0) schema.required = required;
|
|
1593
|
+
return schema;
|
|
1594
|
+
}
|
|
1595
|
+
function jsToOpenApiType(value) {
|
|
1596
|
+
if (value === null || value === void 0) return "string";
|
|
1597
|
+
if (typeof value === "boolean") return "boolean";
|
|
1598
|
+
if (typeof value === "number") return Number.isInteger(value) ? "integer" : "number";
|
|
1599
|
+
if (Array.isArray(value)) return "array";
|
|
1600
|
+
if (typeof value === "object") return "object";
|
|
1601
|
+
return "string";
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
// src/openapi/buildPaths.ts
|
|
1605
|
+
init_cjs_shims();
|
|
1606
|
+
var COLLECTION_QUERY_PARAMS = [
|
|
1607
|
+
{ name: "_page", in: "query", schema: { type: "integer" }, description: "Page number (1-based)" },
|
|
1608
|
+
{ name: "_limit", in: "query", schema: { type: "integer" }, description: "Items per page" },
|
|
1609
|
+
{ name: "_sort", in: "query", schema: { type: "string" }, description: "Field name to sort by" },
|
|
1610
|
+
{
|
|
1611
|
+
name: "_order",
|
|
1612
|
+
in: "query",
|
|
1613
|
+
schema: { type: "string", enum: ["asc", "desc"] },
|
|
1614
|
+
description: "Sort direction"
|
|
1615
|
+
},
|
|
1616
|
+
{
|
|
1617
|
+
name: "_q",
|
|
1618
|
+
in: "query",
|
|
1619
|
+
schema: { type: "string" },
|
|
1620
|
+
description: "Full-text search across all scalar fields (case-insensitive)"
|
|
1621
|
+
},
|
|
1622
|
+
{
|
|
1623
|
+
name: "_expand",
|
|
1624
|
+
in: "query",
|
|
1625
|
+
schema: { type: "string" },
|
|
1626
|
+
description: "Embed related parent object inline (e.g. ?_expand=user)"
|
|
1627
|
+
},
|
|
1628
|
+
{
|
|
1629
|
+
name: "_embed",
|
|
1630
|
+
in: "query",
|
|
1631
|
+
schema: { type: "string" },
|
|
1632
|
+
description: "Embed child collection into each item (e.g. ?_embed=posts)"
|
|
1633
|
+
},
|
|
1634
|
+
{
|
|
1635
|
+
name: "_fields",
|
|
1636
|
+
in: "query",
|
|
1637
|
+
schema: { type: "string" },
|
|
1638
|
+
description: "Comma-separated field projection (e.g. ?_fields=id,name)"
|
|
1639
|
+
}
|
|
1640
|
+
];
|
|
1641
|
+
var ID_PATH_PARAM = {
|
|
1642
|
+
name: "id",
|
|
1643
|
+
in: "path",
|
|
1644
|
+
required: true,
|
|
1645
|
+
schema: { type: "string" },
|
|
1646
|
+
description: "Item id"
|
|
1647
|
+
};
|
|
1648
|
+
function toOpenApiPath(fastifyPath) {
|
|
1649
|
+
return fastifyPath.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, "{$1}");
|
|
1650
|
+
}
|
|
1651
|
+
function extractPathParams(fastifyPath) {
|
|
1652
|
+
const matches = fastifyPath.match(/:([a-zA-Z_][a-zA-Z0-9_]*)/g) ?? [];
|
|
1653
|
+
return matches.map((m) => ({
|
|
1654
|
+
name: m.slice(1),
|
|
1655
|
+
in: "path",
|
|
1656
|
+
required: true,
|
|
1657
|
+
schema: { type: "string" }
|
|
1658
|
+
}));
|
|
1659
|
+
}
|
|
1660
|
+
function singular(name) {
|
|
1661
|
+
return name.endsWith("s") ? name.slice(0, -1) : name;
|
|
1662
|
+
}
|
|
1663
|
+
function schemaRef(name) {
|
|
1664
|
+
return { $ref: `#/components/schemas/${name}` };
|
|
1665
|
+
}
|
|
1666
|
+
function jsonContent(schema) {
|
|
1667
|
+
return { "application/json": { schema } };
|
|
1668
|
+
}
|
|
1669
|
+
function ok(schema, description = "OK") {
|
|
1670
|
+
return { description, content: jsonContent(schema) };
|
|
1671
|
+
}
|
|
1672
|
+
function buildCrudPaths(collection, base, schemaName) {
|
|
1673
|
+
const ref = schemaRef(schemaName);
|
|
1674
|
+
const tag = collection;
|
|
1675
|
+
const sing = singular(collection);
|
|
1676
|
+
const collPath = `${base}/${collection}`;
|
|
1677
|
+
const itemPath = `${base}/${collection}/{id}`;
|
|
1678
|
+
return {
|
|
1679
|
+
[collPath]: {
|
|
1680
|
+
get: {
|
|
1681
|
+
summary: `List ${collection}`,
|
|
1682
|
+
tags: [tag],
|
|
1683
|
+
parameters: COLLECTION_QUERY_PARAMS,
|
|
1684
|
+
responses: {
|
|
1685
|
+
"200": {
|
|
1686
|
+
description: "OK",
|
|
1687
|
+
content: jsonContent({ type: "array", items: ref }),
|
|
1688
|
+
headers: {
|
|
1689
|
+
"X-Total-Count": {
|
|
1690
|
+
description: "Total items (when using ?_page / ?_limit)",
|
|
1691
|
+
schema: { type: "integer" }
|
|
1692
|
+
}
|
|
1693
|
+
}
|
|
1694
|
+
}
|
|
1695
|
+
}
|
|
1696
|
+
},
|
|
1697
|
+
post: {
|
|
1698
|
+
summary: `Create ${sing}`,
|
|
1699
|
+
tags: [tag],
|
|
1700
|
+
requestBody: { required: true, content: jsonContent(ref) },
|
|
1701
|
+
responses: { "201": ok(ref, "Created") }
|
|
1702
|
+
}
|
|
1703
|
+
},
|
|
1704
|
+
[itemPath]: {
|
|
1705
|
+
get: {
|
|
1706
|
+
summary: `Get ${sing}`,
|
|
1707
|
+
tags: [tag],
|
|
1708
|
+
parameters: [
|
|
1709
|
+
ID_PATH_PARAM,
|
|
1710
|
+
...COLLECTION_QUERY_PARAMS.filter(
|
|
1711
|
+
(p) => ["_expand", "_embed", "_fields"].includes(p.name)
|
|
1712
|
+
)
|
|
1713
|
+
],
|
|
1714
|
+
responses: { "200": ok(ref), "404": { description: "Not found" } }
|
|
1715
|
+
},
|
|
1716
|
+
put: {
|
|
1717
|
+
summary: `Replace ${sing}`,
|
|
1718
|
+
tags: [tag],
|
|
1719
|
+
parameters: [ID_PATH_PARAM],
|
|
1720
|
+
requestBody: { required: true, content: jsonContent(ref) },
|
|
1721
|
+
responses: { "200": ok(ref), "404": { description: "Not found" } }
|
|
1722
|
+
},
|
|
1723
|
+
patch: {
|
|
1724
|
+
summary: `Update ${sing}`,
|
|
1725
|
+
tags: [tag],
|
|
1726
|
+
parameters: [ID_PATH_PARAM],
|
|
1727
|
+
requestBody: { required: false, content: jsonContent(ref) },
|
|
1728
|
+
responses: { "200": ok(ref), "404": { description: "Not found" } }
|
|
1729
|
+
},
|
|
1730
|
+
delete: {
|
|
1731
|
+
summary: `Delete ${sing}`,
|
|
1732
|
+
tags: [tag],
|
|
1733
|
+
parameters: [ID_PATH_PARAM],
|
|
1734
|
+
responses: { "200": ok(ref, "Deleted item returned"), "404": { description: "Not found" } }
|
|
1735
|
+
}
|
|
1736
|
+
}
|
|
1737
|
+
};
|
|
1738
|
+
}
|
|
1739
|
+
function buildRelationPaths(relations, base) {
|
|
1740
|
+
const paths = {};
|
|
1741
|
+
for (const [source, fields] of Object.entries(relations)) {
|
|
1742
|
+
for (const [key, def] of Object.entries(fields)) {
|
|
1743
|
+
if (def.type === "many2many") {
|
|
1744
|
+
const forwardPath = `${base}/${source}/{id}/${key}`;
|
|
1745
|
+
const inversePath = `${base}/${def.target}/{id}/${source}`;
|
|
1746
|
+
paths[forwardPath] = {
|
|
1747
|
+
get: {
|
|
1748
|
+
summary: `List ${def.target} linked to ${singular(source)} via ${def.through}`,
|
|
1749
|
+
tags: [source],
|
|
1750
|
+
parameters: [ID_PATH_PARAM],
|
|
1751
|
+
responses: {
|
|
1752
|
+
"200": {
|
|
1753
|
+
description: "OK",
|
|
1754
|
+
content: jsonContent({ type: "array", items: { type: "object" } })
|
|
1755
|
+
},
|
|
1756
|
+
"404": { description: `${singular(source)} not found` }
|
|
1757
|
+
}
|
|
1758
|
+
}
|
|
1759
|
+
};
|
|
1760
|
+
paths[inversePath] = {
|
|
1761
|
+
get: {
|
|
1762
|
+
summary: `List ${source} linked to ${singular(def.target)} via ${def.through} (inverse)`,
|
|
1763
|
+
tags: [def.target],
|
|
1764
|
+
parameters: [ID_PATH_PARAM],
|
|
1765
|
+
responses: {
|
|
1766
|
+
"200": {
|
|
1767
|
+
description: "OK",
|
|
1768
|
+
content: jsonContent({ type: "array", items: { type: "object" } })
|
|
1769
|
+
},
|
|
1770
|
+
"404": { description: `${singular(def.target)} not found` }
|
|
1771
|
+
}
|
|
1772
|
+
}
|
|
1773
|
+
};
|
|
1774
|
+
} else {
|
|
1775
|
+
const parentSing = singular(def.target);
|
|
1776
|
+
const collPath = `${base}/${def.target}/{id}/${source}`;
|
|
1777
|
+
const isOne2One = def.type === "one2one";
|
|
1778
|
+
const responseSchema = isOne2One ? { type: "object" } : { type: "array", items: { type: "object" } };
|
|
1779
|
+
paths[collPath] = {
|
|
1780
|
+
get: {
|
|
1781
|
+
summary: isOne2One ? `Get ${singular(source)} belonging to ${parentSing}` : `List ${source} belonging to ${parentSing}`,
|
|
1782
|
+
tags: [def.target],
|
|
1783
|
+
parameters: [ID_PATH_PARAM],
|
|
1784
|
+
responses: {
|
|
1785
|
+
"200": { description: "OK", content: jsonContent(responseSchema) },
|
|
1786
|
+
"404": { description: `${parentSing} not found` }
|
|
1787
|
+
}
|
|
1788
|
+
}
|
|
1789
|
+
};
|
|
1790
|
+
if (!isOne2One) {
|
|
1791
|
+
const itemPath = `${base}/${def.target}/{id}/${source}/{childId}`;
|
|
1792
|
+
paths[itemPath] = {
|
|
1793
|
+
get: {
|
|
1794
|
+
summary: `Get single ${singular(source)} scoped to ${parentSing}`,
|
|
1795
|
+
tags: [def.target],
|
|
1796
|
+
parameters: [
|
|
1797
|
+
ID_PATH_PARAM,
|
|
1798
|
+
{ name: "childId", in: "path", required: true, schema: { type: "string" } }
|
|
1799
|
+
],
|
|
1800
|
+
responses: {
|
|
1801
|
+
"200": { description: "OK", content: jsonContent({ type: "object" }) },
|
|
1802
|
+
"404": { description: "Not found" }
|
|
1803
|
+
}
|
|
1804
|
+
}
|
|
1805
|
+
};
|
|
1806
|
+
}
|
|
1303
1807
|
}
|
|
1304
1808
|
}
|
|
1305
1809
|
}
|
|
1810
|
+
return paths;
|
|
1811
|
+
}
|
|
1812
|
+
function buildCustomRoutePaths(routes, base) {
|
|
1813
|
+
const paths = {};
|
|
1814
|
+
for (const route of routes) {
|
|
1815
|
+
const openApiPath = toOpenApiPath(`${base}${route.path}`);
|
|
1816
|
+
const method = route.method.toLowerCase();
|
|
1817
|
+
const pathParams = extractPathParams(route.path);
|
|
1818
|
+
const responses = {};
|
|
1819
|
+
if (route.error) {
|
|
1820
|
+
responses[String(route.error)] = { description: `Forced error ${route.error}` };
|
|
1821
|
+
} else {
|
|
1822
|
+
const statuses = /* @__PURE__ */ new Set();
|
|
1823
|
+
for (const s of route.scenarios ?? []) statuses.add(s.response.status ?? 200);
|
|
1824
|
+
if (route.otherwise) statuses.add(route.otherwise.status ?? 200);
|
|
1825
|
+
if (route.response) statuses.add(route.response.status ?? 200);
|
|
1826
|
+
if (statuses.size === 0) statuses.add(200);
|
|
1827
|
+
for (const status of statuses) {
|
|
1828
|
+
const bodySource = (route.scenarios ?? []).find((s) => (s.response.status ?? 200) === status)?.response.body ?? (route.otherwise?.status ?? 200) === status ? route.otherwise?.body : route.response?.body;
|
|
1829
|
+
responses[String(status)] = {
|
|
1830
|
+
description: status < 400 ? "OK" : "Error",
|
|
1831
|
+
...bodySource != null ? { content: jsonContent(inferResponseSchema(bodySource)) } : {}
|
|
1832
|
+
};
|
|
1833
|
+
}
|
|
1834
|
+
}
|
|
1835
|
+
const desc = route.handler ? `Handler: ${route.handler}()` : route.scenarios?.length ? `Conditional scenarios (${route.scenarios.length})` : "Custom static route";
|
|
1836
|
+
const operation = {
|
|
1837
|
+
summary: `${route.method.toUpperCase()} ${route.path}`,
|
|
1838
|
+
description: desc,
|
|
1839
|
+
tags: ["custom"],
|
|
1840
|
+
...pathParams.length > 0 ? { parameters: pathParams } : {},
|
|
1841
|
+
responses
|
|
1842
|
+
};
|
|
1843
|
+
if (!paths[openApiPath]) paths[openApiPath] = {};
|
|
1844
|
+
paths[openApiPath][method] = operation;
|
|
1845
|
+
}
|
|
1846
|
+
return paths;
|
|
1847
|
+
}
|
|
1848
|
+
function inferResponseSchema(body) {
|
|
1849
|
+
if (body === null || body === void 0) return {};
|
|
1850
|
+
if (typeof body !== "object" || Array.isArray(body)) return { type: "object" };
|
|
1851
|
+
const properties = {};
|
|
1852
|
+
for (const [key, value] of Object.entries(body)) {
|
|
1853
|
+
properties[key] = { type: jsToOpenApiType2(value) };
|
|
1854
|
+
}
|
|
1855
|
+
return { type: "object", properties };
|
|
1856
|
+
}
|
|
1857
|
+
function jsToOpenApiType2(value) {
|
|
1858
|
+
if (value === null || value === void 0) return "string";
|
|
1859
|
+
if (typeof value === "boolean") return "boolean";
|
|
1860
|
+
if (typeof value === "number") return Number.isInteger(value) ? "integer" : "number";
|
|
1861
|
+
if (Array.isArray(value)) return "array";
|
|
1862
|
+
if (typeof value === "object") return "object";
|
|
1863
|
+
return "string";
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1866
|
+
// src/openapi/generateOpenApi.ts
|
|
1867
|
+
function generateOpenApi(storage, options, title = "yRest API") {
|
|
1868
|
+
const collections = Object.keys(storage.getData());
|
|
1869
|
+
const relations = storage.getRelations();
|
|
1870
|
+
const schemaBlock = storage.getSchema();
|
|
1871
|
+
const customRoutes = storage.getRoutes();
|
|
1872
|
+
const base = options.base ?? "";
|
|
1873
|
+
const schemas = {};
|
|
1874
|
+
for (const collection of collections) {
|
|
1875
|
+
const items = storage.getCollection(collection) ?? [];
|
|
1876
|
+
const fieldDefs = schemaBlock[collection] ?? {};
|
|
1877
|
+
const schemaName = toSchemaName(collection);
|
|
1878
|
+
schemas[schemaName] = buildCollectionSchema(items, fieldDefs);
|
|
1879
|
+
}
|
|
1880
|
+
const paths = {};
|
|
1881
|
+
for (const collection of collections) {
|
|
1882
|
+
const schemaName = toSchemaName(collection);
|
|
1883
|
+
Object.assign(paths, buildCrudPaths(collection, base, schemaName));
|
|
1884
|
+
}
|
|
1885
|
+
Object.assign(paths, buildRelationPaths(relations, base));
|
|
1886
|
+
Object.assign(paths, buildCustomRoutePaths(customRoutes, base));
|
|
1887
|
+
return {
|
|
1888
|
+
openapi: "3.0.3",
|
|
1889
|
+
info: {
|
|
1890
|
+
title,
|
|
1891
|
+
version: "1.0.0",
|
|
1892
|
+
description: "Generated by yRest from db.yml"
|
|
1893
|
+
},
|
|
1894
|
+
servers: [
|
|
1895
|
+
{
|
|
1896
|
+
url: `http://${options.host}:${options.port}${base}`,
|
|
1897
|
+
description: "yRest mock server"
|
|
1898
|
+
}
|
|
1899
|
+
],
|
|
1900
|
+
paths,
|
|
1901
|
+
components: { schemas }
|
|
1902
|
+
};
|
|
1903
|
+
}
|
|
1904
|
+
function toSchemaName(collection) {
|
|
1905
|
+
const withoutS = collection.endsWith("s") ? collection.slice(0, -1) : collection;
|
|
1906
|
+
return withoutS.charAt(0).toUpperCase() + withoutS.slice(1);
|
|
1907
|
+
}
|
|
1908
|
+
|
|
1909
|
+
// src/router/routes/openapi.routes.ts
|
|
1910
|
+
var import_yaml2 = require("yaml");
|
|
1911
|
+
var OpenApiRouteCommand = class {
|
|
1912
|
+
constructor(storage, options) {
|
|
1913
|
+
this.storage = storage;
|
|
1914
|
+
this.options = options;
|
|
1915
|
+
}
|
|
1916
|
+
storage;
|
|
1917
|
+
options;
|
|
1918
|
+
register(server) {
|
|
1919
|
+
server.get("/_openapi", (_req, reply) => {
|
|
1920
|
+
const doc = generateOpenApi(this.storage, this.options);
|
|
1921
|
+
reply.header("Content-Type", "text/yaml; charset=utf-8");
|
|
1922
|
+
return reply.send((0, import_yaml2.stringify)(doc, { lineWidth: 0, aliasDuplicateObjects: false }));
|
|
1923
|
+
});
|
|
1924
|
+
server.get("/_openapi.json", (_req, reply) => {
|
|
1925
|
+
return reply.send(generateOpenApi(this.storage, this.options));
|
|
1926
|
+
});
|
|
1927
|
+
}
|
|
1306
1928
|
};
|
|
1307
1929
|
|
|
1308
1930
|
// src/router/routes/snapshot.routes.ts
|
|
@@ -1386,6 +2008,7 @@ async function createServer(storage, options, handlers = /* @__PURE__ */ new Map
|
|
|
1386
2008
|
}
|
|
1387
2009
|
const commands = [
|
|
1388
2010
|
new AboutRouteCommand(storage, options, handlers),
|
|
2011
|
+
new OpenApiRouteCommand(storage, options),
|
|
1389
2012
|
...options.snapshot ? [new SnapshotRouteCommand(storage)] : [],
|
|
1390
2013
|
new CustomRouteCommand(storage, options.base, handlers),
|
|
1391
2014
|
...buildResourceRouteCommands(storage, options)
|
|
@@ -1465,10 +2088,12 @@ function buildOptions(opts) {
|
|
|
1465
2088
|
}
|
|
1466
2089
|
function createInMemoryStorage(data) {
|
|
1467
2090
|
const raw = data;
|
|
1468
|
-
const
|
|
2091
|
+
const RESERVED = /* @__PURE__ */ new Set(["_rel", "_routes", "_schema"]);
|
|
2092
|
+
const relations = parseRelations(raw["_rel"]);
|
|
1469
2093
|
const routes = Array.isArray(raw["_routes"]) ? raw["_routes"] : [];
|
|
2094
|
+
const schema = parseSchema(raw["_schema"]);
|
|
1470
2095
|
const collections = Object.fromEntries(
|
|
1471
|
-
Object.entries(raw).filter(([k]) => k
|
|
2096
|
+
Object.entries(raw).filter(([k]) => !RESERVED.has(k))
|
|
1472
2097
|
);
|
|
1473
2098
|
let snapshot = {
|
|
1474
2099
|
data: deepCopyData(collections),
|
|
@@ -1478,6 +2103,7 @@ function createInMemoryStorage(data) {
|
|
|
1478
2103
|
return {
|
|
1479
2104
|
getData: () => collections,
|
|
1480
2105
|
getRelations: () => relations,
|
|
2106
|
+
getSchema: () => schema,
|
|
1481
2107
|
getRoutes: () => routes,
|
|
1482
2108
|
getCollection: (name) => collections[name],
|
|
1483
2109
|
setCollection: (name, items) => {
|