Skip to content

Commit 2411ab5

Browse files
feat(symfony): add getOperation Expression Language function on Mercure topics
1 parent 8a35ee2 commit 2411ab5

File tree

6 files changed

+238
-3
lines changed

6 files changed

+238
-3
lines changed

features/mercure/publish.feature

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,30 @@ Feature: Mercure publish support
3131
"name": "Hello World!"
3232
}
3333
"""
34+
35+
Scenario: Checks that Mercure Updates are dispatched following topics configured with expression language
36+
Given I add "Accept" header equal to "application/ld+json"
37+
And I add "Content-Type" header equal to "application/ld+json"
38+
When I send a "POST" request to "/mercure_with_topics_and_get_operations" with body:
39+
"""
40+
{
41+
"name": "Hello World!"
42+
}
43+
"""
44+
Then the response status code should be 201
45+
And the response should be in JSON
46+
And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8"
47+
Then 1 Mercure update should have been sent
48+
And the Mercure update should have topics:
49+
| http://example.com/mercure_with_topics_and_get_operations/1 |
50+
| http://example.com/custom_resource/mercure_with_topics_and_get_operations/1 |
51+
And the Mercure update should have data:
52+
"""
53+
{
54+
"@context": "/contexts/MercureWithTopicsAndGetOperation",
55+
"@id": "/mercure_with_topics_and_get_operations/1",
56+
"@type": "MercureWithTopicsAndGetOperation",
57+
"id": 1,
58+
"name": "Hello World!"
59+
}
60+
"""

src/Doctrine/EventListener/PublishMercureUpdatesListener.php

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
use ApiPlatform\GraphQl\Subscription\SubscriptionManagerInterface as GraphQlSubscriptionManagerInterface;
2323
use ApiPlatform\Metadata\HttpOperation;
2424
use ApiPlatform\Metadata\IriConverterInterface;
25+
use ApiPlatform\Metadata\Operation;
2526
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
2627
use ApiPlatform\Metadata\ResourceClassResolverInterface;
2728
use ApiPlatform\Metadata\UrlGeneratorInterface;
@@ -84,7 +85,10 @@ public function __construct(LegacyResourceClassResolverInterface|ResourceClassRe
8485
$this->expressionLanguage->addFunction($rawurlencode);
8586

8687
$this->expressionLanguage->addFunction(
87-
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))
88+
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))
89+
);
90+
$this->expressionLanguage->addFunction(
91+
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))
8892
);
8993
}
9094

tests/Doctrine/EventListener/PublishMercureUpdatesListenerTest.php

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
use ApiPlatform\Metadata\Get;
2121
use ApiPlatform\Metadata\IriConverterInterface;
2222
use ApiPlatform\Metadata\Operations;
23+
use ApiPlatform\Metadata\Post;
2324
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
2425
use ApiPlatform\Metadata\Resource\ResourceMetadataCollection;
2526
use ApiPlatform\Metadata\ResourceClassResolverInterface;
@@ -30,6 +31,7 @@
3031
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyFriend;
3132
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyMercure;
3233
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyOffer;
34+
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MercureWithTopicsAndGetOperation;
3335
use Doctrine\ORM\EntityManagerInterface;
3436
use Doctrine\ORM\Event\OnFlushEventArgs;
3537
use Doctrine\ORM\UnitOfWork;
@@ -169,6 +171,123 @@ public function testPublishUpdate(): void
169171
$this->assertEquals([null, null, null, null, null, 10, null], $retry);
170172
}
171173

174+
public function testPublishUpdateMultipleTopicsUsingExpressionLanguage(): void
175+
{
176+
$mercure = [
177+
'topics' => [
178+
'@=iri(object)',
179+
'@=iri(object, '.UrlGeneratorInterface::ABS_PATH.')',
180+
'@=iri(object, '.UrlGeneratorInterface::ABS_URL.', getOperation(object, "/custom_resource/mercure_with_topics_and_get_operations/{id}{._format}"))',
181+
],
182+
];
183+
184+
$toInsert = new MercureWithTopicsAndGetOperation();
185+
$toInsert->id = 1;
186+
$toInsert->name = 'Hello World!';
187+
188+
$toUpdate = new MercureWithTopicsAndGetOperation();
189+
$toUpdate->id = 2;
190+
$toUpdate->name = 'Hello World!';
191+
192+
$toDelete = new MercureWithTopicsAndGetOperation();
193+
$toDelete->id = 3;
194+
$toDelete->name = 'Hello World!';
195+
196+
// Even if it's the Post operation which sends Updates to Mercure,
197+
// the `mercure` configuration is retrieved from the first operation
198+
// of the resource because the Doctrine Listener doesn't have a
199+
// reference to the operation.
200+
$getOperation = (new Get())->withMercure($mercure)->withShortName('MercureWithTopicsAndGetOperation');
201+
$customGetOperation = (new Get(uriTemplate: '/custom_resource/mercure_with_topics_and_get_operations/{id}{._format}'));
202+
$postOperation = (new Post());
203+
204+
$resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class);
205+
$resourceClassResolverProphecy->getResourceClass(Argument::type(MercureWithTopicsAndGetOperation::class))->willReturn(MercureWithTopicsAndGetOperation::class);
206+
$resourceClassResolverProphecy->isResourceClass(MercureWithTopicsAndGetOperation::class)->willReturn(true);
207+
208+
$iriConverterProphecy = $this->prophesize(IriConverterInterface::class);
209+
210+
$iriConverterProphecy->getIriFromResource($toInsert, UrlGeneratorInterface::ABS_URL, null)->willReturn('http://example.com/mercure_with_topics_and_get_operations/1')->shouldBeCalled();
211+
$iriConverterProphecy->getIriFromResource($toInsert, UrlGeneratorInterface::ABS_PATH, null)->willReturn('/mercure_with_topics_and_get_operations/1')->shouldBeCalled();
212+
$iriConverterProphecy->getIriFromResource($toInsert, UrlGeneratorInterface::ABS_URL, Argument::exact($customGetOperation))->willReturn('http://example.com/custom_resource/mercure_with_topics_and_get_operations/1')->shouldBeCalled();
213+
214+
$iriConverterProphecy->getIriFromResource($toUpdate, UrlGeneratorInterface::ABS_URL, null)->willReturn('http://example.com/mercure_with_topics_and_get_operations/2')->shouldBeCalled();
215+
$iriConverterProphecy->getIriFromResource($toUpdate, UrlGeneratorInterface::ABS_PATH, null)->willReturn('/mercure_with_topics_and_get_operations/2')->shouldBeCalled();
216+
$iriConverterProphecy->getIriFromResource($toUpdate, UrlGeneratorInterface::ABS_URL, Argument::exact($customGetOperation))->willReturn('http://example.com/custom_resource/mercure_with_topics_and_get_operations/2')->shouldBeCalled();
217+
218+
$iriConverterProphecy->getIriFromResource($toDelete)->willReturn('/mercure_with_topics_and_get_operations/3')->shouldBeCalled();
219+
$iriConverterProphecy->getIriFromResource($toDelete, UrlGeneratorInterface::ABS_URL, null)->willReturn('http://example.com/mercure_with_topics_and_get_operations/3')->shouldBeCalled();
220+
$iriConverterProphecy->getIriFromResource($toDelete, UrlGeneratorInterface::ABS_PATH, null)->willReturn('/mercure_with_topics_and_get_operations/3')->shouldBeCalled();
221+
$iriConverterProphecy->getIriFromResource($toDelete, UrlGeneratorInterface::ABS_URL, Argument::exact($customGetOperation))->willReturn('http://example.com/custom_resource/mercure_with_topics_and_get_operations/3')->shouldBeCalled();
222+
223+
$resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class);
224+
225+
$resourceMetadataFactoryProphecy->create(MercureWithTopicsAndGetOperation::class)->willReturn(new ResourceMetadataCollection(MercureWithTopicsAndGetOperation::class, [
226+
(new ApiResource())->withOperations(new Operations([
227+
'get' => $getOperation,
228+
'custom_get' => $customGetOperation,
229+
'post' => $postOperation,
230+
])),
231+
]))->shouldBeCalled();
232+
233+
$serializerProphecy = $this->prophesize(SerializerInterface::class);
234+
$serializerProphecy->serialize($toInsert, 'jsonld', [])->willReturn('{"@type":"MercureWithTopicsAndGetOperation","@id":"/mercure_with_topics_and_get_operations/1","id":1,"name":"Hello World!"}')->shouldBeCalled();
235+
$serializerProphecy->serialize($toUpdate, 'jsonld', [])->willReturn('{"@type":"MercureWithTopicsAndGetOperation","@id":"/mercure_with_topics_and_get_operations/2","id":2,"name":"Hello World!"}')->shouldBeCalled();
236+
237+
$formats = ['jsonld' => ['application/ld+json'], 'jsonhal' => ['application/hal+json']];
238+
239+
$topics = [];
240+
$private = [];
241+
$retry = [];
242+
$data = [];
243+
244+
$defaultHub = $this->createMockHub(function (Update $update) use (&$topics, &$private, &$retry, &$data): string {
245+
$topics = array_merge($topics, $update->getTopics());
246+
$private[] = $update->isPrivate();
247+
$retry[] = $update->getRetry();
248+
$data[] = $update->getData();
249+
250+
return 'id';
251+
});
252+
253+
$listener = new PublishMercureUpdatesListener(
254+
$resourceClassResolverProphecy->reveal(),
255+
$iriConverterProphecy->reveal(),
256+
$resourceMetadataFactoryProphecy->reveal(),
257+
$serializerProphecy->reveal(),
258+
$formats,
259+
null,
260+
new HubRegistry($defaultHub, ['default' => $defaultHub]),
261+
null,
262+
null,
263+
null,
264+
true,
265+
);
266+
267+
$uowProphecy = $this->prophesize(UnitOfWork::class);
268+
$uowProphecy->getScheduledEntityInsertions()->willReturn([$toInsert])->shouldBeCalled();
269+
$uowProphecy->getScheduledEntityUpdates()->willReturn([$toUpdate])->shouldBeCalled();
270+
$uowProphecy->getScheduledEntityDeletions()->willReturn([$toDelete])->shouldBeCalled();
271+
272+
$emProphecy = $this->prophesize(EntityManagerInterface::class);
273+
$emProphecy->getUnitOfWork()->willReturn($uowProphecy->reveal())->shouldBeCalled();
274+
$eventArgs = new OnFlushEventArgs($emProphecy->reveal());
275+
276+
$listener->onFlush($eventArgs);
277+
$listener->postFlush();
278+
279+
$this->assertEquals([
280+
'{"@type":"MercureWithTopicsAndGetOperation","@id":"/mercure_with_topics_and_get_operations/1","id":1,"name":"Hello World!"}',
281+
'{"@type":"MercureWithTopicsAndGetOperation","@id":"/mercure_with_topics_and_get_operations/2","id":2,"name":"Hello World!"}',
282+
'{"@id":"\/mercure_with_topics_and_get_operations\/3","@type":"MercureWithTopicsAndGetOperation"}',
283+
], $data);
284+
$this->assertEquals([
285+
'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',
286+
'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',
287+
'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',
288+
], $topics);
289+
}
290+
172291
public function testPublishGraphQlUpdates(): void
173292
{
174293
$toUpdate = new Dummy();
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
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\Tests\Fixtures\TestBundle\Document;
15+
16+
use ApiPlatform\Metadata\ApiResource;
17+
use ApiPlatform\Metadata\Get;
18+
use ApiPlatform\Metadata\Post;
19+
use ApiPlatform\Metadata\UrlGeneratorInterface;
20+
use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM;
21+
22+
#[ApiResource(
23+
operations: [
24+
new Get(uriTemplate: '/mercure_with_topics_and_get_operations/{id}{._format}'),
25+
new Post(uriTemplate: '/mercure_with_topics_and_get_operations{._format}'),
26+
new Get(uriTemplate: '/custom_resource/mercure_with_topics_and_get_operations/{id}{._format}'),
27+
],
28+
mercure: [
29+
'topics' => [
30+
'@=iri(object)',
31+
'@=iri(object, '.UrlGeneratorInterface::ABS_URL.', getOperation(object, "/custom_resource/mercure_with_topics_and_get_operations/{id}{._format}"))',
32+
],
33+
]
34+
)]
35+
#[ODM\Document]
36+
class MercureWithTopicsAndGetOperation
37+
{
38+
#[ODM\Id(strategy: 'INCREMENT', type: 'int')]
39+
public $id;
40+
#[ODM\Field(type: 'string')]
41+
public $name;
42+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
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\Tests\Fixtures\TestBundle\Entity;
15+
16+
use ApiPlatform\Metadata\ApiResource;
17+
use ApiPlatform\Metadata\Get;
18+
use ApiPlatform\Metadata\Post;
19+
use ApiPlatform\Metadata\UrlGeneratorInterface;
20+
use Doctrine\ORM\Mapping as ORM;
21+
22+
#[ApiResource(
23+
operations: [
24+
new Get(uriTemplate: '/mercure_with_topics_and_get_operations/{id}{._format}'),
25+
new Post(uriTemplate: '/mercure_with_topics_and_get_operations{._format}'),
26+
new Get(uriTemplate: '/custom_resource/mercure_with_topics_and_get_operations/{id}{._format}'),
27+
],
28+
mercure: [
29+
'topics' => [
30+
'@=iri(object)',
31+
'@=iri(object, '.UrlGeneratorInterface::ABS_URL.', getOperation(object, "/custom_resource/mercure_with_topics_and_get_operations/{id}{._format}"))',
32+
],
33+
]
34+
)]
35+
#[ORM\Entity]
36+
class MercureWithTopicsAndGetOperation
37+
{
38+
#[ORM\Id]
39+
#[ORM\Column(type: 'integer')]
40+
#[ORM\GeneratedValue(strategy: 'AUTO')]
41+
public $id;
42+
#[ORM\Column]
43+
public $name;
44+
}

tests/Symfony/Bundle/Test/ApiTestCaseTest.php

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -245,8 +245,7 @@ public function testFindIriBy(): void
245245
*/
246246
public function testGetMercureMessages(): void
247247
{
248-
// debug mode is required to get Mercure TraceableHub
249-
$this->recreateSchema(['debug' => true, 'environment' => 'mercure']);
248+
$this->recreateSchema(['environment' => 'mercure']);
250249

251250
self::createClient()->request('POST', '/direct_mercures', [
252251
'headers' => [

0 commit comments

Comments
 (0)