Skip to content

Commit

Permalink
Support for Mercure 0.10 (#3584)
Browse files Browse the repository at this point in the history
* Support for Mercure 0.10

* Fix linters

* Fix testPublishUpdate
  • Loading branch information
dunglas authored May 28, 2020
1 parent 2d0d948 commit ad0d2b1
Show file tree
Hide file tree
Showing 3 changed files with 144 additions and 23 deletions.
66 changes: 49 additions & 17 deletions src/Bridge/Doctrine/EventListener/PublishMercureUpdatesListener.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,19 @@
*/
final class PublishMercureUpdatesListener
{
private const ALLOWED_KEYS = [
'topics' => true,
'data' => true,
'private' => true,
'id' => true,
'type' => true,
'retry' => true,
];

use DispatchTrait;
use ResourceClassInfoTrait;

private $iriConverter;
private $resourceMetadataFactory;
private $serializer;
private $publisher;
private $expressionLanguage;
Expand Down Expand Up @@ -127,60 +135,84 @@ private function storeEntityToPublish($entity, string $property): void
return;
}

$value = $this->resourceMetadataFactory->create($resourceClass)->getAttribute('mercure', false);
if (false === $value) {
$options = $this->resourceMetadataFactory->create($resourceClass)->getAttribute('mercure', false);
if (false === $options) {
return;
}

if (\is_string($value)) {
if (\is_string($options)) {
if (null === $this->expressionLanguage) {
throw new RuntimeException('The Expression Language component is not installed. Try running "composer require symfony/expression-language".');
}

$value = $this->expressionLanguage->evaluate($value, ['object' => $entity]);
$options = $this->expressionLanguage->evaluate($options, ['object' => $entity]);
}

if (true === $value) {
$value = [];
if (true === $options) {
$options = [];
}

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)));
if (!\is_array($options)) {
throw new InvalidArgumentException(sprintf('The value of the "mercure" attribute of the "%s" resource class must be a boolean, an array of options or an expression returning this array, "%s" given.', $resourceClass, \gettype($options)));
}

foreach ($options as $key => $value) {
if (0 === $key) {
if (method_exists(Update::class, 'isPrivate')) {
throw new \InvalidArgumentException('Targets do not exist anymore since Mercure 0.10. Mark the update as private instead or downgrade the Mercure Component to version 0.3');
}

@trigger_error('Targets do not exist anymore since Mercure 0.10. Mark the update as private instead.', E_USER_DEPRECATED);
break;
}

if (!isset(self::ALLOWED_KEYS[$key])) {
throw new InvalidArgumentException(sprintf('The option "%s" set in the "mercure" attribute of the "%s" resource does not exist. Existing options: "%s"', $key, $resourceClass, implode('", "', self::ALLOWED_KEYS)));
}
}

if ('deletedEntities' === $property) {
$this->deletedEntities[(object) [
'id' => $this->iriConverter->getIriFromItem($entity),
'iri' => $this->iriConverter->getIriFromItem($entity, UrlGeneratorInterface::ABS_URL),
]] = $value;
]] = $options;

return;
}

$this->{$property}[$entity] = $value;
$this->{$property}[$entity] = $options;
}

/**
* @param object $entity
*/
private function publishUpdate($entity, array $targets): void
private function publishUpdate($entity, array $options): 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;
$iri = $options['topics'] ?? $entity->iri;
/** @var string $data */
$data = json_encode(['@id' => $entity->id]);
$data = $options['data'] ?? json_encode(['@id' => $entity->id]);
} else {
$resourceClass = $this->getObjectClass($entity);
$context = $this->resourceMetadataFactory->create($resourceClass)->getAttribute('normalization_context', []);

$iri = $this->iriConverter->getIriFromItem($entity, UrlGeneratorInterface::ABS_URL);
$data = $this->serializer->serialize($entity, key($this->formats), $context);
$iri = $options['topics'] ?? $this->iriConverter->getIriFromItem($entity, UrlGeneratorInterface::ABS_URL);
$data = $options['data'] ?? $this->serializer->serialize($entity, key($this->formats), $context);
}

$update = new Update($iri, $data, $targets);
if (method_exists(Update::class, 'isPrivate')) {
$update = new Update($iri, $data, $options['private'] ?? false, $options['id'] ?? null, $options['type'] ?? null, $options['retry'] ?? null);
} else {
/**
* Mercure Component < 0.4.
*
* @phpstan-ignore-next-line
*/
$update = new Update($iri, $data, $options);
}
$this->messageBus ? $this->dispatch($update) : ($this->publisher)($update);
}
}
4 changes: 2 additions & 2 deletions tests/Annotation/ApiResourceTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ public function testConstruct()
'input' => 'Foo',
'iri' => 'http://example.com/res',
'itemOperations' => ['foo' => ['bar']],
'mercure' => '[\'foo\', object.owner]',
'mercure' => ['private' => true],
'messenger' => true,
'normalizationContext' => ['groups' => ['bar']],
'order' => ['foo', 'bar' => 'ASC'],
Expand Down Expand Up @@ -85,7 +85,7 @@ public function testConstruct()
'formats' => ['foo', 'bar' => ['application/bar']],
'filters' => ['foo', 'bar'],
'input' => 'Foo',
'mercure' => '[\'foo\', object.owner]',
'mercure' => ['private' => true],
'messenger' => true,
'normalization_context' => ['groups' => ['bar']],
'order' => ['foo', 'bar' => 'ASC'],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,12 @@
*/
class PublishMercureUpdatesListenerTest extends TestCase
{
public function testPublishUpdate()
public function testLegacyPublishUpdate(): void
{
if (method_exists(Update::class, 'isPrivate')) {
$this->markTestSkipped();
}

$toInsert = new Dummy();
$toInsert->setId(1);
$toInsertNotResource = new NotAResource('foo', 'bar');
Expand Down Expand Up @@ -84,7 +88,7 @@ public function testPublishUpdate()
$targets = [];
$publisher = function (Update $update) use (&$topics, &$targets): string {
$topics = array_merge($topics, $update->getTopics());
$targets[] = $update->getTargets();
$targets[] = $update->getTargets(); // @phpstan-ignore-line

return 'id';
};
Expand Down Expand Up @@ -115,7 +119,92 @@ public function testPublishUpdate()
$this->assertSame([[], [], [], ['foo', 'bar']], $targets);
}

public function testNoPublisher()
public function testPublishUpdate(): void
{
if (!method_exists(Update::class, 'isPrivate')) {
$this->markTestSkipped();
}

$toInsert = new Dummy();
$toInsert->setId(1);
$toInsertNotResource = new NotAResource('foo', 'bar');

$toUpdate = new Dummy();
$toUpdate->setId(2);
$toUpdateNoMercureAttribute = new DummyCar();

$toDelete = new Dummy();
$toDelete->setId(3);
$toDeleteExpressionLanguage = new DummyFriend();
$toDeleteExpressionLanguage->setId(4);

$resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class);
$resourceClassResolverProphecy->getResourceClass(Argument::type(Dummy::class))->willReturn(Dummy::class);
$resourceClassResolverProphecy->getResourceClass(Argument::type(DummyCar::class))->willReturn(DummyCar::class);
$resourceClassResolverProphecy->getResourceClass(Argument::type(DummyFriend::class))->willReturn(DummyFriend::class);
$resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true);
$resourceClassResolverProphecy->isResourceClass(NotAResource::class)->willReturn(false);
$resourceClassResolverProphecy->isResourceClass(DummyCar::class)->willReturn(true);
$resourceClassResolverProphecy->isResourceClass(DummyFriend::class)->willReturn(true);

$iriConverterProphecy = $this->prophesize(IriConverterInterface::class);
$iriConverterProphecy->getIriFromItem($toInsert, UrlGeneratorInterface::ABS_URL)->willReturn('http://example.com/dummies/1')->shouldBeCalled();
$iriConverterProphecy->getIriFromItem($toUpdate, UrlGeneratorInterface::ABS_URL)->willReturn('http://example.com/dummies/2')->shouldBeCalled();
$iriConverterProphecy->getIriFromItem($toDelete, UrlGeneratorInterface::ABS_URL)->willReturn('http://example.com/dummies/3')->shouldBeCalled();
$iriConverterProphecy->getIriFromItem($toDelete)->willReturn('/dummies/3')->shouldBeCalled();
$iriConverterProphecy->getIriFromItem($toDeleteExpressionLanguage)->willReturn('/dummy_friends/4')->shouldBeCalled();
$iriConverterProphecy->getIriFromItem($toDeleteExpressionLanguage, UrlGeneratorInterface::ABS_URL)->willReturn('http://example.com/dummy_friends/4')->shouldBeCalled();

$resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class);
$resourceMetadataFactoryProphecy->create(Dummy::class)->willReturn(new ResourceMetadata(null, null, null, null, null, ['mercure' => true, 'normalization_context' => ['groups' => ['foo', 'bar']]]));
$resourceMetadataFactoryProphecy->create(DummyCar::class)->willReturn(new ResourceMetadata());
$resourceMetadataFactoryProphecy->create(DummyFriend::class)->willReturn(new ResourceMetadata(null, null, null, null, null, ['mercure' => ['private' => true, 'retry' => 10]]));

$serializerProphecy = $this->prophesize(SerializerInterface::class);
$serializerProphecy->serialize($toInsert, 'jsonld', ['groups' => ['foo', 'bar']])->willReturn('1');
$serializerProphecy->serialize($toUpdate, 'jsonld', ['groups' => ['foo', 'bar']])->willReturn('2');

$formats = ['jsonld' => ['application/ld+json'], 'jsonhal' => ['application/hal+json']];

$topics = [];
$private = [];
$retry = [];
$publisher = function (Update $update) use (&$topics, &$private, &$retry): string {
$topics = array_merge($topics, $update->getTopics());
$private[] = $update->isPrivate();
$retry[] = $update->getRetry();

return 'id';
};

$listener = new PublishMercureUpdatesListener(
$resourceClassResolverProphecy->reveal(),
$iriConverterProphecy->reveal(),
$resourceMetadataFactoryProphecy->reveal(),
$serializerProphecy->reveal(),
$formats,
null,
$publisher
);

$uowProphecy = $this->prophesize(UnitOfWork::class);
$uowProphecy->getScheduledEntityInsertions()->willReturn([$toInsert, $toInsertNotResource])->shouldBeCalled();
$uowProphecy->getScheduledEntityUpdates()->willReturn([$toUpdate, $toUpdateNoMercureAttribute])->shouldBeCalled();
$uowProphecy->getScheduledEntityDeletions()->willReturn([$toDelete, $toDeleteExpressionLanguage])->shouldBeCalled();

$emProphecy = $this->prophesize(EntityManagerInterface::class);
$emProphecy->getUnitOfWork()->willReturn($uowProphecy->reveal())->shouldBeCalled();
$eventArgs = new OnFlushEventArgs($emProphecy->reveal());

$listener->onFlush($eventArgs);
$listener->postFlush();

$this->assertSame(['http://example.com/dummies/1', 'http://example.com/dummies/2', 'http://example.com/dummies/3', 'http://example.com/dummy_friends/4'], $topics);
$this->assertSame([false, false, false, true], $private);
$this->assertSame([null, null, null, 10], $retry);
}

public function testNoPublisher(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('A message bus or a publisher must be provided.');
Expand All @@ -134,7 +223,7 @@ public function testNoPublisher()
public function testInvalidMercureAttribute()
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('The value of the "mercure" attribute of the "ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy" resource class must be a boolean, an array of targets or a valid expression, "integer" given.');
$this->expectExceptionMessage('The value of the "mercure" attribute of the "ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy" resource class must be a boolean, an array of options or an expression returning this array, "integer" given.');

$toInsert = new Dummy();

Expand Down

0 comments on commit ad0d2b1

Please sign in to comment.