@yrest/cli 0.8.1 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +195 -45
- package/dist/cli/index.js +1241 -209
- package/dist/cli/index.mjs +1238 -206
- package/dist/index.d.mts +91 -5
- package/dist/index.d.ts +91 -5
- package/dist/index.js +814 -188
- package/dist/index.mjs +811 -185
- package/package.json +1 -1
package/dist/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 =
|
|
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@
|
|
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@
|
|
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:
|
|
67
|
+
name: Laptop Pro 15
|
|
68
|
+
price: 1299.99
|
|
69
|
+
stock: 15
|
|
70
|
+
category: electronics
|
|
71
|
+
featured: true
|
|
51
72
|
- id: 2
|
|
52
|
-
name:
|
|
53
|
-
price:
|
|
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 =
|
|
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@
|
|
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
|
-
|
|
69
|
-
|
|
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:
|
|
74
|
-
|
|
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:
|
|
78
|
-
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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 ${
|
|
803
|
+
`Create a new ${singular2}. Auto-assigns <code>id</code> if not provided.`
|
|
326
804
|
),
|
|
327
|
-
endpointRow("GET", `${p}/:id`, `Get a single ${
|
|
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 ${
|
|
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 ${
|
|
814
|
+
`Partially update a ${singular2} \u2014 only provided fields change.`
|
|
337
815
|
),
|
|
338
|
-
endpointRow("DELETE", `${p}/:id`, `Delete a ${
|
|
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 += ` ${tags.join(" ")}`;
|
|
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
|
|
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 ${
|
|
975
|
+
`# Get single ${singular2}
|
|
365
976
|
curl ${p}/1`,
|
|
366
|
-
`# Create ${
|
|
977
|
+
`# Create ${singular2}
|
|
367
978
|
curl -X POST ${p} \\
|
|
368
979
|
-H "Content-Type: application/json" \\
|
|
369
980
|
-d '{"name":"example"}'`,
|
|
370
|
-
`# Partially update ${
|
|
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 ${
|
|
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
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
curl ${host}${base}/${
|
|
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 += ` ${tags.join(" ")}`;
|
|
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
|
-
${
|
|
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,
|
|
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 ||
|
|
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,
|
|
934
|
-
if (
|
|
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,
|
|
945
|
-
|
|
946
|
-
|
|
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(
|
|
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(
|
|
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((
|
|
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(
|
|
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 [
|
|
1244
|
-
for (const [
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
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
|
|
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,
|
|
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
|
|
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,
|
|
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,
|
|
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();
|