From 6f7b42467771fe4fe85752dcbf2fc2f54ced60d4 Mon Sep 17 00:00:00 2001 From: toriqo Date: Sat, 30 Mar 2019 22:55:06 +0200 Subject: [PATCH 1/5] publish to mercure preserving the request's format and also respect the entity's serializer context --- .../EventListener/PublishMercureUpdatesListener.php | 13 +++++++++++-- .../config/doctrine_orm_mercure_publisher.xml | 1 + 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/Bridge/Doctrine/EventListener/PublishMercureUpdatesListener.php b/src/Bridge/Doctrine/EventListener/PublishMercureUpdatesListener.php index f3bbb744a94..c6096de906f 100644 --- a/src/Bridge/Doctrine/EventListener/PublishMercureUpdatesListener.php +++ b/src/Bridge/Doctrine/EventListener/PublishMercureUpdatesListener.php @@ -25,6 +25,7 @@ use Symfony\Component\Mercure\Update; use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Component\Serializer\SerializerInterface; +use Symfony\Component\HttpFoundation\RequestStack; /** * Publishes resources updates to the Mercure hub. @@ -47,8 +48,9 @@ final class PublishMercureUpdatesListener private $createdEntities; private $updatedEntities; private $deletedEntities; + private $requestStack; - public function __construct(ResourceClassResolverInterface $resourceClassResolver, IriConverterInterface $iriConverter, ResourceMetadataFactoryInterface $resourceMetadataFactory, SerializerInterface $serializer, MessageBusInterface $messageBus = null, callable $publisher = null, ExpressionLanguage $expressionLanguage = null) + public function __construct(ResourceClassResolverInterface $resourceClassResolver, IriConverterInterface $iriConverter, ResourceMetadataFactoryInterface $resourceMetadataFactory, SerializerInterface $serializer, MessageBusInterface $messageBus = null, callable $publisher = null, RequestStack $requestStack, ExpressionLanguage $expressionLanguage = null) { if (null === $messageBus && null === $publisher) { throw new InvalidArgumentException('A message bus or a publisher must be provided.'); @@ -60,6 +62,7 @@ public function __construct(ResourceClassResolverInterface $resourceClassResolve $this->serializer = $serializer; $this->messageBus = $messageBus; $this->publisher = $publisher; + $this->requestStack = $requestStack; $this->expressionLanguage = $expressionLanguage ?? class_exists(ExpressionLanguage::class) ? new ExpressionLanguage() : null; $this->reset(); } @@ -168,8 +171,14 @@ private function publishUpdate($entity, array $targets): void $iri = $entity->iri; $data = json_encode(['@id' => $entity->id]); } else { + // publish the message in the request's format + // respect the entity's serializer context + $request = $this->requestStack->getCurrentRequest(); + $attributes = $request->attributes->get('_api_normalization_context'); + $context = $attributes['groups']; + $iri = $this->iriConverter->getIriFromItem($entity, UrlGeneratorInterface::ABS_URL); - $data = $this->serializer->serialize($entity, 'jsonld'); + $data = $this->serializer->serialize($entity, $request->getRequestFormat(), ['groups' => $context]); } $update = new Update($iri, $data, $targets); diff --git a/src/Bridge/Symfony/Bundle/Resources/config/doctrine_orm_mercure_publisher.xml b/src/Bridge/Symfony/Bundle/Resources/config/doctrine_orm_mercure_publisher.xml index bf806e60986..e9f8071d48a 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/doctrine_orm_mercure_publisher.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/doctrine_orm_mercure_publisher.xml @@ -15,6 +15,7 @@ + From 34f1ab7866b031516d5834cfca4e3fe7c7eecfcb Mon Sep 17 00:00:00 2001 From: toriqo Date: Sun, 31 Mar 2019 09:42:18 +0300 Subject: [PATCH 2/5] use the preferred format set in api_platform.yaml --- .../EventListener/PublishMercureUpdatesListener.php | 6 ++++-- .../Resources/config/doctrine_orm_mercure_publisher.xml | 1 + 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/Bridge/Doctrine/EventListener/PublishMercureUpdatesListener.php b/src/Bridge/Doctrine/EventListener/PublishMercureUpdatesListener.php index c6096de906f..25e9da9d32b 100644 --- a/src/Bridge/Doctrine/EventListener/PublishMercureUpdatesListener.php +++ b/src/Bridge/Doctrine/EventListener/PublishMercureUpdatesListener.php @@ -49,8 +49,9 @@ final class PublishMercureUpdatesListener private $updatedEntities; private $deletedEntities; private $requestStack; + private $formats; - public function __construct(ResourceClassResolverInterface $resourceClassResolver, IriConverterInterface $iriConverter, ResourceMetadataFactoryInterface $resourceMetadataFactory, SerializerInterface $serializer, MessageBusInterface $messageBus = null, callable $publisher = null, RequestStack $requestStack, ExpressionLanguage $expressionLanguage = null) + public function __construct(ResourceClassResolverInterface $resourceClassResolver, IriConverterInterface $iriConverter, ResourceMetadataFactoryInterface $resourceMetadataFactory, SerializerInterface $serializer, MessageBusInterface $messageBus = null, callable $publisher = null, RequestStack $requestStack, array $formats, ExpressionLanguage $expressionLanguage = null) { if (null === $messageBus && null === $publisher) { throw new InvalidArgumentException('A message bus or a publisher must be provided.'); @@ -63,6 +64,7 @@ public function __construct(ResourceClassResolverInterface $resourceClassResolve $this->messageBus = $messageBus; $this->publisher = $publisher; $this->requestStack = $requestStack; + $this->formats = $formats; $this->expressionLanguage = $expressionLanguage ?? class_exists(ExpressionLanguage::class) ? new ExpressionLanguage() : null; $this->reset(); } @@ -178,7 +180,7 @@ private function publishUpdate($entity, array $targets): void $context = $attributes['groups']; $iri = $this->iriConverter->getIriFromItem($entity, UrlGeneratorInterface::ABS_URL); - $data = $this->serializer->serialize($entity, $request->getRequestFormat(), ['groups' => $context]); + $data = $this->serializer->serialize($entity, key($this->formats), ['groups' => $context]); } $update = new Update($iri, $data, $targets); diff --git a/src/Bridge/Symfony/Bundle/Resources/config/doctrine_orm_mercure_publisher.xml b/src/Bridge/Symfony/Bundle/Resources/config/doctrine_orm_mercure_publisher.xml index e9f8071d48a..d353162cfc0 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/doctrine_orm_mercure_publisher.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/doctrine_orm_mercure_publisher.xml @@ -16,6 +16,7 @@ + %api_platform.formats% From 261193a2b1d6bb01c2aaccc592ff397d6ca160e3 Mon Sep 17 00:00:00 2001 From: Alan Poulain Date: Thu, 28 Mar 2019 10:19:54 +0100 Subject: [PATCH 3/5] Manage custom queries by using the resolvers (#2655) --- features/bootstrap/DoctrineContext.php | 24 +++ features/graphql/query.feature | 29 +++- .../PublishMercureUpdatesListener.php | 15 +- .../ApiPlatformExtension.php | 7 +- .../config/doctrine_orm_mercure_publisher.xml | 2 + .../Bundle/Resources/config/graphql.xml | 19 ++- .../Factory/CollectionResolverFactory.php | 15 +- .../Resolver/Factory/ItemResolverFactory.php | 130 +++++++++++++++ src/GraphQl/Resolver/ItemResolver.php | 78 --------- .../QueryCollectionResolverInterface.php | 31 ++++ ...ace.php => QueryItemResolverInterface.php} | 13 +- src/GraphQl/Type/SchemaBuilder.php | 70 ++++---- .../ApiPlatformExtensionTest.php | 13 +- .../TestBundle/Document/DummyCustomQuery.php | 13 ++ .../TestBundle/Entity/DummyCustomQuery.php | 15 ++ .../DummyCustomCollectionQueryResolver.php | 33 ---- .../Resolver/DummyCustomItemQueryResolver.php | 33 ---- .../DummyCustomQueryCollectionResolver.php | 40 +++++ .../Resolver/DummyCustomQueryItemResolver.php | 38 +++++ ...mQueryNotRetrievedItemDocumentResolver.php | 43 +++++ ...mmyCustomQueryNotRetrievedItemResolver.php | 43 +++++ tests/Fixtures/app/config/config_common.yml | 4 +- tests/Fixtures/app/config/config_orm.yml | 6 + .../app/config/config_services_mongodb.yml | 6 + .../Factory/CollectionResolverFactoryTest.php | 37 ++++- .../Factory/ItemResolverFactoryTest.php | 155 ++++++++++++++++++ tests/GraphQl/Resolver/ItemResolverTest.php | 93 ----------- tests/GraphQl/Type/SchemaBuilderTest.php | 9 +- 28 files changed, 702 insertions(+), 312 deletions(-) create mode 100644 src/GraphQl/Resolver/Factory/ItemResolverFactory.php delete mode 100644 src/GraphQl/Resolver/ItemResolver.php create mode 100644 src/GraphQl/Resolver/QueryCollectionResolverInterface.php rename src/GraphQl/Resolver/{QueryResolverInterface.php => QueryItemResolverInterface.php} (54%) delete mode 100644 tests/Fixtures/TestBundle/Resolver/DummyCustomCollectionQueryResolver.php delete mode 100644 tests/Fixtures/TestBundle/Resolver/DummyCustomItemQueryResolver.php create mode 100644 tests/Fixtures/TestBundle/Resolver/DummyCustomQueryCollectionResolver.php create mode 100644 tests/Fixtures/TestBundle/Resolver/DummyCustomQueryItemResolver.php create mode 100644 tests/Fixtures/TestBundle/Resolver/DummyCustomQueryNotRetrievedItemDocumentResolver.php create mode 100644 tests/Fixtures/TestBundle/Resolver/DummyCustomQueryNotRetrievedItemResolver.php create mode 100644 tests/GraphQl/Resolver/Factory/ItemResolverFactoryTest.php delete mode 100644 tests/GraphQl/Resolver/ItemResolverTest.php diff --git a/features/bootstrap/DoctrineContext.php b/features/bootstrap/DoctrineContext.php index ac894d54a97..25784e0695c 100644 --- a/features/bootstrap/DoctrineContext.php +++ b/features/bootstrap/DoctrineContext.php @@ -20,6 +20,7 @@ use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\DummyAggregateOffer as DummyAggregateOfferDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\DummyCar as DummyCarDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\DummyCarColor as DummyCarColorDocument; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\DummyCustomQuery as DummyCustomQueryDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\DummyDate as DummyDateDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\DummyDtoCustom as DummyDtoCustomDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\DummyDtoNoInput as DummyDtoNoInputDocument; @@ -60,6 +61,7 @@ use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyAggregateOffer; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyCar; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyCarColor; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyCustomQuery; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyDate; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyDtoCustom; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyDtoNoInput; @@ -413,6 +415,20 @@ public function thereAreDummyDtoNoOutputObjects(int $nb) $this->manager->flush(); } + /** + * @Given there are :nb dummyCustomQuery objects + */ + public function thereAreDummyCustomQueryObjects(int $nb) + { + for ($i = 1; $i <= $nb; ++$i) { + $dummyCustomQuery = $this->buildDummyCustomQuery(); + + $this->manager->persist($dummyCustomQuery); + } + + $this->manager->flush(); + } + /** * @Given there are :nb dummy objects with JSON and array data */ @@ -1331,6 +1347,14 @@ private function buildDummyDtoNoOutput() return $this->isOrm() ? new DummyDtoNoOutput() : new DummyDtoNoOutputDocument(); } + /** + * @return DummyCustomQuery|DummyCustomQueryDocument + */ + private function buildDummyCustomQuery() + { + return $this->isOrm() ? new DummyCustomQuery() : new DummyCustomQueryDocument(); + } + /** * @return DummyFriend|DummyFriendDocument */ diff --git a/features/graphql/query.feature b/features/graphql/query.feature index 7e61643e7f5..e0b716738dc 100644 --- a/features/graphql/query.feature +++ b/features/graphql/query.feature @@ -284,11 +284,35 @@ Feature: GraphQL query support } """ + Scenario: Custom not retrieved item query + When I send the following GraphQL request: + """ + { + testNotRetrievedItemDummyCustomQuery { + message + } + } + """ + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/json" + And the JSON should be equal to: + """ + { + "data": { + "testNotRetrievedItemDummyCustomQuery": { + "message": "Success (not retrieved)!" + } + } + } + """ + Scenario: Custom item query + Given there are 2 dummyCustomQuery objects When I send the following GraphQL request: """ { - testItemDummyCustomQuery { + testItemDummyCustomQuery(id: "/dummy_custom_queries/1") { message } } @@ -329,6 +353,9 @@ Feature: GraphQL query support "data": { "testCollectionDummyCustomQueries": { "edges": [ + { + "node": {"message": "Success!"} + }, { "node": {"message": "Success!"} } diff --git a/src/Bridge/Doctrine/EventListener/PublishMercureUpdatesListener.php b/src/Bridge/Doctrine/EventListener/PublishMercureUpdatesListener.php index f3bbb744a94..25e9da9d32b 100644 --- a/src/Bridge/Doctrine/EventListener/PublishMercureUpdatesListener.php +++ b/src/Bridge/Doctrine/EventListener/PublishMercureUpdatesListener.php @@ -25,6 +25,7 @@ use Symfony\Component\Mercure\Update; use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Component\Serializer\SerializerInterface; +use Symfony\Component\HttpFoundation\RequestStack; /** * Publishes resources updates to the Mercure hub. @@ -47,8 +48,10 @@ final class PublishMercureUpdatesListener private $createdEntities; private $updatedEntities; private $deletedEntities; + private $requestStack; + private $formats; - public function __construct(ResourceClassResolverInterface $resourceClassResolver, IriConverterInterface $iriConverter, ResourceMetadataFactoryInterface $resourceMetadataFactory, SerializerInterface $serializer, MessageBusInterface $messageBus = null, callable $publisher = null, ExpressionLanguage $expressionLanguage = null) + public function __construct(ResourceClassResolverInterface $resourceClassResolver, IriConverterInterface $iriConverter, ResourceMetadataFactoryInterface $resourceMetadataFactory, SerializerInterface $serializer, MessageBusInterface $messageBus = null, callable $publisher = null, RequestStack $requestStack, array $formats, ExpressionLanguage $expressionLanguage = null) { if (null === $messageBus && null === $publisher) { throw new InvalidArgumentException('A message bus or a publisher must be provided.'); @@ -60,6 +63,8 @@ public function __construct(ResourceClassResolverInterface $resourceClassResolve $this->serializer = $serializer; $this->messageBus = $messageBus; $this->publisher = $publisher; + $this->requestStack = $requestStack; + $this->formats = $formats; $this->expressionLanguage = $expressionLanguage ?? class_exists(ExpressionLanguage::class) ? new ExpressionLanguage() : null; $this->reset(); } @@ -168,8 +173,14 @@ private function publishUpdate($entity, array $targets): void $iri = $entity->iri; $data = json_encode(['@id' => $entity->id]); } else { + // publish the message in the request's format + // respect the entity's serializer context + $request = $this->requestStack->getCurrentRequest(); + $attributes = $request->attributes->get('_api_normalization_context'); + $context = $attributes['groups']; + $iri = $this->iriConverter->getIriFromItem($entity, UrlGeneratorInterface::ABS_URL); - $data = $this->serializer->serialize($entity, 'jsonld'); + $data = $this->serializer->serialize($entity, key($this->formats), ['groups' => $context]); } $update = new Update($iri, $data, $targets); diff --git a/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php b/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php index 8cf630286fb..c287f201c1a 100644 --- a/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -27,7 +27,8 @@ use ApiPlatform\Core\DataProvider\SubresourceDataProviderInterface; use ApiPlatform\Core\DataTransformer\DataTransformerInterface; use ApiPlatform\Core\Exception\RuntimeException; -use ApiPlatform\Core\GraphQl\Resolver\QueryResolverInterface; +use ApiPlatform\Core\GraphQl\Resolver\QueryCollectionResolverInterface; +use ApiPlatform\Core\GraphQl\Resolver\QueryItemResolverInterface; use ApiPlatform\Core\GraphQl\Type\Definition\TypeInterface as GraphQlTypeInterface; use Doctrine\Common\Annotations\Annotation; use Doctrine\ORM\Version; @@ -110,7 +111,9 @@ public function load(array $configs, ContainerBuilder $container) ->addTag('api_platform.subresource_data_provider'); $container->registerForAutoconfiguration(FilterInterface::class) ->addTag('api_platform.filter'); - $container->registerForAutoconfiguration(QueryResolverInterface::class) + $container->registerForAutoconfiguration(QueryItemResolverInterface::class) + ->addTag('api_platform.graphql.query_resolver'); + $container->registerForAutoconfiguration(QueryCollectionResolverInterface::class) ->addTag('api_platform.graphql.query_resolver'); $container->registerForAutoconfiguration(GraphQlTypeInterface::class) ->addTag('api_platform.graphql.type'); diff --git a/src/Bridge/Symfony/Bundle/Resources/config/doctrine_orm_mercure_publisher.xml b/src/Bridge/Symfony/Bundle/Resources/config/doctrine_orm_mercure_publisher.xml index bf806e60986..d353162cfc0 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/doctrine_orm_mercure_publisher.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/doctrine_orm_mercure_publisher.xml @@ -15,6 +15,8 @@ + + %api_platform.formats% diff --git a/src/Bridge/Symfony/Bundle/Resources/config/graphql.xml b/src/Bridge/Symfony/Bundle/Resources/config/graphql.xml index a01873a4fed..00bed20e3b6 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/graphql.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/graphql.xml @@ -9,9 +9,18 @@ + + + + + + + + + @@ -28,13 +37,6 @@ - - - - - - - @@ -62,11 +64,10 @@ + - - %api_platform.collection.pagination.enabled% diff --git a/src/GraphQl/Resolver/Factory/CollectionResolverFactory.php b/src/GraphQl/Resolver/Factory/CollectionResolverFactory.php index 0f768e1b391..c09fd003540 100644 --- a/src/GraphQl/Resolver/Factory/CollectionResolverFactory.php +++ b/src/GraphQl/Resolver/Factory/CollectionResolverFactory.php @@ -18,12 +18,14 @@ use ApiPlatform\Core\DataProvider\SubresourceDataProviderInterface; use ApiPlatform\Core\Exception\ResourceClassNotSupportedException; use ApiPlatform\Core\GraphQl\Resolver\FieldsToAttributesTrait; +use ApiPlatform\Core\GraphQl\Resolver\QueryCollectionResolverInterface; use ApiPlatform\Core\GraphQl\Resolver\ResourceAccessCheckerTrait; use ApiPlatform\Core\GraphQl\Serializer\ItemNormalizer; use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; use ApiPlatform\Core\Security\ResourceAccessCheckerInterface; use GraphQL\Error\Error; use GraphQL\Type\Definition\ResolveInfo; +use Psr\Container\ContainerInterface; use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; @@ -42,16 +44,18 @@ final class CollectionResolverFactory implements ResolverFactoryInterface private $collectionDataProvider; private $subresourceDataProvider; + private $queryResolverLocator; private $normalizer; private $resourceAccessChecker; private $requestStack; private $paginationEnabled; private $resourceMetadataFactory; - public function __construct(CollectionDataProviderInterface $collectionDataProvider, SubresourceDataProviderInterface $subresourceDataProvider, NormalizerInterface $normalizer, ResourceMetadataFactoryInterface $resourceMetadataFactory, ResourceAccessCheckerInterface $resourceAccessChecker = null, RequestStack $requestStack = null, bool $paginationEnabled = false) + public function __construct(CollectionDataProviderInterface $collectionDataProvider, SubresourceDataProviderInterface $subresourceDataProvider, ContainerInterface $queryResolverLocator, NormalizerInterface $normalizer, ResourceMetadataFactoryInterface $resourceMetadataFactory, ResourceAccessCheckerInterface $resourceAccessChecker = null, RequestStack $requestStack = null, bool $paginationEnabled = false) { - $this->subresourceDataProvider = $subresourceDataProvider; $this->collectionDataProvider = $collectionDataProvider; + $this->subresourceDataProvider = $subresourceDataProvider; + $this->queryResolverLocator = $queryResolverLocator; $this->normalizer = $normalizer; $this->resourceAccessChecker = $resourceAccessChecker; $this->requestStack = $requestStack; @@ -87,6 +91,13 @@ public function __invoke(string $resourceClass = null, string $rootClass = null, $collection = $this->collectionDataProvider->getCollection($resourceClass, null, $dataProviderContext); } + $queryResolverId = $resourceMetadata->getGraphqlAttribute($operationName ?? 'query', 'collection_query'); + if (null !== $queryResolverId) { + /** @var QueryCollectionResolverInterface $queryResolver */ + $queryResolver = $this->queryResolverLocator->get($queryResolverId); + $collection = $queryResolver($collection, ['source' => $source, 'args' => $args, 'info' => $info]); + } + $this->canAccess($this->resourceAccessChecker, $resourceMetadata, $resourceClass, $info, $collection, $operationName ?? 'query'); if (!$this->paginationEnabled) { diff --git a/src/GraphQl/Resolver/Factory/ItemResolverFactory.php b/src/GraphQl/Resolver/Factory/ItemResolverFactory.php new file mode 100644 index 00000000000..a3910395d6a --- /dev/null +++ b/src/GraphQl/Resolver/Factory/ItemResolverFactory.php @@ -0,0 +1,130 @@ + + * + * 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\Core\GraphQl\Resolver\Factory; + +use ApiPlatform\Core\Api\IriConverterInterface; +use ApiPlatform\Core\Exception\ItemNotFoundException; +use ApiPlatform\Core\Exception\RuntimeException; +use ApiPlatform\Core\GraphQl\Resolver\FieldsToAttributesTrait; +use ApiPlatform\Core\GraphQl\Resolver\QueryItemResolverInterface; +use ApiPlatform\Core\GraphQl\Resolver\ResourceAccessCheckerTrait; +use ApiPlatform\Core\GraphQl\Serializer\ItemNormalizer; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; +use ApiPlatform\Core\Security\ResourceAccessCheckerInterface; +use ApiPlatform\Core\Util\ClassInfoTrait; +use GraphQL\Type\Definition\ResolveInfo; +use Psr\Container\ContainerInterface; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; + +/** + * Creates a function retrieving an item to resolve a GraphQL query. + * + * @experimental + * + * @author Alan Poulain + * @author Kévin Dunglas + */ +final class ItemResolverFactory implements ResolverFactoryInterface +{ + use ClassInfoTrait; + use FieldsToAttributesTrait; + use ResourceAccessCheckerTrait; + + private $iriConverter; + private $queryResolverLocator; + private $resourceAccessChecker; + private $normalizer; + private $resourceMetadataFactory; + + public function __construct(IriConverterInterface $iriConverter, ContainerInterface $queryResolverLocator, NormalizerInterface $normalizer, ResourceMetadataFactoryInterface $resourceMetadataFactory, ResourceAccessCheckerInterface $resourceAccessChecker = null) + { + $this->iriConverter = $iriConverter; + $this->queryResolverLocator = $queryResolverLocator; + $this->normalizer = $normalizer; + $this->resourceMetadataFactory = $resourceMetadataFactory; + $this->resourceAccessChecker = $resourceAccessChecker; + } + + public function __invoke(?string $resourceClass = null, ?string $rootClass = null, ?string $operationName = null): callable + { + return function ($source, $args, $context, ResolveInfo $info) use ($resourceClass, $operationName) { + // Data already fetched and normalized (field or nested resource) + if (isset($source[$info->fieldName])) { + return $source[$info->fieldName]; + } + + $baseNormalizationContext = ['attributes' => $this->fieldsToAttributes($info)]; + $item = $this->getItem($args, $baseNormalizationContext); + $resourceClass = $this->getResourceClass($item, $resourceClass); + + $resourceMetadata = $this->resourceMetadataFactory->create($resourceClass); + + $queryResolverId = $resourceMetadata->getGraphqlAttribute($operationName ?? 'query', 'item_query'); + if (null !== $queryResolverId) { + /** @var QueryItemResolverInterface $queryResolver */ + $queryResolver = $this->queryResolverLocator->get($queryResolverId); + $item = $queryResolver($item, ['source' => $source, 'args' => $args, 'info' => $info]); + $resourceClass = $this->getResourceClass($item, $resourceClass, sprintf('Custom query resolver "%s"', $queryResolverId).' has to return an item of class %s but returned an item of class %s'); + } + + $this->canAccess($this->resourceAccessChecker, $resourceMetadata, $resourceClass, $info, $item, $operationName ?? 'query'); + + $normalizationContext = $resourceMetadata->getGraphqlAttribute($operationName ?? 'query', 'normalization_context', [], true); + + return $this->normalizer->normalize($item, ItemNormalizer::FORMAT, $normalizationContext + $baseNormalizationContext); + }; + } + + /** + * @return object|null + */ + private function getItem($args, array $baseNormalizationContext) + { + if (!isset($args['id'])) { + return null; + } + + try { + $item = $this->iriConverter->getItemFromIri($args['id'], $baseNormalizationContext); + } catch (ItemNotFoundException $e) { + return null; + } + + return $item; + } + + /** + * @param object|null $item + * + * @throws RuntimeException + */ + private function getResourceClass($item, ?string $resourceClass, string $errorMessage = 'Resolver only handles items of class %s but retrieved item is of class %s'): ?string + { + if (null === $item) { + return $resourceClass; + } + + $itemClass = $this->getObjectClass($item); + + if (null === $resourceClass) { + return $itemClass; + } + + if ($resourceClass !== $itemClass) { + throw new RuntimeException(sprintf($errorMessage, $resourceClass, $itemClass)); + } + + return $resourceClass; + } +} diff --git a/src/GraphQl/Resolver/ItemResolver.php b/src/GraphQl/Resolver/ItemResolver.php deleted file mode 100644 index 74649228686..00000000000 --- a/src/GraphQl/Resolver/ItemResolver.php +++ /dev/null @@ -1,78 +0,0 @@ - - * - * 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\Core\GraphQl\Resolver; - -use ApiPlatform\Core\Api\IriConverterInterface; -use ApiPlatform\Core\Exception\ItemNotFoundException; -use ApiPlatform\Core\GraphQl\Serializer\ItemNormalizer; -use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; -use ApiPlatform\Core\Security\ResourceAccessCheckerInterface; -use ApiPlatform\Core\Util\ClassInfoTrait; -use GraphQL\Type\Definition\ResolveInfo; -use Symfony\Component\Serializer\Normalizer\NormalizerInterface; - -/** - * Creates a function retrieving an item to resolve a GraphQL query. - * - * @experimental - * - * @author Alan Poulain - * @author Kévin Dunglas - */ -final class ItemResolver implements QueryResolverInterface -{ - use ClassInfoTrait; - use FieldsToAttributesTrait; - use ResourceAccessCheckerTrait; - - private $iriConverter; - private $resourceAccessChecker; - private $normalizer; - private $resourceMetadataFactory; - - public function __construct(IriConverterInterface $iriConverter, NormalizerInterface $normalizer, ResourceMetadataFactoryInterface $resourceMetadataFactory, ResourceAccessCheckerInterface $resourceAccessChecker = null) - { - $this->iriConverter = $iriConverter; - $this->normalizer = $normalizer; - $this->resourceMetadataFactory = $resourceMetadataFactory; - $this->resourceAccessChecker = $resourceAccessChecker; - } - - public function __invoke($source, $args, $context, ResolveInfo $info) - { - // Data already fetched and normalized (field or nested resource) - if (isset($source[$info->fieldName])) { - return $source[$info->fieldName]; - } - - if (!isset($args['id'])) { - return null; - } - - $baseNormalizationContext = ['attributes' => $this->fieldsToAttributes($info)]; - try { - $item = $this->iriConverter->getItemFromIri($args['id'], $baseNormalizationContext); - } catch (ItemNotFoundException $e) { - return null; - } - - $resourceClass = $this->getObjectClass($item); - $resourceMetadata = $this->resourceMetadataFactory->create($resourceClass); - $this->canAccess($this->resourceAccessChecker, $resourceMetadata, $resourceClass, $info, $item, 'query'); - - $normalizationContext = $resourceMetadata->getGraphqlAttribute('query', 'normalization_context', [], true); - - return $this->normalizer->normalize($item, ItemNormalizer::FORMAT, $normalizationContext + $baseNormalizationContext); - } -} diff --git a/src/GraphQl/Resolver/QueryCollectionResolverInterface.php b/src/GraphQl/Resolver/QueryCollectionResolverInterface.php new file mode 100644 index 00000000000..f07c1cecbee --- /dev/null +++ b/src/GraphQl/Resolver/QueryCollectionResolverInterface.php @@ -0,0 +1,31 @@ + + * + * 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\Core\GraphQl\Resolver; + +/** + * A function resolving a GraphQL query of a collection. + * + * @experimental + * + * @author Alan Poulain + */ +interface QueryCollectionResolverInterface +{ + /** + * @param object[] $collection + * + * @return object[] + */ + public function __invoke($collection, array $context); +} diff --git a/src/GraphQl/Resolver/QueryResolverInterface.php b/src/GraphQl/Resolver/QueryItemResolverInterface.php similarity index 54% rename from src/GraphQl/Resolver/QueryResolverInterface.php rename to src/GraphQl/Resolver/QueryItemResolverInterface.php index 67e86ffd77e..ddbb9c9a687 100644 --- a/src/GraphQl/Resolver/QueryResolverInterface.php +++ b/src/GraphQl/Resolver/QueryItemResolverInterface.php @@ -13,20 +13,19 @@ namespace ApiPlatform\Core\GraphQl\Resolver; -use GraphQL\Type\Definition\ResolveInfo; - /** - * A function retrieving an item to resolve a GraphQL query. - * Should return the normalized item or collection. + * A function resolving a GraphQL query of an item. * * @experimental * * @author Lukas Lücke */ -interface QueryResolverInterface +interface QueryItemResolverInterface { /** - * @return mixed|null The normalized query result (item or collection) + * @param object|null $item + * + * @return object */ - public function __invoke($source, $args, $context, ResolveInfo $info); + public function __invoke($item, array $context); } diff --git a/src/GraphQl/Type/SchemaBuilder.php b/src/GraphQl/Type/SchemaBuilder.php index 014b170f8cd..0ca176b8f52 100644 --- a/src/GraphQl/Type/SchemaBuilder.php +++ b/src/GraphQl/Type/SchemaBuilder.php @@ -50,27 +50,25 @@ final class SchemaBuilder implements SchemaBuilderInterface private $propertyMetadataFactory; private $resourceNameCollectionFactory; private $resourceMetadataFactory; + private $itemResolverFactory; private $collectionResolverFactory; - private $itemResolver; private $itemMutationResolverFactory; private $defaultFieldResolver; - private $queryResolverLocator; private $filterLocator; private $typesFactory; private $paginationEnabled; private $graphqlTypes = []; - public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory, ResourceMetadataFactoryInterface $resourceMetadataFactory, ResolverFactoryInterface $collectionResolverFactory, ResolverFactoryInterface $itemMutationResolverFactory, callable $itemResolver, callable $defaultFieldResolver, ContainerInterface $queryResolverLocator, TypesFactoryInterface $typesFactory, ContainerInterface $filterLocator = null, bool $paginationEnabled = true) + public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory, ResourceMetadataFactoryInterface $resourceMetadataFactory, ResolverFactoryInterface $itemResolverFactory, ResolverFactoryInterface $collectionResolverFactory, ResolverFactoryInterface $itemMutationResolverFactory, callable $defaultFieldResolver, TypesFactoryInterface $typesFactory, ContainerInterface $filterLocator = null, bool $paginationEnabled = true) { $this->propertyNameCollectionFactory = $propertyNameCollectionFactory; $this->propertyMetadataFactory = $propertyMetadataFactory; $this->resourceNameCollectionFactory = $resourceNameCollectionFactory; $this->resourceMetadataFactory = $resourceMetadataFactory; + $this->itemResolverFactory = $itemResolverFactory; $this->collectionResolverFactory = $collectionResolverFactory; - $this->itemResolver = $itemResolver; $this->itemMutationResolverFactory = $itemMutationResolverFactory; $this->defaultFieldResolver = $defaultFieldResolver; - $this->queryResolverLocator = $queryResolverLocator; $this->typesFactory = $typesFactory; $this->filterLocator = $filterLocator; $this->paginationEnabled = $paginationEnabled; @@ -88,22 +86,18 @@ public function getSchema(): Schema $graphqlConfiguration = $resourceMetadata->getGraphql() ?? []; foreach ($graphqlConfiguration as $operationName => $value) { if ('query' === $operationName) { - $queryFields += $this->getQueryFields($resourceClass, $resourceMetadata, $operationName, ['args' => ['id' => ['type' => GraphQLType::id()]]], []); + $queryFields += $this->getQueryFields($resourceClass, $resourceMetadata, $operationName, [], []); continue; } if ($itemQuery = $resourceMetadata->getGraphqlAttribute($operationName, 'item_query')) { - $value['resolve'] = $this->queryResolverLocator->get($itemQuery); - $queryFields += $this->getQueryFields($resourceClass, $resourceMetadata, $operationName, $value, false); continue; } if ($collectionQuery = $resourceMetadata->getGraphqlAttribute($operationName, 'collection_query')) { - $value['resolve'] = $this->queryResolverLocator->get($collectionQuery); - $queryFields += $this->getQueryFields($resourceClass, $resourceMetadata, $operationName, false, $value); continue; @@ -167,7 +161,7 @@ private function getNodeQueryField(): array 'args' => [ 'id' => ['type' => GraphQLType::nonNull(GraphQLType::id())], ], - 'resolve' => $this->itemResolver, + 'resolve' => ($this->itemResolverFactory)(), ]; } @@ -177,19 +171,21 @@ private function getNodeQueryField(): array * @param array|false $itemConfiguration false if not configured * @param array|false $collectionConfiguration false if not configured */ - private function getQueryFields(string $resourceClass, ResourceMetadata $resourceMetadata, string $operationName, $itemConfiguration, $collectionConfiguration): array + private function getQueryFields(string $resourceClass, ResourceMetadata $resourceMetadata, string $queryName, $itemConfiguration, $collectionConfiguration): array { $queryFields = []; $shortName = $resourceMetadata->getShortName(); - $fieldName = lcfirst('query' === $operationName ? $shortName : $operationName.$shortName); + $fieldName = lcfirst('query' === $queryName ? $shortName : $queryName.$shortName); + + $deprecationReason = $resourceMetadata->getGraphqlAttribute($queryName, 'deprecation_reason', '', true); - $deprecationReason = $resourceMetadata->getGraphqlAttribute($operationName, 'deprecation_reason', '', true); + if (false !== $itemConfiguration && $fieldConfiguration = $this->getResourceFieldConfiguration($resourceClass, $resourceMetadata, null, $deprecationReason, new Type(Type::BUILTIN_TYPE_OBJECT, true, $resourceClass), $resourceClass, false, $queryName, null)) { + $itemConfiguration['args'] = $itemConfiguration['args'] ?? ['id' => ['type' => GraphQLType::nonNull(GraphQLType::id())]]; - if (false !== $itemConfiguration && $fieldConfiguration = $this->getResourceFieldConfiguration($resourceClass, $resourceMetadata, null, $deprecationReason, new Type(Type::BUILTIN_TYPE_OBJECT, true, $resourceClass), $resourceClass)) { $queryFields[$fieldName] = array_merge($fieldConfiguration, $itemConfiguration); } - if (false !== $collectionConfiguration && $fieldConfiguration = $this->getResourceFieldConfiguration($resourceClass, $resourceMetadata, null, $deprecationReason, new Type(Type::BUILTIN_TYPE_OBJECT, false, null, true, null, new Type(Type::BUILTIN_TYPE_OBJECT, false, $resourceClass)), $resourceClass)) { + if (false !== $collectionConfiguration && $fieldConfiguration = $this->getResourceFieldConfiguration($resourceClass, $resourceMetadata, null, $deprecationReason, new Type(Type::BUILTIN_TYPE_OBJECT, false, null, true, null, new Type(Type::BUILTIN_TYPE_OBJECT, false, $resourceClass)), $resourceClass, false, $queryName, null)) { $queryFields[Inflector::pluralize($fieldName)] = array_merge($fieldConfiguration, $collectionConfiguration); } @@ -205,12 +201,11 @@ private function getMutationField(string $resourceClass, ResourceMetadata $resou $resourceType = new Type(Type::BUILTIN_TYPE_OBJECT, true, $resourceClass); $deprecationReason = $resourceMetadata->getGraphqlAttribute($mutationName, 'deprecation_reason', '', true); - if ($fieldConfiguration = $this->getResourceFieldConfiguration($resourceClass, $resourceMetadata, ucfirst("{$mutationName}s a $shortName."), $deprecationReason, $resourceType, $resourceClass, false, $mutationName)) { - $fieldConfiguration['args'] += ['input' => $this->getResourceFieldConfiguration($resourceClass, $resourceMetadata, null, $deprecationReason, $resourceType, $resourceClass, true, $mutationName)]; + if ($fieldConfiguration = $this->getResourceFieldConfiguration($resourceClass, $resourceMetadata, ucfirst("{$mutationName}s a $shortName."), $deprecationReason, $resourceType, $resourceClass, false, null, $mutationName)) { + $fieldConfiguration['args'] += ['input' => $this->getResourceFieldConfiguration($resourceClass, $resourceMetadata, null, $deprecationReason, $resourceType, $resourceClass, true, null, $mutationName)]; if (!$this->isCollection($resourceType)) { - $itemMutationResolverFactory = $this->itemMutationResolverFactory; - $fieldConfiguration['resolve'] = $itemMutationResolverFactory($resourceClass, null, $mutationName); + $fieldConfiguration['resolve'] = ($this->itemMutationResolverFactory)($resourceClass, null, $mutationName); } } @@ -222,10 +217,10 @@ private function getMutationField(string $resourceClass, ResourceMetadata $resou * * @see http://webonyx.github.io/graphql-php/type-system/object-types/ */ - private function getResourceFieldConfiguration(string $resourceClass, ResourceMetadata $resourceMetadata, ?string $fieldDescription, string $deprecationReason, Type $type, string $rootResource, bool $input = false, string $mutationName = null, int $depth = 0): ?array + private function getResourceFieldConfiguration(string $resourceClass, ResourceMetadata $resourceMetadata, ?string $fieldDescription, string $deprecationReason, Type $type, string $rootResource, bool $input, ?string $queryName, ?string $mutationName, int $depth = 0): ?array { try { - if (null === $graphqlType = $this->convertType($type, $input, $mutationName, $depth)) { + if (null === $graphqlType = $this->convertType($type, $input, $queryName, $mutationName, $depth)) { return null; } @@ -260,7 +255,7 @@ private function getResourceFieldConfiguration(string $resourceClass, ResourceMe ]; } - foreach ($resourceMetadata->getGraphqlAttribute('query', 'filters', [], true) as $filterId) { + foreach ($resourceMetadata->getGraphqlAttribute($queryName ?? 'query', 'filters', [], true) as $filterId) { if (null === $this->filterLocator || !$this->filterLocator->has($filterId)) { continue; } @@ -268,7 +263,7 @@ private function getResourceFieldConfiguration(string $resourceClass, ResourceMe foreach ($this->filterLocator->get($filterId)->getDescription($resourceClass) as $key => $value) { $nullable = isset($value['required']) ? !$value['required'] : true; $filterType = \in_array($value['type'], Type::$builtinTypes, true) ? new Type($value['type'], $nullable) : new Type('object', $nullable, $value['type']); - $graphqlFilterType = $this->convertType($filterType, false, null, $depth); + $graphqlFilterType = $this->convertType($filterType, false, $queryName, $mutationName, $depth); if ('[]' === substr($key, -2)) { $graphqlFilterType = GraphQLType::listOf($graphqlFilterType); @@ -291,10 +286,9 @@ private function getResourceFieldConfiguration(string $resourceClass, ResourceMe if ($isStandardGraphqlType || $input) { $resolve = null; } elseif ($this->isCollection($type)) { - $resolverFactory = $this->collectionResolverFactory; - $resolve = $resolverFactory($className, $rootResource, $mutationName); + $resolve = ($this->collectionResolverFactory)($className, $rootResource, $queryName); } else { - $resolve = $this->itemResolver; + $resolve = ($this->itemResolverFactory)($className, $rootResource, $queryName); } return [ @@ -370,7 +364,7 @@ private function convertFilterArgsToTypes(array $args): array * * @throws InvalidTypeException */ - private function convertType(Type $type, bool $input = false, string $mutationName = null, int $depth = 0) + private function convertType(Type $type, bool $input, ?string $queryName, ?string $mutationName, int $depth = 0) { switch ($builtinType = $type->getBuiltinType()) { case Type::BUILTIN_TYPE_BOOL: @@ -410,7 +404,7 @@ private function convertType(Type $type, bool $input = false, string $mutationNa return null; } - $graphqlType = $this->getResourceObjectType($resourceClass, $resourceMetadata, $input, $mutationName, false, $depth); + $graphqlType = $this->getResourceObjectType($resourceClass, $resourceMetadata, $input, $queryName, $mutationName, false, $depth); break; default: throw new InvalidTypeException(sprintf('The type "%s" is not supported.', $builtinType)); @@ -428,7 +422,7 @@ private function convertType(Type $type, bool $input = false, string $mutationNa * * @return ObjectType|InputObjectType */ - private function getResourceObjectType(?string $resourceClass, ResourceMetadata $resourceMetadata, bool $input = false, string $mutationName = null, bool $wrapped = false, int $depth = 0): GraphQLType + private function getResourceObjectType(?string $resourceClass, ResourceMetadata $resourceMetadata, bool $input, ?string $queryName, ?string $mutationName, bool $wrapped = false, int $depth = 0): GraphQLType { $shortName = $resourceMetadata->getShortName(); @@ -463,9 +457,9 @@ private function getResourceObjectType(?string $resourceClass, ResourceMetadata 'name' => $shortName, 'description' => $resourceMetadata->getDescription(), 'resolveField' => $this->defaultFieldResolver, - 'fields' => function () use ($resourceClass, $resourceMetadata, $input, $mutationName, $wrapData, $depth, $ioMetadata) { + 'fields' => function () use ($resourceClass, $resourceMetadata, $input, $mutationName, $queryName, $wrapData, $depth, $ioMetadata) { if ($wrapData) { - $queryNormalizationContext = $resourceMetadata->getGraphqlAttribute('query', 'normalization_context', [], true); + $queryNormalizationContext = $resourceMetadata->getGraphqlAttribute($queryName ?? 'query', 'normalization_context', [], true); $mutationNormalizationContext = $resourceMetadata->getGraphqlAttribute($mutationName ?? '', 'normalization_context', [], true); // Use a new type for the wrapped object only if there is a specific normalization context for the mutation. // If not, use the query type in order to ensure the client cache could be used. @@ -473,13 +467,13 @@ private function getResourceObjectType(?string $resourceClass, ResourceMetadata return [ lcfirst($resourceMetadata->getShortName()) => $useWrappedType ? - $this->getResourceObjectType($resourceClass, $resourceMetadata, $input, $mutationName, true, $depth) : - $this->getResourceObjectType($resourceClass, $resourceMetadata, $input, null, true, $depth), + $this->getResourceObjectType($resourceClass, $resourceMetadata, $input, $queryName, $mutationName, true, $depth) : + $this->getResourceObjectType($resourceClass, $resourceMetadata, $input, $queryName, null, true, $depth), 'clientMutationId' => GraphQLType::string(), ]; } - return $this->getResourceObjectTypeFields($resourceClass, $resourceMetadata, $input, $mutationName, $depth, $ioMetadata); + return $this->getResourceObjectTypeFields($resourceClass, $resourceMetadata, $input, $queryName, $mutationName, $depth, $ioMetadata); }, 'interfaces' => $wrapData ? [] : [$this->getNodeInterface()], ]; @@ -490,7 +484,7 @@ private function getResourceObjectType(?string $resourceClass, ResourceMetadata /** * Gets the fields of the type of the given resource. */ - private function getResourceObjectTypeFields(?string $resourceClass, ResourceMetadata $resourceMetadata, bool $input = false, string $mutationName = null, int $depth = 0, ?array $ioMetadata = null): array + private function getResourceObjectTypeFields(?string $resourceClass, ResourceMetadata $resourceMetadata, bool $input, ?string $queryName, ?string $mutationName, int $depth = 0, ?array $ioMetadata = null): array { $fields = []; $idField = ['type' => GraphQLType::nonNull(GraphQLType::id())]; @@ -524,7 +518,7 @@ private function getResourceObjectTypeFields(?string $resourceClass, ResourceMet if (null !== $resourceClass) { foreach ($this->propertyNameCollectionFactory->create($resourceClass) as $property) { - $propertyMetadata = $this->propertyMetadataFactory->create($resourceClass, $property, ['graphql_operation_name' => $mutationName ?? 'query']); + $propertyMetadata = $this->propertyMetadataFactory->create($resourceClass, $property, ['graphql_operation_name' => $mutationName ?? $queryName ?? 'query']); if ( null === ($propertyType = $propertyMetadata->getType()) || (!$input && false === $propertyMetadata->isReadable()) @@ -538,7 +532,7 @@ private function getResourceObjectTypeFields(?string $resourceClass, ResourceMet $resourceClass = $propertyMetadata->getSubresource()->getResourceClass(); $resourceMetadata = $this->resourceMetadataFactory->create($resourceClass); } - if ($fieldConfiguration = $this->getResourceFieldConfiguration($resourceClass, $resourceMetadata, $propertyMetadata->getDescription(), $propertyMetadata->getAttribute('deprecation_reason', ''), $propertyType, $rootResource, $input, $mutationName, $depth)) { + if ($fieldConfiguration = $this->getResourceFieldConfiguration($resourceClass, $resourceMetadata, $propertyMetadata->getDescription(), $propertyMetadata->getAttribute('deprecation_reason', ''), $propertyType, $rootResource, $input, $queryName, $mutationName, $depth)) { $fields['id' === $property ? '_id' : $property] = $fieldConfiguration; } $resourceClass = $rootResource; diff --git a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php index 80206a7035d..789539f0629 100644 --- a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php +++ b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php @@ -59,7 +59,8 @@ use ApiPlatform\Core\Exception\FilterValidationException; use ApiPlatform\Core\Exception\InvalidArgumentException; use ApiPlatform\Core\Exception\RuntimeException; -use ApiPlatform\Core\GraphQl\Resolver\QueryResolverInterface; +use ApiPlatform\Core\GraphQl\Resolver\QueryCollectionResolverInterface; +use ApiPlatform\Core\GraphQl\Resolver\QueryItemResolverInterface; use ApiPlatform\Core\GraphQl\Type\Definition\TypeInterface as GraphQlTypeInterface; use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; @@ -317,9 +318,9 @@ public function testDisableGraphql() { $containerBuilderProphecy = $this->getBaseContainerBuilderProphecy(); $containerBuilderProphecy->setDefinition('api_platform.action.graphql_entrypoint')->shouldNotBeCalled(); + $containerBuilderProphecy->setDefinition('api_platform.graphql.resolver.factory.item')->shouldNotBeCalled(); $containerBuilderProphecy->setDefinition('api_platform.graphql.resolver.factory.collection')->shouldNotBeCalled(); $containerBuilderProphecy->setDefinition('api_platform.graphql.resolver.factory.item_mutation')->shouldNotBeCalled(); - $containerBuilderProphecy->setDefinition('api_platform.graphql.resolver.item')->shouldNotBeCalled(); $containerBuilderProphecy->setDefinition('api_platform.graphql.resolver.resource_field')->shouldNotBeCalled(); $containerBuilderProphecy->setDefinition('api_platform.graphql.executor')->shouldNotBeCalled(); $containerBuilderProphecy->setDefinition('api_platform.graphql.schema_builder')->shouldNotBeCalled(); @@ -639,9 +640,11 @@ private function getPartialContainerBuilderProphecy() ->willReturn($this->childDefinitionProphecy)->shouldBeCalledTimes(1); $this->childDefinitionProphecy->addTag('api_platform.graphql.type')->shouldBeCalledTimes(1); - $containerBuilderProphecy->registerForAutoconfiguration(QueryResolverInterface::class) + $containerBuilderProphecy->registerForAutoconfiguration(QueryItemResolverInterface::class) ->willReturn($this->childDefinitionProphecy)->shouldBeCalledTimes(1); - $this->childDefinitionProphecy->addTag('api_platform.graphql.query_resolver')->shouldBeCalledTimes(1); + $containerBuilderProphecy->registerForAutoconfiguration(QueryCollectionResolverInterface::class) + ->willReturn($this->childDefinitionProphecy)->shouldBeCalledTimes(1); + $this->childDefinitionProphecy->addTag('api_platform.graphql.query_resolver')->shouldBeCalledTimes(2); $containerBuilderProphecy->getParameter('kernel.bundles')->willReturn([ 'DoctrineBundle' => DoctrineBundle::class, @@ -959,9 +962,9 @@ private function getBaseContainerBuilderProphecy() 'api_platform.graphql.action.entrypoint', 'api_platform.graphql.executor', 'api_platform.graphql.schema_builder', + 'api_platform.graphql.resolver.factory.item', 'api_platform.graphql.resolver.factory.collection', 'api_platform.graphql.resolver.factory.item_mutation', - 'api_platform.graphql.resolver.item', 'api_platform.graphql.resolver.resource_field', 'api_platform.graphql.iterable_type', 'api_platform.graphql.type_locator', diff --git a/tests/Fixtures/TestBundle/Document/DummyCustomQuery.php b/tests/Fixtures/TestBundle/Document/DummyCustomQuery.php index dc010b6998c..1a76bf6d58b 100644 --- a/tests/Fixtures/TestBundle/Document/DummyCustomQuery.php +++ b/tests/Fixtures/TestBundle/Document/DummyCustomQuery.php @@ -14,6 +14,7 @@ namespace ApiPlatform\Core\Tests\Fixtures\TestBundle\Document; use ApiPlatform\Core\Annotation\ApiResource; +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; /** * Dummy with custom GraphQL query resolvers. @@ -24,13 +25,25 @@ * "testItem"={ * "item_query"="app.graphql.query_resolver.dummy_custom_item" * }, + * "testNotRetrievedItem"={ + * "item_query"="app.graphql.query_resolver.dummy_custom_not_retrieved_item_document", + * "args"={} + * }, * "testCollection"={ * "collection_query"="app.graphql.query_resolver.dummy_custom_collection" * } * }) + * @ODM\Document */ class DummyCustomQuery { + /** + * @var int + * + * @ODM\Id(strategy="INCREMENT", type="integer") + */ + public $id; + /** * @var string */ diff --git a/tests/Fixtures/TestBundle/Entity/DummyCustomQuery.php b/tests/Fixtures/TestBundle/Entity/DummyCustomQuery.php index b37303c8848..dac463e3aa1 100644 --- a/tests/Fixtures/TestBundle/Entity/DummyCustomQuery.php +++ b/tests/Fixtures/TestBundle/Entity/DummyCustomQuery.php @@ -14,6 +14,7 @@ namespace ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity; use ApiPlatform\Core\Annotation\ApiResource; +use Doctrine\ORM\Mapping as ORM; /** * Dummy with custom GraphQL query resolvers. @@ -24,13 +25,27 @@ * "testItem"={ * "item_query"="app.graphql.query_resolver.dummy_custom_item" * }, + * "testNotRetrievedItem"={ + * "item_query"="app.graphql.query_resolver.dummy_custom_not_retrieved_item", + * "args"={} + * }, * "testCollection"={ * "collection_query"="app.graphql.query_resolver.dummy_custom_collection" * } * }) + * @ORM\Entity */ class DummyCustomQuery { + /** + * @var int + * + * @ORM\Id + * @ORM\GeneratedValue + * @ORM\Column(type="integer") + */ + public $id; + /** * @var string */ diff --git a/tests/Fixtures/TestBundle/Resolver/DummyCustomCollectionQueryResolver.php b/tests/Fixtures/TestBundle/Resolver/DummyCustomCollectionQueryResolver.php deleted file mode 100644 index 3393b9fc6d4..00000000000 --- a/tests/Fixtures/TestBundle/Resolver/DummyCustomCollectionQueryResolver.php +++ /dev/null @@ -1,33 +0,0 @@ - - * - * 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\Core\Tests\Fixtures\TestBundle\Resolver; - -use ApiPlatform\Core\GraphQl\Resolver\QueryResolverInterface; -use GraphQL\Type\Definition\ResolveInfo; - -/** - * Resolver for dummy collection custom query. - * - * @author Lukas Lücke - */ -class DummyCustomCollectionQueryResolver implements QueryResolverInterface -{ - /** - * {@inheritdoc} - */ - public function __invoke($source, $args, $context, ResolveInfo $info) - { - return ['edges' => [['node' => ['message' => 'Success!']]]]; - } -} diff --git a/tests/Fixtures/TestBundle/Resolver/DummyCustomItemQueryResolver.php b/tests/Fixtures/TestBundle/Resolver/DummyCustomItemQueryResolver.php deleted file mode 100644 index 6bf0995a261..00000000000 --- a/tests/Fixtures/TestBundle/Resolver/DummyCustomItemQueryResolver.php +++ /dev/null @@ -1,33 +0,0 @@ - - * - * 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\Core\Tests\Fixtures\TestBundle\Resolver; - -use ApiPlatform\Core\GraphQl\Resolver\QueryResolverInterface; -use GraphQL\Type\Definition\ResolveInfo; - -/** - * Resolver for dummy item custom query. - * - * @author Lukas Lücke - */ -class DummyCustomItemQueryResolver implements QueryResolverInterface -{ - /** - * {@inheritdoc} - */ - public function __invoke($source, $args, $context, ResolveInfo $info) - { - return ['message' => 'Success!']; - } -} diff --git a/tests/Fixtures/TestBundle/Resolver/DummyCustomQueryCollectionResolver.php b/tests/Fixtures/TestBundle/Resolver/DummyCustomQueryCollectionResolver.php new file mode 100644 index 00000000000..d7cf6fca981 --- /dev/null +++ b/tests/Fixtures/TestBundle/Resolver/DummyCustomQueryCollectionResolver.php @@ -0,0 +1,40 @@ + + * + * 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\Core\Tests\Fixtures\TestBundle\Resolver; + +use ApiPlatform\Core\GraphQl\Resolver\QueryCollectionResolverInterface; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\DummyCustomQuery as DummyCustomQueryDocument; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyCustomQuery; + +/** + * Resolver for dummy collection custom query. + * + * @author Lukas Lücke + */ +class DummyCustomQueryCollectionResolver implements QueryCollectionResolverInterface +{ + /** + * @param DummyCustomQuery[]|DummyCustomQueryDocument[] $collection + * + * @return DummyCustomQuery[]|DummyCustomQueryDocument[] + */ + public function __invoke($collection, array $context) + { + foreach ($collection as $dummy) { + $dummy->message = 'Success!'; + } + + return $collection; + } +} diff --git a/tests/Fixtures/TestBundle/Resolver/DummyCustomQueryItemResolver.php b/tests/Fixtures/TestBundle/Resolver/DummyCustomQueryItemResolver.php new file mode 100644 index 00000000000..503c043aafb --- /dev/null +++ b/tests/Fixtures/TestBundle/Resolver/DummyCustomQueryItemResolver.php @@ -0,0 +1,38 @@ + + * + * 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\Core\Tests\Fixtures\TestBundle\Resolver; + +use ApiPlatform\Core\GraphQl\Resolver\QueryItemResolverInterface; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\DummyCustomQuery as DummyCustomQueryDocument; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyCustomQuery; + +/** + * Resolver for dummy item custom query. + * + * @author Lukas Lücke + */ +class DummyCustomQueryItemResolver implements QueryItemResolverInterface +{ + /** + * @param DummyCustomQuery|DummyCustomQueryDocument|null $item + * + * @return DummyCustomQuery|DummyCustomQueryDocument + */ + public function __invoke($item, array $context) + { + $item->message = 'Success!'; + + return $item; + } +} diff --git a/tests/Fixtures/TestBundle/Resolver/DummyCustomQueryNotRetrievedItemDocumentResolver.php b/tests/Fixtures/TestBundle/Resolver/DummyCustomQueryNotRetrievedItemDocumentResolver.php new file mode 100644 index 00000000000..23fe7f5416b --- /dev/null +++ b/tests/Fixtures/TestBundle/Resolver/DummyCustomQueryNotRetrievedItemDocumentResolver.php @@ -0,0 +1,43 @@ + + * + * 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\Core\Tests\Fixtures\TestBundle\Resolver; + +use ApiPlatform\Core\GraphQl\Resolver\QueryItemResolverInterface; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\DummyCustomQuery as DummyCustomQueryDocument; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyCustomQuery; + +/** + * Resolver for dummy item custom query. + * + * @author Lukas Lücke + */ +class DummyCustomQueryNotRetrievedItemDocumentResolver implements QueryItemResolverInterface +{ + /** + * @param DummyCustomQuery|DummyCustomQueryDocument|null $item + * + * @return DummyCustomQuery|DummyCustomQueryDocument + */ + public function __invoke($item, array $context) + { + if (null === $item) { + $item = new DummyCustomQueryDocument(); + $item->message = 'Success (not retrieved)!'; + + return $item; + } + + return $item; + } +} diff --git a/tests/Fixtures/TestBundle/Resolver/DummyCustomQueryNotRetrievedItemResolver.php b/tests/Fixtures/TestBundle/Resolver/DummyCustomQueryNotRetrievedItemResolver.php new file mode 100644 index 00000000000..0287a0630b7 --- /dev/null +++ b/tests/Fixtures/TestBundle/Resolver/DummyCustomQueryNotRetrievedItemResolver.php @@ -0,0 +1,43 @@ + + * + * 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\Core\Tests\Fixtures\TestBundle\Resolver; + +use ApiPlatform\Core\GraphQl\Resolver\QueryItemResolverInterface; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\DummyCustomQuery as DummyCustomQueryDocument; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyCustomQuery; + +/** + * Resolver for dummy item custom query. + * + * @author Lukas Lücke + */ +class DummyCustomQueryNotRetrievedItemResolver implements QueryItemResolverInterface +{ + /** + * @param DummyCustomQuery|DummyCustomQueryDocument|null $item + * + * @return DummyCustomQuery|DummyCustomQueryDocument + */ + public function __invoke($item, array $context) + { + if (null === $item) { + $item = new DummyCustomQuery(); + $item->message = 'Success (not retrieved)!'; + + return $item; + } + + return $item; + } +} diff --git a/tests/Fixtures/app/config/config_common.yml b/tests/Fixtures/app/config/config_common.yml index 8aae25f0402..0d708d3cbf1 100644 --- a/tests/Fixtures/app/config/config_common.yml +++ b/tests/Fixtures/app/config/config_common.yml @@ -254,13 +254,13 @@ services: - { name: 'messenger.message_handler' } app.graphql.query_resolver.dummy_custom_item: - class: 'ApiPlatform\Core\Tests\Fixtures\TestBundle\Resolver\DummyCustomItemQueryResolver' + class: 'ApiPlatform\Core\Tests\Fixtures\TestBundle\Resolver\DummyCustomQueryItemResolver' public: false tags: - { name: 'api_platform.graphql.query_resolver' } app.graphql.query_resolver.dummy_custom_collection: - class: 'ApiPlatform\Core\Tests\Fixtures\TestBundle\Resolver\DummyCustomCollectionQueryResolver' + class: 'ApiPlatform\Core\Tests\Fixtures\TestBundle\Resolver\DummyCustomQueryCollectionResolver' public: false tags: - { name: 'api_platform.graphql.query_resolver' } diff --git a/tests/Fixtures/app/config/config_orm.yml b/tests/Fixtures/app/config/config_orm.yml index f9466dd710e..4448faf8850 100644 --- a/tests/Fixtures/app/config/config_orm.yml +++ b/tests/Fixtures/app/config/config_orm.yml @@ -71,3 +71,9 @@ services: public: false tags: - { name: 'api_platform.data_persister' } + + app.graphql.query_resolver.dummy_custom_not_retrieved_item: + class: 'ApiPlatform\Core\Tests\Fixtures\TestBundle\Resolver\DummyCustomQueryNotRetrievedItemResolver' + public: false + tags: + - { name: 'api_platform.graphql.query_resolver' } diff --git a/tests/Fixtures/app/config/config_services_mongodb.yml b/tests/Fixtures/app/config/config_services_mongodb.yml index c7bf289b19c..825b23128ed 100644 --- a/tests/Fixtures/app/config/config_services_mongodb.yml +++ b/tests/Fixtures/app/config/config_services_mongodb.yml @@ -56,3 +56,9 @@ services: public: false tags: - { name: 'api_platform.data_persister' } + + app.graphql.query_resolver.dummy_custom_not_retrieved_item_document: + class: 'ApiPlatform\Core\Tests\Fixtures\TestBundle\Resolver\DummyCustomQueryNotRetrievedItemDocumentResolver' + public: false + tags: + - { name: 'api_platform.graphql.query_resolver' } diff --git a/tests/GraphQl/Resolver/Factory/CollectionResolverFactoryTest.php b/tests/GraphQl/Resolver/Factory/CollectionResolverFactoryTest.php index 0fab222de9f..7e288b2b7a4 100644 --- a/tests/GraphQl/Resolver/Factory/CollectionResolverFactoryTest.php +++ b/tests/GraphQl/Resolver/Factory/CollectionResolverFactoryTest.php @@ -28,6 +28,7 @@ use GraphQL\Type\Schema; use PHPUnit\Framework\TestCase; use Prophecy\Argument; +use Psr\Container\ContainerInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; @@ -167,10 +168,31 @@ public function testCreatePaginatorCollectionResolver() ); } + public function testCreateCollectionResolverCustom(): void + { + $factory = $this->createCollectionResolverFactory([ + 'Object1', + 'Object2', + ], [], [], true, null, ['custom_query' => ['collection_query' => 'query_resolver_id']]); + + $resolver = $factory(RelatedDummy::class, Dummy::class, 'custom_query'); + + $resolveInfo = new ResolveInfo('relatedDummies', [], new ObjectType(['name' => '']), new ObjectType(['name' => '']), [], new Schema([]), [], null, null, []); + + $this->assertEquals( + [ + 'totalCount' => 2.0, + 'edges' => [['node' => 'normalizedReturnedObject1', 'cursor' => 'MA=='], ['node' => 'normalizedReturnedObject2', 'cursor' => 'MQ==']], + 'pageInfo' => ['startCursor' => 'MA==', 'endCursor' => 'MQ==', 'hasNextPage' => false, 'hasPreviousPage' => false], + ], + $resolver(null, [], null, $resolveInfo) + ); + } + /** * @param array|\Iterator $collection */ - private function createCollectionResolverFactory($collection, array $subcollection, array $identifiers, bool $paginationEnabled, string $cursor = null): CollectionResolverFactory + private function createCollectionResolverFactory($collection, array $subcollection, array $identifiers, bool $paginationEnabled, string $cursor = null, array $graphqlAttribute = []): CollectionResolverFactory { $collectionDataProviderProphecy = $this->prophesize(CollectionDataProviderInterface::class); @@ -188,6 +210,12 @@ private function createCollectionResolverFactory($collection, array $subcollecti 'graphql' => true, ])->willReturn($subcollection); + $queryResolverLocatorProphecy = $this->prophesize(ContainerInterface::class); + $returnedCollection = ['ReturnedObject1', 'ReturnedObject2']; + $queryResolverLocatorProphecy->get('query_resolver_id')->willReturn(function () use ($returnedCollection) { + return new ArrayPaginator($returnedCollection, 0, 2); + }); + $normalizerProphecy = $this->prophesize(NormalizerInterface::class); if (\is_array($collection)) { @@ -198,12 +226,16 @@ private function createCollectionResolverFactory($collection, array $subcollecti $normalizerProphecy->normalize($collection->current(), Argument::cetera())->willReturn('normalized'.$collection->current()); } + foreach ($returnedCollection as $returnedObject) { + $normalizerProphecy->normalize($returnedObject, Argument::cetera())->willReturn('normalized'.$returnedObject); + } + foreach ($subcollection as $object) { $normalizerProphecy->normalize($object, Argument::cetera())->willReturn('normalized'.$object); } $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); - $resourceMetadataFactoryProphecy->create(RelatedDummy::class)->willReturn(new ResourceMetadata('RelatedDummy', null, null, null, null, ['normalization_context' => ['groups' => ['foo']]])); + $resourceMetadataFactoryProphecy->create(RelatedDummy::class)->willReturn(new ResourceMetadata('RelatedDummy', null, null, null, null, ['normalization_context' => ['groups' => ['foo']]], [], $graphqlAttribute)); $request = new Request(); @@ -213,6 +245,7 @@ private function createCollectionResolverFactory($collection, array $subcollecti return new CollectionResolverFactory( $collectionDataProviderProphecy->reveal(), $subresourceDataProviderProphecy->reveal(), + $queryResolverLocatorProphecy->reveal(), $normalizerProphecy->reveal(), $resourceMetadataFactoryProphecy->reveal(), null, diff --git a/tests/GraphQl/Resolver/Factory/ItemResolverFactoryTest.php b/tests/GraphQl/Resolver/Factory/ItemResolverFactoryTest.php new file mode 100644 index 00000000000..fcf1edf6c51 --- /dev/null +++ b/tests/GraphQl/Resolver/Factory/ItemResolverFactoryTest.php @@ -0,0 +1,155 @@ + + * + * 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\Core\Tests\GraphQl\Resolver\Factory; + +use ApiPlatform\Core\Api\IriConverterInterface; +use ApiPlatform\Core\Exception\ItemNotFoundException; +use ApiPlatform\Core\GraphQl\Resolver\Factory\ItemResolverFactory; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; +use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\RelatedDummy; +use GraphQL\Type\Definition\ObjectType; +use GraphQL\Type\Definition\ResolveInfo; +use GraphQL\Type\Schema; +use PHPUnit\Framework\TestCase; +use Prophecy\Argument; +use Psr\Container\ContainerInterface; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; + +/** + * @author Alan Poulain + * @author Kévin Dunglas + */ +class ItemResolverFactoryTest extends TestCase +{ + private $itemResolverFactory; + private $iriConverterProphecy; + private $queryResolverLocatorProphecy; + private $normalizerProphecy; + private $resourceMetadataFactoryProphecy; + + /** + * {@inheritdoc} + */ + protected function setUp(): void + { + $this->iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $this->queryResolverLocatorProphecy = $this->prophesize(ContainerInterface::class); + $this->normalizerProphecy = $this->prophesize(NormalizerInterface::class); + + $this->resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + $this->resourceMetadataFactoryProphecy->create(Dummy::class)->willReturn(new ResourceMetadata('Dummy', null, null, null, null, ['normalization_context' => ['groups' => ['foo']]])); + $this->resourceMetadataFactoryProphecy->create(RelatedDummy::class)->willReturn(new ResourceMetadata('RelatedDummy', null, null, null, null, ['normalization_context' => ['groups' => ['foo']]])); + + $this->itemResolverFactory = new ItemResolverFactory( + $this->iriConverterProphecy->reveal(), + $this->queryResolverLocatorProphecy->reveal(), + $this->normalizerProphecy->reveal(), + $this->resourceMetadataFactoryProphecy->reveal() + ); + } + + public function testCreateItemResolverNoItem(): void + { + $this->iriConverterProphecy->getItemFromIri('/related_dummies/3', ['attributes' => []])->willThrow(new ItemNotFoundException()); + + $resolveInfo = new ResolveInfo('name', [], new ObjectType(['name' => '']), new ObjectType(['name' => '']), [], new Schema([]), [], null, null, []); + + $this->assertNull(($this->itemResolverFactory)(RelatedDummy::class)(null, ['id' => '/related_dummies/3'], null, $resolveInfo)); + } + + public function testCreateItemResolver(): void + { + $item = new RelatedDummy(); + $this->iriConverterProphecy->getItemFromIri('/related_dummies/3', ['attributes' => []])->willReturn($item); + $this->normalizerProphecy->normalize($item, Argument::cetera())->willReturn('normalizedItem'); + + $resolveInfo = new ResolveInfo('name', [], new ObjectType(['name' => '']), new ObjectType(['name' => '']), [], new Schema([]), [], null, null, []); + + $this->assertEquals('normalizedItem', ($this->itemResolverFactory)(RelatedDummy::class)(null, ['id' => '/related_dummies/3'], null, $resolveInfo)); + } + + public function testCreateItemResolverInvalidItem(): void + { + $item = new Dummy(); + $this->iriConverterProphecy->getItemFromIri('/dummies/3', ['attributes' => []])->willReturn($item); + + $resolveInfo = new ResolveInfo('name', [], new ObjectType(['name' => '']), new ObjectType(['name' => '']), [], new Schema([]), [], null, null, []); + + $this->expectExceptionMessage('Resolver only handles items of class ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\RelatedDummy but retrieved item is of class ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy'); + + $this->assertEquals('normalizedItem', ($this->itemResolverFactory)(RelatedDummy::class)(null, ['id' => '/dummies/3'], null, $resolveInfo)); + } + + public function testCreateItemResolverCustomInvalidReturnedClass(): void + { + $item = new RelatedDummy(); + $this->iriConverterProphecy->getItemFromIri('/related_dummies/3', ['attributes' => []])->willReturn($item); + $this->normalizerProphecy->normalize($item, Argument::cetera())->willReturn('normalizedItem'); + + $resolveInfo = new ResolveInfo('name', [], new ObjectType(['name' => '']), new ObjectType(['name' => '']), [], new Schema([]), [], null, null, []); + + $this->resourceMetadataFactoryProphecy->create(RelatedDummy::class)->willReturn(new ResourceMetadata('RelatedDummy', null, null, null, null, null, null, ['custom_query' => ['item_query' => 'query_resolver_id']])); + + $this->queryResolverLocatorProphecy->get('query_resolver_id')->shouldBeCalled()->willReturn(function () { + return new Dummy(); + }); + + $this->expectExceptionMessage('Custom query resolver "query_resolver_id" has to return an item of class ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\RelatedDummy but returned an item of class ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy'); + + ($this->itemResolverFactory)(null, null, 'custom_query')(null, ['id' => '/related_dummies/3'], null, $resolveInfo); + } + + public function testCreateItemResolverCustom(): void + { + $item = new RelatedDummy(); + $returnedItem = new RelatedDummy(); + $returnedItem->setName('returned'); + $this->iriConverterProphecy->getItemFromIri('/related_dummies/3', ['attributes' => []])->willReturn($item); + $this->normalizerProphecy->normalize($returnedItem, Argument::cetera())->willReturn('normalizedItem'); + + $resolveInfo = new ResolveInfo('name', [], new ObjectType(['name' => '']), new ObjectType(['name' => '']), [], new Schema([]), [], null, null, []); + + $this->resourceMetadataFactoryProphecy->create(RelatedDummy::class)->willReturn(new ResourceMetadata('RelatedDummy', null, null, null, null, null, null, ['custom_query' => ['item_query' => 'query_resolver_id']])); + + $this->queryResolverLocatorProphecy->get('query_resolver_id')->shouldBeCalled()->willReturn(function () use ($returnedItem) { + return $returnedItem; + }); + + $this->assertEquals('normalizedItem', ($this->itemResolverFactory)(null, null, 'custom_query')(null, ['id' => '/related_dummies/3'], null, $resolveInfo)); + } + + /** + * @dataProvider subresourceProvider + */ + public function testCreateSubresourceItemResolver($normalizedSubresource): void + { + $item = new Dummy(); + $this->iriConverterProphecy->getItemFromIri('/dummies/3', ['attributes' => []])->willReturn($item); + $this->normalizerProphecy->normalize($item, Argument::cetera())->willReturn(null); + + $resolveInfo = new ResolveInfo('dummy', [], new ObjectType(['name' => '']), new ObjectType(['name' => '']), [], new Schema([]), [], null, null, []); + + $this->assertEquals($normalizedSubresource, ($this->itemResolverFactory)(Dummy::class)(['dummy' => $normalizedSubresource], ['id' => '/dummies/3'], null, $resolveInfo)); + } + + public function subresourceProvider(): array + { + return [ + ['/related_dummies/3'], + [null], + ]; + } +} diff --git a/tests/GraphQl/Resolver/ItemResolverTest.php b/tests/GraphQl/Resolver/ItemResolverTest.php deleted file mode 100644 index 3a4bdd3c3c5..00000000000 --- a/tests/GraphQl/Resolver/ItemResolverTest.php +++ /dev/null @@ -1,93 +0,0 @@ - - * - * 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\Core\Tests\GraphQl\Resolver; - -use ApiPlatform\Core\Api\IriConverterInterface; -use ApiPlatform\Core\Exception\ItemNotFoundException; -use ApiPlatform\Core\GraphQl\Resolver\ItemResolver; -use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; -use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; -use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy; -use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\RelatedDummy; -use GraphQL\Type\Definition\ObjectType; -use GraphQL\Type\Definition\ResolveInfo; -use GraphQL\Type\Schema; -use PHPUnit\Framework\TestCase; -use Prophecy\Argument; -use Symfony\Component\Serializer\Normalizer\NormalizerInterface; - -/** - * @author Alan Poulain - * @author Kévin Dunglas - */ -class ItemResolverTest extends TestCase -{ - public function testCreateItemResolverNoItem() - { - $resolver = $this->createItemResolver(null); - - $resolveInfo = new ResolveInfo('name', [], new ObjectType(['name' => '']), new ObjectType(['name' => '']), [], new Schema([]), [], null, null, []); - - $this->assertNull($resolver(null, ['id' => '/related_dummies/3'], null, $resolveInfo)); - } - - public function testCreateItemResolver() - { - $resolver = $this->createItemResolver(new RelatedDummy()); - - $resolveInfo = new ResolveInfo('name', [], new ObjectType(['name' => '']), new ObjectType(['name' => '']), [], new Schema([]), [], null, null, []); - - $this->assertEquals('normalizedItem', $resolver(null, ['id' => '/related_dummies/3'], null, $resolveInfo)); - } - - /** - * @dataProvider subresourceProvider - */ - public function testCreateSubresourceItemResolver($normalizedSubresource) - { - $resolver = $this->createItemResolver(new Dummy()); - - $resolveInfo = new ResolveInfo('relatedDummy', [], new ObjectType(['name' => '']), new ObjectType(['name' => '']), [], new Schema([]), [], null, null, []); - - $this->assertEquals($normalizedSubresource, $resolver(['relatedDummy' => $normalizedSubresource], [], null, $resolveInfo)); - } - - public function subresourceProvider(): array - { - return [ - ['/related_dummies/3'], - [null], - ]; - } - - private function createItemResolver($item): ItemResolver - { - $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); - $getItemFromIri = $iriConverterProphecy->getItemFromIri('/related_dummies/3', ['attributes' => []]); - null === $item ? $getItemFromIri->willThrow(new ItemNotFoundException()) : $getItemFromIri->willReturn($item); - - $normalizerProphecy = $this->prophesize(NormalizerInterface::class); - $normalizerProphecy->normalize($item, Argument::cetera())->willReturn('normalizedItem'); - - $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); - $resourceMetadataFactoryProphecy->create(Dummy::class)->willReturn(new ResourceMetadata('Dummy', null, null, null, null, ['normalization_context' => ['groups' => ['foo']]])); - $resourceMetadataFactoryProphecy->create(RelatedDummy::class)->willReturn(new ResourceMetadata('RelatedDummy', null, null, null, null, ['normalization_context' => ['groups' => ['foo']]])); - - return new ItemResolver( - $iriConverterProphecy->reveal(), - $normalizerProphecy->reveal(), - $resourceMetadataFactoryProphecy->reveal() - ); - } -} diff --git a/tests/GraphQl/Type/SchemaBuilderTest.php b/tests/GraphQl/Type/SchemaBuilderTest.php index b2bc93217af..727e1c68e97 100644 --- a/tests/GraphQl/Type/SchemaBuilderTest.php +++ b/tests/GraphQl/Type/SchemaBuilderTest.php @@ -31,7 +31,6 @@ use GraphQL\Type\Definition\Type as GraphQLType; use PHPUnit\Framework\TestCase; use Prophecy\Argument; -use Symfony\Component\DependencyInjection\ServiceLocator; use Symfony\Component\PropertyInfo\Type; /** @@ -259,10 +258,10 @@ private function createSchemaBuilder($propertyMetadataMockBuilder, bool $paginat $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); $resourceNameCollectionFactoryProphecy = $this->prophesize(ResourceNameCollectionFactoryInterface::class); $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + $itemResolverFactoryProphecy = $this->prophesize(ResolverFactoryInterface::class); $collectionResolverFactoryProphecy = $this->prophesize(ResolverFactoryInterface::class); $itemMutationResolverFactoryProphecy = $this->prophesize(ResolverFactoryInterface::class); $typesFactoryProphecy = $this->prophesize(TypesFactoryInterface::class); - $queryResolverLocatorProphecy = $this->prophesize(ServiceLocator::class); $resourceClassNames = []; for ($i = 1; $i <= 3; ++$i) { @@ -294,6 +293,8 @@ private function createSchemaBuilder($propertyMetadataMockBuilder, bool $paginat $resourceNameCollection = new ResourceNameCollection($resourceClassNames); $resourceNameCollectionFactoryProphecy->create()->willReturn($resourceNameCollection); + $itemResolverFactoryProphecy->__invoke(Argument::cetera())->willReturn(function () { + }); $collectionResolverFactoryProphecy->__invoke(Argument::cetera())->willReturn(function () { }); $itemMutationResolverFactoryProphecy->__invoke(Argument::cetera())->willReturn(function () { @@ -306,13 +307,11 @@ private function createSchemaBuilder($propertyMetadataMockBuilder, bool $paginat $propertyMetadataFactoryProphecy->reveal(), $resourceNameCollectionFactoryProphecy->reveal(), $resourceMetadataFactoryProphecy->reveal(), + $itemResolverFactoryProphecy->reveal(), $collectionResolverFactoryProphecy->reveal(), $itemMutationResolverFactoryProphecy->reveal(), function () { }, - function () { - }, - $queryResolverLocatorProphecy->reveal(), $typesFactoryProphecy->reveal(), null, $paginationEnabled From dbcdcfbd761034c00279ccda9bd97cdbf9927bfc Mon Sep 17 00:00:00 2001 From: toriqo Date: Sun, 31 Mar 2019 20:45:44 +0300 Subject: [PATCH 4/5] removed RequestStack in favor of ResourceMetadataFactoryInterface for context extraction --- .../EventListener/PublishMercureUpdatesListener.php | 11 ++++------- .../config/doctrine_orm_mercure_publisher.xml | 1 - 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/src/Bridge/Doctrine/EventListener/PublishMercureUpdatesListener.php b/src/Bridge/Doctrine/EventListener/PublishMercureUpdatesListener.php index 25e9da9d32b..c9f6ac0d9c1 100644 --- a/src/Bridge/Doctrine/EventListener/PublishMercureUpdatesListener.php +++ b/src/Bridge/Doctrine/EventListener/PublishMercureUpdatesListener.php @@ -25,7 +25,6 @@ use Symfony\Component\Mercure\Update; use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Component\Serializer\SerializerInterface; -use Symfony\Component\HttpFoundation\RequestStack; /** * Publishes resources updates to the Mercure hub. @@ -48,10 +47,9 @@ final class PublishMercureUpdatesListener private $createdEntities; private $updatedEntities; private $deletedEntities; - private $requestStack; private $formats; - public function __construct(ResourceClassResolverInterface $resourceClassResolver, IriConverterInterface $iriConverter, ResourceMetadataFactoryInterface $resourceMetadataFactory, SerializerInterface $serializer, MessageBusInterface $messageBus = null, callable $publisher = null, RequestStack $requestStack, array $formats, ExpressionLanguage $expressionLanguage = null) + public function __construct(ResourceClassResolverInterface $resourceClassResolver, IriConverterInterface $iriConverter, ResourceMetadataFactoryInterface $resourceMetadataFactory, SerializerInterface $serializer, MessageBusInterface $messageBus = null, callable $publisher = null, array $formats, ExpressionLanguage $expressionLanguage = null) { if (null === $messageBus && null === $publisher) { throw new InvalidArgumentException('A message bus or a publisher must be provided.'); @@ -175,12 +173,11 @@ private function publishUpdate($entity, array $targets): void } else { // publish the message in the request's format // respect the entity's serializer context - $request = $this->requestStack->getCurrentRequest(); - $attributes = $request->attributes->get('_api_normalization_context'); - $context = $attributes['groups']; + $resourceClass = $this->getObjectClass($entity); + $context = $this->resourceMetadataFactory->create($resourceClass)->getAttribute('normalization_context', []); $iri = $this->iriConverter->getIriFromItem($entity, UrlGeneratorInterface::ABS_URL); - $data = $this->serializer->serialize($entity, key($this->formats), ['groups' => $context]); + $data = $this->serializer->serialize($entity, key($this->formats), $context); } $update = new Update($iri, $data, $targets); diff --git a/src/Bridge/Symfony/Bundle/Resources/config/doctrine_orm_mercure_publisher.xml b/src/Bridge/Symfony/Bundle/Resources/config/doctrine_orm_mercure_publisher.xml index d353162cfc0..1c218e13287 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/doctrine_orm_mercure_publisher.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/doctrine_orm_mercure_publisher.xml @@ -15,7 +15,6 @@ - %api_platform.formats% From 87b17a3ad72dccb565551e268eb80c9d1b4be573 Mon Sep 17 00:00:00 2001 From: toriqo Date: Sun, 31 Mar 2019 20:57:39 +0300 Subject: [PATCH 5/5] removed unused parameter --- .../Doctrine/EventListener/PublishMercureUpdatesListener.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Bridge/Doctrine/EventListener/PublishMercureUpdatesListener.php b/src/Bridge/Doctrine/EventListener/PublishMercureUpdatesListener.php index c9f6ac0d9c1..bd8ad5f156c 100644 --- a/src/Bridge/Doctrine/EventListener/PublishMercureUpdatesListener.php +++ b/src/Bridge/Doctrine/EventListener/PublishMercureUpdatesListener.php @@ -61,7 +61,6 @@ public function __construct(ResourceClassResolverInterface $resourceClassResolve $this->serializer = $serializer; $this->messageBus = $messageBus; $this->publisher = $publisher; - $this->requestStack = $requestStack; $this->formats = $formats; $this->expressionLanguage = $expressionLanguage ?? class_exists(ExpressionLanguage::class) ? new ExpressionLanguage() : null; $this->reset();