Implementing File Uploads in NestJS Fastify: A Step-by-Step Guide to Conversion and Validation with Class-Validator.
Introduction
Class-validator is a powerful validation library for Node.js that allows for the use of decorator-based validation directly on class properties. Utilizing it for file validation can be an extremely useful feature. In this article, we are focusing on transforming a file to the required format and validating it using Class-validator.
Prerequisites
- Fastify-multipart
- NestJS interceptor
Dependencies
npm i -g @nestjs/cli
npm i @nestjs/platform-fastify
npm i fastify
npm i @fastify/multipart
npm i class-validator
npm i class-transformer
Project Setup
Create a new project using NestJs CLI
nest new demo
main.ts (updated)
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import {
FastifyAdapter,
NestFastifyApplication,
} from '@nestjs/platform-fastify';
import multipart from '@fastify/multipart';
async function bootstrap() {
const app = await NestFactory.create<NestFastifyApplication>(
AppModule,
new FastifyAdapter(),
);
app.register(multipart, {
limits: {
fileSize: 1 * 1024 * 1024, //1 MB
},
});
await app.listen(3000);
}
bootstrap();
I want to a validate JSON file using Class-validator. So we will create a dto class first.
dto.ts (created)
import { IsIn, IsNumber, IsString, Length, Min } from 'class-validator';
export class MyFile {
@Length(100)
@IsString()
name: string;
@Min(18)
@IsNumber()
age: string;
@IsIn(['male', 'female'])
@IsString()
sex: string;
}
app.controller.ts (updated)
import {
Body,
Controller,
Get,
Post,
UsePipes,
ValidationPipe,
} from '@nestjs/common';
import { AppService } from './app.service';
import { MyFile } from './dto';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get()
getHello(): string {
return this.appService.getHello();
}
@Post()
@UsePipes(ValidationPipe)
uploadFile(@Body() fileData: MyFile) {
return fileData;
}
}
Now we will write a NestJS interceptor which will validate the file type, stream file content, and attach content to the body by parsing it to JSON.
interceptor.js (created)
import {
BadRequestException,
CallHandler,
ExecutionContext,
NestInterceptor,
} from '@nestjs/common';
import { FastifyRequest } from 'fastify';
import { Observable } from 'rxjs';
import { find } from 'lodash';
import { MultipartFile } from '@fastify/multipart';
export class FileTransformInterceptor implements NestInterceptor {
private async streamToString(multipartFile: MultipartFile): Promise<string> {
const stream = multipartFile.file;
const chunks = [];
return new Promise((resolve, reject) => {
stream.on('data', (chunk) => {
chunks.push(Buffer.from(chunk));
});
stream.on('error', (err) => {
reject(err);
});
stream.on('end', () => {
return resolve(Buffer.concat(chunks).toString('utf8'));
});
stream.on('limit', () => {
reject(new BadRequestException('File size limit exceeded'));
});
});
}
private validateAndGetFileType(filename: string, mimetype: string) {
const nameSplits = filename.split('.');
const extension = nameSplits[nameSplits.length - 1];
const supportedTypes = [
{ extension: 'json', mimetype: 'application/json' },
];
const typeCheckResult =
find(supportedTypes, { extension }) || find(supportedTypes, { mimetype });
if (!typeCheckResult) {
throw new BadRequestException('File type not supported');
}
return typeCheckResult.type;
}
async intercept(
context: ExecutionContext,
next: CallHandler<any>,
): Promise<Observable<any>> {
const req = context.switchToHttp().getRequest<FastifyRequest>();
if (!req.isMultipart()) {
throw new BadRequestException('Request must be multipart');
}
const multipartFile = await req.file();
if (!multipartFile) {
throw new BadRequestException('File required');
}
this.validateAndGetFileType(multipartFile.filename, multipartFile.mimetype);
const fileContents = await this.streamToString(multipartFile);
try {
req.body = JSON.parse(fileContents);
} catch (err) {
throw new BadRequestException('Invalid JSON format');
}
return next.handle();
}
}
Let's attach this to the controller method uploadFile
@Post()
@UsePipes(ValidationPipe)
@UseInterceptors(FileTransformInterceptor) // Interceptor
uploadFile(@Body() fileData: MyFile) {
return fileData;
}
For an invalid file, you will get the following response.
{
"message": [
"name must be a string",
"name must be longer than or equal to 100 characters",
"age must be a number conforming to the specified constraints",
"age must not be less than 18",
"sex must be a string",
"sex must be one of the following values: male, female"
],
"error": "Bad Request",
"statusCode": 400
}
Conclusion
Using this method we maintain normal validation->controller->service flow of NestJS app.