Skip to content

Commit 320b978

Browse files
committed
Automatically dispatch updates to clients using the Mercure protocol
1 parent 750e9be commit 320b978

File tree

18 files changed

+710
-7
lines changed

18 files changed

+710
-7
lines changed

composer.json

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,17 @@
11
{
2+
"repositories": [
3+
{
4+
"type": "vcs",
5+
"url": "https://github.com/symfony/mercure"
6+
},
7+
{
8+
"type": "vcs",
9+
"url": "https://github.com/symfony/mercure-bundle"
10+
}
11+
],
12+
"minimum-stability": "dev",
13+
"prefer-stable": true,
14+
215
"name": "api-platform/core",
316
"type": "library",
417
"description": "The ultimate solution to create web APIs.",
@@ -14,7 +27,6 @@
1427
],
1528
"require": {
1629
"php": ">=7.1",
17-
1830
"doctrine/inflector": "^1.0",
1931
"psr/cache": "^1.0",
2032
"psr/container": "^1.0",
@@ -23,11 +35,10 @@
2335
"symfony/property-access": "^2.7 || ^3.0 || ^4.0",
2436
"symfony/property-info": "^3.3.11 || ^4.0",
2537
"symfony/serializer": "^4.1",
38+
"symfony/web-link": "^4.1",
2639
"willdurand/negotiation": "^2.0.3"
2740
},
2841
"require-dev": {
29-
"symfony/http-foundation": "^3.1@dev || ^4.0@dev",
30-
3142
"behat/behat": "^3.1",
3243
"behat/mink": "^1.7",
3344
"behat/mink-browserkit-driver": "^1.3.1",
@@ -60,6 +71,9 @@
6071
"symfony/finder": "^3.3 || ^4.0",
6172
"symfony/form": "^3.3 || ^4.0",
6273
"symfony/framework-bundle": "^3.3 || ^4.0",
74+
"symfony/mercure": "*",
75+
"symfony/mercure-bundle": "*",
76+
"symfony/messenger": "^4.1",
6377
"symfony/phpunit-bridge": "^3.3 || ^4.0",
6478
"symfony/routing": "^3.3 || ^4.0",
6579
"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
@@ -39,6 +39,7 @@
3939
* @Attribute("iri", type="string"),
4040
* @Attribute("itemOperations", type="array"),
4141
* @Attribute("maximumItemsPerPage", type="int"),
42+
* @Attribute("mercure", type="mixed"),
4243
* @Attribute("normalizationContext", type="array"),
4344
* @Attribute("order", type="array"),
4445
* @Attribute("paginationClientEnabled", type="bool"),
@@ -165,6 +166,13 @@ final class ApiResource
165166
*/
166167
private $maximumItemsPerPage;
167168

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

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

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

149150
/**
@@ -521,4 +522,16 @@ private function registerDataCollector(ContainerBuilder $container, array $confi
521522

522523
$loader->load('data_collector.xml');
523524
}
525+
526+
private function registerMercureConfiguration(ContainerBuilder $container, XmlFileLoader $loader, bool $useDoctrine)
527+
{
528+
if (!$container->hasParameter('mercure.default_hub')) {
529+
return;
530+
}
531+
532+
$loader->load('mercure.xml');
533+
if ($useDoctrine) {
534+
$loader->load('doctrine_orm_mercure_publisher.xml');
535+
}
536+
}
524537
}
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="doctrine.event_listener" event="postFlush" />
21+
</service>
22+
23+
</services>
24+
25+
</container>
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
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+
<argument>%mercure.default_hub%</argument>
13+
14+
<tag name="kernel.event_listener" event="kernel.response" method="onKernelResponse" />
15+
</service>
16+
</services>
17+
</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)