Files
project-manager/src/Support/Validator.php
T
aneurinandClaude Sonnet 5 a85bb182c7 De-duplicate controller boilerplate: owned-project lookup, reorder validation
requireOwnedProjectId() was copy-pasted identically in CardController
and CardStatusController; ProjectController's own
requireOwnedProject() was the same lookup, just returning the full
row instead of the id. New abstract ProjectScopedController (extends
Controller) holds one copy of both, and all three controllers now
extend it instead of Controller directly, forwarding their
ProjectRepository to its constructor.

Separately, CardController::reorder() (card_ids) and
CardStatusController::reorder() (status_ids) each had the same inline
'must be an array of ids, no duplicates' check. Both now call a new
Validator::intIdArray(), which does the same shape check once.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 21:45:17 +01:00

174 lines
4.2 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Support;
use App\Exception\ValidationException;
/**
* Small helper for validating a decoded JSON request body. Accumulates
* field => messages and throws a ValidationException when asked.
*/
final class Validator
{
/** @var array<string, string[]> */
private array $errors = [];
/**
* @param array<string, mixed> $data
*/
public function __construct(private readonly array $data)
{
}
public function has(string $field): bool
{
return array_key_exists($field, $this->data);
}
/**
* A required, non-blank, length-bounded string.
*/
public function requiredString(string $field, int $max, int $min = 1): string
{
if (!$this->has($field) || $this->data[$field] === null) {
$this->errors[$field][] = ucfirst($field) . ' is required.';
return '';
}
return $this->stringValue($field, $max, $min) ?? '';
}
/**
* An optional string; returns null when the field is absent.
*/
public function optionalString(string $field, int $max, int $min = 0): ?string
{
if (!$this->has($field)) {
return null;
}
return $this->stringValue($field, $max, $min);
}
public function optionalBool(string $field): ?bool
{
if (!$this->has($field)) {
return null;
}
$value = $this->data[$field];
if (is_bool($value)) {
return $value;
}
if ($value === 0 || $value === 1) {
return $value === 1;
}
$this->errors[$field][] = ucfirst($field) . ' must be true or false.';
return null;
}
public function optionalInt(string $field, int $min): ?int
{
if (!$this->has($field)) {
return null;
}
$value = $this->data[$field];
if (!is_int($value) || $value < $min) {
$this->errors[$field][] = ucfirst($field) . " must be an integer of at least {$min}.";
return null;
}
return $value;
}
/**
* A required array of unique integer ids -- an ordered list of card or
* status ids to reorder, say. Shape only; whether the ids actually exist
* or are owned by the caller is the caller's job.
*
* @return int[]
*/
public function intIdArray(string $field): array
{
if (!$this->has($field)) {
$this->errors[$field][] = ucfirst($field) . ' is required.';
return [];
}
$value = $this->data[$field];
if (!is_array($value) || array_filter($value, static fn ($id): bool => !is_int($id)) !== []) {
$this->errors[$field][] = ucfirst($field) . ' must be an array of IDs.';
return [];
}
/** @var int[] $value */
if (count($value) !== count(array_unique($value))) {
$this->errors[$field][] = ucfirst($field) . ' must not contain duplicates.';
return [];
}
return $value;
}
public function add(string $field, string $message): void
{
$this->errors[$field][] = $message;
}
public function failed(): bool
{
return $this->errors !== [];
}
/**
* @throws ValidationException when any errors were recorded.
*/
public function assert(): void
{
if ($this->errors !== []) {
throw new ValidationException($this->errors);
}
}
private function stringValue(string $field, int $max, int $min): ?string
{
$value = $this->data[$field];
if (!is_string($value)) {
$this->errors[$field][] = ucfirst($field) . ' must be a string.';
return null;
}
$value = trim($value);
$length = mb_strlen($value);
if ($length < $min) {
$this->errors[$field][] = $min === 1
? ucfirst($field) . ' cannot be empty.'
: ucfirst($field) . " must be at least {$min} characters.";
return null;
}
if ($length > $max) {
$this->errors[$field][] = ucfirst($field) . " must be at most {$max} characters.";
return null;
}
return $value;
}
}