ts-class-to-openapi 1.2.2 → 1.2.4

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/dist/index.js CHANGED
@@ -68,14 +68,14 @@ class SchemaTransformer {
68
68
  this.program = ts.createProgram(fileNames, tsOptions);
69
69
  this.checker = this.program.getTypeChecker();
70
70
  }
71
- getPropertiesByClassDeclaration(classNode, visitedDeclarations = new Set()) {
71
+ getPropertiesByClassDeclaration(classNode, visitedDeclarations = new Set(), genericTypeMap = new Map()) {
72
72
  if (visitedDeclarations.has(classNode)) {
73
73
  return [];
74
74
  }
75
75
  visitedDeclarations.add(classNode);
76
76
  // if no heritage clauses, get properties directly from class
77
77
  if (!classNode.heritageClauses) {
78
- return this.getPropertiesByClassMembers(classNode.members, classNode);
78
+ return this.getPropertiesByClassMembers(classNode.members, classNode, genericTypeMap);
79
79
  } // use heritage clauses to get properties from base classes
80
80
  else {
81
81
  const heritageClause = classNode.heritageClauses[0];
@@ -91,24 +91,42 @@ class SchemaTransformer {
91
91
  return [];
92
92
  const declaration = symbol.declarations?.[0];
93
93
  if (declaration && ts.isClassDeclaration(declaration)) {
94
- baseProperties = this.getPropertiesByClassDeclaration(declaration, visitedDeclarations);
94
+ const newGenericTypeMap = new Map();
95
+ if (declaration.typeParameters && type.typeArguments) {
96
+ declaration.typeParameters.forEach((param, index) => {
97
+ const arg = type.typeArguments[index];
98
+ if (arg) {
99
+ const resolvedArg = this.getTypeNodeToString(arg, genericTypeMap);
100
+ newGenericTypeMap.set(param.name.text, resolvedArg);
101
+ }
102
+ });
103
+ }
104
+ baseProperties = this.getPropertiesByClassDeclaration(declaration, visitedDeclarations, newGenericTypeMap);
95
105
  }
96
- properties = this.getPropertiesByClassMembers(classNode.members, classNode);
106
+ properties = this.getPropertiesByClassMembers(classNode.members, classNode, genericTypeMap);
97
107
  return baseProperties.concat(properties);
98
108
  }
99
109
  else {
100
- return this.getPropertiesByClassMembers(classNode.members, classNode);
110
+ return this.getPropertiesByClassMembers(classNode.members, classNode, genericTypeMap);
101
111
  }
102
112
  }
103
113
  }
104
- getPropertiesByClassMembers(members, parentClassNode) {
114
+ getPropertiesByClassMembers(members, parentClassNode, genericTypeMap = new Map()) {
105
115
  const properties = [];
106
116
  for (const member of members) {
107
117
  if (ts.isPropertyDeclaration(member) &&
108
118
  member.name &&
109
119
  ts.isIdentifier(member.name)) {
120
+ // Skip static, private, and protected properties
121
+ if (member.modifiers) {
122
+ const hasExcludedModifier = member.modifiers.some(m => m.kind === ts.SyntaxKind.StaticKeyword ||
123
+ m.kind === ts.SyntaxKind.PrivateKeyword ||
124
+ m.kind === ts.SyntaxKind.ProtectedKeyword);
125
+ if (hasExcludedModifier)
126
+ continue;
127
+ }
110
128
  const propertyName = member.name.text;
111
- const type = this.getPropertyType(member);
129
+ const type = this.getPropertyType(member, genericTypeMap);
112
130
  const decorators = this.extractDecorators(member);
113
131
  const isOptional = !!member.questionToken;
114
132
  const isGeneric = this.isPropertyTypeGeneric(member);
@@ -168,17 +186,21 @@ class SchemaTransformer {
168
186
  }
169
187
  return properties;
170
188
  }
171
- getPropertyType(property) {
189
+ getPropertyType(property, genericTypeMap = new Map()) {
172
190
  if (property.type) {
173
- return this.getTypeNodeToString(property.type);
191
+ return this.getTypeNodeToString(property.type, genericTypeMap);
174
192
  }
175
193
  const type = this.checker.getTypeAtLocation(property);
176
194
  return this.getStringFromType(type);
177
195
  }
178
- getTypeNodeToString(typeNode) {
196
+ getTypeNodeToString(typeNode, genericTypeMap = new Map()) {
179
197
  if (ts.isTypeReferenceNode(typeNode) &&
180
198
  ts.isIdentifier(typeNode.typeName)) {
181
- if (typeNode.typeName.text.toLowerCase() === 'uploadfile') {
199
+ const typeName = typeNode.typeName.text;
200
+ if (genericTypeMap.has(typeName)) {
201
+ return genericTypeMap.get(typeName);
202
+ }
203
+ if (typeName.toLowerCase() === 'uploadfile') {
182
204
  return 'UploadFile';
183
205
  }
184
206
  if (typeNode.typeArguments && typeNode.typeArguments.length > 0) {
@@ -203,11 +225,11 @@ class SchemaTransformer {
203
225
  return constants.jsPrimitives.Boolean.type;
204
226
  case ts.SyntaxKind.ArrayType:
205
227
  const arrayType = typeNode;
206
- return `${this.getTypeNodeToString(arrayType.elementType)}[]`;
228
+ return `${this.getTypeNodeToString(arrayType.elementType, genericTypeMap)}[]`;
207
229
  case ts.SyntaxKind.UnionType:
208
230
  // Handle union types like string | null
209
231
  const unionType = typeNode;
210
- const types = unionType.types.map(t => this.getTypeNodeToString(t));
232
+ const types = unionType.types.map(t => this.getTypeNodeToString(t, genericTypeMap));
211
233
  // Filter out null and undefined, return the first meaningful type
212
234
  const meaningfulTypes = types.filter(t => t !== 'null' && t !== 'undefined');
213
235
  if (meaningfulTypes.length > 0 && meaningfulTypes[0]) {
@@ -219,6 +241,10 @@ class SchemaTransformer {
219
241
  return 'object';
220
242
  default:
221
243
  const typeText = typeNode.getText();
244
+ // Check if this is a generic type parameter we can resolve
245
+ if (genericTypeMap && genericTypeMap.has(typeText)) {
246
+ return genericTypeMap.get(typeText);
247
+ }
222
248
  // Handle some common TypeScript utility types
223
249
  if (typeText.startsWith('Date'))
224
250
  return constants.jsPrimitives.Date.type;
@@ -453,37 +479,159 @@ class SchemaTransformer {
453
479
  }
454
480
  return SchemaTransformer.instance;
455
481
  }
456
- getSourceFileByClassName(className, sourceOptions) {
457
- let sourceFiles = [];
458
- if (sourceOptions?.isExternal) {
459
- sourceFiles = this.program.getSourceFiles().filter(sf => {
460
- return (sf.fileName.includes(sourceOptions.packageName) &&
461
- (!sourceOptions.filePath || sf.fileName === sourceOptions.filePath));
462
- });
482
+ getSourceFileByClass(cls, sourceOptions) {
483
+ const className = cls.name;
484
+ const sourceFiles = this.getFilteredSourceFiles(sourceOptions);
485
+ const matches = [];
486
+ for (const sourceFile of sourceFiles) {
487
+ const node = sourceFile.statements.find(stmt => ts.isClassDeclaration(stmt) &&
488
+ stmt.name &&
489
+ stmt.name.text === className);
490
+ if (node) {
491
+ matches.push({ sourceFile, node });
492
+ }
463
493
  }
464
- else {
465
- sourceFiles = this.program.getSourceFiles().filter(sf => {
466
- if (sf.isDeclarationFile)
467
- return false;
468
- if (sf.fileName.includes('.d.ts'))
469
- return false;
470
- if (sf.fileName.includes('node_modules'))
471
- return false;
494
+ if (matches.length === 0) {
495
+ return undefined;
496
+ }
497
+ if (matches.length === 1) {
498
+ return matches[0];
499
+ }
500
+ if (matches.length > 1 && !sourceOptions?.filePath) {
501
+ const bestMatch = this.findBestMatch(cls, matches);
502
+ if (bestMatch) {
503
+ return bestMatch;
504
+ }
505
+ const firstMatch = matches[0];
506
+ if (firstMatch) {
507
+ console.warn(`[ts-class-to-openapi] Warning: Found multiple classes with name '${className}'. Using the first one found in '${firstMatch.sourceFile.fileName}'. To resolve this collision, provide 'sourceOptions.filePath'.`);
508
+ }
509
+ }
510
+ return matches[0];
511
+ }
512
+ checkTypeMatch(value, typeNode) {
513
+ const runtimeType = typeof value;
514
+ if (runtimeType === 'string' &&
515
+ typeNode.kind === ts.SyntaxKind.StringKeyword)
516
+ return true;
517
+ if (runtimeType === 'number' &&
518
+ typeNode.kind === ts.SyntaxKind.NumberKeyword)
519
+ return true;
520
+ if (runtimeType === 'boolean' &&
521
+ typeNode.kind === ts.SyntaxKind.BooleanKeyword)
522
+ return true;
523
+ if (Array.isArray(value) && ts.isArrayTypeNode(typeNode)) {
524
+ if (value.length === 0)
525
+ return true;
526
+ const firstItem = value[0];
527
+ const elementType = typeNode.elementType;
528
+ return this.checkTypeMatch(firstItem, elementType);
529
+ }
530
+ if (runtimeType === 'object' && value !== null && !Array.isArray(value)) {
531
+ if (ts.isTypeReferenceNode(typeNode) ||
532
+ typeNode.kind === ts.SyntaxKind.ObjectKeyword) {
472
533
  return true;
534
+ }
535
+ }
536
+ return false;
537
+ }
538
+ findBestMatch(cls, matches) {
539
+ const runtimeSource = cls.toString();
540
+ const runtimeProperties = new Map();
541
+ const regexProperties = new Set();
542
+ // Try to extract properties from runtime source (assignments in constructor)
543
+ try {
544
+ const regex = /this\.([a-zA-Z0-9_$]+)\s*=/g;
545
+ let match;
546
+ while ((match = regex.exec(runtimeSource)) !== null) {
547
+ regexProperties.add(match[1]);
548
+ }
549
+ }
550
+ catch (e) {
551
+ // Ignore regex errors
552
+ }
553
+ // Try to instantiate the class to find properties
554
+ try {
555
+ const instance = new cls();
556
+ Object.keys(instance).forEach(key => {
557
+ runtimeProperties.set(key, instance[key]);
473
558
  });
474
559
  }
475
- for (const sourceFile of sourceFiles) {
476
- let node;
477
- const found = sourceFile.statements.some(stmt => {
478
- node = stmt;
479
- return (ts.isClassDeclaration(stmt) &&
480
- stmt.name &&
481
- stmt.name.text === className);
560
+ catch (e) {
561
+ // Ignore instantiation errors (e.g. required constructor arguments)
562
+ }
563
+ // Try to get properties from class-validator metadata
564
+ try {
565
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
566
+ const { getMetadataStorage } = require('class-validator');
567
+ const metadata = getMetadataStorage();
568
+ const targetMetadata = metadata.getTargetValidationMetadatas(cls, null, false, false);
569
+ targetMetadata.forEach((m) => {
570
+ if (m.propertyName && !runtimeProperties.has(m.propertyName)) {
571
+ runtimeProperties.set(m.propertyName, undefined);
572
+ }
482
573
  });
483
- if (found) {
484
- return { sourceFile, node: node };
574
+ }
575
+ catch (e) {
576
+ // Ignore if class-validator is not available or fails
577
+ }
578
+ // console.log(`[findBestMatch] Class: ${cls.name}, Runtime Props: ${Array.from(runtimeProperties.keys()).join(', ')}`)
579
+ const scores = matches.map(match => {
580
+ let score = 0;
581
+ for (const member of match.node.members) {
582
+ if (ts.isMethodDeclaration(member) && ts.isIdentifier(member.name)) {
583
+ if (runtimeSource.includes(member.name.text)) {
584
+ score += 2;
585
+ }
586
+ }
587
+ else if (ts.isPropertyDeclaration(member) &&
588
+ ts.isIdentifier(member.name)) {
589
+ const propName = member.name.text;
590
+ if (runtimeProperties.has(propName)) {
591
+ score += 1;
592
+ const value = runtimeProperties.get(propName);
593
+ if (member.type && this.checkTypeMatch(value, member.type)) {
594
+ score += 5;
595
+ }
596
+ }
597
+ else if (regexProperties.has(propName)) {
598
+ score += 1;
599
+ }
600
+ }
601
+ }
602
+ return { match, score };
603
+ });
604
+ scores.sort((a, b) => b.score - a.score);
605
+ const firstScore = scores[0];
606
+ const secondScore = scores[1];
607
+ if (firstScore && firstScore.score > 0) {
608
+ if (scores.length === 1 ||
609
+ (secondScore && firstScore.score > secondScore.score)) {
610
+ return firstScore.match;
485
611
  }
486
612
  }
613
+ return undefined;
614
+ }
615
+ getFilteredSourceFiles(sourceOptions) {
616
+ if (sourceOptions?.isExternal) {
617
+ return this.program.getSourceFiles().filter(sf => {
618
+ return (sf.fileName.includes(sourceOptions.packageName) &&
619
+ (!sourceOptions.filePath || sf.fileName === sourceOptions.filePath));
620
+ });
621
+ }
622
+ return this.program.getSourceFiles().filter(sf => {
623
+ if (sf.isDeclarationFile)
624
+ return false;
625
+ if (sf.fileName.includes('.d.ts'))
626
+ return false;
627
+ if (sf.fileName.includes('node_modules'))
628
+ return false;
629
+ if (sourceOptions?.filePath &&
630
+ !sf.fileName.includes(sourceOptions.filePath)) {
631
+ return false;
632
+ }
633
+ return true;
634
+ });
487
635
  }
488
636
  isEnum(propertyDeclaration) {
489
637
  if (!propertyDeclaration.type) {
@@ -1032,7 +1180,7 @@ class SchemaTransformer {
1032
1180
  }
1033
1181
  transform(cls, sourceOptions) {
1034
1182
  let schema = { type: 'object', properties: {} };
1035
- const result = this.getSourceFileByClassName(cls.name, sourceOptions);
1183
+ const result = this.getSourceFileByClass(cls, sourceOptions);
1036
1184
  if (!result?.sourceFile) {
1037
1185
  console.warn(`Class ${cls.name} not found in any source file.`);
1038
1186
  return { name: cls.name, schema: {} };