Skip to content

Commit f5cc1ce

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

File tree

3 files changed

+294
-0
lines changed

3 files changed

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

0 commit comments

Comments
 (0)