@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.mjs
CHANGED
|
@@ -67,6 +67,42 @@ var init_parseRelations = __esm({
|
|
|
67
67
|
}
|
|
68
68
|
});
|
|
69
69
|
|
|
70
|
+
// src/storage/parseSchema.ts
|
|
71
|
+
function parseSchema(raw) {
|
|
72
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
|
|
73
|
+
const result = {};
|
|
74
|
+
for (const [collection, fields] of Object.entries(raw)) {
|
|
75
|
+
if (!fields || typeof fields !== "object" || Array.isArray(fields)) continue;
|
|
76
|
+
result[collection] = {};
|
|
77
|
+
for (const [field, value] of Object.entries(fields)) {
|
|
78
|
+
const def = normaliseFieldDef(value);
|
|
79
|
+
if (def) result[collection][field] = def;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return result;
|
|
83
|
+
}
|
|
84
|
+
function normaliseFieldDef(value) {
|
|
85
|
+
if (value === "required") return { required: true };
|
|
86
|
+
if (value === "optional") return { required: false };
|
|
87
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
88
|
+
const v = value;
|
|
89
|
+
const def = {};
|
|
90
|
+
if (v["required"] === true || v["required"] === false) def.required = v["required"];
|
|
91
|
+
if (typeof v["type"] === "string" && ["string", "integer", "number", "boolean", "object", "array"].includes(v["type"]))
|
|
92
|
+
def.type = v["type"];
|
|
93
|
+
if (typeof v["format"] === "string") def.format = v["format"];
|
|
94
|
+
if (Array.isArray(v["enum"])) def.enum = v["enum"];
|
|
95
|
+
if (typeof v["description"] === "string") def.description = v["description"];
|
|
96
|
+
if (v["default"] !== void 0) def.default = v["default"];
|
|
97
|
+
return def;
|
|
98
|
+
}
|
|
99
|
+
var init_parseSchema = __esm({
|
|
100
|
+
"src/storage/parseSchema.ts"() {
|
|
101
|
+
"use strict";
|
|
102
|
+
init_esm_shims();
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
70
106
|
// src/utils/deepCopy.ts
|
|
71
107
|
function deepCopyData(source) {
|
|
72
108
|
return Object.fromEntries(
|
|
@@ -88,14 +124,16 @@ __export(yrestStorage_exports, {
|
|
|
88
124
|
import { readFileSync as readFileSync2, writeFileSync, renameSync } from "fs";
|
|
89
125
|
import { resolve, dirname as dirname2 } from "path";
|
|
90
126
|
import { randomUUID as randomUUID2 } from "crypto";
|
|
91
|
-
import { parse as parse2, stringify } from "yaml";
|
|
127
|
+
import { parse as parse2, stringify as stringify2 } from "yaml";
|
|
92
128
|
function createYrestStorage(filePath) {
|
|
93
129
|
const absPath = resolve(filePath);
|
|
94
130
|
const raw = parse2(readFileSync2(absPath, "utf8")) ?? {};
|
|
131
|
+
const RESERVED = /* @__PURE__ */ new Set(["_rel", "_routes", "_schema"]);
|
|
95
132
|
const relations = parseRelations(raw["_rel"]);
|
|
96
133
|
const routes = Array.isArray(raw["_routes"]) ? raw["_routes"] : [];
|
|
134
|
+
const schema = parseSchema(raw["_schema"]);
|
|
97
135
|
const data = Object.fromEntries(
|
|
98
|
-
Object.entries(raw).filter(([key]) => key
|
|
136
|
+
Object.entries(raw).filter(([key]) => !RESERVED.has(key))
|
|
99
137
|
);
|
|
100
138
|
let snapshot = {
|
|
101
139
|
data: deepCopyData(data),
|
|
@@ -109,6 +147,9 @@ function createYrestStorage(filePath) {
|
|
|
109
147
|
getRelations() {
|
|
110
148
|
return relations;
|
|
111
149
|
},
|
|
150
|
+
getSchema() {
|
|
151
|
+
return schema;
|
|
152
|
+
},
|
|
112
153
|
getRoutes() {
|
|
113
154
|
return routes;
|
|
114
155
|
},
|
|
@@ -124,14 +165,14 @@ function createYrestStorage(filePath) {
|
|
|
124
165
|
if (routes.length > 0) payload._routes = routes;
|
|
125
166
|
Object.assign(payload, data);
|
|
126
167
|
const tmp = resolve(dirname2(absPath), `.yrest-${randomUUID2()}.tmp`);
|
|
127
|
-
writeFileSync(tmp,
|
|
168
|
+
writeFileSync(tmp, stringify2(payload), "utf8");
|
|
128
169
|
renameSync(tmp, absPath);
|
|
129
170
|
},
|
|
130
171
|
reload() {
|
|
131
172
|
const fresh = parse2(readFileSync2(absPath, "utf8")) ?? {};
|
|
132
173
|
const freshRelations = parseRelations(fresh["_rel"]);
|
|
133
174
|
const freshData = Object.fromEntries(
|
|
134
|
-
Object.entries(fresh).filter(([key]) => key
|
|
175
|
+
Object.entries(fresh).filter(([key]) => !RESERVED.has(key))
|
|
135
176
|
);
|
|
136
177
|
for (const key of Object.keys(data)) delete data[key];
|
|
137
178
|
Object.assign(data, freshData);
|
|
@@ -163,6 +204,7 @@ var init_yrestStorage = __esm({
|
|
|
163
204
|
"use strict";
|
|
164
205
|
init_esm_shims();
|
|
165
206
|
init_parseRelations();
|
|
207
|
+
init_parseSchema();
|
|
166
208
|
init_deepCopy();
|
|
167
209
|
}
|
|
168
210
|
});
|
|
@@ -215,6 +257,7 @@ function dedent(str) {
|
|
|
215
257
|
// src/api/yrestServer.ts
|
|
216
258
|
init_esm_shims();
|
|
217
259
|
init_parseRelations();
|
|
260
|
+
init_parseSchema();
|
|
218
261
|
import { resolve as resolve2 } from "path";
|
|
219
262
|
|
|
220
263
|
// src/utils/handlers.ts
|
|
@@ -348,7 +391,7 @@ function endpointRow(method, path2, desc) {
|
|
|
348
391
|
}
|
|
349
392
|
function resourceAccordion(name, base, isOpen) {
|
|
350
393
|
const p = `${base}/${name}`;
|
|
351
|
-
const
|
|
394
|
+
const singular2 = name.endsWith("s") ? name.slice(0, -1) : name;
|
|
352
395
|
const rows = [
|
|
353
396
|
endpointRow(
|
|
354
397
|
"GET",
|
|
@@ -358,20 +401,20 @@ function resourceAccordion(name, base, isOpen) {
|
|
|
358
401
|
endpointRow(
|
|
359
402
|
"POST",
|
|
360
403
|
p,
|
|
361
|
-
`Create a new ${
|
|
404
|
+
`Create a new ${singular2}. Auto-assigns <code>id</code> if not provided.`
|
|
362
405
|
),
|
|
363
|
-
endpointRow("GET", `${p}/:id`, `Get a single ${
|
|
406
|
+
endpointRow("GET", `${p}/:id`, `Get a single ${singular2} by id.`),
|
|
364
407
|
endpointRow(
|
|
365
408
|
"PUT",
|
|
366
409
|
`${p}/:id`,
|
|
367
|
-
`Fully replace a ${
|
|
410
|
+
`Fully replace a ${singular2}. Original <code>id</code> is always preserved.`
|
|
368
411
|
),
|
|
369
412
|
endpointRow(
|
|
370
413
|
"PATCH",
|
|
371
414
|
`${p}/:id`,
|
|
372
|
-
`Partially update a ${
|
|
415
|
+
`Partially update a ${singular2} \u2014 only provided fields change.`
|
|
373
416
|
),
|
|
374
|
-
endpointRow("DELETE", `${p}/:id`, `Delete a ${
|
|
417
|
+
endpointRow("DELETE", `${p}/:id`, `Delete a ${singular2} and return it as confirmation.`)
|
|
375
418
|
].join("");
|
|
376
419
|
return `
|
|
377
420
|
<details class="resource-card" ${isOpen ? "open" : ""}>
|
|
@@ -390,13 +433,13 @@ function nestedRoutesAccordion(relations, base) {
|
|
|
390
433
|
for (const [key, def] of Object.entries(fields)) {
|
|
391
434
|
const nestedBadge = def.nested ? ` ${badge("nested", "#facc15", "#facc1518")}` : "";
|
|
392
435
|
if (def.type === "many2many") {
|
|
393
|
-
const
|
|
436
|
+
const singular2 = source.endsWith("s") ? source.slice(0, -1) : source;
|
|
394
437
|
const m2mBadge = badge("many2many", "#818cf8", "#818cf818");
|
|
395
438
|
rows.push(
|
|
396
439
|
endpointRow(
|
|
397
440
|
"GET",
|
|
398
441
|
`${base}/${source}/:id/${key}`,
|
|
399
|
-
`List ${def.target} linked to a ${
|
|
442
|
+
`List ${def.target} linked to a ${singular2} via ${def.through}. ${m2mBadge}${nestedBadge}`
|
|
400
443
|
)
|
|
401
444
|
);
|
|
402
445
|
const targetSingular = def.target.endsWith("s") ? def.target.slice(0, -1) : def.target;
|
|
@@ -522,7 +565,7 @@ function examplesBlock(collections, relations, base, host, options, firstCustomR
|
|
|
522
565
|
const firstCol = collections[0];
|
|
523
566
|
if (firstCol) {
|
|
524
567
|
const p = `${host}${base}/${firstCol}`;
|
|
525
|
-
const
|
|
568
|
+
const singular2 = firstCol.endsWith("s") ? firstCol.slice(0, -1) : firstCol;
|
|
526
569
|
examples.push(
|
|
527
570
|
`# List all ${firstCol}
|
|
528
571
|
curl ${p}`,
|
|
@@ -530,17 +573,17 @@ curl ${p}`,
|
|
|
530
573
|
curl "${p}?name=value"`,
|
|
531
574
|
`# Sort and paginate
|
|
532
575
|
curl "${p}?_sort=id&_order=desc&_page=1&_limit=5"`,
|
|
533
|
-
`# Get single ${
|
|
576
|
+
`# Get single ${singular2}
|
|
534
577
|
curl ${p}/1`,
|
|
535
|
-
`# Create ${
|
|
578
|
+
`# Create ${singular2}
|
|
536
579
|
curl -X POST ${p} \\
|
|
537
580
|
-H "Content-Type: application/json" \\
|
|
538
581
|
-d '{"name":"example"}'`,
|
|
539
|
-
`# Partially update ${
|
|
582
|
+
`# Partially update ${singular2}
|
|
540
583
|
curl -X PATCH ${p}/1 \\
|
|
541
584
|
-H "Content-Type: application/json" \\
|
|
542
585
|
-d '{"name":"updated"}'`,
|
|
543
|
-
`# Delete ${
|
|
586
|
+
`# Delete ${singular2}
|
|
544
587
|
curl -X DELETE ${p}/1`
|
|
545
588
|
);
|
|
546
589
|
}
|
|
@@ -1477,6 +1520,379 @@ var NestedRouteCommand = class {
|
|
|
1477
1520
|
}
|
|
1478
1521
|
};
|
|
1479
1522
|
|
|
1523
|
+
// src/router/routes/openapi.routes.ts
|
|
1524
|
+
init_esm_shims();
|
|
1525
|
+
|
|
1526
|
+
// src/openapi/generateOpenApi.ts
|
|
1527
|
+
init_esm_shims();
|
|
1528
|
+
|
|
1529
|
+
// src/openapi/inferSchema.ts
|
|
1530
|
+
init_esm_shims();
|
|
1531
|
+
function buildCollectionSchema(items, fieldDefs = {}) {
|
|
1532
|
+
const sample = items.slice(0, 10);
|
|
1533
|
+
const inferredTypes = /* @__PURE__ */ new Map();
|
|
1534
|
+
for (const item of sample) {
|
|
1535
|
+
for (const [key, value] of Object.entries(item)) {
|
|
1536
|
+
if (!inferredTypes.has(key)) {
|
|
1537
|
+
inferredTypes.set(key, jsToOpenApiType(value));
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
const allFields = /* @__PURE__ */ new Set([...inferredTypes.keys(), ...Object.keys(fieldDefs)]);
|
|
1542
|
+
const properties = {};
|
|
1543
|
+
const required = [];
|
|
1544
|
+
for (const field of allFields) {
|
|
1545
|
+
const def = fieldDefs[field];
|
|
1546
|
+
const inferred = inferredTypes.get(field) ?? "string";
|
|
1547
|
+
const prop = {
|
|
1548
|
+
type: def?.type ?? inferred
|
|
1549
|
+
};
|
|
1550
|
+
if (def?.format) prop.format = def.format;
|
|
1551
|
+
if (def?.description) prop.description = def.description;
|
|
1552
|
+
if (def?.enum) prop.enum = def.enum;
|
|
1553
|
+
if (def?.default !== void 0) prop.default = def.default;
|
|
1554
|
+
properties[field] = prop;
|
|
1555
|
+
if (def?.required === true) required.push(field);
|
|
1556
|
+
}
|
|
1557
|
+
const schema = { type: "object", properties };
|
|
1558
|
+
if (required.length > 0) schema.required = required;
|
|
1559
|
+
return schema;
|
|
1560
|
+
}
|
|
1561
|
+
function jsToOpenApiType(value) {
|
|
1562
|
+
if (value === null || value === void 0) return "string";
|
|
1563
|
+
if (typeof value === "boolean") return "boolean";
|
|
1564
|
+
if (typeof value === "number") return Number.isInteger(value) ? "integer" : "number";
|
|
1565
|
+
if (Array.isArray(value)) return "array";
|
|
1566
|
+
if (typeof value === "object") return "object";
|
|
1567
|
+
return "string";
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
// src/openapi/buildPaths.ts
|
|
1571
|
+
init_esm_shims();
|
|
1572
|
+
var COLLECTION_QUERY_PARAMS = [
|
|
1573
|
+
{ name: "_page", in: "query", schema: { type: "integer" }, description: "Page number (1-based)" },
|
|
1574
|
+
{ name: "_limit", in: "query", schema: { type: "integer" }, description: "Items per page" },
|
|
1575
|
+
{ name: "_sort", in: "query", schema: { type: "string" }, description: "Field name to sort by" },
|
|
1576
|
+
{
|
|
1577
|
+
name: "_order",
|
|
1578
|
+
in: "query",
|
|
1579
|
+
schema: { type: "string", enum: ["asc", "desc"] },
|
|
1580
|
+
description: "Sort direction"
|
|
1581
|
+
},
|
|
1582
|
+
{
|
|
1583
|
+
name: "_q",
|
|
1584
|
+
in: "query",
|
|
1585
|
+
schema: { type: "string" },
|
|
1586
|
+
description: "Full-text search across all scalar fields (case-insensitive)"
|
|
1587
|
+
},
|
|
1588
|
+
{
|
|
1589
|
+
name: "_expand",
|
|
1590
|
+
in: "query",
|
|
1591
|
+
schema: { type: "string" },
|
|
1592
|
+
description: "Embed related parent object inline (e.g. ?_expand=user)"
|
|
1593
|
+
},
|
|
1594
|
+
{
|
|
1595
|
+
name: "_embed",
|
|
1596
|
+
in: "query",
|
|
1597
|
+
schema: { type: "string" },
|
|
1598
|
+
description: "Embed child collection into each item (e.g. ?_embed=posts)"
|
|
1599
|
+
},
|
|
1600
|
+
{
|
|
1601
|
+
name: "_fields",
|
|
1602
|
+
in: "query",
|
|
1603
|
+
schema: { type: "string" },
|
|
1604
|
+
description: "Comma-separated field projection (e.g. ?_fields=id,name)"
|
|
1605
|
+
}
|
|
1606
|
+
];
|
|
1607
|
+
var ID_PATH_PARAM = {
|
|
1608
|
+
name: "id",
|
|
1609
|
+
in: "path",
|
|
1610
|
+
required: true,
|
|
1611
|
+
schema: { type: "string" },
|
|
1612
|
+
description: "Item id"
|
|
1613
|
+
};
|
|
1614
|
+
function toOpenApiPath(fastifyPath) {
|
|
1615
|
+
return fastifyPath.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, "{$1}");
|
|
1616
|
+
}
|
|
1617
|
+
function extractPathParams(fastifyPath) {
|
|
1618
|
+
const matches = fastifyPath.match(/:([a-zA-Z_][a-zA-Z0-9_]*)/g) ?? [];
|
|
1619
|
+
return matches.map((m) => ({
|
|
1620
|
+
name: m.slice(1),
|
|
1621
|
+
in: "path",
|
|
1622
|
+
required: true,
|
|
1623
|
+
schema: { type: "string" }
|
|
1624
|
+
}));
|
|
1625
|
+
}
|
|
1626
|
+
function singular(name) {
|
|
1627
|
+
return name.endsWith("s") ? name.slice(0, -1) : name;
|
|
1628
|
+
}
|
|
1629
|
+
function schemaRef(name) {
|
|
1630
|
+
return { $ref: `#/components/schemas/${name}` };
|
|
1631
|
+
}
|
|
1632
|
+
function jsonContent(schema) {
|
|
1633
|
+
return { "application/json": { schema } };
|
|
1634
|
+
}
|
|
1635
|
+
function ok(schema, description = "OK") {
|
|
1636
|
+
return { description, content: jsonContent(schema) };
|
|
1637
|
+
}
|
|
1638
|
+
function buildCrudPaths(collection, base, schemaName) {
|
|
1639
|
+
const ref = schemaRef(schemaName);
|
|
1640
|
+
const tag = collection;
|
|
1641
|
+
const sing = singular(collection);
|
|
1642
|
+
const collPath = `${base}/${collection}`;
|
|
1643
|
+
const itemPath = `${base}/${collection}/{id}`;
|
|
1644
|
+
return {
|
|
1645
|
+
[collPath]: {
|
|
1646
|
+
get: {
|
|
1647
|
+
summary: `List ${collection}`,
|
|
1648
|
+
tags: [tag],
|
|
1649
|
+
parameters: COLLECTION_QUERY_PARAMS,
|
|
1650
|
+
responses: {
|
|
1651
|
+
"200": {
|
|
1652
|
+
description: "OK",
|
|
1653
|
+
content: jsonContent({ type: "array", items: ref }),
|
|
1654
|
+
headers: {
|
|
1655
|
+
"X-Total-Count": {
|
|
1656
|
+
description: "Total items (when using ?_page / ?_limit)",
|
|
1657
|
+
schema: { type: "integer" }
|
|
1658
|
+
}
|
|
1659
|
+
}
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
},
|
|
1663
|
+
post: {
|
|
1664
|
+
summary: `Create ${sing}`,
|
|
1665
|
+
tags: [tag],
|
|
1666
|
+
requestBody: { required: true, content: jsonContent(ref) },
|
|
1667
|
+
responses: { "201": ok(ref, "Created") }
|
|
1668
|
+
}
|
|
1669
|
+
},
|
|
1670
|
+
[itemPath]: {
|
|
1671
|
+
get: {
|
|
1672
|
+
summary: `Get ${sing}`,
|
|
1673
|
+
tags: [tag],
|
|
1674
|
+
parameters: [
|
|
1675
|
+
ID_PATH_PARAM,
|
|
1676
|
+
...COLLECTION_QUERY_PARAMS.filter(
|
|
1677
|
+
(p) => ["_expand", "_embed", "_fields"].includes(p.name)
|
|
1678
|
+
)
|
|
1679
|
+
],
|
|
1680
|
+
responses: { "200": ok(ref), "404": { description: "Not found" } }
|
|
1681
|
+
},
|
|
1682
|
+
put: {
|
|
1683
|
+
summary: `Replace ${sing}`,
|
|
1684
|
+
tags: [tag],
|
|
1685
|
+
parameters: [ID_PATH_PARAM],
|
|
1686
|
+
requestBody: { required: true, content: jsonContent(ref) },
|
|
1687
|
+
responses: { "200": ok(ref), "404": { description: "Not found" } }
|
|
1688
|
+
},
|
|
1689
|
+
patch: {
|
|
1690
|
+
summary: `Update ${sing}`,
|
|
1691
|
+
tags: [tag],
|
|
1692
|
+
parameters: [ID_PATH_PARAM],
|
|
1693
|
+
requestBody: { required: false, content: jsonContent(ref) },
|
|
1694
|
+
responses: { "200": ok(ref), "404": { description: "Not found" } }
|
|
1695
|
+
},
|
|
1696
|
+
delete: {
|
|
1697
|
+
summary: `Delete ${sing}`,
|
|
1698
|
+
tags: [tag],
|
|
1699
|
+
parameters: [ID_PATH_PARAM],
|
|
1700
|
+
responses: { "200": ok(ref, "Deleted item returned"), "404": { description: "Not found" } }
|
|
1701
|
+
}
|
|
1702
|
+
}
|
|
1703
|
+
};
|
|
1704
|
+
}
|
|
1705
|
+
function buildRelationPaths(relations, base) {
|
|
1706
|
+
const paths = {};
|
|
1707
|
+
for (const [source, fields] of Object.entries(relations)) {
|
|
1708
|
+
for (const [key, def] of Object.entries(fields)) {
|
|
1709
|
+
if (def.type === "many2many") {
|
|
1710
|
+
const forwardPath = `${base}/${source}/{id}/${key}`;
|
|
1711
|
+
const inversePath = `${base}/${def.target}/{id}/${source}`;
|
|
1712
|
+
paths[forwardPath] = {
|
|
1713
|
+
get: {
|
|
1714
|
+
summary: `List ${def.target} linked to ${singular(source)} via ${def.through}`,
|
|
1715
|
+
tags: [source],
|
|
1716
|
+
parameters: [ID_PATH_PARAM],
|
|
1717
|
+
responses: {
|
|
1718
|
+
"200": {
|
|
1719
|
+
description: "OK",
|
|
1720
|
+
content: jsonContent({ type: "array", items: { type: "object" } })
|
|
1721
|
+
},
|
|
1722
|
+
"404": { description: `${singular(source)} not found` }
|
|
1723
|
+
}
|
|
1724
|
+
}
|
|
1725
|
+
};
|
|
1726
|
+
paths[inversePath] = {
|
|
1727
|
+
get: {
|
|
1728
|
+
summary: `List ${source} linked to ${singular(def.target)} via ${def.through} (inverse)`,
|
|
1729
|
+
tags: [def.target],
|
|
1730
|
+
parameters: [ID_PATH_PARAM],
|
|
1731
|
+
responses: {
|
|
1732
|
+
"200": {
|
|
1733
|
+
description: "OK",
|
|
1734
|
+
content: jsonContent({ type: "array", items: { type: "object" } })
|
|
1735
|
+
},
|
|
1736
|
+
"404": { description: `${singular(def.target)} not found` }
|
|
1737
|
+
}
|
|
1738
|
+
}
|
|
1739
|
+
};
|
|
1740
|
+
} else {
|
|
1741
|
+
const parentSing = singular(def.target);
|
|
1742
|
+
const collPath = `${base}/${def.target}/{id}/${source}`;
|
|
1743
|
+
const isOne2One = def.type === "one2one";
|
|
1744
|
+
const responseSchema = isOne2One ? { type: "object" } : { type: "array", items: { type: "object" } };
|
|
1745
|
+
paths[collPath] = {
|
|
1746
|
+
get: {
|
|
1747
|
+
summary: isOne2One ? `Get ${singular(source)} belonging to ${parentSing}` : `List ${source} belonging to ${parentSing}`,
|
|
1748
|
+
tags: [def.target],
|
|
1749
|
+
parameters: [ID_PATH_PARAM],
|
|
1750
|
+
responses: {
|
|
1751
|
+
"200": { description: "OK", content: jsonContent(responseSchema) },
|
|
1752
|
+
"404": { description: `${parentSing} not found` }
|
|
1753
|
+
}
|
|
1754
|
+
}
|
|
1755
|
+
};
|
|
1756
|
+
if (!isOne2One) {
|
|
1757
|
+
const itemPath = `${base}/${def.target}/{id}/${source}/{childId}`;
|
|
1758
|
+
paths[itemPath] = {
|
|
1759
|
+
get: {
|
|
1760
|
+
summary: `Get single ${singular(source)} scoped to ${parentSing}`,
|
|
1761
|
+
tags: [def.target],
|
|
1762
|
+
parameters: [
|
|
1763
|
+
ID_PATH_PARAM,
|
|
1764
|
+
{ name: "childId", in: "path", required: true, schema: { type: "string" } }
|
|
1765
|
+
],
|
|
1766
|
+
responses: {
|
|
1767
|
+
"200": { description: "OK", content: jsonContent({ type: "object" }) },
|
|
1768
|
+
"404": { description: "Not found" }
|
|
1769
|
+
}
|
|
1770
|
+
}
|
|
1771
|
+
};
|
|
1772
|
+
}
|
|
1773
|
+
}
|
|
1774
|
+
}
|
|
1775
|
+
}
|
|
1776
|
+
return paths;
|
|
1777
|
+
}
|
|
1778
|
+
function buildCustomRoutePaths(routes, base) {
|
|
1779
|
+
const paths = {};
|
|
1780
|
+
for (const route of routes) {
|
|
1781
|
+
const openApiPath = toOpenApiPath(`${base}${route.path}`);
|
|
1782
|
+
const method = route.method.toLowerCase();
|
|
1783
|
+
const pathParams = extractPathParams(route.path);
|
|
1784
|
+
const responses = {};
|
|
1785
|
+
if (route.error) {
|
|
1786
|
+
responses[String(route.error)] = { description: `Forced error ${route.error}` };
|
|
1787
|
+
} else {
|
|
1788
|
+
const statuses = /* @__PURE__ */ new Set();
|
|
1789
|
+
for (const s of route.scenarios ?? []) statuses.add(s.response.status ?? 200);
|
|
1790
|
+
if (route.otherwise) statuses.add(route.otherwise.status ?? 200);
|
|
1791
|
+
if (route.response) statuses.add(route.response.status ?? 200);
|
|
1792
|
+
if (statuses.size === 0) statuses.add(200);
|
|
1793
|
+
for (const status of statuses) {
|
|
1794
|
+
const bodySource = (route.scenarios ?? []).find((s) => (s.response.status ?? 200) === status)?.response.body ?? (route.otherwise?.status ?? 200) === status ? route.otherwise?.body : route.response?.body;
|
|
1795
|
+
responses[String(status)] = {
|
|
1796
|
+
description: status < 400 ? "OK" : "Error",
|
|
1797
|
+
...bodySource != null ? { content: jsonContent(inferResponseSchema(bodySource)) } : {}
|
|
1798
|
+
};
|
|
1799
|
+
}
|
|
1800
|
+
}
|
|
1801
|
+
const desc = route.handler ? `Handler: ${route.handler}()` : route.scenarios?.length ? `Conditional scenarios (${route.scenarios.length})` : "Custom static route";
|
|
1802
|
+
const operation = {
|
|
1803
|
+
summary: `${route.method.toUpperCase()} ${route.path}`,
|
|
1804
|
+
description: desc,
|
|
1805
|
+
tags: ["custom"],
|
|
1806
|
+
...pathParams.length > 0 ? { parameters: pathParams } : {},
|
|
1807
|
+
responses
|
|
1808
|
+
};
|
|
1809
|
+
if (!paths[openApiPath]) paths[openApiPath] = {};
|
|
1810
|
+
paths[openApiPath][method] = operation;
|
|
1811
|
+
}
|
|
1812
|
+
return paths;
|
|
1813
|
+
}
|
|
1814
|
+
function inferResponseSchema(body) {
|
|
1815
|
+
if (body === null || body === void 0) return {};
|
|
1816
|
+
if (typeof body !== "object" || Array.isArray(body)) return { type: "object" };
|
|
1817
|
+
const properties = {};
|
|
1818
|
+
for (const [key, value] of Object.entries(body)) {
|
|
1819
|
+
properties[key] = { type: jsToOpenApiType2(value) };
|
|
1820
|
+
}
|
|
1821
|
+
return { type: "object", properties };
|
|
1822
|
+
}
|
|
1823
|
+
function jsToOpenApiType2(value) {
|
|
1824
|
+
if (value === null || value === void 0) return "string";
|
|
1825
|
+
if (typeof value === "boolean") return "boolean";
|
|
1826
|
+
if (typeof value === "number") return Number.isInteger(value) ? "integer" : "number";
|
|
1827
|
+
if (Array.isArray(value)) return "array";
|
|
1828
|
+
if (typeof value === "object") return "object";
|
|
1829
|
+
return "string";
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
// src/openapi/generateOpenApi.ts
|
|
1833
|
+
function generateOpenApi(storage, options, title = "yRest API") {
|
|
1834
|
+
const collections = Object.keys(storage.getData());
|
|
1835
|
+
const relations = storage.getRelations();
|
|
1836
|
+
const schemaBlock = storage.getSchema();
|
|
1837
|
+
const customRoutes = storage.getRoutes();
|
|
1838
|
+
const base = options.base ?? "";
|
|
1839
|
+
const schemas = {};
|
|
1840
|
+
for (const collection of collections) {
|
|
1841
|
+
const items = storage.getCollection(collection) ?? [];
|
|
1842
|
+
const fieldDefs = schemaBlock[collection] ?? {};
|
|
1843
|
+
const schemaName = toSchemaName(collection);
|
|
1844
|
+
schemas[schemaName] = buildCollectionSchema(items, fieldDefs);
|
|
1845
|
+
}
|
|
1846
|
+
const paths = {};
|
|
1847
|
+
for (const collection of collections) {
|
|
1848
|
+
const schemaName = toSchemaName(collection);
|
|
1849
|
+
Object.assign(paths, buildCrudPaths(collection, base, schemaName));
|
|
1850
|
+
}
|
|
1851
|
+
Object.assign(paths, buildRelationPaths(relations, base));
|
|
1852
|
+
Object.assign(paths, buildCustomRoutePaths(customRoutes, base));
|
|
1853
|
+
return {
|
|
1854
|
+
openapi: "3.0.3",
|
|
1855
|
+
info: {
|
|
1856
|
+
title,
|
|
1857
|
+
version: "1.0.0",
|
|
1858
|
+
description: "Generated by yRest from db.yml"
|
|
1859
|
+
},
|
|
1860
|
+
servers: [
|
|
1861
|
+
{
|
|
1862
|
+
url: `http://${options.host}:${options.port}${base}`,
|
|
1863
|
+
description: "yRest mock server"
|
|
1864
|
+
}
|
|
1865
|
+
],
|
|
1866
|
+
paths,
|
|
1867
|
+
components: { schemas }
|
|
1868
|
+
};
|
|
1869
|
+
}
|
|
1870
|
+
function toSchemaName(collection) {
|
|
1871
|
+
const withoutS = collection.endsWith("s") ? collection.slice(0, -1) : collection;
|
|
1872
|
+
return withoutS.charAt(0).toUpperCase() + withoutS.slice(1);
|
|
1873
|
+
}
|
|
1874
|
+
|
|
1875
|
+
// src/router/routes/openapi.routes.ts
|
|
1876
|
+
import { stringify } from "yaml";
|
|
1877
|
+
var OpenApiRouteCommand = class {
|
|
1878
|
+
constructor(storage, options) {
|
|
1879
|
+
this.storage = storage;
|
|
1880
|
+
this.options = options;
|
|
1881
|
+
}
|
|
1882
|
+
storage;
|
|
1883
|
+
options;
|
|
1884
|
+
register(server) {
|
|
1885
|
+
server.get("/_openapi", (_req, reply) => {
|
|
1886
|
+
const doc = generateOpenApi(this.storage, this.options);
|
|
1887
|
+
reply.header("Content-Type", "text/yaml; charset=utf-8");
|
|
1888
|
+
return reply.send(stringify(doc, { lineWidth: 0, aliasDuplicateObjects: false }));
|
|
1889
|
+
});
|
|
1890
|
+
server.get("/_openapi.json", (_req, reply) => {
|
|
1891
|
+
return reply.send(generateOpenApi(this.storage, this.options));
|
|
1892
|
+
});
|
|
1893
|
+
}
|
|
1894
|
+
};
|
|
1895
|
+
|
|
1480
1896
|
// src/router/routes/snapshot.routes.ts
|
|
1481
1897
|
init_esm_shims();
|
|
1482
1898
|
var SnapshotRouteCommand = class {
|
|
@@ -1558,6 +1974,7 @@ async function createServer(storage, options, handlers = /* @__PURE__ */ new Map
|
|
|
1558
1974
|
}
|
|
1559
1975
|
const commands = [
|
|
1560
1976
|
new AboutRouteCommand(storage, options, handlers),
|
|
1977
|
+
new OpenApiRouteCommand(storage, options),
|
|
1561
1978
|
...options.snapshot ? [new SnapshotRouteCommand(storage)] : [],
|
|
1562
1979
|
new CustomRouteCommand(storage, options.base, handlers),
|
|
1563
1980
|
...buildResourceRouteCommands(storage, options)
|
|
@@ -1637,10 +2054,12 @@ function buildOptions(opts) {
|
|
|
1637
2054
|
}
|
|
1638
2055
|
function createInMemoryStorage(data) {
|
|
1639
2056
|
const raw = data;
|
|
2057
|
+
const RESERVED = /* @__PURE__ */ new Set(["_rel", "_routes", "_schema"]);
|
|
1640
2058
|
const relations = parseRelations(raw["_rel"]);
|
|
1641
2059
|
const routes = Array.isArray(raw["_routes"]) ? raw["_routes"] : [];
|
|
2060
|
+
const schema = parseSchema(raw["_schema"]);
|
|
1642
2061
|
const collections = Object.fromEntries(
|
|
1643
|
-
Object.entries(raw).filter(([k]) => k
|
|
2062
|
+
Object.entries(raw).filter(([k]) => !RESERVED.has(k))
|
|
1644
2063
|
);
|
|
1645
2064
|
let snapshot = {
|
|
1646
2065
|
data: deepCopyData(collections),
|
|
@@ -1650,6 +2069,7 @@ function createInMemoryStorage(data) {
|
|
|
1650
2069
|
return {
|
|
1651
2070
|
getData: () => collections,
|
|
1652
2071
|
getRelations: () => relations,
|
|
2072
|
+
getSchema: () => schema,
|
|
1653
2073
|
getRoutes: () => routes,
|
|
1654
2074
|
getCollection: (name) => collections[name],
|
|
1655
2075
|
setCollection: (name, items) => {
|
package/package.json
CHANGED