• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

api-platform / core / 10510640710

22 Aug 2024 03:02PM UTC coverage: 7.708% (+0.005%) from 7.703%
10510640710

push

github

web-flow
feat(laravel): search filter (#6534)

0 of 103 new or added lines in 11 files covered. (0.0%)

9646 existing lines in 299 files now uncovered.

12490 of 162048 relevant lines covered (7.71%)

22.98 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

88.6
/src/Doctrine/EventListener/PublishMercureUpdatesListener.php
1
<?php
2

3
/*
4
 * This file is part of the API Platform project.
5
 *
6
 * (c) Kévin Dunglas <dunglas@gmail.com>
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\Doctrine\EventListener;
15

16
use ApiPlatform\GraphQl\Subscription\MercureSubscriptionIriGeneratorInterface as GraphQlMercureSubscriptionIriGeneratorInterface;
17
use ApiPlatform\GraphQl\Subscription\SubscriptionManagerInterface as GraphQlSubscriptionManagerInterface;
18
use ApiPlatform\Metadata\Exception\InvalidArgumentException;
19
use ApiPlatform\Metadata\Exception\OperationNotFoundException;
20
use ApiPlatform\Metadata\Exception\RuntimeException;
21
use ApiPlatform\Metadata\HttpOperation;
22
use ApiPlatform\Metadata\IriConverterInterface;
23
use ApiPlatform\Metadata\Operation;
24
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
25
use ApiPlatform\Metadata\ResourceClassResolverInterface;
26
use ApiPlatform\Metadata\UrlGeneratorInterface;
27
use ApiPlatform\Metadata\Util\ResourceClassInfoTrait;
28
use ApiPlatform\Symfony\Messenger\DispatchTrait;
29
use Doctrine\Common\EventArgs;
30
use Doctrine\ODM\MongoDB\Event\OnFlushEventArgs as MongoDbOdmOnFlushEventArgs;
31
use Doctrine\ORM\Event\OnFlushEventArgs as OrmOnFlushEventArgs;
32
use Symfony\Component\ExpressionLanguage\ExpressionFunction;
33
use Symfony\Component\ExpressionLanguage\ExpressionLanguage;
34
use Symfony\Component\HttpFoundation\JsonResponse;
35
use Symfony\Component\Mercure\HubRegistry;
36
use Symfony\Component\Mercure\Update;
37
use Symfony\Component\Messenger\MessageBusInterface;
38
use Symfony\Component\Serializer\SerializerInterface;
39

40
/**
41
 * Publishes resources updates to the Mercure hub.
42
 *
43
 * @author Kévin Dunglas <dunglas@gmail.com>
44
 */
45
final class PublishMercureUpdatesListener
46
{
47
    use DispatchTrait;
48
    use ResourceClassInfoTrait;
49
    private const ALLOWED_KEYS = [
50
        'topics' => true,
51
        'data' => true,
52
        'private' => true,
53
        'id' => true,
54
        'type' => true,
55
        'retry' => true,
56
        'normalization_context' => true,
57
        'hub' => true,
58
        'enable_async_update' => true,
59
    ];
60
    private readonly ?ExpressionLanguage $expressionLanguage;
61
    private \SplObjectStorage $createdObjects;
62
    private \SplObjectStorage $updatedObjects;
63
    private \SplObjectStorage $deletedObjects;
64

65
    /**
66
     * @param array<string, string[]|string> $formats
67
     */
68
    public function __construct(ResourceClassResolverInterface $resourceClassResolver, private readonly IriConverterInterface $iriConverter, ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory, private readonly SerializerInterface $serializer, private readonly array $formats, ?MessageBusInterface $messageBus = null, private readonly ?HubRegistry $hubRegistry = null, private readonly ?GraphQlSubscriptionManagerInterface $graphQlSubscriptionManager = null, private readonly ?GraphQlMercureSubscriptionIriGeneratorInterface $graphQlMercureSubscriptionIriGenerator = null, ?ExpressionLanguage $expressionLanguage = null, private bool $includeType = false)
69
    {
70
        if (null === $messageBus && null === $hubRegistry) {
1,217✔
71
            throw new InvalidArgumentException('A message bus or a hub registry must be provided.');
×
72
        }
73

74
        $this->resourceClassResolver = $resourceClassResolver;
1,217✔
75

76
        $this->resourceMetadataFactory = $resourceMetadataFactory;
1,217✔
77
        $this->messageBus = $messageBus;
1,217✔
78
        $this->expressionLanguage = $expressionLanguage ?? (class_exists(ExpressionLanguage::class) ? new ExpressionLanguage() : null);
1,217✔
79
        $this->reset();
1,217✔
80

81
        if ($this->expressionLanguage) {
1,217✔
82
            $rawurlencode = ExpressionFunction::fromPhp('rawurlencode', 'escape');
1,217✔
83
            $this->expressionLanguage->addFunction($rawurlencode);
1,217✔
84

85
            $this->expressionLanguage->addFunction(
1,217✔
86
                new ExpressionFunction('get_operation', 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))
1,217✔
87
            );
1,217✔
88
            $this->expressionLanguage->addFunction(
1,217✔
89
                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))
1,217✔
90
            );
1,217✔
91
        }
92

93
        if (false === $this->includeType) {
1,217✔
94
            trigger_deprecation('api-platform/core', '3.1', 'Having mercure.include_type (always include @type in Mercure updates, even delete ones) set to false in the configuration is deprecated. It will be true by default in API Platform 4.0.');
×
95
        }
96
    }
97

98
    /**
99
     * Collects created, updated and deleted objects.
100
     */
101
    public function onFlush(EventArgs $eventArgs): void
102
    {
103
        if ($eventArgs instanceof OrmOnFlushEventArgs) {
1,217✔
104
            // @phpstan-ignore-next-line
105
            $uow = method_exists($eventArgs, 'getObjectManager') ? $eventArgs->getObjectManager()->getUnitOfWork() : $eventArgs->getEntityManager()->getUnitOfWork();
509✔
UNCOV
106
        } elseif ($eventArgs instanceof MongoDbOdmOnFlushEventArgs) {
708✔
UNCOV
107
            $uow = $eventArgs->getDocumentManager()->getUnitOfWork();
708✔
108
        } else {
109
            return;
×
110
        }
111

112
        $methodName = $eventArgs instanceof OrmOnFlushEventArgs ? 'getScheduledEntityInsertions' : 'getScheduledDocumentInsertions';
1,217✔
113
        foreach ($uow->{$methodName}() as $object) {
1,217✔
114
            $this->storeObjectToPublish($object, 'createdObjects');
1,082✔
115
        }
116

117
        $methodName = $eventArgs instanceof OrmOnFlushEventArgs ? 'getScheduledEntityUpdates' : 'getScheduledDocumentUpdates';
1,217✔
118
        foreach ($uow->{$methodName}() as $object) {
1,217✔
119
            $this->storeObjectToPublish($object, 'updatedObjects');
140✔
120
        }
121

122
        $methodName = $eventArgs instanceof OrmOnFlushEventArgs ? 'getScheduledEntityDeletions' : 'getScheduledDocumentDeletions';
1,217✔
123
        foreach ($uow->{$methodName}() as $object) {
1,217✔
124
            $this->storeObjectToPublish($object, 'deletedObjects');
32✔
125
        }
126
    }
127

128
    /**
129
     * Publishes updates for changes collected on flush, and resets the store.
130
     */
131
    public function postFlush(): void
132
    {
133
        try {
134
            foreach ($this->createdObjects as $object) {
1,217✔
135
                $this->publishUpdate($object, $this->createdObjects[$object], 'create');
17✔
136
            }
137

138
            foreach ($this->updatedObjects as $object) {
1,217✔
139
                $this->publishUpdate($object, $this->updatedObjects[$object], 'update');
12✔
140
            }
141

142
            foreach ($this->deletedObjects as $object) {
1,217✔
143
                $this->publishUpdate($object, $this->deletedObjects[$object], 'delete');
6✔
144
            }
145
        } finally {
146
            $this->reset();
1,217✔
147
        }
148
    }
149

150
    private function reset(): void
151
    {
152
        $this->createdObjects = new \SplObjectStorage();
1,217✔
153
        $this->updatedObjects = new \SplObjectStorage();
1,217✔
154
        $this->deletedObjects = new \SplObjectStorage();
1,217✔
155
    }
156

157
    private function storeObjectToPublish(object $object, string $property): void
158
    {
159
        if (null === $resourceClass = $this->getResourceClass($object)) {
1,189✔
160
            return;
156✔
161
        }
162

163
        $operation = $this->resourceMetadataFactory->create($resourceClass)->getOperation();
1,175✔
164
        try {
165
            $options = $operation->getMercure() ?? false;
1,175✔
166
        } catch (OperationNotFoundException) {
×
167
            return;
×
168
        }
169

170
        if (\is_string($options)) {
1,175✔
171
            if (null === $this->expressionLanguage) {
×
172
                throw new RuntimeException('The Expression Language component is not installed. Try running "composer require symfony/expression-language".');
×
173
            }
174

175
            $options = $this->expressionLanguage->evaluate($options, ['object' => $object]);
×
176
        }
177

178
        if (false === $options) {
1,175✔
179
            return;
1,160✔
180
        }
181

182
        if (true === $options) {
23✔
UNCOV
183
            $options = [];
8✔
184
        }
185

186
        if (!\is_array($options)) {
23✔
187
            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)));
×
188
        }
189

190
        foreach ($options as $key => $value) {
23✔
191
            if (!isset(self::ALLOWED_KEYS[$key])) {
15✔
192
                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)));
×
193
            }
194
        }
195

196
        $options['enable_async_update'] ??= true;
23✔
197

198
        if ('deletedObjects' === $property) {
23✔
199
            $types = $operation instanceof HttpOperation ? $operation->getTypes() : null;
6✔
200
            if (null === $types) {
6✔
201
                $types = [$operation->getShortName()];
6✔
202
            }
203

204
            // We need to evaluate it here, because in publishUpdate() the resource would be already deleted
205
            $this->evaluateTopics($options, $object);
6✔
206

207
            $this->deletedObjects[(object) [
6✔
208
                'id' => $this->iriConverter->getIriFromResource($object),
6✔
209
                'iri' => $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_URL),
6✔
210
                'type' => 1 === \count($types) ? $types[0] : $types,
6✔
211
            ]] = $options;
6✔
212

213
            return;
6✔
214
        }
215

216
        $this->{$property}[$object] = $options;
23✔
217
    }
218

219
    private function publishUpdate(object $object, array $options, string $type): void
220
    {
221
        if ($object instanceof \stdClass) {
23✔
222
            // By convention, if the object has been deleted, we send only its IRI and its type.
223
            // This may change in the feature, because it's not JSON Merge Patch compliant,
224
            // and I'm not a fond of this approach.
225
            $iri = $options['topics'] ?? $object->iri;
6✔
226
            /** @var string $data */
227
            $data = json_encode(['@id' => $object->id] + ($this->includeType ? ['@type' => $object->type] : []), \JSON_THROW_ON_ERROR);
6✔
228
        } else {
229
            $resourceClass = $this->getObjectClass($object);
23✔
230
            $context = $options['normalization_context'] ?? $this->resourceMetadataFactory->create($resourceClass)->getOperation()->getNormalizationContext() ?? [];
23✔
231

232
            // We need to evaluate it here, because in storeObjectToPublish() the resource would not have been persisted yet
233
            $this->evaluateTopics($options, $object);
23✔
234

235
            $iri = $options['topics'] ?? $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_URL);
23✔
236
            $data = $options['data'] ?? $this->serializer->serialize($object, key($this->formats), $context);
23✔
237
        }
238

239
        $updates = array_merge([$this->buildUpdate($iri, $data, $options)], $this->getGraphQlSubscriptionUpdates($object, $options, $type));
23✔
240

241
        foreach ($updates as $update) {
23✔
242
            if ($options['enable_async_update'] && $this->messageBus) {
23✔
UNCOV
243
                $this->dispatch($update);
14✔
UNCOV
244
                continue;
14✔
245
            }
246

247
            $this->hubRegistry->getHub($options['hub'] ?? null)->publish($update);
9✔
248
        }
249
    }
250

251
    private function evaluateTopics(array &$options, object $object): void
252
    {
253
        if (!($options['topics'] ?? false)) {
23✔
254
            return;
14✔
255
        }
256

257
        $topics = [];
12✔
258
        foreach ((array) $options['topics'] as $topic) {
12✔
259
            if (!\is_string($topic)) {
12✔
260
                $topics[] = $topic;
×
261
                continue;
×
262
            }
263

264
            if (!str_starts_with($topic, '@=')) {
12✔
265
                $topics[] = $topic;
3✔
266
                continue;
3✔
267
            }
268

269
            if (null === $this->expressionLanguage) {
9✔
270
                throw new \LogicException('The "@=" expression syntax cannot be used without the Expression Language component. Try running "composer require symfony/expression-language".');
×
271
            }
272

273
            $topics[] = $this->expressionLanguage->evaluate(substr($topic, 2), ['object' => $object]);
9✔
274
        }
275

276
        $options['topics'] = $topics;
12✔
277
    }
278

279
    /**
280
     * @return Update[]
281
     */
282
    private function getGraphQlSubscriptionUpdates(object $object, array $options, string $type): array
283
    {
284
        if ('update' !== $type || !$this->graphQlSubscriptionManager || !$this->graphQlMercureSubscriptionIriGenerator) {
23✔
285
            return [];
17✔
286
        }
287

288
        $payloads = $this->graphQlSubscriptionManager->getPushPayloads($object);
6✔
289

290
        $updates = [];
6✔
291
        foreach ($payloads as [$subscriptionId, $data]) {
6✔
292
            $updates[] = $this->buildUpdate(
6✔
293
                $this->graphQlMercureSubscriptionIriGenerator->generateTopicIri($subscriptionId),
6✔
294
                (string) (new JsonResponse($data))->getContent(),
6✔
295
                $options
6✔
296
            );
6✔
297
        }
298

299
        return $updates;
6✔
300
    }
301

302
    /**
303
     * @param string|string[] $iri
304
     */
305
    private function buildUpdate(string|array $iri, string $data, array $options): Update
306
    {
307
        return new Update($iri, $data, $options['private'] ?? false, $options['id'] ?? null, $options['type'] ?? null, $options['retry'] ?? null);
23✔
308
    }
309
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc