diff --git a/CHANGELOG.md b/CHANGELOG.md
index 98ee0ff0e1e..78f8c6c86bb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,7 @@
## 2.6.x-dev
* MongoDB: Possibility to add execute options (aggregate command fields) for a resource, like `allowDiskUse` (#3144)
+* GraphQL: Allow to format GraphQL errors based on exceptions (#3063)
## 2.5.1
diff --git a/features/graphql/authorization.feature b/features/graphql/authorization.feature
index 7be5fb24499..a4cc77c4ccd 100644
--- a/features/graphql/authorization.feature
+++ b/features/graphql/authorization.feature
@@ -18,6 +18,8 @@ Feature: Authorization checking
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 "errors[0].extensions.status" should be equal to 403
+ And the JSON node "errors[0].extensions.category" should be equal to user
And the JSON node "errors[0].message" should be equal to "Access Denied."
Scenario: An anonymous user tries to retrieve a secured collection
@@ -38,6 +40,8 @@ Feature: Authorization checking
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 "errors[0].extensions.status" should be equal to 403
+ And the JSON node "errors[0].extensions.category" should be equal to user
And the JSON node "errors[0].message" should be equal to "Access Denied."
Scenario: An admin can retrieve a secured collection
@@ -79,6 +83,8 @@ Feature: Authorization checking
And the response should be in JSON
And the header "Content-Type" should be equal to "application/json"
And the JSON node "data.securedDummies" should be null
+ And the JSON node "errors[0].extensions.status" should be equal to 403
+ And the JSON node "errors[0].extensions.category" should be equal to user
And the JSON node "errors[0].message" should be equal to "Access Denied."
Scenario: An anonymous user tries to create a resource they are not allowed to
@@ -96,6 +102,8 @@ Feature: Authorization checking
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 "errors[0].extensions.status" should be equal to 403
+ And the JSON node "errors[0].extensions.category" should be equal to user
And the JSON node "errors[0].message" should be equal to "Only admins can create a secured dummy."
@createSchema
@@ -151,6 +159,8 @@ Feature: Authorization checking
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 "errors[0].extensions.status" should be equal to 403
+ And the JSON node "errors[0].extensions.category" should be equal to user
And the JSON node "errors[0].message" should be equal to "Access Denied."
Scenario: A user can retrieve an item they owns
@@ -186,6 +196,8 @@ Feature: Authorization checking
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 "errors[0].extensions.status" should be equal to 403
+ And the JSON node "errors[0].extensions.category" should be equal to user
And the JSON node "errors[0].message" should be equal to "Access Denied."
Scenario: A user can update an item they owns and transfer it
diff --git a/features/graphql/introspection.feature b/features/graphql/introspection.feature
index 0c093266985..62c34ae86e2 100644
--- a/features/graphql/introspection.feature
+++ b/features/graphql/introspection.feature
@@ -3,9 +3,11 @@ Feature: GraphQL introspection support
@createSchema
Scenario: Execute an empty GraphQL query
When I send a "GET" request to "/graphql"
- Then the response status code should be 400
+ 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 "errors[0].extensions.status" should be equal to 400
+ And the JSON node "errors[0].extensions.category" should be equal to user
And the JSON node "errors[0].message" should be equal to "GraphQL query is not valid."
Scenario: Introspect the GraphQL schema
diff --git a/features/graphql/mutation.feature b/features/graphql/mutation.feature
index ac8a8f36520..0baa2ce0b10 100644
--- a/features/graphql/mutation.feature
+++ b/features/graphql/mutation.feature
@@ -674,7 +674,11 @@ Feature: GraphQL mutation support
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 "errors[0].extensions.status" should be equal to "400"
And the JSON node "errors[0].message" should be equal to "name: This value should not be blank."
+ And the JSON node "errors[0].extensions.violations" should exist
+ And the JSON node "errors[0].extensions.violations[0].path" should be equal to "name"
+ And the JSON node "errors[0].extensions.violations[0].message" should be equal to "This value should not be blank."
Scenario: Execute a custom mutation
Given there are 1 dummyCustomMutation objects
diff --git a/src/Bridge/Symfony/Bundle/Resources/config/graphql.xml b/src/Bridge/Symfony/Bundle/Resources/config/graphql.xml
index 07617218bf5..117b1ab8f87 100644
--- a/src/Bridge/Symfony/Bundle/Resources/config/graphql.xml
+++ b/src/Bridge/Symfony/Bundle/Resources/config/graphql.xml
@@ -167,6 +167,7 @@
+
%kernel.debug%
%api_platform.graphql.graphiql.enabled%
%api_platform.graphql.graphql_playground.enabled%
@@ -217,6 +218,22 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/GraphQl/Action/EntrypointAction.php b/src/GraphQl/Action/EntrypointAction.php
index 18b00be2e72..70785191e6a 100644
--- a/src/GraphQl/Action/EntrypointAction.php
+++ b/src/GraphQl/Action/EntrypointAction.php
@@ -17,16 +17,18 @@
use ApiPlatform\Core\GraphQl\Type\SchemaBuilderInterface;
use GraphQL\Error\Debug;
use GraphQL\Error\Error;
-use GraphQL\Error\UserError;
use GraphQL\Executor\ExecutionResult;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
+use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
/**
* GraphQL API entrypoint.
*
+ * @experimental
+ *
* @author Alan Poulain
*/
final class EntrypointAction
@@ -35,17 +37,19 @@ final class EntrypointAction
private $executor;
private $graphiQlAction;
private $graphQlPlaygroundAction;
+ private $normalizer;
private $debug;
private $graphiqlEnabled;
private $graphQlPlaygroundEnabled;
private $defaultIde;
- public function __construct(SchemaBuilderInterface $schemaBuilder, ExecutorInterface $executor, GraphiQlAction $graphiQlAction, GraphQlPlaygroundAction $graphQlPlaygroundAction, bool $debug = false, bool $graphiqlEnabled = false, bool $graphQlPlaygroundEnabled = false, $defaultIde = false)
+ public function __construct(SchemaBuilderInterface $schemaBuilder, ExecutorInterface $executor, GraphiQlAction $graphiQlAction, GraphQlPlaygroundAction $graphQlPlaygroundAction, NormalizerInterface $normalizer, bool $debug = false, bool $graphiqlEnabled = false, bool $graphQlPlaygroundEnabled = false, $defaultIde = false)
{
$this->schemaBuilder = $schemaBuilder;
$this->executor = $executor;
$this->graphiQlAction = $graphiQlAction;
$this->graphQlPlaygroundAction = $graphQlPlaygroundAction;
+ $this->normalizer = $normalizer;
$this->debug = $debug ? Debug::INCLUDE_DEBUG_MESSAGE | Debug::INCLUDE_TRACE : false;
$this->graphiqlEnabled = $graphiqlEnabled;
$this->graphQlPlaygroundEnabled = $graphQlPlaygroundEnabled;
@@ -54,29 +58,28 @@ public function __construct(SchemaBuilderInterface $schemaBuilder, ExecutorInter
public function __invoke(Request $request): Response
{
- if ($request->isMethod('GET') && 'html' === $request->getRequestFormat()) {
- if ('graphiql' === $this->defaultIde && $this->graphiqlEnabled) {
- return ($this->graphiQlAction)($request);
- }
+ try {
+ if ($request->isMethod('GET') && 'html' === $request->getRequestFormat()) {
+ if ('graphiql' === $this->defaultIde && $this->graphiqlEnabled) {
+ return ($this->graphiQlAction)($request);
+ }
- if ('graphql-playground' === $this->defaultIde && $this->graphQlPlaygroundEnabled) {
- return ($this->graphQlPlaygroundAction)($request);
+ if ('graphql-playground' === $this->defaultIde && $this->graphQlPlaygroundEnabled) {
+ return ($this->graphQlPlaygroundAction)($request);
+ }
}
- }
- try {
[$query, $operation, $variables] = $this->parseRequest($request);
if (null === $query) {
throw new BadRequestHttpException('GraphQL query is not valid.');
}
- $executionResult = $this->executor->executeQuery($this->schemaBuilder->getSchema(), $query, null, null, $variables, $operation);
- } catch (BadRequestHttpException $e) {
- $exception = new UserError($e->getMessage(), 0, $e);
-
- return $this->buildExceptionResponse($exception, Response::HTTP_BAD_REQUEST);
- } catch (\Exception $e) {
- return $this->buildExceptionResponse($e, Response::HTTP_OK);
+ $executionResult = $this->executor
+ ->executeQuery($this->schemaBuilder->getSchema(), $query, null, null, $variables, $operation)
+ ->setErrorFormatter([$this->normalizer, 'normalize']);
+ } catch (\Exception $exception) {
+ $executionResult = (new ExecutionResult(null, [new Error($exception->getMessage(), null, null, null, null, $exception)]))
+ ->setErrorFormatter([$this->normalizer, 'normalize']);
}
return new JsonResponse($executionResult->toArray($this->debug));
@@ -207,11 +210,4 @@ private function decodeVariables(string $variables): array
return $variables;
}
-
- private function buildExceptionResponse(\Exception $e, int $statusCode): JsonResponse
- {
- $executionResult = new ExecutionResult(null, [new Error($e->getMessage(), null, null, null, null, $e)]);
-
- return new JsonResponse($executionResult->toArray($this->debug), $statusCode);
- }
}
diff --git a/src/GraphQl/Action/GraphQlPlaygroundAction.php b/src/GraphQl/Action/GraphQlPlaygroundAction.php
index 295064451f6..614b33ab49f 100644
--- a/src/GraphQl/Action/GraphQlPlaygroundAction.php
+++ b/src/GraphQl/Action/GraphQlPlaygroundAction.php
@@ -22,6 +22,8 @@
/**
* GraphQL Playground entrypoint.
*
+ * @experimental
+ *
* @author Alan Poulain
*/
final class GraphQlPlaygroundAction
diff --git a/src/GraphQl/Action/GraphiQlAction.php b/src/GraphQl/Action/GraphiQlAction.php
index 13c24532fce..1a749cbba25 100644
--- a/src/GraphQl/Action/GraphiQlAction.php
+++ b/src/GraphQl/Action/GraphiQlAction.php
@@ -22,6 +22,8 @@
/**
* GraphiQL entrypoint.
*
+ * @experimental
+ *
* @author Alan Poulain
*/
final class GraphiQlAction
diff --git a/src/GraphQl/Resolver/Factory/ItemMutationResolverFactory.php b/src/GraphQl/Resolver/Factory/ItemMutationResolverFactory.php
index fe5948b990a..5214bfa7232 100644
--- a/src/GraphQl/Resolver/Factory/ItemMutationResolverFactory.php
+++ b/src/GraphQl/Resolver/Factory/ItemMutationResolverFactory.php
@@ -24,7 +24,6 @@
use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface;
use ApiPlatform\Core\Util\ClassInfoTrait;
use ApiPlatform\Core\Util\CloneTrait;
-use GraphQL\Error\Error;
use GraphQL\Type\Definition\ResolveInfo;
use Psr\Container\ContainerInterface;
@@ -106,7 +105,7 @@ public function __invoke(?string $resourceClass = null, ?string $rootClass = nul
$mutationResolver = $this->mutationResolverLocator->get($mutationResolverId);
$item = $mutationResolver($item, $resolverContext);
if (null !== $item && $resourceClass !== $itemClass = $this->getObjectClass($item)) {
- 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);
+ 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()));
}
}
diff --git a/src/GraphQl/Resolver/Factory/ItemResolverFactory.php b/src/GraphQl/Resolver/Factory/ItemResolverFactory.php
index ed00054e537..d7ab7387666 100644
--- a/src/GraphQl/Resolver/Factory/ItemResolverFactory.php
+++ b/src/GraphQl/Resolver/Factory/ItemResolverFactory.php
@@ -21,7 +21,6 @@
use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface;
use ApiPlatform\Core\Util\ClassInfoTrait;
use ApiPlatform\Core\Util\CloneTrait;
-use GraphQL\Error\Error;
use GraphQL\Type\Definition\ResolveInfo;
use Psr\Container\ContainerInterface;
@@ -72,7 +71,7 @@ public function __invoke(?string $resourceClass = null, ?string $rootClass = nul
throw new \LogicException('Item from read stage should be a nullable object.');
}
- $resourceClass = $this->getResourceClass($item, $resourceClass, $info);
+ $resourceClass = $this->getResourceClass($item, $resourceClass);
$resourceMetadata = $this->resourceMetadataFactory->create($resourceClass);
$queryResolverId = $resourceMetadata->getGraphqlAttribute($operationName, 'item_query');
@@ -80,7 +79,7 @@ public function __invoke(?string $resourceClass = null, ?string $rootClass = nul
/** @var QueryItemResolverInterface $queryResolver */
$queryResolver = $this->queryResolverLocator->get($queryResolverId);
$item = $queryResolver($item, $resolverContext);
- $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.');
+ $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->securityStage)($resourceClass, $operationName, $resolverContext + [
@@ -102,13 +101,13 @@ public function __invoke(?string $resourceClass = null, ?string $rootClass = nul
/**
* @param object|null $item
*
- * @throws Error
+ * @throws \UnexpectedValueException
*/
- 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
+ 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) {
if (null === $resourceClass) {
- throw Error::createLocatedError('Resource class cannot be determined.', $info->fieldNodes, $info->path);
+ throw new \UnexpectedValueException('Resource class cannot be determined.');
}
return $resourceClass;
@@ -121,7 +120,7 @@ private function getResourceClass($item, ?string $resourceClass, ResolveInfo $in
}
if ($resourceClass !== $itemClass) {
- throw Error::createLocatedError(sprintf($errorMessage, (new \ReflectionClass($resourceClass))->getShortName(), (new \ReflectionClass($itemClass))->getShortName()), $info->fieldNodes, $info->path);
+ throw new \UnexpectedValueException(sprintf($errorMessage, (new \ReflectionClass($resourceClass))->getShortName(), (new \ReflectionClass($itemClass))->getShortName()));
}
return $resourceClass;
diff --git a/src/GraphQl/Resolver/Stage/ReadStage.php b/src/GraphQl/Resolver/Stage/ReadStage.php
index bfe4bde7ac1..3f16806e16b 100644
--- a/src/GraphQl/Resolver/Stage/ReadStage.php
+++ b/src/GraphQl/Resolver/Stage/ReadStage.php
@@ -21,8 +21,8 @@
use ApiPlatform\Core\GraphQl\Serializer\SerializerContextBuilderInterface;
use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface;
use ApiPlatform\Core\Util\ClassInfoTrait;
-use GraphQL\Error\Error;
use GraphQL\Type\Definition\ResolveInfo;
+use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
/**
* Read stage of GraphQL resolvers.
@@ -63,9 +63,6 @@ public function __invoke(?string $resourceClass, ?string $rootClass, string $ope
}
$args = $context['args'];
- /** @var ResolveInfo $info */
- $info = $context['info'];
-
$normalizationContext = $this->serializerContextBuilder->create($resourceClass, $operationName, $context, true);
if (!$context['is_collection']) {
@@ -74,11 +71,11 @@ public function __invoke(?string $resourceClass, ?string $rootClass, string $ope
if ($identifier && $context['is_mutation']) {
if (null === $item) {
- throw Error::createLocatedError(sprintf('Item "%s" not found.', $args['input']['id']), $info->fieldNodes, $info->path);
+ throw new NotFoundHttpException(sprintf('Item "%s" not found.', $args['input']['id']));
}
if ($resourceClass !== $this->getObjectClass($item)) {
- throw Error::createLocatedError(sprintf('Item "%s" did not match expected type "%s".', $args['input']['id'], $resourceMetadata->getShortName()), $info->fieldNodes, $info->path);
+ throw new \UnexpectedValueException(sprintf('Item "%s" did not match expected type "%s".', $args['input']['id'], $resourceMetadata->getShortName()));
}
}
@@ -92,11 +89,13 @@ public function __invoke(?string $resourceClass, ?string $rootClass, string $ope
$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])) {
$rootResolvedFields = $source[ItemNormalizer::ITEM_IDENTIFIERS_KEY];
$subresourceCollection = $this->getSubresource($rootClass, $rootResolvedFields, $rootProperty, $resourceClass, $normalizationContext, $operationName);
if (!is_iterable($subresourceCollection)) {
- throw new \UnexpectedValueException('Expected subresource collection to be iterable');
+ throw new \UnexpectedValueException('Expected subresource collection to be iterable.');
}
return $subresourceCollection;
diff --git a/src/GraphQl/Resolver/Stage/SecurityPostDenormalizeStage.php b/src/GraphQl/Resolver/Stage/SecurityPostDenormalizeStage.php
index d6ab052d085..23c7b4cd86d 100644
--- a/src/GraphQl/Resolver/Stage/SecurityPostDenormalizeStage.php
+++ b/src/GraphQl/Resolver/Stage/SecurityPostDenormalizeStage.php
@@ -15,8 +15,7 @@
use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface;
use ApiPlatform\Core\Security\ResourceAccessCheckerInterface;
-use GraphQL\Error\Error;
-use GraphQL\Type\Definition\ResolveInfo;
+use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
/**
* Security post denormalize stage of GraphQL resolvers.
@@ -61,8 +60,6 @@ public function __invoke(string $resourceClass, string $operationName, array $co
return;
}
- /** @var ResolveInfo $info */
- $info = $context['info'];
- throw Error::createLocatedError($resourceMetadata->getGraphqlAttribute($operationName, 'security_post_denormalize_message', 'Access Denied.'), $info->fieldNodes, $info->path);
+ throw new AccessDeniedHttpException($resourceMetadata->getGraphqlAttribute($operationName, 'security_post_denormalize_message', 'Access Denied.'));
}
}
diff --git a/src/GraphQl/Resolver/Stage/SecurityStage.php b/src/GraphQl/Resolver/Stage/SecurityStage.php
index b3afa035618..6297c6eba4d 100644
--- a/src/GraphQl/Resolver/Stage/SecurityStage.php
+++ b/src/GraphQl/Resolver/Stage/SecurityStage.php
@@ -15,8 +15,7 @@
use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface;
use ApiPlatform\Core\Security\ResourceAccessCheckerInterface;
-use GraphQL\Error\Error;
-use GraphQL\Type\Definition\ResolveInfo;
+use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
/**
* Security stage of GraphQL resolvers.
@@ -53,8 +52,6 @@ public function __invoke(string $resourceClass, string $operationName, array $co
return;
}
- /** @var ResolveInfo $info */
- $info = $context['info'];
- throw Error::createLocatedError($resourceMetadata->getGraphqlAttribute($operationName, 'security_message', 'Access Denied.'), $info->fieldNodes, $info->path);
+ throw new AccessDeniedHttpException($resourceMetadata->getGraphqlAttribute($operationName, 'security_message', 'Access Denied.'));
}
}
diff --git a/src/GraphQl/Resolver/Stage/SerializeStage.php b/src/GraphQl/Resolver/Stage/SerializeStage.php
index 8c1d6feff07..18e72b51a8e 100644
--- a/src/GraphQl/Resolver/Stage/SerializeStage.php
+++ b/src/GraphQl/Resolver/Stage/SerializeStage.php
@@ -18,8 +18,6 @@
use ApiPlatform\Core\GraphQl\Serializer\ItemNormalizer;
use ApiPlatform\Core\GraphQl\Serializer\SerializerContextBuilderInterface;
use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface;
-use GraphQL\Error\Error;
-use GraphQL\Type\Definition\ResolveInfo;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
/**
@@ -72,8 +70,6 @@ public function __invoke($itemOrCollection, string $resourceClass, string $opera
$normalizationContext = $this->serializerContextBuilder->create($resourceClass, $operationName, $context, true);
$args = $context['args'];
- /** @var ResolveInfo $info */
- $info = $context['info'];
$data = null;
if (!$isCollection) {
@@ -96,7 +92,7 @@ public function __invoke($itemOrCollection, string $resourceClass, string $opera
}
if (null !== $data && !\is_array($data)) {
- throw Error::createLocatedError('Expected serialized data to be a nullable array.', $info->fieldNodes, $info->path);
+ throw new \UnexpectedValueException('Expected serialized data to be a nullable array.');
}
if ($isMutation) {
@@ -109,16 +105,15 @@ public function __invoke($itemOrCollection, string $resourceClass, string $opera
}
/**
- * @throws Error
+ * @throws \LogicException
+ * @throws \UnexpectedValueException
*/
private function serializePaginatedCollection(iterable $collection, array $normalizationContext, array $context): array
{
$args = $context['args'];
- /** @var ResolveInfo $info */
- $info = $context['info'];
if (!($collection instanceof PaginatorInterface)) {
- throw Error::createLocatedError(sprintf('Collection returned by the collection data provider must implement %s', PaginatorInterface::class), $info->fieldNodes, $info->path);
+ throw new \LogicException(sprintf('Collection returned by the collection data provider must implement %s.', PaginatorInterface::class));
}
$offset = 0;
@@ -127,14 +122,14 @@ private function serializePaginatedCollection(iterable $collection, array $norma
if (isset($args['after'])) {
$after = base64_decode($args['after'], true);
if (false === $after) {
- throw Error::createLocatedError(sprintf('Cursor %s is invalid', $args['after']), $info->fieldNodes, $info->path);
+ throw new \UnexpectedValueException(sprintf('Cursor %s is invalid.', $args['after']));
}
$offset = 1 + (int) $after;
}
if (isset($args['before'])) {
$before = base64_decode($args['before'], true);
if (false === $before) {
- throw Error::createLocatedError(sprintf('Cursor %s is invalid', $args['before']), $info->fieldNodes, $info->path);
+ throw new \UnexpectedValueException(sprintf('Cursor %s is invalid.', $args['before']));
}
$offset = (int) $before - $nbPageItems;
}
diff --git a/src/GraphQl/Resolver/Stage/ValidateStage.php b/src/GraphQl/Resolver/Stage/ValidateStage.php
index 14d98e51ae0..3c46aea7746 100644
--- a/src/GraphQl/Resolver/Stage/ValidateStage.php
+++ b/src/GraphQl/Resolver/Stage/ValidateStage.php
@@ -14,10 +14,7 @@
namespace ApiPlatform\Core\GraphQl\Resolver\Stage;
use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface;
-use ApiPlatform\Core\Validator\Exception\ValidationException;
use ApiPlatform\Core\Validator\ValidatorInterface;
-use GraphQL\Error\Error;
-use GraphQL\Type\Definition\ResolveInfo;
/**
* Validate stage of GraphQL resolvers.
@@ -48,13 +45,6 @@ public function __invoke($object, string $resourceClass, string $operationName,
}
$validationGroups = $resourceMetadata->getGraphqlAttribute($operationName, 'validation_groups', null, true);
- try {
- $this->validator->validate($object, ['groups' => $validationGroups]);
- } catch (ValidationException $e) {
- /** @var ResolveInfo $info */
- $info = $context['info'];
-
- throw Error::createLocatedError($e->getMessage(), $info->fieldNodes, $info->path);
- }
+ $this->validator->validate($object, ['groups' => $validationGroups]);
}
}
diff --git a/src/GraphQl/Serializer/Exception/ErrorNormalizer.php b/src/GraphQl/Serializer/Exception/ErrorNormalizer.php
new file mode 100644
index 00000000000..05b214f1c58
--- /dev/null
+++ b/src/GraphQl/Serializer/Exception/ErrorNormalizer.php
@@ -0,0 +1,44 @@
+
+ *
+ * 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\Serializer\Exception;
+
+use GraphQL\Error\Error;
+use GraphQL\Error\FormattedError;
+use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
+
+/**
+ * Normalize GraphQL error (fallback).
+ *
+ * @experimental
+ *
+ * @author Alan Poulain
+ */
+final class ErrorNormalizer implements NormalizerInterface
+{
+ /**
+ * {@inheritdoc}
+ */
+ public function normalize($object, $format = null, array $context = []): array
+ {
+ return FormattedError::createFromException($object);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function supportsNormalization($data, $format = null): bool
+ {
+ return $data instanceof Error;
+ }
+}
diff --git a/src/GraphQl/Serializer/Exception/HttpExceptionNormalizer.php b/src/GraphQl/Serializer/Exception/HttpExceptionNormalizer.php
new file mode 100644
index 00000000000..061cfb3199e
--- /dev/null
+++ b/src/GraphQl/Serializer/Exception/HttpExceptionNormalizer.php
@@ -0,0 +1,52 @@
+
+ *
+ * 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\Serializer\Exception;
+
+use GraphQL\Error\Error;
+use GraphQL\Error\FormattedError;
+use Symfony\Component\HttpKernel\Exception\HttpException;
+use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
+
+/**
+ * Normalize HTTP exceptions.
+ *
+ * @experimental
+ *
+ * @author Alan Poulain
+ */
+final class HttpExceptionNormalizer implements NormalizerInterface
+{
+ /**
+ * {@inheritdoc}
+ */
+ public function normalize($object, $format = null, array $context = []): array
+ {
+ /** @var HttpException */
+ $httpException = $object->getPrevious();
+ $error = FormattedError::createFromException($object);
+ $error['message'] = $httpException->getMessage();
+ $error['extensions']['status'] = $statusCode = $httpException->getStatusCode();
+ $error['extensions']['category'] = $statusCode < 500 ? 'user' : Error::CATEGORY_INTERNAL;
+
+ return $error;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function supportsNormalization($data, $format = null): bool
+ {
+ return $data instanceof Error && $data->getPrevious() instanceof HttpException;
+ }
+}
diff --git a/src/GraphQl/Serializer/Exception/RuntimeExceptionNormalizer.php b/src/GraphQl/Serializer/Exception/RuntimeExceptionNormalizer.php
new file mode 100644
index 00000000000..61bc85fe7e8
--- /dev/null
+++ b/src/GraphQl/Serializer/Exception/RuntimeExceptionNormalizer.php
@@ -0,0 +1,49 @@
+
+ *
+ * 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\Serializer\Exception;
+
+use GraphQL\Error\Error;
+use GraphQL\Error\FormattedError;
+use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
+
+/**
+ * Normalize runtime exceptions to have the right message in production mode.
+ *
+ * @experimental
+ *
+ * @author Alan Poulain
+ */
+final class RuntimeExceptionNormalizer implements NormalizerInterface
+{
+ /**
+ * {@inheritdoc}
+ */
+ public function normalize($object, $format = null, array $context = []): array
+ {
+ /** @var \RuntimeException */
+ $runtimeException = $object->getPrevious();
+ $error = FormattedError::createFromException($object);
+ $error['message'] = $runtimeException->getMessage();
+
+ return $error;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function supportsNormalization($data, $format = null): bool
+ {
+ return $data instanceof Error && $data->getPrevious() instanceof \RuntimeException;
+ }
+}
diff --git a/src/GraphQl/Serializer/Exception/ValidationExceptionNormalizer.php b/src/GraphQl/Serializer/Exception/ValidationExceptionNormalizer.php
new file mode 100644
index 00000000000..d62986ef561
--- /dev/null
+++ b/src/GraphQl/Serializer/Exception/ValidationExceptionNormalizer.php
@@ -0,0 +1,64 @@
+
+ *
+ * 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\Serializer\Exception;
+
+use ApiPlatform\Core\Bridge\Symfony\Validator\Exception\ValidationException;
+use GraphQL\Error\Error;
+use GraphQL\Error\FormattedError;
+use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
+use Symfony\Component\Validator\ConstraintViolation;
+
+/**
+ * Normalize validation exceptions.
+ *
+ * @experimental
+ *
+ * @author Mahmood Bazdar
+ * @author Alan Poulain
+ */
+final class ValidationExceptionNormalizer implements NormalizerInterface
+{
+ /**
+ * {@inheritdoc}
+ */
+ public function normalize($object, $format = null, array $context = []): array
+ {
+ /** @var ValidationException */
+ $validationException = $object->getPrevious();
+ $error = FormattedError::createFromException($object);
+ $error['message'] = $validationException->getMessage();
+ $error['extensions']['status'] = Response::HTTP_BAD_REQUEST;
+ $error['extensions']['category'] = 'user';
+ $error['extensions']['violations'] = [];
+
+ /** @var ConstraintViolation $violation */
+ foreach ($validationException->getConstraintViolationList() as $violation) {
+ $error['extensions']['violations'][] = [
+ 'path' => $violation->getPropertyPath(),
+ 'message' => $violation->getMessage(),
+ ];
+ }
+
+ return $error;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function supportsNormalization($data, $format = null): bool
+ {
+ return $data instanceof Error && $data->getPrevious() instanceof ValidationException;
+ }
+}
diff --git a/src/GraphQl/Serializer/ItemNormalizer.php b/src/GraphQl/Serializer/ItemNormalizer.php
index 12fa55c1f23..42c5d5a9490 100644
--- a/src/GraphQl/Serializer/ItemNormalizer.php
+++ b/src/GraphQl/Serializer/ItemNormalizer.php
@@ -73,7 +73,7 @@ public function normalize($object, $format = null, array $context = [])
$data = parent::normalize($object, $format, $context);
if (!\is_array($data)) {
- throw new UnexpectedValueException('Expected data to be an array');
+ throw new UnexpectedValueException('Expected data to be an array.');
}
$data[self::ITEM_RESOURCE_CLASS_KEY] = $this->getObjectClass($object);
diff --git a/src/GraphQl/Serializer/ObjectNormalizer.php b/src/GraphQl/Serializer/ObjectNormalizer.php
index 2fd5651fe95..56620d5e1d7 100644
--- a/src/GraphQl/Serializer/ObjectNormalizer.php
+++ b/src/GraphQl/Serializer/ObjectNormalizer.php
@@ -73,7 +73,7 @@ public function normalize($object, $format = null, array $context = [])
$data = $this->decorated->normalize($object, $format, $context);
if (!\is_array($data)) {
- throw new UnexpectedValueException('Expected data to be an array');
+ throw new UnexpectedValueException('Expected data to be an array.');
}
if (!isset($originalResource)) {
diff --git a/src/GraphQl/Type/TypeBuilder.php b/src/GraphQl/Type/TypeBuilder.php
index d13beacb973..66b6b23fcc3 100644
--- a/src/GraphQl/Type/TypeBuilder.php
+++ b/src/GraphQl/Type/TypeBuilder.php
@@ -77,7 +77,7 @@ public function getResourceObjectType(?string $resourceClass, ResourceMetadata $
if ($this->typesContainer->has($shortName)) {
$resourceObjectType = $this->typesContainer->get($shortName);
if (!($resourceObjectType instanceof ObjectType || $resourceObjectType instanceof NonNull)) {
- throw new \UnexpectedValueException(sprintf('Expected GraphQL type "%s" to be %s.', $shortName, implode('|', [ObjectType::class, NonNull::class])));
+ throw new \LogicException(sprintf('Expected GraphQL type "%s" to be %s.', $shortName, implode('|', [ObjectType::class, NonNull::class])));
}
return $resourceObjectType;
@@ -137,7 +137,7 @@ public function getNodeInterface(): InterfaceType
if ($this->typesContainer->has('Node')) {
$nodeInterface = $this->typesContainer->get('Node');
if (!$nodeInterface instanceof InterfaceType) {
- throw new \UnexpectedValueException(sprintf('Expected GraphQL type "Node" to be %s.', InterfaceType::class));
+ throw new \LogicException(sprintf('Expected GraphQL type "Node" to be %s.', InterfaceType::class));
}
return $nodeInterface;
diff --git a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php
index 40aff578ba1..84e0867ce47 100644
--- a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php
+++ b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php
@@ -367,6 +367,10 @@ public function testDisableGraphQl()
$containerBuilderProphecy->setDefinition('api_platform.graphql.type_converter', Argument::type(Definition::class))->shouldNotBeCalled();
$containerBuilderProphecy->setDefinition('api_platform.graphql.query_resolver_locator', Argument::type(Definition::class))->shouldNotBeCalled();
$containerBuilderProphecy->setDefinition('api_platform.graphql.mutation_resolver_locator', Argument::type(Definition::class))->shouldNotBeCalled();
+ $containerBuilderProphecy->setDefinition('api_platform.graphql.normalizer.error', Argument::type(Definition::class))->shouldNotBeCalled();
+ $containerBuilderProphecy->setDefinition('api_platform.graphql.normalizer.validation_exception', Argument::type(Definition::class))->shouldNotBeCalled();
+ $containerBuilderProphecy->setDefinition('api_platform.graphql.normalizer.http_exception', Argument::type(Definition::class))->shouldNotBeCalled();
+ $containerBuilderProphecy->setDefinition('api_platform.graphql.normalizer.runtime_exception', Argument::type(Definition::class))->shouldNotBeCalled();
$containerBuilderProphecy->setDefinition('api_platform.graphql.command.export_command', Argument::type(Definition::class))->shouldNotBeCalled();
$containerBuilderProphecy->setParameter('api_platform.graphql.enabled', true)->shouldNotBeCalled();
$containerBuilderProphecy->setParameter('api_platform.graphql.enabled', false)->shouldBeCalled();
@@ -1183,6 +1187,10 @@ private function getBaseContainerBuilderProphecy(array $doctrineIntegrationsToLo
'api_platform.graphql.resolver.stage.write',
'api_platform.graphql.resolver.stage.validate',
'api_platform.graphql.resolver.resource_field',
+ 'api_platform.graphql.normalizer.error',
+ 'api_platform.graphql.normalizer.validation_exception',
+ 'api_platform.graphql.normalizer.http_exception',
+ 'api_platform.graphql.normalizer.runtime_exception',
'api_platform.graphql.iterable_type',
'api_platform.graphql.upload_type',
'api_platform.graphql.type_locator',
diff --git a/tests/GraphQl/Action/EntrypointActionTest.php b/tests/GraphQl/Action/EntrypointActionTest.php
index 74ffd74aad0..9da2d7edc7f 100644
--- a/tests/GraphQl/Action/EntrypointActionTest.php
+++ b/tests/GraphQl/Action/EntrypointActionTest.php
@@ -17,6 +17,8 @@
use ApiPlatform\Core\GraphQl\Action\GraphiQlAction;
use ApiPlatform\Core\GraphQl\Action\GraphQlPlaygroundAction;
use ApiPlatform\Core\GraphQl\ExecutorInterface;
+use ApiPlatform\Core\GraphQl\Serializer\Exception\ErrorNormalizer;
+use ApiPlatform\Core\GraphQl\Serializer\Exception\HttpExceptionNormalizer;
use ApiPlatform\Core\GraphQl\Type\SchemaBuilderInterface;
use GraphQL\Executor\ExecutionResult;
use GraphQL\Type\Schema;
@@ -27,6 +29,7 @@
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\RouterInterface;
+use Symfony\Component\Serializer\Serializer;
use Twig\Environment as TwigEnvironment;
/**
@@ -37,7 +40,7 @@ class EntrypointActionTest extends TestCase
/**
* Hack to avoid transient failing test because of Date header.
*/
- private function assertEqualsWithoutDateHeader(JsonResponse $expected, Response $actual)
+ private function assertEqualsWithoutDateHeader(JsonResponse $expected, Response $actual): void
{
$expected->headers->remove('Date');
$actual->headers->remove('Date');
@@ -53,7 +56,7 @@ public function testGetHtmlAction(): void
$this->assertInstanceOf(Response::class, $mockedEntrypoint($request));
}
- public function testGetAction()
+ public function testGetAction(): void
{
$request = new Request(['query' => 'graphqlQuery', 'variables' => '["graphqlVariable"]', 'operation' => 'graphqlOperationName']);
$request->setRequestFormat('json');
@@ -62,7 +65,7 @@ public function testGetAction()
$this->assertEqualsWithoutDateHeader(new JsonResponse(['GraphQL']), $mockedEntrypoint($request));
}
- public function testPostRawAction()
+ public function testPostRawAction(): void
{
$request = new Request(['variables' => '["graphqlVariable"]', 'operation' => 'graphqlOperationName'], [], [], [], [], [], 'graphqlQuery');
$request->setFormat('graphql', 'application/graphql');
@@ -73,7 +76,7 @@ public function testPostRawAction()
$this->assertEqualsWithoutDateHeader(new JsonResponse(['GraphQL']), $mockedEntrypoint($request));
}
- public function testPostJsonAction()
+ public function testPostJsonAction(): void
{
$request = new Request([], [], [], [], [], [], '{"query": "graphqlQuery", "variables": "[\"graphqlVariable\"]", "operation": "graphqlOperationName"}');
$request->setMethod('POST');
@@ -86,7 +89,7 @@ public function testPostJsonAction()
/**
* @dataProvider multipartRequestProvider
*/
- public function testMultipartRequestAction(?string $operations, ?string $map, array $files, array $variables, Response $expectedResponse)
+ public function testMultipartRequestAction(?string $operations, ?string $map, array $files, array $variables, Response $expectedResponse): void
{
$requestParams = [];
if ($operations) {
@@ -144,82 +147,82 @@ public function multipartRequestProvider(): array
'{"file": ["variables.file"]}',
['file' => $file],
['file' => $file],
- new Response('{"errors":[{"message":"GraphQL multipart request does not respect the specification.","extensions":{"category":"user"}}]}'),
+ new Response('{"errors":[{"message":"GraphQL multipart request does not respect the specification.","extensions":{"category":"user","status":400}}]}'),
],
'upload without providing map' => [
'{"query": "graphqlQuery", "variables": {"file": null}, "operation": "graphqlOperationName"}',
null,
['file' => $file],
['file' => null],
- new Response('{"errors":[{"message":"GraphQL multipart request does not respect the specification.","extensions":{"category":"user"}}]}'),
+ new Response('{"errors":[{"message":"GraphQL multipart request does not respect the specification.","extensions":{"category":"user","status":400}}]}'),
],
'upload with invalid json' => [
'{invalid}',
'{"file": ["file"]}',
['file' => $file],
['file' => null],
- new Response('{"errors":[{"message":"GraphQL data is not valid JSON.","extensions":{"category":"user"}}]}'),
+ new Response('{"errors":[{"message":"GraphQL data is not valid JSON.","extensions":{"category":"user","status":400}}]}'),
],
'upload with invalid map JSON' => [
'{"query": "graphqlQuery", "variables": {"file": null}, "operation": "graphqlOperationName"}',
'{invalid}',
['file' => $file],
['file' => null],
- new Response('{"errors":[{"message":"GraphQL multipart request map is not valid JSON.","extensions":{"category":"user"}}]}'),
+ new Response('{"errors":[{"message":"GraphQL multipart request map is not valid JSON.","extensions":{"category":"user","status":400}}]}'),
],
'upload with no file' => [
'{"query": "graphqlQuery", "variables": {"file": null}, "operation": "graphqlOperationName"}',
'{"file": ["file"]}',
[],
['file' => null],
- new Response('{"errors":[{"message":"GraphQL multipart request file has not been sent correctly.","extensions":{"category":"user"}}]}'),
+ new Response('{"errors":[{"message":"GraphQL multipart request file has not been sent correctly.","extensions":{"category":"user","status":400}}]}'),
],
'upload with wrong map' => [
'{"query": "graphqlQuery", "variables": {"file": null}, "operation": "graphqlOperationName"}',
'{"file": ["file"]}',
['file' => $file],
['file' => null],
- new Response('{"errors":[{"message":"GraphQL multipart request path in map is invalid.","extensions":{"category":"user"}}]}'),
+ new Response('{"errors":[{"message":"GraphQL multipart request path in map is invalid.","extensions":{"category":"user","status":400}}]}'),
],
'upload when variable path does not exist' => [
'{"query": "graphqlQuery", "variables": {"file": null}, "operation": "graphqlOperationName"}',
'{"file": ["variables.wrong"]}',
['file' => $file],
['file' => null],
- new Response('{"errors":[{"message":"GraphQL multipart request path in map does not match the variables.","extensions":{"category":"user"}}]}'),
+ new Response('{"errors":[{"message":"GraphQL multipart request path in map does not match the variables.","extensions":{"category":"user","status":400}}]}'),
],
];
}
- public function testBadContentTypePostAction()
+ public function testBadContentTypePostAction(): void
{
$request = new Request();
$request->setMethod('POST');
$request->headers->set('Content-Type', 'application/xml');
$mockedEntrypoint = $this->getEntrypointAction();
- $this->assertEquals(400, $mockedEntrypoint($request)->getStatusCode());
- $this->assertEquals('{"errors":[{"message":"GraphQL query is not valid.","extensions":{"category":"user"}}]}', $mockedEntrypoint($request)->getContent());
+ $this->assertEquals(200, $mockedEntrypoint($request)->getStatusCode());
+ $this->assertEquals('{"errors":[{"message":"GraphQL query is not valid.","extensions":{"category":"user","status":400}}]}', $mockedEntrypoint($request)->getContent());
}
- public function testBadMethodAction()
+ public function testBadMethodAction(): void
{
$request = new Request();
$request->setMethod('PUT');
$mockedEntrypoint = $this->getEntrypointAction();
- $this->assertEquals(400, $mockedEntrypoint($request)->getStatusCode());
- $this->assertEquals('{"errors":[{"message":"GraphQL query is not valid.","extensions":{"category":"user"}}]}', $mockedEntrypoint($request)->getContent());
+ $this->assertEquals(200, $mockedEntrypoint($request)->getStatusCode());
+ $this->assertEquals('{"errors":[{"message":"GraphQL query is not valid.","extensions":{"category":"user","status":400}}]}', $mockedEntrypoint($request)->getContent());
}
- public function testBadVariablesAction()
+ public function testBadVariablesAction(): void
{
$request = new Request(['query' => 'graphqlQuery', 'variables' => 'graphqlVariable', 'operation' => 'graphqlOperationName']);
$request->setRequestFormat('json');
$mockedEntrypoint = $this->getEntrypointAction();
- $this->assertEquals(400, $mockedEntrypoint($request)->getStatusCode());
- $this->assertEquals('{"errors":[{"message":"GraphQL variables are not valid JSON.","extensions":{"category":"user"}}]}', $mockedEntrypoint($request)->getContent());
+ $this->assertEquals(200, $mockedEntrypoint($request)->getStatusCode());
+ $this->assertEquals('{"errors":[{"message":"GraphQL variables are not valid JSON.","extensions":{"category":"user","status":400}}]}', $mockedEntrypoint($request)->getContent());
}
private function getEntrypointAction(array $variables = ['graphqlVariable']): EntrypointAction
@@ -228,8 +231,14 @@ private function getEntrypointAction(array $variables = ['graphqlVariable']): En
$schemaBuilderProphecy = $this->prophesize(SchemaBuilderInterface::class);
$schemaBuilderProphecy->getSchema()->willReturn($schema->reveal());
+ $normalizer = new Serializer([
+ new HttpExceptionNormalizer(),
+ new ErrorNormalizer(),
+ ]);
+
$executionResultProphecy = $this->prophesize(ExecutionResult::class);
$executionResultProphecy->toArray(false)->willReturn(['GraphQL']);
+ $executionResultProphecy->setErrorFormatter([$normalizer, 'normalize'])->willReturn($executionResultProphecy);
$executorProphecy = $this->prophesize(ExecutorInterface::class);
$executorProphecy->executeQuery(Argument::is($schema->reveal()), 'graphqlQuery', null, null, $variables, 'graphqlOperationName')->willReturn($executionResultProphecy->reveal());
@@ -239,6 +248,6 @@ private function getEntrypointAction(array $variables = ['graphqlVariable']): En
$graphiQlAction = new GraphiQlAction($twigProphecy->reveal(), $routerProphecy->reveal(), true);
$graphQlPlaygroundAction = new GraphQlPlaygroundAction($twigProphecy->reveal(), $routerProphecy->reveal(), true);
- return new EntrypointAction($schemaBuilderProphecy->reveal(), $executorProphecy->reveal(), $graphiQlAction, $graphQlPlaygroundAction, false, true, true, 'graphiql');
+ return new EntrypointAction($schemaBuilderProphecy->reveal(), $executorProphecy->reveal(), $graphiQlAction, $graphQlPlaygroundAction, $normalizer, false, true, true, 'graphiql');
}
}
diff --git a/tests/GraphQl/Resolver/Factory/ItemMutationResolverFactoryTest.php b/tests/GraphQl/Resolver/Factory/ItemMutationResolverFactoryTest.php
index 8f9f5ae178f..ce283a65e33 100644
--- a/tests/GraphQl/Resolver/Factory/ItemMutationResolverFactoryTest.php
+++ b/tests/GraphQl/Resolver/Factory/ItemMutationResolverFactoryTest.php
@@ -24,7 +24,6 @@
use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface;
use ApiPlatform\Core\Metadata\Resource\ResourceMetadata;
use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy;
-use GraphQL\Error\Error;
use GraphQL\Type\Definition\ResolveInfo;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
@@ -321,7 +320,7 @@ public function testResolveCustomBadItem(): void
return $customItem;
});
- $this->expectException(Error::class);
+ $this->expectException(\LogicException::class);
$this->expectExceptionMessage('Custom mutation resolver "query_resolver_id" has to return an item of class shortName but returned an item of class Dummy.');
($this->itemMutationResolverFactory)($resourceClass, $rootClass, $operationName)($source, $args, null, $info);
diff --git a/tests/GraphQl/Resolver/Factory/ItemResolverFactoryTest.php b/tests/GraphQl/Resolver/Factory/ItemResolverFactoryTest.php
index 1093daf904d..bae61f2a5b2 100644
--- a/tests/GraphQl/Resolver/Factory/ItemResolverFactoryTest.php
+++ b/tests/GraphQl/Resolver/Factory/ItemResolverFactoryTest.php
@@ -21,7 +21,6 @@
use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface;
use ApiPlatform\Core\Metadata\Resource\ResourceMetadata;
use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy;
-use GraphQL\Error\Error;
use GraphQL\Type\Definition\ResolveInfo;
use PHPUnit\Framework\TestCase;
use Psr\Container\ContainerInterface;
@@ -148,7 +147,7 @@ public function testResolveNoResourceNoItem(): void
$readStageItem = null;
$this->readStageProphecy->__invoke($resourceClass, $rootClass, $operationName, $resolverContext)->shouldBeCalled()->willReturn($readStageItem);
- $this->expectException(Error::class);
+ $this->expectException(\UnexpectedValueException::class);
$this->expectExceptionMessage('Resource class cannot be determined.');
($this->itemResolverFactory)($resourceClass, $rootClass, $operationName)($source, $args, null, $info);
@@ -167,7 +166,7 @@ public function testResolveBadItem(): void
$readStageItem = new \stdClass();
$this->readStageProphecy->__invoke($resourceClass, $rootClass, $operationName, $resolverContext)->shouldBeCalled()->willReturn($readStageItem);
- $this->expectException(Error::class);
+ $this->expectException(\UnexpectedValueException::class);
$this->expectExceptionMessage('Resolver only handles items of class Dummy but retrieved item is of class stdClass.');
($this->itemResolverFactory)($resourceClass, $rootClass, $operationName)($source, $args, null, $info);
@@ -236,7 +235,7 @@ public function testResolveCustomBadItem(): void
return $customItem;
});
- $this->expectException(Error::class);
+ $this->expectException(\UnexpectedValueException::class);
$this->expectExceptionMessage('Custom query resolver "query_resolver_id" has to return an item of class stdClass but returned an item of class Dummy.');
($this->itemResolverFactory)($resourceClass, $rootClass, $operationName)($source, $args, null, $info);
diff --git a/tests/GraphQl/Resolver/Stage/ReadStageTest.php b/tests/GraphQl/Resolver/Stage/ReadStageTest.php
index d63e39e2fbd..fce8e9e6c65 100644
--- a/tests/GraphQl/Resolver/Stage/ReadStageTest.php
+++ b/tests/GraphQl/Resolver/Stage/ReadStageTest.php
@@ -22,9 +22,9 @@
use ApiPlatform\Core\GraphQl\Serializer\SerializerContextBuilderInterface;
use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface;
use ApiPlatform\Core\Metadata\Resource\ResourceMetadata;
-use GraphQL\Error\Error;
use GraphQL\Type\Definition\ResolveInfo;
use PHPUnit\Framework\TestCase;
+use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
/**
* @author Alan Poulain
@@ -175,8 +175,8 @@ public function itemMutationProvider(): array
return [
'no identifier' => ['myResource', null, $item, false, null],
'identifier' => ['stdClass', 'identifier', $item, false, $item],
- 'identifier bad item' => ['myResource', 'identifier', $item, false, $item, Error::class, 'Item "identifier" did not match expected type "shortName".'],
- 'identifier not found' => ['myResource', 'identifier_not_found', $item, true, null, Error::class, 'Item "identifier_not_found" not found.'],
+ 'identifier bad item' => ['myResource', 'identifier', $item, false, $item, \UnexpectedValueException::class, 'Item "identifier" did not match expected type "shortName".'],
+ 'identifier not found' => ['myResource', 'identifier_not_found', $item, true, null, NotFoundHttpException::class, 'Item "identifier_not_found" not found.'],
];
}
diff --git a/tests/GraphQl/Resolver/Stage/SecurityPostDenormalizeStageTest.php b/tests/GraphQl/Resolver/Stage/SecurityPostDenormalizeStageTest.php
index 301963c836a..1595e10e438 100644
--- a/tests/GraphQl/Resolver/Stage/SecurityPostDenormalizeStageTest.php
+++ b/tests/GraphQl/Resolver/Stage/SecurityPostDenormalizeStageTest.php
@@ -17,10 +17,10 @@
use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface;
use ApiPlatform\Core\Metadata\Resource\ResourceMetadata;
use ApiPlatform\Core\Security\ResourceAccessCheckerInterface;
-use GraphQL\Error\Error;
use GraphQL\Type\Definition\ResolveInfo;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
+use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
/**
* @author Alan Poulain
@@ -107,7 +107,7 @@ public function testNotGranted(): void
$info = $this->prophesize(ResolveInfo::class)->reveal();
- $this->expectException(Error::class);
+ $this->expectException(AccessDeniedHttpException::class);
$this->expectExceptionMessage('Access Denied.');
($this->securityPostDenormalizeStage)($resourceClass, 'item_query', [
diff --git a/tests/GraphQl/Resolver/Stage/SecurityStageTest.php b/tests/GraphQl/Resolver/Stage/SecurityStageTest.php
index a49207679a2..e3d431a2c72 100644
--- a/tests/GraphQl/Resolver/Stage/SecurityStageTest.php
+++ b/tests/GraphQl/Resolver/Stage/SecurityStageTest.php
@@ -17,10 +17,10 @@
use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface;
use ApiPlatform\Core\Metadata\Resource\ResourceMetadata;
use ApiPlatform\Core\Security\ResourceAccessCheckerInterface;
-use GraphQL\Error\Error;
use GraphQL\Type\Definition\ResolveInfo;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
+use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
/**
* @author Alan Poulain
@@ -88,7 +88,7 @@ public function testNotGranted(): void
$info = $this->prophesize(ResolveInfo::class)->reveal();
- $this->expectException(Error::class);
+ $this->expectException(AccessDeniedHttpException::class);
$this->expectExceptionMessage('Access Denied.');
($this->securityStage)($resourceClass, 'item_query', [
diff --git a/tests/GraphQl/Resolver/Stage/SerializeStageTest.php b/tests/GraphQl/Resolver/Stage/SerializeStageTest.php
index 640d15cfa90..0db5f9d5e9e 100644
--- a/tests/GraphQl/Resolver/Stage/SerializeStageTest.php
+++ b/tests/GraphQl/Resolver/Stage/SerializeStageTest.php
@@ -20,7 +20,6 @@
use ApiPlatform\Core\GraphQl\Serializer\SerializerContextBuilderInterface;
use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface;
use ApiPlatform\Core\Metadata\Resource\ResourceMetadata;
-use GraphQL\Error\Error;
use GraphQL\Type\Definition\ResolveInfo;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
@@ -140,13 +139,13 @@ public function testApplyCollectionWithPagination(iterable $collection, array $a
public function applyCollectionWithPaginationProvider(): array
{
return [
- 'not paginator' => [[], [], null, Error::class, 'Collection returned by the collection data provider must implement ApiPlatform\Core\DataProvider\PaginatorInterface'],
+ 'not paginator' => [[], [], null, \LogicException::class, 'Collection returned by the collection data provider must implement ApiPlatform\Core\DataProvider\PaginatorInterface'],
'empty paginator' => [new ArrayPaginator([], 0, 0), [], ['totalCount' => 0., 'edges' => [], 'pageInfo' => ['startCursor' => null, 'endCursor' => null, 'hasNextPage' => false, 'hasPreviousPage' => false]]],
'paginator' => [new ArrayPaginator([new \stdClass(), new \stdClass(), new \stdClass()], 0, 2), [], ['totalCount' => 3., 'edges' => [['node' => ['normalized_item'], 'cursor' => 'MA=='], ['node' => ['normalized_item'], 'cursor' => 'MQ==']], 'pageInfo' => ['startCursor' => 'MA==', 'endCursor' => 'MQ==', 'hasNextPage' => true, 'hasPreviousPage' => false]]],
'paginator with after cursor' => [new ArrayPaginator([new \stdClass(), new \stdClass(), new \stdClass()], 1, 2), ['after' => 'MA=='], ['totalCount' => 3., 'edges' => [['node' => ['normalized_item'], 'cursor' => 'MQ=='], ['node' => ['normalized_item'], 'cursor' => 'Mg==']], 'pageInfo' => ['startCursor' => 'MQ==', 'endCursor' => 'Mg==', 'hasNextPage' => false, 'hasPreviousPage' => true]]],
- 'paginator with bad after cursor' => [new ArrayPaginator([], 0, 0), ['after' => '-'], null, Error::class, 'Cursor - is invalid'],
+ 'paginator with bad after cursor' => [new ArrayPaginator([], 0, 0), ['after' => '-'], null, \UnexpectedValueException::class, 'Cursor - is invalid'],
'paginator with before cursor' => [new ArrayPaginator([new \stdClass(), new \stdClass(), new \stdClass()], 1, 1), ['before' => 'Mg=='], ['totalCount' => 3., 'edges' => [['node' => ['normalized_item'], 'cursor' => 'MQ==']], 'pageInfo' => ['startCursor' => 'MQ==', 'endCursor' => 'MQ==', 'hasNextPage' => false, 'hasPreviousPage' => true]]],
- 'paginator with bad before cursor' => [new ArrayPaginator([], 0, 0), ['before' => '-'], null, Error::class, 'Cursor - is invalid'],
+ 'paginator with bad before cursor' => [new ArrayPaginator([], 0, 0), ['before' => '-'], null, \UnexpectedValueException::class, 'Cursor - is invalid'],
'paginator with last' => [new ArrayPaginator([new \stdClass(), new \stdClass(), new \stdClass()], 1, 2), ['last' => 2], ['totalCount' => 3., 'edges' => [['node' => ['normalized_item'], 'cursor' => 'MQ=='], ['node' => ['normalized_item'], 'cursor' => 'Mg==']], 'pageInfo' => ['startCursor' => 'MQ==', 'endCursor' => 'Mg==', 'hasNextPage' => false, 'hasPreviousPage' => true]]],
];
}
@@ -163,7 +162,7 @@ public function testApplyBadNormalizedData(): void
$this->normalizerProphecy->normalize(Argument::type('stdClass'), ItemNormalizer::FORMAT, $normalizationContext)->willReturn(new \stdClass());
- $this->expectException(Error::class);
+ $this->expectException(\UnexpectedValueException::class);
$this->expectExceptionMessage('Expected serialized data to be a nullable array.');
($this->createSerializeStage(false))(new \stdClass(), $resourceClass, $operationName, $context);
diff --git a/tests/GraphQl/Resolver/Stage/ValidateStageTest.php b/tests/GraphQl/Resolver/Stage/ValidateStageTest.php
index 7b064b75ef3..904bcd50213 100644
--- a/tests/GraphQl/Resolver/Stage/ValidateStageTest.php
+++ b/tests/GraphQl/Resolver/Stage/ValidateStageTest.php
@@ -18,7 +18,6 @@
use ApiPlatform\Core\Metadata\Resource\ResourceMetadata;
use ApiPlatform\Core\Validator\Exception\ValidationException;
use ApiPlatform\Core\Validator\ValidatorInterface;
-use GraphQL\Error\Error;
use GraphQL\Type\Definition\ResolveInfo;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
@@ -92,7 +91,7 @@ public function testApplyNotValidated(): void
$object = new \stdClass();
$this->validatorProphecy->validate($object, ['groups' => $validationGroups])->shouldBeCalled()->willThrow(new ValidationException());
- $this->expectException(Error::class);
+ $this->expectException(ValidationException::class);
($this->validateStage)($object, $resourceClass, $operationName, $context);
}
diff --git a/tests/GraphQl/Serializer/Exception/ErrorNormalizerTest.php b/tests/GraphQl/Serializer/Exception/ErrorNormalizerTest.php
new file mode 100644
index 00000000000..ccceabc9e5a
--- /dev/null
+++ b/tests/GraphQl/Serializer/Exception/ErrorNormalizerTest.php
@@ -0,0 +1,53 @@
+
+ *
+ * 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\Serializer\Exception;
+
+use ApiPlatform\Core\GraphQl\Serializer\Exception\ErrorNormalizer;
+use GraphQL\Error\Error;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * @author Alan Poulain
+ */
+class ErrorNormalizerTest extends TestCase
+{
+ private $errorNormalizer;
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function setUp(): void
+ {
+ parent::setUp();
+
+ $this->errorNormalizer = new ErrorNormalizer();
+ }
+
+ public function testNormalize(): void
+ {
+ $errorMessage = 'test message';
+ $error = new Error($errorMessage);
+
+ $normalizedError = $this->errorNormalizer->normalize($error);
+ $this->assertSame($errorMessage, $normalizedError['message']);
+ $this->assertSame(Error::CATEGORY_GRAPHQL, $normalizedError['extensions']['category']);
+ }
+
+ public function testSupportsNormalization(): void
+ {
+ $error = new Error('test message');
+
+ $this->assertTrue($this->errorNormalizer->supportsNormalization($error));
+ }
+}
diff --git a/tests/GraphQl/Serializer/Exception/HttpExceptionNormalizerTest.php b/tests/GraphQl/Serializer/Exception/HttpExceptionNormalizerTest.php
new file mode 100644
index 00000000000..fadf5b421a7
--- /dev/null
+++ b/tests/GraphQl/Serializer/Exception/HttpExceptionNormalizerTest.php
@@ -0,0 +1,70 @@
+
+ *
+ * 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\Serializer\Exception;
+
+use ApiPlatform\Core\GraphQl\Serializer\Exception\HttpExceptionNormalizer;
+use GraphQL\Error\Error;
+use PHPUnit\Framework\TestCase;
+use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
+use Symfony\Component\HttpKernel\Exception\HttpException;
+use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException;
+
+/**
+ * @author Alan Poulain
+ */
+class HttpExceptionNormalizerTest extends TestCase
+{
+ private $httpExceptionNormalizer;
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function setUp(): void
+ {
+ parent::setUp();
+
+ $this->httpExceptionNormalizer = new HttpExceptionNormalizer();
+ }
+
+ /**
+ * @dataProvider exceptionProvider
+ */
+ public function testNormalize(HttpException $exception, string $expectedExceptionMessage, int $expectedStatus, string $expectedCategory): void
+ {
+ $error = new Error('test message', null, null, null, null, $exception);
+
+ $normalizedError = $this->httpExceptionNormalizer->normalize($error);
+ $this->assertSame($expectedExceptionMessage, $normalizedError['message']);
+ $this->assertSame($expectedStatus, $normalizedError['extensions']['status']);
+ $this->assertSame($expectedCategory, $normalizedError['extensions']['category']);
+ }
+
+ public function exceptionProvider(): array
+ {
+ $exceptionMessage = 'exception message';
+
+ return [
+ 'client error' => [new BadRequestHttpException($exceptionMessage), $exceptionMessage, 400, 'user'],
+ 'server error' => [new ServiceUnavailableHttpException(null, $exceptionMessage), $exceptionMessage, 503, Error::CATEGORY_INTERNAL],
+ ];
+ }
+
+ public function testSupportsNormalization(): void
+ {
+ $exception = new BadRequestHttpException();
+ $error = new Error('test message', null, null, null, null, $exception);
+
+ $this->assertTrue($this->httpExceptionNormalizer->supportsNormalization($error));
+ }
+}
diff --git a/tests/GraphQl/Serializer/Exception/RuntimeExceptionNormalizerTest.php b/tests/GraphQl/Serializer/Exception/RuntimeExceptionNormalizerTest.php
new file mode 100644
index 00000000000..2050dc8f772
--- /dev/null
+++ b/tests/GraphQl/Serializer/Exception/RuntimeExceptionNormalizerTest.php
@@ -0,0 +1,55 @@
+
+ *
+ * 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\Serializer\Exception;
+
+use ApiPlatform\Core\GraphQl\Serializer\Exception\RuntimeExceptionNormalizer;
+use GraphQL\Error\Error;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * @author Alan Poulain
+ */
+class RuntimeExceptionNormalizerTest extends TestCase
+{
+ private $runtimeExceptionNormalizer;
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function setUp(): void
+ {
+ parent::setUp();
+
+ $this->runtimeExceptionNormalizer = new RuntimeExceptionNormalizer();
+ }
+
+ public function testNormalize(): void
+ {
+ $exceptionMessage = 'exception message';
+ $exception = new \RuntimeException($exceptionMessage);
+ $error = new Error('test message', null, null, null, null, $exception);
+
+ $normalizedError = $this->runtimeExceptionNormalizer->normalize($error);
+ $this->assertSame($exceptionMessage, $normalizedError['message']);
+ $this->assertSame(Error::CATEGORY_INTERNAL, $normalizedError['extensions']['category']);
+ }
+
+ public function testSupportsNormalization(): void
+ {
+ $exception = new \RuntimeException();
+ $error = new Error('test message', null, null, null, null, $exception);
+
+ $this->assertTrue($this->runtimeExceptionNormalizer->supportsNormalization($error));
+ }
+}
diff --git a/tests/GraphQl/Serializer/Exception/ValidationExceptionNormalizerTest.php b/tests/GraphQl/Serializer/Exception/ValidationExceptionNormalizerTest.php
new file mode 100644
index 00000000000..37d10665279
--- /dev/null
+++ b/tests/GraphQl/Serializer/Exception/ValidationExceptionNormalizerTest.php
@@ -0,0 +1,73 @@
+
+ *
+ * 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\Serializer\Exception;
+
+use ApiPlatform\Core\Bridge\Symfony\Validator\Exception\ValidationException;
+use ApiPlatform\Core\GraphQl\Serializer\Exception\ValidationExceptionNormalizer;
+use GraphQL\Error\Error;
+use PHPUnit\Framework\TestCase;
+use Symfony\Component\Validator\ConstraintViolation;
+use Symfony\Component\Validator\ConstraintViolationList;
+
+/**
+ * @author Mahmood Bazdar
+ */
+class ValidationExceptionNormalizerTest extends TestCase
+{
+ private $validationExceptionNormalizer;
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function setUp(): void
+ {
+ parent::setUp();
+
+ $this->validationExceptionNormalizer = new ValidationExceptionNormalizer();
+ }
+
+ public function testNormalize(): void
+ {
+ $exceptionMessage = 'exception message';
+ $exception = new ValidationException(new ConstraintViolationList([
+ new ConstraintViolation('message 1', '', [], '', 'field 1', 'invalid'),
+ new ConstraintViolation('message 2', '', [], '', 'field 2', 'invalid'),
+ ]), $exceptionMessage);
+ $error = new Error('test message', null, null, null, null, $exception);
+
+ $normalizedError = $this->validationExceptionNormalizer->normalize($error);
+ $this->assertSame($exceptionMessage, $normalizedError['message']);
+ $this->assertSame(400, $normalizedError['extensions']['status']);
+ $this->assertSame('user', $normalizedError['extensions']['category']);
+ $this->assertArrayHasKey('violations', $normalizedError['extensions']);
+ $this->assertSame([
+ [
+ 'path' => 'field 1',
+ 'message' => 'message 1',
+ ],
+ [
+ 'path' => 'field 2',
+ 'message' => 'message 2',
+ ],
+ ], $normalizedError['extensions']['violations']);
+ }
+
+ public function testSupportsNormalization(): void
+ {
+ $exception = new ValidationException(new ConstraintViolationList([]));
+ $error = new Error('test message', null, null, null, null, $exception);
+
+ $this->assertTrue($this->validationExceptionNormalizer->supportsNormalization($error));
+ }
+}