Repository: nestjs/swagger
Pull request #3029 (2024-08-02 16:14:23)

fix: properly import external enums
## 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?
<!-- Please describe the current behavior that you are modifying, or link to a relevant issue. -->

Issue Number: #2087


## What is the new behavior?

Instead of relaying on fully qualified type for external enums, this prefers the paths from import statements to ensure the required path always exists.

## 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
This solution could probably be used in other places as well, but to not brake anything I opted for externals enums only.


Patch:
diff --git a/lib/plugin/utils/external-imports.util.ts b/lib/plugin/utils/external-imports.util.ts
new file mode 100644
index 00000000..8906dfec
--- /dev/null
+++ b/lib/plugin/utils/external-imports.util.ts
@@ -0,0 +1,54 @@
+import * as ts from 'typescript';
+
+export function getExternalImports(
+  sourceFile: ts.SourceFile
+): Record<string, string> {
+  const externalImports: Record<string, string> = {};
+
+  const importDeclarations = sourceFile.statements.filter(
+    ts.isImportDeclaration
+  );
+
+  for (const declaration of importDeclarations) {
+    const { moduleSpecifier, importClause } = declaration;
+
+    // Skip relative imports
+    if (
+      !ts.isStringLiteral(moduleSpecifier) ||
+      moduleSpecifier.text[0] === '.'
+    ) {
+      continue;
+    }
+
+    if (
+      importClause?.namedBindings &&
+      ts.isNamedImports(importClause.namedBindings)
+    ) {
+      const namedImports = importClause?.namedBindings as ts.NamedImports;
+      for (const namedImport of namedImports.elements) {
+        externalImports[namedImport.name.text] = moduleSpecifier.text;
+      }
+    }
+  }
+  return externalImports;
+}
+
+export function replaceExternalImportsInTypeReference(
+  typeReference: string,
+  externalImports: Record<string, string>
+): string {
+  const regexp = /import\((.+)\).([^\]]+)(\])?/;
+  const match = regexp.exec(typeReference);
+
+  if (match?.length >= 3) {
+    const [, importPath, importName] = match;
+    if (externalImports[importName]) {
+      return typeReference.replace(
+        importPath,
+        `"${externalImports[importName]}"`
+      );
+    }
+  }
+
+  return typeReference;
+}
diff --git a/lib/plugin/visitors/model-class.visitor.ts b/lib/plugin/visitors/model-class.visitor.ts
index 88397a4a..16c90708 100644
--- a/lib/plugin/visitors/model-class.visitor.ts
+++ b/lib/plugin/visitors/model-class.visitor.ts
@@ -28,6 +28,10 @@ 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
@@ -66,6 +70,7 @@ export class ModelClassVisitor extends AbstractFileVisitor {
     program: ts.Program,
     options: PluginOptions
   ) {
+    const externalImports = getExternalImports(sourceFile);
     const typeChecker = program.getTypeChecker();
     sourceFile = this.updateImports(sourceFile, ctx.factory, program);
 
@@ -80,7 +85,8 @@ export class ModelClassVisitor extends AbstractFileVisitor {
               typeChecker,
               options,
               sourceFile,
-              metadata
+              metadata,
+              externalImports
             );
           } else if (
             options.parameterProperties &&
@@ -91,7 +97,8 @@ export class ModelClassVisitor extends AbstractFileVisitor {
               typeChecker,
               options,
               sourceFile,
-              metadata
+              metadata,
+              externalImports
             );
           }
           return node;
@@ -157,7 +164,8 @@ export class ModelClassVisitor extends AbstractFileVisitor {
     typeChecker: ts.TypeChecker,
     options: PluginOptions,
     sourceFile: ts.SourceFile,
-    metadata: ClassMetadata
+    metadata: ClassMetadata,
+    externalImports: Record<string, string>
   ) {
     const isPropertyStatic = (node.modifiers || []).some(
       (modifier: ts.Modifier) => modifier.kind === ts.SyntaxKind.StaticKeyword
@@ -204,7 +212,8 @@ export class ModelClassVisitor extends AbstractFileVisitor {
         options,
         sourceFile.fileName,
         sourceFile,
-        metadata
+        metadata,
+        externalImports
       );
     } catch (err) {
       return node;
@@ -216,7 +225,8 @@ export class ModelClassVisitor extends AbstractFileVisitor {
     typeChecker: ts.TypeChecker,
     options: PluginOptions,
     sourceFile: ts.SourceFile,
-    metadata: ClassMetadata
+    metadata: ClassMetadata,
+    externalImports: Record<string, string>
   ) {
     constructorNode.forEachChild((node) => {
       if (
@@ -237,7 +247,8 @@ export class ModelClassVisitor extends AbstractFileVisitor {
           factory.createNodeArray(),
           options,
           sourceFile.fileName,
-          sourceFile
+          sourceFile,
+          externalImports
         );
 
         const propertyName = node.name.getText();
@@ -303,7 +314,8 @@ export class ModelClassVisitor extends AbstractFileVisitor {
     options: PluginOptions,
     hostFilename: string,
     sourceFile: ts.SourceFile,
-    metadata: ClassMetadata
+    metadata: ClassMetadata,
+    externalImports: Record<string, string>
   ) {
     const objectLiteralExpr = this.createDecoratorObjectLiteralExpr(
       factory,
@@ -312,7 +324,8 @@ export class ModelClassVisitor extends AbstractFileVisitor {
       factory.createNodeArray(),
       options,
       hostFilename,
-      sourceFile
+      sourceFile,
+      externalImports
     );
     this.addClassMetadata(
       compilerNode,
@@ -332,7 +345,8 @@ export class ModelClassVisitor extends AbstractFileVisitor {
     existingProperties: ts.NodeArray<ts.PropertyAssignment> = factory.createNodeArray(),
     options: PluginOptions = {},
     hostFilename = '',
-    sourceFile?: ts.SourceFile
+    sourceFile?: ts.SourceFile,
+    externalImports: Record<string, string> = {}
   ): ts.ObjectLiteralExpression {
     const isRequired = !node.questionToken;
 
@@ -371,7 +385,8 @@ export class ModelClassVisitor extends AbstractFileVisitor {
         typeChecker,
         existingProperties,
         hostFilename,
-        options
+        options,
+        externalImports
       )
     ];
     if (
@@ -528,7 +543,8 @@ export class ModelClassVisitor extends AbstractFileVisitor {
     typeChecker: ts.TypeChecker,
     existingProperties: ts.NodeArray<ts.PropertyAssignment>,
     hostFilename: string,
-    options: PluginOptions
+    options: PluginOptions,
+    externalImports: Record<string, string>
   ) {
     const key = 'enum';
     if (hasPropertyKey(key, existingProperties)) {
@@ -565,8 +581,12 @@ export class ModelClassVisitor extends AbstractFileVisitor {
       isArrayType = typeIsArrayTuple.isArray;
       type = typeIsArrayTuple.type;
     }
-
-    const typeReferenceDescriptor = { typeName: getText(type, typeChecker) };
+    const typeReferenceDescriptor = {
+      typeName: replaceExternalImportsInTypeReference(
+        getText(type, typeChecker),
+        externalImports
+      )
+    };
     const enumIdentifier = typeReferenceToIdentifier(
       typeReferenceDescriptor,
       hostFilename,


Test patch:
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 b652a83d..63fb9032 100644
--- a/test/plugin/fixtures/project/cats/dto/create-cat.dto.ts
+++ b/test/plugin/fixtures/project/cats/dto/create-cat.dto.ts
@@ -1,4 +1,4 @@
-import { ConsoleLogger } from '@nestjs/common';
+import { ConsoleLogger, HttpStatus } from '@nestjs/common';
 import {
   IsIn,
   IsNegative,
@@ -99,6 +99,12 @@ export class CreateCatDto {
   })
   enum: LettersEnum;
 
+  @ApiProperty({
+    enum: HttpStatus,
+    enumName: 'HttpStatus'
+  })
+  externalEnum: HttpStatus;
+
   /**
    * Available language in the application
    * @example FR
diff --git a/test/plugin/fixtures/serialized-meta.fixture.ts b/test/plugin/fixtures/serialized-meta.fixture.ts
index 9bd46dec..35c514c1 100644
--- a/test/plugin/fixtures/serialized-meta.fixture.ts
+++ b/test/plugin/fixtures/serialized-meta.fixture.ts
@@ -138,6 +138,10 @@ export default async () => {
                 required: true,
                 enum: t['./cats/dto/pagination-query.dto'].LettersEnum
               },
+              externalEnum: {
+                required: true,
+                enum: require('@nestjs/common').HttpStatus
+              },
               state: {
                 required: false,
                 description: 'Available language in the application',