MercureResourcePublisher::__construct()   A
last analyzed

Complexity

Conditions 3
Paths 4

Size

Total Lines 24
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 12

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 3
eloc 10
c 1
b 0
f 0
nc 4
nop 11
dl 0
loc 24
ccs 0
cts 11
cp 0
crap 12
rs 9.9332

How to fix   Many Parameters   

Many Parameters

Methods with many parameters are not only hard to understand, but their parameters also often become inconsistent when you need more, or different data.

There are several approaches to avoid long parameter lists:

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