Skip to content

Commit 9920f15

Browse files
[GraphQL] Adding custom error format support (#3063)
* Adding GraphQL custom error format support * Use error normalizers * Changing error format to follow GraphQL spec Co-authored-by: Alan Poulain <[email protected]>
1 parent ce7fffb commit 9920f15

35 files changed

+604
-119
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
## 2.6.x-dev
44

55
* MongoDB: Possibility to add execute options (aggregate command fields) for a resource, like `allowDiskUse` (#3144)
6+
* GraphQL: Allow to format GraphQL errors based on exceptions (#3063)
67

78
## 2.5.1
89

features/graphql/authorization.feature

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ Feature: Authorization checking
1818
Then the response status code should be 200
1919
And the response should be in JSON
2020
And the header "Content-Type" should be equal to "application/json"
21+
And the JSON node "errors[0].extensions.status" should be equal to 403
22+
And the JSON node "errors[0].extensions.category" should be equal to user
2123
And the JSON node "errors[0].message" should be equal to "Access Denied."
2224

2325
Scenario: An anonymous user tries to retrieve a secured collection
@@ -38,6 +40,8 @@ Feature: Authorization checking
3840
Then the response status code should be 200
3941
And the response should be in JSON
4042
And the header "Content-Type" should be equal to "application/json"
43+
And the JSON node "errors[0].extensions.status" should be equal to 403
44+
And the JSON node "errors[0].extensions.category" should be equal to user
4145
And the JSON node "errors[0].message" should be equal to "Access Denied."
4246

4347
Scenario: An admin can retrieve a secured collection
@@ -79,6 +83,8 @@ Feature: Authorization checking
7983
And the response should be in JSON
8084
And the header "Content-Type" should be equal to "application/json"
8185
And the JSON node "data.securedDummies" should be null
86+
And the JSON node "errors[0].extensions.status" should be equal to 403
87+
And the JSON node "errors[0].extensions.category" should be equal to user
8288
And the JSON node "errors[0].message" should be equal to "Access Denied."
8389

8490
Scenario: An anonymous user tries to create a resource they are not allowed to
@@ -96,6 +102,8 @@ Feature: Authorization checking
96102
Then the response status code should be 200
97103
And the response should be in JSON
98104
And the header "Content-Type" should be equal to "application/json"
105+
And the JSON node "errors[0].extensions.status" should be equal to 403
106+
And the JSON node "errors[0].extensions.category" should be equal to user
99107
And the JSON node "errors[0].message" should be equal to "Only admins can create a secured dummy."
100108

101109
@createSchema
@@ -151,6 +159,8 @@ Feature: Authorization checking
151159
Then the response status code should be 200
152160
And the response should be in JSON
153161
And the header "Content-Type" should be equal to "application/json"
162+
And the JSON node "errors[0].extensions.status" should be equal to 403
163+
And the JSON node "errors[0].extensions.category" should be equal to user
154164
And the JSON node "errors[0].message" should be equal to "Access Denied."
155165

156166
Scenario: A user can retrieve an item they owns
@@ -186,6 +196,8 @@ Feature: Authorization checking
186196
Then the response status code should be 200
187197
And the response should be in JSON
188198
And the header "Content-Type" should be equal to "application/json"
199+
And the JSON node "errors[0].extensions.status" should be equal to 403
200+
And the JSON node "errors[0].extensions.category" should be equal to user
189201
And the JSON node "errors[0].message" should be equal to "Access Denied."
190202

191203
Scenario: A user can update an item they owns and transfer it

features/graphql/introspection.feature

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@ Feature: GraphQL introspection support
33
@createSchema
44
Scenario: Execute an empty GraphQL query
55
When I send a "GET" request to "/graphql"
6-
Then the response status code should be 400
6+
Then the response status code should be 200
77
And the response should be in JSON
88
And the header "Content-Type" should be equal to "application/json"
9+
And the JSON node "errors[0].extensions.status" should be equal to 400
10+
And the JSON node "errors[0].extensions.category" should be equal to user
911
And the JSON node "errors[0].message" should be equal to "GraphQL query is not valid."
1012

1113
Scenario: Introspect the GraphQL schema

features/graphql/mutation.feature

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -674,7 +674,11 @@ Feature: GraphQL mutation support
674674
Then the response status code should be 200
675675
And the response should be in JSON
676676
And the header "Content-Type" should be equal to "application/json"
677+
And the JSON node "errors[0].extensions.status" should be equal to "400"
677678
And the JSON node "errors[0].message" should be equal to "name: This value should not be blank."
679+
And the JSON node "errors[0].extensions.violations" should exist
680+
And the JSON node "errors[0].extensions.violations[0].path" should be equal to "name"
681+
And the JSON node "errors[0].extensions.violations[0].message" should be equal to "This value should not be blank."
678682

679683
Scenario: Execute a custom mutation
680684
Given there are 1 dummyCustomMutation objects

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,7 @@
167167
<argument type="service" id="api_platform.graphql.executor" />
168168
<argument type="service" id="api_platform.graphql.action.graphiql" />
169169
<argument type="service" id="api_platform.graphql.action.graphql_playground" />
170+
<argument type="service" id="serializer" />
170171
<argument>%kernel.debug%</argument>
171172
<argument>%api_platform.graphql.graphiql.enabled%</argument>
172173
<argument>%api_platform.graphql.graphql_playground.enabled%</argument>
@@ -217,6 +218,22 @@
217218
<tag name="serializer.normalizer" priority="-995" />
218219
</service>
219220

221+
<service id="api_platform.graphql.normalizer.error" class="ApiPlatform\Core\GraphQl\Serializer\Exception\ErrorNormalizer">
222+
<tag name="serializer.normalizer" priority="-790" />
223+
</service>
224+
225+
<service id="api_platform.graphql.normalizer.validation_exception" class="ApiPlatform\Core\GraphQl\Serializer\Exception\ValidationExceptionNormalizer">
226+
<tag name="serializer.normalizer" priority="-780" />
227+
</service>
228+
229+
<service id="api_platform.graphql.normalizer.http_exception" class="ApiPlatform\Core\GraphQl\Serializer\Exception\HttpExceptionNormalizer">
230+
<tag name="serializer.normalizer" priority="-780" />
231+
</service>
232+
233+
<service id="api_platform.graphql.normalizer.runtime_exception" class="ApiPlatform\Core\GraphQl\Serializer\Exception\RuntimeExceptionNormalizer">
234+
<tag name="serializer.normalizer" priority="-780" />
235+
</service>
236+
220237
<service id="api_platform.graphql.serializer.context_builder" class="ApiPlatform\Core\GraphQl\Serializer\SerializerContextBuilder" public="false">
221238
<argument type="service" id="api_platform.metadata.resource.metadata_factory" />
222239
<argument type="service" id="api_platform.name_converter" on-invalid="ignore" />

src/GraphQl/Action/EntrypointAction.php

Lines changed: 20 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -17,16 +17,18 @@
1717
use ApiPlatform\Core\GraphQl\Type\SchemaBuilderInterface;
1818
use GraphQL\Error\Debug;
1919
use GraphQL\Error\Error;
20-
use GraphQL\Error\UserError;
2120
use GraphQL\Executor\ExecutionResult;
2221
use Symfony\Component\HttpFoundation\JsonResponse;
2322
use Symfony\Component\HttpFoundation\Request;
2423
use Symfony\Component\HttpFoundation\Response;
2524
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
25+
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
2626

2727
/**
2828
* GraphQL API entrypoint.
2929
*
30+
* @experimental
31+
*
3032
* @author Alan Poulain <[email protected]>
3133
*/
3234
final class EntrypointAction
@@ -35,17 +37,19 @@ final class EntrypointAction
3537
private $executor;
3638
private $graphiQlAction;
3739
private $graphQlPlaygroundAction;
40+
private $normalizer;
3841
private $debug;
3942
private $graphiqlEnabled;
4043
private $graphQlPlaygroundEnabled;
4144
private $defaultIde;
4245

43-
public function __construct(SchemaBuilderInterface $schemaBuilder, ExecutorInterface $executor, GraphiQlAction $graphiQlAction, GraphQlPlaygroundAction $graphQlPlaygroundAction, bool $debug = false, bool $graphiqlEnabled = false, bool $graphQlPlaygroundEnabled = false, $defaultIde = false)
46+
public function __construct(SchemaBuilderInterface $schemaBuilder, ExecutorInterface $executor, GraphiQlAction $graphiQlAction, GraphQlPlaygroundAction $graphQlPlaygroundAction, NormalizerInterface $normalizer, bool $debug = false, bool $graphiqlEnabled = false, bool $graphQlPlaygroundEnabled = false, $defaultIde = false)
4447
{
4548
$this->schemaBuilder = $schemaBuilder;
4649
$this->executor = $executor;
4750
$this->graphiQlAction = $graphiQlAction;
4851
$this->graphQlPlaygroundAction = $graphQlPlaygroundAction;
52+
$this->normalizer = $normalizer;
4953
$this->debug = $debug ? Debug::INCLUDE_DEBUG_MESSAGE | Debug::INCLUDE_TRACE : false;
5054
$this->graphiqlEnabled = $graphiqlEnabled;
5155
$this->graphQlPlaygroundEnabled = $graphQlPlaygroundEnabled;
@@ -54,29 +58,28 @@ public function __construct(SchemaBuilderInterface $schemaBuilder, ExecutorInter
5458

5559
public function __invoke(Request $request): Response
5660
{
57-
if ($request->isMethod('GET') && 'html' === $request->getRequestFormat()) {
58-
if ('graphiql' === $this->defaultIde && $this->graphiqlEnabled) {
59-
return ($this->graphiQlAction)($request);
60-
}
61+
try {
62+
if ($request->isMethod('GET') && 'html' === $request->getRequestFormat()) {
63+
if ('graphiql' === $this->defaultIde && $this->graphiqlEnabled) {
64+
return ($this->graphiQlAction)($request);
65+
}
6166

62-
if ('graphql-playground' === $this->defaultIde && $this->graphQlPlaygroundEnabled) {
63-
return ($this->graphQlPlaygroundAction)($request);
67+
if ('graphql-playground' === $this->defaultIde && $this->graphQlPlaygroundEnabled) {
68+
return ($this->graphQlPlaygroundAction)($request);
69+
}
6470
}
65-
}
6671

67-
try {
6872
[$query, $operation, $variables] = $this->parseRequest($request);
6973
if (null === $query) {
7074
throw new BadRequestHttpException('GraphQL query is not valid.');
7175
}
7276

73-
$executionResult = $this->executor->executeQuery($this->schemaBuilder->getSchema(), $query, null, null, $variables, $operation);
74-
} catch (BadRequestHttpException $e) {
75-
$exception = new UserError($e->getMessage(), 0, $e);
76-
77-
return $this->buildExceptionResponse($exception, Response::HTTP_BAD_REQUEST);
78-
} catch (\Exception $e) {
79-
return $this->buildExceptionResponse($e, Response::HTTP_OK);
77+
$executionResult = $this->executor
78+
->executeQuery($this->schemaBuilder->getSchema(), $query, null, null, $variables, $operation)
79+
->setErrorFormatter([$this->normalizer, 'normalize']);
80+
} catch (\Exception $exception) {
81+
$executionResult = (new ExecutionResult(null, [new Error($exception->getMessage(), null, null, null, null, $exception)]))
82+
->setErrorFormatter([$this->normalizer, 'normalize']);
8083
}
8184

8285
return new JsonResponse($executionResult->toArray($this->debug));
@@ -207,11 +210,4 @@ private function decodeVariables(string $variables): array
207210

208211
return $variables;
209212
}
210-
211-
private function buildExceptionResponse(\Exception $e, int $statusCode): JsonResponse
212-
{
213-
$executionResult = new ExecutionResult(null, [new Error($e->getMessage(), null, null, null, null, $e)]);
214-
215-
return new JsonResponse($executionResult->toArray($this->debug), $statusCode);
216-
}
217213
}

src/GraphQl/Action/GraphQlPlaygroundAction.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@
2222
/**
2323
* GraphQL Playground entrypoint.
2424
*
25+
* @experimental
26+
*
2527
* @author Alan Poulain <[email protected]>
2628
*/
2729
final class GraphQlPlaygroundAction

src/GraphQl/Action/GraphiQlAction.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@
2222
/**
2323
* GraphiQL entrypoint.
2424
*
25+
* @experimental
26+
*
2527
* @author Alan Poulain <[email protected]>
2628
*/
2729
final class GraphiQlAction

src/GraphQl/Resolver/Factory/ItemMutationResolverFactory.php

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@
2424
use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface;
2525
use ApiPlatform\Core\Util\ClassInfoTrait;
2626
use ApiPlatform\Core\Util\CloneTrait;
27-
use GraphQL\Error\Error;
2827
use GraphQL\Type\Definition\ResolveInfo;
2928
use Psr\Container\ContainerInterface;
3029

@@ -106,7 +105,7 @@ public function __invoke(?string $resourceClass = null, ?string $rootClass = nul
106105
$mutationResolver = $this->mutationResolverLocator->get($mutationResolverId);
107106
$item = $mutationResolver($item, $resolverContext);
108107
if (null !== $item && $resourceClass !== $itemClass = $this->getObjectClass($item)) {
109-
throw Error::createLocatedError(sprintf('Custom mutation resolver "%s" has to return an item of class %s but returned an item of class %s.', $mutationResolverId, $resourceMetadata->getShortName(), (new \ReflectionClass($itemClass))->getShortName()), $info->fieldNodes, $info->path);
108+
throw new \LogicException(sprintf('Custom mutation resolver "%s" has to return an item of class %s but returned an item of class %s.', $mutationResolverId, $resourceMetadata->getShortName(), (new \ReflectionClass($itemClass))->getShortName()));
110109
}
111110
}
112111

src/GraphQl/Resolver/Factory/ItemResolverFactory.php

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@
2121
use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface;
2222
use ApiPlatform\Core\Util\ClassInfoTrait;
2323
use ApiPlatform\Core\Util\CloneTrait;
24-
use GraphQL\Error\Error;
2524
use GraphQL\Type\Definition\ResolveInfo;
2625
use Psr\Container\ContainerInterface;
2726

@@ -72,15 +71,15 @@ public function __invoke(?string $resourceClass = null, ?string $rootClass = nul
7271
throw new \LogicException('Item from read stage should be a nullable object.');
7372
}
7473

75-
$resourceClass = $this->getResourceClass($item, $resourceClass, $info);
74+
$resourceClass = $this->getResourceClass($item, $resourceClass);
7675
$resourceMetadata = $this->resourceMetadataFactory->create($resourceClass);
7776

7877
$queryResolverId = $resourceMetadata->getGraphqlAttribute($operationName, 'item_query');
7978
if (null !== $queryResolverId) {
8079
/** @var QueryItemResolverInterface $queryResolver */
8180
$queryResolver = $this->queryResolverLocator->get($queryResolverId);
8281
$item = $queryResolver($item, $resolverContext);
83-
$resourceClass = $this->getResourceClass($item, $resourceClass, $info, sprintf('Custom query resolver "%s"', $queryResolverId).' has to return an item of class %s but returned an item of class %s.');
82+
$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.');
8483
}
8584

8685
($this->securityStage)($resourceClass, $operationName, $resolverContext + [
@@ -102,13 +101,13 @@ public function __invoke(?string $resourceClass = null, ?string $rootClass = nul
102101
/**
103102
* @param object|null $item
104103
*
105-
* @throws Error
104+
* @throws \UnexpectedValueException
106105
*/
107-
private function getResourceClass($item, ?string $resourceClass, ResolveInfo $info, string $errorMessage = 'Resolver only handles items of class %s but retrieved item is of class %s.'): string
106+
private function getResourceClass($item, ?string $resourceClass, string $errorMessage = 'Resolver only handles items of class %s but retrieved item is of class %s.'): string
108107
{
109108
if (null === $item) {
110109
if (null === $resourceClass) {
111-
throw Error::createLocatedError('Resource class cannot be determined.', $info->fieldNodes, $info->path);
110+
throw new \UnexpectedValueException('Resource class cannot be determined.');
112111
}
113112

114113
return $resourceClass;
@@ -121,7 +120,7 @@ private function getResourceClass($item, ?string $resourceClass, ResolveInfo $in
121120
}
122121

123122
if ($resourceClass !== $itemClass) {
124-
throw Error::createLocatedError(sprintf($errorMessage, (new \ReflectionClass($resourceClass))->getShortName(), (new \ReflectionClass($itemClass))->getShortName()), $info->fieldNodes, $info->path);
123+
throw new \UnexpectedValueException(sprintf($errorMessage, (new \ReflectionClass($resourceClass))->getShortName(), (new \ReflectionClass($itemClass))->getShortName()));
125124
}
126125

127126
return $resourceClass;

src/GraphQl/Resolver/Stage/ReadStage.php

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@
2121
use ApiPlatform\Core\GraphQl\Serializer\SerializerContextBuilderInterface;
2222
use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface;
2323
use ApiPlatform\Core\Util\ClassInfoTrait;
24-
use GraphQL\Error\Error;
2524
use GraphQL\Type\Definition\ResolveInfo;
25+
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
2626

2727
/**
2828
* Read stage of GraphQL resolvers.
@@ -63,9 +63,6 @@ public function __invoke(?string $resourceClass, ?string $rootClass, string $ope
6363
}
6464

6565
$args = $context['args'];
66-
/** @var ResolveInfo $info */
67-
$info = $context['info'];
68-
6966
$normalizationContext = $this->serializerContextBuilder->create($resourceClass, $operationName, $context, true);
7067

7168
if (!$context['is_collection']) {
@@ -74,11 +71,11 @@ public function __invoke(?string $resourceClass, ?string $rootClass, string $ope
7471

7572
if ($identifier && $context['is_mutation']) {
7673
if (null === $item) {
77-
throw Error::createLocatedError(sprintf('Item "%s" not found.', $args['input']['id']), $info->fieldNodes, $info->path);
74+
throw new NotFoundHttpException(sprintf('Item "%s" not found.', $args['input']['id']));
7875
}
7976

8077
if ($resourceClass !== $this->getObjectClass($item)) {
81-
throw Error::createLocatedError(sprintf('Item "%s" did not match expected type "%s".', $args['input']['id'], $resourceMetadata->getShortName()), $info->fieldNodes, $info->path);
78+
throw new \UnexpectedValueException(sprintf('Item "%s" did not match expected type "%s".', $args['input']['id'], $resourceMetadata->getShortName()));
8279
}
8380
}
8481

@@ -92,11 +89,13 @@ public function __invoke(?string $resourceClass, ?string $rootClass, string $ope
9289
$normalizationContext['filters'] = $this->getNormalizedFilters($args);
9390

9491
$source = $context['source'];
92+
/** @var ResolveInfo $info */
93+
$info = $context['info'];
9594
if (isset($source[$rootProperty = $info->fieldName], $source[ItemNormalizer::ITEM_IDENTIFIERS_KEY])) {
9695
$rootResolvedFields = $source[ItemNormalizer::ITEM_IDENTIFIERS_KEY];
9796
$subresourceCollection = $this->getSubresource($rootClass, $rootResolvedFields, $rootProperty, $resourceClass, $normalizationContext, $operationName);
9897
if (!is_iterable($subresourceCollection)) {
99-
throw new \UnexpectedValueException('Expected subresource collection to be iterable');
98+
throw new \UnexpectedValueException('Expected subresource collection to be iterable.');
10099
}
101100

102101
return $subresourceCollection;

src/GraphQl/Resolver/Stage/SecurityPostDenormalizeStage.php

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,7 @@
1515

1616
use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface;
1717
use ApiPlatform\Core\Security\ResourceAccessCheckerInterface;
18-
use GraphQL\Error\Error;
19-
use GraphQL\Type\Definition\ResolveInfo;
18+
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
2019

2120
/**
2221
* Security post denormalize stage of GraphQL resolvers.
@@ -61,8 +60,6 @@ public function __invoke(string $resourceClass, string $operationName, array $co
6160
return;
6261
}
6362

64-
/** @var ResolveInfo $info */
65-
$info = $context['info'];
66-
throw Error::createLocatedError($resourceMetadata->getGraphqlAttribute($operationName, 'security_post_denormalize_message', 'Access Denied.'), $info->fieldNodes, $info->path);
63+
throw new AccessDeniedHttpException($resourceMetadata->getGraphqlAttribute($operationName, 'security_post_denormalize_message', 'Access Denied.'));
6764
}
6865
}

0 commit comments

Comments
 (0)