diff --git a/packages/common/pipes/file/parse-file-options.interface.ts b/packages/common/pipes/file/parse-file-options.interface.ts index e74630999a6..7602f699743 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 2f04024ea01..3cda9a5fc53 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 { 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 { (error => new HttpErrorByCode[errorHttpStatusCode](error)); this.validators = validators; + this.fileIsRequired = fileIsRequired ?? true; } async transform(value: any): Promise { + 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/common/test/pipes/file/parse-file.pipe.spec.ts b/packages/common/test/pipes/file/parse-file.pipe.spec.ts index ee06ef44b8f..0eecaf98967 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 4107251ff62..6f8bc903431 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(); }); diff --git a/sample/29-file-upload/src/app.controller.ts b/sample/29-file-upload/src/app.controller.ts index 39d3af1fb75..371a1fd699e 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(), }; }