ts-class-to-openapi 1.3.4 → 1.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 CHANGED
@@ -27,7 +27,26 @@ class User {
27
27
  }
28
28
 
29
29
  const schema = transform(User)
30
- // Returns complete OpenAPI schema ready for Swagger/API documentation
30
+
31
+ console.log(JSON.stringify(shema), null, 2)
32
+ ```
33
+
34
+ **Generated output:**
35
+
36
+ ```json
37
+ {
38
+ "name": "User",
39
+ "schema": {
40
+ "type": "object",
41
+ "properties": {
42
+ "id": { "type": "number" },
43
+ "name": { "type": "string" },
44
+ "email": { "type": "string" },
45
+ "age": { "type": "number" }
46
+ },
47
+ "required": ["id", "name", "email"]
48
+ }
49
+ }
31
50
  ```
32
51
 
33
52
  ## 📦 Installation
@@ -55,57 +74,7 @@ npm install ts-class-to-openapi class-validator
55
74
 
56
75
  ## 🎨 Class Transformation Examples
57
76
 
58
- ### 1. Basic TypeScript Class
59
-
60
- Fundamental method: transform any TypeScript class without decorators:
61
-
62
- ```typescript
63
- import { transform } from 'ts-class-to-openapi'
64
-
65
- // Basic TypeScript class - no decorators required
66
- class User {
67
- id: number
68
- name: string
69
- email: string
70
- age: number
71
- active: boolean
72
- tags: string[]
73
- createdAt: Date
74
- }
75
-
76
- // Transform class to OpenAPI schema
77
- const result = transform(User)
78
- console.log(JSON.stringify(result, null, 2))
79
- ```
80
-
81
- **Generated output:**
82
-
83
- ```json
84
- {
85
- "name": "User",
86
- "schema": {
87
- "type": "object",
88
- "properties": {
89
- "id": { "type": "number" },
90
- "name": { "type": "string" },
91
- "email": { "type": "string" },
92
- "age": { "type": "number" },
93
- "active": { "type": "boolean" },
94
- "tags": {
95
- "type": "array",
96
- "items": { "type": "string" }
97
- },
98
- "createdAt": {
99
- "type": "string",
100
- "format": "date-time"
101
- }
102
- },
103
- "required": ["id", "name", "email", "age", "active", "tags", "createdAt"]
104
- }
105
- }
106
- ```
107
-
108
- ### 2. Class with Advanced Validations
77
+ ### 1. Class with Advanced Validations
109
78
 
110
79
  For more detailed schemas, class-validator decorators can be incorporated:
111
80
 
@@ -156,7 +125,7 @@ const result = transform(User)
156
125
  }
157
126
  ```
158
127
 
159
- ### 3. Nested Objects and Arrays
128
+ ### 2. Nested Objects and Arrays
160
129
 
161
130
  Automatic processing of complex relationships:
162
131
 
@@ -229,9 +198,9 @@ const schema = transform(User)
229
198
  }
230
199
  ```
231
200
 
232
- ### 4. Enumerations and Special Types
201
+ ### 3. Enumerations and Special Types
233
202
 
234
- Full compatibility with TypeScript enumerations (both decorated and pure):
203
+ Full compatibility with TypeScript enumerations (both decorated and pure), and literal object as enums:
235
204
 
236
205
  ```typescript
237
206
  import { transform } from 'ts-class-to-openapi'
@@ -299,59 +268,6 @@ const schema = transform(Task)
299
268
  }
300
269
  ```
301
270
 
302
- ### 5. File Upload
303
-
304
- Integrated support for binary file handling:
305
-
306
- ```typescript
307
- import { transform } from 'ts-class-to-openapi'
308
- import { IsNotEmpty, IsOptional } from 'class-validator'
309
-
310
- // Custom file type definition
311
- class UploadFile {}
312
-
313
- class UserProfile {
314
- @IsNotEmpty()
315
- profilePicture: UploadFile
316
-
317
- @IsOptional()
318
- resume: UploadFile
319
-
320
- documents: UploadFile[] // Multiple files
321
- }
322
-
323
- const schema = transform(UserProfile)
324
- ```
325
-
326
- **Generated output:**
327
-
328
- ```json
329
- {
330
- "name": "UserProfile",
331
- "schema": {
332
- "type": "object",
333
- "properties": {
334
- "profilePicture": {
335
- "type": "string",
336
- "format": "binary"
337
- },
338
- "resume": {
339
- "type": "string",
340
- "format": "binary"
341
- },
342
- "documents": {
343
- "type": "array",
344
- "items": {
345
- "type": "string",
346
- "format": "binary"
347
- }
348
- }
349
- },
350
- "required": ["profilePicture", "documents"]
351
- }
352
- }
353
- ```
354
-
355
271
  ## 🌐 REST API Integration
356
272
 
357
273
  ### Implementation with Express.js and Swagger UI
package/dist/index.cjs CHANGED
@@ -189,6 +189,7 @@ var SchemaTransformer = class SchemaTransformer {
189
189
  maxCacheSize;
190
190
  autoCleanup;
191
191
  classFileIndex = /* @__PURE__ */ new Map();
192
+ transformCallIndex = /* @__PURE__ */ new Map();
192
193
  constructor(tsConfigPath = constants.TS_CONFIG_DEFAULT_PATH, options = {}) {
193
194
  this.maxCacheSize = options.maxCacheSize ?? 100;
194
195
  this.autoCleanup = options.autoCleanup ?? true;
@@ -200,19 +201,7 @@ var SchemaTransformer = class SchemaTransformer {
200
201
  const { options: tsOptions, fileNames } = typescript.default.parseJsonConfigFileContent(config, typescript.default.sys, "./");
201
202
  this.program = typescript.default.createProgram(fileNames, tsOptions);
202
203
  this.checker = this.program.getTypeChecker();
203
- this.program.getSourceFiles().forEach((sf) => {
204
- sf.statements.forEach((stmt) => {
205
- if (typescript.default.isClassDeclaration(stmt) && stmt.name) {
206
- const name = stmt.name.text;
207
- const entry = this.classFileIndex.get(name) || [];
208
- entry.push({
209
- sourceFile: sf,
210
- node: stmt
211
- });
212
- this.classFileIndex.set(name, entry);
213
- }
214
- });
215
- });
204
+ this.buildTransformCallIndex();
216
205
  }
217
206
  getPropertiesByClassDeclaration(classNode, visitedDeclarations = /* @__PURE__ */ new Set(), genericTypeMap = /* @__PURE__ */ new Map()) {
218
207
  if (visitedDeclarations.has(classNode)) return [];
@@ -260,6 +249,18 @@ var SchemaTransformer = class SchemaTransformer {
260
249
  const isOptional = !!member.questionToken;
261
250
  const isGeneric = this.isPropertyTypeGeneric(member);
262
251
  const isEnum = this.isEnum(member);
252
+ const isPrimitive = this.isPrimitiveType(type) || isEnum;
253
+ const isClassType = this.isClassType(member);
254
+ const isArray = this.isArrayProperty(member);
255
+ const isTypeLiteral = this.isTypeLiteral(member);
256
+ let genericClassReference = void 0;
257
+ if (isGeneric && !isPrimitive) {
258
+ const baseTypeName = type.replace(/\[\]$/, "").trim();
259
+ if (!this.isPrimitiveType(baseTypeName)) {
260
+ const matches = this.classFileIndex.get(baseTypeName);
261
+ if (matches && matches.length > 0 && matches[0]) genericClassReference = matches[0].node;
262
+ }
263
+ }
263
264
  const property = {
264
265
  name: propertyName,
265
266
  type,
@@ -267,12 +268,13 @@ var SchemaTransformer = class SchemaTransformer {
267
268
  isOptional,
268
269
  isGeneric,
269
270
  originalProperty: member,
270
- isPrimitive: this.isPrimitiveType(type) || isEnum,
271
- isClassType: this.isClassType(member),
272
- isArray: this.isArrayProperty(member),
271
+ isPrimitive,
272
+ isClassType,
273
+ isArray,
273
274
  isEnum,
274
275
  isRef: false,
275
- isTypeLiteral: this.isTypeLiteral(member)
276
+ isTypeLiteral,
277
+ genericClassReference
276
278
  };
277
279
  if (property.isClassType) {
278
280
  const declaration = this.getDeclarationProperty(property);
@@ -310,6 +312,7 @@ var SchemaTransformer = class SchemaTransformer {
310
312
  if (typeName.toLowerCase() === "uploadfile") return "UploadFile";
311
313
  if (typeName.toLowerCase() === "uploadfiledto") return "UploadFileDto";
312
314
  if (typeNode.typeArguments && typeNode.typeArguments.length > 0) {
315
+ if (typeName === "Array") return `${this.getTypeNodeToString(typeNode.typeArguments[0], genericTypeMap)}[]`;
313
316
  const firstTypeArg = typeNode.typeArguments[0];
314
317
  if (firstTypeArg && typescript.default.isTypeReferenceNode(firstTypeArg) && typescript.default.isIdentifier(firstTypeArg.typeName)) {
315
318
  if (firstTypeArg.typeName.text.toLowerCase() === "uploadfile") return "UploadFile";
@@ -332,6 +335,11 @@ var SchemaTransformer = class SchemaTransformer {
332
335
  if (types.length > 0 && types[0]) return types[0];
333
336
  return "object";
334
337
  default:
338
+ if (typescript.default.isIndexedAccessTypeNode(typeNode)) {
339
+ const resolvedType = this.checker.getTypeAtLocation(typeNode);
340
+ const resolved = this.checker.typeToString(resolvedType);
341
+ if (this.isPrimitiveType(resolved)) return resolved;
342
+ }
335
343
  const typeText = typeNode.getText();
336
344
  if (genericTypeMap && genericTypeMap.has(typeText)) return genericTypeMap.get(typeText);
337
345
  if (typeText.startsWith("Date")) return constants.jsPrimitives.Date.type;
@@ -406,7 +414,7 @@ var SchemaTransformer = class SchemaTransformer {
406
414
  isGenericTypeFromSymbol(type) {
407
415
  if (this.isSimpleArrayType(type)) return false;
408
416
  if (type.aliasTypeArguments && type.aliasTypeArguments.length > 0) return true;
409
- if (type.typeArguments && type.typeArguments.length > 0) {
417
+ if (type.typeArguments && type.typeArguments.length > 0 && type.typeArguments[0].symbol.getName() === "Array") {
410
418
  const symbol = type.getSymbol();
411
419
  if (symbol && symbol.getName() === "Array") {
412
420
  const elementType = type.typeArguments[0];
@@ -451,6 +459,7 @@ var SchemaTransformer = class SchemaTransformer {
451
459
  if (!elementType) return false;
452
460
  if (this.isUtilityTypeFromType(elementType)) return false;
453
461
  if (elementType.typeArguments && elementType.typeArguments.length > 0) return false;
462
+ if (type.typeArguments && type.typeArguments[0].symbol && type.typeArguments[0].symbol.getName() !== "Array") return false;
454
463
  return true;
455
464
  }
456
465
  return false;
@@ -508,22 +517,6 @@ var SchemaTransformer = class SchemaTransformer {
508
517
  }
509
518
  return matches[0];
510
519
  }
511
- checkTypeMatch(value, typeNode) {
512
- const runtimeType = typeof value;
513
- if (runtimeType === "string" && typeNode.kind === typescript.default.SyntaxKind.StringKeyword) return true;
514
- if (runtimeType === "number" && typeNode.kind === typescript.default.SyntaxKind.NumberKeyword) return true;
515
- if (runtimeType === "boolean" && typeNode.kind === typescript.default.SyntaxKind.BooleanKeyword) return true;
516
- if (Array.isArray(value) && typescript.default.isArrayTypeNode(typeNode)) {
517
- if (value.length === 0) return true;
518
- const firstItem = value[0];
519
- const elementType = typeNode.elementType;
520
- return this.checkTypeMatch(firstItem, elementType);
521
- }
522
- if (runtimeType === "object" && value !== null && !Array.isArray(value)) {
523
- if (typescript.default.isTypeReferenceNode(typeNode) || typeNode.kind === typescript.default.SyntaxKind.ObjectKeyword) return true;
524
- }
525
- return false;
526
- }
527
520
  findBestMatch(cls, matches) {
528
521
  let instance = {};
529
522
  try {
@@ -565,7 +558,11 @@ var SchemaTransformer = class SchemaTransformer {
565
558
  isClassType(propertyDeclaration) {
566
559
  if (!propertyDeclaration.type) return false;
567
560
  if (this.isArrayProperty(propertyDeclaration)) {
568
- const elementType = propertyDeclaration.type.elementType;
561
+ var _propertyDeclaration$;
562
+ let elementType;
563
+ if (typescript.default.isArrayTypeNode(propertyDeclaration.type)) elementType = propertyDeclaration.type.elementType;
564
+ else if (typescript.default.isTypeReferenceNode(propertyDeclaration.type) && ((_propertyDeclaration$ = propertyDeclaration.type.typeArguments) === null || _propertyDeclaration$ === void 0 ? void 0 : _propertyDeclaration$[0])) elementType = propertyDeclaration.type.typeArguments[0];
565
+ if (!elementType) return false;
569
566
  if (typescript.default.isTypeReferenceNode(elementType) && elementType.typeArguments && elementType.typeArguments.length > 0) {
570
567
  const firstTypeArg = elementType.typeArguments[0];
571
568
  if (firstTypeArg) {
@@ -626,7 +623,9 @@ var SchemaTransformer = class SchemaTransformer {
626
623
  }
627
624
  isArrayProperty(propertyDeclaration) {
628
625
  if (!propertyDeclaration.type) return false;
629
- return typescript.default.isArrayTypeNode(propertyDeclaration.type);
626
+ if (typescript.default.isArrayTypeNode(propertyDeclaration.type)) return true;
627
+ if (typescript.default.isTypeReferenceNode(propertyDeclaration.type) && typescript.default.isIdentifier(propertyDeclaration.type.typeName) && propertyDeclaration.type.typeName.text === "Array") return true;
628
+ return false;
630
629
  }
631
630
  getSchemaFromProperties({ properties, visitedClass, transformedSchema, classDeclaration }) {
632
631
  let schema = {};
@@ -661,6 +660,17 @@ var SchemaTransformer = class SchemaTransformer {
661
660
  visitedClass,
662
661
  transformedSchema
663
662
  });
663
+ else if (property.isGeneric) if (property.genericClassReference) schema = this.getSchemaFromClass({
664
+ isArray: property.isArray,
665
+ visitedClass,
666
+ transformedSchema,
667
+ declaration: property.genericClassReference
668
+ });
669
+ else schema = {
670
+ type: "object",
671
+ properties: {},
672
+ additionalProperties: true
673
+ };
664
674
  else schema = {
665
675
  type: "object",
666
676
  properties: {},
@@ -838,6 +848,11 @@ var SchemaTransformer = class SchemaTransformer {
838
848
  if (decorator.arguments.length === 0) return;
839
849
  const arg = decorator.arguments[0];
840
850
  if (arg && typeof arg === "object" && "kind" in arg) {
851
+ if (typescript.default.isArrayLiteralExpression(arg)) {
852
+ const values = this.extractValuesFromArrayLiteral(arg);
853
+ if (values.length > 0) this.applyEnumValues(values, schema);
854
+ return;
855
+ }
841
856
  const type = this.checker.getTypeAtLocation(arg);
842
857
  if (type.symbol && type.symbol.exports) {
843
858
  const values = [];
@@ -849,15 +864,46 @@ var SchemaTransformer = class SchemaTransformer {
849
864
  }
850
865
  });
851
866
  if (values.length > 0) {
852
- schema.enum = values;
853
- const isString = values.every((v) => typeof v === "string");
854
- const isNumber = values.every((v) => typeof v === "number");
855
- if (isString) schema.type = "string";
856
- else if (isNumber) schema.type = "number";
857
- else schema.type = "string";
867
+ this.applyEnumValues(values, schema);
868
+ return;
858
869
  }
859
870
  }
871
+ const values = this.extractValuesFromObjectLiteral(type);
872
+ if (values.length > 0) this.applyEnumValues(values, schema);
873
+ }
874
+ }
875
+ extractValuesFromArrayLiteral(arrayLiteral) {
876
+ const values = [];
877
+ for (const element of arrayLiteral.elements) if (typescript.default.isStringLiteral(element)) values.push(element.text);
878
+ else if (typescript.default.isNumericLiteral(element)) values.push(Number(element.text));
879
+ else if (typescript.default.isPrefixUnaryExpression(element) && element.operator === typescript.default.SyntaxKind.MinusToken && typescript.default.isNumericLiteral(element.operand)) values.push(-Number(element.operand.text));
880
+ return values;
881
+ }
882
+ extractValuesFromObjectLiteral(type) {
883
+ const values = [];
884
+ const properties = type.getProperties();
885
+ if (!properties || properties.length === 0) return values;
886
+ for (const prop of properties) {
887
+ const propType = this.checker.getTypeOfSymbolAtLocation(prop, prop.valueDeclaration);
888
+ if (propType.isStringLiteral()) values.push(propType.value);
889
+ else if (propType.isNumberLiteral()) values.push(propType.value);
890
+ else if (prop.valueDeclaration && typescript.default.isPropertyAssignment(prop.valueDeclaration)) {
891
+ const initializer = prop.valueDeclaration.initializer;
892
+ if (typescript.default.isStringLiteral(initializer)) values.push(initializer.text);
893
+ else if (typescript.default.isNumericLiteral(initializer)) values.push(Number(initializer.text));
894
+ }
860
895
  }
896
+ return values;
897
+ }
898
+ applyEnumValues(values, schema) {
899
+ schema.enum = values;
900
+ const isString = values.every((v) => typeof v === "string");
901
+ const isNumber = values.every((v) => typeof v === "number");
902
+ if (isString) schema.type = "string";
903
+ else if (isNumber) schema.type = "number";
904
+ else schema.type = "string";
905
+ delete schema.properties;
906
+ delete schema.additionalProperties;
861
907
  }
862
908
  applyDecorators(property, schema) {
863
909
  for (const decorator of property.decorators) switch (decorator.name) {
@@ -935,16 +981,65 @@ var SchemaTransformer = class SchemaTransformer {
935
981
  break;
936
982
  case constants.validatorDecorators.IsEnum.name:
937
983
  if (!property.isArray) this.applyEnumDecorator(decorator, schema);
938
- else if (schema.items) this.applyEnumDecorator(decorator, schema.items);
984
+ else {
985
+ if (!schema.items) {
986
+ schema.type = "array";
987
+ schema.items = {};
988
+ }
989
+ this.applyEnumDecorator(decorator, schema.items);
990
+ }
939
991
  break;
940
992
  }
941
993
  }
994
+ /**
995
+ * Scans all non-declaration source files in the program once and records
996
+ * every call of the form `transform(Foo<Bar, Baz>)`, keyed by class name.
997
+ * This means generic resolution in transform() is a pure O(1) Map lookup
998
+ * with no runtime stack inspection.
999
+ */
1000
+ buildTransformCallIndex() {
1001
+ this.program.getSourceFiles().forEach((sf) => {
1002
+ if (sf.isDeclarationFile) return;
1003
+ sf.statements.forEach((stmt) => {
1004
+ if (typescript.default.isClassDeclaration(stmt) && stmt.name) {
1005
+ const name = stmt.name.text;
1006
+ const entry = this.classFileIndex.get(name) || [];
1007
+ entry.push({
1008
+ sourceFile: sf,
1009
+ node: stmt
1010
+ });
1011
+ this.classFileIndex.set(name, entry);
1012
+ }
1013
+ });
1014
+ const visit = (node) => {
1015
+ if (typescript.default.isCallExpression(node) && node.arguments.length > 0) {
1016
+ const callee = node.expression;
1017
+ if (typescript.default.isIdentifier(callee) && callee.text === "transform" || typescript.default.isPropertyAccessExpression(callee) && callee.name.text === "transform") {
1018
+ const firstArg = node.arguments[0];
1019
+ const typeArgs = firstArg.typeArguments;
1020
+ if (typeArgs && typeArgs.length > 0) {
1021
+ const baseExpr = firstArg.expression ?? firstArg;
1022
+ if (typescript.default.isIdentifier(baseExpr)) {
1023
+ var _this$classFileIndex$;
1024
+ const classNode = (_this$classFileIndex$ = this.classFileIndex.get(baseExpr.text)) === null || _this$classFileIndex$ === void 0 || (_this$classFileIndex$ = _this$classFileIndex$[0]) === null || _this$classFileIndex$ === void 0 ? void 0 : _this$classFileIndex$.node;
1025
+ if (classNode === null || classNode === void 0 ? void 0 : classNode.typeParameters) {
1026
+ const typeMap = /* @__PURE__ */ new Map();
1027
+ classNode.typeParameters.forEach((param, i) => {
1028
+ const typeArg = typeArgs[i];
1029
+ if (typeArg) typeMap.set(param.name.text, this.getTypeNodeToString(typeArg, /* @__PURE__ */ new Map()));
1030
+ });
1031
+ if (typeMap.size > 0) this.transformCallIndex.set(baseExpr.text, typeMap);
1032
+ }
1033
+ }
1034
+ }
1035
+ }
1036
+ }
1037
+ typescript.default.forEachChild(node, visit);
1038
+ };
1039
+ visit(sf);
1040
+ });
1041
+ }
942
1042
  transform(cls, sourceOptions) {
943
- if (this.classCache.has(cls)) return this.classCache.get(cls);
944
- let schema = {
945
- type: "object",
946
- properties: {}
947
- };
948
1043
  const result = this.getSourceFileByClass(cls, sourceOptions);
949
1044
  if (!result || !(result === null || result === void 0 ? void 0 : result.sourceFile)) {
950
1045
  console.warn(`Class ${cls.name} not found in any source file.`);
@@ -958,11 +1053,22 @@ var SchemaTransformer = class SchemaTransformer {
958
1053
  }
959
1054
  };
960
1055
  }
961
- const properties = this.getPropertiesByClassDeclaration(result.node);
1056
+ const genericTypeMap = this.transformCallIndex.get(cls.name) ?? /* @__PURE__ */ new Map();
1057
+ const hasGenericArgs = genericTypeMap.size > 0;
1058
+ if (!hasGenericArgs && this.classCache.has(cls)) return this.classCache.get(cls);
1059
+ let schema = {
1060
+ type: "object",
1061
+ properties: {}
1062
+ };
1063
+ const properties = this.getPropertiesByClassDeclaration(result.node, void 0, genericTypeMap);
962
1064
  schema = this.getSchemaFromProperties({
963
1065
  properties,
964
1066
  classDeclaration: result.node
965
1067
  });
1068
+ if (!hasGenericArgs) this.classCache.set(cls, {
1069
+ name: cls.name,
1070
+ schema
1071
+ });
966
1072
  return {
967
1073
  name: cls.name,
968
1074
  schema
package/dist/index.mjs CHANGED
@@ -164,6 +164,7 @@ var SchemaTransformer = class SchemaTransformer {
164
164
  maxCacheSize;
165
165
  autoCleanup;
166
166
  classFileIndex = /* @__PURE__ */ new Map();
167
+ transformCallIndex = /* @__PURE__ */ new Map();
167
168
  constructor(tsConfigPath = constants.TS_CONFIG_DEFAULT_PATH, options = {}) {
168
169
  this.maxCacheSize = options.maxCacheSize ?? 100;
169
170
  this.autoCleanup = options.autoCleanup ?? true;
@@ -175,19 +176,7 @@ var SchemaTransformer = class SchemaTransformer {
175
176
  const { options: tsOptions, fileNames } = ts.parseJsonConfigFileContent(config, ts.sys, "./");
176
177
  this.program = ts.createProgram(fileNames, tsOptions);
177
178
  this.checker = this.program.getTypeChecker();
178
- this.program.getSourceFiles().forEach((sf) => {
179
- sf.statements.forEach((stmt) => {
180
- if (ts.isClassDeclaration(stmt) && stmt.name) {
181
- const name = stmt.name.text;
182
- const entry = this.classFileIndex.get(name) || [];
183
- entry.push({
184
- sourceFile: sf,
185
- node: stmt
186
- });
187
- this.classFileIndex.set(name, entry);
188
- }
189
- });
190
- });
179
+ this.buildTransformCallIndex();
191
180
  }
192
181
  getPropertiesByClassDeclaration(classNode, visitedDeclarations = /* @__PURE__ */ new Set(), genericTypeMap = /* @__PURE__ */ new Map()) {
193
182
  if (visitedDeclarations.has(classNode)) return [];
@@ -235,6 +224,18 @@ var SchemaTransformer = class SchemaTransformer {
235
224
  const isOptional = !!member.questionToken;
236
225
  const isGeneric = this.isPropertyTypeGeneric(member);
237
226
  const isEnum = this.isEnum(member);
227
+ const isPrimitive = this.isPrimitiveType(type) || isEnum;
228
+ const isClassType = this.isClassType(member);
229
+ const isArray = this.isArrayProperty(member);
230
+ const isTypeLiteral = this.isTypeLiteral(member);
231
+ let genericClassReference = void 0;
232
+ if (isGeneric && !isPrimitive) {
233
+ const baseTypeName = type.replace(/\[\]$/, "").trim();
234
+ if (!this.isPrimitiveType(baseTypeName)) {
235
+ const matches = this.classFileIndex.get(baseTypeName);
236
+ if (matches && matches.length > 0 && matches[0]) genericClassReference = matches[0].node;
237
+ }
238
+ }
238
239
  const property = {
239
240
  name: propertyName,
240
241
  type,
@@ -242,12 +243,13 @@ var SchemaTransformer = class SchemaTransformer {
242
243
  isOptional,
243
244
  isGeneric,
244
245
  originalProperty: member,
245
- isPrimitive: this.isPrimitiveType(type) || isEnum,
246
- isClassType: this.isClassType(member),
247
- isArray: this.isArrayProperty(member),
246
+ isPrimitive,
247
+ isClassType,
248
+ isArray,
248
249
  isEnum,
249
250
  isRef: false,
250
- isTypeLiteral: this.isTypeLiteral(member)
251
+ isTypeLiteral,
252
+ genericClassReference
251
253
  };
252
254
  if (property.isClassType) {
253
255
  const declaration = this.getDeclarationProperty(property);
@@ -285,6 +287,7 @@ var SchemaTransformer = class SchemaTransformer {
285
287
  if (typeName.toLowerCase() === "uploadfile") return "UploadFile";
286
288
  if (typeName.toLowerCase() === "uploadfiledto") return "UploadFileDto";
287
289
  if (typeNode.typeArguments && typeNode.typeArguments.length > 0) {
290
+ if (typeName === "Array") return `${this.getTypeNodeToString(typeNode.typeArguments[0], genericTypeMap)}[]`;
288
291
  const firstTypeArg = typeNode.typeArguments[0];
289
292
  if (firstTypeArg && ts.isTypeReferenceNode(firstTypeArg) && ts.isIdentifier(firstTypeArg.typeName)) {
290
293
  if (firstTypeArg.typeName.text.toLowerCase() === "uploadfile") return "UploadFile";
@@ -307,6 +310,11 @@ var SchemaTransformer = class SchemaTransformer {
307
310
  if (types.length > 0 && types[0]) return types[0];
308
311
  return "object";
309
312
  default:
313
+ if (ts.isIndexedAccessTypeNode(typeNode)) {
314
+ const resolvedType = this.checker.getTypeAtLocation(typeNode);
315
+ const resolved = this.checker.typeToString(resolvedType);
316
+ if (this.isPrimitiveType(resolved)) return resolved;
317
+ }
310
318
  const typeText = typeNode.getText();
311
319
  if (genericTypeMap && genericTypeMap.has(typeText)) return genericTypeMap.get(typeText);
312
320
  if (typeText.startsWith("Date")) return constants.jsPrimitives.Date.type;
@@ -381,7 +389,7 @@ var SchemaTransformer = class SchemaTransformer {
381
389
  isGenericTypeFromSymbol(type) {
382
390
  if (this.isSimpleArrayType(type)) return false;
383
391
  if (type.aliasTypeArguments && type.aliasTypeArguments.length > 0) return true;
384
- if (type.typeArguments && type.typeArguments.length > 0) {
392
+ if (type.typeArguments && type.typeArguments.length > 0 && type.typeArguments[0].symbol.getName() === "Array") {
385
393
  const symbol = type.getSymbol();
386
394
  if (symbol && symbol.getName() === "Array") {
387
395
  const elementType = type.typeArguments[0];
@@ -426,6 +434,7 @@ var SchemaTransformer = class SchemaTransformer {
426
434
  if (!elementType) return false;
427
435
  if (this.isUtilityTypeFromType(elementType)) return false;
428
436
  if (elementType.typeArguments && elementType.typeArguments.length > 0) return false;
437
+ if (type.typeArguments && type.typeArguments[0].symbol && type.typeArguments[0].symbol.getName() !== "Array") return false;
429
438
  return true;
430
439
  }
431
440
  return false;
@@ -483,22 +492,6 @@ var SchemaTransformer = class SchemaTransformer {
483
492
  }
484
493
  return matches[0];
485
494
  }
486
- checkTypeMatch(value, typeNode) {
487
- const runtimeType = typeof value;
488
- if (runtimeType === "string" && typeNode.kind === ts.SyntaxKind.StringKeyword) return true;
489
- if (runtimeType === "number" && typeNode.kind === ts.SyntaxKind.NumberKeyword) return true;
490
- if (runtimeType === "boolean" && typeNode.kind === ts.SyntaxKind.BooleanKeyword) return true;
491
- if (Array.isArray(value) && ts.isArrayTypeNode(typeNode)) {
492
- if (value.length === 0) return true;
493
- const firstItem = value[0];
494
- const elementType = typeNode.elementType;
495
- return this.checkTypeMatch(firstItem, elementType);
496
- }
497
- if (runtimeType === "object" && value !== null && !Array.isArray(value)) {
498
- if (ts.isTypeReferenceNode(typeNode) || typeNode.kind === ts.SyntaxKind.ObjectKeyword) return true;
499
- }
500
- return false;
501
- }
502
495
  findBestMatch(cls, matches) {
503
496
  let instance = {};
504
497
  try {
@@ -540,7 +533,11 @@ var SchemaTransformer = class SchemaTransformer {
540
533
  isClassType(propertyDeclaration) {
541
534
  if (!propertyDeclaration.type) return false;
542
535
  if (this.isArrayProperty(propertyDeclaration)) {
543
- const elementType = propertyDeclaration.type.elementType;
536
+ var _propertyDeclaration$;
537
+ let elementType;
538
+ if (ts.isArrayTypeNode(propertyDeclaration.type)) elementType = propertyDeclaration.type.elementType;
539
+ else if (ts.isTypeReferenceNode(propertyDeclaration.type) && ((_propertyDeclaration$ = propertyDeclaration.type.typeArguments) === null || _propertyDeclaration$ === void 0 ? void 0 : _propertyDeclaration$[0])) elementType = propertyDeclaration.type.typeArguments[0];
540
+ if (!elementType) return false;
544
541
  if (ts.isTypeReferenceNode(elementType) && elementType.typeArguments && elementType.typeArguments.length > 0) {
545
542
  const firstTypeArg = elementType.typeArguments[0];
546
543
  if (firstTypeArg) {
@@ -601,7 +598,9 @@ var SchemaTransformer = class SchemaTransformer {
601
598
  }
602
599
  isArrayProperty(propertyDeclaration) {
603
600
  if (!propertyDeclaration.type) return false;
604
- return ts.isArrayTypeNode(propertyDeclaration.type);
601
+ if (ts.isArrayTypeNode(propertyDeclaration.type)) return true;
602
+ if (ts.isTypeReferenceNode(propertyDeclaration.type) && ts.isIdentifier(propertyDeclaration.type.typeName) && propertyDeclaration.type.typeName.text === "Array") return true;
603
+ return false;
605
604
  }
606
605
  getSchemaFromProperties({ properties, visitedClass, transformedSchema, classDeclaration }) {
607
606
  let schema = {};
@@ -636,6 +635,17 @@ var SchemaTransformer = class SchemaTransformer {
636
635
  visitedClass,
637
636
  transformedSchema
638
637
  });
638
+ else if (property.isGeneric) if (property.genericClassReference) schema = this.getSchemaFromClass({
639
+ isArray: property.isArray,
640
+ visitedClass,
641
+ transformedSchema,
642
+ declaration: property.genericClassReference
643
+ });
644
+ else schema = {
645
+ type: "object",
646
+ properties: {},
647
+ additionalProperties: true
648
+ };
639
649
  else schema = {
640
650
  type: "object",
641
651
  properties: {},
@@ -813,6 +823,11 @@ var SchemaTransformer = class SchemaTransformer {
813
823
  if (decorator.arguments.length === 0) return;
814
824
  const arg = decorator.arguments[0];
815
825
  if (arg && typeof arg === "object" && "kind" in arg) {
826
+ if (ts.isArrayLiteralExpression(arg)) {
827
+ const values = this.extractValuesFromArrayLiteral(arg);
828
+ if (values.length > 0) this.applyEnumValues(values, schema);
829
+ return;
830
+ }
816
831
  const type = this.checker.getTypeAtLocation(arg);
817
832
  if (type.symbol && type.symbol.exports) {
818
833
  const values = [];
@@ -824,15 +839,46 @@ var SchemaTransformer = class SchemaTransformer {
824
839
  }
825
840
  });
826
841
  if (values.length > 0) {
827
- schema.enum = values;
828
- const isString = values.every((v) => typeof v === "string");
829
- const isNumber = values.every((v) => typeof v === "number");
830
- if (isString) schema.type = "string";
831
- else if (isNumber) schema.type = "number";
832
- else schema.type = "string";
842
+ this.applyEnumValues(values, schema);
843
+ return;
833
844
  }
834
845
  }
846
+ const values = this.extractValuesFromObjectLiteral(type);
847
+ if (values.length > 0) this.applyEnumValues(values, schema);
848
+ }
849
+ }
850
+ extractValuesFromArrayLiteral(arrayLiteral) {
851
+ const values = [];
852
+ for (const element of arrayLiteral.elements) if (ts.isStringLiteral(element)) values.push(element.text);
853
+ else if (ts.isNumericLiteral(element)) values.push(Number(element.text));
854
+ else if (ts.isPrefixUnaryExpression(element) && element.operator === ts.SyntaxKind.MinusToken && ts.isNumericLiteral(element.operand)) values.push(-Number(element.operand.text));
855
+ return values;
856
+ }
857
+ extractValuesFromObjectLiteral(type) {
858
+ const values = [];
859
+ const properties = type.getProperties();
860
+ if (!properties || properties.length === 0) return values;
861
+ for (const prop of properties) {
862
+ const propType = this.checker.getTypeOfSymbolAtLocation(prop, prop.valueDeclaration);
863
+ if (propType.isStringLiteral()) values.push(propType.value);
864
+ else if (propType.isNumberLiteral()) values.push(propType.value);
865
+ else if (prop.valueDeclaration && ts.isPropertyAssignment(prop.valueDeclaration)) {
866
+ const initializer = prop.valueDeclaration.initializer;
867
+ if (ts.isStringLiteral(initializer)) values.push(initializer.text);
868
+ else if (ts.isNumericLiteral(initializer)) values.push(Number(initializer.text));
869
+ }
835
870
  }
871
+ return values;
872
+ }
873
+ applyEnumValues(values, schema) {
874
+ schema.enum = values;
875
+ const isString = values.every((v) => typeof v === "string");
876
+ const isNumber = values.every((v) => typeof v === "number");
877
+ if (isString) schema.type = "string";
878
+ else if (isNumber) schema.type = "number";
879
+ else schema.type = "string";
880
+ delete schema.properties;
881
+ delete schema.additionalProperties;
836
882
  }
837
883
  applyDecorators(property, schema) {
838
884
  for (const decorator of property.decorators) switch (decorator.name) {
@@ -910,16 +956,65 @@ var SchemaTransformer = class SchemaTransformer {
910
956
  break;
911
957
  case constants.validatorDecorators.IsEnum.name:
912
958
  if (!property.isArray) this.applyEnumDecorator(decorator, schema);
913
- else if (schema.items) this.applyEnumDecorator(decorator, schema.items);
959
+ else {
960
+ if (!schema.items) {
961
+ schema.type = "array";
962
+ schema.items = {};
963
+ }
964
+ this.applyEnumDecorator(decorator, schema.items);
965
+ }
914
966
  break;
915
967
  }
916
968
  }
969
+ /**
970
+ * Scans all non-declaration source files in the program once and records
971
+ * every call of the form `transform(Foo<Bar, Baz>)`, keyed by class name.
972
+ * This means generic resolution in transform() is a pure O(1) Map lookup
973
+ * with no runtime stack inspection.
974
+ */
975
+ buildTransformCallIndex() {
976
+ this.program.getSourceFiles().forEach((sf) => {
977
+ if (sf.isDeclarationFile) return;
978
+ sf.statements.forEach((stmt) => {
979
+ if (ts.isClassDeclaration(stmt) && stmt.name) {
980
+ const name = stmt.name.text;
981
+ const entry = this.classFileIndex.get(name) || [];
982
+ entry.push({
983
+ sourceFile: sf,
984
+ node: stmt
985
+ });
986
+ this.classFileIndex.set(name, entry);
987
+ }
988
+ });
989
+ const visit = (node) => {
990
+ if (ts.isCallExpression(node) && node.arguments.length > 0) {
991
+ const callee = node.expression;
992
+ if (ts.isIdentifier(callee) && callee.text === "transform" || ts.isPropertyAccessExpression(callee) && callee.name.text === "transform") {
993
+ const firstArg = node.arguments[0];
994
+ const typeArgs = firstArg.typeArguments;
995
+ if (typeArgs && typeArgs.length > 0) {
996
+ const baseExpr = firstArg.expression ?? firstArg;
997
+ if (ts.isIdentifier(baseExpr)) {
998
+ var _this$classFileIndex$;
999
+ const classNode = (_this$classFileIndex$ = this.classFileIndex.get(baseExpr.text)) === null || _this$classFileIndex$ === void 0 || (_this$classFileIndex$ = _this$classFileIndex$[0]) === null || _this$classFileIndex$ === void 0 ? void 0 : _this$classFileIndex$.node;
1000
+ if (classNode === null || classNode === void 0 ? void 0 : classNode.typeParameters) {
1001
+ const typeMap = /* @__PURE__ */ new Map();
1002
+ classNode.typeParameters.forEach((param, i) => {
1003
+ const typeArg = typeArgs[i];
1004
+ if (typeArg) typeMap.set(param.name.text, this.getTypeNodeToString(typeArg, /* @__PURE__ */ new Map()));
1005
+ });
1006
+ if (typeMap.size > 0) this.transformCallIndex.set(baseExpr.text, typeMap);
1007
+ }
1008
+ }
1009
+ }
1010
+ }
1011
+ }
1012
+ ts.forEachChild(node, visit);
1013
+ };
1014
+ visit(sf);
1015
+ });
1016
+ }
917
1017
  transform(cls, sourceOptions) {
918
- if (this.classCache.has(cls)) return this.classCache.get(cls);
919
- let schema = {
920
- type: "object",
921
- properties: {}
922
- };
923
1018
  const result = this.getSourceFileByClass(cls, sourceOptions);
924
1019
  if (!result || !(result === null || result === void 0 ? void 0 : result.sourceFile)) {
925
1020
  console.warn(`Class ${cls.name} not found in any source file.`);
@@ -933,11 +1028,22 @@ var SchemaTransformer = class SchemaTransformer {
933
1028
  }
934
1029
  };
935
1030
  }
936
- const properties = this.getPropertiesByClassDeclaration(result.node);
1031
+ const genericTypeMap = this.transformCallIndex.get(cls.name) ?? /* @__PURE__ */ new Map();
1032
+ const hasGenericArgs = genericTypeMap.size > 0;
1033
+ if (!hasGenericArgs && this.classCache.has(cls)) return this.classCache.get(cls);
1034
+ let schema = {
1035
+ type: "object",
1036
+ properties: {}
1037
+ };
1038
+ const properties = this.getPropertiesByClassDeclaration(result.node, void 0, genericTypeMap);
937
1039
  schema = this.getSchemaFromProperties({
938
1040
  properties,
939
1041
  classDeclaration: result.node
940
1042
  });
1043
+ if (!hasGenericArgs) this.classCache.set(cls, {
1044
+ name: cls.name,
1045
+ schema
1046
+ });
941
1047
  return {
942
1048
  name: cls.name,
943
1049
  schema
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ts-class-to-openapi",
3
- "version": "1.3.4",
3
+ "version": "1.4.1",
4
4
  "description": "Transform TypeScript classes into OpenAPI 3.1.0 schema objects, which support class-validator decorators",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -81,9 +81,9 @@
81
81
  "test": "node --import tsx --test test/testCases/**/*.test.ts",
82
82
  "test:watch": "node --import tsx --inspect --test --watch test/testCases/**/*.test.ts",
83
83
  "test:coverage": "node --import tsx --test --experimental-test-coverage test/testCases/**/*.test.ts",
84
- "build": "tsc --noEmit && rm -rf dist && tsdown",
84
+ "build": "tsc --noEmit && tsdown",
85
85
  "build:watch": "rm -rf dist && tsdown --watch",
86
- "dev": "node --import tsx --test --inspect --watch ./src/run.ts",
86
+ "dev": "node --import tsx --inspect --watch ./src/run.ts",
87
87
  "format": "prettier --write .",
88
88
  "format:check": "prettier --check .",
89
89
  "prepublish": "pnpm build"