Skip to content

Como Fazer: Gerenciamento de Projetos e Tarefas com Recursos Aninhados

Referência FT: FT241 (NENE2-FT/projtrack) — API de Gerenciamento de Projetos e Tarefas

Demonstra uma API de recurso aninhado de dois níveis onde tarefas pertencem a projetos, com validação de existência do pai, atualizações seletivas via PATCH usando array_key_exists(), allowlist de status via restrição CHECK, prioridade como inteiro, e 204 No Content para respostas de DELETE.


Rotas

MétodoCaminhoDescrição
GET/projectsListar projetos (paginado)
POST/projectsCriar um projeto
GET/projects/{id}Obter um único projeto
DELETE/projects/{id}Excluir um projeto (cascata para tarefas)
GET/projects/{projectId}/tasksListar tarefas de um projeto (paginado, filtrável)
POST/projects/{projectId}/tasksCriar uma tarefa dentro de um projeto
GET/projects/{projectId}/tasks/{taskId}Obter uma única tarefa
PATCH/projects/{projectId}/tasks/{taskId}Atualizar tarefa seletivamente (campos omitidos mantidos)
DELETE/projects/{projectId}/tasks/{taskId}Excluir uma tarefa (204 No Content)

Schema

sql
CREATE TABLE IF NOT EXISTS projects (
    id          INTEGER PRIMARY KEY AUTOINCREMENT,
    name        TEXT    NOT NULL,
    description TEXT    NOT NULL DEFAULT '',
    created_at  TEXT    NOT NULL,
    updated_at  TEXT    NOT NULL
);

CREATE TABLE IF NOT EXISTS tasks (
    id          INTEGER PRIMARY KEY AUTOINCREMENT,
    project_id  INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
    title       TEXT    NOT NULL,
    status      TEXT    NOT NULL DEFAULT 'open' CHECK(status IN ('open', 'in_progress', 'done')),
    priority    INTEGER NOT NULL DEFAULT 0,
    created_at  TEXT    NOT NULL,
    updated_at  TEXT    NOT NULL
);

status é restrito no nível de banco com CHECK(status IN (...)) — uma rede de segurança contra valores inválidos que escapem. ON DELETE CASCADE significa que excluir um projeto remove automaticamente todas as suas tarefas. priority tem padrão 0; valores maiores são ordenados primeiro.


Recursos aninhados: validação de existência do pai

Toda operação de tarefa valida que o projeto pai existe antes de tocar a tarefa. Se o ID do projeto for desconhecido, ProjectNotFoundException é lançada imediatamente:

php
private function listTasks(ServerRequestInterface $request): ResponseInterface
{
    $params    = (array) $request->getAttribute(Router::PARAMETERS_ATTRIBUTE);
    $projectId = (int) ($params['projectId'] ?? 0);

    // Garantir que o projeto existe (lança ProjectNotFoundException → 404)
    $this->projects->findById($projectId);

    $items = $this->tasks->findByProject($projectId, $status, $pagination->limit, $pagination->offset);
    // ...
}

ProjectNotFoundException é registrada como um handler de exceção que mapeia para 404 Not Found. Isso significa que /projects/99/tasks retorna 404 quando o projeto 99 não existe — o mesmo status de uma tarefa ausente. Chamadores não conseguem distinguir "projeto ausente" de "tarefa ausente" sem ler o campo detail do Problem Details.

O repositório de tarefas também impõe escopo de projeto no nível SQL:

php
public function findByProjectAndId(int $projectId, int $taskId): Task
{
    $row = $this->executor->fetchOne(
        'SELECT * FROM tasks WHERE id = ? AND project_id = ?',
        [$taskId, $projectId],
    );
    if ($row === null) {
        throw new TaskNotFoundException($projectId, $taskId);
    }
    return $this->hydrate($row);
}

WHERE id = ? AND project_id = ? impede acesso entre projetos — a tarefa 5 do projeto 1 não pode ser obtida via /projects/2/tasks/5, mesmo que a tarefa 5 exista.


PATCH: atualização seletiva de campos com array_key_exists()

PATCH /projects/{projectId}/tasks/{taskId} aceita qualquer subconjunto de title, status, e priority. Campos ausentes do corpo da requisição são preservados.

isset() não consegue distinguir "chave ausente" de "chave presente com null". Para semântica de PATCH, array_key_exists() é a ferramenta correta:

php
$title    = null;
$status   = null;
$priority = null;

if (array_key_exists('title', $body)) {
    if (!is_string($body['title']) || trim($body['title']) === '') {
        $errors[] = new ValidationError('title', 'title must be a non-empty string.', 'invalid_value');
    } else {
        $title = trim($body['title']);
    }
}

if (array_key_exists('status', $body)) {
    $validStatuses = ['open', 'in_progress', 'done'];
    if (!is_string($body['status']) || !in_array($body['status'], $validStatuses, true)) {
        $errors[] = new ValidationError('status', 'status must be one of: open, in_progress, done.', 'invalid_value');
    } else {
        $status = $body['status'];
    }
}

if (array_key_exists('priority', $body)) {
    if (!is_int($body['priority'])) {
        $errors[] = new ValidationError('priority', 'priority must be an integer.', 'invalid_type');
    } else {
        $priority = $body['priority'];
    }
}

$title, $status e $priority permanecem null quando a chave está ausente. O repositório interpreta null como "não alterar":

php
public function update(int $projectId, int $taskId, ?string $title, ?string $status, ?int $priority, string $now): Task
{
    $existing    = $this->findByProjectAndId($projectId, $taskId);
    $newTitle    = $title    ?? $existing->title;
    $newStatus   = $status   ?? $existing->status;
    $newPriority = $priority ?? $existing->priority;

    $this->executor->execute(
        'UPDATE tasks SET title = ?, status = ?, priority = ?, updated_at = ? WHERE id = ? AND project_id = ?',
        [$newTitle, $newStatus, $newPriority, $now, $taskId, $projectId],
    );

    return $this->findByProjectAndId($projectId, $taskId);
}

O operador null-coalescing ?? mescla os valores fornecidos com o registro existente. Um único UPDATE sempre é executado (sem lista de colunas dinâmica) — o valor existente simplesmente substitui a si mesmo quando um campo está ausente.


is_int() para priority: rejeitando floats e strings do JSON

JSON 1 decodifica como PHP int, mas 1.0 decodifica como float e "1" como string. is_int() aceita apenas a forma de inteiro:

php
if (isset($body['priority'])) {
    if (!is_int($body['priority'])) {
        $errors[] = new ValidationError('priority', 'priority must be an integer.', 'invalid_type');
    } else {
        $priority = $body['priority'];
    }
}

is_numeric() passaria "1" e 1.0 — use is_int() para validação estrita de inteiros. Nota: priority é opcional na criação (padrão 0); no PATCH, a mesma verificação se aplica dentro do bloco array_key_exists('priority', $body).


Validação de allowlist de status

O status é validado contra uma allowlist explícita antes de chegar ao banco:

php
$validStatuses = ['open', 'in_progress', 'done'];
if (!is_string($body['status']) || !in_array($body['status'], $validStatuses, true)) {
    $errors[] = new ValidationError('status', 'status must be one of: open, in_progress, done.', 'invalid_value');
}

in_array(..., true) — comparação estrita — garante que o valor é uma string igual a um dos estados permitidos. A restrição CHECK do banco fornece uma segunda camada de defesa, mas a verificação no nível de aplicação fornece um 422 estruturado com uma mensagem de erro significativa em vez de um erro bruto do banco.


Filtro de status para listagem de tarefas

GET /projects/{projectId}/tasks?status=open filtra tarefas por status. O query string é lido com QueryStringParser::string():

php
$status = QueryStringParser::string($request, 'status');

$validStatuses = ['open', 'in_progress', 'done'];
if ($status !== null && !in_array($status, $validStatuses, true)) {
    throw new ValidationException([
        new ValidationError('status', 'status must be one of: open, in_progress, done.', 'invalid_value'),
    ]);
}

QueryStringParser::string() retorna null quando o parâmetro está ausente — nenhum filtro aplicado. Um valor inválido retorna 422 Unprocessable Entity em vez de retornar silenciosamente uma lista vazia.

O repositório constrói a cláusula WHERE dinamicamente:

php
public function findByProject(int $projectId, ?string $status = null, int $limit = 20, int $offset = 0): array
{
    $where  = ['project_id = ?'];
    $params = [$projectId];

    if ($status !== null) {
        $where[]  = 'status = ?';
        $params[] = $status;
    }

    $sql = 'SELECT * FROM tasks WHERE ' . implode(' AND ', $where)
        . ' ORDER BY priority DESC, created_at ASC LIMIT ? OFFSET ?';
    $params[] = $limit;
    $params[] = $offset;

    return array_map($this->hydrate(...), $this->executor->fetchAll($sql, $params));
}

As tarefas são ordenadas por priority DESC (prioridade mais alta primeiro), depois por created_at ASC (tarefas mais antigas primeiro dentro da mesma prioridade).


204 No Content para DELETE

As respostas DELETE não carregam corpo. JsonResponseFactory::createEmpty(204) produz uma resposta 204 No Content:

php
private function deleteTask(ServerRequestInterface $request): ResponseInterface
{
    $params    = (array) $request->getAttribute(Router::PARAMETERS_ATTRIBUTE);
    $projectId = (int) ($params['projectId'] ?? 0);
    $taskId    = (int) ($params['taskId'] ?? 0);

    $this->projects->findById($projectId);
    $this->tasks->delete($projectId, $taskId);

    return $this->json->createEmpty(204);
}

O repositório de tarefas valida a existência antes de excluir:

php
public function delete(int $projectId, int $taskId): void
{
    $this->findByProjectAndId($projectId, $taskId);  // lança TaskNotFoundException se não encontrado
    $this->executor->execute('DELETE FROM tasks WHERE id = ? AND project_id = ?', [$taskId, $projectId]);
}

Se a tarefa não existe (ou pertence a um projeto diferente), TaskNotFoundException é lançada → 404 Not Found antes que qualquer DELETE seja executado.


Recursos integrados do NENE2 utilizados

Recurso integradoFinalidade
PaginationQueryParser::parse()?limit= e ?offset= com padrões seguros
PaginationResponseProduz envelope { items, total, limit, offset }
ValidationException / ValidationError422 estruturado com array errors
QueryStringParser::string()Lê um parâmetro de query string nomeado, retorna null se ausente
JsonRequestBodyParser::parse()Decodifica corpo JSON
JsonResponseFactory::create()Codifica resposta JSON
JsonResponseFactory::createEmpty()Produz resposta sem corpo (ex.: 204)
Router::PARAMETERS_ATTRIBUTERecupera parâmetros de caminho da requisição

Howtos relacionados

Publicado sob a licença MIT.