@yrest/cli 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +96 -20
- package/dist/cli/index.js +808 -193
- package/dist/cli/index.mjs +808 -193
- package/dist/index.d.mts +47 -5
- package/dist/index.d.ts +47 -5
- package/dist/index.js +390 -172
- package/dist/index.mjs +390 -172
- package/package.json +4 -3
package/dist/cli/index.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",
|
|
@@ -265,8 +708,11 @@ var METHOD_COLOR = {
|
|
|
265
708
|
DELETE: "#f85149",
|
|
266
709
|
fn: "#f0883e"
|
|
267
710
|
};
|
|
711
|
+
function escapeHtml(str) {
|
|
712
|
+
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
713
|
+
}
|
|
268
714
|
function badge(label, color, bg) {
|
|
269
|
-
return `<span class="badge" style="background:${bg};color:${color};border:1px solid ${color}40">${label}</span>`;
|
|
715
|
+
return `<span class="badge" style="background:${bg};color:${color};border:1px solid ${color}40">${escapeHtml(label)}</span>`;
|
|
270
716
|
}
|
|
271
717
|
function methodBadge(method) {
|
|
272
718
|
const color = METHOD_COLOR[method] ?? "#7d8590";
|
|
@@ -276,7 +722,7 @@ function endpointRow(method, path, desc) {
|
|
|
276
722
|
return `
|
|
277
723
|
<tr>
|
|
278
724
|
<td class="method-cell">${methodBadge(method)}</td>
|
|
279
|
-
<td class="path-cell"><code>${path}</code></td>
|
|
725
|
+
<td class="path-cell"><code>${escapeHtml(path)}</code></td>
|
|
280
726
|
<td class="desc-cell">${desc}</td>
|
|
281
727
|
</tr>`;
|
|
282
728
|
}
|
|
@@ -310,7 +756,7 @@ function resourceAccordion(name, base, isOpen) {
|
|
|
310
756
|
return `
|
|
311
757
|
<details class="resource-card" ${isOpen ? "open" : ""}>
|
|
312
758
|
<summary>
|
|
313
|
-
<span class="resource-name">/${name}</span>
|
|
759
|
+
<span class="resource-name">/${escapeHtml(name)}</span>
|
|
314
760
|
<span class="route-count">6 routes</span>
|
|
315
761
|
</summary>
|
|
316
762
|
<table>
|
|
@@ -318,6 +764,139 @@ function resourceAccordion(name, base, isOpen) {
|
|
|
318
764
|
</table>
|
|
319
765
|
</details>`;
|
|
320
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
|
+
}
|
|
321
900
|
function examplesBlock(collections, relations, base, host, options, firstCustomRoute) {
|
|
322
901
|
const examples = [];
|
|
323
902
|
const firstCol = collections[0];
|
|
@@ -348,35 +927,36 @@ curl -X DELETE ${p}/1`
|
|
|
348
927
|
const firstRel = Object.entries(relations)[0];
|
|
349
928
|
if (firstRel) {
|
|
350
929
|
const [child, fields] = firstRel;
|
|
351
|
-
const
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
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
|
+
}
|
|
365
945
|
}
|
|
366
946
|
}
|
|
367
947
|
if (options.pageable.enabled && firstCol) {
|
|
368
948
|
examples.push(`# Pageable envelope
|
|
369
949
|
curl "${host}${base}/${firstCol}?_page=2"`);
|
|
370
950
|
}
|
|
371
|
-
const firstParentRel = Object.entries(relations).find(
|
|
372
|
-
([, fields]) => Object.values(fields).includes(firstCol ?? "")
|
|
373
|
-
);
|
|
374
951
|
if (firstCol) {
|
|
375
952
|
examples.push(
|
|
376
953
|
`# Project fields with ?_fields
|
|
377
954
|
curl "${host}${base}/${firstCol}?_fields=id,name"`
|
|
378
955
|
);
|
|
379
956
|
}
|
|
957
|
+
const firstParentRel = Object.entries(relations).find(
|
|
958
|
+
([, fields]) => Object.values(fields).some((def) => def.type !== "many2many" && def.target === firstCol)
|
|
959
|
+
);
|
|
380
960
|
if (firstParentRel && firstCol) {
|
|
381
961
|
const [childName] = firstParentRel;
|
|
382
962
|
examples.push(
|
|
@@ -394,7 +974,7 @@ curl -X POST ${host}/_snapshot/reset`
|
|
|
394
974
|
}
|
|
395
975
|
if (firstCustomRoute) {
|
|
396
976
|
const method = firstCustomRoute.method?.toUpperCase() ?? "GET";
|
|
397
|
-
const fullPath = `${host}${base}${firstCustomRoute.path}`;
|
|
977
|
+
const fullPath = `${host}${base}${escapeHtml(firstCustomRoute.path ?? "")}`;
|
|
398
978
|
const curlFlag = method === "GET" ? "" : `-X ${method} `;
|
|
399
979
|
examples.push(`# Custom route
|
|
400
980
|
curl ${curlFlag}${fullPath}`);
|
|
@@ -402,9 +982,21 @@ curl ${curlFlag}${fullPath}`);
|
|
|
402
982
|
const highlighted = examples.map((e) => e.replace(/^(#.+)$/gm, '<span class="cm">$1</span>')).join("\n\n");
|
|
403
983
|
return `<pre>${highlighted}</pre>`;
|
|
404
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
|
+
})();
|
|
405
996
|
function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map()) {
|
|
406
997
|
const collections = Object.keys(storage.getData());
|
|
407
998
|
const relations = storage.getRelations();
|
|
999
|
+
const customRoutes = storage.getRoutes();
|
|
408
1000
|
const base = options.base;
|
|
409
1001
|
const host = `http://${options.host}:${options.port}`;
|
|
410
1002
|
const modes = [];
|
|
@@ -418,104 +1010,6 @@ function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map(
|
|
|
418
1010
|
if (options.idStrategy !== "increment")
|
|
419
1011
|
modes.push(badge(`id \xB7 ${options.idStrategy}`, "#a371f7", "#a371f718"));
|
|
420
1012
|
const accordions = collections.map((col, i) => resourceAccordion(col, base, i === 0)).join("");
|
|
421
|
-
const nestedRows = [];
|
|
422
|
-
for (const [child, fields] of Object.entries(relations)) {
|
|
423
|
-
for (const [, parent] of Object.entries(fields)) {
|
|
424
|
-
const nestedPath = `${base}/${parent}/:id/${child}`;
|
|
425
|
-
const parentSingular = parent.endsWith("s") ? parent.slice(0, -1) : parent;
|
|
426
|
-
nestedRows.push(
|
|
427
|
-
endpointRow("GET", nestedPath, `List ${child} belonging to a ${parentSingular}.`)
|
|
428
|
-
);
|
|
429
|
-
}
|
|
430
|
-
}
|
|
431
|
-
const nestedAccordion = nestedRows.length ? `
|
|
432
|
-
<details class="resource-card nested-card">
|
|
433
|
-
<summary>
|
|
434
|
-
<span class="resource-name">Nested routes</span>
|
|
435
|
-
<span class="route-count">${nestedRows.length} route${nestedRows.length !== 1 ? "s" : ""}</span>
|
|
436
|
-
</summary>
|
|
437
|
-
<table><tbody>${nestedRows.join("")}</tbody></table>
|
|
438
|
-
</details>` : "";
|
|
439
|
-
const snapshotAccordion = options.snapshot ? `
|
|
440
|
-
<details class="resource-card nested-card">
|
|
441
|
-
<summary>
|
|
442
|
-
<span class="resource-name">/_snapshot</span>
|
|
443
|
-
<span class="route-count">3 routes</span>
|
|
444
|
-
</summary>
|
|
445
|
-
<table><tbody>
|
|
446
|
-
${endpointRow("GET", "/_snapshot", "Returns metadata of the current snapshot: <code>savedAt</code> and item counts per collection.")}
|
|
447
|
-
${endpointRow("POST", "/_snapshot/save", "Replaces the stored snapshot with the current database state.")}
|
|
448
|
-
${endpointRow("POST", "/_snapshot/reset", "Restores the database to the last saved snapshot and persists to disk.")}
|
|
449
|
-
</tbody></table>
|
|
450
|
-
</details>` : "";
|
|
451
|
-
const customRoutes = storage.getRoutes();
|
|
452
|
-
const customRoutesAccordion = customRoutes.length ? `
|
|
453
|
-
<details class="resource-card nested-card">
|
|
454
|
-
<summary>
|
|
455
|
-
<span class="resource-name">Custom routes</span>
|
|
456
|
-
<span class="route-count">${customRoutes.length} route${customRoutes.length !== 1 ? "s" : ""}</span>
|
|
457
|
-
</summary>
|
|
458
|
-
<table><tbody>
|
|
459
|
-
${customRoutes.map((r) => {
|
|
460
|
-
const fullPath = `${base}${r.path}`;
|
|
461
|
-
const tags = [];
|
|
462
|
-
if (r.error) {
|
|
463
|
-
tags.push(`<span style="color:#f85149;font-size:11px">error\xB7${r.error}</span>`);
|
|
464
|
-
}
|
|
465
|
-
if (r.delay && r.delay > 0) {
|
|
466
|
-
tags.push(`<span style="color:#fb923c;font-size:11px">delay\xB7${r.delay}ms</span>`);
|
|
467
|
-
}
|
|
468
|
-
if (r.scenarios?.length) {
|
|
469
|
-
const hasOr = r.scenarios.some((s) => Array.isArray(s.when));
|
|
470
|
-
tags.push(
|
|
471
|
-
`<span style="color:#a371f7;font-size:11px">scenarios\xB7${r.scenarios.length}${hasOr ? " (OR)" : ""}</span>`
|
|
472
|
-
);
|
|
473
|
-
}
|
|
474
|
-
if (r.otherwise) {
|
|
475
|
-
tags.push(`<span style="color:var(--text-muted);font-size:11px">otherwise</span>`);
|
|
476
|
-
}
|
|
477
|
-
let desc;
|
|
478
|
-
if (r.error) {
|
|
479
|
-
desc = `Error injection \u2014 <code>${r.error}</code>`;
|
|
480
|
-
} else if (r.handler) {
|
|
481
|
-
const found = handlers.has(r.handler);
|
|
482
|
-
desc = found ? `Handler \u2014 <code>${r.handler}()</code>` : `Handler \u2014 <code>${r.handler}()</code> <span style="color:#f85149">(not loaded)</span>`;
|
|
483
|
-
} else if (r.scenarios?.length) {
|
|
484
|
-
const hasTemplateInScenarios = r.scenarios.some((s) => s.response.body != null && hasTemplates(s.response.body)) || r.otherwise?.body != null && hasTemplates(r.otherwise.body);
|
|
485
|
-
desc = hasTemplateInScenarios ? `Scenarios \u2014 <code>{{\u2026}}</code>` : `Scenarios`;
|
|
486
|
-
} else if (r.response?.body != null && hasTemplates(r.response.body)) {
|
|
487
|
-
desc = `Dynamic \u2014 <code>{{\u2026}}</code>`;
|
|
488
|
-
} else {
|
|
489
|
-
const status = r.response?.status ?? 200;
|
|
490
|
-
desc = `Static \u2014 <code>${status}</code>${r.response?.headers ? ` + headers` : ""}`;
|
|
491
|
-
}
|
|
492
|
-
if (tags.length) desc += ` ${tags.join(" ")}`;
|
|
493
|
-
return endpointRow(r.method?.toUpperCase() ?? "GET", fullPath, desc);
|
|
494
|
-
}).join("")}
|
|
495
|
-
</tbody></table>
|
|
496
|
-
</details>` : "";
|
|
497
|
-
const routesByHandler = /* @__PURE__ */ new Map();
|
|
498
|
-
for (const r of customRoutes) {
|
|
499
|
-
if (r.handler) {
|
|
500
|
-
const list = routesByHandler.get(r.handler) ?? [];
|
|
501
|
-
list.push({ method: (r.method ?? "GET").toUpperCase(), path: `${base}${r.path}` });
|
|
502
|
-
routesByHandler.set(r.handler, list);
|
|
503
|
-
}
|
|
504
|
-
}
|
|
505
|
-
const handlersAccordion = handlers.size > 0 ? `
|
|
506
|
-
<details class="resource-card nested-card">
|
|
507
|
-
<summary>
|
|
508
|
-
<span class="resource-name">Handlers</span>
|
|
509
|
-
<span class="route-count">${handlers.size} function${handlers.size !== 1 ? "s" : ""}</span>
|
|
510
|
-
</summary>
|
|
511
|
-
<table><tbody>
|
|
512
|
-
${[...handlers.keys()].map((name) => {
|
|
513
|
-
const routes = routesByHandler.get(name);
|
|
514
|
-
const routeDesc = routes ? routes.map((r) => `<code>${r.method} ${r.path}</code>`).join(", ") : `<span style="color:var(--text-muted)">not referenced in _routes</span>`;
|
|
515
|
-
return endpointRow("fn", name + "()", routeDesc);
|
|
516
|
-
}).join("")}
|
|
517
|
-
</tbody></table>
|
|
518
|
-
</details>` : "";
|
|
519
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.`;
|
|
520
1014
|
return `<!DOCTYPE html>
|
|
521
1015
|
<html lang="en">
|
|
@@ -663,10 +1157,10 @@ function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map(
|
|
|
663
1157
|
<h2>Endpoints</h2>
|
|
664
1158
|
<div class="endpoints-grid">
|
|
665
1159
|
${accordions}
|
|
666
|
-
${
|
|
667
|
-
${snapshotAccordion}
|
|
668
|
-
${customRoutesAccordion}
|
|
669
|
-
${handlersAccordion}
|
|
1160
|
+
${nestedRoutesAccordion(relations, base)}
|
|
1161
|
+
${options.snapshot ? snapshotAccordion() : ""}
|
|
1162
|
+
${customRoutesAccordion(customRoutes, base, handlers)}
|
|
1163
|
+
${handlersAccordion(handlers, customRoutes, base)}
|
|
670
1164
|
</div>
|
|
671
1165
|
|
|
672
1166
|
<h2>Query Parameters</h2>
|
|
@@ -752,6 +1246,7 @@ function applyOperator(itemValue, op, filterValue) {
|
|
|
752
1246
|
case "_start":
|
|
753
1247
|
return strItem.toLowerCase().startsWith(filterValue.toLowerCase());
|
|
754
1248
|
case "_regex": {
|
|
1249
|
+
if (filterValue.length > 200) return false;
|
|
755
1250
|
try {
|
|
756
1251
|
return new RegExp(filterValue, "i").test(strItem);
|
|
757
1252
|
} catch {
|
|
@@ -865,10 +1360,11 @@ function expandItems(input, query, resource, storage) {
|
|
|
865
1360
|
const resourceRelations = storage.getRelations()[resource] ?? {};
|
|
866
1361
|
const expansions = /* @__PURE__ */ new Map();
|
|
867
1362
|
for (const expandKey of keys) {
|
|
868
|
-
for (const [field,
|
|
1363
|
+
for (const [field, def] of Object.entries(resourceRelations)) {
|
|
1364
|
+
if (def.type === "many2many") continue;
|
|
869
1365
|
const derivedKey = field.replace(/Id$/i, "");
|
|
870
|
-
if (derivedKey === expandKey ||
|
|
871
|
-
expansions.set(expandKey, { field, parentCollection });
|
|
1366
|
+
if (derivedKey === expandKey || def.target === expandKey || def.target === `${expandKey}s`) {
|
|
1367
|
+
expansions.set(expandKey, { field, parentCollection: def.target });
|
|
872
1368
|
break;
|
|
873
1369
|
}
|
|
874
1370
|
}
|
|
@@ -897,10 +1393,24 @@ function embedItems(input, query, resource, storage) {
|
|
|
897
1393
|
const relations = storage.getRelations();
|
|
898
1394
|
const embeds = /* @__PURE__ */ new Map();
|
|
899
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
|
+
}
|
|
900
1410
|
outer: for (const [childCollection, fields] of Object.entries(relations)) {
|
|
901
|
-
for (const [fkField,
|
|
902
|
-
if (
|
|
903
|
-
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 });
|
|
904
1414
|
break outer;
|
|
905
1415
|
}
|
|
906
1416
|
}
|
|
@@ -909,10 +1419,57 @@ function embedItems(input, query, resource, storage) {
|
|
|
909
1419
|
if (embeds.size === 0) return isArray ? items : input;
|
|
910
1420
|
const result = items.map((item) => {
|
|
911
1421
|
const out = { ...item };
|
|
912
|
-
for (const [embedKey,
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
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
|
+
}
|
|
916
1473
|
}
|
|
917
1474
|
return out;
|
|
918
1475
|
});
|
|
@@ -952,7 +1509,12 @@ var CollectionRouteCommand = class {
|
|
|
952
1509
|
const totalPages = Math.ceil(totalItems / limit) || 1;
|
|
953
1510
|
const data = projectFields(
|
|
954
1511
|
embedItems(
|
|
955
|
-
expandItems(
|
|
1512
|
+
expandItems(
|
|
1513
|
+
applyNested(paginate(sorted, page, limit), this.resource, this.storage),
|
|
1514
|
+
req.query,
|
|
1515
|
+
this.resource,
|
|
1516
|
+
this.storage
|
|
1517
|
+
),
|
|
956
1518
|
req.query,
|
|
957
1519
|
this.resource,
|
|
958
1520
|
this.storage
|
|
@@ -984,7 +1546,12 @@ var CollectionRouteCommand = class {
|
|
|
984
1546
|
}
|
|
985
1547
|
return projectFields(
|
|
986
1548
|
embedItems(
|
|
987
|
-
expandItems(
|
|
1549
|
+
expandItems(
|
|
1550
|
+
applyNested(result, this.resource, this.storage),
|
|
1551
|
+
req.query,
|
|
1552
|
+
this.resource,
|
|
1553
|
+
this.storage
|
|
1554
|
+
),
|
|
988
1555
|
req.query,
|
|
989
1556
|
this.resource,
|
|
990
1557
|
this.storage
|
|
@@ -1165,7 +1732,12 @@ var ItemRouteCommand = class {
|
|
|
1165
1732
|
const fields = (req.query["_fields"] ?? "").split(",").map((f) => f.trim()).filter(Boolean);
|
|
1166
1733
|
return projectFields(
|
|
1167
1734
|
embedItems(
|
|
1168
|
-
expandItems(
|
|
1735
|
+
expandItems(
|
|
1736
|
+
applyNested(item, this.resource, this.storage),
|
|
1737
|
+
req.query,
|
|
1738
|
+
this.resource,
|
|
1739
|
+
this.storage
|
|
1740
|
+
),
|
|
1169
1741
|
req.query,
|
|
1170
1742
|
this.resource,
|
|
1171
1743
|
this.storage
|
|
@@ -1208,32 +1780,68 @@ var NestedRouteCommand = class {
|
|
|
1208
1780
|
relations;
|
|
1209
1781
|
base;
|
|
1210
1782
|
register(server) {
|
|
1211
|
-
for (const [
|
|
1212
|
-
for (const [
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
if (!parentItem) return reply.status(404).send({ error: "Not found" });
|
|
1219
|
-
const children = (this.storage.getCollection(child) ?? []).filter(
|
|
1220
|
-
(item) => String(item[field]) === req.params.id
|
|
1221
|
-
);
|
|
1222
|
-
return children;
|
|
1223
|
-
});
|
|
1224
|
-
server.get(itemPath, (req, reply) => {
|
|
1225
|
-
const parentCollection = this.storage.getCollection(parent) ?? [];
|
|
1226
|
-
const parentItem = findById(parentCollection, req.params.id);
|
|
1227
|
-
if (!parentItem) return reply.status(404).send({ error: "Not found" });
|
|
1228
|
-
const childItem = (this.storage.getCollection(child) ?? []).find(
|
|
1229
|
-
(item) => String(item[field]) === req.params.id && String(item["id"]) === req.params.childId
|
|
1230
|
-
);
|
|
1231
|
-
if (!childItem) return reply.status(404).send({ error: "Not found" });
|
|
1232
|
-
return childItem;
|
|
1233
|
-
});
|
|
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
|
+
}
|
|
1234
1790
|
}
|
|
1235
1791
|
}
|
|
1236
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
|
+
}
|
|
1237
1845
|
};
|
|
1238
1846
|
|
|
1239
1847
|
// src/router/routes/snapshot.routes.ts
|
|
@@ -1412,8 +2020,15 @@ function loadConfigFile(configPath) {
|
|
|
1412
2020
|
|
|
1413
2021
|
// src/utils/handlers.ts
|
|
1414
2022
|
import { existsSync as existsSync3 } from "fs";
|
|
2023
|
+
var ALLOWED_EXTENSIONS = [".js", ".mjs", ".cjs"];
|
|
1415
2024
|
async function loadHandlers(filePath) {
|
|
1416
2025
|
if (!existsSync3(filePath)) return /* @__PURE__ */ new Map();
|
|
2026
|
+
if (!ALLOWED_EXTENSIONS.some((ext) => filePath.endsWith(ext))) {
|
|
2027
|
+
console.error(
|
|
2028
|
+
` \x1B[31m[handlers] ${filePath} \u2014 only .js, .mjs and .cjs files are allowed\x1B[0m`
|
|
2029
|
+
);
|
|
2030
|
+
return /* @__PURE__ */ new Map();
|
|
2031
|
+
}
|
|
1417
2032
|
try {
|
|
1418
2033
|
const mod = await import(filePath);
|
|
1419
2034
|
const map = /* @__PURE__ */ new Map();
|