Dans ma boîte, CodeBuds, nous utilisons la plateforme de chat Mattermost pour communiquer. C'est un système open source très sympathique que nous pouvons mettre en place sur nos serveurs. Ceci permet de ne pas avoir de limite de messages comme sur un compte Slack gratuit par exemple.

Nous nous sommes dit qu'il serait sympa d'avoir la possibilité de faire communiquer nos projets Symfony facilement sur la plateforme. C'est pour cela que nous avons créé un bundle Symfony qui permet de faire tout ça.

Ici nous allons voir en détails comment ce bundle a été créé et pourquoi certains choix ont étés faits.

Exigences

Ce bundle nécessite aussi :

  • php: ^7.4
  • symfony/http-client: 5.0.*

Nous avons choisis d'être compatible avec seulement la dernière version de PHP, 7.4, pour utiliser ses nouvelles fonctionnalités.

Nous aurions pu ne pas utiliser le symfony/http-client mais comme c'est de toute façon un bundle Symfony ceci ne rajoutera pas de surcharge. Il nous permettra aussi de faire des requêtes http de façon plus simple et plus sécurisée.

Comme nous utilisons la version 5.0.* du paquet Symfony ce bundle ne sera compatible que avec la dernière version de symfony.

Comment l'utiliser

Pour que le bundle fonctionne, vous devez le télécharger avec la commande composer suivante: composer require codebuds / mattermost-publication-bundle

Si vous utilisez Symfony flex, cela ajoutera la ligne suivante dans votre fichier .env:

###> codebuds/mattermost-publication###
MATTERMOST_WEBHOOK_URL="http://{your-mattermost-site}/hooks/xxx-generatedkey-xxx"
###< codebuds/mattermost-publication###

Et créez un fichier config/packages/mattermost_publication.yaml :

mattermost_publication:
    webhook_url: '%env(MATTERMOST_WEBHOOK_URL)%'
    # You can also set the default channel, username and icon_url
    # for more information you can checkout the bundle documentation
    # https://github.com/codebuds33/mattermost-publication-bundle/blob/master/README.md

Si vous n'utilisez pas les recettes flex, vous devrez l'ajouter à la main. Si vous voulez vérifier rapidement comment l'utiliser, vous pouvez le trouver dans le [fichier README] (https://github.com/codebuds33/mattermost-publication-bundle/blob/master/README.md). Si vous voulez en savoir plus continuez à lire et nous nous rapprocherons de la source ;)

Code

Le code du bundle est publique sur sa page github

Configuration

Étudions le code. Pour commencer les bundles Symfony ont besoin d'un fichier de configuration services.xml dans le répertoire Resources/config.

<?xml version="1.0" encoding="UTF-8" ?>

<container xmlns="http://symfony.com/schema/dic/services"
           xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
           xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd">

    <services>
        <service id="codebuds_mattermost_publication.mattermost_publication"
                 class="CodeBuds\MattermostPublicationBundle\MattermostPublication" public="true"/>
        <service id="CodeBuds\MattermostPublicationBundle\MattermostPublication"
                 alias="codebuds_mattermost_publication.mattermost_publication" public="true"/>
    </services>
</container>

Cela peux sembler étrange au début mais en regardant de près nous pouvons nous apercevoir que les lignes <services> sont inverser l'un par rapport à l'autre. Ceci permettra à Symfony de autowire ce bundle quand il sera utilisé.

Nous allons avoir besoin des certaines variables configurables avec des valeurs par défaut. Pour ce faire il faut créer deux fichiers PHP dans la répertoire DependencyInjection.

# DependencyInjection/Configuration.php
<?php

namespace CodeBuds\MattermostPublicationBundle\DependencyInjection;

use Symfony\Component\Config\Definition\Builder\TreeBuilder;
use Symfony\Component\Config\Definition\ConfigurationInterface;

class Configuration implements ConfigurationInterface
{
    public function getConfigTreeBuilder()
    {
        $treeBuilder = new TreeBuilder("mattermost_publication");
        $rootNode = $treeBuilder->getRootNode();

        $rootNode
            ->children()
                ->scalarNode("webhook_url")->defaultValue("http://{your-mattermost-site}/hooks/xxx-generatedkey-xxx")->end()
                ->scalarNode("username")->defaultNull()->end()
                ->scalarNode("icon_url")->defaultNull()->end()
                ->scalarNode("channel")->defaultNull()->end()
            ->end();

        return $treeBuilder;
    }
}

Afin de pouvoir publier sur Mattermost il faudra créer un webhook entrant dans celui-ci.

Sans cela il sera impossible d'envoyer un message. Il y a également trois variables optionnelles et permettront de modifier le bot qui publiera (username et icon_url) ou de publier dans un channel différent à l'intérieur de l'équipe Mattermost pour lequel le webhook entrant à été créé.

La chose suivante est de s'assurer que ces variables puissent être utiliser à l'intérieur du bundle. Il est nécessaire de créer un DependencyInjection\Extension.

<?php

namespace CodeBuds\MattermostPublicationBundle\DependencyInjection;

use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\HttpKernel\DependencyInjection\Extension;
use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\Loader\XmlFileLoader;

class MattermostPublicationExtension extends Extension
{
    public function load(array $configs, ContainerBuilder $container)
    {
        $loader = new XmlFileLoader($container, new FileLocator(__DIR__ . '/../Resources/config'));
        try {
            $loader->load('services.xml');
        } catch (\Exception $exception) {
            var_dump($exception);
        }

        $configuration = $this->getConfiguration($configs, $container);

        $config = $this->processConfiguration($configuration, $configs);

        $definition = $container->getDefinition("codebuds_mattermost_publication.mattermost_publication");
        $definition->setArgument(0, $config['webhook_url']);
        $definition->setArgument(1, $config['username']);
        $definition->setArgument(2, $config['icon_url']);
        $definition->setArgument(3, $config['channel']);
    }
}

Il va regarder le fichier XML de configuration et rajouter une "definition". Pour ce faire il cherchera la "definition" de codebuds_mattermost_publication.mattermost_publication, qui est le nom de notre classCodeBuds\MattermostPublicationBundle\MattermostPublication et y injecter les variables.

Cela semble être beaucoup d'étapes alors que rien qui ne pourra faire du boulot a été créé pour l'instant. Nous verrons dans quelques secondes l'intérêt de tout cela. Et maintenant nous pouvons regarder à quoi ressemble un Message !

Message

L'idée de ce Bundle est d'envoyer un message, c'est donc pour celai qu'il y a une classe Message dans le répertoire Model.

# Model/message.php
<?php

namespace CodeBuds\MattermostPublicationBundle\Model;

class Message
{
    private ?string $webhookUrl;

    private ?string $text;

    private ?string $username;

    private ?string $iconUrl;

    private ?string $channel;

    public function __construct()
    {
        $this->webhookUrl = null;
        $this->text = null;
        $this->username = null;
        $this->iconUrl = null;
        $this->channel = null;
    }

    # all the getters and setters ...

    public function toArray()
    {
        $array = [
            'webhook_url' => $this->getText(),
            'text' => $this->getText(),
            'username' => $this->getUsername(),
            'icon_url' => $this->getIconUrl(),
            'channel' => $this->getChannel()
        ];

        return array_filter($array, fn($var) => $var !== null);
    }
}

Celui-ci prend les quatre variables dont nous venons de parler afin de mettre en place l'adresse ou nous allons envoyer le message. Cela nous permettra également de rajouter des décorations sur son enveloppe. Il y a une autre variable, text, qui bien sur est le contenu de ce que nous allons envoyer.

Il y a également une fonction toArray qui va permettre de passer l'objet en tableau. Cette fonction met en place un filtre qui permet d’enlever toutes les variables qui n'auront pas de valeur. Voici donc notre message, voyons comment nous allons pouvoir l'envoyer !

Action

Pour ce fichier MattermostPublication.php qui est le Controller de notre bundle commençons par regarder son constructor et voir comment il prends en compte les variables.

private ?string $webhookUrl;

private ?string $username;

private ?string $iconUrl;

private ?string $channel;

public function __construct(?string $webhookUrl, ?string $username, ?string $iconUrl, ?string $channel)
{
    $this->webhookUrl = $webhookUrl;
    $this->username = $username;
    $this->iconUrl = $iconUrl;
    $this->channel = $channel;
}

Ici nous avons dit que quand la class est instanciée cela prendra 4 variables qui peuvent être un string ou null. Cela peut sembler bizarre pour la variable $webhookUrl vu que sans adresse (URL webhook) aucun message ne peut être envoyé. Nous allons regarder ça de plus près par la suite. Maintenant que le facteur est là observons comment il fait son travail.

/**
* @param Message|string $message
* @return void
* @throws TransportExceptionInterface
*/
public function publish($message)
{
    $message instanceof Message
        ?: $message = (new Message())->setText($message);

    $message->setIconUrl($message->getIconUrl() ?? $this->iconUrl);
    $message->setUsername($message->getUsername() ?? $this->username);
    $message->setChannel($message->getChannel() ?? $this->channel);
    $message->setWebhookUrl($message->getWebhookUrl() ?? $this->webhookUrl);

    $this->publishRequest($message);
}

Cette fonction publish peut prendre seulement du texte ou un modèle Message. Ceci est fait pour rendre l'envoie de message simple plus rapide sans avoir à mettre un timbre sur chaque. Si c'est le choix de la personne qui envoie le message nous allons l'emballer pour. Pour ce faire nous allons tout d'abord vérifier si ce qui nous est envoyé est un message correctement fait ou juste du texte grâce à ?:. Si c'est seulement du texte nous allons l'emballer.

Une fois le message emballé nous allons y ajouter les détails sur l'emballage. Pour ce faire nous vérifions si il y a eu des valeurs spécifique pour les éléments sur ce message, sinon si il y en a de façon général et sinon il n y aura pas de décoration en particulier. Pour éviter d'écrire trop de code à ce sujet nous utilisons l'opérateur ternaire ??. Une fois le message et son emballage bien fini nous allons le donner à la prochaine fonction publishRequest.

/**
* @param Message $message
* @return void
* @throws TransportExceptionInterface
* @throws Exception
*/
private function publishRequest(Message $message)
{
    if ($message->getWebhookUrl() === null) {
        $errors[] = "No webhook URL set for message";
    }

    if ($message->getText() === null) {
        $errors[] = "No text set for message";
    }

    if (isset($errors)) {
        $message = array_reduce($errors, function ($carry, $error) {
            $carry .= "Error: {$error} ";
            return $carry;
        });
        throw new Exception($message);
    }

    $request = HttpClient::create()
        ->request(
        'POST',
        $message->getWebhookUrl(),
        [
            'headers' =>
            [
                'Content-Type' => 'application/json',
            ],
            'json' => $message->toArray()
        ]
    );

    if ($request->getStatusCode() !== 200) {
        throw new Exception("Publication failed, verify the channel and the settings for the webhook");
    };
}

En premier nous allons vérifier que le message à bien un endroit où aller et du contenu. Si ce n'est pas le cas nous allons rajouter des erreurs. Ensuite si il y a des erreurs nous allons nous assurer que le message ne sera pas envoyé et envoyer des Exceptions à l'envoyeur pour l'informer pourquoi le message n'a pas été envoyer.

Si le message est correct et que nous avons toutes les informations dont nous avons besoin afin de pouvoir l'envoyer nous allons utiliser le Symfony HttpClient afin de faire ceci. C'est à ce moment là que la fonction toArray du modèle de Message sera utile pour être certain de ne pas envoyer du contenu en trop qui n'a aucune utilité. Si tout va bien Mattermost nous enverra un accusé de réception indiquant 200. Si cela n'est pas le cas nous allons à nouveau envoyer une Exception à l'envoyeur pour lui indiquer que le message n'a pas été reçu. Cela peux arriver si l'adresse (ici donc l'URL du webhook) était mauvais ou si par exemple l'envoyeur à essayé de l'adresser à quelqu'un de spécifique dans le bâtiment (la team Mattemost avec un channel précis) mais que cela est soit interdit soit la personne n'y a jamais été.

Voilà pour le fonctionnement de ce bundle et pourquoi nous l'avons réalisé. Pour rendre la configuration un peu plus rapide et plus facile, nous avons également ajouté une recette Symfony Flex, voyons comment cela est fait (spoiler il n'y a pas grand chose à voir;))

Recettes Flex

Les recettes Symfony Flex gèrent beaucoup de choses qui devaient être faites manuellement lors de l'ajout d'un package de composition à votre projet. Il peut créer de nouveaux fichiers (comme les fichiers de configuration de package) ou ajouter à ceux existants (comme le fichier .env par défaut) par exemple. Il existe de nombreuses recettes officielles que vous pouvez consulter sur le public [GitHub the contains them] (https://github.com/symfony/recipes) et pour vos propres bundles, vous pouvez cloner le [recette contrib repository] (https: //github.com/symfony/recipes-contrib). Nous n'entrerons pas trop dans les détails sur les recettes de cet article, mais jetez un œil à ce que nous avons ajouté pour cet ensemble.

Ajout de votre répertoire

Lorsque vous clonez le référentiel recettes-contrib, vous verrez de nombreux répertoires, à la racine nous ajouterons tout d'abord un répertoire avec notre nom d'utilisateur [Packagist] (https://packagist.org/packages/codebuds/) codebuds. alors nous avons besoin du nom de notre bundle mattermost-publication-bundle et de la version du bundle dont la recette est pour0.3.

codebuds/
 ├── mattermost-publication-bundle/
 │   ├── 0.3/

C'est là que nous mettrons tous les fichiers (et répertoires) dont nous avons besoin. Tout d'abord un fichier manifest.json contenant les informations sur notre bundle.

{
    "bundles": {
        "CodeBuds\\MattermostPublicationBundle\\MattermostPublicationBundle": ["all"]
    },
    "copy-from-recipe": {
        "config/": "%CONFIG_DIR%/"
    },
    "env": {
        "MATTERMOST_WEBHOOK_URL": "http://{your-mattermost-site}/hooks/xxx-generatedkey-xxx"
    }
}

La partie bundles est juste des informations sur le bundle auquel cette recette est liée. Viens ensuite copier-de-recette qui indique à la recette à partir de quel répertoire à partir de laquelle vous voulez copier des fichiers dans le projet Symfony dans lequel votre bundle est installé et où les copier. Enfin, la partie env vous permet d'ajouter des informations par défaut dans le fichier global .env du projet.

Ajout du fichier de configuration par défaut

Nous pouvons donc voir ici que nous voulons copier les fichiers de config / vers les projets % CONFIG_DIR% /. Donc, pour générer automatiquement une configuration par défaut pour les packages, nous ajouterons le fichier config / packages / mattermost_publication.yaml à notre recette:

mattermost_publication:
    webhook_url: '%env(MATTERMOST_WEBHOOK_URL)%'
    # You can also set the default channel, username and icon_url
    # for more information you can checkout the bundle documentation
    # https://github.com/codebuds33/mattermost-publication-bundle/blob/master/README.md

Et c'est tout ! Vous pouvez maintenant faire une pull request à partir de votre dépôt forké et si elle est acceptée, la prochaine personne qui téléchargera votre bundle dans son projet aura ces fichiers par défaut automatiquement ajoutés !