@aggiovato/yrest 0.2.1 → 0.3.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/dist/cli/index.js CHANGED
@@ -23,8 +23,13 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
23
23
  mod
24
24
  ));
25
25
 
26
+ // node_modules/tsup/assets/cjs_shims.js
27
+ var getImportMetaUrl = () => typeof document === "undefined" ? new URL(`file:${__filename}`).href : document.currentScript && document.currentScript.tagName.toUpperCase() === "SCRIPT" ? document.currentScript.src : new URL("main.js", document.baseURI).href;
28
+ var importMetaUrl = /* @__PURE__ */ getImportMetaUrl();
29
+
26
30
  // src/cli/index.ts
27
31
  var import_commander = require("commander");
32
+ var import_module = require("module");
28
33
 
29
34
  // src/cli/commands/init.ts
30
35
  var import_node_fs = require("fs");
@@ -101,6 +106,7 @@ host: localhost # Host to bind
101
106
  # readonly: false # Block write operations (POST, PUT, PATCH, DELETE)
102
107
  # delay: 0 # Simulated network latency in milliseconds
103
108
  # pageable: false # Wrap GET collections in { data, pagination }. Use true (limit 10) or a number
109
+ # snapshot: false # Save initial db state and expose /_snapshot endpoints (GET / POST save / POST reset)
104
110
  `;
105
111
  function registerInit(program2) {
106
112
  program2.command("init").description("Create a sample db.yml and yrest.config.yml in the current directory").option("-f, --file <name>", "Output filename", "db.yml").option("-s, --sample <name>", `Sample data to use (${SAMPLES.join(", ")})`, "basic").action((flags) => {
@@ -133,6 +139,11 @@ var import_node_fs2 = require("fs");
133
139
  var import_node_path2 = require("path");
134
140
  var import_node_crypto = require("crypto");
135
141
  var import_yaml = require("yaml");
142
+ function deepCopyData(source) {
143
+ return Object.fromEntries(
144
+ Object.entries(source).map(([k, v]) => [k, v.map((item) => ({ ...item }))])
145
+ );
146
+ }
136
147
  function createYamlStorage(filePath) {
137
148
  const absPath = (0, import_node_path2.resolve)(filePath);
138
149
  const raw = (0, import_yaml.parse)((0, import_node_fs2.readFileSync)(absPath, "utf8")) ?? {};
@@ -140,6 +151,11 @@ function createYamlStorage(filePath) {
140
151
  const data = Object.fromEntries(
141
152
  Object.entries(raw).filter(([key]) => key !== "_rel")
142
153
  );
154
+ let snapshot = {
155
+ data: deepCopyData(data),
156
+ relations: { ...relations },
157
+ savedAt: /* @__PURE__ */ new Date()
158
+ };
143
159
  return {
144
160
  getData() {
145
161
  return data;
@@ -169,6 +185,24 @@ function createYamlStorage(filePath) {
169
185
  Object.assign(data, freshData);
170
186
  for (const key of Object.keys(relations)) delete relations[key];
171
187
  Object.assign(relations, freshRelations);
188
+ },
189
+ getSnapshot() {
190
+ return snapshot;
191
+ },
192
+ saveSnapshot() {
193
+ snapshot = {
194
+ data: deepCopyData(data),
195
+ relations: { ...relations },
196
+ savedAt: /* @__PURE__ */ new Date()
197
+ };
198
+ },
199
+ resetToSnapshot() {
200
+ const snap = deepCopyData(snapshot.data);
201
+ for (const key of Object.keys(data)) delete data[key];
202
+ Object.assign(data, snap);
203
+ for (const key of Object.keys(relations)) delete relations[key];
204
+ Object.assign(relations, { ...snapshot.relations });
205
+ this.persist();
172
206
  }
173
207
  };
174
208
  }
@@ -334,6 +368,9 @@ var registerCollectionRoutes = (server, storage, resource, base, options) => {
334
368
  return expandItems(result, req.query, resource, storage);
335
369
  });
336
370
  server.post(base, (req, reply) => {
371
+ if (!req.body || typeof req.body !== "object" || Array.isArray(req.body)) {
372
+ return reply.status(400).send({ error: "Request body must be a JSON object" });
373
+ }
337
374
  const item = createItem(storage, resource, req.body);
338
375
  return reply.status(201).send(expandItems(item, req.query, resource, storage));
339
376
  });
@@ -347,11 +384,17 @@ var registerItemRoutes = (server, storage, resource, base) => {
347
384
  return expandItems(item, req.query, resource, storage);
348
385
  });
349
386
  server.put(`${base}/:id`, (req, reply) => {
387
+ if (!req.body || typeof req.body !== "object" || Array.isArray(req.body)) {
388
+ return reply.status(400).send({ error: "Request body must be a JSON object" });
389
+ }
350
390
  const item = replaceItem(storage, resource, req.params.id, req.body);
351
391
  if (!item) return reply.status(404).send({ error: "Not found" });
352
392
  return expandItems(item, req.query, resource, storage);
353
393
  });
354
394
  server.patch(`${base}/:id`, (req, reply) => {
395
+ if (!req.body || typeof req.body !== "object" || Array.isArray(req.body)) {
396
+ return reply.status(400).send({ error: "Request body must be a JSON object" });
397
+ }
355
398
  const item = patchItem(storage, resource, req.params.id, req.body);
356
399
  if (!item) return reply.status(404).send({ error: "Not found" });
357
400
  return expandItems(item, req.query, resource, storage);
@@ -367,7 +410,9 @@ var registerItemRoutes = (server, storage, resource, base) => {
367
410
  var registerNestedRoutes = (server, storage, relations, base) => {
368
411
  for (const [child, fields] of Object.entries(relations)) {
369
412
  for (const [field, parent] of Object.entries(fields)) {
370
- server.get(`${base}/${parent}/:id/${child}`, (req, reply) => {
413
+ const collectionPath = `${base}/${parent}/:id/${child}`;
414
+ const itemPath = `${base}/${parent}/:id/${child}/:childId`;
415
+ server.get(collectionPath, (req, reply) => {
371
416
  const parentCollection = storage.getCollection(parent) ?? [];
372
417
  const parentItem = findById(parentCollection, req.params.id);
373
418
  if (!parentItem) return reply.status(404).send({ error: "Not found" });
@@ -376,6 +421,16 @@ var registerNestedRoutes = (server, storage, relations, base) => {
376
421
  );
377
422
  return children;
378
423
  });
424
+ server.get(itemPath, (req, reply) => {
425
+ const parentCollection = storage.getCollection(parent) ?? [];
426
+ const parentItem = findById(parentCollection, req.params.id);
427
+ if (!parentItem) return reply.status(404).send({ error: "Not found" });
428
+ const childItem = (storage.getCollection(child) ?? []).find(
429
+ (item) => String(item[field]) === req.params.id && String(item["id"]) === req.params.childId
430
+ );
431
+ if (!childItem) return reply.status(404).send({ error: "Not found" });
432
+ return childItem;
433
+ });
379
434
  }
380
435
  }
381
436
  };
@@ -500,6 +555,14 @@ curl ${host}${base}/${parent}/1/${child}`);
500
555
  examples.push(`# Pageable envelope
501
556
  curl "${host}${base}/${firstCol}?_page=2"`);
502
557
  }
558
+ if (options.snapshot) {
559
+ examples.push(
560
+ `# Snapshot endpoints
561
+ curl ${host}/_snapshot
562
+ curl -X POST ${host}/_snapshot/save
563
+ curl -X POST ${host}/_snapshot/reset`
564
+ );
565
+ }
503
566
  const highlighted = examples.map((e) => e.replace(/^(#.+)$/gm, '<span class="cm">$1</span>')).join("\n\n");
504
567
  return `<pre>${highlighted}</pre>`;
505
568
  }
@@ -514,6 +577,7 @@ function generateAboutHtml(storage, options) {
514
577
  if (options.delay > 0) modes.push(badge(`delay \xB7 ${options.delay}ms`, "#fb923c", "#fb923c18"));
515
578
  if (options.pageable.enabled)
516
579
  modes.push(badge(`pageable \xB7 limit ${options.pageable.limit}`, "#34d399", "#34d39918"));
580
+ if (options.snapshot) modes.push(badge("snapshot", "#c084fc", "#c084fc18"));
517
581
  const accordions = collections.map((col, i) => resourceAccordion(col, base, i === 0)).join("");
518
582
  const nestedRows = [];
519
583
  for (const [child, fields] of Object.entries(relations)) {
@@ -533,6 +597,18 @@ function generateAboutHtml(storage, options) {
533
597
  </summary>
534
598
  <table><tbody>${nestedRows.join("")}</tbody></table>
535
599
  </details>` : "";
600
+ const snapshotAccordion = options.snapshot ? `
601
+ <details class="resource-card nested-card">
602
+ <summary>
603
+ <span class="resource-name">/_snapshot</span>
604
+ <span class="route-count">3 routes</span>
605
+ </summary>
606
+ <table><tbody>
607
+ ${endpointRow("GET", "/_snapshot", "Returns metadata of the current snapshot: <code>savedAt</code> and item counts per collection.")}
608
+ ${endpointRow("POST", "/_snapshot/save", "Replaces the stored snapshot with the current database state.")}
609
+ ${endpointRow("POST", "/_snapshot/reset", "Restores the database to the last saved snapshot and persists to disk.")}
610
+ </tbody></table>
611
+ </details>` : "";
536
612
  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.`;
537
613
  return `<!DOCTYPE html>
538
614
  <html lang="en">
@@ -681,6 +757,7 @@ function generateAboutHtml(storage, options) {
681
757
  <div class="endpoints-grid">
682
758
  ${accordions}
683
759
  ${nestedAccordion}
760
+ ${snapshotAccordion}
684
761
  </div>
685
762
 
686
763
  <h2>Query Parameters</h2>
@@ -715,11 +792,51 @@ function registerAboutRoute(server, storage, options) {
715
792
  });
716
793
  }
717
794
 
795
+ // src/router/routes/snapshot.routes.ts
796
+ function registerSnapshotRoutes(server, storage) {
797
+ server.get("/_snapshot", (_req, reply) => {
798
+ const { data, savedAt } = storage.getSnapshot();
799
+ return reply.send({
800
+ savedAt: savedAt.toISOString(),
801
+ collections: Object.fromEntries(
802
+ Object.entries(data).map(([name, items]) => [name, items.length])
803
+ )
804
+ });
805
+ });
806
+ server.post("/_snapshot/save", (_req, reply) => {
807
+ storage.saveSnapshot();
808
+ const { data, savedAt } = storage.getSnapshot();
809
+ return reply.send({
810
+ message: "Snapshot saved",
811
+ savedAt: savedAt.toISOString(),
812
+ collections: Object.fromEntries(
813
+ Object.entries(data).map(([name, items]) => [name, items.length])
814
+ )
815
+ });
816
+ });
817
+ server.post("/_snapshot/reset", (_req, reply) => {
818
+ storage.resetToSnapshot();
819
+ const { data, savedAt } = storage.getSnapshot();
820
+ return reply.send({
821
+ message: "Database restored to snapshot",
822
+ savedAt: savedAt.toISOString(),
823
+ collections: Object.fromEntries(
824
+ Object.entries(data).map(([name, items]) => [name, items.length])
825
+ )
826
+ });
827
+ });
828
+ }
829
+
718
830
  // src/server/createServer.ts
719
831
  var MUTATING_METHODS = /* @__PURE__ */ new Set(["POST", "PUT", "PATCH", "DELETE"]);
720
832
  async function createServer(storage, options) {
721
833
  const server = (0, import_fastify.default)();
722
834
  await server.register(import_cors.default);
835
+ server.setErrorHandler((err, _req, reply) => {
836
+ const status = err.statusCode ?? 500;
837
+ const message = status < 500 ? err.message || "Request error" : "Internal server error";
838
+ reply.status(status).send({ error: message });
839
+ });
723
840
  if (options.readonly) {
724
841
  server.addHook("onRequest", (_req, reply, done) => {
725
842
  if (MUTATING_METHODS.has(_req.method)) {
@@ -734,6 +851,7 @@ async function createServer(storage, options) {
734
851
  });
735
852
  }
736
853
  registerAboutRoute(server, storage, options);
854
+ if (options.snapshot) registerSnapshotRoutes(server, storage);
737
855
  registerResourceRoutes(server, storage, options);
738
856
  return server;
739
857
  }
@@ -754,6 +872,11 @@ var serverOptionsSchema = import_zod.z.object({
754
872
  base: import_zod.z.string().default("").transform((v) => v && !v.startsWith("/") ? `/${v}` : v),
755
873
  /** When `true`, the server reloads the YAML file automatically on disk changes. */
756
874
  watch: import_zod.z.boolean().default(false),
875
+ /**
876
+ * When `true`, saves a snapshot of the initial database state on startup.
877
+ * Exposes `/_snapshot` endpoints to inspect, restore or update the snapshot.
878
+ */
879
+ snapshot: import_zod.z.boolean().default(false),
757
880
  /** When `true`, all mutating requests (POST, PUT, PATCH, DELETE) are rejected with 405. */
758
881
  readonly: import_zod.z.boolean().default(false),
759
882
  /** Milliseconds to delay every response, simulating network latency. `0` = disabled. */
@@ -786,6 +909,9 @@ function registerServe(program2) {
786
909
  ).option(
787
910
  "--pageable [limit]",
788
911
  "Wrap GET collection responses in { data, pagination } envelope. Optionally set default page size (default: 10)"
912
+ ).option(
913
+ "--snapshot",
914
+ "Save a snapshot of the initial database state and expose /_snapshot endpoints"
789
915
  ).action(async (file, flags, cmd) => {
790
916
  const fileConfig = loadConfigFile((0, import_node_path3.join)(process.cwd(), "yrest.config.yml"));
791
917
  const cliOverrides = Object.fromEntries(
@@ -798,7 +924,14 @@ function registerServe(program2) {
798
924
  ...cliOverrides
799
925
  };
800
926
  const options = serverOptionsSchema.parse(merged);
801
- const storage = createYamlStorage(options.file);
927
+ let storage;
928
+ try {
929
+ storage = createYamlStorage(options.file);
930
+ } catch (err) {
931
+ const msg = err instanceof Error ? err.message : String(err);
932
+ console.error(`Error: cannot load "${options.file}" \u2014 ${msg}`);
933
+ process.exit(1);
934
+ }
802
935
  const server = await createServer(storage, options);
803
936
  await server.listen({ port: options.port, host: options.host });
804
937
  const collections = Object.keys(storage.getData());
@@ -811,6 +944,8 @@ yrest running at http://${options.host}:${options.port}`);
811
944
  console.log(
812
945
  `[pageable] responses wrapped in { data, pagination } (limit: ${options.pageable.limit})`
813
946
  );
947
+ if (options.snapshot)
948
+ console.log("[snapshot] /_snapshot endpoints enabled \u2014 GET / POST save / POST reset");
814
949
  console.log(`
815
950
  Resources (base: ${baseLabel}):`);
816
951
  for (const name of collections) {
@@ -827,8 +962,9 @@ Resources (base: ${baseLabel}):`);
827
962
  try {
828
963
  storage.reload();
829
964
  console.log(`[watch] reloaded ${options.file}`);
830
- } catch {
831
- console.error(`[watch] failed to reload ${options.file} \u2014 check YAML syntax`);
965
+ } catch (err) {
966
+ const msg = err instanceof Error ? err.message : String(err);
967
+ console.error(`[watch] failed to reload ${options.file} \u2014 ${msg}`);
832
968
  }
833
969
  }, 100);
834
970
  });
@@ -839,7 +975,9 @@ Resources (base: ${baseLabel}):`);
839
975
  }
840
976
 
841
977
  // src/cli/index.ts
842
- import_commander.program.name("yrest").description("Zero-config REST API mock server powered by a YAML file").version("0.1.0");
978
+ var require2 = (0, import_module.createRequire)(importMetaUrl);
979
+ var { version } = require2("../../package.json");
980
+ import_commander.program.name("yrest").description("Zero-config REST API mock server powered by a YAML file").version(version);
843
981
  registerInit(import_commander.program);
844
982
  registerServe(import_commander.program);
845
983
  import_commander.program.parse();
@@ -2,6 +2,7 @@
2
2
 
3
3
  // src/cli/index.ts
4
4
  import { program } from "commander";
5
+ import { createRequire } from "module";
5
6
 
6
7
  // src/cli/commands/init.ts
7
8
  import { existsSync, writeFileSync } from "fs";
@@ -78,6 +79,7 @@ host: localhost # Host to bind
78
79
  # readonly: false # Block write operations (POST, PUT, PATCH, DELETE)
79
80
  # delay: 0 # Simulated network latency in milliseconds
80
81
  # pageable: false # Wrap GET collections in { data, pagination }. Use true (limit 10) or a number
82
+ # snapshot: false # Save initial db state and expose /_snapshot endpoints (GET / POST save / POST reset)
81
83
  `;
82
84
  function registerInit(program2) {
83
85
  program2.command("init").description("Create a sample db.yml and yrest.config.yml in the current directory").option("-f, --file <name>", "Output filename", "db.yml").option("-s, --sample <name>", `Sample data to use (${SAMPLES.join(", ")})`, "basic").action((flags) => {
@@ -110,6 +112,11 @@ import { readFileSync, writeFileSync as writeFileSync2, renameSync } from "fs";
110
112
  import { resolve as resolve2, dirname } from "path";
111
113
  import { randomUUID } from "crypto";
112
114
  import { parse, stringify } from "yaml";
115
+ function deepCopyData(source) {
116
+ return Object.fromEntries(
117
+ Object.entries(source).map(([k, v]) => [k, v.map((item) => ({ ...item }))])
118
+ );
119
+ }
113
120
  function createYamlStorage(filePath) {
114
121
  const absPath = resolve2(filePath);
115
122
  const raw = parse(readFileSync(absPath, "utf8")) ?? {};
@@ -117,6 +124,11 @@ function createYamlStorage(filePath) {
117
124
  const data = Object.fromEntries(
118
125
  Object.entries(raw).filter(([key]) => key !== "_rel")
119
126
  );
127
+ let snapshot = {
128
+ data: deepCopyData(data),
129
+ relations: { ...relations },
130
+ savedAt: /* @__PURE__ */ new Date()
131
+ };
120
132
  return {
121
133
  getData() {
122
134
  return data;
@@ -146,6 +158,24 @@ function createYamlStorage(filePath) {
146
158
  Object.assign(data, freshData);
147
159
  for (const key of Object.keys(relations)) delete relations[key];
148
160
  Object.assign(relations, freshRelations);
161
+ },
162
+ getSnapshot() {
163
+ return snapshot;
164
+ },
165
+ saveSnapshot() {
166
+ snapshot = {
167
+ data: deepCopyData(data),
168
+ relations: { ...relations },
169
+ savedAt: /* @__PURE__ */ new Date()
170
+ };
171
+ },
172
+ resetToSnapshot() {
173
+ const snap = deepCopyData(snapshot.data);
174
+ for (const key of Object.keys(data)) delete data[key];
175
+ Object.assign(data, snap);
176
+ for (const key of Object.keys(relations)) delete relations[key];
177
+ Object.assign(relations, { ...snapshot.relations });
178
+ this.persist();
149
179
  }
150
180
  };
151
181
  }
@@ -311,6 +341,9 @@ var registerCollectionRoutes = (server, storage, resource, base, options) => {
311
341
  return expandItems(result, req.query, resource, storage);
312
342
  });
313
343
  server.post(base, (req, reply) => {
344
+ if (!req.body || typeof req.body !== "object" || Array.isArray(req.body)) {
345
+ return reply.status(400).send({ error: "Request body must be a JSON object" });
346
+ }
314
347
  const item = createItem(storage, resource, req.body);
315
348
  return reply.status(201).send(expandItems(item, req.query, resource, storage));
316
349
  });
@@ -324,11 +357,17 @@ var registerItemRoutes = (server, storage, resource, base) => {
324
357
  return expandItems(item, req.query, resource, storage);
325
358
  });
326
359
  server.put(`${base}/:id`, (req, reply) => {
360
+ if (!req.body || typeof req.body !== "object" || Array.isArray(req.body)) {
361
+ return reply.status(400).send({ error: "Request body must be a JSON object" });
362
+ }
327
363
  const item = replaceItem(storage, resource, req.params.id, req.body);
328
364
  if (!item) return reply.status(404).send({ error: "Not found" });
329
365
  return expandItems(item, req.query, resource, storage);
330
366
  });
331
367
  server.patch(`${base}/:id`, (req, reply) => {
368
+ if (!req.body || typeof req.body !== "object" || Array.isArray(req.body)) {
369
+ return reply.status(400).send({ error: "Request body must be a JSON object" });
370
+ }
332
371
  const item = patchItem(storage, resource, req.params.id, req.body);
333
372
  if (!item) return reply.status(404).send({ error: "Not found" });
334
373
  return expandItems(item, req.query, resource, storage);
@@ -344,7 +383,9 @@ var registerItemRoutes = (server, storage, resource, base) => {
344
383
  var registerNestedRoutes = (server, storage, relations, base) => {
345
384
  for (const [child, fields] of Object.entries(relations)) {
346
385
  for (const [field, parent] of Object.entries(fields)) {
347
- server.get(`${base}/${parent}/:id/${child}`, (req, reply) => {
386
+ const collectionPath = `${base}/${parent}/:id/${child}`;
387
+ const itemPath = `${base}/${parent}/:id/${child}/:childId`;
388
+ server.get(collectionPath, (req, reply) => {
348
389
  const parentCollection = storage.getCollection(parent) ?? [];
349
390
  const parentItem = findById(parentCollection, req.params.id);
350
391
  if (!parentItem) return reply.status(404).send({ error: "Not found" });
@@ -353,6 +394,16 @@ var registerNestedRoutes = (server, storage, relations, base) => {
353
394
  );
354
395
  return children;
355
396
  });
397
+ server.get(itemPath, (req, reply) => {
398
+ const parentCollection = storage.getCollection(parent) ?? [];
399
+ const parentItem = findById(parentCollection, req.params.id);
400
+ if (!parentItem) return reply.status(404).send({ error: "Not found" });
401
+ const childItem = (storage.getCollection(child) ?? []).find(
402
+ (item) => String(item[field]) === req.params.id && String(item["id"]) === req.params.childId
403
+ );
404
+ if (!childItem) return reply.status(404).send({ error: "Not found" });
405
+ return childItem;
406
+ });
356
407
  }
357
408
  }
358
409
  };
@@ -477,6 +528,14 @@ curl ${host}${base}/${parent}/1/${child}`);
477
528
  examples.push(`# Pageable envelope
478
529
  curl "${host}${base}/${firstCol}?_page=2"`);
479
530
  }
531
+ if (options.snapshot) {
532
+ examples.push(
533
+ `# Snapshot endpoints
534
+ curl ${host}/_snapshot
535
+ curl -X POST ${host}/_snapshot/save
536
+ curl -X POST ${host}/_snapshot/reset`
537
+ );
538
+ }
480
539
  const highlighted = examples.map((e) => e.replace(/^(#.+)$/gm, '<span class="cm">$1</span>')).join("\n\n");
481
540
  return `<pre>${highlighted}</pre>`;
482
541
  }
@@ -491,6 +550,7 @@ function generateAboutHtml(storage, options) {
491
550
  if (options.delay > 0) modes.push(badge(`delay \xB7 ${options.delay}ms`, "#fb923c", "#fb923c18"));
492
551
  if (options.pageable.enabled)
493
552
  modes.push(badge(`pageable \xB7 limit ${options.pageable.limit}`, "#34d399", "#34d39918"));
553
+ if (options.snapshot) modes.push(badge("snapshot", "#c084fc", "#c084fc18"));
494
554
  const accordions = collections.map((col, i) => resourceAccordion(col, base, i === 0)).join("");
495
555
  const nestedRows = [];
496
556
  for (const [child, fields] of Object.entries(relations)) {
@@ -510,6 +570,18 @@ function generateAboutHtml(storage, options) {
510
570
  </summary>
511
571
  <table><tbody>${nestedRows.join("")}</tbody></table>
512
572
  </details>` : "";
573
+ const snapshotAccordion = options.snapshot ? `
574
+ <details class="resource-card nested-card">
575
+ <summary>
576
+ <span class="resource-name">/_snapshot</span>
577
+ <span class="route-count">3 routes</span>
578
+ </summary>
579
+ <table><tbody>
580
+ ${endpointRow("GET", "/_snapshot", "Returns metadata of the current snapshot: <code>savedAt</code> and item counts per collection.")}
581
+ ${endpointRow("POST", "/_snapshot/save", "Replaces the stored snapshot with the current database state.")}
582
+ ${endpointRow("POST", "/_snapshot/reset", "Restores the database to the last saved snapshot and persists to disk.")}
583
+ </tbody></table>
584
+ </details>` : "";
513
585
  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.`;
514
586
  return `<!DOCTYPE html>
515
587
  <html lang="en">
@@ -658,6 +730,7 @@ function generateAboutHtml(storage, options) {
658
730
  <div class="endpoints-grid">
659
731
  ${accordions}
660
732
  ${nestedAccordion}
733
+ ${snapshotAccordion}
661
734
  </div>
662
735
 
663
736
  <h2>Query Parameters</h2>
@@ -692,11 +765,51 @@ function registerAboutRoute(server, storage, options) {
692
765
  });
693
766
  }
694
767
 
768
+ // src/router/routes/snapshot.routes.ts
769
+ function registerSnapshotRoutes(server, storage) {
770
+ server.get("/_snapshot", (_req, reply) => {
771
+ const { data, savedAt } = storage.getSnapshot();
772
+ return reply.send({
773
+ savedAt: savedAt.toISOString(),
774
+ collections: Object.fromEntries(
775
+ Object.entries(data).map(([name, items]) => [name, items.length])
776
+ )
777
+ });
778
+ });
779
+ server.post("/_snapshot/save", (_req, reply) => {
780
+ storage.saveSnapshot();
781
+ const { data, savedAt } = storage.getSnapshot();
782
+ return reply.send({
783
+ message: "Snapshot saved",
784
+ savedAt: savedAt.toISOString(),
785
+ collections: Object.fromEntries(
786
+ Object.entries(data).map(([name, items]) => [name, items.length])
787
+ )
788
+ });
789
+ });
790
+ server.post("/_snapshot/reset", (_req, reply) => {
791
+ storage.resetToSnapshot();
792
+ const { data, savedAt } = storage.getSnapshot();
793
+ return reply.send({
794
+ message: "Database restored to snapshot",
795
+ savedAt: savedAt.toISOString(),
796
+ collections: Object.fromEntries(
797
+ Object.entries(data).map(([name, items]) => [name, items.length])
798
+ )
799
+ });
800
+ });
801
+ }
802
+
695
803
  // src/server/createServer.ts
696
804
  var MUTATING_METHODS = /* @__PURE__ */ new Set(["POST", "PUT", "PATCH", "DELETE"]);
697
805
  async function createServer(storage, options) {
698
806
  const server = Fastify();
699
807
  await server.register(cors);
808
+ server.setErrorHandler((err, _req, reply) => {
809
+ const status = err.statusCode ?? 500;
810
+ const message = status < 500 ? err.message || "Request error" : "Internal server error";
811
+ reply.status(status).send({ error: message });
812
+ });
700
813
  if (options.readonly) {
701
814
  server.addHook("onRequest", (_req, reply, done) => {
702
815
  if (MUTATING_METHODS.has(_req.method)) {
@@ -711,6 +824,7 @@ async function createServer(storage, options) {
711
824
  });
712
825
  }
713
826
  registerAboutRoute(server, storage, options);
827
+ if (options.snapshot) registerSnapshotRoutes(server, storage);
714
828
  registerResourceRoutes(server, storage, options);
715
829
  return server;
716
830
  }
@@ -731,6 +845,11 @@ var serverOptionsSchema = z.object({
731
845
  base: z.string().default("").transform((v) => v && !v.startsWith("/") ? `/${v}` : v),
732
846
  /** When `true`, the server reloads the YAML file automatically on disk changes. */
733
847
  watch: z.boolean().default(false),
848
+ /**
849
+ * When `true`, saves a snapshot of the initial database state on startup.
850
+ * Exposes `/_snapshot` endpoints to inspect, restore or update the snapshot.
851
+ */
852
+ snapshot: z.boolean().default(false),
734
853
  /** When `true`, all mutating requests (POST, PUT, PATCH, DELETE) are rejected with 405. */
735
854
  readonly: z.boolean().default(false),
736
855
  /** Milliseconds to delay every response, simulating network latency. `0` = disabled. */
@@ -763,6 +882,9 @@ function registerServe(program2) {
763
882
  ).option(
764
883
  "--pageable [limit]",
765
884
  "Wrap GET collection responses in { data, pagination } envelope. Optionally set default page size (default: 10)"
885
+ ).option(
886
+ "--snapshot",
887
+ "Save a snapshot of the initial database state and expose /_snapshot endpoints"
766
888
  ).action(async (file, flags, cmd) => {
767
889
  const fileConfig = loadConfigFile(join(process.cwd(), "yrest.config.yml"));
768
890
  const cliOverrides = Object.fromEntries(
@@ -775,7 +897,14 @@ function registerServe(program2) {
775
897
  ...cliOverrides
776
898
  };
777
899
  const options = serverOptionsSchema.parse(merged);
778
- const storage = createYamlStorage(options.file);
900
+ let storage;
901
+ try {
902
+ storage = createYamlStorage(options.file);
903
+ } catch (err) {
904
+ const msg = err instanceof Error ? err.message : String(err);
905
+ console.error(`Error: cannot load "${options.file}" \u2014 ${msg}`);
906
+ process.exit(1);
907
+ }
779
908
  const server = await createServer(storage, options);
780
909
  await server.listen({ port: options.port, host: options.host });
781
910
  const collections = Object.keys(storage.getData());
@@ -788,6 +917,8 @@ yrest running at http://${options.host}:${options.port}`);
788
917
  console.log(
789
918
  `[pageable] responses wrapped in { data, pagination } (limit: ${options.pageable.limit})`
790
919
  );
920
+ if (options.snapshot)
921
+ console.log("[snapshot] /_snapshot endpoints enabled \u2014 GET / POST save / POST reset");
791
922
  console.log(`
792
923
  Resources (base: ${baseLabel}):`);
793
924
  for (const name of collections) {
@@ -804,8 +935,9 @@ Resources (base: ${baseLabel}):`);
804
935
  try {
805
936
  storage.reload();
806
937
  console.log(`[watch] reloaded ${options.file}`);
807
- } catch {
808
- console.error(`[watch] failed to reload ${options.file} \u2014 check YAML syntax`);
938
+ } catch (err) {
939
+ const msg = err instanceof Error ? err.message : String(err);
940
+ console.error(`[watch] failed to reload ${options.file} \u2014 ${msg}`);
809
941
  }
810
942
  }, 100);
811
943
  });
@@ -816,7 +948,9 @@ Resources (base: ${baseLabel}):`);
816
948
  }
817
949
 
818
950
  // src/cli/index.ts
819
- program.name("yrest").description("Zero-config REST API mock server powered by a YAML file").version("0.1.0");
951
+ var require2 = createRequire(import.meta.url);
952
+ var { version } = require2("../../package.json");
953
+ program.name("yrest").description("Zero-config REST API mock server powered by a YAML file").version(version);
820
954
  registerInit(program);
821
955
  registerServe(program);
822
956
  program.parse();
package/dist/index.d.mts CHANGED
@@ -67,16 +67,28 @@ interface YamlStorage {
67
67
  * @throws {Error} If the file cannot be read or the YAML is malformed.
68
68
  */
69
69
  reload(): void;
70
+ /**
71
+ * Returns the saved snapshot: a frozen copy of `data` and `relations` taken
72
+ * at the last call to {@link saveSnapshot} (or at construction time).
73
+ */
74
+ getSnapshot(): {
75
+ data: Data;
76
+ relations: Relations;
77
+ savedAt: Date;
78
+ };
79
+ /**
80
+ * Replaces the stored snapshot with a deep copy of the current in-memory state.
81
+ * Future calls to {@link resetToSnapshot} will restore to this point.
82
+ */
83
+ saveSnapshot(): void;
84
+ /**
85
+ * Restores the in-memory state to the last saved snapshot and persists to disk.
86
+ * Mutates `data` and `relations` in place so existing references stay valid.
87
+ *
88
+ * @throws {Error} If the filesystem write fails.
89
+ */
90
+ resetToSnapshot(): void;
70
91
  }
71
- /**
72
- * Creates a {@link YamlStorage} instance backed by the given YAML file.
73
- *
74
- * The file is read and parsed eagerly on construction. The `_rel` key is
75
- * extracted as relational metadata; all other top-level keys become collections.
76
- *
77
- * @param filePath - Relative or absolute path to the YAML database file.
78
- * @throws {Error} If the file cannot be read or its YAML is invalid.
79
- */
80
92
  declare function createYamlStorage(filePath: string): YamlStorage;
81
93
 
82
94
  /**
@@ -99,6 +111,11 @@ declare const serverOptionsSchema: z.ZodObject<{
99
111
  base: z.ZodEffects<z.ZodDefault<z.ZodString>, string, string | undefined>;
100
112
  /** When `true`, the server reloads the YAML file automatically on disk changes. */
101
113
  watch: z.ZodDefault<z.ZodBoolean>;
114
+ /**
115
+ * When `true`, saves a snapshot of the initial database state on startup.
116
+ * Exposes `/_snapshot` endpoints to inspect, restore or update the snapshot.
117
+ */
118
+ snapshot: z.ZodDefault<z.ZodBoolean>;
102
119
  /** When `true`, all mutating requests (POST, PUT, PATCH, DELETE) are rejected with 405. */
103
120
  readonly: z.ZodDefault<z.ZodBoolean>;
104
121
  /** Milliseconds to delay every response, simulating network latency. `0` = disabled. */
@@ -117,6 +134,7 @@ declare const serverOptionsSchema: z.ZodObject<{
117
134
  host: string;
118
135
  base: string;
119
136
  watch: boolean;
137
+ snapshot: boolean;
120
138
  readonly: boolean;
121
139
  delay: number;
122
140
  pageable: {
@@ -129,6 +147,7 @@ declare const serverOptionsSchema: z.ZodObject<{
129
147
  host?: string | undefined;
130
148
  base?: string | undefined;
131
149
  watch?: boolean | undefined;
150
+ snapshot?: boolean | undefined;
132
151
  readonly?: boolean | undefined;
133
152
  delay?: number | undefined;
134
153
  pageable?: number | boolean | undefined;
package/dist/index.d.ts CHANGED
@@ -67,16 +67,28 @@ interface YamlStorage {
67
67
  * @throws {Error} If the file cannot be read or the YAML is malformed.
68
68
  */
69
69
  reload(): void;
70
+ /**
71
+ * Returns the saved snapshot: a frozen copy of `data` and `relations` taken
72
+ * at the last call to {@link saveSnapshot} (or at construction time).
73
+ */
74
+ getSnapshot(): {
75
+ data: Data;
76
+ relations: Relations;
77
+ savedAt: Date;
78
+ };
79
+ /**
80
+ * Replaces the stored snapshot with a deep copy of the current in-memory state.
81
+ * Future calls to {@link resetToSnapshot} will restore to this point.
82
+ */
83
+ saveSnapshot(): void;
84
+ /**
85
+ * Restores the in-memory state to the last saved snapshot and persists to disk.
86
+ * Mutates `data` and `relations` in place so existing references stay valid.
87
+ *
88
+ * @throws {Error} If the filesystem write fails.
89
+ */
90
+ resetToSnapshot(): void;
70
91
  }
71
- /**
72
- * Creates a {@link YamlStorage} instance backed by the given YAML file.
73
- *
74
- * The file is read and parsed eagerly on construction. The `_rel` key is
75
- * extracted as relational metadata; all other top-level keys become collections.
76
- *
77
- * @param filePath - Relative or absolute path to the YAML database file.
78
- * @throws {Error} If the file cannot be read or its YAML is invalid.
79
- */
80
92
  declare function createYamlStorage(filePath: string): YamlStorage;
81
93
 
82
94
  /**
@@ -99,6 +111,11 @@ declare const serverOptionsSchema: z.ZodObject<{
99
111
  base: z.ZodEffects<z.ZodDefault<z.ZodString>, string, string | undefined>;
100
112
  /** When `true`, the server reloads the YAML file automatically on disk changes. */
101
113
  watch: z.ZodDefault<z.ZodBoolean>;
114
+ /**
115
+ * When `true`, saves a snapshot of the initial database state on startup.
116
+ * Exposes `/_snapshot` endpoints to inspect, restore or update the snapshot.
117
+ */
118
+ snapshot: z.ZodDefault<z.ZodBoolean>;
102
119
  /** When `true`, all mutating requests (POST, PUT, PATCH, DELETE) are rejected with 405. */
103
120
  readonly: z.ZodDefault<z.ZodBoolean>;
104
121
  /** Milliseconds to delay every response, simulating network latency. `0` = disabled. */
@@ -117,6 +134,7 @@ declare const serverOptionsSchema: z.ZodObject<{
117
134
  host: string;
118
135
  base: string;
119
136
  watch: boolean;
137
+ snapshot: boolean;
120
138
  readonly: boolean;
121
139
  delay: number;
122
140
  pageable: {
@@ -129,6 +147,7 @@ declare const serverOptionsSchema: z.ZodObject<{
129
147
  host?: string | undefined;
130
148
  base?: string | undefined;
131
149
  watch?: boolean | undefined;
150
+ snapshot?: boolean | undefined;
132
151
  readonly?: boolean | undefined;
133
152
  delay?: number | undefined;
134
153
  pageable?: number | boolean | undefined;
package/dist/index.js CHANGED
@@ -41,6 +41,11 @@ var import_node_fs = require("fs");
41
41
  var import_node_path = require("path");
42
42
  var import_node_crypto = require("crypto");
43
43
  var import_yaml = require("yaml");
44
+ function deepCopyData(source) {
45
+ return Object.fromEntries(
46
+ Object.entries(source).map(([k, v]) => [k, v.map((item) => ({ ...item }))])
47
+ );
48
+ }
44
49
  function createYamlStorage(filePath) {
45
50
  const absPath = (0, import_node_path.resolve)(filePath);
46
51
  const raw = (0, import_yaml.parse)((0, import_node_fs.readFileSync)(absPath, "utf8")) ?? {};
@@ -48,6 +53,11 @@ function createYamlStorage(filePath) {
48
53
  const data = Object.fromEntries(
49
54
  Object.entries(raw).filter(([key]) => key !== "_rel")
50
55
  );
56
+ let snapshot = {
57
+ data: deepCopyData(data),
58
+ relations: { ...relations },
59
+ savedAt: /* @__PURE__ */ new Date()
60
+ };
51
61
  return {
52
62
  getData() {
53
63
  return data;
@@ -77,6 +87,24 @@ function createYamlStorage(filePath) {
77
87
  Object.assign(data, freshData);
78
88
  for (const key of Object.keys(relations)) delete relations[key];
79
89
  Object.assign(relations, freshRelations);
90
+ },
91
+ getSnapshot() {
92
+ return snapshot;
93
+ },
94
+ saveSnapshot() {
95
+ snapshot = {
96
+ data: deepCopyData(data),
97
+ relations: { ...relations },
98
+ savedAt: /* @__PURE__ */ new Date()
99
+ };
100
+ },
101
+ resetToSnapshot() {
102
+ const snap = deepCopyData(snapshot.data);
103
+ for (const key of Object.keys(data)) delete data[key];
104
+ Object.assign(data, snap);
105
+ for (const key of Object.keys(relations)) delete relations[key];
106
+ Object.assign(relations, { ...snapshot.relations });
107
+ this.persist();
80
108
  }
81
109
  };
82
110
  }
@@ -242,6 +270,9 @@ var registerCollectionRoutes = (server, storage, resource, base, options) => {
242
270
  return expandItems(result, req.query, resource, storage);
243
271
  });
244
272
  server.post(base, (req, reply) => {
273
+ if (!req.body || typeof req.body !== "object" || Array.isArray(req.body)) {
274
+ return reply.status(400).send({ error: "Request body must be a JSON object" });
275
+ }
245
276
  const item = createItem(storage, resource, req.body);
246
277
  return reply.status(201).send(expandItems(item, req.query, resource, storage));
247
278
  });
@@ -255,11 +286,17 @@ var registerItemRoutes = (server, storage, resource, base) => {
255
286
  return expandItems(item, req.query, resource, storage);
256
287
  });
257
288
  server.put(`${base}/:id`, (req, reply) => {
289
+ if (!req.body || typeof req.body !== "object" || Array.isArray(req.body)) {
290
+ return reply.status(400).send({ error: "Request body must be a JSON object" });
291
+ }
258
292
  const item = replaceItem(storage, resource, req.params.id, req.body);
259
293
  if (!item) return reply.status(404).send({ error: "Not found" });
260
294
  return expandItems(item, req.query, resource, storage);
261
295
  });
262
296
  server.patch(`${base}/:id`, (req, reply) => {
297
+ if (!req.body || typeof req.body !== "object" || Array.isArray(req.body)) {
298
+ return reply.status(400).send({ error: "Request body must be a JSON object" });
299
+ }
263
300
  const item = patchItem(storage, resource, req.params.id, req.body);
264
301
  if (!item) return reply.status(404).send({ error: "Not found" });
265
302
  return expandItems(item, req.query, resource, storage);
@@ -275,7 +312,9 @@ var registerItemRoutes = (server, storage, resource, base) => {
275
312
  var registerNestedRoutes = (server, storage, relations, base) => {
276
313
  for (const [child, fields] of Object.entries(relations)) {
277
314
  for (const [field, parent] of Object.entries(fields)) {
278
- server.get(`${base}/${parent}/:id/${child}`, (req, reply) => {
315
+ const collectionPath = `${base}/${parent}/:id/${child}`;
316
+ const itemPath = `${base}/${parent}/:id/${child}/:childId`;
317
+ server.get(collectionPath, (req, reply) => {
279
318
  const parentCollection = storage.getCollection(parent) ?? [];
280
319
  const parentItem = findById(parentCollection, req.params.id);
281
320
  if (!parentItem) return reply.status(404).send({ error: "Not found" });
@@ -284,6 +323,16 @@ var registerNestedRoutes = (server, storage, relations, base) => {
284
323
  );
285
324
  return children;
286
325
  });
326
+ server.get(itemPath, (req, reply) => {
327
+ const parentCollection = storage.getCollection(parent) ?? [];
328
+ const parentItem = findById(parentCollection, req.params.id);
329
+ if (!parentItem) return reply.status(404).send({ error: "Not found" });
330
+ const childItem = (storage.getCollection(child) ?? []).find(
331
+ (item) => String(item[field]) === req.params.id && String(item["id"]) === req.params.childId
332
+ );
333
+ if (!childItem) return reply.status(404).send({ error: "Not found" });
334
+ return childItem;
335
+ });
287
336
  }
288
337
  }
289
338
  };
@@ -408,6 +457,14 @@ curl ${host}${base}/${parent}/1/${child}`);
408
457
  examples.push(`# Pageable envelope
409
458
  curl "${host}${base}/${firstCol}?_page=2"`);
410
459
  }
460
+ if (options.snapshot) {
461
+ examples.push(
462
+ `# Snapshot endpoints
463
+ curl ${host}/_snapshot
464
+ curl -X POST ${host}/_snapshot/save
465
+ curl -X POST ${host}/_snapshot/reset`
466
+ );
467
+ }
411
468
  const highlighted = examples.map((e) => e.replace(/^(#.+)$/gm, '<span class="cm">$1</span>')).join("\n\n");
412
469
  return `<pre>${highlighted}</pre>`;
413
470
  }
@@ -422,6 +479,7 @@ function generateAboutHtml(storage, options) {
422
479
  if (options.delay > 0) modes.push(badge(`delay \xB7 ${options.delay}ms`, "#fb923c", "#fb923c18"));
423
480
  if (options.pageable.enabled)
424
481
  modes.push(badge(`pageable \xB7 limit ${options.pageable.limit}`, "#34d399", "#34d39918"));
482
+ if (options.snapshot) modes.push(badge("snapshot", "#c084fc", "#c084fc18"));
425
483
  const accordions = collections.map((col, i) => resourceAccordion(col, base, i === 0)).join("");
426
484
  const nestedRows = [];
427
485
  for (const [child, fields] of Object.entries(relations)) {
@@ -441,6 +499,18 @@ function generateAboutHtml(storage, options) {
441
499
  </summary>
442
500
  <table><tbody>${nestedRows.join("")}</tbody></table>
443
501
  </details>` : "";
502
+ const snapshotAccordion = options.snapshot ? `
503
+ <details class="resource-card nested-card">
504
+ <summary>
505
+ <span class="resource-name">/_snapshot</span>
506
+ <span class="route-count">3 routes</span>
507
+ </summary>
508
+ <table><tbody>
509
+ ${endpointRow("GET", "/_snapshot", "Returns metadata of the current snapshot: <code>savedAt</code> and item counts per collection.")}
510
+ ${endpointRow("POST", "/_snapshot/save", "Replaces the stored snapshot with the current database state.")}
511
+ ${endpointRow("POST", "/_snapshot/reset", "Restores the database to the last saved snapshot and persists to disk.")}
512
+ </tbody></table>
513
+ </details>` : "";
444
514
  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.`;
445
515
  return `<!DOCTYPE html>
446
516
  <html lang="en">
@@ -589,6 +659,7 @@ function generateAboutHtml(storage, options) {
589
659
  <div class="endpoints-grid">
590
660
  ${accordions}
591
661
  ${nestedAccordion}
662
+ ${snapshotAccordion}
592
663
  </div>
593
664
 
594
665
  <h2>Query Parameters</h2>
@@ -623,11 +694,51 @@ function registerAboutRoute(server, storage, options) {
623
694
  });
624
695
  }
625
696
 
697
+ // src/router/routes/snapshot.routes.ts
698
+ function registerSnapshotRoutes(server, storage) {
699
+ server.get("/_snapshot", (_req, reply) => {
700
+ const { data, savedAt } = storage.getSnapshot();
701
+ return reply.send({
702
+ savedAt: savedAt.toISOString(),
703
+ collections: Object.fromEntries(
704
+ Object.entries(data).map(([name, items]) => [name, items.length])
705
+ )
706
+ });
707
+ });
708
+ server.post("/_snapshot/save", (_req, reply) => {
709
+ storage.saveSnapshot();
710
+ const { data, savedAt } = storage.getSnapshot();
711
+ return reply.send({
712
+ message: "Snapshot saved",
713
+ savedAt: savedAt.toISOString(),
714
+ collections: Object.fromEntries(
715
+ Object.entries(data).map(([name, items]) => [name, items.length])
716
+ )
717
+ });
718
+ });
719
+ server.post("/_snapshot/reset", (_req, reply) => {
720
+ storage.resetToSnapshot();
721
+ const { data, savedAt } = storage.getSnapshot();
722
+ return reply.send({
723
+ message: "Database restored to snapshot",
724
+ savedAt: savedAt.toISOString(),
725
+ collections: Object.fromEntries(
726
+ Object.entries(data).map(([name, items]) => [name, items.length])
727
+ )
728
+ });
729
+ });
730
+ }
731
+
626
732
  // src/server/createServer.ts
627
733
  var MUTATING_METHODS = /* @__PURE__ */ new Set(["POST", "PUT", "PATCH", "DELETE"]);
628
734
  async function createServer(storage, options) {
629
735
  const server = (0, import_fastify.default)();
630
736
  await server.register(import_cors.default);
737
+ server.setErrorHandler((err, _req, reply) => {
738
+ const status = err.statusCode ?? 500;
739
+ const message = status < 500 ? err.message || "Request error" : "Internal server error";
740
+ reply.status(status).send({ error: message });
741
+ });
631
742
  if (options.readonly) {
632
743
  server.addHook("onRequest", (_req, reply, done) => {
633
744
  if (MUTATING_METHODS.has(_req.method)) {
@@ -642,6 +753,7 @@ async function createServer(storage, options) {
642
753
  });
643
754
  }
644
755
  registerAboutRoute(server, storage, options);
756
+ if (options.snapshot) registerSnapshotRoutes(server, storage);
645
757
  registerResourceRoutes(server, storage, options);
646
758
  return server;
647
759
  }
@@ -662,6 +774,11 @@ var serverOptionsSchema = import_zod.z.object({
662
774
  base: import_zod.z.string().default("").transform((v) => v && !v.startsWith("/") ? `/${v}` : v),
663
775
  /** When `true`, the server reloads the YAML file automatically on disk changes. */
664
776
  watch: import_zod.z.boolean().default(false),
777
+ /**
778
+ * When `true`, saves a snapshot of the initial database state on startup.
779
+ * Exposes `/_snapshot` endpoints to inspect, restore or update the snapshot.
780
+ */
781
+ snapshot: import_zod.z.boolean().default(false),
665
782
  /** When `true`, all mutating requests (POST, PUT, PATCH, DELETE) are rejected with 405. */
666
783
  readonly: import_zod.z.boolean().default(false),
667
784
  /** Milliseconds to delay every response, simulating network latency. `0` = disabled. */
package/dist/index.mjs CHANGED
@@ -3,6 +3,11 @@ import { readFileSync, writeFileSync, renameSync } from "fs";
3
3
  import { resolve, dirname } from "path";
4
4
  import { randomUUID } from "crypto";
5
5
  import { parse, stringify } from "yaml";
6
+ function deepCopyData(source) {
7
+ return Object.fromEntries(
8
+ Object.entries(source).map(([k, v]) => [k, v.map((item) => ({ ...item }))])
9
+ );
10
+ }
6
11
  function createYamlStorage(filePath) {
7
12
  const absPath = resolve(filePath);
8
13
  const raw = parse(readFileSync(absPath, "utf8")) ?? {};
@@ -10,6 +15,11 @@ function createYamlStorage(filePath) {
10
15
  const data = Object.fromEntries(
11
16
  Object.entries(raw).filter(([key]) => key !== "_rel")
12
17
  );
18
+ let snapshot = {
19
+ data: deepCopyData(data),
20
+ relations: { ...relations },
21
+ savedAt: /* @__PURE__ */ new Date()
22
+ };
13
23
  return {
14
24
  getData() {
15
25
  return data;
@@ -39,6 +49,24 @@ function createYamlStorage(filePath) {
39
49
  Object.assign(data, freshData);
40
50
  for (const key of Object.keys(relations)) delete relations[key];
41
51
  Object.assign(relations, freshRelations);
52
+ },
53
+ getSnapshot() {
54
+ return snapshot;
55
+ },
56
+ saveSnapshot() {
57
+ snapshot = {
58
+ data: deepCopyData(data),
59
+ relations: { ...relations },
60
+ savedAt: /* @__PURE__ */ new Date()
61
+ };
62
+ },
63
+ resetToSnapshot() {
64
+ const snap = deepCopyData(snapshot.data);
65
+ for (const key of Object.keys(data)) delete data[key];
66
+ Object.assign(data, snap);
67
+ for (const key of Object.keys(relations)) delete relations[key];
68
+ Object.assign(relations, { ...snapshot.relations });
69
+ this.persist();
42
70
  }
43
71
  };
44
72
  }
@@ -204,6 +232,9 @@ var registerCollectionRoutes = (server, storage, resource, base, options) => {
204
232
  return expandItems(result, req.query, resource, storage);
205
233
  });
206
234
  server.post(base, (req, reply) => {
235
+ if (!req.body || typeof req.body !== "object" || Array.isArray(req.body)) {
236
+ return reply.status(400).send({ error: "Request body must be a JSON object" });
237
+ }
207
238
  const item = createItem(storage, resource, req.body);
208
239
  return reply.status(201).send(expandItems(item, req.query, resource, storage));
209
240
  });
@@ -217,11 +248,17 @@ var registerItemRoutes = (server, storage, resource, base) => {
217
248
  return expandItems(item, req.query, resource, storage);
218
249
  });
219
250
  server.put(`${base}/:id`, (req, reply) => {
251
+ if (!req.body || typeof req.body !== "object" || Array.isArray(req.body)) {
252
+ return reply.status(400).send({ error: "Request body must be a JSON object" });
253
+ }
220
254
  const item = replaceItem(storage, resource, req.params.id, req.body);
221
255
  if (!item) return reply.status(404).send({ error: "Not found" });
222
256
  return expandItems(item, req.query, resource, storage);
223
257
  });
224
258
  server.patch(`${base}/:id`, (req, reply) => {
259
+ if (!req.body || typeof req.body !== "object" || Array.isArray(req.body)) {
260
+ return reply.status(400).send({ error: "Request body must be a JSON object" });
261
+ }
225
262
  const item = patchItem(storage, resource, req.params.id, req.body);
226
263
  if (!item) return reply.status(404).send({ error: "Not found" });
227
264
  return expandItems(item, req.query, resource, storage);
@@ -237,7 +274,9 @@ var registerItemRoutes = (server, storage, resource, base) => {
237
274
  var registerNestedRoutes = (server, storage, relations, base) => {
238
275
  for (const [child, fields] of Object.entries(relations)) {
239
276
  for (const [field, parent] of Object.entries(fields)) {
240
- server.get(`${base}/${parent}/:id/${child}`, (req, reply) => {
277
+ const collectionPath = `${base}/${parent}/:id/${child}`;
278
+ const itemPath = `${base}/${parent}/:id/${child}/:childId`;
279
+ server.get(collectionPath, (req, reply) => {
241
280
  const parentCollection = storage.getCollection(parent) ?? [];
242
281
  const parentItem = findById(parentCollection, req.params.id);
243
282
  if (!parentItem) return reply.status(404).send({ error: "Not found" });
@@ -246,6 +285,16 @@ var registerNestedRoutes = (server, storage, relations, base) => {
246
285
  );
247
286
  return children;
248
287
  });
288
+ server.get(itemPath, (req, reply) => {
289
+ const parentCollection = storage.getCollection(parent) ?? [];
290
+ const parentItem = findById(parentCollection, req.params.id);
291
+ if (!parentItem) return reply.status(404).send({ error: "Not found" });
292
+ const childItem = (storage.getCollection(child) ?? []).find(
293
+ (item) => String(item[field]) === req.params.id && String(item["id"]) === req.params.childId
294
+ );
295
+ if (!childItem) return reply.status(404).send({ error: "Not found" });
296
+ return childItem;
297
+ });
249
298
  }
250
299
  }
251
300
  };
@@ -370,6 +419,14 @@ curl ${host}${base}/${parent}/1/${child}`);
370
419
  examples.push(`# Pageable envelope
371
420
  curl "${host}${base}/${firstCol}?_page=2"`);
372
421
  }
422
+ if (options.snapshot) {
423
+ examples.push(
424
+ `# Snapshot endpoints
425
+ curl ${host}/_snapshot
426
+ curl -X POST ${host}/_snapshot/save
427
+ curl -X POST ${host}/_snapshot/reset`
428
+ );
429
+ }
373
430
  const highlighted = examples.map((e) => e.replace(/^(#.+)$/gm, '<span class="cm">$1</span>')).join("\n\n");
374
431
  return `<pre>${highlighted}</pre>`;
375
432
  }
@@ -384,6 +441,7 @@ function generateAboutHtml(storage, options) {
384
441
  if (options.delay > 0) modes.push(badge(`delay \xB7 ${options.delay}ms`, "#fb923c", "#fb923c18"));
385
442
  if (options.pageable.enabled)
386
443
  modes.push(badge(`pageable \xB7 limit ${options.pageable.limit}`, "#34d399", "#34d39918"));
444
+ if (options.snapshot) modes.push(badge("snapshot", "#c084fc", "#c084fc18"));
387
445
  const accordions = collections.map((col, i) => resourceAccordion(col, base, i === 0)).join("");
388
446
  const nestedRows = [];
389
447
  for (const [child, fields] of Object.entries(relations)) {
@@ -403,6 +461,18 @@ function generateAboutHtml(storage, options) {
403
461
  </summary>
404
462
  <table><tbody>${nestedRows.join("")}</tbody></table>
405
463
  </details>` : "";
464
+ const snapshotAccordion = options.snapshot ? `
465
+ <details class="resource-card nested-card">
466
+ <summary>
467
+ <span class="resource-name">/_snapshot</span>
468
+ <span class="route-count">3 routes</span>
469
+ </summary>
470
+ <table><tbody>
471
+ ${endpointRow("GET", "/_snapshot", "Returns metadata of the current snapshot: <code>savedAt</code> and item counts per collection.")}
472
+ ${endpointRow("POST", "/_snapshot/save", "Replaces the stored snapshot with the current database state.")}
473
+ ${endpointRow("POST", "/_snapshot/reset", "Restores the database to the last saved snapshot and persists to disk.")}
474
+ </tbody></table>
475
+ </details>` : "";
406
476
  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.`;
407
477
  return `<!DOCTYPE html>
408
478
  <html lang="en">
@@ -551,6 +621,7 @@ function generateAboutHtml(storage, options) {
551
621
  <div class="endpoints-grid">
552
622
  ${accordions}
553
623
  ${nestedAccordion}
624
+ ${snapshotAccordion}
554
625
  </div>
555
626
 
556
627
  <h2>Query Parameters</h2>
@@ -585,11 +656,51 @@ function registerAboutRoute(server, storage, options) {
585
656
  });
586
657
  }
587
658
 
659
+ // src/router/routes/snapshot.routes.ts
660
+ function registerSnapshotRoutes(server, storage) {
661
+ server.get("/_snapshot", (_req, reply) => {
662
+ const { data, savedAt } = storage.getSnapshot();
663
+ return reply.send({
664
+ savedAt: savedAt.toISOString(),
665
+ collections: Object.fromEntries(
666
+ Object.entries(data).map(([name, items]) => [name, items.length])
667
+ )
668
+ });
669
+ });
670
+ server.post("/_snapshot/save", (_req, reply) => {
671
+ storage.saveSnapshot();
672
+ const { data, savedAt } = storage.getSnapshot();
673
+ return reply.send({
674
+ message: "Snapshot saved",
675
+ savedAt: savedAt.toISOString(),
676
+ collections: Object.fromEntries(
677
+ Object.entries(data).map(([name, items]) => [name, items.length])
678
+ )
679
+ });
680
+ });
681
+ server.post("/_snapshot/reset", (_req, reply) => {
682
+ storage.resetToSnapshot();
683
+ const { data, savedAt } = storage.getSnapshot();
684
+ return reply.send({
685
+ message: "Database restored to snapshot",
686
+ savedAt: savedAt.toISOString(),
687
+ collections: Object.fromEntries(
688
+ Object.entries(data).map(([name, items]) => [name, items.length])
689
+ )
690
+ });
691
+ });
692
+ }
693
+
588
694
  // src/server/createServer.ts
589
695
  var MUTATING_METHODS = /* @__PURE__ */ new Set(["POST", "PUT", "PATCH", "DELETE"]);
590
696
  async function createServer(storage, options) {
591
697
  const server = Fastify();
592
698
  await server.register(cors);
699
+ server.setErrorHandler((err, _req, reply) => {
700
+ const status = err.statusCode ?? 500;
701
+ const message = status < 500 ? err.message || "Request error" : "Internal server error";
702
+ reply.status(status).send({ error: message });
703
+ });
593
704
  if (options.readonly) {
594
705
  server.addHook("onRequest", (_req, reply, done) => {
595
706
  if (MUTATING_METHODS.has(_req.method)) {
@@ -604,6 +715,7 @@ async function createServer(storage, options) {
604
715
  });
605
716
  }
606
717
  registerAboutRoute(server, storage, options);
718
+ if (options.snapshot) registerSnapshotRoutes(server, storage);
607
719
  registerResourceRoutes(server, storage, options);
608
720
  return server;
609
721
  }
@@ -624,6 +736,11 @@ var serverOptionsSchema = z.object({
624
736
  base: z.string().default("").transform((v) => v && !v.startsWith("/") ? `/${v}` : v),
625
737
  /** When `true`, the server reloads the YAML file automatically on disk changes. */
626
738
  watch: z.boolean().default(false),
739
+ /**
740
+ * When `true`, saves a snapshot of the initial database state on startup.
741
+ * Exposes `/_snapshot` endpoints to inspect, restore or update the snapshot.
742
+ */
743
+ snapshot: z.boolean().default(false),
627
744
  /** When `true`, all mutating requests (POST, PUT, PATCH, DELETE) are rejected with 405. */
628
745
  readonly: z.boolean().default(false),
629
746
  /** Milliseconds to delay every response, simulating network latency. `0` = disabled. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aggiovato/yrest",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "Zero-config REST API mock server powered by a YAML file",
5
5
  "keywords": [
6
6
  "yaml",