@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.mjs CHANGED
@@ -67,6 +67,42 @@ var init_parseRelations = __esm({
67
67
  }
68
68
  });
69
69
 
70
+ // src/storage/parseSchema.ts
71
+ function parseSchema(raw) {
72
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
73
+ const result = {};
74
+ for (const [collection, fields] of Object.entries(raw)) {
75
+ if (!fields || typeof fields !== "object" || Array.isArray(fields)) continue;
76
+ result[collection] = {};
77
+ for (const [field, value] of Object.entries(fields)) {
78
+ const def = normaliseFieldDef(value);
79
+ if (def) result[collection][field] = def;
80
+ }
81
+ }
82
+ return result;
83
+ }
84
+ function normaliseFieldDef(value) {
85
+ if (value === "required") return { required: true };
86
+ if (value === "optional") return { required: false };
87
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
88
+ const v = value;
89
+ const def = {};
90
+ if (v["required"] === true || v["required"] === false) def.required = v["required"];
91
+ if (typeof v["type"] === "string" && ["string", "integer", "number", "boolean", "object", "array"].includes(v["type"]))
92
+ def.type = v["type"];
93
+ if (typeof v["format"] === "string") def.format = v["format"];
94
+ if (Array.isArray(v["enum"])) def.enum = v["enum"];
95
+ if (typeof v["description"] === "string") def.description = v["description"];
96
+ if (v["default"] !== void 0) def.default = v["default"];
97
+ return def;
98
+ }
99
+ var init_parseSchema = __esm({
100
+ "src/storage/parseSchema.ts"() {
101
+ "use strict";
102
+ init_esm_shims();
103
+ }
104
+ });
105
+
70
106
  // src/utils/deepCopy.ts
71
107
  function deepCopyData(source) {
72
108
  return Object.fromEntries(
@@ -88,14 +124,16 @@ __export(yrestStorage_exports, {
88
124
  import { readFileSync as readFileSync2, writeFileSync, renameSync } from "fs";
89
125
  import { resolve, dirname as dirname2 } from "path";
90
126
  import { randomUUID as randomUUID2 } from "crypto";
91
- import { parse as parse2, stringify } from "yaml";
127
+ import { parse as parse2, stringify as stringify2 } from "yaml";
92
128
  function createYrestStorage(filePath) {
93
129
  const absPath = resolve(filePath);
94
130
  const raw = parse2(readFileSync2(absPath, "utf8")) ?? {};
131
+ const RESERVED = /* @__PURE__ */ new Set(["_rel", "_routes", "_schema"]);
95
132
  const relations = parseRelations(raw["_rel"]);
96
133
  const routes = Array.isArray(raw["_routes"]) ? raw["_routes"] : [];
134
+ const schema = parseSchema(raw["_schema"]);
97
135
  const data = Object.fromEntries(
98
- Object.entries(raw).filter(([key]) => key !== "_rel" && key !== "_routes")
136
+ Object.entries(raw).filter(([key]) => !RESERVED.has(key))
99
137
  );
100
138
  let snapshot = {
101
139
  data: deepCopyData(data),
@@ -109,6 +147,9 @@ function createYrestStorage(filePath) {
109
147
  getRelations() {
110
148
  return relations;
111
149
  },
150
+ getSchema() {
151
+ return schema;
152
+ },
112
153
  getRoutes() {
113
154
  return routes;
114
155
  },
@@ -124,14 +165,14 @@ function createYrestStorage(filePath) {
124
165
  if (routes.length > 0) payload._routes = routes;
125
166
  Object.assign(payload, data);
126
167
  const tmp = resolve(dirname2(absPath), `.yrest-${randomUUID2()}.tmp`);
127
- writeFileSync(tmp, stringify(payload), "utf8");
168
+ writeFileSync(tmp, stringify2(payload), "utf8");
128
169
  renameSync(tmp, absPath);
129
170
  },
130
171
  reload() {
131
172
  const fresh = parse2(readFileSync2(absPath, "utf8")) ?? {};
132
173
  const freshRelations = parseRelations(fresh["_rel"]);
133
174
  const freshData = Object.fromEntries(
134
- Object.entries(fresh).filter(([key]) => key !== "_rel" && key !== "_routes")
175
+ Object.entries(fresh).filter(([key]) => !RESERVED.has(key))
135
176
  );
136
177
  for (const key of Object.keys(data)) delete data[key];
137
178
  Object.assign(data, freshData);
@@ -163,6 +204,7 @@ var init_yrestStorage = __esm({
163
204
  "use strict";
164
205
  init_esm_shims();
165
206
  init_parseRelations();
207
+ init_parseSchema();
166
208
  init_deepCopy();
167
209
  }
168
210
  });
@@ -215,6 +257,7 @@ function dedent(str) {
215
257
  // src/api/yrestServer.ts
216
258
  init_esm_shims();
217
259
  init_parseRelations();
260
+ init_parseSchema();
218
261
  import { resolve as resolve2 } from "path";
219
262
 
220
263
  // src/utils/handlers.ts
@@ -348,7 +391,7 @@ function endpointRow(method, path2, desc) {
348
391
  }
349
392
  function resourceAccordion(name, base, isOpen) {
350
393
  const p = `${base}/${name}`;
351
- const singular = name.endsWith("s") ? name.slice(0, -1) : name;
394
+ const singular2 = name.endsWith("s") ? name.slice(0, -1) : name;
352
395
  const rows = [
353
396
  endpointRow(
354
397
  "GET",
@@ -358,20 +401,20 @@ function resourceAccordion(name, base, isOpen) {
358
401
  endpointRow(
359
402
  "POST",
360
403
  p,
361
- `Create a new ${singular}. Auto-assigns <code>id</code> if not provided.`
404
+ `Create a new ${singular2}. Auto-assigns <code>id</code> if not provided.`
362
405
  ),
363
- endpointRow("GET", `${p}/:id`, `Get a single ${singular} by id.`),
406
+ endpointRow("GET", `${p}/:id`, `Get a single ${singular2} by id.`),
364
407
  endpointRow(
365
408
  "PUT",
366
409
  `${p}/:id`,
367
- `Fully replace a ${singular}. Original <code>id</code> is always preserved.`
410
+ `Fully replace a ${singular2}. Original <code>id</code> is always preserved.`
368
411
  ),
369
412
  endpointRow(
370
413
  "PATCH",
371
414
  `${p}/:id`,
372
- `Partially update a ${singular} \u2014 only provided fields change.`
415
+ `Partially update a ${singular2} \u2014 only provided fields change.`
373
416
  ),
374
- endpointRow("DELETE", `${p}/:id`, `Delete a ${singular} and return it as confirmation.`)
417
+ endpointRow("DELETE", `${p}/:id`, `Delete a ${singular2} and return it as confirmation.`)
375
418
  ].join("");
376
419
  return `
377
420
  <details class="resource-card" ${isOpen ? "open" : ""}>
@@ -390,13 +433,13 @@ function nestedRoutesAccordion(relations, base) {
390
433
  for (const [key, def] of Object.entries(fields)) {
391
434
  const nestedBadge = def.nested ? ` ${badge("nested", "#facc15", "#facc1518")}` : "";
392
435
  if (def.type === "many2many") {
393
- const singular = source.endsWith("s") ? source.slice(0, -1) : source;
436
+ const singular2 = source.endsWith("s") ? source.slice(0, -1) : source;
394
437
  const m2mBadge = badge("many2many", "#818cf8", "#818cf818");
395
438
  rows.push(
396
439
  endpointRow(
397
440
  "GET",
398
441
  `${base}/${source}/:id/${key}`,
399
- `List ${def.target} linked to a ${singular} via ${def.through}. ${m2mBadge}${nestedBadge}`
442
+ `List ${def.target} linked to a ${singular2} via ${def.through}. ${m2mBadge}${nestedBadge}`
400
443
  )
401
444
  );
402
445
  const targetSingular = def.target.endsWith("s") ? def.target.slice(0, -1) : def.target;
@@ -522,7 +565,7 @@ function examplesBlock(collections, relations, base, host, options, firstCustomR
522
565
  const firstCol = collections[0];
523
566
  if (firstCol) {
524
567
  const p = `${host}${base}/${firstCol}`;
525
- const singular = firstCol.endsWith("s") ? firstCol.slice(0, -1) : firstCol;
568
+ const singular2 = firstCol.endsWith("s") ? firstCol.slice(0, -1) : firstCol;
526
569
  examples.push(
527
570
  `# List all ${firstCol}
528
571
  curl ${p}`,
@@ -530,17 +573,17 @@ curl ${p}`,
530
573
  curl "${p}?name=value"`,
531
574
  `# Sort and paginate
532
575
  curl "${p}?_sort=id&_order=desc&_page=1&_limit=5"`,
533
- `# Get single ${singular}
576
+ `# Get single ${singular2}
534
577
  curl ${p}/1`,
535
- `# Create ${singular}
578
+ `# Create ${singular2}
536
579
  curl -X POST ${p} \\
537
580
  -H "Content-Type: application/json" \\
538
581
  -d '{"name":"example"}'`,
539
- `# Partially update ${singular}
582
+ `# Partially update ${singular2}
540
583
  curl -X PATCH ${p}/1 \\
541
584
  -H "Content-Type: application/json" \\
542
585
  -d '{"name":"updated"}'`,
543
- `# Delete ${singular}
586
+ `# Delete ${singular2}
544
587
  curl -X DELETE ${p}/1`
545
588
  );
546
589
  }
@@ -1477,6 +1520,379 @@ var NestedRouteCommand = class {
1477
1520
  }
1478
1521
  };
1479
1522
 
1523
+ // src/router/routes/openapi.routes.ts
1524
+ init_esm_shims();
1525
+
1526
+ // src/openapi/generateOpenApi.ts
1527
+ init_esm_shims();
1528
+
1529
+ // src/openapi/inferSchema.ts
1530
+ init_esm_shims();
1531
+ function buildCollectionSchema(items, fieldDefs = {}) {
1532
+ const sample = items.slice(0, 10);
1533
+ const inferredTypes = /* @__PURE__ */ new Map();
1534
+ for (const item of sample) {
1535
+ for (const [key, value] of Object.entries(item)) {
1536
+ if (!inferredTypes.has(key)) {
1537
+ inferredTypes.set(key, jsToOpenApiType(value));
1538
+ }
1539
+ }
1540
+ }
1541
+ const allFields = /* @__PURE__ */ new Set([...inferredTypes.keys(), ...Object.keys(fieldDefs)]);
1542
+ const properties = {};
1543
+ const required = [];
1544
+ for (const field of allFields) {
1545
+ const def = fieldDefs[field];
1546
+ const inferred = inferredTypes.get(field) ?? "string";
1547
+ const prop = {
1548
+ type: def?.type ?? inferred
1549
+ };
1550
+ if (def?.format) prop.format = def.format;
1551
+ if (def?.description) prop.description = def.description;
1552
+ if (def?.enum) prop.enum = def.enum;
1553
+ if (def?.default !== void 0) prop.default = def.default;
1554
+ properties[field] = prop;
1555
+ if (def?.required === true) required.push(field);
1556
+ }
1557
+ const schema = { type: "object", properties };
1558
+ if (required.length > 0) schema.required = required;
1559
+ return schema;
1560
+ }
1561
+ function jsToOpenApiType(value) {
1562
+ if (value === null || value === void 0) return "string";
1563
+ if (typeof value === "boolean") return "boolean";
1564
+ if (typeof value === "number") return Number.isInteger(value) ? "integer" : "number";
1565
+ if (Array.isArray(value)) return "array";
1566
+ if (typeof value === "object") return "object";
1567
+ return "string";
1568
+ }
1569
+
1570
+ // src/openapi/buildPaths.ts
1571
+ init_esm_shims();
1572
+ var COLLECTION_QUERY_PARAMS = [
1573
+ { name: "_page", in: "query", schema: { type: "integer" }, description: "Page number (1-based)" },
1574
+ { name: "_limit", in: "query", schema: { type: "integer" }, description: "Items per page" },
1575
+ { name: "_sort", in: "query", schema: { type: "string" }, description: "Field name to sort by" },
1576
+ {
1577
+ name: "_order",
1578
+ in: "query",
1579
+ schema: { type: "string", enum: ["asc", "desc"] },
1580
+ description: "Sort direction"
1581
+ },
1582
+ {
1583
+ name: "_q",
1584
+ in: "query",
1585
+ schema: { type: "string" },
1586
+ description: "Full-text search across all scalar fields (case-insensitive)"
1587
+ },
1588
+ {
1589
+ name: "_expand",
1590
+ in: "query",
1591
+ schema: { type: "string" },
1592
+ description: "Embed related parent object inline (e.g. ?_expand=user)"
1593
+ },
1594
+ {
1595
+ name: "_embed",
1596
+ in: "query",
1597
+ schema: { type: "string" },
1598
+ description: "Embed child collection into each item (e.g. ?_embed=posts)"
1599
+ },
1600
+ {
1601
+ name: "_fields",
1602
+ in: "query",
1603
+ schema: { type: "string" },
1604
+ description: "Comma-separated field projection (e.g. ?_fields=id,name)"
1605
+ }
1606
+ ];
1607
+ var ID_PATH_PARAM = {
1608
+ name: "id",
1609
+ in: "path",
1610
+ required: true,
1611
+ schema: { type: "string" },
1612
+ description: "Item id"
1613
+ };
1614
+ function toOpenApiPath(fastifyPath) {
1615
+ return fastifyPath.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, "{$1}");
1616
+ }
1617
+ function extractPathParams(fastifyPath) {
1618
+ const matches = fastifyPath.match(/:([a-zA-Z_][a-zA-Z0-9_]*)/g) ?? [];
1619
+ return matches.map((m) => ({
1620
+ name: m.slice(1),
1621
+ in: "path",
1622
+ required: true,
1623
+ schema: { type: "string" }
1624
+ }));
1625
+ }
1626
+ function singular(name) {
1627
+ return name.endsWith("s") ? name.slice(0, -1) : name;
1628
+ }
1629
+ function schemaRef(name) {
1630
+ return { $ref: `#/components/schemas/${name}` };
1631
+ }
1632
+ function jsonContent(schema) {
1633
+ return { "application/json": { schema } };
1634
+ }
1635
+ function ok(schema, description = "OK") {
1636
+ return { description, content: jsonContent(schema) };
1637
+ }
1638
+ function buildCrudPaths(collection, base, schemaName) {
1639
+ const ref = schemaRef(schemaName);
1640
+ const tag = collection;
1641
+ const sing = singular(collection);
1642
+ const collPath = `${base}/${collection}`;
1643
+ const itemPath = `${base}/${collection}/{id}`;
1644
+ return {
1645
+ [collPath]: {
1646
+ get: {
1647
+ summary: `List ${collection}`,
1648
+ tags: [tag],
1649
+ parameters: COLLECTION_QUERY_PARAMS,
1650
+ responses: {
1651
+ "200": {
1652
+ description: "OK",
1653
+ content: jsonContent({ type: "array", items: ref }),
1654
+ headers: {
1655
+ "X-Total-Count": {
1656
+ description: "Total items (when using ?_page / ?_limit)",
1657
+ schema: { type: "integer" }
1658
+ }
1659
+ }
1660
+ }
1661
+ }
1662
+ },
1663
+ post: {
1664
+ summary: `Create ${sing}`,
1665
+ tags: [tag],
1666
+ requestBody: { required: true, content: jsonContent(ref) },
1667
+ responses: { "201": ok(ref, "Created") }
1668
+ }
1669
+ },
1670
+ [itemPath]: {
1671
+ get: {
1672
+ summary: `Get ${sing}`,
1673
+ tags: [tag],
1674
+ parameters: [
1675
+ ID_PATH_PARAM,
1676
+ ...COLLECTION_QUERY_PARAMS.filter(
1677
+ (p) => ["_expand", "_embed", "_fields"].includes(p.name)
1678
+ )
1679
+ ],
1680
+ responses: { "200": ok(ref), "404": { description: "Not found" } }
1681
+ },
1682
+ put: {
1683
+ summary: `Replace ${sing}`,
1684
+ tags: [tag],
1685
+ parameters: [ID_PATH_PARAM],
1686
+ requestBody: { required: true, content: jsonContent(ref) },
1687
+ responses: { "200": ok(ref), "404": { description: "Not found" } }
1688
+ },
1689
+ patch: {
1690
+ summary: `Update ${sing}`,
1691
+ tags: [tag],
1692
+ parameters: [ID_PATH_PARAM],
1693
+ requestBody: { required: false, content: jsonContent(ref) },
1694
+ responses: { "200": ok(ref), "404": { description: "Not found" } }
1695
+ },
1696
+ delete: {
1697
+ summary: `Delete ${sing}`,
1698
+ tags: [tag],
1699
+ parameters: [ID_PATH_PARAM],
1700
+ responses: { "200": ok(ref, "Deleted item returned"), "404": { description: "Not found" } }
1701
+ }
1702
+ }
1703
+ };
1704
+ }
1705
+ function buildRelationPaths(relations, base) {
1706
+ const paths = {};
1707
+ for (const [source, fields] of Object.entries(relations)) {
1708
+ for (const [key, def] of Object.entries(fields)) {
1709
+ if (def.type === "many2many") {
1710
+ const forwardPath = `${base}/${source}/{id}/${key}`;
1711
+ const inversePath = `${base}/${def.target}/{id}/${source}`;
1712
+ paths[forwardPath] = {
1713
+ get: {
1714
+ summary: `List ${def.target} linked to ${singular(source)} via ${def.through}`,
1715
+ tags: [source],
1716
+ parameters: [ID_PATH_PARAM],
1717
+ responses: {
1718
+ "200": {
1719
+ description: "OK",
1720
+ content: jsonContent({ type: "array", items: { type: "object" } })
1721
+ },
1722
+ "404": { description: `${singular(source)} not found` }
1723
+ }
1724
+ }
1725
+ };
1726
+ paths[inversePath] = {
1727
+ get: {
1728
+ summary: `List ${source} linked to ${singular(def.target)} via ${def.through} (inverse)`,
1729
+ tags: [def.target],
1730
+ parameters: [ID_PATH_PARAM],
1731
+ responses: {
1732
+ "200": {
1733
+ description: "OK",
1734
+ content: jsonContent({ type: "array", items: { type: "object" } })
1735
+ },
1736
+ "404": { description: `${singular(def.target)} not found` }
1737
+ }
1738
+ }
1739
+ };
1740
+ } else {
1741
+ const parentSing = singular(def.target);
1742
+ const collPath = `${base}/${def.target}/{id}/${source}`;
1743
+ const isOne2One = def.type === "one2one";
1744
+ const responseSchema = isOne2One ? { type: "object" } : { type: "array", items: { type: "object" } };
1745
+ paths[collPath] = {
1746
+ get: {
1747
+ summary: isOne2One ? `Get ${singular(source)} belonging to ${parentSing}` : `List ${source} belonging to ${parentSing}`,
1748
+ tags: [def.target],
1749
+ parameters: [ID_PATH_PARAM],
1750
+ responses: {
1751
+ "200": { description: "OK", content: jsonContent(responseSchema) },
1752
+ "404": { description: `${parentSing} not found` }
1753
+ }
1754
+ }
1755
+ };
1756
+ if (!isOne2One) {
1757
+ const itemPath = `${base}/${def.target}/{id}/${source}/{childId}`;
1758
+ paths[itemPath] = {
1759
+ get: {
1760
+ summary: `Get single ${singular(source)} scoped to ${parentSing}`,
1761
+ tags: [def.target],
1762
+ parameters: [
1763
+ ID_PATH_PARAM,
1764
+ { name: "childId", in: "path", required: true, schema: { type: "string" } }
1765
+ ],
1766
+ responses: {
1767
+ "200": { description: "OK", content: jsonContent({ type: "object" }) },
1768
+ "404": { description: "Not found" }
1769
+ }
1770
+ }
1771
+ };
1772
+ }
1773
+ }
1774
+ }
1775
+ }
1776
+ return paths;
1777
+ }
1778
+ function buildCustomRoutePaths(routes, base) {
1779
+ const paths = {};
1780
+ for (const route of routes) {
1781
+ const openApiPath = toOpenApiPath(`${base}${route.path}`);
1782
+ const method = route.method.toLowerCase();
1783
+ const pathParams = extractPathParams(route.path);
1784
+ const responses = {};
1785
+ if (route.error) {
1786
+ responses[String(route.error)] = { description: `Forced error ${route.error}` };
1787
+ } else {
1788
+ const statuses = /* @__PURE__ */ new Set();
1789
+ for (const s of route.scenarios ?? []) statuses.add(s.response.status ?? 200);
1790
+ if (route.otherwise) statuses.add(route.otherwise.status ?? 200);
1791
+ if (route.response) statuses.add(route.response.status ?? 200);
1792
+ if (statuses.size === 0) statuses.add(200);
1793
+ for (const status of statuses) {
1794
+ const bodySource = (route.scenarios ?? []).find((s) => (s.response.status ?? 200) === status)?.response.body ?? (route.otherwise?.status ?? 200) === status ? route.otherwise?.body : route.response?.body;
1795
+ responses[String(status)] = {
1796
+ description: status < 400 ? "OK" : "Error",
1797
+ ...bodySource != null ? { content: jsonContent(inferResponseSchema(bodySource)) } : {}
1798
+ };
1799
+ }
1800
+ }
1801
+ const desc = route.handler ? `Handler: ${route.handler}()` : route.scenarios?.length ? `Conditional scenarios (${route.scenarios.length})` : "Custom static route";
1802
+ const operation = {
1803
+ summary: `${route.method.toUpperCase()} ${route.path}`,
1804
+ description: desc,
1805
+ tags: ["custom"],
1806
+ ...pathParams.length > 0 ? { parameters: pathParams } : {},
1807
+ responses
1808
+ };
1809
+ if (!paths[openApiPath]) paths[openApiPath] = {};
1810
+ paths[openApiPath][method] = operation;
1811
+ }
1812
+ return paths;
1813
+ }
1814
+ function inferResponseSchema(body) {
1815
+ if (body === null || body === void 0) return {};
1816
+ if (typeof body !== "object" || Array.isArray(body)) return { type: "object" };
1817
+ const properties = {};
1818
+ for (const [key, value] of Object.entries(body)) {
1819
+ properties[key] = { type: jsToOpenApiType2(value) };
1820
+ }
1821
+ return { type: "object", properties };
1822
+ }
1823
+ function jsToOpenApiType2(value) {
1824
+ if (value === null || value === void 0) return "string";
1825
+ if (typeof value === "boolean") return "boolean";
1826
+ if (typeof value === "number") return Number.isInteger(value) ? "integer" : "number";
1827
+ if (Array.isArray(value)) return "array";
1828
+ if (typeof value === "object") return "object";
1829
+ return "string";
1830
+ }
1831
+
1832
+ // src/openapi/generateOpenApi.ts
1833
+ function generateOpenApi(storage, options, title = "yRest API") {
1834
+ const collections = Object.keys(storage.getData());
1835
+ const relations = storage.getRelations();
1836
+ const schemaBlock = storage.getSchema();
1837
+ const customRoutes = storage.getRoutes();
1838
+ const base = options.base ?? "";
1839
+ const schemas = {};
1840
+ for (const collection of collections) {
1841
+ const items = storage.getCollection(collection) ?? [];
1842
+ const fieldDefs = schemaBlock[collection] ?? {};
1843
+ const schemaName = toSchemaName(collection);
1844
+ schemas[schemaName] = buildCollectionSchema(items, fieldDefs);
1845
+ }
1846
+ const paths = {};
1847
+ for (const collection of collections) {
1848
+ const schemaName = toSchemaName(collection);
1849
+ Object.assign(paths, buildCrudPaths(collection, base, schemaName));
1850
+ }
1851
+ Object.assign(paths, buildRelationPaths(relations, base));
1852
+ Object.assign(paths, buildCustomRoutePaths(customRoutes, base));
1853
+ return {
1854
+ openapi: "3.0.3",
1855
+ info: {
1856
+ title,
1857
+ version: "1.0.0",
1858
+ description: "Generated by yRest from db.yml"
1859
+ },
1860
+ servers: [
1861
+ {
1862
+ url: `http://${options.host}:${options.port}${base}`,
1863
+ description: "yRest mock server"
1864
+ }
1865
+ ],
1866
+ paths,
1867
+ components: { schemas }
1868
+ };
1869
+ }
1870
+ function toSchemaName(collection) {
1871
+ const withoutS = collection.endsWith("s") ? collection.slice(0, -1) : collection;
1872
+ return withoutS.charAt(0).toUpperCase() + withoutS.slice(1);
1873
+ }
1874
+
1875
+ // src/router/routes/openapi.routes.ts
1876
+ import { stringify } from "yaml";
1877
+ var OpenApiRouteCommand = class {
1878
+ constructor(storage, options) {
1879
+ this.storage = storage;
1880
+ this.options = options;
1881
+ }
1882
+ storage;
1883
+ options;
1884
+ register(server) {
1885
+ server.get("/_openapi", (_req, reply) => {
1886
+ const doc = generateOpenApi(this.storage, this.options);
1887
+ reply.header("Content-Type", "text/yaml; charset=utf-8");
1888
+ return reply.send(stringify(doc, { lineWidth: 0, aliasDuplicateObjects: false }));
1889
+ });
1890
+ server.get("/_openapi.json", (_req, reply) => {
1891
+ return reply.send(generateOpenApi(this.storage, this.options));
1892
+ });
1893
+ }
1894
+ };
1895
+
1480
1896
  // src/router/routes/snapshot.routes.ts
1481
1897
  init_esm_shims();
1482
1898
  var SnapshotRouteCommand = class {
@@ -1558,6 +1974,7 @@ async function createServer(storage, options, handlers = /* @__PURE__ */ new Map
1558
1974
  }
1559
1975
  const commands = [
1560
1976
  new AboutRouteCommand(storage, options, handlers),
1977
+ new OpenApiRouteCommand(storage, options),
1561
1978
  ...options.snapshot ? [new SnapshotRouteCommand(storage)] : [],
1562
1979
  new CustomRouteCommand(storage, options.base, handlers),
1563
1980
  ...buildResourceRouteCommands(storage, options)
@@ -1637,10 +2054,12 @@ function buildOptions(opts) {
1637
2054
  }
1638
2055
  function createInMemoryStorage(data) {
1639
2056
  const raw = data;
2057
+ const RESERVED = /* @__PURE__ */ new Set(["_rel", "_routes", "_schema"]);
1640
2058
  const relations = parseRelations(raw["_rel"]);
1641
2059
  const routes = Array.isArray(raw["_routes"]) ? raw["_routes"] : [];
2060
+ const schema = parseSchema(raw["_schema"]);
1642
2061
  const collections = Object.fromEntries(
1643
- Object.entries(raw).filter(([k]) => k !== "_rel" && k !== "_routes")
2062
+ Object.entries(raw).filter(([k]) => !RESERVED.has(k))
1644
2063
  );
1645
2064
  let snapshot = {
1646
2065
  data: deepCopyData(collections),
@@ -1650,6 +2069,7 @@ function createInMemoryStorage(data) {
1650
2069
  return {
1651
2070
  getData: () => collections,
1652
2071
  getRelations: () => relations,
2072
+ getSchema: () => schema,
1653
2073
  getRoutes: () => routes,
1654
2074
  getCollection: (name) => collections[name],
1655
2075
  setCollection: (name, items) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yrest/cli",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "YAML-powered json-server alternative. Zero-config REST API mock server with full CRUD, relations, filters and snapshots from a db.yml file.",
5
5
  "keywords": [
6
6
  "yrest",