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/README.md CHANGED
@@ -22,6 +22,10 @@ Automatic CRUD API generator for TypeScript with Drizzle ORM — build REST APIs
22
22
  - **Lifecycle Hooks** — `beforeCreate`, `afterCreate`, `afterCreateMany`, `beforeUpdate`, `afterUpdate`, `beforeDelete`, `afterDelete`, `beforeFind`, `afterFind`
23
23
  - **Structured Logging** — pluggable `CrudoraLogger` with correlation IDs per request; compatible with pino, winston
24
24
  - **Field Security** — `hidden` fields stripped at query time via `getTableColumns()`
25
+ - **Request Timeout** — built-in socket-level timeout middleware; returns `503` when a handler exceeds the configured limit
26
+ - **Health Check** — built-in `GET /health` endpoint; configurable path or disable entirely
27
+ - **Graceful Shutdown** — `listen()` returns the underlying `http.Server` for clean SIGTERM handling
28
+ - **Built-in API Docs** — Scalar interactive UI at `/docs`; fully configurable (theme, layout, info); enable with `docs: true` after installing `@scalar/express-api-reference`
25
29
  - **Standardized Responses** — all endpoints return `{ success, data, meta?, error? }` OpenAPI-style envelope
26
30
  - **Schema Generator** — auto-generate Drizzle TypeScript schema files from models
27
31
  - **TypeScript First** — full type safety, ESM and CJS dual build
@@ -92,12 +96,22 @@ class User extends Model {
92
96
  }
93
97
  }
94
98
 
95
- const server = new CrudoraServer({ db, dialect: 'postgresql', port: 3000 });
99
+ const server = new CrudoraServer({
100
+ db,
101
+ dialect: 'postgresql',
102
+ port: 3000,
103
+ timeout: 30_000, // 503 after 30 s with no response
104
+ healthCheck: true, // GET /health → { status: 'ok' }
105
+ // docs: true, // GET /docs → Scalar UI (requires @scalar/express-api-reference)
106
+ });
96
107
 
97
- server
108
+ const httpServer = server
98
109
  .registerModel(User)
99
110
  .generateRoutes()
100
111
  .listen();
112
+
113
+ // Graceful shutdown
114
+ process.on('SIGTERM', () => httpServer.close(() => process.exit(0)));
101
115
  ```
102
116
 
103
117
  ## Generated API Endpoints
@@ -473,9 +487,6 @@ npx crudora generate-schema --entry src/server.ts --output src/db/schema.ts
473
487
 
474
488
  ```typescript
475
489
  server
476
- .get('/health', (_req, res) => {
477
- res.json({ success: true, data: { status: 'ok', timestamp: new Date() } });
478
- })
479
490
  .post('/auth/login', async (req, res) => {
480
491
  const { email, password } = req.body;
481
492
  const userRepo = server.getCrudora().getRepository(User);
@@ -521,6 +532,46 @@ server.post('/auth/login', async (req, res) => {
521
532
 
522
533
  See the [Authentication Guide](./docs/authentication.md) for register, per-route middleware, role guards, and a full JWT example.
523
534
 
535
+ ## API Documentation (Scalar)
536
+
537
+ Crudora can serve an interactive API reference powered by [Scalar](https://scalar.com) — auto-generated from your registered models.
538
+
539
+ **1. Install the peer dependency:**
540
+
541
+ ```bash
542
+ npm install @scalar/express-api-reference
543
+ ```
544
+
545
+ **2. Enable `docs` in your server config:**
546
+
547
+ ```typescript
548
+ const server = new CrudoraServer({
549
+ db,
550
+ dialect: 'postgresql',
551
+ docs: true, // → GET /docs (UI) + GET /docs/openapi.json (spec)
552
+ });
553
+ ```
554
+
555
+ **3. Customize as needed:**
556
+
557
+ ```typescript
558
+ docs: {
559
+ path: '/docs', // custom mount path, e.g. '/api-docs'
560
+ title: 'My API', // shown in Scalar UI header
561
+ version: '1.0.0',
562
+ description: 'Full description of what this API does.',
563
+ scalar: { // any @scalar/express-api-reference option
564
+ theme: 'purple', // 'default' | 'alternate' | 'moon' | 'purple' | ...
565
+ darkMode: true,
566
+ layout: 'classic', // 'modern' (default) | 'classic'
567
+ },
568
+ },
569
+ ```
570
+
571
+ The raw OpenAPI 3.0 spec is always available at `{path}/openapi.json` — useful for importing into Postman, Insomnia, or other tooling even without the UI package installed.
572
+
573
+ > If `@scalar/express-api-reference` is not installed and `docs` is enabled, Crudora logs a warning and serves a plain install-prompt page. The spec endpoint is unaffected.
574
+
524
575
  ## Project Setup
525
576
 
526
577
  ```bash
@@ -544,6 +595,7 @@ npx ts-node src/server.ts
544
595
  - [Custom Routes](./docs/custom-routes.md)
545
596
  - [Authentication Guide](./docs/authentication.md)
546
597
  - [Deployment Guide](./docs/deployment.md)
598
+ - [API Documentation (Scalar)](#api-documentation-scalar)
547
599
 
548
600
  ## Contributing
549
601
 
package/dist/index.cjs CHANGED
@@ -40,6 +40,7 @@ __export(src_exports, {
40
40
  HasOne: () => HasOne,
41
41
  Model: () => Model,
42
42
  NotFoundError: () => NotFoundError,
43
+ OpenApiGenerator: () => OpenApiGenerator,
43
44
  Repository: () => Repository,
44
45
  SchemaGenerator: () => SchemaGenerator,
45
46
  ValidationGenerator: () => ValidationGenerator,
@@ -864,6 +865,299 @@ function getColumnImport(type, dialect) {
864
865
  }
865
866
  }
866
867
 
868
+ // src/core/openApiGenerator.ts
869
+ var SYSTEM_FIELDS2 = /* @__PURE__ */ new Set(["createdAt", "updatedAt", "deletedAt"]);
870
+ function fieldToSchema(opts) {
871
+ switch (opts.type) {
872
+ case "uuid":
873
+ return { type: "string", format: "uuid" };
874
+ case "text":
875
+ return { type: "string" };
876
+ case "integer":
877
+ return { type: "integer" };
878
+ case "number":
879
+ return { type: "number", format: "double" };
880
+ case "boolean":
881
+ return { type: "boolean" };
882
+ case "date":
883
+ return { type: "string", format: "date-time" };
884
+ case "decimal":
885
+ return { type: "number", format: "decimal" };
886
+ case "json":
887
+ return { type: "object" };
888
+ case "enum":
889
+ return { type: "string", enum: opts.enumValues ?? [] };
890
+ case "bigint":
891
+ return { type: "integer", format: "int64" };
892
+ case "serial":
893
+ return { type: "integer" };
894
+ case "array":
895
+ return { type: "array", items: { type: "string" } };
896
+ default: {
897
+ const s = { type: "string" };
898
+ if (opts.length) s.maxLength = opts.length;
899
+ return s;
900
+ }
901
+ }
902
+ }
903
+ function buildOutputSchema(modelClass) {
904
+ const fields = getFieldMetadata(modelClass);
905
+ const hidden = new Set(modelClass.hidden ?? []);
906
+ const properties = {};
907
+ for (const [name, opts] of Object.entries(fields)) {
908
+ if (hidden.has(name)) continue;
909
+ const schema = fieldToSchema(opts);
910
+ if (opts.nullable) schema.nullable = true;
911
+ properties[name] = schema;
912
+ }
913
+ if (modelClass.timestamps !== false) {
914
+ properties.createdAt = { type: "string", format: "date-time" };
915
+ properties.updatedAt = { type: "string", format: "date-time" };
916
+ }
917
+ if (modelClass.softDelete) {
918
+ properties.deletedAt = { type: "string", format: "date-time", nullable: true };
919
+ }
920
+ return { type: "object", properties };
921
+ }
922
+ function buildInputSchema(modelClass) {
923
+ const fields = getFieldMetadata(modelClass);
924
+ const fillable = modelClass.fillable;
925
+ const properties = {};
926
+ const required = [];
927
+ let entries = Object.entries(fields).filter(
928
+ ([name, opts]) => !opts.primary && !SYSTEM_FIELDS2.has(name)
929
+ );
930
+ if (fillable?.length) {
931
+ entries = entries.filter(([name]) => fillable.includes(name));
932
+ }
933
+ for (const [name, opts] of entries) {
934
+ const schema2 = fieldToSchema(opts);
935
+ if (opts.nullable) schema2.nullable = true;
936
+ properties[name] = schema2;
937
+ if (opts.required && !opts.nullable) required.push(name);
938
+ }
939
+ const schema = { type: "object", properties };
940
+ if (required.length > 0) schema.required = required;
941
+ return schema;
942
+ }
943
+ function capital(s) {
944
+ return s.charAt(0).toUpperCase() + s.slice(1);
945
+ }
946
+ function itemLabel(tableName) {
947
+ return tableName.endsWith("s") ? tableName.slice(0, -1) : tableName;
948
+ }
949
+ var listResponse = (ref) => ({
950
+ description: "OK",
951
+ content: {
952
+ "application/json": {
953
+ schema: {
954
+ type: "object",
955
+ properties: {
956
+ success: { type: "boolean", example: true },
957
+ data: { type: "array", items: { $ref: ref } },
958
+ meta: {
959
+ type: "object",
960
+ properties: {
961
+ pagination: {
962
+ type: "object",
963
+ properties: {
964
+ page: { type: "integer" },
965
+ limit: { type: "integer" },
966
+ total: { type: "integer" },
967
+ pages: { type: "integer" }
968
+ }
969
+ }
970
+ }
971
+ }
972
+ }
973
+ }
974
+ }
975
+ }
976
+ });
977
+ var itemResponse = (ref, status = "OK") => ({
978
+ description: status,
979
+ content: {
980
+ "application/json": {
981
+ schema: {
982
+ type: "object",
983
+ properties: {
984
+ success: { type: "boolean", example: true },
985
+ data: { $ref: ref }
986
+ }
987
+ }
988
+ }
989
+ }
990
+ });
991
+ var errorResponse = (description, code) => ({
992
+ description,
993
+ content: {
994
+ "application/json": {
995
+ schema: {
996
+ type: "object",
997
+ properties: {
998
+ success: { type: "boolean", example: false },
999
+ error: {
1000
+ type: "object",
1001
+ properties: {
1002
+ code: { type: "string", example: code },
1003
+ message: { type: "string" }
1004
+ }
1005
+ }
1006
+ }
1007
+ }
1008
+ }
1009
+ }
1010
+ });
1011
+ var validationError = {
1012
+ description: "Validation Error",
1013
+ content: {
1014
+ "application/json": {
1015
+ schema: {
1016
+ type: "object",
1017
+ properties: {
1018
+ success: { type: "boolean", example: false },
1019
+ error: {
1020
+ type: "object",
1021
+ properties: {
1022
+ code: { type: "string", example: "VALIDATION_ERROR" },
1023
+ message: { type: "string" },
1024
+ details: {
1025
+ type: "array",
1026
+ items: {
1027
+ type: "object",
1028
+ properties: {
1029
+ field: { type: "string" },
1030
+ message: { type: "string" }
1031
+ }
1032
+ }
1033
+ }
1034
+ }
1035
+ }
1036
+ }
1037
+ }
1038
+ }
1039
+ }
1040
+ };
1041
+ var idParam = { in: "path", name: "id", required: true, schema: { type: "string" } };
1042
+ var OpenApiGenerator = class {
1043
+ static generate(models, customRoutes, basePath, info) {
1044
+ const schemas = {};
1045
+ const paths = {};
1046
+ for (const [, modelClass] of models) {
1047
+ const tableName = modelClass.getTableName();
1048
+ const tag = capital(tableName);
1049
+ const item = itemLabel(tableName);
1050
+ const ref = `#/components/schemas/${tag}`;
1051
+ const inputRef = `#/components/schemas/${tag}Input`;
1052
+ const col = `${basePath}/${tableName}`;
1053
+ const byId = `${basePath}/${tableName}/{id}`;
1054
+ schemas[tag] = buildOutputSchema(modelClass);
1055
+ schemas[`${tag}Input`] = buildInputSchema(modelClass);
1056
+ paths[col] = {
1057
+ get: {
1058
+ summary: `List ${tableName}`,
1059
+ operationId: `list${tag}`,
1060
+ tags: [tag],
1061
+ parameters: [
1062
+ { in: "query", name: "page", schema: { type: "integer", default: 1 } },
1063
+ { in: "query", name: "limit", schema: { type: "integer", default: 10 } },
1064
+ { in: "query", name: "cursor", schema: { type: "string" } },
1065
+ { in: "query", name: "sortBy", schema: { type: "string" } },
1066
+ { in: "query", name: "sortOrder", schema: { type: "string", enum: ["asc", "desc"] } },
1067
+ { in: "query", name: "withDeleted", schema: { type: "boolean" } }
1068
+ ],
1069
+ responses: { "200": listResponse(ref) }
1070
+ },
1071
+ post: {
1072
+ summary: `Create ${item}`,
1073
+ operationId: `create${tag}`,
1074
+ tags: [tag],
1075
+ requestBody: {
1076
+ required: true,
1077
+ content: { "application/json": { schema: { $ref: inputRef } } }
1078
+ },
1079
+ responses: {
1080
+ "201": itemResponse(ref, "Created"),
1081
+ "422": validationError
1082
+ }
1083
+ }
1084
+ };
1085
+ paths[byId] = {
1086
+ get: {
1087
+ summary: `Get ${item} by ID`,
1088
+ operationId: `get${tag}ById`,
1089
+ tags: [tag],
1090
+ parameters: [idParam],
1091
+ responses: {
1092
+ "200": itemResponse(ref),
1093
+ "404": errorResponse("Not Found", "NOT_FOUND")
1094
+ }
1095
+ },
1096
+ put: {
1097
+ summary: `Replace ${item}`,
1098
+ operationId: `replace${tag}`,
1099
+ tags: [tag],
1100
+ parameters: [idParam],
1101
+ requestBody: {
1102
+ required: true,
1103
+ content: { "application/json": { schema: { $ref: inputRef } } }
1104
+ },
1105
+ responses: {
1106
+ "200": itemResponse(ref),
1107
+ "404": errorResponse("Not Found", "NOT_FOUND"),
1108
+ "422": validationError
1109
+ }
1110
+ },
1111
+ patch: {
1112
+ summary: `Update ${item}`,
1113
+ operationId: `update${tag}`,
1114
+ tags: [tag],
1115
+ parameters: [idParam],
1116
+ requestBody: {
1117
+ required: true,
1118
+ content: { "application/json": { schema: { $ref: inputRef } } }
1119
+ },
1120
+ responses: {
1121
+ "200": itemResponse(ref),
1122
+ "404": errorResponse("Not Found", "NOT_FOUND"),
1123
+ "422": validationError
1124
+ }
1125
+ },
1126
+ delete: {
1127
+ summary: `Delete ${item}`,
1128
+ operationId: `delete${tag}`,
1129
+ tags: [tag],
1130
+ parameters: [idParam],
1131
+ responses: {
1132
+ "200": itemResponse(ref),
1133
+ "404": errorResponse("Not Found", "NOT_FOUND")
1134
+ }
1135
+ }
1136
+ };
1137
+ }
1138
+ for (const route of customRoutes) {
1139
+ const fullPath = `${basePath}${route.path}`.replace(/:([a-zA-Z_]+)/g, "{$1}");
1140
+ if (!paths[fullPath]) paths[fullPath] = {};
1141
+ paths[fullPath][route.method.toLowerCase()] = {
1142
+ summary: `${route.method} ${route.path}`,
1143
+ tags: ["Custom"],
1144
+ responses: { "200": { description: "OK" } }
1145
+ };
1146
+ }
1147
+ const spec = {
1148
+ openapi: "3.0.0",
1149
+ info: {
1150
+ title: info?.title ?? "Crudora API",
1151
+ version: info?.version ?? "1.0.0"
1152
+ },
1153
+ paths,
1154
+ components: { schemas }
1155
+ };
1156
+ if (info?.description) spec.info.description = info.description;
1157
+ return spec;
1158
+ }
1159
+ };
1160
+
867
1161
  // src/utils/validation.ts
868
1162
  var import_zod = require("zod");
869
1163
  function zodTypeFor(opts, forStrict) {
@@ -916,14 +1210,14 @@ function zodTypeFor(opts, forStrict) {
916
1210
  }
917
1211
  return base.optional();
918
1212
  }
919
- var SYSTEM_FIELDS2 = /* @__PURE__ */ new Set(["createdAt", "updatedAt", "deletedAt"]);
1213
+ var SYSTEM_FIELDS3 = /* @__PURE__ */ new Set(["createdAt", "updatedAt", "deletedAt"]);
920
1214
  function resolveFields(modelClass) {
921
1215
  const fieldMeta = getFieldMetadata(modelClass);
922
1216
  const hasMeta = Object.keys(fieldMeta).length > 0;
923
1217
  const fillable = modelClass.fillable;
924
1218
  if (hasMeta) {
925
1219
  let entries = Object.entries(fieldMeta).filter(
926
- ([name, opts]) => !opts.primary && !SYSTEM_FIELDS2.has(name)
1220
+ ([name, opts]) => !opts.primary && !SYSTEM_FIELDS3.has(name)
927
1221
  );
928
1222
  if (fillable?.length) {
929
1223
  entries = entries.filter(([name]) => fillable.includes(name));
@@ -1202,6 +1496,14 @@ var Crudora = class {
1202
1496
  const modelClasses = Array.from(this.models.values());
1203
1497
  return SchemaGenerator.generateDrizzleSchema(modelClasses, this.dialect);
1204
1498
  }
1499
+ generateOpenApiSpec(basePath = "/api", info) {
1500
+ return OpenApiGenerator.generate(
1501
+ this.models,
1502
+ this.customRoutes.map(({ method, path }) => ({ method, path })),
1503
+ basePath,
1504
+ info
1505
+ );
1506
+ }
1205
1507
  getValidationSchema(modelClass) {
1206
1508
  return ValidationGenerator.generateZodSchema(modelClass);
1207
1509
  }
@@ -1451,6 +1753,7 @@ var Crudora = class {
1451
1753
  };
1452
1754
 
1453
1755
  // src/core/crudoraServer.ts
1756
+ var import_http = __toESM(require("http"), 1);
1454
1757
  var import_express = __toESM(require("express"), 1);
1455
1758
  var import_crypto2 = require("crypto");
1456
1759
  function createRateLimiter(config) {
@@ -1495,8 +1798,43 @@ function createDefaultLogger() {
1495
1798
  debug: (msg, ctx) => console.debug(fmt("debug", msg, ctx))
1496
1799
  };
1497
1800
  }
1801
+ function resolveDocs(docs) {
1802
+ if (!docs) return false;
1803
+ if (docs === true) return { path: "/docs" };
1804
+ if (typeof docs === "string") return { path: docs };
1805
+ return { path: docs.path ?? "/docs", ...docs };
1806
+ }
1807
+ function tryRequireScalar() {
1808
+ try {
1809
+ return require("@scalar/express-api-reference");
1810
+ } catch {
1811
+ return null;
1812
+ }
1813
+ }
1814
+ function buildInstallPromptHtml(specUrl) {
1815
+ return `<!DOCTYPE html>
1816
+ <html>
1817
+ <head>
1818
+ <title>API Docs</title>
1819
+ <meta charset="utf-8" />
1820
+ <style>
1821
+ body { font-family: system-ui, sans-serif; max-width: 600px; margin: 80px auto; padding: 0 24px; color: #333; }
1822
+ code { background: #f4f4f4; padding: 2px 6px; border-radius: 4px; }
1823
+ pre { background: #f4f4f4; padding: 16px; border-radius: 8px; overflow: auto; }
1824
+ a { color: #0070f3; }
1825
+ </style>
1826
+ </head>
1827
+ <body>
1828
+ <h2>API Docs</h2>
1829
+ <p>Install <code>@scalar/express-api-reference</code> to enable the interactive UI:</p>
1830
+ <pre>npm install @scalar/express-api-reference</pre>
1831
+ <p>OpenAPI spec: <a href="${specUrl}">${specUrl}</a></p>
1832
+ </body>
1833
+ </html>`;
1834
+ }
1498
1835
  var CrudoraServer = class {
1499
1836
  constructor(config) {
1837
+ this.httpServer = null;
1500
1838
  const resolvedLogger = config.logger === void 0 ? createDefaultLogger() : config.logger;
1501
1839
  const resolvedRateLimit = config.rateLimit === false ? false : {
1502
1840
  windowMs: config.rateLimit?.windowMs ?? 6e4,
@@ -1510,9 +1848,12 @@ var CrudoraServer = class {
1510
1848
  bodyParser: true,
1511
1849
  bodyParserLimit: "100kb",
1512
1850
  basePath: "/api",
1851
+ timeout: 0,
1852
+ healthCheck: true,
1513
1853
  ...config,
1514
1854
  logger: resolvedLogger,
1515
- rateLimit: resolvedRateLimit
1855
+ rateLimit: resolvedRateLimit,
1856
+ docs: resolveDocs(config.docs)
1516
1857
  };
1517
1858
  this.app = (0, import_express.default)();
1518
1859
  this.crudora = new Crudora(
@@ -1533,6 +1874,19 @@ var CrudoraServer = class {
1533
1874
  req.correlationId = (0, import_crypto2.randomUUID)();
1534
1875
  next();
1535
1876
  });
1877
+ if (this.config.timeout > 0) {
1878
+ const timeoutMs = this.config.timeout;
1879
+ this.app.use((_req, res, next) => {
1880
+ const timer = setTimeout(() => {
1881
+ if (!res.headersSent) {
1882
+ res.status(503).json({ success: false, error: { code: "TIMEOUT", message: "Request timed out" } });
1883
+ }
1884
+ }, timeoutMs);
1885
+ res.on("finish", () => clearTimeout(timer));
1886
+ res.on("close", () => clearTimeout(timer));
1887
+ next();
1888
+ });
1889
+ }
1536
1890
  if (this.config.rateLimit !== false) {
1537
1891
  this.app.use(createRateLimiter(this.config.rateLimit));
1538
1892
  }
@@ -1574,6 +1928,37 @@ var CrudoraServer = class {
1574
1928
  return this;
1575
1929
  }
1576
1930
  generateRoutes() {
1931
+ if (this.config.healthCheck !== false) {
1932
+ const healthPath = typeof this.config.healthCheck === "string" ? this.config.healthCheck : "/health";
1933
+ this.app.get(healthPath, (_req, res) => {
1934
+ res.json({ success: true, data: { status: "ok", timestamp: (/* @__PURE__ */ new Date()).toISOString() } });
1935
+ });
1936
+ }
1937
+ if (this.config.docs !== false) {
1938
+ const { path: docsPath, title, version, description, scalar: scalarOpts } = this.config.docs;
1939
+ const specPath = `${docsPath}/openapi.json`;
1940
+ const spec = this.crudora.generateOpenApiSpec(this.config.basePath, { title, version, description });
1941
+ this.app.get(specPath, (_req, res) => {
1942
+ res.json(spec);
1943
+ });
1944
+ const scalarPkg = tryRequireScalar();
1945
+ if (scalarPkg) {
1946
+ this.app.use(docsPath, scalarPkg.apiReference({
1947
+ spec: { url: specPath },
1948
+ ...scalarOpts
1949
+ }));
1950
+ } else {
1951
+ if (this.config.logger !== false) {
1952
+ this.config.logger.warn(
1953
+ 'docs is enabled but @scalar/express-api-reference is not installed. Run "npm install @scalar/express-api-reference" to enable the interactive UI.'
1954
+ );
1955
+ }
1956
+ this.app.get(docsPath, (_req, res) => {
1957
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
1958
+ res.send(buildInstallPromptHtml(specPath));
1959
+ });
1960
+ }
1961
+ }
1577
1962
  this.crudora.generateRoutes(this.app, this.config.basePath);
1578
1963
  return this;
1579
1964
  }
@@ -1581,12 +1966,32 @@ var CrudoraServer = class {
1581
1966
  this.app.use(middleware);
1582
1967
  return this;
1583
1968
  }
1969
+ /**
1970
+ * Starts the HTTP server and returns the underlying `http.Server` instance.
1971
+ * Use the returned server for graceful shutdown:
1972
+ *
1973
+ * @example
1974
+ * const httpServer = server.listen();
1975
+ * process.on('SIGTERM', () => httpServer.close(() => process.exit(0)));
1976
+ */
1584
1977
  listen(callback) {
1585
- this.app.listen(this.config.port, () => {
1978
+ this.httpServer = import_http.default.createServer(this.app);
1979
+ this.httpServer.listen(this.config.port, () => {
1586
1980
  console.log(`\u{1F680} Crudora server running on port ${this.config.port}`);
1587
1981
  console.log(`\u{1F4DA} API available at http://localhost:${this.config.port}${this.config.basePath}`);
1982
+ if (this.config.docs !== false) {
1983
+ console.log(`\u{1F4D6} API docs at http://localhost:${this.config.port}${this.config.docs.path}`);
1984
+ }
1588
1985
  if (callback) callback();
1589
1986
  });
1987
+ return this.httpServer;
1988
+ }
1989
+ /**
1990
+ * Returns the `http.Server` instance after `listen()` has been called, or `null` before.
1991
+ * Useful when you need the server reference without calling listen again.
1992
+ */
1993
+ getHttpServer() {
1994
+ return this.httpServer;
1590
1995
  }
1591
1996
  getApp() {
1592
1997
  return this.app;
@@ -1683,6 +2088,7 @@ Model.softDelete = false;
1683
2088
  HasOne,
1684
2089
  Model,
1685
2090
  NotFoundError,
2091
+ OpenApiGenerator,
1686
2092
  Repository,
1687
2093
  SchemaGenerator,
1688
2094
  ValidationGenerator,