@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.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
|
}
|
|
@@ -177,11 +211,92 @@ function createYamlStorage(filePath) {
|
|
|
177
211
|
var import_fastify = __toESM(require("fastify"));
|
|
178
212
|
var import_cors = __toESM(require("@fastify/cors"));
|
|
179
213
|
|
|
180
|
-
// src/
|
|
214
|
+
// src/utils/params.ts
|
|
181
215
|
function nextId(items) {
|
|
182
216
|
const ids = items.map((i) => i["id"]).filter((id) => typeof id === "number");
|
|
183
217
|
return ids.length > 0 ? Math.max(...ids) + 1 : 1;
|
|
184
218
|
}
|
|
219
|
+
function firstParam(value) {
|
|
220
|
+
if (value === void 0) return void 0;
|
|
221
|
+
return Array.isArray(value) ? value[0] : value;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// src/services/query.service.ts
|
|
225
|
+
var OPERATORS = ["_gte", "_lte", "_ne", "_like", "_start", "_regex"];
|
|
226
|
+
function applyOperator(itemValue, op, filterValue) {
|
|
227
|
+
const strItem = String(itemValue);
|
|
228
|
+
const numItem = Number(itemValue);
|
|
229
|
+
const numFilter = Number(filterValue);
|
|
230
|
+
const numeric = !isNaN(numItem) && !isNaN(numFilter) && filterValue.trim() !== "";
|
|
231
|
+
switch (op) {
|
|
232
|
+
case "_gte":
|
|
233
|
+
return numeric ? numItem >= numFilter : strItem >= filterValue;
|
|
234
|
+
case "_lte":
|
|
235
|
+
return numeric ? numItem <= numFilter : strItem <= filterValue;
|
|
236
|
+
case "_ne":
|
|
237
|
+
return strItem !== filterValue;
|
|
238
|
+
case "_like":
|
|
239
|
+
return strItem.toLowerCase().includes(filterValue.toLowerCase());
|
|
240
|
+
case "_start":
|
|
241
|
+
return strItem.toLowerCase().startsWith(filterValue.toLowerCase());
|
|
242
|
+
case "_regex": {
|
|
243
|
+
try {
|
|
244
|
+
return new RegExp(filterValue, "i").test(strItem);
|
|
245
|
+
} catch {
|
|
246
|
+
return false;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
function filterByQuery(items, query) {
|
|
252
|
+
const filters = Object.entries(query).filter(([key]) => !key.startsWith("_"));
|
|
253
|
+
if (filters.length === 0) return items;
|
|
254
|
+
return items.filter(
|
|
255
|
+
(item) => filters.every(([key, value]) => {
|
|
256
|
+
const op = OPERATORS.find((o) => key.endsWith(o));
|
|
257
|
+
if (op) {
|
|
258
|
+
const field = key.slice(0, -op.length);
|
|
259
|
+
if (item[field] === void 0) return false;
|
|
260
|
+
const filterVal = Array.isArray(value) ? value[0] : value;
|
|
261
|
+
return applyOperator(item[field], op, filterVal);
|
|
262
|
+
}
|
|
263
|
+
if (item[key] === void 0) return false;
|
|
264
|
+
const itemStr = String(item[key]);
|
|
265
|
+
return Array.isArray(value) ? value.includes(itemStr) : itemStr === value;
|
|
266
|
+
})
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
function fullTextSearch(items, term) {
|
|
270
|
+
const lower = term.toLowerCase();
|
|
271
|
+
return items.filter(
|
|
272
|
+
(item) => Object.values(item).some((val) => {
|
|
273
|
+
if (val === null || val === void 0 || typeof val === "object") return false;
|
|
274
|
+
return String(val).toLowerCase().includes(lower);
|
|
275
|
+
})
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
function sortBy(items, field, order) {
|
|
279
|
+
const direction = order === "desc" ? -1 : 1;
|
|
280
|
+
return [...items].sort((a, b) => {
|
|
281
|
+
const av = a[field];
|
|
282
|
+
const bv = b[field];
|
|
283
|
+
if (av === void 0) return 1;
|
|
284
|
+
if (bv === void 0) return -1;
|
|
285
|
+
if (typeof av === "number" && typeof bv === "number") return (av - bv) * direction;
|
|
286
|
+
return String(av).localeCompare(String(bv), void 0, { sensitivity: "base" }) * direction;
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
function projectFields(input, fields) {
|
|
290
|
+
if (fields.length === 0) return input;
|
|
291
|
+
const project = (item) => Object.fromEntries(fields.filter((f) => f in item).map((f) => [f, item[f]]));
|
|
292
|
+
return Array.isArray(input) ? input.map(project) : project(input);
|
|
293
|
+
}
|
|
294
|
+
function paginate(items, page, limit) {
|
|
295
|
+
const start = (page - 1) * limit;
|
|
296
|
+
return items.slice(start, start + limit);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// src/services/resource.service.ts
|
|
185
300
|
function findById(items, id) {
|
|
186
301
|
return items.find((i) => String(i["id"]) === id);
|
|
187
302
|
}
|
|
@@ -218,36 +333,17 @@ function patchItem(storage, resource, id, body) {
|
|
|
218
333
|
storage.persist();
|
|
219
334
|
return updated;
|
|
220
335
|
}
|
|
221
|
-
function
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
return
|
|
229
|
-
(item) => filters.every(([key, value]) => {
|
|
230
|
-
if (item[key] === void 0) return false;
|
|
231
|
-
const itemStr = String(item[key]);
|
|
232
|
-
return Array.isArray(value) ? value.includes(itemStr) : itemStr === value;
|
|
233
|
-
})
|
|
234
|
-
);
|
|
235
|
-
}
|
|
236
|
-
function sortBy(items, field, order) {
|
|
237
|
-
const direction = order === "desc" ? -1 : 1;
|
|
238
|
-
return [...items].sort((a, b) => {
|
|
239
|
-
const av = a[field];
|
|
240
|
-
const bv = b[field];
|
|
241
|
-
if (av === void 0) return 1;
|
|
242
|
-
if (bv === void 0) return -1;
|
|
243
|
-
if (typeof av === "number" && typeof bv === "number") return (av - bv) * direction;
|
|
244
|
-
return String(av).localeCompare(String(bv), void 0, { sensitivity: "base" }) * direction;
|
|
245
|
-
});
|
|
246
|
-
}
|
|
247
|
-
function paginate(items, page, limit) {
|
|
248
|
-
const start = (page - 1) * limit;
|
|
249
|
-
return items.slice(start, start + limit);
|
|
336
|
+
function deleteItem(storage, resource, id) {
|
|
337
|
+
const collection = storage.getCollection(resource) ?? [];
|
|
338
|
+
const idx = findIndexById(collection, id);
|
|
339
|
+
if (idx === -1) return void 0;
|
|
340
|
+
const [deleted] = collection.splice(idx, 1);
|
|
341
|
+
storage.setCollection(resource, collection);
|
|
342
|
+
storage.persist();
|
|
343
|
+
return deleted;
|
|
250
344
|
}
|
|
345
|
+
|
|
346
|
+
// src/services/expand.service.ts
|
|
251
347
|
function expandItems(input, query, resource, storage) {
|
|
252
348
|
const isArray = Array.isArray(input);
|
|
253
349
|
const items = isArray ? input : [input];
|
|
@@ -280,14 +376,35 @@ function expandItems(input, query, resource, storage) {
|
|
|
280
376
|
});
|
|
281
377
|
return isArray ? expanded : expanded[0];
|
|
282
378
|
}
|
|
283
|
-
function
|
|
284
|
-
const
|
|
285
|
-
const
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
storage.
|
|
290
|
-
|
|
379
|
+
function embedItems(input, query, resource, storage) {
|
|
380
|
+
const isArray = Array.isArray(input);
|
|
381
|
+
const items = isArray ? input : [input];
|
|
382
|
+
const embedParam = query["_embed"];
|
|
383
|
+
if (!embedParam) return isArray ? items : input;
|
|
384
|
+
const keys = (Array.isArray(embedParam) ? embedParam : [embedParam]).flatMap((v) => v.split(",")).map((k) => k.trim()).filter(Boolean);
|
|
385
|
+
const relations = storage.getRelations();
|
|
386
|
+
const embeds = /* @__PURE__ */ new Map();
|
|
387
|
+
for (const embedKey of keys) {
|
|
388
|
+
outer: for (const [childCollection, fields] of Object.entries(relations)) {
|
|
389
|
+
for (const [fkField, parentCollection] of Object.entries(fields)) {
|
|
390
|
+
if (parentCollection === resource && childCollection === embedKey) {
|
|
391
|
+
embeds.set(embedKey, { childCollection, fkField });
|
|
392
|
+
break outer;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
if (embeds.size === 0) return isArray ? items : input;
|
|
398
|
+
const result = items.map((item) => {
|
|
399
|
+
const out = { ...item };
|
|
400
|
+
for (const [embedKey, { childCollection, fkField }] of embeds) {
|
|
401
|
+
out[embedKey] = (storage.getCollection(childCollection) ?? []).filter(
|
|
402
|
+
(child) => String(child[fkField]) === String(item["id"])
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
return out;
|
|
406
|
+
});
|
|
407
|
+
return isArray ? result : result[0];
|
|
291
408
|
}
|
|
292
409
|
|
|
293
410
|
// src/router/routes/collection.routes.ts
|
|
@@ -295,9 +412,12 @@ var registerCollectionRoutes = (server, storage, resource, base, options) => {
|
|
|
295
412
|
server.get(base, (req, reply) => {
|
|
296
413
|
const collection = storage.getCollection(resource) ?? [];
|
|
297
414
|
const filtered = filterByQuery(collection, req.query);
|
|
415
|
+
const searchTerm = firstParam(req.query["_q"]);
|
|
416
|
+
const searched = searchTerm ? fullTextSearch(filtered, searchTerm) : filtered;
|
|
417
|
+
const fields = (firstParam(req.query["_fields"]) ?? "").split(",").map((f) => f.trim()).filter(Boolean);
|
|
298
418
|
const sortField = firstParam(req.query["_sort"]);
|
|
299
419
|
const sortOrder = firstParam(req.query["_order"]) === "desc" ? "desc" : "asc";
|
|
300
|
-
const sorted = sortField ? sortBy(
|
|
420
|
+
const sorted = sortField ? sortBy(searched, sortField, sortOrder) : searched;
|
|
301
421
|
if (options.pageable.enabled) {
|
|
302
422
|
const defaultLimit = options.pageable.limit;
|
|
303
423
|
const page = Math.max(1, parseInt(firstParam(req.query["_page"]) ?? "1", 10) || 1);
|
|
@@ -307,7 +427,15 @@ var registerCollectionRoutes = (server, storage, resource, base, options) => {
|
|
|
307
427
|
);
|
|
308
428
|
const totalItems = sorted.length;
|
|
309
429
|
const totalPages = Math.ceil(totalItems / limit) || 1;
|
|
310
|
-
const data =
|
|
430
|
+
const data = projectFields(
|
|
431
|
+
embedItems(
|
|
432
|
+
expandItems(paginate(sorted, page, limit), req.query, resource, storage),
|
|
433
|
+
req.query,
|
|
434
|
+
resource,
|
|
435
|
+
storage
|
|
436
|
+
),
|
|
437
|
+
fields
|
|
438
|
+
);
|
|
311
439
|
const pagination = {
|
|
312
440
|
page,
|
|
313
441
|
limit,
|
|
@@ -331,9 +459,15 @@ var registerCollectionRoutes = (server, storage, resource, base, options) => {
|
|
|
331
459
|
reply.header("X-Total-Count", String(sorted.length));
|
|
332
460
|
result = paginate(sorted, page, limit);
|
|
333
461
|
}
|
|
334
|
-
return
|
|
462
|
+
return projectFields(
|
|
463
|
+
embedItems(expandItems(result, req.query, resource, storage), req.query, resource, storage),
|
|
464
|
+
fields
|
|
465
|
+
);
|
|
335
466
|
});
|
|
336
467
|
server.post(base, (req, reply) => {
|
|
468
|
+
if (!req.body || typeof req.body !== "object" || Array.isArray(req.body)) {
|
|
469
|
+
return reply.status(400).send({ error: "Request body must be a JSON object" });
|
|
470
|
+
}
|
|
337
471
|
const item = createItem(storage, resource, req.body);
|
|
338
472
|
return reply.status(201).send(expandItems(item, req.query, resource, storage));
|
|
339
473
|
});
|
|
@@ -344,14 +478,24 @@ var registerItemRoutes = (server, storage, resource, base) => {
|
|
|
344
478
|
server.get(`${base}/:id`, (req, reply) => {
|
|
345
479
|
const item = findById(storage.getCollection(resource) ?? [], req.params.id);
|
|
346
480
|
if (!item) return reply.status(404).send({ error: "Not found" });
|
|
347
|
-
|
|
481
|
+
const fields = (req.query["_fields"] ?? "").split(",").map((f) => f.trim()).filter(Boolean);
|
|
482
|
+
return projectFields(
|
|
483
|
+
embedItems(expandItems(item, req.query, resource, storage), req.query, resource, storage),
|
|
484
|
+
fields
|
|
485
|
+
);
|
|
348
486
|
});
|
|
349
487
|
server.put(`${base}/:id`, (req, reply) => {
|
|
488
|
+
if (!req.body || typeof req.body !== "object" || Array.isArray(req.body)) {
|
|
489
|
+
return reply.status(400).send({ error: "Request body must be a JSON object" });
|
|
490
|
+
}
|
|
350
491
|
const item = replaceItem(storage, resource, req.params.id, req.body);
|
|
351
492
|
if (!item) return reply.status(404).send({ error: "Not found" });
|
|
352
493
|
return expandItems(item, req.query, resource, storage);
|
|
353
494
|
});
|
|
354
495
|
server.patch(`${base}/:id`, (req, reply) => {
|
|
496
|
+
if (!req.body || typeof req.body !== "object" || Array.isArray(req.body)) {
|
|
497
|
+
return reply.status(400).send({ error: "Request body must be a JSON object" });
|
|
498
|
+
}
|
|
355
499
|
const item = patchItem(storage, resource, req.params.id, req.body);
|
|
356
500
|
if (!item) return reply.status(404).send({ error: "Not found" });
|
|
357
501
|
return expandItems(item, req.query, resource, storage);
|
|
@@ -367,7 +511,9 @@ var registerItemRoutes = (server, storage, resource, base) => {
|
|
|
367
511
|
var registerNestedRoutes = (server, storage, relations, base) => {
|
|
368
512
|
for (const [child, fields] of Object.entries(relations)) {
|
|
369
513
|
for (const [field, parent] of Object.entries(fields)) {
|
|
370
|
-
|
|
514
|
+
const collectionPath = `${base}/${parent}/:id/${child}`;
|
|
515
|
+
const itemPath = `${base}/${parent}/:id/${child}/:childId`;
|
|
516
|
+
server.get(collectionPath, (req, reply) => {
|
|
371
517
|
const parentCollection = storage.getCollection(parent) ?? [];
|
|
372
518
|
const parentItem = findById(parentCollection, req.params.id);
|
|
373
519
|
if (!parentItem) return reply.status(404).send({ error: "Not found" });
|
|
@@ -376,6 +522,16 @@ var registerNestedRoutes = (server, storage, relations, base) => {
|
|
|
376
522
|
);
|
|
377
523
|
return children;
|
|
378
524
|
});
|
|
525
|
+
server.get(itemPath, (req, reply) => {
|
|
526
|
+
const parentCollection = storage.getCollection(parent) ?? [];
|
|
527
|
+
const parentItem = findById(parentCollection, req.params.id);
|
|
528
|
+
if (!parentItem) return reply.status(404).send({ error: "Not found" });
|
|
529
|
+
const childItem = (storage.getCollection(child) ?? []).find(
|
|
530
|
+
(item) => String(item[field]) === req.params.id && String(item["id"]) === req.params.childId
|
|
531
|
+
);
|
|
532
|
+
if (!childItem) return reply.status(404).send({ error: "Not found" });
|
|
533
|
+
return childItem;
|
|
534
|
+
});
|
|
379
535
|
}
|
|
380
536
|
}
|
|
381
537
|
};
|
|
@@ -500,6 +656,30 @@ curl ${host}${base}/${parent}/1/${child}`);
|
|
|
500
656
|
examples.push(`# Pageable envelope
|
|
501
657
|
curl "${host}${base}/${firstCol}?_page=2"`);
|
|
502
658
|
}
|
|
659
|
+
const firstParentRel = Object.entries(relations).find(
|
|
660
|
+
([, fields]) => Object.values(fields).includes(firstCol ?? "")
|
|
661
|
+
);
|
|
662
|
+
if (firstCol) {
|
|
663
|
+
examples.push(
|
|
664
|
+
`# Project fields with ?_fields
|
|
665
|
+
curl "${host}${base}/${firstCol}?_fields=id,name"`
|
|
666
|
+
);
|
|
667
|
+
}
|
|
668
|
+
if (firstParentRel && firstCol) {
|
|
669
|
+
const [childName] = firstParentRel;
|
|
670
|
+
examples.push(
|
|
671
|
+
`# Embed child collection with ?_embed
|
|
672
|
+
curl "${host}${base}/${firstCol}/1?_embed=${childName}"`
|
|
673
|
+
);
|
|
674
|
+
}
|
|
675
|
+
if (options.snapshot) {
|
|
676
|
+
examples.push(
|
|
677
|
+
`# Snapshot endpoints
|
|
678
|
+
curl ${host}/_snapshot
|
|
679
|
+
curl -X POST ${host}/_snapshot/save
|
|
680
|
+
curl -X POST ${host}/_snapshot/reset`
|
|
681
|
+
);
|
|
682
|
+
}
|
|
503
683
|
const highlighted = examples.map((e) => e.replace(/^(#.+)$/gm, '<span class="cm">$1</span>')).join("\n\n");
|
|
504
684
|
return `<pre>${highlighted}</pre>`;
|
|
505
685
|
}
|
|
@@ -514,6 +694,7 @@ function generateAboutHtml(storage, options) {
|
|
|
514
694
|
if (options.delay > 0) modes.push(badge(`delay \xB7 ${options.delay}ms`, "#fb923c", "#fb923c18"));
|
|
515
695
|
if (options.pageable.enabled)
|
|
516
696
|
modes.push(badge(`pageable \xB7 limit ${options.pageable.limit}`, "#34d399", "#34d39918"));
|
|
697
|
+
if (options.snapshot) modes.push(badge("snapshot", "#c084fc", "#c084fc18"));
|
|
517
698
|
const accordions = collections.map((col, i) => resourceAccordion(col, base, i === 0)).join("");
|
|
518
699
|
const nestedRows = [];
|
|
519
700
|
for (const [child, fields] of Object.entries(relations)) {
|
|
@@ -533,6 +714,18 @@ function generateAboutHtml(storage, options) {
|
|
|
533
714
|
</summary>
|
|
534
715
|
<table><tbody>${nestedRows.join("")}</tbody></table>
|
|
535
716
|
</details>` : "";
|
|
717
|
+
const snapshotAccordion = options.snapshot ? `
|
|
718
|
+
<details class="resource-card nested-card">
|
|
719
|
+
<summary>
|
|
720
|
+
<span class="resource-name">/_snapshot</span>
|
|
721
|
+
<span class="route-count">3 routes</span>
|
|
722
|
+
</summary>
|
|
723
|
+
<table><tbody>
|
|
724
|
+
${endpointRow("GET", "/_snapshot", "Returns metadata of the current snapshot: <code>savedAt</code> and item counts per collection.")}
|
|
725
|
+
${endpointRow("POST", "/_snapshot/save", "Replaces the stored snapshot with the current database state.")}
|
|
726
|
+
${endpointRow("POST", "/_snapshot/reset", "Restores the database to the last saved snapshot and persists to disk.")}
|
|
727
|
+
</tbody></table>
|
|
728
|
+
</details>` : "";
|
|
536
729
|
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
730
|
return `<!DOCTYPE html>
|
|
538
731
|
<html lang="en">
|
|
@@ -681,6 +874,7 @@ function generateAboutHtml(storage, options) {
|
|
|
681
874
|
<div class="endpoints-grid">
|
|
682
875
|
${accordions}
|
|
683
876
|
${nestedAccordion}
|
|
877
|
+
${snapshotAccordion}
|
|
684
878
|
</div>
|
|
685
879
|
|
|
686
880
|
<h2>Query Parameters</h2>
|
|
@@ -689,9 +883,17 @@ function generateAboutHtml(storage, options) {
|
|
|
689
883
|
<thead><tr><th>Param</th><th>Example</th><th>Description</th></tr></thead>
|
|
690
884
|
<tbody>
|
|
691
885
|
<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>
|
|
886
|
+
<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>
|
|
887
|
+
<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>
|
|
888
|
+
<tr><td><code>?field_like</code></td><td><code>?name_like=ana</code></td><td>Case-insensitive substring match.</td></tr>
|
|
889
|
+
<tr><td><code>?field_start</code></td><td><code>?name_start=A</code></td><td>Case-insensitive prefix match.</td></tr>
|
|
890
|
+
<tr><td><code>?field_regex</code></td><td><code>?email_regex=gmail</code></td><td>Case-insensitive regular expression match.</td></tr>
|
|
891
|
+
<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>
|
|
692
892
|
<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>
|
|
693
893
|
<tr><td><code>?_page & ?_limit</code></td><td><code>?_page=2&_limit=10</code></td><td>${paginationDesc}</td></tr>
|
|
694
894
|
<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>
|
|
895
|
+
<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>
|
|
896
|
+
<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>
|
|
695
897
|
</tbody>
|
|
696
898
|
</table>
|
|
697
899
|
</div>
|
|
@@ -715,11 +917,51 @@ function registerAboutRoute(server, storage, options) {
|
|
|
715
917
|
});
|
|
716
918
|
}
|
|
717
919
|
|
|
920
|
+
// src/router/routes/snapshot.routes.ts
|
|
921
|
+
function registerSnapshotRoutes(server, storage) {
|
|
922
|
+
server.get("/_snapshot", (_req, reply) => {
|
|
923
|
+
const { data, savedAt } = storage.getSnapshot();
|
|
924
|
+
return reply.send({
|
|
925
|
+
savedAt: savedAt.toISOString(),
|
|
926
|
+
collections: Object.fromEntries(
|
|
927
|
+
Object.entries(data).map(([name, items]) => [name, items.length])
|
|
928
|
+
)
|
|
929
|
+
});
|
|
930
|
+
});
|
|
931
|
+
server.post("/_snapshot/save", (_req, reply) => {
|
|
932
|
+
storage.saveSnapshot();
|
|
933
|
+
const { data, savedAt } = storage.getSnapshot();
|
|
934
|
+
return reply.send({
|
|
935
|
+
message: "Snapshot saved",
|
|
936
|
+
savedAt: savedAt.toISOString(),
|
|
937
|
+
collections: Object.fromEntries(
|
|
938
|
+
Object.entries(data).map(([name, items]) => [name, items.length])
|
|
939
|
+
)
|
|
940
|
+
});
|
|
941
|
+
});
|
|
942
|
+
server.post("/_snapshot/reset", (_req, reply) => {
|
|
943
|
+
storage.resetToSnapshot();
|
|
944
|
+
const { data, savedAt } = storage.getSnapshot();
|
|
945
|
+
return reply.send({
|
|
946
|
+
message: "Database restored to snapshot",
|
|
947
|
+
savedAt: savedAt.toISOString(),
|
|
948
|
+
collections: Object.fromEntries(
|
|
949
|
+
Object.entries(data).map(([name, items]) => [name, items.length])
|
|
950
|
+
)
|
|
951
|
+
});
|
|
952
|
+
});
|
|
953
|
+
}
|
|
954
|
+
|
|
718
955
|
// src/server/createServer.ts
|
|
719
956
|
var MUTATING_METHODS = /* @__PURE__ */ new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
|
720
957
|
async function createServer(storage, options) {
|
|
721
958
|
const server = (0, import_fastify.default)();
|
|
722
959
|
await server.register(import_cors.default);
|
|
960
|
+
server.setErrorHandler((err, _req, reply) => {
|
|
961
|
+
const status = err.statusCode ?? 500;
|
|
962
|
+
const message = status < 500 ? err.message || "Request error" : "Internal server error";
|
|
963
|
+
reply.status(status).send({ error: message });
|
|
964
|
+
});
|
|
723
965
|
if (options.readonly) {
|
|
724
966
|
server.addHook("onRequest", (_req, reply, done) => {
|
|
725
967
|
if (MUTATING_METHODS.has(_req.method)) {
|
|
@@ -734,6 +976,7 @@ async function createServer(storage, options) {
|
|
|
734
976
|
});
|
|
735
977
|
}
|
|
736
978
|
registerAboutRoute(server, storage, options);
|
|
979
|
+
if (options.snapshot) registerSnapshotRoutes(server, storage);
|
|
737
980
|
registerResourceRoutes(server, storage, options);
|
|
738
981
|
return server;
|
|
739
982
|
}
|
|
@@ -754,6 +997,11 @@ var serverOptionsSchema = import_zod.z.object({
|
|
|
754
997
|
base: import_zod.z.string().default("").transform((v) => v && !v.startsWith("/") ? `/${v}` : v),
|
|
755
998
|
/** When `true`, the server reloads the YAML file automatically on disk changes. */
|
|
756
999
|
watch: import_zod.z.boolean().default(false),
|
|
1000
|
+
/**
|
|
1001
|
+
* When `true`, saves a snapshot of the initial database state on startup.
|
|
1002
|
+
* Exposes `/_snapshot` endpoints to inspect, restore or update the snapshot.
|
|
1003
|
+
*/
|
|
1004
|
+
snapshot: import_zod.z.boolean().default(false),
|
|
757
1005
|
/** When `true`, all mutating requests (POST, PUT, PATCH, DELETE) are rejected with 405. */
|
|
758
1006
|
readonly: import_zod.z.boolean().default(false),
|
|
759
1007
|
/** Milliseconds to delay every response, simulating network latency. `0` = disabled. */
|
|
@@ -786,6 +1034,9 @@ function registerServe(program2) {
|
|
|
786
1034
|
).option(
|
|
787
1035
|
"--pageable [limit]",
|
|
788
1036
|
"Wrap GET collection responses in { data, pagination } envelope. Optionally set default page size (default: 10)"
|
|
1037
|
+
).option(
|
|
1038
|
+
"--snapshot",
|
|
1039
|
+
"Save a snapshot of the initial database state and expose /_snapshot endpoints"
|
|
789
1040
|
).action(async (file, flags, cmd) => {
|
|
790
1041
|
const fileConfig = loadConfigFile((0, import_node_path3.join)(process.cwd(), "yrest.config.yml"));
|
|
791
1042
|
const cliOverrides = Object.fromEntries(
|
|
@@ -798,7 +1049,14 @@ function registerServe(program2) {
|
|
|
798
1049
|
...cliOverrides
|
|
799
1050
|
};
|
|
800
1051
|
const options = serverOptionsSchema.parse(merged);
|
|
801
|
-
|
|
1052
|
+
let storage;
|
|
1053
|
+
try {
|
|
1054
|
+
storage = createYamlStorage(options.file);
|
|
1055
|
+
} catch (err) {
|
|
1056
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1057
|
+
console.error(`Error: cannot load "${options.file}" \u2014 ${msg}`);
|
|
1058
|
+
process.exit(1);
|
|
1059
|
+
}
|
|
802
1060
|
const server = await createServer(storage, options);
|
|
803
1061
|
await server.listen({ port: options.port, host: options.host });
|
|
804
1062
|
const collections = Object.keys(storage.getData());
|
|
@@ -811,6 +1069,8 @@ yrest running at http://${options.host}:${options.port}`);
|
|
|
811
1069
|
console.log(
|
|
812
1070
|
`[pageable] responses wrapped in { data, pagination } (limit: ${options.pageable.limit})`
|
|
813
1071
|
);
|
|
1072
|
+
if (options.snapshot)
|
|
1073
|
+
console.log("[snapshot] /_snapshot endpoints enabled \u2014 GET / POST save / POST reset");
|
|
814
1074
|
console.log(`
|
|
815
1075
|
Resources (base: ${baseLabel}):`);
|
|
816
1076
|
for (const name of collections) {
|
|
@@ -827,8 +1087,9 @@ Resources (base: ${baseLabel}):`);
|
|
|
827
1087
|
try {
|
|
828
1088
|
storage.reload();
|
|
829
1089
|
console.log(`[watch] reloaded ${options.file}`);
|
|
830
|
-
} catch {
|
|
831
|
-
|
|
1090
|
+
} catch (err) {
|
|
1091
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1092
|
+
console.error(`[watch] failed to reload ${options.file} \u2014 ${msg}`);
|
|
832
1093
|
}
|
|
833
1094
|
}, 100);
|
|
834
1095
|
});
|
|
@@ -839,7 +1100,9 @@ Resources (base: ${baseLabel}):`);
|
|
|
839
1100
|
}
|
|
840
1101
|
|
|
841
1102
|
// src/cli/index.ts
|
|
842
|
-
|
|
1103
|
+
var require2 = (0, import_module.createRequire)(importMetaUrl);
|
|
1104
|
+
var { version } = require2("../../package.json");
|
|
1105
|
+
import_commander.program.name("yrest").description("Zero-config REST API mock server powered by a YAML file").version(version);
|
|
843
1106
|
registerInit(import_commander.program);
|
|
844
1107
|
registerServe(import_commander.program);
|
|
845
1108
|
import_commander.program.parse();
|