From e9d311a39dd0ac8791cba3a5afb318f3746ab30b Mon Sep 17 00:00:00 2001 From: Thiago Martins Date: Tue, 31 May 2022 17:22:42 -0300 Subject: [PATCH 01/10] feat(common): add parse file pipe --- .../common/pipes/file/file-type.validator.ts | 17 +++++++ .../pipes/file/file-validator.interface.ts | 6 +++ packages/common/pipes/file/index.ts | 6 +++ .../pipes/file/max-file-size.validator.ts | 17 +++++++ .../file/parse-file-options.interface.ts | 8 ++++ .../pipes/file/parse-file-pipe.builder.ts | 37 +++++++++++++++ packages/common/pipes/file/parse-file.pipe.ts | 45 +++++++++++++++++++ packages/common/pipes/index.ts | 1 + 8 files changed, 137 insertions(+) create mode 100644 packages/common/pipes/file/file-type.validator.ts create mode 100644 packages/common/pipes/file/file-validator.interface.ts create mode 100644 packages/common/pipes/file/index.ts create mode 100644 packages/common/pipes/file/max-file-size.validator.ts create mode 100644 packages/common/pipes/file/parse-file-options.interface.ts create mode 100644 packages/common/pipes/file/parse-file-pipe.builder.ts create mode 100644 packages/common/pipes/file/parse-file.pipe.ts 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 00000000000..9328531cb56 --- /dev/null +++ b/packages/common/pipes/file/file-type.validator.ts @@ -0,0 +1,17 @@ +import { FileValidator } from './file-validator.interface'; + +export type FileTypeValidatorOptions = { + fileType: string; +}; + +export class FileTypeValidator extends FileValidator { + buildErrorMessage(): string { + return `Validation failed (expected type is ${this.validationOptions.fileType})`; + } + + isValid(file: any): boolean { + if (!this.validationOptions) return true; + + return file.mimetype === 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 00000000000..bd6067ea724 --- /dev/null +++ b/packages/common/pipes/file/file-validator.interface.ts @@ -0,0 +1,6 @@ +export abstract class FileValidator> { + constructor(protected readonly validationOptions: TValidationOptions) {} + + abstract isValid(file?: any): boolean; + 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 00000000000..11d19e3188e --- /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 00000000000..d2fd5079584 --- /dev/null +++ b/packages/common/pipes/file/max-file-size.validator.ts @@ -0,0 +1,17 @@ +import { FileValidator } from './file-validator.interface'; + +export type MaxFileSizeValidatorOptions = { + maxSize: number; +}; + +export class MaxFileSizeValidator extends FileValidator { + 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 00000000000..e74630999a6 --- /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 00000000000..c7404517cdb --- /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, + ): 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 00000000000..cf578b9509d --- /dev/null +++ b/packages/common/pipes/file/parse-file.pipe.ts @@ -0,0 +1,45 @@ +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'; + +@Injectable() +export class ParseFilePipe implements PipeTransform { + 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 { + if (this.validators.length) { + this.validate(value); + } + return value; + } + + protected validate(file: any): any { + const failingValidator = this.validators.find( + validator => !validator.isValid(file), + ); + + if (failingValidator) { + const errorMessage = failingValidator.buildErrorMessage(file); + throw this.exceptionFactory(errorMessage); + } + return file; + } +} diff --git a/packages/common/pipes/index.ts b/packages/common/pipes/index.ts index a6936549377..ebbc55f272a 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'; From 466abf66a8fbe7c49aa6df68ee4f39d271740817 Mon Sep 17 00:00:00 2001 From: Thiago Martins Date: Tue, 31 May 2022 17:22:59 -0300 Subject: [PATCH 02/10] test(common): add test for parse file pipe --- .../pipes/file/file-type.validator.spec.ts | 53 ++++++++ .../file/max-file-size.validator.spec.ts | 56 ++++++++ .../test/pipes/file/parse-file.pipe.spec.ts | 123 ++++++++++++++++++ 3 files changed, 232 insertions(+) create mode 100644 packages/common/test/pipes/file/file-type.validator.spec.ts create mode 100644 packages/common/test/pipes/file/max-file-size.validator.spec.ts create mode 100644 packages/common/test/pipes/file/parse-file.pipe.spec.ts 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 00000000000..673e9e955a5 --- /dev/null +++ b/packages/common/test/pipes/file/file-type.validator.spec.ts @@ -0,0 +1,53 @@ +import { FileTypeValidator } from '@nestjs/common/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: Partial = { + 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: Partial = { + 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: Partial = {}; + + 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 00000000000..459594c73eb --- /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: Partial = { + 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: Partial = { + 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: Partial = { + 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.spec.ts b/packages/common/test/pipes/file/parse-file.pipe.spec.ts new file mode 100644 index 00000000000..235227a7efe --- /dev/null +++ b/packages/common/test/pipes/file/parse-file.pipe.spec.ts @@ -0,0 +1,123 @@ +import { HttpStatus } from '@nestjs/common/enums'; +import { + BadRequestException, + ConflictException, +} from '@nestjs/common/exceptions'; +import { FileValidator, ParseFilePipe } from '@nestjs/common/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: Partial = { + 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: Partial = { + 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: Partial = { + path: 'some-path', + }; + + await expect(parseFilePipe.transform(requestFile)).to.eventually.eql( + requestFile, + ); + }); + }); + + describe('when some the 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: Partial = { + 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: Partial = { + path: 'some-path', + }; + + await expect(parseFilePipe.transform(requestFile)).to.be.rejectedWith( + ConflictException, + ); + }); + }); + }); + }); +}); From b277f958a5ced2fef7476c0929782038b2497be7 Mon Sep 17 00:00:00 2001 From: Thiago Martins Date: Wed, 1 Jun 2022 12:06:17 -0300 Subject: [PATCH 03/10] fix(test): remove multer types --- .../common/test/pipes/file/file-type.validator.spec.ts | 6 +++--- .../test/pipes/file/max-file-size.validator.spec.ts | 6 +++--- .../common/test/pipes/file/parse-file.pipe.spec.ts | 10 +++++----- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/common/test/pipes/file/file-type.validator.spec.ts b/packages/common/test/pipes/file/file-type.validator.spec.ts index 673e9e955a5..96c4d76a4d1 100644 --- a/packages/common/test/pipes/file/file-type.validator.spec.ts +++ b/packages/common/test/pipes/file/file-type.validator.spec.ts @@ -8,7 +8,7 @@ describe('FileTypeValidator', () => { fileType: 'image/jpeg', }); - const requestFile: Partial = { + const requestFile = { mimetype: 'image/jpeg', }; @@ -20,7 +20,7 @@ describe('FileTypeValidator', () => { fileType: 'image/jpeg', }); - const requestFile: Partial = { + const requestFile = { mimetype: 'image/png', }; @@ -32,7 +32,7 @@ describe('FileTypeValidator', () => { fileType: 'image/jpeg', }); - const requestFile: Partial = {}; + const requestFile = {}; expect(fileTypeValidator.isValid(requestFile)).to.equal(false); }); 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 index 459594c73eb..9ba8e4283bc 100644 --- a/packages/common/test/pipes/file/max-file-size.validator.spec.ts +++ b/packages/common/test/pipes/file/max-file-size.validator.spec.ts @@ -10,7 +10,7 @@ describe('MaxFileSizeValidator', () => { maxSize: oneKb, }); - const requestFile: Partial = { + const requestFile = { size: 100, }; @@ -22,7 +22,7 @@ describe('MaxFileSizeValidator', () => { maxSize: oneKb, }); - const requestFile: Partial = { + const requestFile = { size: oneKb + 1, }; @@ -34,7 +34,7 @@ describe('MaxFileSizeValidator', () => { maxSize: oneKb, }); - const requestFile: Partial = { + const requestFile = { size: oneKb, }; 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 235227a7efe..e2b28732743 100644 --- a/packages/common/test/pipes/file/parse-file.pipe.spec.ts +++ b/packages/common/test/pipes/file/parse-file.pipe.spec.ts @@ -37,7 +37,7 @@ describe('ParseFilePipe', () => { }); it('should return the file object', async () => { - const requestFile: Partial = { + const requestFile = { path: 'some-path', }; @@ -53,7 +53,7 @@ describe('ParseFilePipe', () => { }); it('should return the file object', async () => { - const requestFile: Partial = { + const requestFile = { path: 'some-path', }; @@ -71,7 +71,7 @@ describe('ParseFilePipe', () => { }); it('should return the file object', async () => { - const requestFile: Partial = { + const requestFile = { path: 'some-path', }; @@ -90,7 +90,7 @@ describe('ParseFilePipe', () => { }); it('should throw a BadRequestException', async () => { - const requestFile: Partial = { + const requestFile = { path: 'some-path', }; @@ -109,7 +109,7 @@ describe('ParseFilePipe', () => { }); it('should throw this custom Error', async () => { - const requestFile: Partial = { + const requestFile = { path: 'some-path', }; From c43dbd86241788de5824abdc4c6291c33c3e1a3a Mon Sep 17 00:00:00 2001 From: Thiago Martins Date: Wed, 1 Jun 2022 13:54:49 -0300 Subject: [PATCH 04/10] test(common): fix import to relative path fix import to relative path, the alias was causing cyclic references --- .../common/test/pipes/file/file-type.validator.spec.ts | 2 +- packages/common/test/pipes/file/parse-file.pipe.spec.ts | 9 +++------ 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/packages/common/test/pipes/file/file-type.validator.spec.ts b/packages/common/test/pipes/file/file-type.validator.spec.ts index 96c4d76a4d1..b6fa7946236 100644 --- a/packages/common/test/pipes/file/file-type.validator.spec.ts +++ b/packages/common/test/pipes/file/file-type.validator.spec.ts @@ -1,4 +1,4 @@ -import { FileTypeValidator } from '@nestjs/common/pipes'; +import { FileTypeValidator } from '../../../pipes'; import { expect } from 'chai'; describe('FileTypeValidator', () => { 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 e2b28732743..fc5b05f7099 100644 --- a/packages/common/test/pipes/file/parse-file.pipe.spec.ts +++ b/packages/common/test/pipes/file/parse-file.pipe.spec.ts @@ -1,9 +1,6 @@ -import { HttpStatus } from '@nestjs/common/enums'; -import { - BadRequestException, - ConflictException, -} from '@nestjs/common/exceptions'; -import { FileValidator, ParseFilePipe } from '@nestjs/common/pipes'; +import { HttpStatus } from '../../../enums'; +import { BadRequestException, ConflictException } from '../../../exceptions'; +import { FileValidator, ParseFilePipe } from '../../../pipes'; import { expect } from 'chai'; class AlwaysValidValidator extends FileValidator { From b178eb5bce0bcacadddfbb9f58b295b25816d60a Mon Sep 17 00:00:00 2001 From: Thiago Martins Date: Wed, 1 Jun 2022 14:25:47 -0300 Subject: [PATCH 05/10] refactor(common): add braces add curly braces to if statement for consistency --- packages/common/pipes/file/file-type.validator.ts | 4 +++- packages/common/pipes/file/max-file-size.validator.ts | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/common/pipes/file/file-type.validator.ts b/packages/common/pipes/file/file-type.validator.ts index 9328531cb56..163f34c24d4 100644 --- a/packages/common/pipes/file/file-type.validator.ts +++ b/packages/common/pipes/file/file-type.validator.ts @@ -10,7 +10,9 @@ export class FileTypeValidator extends FileValidator { } isValid(file: any): boolean { - if (!this.validationOptions) return true; + if (!this.validationOptions) { + return true; + } return file.mimetype === this.validationOptions.fileType; } diff --git a/packages/common/pipes/file/max-file-size.validator.ts b/packages/common/pipes/file/max-file-size.validator.ts index d2fd5079584..822ef9b8e1c 100644 --- a/packages/common/pipes/file/max-file-size.validator.ts +++ b/packages/common/pipes/file/max-file-size.validator.ts @@ -10,7 +10,9 @@ export class MaxFileSizeValidator extends FileValidator Date: Wed, 1 Jun 2022 14:27:30 -0300 Subject: [PATCH 06/10] test(common): add builder tests add tests for parse file pipe builder and refactor associated classes --- .../pipes/file/parse-file-pipe.builder.ts | 2 +- packages/common/pipes/file/parse-file.pipe.ts | 4 + .../file/parse-file-pipe.builder.spec.ts | 77 +++++++++++++++++++ 3 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 packages/common/test/pipes/file/parse-file-pipe.builder.spec.ts diff --git a/packages/common/pipes/file/parse-file-pipe.builder.ts b/packages/common/pipes/file/parse-file-pipe.builder.ts index c7404517cdb..7835322d972 100644 --- a/packages/common/pipes/file/parse-file-pipe.builder.ts +++ b/packages/common/pipes/file/parse-file-pipe.builder.ts @@ -11,7 +11,7 @@ import { ParseFileOptions } from './parse-file-options.interface'; import { ParseFilePipe } from './parse-file.pipe'; export class ParseFilePipeBuilder { - private validators: FileValidator[]; + private validators: FileValidator[] = []; addMaxSizeValidator(options: MaxFileSizeValidatorOptions) { this.validators.push(new MaxFileSizeValidator(options)); diff --git a/packages/common/pipes/file/parse-file.pipe.ts b/packages/common/pipes/file/parse-file.pipe.ts index cf578b9509d..025d24c8a9b 100644 --- a/packages/common/pipes/file/parse-file.pipe.ts +++ b/packages/common/pipes/file/parse-file.pipe.ts @@ -42,4 +42,8 @@ export class ParseFilePipe implements PipeTransform { } return file; } + + getValidators() { + return this.validators; + } } 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 00000000000..8c31904aaaf --- /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(), + ); + }); + }); + }); +}); From 2ad86480c556b9f27d6374a14fdb13d84c3453ad Mon Sep 17 00:00:00 2001 From: Thiago Martins Date: Wed, 1 Jun 2022 14:54:25 -0300 Subject: [PATCH 07/10] docs(common): add validators docs --- packages/common/pipes/file/file-type.validator.ts | 7 +++++++ .../common/pipes/file/file-validator.interface.ts | 12 ++++++++++++ .../common/pipes/file/max-file-size.validator.ts | 7 +++++++ packages/common/pipes/file/parse-file.pipe.ts | 13 +++++++++++++ 4 files changed, 39 insertions(+) diff --git a/packages/common/pipes/file/file-type.validator.ts b/packages/common/pipes/file/file-type.validator.ts index 163f34c24d4..f7dc55c8fef 100644 --- a/packages/common/pipes/file/file-type.validator.ts +++ b/packages/common/pipes/file/file-type.validator.ts @@ -4,6 +4,13 @@ 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 { buildErrorMessage(): string { return `Validation failed (expected type is ${this.validationOptions.fileType})`; diff --git a/packages/common/pipes/file/file-validator.interface.ts b/packages/common/pipes/file/file-validator.interface.ts index bd6067ea724..d14a86090bd 100644 --- a/packages/common/pipes/file/file-validator.interface.ts +++ b/packages/common/pipes/file/file-validator.interface.ts @@ -1,6 +1,18 @@ +/** + * Interface describing FileValidators, which can be added to a {@link ParseFilePipe}. + */ export abstract class FileValidator> { 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; + + /** + * 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/max-file-size.validator.ts b/packages/common/pipes/file/max-file-size.validator.ts index 822ef9b8e1c..847ee19f07b 100644 --- a/packages/common/pipes/file/max-file-size.validator.ts +++ b/packages/common/pipes/file/max-file-size.validator.ts @@ -4,6 +4,13 @@ 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 { buildErrorMessage(): string { return `Validation failed (expected size is less than ${this.validationOptions.maxSize})`; diff --git a/packages/common/pipes/file/parse-file.pipe.ts b/packages/common/pipes/file/parse-file.pipe.ts index 025d24c8a9b..fe688de2bb5 100644 --- a/packages/common/pipes/file/parse-file.pipe.ts +++ b/packages/common/pipes/file/parse-file.pipe.ts @@ -5,6 +5,16 @@ import { PipeTransform } from '../../interfaces/features/pipe-transform.interfac import { ParseFileOptions } from './parse-file-options.interface'; import { FileValidator } from './file-validator.interface'; +/** + * 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 { protected exceptionFactory: (error: string) => any; @@ -43,6 +53,9 @@ export class ParseFilePipe implements PipeTransform { return file; } + /** + * @returns list of validators used in this pipe. + */ getValidators() { return this.validators; } From bdd939749f3943d63b383cc449a88bca3bf12661 Mon Sep 17 00:00:00 2001 From: Thiago Martins Date: Fri, 17 Jun 2022 14:56:18 -0300 Subject: [PATCH 08/10] refactor(common): parse file pipe add async validation feature --- .../pipes/file/file-validator.interface.ts | 2 +- packages/common/pipes/file/parse-file.pipe.ts | 22 ++++++++++++------- .../test/pipes/file/parse-file.pipe.spec.ts | 2 +- 3 files changed, 16 insertions(+), 10 deletions(-) diff --git a/packages/common/pipes/file/file-validator.interface.ts b/packages/common/pipes/file/file-validator.interface.ts index d14a86090bd..ad453cdae5f 100644 --- a/packages/common/pipes/file/file-validator.interface.ts +++ b/packages/common/pipes/file/file-validator.interface.ts @@ -8,7 +8,7 @@ export abstract class FileValidator> { * 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; + abstract isValid(file?: any): boolean | Promise; /** * Builds an error message in case the validation fails. diff --git a/packages/common/pipes/file/parse-file.pipe.ts b/packages/common/pipes/file/parse-file.pipe.ts index fe688de2bb5..e952c38f529 100644 --- a/packages/common/pipes/file/parse-file.pipe.ts +++ b/packages/common/pipes/file/parse-file.pipe.ts @@ -4,6 +4,7 @@ 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 @@ -36,21 +37,26 @@ export class ParseFilePipe implements PipeTransform { async transform(value: any): Promise { if (this.validators.length) { - this.validate(value); + await this.validate(value); } return value; } - protected validate(file: any): any { - const failingValidator = this.validators.find( - validator => !validator.isValid(file), - ); + protected async validate(file: any): Promise { + 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 (failingValidator) { - const errorMessage = failingValidator.buildErrorMessage(file); + if (!isValid) { + const errorMessage = validator.buildErrorMessage(file); throw this.exceptionFactory(errorMessage); } - return file; } /** 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 fc5b05f7099..ee06ef44b8f 100644 --- a/packages/common/test/pipes/file/parse-file.pipe.spec.ts +++ b/packages/common/test/pipes/file/parse-file.pipe.spec.ts @@ -78,7 +78,7 @@ describe('ParseFilePipe', () => { }); }); - describe('when some the validator invalidates the file', () => { + describe('when some validator invalidates the file', () => { describe('and the pipe has the default error', () => { beforeEach(() => { parseFilePipe = new ParseFilePipe({ From ad0d1bcb1746de28544f3ad5646aea50d0bd1d93 Mon Sep 17 00:00:00 2001 From: Thiago Martins Date: Fri, 17 Jun 2022 16:33:06 -0300 Subject: [PATCH 09/10] refactor(common): file type validator add partial mimetype check --- packages/common/pipes/file/file-type.validator.ts | 6 +++++- .../test/pipes/file/file-type.validator.spec.ts | 12 ++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/common/pipes/file/file-type.validator.ts b/packages/common/pipes/file/file-type.validator.ts index f7dc55c8fef..33404914992 100644 --- a/packages/common/pipes/file/file-type.validator.ts +++ b/packages/common/pipes/file/file-type.validator.ts @@ -21,6 +21,10 @@ export class FileTypeValidator extends FileValidator { return true; } - return file.mimetype === this.validationOptions.fileType; + if (!file.mimetype) { + return false; + } + + return (file.mimetype as string).endsWith(this.validationOptions.fileType); } } diff --git a/packages/common/test/pipes/file/file-type.validator.spec.ts b/packages/common/test/pipes/file/file-type.validator.spec.ts index b6fa7946236..c4248f66a08 100644 --- a/packages/common/test/pipes/file/file-type.validator.spec.ts +++ b/packages/common/test/pipes/file/file-type.validator.spec.ts @@ -15,6 +15,18 @@ describe('FileTypeValidator', () => { 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', From 2e8426b2b361c38948f3070005b7d7bba598afda Mon Sep 17 00:00:00 2001 From: Thiago Martins Date: Fri, 17 Jun 2022 16:33:44 -0300 Subject: [PATCH 10/10] test(sample): add file upload e2e test add test using file parse pipe --- sample/29-file-upload/e2e/app/app.e2e-spec.ts | 23 ++++++++++- sample/29-file-upload/src/app.controller.ts | 39 +++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) 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 826b5ba8d5f..4107251ff62 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(); }); diff --git a/sample/29-file-upload/src/app.controller.ts b/sample/29-file-upload/src/app.controller.ts index dc59cd260e9..39d3af1fb75 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(), + }; + } }