Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/4752 file validators pipe #9718

Merged
30 changes: 30 additions & 0 deletions 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);
}
}
18 changes: 18 additions & 0 deletions 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>> {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Excellent code structure, it was very well thought out in that aspect.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks Hebert, although this still lacks promise handling, so I'll have to add that before merging

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;
}
6 changes: 6 additions & 0 deletions 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';
26 changes: 26 additions & 0 deletions 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;
}
}
8 changes: 8 additions & 0 deletions 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;
}
37 changes: 37 additions & 0 deletions 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;
}
}
68 changes: 68 additions & 0 deletions 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;
}
}
1 change: 1 addition & 0 deletions packages/common/pipes/index.ts
Expand Up @@ -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';
65 changes: 65 additions & 0 deletions 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})`,
);
});
});
});
56 changes: 56 additions & 0 deletions 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})`,
);
});
});
});