@aggiovato/yrest 0.3.0 → 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 +167 -42
- package/dist/cli/index.mjs +167 -42
- package/dist/index.js +167 -42
- package/dist/index.mjs +167 -42
- package/package.json +1 -1
package/dist/cli/index.js
CHANGED
|
@@ -211,11 +211,92 @@ function createYamlStorage(filePath) {
|
|
|
211
211
|
var import_fastify = __toESM(require("fastify"));
|
|
212
212
|
var import_cors = __toESM(require("@fastify/cors"));
|
|
213
213
|
|
|
214
|
-
// src/
|
|
214
|
+
// src/utils/params.ts
|
|
215
215
|
function nextId(items) {
|
|
216
216
|
const ids = items.map((i) => i["id"]).filter((id) => typeof id === "number");
|
|
217
217
|
return ids.length > 0 ? Math.max(...ids) + 1 : 1;
|
|
218
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
|
|
219
300
|
function findById(items, id) {
|
|
220
301
|
return items.find((i) => String(i["id"]) === id);
|
|
221
302
|
}
|
|
@@ -252,36 +333,17 @@ function patchItem(storage, resource, id, body) {
|
|
|
252
333
|
storage.persist();
|
|
253
334
|
return updated;
|
|
254
335
|
}
|
|
255
|
-
function
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
return
|
|
263
|
-
(item) => filters.every(([key, value]) => {
|
|
264
|
-
if (item[key] === void 0) return false;
|
|
265
|
-
const itemStr = String(item[key]);
|
|
266
|
-
return Array.isArray(value) ? value.includes(itemStr) : itemStr === value;
|
|
267
|
-
})
|
|
268
|
-
);
|
|
269
|
-
}
|
|
270
|
-
function sortBy(items, field, order) {
|
|
271
|
-
const direction = order === "desc" ? -1 : 1;
|
|
272
|
-
return [...items].sort((a, b) => {
|
|
273
|
-
const av = a[field];
|
|
274
|
-
const bv = b[field];
|
|
275
|
-
if (av === void 0) return 1;
|
|
276
|
-
if (bv === void 0) return -1;
|
|
277
|
-
if (typeof av === "number" && typeof bv === "number") return (av - bv) * direction;
|
|
278
|
-
return String(av).localeCompare(String(bv), void 0, { sensitivity: "base" }) * direction;
|
|
279
|
-
});
|
|
280
|
-
}
|
|
281
|
-
function paginate(items, page, limit) {
|
|
282
|
-
const start = (page - 1) * limit;
|
|
283
|
-
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;
|
|
284
344
|
}
|
|
345
|
+
|
|
346
|
+
// src/services/expand.service.ts
|
|
285
347
|
function expandItems(input, query, resource, storage) {
|
|
286
348
|
const isArray = Array.isArray(input);
|
|
287
349
|
const items = isArray ? input : [input];
|
|
@@ -314,14 +376,35 @@ function expandItems(input, query, resource, storage) {
|
|
|
314
376
|
});
|
|
315
377
|
return isArray ? expanded : expanded[0];
|
|
316
378
|
}
|
|
317
|
-
function
|
|
318
|
-
const
|
|
319
|
-
const
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
storage.
|
|
324
|
-
|
|
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];
|
|
325
408
|
}
|
|
326
409
|
|
|
327
410
|
// src/router/routes/collection.routes.ts
|
|
@@ -329,9 +412,12 @@ var registerCollectionRoutes = (server, storage, resource, base, options) => {
|
|
|
329
412
|
server.get(base, (req, reply) => {
|
|
330
413
|
const collection = storage.getCollection(resource) ?? [];
|
|
331
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);
|
|
332
418
|
const sortField = firstParam(req.query["_sort"]);
|
|
333
419
|
const sortOrder = firstParam(req.query["_order"]) === "desc" ? "desc" : "asc";
|
|
334
|
-
const sorted = sortField ? sortBy(
|
|
420
|
+
const sorted = sortField ? sortBy(searched, sortField, sortOrder) : searched;
|
|
335
421
|
if (options.pageable.enabled) {
|
|
336
422
|
const defaultLimit = options.pageable.limit;
|
|
337
423
|
const page = Math.max(1, parseInt(firstParam(req.query["_page"]) ?? "1", 10) || 1);
|
|
@@ -341,7 +427,15 @@ var registerCollectionRoutes = (server, storage, resource, base, options) => {
|
|
|
341
427
|
);
|
|
342
428
|
const totalItems = sorted.length;
|
|
343
429
|
const totalPages = Math.ceil(totalItems / limit) || 1;
|
|
344
|
-
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
|
+
);
|
|
345
439
|
const pagination = {
|
|
346
440
|
page,
|
|
347
441
|
limit,
|
|
@@ -365,7 +459,10 @@ var registerCollectionRoutes = (server, storage, resource, base, options) => {
|
|
|
365
459
|
reply.header("X-Total-Count", String(sorted.length));
|
|
366
460
|
result = paginate(sorted, page, limit);
|
|
367
461
|
}
|
|
368
|
-
return
|
|
462
|
+
return projectFields(
|
|
463
|
+
embedItems(expandItems(result, req.query, resource, storage), req.query, resource, storage),
|
|
464
|
+
fields
|
|
465
|
+
);
|
|
369
466
|
});
|
|
370
467
|
server.post(base, (req, reply) => {
|
|
371
468
|
if (!req.body || typeof req.body !== "object" || Array.isArray(req.body)) {
|
|
@@ -381,7 +478,11 @@ var registerItemRoutes = (server, storage, resource, base) => {
|
|
|
381
478
|
server.get(`${base}/:id`, (req, reply) => {
|
|
382
479
|
const item = findById(storage.getCollection(resource) ?? [], req.params.id);
|
|
383
480
|
if (!item) return reply.status(404).send({ error: "Not found" });
|
|
384
|
-
|
|
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
|
+
);
|
|
385
486
|
});
|
|
386
487
|
server.put(`${base}/:id`, (req, reply) => {
|
|
387
488
|
if (!req.body || typeof req.body !== "object" || Array.isArray(req.body)) {
|
|
@@ -555,6 +656,22 @@ curl ${host}${base}/${parent}/1/${child}`);
|
|
|
555
656
|
examples.push(`# Pageable envelope
|
|
556
657
|
curl "${host}${base}/${firstCol}?_page=2"`);
|
|
557
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
|
+
}
|
|
558
675
|
if (options.snapshot) {
|
|
559
676
|
examples.push(
|
|
560
677
|
`# Snapshot endpoints
|
|
@@ -766,9 +883,17 @@ function generateAboutHtml(storage, options) {
|
|
|
766
883
|
<thead><tr><th>Param</th><th>Example</th><th>Description</th></tr></thead>
|
|
767
884
|
<tbody>
|
|
768
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>
|
|
769
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>
|
|
770
893
|
<tr><td><code>?_page & ?_limit</code></td><td><code>?_page=2&_limit=10</code></td><td>${paginationDesc}</td></tr>
|
|
771
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>
|
|
772
897
|
</tbody>
|
|
773
898
|
</table>
|
|
774
899
|
</div>
|
package/dist/cli/index.mjs
CHANGED
|
@@ -184,11 +184,92 @@ function createYamlStorage(filePath) {
|
|
|
184
184
|
import Fastify from "fastify";
|
|
185
185
|
import cors from "@fastify/cors";
|
|
186
186
|
|
|
187
|
-
// src/
|
|
187
|
+
// src/utils/params.ts
|
|
188
188
|
function nextId(items) {
|
|
189
189
|
const ids = items.map((i) => i["id"]).filter((id) => typeof id === "number");
|
|
190
190
|
return ids.length > 0 ? Math.max(...ids) + 1 : 1;
|
|
191
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
|
|
192
273
|
function findById(items, id) {
|
|
193
274
|
return items.find((i) => String(i["id"]) === id);
|
|
194
275
|
}
|
|
@@ -225,36 +306,17 @@ function patchItem(storage, resource, id, body) {
|
|
|
225
306
|
storage.persist();
|
|
226
307
|
return updated;
|
|
227
308
|
}
|
|
228
|
-
function
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
return
|
|
236
|
-
(item) => filters.every(([key, value]) => {
|
|
237
|
-
if (item[key] === void 0) return false;
|
|
238
|
-
const itemStr = String(item[key]);
|
|
239
|
-
return Array.isArray(value) ? value.includes(itemStr) : itemStr === value;
|
|
240
|
-
})
|
|
241
|
-
);
|
|
242
|
-
}
|
|
243
|
-
function sortBy(items, field, order) {
|
|
244
|
-
const direction = order === "desc" ? -1 : 1;
|
|
245
|
-
return [...items].sort((a, b) => {
|
|
246
|
-
const av = a[field];
|
|
247
|
-
const bv = b[field];
|
|
248
|
-
if (av === void 0) return 1;
|
|
249
|
-
if (bv === void 0) return -1;
|
|
250
|
-
if (typeof av === "number" && typeof bv === "number") return (av - bv) * direction;
|
|
251
|
-
return String(av).localeCompare(String(bv), void 0, { sensitivity: "base" }) * direction;
|
|
252
|
-
});
|
|
253
|
-
}
|
|
254
|
-
function paginate(items, page, limit) {
|
|
255
|
-
const start = (page - 1) * limit;
|
|
256
|
-
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;
|
|
257
317
|
}
|
|
318
|
+
|
|
319
|
+
// src/services/expand.service.ts
|
|
258
320
|
function expandItems(input, query, resource, storage) {
|
|
259
321
|
const isArray = Array.isArray(input);
|
|
260
322
|
const items = isArray ? input : [input];
|
|
@@ -287,14 +349,35 @@ function expandItems(input, query, resource, storage) {
|
|
|
287
349
|
});
|
|
288
350
|
return isArray ? expanded : expanded[0];
|
|
289
351
|
}
|
|
290
|
-
function
|
|
291
|
-
const
|
|
292
|
-
const
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
storage.
|
|
297
|
-
|
|
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];
|
|
298
381
|
}
|
|
299
382
|
|
|
300
383
|
// src/router/routes/collection.routes.ts
|
|
@@ -302,9 +385,12 @@ var registerCollectionRoutes = (server, storage, resource, base, options) => {
|
|
|
302
385
|
server.get(base, (req, reply) => {
|
|
303
386
|
const collection = storage.getCollection(resource) ?? [];
|
|
304
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);
|
|
305
391
|
const sortField = firstParam(req.query["_sort"]);
|
|
306
392
|
const sortOrder = firstParam(req.query["_order"]) === "desc" ? "desc" : "asc";
|
|
307
|
-
const sorted = sortField ? sortBy(
|
|
393
|
+
const sorted = sortField ? sortBy(searched, sortField, sortOrder) : searched;
|
|
308
394
|
if (options.pageable.enabled) {
|
|
309
395
|
const defaultLimit = options.pageable.limit;
|
|
310
396
|
const page = Math.max(1, parseInt(firstParam(req.query["_page"]) ?? "1", 10) || 1);
|
|
@@ -314,7 +400,15 @@ var registerCollectionRoutes = (server, storage, resource, base, options) => {
|
|
|
314
400
|
);
|
|
315
401
|
const totalItems = sorted.length;
|
|
316
402
|
const totalPages = Math.ceil(totalItems / limit) || 1;
|
|
317
|
-
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
|
+
);
|
|
318
412
|
const pagination = {
|
|
319
413
|
page,
|
|
320
414
|
limit,
|
|
@@ -338,7 +432,10 @@ var registerCollectionRoutes = (server, storage, resource, base, options) => {
|
|
|
338
432
|
reply.header("X-Total-Count", String(sorted.length));
|
|
339
433
|
result = paginate(sorted, page, limit);
|
|
340
434
|
}
|
|
341
|
-
return
|
|
435
|
+
return projectFields(
|
|
436
|
+
embedItems(expandItems(result, req.query, resource, storage), req.query, resource, storage),
|
|
437
|
+
fields
|
|
438
|
+
);
|
|
342
439
|
});
|
|
343
440
|
server.post(base, (req, reply) => {
|
|
344
441
|
if (!req.body || typeof req.body !== "object" || Array.isArray(req.body)) {
|
|
@@ -354,7 +451,11 @@ var registerItemRoutes = (server, storage, resource, base) => {
|
|
|
354
451
|
server.get(`${base}/:id`, (req, reply) => {
|
|
355
452
|
const item = findById(storage.getCollection(resource) ?? [], req.params.id);
|
|
356
453
|
if (!item) return reply.status(404).send({ error: "Not found" });
|
|
357
|
-
|
|
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
|
+
);
|
|
358
459
|
});
|
|
359
460
|
server.put(`${base}/:id`, (req, reply) => {
|
|
360
461
|
if (!req.body || typeof req.body !== "object" || Array.isArray(req.body)) {
|
|
@@ -528,6 +629,22 @@ curl ${host}${base}/${parent}/1/${child}`);
|
|
|
528
629
|
examples.push(`# Pageable envelope
|
|
529
630
|
curl "${host}${base}/${firstCol}?_page=2"`);
|
|
530
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
|
+
}
|
|
531
648
|
if (options.snapshot) {
|
|
532
649
|
examples.push(
|
|
533
650
|
`# Snapshot endpoints
|
|
@@ -739,9 +856,17 @@ function generateAboutHtml(storage, options) {
|
|
|
739
856
|
<thead><tr><th>Param</th><th>Example</th><th>Description</th></tr></thead>
|
|
740
857
|
<tbody>
|
|
741
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>
|
|
742
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>
|
|
743
866
|
<tr><td><code>?_page & ?_limit</code></td><td><code>?_page=2&_limit=10</code></td><td>${paginationDesc}</td></tr>
|
|
744
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>
|
|
745
870
|
</tbody>
|
|
746
871
|
</table>
|
|
747
872
|
</div>
|
package/dist/index.js
CHANGED
|
@@ -113,11 +113,92 @@ function createYamlStorage(filePath) {
|
|
|
113
113
|
var import_fastify = __toESM(require("fastify"));
|
|
114
114
|
var import_cors = __toESM(require("@fastify/cors"));
|
|
115
115
|
|
|
116
|
-
// src/
|
|
116
|
+
// src/utils/params.ts
|
|
117
117
|
function nextId(items) {
|
|
118
118
|
const ids = items.map((i) => i["id"]).filter((id) => typeof id === "number");
|
|
119
119
|
return ids.length > 0 ? Math.max(...ids) + 1 : 1;
|
|
120
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
|
|
121
202
|
function findById(items, id) {
|
|
122
203
|
return items.find((i) => String(i["id"]) === id);
|
|
123
204
|
}
|
|
@@ -154,36 +235,17 @@ function patchItem(storage, resource, id, body) {
|
|
|
154
235
|
storage.persist();
|
|
155
236
|
return updated;
|
|
156
237
|
}
|
|
157
|
-
function
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
return
|
|
165
|
-
(item) => filters.every(([key, value]) => {
|
|
166
|
-
if (item[key] === void 0) return false;
|
|
167
|
-
const itemStr = String(item[key]);
|
|
168
|
-
return Array.isArray(value) ? value.includes(itemStr) : itemStr === value;
|
|
169
|
-
})
|
|
170
|
-
);
|
|
171
|
-
}
|
|
172
|
-
function sortBy(items, field, order) {
|
|
173
|
-
const direction = order === "desc" ? -1 : 1;
|
|
174
|
-
return [...items].sort((a, b) => {
|
|
175
|
-
const av = a[field];
|
|
176
|
-
const bv = b[field];
|
|
177
|
-
if (av === void 0) return 1;
|
|
178
|
-
if (bv === void 0) return -1;
|
|
179
|
-
if (typeof av === "number" && typeof bv === "number") return (av - bv) * direction;
|
|
180
|
-
return String(av).localeCompare(String(bv), void 0, { sensitivity: "base" }) * direction;
|
|
181
|
-
});
|
|
182
|
-
}
|
|
183
|
-
function paginate(items, page, limit) {
|
|
184
|
-
const start = (page - 1) * limit;
|
|
185
|
-
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;
|
|
186
246
|
}
|
|
247
|
+
|
|
248
|
+
// src/services/expand.service.ts
|
|
187
249
|
function expandItems(input, query, resource, storage) {
|
|
188
250
|
const isArray = Array.isArray(input);
|
|
189
251
|
const items = isArray ? input : [input];
|
|
@@ -216,14 +278,35 @@ function expandItems(input, query, resource, storage) {
|
|
|
216
278
|
});
|
|
217
279
|
return isArray ? expanded : expanded[0];
|
|
218
280
|
}
|
|
219
|
-
function
|
|
220
|
-
const
|
|
221
|
-
const
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
storage.
|
|
226
|
-
|
|
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];
|
|
227
310
|
}
|
|
228
311
|
|
|
229
312
|
// src/router/routes/collection.routes.ts
|
|
@@ -231,9 +314,12 @@ var registerCollectionRoutes = (server, storage, resource, base, options) => {
|
|
|
231
314
|
server.get(base, (req, reply) => {
|
|
232
315
|
const collection = storage.getCollection(resource) ?? [];
|
|
233
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);
|
|
234
320
|
const sortField = firstParam(req.query["_sort"]);
|
|
235
321
|
const sortOrder = firstParam(req.query["_order"]) === "desc" ? "desc" : "asc";
|
|
236
|
-
const sorted = sortField ? sortBy(
|
|
322
|
+
const sorted = sortField ? sortBy(searched, sortField, sortOrder) : searched;
|
|
237
323
|
if (options.pageable.enabled) {
|
|
238
324
|
const defaultLimit = options.pageable.limit;
|
|
239
325
|
const page = Math.max(1, parseInt(firstParam(req.query["_page"]) ?? "1", 10) || 1);
|
|
@@ -243,7 +329,15 @@ var registerCollectionRoutes = (server, storage, resource, base, options) => {
|
|
|
243
329
|
);
|
|
244
330
|
const totalItems = sorted.length;
|
|
245
331
|
const totalPages = Math.ceil(totalItems / limit) || 1;
|
|
246
|
-
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
|
+
);
|
|
247
341
|
const pagination = {
|
|
248
342
|
page,
|
|
249
343
|
limit,
|
|
@@ -267,7 +361,10 @@ var registerCollectionRoutes = (server, storage, resource, base, options) => {
|
|
|
267
361
|
reply.header("X-Total-Count", String(sorted.length));
|
|
268
362
|
result = paginate(sorted, page, limit);
|
|
269
363
|
}
|
|
270
|
-
return
|
|
364
|
+
return projectFields(
|
|
365
|
+
embedItems(expandItems(result, req.query, resource, storage), req.query, resource, storage),
|
|
366
|
+
fields
|
|
367
|
+
);
|
|
271
368
|
});
|
|
272
369
|
server.post(base, (req, reply) => {
|
|
273
370
|
if (!req.body || typeof req.body !== "object" || Array.isArray(req.body)) {
|
|
@@ -283,7 +380,11 @@ var registerItemRoutes = (server, storage, resource, base) => {
|
|
|
283
380
|
server.get(`${base}/:id`, (req, reply) => {
|
|
284
381
|
const item = findById(storage.getCollection(resource) ?? [], req.params.id);
|
|
285
382
|
if (!item) return reply.status(404).send({ error: "Not found" });
|
|
286
|
-
|
|
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
|
+
);
|
|
287
388
|
});
|
|
288
389
|
server.put(`${base}/:id`, (req, reply) => {
|
|
289
390
|
if (!req.body || typeof req.body !== "object" || Array.isArray(req.body)) {
|
|
@@ -457,6 +558,22 @@ curl ${host}${base}/${parent}/1/${child}`);
|
|
|
457
558
|
examples.push(`# Pageable envelope
|
|
458
559
|
curl "${host}${base}/${firstCol}?_page=2"`);
|
|
459
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
|
+
}
|
|
460
577
|
if (options.snapshot) {
|
|
461
578
|
examples.push(
|
|
462
579
|
`# Snapshot endpoints
|
|
@@ -668,9 +785,17 @@ function generateAboutHtml(storage, options) {
|
|
|
668
785
|
<thead><tr><th>Param</th><th>Example</th><th>Description</th></tr></thead>
|
|
669
786
|
<tbody>
|
|
670
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>
|
|
671
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>
|
|
672
795
|
<tr><td><code>?_page & ?_limit</code></td><td><code>?_page=2&_limit=10</code></td><td>${paginationDesc}</td></tr>
|
|
673
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>
|
|
674
799
|
</tbody>
|
|
675
800
|
</table>
|
|
676
801
|
</div>
|
package/dist/index.mjs
CHANGED
|
@@ -75,11 +75,92 @@ function createYamlStorage(filePath) {
|
|
|
75
75
|
import Fastify from "fastify";
|
|
76
76
|
import cors from "@fastify/cors";
|
|
77
77
|
|
|
78
|
-
// src/
|
|
78
|
+
// src/utils/params.ts
|
|
79
79
|
function nextId(items) {
|
|
80
80
|
const ids = items.map((i) => i["id"]).filter((id) => typeof id === "number");
|
|
81
81
|
return ids.length > 0 ? Math.max(...ids) + 1 : 1;
|
|
82
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
|
|
83
164
|
function findById(items, id) {
|
|
84
165
|
return items.find((i) => String(i["id"]) === id);
|
|
85
166
|
}
|
|
@@ -116,36 +197,17 @@ function patchItem(storage, resource, id, body) {
|
|
|
116
197
|
storage.persist();
|
|
117
198
|
return updated;
|
|
118
199
|
}
|
|
119
|
-
function
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
return
|
|
127
|
-
(item) => filters.every(([key, value]) => {
|
|
128
|
-
if (item[key] === void 0) return false;
|
|
129
|
-
const itemStr = String(item[key]);
|
|
130
|
-
return Array.isArray(value) ? value.includes(itemStr) : itemStr === value;
|
|
131
|
-
})
|
|
132
|
-
);
|
|
133
|
-
}
|
|
134
|
-
function sortBy(items, field, order) {
|
|
135
|
-
const direction = order === "desc" ? -1 : 1;
|
|
136
|
-
return [...items].sort((a, b) => {
|
|
137
|
-
const av = a[field];
|
|
138
|
-
const bv = b[field];
|
|
139
|
-
if (av === void 0) return 1;
|
|
140
|
-
if (bv === void 0) return -1;
|
|
141
|
-
if (typeof av === "number" && typeof bv === "number") return (av - bv) * direction;
|
|
142
|
-
return String(av).localeCompare(String(bv), void 0, { sensitivity: "base" }) * direction;
|
|
143
|
-
});
|
|
144
|
-
}
|
|
145
|
-
function paginate(items, page, limit) {
|
|
146
|
-
const start = (page - 1) * limit;
|
|
147
|
-
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;
|
|
148
208
|
}
|
|
209
|
+
|
|
210
|
+
// src/services/expand.service.ts
|
|
149
211
|
function expandItems(input, query, resource, storage) {
|
|
150
212
|
const isArray = Array.isArray(input);
|
|
151
213
|
const items = isArray ? input : [input];
|
|
@@ -178,14 +240,35 @@ function expandItems(input, query, resource, storage) {
|
|
|
178
240
|
});
|
|
179
241
|
return isArray ? expanded : expanded[0];
|
|
180
242
|
}
|
|
181
|
-
function
|
|
182
|
-
const
|
|
183
|
-
const
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
storage.
|
|
188
|
-
|
|
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];
|
|
189
272
|
}
|
|
190
273
|
|
|
191
274
|
// src/router/routes/collection.routes.ts
|
|
@@ -193,9 +276,12 @@ var registerCollectionRoutes = (server, storage, resource, base, options) => {
|
|
|
193
276
|
server.get(base, (req, reply) => {
|
|
194
277
|
const collection = storage.getCollection(resource) ?? [];
|
|
195
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);
|
|
196
282
|
const sortField = firstParam(req.query["_sort"]);
|
|
197
283
|
const sortOrder = firstParam(req.query["_order"]) === "desc" ? "desc" : "asc";
|
|
198
|
-
const sorted = sortField ? sortBy(
|
|
284
|
+
const sorted = sortField ? sortBy(searched, sortField, sortOrder) : searched;
|
|
199
285
|
if (options.pageable.enabled) {
|
|
200
286
|
const defaultLimit = options.pageable.limit;
|
|
201
287
|
const page = Math.max(1, parseInt(firstParam(req.query["_page"]) ?? "1", 10) || 1);
|
|
@@ -205,7 +291,15 @@ var registerCollectionRoutes = (server, storage, resource, base, options) => {
|
|
|
205
291
|
);
|
|
206
292
|
const totalItems = sorted.length;
|
|
207
293
|
const totalPages = Math.ceil(totalItems / limit) || 1;
|
|
208
|
-
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
|
+
);
|
|
209
303
|
const pagination = {
|
|
210
304
|
page,
|
|
211
305
|
limit,
|
|
@@ -229,7 +323,10 @@ var registerCollectionRoutes = (server, storage, resource, base, options) => {
|
|
|
229
323
|
reply.header("X-Total-Count", String(sorted.length));
|
|
230
324
|
result = paginate(sorted, page, limit);
|
|
231
325
|
}
|
|
232
|
-
return
|
|
326
|
+
return projectFields(
|
|
327
|
+
embedItems(expandItems(result, req.query, resource, storage), req.query, resource, storage),
|
|
328
|
+
fields
|
|
329
|
+
);
|
|
233
330
|
});
|
|
234
331
|
server.post(base, (req, reply) => {
|
|
235
332
|
if (!req.body || typeof req.body !== "object" || Array.isArray(req.body)) {
|
|
@@ -245,7 +342,11 @@ var registerItemRoutes = (server, storage, resource, base) => {
|
|
|
245
342
|
server.get(`${base}/:id`, (req, reply) => {
|
|
246
343
|
const item = findById(storage.getCollection(resource) ?? [], req.params.id);
|
|
247
344
|
if (!item) return reply.status(404).send({ error: "Not found" });
|
|
248
|
-
|
|
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
|
+
);
|
|
249
350
|
});
|
|
250
351
|
server.put(`${base}/:id`, (req, reply) => {
|
|
251
352
|
if (!req.body || typeof req.body !== "object" || Array.isArray(req.body)) {
|
|
@@ -419,6 +520,22 @@ curl ${host}${base}/${parent}/1/${child}`);
|
|
|
419
520
|
examples.push(`# Pageable envelope
|
|
420
521
|
curl "${host}${base}/${firstCol}?_page=2"`);
|
|
421
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
|
+
}
|
|
422
539
|
if (options.snapshot) {
|
|
423
540
|
examples.push(
|
|
424
541
|
`# Snapshot endpoints
|
|
@@ -630,9 +747,17 @@ function generateAboutHtml(storage, options) {
|
|
|
630
747
|
<thead><tr><th>Param</th><th>Example</th><th>Description</th></tr></thead>
|
|
631
748
|
<tbody>
|
|
632
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>
|
|
633
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>
|
|
634
757
|
<tr><td><code>?_page & ?_limit</code></td><td><code>?_page=2&_limit=10</code></td><td>${paginationDesc}</td></tr>
|
|
635
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>
|
|
636
761
|
</tbody>
|
|
637
762
|
</table>
|
|
638
763
|
</div>
|