Competições Senac RS · Seletiva 26
Módulo 04 · Apostila teórica
Módulo 04 — Back-end e API RESTful
Onde esse módulo entra. É o Módulo B da prova (2h30, 1º dia), a metade "servidor" do sistema. O banco (módulo 03) guarda os dados; a API é quem serve esses dados pro mundo e guarda as regras de negócio. Vale 20%, e sustenta o front (40%): um front lindo consumindo uma API quebrada não pontua. Natureza 🔄 revisão, as duas já fizeram API na Regional. O jogo agora é fazer uma API limpa, segura e correta que chegue no nível 3.
Como ler o código deste módulo: o conceito é sempre um só, mas onde a sintaxe muda de verdade o exemplo aparece nas duas stacks: Node.js + Express + MySQL e PHP + MySQL (PHP puro, sem framework). O SQL por baixo é o mesmo dos dois lados, é o banco do módulo 03. No treino a gente domina uma stack como principal e conhece a outra o bastante pra não travar se o cenário mudar.
1. O que é uma API e por que ela existe
API é a camada que fica entre o mundo (o front, o app, outro sistema) e o banco. Ninguém fala com o banco direto: fala com a API, e ela decide o que pode e o que não pode.
1.1 Estático vs dinâmico: os dois tipos de conteúdo web
O plano de curso (UC13) pede essa distinção, e ela explica a arquitetura inteira do sistema:
- Conteúdo estático: arquivo pronto no servidor, entregue igual pra todo mundo. O HTML, o CSS e o JS do front do Wedding Pass são estáticos: o servidor só entrega o arquivo.
- Conteúdo dinâmico: resposta montada na hora, consultando o banco. A lista de convidados é diferente a cada requisição (alguém confirmou, alguém fez check-in). Isso é a API.
O sistema da prova junta os dois: um front estático (módulo 05) que busca dados dinâmicos na API. A separação é limpa: arquivos de um lado, dados do outro.
1.2 Agnóstica ao cliente: a fonte única da verdade
O descritivo diz uma frase que vale ouro: a API tem que ser agnóstica ao cliente. Quer dizer: não importa quem está consumindo (o front web, um app mobile, o Postman), a regra é a mesma, porque ela mora no servidor. A API é a fonte única da verdade.
🎯 A consequência prática disso. Regra de negócio (não deixar check-in duplo, não passar da capacidade, só admin pode apagar) vive na API, nunca só no front. O front pode até esconder um botão, mas quem impede de verdade é o back. Se a regra só existe no front, qualquer um que chame a API direto fura ela. Isso é conceito de mercado e é foco de prova: a banca testa chamando a API por fora do front.
1.3 Anatomia de uma requisição e de uma resposta
Toda conversa com a API tem o mesmo formato. A requisição:
POST /convidados HTTP/1.1
Host: localhost:3000
Content-Type: application/json
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
{ "nome": "Marina Souza", "casamento_id": 1, "acompanhantes": 2 }
Quatro partes: o método (POST), a rota (/convidados), os cabeçalhos (o Content-Type avisa que o corpo é JSON; o Authorization carrega o crachá da seção 8) e o corpo (o JSON com os dados).
A resposta espelha:
HTTP/1.1 201 Created
Content-Type: application/json
{ "id": 42, "nome": "Marina Souza", "casamento_id": 1, "acompanhantes": 2 }
Três partes: o status code (201, seção 2.3), os cabeçalhos e o corpo. Ler requisição e resposta com naturalidade é o que faz o Postman (seção 15) e a aba Network do navegador virarem ferramentas de debug em vez de mistério.
2. REST: o jeito organizado de montar a API
REST é um conjunto de convenções pra deixar a API previsível. A ideia central: você trata tudo como recursos (coisas), e age sobre eles com verbos padronizados. Quem conhece a convenção adivinha a rota sem ler documentação. A banca conhece a convenção.
2.1 Recursos e rotas: substantivos no plural
Cada tipo de coisa é um recurso com um endereço. As regras que o mercado consolidou:
- Substantivo no plural, nunca verbo:
/convidados, não/listarConvidados. O verbo já está no método HTTP. - O id vem na rota:
/convidados/12é o convidado 12. - Hierarquia quando um recurso vive dentro do outro:
/casamentos/1/mesassão as mesas do casamento 1. - Ação que não é CRUD vira sub-recurso: o check-in do convidado 12 é
POST /convidados/12/checkin. Melhor que inventar/fazerCheckin?id=12.
⚠️ Pega-ratão: misturar convenções (/convidado, /Convidados, /lista_convidados no mesmo sistema). Cada rota individual funciona, mas o conjunto vira bagunça, e consistência é exatamente o tipo de coisa que o julgamento (J) do CIS enxerga. Decide o padrão no minuto 1 e não desvia.
2.2 Verbos HTTP: a ação certa pra cada coisa
| Verbo | O que faz | Exemplo no Wedding Pass | Idempotente? |
|---|---|---|---|
| GET | Buscar/listar (não muda nada) | GET /convidados |
Sim |
| POST | Criar novo | POST /convidados |
Não (cada chamada cria outro) |
| PUT | Substituir o recurso inteiro | PUT /convidados/12 manda todos os campos |
Sim |
| PATCH | Alterar parte do recurso | PATCH /convidados/12 manda só { "mesa_id": 3 } |
Na prática, sim |
| DELETE | Apagar | DELETE /convidados/12 |
Sim |
Idempotente quer dizer: chamar duas vezes dá no mesmo que chamar uma. DELETE /convidados/12 duas vezes deixa o sistema no mesmo estado (o segundo dá 404, mas nada muda). POST duas vezes cria dois registros. Essa palavra impressiona na apresentação e explica por que o check-in duplicado (seção 11.2) é um problema real: dois POSTs criam dois check-ins, a menos que alguém impeça.
PUT vs PATCH na prova: os dois atualizam. PUT formalmente substitui tudo, PATCH altera campos. Escolhe um pros seus updates e usa sempre o mesmo; consistência vale mais que a sutileza teórica.
🎯 Foco de prova: o descritivo cita "usar os verbos HTTP corretamente" de forma explícita. Isso é avaliação objetiva. Usar POST pra buscar dado, ou GET pra apagar, é o tipo de erro que a banca marca na hora. Verbo certo pra ação certa é ponto garantido, e é só disciplina. Regra prática: GET nunca muda nada no banco.
2.3 Status codes: a tabela completa da prova
Toda resposta vem com um código dizendo o que aconteceu. A família conta a história: 2xx deu certo, 4xx o cliente errou, 5xx o servidor errou. Os que o Wedding Pass usa:
| Código | Nome | Quando usar |
|---|---|---|
| 200 | OK | GET que achou, PUT/PATCH que atualizou |
| 201 | Created | POST que criou (convidado novo, check-in registrado) |
| 204 | No Content | DELETE que deu certo (sem corpo na resposta) |
| 400 | Bad Request | dado inválido: nome vazio, e-mail torto, acompanhantes negativo |
| 401 | Unauthorized | não autenticado: sem token, token vencido ou inválido |
| 403 | Forbidden | autenticado mas sem permissão: recepção tentando DELETE |
| 404 | Not Found | o recurso não existe: GET /convidados/9999 |
| 409 | Conflict | conflito com o estado atual: check-in duplicado, e-mail já cadastrado, mesa cheia |
| 500 | Internal Server Error | o servidor quebrou (bug não tratado; a seção 12 esconde o detalhe) |
A dupla que mais confunde: 401 vs 403. 401 é "eu não sei quem você é" (falta crachá). 403 é "eu sei quem você é, e você não pode" (crachá de recepção na porta da sala de admin). A banca adora perguntar essa diferença, e o front (módulo 05) trata os dois diferente: 401 manda pro login, 403 mostra "sem permissão".
Existe também o 422 (dado bem formado mas inválido pela regra), que parte do mercado usa no lugar do 400. Na prova, 400 pra dado inválido resolve tudo; de novo, consistência vale mais que sofisticação.
⚠️ Pega-ratão: responder tudo com 200, até quando deu erro. Aí o front não sabe se foi sucesso ou falha e trata errado. Pior ainda: 200 com { "sucesso": false } dentro. Status code certo é o que deixa o front reagir direito (mostrar o erro, redirecionar, avisar). É pouco esforço pra bastante clareza, e é lido na avaliação.
2.4 O mapa de endpoints do Wedding Pass
Desenhar esse mapa antes de codar é o primeiro passo do Módulo B (e a Prova Surpresa cobra exatamente essa habilidade, módulo 07). O esqueleto do sistema:
| Método + rota | O que faz | Quem pode |
|---|---|---|
POST /auth/login |
Autentica e devolve o token | público |
GET /convidados |
Lista com busca, filtro e paginação (seção 13) | logado |
GET /convidados/:id |
Detalhe de um convidado | logado |
POST /convidados |
Cadastra convidado | admin, organizador |
PUT /convidados/:id |
Atualiza convidado | admin, organizador |
DELETE /convidados/:id |
Remove convidado | admin |
GET /casamentos/:id/capacidade |
Ocupação atual + alerta 90% | logado |
GET /mesas · POST /mesas · etc. |
CRUD de mesas | admin, organizador |
POST /convidados/:id/checkin |
Check-in com bloqueio de dupla entrada | recepcao, admin |
GET /convites/:codigo |
Consulta pública do convite (RSVP) | público |
POST /convites/:codigo/resposta |
Confirma ou recusa presença | público |
Os perfis (admin, organizador, recepcao) são os do ENUM da tabela usuario do módulo 03. O fluxo completo de convite e RSVP ganha vida no módulo 07; aqui o que importa é enxergar o sistema inteiro como uma tabela dessas.
🛠️ Gambiarra boa: na prova, escreve esse mapa num papel ou num comentário no topo do projeto nos primeiros 10 minutos do Módulo B. Ele vira seu checklist de progresso (riscar rota pronta é motivador e evita esquecer endpoint) e sua resposta pronta quando a banca perguntar "como você organizou a API?".
3. Arquitetura em camadas: código organizado nas duas stacks
O plano de curso pede código orientado a objetos (UC4), padrões de projeto e MVC (UC9). Na prática de API, isso vira camadas: cada arquivo com um trabalho só.
3.1 As três camadas (e a regra de ouro)
- Rota / Controller: recebe a requisição, valida o formato, chama quem resolve e devolve a resposta com o status certo. Não faz regra de negócio, só coordena.
- Serviço (regra de negócio): onde moram as decisões. Pode fazer check-in? A mesa comporta? Passou da capacidade? Não sabe o que é HTTP nem SQL.
- Repositório (acesso a dados): quem conversa com o banco de fato. Só SQL, nada de decisão.
A regra de ouro: cada camada só fala com a vizinha de baixo. Controller chama serviço, serviço chama repositório. SQL dentro do controller é a camada pulando o muro, e é o primeiro sintoma de código que vai virar nota 1 em organização.
🛠️ Gambiarra boa: separar rende nota e velocidade. Parece burocracia, mas separar em camadas é o que deixa o código legível pra banca (ponto de julgamento) e fácil de você mesma achar o bug na hora do desespero: erro de SQL? Repositório. Regra errada? Serviço. Status errado? Controller. Quando tudo está num arquivo de 400 linhas, achar onde mexer custa minutos que você não tem.
3.2 O esqueleto em Node.js + Express
api/
├── server.js ← sobe o Express, registra middlewares e rotas
├── .env ← segredos (NUNCA vai pro Git; módulo 02, .gitignore)
├── package.json
└── src/
├── config/
│ └── db.js ← pool de conexão (seção 4.1)
├── routes/
│ └── convidados.routes.js ← só mapeia URL → controller
├── controllers/
│ └── convidados.controller.js
├── services/
│ └── convidados.service.js ← regras de negócio
├── repositories/
│ └── convidados.repository.js ← SQL, só SQL
├── middlewares/
│ ├── auth.js ← exigeLogin, exigePerfil (seções 8 e 9)
│ └── erros.js ← tratador central (seção 12)
└── helpers/
└── validar.js ← o validador reutilizável (seção 5.1)
O server.js que amarra tudo:
require('dotenv').config();
const express = require('express');
const cors = require('cors');
const app = express();
app.use(cors()); // seção 10
app.use(express.json()); // sem isso, req.body chega undefined
app.use('/auth', require('./src/routes/auth.routes'));
app.use('/convidados', require('./src/routes/convidados.routes'));
app.use('/mesas', require('./src/routes/mesas.routes'));
app.use(require('./src/middlewares/erros')); // por último, sempre
app.listen(3000, () => console.log('API no ar na porta 3000'));
E um arquivo de rotas, que é só o mapa:
// src/routes/convidados.routes.js
const router = require('express').Router();
const controller = require('../controllers/convidados.controller');
const { exigeLogin, exigePerfil } = require('../middlewares/auth');
router.get('/', exigeLogin, controller.listar);
router.get('/:id', exigeLogin, controller.buscar);
router.post('/', exigeLogin, exigePerfil('admin', 'organizador'), controller.criar);
router.put('/:id', exigeLogin, exigePerfil('admin', 'organizador'), controller.atualizar);
router.delete('/:id', exigeLogin, exigePerfil('admin'), controller.remover);
router.post('/:id/checkin', exigeLogin, exigePerfil('recepcao', 'admin'), controller.checkin);
module.exports = router;
Repara que esse arquivo é o mapa de endpoints da seção 2.4 em código. A banca lendo ele entende o sistema inteiro em 20 segundos. Isso é organização pontuando.
⚠️ Pega-ratão do express.json(): esquecer essa linha faz todo req.body chegar undefined e a validação recusar tudo. Sintoma clássico: "mando o JSON certo no Postman e a API diz que o campo é obrigatório". Confere o middleware antes de caçar bug na validação.
3.3 O esqueleto em PHP puro
Sem framework, o padrão é o front controller: toda requisição entra por um único index.php, que roteia.
api/
├── public/
│ └── index.php ← único ponto de entrada (roteador)
├── composer.json ← dependências (firebase/php-jwt)
├── .env ← segredos (NUNCA vai pro Git)
└── src/
├── config/conexao.php ← PDO (seção 4.2)
├── controllers/
├── services/
├── repositories/
└── helpers/
├── validar.php
├── auth.php ← exigeLogin, exigePerfil
└── resposta.php ← json() e erro() padronizados
O roteador em public/index.php:
<?php
require __DIR__ . '/../vendor/autoload.php';
require __DIR__ . '/../src/helpers/resposta.php';
// ... demais requires
header('Content-Type: application/json; charset=utf-8');
$metodo = $_SERVER['REQUEST_METHOD'];
$uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
// "/convidados/12/checkin" → ['convidados', '12', 'checkin']
$partes = explode('/', trim($uri, '/'));
$recurso = $partes[0] ?? '';
$id = $partes[1] ?? null;
$acao = $partes[2] ?? null;
$corpo = json_decode(file_get_contents('php://input'), true) ?? [];
try {
match (true) {
$metodo === 'POST' && $recurso === 'auth' && $id === 'login' => login($corpo),
$metodo === 'GET' && $recurso === 'convidados' && !$id => listarConvidados($_GET),
$metodo === 'GET' && $recurso === 'convidados' => buscarConvidado($id),
$metodo === 'POST' && $recurso === 'convidados' && $acao === 'checkin' => fazerCheckin($id),
$metodo === 'POST' && $recurso === 'convidados' => criarConvidado($corpo),
$metodo === 'PUT' && $recurso === 'convidados' => atualizarConvidado($id, $corpo),
$metodo === 'DELETE' && $recurso === 'convidados' => removerConvidado($id),
default => erro(404, 'Rota não encontrada'),
};
} catch (Throwable $e) {
error_log($e); // stack trace no log, não na resposta
erro(500, 'Erro interno no servidor'); // seção 12
}
E os dois helpers de resposta que padronizam tudo:
<?php
// src/helpers/resposta.php
function json(int $status, array $dados): void {
http_response_code($status);
echo json_encode($dados, JSON_UNESCAPED_UNICODE);
exit;
}
function erro(int $status, string $mensagem, array $detalhes = []): void {
json($status, ['erro' => $mensagem, 'detalhes' => $detalhes]);
}
🛠️ Gambiarra boa: subir a API PHP em um comando. php -S localhost:8000 index.php dentro da pasta public/ sobe um servidor de desenvolvimento que roteia tudo pelo index.php. Sem Apache, sem XAMPP, sem virtual host. Pra prova é perfeito; e o servidor embutido recarrega o código a cada requisição, então nem restart precisa.
3.4 Uma requisição atravessando as camadas
POST /convidados com o JSON da Marina, do começo ao fim:
- Rota casa
POST /convidadose vê que exige login + perfil (middlewares passam ou barram com 401/403). - Controller valida o formato do corpo (seção 5.1). Formato ruim: responde 400 e acabou. Formato ok: chama o serviço.
- Serviço aplica a regra: a mesa 3 tem lugar pra Marina + 2 acompanhantes? Não tem: lança erro 409. Tem: chama o repositório.
- Repositório roda o INSERT com prepared statement (seção 6) e devolve o id novo.
- A resposta volta o caminho: serviço → controller →
201 Createdcom o convidado criado no corpo.
Essa narração de fluxo, com um exemplo concreto, é exatamente o que o Módulo D (apresentação, módulo 10) pede. Treinar ela agora é estudar pra duas provas ao mesmo tempo.
4. Conexão com o banco: o cabo entre a API e o MySQL
Primeira coisa que o Módulo B precisa funcionando. As duas stacks, lado a lado.
4.1 Node: mysql2 com pool de conexões
// src/config/db.js
const mysql = require('mysql2/promise');
const pool = mysql.createPool({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASS,
database: process.env.DB_NAME,
waitForConnections: true,
connectionLimit: 10,
});
module.exports = pool;
Por que pool e não uma conexão só: o pool mantém um conjunto de conexões abertas e empresta uma a cada consulta. Sem pool, ou você abre e fecha conexão a cada query (lento) ou segura uma conexão global que cai e derruba a API junto. createPool resolve os dois problemas com o mesmo esforço de digitação que createConnection. Não tem motivo pra não usar.
Usando (repare no destructuring do array que o mysql2 devolve):
const pool = require('../config/db');
const [linhas] = await pool.query('SELECT * FROM convidado WHERE casamento_id = ?', [1]);
4.2 PHP: PDO
PDO é a interface padrão do PHP pra banco. As três opções do construtor abaixo não são decoração, são o que faz o PDO se comportar direito:
<?php
// src/config/conexao.php
function conectar(): PDO {
static $pdo = null;
if ($pdo === null) {
$pdo = new PDO(
'mysql:host=localhost;dbname=wedding_pass;charset=utf8mb4',
getenv('DB_USER'),
getenv('DB_PASS'),
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, // erro vira exceção
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, // fetch devolve array assoc.
PDO::ATTR_EMULATE_PREPARES => false, // prepared statement REAL
]
);
}
return $pdo;
}
ERRMODE_EXCEPTION: sem isso, um SQL errado falha em silêncio e você fica caçando por que a tabela está vazia. Com isso, o erro explode na cara com a mensagem do MySQL. Em prova, erro barulhento é presente.FETCH_ASSOC: devolve['nome' => 'Marina']em vez de duplicar cada coluna com índice numérico.EMULATE_PREPARES => false: manda o prepared statement de verdade pro MySQL, em vez de o PHP montar a string por conta. Mais seguro e mais fiel (importa na seção 6).
O static $pdo faz a função devolver sempre a mesma conexão dentro da requisição: o equivalente simples do pool pro modelo do PHP (que abre e fecha por requisição).
4.3 Configuração fora do código
Host, usuário, senha e nome do banco não podem estar chumbados no código, por dois motivos: trocar de máquina (a da prova!) sem editar código, e nunca commitar senha (módulo 02, .gitignore).
- Node: arquivo
.env+ pacotedotenv(require('dotenv').config()na primeira linha do server). As variáveis chegam emprocess.env.DB_HOST. - PHP: mesmo
.envlido comgetenv()/$_ENV(viavlucas/phpdotenvou umparse_ini_filesimples numconfig.phpfora do Git).
🛠️ Gambiarra boa: commita um .env.exemplo com as chaves e valores falsos (DB_PASS=troque-aqui). Na máquina da prova, cp .env.exemplo .env, preenche e pronto. Você ganha o setup documentado de graça e a banca vê profissionalismo no repositório.
5. CRUD com validação: o feijão com arroz bem-feito
CRUD é Create, Read, Update, Delete: as quatro operações sobre cada recurso. O descritivo pede os CRUDs completos. Fazer CRUD é fácil; fazer CRUD validado é o que pontua.
Validar é não confiar no que chega. Antes de gravar: os obrigatórios vieram? Os formatos batem? As regras de negócio passam?
🎯 A validação é onde mora o nível 3. Um CRUD que grava qualquer coisa é nível 2. Um CRUD que recusa dado inválido com resposta clara (400 + a lista do que está errado) é nível 3. E lembra: a validação de verdade é no back. O front valida pra dar experiência boa (módulo 05); o back valida pra garantir.
5.1 O validador reutilizável (escreve uma vez, usa em todo endpoint)
Validar na mão em cada endpoint gera código repetido e esquecimento. O padrão de mercado é um validador genérico que recebe os dados e as regras:
Node:
// src/helpers/validar.js
function validar(dados, regras) {
const erros = [];
for (const [campo, r] of Object.entries(regras)) {
const valor = dados?.[campo];
const vazio = valor === undefined || valor === null || valor === '';
if (r.obrigatorio && vazio) {
erros.push(`${campo} é obrigatório`);
continue;
}
if (vazio) continue; // opcional não preenchido: ok
if (r.max && String(valor).length > r.max)
erros.push(`${campo} passa de ${r.max} caracteres`);
if (r.regex && !r.regex.test(String(valor)))
erros.push(`${campo} está num formato inválido`);
if (r.inteiro && !Number.isInteger(Number(valor)))
erros.push(`${campo} precisa ser um número inteiro`);
}
return erros;
}
module.exports = validar;
PHP:
<?php
// src/helpers/validar.php
function validar(array $dados, array $regras): array {
$erros = [];
foreach ($regras as $campo => $r) {
$valor = $dados[$campo] ?? null;
$vazio = $valor === null || $valor === '';
if (($r['obrigatorio'] ?? false) && $vazio) {
$erros[] = "$campo é obrigatório";
continue;
}
if ($vazio) continue;
if (isset($r['max']) && mb_strlen((string)$valor) > $r['max'])
$erros[] = "$campo passa de {$r['max']} caracteres";
if (isset($r['regex']) && !preg_match($r['regex'], (string)$valor))
$erros[] = "$campo está num formato inválido";
if (($r['inteiro'] ?? false) && filter_var($valor, FILTER_VALIDATE_INT) === false)
$erros[] = "$campo precisa ser um número inteiro";
}
return $erros;
}
São 25 linhas que cobrem 90% da validação da prova inteira. O que muda por endpoint é só o conjunto de regras.
5.2 O POST /convidados completo em Node
As três camadas em ação. Controller:
// src/controllers/convidados.controller.js
const validar = require('../helpers/validar');
const service = require('../services/convidados.service');
const regrasConvidado = {
nome: { obrigatorio: true, max: 100 },
email: { regex: /^\S+@\S+\.\S+$/, max: 150 },
telefone: { max: 15 },
acompanhantes: { inteiro: true },
casamento_id: { obrigatorio: true, inteiro: true },
};
async function criar(req, res, next) {
try {
const erros = validar(req.body, regrasConvidado);
if (erros.length > 0) {
return res.status(400).json({ erro: 'Dados inválidos', detalhes: erros });
}
const convidado = await service.criar(req.body);
res.status(201).json(convidado);
} catch (erro) {
next(erro); // manda pro tratador central (seção 12)
}
}
Serviço (a regra de negócio que o controller não conhece):
// src/services/convidados.service.js
const repo = require('../repositories/convidados.repository');
async function criar(dados) {
if (dados.mesa_id) {
const livres = await repo.lugaresLivresNaMesa(dados.mesa_id);
if (livres < 1 + (dados.acompanhantes ?? 0)) {
const erro = new Error('A mesa não tem lugares suficientes');
erro.status = 409;
throw erro;
}
}
return repo.inserir(dados);
}
Repositório (só SQL):
// src/repositories/convidados.repository.js
const pool = require('../config/db');
async function inserir(c) {
const [resultado] = await pool.query(
`INSERT INTO convidado (casamento_id, mesa_id, nome, email, telefone, acompanhantes)
VALUES (?, ?, ?, ?, ?, ?)`,
[c.casamento_id, c.mesa_id ?? null, c.nome, c.email ?? null,
c.telefone ?? null, c.acompanhantes ?? 0]
);
return { id: resultado.insertId, ...c };
}
5.3 O mesmo endpoint em PHP
No PHP sem framework as camadas podem morar em funções, mas a separação é a mesma:
<?php
// src/controllers/convidados.controller.php
function criarConvidado(array $corpo): void {
$erros = validar($corpo, [
'nome' => ['obrigatorio' => true, 'max' => 100],
'email' => ['regex' => '/^\S+@\S+\.\S+$/', 'max' => 150],
'telefone' => ['max' => 15],
'acompanhantes' => ['inteiro' => true],
'casamento_id' => ['obrigatorio' => true, 'inteiro' => true],
]);
if ($erros) erro(400, 'Dados inválidos', $erros);
// regra de negócio (num projeto maior, iria pro service)
if (!empty($corpo['mesa_id'])) {
$livres = lugaresLivresNaMesa((int)$corpo['mesa_id']);
if ($livres < 1 + (int)($corpo['acompanhantes'] ?? 0)) {
erro(409, 'A mesa não tem lugares suficientes');
}
}
$pdo = conectar();
$stmt = $pdo->prepare(
'INSERT INTO convidado (casamento_id, mesa_id, nome, email, telefone, acompanhantes)
VALUES (?, ?, ?, ?, ?, ?)'
);
$stmt->execute([
$corpo['casamento_id'],
$corpo['mesa_id'] ?? null,
$corpo['nome'],
$corpo['email'] ?? null,
$corpo['telefone'] ?? null,
$corpo['acompanhantes'] ?? 0,
]);
json(201, ['id' => (int)$pdo->lastInsertId()] + $corpo);
}
O resto do CRUD segue o mesmo molde. Dois detalhes que valem nota:
- PUT que não achou o registro: confere
affectedRows(Node) /rowCount()(PHP). Zero linhas afetadas em um UPDATE por id que não existe: responde 404, não 200. - DELETE barrado por FK: tentar apagar um convidado que já tem check-in bate no
ON DELETE RESTRICTdo módulo 03. O MySQL devolve erro 1451; a API traduz pra409 Conflictcom mensagem clara ("esse convidado já tem check-in registrado"), não pra 500. Constraint do banco + tradução na API é o time completo.
⚠️ Pega-ratão: validar só no front. É o mesmo erro da autorização: bonito pro usuário, furado pra quem chama a API direto. Validação no front melhora a experiência; validação no back protege os dados. A banca chama a API direto.
6. SQL injection: o ataque que a banca conhece pelo nome
Segurança web é conteúdo explícito da UC13, e injection é o item número 1 de toda lista de riscos (OWASP) há vinte anos. Entender o ataque é o que faz a defesa deixar de ser decoreba.
6.1 O ataque
Imagina um login montado por concatenação:
// ⚠️ NUNCA FAZER: a query montada colando strings
const sql = `SELECT * FROM usuario
WHERE email = '${email}' AND senha = '${senha}'`;
Agora alguém digita no campo senha: ' OR '1'='1. A query final vira:
SELECT * FROM usuario
WHERE email = 'qualquer@coisa.com' AND senha = '' OR '1'='1';
'1'='1' é sempre verdadeiro, o OR engole o resto, a consulta devolve todos os usuários e o atacante entra sem senha. O dado do usuário virou código SQL. Esse é o problema inteiro em uma frase.
6.2 A defesa: prepared statements (que você já está usando)
Prepared statement separa a viagem: a query vai numa via, os dados vão na outra, e o MySQL nunca interpreta dado como SQL. O ' OR '1'='1 vira literalmente uma senha esquisita sendo procurada, e não acha nada.
Node (mysql2), o ? é o placeholder:
const [linhas] = await pool.query(
'SELECT * FROM usuario WHERE email = ?',
[email]
);
PHP (PDO), prepare + execute:
$stmt = $pdo->prepare('SELECT * FROM usuario WHERE email = ?');
$stmt->execute([$email]);
$usuario = $stmt->fetch();
A regra é binária e sem exceção: variável na query = placeholder. Todos os exemplos deste módulo já seguem isso; a seção existe pra você saber explicar o porquê (pergunta clássica de banca e material de apresentação).
6.3 Onde o placeholder não chega: ORDER BY e a whitelist
Placeholder funciona pra valores, não pra nomes de coluna ou direção de ordenação. ORDER BY ? não faz o que parece. Quando a ordenação vem do usuário (seção 13), a defesa é outra: whitelist.
const colunasPermitidas = ['nome', 'criado_em', 'acompanhantes'];
const ordem = colunasPermitidas.includes(req.query.ordenar) ? req.query.ordenar : 'nome';
const direcao = req.query.direcao === 'desc' ? 'DESC' : 'ASC';
const sql = `SELECT * FROM convidado ORDER BY ${ordem} ${direcao}`;
A interpolação aqui é segura porque ordem e direcao só podem ser valores que você mesma escreveu. O usuário escolhe da sua lista, nunca injeta texto livre. Mesmo raciocínio em PHP com in_array().
🎯 Foco de prova: "por que você usou prepared statement?" é pergunta de banca com resposta pronta: "porque é a única defesa correta contra SQL injection: o dado viaja separado da query e nunca é interpretado como SQL". Trinta segundos, nível 3 de maturidade.
7. Senha no back: bcrypt e password_hash
O módulo 03 (seção 18.3) fechou o lado do banco: a coluna é senha_hash VARCHAR(255) e senha em texto puro nunca é gravada. Agora o lado da API: quem gera e quem confere o hash.
Node (bcryptjs):
const bcrypt = require('bcryptjs');
// no cadastro de usuário:
const hash = await bcrypt.hash(senha, 10); // 10 = custo (rounds)
// no login:
const bate = await bcrypt.compare(senhaDigitada, usuario.senha_hash);
PHP (nativo, sem instalar nada):
// no cadastro:
$hash = password_hash($senha, PASSWORD_DEFAULT); // bcrypt por padrão
// no login:
$bate = password_verify($senhaDigitada, $usuario['senha_hash']);
Por que existe compare/verify em vez de comparar strings: o bcrypt embute um sal aleatório em cada hash, então a mesma senha gera hashes diferentes a cada cadastro. Só a função sabe extrair o sal e refazer a conta. Consequências práticas:
- Nunca
WHERE senha_hash = ?no SQL: busca o usuário pelo e-mail e confere a senha com a função. - Nunca
hash1 === hash2no código. - O custo (10 rounds) é proposital: torna o hash lento de calcular, o que inviabiliza testar bilhões de senhas por segundo num vazamento. Hash de senha lento é qualidade, não defeito (MD5 e SHA-256 são rápidos demais, por isso não servem pra senha).
🎯 Foco de prova (objetivo): senha com hash é item de checklist da banca. Abrir a tabela usuario e ver $2b$10$... em vez de 123456 é a diferença entre pontuar e zerar o critério. E saber explicar o sal é o upgrade de julgamento por cima do ponto objetivo.
8. Autenticação com JWT: o crachá do sistema
🎯 Foco de prova (objetivo): o descritivo pede autenticação com JWT. Ou está lá funcionando, ou é zero.
Autenticação responde quem é você (o login). Autorização (seção 9) responde o que você pode fazer. O JWT é a ponte entre as duas: o crachá que o login emite e que toda rota protegida confere.
8.1 A anatomia do token
Um JWT são três blocos de texto separados por ponto: header.payload.signature.
- Header: o algoritmo de assinatura (
HS256= HMAC com SHA-256). - Payload: os dados do usuário que a API quer carregar. No Wedding Pass:
{
"id": 3,
"nome": "Ana",
"perfil": "recepcao",
"exp": 1767225600
}
- Signature: o hash do header + payload calculado com a chave secreta do servidor. É o lacre: se alguém alterar uma vírgula do payload (tipo trocar
"recepcao"por"admin"), a assinatura não bate mais e a API rejeita.
⚠️ Pega-ratão que derruba gente experiente: o payload é apenas codificado em Base64, não criptografado. Qualquer um cola o token no jwt.io e lê o conteúdo. O que o token garante é que ninguém alterou (integridade), não que ninguém leu (sigilo). Logo: id, nome e perfil no payload, sim; senha ou qualquer dado sensível, jamais.
O campo exp é a expiração em timestamp. Token vencido = 401, mesmo com assinatura válida.
8.2 O login que emite o token
Node (jsonwebtoken):
// src/controllers/auth.controller.js
const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');
const pool = require('../config/db');
async function login(req, res, next) {
try {
const { email, senha } = req.body;
const [linhas] = await pool.query('SELECT * FROM usuario WHERE email = ?', [email]);
const usuario = linhas[0];
if (!usuario || !(await bcrypt.compare(senha, usuario.senha_hash))) {
return res.status(401).json({ erro: 'E-mail ou senha inválidos' });
}
const token = jwt.sign(
{ id: usuario.id, nome: usuario.nome, perfil: usuario.perfil },
process.env.JWT_SECRET,
{ expiresIn: '8h' }
);
res.json({
token,
usuario: { id: usuario.id, nome: usuario.nome, perfil: usuario.perfil },
});
} catch (erro) { next(erro); }
}
PHP (firebase/php-jwt, via composer require firebase/php-jwt):
<?php
use Firebase\JWT\JWT;
function login(array $corpo): void {
$pdo = conectar();
$stmt = $pdo->prepare('SELECT * FROM usuario WHERE email = ?');
$stmt->execute([$corpo['email'] ?? '']);
$usuario = $stmt->fetch();
if (!$usuario || !password_verify($corpo['senha'] ?? '', $usuario['senha_hash'])) {
erro(401, 'E-mail ou senha inválidos');
}
$token = JWT::encode([
'id' => $usuario['id'],
'nome' => $usuario['nome'],
'perfil' => $usuario['perfil'],
'exp' => time() + 60 * 60 * 8, // 8 horas
], getenv('JWT_SECRET'), 'HS256');
json(200, [
'token' => $token,
'usuario' => ['id' => $usuario['id'], 'nome' => $usuario['nome'], 'perfil' => $usuario['perfil']],
]);
}
Dois detalhes de segurança embutidos aí:
- A mensagem de erro é a mesma pra e-mail inexistente e pra senha errada ("E-mail ou senha inválidos"). Dizer "e-mail não encontrado" entrega pra um atacante quais e-mails existem no sistema.
- A chave (
JWT_SECRET) mora no.env, nunca no código. Ela é o que impede falsificação de crachá; vazou a chave, caiu o sistema.
8.3 O middleware que protege as rotas
Node:
// src/middlewares/auth.js
const jwt = require('jsonwebtoken');
function exigeLogin(req, res, next) {
const cabecalho = req.headers.authorization ?? '';
if (!cabecalho.startsWith('Bearer ')) {
return res.status(401).json({ erro: 'Token não enviado' });
}
try {
req.usuario = jwt.verify(cabecalho.slice(7), process.env.JWT_SECRET);
next();
} catch {
return res.status(401).json({ erro: 'Token inválido ou expirado' });
}
}
O jwt.verify confere assinatura e expiração de uma vez. Passou: os dados do crachá ficam em req.usuario pra qualquer camada usar (quem registrou o check-in? req.usuario.id). A rota usa o middleware como visto na seção 3.2.
PHP (sem middleware nativo, a proteção é a primeira linha do controller):
<?php
// src/helpers/auth.php
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
function exigeLogin(): object {
$cabecalho = $_SERVER['HTTP_AUTHORIZATION']
?? (function_exists('getallheaders') ? (getallheaders()['Authorization'] ?? '') : '');
if (!str_starts_with($cabecalho, 'Bearer ')) {
erro(401, 'Token não enviado');
}
try {
return JWT::decode(substr($cabecalho, 7), new Key(getenv('JWT_SECRET'), 'HS256'));
} catch (Exception $e) {
erro(401, 'Token inválido ou expirado');
}
}
E dentro de qualquer controller protegido:
function removerConvidado(?string $id): void {
$usuario = exigeLogin(); // barra com 401 se não passar
exigePerfil($usuario, 'admin'); // seção 9: barra com 403
// ... daqui pra baixo, só quem pode
}
⚠️ Pega-ratão específico do PHP: o Apache às vezes descarta o header Authorization antes de chegar no PHP (aí $_SERVER['HTTP_AUTHORIZATION'] vem vazio e todo mundo toma 401 "sem motivo"). O fallback com getallheaders() acima resolve; com o servidor embutido (php -S) o problema nem existe. Se um dia a auth "parar do nada" no Apache, o suspeito é esse.
8.4 Expiração e onde o front guarda
- Expiração pra prova: 8h. Cobre a prova inteira e a apresentação; ninguém desloga no meio da demo. 🛠️ Em produção seria mais curto (15min a 1h com refresh token), e saber falar disso na apresentação transforma uma escolha pragmática em ponto de maturidade: "usei 8h pelo contexto; em produção encurtaria e adicionaria refresh".
- Onde o front guarda:
localStorageé o caminho da prova (simples, sobrevive a refresh, o módulo 05 implementa). A alternativa de mercado é cookiehttpOnly(o JS não consegue ler, protege contra roubo por script injetado), que dá mais trabalho de configurar. Trade-off clássico; na prova,localStorage+ saber explicar a diferença.
9. Autorização por perfil (roles): o que cada crachá abre
O token carrega o perfil (o ENUM do módulo 03: admin, organizador, recepcao). Cada rota declara quais perfis aceita, e a API barra o resto com 403.
Node (o segundo middleware, parametrizado):
// src/middlewares/auth.js (continuação)
function exigePerfil(...perfis) {
return (req, res, next) => {
if (!perfis.includes(req.usuario.perfil)) {
return res.status(403).json({ erro: 'Seu perfil não tem permissão pra essa ação' });
}
next();
};
}
module.exports = { exigeLogin, exigePerfil };
Na rota, os dois em sequência (a ordem importa: primeiro sabe quem é, depois confere o que pode):
router.delete('/:id', exigeLogin, exigePerfil('admin'), controller.remover);
router.post('/:id/checkin', exigeLogin, exigePerfil('recepcao', 'admin'), controller.checkin);
PHP:
<?php
// src/helpers/auth.php (continuação)
function exigePerfil(object $usuario, string ...$perfis): void {
if (!in_array($usuario->perfil, $perfis, true)) {
erro(403, 'Seu perfil não tem permissão pra essa ação');
}
}
A tabela de quem pode o quê já está pronta: é a coluna "Quem pode" do mapa da seção 2.4. Implementar autorização é transcrever aquela coluna pra middlewares.
🎯 Foco de prova: o sistema tem três perfis com poderes diferentes, e a autorização precisa estar no back. Testar isso é fácil pra banca: loga como recepção, copia o token, chama DELETE /convidados/12 no Postman. Se apagar, o controle de acesso inteiro vale zero, não importa quão bonito o front esconde o botão. O roteiro de teste da seção 15 inclui exatamente essa chamada.
⚠️ Pega-ratão: proteger as rotas "importantes" e esquecer as secundárias. GET /convidados aberto sem token vaza a lista inteira de convidados pra qualquer um. A regra: toda rota é protegida por padrão; pública é exceção declarada de propósito (login, página de RSVP).
10. CORS: deixar o front conversar com o back
Quando o front (por exemplo http://localhost:5500) chama a API (http://localhost:3000), o navegador bloqueia por segurança, a não ser que a API diga "pode, eu confio nessa origem". Isso é o CORS (Cross-Origin Resource Sharing). Detalhe que confunde: quem barra é o navegador do lado do front, mas quem autoriza é o servidor. Por isso a correção é sempre no back.
Tem ainda o preflight: antes de um POST com JSON ou com header Authorization, o navegador manda sozinho uma requisição OPTIONS perguntando "posso?". Se a API não responder o OPTIONS direito, o POST nem acontece, e o sintoma é o famoso "front não conecta".
Node: o pacote cors resolve tudo, inclusive o preflight:
const cors = require('cors');
app.use(cors()); // liberado geral: perfeito pra desenvolvimento e prova
PHP: os headers no topo do index.php, mais o tratamento do OPTIONS:
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Authorization');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(204);
exit; // preflight respondido, o navegador libera a requisição real
}
🎯 Foco de prova: o descritivo cita liberar CORS. É objetivo e é a causa número 1 de "meu front não conecta na API" no dia da prova. O erro aparece no console do navegador (em inglês, módulo 09: "blocked by CORS policy"), nunca no terminal da API, o que engana quem procura no lugar errado.
🛠️ Gambiarra boa: libera o CORS no minuto 1 do Módulo B, antes mesmo de existir front. São 2 linhas. Aí quando o front chegar no Módulo C, a ponte já está pronta e você não descobre o problema no pior momento. Em produção se restringiria a origem (origin: 'https://seusite.com'); na prova, * e bola pra frente, sabendo explicar por quê.
11. Regras de negócio no servidor: capacidade, corrida e transação
As regras que separam a API nota 2 da nota 3. Todas moram no serviço, todas têm o banco como última linha de defesa.
11.1 A regra de capacidade e o alerta de 90%
O casamento tem capacidade_total (tabela do módulo 03). A API precisa: impedir confirmação que estoure o limite e informar a ocupação pro dashboard acender o alerta de 90%.
A consulta que resolve os dois (uma subquery, módulo 03 seção 13):
SELECT
ca.capacidade_total,
(SELECT COALESCE(SUM(1 + cv.acompanhantes), 0)
FROM convidado cv
JOIN convite co ON co.convidado_id = cv.id
WHERE cv.casamento_id = ca.id
AND co.status = 'confirmado') AS confirmados
FROM casamento ca
WHERE ca.id = ?;
O serviço transforma em decisão:
async function statusCapacidade(casamentoId) {
const { capacidade_total, confirmados } = await repo.capacidade(casamentoId);
return {
capacidade_total,
confirmados,
alerta90: confirmados >= capacidade_total * 0.9,
};
}
async function confirmarPresenca(codigo) {
const convite = await repo.buscarConvitePorCodigo(codigo);
if (!convite) { const e = new Error('Convite não encontrado'); e.status = 404; throw e; }
const { capacidade_total, confirmados } = await repo.capacidade(convite.casamento_id);
const grupo = 1 + convite.acompanhantes;
if (confirmados + grupo > capacidade_total) {
const e = new Error('O casamento atingiu a capacidade máxima');
e.status = 409; throw e;
}
return repo.confirmar(convite.id);
}
Repara: a conta inclui os acompanhantes (1 + acompanhantes por convidado). Esquecer os acompanhantes na soma é o clássico bug de regra de negócio que passa no teste rápido e explode na demo. E a conta mora no servidor porque é regra de negócio: dashboard, página de RSVP e Postman recebem o mesmo veredito.
11.2 O check-in duplo: condição de corrida e as três camadas de defesa
O descritivo pede bloquear dupla entrada: um convidado não entra duas vezes. A implementação ingênua:
1. SELECT: esse convidado já tem check-in?
2. Se não tem, INSERT.
Parece certa e tem um buraco: se dois pedidos chegam quase juntos (duas recepcionistas escaneiam o mesmo código, ou um clique duplo), os dois rodam o SELECT antes de qualquer INSERT, os dois leem "não tem", e os dois inserem. Isso é uma condição de corrida: o resultado depende da ordem de chegada em milissegundos que você não controla.
A defesa em três camadas, da cosmética à garantia:
- Front (módulo 05): desabilita o botão após o clique. Melhora a experiência, não garante nada (duas abas, dois computadores).
- API: não faz SELECT + INSERT; faz só o INSERT e trata o erro de duplicidade. Uma operação só, sem janela entre checar e agir.
- Banco: a constraint
UNIQUE (convidado_id)na tabelacheckin(módulo 03, seção 8). Essa é a garantia real. O MySQL é incapaz de aceitar o segundo INSERT, não importa quantos cheguem juntos.
O código da camada 2, que converte a recusa do banco em resposta educada:
Node:
// src/services/checkin.service.js
async function fazerCheckin(convidadoId, usuarioId) {
try {
const [r] = await pool.query(
'INSERT INTO checkin (convidado_id, registrado_por) VALUES (?, ?)',
[convidadoId, usuarioId]
);
return { id: r.insertId };
} catch (erro) {
if (erro.code === 'ER_DUP_ENTRY') { // a UNIQUE barrou
const jaEntrou = new Error('Esse convidado já fez check-in');
jaEntrou.status = 409;
throw jaEntrou;
}
throw erro; // outro erro: deixa o tratador central cuidar
}
}
PHP:
function fazerCheckin(?string $convidadoId): void {
$usuario = exigeLogin();
exigePerfil($usuario, 'recepcao', 'admin');
try {
$pdo = conectar();
$stmt = $pdo->prepare('INSERT INTO checkin (convidado_id, registrado_por) VALUES (?, ?)');
$stmt->execute([$convidadoId, $usuario->id]);
json(201, ['mensagem' => 'Check-in registrado']);
} catch (PDOException $e) {
if (($e->errorInfo[1] ?? 0) === 1062) { // 1062 = violação de UNIQUE no MySQL
erro(409, 'Esse convidado já fez check-in');
}
throw $e;
}
}
Variante útil quando não existe tabela própria (um campo de status na própria linha): o UPDATE atômico, que checa e age na mesma instrução:
UPDATE convite SET status = 'confirmado', respondido_em = NOW()
WHERE codigo = ? AND status = 'pendente';
Se affectedRows for 0, alguém confirmou antes (ou o código não existe): responde 409/404. Checagem e escrita numa operação só = sem janela de corrida.
🎯 Isso é assunto de nível 3 e de apresentação. A história pronta pro pitch do Módulo D: "o check-in tem três camadas de defesa: o botão desabilita no front, a API insere e trata a duplicidade, e a constraint UNIQUE no banco garante mesmo se dois pedidos chegarem juntos, porque existe condição de corrida entre checar e gravar". Quarenta segundos que mostram profundidade que a maioria não tem. Guarda essa pra contar no pitch.
11.3 Transações no código: tudo ou nada
O conceito veio do módulo 03 (seção 17); aqui é onde ele entra no código da API. Use quando duas ou mais escritas precisam acontecer juntas: confirmar o convite E atribuir a mesa; remanejar um convidado E liberar o lugar antigo.
Node: transação exige a mesma conexão do começo ao fim, então se pede uma emprestada ao pool:
const conexao = await pool.getConnection();
try {
await conexao.beginTransaction();
await conexao.query(
"UPDATE convite SET status = 'confirmado', respondido_em = NOW() WHERE id = ?",
[conviteId]
);
await conexao.query(
'UPDATE convidado SET mesa_id = ? WHERE id = ?',
[mesaId, convidadoId]
);
await conexao.commit(); // as duas valem
} catch (erro) {
await conexao.rollback(); // nenhuma vale
throw erro;
} finally {
conexao.release(); // devolve a conexão pro pool, SEMPRE
}
PHP (PDO):
$pdo = conectar();
try {
$pdo->beginTransaction();
$pdo->prepare("UPDATE convite SET status = 'confirmado', respondido_em = NOW() WHERE id = ?")
->execute([$conviteId]);
$pdo->prepare('UPDATE convidado SET mesa_id = ? WHERE id = ?')
->execute([$mesaId, $convidadoId]);
$pdo->commit();
} catch (Exception $e) {
$pdo->rollBack();
throw $e;
}
⚠️ Pega-ratão do Node: rodar beginTransaction numa conexão e as queries em pool.query(). O pool pode entregar outra conexão pra cada query, e aí metade da "transação" roda fora dela. Transação no mysql2 = getConnection() + tudo pela mesma conexao + release() no finally. Esquecer o release vaza conexões até o pool secar e a API travar (10 requisições depois, do nada).
12. Tratamento de erro padronizado: um formato só pra tudo
Toda resposta de erro da API, de 400 a 500, no mesmo formato:
{ "erro": "mensagem legível pra quem usa", "detalhes": ["campo nome é obrigatório"] }
Por quê: o front (módulo 05) escreve um tratador pra exibir qualquer erro, a banca vê consistência, e você nunca perde tempo decidindo formato de novo. Os exemplos deste módulo já seguem esse formato; aqui é a central que garante ele até pros erros que você não previu.
Node: o middleware de erro (4 parâmetros, registrado por último no server.js):
// src/middlewares/erros.js
module.exports = (erro, req, res, next) => {
const status = erro.status ?? 500;
const mensagem = status === 500 ? 'Erro interno no servidor' : erro.message;
if (status === 500) console.error(erro); // stack trace no terminal, pra VOCÊ
res.status(status).json({ erro: mensagem, detalhes: erro.detalhes ?? [] });
};
O padrão do time inteiro: serviço lança Error com .status; controller faz try/catch e chama next(erro); a central formata. Erro esperado (400, 404, 409) sai com a mensagem que o serviço escreveu; erro inesperado sai como 500 genérico.
PHP: o try/catch (Throwable) global do index.php (seção 3.3) já é a central. Erros esperados saem pelo helper erro() no meio do caminho; qualquer exceção não tratada vira error_log($e) + 500 genérico.
Por que o 500 é genérico de propósito: a mensagem crua de uma exceção pode vazar caminho de arquivo, SQL e estrutura interna. Isso é falha de segurança (informação pra atacante) e de acabamento (usuário vendo stack trace). O detalhe completo vai pro log/terminal, que é onde você debuga; pro cliente vai "Erro interno no servidor".
🎯 Foco de prova: mensagens de erro claras e consistentes são critério de julgamento, e o módulo 08 (lapidação) chama isso de "o ganho de nível 3 mais barato". A central de erro é o alicerce disso no back: 15 linhas, escritas uma vez, elevam TODAS as respostas de erro do sistema.
13. Paginação, filtro e ordenação no servidor
A UC13 cita ordenação e filtragem explicitamente, e o Wedding Pass tem a tela perfeita pra isso: a lista de convidados (com busca, filtro por mesa, ordenação e páginas). O contrato:
GET /convidados?busca=silva&mesa_id=3&ordenar=nome&direcao=asc&pagina=2&por_pagina=20
Tudo opcional, tudo com default sensato. A implementação (Node; em PHP muda $_GET e a sintaxe, a lógica é idêntica):
// src/repositories/convidados.repository.js
async function listar(filtros) {
const clausulas = [];
const valores = [];
if (filtros.busca) {
clausulas.push('nome LIKE ?');
valores.push(`%${filtros.busca}%`);
}
if (filtros.mesa_id) {
clausulas.push('mesa_id = ?');
valores.push(Number(filtros.mesa_id));
}
const where = clausulas.length ? `WHERE ${clausulas.join(' AND ')}` : '';
// whitelist (seção 6.3): nome de coluna NUNCA vem direto do usuário
const ordem = ['nome', 'criado_em', 'acompanhantes'].includes(filtros.ordenar)
? filtros.ordenar : 'nome';
const direcao = filtros.direcao === 'desc' ? 'DESC' : 'ASC';
const porPagina = Math.min(Number(filtros.por_pagina) || 20, 100); // teto anti-abuso
const offset = ((Number(filtros.pagina) || 1) - 1) * porPagina;
const [linhas] = await pool.query(
`SELECT * FROM convidado ${where} ORDER BY ${ordem} ${direcao} LIMIT ? OFFSET ?`,
[...valores, porPagina, offset]
);
const [[{ total }]] = await pool.query(
`SELECT COUNT(*) AS total FROM convidado ${where}`, valores
);
return { dados: linhas, total, pagina: Number(filtros.pagina) || 1, por_pagina: porPagina };
}
Os pontos que valem nota nesse bloco:
- WHERE dinâmico com placeholders: as cláusulas entram só se o filtro veio, e os valores viajam sempre por
?. Filtro combinável e seguro. - A whitelist do ORDER BY, exatamente como na seção 6.3.
- O COUNT com o mesmo WHERE: o front precisa do total pra desenhar a paginação ("página 2 de 7"). Devolver
dados + totalé o contrato completo. - Teto no
por_pagina: ninguém pede 1 milhão de linhas e derruba a API.
🛠️ Na prova, calibra o esforço: se a lista é pequena (mesas de um casamento), filtrar no front resolve e é honesto. Mas a lista de convidados é a lista grande do domínio, e o descritivo pede filtragem no servidor: implementa nela o pacote completo e aponta isso na apresentação. Um endpoint exemplar vale mais que cinco medianos.
14. ORM: o que é, e por que o SQL direto ganha a prova
A UC9 pede o conceito de ORM, então ele pode virar pergunta de banca mesmo que você nunca use um na prova.
ORM (Object-Relational Mapping) é a biblioteca que mapeia tabela em objeto e esconde o SQL: você escreve Convidado.findAll({ where: { mesa_id: 3 } }) e o ORM gera o SELECT. No mercado: Sequelize e Prisma no Node; Eloquent (Laravel) e Doctrine no PHP.
O que o ORM compra, e o que cobra:
| ORM | SQL direto (nosso caminho) | |
|---|---|---|
| Produtividade em projeto grande | alta (migrations, relações prontas) | manual |
| Setup inicial | modelos, config, docs da lib | zero: a conexão da seção 4 |
| Controle sobre a query | você confia no que ele gera | total: a query é a que você escreveu |
| Debug | camada extra entre você e o erro | o erro do MySQL na cara |
| Na prova de 2h30 | tempo de setup que você não tem | começa a pontuar no minuto 5 |
A decisão pra Seletiva é clara: SQL direto com prepared statements. Os motivos, prontos pra apresentação: o tempo de setup do ORM não se paga em 2h30; a banca avalia o SQL (módulo 03 vale 10% sozinho) e o SQL escondido no ORM não mostra domínio; e o debug transparente vale ouro sob pressão. A resposta nota 3 pra "por que não usou ORM?": "conheço o conceito, mapeia tabelas em objetos e agiliza projetos grandes, mas numa prova curta o SQL direto me dá controle, velocidade e mostra o domínio que está sendo avaliado".
15. Testar a API sem front: Postman, Insomnia e debug
O Módulo B termina antes de existir front. Como provar que a API funciona? Postman ou Insomnia (os dois servem; escolhe um e domina). O cronograma reserva tempo pra isso na semana da API, e a banca pode pedir pra ver.
Organização que rende: uma coleção por sistema, uma pasta por recurso (auth, convidados, mesas, check-in), uma requisição salva por endpoint. Duas variáveis de ambiente: {{base_url}} (troca localhost pela URL da prova em um lugar só) e {{token}} (cola o token do login uma vez, toda requisição usa Bearer {{token}} no header Authorization).
O roteiro de teste de cada endpoint protegido (4 chamadas, sempre as mesmas):
- Sem token → tem que dar 401.
- Com token de perfil errado (recepção tentando DELETE) → tem que dar 403.
- Com dado inválido (nome vazio) → tem que dar 400 com a lista de detalhes.
- Com tudo certo → 200/201 e o dado confere no banco.
Mais os especiais: check-in duas vezes no mesmo convidado (a segunda tem que dar 409) e GET de id inexistente (404). Se a coleção inteira passa nesse roteiro, a API está defendida, e essa frase é literalmente o fechamento do Módulo B.
Quando dá errado, o kit de debug:
- Ler o erro de baixo pra cima não, de cima pra baixo sim: a primeira linha do stack trace diz o quê (
ER_DUP_ENTRY,Cannot read properties of undefined), as seguintes dizem onde (arquivo:linha do SEU código, ignora as linhas de node_modules/vendor). O módulo 09 treina o inglês dessas mensagens. console.logestratégico (Node) /error_log(PHP): um na entrada do controller (chegou? com que corpo?), um antes do SQL (a query montada e os valores). Dois pontos de luz acham 90% dos bugs.- Reinício automático:
node --watch server.js(ou nodemon) reinicia o Node a cada save; ophp -Sjá recarrega sozinho por requisição. - ⚠️ O trio de sintomas clássicos:
req.bodyundefined = faltouexpress.json()(seção 3.2); "front não conecta" com API viva = CORS (seção 10); 401 em tudo do nada no Apache = header Authorization descartado (seção 8.3). Memoriza os três, cada um já custou meia hora de prova de alguém.
16. Checklist de segurança do Módulo B
Os riscos do OWASP Top 10 filtrados pro que a banca enxerga e testa, cada um com defesa já construída neste módulo:
| Risco (OWASP) | Como apareceria na prova | Defesa | Seção |
|---|---|---|---|
| Injection | login furado com ' OR '1'='1 |
prepared statements sempre; whitelist no ORDER BY | 6 |
| Falha de autenticação | senha em texto puro; token sem expiração | bcrypt/password_hash; JWT com exp e segredo no .env |
7, 8 |
| Falha de controle de acesso | endpoint aberto; perfil não conferido | exigeLogin + exigePerfil em toda rota não pública |
9 |
| Exposição de dados | stack trace na resposta; senha_hash no JSON |
500 genérico; nunca devolver a coluna de senha | 12 |
O último item merece o reforço: SELECT * na tabela usuario devolve o senha_hash junto, e ele vaza pro front no JSON do login ou da listagem. Lista as colunas (SELECT id, nome, email, perfil FROM usuario) ou remove o campo antes do res.json. É o tipo de vazamento que a banca acha em 10 segundos abrindo a aba Network.
A checklist de fim de Módulo B, pra rodar nos últimos 10 minutos:
- [ ] Senha no banco só com hash? (abre a tabela e olha)
- [ ] Toda rota não pública exige token? (roteiro da seção 15, chamada 1)
- [ ] Perfil conferido nas rotas restritas? (chamada 2)
- [ ] Validação barrando dado lixo com 400? (chamada 3)
- [ ] CORS liberado? (o front do Módulo C agradece)
- [ ] Nenhum
senha_hashem resposta JSON? - [ ] Erro 500 sem stack trace na resposta?
- [ ] Check-in duplicado respondendo 409?
17. Autoavaliação do módulo
- O que quer dizer a API ser "agnóstica ao cliente", e por que a regra de negócio mora nela?
- Qual a diferença entre conteúdo estático e dinâmico, e onde cada um vive no Wedding Pass?
- Qual verbo HTTP pra cada ação (criar, buscar, atualizar, apagar)? O que é idempotência?
- Qual a diferença entre 401 e 403? E quando usar 409?
- O que faz cada camada (controller, serviço, repositório) e qual a regra de ouro entre elas?
- Por que usar pool de conexões no Node? E o que fazem as três opções do PDO na conexão?
- Como funciona o ataque
' OR '1'='1e por que o prepared statement barra ele? - Por que
ORDER BY ?não funciona e qual a defesa certa pra ordenação vinda do usuário? - Por que existe
bcrypt.compare/password_verifyem vez de comparar os hashes com===? - Quais as três partes de um JWT e o que cada uma garante? O que NUNCA vai no payload, e por quê?
- Qual a diferença entre autenticação e autorização, e onde cada middleware entra?
- O que é o preflight (OPTIONS) do CORS e qual o sintoma clássico quando ele não é tratado?
- O que é a condição de corrida no check-in e quais as três camadas de defesa?
- Quando usar transação na API, e por que no Node ela exige
getConnection()em vez depool.query()? - Por que a resposta de erro 500 tem que ser genérica, e pra onde vai o detalhe do erro?
- Se a banca perguntar "por que você não usou ORM?", qual a resposta nota 3?
Referências do módulo
Bibliografia do plano de curso (UC13/UC9):
- DUARTE, William. Programação Web com Node.js: Completa e sem frameworks. Casa do Código.
- Documentação oficial do Express: expressjs.com — Routing e Middleware
- Manual oficial do PHP: PDO · password_hash · password_verify
Segurança:
- OWASP Cheat Sheet Series (em especial SQL Injection Prevention e Authentication)
- JWT.io — Introduction to JSON Web Tokens
- (The only proper) PDO tutorial — phpdelusions
Referências de mercado (pesquisa web):
- MDN — Códigos de status de respostas HTTP
- Corbado — Node.js Express JWT Authentication with MySQL & Roles
- DigitalOcean — How To Use JSON Web Tokens (JWTs) in Express.js
- BezKoder — Node.js Rest APIs with Express & MySQL
- dev.to — How I structure my REST APIs
- Medium (pt-BR) — Construindo uma REST API com Node.js, Express e MySQL
- DevMedia — Web services RESTful: segurança com JWT
- Medium (pt-BR) — Autenticação de API PHP com JSON Web Token
- firebase/php-jwt — biblioteca JWT pra PHP
Back-end no lugar? Agora vem o módulo mais pesado da prova, onde tudo isso vira tela: 05 — Front-end web.