@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/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, import_yaml2.parse)((0, import_node_fs3.readFileSync)(absPath, "utf8")) ?? {};
64
- const relations = raw["_rel"] ?? {};
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 !== "_rel" && key !== "_routes")
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, import_yaml2.stringify)(payload), "utf8");
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, import_yaml2.parse)((0, import_node_fs3.readFileSync)(absPath, "utf8")) ?? {};
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 !== "_rel" && key !== "_routes")
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, import_yaml2;
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
- import_yaml2 = require("yaml");
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.template.ts
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 singular = name.endsWith("s") ? name.slice(0, -1) : name;
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 ${singular}. Auto-assigns <code>id</code> if not provided.`
438
+ `Create a new ${singular2}. Auto-assigns <code>id</code> if not provided.`
350
439
  ),
351
- endpointRow("GET", `${p}/:id`, `Get a single ${singular} by id.`),
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 ${singular}. Original <code>id</code> is always preserved.`
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 ${singular} \u2014 only provided fields change.`
449
+ `Partially update a ${singular2} \u2014 only provided fields change.`
361
450
  ),
362
- endpointRow("DELETE", `${p}/:id`, `Delete a ${singular} and return it as confirmation.`)
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 += `&ensp;${tags.join("&ensp;")}`;
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 singular = firstCol.endsWith("s") ? firstCol.slice(0, -1) : firstCol;
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 ${singular}
610
+ `# Get single ${singular2}
389
611
  curl ${p}/1`,
390
- `# Create ${singular}
612
+ `# Create ${singular2}
391
613
  curl -X POST ${p} \\
392
614
  -H "Content-Type: application/json" \\
393
615
  -d '{"name":"example"}'`,
394
- `# Partially update ${singular}
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 ${singular}
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 fk = Object.keys(fields)[0];
406
- const expandKey = fk.replace(/Id$/i, "");
407
- examples.push(
408
- `# Embed parent with ?_expand
409
- curl "${host}${base}/${child}/1?_expand=${expandKey}"`
410
- );
411
- }
412
- const firstRelEntry = Object.entries(relations)[0];
413
- if (firstRelEntry) {
414
- const [child, fields] = firstRelEntry;
415
- const parent = Object.values(fields)[0];
416
- if (parent) {
417
- examples.push(`# Nested resource
418
- curl ${host}${base}/${parent}/1/${child}`);
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 += `&ensp;${tags.join("&ensp;")}`;
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
- ${nestedAccordion}
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, parentCollection] of Object.entries(resourceRelations)) {
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 || parentCollection === expandKey || parentCollection === `${expandKey}s`) {
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, parentCollection] of Object.entries(fields)) {
965
- if (parentCollection === resource && childCollection === embedKey) {
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, { childCollection, fkField }] of embeds) {
976
- out[embedKey] = (storage.getCollection(childCollection) ?? []).filter(
977
- (child) => String(child[fkField]) === String(item["id"])
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(paginate(sorted, page, limit), req.query, this.resource, this.storage),
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(result, req.query, this.resource, this.storage),
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(item, req.query, this.resource, this.storage),
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 [child, fields] of Object.entries(this.relations)) {
1281
- for (const [field, parent] of Object.entries(fields)) {
1282
- const collectionPath = `${this.base}/${parent}/:id/${child}`;
1283
- const itemPath = `${this.base}/${parent}/:id/${child}/:childId`;
1284
- server.get(collectionPath, (req, reply) => {
1285
- const parentCollection = this.storage.getCollection(parent) ?? [];
1286
- const parentItem = findById(parentCollection, req.params.id);
1287
- if (!parentItem) return reply.status(404).send({ error: "Not found" });
1288
- const children = (this.storage.getCollection(child) ?? []).filter(
1289
- (item) => String(item[field]) === req.params.id
1290
- );
1291
- return children;
1292
- });
1293
- server.get(itemPath, (req, reply) => {
1294
- const parentCollection = this.storage.getCollection(parent) ?? [];
1295
- const parentItem = findById(parentCollection, req.params.id);
1296
- if (!parentItem) return reply.status(404).send({ error: "Not found" });
1297
- const childItem = (this.storage.getCollection(child) ?? []).find(
1298
- (item) => String(item[field]) === req.params.id && String(item["id"]) === req.params.childId
1299
- );
1300
- if (!childItem) return reply.status(404).send({ error: "Not found" });
1301
- return childItem;
1302
- });
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 relations = raw["_rel"] ?? {};
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 !== "_rel" && k !== "_routes")
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) => {