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 arquivo

O 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

Exercícios

  1. Exercício 1: Implemente uma rota GET /health que retorna um JSON com o status da API (por exemplo, {"status": "ok"}).

    ✓ Resposta: Adicione no index.php:
    $router->add('GET', '/health', function() { header('Content-Type: application/json'); echo json_encode(['status' => 'ok']); });
  2. Exercício 2: Crie um método update no controller que aceite apenas campos title e completed. Se o campo title for vazio, retorne erro 422.

    ✓ Resposta: No TaskController::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);
        }
    }
  3. Exercício 3: Adicione suporte ao método PATCH para atualização parcial (apenas os campos fornecidos).

    ✓ Resposta: No index.php, adicione:
    $router->add('PATCH', '/tasks/{id}', fn($id) => $taskController->update($id));
    E no controller, o método update já funciona para PATCH, pois só atualiza campos presentes.
  4. 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: No index.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);
    });
  5. Exercício 5: Implemente um método search que aceita um parâmetro de consulta q e retorna tarefas cujo título contenha a string (case-insensitive).

    ✓ Resposta: Adicione no controller:
    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));
    }
    E registre a rota:
    $router->add('GET', '/tasks/search', fn() => $taskController->search());