@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.mjs
CHANGED
|
@@ -9,62 +9,470 @@ import { existsSync, writeFileSync } from "fs";
|
|
|
9
9
|
import { resolve } from "path";
|
|
10
10
|
|
|
11
11
|
// src/cli/commands/templates/basic.ts
|
|
12
|
-
var basicTemplate =
|
|
12
|
+
var basicTemplate = `# yrest basic sample
|
|
13
|
+
# Run: npx @yrest/cli serve db.yml
|
|
14
|
+
# Docs: GET http://localhost:3070/_about
|
|
15
|
+
|
|
16
|
+
users:
|
|
13
17
|
- id: 1
|
|
14
|
-
name: Ana
|
|
15
|
-
email: ana@
|
|
18
|
+
name: Ana Garc\xEDa
|
|
19
|
+
email: ana@example.com
|
|
20
|
+
role: admin
|
|
21
|
+
active: true
|
|
16
22
|
- id: 2
|
|
17
|
-
name: Luis
|
|
18
|
-
email: luis@
|
|
23
|
+
name: Luis Mart\xEDnez
|
|
24
|
+
email: luis@example.com
|
|
25
|
+
role: editor
|
|
26
|
+
active: true
|
|
27
|
+
- id: 3
|
|
28
|
+
name: Sara L\xF3pez
|
|
29
|
+
email: sara@example.com
|
|
30
|
+
role: user
|
|
31
|
+
active: true
|
|
32
|
+
- id: 4
|
|
33
|
+
name: Diego Ruiz
|
|
34
|
+
email: diego@example.com
|
|
35
|
+
role: user
|
|
36
|
+
active: false
|
|
19
37
|
|
|
20
38
|
products:
|
|
21
39
|
- id: 1
|
|
22
|
-
name: Laptop
|
|
23
|
-
price:
|
|
40
|
+
name: Laptop Pro 15
|
|
41
|
+
price: 1299.99
|
|
42
|
+
stock: 15
|
|
43
|
+
category: electronics
|
|
44
|
+
featured: true
|
|
45
|
+
- id: 2
|
|
46
|
+
name: Wireless Mouse
|
|
47
|
+
price: 39.99
|
|
48
|
+
stock: 80
|
|
49
|
+
category: accessories
|
|
50
|
+
featured: false
|
|
51
|
+
- id: 3
|
|
52
|
+
name: Mechanical Keyboard
|
|
53
|
+
price: 129.99
|
|
54
|
+
stock: 45
|
|
55
|
+
category: accessories
|
|
56
|
+
featured: true
|
|
57
|
+
- id: 4
|
|
58
|
+
name: 4K Monitor 27"
|
|
59
|
+
price: 549.99
|
|
60
|
+
stock: 20
|
|
61
|
+
category: electronics
|
|
62
|
+
featured: true
|
|
63
|
+
- id: 5
|
|
64
|
+
name: USB-C Hub 7-in-1
|
|
65
|
+
price: 49.99
|
|
66
|
+
stock: 100
|
|
67
|
+
category: accessories
|
|
68
|
+
featured: false
|
|
69
|
+
|
|
70
|
+
categories:
|
|
71
|
+
- id: 1
|
|
72
|
+
name: Electronics
|
|
73
|
+
slug: electronics
|
|
74
|
+
description: Laptops, monitors and computing gear
|
|
24
75
|
- id: 2
|
|
25
|
-
name:
|
|
26
|
-
|
|
76
|
+
name: Accessories
|
|
77
|
+
slug: accessories
|
|
78
|
+
description: Peripherals and add-ons
|
|
79
|
+
|
|
80
|
+
# Try these queries:
|
|
81
|
+
# GET /users?role=admin
|
|
82
|
+
# GET /products?featured=true&_sort=price&_order=asc
|
|
83
|
+
# GET /products?price_lte=100
|
|
84
|
+
# GET /users?_q=garcia
|
|
85
|
+
# GET /products?_fields=id,name,price&_page=1&_limit=3
|
|
27
86
|
`;
|
|
28
87
|
|
|
29
88
|
// src/cli/commands/templates/relational.ts
|
|
30
|
-
var relationalTemplate =
|
|
89
|
+
var relationalTemplate = `# yrest relational sample \u2014 blog
|
|
90
|
+
# Demonstrates: many2one, one2one and many2many relationships
|
|
91
|
+
# Run: npx @yrest/cli serve db.yml
|
|
92
|
+
# Docs: GET http://localhost:3070/_about
|
|
93
|
+
|
|
94
|
+
_rel:
|
|
95
|
+
# many2one \u2014 posts and comments belong to a user
|
|
31
96
|
posts:
|
|
32
97
|
userId: users
|
|
98
|
+
# many2many \u2014 posts can have multiple tags via the post_tags pivot
|
|
99
|
+
tags:
|
|
100
|
+
type: many2many
|
|
101
|
+
target: tags
|
|
102
|
+
through: post_tags
|
|
103
|
+
foreignKey: postId
|
|
104
|
+
otherKey: tagId
|
|
33
105
|
comments:
|
|
34
106
|
postId: posts
|
|
107
|
+
userId: users
|
|
108
|
+
|
|
109
|
+
# one2one \u2014 each user has exactly one profile
|
|
110
|
+
profiles:
|
|
111
|
+
userId:
|
|
112
|
+
type: one2one
|
|
113
|
+
target: users
|
|
35
114
|
|
|
36
115
|
users:
|
|
37
116
|
- id: 1
|
|
38
|
-
name: Ana
|
|
39
|
-
email: ana@
|
|
117
|
+
name: Ana Garc\xEDa
|
|
118
|
+
email: ana@example.com
|
|
119
|
+
role: author
|
|
40
120
|
- id: 2
|
|
41
|
-
name: Luis
|
|
42
|
-
email: luis@
|
|
121
|
+
name: Luis Mart\xEDnez
|
|
122
|
+
email: luis@example.com
|
|
123
|
+
role: author
|
|
124
|
+
- id: 3
|
|
125
|
+
name: Sara L\xF3pez
|
|
126
|
+
email: sara@example.com
|
|
127
|
+
role: reader
|
|
128
|
+
|
|
129
|
+
profiles:
|
|
130
|
+
- id: 1
|
|
131
|
+
userId: 1
|
|
132
|
+
bio: Full-stack developer and open-source enthusiast
|
|
133
|
+
avatar: https://i.pravatar.cc/150?img=1
|
|
134
|
+
website: https://ana.dev
|
|
135
|
+
- id: 2
|
|
136
|
+
userId: 2
|
|
137
|
+
bio: Backend engineer, coffee addict
|
|
138
|
+
avatar: https://i.pravatar.cc/150?img=2
|
|
139
|
+
website: https://luisdev.io
|
|
140
|
+
- id: 3
|
|
141
|
+
userId: 3
|
|
142
|
+
bio: Designer turned frontend developer
|
|
143
|
+
avatar: https://i.pravatar.cc/150?img=3
|
|
144
|
+
website: null
|
|
145
|
+
|
|
146
|
+
tags:
|
|
147
|
+
- id: 1
|
|
148
|
+
name: typescript
|
|
149
|
+
color: "#3178c6"
|
|
150
|
+
- id: 2
|
|
151
|
+
name: api
|
|
152
|
+
color: "#10b981"
|
|
153
|
+
- id: 3
|
|
154
|
+
name: testing
|
|
155
|
+
color: "#f59e0b"
|
|
156
|
+
- id: 4
|
|
157
|
+
name: devtools
|
|
158
|
+
color: "#8b5cf6"
|
|
159
|
+
- id: 5
|
|
160
|
+
name: yaml
|
|
161
|
+
color: "#ef4444"
|
|
43
162
|
|
|
44
163
|
posts:
|
|
45
164
|
- id: 1
|
|
46
|
-
title:
|
|
47
|
-
|
|
165
|
+
title: Getting started with TypeScript
|
|
166
|
+
slug: getting-started-typescript
|
|
167
|
+
body: TypeScript adds static typing to JavaScript, catching errors at compile time...
|
|
48
168
|
userId: 1
|
|
169
|
+
published: true
|
|
170
|
+
views: 1420
|
|
171
|
+
createdAt: "2024-11-01"
|
|
49
172
|
- id: 2
|
|
50
|
-
title:
|
|
51
|
-
|
|
173
|
+
title: Building REST APIs with Fastify
|
|
174
|
+
slug: rest-apis-fastify
|
|
175
|
+
body: Fastify is the fastest Node.js web framework, perfect for building APIs...
|
|
52
176
|
userId: 1
|
|
177
|
+
published: true
|
|
178
|
+
views: 980
|
|
179
|
+
createdAt: "2024-11-15"
|
|
180
|
+
- id: 3
|
|
181
|
+
title: Testing strategies for modern apps
|
|
182
|
+
slug: testing-strategies-modern
|
|
183
|
+
body: A solid test strategy covers unit, integration and end-to-end scenarios...
|
|
184
|
+
userId: 2
|
|
185
|
+
published: true
|
|
186
|
+
views: 640
|
|
187
|
+
createdAt: "2024-12-03"
|
|
188
|
+
- id: 4
|
|
189
|
+
title: YAML as a database format
|
|
190
|
+
slug: yaml-database-format
|
|
191
|
+
body: YAML is human-readable and expressive enough for mock data during development...
|
|
192
|
+
userId: 2
|
|
193
|
+
published: false
|
|
194
|
+
views: 0
|
|
195
|
+
createdAt: "2025-01-10"
|
|
196
|
+
|
|
197
|
+
# pivot table for posts \u2194 tags (many2many)
|
|
198
|
+
post_tags:
|
|
199
|
+
- { id: 1, postId: 1, tagId: 1 }
|
|
200
|
+
- { id: 2, postId: 1, tagId: 2 }
|
|
201
|
+
- { id: 3, postId: 2, tagId: 2 }
|
|
202
|
+
- { id: 4, postId: 2, tagId: 4 }
|
|
203
|
+
- { id: 5, postId: 3, tagId: 3 }
|
|
204
|
+
- { id: 6, postId: 3, tagId: 1 }
|
|
205
|
+
- { id: 7, postId: 4, tagId: 5 }
|
|
206
|
+
- { id: 8, postId: 4, tagId: 2 }
|
|
53
207
|
|
|
54
208
|
comments:
|
|
55
209
|
- id: 1
|
|
56
|
-
body: Great
|
|
210
|
+
body: Great introduction! This helped me a lot.
|
|
57
211
|
postId: 1
|
|
212
|
+
userId: 3
|
|
213
|
+
likes: 5
|
|
58
214
|
- id: 2
|
|
59
|
-
body:
|
|
215
|
+
body: Could you cover generics in a follow-up post?
|
|
60
216
|
postId: 1
|
|
217
|
+
userId: 2
|
|
218
|
+
likes: 3
|
|
219
|
+
- id: 3
|
|
220
|
+
body: Fastify is indeed much faster than Express in my benchmarks.
|
|
221
|
+
postId: 2
|
|
222
|
+
userId: 3
|
|
223
|
+
likes: 8
|
|
224
|
+
- id: 4
|
|
225
|
+
body: Do you have a GitHub repo with these examples?
|
|
226
|
+
postId: 2
|
|
227
|
+
userId: 1
|
|
228
|
+
likes: 1
|
|
229
|
+
- id: 5
|
|
230
|
+
body: E2E tests are underrated. Solid post!
|
|
231
|
+
postId: 3
|
|
232
|
+
userId: 1
|
|
233
|
+
likes: 4
|
|
234
|
+
|
|
235
|
+
# Try these queries:
|
|
236
|
+
# GET /posts?published=true&_sort=views&_order=desc
|
|
237
|
+
# GET /posts/1?_expand=user \u2192 embeds author object
|
|
238
|
+
# GET /users/1?_embed=posts \u2192 embeds posts array
|
|
239
|
+
# GET /users/1/profiles \u2192 one2one nested route
|
|
240
|
+
# GET /posts/1/tags \u2192 many2many nested route
|
|
241
|
+
# GET /posts/1?_embed=tags \u2192 many2many via ?_embed
|
|
242
|
+
# GET /users/1?_embed=profiles \u2192 one2one via ?_embed (returns object, not array)
|
|
243
|
+
`;
|
|
244
|
+
|
|
245
|
+
// src/cli/commands/templates/ecommerce.ts
|
|
246
|
+
var ecommerceTemplate = `# yrest ecommerce sample
|
|
247
|
+
# Demonstrates: many2one, many2many, _routes with scenarios, template vars and delay
|
|
248
|
+
# Run: npx @yrest/cli serve db.yml
|
|
249
|
+
# Docs: GET http://localhost:3070/_about
|
|
250
|
+
|
|
251
|
+
_rel:
|
|
252
|
+
# many2one
|
|
253
|
+
orders:
|
|
254
|
+
userId: users
|
|
255
|
+
order_items:
|
|
256
|
+
orderId: orders
|
|
257
|
+
productId: products
|
|
258
|
+
|
|
259
|
+
# many2many \u2014 products belong to multiple categories via pivot
|
|
260
|
+
products:
|
|
261
|
+
categories:
|
|
262
|
+
type: many2many
|
|
263
|
+
target: categories
|
|
264
|
+
through: product_categories
|
|
265
|
+
foreignKey: productId
|
|
266
|
+
otherKey: categoryId
|
|
267
|
+
|
|
268
|
+
_routes:
|
|
269
|
+
# Login with conditional scenarios
|
|
270
|
+
- method: POST
|
|
271
|
+
path: /auth/login
|
|
272
|
+
scenarios:
|
|
273
|
+
- when:
|
|
274
|
+
body.email: admin@example.com
|
|
275
|
+
body.password: secret
|
|
276
|
+
response:
|
|
277
|
+
status: 200
|
|
278
|
+
body:
|
|
279
|
+
token: tok-admin-abc123
|
|
280
|
+
role: admin
|
|
281
|
+
userId: 1
|
|
282
|
+
- when:
|
|
283
|
+
body.email: user@example.com
|
|
284
|
+
body.password: secret
|
|
285
|
+
response:
|
|
286
|
+
status: 200
|
|
287
|
+
body:
|
|
288
|
+
token: tok-user-xyz789
|
|
289
|
+
role: user
|
|
290
|
+
userId: 2
|
|
291
|
+
otherwise:
|
|
292
|
+
status: 401
|
|
293
|
+
body:
|
|
294
|
+
error: Invalid credentials
|
|
295
|
+
|
|
296
|
+
# Logout \u2014 always 204
|
|
297
|
+
- method: POST
|
|
298
|
+
path: /auth/logout
|
|
299
|
+
response:
|
|
300
|
+
status: 204
|
|
301
|
+
|
|
302
|
+
# Static featured products list
|
|
303
|
+
- method: GET
|
|
304
|
+
path: /store/featured
|
|
305
|
+
response:
|
|
306
|
+
status: 200
|
|
307
|
+
body:
|
|
308
|
+
- id: 1
|
|
309
|
+
name: Laptop Pro 15
|
|
310
|
+
price: 1299.99
|
|
311
|
+
badge: Best Seller
|
|
312
|
+
- id: 4
|
|
313
|
+
name: 4K Monitor 27"
|
|
314
|
+
price: 549.99
|
|
315
|
+
badge: New Arrival
|
|
316
|
+
- id: 3
|
|
317
|
+
name: Mechanical Keyboard
|
|
318
|
+
price: 129.99
|
|
319
|
+
badge: On Sale
|
|
320
|
+
|
|
321
|
+
# Template variables \u2014 echoes the requested product id
|
|
322
|
+
- method: GET
|
|
323
|
+
path: /products/:id/summary
|
|
324
|
+
response:
|
|
325
|
+
status: 200
|
|
326
|
+
body:
|
|
327
|
+
productId: "{{params.id}}"
|
|
328
|
+
requestedAt: "{{now}}"
|
|
329
|
+
source: mock
|
|
330
|
+
|
|
331
|
+
# Cancel order with simulated latency and template vars
|
|
332
|
+
- method: POST
|
|
333
|
+
path: /orders/:id/cancel
|
|
334
|
+
delay: 500
|
|
335
|
+
response:
|
|
336
|
+
status: 200
|
|
337
|
+
body:
|
|
338
|
+
orderId: "{{params.id}}"
|
|
339
|
+
status: cancelled
|
|
340
|
+
cancelledAt: "{{now}}"
|
|
341
|
+
|
|
342
|
+
# Simulate a service outage for testing error handling
|
|
343
|
+
- method: GET
|
|
344
|
+
path: /store/inventory/sync
|
|
345
|
+
error: 503
|
|
346
|
+
errorBody:
|
|
347
|
+
message: Inventory service temporarily unavailable
|
|
348
|
+
retryAfter: 30
|
|
349
|
+
|
|
350
|
+
users:
|
|
351
|
+
- id: 1
|
|
352
|
+
name: Ana Garc\xEDa
|
|
353
|
+
email: admin@example.com
|
|
354
|
+
role: admin
|
|
355
|
+
active: true
|
|
356
|
+
- id: 2
|
|
357
|
+
name: Luis Mart\xEDnez
|
|
358
|
+
email: user@example.com
|
|
359
|
+
role: user
|
|
360
|
+
active: true
|
|
361
|
+
- id: 3
|
|
362
|
+
name: Sara L\xF3pez
|
|
363
|
+
email: sara@example.com
|
|
364
|
+
role: user
|
|
365
|
+
active: true
|
|
366
|
+
- id: 4
|
|
367
|
+
name: Diego Ruiz
|
|
368
|
+
email: diego@example.com
|
|
369
|
+
role: user
|
|
370
|
+
active: false
|
|
371
|
+
|
|
372
|
+
categories:
|
|
373
|
+
- id: 1
|
|
374
|
+
name: Laptops
|
|
375
|
+
slug: laptops
|
|
376
|
+
- id: 2
|
|
377
|
+
name: Peripherals
|
|
378
|
+
slug: peripherals
|
|
379
|
+
- id: 3
|
|
380
|
+
name: Monitors
|
|
381
|
+
slug: monitors
|
|
382
|
+
- id: 4
|
|
383
|
+
name: Accessories
|
|
384
|
+
slug: accessories
|
|
385
|
+
|
|
386
|
+
products:
|
|
387
|
+
- id: 1
|
|
388
|
+
name: Laptop Pro 15
|
|
389
|
+
description: High-performance laptop for developers
|
|
390
|
+
price: 1299.99
|
|
391
|
+
stock: 15
|
|
392
|
+
sku: LAP-001
|
|
393
|
+
active: true
|
|
394
|
+
- id: 2
|
|
395
|
+
name: Wireless Mouse
|
|
396
|
+
description: Ergonomic wireless mouse with USB-C receiver
|
|
397
|
+
price: 39.99
|
|
398
|
+
stock: 80
|
|
399
|
+
sku: MOU-001
|
|
400
|
+
active: true
|
|
401
|
+
- id: 3
|
|
402
|
+
name: Mechanical Keyboard
|
|
403
|
+
description: Tactile switches, full RGB, TKL layout
|
|
404
|
+
price: 129.99
|
|
405
|
+
stock: 45
|
|
406
|
+
sku: KEY-001
|
|
407
|
+
active: true
|
|
408
|
+
- id: 4
|
|
409
|
+
name: 4K Monitor 27"
|
|
410
|
+
description: IPS panel, 144Hz, HDR400
|
|
411
|
+
price: 549.99
|
|
412
|
+
stock: 20
|
|
413
|
+
sku: MON-001
|
|
414
|
+
active: true
|
|
415
|
+
- id: 5
|
|
416
|
+
name: USB-C Hub 7-in-1
|
|
417
|
+
description: HDMI, SD card and USB-A ports
|
|
418
|
+
price: 49.99
|
|
419
|
+
stock: 100
|
|
420
|
+
sku: HUB-001
|
|
421
|
+
active: true
|
|
422
|
+
|
|
423
|
+
# pivot table for products \u2194 categories (many2many)
|
|
424
|
+
product_categories:
|
|
425
|
+
- { id: 1, productId: 1, categoryId: 1 }
|
|
426
|
+
- { id: 2, productId: 2, categoryId: 2 }
|
|
427
|
+
- { id: 3, productId: 2, categoryId: 4 }
|
|
428
|
+
- { id: 4, productId: 3, categoryId: 2 }
|
|
429
|
+
- { id: 5, productId: 3, categoryId: 4 }
|
|
430
|
+
- { id: 6, productId: 4, categoryId: 3 }
|
|
431
|
+
- { id: 7, productId: 5, categoryId: 4 }
|
|
432
|
+
|
|
433
|
+
orders:
|
|
434
|
+
- id: 1
|
|
435
|
+
userId: 2
|
|
436
|
+
status: delivered
|
|
437
|
+
total: 1339.98
|
|
438
|
+
createdAt: "2024-12-10"
|
|
439
|
+
- id: 2
|
|
440
|
+
userId: 3
|
|
441
|
+
status: processing
|
|
442
|
+
total: 179.98
|
|
443
|
+
createdAt: "2025-01-15"
|
|
444
|
+
- id: 3
|
|
445
|
+
userId: 2
|
|
446
|
+
status: pending
|
|
447
|
+
total: 549.99
|
|
448
|
+
createdAt: "2025-02-01"
|
|
449
|
+
|
|
450
|
+
order_items:
|
|
451
|
+
- { id: 1, orderId: 1, productId: 1, quantity: 1, unitPrice: 1299.99 }
|
|
452
|
+
- { id: 2, orderId: 1, productId: 2, quantity: 1, unitPrice: 39.99 }
|
|
453
|
+
- { id: 3, orderId: 2, productId: 3, quantity: 1, unitPrice: 129.99 }
|
|
454
|
+
- { id: 4, orderId: 2, productId: 2, quantity: 1, unitPrice: 39.99 }
|
|
455
|
+
- { id: 5, orderId: 3, productId: 4, quantity: 1, unitPrice: 549.99 }
|
|
456
|
+
|
|
457
|
+
# Try these queries:
|
|
458
|
+
# POST /auth/login { "email": "admin@example.com", "password": "secret" }
|
|
459
|
+
# GET /store/featured
|
|
460
|
+
# GET /products/1/summary
|
|
461
|
+
# GET /products/1/categories \u2192 many2many nested route
|
|
462
|
+
# GET /products/1?_embed=categories \u2192 many2many via ?_embed
|
|
463
|
+
# GET /users/2?_embed=orders \u2192 many2one ?_embed
|
|
464
|
+
# GET /users/2/orders \u2192 nested route
|
|
465
|
+
# GET /orders/1?_embed=order_items \u2192 nested items
|
|
466
|
+
# POST /orders/1/cancel \u2192 delayed response with template vars
|
|
467
|
+
# GET /store/inventory/sync \u2192 forced 503 error
|
|
61
468
|
`;
|
|
62
469
|
|
|
63
470
|
// src/cli/commands/templates/index.ts
|
|
64
|
-
var SAMPLES = ["basic", "relational"];
|
|
471
|
+
var SAMPLES = ["basic", "relational", "ecommerce"];
|
|
65
472
|
var templates = {
|
|
66
473
|
basic: basicTemplate,
|
|
67
|
-
relational: relationalTemplate
|
|
474
|
+
relational: relationalTemplate,
|
|
475
|
+
ecommerce: ecommerceTemplate
|
|
68
476
|
};
|
|
69
477
|
|
|
70
478
|
// src/cli/commands/init.ts
|
|
@@ -114,6 +522,50 @@ import { resolve as resolve2, dirname } from "path";
|
|
|
114
522
|
import { randomUUID } from "crypto";
|
|
115
523
|
import { parse, stringify } from "yaml";
|
|
116
524
|
|
|
525
|
+
// src/storage/parseRelations.ts
|
|
526
|
+
function parseRelations(raw) {
|
|
527
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
|
|
528
|
+
const result = {};
|
|
529
|
+
for (const [collection, fields] of Object.entries(raw)) {
|
|
530
|
+
if (!fields || typeof fields !== "object" || Array.isArray(fields)) continue;
|
|
531
|
+
result[collection] = {};
|
|
532
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
533
|
+
const def = normaliseRelationDef(key, value);
|
|
534
|
+
if (def) result[collection][key] = def;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
return result;
|
|
538
|
+
}
|
|
539
|
+
function normaliseRelationDef(key, value) {
|
|
540
|
+
if (typeof value === "string") {
|
|
541
|
+
return { type: "many2one", target: value };
|
|
542
|
+
}
|
|
543
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
544
|
+
const v = value;
|
|
545
|
+
const type = v["type"];
|
|
546
|
+
const nested = v["nested"] === true ? true : void 0;
|
|
547
|
+
if (type === "many2one" || type === void 0) {
|
|
548
|
+
const target = v["target"];
|
|
549
|
+
if (typeof target !== "string") return null;
|
|
550
|
+
return nested ? { type: "many2one", target, nested } : { type: "many2one", target };
|
|
551
|
+
}
|
|
552
|
+
if (type === "one2one") {
|
|
553
|
+
const target = v["target"];
|
|
554
|
+
if (typeof target !== "string") return null;
|
|
555
|
+
return nested ? { type: "one2one", target, nested } : { type: "one2one", target };
|
|
556
|
+
}
|
|
557
|
+
if (type === "many2many") {
|
|
558
|
+
const target = typeof v["target"] === "string" ? v["target"] : key;
|
|
559
|
+
const through = v["through"];
|
|
560
|
+
const foreignKey = v["foreignKey"];
|
|
561
|
+
const otherKey = v["otherKey"];
|
|
562
|
+
if (typeof through !== "string" || typeof foreignKey !== "string" || typeof otherKey !== "string")
|
|
563
|
+
return null;
|
|
564
|
+
return nested ? { type: "many2many", target, through, foreignKey, otherKey, nested } : { type: "many2many", target, through, foreignKey, otherKey };
|
|
565
|
+
}
|
|
566
|
+
return null;
|
|
567
|
+
}
|
|
568
|
+
|
|
117
569
|
// src/utils/deepCopy.ts
|
|
118
570
|
function deepCopyData(source) {
|
|
119
571
|
return Object.fromEntries(
|
|
@@ -125,7 +577,7 @@ function deepCopyData(source) {
|
|
|
125
577
|
function createYrestStorage(filePath) {
|
|
126
578
|
const absPath = resolve2(filePath);
|
|
127
579
|
const raw = parse(readFileSync(absPath, "utf8")) ?? {};
|
|
128
|
-
const relations = raw["_rel"]
|
|
580
|
+
const relations = parseRelations(raw["_rel"]);
|
|
129
581
|
const routes = Array.isArray(raw["_routes"]) ? raw["_routes"] : [];
|
|
130
582
|
const data = Object.fromEntries(
|
|
131
583
|
Object.entries(raw).filter(([key]) => key !== "_rel" && key !== "_routes")
|
|
@@ -162,7 +614,7 @@ function createYrestStorage(filePath) {
|
|
|
162
614
|
},
|
|
163
615
|
reload() {
|
|
164
616
|
const fresh = parse(readFileSync(absPath, "utf8")) ?? {};
|
|
165
|
-
const freshRelations = fresh["_rel"]
|
|
617
|
+
const freshRelations = parseRelations(fresh["_rel"]);
|
|
166
618
|
const freshData = Object.fromEntries(
|
|
167
619
|
Object.entries(fresh).filter(([key]) => key !== "_rel" && key !== "_routes")
|
|
168
620
|
);
|
|
@@ -247,16 +699,7 @@ function hasTemplates(value) {
|
|
|
247
699
|
return typeof value === "string" ? value.includes("{{") : JSON.stringify(value).includes("{{");
|
|
248
700
|
}
|
|
249
701
|
|
|
250
|
-
// src/router/templates/about.
|
|
251
|
-
var _dir = dirname2(fileURLToPath(import.meta.url));
|
|
252
|
-
var LOGO_SRC = (() => {
|
|
253
|
-
try {
|
|
254
|
-
const buf = readFileSync2(join(_dir, "../../assets/logo-color.png"));
|
|
255
|
-
return `data:image/png;base64,${buf.toString("base64")}`;
|
|
256
|
-
} catch {
|
|
257
|
-
return "";
|
|
258
|
-
}
|
|
259
|
-
})();
|
|
702
|
+
// src/router/templates/about.helpers.ts
|
|
260
703
|
var METHOD_COLOR = {
|
|
261
704
|
GET: "#3fb950",
|
|
262
705
|
POST: "#58a6ff",
|
|
@@ -321,6 +764,139 @@ function resourceAccordion(name, base, isOpen) {
|
|
|
321
764
|
</table>
|
|
322
765
|
</details>`;
|
|
323
766
|
}
|
|
767
|
+
function nestedRoutesAccordion(relations, base) {
|
|
768
|
+
const rows = [];
|
|
769
|
+
for (const [source, fields] of Object.entries(relations)) {
|
|
770
|
+
for (const [key, def] of Object.entries(fields)) {
|
|
771
|
+
const nestedBadge = def.nested ? ` ${badge("nested", "#facc15", "#facc1518")}` : "";
|
|
772
|
+
if (def.type === "many2many") {
|
|
773
|
+
const singular = source.endsWith("s") ? source.slice(0, -1) : source;
|
|
774
|
+
const m2mBadge = badge("many2many", "#818cf8", "#818cf818");
|
|
775
|
+
rows.push(
|
|
776
|
+
endpointRow(
|
|
777
|
+
"GET",
|
|
778
|
+
`${base}/${source}/:id/${key}`,
|
|
779
|
+
`List ${def.target} linked to a ${singular} via ${def.through}. ${m2mBadge}${nestedBadge}`
|
|
780
|
+
)
|
|
781
|
+
);
|
|
782
|
+
const targetSingular = def.target.endsWith("s") ? def.target.slice(0, -1) : def.target;
|
|
783
|
+
rows.push(
|
|
784
|
+
endpointRow(
|
|
785
|
+
"GET",
|
|
786
|
+
`${base}/${def.target}/:id/${source}`,
|
|
787
|
+
`List ${source} linked to a ${targetSingular} via ${def.through} (inverse). ${m2mBadge}`
|
|
788
|
+
)
|
|
789
|
+
);
|
|
790
|
+
} else {
|
|
791
|
+
const path = `${base}/${def.target}/:id/${source}`;
|
|
792
|
+
const parentSingular = def.target.endsWith("s") ? def.target.slice(0, -1) : def.target;
|
|
793
|
+
const typeBadge = def.type === "one2one" ? ` ${badge("one2one", "#34d399", "#34d39918")}` : "";
|
|
794
|
+
rows.push(
|
|
795
|
+
endpointRow(
|
|
796
|
+
"GET",
|
|
797
|
+
path,
|
|
798
|
+
`${def.type === "one2one" ? "Get" : "List"} ${source} belonging to a ${parentSingular}.${typeBadge}${nestedBadge}`
|
|
799
|
+
)
|
|
800
|
+
);
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
if (!rows.length) return "";
|
|
805
|
+
return `
|
|
806
|
+
<details class="resource-card nested-card">
|
|
807
|
+
<summary>
|
|
808
|
+
<span class="resource-name">Nested routes</span>
|
|
809
|
+
<span class="route-count">${rows.length} route${rows.length !== 1 ? "s" : ""}</span>
|
|
810
|
+
</summary>
|
|
811
|
+
<table><tbody>${rows.join("")}</tbody></table>
|
|
812
|
+
</details>`;
|
|
813
|
+
}
|
|
814
|
+
function snapshotAccordion() {
|
|
815
|
+
return `
|
|
816
|
+
<details class="resource-card nested-card">
|
|
817
|
+
<summary>
|
|
818
|
+
<span class="resource-name">/_snapshot</span>
|
|
819
|
+
<span class="route-count">3 routes</span>
|
|
820
|
+
</summary>
|
|
821
|
+
<table><tbody>
|
|
822
|
+
${endpointRow("GET", "/_snapshot", "Returns metadata of the current snapshot: <code>savedAt</code> and item counts per collection.")}
|
|
823
|
+
${endpointRow("POST", "/_snapshot/save", "Replaces the stored snapshot with the current database state.")}
|
|
824
|
+
${endpointRow("POST", "/_snapshot/reset", "Restores the database to the last saved snapshot and persists to disk.")}
|
|
825
|
+
</tbody></table>
|
|
826
|
+
</details>`;
|
|
827
|
+
}
|
|
828
|
+
function customRoutesAccordion(routes, base, handlers) {
|
|
829
|
+
if (!routes.length) return "";
|
|
830
|
+
const rows = routes.map((r) => {
|
|
831
|
+
const fullPath = `${base}${r.path}`;
|
|
832
|
+
const tags = [];
|
|
833
|
+
if (r.error) tags.push(`<span style="color:#f85149;font-size:11px">error\xB7${r.error}</span>`);
|
|
834
|
+
if (r.delay && r.delay > 0)
|
|
835
|
+
tags.push(`<span style="color:#fb923c;font-size:11px">delay\xB7${r.delay}ms</span>`);
|
|
836
|
+
if (r.scenarios?.length) {
|
|
837
|
+
const hasOr = r.scenarios.some((s) => Array.isArray(s.when));
|
|
838
|
+
tags.push(
|
|
839
|
+
`<span style="color:#a371f7;font-size:11px">scenarios\xB7${r.scenarios.length}${hasOr ? " (OR)" : ""}</span>`
|
|
840
|
+
);
|
|
841
|
+
}
|
|
842
|
+
if (r.otherwise)
|
|
843
|
+
tags.push(`<span style="color:var(--text-muted);font-size:11px">otherwise</span>`);
|
|
844
|
+
let desc;
|
|
845
|
+
if (r.error) {
|
|
846
|
+
desc = `Error injection \u2014 <code>${r.error}</code>`;
|
|
847
|
+
} else if (r.handler) {
|
|
848
|
+
const found = handlers.has(r.handler);
|
|
849
|
+
const handlerName = escapeHtml(r.handler);
|
|
850
|
+
desc = found ? `Handler \u2014 <code>${handlerName}()</code>` : `Handler \u2014 <code>${handlerName}()</code> <span style="color:#f85149">(not loaded)</span>`;
|
|
851
|
+
} else if (r.scenarios?.length) {
|
|
852
|
+
const hasTemplateInScenarios = r.scenarios.some((s) => s.response.body != null && hasTemplates(s.response.body)) || r.otherwise?.body != null && hasTemplates(r.otherwise.body);
|
|
853
|
+
desc = hasTemplateInScenarios ? `Scenarios \u2014 <code>{{\u2026}}</code>` : `Scenarios`;
|
|
854
|
+
} else if (r.response?.body != null && hasTemplates(r.response.body)) {
|
|
855
|
+
desc = `Dynamic \u2014 <code>{{\u2026}}</code>`;
|
|
856
|
+
} else {
|
|
857
|
+
const status = r.response?.status ?? 200;
|
|
858
|
+
desc = `Static \u2014 <code>${status}</code>${r.response?.headers ? ` + headers` : ""}`;
|
|
859
|
+
}
|
|
860
|
+
if (tags.length) desc += ` ${tags.join(" ")}`;
|
|
861
|
+
return endpointRow(r.method?.toUpperCase() ?? "GET", fullPath, desc);
|
|
862
|
+
});
|
|
863
|
+
return `
|
|
864
|
+
<details class="resource-card nested-card">
|
|
865
|
+
<summary>
|
|
866
|
+
<span class="resource-name">Custom routes</span>
|
|
867
|
+
<span class="route-count">${routes.length} route${routes.length !== 1 ? "s" : ""}</span>
|
|
868
|
+
</summary>
|
|
869
|
+
<table><tbody>
|
|
870
|
+
${rows.join("")}
|
|
871
|
+
</tbody></table>
|
|
872
|
+
</details>`;
|
|
873
|
+
}
|
|
874
|
+
function handlersAccordion(handlers, routes, base) {
|
|
875
|
+
if (!handlers.size) return "";
|
|
876
|
+
const routesByHandler = /* @__PURE__ */ new Map();
|
|
877
|
+
for (const r of routes) {
|
|
878
|
+
if (r.handler) {
|
|
879
|
+
const list = routesByHandler.get(r.handler) ?? [];
|
|
880
|
+
list.push({ method: (r.method ?? "GET").toUpperCase(), path: `${base}${r.path}` });
|
|
881
|
+
routesByHandler.set(r.handler, list);
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
const rows = [...handlers.keys()].map((name) => {
|
|
885
|
+
const linked = routesByHandler.get(name);
|
|
886
|
+
const routeDesc = linked ? linked.map((r) => `<code>${r.method} ${r.path}</code>`).join(", ") : `<span style="color:var(--text-muted)">not referenced in _routes</span>`;
|
|
887
|
+
return endpointRow("fn", name + "()", routeDesc);
|
|
888
|
+
});
|
|
889
|
+
return `
|
|
890
|
+
<details class="resource-card nested-card">
|
|
891
|
+
<summary>
|
|
892
|
+
<span class="resource-name">Handlers</span>
|
|
893
|
+
<span class="route-count">${handlers.size} function${handlers.size !== 1 ? "s" : ""}</span>
|
|
894
|
+
</summary>
|
|
895
|
+
<table><tbody>
|
|
896
|
+
${rows.join("")}
|
|
897
|
+
</tbody></table>
|
|
898
|
+
</details>`;
|
|
899
|
+
}
|
|
324
900
|
function examplesBlock(collections, relations, base, host, options, firstCustomRoute) {
|
|
325
901
|
const examples = [];
|
|
326
902
|
const firstCol = collections[0];
|
|
@@ -351,35 +927,36 @@ curl -X DELETE ${p}/1`
|
|
|
351
927
|
const firstRel = Object.entries(relations)[0];
|
|
352
928
|
if (firstRel) {
|
|
353
929
|
const [child, fields] = firstRel;
|
|
354
|
-
const
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
curl ${host}${base}/${
|
|
930
|
+
const firstField = Object.entries(fields)[0];
|
|
931
|
+
if (firstField) {
|
|
932
|
+
const [fk, def] = firstField;
|
|
933
|
+
if (def.type !== "many2many") {
|
|
934
|
+
const expandKey = fk.replace(/Id$/i, "");
|
|
935
|
+
examples.push(
|
|
936
|
+
`# Embed parent with ?_expand
|
|
937
|
+
curl "${host}${base}/${child}/1?_expand=${expandKey}"`,
|
|
938
|
+
`# Nested resource
|
|
939
|
+
curl ${host}${base}/${def.target}/1/${child}`
|
|
940
|
+
);
|
|
941
|
+
} else {
|
|
942
|
+
examples.push(`# Many-to-many embed
|
|
943
|
+
curl "${host}${base}/${child}/1/${fk}"`);
|
|
944
|
+
}
|
|
368
945
|
}
|
|
369
946
|
}
|
|
370
947
|
if (options.pageable.enabled && firstCol) {
|
|
371
948
|
examples.push(`# Pageable envelope
|
|
372
949
|
curl "${host}${base}/${firstCol}?_page=2"`);
|
|
373
950
|
}
|
|
374
|
-
const firstParentRel = Object.entries(relations).find(
|
|
375
|
-
([, fields]) => Object.values(fields).includes(firstCol ?? "")
|
|
376
|
-
);
|
|
377
951
|
if (firstCol) {
|
|
378
952
|
examples.push(
|
|
379
953
|
`# Project fields with ?_fields
|
|
380
954
|
curl "${host}${base}/${firstCol}?_fields=id,name"`
|
|
381
955
|
);
|
|
382
956
|
}
|
|
957
|
+
const firstParentRel = Object.entries(relations).find(
|
|
958
|
+
([, fields]) => Object.values(fields).some((def) => def.type !== "many2many" && def.target === firstCol)
|
|
959
|
+
);
|
|
383
960
|
if (firstParentRel && firstCol) {
|
|
384
961
|
const [childName] = firstParentRel;
|
|
385
962
|
examples.push(
|
|
@@ -405,9 +982,21 @@ curl ${curlFlag}${fullPath}`);
|
|
|
405
982
|
const highlighted = examples.map((e) => e.replace(/^(#.+)$/gm, '<span class="cm">$1</span>')).join("\n\n");
|
|
406
983
|
return `<pre>${highlighted}</pre>`;
|
|
407
984
|
}
|
|
985
|
+
|
|
986
|
+
// src/router/templates/about.template.ts
|
|
987
|
+
var _dir = dirname2(fileURLToPath(import.meta.url));
|
|
988
|
+
var LOGO_SRC = (() => {
|
|
989
|
+
try {
|
|
990
|
+
const buf = readFileSync2(join(_dir, "../../assets/logo-color.png"));
|
|
991
|
+
return `data:image/png;base64,${buf.toString("base64")}`;
|
|
992
|
+
} catch {
|
|
993
|
+
return "";
|
|
994
|
+
}
|
|
995
|
+
})();
|
|
408
996
|
function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map()) {
|
|
409
997
|
const collections = Object.keys(storage.getData());
|
|
410
998
|
const relations = storage.getRelations();
|
|
999
|
+
const customRoutes = storage.getRoutes();
|
|
411
1000
|
const base = options.base;
|
|
412
1001
|
const host = `http://${options.host}:${options.port}`;
|
|
413
1002
|
const modes = [];
|
|
@@ -421,105 +1010,6 @@ function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map(
|
|
|
421
1010
|
if (options.idStrategy !== "increment")
|
|
422
1011
|
modes.push(badge(`id \xB7 ${options.idStrategy}`, "#a371f7", "#a371f718"));
|
|
423
1012
|
const accordions = collections.map((col, i) => resourceAccordion(col, base, i === 0)).join("");
|
|
424
|
-
const nestedRows = [];
|
|
425
|
-
for (const [child, fields] of Object.entries(relations)) {
|
|
426
|
-
for (const [, parent] of Object.entries(fields)) {
|
|
427
|
-
const nestedPath = `${base}/${parent}/:id/${child}`;
|
|
428
|
-
const parentSingular = parent.endsWith("s") ? parent.slice(0, -1) : parent;
|
|
429
|
-
nestedRows.push(
|
|
430
|
-
endpointRow("GET", nestedPath, `List ${child} belonging to a ${parentSingular}.`)
|
|
431
|
-
);
|
|
432
|
-
}
|
|
433
|
-
}
|
|
434
|
-
const nestedAccordion = nestedRows.length ? `
|
|
435
|
-
<details class="resource-card nested-card">
|
|
436
|
-
<summary>
|
|
437
|
-
<span class="resource-name">Nested routes</span>
|
|
438
|
-
<span class="route-count">${nestedRows.length} route${nestedRows.length !== 1 ? "s" : ""}</span>
|
|
439
|
-
</summary>
|
|
440
|
-
<table><tbody>${nestedRows.join("")}</tbody></table>
|
|
441
|
-
</details>` : "";
|
|
442
|
-
const snapshotAccordion = options.snapshot ? `
|
|
443
|
-
<details class="resource-card nested-card">
|
|
444
|
-
<summary>
|
|
445
|
-
<span class="resource-name">/_snapshot</span>
|
|
446
|
-
<span class="route-count">3 routes</span>
|
|
447
|
-
</summary>
|
|
448
|
-
<table><tbody>
|
|
449
|
-
${endpointRow("GET", "/_snapshot", "Returns metadata of the current snapshot: <code>savedAt</code> and item counts per collection.")}
|
|
450
|
-
${endpointRow("POST", "/_snapshot/save", "Replaces the stored snapshot with the current database state.")}
|
|
451
|
-
${endpointRow("POST", "/_snapshot/reset", "Restores the database to the last saved snapshot and persists to disk.")}
|
|
452
|
-
</tbody></table>
|
|
453
|
-
</details>` : "";
|
|
454
|
-
const customRoutes = storage.getRoutes();
|
|
455
|
-
const customRoutesAccordion = customRoutes.length ? `
|
|
456
|
-
<details class="resource-card nested-card">
|
|
457
|
-
<summary>
|
|
458
|
-
<span class="resource-name">Custom routes</span>
|
|
459
|
-
<span class="route-count">${customRoutes.length} route${customRoutes.length !== 1 ? "s" : ""}</span>
|
|
460
|
-
</summary>
|
|
461
|
-
<table><tbody>
|
|
462
|
-
${customRoutes.map((r) => {
|
|
463
|
-
const fullPath = `${base}${r.path}`;
|
|
464
|
-
const tags = [];
|
|
465
|
-
if (r.error) {
|
|
466
|
-
tags.push(`<span style="color:#f85149;font-size:11px">error\xB7${r.error}</span>`);
|
|
467
|
-
}
|
|
468
|
-
if (r.delay && r.delay > 0) {
|
|
469
|
-
tags.push(`<span style="color:#fb923c;font-size:11px">delay\xB7${r.delay}ms</span>`);
|
|
470
|
-
}
|
|
471
|
-
if (r.scenarios?.length) {
|
|
472
|
-
const hasOr = r.scenarios.some((s) => Array.isArray(s.when));
|
|
473
|
-
tags.push(
|
|
474
|
-
`<span style="color:#a371f7;font-size:11px">scenarios\xB7${r.scenarios.length}${hasOr ? " (OR)" : ""}</span>`
|
|
475
|
-
);
|
|
476
|
-
}
|
|
477
|
-
if (r.otherwise) {
|
|
478
|
-
tags.push(`<span style="color:var(--text-muted);font-size:11px">otherwise</span>`);
|
|
479
|
-
}
|
|
480
|
-
let desc;
|
|
481
|
-
if (r.error) {
|
|
482
|
-
desc = `Error injection \u2014 <code>${r.error}</code>`;
|
|
483
|
-
} else if (r.handler) {
|
|
484
|
-
const found = handlers.has(r.handler);
|
|
485
|
-
const handlerName = escapeHtml(r.handler);
|
|
486
|
-
desc = found ? `Handler \u2014 <code>${handlerName}()</code>` : `Handler \u2014 <code>${handlerName}()</code> <span style="color:#f85149">(not loaded)</span>`;
|
|
487
|
-
} else if (r.scenarios?.length) {
|
|
488
|
-
const hasTemplateInScenarios = r.scenarios.some((s) => s.response.body != null && hasTemplates(s.response.body)) || r.otherwise?.body != null && hasTemplates(r.otherwise.body);
|
|
489
|
-
desc = hasTemplateInScenarios ? `Scenarios \u2014 <code>{{\u2026}}</code>` : `Scenarios`;
|
|
490
|
-
} else if (r.response?.body != null && hasTemplates(r.response.body)) {
|
|
491
|
-
desc = `Dynamic \u2014 <code>{{\u2026}}</code>`;
|
|
492
|
-
} else {
|
|
493
|
-
const status = r.response?.status ?? 200;
|
|
494
|
-
desc = `Static \u2014 <code>${status}</code>${r.response?.headers ? ` + headers` : ""}`;
|
|
495
|
-
}
|
|
496
|
-
if (tags.length) desc += ` ${tags.join(" ")}`;
|
|
497
|
-
return endpointRow(r.method?.toUpperCase() ?? "GET", fullPath, desc);
|
|
498
|
-
}).join("")}
|
|
499
|
-
</tbody></table>
|
|
500
|
-
</details>` : "";
|
|
501
|
-
const routesByHandler = /* @__PURE__ */ new Map();
|
|
502
|
-
for (const r of customRoutes) {
|
|
503
|
-
if (r.handler) {
|
|
504
|
-
const list = routesByHandler.get(r.handler) ?? [];
|
|
505
|
-
list.push({ method: (r.method ?? "GET").toUpperCase(), path: `${base}${r.path}` });
|
|
506
|
-
routesByHandler.set(r.handler, list);
|
|
507
|
-
}
|
|
508
|
-
}
|
|
509
|
-
const handlersAccordion = handlers.size > 0 ? `
|
|
510
|
-
<details class="resource-card nested-card">
|
|
511
|
-
<summary>
|
|
512
|
-
<span class="resource-name">Handlers</span>
|
|
513
|
-
<span class="route-count">${handlers.size} function${handlers.size !== 1 ? "s" : ""}</span>
|
|
514
|
-
</summary>
|
|
515
|
-
<table><tbody>
|
|
516
|
-
${[...handlers.keys()].map((name) => {
|
|
517
|
-
const routes = routesByHandler.get(name);
|
|
518
|
-
const routeDesc = routes ? routes.map((r) => `<code>${r.method} ${r.path}</code>`).join(", ") : `<span style="color:var(--text-muted)">not referenced in _routes</span>`;
|
|
519
|
-
return endpointRow("fn", name + "()", routeDesc);
|
|
520
|
-
}).join("")}
|
|
521
|
-
</tbody></table>
|
|
522
|
-
</details>` : "";
|
|
523
1013
|
const paginationDesc = options.pageable.enabled ? `Pageable mode active \u2014 default limit <code>${options.pageable.limit}</code>. Response wrapped in <code>{ data, pagination }</code>.` : `Returns the requested slice. <code>X-Total-Count</code> header reflects the total before pagination.`;
|
|
524
1014
|
return `<!DOCTYPE html>
|
|
525
1015
|
<html lang="en">
|
|
@@ -667,10 +1157,10 @@ function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map(
|
|
|
667
1157
|
<h2>Endpoints</h2>
|
|
668
1158
|
<div class="endpoints-grid">
|
|
669
1159
|
${accordions}
|
|
670
|
-
${
|
|
671
|
-
${snapshotAccordion}
|
|
672
|
-
${customRoutesAccordion}
|
|
673
|
-
${handlersAccordion}
|
|
1160
|
+
${nestedRoutesAccordion(relations, base)}
|
|
1161
|
+
${options.snapshot ? snapshotAccordion() : ""}
|
|
1162
|
+
${customRoutesAccordion(customRoutes, base, handlers)}
|
|
1163
|
+
${handlersAccordion(handlers, customRoutes, base)}
|
|
674
1164
|
</div>
|
|
675
1165
|
|
|
676
1166
|
<h2>Query Parameters</h2>
|
|
@@ -870,10 +1360,11 @@ function expandItems(input, query, resource, storage) {
|
|
|
870
1360
|
const resourceRelations = storage.getRelations()[resource] ?? {};
|
|
871
1361
|
const expansions = /* @__PURE__ */ new Map();
|
|
872
1362
|
for (const expandKey of keys) {
|
|
873
|
-
for (const [field,
|
|
1363
|
+
for (const [field, def] of Object.entries(resourceRelations)) {
|
|
1364
|
+
if (def.type === "many2many") continue;
|
|
874
1365
|
const derivedKey = field.replace(/Id$/i, "");
|
|
875
|
-
if (derivedKey === expandKey ||
|
|
876
|
-
expansions.set(expandKey, { field, parentCollection });
|
|
1366
|
+
if (derivedKey === expandKey || def.target === expandKey || def.target === `${expandKey}s`) {
|
|
1367
|
+
expansions.set(expandKey, { field, parentCollection: def.target });
|
|
877
1368
|
break;
|
|
878
1369
|
}
|
|
879
1370
|
}
|
|
@@ -902,10 +1393,24 @@ function embedItems(input, query, resource, storage) {
|
|
|
902
1393
|
const relations = storage.getRelations();
|
|
903
1394
|
const embeds = /* @__PURE__ */ new Map();
|
|
904
1395
|
for (const embedKey of keys) {
|
|
1396
|
+
const ownRelations = relations[resource] ?? {};
|
|
1397
|
+
if (embedKey in ownRelations) {
|
|
1398
|
+
const def = ownRelations[embedKey];
|
|
1399
|
+
if (def.type === "many2many") {
|
|
1400
|
+
embeds.set(embedKey, {
|
|
1401
|
+
kind: "many2many",
|
|
1402
|
+
target: def.target,
|
|
1403
|
+
through: def.through,
|
|
1404
|
+
foreignKey: def.foreignKey,
|
|
1405
|
+
otherKey: def.otherKey
|
|
1406
|
+
});
|
|
1407
|
+
continue;
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
905
1410
|
outer: for (const [childCollection, fields] of Object.entries(relations)) {
|
|
906
|
-
for (const [fkField,
|
|
907
|
-
if (
|
|
908
|
-
embeds.set(embedKey, { childCollection, fkField });
|
|
1411
|
+
for (const [fkField, def] of Object.entries(fields)) {
|
|
1412
|
+
if ((def.type === "many2one" || def.type === "one2one") && def.target === resource && childCollection === embedKey) {
|
|
1413
|
+
embeds.set(embedKey, { kind: def.type, childCollection, fkField });
|
|
909
1414
|
break outer;
|
|
910
1415
|
}
|
|
911
1416
|
}
|
|
@@ -914,10 +1419,57 @@ function embedItems(input, query, resource, storage) {
|
|
|
914
1419
|
if (embeds.size === 0) return isArray ? items : input;
|
|
915
1420
|
const result = items.map((item) => {
|
|
916
1421
|
const out = { ...item };
|
|
917
|
-
for (const [embedKey,
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
1422
|
+
for (const [embedKey, spec] of embeds) {
|
|
1423
|
+
if (spec.kind === "many2many") {
|
|
1424
|
+
const pivot = storage.getCollection(spec.through) ?? [];
|
|
1425
|
+
const matchingIds = new Set(
|
|
1426
|
+
pivot.filter((row) => String(row[spec.foreignKey]) === String(item["id"])).map((row) => String(row[spec.otherKey]))
|
|
1427
|
+
);
|
|
1428
|
+
out[embedKey] = (storage.getCollection(spec.target) ?? []).filter(
|
|
1429
|
+
(t) => matchingIds.has(String(t["id"]))
|
|
1430
|
+
);
|
|
1431
|
+
} else if (spec.kind === "one2one") {
|
|
1432
|
+
out[embedKey] = (storage.getCollection(spec.childCollection) ?? []).find(
|
|
1433
|
+
(child) => String(child[spec.fkField]) === String(item["id"])
|
|
1434
|
+
) ?? null;
|
|
1435
|
+
} else {
|
|
1436
|
+
out[embedKey] = (storage.getCollection(spec.childCollection) ?? []).filter(
|
|
1437
|
+
(child) => String(child[spec.fkField]) === String(item["id"])
|
|
1438
|
+
);
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
return out;
|
|
1442
|
+
});
|
|
1443
|
+
return isArray ? result : result[0];
|
|
1444
|
+
}
|
|
1445
|
+
function applyNested(input, resource, storage) {
|
|
1446
|
+
const isArray = Array.isArray(input);
|
|
1447
|
+
const items = isArray ? input : [input];
|
|
1448
|
+
const resourceRelations = storage.getRelations()[resource] ?? {};
|
|
1449
|
+
const nestedDefs = Object.entries(resourceRelations).filter(([, def]) => def.nested === true);
|
|
1450
|
+
if (nestedDefs.length === 0) return input;
|
|
1451
|
+
const result = items.map((item) => {
|
|
1452
|
+
const out = { ...item };
|
|
1453
|
+
for (const [key, def] of nestedDefs) {
|
|
1454
|
+
if (def.type === "many2many") {
|
|
1455
|
+
const pivot = storage.getCollection(def.through) ?? [];
|
|
1456
|
+
const matchingIds = new Set(
|
|
1457
|
+
pivot.filter((row) => String(row[def.foreignKey]) === String(item["id"])).map((row) => String(row[def.otherKey]))
|
|
1458
|
+
);
|
|
1459
|
+
out[key] = (storage.getCollection(def.target) ?? []).filter(
|
|
1460
|
+
(t) => matchingIds.has(String(t["id"]))
|
|
1461
|
+
);
|
|
1462
|
+
} else {
|
|
1463
|
+
const foreignKeyValue = item[key];
|
|
1464
|
+
if (foreignKeyValue === void 0) continue;
|
|
1465
|
+
const parent = (storage.getCollection(def.target) ?? []).find(
|
|
1466
|
+
(p) => String(p["id"]) === String(foreignKeyValue)
|
|
1467
|
+
);
|
|
1468
|
+
if (parent !== void 0) {
|
|
1469
|
+
const embedKey = key.replace(/Id$/i, "");
|
|
1470
|
+
out[embedKey] = parent;
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
921
1473
|
}
|
|
922
1474
|
return out;
|
|
923
1475
|
});
|
|
@@ -957,7 +1509,12 @@ var CollectionRouteCommand = class {
|
|
|
957
1509
|
const totalPages = Math.ceil(totalItems / limit) || 1;
|
|
958
1510
|
const data = projectFields(
|
|
959
1511
|
embedItems(
|
|
960
|
-
expandItems(
|
|
1512
|
+
expandItems(
|
|
1513
|
+
applyNested(paginate(sorted, page, limit), this.resource, this.storage),
|
|
1514
|
+
req.query,
|
|
1515
|
+
this.resource,
|
|
1516
|
+
this.storage
|
|
1517
|
+
),
|
|
961
1518
|
req.query,
|
|
962
1519
|
this.resource,
|
|
963
1520
|
this.storage
|
|
@@ -989,7 +1546,12 @@ var CollectionRouteCommand = class {
|
|
|
989
1546
|
}
|
|
990
1547
|
return projectFields(
|
|
991
1548
|
embedItems(
|
|
992
|
-
expandItems(
|
|
1549
|
+
expandItems(
|
|
1550
|
+
applyNested(result, this.resource, this.storage),
|
|
1551
|
+
req.query,
|
|
1552
|
+
this.resource,
|
|
1553
|
+
this.storage
|
|
1554
|
+
),
|
|
993
1555
|
req.query,
|
|
994
1556
|
this.resource,
|
|
995
1557
|
this.storage
|
|
@@ -1170,7 +1732,12 @@ var ItemRouteCommand = class {
|
|
|
1170
1732
|
const fields = (req.query["_fields"] ?? "").split(",").map((f) => f.trim()).filter(Boolean);
|
|
1171
1733
|
return projectFields(
|
|
1172
1734
|
embedItems(
|
|
1173
|
-
expandItems(
|
|
1735
|
+
expandItems(
|
|
1736
|
+
applyNested(item, this.resource, this.storage),
|
|
1737
|
+
req.query,
|
|
1738
|
+
this.resource,
|
|
1739
|
+
this.storage
|
|
1740
|
+
),
|
|
1174
1741
|
req.query,
|
|
1175
1742
|
this.resource,
|
|
1176
1743
|
this.storage
|
|
@@ -1213,32 +1780,68 @@ var NestedRouteCommand = class {
|
|
|
1213
1780
|
relations;
|
|
1214
1781
|
base;
|
|
1215
1782
|
register(server) {
|
|
1216
|
-
for (const [
|
|
1217
|
-
for (const [
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
if (!parentItem) return reply.status(404).send({ error: "Not found" });
|
|
1224
|
-
const children = (this.storage.getCollection(child) ?? []).filter(
|
|
1225
|
-
(item) => String(item[field]) === req.params.id
|
|
1226
|
-
);
|
|
1227
|
-
return children;
|
|
1228
|
-
});
|
|
1229
|
-
server.get(itemPath, (req, reply) => {
|
|
1230
|
-
const parentCollection = this.storage.getCollection(parent) ?? [];
|
|
1231
|
-
const parentItem = findById(parentCollection, req.params.id);
|
|
1232
|
-
if (!parentItem) return reply.status(404).send({ error: "Not found" });
|
|
1233
|
-
const childItem = (this.storage.getCollection(child) ?? []).find(
|
|
1234
|
-
(item) => String(item[field]) === req.params.id && String(item["id"]) === req.params.childId
|
|
1235
|
-
);
|
|
1236
|
-
if (!childItem) return reply.status(404).send({ error: "Not found" });
|
|
1237
|
-
return childItem;
|
|
1238
|
-
});
|
|
1783
|
+
for (const [source, fields] of Object.entries(this.relations)) {
|
|
1784
|
+
for (const [key, def] of Object.entries(fields)) {
|
|
1785
|
+
if (def.type === "many2many") {
|
|
1786
|
+
this.registerMany2Many(server, source, key, def);
|
|
1787
|
+
} else {
|
|
1788
|
+
this.registerFkRelation(server, source, key, def.target, def.type);
|
|
1789
|
+
}
|
|
1239
1790
|
}
|
|
1240
1791
|
}
|
|
1241
1792
|
}
|
|
1793
|
+
registerFkRelation(server, child, fkField, parent, type) {
|
|
1794
|
+
const collectionPath = `${this.base}/${parent}/:id/${child}`;
|
|
1795
|
+
const itemPath = `${this.base}/${parent}/:id/${child}/:childId`;
|
|
1796
|
+
server.get(collectionPath, (req, reply) => {
|
|
1797
|
+
const parentCollection = this.storage.getCollection(parent) ?? [];
|
|
1798
|
+
const parentItem = findById(parentCollection, req.params.id);
|
|
1799
|
+
if (!parentItem) return reply.status(404).send({ error: "Not found" });
|
|
1800
|
+
const all = (this.storage.getCollection(child) ?? []).filter(
|
|
1801
|
+
(item) => String(item[fkField]) === req.params.id
|
|
1802
|
+
);
|
|
1803
|
+
if (type === "one2one") return all[0] ?? reply.status(404).send({ error: "Not found" });
|
|
1804
|
+
return all;
|
|
1805
|
+
});
|
|
1806
|
+
if (type === "many2one") {
|
|
1807
|
+
server.get(itemPath, (req, reply) => {
|
|
1808
|
+
const parentCollection = this.storage.getCollection(parent) ?? [];
|
|
1809
|
+
const parentItem = findById(parentCollection, req.params.id);
|
|
1810
|
+
if (!parentItem) return reply.status(404).send({ error: "Not found" });
|
|
1811
|
+
const childItem = (this.storage.getCollection(child) ?? []).find(
|
|
1812
|
+
(item) => String(item[fkField]) === req.params.id && String(item["id"]) === req.params.childId
|
|
1813
|
+
);
|
|
1814
|
+
if (!childItem) return reply.status(404).send({ error: "Not found" });
|
|
1815
|
+
return childItem;
|
|
1816
|
+
});
|
|
1817
|
+
}
|
|
1818
|
+
}
|
|
1819
|
+
registerMany2Many(server, source, alias, def) {
|
|
1820
|
+
server.get(`${this.base}/${source}/:id/${alias}`, (req, reply) => {
|
|
1821
|
+
const sourceCollection = this.storage.getCollection(source) ?? [];
|
|
1822
|
+
const sourceItem = findById(sourceCollection, req.params.id);
|
|
1823
|
+
if (!sourceItem) return reply.status(404).send({ error: "Not found" });
|
|
1824
|
+
const pivot = this.storage.getCollection(def.through) ?? [];
|
|
1825
|
+
const matchingIds = new Set(
|
|
1826
|
+
pivot.filter((row) => String(row[def.foreignKey]) === req.params.id).map((row) => String(row[def.otherKey]))
|
|
1827
|
+
);
|
|
1828
|
+
return (this.storage.getCollection(def.target) ?? []).filter(
|
|
1829
|
+
(t) => matchingIds.has(String(t["id"]))
|
|
1830
|
+
);
|
|
1831
|
+
});
|
|
1832
|
+
server.get(`${this.base}/${def.target}/:id/${source}`, (req, reply) => {
|
|
1833
|
+
const targetCollection = this.storage.getCollection(def.target) ?? [];
|
|
1834
|
+
const targetItem = findById(targetCollection, req.params.id);
|
|
1835
|
+
if (!targetItem) return reply.status(404).send({ error: "Not found" });
|
|
1836
|
+
const pivot = this.storage.getCollection(def.through) ?? [];
|
|
1837
|
+
const matchingIds = new Set(
|
|
1838
|
+
pivot.filter((row) => String(row[def.otherKey]) === req.params.id).map((row) => String(row[def.foreignKey]))
|
|
1839
|
+
);
|
|
1840
|
+
return (this.storage.getCollection(source) ?? []).filter(
|
|
1841
|
+
(t) => matchingIds.has(String(t["id"]))
|
|
1842
|
+
);
|
|
1843
|
+
});
|
|
1844
|
+
}
|
|
1242
1845
|
};
|
|
1243
1846
|
|
|
1244
1847
|
// src/router/routes/snapshot.routes.ts
|