@aggiovato/yrest 0.2.2 → 0.4.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 +310 -47
- package/dist/cli/index.mjs +306 -47
- package/dist/index.d.mts +28 -9
- package/dist/index.d.ts +28 -9
- package/dist/index.js +285 -43
- package/dist/index.mjs +285 -43
- package/package.json +1 -1
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
|
}
|
|
@@ -154,11 +184,92 @@ function createYamlStorage(filePath) {
|
|
|
154
184
|
import Fastify from "fastify";
|
|
155
185
|
import cors from "@fastify/cors";
|
|
156
186
|
|
|
157
|
-
// src/
|
|
187
|
+
// src/utils/params.ts
|
|
158
188
|
function nextId(items) {
|
|
159
189
|
const ids = items.map((i) => i["id"]).filter((id) => typeof id === "number");
|
|
160
190
|
return ids.length > 0 ? Math.max(...ids) + 1 : 1;
|
|
161
191
|
}
|
|
192
|
+
function firstParam(value) {
|
|
193
|
+
if (value === void 0) return void 0;
|
|
194
|
+
return Array.isArray(value) ? value[0] : value;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// src/services/query.service.ts
|
|
198
|
+
var OPERATORS = ["_gte", "_lte", "_ne", "_like", "_start", "_regex"];
|
|
199
|
+
function applyOperator(itemValue, op, filterValue) {
|
|
200
|
+
const strItem = String(itemValue);
|
|
201
|
+
const numItem = Number(itemValue);
|
|
202
|
+
const numFilter = Number(filterValue);
|
|
203
|
+
const numeric = !isNaN(numItem) && !isNaN(numFilter) && filterValue.trim() !== "";
|
|
204
|
+
switch (op) {
|
|
205
|
+
case "_gte":
|
|
206
|
+
return numeric ? numItem >= numFilter : strItem >= filterValue;
|
|
207
|
+
case "_lte":
|
|
208
|
+
return numeric ? numItem <= numFilter : strItem <= filterValue;
|
|
209
|
+
case "_ne":
|
|
210
|
+
return strItem !== filterValue;
|
|
211
|
+
case "_like":
|
|
212
|
+
return strItem.toLowerCase().includes(filterValue.toLowerCase());
|
|
213
|
+
case "_start":
|
|
214
|
+
return strItem.toLowerCase().startsWith(filterValue.toLowerCase());
|
|
215
|
+
case "_regex": {
|
|
216
|
+
try {
|
|
217
|
+
return new RegExp(filterValue, "i").test(strItem);
|
|
218
|
+
} catch {
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
function filterByQuery(items, query) {
|
|
225
|
+
const filters = Object.entries(query).filter(([key]) => !key.startsWith("_"));
|
|
226
|
+
if (filters.length === 0) return items;
|
|
227
|
+
return items.filter(
|
|
228
|
+
(item) => filters.every(([key, value]) => {
|
|
229
|
+
const op = OPERATORS.find((o) => key.endsWith(o));
|
|
230
|
+
if (op) {
|
|
231
|
+
const field = key.slice(0, -op.length);
|
|
232
|
+
if (item[field] === void 0) return false;
|
|
233
|
+
const filterVal = Array.isArray(value) ? value[0] : value;
|
|
234
|
+
return applyOperator(item[field], op, filterVal);
|
|
235
|
+
}
|
|
236
|
+
if (item[key] === void 0) return false;
|
|
237
|
+
const itemStr = String(item[key]);
|
|
238
|
+
return Array.isArray(value) ? value.includes(itemStr) : itemStr === value;
|
|
239
|
+
})
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
function fullTextSearch(items, term) {
|
|
243
|
+
const lower = term.toLowerCase();
|
|
244
|
+
return items.filter(
|
|
245
|
+
(item) => Object.values(item).some((val) => {
|
|
246
|
+
if (val === null || val === void 0 || typeof val === "object") return false;
|
|
247
|
+
return String(val).toLowerCase().includes(lower);
|
|
248
|
+
})
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
function sortBy(items, field, order) {
|
|
252
|
+
const direction = order === "desc" ? -1 : 1;
|
|
253
|
+
return [...items].sort((a, b) => {
|
|
254
|
+
const av = a[field];
|
|
255
|
+
const bv = b[field];
|
|
256
|
+
if (av === void 0) return 1;
|
|
257
|
+
if (bv === void 0) return -1;
|
|
258
|
+
if (typeof av === "number" && typeof bv === "number") return (av - bv) * direction;
|
|
259
|
+
return String(av).localeCompare(String(bv), void 0, { sensitivity: "base" }) * direction;
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
function projectFields(input, fields) {
|
|
263
|
+
if (fields.length === 0) return input;
|
|
264
|
+
const project = (item) => Object.fromEntries(fields.filter((f) => f in item).map((f) => [f, item[f]]));
|
|
265
|
+
return Array.isArray(input) ? input.map(project) : project(input);
|
|
266
|
+
}
|
|
267
|
+
function paginate(items, page, limit) {
|
|
268
|
+
const start = (page - 1) * limit;
|
|
269
|
+
return items.slice(start, start + limit);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// src/services/resource.service.ts
|
|
162
273
|
function findById(items, id) {
|
|
163
274
|
return items.find((i) => String(i["id"]) === id);
|
|
164
275
|
}
|
|
@@ -195,36 +306,17 @@ function patchItem(storage, resource, id, body) {
|
|
|
195
306
|
storage.persist();
|
|
196
307
|
return updated;
|
|
197
308
|
}
|
|
198
|
-
function
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
return
|
|
206
|
-
(item) => filters.every(([key, value]) => {
|
|
207
|
-
if (item[key] === void 0) return false;
|
|
208
|
-
const itemStr = String(item[key]);
|
|
209
|
-
return Array.isArray(value) ? value.includes(itemStr) : itemStr === value;
|
|
210
|
-
})
|
|
211
|
-
);
|
|
212
|
-
}
|
|
213
|
-
function sortBy(items, field, order) {
|
|
214
|
-
const direction = order === "desc" ? -1 : 1;
|
|
215
|
-
return [...items].sort((a, b) => {
|
|
216
|
-
const av = a[field];
|
|
217
|
-
const bv = b[field];
|
|
218
|
-
if (av === void 0) return 1;
|
|
219
|
-
if (bv === void 0) return -1;
|
|
220
|
-
if (typeof av === "number" && typeof bv === "number") return (av - bv) * direction;
|
|
221
|
-
return String(av).localeCompare(String(bv), void 0, { sensitivity: "base" }) * direction;
|
|
222
|
-
});
|
|
223
|
-
}
|
|
224
|
-
function paginate(items, page, limit) {
|
|
225
|
-
const start = (page - 1) * limit;
|
|
226
|
-
return items.slice(start, start + limit);
|
|
309
|
+
function deleteItem(storage, resource, id) {
|
|
310
|
+
const collection = storage.getCollection(resource) ?? [];
|
|
311
|
+
const idx = findIndexById(collection, id);
|
|
312
|
+
if (idx === -1) return void 0;
|
|
313
|
+
const [deleted] = collection.splice(idx, 1);
|
|
314
|
+
storage.setCollection(resource, collection);
|
|
315
|
+
storage.persist();
|
|
316
|
+
return deleted;
|
|
227
317
|
}
|
|
318
|
+
|
|
319
|
+
// src/services/expand.service.ts
|
|
228
320
|
function expandItems(input, query, resource, storage) {
|
|
229
321
|
const isArray = Array.isArray(input);
|
|
230
322
|
const items = isArray ? input : [input];
|
|
@@ -257,14 +349,35 @@ function expandItems(input, query, resource, storage) {
|
|
|
257
349
|
});
|
|
258
350
|
return isArray ? expanded : expanded[0];
|
|
259
351
|
}
|
|
260
|
-
function
|
|
261
|
-
const
|
|
262
|
-
const
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
storage.
|
|
267
|
-
|
|
352
|
+
function embedItems(input, query, resource, storage) {
|
|
353
|
+
const isArray = Array.isArray(input);
|
|
354
|
+
const items = isArray ? input : [input];
|
|
355
|
+
const embedParam = query["_embed"];
|
|
356
|
+
if (!embedParam) return isArray ? items : input;
|
|
357
|
+
const keys = (Array.isArray(embedParam) ? embedParam : [embedParam]).flatMap((v) => v.split(",")).map((k) => k.trim()).filter(Boolean);
|
|
358
|
+
const relations = storage.getRelations();
|
|
359
|
+
const embeds = /* @__PURE__ */ new Map();
|
|
360
|
+
for (const embedKey of keys) {
|
|
361
|
+
outer: for (const [childCollection, fields] of Object.entries(relations)) {
|
|
362
|
+
for (const [fkField, parentCollection] of Object.entries(fields)) {
|
|
363
|
+
if (parentCollection === resource && childCollection === embedKey) {
|
|
364
|
+
embeds.set(embedKey, { childCollection, fkField });
|
|
365
|
+
break outer;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
if (embeds.size === 0) return isArray ? items : input;
|
|
371
|
+
const result = items.map((item) => {
|
|
372
|
+
const out = { ...item };
|
|
373
|
+
for (const [embedKey, { childCollection, fkField }] of embeds) {
|
|
374
|
+
out[embedKey] = (storage.getCollection(childCollection) ?? []).filter(
|
|
375
|
+
(child) => String(child[fkField]) === String(item["id"])
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
return out;
|
|
379
|
+
});
|
|
380
|
+
return isArray ? result : result[0];
|
|
268
381
|
}
|
|
269
382
|
|
|
270
383
|
// src/router/routes/collection.routes.ts
|
|
@@ -272,9 +385,12 @@ var registerCollectionRoutes = (server, storage, resource, base, options) => {
|
|
|
272
385
|
server.get(base, (req, reply) => {
|
|
273
386
|
const collection = storage.getCollection(resource) ?? [];
|
|
274
387
|
const filtered = filterByQuery(collection, req.query);
|
|
388
|
+
const searchTerm = firstParam(req.query["_q"]);
|
|
389
|
+
const searched = searchTerm ? fullTextSearch(filtered, searchTerm) : filtered;
|
|
390
|
+
const fields = (firstParam(req.query["_fields"]) ?? "").split(",").map((f) => f.trim()).filter(Boolean);
|
|
275
391
|
const sortField = firstParam(req.query["_sort"]);
|
|
276
392
|
const sortOrder = firstParam(req.query["_order"]) === "desc" ? "desc" : "asc";
|
|
277
|
-
const sorted = sortField ? sortBy(
|
|
393
|
+
const sorted = sortField ? sortBy(searched, sortField, sortOrder) : searched;
|
|
278
394
|
if (options.pageable.enabled) {
|
|
279
395
|
const defaultLimit = options.pageable.limit;
|
|
280
396
|
const page = Math.max(1, parseInt(firstParam(req.query["_page"]) ?? "1", 10) || 1);
|
|
@@ -284,7 +400,15 @@ var registerCollectionRoutes = (server, storage, resource, base, options) => {
|
|
|
284
400
|
);
|
|
285
401
|
const totalItems = sorted.length;
|
|
286
402
|
const totalPages = Math.ceil(totalItems / limit) || 1;
|
|
287
|
-
const data =
|
|
403
|
+
const data = projectFields(
|
|
404
|
+
embedItems(
|
|
405
|
+
expandItems(paginate(sorted, page, limit), req.query, resource, storage),
|
|
406
|
+
req.query,
|
|
407
|
+
resource,
|
|
408
|
+
storage
|
|
409
|
+
),
|
|
410
|
+
fields
|
|
411
|
+
);
|
|
288
412
|
const pagination = {
|
|
289
413
|
page,
|
|
290
414
|
limit,
|
|
@@ -308,9 +432,15 @@ var registerCollectionRoutes = (server, storage, resource, base, options) => {
|
|
|
308
432
|
reply.header("X-Total-Count", String(sorted.length));
|
|
309
433
|
result = paginate(sorted, page, limit);
|
|
310
434
|
}
|
|
311
|
-
return
|
|
435
|
+
return projectFields(
|
|
436
|
+
embedItems(expandItems(result, req.query, resource, storage), req.query, resource, storage),
|
|
437
|
+
fields
|
|
438
|
+
);
|
|
312
439
|
});
|
|
313
440
|
server.post(base, (req, reply) => {
|
|
441
|
+
if (!req.body || typeof req.body !== "object" || Array.isArray(req.body)) {
|
|
442
|
+
return reply.status(400).send({ error: "Request body must be a JSON object" });
|
|
443
|
+
}
|
|
314
444
|
const item = createItem(storage, resource, req.body);
|
|
315
445
|
return reply.status(201).send(expandItems(item, req.query, resource, storage));
|
|
316
446
|
});
|
|
@@ -321,14 +451,24 @@ var registerItemRoutes = (server, storage, resource, base) => {
|
|
|
321
451
|
server.get(`${base}/:id`, (req, reply) => {
|
|
322
452
|
const item = findById(storage.getCollection(resource) ?? [], req.params.id);
|
|
323
453
|
if (!item) return reply.status(404).send({ error: "Not found" });
|
|
324
|
-
|
|
454
|
+
const fields = (req.query["_fields"] ?? "").split(",").map((f) => f.trim()).filter(Boolean);
|
|
455
|
+
return projectFields(
|
|
456
|
+
embedItems(expandItems(item, req.query, resource, storage), req.query, resource, storage),
|
|
457
|
+
fields
|
|
458
|
+
);
|
|
325
459
|
});
|
|
326
460
|
server.put(`${base}/:id`, (req, reply) => {
|
|
461
|
+
if (!req.body || typeof req.body !== "object" || Array.isArray(req.body)) {
|
|
462
|
+
return reply.status(400).send({ error: "Request body must be a JSON object" });
|
|
463
|
+
}
|
|
327
464
|
const item = replaceItem(storage, resource, req.params.id, req.body);
|
|
328
465
|
if (!item) return reply.status(404).send({ error: "Not found" });
|
|
329
466
|
return expandItems(item, req.query, resource, storage);
|
|
330
467
|
});
|
|
331
468
|
server.patch(`${base}/:id`, (req, reply) => {
|
|
469
|
+
if (!req.body || typeof req.body !== "object" || Array.isArray(req.body)) {
|
|
470
|
+
return reply.status(400).send({ error: "Request body must be a JSON object" });
|
|
471
|
+
}
|
|
332
472
|
const item = patchItem(storage, resource, req.params.id, req.body);
|
|
333
473
|
if (!item) return reply.status(404).send({ error: "Not found" });
|
|
334
474
|
return expandItems(item, req.query, resource, storage);
|
|
@@ -344,7 +484,9 @@ var registerItemRoutes = (server, storage, resource, base) => {
|
|
|
344
484
|
var registerNestedRoutes = (server, storage, relations, base) => {
|
|
345
485
|
for (const [child, fields] of Object.entries(relations)) {
|
|
346
486
|
for (const [field, parent] of Object.entries(fields)) {
|
|
347
|
-
|
|
487
|
+
const collectionPath = `${base}/${parent}/:id/${child}`;
|
|
488
|
+
const itemPath = `${base}/${parent}/:id/${child}/:childId`;
|
|
489
|
+
server.get(collectionPath, (req, reply) => {
|
|
348
490
|
const parentCollection = storage.getCollection(parent) ?? [];
|
|
349
491
|
const parentItem = findById(parentCollection, req.params.id);
|
|
350
492
|
if (!parentItem) return reply.status(404).send({ error: "Not found" });
|
|
@@ -353,6 +495,16 @@ var registerNestedRoutes = (server, storage, relations, base) => {
|
|
|
353
495
|
);
|
|
354
496
|
return children;
|
|
355
497
|
});
|
|
498
|
+
server.get(itemPath, (req, reply) => {
|
|
499
|
+
const parentCollection = storage.getCollection(parent) ?? [];
|
|
500
|
+
const parentItem = findById(parentCollection, req.params.id);
|
|
501
|
+
if (!parentItem) return reply.status(404).send({ error: "Not found" });
|
|
502
|
+
const childItem = (storage.getCollection(child) ?? []).find(
|
|
503
|
+
(item) => String(item[field]) === req.params.id && String(item["id"]) === req.params.childId
|
|
504
|
+
);
|
|
505
|
+
if (!childItem) return reply.status(404).send({ error: "Not found" });
|
|
506
|
+
return childItem;
|
|
507
|
+
});
|
|
356
508
|
}
|
|
357
509
|
}
|
|
358
510
|
};
|
|
@@ -477,6 +629,30 @@ curl ${host}${base}/${parent}/1/${child}`);
|
|
|
477
629
|
examples.push(`# Pageable envelope
|
|
478
630
|
curl "${host}${base}/${firstCol}?_page=2"`);
|
|
479
631
|
}
|
|
632
|
+
const firstParentRel = Object.entries(relations).find(
|
|
633
|
+
([, fields]) => Object.values(fields).includes(firstCol ?? "")
|
|
634
|
+
);
|
|
635
|
+
if (firstCol) {
|
|
636
|
+
examples.push(
|
|
637
|
+
`# Project fields with ?_fields
|
|
638
|
+
curl "${host}${base}/${firstCol}?_fields=id,name"`
|
|
639
|
+
);
|
|
640
|
+
}
|
|
641
|
+
if (firstParentRel && firstCol) {
|
|
642
|
+
const [childName] = firstParentRel;
|
|
643
|
+
examples.push(
|
|
644
|
+
`# Embed child collection with ?_embed
|
|
645
|
+
curl "${host}${base}/${firstCol}/1?_embed=${childName}"`
|
|
646
|
+
);
|
|
647
|
+
}
|
|
648
|
+
if (options.snapshot) {
|
|
649
|
+
examples.push(
|
|
650
|
+
`# Snapshot endpoints
|
|
651
|
+
curl ${host}/_snapshot
|
|
652
|
+
curl -X POST ${host}/_snapshot/save
|
|
653
|
+
curl -X POST ${host}/_snapshot/reset`
|
|
654
|
+
);
|
|
655
|
+
}
|
|
480
656
|
const highlighted = examples.map((e) => e.replace(/^(#.+)$/gm, '<span class="cm">$1</span>')).join("\n\n");
|
|
481
657
|
return `<pre>${highlighted}</pre>`;
|
|
482
658
|
}
|
|
@@ -491,6 +667,7 @@ function generateAboutHtml(storage, options) {
|
|
|
491
667
|
if (options.delay > 0) modes.push(badge(`delay \xB7 ${options.delay}ms`, "#fb923c", "#fb923c18"));
|
|
492
668
|
if (options.pageable.enabled)
|
|
493
669
|
modes.push(badge(`pageable \xB7 limit ${options.pageable.limit}`, "#34d399", "#34d39918"));
|
|
670
|
+
if (options.snapshot) modes.push(badge("snapshot", "#c084fc", "#c084fc18"));
|
|
494
671
|
const accordions = collections.map((col, i) => resourceAccordion(col, base, i === 0)).join("");
|
|
495
672
|
const nestedRows = [];
|
|
496
673
|
for (const [child, fields] of Object.entries(relations)) {
|
|
@@ -510,6 +687,18 @@ function generateAboutHtml(storage, options) {
|
|
|
510
687
|
</summary>
|
|
511
688
|
<table><tbody>${nestedRows.join("")}</tbody></table>
|
|
512
689
|
</details>` : "";
|
|
690
|
+
const snapshotAccordion = options.snapshot ? `
|
|
691
|
+
<details class="resource-card nested-card">
|
|
692
|
+
<summary>
|
|
693
|
+
<span class="resource-name">/_snapshot</span>
|
|
694
|
+
<span class="route-count">3 routes</span>
|
|
695
|
+
</summary>
|
|
696
|
+
<table><tbody>
|
|
697
|
+
${endpointRow("GET", "/_snapshot", "Returns metadata of the current snapshot: <code>savedAt</code> and item counts per collection.")}
|
|
698
|
+
${endpointRow("POST", "/_snapshot/save", "Replaces the stored snapshot with the current database state.")}
|
|
699
|
+
${endpointRow("POST", "/_snapshot/reset", "Restores the database to the last saved snapshot and persists to disk.")}
|
|
700
|
+
</tbody></table>
|
|
701
|
+
</details>` : "";
|
|
513
702
|
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
703
|
return `<!DOCTYPE html>
|
|
515
704
|
<html lang="en">
|
|
@@ -658,6 +847,7 @@ function generateAboutHtml(storage, options) {
|
|
|
658
847
|
<div class="endpoints-grid">
|
|
659
848
|
${accordions}
|
|
660
849
|
${nestedAccordion}
|
|
850
|
+
${snapshotAccordion}
|
|
661
851
|
</div>
|
|
662
852
|
|
|
663
853
|
<h2>Query Parameters</h2>
|
|
@@ -666,9 +856,17 @@ function generateAboutHtml(storage, options) {
|
|
|
666
856
|
<thead><tr><th>Param</th><th>Example</th><th>Description</th></tr></thead>
|
|
667
857
|
<tbody>
|
|
668
858
|
<tr><td><code>?field=value</code></td><td><code>?name=Ana&role=admin</code></td><td>Filter by any field. Multiple params are ANDed.</td></tr>
|
|
859
|
+
<tr><td><code>?field_gte / _lte</code></td><td><code>?price_gte=10&price_lte=50</code></td><td>Numeric or lexicographic range. Works with any comparable field.</td></tr>
|
|
860
|
+
<tr><td><code>?field_ne</code></td><td><code>?status_ne=inactive</code></td><td>Exclude items where the field equals the value.</td></tr>
|
|
861
|
+
<tr><td><code>?field_like</code></td><td><code>?name_like=ana</code></td><td>Case-insensitive substring match.</td></tr>
|
|
862
|
+
<tr><td><code>?field_start</code></td><td><code>?name_start=A</code></td><td>Case-insensitive prefix match.</td></tr>
|
|
863
|
+
<tr><td><code>?field_regex</code></td><td><code>?email_regex=gmail</code></td><td>Case-insensitive regular expression match.</td></tr>
|
|
864
|
+
<tr><td><code>?_q</code></td><td><code>?_q=ana</code></td><td>Full-text search across all scalar fields (case-insensitive substring).</td></tr>
|
|
669
865
|
<tr><td><code>?_sort & ?_order</code></td><td><code>?_sort=name&_order=desc</code></td><td>Sort by field. <code>_order</code>: <code>asc</code> (default) or <code>desc</code>.</td></tr>
|
|
670
866
|
<tr><td><code>?_page & ?_limit</code></td><td><code>?_page=2&_limit=10</code></td><td>${paginationDesc}</td></tr>
|
|
671
867
|
<tr><td><code>?_expand</code></td><td><code>?_expand=user</code></td><td>Embed a related parent object inline. Requires <code>_rel</code> in the YAML file.</td></tr>
|
|
868
|
+
<tr><td><code>?_embed</code></td><td><code>?_embed=posts</code></td><td>Embed child collections into each parent item. Requires <code>_rel</code> in the YAML file.</td></tr>
|
|
869
|
+
<tr><td><code>?_fields</code></td><td><code>?_fields=id,name</code></td><td>Return only the specified fields. Applied last \u2014 can include embedded/expanded keys.</td></tr>
|
|
672
870
|
</tbody>
|
|
673
871
|
</table>
|
|
674
872
|
</div>
|
|
@@ -692,11 +890,51 @@ function registerAboutRoute(server, storage, options) {
|
|
|
692
890
|
});
|
|
693
891
|
}
|
|
694
892
|
|
|
893
|
+
// src/router/routes/snapshot.routes.ts
|
|
894
|
+
function registerSnapshotRoutes(server, storage) {
|
|
895
|
+
server.get("/_snapshot", (_req, reply) => {
|
|
896
|
+
const { data, savedAt } = storage.getSnapshot();
|
|
897
|
+
return reply.send({
|
|
898
|
+
savedAt: savedAt.toISOString(),
|
|
899
|
+
collections: Object.fromEntries(
|
|
900
|
+
Object.entries(data).map(([name, items]) => [name, items.length])
|
|
901
|
+
)
|
|
902
|
+
});
|
|
903
|
+
});
|
|
904
|
+
server.post("/_snapshot/save", (_req, reply) => {
|
|
905
|
+
storage.saveSnapshot();
|
|
906
|
+
const { data, savedAt } = storage.getSnapshot();
|
|
907
|
+
return reply.send({
|
|
908
|
+
message: "Snapshot saved",
|
|
909
|
+
savedAt: savedAt.toISOString(),
|
|
910
|
+
collections: Object.fromEntries(
|
|
911
|
+
Object.entries(data).map(([name, items]) => [name, items.length])
|
|
912
|
+
)
|
|
913
|
+
});
|
|
914
|
+
});
|
|
915
|
+
server.post("/_snapshot/reset", (_req, reply) => {
|
|
916
|
+
storage.resetToSnapshot();
|
|
917
|
+
const { data, savedAt } = storage.getSnapshot();
|
|
918
|
+
return reply.send({
|
|
919
|
+
message: "Database restored to snapshot",
|
|
920
|
+
savedAt: savedAt.toISOString(),
|
|
921
|
+
collections: Object.fromEntries(
|
|
922
|
+
Object.entries(data).map(([name, items]) => [name, items.length])
|
|
923
|
+
)
|
|
924
|
+
});
|
|
925
|
+
});
|
|
926
|
+
}
|
|
927
|
+
|
|
695
928
|
// src/server/createServer.ts
|
|
696
929
|
var MUTATING_METHODS = /* @__PURE__ */ new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
|
697
930
|
async function createServer(storage, options) {
|
|
698
931
|
const server = Fastify();
|
|
699
932
|
await server.register(cors);
|
|
933
|
+
server.setErrorHandler((err, _req, reply) => {
|
|
934
|
+
const status = err.statusCode ?? 500;
|
|
935
|
+
const message = status < 500 ? err.message || "Request error" : "Internal server error";
|
|
936
|
+
reply.status(status).send({ error: message });
|
|
937
|
+
});
|
|
700
938
|
if (options.readonly) {
|
|
701
939
|
server.addHook("onRequest", (_req, reply, done) => {
|
|
702
940
|
if (MUTATING_METHODS.has(_req.method)) {
|
|
@@ -711,6 +949,7 @@ async function createServer(storage, options) {
|
|
|
711
949
|
});
|
|
712
950
|
}
|
|
713
951
|
registerAboutRoute(server, storage, options);
|
|
952
|
+
if (options.snapshot) registerSnapshotRoutes(server, storage);
|
|
714
953
|
registerResourceRoutes(server, storage, options);
|
|
715
954
|
return server;
|
|
716
955
|
}
|
|
@@ -731,6 +970,11 @@ var serverOptionsSchema = z.object({
|
|
|
731
970
|
base: z.string().default("").transform((v) => v && !v.startsWith("/") ? `/${v}` : v),
|
|
732
971
|
/** When `true`, the server reloads the YAML file automatically on disk changes. */
|
|
733
972
|
watch: z.boolean().default(false),
|
|
973
|
+
/**
|
|
974
|
+
* When `true`, saves a snapshot of the initial database state on startup.
|
|
975
|
+
* Exposes `/_snapshot` endpoints to inspect, restore or update the snapshot.
|
|
976
|
+
*/
|
|
977
|
+
snapshot: z.boolean().default(false),
|
|
734
978
|
/** When `true`, all mutating requests (POST, PUT, PATCH, DELETE) are rejected with 405. */
|
|
735
979
|
readonly: z.boolean().default(false),
|
|
736
980
|
/** Milliseconds to delay every response, simulating network latency. `0` = disabled. */
|
|
@@ -763,6 +1007,9 @@ function registerServe(program2) {
|
|
|
763
1007
|
).option(
|
|
764
1008
|
"--pageable [limit]",
|
|
765
1009
|
"Wrap GET collection responses in { data, pagination } envelope. Optionally set default page size (default: 10)"
|
|
1010
|
+
).option(
|
|
1011
|
+
"--snapshot",
|
|
1012
|
+
"Save a snapshot of the initial database state and expose /_snapshot endpoints"
|
|
766
1013
|
).action(async (file, flags, cmd) => {
|
|
767
1014
|
const fileConfig = loadConfigFile(join(process.cwd(), "yrest.config.yml"));
|
|
768
1015
|
const cliOverrides = Object.fromEntries(
|
|
@@ -775,7 +1022,14 @@ function registerServe(program2) {
|
|
|
775
1022
|
...cliOverrides
|
|
776
1023
|
};
|
|
777
1024
|
const options = serverOptionsSchema.parse(merged);
|
|
778
|
-
|
|
1025
|
+
let storage;
|
|
1026
|
+
try {
|
|
1027
|
+
storage = createYamlStorage(options.file);
|
|
1028
|
+
} catch (err) {
|
|
1029
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1030
|
+
console.error(`Error: cannot load "${options.file}" \u2014 ${msg}`);
|
|
1031
|
+
process.exit(1);
|
|
1032
|
+
}
|
|
779
1033
|
const server = await createServer(storage, options);
|
|
780
1034
|
await server.listen({ port: options.port, host: options.host });
|
|
781
1035
|
const collections = Object.keys(storage.getData());
|
|
@@ -788,6 +1042,8 @@ yrest running at http://${options.host}:${options.port}`);
|
|
|
788
1042
|
console.log(
|
|
789
1043
|
`[pageable] responses wrapped in { data, pagination } (limit: ${options.pageable.limit})`
|
|
790
1044
|
);
|
|
1045
|
+
if (options.snapshot)
|
|
1046
|
+
console.log("[snapshot] /_snapshot endpoints enabled \u2014 GET / POST save / POST reset");
|
|
791
1047
|
console.log(`
|
|
792
1048
|
Resources (base: ${baseLabel}):`);
|
|
793
1049
|
for (const name of collections) {
|
|
@@ -804,8 +1060,9 @@ Resources (base: ${baseLabel}):`);
|
|
|
804
1060
|
try {
|
|
805
1061
|
storage.reload();
|
|
806
1062
|
console.log(`[watch] reloaded ${options.file}`);
|
|
807
|
-
} catch {
|
|
808
|
-
|
|
1063
|
+
} catch (err) {
|
|
1064
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1065
|
+
console.error(`[watch] failed to reload ${options.file} \u2014 ${msg}`);
|
|
809
1066
|
}
|
|
810
1067
|
}, 100);
|
|
811
1068
|
});
|
|
@@ -816,7 +1073,9 @@ Resources (base: ${baseLabel}):`);
|
|
|
816
1073
|
}
|
|
817
1074
|
|
|
818
1075
|
// src/cli/index.ts
|
|
819
|
-
|
|
1076
|
+
var require2 = createRequire(import.meta.url);
|
|
1077
|
+
var { version } = require2("../../package.json");
|
|
1078
|
+
program.name("yrest").description("Zero-config REST API mock server powered by a YAML file").version(version);
|
|
820
1079
|
registerInit(program);
|
|
821
1080
|
registerServe(program);
|
|
822
1081
|
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;
|