@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/cli/index.js
CHANGED
|
@@ -593,6 +593,36 @@ function normaliseRelationDef(key, value) {
|
|
|
593
593
|
return null;
|
|
594
594
|
}
|
|
595
595
|
|
|
596
|
+
// src/storage/parseSchema.ts
|
|
597
|
+
function parseSchema(raw) {
|
|
598
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
|
|
599
|
+
const result = {};
|
|
600
|
+
for (const [collection, fields] of Object.entries(raw)) {
|
|
601
|
+
if (!fields || typeof fields !== "object" || Array.isArray(fields)) continue;
|
|
602
|
+
result[collection] = {};
|
|
603
|
+
for (const [field, value] of Object.entries(fields)) {
|
|
604
|
+
const def = normaliseFieldDef(value);
|
|
605
|
+
if (def) result[collection][field] = def;
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
return result;
|
|
609
|
+
}
|
|
610
|
+
function normaliseFieldDef(value) {
|
|
611
|
+
if (value === "required") return { required: true };
|
|
612
|
+
if (value === "optional") return { required: false };
|
|
613
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
614
|
+
const v = value;
|
|
615
|
+
const def = {};
|
|
616
|
+
if (v["required"] === true || v["required"] === false) def.required = v["required"];
|
|
617
|
+
if (typeof v["type"] === "string" && ["string", "integer", "number", "boolean", "object", "array"].includes(v["type"]))
|
|
618
|
+
def.type = v["type"];
|
|
619
|
+
if (typeof v["format"] === "string") def.format = v["format"];
|
|
620
|
+
if (Array.isArray(v["enum"])) def.enum = v["enum"];
|
|
621
|
+
if (typeof v["description"] === "string") def.description = v["description"];
|
|
622
|
+
if (v["default"] !== void 0) def.default = v["default"];
|
|
623
|
+
return def;
|
|
624
|
+
}
|
|
625
|
+
|
|
596
626
|
// src/utils/deepCopy.ts
|
|
597
627
|
function deepCopyData(source) {
|
|
598
628
|
return Object.fromEntries(
|
|
@@ -604,10 +634,12 @@ function deepCopyData(source) {
|
|
|
604
634
|
function createYrestStorage(filePath) {
|
|
605
635
|
const absPath = (0, import_node_path2.resolve)(filePath);
|
|
606
636
|
const raw = (0, import_yaml.parse)((0, import_node_fs2.readFileSync)(absPath, "utf8")) ?? {};
|
|
637
|
+
const RESERVED = /* @__PURE__ */ new Set(["_rel", "_routes", "_schema"]);
|
|
607
638
|
const relations = parseRelations(raw["_rel"]);
|
|
608
639
|
const routes = Array.isArray(raw["_routes"]) ? raw["_routes"] : [];
|
|
640
|
+
const schema = parseSchema(raw["_schema"]);
|
|
609
641
|
const data = Object.fromEntries(
|
|
610
|
-
Object.entries(raw).filter(([key]) => key
|
|
642
|
+
Object.entries(raw).filter(([key]) => !RESERVED.has(key))
|
|
611
643
|
);
|
|
612
644
|
let snapshot = {
|
|
613
645
|
data: deepCopyData(data),
|
|
@@ -621,6 +653,9 @@ function createYrestStorage(filePath) {
|
|
|
621
653
|
getRelations() {
|
|
622
654
|
return relations;
|
|
623
655
|
},
|
|
656
|
+
getSchema() {
|
|
657
|
+
return schema;
|
|
658
|
+
},
|
|
624
659
|
getRoutes() {
|
|
625
660
|
return routes;
|
|
626
661
|
},
|
|
@@ -643,7 +678,7 @@ function createYrestStorage(filePath) {
|
|
|
643
678
|
const fresh = (0, import_yaml.parse)((0, import_node_fs2.readFileSync)(absPath, "utf8")) ?? {};
|
|
644
679
|
const freshRelations = parseRelations(fresh["_rel"]);
|
|
645
680
|
const freshData = Object.fromEntries(
|
|
646
|
-
Object.entries(fresh).filter(([key]) => key
|
|
681
|
+
Object.entries(fresh).filter(([key]) => !RESERVED.has(key))
|
|
647
682
|
);
|
|
648
683
|
for (const key of Object.keys(data)) delete data[key];
|
|
649
684
|
Object.assign(data, freshData);
|
|
@@ -755,7 +790,7 @@ function endpointRow(method, path, desc) {
|
|
|
755
790
|
}
|
|
756
791
|
function resourceAccordion(name, base, isOpen) {
|
|
757
792
|
const p = `${base}/${name}`;
|
|
758
|
-
const
|
|
793
|
+
const singular2 = name.endsWith("s") ? name.slice(0, -1) : name;
|
|
759
794
|
const rows = [
|
|
760
795
|
endpointRow(
|
|
761
796
|
"GET",
|
|
@@ -765,20 +800,20 @@ function resourceAccordion(name, base, isOpen) {
|
|
|
765
800
|
endpointRow(
|
|
766
801
|
"POST",
|
|
767
802
|
p,
|
|
768
|
-
`Create a new ${
|
|
803
|
+
`Create a new ${singular2}. Auto-assigns <code>id</code> if not provided.`
|
|
769
804
|
),
|
|
770
|
-
endpointRow("GET", `${p}/:id`, `Get a single ${
|
|
805
|
+
endpointRow("GET", `${p}/:id`, `Get a single ${singular2} by id.`),
|
|
771
806
|
endpointRow(
|
|
772
807
|
"PUT",
|
|
773
808
|
`${p}/:id`,
|
|
774
|
-
`Fully replace a ${
|
|
809
|
+
`Fully replace a ${singular2}. Original <code>id</code> is always preserved.`
|
|
775
810
|
),
|
|
776
811
|
endpointRow(
|
|
777
812
|
"PATCH",
|
|
778
813
|
`${p}/:id`,
|
|
779
|
-
`Partially update a ${
|
|
814
|
+
`Partially update a ${singular2} \u2014 only provided fields change.`
|
|
780
815
|
),
|
|
781
|
-
endpointRow("DELETE", `${p}/:id`, `Delete a ${
|
|
816
|
+
endpointRow("DELETE", `${p}/:id`, `Delete a ${singular2} and return it as confirmation.`)
|
|
782
817
|
].join("");
|
|
783
818
|
return `
|
|
784
819
|
<details class="resource-card" ${isOpen ? "open" : ""}>
|
|
@@ -797,13 +832,13 @@ function nestedRoutesAccordion(relations, base) {
|
|
|
797
832
|
for (const [key, def] of Object.entries(fields)) {
|
|
798
833
|
const nestedBadge = def.nested ? ` ${badge("nested", "#facc15", "#facc1518")}` : "";
|
|
799
834
|
if (def.type === "many2many") {
|
|
800
|
-
const
|
|
835
|
+
const singular2 = source.endsWith("s") ? source.slice(0, -1) : source;
|
|
801
836
|
const m2mBadge = badge("many2many", "#818cf8", "#818cf818");
|
|
802
837
|
rows.push(
|
|
803
838
|
endpointRow(
|
|
804
839
|
"GET",
|
|
805
840
|
`${base}/${source}/:id/${key}`,
|
|
806
|
-
`List ${def.target} linked to a ${
|
|
841
|
+
`List ${def.target} linked to a ${singular2} via ${def.through}. ${m2mBadge}${nestedBadge}`
|
|
807
842
|
)
|
|
808
843
|
);
|
|
809
844
|
const targetSingular = def.target.endsWith("s") ? def.target.slice(0, -1) : def.target;
|
|
@@ -929,7 +964,7 @@ function examplesBlock(collections, relations, base, host, options, firstCustomR
|
|
|
929
964
|
const firstCol = collections[0];
|
|
930
965
|
if (firstCol) {
|
|
931
966
|
const p = `${host}${base}/${firstCol}`;
|
|
932
|
-
const
|
|
967
|
+
const singular2 = firstCol.endsWith("s") ? firstCol.slice(0, -1) : firstCol;
|
|
933
968
|
examples.push(
|
|
934
969
|
`# List all ${firstCol}
|
|
935
970
|
curl ${p}`,
|
|
@@ -937,17 +972,17 @@ curl ${p}`,
|
|
|
937
972
|
curl "${p}?name=value"`,
|
|
938
973
|
`# Sort and paginate
|
|
939
974
|
curl "${p}?_sort=id&_order=desc&_page=1&_limit=5"`,
|
|
940
|
-
`# Get single ${
|
|
975
|
+
`# Get single ${singular2}
|
|
941
976
|
curl ${p}/1`,
|
|
942
|
-
`# Create ${
|
|
977
|
+
`# Create ${singular2}
|
|
943
978
|
curl -X POST ${p} \\
|
|
944
979
|
-H "Content-Type: application/json" \\
|
|
945
980
|
-d '{"name":"example"}'`,
|
|
946
|
-
`# Partially update ${
|
|
981
|
+
`# Partially update ${singular2}
|
|
947
982
|
curl -X PATCH ${p}/1 \\
|
|
948
983
|
-H "Content-Type: application/json" \\
|
|
949
984
|
-d '{"name":"updated"}'`,
|
|
950
|
-
`# Delete ${
|
|
985
|
+
`# Delete ${singular2}
|
|
951
986
|
curl -X DELETE ${p}/1`
|
|
952
987
|
);
|
|
953
988
|
}
|
|
@@ -1683,7 +1718,7 @@ var CustomRouteCommand = class {
|
|
|
1683
1718
|
url,
|
|
1684
1719
|
handler: async (req, reply) => {
|
|
1685
1720
|
if (route.delay && route.delay > 0) {
|
|
1686
|
-
await new Promise((
|
|
1721
|
+
await new Promise((resolve6) => setTimeout(resolve6, route.delay));
|
|
1687
1722
|
}
|
|
1688
1723
|
if (route.error) {
|
|
1689
1724
|
const body2 = route.errorBody ?? { error: `Forced error ${route.error}` };
|
|
@@ -1871,6 +1906,371 @@ var NestedRouteCommand = class {
|
|
|
1871
1906
|
}
|
|
1872
1907
|
};
|
|
1873
1908
|
|
|
1909
|
+
// src/openapi/inferSchema.ts
|
|
1910
|
+
function buildCollectionSchema(items, fieldDefs = {}) {
|
|
1911
|
+
const sample = items.slice(0, 10);
|
|
1912
|
+
const inferredTypes = /* @__PURE__ */ new Map();
|
|
1913
|
+
for (const item of sample) {
|
|
1914
|
+
for (const [key, value] of Object.entries(item)) {
|
|
1915
|
+
if (!inferredTypes.has(key)) {
|
|
1916
|
+
inferredTypes.set(key, jsToOpenApiType(value));
|
|
1917
|
+
}
|
|
1918
|
+
}
|
|
1919
|
+
}
|
|
1920
|
+
const allFields = /* @__PURE__ */ new Set([...inferredTypes.keys(), ...Object.keys(fieldDefs)]);
|
|
1921
|
+
const properties = {};
|
|
1922
|
+
const required = [];
|
|
1923
|
+
for (const field of allFields) {
|
|
1924
|
+
const def = fieldDefs[field];
|
|
1925
|
+
const inferred = inferredTypes.get(field) ?? "string";
|
|
1926
|
+
const prop = {
|
|
1927
|
+
type: def?.type ?? inferred
|
|
1928
|
+
};
|
|
1929
|
+
if (def?.format) prop.format = def.format;
|
|
1930
|
+
if (def?.description) prop.description = def.description;
|
|
1931
|
+
if (def?.enum) prop.enum = def.enum;
|
|
1932
|
+
if (def?.default !== void 0) prop.default = def.default;
|
|
1933
|
+
properties[field] = prop;
|
|
1934
|
+
if (def?.required === true) required.push(field);
|
|
1935
|
+
}
|
|
1936
|
+
const schema = { type: "object", properties };
|
|
1937
|
+
if (required.length > 0) schema.required = required;
|
|
1938
|
+
return schema;
|
|
1939
|
+
}
|
|
1940
|
+
function jsToOpenApiType(value) {
|
|
1941
|
+
if (value === null || value === void 0) return "string";
|
|
1942
|
+
if (typeof value === "boolean") return "boolean";
|
|
1943
|
+
if (typeof value === "number") return Number.isInteger(value) ? "integer" : "number";
|
|
1944
|
+
if (Array.isArray(value)) return "array";
|
|
1945
|
+
if (typeof value === "object") return "object";
|
|
1946
|
+
return "string";
|
|
1947
|
+
}
|
|
1948
|
+
|
|
1949
|
+
// src/openapi/buildPaths.ts
|
|
1950
|
+
var COLLECTION_QUERY_PARAMS = [
|
|
1951
|
+
{ name: "_page", in: "query", schema: { type: "integer" }, description: "Page number (1-based)" },
|
|
1952
|
+
{ name: "_limit", in: "query", schema: { type: "integer" }, description: "Items per page" },
|
|
1953
|
+
{ name: "_sort", in: "query", schema: { type: "string" }, description: "Field name to sort by" },
|
|
1954
|
+
{
|
|
1955
|
+
name: "_order",
|
|
1956
|
+
in: "query",
|
|
1957
|
+
schema: { type: "string", enum: ["asc", "desc"] },
|
|
1958
|
+
description: "Sort direction"
|
|
1959
|
+
},
|
|
1960
|
+
{
|
|
1961
|
+
name: "_q",
|
|
1962
|
+
in: "query",
|
|
1963
|
+
schema: { type: "string" },
|
|
1964
|
+
description: "Full-text search across all scalar fields (case-insensitive)"
|
|
1965
|
+
},
|
|
1966
|
+
{
|
|
1967
|
+
name: "_expand",
|
|
1968
|
+
in: "query",
|
|
1969
|
+
schema: { type: "string" },
|
|
1970
|
+
description: "Embed related parent object inline (e.g. ?_expand=user)"
|
|
1971
|
+
},
|
|
1972
|
+
{
|
|
1973
|
+
name: "_embed",
|
|
1974
|
+
in: "query",
|
|
1975
|
+
schema: { type: "string" },
|
|
1976
|
+
description: "Embed child collection into each item (e.g. ?_embed=posts)"
|
|
1977
|
+
},
|
|
1978
|
+
{
|
|
1979
|
+
name: "_fields",
|
|
1980
|
+
in: "query",
|
|
1981
|
+
schema: { type: "string" },
|
|
1982
|
+
description: "Comma-separated field projection (e.g. ?_fields=id,name)"
|
|
1983
|
+
}
|
|
1984
|
+
];
|
|
1985
|
+
var ID_PATH_PARAM = {
|
|
1986
|
+
name: "id",
|
|
1987
|
+
in: "path",
|
|
1988
|
+
required: true,
|
|
1989
|
+
schema: { type: "string" },
|
|
1990
|
+
description: "Item id"
|
|
1991
|
+
};
|
|
1992
|
+
function toOpenApiPath(fastifyPath) {
|
|
1993
|
+
return fastifyPath.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, "{$1}");
|
|
1994
|
+
}
|
|
1995
|
+
function extractPathParams(fastifyPath) {
|
|
1996
|
+
const matches = fastifyPath.match(/:([a-zA-Z_][a-zA-Z0-9_]*)/g) ?? [];
|
|
1997
|
+
return matches.map((m) => ({
|
|
1998
|
+
name: m.slice(1),
|
|
1999
|
+
in: "path",
|
|
2000
|
+
required: true,
|
|
2001
|
+
schema: { type: "string" }
|
|
2002
|
+
}));
|
|
2003
|
+
}
|
|
2004
|
+
function singular(name) {
|
|
2005
|
+
return name.endsWith("s") ? name.slice(0, -1) : name;
|
|
2006
|
+
}
|
|
2007
|
+
function schemaRef(name) {
|
|
2008
|
+
return { $ref: `#/components/schemas/${name}` };
|
|
2009
|
+
}
|
|
2010
|
+
function jsonContent(schema) {
|
|
2011
|
+
return { "application/json": { schema } };
|
|
2012
|
+
}
|
|
2013
|
+
function ok(schema, description = "OK") {
|
|
2014
|
+
return { description, content: jsonContent(schema) };
|
|
2015
|
+
}
|
|
2016
|
+
function buildCrudPaths(collection, base, schemaName) {
|
|
2017
|
+
const ref = schemaRef(schemaName);
|
|
2018
|
+
const tag = collection;
|
|
2019
|
+
const sing = singular(collection);
|
|
2020
|
+
const collPath = `${base}/${collection}`;
|
|
2021
|
+
const itemPath = `${base}/${collection}/{id}`;
|
|
2022
|
+
return {
|
|
2023
|
+
[collPath]: {
|
|
2024
|
+
get: {
|
|
2025
|
+
summary: `List ${collection}`,
|
|
2026
|
+
tags: [tag],
|
|
2027
|
+
parameters: COLLECTION_QUERY_PARAMS,
|
|
2028
|
+
responses: {
|
|
2029
|
+
"200": {
|
|
2030
|
+
description: "OK",
|
|
2031
|
+
content: jsonContent({ type: "array", items: ref }),
|
|
2032
|
+
headers: {
|
|
2033
|
+
"X-Total-Count": {
|
|
2034
|
+
description: "Total items (when using ?_page / ?_limit)",
|
|
2035
|
+
schema: { type: "integer" }
|
|
2036
|
+
}
|
|
2037
|
+
}
|
|
2038
|
+
}
|
|
2039
|
+
}
|
|
2040
|
+
},
|
|
2041
|
+
post: {
|
|
2042
|
+
summary: `Create ${sing}`,
|
|
2043
|
+
tags: [tag],
|
|
2044
|
+
requestBody: { required: true, content: jsonContent(ref) },
|
|
2045
|
+
responses: { "201": ok(ref, "Created") }
|
|
2046
|
+
}
|
|
2047
|
+
},
|
|
2048
|
+
[itemPath]: {
|
|
2049
|
+
get: {
|
|
2050
|
+
summary: `Get ${sing}`,
|
|
2051
|
+
tags: [tag],
|
|
2052
|
+
parameters: [
|
|
2053
|
+
ID_PATH_PARAM,
|
|
2054
|
+
...COLLECTION_QUERY_PARAMS.filter(
|
|
2055
|
+
(p) => ["_expand", "_embed", "_fields"].includes(p.name)
|
|
2056
|
+
)
|
|
2057
|
+
],
|
|
2058
|
+
responses: { "200": ok(ref), "404": { description: "Not found" } }
|
|
2059
|
+
},
|
|
2060
|
+
put: {
|
|
2061
|
+
summary: `Replace ${sing}`,
|
|
2062
|
+
tags: [tag],
|
|
2063
|
+
parameters: [ID_PATH_PARAM],
|
|
2064
|
+
requestBody: { required: true, content: jsonContent(ref) },
|
|
2065
|
+
responses: { "200": ok(ref), "404": { description: "Not found" } }
|
|
2066
|
+
},
|
|
2067
|
+
patch: {
|
|
2068
|
+
summary: `Update ${sing}`,
|
|
2069
|
+
tags: [tag],
|
|
2070
|
+
parameters: [ID_PATH_PARAM],
|
|
2071
|
+
requestBody: { required: false, content: jsonContent(ref) },
|
|
2072
|
+
responses: { "200": ok(ref), "404": { description: "Not found" } }
|
|
2073
|
+
},
|
|
2074
|
+
delete: {
|
|
2075
|
+
summary: `Delete ${sing}`,
|
|
2076
|
+
tags: [tag],
|
|
2077
|
+
parameters: [ID_PATH_PARAM],
|
|
2078
|
+
responses: { "200": ok(ref, "Deleted item returned"), "404": { description: "Not found" } }
|
|
2079
|
+
}
|
|
2080
|
+
}
|
|
2081
|
+
};
|
|
2082
|
+
}
|
|
2083
|
+
function buildRelationPaths(relations, base) {
|
|
2084
|
+
const paths = {};
|
|
2085
|
+
for (const [source, fields] of Object.entries(relations)) {
|
|
2086
|
+
for (const [key, def] of Object.entries(fields)) {
|
|
2087
|
+
if (def.type === "many2many") {
|
|
2088
|
+
const forwardPath = `${base}/${source}/{id}/${key}`;
|
|
2089
|
+
const inversePath = `${base}/${def.target}/{id}/${source}`;
|
|
2090
|
+
paths[forwardPath] = {
|
|
2091
|
+
get: {
|
|
2092
|
+
summary: `List ${def.target} linked to ${singular(source)} via ${def.through}`,
|
|
2093
|
+
tags: [source],
|
|
2094
|
+
parameters: [ID_PATH_PARAM],
|
|
2095
|
+
responses: {
|
|
2096
|
+
"200": {
|
|
2097
|
+
description: "OK",
|
|
2098
|
+
content: jsonContent({ type: "array", items: { type: "object" } })
|
|
2099
|
+
},
|
|
2100
|
+
"404": { description: `${singular(source)} not found` }
|
|
2101
|
+
}
|
|
2102
|
+
}
|
|
2103
|
+
};
|
|
2104
|
+
paths[inversePath] = {
|
|
2105
|
+
get: {
|
|
2106
|
+
summary: `List ${source} linked to ${singular(def.target)} via ${def.through} (inverse)`,
|
|
2107
|
+
tags: [def.target],
|
|
2108
|
+
parameters: [ID_PATH_PARAM],
|
|
2109
|
+
responses: {
|
|
2110
|
+
"200": {
|
|
2111
|
+
description: "OK",
|
|
2112
|
+
content: jsonContent({ type: "array", items: { type: "object" } })
|
|
2113
|
+
},
|
|
2114
|
+
"404": { description: `${singular(def.target)} not found` }
|
|
2115
|
+
}
|
|
2116
|
+
}
|
|
2117
|
+
};
|
|
2118
|
+
} else {
|
|
2119
|
+
const parentSing = singular(def.target);
|
|
2120
|
+
const collPath = `${base}/${def.target}/{id}/${source}`;
|
|
2121
|
+
const isOne2One = def.type === "one2one";
|
|
2122
|
+
const responseSchema = isOne2One ? { type: "object" } : { type: "array", items: { type: "object" } };
|
|
2123
|
+
paths[collPath] = {
|
|
2124
|
+
get: {
|
|
2125
|
+
summary: isOne2One ? `Get ${singular(source)} belonging to ${parentSing}` : `List ${source} belonging to ${parentSing}`,
|
|
2126
|
+
tags: [def.target],
|
|
2127
|
+
parameters: [ID_PATH_PARAM],
|
|
2128
|
+
responses: {
|
|
2129
|
+
"200": { description: "OK", content: jsonContent(responseSchema) },
|
|
2130
|
+
"404": { description: `${parentSing} not found` }
|
|
2131
|
+
}
|
|
2132
|
+
}
|
|
2133
|
+
};
|
|
2134
|
+
if (!isOne2One) {
|
|
2135
|
+
const itemPath = `${base}/${def.target}/{id}/${source}/{childId}`;
|
|
2136
|
+
paths[itemPath] = {
|
|
2137
|
+
get: {
|
|
2138
|
+
summary: `Get single ${singular(source)} scoped to ${parentSing}`,
|
|
2139
|
+
tags: [def.target],
|
|
2140
|
+
parameters: [
|
|
2141
|
+
ID_PATH_PARAM,
|
|
2142
|
+
{ name: "childId", in: "path", required: true, schema: { type: "string" } }
|
|
2143
|
+
],
|
|
2144
|
+
responses: {
|
|
2145
|
+
"200": { description: "OK", content: jsonContent({ type: "object" }) },
|
|
2146
|
+
"404": { description: "Not found" }
|
|
2147
|
+
}
|
|
2148
|
+
}
|
|
2149
|
+
};
|
|
2150
|
+
}
|
|
2151
|
+
}
|
|
2152
|
+
}
|
|
2153
|
+
}
|
|
2154
|
+
return paths;
|
|
2155
|
+
}
|
|
2156
|
+
function buildCustomRoutePaths(routes, base) {
|
|
2157
|
+
const paths = {};
|
|
2158
|
+
for (const route of routes) {
|
|
2159
|
+
const openApiPath = toOpenApiPath(`${base}${route.path}`);
|
|
2160
|
+
const method = route.method.toLowerCase();
|
|
2161
|
+
const pathParams = extractPathParams(route.path);
|
|
2162
|
+
const responses = {};
|
|
2163
|
+
if (route.error) {
|
|
2164
|
+
responses[String(route.error)] = { description: `Forced error ${route.error}` };
|
|
2165
|
+
} else {
|
|
2166
|
+
const statuses = /* @__PURE__ */ new Set();
|
|
2167
|
+
for (const s of route.scenarios ?? []) statuses.add(s.response.status ?? 200);
|
|
2168
|
+
if (route.otherwise) statuses.add(route.otherwise.status ?? 200);
|
|
2169
|
+
if (route.response) statuses.add(route.response.status ?? 200);
|
|
2170
|
+
if (statuses.size === 0) statuses.add(200);
|
|
2171
|
+
for (const status of statuses) {
|
|
2172
|
+
const bodySource = (route.scenarios ?? []).find((s) => (s.response.status ?? 200) === status)?.response.body ?? (route.otherwise?.status ?? 200) === status ? route.otherwise?.body : route.response?.body;
|
|
2173
|
+
responses[String(status)] = {
|
|
2174
|
+
description: status < 400 ? "OK" : "Error",
|
|
2175
|
+
...bodySource != null ? { content: jsonContent(inferResponseSchema(bodySource)) } : {}
|
|
2176
|
+
};
|
|
2177
|
+
}
|
|
2178
|
+
}
|
|
2179
|
+
const desc = route.handler ? `Handler: ${route.handler}()` : route.scenarios?.length ? `Conditional scenarios (${route.scenarios.length})` : "Custom static route";
|
|
2180
|
+
const operation = {
|
|
2181
|
+
summary: `${route.method.toUpperCase()} ${route.path}`,
|
|
2182
|
+
description: desc,
|
|
2183
|
+
tags: ["custom"],
|
|
2184
|
+
...pathParams.length > 0 ? { parameters: pathParams } : {},
|
|
2185
|
+
responses
|
|
2186
|
+
};
|
|
2187
|
+
if (!paths[openApiPath]) paths[openApiPath] = {};
|
|
2188
|
+
paths[openApiPath][method] = operation;
|
|
2189
|
+
}
|
|
2190
|
+
return paths;
|
|
2191
|
+
}
|
|
2192
|
+
function inferResponseSchema(body) {
|
|
2193
|
+
if (body === null || body === void 0) return {};
|
|
2194
|
+
if (typeof body !== "object" || Array.isArray(body)) return { type: "object" };
|
|
2195
|
+
const properties = {};
|
|
2196
|
+
for (const [key, value] of Object.entries(body)) {
|
|
2197
|
+
properties[key] = { type: jsToOpenApiType2(value) };
|
|
2198
|
+
}
|
|
2199
|
+
return { type: "object", properties };
|
|
2200
|
+
}
|
|
2201
|
+
function jsToOpenApiType2(value) {
|
|
2202
|
+
if (value === null || value === void 0) return "string";
|
|
2203
|
+
if (typeof value === "boolean") return "boolean";
|
|
2204
|
+
if (typeof value === "number") return Number.isInteger(value) ? "integer" : "number";
|
|
2205
|
+
if (Array.isArray(value)) return "array";
|
|
2206
|
+
if (typeof value === "object") return "object";
|
|
2207
|
+
return "string";
|
|
2208
|
+
}
|
|
2209
|
+
|
|
2210
|
+
// src/openapi/generateOpenApi.ts
|
|
2211
|
+
function generateOpenApi(storage, options, title = "yRest API") {
|
|
2212
|
+
const collections = Object.keys(storage.getData());
|
|
2213
|
+
const relations = storage.getRelations();
|
|
2214
|
+
const schemaBlock = storage.getSchema();
|
|
2215
|
+
const customRoutes = storage.getRoutes();
|
|
2216
|
+
const base = options.base ?? "";
|
|
2217
|
+
const schemas = {};
|
|
2218
|
+
for (const collection of collections) {
|
|
2219
|
+
const items = storage.getCollection(collection) ?? [];
|
|
2220
|
+
const fieldDefs = schemaBlock[collection] ?? {};
|
|
2221
|
+
const schemaName = toSchemaName(collection);
|
|
2222
|
+
schemas[schemaName] = buildCollectionSchema(items, fieldDefs);
|
|
2223
|
+
}
|
|
2224
|
+
const paths = {};
|
|
2225
|
+
for (const collection of collections) {
|
|
2226
|
+
const schemaName = toSchemaName(collection);
|
|
2227
|
+
Object.assign(paths, buildCrudPaths(collection, base, schemaName));
|
|
2228
|
+
}
|
|
2229
|
+
Object.assign(paths, buildRelationPaths(relations, base));
|
|
2230
|
+
Object.assign(paths, buildCustomRoutePaths(customRoutes, base));
|
|
2231
|
+
return {
|
|
2232
|
+
openapi: "3.0.3",
|
|
2233
|
+
info: {
|
|
2234
|
+
title,
|
|
2235
|
+
version: "1.0.0",
|
|
2236
|
+
description: "Generated by yRest from db.yml"
|
|
2237
|
+
},
|
|
2238
|
+
servers: [
|
|
2239
|
+
{
|
|
2240
|
+
url: `http://${options.host}:${options.port}${base}`,
|
|
2241
|
+
description: "yRest mock server"
|
|
2242
|
+
}
|
|
2243
|
+
],
|
|
2244
|
+
paths,
|
|
2245
|
+
components: { schemas }
|
|
2246
|
+
};
|
|
2247
|
+
}
|
|
2248
|
+
function toSchemaName(collection) {
|
|
2249
|
+
const withoutS = collection.endsWith("s") ? collection.slice(0, -1) : collection;
|
|
2250
|
+
return withoutS.charAt(0).toUpperCase() + withoutS.slice(1);
|
|
2251
|
+
}
|
|
2252
|
+
|
|
2253
|
+
// src/router/routes/openapi.routes.ts
|
|
2254
|
+
var import_yaml2 = require("yaml");
|
|
2255
|
+
var OpenApiRouteCommand = class {
|
|
2256
|
+
constructor(storage, options) {
|
|
2257
|
+
this.storage = storage;
|
|
2258
|
+
this.options = options;
|
|
2259
|
+
}
|
|
2260
|
+
storage;
|
|
2261
|
+
options;
|
|
2262
|
+
register(server) {
|
|
2263
|
+
server.get("/_openapi", (_req, reply) => {
|
|
2264
|
+
const doc = generateOpenApi(this.storage, this.options);
|
|
2265
|
+
reply.header("Content-Type", "text/yaml; charset=utf-8");
|
|
2266
|
+
return reply.send((0, import_yaml2.stringify)(doc, { lineWidth: 0, aliasDuplicateObjects: false }));
|
|
2267
|
+
});
|
|
2268
|
+
server.get("/_openapi.json", (_req, reply) => {
|
|
2269
|
+
return reply.send(generateOpenApi(this.storage, this.options));
|
|
2270
|
+
});
|
|
2271
|
+
}
|
|
2272
|
+
};
|
|
2273
|
+
|
|
1874
2274
|
// src/router/routes/snapshot.routes.ts
|
|
1875
2275
|
var SnapshotRouteCommand = class {
|
|
1876
2276
|
constructor(storage) {
|
|
@@ -1951,6 +2351,7 @@ async function createServer(storage, options, handlers = /* @__PURE__ */ new Map
|
|
|
1951
2351
|
}
|
|
1952
2352
|
const commands = [
|
|
1953
2353
|
new AboutRouteCommand(storage, options, handlers),
|
|
2354
|
+
new OpenApiRouteCommand(storage, options),
|
|
1954
2355
|
...options.snapshot ? [new SnapshotRouteCommand(storage)] : [],
|
|
1955
2356
|
new CustomRouteCommand(storage, options.base, handlers),
|
|
1956
2357
|
...buildResourceRouteCommands(storage, options)
|
|
@@ -2038,11 +2439,11 @@ var yrestOptionsSchema = import_zod.z.object({
|
|
|
2038
2439
|
|
|
2039
2440
|
// src/config/loadConfigFile.ts
|
|
2040
2441
|
var import_node_fs4 = require("fs");
|
|
2041
|
-
var
|
|
2442
|
+
var import_yaml3 = require("yaml");
|
|
2042
2443
|
function loadConfigFile(configPath) {
|
|
2043
2444
|
if (!(0, import_node_fs4.existsSync)(configPath)) return {};
|
|
2044
2445
|
const raw = (0, import_node_fs4.readFileSync)(configPath, "utf8");
|
|
2045
|
-
return (0,
|
|
2446
|
+
return (0, import_yaml3.parse)(raw) ?? {};
|
|
2046
2447
|
}
|
|
2047
2448
|
|
|
2048
2449
|
// src/utils/handlers.ts
|
|
@@ -2195,7 +2596,7 @@ function registerServe(program2) {
|
|
|
2195
2596
|
// src/cli/commands/handler.ts
|
|
2196
2597
|
var import_node_fs7 = require("fs");
|
|
2197
2598
|
var import_node_path5 = require("path");
|
|
2198
|
-
var
|
|
2599
|
+
var import_yaml4 = require("yaml");
|
|
2199
2600
|
var HANDLERS_FILE_HEADER = `// yrest handlers \u2014 loaded via "handlers:" in yrest.config.yml
|
|
2200
2601
|
// Handler signature: (req: HandlerRequest) => HandlerResponse | Promise<HandlerResponse>
|
|
2201
2602
|
// See https://github.com/aggiovato/yaml-rest for full documentation
|
|
@@ -2251,13 +2652,13 @@ function registerHandler(program2) {
|
|
|
2251
2652
|
console.error(` Error: database file not found at ${dbPath}`);
|
|
2252
2653
|
process.exit(1);
|
|
2253
2654
|
}
|
|
2254
|
-
const raw = (0,
|
|
2655
|
+
const raw = (0, import_yaml4.parse)((0, import_node_fs7.readFileSync)(dbPath, "utf8")) ?? {};
|
|
2255
2656
|
if (!Array.isArray(raw["_routes"])) raw["_routes"] = [];
|
|
2256
2657
|
const routes = raw["_routes"];
|
|
2257
2658
|
const alreadyRegistered = routes.some((r) => r["handler"] === name);
|
|
2258
2659
|
if (!alreadyRegistered) {
|
|
2259
2660
|
routes.push({ method: flags.method.toUpperCase(), path: flags.path, handler: name });
|
|
2260
|
-
(0, import_node_fs7.writeFileSync)(dbPath, (0,
|
|
2661
|
+
(0, import_node_fs7.writeFileSync)(dbPath, (0, import_yaml4.stringify)(raw), "utf8");
|
|
2261
2662
|
console.log(` Added _routes entry to ${(0, import_node_path5.basename)(dbPath)}`);
|
|
2262
2663
|
} else {
|
|
2263
2664
|
console.log(` Handler "${name}" already in _routes \u2014 skipped`);
|
|
@@ -2280,6 +2681,33 @@ function registerHandler(program2) {
|
|
|
2280
2681
|
});
|
|
2281
2682
|
}
|
|
2282
2683
|
|
|
2684
|
+
// src/cli/commands/openapi.ts
|
|
2685
|
+
var import_node_fs8 = require("fs");
|
|
2686
|
+
var import_node_path6 = require("path");
|
|
2687
|
+
var import_yaml5 = require("yaml");
|
|
2688
|
+
function registerOpenApi(program2) {
|
|
2689
|
+
program2.command("openapi <file>").description("Generate an OpenAPI 3.0 spec from a db.yml file").option("-o, --output <file>", "Output file (default: openapi.yaml / openapi.json)").option("--format <fmt>", "Output format: yaml (default) or json", "yaml").option("--stdout", "Print to stdout instead of writing a file").option("--base <base>", "Base path prefix applied to all routes", "").option("--port <port>", "Server port shown in the servers block", "3070").option("--host <host>", "Server host shown in the servers block", "localhost").option("--title <title>", "API title for the info block", "yRest API").action((file, opts) => {
|
|
2690
|
+
const storage = createYrestStorage((0, import_node_path6.resolve)(file));
|
|
2691
|
+
const options = yrestOptionsSchema.parse({
|
|
2692
|
+
file,
|
|
2693
|
+
base: opts["base"] || void 0,
|
|
2694
|
+
port: Number(opts["port"]) || 3070,
|
|
2695
|
+
host: opts["host"] || "localhost"
|
|
2696
|
+
});
|
|
2697
|
+
const doc = generateOpenApi(storage, options, opts["title"]);
|
|
2698
|
+
const isJson = opts["format"] === "json";
|
|
2699
|
+
const output = isJson ? JSON.stringify(doc, null, 2) : (0, import_yaml5.stringify)(doc, { lineWidth: 0, aliasDuplicateObjects: false });
|
|
2700
|
+
if (opts["stdout"]) {
|
|
2701
|
+
process.stdout.write(output);
|
|
2702
|
+
return;
|
|
2703
|
+
}
|
|
2704
|
+
const defaultFile = isJson ? "openapi.json" : "openapi.yaml";
|
|
2705
|
+
const outFile = (0, import_node_path6.resolve)(opts["output"] ?? defaultFile);
|
|
2706
|
+
(0, import_node_fs8.writeFileSync)(outFile, output, "utf8");
|
|
2707
|
+
console.log(`\u2713 OpenAPI spec written to ${outFile}`);
|
|
2708
|
+
});
|
|
2709
|
+
}
|
|
2710
|
+
|
|
2283
2711
|
// src/cli/index.ts
|
|
2284
2712
|
var require2 = (0, import_module.createRequire)(importMetaUrl);
|
|
2285
2713
|
var { version } = require2("../../package.json");
|
|
@@ -2287,4 +2715,5 @@ import_commander.program.name("yrest").description("Zero-config REST API mock se
|
|
|
2287
2715
|
registerInit(import_commander.program);
|
|
2288
2716
|
registerServe(import_commander.program);
|
|
2289
2717
|
registerHandler(import_commander.program);
|
|
2718
|
+
registerOpenApi(import_commander.program);
|
|
2290
2719
|
import_commander.program.parse();
|