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
![]() |
|||||
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
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
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. ![]() |
|||||
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
|
|||||
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
|
|||||
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 |