@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.
@@ -9,62 +9,470 @@ import { existsSync, writeFileSync } from "fs";
9
9
  import { resolve } from "path";
10
10
 
11
11
  // src/cli/commands/templates/basic.ts
12
- var basicTemplate = `users:
12
+ var basicTemplate = `# yrest basic sample
13
+ # Run: npx @yrest/cli serve db.yml
14
+ # Docs: GET http://localhost:3070/_about
15
+
16
+ users:
13
17
  - id: 1
14
- name: Ana
15
- email: ana@test.com
18
+ name: Ana Garc\xEDa
19
+ email: ana@example.com
20
+ role: admin
21
+ active: true
16
22
  - id: 2
17
- name: Luis
18
- email: luis@test.com
23
+ name: Luis Mart\xEDnez
24
+ email: luis@example.com
25
+ role: editor
26
+ active: true
27
+ - id: 3
28
+ name: Sara L\xF3pez
29
+ email: sara@example.com
30
+ role: user
31
+ active: true
32
+ - id: 4
33
+ name: Diego Ruiz
34
+ email: diego@example.com
35
+ role: user
36
+ active: false
19
37
 
20
38
  products:
21
39
  - id: 1
22
- name: Laptop
23
- price: 999
40
+ name: Laptop Pro 15
41
+ price: 1299.99
42
+ stock: 15
43
+ category: electronics
44
+ featured: true
45
+ - id: 2
46
+ name: Wireless Mouse
47
+ price: 39.99
48
+ stock: 80
49
+ category: accessories
50
+ featured: false
51
+ - id: 3
52
+ name: Mechanical Keyboard
53
+ price: 129.99
54
+ stock: 45
55
+ category: accessories
56
+ featured: true
57
+ - id: 4
58
+ name: 4K Monitor 27"
59
+ price: 549.99
60
+ stock: 20
61
+ category: electronics
62
+ featured: true
63
+ - id: 5
64
+ name: USB-C Hub 7-in-1
65
+ price: 49.99
66
+ stock: 100
67
+ category: accessories
68
+ featured: false
69
+
70
+ categories:
71
+ - id: 1
72
+ name: Electronics
73
+ slug: electronics
74
+ description: Laptops, monitors and computing gear
24
75
  - id: 2
25
- name: Phone
26
- price: 499
76
+ name: Accessories
77
+ slug: accessories
78
+ description: Peripherals and add-ons
79
+
80
+ # Try these queries:
81
+ # GET /users?role=admin
82
+ # GET /products?featured=true&_sort=price&_order=asc
83
+ # GET /products?price_lte=100
84
+ # GET /users?_q=garcia
85
+ # GET /products?_fields=id,name,price&_page=1&_limit=3
27
86
  `;
28
87
 
29
88
  // src/cli/commands/templates/relational.ts
30
- var relationalTemplate = `_rel:
89
+ var relationalTemplate = `# yrest relational sample \u2014 blog
90
+ # Demonstrates: many2one, one2one and many2many relationships
91
+ # Run: npx @yrest/cli serve db.yml
92
+ # Docs: GET http://localhost:3070/_about
93
+
94
+ _rel:
95
+ # many2one \u2014 posts and comments belong to a user
31
96
  posts:
32
97
  userId: users
98
+ # many2many \u2014 posts can have multiple tags via the post_tags pivot
99
+ tags:
100
+ type: many2many
101
+ target: tags
102
+ through: post_tags
103
+ foreignKey: postId
104
+ otherKey: tagId
33
105
  comments:
34
106
  postId: posts
107
+ userId: users
108
+
109
+ # one2one \u2014 each user has exactly one profile
110
+ profiles:
111
+ userId:
112
+ type: one2one
113
+ target: users
35
114
 
36
115
  users:
37
116
  - id: 1
38
- name: Ana
39
- email: ana@test.com
117
+ name: Ana Garc\xEDa
118
+ email: ana@example.com
119
+ role: author
120
+ - id: 2
121
+ name: Luis Mart\xEDnez
122
+ email: luis@example.com
123
+ role: author
124
+ - id: 3
125
+ name: Sara L\xF3pez
126
+ email: sara@example.com
127
+ role: reader
128
+
129
+ profiles:
130
+ - id: 1
131
+ userId: 1
132
+ bio: Full-stack developer and open-source enthusiast
133
+ avatar: https://i.pravatar.cc/150?img=1
134
+ website: https://ana.dev
135
+ - id: 2
136
+ userId: 2
137
+ bio: Backend engineer, coffee addict
138
+ avatar: https://i.pravatar.cc/150?img=2
139
+ website: https://luisdev.io
140
+ - id: 3
141
+ userId: 3
142
+ bio: Designer turned frontend developer
143
+ avatar: https://i.pravatar.cc/150?img=3
144
+ website: null
145
+
146
+ tags:
147
+ - id: 1
148
+ name: typescript
149
+ color: "#3178c6"
40
150
  - id: 2
41
- name: Luis
42
- email: luis@test.com
151
+ name: api
152
+ color: "#10b981"
153
+ - id: 3
154
+ name: testing
155
+ color: "#f59e0b"
156
+ - id: 4
157
+ name: devtools
158
+ color: "#8b5cf6"
159
+ - id: 5
160
+ name: yaml
161
+ color: "#ef4444"
43
162
 
44
163
  posts:
45
164
  - id: 1
46
- title: First post
47
- body: Content of the first post
165
+ title: Getting started with TypeScript
166
+ slug: getting-started-typescript
167
+ body: TypeScript adds static typing to JavaScript, catching errors at compile time...
48
168
  userId: 1
169
+ published: true
170
+ views: 1420
171
+ createdAt: "2024-11-01"
49
172
  - id: 2
50
- title: Second post
51
- body: Content of the second post
173
+ title: Building REST APIs with Fastify
174
+ slug: rest-apis-fastify
175
+ body: Fastify is the fastest Node.js web framework, perfect for building APIs...
52
176
  userId: 1
177
+ published: true
178
+ views: 980
179
+ createdAt: "2024-11-15"
180
+ - id: 3
181
+ title: Testing strategies for modern apps
182
+ slug: testing-strategies-modern
183
+ body: A solid test strategy covers unit, integration and end-to-end scenarios...
184
+ userId: 2
185
+ published: true
186
+ views: 640
187
+ createdAt: "2024-12-03"
188
+ - id: 4
189
+ title: YAML as a database format
190
+ slug: yaml-database-format
191
+ body: YAML is human-readable and expressive enough for mock data during development...
192
+ userId: 2
193
+ published: false
194
+ views: 0
195
+ createdAt: "2025-01-10"
196
+
197
+ # pivot table for posts \u2194 tags (many2many)
198
+ post_tags:
199
+ - { id: 1, postId: 1, tagId: 1 }
200
+ - { id: 2, postId: 1, tagId: 2 }
201
+ - { id: 3, postId: 2, tagId: 2 }
202
+ - { id: 4, postId: 2, tagId: 4 }
203
+ - { id: 5, postId: 3, tagId: 3 }
204
+ - { id: 6, postId: 3, tagId: 1 }
205
+ - { id: 7, postId: 4, tagId: 5 }
206
+ - { id: 8, postId: 4, tagId: 2 }
53
207
 
54
208
  comments:
55
209
  - id: 1
56
- body: Great post!
210
+ body: Great introduction! This helped me a lot.
57
211
  postId: 1
212
+ userId: 3
213
+ likes: 5
58
214
  - id: 2
59
- body: Thanks for sharing
215
+ body: Could you cover generics in a follow-up post?
60
216
  postId: 1
217
+ userId: 2
218
+ likes: 3
219
+ - id: 3
220
+ body: Fastify is indeed much faster than Express in my benchmarks.
221
+ postId: 2
222
+ userId: 3
223
+ likes: 8
224
+ - id: 4
225
+ body: Do you have a GitHub repo with these examples?
226
+ postId: 2
227
+ userId: 1
228
+ likes: 1
229
+ - id: 5
230
+ body: E2E tests are underrated. Solid post!
231
+ postId: 3
232
+ userId: 1
233
+ likes: 4
234
+
235
+ # Try these queries:
236
+ # GET /posts?published=true&_sort=views&_order=desc
237
+ # GET /posts/1?_expand=user \u2192 embeds author object
238
+ # GET /users/1?_embed=posts \u2192 embeds posts array
239
+ # GET /users/1/profiles \u2192 one2one nested route
240
+ # GET /posts/1/tags \u2192 many2many nested route
241
+ # GET /posts/1?_embed=tags \u2192 many2many via ?_embed
242
+ # GET /users/1?_embed=profiles \u2192 one2one via ?_embed (returns object, not array)
243
+ `;
244
+
245
+ // src/cli/commands/templates/ecommerce.ts
246
+ var ecommerceTemplate = `# yrest ecommerce sample
247
+ # Demonstrates: many2one, many2many, _routes with scenarios, template vars and delay
248
+ # Run: npx @yrest/cli serve db.yml
249
+ # Docs: GET http://localhost:3070/_about
250
+
251
+ _rel:
252
+ # many2one
253
+ orders:
254
+ userId: users
255
+ order_items:
256
+ orderId: orders
257
+ productId: products
258
+
259
+ # many2many \u2014 products belong to multiple categories via pivot
260
+ products:
261
+ categories:
262
+ type: many2many
263
+ target: categories
264
+ through: product_categories
265
+ foreignKey: productId
266
+ otherKey: categoryId
267
+
268
+ _routes:
269
+ # Login with conditional scenarios
270
+ - method: POST
271
+ path: /auth/login
272
+ scenarios:
273
+ - when:
274
+ body.email: admin@example.com
275
+ body.password: secret
276
+ response:
277
+ status: 200
278
+ body:
279
+ token: tok-admin-abc123
280
+ role: admin
281
+ userId: 1
282
+ - when:
283
+ body.email: user@example.com
284
+ body.password: secret
285
+ response:
286
+ status: 200
287
+ body:
288
+ token: tok-user-xyz789
289
+ role: user
290
+ userId: 2
291
+ otherwise:
292
+ status: 401
293
+ body:
294
+ error: Invalid credentials
295
+
296
+ # Logout \u2014 always 204
297
+ - method: POST
298
+ path: /auth/logout
299
+ response:
300
+ status: 204
301
+
302
+ # Static featured products list
303
+ - method: GET
304
+ path: /store/featured
305
+ response:
306
+ status: 200
307
+ body:
308
+ - id: 1
309
+ name: Laptop Pro 15
310
+ price: 1299.99
311
+ badge: Best Seller
312
+ - id: 4
313
+ name: 4K Monitor 27"
314
+ price: 549.99
315
+ badge: New Arrival
316
+ - id: 3
317
+ name: Mechanical Keyboard
318
+ price: 129.99
319
+ badge: On Sale
320
+
321
+ # Template variables \u2014 echoes the requested product id
322
+ - method: GET
323
+ path: /products/:id/summary
324
+ response:
325
+ status: 200
326
+ body:
327
+ productId: "{{params.id}}"
328
+ requestedAt: "{{now}}"
329
+ source: mock
330
+
331
+ # Cancel order with simulated latency and template vars
332
+ - method: POST
333
+ path: /orders/:id/cancel
334
+ delay: 500
335
+ response:
336
+ status: 200
337
+ body:
338
+ orderId: "{{params.id}}"
339
+ status: cancelled
340
+ cancelledAt: "{{now}}"
341
+
342
+ # Simulate a service outage for testing error handling
343
+ - method: GET
344
+ path: /store/inventory/sync
345
+ error: 503
346
+ errorBody:
347
+ message: Inventory service temporarily unavailable
348
+ retryAfter: 30
349
+
350
+ users:
351
+ - id: 1
352
+ name: Ana Garc\xEDa
353
+ email: admin@example.com
354
+ role: admin
355
+ active: true
356
+ - id: 2
357
+ name: Luis Mart\xEDnez
358
+ email: user@example.com
359
+ role: user
360
+ active: true
361
+ - id: 3
362
+ name: Sara L\xF3pez
363
+ email: sara@example.com
364
+ role: user
365
+ active: true
366
+ - id: 4
367
+ name: Diego Ruiz
368
+ email: diego@example.com
369
+ role: user
370
+ active: false
371
+
372
+ categories:
373
+ - id: 1
374
+ name: Laptops
375
+ slug: laptops
376
+ - id: 2
377
+ name: Peripherals
378
+ slug: peripherals
379
+ - id: 3
380
+ name: Monitors
381
+ slug: monitors
382
+ - id: 4
383
+ name: Accessories
384
+ slug: accessories
385
+
386
+ products:
387
+ - id: 1
388
+ name: Laptop Pro 15
389
+ description: High-performance laptop for developers
390
+ price: 1299.99
391
+ stock: 15
392
+ sku: LAP-001
393
+ active: true
394
+ - id: 2
395
+ name: Wireless Mouse
396
+ description: Ergonomic wireless mouse with USB-C receiver
397
+ price: 39.99
398
+ stock: 80
399
+ sku: MOU-001
400
+ active: true
401
+ - id: 3
402
+ name: Mechanical Keyboard
403
+ description: Tactile switches, full RGB, TKL layout
404
+ price: 129.99
405
+ stock: 45
406
+ sku: KEY-001
407
+ active: true
408
+ - id: 4
409
+ name: 4K Monitor 27"
410
+ description: IPS panel, 144Hz, HDR400
411
+ price: 549.99
412
+ stock: 20
413
+ sku: MON-001
414
+ active: true
415
+ - id: 5
416
+ name: USB-C Hub 7-in-1
417
+ description: HDMI, SD card and USB-A ports
418
+ price: 49.99
419
+ stock: 100
420
+ sku: HUB-001
421
+ active: true
422
+
423
+ # pivot table for products \u2194 categories (many2many)
424
+ product_categories:
425
+ - { id: 1, productId: 1, categoryId: 1 }
426
+ - { id: 2, productId: 2, categoryId: 2 }
427
+ - { id: 3, productId: 2, categoryId: 4 }
428
+ - { id: 4, productId: 3, categoryId: 2 }
429
+ - { id: 5, productId: 3, categoryId: 4 }
430
+ - { id: 6, productId: 4, categoryId: 3 }
431
+ - { id: 7, productId: 5, categoryId: 4 }
432
+
433
+ orders:
434
+ - id: 1
435
+ userId: 2
436
+ status: delivered
437
+ total: 1339.98
438
+ createdAt: "2024-12-10"
439
+ - id: 2
440
+ userId: 3
441
+ status: processing
442
+ total: 179.98
443
+ createdAt: "2025-01-15"
444
+ - id: 3
445
+ userId: 2
446
+ status: pending
447
+ total: 549.99
448
+ createdAt: "2025-02-01"
449
+
450
+ order_items:
451
+ - { id: 1, orderId: 1, productId: 1, quantity: 1, unitPrice: 1299.99 }
452
+ - { id: 2, orderId: 1, productId: 2, quantity: 1, unitPrice: 39.99 }
453
+ - { id: 3, orderId: 2, productId: 3, quantity: 1, unitPrice: 129.99 }
454
+ - { id: 4, orderId: 2, productId: 2, quantity: 1, unitPrice: 39.99 }
455
+ - { id: 5, orderId: 3, productId: 4, quantity: 1, unitPrice: 549.99 }
456
+
457
+ # Try these queries:
458
+ # POST /auth/login { "email": "admin@example.com", "password": "secret" }
459
+ # GET /store/featured
460
+ # GET /products/1/summary
461
+ # GET /products/1/categories \u2192 many2many nested route
462
+ # GET /products/1?_embed=categories \u2192 many2many via ?_embed
463
+ # GET /users/2?_embed=orders \u2192 many2one ?_embed
464
+ # GET /users/2/orders \u2192 nested route
465
+ # GET /orders/1?_embed=order_items \u2192 nested items
466
+ # POST /orders/1/cancel \u2192 delayed response with template vars
467
+ # GET /store/inventory/sync \u2192 forced 503 error
61
468
  `;
62
469
 
63
470
  // src/cli/commands/templates/index.ts
64
- var SAMPLES = ["basic", "relational"];
471
+ var SAMPLES = ["basic", "relational", "ecommerce"];
65
472
  var templates = {
66
473
  basic: basicTemplate,
67
- relational: relationalTemplate
474
+ relational: relationalTemplate,
475
+ ecommerce: ecommerceTemplate
68
476
  };
69
477
 
70
478
  // src/cli/commands/init.ts
@@ -114,6 +522,80 @@ import { resolve as resolve2, dirname } from "path";
114
522
  import { randomUUID } from "crypto";
115
523
  import { parse, stringify } from "yaml";
116
524
 
525
+ // src/storage/parseRelations.ts
526
+ function parseRelations(raw) {
527
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
528
+ const result = {};
529
+ for (const [collection, fields] of Object.entries(raw)) {
530
+ if (!fields || typeof fields !== "object" || Array.isArray(fields)) continue;
531
+ result[collection] = {};
532
+ for (const [key, value] of Object.entries(fields)) {
533
+ const def = normaliseRelationDef(key, value);
534
+ if (def) result[collection][key] = def;
535
+ }
536
+ }
537
+ return result;
538
+ }
539
+ function normaliseRelationDef(key, value) {
540
+ if (typeof value === "string") {
541
+ return { type: "many2one", target: value };
542
+ }
543
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
544
+ const v = value;
545
+ const type = v["type"];
546
+ const nested = v["nested"] === true ? true : void 0;
547
+ if (type === "many2one" || type === void 0) {
548
+ const target = v["target"];
549
+ if (typeof target !== "string") return null;
550
+ return nested ? { type: "many2one", target, nested } : { type: "many2one", target };
551
+ }
552
+ if (type === "one2one") {
553
+ const target = v["target"];
554
+ if (typeof target !== "string") return null;
555
+ return nested ? { type: "one2one", target, nested } : { type: "one2one", target };
556
+ }
557
+ if (type === "many2many") {
558
+ const target = typeof v["target"] === "string" ? v["target"] : key;
559
+ const through = v["through"];
560
+ const foreignKey = v["foreignKey"];
561
+ const otherKey = v["otherKey"];
562
+ if (typeof through !== "string" || typeof foreignKey !== "string" || typeof otherKey !== "string")
563
+ return null;
564
+ return nested ? { type: "many2many", target, through, foreignKey, otherKey, nested } : { type: "many2many", target, through, foreignKey, otherKey };
565
+ }
566
+ return null;
567
+ }
568
+
569
+ // src/storage/parseSchema.ts
570
+ function parseSchema(raw) {
571
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
572
+ const result = {};
573
+ for (const [collection, fields] of Object.entries(raw)) {
574
+ if (!fields || typeof fields !== "object" || Array.isArray(fields)) continue;
575
+ result[collection] = {};
576
+ for (const [field, value] of Object.entries(fields)) {
577
+ const def = normaliseFieldDef(value);
578
+ if (def) result[collection][field] = def;
579
+ }
580
+ }
581
+ return result;
582
+ }
583
+ function normaliseFieldDef(value) {
584
+ if (value === "required") return { required: true };
585
+ if (value === "optional") return { required: false };
586
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
587
+ const v = value;
588
+ const def = {};
589
+ if (v["required"] === true || v["required"] === false) def.required = v["required"];
590
+ if (typeof v["type"] === "string" && ["string", "integer", "number", "boolean", "object", "array"].includes(v["type"]))
591
+ def.type = v["type"];
592
+ if (typeof v["format"] === "string") def.format = v["format"];
593
+ if (Array.isArray(v["enum"])) def.enum = v["enum"];
594
+ if (typeof v["description"] === "string") def.description = v["description"];
595
+ if (v["default"] !== void 0) def.default = v["default"];
596
+ return def;
597
+ }
598
+
117
599
  // src/utils/deepCopy.ts
118
600
  function deepCopyData(source) {
119
601
  return Object.fromEntries(
@@ -125,10 +607,12 @@ function deepCopyData(source) {
125
607
  function createYrestStorage(filePath) {
126
608
  const absPath = resolve2(filePath);
127
609
  const raw = parse(readFileSync(absPath, "utf8")) ?? {};
128
- const relations = raw["_rel"] ?? {};
610
+ const RESERVED = /* @__PURE__ */ new Set(["_rel", "_routes", "_schema"]);
611
+ const relations = parseRelations(raw["_rel"]);
129
612
  const routes = Array.isArray(raw["_routes"]) ? raw["_routes"] : [];
613
+ const schema = parseSchema(raw["_schema"]);
130
614
  const data = Object.fromEntries(
131
- Object.entries(raw).filter(([key]) => key !== "_rel" && key !== "_routes")
615
+ Object.entries(raw).filter(([key]) => !RESERVED.has(key))
132
616
  );
133
617
  let snapshot = {
134
618
  data: deepCopyData(data),
@@ -142,6 +626,9 @@ function createYrestStorage(filePath) {
142
626
  getRelations() {
143
627
  return relations;
144
628
  },
629
+ getSchema() {
630
+ return schema;
631
+ },
145
632
  getRoutes() {
146
633
  return routes;
147
634
  },
@@ -162,9 +649,9 @@ function createYrestStorage(filePath) {
162
649
  },
163
650
  reload() {
164
651
  const fresh = parse(readFileSync(absPath, "utf8")) ?? {};
165
- const freshRelations = fresh["_rel"] ?? {};
652
+ const freshRelations = parseRelations(fresh["_rel"]);
166
653
  const freshData = Object.fromEntries(
167
- Object.entries(fresh).filter(([key]) => key !== "_rel" && key !== "_routes")
654
+ Object.entries(fresh).filter(([key]) => !RESERVED.has(key))
168
655
  );
169
656
  for (const key of Object.keys(data)) delete data[key];
170
657
  Object.assign(data, freshData);
@@ -247,16 +734,7 @@ function hasTemplates(value) {
247
734
  return typeof value === "string" ? value.includes("{{") : JSON.stringify(value).includes("{{");
248
735
  }
249
736
 
250
- // src/router/templates/about.template.ts
251
- var _dir = dirname2(fileURLToPath(import.meta.url));
252
- var LOGO_SRC = (() => {
253
- try {
254
- const buf = readFileSync2(join(_dir, "../../assets/logo-color.png"));
255
- return `data:image/png;base64,${buf.toString("base64")}`;
256
- } catch {
257
- return "";
258
- }
259
- })();
737
+ // src/router/templates/about.helpers.ts
260
738
  var METHOD_COLOR = {
261
739
  GET: "#3fb950",
262
740
  POST: "#58a6ff",
@@ -285,7 +763,7 @@ function endpointRow(method, path, desc) {
285
763
  }
286
764
  function resourceAccordion(name, base, isOpen) {
287
765
  const p = `${base}/${name}`;
288
- const singular = name.endsWith("s") ? name.slice(0, -1) : name;
766
+ const singular2 = name.endsWith("s") ? name.slice(0, -1) : name;
289
767
  const rows = [
290
768
  endpointRow(
291
769
  "GET",
@@ -295,20 +773,20 @@ function resourceAccordion(name, base, isOpen) {
295
773
  endpointRow(
296
774
  "POST",
297
775
  p,
298
- `Create a new ${singular}. Auto-assigns <code>id</code> if not provided.`
776
+ `Create a new ${singular2}. Auto-assigns <code>id</code> if not provided.`
299
777
  ),
300
- endpointRow("GET", `${p}/:id`, `Get a single ${singular} by id.`),
778
+ endpointRow("GET", `${p}/:id`, `Get a single ${singular2} by id.`),
301
779
  endpointRow(
302
780
  "PUT",
303
781
  `${p}/:id`,
304
- `Fully replace a ${singular}. Original <code>id</code> is always preserved.`
782
+ `Fully replace a ${singular2}. Original <code>id</code> is always preserved.`
305
783
  ),
306
784
  endpointRow(
307
785
  "PATCH",
308
786
  `${p}/:id`,
309
- `Partially update a ${singular} \u2014 only provided fields change.`
787
+ `Partially update a ${singular2} \u2014 only provided fields change.`
310
788
  ),
311
- endpointRow("DELETE", `${p}/:id`, `Delete a ${singular} and return it as confirmation.`)
789
+ endpointRow("DELETE", `${p}/:id`, `Delete a ${singular2} and return it as confirmation.`)
312
790
  ].join("");
313
791
  return `
314
792
  <details class="resource-card" ${isOpen ? "open" : ""}>
@@ -321,12 +799,145 @@ function resourceAccordion(name, base, isOpen) {
321
799
  </table>
322
800
  </details>`;
323
801
  }
802
+ function nestedRoutesAccordion(relations, base) {
803
+ const rows = [];
804
+ for (const [source, fields] of Object.entries(relations)) {
805
+ for (const [key, def] of Object.entries(fields)) {
806
+ const nestedBadge = def.nested ? ` ${badge("nested", "#facc15", "#facc1518")}` : "";
807
+ if (def.type === "many2many") {
808
+ const singular2 = source.endsWith("s") ? source.slice(0, -1) : source;
809
+ const m2mBadge = badge("many2many", "#818cf8", "#818cf818");
810
+ rows.push(
811
+ endpointRow(
812
+ "GET",
813
+ `${base}/${source}/:id/${key}`,
814
+ `List ${def.target} linked to a ${singular2} via ${def.through}. ${m2mBadge}${nestedBadge}`
815
+ )
816
+ );
817
+ const targetSingular = def.target.endsWith("s") ? def.target.slice(0, -1) : def.target;
818
+ rows.push(
819
+ endpointRow(
820
+ "GET",
821
+ `${base}/${def.target}/:id/${source}`,
822
+ `List ${source} linked to a ${targetSingular} via ${def.through} (inverse). ${m2mBadge}`
823
+ )
824
+ );
825
+ } else {
826
+ const path = `${base}/${def.target}/:id/${source}`;
827
+ const parentSingular = def.target.endsWith("s") ? def.target.slice(0, -1) : def.target;
828
+ const typeBadge = def.type === "one2one" ? ` ${badge("one2one", "#34d399", "#34d39918")}` : "";
829
+ rows.push(
830
+ endpointRow(
831
+ "GET",
832
+ path,
833
+ `${def.type === "one2one" ? "Get" : "List"} ${source} belonging to a ${parentSingular}.${typeBadge}${nestedBadge}`
834
+ )
835
+ );
836
+ }
837
+ }
838
+ }
839
+ if (!rows.length) return "";
840
+ return `
841
+ <details class="resource-card nested-card">
842
+ <summary>
843
+ <span class="resource-name">Nested routes</span>
844
+ <span class="route-count">${rows.length} route${rows.length !== 1 ? "s" : ""}</span>
845
+ </summary>
846
+ <table><tbody>${rows.join("")}</tbody></table>
847
+ </details>`;
848
+ }
849
+ function snapshotAccordion() {
850
+ return `
851
+ <details class="resource-card nested-card">
852
+ <summary>
853
+ <span class="resource-name">/_snapshot</span>
854
+ <span class="route-count">3 routes</span>
855
+ </summary>
856
+ <table><tbody>
857
+ ${endpointRow("GET", "/_snapshot", "Returns metadata of the current snapshot: <code>savedAt</code> and item counts per collection.")}
858
+ ${endpointRow("POST", "/_snapshot/save", "Replaces the stored snapshot with the current database state.")}
859
+ ${endpointRow("POST", "/_snapshot/reset", "Restores the database to the last saved snapshot and persists to disk.")}
860
+ </tbody></table>
861
+ </details>`;
862
+ }
863
+ function customRoutesAccordion(routes, base, handlers) {
864
+ if (!routes.length) return "";
865
+ const rows = routes.map((r) => {
866
+ const fullPath = `${base}${r.path}`;
867
+ const tags = [];
868
+ if (r.error) tags.push(`<span style="color:#f85149;font-size:11px">error\xB7${r.error}</span>`);
869
+ if (r.delay && r.delay > 0)
870
+ tags.push(`<span style="color:#fb923c;font-size:11px">delay\xB7${r.delay}ms</span>`);
871
+ if (r.scenarios?.length) {
872
+ const hasOr = r.scenarios.some((s) => Array.isArray(s.when));
873
+ tags.push(
874
+ `<span style="color:#a371f7;font-size:11px">scenarios\xB7${r.scenarios.length}${hasOr ? " (OR)" : ""}</span>`
875
+ );
876
+ }
877
+ if (r.otherwise)
878
+ tags.push(`<span style="color:var(--text-muted);font-size:11px">otherwise</span>`);
879
+ let desc;
880
+ if (r.error) {
881
+ desc = `Error injection \u2014 <code>${r.error}</code>`;
882
+ } else if (r.handler) {
883
+ const found = handlers.has(r.handler);
884
+ const handlerName = escapeHtml(r.handler);
885
+ desc = found ? `Handler \u2014 <code>${handlerName}()</code>` : `Handler \u2014 <code>${handlerName}()</code> <span style="color:#f85149">(not loaded)</span>`;
886
+ } else if (r.scenarios?.length) {
887
+ const hasTemplateInScenarios = r.scenarios.some((s) => s.response.body != null && hasTemplates(s.response.body)) || r.otherwise?.body != null && hasTemplates(r.otherwise.body);
888
+ desc = hasTemplateInScenarios ? `Scenarios \u2014 <code>{{\u2026}}</code>` : `Scenarios`;
889
+ } else if (r.response?.body != null && hasTemplates(r.response.body)) {
890
+ desc = `Dynamic \u2014 <code>{{\u2026}}</code>`;
891
+ } else {
892
+ const status = r.response?.status ?? 200;
893
+ desc = `Static \u2014 <code>${status}</code>${r.response?.headers ? ` + headers` : ""}`;
894
+ }
895
+ if (tags.length) desc += `&ensp;${tags.join("&ensp;")}`;
896
+ return endpointRow(r.method?.toUpperCase() ?? "GET", fullPath, desc);
897
+ });
898
+ return `
899
+ <details class="resource-card nested-card">
900
+ <summary>
901
+ <span class="resource-name">Custom routes</span>
902
+ <span class="route-count">${routes.length} route${routes.length !== 1 ? "s" : ""}</span>
903
+ </summary>
904
+ <table><tbody>
905
+ ${rows.join("")}
906
+ </tbody></table>
907
+ </details>`;
908
+ }
909
+ function handlersAccordion(handlers, routes, base) {
910
+ if (!handlers.size) return "";
911
+ const routesByHandler = /* @__PURE__ */ new Map();
912
+ for (const r of routes) {
913
+ if (r.handler) {
914
+ const list = routesByHandler.get(r.handler) ?? [];
915
+ list.push({ method: (r.method ?? "GET").toUpperCase(), path: `${base}${r.path}` });
916
+ routesByHandler.set(r.handler, list);
917
+ }
918
+ }
919
+ const rows = [...handlers.keys()].map((name) => {
920
+ const linked = routesByHandler.get(name);
921
+ const routeDesc = linked ? linked.map((r) => `<code>${r.method} ${r.path}</code>`).join(", ") : `<span style="color:var(--text-muted)">not referenced in _routes</span>`;
922
+ return endpointRow("fn", name + "()", routeDesc);
923
+ });
924
+ return `
925
+ <details class="resource-card nested-card">
926
+ <summary>
927
+ <span class="resource-name">Handlers</span>
928
+ <span class="route-count">${handlers.size} function${handlers.size !== 1 ? "s" : ""}</span>
929
+ </summary>
930
+ <table><tbody>
931
+ ${rows.join("")}
932
+ </tbody></table>
933
+ </details>`;
934
+ }
324
935
  function examplesBlock(collections, relations, base, host, options, firstCustomRoute) {
325
936
  const examples = [];
326
937
  const firstCol = collections[0];
327
938
  if (firstCol) {
328
939
  const p = `${host}${base}/${firstCol}`;
329
- const singular = firstCol.endsWith("s") ? firstCol.slice(0, -1) : firstCol;
940
+ const singular2 = firstCol.endsWith("s") ? firstCol.slice(0, -1) : firstCol;
330
941
  examples.push(
331
942
  `# List all ${firstCol}
332
943
  curl ${p}`,
@@ -334,52 +945,53 @@ curl ${p}`,
334
945
  curl "${p}?name=value"`,
335
946
  `# Sort and paginate
336
947
  curl "${p}?_sort=id&_order=desc&_page=1&_limit=5"`,
337
- `# Get single ${singular}
948
+ `# Get single ${singular2}
338
949
  curl ${p}/1`,
339
- `# Create ${singular}
950
+ `# Create ${singular2}
340
951
  curl -X POST ${p} \\
341
952
  -H "Content-Type: application/json" \\
342
953
  -d '{"name":"example"}'`,
343
- `# Partially update ${singular}
954
+ `# Partially update ${singular2}
344
955
  curl -X PATCH ${p}/1 \\
345
956
  -H "Content-Type: application/json" \\
346
957
  -d '{"name":"updated"}'`,
347
- `# Delete ${singular}
958
+ `# Delete ${singular2}
348
959
  curl -X DELETE ${p}/1`
349
960
  );
350
961
  }
351
962
  const firstRel = Object.entries(relations)[0];
352
963
  if (firstRel) {
353
964
  const [child, fields] = firstRel;
354
- const fk = Object.keys(fields)[0];
355
- const expandKey = fk.replace(/Id$/i, "");
356
- examples.push(
357
- `# Embed parent with ?_expand
358
- curl "${host}${base}/${child}/1?_expand=${expandKey}"`
359
- );
360
- }
361
- const firstRelEntry = Object.entries(relations)[0];
362
- if (firstRelEntry) {
363
- const [child, fields] = firstRelEntry;
364
- const parent = Object.values(fields)[0];
365
- if (parent) {
366
- examples.push(`# Nested resource
367
- curl ${host}${base}/${parent}/1/${child}`);
965
+ const firstField = Object.entries(fields)[0];
966
+ if (firstField) {
967
+ const [fk, def] = firstField;
968
+ if (def.type !== "many2many") {
969
+ const expandKey = fk.replace(/Id$/i, "");
970
+ examples.push(
971
+ `# Embed parent with ?_expand
972
+ curl "${host}${base}/${child}/1?_expand=${expandKey}"`,
973
+ `# Nested resource
974
+ curl ${host}${base}/${def.target}/1/${child}`
975
+ );
976
+ } else {
977
+ examples.push(`# Many-to-many embed
978
+ curl "${host}${base}/${child}/1/${fk}"`);
979
+ }
368
980
  }
369
981
  }
370
982
  if (options.pageable.enabled && firstCol) {
371
983
  examples.push(`# Pageable envelope
372
984
  curl "${host}${base}/${firstCol}?_page=2"`);
373
985
  }
374
- const firstParentRel = Object.entries(relations).find(
375
- ([, fields]) => Object.values(fields).includes(firstCol ?? "")
376
- );
377
986
  if (firstCol) {
378
987
  examples.push(
379
988
  `# Project fields with ?_fields
380
989
  curl "${host}${base}/${firstCol}?_fields=id,name"`
381
990
  );
382
991
  }
992
+ const firstParentRel = Object.entries(relations).find(
993
+ ([, fields]) => Object.values(fields).some((def) => def.type !== "many2many" && def.target === firstCol)
994
+ );
383
995
  if (firstParentRel && firstCol) {
384
996
  const [childName] = firstParentRel;
385
997
  examples.push(
@@ -405,9 +1017,21 @@ curl ${curlFlag}${fullPath}`);
405
1017
  const highlighted = examples.map((e) => e.replace(/^(#.+)$/gm, '<span class="cm">$1</span>')).join("\n\n");
406
1018
  return `<pre>${highlighted}</pre>`;
407
1019
  }
1020
+
1021
+ // src/router/templates/about.template.ts
1022
+ var _dir = dirname2(fileURLToPath(import.meta.url));
1023
+ var LOGO_SRC = (() => {
1024
+ try {
1025
+ const buf = readFileSync2(join(_dir, "../../assets/logo-color.png"));
1026
+ return `data:image/png;base64,${buf.toString("base64")}`;
1027
+ } catch {
1028
+ return "";
1029
+ }
1030
+ })();
408
1031
  function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map()) {
409
1032
  const collections = Object.keys(storage.getData());
410
1033
  const relations = storage.getRelations();
1034
+ const customRoutes = storage.getRoutes();
411
1035
  const base = options.base;
412
1036
  const host = `http://${options.host}:${options.port}`;
413
1037
  const modes = [];
@@ -421,105 +1045,6 @@ function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map(
421
1045
  if (options.idStrategy !== "increment")
422
1046
  modes.push(badge(`id \xB7 ${options.idStrategy}`, "#a371f7", "#a371f718"));
423
1047
  const accordions = collections.map((col, i) => resourceAccordion(col, base, i === 0)).join("");
424
- const nestedRows = [];
425
- for (const [child, fields] of Object.entries(relations)) {
426
- for (const [, parent] of Object.entries(fields)) {
427
- const nestedPath = `${base}/${parent}/:id/${child}`;
428
- const parentSingular = parent.endsWith("s") ? parent.slice(0, -1) : parent;
429
- nestedRows.push(
430
- endpointRow("GET", nestedPath, `List ${child} belonging to a ${parentSingular}.`)
431
- );
432
- }
433
- }
434
- const nestedAccordion = nestedRows.length ? `
435
- <details class="resource-card nested-card">
436
- <summary>
437
- <span class="resource-name">Nested routes</span>
438
- <span class="route-count">${nestedRows.length} route${nestedRows.length !== 1 ? "s" : ""}</span>
439
- </summary>
440
- <table><tbody>${nestedRows.join("")}</tbody></table>
441
- </details>` : "";
442
- const snapshotAccordion = options.snapshot ? `
443
- <details class="resource-card nested-card">
444
- <summary>
445
- <span class="resource-name">/_snapshot</span>
446
- <span class="route-count">3 routes</span>
447
- </summary>
448
- <table><tbody>
449
- ${endpointRow("GET", "/_snapshot", "Returns metadata of the current snapshot: <code>savedAt</code> and item counts per collection.")}
450
- ${endpointRow("POST", "/_snapshot/save", "Replaces the stored snapshot with the current database state.")}
451
- ${endpointRow("POST", "/_snapshot/reset", "Restores the database to the last saved snapshot and persists to disk.")}
452
- </tbody></table>
453
- </details>` : "";
454
- const customRoutes = storage.getRoutes();
455
- const customRoutesAccordion = customRoutes.length ? `
456
- <details class="resource-card nested-card">
457
- <summary>
458
- <span class="resource-name">Custom routes</span>
459
- <span class="route-count">${customRoutes.length} route${customRoutes.length !== 1 ? "s" : ""}</span>
460
- </summary>
461
- <table><tbody>
462
- ${customRoutes.map((r) => {
463
- const fullPath = `${base}${r.path}`;
464
- const tags = [];
465
- if (r.error) {
466
- tags.push(`<span style="color:#f85149;font-size:11px">error\xB7${r.error}</span>`);
467
- }
468
- if (r.delay && r.delay > 0) {
469
- tags.push(`<span style="color:#fb923c;font-size:11px">delay\xB7${r.delay}ms</span>`);
470
- }
471
- if (r.scenarios?.length) {
472
- const hasOr = r.scenarios.some((s) => Array.isArray(s.when));
473
- tags.push(
474
- `<span style="color:#a371f7;font-size:11px">scenarios\xB7${r.scenarios.length}${hasOr ? " (OR)" : ""}</span>`
475
- );
476
- }
477
- if (r.otherwise) {
478
- tags.push(`<span style="color:var(--text-muted);font-size:11px">otherwise</span>`);
479
- }
480
- let desc;
481
- if (r.error) {
482
- desc = `Error injection \u2014 <code>${r.error}</code>`;
483
- } else if (r.handler) {
484
- const found = handlers.has(r.handler);
485
- const handlerName = escapeHtml(r.handler);
486
- desc = found ? `Handler \u2014 <code>${handlerName}()</code>` : `Handler \u2014 <code>${handlerName}()</code> <span style="color:#f85149">(not loaded)</span>`;
487
- } else if (r.scenarios?.length) {
488
- const hasTemplateInScenarios = r.scenarios.some((s) => s.response.body != null && hasTemplates(s.response.body)) || r.otherwise?.body != null && hasTemplates(r.otherwise.body);
489
- desc = hasTemplateInScenarios ? `Scenarios \u2014 <code>{{\u2026}}</code>` : `Scenarios`;
490
- } else if (r.response?.body != null && hasTemplates(r.response.body)) {
491
- desc = `Dynamic \u2014 <code>{{\u2026}}</code>`;
492
- } else {
493
- const status = r.response?.status ?? 200;
494
- desc = `Static \u2014 <code>${status}</code>${r.response?.headers ? ` + headers` : ""}`;
495
- }
496
- if (tags.length) desc += `&ensp;${tags.join("&ensp;")}`;
497
- return endpointRow(r.method?.toUpperCase() ?? "GET", fullPath, desc);
498
- }).join("")}
499
- </tbody></table>
500
- </details>` : "";
501
- const routesByHandler = /* @__PURE__ */ new Map();
502
- for (const r of customRoutes) {
503
- if (r.handler) {
504
- const list = routesByHandler.get(r.handler) ?? [];
505
- list.push({ method: (r.method ?? "GET").toUpperCase(), path: `${base}${r.path}` });
506
- routesByHandler.set(r.handler, list);
507
- }
508
- }
509
- const handlersAccordion = handlers.size > 0 ? `
510
- <details class="resource-card nested-card">
511
- <summary>
512
- <span class="resource-name">Handlers</span>
513
- <span class="route-count">${handlers.size} function${handlers.size !== 1 ? "s" : ""}</span>
514
- </summary>
515
- <table><tbody>
516
- ${[...handlers.keys()].map((name) => {
517
- const routes = routesByHandler.get(name);
518
- const routeDesc = routes ? routes.map((r) => `<code>${r.method} ${r.path}</code>`).join(", ") : `<span style="color:var(--text-muted)">not referenced in _routes</span>`;
519
- return endpointRow("fn", name + "()", routeDesc);
520
- }).join("")}
521
- </tbody></table>
522
- </details>` : "";
523
1048
  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.`;
524
1049
  return `<!DOCTYPE html>
525
1050
  <html lang="en">
@@ -667,10 +1192,10 @@ function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map(
667
1192
  <h2>Endpoints</h2>
668
1193
  <div class="endpoints-grid">
669
1194
  ${accordions}
670
- ${nestedAccordion}
671
- ${snapshotAccordion}
672
- ${customRoutesAccordion}
673
- ${handlersAccordion}
1195
+ ${nestedRoutesAccordion(relations, base)}
1196
+ ${options.snapshot ? snapshotAccordion() : ""}
1197
+ ${customRoutesAccordion(customRoutes, base, handlers)}
1198
+ ${handlersAccordion(handlers, customRoutes, base)}
674
1199
  </div>
675
1200
 
676
1201
  <h2>Query Parameters</h2>
@@ -870,10 +1395,11 @@ function expandItems(input, query, resource, storage) {
870
1395
  const resourceRelations = storage.getRelations()[resource] ?? {};
871
1396
  const expansions = /* @__PURE__ */ new Map();
872
1397
  for (const expandKey of keys) {
873
- for (const [field, parentCollection] of Object.entries(resourceRelations)) {
1398
+ for (const [field, def] of Object.entries(resourceRelations)) {
1399
+ if (def.type === "many2many") continue;
874
1400
  const derivedKey = field.replace(/Id$/i, "");
875
- if (derivedKey === expandKey || parentCollection === expandKey || parentCollection === `${expandKey}s`) {
876
- expansions.set(expandKey, { field, parentCollection });
1401
+ if (derivedKey === expandKey || def.target === expandKey || def.target === `${expandKey}s`) {
1402
+ expansions.set(expandKey, { field, parentCollection: def.target });
877
1403
  break;
878
1404
  }
879
1405
  }
@@ -902,10 +1428,24 @@ function embedItems(input, query, resource, storage) {
902
1428
  const relations = storage.getRelations();
903
1429
  const embeds = /* @__PURE__ */ new Map();
904
1430
  for (const embedKey of keys) {
1431
+ const ownRelations = relations[resource] ?? {};
1432
+ if (embedKey in ownRelations) {
1433
+ const def = ownRelations[embedKey];
1434
+ if (def.type === "many2many") {
1435
+ embeds.set(embedKey, {
1436
+ kind: "many2many",
1437
+ target: def.target,
1438
+ through: def.through,
1439
+ foreignKey: def.foreignKey,
1440
+ otherKey: def.otherKey
1441
+ });
1442
+ continue;
1443
+ }
1444
+ }
905
1445
  outer: for (const [childCollection, fields] of Object.entries(relations)) {
906
- for (const [fkField, parentCollection] of Object.entries(fields)) {
907
- if (parentCollection === resource && childCollection === embedKey) {
908
- embeds.set(embedKey, { childCollection, fkField });
1446
+ for (const [fkField, def] of Object.entries(fields)) {
1447
+ if ((def.type === "many2one" || def.type === "one2one") && def.target === resource && childCollection === embedKey) {
1448
+ embeds.set(embedKey, { kind: def.type, childCollection, fkField });
909
1449
  break outer;
910
1450
  }
911
1451
  }
@@ -914,10 +1454,57 @@ function embedItems(input, query, resource, storage) {
914
1454
  if (embeds.size === 0) return isArray ? items : input;
915
1455
  const result = items.map((item) => {
916
1456
  const out = { ...item };
917
- for (const [embedKey, { childCollection, fkField }] of embeds) {
918
- out[embedKey] = (storage.getCollection(childCollection) ?? []).filter(
919
- (child) => String(child[fkField]) === String(item["id"])
920
- );
1457
+ for (const [embedKey, spec] of embeds) {
1458
+ if (spec.kind === "many2many") {
1459
+ const pivot = storage.getCollection(spec.through) ?? [];
1460
+ const matchingIds = new Set(
1461
+ pivot.filter((row) => String(row[spec.foreignKey]) === String(item["id"])).map((row) => String(row[spec.otherKey]))
1462
+ );
1463
+ out[embedKey] = (storage.getCollection(spec.target) ?? []).filter(
1464
+ (t) => matchingIds.has(String(t["id"]))
1465
+ );
1466
+ } else if (spec.kind === "one2one") {
1467
+ out[embedKey] = (storage.getCollection(spec.childCollection) ?? []).find(
1468
+ (child) => String(child[spec.fkField]) === String(item["id"])
1469
+ ) ?? null;
1470
+ } else {
1471
+ out[embedKey] = (storage.getCollection(spec.childCollection) ?? []).filter(
1472
+ (child) => String(child[spec.fkField]) === String(item["id"])
1473
+ );
1474
+ }
1475
+ }
1476
+ return out;
1477
+ });
1478
+ return isArray ? result : result[0];
1479
+ }
1480
+ function applyNested(input, resource, storage) {
1481
+ const isArray = Array.isArray(input);
1482
+ const items = isArray ? input : [input];
1483
+ const resourceRelations = storage.getRelations()[resource] ?? {};
1484
+ const nestedDefs = Object.entries(resourceRelations).filter(([, def]) => def.nested === true);
1485
+ if (nestedDefs.length === 0) return input;
1486
+ const result = items.map((item) => {
1487
+ const out = { ...item };
1488
+ for (const [key, def] of nestedDefs) {
1489
+ if (def.type === "many2many") {
1490
+ const pivot = storage.getCollection(def.through) ?? [];
1491
+ const matchingIds = new Set(
1492
+ pivot.filter((row) => String(row[def.foreignKey]) === String(item["id"])).map((row) => String(row[def.otherKey]))
1493
+ );
1494
+ out[key] = (storage.getCollection(def.target) ?? []).filter(
1495
+ (t) => matchingIds.has(String(t["id"]))
1496
+ );
1497
+ } else {
1498
+ const foreignKeyValue = item[key];
1499
+ if (foreignKeyValue === void 0) continue;
1500
+ const parent = (storage.getCollection(def.target) ?? []).find(
1501
+ (p) => String(p["id"]) === String(foreignKeyValue)
1502
+ );
1503
+ if (parent !== void 0) {
1504
+ const embedKey = key.replace(/Id$/i, "");
1505
+ out[embedKey] = parent;
1506
+ }
1507
+ }
921
1508
  }
922
1509
  return out;
923
1510
  });
@@ -957,7 +1544,12 @@ var CollectionRouteCommand = class {
957
1544
  const totalPages = Math.ceil(totalItems / limit) || 1;
958
1545
  const data = projectFields(
959
1546
  embedItems(
960
- expandItems(paginate(sorted, page, limit), req.query, this.resource, this.storage),
1547
+ expandItems(
1548
+ applyNested(paginate(sorted, page, limit), this.resource, this.storage),
1549
+ req.query,
1550
+ this.resource,
1551
+ this.storage
1552
+ ),
961
1553
  req.query,
962
1554
  this.resource,
963
1555
  this.storage
@@ -989,7 +1581,12 @@ var CollectionRouteCommand = class {
989
1581
  }
990
1582
  return projectFields(
991
1583
  embedItems(
992
- expandItems(result, req.query, this.resource, this.storage),
1584
+ expandItems(
1585
+ applyNested(result, this.resource, this.storage),
1586
+ req.query,
1587
+ this.resource,
1588
+ this.storage
1589
+ ),
993
1590
  req.query,
994
1591
  this.resource,
995
1592
  this.storage
@@ -1094,7 +1691,7 @@ var CustomRouteCommand = class {
1094
1691
  url,
1095
1692
  handler: async (req, reply) => {
1096
1693
  if (route.delay && route.delay > 0) {
1097
- await new Promise((resolve5) => setTimeout(resolve5, route.delay));
1694
+ await new Promise((resolve6) => setTimeout(resolve6, route.delay));
1098
1695
  }
1099
1696
  if (route.error) {
1100
1697
  const body2 = route.errorBody ?? { error: `Forced error ${route.error}` };
@@ -1170,7 +1767,12 @@ var ItemRouteCommand = class {
1170
1767
  const fields = (req.query["_fields"] ?? "").split(",").map((f) => f.trim()).filter(Boolean);
1171
1768
  return projectFields(
1172
1769
  embedItems(
1173
- expandItems(item, req.query, this.resource, this.storage),
1770
+ expandItems(
1771
+ applyNested(item, this.resource, this.storage),
1772
+ req.query,
1773
+ this.resource,
1774
+ this.storage
1775
+ ),
1174
1776
  req.query,
1175
1777
  this.resource,
1176
1778
  this.storage
@@ -1213,31 +1815,432 @@ var NestedRouteCommand = class {
1213
1815
  relations;
1214
1816
  base;
1215
1817
  register(server) {
1216
- for (const [child, fields] of Object.entries(this.relations)) {
1217
- for (const [field, parent] of Object.entries(fields)) {
1218
- const collectionPath = `${this.base}/${parent}/:id/${child}`;
1219
- const itemPath = `${this.base}/${parent}/:id/${child}/:childId`;
1220
- server.get(collectionPath, (req, reply) => {
1221
- const parentCollection = this.storage.getCollection(parent) ?? [];
1222
- const parentItem = findById(parentCollection, req.params.id);
1223
- if (!parentItem) return reply.status(404).send({ error: "Not found" });
1224
- const children = (this.storage.getCollection(child) ?? []).filter(
1225
- (item) => String(item[field]) === req.params.id
1226
- );
1227
- return children;
1228
- });
1229
- server.get(itemPath, (req, reply) => {
1230
- const parentCollection = this.storage.getCollection(parent) ?? [];
1231
- const parentItem = findById(parentCollection, req.params.id);
1232
- if (!parentItem) return reply.status(404).send({ error: "Not found" });
1233
- const childItem = (this.storage.getCollection(child) ?? []).find(
1234
- (item) => String(item[field]) === req.params.id && String(item["id"]) === req.params.childId
1235
- );
1236
- if (!childItem) return reply.status(404).send({ error: "Not found" });
1237
- return childItem;
1238
- });
1818
+ for (const [source, fields] of Object.entries(this.relations)) {
1819
+ for (const [key, def] of Object.entries(fields)) {
1820
+ if (def.type === "many2many") {
1821
+ this.registerMany2Many(server, source, key, def);
1822
+ } else {
1823
+ this.registerFkRelation(server, source, key, def.target, def.type);
1824
+ }
1825
+ }
1826
+ }
1827
+ }
1828
+ registerFkRelation(server, child, fkField, parent, type) {
1829
+ const collectionPath = `${this.base}/${parent}/:id/${child}`;
1830
+ const itemPath = `${this.base}/${parent}/:id/${child}/:childId`;
1831
+ server.get(collectionPath, (req, reply) => {
1832
+ const parentCollection = this.storage.getCollection(parent) ?? [];
1833
+ const parentItem = findById(parentCollection, req.params.id);
1834
+ if (!parentItem) return reply.status(404).send({ error: "Not found" });
1835
+ const all = (this.storage.getCollection(child) ?? []).filter(
1836
+ (item) => String(item[fkField]) === req.params.id
1837
+ );
1838
+ if (type === "one2one") return all[0] ?? reply.status(404).send({ error: "Not found" });
1839
+ return all;
1840
+ });
1841
+ if (type === "many2one") {
1842
+ server.get(itemPath, (req, reply) => {
1843
+ const parentCollection = this.storage.getCollection(parent) ?? [];
1844
+ const parentItem = findById(parentCollection, req.params.id);
1845
+ if (!parentItem) return reply.status(404).send({ error: "Not found" });
1846
+ const childItem = (this.storage.getCollection(child) ?? []).find(
1847
+ (item) => String(item[fkField]) === req.params.id && String(item["id"]) === req.params.childId
1848
+ );
1849
+ if (!childItem) return reply.status(404).send({ error: "Not found" });
1850
+ return childItem;
1851
+ });
1852
+ }
1853
+ }
1854
+ registerMany2Many(server, source, alias, def) {
1855
+ server.get(`${this.base}/${source}/:id/${alias}`, (req, reply) => {
1856
+ const sourceCollection = this.storage.getCollection(source) ?? [];
1857
+ const sourceItem = findById(sourceCollection, req.params.id);
1858
+ if (!sourceItem) return reply.status(404).send({ error: "Not found" });
1859
+ const pivot = this.storage.getCollection(def.through) ?? [];
1860
+ const matchingIds = new Set(
1861
+ pivot.filter((row) => String(row[def.foreignKey]) === req.params.id).map((row) => String(row[def.otherKey]))
1862
+ );
1863
+ return (this.storage.getCollection(def.target) ?? []).filter(
1864
+ (t) => matchingIds.has(String(t["id"]))
1865
+ );
1866
+ });
1867
+ server.get(`${this.base}/${def.target}/:id/${source}`, (req, reply) => {
1868
+ const targetCollection = this.storage.getCollection(def.target) ?? [];
1869
+ const targetItem = findById(targetCollection, req.params.id);
1870
+ if (!targetItem) return reply.status(404).send({ error: "Not found" });
1871
+ const pivot = this.storage.getCollection(def.through) ?? [];
1872
+ const matchingIds = new Set(
1873
+ pivot.filter((row) => String(row[def.otherKey]) === req.params.id).map((row) => String(row[def.foreignKey]))
1874
+ );
1875
+ return (this.storage.getCollection(source) ?? []).filter(
1876
+ (t) => matchingIds.has(String(t["id"]))
1877
+ );
1878
+ });
1879
+ }
1880
+ };
1881
+
1882
+ // src/openapi/inferSchema.ts
1883
+ function buildCollectionSchema(items, fieldDefs = {}) {
1884
+ const sample = items.slice(0, 10);
1885
+ const inferredTypes = /* @__PURE__ */ new Map();
1886
+ for (const item of sample) {
1887
+ for (const [key, value] of Object.entries(item)) {
1888
+ if (!inferredTypes.has(key)) {
1889
+ inferredTypes.set(key, jsToOpenApiType(value));
1890
+ }
1891
+ }
1892
+ }
1893
+ const allFields = /* @__PURE__ */ new Set([...inferredTypes.keys(), ...Object.keys(fieldDefs)]);
1894
+ const properties = {};
1895
+ const required = [];
1896
+ for (const field of allFields) {
1897
+ const def = fieldDefs[field];
1898
+ const inferred = inferredTypes.get(field) ?? "string";
1899
+ const prop = {
1900
+ type: def?.type ?? inferred
1901
+ };
1902
+ if (def?.format) prop.format = def.format;
1903
+ if (def?.description) prop.description = def.description;
1904
+ if (def?.enum) prop.enum = def.enum;
1905
+ if (def?.default !== void 0) prop.default = def.default;
1906
+ properties[field] = prop;
1907
+ if (def?.required === true) required.push(field);
1908
+ }
1909
+ const schema = { type: "object", properties };
1910
+ if (required.length > 0) schema.required = required;
1911
+ return schema;
1912
+ }
1913
+ function jsToOpenApiType(value) {
1914
+ if (value === null || value === void 0) return "string";
1915
+ if (typeof value === "boolean") return "boolean";
1916
+ if (typeof value === "number") return Number.isInteger(value) ? "integer" : "number";
1917
+ if (Array.isArray(value)) return "array";
1918
+ if (typeof value === "object") return "object";
1919
+ return "string";
1920
+ }
1921
+
1922
+ // src/openapi/buildPaths.ts
1923
+ var COLLECTION_QUERY_PARAMS = [
1924
+ { name: "_page", in: "query", schema: { type: "integer" }, description: "Page number (1-based)" },
1925
+ { name: "_limit", in: "query", schema: { type: "integer" }, description: "Items per page" },
1926
+ { name: "_sort", in: "query", schema: { type: "string" }, description: "Field name to sort by" },
1927
+ {
1928
+ name: "_order",
1929
+ in: "query",
1930
+ schema: { type: "string", enum: ["asc", "desc"] },
1931
+ description: "Sort direction"
1932
+ },
1933
+ {
1934
+ name: "_q",
1935
+ in: "query",
1936
+ schema: { type: "string" },
1937
+ description: "Full-text search across all scalar fields (case-insensitive)"
1938
+ },
1939
+ {
1940
+ name: "_expand",
1941
+ in: "query",
1942
+ schema: { type: "string" },
1943
+ description: "Embed related parent object inline (e.g. ?_expand=user)"
1944
+ },
1945
+ {
1946
+ name: "_embed",
1947
+ in: "query",
1948
+ schema: { type: "string" },
1949
+ description: "Embed child collection into each item (e.g. ?_embed=posts)"
1950
+ },
1951
+ {
1952
+ name: "_fields",
1953
+ in: "query",
1954
+ schema: { type: "string" },
1955
+ description: "Comma-separated field projection (e.g. ?_fields=id,name)"
1956
+ }
1957
+ ];
1958
+ var ID_PATH_PARAM = {
1959
+ name: "id",
1960
+ in: "path",
1961
+ required: true,
1962
+ schema: { type: "string" },
1963
+ description: "Item id"
1964
+ };
1965
+ function toOpenApiPath(fastifyPath) {
1966
+ return fastifyPath.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, "{$1}");
1967
+ }
1968
+ function extractPathParams(fastifyPath) {
1969
+ const matches = fastifyPath.match(/:([a-zA-Z_][a-zA-Z0-9_]*)/g) ?? [];
1970
+ return matches.map((m) => ({
1971
+ name: m.slice(1),
1972
+ in: "path",
1973
+ required: true,
1974
+ schema: { type: "string" }
1975
+ }));
1976
+ }
1977
+ function singular(name) {
1978
+ return name.endsWith("s") ? name.slice(0, -1) : name;
1979
+ }
1980
+ function schemaRef(name) {
1981
+ return { $ref: `#/components/schemas/${name}` };
1982
+ }
1983
+ function jsonContent(schema) {
1984
+ return { "application/json": { schema } };
1985
+ }
1986
+ function ok(schema, description = "OK") {
1987
+ return { description, content: jsonContent(schema) };
1988
+ }
1989
+ function buildCrudPaths(collection, base, schemaName) {
1990
+ const ref = schemaRef(schemaName);
1991
+ const tag = collection;
1992
+ const sing = singular(collection);
1993
+ const collPath = `${base}/${collection}`;
1994
+ const itemPath = `${base}/${collection}/{id}`;
1995
+ return {
1996
+ [collPath]: {
1997
+ get: {
1998
+ summary: `List ${collection}`,
1999
+ tags: [tag],
2000
+ parameters: COLLECTION_QUERY_PARAMS,
2001
+ responses: {
2002
+ "200": {
2003
+ description: "OK",
2004
+ content: jsonContent({ type: "array", items: ref }),
2005
+ headers: {
2006
+ "X-Total-Count": {
2007
+ description: "Total items (when using ?_page / ?_limit)",
2008
+ schema: { type: "integer" }
2009
+ }
2010
+ }
2011
+ }
2012
+ }
2013
+ },
2014
+ post: {
2015
+ summary: `Create ${sing}`,
2016
+ tags: [tag],
2017
+ requestBody: { required: true, content: jsonContent(ref) },
2018
+ responses: { "201": ok(ref, "Created") }
2019
+ }
2020
+ },
2021
+ [itemPath]: {
2022
+ get: {
2023
+ summary: `Get ${sing}`,
2024
+ tags: [tag],
2025
+ parameters: [
2026
+ ID_PATH_PARAM,
2027
+ ...COLLECTION_QUERY_PARAMS.filter(
2028
+ (p) => ["_expand", "_embed", "_fields"].includes(p.name)
2029
+ )
2030
+ ],
2031
+ responses: { "200": ok(ref), "404": { description: "Not found" } }
2032
+ },
2033
+ put: {
2034
+ summary: `Replace ${sing}`,
2035
+ tags: [tag],
2036
+ parameters: [ID_PATH_PARAM],
2037
+ requestBody: { required: true, content: jsonContent(ref) },
2038
+ responses: { "200": ok(ref), "404": { description: "Not found" } }
2039
+ },
2040
+ patch: {
2041
+ summary: `Update ${sing}`,
2042
+ tags: [tag],
2043
+ parameters: [ID_PATH_PARAM],
2044
+ requestBody: { required: false, content: jsonContent(ref) },
2045
+ responses: { "200": ok(ref), "404": { description: "Not found" } }
2046
+ },
2047
+ delete: {
2048
+ summary: `Delete ${sing}`,
2049
+ tags: [tag],
2050
+ parameters: [ID_PATH_PARAM],
2051
+ responses: { "200": ok(ref, "Deleted item returned"), "404": { description: "Not found" } }
2052
+ }
2053
+ }
2054
+ };
2055
+ }
2056
+ function buildRelationPaths(relations, base) {
2057
+ const paths = {};
2058
+ for (const [source, fields] of Object.entries(relations)) {
2059
+ for (const [key, def] of Object.entries(fields)) {
2060
+ if (def.type === "many2many") {
2061
+ const forwardPath = `${base}/${source}/{id}/${key}`;
2062
+ const inversePath = `${base}/${def.target}/{id}/${source}`;
2063
+ paths[forwardPath] = {
2064
+ get: {
2065
+ summary: `List ${def.target} linked to ${singular(source)} via ${def.through}`,
2066
+ tags: [source],
2067
+ parameters: [ID_PATH_PARAM],
2068
+ responses: {
2069
+ "200": {
2070
+ description: "OK",
2071
+ content: jsonContent({ type: "array", items: { type: "object" } })
2072
+ },
2073
+ "404": { description: `${singular(source)} not found` }
2074
+ }
2075
+ }
2076
+ };
2077
+ paths[inversePath] = {
2078
+ get: {
2079
+ summary: `List ${source} linked to ${singular(def.target)} via ${def.through} (inverse)`,
2080
+ tags: [def.target],
2081
+ parameters: [ID_PATH_PARAM],
2082
+ responses: {
2083
+ "200": {
2084
+ description: "OK",
2085
+ content: jsonContent({ type: "array", items: { type: "object" } })
2086
+ },
2087
+ "404": { description: `${singular(def.target)} not found` }
2088
+ }
2089
+ }
2090
+ };
2091
+ } else {
2092
+ const parentSing = singular(def.target);
2093
+ const collPath = `${base}/${def.target}/{id}/${source}`;
2094
+ const isOne2One = def.type === "one2one";
2095
+ const responseSchema = isOne2One ? { type: "object" } : { type: "array", items: { type: "object" } };
2096
+ paths[collPath] = {
2097
+ get: {
2098
+ summary: isOne2One ? `Get ${singular(source)} belonging to ${parentSing}` : `List ${source} belonging to ${parentSing}`,
2099
+ tags: [def.target],
2100
+ parameters: [ID_PATH_PARAM],
2101
+ responses: {
2102
+ "200": { description: "OK", content: jsonContent(responseSchema) },
2103
+ "404": { description: `${parentSing} not found` }
2104
+ }
2105
+ }
2106
+ };
2107
+ if (!isOne2One) {
2108
+ const itemPath = `${base}/${def.target}/{id}/${source}/{childId}`;
2109
+ paths[itemPath] = {
2110
+ get: {
2111
+ summary: `Get single ${singular(source)} scoped to ${parentSing}`,
2112
+ tags: [def.target],
2113
+ parameters: [
2114
+ ID_PATH_PARAM,
2115
+ { name: "childId", in: "path", required: true, schema: { type: "string" } }
2116
+ ],
2117
+ responses: {
2118
+ "200": { description: "OK", content: jsonContent({ type: "object" }) },
2119
+ "404": { description: "Not found" }
2120
+ }
2121
+ }
2122
+ };
2123
+ }
2124
+ }
2125
+ }
2126
+ }
2127
+ return paths;
2128
+ }
2129
+ function buildCustomRoutePaths(routes, base) {
2130
+ const paths = {};
2131
+ for (const route of routes) {
2132
+ const openApiPath = toOpenApiPath(`${base}${route.path}`);
2133
+ const method = route.method.toLowerCase();
2134
+ const pathParams = extractPathParams(route.path);
2135
+ const responses = {};
2136
+ if (route.error) {
2137
+ responses[String(route.error)] = { description: `Forced error ${route.error}` };
2138
+ } else {
2139
+ const statuses = /* @__PURE__ */ new Set();
2140
+ for (const s of route.scenarios ?? []) statuses.add(s.response.status ?? 200);
2141
+ if (route.otherwise) statuses.add(route.otherwise.status ?? 200);
2142
+ if (route.response) statuses.add(route.response.status ?? 200);
2143
+ if (statuses.size === 0) statuses.add(200);
2144
+ for (const status of statuses) {
2145
+ const bodySource = (route.scenarios ?? []).find((s) => (s.response.status ?? 200) === status)?.response.body ?? (route.otherwise?.status ?? 200) === status ? route.otherwise?.body : route.response?.body;
2146
+ responses[String(status)] = {
2147
+ description: status < 400 ? "OK" : "Error",
2148
+ ...bodySource != null ? { content: jsonContent(inferResponseSchema(bodySource)) } : {}
2149
+ };
1239
2150
  }
1240
2151
  }
2152
+ const desc = route.handler ? `Handler: ${route.handler}()` : route.scenarios?.length ? `Conditional scenarios (${route.scenarios.length})` : "Custom static route";
2153
+ const operation = {
2154
+ summary: `${route.method.toUpperCase()} ${route.path}`,
2155
+ description: desc,
2156
+ tags: ["custom"],
2157
+ ...pathParams.length > 0 ? { parameters: pathParams } : {},
2158
+ responses
2159
+ };
2160
+ if (!paths[openApiPath]) paths[openApiPath] = {};
2161
+ paths[openApiPath][method] = operation;
2162
+ }
2163
+ return paths;
2164
+ }
2165
+ function inferResponseSchema(body) {
2166
+ if (body === null || body === void 0) return {};
2167
+ if (typeof body !== "object" || Array.isArray(body)) return { type: "object" };
2168
+ const properties = {};
2169
+ for (const [key, value] of Object.entries(body)) {
2170
+ properties[key] = { type: jsToOpenApiType2(value) };
2171
+ }
2172
+ return { type: "object", properties };
2173
+ }
2174
+ function jsToOpenApiType2(value) {
2175
+ if (value === null || value === void 0) return "string";
2176
+ if (typeof value === "boolean") return "boolean";
2177
+ if (typeof value === "number") return Number.isInteger(value) ? "integer" : "number";
2178
+ if (Array.isArray(value)) return "array";
2179
+ if (typeof value === "object") return "object";
2180
+ return "string";
2181
+ }
2182
+
2183
+ // src/openapi/generateOpenApi.ts
2184
+ function generateOpenApi(storage, options, title = "yRest API") {
2185
+ const collections = Object.keys(storage.getData());
2186
+ const relations = storage.getRelations();
2187
+ const schemaBlock = storage.getSchema();
2188
+ const customRoutes = storage.getRoutes();
2189
+ const base = options.base ?? "";
2190
+ const schemas = {};
2191
+ for (const collection of collections) {
2192
+ const items = storage.getCollection(collection) ?? [];
2193
+ const fieldDefs = schemaBlock[collection] ?? {};
2194
+ const schemaName = toSchemaName(collection);
2195
+ schemas[schemaName] = buildCollectionSchema(items, fieldDefs);
2196
+ }
2197
+ const paths = {};
2198
+ for (const collection of collections) {
2199
+ const schemaName = toSchemaName(collection);
2200
+ Object.assign(paths, buildCrudPaths(collection, base, schemaName));
2201
+ }
2202
+ Object.assign(paths, buildRelationPaths(relations, base));
2203
+ Object.assign(paths, buildCustomRoutePaths(customRoutes, base));
2204
+ return {
2205
+ openapi: "3.0.3",
2206
+ info: {
2207
+ title,
2208
+ version: "1.0.0",
2209
+ description: "Generated by yRest from db.yml"
2210
+ },
2211
+ servers: [
2212
+ {
2213
+ url: `http://${options.host}:${options.port}${base}`,
2214
+ description: "yRest mock server"
2215
+ }
2216
+ ],
2217
+ paths,
2218
+ components: { schemas }
2219
+ };
2220
+ }
2221
+ function toSchemaName(collection) {
2222
+ const withoutS = collection.endsWith("s") ? collection.slice(0, -1) : collection;
2223
+ return withoutS.charAt(0).toUpperCase() + withoutS.slice(1);
2224
+ }
2225
+
2226
+ // src/router/routes/openapi.routes.ts
2227
+ import { stringify as stringify2 } from "yaml";
2228
+ var OpenApiRouteCommand = class {
2229
+ constructor(storage, options) {
2230
+ this.storage = storage;
2231
+ this.options = options;
2232
+ }
2233
+ storage;
2234
+ options;
2235
+ register(server) {
2236
+ server.get("/_openapi", (_req, reply) => {
2237
+ const doc = generateOpenApi(this.storage, this.options);
2238
+ reply.header("Content-Type", "text/yaml; charset=utf-8");
2239
+ return reply.send(stringify2(doc, { lineWidth: 0, aliasDuplicateObjects: false }));
2240
+ });
2241
+ server.get("/_openapi.json", (_req, reply) => {
2242
+ return reply.send(generateOpenApi(this.storage, this.options));
2243
+ });
1241
2244
  }
1242
2245
  };
1243
2246
 
@@ -1321,6 +2324,7 @@ async function createServer(storage, options, handlers = /* @__PURE__ */ new Map
1321
2324
  }
1322
2325
  const commands = [
1323
2326
  new AboutRouteCommand(storage, options, handlers),
2327
+ new OpenApiRouteCommand(storage, options),
1324
2328
  ...options.snapshot ? [new SnapshotRouteCommand(storage)] : [],
1325
2329
  new CustomRouteCommand(storage, options.base, handlers),
1326
2330
  ...buildResourceRouteCommands(storage, options)
@@ -1565,7 +2569,7 @@ function registerServe(program2) {
1565
2569
  // src/cli/commands/handler.ts
1566
2570
  import { existsSync as existsSync4, readFileSync as readFileSync4, appendFileSync, writeFileSync as writeFileSync3 } from "fs";
1567
2571
  import { join as join3, resolve as resolve4, basename } from "path";
1568
- import { parse as parse3, stringify as stringify2 } from "yaml";
2572
+ import { parse as parse3, stringify as stringify3 } from "yaml";
1569
2573
  var HANDLERS_FILE_HEADER = `// yrest handlers \u2014 loaded via "handlers:" in yrest.config.yml
1570
2574
  // Handler signature: (req: HandlerRequest) => HandlerResponse | Promise<HandlerResponse>
1571
2575
  // See https://github.com/aggiovato/yaml-rest for full documentation
@@ -1627,7 +2631,7 @@ function registerHandler(program2) {
1627
2631
  const alreadyRegistered = routes.some((r) => r["handler"] === name);
1628
2632
  if (!alreadyRegistered) {
1629
2633
  routes.push({ method: flags.method.toUpperCase(), path: flags.path, handler: name });
1630
- writeFileSync3(dbPath, stringify2(raw), "utf8");
2634
+ writeFileSync3(dbPath, stringify3(raw), "utf8");
1631
2635
  console.log(` Added _routes entry to ${basename(dbPath)}`);
1632
2636
  } else {
1633
2637
  console.log(` Handler "${name}" already in _routes \u2014 skipped`);
@@ -1650,6 +2654,33 @@ function registerHandler(program2) {
1650
2654
  });
1651
2655
  }
1652
2656
 
2657
+ // src/cli/commands/openapi.ts
2658
+ import { writeFileSync as writeFileSync4 } from "fs";
2659
+ import { resolve as resolve5 } from "path";
2660
+ import { stringify as stringify4 } from "yaml";
2661
+ function registerOpenApi(program2) {
2662
+ 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) => {
2663
+ const storage = createYrestStorage(resolve5(file));
2664
+ const options = yrestOptionsSchema.parse({
2665
+ file,
2666
+ base: opts["base"] || void 0,
2667
+ port: Number(opts["port"]) || 3070,
2668
+ host: opts["host"] || "localhost"
2669
+ });
2670
+ const doc = generateOpenApi(storage, options, opts["title"]);
2671
+ const isJson = opts["format"] === "json";
2672
+ const output = isJson ? JSON.stringify(doc, null, 2) : stringify4(doc, { lineWidth: 0, aliasDuplicateObjects: false });
2673
+ if (opts["stdout"]) {
2674
+ process.stdout.write(output);
2675
+ return;
2676
+ }
2677
+ const defaultFile = isJson ? "openapi.json" : "openapi.yaml";
2678
+ const outFile = resolve5(opts["output"] ?? defaultFile);
2679
+ writeFileSync4(outFile, output, "utf8");
2680
+ console.log(`\u2713 OpenAPI spec written to ${outFile}`);
2681
+ });
2682
+ }
2683
+
1653
2684
  // src/cli/index.ts
1654
2685
  var require2 = createRequire(import.meta.url);
1655
2686
  var { version } = require2("../../package.json");
@@ -1657,4 +2688,5 @@ program.name("yrest").description("Zero-config REST API mock server powered by a
1657
2688
  registerInit(program);
1658
2689
  registerServe(program);
1659
2690
  registerHandler(program);
2691
+ registerOpenApi(program);
1660
2692
  program.parse();