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/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/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', () => {