fix(core): apply middleware to versioned controllers (ctrl-level)

This commit is contained in:
Kamil Myśliwiec
2023-06-14 11:34:56 +02:00
parent d6507dce5d
commit 7aa710724d
9 changed files with 251 additions and 38 deletions

View File

@@ -418,4 +418,67 @@ describe('URI Versioning', () => {
await app.close();
});
});
// ======================================================================== //
describe.only('with middleware applied', () => {
before(async () => {
const moduleRef = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleRef.createNestApplication();
app.enableVersioning({
type: VersioningType.URI,
defaultVersion: '1',
});
await app.init();
});
describe('GET /middleware', () => {
it('should return "Hello from middleware function!"', () => {
return request(app.getHttpServer())
.get('/v1/middleware')
.expect(200)
.expect('Hello from middleware function!');
});
});
describe('GET /middleware/override', () => {
it('should return "Hello from middleware function!"', () => {
return request(app.getHttpServer())
.get('/v2/middleware/override')
.expect(200)
.expect('Hello from middleware function!');
});
});
describe('GET /middleware/multiple', () => {
it('should return "Hello from middleware function!" (v1)', () => {
return request(app.getHttpServer())
.get('/v1/middleware/multiple')
.expect(200)
.expect('Hello from middleware function!');
});
it('should return "Hello from middleware function!" (v2)', () => {
return request(app.getHttpServer())
.get('/v2/middleware/multiple')
.expect(200)
.expect('Hello from middleware function!');
});
});
describe('GET /middleware/neutral', () => {
it('should return "Hello from middleware function!"', () => {
return request(app.getHttpServer())
.get('/middleware/neutral')
.expect(200)
.expect('Hello from middleware function!');
});
});
after(async () => {
await app.close();
});
});
});

View File

@@ -1,11 +1,14 @@
import { Module } from '@nestjs/common';
import { MiddlewareConsumer, Module } from '@nestjs/common';
import { AppV1Controller } from './app-v1.controller';
import { AppV2Controller } from './app-v2.controller';
import { MiddlewareController } from './middleware.controller';
import { MultipleMiddlewareVersionController } from './multiple-middleware.controller';
import { MultipleVersionController } from './multiple.controller';
import { NoVersioningController } from './no-versioning.controller';
import { VersionNeutralMiddlewareController } from './neutral-middleware.controller';
import { VersionNeutralController } from './neutral.controller';
import { OverrideController } from './override.controller';
import { NoVersioningController } from './no-versioning.controller';
import { OverridePartialController } from './override-partial.controller';
import { OverrideController } from './override.controller';
@Module({
imports: [],
@@ -17,6 +20,19 @@ import { OverridePartialController } from './override-partial.controller';
VersionNeutralController,
OverrideController,
OverridePartialController,
MiddlewareController,
MultipleMiddlewareVersionController,
VersionNeutralMiddlewareController,
],
})
export class AppModule {}
export class AppModule {
configure(consumer: MiddlewareConsumer) {
consumer
.apply((req, res) => res.end('Hello from middleware function!'))
.forRoutes(
MiddlewareController,
MultipleMiddlewareVersionController,
VersionNeutralMiddlewareController,
);
}
}

View File

@@ -0,0 +1,18 @@
import { Controller, Get, Version } from '@nestjs/common';
@Controller({
path: 'middleware',
version: '1',
})
export class MiddlewareController {
@Get('/')
hello() {
return 'Hello from "MiddlewareController"!';
}
@Version('2')
@Get('/override')
hellov2() {
return 'Hello from "MiddlewareController"!';
}
}

View File

@@ -0,0 +1,12 @@
import { Controller, Get } from '@nestjs/common';
@Controller({
version: ['1', '2'],
path: 'middleware',
})
export class MultipleMiddlewareVersionController {
@Get('/multiple')
multiple() {
return 'Multiple Versions 1 or 2';
}
}

View File

@@ -0,0 +1,12 @@
import { Controller, Get, VERSION_NEUTRAL } from '@nestjs/common';
@Controller({
path: 'middleware',
version: VERSION_NEUTRAL,
})
export class VersionNeutralMiddlewareController {
@Get('/neutral')
neutral() {
return 'Neutral';
}
}

View File

@@ -66,7 +66,7 @@ export class MiddlewareModule<
config,
appRef,
);
this.routesMapper = new RoutesMapper(container);
this.routesMapper = new RoutesMapper(container, config);
this.resolver = new MiddlewareResolver(middlewareContainer, injector);
this.routeInfoPathExtractor = new RouteInfoPathExtractor(config);
this.injector = injector;

View File

@@ -1,35 +1,48 @@
import { MODULE_PATH, PATH_METADATA } from '@nestjs/common/constants';
import { RouteInfo, Type } from '@nestjs/common/interfaces';
import {
MODULE_PATH,
PATH_METADATA,
VERSION_METADATA,
} from '@nestjs/common/constants';
import {
RouteInfo,
Type,
VERSION_NEUTRAL,
VersionValue,
} from '@nestjs/common/interfaces';
import {
addLeadingSlash,
isString,
isUndefined,
} from '@nestjs/common/utils/shared.utils';
import { ApplicationConfig } from '../application-config';
import { NestContainer } from '../injector/container';
import { Module } from '../injector/module';
import { MetadataScanner } from '../metadata-scanner';
import { PathsExplorer } from '../router/paths-explorer';
import { PathsExplorer, RouteDefinition } from '../router/paths-explorer';
import { targetModulesByContainer } from '../router/router-module';
export class RoutesMapper {
private readonly pathsExplorer: PathsExplorer;
constructor(private readonly container: NestContainer) {
constructor(
private readonly container: NestContainer,
private readonly applicationConfig: ApplicationConfig,
) {
this.pathsExplorer = new PathsExplorer(new MetadataScanner());
}
public mapRouteToRouteInfo(
route: Type<any> | RouteInfo | string,
controllerOrRoute: Type<any> | RouteInfo | string,
): RouteInfo[] {
if (isString(route)) {
return this.getRouteInfoFromPath(route);
if (isString(controllerOrRoute)) {
return this.getRouteInfoFromPath(controllerOrRoute);
}
const routePathOrPaths = this.getRoutePath(route);
if (this.isRouteInfo(routePathOrPaths, route)) {
return this.getRouteInfoFromObject(route);
const routePathOrPaths = this.getRoutePath(controllerOrRoute);
if (this.isRouteInfo(routePathOrPaths, controllerOrRoute)) {
return this.getRouteInfoFromObject(controllerOrRoute);
}
return this.getRouteInfoFromController(route, routePathOrPaths);
return this.getRouteInfoFromController(controllerOrRoute, routePathOrPaths);
}
private getRouteInfoFromPath(routePath: string): RouteInfo[] {
@@ -62,33 +75,47 @@ export class RoutesMapper {
Object.create(controller),
controller.prototype,
);
const controllerVersion = this.getVersionMetadata(controller);
const versioningConfig = this.applicationConfig.getVersioning();
const moduleRef = this.getHostModuleOfController(controller);
const modulePath = this.getModulePath(moduleRef?.metatype);
const concatPaths = <T>(acc: T[], currentValue: T[]) =>
acc.concat(currentValue);
const toUndefinedIfNeural = (version: VersionValue) =>
version === VERSION_NEUTRAL ? undefined : version;
const toRouteInfo = (item: RouteDefinition, prefix: string) =>
item.path
?.map(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),
}));
}
routeInfo.version = toUndefinedIfNeural(version);
}
return routeInfo;
})
.flat() as RouteInfo[];
return []
.concat(routePath)
.map(routePath =>
controllerPaths
.map(item =>
item.path?.map(p => {
let path = modulePath ?? '';
path += this.normalizeGlobalPath(routePath) + addLeadingSlash(p);
const routeInfo: RouteInfo = {
path,
method: item.requestMethod,
};
if (item.version) {
routeInfo.version = item.version;
}
return routeInfo;
}),
)
.map(item => toRouteInfo(item, routePath))
.reduce(concatPaths, []),
)
.reduce(concatPaths, []);
@@ -141,4 +168,16 @@ export class RoutesMapper {
);
return modulePath ?? Reflect.getMetadata(MODULE_PATH, metatype);
}
private getVersionMetadata(
metatype: Type<unknown> | Function,
): VersionValue | undefined {
const versioningConfig = this.applicationConfig.getVersioning();
if (versioningConfig) {
return (
Reflect.getMetadata(VERSION_METADATA, metatype) ??
versioningConfig.defaultVersion
);
}
}
}

View File

@@ -1,5 +1,11 @@
import { expect } from 'chai';
import { Controller, Get, RequestMethod, Version } from '../../../common';
import {
Controller,
Get,
RequestMethod,
Version,
VersioningType,
} from '../../../common';
import { ApplicationConfig } from '../../application-config';
import { NestContainer } from '../../injector/container';
import { MiddlewareBuilder } from '../../middleware/builder';
@@ -13,8 +19,9 @@ describe('MiddlewareBuilder', () => {
beforeEach(() => {
const container = new NestContainer();
const appConfig = new ApplicationConfig();
appConfig.enableVersioning({ type: VersioningType.URI });
builder = new MiddlewareBuilder(
new RoutesMapper(container),
new RoutesMapper(container, appConfig),
new NoopHttpAdapter({}),
new RouteInfoPathExtractor(appConfig),
);

View File

@@ -1,12 +1,13 @@
import { Version } from '../../../common';
import { MiddlewareConfiguration } from '../../../common/interfaces';
import { expect } from 'chai';
import { Version, VersioningType } from '../../../common';
import { Controller } from '../../../common/decorators/core/controller.decorator';
import {
Get,
RequestMapping,
} from '../../../common/decorators/http/request-mapping.decorator';
import { RequestMethod } from '../../../common/enums/request-method.enum';
import { MiddlewareConfiguration } from '../../../common/interfaces';
import { ApplicationConfig } from '../../application-config';
import { NestContainer } from '../../injector/container';
import { RoutesMapper } from '../../middleware/routes-mapper';
@@ -26,7 +27,9 @@ describe('RoutesMapper', () => {
let mapper: RoutesMapper;
beforeEach(() => {
mapper = new RoutesMapper(new NestContainer());
const appConfig = new ApplicationConfig();
appConfig.enableVersioning({ type: VersioningType.URI });
mapper = new RoutesMapper(new NestContainer(), appConfig);
});
it('should map @Controller() to "ControllerMetadata" in forRoutes', () => {
@@ -81,4 +84,47 @@ describe('RoutesMapper', () => {
{ path: '/test2/another', method: RequestMethod.DELETE },
]);
});
@Controller({
version: '1',
path: 'versioned',
})
class VersionedController {
@Get()
hello() {
return 'Hello from "VersionedController"!';
}
@Version('2')
@Get('/override')
override() {
return 'Hello from "VersionedController"!';
}
}
@Controller({
version: ['1', '2'],
})
class MultipleVersionController {
@Get('multiple')
multiple() {
return 'Multiple Versions 1 or 2';
}
}
it('should map a versioned controller to the corresponding route info objects (single version)', () => {
expect(mapper.mapRouteToRouteInfo(VersionedController)).to.deep.equal([
{ path: '/versioned/', version: '1', method: RequestMethod.GET },
{ path: '/versioned/override', version: '2', method: RequestMethod.GET },
]);
});
it('should map a versioned controller to the corresponding route info objects (multiple versions)', () => {
expect(mapper.mapRouteToRouteInfo(MultipleVersionController)).to.deep.equal(
[
{ path: '/multiple', version: '1', method: RequestMethod.GET },
{ path: '/multiple', version: '2', method: RequestMethod.GET },
],
);
});
});