Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
12 changes: 12 additions & 0 deletions features/graphql/authorization.feature
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion features/graphql/introspection.feature
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions features/graphql/mutation.feature
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions src/Bridge/Symfony/Bundle/Resources/config/graphql.xml
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@
<argument type="service" id="api_platform.graphql.executor" />
<argument type="service" id="api_platform.graphql.action.graphiql" />
<argument type="service" id="api_platform.graphql.action.graphql_playground" />
<argument type="service" id="serializer" />
<argument>%kernel.debug%</argument>
<argument>%api_platform.graphql.graphiql.enabled%</argument>
<argument>%api_platform.graphql.graphql_playground.enabled%</argument>
Expand Down Expand Up @@ -217,6 +218,22 @@
<tag name="serializer.normalizer" priority="-995" />
</service>

<service id="api_platform.graphql.normalizer.error" class="ApiPlatform\Core\GraphQl\Serializer\Exception\ErrorNormalizer">
<tag name="serializer.normalizer" priority="-790" />
</service>

<service id="api_platform.graphql.normalizer.validation_exception" class="ApiPlatform\Core\GraphQl\Serializer\Exception\ValidationExceptionNormalizer">
<tag name="serializer.normalizer" priority="-780" />
</service>

<service id="api_platform.graphql.normalizer.http_exception" class="ApiPlatform\Core\GraphQl\Serializer\Exception\HttpExceptionNormalizer">
<tag name="serializer.normalizer" priority="-780" />
</service>

<service id="api_platform.graphql.normalizer.runtime_exception" class="ApiPlatform\Core\GraphQl\Serializer\Exception\RuntimeExceptionNormalizer">
<tag name="serializer.normalizer" priority="-780" />
</service>

<service id="api_platform.graphql.serializer.context_builder" class="ApiPlatform\Core\GraphQl\Serializer\SerializerContextBuilder" public="false">
<argument type="service" id="api_platform.metadata.resource.metadata_factory" />
<argument type="service" id="api_platform.name_converter" on-invalid="ignore" />
Expand Down
44 changes: 20 additions & 24 deletions src/GraphQl/Action/EntrypointAction.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 <[email protected]>
*/
final class EntrypointAction
Expand All @@ -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;
Expand All @@ -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));
Expand Down Expand Up @@ -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);
}
}
2 changes: 2 additions & 0 deletions src/GraphQl/Action/GraphQlPlaygroundAction.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@
/**
* GraphQL Playground entrypoint.
*
* @experimental
*
* @author Alan Poulain <[email protected]>
*/
final class GraphQlPlaygroundAction
Expand Down
2 changes: 2 additions & 0 deletions src/GraphQl/Action/GraphiQlAction.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@
/**
* GraphiQL entrypoint.
*
* @experimental
*
* @author Alan Poulain <[email protected]>
*/
final class GraphiQlAction
Expand Down
3 changes: 1 addition & 2 deletions src/GraphQl/Resolver/Factory/ItemMutationResolverFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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()));
}
}

Expand Down
13 changes: 6 additions & 7 deletions src/GraphQl/Resolver/Factory/ItemResolverFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -72,15 +71,15 @@ 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');
if (null !== $queryResolverId) {
/** @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 + [
Expand All @@ -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;
Expand All @@ -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;
Expand Down
13 changes: 6 additions & 7 deletions src/GraphQl/Resolver/Stage/ReadStage.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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']) {
Expand All @@ -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()));
}
}

Expand All @@ -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;
Expand Down
7 changes: 2 additions & 5 deletions src/GraphQl/Resolver/Stage/SecurityPostDenormalizeStage.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.'));
}
}
Loading