Skip to content

Commit

Permalink
Merge ef7a56e into a12ac48
Browse files Browse the repository at this point in the history
  • Loading branch information
mahmoodbazdar committed Nov 13, 2019
2 parents a12ac48 + ef7a56e commit d5569d6
Show file tree
Hide file tree
Showing 35 changed files with 604 additions and 119 deletions.
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 <contact@alanpoulain.eu>
*/
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 <contact@alanpoulain.eu>
*/
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 <contact@alanpoulain.eu>
*/
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.'));
}
}

0 comments on commit d5569d6

Please sign in to comment.