@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/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 !== "_rel" && key !== "_routes")
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 !== "_rel" && key !== "_routes")
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 singular = name.endsWith("s") ? name.slice(0, -1) : name;
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 ${singular}. Auto-assigns <code>id</code> if not provided.`
803
+ `Create a new ${singular2}. Auto-assigns <code>id</code> if not provided.`
769
804
  ),
770
- endpointRow("GET", `${p}/:id`, `Get a single ${singular} by id.`),
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 ${singular}. Original <code>id</code> is always preserved.`
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 ${singular} \u2014 only provided fields change.`
814
+ `Partially update a ${singular2} \u2014 only provided fields change.`
780
815
  ),
781
- endpointRow("DELETE", `${p}/:id`, `Delete a ${singular} and return it as confirmation.`)
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 singular = source.endsWith("s") ? source.slice(0, -1) : source;
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 ${singular} via ${def.through}. ${m2mBadge}${nestedBadge}`
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 singular = firstCol.endsWith("s") ? firstCol.slice(0, -1) : firstCol;
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 ${singular}
975
+ `# Get single ${singular2}
941
976
  curl ${p}/1`,
942
- `# Create ${singular}
977
+ `# Create ${singular2}
943
978
  curl -X POST ${p} \\
944
979
  -H "Content-Type: application/json" \\
945
980
  -d '{"name":"example"}'`,
946
- `# Partially update ${singular}
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 ${singular}
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((resolve5) => setTimeout(resolve5, route.delay));
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 import_yaml2 = require("yaml");
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, import_yaml2.parse)(raw) ?? {};
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 import_yaml3 = require("yaml");
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, import_yaml3.parse)((0, import_node_fs7.readFileSync)(dbPath, "utf8")) ?? {};
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, import_yaml3.stringify)(raw), "utf8");
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();