Data Transfer Objects (DTOs) are the basis of data validation in NestJS applications. DTOs enable various layers of flexible data validation in NestJS.
In this publication, you will take a deep dive into data transfer objects, discussing validation mechanisms, authentication models, and everything else you need to know about data transfer objects.
Prerequisites
This tutorial is not beginner-themed. If you’re just getting started with NestJS, read this article.
You’ll need to meet these requirements to fully understand this article.
- Prior experience creating NestJS apps with MongoDB.
- Competence with PostMan (recommended) or any other API testing tool.
- Basic knowledge of bcrypt or the NodeJS crypto module.
Once you meet these requirements, we can get started.
What is a Data Transfer Object?
A data transfer object, commonly called a DTO, is an object used to validate data and define data structure sent into your Nest applications. DTOs are similar to interfaces, but differ from interfaces in the following ways:
- Interfaces are used for type-checking and structure definition.
- A DTO is used for type-checking, structure definition, and data validation.
- Interfaces disappear during compilation, as it’s native to TypeScript and doesn't exist in JavaScript.
- DTOs are defined using classes that are supported in native JavaScript. Hence, it remains after compilation.
DTOs alone can only do type-checking and structure definition. To run data validations using DTOs, you’ll need to use the NestJS ValidationPipe
.
Validation Mechanisms
Pipes are used to validate data in NestJS. Data passing through a pipe is evaluated, if it passes the validation test, it is returned unmodified; otherwise, an error is thrown.
NestJS has 8 inbuilt pipes, but you’ll be focusing on the ValidationPipe
which makes use of the class-validator
package, because it abstracts a lot of verbose code and makes it easy to validate data using decorators.
DTOs in a Simple User Authentication Model
A user authentication model is a perfect example to demystify DTOs. This is because it requires multiple levels of data validation. Let’s make one and explore DTOs and the ValidationPipe
to validate all forms of data coming into our application.
Setting up Development Environment
To set up your development environment:
- Generate a new project using the Nest CLI,
- Generate your
Module
,Service
, andController
using the CLI, - Install
Mongoose
and connect your application to the database, - Create your schema folder and define the
Schema
.
A typical user-auth schema should look like:
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { Document } from 'mongoose';
export type UserDocument = User & Document;
@Schema()
export class User {
@Prop({ required: true })
fullName: string;
@Prop({ required: true })
email: string;
@Prop({ required: true })
password: string;
}
export const UserSchema = SchemaFactory.createForClass(User);
Having the fullName
, email
, and password
as required properties.
- Create a folder inside your module and call it
dto
. This is where your dto classes will be stored and exported. - Create a file inside your
dto
folder and name ituser.dto.ts
.
Structure of a DTO
A DTO is a class, so it follows the same syntax a class does.
export class newUserDto {
fullName: string;
email: string;
password: string;
}
Above is the structure of a basic DTO.
This structure is only useful for type-checking, it has no data validation properties.
Implementing Data Validation
To add validation functions; follow the steps below;
- Inside your
main.ts
, - Import
{ValidationPipe}
from'@nestjs/common'
, - Inside the
bootstrap
function and directly below theapp
constant, call theuseGlobalPipes
method onapp
and pass innew ValidationPipe()
as an argument.
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ValidationPipe } from '@nestjs/common';
import * as dotenv from 'dotenv';
dotenv.config();
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(new ValidationPipe());
await app.listen(process.env.PORT);
}
bootstrap();
This will enable auto-validation and ensure that all endpoints are protected from receiving incorrect data. The Validation pipe takes in an options object as an argument, you can find out more about it in the official documentation.
- Install the
class-validator
and theclass-transformer
package by running:
npm i --save class-validator class-transformer
The class-transformer
converts JSON objects into an instance of your DTO class and vice-versa.
The class-validator
package has a lot of validation queries, which you can find in their documentation, but you’ll be focusing on a few of them related to user data validation. In your user.dto.ts
file, import the following queries from class-validator
:
-
IsNotEmpty
: This validator checks if a given value is empty. It takes in an object of options as an argument. -
IsString
: This validator checks if a given value is a real string. It takes in an object of options as an argument. -
IsEmail
: This validator checks if a given value is of type email. It takes in an object of options as an argument. -
Length
: This Validator takes in 3 arguments. The first is theminLength
, then themaxLength
, and finally an object of options.
The above will be used as decorators to validate their respective fields. Implement this in your DTO,
import { IsNotEmpty, IsEmail, Length, IsString } from 'class-validator';
export class newUserDto {
@IsNotEmpty({ message: 'Please Enter Full Name' })
@IsString({ message: 'Please Enter Valid Name' })
fullName: string;
@IsEmail({ message: 'Please Enter a Valid Email' })
email: string;
@Length(6, 50, {
message: 'Password length Must be between 6 and 50 charcters',
})
password: string;
}
The password field has only a Length
validation but in production, you might need to add a few more fields to ensure the password is more secure.
These new validation fields might include checking if the password contains uppercase, lowercase, and numbers.
This can be done in several ways, including:
- Padding with more validation decorators, like you did in the
fullName
field. - Using the
@Matches
validator and passing a regular expression that checks for your requirements as an argument.
But the cleanest way to do this would be by creating a custom validator to suit your specifications. You can learn more about custom decorators here.
Post & Put Requests
Test the data validation by making a few POST
and PUT
requests to your application. But before that, it’s bad practice to store your passwords in plain text. So hash them first before storing them in the database;
Hashing Passwords
You can hash the passwords using a package called bcrypt
or the NodeJS crypto module. This publication covers bcrypt
. To install bcrypt
run
npm i bcrypt
npm i -D @types/bcrypt
Create a utils
folder to store files that contain utility functions, like the function you’ll use to hash the passwords.
To ensure that user passwords are protected, you’ll need to hash them before they are stored in the database. Let’s implement this
- Create a file in your
utils
folder, this will contain your utility functions. - Import
*
asbcrypt
from'bcrypt'
- Create a function that takes in the raw password and hashes it using
bcrypt
then returns the hashed password. Export the function.
import * as bcrypt from 'bcrypt';
export async function hashPassword(textPassword: string) {
const salt = await bcrypt.genSalt();
const hash = await bcrypt.hash(textPassword, salt);
return hash;
}
There are different ways to use the bcrypt
package, which you can find here.
Now that you have a function to hash our passwords, Implement your first POST
request.
Creating a New User
To create a new user with valid credentials, you’ll need to import the DTO you created earlier into your service.
Service Logic
- Create an
async
functionnewUser
that takes in a parameteruser
which should be the data of the new user. Set its type to the DTO created earlier. - Import the
hashPassword
function. - Inside the
async
function, create a constantpassword
, and assign the return value of awaiting thehashPassword
function withuser.password
as an argument. - Return a new instance of your own version of my
userModel
as an argument pass in an object containing a destructureduser
and thepassword
, and call thesave()
method on it.
import { Injectable } from '@nestjs/common';
import { Model } from 'mongoose';
import { InjectModel } from '@nestjs/mongoose';
import { User, UserDocument } from './schemas/user.schema';
import { newUserDto } from './dto/user.dto';
import { hashPassword } from './utilis/bcrypt.utils';
@Injectable()
export class AuthService {
constructor(
@InjectModel(User.name)
private userModel: Model<UserDocument>,
) {}
async newUser(user: newUserDto): Promise<User> {
const password = await hashPassword(user.password);
return await new this.userModel({ ...user, password }).save();
}
}
The DTO and the validators we set up earlier will continuously check if the data is valid before storing the user data in the database.
Implement the controller logic so that you can test the DTO with some dummy data.
Controller Logic
import {
Controller,
Body,
Post,
} from '@nestjs/common';
import { AuthService } from './auth.service';
import { newUserDto } from './dto/user.dto';
@Controller('users')
export class AuthController {
constructor(private readonly service: AuthService) {}
@Post('signup')
async createUser(
@Body()
user: newUserDto,
): Promise<newUserDto> {
return await this.service.newUser(user);
}
As you can see in the code block above, the user
parameter has a type of newUserDto
. This ensures that all data coming into the application matches the DTO, else it throws an error.
Testing Endpoints
Test the validation with some dummy data by making requests to http://localhost:3000/users/signup
using PostMan or your preferred testing tool.
{
"fullName":"Jon Snow",
"email":"snow@housestark.com",
"password":"password"
}
The JSON data above checks all validation boxes. Hence, you’ll get a 201
response with data, with an _id
property and a hashed password. Copy the _id
property, you’ll need it when testing data validation in PUT
requests.
{
"fullName":"Arya Stark",
"email":"aryathefaceless@housestark",
"password":"password"
}
The JSON data above has an invalid email
property. Hence, you’ll get a 400
response with a message that describes the error. Save it to the database by updating the email
property to aryathefaceless@housestark.com
.
{
"fullName":"Tyrion Lannister",
"email":"tyrion@houselannister.com",
"password":"pass",
"house":"Lannister"
}
The JSON data above has 2 problems, the password is too short (less than 6 characters) and there is an extra field house
.
Increase the length of the password
and send the request again. You’ll get a 200
request with the processed data, but the house
field isn’t stored in the database because it was filtered when it was passed through the validation pipe.
Updating a User
Implement a PUT
route to update user data when required.
Service Logic
async updateUser(id: string, userData: newUserDto): Promise<User> {
return await this.userModel.findByIdAndUpdate(id, userData);
}
Controller Logic
@Put(':id')
async updateuser(
@Param('id')
id: string,
@Body()
user: newUserDto,
): Promise<newUserDto> {
return this.service.updateUser(id, user);
}
Similar to the POST
request, the user
is given a type of newUserDto
. Hence, all data is checked thoroughly before it is saved in the database.
Test this endpoint by updating one of the users stored in your database. Recall that you copied the _id
belonging to Jon Snow.
So make a PUT
request to http://localhost:3000/id
with the following data;
{
"fullName":"Jon Snow",
"email":"snow@housetargaryen.com",
"password":"password"
}
The data above checks all validation boxes, so it would return a 200
response. If any of the data fields contain invalid data, it would return a 400
response and the PUT
request would fail.
Logging Users in
A user-authentication model wouldn't be complete if you couldn't log users in. To implement the logic for that;
Firstly, you’ll need a bcrypt
utility function that will compare the hashed and plain text passwords. Like so,
export async function validatePassword(textPassword: string, hash: string) {
const validUser = await bcrypt.compare(textPassword, hash);
return validUser;
}
Then, you’ll have to create a new DTO for the login data. The DTO is similar to the newUserDto
but without the fullName
property.
import { IsEmail } from 'class-validator';
export class loginUserDto {
@IsEmail({ message: 'Please Enter a Valid Email' })
email: string;
password: string;
}
Notice that no validator was added to the password
property. This is because it could pose a security threat in the future, as it exposes the password length range to malicious users.
Service Logic
async loginUser(loginData: loginUserDto) {
const email = loginData.email;
const user = await this.userModel.findOne({ email });
if (user) {
const valid = await validatePassword(loginData.password, user.password);
if (valid) {
return user;
}
}
return new UnauthorizedException('Invalid Credentials');
}
Your service logic should be able to,
Check if the user exists;
- If the user exists, validate the passwords
- If the user doesn't exist, throw an exception
Validate Passwords;
- If the passwords match, return the user
- If the passwords don’t match, throw an exception
Controller Logic
@Post('login')
async loginUser(
@Body()
loginData: loginUserDto,
) {
return await this.service.loginUser(loginData);
}
Your controller should make the POST
request to http://localhost:3000/users/login
.
Conclusion
You’re finally at the end of this article. Here’s a recap what you’ve covered.
- What a DTO is,
- Differences between a DTO and an interface,
- Structure of a DTO,
- NestJS validation mechanism,
- Making a simple user-authentication model with bcrypt.
That’s quite a lot, congratulations on making it this far.
You can find the code on Github.
Happy Coding!
Top comments (0)