TD2 – Réponses HTTP Réponses HTTP, Moteur de template Twig

L’objectif de ce TD est d’améliorer nos réponses HTTP sur plusieurs points :

La classe Response

Nous allons utiliser la classe Response du composant HttpFoundation de Symfony. Selon la documentation de Symfony, un objet Response contient toute l’information qui doit être renvoyée au client HTTP : des en-têtes, un code de réponse et un corps de réponse. Voici un exemple d’appel au constructeur (tous les arguments sont optionnels) :

use Symfony\Component\HttpFoundation\Response;

$response = new Response(
    'Corps de la réponse : page Web ou JSON',
    Response::HTTP_OK, // Code 200 OK
    ['content-type' => 'text/html'] // En-tête pour indiquer une réponse HTML
);

L’envoi de la réponse au client HTTP se fait tout simplement

$response->send();

Des actions qui retournent des Response

Nous souhaitons modifier nos actions pour qu’elles retournent toutes une instance de la classe Response. Ceci nous permettra par la suite d’utiliser toutes les possibilités des réponses HTTP, notamment une meilleure redirection dans l’exercice 2, ou des codes de réponse HTTP personnalisés dans l’exercice 4.

Pour que ControleurGenerique::afficherVue() renvoie une Response, il faut que les vues renvoient une chaîne de caractères plutôt que d’écrire directement la réponse HTTP. Pour ceci, nous allons temporairement rediriger la sortie standard vers un fichier tampon de sortie (output buffer ou ‘ob’) avec la commande

ob_start();

Tant qu’elle est enclenchée, aucune donnée, hormis les en-têtes, n’est envoyée au navigateur du client HTTP. Quand l’exécution des vues est fini, nous récupérons le contenu de ce fichier tampon puis l’effaçons avec

$corpsReponse = ob_get_clean();

Il ne reste plus qu’à créer un objet Response à partir de ce corps de réponse.

  1. Modifiez ControleurGenerique::afficherVue() pour renvoyer une Response :

    -    protected static function afficherVue(string $cheminVue, array $parametres = []): void
    +    protected static function afficherVue(string $cheminVue, array $parametres = []): Response
       {
             extract($parametres);
             $messagesFlash = MessageFlash::lireTousMessages();
    +        ob_start();
             require __DIR__ . "/../vue/$cheminVue";
    +        $corpsReponse = ob_get_clean();
    +        return new Response($corpsReponse);
       }
    
  2. Modifier la première action ControleurPublication::afficherListe() pour que le fonction renvoie la réponse fournie par afficherVue().

  3. Dans RouteurURL, récupérer la réponse renvoyée par call_user_func_array(), puis appelez une méthode vue plus haut pour l’envoyer au client HTTP.

  4. Testez l’URL web/ qui renvoie vers l’action afficherListe(). Cela doit marcher.

  5. Dans toutes les actions, mettez à jour le code pour que l’action renvoie la réponse fournie par ControleurGenerique::afficherVue().

    Remarque : Vous devrez peut-être modifier le type de retour des actions pour que le site continue de marcher. Notez que les redirections risquent d’être cassées temporairement.

Des redirections plus propres

Le composant HttpFoundation de Symfony fournit aussi la classe RedirectResponse qui hérite de Response. Cette classe permet de bénéficier automatiquement d’une redirection plus professionnelle.

En effet, si vous ouvrez son code source vendor/symfony/http-foundation/Response.php dans votre IDE, vous verrez qu’en plus de mettre en place un en-tête Location : comme nous le faisions, elle écrit la balise suivante (voir la méthode setTargetUrl())

<meta http-equiv="refresh" content="0;url=url_de_redirection" />

Ceci permet une meilleure compatibilité avec différents navigateurs. En effet, l’en-tête Location : n’est pas complètement supportée (59% des navigateurs) (cliquez sur le bouton Usage relative pour améliorer l’affichage du site caniuse.com). Au contraire, la balise <meta http-equiv="refresh" /> est supportée par 97% des navigateurs actuellement.

De plus, RedirectResponse associe automatiquement le code de réponse 302 Found qui indique une redirection temporaire. Profitons-en pour remarquer que la réécriture d’URL indiquée dans le fichier de configuration .htaccess de Apache utilisait un code 301 Moved Permanently de redirection permanente. Ceci était utilisé par exemple pour rediriger la requête web/controleurFrontal.php/connexion vers web/connexion. Une redirection permanente permet au navigateur d’optimiser la requête : le navigateur garde en cache la redirection et l’effectue lui-même sans envoyer de requête au serveur.

  1. Modifier le code de ControleurGenerique::rediriger() pour renvoyer une nouvelle RedirectResponse vers l’URL absolue qui vient d’être générée.

  2. Dans toutes les actions, mettez à jour le code pour que l’action renvoie la réponse fournie par ControleurGenerique::rediriger().

  3. Testez votre site qui doit remarcher complètement.

Utilisation des codes de réponses pour les erreurs

Les méthodes UrlMatcher::match(), ControllerResolver::getController() et ArgumentResolver::getArguments() utilisés dans RouteurURL peuvent lever des exceptions. Nous allons les traiter en envoyant une réponse HTTP adéquate, en faisant particulièrement attention au code de réponse.

  1. Pour découvrir quelle méthode lance quelle exception, il faut lire la PHPDoc. Pour exemple, pour la méthode UrlMatcher::match() :
    • Avec PhpStorm, on accède à la documentation survolant $associateurUrl->match() avec la souris.
      Comme la liste des exceptions est documenté dans l’interface UrlMatcherInterface, il faut cliquer sur UrlMatcherInterface::match. On trouve alors les 3 exceptions levées par cette méthode.
    • Avec vscode ou vscodium, vous devriez voir la liste des exceptions en survolant simplement $associateurUrl->match() dans RouteurURL.
  2. Listez en commentaire du code toutes les exceptions levées par les 3 méthodes (5 types d’exception en tout).

Nous allons maintenant traiter ces exceptions avec une réponse HTTP adaptée. Les codes de réponse qui signalent une erreur de l’utilisateur sont en 4xx. Voici quelques codes de réponse HTTP utiles :

  1. Changer la méthode ControleurGenerique::afficherErreur() pour le code suivant qui permet d’ajouter un code de réponse :
    public static function afficherErreur($messageErreur = "", $statusCode = 400): Response
    {
        $reponse = ControleurGenerique::afficherVue('vueGenerale.php', [
            "pagetitle" => "Problème",
            "cheminVueBody" => "erreur.php",
            "errorMessage" => $messageErreur
        ]);
    
        $reponse->setStatusCode($statusCode);
        return $reponse;
    }
    
  2. Parmi les 6 exceptions levées, 2 correspondent à des codes de réponses HTTP spécifiques. Pour les autres exceptions, nous renverrons le code de réponse d’erreur générique 400.
    Dans RouteurURL, gérez l’exception avec des catch successifs qui permettent de gérer de l’exception la plus spécifique à l’exception la plus générique :
    try {
       $associateurUrl = new UrlMatcher($routes, $contexteRequete);
       $donneesRoute = $associateurUrl->match($requete->getPathInfo());
       $requete->attributes->add($donneesRoute);
    
       $resolveurDeControleur = new ControllerResolver();
       $controleur = $resolveurDeControleur->getController($requete);
    
       $resolveurDArguments = new ArgumentResolver();
       $arguments = $resolveurDArguments->getArguments($requete, $controleur);
    
       $reponse = call_user_func_array($controleur, $arguments);
    } catch (TypeExceptionSpecifique1 $exception) {
       // Remplacez xxx par le bon code d'erreur
       $reponse = ControleurGenerique::afficherErreur($exception->getMessage(), xxx);
    } catch (TypeExceptionSpecifique2 $exception) {
       // Remplacez xxx par le bon code d'erreur
       $reponse = ControleurGenerique::afficherErreur($exception->getMessage(), xxx);
    } catch (\Exception $exception) {
       $reponse = ControleurGenerique::afficherErreur($exception->getMessage()) ;
    }
    $reponse->send();
    
  3. Testez votre code en appelant une route qui n’existe pas. Observez le message d’erreur, ainsi que le code de retour avec les outils de développement, onglet Réseau.

  4. Testez votre code en appelant une méthode non prise en charge. Observez le message d’erreur, ainsi que le code de retour avec les outils de développement.

  5. Modifiez, le temps de cette question, votre code pour que l’action afficherListe() prenne un argument quelconque. Appelez l’URL web/. Observez le message d’erreur et le code de retour avec les outils de développement.

Un langage de gabarit : Twig

Le principe des langages de gabarit (template engines) est de fournir un langage adapté aux vues. Voyons les contraintes d’un bon langage de gabarits :

Sources :

Initialisation de Twig

  1. Installez le paquet Twig avec la commande
    composer require twig/twig
    
  2. Initialisez Twig dans RouteurURL.php. :
    use Twig\Environment;
    use Twig\Loader\FilesystemLoader;
    
    $twigLoader = new FilesystemLoader(__DIR__ . '/../vue/');
    $twig = new Environment(
        $twigLoader,
        [
            'autoescape' => 'html',
            'strict_variables' => true
        ]
    );
    Conteneur::ajouterService("twig", $twig);
    

    Explication : Ce code indique le répertoire de base des vues Twig à l’aide du FilesystemLoader. Puis, nous créons l’objet $twig en lui indiquant des options : échappement automatique des variables pour du HTML et signaler avec une exception les variables invalides. Enfin, nous stockons ce service dans le Conteneur pour pouvoir s’en resservir dans une autre partie du code.

  3. Dans le ControleurGenerique, créez une nouvelle méthode afficherTwig :

     protected static function afficherTwig(string $cheminVue, array $parametres = []): Response
     {
         /** @var Environment $twig */
         $twig = Conteneur::recupererService("twig");
         $corpsReponse = $twig->render($cheminVue, $parametres);
         return new Response($corpsReponse);
     }
    

    Explication : La méthode render de Twig exécute une vue et renvoie la chaîne de caractères produite.

Premier gabarit Twig, héritage de gabarit

Nous allons créer notre premier gabarit Twig qui correspond à formulaireConnexion.php. Pour que cette vue s’insère sans une mise-en-page générale (anciennement vueGenerale.php), nous allons utiliser le mécanisme d’héritage de gabarit.

Nous allons remplacer src/vue/vueGenerale.php par le fichier src/vue/base.html.twig suivant :

<!DOCTYPE html>
<html lang="fr">
<head>
    <title>{% block page_title %}The Feed{% endblock %}</title>
    <meta charset="utf-8">
    <link rel="stylesheet" type="text/css" href="{# lien vers le CSS #}">
</head>
<body>
<header>
    <div id="titre" class="center">
        <a href="{# lien #}"><span>The Feed</span></a>
        <nav>
            <a href="{# lien #}">Accueil</a>
            {# si l'utilisateur est connecte #}
            <a href="{# lien #}">Ma page</a>
            <a href="{# lien #}">Déconnexion</a>
            {# sinon #}
            <a href="{# lien #}">Inscription</a>
            <a href="{# lien #}">Connexion</a>
            {# fin si #}
        </nav>
    </div>
</header>
<div id="flashes-container">
    {# boucle sur les types de messages flash #}
        {# boucle sur les messages flash de ce type #}
            <span class="flashes flashes-{# type du message #}">{# message flash #}</span>
        {# fin boucle #}
    {# fin boucle #}
</div>
{% block page_content %}{% endblock %}
</body>
</html>

Remarque : c’est normal que plusieurs aspects de la page soient cassés (menu, lien, CSS). Nous implémenterons toutes les fonctionnalités entre commentaire Twig {# #} dans la suite.

Les balises Twig {% block page_title %}{% endblock %} définissent un bloc, c’est-à-dire une partie de la page qui pourra être remplacée dans une autre vue. Nous allons justement reprendre cette vue et remplacer le titre et le contenu de la page dans une nouvelle vue src/vue/utilisateur/connexion.html.twig :

{% extends "base.html.twig" %}

{% block page_title %}Connexion{% endblock %}

{% block page_content %}
    <main>
        <form action="{# lien vers la page de traitement #}" id="form-access" class="center" method="post">
            <fieldset>
                <legend>Connexion</legend>
                <div class="access-container">
                    <label for="login">Login</label>
                    <input id="login" type="text" name="login" required/>
                </div>
                <div class="access-container">
                    <label for="password">Mot de passe</label>
                    <input id="password" type="password" name="mot-de-passe" required/>
                </div>
                <input id="access-submit" type="submit" value="Se connecter">
            </fieldset>
        </form>
    </main>
{% endblock %}

La balise Twig {% extends %} permet d’hériter d’un gabarit. On peut alors remplacer le contenu d’un bloc en le redéfinissant.

  1. Créez les vues src/vue/base.html.twig et src/vue/utilisateur/connexion.html.twig comme précédemment.
  2. Changer la méthode afficherFormulaireConnexion du ControleurUtilisateur pour appeler cette vue à l’aide de afficherTwig.

    Rappel : Le chemin de la vue est relatif au dossier src/vue/ que nous avions donné à FilesystemLoader.

  3. L’URL web/connexion doit afficher le formulaire de connexion, mais sans CSS.

Syntaxe de base de Twig

  1. Créez une nouvelle vue src/vue/publication/feed.html.twig avec le contenu suivant :

    {% extends "base.html.twig" %}
    
    {% block page_title %}The Feed{% endblock %}
    
    {% block page_content %}
       <main id="the-feed-main">
          <div id="feed">
                {# si l'utilisateur est connecté #}
                   <form id="feedy-new" action="{# lien #}" method="post">
                      <fieldset>
                            <legend>Nouveau feedy</legend>
                            <div>
                               <textarea required id="message" minlength="1" maxlength="250" name="message"
                                        placeholder="Qu'avez-vous en tête?"></textarea>
                            </div>
                            <div>
                               <input id="feedy-new-submit" type="submit" value="Feeder!">
                            </div>
                      </fieldset>
                   </form>
                {# fin si #}
                {# boucle sur les publications #}
                   <div class="feedy">
                      <div class="feedy-header">
                            <a href="{# lien vers afficherPublications #}">
                               <img class="avatar"
                                     src="{# lien vers l'image de profil de l'auteur de la publication #}"
                                     alt="avatar de l'utilisateur">
                            </a>
                            <div class="feedy-info">
                               <span>{# login de l'auteur de la publication #}</span>
                               <span> - </span>
                               <span>{# date de la publication #}</span>
                               <p>{# message de la publication #}</p>
                            </div>
                      </div>
                   </div>
                {# s'il n'y a pas de publication #}
                   <p id="no-publications" class="center">Pas de publications pour le moment!</p>
                {# fin de boucle #}
          </div>
       </main>
    {% endblock %}
    
  2. Changer les actions ControleurPublication::afficherListe() et ControleurUtilisateur::afficherPublications() pour appeler cette vue, en fournissant en paramètre le tableau des publications.

  3. Codez avec la syntaxe Twig la boucle des publications, son cas particulier quand il n’y a pas de publication, et les affichages liés aux publications (sauf la date qui sera affichée dans le prochain exercice).

    Note : les liens et la gestion de l’utilisateur connecté seront fait plus tard.

Les filtres de Twig

Les variables peuvent être modifiées par des filtres. Les filtres sont séparés de la variable par un symbole de pipe |. Plusieurs filtres peuvent être enchaînés, auquel cas la sortie d’un filtre est appliquée au suivant. Par exemple,

{{ donnee|lower|truncate(20) }} {# minuscule puis tronque à 20 caractères #}

Le filtre escape (ou son raccourci e) permet d’appliquer un échappement personnalisé :

{{ user.username|e('js') }} {# échappement dans un contexte JavaScript #}
{{ user.username|e('css') }} {# contexte CSS #}
{{ user.username|e('url') }} {# contexte bout d'URL, par ex. query string #}
{{ user.username|e('html_attr') }} {# contexte attribut d'une balise HTML #}

Le filtre date formate une date avec un format personnalisé. La documentation liste les filtres de Twig fournis par défaut.

  1. Affichez la date des publications en utilisant un filtre pour qu’elle soit affichée comme suit : 09 March 2023.

    Aide : Allez voir les liens précédents sur la documentation pour trouver le bon format.

Étendre la syntaxe de Twig

Twig peut être étendu de nombreuses façons : vous pouvez ajouter des filtres, des fonctions, des variables globales. Ou, plus rarement, des balises, des tests et des opérateurs.

À la manière de Symfony, nous allons rajouter des fonctions à Twig pour gérer les liens liés aux routes ou aux assets.

La syntaxe pour rajouter une fonction est

use Twig\TwigFunction;

$twig->addFunction(new TwigFunction("route", $callable));

$callable est une variable au format callable (comme avec call_user_func() au TD1). Pour exemple, pour donner la méthode d’un objet, on peut utiliser la syntaxe

$callable = [$objet, "nomMethode"];

ou la syntaxe callable de première classe apparue avec PHP 8.1

$callable = $objet->nomMethode(...); // Les ... font parti de la syntaxe

La fonction est alors disponible dans Twig, par exemple comme ceci :

{{ route("afficherListe") }}
  1. Dans RouteurURL, ajoutez deux fonctions à Twig :
    • une fonction route pour la méthode $generateurUrl->generate() ;
    • une fonction asset pour la méthode $assistantUrl->getAbsoluteUrl() ;
  2. Utilisez ces fonctions dans toutes vos vues Twig pour réparer tous les liens (CSS, menu, action du formulaire), sauf le lien “Ma Page” du menu de navigation vers la route paramétrée de l’utilisateur connecté.

    Aide :

    • pour le lien vers la page personnelle de l’auteur d’une publication, vous devrez générer la route vers l’action afficherPublications en la méthode $generateurUrl->generate() qui attend un tableau associatif comme deuxième argument. Les tableaux associatifs se créent avec la syntaxe JSON
      {'nomCle' : 'valeur'}
      
    • pour l’asset correspondant à la photo de profil, vous aurez besoin de concaténer des chaînes de caractères avec ~ en Twig.
  3. Testez votre site ; le CSS et les liens doivent remarcher.

Il ne nous reste plus qu’à restaurer les utilisateurs connectés et les messages Flash. Pour ceci, nous allons rajouter des variables globales à Twig :

$twig->addGlobal('nomVariableTwig', $variablePHP);
  1. Dans RouteurURL, rajoutez une variable globale contenant l’identifiant de l’utilisateur connecté (cf. la classe ConnexionUtilisateur).

    Rappel : Par convention dans notre site, cette variable vaut null si l’utilisateur n’est pas connecté.

  2. Mettez à jour base.html.twig et publication/feed.html.twig pour prendre en compte si l’utilisateur est connecté au niveau de l’interface.

    Aide : Pour tester si un objet n’est pas null, vous pouvez faire

    {% if objectVariable is not null %}
    

    ou

    {% if objectVariable %}
    

    car la conversion d’un objet en booléen est true si et seulement l’objet est non nul (comme en PHP).

Pour rajouter les messages Flash, nous pourrions être tentés de faire

$twig->addGlobal('messagesFlash', MessageFlash::lireTousMessages());

dans RouteurURL. Cependant, nous aimerions que les messages Flash soient lus au moment de l’évaluation des vues, et non pas au début du script PHP. Du coup, nous proposons de stocker une instance de MessageFlash :

$twig->addGlobal('messagesFlash', new MessageFlash());

et d’appeler la méthode lireMessages() dans la vue Twig avec messagesFlash.lireMessages().

  1. Dans RouteurURL, rajoutez une variable globale messagesFlash.

  2. Dans base.html.twig, affichez les messages Flash.

    Note : Il faut donc boucler sur les types de messages Flash, puis sur les messages de ce type. Attention, une erreur vicieuse consiste à lire les messages d’un type avec la commande

    {{ messagesFlash.lireTousMessages()[type] }}
    

    Cette manière ne marche pas car un message Flash se détruit après lecture. Du coup, le code précédent détruit tous les messages Flash, même ceux qui ne sont du type type.

  3. Créez la dernière vue manquante src/vue/utilisateur/inscription.html.twig. Changez l’action ControleurUtilisateur::afficherFormulaireCreation() pour appeler cette vue.

  4. Il ne reste plus qu’à gérer la vue d’erreur, qui est appelée en cas d’exception :

    • créez une vue d’erreur src/vue/erreur.html.twig qui étend base.html.twig et affiche une variable messageErreur qui lui sera donné en paramètre.
    • Modifiez la méthode ControleurGenerique::afficherErreur() pour appeler cette vue.
    • Testez si la vue d’erreur fonctionne en demandant par exemple une route inconnue.

Bonus : pour le projet ?

  1. Il est facile d’adopter une approche par composant dans les vues, c’est-à-dire de définir des bouts de vues facilement réutilisables.

    Allez voir la documentation des macros, de la fonction include ou de la balise embed.

  2. La compilation des vues Twig peut être précalculée et stockée dans un cache. Utilisez la configuration auto_reload lors du développement pour mettre à jour le cache à chaque changement de code source des vues.
  3. La fonction dump facilite le débogage en affichant un résultat similaire à var_dump().