REST (Representational State Transfer) é o estilo arquitetural dominante para APIs web. Uma API REST expõe recursos via URLs, usa os verbos HTTP (GET, POST, PUT, PATCH, DELETE) de acordo com a semântica da operação, retorna respostas em JSON com os códigos de status corretos, e é stateless — cada requisição carrega todas as informações necessárias para ser processada.
Construir APIs REST em PHP puro (sem framework) é o melhor exercício para entender o que frameworks fazem por baixo dos panos. Neste artigo construímos um mini-framework de roteamento, um sistema de resposta JSON padronizado, tratamento de erros estruturado, e aplicamos boas práticas de design de API que se traduzem diretamente para qualquer framework que você usar depois.
Estrutura de uma requisição HTTP
Antes de escrever código, é essencial entender como o PHP expõe uma requisição HTTP:
<?php
declare(strict_types=1);
// ── Método HTTP ────────────────────────────────────────────────────────
$metodo = $_SERVER['REQUEST_METHOD']; // GET, POST, PUT, PATCH, DELETE
// ── URI e query string ─────────────────────────────────────────────────
$uri = $_SERVER['REQUEST_URI']; // /api/produtos?pagina=2&limite=20
$path = parse_url($uri, PHP_URL_PATH); // /api/produtos
parse_str($_SERVER['QUERY_STRING'] ?? '', $query); // ['pagina' => '2', 'limite' => '20']
// ── Headers ────────────────────────────────────────────────────────────
$contentType = $_SERVER['CONTENT_TYPE'] ?? '';
$accept = $_SERVER['HTTP_ACCEPT'] ?? '';
$authHeader = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
// ── Corpo da requisição ────────────────────────────────────────────────
// Para POST com Content-Type: application/x-www-form-urlencoded → $_POST
// Para PUT, PATCH, DELETE e JSON → ler php://input
$corpoRaw = file_get_contents('php://input');
$corpo = json_decode($corpoRaw, associative: true) ?? [];
// json_last_error() verifica se o JSON era válido
if ($corpoRaw !== '' && json_last_error() !== JSON_ERROR_NONE) {
// Corpo inválido — retornar 400 Bad Request
}
Sistema de resposta JSON padronizado
Toda API precisa de um formato de resposta consistente. Um envelope padrão facilita o consumo pelo cliente e o debugging.
<?php
declare(strict_types=1);
class Resposta
{
private function __construct() {}
// ── Respostas de sucesso ──────────────────────────────────────────
public static function ok(mixed $dados = null, string $mensagem = ''): never
{
self::enviar(200, ['sucesso' => true, 'dados' => $dados, 'mensagem' => $mensagem]);
}
public static function criado(mixed $dados = null, string $mensagem = 'Recurso criado.'): never
{
self::enviar(201, ['sucesso' => true, 'dados' => $dados, 'mensagem' => $mensagem]);
}
public static function semConteudo(): never
{
http_response_code(204);
exit;
}
// ── Respostas de erro ─────────────────────────────────────────────
public static function erro(
int $codigo,
string $mensagem,
array $detalhes = [],
): never {
self::enviar($codigo, [
'sucesso' => false,
'erro' => [
'codigo' => $codigo,
'mensagem' => $mensagem,
'detalhes' => $detalhes ?: null,
],
]);
}
public static function badRequest(string $mensagem = 'Requisição inválida.', array $detalhes = []): never
{
self::erro(400, $mensagem, $detalhes);
}
public static function naoAutorizado(string $mensagem = 'Autenticação necessária.'): never
{
self::erro(401, $mensagem);
}
public static function proibido(string $mensagem = 'Acesso negado.'): never
{
self::erro(403, $mensagem);
}
public static function naoEncontrado(string $mensagem = 'Recurso não encontrado.'): never
{
self::erro(404, $mensagem);
}
public static function erroInterno(string $mensagem = 'Erro interno do servidor.'): never
{
self::erro(500, $mensagem);
}
// ── Resposta paginada ─────────────────────────────────────────────
public static function paginado(
array $itens,
int $total,
int $pagina,
int $porPagina,
): never {
$totalPaginas = (int) ceil($total / $porPagina);
self::enviar(200, [
'sucesso' => true,
'dados' => $itens,
'paginacao' => [
'total' => $total,
'por_pagina' => $porPagina,
'pagina_atual' => $pagina,
'total_paginas'=> $totalPaginas,
'tem_proxima' => $pagina < $totalPaginas,
'tem_anterior' => $pagina > 1,
],
]);
}
private static function enviar(int $codigo, array $corpo): never
{
http_response_code($codigo);
header('Content-Type: application/json; charset=utf-8');
header('X-Content-Type-Options: nosniff');
echo json_encode($corpo, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
exit;
}
}
Router — roteamento com parâmetros dinâmicos
<?php
declare(strict_types=1);
class Router
{
private array $rotas = [];
public function get(string $padrao, callable $handler): void
{
$this->registrar('GET', $padrao, $handler);
}
public function post(string $padrao, callable $handler): void
{
$this->registrar('POST', $padrao, $handler);
}
public function put(string $padrao, callable $handler): void
{
$this->registrar('PUT', $padrao, $handler);
}
public function patch(string $padrao, callable $handler): void
{
$this->registrar('PATCH', $padrao, $handler);
}
public function delete(string $padrao, callable $handler): void
{
$this->registrar('DELETE', $padrao, $handler);
}
private function registrar(string $metodo, string $padrao, callable $handler): void
{
// Converte /produtos/{id} → regex /produtos/(?P<id>[^/]+).
// O que está fora dos {} precisa ser escapado: sem preg_quote, o ponto
// de uma rota como /feed.xml vira "qualquer caractere" e /feedaxml responde
// igual — dois endereços para o mesmo recurso. Por isso o padrão é
// quebrado nos placeholders, e só os pedaços literais passam pelo escape.
$partes = preg_split('/(\{\w+\})/', $padrao, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY);
$regex = '';
foreach ($partes as $parte) {
$regex .= preg_match('/^\{(\w+)\}$/', $parte, $m)
? "(?P<{$m[1]}>[^/]+)"
: preg_quote($parte, '#');
}
$this->rotas[] = [
'metodo' => $metodo,
'padrao' => "#^{$regex}$#",
'handler' => $handler,
];
}
public function despachar(string $metodo, string $uri): void
{
$path = parse_url($uri, PHP_URL_PATH);
foreach ($this->rotas as $rota) {
if ($rota['metodo'] !== $metodo) continue;
if (preg_match($rota['padrao'], $path, $matches)) {
// Extrai apenas parâmetros nomeados (filtra índices numéricos)
$params = array_filter(
$matches,
fn($k) => is_string($k),
ARRAY_FILTER_USE_KEY
);
// Tipagem automática, mas só do inteiro escrito de forma
// canônica. Com is_numeric() a conversão é destrutiva e
// silenciosa: "01310" viraria 1310, "1e3" viraria 1000, "3.9"
// viraria 3 e um número acima de PHP_INT_MAX saturaria no
// máximo. A API responderia sobre outro recurso sem avisar.
$params = array_map(
fn($v) => ctype_digit($v) && $v === (string) (int) $v ? (int) $v : $v,
$params
);
($rota['handler'])($params);
return;
}
}
Resposta::naoEncontrado("Rota não encontrada: {$metodo} {$path}");
}
}
Controllers — organizando os handlers
<?php
declare(strict_types=1);
class ProdutoController
{
public function __construct(
private readonly ProdutoRepositorioInterface $repositorio,
) {}
// GET /api/produtos
public function listar(array $params): void
{
$pagina = max(1, (int) ($_GET['pagina'] ?? 1));
$porPagina = min(100, max(1, (int) ($_GET['limite'] ?? 20)));
$categoria = $_GET['categoria'] ?? null;
$resultado = $this->repositorio->buscarPaginado(
pagina: $pagina,
porPagina: $porPagina,
categoria: $categoria,
);
Resposta::paginado(
itens: array_map($this->serializar(...), $resultado['itens']),
total: $resultado['total'],
pagina: $pagina,
porPagina: $porPagina,
);
}
// GET /api/produtos/{id}
public function mostrar(array $params): void
{
$produto = $this->repositorio->buscarPorId($params['id']);
if ($produto === null) {
Resposta::naoEncontrado("Produto #{$params['id']} não encontrado.");
}
Resposta::ok($this->serializar($produto));
}
// POST /api/produtos
public function criar(array $params): void
{
$corpo = $this->lerCorpo();
$erros = $this->validarCriacao($corpo);
if (!empty($erros)) {
Resposta::badRequest('Dados inválidos.', $erros);
}
$produto = $this->repositorio->salvar(new Produto(
id: 0,
nome: trim($corpo['nome']),
preco: (float) $corpo['preco'],
estoque: (int) ($corpo['estoque'] ?? 0),
ativo: (bool) ($corpo['ativo'] ?? true),
));
Resposta::criado($this->serializar($produto), "Produto criado com sucesso.");
}
// PUT /api/produtos/{id}
public function atualizar(array $params): void
{
$produto = $this->repositorio->buscarPorId($params['id']);
if ($produto === null) Resposta::naoEncontrado();
$corpo = $this->lerCorpo();
$erros = $this->validarCriacao($corpo);
if (!empty($erros)) Resposta::badRequest('Dados inválidos.', $erros);
$atualizado = new Produto(
id: $produto->id,
nome: trim($corpo['nome']),
preco: (float) $corpo['preco'],
estoque: (int) ($corpo['estoque'] ?? $produto->estoque),
ativo: (bool) ($corpo['ativo'] ?? $produto->ativo),
);
Resposta::ok($this->serializar($this->repositorio->salvar($atualizado)));
}
// PATCH /api/produtos/{id}
public function atualizarParcial(array $params): void
{
$produto = $this->repositorio->buscarPorId($params['id']);
if ($produto === null) Resposta::naoEncontrado();
$corpo = $this->lerCorpo();
// PATCH só atualiza os campos fornecidos — mantém o resto
$atualizado = new Produto(
id: $produto->id,
nome: isset($corpo['nome']) ? trim($corpo['nome']) : $produto->nome,
preco: isset($corpo['preco']) ? (float) $corpo['preco'] : $produto->preco,
estoque: isset($corpo['estoque']) ? (int) $corpo['estoque'] : $produto->estoque,
ativo: isset($corpo['ativo']) ? (bool) $corpo['ativo'] : $produto->ativo,
);
Resposta::ok($this->serializar($this->repositorio->salvar($atualizado)));
}
// DELETE /api/produtos/{id}
public function deletar(array $params): void
{
$produto = $this->repositorio->buscarPorId($params['id']);
if ($produto === null) Resposta::naoEncontrado();
$this->repositorio->deletar($params['id']);
Resposta::semConteudo();
}
// ── Helpers privados ─────────────────────────────────────────────
private function lerCorpo(): array
{
$raw = file_get_contents('php://input');
if (empty($raw)) return [];
$dados = json_decode($raw, associative: true);
if (json_last_error() !== JSON_ERROR_NONE) {
Resposta::badRequest('JSON inválido: ' . json_last_error_msg());
}
return $dados ?? [];
}
private function validarCriacao(array $dados): array
{
$erros = [];
if (empty($dados['nome'])) {
$erros['nome'] = 'O nome é obrigatório.';
} elseif (strlen($dados['nome']) > 200) {
$erros['nome'] = 'O nome deve ter no máximo 200 caracteres.';
}
if (!isset($dados['preco']) || !is_numeric($dados['preco']) || $dados['preco'] < 0) {
$erros['preco'] = 'O preço deve ser um número positivo.';
}
return $erros;
}
private function serializar(Produto $p): array
{
return [
'id' => $p->id,
'nome' => $p->nome,
'preco' => $p->preco,
'estoque' => $p->estoque,
'ativo' => $p->ativo,
];
}
}
Tratamento global de erros e ponto de entrada
<?php
declare(strict_types=1);
// public/index.php — ponto de entrada único
require_once __DIR__ . '/../vendor/autoload.php';
// Headers CORS para APIs consumidas por outros domínios. O curinga serve a
// API pública, que autentica por token no header Authorization. Ele não serve
// a quem autentica por cookie: com "*" o navegador se recusa a enviar credencial,
// e combinar o curinga com Access-Control-Allow-Credentials: true faz o browser
// descartar a resposta. Nesse caso, ecoe a origem exata vinda de uma lista.
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Authorization');
// Preflight CORS
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(204);
exit;
}
// Handler global de exceções não capturadas
set_exception_handler(function (\Throwable $e) {
$isDev = getenv('APP_ENV') === 'development';
error_log($e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine());
Resposta::erroInterno(
$isDev ? $e->getMessage() : 'Erro interno do servidor.'
);
});
// Handler de erros PHP como exceções
set_error_handler(function (int $severity, string $msg, string $file, int $line): bool {
if (!(error_reporting() & $severity)) return false;
throw new \ErrorException($msg, 0, $severity, $file, $line);
});
// Conexão e dependências
$pdo = criarConexao(/* ... */);
$repositorio = new ProdutoRepositorioPDO($pdo);
$controller = new ProdutoController($repositorio);
// Definição das rotas
$router = new Router();
$router->get ('/api/produtos', [$controller, 'listar']);
$router->get ('/api/produtos/{id}', [$controller, 'mostrar']);
$router->post ('/api/produtos', [$controller, 'criar']);
$router->put ('/api/produtos/{id}', [$controller, 'atualizar']);
$router->patch ('/api/produtos/{id}', [$controller, 'atualizarParcial']);
$router->delete('/api/produtos/{id}', [$controller, 'deletar']);
// Despacha a requisição
$router->despachar(
$_SERVER['REQUEST_METHOD'],
$_SERVER['REQUEST_URI'],
);
Boas práticas de design de API REST
# Nomes de recursos sempre no plural e em substantivos
GET /api/produtos ✅ GET /api/buscarProdutos ❌
GET /api/produtos/42 ✅ GET /api/getProduto/42 ❌
POST /api/produtos ✅ POST /api/criarProduto ❌
# Relacionamentos como sub-recursos
GET /api/pedidos/7/itens # itens do pedido 7
GET /api/usuarios/3/pedidos # pedidos do usuário 3
# Ações que não mapeiam para CRUD usam verbos como sufixo
POST /api/pedidos/7/cancelar # cancelar pedido 7
POST /api/usuarios/3/ativar # ativar usuário 3
# Versionamento na URL — protege clientes existentes
GET /api/v1/produtos
GET /api/v2/produtos # nova versão com breaking changes
# Filtros, ordenação e paginação via query string
GET /api/produtos?categoria=eletronicos&preco_min=100&preco_max=2000
GET /api/produtos?ordenar=preco&direcao=asc
GET /api/produtos?pagina=2&limite=20
# Códigos de status HTTP corretos — não retornar 200 para tudo
200 OK → GET bem-sucedido, UPDATE bem-sucedido
201 Created → POST que criou um recurso
204 No Content → DELETE bem-sucedido, sem corpo de resposta
400 Bad Request → dados inválidos, JSON malformado
401 Unauthorized → sem autenticação
403 Forbidden → autenticado mas sem permissão
404 Not Found → recurso não existe
409 Conflict → conflito de estado (email duplicado)
422 Unprocessable → validação falhou (alternativa ao 400)
500 Internal Error → erro não tratado no servidor
Escrever a API sem framework tem um efeito colateral útil: cada decisão que o Laravel ou o Slim tomam por você aparece à vista. O envelope de resposta é uma convenção sua, não uma lei do REST — mas escolher uma e não variar dela é o que permite ao cliente tratar sucesso e erro num lugar só. O mesmo vale para os códigos de status: devolver 200 com "sucesso": false no corpo obriga todo consumidor a ler o JSON para saber se a chamada deu certo.
O roteador é onde as arestas se escondem. Um padrão que vai direto para o regex sem escape transforma o ponto de /feed.xml em curinga, e uma conversão baseada em is_numeric() engole zero à esquerda e notação científica sem dizer nada. Nos dois casos a requisição é atendida, o status é 200, e o recurso devolvido é o errado — que é a pior forma de um bug se apresentar.
Fontes e leituras recomendadas
- MDN — HTTP Methods: https://developer.mozilla.org/pt-BR/docs/Web/HTTP/Methods
- MDN — HTTP Status Codes: https://developer.mozilla.org/pt-BR/docs/Web/HTTP/Status
- REST API Design Best Practices — Fielding dissertation: https://ics.uci.edu/~fielding/pubs/dissertation/rest_arch_style.htm
- PHP Manual — php://input: https://www.php.net/manual/pt_BR/wrappers.php.php
- JSON:API specification: https://jsonapi.org
Exercícios
Exercício 1
Estenda o Router para suportar middlewares: método middleware(callable ...$mws) que envolve o handler — cada middleware recebe ($params, $proximo) e pode chamar $proximo($params) para continuar ou retornar uma resposta de erro.
Ver resposta
✓ Resposta: A construção de trás para frente (array_reverse) é o que faz o primeiro middleware registrado ser o mais externo. E a regra de ouro da cebola: não chamar $proximo é o que aborta a requisição — é assim que o middleware de autenticação barra sem lançar exceção.
<?php
declare(strict_types=1);
final class Rota
{
/** @var callable[] */
private array $middlewares = [];
public function __construct(
public readonly string $metodo,
public readonly string $path,
public readonly mixed $handler,
) {}
public function middleware(callable ...$mws): static
{
foreach ($mws as $mw) {
$this->middlewares[] = $mw;
}
return $this;
}
/**
* Monta a cebola: o último middleware fica mais perto do handler.
* Construindo de trás para frente, cada camada recebe como $proximo a
* cadeia que já foi montada — é o mesmo mecanismo do pipeline do Laravel.
*/
public function executar(array $params): mixed
{
$proximo = fn (array $p): mixed => ($this->handler)($p);
foreach (array_reverse($this->middlewares) as $mw) {
$anterior = $proximo;
$proximo = fn (array $p): mixed => $mw($p, $anterior);
}
return $proximo($params);
}
}
final class Router
{
/** @var Rota[] */
private array $rotas = [];
/** @var callable[] aplicados a todas as rotas */
private array $globais = [];
public function middlewareGlobal(callable ...$mws): void
{
foreach ($mws as $mw) {
$this->globais[] = $mw;
}
}
public function get(string $path, callable $handler): Rota
{
return $this->adicionar('GET', $path, $handler);
}
public function post(string $path, callable $handler): Rota
{
return $this->adicionar('POST', $path, $handler);
}
public function put(string $path, callable $handler): Rota
{
return $this->adicionar('PUT', $path, $handler);
}
public function delete(string $path, callable $handler): Rota
{
return $this->adicionar('DELETE', $path, $handler);
}
private function adicionar(string $metodo, string $path, callable $handler): Rota
{
$rota = new Rota($metodo, $path, $handler);
$this->rotas[] = $rota;
return $rota; // devolve para permitir ->middleware(...) encadeado
}
public function despachar(string $metodo, string $uri): mixed
{
$caminho = rtrim(parse_url($uri, PHP_URL_PATH) ?? '/', '/') ?: '/';
foreach ($this->rotas as $rota) {
if ($rota->metodo !== strtoupper($metodo)) {
continue;
}
$regex = '#^' . preg_replace(
'/\\\\\{(\w+)\\\\\}/', '(?<$1>[^/]+)', preg_quote($rota->path, '#')
) . '$#';
if (preg_match($regex, $caminho, $m)) {
$params = array_filter($m, 'is_string', ARRAY_FILTER_USE_KEY);
return $rota->middleware(...$this->globais)->executar($params);
}
}
http_response_code(404);
return ['erro' => 'nao_encontrado'];
}
}
// ---------- Middlewares ---------------------------------------------------
$autenticar = static function (array $params, callable $proximo): mixed {
$header = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
if (!str_starts_with($header, 'Bearer ')) {
http_response_code(401);
// Não chamar $proximo é o que interrompe a cadeia.
return ['erro' => 'nao_autenticado'];
}
$params['usuario_id'] = 7; // viria da validação do token
return $proximo($params);
};
$logar = static function (array $params, callable $proximo): mixed {
$inicio = hrtime(true);
$resposta = $proximo($params); // antes e depois: a cebola permite os dois
error_log(sprintf('%s %s — %.2f ms',
$_SERVER['REQUEST_METHOD'] ?? 'CLI', $_SERVER['REQUEST_URI'] ?? '/',
(hrtime(true) - $inicio) / 1_000_000));
return $resposta;
};
$json = static function (array $params, callable $proximo): mixed {
header('Content-Type: application/json; charset=utf-8');
return $proximo($params);
};
// ---------- Uso -----------------------------------------------------------
$router = new Router();
$router->middlewareGlobal($json, $logar);
$router->get('/api/perfil', fn (array $p): array => ['usuario' => $p['usuario_id']])
->middleware($autenticar);
echo json_encode($router->despachar('GET', '/api/perfil'));
Exercício 2
Implemente um UsuarioController completo com as rotas: GET /api/usuarios, GET /api/usuarios/{id}, POST /api/usuarios, PUT /api/usuarios/{id}, DELETE /api/usuarios/{id}. Inclua validação de email único no POST.
Ver resposta
✓ Resposta: Os status contam história: 201 com Location ao criar, 204 sem corpo ao excluir, 422 para validação e 409 para conflito. E note a corrida no e-mail único — a validação antecipada melhora a mensagem, mas quem garante é o UNIQUE.
<?php
declare(strict_types=1);
final class Resposta
{
public static function json(mixed $dados, int $status = 200): array
{
http_response_code($status);
return ['status' => $status, 'corpo' => $dados];
}
public static function erro(string $codigo, string $msg, int $status, array $extra = []): array
{
return self::json(['erro' => $codigo, 'mensagem' => $msg] + $extra, $status);
}
}
final class UsuarioController
{
public function __construct(private readonly PDO $pdo) {}
/** GET /api/usuarios */
public function index(array $params): array
{
$pagina = max(1, (int) ($_GET['pagina'] ?? 1));
$porPagina = min(100, max(1, (int) ($_GET['por_pagina'] ?? 20)));
$total = (int) $this->pdo->query('SELECT COUNT(*) FROM usuarios')->fetchColumn();
$stmt = $this->pdo->prepare(
'SELECT id, nome, email, criado_em FROM usuarios
ORDER BY id LIMIT :limite OFFSET :offset'
);
$stmt->bindValue(':limite', $porPagina, PDO::PARAM_INT);
$stmt->bindValue(':offset', ($pagina - 1) * $porPagina, PDO::PARAM_INT);
$stmt->execute();
return Resposta::json([
'dados' => $stmt->fetchAll(PDO::FETCH_ASSOC),
'meta' => ['pagina' => $pagina, 'total' => $total,
'paginas' => (int) ceil($total / $porPagina)],
]);
}
/** GET /api/usuarios/{id} */
public function mostrar(array $params): array
{
$usuario = $this->buscar((int) $params['id']);
if ($usuario === null) {
return Resposta::erro('nao_encontrado', 'Usuário não encontrado.', 404);
}
return Resposta::json($usuario);
}
/** POST /api/usuarios */
public function criar(array $params): array
{
$dados = $this->corpoJson();
$erros = $this->validar($dados);
if ($erros !== []) {
// 422: sintaxe correta, conteúdo inválido. Diferente de 400.
return Resposta::erro('validacao', 'Dados inválidos.', 422, ['campos' => $erros]);
}
try {
$stmt = $this->pdo->prepare(
'INSERT INTO usuarios (nome, email, senha_hash, uuid)
VALUES (:nome, :email, :senha, :uuid)'
);
$stmt->execute([
':nome' => trim($dados['nome']),
':email' => mb_strtolower(trim($dados['email'])),
':senha' => password_hash($dados['senha'], PASSWORD_DEFAULT),
':uuid' => bin2hex(random_bytes(16)),
]);
} catch (PDOException $e) {
// A checagem antecipada do e-mail tem corrida: duas requisições
// simultâneas passam as duas. Quem garante de verdade é o UNIQUE
// do banco — por isso o catch do 23000 também existe.
if ($e->getCode() === '23000') {
return Resposta::erro('email_duplicado', 'E-mail já cadastrado.', 409);
}
throw $e;
}
$id = (int) $this->pdo->lastInsertId();
header('Location: /api/usuarios/' . $id);
return Resposta::json($this->buscar($id), 201);
}
/** PUT /api/usuarios/{id} */
public function atualizar(array $params): array
{
$id = (int) $params['id'];
if ($this->buscar($id) === null) {
return Resposta::erro('nao_encontrado', 'Usuário não encontrado.', 404);
}
$dados = $this->corpoJson();
$erros = $this->validar($dados, exigirSenha: false, ignorarId: $id);
if ($erros !== []) {
return Resposta::erro('validacao', 'Dados inválidos.', 422, ['campos' => $erros]);
}
$stmt = $this->pdo->prepare(
'UPDATE usuarios SET nome = :nome, email = :email WHERE id = :id'
);
$stmt->execute([
':nome' => trim($dados['nome']),
':email' => mb_strtolower(trim($dados['email'])),
':id' => $id,
]);
return Resposta::json($this->buscar($id));
}
/** DELETE /api/usuarios/{id} */
public function remover(array $params): array
{
$stmt = $this->pdo->prepare('DELETE FROM usuarios WHERE id = :id');
$stmt->execute([':id' => (int) $params['id']]);
if ($stmt->rowCount() === 0) {
return Resposta::erro('nao_encontrado', 'Usuário não encontrado.', 404);
}
// 204: sucesso sem corpo. Não devolva JSON aqui.
http_response_code(204);
return ['status' => 204, 'corpo' => null];
}
private function buscar(int $id): ?array
{
$stmt = $this->pdo->prepare(
'SELECT id, nome, email, criado_em FROM usuarios WHERE id = :id'
);
$stmt->execute([':id' => $id]);
return $stmt->fetch(PDO::FETCH_ASSOC) ?: null;
}
private function corpoJson(): array
{
$bruto = file_get_contents('php://input');
try {
return json_decode($bruto ?: '{}', true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException) {
return [];
}
}
private function validar(array $d, bool $exigirSenha = true, ?int $ignorarId = null): array
{
$erros = [];
if (trim($d['nome'] ?? '') === '') {
$erros['nome'] = 'Obrigatório.';
}
$email = trim($d['email'] ?? '');
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
$erros['email'] = 'E-mail inválido.';
} else {
$sql = 'SELECT COUNT(*) FROM usuarios WHERE email = :email';
$bind = [':email' => mb_strtolower($email)];
if ($ignorarId !== null) {
$sql .= ' AND id <> :id'; // no update, ele mesmo não conta
$bind[':id'] = $ignorarId;
}
$stmt = $this->pdo->prepare($sql);
$stmt->execute($bind);
if ((int) $stmt->fetchColumn() > 0) {
$erros['email'] = 'E-mail já cadastrado.';
}
}
if ($exigirSenha && mb_strlen($d['senha'] ?? '') < 8) {
$erros['senha'] = 'Mínimo de 8 caracteres.';
}
return $erros;
}
}
$router->get('/api/usuarios', fn ($p) => $controller->index($p));
$router->get('/api/usuarios/{id}', fn ($p) => $controller->mostrar($p));
$router->post('/api/usuarios', fn ($p) => $controller->criar($p));
$router->put('/api/usuarios/{id}', fn ($p) => $controller->atualizar($p));
$router->delete('/api/usuarios/{id}', fn ($p) => $controller->remover($p));
Exercício 3
Adicione suporte a sub-recursos: GET /api/usuarios/{id}/pedidos que retorna os pedidos de um usuário específico, com paginação. O controller deve verificar se o usuário existe antes de buscar os pedidos.
Ver resposta
✓ Resposta: Verificar o pai antes é o que dá significado às respostas: 404 quando o usuário não existe, 200 com lista vazia quando existe mas não tem pedidos. Sem isso, o cliente da API não consegue distinguir "id errado" de "nenhum pedido ainda".
<?php
declare(strict_types=1);
final class PedidoController
{
public function __construct(private readonly PDO $pdo) {}
/** GET /api/usuarios/{id}/pedidos?pagina=1&por_pagina=20 */
public function porUsuario(array $params): array
{
$usuarioId = (int) $params['id'];
// Verificar o pai PRIMEIRO muda a semântica da resposta:
// usuário inexistente → 404
// usuário sem pedidos → 200 com lista vazia
// Sem essa checagem, os dois casos devolveriam a mesma coisa.
if (!$this->usuarioExiste($usuarioId)) {
return Resposta::erro('usuario_nao_encontrado',
"Usuário {$usuarioId} não existe.", 404);
}
$pagina = max(1, (int) ($_GET['pagina'] ?? 1));
$porPagina = min(100, max(1, (int) ($_GET['por_pagina'] ?? 20)));
$filtros = ['p.usuario_id = :usuario'];
$bind = [':usuario' => $usuarioId];
if (($status = $_GET['status'] ?? null) !== null) {
$filtros[] = 'p.status = :status';
$bind[':status'] = $status;
}
$where = implode(' AND ', $filtros);
$stmtTotal = $this->pdo->prepare("SELECT COUNT(*) FROM pedidos p WHERE {$where}");
$stmtTotal->execute($bind);
$total = (int) $stmtTotal->fetchColumn();
$stmt = $this->pdo->prepare(
"SELECT p.id, p.status, p.total, p.criado_em,
COUNT(i.id) AS itens
FROM pedidos p
LEFT JOIN pedido_itens i ON i.pedido_id = p.id
WHERE {$where}
GROUP BY p.id, p.status, p.total, p.criado_em
ORDER BY p.criado_em DESC
LIMIT :limite OFFSET :offset"
);
foreach ($bind as $chave => $valor) {
$stmt->bindValue($chave, $valor);
}
$stmt->bindValue(':limite', $porPagina, PDO::PARAM_INT);
$stmt->bindValue(':offset', ($pagina - 1) * $porPagina, PDO::PARAM_INT);
$stmt->execute();
$paginas = (int) ceil($total / $porPagina);
return Resposta::json([
'dados' => $stmt->fetchAll(PDO::FETCH_ASSOC),
'meta' => [
'usuario_id' => $usuarioId,
'pagina' => $pagina,
'por_pagina' => $porPagina,
'total' => $total,
'paginas' => $paginas,
],
'links' => [
'self' => "/api/usuarios/{$usuarioId}/pedidos?pagina={$pagina}",
'proxima' => $pagina < $paginas
? "/api/usuarios/{$usuarioId}/pedidos?pagina=" . ($pagina + 1) : null,
'anterior' => $pagina > 1
? "/api/usuarios/{$usuarioId}/pedidos?pagina=" . ($pagina - 1) : null,
],
]);
}
private function usuarioExiste(int $id): bool
{
$stmt = $this->pdo->prepare('SELECT 1 FROM usuarios WHERE id = :id LIMIT 1');
$stmt->execute([':id' => $id]);
return $stmt->fetchColumn() !== false;
}
}
$router->get('/api/usuarios/{id}/pedidos', fn ($p) => $pedidoController->porUsuario($p))
->middleware($autenticar);
// GET /api/usuarios/7/pedidos?pagina=1
// { "dados": [...], "meta": {...}, "links": { "proxima": "...?pagina=2" } }
//
// GET /api/usuarios/999/pedidos
// 404 { "erro": "usuario_nao_encontrado" }
Exercício 4
Implemente rate limiting simples: use APCu ou um arquivo JSON para registrar o número de requisições por IP na última hora. Se ultrapassar 100 requisições, retornar 429 Too Many Requests com header Retry-After.
Ver resposta
✓ Resposta: Confiar em X-Forwarded-For sem validar é o furo comum: o cliente pode forjar o header e trocar de "IP" a cada requisição. Só aceite esse header vindo de um proxy seu, e nunca guarde o IP em texto puro num rate limiter de longa duração — sha1 na chave já ajuda.
<?php
declare(strict_types=1);
interface LimitadorInterface
{
/** @return array{permitido: bool, restante: int, retry_after: int} */
public function verificar(string $chave, int $limite, int $janelaSegundos): array;
}
final class LimitadorApcu implements LimitadorInterface
{
public function verificar(string $chave, int $limite, int $janela): array
{
$agora = time();
$chave = 'rl:' . sha1($chave);
// Janela deslizante: guarda os timestamps e descarta os que saíram
// da janela. Mais justo que o contador fixo, onde o cliente pode
// gastar o limite todo no fim de uma janela e de novo no começo da
// seguinte — o dobro em poucos segundos.
$registros = apcu_fetch($chave) ?: [];
$registros = array_values(array_filter(
$registros,
static fn(int $t): bool => $t > $agora - $janela,
));
if (count($registros) >= $limite) {
$maisAntigo = min($registros);
return [
'permitido' => false,
'restante' => 0,
'retry_after' => max(1, ($maisAntigo + $janela) - $agora),
];
}
$registros[] = $agora;
apcu_store($chave, $registros, $janela);
return [
'permitido' => true,
'restante' => $limite - count($registros),
'retry_after' => 0,
];
}
}
/** Alternativa sem APCu, para hospedagem compartilhada. */
final class LimitadorArquivo implements LimitadorInterface
{
public function __construct(private readonly string $diretorio = '/tmp/ratelimit') {}
public function verificar(string $chave, int $limite, int $janela): array
{
if (!is_dir($this->diretorio)) {
mkdir($this->diretorio, 0700, true);
}
$arquivo = $this->diretorio . '/' . sha1($chave) . '.json';
$agora = time();
$handle = fopen($arquivo, 'c+');
// LOCK_EX é o que impede duas requisições simultâneas de lerem o
// mesmo contador e gravarem por cima uma da outra.
flock($handle, LOCK_EX);
$conteudo = stream_get_contents($handle);
$registros = $conteudo ? (json_decode($conteudo, true) ?: []) : [];
$registros = array_values(array_filter(
$registros, static fn(int $t): bool => $t > $agora - $janela
));
$permitido = count($registros) < $limite;
if ($permitido) {
$registros[] = $agora;
}
ftruncate($handle, 0);
rewind($handle);
fwrite($handle, json_encode($registros));
flock($handle, LOCK_UN);
fclose($handle);
return [
'permitido' => $permitido,
'restante' => max(0, $limite - count($registros)),
'retry_after' => $permitido ? 0 : max(1, (min($registros) + $janela) - $agora),
];
}
}
// ---------- Middleware ----------------------------------------------------
function limitarTaxa(LimitadorInterface $limitador, int $limite = 100, int $janela = 3600): callable
{
return static function (array $params, callable $proximo) use ($limitador, $limite, $janela): mixed {
$ip = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['REMOTE_ADDR'] ?? 'desconhecido';
$ip = trim(explode(',', $ip)[0]); // o primeiro da cadeia de proxies
$r = $limitador->verificar($ip, $limite, $janela);
header('X-RateLimit-Limit: ' . $limite);
header('X-RateLimit-Remaining: ' . $r['restante']);
if (!$r['permitido']) {
http_response_code(429);
header('Retry-After: ' . $r['retry_after']);
return [
'erro' => 'muitas_requisicoes',
'mensagem' => "Limite de {$limite} requisições por hora excedido.",
'tente_em' => $r['retry_after'] . 's',
];
}
return $proximo($params);
};
}
$router->middlewareGlobal(limitarTaxa(new LimitadorApcu(), limite: 100, janela: 3600));
Exercício 5
Desafio: crie um sistema de versionamento de API: rotas registradas como /api/v1/produtos e /api/v2/produtos podem coexistir, com controllers diferentes. Implemente uma classe ApiVersion que lê o prefixo de versão da URL e instancia o controller correto via array de mapeamento.
Ver resposta
✓ Resposta: Repare que usuarios aponta para a mesma classe nas duas versões: versionar não obriga a duplicar tudo, só o que mudou de contrato. E os cabeçalhos Deprecation/Sunset são o que permite desligar a v1 um dia sem pegar ninguém de surpresa.
<?php
declare(strict_types=1);
enum VersaoApi: string
{
case V1 = 'v1';
case V2 = 'v2';
public function depreciada(): bool
{
return $this === self::V1;
}
public function sunsetEm(): ?string
{
return $this === self::V1 ? '2027-01-01' : null;
}
}
final class ApiVersion
{
/** @var array<string, array<string, class-string>> versão => recurso => controller */
private const MAPA = [
'v1' => [
'produtos' => ProdutoControllerV1::class,
'usuarios' => UsuarioControllerV1::class,
],
'v2' => [
'produtos' => ProdutoControllerV2::class,
// usuarios não mudou na v2: reaproveita o da v1.
'usuarios' => UsuarioControllerV1::class,
],
];
public const PADRAO = VersaoApi::V2;
/** Lê /api/v1/produtos → [VersaoApi::V1, 'produtos', resto] */
public static function daUri(string $uri): array
{
$caminho = trim(parse_url($uri, PHP_URL_PATH) ?? '', '/');
$partes = explode('/', $caminho);
// Espera api/{versao}/{recurso}/...
if (($partes[0] ?? '') !== 'api') {
throw new InvalidArgumentException('URI fora do prefixo /api.');
}
$versao = VersaoApi::tryFrom($partes[1] ?? '');
if ($versao === null) {
// Sem versão na URL: assume a padrão e desloca os índices.
$versao = self::PADRAO;
$recurso = $partes[1] ?? '';
$resto = array_slice($partes, 2);
} else {
$recurso = $partes[2] ?? '';
$resto = array_slice($partes, 3);
}
return [$versao, $recurso, $resto];
}
public static function controller(VersaoApi $versao, string $recurso): object
{
$classe = self::MAPA[$versao->value][$recurso]
?? throw new RuntimeException(
"Recurso '{$recurso}' não existe na {$versao->value}."
);
return new $classe();
}
/** Avisa o cliente que a versão vai sair do ar (RFC 8594). */
public static function cabecalhos(VersaoApi $versao): void
{
header('X-Api-Version: ' . $versao->value);
if ($versao->depreciada()) {
header('Deprecation: true');
header('Sunset: ' . $versao->sunsetEm());
header('Link: </api/v2>; rel="successor-version"');
}
}
}
// ---------- Os controllers convivem --------------------------------------
final class ProdutoControllerV1
{
public function index(array $params): array
{
// Formato antigo: lista crua, preço como float.
return [
['id' => 1, 'nome' => 'Teclado', 'preco' => 349.90],
];
}
}
final class ProdutoControllerV2
{
public function index(array $params): array
{
// Formato novo: envelope com meta, preço em centavos (sem float),
// e campos renomeados. Quebrar contrato é o motivo de existir a v2.
return [
'dados' => [
['id' => 1, 'titulo' => 'Teclado', 'preco_centavos' => 34990,
'moeda' => 'BRL'],
],
'meta' => ['versao' => 'v2', 'total' => 1],
];
}
}
final class UsuarioControllerV1
{
public function index(array $params): array
{
return [['id' => 7, 'nome' => 'Ana']];
}
}
// ---------- Despacho ------------------------------------------------------
try {
[$versao, $recurso, $resto] = ApiVersion::daUri($_SERVER['REQUEST_URI'] ?? '/api/v2/produtos');
ApiVersion::cabecalhos($versao);
$controller = ApiVersion::controller($versao, $recurso);
header('Content-Type: application/json; charset=utf-8');
echo json_encode($controller->index(['resto' => $resto]), JSON_UNESCAPED_UNICODE);
} catch (RuntimeException $e) {
http_response_code(404);
echo json_encode(['erro' => 'recurso_desconhecido', 'mensagem' => $e->getMessage()]);
}
// GET /api/v1/produtos → [{"id":1,"nome":"Teclado","preco":349.9}]
// Deprecation: true / Sunset: 2027-01-01
// GET /api/v2/produtos → {"dados":[{"id":1,"titulo":"Teclado",...}],"meta":{...}}