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 +57 -5
- package/dist/index.cjs +410 -4
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +154 -2
- package/dist/index.d.ts +154 -2
- package/dist/index.js +416 -4
- package/dist/index.js.map +1 -1
- package/package.json +6 -2
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({
|
|
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
|
|
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 && !
|
|
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.
|
|
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,
|