Nível 2Unidade 3 · Web dinâmica server-side3 aulas de 50 min + 1 h EAD
Aula 13 — Rotas e controladores
Nível 2 — Desenvolvimento Web · WebLab
Na Aula 12 as rotas do Café Cerrado saíram do server.js e ganharam arquivo próprio com express.Router. Mas a lógica continua morando dentro delas: a rota sabe o endereço e faz o serviço. Hoje damos o último passo da arquitetura do back-end — a rota vira um índice de duas palavras por linha, o controlador executa, e uma terceira camada guarda os dados em disco. No fim da aula a API do Café Cerrado tem CRUD completo, busca por query string, validação de verdade e produtos que sobrevivem ao reinício do servidor.
Na Aula 11 você criou o repositório cafe-cerrado-api, instalou o Express 5 e serviu o site da Unidade 2 pela pasta public/. Na Aula 12 o server.js virou uma montagem de peças: express.json(), middleware de log, express.static, o router de /api/produtos, o 404 da API e o tratador de erros. Hoje esvaziamos as rotas e distribuímos a lógica entre um controlador e um repositório.
Checklist antes de começar:
[ ] cafe-cerrado-api rodando com npm run dev e respondendo em http://localhost:3000.
[ ] GET http://localhost:3000/api/produtos devolvendo a lista de produtos em JSON.
[ ] POST /api/produtos com corpo JSON funcionando (mesmo que sem validação caprichada) — sinal de que express.json() está no lugar certo.
[ ] Extensão REST Client instalada no VS Code e o arquivo testes.http versionado no repositório.
Olhe o arquivo de rotas que você terminou na Aula 12. Ele provavelmente parece com isto:
JavaScript
// cafe-cerrado-api/routes/produtos.js — versão da Aula 12constexpress=require('express');constprodutos=require('../data/produtos.json');constrouter=express.Router();router.get('/',(req,res)=>{res.json(produtos);});router.get('/:id',(req,res)=>{constproduto=produtos.find((p)=>p.id===Number(req.params.id));if(!produto){returnres.status(404).json({erro:'Produto não encontrado.'});}res.json(produto);});module.exports=router;
Funciona. Com dois endpoints, funciona muito bem. O problema aparece quando o recurso cresce: some POST, PUT, DELETE, validação de cada campo, filtro por categoria, busca por nome, ordenação, e daqui a duas aulas a checagem de quem está logado. O arquivo de rotas passa de 20 para 200 linhas e você perde a resposta para uma pergunta simples: o que esta API oferece?
A separação que vamos fazer hoje responde a essa pergunta em cinco linhas. Ela tem nome — é o MVC aplicado ao servidor — e três camadas:
Camada
Arquivo
Responsabilidade
Rota
routes/produtos.js
Casar método + caminho com uma função. Nada além disso.
Controlador
controllers/produtosController.js
Ler a requisição, validar, decidir status e montar a resposta.
Repositório (dados)
data/repositorio.js
Ler e gravar os produtos. Não sabe que existe HTTP.
Três motivos práticos, em ordem de importância para você agora:
Navegabilidade. Abrir routes/produtos.js e ver cinco linhas é abrir o índice da API. Quem entra no projeto — inclusive você daqui a três semanas — descobre em dez segundos o que existe.
Testabilidade. Um controlador é uma função comum que recebe req e res. Dá para chamá-la sem subir servidor. Um repositório é uma função que devolve dados; dá para trocá-lo por outro sem tocar em nada acima.
Divisão de trabalho. Duas pessoas mexendo no mesmo arquivo de 200 linhas geram conflito de merge toda hora. Em arquivos separados por responsabilidade, cada uma trabalha no seu.
Há um quarto motivo, que só aparece mais tarde: quando você trocar o arquivo JSON por um banco de dados de verdade, só o repositório muda. Rotas, controladores e front-end continuam iguais. Essa é a promessa das camadas, e você vai comprová-la no Nível 3.
🔎 Por baixo do capô
O "M" do MVC (Model) é frequentemente confundido com "a tabela do banco". No desenho original, de Trygve Reenskaug nos anos 1970 para a linguagem Smalltalk, o Model é o domínio — os dados e as regras que valem sobre eles, independentemente de tela ou de protocolo. É por isso que o nosso repositorio.js não pode conter res.status(404): status HTTP é assunto do controlador, não do domínio. Se o repositório souber o que é HTTP, você não consegue reaproveitá-lo em um script de linha de comando, em um job agendado ou em um teste.
Todo controlador de API faz sempre as mesmas três coisas, nesta ordem:
Extrair o que veio na requisição: req.params, req.query, req.body, req.headers.
Decidir: os dados são válidos? O recurso existe? Quem pediu tem permissão (a partir da Aula 14)?
Responder: escolher o status, montar o corpo, devolver.
Quando um controlador seu passar de umas 25 linhas, quase sempre é porque o passo 2 cresceu — e o passo 2 é regra de negócio, que merece uma função à parte. Você vai fazer exatamente isso com a validação, na seção 5.
REST é o estilo arquitetural descrito por Roy Fielding em 2000 para explicar por que a web funciona em escala planetária. Na prática do dia a dia, ele virou um punhado de convenções sobre como URLs e métodos HTTP expressam operações. Segui-las não é frescura: é o que faz um desenvolvedor abrir sua API pela primeira vez e adivinhar corretamente como usá-la.
Operação
Método e caminho
Sucesso
Listar
GET /api/produtos
200 + array
Detalhar
GET /api/produtos/7
200 + objeto
Criar
POST /api/produtos
201 + objeto criado
Atualizar
PUT /api/produtos/7
200 + objeto atualizado
Excluir
DELETE /api/produtos/7
204 sem corpo
As regras que sustentam essa tabela:
Recursos são substantivos no plural: /produtos, /categorias, /pedidos. Nunca /criarProduto ou /listarProdutos — o verbo já está no método HTTP. Uma URL com verbo é o sintoma mais fácil de detectar de uma API que não é REST.
Parâmetro de rota identifica: /api/produtos/7 diz qual produto. É parte da identidade do recurso.
Query string refina: /api/produtos?categoria=doces&ordenar=preco diz como listar. Ela nunca muda qual recurso está sendo acessado, só a fatia devolvida.
Aninhamento expressa relação: GET /api/produtos/7/avaliacoes são as avaliações do produto 7. Use no máximo dois níveis; três já viram um labirinto.
O mesmo caminho serve a métodos diferentes: /api/produtos/7 responde a GET, PUT e DELETE. Isso é proposital — o recurso é um só; o que muda é a intenção.
2.1 Idempotência: a propriedade que quase ninguém explica¶
Um método é idempotente quando repeti-lo produz o mesmo estado final que executá-lo uma vez.
GET é idempotente e seguro (não muda nada). Peça mil vezes, o servidor continua igual.
PUT é idempotente: mandar o mesmo produto atualizado dez vezes deixa o produto no mesmo estado.
DELETE é idempotente quanto ao estado: depois da primeira exclusão o recurso não existe mais, e continuar excluindo não muda isso (a resposta, sim, muda para 404 — o estado, não).
POSTnão é idempotente: dez requisições criam dez produtos.
Isso não é trivia de prova. É a razão pela qual navegadores e proxies podem repetir automaticamente um GET que falhou por timeout, mas nunca repetem um POST — e é por isso que aquele aviso "não atualize a página, seu pedido está sendo processado" existe em site de compra.
📌 Vale gravar
Saiba dizer, com exemplo, a diferença entre método seguro (não altera estado: GET, HEAD, OPTIONS) e método idempotente (repetir não muda o resultado: os seguros mais PUT e DELETE). Todo método seguro é idempotente; o contrário não vale — DELETE é idempotente e não é seguro.
Pelo livro, PUTsubstitui o recurso inteiro: o que não vier no corpo deveria ser apagado. PATCH aplica uma alteração parcial: só os campos enviados mudam.
Na prática, uma quantidade enorme de APIs implementa PUT com semântica de PATCH, porque é mais cômodo para o front. Nós vamos fazer o mesmo — mas conscientemente, e o documento de contrato da API vai dizer isso com todas as letras. O desafio ⭐⭐ desta aula pede que você implemente os dois com a semântica correta e sinta a diferença.
🧠 Você sabia?
A tese de doutorado que definiu REST não descreve nenhuma API. Roy Fielding, um dos autores da especificação do HTTP/1.1, escreveu em 2000 um trabalho sobre estilos arquiteturais de software em rede, e o capítulo 5 — chamado "Representational State Transfer" — explica por que a própria web escala: recursos com identificadores únicos, comunicação sem estado, respostas cacheáveis, interface uniforme. O termo "API REST", que hoje aparece em toda vaga de emprego, é uma leitura bem mais estreita do que ele propôs. Fielding chegou a escrever, anos depois, um texto irritado dizendo que a maioria das "APIs REST" não é REST.
Uma API só é útil quando alguém consegue usá-la sem ler o seu código. Esse "alguém" pode ser um colega de equipe, um aplicativo móvel, ou você mesmo na Aula 15, escrevendo o front. O documento que torna isso possível chama-se contrato: para cada endpoint, qual método, qual caminho, o que vai no corpo, o que volta e com qual status.
Este é o contrato do recurso produto do Café Cerrado, que você vai implementar hoje e colar no README.md:
Método e caminho
Corpo da requisição
Resposta
GET /api/produtos
—
200 + array de produtos
GET /api/produtos/:id
—
200 + produto, ou 404
POST /api/produtos
produto sem id
201 + produto criado, ou 400
PUT /api/produtos/:id
campos a alterar
200 + produto atualizado, 400 ou 404
DELETE /api/produtos/:id
—
204 sem corpo, ou 404
Parâmetros de query string aceitos por GET /api/produtos:
Parâmetro
Exemplo
Efeito
q
?q=cafe
Busca no nome e na descrição, ignorando acento e caixa
categoria
?categoria=doces
Filtra por categoria exata
ordenar
?ordenar=preco
Ordena por preco, -preco (decrescente) ou nome
E o formato de um produto, que é o mesmo na entrada e na saída (menos o id, que o servidor gera):
JSON
{"id":9,"nome":"Bolo de Milho Verde","categoria":"doces","preco":9.5,"descricao":"Fatia de bolo cremoso feito com milho da feira do produtor.","imagem":"img/bolo-de-milho.jpg"}
Escreva o contrato antes de programar, mesmo trabalhando sozinho. Ele obriga você a decidir os nomes e os status antes de estar com as mãos no código, e vira a fonte da verdade quando front e back discordam. Em projetos maiores, esse documento é gerado em um formato padronizado chamado OpenAPI — no Nível 3 você vai vê-lo funcionando com o Swagger.
Escolher o status certo é comunicação, não decoração. O front-end da Aula 15 vai tomar decisões olhando exclusivamente para o número: 201 limpa o formulário, 400 mostra os erros nos campos, 404 mostra "produto não encontrado", 401 manda fazer login.
Código
Nome
Quando devolver
200
OK
Consulta ou atualização bem-sucedida, com corpo.
201
Created
Recurso criado. Devolva o objeto criado e o cabeçalho Location.
204
No Content
Sucesso sem nada a dizer. O caso clássico é DELETE.
400
Bad Request
O cliente mandou dados inválidos ou incompletos.
404
Not Found
O recurso pedido não existe (ou a rota não existe).
409
Conflict
O pedido conflita com o estado atual (nome duplicado, por exemplo).
500
Internal Server Error
Deu errado do lado do servidor. Culpa sua, não do cliente.
A regra mnemônica das faixas: 2xx deu certo; 3xx procure em outro lugar; 4xx o cliente errou; 5xx o servidor errou. Um 500 em log de produção é sempre um bug esperando para ser corrigido — nunca use 500 para dizer "você digitou errado".
⚠️ Atenção
Devolver 200 com { "erro": "produto não encontrado" } no corpo é um erro de projeto comum e caro. O fetch do front só rejeita a Promise em falha de rede; para tudo mais é preciso olhar resposta.ok, que é calculado a partir do status. Se você mente no status, o front precisa abrir o corpo e adivinhar — e quando alguém consumir sua API com outra ferramenta, vai contar como sucesso o que foi falha.
🔬 Investigue
Com o servidor da Aula 12 rodando, execute no terminal: curl -i http://localhost:3000/api/produtos e depois curl -i http://localhost:3000/api/produtos/9999. A opção -i mostra a linha de status e os cabeçalhos antes do corpo. Anote três coisas: o status de cada resposta, o valor de Content-Type e o valor de Content-Length. Agora rode curl -I http://localhost:3000/api/produtos (-I faz um HEAD): o corpo some, mas o Content-Length continua lá. Por que o servidor calcularia o tamanho de um corpo que não vai enviar?
Na Unidade 2, a busca do cardápio do Café Cerrado filtrava um array já carregado no navegador com filter. Aquilo era correto para 12 produtos. Com 12 mil, baixar tudo para descartar 11.990 no cliente é desperdício de banda, de bateria e de tempo — e no celular do estudante que está no ônibus, isso se sente.
A partir de hoje o filtro mora no servidor, e o front só pede o que precisa:
JavaScript
// no front, na Aula 15constresposta=awaitfetch(`/api/produtos?q=${encodeURIComponent(termo)}`);
No Express, tudo que vem depois do ? chega pronto em req.query, já decodificado e transformado em objeto:
Três cuidados que separam uma listagem robusta de uma quebradiça:
Todo valor de query string é string.?limite=10 chega como '10', não como 10. Converta com Number() e valide antes de usar.
O parâmetro pode não vir.req.query.q é undefined quando ninguém buscou nada. Trate a ausência como "não filtre", nunca como "filtre por vazio".
O mesmo parâmetro pode vir repetido.?categoria=doces&categoria=cafes faz req.query.categoria virar um array. Se o seu código chama .toLowerCase() direto, ele quebra com TypeError: categoria.toLowerCase is not a function. Quem manda essa URL nem sempre é um usuário distraído; às vezes é alguém testando sua API.
⚠️ Atenção — mudou no Express 5
No Express 4 era possível escrever req.query.categoria = 'doces' dentro de um middleware para "normalizar" a entrada. No Express 5, req.query virou um getter: a propriedade é calculada na primeira leitura e não aceita atribuição. Se você tentar, o valor simplesmente não muda (ou estoura em modo estrito). Precisa de um valor tratado? Guarde em outra variável, ou pendure em req com outro nome: req.filtros = { categoria }.
O cliente digita cafe no celular, sem acento e sem maiúscula. O produto se chama Frappê de Café. Comparação literal não acha nada, e o usuário conclui que a cafeteria não vende café.
A solução é normalizar os dois lados da comparação com a mesma função:
JavaScript
functionnormalizar(texto){returnString(texto).normalize('NFD')// separa a letra do acento.replace(/[\u0300-\u036f]/g,'')// remove os acentos combinantes.toLowerCase().trim();}
normalize('NFD') decompõe é em e seguido de um acento combinante (um caractere invisível na faixa Unicode U+0300–U+036F). A expressão regular varre esses combinantes e os apaga, sobrando e. Mesma coisa para ç, ã, ü. É a mesma técnica que você usa para gerar slug de URL a partir de um título.
5. Validar no servidor: a única validação que conta¶
O required do HTML ajuda o usuário. O JavaScript do formulário ajuda mais ainda. Nenhum dos dois protege nada: qualquer pessoa abre o terminal e manda
sem nunca ter visto o seu formulário. Se a API aceitar, o produto entra no arquivo com preço negativo e o cardápio quebra para todo mundo.
Uma boa validação de API tem quatro propriedades:
Devolve 400, não 500. Dado ruim é culpa do cliente.
Diz qual campo está errado, para o front destacar o campo certo. Um {"erro": "dados inválidos"} genérico obriga o usuário a adivinhar.
Devolve todos os erros de uma vez, não um por requisição. Ninguém merece enviar o formulário cinco vezes.
Normaliza enquanto valida: corta espaços com trim(), converte "6.5" em 6.5, arredonda o preço para dois decimais. O que entra no arquivo já entra limpo.
O formato de resposta que vamos adotar em todo o projeto:
JSON
{"erro":"Dados inválidos.","detalhes":[{"campo":"nome","mensagem":"O nome precisa ter ao menos 3 caracteres."},{"campo":"preco","mensagem":"O preço precisa ser um número maior que zero."}]}
Em projetos maiores você usaria uma biblioteca de validação por esquema (Zod, Joi, express-validator). Escrever a validação à mão uma vez, como faremos hoje, é o que faz você entender o que essas bibliotecas automatizam.
💡 Dica
Valide e normalize no mesmo lugar, devolvendo um objeto novo com só os campos aceitos. Nunca faça produtos.push(req.body): isso deixa o cliente injetar qualquer campo no seu registro — inclusive um dono falso, que na Aula 16 vai virar problema de segurança de verdade.
6. Persistência: os dados precisam sobreviver ao Ctrl + C¶
Até a Aula 12, o routes/produtos.js carregava o data/produtos.json com um require e trabalhava sobre esse array em memória: o arquivo era lido uma vez, na inicialização, e o POST só empurrava o produto novo para dentro do array. Isso significa que reiniciar o servidor apaga tudo que foi criado — e o node --watch reinicia sozinho a cada arquivo salvo. Você cria três produtos, corrige uma vírgula, e eles somem.
A solução definitiva é um banco de dados; ela chega no Nível 3. Para o Café Cerrado, um arquivo JSON entrega o conceito de persistência com o que você já sabe: ler arquivo, converter texto em objeto, converter objeto em texto, gravar.
O módulo do Node para isso é o fs, e a versão que interessa é a de Promises:
JavaScript
constfs=require('node:fs/promises');
O prefixo node: explicita que o módulo é interno do Node, não um pacote do node_modules. É a forma recomendada desde o Node 18 e evita um ataque real chamado dependency confusion, em que alguém publica no npm um pacote com o nome de um módulo interno.
Duas armadilhas para tratar desde já:
O arquivo pode não existir na primeira execução. fs.readFile lança um erro com erro.code === 'ENOENT' (Error NO ENTry). Nesse caso, o certo é devolver uma lista vazia, não derrubar o servidor.
Gravar direto por cima é arriscado. Se o processo morrer no meio de um writeFile, o arquivo fica pela metade — e um JSON pela metade não é JSON. A técnica padrão é gravar em um arquivo temporário e depois renomeá-lo por cima do original: no mesmo sistema de arquivos, rename é atômico, ou seja, ou vale o conteúdo antigo inteiro ou o novo inteiro, nunca uma mistura.
🔎 Por baixo do capô
Por que rename é atômico e writeFile não? Porque writeFile copia bytes para dentro do arquivo, e uma queda no meio deixa metade nova e metade velha. Já rename só troca uma entrada de diretório: o nome produtos.json passa a apontar para outro conjunto de blocos que já está inteiro no disco. Essa é a mesma ideia que bancos de dados usam com o write-ahead log e que o Git usa ao gravar objetos. Um detalhe importante: isso só vale se o arquivo temporário estiver no mesmo sistema de arquivos — por isso criamos o .tmp ao lado do arquivo final, e não em /tmp.
O data/repositorio.js que você vai escrever no Mão na massa é uma implementação do padrão Repository, catalogado por Martin Fowler: uma camada que se comporta como uma coleção de objetos em memória e esconde completamente de onde os dados vêm.
O contrato do nosso repositório tem três funções: lerTodos(), salvarTodos(lista) e proximoId(lista). Nenhuma delas menciona arquivo, caminho, JSON ou fs. Isso é proposital — é o que permite, no futuro, trocar o corpo dessas funções por consultas SQL sem que o controlador perceba.
O ganho fica visível quando você faz a conta do que muda em cada cenário:
Mudança
O que precisa ser reescrito
Trocar JSON por MySQL
Só data/repositorio.js
Adicionar campo estoque ao produto
Validação no controlador
Trocar /api/produtos por /api/v2/produtos
Uma linha no server.js
O preço do padrão é um arquivo a mais e uma indireção a mais para ler. Em um projeto de duas telas isso pode ser exagero; em um projeto que vai crescer por um semestre inteiro, paga-se sozinho na terceira semana.
💻 Mão na massa — CRUD completo da API do Café Cerrado¶
Objetivo desta prática: sair de duas rotas de leitura e chegar a cinco endpoints com validação, busca e persistência em disco. Trabalhe dentro de cafe-cerrado-api, com npm run dev rodando em um terminal.
Passo 1 — Os dados saem do código e vão para o disco¶
O data/produtos.json existe desde a Aula 11 e não muda hoje: são os mesmos dez produtos do cardápio, com os mesmos ids, nomes, preços e categorias. O que muda é quem o lê e quem escreve nele — até a Aula 12 o arquivo era carregado uma vez por um require e o resto acontecia na memória; a partir de hoje toda leitura e toda escrita passam por um repositório.
Confira que o seu está exatamente assim. Note que ele é só dados: nenhum module.exports, nenhuma vírgula sobrando, aspas duplas em todas as chaves — é JSON, não JavaScript.
cafe-cerrado-api/data/produtos.json
JSON
[{"id":1,"nome":"Espresso do Cerrado","categoria":"cafes","preco":6,"descricao":"Grãos de Alto Paraíso, torra média, corpo encorpado e final achocolatado.","imagem":"img/espresso.jpg"},{"id":2,"nome":"Coado da Casa","categoria":"cafes","preco":8.5,"descricao":"Duzentos mililitros em coador de papel, moagem média feita na hora do pedido.","imagem":"img/coado.jpg"},{"id":3,"nome":"Cappuccino Sinop","categoria":"cafes","preco":12,"descricao":"Espresso duplo, leite vaporizado e canela do Cerrado por cima.","imagem":"img/cappuccino.jpg"},{"id":4,"nome":"Latte de Baunilha","categoria":"cafes","preco":14,"descricao":"Espresso, leite vaporizado e calda de baunilha feita na casa.","imagem":"img/latte.jpg"},{"id":5,"nome":"Cold Brew da Chapada","categoria":"geladas","preco":15,"descricao":"Extração a frio por dezoito horas, servida com gelo e rodela de laranja.","imagem":"img/cold-brew.jpg"},{"id":6,"nome":"Frappê de Café","categoria":"geladas","preco":16,"descricao":"Espresso batido com gelo, leite e chantili. Também sai sem lactose.","imagem":"img/frappe.jpg"},{"id":7,"nome":"Pão de Queijo Mineiro","categoria":"salgados","preco":7,"descricao":"Porção com quatro unidades de polvilho azedo com queijo canastra.","imagem":"img/pao-de-queijo.jpg"},{"id":8,"nome":"Torta de Frango","categoria":"salgados","preco":13,"descricao":"Fatia generosa com massa amanteigada e recheio de frango desfiado.","imagem":"img/torta-de-frango.jpg"},{"id":9,"nome":"Bolo de Milho Verde","categoria":"doces","preco":9.5,"descricao":"Fatia de bolo cremoso feito com milho da feira do produtor.","imagem":"img/bolo-de-milho.jpg"},{"id":10,"nome":"Brownie de Castanha","categoria":"doces","preco":11,"descricao":"Chocolate meio amargo com castanha-do-pará. Sem glúten.","imagem":"img/brownie.jpg"}]
Agora o repositório. Ele é a única parte do sistema que sabe que existe um arquivo.
cafe-cerrado-api/data/repositorio.js
JavaScript
// Camada de acesso a dados do Café Cerrado.// Só esta camada sabe que os produtos moram em um arquivo JSON.constfs=require('node:fs/promises');constpath=require('node:path');constARQUIVO=path.join(__dirname,'produtos.json');// Lê o arquivo inteiro e devolve um array de produtos.// Se o arquivo ainda não existe, começa vazio em vez de estourar.asyncfunctionlerTodos(){try{consttexto=awaitfs.readFile(ARQUIVO,'utf-8');returnJSON.parse(texto);}catch(erro){if(erro.code==='ENOENT'){return[];}throwerro;}}// Grava a lista inteira. Escreve primeiro em um arquivo temporário e// depois renomeia: assim nunca sobra um JSON pela metade no disco.asyncfunctionsalvarTodos(lista){consttemporario=`${ARQUIVO}.tmp`;awaitfs.writeFile(temporario,JSON.stringify(lista,null,2),'utf-8');awaitfs.rename(temporario,ARQUIVO);}// Próximo id disponível: o maior existente mais um.functionproximoId(lista){returnlista.reduce((maior,produto)=>Math.max(maior,produto.id),0)+1;}module.exports={lerTodos,salvarTodos,proximoId};
Repare no path.join(__dirname, 'produtos.json'). Se você escrevesse './data/produtos.json', o caminho seria resolvido a partir do diretório de onde você rodou o node, não de onde o arquivo está. Rodar npm run dev de dentro de outra pasta quebraria tudo. __dirname é a pasta do arquivo atual e resolve isso de vez.
constrepositorio=require('../data/repositorio');// A mesma lista branca de sempre: as quatro categorias do cardápio.constCATEGORIAS=['cafes','geladas','salgados','doces'];// Deixa o texto comparável: sem acento, sem maiúscula, sem espaço nas pontas.functionnormalizar(texto){returnString(texto).normalize('NFD').replace(/[\u0300-\u036f]/g,'').toLowerCase().trim();}// GET /api/produtos?q=cafe&categoria=cafes&ordenar=precoexports.listar=async(req,res)=>{const{q,categoria,ordenar}=req.query;letlista=awaitrepositorio.lerTodos();if(typeofcategoria==='string'&&categoria!==''){constalvo=normalizar(categoria);lista=lista.filter((produto)=>normalizar(produto.categoria)===alvo);}if(typeofq==='string'&&q!==''){consttermo=normalizar(q);lista=lista.filter((produto)=>normalizar(produto.nome).includes(termo)||normalizar(produto.descricao).includes(termo),);}if(ordenar==='preco'){lista=[...lista].sort((a,b)=>a.preco-b.preco);}elseif(ordenar==='-preco'){lista=[...lista].sort((a,b)=>b.preco-a.preco);}elseif(ordenar==='nome'){lista=[...lista].sort((a,b)=>a.nome.localeCompare(b.nome,'pt-BR'));}res.json(lista);};// Converte o :id da rota. Se não for um inteiro positivo, já responde 400// e devolve null — os três handlers de id usam esta mesma checagem.functionidDaRota(req,res){constid=Number(req.params.id);if(!Number.isInteger(id)||id<=0){res.status(400).json({erro:'O id precisa ser um número inteiro positivo.'});returnnull;}returnid;}// GET /api/produtos/7exports.obter=async(req,res)=>{constid=idDaRota(req,res);if(id===null)return;constlista=awaitrepositorio.lerTodos();constproduto=lista.find((item)=>item.id===id);if(!produto){returnres.status(404).json({erro:`Produto ${id} não encontrado.`});}res.json(produto);};
Três decisões que valem comentário:
typeof categoria === 'string' protege contra a query string repetida (?categoria=a&categoria=b), que chegaria como array e quebraria o normalizar.
[...lista].sort(...) ordena uma cópia. sort altera o array original, e o original aqui veio direto do arquivo — ordenar no lugar não faria estrago hoje, mas é o tipo de efeito colateral que assombra depois.
Number('abc') é NaN, e Number.isInteger(NaN) é false. Por isso /api/produtos/abc devolve 400, e não uma busca silenciosa por undefined. A checagem virou a função idDaRota justamente porque obter, atualizar e remover precisam da mesma regra: se ela existisse só no obter, PUT /api/produtos/abacaxi devolveria 404 e a sua API passaria a mentir sobre a diferença entre "id inválido" e "produto inexistente".
Acrescente ao mesmo arquivo do controlador, logo abaixo de normalizar, a função de validação:
cafe-cerrado-api/controllers/produtosController.js (acrescente após normalizar)
JavaScript
// Valida e normaliza o corpo da requisição.// Com { parcial: true }, campos ausentes são ignorados (usado no PUT).functionvalidarProduto(corpo={},{parcial=false}={}){consterros=[];constdados={};if(corpo.nome!==undefined||!parcial){constnome=typeofcorpo.nome==='string'?corpo.nome.trim():'';if(nome.length<3){erros.push({campo:'nome',mensagem:'O nome precisa ter ao menos 3 caracteres.'});}else{dados.nome=nome;}}if(corpo.preco!==undefined||!parcial){constpreco=Number(corpo.preco);if(!Number.isFinite(preco)||preco<=0){erros.push({campo:'preco',mensagem:'O preço precisa ser um número maior que zero.'});}else{dados.preco=Math.round(preco*100)/100;}}if(corpo.categoria!==undefined||!parcial){constcategoria=typeofcorpo.categoria==='string'?normalizar(corpo.categoria):'';if(!CATEGORIAS.includes(categoria)){erros.push({campo:'categoria',mensagem:`A categoria precisa ser uma destas: ${CATEGORIAS.join(', ')}.`,});}else{dados.categoria=categoria;}}if(corpo.descricao!==undefined){dados.descricao=String(corpo.descricao).trim();}elseif(!parcial){dados.descricao='';}if(corpo.imagem!==undefined){dados.imagem=String(corpo.imagem).trim();}elseif(!parcial){dados.imagem='';}return{erros,dados};}
Agora as três operações de escrita, no fim do arquivo:
cafe-cerrado-api/controllers/produtosController.js (acrescente ao final)
JavaScript
// POST /api/produtosexports.criar=async(req,res)=>{const{erros,dados}=validarProduto(req.body);if(erros.length>0){returnres.status(400).json({erro:'Dados inválidos.',detalhes:erros});}constlista=awaitrepositorio.lerTodos();constnovo={id:repositorio.proximoId(lista),...dados};lista.push(novo);awaitrepositorio.salvarTodos(lista);res.status(201).location(`/api/produtos/${novo.id}`).json(novo);};// PUT /api/produtos/7exports.atualizar=async(req,res)=>{constid=idDaRota(req,res);if(id===null)return;constlista=awaitrepositorio.lerTodos();constindice=lista.findIndex((item)=>item.id===id);if(indice===-1){returnres.status(404).json({erro:`Produto ${id} não encontrado.`});}const{erros,dados}=validarProduto(req.body,{parcial:true});if(erros.length>0){returnres.status(400).json({erro:'Dados inválidos.',detalhes:erros});}if(Object.keys(dados).length===0){returnres.status(400).json({erro:'Envie ao menos um campo para atualizar.'});}constatualizado={...lista[indice],...dados,id};lista[indice]=atualizado;awaitrepositorio.salvarTodos(lista);res.json(atualizado);};// DELETE /api/produtos/7exports.remover=async(req,res)=>{constid=idDaRota(req,res);if(id===null)return;constlista=awaitrepositorio.lerTodos();constindice=lista.findIndex((item)=>item.id===id);if(indice===-1){returnres.status(404).json({erro:`Produto ${id} não encontrado.`});}lista.splice(indice,1);awaitrepositorio.salvarTodos(lista);res.status(204).end();};
Detalhes que merecem sua atenção:
Em criar, o objeto novo é montado como { id: ..., ...dados }, e não a partir de req.body. Só os campos que a validação aprovou entram no arquivo.
Em atualizar, o id aparece de novo no fim de { ...lista[indice], ...dados, id }. Isso impede que alguém troque o id do produto mandando {"id": 999} no corpo — o último valor vence no espalhamento.
res.status(204).end(): 204 significa "sem conteúdo". Chamar res.json() depois de 204 é contraditório, e alguns clientes reclamam.
Nenhum controlador tem try/catch. No Express 5, um erro lançado dentro de uma função async é capturado e encaminhado automaticamente ao middleware de erro. Era exatamente isso que exigia .catch(next) no Express 4.
⚠️ Atenção
A ordem importa. Se você acrescentar depois uma rota fixa como router.get('/destaques', ...), ela precisa vir antes de router.get('/:id', ...). O Express testa na ordem de registro, e /:id casa com qualquer coisa — inclusive com a palavra destaques, que viraria req.params.id = 'destaques' e devolveria 400. Esse é o desafio ⭐ de hoje.
E o server.jsnão é reescrito: ele continua sendo o índice enxuto da Aula 12, com os middlewares em middlewares/, os dois routers montados e o fallback da SPA. Muda uma linha só — o 404 da API, que agora usa o curinga do Express 5 para cobrir também /api sozinho:
cafe-cerrado-api/server.js
JavaScript
constpath=require('node:path');constexpress=require('express');constprodutosRouter=require('./routes/produtos');constcategoriasRouter=require('./routes/categorias');constregistrarRequisicao=require('./middlewares/registro');const{naoEncontradoApi,tratadorDeErros}=require('./middlewares/erros');constapp=express();constPORTA=process.env.PORT||3000;// 1. Interpreta corpos JSON e coloca o resultado em req.body.app.use(express.json());// 2. Log de toda requisição, com status e duração (middlewares/registro.js).app.use(registrarRequisicao);// 3. Site estático do Café Cerrado (Unidades 1 e 2).app.use(express.static(path.join(__dirname,'public')));// 4. Recursos da API — um app.use por recurso.app.use('/api/produtos',produtosRouter);app.use('/api/categorias',categoriasRouter);// 5. Qualquer outra rota sob /api que não casou: 404 em JSON.// Mesmo middleware da Aula 12, agora montado com o curinga do Express 5.app.all('/api/{*splat}',naoEncontradoApi);// 6. Fora da API, devolve o index.html: quem resolve a rota é a SPA da Aula 10.app.get('/{*splat}',(req,res)=>{res.sendFile(path.join(__dirname,'public','index.html'));});// 7. Tratador de erros: quatro parâmetros, sempre por último.app.use(tratadorDeErros);app.listen(PORTA,()=>{console.log(`Café Cerrado API em http://localhost:${PORTA}`);});
Três coisas que continuam exatamente como estavam, e não é por acaso:
process.env.PORT || 3000. Fixar 3000 no código funciona na sua máquina e quebra no primeiro deploy: os serviços de hospedagem definem a porta por variável de ambiente, e o Capítulo 05 da trilha Deploy cobra isso.
path.join(__dirname, 'public') em vez de 'public'. Pelo mesmo motivo do __dirname no repositório: caminho relativo é resolvido a partir de onde você rodou o node.
O fallback app.get('/{*splat}', …). Sem ele, recarregar a página em /#/cardapio funciona (o hash não vai ao servidor), mas qualquer caminho novo devolve o 404 padrão do Express em HTML — e o Checkpoint da Aula 12 deixa de valer.
O padrão '/api/{*splat}' é a sintaxe de curinga do Express 5. As chaves tornam o trecho opcional (então /api sozinho também casa) e *splat captura o resto do caminho em req.params.splat, que é um array de segmentos. No Express 4 isso se escrevia '/api/*' — se você encontrar essa forma em um tutorial antigo, saiba que ela não funciona mais.
💡 Dica
O log do passo 2 usa res.on('finish', ...) em vez de imprimir antes de next(). A diferença: finish dispara quando a resposta terminou de ser enviada, então você consegue registrar o status e a duração reais. Um log que só mostra o que entrou não ajuda a caçar lentidão.
Substitua o testes.http da Aula 12 por este roteiro completo. Cada bloco separado por ### vira um botão "Send Request" no VS Code.
cafe-cerrado-api/testes.http
HTTP
@base = http://localhost:3000/api### 1. Listar tudo (200, array com 10 itens)GET {{base}}/produtos### 2. Buscar por nome, sem acento e em minúscula (200, acha o "Frappê de Café")GET {{base}}/produtos?q=cafe### 3. Filtrar por categoria e ordenar do mais barato ao mais caro (200)GET {{base}}/produtos?categoria=doces&ordenar=preco### 4. Detalhar um produto existente (200)GET {{base}}/produtos/3### 5. Detalhar um id inexistente (404)GET {{base}}/produtos/9999### 6. Detalhar um id que nem é número (400)GET {{base}}/produtos/abacaxi### 7. Criar produto válido (201 + cabeçalho Location, id 11)POST {{base}}/produtosContent-Type: application/json{ "nome": "Suco de Cupuaçu", "categoria": "geladas", "preco": 11.5, "descricao": "Polpa batida com água gelada, sem açúcar.", "imagem": "img/suco-cupuacu.jpg"}### 8. Criar produto inválido (400 com três itens em detalhes)POST {{base}}/produtosContent-Type: application/json{ "nome": "Ab", "categoria": "sobremesa", "preco": -3}### 9. Atualizar só o preço (200, os outros campos continuam iguais)PUT {{base}}/produtos/11Content-Type: application/json{ "preco": 12.9}### 10. Atualizar produto inexistente (404)PUT {{base}}/produtos/9999Content-Type: application/json{ "preco": 1}### 11. Excluir (204, sem corpo)DELETE {{base}}/produtos/11### 12. Excluir de novo (404 — o recurso já não existe)DELETE {{base}}/produtos/11### 13. Rota de API que não existe (404 em JSON, não em HTML)GET {{base}}/pedidos
Com npm run dev rodando, execute os 13 blocos na ordem. O roteiro foi montado para contar uma história: o bloco 7 cria o produto de id 11 (o cardápio vai até o 10), o 9 altera esse mesmo produto, o 11 o exclui e o 12 prova que ele sumiu.
O resultado esperado, bloco a bloco:
Blocos
Status esperado
O que confirma
1 a 4
200
Leitura, busca, filtro e ordenação
5 e 6
404 e 400
Id inexistente × id malformado
7 e 8
201 e 400
Criação e validação: o bloco 8 viola as três regras de uma vez (nome curto, categoria fora da lista branca e preço negativo), então detalhes traz três itens
9 a 13
200, 404, 204, 404, 404
Atualização parcial, exclusão e 404 de API
Duas provas finais, e só então a prática está encerrada:
Abra data/produtos.json no editor depois do bloco 7. O produto novo está lá, com indentação de 2 espaços. Os dados saíram da memória e foram para o disco.
Pare o servidor com Ctrl + C, suba de novo e rode o bloco 1. O produto criado continua na lista. Era exatamente isso que não acontecia até a Aula 12.
A2. O cliente chama GET /api/produtos?q=. O que req.query.q vale nessa requisição, e por que a listagem devolve todos os produtos em vez de nenhum? Aponte a linha exata do controlador que decide isso.
A3. Verdadeiro ou falso, com uma linha de justificativa cada:
(a) No Express 5, exports.criar precisa de try/catch para que um erro de fs.writeFile chegue ao tratador de erros.
(b) req.query.categoria = 'doces' dentro de um middleware altera o filtro que o controlador vai ler.
(c) DELETE /api/produtos/2 repetido devolve o mesmo status nas duas vezes.
A4. Em atualizar, o objeto final é { ...lista[indice], ...dados, id }. Descreva o que acontece com o produto 3 se um cliente mandar PUT /api/produtos/3 com o corpo {"id": 99, "preco": 7} — e o que aconteceria se o id não estivesse repetido no fim.
A5. Alguém trocou lista = [...lista].sort(...) por lista.sort(...) no listar. O endpoint continua devolvendo a resposta certa? E o arquivo produtos.json no disco, muda? Justifique olhando de onde veio o array.
A6. Explique, em duas linhas, por que data/repositorio.js não pode conter res.status(404) — e o que quebraria se contivesse.
B1. Endpoint de contagem. Implemente GET /api/produtos/contagem, que devolve { "total": 10 } respeitando os mesmos filtros de q e categoria do listar.
Resultado esperado: GET /api/produtos/contagem?categoria=cafes devolve 200 com {"total":4}; sem query string, devolve o total geral. A rota nova não pode ser engolida por /:id.
Dica
Duas coisas: a rota fixa precisa ser registrada antes de router.get('/:id', ...), e a lógica de filtro está duplicada — extraia-a para uma função aplicarFiltros(lista, req.query) usada pelos dois controladores.
B2. Categorias com controlador próprio. O routes/categorias.js da Aula 12 responde GET /api/categorias, mas ainda com a lógica dentro do router. Aplique a ele a arquitetura de hoje: crie controllers/categoriasController.js, faça o GET /api/categorias devolver também a quantidade de produtos de cada categoria e acrescente GET /api/categorias/:slug/produtos.
Resultado esperado: GET /api/categorias devolve [{"slug":"cafes","total":4}, {"slug":"geladas","total":2}, {"slug":"salgados","total":2}, {"slug":"doces","total":2}]; GET /api/categorias/doces/produtos devolve só os doces; GET /api/categorias/inexistente/produtos devolve 404.
Dica
O app.use('/api/categorias', categoriasRouter) já está no server.js desde a Aula 12: o trabalho é mover o corpo dos handlers para controllers/categoriasController.js e ler os produtos pelo repositorio, não por require do JSON. Para contar por categoria, reduce sobre a lista de produtos acumulando em um objeto resolve em cinco linhas.
B3. Nome duplicado devolve 409. Hoje é possível criar dois produtos chamados "Cappuccino Sinop". Impeça isso no criar e no atualizar, devolvendo 409 Conflict com uma mensagem clara.
Resultado esperado: criar um produto com nome já existente (ignorando acento e caixa) devolve 409; renomear um produto para o nome de outro também; renomear um produto para o próprio nome continua funcionando.
Dica
Compare com normalizar dos dois lados. No atualizar, a checagem precisa ignorar o próprio produto: lista.some((p) => p.id !== id && normalizar(p.nome) === normalizar(dados.nome)).
B4. Contrato documentado. Escreva no README.md do cafe-cerrado-api a tabela de contrato da API: uma linha por endpoint, com método, caminho e o que devolve em caso de sucesso. Abaixo da tabela, um bloco JSON de exemplo do corpo do POST e um da resposta de erro 400.
Resultado esperado: alguém que nunca viu o projeto consegue usar a API só lendo o README, sem abrir código.
Dica
Cinco linhas na tabela (mais as que você criou nos itens B1 e B2). Não esqueça de documentar os parâmetros de query string aceitos por GET /api/produtos — é a parte que o front da Aula 15 mais vai usar.
C1. Paginação com metadados. Listar 12 produtos é fácil; listar 12 mil não é. Implemente paginação em GET /api/produtos com os parâmetros pagina (padrão 1) e limite (padrão 10, máximo 50), devolvendo um envelope com os dados e os metadados:
Valores inválidos (?pagina=0, ?limite=999, ?pagina=abc) não podem quebrar nem devolver 500: eles caem no padrão ou no teto. A paginação é aplicada depois do filtro e da ordenação — total é o total filtrado, não o total do arquivo.
Resultado esperado: ?limite=2&pagina=2 devolve os produtos 3 e 4 da lista ordenada, com totalPaginas: 3; ?pagina=99 devolve dados: [] e paginacao coerente; ?limite=999 devolve no máximo 50 itens.
Dica
const pagina = Math.max(1, Number(req.query.pagina) || 1) resolve 0, abc e ausência de uma vez, porque NaN || 1 é 1. Para o limite, Math.min(50, Math.max(1, Number(req.query.limite) || 10)). A fatia é lista.slice((pagina - 1) * limite, pagina * limite), e totalPaginas é Math.ceil(total / limite) — cuidado com o caso total === 0, em que o resultado deve ser 0 ou 1, e não NaN.
O Café Cerrado quer uma vitrine de destaques na página inicial. Você acrescenta router.get('/destaques', controlador.destaques) no fim do arquivo de rotas, sobe o servidor, abre http://localhost:3000/api/produtos/destaques e recebe:
JSON
{"erro":"O id precisa ser um número inteiro positivo."}
O controlador destaques nem foi chamado — coloque um console.log dentro dele para se convencer. Descubra por que o Express entregou a requisição para o controlador errado e conserte, deixando o endpoint de destaques funcionando: ele devolve os três produtos mais caros.
Critérios de pronto
GET /api/produtos/destaques devolve 200 com exatamente três produtos, do mais caro para o mais barato.
GET /api/produtos/3 continua devolvendo o produto 3, sem regressão.
Um comentário de duas linhas no routes/produtos.js explica a regra que você descobriu sobre a ordem de registro das rotas.
Um bloco novo no testes.http cobre o endpoint de destaques.
Pistas
Adicione console.log('entrou em obter', req.params.id) na primeira linha de exports.obter e repita a requisição. O que aparece no terminal?
Leia a página "Routing" da documentação do Express prestando atenção em uma frase: as rotas são avaliadas na ordem em que foram definidas, e a primeira que casar vence.
/:id é um curinga de um segmento. Que caminhos de um segmento não casam com ele?
A correção é mover uma linha. A prevenção é uma convenção: rotas fixas antes de rotas com parâmetro, sempre.
O PUT que você escreveu hoje mente: pelo RFC 9110, PUT substitui o recurso inteiro, então mandar {"preco": 12.9} deveria apagar nome, categoria e descrição. O nosso preserva — comportamento de PATCH usando o nome errado. Muitas APIs de mercado fazem isso, mas quase nenhuma documenta, e é aí que o cliente se machuca. Implemente os dois métodos com a semântica correta e documente a diferença.
Critérios de pronto
PUT /api/produtos/3 com corpo incompleto devolve 400 listando os campos obrigatórios ausentes; com corpo completo, substitui todos os campos (o id é preservado).
PATCH /api/produtos/3 com {"preco": 12.9} altera só o preço e devolve 200 com o produto inteiro.
PATCH com corpo {} devolve 400 com uma mensagem que explica o problema.
A tabela de contrato no README.md ganha as duas linhas, com uma frase dizendo o que cada método faz com os campos ausentes.
O testes.http prova a diferença: um PUT parcial que falha e um PATCH equivalente que funciona, lado a lado.
Pistas
A função validarProduto já tem a chave: o PUT usa { parcial: false } e o PATCH usa { parcial: true }.
Com parcial: false, o objeto salvo deve ser montado do zero — { id, ...dados } — e não espalhando o produto antigo por cima.
router.patch('/:id', controlador.remendar) é a linha nova no arquivo de rotas.
Antes de codificar, procure na especificação do HTTP (RFC 9110, seção sobre PUT) a frase que define substituição. Cole-a como comentário no controlador: ela justifica o 400.
A dona do Café Cerrado pediu um resumo do cardápio: quantos produtos existem, quanto custa o mais caro e o mais barato, qual o preço médio por categoria. Ela não sabe o que é JSON, mas o seu front vai saber. Construa GET /api/produtos/resumo sem fazer o cálculo virar um monstro de dez for aninhados — e sem duplicar nada que já existe no controlador.
Critérios de pronto
GET /api/produtos/resumo devolve 200 com total, precoMinimo, precoMaximo, precoMedio (duas casas) e um array porCategoria com slug, total e precoMedio de cada categoria.
O resumo respeita ?categoria= e ?q=, reaproveitando a mesma função de filtro do listar (nenhuma linha de filtro copiada e colada).
Com o cardápio vazio (renomeie produtos.json temporariamente), o endpoint devolve 200 com total: 0 e nenhum NaN no corpo.
Um bloco no testes.http e uma linha na tabela do README.md.
Pistas
reduce resolve os quatro agregados numéricos em uma passada; Math.min(...lista.map(...)) estoura com lista vazia, então trate esse caso antes.
Para agrupar por categoria sem bibliotecas: Object.groupBy existe no Node 22 e devolve um objeto com um array por chave.
Arredonde só na hora de responder: Number(media.toFixed(2)) devolve número, enquanto media.toFixed(2) devolve string — e string em campo numérico atrapalha o front.
Lembre-se do desafio ⭐: resumo é uma rota fixa.
⭐⭐⭐
⭐⭐⭐ Dois pedidos ao mesmo tempo, um produto perdido¶
nodeapibugperformance
Seu criar faz três coisas em sequência: lê o arquivo, acrescenta um item, grava o arquivo. Entre a leitura e a gravação existe uma janela de alguns milissegundos. Se duas requisições entrarem nessa janela juntas, as duas leem a mesma lista de 5 itens, cada uma acrescenta o seu produto com o mesmo id 6, e a segunda gravação apaga a primeira. Isso se chama lost update, e é a razão de existirem transações em bancos de dados. Prove que o bug existe na sua API, meça o estrago e conserte.
Critérios de pronto
Um script scripts/estresse.js dispara 50 POST simultâneos com Promise.all e imprime quantos produtos foram efetivamente gravados no arquivo. Antes da correção, o número é visivelmente menor que 50.
Uma tabela de três linhas no README.md registra a medição: produtos esperados, produtos gravados antes da correção, produtos gravados depois.
Depois da correção, as 50 requisições resultam em 50 produtos com ids únicos e sequenciais, sem nenhum id repetido.
A correção fica dentro do repositório: o controlador não muda uma linha.
Um comentário no repositorio.js explica em três linhas por que o Node, sendo de thread única, mesmo assim sofre desse problema.
Pistas
O Node executa JavaScript em uma thread só, mas await devolve o controle ao event loop: entre o await lerTodos() e o await salvarTodos() outra requisição roda inteirinha. Concorrência não é paralelismo.
A solução mais simples é uma fila de promessas: guarde a última operação em uma variável e encadeie a próxima com fila = fila.then(() => operacao()). Assim, cada escrita só começa quando a anterior terminou.
Exponha uma função atualizarComExclusividade(transformar) no repositório, que recebe uma função (lista) => novaLista, e faça criar, atualizar e remover passarem por ela.
Para o script de estresse, Array.from({ length: 50 }, (valor, i) => fetch(url, opcoes(i))) dentro de Promise.all basta; conte o resultado com fs.readFile depois de todas as respostas chegarem.
Meça também o tempo total: a fila serializa as escritas e isso custa. Vale a pena discutir no README se o custo compensa.
No seu projeto autoral, refatore a API para a arquitetura de hoje e complete o CRUD:
Separe routes/, controllers/ e data/repositorio.js, deixando o arquivo de rotas com uma linha por endpoint.
Implemente os cinco endpoints do seu recurso principal com os status corretos (200, 201, 204, 400, 404).
Persista em arquivo JSON com fs/promises, tratando ENOENT e gravando por arquivo temporário + rename.
Implemente busca e filtro por query string no listar, com normalização de acentos.
Atualize o testes.http com o roteiro completo (criar → listar → atualizar → detalhar → excluir → detalhar), incluindo pelo menos dois casos de erro.
Critério de pronto: clonando o repositório em uma pasta limpa, npm install && npm run dev sobe a API; os 13 blocos equivalentes do seu testes.http devolvem os status esperados; e um produto criado continua existindo depois de reiniciar o servidor.
QUEIRÓS, Ricardo; PORTELA, Filipe. Introdução ao Desenvolvimento Moderno para a Web. FCA, 2018 — capítulo sobre APIs REST.
LOUDON, Kyle. Desenvolvimento de Grandes Aplicações Web. Novatec, 2019 — organização em camadas e lógica de negócio.
ALVES, William P. Projetos de Sistemas Web. Érica, 2015 — modelagem das operações de um sistema.
Sua API já faz tudo — e é exatamente esse o problema: qualquer pessoa com um terminal pode excluir o cardápio inteiro. Na próxima aula você resolve a pergunta que toda aplicação real precisa responder antes de ir ao ar: quem está fazendo esta requisição? Vamos delegar o login ao Google, verificar o token no servidor e proteger as rotas de escrita. Crie antes da aula uma conta no Google Cloud Console com o seu Gmail — isso poupa quinze minutos da prática.