Créer des routes personnalisées dans WordPress

Il y a un an commençaient mes recherches pour créer mon "mini-framework" de développement de templates wordpress en essayant de rapprocher l'univers de développement des templates wordpress de ce que propose symfony. Depuis le premier article sur le sujet, de l'eau a coulé sous les ponts et le template de base a beaucoup évolué. Avant de publier la dernière version de cette base, je voulais partager la mise en place d'un routeur spécifique permettant de simplifier grandement la création d'URL sans avoir à utiliser les classiques templates de Wordpress ni à devoir créer des pages ou des articles par le biais de l'éditeur de Wordpress.

Mais d'abord, pourquoi Wordpress ?

Je vous entend d'ici : mais si tu veux utiliser des outils made in symfony, pourquoi utiliser wordpress et pas symfony directement ? Oui bon c'est vrai, mais ... Il y a pour moi 2 raisons essentielles à cela :

Le premier avantage que je vois à l'utilisation de Wordpress est que le back-office est déjà fait et parfaitement opérationnel. De plus, en lui joignant quelques plugins, des custom posts et des champs personnalisés (grâce à ACF) on couvre la grande majorité des cas d'usages pour créer un site web.

Le second est que toutes les écoles de "communication" et apparentées forment les étudiants à l'utilisation de Wordpress. De fait, lors du développement d'un site web, vous avez la quasi certitude que les personnes qui devront gérer ce site sauront vite trouver leurs marques sans avoir besoin de les former à un back-office "maison". Et ça évite le loooooong développement d'un back office personnalisé.

Un routeur complémentaire

Pour créer des routes dans Wordpress, il y a deux solutions principales :

La première est tout simplement de créer des articles ou des pages. Un permalink est alors généré et grâce aux templates de page ou de single_post par exemple, ces url sont directement opérationnelles.

A mes débuts, je créais, une page vierge. Je lui affectais un template spécifique. Et zou. Ça fonctionne, mais cela crée dans le back office plein de pages vides que les utilisateurs n'ont pas le droit de modifier !

L'autre solution consiste à créer des routes via le fichier functions.php mais cette solution oblige à ajouter pas mal de lignes de code (même s'il est facile de simplifier cela à l'aide d'une classe spécifique). De plus, cette option passe par la création d'un fichier de template supplémentaire.

Ça évite les pages vides non modifiables, mais il reste quand même pas mal de code dans le fichier functions.php et des fichier de template juste pour des taches asynchrones par exemple.

Toutes ces solutions fonctionnent, mais sur des gros sites, c'est vite le grand bazar. Il fallait donc trouver une solution.

Vers un joli routeur

Je me suis creusé la tête un petit moment avant de mettre en place la solution de routing que je partage donc ici avec vous.

Je rappelle que l'objectif était le suivant :

  • Se passer des classiques fichiers de templates de wordpress et des pages inutiles,
  • Utiliser des annotations pour générer nos routes facilement.

Les dépendances

Pour faire ça bien, nous allons utiliser quelques outils bien pratiques proposés par Symfony et Doctrine :

  • symfony/routing
  • symfony/http-foundation
  • symfony/finder
  • doctrine/common
  • doctrine/annotations

Lorsque l'on appelle une url dans wordpress, en gros, ce dernier cherche le contenu (post, page...) qui correspond à l'url, regarde quel template particulier est à utiliser, et le charge pour afficher la page. Il nous faut donc laisser wordpress faire son travail, sauf si on a déclaré une route dans un de nos controlleurs.

Le routeur

Pour mettre en place un routage, symfony propose des outils basés notamment sur des Routes rassemblées en une Collection et un UrlMatcher.
On a également besoin de deux classes Resolver : le ControllerResolver et l'ArgumentResolver.

Les routes

Une route est donc un objet qui prend en paramètres une url (que l'on veut créer) et un tableau d'options dont la clé _controller désignera le controlleur à utiliser. On peut également lui adjoindre des paramètres :

$maNouvelleRoute = new Route('/profile/{user}', [
    '_controller' => 'App\Controller\FaqAskController::getQuestion',
    'user' => null // uniquement si vous voulez donner une valeur par défaut à user et le rendre optionnel
])

Toutes les routes que l'on veut créer sont rassemblées dans un objet de type RouteCollection :

$this->routes = new RouteCollection();
$this->routes->add("nom_de_ma_route", $maNouvelleRoute);

On a ainsi une collection de routes disponible dans notre application.

Le routing

Le principe de base est le suivant :

Notre routeur va recevoir un objet Request (Symfony\Component\HttpFoundation\Request) qui va représenter la requête http avec tous ses paramètres. L'UrlMatcher va analyser cette requête et regarder si l'url match avec une de nos routes. Si oui, le ControllerResolver va nous extraire le contrôleur dont nous avons besoin et l'ArgumenResolver les paramètres de notre requête. Puis le routeur va appeler la méthode voulue du controller lié à la route avec tous les paramètres nécessaires :

$this->request = Request::createFromGlobals();

$context = new RequestContext();
$context->fromRequest($this->request);
$this->matcher = new UrlMatcher($this->getRoutes(), $context); // Vous vous doutez bien sur que la méthode $this->getRoutes() nous renvoie notre collection de routes
$this->request->attributes->add($this->matcher->match($this->request->getPathInfo())); // On ajoute des attributs issus du matcher à notre requête. Ceux-ci seront utilisés par les resolvers

public function execute()
{
    $controllerResolver = new ControllerResolver();
    $controller         = $controllerResolver->getController($this->getRequest());
    $argumentsResolver  = new ArgumentResolver();
    $arguments          = $argumentsResolver->getArguments($this->getRequest(), $controller);
    call_user_func_array($controller, $arguments);
}

Et voilà pour l'essentiel. Il faut maintenant que wordpress puisse utiliser notre routeur. Donc à la racine de notre thème, on ajoute un fichier routes.php :

<?php
namespace App;

use App\Router\Router;
use Symfony\Component\Routing\Route;

$_ROUTER = new Router();

$_ROUTER->addRoute('user_profile', new Route('/profile/{user}', [
    '_controller' => 'App\Controller\UserController::getUserProfile'
]));

add_action( 'init', function () {
    global $_ROUTER;
    if($_ROUTER->match()) {
        $_ROUTER->execute();
        die;
    }
} );

Sans oublier évidemment d'inclure ce fichier 'routes.php' dans notre fichier 'functions.php'

require(__DIR__ . '/routes.php');

Et les routes ?

Tout ceci marche très bien et semble efficace mais si j'ai 200 routes dans mon site, ce fichier 'routes.php' va vite devenir très long et compliqué. On aimerait donc pouvoir se passer d'ajouter les routes dans ce fichier et supprimer ces lignes :

$_ROUTER->addRoute('user_profile', new Route('/profile/{user}', [
    '_controller' => 'App\Controller\UserController::getUserProfile' // "getUserProfile" étant bien-sûr une méthode du contrôleur "UserController"
]));

Des annotations

Vu que l'on est rigoureux et que l'on s'est préalablement imposé de travailler proprement en créant des controllers pour faire le lien entre les data et les vues, que tous nos controllers sont bien regroupés dans le dossier Controller de notre template, on peut mettre en place un système qui va analyser tous nos controllers et vérifier si certaines méthodes sont des routes. Pour cela, on va utiliser des annotations pour nos méthodes et un objet RoutesCollector dans notre routeur qui lui va se charger de générer notre collection de routes.

On va donc créer notre collection en utilisant des annotations fournies par le composant routing de symfony et les ajouter dans les méthodes de nos controllers :

use Symfony\Component\Routing\Annotation\Route;

/**
* @Route("/profile/{user}", name="user_profile")
*/
public function getUserProfile(User $user)
{
    ...
}

Le RoutesCollector

Cette classe prend en charge la récupération des routes dans nos contrôleurs et génère la RouteCollection dont notre routeur a besoin.

Pour cela, on va parcourir récursivement le projet avec le finder de symfony et regarder toutes les classes de type BlablaController grâce à ReflectionClass.

public function getRoutes($folderPath = null): RouteCollection
    {
        $finder = new Finder();
        
        foreach (self::EXCLUDED_FOLDERS as $folder) {
            $finder->exclude($folder);
        }
        $finder
            ->files()
            ->in($this->folder)
            ->name('*Controller.php')
            ->notName('AbstractController.php')
            ;
        
        foreach ($finder as $file) {
            $fileName = basename($file->getFilename(), '.php');
            preg_match('/(namespace )(.*?)(;)/', $file->getContents(), $matches);
            $namespace = $matches[2];
            $class     = $this->getReflectionClass($fileName, $namespace);
            foreach ($class->getMethods() as $method) {
                $this->addRouteFromMethod($method);
            }
        }

        if($this->hasErrors()) {
            $this->dispatchErrors();
        }

        return $this->routes;
    }
private function getReflectionClass($fileName, $folder = null): \ReflectionClass
    {
        $namespace     = $this->getNamespace($folder); // Cette méthode retour \Controller si null ou \Controller\NomDuDossier sinon
        $className     = $namespace . '\\' . basename($fileName, '.php');
        return new \ReflectionClass($className);
    }

On analyse alors toutes les méthodes de nos classes et grâce à l'outil AnnotationReader de Doctrine, on regarde si des annotations de type @Route sont présentes. Et si oui, on en fait une nouvelle route et on l'ajoute à notre collection.

use Symfony\Component\Routing\Annotation\Route as SiteRoute; 
// Utilisation d'un alias pour éviter les confusions entre Symfony\Component\Routing\Annotation\Route et Symfony\Component\Routing\Route

$this->parser = new DocParser();
$this->reader = new AnnotationReader($this->parser);

private function getRoute(\ReflectionMethod $method): ?SiteRoute
    {
       return $this->reader->getMethodAnnotation($method, self::ROUTE_CLASS);
    }

private function getMethodArguments(\ReflectionMethod $method): array
    {
        $arguments = [];
        foreach ($method->getParameters() as $parameter) {
            $arguments[$parameter->getName()] = ($parameter->isOptional()) ? $parameter->getDefaultValue() : null;
        }
        return $arguments;
    }

Et ensuite

private function addRouteFromMethod($method) {

    if ($this->hasValidRoute($method)) {

    if(!$this->matchResponseReturn($method)) {
                throw new \Exception(
                    sprintf(
                        'La méthode %s du contôleur %s ne renvoie pas une réponse valide', 
                        $method->getName(), 
                        $method->getDeclaringClass()->getName()
                    )
                );
            }

            $route     = $this->getRoute($method);
            $arguments = $this->getMethodArguments($method);
            $arguments['_controller'] = $class->getName() . '::' . $method->getName();
            $this->routes->add($route->getName(), new Route($route->getPath(), $arguments));
    }
}

Et voilà. Notre routeur fonctionne et simplifie grandement la donne dans notre développement.

Gestion des conflits d'Url

Lorsque l'on ajoute une route avec les annotations ou que l'on crée une nouvelle page ou un nouvel article dans Wordpress, il se peut que leurs URL soient les mêmes. Auquel cas, c'est le routeur qui prend le dessus, sauf si la route ajoutée par annotations ne prend de paramètres. Bref, c'est confus et risque de poser des problèmes.
Il fallait donc avertir le développeur et/ou l'administrateur de ces potentiels conflits. Pour cela, nous avions vu au début de cet article que la méthode "getRoutes" de notre RoutesCollector possédait ces quelques lignes :

if($this->hasErrors()) {
   $this->dispatchErrors();
}

Il fallait donc envoyer ces erreurs à Wordpress, dans l'admin et dans le front (mais uniquement pour les administrateurs connectés au back office)

/**
     * this adds error notice in the WP back office and front webSite in order to list routes conflicts
     */
    private function dispatchErrors(): void
    {
        $this->addWordpressAdminNotice();
        if(is_user_logged_in()) {
            $this->addWordpressFrontNotice();
        }
    }

    /**
     * Adds error notice in wordpress back office if routes conflicts
     */
    private function addWordpressAdminNotice()
    {
        add_action('admin_enqueue_scripts', function ($hook) {
            if ('post.php' !== $hook) {
                return;
            }
            wp_register_script( 'routes_validator_admin', get_bloginfo('template_directory') . '/Router/assets/js/adminNotice.js' );
            wp_enqueue_script( 'routes_validator_admin' );
            wp_localize_script( 'routes_validator_admin', 'my_routes_errors', ['error_message' => base64_encode(utf8_decode($this->createErrorMessage()))] );
        });
    }

    /**
     * Adds error notice in wordpress front website if routes conflicts
     */
    private function addWordpressFrontNotice()
    {
        wp_enqueue_style('notice_alert', get_bloginfo('template_directory') . '/Router/assets/css/notice.css');
        add_action('wp_enqueue_scripts', function ($hook) {
            wp_register_script( 'routes_validator_front', get_bloginfo('template_directory') . '/Router/assets/js/frontNotice.js' );
            wp_enqueue_script( 'routes_validator_front' );
            wp_localize_script( 'routes_validator_front', 'my_routes_errors', ['error_message' => base64_encode(utf8_decode($this->createErrorMessage()))] );
        });
    }

Pour l'admin, le fichier js crée une notice gutemberg

// Router/assets/js/adminNotice.js 

( function( wp ) {
        wp.data.dispatch('core/notices').createErrorNotice(
            atob(my_routes_errors.error_message),
            {
                isDismissible: true,
                __unstableHTML: true
            }
        );
    } )( window.wp );

Et pour le front, on ajoute simplement un élément html à la page

// Router/assets/js/frontNotice.js 

document.addEventListener('DOMContentLoaded', () => {

    const notice = document.createElement('div')
    notice.innerHTML = atob(my_routes_errors.error_message)
    notice.classList.add('notice-alert')
    document.querySelector('body').appendChild(notice)

})

Précisions complémentaires

Pour que tout marche parfaitement et que wordpress retrouve aussi ses petits, j'ai du changer quelque peu le retour des méthodes des controleurs afin qu'ils renvoient une vraie réponse, i.e. un objet de type Symfony\Component\HttpFoundation\Response. Pour mémoire, avant, nos fichiers de template (que l'on peut désormais supprimer \o/) mentionnaient

echo $userController->getUserProfile(int $user);

Et nos contrôleurs renvoyaient quelque chose du type

return $this->twig->render('user/profile.html.twig', [
            'user' => $user
        ]));

Pour que tout marche bien dans wordpress et que nos contrôleurs renvoient des réponses http valides, j'ai donc créé une nouvelle méthode "publish" dans l'AbstractController :

protected function publish(string $content, $status = Response::HTTP_OK)
    {
        $this->response->setContent($content);
        $this->response->setStatusCode($status);
        return $this->response->send();
    }

De fait, désormais, les contrôleurs utilisant des annotations @Route doivent utiliser cette méthode (ou à minima renvoyer un type ":Response") sans quoi une exception est levée :

return $this->publish(
    $this->twig->render('user/profile.html.twig', [
            'user' => $user
    ]))
);

Pour conclure

L'implémentation de ce routeur est toute fraîche et fera peut être naître d'autres nécessités ou lèvera des problèmes. Si tel était le cas, nous y reviendrons mais en attendant, cela semble très bien fonctionner.

Et surtout, tout est plus simple et plus clair :
Avant : à minima un fichier de template -> un controller -> une méthode + la déclaration d'une route dans le fichier functions.php
Après : un controller -> une méthode + 1 ligne d'annotation au dessus de la méthode.

Le prochain épisode de notre saga sera consacrée aux formulaires et notamment à la création d'un plugin wordpress permettant de générer des formulaires basés sur le modèle de symfony.

chevron_right

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *