@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 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/services/resourceService.ts
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 firstParam(value) {
256
- if (value === void 0) return void 0;
257
- return Array.isArray(value) ? value[0] : value;
258
- }
259
- function filterByQuery(items, query) {
260
- const filters = Object.entries(query).filter(([key]) => !key.startsWith("_"));
261
- if (filters.length === 0) return items;
262
- return items.filter(
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 deleteItem(storage, resource, id) {
318
- const collection = storage.getCollection(resource) ?? [];
319
- const idx = findIndexById(collection, id);
320
- if (idx === -1) return void 0;
321
- const [deleted] = collection.splice(idx, 1);
322
- storage.setCollection(resource, collection);
323
- storage.persist();
324
- return deleted;
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(filtered, sortField, sortOrder) : filtered;
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 = expandItems(paginate(sorted, page, limit), req.query, resource, storage);
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 expandItems(result, req.query, resource, storage);
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
- return expandItems(item, req.query, resource, storage);
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&amp;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&amp;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 &amp; ?_order</code></td><td><code>?_sort=name&amp;_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 &amp; ?_limit</code></td><td><code>?_page=2&amp;_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>
@@ -184,11 +184,92 @@ function createYamlStorage(filePath) {
184
184
  import Fastify from "fastify";
185
185
  import cors from "@fastify/cors";
186
186
 
187
- // src/services/resourceService.ts
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 firstParam(value) {
229
- if (value === void 0) return void 0;
230
- return Array.isArray(value) ? value[0] : value;
231
- }
232
- function filterByQuery(items, query) {
233
- const filters = Object.entries(query).filter(([key]) => !key.startsWith("_"));
234
- if (filters.length === 0) return items;
235
- return items.filter(
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 deleteItem(storage, resource, id) {
291
- const collection = storage.getCollection(resource) ?? [];
292
- const idx = findIndexById(collection, id);
293
- if (idx === -1) return void 0;
294
- const [deleted] = collection.splice(idx, 1);
295
- storage.setCollection(resource, collection);
296
- storage.persist();
297
- return deleted;
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(filtered, sortField, sortOrder) : filtered;
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 = expandItems(paginate(sorted, page, limit), req.query, resource, storage);
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 expandItems(result, req.query, resource, storage);
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
- return expandItems(item, req.query, resource, storage);
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&amp;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&amp;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 &amp; ?_order</code></td><td><code>?_sort=name&amp;_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 &amp; ?_limit</code></td><td><code>?_page=2&amp;_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/services/resourceService.ts
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 firstParam(value) {
158
- if (value === void 0) return void 0;
159
- return Array.isArray(value) ? value[0] : value;
160
- }
161
- function filterByQuery(items, query) {
162
- const filters = Object.entries(query).filter(([key]) => !key.startsWith("_"));
163
- if (filters.length === 0) return items;
164
- return items.filter(
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 deleteItem(storage, resource, id) {
220
- const collection = storage.getCollection(resource) ?? [];
221
- const idx = findIndexById(collection, id);
222
- if (idx === -1) return void 0;
223
- const [deleted] = collection.splice(idx, 1);
224
- storage.setCollection(resource, collection);
225
- storage.persist();
226
- return deleted;
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(filtered, sortField, sortOrder) : filtered;
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 = expandItems(paginate(sorted, page, limit), req.query, resource, storage);
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 expandItems(result, req.query, resource, storage);
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
- return expandItems(item, req.query, resource, storage);
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&amp;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&amp;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 &amp; ?_order</code></td><td><code>?_sort=name&amp;_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 &amp; ?_limit</code></td><td><code>?_page=2&amp;_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/services/resourceService.ts
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 firstParam(value) {
120
- if (value === void 0) return void 0;
121
- return Array.isArray(value) ? value[0] : value;
122
- }
123
- function filterByQuery(items, query) {
124
- const filters = Object.entries(query).filter(([key]) => !key.startsWith("_"));
125
- if (filters.length === 0) return items;
126
- return items.filter(
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 deleteItem(storage, resource, id) {
182
- const collection = storage.getCollection(resource) ?? [];
183
- const idx = findIndexById(collection, id);
184
- if (idx === -1) return void 0;
185
- const [deleted] = collection.splice(idx, 1);
186
- storage.setCollection(resource, collection);
187
- storage.persist();
188
- return deleted;
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(filtered, sortField, sortOrder) : filtered;
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 = expandItems(paginate(sorted, page, limit), req.query, resource, storage);
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 expandItems(result, req.query, resource, storage);
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
- return expandItems(item, req.query, resource, storage);
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&amp;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&amp;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 &amp; ?_order</code></td><td><code>?_sort=name&amp;_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 &amp; ?_limit</code></td><td><code>?_page=2&amp;_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>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aggiovato/yrest",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Zero-config REST API mock server powered by a YAML file",
5
5
  "keywords": [
6
6
  "yaml",