Repository: nestjs/nest
Pull request #10025 (2022-07-26 19:28:52)

Fix/10017 parse file pipe builder
## PR Checklist
Please check if your PR fulfills the following requirements:

- [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)
- [x] 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: #10017


## What is the new behavior?

Now we can specify if a given `file` parameter is required in the `ParseFilePipeBuilder` or in the `ParseFilePipe`:
```ts
@UploadedFile(
  new ParseFilePipeBuilder()
    .addFileTypeValidator({
      fileType: 'json',
    })
    .build({
      fileIsRequired: false,
    }),
)
file?: Express.Multer.File,
``` 

With that, when a file is explicitly set to be optional, the ParseFileValidator won't throw an error. On the other hand, when the file is required (default behavior), it will now throw a 400 error.

## 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


Patch:
diff --git a/packages/common/pipes/file/parse-file-options.interface.ts b/packages/common/pipes/file/parse-file-options.interface.ts
index e74630999..7602f6997 100644
--- a/packages/common/pipes/file/parse-file-options.interface.ts
+++ b/packages/common/pipes/file/parse-file-options.interface.ts
@@ -5,4 +5,10 @@ export interface ParseFileOptions {
   validators?: FileValidator[];
   errorHttpStatusCode?: ErrorHttpStatusCode;
   exceptionFactory?: (error: string) => any;
+
+  /**
+   * Defines if file parameter is required.
+   * @default true
+   */
+  fileIsRequired?: boolean;
 }
diff --git a/packages/common/pipes/file/parse-file.pipe.ts b/packages/common/pipes/file/parse-file.pipe.ts
index 2f04024ea..3cda9a5fc 100644
--- a/packages/common/pipes/file/parse-file.pipe.ts
+++ b/packages/common/pipes/file/parse-file.pipe.ts
@@ -1,9 +1,10 @@
+import { isUndefined } from '../../utils/shared.utils';
 import { Injectable, Optional } from '../../decorators/core';
 import { HttpStatus } from '../../enums';
-import { HttpErrorByCode } from '../../utils/http-error-by-code.util';
 import { PipeTransform } from '../../interfaces/features/pipe-transform.interface';
-import { ParseFileOptions } from './parse-file-options.interface';
+import { HttpErrorByCode } from '../../utils/http-error-by-code.util';
 import { FileValidator } from './file-validator.interface';
+import { ParseFileOptions } from './parse-file-options.interface';
 
 /**
  * Defines the built-in ParseFile Pipe. This pipe can be used to validate incoming files
@@ -19,12 +20,14 @@ import { FileValidator } from './file-validator.interface';
 export class ParseFilePipe implements PipeTransform<any> {
   protected exceptionFactory: (error: string) => any;
   private readonly validators: FileValidator[];
+  private readonly fileIsRequired: boolean;
 
   constructor(@Optional() options: ParseFileOptions = {}) {
     const {
       exceptionFactory,
       errorHttpStatusCode = HttpStatus.BAD_REQUEST,
       validators = [],
+      fileIsRequired,
     } = options;
 
     this.exceptionFactory =
@@ -32,9 +35,18 @@ export class ParseFilePipe implements PipeTransform<any> {
       (error => new HttpErrorByCode[errorHttpStatusCode](error));
 
     this.validators = validators;
+    this.fileIsRequired = fileIsRequired ?? true;
   }
 
   async transform(value: any): Promise<any> {
+    if (isUndefined(value)) {
+      if (this.fileIsRequired) {
+        throw this.exceptionFactory('File is required');
+      }
+
+      return value;
+    }
+
     if (this.validators.length) {
       await this.validate(value);
     }
diff --git a/packages/core/router/router-explorer.ts b/packages/core/router/router-explorer.ts
index adeb55772..6dbbad0ae 100644
--- a/packages/core/router/router-explorer.ts
+++ b/packages/core/router/router-explorer.ts
@@ -9,7 +9,10 @@ import { VersioningType } from '@nestjs/common/enums/version-type.enum';
 import { InternalServerErrorException } from '@nestjs/common/exceptions';
 import { Controller } from '@nestjs/common/interfaces/controllers/controller.interface';
 import { Type } from '@nestjs/common/interfaces/type.interface';
-import { VersionValue } from '@nestjs/common/interfaces/version-options.interface';
+import {
+  VersionValue,
+  VERSION_NEUTRAL,
+} from '@nestjs/common/interfaces/version-options.interface';
 import { Logger } from '@nestjs/common/services/logger.service';
 import {
   addLeadingSlash,
diff --git a/sample/29-file-upload/src/app.controller.ts b/sample/29-file-upload/src/app.controller.ts
index 39d3af1fb..371a1fd69 100644
--- a/sample/29-file-upload/src/app.controller.ts
+++ b/sample/29-file-upload/src/app.controller.ts
@@ -42,13 +42,15 @@ export class AppController {
         .addFileTypeValidator({
           fileType: 'json',
         })
-        .build(),
+        .build({
+          fileIsRequired: false,
+        }),
     )
-    file: Express.Multer.File,
+    file?: Express.Multer.File,
   ) {
     return {
       body,
-      file: file.buffer.toString(),
+      file: file?.buffer.toString(),
     };
   }
 


Test patch:
diff --git a/packages/common/test/pipes/file/parse-file.pipe.spec.ts b/packages/common/test/pipes/file/parse-file.pipe.spec.ts
index ee06ef44b..0eecaf989 100644
--- a/packages/common/test/pipes/file/parse-file.pipe.spec.ts
+++ b/packages/common/test/pipes/file/parse-file.pipe.spec.ts
@@ -116,5 +116,65 @@ describe('ParseFilePipe', () => {
         });
       });
     });
+
+    describe('when fileIsRequired is false', () => {
+      beforeEach(() => {
+        parseFilePipe = new ParseFilePipe({
+          validators: [],
+          fileIsRequired: false,
+        });
+      });
+
+      it('should pass validation if no file is provided', async () => {
+        const requestFile = undefined;
+
+        await expect(parseFilePipe.transform(requestFile)).to.eventually.eql(
+          requestFile,
+        );
+      });
+    });
+
+    describe('when fileIsRequired is true', () => {
+      beforeEach(() => {
+        parseFilePipe = new ParseFilePipe({
+          validators: [],
+          fileIsRequired: true,
+        });
+      });
+
+      it('should throw an error if no file is provided', async () => {
+        const requestFile = undefined;
+
+        await expect(parseFilePipe.transform(requestFile)).to.be.rejectedWith(
+          BadRequestException,
+        );
+      });
+
+      it('should pass validation if a file is provided', async () => {
+        const requestFile = {
+          path: 'some-path',
+        };
+
+        await expect(parseFilePipe.transform(requestFile)).to.eventually.eql(
+          requestFile,
+        );
+      });
+    });
+
+    describe('when fileIsRequired is not explicitly provided', () => {
+      beforeEach(() => {
+        parseFilePipe = new ParseFilePipe({
+          validators: [new AlwaysInvalidValidator({})],
+        });
+      });
+
+      it('should throw an error if no file is provided', async () => {
+        const requestFile = undefined;
+
+        await expect(parseFilePipe.transform(requestFile)).to.be.rejectedWith(
+          BadRequestException,
+        );
+      });
+    });
   });
 });
diff --git a/sample/29-file-upload/e2e/app/app.e2e-spec.ts b/sample/29-file-upload/e2e/app/app.e2e-spec.ts
index 4107251ff..6f8bc9034 100644
--- a/sample/29-file-upload/e2e/app/app.e2e-spec.ts
+++ b/sample/29-file-upload/e2e/app/app.e2e-spec.ts
@@ -51,6 +51,18 @@ describe('E2E FileTest', () => {
       .expect(400);
   });
 
+  it('should throw when file is required but no file is uploaded', async () => {
+    return request(app.getHttpServer())
+      .post('/file/fail-validation')
+      .expect(400);
+  });
+
+  it('should allow for optional file uploads with validation enabled (fixes #10017)', () => {
+    return request(app.getHttpServer())
+      .post('/file/pass-validation')
+      .expect(201);
+  });
+
   afterAll(async () => {
     await app.close();
   });