Aula 6: API REST com NestJS
A aula 4 definiu o contrato: recursos, verbos, status, formato de erro, paginação. A aula 5 montou a estrutura: módulos, controllers, providers. Esta aula junta as duas — implementa o contrato dentro da estrutura.
Ao fim da aula, a API de livros deixa de ter duas rotas de leitura e passa a ter um recurso completo: cinco operações, entrada validada, erros padronizados, listagem paginada e documentação gerada a partir do próprio código.
| O que vem depois | Onde |
|---|---|
| Persistência com ORM e banco relacional | Aula 7 — Acesso a bases de dados |
| Autenticação, guards e proteção de rotas | Aula 8 — Autenticação e autorização |
| Consumo desta mesma API pelo cliente web | Módulo 3 — Next.js |
Objetivos
Ao final desta aula, você deve ser capaz de:
- Traduzir um contrato REST em rotas, verbos e códigos de status no NestJS.
- Definir DTOs de entrada com
class-validatore explicar por que precisam ser classes. - Configurar o
ValidationPipeglobal e descrever o efeito dewhitelist,forbidNonWhitelistedetransform. - Implementar um CRUD completo com regras de unicidade e mapeamento correto de erros.
- Padronizar o corpo de erro da API com um exception filter.
- Implementar paginação e filtros com parâmetros de consulta validados.
- Controlar o que a API expõe na saída, separando representação de modelo interno.
- Documentar a API com OpenAPI e testá-la pelo Swagger UI.
Ambiente sugerido
Esta aula continua o projeto do laboratório 5. As dependências novas são:
npm i class-validator class-transformer
npm i @nestjs/swagger
| Pacote | Papel |
|---|---|
class-validator | Decorators de regra (@IsInt, @IsNotEmpty, ...) |
class-transformer | Converte o JSON cru numa instância da classe do DTO |
@nestjs/swagger | Gera o documento OpenAPI e serve o Swagger UI |
Parte 1 — Do contrato ao código
O recurso livros já existe. Ele agora ganha o contrato completo definido na
aula 4:
| Operação | Verbo | Rota | Sucesso | Erros |
|---|---|---|---|---|
| Listar (paginado, filtrável) | GET | /livros | 200 | 400 |
| Buscar por id | GET | /livros/:id | 200 | 400, 404 |
| Criar | POST | /livros | 201 | 400, 409 |
| Atualizar parcialmente | PATCH | /livros/:id | 200 | 400, 404, 409 |
| Remover | DELETE | /livros/:id | 204 | 400, 404 |
Duas escolhas dessa tabela merecem justificativa, porque são as que os alunos mais invertem:
PATCH, nãoPUT.PUTsubstitui o recurso inteiro: os campos omitidos passam a valernull.PATCHaltera só o que veio. Para formulários de edição parcial — o caso comum em web e mobile —PATCHé o correto.204, não200, noDELETE. Não há representação a devolver depois de remover. Devolver200com um corpo do tipo"removido com sucesso"obriga o cliente a interpretar texto quando o status já disse tudo.
E uma decisão nova, que a aula 4 antecipou e agora vira código:
| Situação | Status | Por quê |
|---|---|---|
| ISBN já cadastrado | 409 Conflict | A requisição está bem formada; o conflito é com o estado atual do recurso |
| ISBN com formato inválido | 400 Bad Request | A requisição está malformada; não chega a ser avaliada |
Parte 2 — DTOs: a fronteira entre a rede e o código tipado
Um DTO (Data Transfer Object) descreve a forma de um dado que atravessa a fronteira da aplicação. No NestJS ele cumpre três papéis ao mesmo tempo: define o tipo, carrega as regras de validação e documenta o contrato.
import { ApiProperty } from '@nestjs/swagger';
import { IsInt, IsISBN, IsNotEmpty, IsString, Max, MaxLength, Min } from 'class-validator';
export class CriarLivroDto {
@ApiProperty({ example: 'Dom Casmurro', maxLength: 200 })
@IsString()
@IsNotEmpty({ message: 'o título é obrigatório' })
@MaxLength(200)
titulo!: string;
@ApiProperty({ example: 'Machado de Assis', maxLength: 120 })
@IsString()
@IsNotEmpty()
@MaxLength(120)
autor!: string;
@ApiProperty({ example: '9788525406958' })
@IsISBN()
isbn!: string;
@ApiProperty({ example: 1899, minimum: 1450 })
@IsInt()
@Min(1450)
@Max(2100)
ano!: number;
}
Por que uma classe, e não uma interface
Na aula 5 o modelo Livro era uma interface. O DTO não pode ser: uma interface
desaparece na transpilação, e um decorator não tem onde se pendurar. A classe
sobrevive em tempo de execução, e é isso que permite ao class-validator
descobrir, em plena requisição, quais regras aplicar.
Escrever dto: CriarLivroDto na assinatura do método não garante nada em
tempo de execução — esse tipo é apagado pelo compilador (aula 3, Parte 1). Sem o
ValidationPipe, o método receberia literalmente o que o cliente mandou,
incluindo ano: "mil e oitocentos". O tipo é garantia de compilação; o decorator
é garantia de execução. A API precisa das duas.
O DTO de atualização
Como PATCH aceita qualquer subconjunto dos campos, o DTO de atualização é o de
criação com todos os campos opcionais. Existe um utilitário para isso:
import { PartialType } from '@nestjs/swagger';
import { CriarLivroDto } from './criar-livro.dto';
export class AtualizarLivroDto extends PartialType(CriarLivroDto) {}
PartialType de @nestjs/swaggerExiste um PartialType em @nestjs/mapped-types que também funciona — e que faz
o DTO derivado perder os metadados de @ApiProperty, deixando o corpo do PATCH
vazio na documentação. Quando o projeto usa Swagger, importe sempre de
@nestjs/swagger: essa versão preserva as duas famílias de metadados.
Parte 3 — O ValidationPipe global
Declarar as regras não basta; é preciso ligar quem as executa. Fazer isso uma vez no bootstrap vale para todos os DTOs da aplicação:
import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
transformOptions: { enableImplicitConversion: true },
}),
);
await app.listen(3000);
}
void bootstrap();
| Opção | Efeito | Por que ligar |
|---|---|---|
whitelist | Remove do objeto os campos que o DTO não declara | Impede mass assignment: um cliente não consegue gravar admin: true só porque mandou o campo |
forbidNonWhitelisted | Em vez de remover em silêncio, responde 400 | Erro de digitação no cliente vira erro visível, não dado perdido |
transform | Entrega uma instância do DTO, não um objeto avulso | Métodos e valores padrão da classe passam a funcionar |
enableImplicitConversion | Converte tipos primitivos pela anotação do DTO | ?pagina=2 chega como número, sem @Type() em cada campo |
O resultado é um 400 com todas as mensagens de uma vez, e não só a primeira:
{
"message": [
"o título é obrigatório",
"isbn must be an ISBN",
"ano must not be less than 1450"
],
"error": "Bad Request",
"statusCode": 400
}
Devolver a lista completa não é detalhe: é o que permite ao formulário do cliente web ou mobile marcar todos os campos inválidos de uma vez, em vez de fazer o usuário descobrir um erro por submissão.
class-validator gera mensagens em inglês por padrão. Você pode sobrescrevê-las
caso a caso, como em @IsNotEmpty({ message: 'o título é obrigatório' }). Vale a
pena para os campos que o usuário final vê; para os demais, o texto padrão
costuma bastar durante o desenvolvimento.
Parte 4 — O CRUD completo
O provider de dados
import { Injectable } from '@nestjs/common';
import { Livro } from './livro.model';
@Injectable()
export class LivrosStore {
private readonly livros: Livro[] = [
{ id: 1, titulo: 'Dom Casmurro', autor: 'Machado de Assis', isbn: '9788525406958', ano: 1899 },
{ id: 2, titulo: 'A Hora da Estrela', autor: 'Clarice Lispector', isbn: '9788520925829', ano: 1977 },
];
private proximoId = 3;
todos(): Livro[] {
return this.livros;
}
porId(id: number): Livro | undefined {
return this.livros.find((livro) => livro.id === id);
}
porIsbn(isbn: string): Livro | undefined {
return this.livros.find((livro) => livro.isbn === isbn);
}
inserir(dados: Omit<Livro, 'id'>): Livro {
const novo: Livro = { id: this.proximoId++, ...dados };
this.livros.push(novo);
return novo;
}
substituir(livro: Livro): Livro {
const indice = this.livros.findIndex((item) => item.id === livro.id);
this.livros[indice] = livro;
return livro;
}
remover(id: number): boolean {
const indice = this.livros.findIndex((item) => item.id === id);
if (indice === -1) {
return false;
}
this.livros.splice(indice, 1);
return true;
}
}
Omit<Livro, 'id'> é um utility type da aula 3: descreve "um livro sem o
campo id", que é exatamente o que existe antes da inserção. Sem ele, seria
preciso declarar um segundo tipo quase idêntico.
O service
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { CriarLivroDto } from './dto/criar-livro.dto';
import { AtualizarLivroDto } from './dto/atualizar-livro.dto';
import { ConsultarLivrosDto } from './dto/consultar-livros.dto';
import { LivrosStore } from './livros-store';
import { Livro } from './livro.model';
@Injectable()
export class LivrosService {
constructor(private readonly store: LivrosStore) {}
listar(consulta: ConsultarLivrosDto) {
const { autor, pagina, tamanho } = consulta;
let resultado = this.store.todos();
if (autor) {
const alvo = autor.toLowerCase();
resultado = resultado.filter((livro) => livro.autor.toLowerCase().includes(alvo));
}
const total = resultado.length;
const inicio = (pagina - 1) * tamanho;
return {
dados: resultado.slice(inicio, inicio + tamanho),
pagina,
tamanho,
total,
totalDePaginas: Math.ceil(total / tamanho) || 1,
};
}
buscarPorId(id: number): Livro {
const livro = this.store.porId(id);
if (!livro) {
throw new NotFoundException(`Livro ${id} não encontrado`);
}
return livro;
}
criar(dto: CriarLivroDto): Livro {
this.garantirIsbnInedito(dto.isbn);
return this.store.inserir(dto);
}
atualizar(id: number, dto: AtualizarLivroDto): Livro {
const atual = this.buscarPorId(id);
if (dto.isbn && dto.isbn !== atual.isbn) {
this.garantirIsbnInedito(dto.isbn);
}
return this.store.substituir({ ...atual, ...dto });
}
remover(id: number): void {
const removido = this.store.remover(id);
if (!removido) {
throw new NotFoundException(`Livro ${id} não encontrado`);
}
}
private garantirIsbnInedito(isbn: string): void {
if (this.store.porIsbn(isbn)) {
throw new ConflictException(`ISBN ${isbn} já cadastrado`);
}
}
}
Três detalhes que separam este código de uma versão ingênua:
atualizarchamabuscarPorId, que já lança404. A verificação de existência não é duplicada.- A checagem de ISBN só roda se o ISBN mudou — sem isso, atualizar o título de um livro dispararia
409contra ele mesmo. { ...atual, ...dto }implementa a semântica dePATCH: o que não veio no corpo permanece como estava.
O controller
import {
Body, Controller, Delete, Get, HttpCode, HttpStatus,
Param, ParseIntPipe, Patch, Post, Query,
} from '@nestjs/common';
import {
ApiConflictResponse, ApiCreatedResponse, ApiNoContentResponse,
ApiNotFoundResponse, ApiOkResponse, ApiOperation, ApiTags,
} from '@nestjs/swagger';
import { LivrosService } from './livros.service';
import { CriarLivroDto } from './dto/criar-livro.dto';
import { AtualizarLivroDto } from './dto/atualizar-livro.dto';
import { ConsultarLivrosDto } from './dto/consultar-livros.dto';
@ApiTags('livros')
@Controller('livros')
export class LivrosController {
constructor(private readonly livrosService: LivrosService) {}
@Get()
@ApiOperation({ summary: 'Lista livros com filtro e paginação' })
@ApiOkResponse({ description: 'Página de resultados' })
listar(@Query() consulta: ConsultarLivrosDto) {
return this.livrosService.listar(consulta);
}
@Get(':id')
@ApiOperation({ summary: 'Busca um livro pelo identificador' })
@ApiOkResponse({ description: 'Livro encontrado' })
@ApiNotFoundResponse({ description: 'Livro não encontrado' })
buscarPorId(@Param('id', ParseIntPipe) id: number) {
return this.livrosService.buscarPorId(id);
}
@Post()
@ApiOperation({ summary: 'Cadastra um livro' })
@ApiCreatedResponse({ description: 'Livro cadastrado' })
@ApiConflictResponse({ description: 'ISBN já cadastrado' })
criar(@Body() dto: CriarLivroDto) {
return this.livrosService.criar(dto);
}
@Patch(':id')
@ApiOperation({ summary: 'Altera parcialmente um livro' })
@ApiOkResponse({ description: 'Livro atualizado' })
@ApiNotFoundResponse({ description: 'Livro não encontrado' })
@ApiConflictResponse({ description: 'ISBN já cadastrado' })
atualizar(
@Param('id', ParseIntPipe) id: number,
@Body() dto: AtualizarLivroDto,
) {
return this.livrosService.atualizar(id, dto);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Remove um livro' })
@ApiNoContentResponse({ description: 'Livro removido' })
@ApiNotFoundResponse({ description: 'Livro não encontrado' })
remover(@Param('id', ParseIntPipe) id: number): void {
this.livrosService.remover(id);
}
}
O controller inteiro não tem um único if. Cada método faz três coisas: declara
o contrato nos decorators, extrai os argumentos e delega. Quando um controller
começa a ganhar lógica, é sinal de que uma regra escapou do service.
Parte 5 — Filtros e paginação
Parâmetros de consulta também são entrada não confiável e merecem o mesmo tratamento do corpo — inclusive porque chegam sempre como texto:
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
export class ConsultarLivrosDto {
@ApiPropertyOptional({ description: 'Filtra por trecho do nome do autor' })
@IsOptional()
@IsString()
autor?: string;
@ApiPropertyOptional({ default: 1, minimum: 1 })
@IsOptional()
@IsInt()
@Min(1)
pagina: number = 1;
@ApiPropertyOptional({ default: 20, minimum: 1, maximum: 100 })
@IsOptional()
@IsInt()
@Min(1)
@Max(100)
tamanho: number = 20;
}
Três decisões embutidas aí:
- Valores padrão na própria classe. Como
transform: trueentrega uma instância,paginaetamanhojá chegam preenchidos quando o cliente não os informa. Semtransform, o valor padrão nunca seria aplicado. - Teto em
tamanho. Sem@Max(100), um cliente pediria?tamanho=1000000e derrubaria o serviço — o problema que a aula 4 chamou de "listagem sem paginação", só que disfarçado. - Envelope na resposta. Uma lista paginada precisa devolver os metadados junto, senão o cliente não tem como desenhar a paginação.
{
"dados": [ { "id": 1, "titulo": "Dom Casmurro", "autor": "Machado de Assis", "ano": 1899 } ],
"pagina": 1,
"tamanho": 20,
"total": 1,
"totalDePaginas": 1
}
GET /livros/:id devolve o objeto direto, sem envelope. GET /livros devolve o
envelope porque precisa carregar informação que não cabe na lista — total e
página atual. A regra prática: envelope quando há metadados a transportar, objeto
puro quando não há.
Parte 6 — Padronizando o corpo de erro
O formato de erro padrão do NestJS é utilizável, mas tem duas inconsistências: o
campo message às vezes é uma string e às vezes é um array, e o corpo não segue
o formato da RFC 9457 discutido na
aula 4. Um exception filter global resolve os dois de uma vez.
import {
ArgumentsHost, Catch, ExceptionFilter, HttpException, HttpStatus, Logger,
} from '@nestjs/common';
import { Request, Response } from 'express';
@Catch()
export class FiltroDeExcecoes implements ExceptionFilter {
private readonly logger = new Logger(FiltroDeExcecoes.name);
catch(excecao: unknown, host: ArgumentsHost): void {
const contexto = host.switchToHttp();
const resposta = contexto.getResponse<Response>();
const requisicao = contexto.getRequest<Request>();
const status =
excecao instanceof HttpException
? excecao.getStatus()
: HttpStatus.INTERNAL_SERVER_ERROR;
const corpoOriginal =
excecao instanceof HttpException ? excecao.getResponse() : null;
// `message` chega como string nos erros de negócio e como array nos de
// validação. O contrato da API normaliza os dois para array.
// Em 5xx o detalhe nunca vai para o cliente: só a mensagem genérica.
const detalhes =
status >= HttpStatus.INTERNAL_SERVER_ERROR
? 'Erro interno do servidor'
: typeof corpoOriginal === 'object' &&
corpoOriginal !== null &&
'message' in corpoOriginal
? (corpoOriginal as { message: string | string[] }).message
: String(corpoOriginal ?? 'Erro');
if (status >= HttpStatus.INTERNAL_SERVER_ERROR) {
this.logger.error(
`${requisicao.method} ${requisicao.url}`,
excecao instanceof Error ? excecao.stack : String(excecao),
);
}
resposta.status(status).json({
status,
titulo: HttpStatus[status],
detalhes: Array.isArray(detalhes) ? detalhes : [detalhes],
caminho: requisicao.url,
momento: new Date().toISOString(),
});
}
}
Registre-o globalmente:
app.useGlobalFilters(new FiltroDeExcecoes());
Agora todo erro da API tem a mesma forma, seja de validação, de negócio ou inesperado:
{
"status": 409,
"titulo": "CONFLICT",
"detalhes": ["ISBN 9788525406958 já cadastrado"],
"caminho": "/livros",
"momento": "2026-08-02T14:32:10.417Z"
}
@Catch() sem argumento captura tudo, inclusive erros inesperados — falha de
conexão, undefined onde não devia. Repassar a mensagem original desses erros
vaza caminho de arquivo, versão de biblioteca e às vezes trecho de consulta SQL.
Por isso o filtro acima registra o detalhe no log do servidor e devolve ao cliente
apenas "Erro interno do servidor". O usuário não perde nada; o atacante, sim.
Parte 7 — O que a API expõe
Até aqui, o objeto devolvido é o mesmo objeto interno. Isso funciona enquanto os dois coincidirem — e para de funcionar no dia em que o modelo ganhar um campo que não deve sair, como uma anotação interna ou o histórico de alterações.
O class-transformer permite marcar o que não sai:
import { Exclude } from 'class-transformer';
export class LivroEntity {
id!: number;
titulo!: string;
autor!: string;
isbn!: string;
ano!: number;
@Exclude()
observacaoInterna?: string;
constructor(parcial: Partial<LivroEntity>) {
Object.assign(this, parcial);
}
}
Com o ClassSerializerInterceptor ativo, o campo marcado some da resposta:
import { ClassSerializerInterceptor } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector)));
Repare em onde isso acontece: o interceptor age no caminho de saída do pipeline (aula 5, Parte 7), depois do handler. É por isso que o service pode continuar devolvendo o objeto completo sem se preocupar com o que é público.
"Nunca exponha o modelo interno" é a formulação conceitual; @Exclude() é uma das
implementações. A outra — mais explícita e à prova de esquecimento — é montar um
objeto de resposta campo a campo. A primeira é mais econômica, a segunda é mais
segura: um campo novo no modelo entra automaticamente na resposta com
@Exclude(), e não entra na montagem manual.
Parte 8 — OpenAPI e Swagger UI
Os decorators @ApiTags, @ApiOperation e @ApiOkResponse que já estão no
controller não fazem nada sozinhos. Falta montar o documento:
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
const config = new DocumentBuilder()
.setTitle('API da Biblioteca')
.setDescription('Serviço de acervo consumido pelos clientes web e mobile')
.setVersion('1.0.0')
.addTag('livros')
.build();
const documento = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('docs', app, documento);
O Swagger UI passa a responder em http://localhost:3000/docs, e o documento
OpenAPI bruto em http://localhost:3000/docs-json.
O ganho não é a página bonita — é o documento. A partir dele dá para gerar cliente tipado para TypeScript e para Dart, montar testes de contrato e alimentar ferramentas de mock. Como o documento é derivado dos DTOs e dos decorators, ele não envelhece à parte do código: acrescentar um campo ao DTO o atualiza sozinho.
Sem configuração extra, cada propriedade precisa de @ApiProperty para aparecer
na documentação. O plugin do Swagger para o Nest CLI
lê os tipos do TypeScript e infere boa parte disso sozinho. Vale ativar depois que
você tiver escrito os decorators à mão pelo menos uma vez — entender o que o
plugin automatiza é o que permite depurá-lo quando ele erra.
Erros comuns
| Erro | Sintoma | Correção |
|---|---|---|
DTO declarado como interface | Validação nunca roda; qualquer corpo passa | Usar class |
Esquecer useGlobalPipes | O mesmo sintoma, com o DTO correto | Registrar o ValidationPipe no bootstrap |
PartialType importado de @nestjs/mapped-types | Corpo do PATCH vazio no Swagger | Importar de @nestjs/swagger |
| Validar ISBN duplicado sem checar se ele mudou | 409 ao editar o título de um livro | Comparar com o valor atual antes |
Usar PUT para edição parcial | Campos omitidos são apagados | Usar PATCH |
DELETE devolvendo 200 com corpo | Cliente precisa interpretar texto | @HttpCode(HttpStatus.NO_CONTENT) |
Listagem sem teto de tamanho | Uma requisição derruba o serviço | @Max(100) no DTO de consulta |
Repassar a exceção original em erros 5xx | Vazamento de caminho de arquivo e detalhe interno | Registrar no log, devolver mensagem genérica |
@Query() sem DTO | Parâmetros chegam como texto e não são validados | Criar o DTO de consulta |
| Devolver o objeto interno direto | Campo sensível vaza no dia em que for criado | @Exclude() ou montagem explícita |
Laboratório 6 — O recurso completo
Continuação direta do laboratório 5. Ao final, a API de livros terá as cinco operações, entrada validada, erros padronizados e documentação navegável.
Passo 1 — Instalar as dependências
npm i class-validator class-transformer
npm i @nestjs/swagger
Passo 2 — Acrescentar o ISBN ao modelo
export interface Livro {
id: number;
titulo: string;
autor: string;
isbn: string;
ano: number;
}
Atualize também os dados iniciais do LivrosStore para incluir o campo — use os
ISBN do exemplo da Parte 4, que são válidos e passam pelo @IsISBN().
Passo 3 — Criar os DTOs
mkdir -p src/livros/dto
Crie os três arquivos das Partes 2 e 5:
src/livros/dto/criar-livro.dto.tssrc/livros/dto/atualizar-livro.dto.tssrc/livros/dto/consultar-livros.dto.ts
Escreva o DTO de criação do seu recurso principal. Para cada campo, pergunte:
qual é a menor regra que impede um dado inválido de entrar? Um campo de texto
livre quase sempre precisa de @MaxLength — sem ele, alguém acaba gravando um
parágrafo inteiro onde deveria haver um nome.
Passo 4 — Ativar o ValidationPipe
Edite src/main.ts conforme a Parte 3 e confirme que a aplicação reinicia sem
erro.
Nada muda no comportamento ainda, porque nenhuma rota recebe corpo. O efeito
aparece no passo 9, quando o POST existir: é o ValidationPipe que fará a
diferença entre aceitar qualquer coisa e recusar com 400.
Passo 5 — Completar o LivrosStore
Acrescente porIsbn, inserir, substituir e remover, conforme a Parte 4.
Passo 6 — Completar o LivrosService
Implemente listar, criar, atualizar e remover conforme a Parte 4.
Escreva o método garantirIsbnInedito por último e observe que ele é chamado de
dois lugares — é a razão de ele ser um método privado, e não código repetido.
Passo 7 — Completar o LivrosController
Implemente os cinco métodos conforme a Parte 4. Preste atenção em dois pontos:
@HttpCode(HttpStatus.NO_CONTENT)noDELETE;- a rota
@Get(':id')deve vir depois de@Get(), e depois de qualquer rota fixa que você venha a criar.
Passo 8 — Testar o caminho feliz
- curl
- Swagger UI
# criar — 201
curl -i -X POST http://localhost:3000/livros \
-H 'Content-Type: application/json' \
-d '{"titulo":"Vidas Secas","autor":"Graciliano Ramos","isbn":"9788503012348","ano":1938}'
# listar — 200, com envelope de paginação
curl -i http://localhost:3000/livros
# filtrar e paginar — 200
curl -i "http://localhost:3000/livros?autor=machado&pagina=1&tamanho=5"
# atualizar parcialmente — 200
curl -i -X PATCH http://localhost:3000/livros/1 \
-H 'Content-Type: application/json' \
-d '{"ano":1900}'
# remover — 204, sem corpo
curl -i -X DELETE http://localhost:3000/livros/1
Depois do passo 10, abra http://localhost:3000/docs e use o botão Try it
out de cada operação. A vantagem sobre o curl é que o Swagger já preenche o
corpo com os exemplos declarados em @ApiProperty, o que reduz erro de digitação.
A desvantagem é que ele esconde os cabeçalhos da requisição. Faça pelo menos uma
rodada com curl -i para ver o status cru — é o que você vai precisar
interpretar quando o cliente mobile falhar.
Confirme, em cada resposta, que o status corresponde ao da tabela da Parte 1.
Passo 9 — Testar os erros
Estes são obrigatórios. Cada um exercita uma regra diferente:
# 1. campo faltando e tipo errado — 400 com várias mensagens
curl -i -X POST http://localhost:3000/livros \
-H 'Content-Type: application/json' \
-d '{"titulo":"","autor":"X","isbn":"nao-e-isbn","ano":"mil"}'
# 2. campo não declarado no DTO — 400 (efeito de forbidNonWhitelisted)
curl -i -X POST http://localhost:3000/livros \
-H 'Content-Type: application/json' \
-d '{"titulo":"T","autor":"A","isbn":"9788503012348","ano":1938,"admin":true}'
# 3. ISBN repetido — 409
# (rode duas vezes seguidas o mesmo POST válido)
# 4. id inexistente — 404
curl -i http://localhost:3000/livros/9999
# 5. id não numérico — 400 (efeito do ParseIntPipe)
curl -i http://localhost:3000/livros/abc
# 6. tamanho de página acima do teto — 400
curl -i "http://localhost:3000/livros?tamanho=99999"
Se ele devolver 201 em vez de 400, forbidNonWhitelisted não está ativo — e
a sua API está aceitando campos arbitrários. Hoje isso é inofensivo porque o
Store ignora o excedente; na aula 7, com o Prisma gravando o objeto, o mesmo
descuido vira falha de segurança.
Passo 10 — Padronizar os erros e publicar a documentação
- Crie
src/comum/filtros/http-exception.filter.tsconforme a Parte 6. - Registre o filtro e o Swagger em
src/main.ts(Partes 6 e 8). - Repita os seis testes do passo 9 e confirme que todos devolvem o mesmo formato de corpo.
- Abra
http://localhost:3000/docse confira que as cinco operações aparecem, com os exemplos dos DTOs.
Passo 11 — Verificar a documentação do PATCH
No Swagger UI, expanda o PATCH /livros/:id e confirme que o corpo mostra os
quatro campos como opcionais. Se aparecer vazio, PartialType foi importado de
@nestjs/mapped-types — troque para @nestjs/swagger e recarregue.
Esse é um bom exemplo de defeito que não quebra a aplicação: o PATCH
continua funcionando, só a documentação fica errada. E documentação errada é pior
do que documentação ausente, porque o cliente confia nela.
Critérios de conclusão
- as cinco operações respondem com os status da tabela da Parte 1;
- corpo inválido devolve
400com todas as mensagens, não só a primeira; - campo não declarado devolve
400; - ISBN repetido devolve
409, e editar o título de um livro não devolve409; -
DELETEdevolve204sem corpo; - a listagem devolve envelope com
total,paginaetamanho, e respeita o teto; - todos os erros têm o mesmo formato de corpo;
-
/docsmostra as cinco operações, e o corpo doPATCHnão está vazio; - o controller não contém nenhum
if; - o mesmo conjunto existe no seu projeto, com o seu recurso principal.
Fechamento
Esta aula fechou o primeiro ciclo completo de backend: da estrutura vazia da aula 5 a um recurso com contrato explícito, entrada validada, erros padronizados e documentação gerada do próprio código.
O que sustenta esse resultado é a separação que a aula 5 montou. Repare que a regra de negócio nunca soube o que é uma requisição HTTP, e o controller nunca soube o que é um livro válido. Na aula 7, essa separação será submetida ao teste que importa: o array em memória vira PostgreSQL, e o contrato publicado aqui — rotas, DTOs, status — não pode mudar, porque os clientes web e mobile já dependem dele.
Exercícios (Checkpoints)
-
Justifique a escolha de verbo e status para cada operação da tabela da Parte 1. Em seguida, descreva o que aconteceria com um formulário de edição parcial se a API usasse
PUTem vez dePATCH. -
Explique por que um DTO precisa ser uma classe, recorrendo ao que a aula 3 mostrou sobre o que sobrevive à transpilação. Descreva o comportamento observável da API se alguém trocar
classporinterfaceno DTO de criação. -
Sobre o
ValidationPipe:a. Descreva o efeito de cada opção (
whitelist,forbidNonWhitelisted,transform) em termos do que o cliente observa.b. Explique por que os valores padrão de
paginaetamanhodeixam de funcionar setransformforfalse. -
Diagnostique: um
PATCHque altera apenas o título de um livro está devolvendo409 Conflict. Aponte a causa provável no service e escreva a correção. -
Compare o formato de erro padrão do NestJS com o produzido pelo filtro da Parte 6. Cite duas vantagens concretas do segundo para quem escreve o cliente mobile.
-
Analise o risco de um
@Catch()que repassa ao cliente a mensagem da exceção original. Descreva um cenário em que isso entrega ao atacante informação útil. -
Implemente no laboratório uma rota
GET /livros/isbn/:isbnque busque por ISBN e devolva404se não existir. Indique onde ela deve ser declarada em relação a@Get(':id')e justifique. -
Projete o DTO de consulta de um recurso do seu projeto com pelo menos um filtro, paginação e ordenação. Para cada campo, indique a validação escolhida e o que ela impede.
-
Explique por que a coleção devolve envelope e o item devolve o objeto puro, e avalie se a mesma decisão vale para um endpoint que devolve um único registro com metadados de cache.
-
Compare as duas formas de controlar a saída —
@Exclude()e montagem explícita do objeto de resposta — quanto ao que acontece quando um campo novo é acrescentado ao modelo meses depois.
Referências
Principais
- NestJS — Validation —
ValidationPipe, DTOs e opções - NestJS — Exception filters — filtros globais e exceções embutidas
- NestJS — OpenAPI — geração do documento e Swagger UI
- NestJS — Serialization —
ClassSerializerInterceptore@Exclude() - class-validator — lista de decorators — referência de consulta
Aprofundamento
- RFC 9457 — Problem Details for HTTP APIs — formato padronizado de erro
- NestJS — Mapped types —
PartialType,PickType,OmitType - NestJS — CLI plugin do Swagger — inferência automática de
@ApiProperty - NestJS — Pipes — pipes embutidos e pipes personalizados
- class-transformer — transformação e serialização de objetos