From 4ca7799e248ec9cb6f1fd90ecfffb89d91c1aae3 Mon Sep 17 00:00:00 2001
From: hee_c <ways2kim@gmail.com>
Date: Sun, 24 Nov 2024 13:15:18 +0900
Subject: [PATCH] feat: Add recursiveModuleScan option to scanner

The module scanning now correctly respects:
- Default scan depth (1 level) with `deepScanRoutes`
- Full recursive scanning with `recursiveModuleScan`
- Custom depth limits with `maxScanDepth`
---
 .../depth1-dogs/depth1-dogs.controller.ts     |  11 ++
 .../dogs/depth1-dogs/depth1-dogs.module.ts    |   9 ++
 .../depth2-dogs/depth2-dogs.controller.ts     |  11 ++
 .../dogs/depth2-dogs/depth2-dogs.module.ts    |   9 ++
 .../depth3-dogs/depth3-dogs.controller.ts     |  11 ++
 .../dogs/depth3-dogs/depth3-dogs.module.ts    |   7 +
 e2e/src/dogs/dogs.controller.ts               |  16 +++
 e2e/src/dogs/dogs.module.ts                   |   9 ++
 e2e/validate-schema.e2e-spec.ts               | 113 ++++++++++++++++
 .../swagger-document-options.interface.ts     |  16 +++
 lib/swagger-scanner.ts                        | 128 +++++++++++++++---
 11 files changed, 321 insertions(+), 19 deletions(-)
 create mode 100644 e2e/src/dogs/depth1-dogs/depth1-dogs.controller.ts
 create mode 100644 e2e/src/dogs/depth1-dogs/depth1-dogs.module.ts
 create mode 100644 e2e/src/dogs/depth2-dogs/depth2-dogs.controller.ts
 create mode 100644 e2e/src/dogs/depth2-dogs/depth2-dogs.module.ts
 create mode 100644 e2e/src/dogs/depth3-dogs/depth3-dogs.controller.ts
 create mode 100644 e2e/src/dogs/depth3-dogs/depth3-dogs.module.ts
 create mode 100644 e2e/src/dogs/dogs.controller.ts
 create mode 100644 e2e/src/dogs/dogs.module.ts

diff --git a/e2e/src/dogs/depth1-dogs/depth1-dogs.controller.ts b/e2e/src/dogs/depth1-dogs/depth1-dogs.controller.ts
new file mode 100644
index 000000000..484540f7f
--- /dev/null
+++ b/e2e/src/dogs/depth1-dogs/depth1-dogs.controller.ts
@@ -0,0 +1,11 @@
+import { Controller, Get } from '@nestjs/common';
+import { ApiTags } from '../../../../lib';
+
+@ApiTags('depth1-dogs')
+@Controller('depth1-dogs')
+export class Depth1DogsController {
+  @Get()
+  findAll() {
+    return 'Depth1 Dogs';
+  }
+}
diff --git a/e2e/src/dogs/depth1-dogs/depth1-dogs.module.ts b/e2e/src/dogs/depth1-dogs/depth1-dogs.module.ts
new file mode 100644
index 000000000..5274cc05f
--- /dev/null
+++ b/e2e/src/dogs/depth1-dogs/depth1-dogs.module.ts
@@ -0,0 +1,9 @@
+import { Module } from '@nestjs/common';
+import { Depth1DogsController } from './depth1-dogs.controller';
+import { Depth2DogsModule } from '../depth2-dogs/depth2-dogs.module';
+
+@Module({
+  imports: [Depth2DogsModule],
+  controllers: [Depth1DogsController]
+})
+export class Depth1DogsModule {}
diff --git a/e2e/src/dogs/depth2-dogs/depth2-dogs.controller.ts b/e2e/src/dogs/depth2-dogs/depth2-dogs.controller.ts
new file mode 100644
index 000000000..17a92d9ef
--- /dev/null
+++ b/e2e/src/dogs/depth2-dogs/depth2-dogs.controller.ts
@@ -0,0 +1,11 @@
+import { Controller, Get } from '@nestjs/common';
+import { ApiTags } from '../../../../lib';
+
+@ApiTags('depth2-dogs')
+@Controller('depth2-dogs')
+export class Depth2DogsController {
+  @Get()
+  findAll() {
+    return 'Depth2 Dogs';
+  }
+}
diff --git a/e2e/src/dogs/depth2-dogs/depth2-dogs.module.ts b/e2e/src/dogs/depth2-dogs/depth2-dogs.module.ts
new file mode 100644
index 000000000..87daf4275
--- /dev/null
+++ b/e2e/src/dogs/depth2-dogs/depth2-dogs.module.ts
@@ -0,0 +1,9 @@
+import { Module } from '@nestjs/common';
+import { Depth2DogsController } from './depth2-dogs.controller';
+import { Depth3DogsModule } from '../depth3-dogs/depth3-dogs.module';
+
+@Module({
+  imports: [Depth3DogsModule],
+  controllers: [Depth2DogsController]
+})
+export class Depth2DogsModule {}
diff --git a/e2e/src/dogs/depth3-dogs/depth3-dogs.controller.ts b/e2e/src/dogs/depth3-dogs/depth3-dogs.controller.ts
new file mode 100644
index 000000000..01546526f
--- /dev/null
+++ b/e2e/src/dogs/depth3-dogs/depth3-dogs.controller.ts
@@ -0,0 +1,11 @@
+import { Controller, Get } from '@nestjs/common';
+import { ApiTags } from '../../../../lib';
+
+@ApiTags('depth3-dogs')
+@Controller('depth3-dogs')
+export class Depth3DogsController {
+  @Get()
+  findAll() {
+    return 'Depth3 Dogs';
+  }
+}
diff --git a/e2e/src/dogs/depth3-dogs/depth3-dogs.module.ts b/e2e/src/dogs/depth3-dogs/depth3-dogs.module.ts
new file mode 100644
index 000000000..08b9f7975
--- /dev/null
+++ b/e2e/src/dogs/depth3-dogs/depth3-dogs.module.ts
@@ -0,0 +1,7 @@
+import { Module } from '@nestjs/common';
+import { Depth3DogsController } from './depth3-dogs.controller';
+
+@Module({
+  controllers: [Depth3DogsController]
+})
+export class Depth3DogsModule {}
diff --git a/e2e/src/dogs/dogs.controller.ts b/e2e/src/dogs/dogs.controller.ts
new file mode 100644
index 000000000..4c7f0f11e
--- /dev/null
+++ b/e2e/src/dogs/dogs.controller.ts
@@ -0,0 +1,16 @@
+import { Controller, Get } from '@nestjs/common';
+import { ApiTags } from '../../../lib';
+
+@ApiTags('dogs')
+@Controller('dogs')
+export class DogsController {
+  @Get()
+  findAll() {
+    return 'Dogs';
+  }
+
+  @Get('puppies')
+  findPuppies() {
+    return 'Puppies';
+  }
+}
diff --git a/e2e/src/dogs/dogs.module.ts b/e2e/src/dogs/dogs.module.ts
new file mode 100644
index 000000000..8cbf2be3a
--- /dev/null
+++ b/e2e/src/dogs/dogs.module.ts
@@ -0,0 +1,9 @@
+import { Module } from '@nestjs/common';
+import { DogsController } from './dogs.controller';
+import { Depth1DogsModule } from './depth1-dogs/depth1-dogs.module';
+
+@Module({
+  imports: [Depth1DogsModule],
+  controllers: [DogsController]
+})
+export class DogsModule {}
diff --git a/e2e/validate-schema.e2e-spec.ts b/e2e/validate-schema.e2e-spec.ts
index 0e397c3ba..80ecbdf36 100644
--- a/e2e/validate-schema.e2e-spec.ts
+++ b/e2e/validate-schema.e2e-spec.ts
@@ -13,6 +13,7 @@ import {
 import { ApplicationModule } from './src/app.module';
 import { Cat } from './src/cats/classes/cat.class';
 import { TagDto } from './src/cats/dto/tag.dto';
+import { DogsModule } from './src/dogs/dogs.module';
 
 describe('Validate OpenAPI schema', () => {
   let app: INestApplication;
@@ -215,3 +216,115 @@ describe('Validate OpenAPI schema', () => {
     });
   });
 });
+
+describe('Nested module scanning', () => {
+  let app: INestApplication;
+  let options: Omit<OpenAPIObject, 'paths'>;
+
+  beforeEach(async () => {
+    app = await NestFactory.create(DogsModule, {
+      logger: false
+    });
+    app.setGlobalPrefix('api/');
+
+    options = new DocumentBuilder()
+      .setTitle('Cats example')
+      .setDescription('The cats API description')
+      .setVersion('1.0')
+      .build();
+  });
+
+  describe('deepScanRoutes', () => {
+    it('should include only 1-depth nested routes when deepScanRoutes is true', async () => {
+      const document = SwaggerModule.createDocument(app, options, {
+        deepScanRoutes: true,
+        include: [DogsModule]
+      });
+
+      // Root module routes should be included
+      expect(document.paths['/api/dogs']).toBeDefined();
+      expect(document.paths['/api/dogs/puppies']).toBeDefined();
+
+      // First depth routes should be included
+      expect(document.paths['/api/depth1-dogs']).toBeDefined();
+
+      // Deeper routes should NOT be included
+      expect(document.paths['/api/depth2-dogs']).toBeUndefined();
+      expect(document.paths['/api/depth3-dogs']).toBeUndefined();
+
+      // Verify controller tags are correct
+      expect(document.paths['/api/dogs'].get.tags).toContain('dogs');
+      expect(document.paths['/api/depth1-dogs'].get.tags).toContain('depth1-dogs');
+    });
+  });
+
+  describe('recursiveModuleScan', () => {
+    it('should include all nested routes when recursiveModuleScan is enabled', async () => {
+      const document = SwaggerModule.createDocument(app, options, {
+        include: [DogsModule],
+        deepScanRoutes: true,
+        recursiveModuleScan: true
+      });
+
+      // All routes at every depth should be included
+      expect(document.paths['/api/dogs']).toBeDefined();
+      expect(document.paths['/api/dogs/puppies']).toBeDefined();
+      expect(document.paths['/api/depth1-dogs']).toBeDefined();
+      expect(document.paths['/api/depth2-dogs']).toBeDefined();
+      expect(document.paths['/api/depth3-dogs']).toBeDefined();
+
+      // Verify all controller tags are correct
+      expect(document.paths['/api/dogs'].get.tags).toContain('dogs');
+      expect(document.paths['/api/depth1-dogs'].get.tags).toContain('depth1-dogs');
+      expect(document.paths['/api/depth2-dogs'].get.tags).toContain('depth2-dogs');
+      expect(document.paths['/api/depth3-dogs'].get.tags).toContain('depth3-dogs');
+    });
+  });
+
+  describe('maxScanDepth', () => {
+    it('should limit scanning depth when maxScanDepth is set', async () => {
+      const document = SwaggerModule.createDocument(app, options, {
+        include: [DogsModule],
+        deepScanRoutes: true,
+        recursiveModuleScan: true,
+        maxScanDepth: 1
+      });
+
+      // Routes up to depth 1 should be included
+      expect(document.paths['/api/dogs']).toBeDefined();
+      expect(document.paths['/api/dogs/puppies']).toBeDefined();
+      expect(document.paths['/api/depth1-dogs']).toBeDefined();
+
+      // Routes beyond depth 1 should NOT be included
+      expect(document.paths['/api/depth2-dogs']).toBeUndefined();
+      expect(document.paths['/api/depth3-dogs']).toBeUndefined();
+
+      // Verify included controller tags are correct
+      expect(document.paths['/api/dogs'].get.tags).toContain('dogs');
+      expect(document.paths['/api/depth1-dogs'].get.tags).toContain('depth1-dogs');
+    });
+
+    it('should include routes up to specified maxScanDepth', async () => {
+      const document = SwaggerModule.createDocument(app, options, {
+        include: [DogsModule],
+        deepScanRoutes: true,
+        recursiveModuleScan: true,
+        maxScanDepth: 2
+      });
+
+      // Routes up to depth 2 should be included
+      expect(document.paths['/api/dogs']).toBeDefined();
+      expect(document.paths['/api/dogs/puppies']).toBeDefined();
+      expect(document.paths['/api/depth1-dogs']).toBeDefined();
+      expect(document.paths['/api/depth2-dogs']).toBeDefined();
+
+      // Routes beyond depth 2 should NOT be included
+      expect(document.paths['/api/depth3-dogs']).toBeUndefined();
+
+      // Verify included controller tags are correct
+      expect(document.paths['/api/dogs'].get.tags).toContain('dogs');
+      expect(document.paths['/api/depth1-dogs'].get.tags).toContain('depth1-dogs');
+      expect(document.paths['/api/depth2-dogs'].get.tags).toContain('depth2-dogs');
+    });
+  });
+});
diff --git a/lib/interfaces/swagger-document-options.interface.ts b/lib/interfaces/swagger-document-options.interface.ts
index bf9e7045b..c184ad02c 100644
--- a/lib/interfaces/swagger-document-options.interface.ts
+++ b/lib/interfaces/swagger-document-options.interface.ts
@@ -53,4 +53,20 @@ export interface SwaggerDocumentOptions {
    * @default true
    */
   autoTagControllers?: boolean;
+
+  /**
+   * If `true`, swagger will recursively scan all nested imported by `include` modules.
+   * When enabled, this overrides the default behavior of `deepScanRoutes` to scan all depths.
+   *
+   * @default false
+   */
+  recursiveModuleScan?: boolean;
+
+  /**
+   * Maximum depth level for recursive module scanning.
+   * Only applies when `recursiveModuleScan` is `true`.
+   *
+   * @default Infinity
+   */
+  maxScanDepth?: number;
 }
diff --git a/lib/swagger-scanner.ts b/lib/swagger-scanner.ts
index b7ee197d2..695709e9c 100644
--- a/lib/swagger-scanner.ts
+++ b/lib/swagger-scanner.ts
@@ -41,7 +41,9 @@ export class SwaggerScanner {
       ignoreGlobalPrefix = false,
       operationIdFactory,
       linkNameFactory,
-      autoTagControllers = true
+      autoTagControllers = true,
+      recursiveModuleScan = false,
+      maxScanDepth = Infinity
     } = options;
 
     const container = (app as any).container as NestContainer;
@@ -55,34 +57,55 @@ export class SwaggerScanner {
       ? stripLastSlash(getGlobalPrefix(app))
       : '';
 
+    const processedModules = new Set<string>();
+
     const denormalizedPaths = modules.map(
       ({ controllers, metatype, imports }) => {
         let result: ModuleRoute[] = [];
 
         if (deepScanRoutes) {
-          // Only load submodules routes if explicitly enabled
-          const isGlobal = (module: Type<any>) =>
-            !container.isGlobalModule(module);
-
-          Array.from(imports.values())
-            .filter(isGlobal as any)
-            .forEach(({ metatype, controllers }) => {
-              const modulePath = this.getModulePathMetadata(
+          if (!recursiveModuleScan) {
+            // Only load submodules routes if explicitly enabled
+            const isGlobal = (module: Type<any>) =>
+              !container.isGlobalModule(module);
+
+            Array.from(imports.values())
+              .filter(isGlobal as any)
+              .forEach(({ metatype, controllers }) => {
+                const modulePath = this.getModulePathMetadata(
+                  container,
+                  metatype
+                );
+                result = result.concat(
+                  this.scanModuleControllers(
+                    controllers,
+                    modulePath,
+                    globalPrefix,
+                    internalConfigRef,
+                    operationIdFactory,
+                    linkNameFactory,
+                    autoTagControllers
+                  )
+                );
+              });
+          } else {
+            result = result.concat(
+              this.scanModuleImportsRecursively(
+                imports,
                 container,
-                metatype
-              );
-              result = result.concat(
-                this.scanModuleControllers(
-                  controllers,
-                  modulePath,
+                0,
+                maxScanDepth,
+                processedModules,
+                {
                   globalPrefix,
                   internalConfigRef,
                   operationIdFactory,
                   linkNameFactory,
-                  autoTagControllers
-                )
-              );
-            });
+                  autoTagControllers,
+                }
+              )
+            );
+          }
         }
         const modulePath = this.getModulePathMetadata(container, metatype);
         result = result.concat(
@@ -170,4 +193,71 @@ export class SwaggerScanner {
     );
     return modulePath ?? Reflect.getMetadata(MODULE_PATH, metatype);
   }
+
+  private scanModuleImportsRecursively(
+    imports: Set<Module>,
+    container: NestContainer,
+    currentDepth: number,
+    maxDepth: number | undefined,
+    processedModules: Set<string>,
+    options: {
+      globalPrefix: string;
+      internalConfigRef: ApplicationConfig;
+      operationIdFactory?: OperationIdFactory;
+      linkNameFactory?: (
+        controllerKey: string,
+        methodKey: string,
+        fieldKey: string
+      ) => string;
+      autoTagControllers?: boolean;
+    }
+  ): ModuleRoute[] {
+    let result: ModuleRoute[] = [];
+
+    for (const { metatype, controllers, imports: subImports } of imports.values()) {
+      // Skip if module has already been processed
+      const moduleId = this.getModuleId(metatype);
+      if (processedModules.has(moduleId) || container.isGlobalModule(metatype) || (maxDepth !== undefined && currentDepth > maxDepth)) {
+        continue;
+      }
+      processedModules.add(moduleId);
+
+      // Scan current module's controllers
+      const modulePath = this.getModulePathMetadata(container, metatype);
+      result = result.concat(
+        this.scanModuleControllers(
+          controllers,
+          modulePath,
+          options.globalPrefix,
+          options.internalConfigRef,
+          options.operationIdFactory,
+          options.linkNameFactory,
+          options.autoTagControllers
+        )
+      );
+
+      // Process sub-imports if any
+      if (subImports.size > 0) {
+        const nextDepth = currentDepth + 1;
+        if (maxDepth === undefined || nextDepth < maxDepth) {
+          result = result.concat(
+            this.scanModuleImportsRecursively(
+              subImports,
+              container,
+              nextDepth,
+              maxDepth,
+              processedModules,
+              options
+            )
+          );
+        }
+      }
+    }
+
+    return result;
+  }
+
+  private getModuleId(metatype: Type<any>): string {
+    return metatype.name || Math.random().toString(36).substring(2);
+  }
 }