Repository: nestjs/swagger
Pull request #3134 (2024-10-29 13:50:22)

fix(plugin): support compiler options paths mapping
## PR Checklist
Please check if your PR fulfills the following requirements:

- [x] The commit message follows our guidelines: https://github.com/nestjs/nest/blob/master/CONTRIBUTING.md
- [x] Tests for the changes have been added (for bug fixes / features)
- [ ] Docs have been added / updated (for bug fixes / features)


## PR Type
What kind of change does this PR introduce?

<!-- Please check the one that applies to this PR using "x". -->
- [x] Bugfix
- [ ] Feature
- [ ] Code style update (formatting, local variables)
- [ ] Refactoring (no functional changes, no api changes)
- [ ] Build related changes
- [ ] CI related changes
- [ ] Other... Please describe:

## What is the current behavior?
Currently the compiler options paths are completely ignored leading to imports that have no common path with program source and being resolved in the end to something like: `../../../@mapped-path`.  

Issue Number: N/A


## What is the new behavior?
Compiler options paths are respected and resolved relatively to the program source.

## Does this PR introduce a breaking change?
- [ ] Yes
- [x] No

<!-- If this PR contains a breaking change, please describe the impact and migration path for existing applications below. -->


## Other information
Not sure if we could leave the old `typeReference` alone and only adjust import path? Both seems to work fine in my tests.


Patch:
diff --git a/lib/plugin/utils/plugin-utils.ts b/lib/plugin/utils/plugin-utils.ts
index 7d1bd208..188f72bd 100644
--- a/lib/plugin/utils/plugin-utils.ts
+++ b/lib/plugin/utils/plugin-utils.ts
@@ -115,7 +115,10 @@ export function getTypeReferenceAsString(
     if (type.aliasSymbol) {
       return { typeName: 'Object', arrayDepth };
     }
-    if (typeChecker.getApparentType(type).getSymbol().getEscapedName() === 'String') {
+    if (
+      typeChecker.getApparentType(type).getSymbol().getEscapedName() ===
+      'String'
+    ) {
       return { typeName: String.name, arrayDepth };
     }
     return { typeName: undefined };
@@ -140,7 +143,8 @@ export function hasPropertyKey(
 export function replaceImportPath(
   typeReference: string,
   fileName: string,
-  options: PluginOptions
+  options: PluginOptions,
+  compilerOptionsPaths: ts.MapLike<string[]>
 ) {
   if (!typeReference.includes('import')) {
     return { typeReference, importPath: null };
@@ -167,6 +171,19 @@ export function replaceImportPath(
       ? convertPath(options.pathToSource)
       : posix.dirname(convertPath(fileName));
 
+    for (const [key, value] of Object.entries(compilerOptionsPaths)) {
+      const keyToMatch = key.replace('*', '');
+      if (importPath.includes(keyToMatch)) {
+        const newImportPath = posix.join(
+          from,
+          importPath.replace(keyToMatch, value[0].replace('*', ''))
+        );
+        typeReference = typeReference.replace(importPath, newImportPath);
+        importPath = newImportPath;
+        break;
+      }
+    }
+
     let relativePath = posix.relative(from, importPath);
     relativePath = relativePath[0] !== '.' ? './' + relativePath : relativePath;
 
@@ -250,7 +267,8 @@ export function isAutoGeneratedEnumUnion(
       return undefined;
     }
     const undefinedTypeIndex = type.types.findIndex(
-      (type: any) => type.intrinsicName === 'undefined' || type.intrinsicName === 'null'
+      (type: any) =>
+        type.intrinsicName === 'undefined' || type.intrinsicName === 'null'
     );
     if (undefinedTypeIndex < 0) {
       return undefined;
diff --git a/lib/plugin/utils/type-reference-to-identifier.util.ts b/lib/plugin/utils/type-reference-to-identifier.util.ts
index a4368c58..993c7463 100644
--- a/lib/plugin/utils/type-reference-to-identifier.util.ts
+++ b/lib/plugin/utils/type-reference-to-identifier.util.ts
@@ -13,7 +13,8 @@ export function typeReferenceToIdentifier(
   options: PluginOptions,
   factory: ts.NodeFactory,
   type: ts.Type,
-  typeImports: Record<string, string>
+  typeImports: Record<string, string>,
+  compilerOptionsPaths: ts.MapLike<string[]>
 ) {
   if (options.readonly) {
     assertReferenceableType(
@@ -27,7 +28,8 @@ export function typeReferenceToIdentifier(
   const { typeReference, importPath, typeName } = replaceImportPath(
     typeReferenceDescriptor.typeName,
     hostFilename,
-    options
+    options,
+    compilerOptionsPaths
   );
 
   let identifier: ts.Identifier;
diff --git a/lib/plugin/visitors/controller-class.visitor.ts b/lib/plugin/visitors/controller-class.visitor.ts
index ced3d49b..70349c31 100644
--- a/lib/plugin/visitors/controller-class.visitor.ts
+++ b/lib/plugin/visitors/controller-class.visitor.ts
@@ -58,6 +58,7 @@ export class ControllerClassVisitor extends AbstractFileVisitor {
     options: PluginOptions
   ) {
     const typeChecker = program.getTypeChecker();
+    const compilerOptionsPaths = program.getCompilerOptions().paths ?? {};
     if (!options.readonly) {
       sourceFile = this.updateImports(sourceFile, ctx.factory, program);
     }
@@ -72,7 +73,8 @@ export class ControllerClassVisitor extends AbstractFileVisitor {
             typeChecker,
             options,
             sourceFile,
-            metadata
+            metadata,
+            compilerOptionsPaths
           );
           if (!options.readonly) {
             return updatedNode;
@@ -121,7 +123,8 @@ export class ControllerClassVisitor extends AbstractFileVisitor {
     typeChecker: ts.TypeChecker,
     options: PluginOptions,
     sourceFile: ts.SourceFile,
-    metadata: ClassMetadata
+    metadata: ClassMetadata,
+    compilerOptionsPaths: ts.MapLike<string[]>
   ): ts.MethodDeclaration {
     const hostFilename = sourceFile.fileName;
     const decorators =
@@ -165,7 +168,8 @@ export class ControllerClassVisitor extends AbstractFileVisitor {
       factory.createNodeArray(),
       hostFilename,
       metadata,
-      options
+      options,
+      compilerOptionsPaths
     );
     const updatedDecorators = [
       ...apiOperationDecoratorsArray,
@@ -389,7 +393,8 @@ export class ControllerClassVisitor extends AbstractFileVisitor {
     existingProperties: ts.NodeArray<ts.PropertyAssignment> = factory.createNodeArray(),
     hostFilename: string,
     metadata: ClassMetadata,
-    options: PluginOptions
+    options: PluginOptions,
+    compilerOptionsPaths: ts.MapLike<string[]>
   ): ts.ObjectLiteralExpression {
     let properties = [];
     if (!options.readonly) {
@@ -405,7 +410,8 @@ export class ControllerClassVisitor extends AbstractFileVisitor {
         typeChecker,
         existingProperties,
         hostFilename,
-        options
+        options,
+        compilerOptionsPaths
       )
     ]);
     const objectLiteralExpr = factory.createObjectLiteralExpression(
@@ -435,7 +441,8 @@ export class ControllerClassVisitor extends AbstractFileVisitor {
     typeChecker: ts.TypeChecker,
     existingProperties: ts.NodeArray<ts.PropertyAssignment>,
     hostFilename: string,
-    options: PluginOptions
+    options: PluginOptions,
+    compilerOptionsPaths: ts.MapLike<string[]>
   ) {
     if (hasPropertyKey('type', existingProperties)) {
       return undefined;
@@ -458,7 +465,8 @@ export class ControllerClassVisitor extends AbstractFileVisitor {
       options,
       factory,
       type,
-      this._typeImports
+      this._typeImports,
+      compilerOptionsPaths
     );
     return factory.createPropertyAssignment('type', identifier);
   }
diff --git a/lib/plugin/visitors/model-class.visitor.ts b/lib/plugin/visitors/model-class.visitor.ts
index 69ece62d..e6677000 100644
--- a/lib/plugin/visitors/model-class.visitor.ts
+++ b/lib/plugin/visitors/model-class.visitor.ts
@@ -3,6 +3,10 @@ import { posix } from 'path';
 import * as ts from 'typescript';
 import { factory, PropertyAssignment } from 'typescript';
 import { ApiHideProperty, ApiProperty } from '../../decorators';
+import {
+  decoratorsProperties,
+  decoratorsPropertiesMappingType
+} from '../../services/decorators-properties';
 import { PluginOptions } from '../merge-options';
 import { METADATA_FACTORY_NAME } from '../plugin-constants';
 import { pluginDebugLogger } from '../plugin-debug-logger';
@@ -16,6 +20,10 @@ import {
   getTsDocTagsOfNode,
   isEnum
 } from '../utils/ast-utils';
+import {
+  getExternalImports,
+  replaceExternalImportsInTypeReference
+} from '../utils/external-imports.util';
 import {
   canReferenceNode,
   convertPath,
@@ -28,14 +36,6 @@ import {
 } from '../utils/plugin-utils';
 import { typeReferenceToIdentifier } from '../utils/type-reference-to-identifier.util';
 import { AbstractFileVisitor } from './abstract.visitor';
-import {
-  getExternalImports,
-  replaceExternalImportsInTypeReference
-} from '../utils/external-imports.util';
-import {
-  decoratorsProperties,
-  decoratorsPropertiesMappingType
-} from '../../services/decorators-properties';
 
 type ClassMetadata = Record<string, ts.ObjectLiteralExpression>;
 
@@ -72,6 +72,7 @@ export class ModelClassVisitor extends AbstractFileVisitor {
   ) {
     const externalImports = getExternalImports(sourceFile);
     const typeChecker = program.getTypeChecker();
+    const compilerOptionsPaths = program.getCompilerOptions().paths ?? {};
     sourceFile = this.updateImports(sourceFile, ctx.factory, program);
 
     const propertyNodeVisitorFactory =
@@ -86,7 +87,8 @@ export class ModelClassVisitor extends AbstractFileVisitor {
               options,
               sourceFile,
               metadata,
-              externalImports
+              externalImports,
+              compilerOptionsPaths
             );
           } else if (
             options.parameterProperties &&
@@ -98,7 +100,8 @@ export class ModelClassVisitor extends AbstractFileVisitor {
               options,
               sourceFile,
               metadata,
-              externalImports
+              externalImports,
+              compilerOptionsPaths
             );
           }
           return node;
@@ -165,7 +168,8 @@ export class ModelClassVisitor extends AbstractFileVisitor {
     options: PluginOptions,
     sourceFile: ts.SourceFile,
     metadata: ClassMetadata,
-    externalImports: Record<string, string>
+    externalImports: Record<string, string>,
+    compilerOptionsPaths: ts.MapLike<string[]>
   ) {
     const isPropertyStatic = (node.modifiers || []).some(
       (modifier: ts.Modifier) => modifier.kind === ts.SyntaxKind.StaticKeyword
@@ -213,7 +217,8 @@ export class ModelClassVisitor extends AbstractFileVisitor {
         sourceFile.fileName,
         sourceFile,
         metadata,
-        externalImports
+        externalImports,
+        compilerOptionsPaths
       );
     } catch (err) {
       return node;
@@ -226,7 +231,8 @@ export class ModelClassVisitor extends AbstractFileVisitor {
     options: PluginOptions,
     sourceFile: ts.SourceFile,
     metadata: ClassMetadata,
-    externalImports: Record<string, string>
+    externalImports: Record<string, string>,
+    compilerOptionsPaths: ts.MapLike<string[]>
   ) {
     constructorNode.forEachChild((node) => {
       if (
@@ -248,7 +254,8 @@ export class ModelClassVisitor extends AbstractFileVisitor {
           options,
           sourceFile.fileName,
           sourceFile,
-          externalImports
+          externalImports,
+          compilerOptionsPaths
         );
 
         const propertyName = node.name.getText();
@@ -315,7 +322,8 @@ export class ModelClassVisitor extends AbstractFileVisitor {
     hostFilename: string,
     sourceFile: ts.SourceFile,
     metadata: ClassMetadata,
-    externalImports: Record<string, string>
+    externalImports: Record<string, string>,
+    compilerOptionsPaths: ts.MapLike<string[]>
   ) {
     const objectLiteralExpr = this.createDecoratorObjectLiteralExpr(
       factory,
@@ -325,7 +333,8 @@ export class ModelClassVisitor extends AbstractFileVisitor {
       options,
       hostFilename,
       sourceFile,
-      externalImports
+      externalImports,
+      compilerOptionsPaths
     );
     this.addClassMetadata(
       compilerNode,
@@ -346,7 +355,8 @@ export class ModelClassVisitor extends AbstractFileVisitor {
     options: PluginOptions = {},
     hostFilename = '',
     sourceFile?: ts.SourceFile,
-    externalImports: Record<string, string> = {}
+    externalImports: Record<string, string> = {},
+    compilerOptionsPaths: ts.MapLike<string[]> = {}
   ): ts.ObjectLiteralExpression {
     const isRequired = !node.questionToken;
 
@@ -363,7 +373,9 @@ export class ModelClassVisitor extends AbstractFileVisitor {
         typeChecker,
         existingProperties,
         hostFilename,
-        options
+        options,
+        externalImports,
+        compilerOptionsPaths
       ),
       ...this.createDescriptionAndTsDocTagPropertyAssignments(
         factory,
@@ -386,7 +398,8 @@ export class ModelClassVisitor extends AbstractFileVisitor {
         existingProperties,
         hostFilename,
         options,
-        externalImports
+        externalImports,
+        compilerOptionsPaths
       )
     ];
     if (
@@ -411,7 +424,9 @@ export class ModelClassVisitor extends AbstractFileVisitor {
     typeChecker: ts.TypeChecker,
     existingProperties: ts.NodeArray<ts.PropertyAssignment>,
     hostFilename: string,
-    options: PluginOptions
+    options: PluginOptions,
+    externalImports: Record<string, string>,
+    compilerOptionsPaths: ts.MapLike<string[]>
   ): ts.PropertyAssignment[] {
     const key = 'type';
     if (hasPropertyKey(key, existingProperties)) {
@@ -426,7 +441,9 @@ export class ModelClassVisitor extends AbstractFileVisitor {
           typeChecker,
           existingProperties,
           hostFilename,
-          options
+          options,
+          externalImports,
+          compilerOptionsPaths
         );
         return [factory.createPropertyAssignment(key, initializer)];
       } else if (ts.isUnionTypeNode(node)) {
@@ -443,7 +460,9 @@ export class ModelClassVisitor extends AbstractFileVisitor {
             typeChecker,
             existingProperties,
             hostFilename,
-            options
+            options,
+            externalImports,
+            compilerOptionsPaths
           );
           if (!isNullable) {
             return propertyAssignments;
@@ -475,7 +494,8 @@ export class ModelClassVisitor extends AbstractFileVisitor {
       options,
       factory,
       type,
-      this._typeImports
+      this._typeImports,
+      compilerOptionsPaths
     );
 
     const initializer = factory.createArrowFunction(
@@ -495,7 +515,9 @@ export class ModelClassVisitor extends AbstractFileVisitor {
     typeChecker: ts.TypeChecker,
     existingProperties: ts.NodeArray<ts.PropertyAssignment>,
     hostFilename: string,
-    options: PluginOptions
+    options: PluginOptions,
+    externalImports: Record<string, string>,
+    compilerOptionsPaths: ts.MapLike<string[]>
   ) {
     const propertyAssignments = Array.from(node.members || []).map((member) => {
       const literalExpr = this.createDecoratorObjectLiteralExpr(
@@ -504,7 +526,10 @@ export class ModelClassVisitor extends AbstractFileVisitor {
         typeChecker,
         existingProperties,
         options,
-        hostFilename
+        hostFilename,
+        undefined,
+        externalImports,
+        compilerOptionsPaths
       );
       return factory.createPropertyAssignment(
         factory.createIdentifier(member.name.getText()),
@@ -544,7 +569,8 @@ export class ModelClassVisitor extends AbstractFileVisitor {
     existingProperties: ts.NodeArray<ts.PropertyAssignment>,
     hostFilename: string,
     options: PluginOptions,
-    externalImports: Record<string, string>
+    externalImports: Record<string, string>,
+    compilerOptionsPaths: ts.MapLike<string[]>
   ) {
     const key = 'enum';
     if (hasPropertyKey(key, existingProperties)) {
@@ -592,7 +618,8 @@ export class ModelClassVisitor extends AbstractFileVisitor {
       options,
       factory,
       type,
-      this._typeImports
+      this._typeImports,
+      compilerOptionsPaths
     );
 
     const enumProperty = factory.createPropertyAssignment(key, enumIdentifier);


Test patch:
diff --git a/test/plugin/fixtures/project/cats/dto/absolute-owner.dto.ts b/test/plugin/fixtures/project/cats/dto/absolute-owner.dto.ts
new file mode 100644
index 00000000..a2e26569
--- /dev/null
+++ b/test/plugin/fixtures/project/cats/dto/absolute-owner.dto.ts
@@ -0,0 +1,4 @@
+export enum AbsoluteOwner {
+  YES = 'YES',
+  NO = 'NO'
+}
diff --git a/test/plugin/fixtures/project/cats/dto/create-cat.dto.ts b/test/plugin/fixtures/project/cats/dto/create-cat.dto.ts
index 63fb9032..60b4e2d4 100644
--- a/test/plugin/fixtures/project/cats/dto/create-cat.dto.ts
+++ b/test/plugin/fixtures/project/cats/dto/create-cat.dto.ts
@@ -13,6 +13,8 @@ import { ApiExtraModels, ApiProperty } from '../../../../lib';
 import { ExtraModel } from './extra-model.dto';
 import { LettersEnum } from './pagination-query.dto';
 import { TagDto } from './tag.dto';
+import { Owner } from '@package-a/owner';
+import { AbsoluteOwner } from 'different-cats/dto/absolute-owner.dto';
 
 enum NonExportedEnum {
   YES = 'YES',
@@ -105,6 +107,18 @@ export class CreateCatDto {
   })
   externalEnum: HttpStatus;
 
+  @ApiProperty({
+    enum: Owner,
+    enumName: 'Owner'
+  })
+  customPathImportOwner: Owner;
+
+  @ApiProperty({
+    enum: AbsoluteOwner,
+    enumName: 'AbsoluteOwner'
+  })
+  absoluteImportOwner: AbsoluteOwner;
+
   /**
    * Available language in the application
    * @example FR
diff --git a/test/plugin/fixtures/project/package-a/owner.ts b/test/plugin/fixtures/project/package-a/owner.ts
new file mode 100644
index 00000000..6fb85005
--- /dev/null
+++ b/test/plugin/fixtures/project/package-a/owner.ts
@@ -0,0 +1,4 @@
+export enum Owner {
+  YES = 'YES',
+  NO = 'NO'
+}
diff --git a/test/plugin/fixtures/project/tsconfig.json b/test/plugin/fixtures/project/tsconfig.json
index 2f6b08ab..28e7bc5d 100644
--- a/test/plugin/fixtures/project/tsconfig.json
+++ b/test/plugin/fixtures/project/tsconfig.json
@@ -11,7 +11,11 @@
     "outDir": "./dist",
     "baseUrl": "./",
     "incremental": true,
-    "skipLibCheck": true
+    "skipLibCheck": true,
+    "paths": {
+      "@package-a/owner": ["package-a/owner"],
+      "different-cats/*": ["./cats/*"]
+    }
   },
   "include": ["./**/*"]
 }
diff --git a/test/plugin/fixtures/serialized-meta.fixture.ts b/test/plugin/fixtures/serialized-meta.fixture.ts
index d8d9eecf..77252013 100644
--- a/test/plugin/fixtures/serialized-meta.fixture.ts
+++ b/test/plugin/fixtures/serialized-meta.fixture.ts
@@ -4,6 +4,10 @@ export default async () => {
     ['./cats/dto/pagination-query.dto']: await import(
       './cats/dto/pagination-query.dto'
     ),
+    ['./package-a/owner']: await import('./package-a/owner'),
+    ['./cats/dto/absolute-owner.dto']: await import(
+      './cats/dto/absolute-owner.dto'
+    ),
     ['./cats/dto/create-cat.dto']: await import('./cats/dto/create-cat.dto'),
     ['./cats/dto/tag.dto']: await import('./cats/dto/tag.dto'),
     ['./cats/classes/cat.class']: await import('./cats/classes/cat.class')
@@ -143,6 +147,14 @@ export default async () => {
                 required: true,
                 enum: require('@nestjs/common').HttpStatus
               },
+              customPathImportOwner: {
+                required: true,
+                enum: t['./package-a/owner'].Owner
+              },
+              absoluteImportOwner: {
+                required: true,
+                enum: t['./cats/dto/absolute-owner.dto'].AbsoluteOwner
+              },
               state: {
                 required: false,
                 description: 'Available language in the application',