ts-class-to-openapi 1.0.4 → 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.js CHANGED
@@ -97,6 +97,12 @@ class SchemaTransformer {
97
97
  * @private
98
98
  */
99
99
  loadedFiles = new Set();
100
+ /**
101
+ * Set of class names currently being processed to prevent circular references
102
+ * Key format: "fileName:className" for uniqueness across different files
103
+ * @private
104
+ */
105
+ processingClasses = new Set();
100
106
  /**
101
107
  * Private constructor for singleton pattern.
102
108
  *
@@ -149,20 +155,19 @@ class SchemaTransformer {
149
155
  }
150
156
  }
151
157
  /**
152
- * Gets relevant source files for a class, filtering out unnecessary files to save memory.
158
+ * Transforms a class by its name into an OpenAPI schema object.
159
+ * Considers the context of the calling file to resolve ambiguous class names.
160
+ * Includes circular reference detection to prevent infinite recursion.
153
161
  *
154
- * @param className - The name of the class to find files for
155
- * @param filePath - Optional specific file path
156
- * @returns Array of relevant source files
162
+ * @param className - The name of the class to transform
163
+ * @param contextFilePath - Optional path to context file for resolving class ambiguity
164
+ * @returns Object containing the class name and its corresponding JSON schema
165
+ * @throws {Error} When the specified class cannot be found
157
166
  * @private
158
167
  */
159
- getRelevantSourceFiles(className, filePath) {
160
- if (filePath) {
161
- const sourceFile = this.program.getSourceFile(filePath);
162
- return sourceFile ? [sourceFile] : [];
163
- }
164
- // Only get source files that are not declaration files and not in node_modules
165
- return this.program.getSourceFiles().filter(sf => {
168
+ transformByName(className, contextFilePath) {
169
+ // Get all relevant source files (not declaration files and not in node_modules)
170
+ const sourceFiles = this.program.getSourceFiles().filter(sf => {
166
171
  if (sf.isDeclarationFile)
167
172
  return false;
168
173
  if (sf.fileName.includes('.d.ts'))
@@ -173,23 +178,9 @@ class SchemaTransformer {
173
178
  this.loadedFiles.add(sf.fileName);
174
179
  return true;
175
180
  });
176
- }
177
- /**
178
- * Transforms a class by its name into an OpenAPI schema object.
179
- * Now considers the context of the calling file to resolve ambiguous class names.
180
- *
181
- * @param className - The name of the class to transform
182
- * @param filePath - Optional path to the file containing the class
183
- * @param contextFile - Optional context file for resolving class ambiguity
184
- * @returns Object containing the class name and its corresponding JSON schema
185
- * @throws {Error} When the specified class cannot be found
186
- * @private
187
- */
188
- transformByName(className, filePath, contextFile) {
189
- const sourceFiles = this.getRelevantSourceFiles(className, filePath);
190
181
  // If we have a context file, try to find the class in that file first
191
- if (contextFile) {
192
- const contextSourceFile = this.program.getSourceFile(contextFile);
182
+ if (contextFilePath) {
183
+ const contextSourceFile = this.program.getSourceFile(contextFilePath);
193
184
  if (contextSourceFile) {
194
185
  const classNode = this.findClassByName(contextSourceFile, className);
195
186
  if (classNode) {
@@ -198,15 +189,34 @@ class SchemaTransformer {
198
189
  if (this.classCache.has(cacheKey)) {
199
190
  return this.classCache.get(cacheKey);
200
191
  }
201
- const result = this.transformClass(classNode, contextSourceFile);
202
- this.classCache.set(cacheKey, result);
203
- this.cleanupCache();
204
- return result;
192
+ // Check for circular reference before processing
193
+ if (this.processingClasses.has(cacheKey)) {
194
+ // Return a $ref reference to break circular dependency (OpenAPI 3.1 style)
195
+ return {
196
+ name: className,
197
+ schema: {
198
+ $ref: `#/components/schemas/${className}`,
199
+ description: `Reference to ${className} (circular reference detected)`,
200
+ },
201
+ };
202
+ }
203
+ // Mark this class as being processed
204
+ this.processingClasses.add(cacheKey);
205
+ try {
206
+ const result = this.transformClass(classNode, contextSourceFile);
207
+ this.classCache.set(cacheKey, result);
208
+ this.cleanupCache();
209
+ return result;
210
+ }
211
+ finally {
212
+ // Always remove from processing set when done
213
+ this.processingClasses.delete(cacheKey);
214
+ }
205
215
  }
206
216
  }
207
217
  }
208
218
  // Fallback to searching all files, but prioritize files that are more likely to be relevant
209
- const prioritizedFiles = this.prioritizeSourceFiles(sourceFiles, contextFile);
219
+ const prioritizedFiles = this.prioritizeSourceFiles(sourceFiles, contextFilePath);
210
220
  for (const sourceFile of prioritizedFiles) {
211
221
  const classNode = this.findClassByName(sourceFile, className);
212
222
  if (classNode && sourceFile?.fileName) {
@@ -215,12 +225,31 @@ class SchemaTransformer {
215
225
  if (this.classCache.has(cacheKey)) {
216
226
  return this.classCache.get(cacheKey);
217
227
  }
218
- const result = this.transformClass(classNode, sourceFile);
219
- // Cache using fileName:className as key for uniqueness
220
- this.classCache.set(cacheKey, result);
221
- // Clean up cache if it gets too large
222
- this.cleanupCache();
223
- return result;
228
+ // Check for circular reference before processing
229
+ if (this.processingClasses.has(cacheKey)) {
230
+ // Return a $ref reference to break circular dependency (OpenAPI 3.1 style)
231
+ return {
232
+ name: className,
233
+ schema: {
234
+ $ref: `#/components/schemas/${className}`,
235
+ description: `Reference to ${className} (circular reference detected)`,
236
+ },
237
+ };
238
+ }
239
+ // Mark this class as being processed
240
+ this.processingClasses.add(cacheKey);
241
+ try {
242
+ const result = this.transformClass(classNode, sourceFile);
243
+ // Cache using fileName:className as key for uniqueness
244
+ this.classCache.set(cacheKey, result);
245
+ // Clean up cache if it gets too large
246
+ this.cleanupCache();
247
+ return result;
248
+ }
249
+ finally {
250
+ // Always remove from processing set when done
251
+ this.processingClasses.delete(cacheKey);
252
+ }
224
253
  }
225
254
  }
226
255
  throw new Error(`Class ${className} not found`);
@@ -230,15 +259,15 @@ class SchemaTransformer {
230
259
  * Gives priority to files in the same directory or with similar names.
231
260
  *
232
261
  * @param sourceFiles - Array of source files to prioritize
233
- * @param contextFile - Optional context file for prioritization
262
+ * @param contextFilePath - Optional path to context file for prioritization
234
263
  * @returns Prioritized array of source files
235
264
  * @private
236
265
  */
237
- prioritizeSourceFiles(sourceFiles, contextFile) {
238
- if (!contextFile) {
266
+ prioritizeSourceFiles(sourceFiles, contextFilePath) {
267
+ if (!contextFilePath) {
239
268
  return sourceFiles;
240
269
  }
241
- const contextDir = contextFile.substring(0, contextFile.lastIndexOf('/'));
270
+ const contextDir = contextFilePath.substring(0, contextFilePath.lastIndexOf('/'));
242
271
  return sourceFiles.sort((a, b) => {
243
272
  const aDir = a.fileName.substring(0, a.fileName.lastIndexOf('/'));
244
273
  const bDir = b.fileName.substring(0, b.fileName.lastIndexOf('/'));
@@ -283,16 +312,88 @@ class SchemaTransformer {
283
312
  /**
284
313
  * Clears the current singleton instance. Useful for testing or when you need
285
314
  * to create a new instance with different configuration.
315
+ * @private
286
316
  */
287
317
  static clearInstance() {
288
- SchemaTransformer.instance = null;
318
+ SchemaTransformer.instance = undefined;
319
+ }
320
+ /**
321
+ * Flag to prevent recursive disposal calls
322
+ * @private
323
+ */
324
+ static disposingInProgress = false;
325
+ /**
326
+ * Completely disposes of the current singleton instance and releases all resources.
327
+ * This is a static method that can be called without having an instance reference.
328
+ * Ensures complete memory cleanup regardless of the current state.
329
+ *
330
+ * @example
331
+ * ```typescript
332
+ * SchemaTransformer.disposeInstance();
333
+ * // All resources released, next getInstance() will create fresh instance
334
+ * ```
335
+ *
336
+ * @public
337
+ */
338
+ static disposeInstance() {
339
+ // Prevent recursive disposal calls
340
+ if (SchemaTransformer.disposingInProgress) {
341
+ return;
342
+ }
343
+ SchemaTransformer.disposingInProgress = true;
344
+ try {
345
+ if (SchemaTransformer.instance) {
346
+ SchemaTransformer.instance.dispose();
347
+ }
348
+ }
349
+ catch (error) {
350
+ // Log any disposal errors but continue with cleanup
351
+ console.warn('Warning during static disposal:', error);
352
+ }
353
+ finally {
354
+ // Always ensure the static instance is cleared
355
+ SchemaTransformer.instance = undefined;
356
+ SchemaTransformer.disposingInProgress = false;
357
+ // Force garbage collection for cleanup
358
+ if (global.gc) {
359
+ global.gc();
360
+ }
361
+ }
362
+ }
363
+ /**
364
+ * @deprecated Use disposeInstance() instead for better clarity
365
+ * @private
366
+ */
367
+ static dispose() {
368
+ SchemaTransformer.disposeInstance();
289
369
  }
290
370
  static getInstance(tsConfigPath, options) {
291
- if (!SchemaTransformer.instance) {
371
+ if (!SchemaTransformer.instance || SchemaTransformer.isInstanceDisposed()) {
292
372
  SchemaTransformer.instance = new SchemaTransformer(tsConfigPath, options);
293
373
  }
294
374
  return SchemaTransformer.instance;
295
375
  }
376
+ /**
377
+ * Internal method to check if current instance is disposed
378
+ * @private
379
+ */
380
+ static isInstanceDisposed() {
381
+ return SchemaTransformer.instance
382
+ ? SchemaTransformer.instance.isDisposed()
383
+ : true;
384
+ }
385
+ /**
386
+ * Transforms a class using the singleton instance
387
+ * @param cls - The class constructor function to transform
388
+ * @param options - Optional configuration for memory management (only used if no instance exists)
389
+ * @returns Object containing the class name and its corresponding JSON schema
390
+ * @public
391
+ */
392
+ static transformClass(cls, options) {
393
+ // Use the singleton instance instead of creating a temporary one
394
+ const transformer = SchemaTransformer.getInstance(undefined, options);
395
+ return transformer.transform(cls);
396
+ }
296
397
  /**
297
398
  * Transforms a class constructor function into an OpenAPI schema object.
298
399
  *
@@ -327,31 +428,147 @@ class SchemaTransformer {
327
428
  clearCache() {
328
429
  this.classCache.clear();
329
430
  this.loadedFiles.clear();
431
+ this.processingClasses.clear();
330
432
  // Force garbage collection hint if available
331
433
  if (global.gc) {
332
434
  global.gc();
333
435
  }
334
436
  }
437
+ /**
438
+ * Completely disposes of the transformer instance and releases all resources.
439
+ * This includes clearing all caches, releasing TypeScript program resources,
440
+ * and resetting the singleton instance.
441
+ *
442
+ * After calling this method, you need to call getInstance() again to get a new instance.
443
+ *
444
+ * @example
445
+ * ```typescript
446
+ * const transformer = SchemaTransformer.getInstance();
447
+ * // ... use transformer
448
+ * transformer.dispose();
449
+ * // transformer is now unusable, need to get new instance
450
+ * const newTransformer = SchemaTransformer.getInstance();
451
+ * ```
452
+ *
453
+ * @private
454
+ */
455
+ dispose() {
456
+ try {
457
+ // Clear all caches and sets completely
458
+ this.classCache.clear();
459
+ this.loadedFiles.clear();
460
+ this.processingClasses.clear();
461
+ // Release TypeScript program resources
462
+ // While TypeScript doesn't provide explicit disposal methods,
463
+ // we can help garbage collection by clearing all references
464
+ // Clear all references to TypeScript objects
465
+ // @ts-ignore - We're intentionally setting these to null for cleanup
466
+ this.program = null;
467
+ // @ts-ignore - We're intentionally setting these to null for cleanup
468
+ this.checker = null;
469
+ }
470
+ catch (error) {
471
+ // If there's any error during disposal, log it but continue
472
+ console.warn('Warning during transformer disposal:', error);
473
+ }
474
+ finally {
475
+ // Force garbage collection for cleanup
476
+ if (global.gc) {
477
+ global.gc();
478
+ }
479
+ }
480
+ }
481
+ /**
482
+ * Completely resets the transformer by disposing current instance and creating a new one.
483
+ * This is useful when you need a fresh start with different TypeScript configuration
484
+ * or want to ensure all resources are properly released and recreated.
485
+ *
486
+ * @param tsConfigPath - Optional path to a specific TypeScript config file for the new instance
487
+ * @param options - Configuration options for memory management for the new instance
488
+ * @returns A fresh SchemaTransformer instance
489
+ *
490
+ * @example
491
+ * ```typescript
492
+ * const transformer = SchemaTransformer.getInstance();
493
+ * // ... use transformer
494
+ * const freshTransformer = transformer.reset('./new-tsconfig.json');
495
+ * ```
496
+ *
497
+ * @private
498
+ */
499
+ reset(tsConfigPath, options) {
500
+ // Dispose current instance using static method to properly clear instance reference
501
+ SchemaTransformer.disposeInstance();
502
+ // Create and return new instance
503
+ return SchemaTransformer.getInstance(tsConfigPath, options);
504
+ }
335
505
  /**
336
506
  * Gets memory usage statistics for monitoring and debugging.
337
507
  *
338
- * @returns Object containing cache size and loaded files count
508
+ * @returns Object containing cache size, loaded files count, and processing status
339
509
  *
340
510
  * @example
341
511
  * ```typescript
342
512
  * const transformer = SchemaTransformer.getInstance();
343
513
  * const stats = transformer.getMemoryStats();
344
514
  * console.log(`Cache entries: ${stats.cacheSize}, Files loaded: ${stats.loadedFiles}`);
515
+ * console.log(`Currently processing: ${stats.currentlyProcessing} classes`);
345
516
  * ```
346
517
  *
347
- * @public
518
+ * @private
348
519
  */
349
520
  getMemoryStats() {
350
521
  return {
351
- cacheSize: this.classCache.size,
352
- loadedFiles: this.loadedFiles.size,
522
+ cacheSize: this.classCache?.size || 0,
523
+ loadedFiles: this.loadedFiles?.size || 0,
524
+ currentlyProcessing: this.processingClasses?.size || 0,
525
+ maxCacheSize: this.maxCacheSize || 0,
526
+ autoCleanup: this.autoCleanup || false,
527
+ isDisposed: !this.program || !this.checker,
353
528
  };
354
529
  }
530
+ /**
531
+ * Checks if the transformer instance has been disposed and is no longer usable.
532
+ *
533
+ * @returns True if the instance has been disposed
534
+ *
535
+ * @example
536
+ * ```typescript
537
+ * const transformer = SchemaTransformer.getInstance();
538
+ * transformer.dispose();
539
+ * console.log(transformer.isDisposed()); // true
540
+ * ```
541
+ *
542
+ * @private
543
+ */
544
+ isDisposed() {
545
+ return (!this.program ||
546
+ !this.checker ||
547
+ !this.classCache ||
548
+ !this.loadedFiles ||
549
+ !this.processingClasses);
550
+ }
551
+ /**
552
+ * Static method to check if there's an active singleton instance.
553
+ *
554
+ * @returns True if there's an active instance, false if disposed or never created
555
+ *
556
+ * @example
557
+ * ```typescript
558
+ * console.log(SchemaTransformer.hasActiveInstance()); // false
559
+ * const transformer = SchemaTransformer.getInstance();
560
+ * console.log(SchemaTransformer.hasActiveInstance()); // true
561
+ * SchemaTransformer.dispose();
562
+ * console.log(SchemaTransformer.hasActiveInstance()); // false
563
+ * ```
564
+ *
565
+ * @private
566
+ */
567
+ static hasActiveInstance() {
568
+ return (SchemaTransformer.instance !== null &&
569
+ SchemaTransformer.instance !== undefined &&
570
+ !SchemaTransformer.isInstanceDisposed());
571
+ }
355
572
  /**
356
573
  * Finds a class declaration by name within a source file.
357
574
  *
@@ -782,20 +999,24 @@ class SchemaTransformer {
782
999
  * Generates an OpenAPI schema from extracted property information.
783
1000
  *
784
1001
  * @param properties - Array of property information to process
785
- * @param contextFile - Optional context file path for resolving class references
1002
+ * @param contextFilePath - Optional context file path for resolving class references
786
1003
  * @returns Complete OpenAPI schema object with properties and validation rules
787
1004
  * @private
788
1005
  */
789
- generateSchema(properties, contextFile) {
1006
+ generateSchema(properties, contextFilePath) {
790
1007
  const schema = {
791
1008
  type: 'object',
792
1009
  properties: {},
793
1010
  required: [],
794
1011
  };
795
1012
  for (const property of properties) {
796
- const { type, format, nestedSchema } = this.mapTypeToSchema(property.type, contextFile);
1013
+ const { type, format, nestedSchema } = this.mapTypeToSchema(property.type, contextFilePath);
797
1014
  if (nestedSchema) {
798
1015
  schema.properties[property.name] = nestedSchema;
1016
+ // Skip decorator application for $ref schemas
1017
+ if (this.isRefSchema(nestedSchema)) {
1018
+ continue;
1019
+ }
799
1020
  }
800
1021
  else {
801
1022
  schema.properties[property.name] = { type };
@@ -818,15 +1039,15 @@ class SchemaTransformer {
818
1039
  * Handles primitive types, arrays, and nested objects recursively.
819
1040
  *
820
1041
  * @param type - The TypeScript type string to map
821
- * @param contextFile - Optional context file path for resolving class references
1042
+ * @param contextFilePath - Optional context file path for resolving class references
822
1043
  * @returns Object containing OpenAPI type, optional format, and nested schema
823
1044
  * @private
824
1045
  */
825
- mapTypeToSchema(type, contextFile) {
1046
+ mapTypeToSchema(type, contextFilePath) {
826
1047
  // Handle arrays
827
1048
  if (type.endsWith('[]')) {
828
1049
  const elementType = type.slice(0, -2);
829
- const elementSchema = this.mapTypeToSchema(elementType, contextFile);
1050
+ const elementSchema = this.mapTypeToSchema(elementType, contextFilePath);
830
1051
  const items = elementSchema.nestedSchema || {
831
1052
  type: elementSchema.type,
832
1053
  };
@@ -887,7 +1108,14 @@ class SchemaTransformer {
887
1108
  }
888
1109
  // Handle nested objects
889
1110
  try {
890
- const nestedResult = this.transformByName(type, undefined, contextFile);
1111
+ const nestedResult = this.transformByName(type, contextFilePath);
1112
+ // Check if it's a $ref schema (circular reference)
1113
+ if (nestedResult.schema.$ref) {
1114
+ return {
1115
+ type: constants.jsPrimitives.Object.value,
1116
+ nestedSchema: nestedResult.schema,
1117
+ };
1118
+ }
891
1119
  return {
892
1120
  type: constants.jsPrimitives.Object.value,
893
1121
  nestedSchema: nestedResult.schema,
@@ -898,6 +1126,16 @@ class SchemaTransformer {
898
1126
  }
899
1127
  }
900
1128
  }
1129
+ /**
1130
+ * Checks if a schema is a $ref schema (circular reference).
1131
+ *
1132
+ * @param schema - The schema to check
1133
+ * @returns True if it's a $ref schema
1134
+ * @private
1135
+ */
1136
+ isRefSchema(schema) {
1137
+ return '$ref' in schema;
1138
+ }
901
1139
  /**
902
1140
  * Applies class-validator decorators to schema properties.
903
1141
  * Maps validation decorators to their corresponding OpenAPI schema constraints.
@@ -908,6 +1146,10 @@ class SchemaTransformer {
908
1146
  * @private
909
1147
  */
910
1148
  applyDecorators(decorators, schema, propertyName) {
1149
+ // Skip applying decorators to $ref schemas
1150
+ if (this.isRefSchema(schema)) {
1151
+ return;
1152
+ }
911
1153
  const isArrayType = schema.properties[propertyName].type ===
912
1154
  constants.jsPrimitives.Array.value;
913
1155
  for (const decorator of decorators) {
@@ -1229,6 +1471,10 @@ class SchemaTransformer {
1229
1471
  * @private
1230
1472
  */
1231
1473
  applyTypeBasedFormats(property, schema) {
1474
+ // Skip applying type-based formats to $ref schemas
1475
+ if (this.isRefSchema(schema)) {
1476
+ return;
1477
+ }
1232
1478
  const propertyName = property.name;
1233
1479
  const propertyType = property.type.toLowerCase();
1234
1480
  const propertySchema = schema.properties[propertyName];
@@ -1263,6 +1509,10 @@ class SchemaTransformer {
1263
1509
  * @private
1264
1510
  */
1265
1511
  determineRequiredStatus(property, schema) {
1512
+ // Skip determining required status for $ref schemas
1513
+ if (this.isRefSchema(schema)) {
1514
+ return;
1515
+ }
1266
1516
  const propertyName = property.name;
1267
1517
  // Check if already marked as required by IsNotEmpty or ArrayNotEmpty decorator
1268
1518
  const isAlreadyRequired = schema.required.includes(propertyName);
@@ -1303,7 +1553,8 @@ class SchemaTransformer {
1303
1553
  * @public
1304
1554
  */
1305
1555
  function transform(cls, options) {
1306
- return SchemaTransformer.getInstance(undefined, options).transform(cls);
1556
+ return SchemaTransformer.transformClass(cls, options);
1307
1557
  }
1308
1558
 
1559
+ exports.SchemaTransformer = SchemaTransformer;
1309
1560
  exports.transform = transform;