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/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
  }
@@ -1496,6 +1798,40 @@ function createDefaultLogger() {
1496
1798
  debug: (msg, ctx) => console.debug(fmt("debug", msg, ctx))
1497
1799
  };
1498
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
+ }
1499
1835
  var CrudoraServer = class {
1500
1836
  constructor(config) {
1501
1837
  this.httpServer = null;
@@ -1516,7 +1852,8 @@ var CrudoraServer = class {
1516
1852
  healthCheck: true,
1517
1853
  ...config,
1518
1854
  logger: resolvedLogger,
1519
- rateLimit: resolvedRateLimit
1855
+ rateLimit: resolvedRateLimit,
1856
+ docs: resolveDocs(config.docs)
1520
1857
  };
1521
1858
  this.app = (0, import_express.default)();
1522
1859
  this.crudora = new Crudora(
@@ -1597,6 +1934,31 @@ var CrudoraServer = class {
1597
1934
  res.json({ success: true, data: { status: "ok", timestamp: (/* @__PURE__ */ new Date()).toISOString() } });
1598
1935
  });
1599
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
+ }
1600
1962
  this.crudora.generateRoutes(this.app, this.config.basePath);
1601
1963
  return this;
1602
1964
  }
@@ -1617,6 +1979,9 @@ var CrudoraServer = class {
1617
1979
  this.httpServer.listen(this.config.port, () => {
1618
1980
  console.log(`\u{1F680} Crudora server running on port ${this.config.port}`);
1619
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
+ }
1620
1985
  if (callback) callback();
1621
1986
  });
1622
1987
  return this.httpServer;
@@ -1723,6 +2088,7 @@ Model.softDelete = false;
1723
2088
  HasOne,
1724
2089
  Model,
1725
2090
  NotFoundError,
2091
+ OpenApiGenerator,
1726
2092
  Repository,
1727
2093
  SchemaGenerator,
1728
2094
  ValidationGenerator,