Skip to content

Commit

Permalink
feat(graphql): use provider for read stage
Browse files Browse the repository at this point in the history
  • Loading branch information
alanpoulain authored and soyuka committed Nov 18, 2021
1 parent 051abbb commit f0b4e38
Show file tree
Hide file tree
Showing 18 changed files with 451 additions and 161 deletions.
29 changes: 29 additions & 0 deletions features/main/crud_uri_variables.feature
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,35 @@ Feature: Uri Variables
"hydra:totalItems": 2
}
"""
When I send the following GraphQL request:
"""
{
companies {
edges {
node {
name
employees {
edges {
node {
name
}
}
}
}
}
}
}
"""
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 node "data.companies.edges[0].node.name" should be equal to "Foo Company 1"
And the JSON node "data.companies.edges[0].node.employees.edges" should have 1 element
And the JSON node "data.companies.edges[0].node.employees.edges[0].node.name" should be equal to "foo"
And the JSON node "data.companies.edges[1].node.name" should be equal to "Foo Company 2"
And the JSON node "data.companies.edges[1].node.employees.edges" should have 2 elements
And the JSON node "data.companies.edges[1].node.employees.edges[0].node.name" should be equal to "foo2"
And the JSON node "data.companies.edges[1].node.employees.edges[1].node.name" should be equal to "foo3"

@php8
Scenario: Retrieve the company of an employee
Expand Down
40 changes: 28 additions & 12 deletions src/Bridge/Doctrine/Orm/State/LinksHandlerTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,19 +28,35 @@ private function handleLinks(QueryBuilder $queryBuilder, array $identifiers, Que
$doctrineClassMetadata = $manager->getClassMetadata($resourceClass);
$alias = $queryBuilder->getRootAliases()[0];

if (!$identifiers) {
return;
}

$links = $operation instanceof GraphQlOperation ? $operation->getLinks() : $operation->getUriVariables();

// if ($linkClass = $context['linkClass'] ?? false) {
// foreach ($links as $link) {
// if ($linkClass === $link->getTargetClass()) {
// foreach ($identifiers as $identifier => $value) {
// $this->applyLink($queryBuilder, $queryNameGenerator, $doctrineClassMetadata, $alias, $link, $identifier, $value);
// }
//
// return;
// }
// }
// }
if ($linkClass = $context['linkClass'] ?? false) {
$newLinks = [];

foreach ($links as $link) {
if ($linkClass === $link->getFromClass()) {
$newLinks[] = $link;
}
}

$operation = $this->resourceMetadataCollectionFactory->create($linkClass)->getOperation($operationName);
$links = $operation instanceof GraphQlOperation ? $operation->getLinks() : $operation->getUriVariables();
foreach ($links as $link) {
if ($resourceClass === $link->getToClass()) {
$newLinks[] = $link;
}
}

if (!$newLinks) {
throw new RuntimeException(sprintf('The class "%s" cannot be retrieved from "%s".', $resourceClass, $linkClass));
}

$links = $newLinks;
}

if (!$links) {
return;
Expand Down Expand Up @@ -85,7 +101,7 @@ private function handleLinks(QueryBuilder $queryBuilder, array $identifiers, Que
$identifierProperty = $identifierProperties[0];
$placeholder = $queryNameGenerator->generateParameterName($identifierProperty);

if ($link->getFromProperty()) {
if ($link->getFromProperty() && !$link->getToProperty()) {
$doctrineClassMetadata = $manager->getClassMetadata($link->getFromClass());
$joinAlias = $queryNameGenerator->generateJoinAlias('m');
$assocationMapping = $doctrineClassMetadata->getAssociationMappings()[$link->getFromProperty()];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -802,6 +802,9 @@ private function registerLegacyServices(ContainerBuilder $container, array $conf
$container->removeAlias('api_platform.openapi.factory');
$container->setAlias('api_platform.openapi.factory', 'api_platform.openapi.factory.legacy');

$container->removeAlias('api_platform.graphql.resolver.stage.read');
$container->setAlias('api_platform.graphql.resolver.stage.read', 'api_platform.graphql.resolver.stage.read.legacy');

$container->removeAlias('api_platform.graphql.type_converter');
$container->setAlias('api_platform.graphql.type_converter', 'api_platform.graphql.type_converter.legacy');

Expand Down
9 changes: 9 additions & 0 deletions src/Core/Bridge/Symfony/Bundle/Resources/config/graphql.xml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,15 @@
<!-- Resolver Stages -->

<service id="api_platform.graphql.resolver.stage.read" class="ApiPlatform\GraphQl\Resolver\Stage\ReadStage" public="false">
<argument type="service" id="api_platform.metadata.resource.metadata_collection_factory" />
<argument type="service" id="api_platform.symfony.iri_converter" />
<argument type="service" id="api_platform.state_provider" />
<argument type="service" id="api_platform.graphql.serializer.context_builder" />
<argument>%api_platform.graphql.nesting_separator%</argument>
</service>

<!-- TODO: 3.0 change class -->
<service id="api_platform.graphql.resolver.stage.read.legacy" class="ApiPlatform\Core\GraphQl\Resolver\Stage\ReadStage" public="false">
<argument type="service" id="api_platform.metadata.resource.metadata_collection_factory" />
<argument type="service" id="api_platform.symfony.iri_converter" />
<argument type="service" id="api_platform.collection_data_provider" />
Expand Down
194 changes: 194 additions & 0 deletions src/Core/GraphQl/Resolver/Stage/ReadStage.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* 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\Stage;

use ApiPlatform\Api\IriConverterInterface;
use ApiPlatform\Core\DataProvider\ContextAwareCollectionDataProviderInterface;
use ApiPlatform\Core\DataProvider\SubresourceDataProviderInterface;
use ApiPlatform\Core\Util\ArrayTrait;
use ApiPlatform\Core\Util\ClassInfoTrait;
use ApiPlatform\Exception\ItemNotFoundException;
use ApiPlatform\Exception\OperationNotFoundException;
use ApiPlatform\GraphQl\Resolver\Stage\ReadStageInterface;
use ApiPlatform\GraphQl\Resolver\Util\IdentifierTrait;
use ApiPlatform\GraphQl\Serializer\ItemNormalizer;
use ApiPlatform\GraphQl\Serializer\SerializerContextBuilderInterface;
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
use GraphQL\Type\Definition\ResolveInfo;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;

/**
* Read stage of GraphQL resolvers.
*
* @author Alan Poulain <contact@alanpoulain.eu>
*/
final class ReadStage implements ReadStageInterface
{
use ArrayTrait;
use ClassInfoTrait;
use IdentifierTrait;

private $resourceMetadataCollectionFactory;
private $iriConverter;
private $collectionDataProvider;
private $subresourceDataProvider;
private $serializerContextBuilder;
private $nestingSeparator;

public function __construct(ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, IriConverterInterface $iriConverter, ContextAwareCollectionDataProviderInterface $collectionDataProvider, SubresourceDataProviderInterface $subresourceDataProvider, SerializerContextBuilderInterface $serializerContextBuilder, string $nestingSeparator)
{
$this->resourceMetadataCollectionFactory = $resourceMetadataCollectionFactory;
$this->iriConverter = $iriConverter;
$this->collectionDataProvider = $collectionDataProvider;
$this->subresourceDataProvider = $subresourceDataProvider;
$this->serializerContextBuilder = $serializerContextBuilder;
$this->nestingSeparator = $nestingSeparator;
}

/**
* {@inheritdoc}
*/
public function __invoke(?string $resourceClass, ?string $rootClass, string $operationName, array $context)
{
$operation = null;
try {
$operation = $resourceClass ? $this->resourceMetadataCollectionFactory->create($resourceClass)->getOperation($operationName) : null;
} catch (OperationNotFoundException $e) {
// ReadStage may be invoked without an existing operation
}

if ($operation && !($operation->canRead() ?? true)) {
return $context['is_collection'] ? [] : null;
}

$args = $context['args'];
$normalizationContext = $this->serializerContextBuilder->create($resourceClass, $operationName, $context, true);

if (!$context['is_collection']) {
$identifier = $this->getIdentifierFromContext($context);
$item = $this->getItem($identifier, $normalizationContext);

if ($identifier && ($context['is_mutation'] || $context['is_subscription'])) {
if (null === $item) {
throw new NotFoundHttpException(sprintf('Item "%s" not found.', $args['input']['id']));
}

if ($resourceClass !== $this->getObjectClass($item)) {
throw new \UnexpectedValueException(sprintf('Item "%s" did not match expected type "%s".', $args['input']['id'], $operation->getShortName()));
}
}

return $item;
}

if (null === $rootClass) {
return [];
}

$normalizationContext['filters'] = $this->getNormalizedFilters($args);

$source = $context['source'];
/** @var ResolveInfo $info */
$info = $context['info'];
if (isset($source[$rootProperty = $info->fieldName], $source[ItemNormalizer::ITEM_IDENTIFIERS_KEY], $source[ItemNormalizer::ITEM_RESOURCE_CLASS_KEY])) {
$rootResolvedFields = $source[ItemNormalizer::ITEM_IDENTIFIERS_KEY];
$rootResolvedClass = $source[ItemNormalizer::ITEM_RESOURCE_CLASS_KEY];
$subresourceCollection = $this->getSubresource($rootResolvedClass, $rootResolvedFields, $rootProperty, $resourceClass, $normalizationContext, $operationName);
if (!is_iterable($subresourceCollection)) {
throw new \UnexpectedValueException('Expected subresource collection to be iterable.');
}

return $subresourceCollection;
}

return $this->collectionDataProvider->getCollection($resourceClass, $operationName, $normalizationContext);
}

/**
* @return object|null
*/
private function getItem(?string $identifier, array $normalizationContext)
{
if (null === $identifier) {
return null;
}

try {
$item = $this->iriConverter->getItemFromIri($identifier, $normalizationContext);
} catch (ItemNotFoundException $e) {
return null;
}

return $item;
}

private function getNormalizedFilters(array $args): array
{
$filters = $args;

foreach ($filters as $name => $value) {
if (\is_array($value)) {
if (strpos($name, '_list')) {
$name = substr($name, 0, \strlen($name) - \strlen('_list'));
}

// If the value contains arrays, we need to merge them for the filters to understand this syntax, proper to GraphQL to preserve the order of the arguments.
if ($this->isSequentialArrayOfArrays($value)) {
if (\count($value[0]) > 1) {
$deprecationMessage = "The filter syntax \"$name: {";
$filterArgsOld = [];
$filterArgsNew = [];
foreach ($value[0] as $filterArgName => $filterArgValue) {
$filterArgsOld[] = "$filterArgName: \"$filterArgValue\"";
$filterArgsNew[] = sprintf('{%s: "%s"}', $filterArgName, $filterArgValue);
}
$deprecationMessage .= sprintf('%s}" is deprecated since API Platform 2.6, use the following syntax instead: "%s: [%s]".', implode(', ', $filterArgsOld), $name, implode(', ', $filterArgsNew));
@trigger_error($deprecationMessage, \E_USER_DEPRECATED);
}
$value = array_merge(...$value);
}
$filters[$name] = $this->getNormalizedFilters($value);
}

if (\is_string($name) && strpos($name, $this->nestingSeparator)) {
// Gives a chance to relations/nested fields.
$index = array_search($name, array_keys($filters), true);
$filters =
\array_slice($filters, 0, $index + 1) +
[str_replace($this->nestingSeparator, '.', $name) => $value] +
\array_slice($filters, $index + 1);
}
}

return $filters;
}

/**
* @return iterable|object|null
*/
private function getSubresource(string $rootResolvedClass, array $rootResolvedFields, string $rootProperty, string $subresourceClass, array $normalizationContext, string $operationName)
{
$resolvedIdentifiers = [];
$rootIdentifiers = array_keys($rootResolvedFields);
foreach ($rootIdentifiers as $rootIdentifier) {
$resolvedIdentifiers[$rootIdentifier] = [$rootResolvedClass, $rootIdentifier];
}

return $this->subresourceDataProvider->getSubresource($subresourceClass, $rootResolvedFields, $normalizationContext + [
'property' => $rootProperty,
'identifiers' => $resolvedIdentifiers,
'collection' => true,
], $operationName);
}
}
Loading

0 comments on commit f0b4e38

Please sign in to comment.