crudora 0.3.0 → 0.4.1
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 +369 -3
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +119 -1
- package/dist/index.d.ts +119 -1
- package/dist/index.js +375 -3
- package/dist/index.js.map +1 -1
- package/package.json +6 -2
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
|
|
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 && !
|
|
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,
|