@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.
@@ -106,7 +106,7 @@ function registerInit(program2) {
106
106
 
107
107
  // src/cli/commands/serve.ts
108
108
  import { watchFile } from "fs";
109
- import { join, resolve as resolve3 } from "path";
109
+ import { join as join2, resolve as resolve3 } from "path";
110
110
 
111
111
  // src/storage/yrestStorage.ts
112
112
  import { readFileSync, writeFileSync as writeFileSync2, renameSync } from "fs";
@@ -196,6 +196,11 @@ function createYrestStorage(filePath) {
196
196
  import Fastify from "fastify";
197
197
  import cors from "@fastify/cors";
198
198
 
199
+ // src/router/templates/about.template.ts
200
+ import { readFileSync as readFileSync2 } from "fs";
201
+ import { dirname as dirname2, join } from "path";
202
+ import { fileURLToPath } from "url";
203
+
199
204
  // src/utils/interpolate.ts
200
205
  import { randomUUID as randomUUID2 } from "crypto";
201
206
  function getPath(obj, path) {
@@ -243,6 +248,15 @@ function hasTemplates(value) {
243
248
  }
244
249
 
245
250
  // src/router/templates/about.template.ts
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
+ })();
246
260
  var METHOD_COLOR = {
247
261
  GET: "#3fb950",
248
262
  POST: "#58a6ff",
@@ -401,6 +415,8 @@ function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map(
401
415
  modes.push(badge(`pageable \xB7 limit ${options.pageable.limit}`, "#34d399", "#34d39918"));
402
416
  if (options.snapshot) modes.push(badge("snapshot", "#c084fc", "#c084fc18"));
403
417
  if (handlers.size > 0) modes.push(badge(`handlers \xB7 ${handlers.size}`, "#f0883e", "#f0883e18"));
418
+ if (options.idStrategy !== "increment")
419
+ modes.push(badge(`id \xB7 ${options.idStrategy}`, "#a371f7", "#a371f718"));
404
420
  const accordions = collections.map((col, i) => resourceAccordion(col, base, i === 0)).join("");
405
421
  const nestedRows = [];
406
422
  for (const [child, fields] of Object.entries(relations)) {
@@ -442,16 +458,38 @@ function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map(
442
458
  <table><tbody>
443
459
  ${customRoutes.map((r) => {
444
460
  const fullPath = `${base}${r.path}`;
461
+ const tags = [];
462
+ if (r.error) {
463
+ tags.push(`<span style="color:#f85149;font-size:11px">error\xB7${r.error}</span>`);
464
+ }
465
+ if (r.delay && r.delay > 0) {
466
+ tags.push(`<span style="color:#fb923c;font-size:11px">delay\xB7${r.delay}ms</span>`);
467
+ }
468
+ if (r.scenarios?.length) {
469
+ const hasOr = r.scenarios.some((s) => Array.isArray(s.when));
470
+ tags.push(
471
+ `<span style="color:#a371f7;font-size:11px">scenarios\xB7${r.scenarios.length}${hasOr ? " (OR)" : ""}</span>`
472
+ );
473
+ }
474
+ if (r.otherwise) {
475
+ tags.push(`<span style="color:var(--text-muted);font-size:11px">otherwise</span>`);
476
+ }
445
477
  let desc;
446
- if (r.handler) {
478
+ if (r.error) {
479
+ desc = `Error injection \u2014 <code>${r.error}</code>`;
480
+ } else if (r.handler) {
447
481
  const found = handlers.has(r.handler);
448
482
  desc = found ? `Handler \u2014 <code>${r.handler}()</code>` : `Handler \u2014 <code>${r.handler}()</code> <span style="color:#f85149">(not loaded)</span>`;
483
+ } else if (r.scenarios?.length) {
484
+ const hasTemplateInScenarios = r.scenarios.some((s) => s.response.body != null && hasTemplates(s.response.body)) || r.otherwise?.body != null && hasTemplates(r.otherwise.body);
485
+ desc = hasTemplateInScenarios ? `Scenarios \u2014 <code>{{\u2026}}</code>` : `Scenarios`;
449
486
  } else if (r.response?.body != null && hasTemplates(r.response.body)) {
450
- desc = `Dynamic body \u2014 <code>{{\u2026}}</code>`;
487
+ desc = `Dynamic \u2014 <code>{{\u2026}}</code>`;
451
488
  } else {
452
489
  const status = r.response?.status ?? 200;
453
- desc = `Static \u2014 <code>${status}</code>${r.response?.headers ? ` + custom headers` : ""}`;
490
+ desc = `Static \u2014 <code>${status}</code>${r.response?.headers ? ` + headers` : ""}`;
454
491
  }
492
+ if (tags.length) desc += `&ensp;${tags.join("&ensp;")}`;
455
493
  return endpointRow(r.method?.toUpperCase() ?? "GET", fullPath, desc);
456
494
  }).join("")}
457
495
  </tbody></table>
@@ -603,7 +641,7 @@ function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map(
603
641
 
604
642
  <div class="banner">
605
643
  <div class="banner-inner">
606
- <h1><span class="y">y</span><span class="rest">rest</span></h1>
644
+ ${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>`}
607
645
  <p>Zero-config REST API mock server</p>
608
646
  <div class="banner-meta">
609
647
  <span>URL <strong>${host}</strong></span>
@@ -655,7 +693,7 @@ function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map(
655
693
  ${collections.length ? `<h2>Examples</h2><div class="card">${examplesBlock(collections, relations, base, host, options, customRoutes[0])}</div>` : ""}
656
694
 
657
695
  <footer>
658
- Powered by <a href="https://github.com/aggiovato/yaml-rest" target="_blank">@aggiovato/yrest</a> &nbsp;\xB7&nbsp; <a href="/_about">/_about</a>
696
+ Powered by <a href="https://github.com/aggiovato/yRest" target="_blank">@yrest/cli</a> &nbsp;\xB7&nbsp; <a href="/_about">/_about</a>
659
697
  </footer>
660
698
 
661
699
  </div>
@@ -686,6 +724,10 @@ function nextId(items) {
686
724
  const ids = items.map((i) => i["id"]).filter((id) => typeof id === "number");
687
725
  return ids.length > 0 ? Math.max(...ids) + 1 : 1;
688
726
  }
727
+ function generateId(items, strategy) {
728
+ if (strategy === "uuid") return crypto.randomUUID();
729
+ return nextId(items);
730
+ }
689
731
  function firstParam(value) {
690
732
  if (value === void 0) return void 0;
691
733
  return Array.isArray(value) ? value[0] : value;
@@ -773,10 +815,10 @@ function findById(items, id) {
773
815
  function findIndexById(items, id) {
774
816
  return items.findIndex((i) => String(i["id"]) === id);
775
817
  }
776
- function createItem(storage, resource, body) {
818
+ function createItem(storage, resource, body, idStrategy = "increment") {
777
819
  const collection = storage.getCollection(resource) ?? [];
778
820
  const item = {
779
- id: body["id"] !== void 0 ? body["id"] : nextId(collection),
821
+ id: body["id"] !== void 0 ? body["id"] : generateId(collection, idStrategy),
780
822
  ...body
781
823
  };
782
824
  storage.setCollection(resource, [...collection, item]);
@@ -954,13 +996,74 @@ var CollectionRouteCommand = class {
954
996
  if (!req.body || typeof req.body !== "object" || Array.isArray(req.body)) {
955
997
  return reply.status(400).send({ error: "Request body must be a JSON object" });
956
998
  }
957
- const item = createItem(this.storage, this.resource, req.body);
999
+ const item = createItem(
1000
+ this.storage,
1001
+ this.resource,
1002
+ req.body,
1003
+ this.options.idStrategy
1004
+ );
958
1005
  return reply.status(201).send(expandItems(item, req.query, this.resource, this.storage));
959
1006
  });
960
1007
  }
961
1008
  };
962
1009
 
1010
+ // src/utils/conditions.ts
1011
+ function resolveRequestPath(dotPath, req) {
1012
+ const [root, ...rest] = dotPath.split(".");
1013
+ let value;
1014
+ switch (root) {
1015
+ case "body":
1016
+ value = req.body;
1017
+ break;
1018
+ case "params":
1019
+ value = req.params;
1020
+ break;
1021
+ case "query":
1022
+ value = req.query;
1023
+ break;
1024
+ case "headers":
1025
+ value = req.headers;
1026
+ break;
1027
+ default:
1028
+ return void 0;
1029
+ }
1030
+ for (const key of rest) {
1031
+ if (value != null && typeof value === "object") {
1032
+ value = value[key];
1033
+ } else {
1034
+ return void 0;
1035
+ }
1036
+ }
1037
+ return value;
1038
+ }
1039
+ function matchConditionGroup(group, req) {
1040
+ return Object.entries(group).every(([key, expected]) => {
1041
+ const op = OPERATORS.find((o) => key.endsWith(o));
1042
+ if (op) {
1043
+ const path = key.slice(0, -op.length);
1044
+ const value2 = resolveRequestPath(path, req);
1045
+ if (value2 === void 0) return false;
1046
+ return applyOperator(value2, op, String(expected));
1047
+ }
1048
+ const value = resolveRequestPath(key, req);
1049
+ return String(value) === String(expected);
1050
+ });
1051
+ }
1052
+ function matchWhen(when, req) {
1053
+ if (Array.isArray(when)) {
1054
+ return when.some((group) => matchConditionGroup(group, req));
1055
+ }
1056
+ return matchConditionGroup(when, req);
1057
+ }
1058
+ function findMatchingScenario(scenarios, req) {
1059
+ return scenarios.find((s) => matchWhen(s.when, req));
1060
+ }
1061
+
963
1062
  // src/router/routes/custom.routes.ts
1063
+ function resolveBody(body, ctx) {
1064
+ if (body != null && hasTemplates(body)) return interpolate(body, ctx);
1065
+ return body ?? null;
1066
+ }
964
1067
  var CustomRouteCommand = class {
965
1068
  constructor(storage, base, handlers = /* @__PURE__ */ new Map()) {
966
1069
  this.storage = storage;
@@ -985,6 +1088,13 @@ var CustomRouteCommand = class {
985
1088
  method,
986
1089
  url,
987
1090
  handler: async (req, reply) => {
1091
+ if (route.delay && route.delay > 0) {
1092
+ await new Promise((resolve5) => setTimeout(resolve5, route.delay));
1093
+ }
1094
+ if (route.error) {
1095
+ const body2 = route.errorBody ?? { error: `Forced error ${route.error}` };
1096
+ return reply.status(route.error).send(body2);
1097
+ }
988
1098
  for (const [key, value] of Object.entries(headers)) {
989
1099
  reply.header(key, value);
990
1100
  }
@@ -994,13 +1104,13 @@ var CustomRouteCommand = class {
994
1104
  return reply.status(501).send({ error: `Handler "${handlerName}" is not defined in the handlers file` });
995
1105
  }
996
1106
  try {
997
- const ctx = {
1107
+ const ctx2 = {
998
1108
  params: req.params,
999
1109
  query: req.query,
1000
1110
  body: req.body,
1001
1111
  headers: req.headers
1002
1112
  };
1003
- const result = await fn(ctx);
1113
+ const result = await fn(ctx2);
1004
1114
  const resStatus = result.status ?? 200;
1005
1115
  for (const [k, v] of Object.entries(result.headers ?? {})) {
1006
1116
  reply.header(k, v);
@@ -1012,12 +1122,24 @@ var CustomRouteCommand = class {
1012
1122
  return reply.status(500).send({ error: `Handler "${handlerName}" threw an error: ${msg}` });
1013
1123
  }
1014
1124
  }
1015
- const body = dynamic ? interpolate(rawBody, {
1125
+ const ctx = {
1016
1126
  params: req.params,
1017
1127
  query: req.query,
1018
1128
  body: req.body,
1019
1129
  headers: req.headers
1020
- }) : rawBody;
1130
+ };
1131
+ if (route.scenarios?.length) {
1132
+ const matched = findMatchingScenario(route.scenarios, ctx);
1133
+ const active = matched?.response ?? route.otherwise;
1134
+ if (active) {
1135
+ const aStatus = active.status ?? 200;
1136
+ const aBody = resolveBody(active.body, ctx);
1137
+ for (const [k, v] of Object.entries(active.headers ?? {})) reply.header(k, v);
1138
+ if (!active.body && aStatus === 204) return reply.status(aStatus).send();
1139
+ return reply.status(aStatus).send(aBody);
1140
+ }
1141
+ }
1142
+ const body = dynamic ? interpolate(rawBody, ctx) : rawBody;
1021
1143
  if (body === null && status === 204) return reply.status(status).send();
1022
1144
  return reply.status(status).send(body);
1023
1145
  }
@@ -1269,15 +1391,22 @@ var yrestOptionsSchema = z.object({
1269
1391
  pageable: z.union([z.boolean(), z.coerce.number().int().positive()]).default(false).transform((v) => ({
1270
1392
  enabled: v !== false,
1271
1393
  limit: v === false || v === true ? 10 : v
1272
- }))
1394
+ })),
1395
+ /**
1396
+ * Strategy used to generate `id` values for new items when no `id` is provided in the body.
1397
+ *
1398
+ * - `"increment"` (default) — next integer above the current max id in the collection.
1399
+ * - `"uuid"` — a random UUID v4 string (`crypto.randomUUID()`).
1400
+ */
1401
+ idStrategy: z.enum(["increment", "uuid"]).default("increment")
1273
1402
  });
1274
1403
 
1275
1404
  // src/config/loadConfigFile.ts
1276
- import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
1405
+ import { existsSync as existsSync2, readFileSync as readFileSync3 } from "fs";
1277
1406
  import { parse as parse2 } from "yaml";
1278
1407
  function loadConfigFile(configPath) {
1279
1408
  if (!existsSync2(configPath)) return {};
1280
- const raw = readFileSync2(configPath, "utf8");
1409
+ const raw = readFileSync3(configPath, "utf8");
1281
1410
  return parse2(raw) ?? {};
1282
1411
  }
1283
1412
 
@@ -1314,8 +1443,12 @@ function registerServe(program2) {
1314
1443
  ).option(
1315
1444
  "--handlers <file>",
1316
1445
  "Path to a JavaScript file exporting custom route handler functions"
1446
+ ).option(
1447
+ "--id-strategy <strategy>",
1448
+ "Id generation strategy for new items: increment (default) or uuid",
1449
+ "increment"
1317
1450
  ).action(async (file, flags, cmd) => {
1318
- const fileConfig = loadConfigFile(join(process.cwd(), "yrest.config.yml"));
1451
+ const fileConfig = loadConfigFile(join2(process.cwd(), "yrest.config.yml"));
1319
1452
  const cliOverrides = Object.fromEntries(
1320
1453
  Object.entries(flags).filter(([key]) => cmd.getOptionValueSource(key) === "cli")
1321
1454
  );
@@ -1418,8 +1551,8 @@ function registerServe(program2) {
1418
1551
  }
1419
1552
 
1420
1553
  // src/cli/commands/handler.ts
1421
- import { existsSync as existsSync4, readFileSync as readFileSync3, appendFileSync, writeFileSync as writeFileSync3 } from "fs";
1422
- import { join as join2, resolve as resolve4, basename } from "path";
1554
+ import { existsSync as existsSync4, readFileSync as readFileSync4, appendFileSync, writeFileSync as writeFileSync3 } from "fs";
1555
+ import { join as join3, resolve as resolve4, basename } from "path";
1423
1556
  import { parse as parse3, stringify as stringify2 } from "yaml";
1424
1557
  var HANDLERS_FILE_HEADER = `// yrest handlers \u2014 loaded via "handlers:" in yrest.config.yml
1425
1558
  // Handler signature: (req: HandlerRequest) => HandlerResponse | Promise<HandlerResponse>
@@ -1446,7 +1579,7 @@ function registerHandler(program2) {
1446
1579
  "--register",
1447
1580
  "Also add a _routes entry to db.yml linking this handler to method + path"
1448
1581
  ).action((name, flags) => {
1449
- const fileConfig = loadConfigFile(join2(process.cwd(), "yrest.config.yml"));
1582
+ const fileConfig = loadConfigFile(join3(process.cwd(), "yrest.config.yml"));
1450
1583
  const handlersPath = resolve4(
1451
1584
  fileConfig.handlers ?? "yrest.handlers.js"
1452
1585
  );
@@ -1459,7 +1592,7 @@ function registerHandler(program2) {
1459
1592
  );
1460
1593
  console.log(` Created ${basename(handlersPath)}`);
1461
1594
  } else {
1462
- const existing = readFileSync3(handlersPath, "utf8");
1595
+ const existing = readFileSync4(handlersPath, "utf8");
1463
1596
  if (existing.includes(`function ${name}(`)) {
1464
1597
  console.error(` Error: handler "${name}" already exists in ${basename(handlersPath)}`);
1465
1598
  process.exit(1);
@@ -1476,7 +1609,7 @@ function registerHandler(program2) {
1476
1609
  console.error(` Error: database file not found at ${dbPath}`);
1477
1610
  process.exit(1);
1478
1611
  }
1479
- const raw = parse3(readFileSync3(dbPath, "utf8")) ?? {};
1612
+ const raw = parse3(readFileSync4(dbPath, "utf8")) ?? {};
1480
1613
  if (!Array.isArray(raw["_routes"])) raw["_routes"] = [];
1481
1614
  const routes = raw["_routes"];
1482
1615
  const alreadyRegistered = routes.some((r) => r["handler"] === name);
package/dist/index.d.mts CHANGED
@@ -21,19 +21,72 @@ type Data = Record<string, Resource[]>;
21
21
  * // GET /users/1/posts → returns posts where userId === "1"
22
22
  */
23
23
  type Relations = Record<string, Record<string, string>>;
24
+ /**
25
+ * A static response block shared by {@link CustomRoute} and {@link Scenario}.
26
+ */
27
+ type RouteResponse = {
28
+ /** HTTP status code. Defaults to `200` if omitted. */
29
+ status?: number;
30
+ /** Response body. Any YAML-serialisable value (object, array, string, number, null). */
31
+ body?: unknown;
32
+ /** Additional response headers to set alongside `Content-Type`. */
33
+ headers?: Record<string, string>;
34
+ };
35
+ /**
36
+ * A conditional response variant for a custom route.
37
+ *
38
+ * Evaluated in declaration order — the first scenario whose `when` conditions match wins.
39
+ * If none match, the route falls back to `otherwise:` (if defined) or `response:`.
40
+ *
41
+ * **`when` as an object** — all entries must match (AND):
42
+ * ```yaml
43
+ * when: { body.email: ana@test.com, body.password: secret }
44
+ * ```
45
+ *
46
+ * **`when` as an array** — any group must match (OR of ANDs):
47
+ * ```yaml
48
+ * when:
49
+ * - { body.role: admin }
50
+ * - { body.role: superadmin }
51
+ * ```
52
+ *
53
+ * Condition keys use dot-notation (`body.X`, `params.X`, `query.X`, `headers.X`).
54
+ * Field operator suffixes are supported: `_ne`, `_like`, `_start`, `_regex`, `_gte`, `_lte`.
55
+ * Response bodies support `{{}}` template variables (same as static routes).
56
+ */
57
+ type Scenario = {
58
+ /**
59
+ * Condition(s) to evaluate against the request.
60
+ * - Object → all entries AND
61
+ * - Array of objects → any group OR (each group is AND internally)
62
+ */
63
+ when: Record<string, unknown> | Record<string, unknown>[];
64
+ /** Response to return when the conditions match. Supports `{{}}` template variables. */
65
+ response: RouteResponse;
66
+ };
24
67
  /**
25
68
  * A single custom route declared under `_routes` in the YAML file.
26
69
  *
27
- * Custom routes are registered before resource routes and take priority over them.
28
- * They always return a static, pre-defined response regardless of the request body or params.
70
+ * Resolution priority per request:
71
+ * 1. `handler` function (if defined and found in the handlers file)
72
+ * 2. First matching `scenario` (evaluated in declaration order)
73
+ * 3. `otherwise` block (explicit fallback when scenarios are defined but none matched)
74
+ * 4. Static `response` block (final fallback)
29
75
  *
30
76
  * @example
31
77
  * // _routes:
32
78
  * // - method: POST
33
79
  * // path: /login
34
- * // response:
35
- * // status: 200
36
- * // body: { token: abc123 }
80
+ * // scenarios:
81
+ * // - when: { body.password: secret }
82
+ * // response: { status: 200, body: { token: real-tok } }
83
+ * // - when:
84
+ * // - { body.role: admin }
85
+ * // - { body.role: superadmin }
86
+ * // response: { status: 200, body: { token: admin-tok } }
87
+ * // otherwise:
88
+ * // status: 401
89
+ * // body: { error: Invalid credentials }
37
90
  */
38
91
  type CustomRoute = {
39
92
  /** HTTP method (case-insensitive: GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS). */
@@ -42,19 +95,36 @@ type CustomRoute = {
42
95
  path: string;
43
96
  /**
44
97
  * Name of an exported function in the handlers file (`handlers:` in config).
45
- * When set, the function is called on every request and its return value is used as the response.
46
- * Takes priority over `response:`. Falls back to `response:` if the name is not found.
98
+ * Takes priority over `scenarios` and `response`. Falls back to `response` if not found.
47
99
  */
48
100
  handler?: string;
49
- /** Static or template response. Used when `handler` is absent or not found in the handlers file. */
50
- response?: {
51
- /** HTTP status code. Defaults to `200` if omitted. */
52
- status?: number;
53
- /** Response body. Any YAML-serialisable value (object, array, string, number, null). */
54
- body?: unknown;
55
- /** Additional response headers to set alongside `Content-Type`. */
56
- headers?: Record<string, string>;
57
- };
101
+ /** Conditional response variants. Evaluated in order first match wins. */
102
+ scenarios?: Scenario[];
103
+ /**
104
+ * Explicit fallback response when `scenarios` are defined but none matched.
105
+ * Takes priority over `response` when present. Supports `{{}}` template variables.
106
+ */
107
+ otherwise?: RouteResponse;
108
+ /** Static or template response. Final fallback when no handler, scenario, or otherwise applies. */
109
+ response?: RouteResponse;
110
+ /**
111
+ * Per-route response delay in milliseconds. Overrides the global `--delay` option for this route.
112
+ * Applied before any response is sent, regardless of which path resolved the response.
113
+ */
114
+ delay?: number;
115
+ /**
116
+ * Forces this route to always return the given HTTP status code as an error,
117
+ * bypassing handlers, scenarios and the static response entirely.
118
+ * Applied after `delay:` so slow-error scenarios still work.
119
+ *
120
+ * @example
121
+ * // Always return 503
122
+ * error: 503
123
+ * errorBody: { message: "Service unavailable" }
124
+ */
125
+ error?: number;
126
+ /** Optional body to return alongside `error:`. Defaults to `{ error: "Forced error <status>" }`. */
127
+ errorBody?: unknown;
58
128
  };
59
129
  /**
60
130
  * In-memory store backed by a YAML file.
@@ -194,6 +264,13 @@ declare const yrestOptionsSchema: z.ZodObject<{
194
264
  enabled: boolean;
195
265
  limit: number;
196
266
  }, number | boolean | undefined>;
267
+ /**
268
+ * Strategy used to generate `id` values for new items when no `id` is provided in the body.
269
+ *
270
+ * - `"increment"` (default) — next integer above the current max id in the collection.
271
+ * - `"uuid"` — a random UUID v4 string (`crypto.randomUUID()`).
272
+ */
273
+ idStrategy: z.ZodDefault<z.ZodEnum<["increment", "uuid"]>>;
197
274
  }, "strip", z.ZodTypeAny, {
198
275
  file: string;
199
276
  port: number;
@@ -207,6 +284,7 @@ declare const yrestOptionsSchema: z.ZodObject<{
207
284
  enabled: boolean;
208
285
  limit: number;
209
286
  };
287
+ idStrategy: "increment" | "uuid";
210
288
  handlers?: string | undefined;
211
289
  }, {
212
290
  file: string;
@@ -219,6 +297,7 @@ declare const yrestOptionsSchema: z.ZodObject<{
219
297
  delay?: number | undefined;
220
298
  handlers?: string | undefined;
221
299
  pageable?: number | boolean | undefined;
300
+ idStrategy?: "increment" | "uuid" | undefined;
222
301
  }>;
223
302
  /**
224
303
  * Resolved server configuration after Zod validation and transformation.
package/dist/index.d.ts CHANGED
@@ -21,19 +21,72 @@ type Data = Record<string, Resource[]>;
21
21
  * // GET /users/1/posts → returns posts where userId === "1"
22
22
  */
23
23
  type Relations = Record<string, Record<string, string>>;
24
+ /**
25
+ * A static response block shared by {@link CustomRoute} and {@link Scenario}.
26
+ */
27
+ type RouteResponse = {
28
+ /** HTTP status code. Defaults to `200` if omitted. */
29
+ status?: number;
30
+ /** Response body. Any YAML-serialisable value (object, array, string, number, null). */
31
+ body?: unknown;
32
+ /** Additional response headers to set alongside `Content-Type`. */
33
+ headers?: Record<string, string>;
34
+ };
35
+ /**
36
+ * A conditional response variant for a custom route.
37
+ *
38
+ * Evaluated in declaration order — the first scenario whose `when` conditions match wins.
39
+ * If none match, the route falls back to `otherwise:` (if defined) or `response:`.
40
+ *
41
+ * **`when` as an object** — all entries must match (AND):
42
+ * ```yaml
43
+ * when: { body.email: ana@test.com, body.password: secret }
44
+ * ```
45
+ *
46
+ * **`when` as an array** — any group must match (OR of ANDs):
47
+ * ```yaml
48
+ * when:
49
+ * - { body.role: admin }
50
+ * - { body.role: superadmin }
51
+ * ```
52
+ *
53
+ * Condition keys use dot-notation (`body.X`, `params.X`, `query.X`, `headers.X`).
54
+ * Field operator suffixes are supported: `_ne`, `_like`, `_start`, `_regex`, `_gte`, `_lte`.
55
+ * Response bodies support `{{}}` template variables (same as static routes).
56
+ */
57
+ type Scenario = {
58
+ /**
59
+ * Condition(s) to evaluate against the request.
60
+ * - Object → all entries AND
61
+ * - Array of objects → any group OR (each group is AND internally)
62
+ */
63
+ when: Record<string, unknown> | Record<string, unknown>[];
64
+ /** Response to return when the conditions match. Supports `{{}}` template variables. */
65
+ response: RouteResponse;
66
+ };
24
67
  /**
25
68
  * A single custom route declared under `_routes` in the YAML file.
26
69
  *
27
- * Custom routes are registered before resource routes and take priority over them.
28
- * They always return a static, pre-defined response regardless of the request body or params.
70
+ * Resolution priority per request:
71
+ * 1. `handler` function (if defined and found in the handlers file)
72
+ * 2. First matching `scenario` (evaluated in declaration order)
73
+ * 3. `otherwise` block (explicit fallback when scenarios are defined but none matched)
74
+ * 4. Static `response` block (final fallback)
29
75
  *
30
76
  * @example
31
77
  * // _routes:
32
78
  * // - method: POST
33
79
  * // path: /login
34
- * // response:
35
- * // status: 200
36
- * // body: { token: abc123 }
80
+ * // scenarios:
81
+ * // - when: { body.password: secret }
82
+ * // response: { status: 200, body: { token: real-tok } }
83
+ * // - when:
84
+ * // - { body.role: admin }
85
+ * // - { body.role: superadmin }
86
+ * // response: { status: 200, body: { token: admin-tok } }
87
+ * // otherwise:
88
+ * // status: 401
89
+ * // body: { error: Invalid credentials }
37
90
  */
38
91
  type CustomRoute = {
39
92
  /** HTTP method (case-insensitive: GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS). */
@@ -42,19 +95,36 @@ type CustomRoute = {
42
95
  path: string;
43
96
  /**
44
97
  * Name of an exported function in the handlers file (`handlers:` in config).
45
- * When set, the function is called on every request and its return value is used as the response.
46
- * Takes priority over `response:`. Falls back to `response:` if the name is not found.
98
+ * Takes priority over `scenarios` and `response`. Falls back to `response` if not found.
47
99
  */
48
100
  handler?: string;
49
- /** Static or template response. Used when `handler` is absent or not found in the handlers file. */
50
- response?: {
51
- /** HTTP status code. Defaults to `200` if omitted. */
52
- status?: number;
53
- /** Response body. Any YAML-serialisable value (object, array, string, number, null). */
54
- body?: unknown;
55
- /** Additional response headers to set alongside `Content-Type`. */
56
- headers?: Record<string, string>;
57
- };
101
+ /** Conditional response variants. Evaluated in order first match wins. */
102
+ scenarios?: Scenario[];
103
+ /**
104
+ * Explicit fallback response when `scenarios` are defined but none matched.
105
+ * Takes priority over `response` when present. Supports `{{}}` template variables.
106
+ */
107
+ otherwise?: RouteResponse;
108
+ /** Static or template response. Final fallback when no handler, scenario, or otherwise applies. */
109
+ response?: RouteResponse;
110
+ /**
111
+ * Per-route response delay in milliseconds. Overrides the global `--delay` option for this route.
112
+ * Applied before any response is sent, regardless of which path resolved the response.
113
+ */
114
+ delay?: number;
115
+ /**
116
+ * Forces this route to always return the given HTTP status code as an error,
117
+ * bypassing handlers, scenarios and the static response entirely.
118
+ * Applied after `delay:` so slow-error scenarios still work.
119
+ *
120
+ * @example
121
+ * // Always return 503
122
+ * error: 503
123
+ * errorBody: { message: "Service unavailable" }
124
+ */
125
+ error?: number;
126
+ /** Optional body to return alongside `error:`. Defaults to `{ error: "Forced error <status>" }`. */
127
+ errorBody?: unknown;
58
128
  };
59
129
  /**
60
130
  * In-memory store backed by a YAML file.
@@ -194,6 +264,13 @@ declare const yrestOptionsSchema: z.ZodObject<{
194
264
  enabled: boolean;
195
265
  limit: number;
196
266
  }, number | boolean | undefined>;
267
+ /**
268
+ * Strategy used to generate `id` values for new items when no `id` is provided in the body.
269
+ *
270
+ * - `"increment"` (default) — next integer above the current max id in the collection.
271
+ * - `"uuid"` — a random UUID v4 string (`crypto.randomUUID()`).
272
+ */
273
+ idStrategy: z.ZodDefault<z.ZodEnum<["increment", "uuid"]>>;
197
274
  }, "strip", z.ZodTypeAny, {
198
275
  file: string;
199
276
  port: number;
@@ -207,6 +284,7 @@ declare const yrestOptionsSchema: z.ZodObject<{
207
284
  enabled: boolean;
208
285
  limit: number;
209
286
  };
287
+ idStrategy: "increment" | "uuid";
210
288
  handlers?: string | undefined;
211
289
  }, {
212
290
  file: string;
@@ -219,6 +297,7 @@ declare const yrestOptionsSchema: z.ZodObject<{
219
297
  delay?: number | undefined;
220
298
  handlers?: string | undefined;
221
299
  pageable?: number | boolean | undefined;
300
+ idStrategy?: "increment" | "uuid" | undefined;
222
301
  }>;
223
302
  /**
224
303
  * Resolved server configuration after Zod validation and transformation.