Validar o input/entrada de dados em aplicações é uma das poucas verdades absolutas no mundo da programação. Você simplesmente não pode, jamais, confiar que os dados enviados para sua aplicação processar estarão no formato e tipo corretos que você precisa.
Mesmo que você adicione validação na sua interface ou front-end, dificilmente você conseguirá garantir que, em algum momento, algum usuário descuidado ou malicioso acabe lhe enviando dados fora do padrão que você espera e isso pode ser desastroso.
Quando você pensa em desenvolvimento web então, nem se fala. Existem muitas formas de burlar validações colocadas no front-end, seja através de maneiras simples como desativando javascript ou inspecionando e alterando o HTML da página e muito mais, a formas mais complexas como capturando requisições HTTP do front para o backend e alterando as mesmas (Man in the Middle Attack).
No caso do Node.js, mais especificamente do web framework NestJS, um dos mais usados nesta plataforma, os dados recebidos são provenientes do body, params e querystring, que aceitam apenas texto de qualquer formato. Se você está fazendo web APIs RESTful, esta validação que vou ensinar neste tutorial será a sua primeira e muitas vezes única defesa contra o descuido e as más intenções de quem for utilizar as suas APIs.
Embora você possa fazer todas suas validações manualmente, usando ‘ifs’ e outras práticas básicas de programação, isso geralmente leva a muito código repetitivo, complexo de testar unitariamente e arquiteturas pouco elegantes. Usaremos neste tutorial os pacotes recomendados pelo NestJS e também o design pattern DTO, o qual eu explico abaixo, para validação de dados e espero gerar alguns insights de como você pode levar validação a sério na sua aplicação sem torná-la um espaguete Javascript.
Então vamos lá!
Atenção: Este tutorial requer conhecimento prévio de Node.js, que podem ser obtidos em outros artigos, e também conhecimento básico de NestJS, que pode ser obtido aqui.
#1 – Data Transfer Objects
O padrão Data Transfer Object (DTO), também conhecido simplesmente como “DTO”, é um padrão de design de software utilizado para transferir dados entre diferentes partes de um sistema de software. Ele é frequentemente usado em aplicativos que seguem a arquitetura de camadas, como aplicações web, para separar a lógica de negócios da lógica de apresentação e facilitar a comunicação entre essas camadas.
A ideia por trás do DTO é criar objetos simples que contenham dados que precisam ser transferidos entre componentes do sistema, como entre o servidor e o cliente, ou entre diferentes camadas da aplicação. Os DTOs são usados para encapsular os dados em uma estrutura de objeto que pode ser facilmente serializada e desserializada, tornando a transferência de dados mais eficiente e menos propensa a erros.
Os DTOs geralmente contêm apenas campos de dados e não possuem lógica de negócios associada. Eles são frequentemente usados em situações em que os objetos de domínio (que representam entidades de negócios com lógica associada) não são adequados para serem transmitidos diretamente, devido a questões de desempenho, segurança ou complexidade.
Por exemplo, em uma aplicação web, você pode ter um DTO que representa os detalhes de um usuário, contendo apenas os campos relevantes, como nome, e-mail e número de telefone. Quando um cliente faz uma solicitação para recuperar os detalhes de um usuário, o servidor pode buscar esses detalhes, mapeá-los para um DTO e enviá-los de volta ao cliente. Isso evita que o cliente acesse diretamente o objeto de domínio do usuário, protegendo a integridade dos dados e permitindo a flexibilidade na representação dos dados enviados para o cliente.
Em resumo, o padrão Data Transfer Object (DTO) é uma técnica de design de software que permite a transferência eficiente de dados entre diferentes partes de um sistema, simplificando a serialização e desserialização de dados e isolando a lógica de negócios dos objetos de transferência de dados. No caso do NestJS, usamos esse padrão para definir também as regras de validação dos dados de entrada, como mostrarei a seguir.
#2 – Estruturando o Projeto
Vamos começar de maneira bem simples, crie um projeto NestJS do zero com o comando abaixo.
1 2 3 |
npx @nestjs/cli new dto-validation-example |
Em seguida, instale as dependências que vamos precisar para criação dos DTOs e validação, com o comando abaixo.
1 2 3 |
npm install class-validator class-transformer |
Agora, para que não tenha de se preocupar em codificar uma aplicação do zero, apenas crie uma pasta user dentro de src e crie os seguintes arquivos, que irão fornecer para a gente uma API REST de CRUD de usuários (users). Primeiro, um user.service.ts, inclua o seguinte código, que representa um serviço que faz gestão dos dados de usuário em memória:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 |
import { Injectable, NotFoundException } from '@nestjs/common'; export type User = { id?: number; name: string; age: number; uf: string; } @Injectable() export class UserService { users = []; getUsers() { return this.users; } getUser(id) { return this.users.find(u => u.id == id); } addUser(newUser) { const nextId = this.users.length > 0 ? this.users[this.users.length - 1].id + 1 : 1; newUser.id = nextId; this.users.push(newUser); return newUser; } replaceUser(id, newData) { const index = this.users.findIndex(u => u.id == id); if (index === -1) throw new NotFoundException(); newData.id = id; this.users[index] = newData; return newData; } updateUser(id, newData) { const index = this.users.findIndex(u => u.id == id); if (index === -1) throw new NotFoundException(); const user = this.users[index]; if (newData.name) user.name = newData.name; if (newData.age) user.age = newData.age; if (newData.uf) user.uf = newData.uf; this.users[index] = user; return user; } deleteUser(id) { const index = this.users.findIndex(u => u.id == id); if (index === -1) throw new NotFoundException(); this.users.splice(index, 1); return true; } } |
Depois, crie um app.controller.ts, inclua o seguinte código, que gerencia as rotas do nosso CRUD de usuários e as direciona para o service processar.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
import { Controller, Get, Post, Patch, Put, Delete, Body, Param } from '@nestjs/common'; import { UserService } from './user.service'; @Controller("users") export class UserController { constructor(private readonly userService: UserService) {} @Get() getUsers() { return this.userService.getUsers(); } @Get(":id") getUser(@Param("id") id) { return this.userService.getUser(id); } @Post() addUser(@Body() user) { return this.userService.addUser(user); } @Put(":id") replaceUser(@Param("id") id, @Body() newData) { return this.userService.replaceUser(id, newData); } @Patch(":id") updateUser(@Param("id") id, @Body() newData) { return this.userService.updateUser(id, newData); } @Delete(":id") deleteUser(@Param("id") id) { return this.userService.deleteUser(id); } } |
Repare neste código que eu não tipei nada. Isso não é recomendado, mas mesmo que eu tivesse tipado, não iria garantir que os dados que chegariam pra gente no service estariam com os tipos corretos, já que quando o assunto é web, tudo é passado como string nas requests. Assim, para que possamos de fato ter tipos reais chegando no nosso backend, precisamos fazer um pipeline de transformação. Esse mesmo pipeline de transformação, que segue princípios semelhantes aos middlewares de execução do Express, também terá uma etapa de validação, o que faremos facilmente com decorators.
E por último, crie um user.module.ts com o seguinte conteúdo:
1 2 3 4 5 6 7 8 9 10 11 12 |
import { Module } from '@nestjs/common'; import { UserController } from './user.controller'; import { UserService } from './user.service'; @Module({ imports: [], controllers: [UserController], providers: [UserService], }) export class UserModule {} |
Agora vá no módulo App e importe o módulo User, como abaixo.
1 2 3 4 5 6 7 8 9 10 11 12 13 |
import { Module } from '@nestjs/common'; import { AppController } from './app.controller'; import { AppService } from './app.service'; import { UserModule } from './user/user.module'; @Module({ imports: [UserModule], controllers: [AppController], providers: [AppService], }) export class AppModule {} |
Antes de prosseguir, suba sua aplicação e teste ela no Postman ou Insomnia, para garantir que você entendeu como ela funciona.
#3 – Validation Pipe e Create DTO
Uma vez com o projeto estruturado e funcionando, é hora de criarmos o nosso validation pipe usando o padrão DTO. Para isso, vamos começar configurando a nossa aplicação com um validation pipe global, que funcionará exatamente como se fosse um middleware global do Express. Você faz isso no arquivo main.ts, como abaixo.
1 2 3 4 5 6 7 8 9 10 11 12 |
import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; import { ValidationPipe } from '@nestjs/common'; async function bootstrap() { const app = await NestFactory.create(AppModule); app.useGlobalPipes(new ValidationPipe()) await app.listen(3000); } bootstrap(); |
Isso vai fazer com que todas requisições que chegarem no backend vão passar pelas nossas validações e transformações, que criaremos a seguir. Vamos começar definindo o DTO que vai transformar e validar os dados do body de um POST de novo usuário. Crie uma pasta dto dentro de src/user e nela coloque um create-user.dto.ts como abaixo. Temos várias coisas novas aqui, então vou explicar uma a uma logo mais.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
import { IsInt, Min, IsString, Length } from "class-validator"; export class CreateUserDTO { @IsString() name: string; @IsInt() @Min(18) age: number; @IsString() @Length(2, 2, { message: 'A UF deve conter exatamente duas letras.' }) uf: string; } |
Primeiro, fiz alguns imports que vão ser necessários. Você pode precisar de mais imports, dependendo das validações que quiser fazer no seu DTO.
Segundo, o DTO é uma classe que deve ser exportada, pois usaremos ele em nosso controller. Cada operação que vai receber dados com um schema diferente no body deve ter o seu DTO, esse aqui será para a criação de usuário.
Terceiro, o primeiro campo que vamos transformar e validar é o name, que é do tipo string. Acima do campo name eu usei o decorator @IsString() que valida se o dado recebido no campo name realmente é uma string e não um número, objeto ou boolean por exemplo, disparando um erro caso isso ocorra. Inclusive você pode personalizar a mensagem de erro nos parâmetros do decorator.
Na sequência, é hora de transformar o campo age para number e definir via decorators que ele deve ser validado como integer e que o valor mínimo que ele deve possuir é 18, afinal não queremos cadastrar clientes menores de idade em nossa API (exemplo didático). Isso vai fazer com que não apenas o formato do dado seja validado como o valor do mesmo.
E por último, temos o campo UF que precisamos garantir não apenas que seja uma string, mas que possua exatamente dois caracteres. Poderíamos ir além inclusive, definindo quais os valores possíveis através de uma expressão regular, mas por ora isto já é o suficiente como exemplo.
Agora para usar este nosso DTO é muito simples: vá até o UserController, importe o DTO no topo do arquivo e coloque o mesmo como tipo da variável que vem no body da request da função addUser, como abaixo.
1 2 3 4 5 6 |
@Post() addUser(@Body() user : CreateUserDTO) { return this.userService.addUser(user); } |
Isso vai fazer com que o body da requisição seja validado e transformado com todas as regras do DTO que criamos anteriormente, resultando em erros como abaixo nos testes, caso você não passe um body adequado.
E com isso finalizamos nosso primeiro DTO.
#4 – Transformation Pipe e Replace DTO
Agora que entendemos o básico sobre criação e uso de DTOs, é hora de criarmos os demais. Nosso próximo objetivo é o DTO de atualização completa (substituição) de usuário, que vamos criar em um arquivo replace-user.dto.ts.
1 2 3 4 5 6 7 |
import { CreateUserDTO } from "./create-user.dto"; export class ReplaceUserDTO extends CreateUserDTO { } |
Repare o uso inteligente de orientação à objetos aqui, mais especificamente do conceito de herança. Como a validação e transformação de um replace é idêntica aos tipos de criação de user, eu criei o DTO de replace como sendo filho do DTO de create. Outra opção seria simplesmente utilizar o mesmo DTO, mas isto poderia causar confusão semântica mais tarde, confundindo o programador que fosse olhar o código.
Agora podemos usar o ReplaceUserDTO no body da função de atualização completa (PUT).
1 2 3 4 5 6 |
@Put(":id") replaceUser(@Param("id") id, @Body() newData : ReplaceUserDTO) { return this.userService.replaceUser(id, newData); } |
Com isso resolvemos a necessidade de transformação e validação do corpo da requisição. Mas e aquele “id” perdido ali? Como fazer para transformar e validar ele?
Caso você tenha tentado simplesmente adicionar o tipo number nele, deve ter reparado que isso não funciona de fato. Isso porque apesar de dizer para o TypeScript em tempo de desenvolvimento que ele é um number, na hora que executar o programa, ele continuará recebendo string pois é um parâmetro de URL. Por isso o que precisamos usar aqui é um Transformation Pipe, que pode ser passado como segundo parâmetro do decorator @Param.
1 2 3 4 5 6 |
@Put(":id") replaceUser(@Param("id", ParseIntPipe) id : number, @Body() newData : ReplaceUserDTO) { return this.userService.replaceUser(id, newData); } |
Aqui eu usei o ParseIntPipe, que você precisará importar da @nestjs/common no topo do arquivo. Basicamente o que ele faz é, antes de passar para a variável local id o parâmetro que veio na URL, ele tenta convertê-lo para inteiro, inclusive dando erro caso não seja possível.
Aproveite agora e use este mesmo pipe nas funções de GET e DELETE que esperam um id, como abaixo.
1 2 3 4 5 6 7 8 9 10 11 |
@Get(":id") getUser(@Param("id", ParseIntPipe) id : number) { return this.userService.getUser(id); } @Delete(":id") deleteUser(@Param("id", ParseIntPipe) id: number) { return this.userService.deleteUser(id); } |
#5 – Último DTO
Próximo passo, é criarmos o DTO de atualização parcial (PATCH). Este DTO é praticamente idêntico ao de substituição, no entanto como ele permite a passagem opcional dos campos, temos de ter uma abordagem que permita isso. Para fazer isso da forma mais elegante que conheço, precisaremos instalar mais um pacote no projeto, com o comando abaixo.
1 2 3 |
npm install @nestjs/mapped-types |
Com este pacote instalado, agora você pode criar um update-user.dto.ts como abaixo.
1 2 3 4 5 6 7 8 |
import { CreateUserDTO } from "./create-user.dto"; import { PartialType } from "@nestjs/mapped-types"; export class UpdateUserDTO extends PartialType(CreateUserDTO) { } |
Aqui a abordagem é semelhante a do ReplaceUserDTO, mas ao invés de simplesmente herdar de CreateUserDTO, nós usamos a função PartialType do pacote Mapped Types que instalamos agora há pouco. Essa função permite que todos os campos do CreateUserDTO sejam opcionais neste novo DTO que estamos criando.
Para usar este DTO, nenhuma novidade, apenas combinação do que vimos até aqui.
1 2 3 4 5 6 |
@Patch(":id") updateUser(@Param("id", ParseIntPipe) id : number, @Body() newData: UpdateUserDTO) { return this.userService.updateUser(id, newData); } |
E com isso temos toda nossa API devidamente validada e transformada para os tipos que esperamos no backend. Também podemos finalizar a tipagem ajustando nosso User Service de acordo.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 |
import { Injectable, NotFoundException } from '@nestjs/common'; import { CreateUserDTO } from './dto/create-user.dto'; import { UpdateUserDTO } from './dto/update-user.dto'; import { ReplaceUserDTO } from './dto/replace-user.dto'; export type User = { id?: number; name: string; age: number; uf: string; } @Injectable() export class UserService { users: User[] = []; getUsers(): User[] { return this.users; } getUser(id: number): User { return this.users.find(u => u.id === id); } addUser(newUser: CreateUserDTO): User { const nextId = this.users.length > 0 ? this.users[this.users.length - 1].id + 1 : 1; const userWithId: User = { ...newUser }; userWithId.id = nextId; this.users.push(userWithId); return userWithId; } replaceUser(id: number, newData: ReplaceUserDTO): User { const index = this.users.findIndex(u => u.id == id); if (index === -1) throw new NotFoundException(); const userWithId: User = { ...newData }; userWithId.id = id; this.users[index] = userWithId; return userWithId; } updateUser(id: number, newData: UpdateUserDTO): User { const index = this.users.findIndex(u => u.id == id); if (index === -1) throw new NotFoundException(); const user = this.users[index]; if (newData.name) user.name = newData.name; if (newData.age) user.age = newData.age; if (newData.uf) user.uf = newData.uf; this.users[index] = user; return user; } deleteUser(id: number): boolean { const index = this.users.findIndex(u => u.id == id); if (index === -1) throw new NotFoundException(); this.users.splice(index, 1); return true; } } |
Espero que tenha gostado do tutorial, até a próxima!
Olá, tudo bem?
O que você achou deste conteúdo? Conte nos comentários.
Meus parabéns.
Continue com essa didáctica, e como resultado garantido terás impulsionado milhões a volta do planeta.
Sou prova disto.
Fico feliz que tenha gostado e agradeço a gentileza do comentário.