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