diff --git a/packages/common/interfaces/nest-application.interface.ts b/packages/common/interfaces/nest-application.interface.ts index 9c3e7cae4d6..94ee67f6f28 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 a3fa36fdb51..1ceded150b7 100644 --- a/packages/core/application-config.ts +++ b/packages/core/application-config.ts @@ -14,7 +14,7 @@ import { InstanceWrapper } from './injector/instance-wrapper.js'; import { ExcludeRouteMetadata } from './router/interfaces/exclude-route-metadata.interface.js'; export class ApplicationConfig { - private globalPrefix = ''; + private globalPrefixes: string[] = []; private globalPrefixOptions: GlobalPrefixOptions = {}; private globalPipes: Array = []; private globalFilters: Array = []; @@ -33,12 +33,34 @@ export class ApplicationConfig { constructor(private ioAdapter: WebSocketAdapter | null = null) {} - public setGlobalPrefix(prefix: string) { - this.globalPrefix = prefix; - } - - public getGlobalPrefix() { - return this.globalPrefix; + public setGlobalPrefix(prefix: string | string[]) { + this.globalPrefixes = Array.isArray(prefix) ? prefix : [prefix]; + } + + /** + * Returns the first global prefix, or an empty string if none was set. + * + * This method predates support for multiple prefixes and keeps its + * `string` return type on purpose, so that existing consumers (e.g. + * `@nestjs/swagger`) are not broken. When several prefixes have been set, + * only the first one is returned; use {@link getGlobalPrefixes} to get all + * of them. + * + * @deprecated Use {@link getGlobalPrefixes} instead. This method will be + * removed in NestJS v13. + */ + public getGlobalPrefix(): string { + // Intentionally returns only the first prefix to preserve the previous + // `string` contract. See the JSDoc above. + return this.globalPrefixes[0] ?? ''; + } + + /** + * Returns every global prefix set via {@link setGlobalPrefix}, in the order + * they were provided. Returns an empty array if none was set. + */ + 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 e108887aa96..9b800785190 100644 --- a/packages/core/middleware/route-info-path-extractor.ts +++ b/packages/core/middleware/route-info-path-extractor.ts @@ -13,34 +13,43 @@ import { 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) @@ -99,10 +108,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 35d2cbd0398..d332a784261 100644 --- a/packages/core/nest-application.ts +++ b/packages/core/nest-application.ts @@ -211,8 +211,11 @@ export class NestApplication public async registerRouter() { await this.registerMiddleware(this.httpAdapter); - const prefix = this.config.getGlobalPrefix(); - const basePath = addLeadingSlash(prefix); + const prefixes = this.config.getGlobalPrefixes(); + const basePaths = + prefixes.length > 0 + ? prefixes.map(prefix => addLeadingSlash(prefix)) + : ['']; const conflictPolicy = this.config.getRouteConflictPolicy(); const resolutionStrategy = this.config.getRouteResolutionStrategy(); @@ -227,7 +230,7 @@ export class NestApplication const adapterRejectsDuplicates = !adapterIsOrderSensitive; if (!conflictPolicy && !shouldSortBySpecificity) { - this.routesResolver.resolve(this.httpAdapter, basePath); + this.routesResolver.resolve(this.httpAdapter, basePaths); return; } @@ -238,7 +241,7 @@ export class NestApplication // from `instance.route()` and would short-circuit both the resolve // loop and the aggregated `RouteConflictException`. const resolvedRoutes: ResolvedRoute[] = []; - this.routesResolver.resolve(this.httpAdapter, basePath, { + this.routesResolver.resolve(this.httpAdapter, basePaths, { onRouteResolved: route => resolvedRoutes.push(route), deferRegistration: true, }); @@ -467,7 +470,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 8505be01723..1a5bf97038e 100644 --- a/packages/core/router/interfaces/resolver.interface.ts +++ b/packages/core/router/interfaces/resolver.interface.ts @@ -5,7 +5,7 @@ import { RouteResolutionOptions } from './route-resolution-options.interface.js' export interface Resolver { resolve( applicationRef: HttpServer, - basePath: string, + basePath: string | string[], options?: RouteResolutionOptions, ): void; registerResolvedRoute(applicationRef: HttpServer, route: ResolvedRoute): void; diff --git a/packages/core/router/interfaces/route-path-metadata.interface.ts b/packages/core/router/interfaces/route-path-metadata.interface.ts index d8c0336b7f8..fba9884a1f4 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 b9f7f166edd..6badcd63a98 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 1b2666bbc2e..bfe8a69b662 100644 --- a/packages/core/router/routes-resolver.ts +++ b/packages/core/router/routes-resolver.ts @@ -68,7 +68,7 @@ export class RoutesResolver implements Resolver { public resolve( applicationRef: T, - globalPrefix: string, + globalPrefix: string | string[], options: RouteResolutionOptions = {}, ) { const modules = this.container.getModules(); @@ -95,7 +95,7 @@ export class RoutesResolver implements Resolver { public registerRouters( routes: Map>, moduleName: string, - globalPrefix: string, + globalPrefix: string | string[], modulePath: string, applicationRef: HttpServer, options: RouteResolutionOptions = {}, diff --git a/packages/core/test/application-config.spec.ts b/packages/core/test/application-config.spec.ts index 5fded1879b7..34cd0266168 100644 --- a/packages/core/test/application-config.spec.ts +++ b/packages/core/test/application-config.spec.ts @@ -16,6 +16,25 @@ describe('ApplicationConfig', () => { expect(appConfig.getGlobalPrefix()).toEqual(path); }); + it('should set global path as array', () => { + const paths = ['api', 'v1']; + appConfig.setGlobalPrefix(paths); + + expect(appConfig.getGlobalPrefix()).toEqual('api'); + expect(appConfig.getGlobalPrefixes()).toEqual(paths); + }); + it('should return all prefixes via getGlobalPrefixes', () => { + const paths = ['prefix1', 'prefix2', 'prefix3']; + appConfig.setGlobalPrefix(paths); + + expect(appConfig.getGlobalPrefixes()).toEqual(paths); + }); + it('should convert single string to array in getGlobalPrefixes', () => { + const path = 'test'; + appConfig.setGlobalPrefix(path); + + expect(appConfig.getGlobalPrefixes()).toEqual([path]); + }); it('should set global path options', () => { const options: GlobalPrefixOptions = { exclude: [ @@ -33,6 +52,9 @@ describe('ApplicationConfig', () => { it('should has empty string as a global path by default', () => { expect(appConfig.getGlobalPrefix()).toEqual(''); }); + it('should return empty array as global prefixes by default', () => { + expect(appConfig.getGlobalPrefixes()).toEqual([]); + }); it('should has empty string as a global path option by default', () => { expect(appConfig.getGlobalPrefixOptions()).toEqual({}); }); 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 7e2ed75be5b..05a2dec78b1 100644 --- a/packages/core/test/middleware/route-info-path-extractor.spec.ts +++ b/packages/core/test/middleware/route-info-path-extractor.spec.ts @@ -34,7 +34,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({ @@ -53,7 +53,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', @@ -123,7 +123,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({ @@ -142,7 +142,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 0f7b29326eb..29c6fe755c3 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', () => { ).toEqual(['/ctrlPath']); vi.restoreAllMocks(); }); + + it('should return paths for each global prefix when array is provided', () => { + expect( + routePathFactory.create({ + ctrlPath: '/ctrlPath/', + methodPath: '/methodPath/', + globalPrefix: ['api', 'v1'], + }), + ).toEqual(['/api/ctrlPath/methodPath', '/v1/ctrlPath/methodPath']); + + expect( + routePathFactory.create({ + ctrlPath: '/ctrlPath/', + methodPath: '/methodPath/', + modulePath: '/modulePath/', + globalPrefix: ['/prefix1', '/prefix2'], + }), + ).toEqual([ + '/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).toEqual(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', + }), + ).toEqual([ + '/api/v1.0.0/ctrlPath/methodPath', + '/v1/v1.0.0/ctrlPath/methodPath', + ]); + }); }); describe('isExcludedFromGlobalPrefix', () => {