Skip to content

Commit f0b4e38

Browse files
alanpoulainsoyuka
authored andcommitted
feat(graphql): use provider for read stage
1 parent 051abbb commit f0b4e38

File tree

18 files changed

+451
-161
lines changed

18 files changed

+451
-161
lines changed

features/main/crud_uri_variables.feature

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,35 @@ Feature: Uri Variables
121121
"hydra:totalItems": 2
122122
}
123123
"""
124+
When I send the following GraphQL request:
125+
"""
126+
{
127+
companies {
128+
edges {
129+
node {
130+
name
131+
employees {
132+
edges {
133+
node {
134+
name
135+
}
136+
}
137+
}
138+
}
139+
}
140+
}
141+
}
142+
"""
143+
Then the response status code should be 200
144+
And the response should be in JSON
145+
And the header "Content-Type" should be equal to "application/json"
146+
And the JSON node "data.companies.edges[0].node.name" should be equal to "Foo Company 1"
147+
And the JSON node "data.companies.edges[0].node.employees.edges" should have 1 element
148+
And the JSON node "data.companies.edges[0].node.employees.edges[0].node.name" should be equal to "foo"
149+
And the JSON node "data.companies.edges[1].node.name" should be equal to "Foo Company 2"
150+
And the JSON node "data.companies.edges[1].node.employees.edges" should have 2 elements
151+
And the JSON node "data.companies.edges[1].node.employees.edges[0].node.name" should be equal to "foo2"
152+
And the JSON node "data.companies.edges[1].node.employees.edges[1].node.name" should be equal to "foo3"
124153

125154
@php8
126155
Scenario: Retrieve the company of an employee

src/Bridge/Doctrine/Orm/State/LinksHandlerTrait.php

Lines changed: 28 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -28,19 +28,35 @@ private function handleLinks(QueryBuilder $queryBuilder, array $identifiers, Que
2828
$doctrineClassMetadata = $manager->getClassMetadata($resourceClass);
2929
$alias = $queryBuilder->getRootAliases()[0];
3030

31+
if (!$identifiers) {
32+
return;
33+
}
34+
3135
$links = $operation instanceof GraphQlOperation ? $operation->getLinks() : $operation->getUriVariables();
3236

33-
// if ($linkClass = $context['linkClass'] ?? false) {
34-
// foreach ($links as $link) {
35-
// if ($linkClass === $link->getTargetClass()) {
36-
// foreach ($identifiers as $identifier => $value) {
37-
// $this->applyLink($queryBuilder, $queryNameGenerator, $doctrineClassMetadata, $alias, $link, $identifier, $value);
38-
// }
39-
//
40-
// return;
41-
// }
42-
// }
43-
// }
37+
if ($linkClass = $context['linkClass'] ?? false) {
38+
$newLinks = [];
39+
40+
foreach ($links as $link) {
41+
if ($linkClass === $link->getFromClass()) {
42+
$newLinks[] = $link;
43+
}
44+
}
45+
46+
$operation = $this->resourceMetadataCollectionFactory->create($linkClass)->getOperation($operationName);
47+
$links = $operation instanceof GraphQlOperation ? $operation->getLinks() : $operation->getUriVariables();
48+
foreach ($links as $link) {
49+
if ($resourceClass === $link->getToClass()) {
50+
$newLinks[] = $link;
51+
}
52+
}
53+
54+
if (!$newLinks) {
55+
throw new RuntimeException(sprintf('The class "%s" cannot be retrieved from "%s".', $resourceClass, $linkClass));
56+
}
57+
58+
$links = $newLinks;
59+
}
4460

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

88-
if ($link->getFromProperty()) {
104+
if ($link->getFromProperty() && !$link->getToProperty()) {
89105
$doctrineClassMetadata = $manager->getClassMetadata($link->getFromClass());
90106
$joinAlias = $queryNameGenerator->generateJoinAlias('m');
91107
$assocationMapping = $doctrineClassMetadata->getAssociationMappings()[$link->getFromProperty()];

src/Core/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -802,6 +802,9 @@ private function registerLegacyServices(ContainerBuilder $container, array $conf
802802
$container->removeAlias('api_platform.openapi.factory');
803803
$container->setAlias('api_platform.openapi.factory', 'api_platform.openapi.factory.legacy');
804804

805+
$container->removeAlias('api_platform.graphql.resolver.stage.read');
806+
$container->setAlias('api_platform.graphql.resolver.stage.read', 'api_platform.graphql.resolver.stage.read.legacy');
807+
805808
$container->removeAlias('api_platform.graphql.type_converter');
806809
$container->setAlias('api_platform.graphql.type_converter', 'api_platform.graphql.type_converter.legacy');
807810

src/Core/Bridge/Symfony/Bundle/Resources/config/graphql.xml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,15 @@
5353
<!-- Resolver Stages -->
5454

5555
<service id="api_platform.graphql.resolver.stage.read" class="ApiPlatform\GraphQl\Resolver\Stage\ReadStage" public="false">
56+
<argument type="service" id="api_platform.metadata.resource.metadata_collection_factory" />
57+
<argument type="service" id="api_platform.symfony.iri_converter" />
58+
<argument type="service" id="api_platform.state_provider" />
59+
<argument type="service" id="api_platform.graphql.serializer.context_builder" />
60+
<argument>%api_platform.graphql.nesting_separator%</argument>
61+
</service>
62+
63+
<!-- TODO: 3.0 change class -->
64+
<service id="api_platform.graphql.resolver.stage.read.legacy" class="ApiPlatform\Core\GraphQl\Resolver\Stage\ReadStage" public="false">
5665
<argument type="service" id="api_platform.metadata.resource.metadata_collection_factory" />
5766
<argument type="service" id="api_platform.symfony.iri_converter" />
5867
<argument type="service" id="api_platform.collection_data_provider" />
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Core\GraphQl\Resolver\Stage;
15+
16+
use ApiPlatform\Api\IriConverterInterface;
17+
use ApiPlatform\Core\DataProvider\ContextAwareCollectionDataProviderInterface;
18+
use ApiPlatform\Core\DataProvider\SubresourceDataProviderInterface;
19+
use ApiPlatform\Core\Util\ArrayTrait;
20+
use ApiPlatform\Core\Util\ClassInfoTrait;
21+
use ApiPlatform\Exception\ItemNotFoundException;
22+
use ApiPlatform\Exception\OperationNotFoundException;
23+
use ApiPlatform\GraphQl\Resolver\Stage\ReadStageInterface;
24+
use ApiPlatform\GraphQl\Resolver\Util\IdentifierTrait;
25+
use ApiPlatform\GraphQl\Serializer\ItemNormalizer;
26+
use ApiPlatform\GraphQl\Serializer\SerializerContextBuilderInterface;
27+
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
28+
use GraphQL\Type\Definition\ResolveInfo;
29+
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
30+
31+
/**
32+
* Read stage of GraphQL resolvers.
33+
*
34+
* @author Alan Poulain <[email protected]>
35+
*/
36+
final class ReadStage implements ReadStageInterface
37+
{
38+
use ArrayTrait;
39+
use ClassInfoTrait;
40+
use IdentifierTrait;
41+
42+
private $resourceMetadataCollectionFactory;
43+
private $iriConverter;
44+
private $collectionDataProvider;
45+
private $subresourceDataProvider;
46+
private $serializerContextBuilder;
47+
private $nestingSeparator;
48+
49+
public function __construct(ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, IriConverterInterface $iriConverter, ContextAwareCollectionDataProviderInterface $collectionDataProvider, SubresourceDataProviderInterface $subresourceDataProvider, SerializerContextBuilderInterface $serializerContextBuilder, string $nestingSeparator)
50+
{
51+
$this->resourceMetadataCollectionFactory = $resourceMetadataCollectionFactory;
52+
$this->iriConverter = $iriConverter;
53+
$this->collectionDataProvider = $collectionDataProvider;
54+
$this->subresourceDataProvider = $subresourceDataProvider;
55+
$this->serializerContextBuilder = $serializerContextBuilder;
56+
$this->nestingSeparator = $nestingSeparator;
57+
}
58+
59+
/**
60+
* {@inheritdoc}
61+
*/
62+
public function __invoke(?string $resourceClass, ?string $rootClass, string $operationName, array $context)
63+
{
64+
$operation = null;
65+
try {
66+
$operation = $resourceClass ? $this->resourceMetadataCollectionFactory->create($resourceClass)->getOperation($operationName) : null;
67+
} catch (OperationNotFoundException $e) {
68+
// ReadStage may be invoked without an existing operation
69+
}
70+
71+
if ($operation && !($operation->canRead() ?? true)) {
72+
return $context['is_collection'] ? [] : null;
73+
}
74+
75+
$args = $context['args'];
76+
$normalizationContext = $this->serializerContextBuilder->create($resourceClass, $operationName, $context, true);
77+
78+
if (!$context['is_collection']) {
79+
$identifier = $this->getIdentifierFromContext($context);
80+
$item = $this->getItem($identifier, $normalizationContext);
81+
82+
if ($identifier && ($context['is_mutation'] || $context['is_subscription'])) {
83+
if (null === $item) {
84+
throw new NotFoundHttpException(sprintf('Item "%s" not found.', $args['input']['id']));
85+
}
86+
87+
if ($resourceClass !== $this->getObjectClass($item)) {
88+
throw new \UnexpectedValueException(sprintf('Item "%s" did not match expected type "%s".', $args['input']['id'], $operation->getShortName()));
89+
}
90+
}
91+
92+
return $item;
93+
}
94+
95+
if (null === $rootClass) {
96+
return [];
97+
}
98+
99+
$normalizationContext['filters'] = $this->getNormalizedFilters($args);
100+
101+
$source = $context['source'];
102+
/** @var ResolveInfo $info */
103+
$info = $context['info'];
104+
if (isset($source[$rootProperty = $info->fieldName], $source[ItemNormalizer::ITEM_IDENTIFIERS_KEY], $source[ItemNormalizer::ITEM_RESOURCE_CLASS_KEY])) {
105+
$rootResolvedFields = $source[ItemNormalizer::ITEM_IDENTIFIERS_KEY];
106+
$rootResolvedClass = $source[ItemNormalizer::ITEM_RESOURCE_CLASS_KEY];
107+
$subresourceCollection = $this->getSubresource($rootResolvedClass, $rootResolvedFields, $rootProperty, $resourceClass, $normalizationContext, $operationName);
108+
if (!is_iterable($subresourceCollection)) {
109+
throw new \UnexpectedValueException('Expected subresource collection to be iterable.');
110+
}
111+
112+
return $subresourceCollection;
113+
}
114+
115+
return $this->collectionDataProvider->getCollection($resourceClass, $operationName, $normalizationContext);
116+
}
117+
118+
/**
119+
* @return object|null
120+
*/
121+
private function getItem(?string $identifier, array $normalizationContext)
122+
{
123+
if (null === $identifier) {
124+
return null;
125+
}
126+
127+
try {
128+
$item = $this->iriConverter->getItemFromIri($identifier, $normalizationContext);
129+
} catch (ItemNotFoundException $e) {
130+
return null;
131+
}
132+
133+
return $item;
134+
}
135+
136+
private function getNormalizedFilters(array $args): array
137+
{
138+
$filters = $args;
139+
140+
foreach ($filters as $name => $value) {
141+
if (\is_array($value)) {
142+
if (strpos($name, '_list')) {
143+
$name = substr($name, 0, \strlen($name) - \strlen('_list'));
144+
}
145+
146+
// 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.
147+
if ($this->isSequentialArrayOfArrays($value)) {
148+
if (\count($value[0]) > 1) {
149+
$deprecationMessage = "The filter syntax \"$name: {";
150+
$filterArgsOld = [];
151+
$filterArgsNew = [];
152+
foreach ($value[0] as $filterArgName => $filterArgValue) {
153+
$filterArgsOld[] = "$filterArgName: \"$filterArgValue\"";
154+
$filterArgsNew[] = sprintf('{%s: "%s"}', $filterArgName, $filterArgValue);
155+
}
156+
$deprecationMessage .= sprintf('%s}" is deprecated since API Platform 2.6, use the following syntax instead: "%s: [%s]".', implode(', ', $filterArgsOld), $name, implode(', ', $filterArgsNew));
157+
@trigger_error($deprecationMessage, \E_USER_DEPRECATED);
158+
}
159+
$value = array_merge(...$value);
160+
}
161+
$filters[$name] = $this->getNormalizedFilters($value);
162+
}
163+
164+
if (\is_string($name) && strpos($name, $this->nestingSeparator)) {
165+
// Gives a chance to relations/nested fields.
166+
$index = array_search($name, array_keys($filters), true);
167+
$filters =
168+
\array_slice($filters, 0, $index + 1) +
169+
[str_replace($this->nestingSeparator, '.', $name) => $value] +
170+
\array_slice($filters, $index + 1);
171+
}
172+
}
173+
174+
return $filters;
175+
}
176+
177+
/**
178+
* @return iterable|object|null
179+
*/
180+
private function getSubresource(string $rootResolvedClass, array $rootResolvedFields, string $rootProperty, string $subresourceClass, array $normalizationContext, string $operationName)
181+
{
182+
$resolvedIdentifiers = [];
183+
$rootIdentifiers = array_keys($rootResolvedFields);
184+
foreach ($rootIdentifiers as $rootIdentifier) {
185+
$resolvedIdentifiers[$rootIdentifier] = [$rootResolvedClass, $rootIdentifier];
186+
}
187+
188+
return $this->subresourceDataProvider->getSubresource($subresourceClass, $rootResolvedFields, $normalizationContext + [
189+
'property' => $rootProperty,
190+
'identifiers' => $resolvedIdentifiers,
191+
'collection' => true,
192+
], $operationName);
193+
}
194+
}

0 commit comments

Comments
 (0)