diff --git a/features/mercure/publish.feature b/features/mercure/publish.feature index ee7e3b7793f..ac0c27fa7ed 100644 --- a/features/mercure/publish.feature +++ b/features/mercure/publish.feature @@ -31,3 +31,30 @@ Feature: Mercure publish support "name": "Hello World!" } """ + + Scenario: Checks that Mercure Updates are dispatched following topics configured with expression language + Given I add "Accept" header equal to "application/ld+json" + And I add "Content-Type" header equal to "application/ld+json" + When I send a "POST" request to "/mercure_with_topics_and_get_operations" with body: + """ + { + "name": "Hello World!" + } + """ + Then the response status code should be 201 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + Then 1 Mercure update should have been sent + And the Mercure update should have topics: + | http://example.com/mercure_with_topics_and_get_operations/1 | + | http://example.com/custom_resource/mercure_with_topics_and_get_operations/1 | + And the Mercure update should have data: + """ + { + "@context": "/contexts/MercureWithTopicsAndGetOperation", + "@id": "/mercure_with_topics_and_get_operations/1", + "@type": "MercureWithTopicsAndGetOperation", + "id": 1, + "name": "Hello World!" + } + """ diff --git a/src/Doctrine/EventListener/PublishMercureUpdatesListener.php b/src/Doctrine/EventListener/PublishMercureUpdatesListener.php index efe68a78e04..ad27437020f 100644 --- a/src/Doctrine/EventListener/PublishMercureUpdatesListener.php +++ b/src/Doctrine/EventListener/PublishMercureUpdatesListener.php @@ -22,6 +22,7 @@ use ApiPlatform\GraphQl\Subscription\SubscriptionManagerInterface as GraphQlSubscriptionManagerInterface; use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\IriConverterInterface; +use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\ResourceClassResolverInterface; use ApiPlatform\Metadata\UrlGeneratorInterface; @@ -84,7 +85,10 @@ public function __construct(LegacyResourceClassResolverInterface|ResourceClassRe $this->expressionLanguage->addFunction($rawurlencode); $this->expressionLanguage->addFunction( - new ExpressionFunction('iri', static fn (string $apiResource, int $referenceType = UrlGeneratorInterface::ABS_URL): string => sprintf('iri(%s, %d)', $apiResource, $referenceType), static fn (array $arguments, $apiResource, int $referenceType = UrlGeneratorInterface::ABS_URL): string => $iriConverter->getIriFromResource($apiResource, $referenceType)) + new ExpressionFunction('getOperation', static fn (string $apiResource, string $name): string => sprintf('getOperation(%s, %s)', $apiResource, $name), static fn (array $arguments, $apiResource, string $name): Operation => $resourceMetadataFactory->create($resourceClassResolver->getResourceClass($apiResource))->getOperation($name)) + ); + $this->expressionLanguage->addFunction( + new ExpressionFunction('iri', static fn (string $apiResource, int $referenceType = UrlGeneratorInterface::ABS_URL, string $operation = null): string => sprintf('iri(%s, %d, %s)', $apiResource, $referenceType, $operation), static fn (array $arguments, $apiResource, int $referenceType = UrlGeneratorInterface::ABS_URL, $operation = null): string => $iriConverter->getIriFromResource($apiResource, $referenceType, $operation)) ); } diff --git a/tests/Doctrine/EventListener/PublishMercureUpdatesListenerTest.php b/tests/Doctrine/EventListener/PublishMercureUpdatesListenerTest.php index 78a5a1680ba..ec032893eb8 100644 --- a/tests/Doctrine/EventListener/PublishMercureUpdatesListenerTest.php +++ b/tests/Doctrine/EventListener/PublishMercureUpdatesListenerTest.php @@ -20,6 +20,7 @@ use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\IriConverterInterface; use ApiPlatform\Metadata\Operations; +use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; use ApiPlatform\Metadata\ResourceClassResolverInterface; @@ -30,6 +31,7 @@ use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyFriend; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyMercure; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyOffer; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MercureWithTopicsAndGetOperation; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\Event\OnFlushEventArgs; use Doctrine\ORM\UnitOfWork; @@ -169,6 +171,123 @@ public function testPublishUpdate(): void $this->assertEquals([null, null, null, null, null, 10, null], $retry); } + public function testPublishUpdateMultipleTopicsUsingExpressionLanguage(): void + { + $mercure = [ + 'topics' => [ + '@=iri(object)', + '@=iri(object, '.UrlGeneratorInterface::ABS_PATH.')', + '@=iri(object, '.UrlGeneratorInterface::ABS_URL.', getOperation(object, "/custom_resource/mercure_with_topics_and_get_operations/{id}{._format}"))', + ], + ]; + + $toInsert = new MercureWithTopicsAndGetOperation(); + $toInsert->id = 1; + $toInsert->name = 'Hello World!'; + + $toUpdate = new MercureWithTopicsAndGetOperation(); + $toUpdate->id = 2; + $toUpdate->name = 'Hello World!'; + + $toDelete = new MercureWithTopicsAndGetOperation(); + $toDelete->id = 3; + $toDelete->name = 'Hello World!'; + + // Even if it's the Post operation which sends Updates to Mercure, + // the `mercure` configuration is retrieved from the first operation + // of the resource because the Doctrine Listener doesn't have a + // reference to the operation. + $getOperation = (new Get())->withMercure($mercure)->withShortName('MercureWithTopicsAndGetOperation'); + $customGetOperation = (new Get(uriTemplate: '/custom_resource/mercure_with_topics_and_get_operations/{id}{._format}')); + $postOperation = (new Post()); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass(Argument::type(MercureWithTopicsAndGetOperation::class))->willReturn(MercureWithTopicsAndGetOperation::class); + $resourceClassResolverProphecy->isResourceClass(MercureWithTopicsAndGetOperation::class)->willReturn(true); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + + $iriConverterProphecy->getIriFromResource($toInsert, UrlGeneratorInterface::ABS_URL, null)->willReturn('http://example.com/mercure_with_topics_and_get_operations/1')->shouldBeCalled(); + $iriConverterProphecy->getIriFromResource($toInsert, UrlGeneratorInterface::ABS_PATH, null)->willReturn('/mercure_with_topics_and_get_operations/1')->shouldBeCalled(); + $iriConverterProphecy->getIriFromResource($toInsert, UrlGeneratorInterface::ABS_URL, Argument::exact($customGetOperation))->willReturn('http://example.com/custom_resource/mercure_with_topics_and_get_operations/1')->shouldBeCalled(); + + $iriConverterProphecy->getIriFromResource($toUpdate, UrlGeneratorInterface::ABS_URL, null)->willReturn('http://example.com/mercure_with_topics_and_get_operations/2')->shouldBeCalled(); + $iriConverterProphecy->getIriFromResource($toUpdate, UrlGeneratorInterface::ABS_PATH, null)->willReturn('/mercure_with_topics_and_get_operations/2')->shouldBeCalled(); + $iriConverterProphecy->getIriFromResource($toUpdate, UrlGeneratorInterface::ABS_URL, Argument::exact($customGetOperation))->willReturn('http://example.com/custom_resource/mercure_with_topics_and_get_operations/2')->shouldBeCalled(); + + $iriConverterProphecy->getIriFromResource($toDelete)->willReturn('/mercure_with_topics_and_get_operations/3')->shouldBeCalled(); + $iriConverterProphecy->getIriFromResource($toDelete, UrlGeneratorInterface::ABS_URL, null)->willReturn('http://example.com/mercure_with_topics_and_get_operations/3')->shouldBeCalled(); + $iriConverterProphecy->getIriFromResource($toDelete, UrlGeneratorInterface::ABS_PATH, null)->willReturn('/mercure_with_topics_and_get_operations/3')->shouldBeCalled(); + $iriConverterProphecy->getIriFromResource($toDelete, UrlGeneratorInterface::ABS_URL, Argument::exact($customGetOperation))->willReturn('http://example.com/custom_resource/mercure_with_topics_and_get_operations/3')->shouldBeCalled(); + + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + + $resourceMetadataFactoryProphecy->create(MercureWithTopicsAndGetOperation::class)->willReturn(new ResourceMetadataCollection(MercureWithTopicsAndGetOperation::class, [ + (new ApiResource())->withOperations(new Operations([ + 'get' => $getOperation, + 'custom_get' => $customGetOperation, + 'post' => $postOperation, + ])), + ]))->shouldBeCalled(); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + $serializerProphecy->serialize($toInsert, 'jsonld', [])->willReturn('{"@type":"MercureWithTopicsAndGetOperation","@id":"/mercure_with_topics_and_get_operations/1","id":1,"name":"Hello World!"}')->shouldBeCalled(); + $serializerProphecy->serialize($toUpdate, 'jsonld', [])->willReturn('{"@type":"MercureWithTopicsAndGetOperation","@id":"/mercure_with_topics_and_get_operations/2","id":2,"name":"Hello World!"}')->shouldBeCalled(); + + $formats = ['jsonld' => ['application/ld+json'], 'jsonhal' => ['application/hal+json']]; + + $topics = []; + $private = []; + $retry = []; + $data = []; + + $defaultHub = $this->createMockHub(function (Update $update) use (&$topics, &$private, &$retry, &$data): string { + $topics = array_merge($topics, $update->getTopics()); + $private[] = $update->isPrivate(); + $retry[] = $update->getRetry(); + $data[] = $update->getData(); + + return 'id'; + }); + + $listener = new PublishMercureUpdatesListener( + $resourceClassResolverProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceMetadataFactoryProphecy->reveal(), + $serializerProphecy->reveal(), + $formats, + null, + new HubRegistry($defaultHub, ['default' => $defaultHub]), + null, + null, + null, + true, + ); + + $uowProphecy = $this->prophesize(UnitOfWork::class); + $uowProphecy->getScheduledEntityInsertions()->willReturn([$toInsert])->shouldBeCalled(); + $uowProphecy->getScheduledEntityUpdates()->willReturn([$toUpdate])->shouldBeCalled(); + $uowProphecy->getScheduledEntityDeletions()->willReturn([$toDelete])->shouldBeCalled(); + + $emProphecy = $this->prophesize(EntityManagerInterface::class); + $emProphecy->getUnitOfWork()->willReturn($uowProphecy->reveal())->shouldBeCalled(); + $eventArgs = new OnFlushEventArgs($emProphecy->reveal()); + + $listener->onFlush($eventArgs); + $listener->postFlush(); + + $this->assertEquals([ + '{"@type":"MercureWithTopicsAndGetOperation","@id":"/mercure_with_topics_and_get_operations/1","id":1,"name":"Hello World!"}', + '{"@type":"MercureWithTopicsAndGetOperation","@id":"/mercure_with_topics_and_get_operations/2","id":2,"name":"Hello World!"}', + '{"@id":"\/mercure_with_topics_and_get_operations\/3","@type":"MercureWithTopicsAndGetOperation"}', + ], $data); + $this->assertEquals([ + 'http://example.com/mercure_with_topics_and_get_operations/1', '/mercure_with_topics_and_get_operations/1', 'http://example.com/custom_resource/mercure_with_topics_and_get_operations/1', + 'http://example.com/mercure_with_topics_and_get_operations/2', '/mercure_with_topics_and_get_operations/2', 'http://example.com/custom_resource/mercure_with_topics_and_get_operations/2', + 'http://example.com/mercure_with_topics_and_get_operations/3', '/mercure_with_topics_and_get_operations/3', 'http://example.com/custom_resource/mercure_with_topics_and_get_operations/3', + ], $topics); + } + public function testPublishGraphQlUpdates(): void { $toUpdate = new Dummy(); diff --git a/tests/Fixtures/TestBundle/Document/MercureWithTopicsAndGetOperation.php b/tests/Fixtures/TestBundle/Document/MercureWithTopicsAndGetOperation.php new file mode 100644 index 00000000000..0447b030c6a --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/MercureWithTopicsAndGetOperation.php @@ -0,0 +1,42 @@ + + * + * 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\Tests\Fixtures\TestBundle\Document; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Post; +use ApiPlatform\Metadata\UrlGeneratorInterface; +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; + +#[ApiResource( + operations: [ + new Get(uriTemplate: '/mercure_with_topics_and_get_operations/{id}{._format}'), + new Post(uriTemplate: '/mercure_with_topics_and_get_operations{._format}'), + new Get(uriTemplate: '/custom_resource/mercure_with_topics_and_get_operations/{id}{._format}'), + ], + mercure: [ + 'topics' => [ + '@=iri(object)', + '@=iri(object, '.UrlGeneratorInterface::ABS_URL.', getOperation(object, "/custom_resource/mercure_with_topics_and_get_operations/{id}{._format}"))', + ], + ] +)] +#[ODM\Document] +class MercureWithTopicsAndGetOperation +{ + #[ODM\Id(strategy: 'INCREMENT', type: 'int')] + public $id; + #[ODM\Field(type: 'string')] + public $name; +} diff --git a/tests/Fixtures/TestBundle/Entity/MercureWithTopicsAndGetOperation.php b/tests/Fixtures/TestBundle/Entity/MercureWithTopicsAndGetOperation.php new file mode 100644 index 00000000000..4556c4b549e --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/MercureWithTopicsAndGetOperation.php @@ -0,0 +1,44 @@ + + * + * 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\Tests\Fixtures\TestBundle\Entity; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Post; +use ApiPlatform\Metadata\UrlGeneratorInterface; +use Doctrine\ORM\Mapping as ORM; + +#[ApiResource( + operations: [ + new Get(uriTemplate: '/mercure_with_topics_and_get_operations/{id}{._format}'), + new Post(uriTemplate: '/mercure_with_topics_and_get_operations{._format}'), + new Get(uriTemplate: '/custom_resource/mercure_with_topics_and_get_operations/{id}{._format}'), + ], + mercure: [ + 'topics' => [ + '@=iri(object)', + '@=iri(object, '.UrlGeneratorInterface::ABS_URL.', getOperation(object, "/custom_resource/mercure_with_topics_and_get_operations/{id}{._format}"))', + ], + ] +)] +#[ORM\Entity] +class MercureWithTopicsAndGetOperation +{ + #[ORM\Id] + #[ORM\Column(type: 'integer')] + #[ORM\GeneratedValue(strategy: 'AUTO')] + public $id; + #[ORM\Column] + public $name; +} diff --git a/tests/Symfony/Bundle/Test/ApiTestCaseTest.php b/tests/Symfony/Bundle/Test/ApiTestCaseTest.php index 6e6def4da16..1bb544937eb 100644 --- a/tests/Symfony/Bundle/Test/ApiTestCaseTest.php +++ b/tests/Symfony/Bundle/Test/ApiTestCaseTest.php @@ -245,8 +245,7 @@ public function testFindIriBy(): void */ public function testGetMercureMessages(): void { - // debug mode is required to get Mercure TraceableHub - $this->recreateSchema(['debug' => true, 'environment' => 'mercure']); + $this->recreateSchema(['environment' => 'mercure']); self::createClient()->request('POST', '/direct_mercures', [ 'headers' => [