오픈소스 멘토링 때 선정했던 두 가지 이슈 중에, 선택하지 않았던 nest의 file-validation-pipe의 이슈를 다시 살펴보았다.
누군가가 PR을 하겠다고 코멘트가 달려있어서 선택하지 않았던 이슈였지만 누가 먼저 PR을 보내느냐가 중요하다던 말이 떠올랐다. 간단한 이슈였기 때문에 바로 PR을 보냈고, 곧바로 머지 되었다.
(멤버분의 코멘트로 보아 11버전에서 업데이트 될 것 같다)
이슈 정의
기존 NestJS의 파일 Validation에서, 에러 메세지에 실제 파일 정보가 담기지 않는다. 제한된 설정값만 담겨있기 때문에 사용자가 어떤 문제가 발생했는지 명확하게 이해하지 못한다.
이슈 분석
nest의 공식문서에 따르면, 파일 업로드 시 인터셉터에서 파일을 컨텍스트에 담고 @UploadFile() 데코레이터를 사용해 request 컨텍스트 내부에서 파일을 추출하기만 하면 된다고 안내되어 있다.
@Post('upload')
@UseInterceptors(FileInterceptor('file'))
uploadFile(@UploadedFile() file: Express.Multer.File) {
console.log(file);
}
PipeTransForm 인터페이스를 통해 따로 ValidationPipe을 구현해서 사용해도 되지만, Nest에서는 파일에 대한 표준 내장 ParseFilePipe을 제공하고, ParseFilePipe에 FileValidator 추상 클래스를 파라미터로 받아서 사용할 수 있다.
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 =
exceptionFactory ||
(error => new HttpErrorByCode[errorHttpStatusCode](error));
this.validators = validators;
this.fileIsRequired = fileIsRequired ?? true;
}
}
export interface ParseFileOptions {
validators?: FileValidator[];
errorHttpStatusCode?: ErrorHttpStatusCode;
exceptionFactory?: (error: string) => any;
fileIsRequired?: boolean;
}
내장 FileValidator을 사용할 경우 @UploadFile()의 request 컨텍스트의 파일을 넘겨받아 벨리데이션이 가능하다.
(request의 파라미터(@param())에서 파일을 가지고 있고, 공식문서에서는 이를 FileValidator에 넘겨 사용한다고 한다.)
export abstract class FileValidator<
TValidationOptions = Record<string, any>,
TFile extends IFile = IFile,
> {
constructor(protected readonly validationOptions: TValidationOptions) {}
abstract isValid(
file?: TFile | TFile[] | Record<string, TFile[]>,
): boolean | Promise<boolean>;
abstract buildErrorMessage(file: any): string;
}
export interface IFile {
mimetype: string;
size: number;
}
Nest의 기본 내장 코드들을 활용해서 간단하게 컨트롤러에서 1KB 미만의 jpg,jpeg,png 파일을 업로드할 수 있는 API를 만들 수 있다. 아래는 베이직한 API이다.
@Post('upload-image')
@UseInterceptors(FileInterceptor('file'))
uploadFile(
@UploadedFile(
new ParseFilePipe({
validators: [
new MaxFileSizeValidator({ maxSize: 1024 }),
new FileTypeValidator({ fileType: '.(png|jpeg|jpg)' }),
],
}),
)
file: Express.Multer.File,
) {
console.log(file);
}
위에서 언급한 것 처럼, Validator에서 파일이 넘어오게 되어있는데, 에러 메세지는 단순 validationOptions에 따른 에러 메세지 밖에 나오지 않기 때문에, 파일 인터페이스에 들어있는 현재 파일의 크기, 타입을 메세지로 나타내어주지 않고 있다.
export class MaxFileSizeValidator extends FileValidator<
MaxFileSizeValidatorOptions,
IFile
> {
buildErrorMessage(): string {
if ('message' in this.validationOptions) {
if (typeof this.validationOptions.message === 'function') {
return this.validationOptions.message(this.validationOptions.maxSize);
}
return this.validationOptions.message;
}
return `Validation failed (expected size is less than ${this.validationOptions.maxSize})`;
}
}
export class FileTypeValidator extends FileValidator<
FileTypeValidatorOptions,
IFile
> {
buildErrorMessage(): string {
return `Validation failed (expected type is ${this.validationOptions.fileType})`;
}
}
해결
FileValidator을 상속받아 사용하는 하위 클래스들에서, 그대로 파일을 넘겨받아 사용할 수 있기 때문에
에러 메세지를 만들 때 File을 넘겨받아 사용할 수 있게 해주고 테스트 코드까지 작성해주었다.
export class FileTypeValidator extends FileValidator<
FileTypeValidatorOptions,
IFile
> {
buildErrorMessage(file?: IFile): string {
if (file?.mimetype) {
return `Validation failed (current file type is ${file.mimetype}, expected type is ${this.validationOptions.fileType})`;
}
return `Validation failed (expected type is ${this.validationOptions.fileType})`;
}
}
export class MaxFileSizeValidator extends FileValidator<
MaxFileSizeValidatorOptions,
IFile
> {
buildErrorMessage(file?: IFile): string {
if ('message' in this.validationOptions) {
if (typeof this.validationOptions.message === 'function') {
return this.validationOptions.message(this.validationOptions.maxSize);
}
return this.validationOptions.message!;
}
if (file?.size) {
return `Validation failed (current file size is ${file.size}, expected size is less than ${this.validationOptions.maxSize})`;
}
return `Validation failed (expected size is less than ${this.validationOptions.maxSize})`;
}
}
이렇게, 선정했던 두 가지의 이슈를 모두 직접 해결할 수 있어서 뿌듯하다.
앞으로도 기여하고 싶은 오픈소스에 기웃거리면서 하나하나 해결하다보면 직접 이슈를 생성할 수 있는 날이 오지 않을까? 하는 기대가 든다. 점점 성장하는 오픈소스 포스팅이 되길 기원한다. 아자아자!
2023.04 ~ 백엔드 개발자의 기록
포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!