@yrest/cli 0.8.1 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +195 -45
- package/dist/cli/index.js +1241 -209
- package/dist/cli/index.mjs +1238 -206
- package/dist/index.d.mts +91 -5
- package/dist/index.d.ts +91 -5
- package/dist/index.js +814 -188
- package/dist/index.mjs +811 -185
- package/package.json +1 -1
package/dist/cli/index.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
|
|
120
|
+
- id: 2
|
|
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"
|
|
40
150
|
- id: 2
|
|
41
|
-
name:
|
|
42
|
-
|
|
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,80 @@ 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
|
+
|
|
569
|
+
// src/storage/parseSchema.ts
|
|
570
|
+
function parseSchema(raw) {
|
|
571
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
|
|
572
|
+
const result = {};
|
|
573
|
+
for (const [collection, fields] of Object.entries(raw)) {
|
|
574
|
+
if (!fields || typeof fields !== "object" || Array.isArray(fields)) continue;
|
|
575
|
+
result[collection] = {};
|
|
576
|
+
for (const [field, value] of Object.entries(fields)) {
|
|
577
|
+
const def = normaliseFieldDef(value);
|
|
578
|
+
if (def) result[collection][field] = def;
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
return result;
|
|
582
|
+
}
|
|
583
|
+
function normaliseFieldDef(value) {
|
|
584
|
+
if (value === "required") return { required: true };
|
|
585
|
+
if (value === "optional") return { required: false };
|
|
586
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
587
|
+
const v = value;
|
|
588
|
+
const def = {};
|
|
589
|
+
if (v["required"] === true || v["required"] === false) def.required = v["required"];
|
|
590
|
+
if (typeof v["type"] === "string" && ["string", "integer", "number", "boolean", "object", "array"].includes(v["type"]))
|
|
591
|
+
def.type = v["type"];
|
|
592
|
+
if (typeof v["format"] === "string") def.format = v["format"];
|
|
593
|
+
if (Array.isArray(v["enum"])) def.enum = v["enum"];
|
|
594
|
+
if (typeof v["description"] === "string") def.description = v["description"];
|
|
595
|
+
if (v["default"] !== void 0) def.default = v["default"];
|
|
596
|
+
return def;
|
|
597
|
+
}
|
|
598
|
+
|
|
117
599
|
// src/utils/deepCopy.ts
|
|
118
600
|
function deepCopyData(source) {
|
|
119
601
|
return Object.fromEntries(
|
|
@@ -125,10 +607,12 @@ function deepCopyData(source) {
|
|
|
125
607
|
function createYrestStorage(filePath) {
|
|
126
608
|
const absPath = resolve2(filePath);
|
|
127
609
|
const raw = parse(readFileSync(absPath, "utf8")) ?? {};
|
|
128
|
-
const
|
|
610
|
+
const RESERVED = /* @__PURE__ */ new Set(["_rel", "_routes", "_schema"]);
|
|
611
|
+
const relations = parseRelations(raw["_rel"]);
|
|
129
612
|
const routes = Array.isArray(raw["_routes"]) ? raw["_routes"] : [];
|
|
613
|
+
const schema = parseSchema(raw["_schema"]);
|
|
130
614
|
const data = Object.fromEntries(
|
|
131
|
-
Object.entries(raw).filter(([key]) => key
|
|
615
|
+
Object.entries(raw).filter(([key]) => !RESERVED.has(key))
|
|
132
616
|
);
|
|
133
617
|
let snapshot = {
|
|
134
618
|
data: deepCopyData(data),
|
|
@@ -142,6 +626,9 @@ function createYrestStorage(filePath) {
|
|
|
142
626
|
getRelations() {
|
|
143
627
|
return relations;
|
|
144
628
|
},
|
|
629
|
+
getSchema() {
|
|
630
|
+
return schema;
|
|
631
|
+
},
|
|
145
632
|
getRoutes() {
|
|
146
633
|
return routes;
|
|
147
634
|
},
|
|
@@ -162,9 +649,9 @@ function createYrestStorage(filePath) {
|
|
|
162
649
|
},
|
|
163
650
|
reload() {
|
|
164
651
|
const fresh = parse(readFileSync(absPath, "utf8")) ?? {};
|
|
165
|
-
const freshRelations = fresh["_rel"]
|
|
652
|
+
const freshRelations = parseRelations(fresh["_rel"]);
|
|
166
653
|
const freshData = Object.fromEntries(
|
|
167
|
-
Object.entries(fresh).filter(([key]) => key
|
|
654
|
+
Object.entries(fresh).filter(([key]) => !RESERVED.has(key))
|
|
168
655
|
);
|
|
169
656
|
for (const key of Object.keys(data)) delete data[key];
|
|
170
657
|
Object.assign(data, freshData);
|
|
@@ -247,16 +734,7 @@ function hasTemplates(value) {
|
|
|
247
734
|
return typeof value === "string" ? value.includes("{{") : JSON.stringify(value).includes("{{");
|
|
248
735
|
}
|
|
249
736
|
|
|
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
|
-
})();
|
|
737
|
+
// src/router/templates/about.helpers.ts
|
|
260
738
|
var METHOD_COLOR = {
|
|
261
739
|
GET: "#3fb950",
|
|
262
740
|
POST: "#58a6ff",
|
|
@@ -285,7 +763,7 @@ function endpointRow(method, path, desc) {
|
|
|
285
763
|
}
|
|
286
764
|
function resourceAccordion(name, base, isOpen) {
|
|
287
765
|
const p = `${base}/${name}`;
|
|
288
|
-
const
|
|
766
|
+
const singular2 = name.endsWith("s") ? name.slice(0, -1) : name;
|
|
289
767
|
const rows = [
|
|
290
768
|
endpointRow(
|
|
291
769
|
"GET",
|
|
@@ -295,20 +773,20 @@ function resourceAccordion(name, base, isOpen) {
|
|
|
295
773
|
endpointRow(
|
|
296
774
|
"POST",
|
|
297
775
|
p,
|
|
298
|
-
`Create a new ${
|
|
776
|
+
`Create a new ${singular2}. Auto-assigns <code>id</code> if not provided.`
|
|
299
777
|
),
|
|
300
|
-
endpointRow("GET", `${p}/:id`, `Get a single ${
|
|
778
|
+
endpointRow("GET", `${p}/:id`, `Get a single ${singular2} by id.`),
|
|
301
779
|
endpointRow(
|
|
302
780
|
"PUT",
|
|
303
781
|
`${p}/:id`,
|
|
304
|
-
`Fully replace a ${
|
|
782
|
+
`Fully replace a ${singular2}. Original <code>id</code> is always preserved.`
|
|
305
783
|
),
|
|
306
784
|
endpointRow(
|
|
307
785
|
"PATCH",
|
|
308
786
|
`${p}/:id`,
|
|
309
|
-
`Partially update a ${
|
|
787
|
+
`Partially update a ${singular2} \u2014 only provided fields change.`
|
|
310
788
|
),
|
|
311
|
-
endpointRow("DELETE", `${p}/:id`, `Delete a ${
|
|
789
|
+
endpointRow("DELETE", `${p}/:id`, `Delete a ${singular2} and return it as confirmation.`)
|
|
312
790
|
].join("");
|
|
313
791
|
return `
|
|
314
792
|
<details class="resource-card" ${isOpen ? "open" : ""}>
|
|
@@ -321,12 +799,145 @@ function resourceAccordion(name, base, isOpen) {
|
|
|
321
799
|
</table>
|
|
322
800
|
</details>`;
|
|
323
801
|
}
|
|
802
|
+
function nestedRoutesAccordion(relations, base) {
|
|
803
|
+
const rows = [];
|
|
804
|
+
for (const [source, fields] of Object.entries(relations)) {
|
|
805
|
+
for (const [key, def] of Object.entries(fields)) {
|
|
806
|
+
const nestedBadge = def.nested ? ` ${badge("nested", "#facc15", "#facc1518")}` : "";
|
|
807
|
+
if (def.type === "many2many") {
|
|
808
|
+
const singular2 = source.endsWith("s") ? source.slice(0, -1) : source;
|
|
809
|
+
const m2mBadge = badge("many2many", "#818cf8", "#818cf818");
|
|
810
|
+
rows.push(
|
|
811
|
+
endpointRow(
|
|
812
|
+
"GET",
|
|
813
|
+
`${base}/${source}/:id/${key}`,
|
|
814
|
+
`List ${def.target} linked to a ${singular2} via ${def.through}. ${m2mBadge}${nestedBadge}`
|
|
815
|
+
)
|
|
816
|
+
);
|
|
817
|
+
const targetSingular = def.target.endsWith("s") ? def.target.slice(0, -1) : def.target;
|
|
818
|
+
rows.push(
|
|
819
|
+
endpointRow(
|
|
820
|
+
"GET",
|
|
821
|
+
`${base}/${def.target}/:id/${source}`,
|
|
822
|
+
`List ${source} linked to a ${targetSingular} via ${def.through} (inverse). ${m2mBadge}`
|
|
823
|
+
)
|
|
824
|
+
);
|
|
825
|
+
} else {
|
|
826
|
+
const path = `${base}/${def.target}/:id/${source}`;
|
|
827
|
+
const parentSingular = def.target.endsWith("s") ? def.target.slice(0, -1) : def.target;
|
|
828
|
+
const typeBadge = def.type === "one2one" ? ` ${badge("one2one", "#34d399", "#34d39918")}` : "";
|
|
829
|
+
rows.push(
|
|
830
|
+
endpointRow(
|
|
831
|
+
"GET",
|
|
832
|
+
path,
|
|
833
|
+
`${def.type === "one2one" ? "Get" : "List"} ${source} belonging to a ${parentSingular}.${typeBadge}${nestedBadge}`
|
|
834
|
+
)
|
|
835
|
+
);
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
if (!rows.length) return "";
|
|
840
|
+
return `
|
|
841
|
+
<details class="resource-card nested-card">
|
|
842
|
+
<summary>
|
|
843
|
+
<span class="resource-name">Nested routes</span>
|
|
844
|
+
<span class="route-count">${rows.length} route${rows.length !== 1 ? "s" : ""}</span>
|
|
845
|
+
</summary>
|
|
846
|
+
<table><tbody>${rows.join("")}</tbody></table>
|
|
847
|
+
</details>`;
|
|
848
|
+
}
|
|
849
|
+
function snapshotAccordion() {
|
|
850
|
+
return `
|
|
851
|
+
<details class="resource-card nested-card">
|
|
852
|
+
<summary>
|
|
853
|
+
<span class="resource-name">/_snapshot</span>
|
|
854
|
+
<span class="route-count">3 routes</span>
|
|
855
|
+
</summary>
|
|
856
|
+
<table><tbody>
|
|
857
|
+
${endpointRow("GET", "/_snapshot", "Returns metadata of the current snapshot: <code>savedAt</code> and item counts per collection.")}
|
|
858
|
+
${endpointRow("POST", "/_snapshot/save", "Replaces the stored snapshot with the current database state.")}
|
|
859
|
+
${endpointRow("POST", "/_snapshot/reset", "Restores the database to the last saved snapshot and persists to disk.")}
|
|
860
|
+
</tbody></table>
|
|
861
|
+
</details>`;
|
|
862
|
+
}
|
|
863
|
+
function customRoutesAccordion(routes, base, handlers) {
|
|
864
|
+
if (!routes.length) return "";
|
|
865
|
+
const rows = routes.map((r) => {
|
|
866
|
+
const fullPath = `${base}${r.path}`;
|
|
867
|
+
const tags = [];
|
|
868
|
+
if (r.error) tags.push(`<span style="color:#f85149;font-size:11px">error\xB7${r.error}</span>`);
|
|
869
|
+
if (r.delay && r.delay > 0)
|
|
870
|
+
tags.push(`<span style="color:#fb923c;font-size:11px">delay\xB7${r.delay}ms</span>`);
|
|
871
|
+
if (r.scenarios?.length) {
|
|
872
|
+
const hasOr = r.scenarios.some((s) => Array.isArray(s.when));
|
|
873
|
+
tags.push(
|
|
874
|
+
`<span style="color:#a371f7;font-size:11px">scenarios\xB7${r.scenarios.length}${hasOr ? " (OR)" : ""}</span>`
|
|
875
|
+
);
|
|
876
|
+
}
|
|
877
|
+
if (r.otherwise)
|
|
878
|
+
tags.push(`<span style="color:var(--text-muted);font-size:11px">otherwise</span>`);
|
|
879
|
+
let desc;
|
|
880
|
+
if (r.error) {
|
|
881
|
+
desc = `Error injection \u2014 <code>${r.error}</code>`;
|
|
882
|
+
} else if (r.handler) {
|
|
883
|
+
const found = handlers.has(r.handler);
|
|
884
|
+
const handlerName = escapeHtml(r.handler);
|
|
885
|
+
desc = found ? `Handler \u2014 <code>${handlerName}()</code>` : `Handler \u2014 <code>${handlerName}()</code> <span style="color:#f85149">(not loaded)</span>`;
|
|
886
|
+
} else if (r.scenarios?.length) {
|
|
887
|
+
const hasTemplateInScenarios = r.scenarios.some((s) => s.response.body != null && hasTemplates(s.response.body)) || r.otherwise?.body != null && hasTemplates(r.otherwise.body);
|
|
888
|
+
desc = hasTemplateInScenarios ? `Scenarios \u2014 <code>{{\u2026}}</code>` : `Scenarios`;
|
|
889
|
+
} else if (r.response?.body != null && hasTemplates(r.response.body)) {
|
|
890
|
+
desc = `Dynamic \u2014 <code>{{\u2026}}</code>`;
|
|
891
|
+
} else {
|
|
892
|
+
const status = r.response?.status ?? 200;
|
|
893
|
+
desc = `Static \u2014 <code>${status}</code>${r.response?.headers ? ` + headers` : ""}`;
|
|
894
|
+
}
|
|
895
|
+
if (tags.length) desc += ` ${tags.join(" ")}`;
|
|
896
|
+
return endpointRow(r.method?.toUpperCase() ?? "GET", fullPath, desc);
|
|
897
|
+
});
|
|
898
|
+
return `
|
|
899
|
+
<details class="resource-card nested-card">
|
|
900
|
+
<summary>
|
|
901
|
+
<span class="resource-name">Custom routes</span>
|
|
902
|
+
<span class="route-count">${routes.length} route${routes.length !== 1 ? "s" : ""}</span>
|
|
903
|
+
</summary>
|
|
904
|
+
<table><tbody>
|
|
905
|
+
${rows.join("")}
|
|
906
|
+
</tbody></table>
|
|
907
|
+
</details>`;
|
|
908
|
+
}
|
|
909
|
+
function handlersAccordion(handlers, routes, base) {
|
|
910
|
+
if (!handlers.size) return "";
|
|
911
|
+
const routesByHandler = /* @__PURE__ */ new Map();
|
|
912
|
+
for (const r of routes) {
|
|
913
|
+
if (r.handler) {
|
|
914
|
+
const list = routesByHandler.get(r.handler) ?? [];
|
|
915
|
+
list.push({ method: (r.method ?? "GET").toUpperCase(), path: `${base}${r.path}` });
|
|
916
|
+
routesByHandler.set(r.handler, list);
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
const rows = [...handlers.keys()].map((name) => {
|
|
920
|
+
const linked = routesByHandler.get(name);
|
|
921
|
+
const routeDesc = linked ? linked.map((r) => `<code>${r.method} ${r.path}</code>`).join(", ") : `<span style="color:var(--text-muted)">not referenced in _routes</span>`;
|
|
922
|
+
return endpointRow("fn", name + "()", routeDesc);
|
|
923
|
+
});
|
|
924
|
+
return `
|
|
925
|
+
<details class="resource-card nested-card">
|
|
926
|
+
<summary>
|
|
927
|
+
<span class="resource-name">Handlers</span>
|
|
928
|
+
<span class="route-count">${handlers.size} function${handlers.size !== 1 ? "s" : ""}</span>
|
|
929
|
+
</summary>
|
|
930
|
+
<table><tbody>
|
|
931
|
+
${rows.join("")}
|
|
932
|
+
</tbody></table>
|
|
933
|
+
</details>`;
|
|
934
|
+
}
|
|
324
935
|
function examplesBlock(collections, relations, base, host, options, firstCustomRoute) {
|
|
325
936
|
const examples = [];
|
|
326
937
|
const firstCol = collections[0];
|
|
327
938
|
if (firstCol) {
|
|
328
939
|
const p = `${host}${base}/${firstCol}`;
|
|
329
|
-
const
|
|
940
|
+
const singular2 = firstCol.endsWith("s") ? firstCol.slice(0, -1) : firstCol;
|
|
330
941
|
examples.push(
|
|
331
942
|
`# List all ${firstCol}
|
|
332
943
|
curl ${p}`,
|
|
@@ -334,52 +945,53 @@ curl ${p}`,
|
|
|
334
945
|
curl "${p}?name=value"`,
|
|
335
946
|
`# Sort and paginate
|
|
336
947
|
curl "${p}?_sort=id&_order=desc&_page=1&_limit=5"`,
|
|
337
|
-
`# Get single ${
|
|
948
|
+
`# Get single ${singular2}
|
|
338
949
|
curl ${p}/1`,
|
|
339
|
-
`# Create ${
|
|
950
|
+
`# Create ${singular2}
|
|
340
951
|
curl -X POST ${p} \\
|
|
341
952
|
-H "Content-Type: application/json" \\
|
|
342
953
|
-d '{"name":"example"}'`,
|
|
343
|
-
`# Partially update ${
|
|
954
|
+
`# Partially update ${singular2}
|
|
344
955
|
curl -X PATCH ${p}/1 \\
|
|
345
956
|
-H "Content-Type: application/json" \\
|
|
346
957
|
-d '{"name":"updated"}'`,
|
|
347
|
-
`# Delete ${
|
|
958
|
+
`# Delete ${singular2}
|
|
348
959
|
curl -X DELETE ${p}/1`
|
|
349
960
|
);
|
|
350
961
|
}
|
|
351
962
|
const firstRel = Object.entries(relations)[0];
|
|
352
963
|
if (firstRel) {
|
|
353
964
|
const [child, fields] = firstRel;
|
|
354
|
-
const
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
curl ${host}${base}/${
|
|
965
|
+
const firstField = Object.entries(fields)[0];
|
|
966
|
+
if (firstField) {
|
|
967
|
+
const [fk, def] = firstField;
|
|
968
|
+
if (def.type !== "many2many") {
|
|
969
|
+
const expandKey = fk.replace(/Id$/i, "");
|
|
970
|
+
examples.push(
|
|
971
|
+
`# Embed parent with ?_expand
|
|
972
|
+
curl "${host}${base}/${child}/1?_expand=${expandKey}"`,
|
|
973
|
+
`# Nested resource
|
|
974
|
+
curl ${host}${base}/${def.target}/1/${child}`
|
|
975
|
+
);
|
|
976
|
+
} else {
|
|
977
|
+
examples.push(`# Many-to-many embed
|
|
978
|
+
curl "${host}${base}/${child}/1/${fk}"`);
|
|
979
|
+
}
|
|
368
980
|
}
|
|
369
981
|
}
|
|
370
982
|
if (options.pageable.enabled && firstCol) {
|
|
371
983
|
examples.push(`# Pageable envelope
|
|
372
984
|
curl "${host}${base}/${firstCol}?_page=2"`);
|
|
373
985
|
}
|
|
374
|
-
const firstParentRel = Object.entries(relations).find(
|
|
375
|
-
([, fields]) => Object.values(fields).includes(firstCol ?? "")
|
|
376
|
-
);
|
|
377
986
|
if (firstCol) {
|
|
378
987
|
examples.push(
|
|
379
988
|
`# Project fields with ?_fields
|
|
380
989
|
curl "${host}${base}/${firstCol}?_fields=id,name"`
|
|
381
990
|
);
|
|
382
991
|
}
|
|
992
|
+
const firstParentRel = Object.entries(relations).find(
|
|
993
|
+
([, fields]) => Object.values(fields).some((def) => def.type !== "many2many" && def.target === firstCol)
|
|
994
|
+
);
|
|
383
995
|
if (firstParentRel && firstCol) {
|
|
384
996
|
const [childName] = firstParentRel;
|
|
385
997
|
examples.push(
|
|
@@ -405,9 +1017,21 @@ curl ${curlFlag}${fullPath}`);
|
|
|
405
1017
|
const highlighted = examples.map((e) => e.replace(/^(#.+)$/gm, '<span class="cm">$1</span>')).join("\n\n");
|
|
406
1018
|
return `<pre>${highlighted}</pre>`;
|
|
407
1019
|
}
|
|
1020
|
+
|
|
1021
|
+
// src/router/templates/about.template.ts
|
|
1022
|
+
var _dir = dirname2(fileURLToPath(import.meta.url));
|
|
1023
|
+
var LOGO_SRC = (() => {
|
|
1024
|
+
try {
|
|
1025
|
+
const buf = readFileSync2(join(_dir, "../../assets/logo-color.png"));
|
|
1026
|
+
return `data:image/png;base64,${buf.toString("base64")}`;
|
|
1027
|
+
} catch {
|
|
1028
|
+
return "";
|
|
1029
|
+
}
|
|
1030
|
+
})();
|
|
408
1031
|
function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map()) {
|
|
409
1032
|
const collections = Object.keys(storage.getData());
|
|
410
1033
|
const relations = storage.getRelations();
|
|
1034
|
+
const customRoutes = storage.getRoutes();
|
|
411
1035
|
const base = options.base;
|
|
412
1036
|
const host = `http://${options.host}:${options.port}`;
|
|
413
1037
|
const modes = [];
|
|
@@ -421,105 +1045,6 @@ function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map(
|
|
|
421
1045
|
if (options.idStrategy !== "increment")
|
|
422
1046
|
modes.push(badge(`id \xB7 ${options.idStrategy}`, "#a371f7", "#a371f718"));
|
|
423
1047
|
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
1048
|
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
1049
|
return `<!DOCTYPE html>
|
|
525
1050
|
<html lang="en">
|
|
@@ -667,10 +1192,10 @@ function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map(
|
|
|
667
1192
|
<h2>Endpoints</h2>
|
|
668
1193
|
<div class="endpoints-grid">
|
|
669
1194
|
${accordions}
|
|
670
|
-
${
|
|
671
|
-
${snapshotAccordion}
|
|
672
|
-
${customRoutesAccordion}
|
|
673
|
-
${handlersAccordion}
|
|
1195
|
+
${nestedRoutesAccordion(relations, base)}
|
|
1196
|
+
${options.snapshot ? snapshotAccordion() : ""}
|
|
1197
|
+
${customRoutesAccordion(customRoutes, base, handlers)}
|
|
1198
|
+
${handlersAccordion(handlers, customRoutes, base)}
|
|
674
1199
|
</div>
|
|
675
1200
|
|
|
676
1201
|
<h2>Query Parameters</h2>
|
|
@@ -870,10 +1395,11 @@ function expandItems(input, query, resource, storage) {
|
|
|
870
1395
|
const resourceRelations = storage.getRelations()[resource] ?? {};
|
|
871
1396
|
const expansions = /* @__PURE__ */ new Map();
|
|
872
1397
|
for (const expandKey of keys) {
|
|
873
|
-
for (const [field,
|
|
1398
|
+
for (const [field, def] of Object.entries(resourceRelations)) {
|
|
1399
|
+
if (def.type === "many2many") continue;
|
|
874
1400
|
const derivedKey = field.replace(/Id$/i, "");
|
|
875
|
-
if (derivedKey === expandKey ||
|
|
876
|
-
expansions.set(expandKey, { field, parentCollection });
|
|
1401
|
+
if (derivedKey === expandKey || def.target === expandKey || def.target === `${expandKey}s`) {
|
|
1402
|
+
expansions.set(expandKey, { field, parentCollection: def.target });
|
|
877
1403
|
break;
|
|
878
1404
|
}
|
|
879
1405
|
}
|
|
@@ -902,10 +1428,24 @@ function embedItems(input, query, resource, storage) {
|
|
|
902
1428
|
const relations = storage.getRelations();
|
|
903
1429
|
const embeds = /* @__PURE__ */ new Map();
|
|
904
1430
|
for (const embedKey of keys) {
|
|
1431
|
+
const ownRelations = relations[resource] ?? {};
|
|
1432
|
+
if (embedKey in ownRelations) {
|
|
1433
|
+
const def = ownRelations[embedKey];
|
|
1434
|
+
if (def.type === "many2many") {
|
|
1435
|
+
embeds.set(embedKey, {
|
|
1436
|
+
kind: "many2many",
|
|
1437
|
+
target: def.target,
|
|
1438
|
+
through: def.through,
|
|
1439
|
+
foreignKey: def.foreignKey,
|
|
1440
|
+
otherKey: def.otherKey
|
|
1441
|
+
});
|
|
1442
|
+
continue;
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
905
1445
|
outer: for (const [childCollection, fields] of Object.entries(relations)) {
|
|
906
|
-
for (const [fkField,
|
|
907
|
-
if (
|
|
908
|
-
embeds.set(embedKey, { childCollection, fkField });
|
|
1446
|
+
for (const [fkField, def] of Object.entries(fields)) {
|
|
1447
|
+
if ((def.type === "many2one" || def.type === "one2one") && def.target === resource && childCollection === embedKey) {
|
|
1448
|
+
embeds.set(embedKey, { kind: def.type, childCollection, fkField });
|
|
909
1449
|
break outer;
|
|
910
1450
|
}
|
|
911
1451
|
}
|
|
@@ -914,10 +1454,57 @@ function embedItems(input, query, resource, storage) {
|
|
|
914
1454
|
if (embeds.size === 0) return isArray ? items : input;
|
|
915
1455
|
const result = items.map((item) => {
|
|
916
1456
|
const out = { ...item };
|
|
917
|
-
for (const [embedKey,
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
1457
|
+
for (const [embedKey, spec] of embeds) {
|
|
1458
|
+
if (spec.kind === "many2many") {
|
|
1459
|
+
const pivot = storage.getCollection(spec.through) ?? [];
|
|
1460
|
+
const matchingIds = new Set(
|
|
1461
|
+
pivot.filter((row) => String(row[spec.foreignKey]) === String(item["id"])).map((row) => String(row[spec.otherKey]))
|
|
1462
|
+
);
|
|
1463
|
+
out[embedKey] = (storage.getCollection(spec.target) ?? []).filter(
|
|
1464
|
+
(t) => matchingIds.has(String(t["id"]))
|
|
1465
|
+
);
|
|
1466
|
+
} else if (spec.kind === "one2one") {
|
|
1467
|
+
out[embedKey] = (storage.getCollection(spec.childCollection) ?? []).find(
|
|
1468
|
+
(child) => String(child[spec.fkField]) === String(item["id"])
|
|
1469
|
+
) ?? null;
|
|
1470
|
+
} else {
|
|
1471
|
+
out[embedKey] = (storage.getCollection(spec.childCollection) ?? []).filter(
|
|
1472
|
+
(child) => String(child[spec.fkField]) === String(item["id"])
|
|
1473
|
+
);
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
return out;
|
|
1477
|
+
});
|
|
1478
|
+
return isArray ? result : result[0];
|
|
1479
|
+
}
|
|
1480
|
+
function applyNested(input, resource, storage) {
|
|
1481
|
+
const isArray = Array.isArray(input);
|
|
1482
|
+
const items = isArray ? input : [input];
|
|
1483
|
+
const resourceRelations = storage.getRelations()[resource] ?? {};
|
|
1484
|
+
const nestedDefs = Object.entries(resourceRelations).filter(([, def]) => def.nested === true);
|
|
1485
|
+
if (nestedDefs.length === 0) return input;
|
|
1486
|
+
const result = items.map((item) => {
|
|
1487
|
+
const out = { ...item };
|
|
1488
|
+
for (const [key, def] of nestedDefs) {
|
|
1489
|
+
if (def.type === "many2many") {
|
|
1490
|
+
const pivot = storage.getCollection(def.through) ?? [];
|
|
1491
|
+
const matchingIds = new Set(
|
|
1492
|
+
pivot.filter((row) => String(row[def.foreignKey]) === String(item["id"])).map((row) => String(row[def.otherKey]))
|
|
1493
|
+
);
|
|
1494
|
+
out[key] = (storage.getCollection(def.target) ?? []).filter(
|
|
1495
|
+
(t) => matchingIds.has(String(t["id"]))
|
|
1496
|
+
);
|
|
1497
|
+
} else {
|
|
1498
|
+
const foreignKeyValue = item[key];
|
|
1499
|
+
if (foreignKeyValue === void 0) continue;
|
|
1500
|
+
const parent = (storage.getCollection(def.target) ?? []).find(
|
|
1501
|
+
(p) => String(p["id"]) === String(foreignKeyValue)
|
|
1502
|
+
);
|
|
1503
|
+
if (parent !== void 0) {
|
|
1504
|
+
const embedKey = key.replace(/Id$/i, "");
|
|
1505
|
+
out[embedKey] = parent;
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
921
1508
|
}
|
|
922
1509
|
return out;
|
|
923
1510
|
});
|
|
@@ -957,7 +1544,12 @@ var CollectionRouteCommand = class {
|
|
|
957
1544
|
const totalPages = Math.ceil(totalItems / limit) || 1;
|
|
958
1545
|
const data = projectFields(
|
|
959
1546
|
embedItems(
|
|
960
|
-
expandItems(
|
|
1547
|
+
expandItems(
|
|
1548
|
+
applyNested(paginate(sorted, page, limit), this.resource, this.storage),
|
|
1549
|
+
req.query,
|
|
1550
|
+
this.resource,
|
|
1551
|
+
this.storage
|
|
1552
|
+
),
|
|
961
1553
|
req.query,
|
|
962
1554
|
this.resource,
|
|
963
1555
|
this.storage
|
|
@@ -989,7 +1581,12 @@ var CollectionRouteCommand = class {
|
|
|
989
1581
|
}
|
|
990
1582
|
return projectFields(
|
|
991
1583
|
embedItems(
|
|
992
|
-
expandItems(
|
|
1584
|
+
expandItems(
|
|
1585
|
+
applyNested(result, this.resource, this.storage),
|
|
1586
|
+
req.query,
|
|
1587
|
+
this.resource,
|
|
1588
|
+
this.storage
|
|
1589
|
+
),
|
|
993
1590
|
req.query,
|
|
994
1591
|
this.resource,
|
|
995
1592
|
this.storage
|
|
@@ -1094,7 +1691,7 @@ var CustomRouteCommand = class {
|
|
|
1094
1691
|
url,
|
|
1095
1692
|
handler: async (req, reply) => {
|
|
1096
1693
|
if (route.delay && route.delay > 0) {
|
|
1097
|
-
await new Promise((
|
|
1694
|
+
await new Promise((resolve6) => setTimeout(resolve6, route.delay));
|
|
1098
1695
|
}
|
|
1099
1696
|
if (route.error) {
|
|
1100
1697
|
const body2 = route.errorBody ?? { error: `Forced error ${route.error}` };
|
|
@@ -1170,7 +1767,12 @@ var ItemRouteCommand = class {
|
|
|
1170
1767
|
const fields = (req.query["_fields"] ?? "").split(",").map((f) => f.trim()).filter(Boolean);
|
|
1171
1768
|
return projectFields(
|
|
1172
1769
|
embedItems(
|
|
1173
|
-
expandItems(
|
|
1770
|
+
expandItems(
|
|
1771
|
+
applyNested(item, this.resource, this.storage),
|
|
1772
|
+
req.query,
|
|
1773
|
+
this.resource,
|
|
1774
|
+
this.storage
|
|
1775
|
+
),
|
|
1174
1776
|
req.query,
|
|
1175
1777
|
this.resource,
|
|
1176
1778
|
this.storage
|
|
@@ -1213,31 +1815,432 @@ var NestedRouteCommand = class {
|
|
|
1213
1815
|
relations;
|
|
1214
1816
|
base;
|
|
1215
1817
|
register(server) {
|
|
1216
|
-
for (const [
|
|
1217
|
-
for (const [
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1818
|
+
for (const [source, fields] of Object.entries(this.relations)) {
|
|
1819
|
+
for (const [key, def] of Object.entries(fields)) {
|
|
1820
|
+
if (def.type === "many2many") {
|
|
1821
|
+
this.registerMany2Many(server, source, key, def);
|
|
1822
|
+
} else {
|
|
1823
|
+
this.registerFkRelation(server, source, key, def.target, def.type);
|
|
1824
|
+
}
|
|
1825
|
+
}
|
|
1826
|
+
}
|
|
1827
|
+
}
|
|
1828
|
+
registerFkRelation(server, child, fkField, parent, type) {
|
|
1829
|
+
const collectionPath = `${this.base}/${parent}/:id/${child}`;
|
|
1830
|
+
const itemPath = `${this.base}/${parent}/:id/${child}/:childId`;
|
|
1831
|
+
server.get(collectionPath, (req, reply) => {
|
|
1832
|
+
const parentCollection = this.storage.getCollection(parent) ?? [];
|
|
1833
|
+
const parentItem = findById(parentCollection, req.params.id);
|
|
1834
|
+
if (!parentItem) return reply.status(404).send({ error: "Not found" });
|
|
1835
|
+
const all = (this.storage.getCollection(child) ?? []).filter(
|
|
1836
|
+
(item) => String(item[fkField]) === req.params.id
|
|
1837
|
+
);
|
|
1838
|
+
if (type === "one2one") return all[0] ?? reply.status(404).send({ error: "Not found" });
|
|
1839
|
+
return all;
|
|
1840
|
+
});
|
|
1841
|
+
if (type === "many2one") {
|
|
1842
|
+
server.get(itemPath, (req, reply) => {
|
|
1843
|
+
const parentCollection = this.storage.getCollection(parent) ?? [];
|
|
1844
|
+
const parentItem = findById(parentCollection, req.params.id);
|
|
1845
|
+
if (!parentItem) return reply.status(404).send({ error: "Not found" });
|
|
1846
|
+
const childItem = (this.storage.getCollection(child) ?? []).find(
|
|
1847
|
+
(item) => String(item[fkField]) === req.params.id && String(item["id"]) === req.params.childId
|
|
1848
|
+
);
|
|
1849
|
+
if (!childItem) return reply.status(404).send({ error: "Not found" });
|
|
1850
|
+
return childItem;
|
|
1851
|
+
});
|
|
1852
|
+
}
|
|
1853
|
+
}
|
|
1854
|
+
registerMany2Many(server, source, alias, def) {
|
|
1855
|
+
server.get(`${this.base}/${source}/:id/${alias}`, (req, reply) => {
|
|
1856
|
+
const sourceCollection = this.storage.getCollection(source) ?? [];
|
|
1857
|
+
const sourceItem = findById(sourceCollection, req.params.id);
|
|
1858
|
+
if (!sourceItem) return reply.status(404).send({ error: "Not found" });
|
|
1859
|
+
const pivot = this.storage.getCollection(def.through) ?? [];
|
|
1860
|
+
const matchingIds = new Set(
|
|
1861
|
+
pivot.filter((row) => String(row[def.foreignKey]) === req.params.id).map((row) => String(row[def.otherKey]))
|
|
1862
|
+
);
|
|
1863
|
+
return (this.storage.getCollection(def.target) ?? []).filter(
|
|
1864
|
+
(t) => matchingIds.has(String(t["id"]))
|
|
1865
|
+
);
|
|
1866
|
+
});
|
|
1867
|
+
server.get(`${this.base}/${def.target}/:id/${source}`, (req, reply) => {
|
|
1868
|
+
const targetCollection = this.storage.getCollection(def.target) ?? [];
|
|
1869
|
+
const targetItem = findById(targetCollection, req.params.id);
|
|
1870
|
+
if (!targetItem) return reply.status(404).send({ error: "Not found" });
|
|
1871
|
+
const pivot = this.storage.getCollection(def.through) ?? [];
|
|
1872
|
+
const matchingIds = new Set(
|
|
1873
|
+
pivot.filter((row) => String(row[def.otherKey]) === req.params.id).map((row) => String(row[def.foreignKey]))
|
|
1874
|
+
);
|
|
1875
|
+
return (this.storage.getCollection(source) ?? []).filter(
|
|
1876
|
+
(t) => matchingIds.has(String(t["id"]))
|
|
1877
|
+
);
|
|
1878
|
+
});
|
|
1879
|
+
}
|
|
1880
|
+
};
|
|
1881
|
+
|
|
1882
|
+
// src/openapi/inferSchema.ts
|
|
1883
|
+
function buildCollectionSchema(items, fieldDefs = {}) {
|
|
1884
|
+
const sample = items.slice(0, 10);
|
|
1885
|
+
const inferredTypes = /* @__PURE__ */ new Map();
|
|
1886
|
+
for (const item of sample) {
|
|
1887
|
+
for (const [key, value] of Object.entries(item)) {
|
|
1888
|
+
if (!inferredTypes.has(key)) {
|
|
1889
|
+
inferredTypes.set(key, jsToOpenApiType(value));
|
|
1890
|
+
}
|
|
1891
|
+
}
|
|
1892
|
+
}
|
|
1893
|
+
const allFields = /* @__PURE__ */ new Set([...inferredTypes.keys(), ...Object.keys(fieldDefs)]);
|
|
1894
|
+
const properties = {};
|
|
1895
|
+
const required = [];
|
|
1896
|
+
for (const field of allFields) {
|
|
1897
|
+
const def = fieldDefs[field];
|
|
1898
|
+
const inferred = inferredTypes.get(field) ?? "string";
|
|
1899
|
+
const prop = {
|
|
1900
|
+
type: def?.type ?? inferred
|
|
1901
|
+
};
|
|
1902
|
+
if (def?.format) prop.format = def.format;
|
|
1903
|
+
if (def?.description) prop.description = def.description;
|
|
1904
|
+
if (def?.enum) prop.enum = def.enum;
|
|
1905
|
+
if (def?.default !== void 0) prop.default = def.default;
|
|
1906
|
+
properties[field] = prop;
|
|
1907
|
+
if (def?.required === true) required.push(field);
|
|
1908
|
+
}
|
|
1909
|
+
const schema = { type: "object", properties };
|
|
1910
|
+
if (required.length > 0) schema.required = required;
|
|
1911
|
+
return schema;
|
|
1912
|
+
}
|
|
1913
|
+
function jsToOpenApiType(value) {
|
|
1914
|
+
if (value === null || value === void 0) return "string";
|
|
1915
|
+
if (typeof value === "boolean") return "boolean";
|
|
1916
|
+
if (typeof value === "number") return Number.isInteger(value) ? "integer" : "number";
|
|
1917
|
+
if (Array.isArray(value)) return "array";
|
|
1918
|
+
if (typeof value === "object") return "object";
|
|
1919
|
+
return "string";
|
|
1920
|
+
}
|
|
1921
|
+
|
|
1922
|
+
// src/openapi/buildPaths.ts
|
|
1923
|
+
var COLLECTION_QUERY_PARAMS = [
|
|
1924
|
+
{ name: "_page", in: "query", schema: { type: "integer" }, description: "Page number (1-based)" },
|
|
1925
|
+
{ name: "_limit", in: "query", schema: { type: "integer" }, description: "Items per page" },
|
|
1926
|
+
{ name: "_sort", in: "query", schema: { type: "string" }, description: "Field name to sort by" },
|
|
1927
|
+
{
|
|
1928
|
+
name: "_order",
|
|
1929
|
+
in: "query",
|
|
1930
|
+
schema: { type: "string", enum: ["asc", "desc"] },
|
|
1931
|
+
description: "Sort direction"
|
|
1932
|
+
},
|
|
1933
|
+
{
|
|
1934
|
+
name: "_q",
|
|
1935
|
+
in: "query",
|
|
1936
|
+
schema: { type: "string" },
|
|
1937
|
+
description: "Full-text search across all scalar fields (case-insensitive)"
|
|
1938
|
+
},
|
|
1939
|
+
{
|
|
1940
|
+
name: "_expand",
|
|
1941
|
+
in: "query",
|
|
1942
|
+
schema: { type: "string" },
|
|
1943
|
+
description: "Embed related parent object inline (e.g. ?_expand=user)"
|
|
1944
|
+
},
|
|
1945
|
+
{
|
|
1946
|
+
name: "_embed",
|
|
1947
|
+
in: "query",
|
|
1948
|
+
schema: { type: "string" },
|
|
1949
|
+
description: "Embed child collection into each item (e.g. ?_embed=posts)"
|
|
1950
|
+
},
|
|
1951
|
+
{
|
|
1952
|
+
name: "_fields",
|
|
1953
|
+
in: "query",
|
|
1954
|
+
schema: { type: "string" },
|
|
1955
|
+
description: "Comma-separated field projection (e.g. ?_fields=id,name)"
|
|
1956
|
+
}
|
|
1957
|
+
];
|
|
1958
|
+
var ID_PATH_PARAM = {
|
|
1959
|
+
name: "id",
|
|
1960
|
+
in: "path",
|
|
1961
|
+
required: true,
|
|
1962
|
+
schema: { type: "string" },
|
|
1963
|
+
description: "Item id"
|
|
1964
|
+
};
|
|
1965
|
+
function toOpenApiPath(fastifyPath) {
|
|
1966
|
+
return fastifyPath.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, "{$1}");
|
|
1967
|
+
}
|
|
1968
|
+
function extractPathParams(fastifyPath) {
|
|
1969
|
+
const matches = fastifyPath.match(/:([a-zA-Z_][a-zA-Z0-9_]*)/g) ?? [];
|
|
1970
|
+
return matches.map((m) => ({
|
|
1971
|
+
name: m.slice(1),
|
|
1972
|
+
in: "path",
|
|
1973
|
+
required: true,
|
|
1974
|
+
schema: { type: "string" }
|
|
1975
|
+
}));
|
|
1976
|
+
}
|
|
1977
|
+
function singular(name) {
|
|
1978
|
+
return name.endsWith("s") ? name.slice(0, -1) : name;
|
|
1979
|
+
}
|
|
1980
|
+
function schemaRef(name) {
|
|
1981
|
+
return { $ref: `#/components/schemas/${name}` };
|
|
1982
|
+
}
|
|
1983
|
+
function jsonContent(schema) {
|
|
1984
|
+
return { "application/json": { schema } };
|
|
1985
|
+
}
|
|
1986
|
+
function ok(schema, description = "OK") {
|
|
1987
|
+
return { description, content: jsonContent(schema) };
|
|
1988
|
+
}
|
|
1989
|
+
function buildCrudPaths(collection, base, schemaName) {
|
|
1990
|
+
const ref = schemaRef(schemaName);
|
|
1991
|
+
const tag = collection;
|
|
1992
|
+
const sing = singular(collection);
|
|
1993
|
+
const collPath = `${base}/${collection}`;
|
|
1994
|
+
const itemPath = `${base}/${collection}/{id}`;
|
|
1995
|
+
return {
|
|
1996
|
+
[collPath]: {
|
|
1997
|
+
get: {
|
|
1998
|
+
summary: `List ${collection}`,
|
|
1999
|
+
tags: [tag],
|
|
2000
|
+
parameters: COLLECTION_QUERY_PARAMS,
|
|
2001
|
+
responses: {
|
|
2002
|
+
"200": {
|
|
2003
|
+
description: "OK",
|
|
2004
|
+
content: jsonContent({ type: "array", items: ref }),
|
|
2005
|
+
headers: {
|
|
2006
|
+
"X-Total-Count": {
|
|
2007
|
+
description: "Total items (when using ?_page / ?_limit)",
|
|
2008
|
+
schema: { type: "integer" }
|
|
2009
|
+
}
|
|
2010
|
+
}
|
|
2011
|
+
}
|
|
2012
|
+
}
|
|
2013
|
+
},
|
|
2014
|
+
post: {
|
|
2015
|
+
summary: `Create ${sing}`,
|
|
2016
|
+
tags: [tag],
|
|
2017
|
+
requestBody: { required: true, content: jsonContent(ref) },
|
|
2018
|
+
responses: { "201": ok(ref, "Created") }
|
|
2019
|
+
}
|
|
2020
|
+
},
|
|
2021
|
+
[itemPath]: {
|
|
2022
|
+
get: {
|
|
2023
|
+
summary: `Get ${sing}`,
|
|
2024
|
+
tags: [tag],
|
|
2025
|
+
parameters: [
|
|
2026
|
+
ID_PATH_PARAM,
|
|
2027
|
+
...COLLECTION_QUERY_PARAMS.filter(
|
|
2028
|
+
(p) => ["_expand", "_embed", "_fields"].includes(p.name)
|
|
2029
|
+
)
|
|
2030
|
+
],
|
|
2031
|
+
responses: { "200": ok(ref), "404": { description: "Not found" } }
|
|
2032
|
+
},
|
|
2033
|
+
put: {
|
|
2034
|
+
summary: `Replace ${sing}`,
|
|
2035
|
+
tags: [tag],
|
|
2036
|
+
parameters: [ID_PATH_PARAM],
|
|
2037
|
+
requestBody: { required: true, content: jsonContent(ref) },
|
|
2038
|
+
responses: { "200": ok(ref), "404": { description: "Not found" } }
|
|
2039
|
+
},
|
|
2040
|
+
patch: {
|
|
2041
|
+
summary: `Update ${sing}`,
|
|
2042
|
+
tags: [tag],
|
|
2043
|
+
parameters: [ID_PATH_PARAM],
|
|
2044
|
+
requestBody: { required: false, content: jsonContent(ref) },
|
|
2045
|
+
responses: { "200": ok(ref), "404": { description: "Not found" } }
|
|
2046
|
+
},
|
|
2047
|
+
delete: {
|
|
2048
|
+
summary: `Delete ${sing}`,
|
|
2049
|
+
tags: [tag],
|
|
2050
|
+
parameters: [ID_PATH_PARAM],
|
|
2051
|
+
responses: { "200": ok(ref, "Deleted item returned"), "404": { description: "Not found" } }
|
|
2052
|
+
}
|
|
2053
|
+
}
|
|
2054
|
+
};
|
|
2055
|
+
}
|
|
2056
|
+
function buildRelationPaths(relations, base) {
|
|
2057
|
+
const paths = {};
|
|
2058
|
+
for (const [source, fields] of Object.entries(relations)) {
|
|
2059
|
+
for (const [key, def] of Object.entries(fields)) {
|
|
2060
|
+
if (def.type === "many2many") {
|
|
2061
|
+
const forwardPath = `${base}/${source}/{id}/${key}`;
|
|
2062
|
+
const inversePath = `${base}/${def.target}/{id}/${source}`;
|
|
2063
|
+
paths[forwardPath] = {
|
|
2064
|
+
get: {
|
|
2065
|
+
summary: `List ${def.target} linked to ${singular(source)} via ${def.through}`,
|
|
2066
|
+
tags: [source],
|
|
2067
|
+
parameters: [ID_PATH_PARAM],
|
|
2068
|
+
responses: {
|
|
2069
|
+
"200": {
|
|
2070
|
+
description: "OK",
|
|
2071
|
+
content: jsonContent({ type: "array", items: { type: "object" } })
|
|
2072
|
+
},
|
|
2073
|
+
"404": { description: `${singular(source)} not found` }
|
|
2074
|
+
}
|
|
2075
|
+
}
|
|
2076
|
+
};
|
|
2077
|
+
paths[inversePath] = {
|
|
2078
|
+
get: {
|
|
2079
|
+
summary: `List ${source} linked to ${singular(def.target)} via ${def.through} (inverse)`,
|
|
2080
|
+
tags: [def.target],
|
|
2081
|
+
parameters: [ID_PATH_PARAM],
|
|
2082
|
+
responses: {
|
|
2083
|
+
"200": {
|
|
2084
|
+
description: "OK",
|
|
2085
|
+
content: jsonContent({ type: "array", items: { type: "object" } })
|
|
2086
|
+
},
|
|
2087
|
+
"404": { description: `${singular(def.target)} not found` }
|
|
2088
|
+
}
|
|
2089
|
+
}
|
|
2090
|
+
};
|
|
2091
|
+
} else {
|
|
2092
|
+
const parentSing = singular(def.target);
|
|
2093
|
+
const collPath = `${base}/${def.target}/{id}/${source}`;
|
|
2094
|
+
const isOne2One = def.type === "one2one";
|
|
2095
|
+
const responseSchema = isOne2One ? { type: "object" } : { type: "array", items: { type: "object" } };
|
|
2096
|
+
paths[collPath] = {
|
|
2097
|
+
get: {
|
|
2098
|
+
summary: isOne2One ? `Get ${singular(source)} belonging to ${parentSing}` : `List ${source} belonging to ${parentSing}`,
|
|
2099
|
+
tags: [def.target],
|
|
2100
|
+
parameters: [ID_PATH_PARAM],
|
|
2101
|
+
responses: {
|
|
2102
|
+
"200": { description: "OK", content: jsonContent(responseSchema) },
|
|
2103
|
+
"404": { description: `${parentSing} not found` }
|
|
2104
|
+
}
|
|
2105
|
+
}
|
|
2106
|
+
};
|
|
2107
|
+
if (!isOne2One) {
|
|
2108
|
+
const itemPath = `${base}/${def.target}/{id}/${source}/{childId}`;
|
|
2109
|
+
paths[itemPath] = {
|
|
2110
|
+
get: {
|
|
2111
|
+
summary: `Get single ${singular(source)} scoped to ${parentSing}`,
|
|
2112
|
+
tags: [def.target],
|
|
2113
|
+
parameters: [
|
|
2114
|
+
ID_PATH_PARAM,
|
|
2115
|
+
{ name: "childId", in: "path", required: true, schema: { type: "string" } }
|
|
2116
|
+
],
|
|
2117
|
+
responses: {
|
|
2118
|
+
"200": { description: "OK", content: jsonContent({ type: "object" }) },
|
|
2119
|
+
"404": { description: "Not found" }
|
|
2120
|
+
}
|
|
2121
|
+
}
|
|
2122
|
+
};
|
|
2123
|
+
}
|
|
2124
|
+
}
|
|
2125
|
+
}
|
|
2126
|
+
}
|
|
2127
|
+
return paths;
|
|
2128
|
+
}
|
|
2129
|
+
function buildCustomRoutePaths(routes, base) {
|
|
2130
|
+
const paths = {};
|
|
2131
|
+
for (const route of routes) {
|
|
2132
|
+
const openApiPath = toOpenApiPath(`${base}${route.path}`);
|
|
2133
|
+
const method = route.method.toLowerCase();
|
|
2134
|
+
const pathParams = extractPathParams(route.path);
|
|
2135
|
+
const responses = {};
|
|
2136
|
+
if (route.error) {
|
|
2137
|
+
responses[String(route.error)] = { description: `Forced error ${route.error}` };
|
|
2138
|
+
} else {
|
|
2139
|
+
const statuses = /* @__PURE__ */ new Set();
|
|
2140
|
+
for (const s of route.scenarios ?? []) statuses.add(s.response.status ?? 200);
|
|
2141
|
+
if (route.otherwise) statuses.add(route.otherwise.status ?? 200);
|
|
2142
|
+
if (route.response) statuses.add(route.response.status ?? 200);
|
|
2143
|
+
if (statuses.size === 0) statuses.add(200);
|
|
2144
|
+
for (const status of statuses) {
|
|
2145
|
+
const bodySource = (route.scenarios ?? []).find((s) => (s.response.status ?? 200) === status)?.response.body ?? (route.otherwise?.status ?? 200) === status ? route.otherwise?.body : route.response?.body;
|
|
2146
|
+
responses[String(status)] = {
|
|
2147
|
+
description: status < 400 ? "OK" : "Error",
|
|
2148
|
+
...bodySource != null ? { content: jsonContent(inferResponseSchema(bodySource)) } : {}
|
|
2149
|
+
};
|
|
1239
2150
|
}
|
|
1240
2151
|
}
|
|
2152
|
+
const desc = route.handler ? `Handler: ${route.handler}()` : route.scenarios?.length ? `Conditional scenarios (${route.scenarios.length})` : "Custom static route";
|
|
2153
|
+
const operation = {
|
|
2154
|
+
summary: `${route.method.toUpperCase()} ${route.path}`,
|
|
2155
|
+
description: desc,
|
|
2156
|
+
tags: ["custom"],
|
|
2157
|
+
...pathParams.length > 0 ? { parameters: pathParams } : {},
|
|
2158
|
+
responses
|
|
2159
|
+
};
|
|
2160
|
+
if (!paths[openApiPath]) paths[openApiPath] = {};
|
|
2161
|
+
paths[openApiPath][method] = operation;
|
|
2162
|
+
}
|
|
2163
|
+
return paths;
|
|
2164
|
+
}
|
|
2165
|
+
function inferResponseSchema(body) {
|
|
2166
|
+
if (body === null || body === void 0) return {};
|
|
2167
|
+
if (typeof body !== "object" || Array.isArray(body)) return { type: "object" };
|
|
2168
|
+
const properties = {};
|
|
2169
|
+
for (const [key, value] of Object.entries(body)) {
|
|
2170
|
+
properties[key] = { type: jsToOpenApiType2(value) };
|
|
2171
|
+
}
|
|
2172
|
+
return { type: "object", properties };
|
|
2173
|
+
}
|
|
2174
|
+
function jsToOpenApiType2(value) {
|
|
2175
|
+
if (value === null || value === void 0) return "string";
|
|
2176
|
+
if (typeof value === "boolean") return "boolean";
|
|
2177
|
+
if (typeof value === "number") return Number.isInteger(value) ? "integer" : "number";
|
|
2178
|
+
if (Array.isArray(value)) return "array";
|
|
2179
|
+
if (typeof value === "object") return "object";
|
|
2180
|
+
return "string";
|
|
2181
|
+
}
|
|
2182
|
+
|
|
2183
|
+
// src/openapi/generateOpenApi.ts
|
|
2184
|
+
function generateOpenApi(storage, options, title = "yRest API") {
|
|
2185
|
+
const collections = Object.keys(storage.getData());
|
|
2186
|
+
const relations = storage.getRelations();
|
|
2187
|
+
const schemaBlock = storage.getSchema();
|
|
2188
|
+
const customRoutes = storage.getRoutes();
|
|
2189
|
+
const base = options.base ?? "";
|
|
2190
|
+
const schemas = {};
|
|
2191
|
+
for (const collection of collections) {
|
|
2192
|
+
const items = storage.getCollection(collection) ?? [];
|
|
2193
|
+
const fieldDefs = schemaBlock[collection] ?? {};
|
|
2194
|
+
const schemaName = toSchemaName(collection);
|
|
2195
|
+
schemas[schemaName] = buildCollectionSchema(items, fieldDefs);
|
|
2196
|
+
}
|
|
2197
|
+
const paths = {};
|
|
2198
|
+
for (const collection of collections) {
|
|
2199
|
+
const schemaName = toSchemaName(collection);
|
|
2200
|
+
Object.assign(paths, buildCrudPaths(collection, base, schemaName));
|
|
2201
|
+
}
|
|
2202
|
+
Object.assign(paths, buildRelationPaths(relations, base));
|
|
2203
|
+
Object.assign(paths, buildCustomRoutePaths(customRoutes, base));
|
|
2204
|
+
return {
|
|
2205
|
+
openapi: "3.0.3",
|
|
2206
|
+
info: {
|
|
2207
|
+
title,
|
|
2208
|
+
version: "1.0.0",
|
|
2209
|
+
description: "Generated by yRest from db.yml"
|
|
2210
|
+
},
|
|
2211
|
+
servers: [
|
|
2212
|
+
{
|
|
2213
|
+
url: `http://${options.host}:${options.port}${base}`,
|
|
2214
|
+
description: "yRest mock server"
|
|
2215
|
+
}
|
|
2216
|
+
],
|
|
2217
|
+
paths,
|
|
2218
|
+
components: { schemas }
|
|
2219
|
+
};
|
|
2220
|
+
}
|
|
2221
|
+
function toSchemaName(collection) {
|
|
2222
|
+
const withoutS = collection.endsWith("s") ? collection.slice(0, -1) : collection;
|
|
2223
|
+
return withoutS.charAt(0).toUpperCase() + withoutS.slice(1);
|
|
2224
|
+
}
|
|
2225
|
+
|
|
2226
|
+
// src/router/routes/openapi.routes.ts
|
|
2227
|
+
import { stringify as stringify2 } from "yaml";
|
|
2228
|
+
var OpenApiRouteCommand = class {
|
|
2229
|
+
constructor(storage, options) {
|
|
2230
|
+
this.storage = storage;
|
|
2231
|
+
this.options = options;
|
|
2232
|
+
}
|
|
2233
|
+
storage;
|
|
2234
|
+
options;
|
|
2235
|
+
register(server) {
|
|
2236
|
+
server.get("/_openapi", (_req, reply) => {
|
|
2237
|
+
const doc = generateOpenApi(this.storage, this.options);
|
|
2238
|
+
reply.header("Content-Type", "text/yaml; charset=utf-8");
|
|
2239
|
+
return reply.send(stringify2(doc, { lineWidth: 0, aliasDuplicateObjects: false }));
|
|
2240
|
+
});
|
|
2241
|
+
server.get("/_openapi.json", (_req, reply) => {
|
|
2242
|
+
return reply.send(generateOpenApi(this.storage, this.options));
|
|
2243
|
+
});
|
|
1241
2244
|
}
|
|
1242
2245
|
};
|
|
1243
2246
|
|
|
@@ -1321,6 +2324,7 @@ async function createServer(storage, options, handlers = /* @__PURE__ */ new Map
|
|
|
1321
2324
|
}
|
|
1322
2325
|
const commands = [
|
|
1323
2326
|
new AboutRouteCommand(storage, options, handlers),
|
|
2327
|
+
new OpenApiRouteCommand(storage, options),
|
|
1324
2328
|
...options.snapshot ? [new SnapshotRouteCommand(storage)] : [],
|
|
1325
2329
|
new CustomRouteCommand(storage, options.base, handlers),
|
|
1326
2330
|
...buildResourceRouteCommands(storage, options)
|
|
@@ -1565,7 +2569,7 @@ function registerServe(program2) {
|
|
|
1565
2569
|
// src/cli/commands/handler.ts
|
|
1566
2570
|
import { existsSync as existsSync4, readFileSync as readFileSync4, appendFileSync, writeFileSync as writeFileSync3 } from "fs";
|
|
1567
2571
|
import { join as join3, resolve as resolve4, basename } from "path";
|
|
1568
|
-
import { parse as parse3, stringify as
|
|
2572
|
+
import { parse as parse3, stringify as stringify3 } from "yaml";
|
|
1569
2573
|
var HANDLERS_FILE_HEADER = `// yrest handlers \u2014 loaded via "handlers:" in yrest.config.yml
|
|
1570
2574
|
// Handler signature: (req: HandlerRequest) => HandlerResponse | Promise<HandlerResponse>
|
|
1571
2575
|
// See https://github.com/aggiovato/yaml-rest for full documentation
|
|
@@ -1627,7 +2631,7 @@ function registerHandler(program2) {
|
|
|
1627
2631
|
const alreadyRegistered = routes.some((r) => r["handler"] === name);
|
|
1628
2632
|
if (!alreadyRegistered) {
|
|
1629
2633
|
routes.push({ method: flags.method.toUpperCase(), path: flags.path, handler: name });
|
|
1630
|
-
writeFileSync3(dbPath,
|
|
2634
|
+
writeFileSync3(dbPath, stringify3(raw), "utf8");
|
|
1631
2635
|
console.log(` Added _routes entry to ${basename(dbPath)}`);
|
|
1632
2636
|
} else {
|
|
1633
2637
|
console.log(` Handler "${name}" already in _routes \u2014 skipped`);
|
|
@@ -1650,6 +2654,33 @@ function registerHandler(program2) {
|
|
|
1650
2654
|
});
|
|
1651
2655
|
}
|
|
1652
2656
|
|
|
2657
|
+
// src/cli/commands/openapi.ts
|
|
2658
|
+
import { writeFileSync as writeFileSync4 } from "fs";
|
|
2659
|
+
import { resolve as resolve5 } from "path";
|
|
2660
|
+
import { stringify as stringify4 } from "yaml";
|
|
2661
|
+
function registerOpenApi(program2) {
|
|
2662
|
+
program2.command("openapi <file>").description("Generate an OpenAPI 3.0 spec from a db.yml file").option("-o, --output <file>", "Output file (default: openapi.yaml / openapi.json)").option("--format <fmt>", "Output format: yaml (default) or json", "yaml").option("--stdout", "Print to stdout instead of writing a file").option("--base <base>", "Base path prefix applied to all routes", "").option("--port <port>", "Server port shown in the servers block", "3070").option("--host <host>", "Server host shown in the servers block", "localhost").option("--title <title>", "API title for the info block", "yRest API").action((file, opts) => {
|
|
2663
|
+
const storage = createYrestStorage(resolve5(file));
|
|
2664
|
+
const options = yrestOptionsSchema.parse({
|
|
2665
|
+
file,
|
|
2666
|
+
base: opts["base"] || void 0,
|
|
2667
|
+
port: Number(opts["port"]) || 3070,
|
|
2668
|
+
host: opts["host"] || "localhost"
|
|
2669
|
+
});
|
|
2670
|
+
const doc = generateOpenApi(storage, options, opts["title"]);
|
|
2671
|
+
const isJson = opts["format"] === "json";
|
|
2672
|
+
const output = isJson ? JSON.stringify(doc, null, 2) : stringify4(doc, { lineWidth: 0, aliasDuplicateObjects: false });
|
|
2673
|
+
if (opts["stdout"]) {
|
|
2674
|
+
process.stdout.write(output);
|
|
2675
|
+
return;
|
|
2676
|
+
}
|
|
2677
|
+
const defaultFile = isJson ? "openapi.json" : "openapi.yaml";
|
|
2678
|
+
const outFile = resolve5(opts["output"] ?? defaultFile);
|
|
2679
|
+
writeFileSync4(outFile, output, "utf8");
|
|
2680
|
+
console.log(`\u2713 OpenAPI spec written to ${outFile}`);
|
|
2681
|
+
});
|
|
2682
|
+
}
|
|
2683
|
+
|
|
1653
2684
|
// src/cli/index.ts
|
|
1654
2685
|
var require2 = createRequire(import.meta.url);
|
|
1655
2686
|
var { version } = require2("../../package.json");
|
|
@@ -1657,4 +2688,5 @@ program.name("yrest").description("Zero-config REST API mock server powered by a
|
|
|
1657
2688
|
registerInit(program);
|
|
1658
2689
|
registerServe(program);
|
|
1659
2690
|
registerHandler(program);
|
|
2691
|
+
registerOpenApi(program);
|
|
1660
2692
|
program.parse();
|