@yrest/cli 0.5.3
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 +819 -0
- package/dist/cli/index.d.mts +1 -0
- package/dist/cli/index.d.ts +1 -0
- package/dist/cli/index.js +1514 -0
- package/dist/cli/index.mjs +1487 -0
- package/dist/index.d.mts +257 -0
- package/dist/index.d.ts +257 -0
- package/dist/index.js +1179 -0
- package/dist/index.mjs +1140 -0
- package/package.json +90 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1179 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (let key of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
16
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
28
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
|
+
|
|
30
|
+
// src/index.ts
|
|
31
|
+
var src_exports = {};
|
|
32
|
+
__export(src_exports, {
|
|
33
|
+
createServer: () => createServer,
|
|
34
|
+
createYamlStorage: () => createYamlStorage,
|
|
35
|
+
serverOptionsSchema: () => serverOptionsSchema
|
|
36
|
+
});
|
|
37
|
+
module.exports = __toCommonJS(src_exports);
|
|
38
|
+
|
|
39
|
+
// src/storage/yamlStorage.ts
|
|
40
|
+
var import_node_fs = require("fs");
|
|
41
|
+
var import_node_path = require("path");
|
|
42
|
+
var import_node_crypto = require("crypto");
|
|
43
|
+
var import_yaml = require("yaml");
|
|
44
|
+
|
|
45
|
+
// src/utils/deepCopy.ts
|
|
46
|
+
function deepCopyData(source) {
|
|
47
|
+
return Object.fromEntries(
|
|
48
|
+
Object.entries(source).map(([k, v]) => [k, v.map((item) => ({ ...item }))])
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// src/storage/yamlStorage.ts
|
|
53
|
+
function createYamlStorage(filePath) {
|
|
54
|
+
const absPath = (0, import_node_path.resolve)(filePath);
|
|
55
|
+
const raw = (0, import_yaml.parse)((0, import_node_fs.readFileSync)(absPath, "utf8")) ?? {};
|
|
56
|
+
const relations = raw["_rel"] ?? {};
|
|
57
|
+
const routes = Array.isArray(raw["_routes"]) ? raw["_routes"] : [];
|
|
58
|
+
const data = Object.fromEntries(
|
|
59
|
+
Object.entries(raw).filter(([key]) => key !== "_rel" && key !== "_routes")
|
|
60
|
+
);
|
|
61
|
+
let snapshot = {
|
|
62
|
+
data: deepCopyData(data),
|
|
63
|
+
relations: { ...relations },
|
|
64
|
+
savedAt: /* @__PURE__ */ new Date()
|
|
65
|
+
};
|
|
66
|
+
return {
|
|
67
|
+
getData() {
|
|
68
|
+
return data;
|
|
69
|
+
},
|
|
70
|
+
getRelations() {
|
|
71
|
+
return relations;
|
|
72
|
+
},
|
|
73
|
+
getRoutes() {
|
|
74
|
+
return routes;
|
|
75
|
+
},
|
|
76
|
+
getCollection(name) {
|
|
77
|
+
return data[name];
|
|
78
|
+
},
|
|
79
|
+
setCollection(name, items) {
|
|
80
|
+
data[name] = items;
|
|
81
|
+
},
|
|
82
|
+
persist() {
|
|
83
|
+
const payload = {};
|
|
84
|
+
if (Object.keys(relations).length > 0) payload._rel = relations;
|
|
85
|
+
if (routes.length > 0) payload._routes = routes;
|
|
86
|
+
Object.assign(payload, data);
|
|
87
|
+
const tmp = (0, import_node_path.resolve)((0, import_node_path.dirname)(absPath), `.yrest-${(0, import_node_crypto.randomUUID)()}.tmp`);
|
|
88
|
+
(0, import_node_fs.writeFileSync)(tmp, (0, import_yaml.stringify)(payload), "utf8");
|
|
89
|
+
(0, import_node_fs.renameSync)(tmp, absPath);
|
|
90
|
+
},
|
|
91
|
+
reload() {
|
|
92
|
+
const fresh = (0, import_yaml.parse)((0, import_node_fs.readFileSync)(absPath, "utf8")) ?? {};
|
|
93
|
+
const freshRelations = fresh["_rel"] ?? {};
|
|
94
|
+
const freshData = Object.fromEntries(
|
|
95
|
+
Object.entries(fresh).filter(([key]) => key !== "_rel" && key !== "_routes")
|
|
96
|
+
);
|
|
97
|
+
for (const key of Object.keys(data)) delete data[key];
|
|
98
|
+
Object.assign(data, freshData);
|
|
99
|
+
for (const key of Object.keys(relations)) delete relations[key];
|
|
100
|
+
Object.assign(relations, freshRelations);
|
|
101
|
+
},
|
|
102
|
+
getSnapshot() {
|
|
103
|
+
return snapshot;
|
|
104
|
+
},
|
|
105
|
+
saveSnapshot() {
|
|
106
|
+
snapshot = {
|
|
107
|
+
data: deepCopyData(data),
|
|
108
|
+
relations: { ...relations },
|
|
109
|
+
savedAt: /* @__PURE__ */ new Date()
|
|
110
|
+
};
|
|
111
|
+
},
|
|
112
|
+
resetToSnapshot() {
|
|
113
|
+
const snap = deepCopyData(snapshot.data);
|
|
114
|
+
for (const key of Object.keys(data)) delete data[key];
|
|
115
|
+
Object.assign(data, snap);
|
|
116
|
+
for (const key of Object.keys(relations)) delete relations[key];
|
|
117
|
+
Object.assign(relations, { ...snapshot.relations });
|
|
118
|
+
this.persist();
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// src/server/createServer.ts
|
|
124
|
+
var import_fastify = __toESM(require("fastify"));
|
|
125
|
+
var import_cors = __toESM(require("@fastify/cors"));
|
|
126
|
+
|
|
127
|
+
// src/utils/interpolate.ts
|
|
128
|
+
var import_node_crypto2 = require("crypto");
|
|
129
|
+
function getPath(obj, path) {
|
|
130
|
+
return path.split(".").reduce((acc, key) => {
|
|
131
|
+
if (acc != null && typeof acc === "object") return acc[key];
|
|
132
|
+
return void 0;
|
|
133
|
+
}, obj);
|
|
134
|
+
}
|
|
135
|
+
function resolveVar(path, ctx) {
|
|
136
|
+
if (path === "now") return (/* @__PURE__ */ new Date()).toISOString();
|
|
137
|
+
if (path === "uuid") return (0, import_node_crypto2.randomUUID)();
|
|
138
|
+
if (path === "body") return ctx.body;
|
|
139
|
+
if (path.startsWith("body.")) return getPath(ctx.body, path.slice(5));
|
|
140
|
+
if (path.startsWith("params.")) return ctx.params[path.slice(7)] ?? "";
|
|
141
|
+
if (path.startsWith("query.")) {
|
|
142
|
+
const val = ctx.query[path.slice(6)];
|
|
143
|
+
return Array.isArray(val) ? val[0] : val ?? "";
|
|
144
|
+
}
|
|
145
|
+
if (path.startsWith("headers.")) {
|
|
146
|
+
const val = ctx.headers[path.slice(8)];
|
|
147
|
+
return Array.isArray(val) ? val[0] : val ?? "";
|
|
148
|
+
}
|
|
149
|
+
return "";
|
|
150
|
+
}
|
|
151
|
+
function interpolateString(str, ctx) {
|
|
152
|
+
const exact = str.match(/^\{\{([^}]+)\}\}$/);
|
|
153
|
+
if (exact) return resolveVar(exact[1].trim(), ctx);
|
|
154
|
+
return str.replace(/\{\{([^}]+)\}\}/g, (_, path) => {
|
|
155
|
+
const val = resolveVar(path.trim(), ctx);
|
|
156
|
+
return val == null ? "" : String(val);
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
function interpolate(template, ctx) {
|
|
160
|
+
if (typeof template === "string") return interpolateString(template, ctx);
|
|
161
|
+
if (Array.isArray(template)) return template.map((item) => interpolate(item, ctx));
|
|
162
|
+
if (template !== null && typeof template === "object") {
|
|
163
|
+
return Object.fromEntries(
|
|
164
|
+
Object.entries(template).map(([k, v]) => [k, interpolate(v, ctx)])
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
return template;
|
|
168
|
+
}
|
|
169
|
+
function hasTemplates(value) {
|
|
170
|
+
return typeof value === "string" ? value.includes("{{") : JSON.stringify(value).includes("{{");
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// src/router/templates/about.template.ts
|
|
174
|
+
var METHOD_COLOR = {
|
|
175
|
+
GET: "#3fb950",
|
|
176
|
+
POST: "#58a6ff",
|
|
177
|
+
PUT: "#d29922",
|
|
178
|
+
PATCH: "#a371f7",
|
|
179
|
+
DELETE: "#f85149",
|
|
180
|
+
fn: "#f0883e"
|
|
181
|
+
};
|
|
182
|
+
function badge(label, color, bg) {
|
|
183
|
+
return `<span class="badge" style="background:${bg};color:${color};border:1px solid ${color}40">${label}</span>`;
|
|
184
|
+
}
|
|
185
|
+
function methodBadge(method) {
|
|
186
|
+
const color = METHOD_COLOR[method] ?? "#7d8590";
|
|
187
|
+
return badge(method, color, `${color}18`);
|
|
188
|
+
}
|
|
189
|
+
function endpointRow(method, path, desc) {
|
|
190
|
+
return `
|
|
191
|
+
<tr>
|
|
192
|
+
<td class="method-cell">${methodBadge(method)}</td>
|
|
193
|
+
<td class="path-cell"><code>${path}</code></td>
|
|
194
|
+
<td class="desc-cell">${desc}</td>
|
|
195
|
+
</tr>`;
|
|
196
|
+
}
|
|
197
|
+
function resourceAccordion(name, base, isOpen) {
|
|
198
|
+
const p = `${base}/${name}`;
|
|
199
|
+
const singular = name.endsWith("s") ? name.slice(0, -1) : name;
|
|
200
|
+
const rows = [
|
|
201
|
+
endpointRow(
|
|
202
|
+
"GET",
|
|
203
|
+
p,
|
|
204
|
+
`List all ${name}. Supports filters, sort, pagination and <code>?_expand</code>.`
|
|
205
|
+
),
|
|
206
|
+
endpointRow(
|
|
207
|
+
"POST",
|
|
208
|
+
p,
|
|
209
|
+
`Create a new ${singular}. Auto-assigns <code>id</code> if not provided.`
|
|
210
|
+
),
|
|
211
|
+
endpointRow("GET", `${p}/:id`, `Get a single ${singular} by id.`),
|
|
212
|
+
endpointRow(
|
|
213
|
+
"PUT",
|
|
214
|
+
`${p}/:id`,
|
|
215
|
+
`Fully replace a ${singular}. Original <code>id</code> is always preserved.`
|
|
216
|
+
),
|
|
217
|
+
endpointRow(
|
|
218
|
+
"PATCH",
|
|
219
|
+
`${p}/:id`,
|
|
220
|
+
`Partially update a ${singular} \u2014 only provided fields change.`
|
|
221
|
+
),
|
|
222
|
+
endpointRow("DELETE", `${p}/:id`, `Delete a ${singular} and return it as confirmation.`)
|
|
223
|
+
].join("");
|
|
224
|
+
return `
|
|
225
|
+
<details class="resource-card" ${isOpen ? "open" : ""}>
|
|
226
|
+
<summary>
|
|
227
|
+
<span class="resource-name">/${name}</span>
|
|
228
|
+
<span class="route-count">6 routes</span>
|
|
229
|
+
</summary>
|
|
230
|
+
<table>
|
|
231
|
+
<tbody>${rows}</tbody>
|
|
232
|
+
</table>
|
|
233
|
+
</details>`;
|
|
234
|
+
}
|
|
235
|
+
function examplesBlock(collections, relations, base, host, options, firstCustomRoute) {
|
|
236
|
+
const examples = [];
|
|
237
|
+
const firstCol = collections[0];
|
|
238
|
+
if (firstCol) {
|
|
239
|
+
const p = `${host}${base}/${firstCol}`;
|
|
240
|
+
const singular = firstCol.endsWith("s") ? firstCol.slice(0, -1) : firstCol;
|
|
241
|
+
examples.push(
|
|
242
|
+
`# List all ${firstCol}
|
|
243
|
+
curl ${p}`,
|
|
244
|
+
`# Filter by field
|
|
245
|
+
curl "${p}?name=value"`,
|
|
246
|
+
`# Sort and paginate
|
|
247
|
+
curl "${p}?_sort=id&_order=desc&_page=1&_limit=5"`,
|
|
248
|
+
`# Get single ${singular}
|
|
249
|
+
curl ${p}/1`,
|
|
250
|
+
`# Create ${singular}
|
|
251
|
+
curl -X POST ${p} \\
|
|
252
|
+
-H "Content-Type: application/json" \\
|
|
253
|
+
-d '{"name":"example"}'`,
|
|
254
|
+
`# Partially update ${singular}
|
|
255
|
+
curl -X PATCH ${p}/1 \\
|
|
256
|
+
-H "Content-Type: application/json" \\
|
|
257
|
+
-d '{"name":"updated"}'`,
|
|
258
|
+
`# Delete ${singular}
|
|
259
|
+
curl -X DELETE ${p}/1`
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
const firstRel = Object.entries(relations)[0];
|
|
263
|
+
if (firstRel) {
|
|
264
|
+
const [child, fields] = firstRel;
|
|
265
|
+
const fk = Object.keys(fields)[0];
|
|
266
|
+
const expandKey = fk.replace(/Id$/i, "");
|
|
267
|
+
examples.push(
|
|
268
|
+
`# Embed parent with ?_expand
|
|
269
|
+
curl "${host}${base}/${child}/1?_expand=${expandKey}"`
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
const firstRelEntry = Object.entries(relations)[0];
|
|
273
|
+
if (firstRelEntry) {
|
|
274
|
+
const [child, fields] = firstRelEntry;
|
|
275
|
+
const parent = Object.values(fields)[0];
|
|
276
|
+
if (parent) {
|
|
277
|
+
examples.push(`# Nested resource
|
|
278
|
+
curl ${host}${base}/${parent}/1/${child}`);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
if (options.pageable.enabled && firstCol) {
|
|
282
|
+
examples.push(`# Pageable envelope
|
|
283
|
+
curl "${host}${base}/${firstCol}?_page=2"`);
|
|
284
|
+
}
|
|
285
|
+
const firstParentRel = Object.entries(relations).find(
|
|
286
|
+
([, fields]) => Object.values(fields).includes(firstCol ?? "")
|
|
287
|
+
);
|
|
288
|
+
if (firstCol) {
|
|
289
|
+
examples.push(
|
|
290
|
+
`# Project fields with ?_fields
|
|
291
|
+
curl "${host}${base}/${firstCol}?_fields=id,name"`
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
if (firstParentRel && firstCol) {
|
|
295
|
+
const [childName] = firstParentRel;
|
|
296
|
+
examples.push(
|
|
297
|
+
`# Embed child collection with ?_embed
|
|
298
|
+
curl "${host}${base}/${firstCol}/1?_embed=${childName}"`
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
if (options.snapshot) {
|
|
302
|
+
examples.push(
|
|
303
|
+
`# Snapshot endpoints
|
|
304
|
+
curl ${host}/_snapshot
|
|
305
|
+
curl -X POST ${host}/_snapshot/save
|
|
306
|
+
curl -X POST ${host}/_snapshot/reset`
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
if (firstCustomRoute) {
|
|
310
|
+
const method = firstCustomRoute.method?.toUpperCase() ?? "GET";
|
|
311
|
+
const fullPath = `${host}${base}${firstCustomRoute.path}`;
|
|
312
|
+
const curlFlag = method === "GET" ? "" : `-X ${method} `;
|
|
313
|
+
examples.push(`# Custom route
|
|
314
|
+
curl ${curlFlag}${fullPath}`);
|
|
315
|
+
}
|
|
316
|
+
const highlighted = examples.map((e) => e.replace(/^(#.+)$/gm, '<span class="cm">$1</span>')).join("\n\n");
|
|
317
|
+
return `<pre>${highlighted}</pre>`;
|
|
318
|
+
}
|
|
319
|
+
function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map()) {
|
|
320
|
+
const collections = Object.keys(storage.getData());
|
|
321
|
+
const relations = storage.getRelations();
|
|
322
|
+
const base = options.base;
|
|
323
|
+
const host = `http://${options.host}:${options.port}`;
|
|
324
|
+
const modes = [];
|
|
325
|
+
if (options.watch) modes.push(badge("watch", "#38bdf8", "#38bdf818"));
|
|
326
|
+
if (options.readonly) modes.push(badge("readonly", "#94a3b8", "#94a3b818"));
|
|
327
|
+
if (options.delay > 0) modes.push(badge(`delay \xB7 ${options.delay}ms`, "#fb923c", "#fb923c18"));
|
|
328
|
+
if (options.pageable.enabled)
|
|
329
|
+
modes.push(badge(`pageable \xB7 limit ${options.pageable.limit}`, "#34d399", "#34d39918"));
|
|
330
|
+
if (options.snapshot) modes.push(badge("snapshot", "#c084fc", "#c084fc18"));
|
|
331
|
+
if (handlers.size > 0) modes.push(badge(`handlers \xB7 ${handlers.size}`, "#f0883e", "#f0883e18"));
|
|
332
|
+
const accordions = collections.map((col, i) => resourceAccordion(col, base, i === 0)).join("");
|
|
333
|
+
const nestedRows = [];
|
|
334
|
+
for (const [child, fields] of Object.entries(relations)) {
|
|
335
|
+
for (const [, parent] of Object.entries(fields)) {
|
|
336
|
+
const nestedPath = `${base}/${parent}/:id/${child}`;
|
|
337
|
+
const parentSingular = parent.endsWith("s") ? parent.slice(0, -1) : parent;
|
|
338
|
+
nestedRows.push(
|
|
339
|
+
endpointRow("GET", nestedPath, `List ${child} belonging to a ${parentSingular}.`)
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
const nestedAccordion = nestedRows.length ? `
|
|
344
|
+
<details class="resource-card nested-card">
|
|
345
|
+
<summary>
|
|
346
|
+
<span class="resource-name">Nested routes</span>
|
|
347
|
+
<span class="route-count">${nestedRows.length} route${nestedRows.length !== 1 ? "s" : ""}</span>
|
|
348
|
+
</summary>
|
|
349
|
+
<table><tbody>${nestedRows.join("")}</tbody></table>
|
|
350
|
+
</details>` : "";
|
|
351
|
+
const snapshotAccordion = options.snapshot ? `
|
|
352
|
+
<details class="resource-card nested-card">
|
|
353
|
+
<summary>
|
|
354
|
+
<span class="resource-name">/_snapshot</span>
|
|
355
|
+
<span class="route-count">3 routes</span>
|
|
356
|
+
</summary>
|
|
357
|
+
<table><tbody>
|
|
358
|
+
${endpointRow("GET", "/_snapshot", "Returns metadata of the current snapshot: <code>savedAt</code> and item counts per collection.")}
|
|
359
|
+
${endpointRow("POST", "/_snapshot/save", "Replaces the stored snapshot with the current database state.")}
|
|
360
|
+
${endpointRow("POST", "/_snapshot/reset", "Restores the database to the last saved snapshot and persists to disk.")}
|
|
361
|
+
</tbody></table>
|
|
362
|
+
</details>` : "";
|
|
363
|
+
const customRoutes = storage.getRoutes();
|
|
364
|
+
const customRoutesAccordion = customRoutes.length ? `
|
|
365
|
+
<details class="resource-card nested-card">
|
|
366
|
+
<summary>
|
|
367
|
+
<span class="resource-name">Custom routes</span>
|
|
368
|
+
<span class="route-count">${customRoutes.length} route${customRoutes.length !== 1 ? "s" : ""}</span>
|
|
369
|
+
</summary>
|
|
370
|
+
<table><tbody>
|
|
371
|
+
${customRoutes.map((r) => {
|
|
372
|
+
const fullPath = `${base}${r.path}`;
|
|
373
|
+
let desc;
|
|
374
|
+
if (r.handler) {
|
|
375
|
+
const found = handlers.has(r.handler);
|
|
376
|
+
desc = found ? `Handler \u2014 <code>${r.handler}()</code>` : `Handler \u2014 <code>${r.handler}()</code> <span style="color:#f85149">(not loaded)</span>`;
|
|
377
|
+
} else if (r.response?.body != null && hasTemplates(r.response.body)) {
|
|
378
|
+
desc = `Dynamic body \u2014 <code>{{\u2026}}</code>`;
|
|
379
|
+
} else {
|
|
380
|
+
const status = r.response?.status ?? 200;
|
|
381
|
+
desc = `Static \u2014 <code>${status}</code>${r.response?.headers ? ` + custom headers` : ""}`;
|
|
382
|
+
}
|
|
383
|
+
return endpointRow(r.method?.toUpperCase() ?? "GET", fullPath, desc);
|
|
384
|
+
}).join("")}
|
|
385
|
+
</tbody></table>
|
|
386
|
+
</details>` : "";
|
|
387
|
+
const routesByHandler = /* @__PURE__ */ new Map();
|
|
388
|
+
for (const r of customRoutes) {
|
|
389
|
+
if (r.handler) {
|
|
390
|
+
const list = routesByHandler.get(r.handler) ?? [];
|
|
391
|
+
list.push({ method: (r.method ?? "GET").toUpperCase(), path: `${base}${r.path}` });
|
|
392
|
+
routesByHandler.set(r.handler, list);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
const handlersAccordion = handlers.size > 0 ? `
|
|
396
|
+
<details class="resource-card nested-card">
|
|
397
|
+
<summary>
|
|
398
|
+
<span class="resource-name">Handlers</span>
|
|
399
|
+
<span class="route-count">${handlers.size} function${handlers.size !== 1 ? "s" : ""}</span>
|
|
400
|
+
</summary>
|
|
401
|
+
<table><tbody>
|
|
402
|
+
${[...handlers.keys()].map((name) => {
|
|
403
|
+
const routes = routesByHandler.get(name);
|
|
404
|
+
const routeDesc = routes ? routes.map((r) => `<code>${r.method} ${r.path}</code>`).join(", ") : `<span style="color:var(--text-muted)">not referenced in _routes</span>`;
|
|
405
|
+
return endpointRow("fn", name + "()", routeDesc);
|
|
406
|
+
}).join("")}
|
|
407
|
+
</tbody></table>
|
|
408
|
+
</details>` : "";
|
|
409
|
+
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.`;
|
|
410
|
+
return `<!DOCTYPE html>
|
|
411
|
+
<html lang="en">
|
|
412
|
+
<head>
|
|
413
|
+
<meta charset="UTF-8">
|
|
414
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
415
|
+
<title>yrest \u2014 API Overview</title>
|
|
416
|
+
<style>
|
|
417
|
+
:root {
|
|
418
|
+
--bg: #0d1117;
|
|
419
|
+
--bg-card: #161b22;
|
|
420
|
+
--bg-hover: #1c2128;
|
|
421
|
+
--bg-inset: #0d1117;
|
|
422
|
+
--border: #30363d;
|
|
423
|
+
--border-hi: #3d444d;
|
|
424
|
+
--text: #e6edf3;
|
|
425
|
+
--text-muted:#7d8590;
|
|
426
|
+
--accent: #58a6ff;
|
|
427
|
+
--radius: 8px;
|
|
428
|
+
}
|
|
429
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
430
|
+
body { background: var(--bg); color: var(--text); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-size: 14px; line-height: 1.6; }
|
|
431
|
+
|
|
432
|
+
/* \u2500\u2500 Banner \u2500\u2500 */
|
|
433
|
+
.banner {
|
|
434
|
+
width: 100%;
|
|
435
|
+
background: linear-gradient(135deg, #0d1117 0%, #161b22 40%, #1a2332 100%);
|
|
436
|
+
border-bottom: 1px solid var(--border);
|
|
437
|
+
padding: 48px 32px 40px;
|
|
438
|
+
}
|
|
439
|
+
.banner-inner { max-width: 1100px; margin: 0 auto; }
|
|
440
|
+
.banner h1 { font-size: clamp(36px, 6vw, 60px); font-weight: 800; letter-spacing: -2px; line-height: 1; }
|
|
441
|
+
.banner h1 .y { color: var(--text); }
|
|
442
|
+
.banner h1 .rest { color: var(--accent); }
|
|
443
|
+
.banner p { color: var(--text-muted); margin-top: 10px; font-size: 15px; }
|
|
444
|
+
.banner-meta { display: flex; gap: 24px; margin-top: 20px; flex-wrap: wrap; }
|
|
445
|
+
.banner-meta span { color: var(--text-muted); font-size: 13px; }
|
|
446
|
+
.banner-meta span strong { color: var(--text); font-family: monospace; }
|
|
447
|
+
|
|
448
|
+
/* \u2500\u2500 Layout \u2500\u2500 */
|
|
449
|
+
.wrap { max-width: 1100px; margin: 0 auto; padding: 32px 24px 48px; }
|
|
450
|
+
h2 { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .08em; color: var(--text-muted); margin: 32px 0 12px; }
|
|
451
|
+
|
|
452
|
+
/* \u2500\u2500 Cards \u2500\u2500 */
|
|
453
|
+
.card { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 20px 24px; }
|
|
454
|
+
.two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
|
455
|
+
@media (max-width: 600px) { .two-col { grid-template-columns: 1fr; } }
|
|
456
|
+
|
|
457
|
+
/* \u2500\u2500 Server info grid \u2500\u2500 */
|
|
458
|
+
.info-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 16px; }
|
|
459
|
+
.stat label { font-size: 10px; text-transform: uppercase; letter-spacing: .07em; color: var(--text-muted); display: block; margin-bottom: 3px; }
|
|
460
|
+
.stat value { font-size: 15px; font-weight: 600; font-family: monospace; color: var(--text); }
|
|
461
|
+
|
|
462
|
+
/* \u2500\u2500 Mode badges \u2500\u2500 */
|
|
463
|
+
.modes { display: flex; gap: 8px; flex-wrap: wrap; min-height: 28px; align-items: center; }
|
|
464
|
+
.badge { display: inline-block; padding: 3px 10px; border-radius: 20px; font-size: 12px; font-weight: 600; white-space: nowrap; }
|
|
465
|
+
|
|
466
|
+
/* \u2500\u2500 Endpoints grid (2 cols on wide, 1 col on narrow) \u2500\u2500 */
|
|
467
|
+
.endpoints-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; }
|
|
468
|
+
@media (max-width: 860px) { .endpoints-grid { grid-template-columns: 1fr; } }
|
|
469
|
+
.nested-card { grid-column: 1 / -1; }
|
|
470
|
+
|
|
471
|
+
/* \u2500\u2500 Accordion (details/summary) \u2500\u2500 */
|
|
472
|
+
.resource-card {
|
|
473
|
+
background: var(--bg-card);
|
|
474
|
+
border: 1px solid var(--border);
|
|
475
|
+
border-radius: var(--radius);
|
|
476
|
+
overflow: hidden;
|
|
477
|
+
transition: border-color .15s;
|
|
478
|
+
}
|
|
479
|
+
.resource-card[open] { border-color: var(--border-hi); }
|
|
480
|
+
.resource-card summary {
|
|
481
|
+
display: flex;
|
|
482
|
+
align-items: center;
|
|
483
|
+
justify-content: space-between;
|
|
484
|
+
padding: 13px 18px;
|
|
485
|
+
cursor: pointer;
|
|
486
|
+
user-select: none;
|
|
487
|
+
list-style: none;
|
|
488
|
+
gap: 8px;
|
|
489
|
+
}
|
|
490
|
+
.resource-card summary::-webkit-details-marker { display: none; }
|
|
491
|
+
.resource-card summary::before {
|
|
492
|
+
content: "\u203A";
|
|
493
|
+
color: var(--text-muted);
|
|
494
|
+
font-size: 18px;
|
|
495
|
+
line-height: 1;
|
|
496
|
+
transition: transform .2s;
|
|
497
|
+
margin-right: 4px;
|
|
498
|
+
flex-shrink: 0;
|
|
499
|
+
}
|
|
500
|
+
.resource-card[open] summary::before { transform: rotate(90deg); }
|
|
501
|
+
.resource-card summary:hover { background: var(--bg-hover); }
|
|
502
|
+
.resource-name { font-family: monospace; font-size: 14px; font-weight: 600; color: var(--accent); flex: 1; }
|
|
503
|
+
.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; }
|
|
504
|
+
|
|
505
|
+
/* \u2500\u2500 Tables \u2500\u2500 */
|
|
506
|
+
table { width: 100%; border-collapse: collapse; }
|
|
507
|
+
td { padding: 8px 12px; border-top: 1px solid var(--border); vertical-align: top; font-size: 13px; }
|
|
508
|
+
.method-cell { width: 78px; white-space: nowrap; }
|
|
509
|
+
.path-cell { width: 44%; white-space: nowrap; }
|
|
510
|
+
.desc-cell { color: var(--text-muted); }
|
|
511
|
+
code { font-family: "SF Mono", "Fira Code", monospace; font-size: 12px; background: #58a6ff15; color: var(--accent); padding: 1px 5px; border-radius: 3px; }
|
|
512
|
+
|
|
513
|
+
/* \u2500\u2500 Query params table \u2500\u2500 */
|
|
514
|
+
.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); }
|
|
515
|
+
.param-table td:first-child { white-space: nowrap; width: 160px; }
|
|
516
|
+
.param-table td:nth-child(2) { white-space: nowrap; width: 200px; }
|
|
517
|
+
|
|
518
|
+
/* \u2500\u2500 Code block \u2500\u2500 */
|
|
519
|
+
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; }
|
|
520
|
+
.cm { color: #3d444d; }
|
|
521
|
+
|
|
522
|
+
/* \u2500\u2500 Warning \u2500\u2500 */
|
|
523
|
+
.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; }
|
|
524
|
+
|
|
525
|
+
/* \u2500\u2500 Footer \u2500\u2500 */
|
|
526
|
+
footer { margin-top: 48px; text-align: center; font-size: 11px; color: var(--text-muted); padding-bottom: 16px; }
|
|
527
|
+
footer a { color: var(--accent); text-decoration: none; }
|
|
528
|
+
</style>
|
|
529
|
+
</head>
|
|
530
|
+
<body>
|
|
531
|
+
|
|
532
|
+
<div class="banner">
|
|
533
|
+
<div class="banner-inner">
|
|
534
|
+
<h1><span class="y">y</span><span class="rest">rest</span></h1>
|
|
535
|
+
<p>Zero-config REST API mock server</p>
|
|
536
|
+
<div class="banner-meta">
|
|
537
|
+
<span>URL <strong>${host}</strong></span>
|
|
538
|
+
<span>Base <strong>${base || "/"}</strong></span>
|
|
539
|
+
<span>File <strong>${options.file}</strong></span>
|
|
540
|
+
<span>Collections <strong>${collections.length}</strong></span>
|
|
541
|
+
</div>
|
|
542
|
+
</div>
|
|
543
|
+
</div>
|
|
544
|
+
|
|
545
|
+
<div class="wrap">
|
|
546
|
+
|
|
547
|
+
<h2>Active Modes</h2>
|
|
548
|
+
<div class="card">
|
|
549
|
+
<div class="modes">${modes.length ? modes.join(" ") : `<span style="color:var(--text-muted);font-size:13px">none</span>`}</div>
|
|
550
|
+
${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>` : ""}
|
|
551
|
+
</div>
|
|
552
|
+
|
|
553
|
+
<h2>Endpoints</h2>
|
|
554
|
+
<div class="endpoints-grid">
|
|
555
|
+
${accordions}
|
|
556
|
+
${nestedAccordion}
|
|
557
|
+
${snapshotAccordion}
|
|
558
|
+
${customRoutesAccordion}
|
|
559
|
+
${handlersAccordion}
|
|
560
|
+
</div>
|
|
561
|
+
|
|
562
|
+
<h2>Query Parameters</h2>
|
|
563
|
+
<div class="card">
|
|
564
|
+
<table class="param-table">
|
|
565
|
+
<thead><tr><th>Param</th><th>Example</th><th>Description</th></tr></thead>
|
|
566
|
+
<tbody>
|
|
567
|
+
<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>
|
|
568
|
+
<tr><td><code>?field_gte / _lte</code></td><td><code>?price_gte=10&price_lte=50</code></td><td>Numeric or lexicographic range. Works with any comparable field.</td></tr>
|
|
569
|
+
<tr><td><code>?field_ne</code></td><td><code>?status_ne=inactive</code></td><td>Exclude items where the field equals the value.</td></tr>
|
|
570
|
+
<tr><td><code>?field_like</code></td><td><code>?name_like=ana</code></td><td>Case-insensitive substring match.</td></tr>
|
|
571
|
+
<tr><td><code>?field_start</code></td><td><code>?name_start=A</code></td><td>Case-insensitive prefix match.</td></tr>
|
|
572
|
+
<tr><td><code>?field_regex</code></td><td><code>?email_regex=gmail</code></td><td>Case-insensitive regular expression match.</td></tr>
|
|
573
|
+
<tr><td><code>?_q</code></td><td><code>?_q=ana</code></td><td>Full-text search across all scalar fields (case-insensitive substring).</td></tr>
|
|
574
|
+
<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>
|
|
575
|
+
<tr><td><code>?_page & ?_limit</code></td><td><code>?_page=2&_limit=10</code></td><td>${paginationDesc}</td></tr>
|
|
576
|
+
<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>
|
|
577
|
+
<tr><td><code>?_embed</code></td><td><code>?_embed=posts</code></td><td>Embed child collections into each parent item. Requires <code>_rel</code> in the YAML file.</td></tr>
|
|
578
|
+
<tr><td><code>?_fields</code></td><td><code>?_fields=id,name</code></td><td>Return only the specified fields. Applied last \u2014 can include embedded/expanded keys.</td></tr>
|
|
579
|
+
</tbody>
|
|
580
|
+
</table>
|
|
581
|
+
</div>
|
|
582
|
+
|
|
583
|
+
${collections.length ? `<h2>Examples</h2><div class="card">${examplesBlock(collections, relations, base, host, options, customRoutes[0])}</div>` : ""}
|
|
584
|
+
|
|
585
|
+
<footer>
|
|
586
|
+
Powered by <a href="https://github.com/aggiovato/yaml-rest" target="_blank">@aggiovato/yrest</a> \xB7 <a href="/_about">/_about</a>
|
|
587
|
+
</footer>
|
|
588
|
+
|
|
589
|
+
</div>
|
|
590
|
+
</body>
|
|
591
|
+
</html>`;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// src/router/routes/about.routes.ts
|
|
595
|
+
var AboutRouteCommand = class {
|
|
596
|
+
constructor(storage, options, handlers = /* @__PURE__ */ new Map()) {
|
|
597
|
+
this.storage = storage;
|
|
598
|
+
this.options = options;
|
|
599
|
+
this.handlers = handlers;
|
|
600
|
+
}
|
|
601
|
+
storage;
|
|
602
|
+
options;
|
|
603
|
+
handlers;
|
|
604
|
+
register(server) {
|
|
605
|
+
server.get("/_about", (_req, reply) => {
|
|
606
|
+
reply.header("Content-Type", "text/html; charset=utf-8");
|
|
607
|
+
return reply.send(generateAboutHtml(this.storage, this.options, this.handlers));
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
};
|
|
611
|
+
|
|
612
|
+
// src/utils/params.ts
|
|
613
|
+
function nextId(items) {
|
|
614
|
+
const ids = items.map((i) => i["id"]).filter((id) => typeof id === "number");
|
|
615
|
+
return ids.length > 0 ? Math.max(...ids) + 1 : 1;
|
|
616
|
+
}
|
|
617
|
+
function firstParam(value) {
|
|
618
|
+
if (value === void 0) return void 0;
|
|
619
|
+
return Array.isArray(value) ? value[0] : value;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// src/services/query.service.ts
|
|
623
|
+
var OPERATORS = ["_gte", "_lte", "_ne", "_like", "_start", "_regex"];
|
|
624
|
+
function applyOperator(itemValue, op, filterValue) {
|
|
625
|
+
const strItem = String(itemValue);
|
|
626
|
+
const numItem = Number(itemValue);
|
|
627
|
+
const numFilter = Number(filterValue);
|
|
628
|
+
const numeric = !isNaN(numItem) && !isNaN(numFilter) && filterValue.trim() !== "";
|
|
629
|
+
switch (op) {
|
|
630
|
+
case "_gte":
|
|
631
|
+
return numeric ? numItem >= numFilter : strItem >= filterValue;
|
|
632
|
+
case "_lte":
|
|
633
|
+
return numeric ? numItem <= numFilter : strItem <= filterValue;
|
|
634
|
+
case "_ne":
|
|
635
|
+
return strItem !== filterValue;
|
|
636
|
+
case "_like":
|
|
637
|
+
return strItem.toLowerCase().includes(filterValue.toLowerCase());
|
|
638
|
+
case "_start":
|
|
639
|
+
return strItem.toLowerCase().startsWith(filterValue.toLowerCase());
|
|
640
|
+
case "_regex": {
|
|
641
|
+
try {
|
|
642
|
+
return new RegExp(filterValue, "i").test(strItem);
|
|
643
|
+
} catch {
|
|
644
|
+
return false;
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
function filterByQuery(items, query) {
|
|
650
|
+
const filters = Object.entries(query).filter(([key]) => !key.startsWith("_"));
|
|
651
|
+
if (filters.length === 0) return items;
|
|
652
|
+
return items.filter(
|
|
653
|
+
(item) => filters.every(([key, value]) => {
|
|
654
|
+
const op = OPERATORS.find((o) => key.endsWith(o));
|
|
655
|
+
if (op) {
|
|
656
|
+
const field = key.slice(0, -op.length);
|
|
657
|
+
if (item[field] === void 0) return false;
|
|
658
|
+
const filterVal = Array.isArray(value) ? value[0] : value;
|
|
659
|
+
return applyOperator(item[field], op, filterVal);
|
|
660
|
+
}
|
|
661
|
+
if (item[key] === void 0) return false;
|
|
662
|
+
const itemStr = String(item[key]);
|
|
663
|
+
return Array.isArray(value) ? value.includes(itemStr) : itemStr === value;
|
|
664
|
+
})
|
|
665
|
+
);
|
|
666
|
+
}
|
|
667
|
+
function fullTextSearch(items, term) {
|
|
668
|
+
const lower = term.toLowerCase();
|
|
669
|
+
return items.filter(
|
|
670
|
+
(item) => Object.values(item).some((val) => {
|
|
671
|
+
if (val === null || val === void 0 || typeof val === "object") return false;
|
|
672
|
+
return String(val).toLowerCase().includes(lower);
|
|
673
|
+
})
|
|
674
|
+
);
|
|
675
|
+
}
|
|
676
|
+
function sortBy(items, field, order) {
|
|
677
|
+
const direction = order === "desc" ? -1 : 1;
|
|
678
|
+
return [...items].sort((a, b) => {
|
|
679
|
+
const av = a[field];
|
|
680
|
+
const bv = b[field];
|
|
681
|
+
if (av === void 0) return 1;
|
|
682
|
+
if (bv === void 0) return -1;
|
|
683
|
+
if (typeof av === "number" && typeof bv === "number") return (av - bv) * direction;
|
|
684
|
+
return String(av).localeCompare(String(bv), void 0, { sensitivity: "base" }) * direction;
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
function projectFields(input, fields) {
|
|
688
|
+
if (fields.length === 0) return input;
|
|
689
|
+
const project = (item) => Object.fromEntries(fields.filter((f) => f in item).map((f) => [f, item[f]]));
|
|
690
|
+
return Array.isArray(input) ? input.map(project) : project(input);
|
|
691
|
+
}
|
|
692
|
+
function paginate(items, page, limit) {
|
|
693
|
+
const start = (page - 1) * limit;
|
|
694
|
+
return items.slice(start, start + limit);
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// src/services/resource.service.ts
|
|
698
|
+
function findById(items, id) {
|
|
699
|
+
return items.find((i) => String(i["id"]) === id);
|
|
700
|
+
}
|
|
701
|
+
function findIndexById(items, id) {
|
|
702
|
+
return items.findIndex((i) => String(i["id"]) === id);
|
|
703
|
+
}
|
|
704
|
+
function createItem(storage, resource, body) {
|
|
705
|
+
const collection = storage.getCollection(resource) ?? [];
|
|
706
|
+
const item = {
|
|
707
|
+
id: body["id"] !== void 0 ? body["id"] : nextId(collection),
|
|
708
|
+
...body
|
|
709
|
+
};
|
|
710
|
+
storage.setCollection(resource, [...collection, item]);
|
|
711
|
+
storage.persist();
|
|
712
|
+
return item;
|
|
713
|
+
}
|
|
714
|
+
function replaceItem(storage, resource, id, body) {
|
|
715
|
+
const collection = storage.getCollection(resource) ?? [];
|
|
716
|
+
const idx = findIndexById(collection, id);
|
|
717
|
+
if (idx === -1) return void 0;
|
|
718
|
+
const updated = { ...body, id: collection[idx]["id"] };
|
|
719
|
+
collection[idx] = updated;
|
|
720
|
+
storage.setCollection(resource, collection);
|
|
721
|
+
storage.persist();
|
|
722
|
+
return updated;
|
|
723
|
+
}
|
|
724
|
+
function patchItem(storage, resource, id, body) {
|
|
725
|
+
const collection = storage.getCollection(resource) ?? [];
|
|
726
|
+
const idx = findIndexById(collection, id);
|
|
727
|
+
if (idx === -1) return void 0;
|
|
728
|
+
const updated = { ...collection[idx], ...body };
|
|
729
|
+
collection[idx] = updated;
|
|
730
|
+
storage.setCollection(resource, collection);
|
|
731
|
+
storage.persist();
|
|
732
|
+
return updated;
|
|
733
|
+
}
|
|
734
|
+
function deleteItem(storage, resource, id) {
|
|
735
|
+
const collection = storage.getCollection(resource) ?? [];
|
|
736
|
+
const idx = findIndexById(collection, id);
|
|
737
|
+
if (idx === -1) return void 0;
|
|
738
|
+
const [deleted] = collection.splice(idx, 1);
|
|
739
|
+
storage.setCollection(resource, collection);
|
|
740
|
+
storage.persist();
|
|
741
|
+
return deleted;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// src/services/expand.service.ts
|
|
745
|
+
function expandItems(input, query, resource, storage) {
|
|
746
|
+
const isArray = Array.isArray(input);
|
|
747
|
+
const items = isArray ? input : [input];
|
|
748
|
+
const expandParam = query["_expand"];
|
|
749
|
+
if (!expandParam) return isArray ? items : input;
|
|
750
|
+
const keys = (Array.isArray(expandParam) ? expandParam : [expandParam]).flatMap((v) => v.split(",")).map((k) => k.trim()).filter(Boolean);
|
|
751
|
+
const resourceRelations = storage.getRelations()[resource] ?? {};
|
|
752
|
+
const expansions = /* @__PURE__ */ new Map();
|
|
753
|
+
for (const expandKey of keys) {
|
|
754
|
+
for (const [field, parentCollection] of Object.entries(resourceRelations)) {
|
|
755
|
+
const derivedKey = field.replace(/Id$/i, "");
|
|
756
|
+
if (derivedKey === expandKey || parentCollection === expandKey || parentCollection === `${expandKey}s`) {
|
|
757
|
+
expansions.set(expandKey, { field, parentCollection });
|
|
758
|
+
break;
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
if (expansions.size === 0) return isArray ? items : input;
|
|
763
|
+
const expanded = items.map((item) => {
|
|
764
|
+
const result = { ...item };
|
|
765
|
+
for (const [expandKey, { field, parentCollection }] of expansions) {
|
|
766
|
+
const foreignKeyValue = item[field];
|
|
767
|
+
if (foreignKeyValue === void 0) continue;
|
|
768
|
+
const parent = (storage.getCollection(parentCollection) ?? []).find(
|
|
769
|
+
(p) => String(p["id"]) === String(foreignKeyValue)
|
|
770
|
+
);
|
|
771
|
+
if (parent !== void 0) result[expandKey] = parent;
|
|
772
|
+
}
|
|
773
|
+
return result;
|
|
774
|
+
});
|
|
775
|
+
return isArray ? expanded : expanded[0];
|
|
776
|
+
}
|
|
777
|
+
function embedItems(input, query, resource, storage) {
|
|
778
|
+
const isArray = Array.isArray(input);
|
|
779
|
+
const items = isArray ? input : [input];
|
|
780
|
+
const embedParam = query["_embed"];
|
|
781
|
+
if (!embedParam) return isArray ? items : input;
|
|
782
|
+
const keys = (Array.isArray(embedParam) ? embedParam : [embedParam]).flatMap((v) => v.split(",")).map((k) => k.trim()).filter(Boolean);
|
|
783
|
+
const relations = storage.getRelations();
|
|
784
|
+
const embeds = /* @__PURE__ */ new Map();
|
|
785
|
+
for (const embedKey of keys) {
|
|
786
|
+
outer: for (const [childCollection, fields] of Object.entries(relations)) {
|
|
787
|
+
for (const [fkField, parentCollection] of Object.entries(fields)) {
|
|
788
|
+
if (parentCollection === resource && childCollection === embedKey) {
|
|
789
|
+
embeds.set(embedKey, { childCollection, fkField });
|
|
790
|
+
break outer;
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
if (embeds.size === 0) return isArray ? items : input;
|
|
796
|
+
const result = items.map((item) => {
|
|
797
|
+
const out = { ...item };
|
|
798
|
+
for (const [embedKey, { childCollection, fkField }] of embeds) {
|
|
799
|
+
out[embedKey] = (storage.getCollection(childCollection) ?? []).filter(
|
|
800
|
+
(child) => String(child[fkField]) === String(item["id"])
|
|
801
|
+
);
|
|
802
|
+
}
|
|
803
|
+
return out;
|
|
804
|
+
});
|
|
805
|
+
return isArray ? result : result[0];
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
// src/router/routes/collection.routes.ts
|
|
809
|
+
var CollectionRouteCommand = class {
|
|
810
|
+
constructor(storage, resource, base, options) {
|
|
811
|
+
this.storage = storage;
|
|
812
|
+
this.resource = resource;
|
|
813
|
+
this.base = base;
|
|
814
|
+
this.options = options;
|
|
815
|
+
}
|
|
816
|
+
storage;
|
|
817
|
+
resource;
|
|
818
|
+
base;
|
|
819
|
+
options;
|
|
820
|
+
register(server) {
|
|
821
|
+
server.get(this.base, (req, reply) => {
|
|
822
|
+
const collection = this.storage.getCollection(this.resource) ?? [];
|
|
823
|
+
const filtered = filterByQuery(collection, req.query);
|
|
824
|
+
const searchTerm = firstParam(req.query["_q"]);
|
|
825
|
+
const searched = searchTerm ? fullTextSearch(filtered, searchTerm) : filtered;
|
|
826
|
+
const fields = (firstParam(req.query["_fields"]) ?? "").split(",").map((f) => f.trim()).filter(Boolean);
|
|
827
|
+
const sortField = firstParam(req.query["_sort"]);
|
|
828
|
+
const sortOrder = firstParam(req.query["_order"]) === "desc" ? "desc" : "asc";
|
|
829
|
+
const sorted = sortField ? sortBy(searched, sortField, sortOrder) : searched;
|
|
830
|
+
if (this.options.pageable.enabled) {
|
|
831
|
+
const defaultLimit = this.options.pageable.limit;
|
|
832
|
+
const page = Math.max(1, parseInt(firstParam(req.query["_page"]) ?? "1", 10) || 1);
|
|
833
|
+
const limit = Math.max(
|
|
834
|
+
1,
|
|
835
|
+
parseInt(firstParam(req.query["_limit"]) ?? String(defaultLimit), 10) || defaultLimit
|
|
836
|
+
);
|
|
837
|
+
const totalItems = sorted.length;
|
|
838
|
+
const totalPages = Math.ceil(totalItems / limit) || 1;
|
|
839
|
+
const data = projectFields(
|
|
840
|
+
embedItems(
|
|
841
|
+
expandItems(paginate(sorted, page, limit), req.query, this.resource, this.storage),
|
|
842
|
+
req.query,
|
|
843
|
+
this.resource,
|
|
844
|
+
this.storage
|
|
845
|
+
),
|
|
846
|
+
fields
|
|
847
|
+
);
|
|
848
|
+
const pagination = {
|
|
849
|
+
page,
|
|
850
|
+
limit,
|
|
851
|
+
totalItems,
|
|
852
|
+
totalPages,
|
|
853
|
+
isFirst: page === 1,
|
|
854
|
+
isLast: page >= totalPages,
|
|
855
|
+
hasNext: page < totalPages,
|
|
856
|
+
hasPrev: page > 1
|
|
857
|
+
};
|
|
858
|
+
return reply.send({ data, pagination });
|
|
859
|
+
}
|
|
860
|
+
const rawPage = firstParam(req.query["_page"]);
|
|
861
|
+
const rawLimit = firstParam(req.query["_limit"]);
|
|
862
|
+
let result;
|
|
863
|
+
if (!rawPage && !rawLimit) {
|
|
864
|
+
result = sorted;
|
|
865
|
+
} else {
|
|
866
|
+
const page = Math.max(1, parseInt(rawPage ?? "1", 10) || 1);
|
|
867
|
+
const limit = Math.max(1, parseInt(rawLimit ?? "10", 10) || 10);
|
|
868
|
+
reply.header("X-Total-Count", String(sorted.length));
|
|
869
|
+
result = paginate(sorted, page, limit);
|
|
870
|
+
}
|
|
871
|
+
return projectFields(
|
|
872
|
+
embedItems(
|
|
873
|
+
expandItems(result, req.query, this.resource, this.storage),
|
|
874
|
+
req.query,
|
|
875
|
+
this.resource,
|
|
876
|
+
this.storage
|
|
877
|
+
),
|
|
878
|
+
fields
|
|
879
|
+
);
|
|
880
|
+
});
|
|
881
|
+
server.post(this.base, (req, reply) => {
|
|
882
|
+
if (!req.body || typeof req.body !== "object" || Array.isArray(req.body)) {
|
|
883
|
+
return reply.status(400).send({ error: "Request body must be a JSON object" });
|
|
884
|
+
}
|
|
885
|
+
const item = createItem(this.storage, this.resource, req.body);
|
|
886
|
+
return reply.status(201).send(expandItems(item, req.query, this.resource, this.storage));
|
|
887
|
+
});
|
|
888
|
+
}
|
|
889
|
+
};
|
|
890
|
+
|
|
891
|
+
// src/router/routes/custom.routes.ts
|
|
892
|
+
var CustomRouteCommand = class {
|
|
893
|
+
constructor(storage, base, handlers = /* @__PURE__ */ new Map()) {
|
|
894
|
+
this.storage = storage;
|
|
895
|
+
this.base = base;
|
|
896
|
+
this.handlers = handlers;
|
|
897
|
+
}
|
|
898
|
+
storage;
|
|
899
|
+
base;
|
|
900
|
+
handlers;
|
|
901
|
+
register(server) {
|
|
902
|
+
for (const route of this.storage.getRoutes()) {
|
|
903
|
+
const method = route.method?.toUpperCase();
|
|
904
|
+
const path = route.path;
|
|
905
|
+
if (!method || !path) continue;
|
|
906
|
+
const url = `${this.base}${path}`;
|
|
907
|
+
const status = route.response?.status ?? 200;
|
|
908
|
+
const rawBody = route.response?.body ?? null;
|
|
909
|
+
const headers = route.response?.headers ?? {};
|
|
910
|
+
const dynamic = rawBody !== null && hasTemplates(rawBody);
|
|
911
|
+
const handlerName = route.handler;
|
|
912
|
+
server.route({
|
|
913
|
+
method,
|
|
914
|
+
url,
|
|
915
|
+
handler: async (req, reply) => {
|
|
916
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
917
|
+
reply.header(key, value);
|
|
918
|
+
}
|
|
919
|
+
if (handlerName) {
|
|
920
|
+
const fn = this.handlers.get(handlerName);
|
|
921
|
+
if (!fn) {
|
|
922
|
+
return reply.status(501).send({ error: `Handler "${handlerName}" is not defined in the handlers file` });
|
|
923
|
+
}
|
|
924
|
+
try {
|
|
925
|
+
const ctx = {
|
|
926
|
+
params: req.params,
|
|
927
|
+
query: req.query,
|
|
928
|
+
body: req.body,
|
|
929
|
+
headers: req.headers
|
|
930
|
+
};
|
|
931
|
+
const result = await fn(ctx);
|
|
932
|
+
const resStatus = result.status ?? 200;
|
|
933
|
+
for (const [k, v] of Object.entries(result.headers ?? {})) {
|
|
934
|
+
reply.header(k, v);
|
|
935
|
+
}
|
|
936
|
+
if (!result.body && resStatus === 204) return reply.status(resStatus).send();
|
|
937
|
+
return reply.status(resStatus).send(result.body ?? null);
|
|
938
|
+
} catch (err) {
|
|
939
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
940
|
+
return reply.status(500).send({ error: `Handler "${handlerName}" threw an error: ${msg}` });
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
const body = dynamic ? interpolate(rawBody, {
|
|
944
|
+
params: req.params,
|
|
945
|
+
query: req.query,
|
|
946
|
+
body: req.body,
|
|
947
|
+
headers: req.headers
|
|
948
|
+
}) : rawBody;
|
|
949
|
+
if (body === null && status === 204) return reply.status(status).send();
|
|
950
|
+
return reply.status(status).send(body);
|
|
951
|
+
}
|
|
952
|
+
});
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
};
|
|
956
|
+
|
|
957
|
+
// src/router/routes/item.routes.ts
|
|
958
|
+
var ItemRouteCommand = class {
|
|
959
|
+
constructor(storage, resource, base) {
|
|
960
|
+
this.storage = storage;
|
|
961
|
+
this.resource = resource;
|
|
962
|
+
this.base = base;
|
|
963
|
+
}
|
|
964
|
+
storage;
|
|
965
|
+
resource;
|
|
966
|
+
base;
|
|
967
|
+
register(server) {
|
|
968
|
+
server.get(`${this.base}/:id`, (req, reply) => {
|
|
969
|
+
const item = findById(this.storage.getCollection(this.resource) ?? [], req.params.id);
|
|
970
|
+
if (!item) return reply.status(404).send({ error: "Not found" });
|
|
971
|
+
const fields = (req.query["_fields"] ?? "").split(",").map((f) => f.trim()).filter(Boolean);
|
|
972
|
+
return projectFields(
|
|
973
|
+
embedItems(
|
|
974
|
+
expandItems(item, req.query, this.resource, this.storage),
|
|
975
|
+
req.query,
|
|
976
|
+
this.resource,
|
|
977
|
+
this.storage
|
|
978
|
+
),
|
|
979
|
+
fields
|
|
980
|
+
);
|
|
981
|
+
});
|
|
982
|
+
server.put(`${this.base}/:id`, (req, reply) => {
|
|
983
|
+
if (!req.body || typeof req.body !== "object" || Array.isArray(req.body)) {
|
|
984
|
+
return reply.status(400).send({ error: "Request body must be a JSON object" });
|
|
985
|
+
}
|
|
986
|
+
const item = replaceItem(this.storage, this.resource, req.params.id, req.body);
|
|
987
|
+
if (!item) return reply.status(404).send({ error: "Not found" });
|
|
988
|
+
return expandItems(item, req.query, this.resource, this.storage);
|
|
989
|
+
});
|
|
990
|
+
server.patch(`${this.base}/:id`, (req, reply) => {
|
|
991
|
+
if (!req.body || typeof req.body !== "object" || Array.isArray(req.body)) {
|
|
992
|
+
return reply.status(400).send({ error: "Request body must be a JSON object" });
|
|
993
|
+
}
|
|
994
|
+
const item = patchItem(this.storage, this.resource, req.params.id, req.body);
|
|
995
|
+
if (!item) return reply.status(404).send({ error: "Not found" });
|
|
996
|
+
return expandItems(item, req.query, this.resource, this.storage);
|
|
997
|
+
});
|
|
998
|
+
server.delete(`${this.base}/:id`, (req, reply) => {
|
|
999
|
+
const item = deleteItem(this.storage, this.resource, req.params.id);
|
|
1000
|
+
if (!item) return reply.status(404).send({ error: "Not found" });
|
|
1001
|
+
return expandItems(item, req.query, this.resource, this.storage);
|
|
1002
|
+
});
|
|
1003
|
+
}
|
|
1004
|
+
};
|
|
1005
|
+
|
|
1006
|
+
// src/router/routes/nested.routes.ts
|
|
1007
|
+
var NestedRouteCommand = class {
|
|
1008
|
+
constructor(storage, relations, base) {
|
|
1009
|
+
this.storage = storage;
|
|
1010
|
+
this.relations = relations;
|
|
1011
|
+
this.base = base;
|
|
1012
|
+
}
|
|
1013
|
+
storage;
|
|
1014
|
+
relations;
|
|
1015
|
+
base;
|
|
1016
|
+
register(server) {
|
|
1017
|
+
for (const [child, fields] of Object.entries(this.relations)) {
|
|
1018
|
+
for (const [field, parent] of Object.entries(fields)) {
|
|
1019
|
+
const collectionPath = `${this.base}/${parent}/:id/${child}`;
|
|
1020
|
+
const itemPath = `${this.base}/${parent}/:id/${child}/:childId`;
|
|
1021
|
+
server.get(collectionPath, (req, reply) => {
|
|
1022
|
+
const parentCollection = this.storage.getCollection(parent) ?? [];
|
|
1023
|
+
const parentItem = findById(parentCollection, req.params.id);
|
|
1024
|
+
if (!parentItem) return reply.status(404).send({ error: "Not found" });
|
|
1025
|
+
const children = (this.storage.getCollection(child) ?? []).filter(
|
|
1026
|
+
(item) => String(item[field]) === req.params.id
|
|
1027
|
+
);
|
|
1028
|
+
return children;
|
|
1029
|
+
});
|
|
1030
|
+
server.get(itemPath, (req, reply) => {
|
|
1031
|
+
const parentCollection = this.storage.getCollection(parent) ?? [];
|
|
1032
|
+
const parentItem = findById(parentCollection, req.params.id);
|
|
1033
|
+
if (!parentItem) return reply.status(404).send({ error: "Not found" });
|
|
1034
|
+
const childItem = (this.storage.getCollection(child) ?? []).find(
|
|
1035
|
+
(item) => String(item[field]) === req.params.id && String(item["id"]) === req.params.childId
|
|
1036
|
+
);
|
|
1037
|
+
if (!childItem) return reply.status(404).send({ error: "Not found" });
|
|
1038
|
+
return childItem;
|
|
1039
|
+
});
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
};
|
|
1044
|
+
|
|
1045
|
+
// src/router/routes/snapshot.routes.ts
|
|
1046
|
+
var SnapshotRouteCommand = class {
|
|
1047
|
+
constructor(storage) {
|
|
1048
|
+
this.storage = storage;
|
|
1049
|
+
}
|
|
1050
|
+
storage;
|
|
1051
|
+
register(server) {
|
|
1052
|
+
server.get("/_snapshot", (_req, reply) => {
|
|
1053
|
+
const { data, savedAt } = this.storage.getSnapshot();
|
|
1054
|
+
return reply.send({
|
|
1055
|
+
savedAt: savedAt.toISOString(),
|
|
1056
|
+
collections: Object.fromEntries(
|
|
1057
|
+
Object.entries(data).map(([name, items]) => [name, items.length])
|
|
1058
|
+
)
|
|
1059
|
+
});
|
|
1060
|
+
});
|
|
1061
|
+
server.post("/_snapshot/save", (_req, reply) => {
|
|
1062
|
+
this.storage.saveSnapshot();
|
|
1063
|
+
const { data, savedAt } = this.storage.getSnapshot();
|
|
1064
|
+
return reply.send({
|
|
1065
|
+
message: "Snapshot saved",
|
|
1066
|
+
savedAt: savedAt.toISOString(),
|
|
1067
|
+
collections: Object.fromEntries(
|
|
1068
|
+
Object.entries(data).map(([name, items]) => [name, items.length])
|
|
1069
|
+
)
|
|
1070
|
+
});
|
|
1071
|
+
});
|
|
1072
|
+
server.post("/_snapshot/reset", (_req, reply) => {
|
|
1073
|
+
this.storage.resetToSnapshot();
|
|
1074
|
+
const { data, savedAt } = this.storage.getSnapshot();
|
|
1075
|
+
return reply.send({
|
|
1076
|
+
message: "Database restored to snapshot",
|
|
1077
|
+
savedAt: savedAt.toISOString(),
|
|
1078
|
+
collections: Object.fromEntries(
|
|
1079
|
+
Object.entries(data).map(([name, items]) => [name, items.length])
|
|
1080
|
+
)
|
|
1081
|
+
});
|
|
1082
|
+
});
|
|
1083
|
+
}
|
|
1084
|
+
};
|
|
1085
|
+
|
|
1086
|
+
// src/router/resource.router.ts
|
|
1087
|
+
function buildResourceRouteCommands(storage, options) {
|
|
1088
|
+
const commands = [];
|
|
1089
|
+
for (const resource of Object.keys(storage.getData())) {
|
|
1090
|
+
const resourceBase = `${options.base}/${resource}`;
|
|
1091
|
+
commands.push(new CollectionRouteCommand(storage, resource, resourceBase, options));
|
|
1092
|
+
commands.push(new ItemRouteCommand(storage, resource, resourceBase));
|
|
1093
|
+
}
|
|
1094
|
+
commands.push(new NestedRouteCommand(storage, storage.getRelations(), options.base));
|
|
1095
|
+
return commands;
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
// src/server/createServer.ts
|
|
1099
|
+
var MUTATING_METHODS = /* @__PURE__ */ new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
|
1100
|
+
async function createServer(storage, options, handlers = /* @__PURE__ */ new Map()) {
|
|
1101
|
+
const server = (0, import_fastify.default)();
|
|
1102
|
+
await server.register(import_cors.default, {
|
|
1103
|
+
exposedHeaders: ["X-Total-Count"]
|
|
1104
|
+
});
|
|
1105
|
+
server.setErrorHandler((err, _req, reply) => {
|
|
1106
|
+
const status = err.statusCode ?? 500;
|
|
1107
|
+
const message = status < 500 ? err.message || "Request error" : "Internal server error";
|
|
1108
|
+
reply.status(status).send({ error: message });
|
|
1109
|
+
});
|
|
1110
|
+
if (options.readonly) {
|
|
1111
|
+
server.addHook("onRequest", (_req, reply, done) => {
|
|
1112
|
+
if (MUTATING_METHODS.has(_req.method)) {
|
|
1113
|
+
reply.status(405).header("Allow", "GET, HEAD, OPTIONS").send({ error: "Server is running in readonly mode" });
|
|
1114
|
+
}
|
|
1115
|
+
done();
|
|
1116
|
+
});
|
|
1117
|
+
}
|
|
1118
|
+
if (options.delay > 0) {
|
|
1119
|
+
server.addHook("onSend", (_req, _reply, payload, done) => {
|
|
1120
|
+
setTimeout(() => done(null, payload), options.delay);
|
|
1121
|
+
});
|
|
1122
|
+
}
|
|
1123
|
+
const commands = [
|
|
1124
|
+
new AboutRouteCommand(storage, options, handlers),
|
|
1125
|
+
...options.snapshot ? [new SnapshotRouteCommand(storage)] : [],
|
|
1126
|
+
new CustomRouteCommand(storage, options.base, handlers),
|
|
1127
|
+
...buildResourceRouteCommands(storage, options)
|
|
1128
|
+
];
|
|
1129
|
+
for (const command of commands) {
|
|
1130
|
+
command.register(server);
|
|
1131
|
+
}
|
|
1132
|
+
return server;
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
// src/config/loadOptions.ts
|
|
1136
|
+
var import_zod = require("zod");
|
|
1137
|
+
var serverOptionsSchema = import_zod.z.object({
|
|
1138
|
+
/** Path to the YAML database file. Must be a non-empty string. */
|
|
1139
|
+
file: import_zod.z.string().min(1),
|
|
1140
|
+
/** TCP port the server listens on. Accepts string input and coerces to number. */
|
|
1141
|
+
port: import_zod.z.coerce.number().int().positive().default(3070),
|
|
1142
|
+
/** Hostname or IP address to bind. */
|
|
1143
|
+
host: import_zod.z.string().default("localhost"),
|
|
1144
|
+
/**
|
|
1145
|
+
* URL prefix prepended to every route (e.g. `/api`).
|
|
1146
|
+
* A leading slash is added automatically if omitted.
|
|
1147
|
+
*/
|
|
1148
|
+
base: import_zod.z.string().default("").transform((v) => v && !v.startsWith("/") ? `/${v}` : v),
|
|
1149
|
+
/** When `true`, the server reloads the YAML file automatically on disk changes. */
|
|
1150
|
+
watch: import_zod.z.boolean().default(false),
|
|
1151
|
+
/**
|
|
1152
|
+
* When `true`, saves a snapshot of the initial database state on startup.
|
|
1153
|
+
* Exposes `/_snapshot` endpoints to inspect, restore or update the snapshot.
|
|
1154
|
+
*/
|
|
1155
|
+
snapshot: import_zod.z.boolean().default(false),
|
|
1156
|
+
/** When `true`, all mutating requests (POST, PUT, PATCH, DELETE) are rejected with 405. */
|
|
1157
|
+
readonly: import_zod.z.boolean().default(false),
|
|
1158
|
+
/** Milliseconds to delay every response, simulating network latency. `0` = disabled. */
|
|
1159
|
+
delay: import_zod.z.coerce.number().int().min(0).default(0),
|
|
1160
|
+
/**
|
|
1161
|
+
* Path to a JavaScript file exporting handler functions for custom routes.
|
|
1162
|
+
* When set, functions are loaded at startup and referenced by name via `handler:` in `_routes`.
|
|
1163
|
+
*/
|
|
1164
|
+
handlers: import_zod.z.string().optional(),
|
|
1165
|
+
/**
|
|
1166
|
+
* Wraps GET collection responses in a `{ data, pagination }` envelope.
|
|
1167
|
+
* Accepts `true` (default limit 10), `false` (disabled), or a positive integer (custom limit).
|
|
1168
|
+
*/
|
|
1169
|
+
pageable: import_zod.z.union([import_zod.z.boolean(), import_zod.z.coerce.number().int().positive()]).default(false).transform((v) => ({
|
|
1170
|
+
enabled: v !== false,
|
|
1171
|
+
limit: v === false || v === true ? 10 : v
|
|
1172
|
+
}))
|
|
1173
|
+
});
|
|
1174
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
1175
|
+
0 && (module.exports = {
|
|
1176
|
+
createServer,
|
|
1177
|
+
createYamlStorage,
|
|
1178
|
+
serverOptionsSchema
|
|
1179
|
+
});
|