Skip to content

Commit d8b2348

Browse files
[Webhook] Add new component documentation
1 parent e799862 commit d8b2348

File tree

3 files changed

+296
-0
lines changed

3 files changed

+296
-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
@@ -3432,6 +3432,17 @@ enabled
34323432

34333433
Adds a `Link HTTP header`_ to the response.
34343434

3435+
webhooks
3436+
~~~~~~~~
3437+
3438+
.. versionadded:: 6.3
3439+
3440+
The Webhooks configuration was introduced in Symfony 6.3.
3441+
3442+
The ``webhooks`` option (and its children) are used to configure
3443+
the webhooks defined in your application. Read more about the options
3444+
in the :ref:`Webhooks documentation <webhook>`.
3445+
34353446
workflows
34363447
~~~~~~~~~
34373448

webhook.rst

Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
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+
If the webhook request matches all preconditions, the ``doParse()`` method is executed
89+
and returns a :class:`Symfony\\Component\\RemoteEvent\\RemoteEvent`.
90+
91+
Consume Remote Events
92+
~~~~~~~~~~~~~~~~~~~~~
93+
94+
Once a remote event has been created, it has to be consumed by a **remote event consumer**.
95+
A remote event consumer can be declared in two ways: by implementing
96+
the :class:`Symfony\\Component\\RemoteEvent\\Consumer\\ConsumerInterface`,
97+
or by using the
98+
:class:`Symfony\\Component\\RemoteEvent\\Attribute\\AsRemoteEventConsumer` attribute.
99+
In both case, the consumer must implement the ``consume()`` method::
100+
101+
use Symfony\Component\RemoteEvent\Attribute\AsRemoteEventConsumer;
102+
use Symfony\Component\RemoteEvent\RemoteEvent;
103+
104+
#[AsRemoteEventConsumer(name: 'mailer_callback.event')]
105+
class MailerCallbackEventConsumer
106+
{
107+
public function consume(RemoteEvent $event): void
108+
{
109+
// Process the event returned by your parser
110+
}
111+
}
112+
113+
You can then create your own logic in the ``consume()`` method.
114+
115+
Handle Complex Payloads
116+
-----------------------
117+
118+
When you set up a webhook, there is a chance that the payload you
119+
receive will contain a lot of information. It may then be necessary
120+
to move the logic of transforming the payload into a remote event in
121+
another service. The Webhook component provides an interface for this:
122+
the :class:`Symfony\\Component\\RemoteEvent\\PayloadConverterInterface`
123+
interface. This allows to create a converter that will be used in the
124+
request parser. By coupling the converter with a custom remote event
125+
extending :class:`Symfony\\Component\\RemoteEvent\\RemoteEvent`, you
126+
can pass any type of information to your remote event consumer::
127+
128+
// src/RemoteEvent/GenericMailerRemoteEvent.php
129+
namespace App\RemoteEvent;
130+
131+
use Symfony\Component\RemoteEvent\RemoteEvent;
132+
133+
// We first create a new remote event specialized in mailer events
134+
class GenericMailerRemoteEvent extends RemoteEvent
135+
{
136+
public const EVENT_NAME = 'mailer.remote_event';
137+
138+
public function __construct(string $mailId, array $payload, private readonly bool $delivered)
139+
{
140+
parent::__construct(self::EVENT_NAME, $mailId, $payload);
141+
}
142+
143+
public function isDelivered(): bool
144+
{
145+
return $this->delivered;
146+
}
147+
}
148+
149+
// src/RemoteEvent/MailerProviderPayloadConverter.php
150+
namespace App\RemoteEvent;
151+
152+
use Symfony\Component\RemoteEvent\Exception\ParseException;
153+
use Symfony\Component\RemoteEvent\PayloadConverterInterface;
154+
155+
// This converter transforms a provider specific payload to
156+
// our generic mailer remote event
157+
class SpecificMailerProviderPayloadConverter implements PayloadConverterInterface
158+
{
159+
public function convert(array $payload): MailerRemoteEvent
160+
{
161+
// Payload contains all the information your email provider sends you
162+
if (!isset($payload['mail_uid'])) {
163+
throw new ParseException('This payload must contain a mail uid.');
164+
}
165+
166+
return new MailerRemoteEvent($payload['mail_uid'], $payload, $payload['delivered'] ?? false);
167+
}
168+
}
169+
170+
The ``SpecificMailerProviderPayloadConverter`` can now be injected in our request parser
171+
and be used to return the remote event::
172+
173+
namespace App\Webhook;
174+
175+
use App\RemoteEvent\SpecificMailerProviderPayloadConverter;
176+
use Symfony\Component\HttpFoundation\ChainRequestMatcher;
177+
use Symfony\Component\HttpFoundation\Request;
178+
use Symfony\Component\HttpFoundation\RequestMatcher\IsJsonRequestMatcher;
179+
use Symfony\Component\HttpFoundation\RequestMatcher\MethodRequestMatcher;
180+
use Symfony\Component\HttpFoundation\RequestMatcherInterface;
181+
use Symfony\Component\RemoteEvent\Exception\ParseException;
182+
use Symfony\Component\Webhook\Client\AbstractRequestParser;
183+
use Symfony\Component\Webhook\Exception\RejectWebhookException;
184+
185+
final class MailerWebhookParser extends AbstractRequestParser
186+
{
187+
public function __construct(
188+
private readonly SpecificMailerProviderPayloadConverter $converter
189+
) {
190+
}
191+
192+
protected function getRequestMatcher(): RequestMatcherInterface
193+
{
194+
return new ChainRequestMatcher([
195+
new MethodRequestMatcher('POST'),
196+
new IsJsonRequestMatcher(),
197+
]);
198+
}
199+
200+
protected function doParse(Request $request, string $secret): ?RemoteEvent
201+
{
202+
$content = $request->toArray();
203+
204+
try {
205+
// Use the new converter to create our custom remote event
206+
return $this->converter->convert($content);
207+
} catch (ParseException $e) {
208+
throw new RejectWebhookException(406, $e->getMessage(), $e);
209+
}
210+
}
211+
}
212+
213+
Validate Request Preconditions
214+
------------------------------
215+
216+
By using the :class:`Symfony\\Component\\HttpFoundation\\ChainRequestMatcher`,
217+
it is possible to build a powerful request validation chain to determine
218+
if the request that arrived in your webhook endpoint is valid and can
219+
be processed. The following matchers are available:
220+
221+
:class:`Symfony\\Component\\HttpFoundation\\RequestMatcher\\AttributesRequestMatcher`
222+
Matches a request's attributes against a regex. For example, if your request
223+
URL looks like ``/users/{name}``, you may want to check that the ``name``
224+
attribute only contains alphanumeric characters with a regex like
225+
``[a-zA-Z0-9]+``.
226+
227+
:class:`Symfony\\Component\\HttpFoundation\\RequestMatcher\\ExpressionRequestMatcher`
228+
Matches the request against an expression using the ExpressionLanguage component.
229+
230+
:class:`Symfony\\Component\\HttpFoundation\\RequestMatcher\\HostRequestMatcher`
231+
Matches the host that sent the request.
232+
233+
:class:`Symfony\\Component\\HttpFoundation\\RequestMatcher\\IpsRequestMatcher`
234+
Matches an IP or a set of IPs from where the request comes from.
235+
236+
:class:`Symfony\\Component\\HttpFoundation\\RequestMatcher\\IsJsonRequestMatcher`
237+
Matches the request content is a valid JSON.
238+
239+
:class:`Symfony\\Component\\HttpFoundation\\RequestMatcher\\MethodRequestMatcher`
240+
Matches the request uses a given HTTP method.
241+
242+
:class:`Symfony\\Component\\HttpFoundation\\RequestMatcher\\PathRequestMatcher`
243+
Matches the request path.
244+
245+
:class:`Symfony\\Component\\HttpFoundation\\RequestMatcher\\PortRequestMatcher`
246+
Matches the request port.
247+
248+
:class:`Symfony\\Component\\HttpFoundation\\RequestMatcher\\SchemeRequestMatcher`
249+
Matches the request scheme.
250+
251+
Combining multiple request matchers allows to precisely determine if the
252+
webhook is a legitimate request from a known host when combined with
253+
a signature request mechanism::
254+
255+
namespace App\Webhook;
256+
257+
use Symfony\Component\HttpFoundation\ChainRequestMatcher;
258+
use Symfony\Component\HttpFoundation\Request;
259+
use Symfony\Component\HttpFoundation\RequestMatcher\IpsRequestMatcher;
260+
use Symfony\Component\HttpFoundation\RequestMatcher\IsJsonRequestMatcher;
261+
use Symfony\Component\HttpFoundation\RequestMatcher\MethodRequestMatcher;
262+
use Symfony\Component\HttpFoundation\RequestMatcher\SchemeRequestMatcher;
263+
use Symfony\Component\HttpFoundation\RequestMatcherInterface;
264+
use Symfony\Component\RemoteEvent\RemoteEvent;
265+
use Symfony\Component\Webhook\Client\AbstractRequestParser;
266+
267+
final class MailerWebhookParser extends AbstractRequestParser
268+
{
269+
protected function getRequestMatcher(): RequestMatcherInterface
270+
{
271+
return new ChainRequestMatcher([
272+
new MethodRequestMatcher('POST'),
273+
new IsJsonRequestMatcher(),
274+
new IpsRequestMatcher(/** A set of known IPs given by a provider */),
275+
new SchemeRequestMatcher('https'),
276+
]);
277+
}
278+
279+
protected function doParse(Request $request, string $secret): ?RemoteEvent
280+
{
281+
// Verify the signature contained in the request thanks to the
282+
// secret, then return a RemoteEvent
283+
}
284+
}

0 commit comments

Comments
 (0)