Pular para o conteúdo
Cursos

DevClub

LógicaFront-endBack-endMobile

IA Club

IA na prática
Estudar programaçãoEstudar IA
LiçãoIniciantecódigo testado

O que é uma API REST: recurso, verbo, status e contrato

Recurso, verbo HTTP, status code e stateless explicados desenhando o contrato de uma API de tarefas, com cada requisição feita de verdade no curl.

Rodolfo Mori20 min de leitura

Uma API REST é um combinado: cada coisa que o sistema guarda vira um endereço (/tarefas, /tarefas/2), e o que você quer fazer com ela vira um verbo do HTTP (GET, POST, PUT, DELETE). O servidor responde com um número de status e uma representação em JSON. É esse combinado que deixa um app feito por uma pessoa conversar com um back-end feito por outra.

Toda esta lição é a API da Escola Vila Nova: professores publicam tarefas para as turmas, e o app dos alunos consome essas tarefas. O servidor roda em Node 24.16.0 com Express 5.2.1, num MacBook, e cada resposta colada aqui saiu do curl do meu terminal — inclusive os erros.

Um balcão com endereços, pedidos e recibos

Imagine a secretaria da Escola Vila Nova. Cada arquivo tem um endereço claro: as tarefas ficam num armário, e a tarefa 2 fica numa pasta específica. A pessoa chega ao balcão, diz se quer consultar, criar, trocar ou apagar algo e recebe um recibo dizendo o que aconteceu. Ela não entra no arquivo nem precisa saber como a escola o organiza por dentro.

Na API REST, o armário é o recurso, o endereço é a URL, o pedido usa um método HTTP, e o recibo é o status code acompanhado de uma representação, normalmente JSON. Ao ler as primeiras chamadas com curl, tente nomear essas quatro peças antes de olhar a explicação. Se endereço, verbo e resposta contam a mesma história, o contrato está funcionando.

Elas aparecem juntas o tempo todo e significam coisas diferentes.

API é interface de programação: o combinado de como um programa fala com outro. Math.random() é API. O botão de compartilhar do celular é API. Nem toda API passa pela rede.

HTTP é o protocolo — o formato do envelope que vai e volta pela rede. Peça ao curl o envelope inteiro com -v: as linhas abertas por > são o que saiu da sua máquina, as abertas por < são o que voltou do servidor, e as com asterisco são o curl narrando a conexão.

bash
curl -v http://localhost:3939/tarefas/2
* Host localhost:3939 was resolved. * IPv6: ::1 * IPv4: 127.0.0.1 * Trying [::1]:3939... * Connected to localhost (::1) port 3939 > GET /tarefas/2 HTTP/1.1 > Host: localhost:3939 > User-Agent: curl/8.7.1 > Accept: */* > * Request completely sent off < HTTP/1.1 200 OK < X-Powered-By: Express < Content-Type: application/json; charset=utf-8 < Content-Length: 122 < ETag: W/"7a-Q+ogjIvtZpe8rnV6gn4vnbrl8os" < Date: Sat, 22 Aug 2026 22:54:06 GMT < Connection: keep-alive < Keep-Alive: timeout=5 < * Connection #0 to host localhost left intact {"id":2,"titulo":"Lista de equações do 2º grau","turma":"9A","professor":"heitor","prazo":"2026-06-12","entregue":true}

Uma requisição HTTP tem três partes fixas — método (GET), caminho (/tarefas/2) e cabeçalhos — e, às vezes, um corpo. A resposta segue o mesmo desenho: status (200 OK), cabeçalhos, uma linha em branco e o corpo.

REST não é nada disso. É um estilo de usar o HTTP, descrito por Roy Fielding em 2000. Não se instala, não se importa, não tem versão. É um conjunto de decisões sobre como nomear as coisas e qual verbo usar em cada uma.

cliente app da escola, curl requisição POST /tarefas 201 Created Location: /tarefas/4 servidor Node + Express express.json() transforma o corpo em req.body roteador casa método + caminho handler escolhe o status e monta o JSON sem sessão: nada fica guardado entre requisições

O arquivo que implementa esse ciclo começa assim. As rotas entram no meio, e cada seção desta lição acrescenta uma:

js
import express from 'express';

const app = express();
app.use(express.json());

let proximoId = 4;

const tarefas = [
  { id: 1, titulo: 'Ler o capítulo 3 de Dom Casmurro', turma: '9A', professor: 'marina', prazo: '2026-06-15', entregue: false },
  { id: 2, titulo: 'Lista de equações do 2º grau', turma: '9A', professor: 'heitor', prazo: '2026-06-12', entregue: true },
  { id: 3, titulo: 'Maquete do sistema solar', turma: '7B', professor: 'marina', prazo: '2026-06-20', entregue: false },
];

app.listen(3939, () => {
  console.log('API da Escola Vila Nova em http://localhost:3939/tarefas');
});

Para rodar isso na sua máquina são três comandos numa pasta vazia. O type: module é o que libera o import dentro de um arquivo .js:

bash
npm init -y
npm pkg set type=module
npm install express
added 68 packages, and audited 69 packages in 449ms

Com o código salvo em servidor.js, a API sobe e fica esperando:

bash
node servidor.js
API da Escola Vila Nova em http://localhost:3939/tarefas

Esse terminal fica ocupado com o servidor; os curl daqui para baixo saem de um segundo terminal.

O Express é só o carregador de rotas aqui; o que ele faz linha a linha é assunto da primeira rota no Express. O que interessa nesta lição é o combinado, não o framework.

Recurso é substantivo: /tarefas, nunca /buscarTarefa

Recurso é a coisa que a sua API expõe. Na Vila Nova são quatro: tarefa, turma, aluno e professor. Cada uma vira um caminho, no plural e em minúsculas.

o que é caminho devolve
coleção /tarefas todas as tarefas
item /tarefas/2 a tarefa de id 2
subcoleção /turmas/9A/tarefas as tarefas da turma 9A

A coleção é a porta de entrada, e o handler dela não faz nada além de devolver o array inteiro:

js
app.get('/tarefas', (req, res) => {
  res.json(tarefas);
});

O curl mostra o JSON como ele viaja — uma linha só, sem espaço nenhum — então joguei a saída no json_pp, que já vem no macOS e na maioria das distribuições Linux:

bash
curl -s http://localhost:3939/tarefas | json_pp
[ { "entregue" : false, "id" : 1, "prazo" : "2026-06-15", "professor" : "marina", "titulo" : "Ler o capítulo 3 de Dom Casmurro", "turma" : "9A" }, { "entregue" : true, "id" : 2, "prazo" : "2026-06-12", "professor" : "heitor", "titulo" : "Lista de equações do 2º grau", "turma" : "9A" }, { "entregue" : false, "id" : 3, "prazo" : "2026-06-20", "professor" : "marina", "titulo" : "Maquete do sistema solar", "turma" : "7B" } ]

Repare no que não aparece nos caminhos: nenhum verbo. Nada de /buscarTarefa, /criarTarefa, /deletarTarefa. O caminho diz qual coisa; quem diz o que fazer é o método HTTP. É por isso que a API inteira cabe em três caminhos — /tarefas, /tarefas/:id e /turmas/:turma/tarefas — em vez de vinte.

Vale ver o estrago do jeito errado, que existe em muito código real:

js
// como NÃO fazer: verbo no caminho e status 200 para tudo
app.get('/buscarTarefa', (req, res) => {
  const tarefa = tarefas.find((t) => t.id === Number(req.query.id));
  res.json({ sucesso: Boolean(tarefa), tarefa: tarefa ?? null });
});
bash
curl -i "http://localhost:3939/buscarTarefa?id=99"
HTTP/1.1 200 OK X-Powered-By: Express Content-Type: application/json; charset=utf-8 Content-Length: 31 ETag: W/"1f-fli8cdNPkgC9/EfWrthWA71PWF8" Date: Sat, 22 Aug 2026 22:54:14 GMT Connection: keep-alive Keep-Alive: timeout=5

{“sucesso”:false,“tarefa”:null}

A tarefa 99 não existe e o servidor respondeu 200 OK. Para o navegador, para o cache, para o monitoramento e para qualquer biblioteca de HTTP, essa requisição deu certo. O erro ficou escondido dentro do corpo, onde só o seu if enxerga.

O mesmo pedido no estilo REST tem este handler:

js
app.get('/tarefas/:id', (req, res) => {
  const tarefa = tarefas.find((t) => t.id === Number(req.params.id));
  if (!tarefa) {
    return res.status(404).json({ erro: `Tarefa ${req.params.id} não existe` });
  }
  res.json(tarefa);
});
bash
curl -i http://localhost:3939/tarefas/99
HTTP/1.1 404 Not Found X-Powered-By: Express Content-Type: application/json; charset=utf-8 Content-Length: 32 ETag: W/"20-4+4Ql2SshCKPglGHAbS/2YltDxM" Date: Sat, 22 Aug 2026 22:54:14 GMT Connection: keep-alive Keep-Alive: timeout=5

{“erro”:“Tarefa 99 não existe”}

Mesmo dado faltando, resposta honesta. O :id do caminho chega no handler dentro de req.params — a diferença entre ele, req.query e req.body tem uma lição só para ela mais para a frente.

O verbo carrega a intenção — e é aí que entra a idempotência

Com o recurso definido, o método HTTP escolhe a operação. São cinco no dia a dia:

verbo o que faz leva corpo idempotente
GET lê, e nunca muda nada não sim
POST cria um item novo na coleção sim não
PUT substitui o item inteiro sim sim
PATCH altera alguns campos do item sim depende
DELETE remove o item não sim

Idempotente é a palavra que cai em entrevista e assusta sem motivo. Quer dizer: repetir a mesma requisição deixa o servidor no mesmo estado em que uma única chamada deixaria. Não é a resposta que precisa ser igual — é o efeito.

Dá para provar. O handler do POST é este:

js
app.post('/tarefas', (req, res) => {
  const nova = {
    id: proximoId++,
    titulo: req.body.titulo,
    turma: req.body.turma,
    professor: req.body.professor,
    prazo: req.body.prazo,
    entregue: false,
  };
  tarefas.push(nova);
  res.status(201).location(`/tarefas/${nova.id}`).json(nova);
});

Mandei exatamente esta requisição, duas vezes seguidas:

bash
curl -i -X POST http://localhost:3939/tarefas \
  -H "Content-Type: application/json" \
  -d '{"titulo":"Resumo do capítulo 4","turma":"9A","professor":"marina","prazo":"2026-06-22"}'
HTTP/1.1 201 Created X-Powered-By: Express Location: /tarefas/4 Content-Type: application/json; charset=utf-8 Content-Length: 113 ETag: W/"71-cEYoVUTv2qAkGJ/kpf4sN2RYsaU" Date: Sat, 22 Aug 2026 22:54:14 GMT Connection: keep-alive Keep-Alive: timeout=5

{“id”:4,“titulo”:“Resumo do capítulo 4”,“turma”:“9A”,“professor”:“marina”,“prazo”:“2026-06-22”,“entregue”:false}

E a segunda, mandada logo em seguida:

HTTP/1.1 201 Created X-Powered-By: Express Location: /tarefas/5 Content-Type: application/json; charset=utf-8 Content-Length: 113 ETag: W/"71-ZqYExNmm0Z7I+Mf/nO0yOhzN2ls" Date: Sat, 22 Aug 2026 22:54:14 GMT Connection: keep-alive Keep-Alive: timeout=5

{“id”:5,“titulo”:“Resumo do capítulo 4”,“turma”:“9A”,“professor”:“marina”,“prazo”:“2026-06-22”,“entregue”:false}

Duas tarefas, ids 4 e 5. É por isso que POST não é idempotente, e é por isso que o botão “salvar” clicado duas vezes num sinal ruim de celular cria dois cadastros.

Agora o PUT, que manda o item inteiro e substitui o que estava lá:

js
app.put('/tarefas/:id', (req, res) => {
  const i = tarefas.findIndex((t) => t.id === Number(req.params.id));
  if (i === -1) return res.status(404).json({ erro: 'Tarefa não existe' });

  tarefas[i] = {
    id: tarefas[i].id,
    titulo: req.body.titulo,
    turma: req.body.turma,
    professor: req.body.professor,
    prazo: req.body.prazo,
    entregue: req.body.entregue ?? false,
  };
  res.json(tarefas[i]);
});
bash
curl -i -X PUT http://localhost:3939/tarefas/1 \
  -H "Content-Type: application/json" \
  -d '{"titulo":"Ler o capítulo 3 de Dom Casmurro","turma":"9A","professor":"marina","prazo":"2026-06-18","entregue":true}'
HTTP/1.1 200 OK X-Powered-By: Express Content-Type: application/json; charset=utf-8 Content-Length: 124 ETag: W/"7c-/H0CHZ+0JPV1goQ6rrnQIMcwy2M" Date: Sat, 22 Aug 2026 22:54:14 GMT Connection: keep-alive Keep-Alive: timeout=5

{“id”:1,“titulo”:“Ler o capítulo 3 de Dom Casmurro”,“turma”:“9A”,“professor”:“marina”,“prazo”:“2026-06-18”,“entregue”:true}

Repetindo o mesmo comando, a resposta volta idêntica — inclusive o ETag, que é uma assinatura do conteúdo:

HTTP/1.1 200 OK X-Powered-By: Express Content-Type: application/json; charset=utf-8 Content-Length: 124 ETag: W/"7c-/H0CHZ+0JPV1goQ6rrnQIMcwy2M" Date: Sat, 22 Aug 2026 22:54:14 GMT Connection: keep-alive Keep-Alive: timeout=5

{“id”:1,“titulo”:“Ler o capítulo 3 de Dom Casmurro”,“turma”:“9A”,“professor”:“marina”,“prazo”:“2026-06-18”,“entregue”:true}

Duas chamadas, um resultado só, nenhuma tarefa a mais. Esse é o sentido prático de idempotência: dá para repetir sem medo, e é o que permite ao app tentar de novo quando a rede cai no meio do envio.

O PATCH existe para o caso mais comum da escola: o aluno marcou a tarefa como entregue e nada mais mudou.

js
app.patch('/tarefas/:id', (req, res) => {
  const tarefa = tarefas.find((t) => t.id === Number(req.params.id));
  if (!tarefa) return res.status(404).json({ erro: 'Tarefa não existe' });

  Object.assign(tarefa, req.body, { id: tarefa.id });
  res.json(tarefa);
});
bash
curl -i -X PATCH http://localhost:3939/tarefas/3 \
  -H "Content-Type: application/json" -d '{"entregue":true}'
HTTP/1.1 200 OK X-Powered-By: Express Content-Type: application/json; charset=utf-8 Content-Length: 115 ETag: W/"73-/Y5L/HBXwgUEgNGh21XlUfs3HyU" Date: Sat, 22 Aug 2026 22:54:14 GMT Connection: keep-alive Keep-Alive: timeout=5

{“id”:3,“titulo”:“Maquete do sistema solar”,“turma”:“7B”,“professor”:“marina”,“prazo”:“2026-06-20”,“entregue”:true}

Um campo no corpo, o resto intacto. Se essa mesma tela usasse PUT mandando só {"entregue":true}, o título, a turma e o prazo virariam undefined — o PUT substitui, não mescla. É o erro de design mais caro desta lição.

O DELETE mostra a parte que confunde. O handler tira o item do array e responde sem corpo nenhum:

js
app.delete('/tarefas/:id', (req, res) => {
  const i = tarefas.findIndex((t) => t.id === Number(req.params.id));
  if (i === -1) return res.status(404).json({ erro: 'Tarefa não existe' });

  tarefas.splice(i, 1);
  res.status(204).end();
});

Apaguei a tarefa 5 e mandei o mesmo comando outra vez:

bash
curl -i -X DELETE http://localhost:3939/tarefas/5
HTTP/1.1 204 No Content X-Powered-By: Express Date: Sat, 22 Aug 2026 22:54:14 GMT Connection: keep-alive Keep-Alive: timeout=5

Depois dos cabeçalhos não veio nada: 204 quer dizer “deu certo e não tenho o que te mostrar”. Na segunda vez, com a tarefa já apagada:

HTTP/1.1 404 Not Found X-Powered-By: Express Content-Type: application/json; charset=utf-8 Content-Length: 29 ETag: W/"1d-9dxQ4p+ziLd1lNzazXdmJyEEDtg" Date: Sat, 22 Aug 2026 22:54:14 GMT Connection: keep-alive Keep-Alive: timeout=5

{“erro”:“Tarefa não existe”}

O status mudou de 204 para 404, mas o estado do servidor é o mesmo nos dois casos: a tarefa 5 não existe. DELETE continua idempotente. O que muda é só o relato.

E o servidor sabe dizer quais verbos aceita em cada caminho. O Express responde OPTIONS sozinho, montando o cabeçalho Allow a partir das rotas que você registrou:

bash
curl -i -X OPTIONS http://localhost:3939/tarefas
HTTP/1.1 200 OK X-Powered-By: Express Allow: GET, HEAD, POST Content-Length: 15 Content-Type: text/plain X-Content-Type-Options: nosniff Date: Sat, 22 Aug 2026 22:54:14 GMT Connection: keep-alive Keep-Alive: timeout=5

GET, HEAD, POST

bash
curl -i -X OPTIONS http://localhost:3939/tarefas/2
HTTP/1.1 200 OK X-Powered-By: Express Allow: DELETE, GET, HEAD, PATCH, PUT Content-Length: 29 Content-Type: text/plain X-Content-Type-Options: nosniff Date: Sat, 22 Aug 2026 22:54:14 GMT Connection: keep-alive Keep-Alive: timeout=5

DELETE, GET, HEAD, PATCH, PUT

Esses dois Allow são o desenho da API inteira em duas linhas: na coleção você lista e cria; no item você lê, substitui, altera e apaga. Não existe DELETE /tarefas, porque apagar todas as tarefas da escola de uma vez não é uma operação que alguém deva conseguir fazer sem querer.

O status code é a primeira coisa que o cliente lê

O número de três dígitos vem antes de qualquer JSON, e o primeiro dígito já conta a história: 2xx deu certo, 3xx mudou de lugar, 4xx o cliente errou, 5xx o servidor errou. Essa divisão é o que decide quem é acordado às duas da manhã.

status quando a Vila Nova usa corpo
200 OK leitura, PUT e PATCH que deram certo o recurso
201 Created POST que criou a tarefa o recurso criado, mais o cabeçalho Location
204 No Content DELETE que apagou vazio, de propósito
400 Bad Request faltou titulo no corpo o que estava errado
401 Unauthorized não veio quem é o professor como se identificar
404 Not Found a tarefa 99 não existe uma mensagem curta
415 Unsupported Media Type o corpo não veio como JSON o formato aceito
500 Internal Server Error o seu código quebrou nada de detalhe interno

Dois detalhes separam uma API amadora de uma profissional, e os dois estão no 201 de agora há pouco. O primeiro é o cabeçalho Location: /tarefas/4: o servidor diz onde a coisa nova mora, e o cliente não precisa adivinhar o endereço. O segundo é devolver o recurso criado já com o id preenchido — sem isso, o app teria que fazer outra requisição só para descobrir o número.

Do outro lado da tabela, o 204 é o único que não pode ter corpo — mandar JSON junto com ele é erro de protocolo, não questão de estilo. Fora da tabela existe outro caso assim, o 304 Not Modified, e ele aparece no fim desta lição. A tabela completa, com os status que aparecem menos, está em métodos HTTP e status code.

Representação: o JSON é uma foto do recurso, não o recurso

O recurso é a tarefa — uma linha no banco, um objeto na memória do servidor. O que viaja pela rede nunca é a tarefa: é uma representação dela. O cabeçalho Content-Type avisa em que formato a foto foi tirada, e o Accept, na requisição, é o cliente dizendo em que formato prefere receber.

Quando o cliente manda um corpo, é ele quem precisa declarar o formato. E aqui mora o erro que derruba todo mundo na primeira API. Mandei o POST esquecendo o cabeçalho, e cortei a saída nos cabeçalhos porque o corpo é uma página HTML de erro:

bash
curl -i -X POST http://localhost:3939/tarefas \
  -d '{"titulo":"Ficha de leitura","turma":"7B","professor":"marina","prazo":"2026-06-25"}' | head -9
HTTP/1.1 500 Internal Server Error X-Powered-By: Express Content-Security-Policy: default-src 'none' X-Content-Type-Options: nosniff Content-Type: text/html; charset=utf-8 Content-Length: 1081 Date: Sat, 22 Aug 2026 22:55:09 GMT Connection: keep-alive Keep-Alive: timeout=5

No terminal onde o servidor está rodando, apareceu o motivo:

POST /tarefas · X-Professor: (ausente) TypeError: Cannot read properties of undefined (reading 'titulo') at file:///private/tmp/vila-nova/servidor.js:37:22 at Layer.handleRequest (/private/tmp/vila-nova/node_modules/router/lib/layer.js:152:17) at next (/private/tmp/vila-nova/node_modules/router/lib/route.js:157:13) at Route.dispatch (/private/tmp/vila-nova/node_modules/router/lib/route.js:117:3) at handle (/private/tmp/vila-nova/node_modules/router/index.js:435:11) at Layer.handleRequest (/private/tmp/vila-nova/node_modules/router/lib/layer.js:152:17) at /private/tmp/vila-nova/node_modules/router/index.js:295:15 at processParams (/private/tmp/vila-nova/node_modules/router/index.js:582:12) at next (/private/tmp/vila-nova/node_modules/router/index.js:291:5) at file:///private/tmp/vila-nova/servidor.js:16:3

A linha 37 do servidor é titulo: req.body.titulo, e o erro diz que req.body está undefined. Sem o Content-Type, o curl mandou o corpo como application/x-www-form-urlencoded; o express.json() olhou o cabeçalho, viu que não era JSON e não fez nada — sem reclamar. O JSON chegou ao servidor e ninguém o traduziu.

A correção é o servidor parar de confiar no cliente. Duas verificações no começo do handler resolvem, e cada uma tem o seu status:

js
app.post('/tarefas', (req, res) => {
  if (!req.is('application/json')) {
    return res.status(415).json({ erro: 'Envie o cabeçalho Content-Type: application/json' });
  }
  if (!req.body.titulo || !req.body.turma) {
    return res.status(400).json({ erro: 'titulo e turma são obrigatórios' });
  }

  // ... daqui para baixo, o mesmo código de antes
});

Com isso no lugar, a requisição sem cabeçalho vira uma resposta que ensina o cliente a se corrigir:

bash
curl -i -X POST http://localhost:3939/tarefas \
  -d '{"titulo":"Ficha de leitura","turma":"7B","professor":"marina","prazo":"2026-06-25"}'
HTTP/1.1 415 Unsupported Media Type X-Powered-By: Express Content-Type: application/json; charset=utf-8 Content-Length: 60 ETag: W/"3c-0fejpkZUCe7x8q3WBgBcfCbKBmw" Date: Sat, 22 Aug 2026 22:54:45 GMT Connection: keep-alive Keep-Alive: timeout=5

{“erro”:“Envie o cabeçalho Content-Type: application/json”}

E um JSON legítimo, mas sem o título, também:

bash
curl -i -X POST http://localhost:3939/tarefas \
  -H "Content-Type: application/json" -d '{"turma":"7B","professor":"marina"}'
HTTP/1.1 400 Bad Request X-Powered-By: Express Content-Type: application/json; charset=utf-8 Content-Length: 44 ETag: W/"2c-cQnPGHj+zO17sphC2nC6Y+gNka0" Date: Sat, 22 Aug 2026 22:54:45 GMT Connection: keep-alive Keep-Alive: timeout=5

{“erro”:“titulo e turma são obrigatórios”}

Duas linhas de if transformaram um 500 (culpa minha, o servidor quebrou) em 415 e 400 (culpa do cliente, e ele foi avisado do que fazer). Escrever isso campo a campo cansa rápido, e é para isso que existem bibliotecas de validação — assunto de validar a entrada da API. Se o JSON ainda é meio estranho para você, a lição de JSON em JavaScript mostra o parse e o stringify que o Express chama por baixo.

Stateless: o servidor esquece você entre uma chamada e outra

Essa é a restrição do REST que mais gente ignora e a que mais dói depois. Stateless quer dizer que cada requisição carrega tudo que o servidor precisa para respondê-la. Não existe “de onde paramos”.

A rota abaixo só responde a quem se identifica no cabeçalho:

js
app.get('/minhas-tarefas', (req, res) => {
  const professor = req.header('X-Professor');
  if (!professor) {
    return res.status(401).json({ erro: 'Envie o cabeçalho X-Professor em toda requisição' });
  }
  res.json(tarefas.filter((t) => t.professor === professor));
});

Chamei duas vezes, uma atrás da outra, tirando o cabeçalho na segunda:

bash
curl -i http://localhost:3939/minhas-tarefas -H "X-Professor: marina"
HTTP/1.1 200 OK X-Powered-By: Express Content-Type: application/json; charset=utf-8 Content-Length: 356 ETag: W/"164-LVeO1Z8p3Z26Y7fb7jvFSzWh40I" Date: Sat, 22 Aug 2026 22:54:14 GMT Connection: keep-alive Keep-Alive: timeout=5

[{“id”:1,“titulo”:“Ler o capítulo 3 de Dom Casmurro”,“turma”:“9A”,“professor”:“marina”,“prazo”:“2026-06-18”,“entregue”:true},{“id”:3,“titulo”:“Maquete do sistema solar”,“turma”:“7B”,“professor”:“marina”,“prazo”:“2026-06-20”,“entregue”:true},{“id”:4,“titulo”:“Resumo do capítulo 4”,“turma”:“9A”,“professor”:“marina”,“prazo”:“2026-06-22”,“entregue”:false}]

bash
curl -i http://localhost:3939/minhas-tarefas
HTTP/1.1 401 Unauthorized X-Powered-By: Express Content-Type: application/json; charset=utf-8 Content-Length: 62 ETag: W/"3e-UWSvznaCPImrjyVy8wDZHqFD8UY" Date: Sat, 22 Aug 2026 22:54:14 GMT Connection: keep-alive Keep-Alive: timeout=5

{“erro”:“Envie o cabeçalho X-Professor em toda requisição”}

Um instante antes, o servidor sabia que era a professora Marina. Na chamada seguinte, não sabia mais nada. Um middleware de três linhas, colado antes das rotas, mostra exatamente o que ele viu nas duas:

js
app.use((req, res, next) => {
  console.log(`${req.method} ${req.originalUrl} · X-Professor: ${req.header('X-Professor') ?? '(ausente)'}`);
  next();
});
GET /minhas-tarefas · X-Professor: marina GET /minhas-tarefas · X-Professor: (ausente)

Parece um defeito e é uma escolha de projeto. Se o servidor não guarda nada sobre você, qualquer cópia dele consegue atender a sua próxima requisição — dá para pôr três máquinas atrás de um balanceador de carga e cair em qualquer uma delas sem perder o fio. Foi assim que a web escalou. O preço é que o cliente repete a identificação em toda chamada, e no mundo real isso vira o token do JWT viajando no cabeçalho Authorization de cada requisição.

O contrato da Vila Nova, escrito antes do código

Falta uma rota para o desenho ficar completo: a subcoleção, que é o que o app abre quando o aluno escolhe a turma dele. Repare que ela filtra por turma, e não inventa um caminho novo para isso.

js
app.get('/turmas/:turma/tarefas', (req, res) => {
  const daTurma = tarefas.filter((t) => t.turma === req.params.turma.toUpperCase());
  res.json(daTurma);
});
bash
curl -i http://localhost:3939/turmas/9a/tarefas
HTTP/1.1 200 OK X-Powered-By: Express Content-Type: application/json; charset=utf-8 Content-Length: 363 ETag: W/"16b-QDjWw+heMFVdtuhqP+F7BQaJP6w" Date: Sat, 22 Aug 2026 23:01:35 GMT Connection: keep-alive Keep-Alive: timeout=5

[{“id”:1,“titulo”:“Ler o capítulo 3 de Dom Casmurro”,“turma”:“9A”,“professor”:“marina”,“prazo”:“2026-06-18”,“entregue”:true},{“id”:2,“titulo”:“Lista de equações do 2º grau”,“turma”:“9A”,“professor”:“heitor”,“prazo”:“2026-06-12”,“entregue”:true},{“id”:4,“titulo”:“Resumo do capítulo 4”,“turma”:“9A”,“professor”:“marina”,“prazo”:“2026-06-22”,“entregue”:false}]

Três tarefas da 9A, com a tarefa 1 já atualizada pelo PUT e a tarefa 4 criada pelo POST lá em cima. Uma coleção que não devolve nada não é erro: uma turma sem tarefa responde 200 com uma lista vazia, e não 404. O 404 é para o recurso que não existe, não para a lista que está vazia.

Junte recurso, verbo e status e você tem o contrato — o documento que o front-end precisa para começar a trabalhar sem esperar o seu back-end ficar pronto. É a primeira coisa que eu escrevo, sempre em tabela, sempre antes da primeira linha de código:

verbo e caminho o que faz corpo que entra resposta
GET /tarefas lista tudo 200 + array
GET /tarefas/:id uma tarefa 200 + objeto, ou 404
POST /tarefas cria titulo, turma, professor, prazo 201 + Location, ou 400
PUT /tarefas/:id substitui inteira o objeto completo 200, ou 404
PATCH /tarefas/:id muda um campo só o que mudou 200, ou 404
DELETE /tarefas/:id apaga 204, ou 404
GET /turmas/:turma/tarefas as tarefas da turma 200 + array

Sete linhas descrevem a API inteira, e nenhuma delas fala de Express, de banco de dados ou de linguagem. Esse mesmo contrato pode ser reescrito em Python ou em Go sem o app do aluno perceber a troca — é essa a promessa do REST.

Quando o projeto cresce, a tabela vira um arquivo no formato OpenAPI, que ferramentas leem para gerar documentação e até o cliente HTTP pronto. Mas a tabela vem primeiro; o formato é detalhe.

Onde REST não é a melhor escolha

REST é ótimo para operações sobre recursos bem definidos, que é a maioria esmagadora do trabalho. Ele começa a incomodar em dois pontos.

O primeiro é a quantidade de idas e voltas. A tela inicial do app da Vila Nova mostra as turmas e as tarefas de cada uma; com recursos separados, isso são três requisições em vez de uma. Falta um recurso para a conta fechar — a lista de turmas:

js
app.get('/turmas', (req, res) => {
  const nomes = [...new Set(tarefas.map((t) => t.turma))];
  res.json(nomes.map((nome) => ({ nome })));
});

Medi as duas situações contra o localhost, com curl -w "%{time_total}", em cinco rodadas:

bash
for i in 1 2 3 4 5; do
  um=$(curl -s -o /dev/null -w "%{time_total}" http://localhost:3939/tarefas)
  a=$(curl -s -o /dev/null -w "%{time_total}" http://localhost:3939/turmas)
  b=$(curl -s -o /dev/null -w "%{time_total}" http://localhost:3939/turmas/9A/tarefas)
  c=$(curl -s -o /dev/null -w "%{time_total}" http://localhost:3939/turmas/7B/tarefas)
  node -e "const [u,a,b,c]=process.argv.slice(1).map(Number);console.log('rodada $i  1 requisicao: '+(u*1000).toFixed(1)+'ms   3 requisicoes: '+((a+b+c)*1000).toFixed(1)+'ms')" $um $a $b $c
done
rodada 1 1 requisicao: 0.5ms 3 requisicoes: 1.8ms rodada 2 1 requisicao: 0.6ms 3 requisicoes: 1.5ms rodada 3 1 requisicao: 0.6ms 3 requisicoes: 1.6ms rodada 4 1 requisicao: 0.6ms 3 requisicoes: 1.5ms rodada 5 1 requisicao: 0.5ms 3 requisicoes: 1.4ms

Na minha máquina a diferença é irrelevante, porque a rede entre o curl e o servidor é zero. O número que importa aí não é o tempo: é o três. Troque o localhost por um servidor de verdade e cada ida e volta passa a custar dezenas de milissegundos de latência, que se multiplicam pela quantidade de chamadas. É esse o problema que o GraphQL nasceu para resolver, deixando o cliente pedir num único envelope exatamente os campos que vai usar.

O segundo ponto é o tempo real. Em REST é sempre o cliente que pergunta; se a professora publica uma tarefa agora, o app do aluno só descobre na próxima pergunta. Para o servidor avisar sozinho, o caminho é WebSocket ou Server-Sent Events. E entre serviços de back-end, onde ninguém precisa ler o JSON com olho humano, o gRPC troca legibilidade por velocidade.

Nenhum desses substitui REST — eles convivem no mesmo sistema. A API pública continua REST porque qualquer pessoa consegue testá-la com um curl, como você acabou de fazer.

REST, RESTful e as regras que quase ninguém segue

A definição original tem seis restrições: cliente-servidor, stateless, cache, interface uniforme, sistema em camadas e código sob demanda. As duas primeiras você viu funcionando página acima.

A terceira, cache, esteve o tempo todo naquele ETag que apareceu em quase toda resposta. Se o cliente devolve a assinatura que recebeu, no cabeçalho If-None-Match, o servidor compara e responde que nada mudou:

bash
curl -i http://localhost:3939/tarefas/2 \
  -H 'If-None-Match: W/"7a-Q+ogjIvtZpe8rnV6gn4vnbrl8os"'
HTTP/1.1 304 Not Modified X-Powered-By: Express ETag: W/"7a-Q+ogjIvtZpe8rnV6gn4vnbrl8os" Date: Sat, 22 Aug 2026 23:24:38 GMT Connection: keep-alive Keep-Alive: timeout=5

304 Not Modified, sem uma linha de JSON no fio — é o segundo status que não pode ter corpo. A cópia que o cliente já tinha continua valendo, e quem ganhou o tráfego economizado foi todo mundo no caminho. Repare que essa é a mesma assinatura da primeira resposta desta lição: a tarefa 2 nunca foi alterada.

A quarta, interface uniforme, é o pacote que esta lição inteira montou: recurso no caminho, verbo no método, status na resposta, representação no corpo.

Falta a parte que quase ninguém implementa: cada resposta deveria trazer os links do que se pode fazer em seguida — o tal do HATEOAS. Na prática, o mais perto disso que uma API brasileira típica chega é o cabeçalho Location do 201 que apareceu ali em cima.

Uma decisão pragmática para a primeira API: não perca o sono com HATEOAS. Comece por recurso bem nomeado, método adequado, status coerente, JSON previsível e mensagem de erro que ajuda. Fazer bem essas cinco coisas cria um contrato que cliente, teste e documentação conseguem entender; HATEOAS pode entrar quando o produto realmente precisar orientar ações por links.

O que vem depois

O contrato está de pé; falta escrever o servidor com calma. A próxima lição instala o Express e monta a primeira rota do zero, explicando cada linha do arquivo que apareceu picotado aqui — e mostrando por que ninguém escreve API no node:http cru depois de conhecer o framework. O caminho inteiro, daqui até o deploy, está no guia de Node.

Prefere aprender em vídeo?

Tem uma aula sobre este assunto no nosso canal.

Ver todos os vídeos do canal
  • api
  • rest
  • http
  • back-end
  • json
  • contrato

Perguntas frequentes

API REST e RESTful são a mesma coisa?
No mercado, sim: as duas viraram sinônimo de "API que fala HTTP e JSON, com recurso no caminho e verbo no método". Na definição original, RESTful seria a API que cumpre todas as restrições do REST, inclusive devolver os links de navegação em cada resposta — o que quase nenhuma API brasileira faz.
Preciso de banco de dados para criar uma API REST?
Não para aprender. Um array na memória já responde requisição e ensina recurso, verbo e status. O que você perde é a persistência: reiniciou o servidor, os dados voltam ao estado inicial. O banco entra depois, quando o contrato já está de pé.
Dá para uma API REST devolver XML ou CSV em vez de JSON?
Dá. REST não obriga formato nenhum: o cliente pede o que quer no cabeçalho Accept e o servidor avisa o que mandou no Content-Type. JSON venceu por ser curto e nativo do JavaScript, mas relatório em CSV e integração bancária em XML existem aos montes.
Por que a resposta precisa de status code se o corpo já diz o que houve?
Porque o corpo é para o seu código, e o status é para toda a infraestrutura entre os dois. Cache, balanceador, proxy, monitoramento e as bibliotecas de HTTP olham o número, não o texto. Uma API que devolve 200 com "sucesso: false" fica invisível para o alerta de erro.

Dúvidas e comentários

Travou em algum passo? Pergunte aqui — a equipe e outros alunos respondem.

Todo o código deste artigo foi executado em Node 24.16.0 com Express 5.2.1, e as saídas exibidas são as reais — como produzimos este conteúdo.

Fontes consultadas

  1. MDN — HTTP request methods — developer.mozilla.org
  2. MDN — HTTP response status codes — developer.mozilla.org
  3. RFC 9110 — HTTP Semantics — rfc-editor.org
  4. Roy Fielding — Architectural Styles and the Design of Network-based Software Architectures, cap. 5 — ics.uci.edu

Continue por aqui