@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.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
|
}
|
|
@@ -85,11 +113,92 @@ function createYamlStorage(filePath) {
|
|
|
85
113
|
var import_fastify = __toESM(require("fastify"));
|
|
86
114
|
var import_cors = __toESM(require("@fastify/cors"));
|
|
87
115
|
|
|
88
|
-
// src/
|
|
116
|
+
// src/utils/params.ts
|
|
89
117
|
function nextId(items) {
|
|
90
118
|
const ids = items.map((i) => i["id"]).filter((id) => typeof id === "number");
|
|
91
119
|
return ids.length > 0 ? Math.max(...ids) + 1 : 1;
|
|
92
120
|
}
|
|
121
|
+
function firstParam(value) {
|
|
122
|
+
if (value === void 0) return void 0;
|
|
123
|
+
return Array.isArray(value) ? value[0] : value;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// src/services/query.service.ts
|
|
127
|
+
var OPERATORS = ["_gte", "_lte", "_ne", "_like", "_start", "_regex"];
|
|
128
|
+
function applyOperator(itemValue, op, filterValue) {
|
|
129
|
+
const strItem = String(itemValue);
|
|
130
|
+
const numItem = Number(itemValue);
|
|
131
|
+
const numFilter = Number(filterValue);
|
|
132
|
+
const numeric = !isNaN(numItem) && !isNaN(numFilter) && filterValue.trim() !== "";
|
|
133
|
+
switch (op) {
|
|
134
|
+
case "_gte":
|
|
135
|
+
return numeric ? numItem >= numFilter : strItem >= filterValue;
|
|
136
|
+
case "_lte":
|
|
137
|
+
return numeric ? numItem <= numFilter : strItem <= filterValue;
|
|
138
|
+
case "_ne":
|
|
139
|
+
return strItem !== filterValue;
|
|
140
|
+
case "_like":
|
|
141
|
+
return strItem.toLowerCase().includes(filterValue.toLowerCase());
|
|
142
|
+
case "_start":
|
|
143
|
+
return strItem.toLowerCase().startsWith(filterValue.toLowerCase());
|
|
144
|
+
case "_regex": {
|
|
145
|
+
try {
|
|
146
|
+
return new RegExp(filterValue, "i").test(strItem);
|
|
147
|
+
} catch {
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
function filterByQuery(items, query) {
|
|
154
|
+
const filters = Object.entries(query).filter(([key]) => !key.startsWith("_"));
|
|
155
|
+
if (filters.length === 0) return items;
|
|
156
|
+
return items.filter(
|
|
157
|
+
(item) => filters.every(([key, value]) => {
|
|
158
|
+
const op = OPERATORS.find((o) => key.endsWith(o));
|
|
159
|
+
if (op) {
|
|
160
|
+
const field = key.slice(0, -op.length);
|
|
161
|
+
if (item[field] === void 0) return false;
|
|
162
|
+
const filterVal = Array.isArray(value) ? value[0] : value;
|
|
163
|
+
return applyOperator(item[field], op, filterVal);
|
|
164
|
+
}
|
|
165
|
+
if (item[key] === void 0) return false;
|
|
166
|
+
const itemStr = String(item[key]);
|
|
167
|
+
return Array.isArray(value) ? value.includes(itemStr) : itemStr === value;
|
|
168
|
+
})
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
function fullTextSearch(items, term) {
|
|
172
|
+
const lower = term.toLowerCase();
|
|
173
|
+
return items.filter(
|
|
174
|
+
(item) => Object.values(item).some((val) => {
|
|
175
|
+
if (val === null || val === void 0 || typeof val === "object") return false;
|
|
176
|
+
return String(val).toLowerCase().includes(lower);
|
|
177
|
+
})
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
function sortBy(items, field, order) {
|
|
181
|
+
const direction = order === "desc" ? -1 : 1;
|
|
182
|
+
return [...items].sort((a, b) => {
|
|
183
|
+
const av = a[field];
|
|
184
|
+
const bv = b[field];
|
|
185
|
+
if (av === void 0) return 1;
|
|
186
|
+
if (bv === void 0) return -1;
|
|
187
|
+
if (typeof av === "number" && typeof bv === "number") return (av - bv) * direction;
|
|
188
|
+
return String(av).localeCompare(String(bv), void 0, { sensitivity: "base" }) * direction;
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
function projectFields(input, fields) {
|
|
192
|
+
if (fields.length === 0) return input;
|
|
193
|
+
const project = (item) => Object.fromEntries(fields.filter((f) => f in item).map((f) => [f, item[f]]));
|
|
194
|
+
return Array.isArray(input) ? input.map(project) : project(input);
|
|
195
|
+
}
|
|
196
|
+
function paginate(items, page, limit) {
|
|
197
|
+
const start = (page - 1) * limit;
|
|
198
|
+
return items.slice(start, start + limit);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// src/services/resource.service.ts
|
|
93
202
|
function findById(items, id) {
|
|
94
203
|
return items.find((i) => String(i["id"]) === id);
|
|
95
204
|
}
|
|
@@ -126,36 +235,17 @@ function patchItem(storage, resource, id, body) {
|
|
|
126
235
|
storage.persist();
|
|
127
236
|
return updated;
|
|
128
237
|
}
|
|
129
|
-
function
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
return
|
|
137
|
-
(item) => filters.every(([key, value]) => {
|
|
138
|
-
if (item[key] === void 0) return false;
|
|
139
|
-
const itemStr = String(item[key]);
|
|
140
|
-
return Array.isArray(value) ? value.includes(itemStr) : itemStr === value;
|
|
141
|
-
})
|
|
142
|
-
);
|
|
143
|
-
}
|
|
144
|
-
function sortBy(items, field, order) {
|
|
145
|
-
const direction = order === "desc" ? -1 : 1;
|
|
146
|
-
return [...items].sort((a, b) => {
|
|
147
|
-
const av = a[field];
|
|
148
|
-
const bv = b[field];
|
|
149
|
-
if (av === void 0) return 1;
|
|
150
|
-
if (bv === void 0) return -1;
|
|
151
|
-
if (typeof av === "number" && typeof bv === "number") return (av - bv) * direction;
|
|
152
|
-
return String(av).localeCompare(String(bv), void 0, { sensitivity: "base" }) * direction;
|
|
153
|
-
});
|
|
154
|
-
}
|
|
155
|
-
function paginate(items, page, limit) {
|
|
156
|
-
const start = (page - 1) * limit;
|
|
157
|
-
return items.slice(start, start + limit);
|
|
238
|
+
function deleteItem(storage, resource, id) {
|
|
239
|
+
const collection = storage.getCollection(resource) ?? [];
|
|
240
|
+
const idx = findIndexById(collection, id);
|
|
241
|
+
if (idx === -1) return void 0;
|
|
242
|
+
const [deleted] = collection.splice(idx, 1);
|
|
243
|
+
storage.setCollection(resource, collection);
|
|
244
|
+
storage.persist();
|
|
245
|
+
return deleted;
|
|
158
246
|
}
|
|
247
|
+
|
|
248
|
+
// src/services/expand.service.ts
|
|
159
249
|
function expandItems(input, query, resource, storage) {
|
|
160
250
|
const isArray = Array.isArray(input);
|
|
161
251
|
const items = isArray ? input : [input];
|
|
@@ -188,14 +278,35 @@ function expandItems(input, query, resource, storage) {
|
|
|
188
278
|
});
|
|
189
279
|
return isArray ? expanded : expanded[0];
|
|
190
280
|
}
|
|
191
|
-
function
|
|
192
|
-
const
|
|
193
|
-
const
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
storage.
|
|
198
|
-
|
|
281
|
+
function embedItems(input, query, resource, storage) {
|
|
282
|
+
const isArray = Array.isArray(input);
|
|
283
|
+
const items = isArray ? input : [input];
|
|
284
|
+
const embedParam = query["_embed"];
|
|
285
|
+
if (!embedParam) return isArray ? items : input;
|
|
286
|
+
const keys = (Array.isArray(embedParam) ? embedParam : [embedParam]).flatMap((v) => v.split(",")).map((k) => k.trim()).filter(Boolean);
|
|
287
|
+
const relations = storage.getRelations();
|
|
288
|
+
const embeds = /* @__PURE__ */ new Map();
|
|
289
|
+
for (const embedKey of keys) {
|
|
290
|
+
outer: for (const [childCollection, fields] of Object.entries(relations)) {
|
|
291
|
+
for (const [fkField, parentCollection] of Object.entries(fields)) {
|
|
292
|
+
if (parentCollection === resource && childCollection === embedKey) {
|
|
293
|
+
embeds.set(embedKey, { childCollection, fkField });
|
|
294
|
+
break outer;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
if (embeds.size === 0) return isArray ? items : input;
|
|
300
|
+
const result = items.map((item) => {
|
|
301
|
+
const out = { ...item };
|
|
302
|
+
for (const [embedKey, { childCollection, fkField }] of embeds) {
|
|
303
|
+
out[embedKey] = (storage.getCollection(childCollection) ?? []).filter(
|
|
304
|
+
(child) => String(child[fkField]) === String(item["id"])
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
return out;
|
|
308
|
+
});
|
|
309
|
+
return isArray ? result : result[0];
|
|
199
310
|
}
|
|
200
311
|
|
|
201
312
|
// src/router/routes/collection.routes.ts
|
|
@@ -203,9 +314,12 @@ var registerCollectionRoutes = (server, storage, resource, base, options) => {
|
|
|
203
314
|
server.get(base, (req, reply) => {
|
|
204
315
|
const collection = storage.getCollection(resource) ?? [];
|
|
205
316
|
const filtered = filterByQuery(collection, req.query);
|
|
317
|
+
const searchTerm = firstParam(req.query["_q"]);
|
|
318
|
+
const searched = searchTerm ? fullTextSearch(filtered, searchTerm) : filtered;
|
|
319
|
+
const fields = (firstParam(req.query["_fields"]) ?? "").split(",").map((f) => f.trim()).filter(Boolean);
|
|
206
320
|
const sortField = firstParam(req.query["_sort"]);
|
|
207
321
|
const sortOrder = firstParam(req.query["_order"]) === "desc" ? "desc" : "asc";
|
|
208
|
-
const sorted = sortField ? sortBy(
|
|
322
|
+
const sorted = sortField ? sortBy(searched, sortField, sortOrder) : searched;
|
|
209
323
|
if (options.pageable.enabled) {
|
|
210
324
|
const defaultLimit = options.pageable.limit;
|
|
211
325
|
const page = Math.max(1, parseInt(firstParam(req.query["_page"]) ?? "1", 10) || 1);
|
|
@@ -215,7 +329,15 @@ var registerCollectionRoutes = (server, storage, resource, base, options) => {
|
|
|
215
329
|
);
|
|
216
330
|
const totalItems = sorted.length;
|
|
217
331
|
const totalPages = Math.ceil(totalItems / limit) || 1;
|
|
218
|
-
const data =
|
|
332
|
+
const data = projectFields(
|
|
333
|
+
embedItems(
|
|
334
|
+
expandItems(paginate(sorted, page, limit), req.query, resource, storage),
|
|
335
|
+
req.query,
|
|
336
|
+
resource,
|
|
337
|
+
storage
|
|
338
|
+
),
|
|
339
|
+
fields
|
|
340
|
+
);
|
|
219
341
|
const pagination = {
|
|
220
342
|
page,
|
|
221
343
|
limit,
|
|
@@ -239,9 +361,15 @@ var registerCollectionRoutes = (server, storage, resource, base, options) => {
|
|
|
239
361
|
reply.header("X-Total-Count", String(sorted.length));
|
|
240
362
|
result = paginate(sorted, page, limit);
|
|
241
363
|
}
|
|
242
|
-
return
|
|
364
|
+
return projectFields(
|
|
365
|
+
embedItems(expandItems(result, req.query, resource, storage), req.query, resource, storage),
|
|
366
|
+
fields
|
|
367
|
+
);
|
|
243
368
|
});
|
|
244
369
|
server.post(base, (req, reply) => {
|
|
370
|
+
if (!req.body || typeof req.body !== "object" || Array.isArray(req.body)) {
|
|
371
|
+
return reply.status(400).send({ error: "Request body must be a JSON object" });
|
|
372
|
+
}
|
|
245
373
|
const item = createItem(storage, resource, req.body);
|
|
246
374
|
return reply.status(201).send(expandItems(item, req.query, resource, storage));
|
|
247
375
|
});
|
|
@@ -252,14 +380,24 @@ var registerItemRoutes = (server, storage, resource, base) => {
|
|
|
252
380
|
server.get(`${base}/:id`, (req, reply) => {
|
|
253
381
|
const item = findById(storage.getCollection(resource) ?? [], req.params.id);
|
|
254
382
|
if (!item) return reply.status(404).send({ error: "Not found" });
|
|
255
|
-
|
|
383
|
+
const fields = (req.query["_fields"] ?? "").split(",").map((f) => f.trim()).filter(Boolean);
|
|
384
|
+
return projectFields(
|
|
385
|
+
embedItems(expandItems(item, req.query, resource, storage), req.query, resource, storage),
|
|
386
|
+
fields
|
|
387
|
+
);
|
|
256
388
|
});
|
|
257
389
|
server.put(`${base}/:id`, (req, reply) => {
|
|
390
|
+
if (!req.body || typeof req.body !== "object" || Array.isArray(req.body)) {
|
|
391
|
+
return reply.status(400).send({ error: "Request body must be a JSON object" });
|
|
392
|
+
}
|
|
258
393
|
const item = replaceItem(storage, resource, req.params.id, req.body);
|
|
259
394
|
if (!item) return reply.status(404).send({ error: "Not found" });
|
|
260
395
|
return expandItems(item, req.query, resource, storage);
|
|
261
396
|
});
|
|
262
397
|
server.patch(`${base}/:id`, (req, reply) => {
|
|
398
|
+
if (!req.body || typeof req.body !== "object" || Array.isArray(req.body)) {
|
|
399
|
+
return reply.status(400).send({ error: "Request body must be a JSON object" });
|
|
400
|
+
}
|
|
263
401
|
const item = patchItem(storage, resource, req.params.id, req.body);
|
|
264
402
|
if (!item) return reply.status(404).send({ error: "Not found" });
|
|
265
403
|
return expandItems(item, req.query, resource, storage);
|
|
@@ -275,7 +413,9 @@ var registerItemRoutes = (server, storage, resource, base) => {
|
|
|
275
413
|
var registerNestedRoutes = (server, storage, relations, base) => {
|
|
276
414
|
for (const [child, fields] of Object.entries(relations)) {
|
|
277
415
|
for (const [field, parent] of Object.entries(fields)) {
|
|
278
|
-
|
|
416
|
+
const collectionPath = `${base}/${parent}/:id/${child}`;
|
|
417
|
+
const itemPath = `${base}/${parent}/:id/${child}/:childId`;
|
|
418
|
+
server.get(collectionPath, (req, reply) => {
|
|
279
419
|
const parentCollection = storage.getCollection(parent) ?? [];
|
|
280
420
|
const parentItem = findById(parentCollection, req.params.id);
|
|
281
421
|
if (!parentItem) return reply.status(404).send({ error: "Not found" });
|
|
@@ -284,6 +424,16 @@ var registerNestedRoutes = (server, storage, relations, base) => {
|
|
|
284
424
|
);
|
|
285
425
|
return children;
|
|
286
426
|
});
|
|
427
|
+
server.get(itemPath, (req, reply) => {
|
|
428
|
+
const parentCollection = storage.getCollection(parent) ?? [];
|
|
429
|
+
const parentItem = findById(parentCollection, req.params.id);
|
|
430
|
+
if (!parentItem) return reply.status(404).send({ error: "Not found" });
|
|
431
|
+
const childItem = (storage.getCollection(child) ?? []).find(
|
|
432
|
+
(item) => String(item[field]) === req.params.id && String(item["id"]) === req.params.childId
|
|
433
|
+
);
|
|
434
|
+
if (!childItem) return reply.status(404).send({ error: "Not found" });
|
|
435
|
+
return childItem;
|
|
436
|
+
});
|
|
287
437
|
}
|
|
288
438
|
}
|
|
289
439
|
};
|
|
@@ -408,6 +558,30 @@ curl ${host}${base}/${parent}/1/${child}`);
|
|
|
408
558
|
examples.push(`# Pageable envelope
|
|
409
559
|
curl "${host}${base}/${firstCol}?_page=2"`);
|
|
410
560
|
}
|
|
561
|
+
const firstParentRel = Object.entries(relations).find(
|
|
562
|
+
([, fields]) => Object.values(fields).includes(firstCol ?? "")
|
|
563
|
+
);
|
|
564
|
+
if (firstCol) {
|
|
565
|
+
examples.push(
|
|
566
|
+
`# Project fields with ?_fields
|
|
567
|
+
curl "${host}${base}/${firstCol}?_fields=id,name"`
|
|
568
|
+
);
|
|
569
|
+
}
|
|
570
|
+
if (firstParentRel && firstCol) {
|
|
571
|
+
const [childName] = firstParentRel;
|
|
572
|
+
examples.push(
|
|
573
|
+
`# Embed child collection with ?_embed
|
|
574
|
+
curl "${host}${base}/${firstCol}/1?_embed=${childName}"`
|
|
575
|
+
);
|
|
576
|
+
}
|
|
577
|
+
if (options.snapshot) {
|
|
578
|
+
examples.push(
|
|
579
|
+
`# Snapshot endpoints
|
|
580
|
+
curl ${host}/_snapshot
|
|
581
|
+
curl -X POST ${host}/_snapshot/save
|
|
582
|
+
curl -X POST ${host}/_snapshot/reset`
|
|
583
|
+
);
|
|
584
|
+
}
|
|
411
585
|
const highlighted = examples.map((e) => e.replace(/^(#.+)$/gm, '<span class="cm">$1</span>')).join("\n\n");
|
|
412
586
|
return `<pre>${highlighted}</pre>`;
|
|
413
587
|
}
|
|
@@ -422,6 +596,7 @@ function generateAboutHtml(storage, options) {
|
|
|
422
596
|
if (options.delay > 0) modes.push(badge(`delay \xB7 ${options.delay}ms`, "#fb923c", "#fb923c18"));
|
|
423
597
|
if (options.pageable.enabled)
|
|
424
598
|
modes.push(badge(`pageable \xB7 limit ${options.pageable.limit}`, "#34d399", "#34d39918"));
|
|
599
|
+
if (options.snapshot) modes.push(badge("snapshot", "#c084fc", "#c084fc18"));
|
|
425
600
|
const accordions = collections.map((col, i) => resourceAccordion(col, base, i === 0)).join("");
|
|
426
601
|
const nestedRows = [];
|
|
427
602
|
for (const [child, fields] of Object.entries(relations)) {
|
|
@@ -441,6 +616,18 @@ function generateAboutHtml(storage, options) {
|
|
|
441
616
|
</summary>
|
|
442
617
|
<table><tbody>${nestedRows.join("")}</tbody></table>
|
|
443
618
|
</details>` : "";
|
|
619
|
+
const snapshotAccordion = options.snapshot ? `
|
|
620
|
+
<details class="resource-card nested-card">
|
|
621
|
+
<summary>
|
|
622
|
+
<span class="resource-name">/_snapshot</span>
|
|
623
|
+
<span class="route-count">3 routes</span>
|
|
624
|
+
</summary>
|
|
625
|
+
<table><tbody>
|
|
626
|
+
${endpointRow("GET", "/_snapshot", "Returns metadata of the current snapshot: <code>savedAt</code> and item counts per collection.")}
|
|
627
|
+
${endpointRow("POST", "/_snapshot/save", "Replaces the stored snapshot with the current database state.")}
|
|
628
|
+
${endpointRow("POST", "/_snapshot/reset", "Restores the database to the last saved snapshot and persists to disk.")}
|
|
629
|
+
</tbody></table>
|
|
630
|
+
</details>` : "";
|
|
444
631
|
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
632
|
return `<!DOCTYPE html>
|
|
446
633
|
<html lang="en">
|
|
@@ -589,6 +776,7 @@ function generateAboutHtml(storage, options) {
|
|
|
589
776
|
<div class="endpoints-grid">
|
|
590
777
|
${accordions}
|
|
591
778
|
${nestedAccordion}
|
|
779
|
+
${snapshotAccordion}
|
|
592
780
|
</div>
|
|
593
781
|
|
|
594
782
|
<h2>Query Parameters</h2>
|
|
@@ -597,9 +785,17 @@ function generateAboutHtml(storage, options) {
|
|
|
597
785
|
<thead><tr><th>Param</th><th>Example</th><th>Description</th></tr></thead>
|
|
598
786
|
<tbody>
|
|
599
787
|
<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>
|
|
788
|
+
<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>
|
|
789
|
+
<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>
|
|
790
|
+
<tr><td><code>?field_like</code></td><td><code>?name_like=ana</code></td><td>Case-insensitive substring match.</td></tr>
|
|
791
|
+
<tr><td><code>?field_start</code></td><td><code>?name_start=A</code></td><td>Case-insensitive prefix match.</td></tr>
|
|
792
|
+
<tr><td><code>?field_regex</code></td><td><code>?email_regex=gmail</code></td><td>Case-insensitive regular expression match.</td></tr>
|
|
793
|
+
<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>
|
|
600
794
|
<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>
|
|
601
795
|
<tr><td><code>?_page & ?_limit</code></td><td><code>?_page=2&_limit=10</code></td><td>${paginationDesc}</td></tr>
|
|
602
796
|
<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>
|
|
797
|
+
<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>
|
|
798
|
+
<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>
|
|
603
799
|
</tbody>
|
|
604
800
|
</table>
|
|
605
801
|
</div>
|
|
@@ -623,11 +819,51 @@ function registerAboutRoute(server, storage, options) {
|
|
|
623
819
|
});
|
|
624
820
|
}
|
|
625
821
|
|
|
822
|
+
// src/router/routes/snapshot.routes.ts
|
|
823
|
+
function registerSnapshotRoutes(server, storage) {
|
|
824
|
+
server.get("/_snapshot", (_req, reply) => {
|
|
825
|
+
const { data, savedAt } = storage.getSnapshot();
|
|
826
|
+
return reply.send({
|
|
827
|
+
savedAt: savedAt.toISOString(),
|
|
828
|
+
collections: Object.fromEntries(
|
|
829
|
+
Object.entries(data).map(([name, items]) => [name, items.length])
|
|
830
|
+
)
|
|
831
|
+
});
|
|
832
|
+
});
|
|
833
|
+
server.post("/_snapshot/save", (_req, reply) => {
|
|
834
|
+
storage.saveSnapshot();
|
|
835
|
+
const { data, savedAt } = storage.getSnapshot();
|
|
836
|
+
return reply.send({
|
|
837
|
+
message: "Snapshot saved",
|
|
838
|
+
savedAt: savedAt.toISOString(),
|
|
839
|
+
collections: Object.fromEntries(
|
|
840
|
+
Object.entries(data).map(([name, items]) => [name, items.length])
|
|
841
|
+
)
|
|
842
|
+
});
|
|
843
|
+
});
|
|
844
|
+
server.post("/_snapshot/reset", (_req, reply) => {
|
|
845
|
+
storage.resetToSnapshot();
|
|
846
|
+
const { data, savedAt } = storage.getSnapshot();
|
|
847
|
+
return reply.send({
|
|
848
|
+
message: "Database restored to snapshot",
|
|
849
|
+
savedAt: savedAt.toISOString(),
|
|
850
|
+
collections: Object.fromEntries(
|
|
851
|
+
Object.entries(data).map(([name, items]) => [name, items.length])
|
|
852
|
+
)
|
|
853
|
+
});
|
|
854
|
+
});
|
|
855
|
+
}
|
|
856
|
+
|
|
626
857
|
// src/server/createServer.ts
|
|
627
858
|
var MUTATING_METHODS = /* @__PURE__ */ new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
|
628
859
|
async function createServer(storage, options) {
|
|
629
860
|
const server = (0, import_fastify.default)();
|
|
630
861
|
await server.register(import_cors.default);
|
|
862
|
+
server.setErrorHandler((err, _req, reply) => {
|
|
863
|
+
const status = err.statusCode ?? 500;
|
|
864
|
+
const message = status < 500 ? err.message || "Request error" : "Internal server error";
|
|
865
|
+
reply.status(status).send({ error: message });
|
|
866
|
+
});
|
|
631
867
|
if (options.readonly) {
|
|
632
868
|
server.addHook("onRequest", (_req, reply, done) => {
|
|
633
869
|
if (MUTATING_METHODS.has(_req.method)) {
|
|
@@ -642,6 +878,7 @@ async function createServer(storage, options) {
|
|
|
642
878
|
});
|
|
643
879
|
}
|
|
644
880
|
registerAboutRoute(server, storage, options);
|
|
881
|
+
if (options.snapshot) registerSnapshotRoutes(server, storage);
|
|
645
882
|
registerResourceRoutes(server, storage, options);
|
|
646
883
|
return server;
|
|
647
884
|
}
|
|
@@ -662,6 +899,11 @@ var serverOptionsSchema = import_zod.z.object({
|
|
|
662
899
|
base: import_zod.z.string().default("").transform((v) => v && !v.startsWith("/") ? `/${v}` : v),
|
|
663
900
|
/** When `true`, the server reloads the YAML file automatically on disk changes. */
|
|
664
901
|
watch: import_zod.z.boolean().default(false),
|
|
902
|
+
/**
|
|
903
|
+
* When `true`, saves a snapshot of the initial database state on startup.
|
|
904
|
+
* Exposes `/_snapshot` endpoints to inspect, restore or update the snapshot.
|
|
905
|
+
*/
|
|
906
|
+
snapshot: import_zod.z.boolean().default(false),
|
|
665
907
|
/** When `true`, all mutating requests (POST, PUT, PATCH, DELETE) are rejected with 405. */
|
|
666
908
|
readonly: import_zod.z.boolean().default(false),
|
|
667
909
|
/** Milliseconds to delay every response, simulating network latency. `0` = disabled. */
|