From 4ca7799e248ec9cb6f1fd90ecfffb89d91c1aae3 Mon Sep 17 00:00:00 2001 From: hee_c 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; + + 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(); + const denormalizedPaths = modules.map( ({ controllers, metatype, imports }) => { let result: ModuleRoute[] = []; if (deepScanRoutes) { - // Only load submodules routes if explicitly enabled - const isGlobal = (module: Type) => - !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) => + !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, + container: NestContainer, + currentDepth: number, + maxDepth: number | undefined, + processedModules: Set, + 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): string { + return metatype.name || Math.random().toString(36).substring(2); + } }