Créer un serveur d’envoi de webhooks avec Symfony et le composant Webhook

Voyons comment créer un serveur d’envoi de webhooks avec Symfony. La plupart des documentations montrent comment recevoir des webhooks, mais pas forcément comment créer une fonctionnalité d’envoi des webhook.

Un webhook est plus qu’une simple requête

En soi, il n’y a rien de particulier a implémenter un webhook. Dans sa forme la plus basique, c’est une requête HTTP qui utilise la méthode POST vers une URL prédéfinie. Mais que se passe-t-il quand un webhook est envoyé mais que le serveur distant ne répond pas ? Ou qu’il répond mais avec un status HTTP 500 ? Que se passe-t-il si quelqu’un découvre l’URL du serveur destinataire et envoie ses propres webhook malveillants ?

Dans ces cas-là, envoyer une requête ne suffit pas. Il faut aussi vérifier que l’expéditeur est bien le serveur attendu. Il faut pouvoir vérifier que le message n’a pas été corrompu entre l’envoi et la réception. Et il faut pouvoir notifier le serveur expéditeur que le webhook à bien été reçu.

Le composant Webhook

Depuis sa version 6.4 – qui n’est pas encore sortie en octobre 2023 quand j’écris ces lignes – Symfony dispose d’un composant Webhook. Il contient les briques de bases pour standardiser l’envoi et la réception de webhooks dans l’écosystème Symfony.

Dans ce composant, on retrouve quelques classes pour faciliter la mise en place des webhooks :

  • Subscriber : permet de définir une URL et une chaîne secrète pour le serveur destinataire.
  • RemoteEvent : un évenement envoyé via webhook. Il est composé d’un nom, un id et d’un payload, c’est-à-dire le contenu du webhook.
  • SendWebhookMessage : un message qui contient le Subscriber et le RemoteEvent ci-dessus. À utiliser en combinaison avec Symfony Messenger pour faciliter l’envoi via différents types de transport (synchrone / asynchrone, queues, …).
  • SendWebhookHandler : pour gérer l’envoi du message du webhook

Implémentation de notre test de serveur webhook

Création du transport

Par défaut la classe Transport fourni par Symfony Webhook ne permet pas d’avoir d’informations sur le code HTTP de retour du serveur destinataire. Cette classe implémente l’interface `TransportInterface` qui définie une seule méthode send.

interface TransportInterface
{
    public function send(Subscriber $subscriber, RemoteEvent $event): void;
}

Ce qu’il faut voir ici, c’est que la fonction send ne renvoie rien. Donc quand on envoie un message, il est parti, mais on ne sais pas s’il est bien arrivé ou non. La première chose à faire est donc de redéfinir ce service afin qu’il retourne un code réponse. C’est le code réponse que renverra le serveur destinataire en reconnaissance de la bonne réception du webhook. J’ai donc copié le contenu de vendor/symfony/webhook/Server/Transport.php et je renvoie la valeur de la réponse via

$statusCode = $response->getStatusCode();

On va donc reprendre le code de la classe Transport presque à l’identique, et créer notre propre classe WebhookTransport. Ce qui donne au final :

<?php

namespace App;

use Symfony\Component\HttpClient\HttpOptions;
use Symfony\Component\RemoteEvent\RemoteEvent;
use Symfony\Component\Webhook\Server\RequestConfiguratorInterface;
use Symfony\Component\Webhook\Subscriber;
use Symfony\Contracts\HttpClient\HttpClientInterface;

readonly class WebhookTransport
{
    public function __construct(
        private HttpClientInterface $client,
        private RequestConfiguratorInterface $headers,
        private RequestConfiguratorInterface $body,
        private RequestConfiguratorInterface $signer,
    ) {
    }

    public function send(Subscriber $subscriber, RemoteEvent $event): int
    {
        $options = new HttpOptions();

        $this->headers->configure($event, $subscriber->getSecret(), $options);
        $this->body->configure($event, $subscriber->getSecret(), $options);
        $this->signer->configure($event, $subscriber->getSecret(), $options);

        $response = $this->client->request('POST', $subscriber->getUrl(), $options->toArray());
        $statusCode = $response->getStatusCode();
        return $statusCode;
    }
}

Il faut bien penser à définir les arguments de l’injection de dépendances dans le fichier services.yaml

services:
    App\WebhookTransport:
        arguments:
            - '@http_client'
            - '@webhook.headers_configurator'
            - '@webhook.body_configurator.json'
            - '@webhook.signer'

Création du contrôleur

On peut maintenant créer notre contrôleur qui se chargera d’envoyer un webhook de test à notre serveur destinataire. Pour cela on utilise les classes fournies par Symfony : Subscriber, RemoteEvent et SendWebhookMessage.

<?php

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\RemoteEvent\RemoteEvent;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Webhook\Messenger\SendWebhookMessage;
use Symfony\Component\Webhook\Subscriber;

class ServerController extends AbstractController
{
    public function __construct(
        private readonly MessageBusInterface $bus,
    ) {}

    #[Route('/send', name: 'send')]
    public function send(): Response
    {
        // cette URL est crée plus tard
        $subcriber = new Subscriber('https://127.0.0.1:8000/good', 'secret');
        $event = new RemoteEvent('user_created', 1, ['name' => 'John Doe']);
        $this->bus->dispatch(new SendWebhookMessage($subcriber, $event));
        return new Response('webhook sent');
    }
}

SendWebhookMessage est une classe qui contient le Subcriber et le RemoveEvent avec leurs getters. Rien de plus.

<?php

namespace Symfony\Component\Webhook\Messenger;

//...

class SendWebhookMessage
{
    public function __construct(
        private readonly Subscriber $subscriber,
        private readonly RemoteEvent $event,
    ) {
    }

    public function getSubscriber(): Subscriber
    {
        return $this->subscriber;
    }

    public function getEvent(): RemoteEvent
    {
        return $this->event;
    }
}

Création du handler du message

Ensuite on crée le handler qui va gérer notre message SendWebhookMessage qui a été envoyé via le contrôleur. Cette classe s’occupe seulement d’envoyer le Subscriber et le RemoveEvent à notre WebhookTransport crée à l’instant pour que ce dernier envoie la requête HTTP.

<?php

namespace App;

use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Webhook\Exception\RejectWebhookException;
use Symfony\Component\Webhook\Messenger\SendWebhookMessage;

#[AsMessageHandler('messenger.bus.default')]
readonly class SendWebhookMessageHandler
{
    public function __construct(
        private WebhookTransport $transport,
    ) { }

    public function __invoke(SendWebhookMessage $message): void
    {
        $statusCode = $this->transport->send($message->getSubscriber(), $message->getEvent());
        if ($statusCode === 200) {
            return;
        } else {
            throw new RejectWebhookException($statusCode);
        }
    }
}

Configuration Symfony Messenger

La différence avec le fichier vendor/symfony/webhook/Messenger/SendWebhookHandler.php fourni par Symfony est qu’ici on lance une exception quand le code de retour du webhook n’est pas 200. Cela permettra à Symfony Messenger de pouvoir faire du renvoie automatique de webhook. On va configurer cela maintenant dans le fichier config/packages/messenger.yaml

framework:
    messenger:
        transports:
            # ... reste de la configuration
            webhook_queue:
                dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
                options:
                    check_delayed_interval: 1000
                retry_strategy:
                    max_retries: 5
                    multiplier: 2

        routing:
            Symfony\Component\Webhook\Messenger\SendWebhookMessage: webhook_queue

Dans la configuration définie ci-dessus on va envoyer le message une première fois. Si la réponse du serveur destinataire n’est pas 200 alors une erreur est lancée. Messenger va donc considérer le message comme non envoyé. Du coup, il va relancer le message au maximum 5 fois, et à chaque fois augmenter le délai d’envoi du webhook par 2.
Donc après la première requête “ratée”, le deuxième envoi se fait 1 seconde plus tard, puis le troisième 2 secondes plus tard, puis le quatrième 4 secondes, et ainsi de suite. Bien sûr c’est pour l’exemple, et dans un vrai contexte professionnel, il faudrait mieux adapter ces valeurs (par exemple voici un exemple trouvé sur le web : avec nouvel essai immédiat puis 5s, puis 5m, 30m, 2h, 5h, 10h, puis à nouveau 10h). Cela permet de laisser le temps au serveur distant de corriger son soucis ou d’éviter de relancer le webhook alors que le serveur n’est pas encore accessible à nouveau.

Création d’un contrôleur destinataire de test

Pour finir notre test, on va créer le contrôleur qui va recevoir notre webhook. C’est un contrôleur pour notre test qui permet seulement de tester que tout se passe bien et fonctionne correctement. Dans une vraie application, ce contrôleur serait une application externe.

<?php

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Webhook\Client\RequestParser;

class ReceiverController extends AbstractController
{
    #[Route('/good', name: 'good')]
    public function good(Request $request): Response
    {
        $requestParser = new RequestParser();
        $secret = 'secret';
        $event = $requestParser->parse($request, $secret);

        // Traitement de $event, enregistrement en BDD, etc...

        return new Response('OK', Response::HTTP_OK);
    }

    #[Route('/bad', name: 'bad')]
    public function bad(): Response
    {
        $requestParser = new RequestParser();
        $secret = 'wrong';
        $event = $requestParser->parse($request, $secret);

        // ici $event est  null car la signature ne correspond pas

        return new Response('KO', Response::HTTP_NO_CONTENT);
    }
}

Mise en place de l’analyseur de requête webhook côté destinataire

Ici on découvre un dernier élement du composant Webhook, qui est le RequestParser. Cette object va prendre notre webhook et vérifier que la requête peut bien être décodée en utilisant le header Webhook-Signature envoyé par le serveur expéditeur. Il se base sur le secret qui à été définit lors de la création du Subscriber. C’est une chaine de caractère libre, mais qui n’est connu que du serveur expediteur et du serveur destinataire, ceci afin de s’assurer que le message n’a pas été compromis.

Le webhook à des headers spécifiques définis par Symfony Webhook :

  • Webhook-Signature
  • Webhook-Event
  • Webhook-Id

L’objet RequestParser correspond bien à notre test, mais on pourrait très bien créer notre propre Parser avec notre propre logique en implémentant l’interface Symfony\Component\Webhook\Client\RequestParserInterface.

Test de la fonctionnalité

Avec tout ça en place, il n’y a plus qu’à tester. Normalement en allant sur l’URL /send du serveur expéditeur, on a immédiatement la réponse ‘webhook sent’ qui s’affiche. Entre temps, le message à été enregistrée dans la queue de Messenger (j’utilise ici doctrine avec SQLite pour mes tests).

Pour lancer le webhook, il faut lancer la commande suivante :

bin/console messenger:consume webhook

Si tout se passe bien, le webhook est lancé, le contrôleur le reçoit, renvoit un code 200 et le message est supprimé et considéré comme envoyé.
Maintenant, on peut aussi envoyer le message quand le serveur n’est pas en ligne, et alors avec la commande ci-dessus, Messenger procédera a un envoi et avec l’exception retournée, procédera au renvoi des webhook comme défini dans la configuration.

Conclusion

Mettre en place un webhook est facile car le concept de webhook n’est rien d’autre qu’une requête HTTP effectuée de serveur à serveur. Là où la complexité arrive c’est quand on souhaite que le système prenne en charge les échecs et la sécurité des webhooks. Dans ce cas-là, il y a plus a mettre en place comme je l’ai montré dans cette article.
Le but de cet article était de montrer qu’avec un peu de modification on pouvait partir de la base du composant webhook pour ajouter ces fonctionnalités sur le système de webhook.
Une dernière reflexion et piste d’amélioration / implémentatioon, comme l’a dit Fabien Potencier (le créateur de Symfony) dans son talk présentant ce composant, une meilleure alternative que de se baser uniquement sur les données des webhooks, est d’utiliser les webhook comme système de notification sans payload, et de laisser la récupération des données directement via un call API. Ceci afin d’avoir un modèle de pull de l’API associé à un modèle de push des webhooks. Le meilleur des deux mondes.


Commentaires

Laisser un commentaire

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