crudora 0.2.1 → 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
  }
@@ -1446,6 +1754,7 @@ var Crudora = class {
1446
1754
  };
1447
1755
 
1448
1756
  // src/core/crudoraServer.ts
1757
+ import http from "http";
1449
1758
  import express from "express";
1450
1759
  import { randomUUID as randomUUID2 } from "crypto";
1451
1760
  function createRateLimiter(config) {
@@ -1490,8 +1799,43 @@ function createDefaultLogger() {
1490
1799
  debug: (msg, ctx) => console.debug(fmt("debug", msg, ctx))
1491
1800
  };
1492
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
+ }
1493
1836
  var CrudoraServer = class {
1494
1837
  constructor(config) {
1838
+ this.httpServer = null;
1495
1839
  const resolvedLogger = config.logger === void 0 ? createDefaultLogger() : config.logger;
1496
1840
  const resolvedRateLimit = config.rateLimit === false ? false : {
1497
1841
  windowMs: config.rateLimit?.windowMs ?? 6e4,
@@ -1505,9 +1849,12 @@ var CrudoraServer = class {
1505
1849
  bodyParser: true,
1506
1850
  bodyParserLimit: "100kb",
1507
1851
  basePath: "/api",
1852
+ timeout: 0,
1853
+ healthCheck: true,
1508
1854
  ...config,
1509
1855
  logger: resolvedLogger,
1510
- rateLimit: resolvedRateLimit
1856
+ rateLimit: resolvedRateLimit,
1857
+ docs: resolveDocs(config.docs)
1511
1858
  };
1512
1859
  this.app = express();
1513
1860
  this.crudora = new Crudora(
@@ -1528,6 +1875,19 @@ var CrudoraServer = class {
1528
1875
  req.correlationId = randomUUID2();
1529
1876
  next();
1530
1877
  });
1878
+ if (this.config.timeout > 0) {
1879
+ const timeoutMs = this.config.timeout;
1880
+ this.app.use((_req, res, next) => {
1881
+ const timer = setTimeout(() => {
1882
+ if (!res.headersSent) {
1883
+ res.status(503).json({ success: false, error: { code: "TIMEOUT", message: "Request timed out" } });
1884
+ }
1885
+ }, timeoutMs);
1886
+ res.on("finish", () => clearTimeout(timer));
1887
+ res.on("close", () => clearTimeout(timer));
1888
+ next();
1889
+ });
1890
+ }
1531
1891
  if (this.config.rateLimit !== false) {
1532
1892
  this.app.use(createRateLimiter(this.config.rateLimit));
1533
1893
  }
@@ -1569,6 +1929,37 @@ var CrudoraServer = class {
1569
1929
  return this;
1570
1930
  }
1571
1931
  generateRoutes() {
1932
+ if (this.config.healthCheck !== false) {
1933
+ const healthPath = typeof this.config.healthCheck === "string" ? this.config.healthCheck : "/health";
1934
+ this.app.get(healthPath, (_req, res) => {
1935
+ res.json({ success: true, data: { status: "ok", timestamp: (/* @__PURE__ */ new Date()).toISOString() } });
1936
+ });
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
+ }
1572
1963
  this.crudora.generateRoutes(this.app, this.config.basePath);
1573
1964
  return this;
1574
1965
  }
@@ -1576,12 +1967,32 @@ var CrudoraServer = class {
1576
1967
  this.app.use(middleware);
1577
1968
  return this;
1578
1969
  }
1970
+ /**
1971
+ * Starts the HTTP server and returns the underlying `http.Server` instance.
1972
+ * Use the returned server for graceful shutdown:
1973
+ *
1974
+ * @example
1975
+ * const httpServer = server.listen();
1976
+ * process.on('SIGTERM', () => httpServer.close(() => process.exit(0)));
1977
+ */
1579
1978
  listen(callback) {
1580
- this.app.listen(this.config.port, () => {
1979
+ this.httpServer = http.createServer(this.app);
1980
+ this.httpServer.listen(this.config.port, () => {
1581
1981
  console.log(`\u{1F680} Crudora server running on port ${this.config.port}`);
1582
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
+ }
1583
1986
  if (callback) callback();
1584
1987
  });
1988
+ return this.httpServer;
1989
+ }
1990
+ /**
1991
+ * Returns the `http.Server` instance after `listen()` has been called, or `null` before.
1992
+ * Useful when you need the server reference without calling listen again.
1993
+ */
1994
+ getHttpServer() {
1995
+ return this.httpServer;
1585
1996
  }
1586
1997
  getApp() {
1587
1998
  return this.app;
@@ -1677,6 +2088,7 @@ export {
1677
2088
  HasOne,
1678
2089
  Model,
1679
2090
  NotFoundError,
2091
+ OpenApiGenerator,
1680
2092
  Repository,
1681
2093
  SchemaGenerator,
1682
2094
  ValidationGenerator,