Passed
Push — main ( 6f7970...605cbe )
by Daniel
03:52
created

MercureResourcePublisher::__construct()   A

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

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