diff --git a/packages/common/interfaces/nest-application.interface.ts b/packages/common/interfaces/nest-application.interface.ts index 94bf44a925f..cb342d0ea15 100644 --- a/packages/common/interfaces/nest-application.interface.ts +++ b/packages/common/interfaces/nest-application.interface.ts @@ -69,11 +69,15 @@ export interface INestApplication< /** * Registers a prefix for every HTTP route path. * - * @param {string} prefix The prefix for every HTTP route path (for example `/v1/api`) + * @param {string | string[]} prefix The prefix for every HTTP route path (for example `/v1/api`). + * Can be an array of prefixes to register multiple prefixes (for example `['api', 'v1']`). * @param {GlobalPrefixOptions} options Global prefix options object * @returns {this} */ - setGlobalPrefix(prefix: string, options?: GlobalPrefixOptions): this; + setGlobalPrefix( + prefix: string | string[], + options?: GlobalPrefixOptions, + ): this; /** * Register Ws Adapter which will be used inside Gateways. diff --git a/packages/core/application-config.ts b/packages/core/application-config.ts index e023e08060e..883ad66c9c8 100644 --- a/packages/core/application-config.ts +++ b/packages/core/application-config.ts @@ -11,7 +11,7 @@ import { InstanceWrapper } from './injector/instance-wrapper'; import { ExcludeRouteMetadata } from './router/interfaces/exclude-route-metadata.interface'; export class ApplicationConfig { - private globalPrefix = ''; + private globalPrefixes: string[] = []; private globalPrefixOptions: GlobalPrefixOptions = {}; private globalPipes: Array = []; private globalFilters: Array = []; @@ -27,12 +27,16 @@ export class ApplicationConfig { constructor(private ioAdapter: WebSocketAdapter | null = null) {} - public setGlobalPrefix(prefix: string) { - this.globalPrefix = prefix; + public setGlobalPrefix(prefix: string | string[]) { + this.globalPrefixes = Array.isArray(prefix) ? prefix : [prefix]; } - public getGlobalPrefix() { - return this.globalPrefix; + public getGlobalPrefix(): string { + return this.globalPrefixes[0] ?? ''; + } + + public getGlobalPrefixes(): string[] { + return this.globalPrefixes; } public setGlobalPrefixOptions( diff --git a/packages/core/middleware/route-info-path-extractor.ts b/packages/core/middleware/route-info-path-extractor.ts index 048d48a5409..640c48a6dc7 100644 --- a/packages/core/middleware/route-info-path-extractor.ts +++ b/packages/core/middleware/route-info-path-extractor.ts @@ -15,34 +15,43 @@ import { RoutePathFactory } from './../router/route-path-factory'; export class RouteInfoPathExtractor { private readonly routePathFactory: RoutePathFactory; - private readonly prefixPath: string; + private readonly prefixPaths: string[]; private readonly excludedGlobalPrefixRoutes: ExcludeRouteMetadata[]; private readonly versioningConfig?: VersioningOptions; constructor(private readonly applicationConfig: ApplicationConfig) { this.routePathFactory = new RoutePathFactory(applicationConfig); - this.prefixPath = stripEndSlash( - addLeadingSlash(this.applicationConfig.getGlobalPrefix()), - ); + const prefixes = this.applicationConfig.getGlobalPrefixes(); + this.prefixPaths = + prefixes.length > 0 + ? prefixes.map(p => stripEndSlash(addLeadingSlash(p))) + : ['']; this.excludedGlobalPrefixRoutes = this.applicationConfig.getGlobalPrefixOptions().exclude!; this.versioningConfig = this.applicationConfig.getVersioning(); } + private get prefixPath(): string { + return this.prefixPaths[0]; + } + public extractPathsFrom({ path, method, version }: RouteInfo): string[] { const versionPaths = this.extractVersionPathFrom(version); if (this.isAWildcard(path)) { const entries = versionPaths.length > 0 - ? versionPaths - .map(versionPath => [ - this.prefixPath + versionPath + '$', - this.prefixPath + versionPath + addLeadingSlash(path), + ? this.prefixPaths.flatMap(prefixPath => + versionPaths.flatMap(versionPath => [ + prefixPath + versionPath + '$', + prefixPath + versionPath + addLeadingSlash(path), + ]), + ) + : this.prefixPaths[0] + ? this.prefixPaths.flatMap(prefixPath => [ + prefixPath + '$', + prefixPath + addLeadingSlash(path), ]) - .flat() - : this.prefixPath - ? [this.prefixPath + '$', this.prefixPath + addLeadingSlash(path)] : [addLeadingSlash(path)]; return Array.isArray(this.excludedGlobalPrefixRoutes) @@ -101,10 +110,14 @@ export class RouteInfoPathExtractor { } if (!versionPaths.length) { - return [this.prefixPath + addLeadingSlash(path)]; + return this.prefixPaths.map( + prefixPath => prefixPath + addLeadingSlash(path), + ); } - return versionPaths.map( - versionPath => this.prefixPath + versionPath + addLeadingSlash(path), + return this.prefixPaths.flatMap(prefixPath => + versionPaths.map( + versionPath => prefixPath + versionPath + addLeadingSlash(path), + ), ); } diff --git a/packages/core/nest-application.ts b/packages/core/nest-application.ts index ab8021320ba..69aba442215 100644 --- a/packages/core/nest-application.ts +++ b/packages/core/nest-application.ts @@ -205,9 +205,12 @@ export class NestApplication public async registerRouter() { await this.registerMiddleware(this.httpAdapter); - const prefix = this.config.getGlobalPrefix(); - const basePath = addLeadingSlash(prefix); - this.routesResolver.resolve(this.httpAdapter, basePath); + const prefixes = this.config.getGlobalPrefixes(); + const basePaths = + prefixes.length > 0 + ? prefixes.map(prefix => addLeadingSlash(prefix)) + : ['']; + this.routesResolver.resolve(this.httpAdapter, basePaths); } public async registerRouterHooks() { @@ -374,7 +377,10 @@ export class NestApplication return `${this.getProtocol()}://${host}:${address.port}`; } - public setGlobalPrefix(prefix: string, options?: GlobalPrefixOptions): this { + public setGlobalPrefix( + prefix: string | string[], + options?: GlobalPrefixOptions, + ): this { this.config.setGlobalPrefix(prefix); if (options) { const exclude = options?.exclude diff --git a/packages/core/router/interfaces/resolver.interface.ts b/packages/core/router/interfaces/resolver.interface.ts index ec97cc806e0..f930b0cd2ee 100644 --- a/packages/core/router/interfaces/resolver.interface.ts +++ b/packages/core/router/interfaces/resolver.interface.ts @@ -1,5 +1,5 @@ export interface Resolver { - resolve(instance: any, basePath: string): void; + resolve(instance: any, basePath: string | string[]): void; registerNotFoundHandler(): void; registerExceptionHandler(): void; } diff --git a/packages/core/router/interfaces/route-path-metadata.interface.ts b/packages/core/router/interfaces/route-path-metadata.interface.ts index fb8a769a610..9e7943117c3 100644 --- a/packages/core/router/interfaces/route-path-metadata.interface.ts +++ b/packages/core/router/interfaces/route-path-metadata.interface.ts @@ -14,8 +14,9 @@ export interface RoutePathMetadata { /** * Global route prefix specified with the "NestApplication#setGlobalPrefix" method. + * Can be a single prefix or an array of prefixes. */ - globalPrefix?: string; + globalPrefix?: string | string[]; /** * Module-level path registered through the "RouterModule". diff --git a/packages/core/router/route-path-factory.ts b/packages/core/router/route-path-factory.ts index 159b2ee4ebc..3b86f6e2586 100644 --- a/packages/core/router/route-path-factory.ts +++ b/packages/core/router/route-path-factory.ts @@ -57,19 +57,27 @@ export class RoutePathFactory { paths = this.appendToAllIfDefined(paths, metadata.methodPath); if (metadata.globalPrefix) { - paths = paths.map(path => { - if ( - this.isExcludedFromGlobalPrefix( - path, - requestMethod, - versionOrVersions, - metadata.versioningOptions, - ) - ) { - return path; - } - return stripEndSlash(metadata.globalPrefix || '') + path; - }); + const globalPrefixes = Array.isArray(metadata.globalPrefix) + ? metadata.globalPrefix + : [metadata.globalPrefix]; + + paths = flatten( + paths.map(path => { + if ( + this.isExcludedFromGlobalPrefix( + path, + requestMethod, + versionOrVersions, + metadata.versioningOptions, + ) + ) { + return [path]; + } + return globalPrefixes.map( + prefix => stripEndSlash(prefix || '') + path, + ); + }), + ); } return paths diff --git a/packages/core/router/routes-resolver.ts b/packages/core/router/routes-resolver.ts index 451b855df96..cd4b4db3e6a 100644 --- a/packages/core/router/routes-resolver.ts +++ b/packages/core/router/routes-resolver.ts @@ -70,7 +70,7 @@ export class RoutesResolver implements Resolver { public resolve( applicationRef: T, - globalPrefix: string, + globalPrefix: string | string[], ) { const modules = this.container.getModules(); modules.forEach(({ controllers, metatype }, moduleName) => { @@ -88,7 +88,7 @@ export class RoutesResolver implements Resolver { public registerRouters( routes: Map>, moduleName: string, - globalPrefix: string, + globalPrefix: string | string[], modulePath: string, applicationRef: HttpServer, ) { diff --git a/packages/core/test/application-config.spec.ts b/packages/core/test/application-config.spec.ts index 73e23f7e377..cbca1ef3ccd 100644 --- a/packages/core/test/application-config.spec.ts +++ b/packages/core/test/application-config.spec.ts @@ -17,6 +17,25 @@ describe('ApplicationConfig', () => { expect(appConfig.getGlobalPrefix()).to.be.eql(path); }); + it('should set global path as array', () => { + const paths = ['api', 'v1']; + appConfig.setGlobalPrefix(paths); + + expect(appConfig.getGlobalPrefix()).to.be.eql('api'); + expect(appConfig.getGlobalPrefixes()).to.be.eql(paths); + }); + it('should return all prefixes via getGlobalPrefixes', () => { + const paths = ['prefix1', 'prefix2', 'prefix3']; + appConfig.setGlobalPrefix(paths); + + expect(appConfig.getGlobalPrefixes()).to.be.eql(paths); + }); + it('should convert single string to array in getGlobalPrefixes', () => { + const path = 'test'; + appConfig.setGlobalPrefix(path); + + expect(appConfig.getGlobalPrefixes()).to.be.eql([path]); + }); it('should set global path options', () => { const options: GlobalPrefixOptions = { exclude: [ @@ -34,6 +53,9 @@ describe('ApplicationConfig', () => { it('should has empty string as a global path by default', () => { expect(appConfig.getGlobalPrefix()).to.be.eql(''); }); + it('should return empty array as global prefixes by default', () => { + expect(appConfig.getGlobalPrefixes()).to.be.eql([]); + }); it('should has empty string as a global path option by default', () => { expect(appConfig.getGlobalPrefixOptions()).to.be.eql({}); }); diff --git a/packages/core/test/middleware/route-info-path-extractor.spec.ts b/packages/core/test/middleware/route-info-path-extractor.spec.ts index 62bfb5fc8d7..c4540f8db8b 100644 --- a/packages/core/test/middleware/route-info-path-extractor.spec.ts +++ b/packages/core/test/middleware/route-info-path-extractor.spec.ts @@ -35,7 +35,7 @@ describe('RouteInfoPathExtractor', () => { }); it(`should return correct paths when set global prefix`, () => { - Reflect.set(routeInfoPathExtractor, 'prefixPath', '/api'); + Reflect.set(routeInfoPathExtractor, 'prefixPaths', ['/api']); expect( routeInfoPathExtractor.extractPathsFrom({ @@ -54,7 +54,7 @@ describe('RouteInfoPathExtractor', () => { }); it(`should return correct paths when set global prefix and global prefix options`, () => { - Reflect.set(routeInfoPathExtractor, 'prefixPath', '/api'); + Reflect.set(routeInfoPathExtractor, 'prefixPaths', ['/api']); Reflect.set( routeInfoPathExtractor, 'excludedGlobalPrefixRoutes', @@ -124,7 +124,7 @@ describe('RouteInfoPathExtractor', () => { }); it(`should return correct path when set global prefix`, () => { - Reflect.set(routeInfoPathExtractor, 'prefixPath', '/api'); + Reflect.set(routeInfoPathExtractor, 'prefixPaths', ['/api']); expect( routeInfoPathExtractor.extractPathFrom({ @@ -143,7 +143,7 @@ describe('RouteInfoPathExtractor', () => { }); it(`should return correct path when set global prefix and global prefix options`, () => { - Reflect.set(routeInfoPathExtractor, 'prefixPath', '/api'); + Reflect.set(routeInfoPathExtractor, 'prefixPaths', ['/api']); Reflect.set( routeInfoPathExtractor, 'excludedGlobalPrefixRoutes', diff --git a/packages/core/test/router/route-path-factory.spec.ts b/packages/core/test/router/route-path-factory.spec.ts index acb3ca1f712..e0b5c562dc2 100644 --- a/packages/core/test/router/route-path-factory.spec.ts +++ b/packages/core/test/router/route-path-factory.spec.ts @@ -225,6 +225,61 @@ describe('RoutePathFactory', () => { ).to.deep.equal(['/ctrlPath']); sinon.restore(); }); + + it('should return paths for each global prefix when array is provided', () => { + expect( + routePathFactory.create({ + ctrlPath: '/ctrlPath/', + methodPath: '/methodPath/', + globalPrefix: ['api', 'v1'], + }), + ).to.deep.equal(['/api/ctrlPath/methodPath', '/v1/ctrlPath/methodPath']); + + expect( + routePathFactory.create({ + ctrlPath: '/ctrlPath/', + methodPath: '/methodPath/', + modulePath: '/modulePath/', + globalPrefix: ['/prefix1', '/prefix2'], + }), + ).to.deep.equal([ + '/prefix1/modulePath/ctrlPath/methodPath', + '/prefix2/modulePath/ctrlPath/methodPath', + ]); + }); + + it('should handle single-element array same as string', () => { + const resultArray = routePathFactory.create({ + ctrlPath: '/ctrlPath/', + methodPath: '/methodPath/', + globalPrefix: ['api'], + }); + + const resultString = routePathFactory.create({ + ctrlPath: '/ctrlPath/', + methodPath: '/methodPath/', + globalPrefix: 'api', + }); + + expect(resultArray).to.deep.equal(resultString); + }); + + it('should combine multiple prefixes with versioning', () => { + expect( + routePathFactory.create({ + ctrlPath: '/ctrlPath/', + methodPath: '/methodPath/', + globalPrefix: ['api', 'v1'], + versioningOptions: { + type: VersioningType.URI, + }, + controllerVersion: '1.0.0', + }), + ).to.deep.equal([ + '/api/v1.0.0/ctrlPath/methodPath', + '/v1/v1.0.0/ctrlPath/methodPath', + ]); + }); }); describe('isExcludedFromGlobalPrefix', () => {