Passed
Push — main ( 9e6b50...b1713f )
by Daniel
03:55
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 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