From fae3845bcd3772c0030d9475d076f0dd9d280c15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1ron?= Date: Tue, 20 Apr 2021 00:54:55 -0300 Subject: [PATCH] feat(platform): add fastify-multipart support for fastify platform add FileInterceptor, FilesInterceptor, AnyFilesInterceptor and FileFieldsInterceptor for platform-fastify closes #6894 --- .../multipart/files.constants.ts | 1 + packages/platform-fastify/multipart/index.ts | 4 + .../interceptors/any-files.interceptor.ts | 50 ++ .../interceptors/file-fields.interceptor.ts | 53 ++ .../interceptors/file.interceptor.ts | 49 ++ .../interceptors/files.interceptor.ts | 50 ++ .../multipart/interceptors/index.ts | 4 + .../files-upload-module.interface.ts | 21 + .../multipart/interfaces/index.ts | 3 + .../interfaces/multipart-file.interface.ts | 26 + .../interfaces/multipart-options.interface.ts | 49 ++ .../multipart/multipart.constants.ts | 1 + .../multipart/multipart.module.ts | 74 +++ .../multipart/multipart/index.ts | 3 + .../multipart/multipart/multipart-wrapper.ts | 265 ++++++++ .../multipart/multipart.constants.ts | 10 + .../multipart/multipart/multipart.utils.ts | 30 + .../multipart/utils/filter-async-generator.ts | 14 + .../platform-fastify/multipart/utils/index.ts | 1 + .../any-files.interceptor.spec.ts | 53 ++ .../file-fields.interceptor.spec.ts | 55 ++ .../interceptors/file.interceptor.spec.ts | 53 ++ .../interceptors/files.interceptor.spec.ts | 54 ++ .../multipart/multipart-wrapper.spec.ts | 579 ++++++++++++++++++ .../multipart/multipart.module.spec.ts | 89 +++ .../multipart/multipart.utils.spec.ts | 84 +++ .../utils/filter-async-generator.spec.ts | 24 + 27 files changed, 1699 insertions(+) create mode 100644 packages/platform-fastify/multipart/files.constants.ts create mode 100644 packages/platform-fastify/multipart/index.ts create mode 100644 packages/platform-fastify/multipart/interceptors/any-files.interceptor.ts create mode 100644 packages/platform-fastify/multipart/interceptors/file-fields.interceptor.ts create mode 100644 packages/platform-fastify/multipart/interceptors/file.interceptor.ts create mode 100644 packages/platform-fastify/multipart/interceptors/files.interceptor.ts create mode 100644 packages/platform-fastify/multipart/interceptors/index.ts create mode 100644 packages/platform-fastify/multipart/interfaces/files-upload-module.interface.ts create mode 100644 packages/platform-fastify/multipart/interfaces/index.ts create mode 100644 packages/platform-fastify/multipart/interfaces/multipart-file.interface.ts create mode 100644 packages/platform-fastify/multipart/interfaces/multipart-options.interface.ts create mode 100644 packages/platform-fastify/multipart/multipart.constants.ts create mode 100644 packages/platform-fastify/multipart/multipart.module.ts create mode 100644 packages/platform-fastify/multipart/multipart/index.ts create mode 100644 packages/platform-fastify/multipart/multipart/multipart-wrapper.ts create mode 100644 packages/platform-fastify/multipart/multipart/multipart.constants.ts create mode 100644 packages/platform-fastify/multipart/multipart/multipart.utils.ts create mode 100644 packages/platform-fastify/multipart/utils/filter-async-generator.ts create mode 100644 packages/platform-fastify/multipart/utils/index.ts create mode 100644 packages/platform-fastify/test/multipart/interceptors/any-files.interceptor.spec.ts create mode 100644 packages/platform-fastify/test/multipart/interceptors/file-fields.interceptor.spec.ts create mode 100644 packages/platform-fastify/test/multipart/interceptors/file.interceptor.spec.ts create mode 100644 packages/platform-fastify/test/multipart/interceptors/files.interceptor.spec.ts create mode 100644 packages/platform-fastify/test/multipart/multipart/multipart-wrapper.spec.ts create mode 100644 packages/platform-fastify/test/multipart/multipart/multipart.module.spec.ts create mode 100644 packages/platform-fastify/test/multipart/multipart/multipart.utils.spec.ts create mode 100644 packages/platform-fastify/test/multipart/utils/filter-async-generator.spec.ts diff --git a/packages/platform-fastify/multipart/files.constants.ts b/packages/platform-fastify/multipart/files.constants.ts new file mode 100644 index 00000000000..a2cf013c236 --- /dev/null +++ b/packages/platform-fastify/multipart/files.constants.ts @@ -0,0 +1 @@ +export const MULTIPART_MODULE_OPTIONS = 'MULTIPART_MODULE_OPTIONS'; diff --git a/packages/platform-fastify/multipart/index.ts b/packages/platform-fastify/multipart/index.ts new file mode 100644 index 00000000000..7a5212f5826 --- /dev/null +++ b/packages/platform-fastify/multipart/index.ts @@ -0,0 +1,4 @@ +export * from './interceptors'; +export * from './interfaces'; +export * from './multipart.module'; +export * from './utils'; diff --git a/packages/platform-fastify/multipart/interceptors/any-files.interceptor.ts b/packages/platform-fastify/multipart/interceptors/any-files.interceptor.ts new file mode 100644 index 00000000000..b82293dc604 --- /dev/null +++ b/packages/platform-fastify/multipart/interceptors/any-files.interceptor.ts @@ -0,0 +1,50 @@ +import { + CallHandler, + ExecutionContext, + Inject, + mixin, + NestInterceptor, + Optional, + Type, +} from '@nestjs/common'; +import { Observable } from 'rxjs'; +import { MULTIPART_MODULE_OPTIONS } from '../files.constants'; +import { MultipartOptions } from '../interfaces/multipart-options.interface'; +import { MultipartWrapper, transformException } from '../multipart'; + +export const AnyFilesInterceptor = ( + localOptions?: MultipartOptions, +): Type => { + class MixinInterceptor implements NestInterceptor { + protected options: MultipartOptions; + protected multipart: MultipartWrapper; + + public constructor( + @Optional() + @Inject(MULTIPART_MODULE_OPTIONS) + options: MultipartOptions = {}, + ) { + this.multipart = new MultipartWrapper({ + ...options, + ...localOptions, + }); + } + + public async intercept( + context: ExecutionContext, + next: CallHandler, + ): Promise> { + const req = context.switchToHttp().getRequest(); + const fieldname = 'files'; + try { + req[fieldname] = await this.multipart.any()(req); + } catch (err) { + throw transformException(err); + } + return next.handle(); + } + } + + const Interceptor = mixin(MixinInterceptor); + return Interceptor as Type; +}; diff --git a/packages/platform-fastify/multipart/interceptors/file-fields.interceptor.ts b/packages/platform-fastify/multipart/interceptors/file-fields.interceptor.ts new file mode 100644 index 00000000000..2c501c38207 --- /dev/null +++ b/packages/platform-fastify/multipart/interceptors/file-fields.interceptor.ts @@ -0,0 +1,53 @@ +import { + CallHandler, + ExecutionContext, + Inject, + mixin, + NestInterceptor, + Optional, + Type, +} from '@nestjs/common'; +import { Observable } from 'rxjs'; +import { MULTIPART_MODULE_OPTIONS } from '../files.constants'; +import { + MultipartOptions, + UploadField, +} from '../interfaces/multipart-options.interface'; +import { MultipartWrapper, transformException } from '../multipart'; + +export const FileFieldsInterceptor = ( + uploadFields: UploadField[], + localOptions?: MultipartOptions, +): Type => { + class MixinInterceptor implements NestInterceptor { + protected multipart: MultipartWrapper; + + public constructor( + @Optional() + @Inject(MULTIPART_MODULE_OPTIONS) + options: MultipartOptions = {}, + ) { + this.multipart = new MultipartWrapper({ + ...options, + ...localOptions, + }); + } + + public async intercept( + context: ExecutionContext, + next: CallHandler, + ): Promise> { + const req = context.switchToHttp().getRequest(); + const fieldname = 'files'; + try { + req[fieldname] = await this.multipart.fileFields(uploadFields)(req); + } catch (err) { + throw transformException(err); + } + return next.handle(); + } + } + + const Interceptor = mixin(MixinInterceptor); + return Interceptor as Type; +}; diff --git a/packages/platform-fastify/multipart/interceptors/file.interceptor.ts b/packages/platform-fastify/multipart/interceptors/file.interceptor.ts new file mode 100644 index 00000000000..5d9f26138b8 --- /dev/null +++ b/packages/platform-fastify/multipart/interceptors/file.interceptor.ts @@ -0,0 +1,49 @@ +import { + CallHandler, + ExecutionContext, + Inject, + mixin, + NestInterceptor, + Optional, + Type, +} from '@nestjs/common'; +import { Observable } from 'rxjs'; +import { MULTIPART_MODULE_OPTIONS } from '../files.constants'; +import { MultipartOptions } from '../interfaces/multipart-options.interface'; +import { MultipartWrapper, transformException } from '../multipart'; + +export const FileInterceptor = ( + fieldname: string, + localOptions?: MultipartOptions, +): Type => { + class MixinInterceptor implements NestInterceptor { + protected multipart: MultipartWrapper; + + public constructor( + @Optional() + @Inject(MULTIPART_MODULE_OPTIONS) + options: MultipartOptions = {}, + ) { + this.multipart = new MultipartWrapper({ + ...options, + ...localOptions, + }); + } + + public async intercept( + context: ExecutionContext, + next: CallHandler, + ): Promise> { + const req = context.switchToHttp().getRequest(); + try { + req[fieldname] = await this.multipart.file(fieldname)(req); + } catch (err) { + throw transformException(err); + } + return next.handle(); + } + } + + const Interceptor = mixin(MixinInterceptor); + return Interceptor as Type; +}; diff --git a/packages/platform-fastify/multipart/interceptors/files.interceptor.ts b/packages/platform-fastify/multipart/interceptors/files.interceptor.ts new file mode 100644 index 00000000000..097828bc82f --- /dev/null +++ b/packages/platform-fastify/multipart/interceptors/files.interceptor.ts @@ -0,0 +1,50 @@ +import { + CallHandler, + ExecutionContext, + Inject, + mixin, + NestInterceptor, + Optional, + Type, +} from '@nestjs/common'; +import { Observable } from 'rxjs'; +import { MULTIPART_MODULE_OPTIONS } from '../files.constants'; +import { MultipartOptions } from '../interfaces/multipart-options.interface'; +import { MultipartWrapper, transformException } from '../multipart'; + +export const FilesInterceptor = ( + fieldname: string, + maxCount?: number, + localOptions?: MultipartOptions, +): Type => { + class MixinInterceptor implements NestInterceptor { + protected multipart: MultipartWrapper; + + public constructor( + @Optional() + @Inject(MULTIPART_MODULE_OPTIONS) + options: MultipartOptions = {}, + ) { + this.multipart = new MultipartWrapper({ + ...options, + ...localOptions, + }); + } + + public async intercept( + context: ExecutionContext, + next: CallHandler, + ): Promise> { + const req = context.switchToHttp().getRequest(); + try { + req[fieldname] = await this.multipart.files(fieldname, maxCount)(req); + } catch (err) { + throw transformException(err); + } + return next.handle(); + } + } + + const Interceptor = mixin(MixinInterceptor); + return Interceptor as Type; +}; diff --git a/packages/platform-fastify/multipart/interceptors/index.ts b/packages/platform-fastify/multipart/interceptors/index.ts new file mode 100644 index 00000000000..5f76d7d477c --- /dev/null +++ b/packages/platform-fastify/multipart/interceptors/index.ts @@ -0,0 +1,4 @@ +export * from './any-files.interceptor'; +export * from './file-fields.interceptor'; +export * from './file.interceptor'; +export * from './files.interceptor'; diff --git a/packages/platform-fastify/multipart/interfaces/files-upload-module.interface.ts b/packages/platform-fastify/multipart/interfaces/files-upload-module.interface.ts new file mode 100644 index 00000000000..8668763a25c --- /dev/null +++ b/packages/platform-fastify/multipart/interfaces/files-upload-module.interface.ts @@ -0,0 +1,21 @@ +import { Type } from '@nestjs/common'; +import { ModuleMetadata } from '@nestjs/common/interfaces'; +import { MultipartOptions } from './multipart-options.interface'; + +export type MultipartModuleOptions = MultipartOptions; + +export interface MultipartOptionsFactory { + createMultipartOptions(): + | Promise + | MultipartModuleOptions; +} + +export interface MultipartModuleAsyncOptions + extends Pick { + useExisting?: Type; + useClass?: Type; + useFactory?: ( + ...args: any[] + ) => Promise | MultipartModuleOptions; + inject?: any[]; +} diff --git a/packages/platform-fastify/multipart/interfaces/index.ts b/packages/platform-fastify/multipart/interfaces/index.ts new file mode 100644 index 00000000000..1aa3bc6c406 --- /dev/null +++ b/packages/platform-fastify/multipart/interfaces/index.ts @@ -0,0 +1,3 @@ +export * from './files-upload-module.interface'; +export * from './multipart-file.interface'; +export * from './multipart-options.interface'; diff --git a/packages/platform-fastify/multipart/interfaces/multipart-file.interface.ts b/packages/platform-fastify/multipart/interfaces/multipart-file.interface.ts new file mode 100644 index 00000000000..256862005c8 --- /dev/null +++ b/packages/platform-fastify/multipart/interfaces/multipart-file.interface.ts @@ -0,0 +1,26 @@ +export interface MultipartDiskFile extends MultipartFile { + path: string; + destination: string; +} + +interface MultipartFields { + [x: string]: FastifyMultipartFile | FastifyMultipartFile[]; +} + +export interface FastifyMultipartFile { + toBuffer: () => Promise; + file: NodeJS.ReadStream; + filepath: string; + fieldname: string; + filename: string; + encoding: string; + mimetype: string; + fields: MultipartFields; +} + +export interface MultipartFile extends FastifyMultipartFile { + originalname: string; + size: number; +} + +export type InterceptorFile = MultipartFile | MultipartDiskFile; diff --git a/packages/platform-fastify/multipart/interfaces/multipart-options.interface.ts b/packages/platform-fastify/multipart/interfaces/multipart-options.interface.ts new file mode 100644 index 00000000000..156f3793c4e --- /dev/null +++ b/packages/platform-fastify/multipart/interfaces/multipart-options.interface.ts @@ -0,0 +1,49 @@ +import { FastifyMultipartFile } from './multipart-file.interface'; + +export interface MultipartOptions { + /** Destination folder, if not undefined uploaded file will be saved locally in dest path */ + dest?: string; + /** + * An object specifying the size limits of the following optional properties. This object is passed to busboy + * directly, and the details of properties can be found on https://github.com/mscdex/busboy#busboy-methods + */ + limits?: { + /** Max field name size (in bytes) (Default: 100 bytes) */ + fieldnameSize?: number; + /** Max field value size (in bytes) (Default: 1MB) */ + fieldSize?: number; + /** Max number of non-file fields (Default: Infinity) */ + fields?: number; + /** For multipart forms, the max file size (in bytes) (Default: Infinity) */ + fileSize?: number; + /** For multipart forms, the max number of file fields (Default: Infinity) */ + files?: number; + /** For multipart forms, the max number of parts (fields + files) (Default: Infinity) */ + parts?: number; + /** For multipart forms, the max number of header key=>value pairs to parse Default: 2000 (same as node's http) */ + headerPairs?: number; + }; + /** These are the HTTP headers of the incoming request, which are used by individual parsers */ + headers?: any; + /** highWaterMark to use for this Busboy instance (Default: WritableStream default). */ + highWaterMark?: number; + /** highWaterMark to use for file streams (Default: ReadableStream default) */ + fileHwm?: number; + /** Default character set to use when one isn't defined (Default: 'utf8') */ + defCharset?: string; + /** If paths in the multipart 'filename' field shall be preserved. (Default: false) */ + preservePath?: boolean; + /** Function to control which files are accepted */ + fileFilter?( + req: any, + file: FastifyMultipartFile, + callback: (error: Error | null, acceptFile?: boolean) => void, + ): void; +} + +export interface UploadField { + /** The field name. */ + name: string; + /** Optional maximum number of files per field to accept. */ + maxCount?: number; +} diff --git a/packages/platform-fastify/multipart/multipart.constants.ts b/packages/platform-fastify/multipart/multipart.constants.ts new file mode 100644 index 00000000000..b4814c24390 --- /dev/null +++ b/packages/platform-fastify/multipart/multipart.constants.ts @@ -0,0 +1 @@ +export const MULTIPART_MODULE_ID = 'MULTIPART_MODULE_ID'; diff --git a/packages/platform-fastify/multipart/multipart.module.ts b/packages/platform-fastify/multipart/multipart.module.ts new file mode 100644 index 00000000000..dfcb80d733b --- /dev/null +++ b/packages/platform-fastify/multipart/multipart.module.ts @@ -0,0 +1,74 @@ +import { DynamicModule, Module, Provider } from '@nestjs/common'; +import { randomStringGenerator } from '@nestjs/common/utils/random-string-generator.util'; +import { MULTIPART_MODULE_OPTIONS } from './files.constants'; +import { + MultipartModuleAsyncOptions, + MultipartModuleOptions, + MultipartOptionsFactory, +} from './interfaces/files-upload-module.interface'; +import { MULTIPART_MODULE_ID } from './multipart.constants'; + +@Module({}) +export class MultipartModule { + static register(options: MultipartModuleOptions = {}): DynamicModule { + return { + module: MultipartModule, + providers: [ + { provide: MULTIPART_MODULE_OPTIONS, useValue: options }, + { + provide: MULTIPART_MODULE_ID, + useValue: randomStringGenerator(), + }, + ], + exports: [MULTIPART_MODULE_OPTIONS], + }; + } + + static registerAsync(options: MultipartModuleAsyncOptions): DynamicModule { + return { + module: MultipartModule, + imports: options.imports, + providers: [ + ...this.createAsyncProviders(options), + { + provide: MULTIPART_MODULE_ID, + useValue: randomStringGenerator(), + }, + ], + exports: [MULTIPART_MODULE_OPTIONS], + }; + } + + private static createAsyncProviders( + options: MultipartModuleAsyncOptions, + ): Provider[] { + if (options.useExisting || options.useFactory) { + return [this.createAsyncOptionsProvider(options)]; + } + return [ + this.createAsyncOptionsProvider(options), + { + provide: options.useClass, + useClass: options.useClass, + }, + ]; + } + + private static createAsyncOptionsProvider( + options: MultipartModuleAsyncOptions, + ): Provider { + if (options.useFactory) { + return { + provide: MULTIPART_MODULE_OPTIONS, + useFactory: options.useFactory, + inject: options.inject || [], + }; + } + return { + provide: MULTIPART_MODULE_OPTIONS, + useFactory: async (optionsFactory: MultipartOptionsFactory) => + optionsFactory.createMultipartOptions(), + inject: [options.useExisting || options.useClass], + }; + } +} diff --git a/packages/platform-fastify/multipart/multipart/index.ts b/packages/platform-fastify/multipart/multipart/index.ts new file mode 100644 index 00000000000..a581146d4ce --- /dev/null +++ b/packages/platform-fastify/multipart/multipart/index.ts @@ -0,0 +1,3 @@ +export * from './multipart.constants'; +export * from './multipart.utils'; +export * from './multipart-wrapper'; diff --git a/packages/platform-fastify/multipart/multipart/multipart-wrapper.ts b/packages/platform-fastify/multipart/multipart/multipart-wrapper.ts new file mode 100644 index 00000000000..6fcf6d1a7ae --- /dev/null +++ b/packages/platform-fastify/multipart/multipart/multipart-wrapper.ts @@ -0,0 +1,265 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { randomStringGenerator } from '@nestjs/common/utils/random-string-generator.util'; +import { + FastifyMultipartFile, + MultipartDiskFile, + InterceptorFile, + MultipartFile, + MultipartOptions, + UploadField, +} from '../interfaces'; +import { filterAsyncGenerator } from '../utils'; +import { multipartExceptions } from './multipart.constants'; + +export class MultipartWrapper { + public constructor(private options: MultipartOptions) { } + + public file(fieldname: string) { + return async (req: any): Promise => { + return new Promise(async (resolve, reject) => { + try { + const reqFile: MultipartFile = await req.file(this.options); + let multipartFile = reqFile.fields[fieldname] as + | MultipartFile + | MultipartFile[]; + if (Array.isArray(multipartFile)) { + multipartFile = multipartFile[0]; + } + if (!multipartFile) + throw new Error(multipartExceptions.LIMIT_UNEXPECTED_FILE); + if (typeof this.options.fileFilter === 'function') { + let isFileAccepted = true; + this.options.fileFilter(req, multipartFile, (err, acceptFile) => { + if (err) throw err; + isFileAccepted = acceptFile; + }); + if (!isFileAccepted) return resolve(undefined); + } + // TODO: add typeof === "string" + if (!this.options.dest) { + multipartFile = await this.endStream(multipartFile); + return resolve(multipartFile); + } + if (!fs.existsSync(this.options.dest)) { + await fs.promises.mkdir(this.options.dest, { recursive: true }); + } + const file = await this.writeFile(multipartFile); + return resolve(file); + } catch (err) { + return reject(err); + } + }); + }; + } + + public files(fieldname: string, maxCount?: number) { + return async (req: any): Promise => { + return new Promise(async (resolve, reject) => { + const options = { ...this.options }; + if (maxCount) { + options.limits = { + ...options.limits, + files: maxCount, + }; + } + const files: InterceptorFile[] = []; + try { + const filesGenerator: AsyncGenerator = await req.files( + options, + ); + const filteredFileGenerator = filterAsyncGenerator( + filesGenerator, + async multipartFile => { + // emit 'end' signalling that this iteration will not consume file stream + multipartFile.file.emit('end'); + if (multipartFile.fieldname !== fieldname) return false; + if (!multipartFile) return false; + let isFileAccepted = true; + if (typeof options.fileFilter === 'function') { + options.fileFilter(req, multipartFile, (err, acceptFile) => { + if (err) throw err; + isFileAccepted = acceptFile; + }); + } + return isFileAccepted; + }, + ); + for await (let multipartFile of filteredFileGenerator) { + if (options.dest) { + if (!fs.existsSync(options.dest)) { + await fs.promises.mkdir(options.dest, { recursive: true }); + } + multipartFile = await this.writeFile(multipartFile); + } else { + multipartFile = await this.endStream(multipartFile); + } + files.push(multipartFile); + } + return resolve(files.length === 0 ? undefined : files); + } catch (err) { + return reject(err); + } + }); + }; + } + + public any() { + return async (req: any): Promise => { + return new Promise(async (resolve, reject) => { + try { + const filesGenerator: AsyncGenerator = await req.files( + this.options, + ); + const filteredFileGenerator = filterAsyncGenerator( + filesGenerator, + async multipartFile => { + // emit 'end' signalling that this iteration will not consume file stream + multipartFile.file.emit('end'); + if (!multipartFile) return false; + let isFileAccepted = true; + if (typeof this.options.fileFilter === 'function') { + this.options.fileFilter( + req, + multipartFile, + (err, acceptFile) => { + if (err) throw err; + isFileAccepted = acceptFile; + }, + ); + } + return isFileAccepted; + }, + ); + const files: InterceptorFile[] = []; + for await (let multipartFile of filteredFileGenerator) { + if (this.options.dest) { + if (!fs.existsSync(this.options.dest)) { + await fs.promises.mkdir(this.options.dest, { recursive: true }); + } + multipartFile = await this.writeFile(multipartFile); + } else { + multipartFile = await this.endStream(multipartFile); + } + files.push(multipartFile); + } + return resolve(files.length === 0 ? undefined : files); + } catch (err) { + return reject(err); + } + }); + }; + } + + public fileFields(uploadFields: UploadField[]) { + return async ( + req: any, + ): Promise | undefined> => { + return new Promise(async (resolve, reject) => { + try { + const filesGenerator: AsyncGenerator = await req.files( + this.options, + ); + const uploadFieldKeys = uploadFields.map( + uploadField => uploadField.name, + ); + const filteredFileGenerator = filterAsyncGenerator( + filesGenerator, + async multipartFile => { + // emit 'end' signalling that this iteration will not consume file stream + multipartFile.file.emit('end'); + const indexOfUploadField = uploadFieldKeys.indexOf( + multipartFile.fieldname, + ); + if (indexOfUploadField === -1) { + throw new Error(multipartExceptions.LIMIT_UNEXPECTED_FILE); + } + const field = uploadFields[indexOfUploadField]; + if (multipartFile.fieldname !== field.name) return false; + if (!field.maxCount || field.maxCount <= 0) { + throw new Error(multipartExceptions.FST_FILES_LIMIT); + } + const allFilesInField = multipartFile.fields[field.name]; + if ( + Array.isArray(allFilesInField) && + allFilesInField.length > field.maxCount + ) { + throw new Error(multipartExceptions.FST_FILES_LIMIT); + } + let isFileAccepted = true; + if (typeof this.options.fileFilter === 'function') { + this.options.fileFilter( + req, + multipartFile, + (err, acceptFile) => { + if (err) throw err; + isFileAccepted = acceptFile; + }, + ); + } + return isFileAccepted; + }, + ); + let fieldsObject: Record | undefined; + for await (const file of filteredFileGenerator) { + const indexOfUploadField = uploadFieldKeys.indexOf(file.fieldname); + const field = uploadFields[indexOfUploadField]; + let multipartFile = file as InterceptorFile; + if (this.options.dest) { + if (!fs.existsSync(this.options.dest)) { + await fs.promises.mkdir(this.options.dest, { recursive: true }); + } + multipartFile = await this.writeFile(file); + } else { + multipartFile = await this.endStream(multipartFile); + } + if (!fieldsObject) { + fieldsObject = Object.create(null); + } + if (!fieldsObject[field.name]) { + fieldsObject[field.name] = []; + } + fieldsObject[field.name].push(multipartFile); + } + return resolve(fieldsObject); + } catch (err) { + return reject(err); + } + }); + }; + } + + private async writeFile(file: MultipartFile): Promise { + return new Promise((resolve, reject) => { + const multipartFile = { ...file } as MultipartDiskFile; + const filename = multipartFile.filename; + const extension = path.extname(filename); + const randomFileName = randomStringGenerator() + extension; + multipartFile.originalname = filename; + multipartFile.filename = randomFileName; + multipartFile.destination = this.options.dest; + const filePath = path.join(this.options.dest, randomFileName); + multipartFile.path = filePath; + const outStream = fs.createWriteStream(filePath); + multipartFile.file.pipe(outStream); + outStream.on('error', err => { + multipartFile.file.destroy(); + return reject(err); + }); + outStream.on('finish', () => { + multipartFile.size = outStream.bytesWritten; + return resolve(multipartFile); + }); + }); + } + + private async endStream( + fastifyMultipart: FastifyMultipartFile, + ): Promise { + fastifyMultipart.file.emit('end'); + const multipartFile = { ...fastifyMultipart } as MultipartFile; + multipartFile.size = multipartFile.file.readableLength; + multipartFile.originalname = multipartFile.filename; + return multipartFile; + } +} diff --git a/packages/platform-fastify/multipart/multipart/multipart.constants.ts b/packages/platform-fastify/multipart/multipart/multipart.constants.ts new file mode 100644 index 00000000000..a8d7da029bd --- /dev/null +++ b/packages/platform-fastify/multipart/multipart/multipart.constants.ts @@ -0,0 +1,10 @@ +export const multipartExceptions = { + FST_PARTS_LIMIT: 'reach parts limit', + FST_FILES_LIMIT: 'reach files limit', + FST_FIELDS_LIMIT: 'reach fields limit', + FST_REQ_FILE_TOO_LARGE: + 'request file too large, please check multipart config', + FST_PROTO_VIOLATION: 'prototype property is not allowed as field name', + FST_INVALID_MULTIPART_CONTENT_TYPE: 'the request is not multipart', + LIMIT_UNEXPECTED_FILE: 'Unexpected field', +}; diff --git a/packages/platform-fastify/multipart/multipart/multipart.utils.ts b/packages/platform-fastify/multipart/multipart/multipart.utils.ts new file mode 100644 index 00000000000..25c5eed618e --- /dev/null +++ b/packages/platform-fastify/multipart/multipart/multipart.utils.ts @@ -0,0 +1,30 @@ +import { + BadRequestException, + HttpException, + InternalServerErrorException, + NotAcceptableException, + PayloadTooLargeException, +} from '@nestjs/common'; +import { multipartExceptions } from './multipart.constants'; + +export function transformException(err: Error | undefined) { + if (!err || err instanceof HttpException) { + return err; + } + switch (err.message) { + case multipartExceptions.FST_PARTS_LIMIT: + case multipartExceptions.FST_FILES_LIMIT: + case multipartExceptions.FST_FIELDS_LIMIT: + case multipartExceptions.FST_REQ_FILE_TOO_LARGE: + return new PayloadTooLargeException(err.message); + case multipartExceptions.FST_INVALID_MULTIPART_CONTENT_TYPE: + return new NotAcceptableException(err.message); + case multipartExceptions.FST_PROTO_VIOLATION: + case multipartExceptions.LIMIT_UNEXPECTED_FILE: + return new BadRequestException(err.message); + } + if (err instanceof Error) { + return new InternalServerErrorException(err.message); + } + return err; +} diff --git a/packages/platform-fastify/multipart/utils/filter-async-generator.ts b/packages/platform-fastify/multipart/utils/filter-async-generator.ts new file mode 100644 index 00000000000..86c0fbce90f --- /dev/null +++ b/packages/platform-fastify/multipart/utils/filter-async-generator.ts @@ -0,0 +1,14 @@ +export async function* filterAsyncGenerator( + asyncGenerator: AsyncGenerator, + filter: (value: T) => Promise, +) { + const values: T[] = []; + for await (const value of asyncGenerator) { + const isAccepted = await filter(value); + if (!isAccepted) continue; + values.push(value); + } + for (const value of values) { + yield value; + } +} diff --git a/packages/platform-fastify/multipart/utils/index.ts b/packages/platform-fastify/multipart/utils/index.ts new file mode 100644 index 00000000000..e158bdf3165 --- /dev/null +++ b/packages/platform-fastify/multipart/utils/index.ts @@ -0,0 +1 @@ +export * from './filter-async-generator'; diff --git a/packages/platform-fastify/test/multipart/interceptors/any-files.interceptor.spec.ts b/packages/platform-fastify/test/multipart/interceptors/any-files.interceptor.spec.ts new file mode 100644 index 00000000000..49b27b85691 --- /dev/null +++ b/packages/platform-fastify/test/multipart/interceptors/any-files.interceptor.spec.ts @@ -0,0 +1,53 @@ +import { expect } from 'chai'; +import { of } from 'rxjs'; +import * as sinon from 'sinon'; +import { CallHandler } from '@nestjs/common'; +import { ExecutionContextHost } from '@nestjs/core/helpers/execution-context-host'; +import { AnyFilesInterceptor } from '../../../multipart/interceptors/any-files.interceptor'; + +describe('AnyFilesInterceptor', () => { + it('should return metatype with expected structure', async () => { + const targetClass = AnyFilesInterceptor(); + expect(targetClass.prototype.intercept).to.not.be.undefined; + }); + describe('intercept', () => { + let handler: CallHandler; + const context = new ExecutionContextHost([]); + beforeEach(() => { + handler = { + handle: () => of('test'), + }; + context.switchToHttp = () => + ({ + getRequest: () => { + return { + file: () => () => {}, + files: () => () => {}, + }; + }, + } as any); + }); + it('should call any() with expected params', async () => { + const target = new (AnyFilesInterceptor())(); + const callback = () => {}; + const arraySpy = sinon + .stub((target as any).multipart, 'any') + .returns(callback); + + await target.intercept(context, handler); + + expect(arraySpy.called).to.be.true; + expect(arraySpy.calledWith()).to.be.true; + }); + it('should transform exception', async () => { + const target = new (AnyFilesInterceptor())(); + const callback = () => {}; + (target as any).multipart = { + any: () => callback, + }; + (target.intercept(context, handler) as any).catch( + error => expect(error).to.not.be.undefined, + ); + }); + }); +}); diff --git a/packages/platform-fastify/test/multipart/interceptors/file-fields.interceptor.spec.ts b/packages/platform-fastify/test/multipart/interceptors/file-fields.interceptor.spec.ts new file mode 100644 index 00000000000..579191489d5 --- /dev/null +++ b/packages/platform-fastify/test/multipart/interceptors/file-fields.interceptor.spec.ts @@ -0,0 +1,55 @@ +import { expect } from 'chai'; +import { of } from 'rxjs'; +import * as sinon from 'sinon'; +import { CallHandler } from '@nestjs/common'; +import { ExecutionContextHost } from '@nestjs/core/helpers/execution-context-host'; +import { FileFieldsInterceptor } from '../../../multipart/interceptors/file-fields.interceptor'; +import { UploadField } from '../../../multipart/interfaces'; + +describe('FileFieldsInterceptor', () => { + const uploadFields: UploadField[] = [{ name: 'field', maxCount: 10 }]; + it('should return metatype with expected structure', async () => { + const targetClass = FileFieldsInterceptor(uploadFields); + expect(targetClass.prototype.intercept).to.not.be.undefined; + }); + describe('intercept', () => { + let handler: CallHandler; + const context = new ExecutionContextHost([]); + beforeEach(() => { + handler = { + handle: () => of('test'), + }; + context.switchToHttp = () => + ({ + getRequest: () => { + return { + file: () => () => {}, + files: () => () => {}, + }; + }, + } as any); + }); + it('should call fileFields() with expected params', async () => { + const target = new (FileFieldsInterceptor(uploadFields))(); + const callback = () => {}; + const arraySpy = sinon + .stub((target as any).multipart, 'fileFields') + .returns(callback); + + await target.intercept(context, handler); + + expect(arraySpy.called).to.be.true; + expect(arraySpy.calledWith()).to.be.true; + }); + it('should transform exception', async () => { + const target = new (FileFieldsInterceptor(uploadFields))(); + const callback = () => {}; + (target as any).multipart = { + any: () => callback, + }; + (target.intercept(context, handler) as any).catch( + error => expect(error).to.not.be.undefined, + ); + }); + }); +}); diff --git a/packages/platform-fastify/test/multipart/interceptors/file.interceptor.spec.ts b/packages/platform-fastify/test/multipart/interceptors/file.interceptor.spec.ts new file mode 100644 index 00000000000..4407550ff0f --- /dev/null +++ b/packages/platform-fastify/test/multipart/interceptors/file.interceptor.spec.ts @@ -0,0 +1,53 @@ +import { expect } from 'chai'; +import { of } from 'rxjs'; +import * as sinon from 'sinon'; +import { CallHandler } from '@nestjs/common'; +import { ExecutionContextHost } from '@nestjs/core/helpers/execution-context-host'; +import { FileInterceptor } from '../../../multipart/interceptors/file.interceptor'; + +describe('FileInterceptor', () => { + it('should return metatype with expected structure', async () => { + const targetClass = FileInterceptor('file'); + expect(targetClass.prototype.intercept).to.not.be.undefined; + }); + describe('intercept', () => { + let handler: CallHandler; + const context = new ExecutionContextHost([]); + const fieldName = 'file'; + beforeEach(() => { + handler = { + handle: () => of('test'), + }; + context.switchToHttp = () => + ({ + getRequest: () => { + return { + file: () => () => {}, + }; + }, + } as any); + }); + it('should call file() with expected params', async () => { + const target = new (FileInterceptor(fieldName))(); + const callback = () => {}; + const filesSpy = sinon + .stub((target as any).multipart, 'file') + .returns(callback); + await target.intercept(context as any, handler); + + expect(filesSpy.called).to.be.true; + expect(filesSpy.calledWith(fieldName)).to.be.true; + }); + it('should transform exception', async () => { + const fieldName = 'file'; + const target = new (FileInterceptor(fieldName))(); + const callback = () => {}; + (target as any).multipart = { + file: () => callback, + }; + (target.intercept(context, handler) as any).catch( + error => expect(error).to.not.be.undefined, + ); + }); + }); +}); diff --git a/packages/platform-fastify/test/multipart/interceptors/files.interceptor.spec.ts b/packages/platform-fastify/test/multipart/interceptors/files.interceptor.spec.ts new file mode 100644 index 00000000000..2adc6daea99 --- /dev/null +++ b/packages/platform-fastify/test/multipart/interceptors/files.interceptor.spec.ts @@ -0,0 +1,54 @@ +import { expect } from 'chai'; +import { of } from 'rxjs'; +import * as sinon from 'sinon'; +import { CallHandler } from '@nestjs/common'; +import { ExecutionContextHost } from '@nestjs/core/helpers/execution-context-host'; +import { FilesInterceptor } from '../../../multipart/interceptors/files.interceptor'; + +describe('FilesInterceptor', () => { + it('should return metatype with expected structure', async () => { + const targetClass = FilesInterceptor('files'); + expect(targetClass.prototype.intercept).to.not.be.undefined; + }); + describe('intercept', () => { + let handler: CallHandler; + const context = new ExecutionContextHost([]); + const fieldName = 'files'; + beforeEach(() => { + handler = { + handle: () => of('test'), + }; + context.switchToHttp = () => + ({ + getRequest: () => { + return { + file: () => () => {}, + files: () => () => {}, + }; + }, + } as any); + }); + it('should call files() with expected params', async () => { + const maxCount = 10; + const target = new (FilesInterceptor(fieldName, maxCount))(); + const callback = () => {}; + const filesSpy = sinon + .stub((target as any).multipart, 'files') + .returns(callback); + await target.intercept(context, handler); + expect(filesSpy.called).to.be.true; + expect(filesSpy.calledWith(fieldName, maxCount)).to.be.true; + }); + it('should transform exception', async () => { + const fieldName = 'file'; + const target = new (FilesInterceptor(fieldName))(); + const callback = () => {}; + (target as any).multipart = { + files: () => callback, + }; + (target.intercept(new ExecutionContextHost([]), handler) as any).catch( + error => expect(error).to.not.be.undefined, + ); + }); + }); +}); diff --git a/packages/platform-fastify/test/multipart/multipart/multipart-wrapper.spec.ts b/packages/platform-fastify/test/multipart/multipart/multipart-wrapper.spec.ts new file mode 100644 index 00000000000..5af98224f09 --- /dev/null +++ b/packages/platform-fastify/test/multipart/multipart/multipart-wrapper.spec.ts @@ -0,0 +1,579 @@ +import * as sinon from 'sinon'; +import * as fs from 'fs'; +import * as path from 'path'; +import { expect } from 'chai'; +import { Readable, PassThrough } from 'stream'; +import { HttpException, HttpStatus } from '@nestjs/common'; +import { MultipartOptions } from '../../../multipart/interfaces/multipart-options.interface'; +import { MultipartWrapper } from '../../../multipart/multipart/multipart-wrapper'; +import { InterceptorFile } from '../../../multipart/interfaces/multipart-file.interface'; +import { multipartExceptions } from '../../../multipart/multipart/multipart.constants'; + +describe('MultipartWrapper', () => { + let fileObject: any = {}; + let filesArray: any[] = []; + let req: any = {}; + const objectFieldname = 'single-file-fieldname'; + const arrayFieldname = 'array-files-fieldname'; + const mockReadable = new Readable({ + read(size) { + this.push(null); + }, + }); + async function* getMultipartIterator() { + for await (const multipartFile of filesArray) { + yield multipartFile; + } + } + + beforeEach(() => { + (fs as any).promises.mkdir = (path: string, options: any) => {}; + (fs as any).createWriteStream = (path: string) => new PassThrough(); + (fs as any).existsSync = (path: string) => false; + filesArray = [ + { + fieldname: arrayFieldname, + filename: 'original-file-test-1.png', + encoding: '7bit', + mimetype: 'image/png', + file: mockReadable, + fields: {}, + }, + { + fieldname: arrayFieldname, + filename: 'original-file-test-2.png', + encoding: '7bit', + mimetype: 'image/png', + file: mockReadable, + fields: {}, + }, + ]; + fileObject = { + fieldname: objectFieldname, + filename: 'original-file-test-3.png', + encoding: '7bit', + mimetype: 'image/png', + file: mockReadable, + fields: {}, + }; + fileObject.fields[objectFieldname] = [fileObject]; + fileObject.fields[arrayFieldname] = filesArray; + for (const file of filesArray) { + file.fields[arrayFieldname] = filesArray; + } + req = { + file: async (options: MultipartOptions) => fileObject, + files: async (options: MultipartOptions) => getMultipartIterator(), + }; + }); + describe('writeFile', () => { + it('should call fs.createWriteStream() with expected params', async () => { + const options: MultipartOptions = { + dest: 'upload/test', + }; + const multipart = new MultipartWrapper(options); + const createWriteStreamStub = sinon.spy(fs, 'createWriteStream'); + const file = await (multipart as any).writeFile(fileObject); + expect(createWriteStreamStub.called).to.be.true; + const filePath = path.join(options.dest, file.filename); + expect(createWriteStreamStub.calledWith(filePath)).to.be.true; + }); + it('should generate random filename and keep its originalname', async () => { + const options: MultipartOptions = { + dest: 'upload/test', + }; + const multipart = new MultipartWrapper(options); + const file = await (multipart as any).writeFile(fileObject); + expect(file.originalname).to.equal(fileObject.filename); + expect(file.filename).to.not.equal(fileObject.filename); + }); + it('should add destination and path', async () => { + const options: MultipartOptions = { + dest: 'upload/test', + }; + const multipart = new MultipartWrapper(options); + const file = await (multipart as any).writeFile(fileObject); + const filePath = path.join(options.dest, file.filename); + expect(file.path).to.equal(filePath); + expect(file.destination).to.equal(options.dest); + }); + it('should add bytesWritten number to file.size', async () => { + const options: MultipartOptions = { + dest: 'upload/test', + }; + const multipart = new MultipartWrapper(options); + const bytesWritten = 1234; + (fs as any).createWriteStream = (path: string) => { + const writeStream = new PassThrough(); + (writeStream as any).bytesWritten = bytesWritten; + return writeStream; + }; + const file = await (multipart as any).writeFile(fileObject); + expect(file.size).to.be.equal(bytesWritten); + }); + describe('on error', () => { + it('should call multipartFile.file.destroy()', async () => { + (fs as any).createWriteStream = (path: string) => { + const writeStream = new PassThrough(); + writeStream.on('data', () => { + writeStream.emit('end'); + }); + writeStream.on('end', () => { + writeStream.emit('error'); + }); + return writeStream; + }; + const options: MultipartOptions = { + dest: 'upload/test', + }; + const multipart = new MultipartWrapper(options); + fileObject.file.destroy = () => {}; + const destroyStub = sinon.stub(fileObject.file, 'destroy'); + try { + await (multipart as any).writeFile(fileObject); + } catch (error) {} + expect(destroyStub.called).to.be.true; + }); + }); + }); + describe('endStream', () => { + it('should emit file end', async () => { + const multipart = new MultipartWrapper({}); + const fileEmitStub = sinon.stub(fileObject.file, 'emit'); + await (multipart as any).endStream(fileObject); + expect(fileEmitStub.called).to.be.true; + expect(fileEmitStub.calledWith('end')).to.be.true; + }); + it('should return file with originalname and size', async () => { + const multipart = new MultipartWrapper({}); + const file = await (multipart as any).endStream(fileObject); + expect(file.originalname).to.equal(fileObject.filename); + expect(file.size).to.be.equal(fileObject.file.readableLength); + }); + }); + describe('file', () => { + it('should call file() with expected params', async () => { + const multipart = new MultipartWrapper({}); + const fileSpy = sinon.spy(multipart, 'file'); + await multipart.file(objectFieldname)(req); + expect(fileSpy.called).to.be.true; + expect(fileSpy.calledWith(objectFieldname)).to.be.true; + }); + it('should call req.file() with expected params', async () => { + const options: MultipartOptions = { + limits: { + fieldSize: 10, + }, + }; + const reqSpy = sinon.spy(req, 'file'); + const multipart = new MultipartWrapper(options); + await multipart.file(objectFieldname)(req); + expect(reqSpy.called).to.be.true; + expect(reqSpy.calledWith(options)).to.be.true; + }); + it('should not call writeFile() if dest is undefined', async () => { + const multipart = new MultipartWrapper({}); + const writeFileSpy = sinon.spy(multipart, 'writeFile'); + await multipart.file(objectFieldname)(req); + expect(writeFileSpy.called).to.be.false; + }); + describe('options', () => { + describe('dest', () => { + it('should call mkdir with expected params', async () => { + const options: MultipartOptions = { + dest: 'upload/test', + }; + const multipart = new MultipartWrapper(options); + const fsSpy = sinon.spy(fs.promises, 'mkdir'); + await multipart.file(objectFieldname)(req); + expect(fsSpy.called).to.be.true; + expect(fsSpy.calledWith(options.dest, { recursive: true })).to.be + .true; + }); + it('should call writeFile() with expected params if dest is defined', async () => { + const options: MultipartOptions = { + dest: 'upload/test', + }; + const multipart = new MultipartWrapper(options); + const writeFileSpy = sinon.spy(multipart, 'writeFile'); + await multipart.file(objectFieldname)(req); + expect(writeFileSpy.called).to.be.true; + expect(writeFileSpy.calledWith(fileObject.fields[objectFieldname][0])) + .to.be.true; + }); + }); + describe('fileFilter', () => { + it('should return undefined if options.fileFilter callback is (null, false)', async () => { + const options: MultipartOptions = { + fileFilter: (req, file, cb) => cb(null, false), + }; + const multipart = new MultipartWrapper(options); + const file = await multipart.file(objectFieldname)(req); + expect(file).to.be.undefined; + }); + it('should throw error if options.fileFilter callback is (Error, Boolean)', async () => { + const errorMessage = 'Expect fileFilter test to throw error'; + const errorStatus = HttpStatus.I_AM_A_TEAPOT; + const newHttpError = new HttpException(errorMessage, errorStatus); + const options: MultipartOptions = { + fileFilter: (req, file, cb) => cb(newHttpError, false), + }; + const multipart = new MultipartWrapper(options); + return expect( + multipart.file(objectFieldname)(req), + ).to.be.rejected.and.to.eventually.equal(newHttpError); + }); + }); + }); + }); + describe('files', () => { + it('should call files() with expected params', async () => { + const multipart = new MultipartWrapper({}); + const maxCount = 10; + const filesSpy = sinon.spy(multipart, 'files'); + await multipart.files(arrayFieldname, maxCount)(req); + expect(filesSpy.called).to.be.true; + expect(filesSpy.calledWith(arrayFieldname, maxCount)).to.be.true; + }); + it('should call req.files() with expected options', async () => { + const options: MultipartOptions = { + limits: {}, + }; + const multipart = new MultipartWrapper(options); + const maxCount = 10; + const reqSpy = sinon.spy(req, 'files'); + await multipart.files(arrayFieldname, maxCount)(req); + expect(reqSpy.called).to.be.true; + expect( + reqSpy.calledWith({ + ...options, + limits: { + ...options?.limits, + files: maxCount, + }, + }), + ).to.be.true; + }); + it('should not call writeFile() if dest is undefined', async () => { + const multipart = new MultipartWrapper({ + dest: undefined, + }); + const writeFileSpy = sinon.spy(multipart, 'writeFile'); + await multipart.any()(req); + expect(writeFileSpy.called).to.be.false; + }); + it("should call multipartFile.file.emit('end') if dest is undefined", async () => { + (fs as any).existsSync = (path: string) => true; + fileObject.file = new Readable(); + const emitEndStub = sinon.stub(fileObject.file, 'emit'); + filesArray = [fileObject]; + const multipart = new MultipartWrapper({}); + await multipart.files(arrayFieldname)(req); + expect(emitEndStub.called).to.be.true; + }); + describe('options', () => { + describe('dest', () => { + it('should call mkdir with expected params', async () => { + const options: MultipartOptions = { + dest: 'upload/test', + }; + const multipart = new MultipartWrapper(options); + const fsSpy = sinon.spy(fs.promises, 'mkdir'); + await multipart.files(arrayFieldname)(req); + expect(fsSpy.called).to.be.true; + expect(fsSpy.calledWith(options.dest, { recursive: true })).to.be + .true; + }); + }); + describe('fileFilter', () => { + it('should return undefined if options.fileFilter callback is (null, false)', async () => { + const options: MultipartOptions = { + fileFilter: (req, file, cb) => cb(null, false), + }; + const multipart = new MultipartWrapper(options); + const files = await multipart.files(arrayFieldname)(req); + expect(files).to.be.undefined; + }); + it('should filter specific file if callback is (null, false)', async () => { + const fileToFilter: InterceptorFile = filesArray[1]; + const options: MultipartOptions = { + fileFilter: (req, file, cb) => { + if (file.filename === fileToFilter.filename) { + return cb(null, false); + } + cb(null, true); + }, + }; + const multipart = new MultipartWrapper(options); + const files = await multipart.files(arrayFieldname)(req); + expect(files).to.not.have.members([fileToFilter]); + }); + it('should throw error if options.fileFilter callback is (Error, Boolean)', async () => { + const errorMessage = 'Expect fileFilter test to throw error'; + const errorStatus = HttpStatus.I_AM_A_TEAPOT; + const newHttpError = new HttpException(errorMessage, errorStatus); + const options: MultipartOptions = { + fileFilter: (req, file, cb) => cb(newHttpError, false), + }; + const multipart = new MultipartWrapper(options); + return expect( + multipart.files(arrayFieldname)(req), + ).to.be.rejected.and.to.eventually.equal(newHttpError); + }); + }); + }); + }); + describe('any', () => { + it('should call req.files() with expected options', async () => { + const options: MultipartOptions = { + limits: {}, + }; + const multipart = new MultipartWrapper(options); + const reqSpy = sinon.spy(req, 'files'); + await multipart.any()(req); + expect(reqSpy.called).to.be.true; + expect(reqSpy.calledWith(options)).to.be.true; + }); + it('should not call writeFile() if dest is undefined', async () => { + const multipart = new MultipartWrapper({ + dest: undefined, + }); + const writeFileSpy = sinon.spy(multipart, 'writeFile'); + await multipart.any()(req); + expect(writeFileSpy.called).to.be.false; + }); + it("should call multipartFile.file.emit('end') if dest is undefined", async () => { + (fs as any).existsSync = (path: string) => true; + fileObject.file = new Readable(); + const emitEndStub = sinon.stub(fileObject.file, 'emit'); + filesArray = [fileObject]; + const multipart = new MultipartWrapper({}); + await multipart.any()(req); + expect(emitEndStub.called).to.be.true; + }); + describe('options', () => { + describe('dest', () => { + it('should call mkdir with expected params', async () => { + const options: MultipartOptions = { + dest: 'upload/test', + }; + const multipart = new MultipartWrapper(options); + const fsSpy = sinon.spy(fs.promises, 'mkdir'); + await multipart.any()(req); + expect(fsSpy.called).to.be.true; + expect(fsSpy.calledWith(options.dest, { recursive: true })).to.be + .true; + }); + it('should call writeFile() with expected params', async () => { + const options: MultipartOptions = { + dest: 'upload/test', + }; + const multipart = new MultipartWrapper(options); + const writeFileSpy = sinon.spy(multipart, 'writeFile'); + await multipart.any()(req); + expect(writeFileSpy.called).to.be.true; + expect(writeFileSpy.getCall(0).calledWith(filesArray[0])).to.be.true; + expect(writeFileSpy.getCall(1).calledWith(filesArray[1])).to.be.true; + }); + }); + describe('fileFilter', () => { + it('should return undefined if options.fileFilter callback is (null, false)', async () => { + const options: MultipartOptions = { + fileFilter: (req, file, cb) => cb(null, false), + }; + const multipart = new MultipartWrapper(options); + const files = await multipart.any()(req); + expect(files).to.be.undefined; + }); + it('should filter specific file if callback is (null, false)', async () => { + const fileToFilter: InterceptorFile = filesArray[1]; + const options: MultipartOptions = { + fileFilter: (req, file, cb) => { + if (file.filename === fileToFilter.filename) { + return cb(null, false); + } + cb(null, true); + }, + }; + const multipart = new MultipartWrapper(options); + const files = await multipart.any()(req); + expect(files).to.not.have.members([fileToFilter]); + }); + it('should throw error if options.fileFilter callback is (Error, Boolean)', async () => { + const errorMessage = 'Expect fileFilter test to throw error'; + const errorStatus = HttpStatus.I_AM_A_TEAPOT; + const newHttpError = new HttpException(errorMessage, errorStatus); + const options: MultipartOptions = { + fileFilter: (req, file, cb) => cb(newHttpError, false), + }; + const multipart = new MultipartWrapper(options); + return expect( + multipart.any()(req), + ).to.be.rejected.and.to.eventually.equal(newHttpError); + }); + }); + }); + }); + describe('fileFields', () => { + it('should call req.files() with expected options', async () => { + const options: MultipartOptions = { + limits: {}, + }; + const multipart = new MultipartWrapper(options); + const reqSpy = sinon.spy(req, 'files'); + await multipart.fileFields([ + { name: arrayFieldname, maxCount: 10 }, + { name: objectFieldname, maxCount: 10 }, + ])(req); + expect(reqSpy.called).to.be.true; + expect(reqSpy.calledWith(options)).to.be.true; + }); + it('should not call writeFiles() if dest is undefined', async () => { + const multipart = new MultipartWrapper({ + dest: undefined, + }); + const writeFileSpy = sinon.spy(multipart, 'writeFile'); + await multipart.fileFields([ + { name: arrayFieldname, maxCount: 10 }, + { name: objectFieldname, maxCount: 10 }, + ])(req); + expect(writeFileSpy.called).to.be.false; + }); + it('should call writeFile() with expected params when dest is defined', async () => { + const options: MultipartOptions = { + dest: 'upload/test', + }; + const multipart = new MultipartWrapper(options); + const writeFileSpy = sinon.spy(multipart, 'writeFile'); + await multipart.fileFields([ + { name: arrayFieldname, maxCount: 10 }, + { name: objectFieldname, maxCount: 10 }, + ])(req); + expect(writeFileSpy.called).to.be.true; + expect(writeFileSpy.getCall(0).calledWith(filesArray[0])).to.be.true; + expect(writeFileSpy.getCall(1).calledWith(filesArray[1])).to.be.true; + }); + it("should call multipartFile.file.emit('end') if dest is undefined", async () => { + (fs as any).existsSync = (path: string) => true; + fileObject.file = new Readable(); + const emitEndStub = sinon.stub(fileObject.file, 'emit'); + filesArray = [fileObject]; + const multipart = new MultipartWrapper({}); + await multipart.fileFields([ + { name: arrayFieldname, maxCount: 10 }, + { name: objectFieldname, maxCount: 10 }, + ])(req); + expect(emitEndStub.called).to.be.true; + }); + describe('uploadFields', () => { + it('should throw exception if field is not listed in UploadField array', async () => { + const multipart = new MultipartWrapper({}); + const unknownFieldname = 'unknown-fieldname'; + const unknownFieldFile = { ...fileObject }; + unknownFieldFile.fieldname = unknownFieldname; + filesArray.push(unknownFieldFile); + return expect( + multipart.fileFields([ + { name: arrayFieldname, maxCount: 10 }, + { name: objectFieldname, maxCount: 10 }, + ])(req), + ) + .to.be.rejected.and.to.eventually.have.property('message') + .that.is.equal(multipartExceptions.LIMIT_UNEXPECTED_FILE); + }); + it('should throw exception if files exceed maxCount', async () => { + const multipart = new MultipartWrapper({}); + const maxCount = filesArray.length - 1; + return expect( + multipart.fileFields([ + { name: arrayFieldname, maxCount }, + { name: objectFieldname, maxCount: 1 }, + ])(req), + ) + .to.be.rejected.and.to.eventually.have.property('message') + .that.is.equal(multipartExceptions.FST_FILES_LIMIT); + }); + it('should throw exception if maxCount is zero or negative', async () => { + const multipart = new MultipartWrapper({}); + return expect( + multipart.fileFields([ + { name: arrayFieldname, maxCount: 0 }, + { name: objectFieldname, maxCount: -1 }, + ])(req), + ) + .to.be.rejected.and.to.eventually.have.property('message') + .that.is.equal(multipartExceptions.FST_FILES_LIMIT); + }); + }); + describe('options', () => { + describe('dest', () => { + it('should call mkdir with expected params', async () => { + const options: MultipartOptions = { + dest: 'upload/test', + }; + const multipart = new MultipartWrapper(options); + const fsSpy = sinon.spy(fs.promises, 'mkdir'); + await multipart.fileFields([ + { name: arrayFieldname, maxCount: 10 }, + { name: objectFieldname, maxCount: 10 }, + ])(req); + expect(fsSpy.called).to.be.true; + expect(fsSpy.calledWith(options.dest, { recursive: true })).to.be + .true; + }); + }); + describe('fileFilter', () => { + it('should return undefined if options.fileFilter callback is (null, false)', async () => { + const options: MultipartOptions = { + fileFilter: (req, file, cb) => cb(null, false), + }; + const multipart = new MultipartWrapper(options); + const files = await multipart.fileFields([ + { name: arrayFieldname, maxCount: 10 }, + { name: objectFieldname, maxCount: 10 }, + ])(req); + expect(files).to.be.undefined; + }); + it('should filter specific file if callback is (null, false)', async () => { + const fileToFilterInArray: InterceptorFile = filesArray[1]; + const fileToFilter: InterceptorFile = fileObject; + const options: MultipartOptions = { + fileFilter: (req, file, cb) => { + if ( + file.filename === fileToFilter.filename || + file.filename === fileToFilterInArray.filename + ) { + return cb(null, false); + } + cb(null, true); + }, + }; + const multipart = new MultipartWrapper(options); + const filesRecord = await multipart.fileFields([ + { name: arrayFieldname, maxCount: 10 }, + { name: objectFieldname, maxCount: 10 }, + ])(req); + expect(filesRecord[arrayFieldname]).to.not.have.members([ + fileToFilter, + ]); + expect(filesRecord[objectFieldname]).to.be.undefined; + }); + it('should throw error if options.fileFilter callback is (Error, Boolean)', async () => { + const errorMessage = 'Expect fileFilter test to throw error'; + const errorStatus = HttpStatus.I_AM_A_TEAPOT; + const newHttpError = new HttpException(errorMessage, errorStatus); + const options: MultipartOptions = { + fileFilter: (req, file, cb) => cb(newHttpError, false), + }; + const multipart = new MultipartWrapper(options); + return expect( + multipart.fileFields([ + { name: arrayFieldname, maxCount: 10 }, + { name: objectFieldname, maxCount: 10 }, + ])(req), + ).to.be.rejected.and.to.eventually.equal(newHttpError); + }); + }); + }); + }); +}); diff --git a/packages/platform-fastify/test/multipart/multipart/multipart.module.spec.ts b/packages/platform-fastify/test/multipart/multipart/multipart.module.spec.ts new file mode 100644 index 00000000000..1d52f12cff4 --- /dev/null +++ b/packages/platform-fastify/test/multipart/multipart/multipart.module.spec.ts @@ -0,0 +1,89 @@ +import { expect } from 'chai'; +import * as sinon from 'sinon'; +import { MULTIPART_MODULE_OPTIONS } from '../../../multipart/files.constants'; +import { MultipartModule } from '../../../multipart/multipart.module'; + +describe('MultipartModule', () => { + describe('register', () => { + it('should provide an options', () => { + const options = { + test: 'test', + }; + const dynamicModule = MultipartModule.register(options as any); + + expect(dynamicModule.providers).to.have.length(2); + expect(dynamicModule.imports).to.be.undefined; + expect(dynamicModule.exports).to.include(MULTIPART_MODULE_OPTIONS); + expect(dynamicModule.providers).to.deep.include({ + provide: MULTIPART_MODULE_OPTIONS, + useValue: options, + }); + }); + }); + + describe('register async', () => { + describe('when useFactory', () => { + it('should provide an options', () => { + const options: any = {}; + const asyncOptions = { + useFactory: () => options, + }; + const dynamicModule = MultipartModule.registerAsync(asyncOptions); + + expect(dynamicModule.providers).to.have.length(2); + expect(dynamicModule.imports).to.be.undefined; + expect(dynamicModule.exports).to.include(MULTIPART_MODULE_OPTIONS); + expect(dynamicModule.providers).to.deep.include({ + provide: MULTIPART_MODULE_OPTIONS, + useFactory: asyncOptions.useFactory, + inject: [], + }); + }); + }); + + describe('when useExisting', () => { + it('should provide an options', () => { + const asyncOptions = { + useExisting: Object, + }; + const dynamicModule = MultipartModule.registerAsync( + asyncOptions as any, + ); + + expect(dynamicModule.providers).to.have.length(2); + expect(dynamicModule.imports).to.be.undefined; + expect(dynamicModule.exports).to.include(MULTIPART_MODULE_OPTIONS); + }); + }); + + describe('when useClass', () => { + it('should provide an options', () => { + const asyncOptions = { + useClass: Object, + }; + const dynamicModule = MultipartModule.registerAsync( + asyncOptions as any, + ); + + expect(dynamicModule.providers).to.have.length(3); + expect(dynamicModule.imports).to.be.undefined; + expect(dynamicModule.exports).to.include(MULTIPART_MODULE_OPTIONS); + }); + it('provider should call "createMultipartOptions"', async () => { + const asyncOptions = { + useClass: Object, + }; + const dynamicModule = MultipartModule.registerAsync( + asyncOptions as any, + ); + const optionsFactory = { + createMultipartOptions: sinon.spy(), + }; + await ((dynamicModule.providers[0] as any).useFactory as any)( + optionsFactory, + ); + expect(optionsFactory.createMultipartOptions.called).to.be.true; + }); + }); + }); +}); diff --git a/packages/platform-fastify/test/multipart/multipart/multipart.utils.spec.ts b/packages/platform-fastify/test/multipart/multipart/multipart.utils.spec.ts new file mode 100644 index 00000000000..292ef1e2427 --- /dev/null +++ b/packages/platform-fastify/test/multipart/multipart/multipart.utils.spec.ts @@ -0,0 +1,84 @@ +import { + BadRequestException, + HttpException, + InternalServerErrorException, + NotAcceptableException, + PayloadTooLargeException, +} from '@nestjs/common'; +import { expect } from 'chai'; +import { multipartExceptions } from '../../../multipart/multipart/multipart.constants'; +import { transformException } from '../../../multipart/multipart/multipart.utils'; + +describe('transformException', () => { + describe('if error does not exist', () => { + it('behave as identity', () => { + const err = undefined; + expect(transformException(err)).to.be.eq(err); + }); + }); + describe('if error is instance of HttpException', () => { + it('behave as identity', () => { + const err = new HttpException('response', 500); + expect(transformException(err)).to.be.eq(err); + }); + }); + describe('if error exists and is not instance of HttpException', () => { + describe('should return "NotAcceptableException"', () => { + it('if is FST_INVALID_MULTIPART_CONTENT_TYPE exception', () => { + const err = { + message: multipartExceptions.FST_INVALID_MULTIPART_CONTENT_TYPE, + }; + expect(transformException(err as any)).to.be.instanceof( + NotAcceptableException, + ); + }); + }); + describe('should return "PayloadTooLargeException"', () => { + it('if is FST_PARTS_LIMIT exception', () => { + const err = { message: multipartExceptions.FST_PARTS_LIMIT }; + expect(transformException(err as any)).to.be.instanceof( + PayloadTooLargeException, + ); + }); + it('if is FST_FILES_LIMIT exception', () => { + const err = { message: multipartExceptions.FST_FILES_LIMIT }; + expect(transformException(err as any)).to.be.instanceof( + PayloadTooLargeException, + ); + }); + it('if is FST_FIELDS_LIMIT exception', () => { + const err = { message: multipartExceptions.FST_FIELDS_LIMIT }; + expect(transformException(err as any)).to.be.instanceof( + PayloadTooLargeException, + ); + }); + it('if is FST_REQ_FILE_TOO_LARGE exception', () => { + const err = { message: multipartExceptions.FST_REQ_FILE_TOO_LARGE }; + expect(transformException(err as any)).to.be.instanceof( + PayloadTooLargeException, + ); + }); + }); + describe('should return "BadRequestException"', () => { + it('if is FST_PROTO_VIOLATION exception', () => { + const err = { message: multipartExceptions.FST_PROTO_VIOLATION }; + expect(transformException(err as any)).to.be.instanceof( + BadRequestException, + ); + }); + it('if is LIMIT_UNEXPECTED_FILE exception', () => { + const err = { message: multipartExceptions.LIMIT_UNEXPECTED_FILE }; + expect(transformException(err as any)).to.be.instanceof( + BadRequestException, + ); + }); + }); + }); + describe('if error exists and is not fastify-multipart exception', () => { + it('should return "InternalServerErrorException"', () => { + expect( + transformException(new Error('Internal server exception test')), + ).to.be.instanceof(InternalServerErrorException); + }); + }); +}); diff --git a/packages/platform-fastify/test/multipart/utils/filter-async-generator.spec.ts b/packages/platform-fastify/test/multipart/utils/filter-async-generator.spec.ts new file mode 100644 index 00000000000..477b7e1d626 --- /dev/null +++ b/packages/platform-fastify/test/multipart/utils/filter-async-generator.spec.ts @@ -0,0 +1,24 @@ +import { expect } from 'chai'; +import { filterAsyncGenerator } from '../../../multipart/utils'; + +describe('filterAsyncGenerator', () => { + const numbers = [1, 2, 3, 4, 5]; + async function* asyncGeneratorToFilter() { + for (const number of numbers) { + yield number; + } + } + const filterCondition = (value: number) => value > 3; + + describe('filter', () => { + it('should not add filtered values into async generator', async () => { + const filteredAsyncGenerator = filterAsyncGenerator( + asyncGeneratorToFilter(), + async value => filterCondition(value), + ); + for await (const value of filteredAsyncGenerator) { + expect(filterCondition(value)).to.be.true; + } + }); + }); +});