Projeto: API REST sem framework
LidoNesta aula, vamos construir uma API REST simples sem o uso de frameworks, cobrindo desde o roteamento até o tratamento de erros, com exemplos práticos em PHP puro. Você aprenderá a estruturar um projeto, implementar controllers, manipular JSON e lidar com erros de forma eficiente.
Nesta aula, vamos colocar em prática os conceitos fundamentais de uma API REST, construindo uma aplicação completa sem o auxílio de frameworks. Isso é essencial para entender o que acontece 'por baixo dos panos' em ferramentas como Laravel ou Symfony, e para quando você precisar de uma solução leve e sob medida.
Vamos desenvolver uma API simples de gerenciamento de tarefas (To-Do), com endpoints para listar, criar, atualizar e excluir tarefas. Ao longo do caminho, abordaremos roteamento, controllers, manipulação de JSON e tratamento de erros, sempre com código limpo e boas práticas.
Estrutura do Projeto
Antes de mergulharmos no código, é importante definir uma estrutura de diretórios que facilite a organização e a manutenção do projeto. Uma estrutura comum para uma API sem framework é:
api/
├── public/
│ └── index.php # Ponto de entrada único (front controller)
├── src/
│ ├── Controllers/
│ │ └── TaskController.php
│ ├── Core/
│ │ ├── Router.php
│ │ └── Request.php
│ └── Models/
│ └── Task.php
└── data/
└── tasks.json # Armazenamento simples em arquivoO diretório public é o documento raiz do servidor web, e o index.php é o ponto de entrada único (front controller). Todas as requisições são redirecionadas para esse arquivo, que cuida do roteamento e da execução dos controllers. A pasta src contém o código da aplicação, separado por responsabilidades: Controllers, Core (núcleo) e Models. Os dados são armazenados em um arquivo JSON simples, simulando um banco de dados.
Roteamento
O roteamento é o processo de mapear uma URL (e um método HTTP) para uma ação específica do controller. Em um framework, isso é feito de forma declarativa, mas aqui vamos implementar nosso próprio roteador.
O roteador deve analisar a URI da requisição, extrair o caminho e o método HTTP, e então encontrar a rota correspondente. Podemos definir uma tabela de rotas associando cada padrão a um controller e método.
Vamos implementar uma classe Router que armazena as rotas e as resolve:
// src/Core/Router.php
class Router {
private array $routes = [];
public function add(string $method, string $path, callable $handler): void {
$this->routes[] = [
'method' => $method,
'path' => $path,
'handler' => $handler
];
}
public function resolve(string $method, string $uri): mixed {
$path = parse_url($uri, PHP_URL_PATH);
foreach ($this->routes as $route) {
if ($route['method'] === $method && $route['path'] === $path) {
return call_user_func($route['handler']);
}
}
http_response_code(404);
echo json_encode(['error' => 'Not Found']);
return null;
}
}No index.php, definimos as rotas e passamos a requisição para o roteador:
// public/index.php
require_once __DIR__ . '/../src/Core/Router.php';
require_once __DIR__ . '/../src/Controllers/TaskController.php';
$router = new Router();
$taskController = new TaskController();
$router->add('GET', '/tasks', fn() => $taskController->index());
$router->add('GET', '/tasks/{id}', fn($id) => $taskController->show($id));
$router->add('POST', '/tasks', fn() => $taskController->store());
$router->add('PUT', '/tasks/{id}', fn($id) => $taskController->update($id));
$router->add('DELETE', '/tasks/{id}', fn($id) => $taskController->destroy($id));
$router->resolve($_SERVER['REQUEST_METHOD'], $_SERVER['REQUEST_URI']);Atenção: nesse exemplo, estamos usando rotas com parâmetros como {id}, mas nosso roteador simples não suporta isso. Vamos melhorá-lo para extrair parâmetros da URL. Podemos usar expressões regulares para capturar os valores. Uma abordagem é transformar cada rota em um padrão regex:
// Melhoria no Router.php
public function resolve(string $method, string $uri): mixed {
$path = parse_url($uri, PHP_URL_PATH);
foreach ($this->routes as $route) {
if ($route['method'] !== $method) continue;
$pattern = preg_replace('/\{([a-zA-Z]+)\}/', '(?P<$1>[a-zA-Z0-9_-]+)', $route['path']);
$pattern = '#^' . $pattern . '$#';
if (preg_match($pattern, $path, $matches)) {
$params = array_filter($matches, 'is_string', ARRAY_FILTER_USE_KEY); // remove numeric keys
return call_user_func_array($route['handler'], $params);
}
}
http_response_code(404);
echo json_encode(['error' => 'Not Found']);
return null;
}Assim, as rotas com parâmetros funcionarão corretamente. Nas próximas seções, veremos como o controller recebe esses parâmetros.
Controllers
Controllers são classes responsáveis por receber a requisição, processar os dados e retornar a resposta. Eles são a camada intermediária entre a rota e o modelo (ou lógica de negócio). Em uma API REST, cada método do controller corresponde a uma ação CRUD.
Vamos criar um TaskController com os métodos básicos: index (listar todas), show (exibir uma), store (criar), update (atualizar) e destroy (excluir).
Cada método deve ler os dados da requisição (via $_GET, $_POST ou o corpo da requisição, que pode ser JSON), chamar o modelo apropriado e retornar uma resposta em JSON.
// src/Controllers/TaskController.php
require_once __DIR__ . '/../Models/Task.php';
class TaskController {
private Task $taskModel;
public function __construct() {
$this->taskModel = new Task();
}
public function index(): void {
$tasks = $this->taskModel->all();
$this->jsonResponse($tasks);
}
public function show(int $id): void {
$task = $this->taskModel->find($id);
if ($task) {
$this->jsonResponse($task);
} else {
$this->jsonResponse(['error' => 'Task not found'], 404);
}
}
public function store(): void {
$data = json_decode(file_get_contents('php://input'), true);
if (!isset($data['title'])) {
$this->jsonResponse(['error' => 'Title is required'], 422);
return;
}
$task = $this->taskModel->create($data);
$this->jsonResponse($task, 201);
}
public function update(int $id): void {
$data = json_decode(file_get_contents('php://input'), true);
$task = $this->taskModel->update($id, $data);
if ($task) {
$this->jsonResponse($task);
} else {
$this->jsonResponse(['error' => 'Task not found'], 404);
}
}
public function destroy(int $id): void {
if ($this->taskModel->delete($id)) {
$this->jsonResponse(['message' => 'Task deleted']);
} else {
$this->jsonResponse(['error' => 'Task not found'], 404);
}
}
private function jsonResponse($data, int $status = 200): void {
http_response_code($status);
header('Content-Type: application/json');
echo json_encode($data);
}
}Note que o controller depende de um modelo Task que ainda não implementamos. Na próxima seção, veremos como trabalhar com JSON como armazenamento.
JSON
JSON (JavaScript Object Notation) é o formato de troca de dados mais comum em APIs REST. Em PHP, temos funções nativas para codificar e decodificar JSON: json_encode() e json_decode().
No nosso projeto, vamos usar um arquivo JSON para armazenar as tarefas. O modelo Task será responsável por ler e escrever nesse arquivo, simulando um banco de dados. Vamos implementar os métodos básicos de CRUD.
// src/Models/Task.php
class Task {
private string $dataFile;
public function __construct() {
$this->dataFile = __DIR__ . '/../../data/tasks.json';
}
private function readData(): array {
if (!file_exists($this->dataFile)) {
return [];
}
$json = file_get_contents($this->dataFile);
return json_decode($json, true) ?? [];
}
private function writeData(array $data): void {
file_put_contents($this->dataFile, json_encode($data, JSON_PRETTY_PRINT));
}
public function all(): array {
return $this->readData();
}
public function find(int $id): ?array {
$tasks = $this->readData();
foreach ($tasks as $task) {
if ($task['id'] === $id) {
return $task;
}
}
return null;
}
public function create(array $data): array {
$tasks = $this->readData();
$newId = count($tasks) > 0 ? max(array_column($tasks, 'id')) + 1 : 1;
$task = [
'id' => $newId,
'title' => $data['title'],
'completed' => false,
'created_at' => date('Y-m-d H:i:s')
];
$tasks[] = $task;
$this->writeData($tasks);
return $task;
}
public function update(int $id, array $data): ?array {
$tasks = $this->readData();
foreach ($tasks as &$task) {
if ($task['id'] === $id) {
$task['title'] = $data['title'] ?? $task['title'];
$task['completed'] = $data['completed'] ?? $task['completed'];
$this->writeData($tasks);
return $task;
}
}
return null;
}
public function delete(int $id): bool {
$tasks = $this->readData();
foreach ($tasks as $key => $task) {
if ($task['id'] === $id) {
unset($tasks[$key]);
$this->writeData(array_values($tasks));
return true;
}
}
return false;
}
}É importante notar que essa abordagem não é escalável para grandes volumes de dados, mas é suficiente para fins educacionais e para projetos pequenos. Em produção, você usaria um banco de dados real como MySQL ou PostgreSQL.
Além disso, ao manipular JSON, é essencial verificar se os dados são válidos. Use json_last_error() para detectar erros de decodificação, como veremos na seção de tratamento de erros.
Tratamento de erros
Uma boa API deve retornar mensagens de erro claras e com códigos HTTP apropriados. No PHP, podemos configurar um manipulador de exceções global para capturar erros não tratados e responder em JSON.
Primeiro, vamos criar uma função que converte exceções em respostas JSON:
// public/index.php
set_exception_handler(function (Throwable $e) {
http_response_code(500);
header('Content-Type: application/json');
echo json_encode(['error' => $e->getMessage()]);
});Também podemos tratar erros de validação nos controllers, retornando 422 Unprocessable Entity com detalhes. Por exemplo, no método store, verificamos se o campo title está presente.
Outra prática é criar uma classe de exceção personalizada para erros de validação:
// src/Core/ValidationException.php
class ValidationException extends Exception {}
// No controller, podemos lançar:
throw new ValidationException('Title is required');
// E capturar no manipulador global:
set_exception_handler(function (Throwable $e) {
if ($e instanceof ValidationException) {
http_response_code(422);
} else {
http_response_code(500);
}
header('Content-Type: application/json');
echo json_encode(['error' => $e->getMessage()]);
});Além disso, é importante tratar erros de leitura/escrita no arquivo JSON. Podemos verificar se o arquivo é gravável e lançar uma exceção se não for:
// No modelo Task
private function writeData(array $data): void {
if (!is_writable($this->dataFile)) {
throw new RuntimeException('Data file is not writable');
}
file_put_contents($this->dataFile, json_encode($data, JSON_PRETTY_PRINT));
}Com isso, garantimos que erros inesperados sejam capturados e retornados em JSON, mantendo a consistência da API.
Boas práticas
Ao construir uma API REST, alguns pontos são importantes:
- Use os métodos HTTP corretamente: GET para consultar, POST para criar, PUT/PATCH para atualizar, DELETE para excluir.
- Retorne códigos de status adequados: 200 para sucesso, 201 para criação, 404 para não encontrado, 422 para validação, 500 para erros internos.
- Valide os dados de entrada: nunca confie no cliente; verifique tipos, campos obrigatórios, etc.
- Documente sua API: use ferramentas como OpenAPI ou escreva uma documentação clara.
- Considere a segurança: em produção, use HTTPS, autenticação e evite expor informações sensíveis.
Referências
- Documentação oficial do PHP - json_encode
- Documentação oficial do PHP - json_decode
- Documentação oficial do PHP - set_exception_handler
- Códigos de status HTTP - MDN
- RESTful API - Guia de referência
Exercícios
- Exercício 1: Implemente uma rota
GET /healthque retorna um JSON com o status da API (por exemplo,{"status": "ok"}).✓ Resposta: Adicione noindex.php:$router->add('GET', '/health', function() { header('Content-Type: application/json'); echo json_encode(['status' => 'ok']); }); - Exercício 2: Crie um método
updateno controller que aceite apenas campostitleecompleted. Se o campotitlefor vazio, retorne erro 422.✓ Resposta: NoTaskController::update:public function update(int $id): void { $data = json_decode(file_get_contents('php://input'), true); if (isset($data['title']) && $data['title'] === '') { $this->jsonResponse(['error' => 'Title cannot be empty'], 422); return; } $task = $this->taskModel->update($id, $data); if ($task) { $this->jsonResponse($task); } else { $this->jsonResponse(['error' => 'Task not found'], 404); } } - Exercício 3: Adicione suporte ao método
PATCHpara atualização parcial (apenas os campos fornecidos).✓ Resposta: Noindex.php, adicione:
E no controller, o método$router->add('PATCH', '/tasks/{id}', fn($id) => $taskController->update($id));updatejá funciona para PATCH, pois só atualiza campos presentes. - Exercício 4: Crie uma rota
GET /tasks/{id}que retorne a tarefa com o ID informado, mas se o ID não for numérico, retorne 400 Bad Request.✓ Resposta: Noindex.php, dentro da rota GET /tasks/{id}, podemos validar:$router->add('GET', '/tasks/{id}', function($id) use ($taskController) { if (!ctype_digit($id)) { http_response_code(400); echo json_encode(['error' => 'Invalid ID']); return; } $taskController->show((int)$id); }); - Exercício 5: Implemente um método
searchque aceita um parâmetro de consultaqe retorna tarefas cujo título contenha a string (case-insensitive).✓ Resposta: Adicione no controller:
E registre a rota:public function search(): void { $q = $_GET['q'] ?? ''; $tasks = $this->taskModel->all(); $filtered = array_filter($tasks, function($task) use ($q) { return stripos($task['title'], $q) !== false; }); $this->jsonResponse(array_values($filtered)); }$router->add('GET', '/tasks/search', fn() => $taskController->search());