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,126 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
// Front controller unique. Toutes les requêtes (sauf fichiers statiques
|
||||
// existants côté Apache) tombent ici via la réécriture .htaccess.
|
||||
|
||||
require __DIR__ . '/../src/bootstrap.php';
|
||||
|
||||
use Infolab\Router;
|
||||
use Infolab\Request;
|
||||
use Infolab\Response;
|
||||
use Infolab\Auth;
|
||||
|
||||
use Infolab\Controllers\HealthController;
|
||||
use Infolab\Controllers\CategoriesController;
|
||||
use Infolab\Controllers\MembersController;
|
||||
use Infolab\Controllers\ProjectsController;
|
||||
use Infolab\Controllers\ProjectMembersController;
|
||||
use Infolab\Controllers\RoadmapController;
|
||||
use Infolab\Controllers\DiscussionsController;
|
||||
use Infolab\Controllers\ResourcesController;
|
||||
use Infolab\Controllers\ActivityController;
|
||||
use Infolab\Controllers\StatsController;
|
||||
|
||||
// CORS systématique, y compris sur la preflight OPTIONS.
|
||||
Response::sendCorsHeaders();
|
||||
|
||||
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'OPTIONS') {
|
||||
http_response_code(204);
|
||||
return;
|
||||
}
|
||||
|
||||
// On instancie Request DANS le try : le parsing JSON peut lever ApiException 400
|
||||
// si le body est invalide, et on veut une réponse propre plutôt qu'une fatal error.
|
||||
$router = new Router();
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Routes publiques (GET, pas de token).
|
||||
// ----------------------------------------------------------------------------
|
||||
$router->get('/api/health', [HealthController::class, 'index']);
|
||||
|
||||
$router->get('/api/categories', [CategoriesController::class, 'index']);
|
||||
$router->get('/api/categories/:slug', [CategoriesController::class, 'show']);
|
||||
|
||||
$router->get('/api/members', [MembersController::class, 'index']);
|
||||
$router->get('/api/members/:slug', [MembersController::class, 'show']);
|
||||
|
||||
$router->get('/api/projects', [ProjectsController::class, 'index']);
|
||||
$router->get('/api/projects/:slug', [ProjectsController::class, 'show']);
|
||||
$router->get('/api/projects/:slug/roadmap', [RoadmapController::class, 'index']);
|
||||
$router->get('/api/projects/:slug/members', [ProjectMembersController::class, 'index']);
|
||||
$router->get('/api/projects/:slug/discussions', [DiscussionsController::class, 'index']);
|
||||
$router->get('/api/projects/:slug/resources', [ResourcesController::class, 'index']);
|
||||
|
||||
$router->get('/api/activity', [ActivityController::class, 'index']);
|
||||
$router->get('/api/stats', [StatsController::class, 'index']);
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Écritures (POST/PATCH/DELETE) — protégées automatiquement par Bearer token
|
||||
// (le Router marque ces méthodes comme `auth: true`).
|
||||
// ----------------------------------------------------------------------------
|
||||
$router->post ('/api/categories', [CategoriesController::class, 'store']);
|
||||
$router->patch ('/api/categories/:slug', [CategoriesController::class, 'update']);
|
||||
$router->delete('/api/categories/:slug', [CategoriesController::class, 'destroy']);
|
||||
|
||||
$router->post ('/api/members', [MembersController::class, 'store']);
|
||||
$router->patch ('/api/members/:slug', [MembersController::class, 'update']);
|
||||
$router->delete('/api/members/:slug', [MembersController::class, 'destroy']);
|
||||
|
||||
$router->post ('/api/projects', [ProjectsController::class, 'store']);
|
||||
$router->patch ('/api/projects/:slug', [ProjectsController::class, 'update']);
|
||||
$router->delete('/api/projects/:slug', [ProjectsController::class, 'destroy']);
|
||||
|
||||
$router->post ('/api/projects/:slug/members', [ProjectMembersController::class, 'store']);
|
||||
$router->delete('/api/projects/:slug/members/:member_slug', [ProjectMembersController::class, 'destroy']);
|
||||
|
||||
$router->post ('/api/projects/:slug/roadmap', [RoadmapController::class, 'store']);
|
||||
$router->patch ('/api/projects/:slug/roadmap/:id', [RoadmapController::class, 'update']);
|
||||
$router->delete('/api/projects/:slug/roadmap/:id', [RoadmapController::class, 'destroy']);
|
||||
|
||||
$router->post ('/api/projects/:slug/discussions', [DiscussionsController::class, 'store']);
|
||||
$router->patch ('/api/projects/:slug/discussions/:id', [DiscussionsController::class, 'update']);
|
||||
$router->delete('/api/projects/:slug/discussions/:id', [DiscussionsController::class, 'destroy']);
|
||||
|
||||
$router->post ('/api/projects/:slug/resources', [ResourcesController::class, 'store']);
|
||||
$router->patch ('/api/projects/:slug/resources/:id', [ResourcesController::class, 'update']);
|
||||
$router->delete('/api/projects/:slug/resources/:id', [ResourcesController::class, 'destroy']);
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Dispatch + handler global d'exceptions.
|
||||
// ----------------------------------------------------------------------------
|
||||
try {
|
||||
$request = new Request();
|
||||
[$route, $params] = $router->dispatch($request->method(), $request->path());
|
||||
|
||||
if ($route['auth']) {
|
||||
Auth::requireAdmin($request);
|
||||
}
|
||||
|
||||
[$class, $action] = $route['handler'];
|
||||
$controller = new $class();
|
||||
$controller->$action($request, $params);
|
||||
} catch (ApiException $e) {
|
||||
Response::emit($e->statusCode, $e->payload);
|
||||
} catch (\Throwable $e) {
|
||||
// Toujours loggé vers stderr (visible dans Dozzle côté serveur).
|
||||
error_log(sprintf(
|
||||
'[unhandled] %s in %s:%d — %s',
|
||||
$e::class,
|
||||
$e->getFile(),
|
||||
$e->getLine(),
|
||||
$e->getMessage()
|
||||
));
|
||||
$debug = (getenv('APP_DEBUG') ?: 'false') === 'true';
|
||||
if ($debug) {
|
||||
Response::emit(500, [
|
||||
'error' => 'internal_server_error',
|
||||
'message' => $e->getMessage(),
|
||||
'class' => $e::class,
|
||||
'file' => $e->getFile(),
|
||||
'line' => $e->getLine(),
|
||||
]);
|
||||
} else {
|
||||
Response::emit(500, ['error' => 'internal_server_error']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user