Skip to content

Commit 4cefc92

Browse files
authored
Documented asynchronous gateways (#9)
* Move gateways to their own page * Added docs for async gateway handling
1 parent e3471ab commit 4cefc92

File tree

2 files changed

+162
-105
lines changed

2 files changed

+162
-105
lines changed

docs/notification-center/developers/_index.en.md

Lines changed: 8 additions & 105 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ So calling `$this->notificationCenter->sendNotification()` is actually an abbrev
8080
a `MessageConfig` and adding a `TokenCollectionStamp` which contains your token information.
8181

8282
The task of a gateway implementing the `GatewayInterface` then is to deliver that `Parcel` and returning
83-
a `Receipt` for it. But more for gateways later.
83+
a `Receipt` for it. Gateways have [their dedicated documentation page]({{% ref "gateways" %}}).
8484

8585
For now, let's look at notification types.
8686

@@ -158,110 +158,6 @@ So we now have a list of token definitions and a token context "email". Someone
158158
that for this context, all tokens of type `EmailTokenDefinition` and `AnythingTokenDefinition` should be listed. See the
159159
`GetTokenDefinitionClassesForContextEvent` for more details.
160160

161-
## Gateways
162-
163-
Gateways are responsible for actually sealing and then sending a `Parcel` and issuing a `Receipt` for it.
164-
The Notification Center ships with a `MailerGateway` which sends a `Parcel` using the Symfony Mailer.
165-
Hence, the basic logic looks like this:
166-
167-
```php
168-
class MailerGateway implements GatewayInterface
169-
{
170-
public const NAME = 'mailer';
171-
172-
public function __construct(private MailerInterface $mailer)
173-
{
174-
175-
}
176-
177-
public function getName(): string
178-
{
179-
return self::NAME;
180-
}
181-
182-
public function sealParcel(Parcel $parcel): Parcel
183-
{
184-
return $parcel
185-
->seal()
186-
->withStamp($this->createEmailStamp($parcel))
187-
;
188-
}
189-
190-
public function sendParcel(Parcel $parcel): Receipt
191-
{
192-
$email = $this->createEmail($parcel->getStamp(EmailStamp::class));
193-
194-
try {
195-
$this->mailer->send($email);
196-
197-
return Receipt::createForSuccessfulDelivery($parcel);
198-
} catch (TransportExceptionInterface $e) {
199-
return Receipt::createForUnsuccessfulDelivery(
200-
$parcel,
201-
CouldNotDeliverParcelException::becauseOfGatewayException(
202-
self::NAME,
203-
0,
204-
$e
205-
)
206-
);
207-
}
208-
}
209-
210-
private function createEmailStamp(Parcel $parcel): EmailStamp
211-
{
212-
// Create a stamp that contains all we need to actually send the e-mail
213-
}
214-
215-
private function createEmail(EmailStamp $emailStamp): Symfony\Component\Mime\Email
216-
{
217-
// Create a Symfony Email instance based on our immutable EmailStamp
218-
}
219-
}
220-
```
221-
222-
> ⚠️ Notice how `$parcel->seal()` is called **before** adding the `EmailStamp`. This is an important design decision
223-
> as it allows to make a difference between stamps that were added before and after the sealing process. The difference
224-
> is that if you call `$parcel->unseal()`, the `EmailStamp` will not be present on the parcel. Only the stamps that have
225-
> been added before are.
226-
227-
🚨 Let's talk about **the single most important** design decision when creating your own gateway which you
228-
absolutely have to keep in mind: Your gateway **must not** rely on dynamic information in the `sendParcel()`
229-
method. It **must be immutable**. Let's take the post office analogy: When you prepare your parcel, you can stick as many stamps and labels to
230-
it. You can put placeholder stamps, unpack it, change its content, hand it to your friend to add more content or their own
231-
labels etc. All of which is represented in the Notification Center by the `CreateParcelEvent`. However,
232-
once you go to the counter and you actually want to send the parcel, you have to create one final version it. You cannot send it with `##receiver_name##` written on it and it cannot be sent when still open.
233-
Thus, the parcel must be sealed. This is what you do in your `sealParcel()` method. Basically, this
234-
is the one that does the heavy work. In most cases, you will take all the stamps, process them the way you want and
235-
add another **immutable** stamp that `sendParcel()` will then use. This is exactly what happens in the example above.
236-
237-
The best way to think about this architectural design is to imagine that `sealParcel()` does not happen on the
238-
same server as `sendParcel()`. This will clarify that everything `sendParcel()` requires, must be part of your
239-
`Parcel` and its stamps.
240-
241-
Typical design issues may include:
242-
243-
* Accessing the current request via the `RequestStack` in the `sendParcel()` method. That is not allowed! If you
244-
need something from the current request, it's best to create a stamp for that. Use the `CreateParcelEvent` for it.
245-
* Replacing insert tags in the `sendParcel()` method. This must happen in the `sealParcel()` method. An insert tag
246-
could be e.g. `{{env::request}}` which contains the URL of the current page. This might not exist during `sendParcel()`
247-
because it happens later/on a different server etc. Make sure you replace that information when sealing the parcel.
248-
249-
You can also extend `AbstractGateway` which provides helpers if your gateway e.g. requires certain stamps
250-
to be present on your `Parcel`. E.g. the `MailerGateay` requires a `LanguageConfigStamp` to be present during the
251-
`sealParcel()` stage, because it expects language specific information. And it expects an `EmailStamp` during
252-
the `sendParcel()` stage. However, the `TokenCollectionStamp` is optional - it's also perfectly able
253-
to send a `Parcel` without any token replacements.
254-
255-
Maybe you want to write a `SlackGateway` and you need some kind of `SlackTargetChannelStamp`?
256-
257-
The `AbstractGateway` also provides a simple `replaceTokens(Parcel $parcel, string $value)` method which will replace
258-
tokens in case your Gateway was provided with Contao's `SimpleTokenParser` and the parcel has a `TokenCollectionStamp`.
259-
260-
In order to make your new gateway known to the Notification Center, you have to register it as a
261-
service and tag it using the `notification_center.gateway` tag. If you use the [autoconfiguration
262-
feature of the Symfony Container][DI_Autoconfigure], you don't need to tag the service. Implementing the
263-
`GatewayInterface` will be enough.
264-
265161
## Bulky items
266162

267163
To stick to our post office analogy: Sometimes parcels are really heavy or bulky and they cannot be handed
@@ -329,12 +225,19 @@ Sometimes, you need to add tokens to an already existing notification type or yo
329225
That's when we have to dig a little deeper into the processes of the Notification Center.
330226
There are four events you can use:
331227

228+
* AsynchronousReceiptEvent
332229
* CreateParcelEvent
333230
* GetNotificationTypeForModuleConfigEvent
334231
* GetTokenDefinitionsForNotificationTypeEvent
335232
* GetTokenDefinitionClassesForContextEvent
336233
* ReceiptEvent
337234

235+
### AsynchronousReceiptEvent
236+
237+
This event is dispatched when a Gateway triggers `$notificationCenter->informAboutAsynchronousReceipt()` with the
238+
matching `AsynchronousReceipt`. You can use it to get informed about asynchronous parcel delivery updates, see
239+
[Asynchronous Gateways]({{% relref "gateways#asynchronous-gateways" %}}) for more information.
240+
338241
### CreateParcelEvent
339242

340243
This event is dispatched when a new `Parcel` is created within the Notification Center. If you create the `Parcel` instance
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
---
2+
title: "Gateways"
3+
---
4+
5+
Gateways are responsible for actually sealing and then sending a `Parcel` and issuing a `Receipt` for it.
6+
The Notification Center ships with a `MailerGateway` which sends a `Parcel` using the Symfony Mailer.
7+
Hence, the basic logic looks like this:
8+
9+
```php
10+
class MailerGateway implements GatewayInterface
11+
{
12+
public const NAME = 'mailer';
13+
14+
public function __construct(private MailerInterface $mailer)
15+
{
16+
17+
}
18+
19+
public function getName(): string
20+
{
21+
return self::NAME;
22+
}
23+
24+
public function sealParcel(Parcel $parcel): Parcel
25+
{
26+
return $parcel
27+
->seal()
28+
->withStamp($this->createEmailStamp($parcel))
29+
;
30+
}
31+
32+
public function sendParcel(Parcel $parcel): Receipt
33+
{
34+
$email = $this->createEmail($parcel->getStamp(EmailStamp::class));
35+
36+
try {
37+
$this->mailer->send($email);
38+
39+
return Receipt::createForSuccessfulDelivery($parcel);
40+
} catch (TransportExceptionInterface $e) {
41+
return Receipt::createForUnsuccessfulDelivery(
42+
$parcel,
43+
CouldNotDeliverParcelException::becauseOfGatewayException(
44+
self::NAME,
45+
0,
46+
$e
47+
)
48+
);
49+
}
50+
}
51+
52+
private function createEmailStamp(Parcel $parcel): EmailStamp
53+
{
54+
// Create a stamp that contains all we need to actually send the e-mail
55+
}
56+
57+
private function createEmail(EmailStamp $emailStamp): Symfony\Component\Mime\Email
58+
{
59+
// Create a Symfony Email instance based on our immutable EmailStamp
60+
}
61+
}
62+
```
63+
64+
> ⚠️ Notice how `$parcel->seal()` is called **before** adding the `EmailStamp`. This is an important design decision
65+
> as it allows to make a difference between stamps that were added before and after the sealing process. The difference
66+
> is that if you call `$parcel->unseal()`, the `EmailStamp` will not be present on the parcel. Only the stamps that have
67+
> been added before are.
68+
69+
🚨 Let's talk about **the single most important** design decision when creating your own gateway which you
70+
absolutely have to keep in mind: Your gateway **must not** rely on dynamic information in the `sendParcel()`
71+
method. It **must be immutable**. Let's take the post office analogy: When you prepare your parcel, you can stick as many stamps and labels to
72+
it. You can put placeholder stamps, unpack it, change its content, hand it to your friend to add more content or their own
73+
labels etc. All of which is represented in the Notification Center by the `CreateParcelEvent`. However,
74+
once you go to the counter and you actually want to send the parcel, you have to create one final version it. You cannot send it with `##receiver_name##` written on it and it cannot be sent when still open.
75+
Thus, the parcel must be sealed. This is what you do in your `sealParcel()` method. Basically, this
76+
is the one that does the heavy work. In most cases, you will take all the stamps, process them the way you want and
77+
add another **immutable** stamp that `sendParcel()` will then use. This is exactly what happens in the example above.
78+
79+
The best way to think about this architectural design is to imagine that `sealParcel()` does not happen on the
80+
same server as `sendParcel()`. This will clarify that everything `sendParcel()` requires, must be part of your
81+
`Parcel` and its stamps.
82+
83+
Typical design issues may include:
84+
85+
* Accessing the current request via the `RequestStack` in the `sendParcel()` method. That is not allowed! If you
86+
need something from the current request, it's best to create a stamp for that. Use the `CreateParcelEvent` for it.
87+
* Replacing insert tags in the `sendParcel()` method. This must happen in the `sealParcel()` method. An insert tag
88+
could be e.g. `{{env::request}}` which contains the URL of the current page. This might not exist during `sendParcel()`
89+
because it happens later/on a different server etc. Make sure you replace that information when sealing the parcel.
90+
91+
You can also extend `AbstractGateway` which provides helpers if your gateway e.g. requires certain stamps
92+
to be present on your `Parcel`. E.g. the `MailerGateay` requires a `LanguageConfigStamp` to be present during the
93+
`sealParcel()` stage, because it expects language specific information. And it expects an `EmailStamp` during
94+
the `sendParcel()` stage. However, the `TokenCollectionStamp` is optional - it's also perfectly able
95+
to send a `Parcel` without any token replacements.
96+
97+
Maybe you want to write a `SlackGateway` and you need some kind of `SlackTargetChannelStamp`?
98+
99+
The `AbstractGateway` also provides a simple `replaceTokens(Parcel $parcel, string $value)` method which will replace
100+
tokens in case your Gateway was provided with Contao's `SimpleTokenParser` and the parcel has a `TokenCollectionStamp`.
101+
102+
In order to make your new gateway known to the Notification Center, you have to register it as a
103+
service and tag it using the `notification_center.gateway` tag. If you use the [autoconfiguration
104+
feature of the Symfony Container][DI_Autoconfigure], you don't need to tag the service. Implementing the
105+
`GatewayInterface` will be enough.
106+
107+
## Asynchronous Gateways
108+
109+
When a parcel is given to a Gateway, there's an immediate response on the counter which is called the `Receipt`, we have
110+
already learned about that. And this delivery can be either successful or unsuccessful. Either you managed to hand over
111+
your parcel on the counter, or you didn't because e.g. the nice employee told you, you forgot to add a certain `Stamp`.
112+
113+
Now, what happens to that parcel after you have successfully delivered it to the counter? Exactly, so far, we have no clue.
114+
The immediate `Receipt` only tells us whether our parcel has been accepted by the Gateway or not. But we are also interested
115+
in whether the parcel was actually delivered to the final destination which can be minutes, days or weeks later.
116+
117+
Enter the `AsynchronousReceipt`.
118+
119+
The `MailerGateway` of the Notification Center uses this feature too because by default, Contao uses the Symfony Mailer
120+
and sending the e-mails happens asynchronously using Symfony Messenger. This means that Contao is going to try to send
121+
the e-mail in the background for a few times in order to work around temporary network outages etc.
122+
123+
Hence, when sealing the package, the `MailerGateway` adds another stamp in order to inform any third-party event listeners
124+
about the fact, that this parcel will get asynchronous information:
125+
126+
```php
127+
return $parcel
128+
->seal()
129+
->withStamp(AsynchronousDeliveryStamp::createWithRandomId())
130+
;
131+
```
132+
133+
Now, any listener can access this stamp using `$event->receipt->getParcel()->getStamp(AsynchronousDeliveryStamp::class)?->identifier`
134+
in any of the events and store this identifier for further processing.
135+
136+
The `MailerGatway` itself passes this identifier as a header on the `Email` instance and - because it uses the Symfony Mailer -
137+
registers to the Symfony Mailer `SentMessageEvent` and the `FailedMessageEvent`. This allows it to extract the header, remove
138+
it from the final `Email` and inform any third-party integrators about the fact, that this e-mail now has been sent.
139+
Doing this is pretty straightforward:
140+
141+
```php
142+
$receipt = $error
143+
? AsynchronousReceipt::createForUnsuccessfulDelivery($messageId, $error)
144+
: AsynchronousReceipt::createForSuccessfulDelivery($messageId);
145+
146+
$this->notificationCenter->informAboutAsynchronousReceipt($receipt);
147+
```
148+
149+
The `NotificationCenter::informAboutAsynchronousReceipt()` does nothing more than dispatching an `AsynchronousReceiptEvent`
150+
so listening to it is enough to get informed about any `AsynchronousReceipt`.
151+
152+
As you can see, you can enhance your Gateway with asynchronous capabilities in order to inform third-party developers about
153+
the fact that your `Parcel` is actually sent asynchronously, you gave it an identifier, and you will inform them as soon
154+
as you know the asynchronous process has finished.

0 commit comments

Comments
 (0)