ts-class-to-openapi 1.0.3 → 1.0.5

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.esm.js CHANGED
@@ -36,6 +36,7 @@ const validatorDecorators = {
36
36
  ArrayNotEmpty: { name: 'ArrayNotEmpty' },
37
37
  ArrayMaxSize: { name: 'ArrayMaxSize' },
38
38
  ArrayMinSize: { name: 'ArrayMinSize' },
39
+ IsEnum: { name: 'IsEnum', type: 'string' },
39
40
  };
40
41
  const constants = {
41
42
  TS_CONFIG_DEFAULT_PATH,
@@ -94,6 +95,12 @@ class SchemaTransformer {
94
95
  * @private
95
96
  */
96
97
  loadedFiles = new Set();
98
+ /**
99
+ * Set of class names currently being processed to prevent circular references
100
+ * Key format: "fileName:className" for uniqueness across different files
101
+ * @private
102
+ */
103
+ processingClasses = new Set();
97
104
  /**
98
105
  * Private constructor for singleton pattern.
99
106
  *
@@ -146,20 +153,19 @@ class SchemaTransformer {
146
153
  }
147
154
  }
148
155
  /**
149
- * Gets relevant source files for a class, filtering out unnecessary files to save memory.
156
+ * Transforms a class by its name into an OpenAPI schema object.
157
+ * Considers the context of the calling file to resolve ambiguous class names.
158
+ * Includes circular reference detection to prevent infinite recursion.
150
159
  *
151
- * @param className - The name of the class to find files for
152
- * @param filePath - Optional specific file path
153
- * @returns Array of relevant source files
160
+ * @param className - The name of the class to transform
161
+ * @param contextFilePath - Optional path to context file for resolving class ambiguity
162
+ * @returns Object containing the class name and its corresponding JSON schema
163
+ * @throws {Error} When the specified class cannot be found
154
164
  * @private
155
165
  */
156
- getRelevantSourceFiles(className, filePath) {
157
- if (filePath) {
158
- const sourceFile = this.program.getSourceFile(filePath);
159
- return sourceFile ? [sourceFile] : [];
160
- }
161
- // Only get source files that are not declaration files and not in node_modules
162
- return this.program.getSourceFiles().filter(sf => {
166
+ transformByName(className, contextFilePath) {
167
+ // Get all relevant source files (not declaration files and not in node_modules)
168
+ const sourceFiles = this.program.getSourceFiles().filter(sf => {
163
169
  if (sf.isDeclarationFile)
164
170
  return false;
165
171
  if (sf.fileName.includes('.d.ts'))
@@ -170,19 +176,46 @@ class SchemaTransformer {
170
176
  this.loadedFiles.add(sf.fileName);
171
177
  return true;
172
178
  });
173
- }
174
- /**
175
- * Transforms a class by its name into an OpenAPI schema object.
176
- *
177
- * @param className - The name of the class to transform
178
- * @param filePath - Optional path to the file containing the class
179
- * @returns Object containing the class name and its corresponding JSON schema
180
- * @throws {Error} When the specified class cannot be found
181
- * @private
182
- */
183
- transformByName(className, filePath) {
184
- const sourceFiles = this.getRelevantSourceFiles(className, filePath);
185
- for (const sourceFile of sourceFiles) {
179
+ // If we have a context file, try to find the class in that file first
180
+ if (contextFilePath) {
181
+ const contextSourceFile = this.program.getSourceFile(contextFilePath);
182
+ if (contextSourceFile) {
183
+ const classNode = this.findClassByName(contextSourceFile, className);
184
+ if (classNode) {
185
+ const cacheKey = this.getCacheKey(contextSourceFile.fileName, className);
186
+ // Check cache first
187
+ if (this.classCache.has(cacheKey)) {
188
+ return this.classCache.get(cacheKey);
189
+ }
190
+ // Check for circular reference before processing
191
+ if (this.processingClasses.has(cacheKey)) {
192
+ // Return a $ref reference to break circular dependency (OpenAPI 3.1 style)
193
+ return {
194
+ name: className,
195
+ schema: {
196
+ $ref: `#/components/schemas/${className}`,
197
+ description: `Reference to ${className} (circular reference detected)`,
198
+ },
199
+ };
200
+ }
201
+ // Mark this class as being processed
202
+ this.processingClasses.add(cacheKey);
203
+ try {
204
+ const result = this.transformClass(classNode, contextSourceFile);
205
+ this.classCache.set(cacheKey, result);
206
+ this.cleanupCache();
207
+ return result;
208
+ }
209
+ finally {
210
+ // Always remove from processing set when done
211
+ this.processingClasses.delete(cacheKey);
212
+ }
213
+ }
214
+ }
215
+ }
216
+ // Fallback to searching all files, but prioritize files that are more likely to be relevant
217
+ const prioritizedFiles = this.prioritizeSourceFiles(sourceFiles, contextFilePath);
218
+ for (const sourceFile of prioritizedFiles) {
186
219
  const classNode = this.findClassByName(sourceFile, className);
187
220
  if (classNode && sourceFile?.fileName) {
188
221
  const cacheKey = this.getCacheKey(sourceFile.fileName, className);
@@ -190,16 +223,67 @@ class SchemaTransformer {
190
223
  if (this.classCache.has(cacheKey)) {
191
224
  return this.classCache.get(cacheKey);
192
225
  }
193
- const result = this.transformClass(classNode);
194
- // Cache using fileName:className as key for uniqueness
195
- this.classCache.set(cacheKey, result);
196
- // Clean up cache if it gets too large
197
- this.cleanupCache();
198
- return result;
226
+ // Check for circular reference before processing
227
+ if (this.processingClasses.has(cacheKey)) {
228
+ // Return a $ref reference to break circular dependency (OpenAPI 3.1 style)
229
+ return {
230
+ name: className,
231
+ schema: {
232
+ $ref: `#/components/schemas/${className}`,
233
+ description: `Reference to ${className} (circular reference detected)`,
234
+ },
235
+ };
236
+ }
237
+ // Mark this class as being processed
238
+ this.processingClasses.add(cacheKey);
239
+ try {
240
+ const result = this.transformClass(classNode, sourceFile);
241
+ // Cache using fileName:className as key for uniqueness
242
+ this.classCache.set(cacheKey, result);
243
+ // Clean up cache if it gets too large
244
+ this.cleanupCache();
245
+ return result;
246
+ }
247
+ finally {
248
+ // Always remove from processing set when done
249
+ this.processingClasses.delete(cacheKey);
250
+ }
199
251
  }
200
252
  }
201
253
  throw new Error(`Class ${className} not found`);
202
254
  }
255
+ /**
256
+ * Prioritizes source files based on context to resolve class name conflicts.
257
+ * Gives priority to files in the same directory or with similar names.
258
+ *
259
+ * @param sourceFiles - Array of source files to prioritize
260
+ * @param contextFilePath - Optional path to context file for prioritization
261
+ * @returns Prioritized array of source files
262
+ * @private
263
+ */
264
+ prioritizeSourceFiles(sourceFiles, contextFilePath) {
265
+ if (!contextFilePath) {
266
+ return sourceFiles;
267
+ }
268
+ const contextDir = contextFilePath.substring(0, contextFilePath.lastIndexOf('/'));
269
+ return sourceFiles.sort((a, b) => {
270
+ const aDir = a.fileName.substring(0, a.fileName.lastIndexOf('/'));
271
+ const bDir = b.fileName.substring(0, b.fileName.lastIndexOf('/'));
272
+ // Prioritize files in the same directory as context
273
+ const aInSameDir = aDir === contextDir ? 1 : 0;
274
+ const bInSameDir = bDir === contextDir ? 1 : 0;
275
+ if (aInSameDir !== bInSameDir) {
276
+ return bInSameDir - aInSameDir; // Higher priority first
277
+ }
278
+ // Prioritize non-test files over test files
279
+ const aIsTest = a.fileName.includes('test') || a.fileName.includes('spec') ? 0 : 1;
280
+ const bIsTest = b.fileName.includes('test') || b.fileName.includes('spec') ? 0 : 1;
281
+ if (aIsTest !== bIsTest) {
282
+ return bIsTest - aIsTest; // Non-test files first
283
+ }
284
+ return 0;
285
+ });
286
+ }
203
287
  /**
204
288
  * Gets the singleton instance of SchemaTransformer.
205
289
  *
@@ -226,16 +310,88 @@ class SchemaTransformer {
226
310
  /**
227
311
  * Clears the current singleton instance. Useful for testing or when you need
228
312
  * to create a new instance with different configuration.
313
+ * @private
229
314
  */
230
315
  static clearInstance() {
231
- SchemaTransformer.instance = null;
316
+ SchemaTransformer.instance = undefined;
317
+ }
318
+ /**
319
+ * Flag to prevent recursive disposal calls
320
+ * @private
321
+ */
322
+ static disposingInProgress = false;
323
+ /**
324
+ * Completely disposes of the current singleton instance and releases all resources.
325
+ * This is a static method that can be called without having an instance reference.
326
+ * Ensures complete memory cleanup regardless of the current state.
327
+ *
328
+ * @example
329
+ * ```typescript
330
+ * SchemaTransformer.disposeInstance();
331
+ * // All resources released, next getInstance() will create fresh instance
332
+ * ```
333
+ *
334
+ * @public
335
+ */
336
+ static disposeInstance() {
337
+ // Prevent recursive disposal calls
338
+ if (SchemaTransformer.disposingInProgress) {
339
+ return;
340
+ }
341
+ SchemaTransformer.disposingInProgress = true;
342
+ try {
343
+ if (SchemaTransformer.instance) {
344
+ SchemaTransformer.instance.dispose();
345
+ }
346
+ }
347
+ catch (error) {
348
+ // Log any disposal errors but continue with cleanup
349
+ console.warn('Warning during static disposal:', error);
350
+ }
351
+ finally {
352
+ // Always ensure the static instance is cleared
353
+ SchemaTransformer.instance = undefined;
354
+ SchemaTransformer.disposingInProgress = false;
355
+ // Force garbage collection for cleanup
356
+ if (global.gc) {
357
+ global.gc();
358
+ }
359
+ }
360
+ }
361
+ /**
362
+ * @deprecated Use disposeInstance() instead for better clarity
363
+ * @private
364
+ */
365
+ static dispose() {
366
+ SchemaTransformer.disposeInstance();
232
367
  }
233
368
  static getInstance(tsConfigPath, options) {
234
- if (!SchemaTransformer.instance) {
369
+ if (!SchemaTransformer.instance || SchemaTransformer.isInstanceDisposed()) {
235
370
  SchemaTransformer.instance = new SchemaTransformer(tsConfigPath, options);
236
371
  }
237
372
  return SchemaTransformer.instance;
238
373
  }
374
+ /**
375
+ * Internal method to check if current instance is disposed
376
+ * @private
377
+ */
378
+ static isInstanceDisposed() {
379
+ return SchemaTransformer.instance
380
+ ? SchemaTransformer.instance.isDisposed()
381
+ : true;
382
+ }
383
+ /**
384
+ * Transforms a class using the singleton instance
385
+ * @param cls - The class constructor function to transform
386
+ * @param options - Optional configuration for memory management (only used if no instance exists)
387
+ * @returns Object containing the class name and its corresponding JSON schema
388
+ * @public
389
+ */
390
+ static transformClass(cls, options) {
391
+ // Use the singleton instance instead of creating a temporary one
392
+ const transformer = SchemaTransformer.getInstance(undefined, options);
393
+ return transformer.transform(cls);
394
+ }
239
395
  /**
240
396
  * Transforms a class constructor function into an OpenAPI schema object.
241
397
  *
@@ -270,31 +426,147 @@ class SchemaTransformer {
270
426
  clearCache() {
271
427
  this.classCache.clear();
272
428
  this.loadedFiles.clear();
429
+ this.processingClasses.clear();
273
430
  // Force garbage collection hint if available
274
431
  if (global.gc) {
275
432
  global.gc();
276
433
  }
277
434
  }
435
+ /**
436
+ * Completely disposes of the transformer instance and releases all resources.
437
+ * This includes clearing all caches, releasing TypeScript program resources,
438
+ * and resetting the singleton instance.
439
+ *
440
+ * After calling this method, you need to call getInstance() again to get a new instance.
441
+ *
442
+ * @example
443
+ * ```typescript
444
+ * const transformer = SchemaTransformer.getInstance();
445
+ * // ... use transformer
446
+ * transformer.dispose();
447
+ * // transformer is now unusable, need to get new instance
448
+ * const newTransformer = SchemaTransformer.getInstance();
449
+ * ```
450
+ *
451
+ * @private
452
+ */
453
+ dispose() {
454
+ try {
455
+ // Clear all caches and sets completely
456
+ this.classCache.clear();
457
+ this.loadedFiles.clear();
458
+ this.processingClasses.clear();
459
+ // Release TypeScript program resources
460
+ // While TypeScript doesn't provide explicit disposal methods,
461
+ // we can help garbage collection by clearing all references
462
+ // Clear all references to TypeScript objects
463
+ // @ts-ignore - We're intentionally setting these to null for cleanup
464
+ this.program = null;
465
+ // @ts-ignore - We're intentionally setting these to null for cleanup
466
+ this.checker = null;
467
+ }
468
+ catch (error) {
469
+ // If there's any error during disposal, log it but continue
470
+ console.warn('Warning during transformer disposal:', error);
471
+ }
472
+ finally {
473
+ // Force garbage collection for cleanup
474
+ if (global.gc) {
475
+ global.gc();
476
+ }
477
+ }
478
+ }
479
+ /**
480
+ * Completely resets the transformer by disposing current instance and creating a new one.
481
+ * This is useful when you need a fresh start with different TypeScript configuration
482
+ * or want to ensure all resources are properly released and recreated.
483
+ *
484
+ * @param tsConfigPath - Optional path to a specific TypeScript config file for the new instance
485
+ * @param options - Configuration options for memory management for the new instance
486
+ * @returns A fresh SchemaTransformer instance
487
+ *
488
+ * @example
489
+ * ```typescript
490
+ * const transformer = SchemaTransformer.getInstance();
491
+ * // ... use transformer
492
+ * const freshTransformer = transformer.reset('./new-tsconfig.json');
493
+ * ```
494
+ *
495
+ * @private
496
+ */
497
+ reset(tsConfigPath, options) {
498
+ // Dispose current instance using static method to properly clear instance reference
499
+ SchemaTransformer.disposeInstance();
500
+ // Create and return new instance
501
+ return SchemaTransformer.getInstance(tsConfigPath, options);
502
+ }
278
503
  /**
279
504
  * Gets memory usage statistics for monitoring and debugging.
280
505
  *
281
- * @returns Object containing cache size and loaded files count
506
+ * @returns Object containing cache size, loaded files count, and processing status
282
507
  *
283
508
  * @example
284
509
  * ```typescript
285
510
  * const transformer = SchemaTransformer.getInstance();
286
511
  * const stats = transformer.getMemoryStats();
287
512
  * console.log(`Cache entries: ${stats.cacheSize}, Files loaded: ${stats.loadedFiles}`);
513
+ * console.log(`Currently processing: ${stats.currentlyProcessing} classes`);
288
514
  * ```
289
515
  *
290
- * @public
516
+ * @private
291
517
  */
292
518
  getMemoryStats() {
293
519
  return {
294
- cacheSize: this.classCache.size,
295
- loadedFiles: this.loadedFiles.size,
520
+ cacheSize: this.classCache?.size || 0,
521
+ loadedFiles: this.loadedFiles?.size || 0,
522
+ currentlyProcessing: this.processingClasses?.size || 0,
523
+ maxCacheSize: this.maxCacheSize || 0,
524
+ autoCleanup: this.autoCleanup || false,
525
+ isDisposed: !this.program || !this.checker,
296
526
  };
297
527
  }
528
+ /**
529
+ * Checks if the transformer instance has been disposed and is no longer usable.
530
+ *
531
+ * @returns True if the instance has been disposed
532
+ *
533
+ * @example
534
+ * ```typescript
535
+ * const transformer = SchemaTransformer.getInstance();
536
+ * transformer.dispose();
537
+ * console.log(transformer.isDisposed()); // true
538
+ * ```
539
+ *
540
+ * @private
541
+ */
542
+ isDisposed() {
543
+ return (!this.program ||
544
+ !this.checker ||
545
+ !this.classCache ||
546
+ !this.loadedFiles ||
547
+ !this.processingClasses);
548
+ }
549
+ /**
550
+ * Static method to check if there's an active singleton instance.
551
+ *
552
+ * @returns True if there's an active instance, false if disposed or never created
553
+ *
554
+ * @example
555
+ * ```typescript
556
+ * console.log(SchemaTransformer.hasActiveInstance()); // false
557
+ * const transformer = SchemaTransformer.getInstance();
558
+ * console.log(SchemaTransformer.hasActiveInstance()); // true
559
+ * SchemaTransformer.dispose();
560
+ * console.log(SchemaTransformer.hasActiveInstance()); // false
561
+ * ```
562
+ *
563
+ * @private
564
+ */
565
+ static hasActiveInstance() {
566
+ return (SchemaTransformer.instance !== null &&
567
+ SchemaTransformer.instance !== undefined &&
568
+ !SchemaTransformer.isInstanceDisposed());
569
+ }
298
570
  /**
299
571
  * Finds a class declaration by name within a source file.
300
572
  *
@@ -319,13 +591,14 @@ class SchemaTransformer {
319
591
  * Transforms a TypeScript class declaration into a schema object.
320
592
  *
321
593
  * @param classNode - The TypeScript class declaration node
594
+ * @param sourceFile - The source file containing the class (for context)
322
595
  * @returns Object containing class name and generated schema
323
596
  * @private
324
597
  */
325
- transformClass(classNode) {
598
+ transformClass(classNode, sourceFile) {
326
599
  const className = classNode.name?.text || 'Unknown';
327
600
  const properties = this.extractProperties(classNode);
328
- const schema = this.generateSchema(properties);
601
+ const schema = this.generateSchema(properties, sourceFile?.fileName);
329
602
  return { name: className, schema };
330
603
  }
331
604
  /**
@@ -369,6 +642,237 @@ class SchemaTransformer {
369
642
  const type = this.checker.getTypeAtLocation(property);
370
643
  return this.checker.typeToString(type);
371
644
  }
645
+ /**
646
+ * Resolves generic types by analyzing the type alias and its arguments.
647
+ * For example, User<Role> where User is a type alias will be resolved to its structure.
648
+ *
649
+ * @param typeNode - The TypeScript type reference node with generic arguments
650
+ * @returns String representation of the resolved type or schema
651
+ * @private
652
+ */
653
+ resolveGenericType(typeNode) {
654
+ const typeName = typeNode.typeName.text;
655
+ const typeArguments = typeNode.typeArguments;
656
+ if (!typeArguments || typeArguments.length === 0) {
657
+ return typeName;
658
+ }
659
+ // Try to resolve the type using the TypeScript type checker
660
+ const type = this.checker.getTypeAtLocation(typeNode);
661
+ const resolvedType = this.checker.typeToString(type);
662
+ // If we can resolve it to a meaningful structure, use that
663
+ if (resolvedType &&
664
+ resolvedType !== typeName &&
665
+ !resolvedType.includes('any')) {
666
+ // For type aliases like User<Role>, we want to create a synthetic type name
667
+ // that represents the resolved structure
668
+ const typeArgNames = typeArguments.map(arg => {
669
+ if (ts.isTypeReferenceNode(arg) && ts.isIdentifier(arg.typeName)) {
670
+ return arg.typeName.text;
671
+ }
672
+ return this.getTypeNodeToString(arg);
673
+ });
674
+ return `${typeName}_${typeArgNames.join('_')}`;
675
+ }
676
+ return typeName;
677
+ }
678
+ /**
679
+ * Checks if a type string represents a resolved generic type.
680
+ *
681
+ * @param type - The type string to check
682
+ * @returns True if it's a resolved generic type
683
+ * @private
684
+ */
685
+ isResolvedGenericType(type) {
686
+ // Simple heuristic: resolved generic types contain underscores and
687
+ // the parts after underscore should be known types
688
+ const parts = type.split('_');
689
+ return (parts.length > 1 &&
690
+ parts
691
+ .slice(1)
692
+ .every(part => this.isKnownType(part) || this.isPrimitiveType(part)));
693
+ }
694
+ /**
695
+ * Checks if a type is a known class or interface.
696
+ *
697
+ * @param typeName - The type name to check
698
+ * @returns True if it's a known type
699
+ * @private
700
+ */
701
+ isKnownType(typeName) {
702
+ // First check if it's a primitive type to avoid unnecessary lookups
703
+ if (this.isPrimitiveType(typeName)) {
704
+ return true;
705
+ }
706
+ try {
707
+ // Use a more conservative approach - check if we can find the class
708
+ // without actually transforming it to avoid side effects
709
+ const found = this.findClassInProject(typeName);
710
+ return found !== null;
711
+ }
712
+ catch {
713
+ return false;
714
+ }
715
+ }
716
+ /**
717
+ * Finds a class by name in the project without transforming it.
718
+ *
719
+ * @param className - The class name to find
720
+ * @returns True if found, false otherwise
721
+ * @private
722
+ */
723
+ findClassInProject(className) {
724
+ const sourceFiles = this.program.getSourceFiles().filter(sf => {
725
+ if (sf.isDeclarationFile)
726
+ return false;
727
+ if (sf.fileName.includes('.d.ts'))
728
+ return false;
729
+ if (sf.fileName.includes('node_modules'))
730
+ return false;
731
+ return true;
732
+ });
733
+ for (const sourceFile of sourceFiles) {
734
+ const found = this.findClassByName(sourceFile, className);
735
+ if (found)
736
+ return true;
737
+ }
738
+ return false;
739
+ }
740
+ /**
741
+ * Checks if a type is a primitive type.
742
+ *
743
+ * @param typeName - The type name to check
744
+ * @returns True if it's a primitive type
745
+ * @private
746
+ */
747
+ isPrimitiveType(typeName) {
748
+ const lowerTypeName = typeName.toLowerCase();
749
+ // Check against all primitive types from constants
750
+ const primitiveTypes = [
751
+ constants.jsPrimitives.String.type.toLowerCase(),
752
+ constants.jsPrimitives.Number.type.toLowerCase(),
753
+ constants.jsPrimitives.Boolean.type.toLowerCase(),
754
+ constants.jsPrimitives.Date.type.toLowerCase(),
755
+ constants.jsPrimitives.Buffer.type.toLowerCase(),
756
+ constants.jsPrimitives.Uint8Array.type.toLowerCase(),
757
+ constants.jsPrimitives.File.type.toLowerCase(),
758
+ constants.jsPrimitives.UploadFile.type.toLowerCase(),
759
+ constants.jsPrimitives.BigInt.type.toLowerCase(),
760
+ ];
761
+ return primitiveTypes.includes(lowerTypeName);
762
+ }
763
+ /**
764
+ * Resolves a generic type schema by analyzing the type alias structure.
765
+ *
766
+ * @param resolvedTypeName - The resolved generic type name (e.g., User_Role)
767
+ * @returns OpenAPI schema for the resolved generic type
768
+ * @private
769
+ */
770
+ resolveGenericTypeSchema(resolvedTypeName) {
771
+ const parts = resolvedTypeName.split('_');
772
+ const baseTypeName = parts[0];
773
+ const typeArgNames = parts.slice(1);
774
+ if (!baseTypeName) {
775
+ return null;
776
+ }
777
+ // Find the original type alias declaration
778
+ const typeAliasSymbol = this.findTypeAliasDeclaration(baseTypeName);
779
+ if (!typeAliasSymbol) {
780
+ return null;
781
+ }
782
+ // Create a schema based on the type alias structure, substituting type parameters
783
+ return this.createSchemaFromTypeAlias(typeAliasSymbol, typeArgNames);
784
+ }
785
+ /**
786
+ * Finds a type alias declaration by name.
787
+ *
788
+ * @param typeName - The type alias name to find
789
+ * @returns The type alias declaration node or null
790
+ * @private
791
+ */
792
+ findTypeAliasDeclaration(typeName) {
793
+ for (const sourceFile of this.program.getSourceFiles()) {
794
+ if (sourceFile.isDeclarationFile)
795
+ continue;
796
+ const findTypeAlias = (node) => {
797
+ if (ts.isTypeAliasDeclaration(node) && node.name.text === typeName) {
798
+ return node;
799
+ }
800
+ return ts.forEachChild(node, findTypeAlias) || null;
801
+ };
802
+ const result = findTypeAlias(sourceFile);
803
+ if (result)
804
+ return result;
805
+ }
806
+ return null;
807
+ }
808
+ /**
809
+ * Creates a schema from a type alias declaration, substituting type parameters.
810
+ *
811
+ * @param typeAlias - The type alias declaration
812
+ * @param typeArgNames - The concrete type arguments
813
+ * @returns OpenAPI schema for the type alias
814
+ * @private
815
+ */
816
+ createSchemaFromTypeAlias(typeAlias, typeArgNames) {
817
+ const typeNode = typeAlias.type;
818
+ if (ts.isTypeLiteralNode(typeNode)) {
819
+ const schema = {
820
+ type: 'object',
821
+ properties: {},
822
+ required: [],
823
+ };
824
+ for (const member of typeNode.members) {
825
+ if (ts.isPropertySignature(member) &&
826
+ member.name &&
827
+ ts.isIdentifier(member.name)) {
828
+ const propertyName = member.name.text;
829
+ const isOptional = !!member.questionToken;
830
+ if (member.type) {
831
+ const propertyType = this.resolveTypeParameterInTypeAlias(member.type, typeAlias.typeParameters, typeArgNames);
832
+ const { type, format, nestedSchema } = this.mapTypeToSchema(propertyType);
833
+ if (nestedSchema) {
834
+ schema.properties[propertyName] = nestedSchema;
835
+ }
836
+ else {
837
+ schema.properties[propertyName] = { type };
838
+ if (format)
839
+ schema.properties[propertyName].format = format;
840
+ }
841
+ if (!isOptional) {
842
+ schema.required.push(propertyName);
843
+ }
844
+ }
845
+ }
846
+ }
847
+ return schema;
848
+ }
849
+ return null;
850
+ }
851
+ /**
852
+ * Resolves type parameters in a type alias to concrete types.
853
+ *
854
+ * @param typeNode - The type node to resolve
855
+ * @param typeParameters - The type parameters of the type alias
856
+ * @param typeArgNames - The concrete type arguments
857
+ * @returns The resolved type string
858
+ * @private
859
+ */
860
+ resolveTypeParameterInTypeAlias(typeNode, typeParameters, typeArgNames) {
861
+ if (ts.isTypeReferenceNode(typeNode) &&
862
+ ts.isIdentifier(typeNode.typeName)) {
863
+ const typeName = typeNode.typeName.text;
864
+ // Check if this is a type parameter
865
+ if (typeParameters) {
866
+ const paramIndex = typeParameters.findIndex(param => param.name.text === typeName);
867
+ if (paramIndex !== -1 && paramIndex < typeArgNames.length) {
868
+ const resolvedType = typeArgNames[paramIndex];
869
+ return resolvedType || typeName;
870
+ }
871
+ }
872
+ return typeName;
873
+ }
874
+ return this.getTypeNodeToString(typeNode);
875
+ }
372
876
  /**
373
877
  * Converts a TypeScript type node to its string representation.
374
878
  *
@@ -394,6 +898,7 @@ class SchemaTransformer {
394
898
  return firstTypeArg.typeName.text;
395
899
  }
396
900
  }
901
+ return this.resolveGenericType(typeNode);
397
902
  }
398
903
  return typeNode.typeName.text;
399
904
  }
@@ -492,19 +997,24 @@ class SchemaTransformer {
492
997
  * Generates an OpenAPI schema from extracted property information.
493
998
  *
494
999
  * @param properties - Array of property information to process
1000
+ * @param contextFilePath - Optional context file path for resolving class references
495
1001
  * @returns Complete OpenAPI schema object with properties and validation rules
496
1002
  * @private
497
1003
  */
498
- generateSchema(properties) {
1004
+ generateSchema(properties, contextFilePath) {
499
1005
  const schema = {
500
1006
  type: 'object',
501
1007
  properties: {},
502
1008
  required: [],
503
1009
  };
504
1010
  for (const property of properties) {
505
- const { type, format, nestedSchema } = this.mapTypeToSchema(property.type);
1011
+ const { type, format, nestedSchema } = this.mapTypeToSchema(property.type, contextFilePath);
506
1012
  if (nestedSchema) {
507
1013
  schema.properties[property.name] = nestedSchema;
1014
+ // Skip decorator application for $ref schemas
1015
+ if (this.isRefSchema(nestedSchema)) {
1016
+ continue;
1017
+ }
508
1018
  }
509
1019
  else {
510
1020
  schema.properties[property.name] = { type };
@@ -527,14 +1037,15 @@ class SchemaTransformer {
527
1037
  * Handles primitive types, arrays, and nested objects recursively.
528
1038
  *
529
1039
  * @param type - The TypeScript type string to map
1040
+ * @param contextFilePath - Optional context file path for resolving class references
530
1041
  * @returns Object containing OpenAPI type, optional format, and nested schema
531
1042
  * @private
532
1043
  */
533
- mapTypeToSchema(type) {
1044
+ mapTypeToSchema(type, contextFilePath) {
534
1045
  // Handle arrays
535
1046
  if (type.endsWith('[]')) {
536
1047
  const elementType = type.slice(0, -2);
537
- const elementSchema = this.mapTypeToSchema(elementType);
1048
+ const elementSchema = this.mapTypeToSchema(elementType, contextFilePath);
538
1049
  const items = elementSchema.nestedSchema || {
539
1050
  type: elementSchema.type,
540
1051
  };
@@ -578,9 +1089,31 @@ class SchemaTransformer {
578
1089
  format: constants.jsPrimitives.UploadFile.format,
579
1090
  };
580
1091
  default:
1092
+ // Check if it's a resolved generic type (e.g., User_Role)
1093
+ if (type.includes('_') && this.isResolvedGenericType(type)) {
1094
+ try {
1095
+ const genericSchema = this.resolveGenericTypeSchema(type);
1096
+ if (genericSchema) {
1097
+ return {
1098
+ type: constants.jsPrimitives.Object.value,
1099
+ nestedSchema: genericSchema,
1100
+ };
1101
+ }
1102
+ }
1103
+ catch (error) {
1104
+ console.warn(`Failed to resolve generic type ${type}:`, error);
1105
+ }
1106
+ }
581
1107
  // Handle nested objects
582
1108
  try {
583
- const nestedResult = this.transformByName(type);
1109
+ const nestedResult = this.transformByName(type, contextFilePath);
1110
+ // Check if it's a $ref schema (circular reference)
1111
+ if (nestedResult.schema.$ref) {
1112
+ return {
1113
+ type: constants.jsPrimitives.Object.value,
1114
+ nestedSchema: nestedResult.schema,
1115
+ };
1116
+ }
584
1117
  return {
585
1118
  type: constants.jsPrimitives.Object.value,
586
1119
  nestedSchema: nestedResult.schema,
@@ -591,6 +1124,16 @@ class SchemaTransformer {
591
1124
  }
592
1125
  }
593
1126
  }
1127
+ /**
1128
+ * Checks if a schema is a $ref schema (circular reference).
1129
+ *
1130
+ * @param schema - The schema to check
1131
+ * @returns True if it's a $ref schema
1132
+ * @private
1133
+ */
1134
+ isRefSchema(schema) {
1135
+ return '$ref' in schema;
1136
+ }
594
1137
  /**
595
1138
  * Applies class-validator decorators to schema properties.
596
1139
  * Maps validation decorators to their corresponding OpenAPI schema constraints.
@@ -601,6 +1144,10 @@ class SchemaTransformer {
601
1144
  * @private
602
1145
  */
603
1146
  applyDecorators(decorators, schema, propertyName) {
1147
+ // Skip applying decorators to $ref schemas
1148
+ if (this.isRefSchema(schema)) {
1149
+ return;
1150
+ }
604
1151
  const isArrayType = schema.properties[propertyName].type ===
605
1152
  constants.jsPrimitives.Array.value;
606
1153
  for (const decorator of decorators) {
@@ -716,8 +1263,193 @@ class SchemaTransformer {
716
1263
  case constants.validatorDecorators.ArrayMaxSize.name:
717
1264
  schema.properties[propertyName].maxItems = decorator.arguments[0];
718
1265
  break;
1266
+ case constants.validatorDecorators.IsEnum.name:
1267
+ this.applyEnumDecorator(decorator, schema, propertyName, isArrayType);
1268
+ break;
1269
+ }
1270
+ }
1271
+ }
1272
+ /**
1273
+ * Applies the @IsEnum decorator to a property, handling both primitive values and object enums.
1274
+ * Supports arrays of enum values as well.
1275
+ *
1276
+ * @param decorator - The IsEnum decorator information
1277
+ * @param schema - The schema object to modify
1278
+ * @param propertyName - The name of the property
1279
+ * @param isArrayType - Whether the property is an array type
1280
+ * @private
1281
+ */
1282
+ applyEnumDecorator(decorator, schema, propertyName, isArrayType) {
1283
+ if (!decorator.arguments || decorator.arguments.length === 0) {
1284
+ return;
1285
+ }
1286
+ const enumArg = decorator.arguments[0];
1287
+ let enumValues = [];
1288
+ // Handle different enum argument types
1289
+ if (typeof enumArg === 'string') {
1290
+ // This is likely a reference to an enum type name
1291
+ // We need to try to resolve this to actual enum values
1292
+ enumValues = this.resolveEnumValues(enumArg);
1293
+ }
1294
+ else if (typeof enumArg === 'object' && enumArg !== null) {
1295
+ // Object enum - extract values
1296
+ if (Array.isArray(enumArg)) {
1297
+ // Already an array of values
1298
+ enumValues = enumArg;
1299
+ }
1300
+ else {
1301
+ // Enum object - get all values
1302
+ enumValues = Object.values(enumArg);
1303
+ }
1304
+ }
1305
+ // If we couldn't resolve enum values, fall back to string type without enum constraint
1306
+ if (enumValues.length === 0) {
1307
+ if (!isArrayType) {
1308
+ schema.properties[propertyName].type = 'string';
1309
+ }
1310
+ else if (schema.properties[propertyName].items) {
1311
+ schema.properties[propertyName].items.type = 'string';
1312
+ }
1313
+ return;
1314
+ }
1315
+ // Determine the type based on enum values
1316
+ let enumType = 'string';
1317
+ if (enumValues.length > 0) {
1318
+ const firstValue = enumValues[0];
1319
+ if (typeof firstValue === 'number') {
1320
+ enumType = 'number';
1321
+ }
1322
+ else if (typeof firstValue === 'boolean') {
1323
+ enumType = 'boolean';
1324
+ }
1325
+ }
1326
+ // Apply enum to schema
1327
+ if (!isArrayType) {
1328
+ schema.properties[propertyName].type = enumType;
1329
+ schema.properties[propertyName].enum = enumValues;
1330
+ }
1331
+ else if (schema.properties[propertyName].items) {
1332
+ schema.properties[propertyName].items.type = enumType;
1333
+ schema.properties[propertyName].items.enum = enumValues;
1334
+ }
1335
+ }
1336
+ /**
1337
+ * Attempts to resolve enum values from an enum type name.
1338
+ * This searches through the TypeScript AST to find the enum declaration
1339
+ * and extract its values.
1340
+ *
1341
+ * @param enumTypeName - The name of the enum type
1342
+ * @returns Array of enum values if found, empty array otherwise
1343
+ * @private
1344
+ */
1345
+ resolveEnumValues(enumTypeName) {
1346
+ // Search for enum declarations in source files
1347
+ for (const sourceFile of this.program.getSourceFiles()) {
1348
+ if (sourceFile.isDeclarationFile)
1349
+ continue;
1350
+ if (sourceFile.fileName.includes('node_modules'))
1351
+ continue;
1352
+ const enumValues = this.findEnumValues(sourceFile, enumTypeName);
1353
+ if (enumValues.length > 0) {
1354
+ return enumValues;
1355
+ }
1356
+ }
1357
+ return [];
1358
+ }
1359
+ /**
1360
+ * Finds enum values in a specific source file.
1361
+ *
1362
+ * @param sourceFile - The source file to search
1363
+ * @param enumTypeName - The name of the enum to find
1364
+ * @returns Array of enum values if found, empty array otherwise
1365
+ * @private
1366
+ */
1367
+ findEnumValues(sourceFile, enumTypeName) {
1368
+ let enumValues = [];
1369
+ const visit = (node) => {
1370
+ // Handle TypeScript enum declarations
1371
+ if (ts.isEnumDeclaration(node) && node.name?.text === enumTypeName) {
1372
+ enumValues = this.extractEnumValues(node);
1373
+ return;
1374
+ }
1375
+ // Handle const object declarations (like const Status = { ... } as const)
1376
+ if (ts.isVariableStatement(node)) {
1377
+ for (const declaration of node.declarationList.declarations) {
1378
+ if (ts.isVariableDeclaration(declaration) &&
1379
+ ts.isIdentifier(declaration.name) &&
1380
+ declaration.name.text === enumTypeName &&
1381
+ declaration.initializer) {
1382
+ let initializer = declaration.initializer;
1383
+ // Handle "as const" assertions
1384
+ if (ts.isAsExpression(initializer) && initializer.expression) {
1385
+ initializer = initializer.expression;
1386
+ }
1387
+ enumValues = this.extractObjectEnumValues(initializer);
1388
+ return;
1389
+ }
1390
+ }
1391
+ }
1392
+ ts.forEachChild(node, visit);
1393
+ };
1394
+ visit(sourceFile);
1395
+ return enumValues;
1396
+ }
1397
+ /**
1398
+ * Extracts values from a TypeScript enum declaration.
1399
+ *
1400
+ * @param enumNode - The enum declaration node
1401
+ * @returns Array of enum values
1402
+ * @private
1403
+ */
1404
+ extractEnumValues(enumNode) {
1405
+ const values = [];
1406
+ for (const member of enumNode.members) {
1407
+ if (member.initializer) {
1408
+ // Handle initialized enum members
1409
+ if (ts.isStringLiteral(member.initializer)) {
1410
+ values.push(member.initializer.text);
1411
+ }
1412
+ else if (ts.isNumericLiteral(member.initializer)) {
1413
+ values.push(Number(member.initializer.text));
1414
+ }
1415
+ }
1416
+ else {
1417
+ // Handle auto-incremented numeric enums
1418
+ if (values.length === 0) {
1419
+ values.push(0);
1420
+ }
1421
+ else {
1422
+ const lastValue = values[values.length - 1];
1423
+ if (typeof lastValue === 'number') {
1424
+ values.push(lastValue + 1);
1425
+ }
1426
+ }
1427
+ }
1428
+ }
1429
+ return values;
1430
+ }
1431
+ /**
1432
+ * Extracts values from object literal enum (const object as const).
1433
+ *
1434
+ * @param initializer - The object literal initializer
1435
+ * @returns Array of enum values
1436
+ * @private
1437
+ */
1438
+ extractObjectEnumValues(initializer) {
1439
+ const values = [];
1440
+ if (ts.isObjectLiteralExpression(initializer)) {
1441
+ for (const property of initializer.properties) {
1442
+ if (ts.isPropertyAssignment(property) && property.initializer) {
1443
+ if (ts.isStringLiteral(property.initializer)) {
1444
+ values.push(property.initializer.text);
1445
+ }
1446
+ else if (ts.isNumericLiteral(property.initializer)) {
1447
+ values.push(Number(property.initializer.text));
1448
+ }
1449
+ }
719
1450
  }
720
1451
  }
1452
+ return values;
721
1453
  }
722
1454
  /**
723
1455
  * Applies sensible default behaviors for properties without class-validator decorators.
@@ -737,6 +1469,10 @@ class SchemaTransformer {
737
1469
  * @private
738
1470
  */
739
1471
  applyTypeBasedFormats(property, schema) {
1472
+ // Skip applying type-based formats to $ref schemas
1473
+ if (this.isRefSchema(schema)) {
1474
+ return;
1475
+ }
740
1476
  const propertyName = property.name;
741
1477
  const propertyType = property.type.toLowerCase();
742
1478
  const propertySchema = schema.properties[propertyName];
@@ -771,6 +1507,10 @@ class SchemaTransformer {
771
1507
  * @private
772
1508
  */
773
1509
  determineRequiredStatus(property, schema) {
1510
+ // Skip determining required status for $ref schemas
1511
+ if (this.isRefSchema(schema)) {
1512
+ return;
1513
+ }
774
1514
  const propertyName = property.name;
775
1515
  // Check if already marked as required by IsNotEmpty or ArrayNotEmpty decorator
776
1516
  const isAlreadyRequired = schema.required.includes(propertyName);
@@ -811,7 +1551,7 @@ class SchemaTransformer {
811
1551
  * @public
812
1552
  */
813
1553
  function transform(cls, options) {
814
- return SchemaTransformer.getInstance(undefined, options).transform(cls);
1554
+ return SchemaTransformer.transformClass(cls, options);
815
1555
  }
816
1556
 
817
- export { transform };
1557
+ export { SchemaTransformer, transform };