@yrest/cli 0.9.0 → 0.10.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 +103 -29
- package/dist/cli/index.js +450 -21
- package/dist/cli/index.mjs +447 -18
- package/dist/index.d.mts +44 -0
- package/dist/index.d.ts +44 -0
- package/dist/index.js +441 -21
- package/dist/index.mjs +438 -18
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -4,6 +4,48 @@ import * as http from 'http';
|
|
|
4
4
|
|
|
5
5
|
/** A single REST resource item. Field names and value types are user-defined in the YAML file. */
|
|
6
6
|
type Resource = Record<string, unknown>;
|
|
7
|
+
/**
|
|
8
|
+
* Descriptor for a single field declared in the `_schema` block.
|
|
9
|
+
*
|
|
10
|
+
* All properties are optional — omit what you don't need. Fields not mentioned
|
|
11
|
+
* in `_schema` are inferred from the collection data and treated as optional.
|
|
12
|
+
*
|
|
13
|
+
* Shorthand: `fieldName: required` normalises to `{ required: true }`.
|
|
14
|
+
*/
|
|
15
|
+
type FieldDef = {
|
|
16
|
+
/** If `true`, the field is listed in the OpenAPI `required` array and (Phase B) validated on POST/PUT. */
|
|
17
|
+
required?: boolean;
|
|
18
|
+
/** Explicit type override. If absent, inferred from the data. */
|
|
19
|
+
type?: "string" | "integer" | "number" | "boolean" | "object" | "array";
|
|
20
|
+
/** OpenAPI `format` hint (e.g. `email`, `date`, `uuid`, `uri`, `date-time`). */
|
|
21
|
+
format?: string;
|
|
22
|
+
/** Restricts the field to a fixed set of values. */
|
|
23
|
+
enum?: unknown[];
|
|
24
|
+
/** Human-readable description included in the OpenAPI spec. */
|
|
25
|
+
description?: string;
|
|
26
|
+
/** Default value included in the OpenAPI spec. */
|
|
27
|
+
default?: unknown;
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* Field-level schema declarations for one or more collections, parsed from `_schema` in the YAML.
|
|
31
|
+
*
|
|
32
|
+
* - Outer key: collection name.
|
|
33
|
+
* - Inner key: field name within that collection.
|
|
34
|
+
* - Value: {@link FieldDef} descriptor (or the string shorthand `"required"` / `"optional"`).
|
|
35
|
+
*
|
|
36
|
+
* @example
|
|
37
|
+
* ```yaml
|
|
38
|
+
* _schema:
|
|
39
|
+
* users:
|
|
40
|
+
* name: required # shorthand
|
|
41
|
+
* email:
|
|
42
|
+
* required: true
|
|
43
|
+
* format: email
|
|
44
|
+
* age:
|
|
45
|
+
* type: integer
|
|
46
|
+
* ```
|
|
47
|
+
*/
|
|
48
|
+
type SchemaBlock = Record<string, Record<string, FieldDef>>;
|
|
7
49
|
/**
|
|
8
50
|
* The full in-memory database.
|
|
9
51
|
* Keys are collection names (e.g. `"users"`); values are arrays of {@link Resource} items.
|
|
@@ -180,6 +222,8 @@ interface YrestStorage {
|
|
|
180
222
|
getData(): Data;
|
|
181
223
|
/** Returns the relational mappings declared under `_rel`. */
|
|
182
224
|
getRelations(): Relations;
|
|
225
|
+
/** Returns the field-level schema declarations from `_schema`, or `{}` if absent. */
|
|
226
|
+
getSchema(): SchemaBlock;
|
|
183
227
|
/**
|
|
184
228
|
* Returns the items in a named collection, or `undefined` if it does not exist.
|
|
185
229
|
*
|
package/dist/index.js
CHANGED
|
@@ -90,6 +90,42 @@ var init_parseRelations = __esm({
|
|
|
90
90
|
}
|
|
91
91
|
});
|
|
92
92
|
|
|
93
|
+
// src/storage/parseSchema.ts
|
|
94
|
+
function parseSchema(raw) {
|
|
95
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
|
|
96
|
+
const result = {};
|
|
97
|
+
for (const [collection, fields] of Object.entries(raw)) {
|
|
98
|
+
if (!fields || typeof fields !== "object" || Array.isArray(fields)) continue;
|
|
99
|
+
result[collection] = {};
|
|
100
|
+
for (const [field, value] of Object.entries(fields)) {
|
|
101
|
+
const def = normaliseFieldDef(value);
|
|
102
|
+
if (def) result[collection][field] = def;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return result;
|
|
106
|
+
}
|
|
107
|
+
function normaliseFieldDef(value) {
|
|
108
|
+
if (value === "required") return { required: true };
|
|
109
|
+
if (value === "optional") return { required: false };
|
|
110
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
111
|
+
const v = value;
|
|
112
|
+
const def = {};
|
|
113
|
+
if (v["required"] === true || v["required"] === false) def.required = v["required"];
|
|
114
|
+
if (typeof v["type"] === "string" && ["string", "integer", "number", "boolean", "object", "array"].includes(v["type"]))
|
|
115
|
+
def.type = v["type"];
|
|
116
|
+
if (typeof v["format"] === "string") def.format = v["format"];
|
|
117
|
+
if (Array.isArray(v["enum"])) def.enum = v["enum"];
|
|
118
|
+
if (typeof v["description"] === "string") def.description = v["description"];
|
|
119
|
+
if (v["default"] !== void 0) def.default = v["default"];
|
|
120
|
+
return def;
|
|
121
|
+
}
|
|
122
|
+
var init_parseSchema = __esm({
|
|
123
|
+
"src/storage/parseSchema.ts"() {
|
|
124
|
+
"use strict";
|
|
125
|
+
init_cjs_shims();
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
|
|
93
129
|
// src/utils/deepCopy.ts
|
|
94
130
|
function deepCopyData(source) {
|
|
95
131
|
return Object.fromEntries(
|
|
@@ -110,11 +146,13 @@ __export(yrestStorage_exports, {
|
|
|
110
146
|
});
|
|
111
147
|
function createYrestStorage(filePath) {
|
|
112
148
|
const absPath = (0, import_node_path2.resolve)(filePath);
|
|
113
|
-
const raw = (0,
|
|
149
|
+
const raw = (0, import_yaml3.parse)((0, import_node_fs3.readFileSync)(absPath, "utf8")) ?? {};
|
|
150
|
+
const RESERVED = /* @__PURE__ */ new Set(["_rel", "_routes", "_schema"]);
|
|
114
151
|
const relations = parseRelations(raw["_rel"]);
|
|
115
152
|
const routes = Array.isArray(raw["_routes"]) ? raw["_routes"] : [];
|
|
153
|
+
const schema = parseSchema(raw["_schema"]);
|
|
116
154
|
const data = Object.fromEntries(
|
|
117
|
-
Object.entries(raw).filter(([key]) => key
|
|
155
|
+
Object.entries(raw).filter(([key]) => !RESERVED.has(key))
|
|
118
156
|
);
|
|
119
157
|
let snapshot = {
|
|
120
158
|
data: deepCopyData(data),
|
|
@@ -128,6 +166,9 @@ function createYrestStorage(filePath) {
|
|
|
128
166
|
getRelations() {
|
|
129
167
|
return relations;
|
|
130
168
|
},
|
|
169
|
+
getSchema() {
|
|
170
|
+
return schema;
|
|
171
|
+
},
|
|
131
172
|
getRoutes() {
|
|
132
173
|
return routes;
|
|
133
174
|
},
|
|
@@ -143,14 +184,14 @@ function createYrestStorage(filePath) {
|
|
|
143
184
|
if (routes.length > 0) payload._routes = routes;
|
|
144
185
|
Object.assign(payload, data);
|
|
145
186
|
const tmp = (0, import_node_path2.resolve)((0, import_node_path2.dirname)(absPath), `.yrest-${(0, import_node_crypto2.randomUUID)()}.tmp`);
|
|
146
|
-
(0, import_node_fs3.writeFileSync)(tmp, (0,
|
|
187
|
+
(0, import_node_fs3.writeFileSync)(tmp, (0, import_yaml3.stringify)(payload), "utf8");
|
|
147
188
|
(0, import_node_fs3.renameSync)(tmp, absPath);
|
|
148
189
|
},
|
|
149
190
|
reload() {
|
|
150
|
-
const fresh = (0,
|
|
191
|
+
const fresh = (0, import_yaml3.parse)((0, import_node_fs3.readFileSync)(absPath, "utf8")) ?? {};
|
|
151
192
|
const freshRelations = parseRelations(fresh["_rel"]);
|
|
152
193
|
const freshData = Object.fromEntries(
|
|
153
|
-
Object.entries(fresh).filter(([key]) => key
|
|
194
|
+
Object.entries(fresh).filter(([key]) => !RESERVED.has(key))
|
|
154
195
|
);
|
|
155
196
|
for (const key of Object.keys(data)) delete data[key];
|
|
156
197
|
Object.assign(data, freshData);
|
|
@@ -177,7 +218,7 @@ function createYrestStorage(filePath) {
|
|
|
177
218
|
}
|
|
178
219
|
};
|
|
179
220
|
}
|
|
180
|
-
var import_node_fs3, import_node_path2, import_node_crypto2,
|
|
221
|
+
var import_node_fs3, import_node_path2, import_node_crypto2, import_yaml3;
|
|
181
222
|
var init_yrestStorage = __esm({
|
|
182
223
|
"src/storage/yrestStorage.ts"() {
|
|
183
224
|
"use strict";
|
|
@@ -185,8 +226,9 @@ var init_yrestStorage = __esm({
|
|
|
185
226
|
import_node_fs3 = require("fs");
|
|
186
227
|
import_node_path2 = require("path");
|
|
187
228
|
import_node_crypto2 = require("crypto");
|
|
188
|
-
|
|
229
|
+
import_yaml3 = require("yaml");
|
|
189
230
|
init_parseRelations();
|
|
231
|
+
init_parseSchema();
|
|
190
232
|
init_deepCopy();
|
|
191
233
|
}
|
|
192
234
|
});
|
|
@@ -250,6 +292,7 @@ function dedent(str) {
|
|
|
250
292
|
init_cjs_shims();
|
|
251
293
|
var import_node_path3 = require("path");
|
|
252
294
|
init_parseRelations();
|
|
295
|
+
init_parseSchema();
|
|
253
296
|
|
|
254
297
|
// src/utils/handlers.ts
|
|
255
298
|
init_cjs_shims();
|
|
@@ -382,7 +425,7 @@ function endpointRow(method, path, desc) {
|
|
|
382
425
|
}
|
|
383
426
|
function resourceAccordion(name, base, isOpen) {
|
|
384
427
|
const p = `${base}/${name}`;
|
|
385
|
-
const
|
|
428
|
+
const singular2 = name.endsWith("s") ? name.slice(0, -1) : name;
|
|
386
429
|
const rows = [
|
|
387
430
|
endpointRow(
|
|
388
431
|
"GET",
|
|
@@ -392,20 +435,20 @@ function resourceAccordion(name, base, isOpen) {
|
|
|
392
435
|
endpointRow(
|
|
393
436
|
"POST",
|
|
394
437
|
p,
|
|
395
|
-
`Create a new ${
|
|
438
|
+
`Create a new ${singular2}. Auto-assigns <code>id</code> if not provided.`
|
|
396
439
|
),
|
|
397
|
-
endpointRow("GET", `${p}/:id`, `Get a single ${
|
|
440
|
+
endpointRow("GET", `${p}/:id`, `Get a single ${singular2} by id.`),
|
|
398
441
|
endpointRow(
|
|
399
442
|
"PUT",
|
|
400
443
|
`${p}/:id`,
|
|
401
|
-
`Fully replace a ${
|
|
444
|
+
`Fully replace a ${singular2}. Original <code>id</code> is always preserved.`
|
|
402
445
|
),
|
|
403
446
|
endpointRow(
|
|
404
447
|
"PATCH",
|
|
405
448
|
`${p}/:id`,
|
|
406
|
-
`Partially update a ${
|
|
449
|
+
`Partially update a ${singular2} \u2014 only provided fields change.`
|
|
407
450
|
),
|
|
408
|
-
endpointRow("DELETE", `${p}/:id`, `Delete a ${
|
|
451
|
+
endpointRow("DELETE", `${p}/:id`, `Delete a ${singular2} and return it as confirmation.`)
|
|
409
452
|
].join("");
|
|
410
453
|
return `
|
|
411
454
|
<details class="resource-card" ${isOpen ? "open" : ""}>
|
|
@@ -424,13 +467,13 @@ function nestedRoutesAccordion(relations, base) {
|
|
|
424
467
|
for (const [key, def] of Object.entries(fields)) {
|
|
425
468
|
const nestedBadge = def.nested ? ` ${badge("nested", "#facc15", "#facc1518")}` : "";
|
|
426
469
|
if (def.type === "many2many") {
|
|
427
|
-
const
|
|
470
|
+
const singular2 = source.endsWith("s") ? source.slice(0, -1) : source;
|
|
428
471
|
const m2mBadge = badge("many2many", "#818cf8", "#818cf818");
|
|
429
472
|
rows.push(
|
|
430
473
|
endpointRow(
|
|
431
474
|
"GET",
|
|
432
475
|
`${base}/${source}/:id/${key}`,
|
|
433
|
-
`List ${def.target} linked to a ${
|
|
476
|
+
`List ${def.target} linked to a ${singular2} via ${def.through}. ${m2mBadge}${nestedBadge}`
|
|
434
477
|
)
|
|
435
478
|
);
|
|
436
479
|
const targetSingular = def.target.endsWith("s") ? def.target.slice(0, -1) : def.target;
|
|
@@ -556,7 +599,7 @@ function examplesBlock(collections, relations, base, host, options, firstCustomR
|
|
|
556
599
|
const firstCol = collections[0];
|
|
557
600
|
if (firstCol) {
|
|
558
601
|
const p = `${host}${base}/${firstCol}`;
|
|
559
|
-
const
|
|
602
|
+
const singular2 = firstCol.endsWith("s") ? firstCol.slice(0, -1) : firstCol;
|
|
560
603
|
examples.push(
|
|
561
604
|
`# List all ${firstCol}
|
|
562
605
|
curl ${p}`,
|
|
@@ -564,17 +607,17 @@ curl ${p}`,
|
|
|
564
607
|
curl "${p}?name=value"`,
|
|
565
608
|
`# Sort and paginate
|
|
566
609
|
curl "${p}?_sort=id&_order=desc&_page=1&_limit=5"`,
|
|
567
|
-
`# Get single ${
|
|
610
|
+
`# Get single ${singular2}
|
|
568
611
|
curl ${p}/1`,
|
|
569
|
-
`# Create ${
|
|
612
|
+
`# Create ${singular2}
|
|
570
613
|
curl -X POST ${p} \\
|
|
571
614
|
-H "Content-Type: application/json" \\
|
|
572
615
|
-d '{"name":"example"}'`,
|
|
573
|
-
`# Partially update ${
|
|
616
|
+
`# Partially update ${singular2}
|
|
574
617
|
curl -X PATCH ${p}/1 \\
|
|
575
618
|
-H "Content-Type: application/json" \\
|
|
576
619
|
-d '{"name":"updated"}'`,
|
|
577
|
-
`# Delete ${
|
|
620
|
+
`# Delete ${singular2}
|
|
578
621
|
curl -X DELETE ${p}/1`
|
|
579
622
|
);
|
|
580
623
|
}
|
|
@@ -1511,6 +1554,379 @@ var NestedRouteCommand = class {
|
|
|
1511
1554
|
}
|
|
1512
1555
|
};
|
|
1513
1556
|
|
|
1557
|
+
// src/router/routes/openapi.routes.ts
|
|
1558
|
+
init_cjs_shims();
|
|
1559
|
+
|
|
1560
|
+
// src/openapi/generateOpenApi.ts
|
|
1561
|
+
init_cjs_shims();
|
|
1562
|
+
|
|
1563
|
+
// src/openapi/inferSchema.ts
|
|
1564
|
+
init_cjs_shims();
|
|
1565
|
+
function buildCollectionSchema(items, fieldDefs = {}) {
|
|
1566
|
+
const sample = items.slice(0, 10);
|
|
1567
|
+
const inferredTypes = /* @__PURE__ */ new Map();
|
|
1568
|
+
for (const item of sample) {
|
|
1569
|
+
for (const [key, value] of Object.entries(item)) {
|
|
1570
|
+
if (!inferredTypes.has(key)) {
|
|
1571
|
+
inferredTypes.set(key, jsToOpenApiType(value));
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
const allFields = /* @__PURE__ */ new Set([...inferredTypes.keys(), ...Object.keys(fieldDefs)]);
|
|
1576
|
+
const properties = {};
|
|
1577
|
+
const required = [];
|
|
1578
|
+
for (const field of allFields) {
|
|
1579
|
+
const def = fieldDefs[field];
|
|
1580
|
+
const inferred = inferredTypes.get(field) ?? "string";
|
|
1581
|
+
const prop = {
|
|
1582
|
+
type: def?.type ?? inferred
|
|
1583
|
+
};
|
|
1584
|
+
if (def?.format) prop.format = def.format;
|
|
1585
|
+
if (def?.description) prop.description = def.description;
|
|
1586
|
+
if (def?.enum) prop.enum = def.enum;
|
|
1587
|
+
if (def?.default !== void 0) prop.default = def.default;
|
|
1588
|
+
properties[field] = prop;
|
|
1589
|
+
if (def?.required === true) required.push(field);
|
|
1590
|
+
}
|
|
1591
|
+
const schema = { type: "object", properties };
|
|
1592
|
+
if (required.length > 0) schema.required = required;
|
|
1593
|
+
return schema;
|
|
1594
|
+
}
|
|
1595
|
+
function jsToOpenApiType(value) {
|
|
1596
|
+
if (value === null || value === void 0) return "string";
|
|
1597
|
+
if (typeof value === "boolean") return "boolean";
|
|
1598
|
+
if (typeof value === "number") return Number.isInteger(value) ? "integer" : "number";
|
|
1599
|
+
if (Array.isArray(value)) return "array";
|
|
1600
|
+
if (typeof value === "object") return "object";
|
|
1601
|
+
return "string";
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
// src/openapi/buildPaths.ts
|
|
1605
|
+
init_cjs_shims();
|
|
1606
|
+
var COLLECTION_QUERY_PARAMS = [
|
|
1607
|
+
{ name: "_page", in: "query", schema: { type: "integer" }, description: "Page number (1-based)" },
|
|
1608
|
+
{ name: "_limit", in: "query", schema: { type: "integer" }, description: "Items per page" },
|
|
1609
|
+
{ name: "_sort", in: "query", schema: { type: "string" }, description: "Field name to sort by" },
|
|
1610
|
+
{
|
|
1611
|
+
name: "_order",
|
|
1612
|
+
in: "query",
|
|
1613
|
+
schema: { type: "string", enum: ["asc", "desc"] },
|
|
1614
|
+
description: "Sort direction"
|
|
1615
|
+
},
|
|
1616
|
+
{
|
|
1617
|
+
name: "_q",
|
|
1618
|
+
in: "query",
|
|
1619
|
+
schema: { type: "string" },
|
|
1620
|
+
description: "Full-text search across all scalar fields (case-insensitive)"
|
|
1621
|
+
},
|
|
1622
|
+
{
|
|
1623
|
+
name: "_expand",
|
|
1624
|
+
in: "query",
|
|
1625
|
+
schema: { type: "string" },
|
|
1626
|
+
description: "Embed related parent object inline (e.g. ?_expand=user)"
|
|
1627
|
+
},
|
|
1628
|
+
{
|
|
1629
|
+
name: "_embed",
|
|
1630
|
+
in: "query",
|
|
1631
|
+
schema: { type: "string" },
|
|
1632
|
+
description: "Embed child collection into each item (e.g. ?_embed=posts)"
|
|
1633
|
+
},
|
|
1634
|
+
{
|
|
1635
|
+
name: "_fields",
|
|
1636
|
+
in: "query",
|
|
1637
|
+
schema: { type: "string" },
|
|
1638
|
+
description: "Comma-separated field projection (e.g. ?_fields=id,name)"
|
|
1639
|
+
}
|
|
1640
|
+
];
|
|
1641
|
+
var ID_PATH_PARAM = {
|
|
1642
|
+
name: "id",
|
|
1643
|
+
in: "path",
|
|
1644
|
+
required: true,
|
|
1645
|
+
schema: { type: "string" },
|
|
1646
|
+
description: "Item id"
|
|
1647
|
+
};
|
|
1648
|
+
function toOpenApiPath(fastifyPath) {
|
|
1649
|
+
return fastifyPath.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, "{$1}");
|
|
1650
|
+
}
|
|
1651
|
+
function extractPathParams(fastifyPath) {
|
|
1652
|
+
const matches = fastifyPath.match(/:([a-zA-Z_][a-zA-Z0-9_]*)/g) ?? [];
|
|
1653
|
+
return matches.map((m) => ({
|
|
1654
|
+
name: m.slice(1),
|
|
1655
|
+
in: "path",
|
|
1656
|
+
required: true,
|
|
1657
|
+
schema: { type: "string" }
|
|
1658
|
+
}));
|
|
1659
|
+
}
|
|
1660
|
+
function singular(name) {
|
|
1661
|
+
return name.endsWith("s") ? name.slice(0, -1) : name;
|
|
1662
|
+
}
|
|
1663
|
+
function schemaRef(name) {
|
|
1664
|
+
return { $ref: `#/components/schemas/${name}` };
|
|
1665
|
+
}
|
|
1666
|
+
function jsonContent(schema) {
|
|
1667
|
+
return { "application/json": { schema } };
|
|
1668
|
+
}
|
|
1669
|
+
function ok(schema, description = "OK") {
|
|
1670
|
+
return { description, content: jsonContent(schema) };
|
|
1671
|
+
}
|
|
1672
|
+
function buildCrudPaths(collection, base, schemaName) {
|
|
1673
|
+
const ref = schemaRef(schemaName);
|
|
1674
|
+
const tag = collection;
|
|
1675
|
+
const sing = singular(collection);
|
|
1676
|
+
const collPath = `${base}/${collection}`;
|
|
1677
|
+
const itemPath = `${base}/${collection}/{id}`;
|
|
1678
|
+
return {
|
|
1679
|
+
[collPath]: {
|
|
1680
|
+
get: {
|
|
1681
|
+
summary: `List ${collection}`,
|
|
1682
|
+
tags: [tag],
|
|
1683
|
+
parameters: COLLECTION_QUERY_PARAMS,
|
|
1684
|
+
responses: {
|
|
1685
|
+
"200": {
|
|
1686
|
+
description: "OK",
|
|
1687
|
+
content: jsonContent({ type: "array", items: ref }),
|
|
1688
|
+
headers: {
|
|
1689
|
+
"X-Total-Count": {
|
|
1690
|
+
description: "Total items (when using ?_page / ?_limit)",
|
|
1691
|
+
schema: { type: "integer" }
|
|
1692
|
+
}
|
|
1693
|
+
}
|
|
1694
|
+
}
|
|
1695
|
+
}
|
|
1696
|
+
},
|
|
1697
|
+
post: {
|
|
1698
|
+
summary: `Create ${sing}`,
|
|
1699
|
+
tags: [tag],
|
|
1700
|
+
requestBody: { required: true, content: jsonContent(ref) },
|
|
1701
|
+
responses: { "201": ok(ref, "Created") }
|
|
1702
|
+
}
|
|
1703
|
+
},
|
|
1704
|
+
[itemPath]: {
|
|
1705
|
+
get: {
|
|
1706
|
+
summary: `Get ${sing}`,
|
|
1707
|
+
tags: [tag],
|
|
1708
|
+
parameters: [
|
|
1709
|
+
ID_PATH_PARAM,
|
|
1710
|
+
...COLLECTION_QUERY_PARAMS.filter(
|
|
1711
|
+
(p) => ["_expand", "_embed", "_fields"].includes(p.name)
|
|
1712
|
+
)
|
|
1713
|
+
],
|
|
1714
|
+
responses: { "200": ok(ref), "404": { description: "Not found" } }
|
|
1715
|
+
},
|
|
1716
|
+
put: {
|
|
1717
|
+
summary: `Replace ${sing}`,
|
|
1718
|
+
tags: [tag],
|
|
1719
|
+
parameters: [ID_PATH_PARAM],
|
|
1720
|
+
requestBody: { required: true, content: jsonContent(ref) },
|
|
1721
|
+
responses: { "200": ok(ref), "404": { description: "Not found" } }
|
|
1722
|
+
},
|
|
1723
|
+
patch: {
|
|
1724
|
+
summary: `Update ${sing}`,
|
|
1725
|
+
tags: [tag],
|
|
1726
|
+
parameters: [ID_PATH_PARAM],
|
|
1727
|
+
requestBody: { required: false, content: jsonContent(ref) },
|
|
1728
|
+
responses: { "200": ok(ref), "404": { description: "Not found" } }
|
|
1729
|
+
},
|
|
1730
|
+
delete: {
|
|
1731
|
+
summary: `Delete ${sing}`,
|
|
1732
|
+
tags: [tag],
|
|
1733
|
+
parameters: [ID_PATH_PARAM],
|
|
1734
|
+
responses: { "200": ok(ref, "Deleted item returned"), "404": { description: "Not found" } }
|
|
1735
|
+
}
|
|
1736
|
+
}
|
|
1737
|
+
};
|
|
1738
|
+
}
|
|
1739
|
+
function buildRelationPaths(relations, base) {
|
|
1740
|
+
const paths = {};
|
|
1741
|
+
for (const [source, fields] of Object.entries(relations)) {
|
|
1742
|
+
for (const [key, def] of Object.entries(fields)) {
|
|
1743
|
+
if (def.type === "many2many") {
|
|
1744
|
+
const forwardPath = `${base}/${source}/{id}/${key}`;
|
|
1745
|
+
const inversePath = `${base}/${def.target}/{id}/${source}`;
|
|
1746
|
+
paths[forwardPath] = {
|
|
1747
|
+
get: {
|
|
1748
|
+
summary: `List ${def.target} linked to ${singular(source)} via ${def.through}`,
|
|
1749
|
+
tags: [source],
|
|
1750
|
+
parameters: [ID_PATH_PARAM],
|
|
1751
|
+
responses: {
|
|
1752
|
+
"200": {
|
|
1753
|
+
description: "OK",
|
|
1754
|
+
content: jsonContent({ type: "array", items: { type: "object" } })
|
|
1755
|
+
},
|
|
1756
|
+
"404": { description: `${singular(source)} not found` }
|
|
1757
|
+
}
|
|
1758
|
+
}
|
|
1759
|
+
};
|
|
1760
|
+
paths[inversePath] = {
|
|
1761
|
+
get: {
|
|
1762
|
+
summary: `List ${source} linked to ${singular(def.target)} via ${def.through} (inverse)`,
|
|
1763
|
+
tags: [def.target],
|
|
1764
|
+
parameters: [ID_PATH_PARAM],
|
|
1765
|
+
responses: {
|
|
1766
|
+
"200": {
|
|
1767
|
+
description: "OK",
|
|
1768
|
+
content: jsonContent({ type: "array", items: { type: "object" } })
|
|
1769
|
+
},
|
|
1770
|
+
"404": { description: `${singular(def.target)} not found` }
|
|
1771
|
+
}
|
|
1772
|
+
}
|
|
1773
|
+
};
|
|
1774
|
+
} else {
|
|
1775
|
+
const parentSing = singular(def.target);
|
|
1776
|
+
const collPath = `${base}/${def.target}/{id}/${source}`;
|
|
1777
|
+
const isOne2One = def.type === "one2one";
|
|
1778
|
+
const responseSchema = isOne2One ? { type: "object" } : { type: "array", items: { type: "object" } };
|
|
1779
|
+
paths[collPath] = {
|
|
1780
|
+
get: {
|
|
1781
|
+
summary: isOne2One ? `Get ${singular(source)} belonging to ${parentSing}` : `List ${source} belonging to ${parentSing}`,
|
|
1782
|
+
tags: [def.target],
|
|
1783
|
+
parameters: [ID_PATH_PARAM],
|
|
1784
|
+
responses: {
|
|
1785
|
+
"200": { description: "OK", content: jsonContent(responseSchema) },
|
|
1786
|
+
"404": { description: `${parentSing} not found` }
|
|
1787
|
+
}
|
|
1788
|
+
}
|
|
1789
|
+
};
|
|
1790
|
+
if (!isOne2One) {
|
|
1791
|
+
const itemPath = `${base}/${def.target}/{id}/${source}/{childId}`;
|
|
1792
|
+
paths[itemPath] = {
|
|
1793
|
+
get: {
|
|
1794
|
+
summary: `Get single ${singular(source)} scoped to ${parentSing}`,
|
|
1795
|
+
tags: [def.target],
|
|
1796
|
+
parameters: [
|
|
1797
|
+
ID_PATH_PARAM,
|
|
1798
|
+
{ name: "childId", in: "path", required: true, schema: { type: "string" } }
|
|
1799
|
+
],
|
|
1800
|
+
responses: {
|
|
1801
|
+
"200": { description: "OK", content: jsonContent({ type: "object" }) },
|
|
1802
|
+
"404": { description: "Not found" }
|
|
1803
|
+
}
|
|
1804
|
+
}
|
|
1805
|
+
};
|
|
1806
|
+
}
|
|
1807
|
+
}
|
|
1808
|
+
}
|
|
1809
|
+
}
|
|
1810
|
+
return paths;
|
|
1811
|
+
}
|
|
1812
|
+
function buildCustomRoutePaths(routes, base) {
|
|
1813
|
+
const paths = {};
|
|
1814
|
+
for (const route of routes) {
|
|
1815
|
+
const openApiPath = toOpenApiPath(`${base}${route.path}`);
|
|
1816
|
+
const method = route.method.toLowerCase();
|
|
1817
|
+
const pathParams = extractPathParams(route.path);
|
|
1818
|
+
const responses = {};
|
|
1819
|
+
if (route.error) {
|
|
1820
|
+
responses[String(route.error)] = { description: `Forced error ${route.error}` };
|
|
1821
|
+
} else {
|
|
1822
|
+
const statuses = /* @__PURE__ */ new Set();
|
|
1823
|
+
for (const s of route.scenarios ?? []) statuses.add(s.response.status ?? 200);
|
|
1824
|
+
if (route.otherwise) statuses.add(route.otherwise.status ?? 200);
|
|
1825
|
+
if (route.response) statuses.add(route.response.status ?? 200);
|
|
1826
|
+
if (statuses.size === 0) statuses.add(200);
|
|
1827
|
+
for (const status of statuses) {
|
|
1828
|
+
const bodySource = (route.scenarios ?? []).find((s) => (s.response.status ?? 200) === status)?.response.body ?? (route.otherwise?.status ?? 200) === status ? route.otherwise?.body : route.response?.body;
|
|
1829
|
+
responses[String(status)] = {
|
|
1830
|
+
description: status < 400 ? "OK" : "Error",
|
|
1831
|
+
...bodySource != null ? { content: jsonContent(inferResponseSchema(bodySource)) } : {}
|
|
1832
|
+
};
|
|
1833
|
+
}
|
|
1834
|
+
}
|
|
1835
|
+
const desc = route.handler ? `Handler: ${route.handler}()` : route.scenarios?.length ? `Conditional scenarios (${route.scenarios.length})` : "Custom static route";
|
|
1836
|
+
const operation = {
|
|
1837
|
+
summary: `${route.method.toUpperCase()} ${route.path}`,
|
|
1838
|
+
description: desc,
|
|
1839
|
+
tags: ["custom"],
|
|
1840
|
+
...pathParams.length > 0 ? { parameters: pathParams } : {},
|
|
1841
|
+
responses
|
|
1842
|
+
};
|
|
1843
|
+
if (!paths[openApiPath]) paths[openApiPath] = {};
|
|
1844
|
+
paths[openApiPath][method] = operation;
|
|
1845
|
+
}
|
|
1846
|
+
return paths;
|
|
1847
|
+
}
|
|
1848
|
+
function inferResponseSchema(body) {
|
|
1849
|
+
if (body === null || body === void 0) return {};
|
|
1850
|
+
if (typeof body !== "object" || Array.isArray(body)) return { type: "object" };
|
|
1851
|
+
const properties = {};
|
|
1852
|
+
for (const [key, value] of Object.entries(body)) {
|
|
1853
|
+
properties[key] = { type: jsToOpenApiType2(value) };
|
|
1854
|
+
}
|
|
1855
|
+
return { type: "object", properties };
|
|
1856
|
+
}
|
|
1857
|
+
function jsToOpenApiType2(value) {
|
|
1858
|
+
if (value === null || value === void 0) return "string";
|
|
1859
|
+
if (typeof value === "boolean") return "boolean";
|
|
1860
|
+
if (typeof value === "number") return Number.isInteger(value) ? "integer" : "number";
|
|
1861
|
+
if (Array.isArray(value)) return "array";
|
|
1862
|
+
if (typeof value === "object") return "object";
|
|
1863
|
+
return "string";
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1866
|
+
// src/openapi/generateOpenApi.ts
|
|
1867
|
+
function generateOpenApi(storage, options, title = "yRest API") {
|
|
1868
|
+
const collections = Object.keys(storage.getData());
|
|
1869
|
+
const relations = storage.getRelations();
|
|
1870
|
+
const schemaBlock = storage.getSchema();
|
|
1871
|
+
const customRoutes = storage.getRoutes();
|
|
1872
|
+
const base = options.base ?? "";
|
|
1873
|
+
const schemas = {};
|
|
1874
|
+
for (const collection of collections) {
|
|
1875
|
+
const items = storage.getCollection(collection) ?? [];
|
|
1876
|
+
const fieldDefs = schemaBlock[collection] ?? {};
|
|
1877
|
+
const schemaName = toSchemaName(collection);
|
|
1878
|
+
schemas[schemaName] = buildCollectionSchema(items, fieldDefs);
|
|
1879
|
+
}
|
|
1880
|
+
const paths = {};
|
|
1881
|
+
for (const collection of collections) {
|
|
1882
|
+
const schemaName = toSchemaName(collection);
|
|
1883
|
+
Object.assign(paths, buildCrudPaths(collection, base, schemaName));
|
|
1884
|
+
}
|
|
1885
|
+
Object.assign(paths, buildRelationPaths(relations, base));
|
|
1886
|
+
Object.assign(paths, buildCustomRoutePaths(customRoutes, base));
|
|
1887
|
+
return {
|
|
1888
|
+
openapi: "3.0.3",
|
|
1889
|
+
info: {
|
|
1890
|
+
title,
|
|
1891
|
+
version: "1.0.0",
|
|
1892
|
+
description: "Generated by yRest from db.yml"
|
|
1893
|
+
},
|
|
1894
|
+
servers: [
|
|
1895
|
+
{
|
|
1896
|
+
url: `http://${options.host}:${options.port}${base}`,
|
|
1897
|
+
description: "yRest mock server"
|
|
1898
|
+
}
|
|
1899
|
+
],
|
|
1900
|
+
paths,
|
|
1901
|
+
components: { schemas }
|
|
1902
|
+
};
|
|
1903
|
+
}
|
|
1904
|
+
function toSchemaName(collection) {
|
|
1905
|
+
const withoutS = collection.endsWith("s") ? collection.slice(0, -1) : collection;
|
|
1906
|
+
return withoutS.charAt(0).toUpperCase() + withoutS.slice(1);
|
|
1907
|
+
}
|
|
1908
|
+
|
|
1909
|
+
// src/router/routes/openapi.routes.ts
|
|
1910
|
+
var import_yaml2 = require("yaml");
|
|
1911
|
+
var OpenApiRouteCommand = class {
|
|
1912
|
+
constructor(storage, options) {
|
|
1913
|
+
this.storage = storage;
|
|
1914
|
+
this.options = options;
|
|
1915
|
+
}
|
|
1916
|
+
storage;
|
|
1917
|
+
options;
|
|
1918
|
+
register(server) {
|
|
1919
|
+
server.get("/_openapi", (_req, reply) => {
|
|
1920
|
+
const doc = generateOpenApi(this.storage, this.options);
|
|
1921
|
+
reply.header("Content-Type", "text/yaml; charset=utf-8");
|
|
1922
|
+
return reply.send((0, import_yaml2.stringify)(doc, { lineWidth: 0, aliasDuplicateObjects: false }));
|
|
1923
|
+
});
|
|
1924
|
+
server.get("/_openapi.json", (_req, reply) => {
|
|
1925
|
+
return reply.send(generateOpenApi(this.storage, this.options));
|
|
1926
|
+
});
|
|
1927
|
+
}
|
|
1928
|
+
};
|
|
1929
|
+
|
|
1514
1930
|
// src/router/routes/snapshot.routes.ts
|
|
1515
1931
|
init_cjs_shims();
|
|
1516
1932
|
var SnapshotRouteCommand = class {
|
|
@@ -1592,6 +2008,7 @@ async function createServer(storage, options, handlers = /* @__PURE__ */ new Map
|
|
|
1592
2008
|
}
|
|
1593
2009
|
const commands = [
|
|
1594
2010
|
new AboutRouteCommand(storage, options, handlers),
|
|
2011
|
+
new OpenApiRouteCommand(storage, options),
|
|
1595
2012
|
...options.snapshot ? [new SnapshotRouteCommand(storage)] : [],
|
|
1596
2013
|
new CustomRouteCommand(storage, options.base, handlers),
|
|
1597
2014
|
...buildResourceRouteCommands(storage, options)
|
|
@@ -1671,10 +2088,12 @@ function buildOptions(opts) {
|
|
|
1671
2088
|
}
|
|
1672
2089
|
function createInMemoryStorage(data) {
|
|
1673
2090
|
const raw = data;
|
|
2091
|
+
const RESERVED = /* @__PURE__ */ new Set(["_rel", "_routes", "_schema"]);
|
|
1674
2092
|
const relations = parseRelations(raw["_rel"]);
|
|
1675
2093
|
const routes = Array.isArray(raw["_routes"]) ? raw["_routes"] : [];
|
|
2094
|
+
const schema = parseSchema(raw["_schema"]);
|
|
1676
2095
|
const collections = Object.fromEntries(
|
|
1677
|
-
Object.entries(raw).filter(([k]) => k
|
|
2096
|
+
Object.entries(raw).filter(([k]) => !RESERVED.has(k))
|
|
1678
2097
|
);
|
|
1679
2098
|
let snapshot = {
|
|
1680
2099
|
data: deepCopyData(collections),
|
|
@@ -1684,6 +2103,7 @@ function createInMemoryStorage(data) {
|
|
|
1684
2103
|
return {
|
|
1685
2104
|
getData: () => collections,
|
|
1686
2105
|
getRelations: () => relations,
|
|
2106
|
+
getSchema: () => schema,
|
|
1687
2107
|
getRoutes: () => routes,
|
|
1688
2108
|
getCollection: (name) => collections[name],
|
|
1689
2109
|
setCollection: (name, items) => {
|