diff --git a/composer.json b/composer.json index bb6a7fc6322..1281e452653 100644 --- a/composer.json +++ b/composer.json @@ -23,6 +23,7 @@ "symfony/property-access": "^2.7 || ^3.0 || ^4.0", "symfony/property-info": "^3.3.11 || ^4.0", "symfony/serializer": "^4.1", + "symfony/web-link": "^4.1", "willdurand/negotiation": "^2.0.3" }, "require-dev": { @@ -58,6 +59,9 @@ "symfony/finder": "^3.3 || ^4.0", "symfony/form": "^3.3 || ^4.0", "symfony/framework-bundle": "^3.3 || ^4.0", + "symfony/mercure": "*", + "symfony/mercure-bundle": "*", + "symfony/messenger": "^4.1", "symfony/phpunit-bridge": "^3.3 || ^4.0", "symfony/routing": "^3.3 || ^4.0", "symfony/security": "^3.0 || ^4.0", diff --git a/features/mercure/discover.feature b/features/mercure/discover.feature new file mode 100644 index 00000000000..d3747ccb6d6 --- /dev/null +++ b/features/mercure/discover.feature @@ -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 '; rel="http://www.w3.org/ns/hydra/core#apiDocumentation",; 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 '; rel="http://www.w3.org/ns/hydra/core#apiDocumentation"' diff --git a/src/Annotation/ApiResource.php b/src/Annotation/ApiResource.php index 6b221c36c30..fb7cbe5537a 100644 --- a/src/Annotation/ApiResource.php +++ b/src/Annotation/ApiResource.php @@ -41,6 +41,7 @@ * @Attribute("iri", type="string"), * @Attribute("itemOperations", type="array"), * @Attribute("maximumItemsPerPage", type="int"), + * @Attribute("mercure", type="mixed"), * @Attribute("normalizationContext", type="array"), * @Attribute("order", type="array"), * @Attribute("outputClass", type="string"), @@ -175,6 +176,13 @@ final class ApiResource */ private $maximumItemsPerPage; + /** + * @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112 + * + * @var mixed + */ + private $mercure; + /** * @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112 * diff --git a/src/Bridge/Doctrine/EventListener/PublishMercureUpdatesListener.php b/src/Bridge/Doctrine/EventListener/PublishMercureUpdatesListener.php new file mode 100644 index 00000000000..255dac5e149 --- /dev/null +++ b/src/Bridge/Doctrine/EventListener/PublishMercureUpdatesListener.php @@ -0,0 +1,178 @@ + + * + * 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 + * + * @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) { + $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() + { + 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(); + $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".'); + } + + $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))); + } + + if ('deletedEntities' === $property) { + $this->deletedEntities[(object) [ + '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); + } +} diff --git a/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php b/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php index 614f75b67c7..42f51256035 100644 --- a/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -144,6 +144,7 @@ public function load(array $configs, ContainerBuilder $container) $this->registerHttpCacheConfiguration($container, $config, $loader, $useDoctrine); $this->registerValidatorConfiguration($container, $config); $this->registerDataCollectorConfiguration($container, $config, $loader); + $this->registerMercureConfiguration($container, $config, $loader, $useDoctrine); } /** @@ -525,4 +526,18 @@ private function registerDataCollectorConfiguration(ContainerBuilder $container, $loader->load('debug.xml'); } } + + private function registerMercureConfiguration(ContainerBuilder $container, array $config, XmlFileLoader $loader, bool $useDoctrine) + { + if (!$config['mercure']['enabled'] || !$container->hasParameter('mercure.default_hub')) { + return; + } + + $loader->load('mercure.xml'); + $container->getDefinition('api_platform.mercure.listener.response.add_link_header')->addArgument($config['mercure']['hub_url'] ?? '%mercure.default_hub%'); + + if ($useDoctrine) { + $loader->load('doctrine_orm_mercure_publisher.xml'); + } + } } diff --git a/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php b/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php index 55b8401621a..1c82ab3f812 100644 --- a/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php +++ b/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php @@ -23,6 +23,7 @@ use Symfony\Component\Config\Definition\ConfigurationInterface; use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Mercure\Update; use Symfony\Component\Serializer\Exception\ExceptionInterface; /** @@ -218,6 +219,13 @@ public function getConfigTreeBuilder() ->end() ->end() + ->arrayNode('mercure') + ->{class_exists(Update::class) ? 'canBeDisabled' : 'canBeEnabled'}() + ->children() + ->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.') + ->end() + ->end() + ->end(); $this->addExceptionToStatusSection($rootNode); diff --git a/src/Bridge/Symfony/Bundle/Resources/config/doctrine_orm_mercure_publisher.xml b/src/Bridge/Symfony/Bundle/Resources/config/doctrine_orm_mercure_publisher.xml new file mode 100644 index 00000000000..450a6813d96 --- /dev/null +++ b/src/Bridge/Symfony/Bundle/Resources/config/doctrine_orm_mercure_publisher.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Bridge/Symfony/Bundle/Resources/config/mercure.xml b/src/Bridge/Symfony/Bundle/Resources/config/mercure.xml new file mode 100644 index 00000000000..be332409e44 --- /dev/null +++ b/src/Bridge/Symfony/Bundle/Resources/config/mercure.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + diff --git a/src/Hydra/EventListener/AddLinkHeaderListener.php b/src/Hydra/EventListener/AddLinkHeaderListener.php index 1138f59e699..81be5408e33 100644 --- a/src/Hydra/EventListener/AddLinkHeaderListener.php +++ b/src/Hydra/EventListener/AddLinkHeaderListener.php @@ -15,6 +15,8 @@ use ApiPlatform\Core\Api\UrlGeneratorInterface; use ApiPlatform\Core\JsonLd\ContextBuilder; +use Fig\Link\GenericLinkProvider; +use Fig\Link\Link; use Symfony\Component\HttpKernel\Event\FilterResponseEvent; /** @@ -36,9 +38,15 @@ public function __construct(UrlGeneratorInterface $urlGenerator) */ public function onKernelResponse(FilterResponseEvent $event) { - $event->getResponse()->headers->set('Link', sprintf( - '<%s>; rel="%sapiDocumentation"', - $this->urlGenerator->generate('api_doc', ['_format' => 'jsonld'], UrlGeneratorInterface::ABS_URL), ContextBuilder::HYDRA_NS) - ); + $apiDocUrl = $this->urlGenerator->generate('api_doc', ['_format' => 'jsonld'], UrlGeneratorInterface::ABS_URL); + $link = new Link(ContextBuilder::HYDRA_NS.'apiDocumentation', $apiDocUrl); + + $attributes = $event->getRequest()->attributes; + if (null === $linkProvider = $attributes->get('_links')) { + $attributes->set('_links', new GenericLinkProvider([$link])); + + return; + } + $attributes->set('_links', $linkProvider->withLink($link)); } } diff --git a/src/Mercure/EventListener/AddLinkHeaderListener.php b/src/Mercure/EventListener/AddLinkHeaderListener.php new file mode 100644 index 00000000000..48f1f7d25e2 --- /dev/null +++ b/src/Mercure/EventListener/AddLinkHeaderListener.php @@ -0,0 +1,60 @@ + + * + * 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\Mercure\EventListener; + +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; +use Fig\Link\GenericLinkProvider; +use Fig\Link\Link; +use Symfony\Component\HttpKernel\Event\FilterResponseEvent; + +/** + * Adds the HTTP Link header pointing to the Mercure hub for resources having their updates dispatched. + * + * @author Kévin Dunglas + */ +final class AddLinkHeaderListener +{ + private $resourceMetadataFactory; + private $hub; + + public function __construct(ResourceMetadataFactoryInterface $resourceMetadataFactory, string $hub) + { + $this->resourceMetadataFactory = $resourceMetadataFactory; + $this->hub = $hub; + } + + /** + * Sends the Mercure header on each response. + */ + public function onKernelResponse(FilterResponseEvent $event) + { + $link = new Link('mercure', $this->hub); + + $attributes = $event->getRequest()->attributes; + if ( + null === ($resourceClass = $attributes->get('_api_resource_class')) || + false === $this->resourceMetadataFactory->create($resourceClass)->getAttribute('mercure', false) + ) { + return; + } + + if (null === $linkProvider = $attributes->get('_links')) { + $attributes->set('_links', new GenericLinkProvider([$link])); + + return; + } + + $attributes->set('_links', $linkProvider->withLink($link)); + } +} diff --git a/tests/Annotation/ApiResourceTest.php b/tests/Annotation/ApiResourceTest.php index eb918012d00..0356cdece8f 100644 --- a/tests/Annotation/ApiResourceTest.php +++ b/tests/Annotation/ApiResourceTest.php @@ -41,6 +41,7 @@ public function testConstruct() 'iri' => 'http://example.com/res', 'itemOperations' => ['foo' => ['bar']], 'maximumItemsPerPage' => 42, + 'mercure' => '[\'foo\', object.owner]', 'normalizationContext' => ['groups' => ['bar']], 'order' => ['foo', 'bar' => 'ASC'], 'paginationClientEnabled' => true, @@ -74,6 +75,7 @@ public function testConstruct() 'formats' => ['foo', 'bar' => ['application/bar']], 'filters' => ['foo', 'bar'], 'maximum_items_per_page' => 42, + 'mercure' => '[\'foo\', object.owner]', 'normalization_context' => ['groups' => ['bar']], 'order' => ['foo', 'bar' => 'ASC'], 'pagination_client_enabled' => true, diff --git a/tests/Bridge/Doctrine/EventListener/PublishMercureUpdatesListenerTest.php b/tests/Bridge/Doctrine/EventListener/PublishMercureUpdatesListenerTest.php new file mode 100644 index 00000000000..72484274197 --- /dev/null +++ b/tests/Bridge/Doctrine/EventListener/PublishMercureUpdatesListenerTest.php @@ -0,0 +1,164 @@ + + * + * 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\Tests\Bridge\Doctrine\EventListener; + +use ApiPlatform\Core\Api\IriConverterInterface; +use ApiPlatform\Core\Api\ResourceClassResolverInterface; +use ApiPlatform\Core\Api\UrlGeneratorInterface; +use ApiPlatform\Core\Bridge\Doctrine\EventListener\PublishMercureUpdatesListener; +use ApiPlatform\Core\Exception\InvalidArgumentException; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; +use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; +use ApiPlatform\Core\Tests\Fixtures\NotAResource; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyCar; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyFriend; +use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM\Event\OnFlushEventArgs; +use Doctrine\ORM\UnitOfWork; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Mercure\Update; +use Symfony\Component\Serializer\SerializerInterface; + +/** + * @author Kévin Dunglas + */ +class PublishMercureUpdatesListenerTest extends TestCase +{ + public function testPublishUpdate() + { + $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->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])); + $resourceMetadataFactoryProphecy->create(DummyCar::class)->willReturn(new ResourceMetadata()); + $resourceMetadataFactoryProphecy->create(DummyFriend::class)->willReturn(new ResourceMetadata(null, null, null, null, null, ['mercure' => "['foo', 'bar']"])); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + $serializerProphecy->serialize($toInsert, 'jsonld')->willReturn('1'); + $serializerProphecy->serialize($toUpdate, 'jsonld')->willReturn('2'); + + $topics = []; + $targets = []; + $publisher = function (Update $update) use (&$topics, &$targets): string { + $topics = array_merge($topics, $update->getTopics()); + $targets[] = $update->getTargets(); + + return 'id'; + }; + + $listener = new PublishMercureUpdatesListener( + $resourceClassResolverProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceMetadataFactoryProphecy->reveal(), + $serializerProphecy->reveal(), + 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([[], [], [], ['foo', 'bar']], $targets); + } + + public function testNoPublisher() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('A message bus or a publisher must be provided.'); + + $listener = new PublishMercureUpdatesListener( + $this->prophesize(ResourceClassResolverInterface::class)->reveal(), + $this->prophesize(IriConverterInterface::class)->reveal(), + $this->prophesize(ResourceMetadataFactoryInterface::class)->reveal(), + $this->prophesize(SerializerInterface::class)->reveal(), + null, + null + ); + } + + 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.'); + + $toInsert = new Dummy(); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + $resourceMetadataFactoryProphecy->create(Dummy::class)->willReturn(new ResourceMetadata(null, null, null, null, null, ['mercure' => 1])); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + + $listener = new PublishMercureUpdatesListener( + $resourceClassResolverProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceMetadataFactoryProphecy->reveal(), + $serializerProphecy->reveal(), + null, + function (Update $update): string { + return 'will never be called'; + } + ); + + $uowProphecy = $this->prophesize(UnitOfWork::class); + $uowProphecy->getScheduledEntityInsertions()->willReturn([$toInsert])->shouldBeCalled(); + + $emProphecy = $this->prophesize(EntityManagerInterface::class); + $emProphecy->getUnitOfWork()->willReturn($uowProphecy->reveal())->shouldBeCalled(); + $eventArgs = new OnFlushEventArgs($emProphecy->reveal()); + + $listener->onFlush($eventArgs); + //$listener->postFlush(); + } +} diff --git a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php index c8a395a24cb..c63979b3bd7 100644 --- a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php +++ b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php @@ -763,6 +763,8 @@ private function getBaseContainerBuilderProphecy() 'api_platform.http_cache.purger.varnish_client', 'api_platform.http_cache.listener.response.add_tags', 'api_platform.validator', + 'api_platform.mercure.listener.response.add_link_header', + 'api_platform.doctrine.listener.mercure.publish', ]; foreach ($definitions as $definition) { @@ -785,6 +787,9 @@ private function getBaseContainerBuilderProphecy() $containerBuilderProphecy->hasParameter('api_platform.metadata_cache')->willReturn(true)->shouldBeCalled(); $containerBuilderProphecy->getParameter('api_platform.metadata_cache')->willReturn(true)->shouldBeCalled(); + $containerBuilderProphecy->hasParameter('mercure.default_hub')->willReturn(true)->shouldBeCalled(); + + $containerBuilderProphecy->getDefinition('api_platform.mercure.listener.response.add_link_header')->willReturn(new Definition()); return $containerBuilderProphecy; } diff --git a/tests/Bridge/Symfony/Bundle/DependencyInjection/ConfigurationTest.php b/tests/Bridge/Symfony/Bundle/DependencyInjection/ConfigurationTest.php index f5edb651f3c..a44f4161a44 100644 --- a/tests/Bridge/Symfony/Bundle/DependencyInjection/ConfigurationTest.php +++ b/tests/Bridge/Symfony/Bundle/DependencyInjection/ConfigurationTest.php @@ -143,6 +143,10 @@ public function testDefaultConfig() 'vary' => ['Accept'], 'public' => null, ], + 'mercure' => [ + 'enabled' => true, + 'hub_url' => null, + ], 'allow_plain_identifiers' => false, 'resource_class_directories' => [], ], $config); diff --git a/tests/Fixtures/DummyMercurePublisher.php b/tests/Fixtures/DummyMercurePublisher.php new file mode 100644 index 00000000000..efed15a6900 --- /dev/null +++ b/tests/Fixtures/DummyMercurePublisher.php @@ -0,0 +1,24 @@ + + * + * 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\Tests\Fixtures; + +use Symfony\Component\Mercure\Update; + +class DummyMercurePublisher +{ + public function __invoke(Update $update): string + { + return 'dummy'; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/DummyMercure.php b/tests/Fixtures/TestBundle/Entity/DummyMercure.php new file mode 100644 index 00000000000..a6190fba197 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/DummyMercure.php @@ -0,0 +1,32 @@ + + * + * 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\Tests\Fixtures\TestBundle\Entity; + +use ApiPlatform\Core\Annotation\ApiResource; +use Doctrine\ORM\Mapping as ORM; + +/** + * @ORM\Entity + * @ApiResource(mercure=true) + * + * @author Kévin Dunglas + */ +class DummyMercure +{ + /** + * @ORM\Id + * @ORM\Column(type="string") + */ + public $id; +} diff --git a/tests/Fixtures/app/AppKernel.php b/tests/Fixtures/app/AppKernel.php index b556fcf90e7..b835018db86 100644 --- a/tests/Fixtures/app/AppKernel.php +++ b/tests/Fixtures/app/AppKernel.php @@ -19,6 +19,7 @@ use Nelmio\ApiDocBundle\NelmioApiDocBundle; use Symfony\Bundle\FrameworkBundle\FrameworkBundle; use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait; +use Symfony\Bundle\MercureBundle\MercureBundle; use Symfony\Bundle\SecurityBundle\SecurityBundle; use Symfony\Bundle\TwigBundle\TwigBundle; use Symfony\Component\Config\Loader\LoaderInterface; @@ -50,6 +51,7 @@ public function registerBundles(): array new FrameworkBundle(), new TwigBundle(), new DoctrineBundle(), + new MercureBundle(), new ApiPlatformBundle(), new SecurityBundle(), new FOSUserBundle(), diff --git a/tests/Fixtures/app/config/config_test.yml b/tests/Fixtures/app/config/config_test.yml index 511219d4167..1e2651df082 100644 --- a/tests/Fixtures/app/config/config_test.yml +++ b/tests/Fixtures/app/config/config_test.yml @@ -26,6 +26,12 @@ doctrine: twig: strict_variables: '%kernel.debug%' +mercure: + hubs: + default: + url: https://demo.mercure.rocks/hub + jwt: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJtZXJjdXJlIjp7InN1YnNjcmliZSI6WyJmb28iLCJiYXIiXSwicHVibGlzaCI6WyJmb28iXX19.LRLvirgONK13JgacQ_VbcjySbVhkSmHy3IznH3tA9PM + api_platform: title: 'My Dummy API' description: 'This is a test API.' @@ -247,3 +253,6 @@ services: public: false tags: - { name: 'api_platform.data_persister' } + + mercure.hub.default.publisher: + class: ApiPlatform\Core\Tests\Fixtures\DummyMercurePublisher diff --git a/tests/Hydra/EventListener/AddLinkHeaderListenerTest.php b/tests/Hydra/EventListener/AddLinkHeaderListenerTest.php new file mode 100644 index 00000000000..4e9c4226cd4 --- /dev/null +++ b/tests/Hydra/EventListener/AddLinkHeaderListenerTest.php @@ -0,0 +1,53 @@ + + * + * 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\Tests\Hydra\EventListener; + +use ApiPlatform\Core\Api\UrlGeneratorInterface; +use ApiPlatform\Core\Hydra\EventListener\AddLinkHeaderListener; +use Fig\Link\GenericLinkProvider; +use Fig\Link\Link; +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Event\FilterResponseEvent; +use Symfony\Component\WebLink\HttpHeaderSerializer; + +/** + * @author Kévin Dunglas + */ +class AddLinkHeaderListenerTest extends TestCase +{ + /** + * @dataProvider provider + */ + public function testAddLinkHeader(string $expected, Request $request) + { + $urlGenerator = $this->prophesize(UrlGeneratorInterface::class); + $urlGenerator->generate('api_doc', ['_format' => 'jsonld'], UrlGeneratorInterface::ABS_URL)->willReturn('http://example.com/docs')->shouldBeCalled(); + + $event = $this->prophesize(FilterResponseEvent::class); + $event->getRequest()->willReturn($request)->shouldBeCalled(); + + $listener = new AddLinkHeaderListener($urlGenerator->reveal()); + $listener->onKernelResponse($event->reveal()); + $this->assertSame($expected, (new HttpHeaderSerializer())->serialize($request->attributes->get('_links')->getLinks())); + } + + public function provider(): array + { + return [ + ['; rel="http://www.w3.org/ns/hydra/core#apiDocumentation"', new Request()], + ['; rel="mercure",; rel="http://www.w3.org/ns/hydra/core#apiDocumentation"', new Request([], [], ['_links' => new GenericLinkProvider([new Link('mercure', 'https://demo.mercure.rocks/hub')])])], + ]; + } +} diff --git a/tests/Mercure/EventListener/AddLinkHeaderListenerTest.php b/tests/Mercure/EventListener/AddLinkHeaderListenerTest.php new file mode 100644 index 00000000000..7ab4b7d6a51 --- /dev/null +++ b/tests/Mercure/EventListener/AddLinkHeaderListenerTest.php @@ -0,0 +1,83 @@ + + * + * 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\Tests\Mercure\EventListener; + +use ApiPlatform\Core\Mercure\EventListener\AddLinkHeaderListener; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; +use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy; +use Fig\Link\GenericLinkProvider; +use Fig\Link\Link; +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Event\FilterResponseEvent; +use Symfony\Component\WebLink\HttpHeaderSerializer; + +/** + * @author Kévin Dunglas + */ +class AddLinkHeaderListenerTest extends TestCase +{ + /** + * @dataProvider addProvider + */ + public function testAddLinkHeader(string $expected, Request $request) + { + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + $resourceMetadataFactoryProphecy->create(Dummy::class)->willReturn(new ResourceMetadata(null, null, null, null, null, ['mercure' => true])); + + $listener = new AddLinkHeaderListener($resourceMetadataFactoryProphecy->reveal(), 'https://demo.mercure.rocks/hub'); + + $eventProphecy = $this->prophesize(FilterResponseEvent::class); + $eventProphecy->getRequest()->willReturn($request)->shouldBeCalled(); + + $listener->onKernelResponse($eventProphecy->reveal()); + + $this->assertSame($expected, (new HttpHeaderSerializer())->serialize($request->attributes->get('_links')->getLinks())); + } + + public function addProvider(): array + { + return [ + ['; rel="mercure"', new Request([], [], ['_api_resource_class' => Dummy::class])], + ['; rel="http://www.w3.org/ns/hydra/core#apiDocumentation",; rel="mercure"', new Request([], [], ['_api_resource_class' => Dummy::class, '_links' => new GenericLinkProvider([new Link('http://www.w3.org/ns/hydra/core#apiDocumentation', 'http://example.com/docs')])])], + ]; + } + + /** + * @dataProvider doNotAddProvider + */ + public function testDoNotAddHeader(Request $request) + { + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + $resourceMetadataFactoryProphecy->create(Dummy::class)->willReturn(new ResourceMetadata()); + + $listener = new AddLinkHeaderListener($resourceMetadataFactoryProphecy->reveal(), 'https://demo.mercure.rocks/hub'); + + $eventProphecy = $this->prophesize(FilterResponseEvent::class); + $eventProphecy->getRequest()->willReturn($request)->shouldBeCalled(); + + $listener->onKernelResponse($eventProphecy->reveal()); + + $this->assertNull($request->attributes->get('_links')); + } + + public function doNotAddProvider(): array + { + return [ + [new Request()], + [new Request([], [], ['_api_resource_class' => Dummy::class])], + ]; + } +}