crudora 0.3.0 → 0.4.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.js CHANGED
@@ -1,3 +1,10 @@
1
+ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
2
+ get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
3
+ }) : x)(function(x) {
4
+ if (typeof require !== "undefined") return require.apply(this, arguments);
5
+ throw Error('Dynamic require of "' + x + '" is not supported');
6
+ });
7
+
1
8
  // src/index.ts
2
9
  import "reflect-metadata";
3
10
 
@@ -832,6 +839,299 @@ function getColumnImport(type, dialect) {
832
839
  }
833
840
  }
834
841
 
842
+ // src/core/openApiGenerator.ts
843
+ var SYSTEM_FIELDS2 = /* @__PURE__ */ new Set(["createdAt", "updatedAt", "deletedAt"]);
844
+ function fieldToSchema(opts) {
845
+ switch (opts.type) {
846
+ case "uuid":
847
+ return { type: "string", format: "uuid" };
848
+ case "text":
849
+ return { type: "string" };
850
+ case "integer":
851
+ return { type: "integer" };
852
+ case "number":
853
+ return { type: "number", format: "double" };
854
+ case "boolean":
855
+ return { type: "boolean" };
856
+ case "date":
857
+ return { type: "string", format: "date-time" };
858
+ case "decimal":
859
+ return { type: "number", format: "decimal" };
860
+ case "json":
861
+ return { type: "object" };
862
+ case "enum":
863
+ return { type: "string", enum: opts.enumValues ?? [] };
864
+ case "bigint":
865
+ return { type: "integer", format: "int64" };
866
+ case "serial":
867
+ return { type: "integer" };
868
+ case "array":
869
+ return { type: "array", items: { type: "string" } };
870
+ default: {
871
+ const s = { type: "string" };
872
+ if (opts.length) s.maxLength = opts.length;
873
+ return s;
874
+ }
875
+ }
876
+ }
877
+ function buildOutputSchema(modelClass) {
878
+ const fields = getFieldMetadata(modelClass);
879
+ const hidden = new Set(modelClass.hidden ?? []);
880
+ const properties = {};
881
+ for (const [name, opts] of Object.entries(fields)) {
882
+ if (hidden.has(name)) continue;
883
+ const schema = fieldToSchema(opts);
884
+ if (opts.nullable) schema.nullable = true;
885
+ properties[name] = schema;
886
+ }
887
+ if (modelClass.timestamps !== false) {
888
+ properties.createdAt = { type: "string", format: "date-time" };
889
+ properties.updatedAt = { type: "string", format: "date-time" };
890
+ }
891
+ if (modelClass.softDelete) {
892
+ properties.deletedAt = { type: "string", format: "date-time", nullable: true };
893
+ }
894
+ return { type: "object", properties };
895
+ }
896
+ function buildInputSchema(modelClass) {
897
+ const fields = getFieldMetadata(modelClass);
898
+ const fillable = modelClass.fillable;
899
+ const properties = {};
900
+ const required = [];
901
+ let entries = Object.entries(fields).filter(
902
+ ([name, opts]) => !opts.primary && !SYSTEM_FIELDS2.has(name)
903
+ );
904
+ if (fillable?.length) {
905
+ entries = entries.filter(([name]) => fillable.includes(name));
906
+ }
907
+ for (const [name, opts] of entries) {
908
+ const schema2 = fieldToSchema(opts);
909
+ if (opts.nullable) schema2.nullable = true;
910
+ properties[name] = schema2;
911
+ if (opts.required && !opts.nullable) required.push(name);
912
+ }
913
+ const schema = { type: "object", properties };
914
+ if (required.length > 0) schema.required = required;
915
+ return schema;
916
+ }
917
+ function capital(s) {
918
+ return s.charAt(0).toUpperCase() + s.slice(1);
919
+ }
920
+ function itemLabel(tableName) {
921
+ return tableName.endsWith("s") ? tableName.slice(0, -1) : tableName;
922
+ }
923
+ var listResponse = (ref) => ({
924
+ description: "OK",
925
+ content: {
926
+ "application/json": {
927
+ schema: {
928
+ type: "object",
929
+ properties: {
930
+ success: { type: "boolean", example: true },
931
+ data: { type: "array", items: { $ref: ref } },
932
+ meta: {
933
+ type: "object",
934
+ properties: {
935
+ pagination: {
936
+ type: "object",
937
+ properties: {
938
+ page: { type: "integer" },
939
+ limit: { type: "integer" },
940
+ total: { type: "integer" },
941
+ pages: { type: "integer" }
942
+ }
943
+ }
944
+ }
945
+ }
946
+ }
947
+ }
948
+ }
949
+ }
950
+ });
951
+ var itemResponse = (ref, status = "OK") => ({
952
+ description: status,
953
+ content: {
954
+ "application/json": {
955
+ schema: {
956
+ type: "object",
957
+ properties: {
958
+ success: { type: "boolean", example: true },
959
+ data: { $ref: ref }
960
+ }
961
+ }
962
+ }
963
+ }
964
+ });
965
+ var errorResponse = (description, code) => ({
966
+ description,
967
+ content: {
968
+ "application/json": {
969
+ schema: {
970
+ type: "object",
971
+ properties: {
972
+ success: { type: "boolean", example: false },
973
+ error: {
974
+ type: "object",
975
+ properties: {
976
+ code: { type: "string", example: code },
977
+ message: { type: "string" }
978
+ }
979
+ }
980
+ }
981
+ }
982
+ }
983
+ }
984
+ });
985
+ var validationError = {
986
+ description: "Validation Error",
987
+ content: {
988
+ "application/json": {
989
+ schema: {
990
+ type: "object",
991
+ properties: {
992
+ success: { type: "boolean", example: false },
993
+ error: {
994
+ type: "object",
995
+ properties: {
996
+ code: { type: "string", example: "VALIDATION_ERROR" },
997
+ message: { type: "string" },
998
+ details: {
999
+ type: "array",
1000
+ items: {
1001
+ type: "object",
1002
+ properties: {
1003
+ field: { type: "string" },
1004
+ message: { type: "string" }
1005
+ }
1006
+ }
1007
+ }
1008
+ }
1009
+ }
1010
+ }
1011
+ }
1012
+ }
1013
+ }
1014
+ };
1015
+ var idParam = { in: "path", name: "id", required: true, schema: { type: "string" } };
1016
+ var OpenApiGenerator = class {
1017
+ static generate(models, customRoutes, basePath, info) {
1018
+ const schemas = {};
1019
+ const paths = {};
1020
+ for (const [, modelClass] of models) {
1021
+ const tableName = modelClass.getTableName();
1022
+ const tag = capital(tableName);
1023
+ const item = itemLabel(tableName);
1024
+ const ref = `#/components/schemas/${tag}`;
1025
+ const inputRef = `#/components/schemas/${tag}Input`;
1026
+ const col = `${basePath}/${tableName}`;
1027
+ const byId = `${basePath}/${tableName}/{id}`;
1028
+ schemas[tag] = buildOutputSchema(modelClass);
1029
+ schemas[`${tag}Input`] = buildInputSchema(modelClass);
1030
+ paths[col] = {
1031
+ get: {
1032
+ summary: `List ${tableName}`,
1033
+ operationId: `list${tag}`,
1034
+ tags: [tag],
1035
+ parameters: [
1036
+ { in: "query", name: "page", schema: { type: "integer", default: 1 } },
1037
+ { in: "query", name: "limit", schema: { type: "integer", default: 10 } },
1038
+ { in: "query", name: "cursor", schema: { type: "string" } },
1039
+ { in: "query", name: "sortBy", schema: { type: "string" } },
1040
+ { in: "query", name: "sortOrder", schema: { type: "string", enum: ["asc", "desc"] } },
1041
+ { in: "query", name: "withDeleted", schema: { type: "boolean" } }
1042
+ ],
1043
+ responses: { "200": listResponse(ref) }
1044
+ },
1045
+ post: {
1046
+ summary: `Create ${item}`,
1047
+ operationId: `create${tag}`,
1048
+ tags: [tag],
1049
+ requestBody: {
1050
+ required: true,
1051
+ content: { "application/json": { schema: { $ref: inputRef } } }
1052
+ },
1053
+ responses: {
1054
+ "201": itemResponse(ref, "Created"),
1055
+ "422": validationError
1056
+ }
1057
+ }
1058
+ };
1059
+ paths[byId] = {
1060
+ get: {
1061
+ summary: `Get ${item} by ID`,
1062
+ operationId: `get${tag}ById`,
1063
+ tags: [tag],
1064
+ parameters: [idParam],
1065
+ responses: {
1066
+ "200": itemResponse(ref),
1067
+ "404": errorResponse("Not Found", "NOT_FOUND")
1068
+ }
1069
+ },
1070
+ put: {
1071
+ summary: `Replace ${item}`,
1072
+ operationId: `replace${tag}`,
1073
+ tags: [tag],
1074
+ parameters: [idParam],
1075
+ requestBody: {
1076
+ required: true,
1077
+ content: { "application/json": { schema: { $ref: inputRef } } }
1078
+ },
1079
+ responses: {
1080
+ "200": itemResponse(ref),
1081
+ "404": errorResponse("Not Found", "NOT_FOUND"),
1082
+ "422": validationError
1083
+ }
1084
+ },
1085
+ patch: {
1086
+ summary: `Update ${item}`,
1087
+ operationId: `update${tag}`,
1088
+ tags: [tag],
1089
+ parameters: [idParam],
1090
+ requestBody: {
1091
+ required: true,
1092
+ content: { "application/json": { schema: { $ref: inputRef } } }
1093
+ },
1094
+ responses: {
1095
+ "200": itemResponse(ref),
1096
+ "404": errorResponse("Not Found", "NOT_FOUND"),
1097
+ "422": validationError
1098
+ }
1099
+ },
1100
+ delete: {
1101
+ summary: `Delete ${item}`,
1102
+ operationId: `delete${tag}`,
1103
+ tags: [tag],
1104
+ parameters: [idParam],
1105
+ responses: {
1106
+ "200": itemResponse(ref),
1107
+ "404": errorResponse("Not Found", "NOT_FOUND")
1108
+ }
1109
+ }
1110
+ };
1111
+ }
1112
+ for (const route of customRoutes) {
1113
+ const fullPath = `${basePath}${route.path}`.replace(/:([a-zA-Z_]+)/g, "{$1}");
1114
+ if (!paths[fullPath]) paths[fullPath] = {};
1115
+ paths[fullPath][route.method.toLowerCase()] = {
1116
+ summary: `${route.method} ${route.path}`,
1117
+ tags: ["Custom"],
1118
+ responses: { "200": { description: "OK" } }
1119
+ };
1120
+ }
1121
+ const spec = {
1122
+ openapi: "3.0.0",
1123
+ info: {
1124
+ title: info?.title ?? "Crudora API",
1125
+ version: info?.version ?? "1.0.0"
1126
+ },
1127
+ paths,
1128
+ components: { schemas }
1129
+ };
1130
+ if (info?.description) spec.info.description = info.description;
1131
+ return spec;
1132
+ }
1133
+ };
1134
+
835
1135
  // src/utils/validation.ts
836
1136
  import { z } from "zod";
837
1137
  function zodTypeFor(opts, forStrict) {
@@ -884,14 +1184,14 @@ function zodTypeFor(opts, forStrict) {
884
1184
  }
885
1185
  return base.optional();
886
1186
  }
887
- var SYSTEM_FIELDS2 = /* @__PURE__ */ new Set(["createdAt", "updatedAt", "deletedAt"]);
1187
+ var SYSTEM_FIELDS3 = /* @__PURE__ */ new Set(["createdAt", "updatedAt", "deletedAt"]);
888
1188
  function resolveFields(modelClass) {
889
1189
  const fieldMeta = getFieldMetadata(modelClass);
890
1190
  const hasMeta = Object.keys(fieldMeta).length > 0;
891
1191
  const fillable = modelClass.fillable;
892
1192
  if (hasMeta) {
893
1193
  let entries = Object.entries(fieldMeta).filter(
894
- ([name, opts]) => !opts.primary && !SYSTEM_FIELDS2.has(name)
1194
+ ([name, opts]) => !opts.primary && !SYSTEM_FIELDS3.has(name)
895
1195
  );
896
1196
  if (fillable?.length) {
897
1197
  entries = entries.filter(([name]) => fillable.includes(name));
@@ -1197,6 +1497,14 @@ var Crudora = class {
1197
1497
  const modelClasses = Array.from(this.models.values());
1198
1498
  return SchemaGenerator.generateDrizzleSchema(modelClasses, this.dialect);
1199
1499
  }
1500
+ generateOpenApiSpec(basePath = "/api", info) {
1501
+ return OpenApiGenerator.generate(
1502
+ this.models,
1503
+ this.customRoutes.map(({ method, path }) => ({ method, path })),
1504
+ basePath,
1505
+ info
1506
+ );
1507
+ }
1200
1508
  getValidationSchema(modelClass) {
1201
1509
  return ValidationGenerator.generateZodSchema(modelClass);
1202
1510
  }
@@ -1491,6 +1799,40 @@ function createDefaultLogger() {
1491
1799
  debug: (msg, ctx) => console.debug(fmt("debug", msg, ctx))
1492
1800
  };
1493
1801
  }
1802
+ function resolveDocs(docs) {
1803
+ if (!docs) return false;
1804
+ if (docs === true) return { path: "/docs" };
1805
+ if (typeof docs === "string") return { path: docs };
1806
+ return { path: docs.path ?? "/docs", ...docs };
1807
+ }
1808
+ function tryRequireScalar() {
1809
+ try {
1810
+ return __require("@scalar/express-api-reference");
1811
+ } catch {
1812
+ return null;
1813
+ }
1814
+ }
1815
+ function buildInstallPromptHtml(specUrl) {
1816
+ return `<!DOCTYPE html>
1817
+ <html>
1818
+ <head>
1819
+ <title>API Docs</title>
1820
+ <meta charset="utf-8" />
1821
+ <style>
1822
+ body { font-family: system-ui, sans-serif; max-width: 600px; margin: 80px auto; padding: 0 24px; color: #333; }
1823
+ code { background: #f4f4f4; padding: 2px 6px; border-radius: 4px; }
1824
+ pre { background: #f4f4f4; padding: 16px; border-radius: 8px; overflow: auto; }
1825
+ a { color: #0070f3; }
1826
+ </style>
1827
+ </head>
1828
+ <body>
1829
+ <h2>API Docs</h2>
1830
+ <p>Install <code>@scalar/express-api-reference</code> to enable the interactive UI:</p>
1831
+ <pre>npm install @scalar/express-api-reference</pre>
1832
+ <p>OpenAPI spec: <a href="${specUrl}">${specUrl}</a></p>
1833
+ </body>
1834
+ </html>`;
1835
+ }
1494
1836
  var CrudoraServer = class {
1495
1837
  constructor(config) {
1496
1838
  this.httpServer = null;
@@ -1511,7 +1853,8 @@ var CrudoraServer = class {
1511
1853
  healthCheck: true,
1512
1854
  ...config,
1513
1855
  logger: resolvedLogger,
1514
- rateLimit: resolvedRateLimit
1856
+ rateLimit: resolvedRateLimit,
1857
+ docs: resolveDocs(config.docs)
1515
1858
  };
1516
1859
  this.app = express();
1517
1860
  this.crudora = new Crudora(
@@ -1592,6 +1935,31 @@ var CrudoraServer = class {
1592
1935
  res.json({ success: true, data: { status: "ok", timestamp: (/* @__PURE__ */ new Date()).toISOString() } });
1593
1936
  });
1594
1937
  }
1938
+ if (this.config.docs !== false) {
1939
+ const { path: docsPath, title, version, description, scalar: scalarOpts } = this.config.docs;
1940
+ const specPath = `${docsPath}/openapi.json`;
1941
+ const spec = this.crudora.generateOpenApiSpec(this.config.basePath, { title, version, description });
1942
+ this.app.get(specPath, (_req, res) => {
1943
+ res.json(spec);
1944
+ });
1945
+ const scalarPkg = tryRequireScalar();
1946
+ if (scalarPkg) {
1947
+ this.app.use(docsPath, scalarPkg.apiReference({
1948
+ spec: { url: specPath },
1949
+ ...scalarOpts
1950
+ }));
1951
+ } else {
1952
+ if (this.config.logger !== false) {
1953
+ this.config.logger.warn(
1954
+ 'docs is enabled but @scalar/express-api-reference is not installed. Run "npm install @scalar/express-api-reference" to enable the interactive UI.'
1955
+ );
1956
+ }
1957
+ this.app.get(docsPath, (_req, res) => {
1958
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
1959
+ res.send(buildInstallPromptHtml(specPath));
1960
+ });
1961
+ }
1962
+ }
1595
1963
  this.crudora.generateRoutes(this.app, this.config.basePath);
1596
1964
  return this;
1597
1965
  }
@@ -1612,6 +1980,9 @@ var CrudoraServer = class {
1612
1980
  this.httpServer.listen(this.config.port, () => {
1613
1981
  console.log(`\u{1F680} Crudora server running on port ${this.config.port}`);
1614
1982
  console.log(`\u{1F4DA} API available at http://localhost:${this.config.port}${this.config.basePath}`);
1983
+ if (this.config.docs !== false) {
1984
+ console.log(`\u{1F4D6} API docs at http://localhost:${this.config.port}${this.config.docs.path}`);
1985
+ }
1615
1986
  if (callback) callback();
1616
1987
  });
1617
1988
  return this.httpServer;
@@ -1717,6 +2088,7 @@ export {
1717
2088
  HasOne,
1718
2089
  Model,
1719
2090
  NotFoundError,
2091
+ OpenApiGenerator,
1720
2092
  Repository,
1721
2093
  SchemaGenerator,
1722
2094
  ValidationGenerator,