Skip to content

Commit 9ece4d5

Browse files
committed
feat: Enhance included module scanning depth control
The module scanning now correctly respects: - Default scan depth (1 level) with `deepScanRoutes` - Full recursive scanning with `recursiveModuleScan` - Custom depth limits with `maxScanDepth`
1 parent a0712bb commit 9ece4d5

11 files changed

+321
-19
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { Controller, Get } from '@nestjs/common';
2+
import { ApiTags } from '../../../../lib';
3+
4+
@ApiTags('depth1-dogs')
5+
@Controller('depth1-dogs')
6+
export class Depth1DogsController {
7+
@Get()
8+
findAll() {
9+
return 'Depth1 Dogs';
10+
}
11+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { Module } from '@nestjs/common';
2+
import { Depth1DogsController } from './depth1-dogs.controller';
3+
import { Depth2DogsModule } from '../depth2-dogs/depth2-dogs.module';
4+
5+
@Module({
6+
imports: [Depth2DogsModule],
7+
controllers: [Depth1DogsController]
8+
})
9+
export class Depth1DogsModule {}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { Controller, Get } from '@nestjs/common';
2+
import { ApiTags } from '../../../../lib';
3+
4+
@ApiTags('depth2-dogs')
5+
@Controller('depth2-dogs')
6+
export class Depth2DogsController {
7+
@Get()
8+
findAll() {
9+
return 'Depth2 Dogs';
10+
}
11+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { Module } from '@nestjs/common';
2+
import { Depth2DogsController } from './depth2-dogs.controller';
3+
import { Depth3DogsModule } from '../depth3-dogs/depth3-dogs.module';
4+
5+
@Module({
6+
imports: [Depth3DogsModule],
7+
controllers: [Depth2DogsController]
8+
})
9+
export class Depth2DogsModule {}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { Controller, Get } from '@nestjs/common';
2+
import { ApiTags } from '../../../../lib';
3+
4+
@ApiTags('depth3-dogs')
5+
@Controller('depth3-dogs')
6+
export class Depth3DogsController {
7+
@Get()
8+
findAll() {
9+
return 'Depth3 Dogs';
10+
}
11+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { Module } from '@nestjs/common';
2+
import { Depth3DogsController } from './depth3-dogs.controller';
3+
4+
@Module({
5+
controllers: [Depth3DogsController]
6+
})
7+
export class Depth3DogsModule {}

e2e/src/dogs/dogs.controller.ts

+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { Controller, Get } from '@nestjs/common';
2+
import { ApiTags } from '../../../lib';
3+
4+
@ApiTags('dogs')
5+
@Controller('dogs')
6+
export class DogsController {
7+
@Get()
8+
findAll() {
9+
return 'Dogs';
10+
}
11+
12+
@Get('puppies')
13+
findPuppies() {
14+
return 'Puppies';
15+
}
16+
}

e2e/src/dogs/dogs.module.ts

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { Module } from '@nestjs/common';
2+
import { DogsController } from './dogs.controller';
3+
import { Depth1DogsModule } from './depth1-dogs/depth1-dogs.module';
4+
5+
@Module({
6+
imports: [Depth1DogsModule],
7+
controllers: [DogsController]
8+
})
9+
export class DogsModule {}

e2e/validate-schema.e2e-spec.ts

+113
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
import { ApplicationModule } from './src/app.module';
1414
import { Cat } from './src/cats/classes/cat.class';
1515
import { TagDto } from './src/cats/dto/tag.dto';
16+
import { DogsModule } from './src/dogs/dogs.module';
1617

1718
describe('Validate OpenAPI schema', () => {
1819
let app: INestApplication;
@@ -215,3 +216,115 @@ describe('Validate OpenAPI schema', () => {
215216
});
216217
});
217218
});
219+
220+
describe('Nested module scanning', () => {
221+
let app: INestApplication;
222+
let options: Omit<OpenAPIObject, 'paths'>;
223+
224+
beforeEach(async () => {
225+
app = await NestFactory.create(DogsModule, {
226+
logger: false
227+
});
228+
app.setGlobalPrefix('api/');
229+
230+
options = new DocumentBuilder()
231+
.setTitle('Cats example')
232+
.setDescription('The cats API description')
233+
.setVersion('1.0')
234+
.build();
235+
});
236+
237+
describe('deepScanRoutes', () => {
238+
it('should include only 1-depth nested routes when deepScanRoutes is true', async () => {
239+
const document = SwaggerModule.createDocument(app, options, {
240+
deepScanRoutes: true,
241+
include: [DogsModule]
242+
});
243+
244+
// Root module routes should be included
245+
expect(document.paths['/api/dogs']).toBeDefined();
246+
expect(document.paths['/api/dogs/puppies']).toBeDefined();
247+
248+
// First depth routes should be included
249+
expect(document.paths['/api/depth1-dogs']).toBeDefined();
250+
251+
// Deeper routes should NOT be included
252+
expect(document.paths['/api/depth2-dogs']).toBeUndefined();
253+
expect(document.paths['/api/depth3-dogs']).toBeUndefined();
254+
255+
// Verify controller tags are correct
256+
expect(document.paths['/api/dogs'].get.tags).toContain('dogs');
257+
expect(document.paths['/api/depth1-dogs'].get.tags).toContain('depth1-dogs');
258+
});
259+
});
260+
261+
describe('recursiveModuleScan', () => {
262+
it('should include all nested routes when recursiveModuleScan is enabled', async () => {
263+
const document = SwaggerModule.createDocument(app, options, {
264+
include: [DogsModule],
265+
deepScanRoutes: true,
266+
recursiveModuleScan: true
267+
});
268+
269+
// All routes at every depth should be included
270+
expect(document.paths['/api/dogs']).toBeDefined();
271+
expect(document.paths['/api/dogs/puppies']).toBeDefined();
272+
expect(document.paths['/api/depth1-dogs']).toBeDefined();
273+
expect(document.paths['/api/depth2-dogs']).toBeDefined();
274+
expect(document.paths['/api/depth3-dogs']).toBeDefined();
275+
276+
// Verify all controller tags are correct
277+
expect(document.paths['/api/dogs'].get.tags).toContain('dogs');
278+
expect(document.paths['/api/depth1-dogs'].get.tags).toContain('depth1-dogs');
279+
expect(document.paths['/api/depth2-dogs'].get.tags).toContain('depth2-dogs');
280+
expect(document.paths['/api/depth3-dogs'].get.tags).toContain('depth3-dogs');
281+
});
282+
});
283+
284+
describe('maxScanDepth', () => {
285+
it('should limit scanning depth when maxScanDepth is set', async () => {
286+
const document = SwaggerModule.createDocument(app, options, {
287+
include: [DogsModule],
288+
deepScanRoutes: true,
289+
recursiveModuleScan: true,
290+
maxScanDepth: 1
291+
});
292+
293+
// Routes up to depth 1 should be included
294+
expect(document.paths['/api/dogs']).toBeDefined();
295+
expect(document.paths['/api/dogs/puppies']).toBeDefined();
296+
expect(document.paths['/api/depth1-dogs']).toBeDefined();
297+
298+
// Routes beyond depth 1 should NOT be included
299+
expect(document.paths['/api/depth2-dogs']).toBeUndefined();
300+
expect(document.paths['/api/depth3-dogs']).toBeUndefined();
301+
302+
// Verify included controller tags are correct
303+
expect(document.paths['/api/dogs'].get.tags).toContain('dogs');
304+
expect(document.paths['/api/depth1-dogs'].get.tags).toContain('depth1-dogs');
305+
});
306+
307+
it('should include routes up to specified maxScanDepth', async () => {
308+
const document = SwaggerModule.createDocument(app, options, {
309+
include: [DogsModule],
310+
deepScanRoutes: true,
311+
recursiveModuleScan: true,
312+
maxScanDepth: 2
313+
});
314+
315+
// Routes up to depth 2 should be included
316+
expect(document.paths['/api/dogs']).toBeDefined();
317+
expect(document.paths['/api/dogs/puppies']).toBeDefined();
318+
expect(document.paths['/api/depth1-dogs']).toBeDefined();
319+
expect(document.paths['/api/depth2-dogs']).toBeDefined();
320+
321+
// Routes beyond depth 2 should NOT be included
322+
expect(document.paths['/api/depth3-dogs']).toBeUndefined();
323+
324+
// Verify included controller tags are correct
325+
expect(document.paths['/api/dogs'].get.tags).toContain('dogs');
326+
expect(document.paths['/api/depth1-dogs'].get.tags).toContain('depth1-dogs');
327+
expect(document.paths['/api/depth2-dogs'].get.tags).toContain('depth2-dogs');
328+
});
329+
});
330+
});

lib/interfaces/swagger-document-options.interface.ts

+16
Original file line numberDiff line numberDiff line change
@@ -53,4 +53,20 @@ export interface SwaggerDocumentOptions {
5353
* @default true
5454
*/
5555
autoTagControllers?: boolean;
56+
57+
/**
58+
* If `true`, swagger will recursively scan all nested imported by `include` modules.
59+
* When enabled, this overrides the default behavior of `deepScanRoutes` to scan all depths.
60+
*
61+
* @default false
62+
*/
63+
recursiveModuleScan?: boolean;
64+
65+
/**
66+
* Maximum depth level for recursive module scanning.
67+
* Only applies when `recursiveModuleScan` is `true`.
68+
*
69+
* @default Infinity
70+
*/
71+
maxScanDepth?: number;
5672
}

lib/swagger-scanner.ts

+109-19
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,9 @@ export class SwaggerScanner {
4141
ignoreGlobalPrefix = false,
4242
operationIdFactory,
4343
linkNameFactory,
44-
autoTagControllers = true
44+
autoTagControllers = true,
45+
recursiveModuleScan = false,
46+
maxScanDepth = Infinity
4547
} = options;
4648

4749
const container = (app as any).container as NestContainer;
@@ -55,34 +57,55 @@ export class SwaggerScanner {
5557
? stripLastSlash(getGlobalPrefix(app))
5658
: '';
5759

60+
const processedModules = new Set<string>();
61+
5862
const denormalizedPaths = modules.map(
5963
({ controllers, metatype, imports }) => {
6064
let result: ModuleRoute[] = [];
6165

6266
if (deepScanRoutes) {
63-
// Only load submodules routes if explicitly enabled
64-
const isGlobal = (module: Type<any>) =>
65-
!container.isGlobalModule(module);
66-
67-
Array.from(imports.values())
68-
.filter(isGlobal as any)
69-
.forEach(({ metatype, controllers }) => {
70-
const modulePath = this.getModulePathMetadata(
67+
if (!recursiveModuleScan) {
68+
// Only load submodules routes if explicitly enabled
69+
const isGlobal = (module: Type<any>) =>
70+
!container.isGlobalModule(module);
71+
72+
Array.from(imports.values())
73+
.filter(isGlobal as any)
74+
.forEach(({ metatype, controllers }) => {
75+
const modulePath = this.getModulePathMetadata(
76+
container,
77+
metatype
78+
);
79+
result = result.concat(
80+
this.scanModuleControllers(
81+
controllers,
82+
modulePath,
83+
globalPrefix,
84+
internalConfigRef,
85+
operationIdFactory,
86+
linkNameFactory,
87+
autoTagControllers
88+
)
89+
);
90+
});
91+
} else {
92+
result = result.concat(
93+
this.scanModuleImportsRecursively(
94+
imports,
7195
container,
72-
metatype
73-
);
74-
result = result.concat(
75-
this.scanModuleControllers(
76-
controllers,
77-
modulePath,
96+
0,
97+
maxScanDepth,
98+
processedModules,
99+
{
78100
globalPrefix,
79101
internalConfigRef,
80102
operationIdFactory,
81103
linkNameFactory,
82-
autoTagControllers
83-
)
84-
);
85-
});
104+
autoTagControllers,
105+
}
106+
)
107+
);
108+
}
86109
}
87110
const modulePath = this.getModulePathMetadata(container, metatype);
88111
result = result.concat(
@@ -170,4 +193,71 @@ export class SwaggerScanner {
170193
);
171194
return modulePath ?? Reflect.getMetadata(MODULE_PATH, metatype);
172195
}
196+
197+
private scanModuleImportsRecursively(
198+
imports: Set<Module>,
199+
container: NestContainer,
200+
currentDepth: number,
201+
maxDepth: number | undefined,
202+
processedModules: Set<string>,
203+
options: {
204+
globalPrefix: string;
205+
internalConfigRef: ApplicationConfig;
206+
operationIdFactory?: OperationIdFactory;
207+
linkNameFactory?: (
208+
controllerKey: string,
209+
methodKey: string,
210+
fieldKey: string
211+
) => string;
212+
autoTagControllers?: boolean;
213+
}
214+
): ModuleRoute[] {
215+
let result: ModuleRoute[] = [];
216+
217+
for (const { metatype, controllers, imports: subImports } of imports.values()) {
218+
// Skip if module has already been processed
219+
const moduleId = this.getModuleId(metatype);
220+
if (processedModules.has(moduleId) || container.isGlobalModule(metatype) || (maxDepth !== undefined && currentDepth > maxDepth)) {
221+
continue;
222+
}
223+
processedModules.add(moduleId);
224+
225+
// Scan current module's controllers
226+
const modulePath = this.getModulePathMetadata(container, metatype);
227+
result = result.concat(
228+
this.scanModuleControllers(
229+
controllers,
230+
modulePath,
231+
options.globalPrefix,
232+
options.internalConfigRef,
233+
options.operationIdFactory,
234+
options.linkNameFactory,
235+
options.autoTagControllers
236+
)
237+
);
238+
239+
// Process sub-imports if any
240+
if (subImports.size > 0) {
241+
const nextDepth = currentDepth + 1;
242+
if (maxDepth === undefined || nextDepth < maxDepth) {
243+
result = result.concat(
244+
this.scanModuleImportsRecursively(
245+
subImports,
246+
container,
247+
nextDepth,
248+
maxDepth,
249+
processedModules,
250+
options
251+
)
252+
);
253+
}
254+
}
255+
}
256+
257+
return result;
258+
}
259+
260+
private getModuleId(metatype: Type<any>): string {
261+
return metatype.name || Math.random().toString(36).substring(2);
262+
}
173263
}

0 commit comments

Comments
 (0)