├── .github └── workflows │ └── relese.yml ├── .gitignore ├── .npmignore ├── .prettierrc ├── LICENSE ├── README.MD ├── package-lock.json ├── package.json ├── src ├── classes │ └── FileData.ts ├── config │ ├── defaultFileSaver.config.ts │ └── defaultInterceptor.config.ts ├── fileSaver │ ├── default.file-saver.ts │ ├── local.file-saver.ts │ └── s3.file-saver.ts ├── index.ts ├── interceptors │ └── formdata.interceptor.ts ├── interfaces │ └── file.interface.ts ├── test │ ├── fileSaver │ │ ├── defaultFileSaver.spec.ts │ │ ├── localFileSaver.spec.ts │ │ └── s3FileSaver.spec.ts │ ├── interceptor │ │ └── fileInterceptor.spec.ts │ └── validators │ │ ├── hasMimetype.spec.ts │ │ ├── isFileData.spec.ts │ │ ├── maxFileSize.spec.ts │ │ └── minFileSize.spec.ts └── validators │ ├── hasMimeType.decorator.ts │ ├── isFileData.decorator.ts │ ├── maxFileSize.decorator.ts │ └── minFileSize.decorator.ts └── tsconfig.json /.github/workflows/relese.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | permissions: 9 | contents: write 10 | pull-requests: write 11 | issues: write 12 | 13 | jobs: 14 | release: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Checkout code 18 | uses: actions/checkout@v4 19 | 20 | - name: Set up Node.js 21 | uses: actions/setup-node@v4 22 | with: 23 | node-version: "lts/*" 24 | registry-url: "https://registry.npmjs.org/" 25 | 26 | - name: Install dependencies 27 | run: npm ci 28 | 29 | - name: Build project 30 | run: npm run build 31 | 32 | - name: Run tests 33 | run: npm run test 34 | 35 | - name: Audit project 36 | run: npm audit 37 | 38 | - name: Release 39 | env: 40 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 41 | NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }} 42 | run: npx semantic-release 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | 3 | node_modules -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | dist/test/ -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": false, 3 | "trailingComma": "all" 4 | } 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 h3llmy https://github.com/h3llmy/nest-formdata 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.MD: -------------------------------------------------------------------------------- 1 | # nestjs-formdata-interceptor 2 | 3 | `nestjs-formdata-interceptor` is a powerful library for [NestJS](https://docs.nestjs.com/) that provides seamless interception and handling of multipart/form-data requests. This functionality is particularly beneficial for efficiently managing file uploads in your application. 4 | 5 | ## Getting Started 6 | 7 | ### Installation 8 | 9 | To install `nestjs-formdata-interceptor` using npm: 10 | 11 | ```sh 12 | npm install nestjs-formdata-interceptor 13 | ``` 14 | 15 | ### OR using yarn 16 | 17 | ```sh 18 | yarn add nestjs-formdata-interceptor 19 | ``` 20 | 21 | ### Usage 22 | 23 | To use `nestjs-formdata-interceptor`, import it into the main directory of your NestJS application and configure it as shown below: 24 | 25 | ```ts 26 | import { NestFactory } from "@nestjs/core"; 27 | import { NestExpressApplication } from "@nestjs/platform-express"; 28 | import { AppModule } from "./app.module"; 29 | import { 30 | FormdataInterceptor, 31 | LocalFileSaver, 32 | } from "nestjs-formdata-interceptor"; 33 | 34 | async function bootstrap() { 35 | const app = await NestFactory.create(AppModule); 36 | 37 | app.useGlobalInterceptors( 38 | new FormdataInterceptor({ 39 | customFileName(context, originalFileName) { 40 | return `${Date.now()}-${originalFileName}`; 41 | }, 42 | fileSaver: new LocalFileSaver({ 43 | prefixDirectory: "./public", 44 | customDirectory(context, originalDirectory) { 45 | return originalDirectory; 46 | }, 47 | }), 48 | }), 49 | ); 50 | 51 | await app.listen(3000); 52 | } 53 | bootstrap(); 54 | ``` 55 | 56 | ### Fastify 57 | 58 | need to install [`@fastify/multipart`](https://www.npmjs.com/package/@fastify/multipart) package. 59 | 60 | ```ts 61 | import { NestFactory } from "@nestjs/core"; 62 | import { AppModule } from "./app.module"; 63 | import { 64 | FastifyAdapter, 65 | NestFastifyApplication, 66 | } from "@nestjs/platform-fastify"; 67 | import fastifyMultipart from "@fastify/multipart"; 68 | import { 69 | LocalFileSaver, 70 | FormdataInterceptor, 71 | } from "nestjs-formdata-interceptor"; 72 | 73 | async function bootstrap() { 74 | const app = await NestFactory.create( 75 | AppModule, 76 | new FastifyAdapter(), 77 | ); 78 | app.register(fastifyMultipart); 79 | 80 | app.useGlobalInterceptors( 81 | new FormdataInterceptor({ 82 | customFileName(context, originalFileName) { 83 | return `${Date.now()}-${originalFileName}`; 84 | }, 85 | fileSaver: new LocalFileSaver({ 86 | prefixDirectory: "./public", 87 | customDirectory(context, originalDirectory) { 88 | return originalDirectory; 89 | }, 90 | }), 91 | }), 92 | ); 93 | 94 | await app.listen(3000); 95 | } 96 | bootstrap(); 97 | ``` 98 | 99 | ### OR 100 | 101 | you can use route spesific interceptor 102 | 103 | ```ts 104 | import { Body, Controller, Post, UseInterceptors } from "@nestjs/common"; 105 | import { AppService } from "./app.service"; 106 | import { CreateDto } from "./dto/create.dto"; 107 | import { FormdataInterceptor } from "nestjs-formdata-interceptor"; 108 | 109 | @Controller() 110 | export class AppController { 111 | constructor(private readonly appService: AppService) {} 112 | 113 | @Post() 114 | @UseInterceptors(new FormdataInterceptor()) 115 | getHello(@Body() createDto: CreateDto) { 116 | // your controller logic 117 | } 118 | } 119 | ``` 120 | 121 | ### Explanation 122 | 123 | #### 1. Custom File Name: 124 | 125 | The `customFileName` function allows you to generate custom file names for each uploaded file. In the example above, the file name is prefixed with the current timestamp followed by the original file name. 126 | 127 | #### 2. File Saver: 128 | 129 | - The `LocalFileSaver` is used to define the directory where the files will be saved. 130 | 131 | - `prefixDirectory` specifies the root directory where all files will be saved. 132 | - `customDirectory` allows you to specify a custom sub-directory within the root directory. By default, it uses the original directory provided. 133 | 134 | ### Custom File Saver 135 | 136 | If you need custom file-saving logic, implement the IFileSaver interface. Here's an example: 137 | 138 | ```ts 139 | import { FileData, IFileSaver } from "nestjs-formdata-interceptor"; 140 | import { ExecutionContext } from "@nestjs/common"; 141 | 142 | export class CustomFileSaver implements IFileSaver { 143 | public save( 144 | fileData: FileData, 145 | context: ExecutionContext, 146 | args: unknown, // this will be get from save method payload 147 | ): any { 148 | // do your file save logic 149 | // and return the file save data 150 | } 151 | } 152 | ``` 153 | 154 | Then, use your custom file saver in the interceptor configuration: 155 | 156 | ```ts 157 | import { NestFactory } from "@nestjs/core"; 158 | import { NestExpressApplication } from "@nestjs/platform-express"; 159 | import { AppModule } from "./app.module"; 160 | import { FormdataInterceptor } from "nestjs-formdata-interceptor"; 161 | import { CustomFileSaver } from "path-to-your-file-saver"; 162 | 163 | async function bootstrap() { 164 | const app = await NestFactory.create(AppModule); 165 | 166 | app.useGlobalInterceptors( 167 | new FormdataInterceptor({ 168 | customFileName(context, originalFileName) { 169 | return `${Date.now()}-${originalFileName}`; 170 | }, 171 | fileSaver: new CustomFileSaver(), 172 | }), 173 | ); 174 | 175 | await app.listen(3000); 176 | } 177 | bootstrap(); 178 | ``` 179 | 180 | ### File Validation 181 | 182 | If you are using [`class-validator`](https://www.npmjs.com/package/class-validator) describe dto and specify validation rules 183 | 184 | ```ts 185 | import { IsArray, IsNotEmpty } from "class-validator"; 186 | import { 187 | FileData, 188 | LocalFileData, 189 | HasMimeType, 190 | IsFileData, 191 | MaxFileSize, 192 | MimeType, 193 | MinFileSize, 194 | } from "nestjs-formdata-interceptor"; 195 | 196 | export class CreateDto { 197 | @IsArray() 198 | @IsNotEmpty() 199 | @IsFileData({ each: true }) 200 | @HasMimeType([MimeType["video/mp4"], "image/png"], { each: true }) 201 | @MinFileSize(2000000, { each: true }) 202 | @MaxFileSize(4000000, { each: true }) 203 | // array file 204 | files: LocalFileData[]; 205 | 206 | @IsFileData() 207 | @IsNotEmpty() 208 | @HasMimeType([MimeType["video/mp4"], "image/png"]) 209 | @MinFileSize(2000000) 210 | @MaxFileSize(4000000) 211 | // single file 212 | file: LocalFileData; 213 | 214 | @IsFileData() 215 | @IsNotEmpty() 216 | @HasMimeType([MimeType["video/mp4"], "image/png"]) 217 | @MinFileSize(2000000) 218 | @MaxFileSize(4000000) 219 | /** 220 | * customize file data save method 221 | * @param args [string] the payload sent to the custom file saver 222 | * @returns [Promise] the file path where the file was saved 223 | * */ 224 | customizeFileData: FileData, string>; 225 | } 226 | ``` 227 | 228 | ### Controller 229 | 230 | Define your controller to handle file uploads: 231 | 232 | ```ts 233 | import { Body, Controller, Post } from "@nestjs/common"; 234 | import { AppService } from "./app.service"; 235 | import { CreateDto } from "./dto/create.dto"; 236 | 237 | @Controller() 238 | export class AppController { 239 | constructor(private readonly appService: AppService) {} 240 | 241 | @Post() 242 | async getHello(@Body() createDto: CreateDto) { 243 | // save single file 244 | createDto.file.save(); // by default the file save data type is string 245 | 246 | // custom the file path on save function 247 | createDto.file.save({ path: "/custom-path" }); // This path will be appended to the prefix directory if it is set. 248 | 249 | // save multiple file 250 | createDto.files.map((file) => file.save()); // by default the file save data type is string 251 | 252 | // customize file data example 253 | await createDto.customizeFileData.save("bucket_name"); // it will be returning Promise 254 | } 255 | } 256 | ``` 257 | 258 | With this setup, `nestjs-formdata-interceptor` will manage multipart/form-data requests efficiently, allowing for structured handling of file uploads in your NestJS application. 259 | 260 | ## Contributors 261 | 262 | 263 | 264 | 265 | 266 | ## License 267 | 268 | [MIT](LICENSE) 269 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nestjs-formdata-interceptor", 3 | "version": "1.3.6", 4 | "description": "nest js formdata interceptor", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "files": [ 8 | "dist" 9 | ], 10 | "author": { 11 | "name": "h3llmy" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "git+https://github.com/h3llmy/nest-formdata-interceptor.git" 16 | }, 17 | "publishConfig": { 18 | "access": "public" 19 | }, 20 | "release": { 21 | "branches": [ 22 | "+([0-9])?(.{+([0-9]),x}).x", 23 | "master", 24 | "next", 25 | "next-major", 26 | { 27 | "name": "development", 28 | "prerelease": true 29 | }, 30 | { 31 | "name": "beta", 32 | "prerelease": true 33 | }, 34 | { 35 | "name": "alpha", 36 | "prerelease": true 37 | } 38 | ] 39 | }, 40 | "license": "MIT", 41 | "scripts": { 42 | "build": "tsc --build", 43 | "test": "jest", 44 | "prepublish": "npm test && npm run build", 45 | "test:watch": "jest --watchAll" 46 | }, 47 | "jest": { 48 | "preset": "ts-jest", 49 | "testEnvironment": "node", 50 | "moduleFileExtensions": [ 51 | "ts", 52 | "js" 53 | ], 54 | "testMatch": [ 55 | "**/*.spec.ts", 56 | "**/*.test.ts" 57 | ] 58 | }, 59 | "devDependencies": { 60 | "@types/busboy": "^1.5.4", 61 | "@types/jest": "^29.5.14", 62 | "@types/node": "^22.14.0", 63 | "jest": "^29.7.0", 64 | "semantic-release": "^24.2.3", 65 | "ts-jest": "^29.3.1", 66 | "typescript": "^5.8.3" 67 | }, 68 | "dependencies": { 69 | "busboy": "^1.6.0" 70 | }, 71 | "peerDependencies": { 72 | "@aws-sdk/client-s3": "^3.x", 73 | "@nestjs/common": "^11.x", 74 | "class-validator": "^0.x", 75 | "rxjs": "^7.x" 76 | }, 77 | "peerDependenciesMeta": { 78 | "@aws-sdk/client-s3": { 79 | "optional": true 80 | }, 81 | "class-validator": { 82 | "optional": true 83 | } 84 | }, 85 | "keywords": [ 86 | "nestjs", 87 | "formdata", 88 | "interceptor", 89 | "form data", 90 | "nestjs multipart", 91 | "nestjs form data", 92 | "nestjs form data interceptor", 93 | "nestjs file upload", 94 | "nestjs validate file", 95 | "nestjs store file", 96 | "file middleware", 97 | "nestjs multipart/form-data" 98 | ] 99 | } 100 | -------------------------------------------------------------------------------- /src/classes/FileData.ts: -------------------------------------------------------------------------------- 1 | import { MimeType } from "../interfaces/file.interface"; 2 | 3 | /** 4 | * Represents the data for a file upload. 5 | */ 6 | export class FileData { 7 | constructor( 8 | public originalFileName: string, 9 | public fileName: string, 10 | public fileNameFull: string, 11 | public encoding: string, 12 | public mimetype: MimeType, 13 | public fileExtension: string, 14 | public fileSize: number, 15 | public hash: string, 16 | public buffer: Buffer, 17 | ) {} 18 | 19 | /** 20 | * Saves the file data to the specified location. 21 | * @param args the payload sent to the custom file saver 22 | * @returns the file path where the file was saved 23 | */ 24 | save(args: SavePayloadType): ReturnType { 25 | return "" as ReturnType; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/config/defaultFileSaver.config.ts: -------------------------------------------------------------------------------- 1 | import { DefaultFileSaverOptions } from "../interfaces/file.interface"; 2 | 3 | export const DEFAULT_FILE_SAVER_OPTION: DefaultFileSaverOptions = { 4 | prefixDirectory: "./public", 5 | }; 6 | -------------------------------------------------------------------------------- /src/config/defaultInterceptor.config.ts: -------------------------------------------------------------------------------- 1 | import { LocalFileSaver } from "../fileSaver/local.file-saver"; 2 | import { IFileOptions } from "../interfaces/file.interface"; 3 | 4 | export const DEFAULT_INTERCEPTOR_CONFIG: IFileOptions = { 5 | fileSaver: new LocalFileSaver(), 6 | requestFileLocation: "body", 7 | }; 8 | -------------------------------------------------------------------------------- /src/fileSaver/default.file-saver.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import fs from "fs"; 3 | import { ExecutionContext } from "@nestjs/common"; 4 | import { 5 | DefaultFileSaverOptions, 6 | IFileSaver, 7 | } from "../interfaces/file.interface"; 8 | import { FileData } from "../classes/FileData"; 9 | import { DEFAULT_FILE_SAVER_OPTION } from "../config/defaultFileSaver.config"; 10 | 11 | /** 12 | * Default implementation of the IFileSaver interface. 13 | * This class is responsible for saving file data to the filesystem. 14 | * 15 | * @deprecated This class is deprecated. Use LocalFileSaver instead. 16 | * @see {@link LocalFileSaver} 17 | */ 18 | export class DefaultFileSaver implements IFileSaver { 19 | /** 20 | * Constructs a new instance of the DefaultFileSaver class. 21 | * @param options - Optional configuration options for the file saver. 22 | */ 23 | constructor(private readonly options?: DefaultFileSaverOptions) { 24 | this.options = { ...DEFAULT_FILE_SAVER_OPTION, ...options }; 25 | } 26 | 27 | /** 28 | * Saves the provided file data to the specified file path. 29 | * Ensures the directory exists and writes the file buffer to the file path. 30 | * @param fileData - The file data to save. 31 | * @param context - The execution context, typically provided by NestJS. 32 | * @returns The file path where the file was saved. 33 | */ 34 | public save(fileData: FileData, context: ExecutionContext): string { 35 | // Determine the directory where the file will be saved. 36 | const directory = this.options.customDirectory 37 | ? this.options.customDirectory(context, this.options.prefixDirectory) 38 | : this.options.prefixDirectory; 39 | 40 | // Construct the full file path, ensuring platform-independent path separators. 41 | const filePath = path 42 | .join(directory, fileData.fileNameFull) 43 | .replace(/\\/g, "/"); 44 | 45 | // Ensure the directory exists, creating it recursively if necessary. 46 | if (!fs.existsSync(directory)) { 47 | fs.mkdirSync(directory, { recursive: true }); 48 | } 49 | 50 | // Write the file buffer to the specified file path. 51 | fs.writeFileSync(filePath, fileData.buffer); 52 | 53 | // Return the file path where the file was saved. 54 | return filePath; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/fileSaver/local.file-saver.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import fs from "fs"; 3 | import { ExecutionContext } from "@nestjs/common"; 4 | import { 5 | IFileSaver, 6 | ILocalFileSaverOptions, 7 | } from "../interfaces/file.interface"; 8 | import { FileData } from "../classes/FileData"; 9 | import { DEFAULT_FILE_SAVER_OPTION } from "../config/defaultFileSaver.config"; 10 | 11 | /** 12 | * Default implementation of the IFileSaver interface. 13 | * This class is responsible for saving file data to the filesystem. 14 | */ 15 | export class LocalFileSaver implements IFileSaver { 16 | /** 17 | * Constructs a new instance of the LocalFileSaver class. 18 | * @param options - Optional configuration options for the file saver. 19 | */ 20 | constructor(private readonly options?: ILocalFileSaverOptions) { 21 | this.options = { ...DEFAULT_FILE_SAVER_OPTION, ...options }; 22 | } 23 | 24 | /** 25 | * Saves the provided file data to the specified file path. 26 | * Ensures the directory exists and writes the file buffer to the file path. 27 | * @param fileData - The file data to save. 28 | * @param context - The execution context, typically provided by NestJS. 29 | * @param options - Optional configuration for the file path and directory. 30 | * @returns The file path where the file was saved. 31 | */ 32 | public save( 33 | fileData: FileData, 34 | context: ExecutionContext, 35 | options?: ILocalFileSaverOptions, 36 | ): string { 37 | // Determine the directory where the file will be saved. 38 | const directory = this.options.customDirectory 39 | ? this.options.customDirectory(context, this.options.prefixDirectory) 40 | : this.options.prefixDirectory; 41 | 42 | const fullDirectory = path 43 | .join(...[directory, options?.path].filter(Boolean)) 44 | .replace(/\\/g, "/"); 45 | 46 | // Construct the full file path, ensuring platform-independent path separators. 47 | const filePath = path 48 | .join(fullDirectory, fileData.fileNameFull) 49 | .replace(/\\/g, "/"); 50 | 51 | // Ensure the directory exists, creating it recursively if necessary. 52 | if (!fs.existsSync(fullDirectory)) { 53 | fs.mkdirSync(fullDirectory, { recursive: true }); 54 | } 55 | 56 | // Write the file buffer to the specified file path. 57 | fs.writeFileSync(filePath, fileData.buffer); 58 | 59 | // Return the file path where the file was saved. 60 | return filePath; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/fileSaver/s3.file-saver.ts: -------------------------------------------------------------------------------- 1 | import { ExecutionContext, InternalServerErrorException } from "@nestjs/common"; 2 | import { FileData } from "../classes/FileData"; 3 | import { 4 | IFileSaver, 5 | IS3FileSaverOptions, 6 | S3FileDataOptions, 7 | } from "../interfaces/file.interface"; 8 | import { 9 | PutObjectCommand, 10 | PutObjectCommandInput, 11 | S3Client, 12 | } from "@aws-sdk/client-s3"; 13 | 14 | export class S3FileSaver implements IFileSaver { 15 | private readonly s3Client: S3Client; 16 | 17 | /** 18 | * Initializes a new instance of the S3FileSaver class. 19 | * Sets up the S3 client using the provided configuration options. 20 | * @param fileUploadOptions - configuration options for S3 file uploads. 21 | */ 22 | constructor(private readonly fileUploadOptions: IS3FileSaverOptions) { 23 | this.s3Client = new S3Client(fileUploadOptions); 24 | } 25 | 26 | /** 27 | * Saves the provided file data to an S3 bucket. 28 | * Constructs the S3 object parameters and sends a PutObjectCommand to S3. 29 | * @param fileData - The file data to be saved, including the buffer and metadata. 30 | * @param context - The execution context, typically provided by NestJS. 31 | * @param options - Optional S3-specific file data options, such as bucket and additional parameters. 32 | * @returns A promise that resolves to the URL of the saved file in S3. 33 | * @throws InternalServerErrorException if the S3 bucket is not specified. 34 | */ 35 | async save( 36 | fileData: FileData, 37 | context: ExecutionContext, 38 | options?: S3FileDataOptions, 39 | ): Promise { 40 | const params: PutObjectCommandInput = { 41 | Bucket: options?.Bucket ?? this.fileUploadOptions?.bucket, 42 | Key: fileData.fileNameFull, 43 | Body: fileData.buffer, 44 | ContentType: fileData.mimetype, 45 | ...options, 46 | }; 47 | 48 | if (!params?.Bucket) { 49 | throw new InternalServerErrorException(`Bucket is required`); 50 | } 51 | 52 | const command = new PutObjectCommand(params); 53 | await this.s3Client.send(command); 54 | 55 | const s3Endpoint = await this.s3Client.config.endpoint(); 56 | 57 | const fileUrl = this.fileUploadOptions.endpoint 58 | ? `${this.fileUploadOptions.endpoint}${s3Endpoint.path}${params.Bucket}/${params.Key}` 59 | : `https://${params.Bucket}.s3${this.fileUploadOptions.region}.amazonaws.com/${params.Key}`; 60 | 61 | return fileUrl; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./interceptors/formdata.interceptor"; 2 | export * from "./interfaces/file.interface"; 3 | 4 | export * from "./validators/isFileData.decorator"; 5 | export * from "./validators/minFileSize.decorator"; 6 | export * from "./validators/maxFileSize.decorator"; 7 | export * from "./validators/hasMimeType.decorator"; 8 | 9 | export * from "./fileSaver/default.file-saver"; 10 | export * from "./fileSaver/s3.file-saver"; 11 | export * from "./fileSaver/local.file-saver"; 12 | 13 | export * from "./classes/FileData"; 14 | -------------------------------------------------------------------------------- /src/interceptors/formdata.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Injectable, 3 | NestInterceptor, 4 | ExecutionContext, 5 | CallHandler, 6 | } from "@nestjs/common"; 7 | import { Observable } from "rxjs"; 8 | import Busboy from "busboy"; 9 | import { 10 | IFileOptions, 11 | IFileSaver, 12 | MimeType, 13 | } from "../interfaces/file.interface"; 14 | import { FileData } from "../classes/FileData"; 15 | import { DEFAULT_INTERCEPTOR_CONFIG } from "../config/defaultInterceptor.config"; 16 | import crypto from "crypto"; 17 | 18 | /** 19 | * Interceptor to handle file uploads using Busboy. 20 | */ 21 | @Injectable() 22 | export class FormdataInterceptor implements NestInterceptor { 23 | private readonly arrayRegexPattern: RegExp = /\[\]$/; 24 | private readonly nestedRegexPattern: RegExp = /[[\]]/; 25 | private httpRequest: any; 26 | private busboy: Busboy.Busboy; 27 | 28 | /** 29 | * Constructs a new instance of the FormdataInterceptor class. 30 | * @param fileOptions - Optional configuration options for file handling. 31 | */ 32 | constructor(private readonly fileOptions?: IFileOptions) { 33 | this.fileOptions = { ...DEFAULT_INTERCEPTOR_CONFIG, ...fileOptions }; 34 | } 35 | 36 | /** 37 | * Intercepts the request to handle file uploads if the content type is multipart/form-data. 38 | * @param context - The execution context. 39 | * @param next - The next call handler. 40 | * @returns An Observable that processes the file upload. 41 | */ 42 | public async intercept( 43 | context: ExecutionContext, 44 | next: CallHandler 45 | ): Promise> { 46 | const { customFileName, fileSaver } = this.fileOptions; 47 | const ctx = context.switchToHttp(); 48 | this.httpRequest = ctx.getRequest(); 49 | 50 | const request = this.httpRequest.raw ?? this.httpRequest; 51 | 52 | const contentType = request.headers["content-type"]; 53 | 54 | if (contentType?.includes("multipart/form-data")) { 55 | return this.handleMultipartFormData( 56 | context, 57 | next, 58 | customFileName, 59 | fileSaver 60 | ); 61 | } 62 | 63 | return next.handle(); 64 | } 65 | 66 | /** 67 | * Handles multipart/form-data file uploads. 68 | * @param context - The execution context. 69 | * @param next - The next call handler. 70 | * @param request - The HTTP request. 71 | * @param customFileName - Optional function to customize file names. 72 | * @param fileSaver - Optional file saver implementation. 73 | * @returns An Observable that processes the file upload. 74 | */ 75 | private async handleMultipartFormData( 76 | context: ExecutionContext, 77 | next: CallHandler, 78 | customFileName?: ( 79 | context: ExecutionContext, 80 | originalFileName: string 81 | ) => Promise | string, 82 | fileSaver?: IFileSaver 83 | ): Promise> { 84 | return new Observable((observer) => { 85 | this.busboy = Busboy({ headers: this.httpRequest.headers }); 86 | const files: Record = {}; 87 | const fields: Record = {}; 88 | 89 | this.busboy.on("file", async (fieldName, fileStream, fileInfo) => { 90 | const bufferChunks: Uint8Array[] = []; 91 | let fileSize: number = 0; 92 | 93 | fileStream.on("data", (data) => { 94 | bufferChunks.push(data); 95 | fileSize += data.length; 96 | }); 97 | 98 | fileStream.on("end", async () => { 99 | if(fileInfo.filename === undefined && fileInfo.mimeType === "application/octet-stream"){ 100 | this.handleField(files, fieldName, undefined); 101 | return 102 | } 103 | const hash = crypto.createHash("md5"); 104 | const fileBuffer: Buffer = Buffer.concat(bufferChunks); 105 | const fileExtension = fileInfo.filename.split(".").pop(); 106 | const fileNameOnly = fileInfo.filename 107 | .split(".") 108 | .slice(0, -1) 109 | .join("."); 110 | const finalFileName = customFileName 111 | ? await customFileName(context, fileNameOnly) 112 | : fileNameOnly; 113 | const fullFileName = `${finalFileName}.${fileExtension}`; 114 | 115 | this.assignFile(fileSaver, context); 116 | 117 | const fileData: FileData = new FileData( 118 | fileInfo.filename, 119 | finalFileName, 120 | fullFileName, 121 | fileInfo.encoding, 122 | fileInfo.mimeType as MimeType, 123 | fileExtension, 124 | fileSize, 125 | hash.update(fileBuffer).digest("hex"), 126 | fileBuffer 127 | ); 128 | 129 | this.handleField(files, fieldName, fileData); 130 | }); 131 | 132 | fileStream.on("error", (error) => { 133 | observer.error(error); 134 | this.handleDone(); 135 | }); 136 | }); 137 | 138 | this.busboy.on("field", (fieldName, val) => { 139 | this.handleField(fields, fieldName, val); 140 | }); 141 | 142 | this.busboy.on("finish", () => { 143 | this.httpRequest["body"] = { ...fields }; 144 | this.httpRequest[this.fileOptions.requestFileLocation] = { 145 | ...this.httpRequest[this.fileOptions.requestFileLocation], 146 | ...files, 147 | }; 148 | next.handle().subscribe({ 149 | next: (val) => observer.next(val), 150 | error: (error) => observer.error(error), 151 | complete: () => { 152 | observer.complete(); 153 | this.handleDone(); 154 | }, 155 | }); 156 | }); 157 | 158 | this.httpRequest?.raw 159 | ? this.httpRequest.raw.pipe(this.busboy) 160 | : this.httpRequest.pipe(this.busboy); 161 | }); 162 | } 163 | 164 | /** 165 | * Cleans up the resources used by the `busboy` instance and removes all event listeners. 166 | */ 167 | private handleDone(): void { 168 | this.busboy.removeAllListeners(); 169 | this.httpRequest.raw 170 | ? this.httpRequest.raw.unpipe(this.busboy) 171 | : this.httpRequest.unpipe(this.busboy); 172 | } 173 | 174 | /** 175 | * assign save file strategy 176 | * 177 | * @param fileSaver - file saver strategy 178 | * @param context - The execution context. 179 | */ 180 | private assignFile(fileSaver: IFileSaver, context: ExecutionContext): void { 181 | FileData.prototype.save = function (this: FileData, args: unknown) { 182 | return fileSaver.save(this, context, args); 183 | }; 184 | } 185 | 186 | /** 187 | * Handles a field by setting its value in the target object, supporting nested fields and arrays. 188 | * @param target - The target object to set the field value. 189 | * @param fieldName - The name of the field. 190 | * @param value - The value to set. 191 | */ 192 | private handleField(target: any, fieldName: string, value: any) { 193 | const keys = fieldName.split(this.nestedRegexPattern).filter(Boolean); 194 | const isArrayField = this.arrayRegexPattern.test(fieldName); 195 | this.setNestedValue(target, keys, value, isArrayField); 196 | } 197 | 198 | /** 199 | * Sets a nested value in an object, supporting nested fields and arrays. 200 | * @param obj - The object to set the nested value in. 201 | * @param keys - The keys representing the nested path. 202 | * @param value - The value to set. 203 | * @param isArray - Whether the field is an array. 204 | */ 205 | private setNestedValue( 206 | obj: any, 207 | keys: string[], 208 | value: any, 209 | isArray = false 210 | ) { 211 | let current = obj; 212 | 213 | keys.forEach((key, index) => { 214 | const isLastKey = index === keys.length - 1; 215 | 216 | if (isLastKey) { 217 | this.assignValue(current, key, value, isArray); 218 | } else { 219 | if (!current[key]) { 220 | current[key] = {}; 221 | } 222 | current = current[key]; 223 | } 224 | }); 225 | } 226 | 227 | /** 228 | * Assigns a value to the specified key in an object, supporting arrays. 229 | * @param obj - The object to set the value in. 230 | * @param key - The key to set the value for. 231 | * @param value - The value to set. 232 | * @param isArray - Whether the field is an array. 233 | */ 234 | private assignValue(obj: any, key: string, value: any, isArray: boolean) { 235 | isArray 236 | ? this.assignArrayValue(obj, key, value) 237 | : this.assignSingleValue(obj, key, value); 238 | } 239 | 240 | /** 241 | * Assigns a single value or array value to the specified key in an object. 242 | * @param obj - The object to set the value in. 243 | * @param key - The key to set the value for. 244 | * @param value - The value to set. 245 | */ 246 | private assignSingleValue(obj: any, key: string, value: any) { 247 | if (obj[key]) { 248 | if (Array.isArray(obj[key])) { 249 | obj[key].push(value); 250 | } else { 251 | obj[key] = [obj[key], value]; 252 | } 253 | } else { 254 | obj[key] = value; 255 | } 256 | } 257 | 258 | /** 259 | * Assigns a value to an array at the specified key in an object. 260 | * @param obj - The object to set the value in. 261 | * @param key - The key to set the value for. 262 | * @param value - The value to set. 263 | */ 264 | private assignArrayValue(obj: any, key: string, value: any) { 265 | if (!Array.isArray(obj[key])) { 266 | obj[key] = []; 267 | } 268 | obj[key].push(value); 269 | } 270 | } 271 | -------------------------------------------------------------------------------- /src/interfaces/file.interface.ts: -------------------------------------------------------------------------------- 1 | import { ExecutionContext } from "@nestjs/common"; 2 | import { FileData } from "../classes/FileData"; 3 | import { PutObjectCommandInput, S3ClientConfig } from "@aws-sdk/client-s3"; 4 | /** 5 | * Enum for the mime types. 6 | */ 7 | export enum MimeType { 8 | "application/*" = "application/*", 9 | "image/*" = "image/*", 10 | "audio/*" = "audio/*", 11 | "text/*" = "text/*", 12 | "video/*" = "video/*", 13 | "application/andrew-inset" = "application/andrew-inset", 14 | "application/applixware" = "application/applixware", 15 | "application/atom+xml" = "application/atom+xml", 16 | "application/atomcat+xml" = "application/atomcat+xml", 17 | "application/atomsvc+xml" = "application/atomsvc+xml", 18 | "application/ccxml+xml" = "application/ccxml+xml", 19 | "application/cdmi-capability" = "application/cdmi-capability", 20 | "application/cdmi-container" = "application/cdmi-container", 21 | "application/cdmi-domain" = "application/cdmi-domain", 22 | "application/cdmi-object" = "application/cdmi-object", 23 | "application/cdmi-queue" = "application/cdmi-queue", 24 | "application/cu-seeme" = "application/cu-seeme", 25 | "application/dash+xml" = "application/dash+xml", 26 | "application/davmount+xml" = "application/davmount+xml", 27 | "application/docbook+xml" = "application/docbook+xml", 28 | "application/dssc+der" = "application/dssc+der", 29 | "application/dssc+xml" = "application/dssc+xml", 30 | "application/ecmascript" = "application/ecmascript", 31 | "application/emma+xml" = "application/emma+xml", 32 | "application/epub+zip" = "application/epub+zip", 33 | "application/exi" = "application/exi", 34 | "application/font-tdpfr" = "application/font-tdpfr", 35 | "application/gml+xml" = "application/gml+xml", 36 | "application/gpx+xml" = "application/gpx+xml", 37 | "application/gxf" = "application/gxf", 38 | "application/hyperstudio" = "application/hyperstudio", 39 | "application/inkml+xml" = "application/inkml+xml", 40 | "application/ipfix" = "application/ipfix", 41 | "application/java-archive" = "application/java-archive", 42 | "application/java-serialized-object" = "application/java-serialized-object", 43 | "application/java-vm" = "application/java-vm", 44 | "application/javascript" = "application/javascript", 45 | "application/json" = "application/json", 46 | "application/jsonml+json" = "application/jsonml+json", 47 | "application/lost+xml" = "application/lost+xml", 48 | "application/mac-binhex40" = "application/mac-binhex40", 49 | "application/mac-compactpro" = "application/mac-compactpro", 50 | "application/mads+xml" = "application/mads+xml", 51 | "application/marc" = "application/marc", 52 | "application/marcxml+xml" = "application/marcxml+xml", 53 | "application/mathematica" = "application/mathematica", 54 | "application/mathml+xml" = "application/mathml+xml", 55 | "application/mbox" = "application/mbox", 56 | "application/mediaservercontrol+xml" = "application/mediaservercontrol+xml", 57 | "application/metalink+xml" = "application/metalink+xml", 58 | "application/metalink4+xml" = "application/metalink4+xml", 59 | "application/mets+xml" = "application/mets+xml", 60 | "application/mods+xml" = "application/mods+xml", 61 | "application/mp21" = "application/mp21", 62 | "application/mp4" = "application/mp4", 63 | "application/msword" = "application/msword", 64 | "application/mxf" = "application/mxf", 65 | "application/octet-stream" = "application/octet-stream", 66 | "application/oda" = "application/oda", 67 | "application/oebps-package+xml" = "application/oebps-package+xml", 68 | "application/ogg" = "application/ogg", 69 | "application/omdoc+xml" = "application/omdoc+xml", 70 | "application/onenote" = "application/onenote", 71 | "application/oxps" = "application/oxps", 72 | "application/patch-ops-error+xml" = "application/patch-ops-error+xml", 73 | "application/pdf" = "application/pdf", 74 | "application/pgp-encrypted" = "application/pgp-encrypted", 75 | "application/pgp-signature" = "application/pgp-signature", 76 | "application/pics-rules" = "application/pics-rules", 77 | "application/pkcs10" = "application/pkcs10", 78 | "application/pkcs7-mime" = "application/pkcs7-mime", 79 | "application/pkcs7-signature" = "application/pkcs7-signature", 80 | "application/pkcs8" = "application/pkcs8", 81 | "application/pkix-attr-cert" = "application/pkix-attr-cert", 82 | "application/pkix-cert" = "application/pkix-cert", 83 | "application/pkix-crl" = "application/pkix-crl", 84 | "application/pkix-pkipath" = "application/pkix-pkipath", 85 | "application/pls+xml" = "application/pls+xml", 86 | "application/postscript" = "application/postscript", 87 | "application/prs.cww" = "application/prs.cww", 88 | "application/pskc+xml" = "application/pskc+xml", 89 | "application/rdf+xml" = "application/rdf+xml", 90 | "application/reginfo+xml" = "application/reginfo+xml", 91 | "application/relax-ng-compact-syntax" = "application/relax-ng-compact-syntax", 92 | "application/resource-lists+xml" = "application/resource-lists+xml", 93 | "application/resource-lists-diff+xml" = "application/resource-lists-diff+xml", 94 | "application/rls-services+xml" = "application/rls-services+xml", 95 | "application/rss+xml" = "application/rss+xml", 96 | "application/rtf" = "application/rtf", 97 | "application/sbml+xml" = "application/sbml+xml", 98 | "application/scvp-cv-request" = "application/scvp-cv-request", 99 | "application/scvp-cv-response" = "application/scvp-cv-response", 100 | "application/scvp-vp-request" = "application/scvp-vp-request", 101 | "application/scvp-vp-response" = "application/scvp-vp-response", 102 | "application/sdp" = "application/sdp", 103 | "application/set-payment-initiation" = "application/set-payment-initiation", 104 | "application/set-registration-initiation" = "application/set-registration-initiation", 105 | "application/shf+xml" = "application/shf+xml", 106 | "application/smil+xml" = "application/smil+xml", 107 | "application/sparql-query" = "application/sparql-query", 108 | "application/sparql-results+xml" = "application/sparql-results+xml", 109 | "application/srgs" = "application/srgs", 110 | "application/srgs+xml" = "application/srgs+xml", 111 | "application/sru+xml" = "application/sru+xml", 112 | "application/ssml+xml" = "application/ssml+xml", 113 | "application/tei+xml" = "application/tei+xml", 114 | "application/thraud+xml" = "application/thraud+xml", 115 | "application/timestamped-data" = "application/timestamped-data", 116 | "application/vnd.3gpp.pic-bw-large" = "application/vnd.3gpp.pic-bw-large", 117 | "application/vnd.3gpp.pic-bw-small" = "application/vnd.3gpp.pic-bw-small", 118 | "application/vnd.3gpp.pic-bw-var" = "application/vnd.3gpp.pic-bw-var", 119 | "application/vnd.3gpp2.tcap" = "application/vnd.3gpp2.tcap", 120 | "application/vnd.3m.post-it-notes" = "application/vnd.3m.post-it-notes", 121 | "application/vnd.accpac.simply.aso" = "application/vnd.accpac.simply.aso", 122 | "application/vnd.accpac.simply.imp" = "application/vnd.accpac.simply.imp", 123 | "application/vnd.acucobol" = "application/vnd.acucobol", 124 | "application/vnd.acucorp" = "application/vnd.acucorp", 125 | "application/vnd.adobe.air-application-installer-package+zip" = "application/vnd.adobe.air-application-installer-package+zip", 126 | "application/vnd.adobe.formscentral.fcdt" = "application/vnd.adobe.formscentral.fcdt", 127 | "application/vnd.adobe.fxp" = "application/vnd.adobe.fxp", 128 | "application/vnd.adobe.xdp+xml" = "application/vnd.adobe.xdp+xml", 129 | "application/vnd.adobe.xfdf" = "application/vnd.adobe.xfdf", 130 | "application/vnd.ahead.space" = "application/vnd.ahead.space", 131 | "application/vnd.airzip.filesecure.azf" = "application/vnd.airzip.filesecure.azf", 132 | "application/vnd.airzip.filesecure.azs" = "application/vnd.airzip.filesecure.azs", 133 | "application/vnd.amazon.ebook" = "application/vnd.amazon.ebook", 134 | "application/vnd.americandynamics.acc" = "application/vnd.americandynamics.acc", 135 | "application/vnd.amiga.ami" = "application/vnd.amiga.ami", 136 | "application/vnd.android.package-archive" = "application/vnd.android.package-archive", 137 | "application/vnd.anser-web-certificate-issue-initiation" = "application/vnd.anser-web-certificate-issue-initiation", 138 | "application/vnd.anser-web-funds-transfer-initiation" = "application/vnd.anser-web-funds-transfer-initiation", 139 | "application/vnd.antix.game-component" = "application/vnd.antix.game-component", 140 | "application/vnd.apple.installer+xml" = "application/vnd.apple.installer+xml", 141 | "application/vnd.apple.mpegurl" = "application/vnd.apple.mpegurl", 142 | "application/vnd.aristanetworks.swi" = "application/vnd.aristanetworks.swi", 143 | "application/vnd.astraea-software.iota" = "application/vnd.astraea-software.iota", 144 | "application/vnd.audiograph" = "application/vnd.audiograph", 145 | "application/vnd.blueice.multipass" = "application/vnd.blueice.multipass", 146 | "application/vnd.bmi" = "application/vnd.bmi", 147 | "application/vnd.businessobjects" = "application/vnd.businessobjects", 148 | "application/vnd.chemdraw+xml" = "application/vnd.chemdraw+xml", 149 | "application/vnd.chipnuts.karaoke-mmd" = "application/vnd.chipnuts.karaoke-mmd", 150 | "application/vnd.cinderella" = "application/vnd.cinderella", 151 | "application/vnd.claymore" = "application/vnd.claymore", 152 | "application/vnd.cloanto.rp9" = "application/vnd.cloanto.rp9", 153 | "application/vnd.clonk.c4group" = "application/vnd.clonk.c4group", 154 | "application/vnd.cluetrust.cartomobile-config" = "application/vnd.cluetrust.cartomobile-config", 155 | "application/vnd.cluetrust.cartomobile-config-pkg" = "application/vnd.cluetrust.cartomobile-config-pkg", 156 | "application/vnd.commonspace" = "application/vnd.commonspace", 157 | "image/jpeg" = "image/jpeg", 158 | "image/png" = "image/png", 159 | "image/gif" = "image/gif", 160 | "image/bmp" = "image/bmp", 161 | "image/webp" = "image/webp", 162 | "image/svg+xml" = "image/svg+xml", 163 | "video/mp4" = "video/mp4", 164 | "video/mpeg" = "video/mpeg", 165 | "video/webm" = "video/webm", 166 | "video/quicktime" = "video/quicktime", 167 | "video/x-msvideo" = "video/x-msvideo", 168 | "video/x-ms-wmv" = "video/x-ms-wmv", 169 | "audio/mpeg" = "audio/mpeg", 170 | "audio/ogg" = "audio/ogg", 171 | "audio/wav" = "audio/wav", 172 | "audio/webm" = "audio/webm", 173 | "audio/midi" = "audio/midi", 174 | "text/plain" = "text/plain", 175 | "text/html" = "text/html", 176 | } 177 | 178 | /** 179 | * Interface for saving file data. 180 | */ 181 | export interface IFileSaver { 182 | /** 183 | * Saves the provided file data to the specified file path. 184 | * @param fileData - The file data to save. 185 | * @param context - The execution context, typically provided by NestJS. 186 | * @param args - Optional payload sent to save method. 187 | * @returns The file path where the file was saved. 188 | */ 189 | save(fileData: FileData, context: ExecutionContext, args?: unknown): any; 190 | } 191 | 192 | /** 193 | * Options for customizing file upload behavior. 194 | */ 195 | export interface IFileOptions { 196 | /** 197 | * Function to customize the file name. 198 | * @param context - The execution context, typically provided by NestJS. 199 | * @param originalFileName - The original file name. 200 | * @returns The customized file name. 201 | */ 202 | customFileName?: ( 203 | context: ExecutionContext, 204 | originalFileName: string, 205 | ) => Promise | string; 206 | /** 207 | * Custom file saver implementation. 208 | * @default DefaultFileSaver 209 | */ 210 | fileSaver?: IFileSaver; 211 | 212 | /** 213 | * Location of the file in the request context. 214 | * @default 'body' 215 | */ 216 | requestFileLocation?: string; 217 | } 218 | 219 | /** 220 | * Options for customizing behavior of the DefaultFileSaver. 221 | * @deprecated Use ILocalFileSaverOptions instead. 222 | */ 223 | export interface DefaultFileSaverOptions { 224 | /** 225 | * Prefix directory where files will be saved. 226 | */ 227 | prefixDirectory?: string; 228 | /** 229 | * Function to customize the directory where files will be saved. 230 | * @param context - The execution context, typically provided by NestJS. 231 | * @param originalDirectory - The original directory. 232 | * @returns The customized directory. 233 | */ 234 | customDirectory?: ( 235 | context: ExecutionContext, 236 | originalDirectory: string, 237 | ) => string; 238 | } 239 | 240 | /** 241 | * Options for configuring the S3FileSaver. 242 | * Extends the S3ClientConfig interface from AWS SDK. 243 | */ 244 | export interface IS3FileSaverOptions extends S3ClientConfig { 245 | /** 246 | * The name of the S3 bucket to use for file storage. 247 | * If not provided, it should be specified in the save method options. 248 | */ 249 | bucket?: string; 250 | } 251 | 252 | /** 253 | * Options for S3 file data operations. 254 | * This type excludes 'Key', 'Body', and 'ContentType' from PutObjectCommandInput, 255 | * as these are typically handled internally by the file saver. 256 | * 257 | * @typedef {Omit} S3FileDataOptions 258 | * @see {@link PutObjectCommandInput} from '@aws-sdk/client-s3' 259 | */ 260 | export type S3FileDataOptions = Omit< 261 | PutObjectCommandInput, 262 | "Key" | "Body" | "ContentType" 263 | >; 264 | 265 | /** 266 | * Represents the file data structure specific to S3 storage operations. 267 | * 268 | * @typedef {FileData, S3FileDataOptions | void>} S3FileData 269 | * @extends {FileData} 270 | * 271 | * @property {Promise} The promise that resolves to the URL of the saved file in S3. 272 | * @property {S3FileDataOptions | void} Optional S3-specific file data options. 273 | */ 274 | export type S3FileData = FileData, S3FileDataOptions | void>; 275 | 276 | /** 277 | * Options for configuring the LocalFileSaver. 278 | */ 279 | export interface ILocalFileSaverOptions { 280 | /** 281 | * The directory where files will be saved. 282 | * If not provided, it should be specified in the save method options. 283 | */ 284 | path?: string; 285 | } 286 | 287 | /** 288 | * Represents the file data structure specific to local storage operations. 289 | * 290 | * @typedef {FileData} LocalFileData 291 | * @extends {FileData} 292 | * 293 | * @property {string} The file path where the file was saved. 294 | * @property {ILocalFileSaverOptions | void} Optional local-specific file data options. 295 | */ 296 | export type LocalFileData = FileData; 297 | 298 | /** 299 | * Options for customizing behavior of the LocalFileSaver. 300 | */ 301 | export interface ILocalFileSaverOptions { 302 | /** 303 | * Prefix directory where files will be saved. 304 | */ 305 | prefixDirectory?: string; 306 | /** 307 | * Function to customize the directory where files will be saved. 308 | * @param context - The execution context, typically provided by NestJS. 309 | * @param originalDirectory - The original directory. 310 | * @returns The customized directory. 311 | */ 312 | customDirectory?: ( 313 | context: ExecutionContext, 314 | originalDirectory: string, 315 | ) => string; 316 | } 317 | -------------------------------------------------------------------------------- /src/test/fileSaver/defaultFileSaver.spec.ts: -------------------------------------------------------------------------------- 1 | import { DefaultFileSaver } from "../../fileSaver/default.file-saver"; 2 | import { 3 | DefaultFileSaverOptions, 4 | MimeType, 5 | } from "../../interfaces/file.interface"; 6 | import fs from "fs"; 7 | import path from "path"; 8 | import { ExecutionContext } from "@nestjs/common"; 9 | import { FileData } from "../../classes/FileData"; 10 | 11 | // Mock the fs module 12 | jest.mock("fs"); 13 | 14 | describe("DefaultFileSaver", () => { 15 | let fileSaver: DefaultFileSaver; 16 | const mockPrefixDirectory = "./public"; 17 | const mockExecutionContext = {} as ExecutionContext; 18 | 19 | const mockFileData = new FileData( 20 | "file.txt", 21 | "file", 22 | "file.txt", 23 | "7bit", 24 | MimeType["image/png"], 25 | "txt", 26 | 100, 27 | "hash", 28 | Buffer.from("Hello, world!") 29 | ); 30 | 31 | beforeEach(() => { 32 | const options: DefaultFileSaverOptions = { 33 | prefixDirectory: mockPrefixDirectory, 34 | }; 35 | fileSaver = new DefaultFileSaver(options); 36 | }); 37 | 38 | afterEach(() => { 39 | jest.clearAllMocks(); 40 | }); 41 | 42 | it("should create directory if it does not exist and save file", () => { 43 | const mockFilePath = path 44 | .join(mockPrefixDirectory, mockFileData.fileNameFull) 45 | .replace(/\\/g, "/"); 46 | 47 | // Mock implementations for fs functions 48 | (fs.existsSync as jest.Mock).mockReturnValue(false); 49 | (fs.mkdirSync as jest.Mock).mockReturnValue(undefined); 50 | (fs.writeFileSync as jest.Mock).mockReturnValue(undefined); 51 | 52 | const result = fileSaver.save(mockFileData, mockExecutionContext); 53 | 54 | expect(fs.existsSync).toHaveBeenCalledWith(mockPrefixDirectory); 55 | expect(fs.mkdirSync).toHaveBeenCalledWith(mockPrefixDirectory, { 56 | recursive: true, 57 | }); 58 | expect(fs.writeFileSync).toHaveBeenCalledWith( 59 | mockFilePath, 60 | mockFileData.buffer 61 | ); 62 | expect(result).toBe(mockFilePath); 63 | }); 64 | 65 | it("should save file when directory already exists", () => { 66 | const mockFilePath = path 67 | .join(mockPrefixDirectory, mockFileData.fileNameFull) 68 | .replace(/\\/g, "/"); 69 | 70 | // Mock implementations for fs functions 71 | (fs.existsSync as jest.Mock).mockReturnValue(true); 72 | (fs.writeFileSync as jest.Mock).mockReturnValue(undefined); 73 | 74 | const result = fileSaver.save(mockFileData, mockExecutionContext); 75 | 76 | expect(fs.existsSync).toHaveBeenCalledWith(mockPrefixDirectory); 77 | expect(fs.mkdirSync).not.toHaveBeenCalled(); 78 | expect(fs.writeFileSync).toHaveBeenCalledWith( 79 | mockFilePath, 80 | mockFileData.buffer 81 | ); 82 | expect(result).toBe(mockFilePath); 83 | }); 84 | 85 | it("should use custom directory function if provided", () => { 86 | const customDirectory = jest.fn().mockReturnValue("custom/dir"); 87 | const options: DefaultFileSaverOptions = { 88 | prefixDirectory: mockPrefixDirectory, 89 | customDirectory, 90 | }; 91 | fileSaver = new DefaultFileSaver(options); 92 | const mockFilePath = path 93 | .join("custom/dir", mockFileData.fileNameFull) 94 | .replace(/\\/g, "/"); 95 | 96 | // Mock implementations for fs functions 97 | (fs.existsSync as jest.Mock).mockReturnValue(false); 98 | (fs.mkdirSync as jest.Mock).mockReturnValue(undefined); 99 | (fs.writeFileSync as jest.Mock).mockReturnValue(undefined); 100 | 101 | const result = fileSaver.save(mockFileData, mockExecutionContext); 102 | 103 | expect(customDirectory).toHaveBeenCalledWith( 104 | mockExecutionContext, 105 | mockPrefixDirectory 106 | ); 107 | expect(fs.existsSync).toHaveBeenCalledWith("custom/dir"); 108 | expect(fs.mkdirSync).toHaveBeenCalledWith("custom/dir", { 109 | recursive: true, 110 | }); 111 | expect(fs.writeFileSync).toHaveBeenCalledWith( 112 | mockFilePath, 113 | mockFileData.buffer 114 | ); 115 | expect(result).toBe(mockFilePath); 116 | }); 117 | }); 118 | -------------------------------------------------------------------------------- /src/test/fileSaver/localFileSaver.spec.ts: -------------------------------------------------------------------------------- 1 | import { LocalFileSaver } from "../../fileSaver/local.file-saver"; 2 | import { 3 | ILocalFileSaverOptions, 4 | MimeType, 5 | } from "../../interfaces/file.interface"; 6 | import fs from "fs"; 7 | import path from "path"; 8 | import { ExecutionContext } from "@nestjs/common"; 9 | import { FileData } from "../../classes/FileData"; 10 | 11 | // Mock the fs module 12 | jest.mock("fs"); 13 | 14 | describe("LocalFileSaver", () => { 15 | let fileSaver: LocalFileSaver; 16 | const mockPrefixDirectory = "./public"; 17 | const mockExecutionContext = {} as ExecutionContext; 18 | 19 | const mockFileData = new FileData( 20 | "file.txt", 21 | "file", 22 | "file.txt", 23 | "7bit", 24 | MimeType["image/png"], 25 | "txt", 26 | 100, 27 | "hash", 28 | Buffer.from("Hello, world!"), 29 | ); 30 | 31 | beforeEach(() => { 32 | const options: ILocalFileSaverOptions = { 33 | prefixDirectory: mockPrefixDirectory, 34 | }; 35 | fileSaver = new LocalFileSaver(options); 36 | }); 37 | 38 | afterEach(() => { 39 | jest.clearAllMocks(); 40 | }); 41 | 42 | it("should create directory if it does not exist and save file", () => { 43 | const mockFilePath = path 44 | .join(mockPrefixDirectory, mockFileData.fileNameFull) 45 | .replace(/\\/g, "/"); 46 | // Mock implementations for fs functions 47 | (fs.existsSync as jest.Mock).mockReturnValue(false); 48 | (fs.mkdirSync as jest.Mock).mockReturnValue(undefined); 49 | (fs.writeFileSync as jest.Mock).mockReturnValue(undefined); 50 | 51 | const result = fileSaver.save(mockFileData, mockExecutionContext); 52 | 53 | expect(fs.existsSync).toHaveBeenCalledWith( 54 | mockPrefixDirectory.replace(/^\.\//, ""), 55 | ); 56 | expect(fs.mkdirSync).toHaveBeenCalledWith( 57 | mockPrefixDirectory.replace(/^\.\//, ""), 58 | { 59 | recursive: true, 60 | }, 61 | ); 62 | expect(fs.writeFileSync).toHaveBeenCalledWith( 63 | mockFilePath.replace(/^\.\//, ""), 64 | mockFileData.buffer, 65 | ); 66 | expect(result).toBe(mockFilePath); 67 | }); 68 | 69 | it("should save file in subdirectory when options.path is provided", () => { 70 | const subPath = "sub/folder"; 71 | const fullDirectory = path.join(mockPrefixDirectory, subPath); 72 | 73 | const mockFilePath = path 74 | .join(fullDirectory, mockFileData.fileNameFull) 75 | .replace(/\\/g, "/"); 76 | 77 | (fs.existsSync as jest.Mock).mockReturnValue(false); 78 | (fs.mkdirSync as jest.Mock).mockReturnValue(undefined); 79 | (fs.writeFileSync as jest.Mock).mockReturnValue(undefined); 80 | 81 | const result = fileSaver.save(mockFileData, mockExecutionContext, { 82 | path: subPath, 83 | }); 84 | 85 | expect(fs.existsSync).toHaveBeenCalledWith( 86 | fullDirectory.replace(/\\/g, "/"), 87 | ); 88 | expect(fs.mkdirSync).toHaveBeenCalledWith( 89 | fullDirectory.replace(/\\/g, "/"), 90 | { 91 | recursive: true, 92 | }, 93 | ); 94 | expect(fs.writeFileSync).toHaveBeenCalledWith( 95 | mockFilePath, 96 | mockFileData.buffer, 97 | ); 98 | expect(result).toBe(mockFilePath); 99 | }); 100 | 101 | it("should save file when directory already exists", () => { 102 | const mockFilePath = path 103 | .join(mockPrefixDirectory, mockFileData.fileNameFull) 104 | .replace(/\\/g, "/"); 105 | 106 | // Mock implementations for fs functions 107 | (fs.existsSync as jest.Mock).mockReturnValue(true); 108 | (fs.writeFileSync as jest.Mock).mockReturnValue(undefined); 109 | 110 | const result = fileSaver.save(mockFileData, mockExecutionContext); 111 | 112 | expect(fs.existsSync).toHaveBeenCalledWith( 113 | mockPrefixDirectory.replace(/^\.\//, ""), 114 | ); 115 | expect(fs.mkdirSync).not.toHaveBeenCalled(); 116 | expect(fs.writeFileSync).toHaveBeenCalledWith( 117 | mockFilePath, 118 | mockFileData.buffer, 119 | ); 120 | expect(result).toBe(mockFilePath); 121 | }); 122 | 123 | it("should use custom directory function if provided", () => { 124 | const customDirectory = jest.fn().mockReturnValue("custom/dir"); 125 | const options: ILocalFileSaverOptions = { 126 | prefixDirectory: mockPrefixDirectory, 127 | customDirectory, 128 | }; 129 | fileSaver = new LocalFileSaver(options); 130 | const mockFilePath = path 131 | .join("custom/dir", mockFileData.fileNameFull) 132 | .replace(/\\/g, "/"); 133 | 134 | // Mock implementations for fs functions 135 | (fs.existsSync as jest.Mock).mockReturnValue(false); 136 | (fs.mkdirSync as jest.Mock).mockReturnValue(undefined); 137 | (fs.writeFileSync as jest.Mock).mockReturnValue(undefined); 138 | 139 | const result = fileSaver.save(mockFileData, mockExecutionContext); 140 | 141 | expect(customDirectory).toHaveBeenCalledWith( 142 | mockExecutionContext, 143 | mockPrefixDirectory, 144 | ); 145 | expect(fs.existsSync).toHaveBeenCalledWith("custom/dir"); 146 | expect(fs.mkdirSync).toHaveBeenCalledWith("custom/dir", { 147 | recursive: true, 148 | }); 149 | expect(fs.writeFileSync).toHaveBeenCalledWith( 150 | mockFilePath, 151 | mockFileData.buffer, 152 | ); 153 | expect(result).toBe(mockFilePath); 154 | }); 155 | }); 156 | -------------------------------------------------------------------------------- /src/test/fileSaver/s3FileSaver.spec.ts: -------------------------------------------------------------------------------- 1 | import { S3FileSaver } from "../../fileSaver/s3.file-saver"; 2 | import { 3 | IS3FileSaverOptions, 4 | MimeType, 5 | S3FileDataOptions, 6 | } from "../../interfaces/file.interface"; 7 | import { ExecutionContext, InternalServerErrorException } from "@nestjs/common"; 8 | import { FileData } from "../../classes/FileData"; 9 | import { 10 | S3Client, 11 | PutObjectCommand, 12 | PutObjectCommandInput, 13 | } from "@aws-sdk/client-s3"; 14 | 15 | // Mock AWS SDK 16 | jest.mock("@aws-sdk/client-s3"); 17 | 18 | describe("S3FileSaver", () => { 19 | let fileSaver: S3FileSaver; 20 | let mockS3ClientSend: jest.Mock; 21 | const mockExecutionContext = {} as ExecutionContext; 22 | 23 | const mockFileData = new FileData( 24 | "test.jpg", 25 | "field", 26 | "test.jpg", 27 | "7bit", 28 | MimeType["image/jpeg"], 29 | "jpg", 30 | 123, 31 | "hash", 32 | Buffer.from("mock content"), 33 | ); 34 | 35 | const defaultOptions: IS3FileSaverOptions = { 36 | region: "us-east-1", 37 | bucket: "mock-bucket", 38 | endpoint: "https://custom-endpoint.com/", 39 | }; 40 | 41 | beforeEach(() => { 42 | mockS3ClientSend = jest.fn().mockResolvedValue({}); 43 | (S3Client as jest.Mock).mockImplementation(() => ({ 44 | send: mockS3ClientSend, 45 | config: { 46 | endpoint: jest.fn().mockResolvedValue({ path: "/" }), 47 | }, 48 | })); 49 | 50 | (PutObjectCommand as unknown as jest.Mock).mockImplementation( 51 | (params: PutObjectCommandInput) => ({ 52 | mockCommand: true, 53 | input: params, 54 | }), 55 | ); 56 | 57 | fileSaver = new S3FileSaver(defaultOptions); 58 | }); 59 | 60 | afterEach(() => { 61 | jest.clearAllMocks(); 62 | }); 63 | 64 | it("should upload file to S3 and return custom URL", async () => { 65 | const result = await fileSaver.save(mockFileData, mockExecutionContext); 66 | 67 | expect(PutObjectCommand).toHaveBeenCalledWith( 68 | expect.objectContaining({ 69 | Bucket: "mock-bucket", 70 | Key: "test.jpg", 71 | Body: mockFileData.buffer, 72 | ContentType: "image/jpeg", 73 | }), 74 | ); 75 | 76 | expect(mockS3ClientSend).toHaveBeenCalledWith( 77 | expect.objectContaining({ mockCommand: true }), 78 | ); 79 | 80 | expect(result).toBe("https://custom-endpoint.com//mock-bucket/test.jpg"); 81 | }); 82 | 83 | it("should throw an exception if bucket is missing", async () => { 84 | const noBucketOptions: IS3FileSaverOptions = { 85 | region: "us-east-1", 86 | }; 87 | fileSaver = new S3FileSaver(noBucketOptions); 88 | 89 | await expect( 90 | fileSaver.save(mockFileData, mockExecutionContext), 91 | ).rejects.toThrow(InternalServerErrorException); 92 | }); 93 | 94 | it("should use Bucket from save() options if provided", async () => { 95 | const saveOptions: S3FileDataOptions = { 96 | Bucket: "alternate-bucket", 97 | }; 98 | 99 | await fileSaver.save(mockFileData, mockExecutionContext, saveOptions); 100 | 101 | expect(PutObjectCommand).toHaveBeenCalledWith( 102 | expect.objectContaining({ 103 | Bucket: "alternate-bucket", 104 | }), 105 | ); 106 | }); 107 | 108 | it("should return default AWS S3 URL if no endpoint is provided", async () => { 109 | const fallbackOptions: IS3FileSaverOptions = { 110 | region: "us-west-2", 111 | bucket: "backup-bucket", 112 | }; 113 | fileSaver = new S3FileSaver(fallbackOptions); 114 | 115 | const result = await fileSaver.save(mockFileData, mockExecutionContext); 116 | 117 | expect(result).toBe( 118 | "https://backup-bucket.s3us-west-2.amazonaws.com/test.jpg", 119 | ); 120 | }); 121 | }); 122 | -------------------------------------------------------------------------------- /src/test/interceptor/fileInterceptor.spec.ts: -------------------------------------------------------------------------------- 1 | import { of } from "rxjs"; 2 | import { FormdataInterceptor } from "../../interceptors/formdata.interceptor"; 3 | import { CallHandler, ExecutionContext } from "@nestjs/common"; 4 | 5 | describe("FormdataInterceptor", () => { 6 | let interceptor: FormdataInterceptor; 7 | 8 | const mockRequest = { 9 | headers: { "content-type": "multipart/form-data" }, 10 | pipe: jest.fn(), 11 | body: {}, 12 | }; 13 | 14 | const executionContext = { 15 | switchToHttp: jest.fn().mockReturnThis(), 16 | getRequest: jest.fn().mockReturnValue(mockRequest), 17 | } as unknown as ExecutionContext; 18 | 19 | const next = { 20 | handle: jest.fn().mockReturnValue(of({})), 21 | } as unknown as CallHandler; 22 | 23 | beforeEach(() => { 24 | interceptor = new FormdataInterceptor(); 25 | }); 26 | 27 | it("should be defined", () => { 28 | expect(interceptor).toBeDefined(); 29 | }); 30 | 31 | it("should call handleMultipartFormData if content type is multipart/form-data", async () => { 32 | const spy = jest 33 | .spyOn(interceptor as any, "handleMultipartFormData") 34 | .mockReturnValue(of({})); 35 | (await interceptor.intercept(executionContext, next)).subscribe(); 36 | expect(spy).toHaveBeenCalled(); 37 | }); 38 | 39 | it("should handle form with optional file upload", async () => { 40 | const boundary = "d1bf46b3-aa33-4061-b28d-6c5ced8b08ee"; 41 | mockRequest.headers = { 42 | "content-type": `multipart/form-data; boundary=${boundary}`, 43 | }; 44 | mockRequest.body = [ 45 | "\r\n--d1bf46b3-aa33-4061-b28d-6c5ced8b08ee\r\n", 46 | "Content-Type: application/octet-stream\r\n" + 47 | 'Content-Disposition: form-data; name=batch-1; filename=""' + 48 | "\r\n\r\n", 49 | "\r\n--d1bf46b3-aa33-4061-b28d-6c5ced8b08ee--", 50 | ]; 51 | // @ts-ignore 52 | mockRequest.pipe = (busboy) => { 53 | // @ts-ignore 54 | for (const src of mockRequest.body) { 55 | const buf = typeof src === "string" ? Buffer.from(src, "utf8") : src; 56 | busboy.write(buf); 57 | } 58 | }; 59 | const observer = { 60 | next: (x) => console.log("Observer got a next value: " + x), 61 | error: (err) => console.error("Observer got an error: " + err), 62 | complete: () => console.log("Observer got a complete notification"), 63 | }; 64 | 65 | (await interceptor.intercept(executionContext, next)).subscribe(); 66 | }); 67 | 68 | it("should not call handleMultipartFormData if content type is not multipart/form-data", async () => { 69 | mockRequest.headers = { "content-type": "application/json" }; 70 | const spy = jest 71 | .spyOn(interceptor as any, "handleMultipartFormData") 72 | .mockReturnValue(of({})); 73 | (await interceptor.intercept(executionContext, next)).subscribe(); 74 | expect(spy).not.toHaveBeenCalled(); 75 | }); 76 | 77 | it("should call next.handle if content type is not multipart/form-data", async () => { 78 | mockRequest.headers = { "content-type": "application/json" }; 79 | (await interceptor.intercept(executionContext, next)).subscribe(); 80 | expect(next.handle).toHaveBeenCalled(); 81 | }); 82 | 83 | it("should handle nested fields correctly", async () => { 84 | const target = {}; 85 | const fieldname = "user[address][city]"; 86 | const value = "New York"; 87 | interceptor["handleField"](target, fieldname, value); 88 | 89 | expect(target).toEqual({ 90 | user: { 91 | address: { 92 | city: "New York", 93 | }, 94 | }, 95 | }); 96 | }); 97 | 98 | it("should handle array fields correctly", async () => { 99 | const target = {}; 100 | const fieldname = "tags[]"; 101 | const value1 = "tag1"; 102 | const value2 = "tag2"; 103 | interceptor["handleField"](target, fieldname, value1); 104 | interceptor["handleField"](target, fieldname, value2); 105 | 106 | expect(target).toEqual({ 107 | tags: ["tag1", "tag2"], 108 | }); 109 | }); 110 | }); 111 | -------------------------------------------------------------------------------- /src/test/validators/hasMimetype.spec.ts: -------------------------------------------------------------------------------- 1 | import { validate, IsDefined, IsOptional } from "class-validator"; 2 | import { MimeType } from "../../interfaces/file.interface"; 3 | import { HasMimeType } from "../../validators/hasMimeType.decorator"; 4 | import { FileData } from "../../classes/FileData"; 5 | 6 | class TestSingleFileClass { 7 | @IsDefined() 8 | @HasMimeType(["image/jpeg", "image/png"]) 9 | file: FileData; 10 | } 11 | 12 | class TestSingleFileOptionalClass { 13 | @IsOptional() 14 | @HasMimeType(["image/*"]) 15 | file?: FileData; 16 | } 17 | 18 | class TestingGenericFileClass { 19 | @IsDefined() 20 | @HasMimeType(["image/*"]) 21 | file: FileData; 22 | } 23 | 24 | class TestMultipleFilesClass { 25 | @IsDefined() 26 | @HasMimeType(["image/jpeg", "image/png"], { each: true }) 27 | files: FileData[]; 28 | } 29 | 30 | describe("HasMimeType", () => { 31 | const createFileData = (mimetype: MimeType | string): FileData => { 32 | return new FileData( 33 | "image.png", 34 | "image.png", 35 | "image.png", 36 | "7bit", 37 | mimetype as MimeType, 38 | "png", 39 | 10000, 40 | "hash", 41 | Buffer.from("test"), 42 | ); 43 | }; 44 | 45 | it("should validate single file with allowed mimetype", async () => { 46 | const instance = new TestSingleFileClass(); 47 | instance.file = createFileData("image/jpeg"); 48 | 49 | const errors = await validate(instance); 50 | expect(errors.length).toBe(0); 51 | }); 52 | 53 | it("should validate single file with allowed generic mimetype", async () => { 54 | const instance = new TestingGenericFileClass(); 55 | instance.file = createFileData("image/jpeg"); 56 | 57 | const errors = await validate(instance); 58 | expect(errors.length).toBe(0); 59 | }); 60 | 61 | it("should fail validation single file with allowed generic mimetype", async () => { 62 | const instance = new TestingGenericFileClass(); 63 | instance.file = createFileData("video/mp4"); 64 | 65 | const errors = await validate(instance); 66 | expect(errors.length).toBeGreaterThan(0); 67 | expect(errors[0].constraints).toHaveProperty("HasMimeTypeConstraint"); 68 | }); 69 | 70 | it("should success validation optional file with no file", async () => { 71 | const instance = new TestSingleFileOptionalClass(); 72 | 73 | const errors = await validate(instance); 74 | expect(errors.length).toBe(0); 75 | }); 76 | 77 | it("should fail validation optional file with wrong mimetype", async () => { 78 | const instance = new TestSingleFileOptionalClass(); 79 | instance.file = createFileData("application/pdf"); 80 | 81 | const errors = await validate(instance); 82 | expect(errors.length).toBeGreaterThan(0); 83 | expect(errors[0].constraints).toHaveProperty("HasMimeTypeConstraint"); 84 | }); 85 | 86 | it("should fail validation for single file with disallowed mimetype", async () => { 87 | const instance = new TestSingleFileClass(); 88 | instance.file = createFileData("application/pdf"); 89 | 90 | const errors = await validate(instance); 91 | expect(errors.length).toBeGreaterThan(0); 92 | expect(errors[0].constraints).toHaveProperty("HasMimeTypeConstraint"); 93 | }); 94 | 95 | it("should validate array of files with allowed mime types", async () => { 96 | const instance = new TestMultipleFilesClass(); 97 | instance.files = [ 98 | createFileData("image/jpeg"), 99 | createFileData("image/png"), 100 | ]; 101 | 102 | const errors = await validate(instance); 103 | expect(errors.length).toBe(0); 104 | }); 105 | 106 | it("should fail validation for array of files with one disallowed mimetype", async () => { 107 | const instance = new TestMultipleFilesClass(); 108 | instance.files = [ 109 | createFileData("image/jpeg"), 110 | createFileData("application/pdf"), 111 | ]; 112 | 113 | const errors = await validate(instance); 114 | expect(errors.length).toBeGreaterThan(0); 115 | expect(errors[0].constraints).toHaveProperty("HasMimeTypeConstraint"); 116 | }); 117 | 118 | it("should fail validation for empty mimetype in single file", async () => { 119 | const instance = new TestSingleFileClass(); 120 | instance.file = createFileData(""); 121 | 122 | const errors = await validate(instance); 123 | expect(errors.length).toBeGreaterThan(0); 124 | expect(errors[0].constraints).toHaveProperty("HasMimeTypeConstraint"); 125 | }); 126 | 127 | it("should fail validation for null mimetype in single file", async () => { 128 | const instance = new TestSingleFileClass(); 129 | instance.file = createFileData(null as any); 130 | 131 | const errors = await validate(instance); 132 | expect(errors.length).toBeGreaterThan(0); 133 | expect(errors[0].constraints).toHaveProperty("HasMimeTypeConstraint"); 134 | }); 135 | 136 | it("should validate an empty array for each option", async () => { 137 | const instance = new TestMultipleFilesClass(); 138 | instance.files = []; 139 | 140 | const errors = await validate(instance); 141 | expect(errors.length).toBe(0); 142 | }); 143 | }); 144 | -------------------------------------------------------------------------------- /src/test/validators/isFileData.spec.ts: -------------------------------------------------------------------------------- 1 | import { validate, IsDefined } from "class-validator"; 2 | import { MimeType } from "../../interfaces/file.interface"; 3 | import { IsFileData } from "../../validators/isFileData.decorator"; 4 | import { FileData } from "../../classes/FileData"; 5 | 6 | class TestSingleFileClass { 7 | @IsDefined() 8 | @IsFileData() 9 | file: FileData; 10 | } 11 | 12 | class TestMultipleFilesClass { 13 | @IsDefined() 14 | @IsFileData({ each: true }) 15 | files: FileData[]; 16 | } 17 | 18 | describe("IsFileData", () => { 19 | const createFileDataConstant = (): FileData => { 20 | return new FileData( 21 | "image.png", 22 | "image.png", 23 | "image.png", 24 | "7bit", 25 | MimeType["image/png"], 26 | "png", 27 | 10000, 28 | "hash", 29 | Buffer.from("test"), 30 | ); 31 | }; 32 | 33 | const createFileData = (mimetype: MimeType | string): any => ({ 34 | originalFileName: "testFile.jpg", 35 | fileName: "testFile", 36 | fileNameFull: `testFile.${mimetype?.split("/")[1] || "file"}`, 37 | encoding: "7bit", 38 | mimetype: mimetype as MimeType, 39 | fileExtension: mimetype?.split("/")[1] || "", 40 | fileSize: 1024, 41 | buffer: Buffer.from("test content"), 42 | save: () => { 43 | return "file saved"; 44 | }, 45 | }); 46 | 47 | it("should validate single file with allowed mimetype", async () => { 48 | const instance = new TestSingleFileClass(); 49 | instance.file = createFileDataConstant(); 50 | 51 | const errors = await validate(instance); 52 | expect(errors.length).toBe(0); 53 | }); 54 | 55 | it("should fail validation for single file with disallowed mimetype", async () => { 56 | const instance = new TestSingleFileClass(); 57 | instance.file = createFileData("application/pdf"); 58 | 59 | const errors = await validate(instance); 60 | expect(errors.length).toBeGreaterThan(0); 61 | expect(errors[0].constraints).toHaveProperty("IsFileDataConstraint"); 62 | }); 63 | 64 | it("should validate array of files with allowed mime types", async () => { 65 | const instance = new TestMultipleFilesClass(); 66 | instance.files = [createFileDataConstant(), createFileDataConstant()]; 67 | 68 | const errors = await validate(instance); 69 | expect(errors.length).toBe(0); 70 | }); 71 | 72 | it("should fail validation for array of files with one disallowed mimetype", async () => { 73 | const instance = new TestMultipleFilesClass(); 74 | instance.files = [ 75 | createFileData("image/jpeg"), 76 | createFileData("application/pdf"), 77 | ]; 78 | 79 | const errors = await validate(instance); 80 | expect(errors.length).toBeGreaterThan(0); 81 | expect(errors[0].constraints).toHaveProperty("IsFileDataConstraint"); 82 | }); 83 | 84 | it("should validate an empty array for each option", async () => { 85 | const instance = new TestMultipleFilesClass(); 86 | instance.files = []; 87 | 88 | const errors = await validate(instance); 89 | expect(errors.length).toBe(0); 90 | }); 91 | }); 92 | -------------------------------------------------------------------------------- /src/test/validators/maxFileSize.spec.ts: -------------------------------------------------------------------------------- 1 | import { validate, IsDefined } from "class-validator"; 2 | import { MimeType } from "../../interfaces/file.interface"; 3 | import { MaxFileSize } from "../../validators/maxFileSize.decorator"; 4 | import { FileData } from "../../classes/FileData"; 5 | 6 | // Test class for a single file 7 | class TestSingleFileClass { 8 | @IsDefined() 9 | @MaxFileSize(10000) 10 | file: FileData; 11 | } 12 | 13 | // Test class for multiple files 14 | class TestMultipleFilesClass { 15 | @IsDefined() 16 | @MaxFileSize(10000, { each: true }) 17 | files: FileData[]; 18 | } 19 | 20 | describe("MaxFileSize", () => { 21 | const createFileData = (fileSize: number): FileData => { 22 | return new FileData( 23 | "image.png", 24 | "image.png", 25 | "image.png", 26 | "7bit", 27 | "image/jpeg" as MimeType, 28 | "png", 29 | fileSize, 30 | "hash", 31 | Buffer.from("test"), 32 | ); 33 | }; 34 | 35 | it("should validate single file within max file size", async () => { 36 | const instance = new TestSingleFileClass(); 37 | instance.file = createFileData(9000); 38 | 39 | const errors = await validate(instance); 40 | expect(errors.length).toBe(0); 41 | }); 42 | 43 | it("should fail validation for single file exceeding max file size", async () => { 44 | const instance = new TestSingleFileClass(); 45 | instance.file = createFileData(15000); 46 | 47 | const errors = await validate(instance); 48 | expect(errors.length).toBeGreaterThan(0); 49 | expect(errors[0].constraints).toHaveProperty("MaxFileSizeConstraint"); 50 | }); 51 | 52 | it("should validate multiple files within max file size", async () => { 53 | const instance = new TestMultipleFilesClass(); 54 | instance.files = [createFileData(5000), createFileData(4000)]; 55 | 56 | const errors = await validate(instance); 57 | expect(errors.length).toBe(0); 58 | }); 59 | 60 | it("should fail validation for multiple files with one exceeding max file size", async () => { 61 | const instance = new TestMultipleFilesClass(); 62 | instance.files = [createFileData(5000), createFileData(15000)]; 63 | 64 | const errors = await validate(instance); 65 | expect(errors.length).toBeGreaterThan(0); 66 | expect(errors[0].constraints).toHaveProperty("MaxFileSizeConstraint"); 67 | }); 68 | 69 | it("should fail validation for multiple files with all exceeding max file size", async () => { 70 | const instance = new TestMultipleFilesClass(); 71 | instance.files = [createFileData(15000), createFileData(20000)]; 72 | 73 | const errors = await validate(instance); 74 | expect(errors.length).toBeGreaterThan(0); 75 | expect(errors[0].constraints).toHaveProperty("MaxFileSizeConstraint"); 76 | }); 77 | 78 | it("should validate an empty array for each option", async () => { 79 | const instance = new TestMultipleFilesClass(); 80 | instance.files = []; 81 | 82 | const errors = await validate(instance); 83 | expect(errors.length).toBe(0); 84 | }); 85 | }); 86 | -------------------------------------------------------------------------------- /src/test/validators/minFileSize.spec.ts: -------------------------------------------------------------------------------- 1 | import { validate, IsDefined } from "class-validator"; 2 | import { MimeType } from "../../interfaces/file.interface"; 3 | import { MinFileSize } from "../../validators/minFileSize.decorator"; 4 | import { FileData } from "../../classes/FileData"; 5 | 6 | // Test class for a single file 7 | class TestSingleFileClass { 8 | @IsDefined() 9 | @MinFileSize(10000) 10 | file: FileData; 11 | } 12 | 13 | // Test class for multiple files 14 | class TestMultipleFilesClass { 15 | @IsDefined() 16 | @MinFileSize(10000, { each: true }) 17 | files: FileData[]; 18 | } 19 | 20 | describe("MaxFileSize", () => { 21 | const createFileData = (fileSize: number): FileData => { 22 | return new FileData( 23 | "image.png", 24 | "image.png", 25 | "image.png", 26 | "7bit", 27 | "image/jpeg" as MimeType, 28 | "png", 29 | fileSize, 30 | "hash", 31 | Buffer.from("test"), 32 | ); 33 | }; 34 | 35 | it("should validate single file within max file size", async () => { 36 | const instance = new TestSingleFileClass(); 37 | instance.file = createFileData(90000); 38 | 39 | const errors = await validate(instance); 40 | expect(errors.length).toBe(0); 41 | }); 42 | 43 | it("should fail validation for single file exceeding max file size", async () => { 44 | const instance = new TestSingleFileClass(); 45 | instance.file = createFileData(1500); 46 | 47 | const errors = await validate(instance); 48 | expect(errors.length).toBeGreaterThan(0); 49 | expect(errors[0].constraints).toHaveProperty("MinFileSizeConstraint"); 50 | }); 51 | 52 | it("should validate multiple files within max file size", async () => { 53 | const instance = new TestMultipleFilesClass(); 54 | instance.files = [createFileData(50000), createFileData(40000)]; 55 | 56 | const errors = await validate(instance); 57 | expect(errors.length).toBe(0); 58 | }); 59 | 60 | it("should fail validation for multiple files with one exceeding max file size", async () => { 61 | const instance = new TestMultipleFilesClass(); 62 | instance.files = [createFileData(5000), createFileData(15000)]; 63 | 64 | const errors = await validate(instance); 65 | expect(errors.length).toBeGreaterThan(0); 66 | expect(errors[0].constraints).toHaveProperty("MinFileSizeConstraint"); 67 | }); 68 | 69 | it("should fail validation for multiple files with all exceeding max file size", async () => { 70 | const instance = new TestMultipleFilesClass(); 71 | instance.files = [createFileData(1500), createFileData(2000)]; 72 | 73 | const errors = await validate(instance); 74 | expect(errors.length).toBeGreaterThan(0); 75 | expect(errors[0].constraints).toHaveProperty("MinFileSizeConstraint"); 76 | }); 77 | 78 | it("should validate an empty array for each option", async () => { 79 | const instance = new TestMultipleFilesClass(); 80 | instance.files = []; 81 | 82 | const errors = await validate(instance); 83 | expect(errors.length).toBe(0); 84 | }); 85 | }); 86 | -------------------------------------------------------------------------------- /src/validators/hasMimeType.decorator.ts: -------------------------------------------------------------------------------- 1 | import { FileData } from "../classes/FileData"; 2 | import { MimeType } from "../interfaces/file.interface"; 3 | import { 4 | registerDecorator, 5 | ValidationArguments, 6 | ValidationOptions, 7 | ValidatorConstraint, 8 | ValidatorConstraintInterface, 9 | } from "class-validator"; 10 | 11 | /** 12 | * Custom validator constraint to check if the mimetype of a FileData object matches the specified types. 13 | */ 14 | @ValidatorConstraint({ async: false }) 15 | class HasMimeTypeConstraint implements ValidatorConstraintInterface { 16 | /** 17 | * Validates if the mimetype of the provided FileData object matches the specified types. 18 | * @param value The FileData object to validate. 19 | * @param args The validation arguments containing the constraints and options. 20 | * @returns A boolean indicating whether the mimetype matches the specified types. 21 | */ 22 | public validate(value: FileData, args: ValidationArguments) { 23 | const [mimeType, option] = args.constraints as [ 24 | (MimeType | string)[], 25 | ValidationOptions, 26 | ]; 27 | 28 | if (option?.each && Array.isArray(value)) { 29 | return value.every((item: FileData) => 30 | this.matchesWildcard(mimeType, item.mimetype), 31 | ); 32 | } else { 33 | return this.matchesWildcard(mimeType, value?.mimetype); 34 | } 35 | } 36 | 37 | /** 38 | * Checks if a mimetype matches a wildcard type. 39 | * @param mimeType An array of allowed mimetypes (or a single mimetype). 40 | * @param type The mimetype to check against the allowed types. 41 | * @returns A boolean indicating whether the mimetype matches any of the allowed types. 42 | */ 43 | private matchesWildcard(mimeType?: (MimeType | string)[], type?: string) { 44 | return mimeType.some((allowedType) => { 45 | if (allowedType?.endsWith("/*")) { 46 | return type?.startsWith(allowedType.slice(0, -2)); 47 | } 48 | return type === allowedType; 49 | }); 50 | } 51 | 52 | /** 53 | * Generates the default error message for the HasMimeType constraint. 54 | * @param args The validation arguments containing the constraints and options. 55 | * @returns The default error message. 56 | */ 57 | public defaultMessage(args: ValidationArguments) { 58 | const [mimeType, option] = args.constraints as [ 59 | (MimeType | string)[], 60 | ValidationOptions, 61 | ]; 62 | 63 | if (option?.each) { 64 | return `The mimetype of ${args.property} must be an array of ${mimeType}`; 65 | } else { 66 | return `The mimetype of ${args.property} must be an ${mimeType}`; 67 | } 68 | } 69 | } 70 | 71 | /** 72 | * Decorator that applies the HasMimeType constraint to the specified property. 73 | * @param mimeType The allowed mimetype(s) to validate against. 74 | * @param validationOptions The validation options. 75 | * @returns The decorator function. 76 | */ 77 | export function HasMimeType( 78 | mimeType: (MimeType | string)[], 79 | validationOptions?: ValidationOptions, 80 | ) { 81 | return (object: object, propertyName: string) => { 82 | registerDecorator({ 83 | target: object.constructor, 84 | propertyName: propertyName, 85 | options: validationOptions, 86 | constraints: [mimeType, validationOptions], 87 | validator: HasMimeTypeConstraint, 88 | }); 89 | }; 90 | } 91 | -------------------------------------------------------------------------------- /src/validators/isFileData.decorator.ts: -------------------------------------------------------------------------------- 1 | import { FileData } from "../classes/FileData"; 2 | import { 3 | registerDecorator, 4 | ValidationArguments, 5 | ValidationOptions, 6 | ValidatorConstraint, 7 | ValidatorConstraintInterface, 8 | } from "class-validator"; 9 | 10 | /** 11 | * Validator constraint to check if the value is an instance of FileData or, if the `each` option is true, an array of instances of FileData. 12 | */ 13 | @ValidatorConstraint({ async: false }) 14 | class IsFileDataConstraint implements ValidatorConstraintInterface { 15 | /** 16 | * Validates if the value is an instance of FileData or an array of instances of FileData when `each` option is true. 17 | * @param value - The value to validate. 18 | * @param args - The validation arguments containing constraints and other metadata. 19 | * @returns `true` if the value is valid, otherwise `false`. 20 | */ 21 | public validate(value: FileData, args: ValidationArguments) { 22 | const [option] = args.constraints as [ValidationOptions]; 23 | 24 | if (option?.each && Array.isArray(value)) { 25 | return value.every((item) => item instanceof FileData); 26 | } else { 27 | return value instanceof FileData; 28 | } 29 | } 30 | 31 | /** 32 | * Provides a default error message if the validation fails. 33 | * @param args - The validation arguments containing constraints and other metadata. 34 | * @returns The default error message. 35 | */ 36 | public defaultMessage(args: ValidationArguments) { 37 | const [option] = args.constraints as [ValidationOptions]; 38 | 39 | if (option?.each) { 40 | return `The value ${args.property} must be an array of instances of FileData`; 41 | } else { 42 | return `The value ${args.property} must be an instance of FileData`; 43 | } 44 | } 45 | } 46 | 47 | /** 48 | * Decorator function to validate if a property is an instance of FileData or, if the `each` option is true, an array of instances of FileData. 49 | * @param validationOptions - Optional validation options to customize the validation behavior. 50 | * @returns A property decorator function. 51 | */ 52 | export function IsFileData(validationOptions?: ValidationOptions) { 53 | return (object: object, propertyName: string) => { 54 | registerDecorator({ 55 | target: object.constructor, 56 | propertyName: propertyName, 57 | options: validationOptions, 58 | constraints: [validationOptions], 59 | validator: IsFileDataConstraint, 60 | }); 61 | }; 62 | } 63 | -------------------------------------------------------------------------------- /src/validators/maxFileSize.decorator.ts: -------------------------------------------------------------------------------- 1 | import { FileData } from "../classes/FileData"; 2 | import { 3 | registerDecorator, 4 | ValidationArguments, 5 | ValidationOptions, 6 | ValidatorConstraint, 7 | ValidatorConstraintInterface, 8 | } from "class-validator"; 9 | 10 | /** 11 | * Validator constraint to check if the file size is within the maximum limit. 12 | */ 13 | @ValidatorConstraint({ async: false }) 14 | class MaxFileSizeConstraint implements ValidatorConstraintInterface { 15 | /** 16 | * Validates if the file size is within the maximum limit. 17 | * @param value - The FileData object to validate. 18 | * @param args - The validation arguments containing constraints and other metadata. 19 | * @returns `true` if the file size is within the limit, otherwise `false`. 20 | */ 21 | public validate(value: FileData, args: ValidationArguments) { 22 | const [maxSize, options] = args.constraints as [number, ValidationOptions]; 23 | 24 | if (options?.each && Array.isArray(value)) { 25 | return value.every((item: FileData) => item.fileSize < maxSize); 26 | } else { 27 | return value?.fileSize < maxSize; 28 | } 29 | } 30 | 31 | /** 32 | * Provides a default error message if the validation fails. 33 | * @param args - The validation arguments containing constraints and other metadata. 34 | * @returns The default error message. 35 | */ 36 | public defaultMessage(args: ValidationArguments) { 37 | const [maxSize] = args.constraints as [number]; 38 | 39 | return `The file ${args.property} maximum file size is ${maxSize} bytes`; 40 | } 41 | } 42 | 43 | /** 44 | * Decorator function to validate if the file size is within the maximum limit. 45 | * @param maxSize - The maximum file size in bytes. 46 | * @param validationOptions - Optional validation options to customize the validation behavior. 47 | * @returns A property decorator function. 48 | */ 49 | export function MaxFileSize( 50 | maxSize: number, 51 | validationOptions?: ValidationOptions, 52 | ) { 53 | return (object: object, propertyName: string) => { 54 | registerDecorator({ 55 | target: object.constructor, 56 | propertyName: propertyName, 57 | options: validationOptions, 58 | constraints: [maxSize, validationOptions], 59 | validator: MaxFileSizeConstraint, 60 | }); 61 | }; 62 | } 63 | -------------------------------------------------------------------------------- /src/validators/minFileSize.decorator.ts: -------------------------------------------------------------------------------- 1 | import { FileData } from "../classes/FileData"; 2 | import { 3 | registerDecorator, 4 | ValidationArguments, 5 | ValidationOptions, 6 | ValidatorConstraint, 7 | ValidatorConstraintInterface, 8 | } from "class-validator"; 9 | 10 | /** 11 | * Validator constraint to check if the file size is above the minimum limit. 12 | */ 13 | @ValidatorConstraint({ async: false }) 14 | class MinFileSizeConstraint implements ValidatorConstraintInterface { 15 | /** 16 | * Validates if the file size is above the minimum limit. 17 | * @param value - The FileData object to validate. 18 | * @param args - The validation arguments containing constraints and other metadata. 19 | * @returns `true` if the file size is above the limit, otherwise `false`. 20 | */ 21 | public validate(value: FileData, args: ValidationArguments) { 22 | const [minSize, options] = args.constraints as [number, ValidationOptions]; 23 | 24 | if (options?.each && Array.isArray(value)) { 25 | return value.every((item: FileData) => item.fileSize > minSize); 26 | } else { 27 | return value?.fileSize > minSize; 28 | } 29 | } 30 | 31 | /** 32 | * Provides a default error message if the validation fails. 33 | * @param args - The validation arguments containing constraints and other metadata. 34 | * @returns The default error message. 35 | */ 36 | public defaultMessage(args: ValidationArguments) { 37 | const [minSize] = args.constraints as [number]; 38 | 39 | return `The file ${args.property} minimum file size is ${minSize} bytes`; 40 | } 41 | } 42 | 43 | /** 44 | * Decorator function to validate if the file size is above the minimum limit. 45 | * @param minSize - The minimum file size in bytes. 46 | * @param validationOptions - Optional validation options to customize the validation behavior. 47 | * @returns A property decorator function. 48 | */ 49 | export function MinFileSize( 50 | minSize: number, 51 | validationOptions?: ValidationOptions, 52 | ) { 53 | return (object: object, propertyName: string) => { 54 | registerDecorator({ 55 | target: object.constructor, 56 | propertyName: propertyName, 57 | options: validationOptions, 58 | constraints: [minSize, validationOptions], 59 | validator: MinFileSizeConstraint, 60 | }); 61 | }; 62 | } 63 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": false, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "allowSyntheticDefaultImports": true, 9 | "target": "ES2021", 10 | "sourceMap": true, 11 | "outDir": "dist", 12 | "incremental": true, 13 | "skipLibCheck": true, 14 | "strictNullChecks": false, 15 | "noImplicitAny": false, 16 | "strictBindCallApply": false, 17 | "forceConsistentCasingInFileNames": false, 18 | "noFallthroughCasesInSwitch": false, 19 | "baseUrl": ".", 20 | "esModuleInterop": true 21 | }, 22 | "include": ["src/**/*.ts"], 23 | "exclude": ["node_modules"] 24 | } 25 | --------------------------------------------------------------------------------