Repository: nestjs/nest
Pull request #9718 (2022-06-02 17:52:12)

Feature/4752 file validators pipe
## 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)
- [ ] 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". -->
- [ ] Bugfix
- [x] 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?
We don't have validation options out of the box for the `@UploadedFile()` decorator.

Issue Number: #4752


## What is the new behavior?

We can use some default validators using pipes: `MaxFileSizeValidator` and `FileTypeValidator`. Also, we can pass our own validators if they follow the `FileValidator` interface.

## 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

This still needs tests to verify if it's actually working with files (e2e tests maybe?)
This is a continuation of a previous, stale [PR](https://github.com/nestjs/nest/pull/8965)


Patch:
diff --git a/packages/common/pipes/file/file-type.validator.ts b/packages/common/pipes/file/file-type.validator.ts
new file mode 100644
index 000000000..334049149
--- /dev/null
+++ b/packages/common/pipes/file/file-type.validator.ts
@@ -0,0 +1,30 @@
+import { FileValidator } from './file-validator.interface';
+
+export type FileTypeValidatorOptions = {
+  fileType: string;
+};
+
+/**
+ * Defines the built-in FileType File Validator
+ *
+ * @see [File Validators](https://docs.nestjs.com/techniques/file-upload#validators)
+ *
+ * @publicApi
+ */
+export class FileTypeValidator extends FileValidator<FileTypeValidatorOptions> {
+  buildErrorMessage(): string {
+    return `Validation failed (expected type is ${this.validationOptions.fileType})`;
+  }
+
+  isValid(file: any): boolean {
+    if (!this.validationOptions) {
+      return true;
+    }
+
+    if (!file.mimetype) {
+      return false;
+    }
+
+    return (file.mimetype as string).endsWith(this.validationOptions.fileType);
+  }
+}
diff --git a/packages/common/pipes/file/file-validator.interface.ts b/packages/common/pipes/file/file-validator.interface.ts
new file mode 100644
index 000000000..ad453cdae
--- /dev/null
+++ b/packages/common/pipes/file/file-validator.interface.ts
@@ -0,0 +1,18 @@
+/**
+ * Interface describing FileValidators, which can be added to a {@link ParseFilePipe}.
+ */
+export abstract class FileValidator<TValidationOptions = Record<string, any>> {
+  constructor(protected readonly validationOptions: TValidationOptions) {}
+
+  /**
+   * Indicates if this file should be considered valid, according to the options passed in the constructor.
+   * @param file the file from the request object
+   */
+  abstract isValid(file?: any): boolean | Promise<boolean>;
+
+  /**
+   * Builds an error message in case the validation fails.
+   * @param file the file from the request object
+   */
+  abstract buildErrorMessage(file: any): string;
+}
diff --git a/packages/common/pipes/file/index.ts b/packages/common/pipes/file/index.ts
new file mode 100644
index 000000000..11d19e318
--- /dev/null
+++ b/packages/common/pipes/file/index.ts
@@ -0,0 +1,6 @@
+export * from './file-type.validator';
+export * from './file-validator.interface';
+export * from './max-file-size.validator';
+export * from './parse-file-options.interface';
+export * from './parse-file.pipe';
+export * from './parse-file-pipe.builder';
diff --git a/packages/common/pipes/file/max-file-size.validator.ts b/packages/common/pipes/file/max-file-size.validator.ts
new file mode 100644
index 000000000..847ee19f0
--- /dev/null
+++ b/packages/common/pipes/file/max-file-size.validator.ts
@@ -0,0 +1,26 @@
+import { FileValidator } from './file-validator.interface';
+
+export type MaxFileSizeValidatorOptions = {
+  maxSize: number;
+};
+
+/**
+ * Defines the built-in MaxSize File Validator
+ *
+ * @see [File Validators](https://docs.nestjs.com/techniques/file-upload#validators)
+ *
+ * @publicApi
+ */
+export class MaxFileSizeValidator extends FileValidator<MaxFileSizeValidatorOptions> {
+  buildErrorMessage(): string {
+    return `Validation failed (expected size is less than ${this.validationOptions.maxSize})`;
+  }
+
+  public isValid(file: any): boolean {
+    if (!this.validationOptions) {
+      return true;
+    }
+
+    return file.size < this.validationOptions.maxSize;
+  }
+}
diff --git a/packages/common/pipes/file/parse-file-options.interface.ts b/packages/common/pipes/file/parse-file-options.interface.ts
new file mode 100644
index 000000000..e74630999
--- /dev/null
+++ b/packages/common/pipes/file/parse-file-options.interface.ts
@@ -0,0 +1,8 @@
+import { ErrorHttpStatusCode } from '../../utils/http-error-by-code.util';
+import { FileValidator } from './file-validator.interface';
+
+export interface ParseFileOptions {
+  validators?: FileValidator[];
+  errorHttpStatusCode?: ErrorHttpStatusCode;
+  exceptionFactory?: (error: string) => any;
+}
diff --git a/packages/common/pipes/file/parse-file-pipe.builder.ts b/packages/common/pipes/file/parse-file-pipe.builder.ts
new file mode 100644
index 000000000..7835322d9
--- /dev/null
+++ b/packages/common/pipes/file/parse-file-pipe.builder.ts
@@ -0,0 +1,37 @@
+import {
+  FileTypeValidator,
+  FileTypeValidatorOptions,
+} from './file-type.validator';
+import { FileValidator } from './file-validator.interface';
+import {
+  MaxFileSizeValidator,
+  MaxFileSizeValidatorOptions,
+} from './max-file-size.validator';
+import { ParseFileOptions } from './parse-file-options.interface';
+import { ParseFilePipe } from './parse-file.pipe';
+
+export class ParseFilePipeBuilder {
+  private validators: FileValidator[] = [];
+
+  addMaxSizeValidator(options: MaxFileSizeValidatorOptions) {
+    this.validators.push(new MaxFileSizeValidator(options));
+    return this;
+  }
+
+  addFileTypeValidator(options: FileTypeValidatorOptions) {
+    this.validators.push(new FileTypeValidator(options));
+    return this;
+  }
+
+  build(
+    additionalOptions?: Omit<ParseFileOptions, 'validators'>,
+  ): ParseFilePipe {
+    const parseFilePipe = new ParseFilePipe({
+      ...additionalOptions,
+      validators: this.validators,
+    });
+
+    this.validators = [];
+    return parseFilePipe;
+  }
+}
diff --git a/packages/common/pipes/file/parse-file.pipe.ts b/packages/common/pipes/file/parse-file.pipe.ts
new file mode 100644
index 000000000..e952c38f5
--- /dev/null
+++ b/packages/common/pipes/file/parse-file.pipe.ts
@@ -0,0 +1,68 @@
+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 { FileValidator } from './file-validator.interface';
+import { throws } from 'assert';
+
+/**
+ * Defines the built-in ParseFile Pipe. This pipe can be used to validate incoming files
+ * with `@UploadedFile()` decorator. You can use either other specific built-in validators
+ * or provide one of your own, simply implementing it through {@link FileValidator}
+ * interface and adding it to ParseFilePipe's constructor.
+ *
+ * @see [Built-in Pipes](https://docs.nestjs.com/pipes#built-in-pipes)
+ *
+ * @publicApi
+ */
+@Injectable()
+export class ParseFilePipe implements PipeTransform<any> {
+  protected exceptionFactory: (error: string) => any;
+  private readonly validators: FileValidator[];
+
+  constructor(@Optional() options: ParseFileOptions = {}) {
+    const {
+      exceptionFactory,
+      errorHttpStatusCode = HttpStatus.BAD_REQUEST,
+      validators = [],
+    } = options;
+
+    this.exceptionFactory =
+      exceptionFactory ||
+      (error => new HttpErrorByCode[errorHttpStatusCode](error));
+
+    this.validators = validators;
+  }
+
+  async transform(value: any): Promise<any> {
+    if (this.validators.length) {
+      await this.validate(value);
+    }
+    return value;
+  }
+
+  protected async validate(file: any): Promise<any> {
+    for (const validator of this.validators) {
+      await this.validateOrThrow(file, validator);
+    }
+
+    return file;
+  }
+
+  private async validateOrThrow(file: any, validator: FileValidator) {
+    const isValid = await validator.isValid(file);
+
+    if (!isValid) {
+      const errorMessage = validator.buildErrorMessage(file);
+      throw this.exceptionFactory(errorMessage);
+    }
+  }
+
+  /**
+   * @returns list of validators used in this pipe.
+   */
+  getValidators() {
+    return this.validators;
+  }
+}
diff --git a/packages/common/pipes/index.ts b/packages/common/pipes/index.ts
index a69365493..ebbc55f27 100644
--- a/packages/common/pipes/index.ts
+++ b/packages/common/pipes/index.ts
@@ -6,3 +6,4 @@ export * from './parse-float.pipe';
 export * from './parse-enum.pipe';
 export * from './parse-uuid.pipe';
 export * from './validation.pipe';
+export * from './file';
diff --git a/sample/29-file-upload/src/app.controller.ts b/sample/29-file-upload/src/app.controller.ts
index dc59cd260..39d3af1fb 100644
--- a/sample/29-file-upload/src/app.controller.ts
+++ b/sample/29-file-upload/src/app.controller.ts
@@ -2,6 +2,7 @@ import {
   Body,
   Controller,
   Get,
+  ParseFilePipeBuilder,
   Post,
   UploadedFile,
   UseInterceptors,
@@ -31,4 +32,42 @@ export class AppController {
       file: file.buffer.toString(),
     };
   }
+
+  @UseInterceptors(FileInterceptor('file'))
+  @Post('file/pass-validation')
+  uploadFileAndPassValidation(
+    @Body() body: SampleDto,
+    @UploadedFile(
+      new ParseFilePipeBuilder()
+        .addFileTypeValidator({
+          fileType: 'json',
+        })
+        .build(),
+    )
+    file: Express.Multer.File,
+  ) {
+    return {
+      body,
+      file: file.buffer.toString(),
+    };
+  }
+
+  @UseInterceptors(FileInterceptor('file'))
+  @Post('file/fail-validation')
+  uploadFileAndFailValidation(
+    @Body() body: SampleDto,
+    @UploadedFile(
+      new ParseFilePipeBuilder()
+        .addFileTypeValidator({
+          fileType: 'jpg',
+        })
+        .build(),
+    )
+    file: Express.Multer.File,
+  ) {
+    return {
+      body,
+      file: file.buffer.toString(),
+    };
+  }
 }


Test patch:
diff --git a/packages/common/test/pipes/file/file-type.validator.spec.ts b/packages/common/test/pipes/file/file-type.validator.spec.ts
new file mode 100644
index 000000000..c4248f66a
--- /dev/null
+++ b/packages/common/test/pipes/file/file-type.validator.spec.ts
@@ -0,0 +1,65 @@
+import { FileTypeValidator } from '../../../pipes';
+import { expect } from 'chai';
+
+describe('FileTypeValidator', () => {
+  describe('isValid', () => {
+    it('should return true when the file mimetype is the same as the specified', () => {
+      const fileTypeValidator = new FileTypeValidator({
+        fileType: 'image/jpeg',
+      });
+
+      const requestFile = {
+        mimetype: 'image/jpeg',
+      };
+
+      expect(fileTypeValidator.isValid(requestFile)).to.equal(true);
+    });
+
+    it('should return true when the file mimetype ends with the specified option type', () => {
+      const fileTypeValidator = new FileTypeValidator({
+        fileType: 'jpeg',
+      });
+
+      const requestFile = {
+        mimetype: 'image/jpeg',
+      };
+
+      expect(fileTypeValidator.isValid(requestFile)).to.equal(true);
+    });
+
+    it('should return false when the file mimetype is different from the specified', () => {
+      const fileTypeValidator = new FileTypeValidator({
+        fileType: 'image/jpeg',
+      });
+
+      const requestFile = {
+        mimetype: 'image/png',
+      };
+
+      expect(fileTypeValidator.isValid(requestFile)).to.equal(false);
+    });
+
+    it('should return false when the file mimetype was not provided', () => {
+      const fileTypeValidator = new FileTypeValidator({
+        fileType: 'image/jpeg',
+      });
+
+      const requestFile = {};
+
+      expect(fileTypeValidator.isValid(requestFile)).to.equal(false);
+    });
+  });
+
+  describe('buildErrorMessage', () => {
+    it('should return a string with the format "Validation failed (expected type is #fileType)"', () => {
+      const fileType = 'image/jpeg';
+      const fileTypeValidator = new FileTypeValidator({
+        fileType,
+      });
+
+      expect(fileTypeValidator.buildErrorMessage()).to.equal(
+        `Validation failed (expected type is ${fileType})`,
+      );
+    });
+  });
+});
diff --git a/packages/common/test/pipes/file/max-file-size.validator.spec.ts b/packages/common/test/pipes/file/max-file-size.validator.spec.ts
new file mode 100644
index 000000000..9ba8e4283
--- /dev/null
+++ b/packages/common/test/pipes/file/max-file-size.validator.spec.ts
@@ -0,0 +1,56 @@
+import { expect } from 'chai';
+import { MaxFileSizeValidator } from '../../../pipes';
+
+describe('MaxFileSizeValidator', () => {
+  const oneKb = 1024;
+
+  describe('isValid', () => {
+    it('should return true when the file size is less than the maximum size', () => {
+      const maxFileSizeValidator = new MaxFileSizeValidator({
+        maxSize: oneKb,
+      });
+
+      const requestFile = {
+        size: 100,
+      };
+
+      expect(maxFileSizeValidator.isValid(requestFile)).to.equal(true);
+    });
+
+    it('should return false when the file size is greater than the maximum size', () => {
+      const maxFileSizeValidator = new MaxFileSizeValidator({
+        maxSize: oneKb,
+      });
+
+      const requestFile = {
+        size: oneKb + 1,
+      };
+
+      expect(maxFileSizeValidator.isValid(requestFile)).to.equal(false);
+    });
+
+    it('should return false when the file size is equal to the maximum size', () => {
+      const maxFileSizeValidator = new MaxFileSizeValidator({
+        maxSize: oneKb,
+      });
+
+      const requestFile = {
+        size: oneKb,
+      };
+
+      expect(maxFileSizeValidator.isValid(requestFile)).to.equal(false);
+    });
+  });
+
+  describe('buildErrorMessage', () => {
+    it('should return a string with the format "Validation failed (expected size is less than #maxSize")', () => {
+      const maxFileSizeValidator = new MaxFileSizeValidator({
+        maxSize: oneKb,
+      });
+
+      expect(maxFileSizeValidator.buildErrorMessage()).to.equal(
+        `Validation failed (expected size is less than ${oneKb})`,
+      );
+    });
+  });
+});
diff --git a/packages/common/test/pipes/file/parse-file-pipe.builder.spec.ts b/packages/common/test/pipes/file/parse-file-pipe.builder.spec.ts
new file mode 100644
index 000000000..8c31904aa
--- /dev/null
+++ b/packages/common/test/pipes/file/parse-file-pipe.builder.spec.ts
@@ -0,0 +1,77 @@
+import { expect } from 'chai';
+import {
+  FileTypeValidator,
+  MaxFileSizeValidator,
+  ParseFilePipeBuilder,
+} from '../../../pipes';
+
+describe('ParseFilePipeBuilder', () => {
+  let parseFilePipeBuilder: ParseFilePipeBuilder;
+
+  beforeEach(() => {
+    parseFilePipeBuilder = new ParseFilePipeBuilder();
+  });
+
+  describe('build', () => {
+    describe('when no validator was passed', () => {
+      it('should return a ParseFilePipe with no validators', () => {
+        const parseFilePipe = parseFilePipeBuilder.build();
+        expect(parseFilePipe.getValidators()).to.be.empty;
+      });
+    });
+
+    describe('when addMaxSizeValidator was chained', () => {
+      it('should return a ParseFilePipe with MaxSizeValidator and given options', () => {
+        const options = {
+          maxSize: 1000,
+        };
+        const parseFilePipe = parseFilePipeBuilder
+          .addMaxSizeValidator(options)
+          .build();
+
+        expect(parseFilePipe.getValidators()).to.deep.include(
+          new MaxFileSizeValidator(options),
+        );
+      });
+    });
+
+    describe('when addFileTypeValidator was chained', () => {
+      it('should return a ParseFilePipe with FileTypeValidator and given options', () => {
+        const options = {
+          fileType: 'image/jpeg',
+        };
+        const parseFilePipe = parseFilePipeBuilder
+          .addFileTypeValidator(options)
+          .build();
+
+        expect(parseFilePipe.getValidators()).to.deep.include(
+          new FileTypeValidator(options),
+        );
+      });
+    });
+
+    describe('when it is called twice with different validators', () => {
+      it('should not reuse validators', () => {
+        const maxSizeValidatorOptions = {
+          maxSize: 1000,
+        };
+
+        const pipeWithMaxSizeValidator = parseFilePipeBuilder
+          .addMaxSizeValidator(maxSizeValidatorOptions)
+          .build();
+
+        const fileTypeValidatorOptions = {
+          fileType: 'image/jpeg',
+        };
+
+        const pipeWithFileTypeValidator = parseFilePipeBuilder
+          .addFileTypeValidator(fileTypeValidatorOptions)
+          .build();
+
+        expect(pipeWithFileTypeValidator.getValidators()).not.to.deep.equal(
+          pipeWithMaxSizeValidator.getValidators(),
+        );
+      });
+    });
+  });
+});
diff --git a/packages/common/test/pipes/file/parse-file.pipe.spec.ts b/packages/common/test/pipes/file/parse-file.pipe.spec.ts
new file mode 100644
index 000000000..ee06ef44b
--- /dev/null
+++ b/packages/common/test/pipes/file/parse-file.pipe.spec.ts
@@ -0,0 +1,120 @@
+import { HttpStatus } from '../../../enums';
+import { BadRequestException, ConflictException } from '../../../exceptions';
+import { FileValidator, ParseFilePipe } from '../../../pipes';
+import { expect } from 'chai';
+
+class AlwaysValidValidator extends FileValidator {
+  isValid(): boolean {
+    return true;
+  }
+  buildErrorMessage(): string {
+    return '';
+  }
+}
+
+const customErrorMessage = 'Error!';
+
+class AlwaysInvalidValidator extends FileValidator {
+  isValid(): boolean {
+    return false;
+  }
+  buildErrorMessage(): string {
+    return customErrorMessage;
+  }
+}
+
+describe('ParseFilePipe', () => {
+  let parseFilePipe: ParseFilePipe;
+  describe('transform', () => {
+    describe('when there are no validators (explicit)', () => {
+      beforeEach(() => {
+        parseFilePipe = new ParseFilePipe({
+          validators: [],
+        });
+      });
+
+      it('should return the file object', async () => {
+        const requestFile = {
+          path: 'some-path',
+        };
+
+        await expect(parseFilePipe.transform(requestFile)).to.eventually.eql(
+          requestFile,
+        );
+      });
+    });
+
+    describe('when there are no validators (by default constructor)', () => {
+      beforeEach(() => {
+        parseFilePipe = new ParseFilePipe();
+      });
+
+      it('should return the file object', async () => {
+        const requestFile = {
+          path: 'some-path',
+        };
+
+        await expect(parseFilePipe.transform(requestFile)).to.eventually.eql(
+          requestFile,
+        );
+      });
+    });
+
+    describe('when all the validators validate the file', () => {
+      beforeEach(() => {
+        parseFilePipe = new ParseFilePipe({
+          validators: [new AlwaysValidValidator({})],
+        });
+      });
+
+      it('should return the file object', async () => {
+        const requestFile = {
+          path: 'some-path',
+        };
+
+        await expect(parseFilePipe.transform(requestFile)).to.eventually.eql(
+          requestFile,
+        );
+      });
+    });
+
+    describe('when some validator invalidates the file', () => {
+      describe('and the pipe has the default error', () => {
+        beforeEach(() => {
+          parseFilePipe = new ParseFilePipe({
+            validators: [new AlwaysInvalidValidator({})],
+          });
+        });
+
+        it('should throw a BadRequestException', async () => {
+          const requestFile = {
+            path: 'some-path',
+          };
+
+          await expect(parseFilePipe.transform(requestFile)).to.be.rejectedWith(
+            BadRequestException,
+          );
+        });
+      });
+
+      describe('and the pipe has a custom error code', () => {
+        beforeEach(() => {
+          parseFilePipe = new ParseFilePipe({
+            validators: [new AlwaysInvalidValidator({})],
+            errorHttpStatusCode: HttpStatus.CONFLICT,
+          });
+        });
+
+        it('should throw this custom Error', async () => {
+          const requestFile = {
+            path: 'some-path',
+          };
+
+          await expect(parseFilePipe.transform(requestFile)).to.be.rejectedWith(
+            ConflictException,
+          );
+        });
+      });
+    });
+  });
+});
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 826b5ba8d..4107251ff 100644
--- a/sample/29-file-upload/e2e/app/app.e2e-spec.ts
+++ b/sample/29-file-upload/e2e/app/app.e2e-spec.ts
@@ -1,7 +1,6 @@
 import { INestApplication } from '@nestjs/common';
 import { Test } from '@nestjs/testing';
 import { readFileSync } from 'fs';
-import { join } from 'path';
 import * as request from 'supertest';
 import { AppModule } from '../../src/app.module';
 
@@ -30,6 +29,28 @@ describe('E2E FileTest', () => {
       });
   });
 
+  it('should allow for file uploads that pass validation', async () => {
+    return request(app.getHttpServer())
+      .post('/file/pass-validation')
+      .attach('file', './package.json')
+      .field('name', 'test')
+      .expect(201)
+      .expect({
+        body: {
+          name: 'test',
+        },
+        file: readFileSync('./package.json').toString(),
+      });
+  });
+
+  it('should throw for file uploads that do not pass validation', async () => {
+    return request(app.getHttpServer())
+      .post('/file/fail-validation')
+      .attach('file', './package.json')
+      .field('name', 'test')
+      .expect(400);
+  });
+
   afterAll(async () => {
     await app.close();
   });