MercureResourcePublisher   B
last analyzed

Complexity

Total Complexity 47

Size/Duplication

Total Lines 272
Duplicated Lines 0 %

Test Coverage

Coverage 0%

Importance

Changes 3
Bugs 0 Features 0
Metric Value
eloc 136
dl 0
loc 272
ccs 0
cts 126
cp 0
rs 8.64
c 3
b 0
f 0
wmc 47

11 Methods

Rating   Name   Duplication   Size   Complexity  
A buildUpdate() 0 3 1
A add() 0 19 5
A __construct() 0 24 3
B normalizeMercureOptions() 0 28 7
A storeObjectToPublish() 0 20 3
A reset() 0 5 1
B getObjectMercureOptions() 0 39 10
A getGraphQlSubscriptionUpdates() 0 18 5
A propagate() 0 16 4
A getObjectData() 0 16 2
A publishUpdate() 0 30 6

How to fix   Complexity   

Complex Class

Complex classes like MercureResourcePublisher often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use MercureResourcePublisher, and based on these observations, apply Extract Interface, too.

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\Exception\InvalidArgumentException as LegacyInvalidArgumentException;
17
use ApiPlatform\Exception\OperationNotFoundException as LegacyOperationNotFoundException;
18
use ApiPlatform\GraphQl\Subscription\MercureSubscriptionIriGeneratorInterface as GraphQlMercureSubscriptionIriGeneratorInterface;
19
use ApiPlatform\GraphQl\Subscription\SubscriptionManagerInterface as GraphQlSubscriptionManagerInterface;
20
use ApiPlatform\Metadata\Exception\InvalidArgumentException;
21
use ApiPlatform\Metadata\Exception\OperationNotFoundException;
22
use ApiPlatform\Metadata\Exception\RuntimeException;
23
use ApiPlatform\Metadata\IriConverterInterface;
24
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
25
use ApiPlatform\Metadata\ResourceClassResolverInterface;
26
use ApiPlatform\Metadata\UrlGeneratorInterface;
27
use ApiPlatform\Serializer\SerializerContextBuilderInterface;
28
use Doctrine\ORM\PersistentCollection;
29
use Silverback\ApiComponentsBundle\HttpCache\ResourceChangedPropagatorInterface;
30
use Silverback\ApiComponentsBundle\Utility\ResourceClassInfoTrait;
31
use Symfony\Component\ExpressionLanguage\ExpressionFunction;
32
use Symfony\Component\ExpressionLanguage\ExpressionLanguage;
33
use Symfony\Component\HttpFoundation\JsonResponse;
34
use Symfony\Component\HttpFoundation\Request;
35
use Symfony\Component\HttpFoundation\RequestStack;
36
use Symfony\Component\Mercure\HubRegistry;
37
use Symfony\Component\Mercure\Update;
38
use Symfony\Component\Messenger\MessageBusInterface;
39
use Symfony\Component\Serializer\SerializerAwareInterface;
40
use Symfony\Component\Serializer\SerializerAwareTrait;
41
42
class MercureResourcePublisher implements SerializerAwareInterface, ResourceChangedPropagatorInterface
43
{
44
    use DispatchTrait;
45
    use ResourceClassInfoTrait;
46
    use SerializerAwareTrait;
47
    private const ALLOWED_KEYS = [
48
        'topics' => true,
49
        'data' => true,
50
        'private' => true,
51
        'id' => true,
52
        'type' => true,
53
        'retry' => true,
54
        'normalization_context' => true,
55
        'hub' => true,
56
        'enable_async_update' => true,
57
    ];
58
59
    private readonly ?ExpressionLanguage $expressionLanguage;
60
    private \SplObjectStorage $createdObjects;
61
    private \SplObjectStorage $updatedObjects;
62
    private \SplObjectStorage $deletedObjects;
63
64
    // Do we want MessageBusInterface instead ? we don't have messenger installed yet, probably just use the default hub for now
65
    public function __construct(
66
        private readonly HubRegistry $hubRegistry,
67
        private readonly IriConverterInterface $iriConverter,
68
        private readonly SerializerContextBuilderInterface $serializerContextBuilder,
69
        private readonly RequestStack $requestStack,
70
        private readonly array $formats,
71
        ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory,
72
        ResourceClassResolverInterface $resourceClassResolver,
73
        ?MessageBusInterface $messageBus = null,
74
        private readonly ?GraphQlSubscriptionManagerInterface $graphQlSubscriptionManager = null,
75
        private readonly ?GraphQlMercureSubscriptionIriGeneratorInterface $graphQlMercureSubscriptionIriGenerator = null,
76
        ?ExpressionLanguage $expressionLanguage = null
77
    ) {
78
        $this->reset();
79
        $this->resourceClassResolver = $resourceClassResolver;
80
        $this->resourceMetadataFactory = $resourceMetadataFactory;
81
        $this->messageBus = $messageBus;
82
        $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...
83
        if ($this->expressionLanguage) {
84
            $rawurlencode = ExpressionFunction::fromPhp('rawurlencode', 'escape');
85
            $this->expressionLanguage->addFunction($rawurlencode);
86
87
            $this->expressionLanguage->addFunction(
88
                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))
89
            );
90
        }
91
    }
92
93
    public function reset(): void
94
    {
95
        $this->createdObjects = new \SplObjectStorage();
96
        $this->updatedObjects = new \SplObjectStorage();
97
        $this->deletedObjects = new \SplObjectStorage();
98
    }
99
100
    public function add(object $item, ?string $type = null): void
101
    {
102
        $property = sprintf('%sObjects', $type);
103
        if (!isset($this->{$property})) {
104
            throw new \InvalidArgumentException(sprintf('Cannot collect Mercure resource with type %s : the property %s does not exist.', $type, $property));
105
        }
106
107
        if (!is_iterable($item)) {
108
            $this->storeObjectToPublish($item, $property);
109
110
            return;
111
        }
112
113
        if ($item instanceof PersistentCollection) {
114
            $item = clone $item;
115
        }
116
117
        foreach ($item as $i) {
118
            $this->storeObjectToPublish($i, $property);
119
        }
120
    }
121
122
    private function storeObjectToPublish(object $object, string $property): void
123
    {
124
        $options = $this->getObjectMercureOptions($object);
125
        if (null === $options) {
126
            return;
127
        }
128
129
        $id = $this->iriConverter->getIriFromResource($object);
130
        $iri = $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_URL);
131
        $objectData = ['id' => $id, 'iri' => $iri, 'mercureOptions' => $this->normalizeMercureOptions($options)];
132
133
        if ('deletedObjects' === $property) {
134
            $this->createdObjects->detach($object);
135
            $this->updatedObjects->detach($object);
136
            $this->deletedObjects[$object] = $objectData;
137
138
            return;
139
        }
140
141
        $this->{$property}[$object] = $objectData;
142
    }
143
144
    private function getObjectMercureOptions(object $object): ?array
145
    {
146
        if (null === $resourceClass = $this->getResourceClass($object)) {
147
            return null;
148
        }
149
150
        try {
151
            $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

151
            $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...
152
        } catch (OperationNotFoundException|LegacyOperationNotFoundException) {
153
            return null;
154
        }
155
156
        if (\is_string($options)) {
157
            if (null === $this->expressionLanguage) {
158
                throw new RuntimeException('The Expression Language component is not installed. Try running "composer require symfony/expression-language".');
159
            }
160
161
            $options = $this->expressionLanguage->evaluate($options, ['object' => $object]);
162
        }
163
164
        if (false === $options) {
165
            return null;
166
        }
167
168
        if (true === $options) {
169
            return [];
170
        }
171
172
        if (!\is_array($options)) {
173
            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)));
174
        }
175
176
        foreach ($options as $key => $value) {
177
            if (!isset(self::ALLOWED_KEYS[$key])) {
178
                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)));
179
            }
180
        }
181
182
        return $options;
183
    }
184
185
    private function normalizeMercureOptions(array $options): array
186
    {
187
        $options['enable_async_update'] ??= true;
188
189
        if ($options['topics'] ?? false) {
190
            $topics = [];
191
            foreach ((array) $options['topics'] as $topic) {
192
                if (!\is_string($topic) || !str_starts_with($topic, '@=')) {
193
                    $topics[] = $topic;
194
                    continue;
195
                }
196
197
                if (!str_starts_with($topic, '@=')) {
198
                    $topics[] = $topic;
199
                    continue;
200
                }
201
202
                if (null === $this->expressionLanguage) {
203
                    throw new \LogicException('The "@=" expression syntax cannot be used without the Expression Language component. Try running "composer require symfony/expression-language".');
204
                }
205
206
                $topics[] = $this->expressionLanguage->evaluate(substr($topic, 2), ['object' => $object]);
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $object seems to be never defined.
Loading history...
207
            }
208
209
            $options['topics'] = $topics;
210
        }
211
212
        return $options;
213
    }
214
215
    public function propagate(): void
216
    {
217
        try {
218
            foreach ($this->createdObjects as $object) {
219
                $this->publishUpdate($object, $this->createdObjects[$object], 'create');
220
            }
221
222
            foreach ($this->updatedObjects as $object) {
223
                $this->publishUpdate($object, $this->updatedObjects[$object], 'update');
224
            }
225
226
            foreach ($this->deletedObjects as $object) {
227
                $this->publishUpdate($object, $this->deletedObjects[$object], 'delete');
228
            }
229
        } finally {
230
            $this->reset();
231
        }
232
    }
233
234
    private function getObjectData(object $object, string $iri)
235
    {
236
        $resourceClass = $this->getObjectClass($object);
237
238
        $request = $this->requestStack->getCurrentRequest();
239
        if (!$request) {
240
            $request = Request::create($iri);
241
        }
242
        $attributes = [
243
            'operation' => $this->resourceMetadataFactory->create($resourceClass)->getOperation(),
244
            'resource_class' => $resourceClass,
245
        ];
246
        $baseContext = $this->serializerContextBuilder->createFromRequest($request, true, $attributes);
247
        $context = array_merge($baseContext, $options['normalization_context'] ?? []);
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...
248
249
        return $options['data'] ?? $this->serializer->serialize($object, key($this->formats), $context);
250
    }
251
252
    private function publishUpdate(object $object, array $objectData, string $type): void
253
    {
254
        $options = $objectData['mercureOptions'];
255
        $iri = $options['topics'] ?? $objectData['iri'];
256
257
        $getDeletedObjectData = static function () use ($objectData) {
258
            return json_encode(['@id' => $objectData['id']], \JSON_THROW_ON_ERROR);
259
        };
260
261
        if ('delete' === $type) {
262
            $data = $getDeletedObjectData();
263
        } else {
264
            try {
265
                $data = $this->getObjectData($object, $iri);
266
            } catch (InvalidArgumentException|LegacyInvalidArgumentException) {
267
                // the object may have been deleted at the database level with delete cascades...
268
                $type = 'delete';
269
                $data = $getDeletedObjectData();
270
            }
271
        }
272
273
        $updates = array_merge([$this->buildUpdate($iri, $data, $options)], $this->getGraphQlSubscriptionUpdates($object, $options, $type));
274
275
        foreach ($updates as $update) {
276
            if ($options['enable_async_update'] && $this->messageBus) {
277
                $this->dispatch($update);
278
                continue;
279
            }
280
281
            $this->hubRegistry->getHub($options['hub'] ?? null)->publish($update);
282
        }
283
    }
284
285
    /**
286
     * @return Update[]
287
     */
288
    private function getGraphQlSubscriptionUpdates(object $object, array $options, string $type): array
289
    {
290
        if ('update' !== $type || !$this->graphQlSubscriptionManager || !$this->graphQlMercureSubscriptionIriGenerator) {
291
            return [];
292
        }
293
294
        $payloads = $this->graphQlSubscriptionManager->getPushPayloads($object);
295
296
        $updates = [];
297
        foreach ($payloads as [$subscriptionId, $data]) {
298
            $updates[] = $this->buildUpdate(
299
                $this->graphQlMercureSubscriptionIriGenerator->generateTopicIri($subscriptionId),
300
                (string) (new JsonResponse($data))->getContent(),
301
                $options
302
            );
303
        }
304
305
        return $updates;
306
    }
307
308
    /**
309
     * @param string|string[] $iri
310
     */
311
    private function buildUpdate(string|array $iri, string $data, array $options): Update
312
    {
313
        return new Update($iri, $data, $options['private'] ?? false, $options['id'] ?? null, $options['type'] ?? null, $options['retry'] ?? null);
314
    }
315
}
316