More MVC style

This commit is contained in:
julien
2026-03-09 12:01:20 +01:00
parent 4b2b7da9b4
commit 18eabb0560
8 changed files with 300 additions and 254 deletions

View File

@@ -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,
];
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Factories;
use App\Controllers\PostController;
use Slim\Views\Twig;
use App\Repositories\PostRepositoryInterface;
/**
* Fabrique d'instance de PostController pour faciliter l'intégration avec un container.
*/
final class PostControllerFactory
{
/**
* Crée une instance de PostController depuis le tableau de services renvoyé par ServiceFactory.
*
* @param array<string,mixed> $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);
}
}

View File

@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace App\Factories;
use Slim\Views\Twig;
use Twig\Loader\FilesystemLoader;
use Medoo\Medoo;
use Dotenv\Dotenv;
use App\Repositories\PostRepositoryMedoo;
use App\Repositories\PostRepositoryInterface;
/**
* Fabrique des services de l'application.
*
* Cette fabrique centralise la création des dépendances (Twig, DB, repositories...)
* pour garder public/index.php minimal et faciliter les tests/unit.
*/
final class ServiceFactory
{
/**
* Crée et retourne un tableau associatif de services.
*
* Clefs retournées :
* - 'view' => Twig
* - 'postRepository' => PostRepositoryInterface
*
* @param array<string,mixed>|null $overrides Permet d'injecter des remplacements pour les tests.
* @return array<string,mixed>
*/
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;
}
}

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Repositories;
/**
* Interface décrivant les opérations sur les posts.
*/
interface PostRepositoryInterface
{
/**
* Retourne tous les posts triés par id descendant.
*
* @return array<int, array{id:int, title:string, content:string}>
*/
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;
}

View File

@@ -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<int, array{id:int, title:string, content:string}>
* @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
{

View File

@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace App\Requests;
/**
* Classe simple responsable de la validation / sanitation des données
* provenant des formulaires de post.
*
* Usage : $valid = PostRequest::fromArray($data)->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 !== '';
}
}

View File

@@ -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']);