How to serialize responses in nestjs

ยท

8 min read

When writing APIs, you'll most likely have to return responses to the client. For example, when writing a sign-up API, you may be required to return the created user object to the client.

An important step to take in development is ensuring that only the required amount of data is returned for performance reasons -and that It does not include sensitive information such as passwords that could compromise your users' accounts. This can be achieved by serializing responses before they are returned.

In this tutorial, you'll learn how serialization works in nestjs and how to serialize your responses in nestjs

prerequisites

To follow along seamlessly, this article assumes you have:

  • some experience building REST APIS with nodejs and nestjs

  • An understanding of nestjs project structure and terminologies such as decorators, modules, controllers, providers etc

  • Some experience programming in javascript and typescript

How nestjs serialization works

serialization in nestjs is enabled using the ClassSerializerInterceptor. It intercepts the responses and serialises them to the desired format before returning them to the client. The ClassSerializerInterceptor interceptor can be set at the application level to enable serialization by controllers -or at the service level for serialization by services.

An entity class (serializer class) is created to define the shape of the data one would like to return to the client. The class transformer package is then used to provide a set of rules to transform the data before it is returned to the client. The serialized data becomes an instance of the entity class and the returned value of a method handler as shown in the example below.

Example.

This example is a citizen registration app where citizens signup to become users by providing their information. It demonstrates serializing data by excluding some information provided such as password and includes the citizenStatus -a computation derived from the provided date of birth.

Set up:

To get started, in your terminal, install nestjs CLI if you do not have it installed already

npm install -g @nestjs/cli

Use the nest CLI to generate a new project boilerplate and select the package manager you'll be using

nest new citizen_app

next, install some packages that will be required for this example

npm i --save class-validator class-transformer

In the root of your project, create a userData.json file to serve as data storage for this project

touch userData.json

Rest Endpoints:

With the set-up complete, regenerate a resource for the user and select generate REST endpoints

nest g resource user

A new user folder should be added to your src folder containing, service, controllers, dto, entities etc

In the create-user.dto.ts, define the properties that will be used to create the user. Add some validations to the properties using the class-validator package as shown below:

import { IsNotEmpty, IsString, IsDateString, IsEmail } from 'class-validator';

export class CreateUserDto {
  @IsNotEmpty() //should not be empty
  @IsString() //should be a string
  firstname: string;

  @IsNotEmpty()
  @IsString()
  lastname: string;

  @IsDateString({ strict: true }) //should be in format 2022-07-15.
  dateOfBirth: Date;

  @IsEmail() //should be a valid email
  email: string;

  @IsNotEmpty()
  @IsString()
  password: string;

You have to enable global validations in your src/main.ts bootstrap() function for the validations to be applied. Add this code just before your app.listen()

app.useGlobalPipes(new ValidationPipe())

In the user folder, create a new folder called interfaces. create an interface for the created user

export interface IUser {
  id: number;
  firstname: string;
  lastname: string;
  dateOfBirth: Date;
  email: string;
  password: string;
}

In the src/user.service.ts, the UserService class will need to have access to the data storage file to read and write to it. To achieve that, The UserService class has to instantiate by reading the userData.json file as follows:

import * as fs from 'fs';
import * as path from 'path';
import {Injectable} from '@nestjs/commom';
import { CreateUserDto } from './dto/create-user.dto';
import { IUser } from './interfaces';

@Injectable()
export class UserService {
  private userData: IUser[];

  constructor() {
    fs.promises
      .readFile(path.join(__dirname, '../../userData.json'), 'utf-8')
      .then((data) => {
        this.userData = JSON.parse(data);
      })
      .catch((err) => {
        console.error(err);
      });
  }

  private saveUser() {
    fs.promises.writeFile(
      path.join(__dirname, '../../userData.json'),
      JSON.stringify(this.userData),
    );
  }
//the rest of the generated methods go here...
}

Still in src/user/user.service.ts, add some actual implementation to the rest of the method handlers.

//continuation of userService class...
//create new user
  create(dto: CreateUserDto) {
    const existingUser = this.userData.find((user) => user.email ===                     dto.email);
    if (existingUser) throw new ConflictException('user already exists');

    const newUser: IUser = {
      id: this.userData.length + 1,
      firstname: dto.firstname,
      lastname: dto.lastname,
      dateOfBirth: dto.dateOfBirth,
      email: dto.email,
      password: dto.password,
    };
    this.userData.push(newUser); //add new user to userData
    this.saveUser();

    return newUser;
  }

// find all users
  findAll() {
    return this.userData;
  }

// find a user with id
  findOne(id: number) {
    const user = this.userData.find((user) => user.id === id);
    if (!user) throw new NotFoundException('user not found');
    return user;
  }

// delete user
  remove(id: number) {
    const userIndex = this.userData.findIndex((user) => user.id === id);
    if (userIndex === -1) throw new NotFoundException('user not found');
    this.userData.splice(userIndex, 1);
    this.saveUser();
  }

Note that in a real application, you'll have to secure your passwords before saving them by hashing them using a third-party package such as bcrypt

With the UserService fully implemented, start the server by running npm run start:dev in your terminal and send some requests. You can use Postman, Insomnia or any other tool of your choice.

The response from making a POST request to the user endpoint should look like this in Postman.

Notice that among other properties returned in the response object, we have password -which would compromise the security of your users' data. The plan is to have the password excluded from the response object. For this example, the dateOfBirth property will also be excluded and instead, a citizenStatus property -indicating whether the user is a child, adult or senior depending on their date of birth will be included.

Serializing the endpoints

The plan is to use the ClassSerializerInterceptor at the application level so that all the route handlers in the controller that are provided with an instance of the entity class (serializer class) can perform serialization.

In your src/main.ts, import the ClassSerializerInterceptor from @nestjs/common and add the interceptor globally. Your final bootstrap() function should look like this:

import { ClassSerializerInterceptor } from '@nestjs/common';
import { ValidationPipe } from '@nestjs/common';
import { NestFactory, Reflector } from '@nestjs/core';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalPipes(new ValidationPipe()); //set validations to the application level
  // ๐Ÿ‘‡ apply transform to all responses
  app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector)));
  await app.listen(3000);
}
bootstrap();

Next, create an entity class and supply a set of rules for transforming the data. Add this to your src/user/entities/user.entity.ts

import { Exclude, Expose } from 'class-transformer';
import { IUser } from '../interfaces';

interface IUserEntity extends IUser {
  get citizenStatus(): CitizenStatus;
}

type CitizenStatus = 'Child' | 'Adult' | 'Senior';

export class UserEntity implements IUserEntity {
  id: number;
  firstname: string;
  lastname: string;
  email: string;

  @Exclude()
  dateOfBirth: Date;

  @Exclude()
  password: string;

  @Expose()
  get citizenStatus(): CitizenStatus {
    const birthDate = new Date(this.dateOfBirth).getFullYear();
    const currentYear = new Date().getFullYear();
    const age = currentYear - birthDate;
    if (age < 18) {
      return 'Child';
    } else if (age > 18 && age < 60) {
      return 'Adult';
    } else {
      return 'Senior';
    }
  }

In the provided snippet, the @Exclude() decorator from the class-transformer package is used to indicate that the password and dateOfBirth properties should be excluded from the response objects. On the other hand, the citizenStatus property is indicated to be included using the @Expose() decorator.

Since citizenStatus is not an actual property of the entity class -but rather a computed value based on the dateOfBirth property, the get keyword is used to define a getter method for citizenStatus instead of a regular property.

With the entity class created, the next step is to use it in the controller. Refactor your src/user/user.controller.tsto match this:

import { Controller, Get, Post, Body, Param, Delete } from '@nestjs/common';
import { UserService } from './user.service';
import { CreateUserDto } from './dto/create-user.dto';
import { UserEntity } from './entities/user.entity';

@Controller('user')
export class UserController {
  constructor(private readonly userService: UserService) {}

  @Post()
  create(@Body() createUserDto: CreateUserDto): UserEntity {
    return new UserEntity(this.userService.create(createUserDto));
  }

  @Get()
  findAll(): UserEntity[] {
    const users = this.userService.findAll();
    return users.map((user) => new UserEntity(user));
  }

  @Get(':id')
  findOne(@Param('id') id: string): UserEntity {
    return new UserEntity(this.userService.findOne(+id));
  }

  @Delete(':id')
  remove(@Param('id') id: string) {
    return this.userService.remove(+id);
  }
}

The controller's method handlers that will need to be serialized are refactored to return an instance of the entity class with the return values of the delegated service handlers. A return type of UserEntity is also added.

Restart your application and send some requests to confirm the serialiser is working as expected. The GET request to fetch all users provides the following response:

From the response, you can tell the data has been properly serialized to the desired format.

Conclusion

If you've made it to the end of this article, you've learnt how serialization works in nestjs and learnt hands-on to serialize data in nestjs. From the example, you can tell that among other things, you can return only a subset of an object and also make use of getters and setters.

There are so many other transformations you can apply to your response objects and this tutorial only scratched the surface with those. To dive deep into transformations, feel free to read more on the class-transformer Github. Also, check out the nestjs docs on this same topic.

The complete source code for this example can be found on Github here

ย