@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/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
|
}
|
|
@@ -47,11 +75,92 @@ function createYamlStorage(filePath) {
|
|
|
47
75
|
import Fastify from "fastify";
|
|
48
76
|
import cors from "@fastify/cors";
|
|
49
77
|
|
|
50
|
-
// src/
|
|
78
|
+
// src/utils/params.ts
|
|
51
79
|
function nextId(items) {
|
|
52
80
|
const ids = items.map((i) => i["id"]).filter((id) => typeof id === "number");
|
|
53
81
|
return ids.length > 0 ? Math.max(...ids) + 1 : 1;
|
|
54
82
|
}
|
|
83
|
+
function firstParam(value) {
|
|
84
|
+
if (value === void 0) return void 0;
|
|
85
|
+
return Array.isArray(value) ? value[0] : value;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// src/services/query.service.ts
|
|
89
|
+
var OPERATORS = ["_gte", "_lte", "_ne", "_like", "_start", "_regex"];
|
|
90
|
+
function applyOperator(itemValue, op, filterValue) {
|
|
91
|
+
const strItem = String(itemValue);
|
|
92
|
+
const numItem = Number(itemValue);
|
|
93
|
+
const numFilter = Number(filterValue);
|
|
94
|
+
const numeric = !isNaN(numItem) && !isNaN(numFilter) && filterValue.trim() !== "";
|
|
95
|
+
switch (op) {
|
|
96
|
+
case "_gte":
|
|
97
|
+
return numeric ? numItem >= numFilter : strItem >= filterValue;
|
|
98
|
+
case "_lte":
|
|
99
|
+
return numeric ? numItem <= numFilter : strItem <= filterValue;
|
|
100
|
+
case "_ne":
|
|
101
|
+
return strItem !== filterValue;
|
|
102
|
+
case "_like":
|
|
103
|
+
return strItem.toLowerCase().includes(filterValue.toLowerCase());
|
|
104
|
+
case "_start":
|
|
105
|
+
return strItem.toLowerCase().startsWith(filterValue.toLowerCase());
|
|
106
|
+
case "_regex": {
|
|
107
|
+
try {
|
|
108
|
+
return new RegExp(filterValue, "i").test(strItem);
|
|
109
|
+
} catch {
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
function filterByQuery(items, query) {
|
|
116
|
+
const filters = Object.entries(query).filter(([key]) => !key.startsWith("_"));
|
|
117
|
+
if (filters.length === 0) return items;
|
|
118
|
+
return items.filter(
|
|
119
|
+
(item) => filters.every(([key, value]) => {
|
|
120
|
+
const op = OPERATORS.find((o) => key.endsWith(o));
|
|
121
|
+
if (op) {
|
|
122
|
+
const field = key.slice(0, -op.length);
|
|
123
|
+
if (item[field] === void 0) return false;
|
|
124
|
+
const filterVal = Array.isArray(value) ? value[0] : value;
|
|
125
|
+
return applyOperator(item[field], op, filterVal);
|
|
126
|
+
}
|
|
127
|
+
if (item[key] === void 0) return false;
|
|
128
|
+
const itemStr = String(item[key]);
|
|
129
|
+
return Array.isArray(value) ? value.includes(itemStr) : itemStr === value;
|
|
130
|
+
})
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
function fullTextSearch(items, term) {
|
|
134
|
+
const lower = term.toLowerCase();
|
|
135
|
+
return items.filter(
|
|
136
|
+
(item) => Object.values(item).some((val) => {
|
|
137
|
+
if (val === null || val === void 0 || typeof val === "object") return false;
|
|
138
|
+
return String(val).toLowerCase().includes(lower);
|
|
139
|
+
})
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
function sortBy(items, field, order) {
|
|
143
|
+
const direction = order === "desc" ? -1 : 1;
|
|
144
|
+
return [...items].sort((a, b) => {
|
|
145
|
+
const av = a[field];
|
|
146
|
+
const bv = b[field];
|
|
147
|
+
if (av === void 0) return 1;
|
|
148
|
+
if (bv === void 0) return -1;
|
|
149
|
+
if (typeof av === "number" && typeof bv === "number") return (av - bv) * direction;
|
|
150
|
+
return String(av).localeCompare(String(bv), void 0, { sensitivity: "base" }) * direction;
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
function projectFields(input, fields) {
|
|
154
|
+
if (fields.length === 0) return input;
|
|
155
|
+
const project = (item) => Object.fromEntries(fields.filter((f) => f in item).map((f) => [f, item[f]]));
|
|
156
|
+
return Array.isArray(input) ? input.map(project) : project(input);
|
|
157
|
+
}
|
|
158
|
+
function paginate(items, page, limit) {
|
|
159
|
+
const start = (page - 1) * limit;
|
|
160
|
+
return items.slice(start, start + limit);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// src/services/resource.service.ts
|
|
55
164
|
function findById(items, id) {
|
|
56
165
|
return items.find((i) => String(i["id"]) === id);
|
|
57
166
|
}
|
|
@@ -88,36 +197,17 @@ function patchItem(storage, resource, id, body) {
|
|
|
88
197
|
storage.persist();
|
|
89
198
|
return updated;
|
|
90
199
|
}
|
|
91
|
-
function
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
return
|
|
99
|
-
(item) => filters.every(([key, value]) => {
|
|
100
|
-
if (item[key] === void 0) return false;
|
|
101
|
-
const itemStr = String(item[key]);
|
|
102
|
-
return Array.isArray(value) ? value.includes(itemStr) : itemStr === value;
|
|
103
|
-
})
|
|
104
|
-
);
|
|
105
|
-
}
|
|
106
|
-
function sortBy(items, field, order) {
|
|
107
|
-
const direction = order === "desc" ? -1 : 1;
|
|
108
|
-
return [...items].sort((a, b) => {
|
|
109
|
-
const av = a[field];
|
|
110
|
-
const bv = b[field];
|
|
111
|
-
if (av === void 0) return 1;
|
|
112
|
-
if (bv === void 0) return -1;
|
|
113
|
-
if (typeof av === "number" && typeof bv === "number") return (av - bv) * direction;
|
|
114
|
-
return String(av).localeCompare(String(bv), void 0, { sensitivity: "base" }) * direction;
|
|
115
|
-
});
|
|
116
|
-
}
|
|
117
|
-
function paginate(items, page, limit) {
|
|
118
|
-
const start = (page - 1) * limit;
|
|
119
|
-
return items.slice(start, start + limit);
|
|
200
|
+
function deleteItem(storage, resource, id) {
|
|
201
|
+
const collection = storage.getCollection(resource) ?? [];
|
|
202
|
+
const idx = findIndexById(collection, id);
|
|
203
|
+
if (idx === -1) return void 0;
|
|
204
|
+
const [deleted] = collection.splice(idx, 1);
|
|
205
|
+
storage.setCollection(resource, collection);
|
|
206
|
+
storage.persist();
|
|
207
|
+
return deleted;
|
|
120
208
|
}
|
|
209
|
+
|
|
210
|
+
// src/services/expand.service.ts
|
|
121
211
|
function expandItems(input, query, resource, storage) {
|
|
122
212
|
const isArray = Array.isArray(input);
|
|
123
213
|
const items = isArray ? input : [input];
|
|
@@ -150,14 +240,35 @@ function expandItems(input, query, resource, storage) {
|
|
|
150
240
|
});
|
|
151
241
|
return isArray ? expanded : expanded[0];
|
|
152
242
|
}
|
|
153
|
-
function
|
|
154
|
-
const
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
storage.
|
|
160
|
-
|
|
243
|
+
function embedItems(input, query, resource, storage) {
|
|
244
|
+
const isArray = Array.isArray(input);
|
|
245
|
+
const items = isArray ? input : [input];
|
|
246
|
+
const embedParam = query["_embed"];
|
|
247
|
+
if (!embedParam) return isArray ? items : input;
|
|
248
|
+
const keys = (Array.isArray(embedParam) ? embedParam : [embedParam]).flatMap((v) => v.split(",")).map((k) => k.trim()).filter(Boolean);
|
|
249
|
+
const relations = storage.getRelations();
|
|
250
|
+
const embeds = /* @__PURE__ */ new Map();
|
|
251
|
+
for (const embedKey of keys) {
|
|
252
|
+
outer: for (const [childCollection, fields] of Object.entries(relations)) {
|
|
253
|
+
for (const [fkField, parentCollection] of Object.entries(fields)) {
|
|
254
|
+
if (parentCollection === resource && childCollection === embedKey) {
|
|
255
|
+
embeds.set(embedKey, { childCollection, fkField });
|
|
256
|
+
break outer;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
if (embeds.size === 0) return isArray ? items : input;
|
|
262
|
+
const result = items.map((item) => {
|
|
263
|
+
const out = { ...item };
|
|
264
|
+
for (const [embedKey, { childCollection, fkField }] of embeds) {
|
|
265
|
+
out[embedKey] = (storage.getCollection(childCollection) ?? []).filter(
|
|
266
|
+
(child) => String(child[fkField]) === String(item["id"])
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
return out;
|
|
270
|
+
});
|
|
271
|
+
return isArray ? result : result[0];
|
|
161
272
|
}
|
|
162
273
|
|
|
163
274
|
// src/router/routes/collection.routes.ts
|
|
@@ -165,9 +276,12 @@ var registerCollectionRoutes = (server, storage, resource, base, options) => {
|
|
|
165
276
|
server.get(base, (req, reply) => {
|
|
166
277
|
const collection = storage.getCollection(resource) ?? [];
|
|
167
278
|
const filtered = filterByQuery(collection, req.query);
|
|
279
|
+
const searchTerm = firstParam(req.query["_q"]);
|
|
280
|
+
const searched = searchTerm ? fullTextSearch(filtered, searchTerm) : filtered;
|
|
281
|
+
const fields = (firstParam(req.query["_fields"]) ?? "").split(",").map((f) => f.trim()).filter(Boolean);
|
|
168
282
|
const sortField = firstParam(req.query["_sort"]);
|
|
169
283
|
const sortOrder = firstParam(req.query["_order"]) === "desc" ? "desc" : "asc";
|
|
170
|
-
const sorted = sortField ? sortBy(
|
|
284
|
+
const sorted = sortField ? sortBy(searched, sortField, sortOrder) : searched;
|
|
171
285
|
if (options.pageable.enabled) {
|
|
172
286
|
const defaultLimit = options.pageable.limit;
|
|
173
287
|
const page = Math.max(1, parseInt(firstParam(req.query["_page"]) ?? "1", 10) || 1);
|
|
@@ -177,7 +291,15 @@ var registerCollectionRoutes = (server, storage, resource, base, options) => {
|
|
|
177
291
|
);
|
|
178
292
|
const totalItems = sorted.length;
|
|
179
293
|
const totalPages = Math.ceil(totalItems / limit) || 1;
|
|
180
|
-
const data =
|
|
294
|
+
const data = projectFields(
|
|
295
|
+
embedItems(
|
|
296
|
+
expandItems(paginate(sorted, page, limit), req.query, resource, storage),
|
|
297
|
+
req.query,
|
|
298
|
+
resource,
|
|
299
|
+
storage
|
|
300
|
+
),
|
|
301
|
+
fields
|
|
302
|
+
);
|
|
181
303
|
const pagination = {
|
|
182
304
|
page,
|
|
183
305
|
limit,
|
|
@@ -201,9 +323,15 @@ var registerCollectionRoutes = (server, storage, resource, base, options) => {
|
|
|
201
323
|
reply.header("X-Total-Count", String(sorted.length));
|
|
202
324
|
result = paginate(sorted, page, limit);
|
|
203
325
|
}
|
|
204
|
-
return
|
|
326
|
+
return projectFields(
|
|
327
|
+
embedItems(expandItems(result, req.query, resource, storage), req.query, resource, storage),
|
|
328
|
+
fields
|
|
329
|
+
);
|
|
205
330
|
});
|
|
206
331
|
server.post(base, (req, reply) => {
|
|
332
|
+
if (!req.body || typeof req.body !== "object" || Array.isArray(req.body)) {
|
|
333
|
+
return reply.status(400).send({ error: "Request body must be a JSON object" });
|
|
334
|
+
}
|
|
207
335
|
const item = createItem(storage, resource, req.body);
|
|
208
336
|
return reply.status(201).send(expandItems(item, req.query, resource, storage));
|
|
209
337
|
});
|
|
@@ -214,14 +342,24 @@ var registerItemRoutes = (server, storage, resource, base) => {
|
|
|
214
342
|
server.get(`${base}/:id`, (req, reply) => {
|
|
215
343
|
const item = findById(storage.getCollection(resource) ?? [], req.params.id);
|
|
216
344
|
if (!item) return reply.status(404).send({ error: "Not found" });
|
|
217
|
-
|
|
345
|
+
const fields = (req.query["_fields"] ?? "").split(",").map((f) => f.trim()).filter(Boolean);
|
|
346
|
+
return projectFields(
|
|
347
|
+
embedItems(expandItems(item, req.query, resource, storage), req.query, resource, storage),
|
|
348
|
+
fields
|
|
349
|
+
);
|
|
218
350
|
});
|
|
219
351
|
server.put(`${base}/:id`, (req, reply) => {
|
|
352
|
+
if (!req.body || typeof req.body !== "object" || Array.isArray(req.body)) {
|
|
353
|
+
return reply.status(400).send({ error: "Request body must be a JSON object" });
|
|
354
|
+
}
|
|
220
355
|
const item = replaceItem(storage, resource, req.params.id, req.body);
|
|
221
356
|
if (!item) return reply.status(404).send({ error: "Not found" });
|
|
222
357
|
return expandItems(item, req.query, resource, storage);
|
|
223
358
|
});
|
|
224
359
|
server.patch(`${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
|
+
}
|
|
225
363
|
const item = patchItem(storage, resource, req.params.id, req.body);
|
|
226
364
|
if (!item) return reply.status(404).send({ error: "Not found" });
|
|
227
365
|
return expandItems(item, req.query, resource, storage);
|
|
@@ -237,7 +375,9 @@ var registerItemRoutes = (server, storage, resource, base) => {
|
|
|
237
375
|
var registerNestedRoutes = (server, storage, relations, base) => {
|
|
238
376
|
for (const [child, fields] of Object.entries(relations)) {
|
|
239
377
|
for (const [field, parent] of Object.entries(fields)) {
|
|
240
|
-
|
|
378
|
+
const collectionPath = `${base}/${parent}/:id/${child}`;
|
|
379
|
+
const itemPath = `${base}/${parent}/:id/${child}/:childId`;
|
|
380
|
+
server.get(collectionPath, (req, reply) => {
|
|
241
381
|
const parentCollection = storage.getCollection(parent) ?? [];
|
|
242
382
|
const parentItem = findById(parentCollection, req.params.id);
|
|
243
383
|
if (!parentItem) return reply.status(404).send({ error: "Not found" });
|
|
@@ -246,6 +386,16 @@ var registerNestedRoutes = (server, storage, relations, base) => {
|
|
|
246
386
|
);
|
|
247
387
|
return children;
|
|
248
388
|
});
|
|
389
|
+
server.get(itemPath, (req, reply) => {
|
|
390
|
+
const parentCollection = storage.getCollection(parent) ?? [];
|
|
391
|
+
const parentItem = findById(parentCollection, req.params.id);
|
|
392
|
+
if (!parentItem) return reply.status(404).send({ error: "Not found" });
|
|
393
|
+
const childItem = (storage.getCollection(child) ?? []).find(
|
|
394
|
+
(item) => String(item[field]) === req.params.id && String(item["id"]) === req.params.childId
|
|
395
|
+
);
|
|
396
|
+
if (!childItem) return reply.status(404).send({ error: "Not found" });
|
|
397
|
+
return childItem;
|
|
398
|
+
});
|
|
249
399
|
}
|
|
250
400
|
}
|
|
251
401
|
};
|
|
@@ -370,6 +520,30 @@ curl ${host}${base}/${parent}/1/${child}`);
|
|
|
370
520
|
examples.push(`# Pageable envelope
|
|
371
521
|
curl "${host}${base}/${firstCol}?_page=2"`);
|
|
372
522
|
}
|
|
523
|
+
const firstParentRel = Object.entries(relations).find(
|
|
524
|
+
([, fields]) => Object.values(fields).includes(firstCol ?? "")
|
|
525
|
+
);
|
|
526
|
+
if (firstCol) {
|
|
527
|
+
examples.push(
|
|
528
|
+
`# Project fields with ?_fields
|
|
529
|
+
curl "${host}${base}/${firstCol}?_fields=id,name"`
|
|
530
|
+
);
|
|
531
|
+
}
|
|
532
|
+
if (firstParentRel && firstCol) {
|
|
533
|
+
const [childName] = firstParentRel;
|
|
534
|
+
examples.push(
|
|
535
|
+
`# Embed child collection with ?_embed
|
|
536
|
+
curl "${host}${base}/${firstCol}/1?_embed=${childName}"`
|
|
537
|
+
);
|
|
538
|
+
}
|
|
539
|
+
if (options.snapshot) {
|
|
540
|
+
examples.push(
|
|
541
|
+
`# Snapshot endpoints
|
|
542
|
+
curl ${host}/_snapshot
|
|
543
|
+
curl -X POST ${host}/_snapshot/save
|
|
544
|
+
curl -X POST ${host}/_snapshot/reset`
|
|
545
|
+
);
|
|
546
|
+
}
|
|
373
547
|
const highlighted = examples.map((e) => e.replace(/^(#.+)$/gm, '<span class="cm">$1</span>')).join("\n\n");
|
|
374
548
|
return `<pre>${highlighted}</pre>`;
|
|
375
549
|
}
|
|
@@ -384,6 +558,7 @@ function generateAboutHtml(storage, options) {
|
|
|
384
558
|
if (options.delay > 0) modes.push(badge(`delay \xB7 ${options.delay}ms`, "#fb923c", "#fb923c18"));
|
|
385
559
|
if (options.pageable.enabled)
|
|
386
560
|
modes.push(badge(`pageable \xB7 limit ${options.pageable.limit}`, "#34d399", "#34d39918"));
|
|
561
|
+
if (options.snapshot) modes.push(badge("snapshot", "#c084fc", "#c084fc18"));
|
|
387
562
|
const accordions = collections.map((col, i) => resourceAccordion(col, base, i === 0)).join("");
|
|
388
563
|
const nestedRows = [];
|
|
389
564
|
for (const [child, fields] of Object.entries(relations)) {
|
|
@@ -403,6 +578,18 @@ function generateAboutHtml(storage, options) {
|
|
|
403
578
|
</summary>
|
|
404
579
|
<table><tbody>${nestedRows.join("")}</tbody></table>
|
|
405
580
|
</details>` : "";
|
|
581
|
+
const snapshotAccordion = options.snapshot ? `
|
|
582
|
+
<details class="resource-card nested-card">
|
|
583
|
+
<summary>
|
|
584
|
+
<span class="resource-name">/_snapshot</span>
|
|
585
|
+
<span class="route-count">3 routes</span>
|
|
586
|
+
</summary>
|
|
587
|
+
<table><tbody>
|
|
588
|
+
${endpointRow("GET", "/_snapshot", "Returns metadata of the current snapshot: <code>savedAt</code> and item counts per collection.")}
|
|
589
|
+
${endpointRow("POST", "/_snapshot/save", "Replaces the stored snapshot with the current database state.")}
|
|
590
|
+
${endpointRow("POST", "/_snapshot/reset", "Restores the database to the last saved snapshot and persists to disk.")}
|
|
591
|
+
</tbody></table>
|
|
592
|
+
</details>` : "";
|
|
406
593
|
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
594
|
return `<!DOCTYPE html>
|
|
408
595
|
<html lang="en">
|
|
@@ -551,6 +738,7 @@ function generateAboutHtml(storage, options) {
|
|
|
551
738
|
<div class="endpoints-grid">
|
|
552
739
|
${accordions}
|
|
553
740
|
${nestedAccordion}
|
|
741
|
+
${snapshotAccordion}
|
|
554
742
|
</div>
|
|
555
743
|
|
|
556
744
|
<h2>Query Parameters</h2>
|
|
@@ -559,9 +747,17 @@ function generateAboutHtml(storage, options) {
|
|
|
559
747
|
<thead><tr><th>Param</th><th>Example</th><th>Description</th></tr></thead>
|
|
560
748
|
<tbody>
|
|
561
749
|
<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>
|
|
750
|
+
<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>
|
|
751
|
+
<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>
|
|
752
|
+
<tr><td><code>?field_like</code></td><td><code>?name_like=ana</code></td><td>Case-insensitive substring match.</td></tr>
|
|
753
|
+
<tr><td><code>?field_start</code></td><td><code>?name_start=A</code></td><td>Case-insensitive prefix match.</td></tr>
|
|
754
|
+
<tr><td><code>?field_regex</code></td><td><code>?email_regex=gmail</code></td><td>Case-insensitive regular expression match.</td></tr>
|
|
755
|
+
<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>
|
|
562
756
|
<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>
|
|
563
757
|
<tr><td><code>?_page & ?_limit</code></td><td><code>?_page=2&_limit=10</code></td><td>${paginationDesc}</td></tr>
|
|
564
758
|
<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>
|
|
759
|
+
<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>
|
|
760
|
+
<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>
|
|
565
761
|
</tbody>
|
|
566
762
|
</table>
|
|
567
763
|
</div>
|
|
@@ -585,11 +781,51 @@ function registerAboutRoute(server, storage, options) {
|
|
|
585
781
|
});
|
|
586
782
|
}
|
|
587
783
|
|
|
784
|
+
// src/router/routes/snapshot.routes.ts
|
|
785
|
+
function registerSnapshotRoutes(server, storage) {
|
|
786
|
+
server.get("/_snapshot", (_req, reply) => {
|
|
787
|
+
const { data, savedAt } = storage.getSnapshot();
|
|
788
|
+
return reply.send({
|
|
789
|
+
savedAt: savedAt.toISOString(),
|
|
790
|
+
collections: Object.fromEntries(
|
|
791
|
+
Object.entries(data).map(([name, items]) => [name, items.length])
|
|
792
|
+
)
|
|
793
|
+
});
|
|
794
|
+
});
|
|
795
|
+
server.post("/_snapshot/save", (_req, reply) => {
|
|
796
|
+
storage.saveSnapshot();
|
|
797
|
+
const { data, savedAt } = storage.getSnapshot();
|
|
798
|
+
return reply.send({
|
|
799
|
+
message: "Snapshot saved",
|
|
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/reset", (_req, reply) => {
|
|
807
|
+
storage.resetToSnapshot();
|
|
808
|
+
const { data, savedAt } = storage.getSnapshot();
|
|
809
|
+
return reply.send({
|
|
810
|
+
message: "Database restored to snapshot",
|
|
811
|
+
savedAt: savedAt.toISOString(),
|
|
812
|
+
collections: Object.fromEntries(
|
|
813
|
+
Object.entries(data).map(([name, items]) => [name, items.length])
|
|
814
|
+
)
|
|
815
|
+
});
|
|
816
|
+
});
|
|
817
|
+
}
|
|
818
|
+
|
|
588
819
|
// src/server/createServer.ts
|
|
589
820
|
var MUTATING_METHODS = /* @__PURE__ */ new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
|
590
821
|
async function createServer(storage, options) {
|
|
591
822
|
const server = Fastify();
|
|
592
823
|
await server.register(cors);
|
|
824
|
+
server.setErrorHandler((err, _req, reply) => {
|
|
825
|
+
const status = err.statusCode ?? 500;
|
|
826
|
+
const message = status < 500 ? err.message || "Request error" : "Internal server error";
|
|
827
|
+
reply.status(status).send({ error: message });
|
|
828
|
+
});
|
|
593
829
|
if (options.readonly) {
|
|
594
830
|
server.addHook("onRequest", (_req, reply, done) => {
|
|
595
831
|
if (MUTATING_METHODS.has(_req.method)) {
|
|
@@ -604,6 +840,7 @@ async function createServer(storage, options) {
|
|
|
604
840
|
});
|
|
605
841
|
}
|
|
606
842
|
registerAboutRoute(server, storage, options);
|
|
843
|
+
if (options.snapshot) registerSnapshotRoutes(server, storage);
|
|
607
844
|
registerResourceRoutes(server, storage, options);
|
|
608
845
|
return server;
|
|
609
846
|
}
|
|
@@ -624,6 +861,11 @@ var serverOptionsSchema = z.object({
|
|
|
624
861
|
base: z.string().default("").transform((v) => v && !v.startsWith("/") ? `/${v}` : v),
|
|
625
862
|
/** When `true`, the server reloads the YAML file automatically on disk changes. */
|
|
626
863
|
watch: z.boolean().default(false),
|
|
864
|
+
/**
|
|
865
|
+
* When `true`, saves a snapshot of the initial database state on startup.
|
|
866
|
+
* Exposes `/_snapshot` endpoints to inspect, restore or update the snapshot.
|
|
867
|
+
*/
|
|
868
|
+
snapshot: z.boolean().default(false),
|
|
627
869
|
/** When `true`, all mutating requests (POST, PUT, PATCH, DELETE) are rejected with 405. */
|
|
628
870
|
readonly: z.boolean().default(false),
|
|
629
871
|
/** Milliseconds to delay every response, simulating network latency. `0` = disabled. */
|