From 18eabb0560522ad61f462149e1f361d3edfdffff Mon Sep 17 00:00:00 2001 From: julien Date: Mon, 9 Mar 2026 12:01:20 +0100 Subject: [PATCH] More MVC style --- public/index.php | 173 +------------------ src/Controllers/PostController.php | 103 ++++------- src/Factories/PostControllerFactory.php | 31 ++++ src/Factories/ServiceFactory.php | 94 ++++++++++ src/Repositories/PostRepositoryInterface.php | 51 ++++++ src/Repositories/PostRepositoryMedoo.php | 16 +- src/Requests/PostRequest.php | 68 ++++++++ src/Routes/web.php | 18 +- 8 files changed, 300 insertions(+), 254 deletions(-) create mode 100644 src/Factories/PostControllerFactory.php create mode 100644 src/Factories/ServiceFactory.php create mode 100644 src/Repositories/PostRepositoryInterface.php create mode 100644 src/Requests/PostRequest.php diff --git a/public/index.php b/public/index.php index 237dc65..559eb52 100644 --- a/public/index.php +++ b/public/index.php @@ -5,185 +5,30 @@ declare(strict_types=1); require __DIR__ . '/../vendor/autoload.php'; use Slim\Factory\AppFactory; -use Slim\Views\Twig; use Slim\Views\TwigMiddleware; -use Twig\Loader\FilesystemLoader; -use Medoo\Medoo; -use Dotenv\Dotenv; use Throwable; -/** - * Charger les variables d'environnement si présentes (tolérant l'absence du fichier). - */ -$dotenv = Dotenv::createImmutable(__DIR__ . '/../'); -$dotenv->safeLoad(); +// Charger et créer les services centralisés +$services = App\Factories\ServiceFactory::createServices(); -/** - * Configuration centrale avec valeurs par défaut raisonnables. - * - * - 'env' : environnement d'exécution ('production' par défaut) - * - 'twig.cache' : résolu plus bas à partir de TWIG_CACHE ou par défaut selon l'environnement - * - 'db.file' et 'db.file_mode' : fichier SQLite et permissions - */ -$config = [ - 'env' => strtolower((string) ($_ENV['APP_ENV'] ?? $_SERVER['APP_ENV'] ?? 'production')), - 'twig' => [ - 'cache' => null, - ], - 'db' => [ - 'file' => __DIR__ . '/../database/app.sqlite', - 'file_mode' => 0664, - ], -]; +/** @var \Slim\Views\Twig $twig */ +$twig = $services['view']; -/** - * Résolution de la valeur twig.cache depuis la variable d'environnement TWIG_CACHE. - * Si TWIG_CACHE est vide : false en dev, chemin par défaut en production. - */ -$envTwigCache = $_ENV['TWIG_CACHE'] ?? $_SERVER['TWIG_CACHE'] ?? null; -if ($envTwigCache !== null && $envTwigCache !== '') { - $config['twig']['cache'] = (string) $envTwigCache; -} else { - $devEnvs = ['development', 'dev']; - $config['twig']['cache'] = in_array($config['env'], $devEnvs, true) - ? false - : __DIR__ . '/../var/cache/twig'; -} - -$isDebug = in_array($config['env'], ['development', 'dev'], true); - -/** - * Affichage et rapport d'erreurs selon l'environnement. - */ -if ($isDebug) { - ini_set('display_errors', '1'); - ini_set('display_startup_errors', '1'); - error_reporting(E_ALL); -} else { - ini_set('display_errors', '0'); - ini_set('display_startup_errors', '0'); - error_reporting(E_ALL & ~E_DEPRECATED & ~E_STRICT); -} - -// ------------------------- -// Base de données (SQLite) -// ------------------------- - -$dbFile = $config['db']['file']; - -/** - * Créer le fichier de base de données si nécessaire et appliquer les permissions. - */ -if (!file_exists($dbFile)) { - $dbDir = dirname($dbFile); - if (!is_dir($dbDir)) { - mkdir($dbDir, 0755, true); - } - touch($dbFile); - @chmod($dbFile, $config['db']['file_mode']); -} - -/** - * Options Medoo pour SQLite. - */ -$medooOptions = [ - 'database_type' => 'sqlite', - 'database_name' => $dbFile, - 'charset' => 'utf8', -]; - -/** @var Medoo $database */ -$database = new Medoo($medooOptions); - -/** - * Schéma minimal : création de la table 'post' si elle n'existe pas. - */ -$database->query( - <<<'SQL' -CREATE TABLE IF NOT EXISTS post ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - title TEXT NOT NULL, - content TEXT NOT NULL -); -SQL -); - -// ------------------------- -// Services (container simple) -// ------------------------- - -/** - * Container simple (table associative de services). - * - * @var array $container - */ -$container = []; - -/** - * Construire les services et les retourner dans le container. - * - * Cette fonction reste locale pour préserver l'encapsulation. - * - * @param array $config - * @param Medoo $database - * @return array - */ -$container = (static function (array $config, Medoo $database): array { - $services = []; - - // Résoudre le cache Twig et créer le dossier si nécessaire. - $twigCache = $config['twig']['cache']; - if ($twigCache && $twigCache !== false && !is_dir((string) $twigCache)) { - mkdir((string) $twigCache, 0755, true); - } - - // Vue Twig - $loader = new FilesystemLoader(__DIR__ . '/../views'); - $services['view'] = new Twig($loader, ['cache' => $config['twig']['cache']]); - - // Repository Post (implémentation Medoo) - $services['postRepository'] = new App\Repositories\PostRepositoryMedoo($database); - - return $services; -})($config, $database); - -// ------------------------- // Slim app -// ------------------------- - $app = AppFactory::create(); -/** - * Middleware d'erreurs. - * - * Les trois flags correspondent à : displayErrorDetails, logErrors, logErrorDetails. - */ -$errorMiddleware = $app->addErrorMiddleware($isDebug, $isDebug, $isDebug); - -if (!$isDebug) { - $errorHandler = $errorMiddleware->getDefaultErrorHandler(); - // Renderer HTML générique en production pour masquer les détails d'exception. - $errorHandler->registerErrorRenderer('text/html', static function (Throwable $exception, bool $displayErrorDetails): string { - return 'Erreur

Erreur serveur

Une erreur est survenue. Veuillez réessayer plus tard.

'; - }); -} - // Middlewares essentiels $app->addBodyParsingMiddleware(); -$app->add(TwigMiddleware::create($app, $container['view'])); +$app->add(TwigMiddleware::create($app, $twig)); -/** - * Charger les routes : le fichier web.php reçoit l'application et le container. - * On utilise require pour que toute exception remonte à Slim / au handler d'erreurs. - */ +// Charger les routes $routesPath = __DIR__ . '/../src/Routes/web.php'; if (file_exists($routesPath)) { /** @var callable $routes */ $routes = require $routesPath; - $routes($app, $container); -} else { - // En cas d'absence du fichier de routes, on peut laisser l'application démarrer sans routes. - // (Conserver le comportement antérieur — aucune exception levée ici) + $routes($app); } +$errorMiddleware = $app->addErrorMiddleware(true, true, true); + $app->run(); diff --git a/src/Controllers/PostController.php b/src/Controllers/PostController.php index b0dfe61..97d860e 100644 --- a/src/Controllers/PostController.php +++ b/src/Controllers/PostController.php @@ -7,7 +7,8 @@ namespace App\Controllers; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; use Slim\Views\Twig; -use App\Repositories\PostRepositoryMedoo as PostRepository; +use App\Repositories\PostRepositoryInterface as PostRepository; +use App\Requests\PostRequest; /** * Contrôleur pour les posts. @@ -23,26 +24,12 @@ class PostController $this->repo = $repo; } - /** - * Affiche la page d'accueil avec la liste des posts. - * - * @param Request $req - * @param Response $res - * @return Response - */ public function index(Request $req, Response $res): Response { $posts = $this->repo->allDesc(); return $this->view->render($res, 'pages/home.twig', ['posts' => $posts]); } - /** - * Affiche la page d'administration. - * - * @param Request $req - * @param Response $res - * @return Response - */ public function admin(Request $req, Response $res): Response { $posts = $this->repo->allDesc(); @@ -54,85 +41,63 @@ class PostController * * @param Request $req * @param Response $res - * @param array $args + * @param array $args * @return Response */ public function form(Request $req, Response $res, array $args): Response { $id = (int)($args['id'] ?? 0); $post = $id ? $this->repo->find($id) : null; + + // Si id fourni mais post introuvable -> 404 + if ($id > 0 && $post === null) { + return $res->withStatus(404)->write('Article non trouvé'); + } + $action = $id ? "/admin/edit/{$id}" : "/admin/create"; return $this->view->render($res, 'pages/post_form.twig', ['post' => $post, 'action' => $action]); } - /** - * Crée un nouvel article. - * - * @param Request $req - * @param Response $res - * @return Response - */ public function create(Request $req, Response $res): Response { - $data = $this->sanitize($req->getParsedBody()); + $postRequest = PostRequest::fromArray($req->getParsedBody()); + if (! $postRequest->isValid()) { + // Simple gestion d'erreur : rediriger vers admin (on peut ajouter flash messages plus tard) + return $res->withHeader('Location', '/admin')->withStatus(302); + } + + $data = $postRequest->validated(); $this->repo->create($data); return $res->withHeader('Location', '/admin')->withStatus(302); } - /** - * Met à jour un article existant. - * - * @param Request $req - * @param Response $res - * @param array $args - * @return Response - */ public function update(Request $req, Response $res, array $args): Response { $id = (int)$args['id']; - $data = $this->sanitize($req->getParsedBody()); + $existing = $this->repo->find($id); + if ($existing === null) { + return $res->withStatus(404)->write('Article non trouvé'); + } + + $postRequest = PostRequest::fromArray($req->getParsedBody()); + if (! $postRequest->isValid()) { + return $res->withHeader('Location', '/admin')->withStatus(302); + } + + $data = $postRequest->validated(); $this->repo->update($id, $data); return $res->withHeader('Location', '/admin')->withStatus(302); } - /** - * Supprime un article. - * - * @param Request $req - * @param Response $res - * @param array $args - * @return Response - */ public function delete(Request $req, Response $res, array $args): Response { - $this->repo->delete((int)$args['id']); + $id = (int)$args['id']; + $existing = $this->repo->find($id); + if ($existing === null) { + return $res->withStatus(404)->write('Article non trouvé'); + } + + $this->repo->delete($id); return $res->withHeader('Location', '/admin')->withStatus(302); } - - /** - * Sanitize minimal des données entrantes : - * - cast string - * - trim - * - limiter la longueur raisonnablement (pour éviter insertion énorme) - * - * @param mixed $input - * @return array{title:string,content:string} - */ - private function sanitize($input): array - { - $title = isset($input['title']) ? (string)$input['title'] : ''; - $content = isset($input['content']) ? (string)$input['content'] : ''; - - $title = trim($title); - $content = trim($content); - - // Limites raisonnables (adaptables) - $title = mb_substr($title, 0, 255); - $content = mb_substr($content, 0, 65535); - - return [ - 'title' => $title, - 'content' => $content, - ]; - } } diff --git a/src/Factories/PostControllerFactory.php b/src/Factories/PostControllerFactory.php new file mode 100644 index 0000000..d6e9b2b --- /dev/null +++ b/src/Factories/PostControllerFactory.php @@ -0,0 +1,31 @@ + $services + * @return PostController + */ + public static function create(array $services): PostController + { + /** @var Twig $view */ + $view = $services['view']; + /** @var PostRepositoryInterface $repo */ + $repo = $services['postRepository']; + + return new PostController($view, $repo); + } +} diff --git a/src/Factories/ServiceFactory.php b/src/Factories/ServiceFactory.php new file mode 100644 index 0000000..8e1f25c --- /dev/null +++ b/src/Factories/ServiceFactory.php @@ -0,0 +1,94 @@ + Twig + * - 'postRepository' => PostRepositoryInterface + * + * @param array|null $overrides Permet d'injecter des remplacements pour les tests. + * @return array + */ + public static function createServices(?array $overrides = null): array + { + // Charger .env si présent (tolérant l'absence) + $dotenv = Dotenv::createImmutable(__DIR__ . '/../../'); + $dotenv->safeLoad(); + + // Config basique (idem que précédemment mais centralisé) + $env = strtolower((string) ($_ENV['APP_ENV'] ?? $_SERVER['APP_ENV'] ?? 'production')); + $devEnvs = ['development', 'dev']; + $twigCacheEnv = $_ENV['TWIG_CACHE'] ?? $_SERVER['TWIG_CACHE'] ?? null; + if ($twigCacheEnv !== null && $twigCacheEnv !== '') { + $twigCache = (string)$twigCacheEnv; + } else { + $twigCache = in_array($env, $devEnvs, true) ? false : __DIR__ . '/../../var/cache/twig'; + } + + $dbFile = $_ENV['DB_FILE'] ?? __DIR__ . '/../../database/app.sqlite'; + $dbFileMode = 0664; + + // Créer dossier cache si nécessaire + if ($twigCache && $twigCache !== false && !is_dir((string) $twigCache)) { + @mkdir((string) $twigCache, 0755, true); + } + + // Twig + $loader = new FilesystemLoader(__DIR__ . '/../../views'); + $twig = new Twig($loader, ['cache' => $twigCache]); + + // Medoo (SQLite) + $dbDir = dirname($dbFile); + if (!is_dir($dbDir)) { + @mkdir($dbDir, 0755, true); + } + if (!file_exists($dbFile)) { + @touch($dbFile); + @chmod($dbFile, $dbFileMode); + } + + $medooOptions = [ + 'database_type' => 'sqlite', + 'database_name' => $dbFile, + 'charset' => 'utf8', + ]; + $database = new Medoo($medooOptions); + + // Repository + $postRepository = new PostRepositoryMedoo($database); + + $services = [ + 'view' => $twig, + 'database' => $database, + 'postRepository' => $postRepository, + ]; + + // Appliquer overrides si fournis (utile pour tests) + if (is_array($overrides)) { + $services = array_merge($services, $overrides); + } + + return $services; + } +} + \ No newline at end of file diff --git a/src/Repositories/PostRepositoryInterface.php b/src/Repositories/PostRepositoryInterface.php new file mode 100644 index 0000000..9350464 --- /dev/null +++ b/src/Repositories/PostRepositoryInterface.php @@ -0,0 +1,51 @@ + + */ + public function allDesc(): array; + + /** + * Trouve un post par son id. + * + * @param int $id + * @return array{id:int, title:string, content:string}|null + */ + public function find(int $id): ?array; + + /** + * Crée un post. + * + * @param array{title:string,content:string} $data + * @return int id inséré + */ + public function create(array $data): int; + + /** + * Met à jour un post. + * + * @param int $id + * @param array{title:string,content:string} $data + * @return void + */ + public function update(int $id, array $data): void; + + /** + * Supprime un post. + * + * @param int $id + * @return void + */ + public function delete(int $id): void; +} diff --git a/src/Repositories/PostRepositoryMedoo.php b/src/Repositories/PostRepositoryMedoo.php index b9412c6..4592674 100644 --- a/src/Repositories/PostRepositoryMedoo.php +++ b/src/Repositories/PostRepositoryMedoo.php @@ -10,7 +10,7 @@ use Medoo\Medoo; * Repository pour "post" basé sur Medoo. * Retourne et consomme des tableaux associatifs. */ -class PostRepositoryMedoo +class PostRepositoryMedoo implements PostRepositoryInterface { private Medoo $db; @@ -20,7 +20,7 @@ class PostRepositoryMedoo } /** - * @return array + * @inheritDoc */ public function allDesc(): array { @@ -35,7 +35,7 @@ class PostRepositoryMedoo } /** - * @return array{id:int, title:string, content:string}|null + * @inheritDoc */ public function find(int $id): ?array { @@ -53,8 +53,7 @@ class PostRepositoryMedoo } /** - * @param array{title:string,content:string} $data - * @return int Inserted id + * @inheritDoc */ public function create(array $data): int { @@ -66,9 +65,7 @@ class PostRepositoryMedoo } /** - * @param int $id - * @param array{title:string,content:string} $data - * @return void + * @inheritDoc */ public function update(int $id, array $data): void { @@ -79,8 +76,7 @@ class PostRepositoryMedoo } /** - * @param int $id - * @return void + * @inheritDoc */ public function delete(int $id): void { diff --git a/src/Requests/PostRequest.php b/src/Requests/PostRequest.php new file mode 100644 index 0000000..624ecdd --- /dev/null +++ b/src/Requests/PostRequest.php @@ -0,0 +1,68 @@ +validated(); + */ +final class PostRequest +{ + private string $title; + private string $content; + + private function __construct(string $title, string $content) + { + $this->title = $title; + $this->content = $content; + } + + /** + * Crée une instance à partir d'un tableau (ex: $request->getParsedBody()). + * + * @param mixed $input + * @return self + */ + public static function fromArray($input): self + { + $title = isset($input['title']) ? (string)$input['title'] : ''; + $content = isset($input['content']) ? (string)$input['content'] : ''; + + $title = trim($title); + $content = trim($content); + + // Limites raisonnables (configurable si besoin) + $title = mb_substr($title, 0, 255); + $content = mb_substr($content, 0, 65535); + + return new self($title, $content); + } + + /** + * Retourne les données validées/sanitizées prêtes à persister. + * + * @return array{title:string,content:string} + */ + public function validated(): array + { + // Ici on pourrait ajouter des validations plus strictes (ex: required, longueur minimale) + return [ + 'title' => $this->title, + 'content' => $this->content, + ]; + } + + /** + * Indique si le formulaire est suffisamment rempli. + * + * @return bool + */ + public function isValid(): bool + { + return $this->title !== '' && $this->content !== ''; + } +} diff --git a/src/Routes/web.php b/src/Routes/web.php index 8882e26..fcbab76 100644 --- a/src/Routes/web.php +++ b/src/Routes/web.php @@ -3,23 +3,19 @@ declare(strict_types=1); use Slim\App; -use Slim\Views\Twig; -use App\Repositories\PostRepositoryMedoo; -use App\Controllers\PostController; +use App\Factories\ServiceFactory; +use App\Factories\PostControllerFactory; /** * @param App $app - * @param array{view:Twig, postRepository:PostRepositoryMedoo} $container * @return void */ -return function (App $app, array $container): void { - /** @var Twig $view */ - $view = $container['view']; - /** @var PostRepositoryMedoo $repo */ - $repo = $container['postRepository']; +return function (App $app): void { + // Créer services (centralisé) + $services = \App\Factories\ServiceFactory::createServices(); - // Instancier le controller une seule fois tout en gardant la modularité - $controller = new PostController($view, $repo); + // Créer controller via sa factory + $controller = PostControllerFactory::create($services); $app->get('/', [$controller, 'index']); $app->get('/admin', [$controller, 'admin']);