diff --git a/src/GraphQl/Serializer/ItemNormalizer.php b/src/GraphQl/Serializer/ItemNormalizer.php index 1d94476c787..989ca7d1276 100644 --- a/src/GraphQl/Serializer/ItemNormalizer.php +++ b/src/GraphQl/Serializer/ItemNormalizer.php @@ -16,6 +16,7 @@ use ApiPlatform\GraphQl\State\Provider\NoopProvider; use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\GraphQl\Query; +use ApiPlatform\Metadata\GraphQl\QueryCollection; use ApiPlatform\Metadata\IdentifiersExtractorInterface; use ApiPlatform\Metadata\IriConverterInterface; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; @@ -26,6 +27,7 @@ use ApiPlatform\Metadata\Util\ClassInfoTrait; use ApiPlatform\Serializer\CacheKeyTrait; use ApiPlatform\Serializer\ItemNormalizer as BaseItemNormalizer; +use Doctrine\Common\Collections\Collection; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; @@ -109,6 +111,11 @@ public function normalize(mixed $data, ?string $format = null, array $context = $normalizedData[self::ITEM_IDENTIFIERS_KEY] = $this->identifiersExtractor->getIdentifiersFromItem($data, $context['operation'] ?? null); } + if (isset($context['graphql_operation_name']) && 'mercure_subscription' === $context['graphql_operation_name'] && \is_object($data) && isset($normalizedData['id']) && !isset($normalizedData['_id'])) { + $normalizedData['_id'] = $normalizedData['id']; + $normalizedData['id'] = $this->iriConverter->getIriFromResource($data); + } + return $normalizedData; } @@ -123,10 +130,43 @@ protected function normalizeCollectionOfRelations(ApiProperty $propertyMetadata, return [...$attributeValue]; } + // Handle relationships for mercure subscriptions + if ($operation instanceof QueryCollection && 'mercure_subscription' === $context['graphql_operation_name'] && $attributeValue instanceof Collection && !$attributeValue->isEmpty()) { + $relationContext = $context; + $relationContext['attributes'] = $context['attributes']['collection']; + $data['collection'] = []; + foreach ($attributeValue as $item) { + $data['collection'][] = $this->normalize($item, $format, $relationContext); + } + + return $this->addPagination($attributeValue->count(), $data, $context); + } + // to-many are handled directly by the GraphQL resolver return []; } + private function addPagination(int $totalCount, array $data, array $context): array + { + if ($context['attributes']['paginationInfo'] ?? false) { + $data['paginationInfo'] = []; + if (\array_key_exists('hasNextPage', $context['attributes']['paginationInfo'])) { + $data['paginationInfo']['hasNextPage'] = $totalCount > ($context['pagination']['itemsPerPage'] ?? 10); + } + if (\array_key_exists('itemsPerPage', $context['attributes']['paginationInfo'])) { + $data['paginationInfo']['itemsPerPage'] = $context['pagination']['itemsPerPage'] ?? 10; + } + if (\array_key_exists('lastPage', $context['attributes']['paginationInfo'])) { + $data['paginationInfo']['lastPage'] = (int) ceil($totalCount / ($context['pagination']['itemsPerPage'] ?? 10)); + } + if (\array_key_exists('totalCount', $context['attributes']['paginationInfo'])) { + $data['paginationInfo']['totalCount'] = $totalCount; + } + } + + return $data; + } + /** * {@inheritdoc} */ diff --git a/src/GraphQl/State/Processor/SubscriptionProcessor.php b/src/GraphQl/State/Processor/SubscriptionProcessor.php index d4389499221..118e5724b80 100644 --- a/src/GraphQl/State/Processor/SubscriptionProcessor.php +++ b/src/GraphQl/State/Processor/SubscriptionProcessor.php @@ -16,7 +16,7 @@ use ApiPlatform\GraphQl\Subscription\MercureSubscriptionIriGeneratorInterface; use ApiPlatform\GraphQl\Subscription\OperationAwareSubscriptionManagerInterface; use ApiPlatform\GraphQl\Subscription\SubscriptionManagerInterface; -use ApiPlatform\Metadata\GraphQl\Operation as GraphQlOperation; +use ApiPlatform\Metadata\GraphQl\Subscription; use ApiPlatform\Metadata\Operation; use ApiPlatform\State\ProcessorInterface; @@ -32,7 +32,7 @@ public function __construct(private readonly ProcessorInterface $decorated, priv public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []) { $data = $this->decorated->process($data, $operation, $uriVariables, $context); - if (!$operation instanceof GraphQlOperation || !($mercure = $operation->getMercure())) { + if (!$operation instanceof Subscription || !($mercure = $operation->getMercure())) { return $data; } diff --git a/src/GraphQl/Subscription/SubscriptionIdentifierGenerator.php b/src/GraphQl/Subscription/SubscriptionIdentifierGenerator.php index 44afd26aa95..76c5ee248ba 100644 --- a/src/GraphQl/Subscription/SubscriptionIdentifierGenerator.php +++ b/src/GraphQl/Subscription/SubscriptionIdentifierGenerator.php @@ -23,7 +23,21 @@ final class SubscriptionIdentifierGenerator implements SubscriptionIdentifierGen public function generateSubscriptionIdentifier(array $fields): string { unset($fields['mercureUrl'], $fields['clientSubscriptionId']); + $fields = $this->removeTypename($fields); return hash('sha256', print_r($fields, true)); } + + private function removeTypename(array $data): array + { + foreach ($data as $key => $value) { + if ('__typename' === $key) { + unset($data[$key]); + } elseif (\is_array($value)) { + $data[$key] = $this->removeTypename($value); + } + } + + return $data; + } } diff --git a/src/GraphQl/Subscription/SubscriptionManager.php b/src/GraphQl/Subscription/SubscriptionManager.php index afebe45bfb1..e628676d1cf 100644 --- a/src/GraphQl/Subscription/SubscriptionManager.php +++ b/src/GraphQl/Subscription/SubscriptionManager.php @@ -14,16 +14,23 @@ namespace ApiPlatform\GraphQl\Subscription; use ApiPlatform\GraphQl\Resolver\Util\IdentifierTrait; +use ApiPlatform\Metadata\CollectionOperationInterface; +use ApiPlatform\Metadata\Exception\InvalidArgumentException; +use ApiPlatform\Metadata\Exception\OperationNotFoundException; use ApiPlatform\Metadata\GraphQl\Operation; use ApiPlatform\Metadata\GraphQl\Subscription; use ApiPlatform\Metadata\IriConverterInterface; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\UrlGeneratorInterface; +use ApiPlatform\Metadata\Util\PropertyAccessorValueExtractor; use ApiPlatform\Metadata\Util\ResourceClassInfoTrait; use ApiPlatform\Metadata\Util\SortTrait; use ApiPlatform\State\ProcessorInterface; use GraphQL\Type\Definition\ResolveInfo; use Psr\Cache\CacheItemPoolInterface; - +use Psr\Cache\CacheItemInterface; +use Symfony\Component\PropertyAccess\Exception\AccessException; +use Symfony\Component\PropertyAccess\Exception\NoSuchPropertyException; /** * Manages all the queried subscriptions by creating their ID * and saving to a cache the information needed to publish updated data. @@ -42,75 +49,321 @@ public function __construct(private readonly CacheItemPoolInterface $subscriptio public function retrieveSubscriptionId(array $context, ?array $result, ?Operation $operation = null): ?string { + $iri = $operation ? $this->getIdentifierFromOperation($operation, $context['args'] ?? []) : $this->getIdentifierFromContext($context); + if (empty($iri)) { + return null; + } + /** @var ResolveInfo $info */ $info = $context['info']; $fields = $info->getFieldSelection(\PHP_INT_MAX); $this->arrayRecursiveSort($fields, 'ksort'); - $iri = $operation ? $this->getIdentifierFromOperation($operation, $context['args'] ?? []) : $this->getIdentifierFromContext($context); - if (null === $iri) { - return null; + + $options = $operation ? ($operation->getMercure() ?? false) : false; + $private = $options['private'] ?? false; + $privateFields = $options['private_fields'] ?? []; + $this->validateMercureOptions($private, $privateFields); + $previousObject = $context['graphql_context']['previous_object'] ?? null; + $privateFieldData = $this->getPrivateFieldData($private, $privateFields, $previousObject); + $privatePartitionKey = $this->getPrivatePartitionKey($privateFieldData); + + if ($operation instanceof CollectionOperationInterface) { + $subscriptionId = $this->updateSubscriptionCollectionCacheData( + $this->getCollectionSubscriptionIriFromOperation($iri, $operation), + $fields, + $privatePartitionKey + ); + } else { + $subscriptionId = $this->updateSubscriptionItemCacheData( + $iri, + $fields, + $result, + $privatePartitionKey + ); } - $subscriptionsCacheItem = $this->subscriptionsCache->getItem($this->encodeIriToCacheKey($iri)); - $subscriptions = []; + + return $subscriptionId; + } + + public function getPushPayloads(object $object, string $type = 'update'): array + { + if ('delete' === $type) { + return $this->getDeletePushPayloads($object); + } + + return $this->getCreatedOrUpdatedPayloads($object, $type); + } + + /** + * @return array + */ + private function getSubscriptionsFromIri(string $iri, ?string $privatePartitionKey = null): array + { + $subscriptionsCacheItem = $this->getSubscriptionsCacheItem($iri, $privatePartitionKey); + if ($subscriptionsCacheItem->isHit()) { - $subscriptions = $subscriptionsCacheItem->get(); - foreach ($subscriptions as [$subscriptionId, $subscriptionFields, $subscriptionResult]) { - if ($subscriptionFields === $fields) { - return $subscriptionId; - } + return $subscriptionsCacheItem->get(); + } + + return []; + } + + private function getSubscriptionsCacheItem(string $iri, ?string $privatePartitionKey = null): CacheItemInterface + { + return $this->subscriptionsCache->getItem($this->generateCacheKey($iri, $privatePartitionKey)); + } + + private function removeItemFromSubscriptionCache(string $iri, ?string $privatePartitionKey = null): void + { + $cacheKey = $this->generateCacheKey($iri, $privatePartitionKey); + if ($this->subscriptionsCache->hasItem($cacheKey)) { + $this->subscriptionsCache->deleteItem($cacheKey); + } + } + + private function encodeIriToCacheKey(string $iri): string + { + return str_replace('/', '_', $iri); + } + + private function getPrivateFieldValue(string $privateField, object $object): string + { + return PropertyAccessorValueExtractor::getValue($object, $privateField); + } + + private function getCollectionSubscriptionIriFromOperation(string $iri, Operation $operation): string + { + if (null === $operation->getClass()) { + return $this->getCollectionIri($iri); + } + + return $this->iriConverter->getIriFromResource($operation->getClass(), UrlGeneratorInterface::ABS_PATH, $operation) ?? $this->getCollectionIri($iri); + } + + private function getCollectionIri(string $iri): string + { + return substr($iri, 0, strrpos($iri, '/')); + } + + private function getCollectionSubscriptionIri(string $resourceClass, object $object, ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory): string + { + $resourceMetadata = $resourceMetadataCollectionFactory->create($resourceClass); + + try { + $collectionOperation = $resourceMetadata->getOperation(forceCollection: true, forceGraphQl: true); + + return $this->iriConverter->getIriFromResource($resourceClass, UrlGeneratorInterface::ABS_PATH, $collectionOperation) ?? $this->getCollectionIri($this->iriConverter->getIriFromResource($object)); + } catch (OperationNotFoundException) { + return $this->getCollectionIri($this->iriConverter->getIriFromResource($object)); + } + } + + /** + * @return array + */ + private function getPrivateFieldData(bool $private, array $privateFields, ?object $object): array + { + if (!$private || [] === $privateFields || null === $object) { + return []; + } + + $privateFieldData = []; + foreach ($privateFields as $privateField) { + try { + $privateFieldData[$privateField] = $this->getPrivateFieldValue($privateField, $object); + } catch (NoSuchPropertyException|AccessException) { + continue; } } - $subscriptionId = $this->subscriptionIdentifierGenerator->generateSubscriptionIdentifier($fields); - unset($result['clientSubscriptionId']); - $subscriptions[] = [$subscriptionId, $fields, $result]; - $subscriptionsCacheItem->set($subscriptions); - $this->subscriptionsCache->save($subscriptionsCacheItem); + return $privateFieldData; + } - return $subscriptionId; + private function getPrivatePartitionKey(array $privateFieldData): ?string + { + if ([] === $privateFieldData) { + return null; + } + + $privatePartitionData = []; + foreach ($privateFieldData as $field => $value) { + $privatePartitionData[] = \sprintf('%s=%s', $field, $value); + } + + return hash('sha256', implode('|', $privatePartitionData)); } - public function getPushPayloads(object $object): array + private function validateMercureOptions(bool $private, array $privateFields): void { - $iri = $this->iriConverter->getIriFromResource($object); - $subscriptions = $this->getSubscriptionsFromIri($iri); + if ([] !== $privateFields && !$private) { + throw new InvalidArgumentException('"private_fields" requires "mercure.private" to be true.'); + } + } + private function getCreatedOrUpdatedPayloads(object $object, string $type): array + { $resourceClass = $this->getObjectClass($object); $resourceMetadata = $this->resourceMetadataCollectionFactory->create($resourceClass); $shortName = $resourceMetadata->getOperation()->getShortName(); - $payloads = []; - foreach ($subscriptions as [$subscriptionId, $subscriptionFields, $subscriptionResult]) { - $resolverContext = ['fields' => $subscriptionFields, 'is_collection' => false, 'is_mutation' => false, 'is_subscription' => true]; - $operation = (new Subscription())->withName('update_subscription')->withShortName($shortName); - $data = $this->normalizeProcessor->process($object, $operation, [], $resolverContext); + $payloadsBySubscriptionId = []; + foreach ($resourceMetadata as $apiResource) { + foreach ($apiResource->getGraphQlOperations() as $operation) { + if (!$operation instanceof Subscription) { + continue; + } + if ('create' === $type && !$operation instanceof CollectionOperationInterface) { + continue; + } + $mercure = $operation->getMercure() ?? false; + $private = $mercure['private'] ?? false; + $privateFieldsConfig = $mercure['private_fields'] ?? []; + $privateFieldData = $this->getPrivateFieldData($private, $privateFieldsConfig, $object); + $privatePartitionKey = $this->getPrivatePartitionKey($privateFieldData); - unset($data['clientSubscriptionId']); + $iri = $this->iriConverter->getIriFromResource($object); + $collectionIri = $this->getCollectionSubscriptionIri($resourceClass, $object, $this->resourceMetadataCollectionFactory); + $this->appendNormalizedPayloads( + $payloadsBySubscriptionId, + $this->getSubscriptionsFromIri($collectionIri, $privatePartitionKey), + $object, + $shortName + ); - if ($data !== $subscriptionResult) { - $payloads[] = [$subscriptionId, $data]; + if ('create' !== $type) { + $itemSubscriptionsCacheItem = $this->getSubscriptionsCacheItem($iri, $privatePartitionKey); + $itemSubscriptions = $itemSubscriptionsCacheItem->isHit() ? $itemSubscriptionsCacheItem->get() : []; + $updatedItemSubscriptions = $this->appendNormalizedPayloads( + $payloadsBySubscriptionId, + $itemSubscriptions, + $object, + $shortName, + true + ); + + if ($updatedItemSubscriptions !== $itemSubscriptions) { + $itemSubscriptionsCacheItem->set($updatedItemSubscriptions); + $this->subscriptionsCache->save($itemSubscriptionsCacheItem); + } + } } } - return $payloads; + return array_values($payloadsBySubscriptionId); } /** - * @return array + * @param array $payloadsBySubscriptionId + * @param-out array $payloadsBySubscriptionId + * @param array, array}> $subscriptions + * + * @return array, array}> */ - private function getSubscriptionsFromIri(string $iri): array + private function appendNormalizedPayloads(array &$payloadsBySubscriptionId, array $subscriptions, object $object, string $shortName, bool $updateCachedResult = false): array { - $subscriptionsCacheItem = $this->subscriptionsCache->getItem($this->encodeIriToCacheKey($iri)); + $subscriptionOperation = (new Subscription())->withName('mercure_subscription')->withShortName($shortName); + + foreach ($subscriptions as $index => [$subscriptionId, $subscriptionFields, $subscriptionResult]) { + $resolverContext = [ + 'fields' => $subscriptionFields, + 'is_collection' => false, + 'is_mutation' => false, + 'is_subscription' => true, + ]; + $data = $this->normalizeProcessor->process($object, $subscriptionOperation, [], $resolverContext); + + unset($data['clientSubscriptionId']); + + if ($data !== $subscriptionResult) { + $payloadsBySubscriptionId[$subscriptionId] = [$subscriptionId, $data]; + + if ($updateCachedResult) { + $subscriptions[$index][2] = $data; + } + } + } + return $subscriptions; + } + + private function getDeletePushPayloads(object $object): array + { + $iri = $object->id; + $privatePartitionKey = $this->getPrivatePartitionKey($object->private); + $payloads = []; + $payload = ['type' => 'delete', 'payload' => ['id' => $object->id, 'iri' => $object->iri, 'type' => $object->type]]; + // Check for resource class + $collectionIri = isset($object->resourceClass) ? $this->getCollectionSubscriptionIri($object->resourceClass, (object) ['id' => $iri], $this->resourceMetadataCollectionFactory) : $this->getCollectionIri($iri); + foreach ($this->getSubscriptionsFromIri($iri, $privatePartitionKey) as [$subscriptionId, $subscriptionFields, $subscriptionResult]) { + $payloads[] = [$subscriptionId, $payload]; + } + foreach ($this->getSubscriptionsFromIri($collectionIri, $privatePartitionKey) as [$subscriptionId, $subscriptionFields, $subscriptionResult]) { + $payloads[] = [$subscriptionId, $payload]; + } + $this->removeItemFromSubscriptionCache($iri, $privatePartitionKey); + + return $payloads; + } + + private function updateSubscriptionItemCacheData( + string $iri, + array $fields, + ?array $result, + ?string $privatePartitionKey = null, + ): string { + $subscriptionsCacheItem = $this->subscriptionsCache->getItem($this->generateCacheKey($iri, $privatePartitionKey)); + $subscriptions = []; if ($subscriptionsCacheItem->isHit()) { - return $subscriptionsCacheItem->get(); + /* + * @var array, array}> + */ + $subscriptions = $subscriptionsCacheItem->get(); + foreach ($subscriptions as [$subscriptionId, $subscriptionFields, $subscriptionResult]) { + if ($subscriptionFields === $fields) { + return $subscriptionId; + } + } } - return []; + unset($result['clientSubscriptionId']); + $subscriptionId = $this->subscriptionIdentifierGenerator->generateSubscriptionIdentifier($fields); + $subscriptions[] = [$subscriptionId, $fields, $result]; + $subscriptionsCacheItem->set($subscriptions); + $this->subscriptionsCache->save($subscriptionsCacheItem); + + return $subscriptionId; } - private function encodeIriToCacheKey(string $iri): string + private function updateSubscriptionCollectionCacheData( + string $collectionIri, + array $fields, + ?string $privatePartitionKey = null, + ): string { + $subscriptionCollectionCacheItem = $this->subscriptionsCache->getItem($this->generateCacheKey($collectionIri, $privatePartitionKey)); + $collectionSubscriptions = []; + if ($subscriptionCollectionCacheItem->isHit()) { + $collectionSubscriptions = $subscriptionCollectionCacheItem->get(); + foreach ($collectionSubscriptions as [$subscriptionId, $subscriptionFields, $result]) { + if ($subscriptionFields === $fields) { + return $subscriptionId; + } + } + } + $subscriptionId = $this->subscriptionIdentifierGenerator->generateSubscriptionIdentifier($fields + ['__collection' => true]); + $collectionSubscriptions[] = [$subscriptionId, $fields, []]; + $subscriptionCollectionCacheItem->set($collectionSubscriptions); + $this->subscriptionsCache->save($subscriptionCollectionCacheItem); + + return $subscriptionId; + } + + private function generateCacheKey(string $iri, ?string $privatePartitionKey = null): string { - return str_replace('/', '_', $iri); + $cacheKey = $this->encodeIriToCacheKey($iri); + if (null === $privatePartitionKey) { + return $cacheKey; + } + + return $cacheKey.'_'.$privatePartitionKey; } } diff --git a/src/GraphQl/Subscription/SubscriptionManagerInterface.php b/src/GraphQl/Subscription/SubscriptionManagerInterface.php index e04003e9e42..2e64162025e 100644 --- a/src/GraphQl/Subscription/SubscriptionManagerInterface.php +++ b/src/GraphQl/Subscription/SubscriptionManagerInterface.php @@ -25,5 +25,5 @@ interface SubscriptionManagerInterface */ public function retrieveSubscriptionId(array $context, ?array $result): ?string; - public function getPushPayloads(object $object): array; + public function getPushPayloads(object $object, string $type = 'update'): array; } diff --git a/src/GraphQl/Tests/Fixtures/ApiResource/MercureSubscriptionChildDummy.php b/src/GraphQl/Tests/Fixtures/ApiResource/MercureSubscriptionChildDummy.php new file mode 100644 index 00000000000..417fe5734e2 --- /dev/null +++ b/src/GraphQl/Tests/Fixtures/ApiResource/MercureSubscriptionChildDummy.php @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\GraphQl\Tests\Fixtures\ApiResource; + +final class MercureSubscriptionChildDummy +{ + private string $name; + + public function getName(): string + { + return $this->name; + } + + public function setName(string $name): void + { + $this->name = $name; + } +} diff --git a/src/GraphQl/Tests/Serializer/ItemNormalizerTest.php b/src/GraphQl/Tests/Serializer/ItemNormalizerTest.php index e528ee4e941..9982dea495a 100644 --- a/src/GraphQl/Tests/Serializer/ItemNormalizerTest.php +++ b/src/GraphQl/Tests/Serializer/ItemNormalizerTest.php @@ -15,22 +15,29 @@ use ApiPlatform\GraphQl\Serializer\ItemNormalizer; use ApiPlatform\GraphQl\Tests\Fixtures\ApiResource\Dummy; +use ApiPlatform\GraphQl\Tests\Fixtures\ApiResource\MercureSubscriptionChildDummy; use ApiPlatform\GraphQl\Tests\Fixtures\ApiResource\SecuredDummy; use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GraphQl\QueryCollection; use ApiPlatform\Metadata\IdentifiersExtractorInterface; use ApiPlatform\Metadata\IriConverterInterface; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Metadata\Property\PropertyNameCollection; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; use ApiPlatform\Metadata\ResourceAccessCheckerInterface; use ApiPlatform\Metadata\ResourceClassResolverInterface; use ApiPlatform\Metadata\UrlGeneratorInterface; +use Doctrine\Common\Collections\ArrayCollection; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; use Symfony\Component\Serializer\SerializerInterface; +use Symfony\Component\TypeInfo\Type; /** * @author Kévin Dunglas @@ -254,6 +261,111 @@ public function testNormalizeNoResolverData(): void ])); } + public function testNormalizeMercureSubscriptionNestedCollectionRelations(): void + { + $firstChild = new MercureSubscriptionChildDummy(); + $firstChild->setName('alpha'); + $secondChild = new MercureSubscriptionChildDummy(); + $secondChild->setName('beta'); + $thirdChild = new MercureSubscriptionChildDummy(); + $thirdChild->setName('gamma'); + + $children = new ArrayCollection([$firstChild, $secondChild, $thirdChild]); + + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(MercureSubscriptionChildDummy::class, Argument::type('array'))->willReturn(new PropertyNameCollection(['name'])); + + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(MercureSubscriptionChildDummy::class, 'name', Argument::type('array'))->willReturn( + (new ApiProperty()) + ->withNativeType(Type::string()) + ->withReadable(true) + ); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $identifiersExtractorProphecy = $this->prophesize(IdentifiersExtractorInterface::class); + + $resourceMetadataCollectionFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataCollectionFactoryProphecy->create(MercureSubscriptionChildDummy::class)->willReturn(new ResourceMetadataCollection(MercureSubscriptionChildDummy::class, [ + (new ApiResource())->withGraphQlOperations([ + 'collection_query' => (new QueryCollection())->withName('collection_query')->withClass(MercureSubscriptionChildDummy::class), + ]), + ])); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass(null, MercureSubscriptionChildDummy::class)->willReturn(MercureSubscriptionChildDummy::class); + $resourceClassResolverProphecy->getResourceClass($firstChild, null)->willReturn(MercureSubscriptionChildDummy::class); + $resourceClassResolverProphecy->getResourceClass($secondChild, null)->willReturn(MercureSubscriptionChildDummy::class); + $resourceClassResolverProphecy->getResourceClass($thirdChild, null)->willReturn(MercureSubscriptionChildDummy::class); + $resourceClassResolverProphecy->isResourceClass(MercureSubscriptionChildDummy::class)->willReturn(true); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + $serializerProphecy->willImplement(NormalizerInterface::class); + $serializerProphecy->normalize('alpha', ItemNormalizer::FORMAT, Argument::type('array'))->willReturn('alpha'); + $serializerProphecy->normalize('beta', ItemNormalizer::FORMAT, Argument::type('array'))->willReturn('beta'); + $serializerProphecy->normalize('gamma', ItemNormalizer::FORMAT, Argument::type('array'))->willReturn('gamma'); + + $normalizer = new ItemNormalizer( + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $iriConverterProphecy->reveal(), + $identifiersExtractorProphecy->reveal(), + $resourceClassResolverProphecy->reveal(), + null, + null, + null, + null, + $resourceMetadataCollectionFactoryProphecy->reveal() + ); + $normalizer->setSerializer($serializerProphecy->reveal()); + + $relationProperty = (new ApiProperty()) + ->withNativeType(Type::collection(Type::object(ArrayCollection::class), Type::object(MercureSubscriptionChildDummy::class), Type::int())) + ->withReadable(true) + ->withWritable(false) + ->withReadableLink(true); + + $normalizeCollectionOfRelations = \Closure::bind( + static fn (ItemNormalizer $normalizer, ApiProperty $property, iterable $attributeValue, string $resourceClass, ?string $format, array $context): array => $normalizer->normalizeCollectionOfRelations($property, $attributeValue, $resourceClass, $format, $context), + null, + ItemNormalizer::class + ); + + $this->assertSame([ + 'collection' => [ + ['name' => 'alpha'], + ['name' => 'beta'], + ['name' => 'gamma'], + ], + 'paginationInfo' => [ + 'hasNextPage' => true, + 'itemsPerPage' => 2, + 'lastPage' => 2, + 'totalCount' => 3, + ], + ], $normalizeCollectionOfRelations( + $normalizer, + $relationProperty, + $children, + MercureSubscriptionChildDummy::class, + ItemNormalizer::FORMAT, + [ + 'graphql_operation_name' => 'mercure_subscription', + 'attributes' => [ + 'collection' => ['name' => true], + 'paginationInfo' => [ + 'hasNextPage' => true, + 'itemsPerPage' => true, + 'lastPage' => true, + 'totalCount' => true, + ], + ], + 'pagination' => ['itemsPerPage' => 2], + 'no_resolver_data' => true, + ] + )); + } + public function testDenormalize(): void { $context = ['resource_class' => Dummy::class, 'api_allow_update' => true]; diff --git a/src/GraphQl/Tests/State/Processor/SubscriptionProcessorTest.php b/src/GraphQl/Tests/State/Processor/SubscriptionProcessorTest.php index f9ee045f8dc..759e6d36d0a 100644 --- a/src/GraphQl/Tests/State/Processor/SubscriptionProcessorTest.php +++ b/src/GraphQl/Tests/State/Processor/SubscriptionProcessorTest.php @@ -15,8 +15,10 @@ use ApiPlatform\GraphQl\State\Processor\SubscriptionProcessor; use ApiPlatform\GraphQl\Subscription\MercureSubscriptionIriGeneratorInterface; +use ApiPlatform\GraphQl\Subscription\OperationAwareSubscriptionManagerInterface; use ApiPlatform\GraphQl\Subscription\SubscriptionManagerInterface; use ApiPlatform\Metadata\GraphQl\Subscription; +use ApiPlatform\Metadata\GraphQl\SubscriptionCollection; use ApiPlatform\State\ProcessorInterface; use PHPUnit\Framework\TestCase; @@ -31,9 +33,11 @@ public function testProcess(): void $subscriptionManager = $this->createMock(SubscriptionManagerInterface::class); $subscriptionManager->expects($this->once())->method('retrieveSubscriptionId')->willReturn('/1'); $mercureSubscriptionIriGenerator = $this->createMock(MercureSubscriptionIriGeneratorInterface::class); - $mercureSubscriptionIriGenerator->expects($this->once())->method('generateMercureUrl')->with('/1', $operation->getMercure()['hub']); + $mercureSubscriptionIriGenerator->expects($this->once())->method('generateMercureUrl')->with('/1', $operation->getMercure()['hub'])->willReturn('mercure-url'); $processor = new SubscriptionProcessor($decorated, $subscriptionManager, $mercureSubscriptionIriGenerator); - $processor->process([], $operation, [], $context); + $result = $processor->process([], $operation, [], $context); + + $this->assertSame('mercure-url', $result['mercureUrl']); } public function testProcessWithoutId(): void @@ -63,4 +67,46 @@ public function testProcessWithoutMercure(): void $processor = new SubscriptionProcessor($decorated, $subscriptionManager, $mercureSubscriptionIriGenerator); $processor->process([], $operation, [], $context); } + + public function testProcessForwardsCollectionOperationToOperationAwareManager(): void + { + $operation = new SubscriptionCollection(mercure: ['hub' => 'mercure.rocks']); + $context = ['context' => 'value']; + $decorated = $this->createMock(ProcessorInterface::class); + $decorated->expects($this->once())->method('process')->willReturn([]); + $subscriptionManager = $this->createMock(OperationAwareSubscriptionManagerInterface::class); + $subscriptionManager->expects($this->once())->method('retrieveSubscriptionId')->with($context, [], $operation)->willReturn('/1'); + $mercureSubscriptionIriGenerator = $this->createMock(MercureSubscriptionIriGeneratorInterface::class); + $mercureSubscriptionIriGenerator->expects($this->once())->method('generateMercureUrl')->with('/1', $operation->getMercure()['hub'])->willReturn('mercure-url'); + $processor = new SubscriptionProcessor($decorated, $subscriptionManager, $mercureSubscriptionIriGenerator); + + $result = $processor->process([], $operation, [], $context); + + $this->assertSame('mercure-url', $result['mercureUrl']); + } + + public function testProcessCollectionSubscriptionKeepsDecoratedPayloadShape(): void + { + $operation = new SubscriptionCollection(mercure: ['hub' => 'mercure.rocks']); + $context = ['context' => 'value']; + $decoratedPayload = [ + 'shortName' => ['id' => '/dummies/1'], + 'clientSubscriptionId' => 'client-subscription-id', + ]; + + $decorated = $this->createMock(ProcessorInterface::class); + $decorated->expects($this->once())->method('process')->willReturn($decoratedPayload); + $subscriptionManager = $this->createMock(OperationAwareSubscriptionManagerInterface::class); + $subscriptionManager->expects($this->once())->method('retrieveSubscriptionId')->with($context, $decoratedPayload, $operation)->willReturn('/1'); + $mercureSubscriptionIriGenerator = $this->createMock(MercureSubscriptionIriGeneratorInterface::class); + $mercureSubscriptionIriGenerator->expects($this->once())->method('generateMercureUrl')->with('/1', $operation->getMercure()['hub'])->willReturn('mercure-url'); + $processor = new SubscriptionProcessor($decorated, $subscriptionManager, $mercureSubscriptionIriGenerator); + + $result = $processor->process([], $operation, [], $context); + + $this->assertSame(['id' => '/dummies/1'], $result['shortName']); + $this->assertSame('client-subscription-id', $result['clientSubscriptionId']); + $this->assertSame('mercure-url', $result['mercureUrl']); + $this->assertArrayNotHasKey('isCollection', $result); + } } diff --git a/src/GraphQl/Tests/Subscription/SubscriptionManagerTest.php b/src/GraphQl/Tests/Subscription/SubscriptionManagerTest.php index 7afeaeaef03..fb11ee96526 100644 --- a/src/GraphQl/Tests/Subscription/SubscriptionManagerTest.php +++ b/src/GraphQl/Tests/Subscription/SubscriptionManagerTest.php @@ -17,15 +17,19 @@ use ApiPlatform\GraphQl\Subscription\SubscriptionManager; use ApiPlatform\GraphQl\Tests\Fixtures\ApiResource\Dummy; use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Exception\InvalidArgumentException; use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\GraphQl\Subscription; +use ApiPlatform\Metadata\GraphQl\SubscriptionCollection; use ApiPlatform\Metadata\IriConverterInterface; use ApiPlatform\Metadata\Operations; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; +use ApiPlatform\Metadata\UrlGeneratorInterface; use ApiPlatform\State\ProcessorInterface; use GraphQL\Type\Definition\ResolveInfo; use PHPUnit\Framework\TestCase; +use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Psr\Cache\CacheItemInterface; @@ -58,6 +62,22 @@ protected function setUp(): void $this->subscriptionManager = new SubscriptionManager($this->subscriptionsCacheProphecy->reveal(), $this->subscriptionIdentifierGeneratorProphecy->reveal(), $this->normalizeProcessor->reveal(), $this->iriConverterProphecy->reveal(), $this->resourceMetadataCollectionFactory->reveal()); } + private function createCollectionSubscription(array|bool|null $mercure = null): SubscriptionCollection + { + return (new SubscriptionCollection()) + ->withName('update_collection') + ->withShortName('Dummy') + ->withMercure($mercure); + } + + private function createItemSubscription(array|bool|null $mercure = null): Subscription + { + return (new Subscription()) + ->withName('update') + ->withShortName('Dummy') + ->withMercure($mercure); + } + public function testRetrieveSubscriptionIdNoIdentifier(): void { $info = $this->prophesize(ResolveInfo::class); @@ -171,32 +191,294 @@ public function testRetrieveSubscriptionIdHitCachedDifferentFieldsOrder(): void $this->assertSame('subscriptionIdFoo', $this->subscriptionManager->retrieveSubscriptionId($context, $result)); } + public function testRetrieveSubscriptionIdPartitionedPrivateItemUsesDedicatedCacheKey(): void + { + $infoProphecy = $this->prophesize(ResolveInfo::class); + $fields = ['fields' => true]; + $infoProphecy->getFieldSelection(\PHP_INT_MAX)->willReturn($fields); + + $previousObject = new class { + public function getTenant(): int + { + return 42; + } + }; + + $context = [ + 'args' => ['input' => ['id' => '/foos/34']], + 'info' => $infoProphecy->reveal(), + 'is_collection' => false, + 'is_mutation' => false, + 'is_subscription' => true, + 'graphql_context' => ['previous_object' => $previousObject], + ]; + $result = ['result', 'clientSubscriptionId' => 'client-subscription-id']; + $operation = new Subscription(mercure: ['private' => true, 'private_fields' => ['tenant']]); + + $cacheItemProphecy = $this->prophesize(CacheItemInterface::class); + $cacheItemProphecy->isHit()->willReturn(false); + $subscriptionId = 'subscriptionId'; + $this->subscriptionIdentifierGeneratorProphecy->generateSubscriptionIdentifier($fields)->willReturn($subscriptionId); + $cacheItemProphecy->set([[$subscriptionId, $fields, ['result']]])->shouldBeCalled()->willReturn($cacheItemProphecy->reveal()); + $this->subscriptionsCacheProphecy->getItem('_foos_34_'.hash('sha256', 'tenant=42'))->shouldBeCalled()->willReturn($cacheItemProphecy->reveal()); + $this->subscriptionsCacheProphecy->save($cacheItemProphecy->reveal())->shouldBeCalled(); + + $this->assertSame($subscriptionId, $this->subscriptionManager->retrieveSubscriptionId($context, $result, $operation)); + } + + public function testRetrieveSubscriptionIdPartitionedPrivateItemUsesPropertyAccess(): void + { + $infoProphecy = $this->prophesize(ResolveInfo::class); + $fields = ['fields' => true]; + $infoProphecy->getFieldSelection(\PHP_INT_MAX)->willReturn($fields); + + $previousObject = new class { + public int $tenant = 42; + }; + + $context = [ + 'args' => ['input' => ['id' => '/foos/34']], + 'info' => $infoProphecy->reveal(), + 'is_collection' => false, + 'is_mutation' => false, + 'is_subscription' => true, + 'graphql_context' => ['previous_object' => $previousObject], + ]; + $result = ['result', 'clientSubscriptionId' => 'client-subscription-id']; + $operation = new Subscription(mercure: ['private' => true, 'private_fields' => ['tenant']]); + + $cacheItemProphecy = $this->prophesize(CacheItemInterface::class); + $cacheItemProphecy->isHit()->willReturn(false); + $subscriptionId = 'propertyAccessSubscriptionId'; + $this->subscriptionIdentifierGeneratorProphecy->generateSubscriptionIdentifier($fields)->willReturn($subscriptionId); + $cacheItemProphecy->set([[$subscriptionId, $fields, ['result']]])->shouldBeCalled()->willReturn($cacheItemProphecy->reveal()); + $this->subscriptionsCacheProphecy->getItem('_foos_34_'.hash('sha256', 'tenant=42'))->shouldBeCalled()->willReturn($cacheItemProphecy->reveal()); + $this->subscriptionsCacheProphecy->save($cacheItemProphecy->reveal())->shouldBeCalled(); + + $this->assertSame($subscriptionId, $this->subscriptionManager->retrieveSubscriptionId($context, $result, $operation)); + } + + public function testRetrieveSubscriptionIdSharedPrivateItemDoesNotPartitionCacheKey(): void + { + $infoProphecy = $this->prophesize(ResolveInfo::class); + $fields = ['fields' => true]; + $infoProphecy->getFieldSelection(\PHP_INT_MAX)->willReturn($fields); + + $context = [ + 'args' => ['input' => ['id' => '/foos/34']], + 'info' => $infoProphecy->reveal(), + 'is_collection' => false, + 'is_mutation' => false, + 'is_subscription' => true, + ]; + $result = ['result', 'clientSubscriptionId' => 'client-subscription-id']; + $operation = new Subscription(mercure: ['private' => true]); + + $cacheItemProphecy = $this->prophesize(CacheItemInterface::class); + $cacheItemProphecy->isHit()->willReturn(false); + $subscriptionId = 'sharedPrivateItemSubscriptionId'; + $this->subscriptionIdentifierGeneratorProphecy->generateSubscriptionIdentifier($fields)->willReturn($subscriptionId); + $cacheItemProphecy->set([[$subscriptionId, $fields, ['result']]])->shouldBeCalled()->willReturn($cacheItemProphecy->reveal()); + $this->subscriptionsCacheProphecy->getItem('_foos_34')->shouldBeCalled()->willReturn($cacheItemProphecy->reveal()); + $this->subscriptionsCacheProphecy->save($cacheItemProphecy->reveal())->shouldBeCalled(); + + $this->assertSame($subscriptionId, $this->subscriptionManager->retrieveSubscriptionId($context, $result, $operation)); + } + + public function testRetrieveSubscriptionIdPartitionKeyUsesDeclaredFieldOrderAndNames(): void + { + $infoProphecy = $this->prophesize(ResolveInfo::class); + $fields = ['fields' => true]; + $infoProphecy->getFieldSelection(\PHP_INT_MAX)->willReturn($fields); + + $previousObject = new class { + public function getRegion(): string + { + return 'eu'; + } + + public function getTenant(): int + { + return 42; + } + }; + + $context = [ + 'args' => ['input' => ['id' => '/foos/34']], + 'info' => $infoProphecy->reveal(), + 'is_collection' => false, + 'is_mutation' => false, + 'is_subscription' => true, + 'graphql_context' => ['previous_object' => $previousObject], + ]; + $result = ['result', 'clientSubscriptionId' => 'client-subscription-id']; + $operation = new Subscription(mercure: ['private' => true, 'private_fields' => ['region', 'tenant']]); + + $cacheItemProphecy = $this->prophesize(CacheItemInterface::class); + $cacheItemProphecy->isHit()->willReturn(false); + $subscriptionId = 'orderedPartitionSubscriptionId'; + $this->subscriptionIdentifierGeneratorProphecy->generateSubscriptionIdentifier($fields)->willReturn($subscriptionId); + $cacheItemProphecy->set([[$subscriptionId, $fields, ['result']]])->shouldBeCalled()->willReturn($cacheItemProphecy->reveal()); + $this->subscriptionsCacheProphecy->getItem('_foos_34_'.hash('sha256', 'region=eu|tenant=42'))->shouldBeCalled()->willReturn($cacheItemProphecy->reveal()); + $this->subscriptionsCacheProphecy->save($cacheItemProphecy->reveal())->shouldBeCalled(); + + $this->assertSame($subscriptionId, $this->subscriptionManager->retrieveSubscriptionId($context, $result, $operation)); + } + + public function testRetrieveSubscriptionIdRejectsPrivateFieldsWithoutPrivateMercure(): void + { + $infoProphecy = $this->prophesize(ResolveInfo::class); + $infoProphecy->getFieldSelection(\PHP_INT_MAX)->willReturn(['fields' => true]); + + $context = [ + 'args' => ['input' => ['id' => '/foos/34']], + 'info' => $infoProphecy->reveal(), + 'is_collection' => false, + 'is_mutation' => false, + 'is_subscription' => true, + ]; + $operation = new Subscription(mercure: ['private_fields' => ['tenant']]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('"private_fields" requires "mercure.private" to be true.'); + + $this->subscriptionManager->retrieveSubscriptionId($context, ['result'], $operation); + } + + public function testRetrieveSubscriptionIdCollectionOperationUsesCollectionRegistrationPath(): void + { + $infoProphecy = $this->prophesize(ResolveInfo::class); + $fields = ['fields' => true]; + $infoProphecy->getFieldSelection(\PHP_INT_MAX)->willReturn($fields); + + $context = ['args' => ['input' => ['id' => '/foos/34']], 'info' => $infoProphecy->reveal(), 'is_collection' => true, 'is_mutation' => false, 'is_subscription' => true]; + $operation = $this->createCollectionSubscription(); + + $cacheItemProphecy = $this->prophesize(CacheItemInterface::class); + $cacheItemProphecy->isHit()->willReturn(false); + $subscriptionId = 'collectionSubscriptionId'; + $this->subscriptionIdentifierGeneratorProphecy->generateSubscriptionIdentifier($fields + ['__collection' => true])->willReturn($subscriptionId); + $cacheItemProphecy->set([[$subscriptionId, $fields, []]])->shouldBeCalled()->willReturn($cacheItemProphecy->reveal()); + $this->subscriptionsCacheProphecy->getItem('_foos')->shouldBeCalled()->willReturn($cacheItemProphecy->reveal()); + $this->subscriptionsCacheProphecy->save($cacheItemProphecy->reveal())->shouldBeCalled(); + + $this->assertSame($subscriptionId, $this->subscriptionManager->retrieveSubscriptionId($context, null, $operation)); + } + + public function testRetrieveSubscriptionIdSharedPrivateCollectionDoesNotPartitionCacheKey(): void + { + $infoProphecy = $this->prophesize(ResolveInfo::class); + $fields = ['fields' => true]; + $infoProphecy->getFieldSelection(\PHP_INT_MAX)->willReturn($fields); + + $context = ['args' => ['input' => ['id' => '/foos/34']], 'info' => $infoProphecy->reveal(), 'is_collection' => true, 'is_mutation' => false, 'is_subscription' => true]; + $operation = $this->createCollectionSubscription(['private' => true]); + + $cacheItemProphecy = $this->prophesize(CacheItemInterface::class); + $cacheItemProphecy->isHit()->willReturn(false); + $subscriptionId = 'sharedPrivateCollectionSubscriptionId'; + $this->subscriptionIdentifierGeneratorProphecy->generateSubscriptionIdentifier($fields + ['__collection' => true])->willReturn($subscriptionId); + $cacheItemProphecy->set([[$subscriptionId, $fields, []]])->shouldBeCalled()->willReturn($cacheItemProphecy->reveal()); + $this->subscriptionsCacheProphecy->getItem('_foos')->shouldBeCalled()->willReturn($cacheItemProphecy->reveal()); + $this->subscriptionsCacheProphecy->save($cacheItemProphecy->reveal())->shouldBeCalled(); + + $this->assertSame($subscriptionId, $this->subscriptionManager->retrieveSubscriptionId($context, null, $operation)); + } + + public function testRetrieveSubscriptionIdPartitionedPrivateCollectionUsesDedicatedCacheKey(): void + { + $infoProphecy = $this->prophesize(ResolveInfo::class); + $fields = ['fields' => true]; + $infoProphecy->getFieldSelection(\PHP_INT_MAX)->willReturn($fields); + + $previousObject = new class { + public function getTenant(): int + { + return 42; + } + }; + + $context = [ + 'args' => ['input' => ['id' => '/foos/34']], + 'info' => $infoProphecy->reveal(), + 'is_collection' => true, + 'is_mutation' => false, + 'is_subscription' => true, + 'graphql_context' => ['previous_object' => $previousObject], + ]; + $operation = $this->createCollectionSubscription(['private' => true, 'private_fields' => ['tenant']]); + + $cacheItemProphecy = $this->prophesize(CacheItemInterface::class); + $cacheItemProphecy->isHit()->willReturn(false); + $subscriptionId = 'partitionedCollectionSubscriptionId'; + $this->subscriptionIdentifierGeneratorProphecy->generateSubscriptionIdentifier($fields + ['__collection' => true])->willReturn($subscriptionId); + $cacheItemProphecy->set([[$subscriptionId, $fields, []]])->shouldBeCalled()->willReturn($cacheItemProphecy->reveal()); + $this->subscriptionsCacheProphecy->getItem('_foos_'.hash('sha256', 'tenant=42'))->shouldBeCalled()->willReturn($cacheItemProphecy->reveal()); + $this->subscriptionsCacheProphecy->save($cacheItemProphecy->reveal())->shouldBeCalled(); + + $this->assertSame($subscriptionId, $this->subscriptionManager->retrieveSubscriptionId($context, null, $operation)); + } + + public function testRetrieveSubscriptionIdCollectionUsesOperationBasedCollectionSubscriptionIri(): void + { + $infoProphecy = $this->prophesize(ResolveInfo::class); + $fields = ['fields' => true]; + $infoProphecy->getFieldSelection(\PHP_INT_MAX)->willReturn($fields); + + $context = ['args' => ['input' => ['id' => '/foos/34']], 'info' => $infoProphecy->reveal(), 'is_collection' => true, 'is_mutation' => false, 'is_subscription' => true]; + $operation = $this->createCollectionSubscription(true)->withClass(Dummy::class); + + $cacheItemProphecy = $this->prophesize(CacheItemInterface::class); + $cacheItemProphecy->isHit()->willReturn(false); + $subscriptionId = 'subscriptionId'; + $this->iriConverterProphecy->getIriFromResource(Dummy::class, UrlGeneratorInterface::ABS_PATH, $operation)->shouldBeCalled()->willReturn('/graphql/dummies'); + $this->subscriptionIdentifierGeneratorProphecy->generateSubscriptionIdentifier($fields + ['__collection' => true])->willReturn($subscriptionId); + $cacheItemProphecy->set([[$subscriptionId, $fields, []]])->shouldBeCalled()->willReturn($cacheItemProphecy->reveal()); + $this->subscriptionsCacheProphecy->getItem('_graphql_dummies')->shouldBeCalled()->willReturn($cacheItemProphecy->reveal()); + $this->subscriptionsCacheProphecy->save($cacheItemProphecy->reveal())->shouldBeCalled(); + + $this->assertSame($subscriptionId, $this->subscriptionManager->retrieveSubscriptionId($context, null, $operation)); + } + public function testGetPushPayloadsNoHit(): void { $object = new Dummy(); + $itemSubscription = $this->createItemSubscription(true); $this->resourceMetadataCollectionFactory->create(Dummy::class)->willReturn(new ResourceMetadataCollection(Dummy::class, [ - (new ApiResource())->withOperations(new Operations([(new Get())->withShortName('Dummy')])), + (new ApiResource()) + ->withOperations(new Operations([(new Get())->withShortName('Dummy')])) + ->withGraphQlOperations(['update' => $itemSubscription]), ])); $this->iriConverterProphecy->getIriFromResource($object)->willReturn('/dummies/2'); $cacheItemProphecy = $this->prophesize(CacheItemInterface::class); $cacheItemProphecy->isHit()->willReturn(false); + $cacheItemProphecy->isHit()->willReturn(false); $this->subscriptionsCacheProphecy->getItem('_dummies_2')->willReturn($cacheItemProphecy->reveal()); + $this->subscriptionsCacheProphecy->getItem('_dummies')->willReturn($cacheItemProphecy->reveal()); - $this->assertEquals([], $this->subscriptionManager->getPushPayloads($object)); + $this->assertEquals([], $this->subscriptionManager->getPushPayloads($object, 'update')); } public function testGetPushPayloadsHit(): void { $object = new Dummy(); + $itemSubscription = $this->createItemSubscription(true); + $collectionOperation = $this->createCollectionSubscription(true); $this->resourceMetadataCollectionFactory->create(Dummy::class)->willReturn(new ResourceMetadataCollection(Dummy::class, [ - (new ApiResource())->withOperations(new Operations([(new Get())->withShortName('Dummy')])), + (new ApiResource()) + ->withOperations(new Operations([(new Get())->withShortName('Dummy')])) + ->withGraphQlOperations([ + 'update' => $itemSubscription, + 'update_collection' => $collectionOperation, + ]), ])); $this->iriConverterProphecy->getIriFromResource($object)->willReturn('/dummies/2'); + $this->iriConverterProphecy->getIriFromResource(Dummy::class, UrlGeneratorInterface::ABS_PATH, $collectionOperation)->shouldBeCalled()->willReturn('/graphql/dummies'); $cacheItemProphecy = $this->prophesize(CacheItemInterface::class); $cacheItemProphecy->isHit()->willReturn(true); @@ -204,11 +486,23 @@ public function testGetPushPayloadsHit(): void ['subscriptionIdFoo', ['fieldsFoo'], ['resultFoo']], ['subscriptionIdBar', ['fieldsBar'], ['resultBar']], ]); + $cacheItemProphecy->set([ + ['subscriptionIdFoo', ['fieldsFoo'], ['newResultFoo']], + ['subscriptionIdBar', ['fieldsBar'], ['resultBar']], + ])->shouldBeCalled()->willReturn($cacheItemProphecy->reveal()); + $cacheItemProphecyCollection = $this->prophesize(CacheItemInterface::class); + $cacheItemProphecyCollection->isHit()->willReturn(true); + $cacheItemProphecyCollection->get()->willReturn([ + ['subscriptionIdFoo', ['fieldsFoo'], []], + ['subscriptionIdBar', ['fieldsBar'], []], + ]); $this->subscriptionsCacheProphecy->getItem('_dummies_2')->willReturn($cacheItemProphecy->reveal()); + $this->subscriptionsCacheProphecy->getItem('_graphql_dummies')->shouldBeCalled()->willReturn($cacheItemProphecyCollection->reveal()); + $this->subscriptionsCacheProphecy->save($cacheItemProphecy->reveal())->shouldBeCalled(); $this->normalizeProcessor->process( $object, - (new Subscription())->withName('update_subscription')->withShortName('Dummy'), + (new Subscription())->withName('mercure_subscription')->withShortName('Dummy'), [], ['fields' => ['fieldsFoo'], 'is_collection' => false, 'is_mutation' => false, 'is_subscription' => true] )->willReturn( @@ -217,13 +511,442 @@ public function testGetPushPayloadsHit(): void $this->normalizeProcessor->process( $object, - (new Subscription())->withName('update_subscription')->withShortName('Dummy'), + (new Subscription())->withName('mercure_subscription')->withShortName('Dummy'), [], ['fields' => ['fieldsBar'], 'is_collection' => false, 'is_mutation' => false, 'is_subscription' => true] )->willReturn( ['resultBar', 'clientSubscriptionId' => 'client-subscription-id'] ); - $this->assertEquals([['subscriptionIdFoo', ['newResultFoo']]], $this->subscriptionManager->getPushPayloads($object)); + $this->assertEquals([['subscriptionIdFoo', ['newResultFoo']], ['subscriptionIdBar', ['resultBar']]], $this->subscriptionManager->getPushPayloads($object, 'update')); + } + + public function testGetPushPayloadsUpdatesCachedItemSnapshotAfterPublishing(): void + { + $object = new Dummy(); + $itemSubscription = $this->createItemSubscription(true); + $collectionOperation = $this->createCollectionSubscription(true); + + $this->resourceMetadataCollectionFactory->create(Dummy::class)->willReturn(new ResourceMetadataCollection(Dummy::class, [ + (new ApiResource()) + ->withOperations(new Operations([(new Get())->withShortName('Dummy')])) + ->withGraphQlOperations([ + 'update' => $itemSubscription, + 'update_collection' => $collectionOperation, + ]), + ])); + + $this->iriConverterProphecy->getIriFromResource($object)->willReturn('/dummies/2'); + $this->iriConverterProphecy->getIriFromResource(Dummy::class, UrlGeneratorInterface::ABS_PATH, $collectionOperation)->willReturn('/graphql/dummies'); + + $itemCacheItemFirstCallProphecy = $this->prophesize(CacheItemInterface::class); + $itemCacheItemFirstCallProphecy->isHit()->willReturn(true); + $itemCacheItemFirstCallProphecy->get()->willReturn([ + ['subscriptionIdFoo', ['fieldsFoo'], ['staleResultFoo']], + ]); + $itemCacheItemFirstCallProphecy->set([ + ['subscriptionIdFoo', ['fieldsFoo'], ['freshResultFoo']], + ])->shouldBeCalled()->willReturn($itemCacheItemFirstCallProphecy->reveal()); + + $itemCacheItemSecondCallProphecy = $this->prophesize(CacheItemInterface::class); + $itemCacheItemSecondCallProphecy->isHit()->willReturn(true); + $itemCacheItemSecondCallProphecy->get()->willReturn([ + ['subscriptionIdFoo', ['fieldsFoo'], ['freshResultFoo']], + ]); + $itemCacheItemSecondCallProphecy->set(Argument::any())->shouldNotBeCalled(); + + $collectionCacheItemProphecy = $this->prophesize(CacheItemInterface::class); + $collectionCacheItemProphecy->isHit()->willReturn(false); + + $this->subscriptionsCacheProphecy->getItem('_graphql_dummies')->willReturn($collectionCacheItemProphecy->reveal()); + $this->subscriptionsCacheProphecy->getItem('_dummies_2')->willReturn( + $itemCacheItemFirstCallProphecy->reveal(), + $itemCacheItemSecondCallProphecy->reveal() + ); + $this->subscriptionsCacheProphecy->save($itemCacheItemFirstCallProphecy->reveal())->shouldBeCalledTimes(1); + + $this->normalizeProcessor->process( + $object, + (new Subscription())->withName('mercure_subscription')->withShortName('Dummy'), + [], + ['fields' => ['fieldsFoo'], 'is_collection' => false, 'is_mutation' => false, 'is_subscription' => true] + )->willReturn( + ['freshResultFoo', 'clientSubscriptionId' => 'client-subscription-id'], + ['freshResultFoo', 'clientSubscriptionId' => 'client-subscription-id'] + ); + + $this->assertEquals([['subscriptionIdFoo', ['freshResultFoo']]], $this->subscriptionManager->getPushPayloads($object, 'update')); + $this->assertEquals([], $this->subscriptionManager->getPushPayloads($object, 'update')); + } + + public function testGetPushPayloadsCreateTargetsCollectionSubscriptionsOnly(): void + { + $object = new Dummy(); + $itemSubscription = $this->createItemSubscription(true); + $collectionOperation = $this->createCollectionSubscription(true); + + $this->resourceMetadataCollectionFactory->create(Dummy::class)->willReturn(new ResourceMetadataCollection(Dummy::class, [ + (new ApiResource()) + ->withOperations(new Operations([(new Get())->withShortName('Dummy')])) + ->withGraphQlOperations([ + 'update' => $itemSubscription, + 'update_collection' => $collectionOperation, + ]), + ])); + + $this->iriConverterProphecy->getIriFromResource($object)->willReturn('/dummies/2'); + $this->iriConverterProphecy->getIriFromResource(Dummy::class, UrlGeneratorInterface::ABS_PATH, $collectionOperation)->shouldBeCalled()->willReturn('/graphql/dummies'); + + $cacheItemProphecyCollection = $this->prophesize(CacheItemInterface::class); + $cacheItemProphecyCollection->isHit()->willReturn(true); + $cacheItemProphecyCollection->get()->willReturn([ + ['collectionSubscriptionId', ['collectionFields'], []], + ]); + $this->subscriptionsCacheProphecy->getItem('_graphql_dummies')->shouldBeCalled()->willReturn($cacheItemProphecyCollection->reveal()); + $this->subscriptionsCacheProphecy->getItem('_dummies_2')->shouldNotBeCalled(); + + $this->normalizeProcessor->process( + $object, + (new Subscription())->withName('mercure_subscription')->withShortName('Dummy'), + [], + ['fields' => ['collectionFields'], 'is_collection' => false, 'is_mutation' => false, 'is_subscription' => true] + )->willReturn( + ['createdResult', 'clientSubscriptionId' => 'client-subscription-id'] + ); + + $this->assertEquals([['collectionSubscriptionId', ['createdResult']]], $this->subscriptionManager->getPushPayloads($object, 'create')); + } + + public function testGetPushPayloadsCreateUsesSharedPrivateCollectionCacheKey(): void + { + $object = new Dummy(); + $collectionOperation = $this->createCollectionSubscription(['private' => true]); + + $this->resourceMetadataCollectionFactory->create(Dummy::class)->willReturn(new ResourceMetadataCollection(Dummy::class, [ + (new ApiResource()) + ->withOperations(new Operations([ + (new Get())->withShortName('Dummy')->withMercure(['private' => true]), + ])) + ->withGraphQlOperations([ + 'update_collection' => $collectionOperation, + ]), + ])); + + $this->iriConverterProphecy->getIriFromResource($object)->willReturn('/dummies/2'); + $this->iriConverterProphecy->getIriFromResource(Dummy::class, UrlGeneratorInterface::ABS_PATH, $collectionOperation)->shouldBeCalled()->willReturn('/graphql/dummies'); + + $collectionCacheItemProphecy = $this->prophesize(CacheItemInterface::class); + $collectionCacheItemProphecy->isHit()->willReturn(true); + $collectionCacheItemProphecy->get()->willReturn([ + ['sharedPrivateCollectionSubscriptionId', ['collectionFields'], []], + ]); + + $this->subscriptionsCacheProphecy->getItem('_graphql_dummies')->shouldBeCalled()->willReturn($collectionCacheItemProphecy->reveal()); + $this->subscriptionsCacheProphecy->getItem(Argument::containingString(hash('sha256', 'tenant=')))->shouldNotBeCalled(); + + $this->normalizeProcessor->process( + $object, + (new Subscription())->withName('mercure_subscription')->withShortName('Dummy'), + [], + ['fields' => ['collectionFields'], 'is_collection' => false, 'is_mutation' => false, 'is_subscription' => true] + )->willReturn( + ['sharedPrivateCreatedResult', 'clientSubscriptionId' => 'client-subscription-id'] + ); + + $this->assertEquals([['sharedPrivateCollectionSubscriptionId', ['sharedPrivateCreatedResult']]], $this->subscriptionManager->getPushPayloads($object, 'create')); + } + + public function testGetPushPayloadsCreateUsesPartitionedPrivateCollectionCacheKey(): void + { + $object = new class extends Dummy { + public function getTenant(): int + { + return 42; + } + }; + $collectionOperation = $this->createCollectionSubscription(['private' => true, 'private_fields' => ['tenant']]); + $partitionKey = hash('sha256', 'tenant=42'); + + $this->resourceMetadataCollectionFactory->create($object::class)->willReturn(new ResourceMetadataCollection($object::class, [ + (new ApiResource()) + ->withOperations(new Operations([ + (new Get())->withShortName('Dummy')->withMercure(['private' => true, 'private_fields' => ['tenant']]), + ])) + ->withGraphQlOperations([ + 'update_collection' => $collectionOperation, + ]), + ])); + + $this->iriConverterProphecy->getIriFromResource($object)->willReturn('/dummies/2'); + $this->iriConverterProphecy->getIriFromResource($object::class, UrlGeneratorInterface::ABS_PATH, $collectionOperation)->shouldBeCalled()->willReturn('/graphql/dummies'); + + $collectionCacheItemProphecy = $this->prophesize(CacheItemInterface::class); + $collectionCacheItemProphecy->isHit()->willReturn(true); + $collectionCacheItemProphecy->get()->willReturn([ + ['partitionedCollectionSubscriptionId', ['collectionFields'], []], + ]); + + $this->subscriptionsCacheProphecy->getItem('_graphql_dummies_'.$partitionKey)->shouldBeCalled()->willReturn($collectionCacheItemProphecy->reveal()); + + $this->normalizeProcessor->process( + $object, + (new Subscription())->withName('mercure_subscription')->withShortName('Dummy'), + [], + ['fields' => ['collectionFields'], 'is_collection' => false, 'is_mutation' => false, 'is_subscription' => true] + )->willReturn( + ['partitionedCreatedResult', 'clientSubscriptionId' => 'client-subscription-id'] + ); + + $this->assertEquals([['partitionedCollectionSubscriptionId', ['partitionedCreatedResult']]], $this->subscriptionManager->getPushPayloads($object, 'create')); + } + + public function testGetPushPayloadsUpdatePublishesCollectionSubscriptionWithoutItemSubscription(): void + { + $object = new Dummy(); + $itemSubscription = $this->createItemSubscription(true); + $collectionOperation = $this->createCollectionSubscription(true); + + $this->resourceMetadataCollectionFactory->create(Dummy::class)->willReturn(new ResourceMetadataCollection(Dummy::class, [ + (new ApiResource()) + ->withOperations(new Operations([(new Get())->withShortName('Dummy')])) + ->withGraphQlOperations([ + 'update' => $itemSubscription, + 'update_collection' => $collectionOperation, + ]), + ])); + + $this->iriConverterProphecy->getIriFromResource($object)->willReturn('/dummies/2'); + $this->iriConverterProphecy->getIriFromResource(Dummy::class, UrlGeneratorInterface::ABS_PATH, $collectionOperation)->shouldBeCalled()->willReturn('/graphql/dummies'); + + $itemCacheItemProphecy = $this->prophesize(CacheItemInterface::class); + $itemCacheItemProphecy->isHit()->willReturn(false); + + $collectionCacheItemProphecy = $this->prophesize(CacheItemInterface::class); + $collectionCacheItemProphecy->isHit()->willReturn(true); + $collectionCacheItemProphecy->get()->willReturn([ + ['collectionSubscriptionId', ['collectionFields'], []], + ]); + + $this->subscriptionsCacheProphecy->getItem('_dummies_2')->shouldBeCalled()->willReturn($itemCacheItemProphecy->reveal()); + $this->subscriptionsCacheProphecy->getItem('_graphql_dummies')->shouldBeCalled()->willReturn($collectionCacheItemProphecy->reveal()); + + $this->normalizeProcessor->process( + $object, + (new Subscription())->withName('mercure_subscription')->withShortName('Dummy'), + [], + ['fields' => ['collectionFields'], 'is_collection' => false, 'is_mutation' => false, 'is_subscription' => true] + )->willReturn( + ['updatedCollectionResult', 'clientSubscriptionId' => 'client-subscription-id'] + ); + + $this->assertEquals([['collectionSubscriptionId', ['updatedCollectionResult']]], $this->subscriptionManager->getPushPayloads($object, 'update')); + } + + public function testGetPushPayloadsUpdateUsesSharedPrivateCollectionAndItemCacheKeys(): void + { + $object = new Dummy(); + $itemSubscription = $this->createItemSubscription(['private' => true]); + $collectionOperation = $this->createCollectionSubscription(['private' => true]); + + $this->resourceMetadataCollectionFactory->create(Dummy::class)->willReturn(new ResourceMetadataCollection(Dummy::class, [ + (new ApiResource()) + ->withOperations(new Operations([ + (new Get())->withShortName('Dummy')->withMercure(['private' => true]), + ])) + ->withGraphQlOperations([ + 'update' => $itemSubscription, + 'update_collection' => $collectionOperation, + ]), + ])); + + $this->iriConverterProphecy->getIriFromResource($object)->willReturn('/dummies/2'); + $this->iriConverterProphecy->getIriFromResource(Dummy::class, UrlGeneratorInterface::ABS_PATH, $collectionOperation)->shouldBeCalled()->willReturn('/graphql/dummies'); + + $itemCacheItemProphecy = $this->prophesize(CacheItemInterface::class); + $itemCacheItemProphecy->isHit()->willReturn(false); + + $collectionCacheItemProphecy = $this->prophesize(CacheItemInterface::class); + $collectionCacheItemProphecy->isHit()->willReturn(true); + $collectionCacheItemProphecy->get()->willReturn([ + ['sharedPrivateCollectionSubscriptionId', ['collectionFields'], []], + ]); + + $this->subscriptionsCacheProphecy->getItem('_dummies_2')->shouldBeCalled()->willReturn($itemCacheItemProphecy->reveal()); + $this->subscriptionsCacheProphecy->getItem('_graphql_dummies')->shouldBeCalled()->willReturn($collectionCacheItemProphecy->reveal()); + + $this->normalizeProcessor->process( + $object, + (new Subscription())->withName('mercure_subscription')->withShortName('Dummy'), + [], + ['fields' => ['collectionFields'], 'is_collection' => false, 'is_mutation' => false, 'is_subscription' => true] + )->willReturn( + ['sharedPrivateUpdatedResult', 'clientSubscriptionId' => 'client-subscription-id'] + ); + + $this->assertEquals([['sharedPrivateCollectionSubscriptionId', ['sharedPrivateUpdatedResult']]], $this->subscriptionManager->getPushPayloads($object, 'update')); + } + + public function testGetPushPayloadsUpdateUsesPartitionedPrivateCollectionAndItemCacheKeys(): void + { + $object = new class extends Dummy { + public function getTenant(): int + { + return 42; + } + }; + $itemSubscription = $this->createItemSubscription(['private' => true, 'private_fields' => ['tenant']]); + $collectionOperation = $this->createCollectionSubscription(['private' => true, 'private_fields' => ['tenant']]); + $partitionKey = hash('sha256', 'tenant=42'); + + $this->resourceMetadataCollectionFactory->create($object::class)->willReturn(new ResourceMetadataCollection($object::class, [ + (new ApiResource()) + ->withOperations(new Operations([ + (new Get())->withShortName('Dummy')->withMercure(['private' => true, 'private_fields' => ['tenant']]), + ])) + ->withGraphQlOperations([ + 'update' => $itemSubscription, + 'update_collection' => $collectionOperation, + ]), + ])); + + $this->iriConverterProphecy->getIriFromResource($object)->willReturn('/dummies/2'); + $this->iriConverterProphecy->getIriFromResource($object::class, UrlGeneratorInterface::ABS_PATH, $collectionOperation)->shouldBeCalled()->willReturn('/graphql/dummies'); + + $itemCacheItemProphecy = $this->prophesize(CacheItemInterface::class); + $itemCacheItemProphecy->isHit()->willReturn(false); + + $collectionCacheItemProphecy = $this->prophesize(CacheItemInterface::class); + $collectionCacheItemProphecy->isHit()->willReturn(true); + $collectionCacheItemProphecy->get()->willReturn([ + ['partitionedCollectionSubscriptionId', ['collectionFields'], []], + ]); + + $this->subscriptionsCacheProphecy->getItem('_dummies_2_'.$partitionKey)->shouldBeCalled()->willReturn($itemCacheItemProphecy->reveal()); + $this->subscriptionsCacheProphecy->getItem('_graphql_dummies_'.$partitionKey)->shouldBeCalled()->willReturn($collectionCacheItemProphecy->reveal()); + + $this->normalizeProcessor->process( + $object, + (new Subscription())->withName('mercure_subscription')->withShortName('Dummy'), + [], + ['fields' => ['collectionFields'], 'is_collection' => false, 'is_mutation' => false, 'is_subscription' => true] + )->willReturn( + ['partitionedUpdatedResult', 'clientSubscriptionId' => 'client-subscription-id'] + ); + + $this->assertEquals([['partitionedCollectionSubscriptionId', ['partitionedUpdatedResult']]], $this->subscriptionManager->getPushPayloads($object, 'update')); + } + + public function testGetPushPayloadsDeleteReturnsLightweightPayloadAndRemovesItemCache(): void + { + $object = new class { + public string $id = '/dummies/2'; + public string $iri = '/dummies/2'; + public string $type = 'Dummy'; + public array $private = []; + }; + + $itemCacheItemProphecy = $this->prophesize(CacheItemInterface::class); + $itemCacheItemProphecy->isHit()->willReturn(true); + $itemCacheItemProphecy->get()->willReturn([ + ['itemSubscriptionId', ['itemFields'], ['result']], + ]); + + $collectionCacheItemProphecy = $this->prophesize(CacheItemInterface::class); + $collectionCacheItemProphecy->isHit()->willReturn(true); + $collectionCacheItemProphecy->get()->willReturn([ + ['collectionSubscriptionId', ['collectionFields'], []], + ]); + + $this->subscriptionsCacheProphecy->getItem('_dummies_2')->shouldBeCalled()->willReturn($itemCacheItemProphecy->reveal()); + $this->subscriptionsCacheProphecy->getItem('_dummies')->shouldBeCalled()->willReturn($collectionCacheItemProphecy->reveal()); + $this->subscriptionsCacheProphecy->hasItem('_dummies_2')->shouldBeCalled()->willReturn(true); + $this->subscriptionsCacheProphecy->deleteItem('_dummies_2')->shouldBeCalled(); + + $payload = ['type' => 'delete', 'payload' => ['id' => '/dummies/2', 'iri' => '/dummies/2', 'type' => 'Dummy']]; + + $this->assertEquals([ + ['itemSubscriptionId', $payload], + ['collectionSubscriptionId', $payload], + ], $this->subscriptionManager->getPushPayloads($object, 'delete')); + } + + public function testGetPushPayloadsDeleteReturnsPartitionedPrivatePayloadsAndRemovesPartitionedItemCache(): void + { + $object = new class { + public string $id = '/dummies/2'; + public string $iri = '/dummies/2'; + public string $type = 'Dummy'; + public array $private = ['tenant' => '42']; + }; + + $partitionKey = hash('sha256', 'tenant=42'); + + $itemCacheItemProphecy = $this->prophesize(CacheItemInterface::class); + $itemCacheItemProphecy->isHit()->willReturn(true); + $itemCacheItemProphecy->get()->willReturn([ + ['partitionedItemSubscriptionId', ['itemFields'], ['result']], + ]); + + $collectionCacheItemProphecy = $this->prophesize(CacheItemInterface::class); + $collectionCacheItemProphecy->isHit()->willReturn(true); + $collectionCacheItemProphecy->get()->willReturn([ + ['partitionedCollectionSubscriptionId', ['collectionFields'], []], + ]); + + $this->subscriptionsCacheProphecy->getItem('_dummies_2_'.$partitionKey)->shouldBeCalled()->willReturn($itemCacheItemProphecy->reveal()); + $this->subscriptionsCacheProphecy->getItem('_dummies_'.$partitionKey)->shouldBeCalled()->willReturn($collectionCacheItemProphecy->reveal()); + $this->subscriptionsCacheProphecy->hasItem('_dummies_2_'.$partitionKey)->shouldBeCalled()->willReturn(true); + $this->subscriptionsCacheProphecy->deleteItem('_dummies_2_'.$partitionKey)->shouldBeCalled(); + + $payload = ['type' => 'delete', 'payload' => ['id' => '/dummies/2', 'iri' => '/dummies/2', 'type' => 'Dummy']]; + + $this->assertEquals([ + ['partitionedItemSubscriptionId', $payload], + ['partitionedCollectionSubscriptionId', $payload], + ], $this->subscriptionManager->getPushPayloads($object, 'delete')); + } + + public function testGetPushPayloadsDeleteUsesMetadataBasedCollectionSubscriptionIri(): void + { + $object = new class { + public string $resourceClass = Dummy::class; + public string $id = '/dummies/2'; + public string $iri = '/dummies/2'; + public string $type = 'Dummy'; + public array $private = []; + }; + $collectionOperation = $this->createCollectionSubscription(true); + + $this->resourceMetadataCollectionFactory->create(Dummy::class)->willReturn(new ResourceMetadataCollection(Dummy::class, [ + (new ApiResource())->withGraphQlOperations([ + 'update_collection' => $collectionOperation, + ]), + ])); + + $this->iriConverterProphecy->getIriFromResource(Dummy::class, UrlGeneratorInterface::ABS_PATH, $collectionOperation)->shouldBeCalled()->willReturn('/graphql/dummies'); + + $itemCacheItemProphecy = $this->prophesize(CacheItemInterface::class); + $itemCacheItemProphecy->isHit()->willReturn(true); + $itemCacheItemProphecy->get()->willReturn([ + ['itemSubscriptionId', ['itemFields'], ['result']], + ]); + + $collectionCacheItemProphecy = $this->prophesize(CacheItemInterface::class); + $collectionCacheItemProphecy->isHit()->willReturn(true); + $collectionCacheItemProphecy->get()->willReturn([ + ['collectionSubscriptionId', ['collectionFields'], []], + ]); + + $this->subscriptionsCacheProphecy->getItem('_dummies_2')->shouldBeCalled()->willReturn($itemCacheItemProphecy->reveal()); + $this->subscriptionsCacheProphecy->getItem('_graphql_dummies')->shouldBeCalled()->willReturn($collectionCacheItemProphecy->reveal()); + $this->subscriptionsCacheProphecy->hasItem('_dummies_2')->shouldBeCalled()->willReturn(true); + $this->subscriptionsCacheProphecy->deleteItem('_dummies_2')->shouldBeCalled(); + + $payload = ['type' => 'delete', 'payload' => ['id' => '/dummies/2', 'iri' => '/dummies/2', 'type' => 'Dummy']]; + + $this->assertEquals([ + ['itemSubscriptionId', $payload], + ['collectionSubscriptionId', $payload], + ], $this->subscriptionManager->getPushPayloads($object, 'delete')); } } diff --git a/src/GraphQl/Tests/Type/FieldsBuilderTest.php b/src/GraphQl/Tests/Type/FieldsBuilderTest.php index 268c7d56295..86a85555f5b 100644 --- a/src/GraphQl/Tests/Type/FieldsBuilderTest.php +++ b/src/GraphQl/Tests/Type/FieldsBuilderTest.php @@ -28,6 +28,7 @@ use ApiPlatform\Metadata\GraphQl\Query; use ApiPlatform\Metadata\GraphQl\QueryCollection; use ApiPlatform\Metadata\GraphQl\Subscription; +use ApiPlatform\Metadata\GraphQl\SubscriptionCollection; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Metadata\Property\PropertyNameCollection; @@ -461,6 +462,46 @@ public static function subscriptionFieldsProvider(): array ], ], ], + 'collection subscription' => [\stdClass::class, (new SubscriptionCollection())->withClass(\stdClass::class)->withName('action_collection')->withShortName('ShortName')->withMercure(true), $graphqlType = new ObjectType(['name' => 'subscription', 'fields' => []]), $inputGraphqlType = new ObjectType(['name' => 'input', 'fields' => []]), $subscriptionResolver = static function (): void { + }, + [ + 'action_collectionShortNameSubscribe' => [ + 'type' => $graphqlType, + 'description' => 'Subscribes to the action event of a ShortName.', + 'args' => [ + 'input' => [ + 'type' => $inputGraphqlType, + 'description' => null, + 'args' => [], + 'resolve' => null, + 'deprecationReason' => null, + ], + ], + 'resolve' => $subscriptionResolver, + 'deprecationReason' => null, + ], + ], + ], + 'collection subscription custom description' => [\stdClass::class, (new SubscriptionCollection())->withClass(\stdClass::class)->withName('action_collection')->withShortName('ShortName')->withMercure(true)->withDescription('Custom collection description.'), $graphqlType = new ObjectType(['name' => 'subscription', 'fields' => []]), $inputGraphqlType = new ObjectType(['name' => 'input', 'fields' => []]), $subscriptionResolver = static function (): void { + }, + [ + 'action_collectionShortNameSubscribe' => [ + 'type' => $graphqlType, + 'description' => 'Custom collection description.', + 'args' => [ + 'input' => [ + 'type' => $inputGraphqlType, + 'description' => null, + 'args' => [], + 'resolve' => null, + 'deprecationReason' => null, + ], + ], + 'resolve' => $subscriptionResolver, + 'deprecationReason' => null, + ], + ], + ], ]; } diff --git a/src/GraphQl/Tests/Type/SchemaBuilderTest.php b/src/GraphQl/Tests/Type/SchemaBuilderTest.php index 5ba6b42af2e..a4c7b93f9b4 100644 --- a/src/GraphQl/Tests/Type/SchemaBuilderTest.php +++ b/src/GraphQl/Tests/Type/SchemaBuilderTest.php @@ -23,6 +23,7 @@ use ApiPlatform\Metadata\GraphQl\Query; use ApiPlatform\Metadata\GraphQl\QueryCollection; use ApiPlatform\Metadata\GraphQl\Subscription; +use ApiPlatform\Metadata\GraphQl\SubscriptionCollection; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; @@ -79,6 +80,7 @@ public function testGetSchema(string $resourceClass, ResourceMetadataCollection $this->fieldsBuilderProphecy->getCollectionQueryFields($resourceClass, Argument::that(static fn (Operation $arg): bool => 'custom_collection_query' === $arg->getName()), [])->willReturn(['custom_collection_query' => ['custom_collection_query_fields']]); $this->fieldsBuilderProphecy->getMutationFields($resourceClass, Argument::that(static fn (Operation $arg): bool => 'mutation' === $arg->getName()))->willReturn(['mutation' => ['mutation_fields']]); $this->fieldsBuilderProphecy->getSubscriptionFields($resourceClass, Argument::that(static fn (Operation $arg): bool => 'update' === $arg->getName()))->willReturn(['subscription' => ['subscription_fields']]); + $this->fieldsBuilderProphecy->getSubscriptionFields($resourceClass, Argument::that(static fn (Operation $arg): bool => 'update_collection' === $arg->getName()))->willReturn(['collectionSubscription' => ['collection_subscription_fields']]); $this->resourceNameCollectionFactoryProphecy->create()->willReturn(new ResourceNameCollection([$resourceClass])); $this->resourceMetadataCollectionFactoryProphecy->create($resourceClass)->willReturn($resourceMetadata); @@ -176,6 +178,40 @@ public static function schemaProvider(): array ], ]), ], + 'collection subscription' => [$resourceClass = 'resourceClass', new ResourceMetadataCollection($resourceClass, [(new ApiResource())->withGraphQlOperations(['update_collection' => (new SubscriptionCollection())->withName('update_collection')->withMercure(true)])]), + new ObjectType([ + 'name' => 'Query', + 'fields' => [ + 'node' => ['node_fields'], + ], + ]), + null, + new ObjectType([ + 'name' => 'Subscription', + 'fields' => [ + 'collectionSubscription' => ['collection_subscription_fields'], + ], + ]), + ], + 'item and collection subscriptions' => [$resourceClass = 'resourceClass', new ResourceMetadataCollection($resourceClass, [(new ApiResource())->withGraphQlOperations([ + 'update' => (new Subscription())->withName('update')->withMercure(true), + 'update_collection' => (new SubscriptionCollection())->withName('update_collection')->withMercure(true), + ])]), + new ObjectType([ + 'name' => 'Query', + 'fields' => [ + 'node' => ['node_fields'], + ], + ]), + null, + new ObjectType([ + 'name' => 'Subscription', + 'fields' => [ + 'subscription' => ['subscription_fields'], + 'collectionSubscription' => ['collection_subscription_fields'], + ], + ]), + ], ]; } } diff --git a/src/GraphQl/Tests/Type/TypeBuilderTest.php b/src/GraphQl/Tests/Type/TypeBuilderTest.php index ddef6ba5b4e..b7e726c6399 100644 --- a/src/GraphQl/Tests/Type/TypeBuilderTest.php +++ b/src/GraphQl/Tests/Type/TypeBuilderTest.php @@ -26,6 +26,7 @@ use ApiPlatform\Metadata\GraphQl\Query; use ApiPlatform\Metadata\GraphQl\QueryCollection; use ApiPlatform\Metadata\GraphQl\Subscription; +use ApiPlatform\Metadata\GraphQl\SubscriptionCollection; use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; use ApiPlatform\State\Pagination\Pagination; use GraphQL\Type\Definition\EnumType; @@ -470,6 +471,74 @@ public function testGetResourceObjectTypeSubscriptionNested(): void $resourceObjectType->config['fields'](); } + public function testGetResourceObjectTypeCollectionSubscription(): void + { + $resourceMetadata = new ResourceMetadataCollection(\stdClass::class, [(new ApiResource())->withGraphQlOperations([ + 'update_collection' => (new SubscriptionCollection())->withName('update_collection')->withShortName('shortName')->withDescription('description')->withMercure(true), + 'item_query' => (new Query())->withShortName('shortName')->withDescription('description'), + 'collection_query' => new QueryCollection(), + ])]); + $this->typesContainerProphecy->has('update_collectionShortNameSubscriptionPayload')->shouldBeCalled()->willReturn(false); + $this->typesContainerProphecy->set('update_collectionShortNameSubscriptionPayload', Argument::type(ObjectType::class))->shouldBeCalled(); + $this->typesContainerProphecy->has('Node')->shouldBeCalled()->willReturn(false); + $this->typesContainerProphecy->set('Node', Argument::type(InterfaceType::class))->shouldBeCalled(); + + $operation = (new SubscriptionCollection())->withName('update_collection')->withShortName('shortName')->withDescription('description')->withMercure(true)->withClass(\stdClass::class); + /** @var ObjectType $resourceObjectType */ + $resourceObjectType = $this->typeBuilder->getResourceObjectType($resourceMetadata, $operation, null, ['input' => false]); + $this->assertSame('update_collectionShortNameSubscriptionPayload', $resourceObjectType->name); + $this->assertSame('description', $resourceObjectType->description); + $this->assertSame($this->defaultFieldResolver, $resourceObjectType->resolveFieldFn); + $this->assertArrayHasKey('interfaces', $resourceObjectType->config); + $this->assertEquals([], $resourceObjectType->config['interfaces']); + $this->assertArrayHasKey('fields', $resourceObjectType->config); + + $this->typesContainerProphecy->has('shortName')->shouldBeCalled()->willReturn(false); + $this->typesContainerProphecy->set('shortName', Argument::type(ObjectType::class))->shouldBeCalled(); + + $fieldsType = $resourceObjectType->config['fields'](); + $this->assertArrayHasKey('shortName', $fieldsType); + $this->assertArrayHasKey('clientSubscriptionId', $fieldsType); + $this->assertArrayHasKey('mercureUrl', $fieldsType); + $this->assertSame(GraphQLType::string(), $fieldsType['clientSubscriptionId']); + $this->assertSame(GraphQLType::string(), $fieldsType['mercureUrl']); + } + + public function testGetResourceObjectTypeCollectionSubscriptionUsesItemQueryAsWrappedPayload(): void + { + $itemQuery = (new Query())->withName('item_query')->withShortName('shortName')->withDescription('item description')->withClass(\stdClass::class); + $collectionQuery = (new QueryCollection())->withName('collection_query')->withShortName('shortName')->withDescription('collection description')->withClass(\stdClass::class); + $collectionSubscription = (new SubscriptionCollection())->withName('update_collection')->withShortName('shortName')->withDescription('subscription description')->withMercure(true)->withClass(\stdClass::class); + $resourceMetadata = new ResourceMetadataCollection(\stdClass::class, [(new ApiResource())->withGraphQlOperations([ + 'update_collection' => $collectionSubscription, + 'item_query' => $itemQuery, + 'collection_query' => $collectionQuery, + ])]); + + $this->typesContainerProphecy->has('update_collectionShortNameSubscriptionPayload')->shouldBeCalled()->willReturn(false); + $this->typesContainerProphecy->set('update_collectionShortNameSubscriptionPayload', Argument::type(ObjectType::class))->shouldBeCalled(); + $this->typesContainerProphecy->has('Node')->shouldBeCalled()->willReturn(false); + $this->typesContainerProphecy->set('Node', Argument::type(InterfaceType::class))->shouldBeCalled(); + $this->typesContainerProphecy->has('shortName')->shouldBeCalled()->willReturn(false); + $this->typesContainerProphecy->set('shortName', Argument::type(ObjectType::class))->shouldBeCalled(); + + /** @var ObjectType $resourceObjectType */ + $resourceObjectType = $this->typeBuilder->getResourceObjectType($resourceMetadata, $collectionSubscription, null, ['input' => false]); + + $fieldsBuilderProphecy = $this->prophesize(FieldsBuilderEnumInterface::class); + $fieldsBuilderProphecy->getResourceObjectTypeFields(\stdClass::class, $itemQuery, false, 0, null)->shouldBeCalled()->willReturn([]); + $this->fieldsBuilderLocatorProphecy->get('api_platform.graphql.fields_builder')->shouldBeCalled()->willReturn($fieldsBuilderProphecy->reveal()); + + $fields = $resourceObjectType->config['fields'](); + /** @var ObjectType $wrappedType */ + $wrappedType = $fields['shortName']; + $wrappedType->config['fields'](); + + $this->assertArrayHasKey('shortName', $fields); + $this->assertArrayHasKey('clientSubscriptionId', $fields); + $this->assertArrayHasKey('mercureUrl', $fields); + } + public function testGetNodeInterface(): void { $this->typesContainerProphecy->has('Node')->shouldBeCalled()->willReturn(false); diff --git a/src/Metadata/GraphQl/Subscription.php b/src/Metadata/GraphQl/Subscription.php index dbd54aff922..7f7afe0640b 100644 --- a/src/Metadata/GraphQl/Subscription.php +++ b/src/Metadata/GraphQl/Subscription.php @@ -17,7 +17,7 @@ use ApiPlatform\State\OptionsInterface; #[\Attribute(\Attribute::TARGET_CLASS | \Attribute::IS_REPEATABLE)] -final class Subscription extends Operation +class Subscription extends Operation { public function __construct( ?string $resolver = null, @@ -129,10 +129,10 @@ class: $class, stateOptions: $stateOptions, parameters: $parameters, queryParameterValidationEnabled: $queryParameterValidationEnabled, - policy: $policy, rules: $rules, + policy: $policy, extraProperties: $extraProperties, - map: $map + map: $map, ); } } diff --git a/src/Metadata/GraphQl/SubscriptionCollection.php b/src/Metadata/GraphQl/SubscriptionCollection.php new file mode 100644 index 00000000000..f53d50e90af --- /dev/null +++ b/src/Metadata/GraphQl/SubscriptionCollection.php @@ -0,0 +1,139 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Metadata\GraphQl; + +use ApiPlatform\Metadata\CollectionOperationInterface; +use ApiPlatform\Metadata\Parameters; +use ApiPlatform\State\OptionsInterface; + +#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::IS_REPEATABLE)] +final class SubscriptionCollection extends Subscription implements CollectionOperationInterface +{ + public function __construct( + ?string $resolver = null, + ?array $args = null, + ?array $extraArgs = null, + ?array $links = null, + ?string $securityAfterResolver = null, + ?string $securityMessageAfterResolver = null, + + ?string $shortName = null, + ?string $class = null, + ?bool $paginationEnabled = null, + ?string $paginationType = null, + ?int $paginationItemsPerPage = null, + ?int $paginationMaximumItemsPerPage = null, + ?bool $paginationPartial = null, + ?bool $paginationClientEnabled = null, + ?bool $paginationClientItemsPerPage = null, + ?bool $paginationClientPartial = null, + ?bool $paginationFetchJoinCollection = null, + ?bool $paginationUseOutputWalkers = null, + ?array $order = null, + ?string $description = null, + ?array $normalizationContext = null, + ?array $denormalizationContext = null, + ?bool $collectDenormalizationErrors = null, + string|\Stringable|null $security = null, + ?string $securityMessage = null, + string|\Stringable|null $securityPostDenormalize = null, + ?string $securityPostDenormalizeMessage = null, + string|\Stringable|null $securityPostValidation = null, + ?string $securityPostValidationMessage = null, + ?string $deprecationReason = null, + ?array $filters = null, + ?array $validationContext = null, + $input = null, + $output = null, + $mercure = null, + $messenger = null, + ?int $urlGenerationStrategy = null, + ?bool $read = null, + ?bool $deserialize = null, + ?bool $validate = null, + ?bool $write = null, + ?bool $serialize = null, + ?bool $fetchPartial = null, + ?bool $forceEager = null, + ?int $priority = null, + ?string $name = null, + $provider = null, + $processor = null, + ?OptionsInterface $stateOptions = null, + array|Parameters|null $parameters = null, + ?bool $queryParameterValidationEnabled = null, + mixed $rules = null, + ?string $policy = null, + array $extraProperties = [], + ?bool $map = null, + ) { + parent::__construct( + resolver: $resolver, + args: $args, + extraArgs: $extraArgs, + links: $links, + securityAfterResolver: $securityAfterResolver, + securityMessageAfterResolver: $securityMessageAfterResolver, + shortName: $shortName, + class: $class, + paginationEnabled: $paginationEnabled, + paginationType: $paginationType, + paginationItemsPerPage: $paginationItemsPerPage, + paginationMaximumItemsPerPage: $paginationMaximumItemsPerPage, + paginationPartial: $paginationPartial, + paginationClientEnabled: $paginationClientEnabled, + paginationClientItemsPerPage: $paginationClientItemsPerPage, + paginationClientPartial: $paginationClientPartial, + paginationFetchJoinCollection: $paginationFetchJoinCollection, + paginationUseOutputWalkers: $paginationUseOutputWalkers, + order: $order, + description: $description, + normalizationContext: $normalizationContext, + denormalizationContext: $denormalizationContext, + collectDenormalizationErrors: $collectDenormalizationErrors, + security: $security, + securityMessage: $securityMessage, + securityPostDenormalize: $securityPostDenormalize, + securityPostDenormalizeMessage: $securityPostDenormalizeMessage, + securityPostValidation: $securityPostValidation, + securityPostValidationMessage: $securityPostValidationMessage, + deprecationReason: $deprecationReason, + filters: $filters, + validationContext: $validationContext, + input: $input, + output: $output, + mercure: $mercure, + messenger: $messenger, + urlGenerationStrategy: $urlGenerationStrategy, + read: $read, + deserialize: $deserialize, + validate: $validate, + write: $write, + serialize: $serialize, + fetchPartial: $fetchPartial, + forceEager: $forceEager, + priority: $priority, + name: $name ?: 'update_collection_subscription', + provider: $provider, + processor: $processor, + stateOptions: $stateOptions, + parameters: $parameters, + queryParameterValidationEnabled: $queryParameterValidationEnabled, + rules: $rules, + policy: $policy, + extraProperties: $extraProperties, + map: $map, + ); + } +} diff --git a/src/Metadata/Tests/Util/PropertyAccessorValueExtractorTest.php b/src/Metadata/Tests/Util/PropertyAccessorValueExtractorTest.php new file mode 100644 index 00000000000..9a8d076062d --- /dev/null +++ b/src/Metadata/Tests/Util/PropertyAccessorValueExtractorTest.php @@ -0,0 +1,85 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Metadata\Tests\Util; + +use ApiPlatform\Metadata\Util\PropertyAccessorValueExtractor; +use PHPUnit\Framework\TestCase; + +enum PropertyAccessorValueExtractorTestStatus +{ + case ACTIVE; +} + +final class PropertyAccessorValueExtractorTest extends TestCase +{ + public function testGetValueReturnsScalarProperty(): void + { + $object = new class { + public string $tenant = 'tenant-1'; + }; + + $this->assertSame('tenant-1', PropertyAccessorValueExtractor::getValue($object, 'tenant')); + } + + public function testGetValueReturnsNestedIdentifierValue(): void + { + $object = new class { + public object $tenant; + + public function __construct() + { + $this->tenant = new class { + public function getId(): string + { + return 'tenant-1'; + } + }; + } + }; + + $this->assertSame('tenant-1', PropertyAccessorValueExtractor::getValue($object, 'tenant')); + } + + public function testGetValueReturnsBooleanPropertyAsString(): void + { + $object = new class { + public bool $tenant = true; + }; + + $this->assertSame('true', PropertyAccessorValueExtractor::getValue($object, 'tenant')); + } + + public function testGetValueReturnsNullPropertyAsString(): void + { + $object = new class { + public ?string $tenant = null; + }; + + $this->assertSame('null', PropertyAccessorValueExtractor::getValue($object, 'tenant')); + } + + public function testGetValueReturnsUnitEnumName(): void + { + $object = new class { + public PropertyAccessorValueExtractorTestStatus $tenant; + + public function __construct() + { + $this->tenant = PropertyAccessorValueExtractorTestStatus::ACTIVE; + } + }; + + $this->assertSame('ACTIVE', PropertyAccessorValueExtractor::getValue($object, 'tenant')); + } +} diff --git a/src/Metadata/Util/PropertyAccessorValueExtractor.php b/src/Metadata/Util/PropertyAccessorValueExtractor.php new file mode 100644 index 00000000000..9a0bf4826da --- /dev/null +++ b/src/Metadata/Util/PropertyAccessorValueExtractor.php @@ -0,0 +1,54 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Metadata\Util; + +use Symfony\Component\PropertyAccess\PropertyAccess; +use Symfony\Component\PropertyAccess\PropertyAccessorInterface; + +final class PropertyAccessorValueExtractor +{ + private static ?PropertyAccessorInterface $propertyAccessor = null; + + public static function getValue(object $object, string $property): string + { + self::$propertyAccessor ??= PropertyAccess::createPropertyAccessor(); + + $value = self::$propertyAccessor->getValue($object, $property); + if (\is_object($value) && method_exists($value, 'getId')) { + $value = $value->getId(); + } + + if ($value instanceof \BackedEnum) { + return (string) $value->value; + } + + if ($value instanceof \UnitEnum) { + return $value->name; + } + + if (\is_bool($value)) { + return $value ? 'true' : 'false'; + } + + if (null === $value) { + return 'null'; + } + + if ($value instanceof \Stringable || \is_scalar($value)) { + return (string) $value; + } + + return json_encode($value, \JSON_THROW_ON_ERROR); + } +} diff --git a/src/Symfony/Doctrine/EventListener/PublishMercureUpdatesListener.php b/src/Symfony/Doctrine/EventListener/PublishMercureUpdatesListener.php index 7ca43c05dc9..1cb2bdcce40 100644 --- a/src/Symfony/Doctrine/EventListener/PublishMercureUpdatesListener.php +++ b/src/Symfony/Doctrine/EventListener/PublishMercureUpdatesListener.php @@ -25,6 +25,7 @@ use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\ResourceClassResolverInterface; use ApiPlatform\Metadata\UrlGeneratorInterface; +use ApiPlatform\Metadata\Util\PropertyAccessorValueExtractor; use ApiPlatform\Metadata\Util\ResourceClassInfoTrait; use Doctrine\Common\EventArgs; use Doctrine\ODM\MongoDB\Event\OnFlushEventArgs as MongoDbOdmOnFlushEventArgs; @@ -35,6 +36,8 @@ use Symfony\Component\Mercure\HubRegistry; use Symfony\Component\Mercure\Update; use Symfony\Component\Messenger\MessageBusInterface; +use Symfony\Component\PropertyAccess\Exception\AccessException; +use Symfony\Component\PropertyAccess\Exception\NoSuchPropertyException; use Symfony\Component\Serializer\SerializerInterface; /** @@ -50,6 +53,7 @@ final class PublishMercureUpdatesListener 'topics' => true, 'data' => true, 'private' => true, + 'private_fields' => true, 'id' => true, 'type' => true, 'retry' => true, @@ -217,11 +221,27 @@ private function storeObjectToPublish(object $object, string $property): void // We need to evaluate it here, because in publishUpdate() the resource would be already deleted $this->evaluateTopics($options, $object); + $privateData = []; + $mercureOptions = $operation ? ($operation->getMercure() ?? false) : false; + $private = $mercureOptions['private'] ?? false; + $privateFields = $mercureOptions['private_fields'] ?? []; + if ($private && $privateFields) { + foreach ($privateFields as $privateField) { + try { + $privateData[$privateField] = $this->getPrivateFieldValue($privateField, $object); + } catch (NoSuchPropertyException|AccessException) { + continue; + } + } + } + $this->deletedObjects[] = [ 'object' => (object) [ + 'resourceClass' => $resourceClass, 'id' => $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_PATH, $operation), 'iri' => $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_URL, $operation), 'type' => 1 === \count($types) ? $types[0] : $types, + 'private' => $privateData, ], 'options' => $options, 'operation' => $operation, @@ -297,11 +317,11 @@ private function evaluateTopics(array &$options, object $object): void */ private function getGraphQlSubscriptionUpdates(object $object, array $options, string $type): array { - if ('update' !== $type || !$this->graphQlSubscriptionManager || !$this->graphQlMercureSubscriptionIriGenerator) { + if (!$this->graphQlSubscriptionManager || !$this->graphQlMercureSubscriptionIriGenerator) { return []; } - $payloads = $this->graphQlSubscriptionManager->getPushPayloads($object); + $payloads = $this->graphQlSubscriptionManager->getPushPayloads($object, $type); $updates = []; foreach ($payloads as [$subscriptionId, $data]) { @@ -322,4 +342,9 @@ private function buildUpdate(string|array $iri, string $data, array $options): U { return new Update($iri, $data, $options['private'] ?? false, $options['id'] ?? null, $options['type'] ?? null, $options['retry'] ?? null); } + + private function getPrivateFieldValue(string $privateField, object $object): string + { + return PropertyAccessorValueExtractor::getValue($object, $privateField); + } } diff --git a/src/Symfony/Tests/Doctrine/EventListener/PublishMercureUpdatesListenerTest.php b/src/Symfony/Tests/Doctrine/EventListener/PublishMercureUpdatesListenerTest.php index 8ce791e1a57..8118dcd6336 100644 --- a/src/Symfony/Tests/Doctrine/EventListener/PublishMercureUpdatesListenerTest.php +++ b/src/Symfony/Tests/Doctrine/EventListener/PublishMercureUpdatesListenerTest.php @@ -327,7 +327,7 @@ public function testPublishGraphQlUpdates(): void $graphQlSubscriptionManagerProphecy = $this->prophesize(GraphQlSubscriptionManagerInterface::class); $graphQlSubscriptionId = 'subscription-id'; $graphQlSubscriptionData = ['data']; - $graphQlSubscriptionManagerProphecy->getPushPayloads($toUpdate)->willReturn([[$graphQlSubscriptionId, $graphQlSubscriptionData]]); + $graphQlSubscriptionManagerProphecy->getPushPayloads($toUpdate, 'update')->willReturn([[$graphQlSubscriptionId, $graphQlSubscriptionData]]); $graphQlMercureSubscriptionIriGenerator = $this->prophesize(GraphQlMercureSubscriptionIriGeneratorInterface::class); $topicIri = 'subscription-topic-iri'; $graphQlMercureSubscriptionIriGenerator->generateTopicIri($graphQlSubscriptionId)->willReturn($topicIri); @@ -364,6 +364,812 @@ public function testPublishGraphQlUpdates(): void $this->assertEquals(['2', '["data"]'], $data); } + public function testPublishGraphQlCreateUpdates(): void + { + $toInsert = new Dummy(); + $toInsert->setId(1); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass(Argument::type(Dummy::class))->willReturn(Dummy::class); + $resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $iriConverterProphecy->getIriFromResource($toInsert, UrlGeneratorInterface::ABS_URL, Argument::any())->willReturn('http://example.com/dummies/1'); + + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataFactoryProphecy->create(Dummy::class)->willReturn(new ResourceMetadataCollection(Dummy::class, [(new ApiResource())->withOperations(new Operations([ + 'get' => (new Get())->withMercure(['enable_async_update' => false])->withNormalizationContext(['groups' => ['foo', 'bar']]), + ]))])); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + $serializerProphecy->serialize($toInsert, 'jsonld', ['groups' => ['foo', 'bar']])->willReturn('1'); + + $formats = ['jsonld' => ['application/ld+json'], 'jsonhal' => ['application/hal+json']]; + + $topics = []; + $private = []; + $retry = []; + $data = []; + + $defaultHub = $this->createMockHub(static function (Update $update) use (&$topics, &$private, &$retry, &$data): string { + $topics = array_merge($topics, $update->getTopics()); + $private[] = $update->isPrivate(); + $retry[] = $update->getRetry(); + $data[] = $update->getData(); + + return 'id'; + }); + + $graphQlSubscriptionManagerProphecy = $this->prophesize(GraphQlSubscriptionManagerInterface::class); + $graphQlSubscriptionId = 'subscription-id'; + $graphQlSubscriptionData = ['data']; + $graphQlSubscriptionManagerProphecy->getPushPayloads($toInsert, 'create')->willReturn([[$graphQlSubscriptionId, $graphQlSubscriptionData]]); + $graphQlMercureSubscriptionIriGenerator = $this->prophesize(GraphQlMercureSubscriptionIriGeneratorInterface::class); + $topicIri = 'subscription-topic-iri'; + $graphQlMercureSubscriptionIriGenerator->generateTopicIri($graphQlSubscriptionId)->willReturn($topicIri); + + $listener = new PublishMercureUpdatesListener( + $resourceClassResolverProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceMetadataFactoryProphecy->reveal(), + $serializerProphecy->reveal(), + $formats, + null, + new HubRegistry($defaultHub, ['default' => $defaultHub]), + $graphQlSubscriptionManagerProphecy->reveal(), + $graphQlMercureSubscriptionIriGenerator->reveal(), + null, + true, + ); + + $uowProphecy = $this->prophesize(UnitOfWork::class); + $uowProphecy->getScheduledEntityInsertions()->willReturn([$toInsert])->shouldBeCalled(); + $uowProphecy->getScheduledEntityUpdates()->willReturn([])->shouldBeCalled(); + $uowProphecy->getScheduledEntityDeletions()->willReturn([])->shouldBeCalled(); + + $emProphecy = $this->prophesize(EntityManagerInterface::class); + $emProphecy->getUnitOfWork()->willReturn($uowProphecy->reveal())->shouldBeCalled(); + $eventArgs = new OnFlushEventArgs($emProphecy->reveal()); + + $listener->onFlush($eventArgs); + $listener->postFlush(); + + $this->assertEquals(['http://example.com/dummies/1', 'subscription-topic-iri'], $topics); + $this->assertEquals([false, false], $private); + $this->assertEquals([null, null], $retry); + $this->assertEquals(['1', '["data"]'], $data); + } + + public function testPublishGraphQlCreateUpdatesForCollectionSubscriptions(): void + { + $toInsert = new Dummy(); + $toInsert->setId(1); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass(Argument::type(Dummy::class))->willReturn(Dummy::class); + $resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $iriConverterProphecy->getIriFromResource($toInsert, UrlGeneratorInterface::ABS_URL, Argument::any())->willReturn('http://example.com/dummies/1'); + + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataFactoryProphecy->create(Dummy::class)->willReturn(new ResourceMetadataCollection(Dummy::class, [(new ApiResource())->withOperations(new Operations([ + 'get' => (new Get())->withMercure(['enable_async_update' => false])->withNormalizationContext(['groups' => ['foo', 'bar']]), + ]))])); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + $serializerProphecy->serialize($toInsert, 'jsonld', ['groups' => ['foo', 'bar']])->willReturn('1'); + + $formats = ['jsonld' => ['application/ld+json'], 'jsonhal' => ['application/hal+json']]; + + $topics = []; + $private = []; + $retry = []; + $data = []; + + $defaultHub = $this->createMockHub(static function (Update $update) use (&$topics, &$private, &$retry, &$data): string { + $topics = array_merge($topics, $update->getTopics()); + $private[] = $update->isPrivate(); + $retry[] = $update->getRetry(); + $data[] = $update->getData(); + + return 'id'; + }); + + $graphQlSubscriptionManagerProphecy = $this->prophesize(GraphQlSubscriptionManagerInterface::class); + $graphQlCollectionSubscriptionPayloads = [ + ['collection-subscription-id-1', ['data' => ['collection' => 'first']]], + ['collection-subscription-id-2', ['data' => ['collection' => 'second']]], + ]; + $graphQlSubscriptionManagerProphecy->getPushPayloads($toInsert, 'create')->willReturn($graphQlCollectionSubscriptionPayloads); + $graphQlMercureSubscriptionIriGenerator = $this->prophesize(GraphQlMercureSubscriptionIriGeneratorInterface::class); + $graphQlMercureSubscriptionIriGenerator->generateTopicIri('collection-subscription-id-1')->willReturn('collection-subscription-topic-iri-1'); + $graphQlMercureSubscriptionIriGenerator->generateTopicIri('collection-subscription-id-2')->willReturn('collection-subscription-topic-iri-2'); + + $listener = new PublishMercureUpdatesListener( + $resourceClassResolverProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceMetadataFactoryProphecy->reveal(), + $serializerProphecy->reveal(), + $formats, + null, + new HubRegistry($defaultHub, ['default' => $defaultHub]), + $graphQlSubscriptionManagerProphecy->reveal(), + $graphQlMercureSubscriptionIriGenerator->reveal(), + null, + true, + ); + + $uowProphecy = $this->prophesize(UnitOfWork::class); + $uowProphecy->getScheduledEntityInsertions()->willReturn([$toInsert])->shouldBeCalled(); + $uowProphecy->getScheduledEntityUpdates()->willReturn([])->shouldBeCalled(); + $uowProphecy->getScheduledEntityDeletions()->willReturn([])->shouldBeCalled(); + + $emProphecy = $this->prophesize(EntityManagerInterface::class); + $emProphecy->getUnitOfWork()->willReturn($uowProphecy->reveal())->shouldBeCalled(); + $eventArgs = new OnFlushEventArgs($emProphecy->reveal()); + + $listener->onFlush($eventArgs); + $listener->postFlush(); + + $this->assertEquals(['http://example.com/dummies/1', 'collection-subscription-topic-iri-1', 'collection-subscription-topic-iri-2'], $topics); + $this->assertEquals([false, false, false], $private); + $this->assertEquals([null, null, null], $retry); + $this->assertEquals(['1', '{"data":{"collection":"first"}}', '{"data":{"collection":"second"}}'], $data); + } + + public function testPublishGraphQlDeleteUpdates(): void + { + $toDelete = new Dummy(); + $toDelete->setId(2); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass(Argument::type(Dummy::class))->willReturn(Dummy::class); + $resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $iriConverterProphecy->getIriFromResource($toDelete, UrlGeneratorInterface::ABS_PATH, Argument::any())->willReturn('/dummies/2')->shouldBeCalled(); + $iriConverterProphecy->getIriFromResource($toDelete, UrlGeneratorInterface::ABS_URL, Argument::any())->willReturn('http://example.com/dummies/2')->shouldBeCalled(); + + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataFactoryProphecy->create(Dummy::class)->willReturn(new ResourceMetadataCollection(Dummy::class, [(new ApiResource())->withOperations(new Operations([ + 'get' => (new Get())->withMercure(['enable_async_update' => false])->withShortName('Dummy')->withNormalizationContext(['groups' => ['foo', 'bar']]), + ]))])); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + + $formats = ['jsonld' => ['application/ld+json'], 'jsonhal' => ['application/hal+json']]; + + $topics = []; + $private = []; + $retry = []; + $data = []; + + $defaultHub = $this->createMockHub(static function (Update $update) use (&$topics, &$private, &$retry, &$data): string { + $topics = array_merge($topics, $update->getTopics()); + $private[] = $update->isPrivate(); + $retry[] = $update->getRetry(); + $data[] = $update->getData(); + + return 'id'; + }); + + $graphQlSubscriptionManagerProphecy = $this->prophesize(GraphQlSubscriptionManagerInterface::class); + $graphQlSubscriptionId = 'subscription-id'; + $graphQlSubscriptionData = ['data']; + $graphQlSubscriptionManagerProphecy->getPushPayloads(Argument::that(static fn ($object): bool => $object instanceof \stdClass && Dummy::class === $object->resourceClass && '/dummies/2' === $object->id && 'http://example.com/dummies/2' === $object->iri), 'delete')->willReturn([[$graphQlSubscriptionId, $graphQlSubscriptionData]]); + $graphQlMercureSubscriptionIriGenerator = $this->prophesize(GraphQlMercureSubscriptionIriGeneratorInterface::class); + $topicIri = 'subscription-topic-iri'; + $graphQlMercureSubscriptionIriGenerator->generateTopicIri($graphQlSubscriptionId)->willReturn($topicIri); + + $listener = new PublishMercureUpdatesListener( + $resourceClassResolverProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceMetadataFactoryProphecy->reveal(), + $serializerProphecy->reveal(), + $formats, + null, + new HubRegistry($defaultHub, ['default' => $defaultHub]), + $graphQlSubscriptionManagerProphecy->reveal(), + $graphQlMercureSubscriptionIriGenerator->reveal(), + null, + true, + ); + + $uowProphecy = $this->prophesize(UnitOfWork::class); + $uowProphecy->getScheduledEntityInsertions()->willReturn([])->shouldBeCalled(); + $uowProphecy->getScheduledEntityUpdates()->willReturn([])->shouldBeCalled(); + $uowProphecy->getScheduledEntityDeletions()->willReturn([$toDelete])->shouldBeCalled(); + + $emProphecy = $this->prophesize(EntityManagerInterface::class); + $emProphecy->getUnitOfWork()->willReturn($uowProphecy->reveal())->shouldBeCalled(); + $eventArgs = new OnFlushEventArgs($emProphecy->reveal()); + + $listener->onFlush($eventArgs); + $listener->postFlush(); + + $this->assertEquals(['http://example.com/dummies/2', 'subscription-topic-iri'], $topics); + $this->assertEquals([false, false], $private); + $this->assertEquals([null, null], $retry); + $this->assertEquals(['{"@id":"\/dummies\/2","@type":"Dummy"}', '["data"]'], $data); + } + + public function testPublishGraphQlDeleteUpdatesKeepsPrivateMercureFlag(): void + { + $toDelete = new Dummy(); + $toDelete->setId(2); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass(Argument::type(Dummy::class))->willReturn(Dummy::class); + $resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $iriConverterProphecy->getIriFromResource($toDelete, UrlGeneratorInterface::ABS_PATH, Argument::any())->willReturn('/dummies/2')->shouldBeCalled(); + $iriConverterProphecy->getIriFromResource($toDelete, UrlGeneratorInterface::ABS_URL, Argument::any())->willReturn('http://example.com/dummies/2')->shouldBeCalled(); + + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataFactoryProphecy->create(Dummy::class)->willReturn(new ResourceMetadataCollection(Dummy::class, [(new ApiResource())->withOperations(new Operations([ + 'get' => (new Get())->withMercure(['private' => true, 'enable_async_update' => false])->withShortName('Dummy')->withNormalizationContext(['groups' => ['foo', 'bar']]), + ]))])); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + + $formats = ['jsonld' => ['application/ld+json'], 'jsonhal' => ['application/hal+json']]; + + $topics = []; + $private = []; + $retry = []; + $data = []; + + $defaultHub = $this->createMockHub(static function (Update $update) use (&$topics, &$private, &$retry, &$data): string { + $topics = array_merge($topics, $update->getTopics()); + $private[] = $update->isPrivate(); + $retry[] = $update->getRetry(); + $data[] = $update->getData(); + + return 'id'; + }); + + $graphQlSubscriptionManagerProphecy = $this->prophesize(GraphQlSubscriptionManagerInterface::class); + $graphQlSubscriptionId = 'subscription-id'; + $graphQlSubscriptionData = ['data']; + $graphQlSubscriptionManagerProphecy->getPushPayloads(Argument::that(static fn ($object): bool => $object instanceof \stdClass && Dummy::class === $object->resourceClass && '/dummies/2' === $object->id && 'http://example.com/dummies/2' === $object->iri && [] === $object->private), 'delete')->willReturn([[$graphQlSubscriptionId, $graphQlSubscriptionData]]); + $graphQlMercureSubscriptionIriGenerator = $this->prophesize(GraphQlMercureSubscriptionIriGeneratorInterface::class); + $topicIri = 'subscription-topic-iri'; + $graphQlMercureSubscriptionIriGenerator->generateTopicIri($graphQlSubscriptionId)->willReturn($topicIri); + + $listener = new PublishMercureUpdatesListener( + $resourceClassResolverProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceMetadataFactoryProphecy->reveal(), + $serializerProphecy->reveal(), + $formats, + null, + new HubRegistry($defaultHub, ['default' => $defaultHub]), + $graphQlSubscriptionManagerProphecy->reveal(), + $graphQlMercureSubscriptionIriGenerator->reveal(), + null, + true, + ); + + $uowProphecy = $this->prophesize(UnitOfWork::class); + $uowProphecy->getScheduledEntityInsertions()->willReturn([])->shouldBeCalled(); + $uowProphecy->getScheduledEntityUpdates()->willReturn([])->shouldBeCalled(); + $uowProphecy->getScheduledEntityDeletions()->willReturn([$toDelete])->shouldBeCalled(); + + $emProphecy = $this->prophesize(EntityManagerInterface::class); + $emProphecy->getUnitOfWork()->willReturn($uowProphecy->reveal())->shouldBeCalled(); + $eventArgs = new OnFlushEventArgs($emProphecy->reveal()); + + $listener->onFlush($eventArgs); + $listener->postFlush(); + + $this->assertEquals(['http://example.com/dummies/2', 'subscription-topic-iri'], $topics); + $this->assertEquals([true, true], $private); + $this->assertEquals([null, null], $retry); + $this->assertEquals(['{"@id":"\/dummies\/2","@type":"Dummy"}', '["data"]'], $data); + } + + public function testPublishGraphQlUpdatesForCollectionSubscriptions(): void + { + $toUpdate = new Dummy(); + $toUpdate->setId(2); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass(Argument::type(Dummy::class))->willReturn(Dummy::class); + $resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $iriConverterProphecy->getIriFromResource($toUpdate, UrlGeneratorInterface::ABS_URL, Argument::any())->willReturn('http://example.com/dummies/2'); + + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataFactoryProphecy->create(Dummy::class)->willReturn(new ResourceMetadataCollection(Dummy::class, [(new ApiResource())->withOperations(new Operations([ + 'get' => (new Get())->withMercure(['enable_async_update' => false])->withNormalizationContext(['groups' => ['foo', 'bar']]), + ]))])); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + $serializerProphecy->serialize($toUpdate, 'jsonld', ['groups' => ['foo', 'bar']])->willReturn('2'); + + $formats = ['jsonld' => ['application/ld+json'], 'jsonhal' => ['application/hal+json']]; + + $topics = []; + $private = []; + $retry = []; + $data = []; + + $defaultHub = $this->createMockHub(static function (Update $update) use (&$topics, &$private, &$retry, &$data): string { + $topics = array_merge($topics, $update->getTopics()); + $private[] = $update->isPrivate(); + $retry[] = $update->getRetry(); + $data[] = $update->getData(); + + return 'id'; + }); + + $graphQlSubscriptionManagerProphecy = $this->prophesize(GraphQlSubscriptionManagerInterface::class); + $graphQlCollectionSubscriptionPayloads = [ + ['collection-subscription-id-1', ['data' => ['collection' => 'first']]], + ['collection-subscription-id-2', ['data' => ['collection' => 'second']]], + ]; + $graphQlSubscriptionManagerProphecy->getPushPayloads($toUpdate, 'update')->willReturn($graphQlCollectionSubscriptionPayloads); + $graphQlMercureSubscriptionIriGenerator = $this->prophesize(GraphQlMercureSubscriptionIriGeneratorInterface::class); + $graphQlMercureSubscriptionIriGenerator->generateTopicIri('collection-subscription-id-1')->willReturn('collection-subscription-topic-iri-1'); + $graphQlMercureSubscriptionIriGenerator->generateTopicIri('collection-subscription-id-2')->willReturn('collection-subscription-topic-iri-2'); + + $listener = new PublishMercureUpdatesListener( + $resourceClassResolverProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceMetadataFactoryProphecy->reveal(), + $serializerProphecy->reveal(), + $formats, + null, + new HubRegistry($defaultHub, ['default' => $defaultHub]), + $graphQlSubscriptionManagerProphecy->reveal(), + $graphQlMercureSubscriptionIriGenerator->reveal(), + null, + true, + ); + + $uowProphecy = $this->prophesize(UnitOfWork::class); + $uowProphecy->getScheduledEntityInsertions()->willReturn([])->shouldBeCalled(); + $uowProphecy->getScheduledEntityUpdates()->willReturn([$toUpdate])->shouldBeCalled(); + $uowProphecy->getScheduledEntityDeletions()->willReturn([])->shouldBeCalled(); + + $emProphecy = $this->prophesize(EntityManagerInterface::class); + $emProphecy->getUnitOfWork()->willReturn($uowProphecy->reveal())->shouldBeCalled(); + $eventArgs = new OnFlushEventArgs($emProphecy->reveal()); + + $listener->onFlush($eventArgs); + $listener->postFlush(); + + $this->assertEquals(['http://example.com/dummies/2', 'collection-subscription-topic-iri-1', 'collection-subscription-topic-iri-2'], $topics); + $this->assertEquals([false, false, false], $private); + $this->assertEquals([null, null, null], $retry); + $this->assertEquals(['2', '{"data":{"collection":"first"}}', '{"data":{"collection":"second"}}'], $data); + } + + public function testPublishGraphQlUpdatesKeepsPrivateMercureFlag(): void + { + $toUpdate = new Dummy(); + $toUpdate->setId(2); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass(Argument::type(Dummy::class))->willReturn(Dummy::class); + $resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $iriConverterProphecy->getIriFromResource($toUpdate, UrlGeneratorInterface::ABS_URL, Argument::any())->willReturn('http://example.com/dummies/2'); + + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataFactoryProphecy->create(Dummy::class)->willReturn(new ResourceMetadataCollection(Dummy::class, [(new ApiResource())->withOperations(new Operations([ + 'get' => (new Get())->withMercure(['private' => true, 'enable_async_update' => false])->withNormalizationContext(['groups' => ['foo', 'bar']]), + ]))])); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + $serializerProphecy->serialize($toUpdate, 'jsonld', ['groups' => ['foo', 'bar']])->willReturn('2'); + + $formats = ['jsonld' => ['application/ld+json'], 'jsonhal' => ['application/hal+json']]; + + $topics = []; + $private = []; + $retry = []; + $data = []; + + $defaultHub = $this->createMockHub(static function (Update $update) use (&$topics, &$private, &$retry, &$data): string { + $topics = array_merge($topics, $update->getTopics()); + $private[] = $update->isPrivate(); + $retry[] = $update->getRetry(); + $data[] = $update->getData(); + + return 'id'; + }); + + $graphQlSubscriptionManagerProphecy = $this->prophesize(GraphQlSubscriptionManagerInterface::class); + $graphQlSubscriptionId = 'subscription-id'; + $graphQlSubscriptionData = ['data']; + $graphQlSubscriptionManagerProphecy->getPushPayloads($toUpdate, 'update')->willReturn([[$graphQlSubscriptionId, $graphQlSubscriptionData]]); + $graphQlMercureSubscriptionIriGenerator = $this->prophesize(GraphQlMercureSubscriptionIriGeneratorInterface::class); + $topicIri = 'subscription-topic-iri'; + $graphQlMercureSubscriptionIriGenerator->generateTopicIri($graphQlSubscriptionId)->willReturn($topicIri); + + $listener = new PublishMercureUpdatesListener( + $resourceClassResolverProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceMetadataFactoryProphecy->reveal(), + $serializerProphecy->reveal(), + $formats, + null, + new HubRegistry($defaultHub, ['default' => $defaultHub]), + $graphQlSubscriptionManagerProphecy->reveal(), + $graphQlMercureSubscriptionIriGenerator->reveal(), + null, + true, + ); + + $uowProphecy = $this->prophesize(UnitOfWork::class); + $uowProphecy->getScheduledEntityInsertions()->willReturn([])->shouldBeCalled(); + $uowProphecy->getScheduledEntityUpdates()->willReturn([$toUpdate])->shouldBeCalled(); + $uowProphecy->getScheduledEntityDeletions()->willReturn([])->shouldBeCalled(); + + $emProphecy = $this->prophesize(EntityManagerInterface::class); + $emProphecy->getUnitOfWork()->willReturn($uowProphecy->reveal())->shouldBeCalled(); + $eventArgs = new OnFlushEventArgs($emProphecy->reveal()); + + $listener->onFlush($eventArgs); + $listener->postFlush(); + + $this->assertEquals(['http://example.com/dummies/2', 'subscription-topic-iri'], $topics); + $this->assertEquals([true, true], $private); + $this->assertEquals([null, null], $retry); + $this->assertEquals(['2', '["data"]'], $data); + } + + public function testPublishGraphQlCreateUpdatesKeepsPrivatePartitionContext(): void + { + $toInsert = new class { + private int $id = 1; + private int $tenant = 42; + + public function getId(): int + { + return $this->id; + } + + public function getTenant(): int + { + return $this->tenant; + } + }; + $resourceClass = $toInsert::class; + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass(Argument::type($resourceClass))->willReturn($resourceClass); + $resourceClassResolverProphecy->isResourceClass($resourceClass)->willReturn(true); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $iriConverterProphecy->getIriFromResource($toInsert, UrlGeneratorInterface::ABS_URL, Argument::any())->willReturn('http://example.com/partitioned_dummies/1'); + + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataFactoryProphecy->create($resourceClass)->willReturn(new ResourceMetadataCollection($resourceClass, [(new ApiResource())->withOperations(new Operations([ + 'get' => (new Get())->withMercure(['private' => true, 'private_fields' => ['tenant'], 'enable_async_update' => false])->withNormalizationContext(['groups' => ['foo', 'bar']]), + ]))])); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + $serializerProphecy->serialize($toInsert, 'jsonld', ['groups' => ['foo', 'bar']])->willReturn('1'); + + $formats = ['jsonld' => ['application/ld+json'], 'jsonhal' => ['application/hal+json']]; + + $topics = []; + $private = []; + $retry = []; + $data = []; + + $defaultHub = $this->createMockHub(static function (Update $update) use (&$topics, &$private, &$retry, &$data): string { + $topics = array_merge($topics, $update->getTopics()); + $private[] = $update->isPrivate(); + $retry[] = $update->getRetry(); + $data[] = $update->getData(); + + return 'id'; + }); + + $graphQlSubscriptionManagerProphecy = $this->prophesize(GraphQlSubscriptionManagerInterface::class); + $graphQlSubscriptionId = 'subscription-id'; + $graphQlSubscriptionData = ['data']; + $graphQlSubscriptionManagerProphecy->getPushPayloads($toInsert, 'create')->willReturn([[$graphQlSubscriptionId, $graphQlSubscriptionData]]); + $graphQlMercureSubscriptionIriGenerator = $this->prophesize(GraphQlMercureSubscriptionIriGeneratorInterface::class); + $topicIri = 'subscription-topic-iri'; + $graphQlMercureSubscriptionIriGenerator->generateTopicIri($graphQlSubscriptionId)->willReturn($topicIri); + + $listener = new PublishMercureUpdatesListener( + $resourceClassResolverProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceMetadataFactoryProphecy->reveal(), + $serializerProphecy->reveal(), + $formats, + null, + new HubRegistry($defaultHub, ['default' => $defaultHub]), + $graphQlSubscriptionManagerProphecy->reveal(), + $graphQlMercureSubscriptionIriGenerator->reveal(), + null, + true, + ); + + $uowProphecy = $this->prophesize(UnitOfWork::class); + $uowProphecy->getScheduledEntityInsertions()->willReturn([$toInsert])->shouldBeCalled(); + $uowProphecy->getScheduledEntityUpdates()->willReturn([])->shouldBeCalled(); + $uowProphecy->getScheduledEntityDeletions()->willReturn([])->shouldBeCalled(); + + $emProphecy = $this->prophesize(EntityManagerInterface::class); + $emProphecy->getUnitOfWork()->willReturn($uowProphecy->reveal())->shouldBeCalled(); + $eventArgs = new OnFlushEventArgs($emProphecy->reveal()); + + $listener->onFlush($eventArgs); + $listener->postFlush(); + + $this->assertEquals(['http://example.com/partitioned_dummies/1', 'subscription-topic-iri'], $topics); + $this->assertEquals([true, true], $private); + $this->assertEquals([null, null], $retry); + $this->assertEquals(['1', '["data"]'], $data); + } + + public function testPublishGraphQlUpdatesKeepsPrivatePartitionContext(): void + { + $toUpdate = new class { + private int $id = 2; + private int $tenant = 42; + + public function getId(): int + { + return $this->id; + } + + public function getTenant(): int + { + return $this->tenant; + } + }; + $resourceClass = $toUpdate::class; + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass(Argument::type($resourceClass))->willReturn($resourceClass); + $resourceClassResolverProphecy->isResourceClass($resourceClass)->willReturn(true); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $iriConverterProphecy->getIriFromResource($toUpdate, UrlGeneratorInterface::ABS_URL, Argument::any())->willReturn('http://example.com/partitioned_dummies/2'); + + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataFactoryProphecy->create($resourceClass)->willReturn(new ResourceMetadataCollection($resourceClass, [(new ApiResource())->withOperations(new Operations([ + 'get' => (new Get())->withMercure(['private' => true, 'private_fields' => ['tenant'], 'enable_async_update' => false])->withNormalizationContext(['groups' => ['foo', 'bar']]), + ]))])); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + $serializerProphecy->serialize($toUpdate, 'jsonld', ['groups' => ['foo', 'bar']])->willReturn('2'); + + $formats = ['jsonld' => ['application/ld+json'], 'jsonhal' => ['application/hal+json']]; + + $topics = []; + $private = []; + $retry = []; + $data = []; + + $defaultHub = $this->createMockHub(static function (Update $update) use (&$topics, &$private, &$retry, &$data): string { + $topics = array_merge($topics, $update->getTopics()); + $private[] = $update->isPrivate(); + $retry[] = $update->getRetry(); + $data[] = $update->getData(); + + return 'id'; + }); + + $graphQlSubscriptionManagerProphecy = $this->prophesize(GraphQlSubscriptionManagerInterface::class); + $graphQlSubscriptionId = 'subscription-id'; + $graphQlSubscriptionData = ['data']; + $graphQlSubscriptionManagerProphecy->getPushPayloads($toUpdate, 'update')->willReturn([[$graphQlSubscriptionId, $graphQlSubscriptionData]]); + $graphQlMercureSubscriptionIriGenerator = $this->prophesize(GraphQlMercureSubscriptionIriGeneratorInterface::class); + $topicIri = 'subscription-topic-iri'; + $graphQlMercureSubscriptionIriGenerator->generateTopicIri($graphQlSubscriptionId)->willReturn($topicIri); + + $listener = new PublishMercureUpdatesListener( + $resourceClassResolverProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceMetadataFactoryProphecy->reveal(), + $serializerProphecy->reveal(), + $formats, + null, + new HubRegistry($defaultHub, ['default' => $defaultHub]), + $graphQlSubscriptionManagerProphecy->reveal(), + $graphQlMercureSubscriptionIriGenerator->reveal(), + null, + true, + ); + + $uowProphecy = $this->prophesize(UnitOfWork::class); + $uowProphecy->getScheduledEntityInsertions()->willReturn([])->shouldBeCalled(); + $uowProphecy->getScheduledEntityUpdates()->willReturn([$toUpdate])->shouldBeCalled(); + $uowProphecy->getScheduledEntityDeletions()->willReturn([])->shouldBeCalled(); + + $emProphecy = $this->prophesize(EntityManagerInterface::class); + $emProphecy->getUnitOfWork()->willReturn($uowProphecy->reveal())->shouldBeCalled(); + $eventArgs = new OnFlushEventArgs($emProphecy->reveal()); + + $listener->onFlush($eventArgs); + $listener->postFlush(); + + $this->assertEquals(['http://example.com/partitioned_dummies/2', 'subscription-topic-iri'], $topics); + $this->assertEquals([true, true], $private); + $this->assertEquals([null, null], $retry); + $this->assertEquals(['2', '["data"]'], $data); + } + + public function testPublishGraphQlDeleteUpdatesKeepsPrivatePartitionData(): void + { + $toDelete = new class { + private int $id = 2; + private int $tenant = 42; + + public function getId(): int + { + return $this->id; + } + + public function getTenant(): int + { + return $this->tenant; + } + }; + $resourceClass = $toDelete::class; + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass(Argument::type($resourceClass))->willReturn($resourceClass); + $resourceClassResolverProphecy->isResourceClass($resourceClass)->willReturn(true); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $iriConverterProphecy->getIriFromResource($toDelete, UrlGeneratorInterface::ABS_PATH, Argument::any())->willReturn('/partitioned_dummies/2')->shouldBeCalled(); + $iriConverterProphecy->getIriFromResource($toDelete, UrlGeneratorInterface::ABS_URL, Argument::any())->willReturn('http://example.com/partitioned_dummies/2')->shouldBeCalled(); + + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataFactoryProphecy->create($resourceClass)->willReturn(new ResourceMetadataCollection($resourceClass, [(new ApiResource())->withOperations(new Operations([ + 'get' => (new Get())->withMercure(['private' => true, 'private_fields' => ['tenant'], 'enable_async_update' => false])->withShortName('PartitionedDummy')->withNormalizationContext(['groups' => ['foo', 'bar']]), + ]))])); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + + $formats = ['jsonld' => ['application/ld+json'], 'jsonhal' => ['application/hal+json']]; + + $topics = []; + $private = []; + $retry = []; + $data = []; + + $defaultHub = $this->createMockHub(static function (Update $update) use (&$topics, &$private, &$retry, &$data): string { + $topics = array_merge($topics, $update->getTopics()); + $private[] = $update->isPrivate(); + $retry[] = $update->getRetry(); + $data[] = $update->getData(); + + return 'id'; + }); + + $graphQlSubscriptionManagerProphecy = $this->prophesize(GraphQlSubscriptionManagerInterface::class); + $graphQlSubscriptionId = 'subscription-id'; + $graphQlSubscriptionData = ['data']; + $graphQlSubscriptionManagerProphecy->getPushPayloads(Argument::that(static fn ($object) => $object instanceof \stdClass && $resourceClass === $object->resourceClass && '/partitioned_dummies/2' === $object->id && 'http://example.com/partitioned_dummies/2' === $object->iri && ['tenant' => '42'] === $object->private), 'delete')->willReturn([[$graphQlSubscriptionId, $graphQlSubscriptionData]]); + $graphQlMercureSubscriptionIriGenerator = $this->prophesize(GraphQlMercureSubscriptionIriGeneratorInterface::class); + $topicIri = 'subscription-topic-iri'; + $graphQlMercureSubscriptionIriGenerator->generateTopicIri($graphQlSubscriptionId)->willReturn($topicIri); + + $listener = new PublishMercureUpdatesListener( + $resourceClassResolverProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceMetadataFactoryProphecy->reveal(), + $serializerProphecy->reveal(), + $formats, + null, + new HubRegistry($defaultHub, ['default' => $defaultHub]), + $graphQlSubscriptionManagerProphecy->reveal(), + $graphQlMercureSubscriptionIriGenerator->reveal(), + null, + true, + ); + + $uowProphecy = $this->prophesize(UnitOfWork::class); + $uowProphecy->getScheduledEntityInsertions()->willReturn([])->shouldBeCalled(); + $uowProphecy->getScheduledEntityUpdates()->willReturn([])->shouldBeCalled(); + $uowProphecy->getScheduledEntityDeletions()->willReturn([$toDelete])->shouldBeCalled(); + + $emProphecy = $this->prophesize(EntityManagerInterface::class); + $emProphecy->getUnitOfWork()->willReturn($uowProphecy->reveal())->shouldBeCalled(); + $eventArgs = new OnFlushEventArgs($emProphecy->reveal()); + + $listener->onFlush($eventArgs); + $listener->postFlush(); + + $this->assertEquals(['http://example.com/partitioned_dummies/2', 'subscription-topic-iri'], $topics); + $this->assertEquals([true, true], $private); + $this->assertEquals([null, null], $retry); + $this->assertEquals(['{"@id":"\/partitioned_dummies\/2","@type":"PartitionedDummy"}', '["data"]'], $data); + } + + public function testPublishGraphQlDeleteUpdatesKeepsPrivatePartitionDataUsingPropertyAccess(): void + { + $toDelete = new class { + public int $id = 2; + public int $tenant = 42; + }; + $resourceClass = $toDelete::class; + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass(Argument::type($resourceClass))->willReturn($resourceClass); + $resourceClassResolverProphecy->isResourceClass($resourceClass)->willReturn(true); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $iriConverterProphecy->getIriFromResource($toDelete, UrlGeneratorInterface::ABS_PATH, Argument::any())->willReturn('/partitioned_dummies/2')->shouldBeCalled(); + $iriConverterProphecy->getIriFromResource($toDelete, UrlGeneratorInterface::ABS_URL, Argument::any())->willReturn('http://example.com/partitioned_dummies/2')->shouldBeCalled(); + + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataFactoryProphecy->create($resourceClass)->willReturn(new ResourceMetadataCollection($resourceClass, [(new ApiResource())->withOperations(new Operations([ + 'get' => (new Get())->withMercure(['private' => true, 'private_fields' => ['tenant'], 'enable_async_update' => false])->withShortName('PartitionedDummy')->withNormalizationContext(['groups' => ['foo', 'bar']]), + ]))])); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + + $formats = ['jsonld' => ['application/ld+json'], 'jsonhal' => ['application/hal+json']]; + + $topics = []; + $private = []; + $retry = []; + $data = []; + + $defaultHub = $this->createMockHub(static function (Update $update) use (&$topics, &$private, &$retry, &$data): string { + $topics = array_merge($topics, $update->getTopics()); + $private[] = $update->isPrivate(); + $retry[] = $update->getRetry(); + $data[] = $update->getData(); + + return 'id'; + }); + + $graphQlSubscriptionManagerProphecy = $this->prophesize(GraphQlSubscriptionManagerInterface::class); + $graphQlSubscriptionId = 'subscription-id'; + $graphQlSubscriptionData = ['data']; + $graphQlSubscriptionManagerProphecy->getPushPayloads(Argument::that(static fn ($object) => $object instanceof \stdClass && $resourceClass === $object->resourceClass && '/partitioned_dummies/2' === $object->id && 'http://example.com/partitioned_dummies/2' === $object->iri && ['tenant' => '42'] === $object->private), 'delete')->willReturn([[$graphQlSubscriptionId, $graphQlSubscriptionData]]); + $graphQlMercureSubscriptionIriGenerator = $this->prophesize(GraphQlMercureSubscriptionIriGeneratorInterface::class); + $topicIri = 'subscription-topic-iri'; + $graphQlMercureSubscriptionIriGenerator->generateTopicIri($graphQlSubscriptionId)->willReturn($topicIri); + + $listener = new PublishMercureUpdatesListener( + $resourceClassResolverProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceMetadataFactoryProphecy->reveal(), + $serializerProphecy->reveal(), + $formats, + null, + new HubRegistry($defaultHub, ['default' => $defaultHub]), + $graphQlSubscriptionManagerProphecy->reveal(), + $graphQlMercureSubscriptionIriGenerator->reveal(), + null, + true, + ); + + $uowProphecy = $this->prophesize(UnitOfWork::class); + $uowProphecy->getScheduledEntityInsertions()->willReturn([])->shouldBeCalled(); + $uowProphecy->getScheduledEntityUpdates()->willReturn([])->shouldBeCalled(); + $uowProphecy->getScheduledEntityDeletions()->willReturn([$toDelete])->shouldBeCalled(); + + $emProphecy = $this->prophesize(EntityManagerInterface::class); + $emProphecy->getUnitOfWork()->willReturn($uowProphecy->reveal())->shouldBeCalled(); + $eventArgs = new OnFlushEventArgs($emProphecy->reveal()); + + $listener->onFlush($eventArgs); + $listener->postFlush(); + + $this->assertEquals(['http://example.com/partitioned_dummies/2', 'subscription-topic-iri'], $topics); + $this->assertEquals([true, true], $private); + $this->assertEquals([null, null], $retry); + $this->assertEquals(['{"@id":"\/partitioned_dummies\/2","@type":"PartitionedDummy"}', '["data"]'], $data); + } + public function testPublishUpdateWithMultipleResources(): void { $toInsert = new DummyMercureMultiResource(); diff --git a/tests/Fixtures/TestBundle/ApiResource/GraphQlSubscriptionPair.php b/tests/Fixtures/TestBundle/ApiResource/GraphQlSubscriptionPair.php new file mode 100644 index 00000000000..b7729bf99e2 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/GraphQlSubscriptionPair.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\GraphQl\Query; +use ApiPlatform\Metadata\GraphQl\QueryCollection; +use ApiPlatform\Metadata\GraphQl\Subscription; +use ApiPlatform\Metadata\GraphQl\SubscriptionCollection; +use ApiPlatform\Metadata\Operation; + +#[ApiResource( + operations: [new Get(), new GetCollection()], + graphQlOperations: [ + new Query(name: 'item_query'), + new QueryCollection(name: 'collection_query'), + new Subscription(mercure: true, name: 'update'), + new SubscriptionCollection(mercure: true, name: 'update_collection'), + ], + provider: [self::class, 'provide'], +)] +final class GraphQlSubscriptionPair +{ + #[ApiProperty(identifier: true)] + public ?int $id = null; + + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): self + { + $resource = new self(); + $resource->id = isset($uriVariables['id']) ? (int) $uriVariables['id'] : 1; + + return $resource; + } +} diff --git a/tests/Functional/GraphQl/SubscriptionSchemaTest.php b/tests/Functional/GraphQl/SubscriptionSchemaTest.php new file mode 100644 index 00000000000..d49f971175d --- /dev/null +++ b/tests/Functional/GraphQl/SubscriptionSchemaTest.php @@ -0,0 +1,110 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional\GraphQl; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\GraphQlSubscriptionPair; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class SubscriptionSchemaTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [GraphQlSubscriptionPair::class]; + } + + public function testItemAndCollectionSubscriptionsCoexistInSchema(): void + { + $response = self::createClient()->request('POST', '/graphql', ['json' => [ + 'query' => <<<'GRAPHQL' +{ + __schema { + subscriptionType { + fields { + name + } + } + } +} +GRAPHQL, + ]]); + + $this->assertResponseIsSuccessful(); + $json = $response->toArray(false); + $this->assertArrayNotHasKey('errors', $json); + + $fieldNames = array_column($json['data']['__schema']['subscriptionType']['fields'], 'name'); + + $this->assertContains('updateGraphQlSubscriptionPairSubscribe', $fieldNames); + $this->assertContains('update_collectionGraphQlSubscriptionPairSubscribe', $fieldNames); + } + + public function testItemSubscriptionReturnsMercureMetadata(): void + { + $response = self::createClient()->request('POST', '/graphql', ['json' => [ + 'query' => <<<'GRAPHQL' +subscription { + updateGraphQlSubscriptionPairSubscribe(input: {id: "/graph_ql_subscription_pairs/1"}) { + graphQlSubscriptionPair { + id + } + clientSubscriptionId + mercureUrl + } +} +GRAPHQL, + ]]); + + $this->assertResponseIsSuccessful(); + $json = $response->toArray(false); + $this->assertArrayNotHasKey('errors', $json); + + $payload = $json['data']['updateGraphQlSubscriptionPairSubscribe']; + $this->assertSame('/graph_ql_subscription_pairs/1', $payload['graphQlSubscriptionPair']['id']); + $this->assertNull($payload['clientSubscriptionId']); + $this->assertNotEmpty($payload['mercureUrl']); + } + + public function testCollectionSubscriptionReturnsMercureMetadata(): void + { + $response = self::createClient()->request('POST', '/graphql', ['json' => [ + 'query' => <<<'GRAPHQL' +subscription { + update_collectionGraphQlSubscriptionPairSubscribe(input: {id: "/graph_ql_subscription_pairs/1"}) { + graphQlSubscriptionPair { + id + } + clientSubscriptionId + mercureUrl + } +} +GRAPHQL, + ]]); + + $this->assertResponseIsSuccessful(); + $json = $response->toArray(false); + $this->assertArrayNotHasKey('errors', $json); + + $payload = $json['data']['update_collectionGraphQlSubscriptionPairSubscribe']; + $this->assertNull($payload['graphQlSubscriptionPair']); + $this->assertNull($payload['clientSubscriptionId']); + $this->assertNotEmpty($payload['mercureUrl']); + } +}