Skip to content

Mercure custom topics on newly created entities causes error #5074

@Krilline

Description

@Krilline

API Platform version(s) affected: 2.7

Description

Creating a new entity with mercure options having custom topics involving IRI generation like this:

resources:
    App\Entity\WhatsappCustomer:
        mercure:
            private: true
            topics:
                '@=iri(object)'

will cause the following error:

"Unable to generate an IRI for the item of type "App\Entity\WhatsappCustomer"

How to reproduce

Create an option mercure inside a yaml or PHP Attribute and add custom topics inside, the topics must contain
'@=iri(object)'
This error will only work if the entity is created. If the entity is updated, the error will not happen.

Possible Solution

I resolved this bug by decorating the service api_platform.doctrine.orm.listener.mercure.publish OR ApiPlatform\Doctrine\EventListener\PublishMercureUpdatesListener

I replaced the code of the Listener, starting by the storeObjectToPublish function

/**
     * @param object $object
     */
    private function storeObjectToPublish($object, string $property): void
    {
        if (null === $resourceClass = $this->getResourceClass($object)) {
            return;
        }

        try {
            $options = $this->resourceMetadataFactory->create($resourceClass)->getOperation()->getMercure() ?? false;
        } catch (OperationNotFoundException $e) {
            return;
        }

        if (\is_string($options)) {
            if (null === $this->expressionLanguage) {
                throw new RuntimeException('The Expression Language component is not installed. Try running "composer require symfony/expression-language".');
            }

            $options = $this->expressionLanguage->evaluate($options, ['object' => $object]);
        }

        if (false === $options) {
            return;
        }

        if (true === $options) {
            $options = [];
        }

        if (!\is_array($options)) {
            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)));
        }

        foreach ($options as $key => $value) {
            if (0 === $key) {
                if (method_exists(Update::class, 'isPrivate')) {
                    throw new \InvalidArgumentException('Targets do not exist anymore since Mercure 0.10. Mark the update as private instead or downgrade the Mercure Component to version 0.3');
                }

                @trigger_error('Targets do not exist anymore since Mercure 0.10. Mark the update as private instead.', \E_USER_DEPRECATED);
                break;
            }

            if (!isset(self::ALLOWED_KEYS[$key])) {
                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)));
            }

            if ('hub' === $key && !$this->hubRegistry instanceof HubRegistry) {
                throw new InvalidArgumentException(sprintf('The option "hub" of the "mercure" attribute cannot be set on the "%s" resource . Try running "composer require symfony/mercure:^0.5".', $resourceClass));
            }
        }

        $options['enable_async_update'] = $options['enable_async_update'] ?? true;

-        if ($options['topics'] ?? false) {
-            $topics = [];
-            foreach ((array) $options['topics'] as $topic) {
-                if (!\is_string($topic)) {
-                    $topics[] = $topic;
-                    continue;
-                }
-
-                if (0 !== strpos($topic, '@=')) {
-                    $topics[] = $topic;
-                    continue;
-                }
-
-                if (null === $this->expressionLanguage) {
-                    throw new \LogicException('The "@=" expression syntax cannot be used without the Expression Language component. -Try running "composer require symfony/expression-language".');
-                }
-
-                $topics[] = $this->expressionLanguage->evaluate(substr($topic, 2), ['object' => $object]);
-            }
-
-            $options['topics'] = $topics;
-        }

        if ('deletedObjects' === $property) {
            $this->deletedObjects[(object) [
                'id' => $this->iriConverter->getIriFromResource($object),
                'iri' => $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_URL),
            ]] = $options;

            return;
        }

        $this->{$property}[$object] = $options;
    }

And then moved this block of code inside the publishUpdate function

/**
     * @param object $object
     */
    private function publishUpdate($object, array $options, string $type): void
    {
        if ($object instanceof \stdClass) {
            // By convention, if the object has been deleted, we send only its IRI.
            // This may change in the feature, because it's not JSON Merge Patch compliant,
            // and I'm not a fond of this approach.
            $iri = $options['topics'] ?? $object->iri;
            /** @var string $data */
            $data = json_encode(['@id' => $object->id]);
        } else {
            $resourceClass = $this->getObjectClass($object);
            $context = $options['normalization_context'] ?? $this->resourceMetadataFactory->create($resourceClass)->getOperation()->getNormalizationContext() ?? [];

+            if ($options['topics'] ?? false) {
+                $topics = [];
+                foreach ((array) $options['topics'] as $topic) {
+                    if (!\is_string($topic)) {
+                        $topics[] = $topic;
+                        continue;
+                    }
+
+                    if (0 !== strpos($topic, '@=')) {
+                        $topics[] = $topic;
+                        continue;
+                    }
+
+                    if (null === $this->expressionLanguage) {
+                        throw new \LogicException('The "@=" expression syntax cannot be used without the Expression Language component. Try running "composer require symfony/expression-language".');
+                    }
+
+                    $topics[] = $this->expressionLanguage->evaluate(substr($topic, 2), ['object' => $object]);
+                }
+
+                $options['topics'] = $topics;
+            }

            $iri = $options['topics'] ?? $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_URL);
            $data = $options['data'] ?? $this->serializer->serialize($object, key($this->formats), $context);
        }

        $updates = array_merge([$this->buildUpdate($iri, $data, $options)], $this->getGraphQlSubscriptionUpdates($object, $options, $type));

        foreach ($updates as $update) {
            if ($options['enable_async_update'] && $this->messageBus) {
                $this->dispatch($update);
                continue;
            }

            $this->hubRegistry instanceof HubRegistry ? $this->hubRegistry->getHub($options['hub'] ?? null)->publish($update) : ($this->hubRegistry)($update);
        }
    }

I think that what is happening here is that the Id of the newly created entity is not yet available because the storeObjectToPublish function is executed inside the OnFlush function.
So I moved the block of code at a place where the id of the newly created entity is avalaible after the postFlush function.
Tell me what you think of it, didn't saw anyone talk about this issue, I might be the first or it might just not work on my side but I doubt it.
Thanks

Additional Context

Capture d’écran du 2022-10-18 15-13-14

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions