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