-
-
Notifications
You must be signed in to change notification settings - Fork 914
Automatically dispatch updates to clients using the Mercure protocol #2282
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
336e472
a5a3948
656ff13
d5fd181
b80f8d6
a795a62
023114d
2f4c171
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,13 @@ | ||
Feature: Mercure discovery support | ||
In order to let the client discovering the Mercure hub | ||
As a client software developer | ||
I need to retrieve the hub URL through a Link HTTP header | ||
|
||
@createSchema | ||
Scenario: Checks that the Mercure Link is added | ||
Given I send a "GET" request to "/dummy_mercures" | ||
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"' | ||
|
||
Scenario: Checks that the Mercure Link is not added on endpoints where updates are not dispatched | ||
Given I send a "GET" request to "/" | ||
Then the header "Link" should be equal to '<http://example.com/docs.jsonld>; rel="http://www.w3.org/ns/hydra/core#apiDocumentation"' |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,178 @@ | ||
<?php | ||
|
||
/* | ||
* This file is part of the API Platform project. | ||
* | ||
* (c) Kévin Dunglas <[email protected]> | ||
* | ||
* For the full copyright and license information, please view the LICENSE | ||
* file that was distributed with this source code. | ||
*/ | ||
|
||
declare(strict_types=1); | ||
|
||
namespace ApiPlatform\Core\Bridge\Doctrine\EventListener; | ||
|
||
use ApiPlatform\Core\Api\IriConverterInterface; | ||
use ApiPlatform\Core\Api\ResourceClassResolverInterface; | ||
use ApiPlatform\Core\Api\UrlGeneratorInterface; | ||
use ApiPlatform\Core\Exception\InvalidArgumentException; | ||
use ApiPlatform\Core\Exception\RuntimeException; | ||
use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; | ||
use ApiPlatform\Core\Util\ClassInfoTrait; | ||
use Doctrine\ORM\Event\OnFlushEventArgs; | ||
use Symfony\Component\ExpressionLanguage\ExpressionLanguage; | ||
use Symfony\Component\Mercure\Update; | ||
use Symfony\Component\Messenger\MessageBusInterface; | ||
use Symfony\Component\Serializer\SerializerInterface; | ||
|
||
/** | ||
* Publishes resources updates to the Mercure hub. | ||
* | ||
* @author Kévin Dunglas <[email protected]> | ||
* | ||
* @experimental | ||
*/ | ||
final class PublishMercureUpdatesListener | ||
{ | ||
use ClassInfoTrait; | ||
|
||
private $resourceClassResolver; | ||
private $iriConverter; | ||
private $resourceMetadataFactory; | ||
private $serializer; | ||
private $messageBus; | ||
private $publisher; | ||
private $expressionLanguage; | ||
private $createdEntities; | ||
private $updatedEntities; | ||
private $deletedEntities; | ||
|
||
public function __construct(ResourceClassResolverInterface $resourceClassResolver, IriConverterInterface $iriConverter, ResourceMetadataFactoryInterface $resourceMetadataFactory, SerializerInterface $serializer, MessageBusInterface $messageBus = null, callable $publisher = null, ExpressionLanguage $expressionLanguage = null) | ||
{ | ||
if (null === $messageBus && null === $publisher) { | ||
throw new InvalidArgumentException('A message bus or a publisher must be provided.'); | ||
} | ||
|
||
$this->resourceClassResolver = $resourceClassResolver; | ||
$this->iriConverter = $iriConverter; | ||
$this->resourceMetadataFactory = $resourceMetadataFactory; | ||
$this->serializer = $serializer; | ||
$this->messageBus = $messageBus; | ||
$this->publisher = $publisher; | ||
$this->expressionLanguage = $expressionLanguage ?? class_exists(ExpressionLanguage::class) ? new ExpressionLanguage() : null; | ||
$this->reset(); | ||
} | ||
|
||
/** | ||
* Collects created, updated and deleted entities. | ||
*/ | ||
public function onFlush(OnFlushEventArgs $eventArgs) | ||
{ | ||
$uow = $eventArgs->getEntityManager()->getUnitOfWork(); | ||
|
||
foreach ($uow->getScheduledEntityInsertions() as $entity) { | ||
$this->storeEntityToPublish($entity, 'createdEntities'); | ||
} | ||
|
||
foreach ($uow->getScheduledEntityUpdates() as $entity) { | ||
dunglas marked this conversation as resolved.
Show resolved
Hide resolved
|
||
$this->storeEntityToPublish($entity, 'updatedEntities'); | ||
} | ||
|
||
foreach ($uow->getScheduledEntityDeletions() as $entity) { | ||
$this->storeEntityToPublish($entity, 'deletedEntities'); | ||
} | ||
} | ||
|
||
/** | ||
* Publishes updates for changes collected on flush, and resets the store. | ||
*/ | ||
public function postFlush() | ||
dunglas marked this conversation as resolved.
Show resolved
Hide resolved
|
||
{ | ||
try { | ||
foreach ($this->createdEntities as $entity) { | ||
$this->publishUpdate($entity, $this->createdEntities[$entity]); | ||
} | ||
|
||
foreach ($this->updatedEntities as $entity) { | ||
$this->publishUpdate($entity, $this->updatedEntities[$entity]); | ||
} | ||
|
||
foreach ($this->deletedEntities as $entity) { | ||
$this->publishUpdate($entity, $this->deletedEntities[$entity]); | ||
} | ||
} finally { | ||
$this->reset(); | ||
} | ||
} | ||
|
||
private function reset(): void | ||
{ | ||
$this->createdEntities = new \SplObjectStorage(); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. is this the same as There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. indeed, I was wondering if there would be any different from a memory/gc point of view. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No idea :) I would keep it as is for now because it's "safer", but if your suggestion improve GC or something similar, let me know and I'll update! |
||
$this->updatedEntities = new \SplObjectStorage(); | ||
$this->deletedEntities = new \SplObjectStorage(); | ||
} | ||
|
||
/** | ||
* @param object $entity | ||
*/ | ||
private function storeEntityToPublish($entity, string $property): void | ||
{ | ||
$resourceClass = $this->getObjectClass($entity); | ||
if (!$this->resourceClassResolver->isResourceClass($resourceClass)) { | ||
return; | ||
} | ||
|
||
$value = $this->resourceMetadataFactory->create($resourceClass)->getAttribute('mercure', false); | ||
if (false === $value) { | ||
return; | ||
} | ||
|
||
if (\is_string($value)) { | ||
if (null === $this->expressionLanguage) { | ||
throw new RuntimeException('The Expression Language component is not installed. Try running "composer require symfony/expression-language".'); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. is this the proper place to throw this, or can it be done in the metadata factory somehow? |
||
} | ||
|
||
$value = $this->expressionLanguage->evaluate($value, ['object' => $entity]); | ||
} | ||
|
||
if (true === $value) { | ||
$value = []; | ||
} | ||
|
||
if (!\is_array($value)) { | ||
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))); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. as above, proper place for this? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We currently don't handle metadata validation in the factory, but it would definitely be a better place There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. would be nice huh!? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes. I'll keep it as is because it out of this PR's scope, and I'll try to add a proper validator (for all existing attributes) in a subsequent PR. Is it ok for you? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. of course! sorry, my There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I understood it like that no worries :) |
||
} | ||
|
||
if ('deletedEntities' === $property) { | ||
$this->deletedEntities[(object) [ | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. can we transform this to a real class? We had issues in the past with StdClass and it's better for debugging. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Does it worth it? It's a purely internal state. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. https://github.com/api-platform/core/pull/1780/files this was kinda the same, internal state but eventually it hit a bug :p. As you want. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ooo. mine! that was for anonymous classes, not There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It was in the serialization context, and indeed an anonymous class. |
||
'id' => $this->iriConverter->getIriFromItem($entity), | ||
'iri' => $this->iriConverter->getIriFromItem($entity, UrlGeneratorInterface::ABS_URL), | ||
]] = $value; | ||
|
||
return; | ||
} | ||
|
||
$this->$property[$entity] = $value; | ||
} | ||
|
||
/** | ||
* @param object|string $entity | ||
*/ | ||
private function publishUpdate($entity, array $targets): void | ||
{ | ||
if ($entity instanceof \stdClass) { | ||
// By convention, if the entity has been deleted, we send only its IRI | ||
// This may change in the feature, because it's not JSON Merge Patch compliant, | ||
// and I'm not a fond of this approach | ||
$iri = $entity->iri; | ||
$data = json_encode(['@id' => $entity->id]); | ||
} else { | ||
$iri = $this->iriConverter->getIriFromItem($entity, UrlGeneratorInterface::ABS_URL); | ||
$data = $this->serializer->serialize($entity, 'jsonld'); | ||
} | ||
|
||
$update = new Update($iri, $data, $targets); | ||
$this->messageBus ? $this->messageBus->dispatch($update) : ($this->publisher)($update); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,25 @@ | ||
<?xml version="1.0" ?> | ||
|
||
<container xmlns="http://symfony.com/schema/dic/services" | ||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" | ||
xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd"> | ||
|
||
<services> | ||
|
||
<!-- Event listener --> | ||
|
||
<service id="api_platform.doctrine.listener.mercure.publish" class="ApiPlatform\Core\Bridge\Doctrine\EventListener\PublishMercureUpdatesListener"> | ||
<argument type="service" id="api_platform.resource_class_resolver" /> | ||
<argument type="service" id="api_platform.iri_converter" /> | ||
<argument type="service" id="api_platform.metadata.resource.metadata_factory" /> | ||
<argument type="service" id="api_platform.serializer" /> | ||
<argument type="service" id="message_bus" on-invalid="ignore" /> | ||
<argument type="service" id="mercure.hub.default.publisher" /> | ||
|
||
<tag name="doctrine.event_listener" event="onFlush" /> | ||
<tag name="kernel.event_listener" event="kernel.terminate" method="postFlush" /> | ||
</service> | ||
|
||
</services> | ||
|
||
</container> |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,16 @@ | ||
<?xml version="1.0" ?> | ||
|
||
<container xmlns="http://symfony.com/schema/dic/services" | ||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" | ||
xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd"> | ||
|
||
<services> | ||
<!-- Event listener --> | ||
|
||
<service id="api_platform.mercure.listener.response.add_link_header" class="ApiPlatform\Core\Mercure\EventListener\AddLinkHeaderListener"> | ||
<argument type="service" id="api_platform.metadata.resource.metadata_factory" /> | ||
|
||
<tag name="kernel.event_listener" event="kernel.response" method="onKernelResponse" /> | ||
</service> | ||
</services> | ||
</container> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
possibly ensure only 1 is provided?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It would make the wiring a bit harder.