Passed
Push — main ( 9e6b50...b1713f )
by Daniel
03:55
created

MercureResourcePublisher::storeObjectToPublish()   C

Complexity

Conditions 16
Paths 53

Size

Total Lines 80
Code Lines 45

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 272

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 16
eloc 45
c 1
b 0
f 0
nc 53
nop 2
dl 0
loc 80
ccs 0
cts 45
cp 0
crap 272
rs 5.5666

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

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\GraphQl\Subscription\MercureSubscriptionIriGeneratorInterface as GraphQlMercureSubscriptionIriGeneratorInterface;
23
use ApiPlatform\GraphQl\Subscription\SubscriptionManagerInterface as GraphQlSubscriptionManagerInterface;
24
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
25
use ApiPlatform\Serializer\SerializerContextBuilderInterface;
26
use ApiPlatform\Symfony\Messenger\DispatchTrait;
27
use ApiPlatform\Util\ResourceClassInfoTrait;
28
use Silverback\ApiComponentsBundle\HttpCache\ResourceChangedPropagatorInterface;
29
use Symfony\Component\ExpressionLanguage\ExpressionFunction;
30
use Symfony\Component\ExpressionLanguage\ExpressionLanguage;
31
use Symfony\Component\HttpFoundation\JsonResponse;
32
use Symfony\Component\HttpFoundation\RequestStack;
33
use Symfony\Component\Mercure\HubRegistry;
34
use Symfony\Component\Mercure\Update;
35
use Symfony\Component\Messenger\MessageBusInterface;
36
use Symfony\Component\Serializer\SerializerAwareInterface;
37
use Symfony\Component\Serializer\SerializerAwareTrait;
38
39
class MercureResourcePublisher implements SerializerAwareInterface, ResourceChangedPropagatorInterface
40
{
41
    use DispatchTrait;
42
    use ResourceClassInfoTrait;
43
    use SerializerAwareTrait;
44
    private const ALLOWED_KEYS = [
45
        'topics' => true,
46
        'data' => true,
47
        'private' => true,
48
        'id' => true,
49
        'type' => true,
50
        'retry' => true,
51
        'normalization_context' => true,
52
        'hub' => true,
53
        'enable_async_update' => true,
54
    ];
55
56
    private readonly ?ExpressionLanguage $expressionLanguage;
57
    private \SplObjectStorage $createdObjects;
58
    private \SplObjectStorage $updatedObjects;
59
    private \SplObjectStorage $deletedObjects;
60
61
    // Do we want MessageBusInterface instead ? we don't have messenger installed yet, probably just use the default hub for now
62
    public function __construct(
63
        private readonly HubRegistry $hubRegistry,
64
        private readonly IriConverterInterface $iriConverter,
65
        private readonly SerializerContextBuilderInterface $serializerContextBuilder,
66
        private readonly RequestStack $requestStack,
67
        private readonly array $formats,
68
        ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory,
69
        ResourceClassResolverInterface $resourceClassResolver,
70
        ?MessageBusInterface $messageBus = null,
71
        private readonly ?GraphQlSubscriptionManagerInterface $graphQlSubscriptionManager = null,
72
        private readonly ?GraphQlMercureSubscriptionIriGeneratorInterface $graphQlMercureSubscriptionIriGenerator = null,
73
        ?ExpressionLanguage $expressionLanguage = null
74
    ) {
75
        $this->reset();
76
        $this->resourceClassResolver = $resourceClassResolver;
77
        $this->resourceMetadataFactory = $resourceMetadataFactory;
78
        $this->messageBus = $messageBus;
79
        $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...
80
        if ($this->expressionLanguage) {
81
            $rawurlencode = ExpressionFunction::fromPhp('rawurlencode', 'escape');
82
            $this->expressionLanguage->addFunction($rawurlencode);
83
84
            $this->expressionLanguage->addFunction(
85
                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))
86
            );
87
        }
88
    }
89
90
    public function reset(): void
91
    {
92
        $this->createdObjects = new \SplObjectStorage();
93
        $this->updatedObjects = new \SplObjectStorage();
94
        $this->deletedObjects = new \SplObjectStorage();
95
    }
96
97
    public function collectResource($entity, ?string $type = null): void
98
    {
99
        // this is not needed for Mercure.
100
        // this clears cache for endpoints getting collections etc.
101
        // Mercure will only update for individual items
102
    }
103
104
    public function collectItems($items, ?string $type = null): void
105
    {
106
        $property = sprintf('%sObjects', $type);
107
        if (!isset($this->{$property})) {
108
            throw new \InvalidArgumentException(sprintf('Cannot collect Mercure resource with type %s : the property %s does not exist.', $type, $property));
109
        }
110
111
        foreach ($items as $item) {
112
            $this->storeObjectToPublish($item, $property);
113
        }
114
    }
115
116
    /**
117
     * @throws \ApiPlatform\Exception\ResourceClassNotFoundException
118
     *
119
     * @description See: ApiPlatform\Doctrine\EventListener\PublishMercureUpdatesListener
120
     */
121
    private function storeObjectToPublish(object $object, string $property): void
122
    {
123
        if (null === $resourceClass = $this->getResourceClass($object)) {
124
            return;
125
        }
126
127
        try {
128
            $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

128
            $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...
129
        } catch (OperationNotFoundException) {
130
            return;
131
        }
132
133
        if (\is_string($options)) {
134
            if (null === $this->expressionLanguage) {
135
                throw new RuntimeException('The Expression Language component is not installed. Try running "composer require symfony/expression-language".');
136
            }
137
138
            $options = $this->expressionLanguage->evaluate($options, ['object' => $object]);
139
        }
140
141
        if (false === $options) {
142
            return;
143
        }
144
145
        if (true === $options) {
146
            $options = [];
147
        }
148
149
        if (!\is_array($options)) {
150
            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)));
151
        }
152
153
        foreach ($options as $key => $value) {
154
            if (!isset(self::ALLOWED_KEYS[$key])) {
155
                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)));
156
            }
157
        }
158
159
        $options['enable_async_update'] ??= true;
160
161
        if ($options['topics'] ?? false) {
162
            $topics = [];
163
            foreach ((array) $options['topics'] as $topic) {
164
                if (!\is_string($topic)) {
165
                    $topics[] = $topic;
166
                    continue;
167
                }
168
169
                if (!str_starts_with($topic, '@=')) {
170
                    $topics[] = $topic;
171
                    continue;
172
                }
173
174
                if (null === $this->expressionLanguage) {
175
                    throw new \LogicException('The "@=" expression syntax cannot be used without the Expression Language component. Try running "composer require symfony/expression-language".');
176
                }
177
178
                $topics[] = $this->expressionLanguage->evaluate(substr($topic, 2), ['object' => $object]);
179
            }
180
181
            $options['topics'] = $topics;
182
        }
183
184
        $id = $this->iriConverter->getIriFromResource($object);
185
        $iri = $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_URL);
186
        $objectData = ['id' => $id, 'iri' => $iri, 'mercureOptions' => $options];
187
188
        if ('deletedObjects' === $property) {
189
            $this->createdObjects->detach($object);
190
            $this->updatedObjects->detach($object);
191
            $deletedObject = (object) [
192
                'id' => $this->iriConverter->getIriFromResource($object),
193
                'iri' => $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_URL),
194
            ];
195
            $this->deletedObjects[$deletedObject] = $objectData;
196
197
            return;
198
        }
199
200
        $this->{$property}[$object] = $objectData;
201
    }
202
203
    public function propagate(): void
204
    {
205
        try {
206
            foreach ($this->createdObjects as $object) {
207
                $this->publishUpdate($object, $this->createdObjects[$object], 'create');
208
            }
209
210
            foreach ($this->updatedObjects as $object) {
211
                $this->publishUpdate($object, $this->updatedObjects[$object], 'update');
212
            }
213
214
            foreach ($this->deletedObjects as $object) {
215
                $this->publishUpdate($object, $this->deletedObjects[$object], 'delete');
216
            }
217
        } finally {
218
            $this->reset();
219
        }
220
    }
221
222
    private static function getDeletedIriAndData(array $objectData): array
223
    {
224
        // By convention, if the object has been deleted, we send only its IRI.
225
        // This may change in the feature, because it's not JSON Merge Patch compliant,
226
        // and I'm not a fond of this approach.
227
        $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...
228
        /** @var string $data */
229
        $data = json_encode(['@id' => $objectData['id']], \JSON_THROW_ON_ERROR);
230
231
        return [$iri, $data];
232
    }
233
234
    private function publishUpdate(object $object, array $objectData, string $type): void
235
    {
236
        $options = $objectData['mercureOptions'];
237
238
        if ($object instanceof \stdClass) {
239
            [$iri, $data] = self::getDeletedIriAndData($objectData);
240
        } else {
241
            $resourceClass = $this->getObjectClass($object);
242
243
244
            $request = $this->requestStack->getCurrentRequest();
245
            $baseContext = $request ? $this->serializerContextBuilder->createFromRequest($request, true) : [];
246
            $context = $options['normalization_context'] ?? $this->resourceMetadataFactory->create($resourceClass)->getOperation()->getNormalizationContext() ?? [];
247
            $context = array_merge($baseContext, $context);
248
            try {
249
                $iri = $options['topics'] ?? $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_URL);
250
                $data = $options['data'] ?? $this->serializer->serialize($object, key($this->formats), $context);
251
            } catch (InvalidArgumentException) {
252
                // the object may have been deleted at the database level with delete cascades...
253
                [$iri, $data] = self::getDeletedIriAndData($objectData);
254
                $type = 'delete';
255
            }
256
        }
257
258
        $updates = array_merge([$this->buildUpdate($iri, $data, $options)], $this->getGraphQlSubscriptionUpdates($object, $options, $type));
259
260
        foreach ($updates as $update) {
261
            if ($options['enable_async_update'] && $this->messageBus) {
262
                $this->dispatch($update);
263
                continue;
264
            }
265
266
            $this->hubRegistry->getHub($options['hub'] ?? null)->publish($update);
267
        }
268
    }
269
270
    /**
271
     * @return Update[]
272
     */
273
    private function getGraphQlSubscriptionUpdates(object $object, array $options, string $type): array
274
    {
275
        if ('update' !== $type || !$this->graphQlSubscriptionManager || !$this->graphQlMercureSubscriptionIriGenerator) {
276
            return [];
277
        }
278
279
        $payloads = $this->graphQlSubscriptionManager->getPushPayloads($object);
280
281
        $updates = [];
282
        foreach ($payloads as [$subscriptionId, $data]) {
283
            $updates[] = $this->buildUpdate(
284
                $this->graphQlMercureSubscriptionIriGenerator->generateTopicIri($subscriptionId),
285
                (string) (new JsonResponse($data))->getContent(),
286
                $options
287
            );
288
        }
289
290
        return $updates;
291
    }
292
293
    /**
294
     * @param string|string[] $iri
295
     */
296
    private function buildUpdate(string|array $iri, string $data, array $options): Update
297
    {
298
        return new Update($iri, $data, $options['private'] ?? false, $options['id'] ?? null, $options['type'] ?? null, $options['retry'] ?? null);
299
    }
300
}
301