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

MercureResourcePublisher::__construct()   A

Complexity

Conditions 3
Paths 4

Size

Total Lines 22
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
nc 4
nop 9
dl 0
loc 22
ccs 0
cts 11
cp 0
crap 12
rs 9.9332
c 1
b 0
f 0

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

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