@yrest/cli 0.8.1 → 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 +793 -190
- package/dist/cli/index.mjs +793 -190
- package/dist/index.d.mts +47 -5
- package/dist/index.d.ts +47 -5
- package/dist/index.js +375 -169
- package/dist/index.mjs +375 -169
- 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
|
|
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",
|
|
@@ -348,6 +791,139 @@ function resourceAccordion(name, base, isOpen) {
|
|
|
348
791
|
</table>
|
|
349
792
|
</details>`;
|
|
350
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
|
+
}
|
|
351
927
|
function examplesBlock(collections, relations, base, host, options, firstCustomRoute) {
|
|
352
928
|
const examples = [];
|
|
353
929
|
const firstCol = collections[0];
|
|
@@ -378,35 +954,36 @@ curl -X DELETE ${p}/1`
|
|
|
378
954
|
const firstRel = Object.entries(relations)[0];
|
|
379
955
|
if (firstRel) {
|
|
380
956
|
const [child, fields] = firstRel;
|
|
381
|
-
const
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
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
|
+
}
|
|
395
972
|
}
|
|
396
973
|
}
|
|
397
974
|
if (options.pageable.enabled && firstCol) {
|
|
398
975
|
examples.push(`# Pageable envelope
|
|
399
976
|
curl "${host}${base}/${firstCol}?_page=2"`);
|
|
400
977
|
}
|
|
401
|
-
const firstParentRel = Object.entries(relations).find(
|
|
402
|
-
([, fields]) => Object.values(fields).includes(firstCol ?? "")
|
|
403
|
-
);
|
|
404
978
|
if (firstCol) {
|
|
405
979
|
examples.push(
|
|
406
980
|
`# Project fields with ?_fields
|
|
407
981
|
curl "${host}${base}/${firstCol}?_fields=id,name"`
|
|
408
982
|
);
|
|
409
983
|
}
|
|
984
|
+
const firstParentRel = Object.entries(relations).find(
|
|
985
|
+
([, fields]) => Object.values(fields).some((def) => def.type !== "many2many" && def.target === firstCol)
|
|
986
|
+
);
|
|
410
987
|
if (firstParentRel && firstCol) {
|
|
411
988
|
const [childName] = firstParentRel;
|
|
412
989
|
examples.push(
|
|
@@ -432,9 +1009,21 @@ curl ${curlFlag}${fullPath}`);
|
|
|
432
1009
|
const highlighted = examples.map((e) => e.replace(/^(#.+)$/gm, '<span class="cm">$1</span>')).join("\n\n");
|
|
433
1010
|
return `<pre>${highlighted}</pre>`;
|
|
434
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
|
+
})();
|
|
435
1023
|
function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map()) {
|
|
436
1024
|
const collections = Object.keys(storage.getData());
|
|
437
1025
|
const relations = storage.getRelations();
|
|
1026
|
+
const customRoutes = storage.getRoutes();
|
|
438
1027
|
const base = options.base;
|
|
439
1028
|
const host = `http://${options.host}:${options.port}`;
|
|
440
1029
|
const modes = [];
|
|
@@ -448,105 +1037,6 @@ function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map(
|
|
|
448
1037
|
if (options.idStrategy !== "increment")
|
|
449
1038
|
modes.push(badge(`id \xB7 ${options.idStrategy}`, "#a371f7", "#a371f718"));
|
|
450
1039
|
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
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.`;
|
|
551
1041
|
return `<!DOCTYPE html>
|
|
552
1042
|
<html lang="en">
|
|
@@ -694,10 +1184,10 @@ function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map(
|
|
|
694
1184
|
<h2>Endpoints</h2>
|
|
695
1185
|
<div class="endpoints-grid">
|
|
696
1186
|
${accordions}
|
|
697
|
-
${
|
|
698
|
-
${snapshotAccordion}
|
|
699
|
-
${customRoutesAccordion}
|
|
700
|
-
${handlersAccordion}
|
|
1187
|
+
${nestedRoutesAccordion(relations, base)}
|
|
1188
|
+
${options.snapshot ? snapshotAccordion() : ""}
|
|
1189
|
+
${customRoutesAccordion(customRoutes, base, handlers)}
|
|
1190
|
+
${handlersAccordion(handlers, customRoutes, base)}
|
|
701
1191
|
</div>
|
|
702
1192
|
|
|
703
1193
|
<h2>Query Parameters</h2>
|
|
@@ -897,10 +1387,11 @@ function expandItems(input, query, resource, storage) {
|
|
|
897
1387
|
const resourceRelations = storage.getRelations()[resource] ?? {};
|
|
898
1388
|
const expansions = /* @__PURE__ */ new Map();
|
|
899
1389
|
for (const expandKey of keys) {
|
|
900
|
-
for (const [field,
|
|
1390
|
+
for (const [field, def] of Object.entries(resourceRelations)) {
|
|
1391
|
+
if (def.type === "many2many") continue;
|
|
901
1392
|
const derivedKey = field.replace(/Id$/i, "");
|
|
902
|
-
if (derivedKey === expandKey ||
|
|
903
|
-
expansions.set(expandKey, { field, parentCollection });
|
|
1393
|
+
if (derivedKey === expandKey || def.target === expandKey || def.target === `${expandKey}s`) {
|
|
1394
|
+
expansions.set(expandKey, { field, parentCollection: def.target });
|
|
904
1395
|
break;
|
|
905
1396
|
}
|
|
906
1397
|
}
|
|
@@ -929,10 +1420,24 @@ function embedItems(input, query, resource, storage) {
|
|
|
929
1420
|
const relations = storage.getRelations();
|
|
930
1421
|
const embeds = /* @__PURE__ */ new Map();
|
|
931
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
|
+
}
|
|
932
1437
|
outer: for (const [childCollection, fields] of Object.entries(relations)) {
|
|
933
|
-
for (const [fkField,
|
|
934
|
-
if (
|
|
935
|
-
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 });
|
|
936
1441
|
break outer;
|
|
937
1442
|
}
|
|
938
1443
|
}
|
|
@@ -941,10 +1446,57 @@ function embedItems(input, query, resource, storage) {
|
|
|
941
1446
|
if (embeds.size === 0) return isArray ? items : input;
|
|
942
1447
|
const result = items.map((item) => {
|
|
943
1448
|
const out = { ...item };
|
|
944
|
-
for (const [embedKey,
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
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
|
+
}
|
|
948
1500
|
}
|
|
949
1501
|
return out;
|
|
950
1502
|
});
|
|
@@ -984,7 +1536,12 @@ var CollectionRouteCommand = class {
|
|
|
984
1536
|
const totalPages = Math.ceil(totalItems / limit) || 1;
|
|
985
1537
|
const data = projectFields(
|
|
986
1538
|
embedItems(
|
|
987
|
-
expandItems(
|
|
1539
|
+
expandItems(
|
|
1540
|
+
applyNested(paginate(sorted, page, limit), this.resource, this.storage),
|
|
1541
|
+
req.query,
|
|
1542
|
+
this.resource,
|
|
1543
|
+
this.storage
|
|
1544
|
+
),
|
|
988
1545
|
req.query,
|
|
989
1546
|
this.resource,
|
|
990
1547
|
this.storage
|
|
@@ -1016,7 +1573,12 @@ var CollectionRouteCommand = class {
|
|
|
1016
1573
|
}
|
|
1017
1574
|
return projectFields(
|
|
1018
1575
|
embedItems(
|
|
1019
|
-
expandItems(
|
|
1576
|
+
expandItems(
|
|
1577
|
+
applyNested(result, this.resource, this.storage),
|
|
1578
|
+
req.query,
|
|
1579
|
+
this.resource,
|
|
1580
|
+
this.storage
|
|
1581
|
+
),
|
|
1020
1582
|
req.query,
|
|
1021
1583
|
this.resource,
|
|
1022
1584
|
this.storage
|
|
@@ -1197,7 +1759,12 @@ var ItemRouteCommand = class {
|
|
|
1197
1759
|
const fields = (req.query["_fields"] ?? "").split(",").map((f) => f.trim()).filter(Boolean);
|
|
1198
1760
|
return projectFields(
|
|
1199
1761
|
embedItems(
|
|
1200
|
-
expandItems(
|
|
1762
|
+
expandItems(
|
|
1763
|
+
applyNested(item, this.resource, this.storage),
|
|
1764
|
+
req.query,
|
|
1765
|
+
this.resource,
|
|
1766
|
+
this.storage
|
|
1767
|
+
),
|
|
1201
1768
|
req.query,
|
|
1202
1769
|
this.resource,
|
|
1203
1770
|
this.storage
|
|
@@ -1240,32 +1807,68 @@ var NestedRouteCommand = class {
|
|
|
1240
1807
|
relations;
|
|
1241
1808
|
base;
|
|
1242
1809
|
register(server) {
|
|
1243
|
-
for (const [
|
|
1244
|
-
for (const [
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
if (!parentItem) return reply.status(404).send({ error: "Not found" });
|
|
1251
|
-
const children = (this.storage.getCollection(child) ?? []).filter(
|
|
1252
|
-
(item) => String(item[field]) === req.params.id
|
|
1253
|
-
);
|
|
1254
|
-
return children;
|
|
1255
|
-
});
|
|
1256
|
-
server.get(itemPath, (req, reply) => {
|
|
1257
|
-
const parentCollection = this.storage.getCollection(parent) ?? [];
|
|
1258
|
-
const parentItem = findById(parentCollection, req.params.id);
|
|
1259
|
-
if (!parentItem) return reply.status(404).send({ error: "Not found" });
|
|
1260
|
-
const childItem = (this.storage.getCollection(child) ?? []).find(
|
|
1261
|
-
(item) => String(item[field]) === req.params.id && String(item["id"]) === req.params.childId
|
|
1262
|
-
);
|
|
1263
|
-
if (!childItem) return reply.status(404).send({ error: "Not found" });
|
|
1264
|
-
return childItem;
|
|
1265
|
-
});
|
|
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
|
+
}
|
|
1266
1817
|
}
|
|
1267
1818
|
}
|
|
1268
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
|
+
}
|
|
1269
1872
|
};
|
|
1270
1873
|
|
|
1271
1874
|
// src/router/routes/snapshot.routes.ts
|