diff --git a/integration/hello-world/e2e/router-module.spec.ts b/integration/hello-world/e2e/router-module.spec.ts index cc576e261e5..59fdc7ebcd2 100644 --- a/integration/hello-world/e2e/router-module.spec.ts +++ b/integration/hello-world/e2e/router-module.spec.ts @@ -48,7 +48,10 @@ describe('RouterModule', () => { }, ]; const routes2: Routes = [ - { path: 'v1', children: [AuthModule, PaymentsModule, NoSlashModule] }, + { + path: ['v1', 'v2'], + children: [AuthModule, PaymentsModule, NoSlashModule], + }, ]; @Module({ @@ -93,6 +96,12 @@ describe('RouterModule', () => { .expect(200, 'NoSlashController'); }); + it('should hit the "NoSlashController on v2"', async () => { + return request(app.getHttpServer()) + .get('/v2/no-slash-controller') + .expect(200, 'NoSlashController'); + }); + afterEach(async () => { await app.close(); }); diff --git a/packages/common/utils/shared.utils.ts b/packages/common/utils/shared.utils.ts index e936c8d2448..2a2b9c9b7e3 100644 --- a/packages/common/utils/shared.utils.ts +++ b/packages/common/utils/shared.utils.ts @@ -30,6 +30,13 @@ export const addLeadingSlash = (path?: string): string => : path : ''; +export const normalizePaths = (paths: string | string[]): string[] => { + if (Array.isArray(paths)) { + return paths.map(path => normalizePath(path)); + } + return [normalizePath(paths)]; +}; + export const normalizePath = (path?: string): string => path ? path.startsWith('/') diff --git a/packages/core/middleware/routes-mapper.ts b/packages/core/middleware/routes-mapper.ts index d5155aace63..f4367bc7c12 100644 --- a/packages/core/middleware/routes-mapper.ts +++ b/packages/core/middleware/routes-mapper.ts @@ -91,25 +91,31 @@ export class RoutesMapper { const toRouteInfo = (item: RouteDefinition, prefix: string) => item.path?.flatMap(p => { - let endpointPath = modulePath ?? ''; - endpointPath += this.normalizeGlobalPath(prefix) + addLeadingSlash(p); - - const routeInfo: RouteInfo = { - path: endpointPath, - method: item.requestMethod, - }; - const version = item.version ?? controllerVersion; - if (version && versioningConfig) { - if (typeof version !== 'string' && Array.isArray(version)) { - return version.map(v => ({ - ...routeInfo, - version: toUndefinedIfNeural(v), - })); + const endpointPaths = modulePath ?? ['']; + + return endpointPaths.flatMap(endpointPath => { + const fullPath = + endpointPath + + this.normalizeGlobalPath(prefix) + + addLeadingSlash(p); + + const routeInfo: RouteInfo = { + path: fullPath, + method: item.requestMethod, + }; + const version = item.version ?? controllerVersion; + if (version && versioningConfig) { + if (typeof version !== 'string' && Array.isArray(version)) { + return version.map(v => ({ + ...routeInfo, + version: toUndefinedIfNeural(v), + })); + } + routeInfo.version = toUndefinedIfNeural(version); } - routeInfo.version = toUndefinedIfNeural(version); - } - return routeInfo; + return routeInfo; + }); }); return ([] as string[]) @@ -158,7 +164,7 @@ export class RoutesMapper { private getModulePath( metatype: Type | undefined, - ): string | undefined { + ): string[] | undefined { if (!metatype) { return; } diff --git a/packages/core/router/interfaces/route-path-metadata.interface.ts b/packages/core/router/interfaces/route-path-metadata.interface.ts index fb8a769a610..a04edd9bdb8 100644 --- a/packages/core/router/interfaces/route-path-metadata.interface.ts +++ b/packages/core/router/interfaces/route-path-metadata.interface.ts @@ -18,9 +18,9 @@ export interface RoutePathMetadata { globalPrefix?: string; /** - * Module-level path registered through the "RouterModule". + * Module-level path or paths registered through the "RouterModule". */ - modulePath?: string; + modulePath?: string | string[]; /** * Controller-level version (e.g., @Controller({ version: '1.0' }) = "1.0"). diff --git a/packages/core/router/interfaces/routes.interface.ts b/packages/core/router/interfaces/routes.interface.ts index 0193c686900..37029954450 100644 --- a/packages/core/router/interfaces/routes.interface.ts +++ b/packages/core/router/interfaces/routes.interface.ts @@ -1,7 +1,7 @@ import { Type } from '@nestjs/common'; export interface RouteTree { - path: string; + path: string | string[]; module?: Type; children?: (RouteTree | Type)[]; } diff --git a/packages/core/router/router-module.ts b/packages/core/router/router-module.ts index 72162dcdbf5..c095cdf9c05 100644 --- a/packages/core/router/router-module.ts +++ b/packages/core/router/router-module.ts @@ -1,6 +1,6 @@ import { DynamicModule, Inject, Module, Type } from '@nestjs/common'; import { MODULE_PATH } from '@nestjs/common/constants'; -import { normalizePath } from '@nestjs/common/utils/shared.utils'; +import { normalizePaths } from '@nestjs/common/utils/shared.utils'; import { Module as ModuleClass } from '../injector/module'; import { ModulesContainer } from '../injector/modules-container'; import { Routes, RouteTree } from './interfaces'; @@ -58,15 +58,15 @@ export class RouterModule { private initialize() { const flattenedRoutes = flattenRoutePaths(this.routes); flattenedRoutes.forEach(route => { - const modulePath = normalizePath(route.path); - this.registerModulePathMetadata(route.module, modulePath); + const modulePaths = normalizePaths(route.path); + this.registerModulePathMetadata(route.module, modulePaths); this.updateTargetModulesCache(route.module); }); } private registerModulePathMetadata( moduleCtor: Type, - modulePath: string, + modulePath: string | string[], ) { Reflect.defineMetadata( MODULE_PATH + this.modulesContainer.applicationId, diff --git a/packages/core/router/routes-resolver.ts b/packages/core/router/routes-resolver.ts index 451b855df96..3e49341756c 100644 --- a/packages/core/router/routes-resolver.ts +++ b/packages/core/router/routes-resolver.ts @@ -89,7 +89,7 @@ export class RoutesResolver implements Resolver { routes: Map>, moduleName: string, globalPrefix: string, - modulePath: string, + modulePath: string | string[], applicationRef: HttpServer, ) { routes.forEach(instanceWrapper => { @@ -207,7 +207,9 @@ export class RoutesResolver implements Resolver { ); } - private getModulePathMetadata(metatype: Type): string | undefined { + private getModulePathMetadata( + metatype: Type, + ): string | string[] | undefined { const modulesContainer = this.container.getModules(); const modulePath = Reflect.getMetadata( MODULE_PATH + modulesContainer.applicationId, diff --git a/packages/core/router/utils/flatten-route-paths.util.ts b/packages/core/router/utils/flatten-route-paths.util.ts index 473e19b8192..02a7703437d 100644 --- a/packages/core/router/utils/flatten-route-paths.util.ts +++ b/packages/core/router/utils/flatten-route-paths.util.ts @@ -5,7 +5,7 @@ import { Routes } from '../interfaces/routes.interface'; export function flattenRoutePaths(routes: Routes) { const result: Array<{ module: Type; - path: string; + path: string | string[]; }> = []; routes.forEach(item => { if (item.module && item.path) { @@ -14,10 +14,30 @@ export function flattenRoutePaths(routes: Routes) { if (item.children) { const childrenRef = item.children as Routes; childrenRef.forEach(child => { - if (!isString(child) && isString(child.path)) { - child.path = normalizePath( - normalizePath(item.path) + normalizePath(child.path), - ); + if ( + !isString(child) && + (isString(child.path) || Array.isArray(child.path)) + ) { + const normalizedChildPaths: string[] = []; + const parentPaths = Array.isArray(item.path) + ? item.path + : [item.path]; + for (const parentPath of parentPaths) { + const childPaths = Array.isArray(child.path) + ? child.path + : [child.path]; + for (const childPath of childPaths) { + normalizedChildPaths.push( + normalizePath( + normalizePath(parentPath) + normalizePath(childPath), + ), + ); + } + } + child.path = + normalizedChildPaths.length === 1 + ? normalizedChildPaths[0] + : normalizedChildPaths; } else { result.push({ path: item.path, module: child as any as Type }); } diff --git a/packages/core/test/middleware/routes-mapper.spec.ts b/packages/core/test/middleware/routes-mapper.spec.ts index e64a17d473d..e894dee22da 100644 --- a/packages/core/test/middleware/routes-mapper.spec.ts +++ b/packages/core/test/middleware/routes-mapper.spec.ts @@ -127,4 +127,56 @@ describe('RoutesMapper', () => { ], ); }); + + @Controller('test') + class TestControllerWithMultipleModulePaths { + @Get('hello') + hello() { + return 'Hello'; + } + + @Version('2') + @Get('versioned') + versioned() { + return 'Versioned'; + } + } + + it('should map a controller with multiple module paths to the corresponding route info objects', () => { + // Simulate a controller that is registered under multiple module paths + const mockModuleRef = { + metatype: class MockModule {}, + controllers: new Map(), + } as any; + + mockModuleRef.controllers.set(TestControllerWithMultipleModulePaths, {}); + + // Mock the getModulePath to return multiple paths + const originalGetModulePath = (mapper as any).getModulePath; + (mapper as any).getModulePath = () => ['/api/v1', '/api/v2']; + + try { + const result = mapper.mapRouteToRouteInfo( + TestControllerWithMultipleModulePaths, + ); + + expect(result).to.deep.equal([ + { path: '/api/v1/test/hello', method: RequestMethod.GET }, + { path: '/api/v2/test/hello', method: RequestMethod.GET }, + { + path: '/api/v1/test/versioned', + method: RequestMethod.GET, + version: '2', + }, + { + path: '/api/v2/test/versioned', + method: RequestMethod.GET, + version: '2', + }, + ]); + } finally { + // Restore original method + (mapper as any).getModulePath = originalGetModulePath; + } + }); }); diff --git a/packages/core/test/router/utils/flat-routes.spec.ts b/packages/core/test/router/utils/flat-routes.spec.ts index 8900afe541e..4464a595bfa 100644 --- a/packages/core/test/router/utils/flat-routes.spec.ts +++ b/packages/core/test/router/utils/flat-routes.spec.ts @@ -21,6 +21,12 @@ describe('flattenRoutePaths', () => { @Module({}) class ChildModule6 {} @Module({}) + class ChildModule7 {} + @Module({}) + class ChildModule8 {} + @Module({}) + class ChildModule9 {} + @Module({}) class ChildParentPathModule {} @Module({}) class ParentChildModule {} @@ -95,6 +101,23 @@ describe('flattenRoutePaths', () => { }, ], }, + { + path: ['/child5', '/child6', '/child7'], + children: [ + { + path: '', + module: ChildModule7, + }, + { + path: '/child8', + module: ChildModule8, + }, + { + path: ['/child9', '/child10'], + module: ChildModule9, + }, + ], + }, ], }, { path: '/v1', children: [AuthModule, CatsModule, DogsModule] }, @@ -119,6 +142,29 @@ describe('flattenRoutePaths', () => { { path: '/parent/child3', module: ChildModule5 }, { path: '/parent/child3/child', module: ChildParentPathModule }, { path: '/parent/child4', module: ChildModule6 }, + { + path: ['/parent/child5', '/parent/child6', '/parent/child7'], + module: ChildModule7, + }, + { + path: [ + '/parent/child5/child8', + '/parent/child6/child8', + '/parent/child7/child8', + ], + module: ChildModule8, + }, + { + path: [ + '/parent/child5/child9', + '/parent/child5/child10', + '/parent/child6/child9', + '/parent/child6/child10', + '/parent/child7/child9', + '/parent/child7/child10', + ], + module: ChildModule9, + }, { path: '/v1', module: AuthModule }, { path: '/v1', module: CatsModule }, { path: '/v1', module: DogsModule }, @@ -127,6 +173,8 @@ describe('flattenRoutePaths', () => { { path: '/v3', module: AuthModule3 }, { path: '/v3', module: CatsModule3 }, ]; - expect(flattenRoutePaths(routes)).to.be.eql(expectedRoutes); + const result = flattenRoutePaths(routes); + console.log(result); + expect(result).to.be.eql(expectedRoutes); }); }); diff --git a/sample/01-cats-app/src/core/interceptors/transform.interceptor.ts b/sample/01-cats-app/src/core/interceptors/transform.interceptor.ts index c84c6428f5e..b08f31f221d 100644 --- a/sample/01-cats-app/src/core/interceptors/transform.interceptor.ts +++ b/sample/01-cats-app/src/core/interceptors/transform.interceptor.ts @@ -12,9 +12,10 @@ export interface Response { } @Injectable() -export class TransformInterceptor - implements NestInterceptor> -{ +export class TransformInterceptor implements NestInterceptor< + T, + Response +> { intercept( context: ExecutionContext, next: CallHandler, diff --git a/sample/10-fastify/src/core/interceptors/transform.interceptor.ts b/sample/10-fastify/src/core/interceptors/transform.interceptor.ts index c84c6428f5e..b08f31f221d 100644 --- a/sample/10-fastify/src/core/interceptors/transform.interceptor.ts +++ b/sample/10-fastify/src/core/interceptors/transform.interceptor.ts @@ -12,9 +12,10 @@ export interface Response { } @Injectable() -export class TransformInterceptor - implements NestInterceptor> -{ +export class TransformInterceptor implements NestInterceptor< + T, + Response +> { intercept( context: ExecutionContext, next: CallHandler,