Pular para o conteúdo principal

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 depoisOnde
Persistência com ORM e banco relacionalAula 7 — Acesso a bases de dados
Autenticação, guards e proteção de rotasAula 8 — Autenticação e autorização
Consumo desta mesma API pelo cliente webMó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-validator e explicar por que precisam ser classes.
  • Configurar o ValidationPipe global e descrever o efeito de whitelist, forbidNonWhitelisted e transform.
  • 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
PacotePapel
class-validatorDecorators de regra (@IsInt, @IsNotEmpty, ...)
class-transformerConverte o JSON cru numa instância da classe do DTO
@nestjs/swaggerGera 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çãoVerboRotaSucessoErros
Listar (paginado, filtrável)GET/livros200400
Buscar por idGET/livros/:id200400, 404
CriarPOST/livros201400, 409
Atualizar parcialmentePATCH/livros/:id200400, 404, 409
RemoverDELETE/livros/:id204400, 404

Duas escolhas dessa tabela merecem justificativa, porque são as que os alunos mais invertem:

  • PATCH, não PUT. PUT substitui o recurso inteiro: os campos omitidos passam a valer null. PATCH altera só o que veio. Para formulários de edição parcial — o caso comum em web e mobile — PATCH é o correto.
  • 204, não 200, no DELETE. Não há representação a devolver depois de remover. Devolver 200 com 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çãoStatusPor quê
ISBN já cadastrado409 ConflictA requisição está bem formada; o conflito é com o estado atual do recurso
ISBN com formato inválido400 Bad RequestA 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.

src/livros/dto/criar-livro.dto.ts
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.

Do corpo JSON à instância validada: o ValidationPipe transforma o objeto avulso em instância do DTO, executa os decorators de cada campo e descarta o que não foi declarado; se alguma regra falhar, a resposta é 400 com todas as mensagens
Tipo não valida dado externo

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:

src/livros/dto/atualizar-livro.dto.ts
import { PartialType } from '@nestjs/swagger';
import { CriarLivroDto } from './criar-livro.dto';

export class AtualizarLivroDto extends PartialType(CriarLivroDto) {}
Importe PartialType de @nestjs/swagger

Existe 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:

src/main.ts
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çãoEfeitoPor que ligar
whitelistRemove do objeto os campos que o DTO não declaraImpede mass assignment: um cliente não consegue gravar admin: true só porque mandou o campo
forbidNonWhitelistedEm vez de remover em silêncio, responde 400Erro de digitação no cliente vira erro visível, não dado perdido
transformEntrega uma instância do DTO, não um objeto avulsoMétodos e valores padrão da classe passam a funcionar
enableImplicitConversionConverte 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.

Mensagens em português

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

src/livros/livros-store.ts
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

src/livros/livros.service.ts
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:

  • atualizar chama buscarPorId, que já lança 404. 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 409 contra ele mesmo.
  • { ...atual, ...dto } implementa a semântica de PATCH: o que não veio no corpo permanece como estava.

O controller

src/livros/livros.controller.ts
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:

src/livros/dto/consultar-livros.dto.ts
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: true entrega uma instância, pagina e tamanho já chegam preenchidos quando o cliente não os informa. Sem transform, o valor padrão nunca seria aplicado.
  • Teto em tamanho. Sem @Max(100), um cliente pediria ?tamanho=1000000 e 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
}
Por que a coleção tem envelope e o item não

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.

src/comum/filtros/http-exception.filter.ts
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:

src/main.ts
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"
}
Nunca devolva a exceção original ao cliente

@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:

src/livros/livro.entity.ts
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:

src/main.ts
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.

Regra da aula 4 que continua valendo

"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:

src/main.ts
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.

O plugin do CLI reduz a repetição

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

ErroSintomaCorreção
DTO declarado como interfaceValidação nunca roda; qualquer corpo passaUsar class
Esquecer useGlobalPipesO mesmo sintoma, com o DTO corretoRegistrar o ValidationPipe no bootstrap
PartialType importado de @nestjs/mapped-typesCorpo do PATCH vazio no SwaggerImportar de @nestjs/swagger
Validar ISBN duplicado sem checar se ele mudou409 ao editar o título de um livroComparar com o valor atual antes
Usar PUT para edição parcialCampos omitidos são apagadosUsar PATCH
DELETE devolvendo 200 com corpoCliente precisa interpretar texto@HttpCode(HttpStatus.NO_CONTENT)
Listagem sem teto de tamanhoUma requisição derruba o serviço@Max(100) no DTO de consulta
Repassar a exceção original em erros 5xxVazamento de caminho de arquivo e detalhe internoRegistrar no log, devolver mensagem genérica
@Query() sem DTOParâmetros chegam como texto e não são validadosCriar o DTO de consulta
Devolver o objeto interno diretoCampo 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

src/livros/livro.model.ts
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.ts
  • src/livros/dto/atualizar-livro.dto.ts
  • src/livros/dto/consultar-livros.dto.ts
No seu projeto

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) no DELETE;
  • 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

# 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

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"
O cenário 2 é o mais importante da lista

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

  1. Crie src/comum/filtros/http-exception.filter.ts conforme a Parte 6.
  2. Registre o filtro e o Swagger em src/main.ts (Partes 6 e 8).
  3. Repita os seis testes do passo 9 e confirme que todos devolvem o mesmo formato de corpo.
  4. Abra http://localhost:3000/docs e 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 400 com 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 devolve 409;
  • DELETE devolve 204 sem corpo;
  • a listagem devolve envelope com total, pagina e tamanho, e respeita o teto;
  • todos os erros têm o mesmo formato de corpo;
  • /docs mostra as cinco operações, e o corpo do PATCH nã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)

  1. 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 PUT em vez de PATCH.

  2. 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 class por interface no DTO de criação.

  3. 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 pagina e tamanho deixam de funcionar se transform for false.

  4. Diagnostique: um PATCH que altera apenas o título de um livro está devolvendo 409 Conflict. Aponte a causa provável no service e escreva a correção.

  5. 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.

  6. 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.

  7. Implemente no laboratório uma rota GET /livros/isbn/:isbn que busque por ISBN e devolva 404 se não existir. Indique onde ela deve ser declarada em relação a @Get(':id') e justifique.

  8. 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.

  9. 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.

  10. 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

Aprofundamento