components-web-app /
api-components-bundle
| 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
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
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. 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
|
|||||
| 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 |