Skip to content

Commit 633ed95

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

File tree

3 files changed

+288
-0
lines changed

3 files changed

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

0 commit comments

Comments
 (0)