@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.
@@ -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 !== "_rel" && key !== "_routes")
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 !== "_rel" && key !== "_routes")
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 singular = name.endsWith("s") ? name.slice(0, -1) : name;
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 ${singular}. Auto-assigns <code>id</code> if not provided.`
776
+ `Create a new ${singular2}. Auto-assigns <code>id</code> if not provided.`
742
777
  ),
743
- endpointRow("GET", `${p}/:id`, `Get a single ${singular} by id.`),
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 ${singular}. Original <code>id</code> is always preserved.`
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 ${singular} \u2014 only provided fields change.`
787
+ `Partially update a ${singular2} \u2014 only provided fields change.`
753
788
  ),
754
- endpointRow("DELETE", `${p}/:id`, `Delete a ${singular} and return it as confirmation.`)
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 singular = source.endsWith("s") ? source.slice(0, -1) : source;
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 ${singular} via ${def.through}. ${m2mBadge}${nestedBadge}`
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 singular = firstCol.endsWith("s") ? firstCol.slice(0, -1) : firstCol;
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 ${singular}
948
+ `# Get single ${singular2}
914
949
  curl ${p}/1`,
915
- `# Create ${singular}
950
+ `# Create ${singular2}
916
951
  curl -X POST ${p} \\
917
952
  -H "Content-Type: application/json" \\
918
953
  -d '{"name":"example"}'`,
919
- `# Partially update ${singular}
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 ${singular}
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((resolve5) => setTimeout(resolve5, route.delay));
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 stringify2 } from "yaml";
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, stringify2(raw), "utf8");
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
  *