@aggiovato/yrest 0.1.1 → 0.2.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
@@ -90,16 +90,22 @@ var templates = {
90
90
  };
91
91
 
92
92
  // src/cli/commands/init.ts
93
+ var CONFIG_TEMPLATE = `# yrest configuration
94
+ # All options can be overridden with CLI flags
95
+
96
+ file: db.yml # YAML database file
97
+ port: 3070 # Port to listen on
98
+ host: localhost # Host to bind
99
+ # base: /api # Base path prefix for all routes
100
+ # watch: false # Reload db file on change
101
+ # readonly: false # Block write operations (POST, PUT, PATCH, DELETE)
102
+ # delay: 0 # Simulated network latency in milliseconds
103
+ # pageable: false # Wrap GET collections in { data, pagination }. Use true (limit 10) or a number
104
+ `;
93
105
  function registerInit(program2) {
94
- program2.command("init").description("Create a sample db.yml in the current directory").option("-f, --file <name>", "Output filename", "db.yml").option(
95
- "-s, --sample <name>",
96
- `Sample data to use (${SAMPLES.join(", ")})`,
97
- "basic"
98
- ).action((flags) => {
106
+ program2.command("init").description("Create a sample db.yml and yrest.config.yml in the current directory").option("-f, --file <name>", "Output filename", "db.yml").option("-s, --sample <name>", `Sample data to use (${SAMPLES.join(", ")})`, "basic").action((flags) => {
99
107
  if (!SAMPLES.includes(flags.sample)) {
100
- console.error(
101
- `Error: unknown sample "${flags.sample}". Available: ${SAMPLES.join(", ")}`
102
- );
108
+ console.error(`Error: unknown sample "${flags.sample}". Available: ${SAMPLES.join(", ")}`);
103
109
  process.exit(1);
104
110
  }
105
111
  const target = (0, import_node_path.resolve)(process.cwd(), flags.file);
@@ -109,10 +115,19 @@ function registerInit(program2) {
109
115
  }
110
116
  (0, import_node_fs.writeFileSync)(target, templates[flags.sample], "utf8");
111
117
  console.log(`Created ${flags.file} (sample: ${flags.sample})`);
112
- console.log(`Run: npx @aggiovato/yrest serve ${flags.file}`);
118
+ const configTarget = (0, import_node_path.resolve)(process.cwd(), "yrest.config.yml");
119
+ if (!(0, import_node_fs.existsSync)(configTarget)) {
120
+ (0, import_node_fs.writeFileSync)(configTarget, CONFIG_TEMPLATE, "utf8");
121
+ console.log("Created yrest.config.yml");
122
+ }
123
+ console.log(`Run: npx @aggiovato/yrest serve`);
113
124
  });
114
125
  }
115
126
 
127
+ // src/cli/commands/serve.ts
128
+ var import_node_fs4 = require("fs");
129
+ var import_node_path3 = require("path");
130
+
116
131
  // src/storage/yamlStorage.ts
117
132
  var import_node_fs2 = require("fs");
118
133
  var import_node_path2 = require("path");
@@ -143,6 +158,17 @@ function createYamlStorage(filePath) {
143
158
  const tmp = (0, import_node_path2.resolve)((0, import_node_path2.dirname)(absPath), `.yrest-${(0, import_node_crypto.randomUUID)()}.tmp`);
144
159
  (0, import_node_fs2.writeFileSync)(tmp, (0, import_yaml.stringify)(payload), "utf8");
145
160
  (0, import_node_fs2.renameSync)(tmp, absPath);
161
+ },
162
+ reload() {
163
+ const fresh = (0, import_yaml.parse)((0, import_node_fs2.readFileSync)(absPath, "utf8")) ?? {};
164
+ const freshRelations = fresh["_rel"] ?? {};
165
+ const freshData = Object.fromEntries(
166
+ Object.entries(fresh).filter(([key]) => key !== "_rel")
167
+ );
168
+ for (const key of Object.keys(data)) delete data[key];
169
+ Object.assign(data, freshData);
170
+ for (const key of Object.keys(relations)) delete relations[key];
171
+ Object.assign(relations, freshRelations);
146
172
  }
147
173
  };
148
174
  }
@@ -192,17 +218,68 @@ function patchItem(storage, resource, id, body) {
192
218
  storage.persist();
193
219
  return updated;
194
220
  }
221
+ function firstParam(value) {
222
+ if (value === void 0) return void 0;
223
+ return Array.isArray(value) ? value[0] : value;
224
+ }
195
225
  function filterByQuery(items, query) {
196
226
  const filters = Object.entries(query).filter(([key]) => !key.startsWith("_"));
197
227
  if (filters.length === 0) return items;
198
228
  return items.filter(
199
- (item) => filters.every(([key, value]) => item[key] !== void 0 && String(item[key]) === value)
229
+ (item) => filters.every(([key, value]) => {
230
+ if (item[key] === void 0) return false;
231
+ const itemStr = String(item[key]);
232
+ return Array.isArray(value) ? value.includes(itemStr) : itemStr === value;
233
+ })
200
234
  );
201
235
  }
236
+ function sortBy(items, field, order) {
237
+ const direction = order === "desc" ? -1 : 1;
238
+ return [...items].sort((a, b) => {
239
+ const av = a[field];
240
+ const bv = b[field];
241
+ if (av === void 0) return 1;
242
+ if (bv === void 0) return -1;
243
+ if (typeof av === "number" && typeof bv === "number") return (av - bv) * direction;
244
+ return String(av).localeCompare(String(bv), void 0, { sensitivity: "base" }) * direction;
245
+ });
246
+ }
202
247
  function paginate(items, page, limit) {
203
248
  const start = (page - 1) * limit;
204
249
  return items.slice(start, start + limit);
205
250
  }
251
+ function expandItems(input, query, resource, storage) {
252
+ const isArray = Array.isArray(input);
253
+ const items = isArray ? input : [input];
254
+ const expandParam = query["_expand"];
255
+ if (!expandParam) return isArray ? items : input;
256
+ const keys = (Array.isArray(expandParam) ? expandParam : [expandParam]).flatMap((v) => v.split(",")).map((k) => k.trim()).filter(Boolean);
257
+ const resourceRelations = storage.getRelations()[resource] ?? {};
258
+ const expansions = /* @__PURE__ */ new Map();
259
+ for (const expandKey of keys) {
260
+ for (const [field, parentCollection] of Object.entries(resourceRelations)) {
261
+ const derivedKey = field.replace(/Id$/i, "");
262
+ if (derivedKey === expandKey || parentCollection === expandKey || parentCollection === `${expandKey}s`) {
263
+ expansions.set(expandKey, { field, parentCollection });
264
+ break;
265
+ }
266
+ }
267
+ }
268
+ if (expansions.size === 0) return isArray ? items : input;
269
+ const expanded = items.map((item) => {
270
+ const result = { ...item };
271
+ for (const [expandKey, { field, parentCollection }] of expansions) {
272
+ const foreignKeyValue = item[field];
273
+ if (foreignKeyValue === void 0) continue;
274
+ const parent = (storage.getCollection(parentCollection) ?? []).find(
275
+ (p) => String(p["id"]) === String(foreignKeyValue)
276
+ );
277
+ if (parent !== void 0) result[expandKey] = parent;
278
+ }
279
+ return result;
280
+ });
281
+ return isArray ? expanded : expanded[0];
282
+ }
206
283
  function deleteItem(storage, resource, id) {
207
284
  const collection = storage.getCollection(resource) ?? [];
208
285
  const idx = findIndexById(collection, id);
@@ -214,104 +291,513 @@ function deleteItem(storage, resource, id) {
214
291
  }
215
292
 
216
293
  // src/router/routes/collection.routes.ts
217
- function registerCollectionRoutes(server, storage, resource, prefix) {
218
- server.get(prefix, (req, reply) => {
294
+ var registerCollectionRoutes = (server, storage, resource, base, options) => {
295
+ server.get(base, (req, reply) => {
219
296
  const collection = storage.getCollection(resource) ?? [];
220
297
  const filtered = filterByQuery(collection, req.query);
221
- const rawPage = req.query["_page"];
222
- const rawLimit = req.query["_limit"];
223
- if (!rawPage && !rawLimit) return filtered;
224
- const page = Math.max(1, parseInt(rawPage ?? "1", 10) || 1);
225
- const limit = Math.max(1, parseInt(rawLimit ?? "10", 10) || 10);
226
- reply.header("X-Total-Count", String(filtered.length));
227
- return paginate(filtered, page, limit);
298
+ const sortField = firstParam(req.query["_sort"]);
299
+ const sortOrder = firstParam(req.query["_order"]) === "desc" ? "desc" : "asc";
300
+ const sorted = sortField ? sortBy(filtered, sortField, sortOrder) : filtered;
301
+ if (options.pageable.enabled) {
302
+ const defaultLimit = options.pageable.limit;
303
+ const page = Math.max(1, parseInt(firstParam(req.query["_page"]) ?? "1", 10) || 1);
304
+ const limit = Math.max(
305
+ 1,
306
+ parseInt(firstParam(req.query["_limit"]) ?? String(defaultLimit), 10) || defaultLimit
307
+ );
308
+ const totalItems = sorted.length;
309
+ const totalPages = Math.ceil(totalItems / limit) || 1;
310
+ const data = expandItems(paginate(sorted, page, limit), req.query, resource, storage);
311
+ const pagination = {
312
+ page,
313
+ limit,
314
+ totalItems,
315
+ totalPages,
316
+ isFirst: page === 1,
317
+ isLast: page >= totalPages,
318
+ hasNext: page < totalPages,
319
+ hasPrev: page > 1
320
+ };
321
+ return reply.send({ data, pagination });
322
+ }
323
+ const rawPage = firstParam(req.query["_page"]);
324
+ const rawLimit = firstParam(req.query["_limit"]);
325
+ let result;
326
+ if (!rawPage && !rawLimit) {
327
+ result = sorted;
328
+ } else {
329
+ const page = Math.max(1, parseInt(rawPage ?? "1", 10) || 1);
330
+ const limit = Math.max(1, parseInt(rawLimit ?? "10", 10) || 10);
331
+ reply.header("X-Total-Count", String(sorted.length));
332
+ result = paginate(sorted, page, limit);
333
+ }
334
+ return expandItems(result, req.query, resource, storage);
228
335
  });
229
- server.post(prefix, (req, reply) => {
336
+ server.post(base, (req, reply) => {
230
337
  const item = createItem(storage, resource, req.body);
231
- return reply.status(201).send(item);
338
+ return reply.status(201).send(expandItems(item, req.query, resource, storage));
232
339
  });
233
- }
340
+ };
234
341
 
235
342
  // src/router/routes/item.routes.ts
236
- function registerItemRoutes(server, storage, resource, prefix) {
237
- server.get(`${prefix}/:id`, (req, reply) => {
343
+ var registerItemRoutes = (server, storage, resource, base) => {
344
+ server.get(`${base}/:id`, (req, reply) => {
238
345
  const item = findById(storage.getCollection(resource) ?? [], req.params.id);
239
346
  if (!item) return reply.status(404).send({ error: "Not found" });
240
- return item;
347
+ return expandItems(item, req.query, resource, storage);
241
348
  });
242
- server.put(`${prefix}/:id`, (req, reply) => {
349
+ server.put(`${base}/:id`, (req, reply) => {
243
350
  const item = replaceItem(storage, resource, req.params.id, req.body);
244
351
  if (!item) return reply.status(404).send({ error: "Not found" });
245
- return item;
352
+ return expandItems(item, req.query, resource, storage);
246
353
  });
247
- server.patch(`${prefix}/:id`, (req, reply) => {
354
+ server.patch(`${base}/:id`, (req, reply) => {
248
355
  const item = patchItem(storage, resource, req.params.id, req.body);
249
356
  if (!item) return reply.status(404).send({ error: "Not found" });
250
- return item;
357
+ return expandItems(item, req.query, resource, storage);
251
358
  });
252
- server.delete(`${prefix}/:id`, (req, reply) => {
359
+ server.delete(`${base}/:id`, (req, reply) => {
253
360
  const item = deleteItem(storage, resource, req.params.id);
254
361
  if (!item) return reply.status(404).send({ error: "Not found" });
255
- return item;
362
+ return expandItems(item, req.query, resource, storage);
256
363
  });
257
- }
364
+ };
258
365
 
259
366
  // src/router/routes/nested.routes.ts
260
- function registerNestedRoutes(server, storage, relations, base) {
367
+ var registerNestedRoutes = (server, storage, relations, base) => {
261
368
  for (const [child, fields] of Object.entries(relations)) {
262
369
  for (const [field, parent] of Object.entries(fields)) {
263
- server.get(
264
- `${base}/${parent}/:id/${child}`,
265
- (req, reply) => {
266
- const parentCollection = storage.getCollection(parent) ?? [];
267
- const parentItem = findById(parentCollection, req.params.id);
268
- if (!parentItem) return reply.status(404).send({ error: "Not found" });
269
- const children = (storage.getCollection(child) ?? []).filter(
270
- (item) => String(item[field]) === req.params.id
271
- );
272
- return children;
273
- }
274
- );
370
+ server.get(`${base}/${parent}/:id/${child}`, (req, reply) => {
371
+ const parentCollection = storage.getCollection(parent) ?? [];
372
+ const parentItem = findById(parentCollection, req.params.id);
373
+ if (!parentItem) return reply.status(404).send({ error: "Not found" });
374
+ const children = (storage.getCollection(child) ?? []).filter(
375
+ (item) => String(item[field]) === req.params.id
376
+ );
377
+ return children;
378
+ });
275
379
  }
276
380
  }
277
- }
381
+ };
278
382
 
279
383
  // src/router/resource.router.ts
280
- function registerResourceRoutes(server, storage, base) {
384
+ function registerResourceRoutes(server, storage, options) {
281
385
  for (const resource of Object.keys(storage.getData())) {
282
- const prefix = `${base}/${resource}`;
283
- registerCollectionRoutes(server, storage, resource, prefix);
284
- registerItemRoutes(server, storage, resource, prefix);
386
+ const resourceBase = `${options.base}/${resource}`;
387
+ registerCollectionRoutes(server, storage, resource, resourceBase, options);
388
+ registerItemRoutes(server, storage, resource, resourceBase, options);
389
+ }
390
+ registerNestedRoutes(server, storage, storage.getRelations(), options.base);
391
+ }
392
+
393
+ // src/router/templates/about.template.ts
394
+ var METHOD_COLOR = {
395
+ GET: "#3fb950",
396
+ POST: "#58a6ff",
397
+ PUT: "#d29922",
398
+ PATCH: "#a371f7",
399
+ DELETE: "#f85149"
400
+ };
401
+ function badge(label, color, bg) {
402
+ return `<span class="badge" style="background:${bg};color:${color};border:1px solid ${color}40">${label}</span>`;
403
+ }
404
+ function methodBadge(method) {
405
+ const color = METHOD_COLOR[method] ?? "#7d8590";
406
+ return badge(method, color, `${color}18`);
407
+ }
408
+ function endpointRow(method, path, desc) {
409
+ return `
410
+ <tr>
411
+ <td class="method-cell">${methodBadge(method)}</td>
412
+ <td class="path-cell"><code>${path}</code></td>
413
+ <td class="desc-cell">${desc}</td>
414
+ </tr>`;
415
+ }
416
+ function resourceAccordion(name, base, isOpen) {
417
+ const p = `${base}/${name}`;
418
+ const singular = name.endsWith("s") ? name.slice(0, -1) : name;
419
+ const rows = [
420
+ endpointRow(
421
+ "GET",
422
+ p,
423
+ `List all ${name}. Supports filters, sort, pagination and <code>?_expand</code>.`
424
+ ),
425
+ endpointRow(
426
+ "POST",
427
+ p,
428
+ `Create a new ${singular}. Auto-assigns <code>id</code> if not provided.`
429
+ ),
430
+ endpointRow("GET", `${p}/:id`, `Get a single ${singular} by id.`),
431
+ endpointRow(
432
+ "PUT",
433
+ `${p}/:id`,
434
+ `Fully replace a ${singular}. Original <code>id</code> is always preserved.`
435
+ ),
436
+ endpointRow(
437
+ "PATCH",
438
+ `${p}/:id`,
439
+ `Partially update a ${singular} \u2014 only provided fields change.`
440
+ ),
441
+ endpointRow("DELETE", `${p}/:id`, `Delete a ${singular} and return it as confirmation.`)
442
+ ].join("");
443
+ return `
444
+ <details class="resource-card" ${isOpen ? "open" : ""}>
445
+ <summary>
446
+ <span class="resource-name">/${name}</span>
447
+ <span class="route-count">6 routes</span>
448
+ </summary>
449
+ <table>
450
+ <tbody>${rows}</tbody>
451
+ </table>
452
+ </details>`;
453
+ }
454
+ function examplesBlock(collections, relations, base, host, options) {
455
+ const examples = [];
456
+ const firstCol = collections[0];
457
+ if (firstCol) {
458
+ const p = `${host}${base}/${firstCol}`;
459
+ const singular = firstCol.endsWith("s") ? firstCol.slice(0, -1) : firstCol;
460
+ examples.push(
461
+ `# List all ${firstCol}
462
+ curl ${p}`,
463
+ `# Filter by field
464
+ curl "${p}?name=value"`,
465
+ `# Sort and paginate
466
+ curl "${p}?_sort=id&_order=desc&_page=1&_limit=5"`,
467
+ `# Get single ${singular}
468
+ curl ${p}/1`,
469
+ `# Create ${singular}
470
+ curl -X POST ${p} \\
471
+ -H "Content-Type: application/json" \\
472
+ -d '{"name":"example"}'`,
473
+ `# Partially update ${singular}
474
+ curl -X PATCH ${p}/1 \\
475
+ -H "Content-Type: application/json" \\
476
+ -d '{"name":"updated"}'`,
477
+ `# Delete ${singular}
478
+ curl -X DELETE ${p}/1`
479
+ );
480
+ }
481
+ const firstRel = Object.entries(relations)[0];
482
+ if (firstRel) {
483
+ const [child, fields] = firstRel;
484
+ const fk = Object.keys(fields)[0];
485
+ const expandKey = fk.replace(/Id$/i, "");
486
+ examples.push(
487
+ `# Embed parent with ?_expand
488
+ curl "${host}${base}/${child}/1?_expand=${expandKey}"`
489
+ );
490
+ }
491
+ for (const [child, fields] of Object.entries(relations)) {
492
+ for (const [, parent] of Object.entries(fields)) {
493
+ examples.push(`# Nested resource
494
+ curl ${host}${base}/${parent}/1/${child}`);
495
+ break;
496
+ }
497
+ break;
498
+ }
499
+ if (options.pageable.enabled && firstCol) {
500
+ examples.push(`# Pageable envelope
501
+ curl "${host}${base}/${firstCol}?_page=2"`);
502
+ }
503
+ const highlighted = examples.map((e) => e.replace(/^(#.+)$/gm, '<span class="cm">$1</span>')).join("\n\n");
504
+ return `<pre>${highlighted}</pre>`;
505
+ }
506
+ function generateAboutHtml(storage, options) {
507
+ const collections = Object.keys(storage.getData());
508
+ const relations = storage.getRelations();
509
+ const base = options.base;
510
+ const host = `http://${options.host}:${options.port}`;
511
+ const modes = [];
512
+ if (options.watch) modes.push(badge("watch", "#38bdf8", "#38bdf818"));
513
+ if (options.readonly) modes.push(badge("readonly", "#94a3b8", "#94a3b818"));
514
+ if (options.delay > 0) modes.push(badge(`delay \xB7 ${options.delay}ms`, "#fb923c", "#fb923c18"));
515
+ if (options.pageable.enabled)
516
+ modes.push(badge(`pageable \xB7 limit ${options.pageable.limit}`, "#34d399", "#34d39918"));
517
+ const accordions = collections.map((col, i) => resourceAccordion(col, base, i === 0)).join("");
518
+ const nestedRows = [];
519
+ for (const [child, fields] of Object.entries(relations)) {
520
+ for (const [, parent] of Object.entries(fields)) {
521
+ const nestedPath = `${base}/${parent}/:id/${child}`;
522
+ const parentSingular = parent.endsWith("s") ? parent.slice(0, -1) : parent;
523
+ nestedRows.push(
524
+ endpointRow("GET", nestedPath, `List ${child} belonging to a ${parentSingular}.`)
525
+ );
526
+ }
285
527
  }
286
- registerNestedRoutes(server, storage, storage.getRelations(), base);
528
+ const nestedAccordion = nestedRows.length ? `
529
+ <details class="resource-card nested-card">
530
+ <summary>
531
+ <span class="resource-name">Nested routes</span>
532
+ <span class="route-count">${nestedRows.length} route${nestedRows.length !== 1 ? "s" : ""}</span>
533
+ </summary>
534
+ <table><tbody>${nestedRows.join("")}</tbody></table>
535
+ </details>` : "";
536
+ const paginationDesc = options.pageable.enabled ? `Pageable mode active \u2014 default limit <code>${options.pageable.limit}</code>. Response wrapped in <code>{ data, pagination }</code>.` : `Returns the requested slice. <code>X-Total-Count</code> header reflects the total before pagination.`;
537
+ return `<!DOCTYPE html>
538
+ <html lang="en">
539
+ <head>
540
+ <meta charset="UTF-8">
541
+ <meta name="viewport" content="width=device-width,initial-scale=1">
542
+ <title>yrest \u2014 API Overview</title>
543
+ <style>
544
+ :root {
545
+ --bg: #0d1117;
546
+ --bg-card: #161b22;
547
+ --bg-hover: #1c2128;
548
+ --bg-inset: #0d1117;
549
+ --border: #30363d;
550
+ --border-hi: #3d444d;
551
+ --text: #e6edf3;
552
+ --text-muted:#7d8590;
553
+ --accent: #58a6ff;
554
+ --radius: 8px;
555
+ }
556
+ * { box-sizing: border-box; margin: 0; padding: 0; }
557
+ body { background: var(--bg); color: var(--text); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-size: 14px; line-height: 1.6; }
558
+
559
+ /* \u2500\u2500 Banner \u2500\u2500 */
560
+ .banner {
561
+ width: 100%;
562
+ background: linear-gradient(135deg, #0d1117 0%, #161b22 40%, #1a2332 100%);
563
+ border-bottom: 1px solid var(--border);
564
+ padding: 48px 32px 40px;
565
+ }
566
+ .banner-inner { max-width: 1100px; margin: 0 auto; }
567
+ .banner h1 { font-size: clamp(36px, 6vw, 60px); font-weight: 800; letter-spacing: -2px; line-height: 1; }
568
+ .banner h1 .y { color: var(--text); }
569
+ .banner h1 .rest { color: var(--accent); }
570
+ .banner p { color: var(--text-muted); margin-top: 10px; font-size: 15px; }
571
+ .banner-meta { display: flex; gap: 24px; margin-top: 20px; flex-wrap: wrap; }
572
+ .banner-meta span { color: var(--text-muted); font-size: 13px; }
573
+ .banner-meta span strong { color: var(--text); font-family: monospace; }
574
+
575
+ /* \u2500\u2500 Layout \u2500\u2500 */
576
+ .wrap { max-width: 1100px; margin: 0 auto; padding: 32px 24px 48px; }
577
+ h2 { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .08em; color: var(--text-muted); margin: 32px 0 12px; }
578
+
579
+ /* \u2500\u2500 Cards \u2500\u2500 */
580
+ .card { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 20px 24px; }
581
+ .two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
582
+ @media (max-width: 600px) { .two-col { grid-template-columns: 1fr; } }
583
+
584
+ /* \u2500\u2500 Server info grid \u2500\u2500 */
585
+ .info-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 16px; }
586
+ .stat label { font-size: 10px; text-transform: uppercase; letter-spacing: .07em; color: var(--text-muted); display: block; margin-bottom: 3px; }
587
+ .stat value { font-size: 15px; font-weight: 600; font-family: monospace; color: var(--text); }
588
+
589
+ /* \u2500\u2500 Mode badges \u2500\u2500 */
590
+ .modes { display: flex; gap: 8px; flex-wrap: wrap; min-height: 28px; align-items: center; }
591
+ .badge { display: inline-block; padding: 3px 10px; border-radius: 20px; font-size: 12px; font-weight: 600; white-space: nowrap; }
592
+
593
+ /* \u2500\u2500 Endpoints grid (2 cols on wide, 1 col on narrow) \u2500\u2500 */
594
+ .endpoints-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; }
595
+ @media (max-width: 860px) { .endpoints-grid { grid-template-columns: 1fr; } }
596
+ .nested-card { grid-column: 1 / -1; }
597
+
598
+ /* \u2500\u2500 Accordion (details/summary) \u2500\u2500 */
599
+ .resource-card {
600
+ background: var(--bg-card);
601
+ border: 1px solid var(--border);
602
+ border-radius: var(--radius);
603
+ overflow: hidden;
604
+ transition: border-color .15s;
605
+ }
606
+ .resource-card[open] { border-color: var(--border-hi); }
607
+ .resource-card summary {
608
+ display: flex;
609
+ align-items: center;
610
+ justify-content: space-between;
611
+ padding: 13px 18px;
612
+ cursor: pointer;
613
+ user-select: none;
614
+ list-style: none;
615
+ gap: 8px;
616
+ }
617
+ .resource-card summary::-webkit-details-marker { display: none; }
618
+ .resource-card summary::before {
619
+ content: "\u203A";
620
+ color: var(--text-muted);
621
+ font-size: 18px;
622
+ line-height: 1;
623
+ transition: transform .2s;
624
+ margin-right: 4px;
625
+ flex-shrink: 0;
626
+ }
627
+ .resource-card[open] summary::before { transform: rotate(90deg); }
628
+ .resource-card summary:hover { background: var(--bg-hover); }
629
+ .resource-name { font-family: monospace; font-size: 14px; font-weight: 600; color: var(--accent); flex: 1; }
630
+ .route-count { font-size: 11px; color: var(--text-muted); background: var(--bg-inset); border: 1px solid var(--border); padding: 2px 8px; border-radius: 12px; white-space: nowrap; }
631
+
632
+ /* \u2500\u2500 Tables \u2500\u2500 */
633
+ table { width: 100%; border-collapse: collapse; }
634
+ td { padding: 8px 12px; border-top: 1px solid var(--border); vertical-align: top; font-size: 13px; }
635
+ .method-cell { width: 78px; white-space: nowrap; }
636
+ .path-cell { width: 44%; white-space: nowrap; }
637
+ .desc-cell { color: var(--text-muted); }
638
+ code { font-family: "SF Mono", "Fira Code", monospace; font-size: 12px; background: #58a6ff15; color: var(--accent); padding: 1px 5px; border-radius: 3px; }
639
+
640
+ /* \u2500\u2500 Query params table \u2500\u2500 */
641
+ .param-table th { text-align: left; padding: 8px 12px; font-size: 10px; text-transform: uppercase; letter-spacing: .06em; color: var(--text-muted); border-bottom: 1px solid var(--border); }
642
+ .param-table td:first-child { white-space: nowrap; width: 160px; }
643
+ .param-table td:nth-child(2) { white-space: nowrap; width: 200px; }
644
+
645
+ /* \u2500\u2500 Code block \u2500\u2500 */
646
+ pre { background: #010409; border: 1px solid var(--border); color: #e6edf3; padding: 20px 24px; border-radius: var(--radius); font-size: 12.5px; line-height: 1.8; overflow-x: auto; font-family: "SF Mono", "Fira Code", monospace; }
647
+ .cm { color: #3d444d; }
648
+
649
+ /* \u2500\u2500 Warning \u2500\u2500 */
650
+ .warn { background: #2d1f0e; border-left: 3px solid #d29922; padding: 10px 14px; border-radius: 0 6px 6px 0; font-size: 13px; color: #d29922; margin-top: 12px; }
651
+
652
+ /* \u2500\u2500 Footer \u2500\u2500 */
653
+ footer { margin-top: 48px; text-align: center; font-size: 11px; color: var(--text-muted); padding-bottom: 16px; }
654
+ footer a { color: var(--accent); text-decoration: none; }
655
+ </style>
656
+ </head>
657
+ <body>
658
+
659
+ <div class="banner">
660
+ <div class="banner-inner">
661
+ <h1><span class="y">y</span><span class="rest">rest</span></h1>
662
+ <p>Zero-config REST API mock server</p>
663
+ <div class="banner-meta">
664
+ <span>URL <strong>${host}</strong></span>
665
+ <span>Base <strong>${base || "/"}</strong></span>
666
+ <span>File <strong>${options.file}</strong></span>
667
+ <span>Collections <strong>${collections.length}</strong></span>
668
+ </div>
669
+ </div>
670
+ </div>
671
+
672
+ <div class="wrap">
673
+
674
+ <h2>Active Modes</h2>
675
+ <div class="card">
676
+ <div class="modes">${modes.length ? modes.join(" ") : `<span style="color:var(--text-muted);font-size:13px">none</span>`}</div>
677
+ ${options.watch ? `<div class="warn">\u26A0 <strong>Watch mode:</strong> data changes in existing collections reload automatically. Adding or removing entire collections requires a server restart.</div>` : ""}
678
+ </div>
679
+
680
+ <h2>Endpoints</h2>
681
+ <div class="endpoints-grid">
682
+ ${accordions}
683
+ ${nestedAccordion}
684
+ </div>
685
+
686
+ <h2>Query Parameters</h2>
687
+ <div class="card">
688
+ <table class="param-table">
689
+ <thead><tr><th>Param</th><th>Example</th><th>Description</th></tr></thead>
690
+ <tbody>
691
+ <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>
692
+ <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>
693
+ <tr><td><code>?_page &amp; ?_limit</code></td><td><code>?_page=2&amp;_limit=10</code></td><td>${paginationDesc}</td></tr>
694
+ <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>
695
+ </tbody>
696
+ </table>
697
+ </div>
698
+
699
+ ${collections.length ? `<h2>Examples</h2><div class="card">${examplesBlock(collections, relations, base, host, options)}</div>` : ""}
700
+
701
+ <footer>
702
+ Powered by <a href="https://github.com/aggiovato/yaml-rest" target="_blank">@aggiovato/yrest</a> &nbsp;\xB7&nbsp; <a href="/_about">/_about</a>
703
+ </footer>
704
+
705
+ </div>
706
+ </body>
707
+ </html>`;
708
+ }
709
+
710
+ // src/router/routes/about.routes.ts
711
+ function registerAboutRoute(server, storage, options) {
712
+ server.get("/_about", (_req, reply) => {
713
+ reply.header("Content-Type", "text/html; charset=utf-8");
714
+ return reply.send(generateAboutHtml(storage, options));
715
+ });
287
716
  }
288
717
 
289
718
  // src/server/createServer.ts
719
+ var MUTATING_METHODS = /* @__PURE__ */ new Set(["POST", "PUT", "PATCH", "DELETE"]);
290
720
  async function createServer(storage, options) {
291
721
  const server = (0, import_fastify.default)();
292
722
  await server.register(import_cors.default);
293
- registerResourceRoutes(server, storage, options.base);
723
+ if (options.readonly) {
724
+ server.addHook("onRequest", (_req, reply, done) => {
725
+ if (MUTATING_METHODS.has(_req.method)) {
726
+ reply.status(405).header("Allow", "GET, HEAD, OPTIONS").send({ error: "Server is running in readonly mode" });
727
+ }
728
+ done();
729
+ });
730
+ }
731
+ if (options.delay > 0) {
732
+ server.addHook("onSend", (_req, _reply, payload, done) => {
733
+ setTimeout(() => done(null, payload), options.delay);
734
+ });
735
+ }
736
+ registerAboutRoute(server, storage, options);
737
+ registerResourceRoutes(server, storage, options);
294
738
  return server;
295
739
  }
296
740
 
297
741
  // src/config/loadOptions.ts
298
742
  var import_zod = require("zod");
299
743
  var serverOptionsSchema = import_zod.z.object({
744
+ /** Path to the YAML database file. Must be a non-empty string. */
300
745
  file: import_zod.z.string().min(1),
746
+ /** TCP port the server listens on. Accepts string input and coerces to number. */
301
747
  port: import_zod.z.coerce.number().int().positive().default(3070),
748
+ /** Hostname or IP address to bind. */
302
749
  host: import_zod.z.string().default("localhost"),
303
- base: import_zod.z.string().default("").transform((v) => v && !v.startsWith("/") ? `/${v}` : v)
750
+ /**
751
+ * URL prefix prepended to every route (e.g. `/api`).
752
+ * A leading slash is added automatically if omitted.
753
+ */
754
+ base: import_zod.z.string().default("").transform((v) => v && !v.startsWith("/") ? `/${v}` : v),
755
+ /** When `true`, the server reloads the YAML file automatically on disk changes. */
756
+ watch: import_zod.z.boolean().default(false),
757
+ /** When `true`, all mutating requests (POST, PUT, PATCH, DELETE) are rejected with 405. */
758
+ readonly: import_zod.z.boolean().default(false),
759
+ /** Milliseconds to delay every response, simulating network latency. `0` = disabled. */
760
+ delay: import_zod.z.coerce.number().int().min(0).default(0),
761
+ /**
762
+ * Wraps GET collection responses in a `{ data, pagination }` envelope.
763
+ * Accepts `true` (default limit 10), `false` (disabled), or a positive integer (custom limit).
764
+ */
765
+ pageable: import_zod.z.union([import_zod.z.boolean(), import_zod.z.coerce.number().int().positive()]).default(false).transform((v) => ({
766
+ enabled: v !== false,
767
+ limit: v === false || v === true ? 10 : v
768
+ }))
304
769
  });
305
770
 
771
+ // src/config/loadConfigFile.ts
772
+ var import_node_fs3 = require("fs");
773
+ var import_yaml2 = require("yaml");
774
+ function loadConfigFile(configPath) {
775
+ if (!(0, import_node_fs3.existsSync)(configPath)) return {};
776
+ const raw = (0, import_node_fs3.readFileSync)(configPath, "utf8");
777
+ return (0, import_yaml2.parse)(raw) ?? {};
778
+ }
779
+
306
780
  // src/cli/commands/serve.ts
307
781
  function registerServe(program2) {
308
- program2.command("serve").description("Start the mock server using a YAML file as database").argument("[file]", "Path to the YAML database file", "db.yml").option("-p, --port <number>", "Port to listen on", "3070").option("-H, --host <host>", "Host to bind", "localhost").option("-b, --base <path>", "Base path prefix for all routes", "").action(async (file, flags) => {
309
- const options = serverOptionsSchema.parse({
782
+ program2.command("serve").description("Start the mock server using a YAML file as database").argument("[file]", "Path to the YAML database file", "db.yml").option("-p, --port <number>", "Port to listen on", "3070").option("-H, --host <host>", "Host to bind", "localhost").option("-b, --base <path>", "Base path prefix for all routes", "").option("-w, --watch", "Reload db.yml automatically when it changes on disk").option("-r, --readonly", "Reject all write operations (POST, PUT, PATCH, DELETE) with 405").option(
783
+ "-d, --delay <ms>",
784
+ "Add a fixed delay (ms) to all responses to simulate network latency",
785
+ "0"
786
+ ).option(
787
+ "--pageable [limit]",
788
+ "Wrap GET collection responses in { data, pagination } envelope. Optionally set default page size (default: 10)"
789
+ ).action(async (file, flags, cmd) => {
790
+ const fileConfig = loadConfigFile((0, import_node_path3.join)(process.cwd(), "yrest.config.yml"));
791
+ const cliOverrides = Object.fromEntries(
792
+ Object.entries(flags).filter(([key]) => cmd.getOptionValueSource(key) === "cli")
793
+ );
794
+ const merged = {
310
795
  file,
311
- port: flags["port"],
312
- host: flags["host"],
313
- base: flags["base"]
314
- });
796
+ ...fileConfig,
797
+ ...cmd.args.length > 0 ? { file } : {},
798
+ ...cliOverrides
799
+ };
800
+ const options = serverOptionsSchema.parse(merged);
315
801
  const storage = createYamlStorage(options.file);
316
802
  const server = await createServer(storage, options);
317
803
  await server.listen({ port: options.port, host: options.host });
@@ -319,12 +805,36 @@ function registerServe(program2) {
319
805
  const baseLabel = options.base || "/";
320
806
  console.log(`
321
807
  yrest running at http://${options.host}:${options.port}`);
808
+ if (options.readonly) console.log("[readonly] write operations are disabled");
809
+ if (options.delay > 0) console.log(`[delay] ${options.delay}ms added to all responses`);
810
+ if (options.pageable.enabled)
811
+ console.log(
812
+ `[pageable] responses wrapped in { data, pagination } (limit: ${options.pageable.limit})`
813
+ );
322
814
  console.log(`
323
815
  Resources (base: ${baseLabel}):`);
324
816
  for (const name of collections) {
325
817
  console.log(` /${name}`);
326
818
  }
327
819
  console.log("");
820
+ if (options.watch) {
821
+ const absFile = (0, import_node_path3.resolve)(options.file);
822
+ let debounce;
823
+ (0, import_node_fs4.watchFile)(absFile, { interval: 300 }, (curr, prev) => {
824
+ if (curr.mtimeMs === prev.mtimeMs) return;
825
+ clearTimeout(debounce);
826
+ debounce = setTimeout(() => {
827
+ try {
828
+ storage.reload();
829
+ console.log(`[watch] reloaded ${options.file}`);
830
+ } catch {
831
+ console.error(`[watch] failed to reload ${options.file} \u2014 check YAML syntax`);
832
+ }
833
+ }, 100);
834
+ });
835
+ console.log(`[watch] watching ${options.file} for changes
836
+ `);
837
+ }
328
838
  });
329
839
  }
330
840