Repository: nestjs/nest (ts)

Issue:
Multiple version including VERSION_NEUTRAL return 404
### Is there an existing issue for this?

- [X] I have searched the existing issues

### Current behavior

when I use version as array with `VERSION_NEUTRAL` the request without version responses 404 

### Minimum reproduction code

https://github.com/MarcosRava/nest-js-multiple-version-with-neutral

### Steps to reproduce

1. `yarn install`
2.  `yarn test:e2e`

See the error on the console

### Expected behavior

Expect 200 as response status

### Package

- [ ] I don't know. Or some 3rd-party package
- [ ] <code>@nestjs/common</code>
- [X] <code>@nestjs/core</code>
- [ ] <code>@nestjs/microservices</code>
- [ ] <code>@nestjs/platform-express</code>
- [ ] <code>@nestjs/platform-fastify</code>
- [ ] <code>@nestjs/platform-socket.io</code>
- [ ] <code>@nestjs/platform-ws</code>
- [ ] <code>@nestjs/testing</code>
- [ ] <code>@nestjs/websockets</code>
- [ ] Other (see below)

### Other package

_No response_

### NestJS version

8.4.5

### Packages versions

```json
{
  "name": "nest-js-multiple-version-with-neutral",
  "version": "0.0.1",
  "description": "",
  "author": "",
  "private": true,
  "license": "UNLICENSED",
  "scripts": {
    "prebuild": "rimraf dist",
    "build": "nest build",
    "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
    "start": "nest start",
    "start:dev": "nest start --watch",
    "start:debug": "nest start --debug --watch",
    "start:prod": "node dist/main",
    "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
    "test": "jest",
    "test:watch": "jest --watch",
    "test:cov": "jest --coverage",
    "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
    "test:e2e": "jest --config ./test/jest-e2e.json"
  },
  "dependencies": {
    "@nestjs/common": "^8.0.0",
    "@nestjs/core": "^8.0.0",
    "@nestjs/platform-express": "^8.0.0",
    "reflect-metadata": "^0.1.13",
    "rimraf": "^3.0.2",
    "rxjs": "^7.2.0",
    "semver": "^7.3.7"
  },
  "devDependencies": {
    "@nestjs/cli": "^8.0.0",
    "@nestjs/schematics": "^8.0.0",
    "@nestjs/testing": "^8.0.0",
    "@types/express": "^4.17.13",
    "@types/jest": "27.5.0",
    "@types/node": "^16.0.0",
    "@types/supertest": "^2.0.11",
    "@typescript-eslint/eslint-plugin": "^5.0.0",
    "@typescript-eslint/parser": "^5.0.0",
    "eslint": "^8.0.1",
    "eslint-config-prettier": "^8.3.0",
    "eslint-plugin-prettier": "^4.0.0",
    "jest": "28.0.3",
    "prettier": "^2.3.2",
    "source-map-support": "^0.5.20",
    "supertest": "^6.1.3",
    "ts-jest": "28.0.1",
    "ts-loader": "^9.2.3",
    "ts-node": "^10.0.0",
    "tsconfig-paths": "4.0.0",
    "typescript": "^4.3.5"
  },
  "jest": {
    "moduleFileExtensions": [
      "js",
      "json",
      "ts"
    ],
    "rootDir": "src",
    "testRegex": ".*\\.spec\\.ts$",
    "transform": {
      "^.+\\.(t|j)s$": "ts-jest"
    },
    "collectCoverageFrom": [
      "**/*.(t|j)s"
    ],
    "coverageDirectory": "../coverage",
    "testEnvironment": "node"
  }
}

```


### Node.js version

v16.13.1

### In which operating systems have you tested?

- [X] macOS
- [ ] Windows
- [ ] Linux

### Other

_No response_

Pull request description:
fix: when version is an array with neutral version
- [x] The commit message follows our guidelines: https://github.com/nestjs/nest/blob/master/CONTRIBUTING.md
- [x] Tests for the changes have been added (for bug fixes / features)
- [ ] Docs have been added / updated (for bug fixes / features)


## PR Type
What kind of change does this PR introduce?

<!-- Please check the one that applies to this PR using "x". -->
- [x] Bugfix
- [ ] Feature
- [ ] Code style update (formatting, local variables)
- [ ] Refactoring (no functional changes, no api changes)
- [ ] Build related changes
- [ ] CI related changes
- [ ] Other... Please describe:

## What is the current behavior?
<!-- Please describe the current behavior that you are modifying, or link to a relevant issue. -->

Issue Number: closes #9581

## What is the new behavior?

now having `version: [VERSION_NEUTRAL]` will behave the same as `version: VERSION_NEUTRAL`

## Does this PR introduce a breaking change?
- [ ] Yes
- [x] No

<!-- If this PR contains a breaking change, please describe the impact and migration path for existing applications below. -->


## Other information

I've tested those changes with both adapters in a local project of mine

Patch:
diff --git a/packages/core/router/router-explorer.ts b/packages/core/router/router-explorer.ts
index ee509718b..ab0517dc5 100644
--- a/packages/core/router/router-explorer.ts
+++ b/packages/core/router/router-explorer.ts
@@ -338,8 +338,7 @@ export class RouterExplorer {
       return router.applyVersionFilter(handler, version, versioningOptions);
     }
     /**
-     * This can be removed in the next major release.
-     * Left for backward-compatibility.
+     * TODO(v9): This was left for backward-compatibility and can be removed.
      */
     return <TRequest extends Record<string, any> = any, TResponse = any>(
       req: TRequest,
@@ -403,9 +402,16 @@ export class RouterExplorer {
 
         const acceptHeaderVersionParameter = acceptHeaderValue
           ? acceptHeaderValue.split(';')[1]
-          : '';
+          : undefined;
 
-        if (acceptHeaderVersionParameter) {
+        // No version was supplied
+        if (isUndefined(acceptHeaderVersionParameter)) {
+          if (Array.isArray(version)) {
+            if (version.includes(VERSION_NEUTRAL)) {
+              return handler(req, res, next);
+            }
+          }
+        } else {
           const headerVersion = acceptHeaderVersionParameter.split(
             versioningOptions.key,
           )[1];
@@ -427,7 +433,14 @@ export class RouterExplorer {
           req.headers?.[versioningOptions.header] ||
           req.headers?.[versioningOptions.header.toLowerCase()];
 
-        if (customHeaderVersionParameter) {
+        // No version was supplied
+        if (isUndefined(customHeaderVersionParameter)) {
+          if (Array.isArray(version)) {
+            if (version.includes(VERSION_NEUTRAL)) {
+              return handler(req, res, next);
+            }
+          }
+        } else {
           if (Array.isArray(version)) {
             if (version.includes(customHeaderVersionParameter)) {
               return handler(req, res, next);
diff --git a/packages/platform-express/adapters/express-adapter.ts b/packages/platform-express/adapters/express-adapter.ts
index 7a832e0ff..b8dcf9340 100644
--- a/packages/platform-express/adapters/express-adapter.ts
+++ b/packages/platform-express/adapters/express-adapter.ts
@@ -19,6 +19,7 @@ import {
   isNil,
   isObject,
   isString,
+  isUndefined,
 } from '@nestjs/common/utils/shared.utils';
 import { AbstractHttpAdapter } from '@nestjs/core/adapters/http-adapter';
 import { RouterMethodFactory } from '@nestjs/core/helpers/router-method-factory';
@@ -256,9 +257,16 @@ export class ExpressAdapter extends AbstractHttpAdapter {
 
         const acceptHeaderVersionParameter = acceptHeaderValue
           ? acceptHeaderValue.split(';')[1]
-          : '';
+          : undefined;
 
-        if (acceptHeaderVersionParameter) {
+        // No version was supplied
+        if (isUndefined(acceptHeaderVersionParameter)) {
+          if (Array.isArray(version)) {
+            if (version.includes(VERSION_NEUTRAL)) {
+              return handler(req, res, next);
+            }
+          }
+        } else {
           const headerVersion = acceptHeaderVersionParameter.split(
             versioningOptions.key,
           )[1];
@@ -280,7 +288,14 @@ export class ExpressAdapter extends AbstractHttpAdapter {
           req.headers?.[versioningOptions.header] ||
           req.headers?.[versioningOptions.header.toLowerCase()];
 
-        if (customHeaderVersionParameter) {
+        // No version was supplied
+        if (isUndefined(customHeaderVersionParameter)) {
+          if (Array.isArray(version)) {
+            if (version.includes(VERSION_NEUTRAL)) {
+              return handler(req, res, next);
+            }
+          }
+        } else {
           if (Array.isArray(version)) {
             if (version.includes(customHeaderVersionParameter)) {
               return handler(req, res, next);
diff --git a/packages/platform-fastify/adapters/fastify-adapter.ts b/packages/platform-fastify/adapters/fastify-adapter.ts
index fe668b1c3..ebf64274e 100644
--- a/packages/platform-fastify/adapters/fastify-adapter.ts
+++ b/packages/platform-fastify/adapters/fastify-adapter.ts
@@ -154,12 +154,9 @@ export class FastifyAdapter<
           ? acceptHeaderValue.split(';')[1]
           : '';
 
-        if (acceptHeaderVersionParameter) {
-          const headerVersion = acceptHeaderVersionParameter.split(
-            this.versioningOptions.key,
-          )[1];
-          return headerVersion;
-        }
+        return isUndefined(acceptHeaderVersionParameter)
+          ? VERSION_NEUTRAL // No version was supplied
+          : acceptHeaderVersionParameter.split(this.versioningOptions.key)[1];
       }
       // Header Versioning Handler
       else if (this.versioningOptions.type === VersioningType.HEADER) {
@@ -167,9 +164,9 @@ export class FastifyAdapter<
           req.headers?.[this.versioningOptions.header] ||
           req.headers?.[this.versioningOptions.header.toLowerCase()];
 
-        if (customHeaderVersionParameter) {
-          return customHeaderVersionParameter;
-        }
+        return isUndefined(customHeaderVersionParameter)
+          ? VERSION_NEUTRAL // No version was supplied
+          : customHeaderVersionParameter;
       }
       // Custom Versioning Handler
       else if (this.versioningOptions.type === VersioningType.CUSTOM) {


Test patch:
diff --git a/packages/core/test/router/router-explorer.spec.ts b/packages/core/test/router/router-explorer.spec.ts
index e1977f36e..5f5343210 100644
--- a/packages/core/test/router/router-explorer.spec.ts
+++ b/packages/core/test/router/router-explorer.spec.ts
@@ -365,38 +365,38 @@ describe('RouterExplorer', () => {
   });
 
   describe('applyVersionFilter', () => {
-    describe('when the version is VERSION_NEUTRAL', () => {
-      it('should return the handler', () => {
-        const version = VERSION_NEUTRAL as VersionValue;
-        const versioningOptions: VersioningOptions = {
-          type: VersioningType.URI,
-        };
-        const handler = sinon.stub();
+    describe('when the versioning type is URI', () => {
+      describe('and the version is VERSION_NEUTRAL', () => {
+        it('should return the handler', () => {
+          const version: VersionValue = VERSION_NEUTRAL;
+          const versioningOptions: RoutePathMetadata['versioningOptions'] = {
+            type: VersioningType.URI,
+          };
+          const handler = sinon.stub();
 
-        const routePathMetadata: RoutePathMetadata = {
-          methodVersion: version,
-          versioningOptions,
-        };
-        const versionFilter = (routerBuilder as any).applyVersionFilter(
-          null,
-          routePathMetadata,
-          handler,
-        );
+          const routePathMetadata: RoutePathMetadata = {
+            methodVersion: version,
+            versioningOptions,
+          };
+          const versionFilter = (routerBuilder as any).applyVersionFilter(
+            null,
+            routePathMetadata,
+            handler,
+          );
 
-        const req = {};
-        const res = {};
-        const next = sinon.stub();
+          const req = {};
+          const res = {};
+          const next = sinon.stub();
 
-        versionFilter(req, res, next);
+          versionFilter(req, res, next);
 
-        expect(handler.calledWith(req, res, next)).to.be.true;
+          expect(handler.calledWith(req, res, next)).to.be.true;
+        });
       });
-    });
 
-    describe('when the versioning type is URI', () => {
       it('should return the handler', () => {
         const version = '1';
-        const versioningOptions: VersioningOptions = {
+        const versioningOptions: RoutePathMetadata['versioningOptions'] = {
           type: VersioningType.URI,
         };
         const handler = sinon.stub();
@@ -423,7 +423,7 @@ describe('RouterExplorer', () => {
     describe('when the versioning type is MEDIA_TYPE', () => {
       it('should return next if there is no Media Type header', () => {
         const version = '1';
-        const versioningOptions: VersioningOptions = {
+        const versioningOptions: RoutePathMetadata['versioningOptions'] = {
           type: VersioningType.MEDIA_TYPE,
           key: 'v=',
         };
@@ -450,7 +450,7 @@ describe('RouterExplorer', () => {
 
       it('should return next if there is no version in the Media Type header', () => {
         const version = '1';
-        const versioningOptions: VersioningOptions = {
+        const versioningOptions: RoutePathMetadata['versioningOptions'] = {
           type: VersioningType.MEDIA_TYPE,
           key: 'v=',
         };
@@ -476,9 +476,64 @@ describe('RouterExplorer', () => {
       });
 
       describe('when the handler version is an array', () => {
+        describe('and the version has VERSION_NEUTRAL', () => {
+          it('should return the handler if there is no version in the Media Type header', () => {
+            const version: VersionValue = [VERSION_NEUTRAL];
+            const versioningOptions: RoutePathMetadata['versioningOptions'] = {
+              type: VersioningType.MEDIA_TYPE,
+              key: 'v=',
+            };
+            const handler = sinon.stub();
+
+            const routePathMetadata: RoutePathMetadata = {
+              methodVersion: version,
+              versioningOptions,
+            };
+            const versionFilter = (routerBuilder as any).applyVersionFilter(
+              null,
+              routePathMetadata,
+              handler,
+            );
+
+            const req = {};
+            const res = {};
+            const next = sinon.stub();
+
+            versionFilter(req, res, next);
+
+            expect(handler.calledWith(req, res, next)).to.be.true;
+          });
+          it('should return next if the version in the Media Type header does not match the handler version', () => {
+            const version: VersionValue = ['1', '2', VERSION_NEUTRAL];
+            const versioningOptions: RoutePathMetadata['versioningOptions'] = {
+              type: VersioningType.MEDIA_TYPE,
+              key: 'v=',
+            };
+            const handler = sinon.stub();
+
+            const routePathMetadata: RoutePathMetadata = {
+              methodVersion: version,
+              versioningOptions,
+            };
+            const versionFilter = (routerBuilder as any).applyVersionFilter(
+              null,
+              routePathMetadata,
+              handler,
+            );
+
+            const req = { headers: { accept: 'application/json;v=3' } };
+            const res = {};
+            const next = sinon.stub();
+
+            versionFilter(req, res, next);
+
+            expect(next.called).to.be.true;
+          });
+        });
+
         it('should return next if the version in the Media Type header does not match the handler version', () => {
           const version = ['1', '2'];
-          const versioningOptions: VersioningOptions = {
+          const versioningOptions: RoutePathMetadata['versioningOptions'] = {
             type: VersioningType.MEDIA_TYPE,
             key: 'v=',
           };
@@ -505,7 +560,7 @@ describe('RouterExplorer', () => {
 
         it('should return the handler if the version in the Media Type header matches the handler version', () => {
           const version = ['1', '2'];
-          const versioningOptions: VersioningOptions = {
+          const versioningOptions: RoutePathMetadata['versioningOptions'] = {
             type: VersioningType.MEDIA_TYPE,
             key: 'v=',
           };
@@ -534,7 +589,7 @@ describe('RouterExplorer', () => {
       describe('when the handler version is a string', () => {
         it('should return next if the version in the Media Type header does not match the handler version', () => {
           const version = '1';
-          const versioningOptions: VersioningOptions = {
+          const versioningOptions: RoutePathMetadata['versioningOptions'] = {
             type: VersioningType.MEDIA_TYPE,
             key: 'v=',
           };
@@ -561,7 +616,7 @@ describe('RouterExplorer', () => {
 
         it('should return the handler if the version in the Media Type header matches the handler version', () => {
           const version = '1';
-          const versioningOptions: VersioningOptions = {
+          const versioningOptions: RoutePathMetadata['versioningOptions'] = {
             type: VersioningType.MEDIA_TYPE,
             key: 'v=',
           };
@@ -767,7 +822,7 @@ describe('RouterExplorer', () => {
     describe('when the versioning type is HEADER', () => {
       it('should return next if there is no Custom Header', () => {
         const version = '1';
-        const versioningOptions: VersioningOptions = {
+        const versioningOptions: RoutePathMetadata['versioningOptions'] = {
           type: VersioningType.HEADER,
           header: 'X-API-Version',
         };
@@ -794,7 +849,7 @@ describe('RouterExplorer', () => {
 
       it('should return next if there is no version in the Custom Header', () => {
         const version = '1';
-        const versioningOptions: VersioningOptions = {
+        const versioningOptions: RoutePathMetadata['versioningOptions'] = {
           type: VersioningType.HEADER,
           header: 'X-API-Version',
         };
@@ -820,9 +875,64 @@ describe('RouterExplorer', () => {
       });
 
       describe('when the handler version is an array', () => {
+        describe('and the version has VERSION_NEUTRAL', () => {
+          it('should return the handler if there is no version in the Custom Header', () => {
+            const version: VersionValue = [VERSION_NEUTRAL];
+            const versioningOptions: RoutePathMetadata['versioningOptions'] = {
+              type: VersioningType.HEADER,
+              header: 'X-API-Version',
+            };
+            const handler = sinon.stub();
+
+            const routePathMetadata: RoutePathMetadata = {
+              methodVersion: version,
+              versioningOptions,
+            };
+            const versionFilter = (routerBuilder as any).applyVersionFilter(
+              null,
+              routePathMetadata,
+              handler,
+            );
+
+            const req = {};
+            const res = {};
+            const next = sinon.stub();
+
+            versionFilter(req, res, next);
+
+            expect(handler.calledWith(req, res, next)).to.be.true;
+          });
+          it('should return next if the version in the Custom Header does not match the handler version', () => {
+            const version: VersionValue = ['1', '2', VERSION_NEUTRAL];
+            const versioningOptions: RoutePathMetadata['versioningOptions'] = {
+              type: VersioningType.HEADER,
+              header: 'X-API-Version',
+            };
+            const handler = sinon.stub();
+
+            const routePathMetadata: RoutePathMetadata = {
+              methodVersion: version,
+              versioningOptions,
+            };
+            const versionFilter = (routerBuilder as any).applyVersionFilter(
+              null,
+              routePathMetadata,
+              handler,
+            );
+
+            const req = { headers: { 'X-API-Version': '3' } };
+            const res = {};
+            const next = sinon.stub();
+
+            versionFilter(req, res, next);
+
+            expect(next.called).to.be.true;
+          });
+        });
+
         it('should return next if the version in the Custom Header does not match the handler version', () => {
           const version = ['1', '2'];
-          const versioningOptions: VersioningOptions = {
+          const versioningOptions: RoutePathMetadata['versioningOptions'] = {
             type: VersioningType.HEADER,
             header: 'X-API-Version',
           };
@@ -849,7 +959,7 @@ describe('RouterExplorer', () => {
 
         it('should return the handler if the version in the Custom Header matches the handler version', () => {
           const version = ['1', '2'];
-          const versioningOptions: VersioningOptions = {
+          const versioningOptions: RoutePathMetadata['versioningOptions'] = {
             type: VersioningType.HEADER,
             header: 'X-API-Version',
           };
@@ -878,7 +988,7 @@ describe('RouterExplorer', () => {
       describe('when the handler version is a string', () => {
         it('should return next if the version in the Custom Header does not match the handler version', () => {
           const version = '1';
-          const versioningOptions: VersioningOptions = {
+          const versioningOptions: RoutePathMetadata['versioningOptions'] = {
             type: VersioningType.HEADER,
             header: 'X-API-Version',
           };
@@ -905,7 +1015,7 @@ describe('RouterExplorer', () => {
 
         it('should return the handler if the version in the Custom Header matches the handler version', () => {
           const version = '1';
-          const versioningOptions: VersioningOptions = {
+          const versioningOptions: RoutePathMetadata['versioningOptions'] = {
             type: VersioningType.HEADER,
             header: 'X-API-Version',
           };