@yrest/cli 0.6.0 → 0.8.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 +156 -18
- package/assets/logo-color.png +0 -0
- package/assets/logo-figure.png +0 -0
- package/assets/logo-text.png +0 -0
- package/assets/logo-white.png +0 -0
- package/assets/yRest-banner.png +0 -0
- package/dist/cli/index.js +176 -43
- package/dist/cli/index.mjs +155 -22
- package/dist/index.d.mts +95 -16
- package/dist/index.d.ts +95 -16
- package/dist/index.js +159 -25
- package/dist/index.mjs +150 -19
- package/package.json +10 -3
package/README.md
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
|
|
1
|
+
<div align="center">
|
|
2
|
+
<img src="assets/yRest-banner.png" alt="yRest" width="100%" />
|
|
3
|
+
</div>
|
|
4
|
+
|
|
5
|
+
<br/>
|
|
2
6
|
|
|
3
7
|
[](https://www.npmjs.com/package/@yrest/cli)
|
|
4
8
|
[](https://www.npmjs.com/package/@yrest/cli)
|
|
@@ -6,6 +10,7 @@
|
|
|
6
10
|
[](https://github.com/aggiovato/yRest/actions)
|
|
7
11
|
[](https://www.npmjs.com/package/@yrest/cli)
|
|
8
12
|
[](https://www.typescriptlang.org/)
|
|
13
|
+
[](https://socket.dev/npm/package/@yrest/cli)
|
|
9
14
|
|
|
10
15
|
YAML-powered json-server alternative. Zero-config REST API mock server with full CRUD, relations, filters and snapshots from a `db.yml` file.
|
|
11
16
|
|
|
@@ -56,6 +61,7 @@ A YAML-first alternative to json-server for frontend development.
|
|
|
56
61
|
| Custom static routes (`_routes`) | ✅ | ❌ |
|
|
57
62
|
| Template variables in responses | ✅ | ❌ |
|
|
58
63
|
| Handler functions (JS logic) | ✅ | ❌ |
|
|
64
|
+
| Conditional scenarios (`scenarios:`) | ✅ | ❌ |
|
|
59
65
|
| Snapshot endpoints | ✅ | ❌ |
|
|
60
66
|
| Config file | ✅ | ⚠️ |
|
|
61
67
|
| API overview page (`/_about`) | ✅ | ❌ |
|
|
@@ -156,6 +162,7 @@ npx @yrest/cli serve db.yml --handlers yrest.handlers.js
|
|
|
156
162
|
| `--pageable [n]` | `false` | Wrap GET collection responses in `{ data, pagination }`. Optional limit |
|
|
157
163
|
| `--snapshot` | `false` | Save initial state snapshot and expose `/_snapshot` endpoints |
|
|
158
164
|
| `--handlers <file>` | _(none)_ | Path to a JS file exporting handler functions for custom routes |
|
|
165
|
+
| `--id-strategy` | `increment` | Id generation for new items: `increment` (auto-int) or `uuid` |
|
|
159
166
|
|
|
160
167
|
All flags can also be set in `yrest.config.yml` (see below). CLI flags always take priority over the config file.
|
|
161
168
|
|
|
@@ -177,6 +184,7 @@ host: localhost
|
|
|
177
184
|
# pageable: false # true (limit 10), or a number (custom limit)
|
|
178
185
|
# snapshot: false
|
|
179
186
|
# handlers: yrest.handlers.js
|
|
187
|
+
# idStrategy: increment # increment or uuid
|
|
180
188
|
```
|
|
181
189
|
|
|
182
190
|
**Priority order** (highest wins): CLI flags → `yrest.config.yml` → schema defaults.
|
|
@@ -497,6 +505,133 @@ Available variables:
|
|
|
497
505
|
|
|
498
506
|
When a field contains only a single `{{variable}}` placeholder, the resolved value preserves its original type (number, boolean, object). When embedded in a larger string it is stringified.
|
|
499
507
|
|
|
508
|
+
### Conditional scenarios
|
|
509
|
+
|
|
510
|
+
Define multiple conditional response variants for a custom route. Scenarios are evaluated in declaration order — the first matching `when:` wins. If none match, the `otherwise:` block is used (if defined), otherwise the static `response:` block.
|
|
511
|
+
|
|
512
|
+
```yaml
|
|
513
|
+
_routes:
|
|
514
|
+
- method: POST
|
|
515
|
+
path: /login
|
|
516
|
+
scenarios:
|
|
517
|
+
- when:
|
|
518
|
+
body.email: ana@test.com
|
|
519
|
+
body.password: secret
|
|
520
|
+
response:
|
|
521
|
+
status: 200
|
|
522
|
+
body:
|
|
523
|
+
token: tok-ana
|
|
524
|
+
- when:
|
|
525
|
+
body.email: admin@test.com
|
|
526
|
+
body.password: admin
|
|
527
|
+
response:
|
|
528
|
+
status: 200
|
|
529
|
+
body:
|
|
530
|
+
token: tok-admin
|
|
531
|
+
role: admin
|
|
532
|
+
otherwise:
|
|
533
|
+
status: 401
|
|
534
|
+
body:
|
|
535
|
+
error: Invalid credentials
|
|
536
|
+
```
|
|
537
|
+
|
|
538
|
+
**`when:` as an object** — all entries must match (AND semantics):
|
|
539
|
+
|
|
540
|
+
```yaml
|
|
541
|
+
when:
|
|
542
|
+
body.email: ana@test.com
|
|
543
|
+
body.password: secret
|
|
544
|
+
```
|
|
545
|
+
|
|
546
|
+
**`when:` as an array of objects** — any group satisfying all its conditions matches (OR of ANDs):
|
|
547
|
+
|
|
548
|
+
```yaml
|
|
549
|
+
when:
|
|
550
|
+
- body.role: admin
|
|
551
|
+
- body.role: superadmin
|
|
552
|
+
```
|
|
553
|
+
|
|
554
|
+
Condition keys use dot-notation to address request data:
|
|
555
|
+
|
|
556
|
+
| Prefix | Example | Resolves to |
|
|
557
|
+
| ----------- | ------------------- | -------------------------- |
|
|
558
|
+
| `body.X` | `body.email` | `req.body.email` |
|
|
559
|
+
| `params.X` | `params.id` | `req.params.id` |
|
|
560
|
+
| `query.X` | `query.page` | `req.query.page` |
|
|
561
|
+
| `headers.X` | `headers.x-api-key` | `req.headers["x-api-key"]` |
|
|
562
|
+
|
|
563
|
+
Field operator suffixes (`_ne`, `_like`, `_start`, `_regex`, `_gte`, `_lte`) work on condition keys exactly as they do on query params:
|
|
564
|
+
|
|
565
|
+
```yaml
|
|
566
|
+
scenarios:
|
|
567
|
+
- when:
|
|
568
|
+
body.name_like: ana # name contains "ana" (case-insensitive)
|
|
569
|
+
body.age_gte: "18" # age >= 18
|
|
570
|
+
response:
|
|
571
|
+
status: 200
|
|
572
|
+
body: { ok: true }
|
|
573
|
+
```
|
|
574
|
+
|
|
575
|
+
Template variables (`{{}}`) are supported in both scenario and `otherwise` response bodies:
|
|
576
|
+
|
|
577
|
+
```yaml
|
|
578
|
+
scenarios:
|
|
579
|
+
- when:
|
|
580
|
+
body.email: ana@test.com
|
|
581
|
+
response:
|
|
582
|
+
status: 200
|
|
583
|
+
body:
|
|
584
|
+
message: "Welcome {{body.email}}"
|
|
585
|
+
otherwise:
|
|
586
|
+
status: 401
|
|
587
|
+
body:
|
|
588
|
+
error: "Unknown user: {{body.email}}"
|
|
589
|
+
```
|
|
590
|
+
|
|
591
|
+
### Per-route delay
|
|
592
|
+
|
|
593
|
+
Add a fixed delay (ms) to a specific route without affecting the rest of the server. Takes priority over the global `--delay` option for that route:
|
|
594
|
+
|
|
595
|
+
```yaml
|
|
596
|
+
_routes:
|
|
597
|
+
- method: GET
|
|
598
|
+
path: /slow-endpoint
|
|
599
|
+
delay: 800
|
|
600
|
+
response:
|
|
601
|
+
status: 200
|
|
602
|
+
body: { data: loaded }
|
|
603
|
+
```
|
|
604
|
+
|
|
605
|
+
### Error injection
|
|
606
|
+
|
|
607
|
+
Force a custom route to always return a specific HTTP error, regardless of handlers, scenarios or the static response. Useful for simulating outages, payment failures or auth errors:
|
|
608
|
+
|
|
609
|
+
```yaml
|
|
610
|
+
_routes:
|
|
611
|
+
- method: GET
|
|
612
|
+
path: /payments
|
|
613
|
+
error: 503
|
|
614
|
+
errorBody:
|
|
615
|
+
message: Service temporarily unavailable
|
|
616
|
+
retryAfter: 30
|
|
617
|
+
|
|
618
|
+
- method: POST
|
|
619
|
+
path: /checkout
|
|
620
|
+
error: 402
|
|
621
|
+
errorBody:
|
|
622
|
+
error: Payment required
|
|
623
|
+
|
|
624
|
+
- method: GET
|
|
625
|
+
path: /slow-failure
|
|
626
|
+
delay: 400
|
|
627
|
+
error: 500
|
|
628
|
+
```
|
|
629
|
+
|
|
630
|
+
- `error` takes priority over `handler`, `scenarios` and `response` — the route always returns this status.
|
|
631
|
+
- `errorBody` is optional. If omitted, the default body is `{ "error": "Forced error NNN" }`.
|
|
632
|
+
- `delay` still applies before the error is returned.
|
|
633
|
+
- Shown in `/_about` with a red `error·NNN` badge.
|
|
634
|
+
|
|
500
635
|
### Handler functions
|
|
501
636
|
|
|
502
637
|
For routes that need real logic (conditional responses, stateful mocks, request inspection), reference a JavaScript function via the `handler:` field:
|
|
@@ -844,23 +979,26 @@ const server = createYrestServer({
|
|
|
844
979
|
|
|
845
980
|
## Roadmap
|
|
846
981
|
|
|
847
|
-
| Feature
|
|
848
|
-
|
|
|
849
|
-
| Full CRUD from `db.yml`
|
|
850
|
-
| Field filters, operators, full-text search
|
|
851
|
-
| Relations, `_expand`, `_embed`, nested routes
|
|
852
|
-
| Pagination, sorting, field projection
|
|
853
|
-
| Watch, readonly, delay, snapshot modes
|
|
854
|
-
| Custom routes (`_routes`) with static responses
|
|
855
|
-
| Template variables in responses (`{{params.id}}`)
|
|
856
|
-
| Handler functions (`yrest.handlers.js`)
|
|
857
|
-
| Visual panel (`/_panel`)
|
|
858
|
-
| Programmatic API for Vitest / Playwright
|
|
859
|
-
| Docker image
|
|
860
|
-
| OpenAPI export (`yrest openapi db.yml`)
|
|
861
|
-
| VS Code extension with YAML snippets
|
|
862
|
-
| Request validation with JSON Schema
|
|
863
|
-
| Conditional scenarios
|
|
982
|
+
| Feature | Status |
|
|
983
|
+
| -------------------------------------------------- | ------ |
|
|
984
|
+
| Full CRUD from `db.yml` | ✅ |
|
|
985
|
+
| Field filters, operators, full-text search | ✅ |
|
|
986
|
+
| Relations, `_expand`, `_embed`, nested routes | ✅ |
|
|
987
|
+
| Pagination, sorting, field projection | ✅ |
|
|
988
|
+
| Watch, readonly, delay, snapshot modes | ✅ |
|
|
989
|
+
| Custom routes (`_routes`) with static responses | ✅ |
|
|
990
|
+
| Template variables in responses (`{{params.id}}`) | ✅ |
|
|
991
|
+
| Handler functions (`yrest.handlers.js`) | ✅ |
|
|
992
|
+
| Visual panel (`/_panel`) | 🔜 |
|
|
993
|
+
| Programmatic API for Vitest / Playwright | ✅ |
|
|
994
|
+
| Docker image | 🔜 |
|
|
995
|
+
| OpenAPI export (`yrest openapi db.yml`) | 🔜 |
|
|
996
|
+
| VS Code extension with YAML snippets | 🔜 |
|
|
997
|
+
| Request validation with JSON Schema | 🔜 |
|
|
998
|
+
| Conditional scenarios (`scenarios:`, `otherwise:`) | ✅ |
|
|
999
|
+
| Per-route delay (`delay:`) | ✅ |
|
|
1000
|
+
| Error injection (`error:` in `_routes`) | ✅ |
|
|
1001
|
+
| Configurable ID strategy (`idStrategy`) | ✅ |
|
|
864
1002
|
|
|
865
1003
|
---
|
|
866
1004
|
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/dist/cli/index.js
CHANGED
|
@@ -132,8 +132,8 @@ function registerInit(program2) {
|
|
|
132
132
|
}
|
|
133
133
|
|
|
134
134
|
// src/cli/commands/serve.ts
|
|
135
|
-
var
|
|
136
|
-
var
|
|
135
|
+
var import_node_fs6 = require("fs");
|
|
136
|
+
var import_node_path4 = require("path");
|
|
137
137
|
|
|
138
138
|
// src/storage/yrestStorage.ts
|
|
139
139
|
var import_node_fs2 = require("fs");
|
|
@@ -223,6 +223,11 @@ function createYrestStorage(filePath) {
|
|
|
223
223
|
var import_fastify = __toESM(require("fastify"));
|
|
224
224
|
var import_cors = __toESM(require("@fastify/cors"));
|
|
225
225
|
|
|
226
|
+
// src/router/templates/about.template.ts
|
|
227
|
+
var import_node_fs3 = require("fs");
|
|
228
|
+
var import_node_path3 = require("path");
|
|
229
|
+
var import_node_url = require("url");
|
|
230
|
+
|
|
226
231
|
// src/utils/interpolate.ts
|
|
227
232
|
var import_node_crypto2 = require("crypto");
|
|
228
233
|
function getPath(obj, path) {
|
|
@@ -270,6 +275,15 @@ function hasTemplates(value) {
|
|
|
270
275
|
}
|
|
271
276
|
|
|
272
277
|
// src/router/templates/about.template.ts
|
|
278
|
+
var _dir = (0, import_node_path3.dirname)((0, import_node_url.fileURLToPath)(importMetaUrl));
|
|
279
|
+
var LOGO_SRC = (() => {
|
|
280
|
+
try {
|
|
281
|
+
const buf = (0, import_node_fs3.readFileSync)((0, import_node_path3.join)(_dir, "../../assets/logo-color.png"));
|
|
282
|
+
return `data:image/png;base64,${buf.toString("base64")}`;
|
|
283
|
+
} catch {
|
|
284
|
+
return "";
|
|
285
|
+
}
|
|
286
|
+
})();
|
|
273
287
|
var METHOD_COLOR = {
|
|
274
288
|
GET: "#3fb950",
|
|
275
289
|
POST: "#58a6ff",
|
|
@@ -428,6 +442,8 @@ function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map(
|
|
|
428
442
|
modes.push(badge(`pageable \xB7 limit ${options.pageable.limit}`, "#34d399", "#34d39918"));
|
|
429
443
|
if (options.snapshot) modes.push(badge("snapshot", "#c084fc", "#c084fc18"));
|
|
430
444
|
if (handlers.size > 0) modes.push(badge(`handlers \xB7 ${handlers.size}`, "#f0883e", "#f0883e18"));
|
|
445
|
+
if (options.idStrategy !== "increment")
|
|
446
|
+
modes.push(badge(`id \xB7 ${options.idStrategy}`, "#a371f7", "#a371f718"));
|
|
431
447
|
const accordions = collections.map((col, i) => resourceAccordion(col, base, i === 0)).join("");
|
|
432
448
|
const nestedRows = [];
|
|
433
449
|
for (const [child, fields] of Object.entries(relations)) {
|
|
@@ -469,16 +485,38 @@ function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map(
|
|
|
469
485
|
<table><tbody>
|
|
470
486
|
${customRoutes.map((r) => {
|
|
471
487
|
const fullPath = `${base}${r.path}`;
|
|
488
|
+
const tags = [];
|
|
489
|
+
if (r.error) {
|
|
490
|
+
tags.push(`<span style="color:#f85149;font-size:11px">error\xB7${r.error}</span>`);
|
|
491
|
+
}
|
|
492
|
+
if (r.delay && r.delay > 0) {
|
|
493
|
+
tags.push(`<span style="color:#fb923c;font-size:11px">delay\xB7${r.delay}ms</span>`);
|
|
494
|
+
}
|
|
495
|
+
if (r.scenarios?.length) {
|
|
496
|
+
const hasOr = r.scenarios.some((s) => Array.isArray(s.when));
|
|
497
|
+
tags.push(
|
|
498
|
+
`<span style="color:#a371f7;font-size:11px">scenarios\xB7${r.scenarios.length}${hasOr ? " (OR)" : ""}</span>`
|
|
499
|
+
);
|
|
500
|
+
}
|
|
501
|
+
if (r.otherwise) {
|
|
502
|
+
tags.push(`<span style="color:var(--text-muted);font-size:11px">otherwise</span>`);
|
|
503
|
+
}
|
|
472
504
|
let desc;
|
|
473
|
-
if (r.
|
|
505
|
+
if (r.error) {
|
|
506
|
+
desc = `Error injection \u2014 <code>${r.error}</code>`;
|
|
507
|
+
} else if (r.handler) {
|
|
474
508
|
const found = handlers.has(r.handler);
|
|
475
509
|
desc = found ? `Handler \u2014 <code>${r.handler}()</code>` : `Handler \u2014 <code>${r.handler}()</code> <span style="color:#f85149">(not loaded)</span>`;
|
|
510
|
+
} else if (r.scenarios?.length) {
|
|
511
|
+
const hasTemplateInScenarios = r.scenarios.some((s) => s.response.body != null && hasTemplates(s.response.body)) || r.otherwise?.body != null && hasTemplates(r.otherwise.body);
|
|
512
|
+
desc = hasTemplateInScenarios ? `Scenarios \u2014 <code>{{\u2026}}</code>` : `Scenarios`;
|
|
476
513
|
} else if (r.response?.body != null && hasTemplates(r.response.body)) {
|
|
477
|
-
desc = `Dynamic
|
|
514
|
+
desc = `Dynamic \u2014 <code>{{\u2026}}</code>`;
|
|
478
515
|
} else {
|
|
479
516
|
const status = r.response?.status ?? 200;
|
|
480
|
-
desc = `Static \u2014 <code>${status}</code>${r.response?.headers ? ` +
|
|
517
|
+
desc = `Static \u2014 <code>${status}</code>${r.response?.headers ? ` + headers` : ""}`;
|
|
481
518
|
}
|
|
519
|
+
if (tags.length) desc += ` ${tags.join(" ")}`;
|
|
482
520
|
return endpointRow(r.method?.toUpperCase() ?? "GET", fullPath, desc);
|
|
483
521
|
}).join("")}
|
|
484
522
|
</tbody></table>
|
|
@@ -630,7 +668,7 @@ function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map(
|
|
|
630
668
|
|
|
631
669
|
<div class="banner">
|
|
632
670
|
<div class="banner-inner">
|
|
633
|
-
|
|
671
|
+
${LOGO_SRC ? `<img src="${LOGO_SRC}" alt="yRest" height="68" style="display:block;margin-bottom:0px" />` : `<h1><span class="y">y</span><span class="rest">Rest</span></h1>`}
|
|
634
672
|
<p>Zero-config REST API mock server</p>
|
|
635
673
|
<div class="banner-meta">
|
|
636
674
|
<span>URL <strong>${host}</strong></span>
|
|
@@ -682,7 +720,7 @@ function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map(
|
|
|
682
720
|
${collections.length ? `<h2>Examples</h2><div class="card">${examplesBlock(collections, relations, base, host, options, customRoutes[0])}</div>` : ""}
|
|
683
721
|
|
|
684
722
|
<footer>
|
|
685
|
-
Powered by <a href="https://github.com/aggiovato/
|
|
723
|
+
Powered by <a href="https://github.com/aggiovato/yRest" target="_blank">@yrest/cli</a> \xB7 <a href="/_about">/_about</a>
|
|
686
724
|
</footer>
|
|
687
725
|
|
|
688
726
|
</div>
|
|
@@ -713,6 +751,10 @@ function nextId(items) {
|
|
|
713
751
|
const ids = items.map((i) => i["id"]).filter((id) => typeof id === "number");
|
|
714
752
|
return ids.length > 0 ? Math.max(...ids) + 1 : 1;
|
|
715
753
|
}
|
|
754
|
+
function generateId(items, strategy) {
|
|
755
|
+
if (strategy === "uuid") return crypto.randomUUID();
|
|
756
|
+
return nextId(items);
|
|
757
|
+
}
|
|
716
758
|
function firstParam(value) {
|
|
717
759
|
if (value === void 0) return void 0;
|
|
718
760
|
return Array.isArray(value) ? value[0] : value;
|
|
@@ -800,10 +842,10 @@ function findById(items, id) {
|
|
|
800
842
|
function findIndexById(items, id) {
|
|
801
843
|
return items.findIndex((i) => String(i["id"]) === id);
|
|
802
844
|
}
|
|
803
|
-
function createItem(storage, resource, body) {
|
|
845
|
+
function createItem(storage, resource, body, idStrategy = "increment") {
|
|
804
846
|
const collection = storage.getCollection(resource) ?? [];
|
|
805
847
|
const item = {
|
|
806
|
-
id: body["id"] !== void 0 ? body["id"] :
|
|
848
|
+
id: body["id"] !== void 0 ? body["id"] : generateId(collection, idStrategy),
|
|
807
849
|
...body
|
|
808
850
|
};
|
|
809
851
|
storage.setCollection(resource, [...collection, item]);
|
|
@@ -981,13 +1023,74 @@ var CollectionRouteCommand = class {
|
|
|
981
1023
|
if (!req.body || typeof req.body !== "object" || Array.isArray(req.body)) {
|
|
982
1024
|
return reply.status(400).send({ error: "Request body must be a JSON object" });
|
|
983
1025
|
}
|
|
984
|
-
const item = createItem(
|
|
1026
|
+
const item = createItem(
|
|
1027
|
+
this.storage,
|
|
1028
|
+
this.resource,
|
|
1029
|
+
req.body,
|
|
1030
|
+
this.options.idStrategy
|
|
1031
|
+
);
|
|
985
1032
|
return reply.status(201).send(expandItems(item, req.query, this.resource, this.storage));
|
|
986
1033
|
});
|
|
987
1034
|
}
|
|
988
1035
|
};
|
|
989
1036
|
|
|
1037
|
+
// src/utils/conditions.ts
|
|
1038
|
+
function resolveRequestPath(dotPath, req) {
|
|
1039
|
+
const [root, ...rest] = dotPath.split(".");
|
|
1040
|
+
let value;
|
|
1041
|
+
switch (root) {
|
|
1042
|
+
case "body":
|
|
1043
|
+
value = req.body;
|
|
1044
|
+
break;
|
|
1045
|
+
case "params":
|
|
1046
|
+
value = req.params;
|
|
1047
|
+
break;
|
|
1048
|
+
case "query":
|
|
1049
|
+
value = req.query;
|
|
1050
|
+
break;
|
|
1051
|
+
case "headers":
|
|
1052
|
+
value = req.headers;
|
|
1053
|
+
break;
|
|
1054
|
+
default:
|
|
1055
|
+
return void 0;
|
|
1056
|
+
}
|
|
1057
|
+
for (const key of rest) {
|
|
1058
|
+
if (value != null && typeof value === "object") {
|
|
1059
|
+
value = value[key];
|
|
1060
|
+
} else {
|
|
1061
|
+
return void 0;
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
return value;
|
|
1065
|
+
}
|
|
1066
|
+
function matchConditionGroup(group, req) {
|
|
1067
|
+
return Object.entries(group).every(([key, expected]) => {
|
|
1068
|
+
const op = OPERATORS.find((o) => key.endsWith(o));
|
|
1069
|
+
if (op) {
|
|
1070
|
+
const path = key.slice(0, -op.length);
|
|
1071
|
+
const value2 = resolveRequestPath(path, req);
|
|
1072
|
+
if (value2 === void 0) return false;
|
|
1073
|
+
return applyOperator(value2, op, String(expected));
|
|
1074
|
+
}
|
|
1075
|
+
const value = resolveRequestPath(key, req);
|
|
1076
|
+
return String(value) === String(expected);
|
|
1077
|
+
});
|
|
1078
|
+
}
|
|
1079
|
+
function matchWhen(when, req) {
|
|
1080
|
+
if (Array.isArray(when)) {
|
|
1081
|
+
return when.some((group) => matchConditionGroup(group, req));
|
|
1082
|
+
}
|
|
1083
|
+
return matchConditionGroup(when, req);
|
|
1084
|
+
}
|
|
1085
|
+
function findMatchingScenario(scenarios, req) {
|
|
1086
|
+
return scenarios.find((s) => matchWhen(s.when, req));
|
|
1087
|
+
}
|
|
1088
|
+
|
|
990
1089
|
// src/router/routes/custom.routes.ts
|
|
1090
|
+
function resolveBody(body, ctx) {
|
|
1091
|
+
if (body != null && hasTemplates(body)) return interpolate(body, ctx);
|
|
1092
|
+
return body ?? null;
|
|
1093
|
+
}
|
|
991
1094
|
var CustomRouteCommand = class {
|
|
992
1095
|
constructor(storage, base, handlers = /* @__PURE__ */ new Map()) {
|
|
993
1096
|
this.storage = storage;
|
|
@@ -1012,6 +1115,13 @@ var CustomRouteCommand = class {
|
|
|
1012
1115
|
method,
|
|
1013
1116
|
url,
|
|
1014
1117
|
handler: async (req, reply) => {
|
|
1118
|
+
if (route.delay && route.delay > 0) {
|
|
1119
|
+
await new Promise((resolve5) => setTimeout(resolve5, route.delay));
|
|
1120
|
+
}
|
|
1121
|
+
if (route.error) {
|
|
1122
|
+
const body2 = route.errorBody ?? { error: `Forced error ${route.error}` };
|
|
1123
|
+
return reply.status(route.error).send(body2);
|
|
1124
|
+
}
|
|
1015
1125
|
for (const [key, value] of Object.entries(headers)) {
|
|
1016
1126
|
reply.header(key, value);
|
|
1017
1127
|
}
|
|
@@ -1021,13 +1131,13 @@ var CustomRouteCommand = class {
|
|
|
1021
1131
|
return reply.status(501).send({ error: `Handler "${handlerName}" is not defined in the handlers file` });
|
|
1022
1132
|
}
|
|
1023
1133
|
try {
|
|
1024
|
-
const
|
|
1134
|
+
const ctx2 = {
|
|
1025
1135
|
params: req.params,
|
|
1026
1136
|
query: req.query,
|
|
1027
1137
|
body: req.body,
|
|
1028
1138
|
headers: req.headers
|
|
1029
1139
|
};
|
|
1030
|
-
const result = await fn(
|
|
1140
|
+
const result = await fn(ctx2);
|
|
1031
1141
|
const resStatus = result.status ?? 200;
|
|
1032
1142
|
for (const [k, v] of Object.entries(result.headers ?? {})) {
|
|
1033
1143
|
reply.header(k, v);
|
|
@@ -1039,12 +1149,24 @@ var CustomRouteCommand = class {
|
|
|
1039
1149
|
return reply.status(500).send({ error: `Handler "${handlerName}" threw an error: ${msg}` });
|
|
1040
1150
|
}
|
|
1041
1151
|
}
|
|
1042
|
-
const
|
|
1152
|
+
const ctx = {
|
|
1043
1153
|
params: req.params,
|
|
1044
1154
|
query: req.query,
|
|
1045
1155
|
body: req.body,
|
|
1046
1156
|
headers: req.headers
|
|
1047
|
-
}
|
|
1157
|
+
};
|
|
1158
|
+
if (route.scenarios?.length) {
|
|
1159
|
+
const matched = findMatchingScenario(route.scenarios, ctx);
|
|
1160
|
+
const active = matched?.response ?? route.otherwise;
|
|
1161
|
+
if (active) {
|
|
1162
|
+
const aStatus = active.status ?? 200;
|
|
1163
|
+
const aBody = resolveBody(active.body, ctx);
|
|
1164
|
+
for (const [k, v] of Object.entries(active.headers ?? {})) reply.header(k, v);
|
|
1165
|
+
if (!active.body && aStatus === 204) return reply.status(aStatus).send();
|
|
1166
|
+
return reply.status(aStatus).send(aBody);
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
const body = dynamic ? interpolate(rawBody, ctx) : rawBody;
|
|
1048
1170
|
if (body === null && status === 204) return reply.status(status).send();
|
|
1049
1171
|
return reply.status(status).send(body);
|
|
1050
1172
|
}
|
|
@@ -1296,22 +1418,29 @@ var yrestOptionsSchema = import_zod.z.object({
|
|
|
1296
1418
|
pageable: import_zod.z.union([import_zod.z.boolean(), import_zod.z.coerce.number().int().positive()]).default(false).transform((v) => ({
|
|
1297
1419
|
enabled: v !== false,
|
|
1298
1420
|
limit: v === false || v === true ? 10 : v
|
|
1299
|
-
}))
|
|
1421
|
+
})),
|
|
1422
|
+
/**
|
|
1423
|
+
* Strategy used to generate `id` values for new items when no `id` is provided in the body.
|
|
1424
|
+
*
|
|
1425
|
+
* - `"increment"` (default) — next integer above the current max id in the collection.
|
|
1426
|
+
* - `"uuid"` — a random UUID v4 string (`crypto.randomUUID()`).
|
|
1427
|
+
*/
|
|
1428
|
+
idStrategy: import_zod.z.enum(["increment", "uuid"]).default("increment")
|
|
1300
1429
|
});
|
|
1301
1430
|
|
|
1302
1431
|
// src/config/loadConfigFile.ts
|
|
1303
|
-
var
|
|
1432
|
+
var import_node_fs4 = require("fs");
|
|
1304
1433
|
var import_yaml2 = require("yaml");
|
|
1305
1434
|
function loadConfigFile(configPath) {
|
|
1306
|
-
if (!(0,
|
|
1307
|
-
const raw = (0,
|
|
1435
|
+
if (!(0, import_node_fs4.existsSync)(configPath)) return {};
|
|
1436
|
+
const raw = (0, import_node_fs4.readFileSync)(configPath, "utf8");
|
|
1308
1437
|
return (0, import_yaml2.parse)(raw) ?? {};
|
|
1309
1438
|
}
|
|
1310
1439
|
|
|
1311
1440
|
// src/utils/handlers.ts
|
|
1312
|
-
var
|
|
1441
|
+
var import_node_fs5 = require("fs");
|
|
1313
1442
|
async function loadHandlers(filePath) {
|
|
1314
|
-
if (!(0,
|
|
1443
|
+
if (!(0, import_node_fs5.existsSync)(filePath)) return /* @__PURE__ */ new Map();
|
|
1315
1444
|
try {
|
|
1316
1445
|
const mod = await import(filePath);
|
|
1317
1446
|
const map = /* @__PURE__ */ new Map();
|
|
@@ -1341,8 +1470,12 @@ function registerServe(program2) {
|
|
|
1341
1470
|
).option(
|
|
1342
1471
|
"--handlers <file>",
|
|
1343
1472
|
"Path to a JavaScript file exporting custom route handler functions"
|
|
1473
|
+
).option(
|
|
1474
|
+
"--id-strategy <strategy>",
|
|
1475
|
+
"Id generation strategy for new items: increment (default) or uuid",
|
|
1476
|
+
"increment"
|
|
1344
1477
|
).action(async (file, flags, cmd) => {
|
|
1345
|
-
const fileConfig = loadConfigFile((0,
|
|
1478
|
+
const fileConfig = loadConfigFile((0, import_node_path4.join)(process.cwd(), "yrest.config.yml"));
|
|
1346
1479
|
const cliOverrides = Object.fromEntries(
|
|
1347
1480
|
Object.entries(flags).filter(([key]) => cmd.getOptionValueSource(key) === "cli")
|
|
1348
1481
|
);
|
|
@@ -1361,7 +1494,7 @@ function registerServe(program2) {
|
|
|
1361
1494
|
console.error(`Error: cannot load "${options.file}" \u2014 ${msg}`);
|
|
1362
1495
|
process.exit(1);
|
|
1363
1496
|
}
|
|
1364
|
-
const handlers = options.handlers ? await loadHandlers((0,
|
|
1497
|
+
const handlers = options.handlers ? await loadHandlers((0, import_node_path4.resolve)(options.handlers)) : /* @__PURE__ */ new Map();
|
|
1365
1498
|
const yrestServer = createYrestServerFromStorage(storage, options, handlers);
|
|
1366
1499
|
await yrestServer.start();
|
|
1367
1500
|
const collections = Object.keys(storage.getData());
|
|
@@ -1423,9 +1556,9 @@ function registerServe(program2) {
|
|
|
1423
1556
|
${dim(modes.map((m) => `[${m}]`).join(" "))}`);
|
|
1424
1557
|
console.log("");
|
|
1425
1558
|
if (options.watch) {
|
|
1426
|
-
const absFile = (0,
|
|
1559
|
+
const absFile = (0, import_node_path4.resolve)(options.file);
|
|
1427
1560
|
let debounce;
|
|
1428
|
-
(0,
|
|
1561
|
+
(0, import_node_fs6.watchFile)(absFile, { interval: 300 }, (curr, prev) => {
|
|
1429
1562
|
if (curr.mtimeMs === prev.mtimeMs) return;
|
|
1430
1563
|
clearTimeout(debounce);
|
|
1431
1564
|
debounce = setTimeout(() => {
|
|
@@ -1445,8 +1578,8 @@ function registerServe(program2) {
|
|
|
1445
1578
|
}
|
|
1446
1579
|
|
|
1447
1580
|
// src/cli/commands/handler.ts
|
|
1448
|
-
var
|
|
1449
|
-
var
|
|
1581
|
+
var import_node_fs7 = require("fs");
|
|
1582
|
+
var import_node_path5 = require("path");
|
|
1450
1583
|
var import_yaml3 = require("yaml");
|
|
1451
1584
|
var HANDLERS_FILE_HEADER = `// yrest handlers \u2014 loaded via "handlers:" in yrest.config.yml
|
|
1452
1585
|
// Handler signature: (req: HandlerRequest) => HandlerResponse | Promise<HandlerResponse>
|
|
@@ -1473,44 +1606,44 @@ function registerHandler(program2) {
|
|
|
1473
1606
|
"--register",
|
|
1474
1607
|
"Also add a _routes entry to db.yml linking this handler to method + path"
|
|
1475
1608
|
).action((name, flags) => {
|
|
1476
|
-
const fileConfig = loadConfigFile((0,
|
|
1477
|
-
const handlersPath = (0,
|
|
1609
|
+
const fileConfig = loadConfigFile((0, import_node_path5.join)(process.cwd(), "yrest.config.yml"));
|
|
1610
|
+
const handlersPath = (0, import_node_path5.resolve)(
|
|
1478
1611
|
fileConfig.handlers ?? "yrest.handlers.js"
|
|
1479
1612
|
);
|
|
1480
|
-
const dbPath = (0,
|
|
1481
|
-
if (!(0,
|
|
1482
|
-
(0,
|
|
1613
|
+
const dbPath = (0, import_node_path5.resolve)(fileConfig.file ?? "db.yml");
|
|
1614
|
+
if (!(0, import_node_fs7.existsSync)(handlersPath)) {
|
|
1615
|
+
(0, import_node_fs7.writeFileSync)(
|
|
1483
1616
|
handlersPath,
|
|
1484
1617
|
HANDLERS_FILE_HEADER + buildStub(name, flags.method, flags.path),
|
|
1485
1618
|
"utf8"
|
|
1486
1619
|
);
|
|
1487
|
-
console.log(` Created ${(0,
|
|
1620
|
+
console.log(` Created ${(0, import_node_path5.basename)(handlersPath)}`);
|
|
1488
1621
|
} else {
|
|
1489
|
-
const existing = (0,
|
|
1622
|
+
const existing = (0, import_node_fs7.readFileSync)(handlersPath, "utf8");
|
|
1490
1623
|
if (existing.includes(`function ${name}(`)) {
|
|
1491
|
-
console.error(` Error: handler "${name}" already exists in ${(0,
|
|
1624
|
+
console.error(` Error: handler "${name}" already exists in ${(0, import_node_path5.basename)(handlersPath)}`);
|
|
1492
1625
|
process.exit(1);
|
|
1493
1626
|
}
|
|
1494
|
-
(0,
|
|
1495
|
-
console.log(` Added handler "${name}" to ${(0,
|
|
1627
|
+
(0, import_node_fs7.appendFileSync)(handlersPath, buildStub(name, flags.method, flags.path), "utf8");
|
|
1628
|
+
console.log(` Added handler "${name}" to ${(0, import_node_path5.basename)(handlersPath)}`);
|
|
1496
1629
|
}
|
|
1497
1630
|
if (flags.register) {
|
|
1498
1631
|
if (!flags.method || !flags.path) {
|
|
1499
1632
|
console.error(" Error: --register requires --method and --path");
|
|
1500
1633
|
process.exit(1);
|
|
1501
1634
|
}
|
|
1502
|
-
if (!(0,
|
|
1635
|
+
if (!(0, import_node_fs7.existsSync)(dbPath)) {
|
|
1503
1636
|
console.error(` Error: database file not found at ${dbPath}`);
|
|
1504
1637
|
process.exit(1);
|
|
1505
1638
|
}
|
|
1506
|
-
const raw = (0, import_yaml3.parse)((0,
|
|
1639
|
+
const raw = (0, import_yaml3.parse)((0, import_node_fs7.readFileSync)(dbPath, "utf8")) ?? {};
|
|
1507
1640
|
if (!Array.isArray(raw["_routes"])) raw["_routes"] = [];
|
|
1508
1641
|
const routes = raw["_routes"];
|
|
1509
1642
|
const alreadyRegistered = routes.some((r) => r["handler"] === name);
|
|
1510
1643
|
if (!alreadyRegistered) {
|
|
1511
1644
|
routes.push({ method: flags.method.toUpperCase(), path: flags.path, handler: name });
|
|
1512
|
-
(0,
|
|
1513
|
-
console.log(` Added _routes entry to ${(0,
|
|
1645
|
+
(0, import_node_fs7.writeFileSync)(dbPath, (0, import_yaml3.stringify)(raw), "utf8");
|
|
1646
|
+
console.log(` Added _routes entry to ${(0, import_node_path5.basename)(dbPath)}`);
|
|
1514
1647
|
} else {
|
|
1515
1648
|
console.log(` Handler "${name}" already in _routes \u2014 skipped`);
|
|
1516
1649
|
}
|
|
@@ -1518,10 +1651,10 @@ function registerHandler(program2) {
|
|
|
1518
1651
|
console.log(`
|
|
1519
1652
|
Next steps:`);
|
|
1520
1653
|
if (!fileConfig.handlers) {
|
|
1521
|
-
console.log(` 1. Add handlers: ${(0,
|
|
1522
|
-
console.log(` 2. Implement the "${name}" function in ${(0,
|
|
1654
|
+
console.log(` 1. Add handlers: ${(0, import_node_path5.basename)(handlersPath)} to yrest.config.yml`);
|
|
1655
|
+
console.log(` 2. Implement the "${name}" function in ${(0, import_node_path5.basename)(handlersPath)}`);
|
|
1523
1656
|
} else {
|
|
1524
|
-
console.log(` 1. Implement the "${name}" function in ${(0,
|
|
1657
|
+
console.log(` 1. Implement the "${name}" function in ${(0, import_node_path5.basename)(handlersPath)}`);
|
|
1525
1658
|
}
|
|
1526
1659
|
if (!flags.register) {
|
|
1527
1660
|
console.log(
|