@aggiovato/yrest 0.2.2 → 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 +143 -5
- package/dist/cli/index.mjs +139 -5
- package/dist/index.d.mts +28 -9
- package/dist/index.d.ts +28 -9
- package/dist/index.js +118 -1
- package/dist/index.mjs +118 -1
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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();
|
package/dist/cli/index.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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. */
|