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.esm.js CHANGED
@@ -95,6 +95,12 @@ class SchemaTransformer {
95
95
  * @private
96
96
  */
97
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();
98
104
  /**
99
105
  * Private constructor for singleton pattern.
100
106
  *
@@ -147,20 +153,19 @@ class SchemaTransformer {
147
153
  }
148
154
  }
149
155
  /**
150
- * 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.
151
159
  *
152
- * @param className - The name of the class to find files for
153
- * @param filePath - Optional specific file path
154
- * @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
155
164
  * @private
156
165
  */
157
- getRelevantSourceFiles(className, filePath) {
158
- if (filePath) {
159
- const sourceFile = this.program.getSourceFile(filePath);
160
- return sourceFile ? [sourceFile] : [];
161
- }
162
- // Only get source files that are not declaration files and not in node_modules
163
- 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 => {
164
169
  if (sf.isDeclarationFile)
165
170
  return false;
166
171
  if (sf.fileName.includes('.d.ts'))
@@ -171,23 +176,9 @@ class SchemaTransformer {
171
176
  this.loadedFiles.add(sf.fileName);
172
177
  return true;
173
178
  });
174
- }
175
- /**
176
- * Transforms a class by its name into an OpenAPI schema object.
177
- * Now considers the context of the calling file to resolve ambiguous class names.
178
- *
179
- * @param className - The name of the class to transform
180
- * @param filePath - Optional path to the file containing the class
181
- * @param contextFile - Optional context file for resolving class ambiguity
182
- * @returns Object containing the class name and its corresponding JSON schema
183
- * @throws {Error} When the specified class cannot be found
184
- * @private
185
- */
186
- transformByName(className, filePath, contextFile) {
187
- const sourceFiles = this.getRelevantSourceFiles(className, filePath);
188
179
  // If we have a context file, try to find the class in that file first
189
- if (contextFile) {
190
- const contextSourceFile = this.program.getSourceFile(contextFile);
180
+ if (contextFilePath) {
181
+ const contextSourceFile = this.program.getSourceFile(contextFilePath);
191
182
  if (contextSourceFile) {
192
183
  const classNode = this.findClassByName(contextSourceFile, className);
193
184
  if (classNode) {
@@ -196,15 +187,34 @@ class SchemaTransformer {
196
187
  if (this.classCache.has(cacheKey)) {
197
188
  return this.classCache.get(cacheKey);
198
189
  }
199
- const result = this.transformClass(classNode, contextSourceFile);
200
- this.classCache.set(cacheKey, result);
201
- this.cleanupCache();
202
- return result;
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
+ }
203
213
  }
204
214
  }
205
215
  }
206
216
  // Fallback to searching all files, but prioritize files that are more likely to be relevant
207
- const prioritizedFiles = this.prioritizeSourceFiles(sourceFiles, contextFile);
217
+ const prioritizedFiles = this.prioritizeSourceFiles(sourceFiles, contextFilePath);
208
218
  for (const sourceFile of prioritizedFiles) {
209
219
  const classNode = this.findClassByName(sourceFile, className);
210
220
  if (classNode && sourceFile?.fileName) {
@@ -213,12 +223,31 @@ class SchemaTransformer {
213
223
  if (this.classCache.has(cacheKey)) {
214
224
  return this.classCache.get(cacheKey);
215
225
  }
216
- const result = this.transformClass(classNode, sourceFile);
217
- // Cache using fileName:className as key for uniqueness
218
- this.classCache.set(cacheKey, result);
219
- // Clean up cache if it gets too large
220
- this.cleanupCache();
221
- 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
+ }
222
251
  }
223
252
  }
224
253
  throw new Error(`Class ${className} not found`);
@@ -228,15 +257,15 @@ class SchemaTransformer {
228
257
  * Gives priority to files in the same directory or with similar names.
229
258
  *
230
259
  * @param sourceFiles - Array of source files to prioritize
231
- * @param contextFile - Optional context file for prioritization
260
+ * @param contextFilePath - Optional path to context file for prioritization
232
261
  * @returns Prioritized array of source files
233
262
  * @private
234
263
  */
235
- prioritizeSourceFiles(sourceFiles, contextFile) {
236
- if (!contextFile) {
264
+ prioritizeSourceFiles(sourceFiles, contextFilePath) {
265
+ if (!contextFilePath) {
237
266
  return sourceFiles;
238
267
  }
239
- const contextDir = contextFile.substring(0, contextFile.lastIndexOf('/'));
268
+ const contextDir = contextFilePath.substring(0, contextFilePath.lastIndexOf('/'));
240
269
  return sourceFiles.sort((a, b) => {
241
270
  const aDir = a.fileName.substring(0, a.fileName.lastIndexOf('/'));
242
271
  const bDir = b.fileName.substring(0, b.fileName.lastIndexOf('/'));
@@ -281,16 +310,88 @@ class SchemaTransformer {
281
310
  /**
282
311
  * Clears the current singleton instance. Useful for testing or when you need
283
312
  * to create a new instance with different configuration.
313
+ * @private
284
314
  */
285
315
  static clearInstance() {
286
- 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();
287
367
  }
288
368
  static getInstance(tsConfigPath, options) {
289
- if (!SchemaTransformer.instance) {
369
+ if (!SchemaTransformer.instance || SchemaTransformer.isInstanceDisposed()) {
290
370
  SchemaTransformer.instance = new SchemaTransformer(tsConfigPath, options);
291
371
  }
292
372
  return SchemaTransformer.instance;
293
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
+ }
294
395
  /**
295
396
  * Transforms a class constructor function into an OpenAPI schema object.
296
397
  *
@@ -325,31 +426,147 @@ class SchemaTransformer {
325
426
  clearCache() {
326
427
  this.classCache.clear();
327
428
  this.loadedFiles.clear();
429
+ this.processingClasses.clear();
328
430
  // Force garbage collection hint if available
329
431
  if (global.gc) {
330
432
  global.gc();
331
433
  }
332
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
+ }
333
503
  /**
334
504
  * Gets memory usage statistics for monitoring and debugging.
335
505
  *
336
- * @returns Object containing cache size and loaded files count
506
+ * @returns Object containing cache size, loaded files count, and processing status
337
507
  *
338
508
  * @example
339
509
  * ```typescript
340
510
  * const transformer = SchemaTransformer.getInstance();
341
511
  * const stats = transformer.getMemoryStats();
342
512
  * console.log(`Cache entries: ${stats.cacheSize}, Files loaded: ${stats.loadedFiles}`);
513
+ * console.log(`Currently processing: ${stats.currentlyProcessing} classes`);
343
514
  * ```
344
515
  *
345
- * @public
516
+ * @private
346
517
  */
347
518
  getMemoryStats() {
348
519
  return {
349
- cacheSize: this.classCache.size,
350
- 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,
351
526
  };
352
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
+ }
353
570
  /**
354
571
  * Finds a class declaration by name within a source file.
355
572
  *
@@ -780,20 +997,24 @@ class SchemaTransformer {
780
997
  * Generates an OpenAPI schema from extracted property information.
781
998
  *
782
999
  * @param properties - Array of property information to process
783
- * @param contextFile - Optional context file path for resolving class references
1000
+ * @param contextFilePath - Optional context file path for resolving class references
784
1001
  * @returns Complete OpenAPI schema object with properties and validation rules
785
1002
  * @private
786
1003
  */
787
- generateSchema(properties, contextFile) {
1004
+ generateSchema(properties, contextFilePath) {
788
1005
  const schema = {
789
1006
  type: 'object',
790
1007
  properties: {},
791
1008
  required: [],
792
1009
  };
793
1010
  for (const property of properties) {
794
- const { type, format, nestedSchema } = this.mapTypeToSchema(property.type, contextFile);
1011
+ const { type, format, nestedSchema } = this.mapTypeToSchema(property.type, contextFilePath);
795
1012
  if (nestedSchema) {
796
1013
  schema.properties[property.name] = nestedSchema;
1014
+ // Skip decorator application for $ref schemas
1015
+ if (this.isRefSchema(nestedSchema)) {
1016
+ continue;
1017
+ }
797
1018
  }
798
1019
  else {
799
1020
  schema.properties[property.name] = { type };
@@ -816,15 +1037,15 @@ class SchemaTransformer {
816
1037
  * Handles primitive types, arrays, and nested objects recursively.
817
1038
  *
818
1039
  * @param type - The TypeScript type string to map
819
- * @param contextFile - Optional context file path for resolving class references
1040
+ * @param contextFilePath - Optional context file path for resolving class references
820
1041
  * @returns Object containing OpenAPI type, optional format, and nested schema
821
1042
  * @private
822
1043
  */
823
- mapTypeToSchema(type, contextFile) {
1044
+ mapTypeToSchema(type, contextFilePath) {
824
1045
  // Handle arrays
825
1046
  if (type.endsWith('[]')) {
826
1047
  const elementType = type.slice(0, -2);
827
- const elementSchema = this.mapTypeToSchema(elementType, contextFile);
1048
+ const elementSchema = this.mapTypeToSchema(elementType, contextFilePath);
828
1049
  const items = elementSchema.nestedSchema || {
829
1050
  type: elementSchema.type,
830
1051
  };
@@ -885,7 +1106,14 @@ class SchemaTransformer {
885
1106
  }
886
1107
  // Handle nested objects
887
1108
  try {
888
- const nestedResult = this.transformByName(type, undefined, contextFile);
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
+ }
889
1117
  return {
890
1118
  type: constants.jsPrimitives.Object.value,
891
1119
  nestedSchema: nestedResult.schema,
@@ -896,6 +1124,16 @@ class SchemaTransformer {
896
1124
  }
897
1125
  }
898
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
+ }
899
1137
  /**
900
1138
  * Applies class-validator decorators to schema properties.
901
1139
  * Maps validation decorators to their corresponding OpenAPI schema constraints.
@@ -906,6 +1144,10 @@ class SchemaTransformer {
906
1144
  * @private
907
1145
  */
908
1146
  applyDecorators(decorators, schema, propertyName) {
1147
+ // Skip applying decorators to $ref schemas
1148
+ if (this.isRefSchema(schema)) {
1149
+ return;
1150
+ }
909
1151
  const isArrayType = schema.properties[propertyName].type ===
910
1152
  constants.jsPrimitives.Array.value;
911
1153
  for (const decorator of decorators) {
@@ -1227,6 +1469,10 @@ class SchemaTransformer {
1227
1469
  * @private
1228
1470
  */
1229
1471
  applyTypeBasedFormats(property, schema) {
1472
+ // Skip applying type-based formats to $ref schemas
1473
+ if (this.isRefSchema(schema)) {
1474
+ return;
1475
+ }
1230
1476
  const propertyName = property.name;
1231
1477
  const propertyType = property.type.toLowerCase();
1232
1478
  const propertySchema = schema.properties[propertyName];
@@ -1261,6 +1507,10 @@ class SchemaTransformer {
1261
1507
  * @private
1262
1508
  */
1263
1509
  determineRequiredStatus(property, schema) {
1510
+ // Skip determining required status for $ref schemas
1511
+ if (this.isRefSchema(schema)) {
1512
+ return;
1513
+ }
1264
1514
  const propertyName = property.name;
1265
1515
  // Check if already marked as required by IsNotEmpty or ArrayNotEmpty decorator
1266
1516
  const isAlreadyRequired = schema.required.includes(propertyName);
@@ -1301,7 +1551,7 @@ class SchemaTransformer {
1301
1551
  * @public
1302
1552
  */
1303
1553
  function transform(cls, options) {
1304
- return SchemaTransformer.getInstance(undefined, options).transform(cls);
1554
+ return SchemaTransformer.transformClass(cls, options);
1305
1555
  }
1306
1556
 
1307
- export { transform };
1557
+ export { SchemaTransformer, transform };