@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.mjs
CHANGED
|
@@ -566,6 +566,36 @@ function normaliseRelationDef(key, value) {
|
|
|
566
566
|
return null;
|
|
567
567
|
}
|
|
568
568
|
|
|
569
|
+
// src/storage/parseSchema.ts
|
|
570
|
+
function parseSchema(raw) {
|
|
571
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
|
|
572
|
+
const result = {};
|
|
573
|
+
for (const [collection, fields] of Object.entries(raw)) {
|
|
574
|
+
if (!fields || typeof fields !== "object" || Array.isArray(fields)) continue;
|
|
575
|
+
result[collection] = {};
|
|
576
|
+
for (const [field, value] of Object.entries(fields)) {
|
|
577
|
+
const def = normaliseFieldDef(value);
|
|
578
|
+
if (def) result[collection][field] = def;
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
return result;
|
|
582
|
+
}
|
|
583
|
+
function normaliseFieldDef(value) {
|
|
584
|
+
if (value === "required") return { required: true };
|
|
585
|
+
if (value === "optional") return { required: false };
|
|
586
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
587
|
+
const v = value;
|
|
588
|
+
const def = {};
|
|
589
|
+
if (v["required"] === true || v["required"] === false) def.required = v["required"];
|
|
590
|
+
if (typeof v["type"] === "string" && ["string", "integer", "number", "boolean", "object", "array"].includes(v["type"]))
|
|
591
|
+
def.type = v["type"];
|
|
592
|
+
if (typeof v["format"] === "string") def.format = v["format"];
|
|
593
|
+
if (Array.isArray(v["enum"])) def.enum = v["enum"];
|
|
594
|
+
if (typeof v["description"] === "string") def.description = v["description"];
|
|
595
|
+
if (v["default"] !== void 0) def.default = v["default"];
|
|
596
|
+
return def;
|
|
597
|
+
}
|
|
598
|
+
|
|
569
599
|
// src/utils/deepCopy.ts
|
|
570
600
|
function deepCopyData(source) {
|
|
571
601
|
return Object.fromEntries(
|
|
@@ -577,10 +607,12 @@ function deepCopyData(source) {
|
|
|
577
607
|
function createYrestStorage(filePath) {
|
|
578
608
|
const absPath = resolve2(filePath);
|
|
579
609
|
const raw = parse(readFileSync(absPath, "utf8")) ?? {};
|
|
610
|
+
const RESERVED = /* @__PURE__ */ new Set(["_rel", "_routes", "_schema"]);
|
|
580
611
|
const relations = parseRelations(raw["_rel"]);
|
|
581
612
|
const routes = Array.isArray(raw["_routes"]) ? raw["_routes"] : [];
|
|
613
|
+
const schema = parseSchema(raw["_schema"]);
|
|
582
614
|
const data = Object.fromEntries(
|
|
583
|
-
Object.entries(raw).filter(([key]) => key
|
|
615
|
+
Object.entries(raw).filter(([key]) => !RESERVED.has(key))
|
|
584
616
|
);
|
|
585
617
|
let snapshot = {
|
|
586
618
|
data: deepCopyData(data),
|
|
@@ -594,6 +626,9 @@ function createYrestStorage(filePath) {
|
|
|
594
626
|
getRelations() {
|
|
595
627
|
return relations;
|
|
596
628
|
},
|
|
629
|
+
getSchema() {
|
|
630
|
+
return schema;
|
|
631
|
+
},
|
|
597
632
|
getRoutes() {
|
|
598
633
|
return routes;
|
|
599
634
|
},
|
|
@@ -616,7 +651,7 @@ function createYrestStorage(filePath) {
|
|
|
616
651
|
const fresh = parse(readFileSync(absPath, "utf8")) ?? {};
|
|
617
652
|
const freshRelations = parseRelations(fresh["_rel"]);
|
|
618
653
|
const freshData = Object.fromEntries(
|
|
619
|
-
Object.entries(fresh).filter(([key]) => key
|
|
654
|
+
Object.entries(fresh).filter(([key]) => !RESERVED.has(key))
|
|
620
655
|
);
|
|
621
656
|
for (const key of Object.keys(data)) delete data[key];
|
|
622
657
|
Object.assign(data, freshData);
|
|
@@ -728,7 +763,7 @@ function endpointRow(method, path, desc) {
|
|
|
728
763
|
}
|
|
729
764
|
function resourceAccordion(name, base, isOpen) {
|
|
730
765
|
const p = `${base}/${name}`;
|
|
731
|
-
const
|
|
766
|
+
const singular2 = name.endsWith("s") ? name.slice(0, -1) : name;
|
|
732
767
|
const rows = [
|
|
733
768
|
endpointRow(
|
|
734
769
|
"GET",
|
|
@@ -738,20 +773,20 @@ function resourceAccordion(name, base, isOpen) {
|
|
|
738
773
|
endpointRow(
|
|
739
774
|
"POST",
|
|
740
775
|
p,
|
|
741
|
-
`Create a new ${
|
|
776
|
+
`Create a new ${singular2}. Auto-assigns <code>id</code> if not provided.`
|
|
742
777
|
),
|
|
743
|
-
endpointRow("GET", `${p}/:id`, `Get a single ${
|
|
778
|
+
endpointRow("GET", `${p}/:id`, `Get a single ${singular2} by id.`),
|
|
744
779
|
endpointRow(
|
|
745
780
|
"PUT",
|
|
746
781
|
`${p}/:id`,
|
|
747
|
-
`Fully replace a ${
|
|
782
|
+
`Fully replace a ${singular2}. Original <code>id</code> is always preserved.`
|
|
748
783
|
),
|
|
749
784
|
endpointRow(
|
|
750
785
|
"PATCH",
|
|
751
786
|
`${p}/:id`,
|
|
752
|
-
`Partially update a ${
|
|
787
|
+
`Partially update a ${singular2} \u2014 only provided fields change.`
|
|
753
788
|
),
|
|
754
|
-
endpointRow("DELETE", `${p}/:id`, `Delete a ${
|
|
789
|
+
endpointRow("DELETE", `${p}/:id`, `Delete a ${singular2} and return it as confirmation.`)
|
|
755
790
|
].join("");
|
|
756
791
|
return `
|
|
757
792
|
<details class="resource-card" ${isOpen ? "open" : ""}>
|
|
@@ -770,13 +805,13 @@ function nestedRoutesAccordion(relations, base) {
|
|
|
770
805
|
for (const [key, def] of Object.entries(fields)) {
|
|
771
806
|
const nestedBadge = def.nested ? ` ${badge("nested", "#facc15", "#facc1518")}` : "";
|
|
772
807
|
if (def.type === "many2many") {
|
|
773
|
-
const
|
|
808
|
+
const singular2 = source.endsWith("s") ? source.slice(0, -1) : source;
|
|
774
809
|
const m2mBadge = badge("many2many", "#818cf8", "#818cf818");
|
|
775
810
|
rows.push(
|
|
776
811
|
endpointRow(
|
|
777
812
|
"GET",
|
|
778
813
|
`${base}/${source}/:id/${key}`,
|
|
779
|
-
`List ${def.target} linked to a ${
|
|
814
|
+
`List ${def.target} linked to a ${singular2} via ${def.through}. ${m2mBadge}${nestedBadge}`
|
|
780
815
|
)
|
|
781
816
|
);
|
|
782
817
|
const targetSingular = def.target.endsWith("s") ? def.target.slice(0, -1) : def.target;
|
|
@@ -902,7 +937,7 @@ function examplesBlock(collections, relations, base, host, options, firstCustomR
|
|
|
902
937
|
const firstCol = collections[0];
|
|
903
938
|
if (firstCol) {
|
|
904
939
|
const p = `${host}${base}/${firstCol}`;
|
|
905
|
-
const
|
|
940
|
+
const singular2 = firstCol.endsWith("s") ? firstCol.slice(0, -1) : firstCol;
|
|
906
941
|
examples.push(
|
|
907
942
|
`# List all ${firstCol}
|
|
908
943
|
curl ${p}`,
|
|
@@ -910,17 +945,17 @@ curl ${p}`,
|
|
|
910
945
|
curl "${p}?name=value"`,
|
|
911
946
|
`# Sort and paginate
|
|
912
947
|
curl "${p}?_sort=id&_order=desc&_page=1&_limit=5"`,
|
|
913
|
-
`# Get single ${
|
|
948
|
+
`# Get single ${singular2}
|
|
914
949
|
curl ${p}/1`,
|
|
915
|
-
`# Create ${
|
|
950
|
+
`# Create ${singular2}
|
|
916
951
|
curl -X POST ${p} \\
|
|
917
952
|
-H "Content-Type: application/json" \\
|
|
918
953
|
-d '{"name":"example"}'`,
|
|
919
|
-
`# Partially update ${
|
|
954
|
+
`# Partially update ${singular2}
|
|
920
955
|
curl -X PATCH ${p}/1 \\
|
|
921
956
|
-H "Content-Type: application/json" \\
|
|
922
957
|
-d '{"name":"updated"}'`,
|
|
923
|
-
`# Delete ${
|
|
958
|
+
`# Delete ${singular2}
|
|
924
959
|
curl -X DELETE ${p}/1`
|
|
925
960
|
);
|
|
926
961
|
}
|
|
@@ -1656,7 +1691,7 @@ var CustomRouteCommand = class {
|
|
|
1656
1691
|
url,
|
|
1657
1692
|
handler: async (req, reply) => {
|
|
1658
1693
|
if (route.delay && route.delay > 0) {
|
|
1659
|
-
await new Promise((
|
|
1694
|
+
await new Promise((resolve6) => setTimeout(resolve6, route.delay));
|
|
1660
1695
|
}
|
|
1661
1696
|
if (route.error) {
|
|
1662
1697
|
const body2 = route.errorBody ?? { error: `Forced error ${route.error}` };
|
|
@@ -1844,6 +1879,371 @@ var NestedRouteCommand = class {
|
|
|
1844
1879
|
}
|
|
1845
1880
|
};
|
|
1846
1881
|
|
|
1882
|
+
// src/openapi/inferSchema.ts
|
|
1883
|
+
function buildCollectionSchema(items, fieldDefs = {}) {
|
|
1884
|
+
const sample = items.slice(0, 10);
|
|
1885
|
+
const inferredTypes = /* @__PURE__ */ new Map();
|
|
1886
|
+
for (const item of sample) {
|
|
1887
|
+
for (const [key, value] of Object.entries(item)) {
|
|
1888
|
+
if (!inferredTypes.has(key)) {
|
|
1889
|
+
inferredTypes.set(key, jsToOpenApiType(value));
|
|
1890
|
+
}
|
|
1891
|
+
}
|
|
1892
|
+
}
|
|
1893
|
+
const allFields = /* @__PURE__ */ new Set([...inferredTypes.keys(), ...Object.keys(fieldDefs)]);
|
|
1894
|
+
const properties = {};
|
|
1895
|
+
const required = [];
|
|
1896
|
+
for (const field of allFields) {
|
|
1897
|
+
const def = fieldDefs[field];
|
|
1898
|
+
const inferred = inferredTypes.get(field) ?? "string";
|
|
1899
|
+
const prop = {
|
|
1900
|
+
type: def?.type ?? inferred
|
|
1901
|
+
};
|
|
1902
|
+
if (def?.format) prop.format = def.format;
|
|
1903
|
+
if (def?.description) prop.description = def.description;
|
|
1904
|
+
if (def?.enum) prop.enum = def.enum;
|
|
1905
|
+
if (def?.default !== void 0) prop.default = def.default;
|
|
1906
|
+
properties[field] = prop;
|
|
1907
|
+
if (def?.required === true) required.push(field);
|
|
1908
|
+
}
|
|
1909
|
+
const schema = { type: "object", properties };
|
|
1910
|
+
if (required.length > 0) schema.required = required;
|
|
1911
|
+
return schema;
|
|
1912
|
+
}
|
|
1913
|
+
function jsToOpenApiType(value) {
|
|
1914
|
+
if (value === null || value === void 0) return "string";
|
|
1915
|
+
if (typeof value === "boolean") return "boolean";
|
|
1916
|
+
if (typeof value === "number") return Number.isInteger(value) ? "integer" : "number";
|
|
1917
|
+
if (Array.isArray(value)) return "array";
|
|
1918
|
+
if (typeof value === "object") return "object";
|
|
1919
|
+
return "string";
|
|
1920
|
+
}
|
|
1921
|
+
|
|
1922
|
+
// src/openapi/buildPaths.ts
|
|
1923
|
+
var COLLECTION_QUERY_PARAMS = [
|
|
1924
|
+
{ name: "_page", in: "query", schema: { type: "integer" }, description: "Page number (1-based)" },
|
|
1925
|
+
{ name: "_limit", in: "query", schema: { type: "integer" }, description: "Items per page" },
|
|
1926
|
+
{ name: "_sort", in: "query", schema: { type: "string" }, description: "Field name to sort by" },
|
|
1927
|
+
{
|
|
1928
|
+
name: "_order",
|
|
1929
|
+
in: "query",
|
|
1930
|
+
schema: { type: "string", enum: ["asc", "desc"] },
|
|
1931
|
+
description: "Sort direction"
|
|
1932
|
+
},
|
|
1933
|
+
{
|
|
1934
|
+
name: "_q",
|
|
1935
|
+
in: "query",
|
|
1936
|
+
schema: { type: "string" },
|
|
1937
|
+
description: "Full-text search across all scalar fields (case-insensitive)"
|
|
1938
|
+
},
|
|
1939
|
+
{
|
|
1940
|
+
name: "_expand",
|
|
1941
|
+
in: "query",
|
|
1942
|
+
schema: { type: "string" },
|
|
1943
|
+
description: "Embed related parent object inline (e.g. ?_expand=user)"
|
|
1944
|
+
},
|
|
1945
|
+
{
|
|
1946
|
+
name: "_embed",
|
|
1947
|
+
in: "query",
|
|
1948
|
+
schema: { type: "string" },
|
|
1949
|
+
description: "Embed child collection into each item (e.g. ?_embed=posts)"
|
|
1950
|
+
},
|
|
1951
|
+
{
|
|
1952
|
+
name: "_fields",
|
|
1953
|
+
in: "query",
|
|
1954
|
+
schema: { type: "string" },
|
|
1955
|
+
description: "Comma-separated field projection (e.g. ?_fields=id,name)"
|
|
1956
|
+
}
|
|
1957
|
+
];
|
|
1958
|
+
var ID_PATH_PARAM = {
|
|
1959
|
+
name: "id",
|
|
1960
|
+
in: "path",
|
|
1961
|
+
required: true,
|
|
1962
|
+
schema: { type: "string" },
|
|
1963
|
+
description: "Item id"
|
|
1964
|
+
};
|
|
1965
|
+
function toOpenApiPath(fastifyPath) {
|
|
1966
|
+
return fastifyPath.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, "{$1}");
|
|
1967
|
+
}
|
|
1968
|
+
function extractPathParams(fastifyPath) {
|
|
1969
|
+
const matches = fastifyPath.match(/:([a-zA-Z_][a-zA-Z0-9_]*)/g) ?? [];
|
|
1970
|
+
return matches.map((m) => ({
|
|
1971
|
+
name: m.slice(1),
|
|
1972
|
+
in: "path",
|
|
1973
|
+
required: true,
|
|
1974
|
+
schema: { type: "string" }
|
|
1975
|
+
}));
|
|
1976
|
+
}
|
|
1977
|
+
function singular(name) {
|
|
1978
|
+
return name.endsWith("s") ? name.slice(0, -1) : name;
|
|
1979
|
+
}
|
|
1980
|
+
function schemaRef(name) {
|
|
1981
|
+
return { $ref: `#/components/schemas/${name}` };
|
|
1982
|
+
}
|
|
1983
|
+
function jsonContent(schema) {
|
|
1984
|
+
return { "application/json": { schema } };
|
|
1985
|
+
}
|
|
1986
|
+
function ok(schema, description = "OK") {
|
|
1987
|
+
return { description, content: jsonContent(schema) };
|
|
1988
|
+
}
|
|
1989
|
+
function buildCrudPaths(collection, base, schemaName) {
|
|
1990
|
+
const ref = schemaRef(schemaName);
|
|
1991
|
+
const tag = collection;
|
|
1992
|
+
const sing = singular(collection);
|
|
1993
|
+
const collPath = `${base}/${collection}`;
|
|
1994
|
+
const itemPath = `${base}/${collection}/{id}`;
|
|
1995
|
+
return {
|
|
1996
|
+
[collPath]: {
|
|
1997
|
+
get: {
|
|
1998
|
+
summary: `List ${collection}`,
|
|
1999
|
+
tags: [tag],
|
|
2000
|
+
parameters: COLLECTION_QUERY_PARAMS,
|
|
2001
|
+
responses: {
|
|
2002
|
+
"200": {
|
|
2003
|
+
description: "OK",
|
|
2004
|
+
content: jsonContent({ type: "array", items: ref }),
|
|
2005
|
+
headers: {
|
|
2006
|
+
"X-Total-Count": {
|
|
2007
|
+
description: "Total items (when using ?_page / ?_limit)",
|
|
2008
|
+
schema: { type: "integer" }
|
|
2009
|
+
}
|
|
2010
|
+
}
|
|
2011
|
+
}
|
|
2012
|
+
}
|
|
2013
|
+
},
|
|
2014
|
+
post: {
|
|
2015
|
+
summary: `Create ${sing}`,
|
|
2016
|
+
tags: [tag],
|
|
2017
|
+
requestBody: { required: true, content: jsonContent(ref) },
|
|
2018
|
+
responses: { "201": ok(ref, "Created") }
|
|
2019
|
+
}
|
|
2020
|
+
},
|
|
2021
|
+
[itemPath]: {
|
|
2022
|
+
get: {
|
|
2023
|
+
summary: `Get ${sing}`,
|
|
2024
|
+
tags: [tag],
|
|
2025
|
+
parameters: [
|
|
2026
|
+
ID_PATH_PARAM,
|
|
2027
|
+
...COLLECTION_QUERY_PARAMS.filter(
|
|
2028
|
+
(p) => ["_expand", "_embed", "_fields"].includes(p.name)
|
|
2029
|
+
)
|
|
2030
|
+
],
|
|
2031
|
+
responses: { "200": ok(ref), "404": { description: "Not found" } }
|
|
2032
|
+
},
|
|
2033
|
+
put: {
|
|
2034
|
+
summary: `Replace ${sing}`,
|
|
2035
|
+
tags: [tag],
|
|
2036
|
+
parameters: [ID_PATH_PARAM],
|
|
2037
|
+
requestBody: { required: true, content: jsonContent(ref) },
|
|
2038
|
+
responses: { "200": ok(ref), "404": { description: "Not found" } }
|
|
2039
|
+
},
|
|
2040
|
+
patch: {
|
|
2041
|
+
summary: `Update ${sing}`,
|
|
2042
|
+
tags: [tag],
|
|
2043
|
+
parameters: [ID_PATH_PARAM],
|
|
2044
|
+
requestBody: { required: false, content: jsonContent(ref) },
|
|
2045
|
+
responses: { "200": ok(ref), "404": { description: "Not found" } }
|
|
2046
|
+
},
|
|
2047
|
+
delete: {
|
|
2048
|
+
summary: `Delete ${sing}`,
|
|
2049
|
+
tags: [tag],
|
|
2050
|
+
parameters: [ID_PATH_PARAM],
|
|
2051
|
+
responses: { "200": ok(ref, "Deleted item returned"), "404": { description: "Not found" } }
|
|
2052
|
+
}
|
|
2053
|
+
}
|
|
2054
|
+
};
|
|
2055
|
+
}
|
|
2056
|
+
function buildRelationPaths(relations, base) {
|
|
2057
|
+
const paths = {};
|
|
2058
|
+
for (const [source, fields] of Object.entries(relations)) {
|
|
2059
|
+
for (const [key, def] of Object.entries(fields)) {
|
|
2060
|
+
if (def.type === "many2many") {
|
|
2061
|
+
const forwardPath = `${base}/${source}/{id}/${key}`;
|
|
2062
|
+
const inversePath = `${base}/${def.target}/{id}/${source}`;
|
|
2063
|
+
paths[forwardPath] = {
|
|
2064
|
+
get: {
|
|
2065
|
+
summary: `List ${def.target} linked to ${singular(source)} via ${def.through}`,
|
|
2066
|
+
tags: [source],
|
|
2067
|
+
parameters: [ID_PATH_PARAM],
|
|
2068
|
+
responses: {
|
|
2069
|
+
"200": {
|
|
2070
|
+
description: "OK",
|
|
2071
|
+
content: jsonContent({ type: "array", items: { type: "object" } })
|
|
2072
|
+
},
|
|
2073
|
+
"404": { description: `${singular(source)} not found` }
|
|
2074
|
+
}
|
|
2075
|
+
}
|
|
2076
|
+
};
|
|
2077
|
+
paths[inversePath] = {
|
|
2078
|
+
get: {
|
|
2079
|
+
summary: `List ${source} linked to ${singular(def.target)} via ${def.through} (inverse)`,
|
|
2080
|
+
tags: [def.target],
|
|
2081
|
+
parameters: [ID_PATH_PARAM],
|
|
2082
|
+
responses: {
|
|
2083
|
+
"200": {
|
|
2084
|
+
description: "OK",
|
|
2085
|
+
content: jsonContent({ type: "array", items: { type: "object" } })
|
|
2086
|
+
},
|
|
2087
|
+
"404": { description: `${singular(def.target)} not found` }
|
|
2088
|
+
}
|
|
2089
|
+
}
|
|
2090
|
+
};
|
|
2091
|
+
} else {
|
|
2092
|
+
const parentSing = singular(def.target);
|
|
2093
|
+
const collPath = `${base}/${def.target}/{id}/${source}`;
|
|
2094
|
+
const isOne2One = def.type === "one2one";
|
|
2095
|
+
const responseSchema = isOne2One ? { type: "object" } : { type: "array", items: { type: "object" } };
|
|
2096
|
+
paths[collPath] = {
|
|
2097
|
+
get: {
|
|
2098
|
+
summary: isOne2One ? `Get ${singular(source)} belonging to ${parentSing}` : `List ${source} belonging to ${parentSing}`,
|
|
2099
|
+
tags: [def.target],
|
|
2100
|
+
parameters: [ID_PATH_PARAM],
|
|
2101
|
+
responses: {
|
|
2102
|
+
"200": { description: "OK", content: jsonContent(responseSchema) },
|
|
2103
|
+
"404": { description: `${parentSing} not found` }
|
|
2104
|
+
}
|
|
2105
|
+
}
|
|
2106
|
+
};
|
|
2107
|
+
if (!isOne2One) {
|
|
2108
|
+
const itemPath = `${base}/${def.target}/{id}/${source}/{childId}`;
|
|
2109
|
+
paths[itemPath] = {
|
|
2110
|
+
get: {
|
|
2111
|
+
summary: `Get single ${singular(source)} scoped to ${parentSing}`,
|
|
2112
|
+
tags: [def.target],
|
|
2113
|
+
parameters: [
|
|
2114
|
+
ID_PATH_PARAM,
|
|
2115
|
+
{ name: "childId", in: "path", required: true, schema: { type: "string" } }
|
|
2116
|
+
],
|
|
2117
|
+
responses: {
|
|
2118
|
+
"200": { description: "OK", content: jsonContent({ type: "object" }) },
|
|
2119
|
+
"404": { description: "Not found" }
|
|
2120
|
+
}
|
|
2121
|
+
}
|
|
2122
|
+
};
|
|
2123
|
+
}
|
|
2124
|
+
}
|
|
2125
|
+
}
|
|
2126
|
+
}
|
|
2127
|
+
return paths;
|
|
2128
|
+
}
|
|
2129
|
+
function buildCustomRoutePaths(routes, base) {
|
|
2130
|
+
const paths = {};
|
|
2131
|
+
for (const route of routes) {
|
|
2132
|
+
const openApiPath = toOpenApiPath(`${base}${route.path}`);
|
|
2133
|
+
const method = route.method.toLowerCase();
|
|
2134
|
+
const pathParams = extractPathParams(route.path);
|
|
2135
|
+
const responses = {};
|
|
2136
|
+
if (route.error) {
|
|
2137
|
+
responses[String(route.error)] = { description: `Forced error ${route.error}` };
|
|
2138
|
+
} else {
|
|
2139
|
+
const statuses = /* @__PURE__ */ new Set();
|
|
2140
|
+
for (const s of route.scenarios ?? []) statuses.add(s.response.status ?? 200);
|
|
2141
|
+
if (route.otherwise) statuses.add(route.otherwise.status ?? 200);
|
|
2142
|
+
if (route.response) statuses.add(route.response.status ?? 200);
|
|
2143
|
+
if (statuses.size === 0) statuses.add(200);
|
|
2144
|
+
for (const status of statuses) {
|
|
2145
|
+
const bodySource = (route.scenarios ?? []).find((s) => (s.response.status ?? 200) === status)?.response.body ?? (route.otherwise?.status ?? 200) === status ? route.otherwise?.body : route.response?.body;
|
|
2146
|
+
responses[String(status)] = {
|
|
2147
|
+
description: status < 400 ? "OK" : "Error",
|
|
2148
|
+
...bodySource != null ? { content: jsonContent(inferResponseSchema(bodySource)) } : {}
|
|
2149
|
+
};
|
|
2150
|
+
}
|
|
2151
|
+
}
|
|
2152
|
+
const desc = route.handler ? `Handler: ${route.handler}()` : route.scenarios?.length ? `Conditional scenarios (${route.scenarios.length})` : "Custom static route";
|
|
2153
|
+
const operation = {
|
|
2154
|
+
summary: `${route.method.toUpperCase()} ${route.path}`,
|
|
2155
|
+
description: desc,
|
|
2156
|
+
tags: ["custom"],
|
|
2157
|
+
...pathParams.length > 0 ? { parameters: pathParams } : {},
|
|
2158
|
+
responses
|
|
2159
|
+
};
|
|
2160
|
+
if (!paths[openApiPath]) paths[openApiPath] = {};
|
|
2161
|
+
paths[openApiPath][method] = operation;
|
|
2162
|
+
}
|
|
2163
|
+
return paths;
|
|
2164
|
+
}
|
|
2165
|
+
function inferResponseSchema(body) {
|
|
2166
|
+
if (body === null || body === void 0) return {};
|
|
2167
|
+
if (typeof body !== "object" || Array.isArray(body)) return { type: "object" };
|
|
2168
|
+
const properties = {};
|
|
2169
|
+
for (const [key, value] of Object.entries(body)) {
|
|
2170
|
+
properties[key] = { type: jsToOpenApiType2(value) };
|
|
2171
|
+
}
|
|
2172
|
+
return { type: "object", properties };
|
|
2173
|
+
}
|
|
2174
|
+
function jsToOpenApiType2(value) {
|
|
2175
|
+
if (value === null || value === void 0) return "string";
|
|
2176
|
+
if (typeof value === "boolean") return "boolean";
|
|
2177
|
+
if (typeof value === "number") return Number.isInteger(value) ? "integer" : "number";
|
|
2178
|
+
if (Array.isArray(value)) return "array";
|
|
2179
|
+
if (typeof value === "object") return "object";
|
|
2180
|
+
return "string";
|
|
2181
|
+
}
|
|
2182
|
+
|
|
2183
|
+
// src/openapi/generateOpenApi.ts
|
|
2184
|
+
function generateOpenApi(storage, options, title = "yRest API") {
|
|
2185
|
+
const collections = Object.keys(storage.getData());
|
|
2186
|
+
const relations = storage.getRelations();
|
|
2187
|
+
const schemaBlock = storage.getSchema();
|
|
2188
|
+
const customRoutes = storage.getRoutes();
|
|
2189
|
+
const base = options.base ?? "";
|
|
2190
|
+
const schemas = {};
|
|
2191
|
+
for (const collection of collections) {
|
|
2192
|
+
const items = storage.getCollection(collection) ?? [];
|
|
2193
|
+
const fieldDefs = schemaBlock[collection] ?? {};
|
|
2194
|
+
const schemaName = toSchemaName(collection);
|
|
2195
|
+
schemas[schemaName] = buildCollectionSchema(items, fieldDefs);
|
|
2196
|
+
}
|
|
2197
|
+
const paths = {};
|
|
2198
|
+
for (const collection of collections) {
|
|
2199
|
+
const schemaName = toSchemaName(collection);
|
|
2200
|
+
Object.assign(paths, buildCrudPaths(collection, base, schemaName));
|
|
2201
|
+
}
|
|
2202
|
+
Object.assign(paths, buildRelationPaths(relations, base));
|
|
2203
|
+
Object.assign(paths, buildCustomRoutePaths(customRoutes, base));
|
|
2204
|
+
return {
|
|
2205
|
+
openapi: "3.0.3",
|
|
2206
|
+
info: {
|
|
2207
|
+
title,
|
|
2208
|
+
version: "1.0.0",
|
|
2209
|
+
description: "Generated by yRest from db.yml"
|
|
2210
|
+
},
|
|
2211
|
+
servers: [
|
|
2212
|
+
{
|
|
2213
|
+
url: `http://${options.host}:${options.port}${base}`,
|
|
2214
|
+
description: "yRest mock server"
|
|
2215
|
+
}
|
|
2216
|
+
],
|
|
2217
|
+
paths,
|
|
2218
|
+
components: { schemas }
|
|
2219
|
+
};
|
|
2220
|
+
}
|
|
2221
|
+
function toSchemaName(collection) {
|
|
2222
|
+
const withoutS = collection.endsWith("s") ? collection.slice(0, -1) : collection;
|
|
2223
|
+
return withoutS.charAt(0).toUpperCase() + withoutS.slice(1);
|
|
2224
|
+
}
|
|
2225
|
+
|
|
2226
|
+
// src/router/routes/openapi.routes.ts
|
|
2227
|
+
import { stringify as stringify2 } from "yaml";
|
|
2228
|
+
var OpenApiRouteCommand = class {
|
|
2229
|
+
constructor(storage, options) {
|
|
2230
|
+
this.storage = storage;
|
|
2231
|
+
this.options = options;
|
|
2232
|
+
}
|
|
2233
|
+
storage;
|
|
2234
|
+
options;
|
|
2235
|
+
register(server) {
|
|
2236
|
+
server.get("/_openapi", (_req, reply) => {
|
|
2237
|
+
const doc = generateOpenApi(this.storage, this.options);
|
|
2238
|
+
reply.header("Content-Type", "text/yaml; charset=utf-8");
|
|
2239
|
+
return reply.send(stringify2(doc, { lineWidth: 0, aliasDuplicateObjects: false }));
|
|
2240
|
+
});
|
|
2241
|
+
server.get("/_openapi.json", (_req, reply) => {
|
|
2242
|
+
return reply.send(generateOpenApi(this.storage, this.options));
|
|
2243
|
+
});
|
|
2244
|
+
}
|
|
2245
|
+
};
|
|
2246
|
+
|
|
1847
2247
|
// src/router/routes/snapshot.routes.ts
|
|
1848
2248
|
var SnapshotRouteCommand = class {
|
|
1849
2249
|
constructor(storage) {
|
|
@@ -1924,6 +2324,7 @@ async function createServer(storage, options, handlers = /* @__PURE__ */ new Map
|
|
|
1924
2324
|
}
|
|
1925
2325
|
const commands = [
|
|
1926
2326
|
new AboutRouteCommand(storage, options, handlers),
|
|
2327
|
+
new OpenApiRouteCommand(storage, options),
|
|
1927
2328
|
...options.snapshot ? [new SnapshotRouteCommand(storage)] : [],
|
|
1928
2329
|
new CustomRouteCommand(storage, options.base, handlers),
|
|
1929
2330
|
...buildResourceRouteCommands(storage, options)
|
|
@@ -2168,7 +2569,7 @@ function registerServe(program2) {
|
|
|
2168
2569
|
// src/cli/commands/handler.ts
|
|
2169
2570
|
import { existsSync as existsSync4, readFileSync as readFileSync4, appendFileSync, writeFileSync as writeFileSync3 } from "fs";
|
|
2170
2571
|
import { join as join3, resolve as resolve4, basename } from "path";
|
|
2171
|
-
import { parse as parse3, stringify as
|
|
2572
|
+
import { parse as parse3, stringify as stringify3 } from "yaml";
|
|
2172
2573
|
var HANDLERS_FILE_HEADER = `// yrest handlers \u2014 loaded via "handlers:" in yrest.config.yml
|
|
2173
2574
|
// Handler signature: (req: HandlerRequest) => HandlerResponse | Promise<HandlerResponse>
|
|
2174
2575
|
// See https://github.com/aggiovato/yaml-rest for full documentation
|
|
@@ -2230,7 +2631,7 @@ function registerHandler(program2) {
|
|
|
2230
2631
|
const alreadyRegistered = routes.some((r) => r["handler"] === name);
|
|
2231
2632
|
if (!alreadyRegistered) {
|
|
2232
2633
|
routes.push({ method: flags.method.toUpperCase(), path: flags.path, handler: name });
|
|
2233
|
-
writeFileSync3(dbPath,
|
|
2634
|
+
writeFileSync3(dbPath, stringify3(raw), "utf8");
|
|
2234
2635
|
console.log(` Added _routes entry to ${basename(dbPath)}`);
|
|
2235
2636
|
} else {
|
|
2236
2637
|
console.log(` Handler "${name}" already in _routes \u2014 skipped`);
|
|
@@ -2253,6 +2654,33 @@ function registerHandler(program2) {
|
|
|
2253
2654
|
});
|
|
2254
2655
|
}
|
|
2255
2656
|
|
|
2657
|
+
// src/cli/commands/openapi.ts
|
|
2658
|
+
import { writeFileSync as writeFileSync4 } from "fs";
|
|
2659
|
+
import { resolve as resolve5 } from "path";
|
|
2660
|
+
import { stringify as stringify4 } from "yaml";
|
|
2661
|
+
function registerOpenApi(program2) {
|
|
2662
|
+
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) => {
|
|
2663
|
+
const storage = createYrestStorage(resolve5(file));
|
|
2664
|
+
const options = yrestOptionsSchema.parse({
|
|
2665
|
+
file,
|
|
2666
|
+
base: opts["base"] || void 0,
|
|
2667
|
+
port: Number(opts["port"]) || 3070,
|
|
2668
|
+
host: opts["host"] || "localhost"
|
|
2669
|
+
});
|
|
2670
|
+
const doc = generateOpenApi(storage, options, opts["title"]);
|
|
2671
|
+
const isJson = opts["format"] === "json";
|
|
2672
|
+
const output = isJson ? JSON.stringify(doc, null, 2) : stringify4(doc, { lineWidth: 0, aliasDuplicateObjects: false });
|
|
2673
|
+
if (opts["stdout"]) {
|
|
2674
|
+
process.stdout.write(output);
|
|
2675
|
+
return;
|
|
2676
|
+
}
|
|
2677
|
+
const defaultFile = isJson ? "openapi.json" : "openapi.yaml";
|
|
2678
|
+
const outFile = resolve5(opts["output"] ?? defaultFile);
|
|
2679
|
+
writeFileSync4(outFile, output, "utf8");
|
|
2680
|
+
console.log(`\u2713 OpenAPI spec written to ${outFile}`);
|
|
2681
|
+
});
|
|
2682
|
+
}
|
|
2683
|
+
|
|
2256
2684
|
// src/cli/index.ts
|
|
2257
2685
|
var require2 = createRequire(import.meta.url);
|
|
2258
2686
|
var { version } = require2("../../package.json");
|
|
@@ -2260,4 +2688,5 @@ program.name("yrest").description("Zero-config REST API mock server powered by a
|
|
|
2260
2688
|
registerInit(program);
|
|
2261
2689
|
registerServe(program);
|
|
2262
2690
|
registerHandler(program);
|
|
2691
|
+
registerOpenApi(program);
|
|
2263
2692
|
program.parse();
|
package/dist/index.d.mts
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
|
*
|