Skip to content

Commit f9763e7

Browse files
[Webhook] Add new component documentation
1 parent ab5f867 commit f9763e7

File tree

3 files changed

+309
-0
lines changed

3 files changed

+309
-0
lines changed

index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ Topics
6363
translation
6464
validation
6565
web_link
66+
webhook
6667
workflow
6768

6869
Components

reference/configuration/framework.rst

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3504,6 +3504,17 @@ enabled
35043504

35053505
Adds a `Link HTTP header`_ to the response.
35063506

3507+
webhooks
3508+
~~~~~~~~
3509+
3510+
.. versionadded:: 6.3
3511+
3512+
The Webhooks configuration was introduced in Symfony 6.3.
3513+
3514+
The ``webhooks`` option (and its children) are used to configure
3515+
the webhooks defined in your application. Read more about the options
3516+
in the :ref:`Webhooks documentation <webhook>`.
3517+
35073518
workflows
35083519
~~~~~~~~~
35093520

webhook.rst

Lines changed: 297 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,297 @@
1+
Webhook
2+
=======
3+
4+
.. versionadded:: 6.3
5+
6+
The Webhook component was introduced in Symfony 6.3 and is marked
7+
as experimental.
8+
9+
The Webhook component aims at easing the setup of callback functions
10+
through HTTP in your application.
11+
12+
Webhooks allow third-party services to communicate with your application
13+
without you having to make a request to the third-party application.
14+
By providing a webhook URL to the remote service, it will be able to
15+
send a request to your URL when a predefined event occurs in their system.
16+
Unlike an API, it is the third-party service that defines the payload that
17+
will be sent to your own endpoint. It is therefore up to you to adapt to
18+
the service to handle such payload accordingly.
19+
20+
As an example of use case, some mail providers set up a service allowing
21+
you to retrieve the status of one or more emails you have sent
22+
and thus know if they have been delivered or not.
23+
These services then use your webhook to send you this
24+
information once they have it, so that you can store this information
25+
and process it on your side.
26+
27+
Installation
28+
------------
29+
30+
You can install the Webhook component with:
31+
32+
.. code-block:: terminal
33+
34+
$ composer require symfony/webhook
35+
36+
.. include:: /components/require_autoload.rst.inc
37+
38+
Basic Usage
39+
-----------
40+
41+
Parse The Incoming Request
42+
~~~~~~~~~~~~~~~~~~~~~~~~~~
43+
44+
Each type of webhook has its own parser. A parser is a service class that has
45+
the ability to verify that a request meets certain preconditions to be
46+
processed. If so, it returns an event to be handled in your application.
47+
Here is an example of a basic parser::
48+
49+
namespace App\Webhook;
50+
51+
use Symfony\Component\HttpFoundation\ChainRequestMatcher;
52+
use Symfony\Component\HttpFoundation\Request;
53+
use Symfony\Component\HttpFoundation\RequestMatcher\IsJsonRequestMatcher;
54+
use Symfony\Component\HttpFoundation\RequestMatcher\MethodRequestMatcher;
55+
use Symfony\Component\HttpFoundation\RequestMatcherInterface;
56+
use Symfony\Component\RemoteEvent\Exception\ParseException;
57+
use Symfony\Component\Webhook\Client\AbstractRequestParser;
58+
use Symfony\Component\Webhook\Exception\RejectWebhookException;
59+
60+
final class MailerWebhookParser extends AbstractRequestParser
61+
{
62+
protected function getRequestMatcher(): RequestMatcherInterface
63+
{
64+
return new ChainRequestMatcher([
65+
new MethodRequestMatcher('POST'),
66+
new IsJsonRequestMatcher(),
67+
]);
68+
}
69+
70+
protected function doParse(Request $request, string $secret): ?RemoteEvent
71+
{
72+
$content = $request->toArray();
73+
if (!isset($content['signature']['token'])) {
74+
throw new RejectWebhookException(406, 'Payload is malformed.');
75+
}
76+
77+
return new RemoteEvent('mailer_callback.event', 'event-id', $content);
78+
}
79+
}
80+
81+
.. tip::
82+
83+
If you need more flexibility, you can also create your parser by
84+
implementing the
85+
:class:`Symfony\\Component\\Webhook\\Client\\RequestParserInterface`
86+
interface.
87+
88+
A few parsers are already bundled in the component. They allow you to
89+
quickly parse requests from some third-party providers:
90+
91+
- :class:`Symfony\\Component\\Mailer\\Bridge\\Mailgun\\Webhook\\MailgunRequestParser`
92+
- :class:`Symfony\\Component\\Mailer\\Bridge\\Mailgun\\Webhook\\PostmarkRequestParser`
93+
- :class:`Symfony\\Component\\Mailer\\Bridge\\Mailgun\\Webhook\\TwilioRequestParser`
94+
95+
.. note::
96+
97+
When using the Webhook component in a framework context, these classes
98+
are declared as services and can also be accessed through their aliases, for
99+
example ``mailer.webhook.request_parser.mailgun``.
100+
101+
If the webhook request matches all preconditions, the ``doParse()`` method is executed
102+
and returns a :class:`Symfony\\Component\\RemoteEvent\\RemoteEvent`.
103+
104+
Consume Remote Events
105+
~~~~~~~~~~~~~~~~~~~~~
106+
107+
Once a remote event has been created, it has to be consumed by a **remote event consumer**.
108+
A remote event consumer can be declared in two ways: by implementing
109+
the :class:`Symfony\\Component\\RemoteEvent\\Consumer\\ConsumerInterface`,
110+
or by using the
111+
:class:`Symfony\\Component\\RemoteEvent\\Attribute\\AsRemoteEventConsumer` attribute.
112+
In both case, the consumer must implement the ``consume()`` method::
113+
114+
use Symfony\Component\RemoteEvent\Attribute\AsRemoteEventConsumer;
115+
use Symfony\Component\RemoteEvent\RemoteEvent;
116+
117+
#[AsRemoteEventConsumer(name: 'mailer_callback.event')]
118+
class MailerCallbackEventConsumer
119+
{
120+
public function consume(RemoteEvent $event): void
121+
{
122+
// Process the event returned by your parser
123+
}
124+
}
125+
126+
You can then create your own logic in the ``consume()`` method.
127+
128+
Handle Complex Payloads
129+
-----------------------
130+
131+
When you set up a webhook, there is a chance that the payload you
132+
receive will contain a lot of information. It may then be necessary
133+
to move the logic of transforming the payload into a remote event in
134+
another service. The Webhook component provides an interface for this:
135+
the :class:`Symfony\\Component\\RemoteEvent\\PayloadConverterInterface`
136+
interface. This allows to create a converter that will be used in the
137+
request parser. By coupling the converter with a custom remote event
138+
extending :class:`Symfony\\Component\\RemoteEvent\\RemoteEvent`, you
139+
can pass any type of information to your remote event consumer::
140+
141+
// src/RemoteEvent/GenericMailerRemoteEvent.php
142+
namespace App\RemoteEvent;
143+
144+
use Symfony\Component\RemoteEvent\RemoteEvent;
145+
146+
// We first create a new remote event specialized in mailer events
147+
class GenericMailerRemoteEvent extends RemoteEvent
148+
{
149+
public const EVENT_NAME = 'mailer.remote_event';
150+
151+
public function __construct(string $mailId, array $payload, private readonly bool $delivered)
152+
{
153+
parent::__construct(self::EVENT_NAME, $mailId, $payload);
154+
}
155+
156+
public function isDelivered(): bool
157+
{
158+
return $this->delivered;
159+
}
160+
}
161+
162+
// src/RemoteEvent/MailerProviderPayloadConverter.php
163+
namespace App\RemoteEvent;
164+
165+
use Symfony\Component\RemoteEvent\Exception\ParseException;
166+
use Symfony\Component\RemoteEvent\PayloadConverterInterface;
167+
168+
// This converter transforms a provider specific payload to
169+
// our generic mailer remote event
170+
class SpecificMailerProviderPayloadConverter implements PayloadConverterInterface
171+
{
172+
public function convert(array $payload): MailerRemoteEvent
173+
{
174+
// Payload contains all the information your email provider sends you
175+
if (!isset($payload['mail_uid'])) {
176+
throw new ParseException('This payload must contain a mail uid.');
177+
}
178+
179+
return new MailerRemoteEvent($payload['mail_uid'], $payload, $payload['delivered'] ?? false);
180+
}
181+
}
182+
183+
The ``SpecificMailerProviderPayloadConverter`` can now be injected in our request parser
184+
and be used to return the remote event::
185+
186+
namespace App\Webhook;
187+
188+
use App\RemoteEvent\SpecificMailerProviderPayloadConverter;
189+
use Symfony\Component\HttpFoundation\ChainRequestMatcher;
190+
use Symfony\Component\HttpFoundation\Request;
191+
use Symfony\Component\HttpFoundation\RequestMatcher\IsJsonRequestMatcher;
192+
use Symfony\Component\HttpFoundation\RequestMatcher\MethodRequestMatcher;
193+
use Symfony\Component\HttpFoundation\RequestMatcherInterface;
194+
use Symfony\Component\RemoteEvent\Exception\ParseException;
195+
use Symfony\Component\Webhook\Client\AbstractRequestParser;
196+
use Symfony\Component\Webhook\Exception\RejectWebhookException;
197+
198+
final class MailerWebhookParser extends AbstractRequestParser
199+
{
200+
public function __construct(
201+
private readonly SpecificMailerProviderPayloadConverter $converter
202+
) {
203+
}
204+
205+
protected function getRequestMatcher(): RequestMatcherInterface
206+
{
207+
return new ChainRequestMatcher([
208+
new MethodRequestMatcher('POST'),
209+
new IsJsonRequestMatcher(),
210+
]);
211+
}
212+
213+
protected function doParse(Request $request, string $secret): ?RemoteEvent
214+
{
215+
$content = $request->toArray();
216+
217+
try {
218+
// Use the new converter to create our custom remote event
219+
return $this->converter->convert($content);
220+
} catch (ParseException $e) {
221+
throw new RejectWebhookException(406, $e->getMessage(), $e);
222+
}
223+
}
224+
}
225+
226+
Validate Request Preconditions
227+
------------------------------
228+
229+
By using the :class:`Symfony\\Component\\HttpFoundation\\ChainRequestMatcher`,
230+
it is possible to build a powerful request validation chain to determine
231+
if the request that arrived in your webhook endpoint is valid and can
232+
be processed. The following matchers are available:
233+
234+
:class:`Symfony\\Component\\HttpFoundation\\RequestMatcher\\AttributesRequestMatcher`
235+
Matches a request's attributes against a regex. For example, if your request
236+
URL looks like ``/users/{name}``, you may want to check that the ``name``
237+
attribute only contains alphanumeric characters with a regex like
238+
``[a-zA-Z0-9]+``.
239+
240+
:class:`Symfony\\Component\\HttpFoundation\\RequestMatcher\\ExpressionRequestMatcher`
241+
Matches the request against an expression using the ExpressionLanguage component.
242+
243+
:class:`Symfony\\Component\\HttpFoundation\\RequestMatcher\\HostRequestMatcher`
244+
Matches the host that sent the request.
245+
246+
:class:`Symfony\\Component\\HttpFoundation\\RequestMatcher\\IpsRequestMatcher`
247+
Matches an IP or a set of IPs from where the request comes from.
248+
249+
:class:`Symfony\\Component\\HttpFoundation\\RequestMatcher\\IsJsonRequestMatcher`
250+
Matches the request content is a valid JSON.
251+
252+
:class:`Symfony\\Component\\HttpFoundation\\RequestMatcher\\MethodRequestMatcher`
253+
Matches the request uses a given HTTP method.
254+
255+
:class:`Symfony\\Component\\HttpFoundation\\RequestMatcher\\PathRequestMatcher`
256+
Matches the request path.
257+
258+
:class:`Symfony\\Component\\HttpFoundation\\RequestMatcher\\PortRequestMatcher`
259+
Matches the request port.
260+
261+
:class:`Symfony\\Component\\HttpFoundation\\RequestMatcher\\SchemeRequestMatcher`
262+
Matches the request scheme.
263+
264+
Combining multiple request matchers allows to precisely determine if the
265+
webhook is a legitimate request from a known host when combined with
266+
a signature request mechanism::
267+
268+
namespace App\Webhook;
269+
270+
use Symfony\Component\HttpFoundation\ChainRequestMatcher;
271+
use Symfony\Component\HttpFoundation\Request;
272+
use Symfony\Component\HttpFoundation\RequestMatcher\IpsRequestMatcher;
273+
use Symfony\Component\HttpFoundation\RequestMatcher\IsJsonRequestMatcher;
274+
use Symfony\Component\HttpFoundation\RequestMatcher\MethodRequestMatcher;
275+
use Symfony\Component\HttpFoundation\RequestMatcher\SchemeRequestMatcher;
276+
use Symfony\Component\HttpFoundation\RequestMatcherInterface;
277+
use Symfony\Component\RemoteEvent\RemoteEvent;
278+
use Symfony\Component\Webhook\Client\AbstractRequestParser;
279+
280+
final class MailerWebhookParser extends AbstractRequestParser
281+
{
282+
protected function getRequestMatcher(): RequestMatcherInterface
283+
{
284+
return new ChainRequestMatcher([
285+
new MethodRequestMatcher('POST'),
286+
new IsJsonRequestMatcher(),
287+
new IpsRequestMatcher(/** A set of known IPs given by a provider */),
288+
new SchemeRequestMatcher('https'),
289+
]);
290+
}
291+
292+
protected function doParse(Request $request, string $secret): ?RemoteEvent
293+
{
294+
// Verify the signature contained in the request thanks to the
295+
// secret, then return a RemoteEvent
296+
}
297+
}

0 commit comments

Comments
 (0)