Skip to content

Commit 516c9c7

Browse files
authored
Automatically dispatch updates to clients using the Mercure protocol (#2282)
* Automatically dispatch updates to clients using the Mercure protocol * Allow to use different URL to publish and subscribe * Fix PHPStan * Use public repos * Remove useless arg * Review * Revert unrelated change * Don't deal with absolute IRIs in documents for now
1 parent 7e9655a commit 516c9c7

File tree

20 files changed

+717
-4
lines changed

20 files changed

+717
-4
lines changed

composer.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
"symfony/property-access": "^2.7 || ^3.0 || ^4.0",
2424
"symfony/property-info": "^3.3.11 || ^4.0",
2525
"symfony/serializer": "^4.1",
26+
"symfony/web-link": "^4.1",
2627
"willdurand/negotiation": "^2.0.3"
2728
},
2829
"require-dev": {
@@ -58,6 +59,9 @@
5859
"symfony/finder": "^3.3 || ^4.0",
5960
"symfony/form": "^3.3 || ^4.0",
6061
"symfony/framework-bundle": "^3.3 || ^4.0",
62+
"symfony/mercure": "*",
63+
"symfony/mercure-bundle": "*",
64+
"symfony/messenger": "^4.1",
6165
"symfony/phpunit-bridge": "^3.3 || ^4.0",
6266
"symfony/routing": "^3.3 || ^4.0",
6367
"symfony/security": "^3.0 || ^4.0",

features/mercure/discover.feature

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
Feature: Mercure discovery support
2+
In order to let the client discovering the Mercure hub
3+
As a client software developer
4+
I need to retrieve the hub URL through a Link HTTP header
5+
6+
@createSchema
7+
Scenario: Checks that the Mercure Link is added
8+
Given I send a "GET" request to "/dummy_mercures"
9+
Then the header "Link" should be equal to '<http://example.com/docs.jsonld>; rel="http://www.w3.org/ns/hydra/core#apiDocumentation",<https://demo.mercure.rocks/hub>; rel="mercure"'
10+
11+
Scenario: Checks that the Mercure Link is not added on endpoints where updates are not dispatched
12+
Given I send a "GET" request to "/"
13+
Then the header "Link" should be equal to '<http://example.com/docs.jsonld>; rel="http://www.w3.org/ns/hydra/core#apiDocumentation"'

src/Annotation/ApiResource.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
* @Attribute("iri", type="string"),
4242
* @Attribute("itemOperations", type="array"),
4343
* @Attribute("maximumItemsPerPage", type="int"),
44+
* @Attribute("mercure", type="mixed"),
4445
* @Attribute("normalizationContext", type="array"),
4546
* @Attribute("order", type="array"),
4647
* @Attribute("outputClass", type="string"),
@@ -175,6 +176,13 @@ final class ApiResource
175176
*/
176177
private $maximumItemsPerPage;
177178

179+
/**
180+
* @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112
181+
*
182+
* @var mixed
183+
*/
184+
private $mercure;
185+
178186
/**
179187
* @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112
180188
*
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Core\Bridge\Doctrine\EventListener;
15+
16+
use ApiPlatform\Core\Api\IriConverterInterface;
17+
use ApiPlatform\Core\Api\ResourceClassResolverInterface;
18+
use ApiPlatform\Core\Api\UrlGeneratorInterface;
19+
use ApiPlatform\Core\Exception\InvalidArgumentException;
20+
use ApiPlatform\Core\Exception\RuntimeException;
21+
use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface;
22+
use ApiPlatform\Core\Util\ClassInfoTrait;
23+
use Doctrine\ORM\Event\OnFlushEventArgs;
24+
use Symfony\Component\ExpressionLanguage\ExpressionLanguage;
25+
use Symfony\Component\Mercure\Update;
26+
use Symfony\Component\Messenger\MessageBusInterface;
27+
use Symfony\Component\Serializer\SerializerInterface;
28+
29+
/**
30+
* Publishes resources updates to the Mercure hub.
31+
*
32+
* @author Kévin Dunglas <[email protected]>
33+
*
34+
* @experimental
35+
*/
36+
final class PublishMercureUpdatesListener
37+
{
38+
use ClassInfoTrait;
39+
40+
private $resourceClassResolver;
41+
private $iriConverter;
42+
private $resourceMetadataFactory;
43+
private $serializer;
44+
private $messageBus;
45+
private $publisher;
46+
private $expressionLanguage;
47+
private $createdEntities;
48+
private $updatedEntities;
49+
private $deletedEntities;
50+
51+
public function __construct(ResourceClassResolverInterface $resourceClassResolver, IriConverterInterface $iriConverter, ResourceMetadataFactoryInterface $resourceMetadataFactory, SerializerInterface $serializer, MessageBusInterface $messageBus = null, callable $publisher = null, ExpressionLanguage $expressionLanguage = null)
52+
{
53+
if (null === $messageBus && null === $publisher) {
54+
throw new InvalidArgumentException('A message bus or a publisher must be provided.');
55+
}
56+
57+
$this->resourceClassResolver = $resourceClassResolver;
58+
$this->iriConverter = $iriConverter;
59+
$this->resourceMetadataFactory = $resourceMetadataFactory;
60+
$this->serializer = $serializer;
61+
$this->messageBus = $messageBus;
62+
$this->publisher = $publisher;
63+
$this->expressionLanguage = $expressionLanguage ?? class_exists(ExpressionLanguage::class) ? new ExpressionLanguage() : null;
64+
$this->reset();
65+
}
66+
67+
/**
68+
* Collects created, updated and deleted entities.
69+
*/
70+
public function onFlush(OnFlushEventArgs $eventArgs)
71+
{
72+
$uow = $eventArgs->getEntityManager()->getUnitOfWork();
73+
74+
foreach ($uow->getScheduledEntityInsertions() as $entity) {
75+
$this->storeEntityToPublish($entity, 'createdEntities');
76+
}
77+
78+
foreach ($uow->getScheduledEntityUpdates() as $entity) {
79+
$this->storeEntityToPublish($entity, 'updatedEntities');
80+
}
81+
82+
foreach ($uow->getScheduledEntityDeletions() as $entity) {
83+
$this->storeEntityToPublish($entity, 'deletedEntities');
84+
}
85+
}
86+
87+
/**
88+
* Publishes updates for changes collected on flush, and resets the store.
89+
*/
90+
public function postFlush()
91+
{
92+
try {
93+
foreach ($this->createdEntities as $entity) {
94+
$this->publishUpdate($entity, $this->createdEntities[$entity]);
95+
}
96+
97+
foreach ($this->updatedEntities as $entity) {
98+
$this->publishUpdate($entity, $this->updatedEntities[$entity]);
99+
}
100+
101+
foreach ($this->deletedEntities as $entity) {
102+
$this->publishUpdate($entity, $this->deletedEntities[$entity]);
103+
}
104+
} finally {
105+
$this->reset();
106+
}
107+
}
108+
109+
private function reset(): void
110+
{
111+
$this->createdEntities = new \SplObjectStorage();
112+
$this->updatedEntities = new \SplObjectStorage();
113+
$this->deletedEntities = new \SplObjectStorage();
114+
}
115+
116+
/**
117+
* @param object $entity
118+
*/
119+
private function storeEntityToPublish($entity, string $property): void
120+
{
121+
$resourceClass = $this->getObjectClass($entity);
122+
if (!$this->resourceClassResolver->isResourceClass($resourceClass)) {
123+
return;
124+
}
125+
126+
$value = $this->resourceMetadataFactory->create($resourceClass)->getAttribute('mercure', false);
127+
if (false === $value) {
128+
return;
129+
}
130+
131+
if (\is_string($value)) {
132+
if (null === $this->expressionLanguage) {
133+
throw new RuntimeException('The Expression Language component is not installed. Try running "composer require symfony/expression-language".');
134+
}
135+
136+
$value = $this->expressionLanguage->evaluate($value, ['object' => $entity]);
137+
}
138+
139+
if (true === $value) {
140+
$value = [];
141+
}
142+
143+
if (!\is_array($value)) {
144+
throw new InvalidArgumentException(sprintf('The value of the "mercure" attribute of the "%s" resource class must be a boolean, an array of targets or a valid expression, "%s" given.', $resourceClass, \gettype($value)));
145+
}
146+
147+
if ('deletedEntities' === $property) {
148+
$this->deletedEntities[(object) [
149+
'id' => $this->iriConverter->getIriFromItem($entity),
150+
'iri' => $this->iriConverter->getIriFromItem($entity, UrlGeneratorInterface::ABS_URL),
151+
]] = $value;
152+
153+
return;
154+
}
155+
156+
$this->$property[$entity] = $value;
157+
}
158+
159+
/**
160+
* @param object|string $entity
161+
*/
162+
private function publishUpdate($entity, array $targets): void
163+
{
164+
if ($entity instanceof \stdClass) {
165+
// By convention, if the entity has been deleted, we send only its IRI
166+
// This may change in the feature, because it's not JSON Merge Patch compliant,
167+
// and I'm not a fond of this approach
168+
$iri = $entity->iri;
169+
$data = json_encode(['@id' => $entity->id]);
170+
} else {
171+
$iri = $this->iriConverter->getIriFromItem($entity, UrlGeneratorInterface::ABS_URL);
172+
$data = $this->serializer->serialize($entity, 'jsonld');
173+
}
174+
175+
$update = new Update($iri, $data, $targets);
176+
$this->messageBus ? $this->messageBus->dispatch($update) : ($this->publisher)($update);
177+
}
178+
}

src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,7 @@ public function load(array $configs, ContainerBuilder $container)
144144
$this->registerHttpCacheConfiguration($container, $config, $loader, $useDoctrine);
145145
$this->registerValidatorConfiguration($container, $config);
146146
$this->registerDataCollectorConfiguration($container, $config, $loader);
147+
$this->registerMercureConfiguration($container, $config, $loader, $useDoctrine);
147148
}
148149

149150
/**
@@ -525,4 +526,18 @@ private function registerDataCollectorConfiguration(ContainerBuilder $container,
525526
$loader->load('debug.xml');
526527
}
527528
}
529+
530+
private function registerMercureConfiguration(ContainerBuilder $container, array $config, XmlFileLoader $loader, bool $useDoctrine)
531+
{
532+
if (!$config['mercure']['enabled'] || !$container->hasParameter('mercure.default_hub')) {
533+
return;
534+
}
535+
536+
$loader->load('mercure.xml');
537+
$container->getDefinition('api_platform.mercure.listener.response.add_link_header')->addArgument($config['mercure']['hub_url'] ?? '%mercure.default_hub%');
538+
539+
if ($useDoctrine) {
540+
$loader->load('doctrine_orm_mercure_publisher.xml');
541+
}
542+
}
528543
}

src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
use Symfony\Component\Config\Definition\ConfigurationInterface;
2424
use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException;
2525
use Symfony\Component\HttpFoundation\Response;
26+
use Symfony\Component\Mercure\Update;
2627
use Symfony\Component\Serializer\Exception\ExceptionInterface;
2728

2829
/**
@@ -218,6 +219,13 @@ public function getConfigTreeBuilder()
218219
->end()
219220
->end()
220221

222+
->arrayNode('mercure')
223+
->{class_exists(Update::class) ? 'canBeDisabled' : 'canBeEnabled'}()
224+
->children()
225+
->scalarNode('hub_url')->defaultNull()->info('The URL send in the Link HTTP header. If not set, will default to the URL for the Symfony\'s bundle default hub.')
226+
->end()
227+
->end()
228+
221229
->end();
222230

223231
$this->addExceptionToStatusSection($rootNode);
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<?xml version="1.0" ?>
2+
3+
<container xmlns="http://symfony.com/schema/dic/services"
4+
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
5+
xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd">
6+
7+
<services>
8+
9+
<!-- Event listener -->
10+
11+
<service id="api_platform.doctrine.listener.mercure.publish" class="ApiPlatform\Core\Bridge\Doctrine\EventListener\PublishMercureUpdatesListener">
12+
<argument type="service" id="api_platform.resource_class_resolver" />
13+
<argument type="service" id="api_platform.iri_converter" />
14+
<argument type="service" id="api_platform.metadata.resource.metadata_factory" />
15+
<argument type="service" id="api_platform.serializer" />
16+
<argument type="service" id="message_bus" on-invalid="ignore" />
17+
<argument type="service" id="mercure.hub.default.publisher" />
18+
19+
<tag name="doctrine.event_listener" event="onFlush" />
20+
<tag name="kernel.event_listener" event="kernel.terminate" method="postFlush" />
21+
</service>
22+
23+
</services>
24+
25+
</container>
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<?xml version="1.0" ?>
2+
3+
<container xmlns="http://symfony.com/schema/dic/services"
4+
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
5+
xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd">
6+
7+
<services>
8+
<!-- Event listener -->
9+
10+
<service id="api_platform.mercure.listener.response.add_link_header" class="ApiPlatform\Core\Mercure\EventListener\AddLinkHeaderListener">
11+
<argument type="service" id="api_platform.metadata.resource.metadata_factory" />
12+
13+
<tag name="kernel.event_listener" event="kernel.response" method="onKernelResponse" />
14+
</service>
15+
</services>
16+
</container>

src/Hydra/EventListener/AddLinkHeaderListener.php

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515

1616
use ApiPlatform\Core\Api\UrlGeneratorInterface;
1717
use ApiPlatform\Core\JsonLd\ContextBuilder;
18+
use Fig\Link\GenericLinkProvider;
19+
use Fig\Link\Link;
1820
use Symfony\Component\HttpKernel\Event\FilterResponseEvent;
1921

2022
/**
@@ -36,9 +38,15 @@ public function __construct(UrlGeneratorInterface $urlGenerator)
3638
*/
3739
public function onKernelResponse(FilterResponseEvent $event)
3840
{
39-
$event->getResponse()->headers->set('Link', sprintf(
40-
'<%s>; rel="%sapiDocumentation"',
41-
$this->urlGenerator->generate('api_doc', ['_format' => 'jsonld'], UrlGeneratorInterface::ABS_URL), ContextBuilder::HYDRA_NS)
42-
);
41+
$apiDocUrl = $this->urlGenerator->generate('api_doc', ['_format' => 'jsonld'], UrlGeneratorInterface::ABS_URL);
42+
$link = new Link(ContextBuilder::HYDRA_NS.'apiDocumentation', $apiDocUrl);
43+
44+
$attributes = $event->getRequest()->attributes;
45+
if (null === $linkProvider = $attributes->get('_links')) {
46+
$attributes->set('_links', new GenericLinkProvider([$link]));
47+
48+
return;
49+
}
50+
$attributes->set('_links', $linkProvider->withLink($link));
4351
}
4452
}

0 commit comments

Comments
 (0)