Passed
Pull Request — main (#155)
by Daniel
05:12
created

MercureResourcePublisher::collectItems()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 9
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 12

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 3
eloc 5
nc 3
nop 2
dl 0
loc 9
ccs 0
cts 6
cp 0
crap 12
rs 10
c 1
b 0
f 0
1
<?php
2
3
/*
4
 * This file is part of the Silverback API Components Bundle Project
5
 *
6
 * (c) Daniel West <[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 Silverback\ApiComponentsBundle\Mercure;
15
16
use ApiPlatform\Api\IriConverterInterface;
17
use ApiPlatform\Api\ResourceClassResolverInterface;
18
use ApiPlatform\Api\UrlGeneratorInterface;
19
use ApiPlatform\Exception\InvalidArgumentException;
20
use ApiPlatform\Exception\OperationNotFoundException;
21
use ApiPlatform\Exception\RuntimeException;
22
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
23
use ApiPlatform\Symfony\Messenger\DispatchTrait;
24
use ApiPlatform\Util\ResourceClassInfoTrait;
25
use Silverback\ApiComponentsBundle\HttpCache\ResourceChangedPropagatorInterface;
26
use Symfony\Component\ExpressionLanguage\ExpressionFunction;
27
use Symfony\Component\ExpressionLanguage\ExpressionLanguage;
28
use Symfony\Component\Mercure\HubRegistry;
29
use Symfony\Component\Mercure\Update;
30
use Symfony\Component\Serializer\SerializerAwareInterface;
31
use Symfony\Component\Serializer\SerializerAwareTrait;
32
use Symfony\Component\Messenger\MessageBusInterface;
33
34
class MercureResourcePublisher implements SerializerAwareInterface, ResourceChangedPropagatorInterface
35
{
36
    use DispatchTrait;
37
    use ResourceClassInfoTrait;
38
    use SerializerAwareTrait;
39
    private const ALLOWED_KEYS = [
40
        'topics' => true,
41
        'data' => true,
42
        'private' => true,
43
        'id' => true,
44
        'type' => true,
45
        'retry' => true,
46
        'normalization_context' => true,
47
        'hub' => true,
48
        'enable_async_update' => true,
49
    ];
50
51
    private readonly ?ExpressionLanguage $expressionLanguage;
52
    private \SplObjectStorage $createdObjects;
53
    private \SplObjectStorage $updatedObjects;
54
    private \SplObjectStorage $deletedObjects;
55
56
    // Do we want MessageBusInterface instead ? we don't have messenger installed yet, probably just use the default hub for now
57
    public function __construct(
58
        private readonly HubRegistry $hubRegistry,
59
        private readonly IriConverterInterface $iriConverter,
60
        ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory,
61
        ResourceClassResolverInterface $resourceClassResolver,
62
        private readonly array $formats,
63
        ?ExpressionLanguage $expressionLanguage = null,
64
        MessageBusInterface $messageBus = null
0 ignored issues
show
Unused Code introduced by
The parameter $messageBus is not used and could be removed. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unused  annotation

64
        /** @scrutinizer ignore-unused */ MessageBusInterface $messageBus = null

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
65
    ) {
66
        $this->reset();
67
        $this->resourceClassResolver = $resourceClassResolver;
68
        $this->resourceMetadataFactory = $resourceMetadataFactory;
69
        $this->expressionLanguage = $expressionLanguage ?? (class_exists(ExpressionLanguage::class) ? new ExpressionLanguage() : null);
0 ignored issues
show
Bug introduced by
The property expressionLanguage is declared read-only in Silverback\ApiComponents...ercureResourcePublisher.
Loading history...
70
        if ($this->expressionLanguage) {
71
            $rawurlencode = ExpressionFunction::fromPhp('rawurlencode', 'escape');
72
            $this->expressionLanguage->addFunction($rawurlencode);
73
74
            $this->expressionLanguage->addFunction(
75
                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))
76
            );
77
        }
78
    }
79
80
    public function reset(): void
81
    {
82
        $this->createdObjects = new \SplObjectStorage();
83
        $this->updatedObjects = new \SplObjectStorage();
84
        $this->deletedObjects = new \SplObjectStorage();
85
    }
86
87
    public function collectResource($entity, ?string $type = null): void
88
    {
89
        // this is not needed for Mercure.
90
        // this clears cache for endpoints getting collections etc.
91
        // Mercure will only update for individual items
92
    }
93
94
    public function collectItems($items, ?string $type = null): void
95
    {
96
        $property = sprintf('%sObjects', $type);
97
        if (!isset($this->{$property})) {
98
            throw new \InvalidArgumentException(sprintf('Cannot collect Mercure resource with type %s : the property %s does not exist.', $type, $property));
99
        }
100
101
        foreach ($items as $item) {
102
            $this->storeObjectToPublish($item, $property);
103
        }
104
    }
105
106
    /**
107
     * @throws \ApiPlatform\Exception\ResourceClassNotFoundException
108
     *
109
     * @description See: ApiPlatform\Doctrine\EventListener\PublishMercureUpdatesListener
110
     */
111
    private function storeObjectToPublish(object $object, string $property): void
112
    {
113
        if (null === $resourceClass = $this->getResourceClass($object)) {
114
            return;
115
        }
116
117
        try {
118
            $options = $this->resourceMetadataFactory->create($resourceClass)->getOperation()->getMercure() ?? false;
0 ignored issues
show
Bug introduced by
The method create() does not exist on null. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

118
            $options = $this->resourceMetadataFactory->/** @scrutinizer ignore-call */ create($resourceClass)->getOperation()->getMercure() ?? false;

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
119
        } catch (OperationNotFoundException) {
120
            return;
121
        }
122
123
        if (\is_string($options)) {
124
            if (null === $this->expressionLanguage) {
125
                throw new RuntimeException('The Expression Language component is not installed. Try running "composer require symfony/expression-language".');
126
            }
127
128
            $options = $this->expressionLanguage->evaluate($options, ['object' => $object]);
129
        }
130
131
        if (false === $options) {
132
            return;
133
        }
134
135
        if (true === $options) {
136
            $options = [];
137
        }
138
139
        if (!\is_array($options)) {
140
            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)));
141
        }
142
143
        foreach ($options as $key => $value) {
144
            if (!isset(self::ALLOWED_KEYS[$key])) {
145
                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)));
146
            }
147
        }
148
149
        $options['enable_async_update'] ??= true;
150
151
        if ($options['topics'] ?? false) {
152
            $topics = [];
153
            foreach ((array) $options['topics'] as $topic) {
154
                if (!\is_string($topic)) {
155
                    $topics[] = $topic;
156
                    continue;
157
                }
158
159
                if (!str_starts_with($topic, '@=')) {
160
                    $topics[] = $topic;
161
                    continue;
162
                }
163
164
                if (null === $this->expressionLanguage) {
165
                    throw new \LogicException('The "@=" expression syntax cannot be used without the Expression Language component. Try running "composer require symfony/expression-language".');
166
                }
167
168
                $topics[] = $this->expressionLanguage->evaluate(substr($topic, 2), ['object' => $object]);
169
            }
170
171
            $options['topics'] = $topics;
172
        }
173
174
        $id = $this->iriConverter->getIriFromResource($object);
175
        $iri = $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_URL);
176
        $objectData = ['id' => $id, 'iri' => $iri, 'mercureOptions' => $options];
177
178
        if ('deletedObjects' === $property) {
179
            $this->createdObjects->detach($object);
180
            $this->updatedObjects->detach($object);
181
            $deletedObject = (object) [
182
                'id' => $this->iriConverter->getIriFromResource($object),
183
                'iri' => $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_URL),
184
            ];
185
            $this->deletedObjects[$deletedObject] = $objectData;
186
            return;
187
        }
188
189
        $this->{$property}[$object] = $objectData;
190
    }
191
192
    public function propagate(): void
193
    {
194
        try {
195
            foreach ($this->createdObjects as $object) {
196
                $this->publishUpdate($object, $this->createdObjects[$object], 'create');
197
            }
198
199
            foreach ($this->updatedObjects as $object) {
200
                $this->publishUpdate($object, $this->updatedObjects[$object], 'update');
201
            }
202
203
            foreach ($this->deletedObjects as $object) {
204
                $this->publishUpdate($object, $this->deletedObjects[$object], 'delete');
205
            }
206
        } finally {
207
            $this->reset();
208
        }
209
    }
210
211
    private static function getDeletedIriAndData(array $objectData): array
212
    {
213
        // By convention, if the object has been deleted, we send only its IRI.
214
        // This may change in the feature, because it's not JSON Merge Patch compliant,
215
        // and I'm not a fond of this approach.
216
        $iri = $options['topics'] ?? $objectData['iri'];
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $options seems to never exist and therefore isset should always be false.
Loading history...
217
        /** @var string $data */
218
        $data = json_encode(['@id' => $objectData['id']], \JSON_THROW_ON_ERROR);
219
        return [$iri, $data];
220
    }
221
222
    private function publishUpdate(object $object, array $objectData, string $type): void
0 ignored issues
show
Unused Code introduced by
The parameter $type is not used and could be removed. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unused  annotation

222
    private function publishUpdate(object $object, array $objectData, /** @scrutinizer ignore-unused */ string $type): void

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
223
    {
224
        $options = $objectData['mercureOptions'];
225
226
        if ($object instanceof \stdClass) {
227
            [$iri, $data] = self::getDeletedIriAndData($objectData);
228
        } else {
229
            $resourceClass = $this->getObjectClass($object);
230
            $context = $options['normalization_context'] ?? $this->resourceMetadataFactory->create($resourceClass)->getOperation()->getNormalizationContext() ?? [];
231
            try {
232
                $iri = $options['topics'] ?? $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_URL);
233
                $data = $options['data'] ?? $this->serializer->serialize($object, key($this->formats), $context);
234
            } catch (InvalidArgumentException) {
235
                // the object may have been deleted at the database level with delete cascades...
236
                [$iri, $data] = self::getDeletedIriAndData($objectData);
237
                $type = 'delete';
0 ignored issues
show
Unused Code introduced by
The assignment to $type is dead and can be removed.
Loading history...
238
            }
239
        }
240
241
        $updates = [$this->buildUpdate($iri, $data, $options)];
242
243
        foreach ($updates as $update) {
244
            if ($options['enable_async_update'] && $this->messageBus) {
245
                $this->dispatch($update);
246
                continue;
247
            }
248
249
            $this->hubRegistry->getHub($options['hub'] ?? null)->publish($update);
250
        }
251
    }
252
253
    /**
254
     * @param string|string[] $iri
255
     */
256
    private function buildUpdate(string|array $iri, string $data, array $options): Update
257
    {
258
        return new Update($iri, $data, $options['private'] ?? false, $options['id'] ?? null, $options['type'] ?? null, $options['retry'] ?? null);
259
    }
260
}
261