@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/index.d.ts 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
  *
package/dist/index.js CHANGED
@@ -90,6 +90,42 @@ var init_parseRelations = __esm({
90
90
  }
91
91
  });
92
92
 
93
+ // src/storage/parseSchema.ts
94
+ function parseSchema(raw) {
95
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
96
+ const result = {};
97
+ for (const [collection, fields] of Object.entries(raw)) {
98
+ if (!fields || typeof fields !== "object" || Array.isArray(fields)) continue;
99
+ result[collection] = {};
100
+ for (const [field, value] of Object.entries(fields)) {
101
+ const def = normaliseFieldDef(value);
102
+ if (def) result[collection][field] = def;
103
+ }
104
+ }
105
+ return result;
106
+ }
107
+ function normaliseFieldDef(value) {
108
+ if (value === "required") return { required: true };
109
+ if (value === "optional") return { required: false };
110
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
111
+ const v = value;
112
+ const def = {};
113
+ if (v["required"] === true || v["required"] === false) def.required = v["required"];
114
+ if (typeof v["type"] === "string" && ["string", "integer", "number", "boolean", "object", "array"].includes(v["type"]))
115
+ def.type = v["type"];
116
+ if (typeof v["format"] === "string") def.format = v["format"];
117
+ if (Array.isArray(v["enum"])) def.enum = v["enum"];
118
+ if (typeof v["description"] === "string") def.description = v["description"];
119
+ if (v["default"] !== void 0) def.default = v["default"];
120
+ return def;
121
+ }
122
+ var init_parseSchema = __esm({
123
+ "src/storage/parseSchema.ts"() {
124
+ "use strict";
125
+ init_cjs_shims();
126
+ }
127
+ });
128
+
93
129
  // src/utils/deepCopy.ts
94
130
  function deepCopyData(source) {
95
131
  return Object.fromEntries(
@@ -110,11 +146,13 @@ __export(yrestStorage_exports, {
110
146
  });
111
147
  function createYrestStorage(filePath) {
112
148
  const absPath = (0, import_node_path2.resolve)(filePath);
113
- const raw = (0, import_yaml2.parse)((0, import_node_fs3.readFileSync)(absPath, "utf8")) ?? {};
149
+ const raw = (0, import_yaml3.parse)((0, import_node_fs3.readFileSync)(absPath, "utf8")) ?? {};
150
+ const RESERVED = /* @__PURE__ */ new Set(["_rel", "_routes", "_schema"]);
114
151
  const relations = parseRelations(raw["_rel"]);
115
152
  const routes = Array.isArray(raw["_routes"]) ? raw["_routes"] : [];
153
+ const schema = parseSchema(raw["_schema"]);
116
154
  const data = Object.fromEntries(
117
- Object.entries(raw).filter(([key]) => key !== "_rel" && key !== "_routes")
155
+ Object.entries(raw).filter(([key]) => !RESERVED.has(key))
118
156
  );
119
157
  let snapshot = {
120
158
  data: deepCopyData(data),
@@ -128,6 +166,9 @@ function createYrestStorage(filePath) {
128
166
  getRelations() {
129
167
  return relations;
130
168
  },
169
+ getSchema() {
170
+ return schema;
171
+ },
131
172
  getRoutes() {
132
173
  return routes;
133
174
  },
@@ -143,14 +184,14 @@ function createYrestStorage(filePath) {
143
184
  if (routes.length > 0) payload._routes = routes;
144
185
  Object.assign(payload, data);
145
186
  const tmp = (0, import_node_path2.resolve)((0, import_node_path2.dirname)(absPath), `.yrest-${(0, import_node_crypto2.randomUUID)()}.tmp`);
146
- (0, import_node_fs3.writeFileSync)(tmp, (0, import_yaml2.stringify)(payload), "utf8");
187
+ (0, import_node_fs3.writeFileSync)(tmp, (0, import_yaml3.stringify)(payload), "utf8");
147
188
  (0, import_node_fs3.renameSync)(tmp, absPath);
148
189
  },
149
190
  reload() {
150
- const fresh = (0, import_yaml2.parse)((0, import_node_fs3.readFileSync)(absPath, "utf8")) ?? {};
191
+ const fresh = (0, import_yaml3.parse)((0, import_node_fs3.readFileSync)(absPath, "utf8")) ?? {};
151
192
  const freshRelations = parseRelations(fresh["_rel"]);
152
193
  const freshData = Object.fromEntries(
153
- Object.entries(fresh).filter(([key]) => key !== "_rel" && key !== "_routes")
194
+ Object.entries(fresh).filter(([key]) => !RESERVED.has(key))
154
195
  );
155
196
  for (const key of Object.keys(data)) delete data[key];
156
197
  Object.assign(data, freshData);
@@ -177,7 +218,7 @@ function createYrestStorage(filePath) {
177
218
  }
178
219
  };
179
220
  }
180
- var import_node_fs3, import_node_path2, import_node_crypto2, import_yaml2;
221
+ var import_node_fs3, import_node_path2, import_node_crypto2, import_yaml3;
181
222
  var init_yrestStorage = __esm({
182
223
  "src/storage/yrestStorage.ts"() {
183
224
  "use strict";
@@ -185,8 +226,9 @@ var init_yrestStorage = __esm({
185
226
  import_node_fs3 = require("fs");
186
227
  import_node_path2 = require("path");
187
228
  import_node_crypto2 = require("crypto");
188
- import_yaml2 = require("yaml");
229
+ import_yaml3 = require("yaml");
189
230
  init_parseRelations();
231
+ init_parseSchema();
190
232
  init_deepCopy();
191
233
  }
192
234
  });
@@ -250,6 +292,7 @@ function dedent(str) {
250
292
  init_cjs_shims();
251
293
  var import_node_path3 = require("path");
252
294
  init_parseRelations();
295
+ init_parseSchema();
253
296
 
254
297
  // src/utils/handlers.ts
255
298
  init_cjs_shims();
@@ -382,7 +425,7 @@ function endpointRow(method, path, desc) {
382
425
  }
383
426
  function resourceAccordion(name, base, isOpen) {
384
427
  const p = `${base}/${name}`;
385
- const singular = name.endsWith("s") ? name.slice(0, -1) : name;
428
+ const singular2 = name.endsWith("s") ? name.slice(0, -1) : name;
386
429
  const rows = [
387
430
  endpointRow(
388
431
  "GET",
@@ -392,20 +435,20 @@ function resourceAccordion(name, base, isOpen) {
392
435
  endpointRow(
393
436
  "POST",
394
437
  p,
395
- `Create a new ${singular}. Auto-assigns <code>id</code> if not provided.`
438
+ `Create a new ${singular2}. Auto-assigns <code>id</code> if not provided.`
396
439
  ),
397
- endpointRow("GET", `${p}/:id`, `Get a single ${singular} by id.`),
440
+ endpointRow("GET", `${p}/:id`, `Get a single ${singular2} by id.`),
398
441
  endpointRow(
399
442
  "PUT",
400
443
  `${p}/:id`,
401
- `Fully replace a ${singular}. Original <code>id</code> is always preserved.`
444
+ `Fully replace a ${singular2}. Original <code>id</code> is always preserved.`
402
445
  ),
403
446
  endpointRow(
404
447
  "PATCH",
405
448
  `${p}/:id`,
406
- `Partially update a ${singular} \u2014 only provided fields change.`
449
+ `Partially update a ${singular2} \u2014 only provided fields change.`
407
450
  ),
408
- endpointRow("DELETE", `${p}/:id`, `Delete a ${singular} and return it as confirmation.`)
451
+ endpointRow("DELETE", `${p}/:id`, `Delete a ${singular2} and return it as confirmation.`)
409
452
  ].join("");
410
453
  return `
411
454
  <details class="resource-card" ${isOpen ? "open" : ""}>
@@ -424,13 +467,13 @@ function nestedRoutesAccordion(relations, base) {
424
467
  for (const [key, def] of Object.entries(fields)) {
425
468
  const nestedBadge = def.nested ? ` ${badge("nested", "#facc15", "#facc1518")}` : "";
426
469
  if (def.type === "many2many") {
427
- const singular = source.endsWith("s") ? source.slice(0, -1) : source;
470
+ const singular2 = source.endsWith("s") ? source.slice(0, -1) : source;
428
471
  const m2mBadge = badge("many2many", "#818cf8", "#818cf818");
429
472
  rows.push(
430
473
  endpointRow(
431
474
  "GET",
432
475
  `${base}/${source}/:id/${key}`,
433
- `List ${def.target} linked to a ${singular} via ${def.through}. ${m2mBadge}${nestedBadge}`
476
+ `List ${def.target} linked to a ${singular2} via ${def.through}. ${m2mBadge}${nestedBadge}`
434
477
  )
435
478
  );
436
479
  const targetSingular = def.target.endsWith("s") ? def.target.slice(0, -1) : def.target;
@@ -556,7 +599,7 @@ function examplesBlock(collections, relations, base, host, options, firstCustomR
556
599
  const firstCol = collections[0];
557
600
  if (firstCol) {
558
601
  const p = `${host}${base}/${firstCol}`;
559
- const singular = firstCol.endsWith("s") ? firstCol.slice(0, -1) : firstCol;
602
+ const singular2 = firstCol.endsWith("s") ? firstCol.slice(0, -1) : firstCol;
560
603
  examples.push(
561
604
  `# List all ${firstCol}
562
605
  curl ${p}`,
@@ -564,17 +607,17 @@ curl ${p}`,
564
607
  curl "${p}?name=value"`,
565
608
  `# Sort and paginate
566
609
  curl "${p}?_sort=id&_order=desc&_page=1&_limit=5"`,
567
- `# Get single ${singular}
610
+ `# Get single ${singular2}
568
611
  curl ${p}/1`,
569
- `# Create ${singular}
612
+ `# Create ${singular2}
570
613
  curl -X POST ${p} \\
571
614
  -H "Content-Type: application/json" \\
572
615
  -d '{"name":"example"}'`,
573
- `# Partially update ${singular}
616
+ `# Partially update ${singular2}
574
617
  curl -X PATCH ${p}/1 \\
575
618
  -H "Content-Type: application/json" \\
576
619
  -d '{"name":"updated"}'`,
577
- `# Delete ${singular}
620
+ `# Delete ${singular2}
578
621
  curl -X DELETE ${p}/1`
579
622
  );
580
623
  }
@@ -1511,6 +1554,379 @@ var NestedRouteCommand = class {
1511
1554
  }
1512
1555
  };
1513
1556
 
1557
+ // src/router/routes/openapi.routes.ts
1558
+ init_cjs_shims();
1559
+
1560
+ // src/openapi/generateOpenApi.ts
1561
+ init_cjs_shims();
1562
+
1563
+ // src/openapi/inferSchema.ts
1564
+ init_cjs_shims();
1565
+ function buildCollectionSchema(items, fieldDefs = {}) {
1566
+ const sample = items.slice(0, 10);
1567
+ const inferredTypes = /* @__PURE__ */ new Map();
1568
+ for (const item of sample) {
1569
+ for (const [key, value] of Object.entries(item)) {
1570
+ if (!inferredTypes.has(key)) {
1571
+ inferredTypes.set(key, jsToOpenApiType(value));
1572
+ }
1573
+ }
1574
+ }
1575
+ const allFields = /* @__PURE__ */ new Set([...inferredTypes.keys(), ...Object.keys(fieldDefs)]);
1576
+ const properties = {};
1577
+ const required = [];
1578
+ for (const field of allFields) {
1579
+ const def = fieldDefs[field];
1580
+ const inferred = inferredTypes.get(field) ?? "string";
1581
+ const prop = {
1582
+ type: def?.type ?? inferred
1583
+ };
1584
+ if (def?.format) prop.format = def.format;
1585
+ if (def?.description) prop.description = def.description;
1586
+ if (def?.enum) prop.enum = def.enum;
1587
+ if (def?.default !== void 0) prop.default = def.default;
1588
+ properties[field] = prop;
1589
+ if (def?.required === true) required.push(field);
1590
+ }
1591
+ const schema = { type: "object", properties };
1592
+ if (required.length > 0) schema.required = required;
1593
+ return schema;
1594
+ }
1595
+ function jsToOpenApiType(value) {
1596
+ if (value === null || value === void 0) return "string";
1597
+ if (typeof value === "boolean") return "boolean";
1598
+ if (typeof value === "number") return Number.isInteger(value) ? "integer" : "number";
1599
+ if (Array.isArray(value)) return "array";
1600
+ if (typeof value === "object") return "object";
1601
+ return "string";
1602
+ }
1603
+
1604
+ // src/openapi/buildPaths.ts
1605
+ init_cjs_shims();
1606
+ var COLLECTION_QUERY_PARAMS = [
1607
+ { name: "_page", in: "query", schema: { type: "integer" }, description: "Page number (1-based)" },
1608
+ { name: "_limit", in: "query", schema: { type: "integer" }, description: "Items per page" },
1609
+ { name: "_sort", in: "query", schema: { type: "string" }, description: "Field name to sort by" },
1610
+ {
1611
+ name: "_order",
1612
+ in: "query",
1613
+ schema: { type: "string", enum: ["asc", "desc"] },
1614
+ description: "Sort direction"
1615
+ },
1616
+ {
1617
+ name: "_q",
1618
+ in: "query",
1619
+ schema: { type: "string" },
1620
+ description: "Full-text search across all scalar fields (case-insensitive)"
1621
+ },
1622
+ {
1623
+ name: "_expand",
1624
+ in: "query",
1625
+ schema: { type: "string" },
1626
+ description: "Embed related parent object inline (e.g. ?_expand=user)"
1627
+ },
1628
+ {
1629
+ name: "_embed",
1630
+ in: "query",
1631
+ schema: { type: "string" },
1632
+ description: "Embed child collection into each item (e.g. ?_embed=posts)"
1633
+ },
1634
+ {
1635
+ name: "_fields",
1636
+ in: "query",
1637
+ schema: { type: "string" },
1638
+ description: "Comma-separated field projection (e.g. ?_fields=id,name)"
1639
+ }
1640
+ ];
1641
+ var ID_PATH_PARAM = {
1642
+ name: "id",
1643
+ in: "path",
1644
+ required: true,
1645
+ schema: { type: "string" },
1646
+ description: "Item id"
1647
+ };
1648
+ function toOpenApiPath(fastifyPath) {
1649
+ return fastifyPath.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, "{$1}");
1650
+ }
1651
+ function extractPathParams(fastifyPath) {
1652
+ const matches = fastifyPath.match(/:([a-zA-Z_][a-zA-Z0-9_]*)/g) ?? [];
1653
+ return matches.map((m) => ({
1654
+ name: m.slice(1),
1655
+ in: "path",
1656
+ required: true,
1657
+ schema: { type: "string" }
1658
+ }));
1659
+ }
1660
+ function singular(name) {
1661
+ return name.endsWith("s") ? name.slice(0, -1) : name;
1662
+ }
1663
+ function schemaRef(name) {
1664
+ return { $ref: `#/components/schemas/${name}` };
1665
+ }
1666
+ function jsonContent(schema) {
1667
+ return { "application/json": { schema } };
1668
+ }
1669
+ function ok(schema, description = "OK") {
1670
+ return { description, content: jsonContent(schema) };
1671
+ }
1672
+ function buildCrudPaths(collection, base, schemaName) {
1673
+ const ref = schemaRef(schemaName);
1674
+ const tag = collection;
1675
+ const sing = singular(collection);
1676
+ const collPath = `${base}/${collection}`;
1677
+ const itemPath = `${base}/${collection}/{id}`;
1678
+ return {
1679
+ [collPath]: {
1680
+ get: {
1681
+ summary: `List ${collection}`,
1682
+ tags: [tag],
1683
+ parameters: COLLECTION_QUERY_PARAMS,
1684
+ responses: {
1685
+ "200": {
1686
+ description: "OK",
1687
+ content: jsonContent({ type: "array", items: ref }),
1688
+ headers: {
1689
+ "X-Total-Count": {
1690
+ description: "Total items (when using ?_page / ?_limit)",
1691
+ schema: { type: "integer" }
1692
+ }
1693
+ }
1694
+ }
1695
+ }
1696
+ },
1697
+ post: {
1698
+ summary: `Create ${sing}`,
1699
+ tags: [tag],
1700
+ requestBody: { required: true, content: jsonContent(ref) },
1701
+ responses: { "201": ok(ref, "Created") }
1702
+ }
1703
+ },
1704
+ [itemPath]: {
1705
+ get: {
1706
+ summary: `Get ${sing}`,
1707
+ tags: [tag],
1708
+ parameters: [
1709
+ ID_PATH_PARAM,
1710
+ ...COLLECTION_QUERY_PARAMS.filter(
1711
+ (p) => ["_expand", "_embed", "_fields"].includes(p.name)
1712
+ )
1713
+ ],
1714
+ responses: { "200": ok(ref), "404": { description: "Not found" } }
1715
+ },
1716
+ put: {
1717
+ summary: `Replace ${sing}`,
1718
+ tags: [tag],
1719
+ parameters: [ID_PATH_PARAM],
1720
+ requestBody: { required: true, content: jsonContent(ref) },
1721
+ responses: { "200": ok(ref), "404": { description: "Not found" } }
1722
+ },
1723
+ patch: {
1724
+ summary: `Update ${sing}`,
1725
+ tags: [tag],
1726
+ parameters: [ID_PATH_PARAM],
1727
+ requestBody: { required: false, content: jsonContent(ref) },
1728
+ responses: { "200": ok(ref), "404": { description: "Not found" } }
1729
+ },
1730
+ delete: {
1731
+ summary: `Delete ${sing}`,
1732
+ tags: [tag],
1733
+ parameters: [ID_PATH_PARAM],
1734
+ responses: { "200": ok(ref, "Deleted item returned"), "404": { description: "Not found" } }
1735
+ }
1736
+ }
1737
+ };
1738
+ }
1739
+ function buildRelationPaths(relations, base) {
1740
+ const paths = {};
1741
+ for (const [source, fields] of Object.entries(relations)) {
1742
+ for (const [key, def] of Object.entries(fields)) {
1743
+ if (def.type === "many2many") {
1744
+ const forwardPath = `${base}/${source}/{id}/${key}`;
1745
+ const inversePath = `${base}/${def.target}/{id}/${source}`;
1746
+ paths[forwardPath] = {
1747
+ get: {
1748
+ summary: `List ${def.target} linked to ${singular(source)} via ${def.through}`,
1749
+ tags: [source],
1750
+ parameters: [ID_PATH_PARAM],
1751
+ responses: {
1752
+ "200": {
1753
+ description: "OK",
1754
+ content: jsonContent({ type: "array", items: { type: "object" } })
1755
+ },
1756
+ "404": { description: `${singular(source)} not found` }
1757
+ }
1758
+ }
1759
+ };
1760
+ paths[inversePath] = {
1761
+ get: {
1762
+ summary: `List ${source} linked to ${singular(def.target)} via ${def.through} (inverse)`,
1763
+ tags: [def.target],
1764
+ parameters: [ID_PATH_PARAM],
1765
+ responses: {
1766
+ "200": {
1767
+ description: "OK",
1768
+ content: jsonContent({ type: "array", items: { type: "object" } })
1769
+ },
1770
+ "404": { description: `${singular(def.target)} not found` }
1771
+ }
1772
+ }
1773
+ };
1774
+ } else {
1775
+ const parentSing = singular(def.target);
1776
+ const collPath = `${base}/${def.target}/{id}/${source}`;
1777
+ const isOne2One = def.type === "one2one";
1778
+ const responseSchema = isOne2One ? { type: "object" } : { type: "array", items: { type: "object" } };
1779
+ paths[collPath] = {
1780
+ get: {
1781
+ summary: isOne2One ? `Get ${singular(source)} belonging to ${parentSing}` : `List ${source} belonging to ${parentSing}`,
1782
+ tags: [def.target],
1783
+ parameters: [ID_PATH_PARAM],
1784
+ responses: {
1785
+ "200": { description: "OK", content: jsonContent(responseSchema) },
1786
+ "404": { description: `${parentSing} not found` }
1787
+ }
1788
+ }
1789
+ };
1790
+ if (!isOne2One) {
1791
+ const itemPath = `${base}/${def.target}/{id}/${source}/{childId}`;
1792
+ paths[itemPath] = {
1793
+ get: {
1794
+ summary: `Get single ${singular(source)} scoped to ${parentSing}`,
1795
+ tags: [def.target],
1796
+ parameters: [
1797
+ ID_PATH_PARAM,
1798
+ { name: "childId", in: "path", required: true, schema: { type: "string" } }
1799
+ ],
1800
+ responses: {
1801
+ "200": { description: "OK", content: jsonContent({ type: "object" }) },
1802
+ "404": { description: "Not found" }
1803
+ }
1804
+ }
1805
+ };
1806
+ }
1807
+ }
1808
+ }
1809
+ }
1810
+ return paths;
1811
+ }
1812
+ function buildCustomRoutePaths(routes, base) {
1813
+ const paths = {};
1814
+ for (const route of routes) {
1815
+ const openApiPath = toOpenApiPath(`${base}${route.path}`);
1816
+ const method = route.method.toLowerCase();
1817
+ const pathParams = extractPathParams(route.path);
1818
+ const responses = {};
1819
+ if (route.error) {
1820
+ responses[String(route.error)] = { description: `Forced error ${route.error}` };
1821
+ } else {
1822
+ const statuses = /* @__PURE__ */ new Set();
1823
+ for (const s of route.scenarios ?? []) statuses.add(s.response.status ?? 200);
1824
+ if (route.otherwise) statuses.add(route.otherwise.status ?? 200);
1825
+ if (route.response) statuses.add(route.response.status ?? 200);
1826
+ if (statuses.size === 0) statuses.add(200);
1827
+ for (const status of statuses) {
1828
+ const bodySource = (route.scenarios ?? []).find((s) => (s.response.status ?? 200) === status)?.response.body ?? (route.otherwise?.status ?? 200) === status ? route.otherwise?.body : route.response?.body;
1829
+ responses[String(status)] = {
1830
+ description: status < 400 ? "OK" : "Error",
1831
+ ...bodySource != null ? { content: jsonContent(inferResponseSchema(bodySource)) } : {}
1832
+ };
1833
+ }
1834
+ }
1835
+ const desc = route.handler ? `Handler: ${route.handler}()` : route.scenarios?.length ? `Conditional scenarios (${route.scenarios.length})` : "Custom static route";
1836
+ const operation = {
1837
+ summary: `${route.method.toUpperCase()} ${route.path}`,
1838
+ description: desc,
1839
+ tags: ["custom"],
1840
+ ...pathParams.length > 0 ? { parameters: pathParams } : {},
1841
+ responses
1842
+ };
1843
+ if (!paths[openApiPath]) paths[openApiPath] = {};
1844
+ paths[openApiPath][method] = operation;
1845
+ }
1846
+ return paths;
1847
+ }
1848
+ function inferResponseSchema(body) {
1849
+ if (body === null || body === void 0) return {};
1850
+ if (typeof body !== "object" || Array.isArray(body)) return { type: "object" };
1851
+ const properties = {};
1852
+ for (const [key, value] of Object.entries(body)) {
1853
+ properties[key] = { type: jsToOpenApiType2(value) };
1854
+ }
1855
+ return { type: "object", properties };
1856
+ }
1857
+ function jsToOpenApiType2(value) {
1858
+ if (value === null || value === void 0) return "string";
1859
+ if (typeof value === "boolean") return "boolean";
1860
+ if (typeof value === "number") return Number.isInteger(value) ? "integer" : "number";
1861
+ if (Array.isArray(value)) return "array";
1862
+ if (typeof value === "object") return "object";
1863
+ return "string";
1864
+ }
1865
+
1866
+ // src/openapi/generateOpenApi.ts
1867
+ function generateOpenApi(storage, options, title = "yRest API") {
1868
+ const collections = Object.keys(storage.getData());
1869
+ const relations = storage.getRelations();
1870
+ const schemaBlock = storage.getSchema();
1871
+ const customRoutes = storage.getRoutes();
1872
+ const base = options.base ?? "";
1873
+ const schemas = {};
1874
+ for (const collection of collections) {
1875
+ const items = storage.getCollection(collection) ?? [];
1876
+ const fieldDefs = schemaBlock[collection] ?? {};
1877
+ const schemaName = toSchemaName(collection);
1878
+ schemas[schemaName] = buildCollectionSchema(items, fieldDefs);
1879
+ }
1880
+ const paths = {};
1881
+ for (const collection of collections) {
1882
+ const schemaName = toSchemaName(collection);
1883
+ Object.assign(paths, buildCrudPaths(collection, base, schemaName));
1884
+ }
1885
+ Object.assign(paths, buildRelationPaths(relations, base));
1886
+ Object.assign(paths, buildCustomRoutePaths(customRoutes, base));
1887
+ return {
1888
+ openapi: "3.0.3",
1889
+ info: {
1890
+ title,
1891
+ version: "1.0.0",
1892
+ description: "Generated by yRest from db.yml"
1893
+ },
1894
+ servers: [
1895
+ {
1896
+ url: `http://${options.host}:${options.port}${base}`,
1897
+ description: "yRest mock server"
1898
+ }
1899
+ ],
1900
+ paths,
1901
+ components: { schemas }
1902
+ };
1903
+ }
1904
+ function toSchemaName(collection) {
1905
+ const withoutS = collection.endsWith("s") ? collection.slice(0, -1) : collection;
1906
+ return withoutS.charAt(0).toUpperCase() + withoutS.slice(1);
1907
+ }
1908
+
1909
+ // src/router/routes/openapi.routes.ts
1910
+ var import_yaml2 = require("yaml");
1911
+ var OpenApiRouteCommand = class {
1912
+ constructor(storage, options) {
1913
+ this.storage = storage;
1914
+ this.options = options;
1915
+ }
1916
+ storage;
1917
+ options;
1918
+ register(server) {
1919
+ server.get("/_openapi", (_req, reply) => {
1920
+ const doc = generateOpenApi(this.storage, this.options);
1921
+ reply.header("Content-Type", "text/yaml; charset=utf-8");
1922
+ return reply.send((0, import_yaml2.stringify)(doc, { lineWidth: 0, aliasDuplicateObjects: false }));
1923
+ });
1924
+ server.get("/_openapi.json", (_req, reply) => {
1925
+ return reply.send(generateOpenApi(this.storage, this.options));
1926
+ });
1927
+ }
1928
+ };
1929
+
1514
1930
  // src/router/routes/snapshot.routes.ts
1515
1931
  init_cjs_shims();
1516
1932
  var SnapshotRouteCommand = class {
@@ -1592,6 +2008,7 @@ async function createServer(storage, options, handlers = /* @__PURE__ */ new Map
1592
2008
  }
1593
2009
  const commands = [
1594
2010
  new AboutRouteCommand(storage, options, handlers),
2011
+ new OpenApiRouteCommand(storage, options),
1595
2012
  ...options.snapshot ? [new SnapshotRouteCommand(storage)] : [],
1596
2013
  new CustomRouteCommand(storage, options.base, handlers),
1597
2014
  ...buildResourceRouteCommands(storage, options)
@@ -1671,10 +2088,12 @@ function buildOptions(opts) {
1671
2088
  }
1672
2089
  function createInMemoryStorage(data) {
1673
2090
  const raw = data;
2091
+ const RESERVED = /* @__PURE__ */ new Set(["_rel", "_routes", "_schema"]);
1674
2092
  const relations = parseRelations(raw["_rel"]);
1675
2093
  const routes = Array.isArray(raw["_routes"]) ? raw["_routes"] : [];
2094
+ const schema = parseSchema(raw["_schema"]);
1676
2095
  const collections = Object.fromEntries(
1677
- Object.entries(raw).filter(([k]) => k !== "_rel" && k !== "_routes")
2096
+ Object.entries(raw).filter(([k]) => !RESERVED.has(k))
1678
2097
  );
1679
2098
  let snapshot = {
1680
2099
  data: deepCopyData(collections),
@@ -1684,6 +2103,7 @@ function createInMemoryStorage(data) {
1684
2103
  return {
1685
2104
  getData: () => collections,
1686
2105
  getRelations: () => relations,
2106
+ getSchema: () => schema,
1687
2107
  getRoutes: () => routes,
1688
2108
  getCollection: (name) => collections[name],
1689
2109
  setCollection: (name, items) => {