@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/cli/index.js CHANGED
@@ -36,62 +36,470 @@ var import_node_fs = require("fs");
36
36
  var import_node_path = require("path");
37
37
 
38
38
  // src/cli/commands/templates/basic.ts
39
- var basicTemplate = `users:
39
+ var basicTemplate = `# yrest basic sample
40
+ # Run: npx @yrest/cli serve db.yml
41
+ # Docs: GET http://localhost:3070/_about
42
+
43
+ users:
40
44
  - id: 1
41
- name: Ana
42
- email: ana@test.com
45
+ name: Ana Garc\xEDa
46
+ email: ana@example.com
47
+ role: admin
48
+ active: true
43
49
  - id: 2
44
- name: Luis
45
- email: luis@test.com
50
+ name: Luis Mart\xEDnez
51
+ email: luis@example.com
52
+ role: editor
53
+ active: true
54
+ - id: 3
55
+ name: Sara L\xF3pez
56
+ email: sara@example.com
57
+ role: user
58
+ active: true
59
+ - id: 4
60
+ name: Diego Ruiz
61
+ email: diego@example.com
62
+ role: user
63
+ active: false
46
64
 
47
65
  products:
48
66
  - id: 1
49
- name: Laptop
50
- price: 999
67
+ name: Laptop Pro 15
68
+ price: 1299.99
69
+ stock: 15
70
+ category: electronics
71
+ featured: true
51
72
  - id: 2
52
- name: Phone
53
- price: 499
73
+ name: Wireless Mouse
74
+ price: 39.99
75
+ stock: 80
76
+ category: accessories
77
+ featured: false
78
+ - id: 3
79
+ name: Mechanical Keyboard
80
+ price: 129.99
81
+ stock: 45
82
+ category: accessories
83
+ featured: true
84
+ - id: 4
85
+ name: 4K Monitor 27"
86
+ price: 549.99
87
+ stock: 20
88
+ category: electronics
89
+ featured: true
90
+ - id: 5
91
+ name: USB-C Hub 7-in-1
92
+ price: 49.99
93
+ stock: 100
94
+ category: accessories
95
+ featured: false
96
+
97
+ categories:
98
+ - id: 1
99
+ name: Electronics
100
+ slug: electronics
101
+ description: Laptops, monitors and computing gear
102
+ - id: 2
103
+ name: Accessories
104
+ slug: accessories
105
+ description: Peripherals and add-ons
106
+
107
+ # Try these queries:
108
+ # GET /users?role=admin
109
+ # GET /products?featured=true&_sort=price&_order=asc
110
+ # GET /products?price_lte=100
111
+ # GET /users?_q=garcia
112
+ # GET /products?_fields=id,name,price&_page=1&_limit=3
54
113
  `;
55
114
 
56
115
  // src/cli/commands/templates/relational.ts
57
- var relationalTemplate = `_rel:
116
+ var relationalTemplate = `# yrest relational sample \u2014 blog
117
+ # Demonstrates: many2one, one2one and many2many relationships
118
+ # Run: npx @yrest/cli serve db.yml
119
+ # Docs: GET http://localhost:3070/_about
120
+
121
+ _rel:
122
+ # many2one \u2014 posts and comments belong to a user
58
123
  posts:
59
124
  userId: users
125
+ # many2many \u2014 posts can have multiple tags via the post_tags pivot
126
+ tags:
127
+ type: many2many
128
+ target: tags
129
+ through: post_tags
130
+ foreignKey: postId
131
+ otherKey: tagId
60
132
  comments:
61
133
  postId: posts
134
+ userId: users
135
+
136
+ # one2one \u2014 each user has exactly one profile
137
+ profiles:
138
+ userId:
139
+ type: one2one
140
+ target: users
62
141
 
63
142
  users:
64
143
  - id: 1
65
- name: Ana
66
- email: ana@test.com
144
+ name: Ana Garc\xEDa
145
+ email: ana@example.com
146
+ role: author
147
+ - id: 2
148
+ name: Luis Mart\xEDnez
149
+ email: luis@example.com
150
+ role: author
151
+ - id: 3
152
+ name: Sara L\xF3pez
153
+ email: sara@example.com
154
+ role: reader
155
+
156
+ profiles:
157
+ - id: 1
158
+ userId: 1
159
+ bio: Full-stack developer and open-source enthusiast
160
+ avatar: https://i.pravatar.cc/150?img=1
161
+ website: https://ana.dev
67
162
  - id: 2
68
- name: Luis
69
- email: luis@test.com
163
+ userId: 2
164
+ bio: Backend engineer, coffee addict
165
+ avatar: https://i.pravatar.cc/150?img=2
166
+ website: https://luisdev.io
167
+ - id: 3
168
+ userId: 3
169
+ bio: Designer turned frontend developer
170
+ avatar: https://i.pravatar.cc/150?img=3
171
+ website: null
172
+
173
+ tags:
174
+ - id: 1
175
+ name: typescript
176
+ color: "#3178c6"
177
+ - id: 2
178
+ name: api
179
+ color: "#10b981"
180
+ - id: 3
181
+ name: testing
182
+ color: "#f59e0b"
183
+ - id: 4
184
+ name: devtools
185
+ color: "#8b5cf6"
186
+ - id: 5
187
+ name: yaml
188
+ color: "#ef4444"
70
189
 
71
190
  posts:
72
191
  - id: 1
73
- title: First post
74
- body: Content of the first post
192
+ title: Getting started with TypeScript
193
+ slug: getting-started-typescript
194
+ body: TypeScript adds static typing to JavaScript, catching errors at compile time...
75
195
  userId: 1
196
+ published: true
197
+ views: 1420
198
+ createdAt: "2024-11-01"
76
199
  - id: 2
77
- title: Second post
78
- body: Content of the second post
200
+ title: Building REST APIs with Fastify
201
+ slug: rest-apis-fastify
202
+ body: Fastify is the fastest Node.js web framework, perfect for building APIs...
79
203
  userId: 1
204
+ published: true
205
+ views: 980
206
+ createdAt: "2024-11-15"
207
+ - id: 3
208
+ title: Testing strategies for modern apps
209
+ slug: testing-strategies-modern
210
+ body: A solid test strategy covers unit, integration and end-to-end scenarios...
211
+ userId: 2
212
+ published: true
213
+ views: 640
214
+ createdAt: "2024-12-03"
215
+ - id: 4
216
+ title: YAML as a database format
217
+ slug: yaml-database-format
218
+ body: YAML is human-readable and expressive enough for mock data during development...
219
+ userId: 2
220
+ published: false
221
+ views: 0
222
+ createdAt: "2025-01-10"
223
+
224
+ # pivot table for posts \u2194 tags (many2many)
225
+ post_tags:
226
+ - { id: 1, postId: 1, tagId: 1 }
227
+ - { id: 2, postId: 1, tagId: 2 }
228
+ - { id: 3, postId: 2, tagId: 2 }
229
+ - { id: 4, postId: 2, tagId: 4 }
230
+ - { id: 5, postId: 3, tagId: 3 }
231
+ - { id: 6, postId: 3, tagId: 1 }
232
+ - { id: 7, postId: 4, tagId: 5 }
233
+ - { id: 8, postId: 4, tagId: 2 }
80
234
 
81
235
  comments:
82
236
  - id: 1
83
- body: Great post!
237
+ body: Great introduction! This helped me a lot.
84
238
  postId: 1
239
+ userId: 3
240
+ likes: 5
85
241
  - id: 2
86
- body: Thanks for sharing
242
+ body: Could you cover generics in a follow-up post?
87
243
  postId: 1
244
+ userId: 2
245
+ likes: 3
246
+ - id: 3
247
+ body: Fastify is indeed much faster than Express in my benchmarks.
248
+ postId: 2
249
+ userId: 3
250
+ likes: 8
251
+ - id: 4
252
+ body: Do you have a GitHub repo with these examples?
253
+ postId: 2
254
+ userId: 1
255
+ likes: 1
256
+ - id: 5
257
+ body: E2E tests are underrated. Solid post!
258
+ postId: 3
259
+ userId: 1
260
+ likes: 4
261
+
262
+ # Try these queries:
263
+ # GET /posts?published=true&_sort=views&_order=desc
264
+ # GET /posts/1?_expand=user \u2192 embeds author object
265
+ # GET /users/1?_embed=posts \u2192 embeds posts array
266
+ # GET /users/1/profiles \u2192 one2one nested route
267
+ # GET /posts/1/tags \u2192 many2many nested route
268
+ # GET /posts/1?_embed=tags \u2192 many2many via ?_embed
269
+ # GET /users/1?_embed=profiles \u2192 one2one via ?_embed (returns object, not array)
270
+ `;
271
+
272
+ // src/cli/commands/templates/ecommerce.ts
273
+ var ecommerceTemplate = `# yrest ecommerce sample
274
+ # Demonstrates: many2one, many2many, _routes with scenarios, template vars and delay
275
+ # Run: npx @yrest/cli serve db.yml
276
+ # Docs: GET http://localhost:3070/_about
277
+
278
+ _rel:
279
+ # many2one
280
+ orders:
281
+ userId: users
282
+ order_items:
283
+ orderId: orders
284
+ productId: products
285
+
286
+ # many2many \u2014 products belong to multiple categories via pivot
287
+ products:
288
+ categories:
289
+ type: many2many
290
+ target: categories
291
+ through: product_categories
292
+ foreignKey: productId
293
+ otherKey: categoryId
294
+
295
+ _routes:
296
+ # Login with conditional scenarios
297
+ - method: POST
298
+ path: /auth/login
299
+ scenarios:
300
+ - when:
301
+ body.email: admin@example.com
302
+ body.password: secret
303
+ response:
304
+ status: 200
305
+ body:
306
+ token: tok-admin-abc123
307
+ role: admin
308
+ userId: 1
309
+ - when:
310
+ body.email: user@example.com
311
+ body.password: secret
312
+ response:
313
+ status: 200
314
+ body:
315
+ token: tok-user-xyz789
316
+ role: user
317
+ userId: 2
318
+ otherwise:
319
+ status: 401
320
+ body:
321
+ error: Invalid credentials
322
+
323
+ # Logout \u2014 always 204
324
+ - method: POST
325
+ path: /auth/logout
326
+ response:
327
+ status: 204
328
+
329
+ # Static featured products list
330
+ - method: GET
331
+ path: /store/featured
332
+ response:
333
+ status: 200
334
+ body:
335
+ - id: 1
336
+ name: Laptop Pro 15
337
+ price: 1299.99
338
+ badge: Best Seller
339
+ - id: 4
340
+ name: 4K Monitor 27"
341
+ price: 549.99
342
+ badge: New Arrival
343
+ - id: 3
344
+ name: Mechanical Keyboard
345
+ price: 129.99
346
+ badge: On Sale
347
+
348
+ # Template variables \u2014 echoes the requested product id
349
+ - method: GET
350
+ path: /products/:id/summary
351
+ response:
352
+ status: 200
353
+ body:
354
+ productId: "{{params.id}}"
355
+ requestedAt: "{{now}}"
356
+ source: mock
357
+
358
+ # Cancel order with simulated latency and template vars
359
+ - method: POST
360
+ path: /orders/:id/cancel
361
+ delay: 500
362
+ response:
363
+ status: 200
364
+ body:
365
+ orderId: "{{params.id}}"
366
+ status: cancelled
367
+ cancelledAt: "{{now}}"
368
+
369
+ # Simulate a service outage for testing error handling
370
+ - method: GET
371
+ path: /store/inventory/sync
372
+ error: 503
373
+ errorBody:
374
+ message: Inventory service temporarily unavailable
375
+ retryAfter: 30
376
+
377
+ users:
378
+ - id: 1
379
+ name: Ana Garc\xEDa
380
+ email: admin@example.com
381
+ role: admin
382
+ active: true
383
+ - id: 2
384
+ name: Luis Mart\xEDnez
385
+ email: user@example.com
386
+ role: user
387
+ active: true
388
+ - id: 3
389
+ name: Sara L\xF3pez
390
+ email: sara@example.com
391
+ role: user
392
+ active: true
393
+ - id: 4
394
+ name: Diego Ruiz
395
+ email: diego@example.com
396
+ role: user
397
+ active: false
398
+
399
+ categories:
400
+ - id: 1
401
+ name: Laptops
402
+ slug: laptops
403
+ - id: 2
404
+ name: Peripherals
405
+ slug: peripherals
406
+ - id: 3
407
+ name: Monitors
408
+ slug: monitors
409
+ - id: 4
410
+ name: Accessories
411
+ slug: accessories
412
+
413
+ products:
414
+ - id: 1
415
+ name: Laptop Pro 15
416
+ description: High-performance laptop for developers
417
+ price: 1299.99
418
+ stock: 15
419
+ sku: LAP-001
420
+ active: true
421
+ - id: 2
422
+ name: Wireless Mouse
423
+ description: Ergonomic wireless mouse with USB-C receiver
424
+ price: 39.99
425
+ stock: 80
426
+ sku: MOU-001
427
+ active: true
428
+ - id: 3
429
+ name: Mechanical Keyboard
430
+ description: Tactile switches, full RGB, TKL layout
431
+ price: 129.99
432
+ stock: 45
433
+ sku: KEY-001
434
+ active: true
435
+ - id: 4
436
+ name: 4K Monitor 27"
437
+ description: IPS panel, 144Hz, HDR400
438
+ price: 549.99
439
+ stock: 20
440
+ sku: MON-001
441
+ active: true
442
+ - id: 5
443
+ name: USB-C Hub 7-in-1
444
+ description: HDMI, SD card and USB-A ports
445
+ price: 49.99
446
+ stock: 100
447
+ sku: HUB-001
448
+ active: true
449
+
450
+ # pivot table for products \u2194 categories (many2many)
451
+ product_categories:
452
+ - { id: 1, productId: 1, categoryId: 1 }
453
+ - { id: 2, productId: 2, categoryId: 2 }
454
+ - { id: 3, productId: 2, categoryId: 4 }
455
+ - { id: 4, productId: 3, categoryId: 2 }
456
+ - { id: 5, productId: 3, categoryId: 4 }
457
+ - { id: 6, productId: 4, categoryId: 3 }
458
+ - { id: 7, productId: 5, categoryId: 4 }
459
+
460
+ orders:
461
+ - id: 1
462
+ userId: 2
463
+ status: delivered
464
+ total: 1339.98
465
+ createdAt: "2024-12-10"
466
+ - id: 2
467
+ userId: 3
468
+ status: processing
469
+ total: 179.98
470
+ createdAt: "2025-01-15"
471
+ - id: 3
472
+ userId: 2
473
+ status: pending
474
+ total: 549.99
475
+ createdAt: "2025-02-01"
476
+
477
+ order_items:
478
+ - { id: 1, orderId: 1, productId: 1, quantity: 1, unitPrice: 1299.99 }
479
+ - { id: 2, orderId: 1, productId: 2, quantity: 1, unitPrice: 39.99 }
480
+ - { id: 3, orderId: 2, productId: 3, quantity: 1, unitPrice: 129.99 }
481
+ - { id: 4, orderId: 2, productId: 2, quantity: 1, unitPrice: 39.99 }
482
+ - { id: 5, orderId: 3, productId: 4, quantity: 1, unitPrice: 549.99 }
483
+
484
+ # Try these queries:
485
+ # POST /auth/login { "email": "admin@example.com", "password": "secret" }
486
+ # GET /store/featured
487
+ # GET /products/1/summary
488
+ # GET /products/1/categories \u2192 many2many nested route
489
+ # GET /products/1?_embed=categories \u2192 many2many via ?_embed
490
+ # GET /users/2?_embed=orders \u2192 many2one ?_embed
491
+ # GET /users/2/orders \u2192 nested route
492
+ # GET /orders/1?_embed=order_items \u2192 nested items
493
+ # POST /orders/1/cancel \u2192 delayed response with template vars
494
+ # GET /store/inventory/sync \u2192 forced 503 error
88
495
  `;
89
496
 
90
497
  // src/cli/commands/templates/index.ts
91
- var SAMPLES = ["basic", "relational"];
498
+ var SAMPLES = ["basic", "relational", "ecommerce"];
92
499
  var templates = {
93
500
  basic: basicTemplate,
94
- relational: relationalTemplate
501
+ relational: relationalTemplate,
502
+ ecommerce: ecommerceTemplate
95
503
  };
96
504
 
97
505
  // src/cli/commands/init.ts
@@ -141,6 +549,80 @@ var import_node_path2 = require("path");
141
549
  var import_node_crypto = require("crypto");
142
550
  var import_yaml = require("yaml");
143
551
 
552
+ // src/storage/parseRelations.ts
553
+ function parseRelations(raw) {
554
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
555
+ const result = {};
556
+ for (const [collection, fields] of Object.entries(raw)) {
557
+ if (!fields || typeof fields !== "object" || Array.isArray(fields)) continue;
558
+ result[collection] = {};
559
+ for (const [key, value] of Object.entries(fields)) {
560
+ const def = normaliseRelationDef(key, value);
561
+ if (def) result[collection][key] = def;
562
+ }
563
+ }
564
+ return result;
565
+ }
566
+ function normaliseRelationDef(key, value) {
567
+ if (typeof value === "string") {
568
+ return { type: "many2one", target: value };
569
+ }
570
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
571
+ const v = value;
572
+ const type = v["type"];
573
+ const nested = v["nested"] === true ? true : void 0;
574
+ if (type === "many2one" || type === void 0) {
575
+ const target = v["target"];
576
+ if (typeof target !== "string") return null;
577
+ return nested ? { type: "many2one", target, nested } : { type: "many2one", target };
578
+ }
579
+ if (type === "one2one") {
580
+ const target = v["target"];
581
+ if (typeof target !== "string") return null;
582
+ return nested ? { type: "one2one", target, nested } : { type: "one2one", target };
583
+ }
584
+ if (type === "many2many") {
585
+ const target = typeof v["target"] === "string" ? v["target"] : key;
586
+ const through = v["through"];
587
+ const foreignKey = v["foreignKey"];
588
+ const otherKey = v["otherKey"];
589
+ if (typeof through !== "string" || typeof foreignKey !== "string" || typeof otherKey !== "string")
590
+ return null;
591
+ return nested ? { type: "many2many", target, through, foreignKey, otherKey, nested } : { type: "many2many", target, through, foreignKey, otherKey };
592
+ }
593
+ return null;
594
+ }
595
+
596
+ // src/storage/parseSchema.ts
597
+ function parseSchema(raw) {
598
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
599
+ const result = {};
600
+ for (const [collection, fields] of Object.entries(raw)) {
601
+ if (!fields || typeof fields !== "object" || Array.isArray(fields)) continue;
602
+ result[collection] = {};
603
+ for (const [field, value] of Object.entries(fields)) {
604
+ const def = normaliseFieldDef(value);
605
+ if (def) result[collection][field] = def;
606
+ }
607
+ }
608
+ return result;
609
+ }
610
+ function normaliseFieldDef(value) {
611
+ if (value === "required") return { required: true };
612
+ if (value === "optional") return { required: false };
613
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
614
+ const v = value;
615
+ const def = {};
616
+ if (v["required"] === true || v["required"] === false) def.required = v["required"];
617
+ if (typeof v["type"] === "string" && ["string", "integer", "number", "boolean", "object", "array"].includes(v["type"]))
618
+ def.type = v["type"];
619
+ if (typeof v["format"] === "string") def.format = v["format"];
620
+ if (Array.isArray(v["enum"])) def.enum = v["enum"];
621
+ if (typeof v["description"] === "string") def.description = v["description"];
622
+ if (v["default"] !== void 0) def.default = v["default"];
623
+ return def;
624
+ }
625
+
144
626
  // src/utils/deepCopy.ts
145
627
  function deepCopyData(source) {
146
628
  return Object.fromEntries(
@@ -152,10 +634,12 @@ function deepCopyData(source) {
152
634
  function createYrestStorage(filePath) {
153
635
  const absPath = (0, import_node_path2.resolve)(filePath);
154
636
  const raw = (0, import_yaml.parse)((0, import_node_fs2.readFileSync)(absPath, "utf8")) ?? {};
155
- const relations = raw["_rel"] ?? {};
637
+ const RESERVED = /* @__PURE__ */ new Set(["_rel", "_routes", "_schema"]);
638
+ const relations = parseRelations(raw["_rel"]);
156
639
  const routes = Array.isArray(raw["_routes"]) ? raw["_routes"] : [];
640
+ const schema = parseSchema(raw["_schema"]);
157
641
  const data = Object.fromEntries(
158
- Object.entries(raw).filter(([key]) => key !== "_rel" && key !== "_routes")
642
+ Object.entries(raw).filter(([key]) => !RESERVED.has(key))
159
643
  );
160
644
  let snapshot = {
161
645
  data: deepCopyData(data),
@@ -169,6 +653,9 @@ function createYrestStorage(filePath) {
169
653
  getRelations() {
170
654
  return relations;
171
655
  },
656
+ getSchema() {
657
+ return schema;
658
+ },
172
659
  getRoutes() {
173
660
  return routes;
174
661
  },
@@ -189,9 +676,9 @@ function createYrestStorage(filePath) {
189
676
  },
190
677
  reload() {
191
678
  const fresh = (0, import_yaml.parse)((0, import_node_fs2.readFileSync)(absPath, "utf8")) ?? {};
192
- const freshRelations = fresh["_rel"] ?? {};
679
+ const freshRelations = parseRelations(fresh["_rel"]);
193
680
  const freshData = Object.fromEntries(
194
- Object.entries(fresh).filter(([key]) => key !== "_rel" && key !== "_routes")
681
+ Object.entries(fresh).filter(([key]) => !RESERVED.has(key))
195
682
  );
196
683
  for (const key of Object.keys(data)) delete data[key];
197
684
  Object.assign(data, freshData);
@@ -274,16 +761,7 @@ function hasTemplates(value) {
274
761
  return typeof value === "string" ? value.includes("{{") : JSON.stringify(value).includes("{{");
275
762
  }
276
763
 
277
- // src/router/templates/about.template.ts
278
- var _dir = (0, import_node_path3.dirname)((0, import_node_url.fileURLToPath)(importMetaUrl));
279
- var LOGO_SRC = (() => {
280
- try {
281
- const buf = (0, import_node_fs3.readFileSync)((0, import_node_path3.join)(_dir, "../../assets/logo-color.png"));
282
- return `data:image/png;base64,${buf.toString("base64")}`;
283
- } catch {
284
- return "";
285
- }
286
- })();
764
+ // src/router/templates/about.helpers.ts
287
765
  var METHOD_COLOR = {
288
766
  GET: "#3fb950",
289
767
  POST: "#58a6ff",
@@ -312,7 +790,7 @@ function endpointRow(method, path, desc) {
312
790
  }
313
791
  function resourceAccordion(name, base, isOpen) {
314
792
  const p = `${base}/${name}`;
315
- const singular = name.endsWith("s") ? name.slice(0, -1) : name;
793
+ const singular2 = name.endsWith("s") ? name.slice(0, -1) : name;
316
794
  const rows = [
317
795
  endpointRow(
318
796
  "GET",
@@ -322,20 +800,20 @@ function resourceAccordion(name, base, isOpen) {
322
800
  endpointRow(
323
801
  "POST",
324
802
  p,
325
- `Create a new ${singular}. Auto-assigns <code>id</code> if not provided.`
803
+ `Create a new ${singular2}. Auto-assigns <code>id</code> if not provided.`
326
804
  ),
327
- endpointRow("GET", `${p}/:id`, `Get a single ${singular} by id.`),
805
+ endpointRow("GET", `${p}/:id`, `Get a single ${singular2} by id.`),
328
806
  endpointRow(
329
807
  "PUT",
330
808
  `${p}/:id`,
331
- `Fully replace a ${singular}. Original <code>id</code> is always preserved.`
809
+ `Fully replace a ${singular2}. Original <code>id</code> is always preserved.`
332
810
  ),
333
811
  endpointRow(
334
812
  "PATCH",
335
813
  `${p}/:id`,
336
- `Partially update a ${singular} \u2014 only provided fields change.`
814
+ `Partially update a ${singular2} \u2014 only provided fields change.`
337
815
  ),
338
- endpointRow("DELETE", `${p}/:id`, `Delete a ${singular} and return it as confirmation.`)
816
+ endpointRow("DELETE", `${p}/:id`, `Delete a ${singular2} and return it as confirmation.`)
339
817
  ].join("");
340
818
  return `
341
819
  <details class="resource-card" ${isOpen ? "open" : ""}>
@@ -348,12 +826,145 @@ function resourceAccordion(name, base, isOpen) {
348
826
  </table>
349
827
  </details>`;
350
828
  }
829
+ function nestedRoutesAccordion(relations, base) {
830
+ const rows = [];
831
+ for (const [source, fields] of Object.entries(relations)) {
832
+ for (const [key, def] of Object.entries(fields)) {
833
+ const nestedBadge = def.nested ? ` ${badge("nested", "#facc15", "#facc1518")}` : "";
834
+ if (def.type === "many2many") {
835
+ const singular2 = source.endsWith("s") ? source.slice(0, -1) : source;
836
+ const m2mBadge = badge("many2many", "#818cf8", "#818cf818");
837
+ rows.push(
838
+ endpointRow(
839
+ "GET",
840
+ `${base}/${source}/:id/${key}`,
841
+ `List ${def.target} linked to a ${singular2} via ${def.through}. ${m2mBadge}${nestedBadge}`
842
+ )
843
+ );
844
+ const targetSingular = def.target.endsWith("s") ? def.target.slice(0, -1) : def.target;
845
+ rows.push(
846
+ endpointRow(
847
+ "GET",
848
+ `${base}/${def.target}/:id/${source}`,
849
+ `List ${source} linked to a ${targetSingular} via ${def.through} (inverse). ${m2mBadge}`
850
+ )
851
+ );
852
+ } else {
853
+ const path = `${base}/${def.target}/:id/${source}`;
854
+ const parentSingular = def.target.endsWith("s") ? def.target.slice(0, -1) : def.target;
855
+ const typeBadge = def.type === "one2one" ? ` ${badge("one2one", "#34d399", "#34d39918")}` : "";
856
+ rows.push(
857
+ endpointRow(
858
+ "GET",
859
+ path,
860
+ `${def.type === "one2one" ? "Get" : "List"} ${source} belonging to a ${parentSingular}.${typeBadge}${nestedBadge}`
861
+ )
862
+ );
863
+ }
864
+ }
865
+ }
866
+ if (!rows.length) return "";
867
+ return `
868
+ <details class="resource-card nested-card">
869
+ <summary>
870
+ <span class="resource-name">Nested routes</span>
871
+ <span class="route-count">${rows.length} route${rows.length !== 1 ? "s" : ""}</span>
872
+ </summary>
873
+ <table><tbody>${rows.join("")}</tbody></table>
874
+ </details>`;
875
+ }
876
+ function snapshotAccordion() {
877
+ return `
878
+ <details class="resource-card nested-card">
879
+ <summary>
880
+ <span class="resource-name">/_snapshot</span>
881
+ <span class="route-count">3 routes</span>
882
+ </summary>
883
+ <table><tbody>
884
+ ${endpointRow("GET", "/_snapshot", "Returns metadata of the current snapshot: <code>savedAt</code> and item counts per collection.")}
885
+ ${endpointRow("POST", "/_snapshot/save", "Replaces the stored snapshot with the current database state.")}
886
+ ${endpointRow("POST", "/_snapshot/reset", "Restores the database to the last saved snapshot and persists to disk.")}
887
+ </tbody></table>
888
+ </details>`;
889
+ }
890
+ function customRoutesAccordion(routes, base, handlers) {
891
+ if (!routes.length) return "";
892
+ const rows = routes.map((r) => {
893
+ const fullPath = `${base}${r.path}`;
894
+ const tags = [];
895
+ if (r.error) tags.push(`<span style="color:#f85149;font-size:11px">error\xB7${r.error}</span>`);
896
+ if (r.delay && r.delay > 0)
897
+ tags.push(`<span style="color:#fb923c;font-size:11px">delay\xB7${r.delay}ms</span>`);
898
+ if (r.scenarios?.length) {
899
+ const hasOr = r.scenarios.some((s) => Array.isArray(s.when));
900
+ tags.push(
901
+ `<span style="color:#a371f7;font-size:11px">scenarios\xB7${r.scenarios.length}${hasOr ? " (OR)" : ""}</span>`
902
+ );
903
+ }
904
+ if (r.otherwise)
905
+ tags.push(`<span style="color:var(--text-muted);font-size:11px">otherwise</span>`);
906
+ let desc;
907
+ if (r.error) {
908
+ desc = `Error injection \u2014 <code>${r.error}</code>`;
909
+ } else if (r.handler) {
910
+ const found = handlers.has(r.handler);
911
+ const handlerName = escapeHtml(r.handler);
912
+ desc = found ? `Handler \u2014 <code>${handlerName}()</code>` : `Handler \u2014 <code>${handlerName}()</code> <span style="color:#f85149">(not loaded)</span>`;
913
+ } else if (r.scenarios?.length) {
914
+ const hasTemplateInScenarios = r.scenarios.some((s) => s.response.body != null && hasTemplates(s.response.body)) || r.otherwise?.body != null && hasTemplates(r.otherwise.body);
915
+ desc = hasTemplateInScenarios ? `Scenarios \u2014 <code>{{\u2026}}</code>` : `Scenarios`;
916
+ } else if (r.response?.body != null && hasTemplates(r.response.body)) {
917
+ desc = `Dynamic \u2014 <code>{{\u2026}}</code>`;
918
+ } else {
919
+ const status = r.response?.status ?? 200;
920
+ desc = `Static \u2014 <code>${status}</code>${r.response?.headers ? ` + headers` : ""}`;
921
+ }
922
+ if (tags.length) desc += `&ensp;${tags.join("&ensp;")}`;
923
+ return endpointRow(r.method?.toUpperCase() ?? "GET", fullPath, desc);
924
+ });
925
+ return `
926
+ <details class="resource-card nested-card">
927
+ <summary>
928
+ <span class="resource-name">Custom routes</span>
929
+ <span class="route-count">${routes.length} route${routes.length !== 1 ? "s" : ""}</span>
930
+ </summary>
931
+ <table><tbody>
932
+ ${rows.join("")}
933
+ </tbody></table>
934
+ </details>`;
935
+ }
936
+ function handlersAccordion(handlers, routes, base) {
937
+ if (!handlers.size) return "";
938
+ const routesByHandler = /* @__PURE__ */ new Map();
939
+ for (const r of routes) {
940
+ if (r.handler) {
941
+ const list = routesByHandler.get(r.handler) ?? [];
942
+ list.push({ method: (r.method ?? "GET").toUpperCase(), path: `${base}${r.path}` });
943
+ routesByHandler.set(r.handler, list);
944
+ }
945
+ }
946
+ const rows = [...handlers.keys()].map((name) => {
947
+ const linked = routesByHandler.get(name);
948
+ const routeDesc = linked ? linked.map((r) => `<code>${r.method} ${r.path}</code>`).join(", ") : `<span style="color:var(--text-muted)">not referenced in _routes</span>`;
949
+ return endpointRow("fn", name + "()", routeDesc);
950
+ });
951
+ return `
952
+ <details class="resource-card nested-card">
953
+ <summary>
954
+ <span class="resource-name">Handlers</span>
955
+ <span class="route-count">${handlers.size} function${handlers.size !== 1 ? "s" : ""}</span>
956
+ </summary>
957
+ <table><tbody>
958
+ ${rows.join("")}
959
+ </tbody></table>
960
+ </details>`;
961
+ }
351
962
  function examplesBlock(collections, relations, base, host, options, firstCustomRoute) {
352
963
  const examples = [];
353
964
  const firstCol = collections[0];
354
965
  if (firstCol) {
355
966
  const p = `${host}${base}/${firstCol}`;
356
- const singular = firstCol.endsWith("s") ? firstCol.slice(0, -1) : firstCol;
967
+ const singular2 = firstCol.endsWith("s") ? firstCol.slice(0, -1) : firstCol;
357
968
  examples.push(
358
969
  `# List all ${firstCol}
359
970
  curl ${p}`,
@@ -361,52 +972,53 @@ curl ${p}`,
361
972
  curl "${p}?name=value"`,
362
973
  `# Sort and paginate
363
974
  curl "${p}?_sort=id&_order=desc&_page=1&_limit=5"`,
364
- `# Get single ${singular}
975
+ `# Get single ${singular2}
365
976
  curl ${p}/1`,
366
- `# Create ${singular}
977
+ `# Create ${singular2}
367
978
  curl -X POST ${p} \\
368
979
  -H "Content-Type: application/json" \\
369
980
  -d '{"name":"example"}'`,
370
- `# Partially update ${singular}
981
+ `# Partially update ${singular2}
371
982
  curl -X PATCH ${p}/1 \\
372
983
  -H "Content-Type: application/json" \\
373
984
  -d '{"name":"updated"}'`,
374
- `# Delete ${singular}
985
+ `# Delete ${singular2}
375
986
  curl -X DELETE ${p}/1`
376
987
  );
377
988
  }
378
989
  const firstRel = Object.entries(relations)[0];
379
990
  if (firstRel) {
380
991
  const [child, fields] = firstRel;
381
- const fk = Object.keys(fields)[0];
382
- const expandKey = fk.replace(/Id$/i, "");
383
- examples.push(
384
- `# Embed parent with ?_expand
385
- curl "${host}${base}/${child}/1?_expand=${expandKey}"`
386
- );
387
- }
388
- const firstRelEntry = Object.entries(relations)[0];
389
- if (firstRelEntry) {
390
- const [child, fields] = firstRelEntry;
391
- const parent = Object.values(fields)[0];
392
- if (parent) {
393
- examples.push(`# Nested resource
394
- curl ${host}${base}/${parent}/1/${child}`);
992
+ const firstField = Object.entries(fields)[0];
993
+ if (firstField) {
994
+ const [fk, def] = firstField;
995
+ if (def.type !== "many2many") {
996
+ const expandKey = fk.replace(/Id$/i, "");
997
+ examples.push(
998
+ `# Embed parent with ?_expand
999
+ curl "${host}${base}/${child}/1?_expand=${expandKey}"`,
1000
+ `# Nested resource
1001
+ curl ${host}${base}/${def.target}/1/${child}`
1002
+ );
1003
+ } else {
1004
+ examples.push(`# Many-to-many embed
1005
+ curl "${host}${base}/${child}/1/${fk}"`);
1006
+ }
395
1007
  }
396
1008
  }
397
1009
  if (options.pageable.enabled && firstCol) {
398
1010
  examples.push(`# Pageable envelope
399
1011
  curl "${host}${base}/${firstCol}?_page=2"`);
400
1012
  }
401
- const firstParentRel = Object.entries(relations).find(
402
- ([, fields]) => Object.values(fields).includes(firstCol ?? "")
403
- );
404
1013
  if (firstCol) {
405
1014
  examples.push(
406
1015
  `# Project fields with ?_fields
407
1016
  curl "${host}${base}/${firstCol}?_fields=id,name"`
408
1017
  );
409
1018
  }
1019
+ const firstParentRel = Object.entries(relations).find(
1020
+ ([, fields]) => Object.values(fields).some((def) => def.type !== "many2many" && def.target === firstCol)
1021
+ );
410
1022
  if (firstParentRel && firstCol) {
411
1023
  const [childName] = firstParentRel;
412
1024
  examples.push(
@@ -432,9 +1044,21 @@ curl ${curlFlag}${fullPath}`);
432
1044
  const highlighted = examples.map((e) => e.replace(/^(#.+)$/gm, '<span class="cm">$1</span>')).join("\n\n");
433
1045
  return `<pre>${highlighted}</pre>`;
434
1046
  }
1047
+
1048
+ // src/router/templates/about.template.ts
1049
+ var _dir = (0, import_node_path3.dirname)((0, import_node_url.fileURLToPath)(importMetaUrl));
1050
+ var LOGO_SRC = (() => {
1051
+ try {
1052
+ const buf = (0, import_node_fs3.readFileSync)((0, import_node_path3.join)(_dir, "../../assets/logo-color.png"));
1053
+ return `data:image/png;base64,${buf.toString("base64")}`;
1054
+ } catch {
1055
+ return "";
1056
+ }
1057
+ })();
435
1058
  function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map()) {
436
1059
  const collections = Object.keys(storage.getData());
437
1060
  const relations = storage.getRelations();
1061
+ const customRoutes = storage.getRoutes();
438
1062
  const base = options.base;
439
1063
  const host = `http://${options.host}:${options.port}`;
440
1064
  const modes = [];
@@ -448,105 +1072,6 @@ function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map(
448
1072
  if (options.idStrategy !== "increment")
449
1073
  modes.push(badge(`id \xB7 ${options.idStrategy}`, "#a371f7", "#a371f718"));
450
1074
  const accordions = collections.map((col, i) => resourceAccordion(col, base, i === 0)).join("");
451
- const nestedRows = [];
452
- for (const [child, fields] of Object.entries(relations)) {
453
- for (const [, parent] of Object.entries(fields)) {
454
- const nestedPath = `${base}/${parent}/:id/${child}`;
455
- const parentSingular = parent.endsWith("s") ? parent.slice(0, -1) : parent;
456
- nestedRows.push(
457
- endpointRow("GET", nestedPath, `List ${child} belonging to a ${parentSingular}.`)
458
- );
459
- }
460
- }
461
- const nestedAccordion = nestedRows.length ? `
462
- <details class="resource-card nested-card">
463
- <summary>
464
- <span class="resource-name">Nested routes</span>
465
- <span class="route-count">${nestedRows.length} route${nestedRows.length !== 1 ? "s" : ""}</span>
466
- </summary>
467
- <table><tbody>${nestedRows.join("")}</tbody></table>
468
- </details>` : "";
469
- const snapshotAccordion = options.snapshot ? `
470
- <details class="resource-card nested-card">
471
- <summary>
472
- <span class="resource-name">/_snapshot</span>
473
- <span class="route-count">3 routes</span>
474
- </summary>
475
- <table><tbody>
476
- ${endpointRow("GET", "/_snapshot", "Returns metadata of the current snapshot: <code>savedAt</code> and item counts per collection.")}
477
- ${endpointRow("POST", "/_snapshot/save", "Replaces the stored snapshot with the current database state.")}
478
- ${endpointRow("POST", "/_snapshot/reset", "Restores the database to the last saved snapshot and persists to disk.")}
479
- </tbody></table>
480
- </details>` : "";
481
- const customRoutes = storage.getRoutes();
482
- const customRoutesAccordion = customRoutes.length ? `
483
- <details class="resource-card nested-card">
484
- <summary>
485
- <span class="resource-name">Custom routes</span>
486
- <span class="route-count">${customRoutes.length} route${customRoutes.length !== 1 ? "s" : ""}</span>
487
- </summary>
488
- <table><tbody>
489
- ${customRoutes.map((r) => {
490
- const fullPath = `${base}${r.path}`;
491
- const tags = [];
492
- if (r.error) {
493
- tags.push(`<span style="color:#f85149;font-size:11px">error\xB7${r.error}</span>`);
494
- }
495
- if (r.delay && r.delay > 0) {
496
- tags.push(`<span style="color:#fb923c;font-size:11px">delay\xB7${r.delay}ms</span>`);
497
- }
498
- if (r.scenarios?.length) {
499
- const hasOr = r.scenarios.some((s) => Array.isArray(s.when));
500
- tags.push(
501
- `<span style="color:#a371f7;font-size:11px">scenarios\xB7${r.scenarios.length}${hasOr ? " (OR)" : ""}</span>`
502
- );
503
- }
504
- if (r.otherwise) {
505
- tags.push(`<span style="color:var(--text-muted);font-size:11px">otherwise</span>`);
506
- }
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
- }).join("")}
526
- </tbody></table>
527
- </details>` : "";
528
- const routesByHandler = /* @__PURE__ */ new Map();
529
- for (const r of customRoutes) {
530
- if (r.handler) {
531
- const list = routesByHandler.get(r.handler) ?? [];
532
- list.push({ method: (r.method ?? "GET").toUpperCase(), path: `${base}${r.path}` });
533
- routesByHandler.set(r.handler, list);
534
- }
535
- }
536
- const handlersAccordion = handlers.size > 0 ? `
537
- <details class="resource-card nested-card">
538
- <summary>
539
- <span class="resource-name">Handlers</span>
540
- <span class="route-count">${handlers.size} function${handlers.size !== 1 ? "s" : ""}</span>
541
- </summary>
542
- <table><tbody>
543
- ${[...handlers.keys()].map((name) => {
544
- const routes = routesByHandler.get(name);
545
- const routeDesc = routes ? routes.map((r) => `<code>${r.method} ${r.path}</code>`).join(", ") : `<span style="color:var(--text-muted)">not referenced in _routes</span>`;
546
- return endpointRow("fn", name + "()", routeDesc);
547
- }).join("")}
548
- </tbody></table>
549
- </details>` : "";
550
1075
  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.`;
551
1076
  return `<!DOCTYPE html>
552
1077
  <html lang="en">
@@ -694,10 +1219,10 @@ function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map(
694
1219
  <h2>Endpoints</h2>
695
1220
  <div class="endpoints-grid">
696
1221
  ${accordions}
697
- ${nestedAccordion}
698
- ${snapshotAccordion}
699
- ${customRoutesAccordion}
700
- ${handlersAccordion}
1222
+ ${nestedRoutesAccordion(relations, base)}
1223
+ ${options.snapshot ? snapshotAccordion() : ""}
1224
+ ${customRoutesAccordion(customRoutes, base, handlers)}
1225
+ ${handlersAccordion(handlers, customRoutes, base)}
701
1226
  </div>
702
1227
 
703
1228
  <h2>Query Parameters</h2>
@@ -897,10 +1422,11 @@ function expandItems(input, query, resource, storage) {
897
1422
  const resourceRelations = storage.getRelations()[resource] ?? {};
898
1423
  const expansions = /* @__PURE__ */ new Map();
899
1424
  for (const expandKey of keys) {
900
- for (const [field, parentCollection] of Object.entries(resourceRelations)) {
1425
+ for (const [field, def] of Object.entries(resourceRelations)) {
1426
+ if (def.type === "many2many") continue;
901
1427
  const derivedKey = field.replace(/Id$/i, "");
902
- if (derivedKey === expandKey || parentCollection === expandKey || parentCollection === `${expandKey}s`) {
903
- expansions.set(expandKey, { field, parentCollection });
1428
+ if (derivedKey === expandKey || def.target === expandKey || def.target === `${expandKey}s`) {
1429
+ expansions.set(expandKey, { field, parentCollection: def.target });
904
1430
  break;
905
1431
  }
906
1432
  }
@@ -929,10 +1455,24 @@ function embedItems(input, query, resource, storage) {
929
1455
  const relations = storage.getRelations();
930
1456
  const embeds = /* @__PURE__ */ new Map();
931
1457
  for (const embedKey of keys) {
1458
+ const ownRelations = relations[resource] ?? {};
1459
+ if (embedKey in ownRelations) {
1460
+ const def = ownRelations[embedKey];
1461
+ if (def.type === "many2many") {
1462
+ embeds.set(embedKey, {
1463
+ kind: "many2many",
1464
+ target: def.target,
1465
+ through: def.through,
1466
+ foreignKey: def.foreignKey,
1467
+ otherKey: def.otherKey
1468
+ });
1469
+ continue;
1470
+ }
1471
+ }
932
1472
  outer: for (const [childCollection, fields] of Object.entries(relations)) {
933
- for (const [fkField, parentCollection] of Object.entries(fields)) {
934
- if (parentCollection === resource && childCollection === embedKey) {
935
- embeds.set(embedKey, { childCollection, fkField });
1473
+ for (const [fkField, def] of Object.entries(fields)) {
1474
+ if ((def.type === "many2one" || def.type === "one2one") && def.target === resource && childCollection === embedKey) {
1475
+ embeds.set(embedKey, { kind: def.type, childCollection, fkField });
936
1476
  break outer;
937
1477
  }
938
1478
  }
@@ -941,10 +1481,57 @@ function embedItems(input, query, resource, storage) {
941
1481
  if (embeds.size === 0) return isArray ? items : input;
942
1482
  const result = items.map((item) => {
943
1483
  const out = { ...item };
944
- for (const [embedKey, { childCollection, fkField }] of embeds) {
945
- out[embedKey] = (storage.getCollection(childCollection) ?? []).filter(
946
- (child) => String(child[fkField]) === String(item["id"])
947
- );
1484
+ for (const [embedKey, spec] of embeds) {
1485
+ if (spec.kind === "many2many") {
1486
+ const pivot = storage.getCollection(spec.through) ?? [];
1487
+ const matchingIds = new Set(
1488
+ pivot.filter((row) => String(row[spec.foreignKey]) === String(item["id"])).map((row) => String(row[spec.otherKey]))
1489
+ );
1490
+ out[embedKey] = (storage.getCollection(spec.target) ?? []).filter(
1491
+ (t) => matchingIds.has(String(t["id"]))
1492
+ );
1493
+ } else if (spec.kind === "one2one") {
1494
+ out[embedKey] = (storage.getCollection(spec.childCollection) ?? []).find(
1495
+ (child) => String(child[spec.fkField]) === String(item["id"])
1496
+ ) ?? null;
1497
+ } else {
1498
+ out[embedKey] = (storage.getCollection(spec.childCollection) ?? []).filter(
1499
+ (child) => String(child[spec.fkField]) === String(item["id"])
1500
+ );
1501
+ }
1502
+ }
1503
+ return out;
1504
+ });
1505
+ return isArray ? result : result[0];
1506
+ }
1507
+ function applyNested(input, resource, storage) {
1508
+ const isArray = Array.isArray(input);
1509
+ const items = isArray ? input : [input];
1510
+ const resourceRelations = storage.getRelations()[resource] ?? {};
1511
+ const nestedDefs = Object.entries(resourceRelations).filter(([, def]) => def.nested === true);
1512
+ if (nestedDefs.length === 0) return input;
1513
+ const result = items.map((item) => {
1514
+ const out = { ...item };
1515
+ for (const [key, def] of nestedDefs) {
1516
+ if (def.type === "many2many") {
1517
+ const pivot = storage.getCollection(def.through) ?? [];
1518
+ const matchingIds = new Set(
1519
+ pivot.filter((row) => String(row[def.foreignKey]) === String(item["id"])).map((row) => String(row[def.otherKey]))
1520
+ );
1521
+ out[key] = (storage.getCollection(def.target) ?? []).filter(
1522
+ (t) => matchingIds.has(String(t["id"]))
1523
+ );
1524
+ } else {
1525
+ const foreignKeyValue = item[key];
1526
+ if (foreignKeyValue === void 0) continue;
1527
+ const parent = (storage.getCollection(def.target) ?? []).find(
1528
+ (p) => String(p["id"]) === String(foreignKeyValue)
1529
+ );
1530
+ if (parent !== void 0) {
1531
+ const embedKey = key.replace(/Id$/i, "");
1532
+ out[embedKey] = parent;
1533
+ }
1534
+ }
948
1535
  }
949
1536
  return out;
950
1537
  });
@@ -984,7 +1571,12 @@ var CollectionRouteCommand = class {
984
1571
  const totalPages = Math.ceil(totalItems / limit) || 1;
985
1572
  const data = projectFields(
986
1573
  embedItems(
987
- expandItems(paginate(sorted, page, limit), req.query, this.resource, this.storage),
1574
+ expandItems(
1575
+ applyNested(paginate(sorted, page, limit), this.resource, this.storage),
1576
+ req.query,
1577
+ this.resource,
1578
+ this.storage
1579
+ ),
988
1580
  req.query,
989
1581
  this.resource,
990
1582
  this.storage
@@ -1016,7 +1608,12 @@ var CollectionRouteCommand = class {
1016
1608
  }
1017
1609
  return projectFields(
1018
1610
  embedItems(
1019
- expandItems(result, req.query, this.resource, this.storage),
1611
+ expandItems(
1612
+ applyNested(result, this.resource, this.storage),
1613
+ req.query,
1614
+ this.resource,
1615
+ this.storage
1616
+ ),
1020
1617
  req.query,
1021
1618
  this.resource,
1022
1619
  this.storage
@@ -1121,7 +1718,7 @@ var CustomRouteCommand = class {
1121
1718
  url,
1122
1719
  handler: async (req, reply) => {
1123
1720
  if (route.delay && route.delay > 0) {
1124
- await new Promise((resolve5) => setTimeout(resolve5, route.delay));
1721
+ await new Promise((resolve6) => setTimeout(resolve6, route.delay));
1125
1722
  }
1126
1723
  if (route.error) {
1127
1724
  const body2 = route.errorBody ?? { error: `Forced error ${route.error}` };
@@ -1197,7 +1794,12 @@ var ItemRouteCommand = class {
1197
1794
  const fields = (req.query["_fields"] ?? "").split(",").map((f) => f.trim()).filter(Boolean);
1198
1795
  return projectFields(
1199
1796
  embedItems(
1200
- expandItems(item, req.query, this.resource, this.storage),
1797
+ expandItems(
1798
+ applyNested(item, this.resource, this.storage),
1799
+ req.query,
1800
+ this.resource,
1801
+ this.storage
1802
+ ),
1201
1803
  req.query,
1202
1804
  this.resource,
1203
1805
  this.storage
@@ -1240,32 +1842,433 @@ var NestedRouteCommand = class {
1240
1842
  relations;
1241
1843
  base;
1242
1844
  register(server) {
1243
- for (const [child, fields] of Object.entries(this.relations)) {
1244
- for (const [field, parent] of Object.entries(fields)) {
1245
- const collectionPath = `${this.base}/${parent}/:id/${child}`;
1246
- const itemPath = `${this.base}/${parent}/:id/${child}/:childId`;
1247
- server.get(collectionPath, (req, reply) => {
1248
- const parentCollection = this.storage.getCollection(parent) ?? [];
1249
- const parentItem = findById(parentCollection, req.params.id);
1250
- if (!parentItem) return reply.status(404).send({ error: "Not found" });
1251
- const children = (this.storage.getCollection(child) ?? []).filter(
1252
- (item) => String(item[field]) === req.params.id
1253
- );
1254
- return children;
1255
- });
1256
- server.get(itemPath, (req, reply) => {
1257
- const parentCollection = this.storage.getCollection(parent) ?? [];
1258
- const parentItem = findById(parentCollection, req.params.id);
1259
- if (!parentItem) return reply.status(404).send({ error: "Not found" });
1260
- const childItem = (this.storage.getCollection(child) ?? []).find(
1261
- (item) => String(item[field]) === req.params.id && String(item["id"]) === req.params.childId
1262
- );
1263
- if (!childItem) return reply.status(404).send({ error: "Not found" });
1264
- return childItem;
1265
- });
1845
+ for (const [source, fields] of Object.entries(this.relations)) {
1846
+ for (const [key, def] of Object.entries(fields)) {
1847
+ if (def.type === "many2many") {
1848
+ this.registerMany2Many(server, source, key, def);
1849
+ } else {
1850
+ this.registerFkRelation(server, source, key, def.target, def.type);
1851
+ }
1852
+ }
1853
+ }
1854
+ }
1855
+ registerFkRelation(server, child, fkField, parent, type) {
1856
+ const collectionPath = `${this.base}/${parent}/:id/${child}`;
1857
+ const itemPath = `${this.base}/${parent}/:id/${child}/:childId`;
1858
+ server.get(collectionPath, (req, reply) => {
1859
+ const parentCollection = this.storage.getCollection(parent) ?? [];
1860
+ const parentItem = findById(parentCollection, req.params.id);
1861
+ if (!parentItem) return reply.status(404).send({ error: "Not found" });
1862
+ const all = (this.storage.getCollection(child) ?? []).filter(
1863
+ (item) => String(item[fkField]) === req.params.id
1864
+ );
1865
+ if (type === "one2one") return all[0] ?? reply.status(404).send({ error: "Not found" });
1866
+ return all;
1867
+ });
1868
+ if (type === "many2one") {
1869
+ server.get(itemPath, (req, reply) => {
1870
+ const parentCollection = this.storage.getCollection(parent) ?? [];
1871
+ const parentItem = findById(parentCollection, req.params.id);
1872
+ if (!parentItem) return reply.status(404).send({ error: "Not found" });
1873
+ const childItem = (this.storage.getCollection(child) ?? []).find(
1874
+ (item) => String(item[fkField]) === req.params.id && String(item["id"]) === req.params.childId
1875
+ );
1876
+ if (!childItem) return reply.status(404).send({ error: "Not found" });
1877
+ return childItem;
1878
+ });
1879
+ }
1880
+ }
1881
+ registerMany2Many(server, source, alias, def) {
1882
+ server.get(`${this.base}/${source}/:id/${alias}`, (req, reply) => {
1883
+ const sourceCollection = this.storage.getCollection(source) ?? [];
1884
+ const sourceItem = findById(sourceCollection, req.params.id);
1885
+ if (!sourceItem) return reply.status(404).send({ error: "Not found" });
1886
+ const pivot = this.storage.getCollection(def.through) ?? [];
1887
+ const matchingIds = new Set(
1888
+ pivot.filter((row) => String(row[def.foreignKey]) === req.params.id).map((row) => String(row[def.otherKey]))
1889
+ );
1890
+ return (this.storage.getCollection(def.target) ?? []).filter(
1891
+ (t) => matchingIds.has(String(t["id"]))
1892
+ );
1893
+ });
1894
+ server.get(`${this.base}/${def.target}/:id/${source}`, (req, reply) => {
1895
+ const targetCollection = this.storage.getCollection(def.target) ?? [];
1896
+ const targetItem = findById(targetCollection, req.params.id);
1897
+ if (!targetItem) return reply.status(404).send({ error: "Not found" });
1898
+ const pivot = this.storage.getCollection(def.through) ?? [];
1899
+ const matchingIds = new Set(
1900
+ pivot.filter((row) => String(row[def.otherKey]) === req.params.id).map((row) => String(row[def.foreignKey]))
1901
+ );
1902
+ return (this.storage.getCollection(source) ?? []).filter(
1903
+ (t) => matchingIds.has(String(t["id"]))
1904
+ );
1905
+ });
1906
+ }
1907
+ };
1908
+
1909
+ // src/openapi/inferSchema.ts
1910
+ function buildCollectionSchema(items, fieldDefs = {}) {
1911
+ const sample = items.slice(0, 10);
1912
+ const inferredTypes = /* @__PURE__ */ new Map();
1913
+ for (const item of sample) {
1914
+ for (const [key, value] of Object.entries(item)) {
1915
+ if (!inferredTypes.has(key)) {
1916
+ inferredTypes.set(key, jsToOpenApiType(value));
1266
1917
  }
1267
1918
  }
1268
1919
  }
1920
+ const allFields = /* @__PURE__ */ new Set([...inferredTypes.keys(), ...Object.keys(fieldDefs)]);
1921
+ const properties = {};
1922
+ const required = [];
1923
+ for (const field of allFields) {
1924
+ const def = fieldDefs[field];
1925
+ const inferred = inferredTypes.get(field) ?? "string";
1926
+ const prop = {
1927
+ type: def?.type ?? inferred
1928
+ };
1929
+ if (def?.format) prop.format = def.format;
1930
+ if (def?.description) prop.description = def.description;
1931
+ if (def?.enum) prop.enum = def.enum;
1932
+ if (def?.default !== void 0) prop.default = def.default;
1933
+ properties[field] = prop;
1934
+ if (def?.required === true) required.push(field);
1935
+ }
1936
+ const schema = { type: "object", properties };
1937
+ if (required.length > 0) schema.required = required;
1938
+ return schema;
1939
+ }
1940
+ function jsToOpenApiType(value) {
1941
+ if (value === null || value === void 0) return "string";
1942
+ if (typeof value === "boolean") return "boolean";
1943
+ if (typeof value === "number") return Number.isInteger(value) ? "integer" : "number";
1944
+ if (Array.isArray(value)) return "array";
1945
+ if (typeof value === "object") return "object";
1946
+ return "string";
1947
+ }
1948
+
1949
+ // src/openapi/buildPaths.ts
1950
+ var COLLECTION_QUERY_PARAMS = [
1951
+ { name: "_page", in: "query", schema: { type: "integer" }, description: "Page number (1-based)" },
1952
+ { name: "_limit", in: "query", schema: { type: "integer" }, description: "Items per page" },
1953
+ { name: "_sort", in: "query", schema: { type: "string" }, description: "Field name to sort by" },
1954
+ {
1955
+ name: "_order",
1956
+ in: "query",
1957
+ schema: { type: "string", enum: ["asc", "desc"] },
1958
+ description: "Sort direction"
1959
+ },
1960
+ {
1961
+ name: "_q",
1962
+ in: "query",
1963
+ schema: { type: "string" },
1964
+ description: "Full-text search across all scalar fields (case-insensitive)"
1965
+ },
1966
+ {
1967
+ name: "_expand",
1968
+ in: "query",
1969
+ schema: { type: "string" },
1970
+ description: "Embed related parent object inline (e.g. ?_expand=user)"
1971
+ },
1972
+ {
1973
+ name: "_embed",
1974
+ in: "query",
1975
+ schema: { type: "string" },
1976
+ description: "Embed child collection into each item (e.g. ?_embed=posts)"
1977
+ },
1978
+ {
1979
+ name: "_fields",
1980
+ in: "query",
1981
+ schema: { type: "string" },
1982
+ description: "Comma-separated field projection (e.g. ?_fields=id,name)"
1983
+ }
1984
+ ];
1985
+ var ID_PATH_PARAM = {
1986
+ name: "id",
1987
+ in: "path",
1988
+ required: true,
1989
+ schema: { type: "string" },
1990
+ description: "Item id"
1991
+ };
1992
+ function toOpenApiPath(fastifyPath) {
1993
+ return fastifyPath.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, "{$1}");
1994
+ }
1995
+ function extractPathParams(fastifyPath) {
1996
+ const matches = fastifyPath.match(/:([a-zA-Z_][a-zA-Z0-9_]*)/g) ?? [];
1997
+ return matches.map((m) => ({
1998
+ name: m.slice(1),
1999
+ in: "path",
2000
+ required: true,
2001
+ schema: { type: "string" }
2002
+ }));
2003
+ }
2004
+ function singular(name) {
2005
+ return name.endsWith("s") ? name.slice(0, -1) : name;
2006
+ }
2007
+ function schemaRef(name) {
2008
+ return { $ref: `#/components/schemas/${name}` };
2009
+ }
2010
+ function jsonContent(schema) {
2011
+ return { "application/json": { schema } };
2012
+ }
2013
+ function ok(schema, description = "OK") {
2014
+ return { description, content: jsonContent(schema) };
2015
+ }
2016
+ function buildCrudPaths(collection, base, schemaName) {
2017
+ const ref = schemaRef(schemaName);
2018
+ const tag = collection;
2019
+ const sing = singular(collection);
2020
+ const collPath = `${base}/${collection}`;
2021
+ const itemPath = `${base}/${collection}/{id}`;
2022
+ return {
2023
+ [collPath]: {
2024
+ get: {
2025
+ summary: `List ${collection}`,
2026
+ tags: [tag],
2027
+ parameters: COLLECTION_QUERY_PARAMS,
2028
+ responses: {
2029
+ "200": {
2030
+ description: "OK",
2031
+ content: jsonContent({ type: "array", items: ref }),
2032
+ headers: {
2033
+ "X-Total-Count": {
2034
+ description: "Total items (when using ?_page / ?_limit)",
2035
+ schema: { type: "integer" }
2036
+ }
2037
+ }
2038
+ }
2039
+ }
2040
+ },
2041
+ post: {
2042
+ summary: `Create ${sing}`,
2043
+ tags: [tag],
2044
+ requestBody: { required: true, content: jsonContent(ref) },
2045
+ responses: { "201": ok(ref, "Created") }
2046
+ }
2047
+ },
2048
+ [itemPath]: {
2049
+ get: {
2050
+ summary: `Get ${sing}`,
2051
+ tags: [tag],
2052
+ parameters: [
2053
+ ID_PATH_PARAM,
2054
+ ...COLLECTION_QUERY_PARAMS.filter(
2055
+ (p) => ["_expand", "_embed", "_fields"].includes(p.name)
2056
+ )
2057
+ ],
2058
+ responses: { "200": ok(ref), "404": { description: "Not found" } }
2059
+ },
2060
+ put: {
2061
+ summary: `Replace ${sing}`,
2062
+ tags: [tag],
2063
+ parameters: [ID_PATH_PARAM],
2064
+ requestBody: { required: true, content: jsonContent(ref) },
2065
+ responses: { "200": ok(ref), "404": { description: "Not found" } }
2066
+ },
2067
+ patch: {
2068
+ summary: `Update ${sing}`,
2069
+ tags: [tag],
2070
+ parameters: [ID_PATH_PARAM],
2071
+ requestBody: { required: false, content: jsonContent(ref) },
2072
+ responses: { "200": ok(ref), "404": { description: "Not found" } }
2073
+ },
2074
+ delete: {
2075
+ summary: `Delete ${sing}`,
2076
+ tags: [tag],
2077
+ parameters: [ID_PATH_PARAM],
2078
+ responses: { "200": ok(ref, "Deleted item returned"), "404": { description: "Not found" } }
2079
+ }
2080
+ }
2081
+ };
2082
+ }
2083
+ function buildRelationPaths(relations, base) {
2084
+ const paths = {};
2085
+ for (const [source, fields] of Object.entries(relations)) {
2086
+ for (const [key, def] of Object.entries(fields)) {
2087
+ if (def.type === "many2many") {
2088
+ const forwardPath = `${base}/${source}/{id}/${key}`;
2089
+ const inversePath = `${base}/${def.target}/{id}/${source}`;
2090
+ paths[forwardPath] = {
2091
+ get: {
2092
+ summary: `List ${def.target} linked to ${singular(source)} via ${def.through}`,
2093
+ tags: [source],
2094
+ parameters: [ID_PATH_PARAM],
2095
+ responses: {
2096
+ "200": {
2097
+ description: "OK",
2098
+ content: jsonContent({ type: "array", items: { type: "object" } })
2099
+ },
2100
+ "404": { description: `${singular(source)} not found` }
2101
+ }
2102
+ }
2103
+ };
2104
+ paths[inversePath] = {
2105
+ get: {
2106
+ summary: `List ${source} linked to ${singular(def.target)} via ${def.through} (inverse)`,
2107
+ tags: [def.target],
2108
+ parameters: [ID_PATH_PARAM],
2109
+ responses: {
2110
+ "200": {
2111
+ description: "OK",
2112
+ content: jsonContent({ type: "array", items: { type: "object" } })
2113
+ },
2114
+ "404": { description: `${singular(def.target)} not found` }
2115
+ }
2116
+ }
2117
+ };
2118
+ } else {
2119
+ const parentSing = singular(def.target);
2120
+ const collPath = `${base}/${def.target}/{id}/${source}`;
2121
+ const isOne2One = def.type === "one2one";
2122
+ const responseSchema = isOne2One ? { type: "object" } : { type: "array", items: { type: "object" } };
2123
+ paths[collPath] = {
2124
+ get: {
2125
+ summary: isOne2One ? `Get ${singular(source)} belonging to ${parentSing}` : `List ${source} belonging to ${parentSing}`,
2126
+ tags: [def.target],
2127
+ parameters: [ID_PATH_PARAM],
2128
+ responses: {
2129
+ "200": { description: "OK", content: jsonContent(responseSchema) },
2130
+ "404": { description: `${parentSing} not found` }
2131
+ }
2132
+ }
2133
+ };
2134
+ if (!isOne2One) {
2135
+ const itemPath = `${base}/${def.target}/{id}/${source}/{childId}`;
2136
+ paths[itemPath] = {
2137
+ get: {
2138
+ summary: `Get single ${singular(source)} scoped to ${parentSing}`,
2139
+ tags: [def.target],
2140
+ parameters: [
2141
+ ID_PATH_PARAM,
2142
+ { name: "childId", in: "path", required: true, schema: { type: "string" } }
2143
+ ],
2144
+ responses: {
2145
+ "200": { description: "OK", content: jsonContent({ type: "object" }) },
2146
+ "404": { description: "Not found" }
2147
+ }
2148
+ }
2149
+ };
2150
+ }
2151
+ }
2152
+ }
2153
+ }
2154
+ return paths;
2155
+ }
2156
+ function buildCustomRoutePaths(routes, base) {
2157
+ const paths = {};
2158
+ for (const route of routes) {
2159
+ const openApiPath = toOpenApiPath(`${base}${route.path}`);
2160
+ const method = route.method.toLowerCase();
2161
+ const pathParams = extractPathParams(route.path);
2162
+ const responses = {};
2163
+ if (route.error) {
2164
+ responses[String(route.error)] = { description: `Forced error ${route.error}` };
2165
+ } else {
2166
+ const statuses = /* @__PURE__ */ new Set();
2167
+ for (const s of route.scenarios ?? []) statuses.add(s.response.status ?? 200);
2168
+ if (route.otherwise) statuses.add(route.otherwise.status ?? 200);
2169
+ if (route.response) statuses.add(route.response.status ?? 200);
2170
+ if (statuses.size === 0) statuses.add(200);
2171
+ for (const status of statuses) {
2172
+ const bodySource = (route.scenarios ?? []).find((s) => (s.response.status ?? 200) === status)?.response.body ?? (route.otherwise?.status ?? 200) === status ? route.otherwise?.body : route.response?.body;
2173
+ responses[String(status)] = {
2174
+ description: status < 400 ? "OK" : "Error",
2175
+ ...bodySource != null ? { content: jsonContent(inferResponseSchema(bodySource)) } : {}
2176
+ };
2177
+ }
2178
+ }
2179
+ const desc = route.handler ? `Handler: ${route.handler}()` : route.scenarios?.length ? `Conditional scenarios (${route.scenarios.length})` : "Custom static route";
2180
+ const operation = {
2181
+ summary: `${route.method.toUpperCase()} ${route.path}`,
2182
+ description: desc,
2183
+ tags: ["custom"],
2184
+ ...pathParams.length > 0 ? { parameters: pathParams } : {},
2185
+ responses
2186
+ };
2187
+ if (!paths[openApiPath]) paths[openApiPath] = {};
2188
+ paths[openApiPath][method] = operation;
2189
+ }
2190
+ return paths;
2191
+ }
2192
+ function inferResponseSchema(body) {
2193
+ if (body === null || body === void 0) return {};
2194
+ if (typeof body !== "object" || Array.isArray(body)) return { type: "object" };
2195
+ const properties = {};
2196
+ for (const [key, value] of Object.entries(body)) {
2197
+ properties[key] = { type: jsToOpenApiType2(value) };
2198
+ }
2199
+ return { type: "object", properties };
2200
+ }
2201
+ function jsToOpenApiType2(value) {
2202
+ if (value === null || value === void 0) return "string";
2203
+ if (typeof value === "boolean") return "boolean";
2204
+ if (typeof value === "number") return Number.isInteger(value) ? "integer" : "number";
2205
+ if (Array.isArray(value)) return "array";
2206
+ if (typeof value === "object") return "object";
2207
+ return "string";
2208
+ }
2209
+
2210
+ // src/openapi/generateOpenApi.ts
2211
+ function generateOpenApi(storage, options, title = "yRest API") {
2212
+ const collections = Object.keys(storage.getData());
2213
+ const relations = storage.getRelations();
2214
+ const schemaBlock = storage.getSchema();
2215
+ const customRoutes = storage.getRoutes();
2216
+ const base = options.base ?? "";
2217
+ const schemas = {};
2218
+ for (const collection of collections) {
2219
+ const items = storage.getCollection(collection) ?? [];
2220
+ const fieldDefs = schemaBlock[collection] ?? {};
2221
+ const schemaName = toSchemaName(collection);
2222
+ schemas[schemaName] = buildCollectionSchema(items, fieldDefs);
2223
+ }
2224
+ const paths = {};
2225
+ for (const collection of collections) {
2226
+ const schemaName = toSchemaName(collection);
2227
+ Object.assign(paths, buildCrudPaths(collection, base, schemaName));
2228
+ }
2229
+ Object.assign(paths, buildRelationPaths(relations, base));
2230
+ Object.assign(paths, buildCustomRoutePaths(customRoutes, base));
2231
+ return {
2232
+ openapi: "3.0.3",
2233
+ info: {
2234
+ title,
2235
+ version: "1.0.0",
2236
+ description: "Generated by yRest from db.yml"
2237
+ },
2238
+ servers: [
2239
+ {
2240
+ url: `http://${options.host}:${options.port}${base}`,
2241
+ description: "yRest mock server"
2242
+ }
2243
+ ],
2244
+ paths,
2245
+ components: { schemas }
2246
+ };
2247
+ }
2248
+ function toSchemaName(collection) {
2249
+ const withoutS = collection.endsWith("s") ? collection.slice(0, -1) : collection;
2250
+ return withoutS.charAt(0).toUpperCase() + withoutS.slice(1);
2251
+ }
2252
+
2253
+ // src/router/routes/openapi.routes.ts
2254
+ var import_yaml2 = require("yaml");
2255
+ var OpenApiRouteCommand = class {
2256
+ constructor(storage, options) {
2257
+ this.storage = storage;
2258
+ this.options = options;
2259
+ }
2260
+ storage;
2261
+ options;
2262
+ register(server) {
2263
+ server.get("/_openapi", (_req, reply) => {
2264
+ const doc = generateOpenApi(this.storage, this.options);
2265
+ reply.header("Content-Type", "text/yaml; charset=utf-8");
2266
+ return reply.send((0, import_yaml2.stringify)(doc, { lineWidth: 0, aliasDuplicateObjects: false }));
2267
+ });
2268
+ server.get("/_openapi.json", (_req, reply) => {
2269
+ return reply.send(generateOpenApi(this.storage, this.options));
2270
+ });
2271
+ }
1269
2272
  };
1270
2273
 
1271
2274
  // src/router/routes/snapshot.routes.ts
@@ -1348,6 +2351,7 @@ async function createServer(storage, options, handlers = /* @__PURE__ */ new Map
1348
2351
  }
1349
2352
  const commands = [
1350
2353
  new AboutRouteCommand(storage, options, handlers),
2354
+ new OpenApiRouteCommand(storage, options),
1351
2355
  ...options.snapshot ? [new SnapshotRouteCommand(storage)] : [],
1352
2356
  new CustomRouteCommand(storage, options.base, handlers),
1353
2357
  ...buildResourceRouteCommands(storage, options)
@@ -1435,11 +2439,11 @@ var yrestOptionsSchema = import_zod.z.object({
1435
2439
 
1436
2440
  // src/config/loadConfigFile.ts
1437
2441
  var import_node_fs4 = require("fs");
1438
- var import_yaml2 = require("yaml");
2442
+ var import_yaml3 = require("yaml");
1439
2443
  function loadConfigFile(configPath) {
1440
2444
  if (!(0, import_node_fs4.existsSync)(configPath)) return {};
1441
2445
  const raw = (0, import_node_fs4.readFileSync)(configPath, "utf8");
1442
- return (0, import_yaml2.parse)(raw) ?? {};
2446
+ return (0, import_yaml3.parse)(raw) ?? {};
1443
2447
  }
1444
2448
 
1445
2449
  // src/utils/handlers.ts
@@ -1592,7 +2596,7 @@ function registerServe(program2) {
1592
2596
  // src/cli/commands/handler.ts
1593
2597
  var import_node_fs7 = require("fs");
1594
2598
  var import_node_path5 = require("path");
1595
- var import_yaml3 = require("yaml");
2599
+ var import_yaml4 = require("yaml");
1596
2600
  var HANDLERS_FILE_HEADER = `// yrest handlers \u2014 loaded via "handlers:" in yrest.config.yml
1597
2601
  // Handler signature: (req: HandlerRequest) => HandlerResponse | Promise<HandlerResponse>
1598
2602
  // See https://github.com/aggiovato/yaml-rest for full documentation
@@ -1648,13 +2652,13 @@ function registerHandler(program2) {
1648
2652
  console.error(` Error: database file not found at ${dbPath}`);
1649
2653
  process.exit(1);
1650
2654
  }
1651
- const raw = (0, import_yaml3.parse)((0, import_node_fs7.readFileSync)(dbPath, "utf8")) ?? {};
2655
+ const raw = (0, import_yaml4.parse)((0, import_node_fs7.readFileSync)(dbPath, "utf8")) ?? {};
1652
2656
  if (!Array.isArray(raw["_routes"])) raw["_routes"] = [];
1653
2657
  const routes = raw["_routes"];
1654
2658
  const alreadyRegistered = routes.some((r) => r["handler"] === name);
1655
2659
  if (!alreadyRegistered) {
1656
2660
  routes.push({ method: flags.method.toUpperCase(), path: flags.path, handler: name });
1657
- (0, import_node_fs7.writeFileSync)(dbPath, (0, import_yaml3.stringify)(raw), "utf8");
2661
+ (0, import_node_fs7.writeFileSync)(dbPath, (0, import_yaml4.stringify)(raw), "utf8");
1658
2662
  console.log(` Added _routes entry to ${(0, import_node_path5.basename)(dbPath)}`);
1659
2663
  } else {
1660
2664
  console.log(` Handler "${name}" already in _routes \u2014 skipped`);
@@ -1677,6 +2681,33 @@ function registerHandler(program2) {
1677
2681
  });
1678
2682
  }
1679
2683
 
2684
+ // src/cli/commands/openapi.ts
2685
+ var import_node_fs8 = require("fs");
2686
+ var import_node_path6 = require("path");
2687
+ var import_yaml5 = require("yaml");
2688
+ function registerOpenApi(program2) {
2689
+ program2.command("openapi <file>").description("Generate an OpenAPI 3.0 spec from a db.yml file").option("-o, --output <file>", "Output file (default: openapi.yaml / openapi.json)").option("--format <fmt>", "Output format: yaml (default) or json", "yaml").option("--stdout", "Print to stdout instead of writing a file").option("--base <base>", "Base path prefix applied to all routes", "").option("--port <port>", "Server port shown in the servers block", "3070").option("--host <host>", "Server host shown in the servers block", "localhost").option("--title <title>", "API title for the info block", "yRest API").action((file, opts) => {
2690
+ const storage = createYrestStorage((0, import_node_path6.resolve)(file));
2691
+ const options = yrestOptionsSchema.parse({
2692
+ file,
2693
+ base: opts["base"] || void 0,
2694
+ port: Number(opts["port"]) || 3070,
2695
+ host: opts["host"] || "localhost"
2696
+ });
2697
+ const doc = generateOpenApi(storage, options, opts["title"]);
2698
+ const isJson = opts["format"] === "json";
2699
+ const output = isJson ? JSON.stringify(doc, null, 2) : (0, import_yaml5.stringify)(doc, { lineWidth: 0, aliasDuplicateObjects: false });
2700
+ if (opts["stdout"]) {
2701
+ process.stdout.write(output);
2702
+ return;
2703
+ }
2704
+ const defaultFile = isJson ? "openapi.json" : "openapi.yaml";
2705
+ const outFile = (0, import_node_path6.resolve)(opts["output"] ?? defaultFile);
2706
+ (0, import_node_fs8.writeFileSync)(outFile, output, "utf8");
2707
+ console.log(`\u2713 OpenAPI spec written to ${outFile}`);
2708
+ });
2709
+ }
2710
+
1680
2711
  // src/cli/index.ts
1681
2712
  var require2 = (0, import_module.createRequire)(importMetaUrl);
1682
2713
  var { version } = require2("../../package.json");
@@ -1684,4 +2715,5 @@ import_commander.program.name("yrest").description("Zero-config REST API mock se
1684
2715
  registerInit(import_commander.program);
1685
2716
  registerServe(import_commander.program);
1686
2717
  registerHandler(import_commander.program);
2718
+ registerOpenApi(import_commander.program);
1687
2719
  import_commander.program.parse();