Skip to content

Commit 51a318f

Browse files
[Webhook] Add new component documentation
1 parent ad55b0d commit 51a318f

File tree

3 files changed

+292
-0
lines changed

3 files changed

+292
-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
@@ -3407,6 +3407,17 @@ enabled
34073407

34083408
Adds a `Link HTTP header`_ to the response.
34093409

3410+
webhooks
3411+
~~~~~~~~
3412+
3413+
.. versionadded:: 6.3
3414+
3415+
The Webhooks configuration was introduced in Symfony 6.3.
3416+
3417+
The ``webhooks`` option (and its children) are used to configure
3418+
the webhooks defined in your application. Read more about the options
3419+
in the :ref:`Webhooks documentation <webhook>`.
3420+
34103421
workflows
34113422
~~~~~~~~~
34123423

webhook.rst

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

0 commit comments

Comments
 (0)