6min.

Symfony Webhook et RemoteEvent, ou comment simplifier la gestion d’événements externes

This blog post is also available in 🇬🇧 English: Symfony Webhook & RemoteEvent, or how to simplify external event management.

Lors de la conférence SymfonyCon 2022 à Disneyland Paris, Fabien Potencier a dévoilé à la communauté Symfony deux nouveaux composants : Webhook et RemoteEvent.

Ces derniers ont récemment (mars 2023) été mergés dans la branche 6.3 et nous pouvons donc commencer à les tester, c’est ce que nous allons faire dans la suite de cet article !

Section intitulée webhookWebhook

Un webhook permet à un service extérieur à votre application de vous envoyer des événements. Un webhook est un appel HTTP de serveur à serveur. Le format de la requête envoyée est souvent spécifié par l’expéditeur de celui-ci. Par exemple, chez Stripe, lorsque l’événement checkout.session.completed (paiement terminé avec succès) survient, Stripe enverra autant de webhooks que nécessaire. Pour recevoir un webhook, il faut configurer Stripe avec une URL de notre application.

Voyons concrètement, à l’aide d’une application de démonstration, comment nous pouvons recevoir ces webhooks avec un minimum de code.

Section intitulée s-assurer-de-la-livraison-d-un-e-mailS’assurer de la livraison d’un e-mail

Dans cette application de démonstration, nous allons envoyer un e-mail à l’aide du service Postmark, et configurer la réception du webhook Postmark qui nous informera de la livraison de notre e-mail.

Note : Afin de simplifier la démonstration, nous avons volontairement fait le choix de ne pas faire un exemple avec Stripe ou Slack.

Créons une nouvelle application Symfony en précisant l’option --version=next pour installer la future version 6.3.

symfony new --webapp --version=next webhook-demo

Nous aurons besoin du Bridge Mailer Postmark, afin d’envoyer des e-mails facilement via ce service.

symfony composer require symfony/postmark-mailer

Installons ensuite le nouveau composant Webhook.

symfony composer require symfony/webhook

⚠ Étant donné que ces composants sont très récents, ils ne disposent pas encore en mars 2023 de Recipes Flex, mais elles seront disponibles dès la sortie de Symfony 6.3. Nous devons donc, pour le moment, les configurer manuellement.

Premièrement, dans le fichier config/routes.yaml, ajoutons les quelques lignes permettant d’indiquer à notre application comment utiliser le WebhookController fourni par le composant :

# …
webhook:
    resource: '@FrameworkBundle/Resources/config/routing/webhook.xml'
    prefix: /webhook

Ici, nous déclarons également que le préfixe de toutes les URLs de nos webhooks sera /webhook.

Ensuite, dans un nouveau fichier webhook.yaml, ajoutons le code suivant :

framework:
    webhook:
        routing:
            postmark: # cette chaîne de caractères sera utilisée pour construire l'URL de réception du webhook
                service: mailer.webhook.request_parser.postmark

Cette configuration indique au composant Webhook que nous voulons exposer l’URL /webhook/postmark. Et à la réception d’une requête sur cette URL nous voulons utiliser le service mailer.webhook.request_parser.postmark.

Arrêtons-nous un instant sur ce service, qui n’est pas fourni par le composant Webhook mais par le package symfony/postmark-mailer. En effet, Fabien a commencé à intégrer nativement la gestion des webhooks dans deux Bridges du Mailer de Symfony : Postmark et MailGun. À l’avenir, nous pouvons imaginer que la plupart des Bridges Notifier et Mailer disposeront de tout le nécessaire pour gérer les webhooks émis par les services correspondants.

Ce service dont le FQCN est Symfony\Component\Mailer\Bridge\Postmark\Webhook\PostmarkRequestParser permet au composant Webhook de vérifier que les requêtes arrivant sur l’URL définie précédemment sont légitimes et valides.

L’interface RequestParserInterface :

interface RequestParserInterface
{
   /**
    * Parses an HTTP Request and converts it into a RemoteEvent.
    *
    * @return ?RemoteEvent Returns null if the webhook must be ignored
    *
    * @throws RejectWebhookException When the payload is rejected (signature issue, parse issue, ...)
    */
    public function parse(Request $request, string $secret): ?RemoteEvent;

    public function createSuccessfulResponse(): Response;

    public function createRejectedResponse(string $reason): Response;
}

Et son implémentation pour Postmark :

namespace Symfony\Component\Mailer\Bridge\Postmark\Webhook;

final class PostmarkRequestParser extends AbstractRequestParser
{
    public function __construct(
        private readonly PostmarkPayloadConverter $converter,
    ) {
    }

    protected function getRequestMatcher(): RequestMatcherInterface
    {
        return new ChainRequestMatcher([
            new MethodRequestMatcher('POST'),
            // https://postmarkapp.com/support/article/800-ips-for-firewalls#webhooks
            // localhost is added for testing
            new IpsRequestMatcher(['3.134.147.250', '50.31.156.6', '50.31.156.77', '18.217.206.57', '127.0.0.1', '::1']),
            new IsJsonRequestMatcher(),
        ]);
    }

    protected function doParse(Request $request, string $secret): ?AbstractMailerEvent
    {
        $payload = $request->toArray();
        if (
            !isset($payload['RecordType'])
            || !isset($payload['MessageID'])
            || !(isset($payload['Recipient']) || isset($payload['Email']))
            || !isset($payload['Metadata'])
            || !isset($payload['Tag'])
        ) {
            throw new RejectWebhookException(406, 'Payload is malformed.');
        }

        try {
            return $this->converter->convert($payload);
        } catch (ParseException $e) {
            throw new RejectWebhookException(406, $e->getMessage(), $e);
        }
    }
}

Pour effectuer ces validations, PostmarkRequestParser utilise le mécanisme de RequestMatcher inclus dans le composant HTTPFoundation. Concrètement, ce mécanisme propose des classes afin de vérifier que l’IP émettant la requête est incluse dans une liste définie, ou que le contenu de la requête est bien du JSON, ou encore que le verbe HTTP est bien POST. Fabien a privilégié l’utilisation de l’existant plutôt que de réinventer un système similaire.

Si cette première étape est passée avec succès, alors le composant déclenche ensuite la méthode doParse. Celle-ci va valider le contenu de la requête, et s’assurer que toutes les données attendues sont bien présentes, puis, le cas échéant, appeler un nouveau service, le Payload Converter, qui va transformer notre payload en objet.

Section intitulée remoteeventRemoteEvent

C’est à partir d’ici qu’entre en scène le composant RemoteEvent. Son rôle est de convertir les données reçues dans les webhooks en objets validés que l’on peut ensuite utiliser à notre guise.

À partir de la requête HTTP reçue par notre service PostmarkRequestParser, le Payload Converter va créer un objet RemoteEvent, et même plus précisément dans notre cas, un objet MailerDeliveryEvent.

🗒 Notons ici que plusieurs classes héritant de RemoteEvent sont d’ores et déjà à notre disposition dans le code du composant.

Comme pour la partie Webhook, le Bridge Postmark met à notre disposition un servicePostmarkPayloadConverter.

Ici, nous n’avons rien à faire dans notre code puisque c’est le PostmarkRequestParser qui appelle PostmarkPayloadConverter.

Le seul fichier PHP que nous avons besoin de créer pour avoir accès à cet objet MailerDeliveryEvent est un PostmarkConsumer.

use App\Message\MarkEmailAsDeliveredMessage;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\RemoteEvent\Attribute\AsRemoteEventConsumer;
use Symfony\Component\RemoteEvent\Event\Mailer\MailerDeliveryEvent;

#[AsRemoteEventConsumer(name: 'postmark')]
class PostmarkConsumer
{
    public function __construct(
        public readonly TransactionalEmailRepository $transactionalEmailRepository,
        public readonly EntityManagerInterface $em,
    ) {
    }

    public function consume(MailerDeliveryEvent $event): void
    {
        $transactionalEmail = $this->transactionalEmailRepository->findOneBy(['messageIdentifier' => $event->getMetadata()['Message-ID']]); // "Message-ID" est un champ metadata que nous avons défini arbitrairement lors de l'envoi de l'e-mail, Postmark nous le renvoie dans le webhook, ce qui nous permet d'identifier l'e-mail
        $transactionalEmail->setDelivered(true);
        $this->em->persist($transactionalEmail);
        $this->em->flush();
    }
}

Attention à plusieurs choses dans ce fichier. D’abord l’attribut AsRemoteEventConsumer, qui nous permet de déclarer cette classe comme étant un RemoteEventConsumer et donc de la faire connaître au composant RemoteEvent pour qu’il puisse y passer l’objet converti. Le name est également important, il doit être égal à l’entrée de configuration sous routing que nous avons saisie dans le fichier webhook.yaml, soit dans notre cas : postmark.

Dans la méthode consume, nous pouvons enfin disposer de notre objet contenant les données de l’événement à l’origine du déclenchement du webhook, et agir en conséquence.

Pour la démonstration, nous avons choisi de stocker chaque e-mail envoyé en base de données, et de les marquer comme livrés à la réception du webhook émis par Postmark.

Section intitulée code-sourceCode source

Le code source de l’application de démonstration est disponible sur notre GitHub, pour l’utiliser il vous faudra au préalable un compte Postmark configuré sur un domaine dédié (la validation des entrées DNS DKIM et Return-Path est nécessaire).

Sur ce repository, il existe une différence avec le code présenté dans l’article ; nous avons choisi d’utiliser Messenger pour rendre asynchrone le traitement du RemoteEvent reçu. En effet, il nous paraît important de répondre le plus vite possible, au sens HTTP du terme, au service externe et qu’il ne soit pas tributaire du temps de traitement que nous avons à faire. Cependant certains services comme Stripe s’attendent à ce que notre application puisse répondre une erreur 500 par exemple. Dans ce cas, il est possible que Stripe rejoue le webhook quelques instants plus tard.

Section intitulée a-vous-de-jouerÀ vous de jouer

Comme nous venons de le voir, ces deux nouveaux composants apportent toute l’infrastructure nécessaire pour recevoir des webhooks provenant de n’importe quel service.

Si dans vos applications vous gérez des webhooks émis par Slack, Discord, Stripe, PayPal ou votre CRM, n’hésitez pas à les contribuer au sein de Symfony pour en faire profiter la communauté et ainsi enrichir tous les Bridges existants déjà pour Mailer, Notifier et pourquoi pas Translation. 😉

Commentaires et discussions

Nos articles sur le même sujet

Nos formations sur ce sujet

Notre expertise est aussi disponible sous forme de formations professionnelles !

Voir toutes nos formations

Ces clients ont profité de notre expertise