Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions packages/common/interfaces/nest-application.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
36 changes: 29 additions & 7 deletions packages/core/application-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ExcludeRouteMetadata> = {};
private globalPipes: Array<PipeTransform> = [];
private globalFilters: Array<ExceptionFilter> = [];
Expand All @@ -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(
Expand Down
41 changes: 27 additions & 14 deletions packages/core/middleware/route-info-path-extractor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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),
),
);
}

Expand Down
16 changes: 11 additions & 5 deletions packages/core/nest-application.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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;
}

Expand All @@ -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,
});
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/core/router/interfaces/resolver.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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".
Expand Down
34 changes: 21 additions & 13 deletions packages/core/router/route-path-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions packages/core/router/routes-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ export class RoutesResolver implements Resolver {

public resolve<T extends HttpServer>(
applicationRef: T,
globalPrefix: string,
globalPrefix: string | string[],
options: RouteResolutionOptions = {},
) {
const modules = this.container.getModules();
Expand All @@ -95,7 +95,7 @@ export class RoutesResolver implements Resolver {
public registerRouters(
routes: Map<string | symbol | Function, InstanceWrapper<Controller>>,
moduleName: string,
globalPrefix: string,
globalPrefix: string | string[],
modulePath: string,
applicationRef: HttpServer,
options: RouteResolutionOptions = {},
Expand Down
22 changes: 22 additions & 0 deletions packages/core/test/application-config.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ExcludeRouteMetadata> = {
exclude: [
Expand All @@ -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({});
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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',
Expand Down Expand Up @@ -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({
Expand All @@ -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',
Expand Down
55 changes: 55 additions & 0 deletions packages/core/test/router/route-path-factory.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Loading