Commander au clavier une application Symfony grâce au Routing

Comment ajouter à une application Symfony une UI différente, une interface de commande par texte avec autocompletion.

  1. Le contexte
  2. Exploiter le routing de Symfony ?
  3. Deviner des paramètres de route
  4. Améliorations
  5. Bilan
  6. Commander par la voix ?

Lorsqu'une application comporte des centaines de fonctionnalités et des millions de lignes en base de données, il est souvent fastidieux d'accéder à une information. Il faut choisir le bon élément dans un menu, chercher dans une liste, cliquer sur modifier, accéder à un formulaire pour enfin pouvoir modifier une donnée.

Nous allons voir comment on peut ajouter à une application Symfony une UI différente, une interface de commande par texte avec autocompletion.

Le contexte

Interface d'administration

Ceci est une capture d'écran d'interface d'administration d'une application classique. Il y a des listes, des boutons, des menus... Pour accéder à une ressource ou à une fonctionnalité, plusieurs clics sont nécessaires.

Et si on avait une UI différente ?

Dans notre temps dédié aux side projects, nous avons eu l'idée de voir ce qu'on pouvait faire pour accéder plus rapidement et plus efficacement aux ressources d'une application.

S'inspirer des suggestions de résultats comme sur Google, Spotlight ou Alfred sous Mac ?

Exemple lorsqu'on tape "Modifier document" sur Google :

Recherche avec suggestion de résultats

Cela serait pas mal d'avoir la même chose dans notre application, n'est-ce pas ?

Exploiter le routing de Symfony ?

Symfony dispose des commandes Console mais cette interface est dédiée aux développeurs. L'idée est d'avoir un moteur de recherche dans le navigateur qui suggère des résultats qui irait piocher dans notre application sans forcément écrire complètement une API côté Backend. Et pourquoi pas exploiter le Routing de Symfony ? Nous allons voir pas à pas comment nous avons exploité le routing pour répondre à notre besoin.

Récupérer toutes les routes de l'application

<?php

use Symfony\Component\Routing\Route;
use Symfony\Component\Routing\RouterInterface;

class AllRoutesResolver
{
    /** @var RouterInterface */
    private $router;

    public function __construct(RouterInterface $router)
    {
        $this->router = $router;
    }

    /**
     * @return Route[]
     */
    public function getAllRoutes(): array
    {
        return $this->router->getRouteCollection()->all();
    }
}

Cela donne comme résultat :

Dump de *routes* les *routes* de l'application

Filtrons maintenant les routes en ne gardant que les routes avec méthode GET :

<?php
    // ...
    return array_filter(
        $this->router->getRouteCollection()->all(),
        function (Route $route) {
            return \in_array('GET', $route->getMethods(), true);
        }
    );

Humaniser les noms de route

Maintenant qu'on a toutes les routes de l'application, il faut pouvoir les proposer de manière lisible à un utilisateur non-développeur. L'idée est de transformer le nom de chaque route en libellé "humanisé" :

  • admin_user_list ➡ User list
  • admin_user_create ➡ User create
  • admin_generate_invoice_for_order ➡ Generate invoice for Order

Pour obtenir ce résultat, on a simplement supprimé le préfixe admin_ et les _, puis mis la première lettre en majuscule.

Générer l'url

Pour générer l'url à partir d'une route, rien de plus trivial :

<?php
    /** @var RouterInterface $router */
    return $router->generate($routeName, $parameters);

On créé des vues contenant le libellé et l'url et cela donne quelque chose comme ça :

array:140 [▼
  0 => ResultView {#1388 ▼
    +label: "User list"
    +routeName: "admin_user_list"
    +url: "/user/list"

Démo

Pour le widget côté navigateur permettant à l'utilisateur de faire sa recherche et d'avoir des suggestions de résultats, nous avons choisi une librairie assez légère et facilement configurable, notamment au niveau de la source de données : Pixabay/JavaScript-autoComplete.

Et cela donne comme résultat :

Démo

Deviner des paramètres de route

Que faire maintenant si nos routes attendent des paramètres ? En terme d'expérience utilisateur, on souhaite que l'application nous suggère des résultats. Par exemple, si on tape "User update", l'application nous propose l'ensemble des utilisateurs pouvant être modifiés :

➡ User update Korben
➡ User update Leeloo
➡ User update Cornelius

Cela signifie que côté frontend, lorsqu'on aura tapé "User update" + un espace (très important l'espace), une requête XMLHttpRequest (Ajax pour les intimes) sera déclenchée afin de récupérer les routes contenant les noms des utilisateurs.

Récupérer les requirements d'une route

Considérons que l'on a cette route :

# Routing
admin_user_update:
    path: /user/update/{user}
    methods: [GET, POST]
    requirements:
        user: \d+
    defaults: { _controller: AdminBundle:User:update }

Notre requirement apparaît être un paramètre user qui est de type numérique.

Dans notre controller, le paramètre user va être transformé grâce au ParamConverter de Symfony en objet de la classe User :

<?php

class UserController extends Controller
{
    public function updateAction(Request $request, User $user): Response
    {

Ou ici avec un invokable controller (Action Domain Response pattern) :

<?php

class UpdateUserAction
{
    public function __invoke(Request $request, User $user): Response
    {

Par le code, récupérer les requirements d'une route :

<?php

public function getRequirements(Route $route): array
{
    return array_keys($route->getRequirements());
}

On sait ainsi par le code que la route admin_user_update a pour requirement, un paramètre user.

Récupérer les metadata du controller d'une route

Le principe est de récupérer les arguments d'un controller d'une route et de voir de quelle classe il s'agit.

On a besoin de deux choses :

  1. Récupérer le controller d'une route :
<?php
/** @var Symfony\Component\HttpKernel\Controller\ControllerResolverInterface $controllerResolver */
$controllerResolver->getController($request);
  1. Récupérer les metadata des arguments d'un controller :
<?php
/** @var Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadataFactoryInterface $argumentMetadataFactory */
$argumentMetadataFactory->createArgumentMetadata($controller);

Et le code complet donne cela :

<?php

use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Controller\ControllerResolverInterface;
use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata;
use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadataFactoryInterface;
use Symfony\Component\Routing\Route;

class RouteArgumentsMetadata
{
    /** @var ControllerResolverInterface */
    private $controllerResolver;

    /** @var ArgumentMetadataFactoryInterface */
    private $argumentMetadataFactory;

    public function __construct(
        ControllerResolverInterface $controllerResolver,
        ArgumentMetadataFactoryInterface $argumentMetadataFactory
    ) {
        $this->controllerResolver = $controllerResolver;
        $this->argumentMetadataFactory = $argumentMetadataFactory;
    }

    /** @return ArgumentMetadata[] */
    public function getArgumentsMetadata(Route $route): array
    {
        $request = new Request([], [], ['_controller' => $route->getDefault('_controller')]);
        $controller = $this->controllerResolver->getController($request);

        return $this->argumentMetadataFactory->createArgumentMetadata($controller);
    }
}

Résultat :

array:2 [▼
  0 => ArgumentMetadata {#2146 ▼
    -name: "request"
    -type: "Symfony\Component\HttpFoundation\Request"
    -isVariadic: false
    -hasDefaultValue: false
    -defaultValue: null
    -isNullable: false
  }
  1 => ArgumentMetadata {#2151 ▼
    -name: "user"
    -type: "App\Domain\Model\User"
    -isVariadic: false
    -hasDefaultValue: false
    -defaultValue: null
    -isNullable: false
  }
]

On a donc une variable user dont le type est App\Domain\Model\User.

Cela devient intéressant !

Voyons voir ce que l'on peut en faire...

Resolver dédié

Nous allons coder un Resolver dédié pour un paramètre dès lors qu'il est de type App\Domain\Model\User.

Ici on sait que notre classe User est une classe d'entité Doctrine. Nous allons faire appel à un repository Doctrine pour récupèrer une liste des utilisateurs depuis la base de données. On prend soin de retourner le résultat en précisant que la valeur du paramètre user prend pour valeur l'id de l'utilisateur :

<?php

class UserResolver implements ResolverInterface
{
    /** ... */

    /** ResultView[] */
    public function resolve(ResultView $parentResultView): array
    {
        $enabledUsers = $this->userRepository->getEnabledUser();
        $resultViews = [];

        foreach ($enabledUsers as $user) {
            $resultViews[] = new ResultView(
                $parentResultView->label . ' ' . $user->getFullName(),
                $parentResultView->routeName,
                $this->router->generate($parentResultView->routeName, ['user' => $user->getId()]),
                ['user' => $user->getId()]
            );
        }

        return $resultViews;
    }
}

On obtient ce résultat :

array:2 [▼
  0 => ResultView { ▼
    +label: "User update Korben"
    +routeName: "admin_user_update"
    +url: "/user/update/42"
    +parameters: array:1 [▼
      "user" => 42
    ]
  }
  1 => ResultView { ▼
    +label: "User update Leeloo"
    +routeName: "admin_user_update"
    +url: "/user/update/1"
    +parameters: array:1 [▼
      "user" => 1
    ]
  }
  2 => ResultView { ▼
    +label: "User update Cornelius"
    +routeName: "admin_user_update"
    +url: "/user/update/1337"
    +parameters: array:1 [▼
      "user" => 1337
    ]
  }

On peut donc maintenant proposer à l'utilisateur d'accéder à des routes ayant un paramètre.

Améliorations

Resolver Doctrine ?

Pourrait-on avoir un resolver générique dédié à nos classes d'entité Doctrine ?

L'idée est de tester si une classe donnée est une entité Doctrine, c'est à dire via le ManagerRegistry chercher un éventuel EntityManager. Puis avec cet EntityManager, utiliser le bon Repository et la méthode générique de tout Repository Doctrine, findAll() :

<?php

use Doctrine\Common\Persistence\ManagerRegistry;

class DoctrineResolver
{
    public function __construct(ManagerRegistry $managerRegistry, RouterInterface $router)
    {
        $this->managerRegistry = $managerRegistry;
        $this->router = $router;
    }

    public function resolve(ResultView $resultView, string $paramName, string $className): array
    {
        $entityManager = $this->managerRegistry->getManagerForClass($className);

        if (null === $entityManager) {
            return [];
        }

        $objects = $entityManager->getRepository($className)->findAll();
        $resultViews = [];

        foreach ($objects as $object) {
            $parameters = $resultView->parameters;
            $parameters[$paramName] = $object->getId();

            $resultViews[] = new ResultView(
                sprintf('%s %s', $resultView->label, $object),
                $resultView->routeName,
                $this->router->generate($parentResultView->routeName, $parameters),
                $parameters
            );
        }

        return $resultViews;
    }
}

Il faut aussi déclarer une méthode __toString dans nos classes d'entité Doctrine :

<?php

class User
{
    public function __toString(): string
    {
        return $this->getDisplayName();
    }

Ceci afin que l'objet soit transformé en string lorsque le libellé de la route est créé ici :

sprintf('%s %s', $resultView->label, $object),

Traduction des noms de route

On a vu plus haut, comment à partir du nom de la route, la transformer un peu pour l'humaniser.

Cependant les noms de routes dans une application sont généralement en anglais. Comment faire lorsque l'application est disponible en plusieurs langues ? Par exemple pour la route "admin_user_create", en anglais on aurait donc "User Create" et en français "Utilisateur Créer".

Oui, l'action est préfixée par le sujet et du coup la phrase est inversée (Yoda style !). Si on avait dans notre application "Créer document", "Créer facture", "Créer produit"... lorsqu'on tapperait "Cré.." on aurait droit à tous les "Créer..." de l'application. Nous avons donc choisi ici de préfixer les libellés par le sujet de l'action. Quand on tappe "Produ..." on a par exemple "Produit Créer", "Produit Modifier", "Produit Déstocker"...

Prenons les noms des routes :

# Routing
admin_user_list:
    path: /user/list/{user}

admin_user_create:
    path: /user/create/{user}

admin_user_update:
    path: /user/update/{user}

et déposons les dans des fichiers de traductions de Symfony (translations) :

# humanized_routes.en.yml
admin_user_list: User List
admin_user_create: User Create
admin_user_update: User Update
# humanized_routes.fr.yml
admin_user_list: Utilisateur Lister
admin_user_create: Utilisateur Créer
admin_user_update: Utilisateur Modifier

Comment utiliser ces fichiers de traduction ?

Simplement, on regarde si on a la traduction pour un nom de route donné dans la locale de l'utilisateur de l'application :

<?php

use Symfony\Component\Translation\TranslatorBagInterface;
use Symfony\Component\Translation\TranslatorInterface;

class TranslateRouteName
{
    public function __construct(
        TranslatorInterface $translator,
        TranslatorBagInterface $translatorBag
    ) {
        $this->translator = $translator;
        $this->translatorBag = $translatorBag;
    }

    public function handle(string $routeName, string $locale): string
    {
        $catalogue = $this->translatorBag->getCatalogue($locale);

        return $catalogue->has($routeName, 'humanized_routes')
            ? $this->translator->trans($routeName, [], 'humanized_routes', $locale)
            : $this->humanizeRouteName($routeName);
    }

    protected function humanizeRouteName(string $routeName): string
    {
        return ucfirst(str_replace(['admin_', '_'], ['', ' '], $routeName));
    }
}

TranslatorInterface et TranslatorBagInterface sont implémentés par le même service, donc dans notre déclaration de service, on a :

App\ActionsBot\Resolver\TranslateRouteName:
    arguments:
        - '@translator'
        - '@translator'

Démo

Démo avec paramètre

Bilan

Résultat

  • Nouvelle UX / UI basée sur les routes de l'application.
  • Rapidité++ efficacité++.
  • On pourrait imaginer avoir des commandes personnalisées : afficher le chiffre d'affaire du mois, afficher le nombre d'utilisateurs connectés, etc.

Inconvénients

  • Il faut savoir quoi chercher.
  • Savoir comment chercher. On pourrait résoudre ce problème en indiquant sur chaque page, comment y accéder par une recherche texte.
  • Inversion du langage : "Utilisateur Modifier" au lieu de "Modifier utilisateur". Pour améliorer, on pourrait proposer une recherche en langage naturel.

Commander par la voix ?

Depuis quelques mois, nos ordinateurs et enceintes se sont dotés d'assistants vocaux plus ou moins performants : Microsoft Cortana, Siri chez Apple, Amazon Alexa (Echo) ou Google Home.

Pourquoi ne pas piloter notre application web avec la voix ? Par exemple, en réunion on pourrait demander "Quel est le Chiffre d'affaire du mois sur le produit T-SHIRT KORBEN DALLAS ?" et avoir une réponse en synthèse vocale.

Une API web est disponible : Web Speech API

Celle-ci comporte notamment les composants suivants :

var recognition = new SpeechRecognition();
recognition.continuous = true;
recognition.lang = 'fr-FR';

recognition.onresult = function (event) {
  for (i = event.resultIndex; i < event.results.length; i++) {
    var result = event.results[i][0];
    console.log(result.transcript + ': ' + result.confidence);
  }
};

recognition.start();

Le support de l'API SpeechRecognition est très limité pour l'instant :

Can I Use SpeechRecognition

Démo ici (à tester avec Chrome seulement au jour où cet article a été écrit).

C'est une technologie assez promoteuse. À suivre donc !


Photo by Anthony Martino on Unsplash