feat: initial API skeleton — routes, controllers, migrations, deploy docs
Implements the JSON API for Le Donut Infolab per INSTRUCTIONS.md.
Schema (migrations/001-002):
- 8 tables: categories, members, projects, project_members, roadmap_steps,
discussions, resources, activity_log
- 6 categories seeded with colors / dimensions (social, ecologique, transversal)
- FK contraintes + soft delete sur projects, CHECK sur progress_percent
Bare PHP framework (src/):
- Front controller public/index.php + router :params + auth flag par route
- PSR-4 autoload sans Composer (bootstrap.php)
- Database PDO singleton + helpers fetchOne/All/insert/update/delete
- Validator fluent -> 422 {error: validation_failed, fields: {...}}
- Bearer token admin (hash_equals) sur POST/PATCH/DELETE
- Slugger avec unicité (suffixe -2, -3...) via intl Transliterator
- ActivityLogger best-effort sur activity_log
- Erreurs uniformes : 400/401/404/405/409/422/500 + CORS configurable
10 contrôleurs (Health, Categories, Members, Projects, ProjectMembers,
Roadmap, Discussions, Resources, Activity, Stats). Transitions de statut
loggées (created_project, completed_step, joined, etc.).
Cible prod :
- Dockerfile (php:8.3-apache + pdo_mysql + intl + opcache)
- docker-compose.yml sur réseau donut-net, bind 127.0.0.1:8003
- caddy.conf pour infolab.ledonut-marseille.org
- migrations/apply.sh idempotent (table schema_migrations)
- DEPLOY.md avec commandes SSH exactes
Tests :
- tests/smoke.sh exerce 10 endpoints clés, body envoyé via stdin
(--data-binary @-) pour gérer l UTF-8 sous Git Bash Windows.
Vérifié localement sous Laragon Apache + MySQL 8.4 : 10/10 OK + cas
négatifs (401, 404, 405, 409, 422) conformes au contrat.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Infolab;
|
||||
|
||||
/**
|
||||
* Validation fluente des payloads JSON. Accumule les erreurs par champ, puis
|
||||
* `check()` lance une ApiException 422 conforme au contrat documenté :
|
||||
*
|
||||
* { "error": "validation_failed", "fields": { "title": "required", ... } }
|
||||
*
|
||||
* Toutes les règles autres que `required()` sont silencieuses si la clé
|
||||
* n'existe pas — ainsi les PATCH partiels passent sans déclarer les champs absents.
|
||||
*/
|
||||
final class Validator
|
||||
{
|
||||
/** @var array<string,mixed> */
|
||||
private array $data;
|
||||
|
||||
/** @var array<string,string> code d'erreur par champ */
|
||||
private array $errors = [];
|
||||
|
||||
/** @param array<string,mixed> $data */
|
||||
public function __construct(array $data)
|
||||
{
|
||||
$this->data = $data;
|
||||
}
|
||||
|
||||
public function has(string $key): bool
|
||||
{
|
||||
return array_key_exists($key, $this->data);
|
||||
}
|
||||
|
||||
public function value(string $key, mixed $default = null): mixed
|
||||
{
|
||||
return $this->data[$key] ?? $default;
|
||||
}
|
||||
|
||||
/** Définit manuellement une erreur sur un champ (ex: "not_found" après lookup BDD). */
|
||||
public function setError(string $key, string $code): self
|
||||
{
|
||||
$this->errors[$key] = $code;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function required(string $key): self
|
||||
{
|
||||
if (!$this->has($key) || $this->data[$key] === null || $this->data[$key] === '') {
|
||||
$this->errors[$key] ??= 'required';
|
||||
}
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function string(string $key, ?int $max = null, ?int $min = null): self
|
||||
{
|
||||
if (!$this->has($key) || $this->data[$key] === null) {
|
||||
return $this;
|
||||
}
|
||||
$v = $this->data[$key];
|
||||
if (!is_string($v)) {
|
||||
$this->errors[$key] ??= 'must_be_string';
|
||||
return $this;
|
||||
}
|
||||
$len = mb_strlen($v);
|
||||
if ($min !== null && $len < $min) $this->errors[$key] ??= 'too_short';
|
||||
if ($max !== null && $len > $max) $this->errors[$key] ??= 'too_long';
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function integer(string $key, ?int $min = null, ?int $max = null): self
|
||||
{
|
||||
if (!$this->has($key) || $this->data[$key] === null) {
|
||||
return $this;
|
||||
}
|
||||
$v = $this->data[$key];
|
||||
if (is_int($v)) {
|
||||
$i = $v;
|
||||
} elseif (is_string($v) && preg_match('/^-?\d+$/', $v)) {
|
||||
$i = (int) $v;
|
||||
} else {
|
||||
$this->errors[$key] ??= 'must_be_integer';
|
||||
return $this;
|
||||
}
|
||||
if ($min !== null && $i < $min) $this->errors[$key] ??= 'too_small';
|
||||
if ($max !== null && $i > $max) $this->errors[$key] ??= 'too_large';
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function boolean(string $key): self
|
||||
{
|
||||
if (!$this->has($key) || $this->data[$key] === null) {
|
||||
return $this;
|
||||
}
|
||||
$v = $this->data[$key];
|
||||
if (!is_bool($v) && !in_array($v, [0, 1, '0', '1', 'true', 'false'], true)) {
|
||||
$this->errors[$key] ??= 'must_be_boolean';
|
||||
}
|
||||
return $this;
|
||||
}
|
||||
|
||||
/** @param array<int,mixed> $allowed */
|
||||
public function in(string $key, array $allowed): self
|
||||
{
|
||||
if (!$this->has($key) || $this->data[$key] === null) {
|
||||
return $this;
|
||||
}
|
||||
if (!in_array($this->data[$key], $allowed, true)) {
|
||||
$this->errors[$key] ??= 'invalid_value';
|
||||
}
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function url(string $key, int $max = 1024): self
|
||||
{
|
||||
if (!$this->has($key) || $this->data[$key] === null || $this->data[$key] === '') {
|
||||
return $this;
|
||||
}
|
||||
$v = $this->data[$key];
|
||||
if (!is_string($v) || filter_var($v, FILTER_VALIDATE_URL) === false || mb_strlen($v) > $max) {
|
||||
$this->errors[$key] ??= 'invalid_url';
|
||||
}
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function email(string $key): self
|
||||
{
|
||||
if (!$this->has($key) || $this->data[$key] === null || $this->data[$key] === '') {
|
||||
return $this;
|
||||
}
|
||||
if (!is_string($this->data[$key]) || filter_var($this->data[$key], FILTER_VALIDATE_EMAIL) === false) {
|
||||
$this->errors[$key] ??= 'invalid_email';
|
||||
}
|
||||
return $this;
|
||||
}
|
||||
|
||||
/** Format ISO date YYYY-MM-DD strict. */
|
||||
public function date(string $key): self
|
||||
{
|
||||
if (!$this->has($key) || $this->data[$key] === null || $this->data[$key] === '') {
|
||||
return $this;
|
||||
}
|
||||
$v = $this->data[$key];
|
||||
if (!is_string($v)) {
|
||||
$this->errors[$key] ??= 'invalid_date';
|
||||
return $this;
|
||||
}
|
||||
$d = \DateTimeImmutable::createFromFormat('Y-m-d', $v);
|
||||
if ($d === false || $d->format('Y-m-d') !== $v) {
|
||||
$this->errors[$key] ??= 'invalid_date';
|
||||
}
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function colorHex(string $key): self
|
||||
{
|
||||
if (!$this->has($key) || $this->data[$key] === null || $this->data[$key] === '') {
|
||||
return $this;
|
||||
}
|
||||
if (!is_string($this->data[$key]) || !preg_match('/^#[0-9A-Fa-f]{6}$/', $this->data[$key])) {
|
||||
$this->errors[$key] ??= 'invalid_color_hex';
|
||||
}
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function fails(): bool
|
||||
{
|
||||
return $this->errors !== [];
|
||||
}
|
||||
|
||||
/** @return array<string,string> */
|
||||
public function errors(): array
|
||||
{
|
||||
return $this->errors;
|
||||
}
|
||||
|
||||
/** Termine la validation : lance ApiException 422 si au moins une erreur. */
|
||||
public function check(): void
|
||||
{
|
||||
if ($this->errors !== []) {
|
||||
throw new \ApiException(422, [
|
||||
'error' => 'validation_failed',
|
||||
'fields' => $this->errors,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user