@aggiovato/yrest 0.1.1 → 0.2.1
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 +360 -33
- 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/index.js
CHANGED
|
@@ -66,6 +66,17 @@ function createYamlStorage(filePath) {
|
|
|
66
66
|
const tmp = (0, import_node_path.resolve)((0, import_node_path.dirname)(absPath), `.yrest-${(0, import_node_crypto.randomUUID)()}.tmp`);
|
|
67
67
|
(0, import_node_fs.writeFileSync)(tmp, (0, import_yaml.stringify)(payload), "utf8");
|
|
68
68
|
(0, import_node_fs.renameSync)(tmp, absPath);
|
|
69
|
+
},
|
|
70
|
+
reload() {
|
|
71
|
+
const fresh = (0, import_yaml.parse)((0, import_node_fs.readFileSync)(absPath, "utf8")) ?? {};
|
|
72
|
+
const freshRelations = fresh["_rel"] ?? {};
|
|
73
|
+
const freshData = Object.fromEntries(
|
|
74
|
+
Object.entries(fresh).filter(([key]) => key !== "_rel")
|
|
75
|
+
);
|
|
76
|
+
for (const key of Object.keys(data)) delete data[key];
|
|
77
|
+
Object.assign(data, freshData);
|
|
78
|
+
for (const key of Object.keys(relations)) delete relations[key];
|
|
79
|
+
Object.assign(relations, freshRelations);
|
|
69
80
|
}
|
|
70
81
|
};
|
|
71
82
|
}
|
|
@@ -115,17 +126,68 @@ function patchItem(storage, resource, id, body) {
|
|
|
115
126
|
storage.persist();
|
|
116
127
|
return updated;
|
|
117
128
|
}
|
|
129
|
+
function firstParam(value) {
|
|
130
|
+
if (value === void 0) return void 0;
|
|
131
|
+
return Array.isArray(value) ? value[0] : value;
|
|
132
|
+
}
|
|
118
133
|
function filterByQuery(items, query) {
|
|
119
134
|
const filters = Object.entries(query).filter(([key]) => !key.startsWith("_"));
|
|
120
135
|
if (filters.length === 0) return items;
|
|
121
136
|
return items.filter(
|
|
122
|
-
(item) => filters.every(([key, value]) =>
|
|
137
|
+
(item) => filters.every(([key, value]) => {
|
|
138
|
+
if (item[key] === void 0) return false;
|
|
139
|
+
const itemStr = String(item[key]);
|
|
140
|
+
return Array.isArray(value) ? value.includes(itemStr) : itemStr === value;
|
|
141
|
+
})
|
|
123
142
|
);
|
|
124
143
|
}
|
|
144
|
+
function sortBy(items, field, order) {
|
|
145
|
+
const direction = order === "desc" ? -1 : 1;
|
|
146
|
+
return [...items].sort((a, b) => {
|
|
147
|
+
const av = a[field];
|
|
148
|
+
const bv = b[field];
|
|
149
|
+
if (av === void 0) return 1;
|
|
150
|
+
if (bv === void 0) return -1;
|
|
151
|
+
if (typeof av === "number" && typeof bv === "number") return (av - bv) * direction;
|
|
152
|
+
return String(av).localeCompare(String(bv), void 0, { sensitivity: "base" }) * direction;
|
|
153
|
+
});
|
|
154
|
+
}
|
|
125
155
|
function paginate(items, page, limit) {
|
|
126
156
|
const start = (page - 1) * limit;
|
|
127
157
|
return items.slice(start, start + limit);
|
|
128
158
|
}
|
|
159
|
+
function expandItems(input, query, resource, storage) {
|
|
160
|
+
const isArray = Array.isArray(input);
|
|
161
|
+
const items = isArray ? input : [input];
|
|
162
|
+
const expandParam = query["_expand"];
|
|
163
|
+
if (!expandParam) return isArray ? items : input;
|
|
164
|
+
const keys = (Array.isArray(expandParam) ? expandParam : [expandParam]).flatMap((v) => v.split(",")).map((k) => k.trim()).filter(Boolean);
|
|
165
|
+
const resourceRelations = storage.getRelations()[resource] ?? {};
|
|
166
|
+
const expansions = /* @__PURE__ */ new Map();
|
|
167
|
+
for (const expandKey of keys) {
|
|
168
|
+
for (const [field, parentCollection] of Object.entries(resourceRelations)) {
|
|
169
|
+
const derivedKey = field.replace(/Id$/i, "");
|
|
170
|
+
if (derivedKey === expandKey || parentCollection === expandKey || parentCollection === `${expandKey}s`) {
|
|
171
|
+
expansions.set(expandKey, { field, parentCollection });
|
|
172
|
+
break;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
if (expansions.size === 0) return isArray ? items : input;
|
|
177
|
+
const expanded = items.map((item) => {
|
|
178
|
+
const result = { ...item };
|
|
179
|
+
for (const [expandKey, { field, parentCollection }] of expansions) {
|
|
180
|
+
const foreignKeyValue = item[field];
|
|
181
|
+
if (foreignKeyValue === void 0) continue;
|
|
182
|
+
const parent = (storage.getCollection(parentCollection) ?? []).find(
|
|
183
|
+
(p) => String(p["id"]) === String(foreignKeyValue)
|
|
184
|
+
);
|
|
185
|
+
if (parent !== void 0) result[expandKey] = parent;
|
|
186
|
+
}
|
|
187
|
+
return result;
|
|
188
|
+
});
|
|
189
|
+
return isArray ? expanded : expanded[0];
|
|
190
|
+
}
|
|
129
191
|
function deleteItem(storage, resource, id) {
|
|
130
192
|
const collection = storage.getCollection(resource) ?? [];
|
|
131
193
|
const idx = findIndexById(collection, id);
|
|
@@ -137,93 +199,481 @@ function deleteItem(storage, resource, id) {
|
|
|
137
199
|
}
|
|
138
200
|
|
|
139
201
|
// src/router/routes/collection.routes.ts
|
|
140
|
-
|
|
141
|
-
server.get(
|
|
202
|
+
var registerCollectionRoutes = (server, storage, resource, base, options) => {
|
|
203
|
+
server.get(base, (req, reply) => {
|
|
142
204
|
const collection = storage.getCollection(resource) ?? [];
|
|
143
205
|
const filtered = filterByQuery(collection, req.query);
|
|
144
|
-
const
|
|
145
|
-
const
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
206
|
+
const sortField = firstParam(req.query["_sort"]);
|
|
207
|
+
const sortOrder = firstParam(req.query["_order"]) === "desc" ? "desc" : "asc";
|
|
208
|
+
const sorted = sortField ? sortBy(filtered, sortField, sortOrder) : filtered;
|
|
209
|
+
if (options.pageable.enabled) {
|
|
210
|
+
const defaultLimit = options.pageable.limit;
|
|
211
|
+
const page = Math.max(1, parseInt(firstParam(req.query["_page"]) ?? "1", 10) || 1);
|
|
212
|
+
const limit = Math.max(
|
|
213
|
+
1,
|
|
214
|
+
parseInt(firstParam(req.query["_limit"]) ?? String(defaultLimit), 10) || defaultLimit
|
|
215
|
+
);
|
|
216
|
+
const totalItems = sorted.length;
|
|
217
|
+
const totalPages = Math.ceil(totalItems / limit) || 1;
|
|
218
|
+
const data = expandItems(paginate(sorted, page, limit), req.query, resource, storage);
|
|
219
|
+
const pagination = {
|
|
220
|
+
page,
|
|
221
|
+
limit,
|
|
222
|
+
totalItems,
|
|
223
|
+
totalPages,
|
|
224
|
+
isFirst: page === 1,
|
|
225
|
+
isLast: page >= totalPages,
|
|
226
|
+
hasNext: page < totalPages,
|
|
227
|
+
hasPrev: page > 1
|
|
228
|
+
};
|
|
229
|
+
return reply.send({ data, pagination });
|
|
230
|
+
}
|
|
231
|
+
const rawPage = firstParam(req.query["_page"]);
|
|
232
|
+
const rawLimit = firstParam(req.query["_limit"]);
|
|
233
|
+
let result;
|
|
234
|
+
if (!rawPage && !rawLimit) {
|
|
235
|
+
result = sorted;
|
|
236
|
+
} else {
|
|
237
|
+
const page = Math.max(1, parseInt(rawPage ?? "1", 10) || 1);
|
|
238
|
+
const limit = Math.max(1, parseInt(rawLimit ?? "10", 10) || 10);
|
|
239
|
+
reply.header("X-Total-Count", String(sorted.length));
|
|
240
|
+
result = paginate(sorted, page, limit);
|
|
241
|
+
}
|
|
242
|
+
return expandItems(result, req.query, resource, storage);
|
|
151
243
|
});
|
|
152
|
-
server.post(
|
|
244
|
+
server.post(base, (req, reply) => {
|
|
153
245
|
const item = createItem(storage, resource, req.body);
|
|
154
|
-
return reply.status(201).send(item);
|
|
246
|
+
return reply.status(201).send(expandItems(item, req.query, resource, storage));
|
|
155
247
|
});
|
|
156
|
-
}
|
|
248
|
+
};
|
|
157
249
|
|
|
158
250
|
// src/router/routes/item.routes.ts
|
|
159
|
-
|
|
160
|
-
server.get(`${
|
|
251
|
+
var registerItemRoutes = (server, storage, resource, base) => {
|
|
252
|
+
server.get(`${base}/:id`, (req, reply) => {
|
|
161
253
|
const item = findById(storage.getCollection(resource) ?? [], req.params.id);
|
|
162
254
|
if (!item) return reply.status(404).send({ error: "Not found" });
|
|
163
|
-
return item;
|
|
255
|
+
return expandItems(item, req.query, resource, storage);
|
|
164
256
|
});
|
|
165
|
-
server.put(`${
|
|
257
|
+
server.put(`${base}/:id`, (req, reply) => {
|
|
166
258
|
const item = replaceItem(storage, resource, req.params.id, req.body);
|
|
167
259
|
if (!item) return reply.status(404).send({ error: "Not found" });
|
|
168
|
-
return item;
|
|
260
|
+
return expandItems(item, req.query, resource, storage);
|
|
169
261
|
});
|
|
170
|
-
server.patch(`${
|
|
262
|
+
server.patch(`${base}/:id`, (req, reply) => {
|
|
171
263
|
const item = patchItem(storage, resource, req.params.id, req.body);
|
|
172
264
|
if (!item) return reply.status(404).send({ error: "Not found" });
|
|
173
|
-
return item;
|
|
265
|
+
return expandItems(item, req.query, resource, storage);
|
|
174
266
|
});
|
|
175
|
-
server.delete(`${
|
|
267
|
+
server.delete(`${base}/:id`, (req, reply) => {
|
|
176
268
|
const item = deleteItem(storage, resource, req.params.id);
|
|
177
269
|
if (!item) return reply.status(404).send({ error: "Not found" });
|
|
178
|
-
return item;
|
|
270
|
+
return expandItems(item, req.query, resource, storage);
|
|
179
271
|
});
|
|
180
|
-
}
|
|
272
|
+
};
|
|
181
273
|
|
|
182
274
|
// src/router/routes/nested.routes.ts
|
|
183
|
-
|
|
275
|
+
var registerNestedRoutes = (server, storage, relations, base) => {
|
|
184
276
|
for (const [child, fields] of Object.entries(relations)) {
|
|
185
277
|
for (const [field, parent] of Object.entries(fields)) {
|
|
186
|
-
server.get(
|
|
187
|
-
|
|
188
|
-
(
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
return children;
|
|
196
|
-
}
|
|
197
|
-
);
|
|
278
|
+
server.get(`${base}/${parent}/:id/${child}`, (req, reply) => {
|
|
279
|
+
const parentCollection = storage.getCollection(parent) ?? [];
|
|
280
|
+
const parentItem = findById(parentCollection, req.params.id);
|
|
281
|
+
if (!parentItem) return reply.status(404).send({ error: "Not found" });
|
|
282
|
+
const children = (storage.getCollection(child) ?? []).filter(
|
|
283
|
+
(item) => String(item[field]) === req.params.id
|
|
284
|
+
);
|
|
285
|
+
return children;
|
|
286
|
+
});
|
|
198
287
|
}
|
|
199
288
|
}
|
|
200
|
-
}
|
|
289
|
+
};
|
|
201
290
|
|
|
202
291
|
// src/router/resource.router.ts
|
|
203
|
-
function registerResourceRoutes(server, storage,
|
|
292
|
+
function registerResourceRoutes(server, storage, options) {
|
|
204
293
|
for (const resource of Object.keys(storage.getData())) {
|
|
205
|
-
const
|
|
206
|
-
registerCollectionRoutes(server, storage, resource,
|
|
207
|
-
registerItemRoutes(server, storage, resource,
|
|
294
|
+
const resourceBase = `${options.base}/${resource}`;
|
|
295
|
+
registerCollectionRoutes(server, storage, resource, resourceBase, options);
|
|
296
|
+
registerItemRoutes(server, storage, resource, resourceBase, options);
|
|
208
297
|
}
|
|
209
|
-
registerNestedRoutes(server, storage, storage.getRelations(), base);
|
|
298
|
+
registerNestedRoutes(server, storage, storage.getRelations(), options.base);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// src/router/templates/about.template.ts
|
|
302
|
+
var METHOD_COLOR = {
|
|
303
|
+
GET: "#3fb950",
|
|
304
|
+
POST: "#58a6ff",
|
|
305
|
+
PUT: "#d29922",
|
|
306
|
+
PATCH: "#a371f7",
|
|
307
|
+
DELETE: "#f85149"
|
|
308
|
+
};
|
|
309
|
+
function badge(label, color, bg) {
|
|
310
|
+
return `<span class="badge" style="background:${bg};color:${color};border:1px solid ${color}40">${label}</span>`;
|
|
311
|
+
}
|
|
312
|
+
function methodBadge(method) {
|
|
313
|
+
const color = METHOD_COLOR[method] ?? "#7d8590";
|
|
314
|
+
return badge(method, color, `${color}18`);
|
|
315
|
+
}
|
|
316
|
+
function endpointRow(method, path, desc) {
|
|
317
|
+
return `
|
|
318
|
+
<tr>
|
|
319
|
+
<td class="method-cell">${methodBadge(method)}</td>
|
|
320
|
+
<td class="path-cell"><code>${path}</code></td>
|
|
321
|
+
<td class="desc-cell">${desc}</td>
|
|
322
|
+
</tr>`;
|
|
323
|
+
}
|
|
324
|
+
function resourceAccordion(name, base, isOpen) {
|
|
325
|
+
const p = `${base}/${name}`;
|
|
326
|
+
const singular = name.endsWith("s") ? name.slice(0, -1) : name;
|
|
327
|
+
const rows = [
|
|
328
|
+
endpointRow(
|
|
329
|
+
"GET",
|
|
330
|
+
p,
|
|
331
|
+
`List all ${name}. Supports filters, sort, pagination and <code>?_expand</code>.`
|
|
332
|
+
),
|
|
333
|
+
endpointRow(
|
|
334
|
+
"POST",
|
|
335
|
+
p,
|
|
336
|
+
`Create a new ${singular}. Auto-assigns <code>id</code> if not provided.`
|
|
337
|
+
),
|
|
338
|
+
endpointRow("GET", `${p}/:id`, `Get a single ${singular} by id.`),
|
|
339
|
+
endpointRow(
|
|
340
|
+
"PUT",
|
|
341
|
+
`${p}/:id`,
|
|
342
|
+
`Fully replace a ${singular}. Original <code>id</code> is always preserved.`
|
|
343
|
+
),
|
|
344
|
+
endpointRow(
|
|
345
|
+
"PATCH",
|
|
346
|
+
`${p}/:id`,
|
|
347
|
+
`Partially update a ${singular} \u2014 only provided fields change.`
|
|
348
|
+
),
|
|
349
|
+
endpointRow("DELETE", `${p}/:id`, `Delete a ${singular} and return it as confirmation.`)
|
|
350
|
+
].join("");
|
|
351
|
+
return `
|
|
352
|
+
<details class="resource-card" ${isOpen ? "open" : ""}>
|
|
353
|
+
<summary>
|
|
354
|
+
<span class="resource-name">/${name}</span>
|
|
355
|
+
<span class="route-count">6 routes</span>
|
|
356
|
+
</summary>
|
|
357
|
+
<table>
|
|
358
|
+
<tbody>${rows}</tbody>
|
|
359
|
+
</table>
|
|
360
|
+
</details>`;
|
|
361
|
+
}
|
|
362
|
+
function examplesBlock(collections, relations, base, host, options) {
|
|
363
|
+
const examples = [];
|
|
364
|
+
const firstCol = collections[0];
|
|
365
|
+
if (firstCol) {
|
|
366
|
+
const p = `${host}${base}/${firstCol}`;
|
|
367
|
+
const singular = firstCol.endsWith("s") ? firstCol.slice(0, -1) : firstCol;
|
|
368
|
+
examples.push(
|
|
369
|
+
`# List all ${firstCol}
|
|
370
|
+
curl ${p}`,
|
|
371
|
+
`# Filter by field
|
|
372
|
+
curl "${p}?name=value"`,
|
|
373
|
+
`# Sort and paginate
|
|
374
|
+
curl "${p}?_sort=id&_order=desc&_page=1&_limit=5"`,
|
|
375
|
+
`# Get single ${singular}
|
|
376
|
+
curl ${p}/1`,
|
|
377
|
+
`# Create ${singular}
|
|
378
|
+
curl -X POST ${p} \\
|
|
379
|
+
-H "Content-Type: application/json" \\
|
|
380
|
+
-d '{"name":"example"}'`,
|
|
381
|
+
`# Partially update ${singular}
|
|
382
|
+
curl -X PATCH ${p}/1 \\
|
|
383
|
+
-H "Content-Type: application/json" \\
|
|
384
|
+
-d '{"name":"updated"}'`,
|
|
385
|
+
`# Delete ${singular}
|
|
386
|
+
curl -X DELETE ${p}/1`
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
const firstRel = Object.entries(relations)[0];
|
|
390
|
+
if (firstRel) {
|
|
391
|
+
const [child, fields] = firstRel;
|
|
392
|
+
const fk = Object.keys(fields)[0];
|
|
393
|
+
const expandKey = fk.replace(/Id$/i, "");
|
|
394
|
+
examples.push(
|
|
395
|
+
`# Embed parent with ?_expand
|
|
396
|
+
curl "${host}${base}/${child}/1?_expand=${expandKey}"`
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
for (const [child, fields] of Object.entries(relations)) {
|
|
400
|
+
for (const [, parent] of Object.entries(fields)) {
|
|
401
|
+
examples.push(`# Nested resource
|
|
402
|
+
curl ${host}${base}/${parent}/1/${child}`);
|
|
403
|
+
break;
|
|
404
|
+
}
|
|
405
|
+
break;
|
|
406
|
+
}
|
|
407
|
+
if (options.pageable.enabled && firstCol) {
|
|
408
|
+
examples.push(`# Pageable envelope
|
|
409
|
+
curl "${host}${base}/${firstCol}?_page=2"`);
|
|
410
|
+
}
|
|
411
|
+
const highlighted = examples.map((e) => e.replace(/^(#.+)$/gm, '<span class="cm">$1</span>')).join("\n\n");
|
|
412
|
+
return `<pre>${highlighted}</pre>`;
|
|
413
|
+
}
|
|
414
|
+
function generateAboutHtml(storage, options) {
|
|
415
|
+
const collections = Object.keys(storage.getData());
|
|
416
|
+
const relations = storage.getRelations();
|
|
417
|
+
const base = options.base;
|
|
418
|
+
const host = `http://${options.host}:${options.port}`;
|
|
419
|
+
const modes = [];
|
|
420
|
+
if (options.watch) modes.push(badge("watch", "#38bdf8", "#38bdf818"));
|
|
421
|
+
if (options.readonly) modes.push(badge("readonly", "#94a3b8", "#94a3b818"));
|
|
422
|
+
if (options.delay > 0) modes.push(badge(`delay \xB7 ${options.delay}ms`, "#fb923c", "#fb923c18"));
|
|
423
|
+
if (options.pageable.enabled)
|
|
424
|
+
modes.push(badge(`pageable \xB7 limit ${options.pageable.limit}`, "#34d399", "#34d39918"));
|
|
425
|
+
const accordions = collections.map((col, i) => resourceAccordion(col, base, i === 0)).join("");
|
|
426
|
+
const nestedRows = [];
|
|
427
|
+
for (const [child, fields] of Object.entries(relations)) {
|
|
428
|
+
for (const [, parent] of Object.entries(fields)) {
|
|
429
|
+
const nestedPath = `${base}/${parent}/:id/${child}`;
|
|
430
|
+
const parentSingular = parent.endsWith("s") ? parent.slice(0, -1) : parent;
|
|
431
|
+
nestedRows.push(
|
|
432
|
+
endpointRow("GET", nestedPath, `List ${child} belonging to a ${parentSingular}.`)
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
const nestedAccordion = nestedRows.length ? `
|
|
437
|
+
<details class="resource-card nested-card">
|
|
438
|
+
<summary>
|
|
439
|
+
<span class="resource-name">Nested routes</span>
|
|
440
|
+
<span class="route-count">${nestedRows.length} route${nestedRows.length !== 1 ? "s" : ""}</span>
|
|
441
|
+
</summary>
|
|
442
|
+
<table><tbody>${nestedRows.join("")}</tbody></table>
|
|
443
|
+
</details>` : "";
|
|
444
|
+
const paginationDesc = options.pageable.enabled ? `Pageable mode active \u2014 default limit <code>${options.pageable.limit}</code>. Response wrapped in <code>{ data, pagination }</code>.` : `Returns the requested slice. <code>X-Total-Count</code> header reflects the total before pagination.`;
|
|
445
|
+
return `<!DOCTYPE html>
|
|
446
|
+
<html lang="en">
|
|
447
|
+
<head>
|
|
448
|
+
<meta charset="UTF-8">
|
|
449
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
450
|
+
<title>yrest \u2014 API Overview</title>
|
|
451
|
+
<style>
|
|
452
|
+
:root {
|
|
453
|
+
--bg: #0d1117;
|
|
454
|
+
--bg-card: #161b22;
|
|
455
|
+
--bg-hover: #1c2128;
|
|
456
|
+
--bg-inset: #0d1117;
|
|
457
|
+
--border: #30363d;
|
|
458
|
+
--border-hi: #3d444d;
|
|
459
|
+
--text: #e6edf3;
|
|
460
|
+
--text-muted:#7d8590;
|
|
461
|
+
--accent: #58a6ff;
|
|
462
|
+
--radius: 8px;
|
|
463
|
+
}
|
|
464
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
465
|
+
body { background: var(--bg); color: var(--text); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-size: 14px; line-height: 1.6; }
|
|
466
|
+
|
|
467
|
+
/* \u2500\u2500 Banner \u2500\u2500 */
|
|
468
|
+
.banner {
|
|
469
|
+
width: 100%;
|
|
470
|
+
background: linear-gradient(135deg, #0d1117 0%, #161b22 40%, #1a2332 100%);
|
|
471
|
+
border-bottom: 1px solid var(--border);
|
|
472
|
+
padding: 48px 32px 40px;
|
|
473
|
+
}
|
|
474
|
+
.banner-inner { max-width: 1100px; margin: 0 auto; }
|
|
475
|
+
.banner h1 { font-size: clamp(36px, 6vw, 60px); font-weight: 800; letter-spacing: -2px; line-height: 1; }
|
|
476
|
+
.banner h1 .y { color: var(--text); }
|
|
477
|
+
.banner h1 .rest { color: var(--accent); }
|
|
478
|
+
.banner p { color: var(--text-muted); margin-top: 10px; font-size: 15px; }
|
|
479
|
+
.banner-meta { display: flex; gap: 24px; margin-top: 20px; flex-wrap: wrap; }
|
|
480
|
+
.banner-meta span { color: var(--text-muted); font-size: 13px; }
|
|
481
|
+
.banner-meta span strong { color: var(--text); font-family: monospace; }
|
|
482
|
+
|
|
483
|
+
/* \u2500\u2500 Layout \u2500\u2500 */
|
|
484
|
+
.wrap { max-width: 1100px; margin: 0 auto; padding: 32px 24px 48px; }
|
|
485
|
+
h2 { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .08em; color: var(--text-muted); margin: 32px 0 12px; }
|
|
486
|
+
|
|
487
|
+
/* \u2500\u2500 Cards \u2500\u2500 */
|
|
488
|
+
.card { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 20px 24px; }
|
|
489
|
+
.two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
|
490
|
+
@media (max-width: 600px) { .two-col { grid-template-columns: 1fr; } }
|
|
491
|
+
|
|
492
|
+
/* \u2500\u2500 Server info grid \u2500\u2500 */
|
|
493
|
+
.info-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 16px; }
|
|
494
|
+
.stat label { font-size: 10px; text-transform: uppercase; letter-spacing: .07em; color: var(--text-muted); display: block; margin-bottom: 3px; }
|
|
495
|
+
.stat value { font-size: 15px; font-weight: 600; font-family: monospace; color: var(--text); }
|
|
496
|
+
|
|
497
|
+
/* \u2500\u2500 Mode badges \u2500\u2500 */
|
|
498
|
+
.modes { display: flex; gap: 8px; flex-wrap: wrap; min-height: 28px; align-items: center; }
|
|
499
|
+
.badge { display: inline-block; padding: 3px 10px; border-radius: 20px; font-size: 12px; font-weight: 600; white-space: nowrap; }
|
|
500
|
+
|
|
501
|
+
/* \u2500\u2500 Endpoints grid (2 cols on wide, 1 col on narrow) \u2500\u2500 */
|
|
502
|
+
.endpoints-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; }
|
|
503
|
+
@media (max-width: 860px) { .endpoints-grid { grid-template-columns: 1fr; } }
|
|
504
|
+
.nested-card { grid-column: 1 / -1; }
|
|
505
|
+
|
|
506
|
+
/* \u2500\u2500 Accordion (details/summary) \u2500\u2500 */
|
|
507
|
+
.resource-card {
|
|
508
|
+
background: var(--bg-card);
|
|
509
|
+
border: 1px solid var(--border);
|
|
510
|
+
border-radius: var(--radius);
|
|
511
|
+
overflow: hidden;
|
|
512
|
+
transition: border-color .15s;
|
|
513
|
+
}
|
|
514
|
+
.resource-card[open] { border-color: var(--border-hi); }
|
|
515
|
+
.resource-card summary {
|
|
516
|
+
display: flex;
|
|
517
|
+
align-items: center;
|
|
518
|
+
justify-content: space-between;
|
|
519
|
+
padding: 13px 18px;
|
|
520
|
+
cursor: pointer;
|
|
521
|
+
user-select: none;
|
|
522
|
+
list-style: none;
|
|
523
|
+
gap: 8px;
|
|
524
|
+
}
|
|
525
|
+
.resource-card summary::-webkit-details-marker { display: none; }
|
|
526
|
+
.resource-card summary::before {
|
|
527
|
+
content: "\u203A";
|
|
528
|
+
color: var(--text-muted);
|
|
529
|
+
font-size: 18px;
|
|
530
|
+
line-height: 1;
|
|
531
|
+
transition: transform .2s;
|
|
532
|
+
margin-right: 4px;
|
|
533
|
+
flex-shrink: 0;
|
|
534
|
+
}
|
|
535
|
+
.resource-card[open] summary::before { transform: rotate(90deg); }
|
|
536
|
+
.resource-card summary:hover { background: var(--bg-hover); }
|
|
537
|
+
.resource-name { font-family: monospace; font-size: 14px; font-weight: 600; color: var(--accent); flex: 1; }
|
|
538
|
+
.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; }
|
|
539
|
+
|
|
540
|
+
/* \u2500\u2500 Tables \u2500\u2500 */
|
|
541
|
+
table { width: 100%; border-collapse: collapse; }
|
|
542
|
+
td { padding: 8px 12px; border-top: 1px solid var(--border); vertical-align: top; font-size: 13px; }
|
|
543
|
+
.method-cell { width: 78px; white-space: nowrap; }
|
|
544
|
+
.path-cell { width: 44%; white-space: nowrap; }
|
|
545
|
+
.desc-cell { color: var(--text-muted); }
|
|
546
|
+
code { font-family: "SF Mono", "Fira Code", monospace; font-size: 12px; background: #58a6ff15; color: var(--accent); padding: 1px 5px; border-radius: 3px; }
|
|
547
|
+
|
|
548
|
+
/* \u2500\u2500 Query params table \u2500\u2500 */
|
|
549
|
+
.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); }
|
|
550
|
+
.param-table td:first-child { white-space: nowrap; width: 160px; }
|
|
551
|
+
.param-table td:nth-child(2) { white-space: nowrap; width: 200px; }
|
|
552
|
+
|
|
553
|
+
/* \u2500\u2500 Code block \u2500\u2500 */
|
|
554
|
+
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; }
|
|
555
|
+
.cm { color: #3d444d; }
|
|
556
|
+
|
|
557
|
+
/* \u2500\u2500 Warning \u2500\u2500 */
|
|
558
|
+
.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; }
|
|
559
|
+
|
|
560
|
+
/* \u2500\u2500 Footer \u2500\u2500 */
|
|
561
|
+
footer { margin-top: 48px; text-align: center; font-size: 11px; color: var(--text-muted); padding-bottom: 16px; }
|
|
562
|
+
footer a { color: var(--accent); text-decoration: none; }
|
|
563
|
+
</style>
|
|
564
|
+
</head>
|
|
565
|
+
<body>
|
|
566
|
+
|
|
567
|
+
<div class="banner">
|
|
568
|
+
<div class="banner-inner">
|
|
569
|
+
<h1><span class="y">y</span><span class="rest">rest</span></h1>
|
|
570
|
+
<p>Zero-config REST API mock server</p>
|
|
571
|
+
<div class="banner-meta">
|
|
572
|
+
<span>URL <strong>${host}</strong></span>
|
|
573
|
+
<span>Base <strong>${base || "/"}</strong></span>
|
|
574
|
+
<span>File <strong>${options.file}</strong></span>
|
|
575
|
+
<span>Collections <strong>${collections.length}</strong></span>
|
|
576
|
+
</div>
|
|
577
|
+
</div>
|
|
578
|
+
</div>
|
|
579
|
+
|
|
580
|
+
<div class="wrap">
|
|
581
|
+
|
|
582
|
+
<h2>Active Modes</h2>
|
|
583
|
+
<div class="card">
|
|
584
|
+
<div class="modes">${modes.length ? modes.join(" ") : `<span style="color:var(--text-muted);font-size:13px">none</span>`}</div>
|
|
585
|
+
${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>` : ""}
|
|
586
|
+
</div>
|
|
587
|
+
|
|
588
|
+
<h2>Endpoints</h2>
|
|
589
|
+
<div class="endpoints-grid">
|
|
590
|
+
${accordions}
|
|
591
|
+
${nestedAccordion}
|
|
592
|
+
</div>
|
|
593
|
+
|
|
594
|
+
<h2>Query Parameters</h2>
|
|
595
|
+
<div class="card">
|
|
596
|
+
<table class="param-table">
|
|
597
|
+
<thead><tr><th>Param</th><th>Example</th><th>Description</th></tr></thead>
|
|
598
|
+
<tbody>
|
|
599
|
+
<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>
|
|
600
|
+
<tr><td><code>?_sort & ?_order</code></td><td><code>?_sort=name&_order=desc</code></td><td>Sort by field. <code>_order</code>: <code>asc</code> (default) or <code>desc</code>.</td></tr>
|
|
601
|
+
<tr><td><code>?_page & ?_limit</code></td><td><code>?_page=2&_limit=10</code></td><td>${paginationDesc}</td></tr>
|
|
602
|
+
<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>
|
|
603
|
+
</tbody>
|
|
604
|
+
</table>
|
|
605
|
+
</div>
|
|
606
|
+
|
|
607
|
+
${collections.length ? `<h2>Examples</h2><div class="card">${examplesBlock(collections, relations, base, host, options)}</div>` : ""}
|
|
608
|
+
|
|
609
|
+
<footer>
|
|
610
|
+
Powered by <a href="https://github.com/aggiovato/yaml-rest" target="_blank">@aggiovato/yrest</a> \xB7 <a href="/_about">/_about</a>
|
|
611
|
+
</footer>
|
|
612
|
+
|
|
613
|
+
</div>
|
|
614
|
+
</body>
|
|
615
|
+
</html>`;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// src/router/routes/about.routes.ts
|
|
619
|
+
function registerAboutRoute(server, storage, options) {
|
|
620
|
+
server.get("/_about", (_req, reply) => {
|
|
621
|
+
reply.header("Content-Type", "text/html; charset=utf-8");
|
|
622
|
+
return reply.send(generateAboutHtml(storage, options));
|
|
623
|
+
});
|
|
210
624
|
}
|
|
211
625
|
|
|
212
626
|
// src/server/createServer.ts
|
|
627
|
+
var MUTATING_METHODS = /* @__PURE__ */ new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
|
213
628
|
async function createServer(storage, options) {
|
|
214
629
|
const server = (0, import_fastify.default)();
|
|
215
630
|
await server.register(import_cors.default);
|
|
216
|
-
|
|
631
|
+
if (options.readonly) {
|
|
632
|
+
server.addHook("onRequest", (_req, reply, done) => {
|
|
633
|
+
if (MUTATING_METHODS.has(_req.method)) {
|
|
634
|
+
reply.status(405).header("Allow", "GET, HEAD, OPTIONS").send({ error: "Server is running in readonly mode" });
|
|
635
|
+
}
|
|
636
|
+
done();
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
if (options.delay > 0) {
|
|
640
|
+
server.addHook("onSend", (_req, _reply, payload, done) => {
|
|
641
|
+
setTimeout(() => done(null, payload), options.delay);
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
registerAboutRoute(server, storage, options);
|
|
645
|
+
registerResourceRoutes(server, storage, options);
|
|
217
646
|
return server;
|
|
218
647
|
}
|
|
219
648
|
|
|
220
649
|
// src/config/loadOptions.ts
|
|
221
650
|
var import_zod = require("zod");
|
|
222
651
|
var serverOptionsSchema = import_zod.z.object({
|
|
652
|
+
/** Path to the YAML database file. Must be a non-empty string. */
|
|
223
653
|
file: import_zod.z.string().min(1),
|
|
654
|
+
/** TCP port the server listens on. Accepts string input and coerces to number. */
|
|
224
655
|
port: import_zod.z.coerce.number().int().positive().default(3070),
|
|
656
|
+
/** Hostname or IP address to bind. */
|
|
225
657
|
host: import_zod.z.string().default("localhost"),
|
|
226
|
-
|
|
658
|
+
/**
|
|
659
|
+
* URL prefix prepended to every route (e.g. `/api`).
|
|
660
|
+
* A leading slash is added automatically if omitted.
|
|
661
|
+
*/
|
|
662
|
+
base: import_zod.z.string().default("").transform((v) => v && !v.startsWith("/") ? `/${v}` : v),
|
|
663
|
+
/** When `true`, the server reloads the YAML file automatically on disk changes. */
|
|
664
|
+
watch: import_zod.z.boolean().default(false),
|
|
665
|
+
/** When `true`, all mutating requests (POST, PUT, PATCH, DELETE) are rejected with 405. */
|
|
666
|
+
readonly: import_zod.z.boolean().default(false),
|
|
667
|
+
/** Milliseconds to delay every response, simulating network latency. `0` = disabled. */
|
|
668
|
+
delay: import_zod.z.coerce.number().int().min(0).default(0),
|
|
669
|
+
/**
|
|
670
|
+
* Wraps GET collection responses in a `{ data, pagination }` envelope.
|
|
671
|
+
* Accepts `true` (default limit 10), `false` (disabled), or a positive integer (custom limit).
|
|
672
|
+
*/
|
|
673
|
+
pageable: import_zod.z.union([import_zod.z.boolean(), import_zod.z.coerce.number().int().positive()]).default(false).transform((v) => ({
|
|
674
|
+
enabled: v !== false,
|
|
675
|
+
limit: v === false || v === true ? 10 : v
|
|
676
|
+
}))
|
|
227
677
|
});
|
|
228
678
|
// Annotate the CommonJS export names for ESM import in node:
|
|
229
679
|
0 && (module.exports = {
|