@yrest/cli 0.8.0 → 0.9.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 +96 -20
- package/dist/cli/index.js +808 -193
- package/dist/cli/index.mjs +808 -193
- package/dist/index.d.mts +47 -5
- package/dist/index.d.ts +47 -5
- package/dist/index.js +390 -172
- package/dist/index.mjs +390 -172
- package/package.json +4 -3
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
|
|
72
|
+
- id: 2
|
|
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
|
|
51
102
|
- id: 2
|
|
52
|
-
name:
|
|
53
|
-
|
|
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
|
|
67
147
|
- id: 2
|
|
68
|
-
name: Luis
|
|
69
|
-
email: luis@
|
|
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
|
|
162
|
+
- id: 2
|
|
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,50 @@ 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
|
+
|
|
144
596
|
// src/utils/deepCopy.ts
|
|
145
597
|
function deepCopyData(source) {
|
|
146
598
|
return Object.fromEntries(
|
|
@@ -152,7 +604,7 @@ function deepCopyData(source) {
|
|
|
152
604
|
function createYrestStorage(filePath) {
|
|
153
605
|
const absPath = (0, import_node_path2.resolve)(filePath);
|
|
154
606
|
const raw = (0, import_yaml.parse)((0, import_node_fs2.readFileSync)(absPath, "utf8")) ?? {};
|
|
155
|
-
const relations = raw["_rel"]
|
|
607
|
+
const relations = parseRelations(raw["_rel"]);
|
|
156
608
|
const routes = Array.isArray(raw["_routes"]) ? raw["_routes"] : [];
|
|
157
609
|
const data = Object.fromEntries(
|
|
158
610
|
Object.entries(raw).filter(([key]) => key !== "_rel" && key !== "_routes")
|
|
@@ -189,7 +641,7 @@ function createYrestStorage(filePath) {
|
|
|
189
641
|
},
|
|
190
642
|
reload() {
|
|
191
643
|
const fresh = (0, import_yaml.parse)((0, import_node_fs2.readFileSync)(absPath, "utf8")) ?? {};
|
|
192
|
-
const freshRelations = fresh["_rel"]
|
|
644
|
+
const freshRelations = parseRelations(fresh["_rel"]);
|
|
193
645
|
const freshData = Object.fromEntries(
|
|
194
646
|
Object.entries(fresh).filter(([key]) => key !== "_rel" && key !== "_routes")
|
|
195
647
|
);
|
|
@@ -274,16 +726,7 @@ function hasTemplates(value) {
|
|
|
274
726
|
return typeof value === "string" ? value.includes("{{") : JSON.stringify(value).includes("{{");
|
|
275
727
|
}
|
|
276
728
|
|
|
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
|
-
})();
|
|
729
|
+
// src/router/templates/about.helpers.ts
|
|
287
730
|
var METHOD_COLOR = {
|
|
288
731
|
GET: "#3fb950",
|
|
289
732
|
POST: "#58a6ff",
|
|
@@ -292,8 +735,11 @@ var METHOD_COLOR = {
|
|
|
292
735
|
DELETE: "#f85149",
|
|
293
736
|
fn: "#f0883e"
|
|
294
737
|
};
|
|
738
|
+
function escapeHtml(str) {
|
|
739
|
+
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
740
|
+
}
|
|
295
741
|
function badge(label, color, bg) {
|
|
296
|
-
return `<span class="badge" style="background:${bg};color:${color};border:1px solid ${color}40">${label}</span>`;
|
|
742
|
+
return `<span class="badge" style="background:${bg};color:${color};border:1px solid ${color}40">${escapeHtml(label)}</span>`;
|
|
297
743
|
}
|
|
298
744
|
function methodBadge(method) {
|
|
299
745
|
const color = METHOD_COLOR[method] ?? "#7d8590";
|
|
@@ -303,7 +749,7 @@ function endpointRow(method, path, desc) {
|
|
|
303
749
|
return `
|
|
304
750
|
<tr>
|
|
305
751
|
<td class="method-cell">${methodBadge(method)}</td>
|
|
306
|
-
<td class="path-cell"><code>${path}</code></td>
|
|
752
|
+
<td class="path-cell"><code>${escapeHtml(path)}</code></td>
|
|
307
753
|
<td class="desc-cell">${desc}</td>
|
|
308
754
|
</tr>`;
|
|
309
755
|
}
|
|
@@ -337,7 +783,7 @@ function resourceAccordion(name, base, isOpen) {
|
|
|
337
783
|
return `
|
|
338
784
|
<details class="resource-card" ${isOpen ? "open" : ""}>
|
|
339
785
|
<summary>
|
|
340
|
-
<span class="resource-name">/${name}</span>
|
|
786
|
+
<span class="resource-name">/${escapeHtml(name)}</span>
|
|
341
787
|
<span class="route-count">6 routes</span>
|
|
342
788
|
</summary>
|
|
343
789
|
<table>
|
|
@@ -345,6 +791,139 @@ function resourceAccordion(name, base, isOpen) {
|
|
|
345
791
|
</table>
|
|
346
792
|
</details>`;
|
|
347
793
|
}
|
|
794
|
+
function nestedRoutesAccordion(relations, base) {
|
|
795
|
+
const rows = [];
|
|
796
|
+
for (const [source, fields] of Object.entries(relations)) {
|
|
797
|
+
for (const [key, def] of Object.entries(fields)) {
|
|
798
|
+
const nestedBadge = def.nested ? ` ${badge("nested", "#facc15", "#facc1518")}` : "";
|
|
799
|
+
if (def.type === "many2many") {
|
|
800
|
+
const singular = source.endsWith("s") ? source.slice(0, -1) : source;
|
|
801
|
+
const m2mBadge = badge("many2many", "#818cf8", "#818cf818");
|
|
802
|
+
rows.push(
|
|
803
|
+
endpointRow(
|
|
804
|
+
"GET",
|
|
805
|
+
`${base}/${source}/:id/${key}`,
|
|
806
|
+
`List ${def.target} linked to a ${singular} via ${def.through}. ${m2mBadge}${nestedBadge}`
|
|
807
|
+
)
|
|
808
|
+
);
|
|
809
|
+
const targetSingular = def.target.endsWith("s") ? def.target.slice(0, -1) : def.target;
|
|
810
|
+
rows.push(
|
|
811
|
+
endpointRow(
|
|
812
|
+
"GET",
|
|
813
|
+
`${base}/${def.target}/:id/${source}`,
|
|
814
|
+
`List ${source} linked to a ${targetSingular} via ${def.through} (inverse). ${m2mBadge}`
|
|
815
|
+
)
|
|
816
|
+
);
|
|
817
|
+
} else {
|
|
818
|
+
const path = `${base}/${def.target}/:id/${source}`;
|
|
819
|
+
const parentSingular = def.target.endsWith("s") ? def.target.slice(0, -1) : def.target;
|
|
820
|
+
const typeBadge = def.type === "one2one" ? ` ${badge("one2one", "#34d399", "#34d39918")}` : "";
|
|
821
|
+
rows.push(
|
|
822
|
+
endpointRow(
|
|
823
|
+
"GET",
|
|
824
|
+
path,
|
|
825
|
+
`${def.type === "one2one" ? "Get" : "List"} ${source} belonging to a ${parentSingular}.${typeBadge}${nestedBadge}`
|
|
826
|
+
)
|
|
827
|
+
);
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
if (!rows.length) return "";
|
|
832
|
+
return `
|
|
833
|
+
<details class="resource-card nested-card">
|
|
834
|
+
<summary>
|
|
835
|
+
<span class="resource-name">Nested routes</span>
|
|
836
|
+
<span class="route-count">${rows.length} route${rows.length !== 1 ? "s" : ""}</span>
|
|
837
|
+
</summary>
|
|
838
|
+
<table><tbody>${rows.join("")}</tbody></table>
|
|
839
|
+
</details>`;
|
|
840
|
+
}
|
|
841
|
+
function snapshotAccordion() {
|
|
842
|
+
return `
|
|
843
|
+
<details class="resource-card nested-card">
|
|
844
|
+
<summary>
|
|
845
|
+
<span class="resource-name">/_snapshot</span>
|
|
846
|
+
<span class="route-count">3 routes</span>
|
|
847
|
+
</summary>
|
|
848
|
+
<table><tbody>
|
|
849
|
+
${endpointRow("GET", "/_snapshot", "Returns metadata of the current snapshot: <code>savedAt</code> and item counts per collection.")}
|
|
850
|
+
${endpointRow("POST", "/_snapshot/save", "Replaces the stored snapshot with the current database state.")}
|
|
851
|
+
${endpointRow("POST", "/_snapshot/reset", "Restores the database to the last saved snapshot and persists to disk.")}
|
|
852
|
+
</tbody></table>
|
|
853
|
+
</details>`;
|
|
854
|
+
}
|
|
855
|
+
function customRoutesAccordion(routes, base, handlers) {
|
|
856
|
+
if (!routes.length) return "";
|
|
857
|
+
const rows = routes.map((r) => {
|
|
858
|
+
const fullPath = `${base}${r.path}`;
|
|
859
|
+
const tags = [];
|
|
860
|
+
if (r.error) tags.push(`<span style="color:#f85149;font-size:11px">error\xB7${r.error}</span>`);
|
|
861
|
+
if (r.delay && r.delay > 0)
|
|
862
|
+
tags.push(`<span style="color:#fb923c;font-size:11px">delay\xB7${r.delay}ms</span>`);
|
|
863
|
+
if (r.scenarios?.length) {
|
|
864
|
+
const hasOr = r.scenarios.some((s) => Array.isArray(s.when));
|
|
865
|
+
tags.push(
|
|
866
|
+
`<span style="color:#a371f7;font-size:11px">scenarios\xB7${r.scenarios.length}${hasOr ? " (OR)" : ""}</span>`
|
|
867
|
+
);
|
|
868
|
+
}
|
|
869
|
+
if (r.otherwise)
|
|
870
|
+
tags.push(`<span style="color:var(--text-muted);font-size:11px">otherwise</span>`);
|
|
871
|
+
let desc;
|
|
872
|
+
if (r.error) {
|
|
873
|
+
desc = `Error injection \u2014 <code>${r.error}</code>`;
|
|
874
|
+
} else if (r.handler) {
|
|
875
|
+
const found = handlers.has(r.handler);
|
|
876
|
+
const handlerName = escapeHtml(r.handler);
|
|
877
|
+
desc = found ? `Handler \u2014 <code>${handlerName}()</code>` : `Handler \u2014 <code>${handlerName}()</code> <span style="color:#f85149">(not loaded)</span>`;
|
|
878
|
+
} else if (r.scenarios?.length) {
|
|
879
|
+
const hasTemplateInScenarios = r.scenarios.some((s) => s.response.body != null && hasTemplates(s.response.body)) || r.otherwise?.body != null && hasTemplates(r.otherwise.body);
|
|
880
|
+
desc = hasTemplateInScenarios ? `Scenarios \u2014 <code>{{\u2026}}</code>` : `Scenarios`;
|
|
881
|
+
} else if (r.response?.body != null && hasTemplates(r.response.body)) {
|
|
882
|
+
desc = `Dynamic \u2014 <code>{{\u2026}}</code>`;
|
|
883
|
+
} else {
|
|
884
|
+
const status = r.response?.status ?? 200;
|
|
885
|
+
desc = `Static \u2014 <code>${status}</code>${r.response?.headers ? ` + headers` : ""}`;
|
|
886
|
+
}
|
|
887
|
+
if (tags.length) desc += ` ${tags.join(" ")}`;
|
|
888
|
+
return endpointRow(r.method?.toUpperCase() ?? "GET", fullPath, desc);
|
|
889
|
+
});
|
|
890
|
+
return `
|
|
891
|
+
<details class="resource-card nested-card">
|
|
892
|
+
<summary>
|
|
893
|
+
<span class="resource-name">Custom routes</span>
|
|
894
|
+
<span class="route-count">${routes.length} route${routes.length !== 1 ? "s" : ""}</span>
|
|
895
|
+
</summary>
|
|
896
|
+
<table><tbody>
|
|
897
|
+
${rows.join("")}
|
|
898
|
+
</tbody></table>
|
|
899
|
+
</details>`;
|
|
900
|
+
}
|
|
901
|
+
function handlersAccordion(handlers, routes, base) {
|
|
902
|
+
if (!handlers.size) return "";
|
|
903
|
+
const routesByHandler = /* @__PURE__ */ new Map();
|
|
904
|
+
for (const r of routes) {
|
|
905
|
+
if (r.handler) {
|
|
906
|
+
const list = routesByHandler.get(r.handler) ?? [];
|
|
907
|
+
list.push({ method: (r.method ?? "GET").toUpperCase(), path: `${base}${r.path}` });
|
|
908
|
+
routesByHandler.set(r.handler, list);
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
const rows = [...handlers.keys()].map((name) => {
|
|
912
|
+
const linked = routesByHandler.get(name);
|
|
913
|
+
const routeDesc = linked ? linked.map((r) => `<code>${r.method} ${r.path}</code>`).join(", ") : `<span style="color:var(--text-muted)">not referenced in _routes</span>`;
|
|
914
|
+
return endpointRow("fn", name + "()", routeDesc);
|
|
915
|
+
});
|
|
916
|
+
return `
|
|
917
|
+
<details class="resource-card nested-card">
|
|
918
|
+
<summary>
|
|
919
|
+
<span class="resource-name">Handlers</span>
|
|
920
|
+
<span class="route-count">${handlers.size} function${handlers.size !== 1 ? "s" : ""}</span>
|
|
921
|
+
</summary>
|
|
922
|
+
<table><tbody>
|
|
923
|
+
${rows.join("")}
|
|
924
|
+
</tbody></table>
|
|
925
|
+
</details>`;
|
|
926
|
+
}
|
|
348
927
|
function examplesBlock(collections, relations, base, host, options, firstCustomRoute) {
|
|
349
928
|
const examples = [];
|
|
350
929
|
const firstCol = collections[0];
|
|
@@ -375,35 +954,36 @@ curl -X DELETE ${p}/1`
|
|
|
375
954
|
const firstRel = Object.entries(relations)[0];
|
|
376
955
|
if (firstRel) {
|
|
377
956
|
const [child, fields] = firstRel;
|
|
378
|
-
const
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
curl ${host}${base}/${
|
|
957
|
+
const firstField = Object.entries(fields)[0];
|
|
958
|
+
if (firstField) {
|
|
959
|
+
const [fk, def] = firstField;
|
|
960
|
+
if (def.type !== "many2many") {
|
|
961
|
+
const expandKey = fk.replace(/Id$/i, "");
|
|
962
|
+
examples.push(
|
|
963
|
+
`# Embed parent with ?_expand
|
|
964
|
+
curl "${host}${base}/${child}/1?_expand=${expandKey}"`,
|
|
965
|
+
`# Nested resource
|
|
966
|
+
curl ${host}${base}/${def.target}/1/${child}`
|
|
967
|
+
);
|
|
968
|
+
} else {
|
|
969
|
+
examples.push(`# Many-to-many embed
|
|
970
|
+
curl "${host}${base}/${child}/1/${fk}"`);
|
|
971
|
+
}
|
|
392
972
|
}
|
|
393
973
|
}
|
|
394
974
|
if (options.pageable.enabled && firstCol) {
|
|
395
975
|
examples.push(`# Pageable envelope
|
|
396
976
|
curl "${host}${base}/${firstCol}?_page=2"`);
|
|
397
977
|
}
|
|
398
|
-
const firstParentRel = Object.entries(relations).find(
|
|
399
|
-
([, fields]) => Object.values(fields).includes(firstCol ?? "")
|
|
400
|
-
);
|
|
401
978
|
if (firstCol) {
|
|
402
979
|
examples.push(
|
|
403
980
|
`# Project fields with ?_fields
|
|
404
981
|
curl "${host}${base}/${firstCol}?_fields=id,name"`
|
|
405
982
|
);
|
|
406
983
|
}
|
|
984
|
+
const firstParentRel = Object.entries(relations).find(
|
|
985
|
+
([, fields]) => Object.values(fields).some((def) => def.type !== "many2many" && def.target === firstCol)
|
|
986
|
+
);
|
|
407
987
|
if (firstParentRel && firstCol) {
|
|
408
988
|
const [childName] = firstParentRel;
|
|
409
989
|
examples.push(
|
|
@@ -421,7 +1001,7 @@ curl -X POST ${host}/_snapshot/reset`
|
|
|
421
1001
|
}
|
|
422
1002
|
if (firstCustomRoute) {
|
|
423
1003
|
const method = firstCustomRoute.method?.toUpperCase() ?? "GET";
|
|
424
|
-
const fullPath = `${host}${base}${firstCustomRoute.path}`;
|
|
1004
|
+
const fullPath = `${host}${base}${escapeHtml(firstCustomRoute.path ?? "")}`;
|
|
425
1005
|
const curlFlag = method === "GET" ? "" : `-X ${method} `;
|
|
426
1006
|
examples.push(`# Custom route
|
|
427
1007
|
curl ${curlFlag}${fullPath}`);
|
|
@@ -429,9 +1009,21 @@ curl ${curlFlag}${fullPath}`);
|
|
|
429
1009
|
const highlighted = examples.map((e) => e.replace(/^(#.+)$/gm, '<span class="cm">$1</span>')).join("\n\n");
|
|
430
1010
|
return `<pre>${highlighted}</pre>`;
|
|
431
1011
|
}
|
|
1012
|
+
|
|
1013
|
+
// src/router/templates/about.template.ts
|
|
1014
|
+
var _dir = (0, import_node_path3.dirname)((0, import_node_url.fileURLToPath)(importMetaUrl));
|
|
1015
|
+
var LOGO_SRC = (() => {
|
|
1016
|
+
try {
|
|
1017
|
+
const buf = (0, import_node_fs3.readFileSync)((0, import_node_path3.join)(_dir, "../../assets/logo-color.png"));
|
|
1018
|
+
return `data:image/png;base64,${buf.toString("base64")}`;
|
|
1019
|
+
} catch {
|
|
1020
|
+
return "";
|
|
1021
|
+
}
|
|
1022
|
+
})();
|
|
432
1023
|
function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map()) {
|
|
433
1024
|
const collections = Object.keys(storage.getData());
|
|
434
1025
|
const relations = storage.getRelations();
|
|
1026
|
+
const customRoutes = storage.getRoutes();
|
|
435
1027
|
const base = options.base;
|
|
436
1028
|
const host = `http://${options.host}:${options.port}`;
|
|
437
1029
|
const modes = [];
|
|
@@ -445,104 +1037,6 @@ function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map(
|
|
|
445
1037
|
if (options.idStrategy !== "increment")
|
|
446
1038
|
modes.push(badge(`id \xB7 ${options.idStrategy}`, "#a371f7", "#a371f718"));
|
|
447
1039
|
const accordions = collections.map((col, i) => resourceAccordion(col, base, i === 0)).join("");
|
|
448
|
-
const nestedRows = [];
|
|
449
|
-
for (const [child, fields] of Object.entries(relations)) {
|
|
450
|
-
for (const [, parent] of Object.entries(fields)) {
|
|
451
|
-
const nestedPath = `${base}/${parent}/:id/${child}`;
|
|
452
|
-
const parentSingular = parent.endsWith("s") ? parent.slice(0, -1) : parent;
|
|
453
|
-
nestedRows.push(
|
|
454
|
-
endpointRow("GET", nestedPath, `List ${child} belonging to a ${parentSingular}.`)
|
|
455
|
-
);
|
|
456
|
-
}
|
|
457
|
-
}
|
|
458
|
-
const nestedAccordion = nestedRows.length ? `
|
|
459
|
-
<details class="resource-card nested-card">
|
|
460
|
-
<summary>
|
|
461
|
-
<span class="resource-name">Nested routes</span>
|
|
462
|
-
<span class="route-count">${nestedRows.length} route${nestedRows.length !== 1 ? "s" : ""}</span>
|
|
463
|
-
</summary>
|
|
464
|
-
<table><tbody>${nestedRows.join("")}</tbody></table>
|
|
465
|
-
</details>` : "";
|
|
466
|
-
const snapshotAccordion = options.snapshot ? `
|
|
467
|
-
<details class="resource-card nested-card">
|
|
468
|
-
<summary>
|
|
469
|
-
<span class="resource-name">/_snapshot</span>
|
|
470
|
-
<span class="route-count">3 routes</span>
|
|
471
|
-
</summary>
|
|
472
|
-
<table><tbody>
|
|
473
|
-
${endpointRow("GET", "/_snapshot", "Returns metadata of the current snapshot: <code>savedAt</code> and item counts per collection.")}
|
|
474
|
-
${endpointRow("POST", "/_snapshot/save", "Replaces the stored snapshot with the current database state.")}
|
|
475
|
-
${endpointRow("POST", "/_snapshot/reset", "Restores the database to the last saved snapshot and persists to disk.")}
|
|
476
|
-
</tbody></table>
|
|
477
|
-
</details>` : "";
|
|
478
|
-
const customRoutes = storage.getRoutes();
|
|
479
|
-
const customRoutesAccordion = customRoutes.length ? `
|
|
480
|
-
<details class="resource-card nested-card">
|
|
481
|
-
<summary>
|
|
482
|
-
<span class="resource-name">Custom routes</span>
|
|
483
|
-
<span class="route-count">${customRoutes.length} route${customRoutes.length !== 1 ? "s" : ""}</span>
|
|
484
|
-
</summary>
|
|
485
|
-
<table><tbody>
|
|
486
|
-
${customRoutes.map((r) => {
|
|
487
|
-
const fullPath = `${base}${r.path}`;
|
|
488
|
-
const tags = [];
|
|
489
|
-
if (r.error) {
|
|
490
|
-
tags.push(`<span style="color:#f85149;font-size:11px">error\xB7${r.error}</span>`);
|
|
491
|
-
}
|
|
492
|
-
if (r.delay && r.delay > 0) {
|
|
493
|
-
tags.push(`<span style="color:#fb923c;font-size:11px">delay\xB7${r.delay}ms</span>`);
|
|
494
|
-
}
|
|
495
|
-
if (r.scenarios?.length) {
|
|
496
|
-
const hasOr = r.scenarios.some((s) => Array.isArray(s.when));
|
|
497
|
-
tags.push(
|
|
498
|
-
`<span style="color:#a371f7;font-size:11px">scenarios\xB7${r.scenarios.length}${hasOr ? " (OR)" : ""}</span>`
|
|
499
|
-
);
|
|
500
|
-
}
|
|
501
|
-
if (r.otherwise) {
|
|
502
|
-
tags.push(`<span style="color:var(--text-muted);font-size:11px">otherwise</span>`);
|
|
503
|
-
}
|
|
504
|
-
let desc;
|
|
505
|
-
if (r.error) {
|
|
506
|
-
desc = `Error injection \u2014 <code>${r.error}</code>`;
|
|
507
|
-
} else if (r.handler) {
|
|
508
|
-
const found = handlers.has(r.handler);
|
|
509
|
-
desc = found ? `Handler \u2014 <code>${r.handler}()</code>` : `Handler \u2014 <code>${r.handler}()</code> <span style="color:#f85149">(not loaded)</span>`;
|
|
510
|
-
} else if (r.scenarios?.length) {
|
|
511
|
-
const hasTemplateInScenarios = r.scenarios.some((s) => s.response.body != null && hasTemplates(s.response.body)) || r.otherwise?.body != null && hasTemplates(r.otherwise.body);
|
|
512
|
-
desc = hasTemplateInScenarios ? `Scenarios \u2014 <code>{{\u2026}}</code>` : `Scenarios`;
|
|
513
|
-
} else if (r.response?.body != null && hasTemplates(r.response.body)) {
|
|
514
|
-
desc = `Dynamic \u2014 <code>{{\u2026}}</code>`;
|
|
515
|
-
} else {
|
|
516
|
-
const status = r.response?.status ?? 200;
|
|
517
|
-
desc = `Static \u2014 <code>${status}</code>${r.response?.headers ? ` + headers` : ""}`;
|
|
518
|
-
}
|
|
519
|
-
if (tags.length) desc += ` ${tags.join(" ")}`;
|
|
520
|
-
return endpointRow(r.method?.toUpperCase() ?? "GET", fullPath, desc);
|
|
521
|
-
}).join("")}
|
|
522
|
-
</tbody></table>
|
|
523
|
-
</details>` : "";
|
|
524
|
-
const routesByHandler = /* @__PURE__ */ new Map();
|
|
525
|
-
for (const r of customRoutes) {
|
|
526
|
-
if (r.handler) {
|
|
527
|
-
const list = routesByHandler.get(r.handler) ?? [];
|
|
528
|
-
list.push({ method: (r.method ?? "GET").toUpperCase(), path: `${base}${r.path}` });
|
|
529
|
-
routesByHandler.set(r.handler, list);
|
|
530
|
-
}
|
|
531
|
-
}
|
|
532
|
-
const handlersAccordion = handlers.size > 0 ? `
|
|
533
|
-
<details class="resource-card nested-card">
|
|
534
|
-
<summary>
|
|
535
|
-
<span class="resource-name">Handlers</span>
|
|
536
|
-
<span class="route-count">${handlers.size} function${handlers.size !== 1 ? "s" : ""}</span>
|
|
537
|
-
</summary>
|
|
538
|
-
<table><tbody>
|
|
539
|
-
${[...handlers.keys()].map((name) => {
|
|
540
|
-
const routes = routesByHandler.get(name);
|
|
541
|
-
const routeDesc = routes ? routes.map((r) => `<code>${r.method} ${r.path}</code>`).join(", ") : `<span style="color:var(--text-muted)">not referenced in _routes</span>`;
|
|
542
|
-
return endpointRow("fn", name + "()", routeDesc);
|
|
543
|
-
}).join("")}
|
|
544
|
-
</tbody></table>
|
|
545
|
-
</details>` : "";
|
|
546
1040
|
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.`;
|
|
547
1041
|
return `<!DOCTYPE html>
|
|
548
1042
|
<html lang="en">
|
|
@@ -690,10 +1184,10 @@ function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map(
|
|
|
690
1184
|
<h2>Endpoints</h2>
|
|
691
1185
|
<div class="endpoints-grid">
|
|
692
1186
|
${accordions}
|
|
693
|
-
${
|
|
694
|
-
${snapshotAccordion}
|
|
695
|
-
${customRoutesAccordion}
|
|
696
|
-
${handlersAccordion}
|
|
1187
|
+
${nestedRoutesAccordion(relations, base)}
|
|
1188
|
+
${options.snapshot ? snapshotAccordion() : ""}
|
|
1189
|
+
${customRoutesAccordion(customRoutes, base, handlers)}
|
|
1190
|
+
${handlersAccordion(handlers, customRoutes, base)}
|
|
697
1191
|
</div>
|
|
698
1192
|
|
|
699
1193
|
<h2>Query Parameters</h2>
|
|
@@ -779,6 +1273,7 @@ function applyOperator(itemValue, op, filterValue) {
|
|
|
779
1273
|
case "_start":
|
|
780
1274
|
return strItem.toLowerCase().startsWith(filterValue.toLowerCase());
|
|
781
1275
|
case "_regex": {
|
|
1276
|
+
if (filterValue.length > 200) return false;
|
|
782
1277
|
try {
|
|
783
1278
|
return new RegExp(filterValue, "i").test(strItem);
|
|
784
1279
|
} catch {
|
|
@@ -892,10 +1387,11 @@ function expandItems(input, query, resource, storage) {
|
|
|
892
1387
|
const resourceRelations = storage.getRelations()[resource] ?? {};
|
|
893
1388
|
const expansions = /* @__PURE__ */ new Map();
|
|
894
1389
|
for (const expandKey of keys) {
|
|
895
|
-
for (const [field,
|
|
1390
|
+
for (const [field, def] of Object.entries(resourceRelations)) {
|
|
1391
|
+
if (def.type === "many2many") continue;
|
|
896
1392
|
const derivedKey = field.replace(/Id$/i, "");
|
|
897
|
-
if (derivedKey === expandKey ||
|
|
898
|
-
expansions.set(expandKey, { field, parentCollection });
|
|
1393
|
+
if (derivedKey === expandKey || def.target === expandKey || def.target === `${expandKey}s`) {
|
|
1394
|
+
expansions.set(expandKey, { field, parentCollection: def.target });
|
|
899
1395
|
break;
|
|
900
1396
|
}
|
|
901
1397
|
}
|
|
@@ -924,10 +1420,24 @@ function embedItems(input, query, resource, storage) {
|
|
|
924
1420
|
const relations = storage.getRelations();
|
|
925
1421
|
const embeds = /* @__PURE__ */ new Map();
|
|
926
1422
|
for (const embedKey of keys) {
|
|
1423
|
+
const ownRelations = relations[resource] ?? {};
|
|
1424
|
+
if (embedKey in ownRelations) {
|
|
1425
|
+
const def = ownRelations[embedKey];
|
|
1426
|
+
if (def.type === "many2many") {
|
|
1427
|
+
embeds.set(embedKey, {
|
|
1428
|
+
kind: "many2many",
|
|
1429
|
+
target: def.target,
|
|
1430
|
+
through: def.through,
|
|
1431
|
+
foreignKey: def.foreignKey,
|
|
1432
|
+
otherKey: def.otherKey
|
|
1433
|
+
});
|
|
1434
|
+
continue;
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
927
1437
|
outer: for (const [childCollection, fields] of Object.entries(relations)) {
|
|
928
|
-
for (const [fkField,
|
|
929
|
-
if (
|
|
930
|
-
embeds.set(embedKey, { childCollection, fkField });
|
|
1438
|
+
for (const [fkField, def] of Object.entries(fields)) {
|
|
1439
|
+
if ((def.type === "many2one" || def.type === "one2one") && def.target === resource && childCollection === embedKey) {
|
|
1440
|
+
embeds.set(embedKey, { kind: def.type, childCollection, fkField });
|
|
931
1441
|
break outer;
|
|
932
1442
|
}
|
|
933
1443
|
}
|
|
@@ -936,10 +1446,57 @@ function embedItems(input, query, resource, storage) {
|
|
|
936
1446
|
if (embeds.size === 0) return isArray ? items : input;
|
|
937
1447
|
const result = items.map((item) => {
|
|
938
1448
|
const out = { ...item };
|
|
939
|
-
for (const [embedKey,
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
1449
|
+
for (const [embedKey, spec] of embeds) {
|
|
1450
|
+
if (spec.kind === "many2many") {
|
|
1451
|
+
const pivot = storage.getCollection(spec.through) ?? [];
|
|
1452
|
+
const matchingIds = new Set(
|
|
1453
|
+
pivot.filter((row) => String(row[spec.foreignKey]) === String(item["id"])).map((row) => String(row[spec.otherKey]))
|
|
1454
|
+
);
|
|
1455
|
+
out[embedKey] = (storage.getCollection(spec.target) ?? []).filter(
|
|
1456
|
+
(t) => matchingIds.has(String(t["id"]))
|
|
1457
|
+
);
|
|
1458
|
+
} else if (spec.kind === "one2one") {
|
|
1459
|
+
out[embedKey] = (storage.getCollection(spec.childCollection) ?? []).find(
|
|
1460
|
+
(child) => String(child[spec.fkField]) === String(item["id"])
|
|
1461
|
+
) ?? null;
|
|
1462
|
+
} else {
|
|
1463
|
+
out[embedKey] = (storage.getCollection(spec.childCollection) ?? []).filter(
|
|
1464
|
+
(child) => String(child[spec.fkField]) === String(item["id"])
|
|
1465
|
+
);
|
|
1466
|
+
}
|
|
1467
|
+
}
|
|
1468
|
+
return out;
|
|
1469
|
+
});
|
|
1470
|
+
return isArray ? result : result[0];
|
|
1471
|
+
}
|
|
1472
|
+
function applyNested(input, resource, storage) {
|
|
1473
|
+
const isArray = Array.isArray(input);
|
|
1474
|
+
const items = isArray ? input : [input];
|
|
1475
|
+
const resourceRelations = storage.getRelations()[resource] ?? {};
|
|
1476
|
+
const nestedDefs = Object.entries(resourceRelations).filter(([, def]) => def.nested === true);
|
|
1477
|
+
if (nestedDefs.length === 0) return input;
|
|
1478
|
+
const result = items.map((item) => {
|
|
1479
|
+
const out = { ...item };
|
|
1480
|
+
for (const [key, def] of nestedDefs) {
|
|
1481
|
+
if (def.type === "many2many") {
|
|
1482
|
+
const pivot = storage.getCollection(def.through) ?? [];
|
|
1483
|
+
const matchingIds = new Set(
|
|
1484
|
+
pivot.filter((row) => String(row[def.foreignKey]) === String(item["id"])).map((row) => String(row[def.otherKey]))
|
|
1485
|
+
);
|
|
1486
|
+
out[key] = (storage.getCollection(def.target) ?? []).filter(
|
|
1487
|
+
(t) => matchingIds.has(String(t["id"]))
|
|
1488
|
+
);
|
|
1489
|
+
} else {
|
|
1490
|
+
const foreignKeyValue = item[key];
|
|
1491
|
+
if (foreignKeyValue === void 0) continue;
|
|
1492
|
+
const parent = (storage.getCollection(def.target) ?? []).find(
|
|
1493
|
+
(p) => String(p["id"]) === String(foreignKeyValue)
|
|
1494
|
+
);
|
|
1495
|
+
if (parent !== void 0) {
|
|
1496
|
+
const embedKey = key.replace(/Id$/i, "");
|
|
1497
|
+
out[embedKey] = parent;
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
943
1500
|
}
|
|
944
1501
|
return out;
|
|
945
1502
|
});
|
|
@@ -979,7 +1536,12 @@ var CollectionRouteCommand = class {
|
|
|
979
1536
|
const totalPages = Math.ceil(totalItems / limit) || 1;
|
|
980
1537
|
const data = projectFields(
|
|
981
1538
|
embedItems(
|
|
982
|
-
expandItems(
|
|
1539
|
+
expandItems(
|
|
1540
|
+
applyNested(paginate(sorted, page, limit), this.resource, this.storage),
|
|
1541
|
+
req.query,
|
|
1542
|
+
this.resource,
|
|
1543
|
+
this.storage
|
|
1544
|
+
),
|
|
983
1545
|
req.query,
|
|
984
1546
|
this.resource,
|
|
985
1547
|
this.storage
|
|
@@ -1011,7 +1573,12 @@ var CollectionRouteCommand = class {
|
|
|
1011
1573
|
}
|
|
1012
1574
|
return projectFields(
|
|
1013
1575
|
embedItems(
|
|
1014
|
-
expandItems(
|
|
1576
|
+
expandItems(
|
|
1577
|
+
applyNested(result, this.resource, this.storage),
|
|
1578
|
+
req.query,
|
|
1579
|
+
this.resource,
|
|
1580
|
+
this.storage
|
|
1581
|
+
),
|
|
1015
1582
|
req.query,
|
|
1016
1583
|
this.resource,
|
|
1017
1584
|
this.storage
|
|
@@ -1192,7 +1759,12 @@ var ItemRouteCommand = class {
|
|
|
1192
1759
|
const fields = (req.query["_fields"] ?? "").split(",").map((f) => f.trim()).filter(Boolean);
|
|
1193
1760
|
return projectFields(
|
|
1194
1761
|
embedItems(
|
|
1195
|
-
expandItems(
|
|
1762
|
+
expandItems(
|
|
1763
|
+
applyNested(item, this.resource, this.storage),
|
|
1764
|
+
req.query,
|
|
1765
|
+
this.resource,
|
|
1766
|
+
this.storage
|
|
1767
|
+
),
|
|
1196
1768
|
req.query,
|
|
1197
1769
|
this.resource,
|
|
1198
1770
|
this.storage
|
|
@@ -1235,32 +1807,68 @@ var NestedRouteCommand = class {
|
|
|
1235
1807
|
relations;
|
|
1236
1808
|
base;
|
|
1237
1809
|
register(server) {
|
|
1238
|
-
for (const [
|
|
1239
|
-
for (const [
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
if (!parentItem) return reply.status(404).send({ error: "Not found" });
|
|
1246
|
-
const children = (this.storage.getCollection(child) ?? []).filter(
|
|
1247
|
-
(item) => String(item[field]) === req.params.id
|
|
1248
|
-
);
|
|
1249
|
-
return children;
|
|
1250
|
-
});
|
|
1251
|
-
server.get(itemPath, (req, reply) => {
|
|
1252
|
-
const parentCollection = this.storage.getCollection(parent) ?? [];
|
|
1253
|
-
const parentItem = findById(parentCollection, req.params.id);
|
|
1254
|
-
if (!parentItem) return reply.status(404).send({ error: "Not found" });
|
|
1255
|
-
const childItem = (this.storage.getCollection(child) ?? []).find(
|
|
1256
|
-
(item) => String(item[field]) === req.params.id && String(item["id"]) === req.params.childId
|
|
1257
|
-
);
|
|
1258
|
-
if (!childItem) return reply.status(404).send({ error: "Not found" });
|
|
1259
|
-
return childItem;
|
|
1260
|
-
});
|
|
1810
|
+
for (const [source, fields] of Object.entries(this.relations)) {
|
|
1811
|
+
for (const [key, def] of Object.entries(fields)) {
|
|
1812
|
+
if (def.type === "many2many") {
|
|
1813
|
+
this.registerMany2Many(server, source, key, def);
|
|
1814
|
+
} else {
|
|
1815
|
+
this.registerFkRelation(server, source, key, def.target, def.type);
|
|
1816
|
+
}
|
|
1261
1817
|
}
|
|
1262
1818
|
}
|
|
1263
1819
|
}
|
|
1820
|
+
registerFkRelation(server, child, fkField, parent, type) {
|
|
1821
|
+
const collectionPath = `${this.base}/${parent}/:id/${child}`;
|
|
1822
|
+
const itemPath = `${this.base}/${parent}/:id/${child}/:childId`;
|
|
1823
|
+
server.get(collectionPath, (req, reply) => {
|
|
1824
|
+
const parentCollection = this.storage.getCollection(parent) ?? [];
|
|
1825
|
+
const parentItem = findById(parentCollection, req.params.id);
|
|
1826
|
+
if (!parentItem) return reply.status(404).send({ error: "Not found" });
|
|
1827
|
+
const all = (this.storage.getCollection(child) ?? []).filter(
|
|
1828
|
+
(item) => String(item[fkField]) === req.params.id
|
|
1829
|
+
);
|
|
1830
|
+
if (type === "one2one") return all[0] ?? reply.status(404).send({ error: "Not found" });
|
|
1831
|
+
return all;
|
|
1832
|
+
});
|
|
1833
|
+
if (type === "many2one") {
|
|
1834
|
+
server.get(itemPath, (req, reply) => {
|
|
1835
|
+
const parentCollection = this.storage.getCollection(parent) ?? [];
|
|
1836
|
+
const parentItem = findById(parentCollection, req.params.id);
|
|
1837
|
+
if (!parentItem) return reply.status(404).send({ error: "Not found" });
|
|
1838
|
+
const childItem = (this.storage.getCollection(child) ?? []).find(
|
|
1839
|
+
(item) => String(item[fkField]) === req.params.id && String(item["id"]) === req.params.childId
|
|
1840
|
+
);
|
|
1841
|
+
if (!childItem) return reply.status(404).send({ error: "Not found" });
|
|
1842
|
+
return childItem;
|
|
1843
|
+
});
|
|
1844
|
+
}
|
|
1845
|
+
}
|
|
1846
|
+
registerMany2Many(server, source, alias, def) {
|
|
1847
|
+
server.get(`${this.base}/${source}/:id/${alias}`, (req, reply) => {
|
|
1848
|
+
const sourceCollection = this.storage.getCollection(source) ?? [];
|
|
1849
|
+
const sourceItem = findById(sourceCollection, req.params.id);
|
|
1850
|
+
if (!sourceItem) return reply.status(404).send({ error: "Not found" });
|
|
1851
|
+
const pivot = this.storage.getCollection(def.through) ?? [];
|
|
1852
|
+
const matchingIds = new Set(
|
|
1853
|
+
pivot.filter((row) => String(row[def.foreignKey]) === req.params.id).map((row) => String(row[def.otherKey]))
|
|
1854
|
+
);
|
|
1855
|
+
return (this.storage.getCollection(def.target) ?? []).filter(
|
|
1856
|
+
(t) => matchingIds.has(String(t["id"]))
|
|
1857
|
+
);
|
|
1858
|
+
});
|
|
1859
|
+
server.get(`${this.base}/${def.target}/:id/${source}`, (req, reply) => {
|
|
1860
|
+
const targetCollection = this.storage.getCollection(def.target) ?? [];
|
|
1861
|
+
const targetItem = findById(targetCollection, req.params.id);
|
|
1862
|
+
if (!targetItem) return reply.status(404).send({ error: "Not found" });
|
|
1863
|
+
const pivot = this.storage.getCollection(def.through) ?? [];
|
|
1864
|
+
const matchingIds = new Set(
|
|
1865
|
+
pivot.filter((row) => String(row[def.otherKey]) === req.params.id).map((row) => String(row[def.foreignKey]))
|
|
1866
|
+
);
|
|
1867
|
+
return (this.storage.getCollection(source) ?? []).filter(
|
|
1868
|
+
(t) => matchingIds.has(String(t["id"]))
|
|
1869
|
+
);
|
|
1870
|
+
});
|
|
1871
|
+
}
|
|
1264
1872
|
};
|
|
1265
1873
|
|
|
1266
1874
|
// src/router/routes/snapshot.routes.ts
|
|
@@ -1439,8 +2047,15 @@ function loadConfigFile(configPath) {
|
|
|
1439
2047
|
|
|
1440
2048
|
// src/utils/handlers.ts
|
|
1441
2049
|
var import_node_fs5 = require("fs");
|
|
2050
|
+
var ALLOWED_EXTENSIONS = [".js", ".mjs", ".cjs"];
|
|
1442
2051
|
async function loadHandlers(filePath) {
|
|
1443
2052
|
if (!(0, import_node_fs5.existsSync)(filePath)) return /* @__PURE__ */ new Map();
|
|
2053
|
+
if (!ALLOWED_EXTENSIONS.some((ext) => filePath.endsWith(ext))) {
|
|
2054
|
+
console.error(
|
|
2055
|
+
` \x1B[31m[handlers] ${filePath} \u2014 only .js, .mjs and .cjs files are allowed\x1B[0m`
|
|
2056
|
+
);
|
|
2057
|
+
return /* @__PURE__ */ new Map();
|
|
2058
|
+
}
|
|
1444
2059
|
try {
|
|
1445
2060
|
const mod = await import(filePath);
|
|
1446
2061
|
const map = /* @__PURE__ */ new Map();
|