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