diff --git a/integration/hello-world/e2e/middleware-with-versioning.spec.ts b/integration/hello-world/e2e/middleware-with-versioning.spec.ts new file mode 100644 index 00000000000..c8d800a900e --- /dev/null +++ b/integration/hello-world/e2e/middleware-with-versioning.spec.ts @@ -0,0 +1,147 @@ +import { + Controller, + Get, + INestApplication, + MiddlewareConsumer, + Module, + RequestMethod, + Version, + VersioningOptions, + VersioningType, + VERSION_NEUTRAL, +} from '@nestjs/common'; +import { CustomVersioningOptions } from '@nestjs/common/interfaces'; +import { Test } from '@nestjs/testing'; +import * as request from 'supertest'; +import { AppModule } from '../src/app.module'; + +const RETURN_VALUE = 'test'; +const VERSIONED_VALUE = 'test_versioned'; + +@Controller() +class TestController { + @Version('1') + @Get('versioned') + versionedTest() { + return RETURN_VALUE; + } +} + +@Module({ + imports: [AppModule], + controllers: [TestController], +}) +class TestModule { + configure(consumer: MiddlewareConsumer) { + consumer + .apply((req, res, next) => res.send(VERSIONED_VALUE)) + .forRoutes({ + path: '/versioned', + version: '1', + method: RequestMethod.ALL, + }); + } +} + +describe('Middleware', () => { + let app: INestApplication; + + describe('when using default URI versioning', () => { + beforeEach(async () => { + app = await createAppWithVersioning({ + type: VersioningType.URI, + defaultVersion: VERSION_NEUTRAL, + }); + }); + + it(`forRoutes({ path: '/versioned', version: '1', method: RequestMethod.ALL })`, () => { + return request(app.getHttpServer()) + .get('/v1/versioned') + .expect(200, VERSIONED_VALUE); + }); + }); + + describe('when default URI versioning with an alternative prefix', () => { + beforeEach(async () => { + app = await createAppWithVersioning({ + type: VersioningType.URI, + defaultVersion: VERSION_NEUTRAL, + prefix: 'version', + }); + }); + + it(`forRoutes({ path: '/versioned', version: '1', method: RequestMethod.ALL })`, () => { + return request(app.getHttpServer()) + .get('/version1/versioned') + .expect(200, VERSIONED_VALUE); + }); + }); + + describe('when using HEADER versioning', () => { + beforeEach(async () => { + app = await createAppWithVersioning({ + type: VersioningType.HEADER, + header: 'version', + }); + }); + + it(`forRoutes({ path: '/versioned', version: '1', method: RequestMethod.ALL })`, () => { + return request(app.getHttpServer()) + .get('/versioned') + .set('version', '1') + .expect(200, VERSIONED_VALUE); + }); + }); + + describe('when using MEDIA TYPE versioning', () => { + beforeEach(async () => { + app = await createAppWithVersioning({ + type: VersioningType.MEDIA_TYPE, + key: 'v', + defaultVersion: VERSION_NEUTRAL, + }); + }); + + it(`forRoutes({ path: '/versioned', version: '1', method: RequestMethod.ALL })`, () => { + return request(app.getHttpServer()) + .get('/versioned') + .expect(200, VERSIONED_VALUE); + }); + }); + + describe('when using CUSTOM TYPE versioning', () => { + beforeEach(async () => { + const extractor: CustomVersioningOptions['extractor'] = () => '1'; + + app = await createAppWithVersioning({ + type: VersioningType.CUSTOM, + extractor, + }); + }); + + it(`forRoutes({ path: '/versioned', version: '1', method: RequestMethod.ALL })`, () => { + return request(app.getHttpServer()) + .get('/versioned') + .expect(200, VERSIONED_VALUE); + }); + }); + + afterEach(async () => { + await app.close(); + }); +}); + +async function createAppWithVersioning( + versioningOptions: VersioningOptions, +): Promise { + const app = ( + await Test.createTestingModule({ + imports: [TestModule], + }).compile() + ).createNestApplication(); + + app.enableVersioning(versioningOptions); + await app.init(); + + return app; +} diff --git a/packages/common/interfaces/middleware/middleware-configuration.interface.ts b/packages/common/interfaces/middleware/middleware-configuration.interface.ts index aef5012edec..15414b6cb07 100644 --- a/packages/common/interfaces/middleware/middleware-configuration.interface.ts +++ b/packages/common/interfaces/middleware/middleware-configuration.interface.ts @@ -1,9 +1,11 @@ import { RequestMethod } from '../../enums'; import { Type } from '../type.interface'; +import { VersionValue } from '../version-options.interface'; export interface RouteInfo { path: string; method: RequestMethod; + version?: VersionValue; } export interface MiddlewareConfiguration { diff --git a/packages/core/middleware/middleware-module.ts b/packages/core/middleware/middleware-module.ts index 7ba73c5c218..777f589641c 100644 --- a/packages/core/middleware/middleware-module.ts +++ b/packages/core/middleware/middleware-module.ts @@ -1,4 +1,4 @@ -import { HttpServer } from '@nestjs/common'; +import { HttpServer, VersioningType } from '@nestjs/common'; import { RequestMethod } from '@nestjs/common/enums/request-method.enum'; import { MiddlewareConfiguration, @@ -20,6 +20,7 @@ import { Injector } from '../injector/injector'; import { InstanceWrapper } from '../injector/instance-wrapper'; import { InstanceToken, Module } from '../injector/module'; import { REQUEST_CONTEXT_ID } from '../router/request/request-constants'; +import { RoutePathFactory } from '../router/route-path-factory'; import { RouterExceptionFilters } from '../router/router-exception-filters'; import { RouterProxy } from '../router/router-proxy'; import { isRequestMethodAll, isRouteExcluded } from '../router/utils'; @@ -40,6 +41,8 @@ export class MiddlewareModule { private container: NestContainer; private httpAdapter: HttpServer; + constructor(private readonly routePathFactory: RoutePathFactory) {} + public async register( middlewareContainer: MiddlewareContainer, container: NestContainer, @@ -174,8 +177,7 @@ export class MiddlewareModule { await this.bindHandler( instanceWrapper, applicationRef, - routeInfo.method, - routeInfo.path, + routeInfo, moduleRef, collection, ); @@ -185,8 +187,7 @@ export class MiddlewareModule { private async bindHandler( wrapper: InstanceWrapper, applicationRef: HttpServer, - method: RequestMethod, - path: string, + routeInfo: RouteInfo, moduleRef: Module, collection: Map, ) { @@ -197,12 +198,11 @@ export class MiddlewareModule { const isStatic = wrapper.isDependencyTreeStatic(); if (isStatic) { const proxy = await this.createProxy(instance); - return this.registerHandler(applicationRef, method, path, proxy); + return this.registerHandler(applicationRef, routeInfo, proxy); } await this.registerHandler( applicationRef, - method, - path, + routeInfo, async ( req: TRequest, res: TResponse, @@ -266,8 +266,7 @@ export class MiddlewareModule { private async registerHandler( applicationRef: HttpServer, - method: RequestMethod, - path: string, + { path, method, version }: RouteInfo, proxy: ( req: TRequest, res: TResponse, @@ -291,6 +290,15 @@ export class MiddlewareModule { } path = basePath + path; } + + const applicationVersioningConfig = this.config.getVersioning(); + if (version && applicationVersioningConfig.type === VersioningType.URI) { + const versionPrefix = this.routePathFactory.getVersionPrefix( + applicationVersioningConfig, + ); + path = `/${versionPrefix}${version.toString()}${path}`; + } + const isMethodAll = isRequestMethodAll(method); const requestMethod = RequestMethod[method]; const router = await applicationRef.createMiddlewareFactory(method); diff --git a/packages/core/middleware/routes-mapper.ts b/packages/core/middleware/routes-mapper.ts index bbb8ea36cf2..5309bb1093a 100644 --- a/packages/core/middleware/routes-mapper.ts +++ b/packages/core/middleware/routes-mapper.ts @@ -1,5 +1,5 @@ import { MODULE_PATH, PATH_METADATA } from '@nestjs/common/constants'; -import { RouteInfo, Type } from '@nestjs/common/interfaces'; +import { RouteInfo, Type, VersionValue } from '@nestjs/common/interfaces'; import { addLeadingSlash, isString, @@ -22,35 +22,54 @@ export class RoutesMapper { route: Type | RouteInfo | string, ): RouteInfo[] { if (isString(route)) { - const defaultRequestMethod = -1; - return [ - { - path: addLeadingSlash(route), - method: defaultRequestMethod, - }, - ]; + return this.getRouteInfoFromPath(route); } const routePathOrPaths = this.getRoutePath(route); if (this.isRouteInfo(routePathOrPaths, route)) { - return [ - { - path: addLeadingSlash(route.path), - method: route.method, - }, - ]; + return this.getRouteInfoFromObject(route); } + + return this.getRouteInfoFromController(route, routePathOrPaths); + } + + private getRouteInfoFromPath(routePath: string): RouteInfo[] { + const defaultRequestMethod = -1; + return [ + { + path: addLeadingSlash(routePath), + method: defaultRequestMethod, + }, + ]; + } + + private getRouteInfoFromObject(routeInfoObject: RouteInfo): RouteInfo[] { + const routeInfo: RouteInfo = { + path: addLeadingSlash(routeInfoObject.path), + method: routeInfoObject.method, + }; + + if (routeInfoObject.version) { + routeInfo.version = routeInfoObject.version; + } + return [routeInfo]; + } + + private getRouteInfoFromController( + controller: Type, + routePath: string, + ): RouteInfo[] { const controllerPaths = this.routerExplorer.scanForPaths( - Object.create(route), - route.prototype, + Object.create(controller), + controller.prototype, ); - const moduleRef = this.getHostModuleOfController(route); + const moduleRef = this.getHostModuleOfController(controller); const modulePath = this.getModulePath(moduleRef?.metatype); const concatPaths = (acc: T[], currentValue: T[]) => acc.concat(currentValue); return [] - .concat(routePathOrPaths) + .concat(routePath) .map(routePath => controllerPaths .map(item => @@ -58,10 +77,16 @@ export class RoutesMapper { let path = modulePath ?? ''; path += this.normalizeGlobalPath(routePath) + addLeadingSlash(p); - return { + const routeInfo: RouteInfo = { path, method: item.requestMethod, }; + + if (item.version) { + routeInfo.version = item.version; + } + + return routeInfo; }), ) .reduce(concatPaths, []), diff --git a/packages/core/nest-application.ts b/packages/core/nest-application.ts index 39a7c3424d5..a06ee4c9837 100644 --- a/packages/core/nest-application.ts +++ b/packages/core/nest-application.ts @@ -42,6 +42,7 @@ import { MiddlewareModule } from './middleware/middleware-module'; import { NestApplicationContext } from './nest-application-context'; import { ExcludeRouteMetadata } from './router/interfaces/exclude-route-metadata.interface'; import { Resolver } from './router/interfaces/resolver.interface'; +import { RoutePathFactory } from './router/route-path-factory'; import { RoutesResolver } from './router/routes-resolver'; const { SocketModule } = optionalRequire( @@ -63,7 +64,7 @@ export class NestApplication private readonly logger = new Logger(NestApplication.name, { timestamp: true, }); - private readonly middlewareModule = new MiddlewareModule(); + private readonly middlewareModule: MiddlewareModule; private readonly middlewareContainer = new MiddlewareContainer( this.container, ); @@ -85,6 +86,7 @@ export class NestApplication this.selectContextModule(); this.registerHttpServer(); + this.middlewareModule = new MiddlewareModule(new RoutePathFactory(config)); this.routesResolver = new RoutesResolver( this.container, diff --git a/packages/core/test/middleware/builder.spec.ts b/packages/core/test/middleware/builder.spec.ts index 1cc6e144ded..2ff40229d58 100644 --- a/packages/core/test/middleware/builder.spec.ts +++ b/packages/core/test/middleware/builder.spec.ts @@ -1,5 +1,5 @@ import { expect } from 'chai'; -import { Controller, Get } from '../../../common'; +import { Controller, Get, RequestMethod, Version } from '../../../common'; import { NestContainer } from '../../injector/container'; import { MiddlewareBuilder } from '../../middleware/builder'; import { RoutesMapper } from '../../middleware/routes-mapper'; @@ -30,8 +30,12 @@ describe('MiddlewareBuilder', () => { class Test { @Get('route') public getAll() {} + + @Version('1') + @Get('versioned') + public getAllVersioned() {} } - const route = { path: '/test', method: 0 }; + const route = { path: '/test', method: RequestMethod.GET }; it('should store configuration passed as argument', () => { configProxy.forRoutes(route, Test); @@ -40,13 +44,18 @@ describe('MiddlewareBuilder', () => { middleware: [], forRoutes: [ { - method: 0, + method: RequestMethod.GET, path: route.path, }, { - method: 0, + method: RequestMethod.GET, path: '/path/route', }, + { + method: RequestMethod.GET, + path: '/path/versioned', + version: '1', + }, ], }, ]); diff --git a/packages/core/test/middleware/middleware-module.spec.ts b/packages/core/test/middleware/middleware-module.spec.ts index 0b8d44f447a..65e87b6b1d3 100644 --- a/packages/core/test/middleware/middleware-module.spec.ts +++ b/packages/core/test/middleware/middleware-module.spec.ts @@ -1,4 +1,5 @@ import { Injectable } from '@nestjs/common'; +import { RoutePathFactory } from '@nestjs/core/router/route-path-factory'; import { expect } from 'chai'; import * as sinon from 'sinon'; import { Controller } from '../../../common/decorators/core/controller.decorator'; @@ -39,7 +40,7 @@ describe('MiddlewareModule', () => { beforeEach(() => { const appConfig = new ApplicationConfig(); - middlewareModule = new MiddlewareModule(); + middlewareModule = new MiddlewareModule(new RoutePathFactory(appConfig)); (middlewareModule as any).routerExceptionFilter = new RouterExceptionFilters( new NestContainer(), diff --git a/packages/core/test/middleware/routes-mapper.spec.ts b/packages/core/test/middleware/routes-mapper.spec.ts index 7c1aabd5174..f030a676caf 100644 --- a/packages/core/test/middleware/routes-mapper.spec.ts +++ b/packages/core/test/middleware/routes-mapper.spec.ts @@ -1,6 +1,11 @@ +import { Version } from '../../../common'; +import { MiddlewareConfiguration } from '../../../common/interfaces'; import { expect } from 'chai'; import { Controller } from '../../../common/decorators/core/controller.decorator'; -import { RequestMapping } from '../../../common/decorators/http/request-mapping.decorator'; +import { + Get, + RequestMapping, +} from '../../../common/decorators/http/request-mapping.decorator'; import { RequestMethod } from '../../../common/enums/request-method.enum'; import { NestContainer } from '../../injector/container'; import { RoutesMapper } from '../../middleware/routes-mapper'; @@ -13,6 +18,10 @@ describe('RoutesMapper', () => { @RequestMapping({ path: 'another', method: RequestMethod.DELETE }) public getAnother() {} + + @Version('1') + @Get('versioned') + public getVersioned() {} } let mapper: RoutesMapper; @@ -21,17 +30,27 @@ describe('RoutesMapper', () => { }); it('should map @Controller() to "ControllerMetadata" in forRoutes', () => { - const config = { + const config: MiddlewareConfiguration = { middleware: 'Test', - forRoutes: [{ path: 'test', method: RequestMethod.GET }, TestRoute], + forRoutes: [ + { path: 'test', method: RequestMethod.GET }, + { path: 'versioned', version: '1', method: RequestMethod.GET }, + TestRoute, + ], }; expect(mapper.mapRouteToRouteInfo(config.forRoutes[0])).to.deep.equal([ { path: '/test', method: RequestMethod.GET }, ]); + expect(mapper.mapRouteToRouteInfo(config.forRoutes[1])).to.deep.equal([ + { path: '/versioned', version: '1', method: RequestMethod.GET }, + ]); + + expect(mapper.mapRouteToRouteInfo(config.forRoutes[2])).to.deep.equal([ { path: '/test/test', method: RequestMethod.GET }, { path: '/test/another', method: RequestMethod.DELETE }, + { path: '/test/versioned', method: RequestMethod.GET, version: '1' }, ]); }); @Controller(['test', 'test2'])