Skip to content

Commit 5386488

Browse files
authoredAug 28, 2024··
feat(laravel): enable graphQl support (#6550)
1 parent 03357fb commit 5386488

18 files changed

+614
-87
lines changed
 

‎src/GraphQl/Tests/Type/FieldsBuilderTest.php

+7-13
Original file line numberDiff line numberDiff line change
@@ -63,9 +63,6 @@ class FieldsBuilderTest extends TestCase
6363
private ObjectProphecy $typeBuilderProphecy;
6464
private ObjectProphecy $typeConverterProphecy;
6565
private ObjectProphecy $itemResolverFactoryProphecy;
66-
private ObjectProphecy $collectionResolverFactoryProphecy;
67-
private ObjectProphecy $itemMutationResolverFactoryProphecy;
68-
private ObjectProphecy $itemSubscriptionResolverFactoryProphecy;
6966
private ObjectProphecy $filterLocatorProphecy;
7067
private ObjectProphecy $resourceClassResolverProphecy;
7168
private FieldsBuilder $fieldsBuilder;
@@ -82,17 +79,14 @@ protected function setUp(): void
8279
$this->typeBuilderProphecy = $this->prophesize(ContextAwareTypeBuilderInterface::class);
8380
$this->typeConverterProphecy = $this->prophesize(TypeConverterInterface::class);
8481
$this->itemResolverFactoryProphecy = $this->prophesize(ResolverFactoryInterface::class);
85-
$this->collectionResolverFactoryProphecy = $this->prophesize(ResolverFactoryInterface::class);
86-
$this->itemMutationResolverFactoryProphecy = $this->prophesize(ResolverFactoryInterface::class);
87-
$this->itemSubscriptionResolverFactoryProphecy = $this->prophesize(ResolverFactoryInterface::class);
8882
$this->filterLocatorProphecy = $this->prophesize(ContainerInterface::class);
8983
$this->resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class);
9084
$this->fieldsBuilder = $this->buildFieldsBuilder();
9185
}
9286

9387
private function buildFieldsBuilder(?AdvancedNameConverterInterface $advancedNameConverter = null): FieldsBuilder
9488
{
95-
return new FieldsBuilder($this->propertyNameCollectionFactoryProphecy->reveal(), $this->propertyMetadataFactoryProphecy->reveal(), $this->resourceMetadataCollectionFactoryProphecy->reveal(), $this->resourceClassResolverProphecy->reveal(), $this->typesContainerProphecy->reveal(), $this->typeBuilderProphecy->reveal(), $this->typeConverterProphecy->reveal(), $this->itemResolverFactoryProphecy->reveal(), $this->collectionResolverFactoryProphecy->reveal(), $this->itemMutationResolverFactoryProphecy->reveal(), $this->itemSubscriptionResolverFactoryProphecy->reveal(), $this->filterLocatorProphecy->reveal(), new Pagination(), $advancedNameConverter ?? new CustomConverter(), '__');
89+
return new FieldsBuilder($this->propertyNameCollectionFactoryProphecy->reveal(), $this->propertyMetadataFactoryProphecy->reveal(), $this->resourceMetadataCollectionFactoryProphecy->reveal(), $this->resourceClassResolverProphecy->reveal(), $this->typesContainerProphecy->reveal(), $this->typeBuilderProphecy->reveal(), $this->typeConverterProphecy->reveal(), $this->itemResolverFactoryProphecy->reveal(), $this->filterLocatorProphecy->reveal(), new Pagination(), $advancedNameConverter ?? new CustomConverter(), '__');
9690
}
9791

9892
public function testGetNodeQueryFields(): void
@@ -126,7 +120,7 @@ public function testGetItemQueryFields(string $resourceClass, Operation $operati
126120
$this->typeConverterProphecy->convertType(Argument::type(Type::class), false, Argument::that(static fn (Operation $arg): bool => $arg->getName() === $operation->getName()), $resourceClass, $resourceClass, null, 0)->willReturn($graphqlType);
127121
$this->typeConverterProphecy->resolveType(Argument::type('string'))->willReturn(GraphQLType::string());
128122
$this->typeBuilderProphecy->isCollection(Argument::type(Type::class))->willReturn(false);
129-
$this->itemResolverFactoryProphecy->__invoke($resourceClass, $resourceClass, $operation)->willReturn($resolver);
123+
$this->itemResolverFactoryProphecy->__invoke($resourceClass, $resourceClass, $operation, Argument::any())->willReturn($resolver);
130124

131125
$queryFields = $this->fieldsBuilder->getItemQueryFields($resourceClass, $operation, $configuration);
132126

@@ -206,7 +200,7 @@ public function testGetCollectionQueryFields(string $resourceClass, Operation $o
206200
$this->typeConverterProphecy->resolveType(Argument::type('string'))->willReturn(GraphQLType::string());
207201
$this->typeBuilderProphecy->isCollection(Argument::type(Type::class))->willReturn(true);
208202
$this->typeBuilderProphecy->getPaginatedCollectionType($graphqlType, $operation)->willReturn($graphqlType);
209-
$this->collectionResolverFactoryProphecy->__invoke($resourceClass, $resourceClass, $operation)->willReturn($resolver);
203+
$this->itemResolverFactoryProphecy->__invoke($resourceClass, $resourceClass, $operation, Argument::any())->willReturn($resolver);
210204
$this->filterLocatorProphecy->has('my_filter')->willReturn(true);
211205
$filterProphecy = $this->prophesize(FilterInterface::class);
212206
$filterProphecy->getDescription($resourceClass)->willReturn([
@@ -356,7 +350,7 @@ public function testGetMutationFields(string $resourceClass, Operation $operatio
356350
$this->typeConverterProphecy->convertType(Argument::type(Type::class), false, Argument::that(static fn (Operation $arg): bool => $arg->getName() === $operation->getName()), $resourceClass, $resourceClass, null, 0)->willReturn($graphqlType);
357351
$this->typeConverterProphecy->convertType(Argument::type(Type::class), true, Argument::that(static fn (Operation $arg): bool => $arg->getName() === $operation->getName()), $resourceClass, $resourceClass, null, 0)->willReturn($inputGraphqlType);
358352
$this->typeBuilderProphecy->isCollection(Argument::type(Type::class))->willReturn(false);
359-
$this->itemMutationResolverFactoryProphecy->__invoke($resourceClass, $resourceClass, $operation)->willReturn($mutationResolver);
353+
$this->itemResolverFactoryProphecy->__invoke($resourceClass, $resourceClass, $operation, Argument::any())->willReturn($mutationResolver);
360354

361355
$mutationFields = $this->fieldsBuilder->getMutationFields($resourceClass, $operation);
362356

@@ -417,7 +411,7 @@ public function testGetSubscriptionFields(string $resourceClass, Operation $oper
417411
$this->typeConverterProphecy->convertType(Argument::type(Type::class), true, Argument::that(static fn (Operation $arg): bool => $arg->getName() === $operation->getName()), $resourceClass, $resourceClass, null, 0)->willReturn($inputGraphqlType);
418412
$this->typeBuilderProphecy->isCollection(Argument::type(Type::class))->willReturn(false);
419413
$this->resourceMetadataCollectionFactoryProphecy->create($resourceClass)->willReturn(new ResourceMetadataCollection($resourceClass, [(new ApiResource())->withGraphQlOperations([$operation->getName() => $operation])]));
420-
$this->itemSubscriptionResolverFactoryProphecy->__invoke($resourceClass, $resourceClass, $operation)->willReturn($subscriptionResolver);
414+
$this->itemResolverFactoryProphecy->__invoke($resourceClass, $resourceClass, $operation, Argument::any())->willReturn($subscriptionResolver);
421415

422416
$subscriptionFields = $this->fieldsBuilder->getSubscriptionFields($resourceClass, $operation);
423417

@@ -489,14 +483,14 @@ public function testGetResourceObjectTypeFields(string $resourceClass, Operation
489483

490484
if ('propertyObject' === $propertyName) {
491485
$this->typeConverterProphecy->convertType(Argument::type(Type::class), Argument::type('bool'), Argument::that(static fn (Operation $arg): bool => $arg->getName() === $operation->getName()), 'objectClass', $resourceClass, $propertyName, $depth + 1)->willReturn(new ObjectType(['name' => 'objectType', 'fields' => []]));
492-
$this->itemResolverFactoryProphecy->__invoke('objectClass', $resourceClass, $operation)->willReturn(static function (): void {
486+
$this->itemResolverFactoryProphecy->__invoke('objectClass', $resourceClass, $operation, Argument::any())->willReturn(static function (): void {
493487
});
494488
}
495489
if ('propertyNestedResource' === $propertyName) {
496490
$nestedResourceQueryOperation = new Query();
497491
$this->resourceMetadataCollectionFactoryProphecy->create('nestedResourceClass')->willReturn(new ResourceMetadataCollection('nestedResourceClass', [(new ApiResource())->withGraphQlOperations(['item_query' => $nestedResourceQueryOperation])]));
498492
$this->typeConverterProphecy->convertType(Argument::type(Type::class), Argument::type('bool'), Argument::that(static fn (Operation $arg): bool => $arg->getName() === $operation->getName()), 'nestedResourceClass', $resourceClass, $propertyName, $depth + 1)->willReturn(new ObjectType(['name' => 'objectType', 'fields' => []]));
499-
$this->itemResolverFactoryProphecy->__invoke('nestedResourceClass', $resourceClass, $nestedResourceQueryOperation)->willReturn(static function (): void {
493+
$this->itemResolverFactoryProphecy->__invoke('nestedResourceClass', $resourceClass, $nestedResourceQueryOperation, Argument::any())->willReturn(static function (): void {
500494
});
501495
}
502496
}

‎src/GraphQl/Type/FieldsBuilder.php

+5-18
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@
1515

1616
use ApiPlatform\Doctrine\Odm\State\Options as ODMOptions;
1717
use ApiPlatform\Doctrine\Orm\State\Options;
18-
use ApiPlatform\GraphQl\Resolver\Factory\ResolverFactory;
1918
use ApiPlatform\GraphQl\Resolver\Factory\ResolverFactoryInterface;
2019
use ApiPlatform\GraphQl\Type\Definition\TypeInterface;
2120
use ApiPlatform\Metadata\GraphQl\Mutation;
@@ -51,7 +50,7 @@ final class FieldsBuilder implements FieldsBuilderEnumInterface
5150
{
5251
private readonly ContextAwareTypeBuilderInterface $typeBuilder;
5352

54-
public function __construct(private readonly PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory, private readonly ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, private readonly ResourceClassResolverInterface $resourceClassResolver, private readonly TypesContainerInterface $typesContainer, ContextAwareTypeBuilderInterface $typeBuilder, private readonly TypeConverterInterface $typeConverter, private readonly ResolverFactoryInterface $itemResolverFactory, private readonly ?ResolverFactoryInterface $collectionResolverFactory, private readonly ?ResolverFactoryInterface $itemMutationResolverFactory, private readonly ?ResolverFactoryInterface $itemSubscriptionResolverFactory, private readonly ContainerInterface $filterLocator, private readonly Pagination $pagination, private readonly ?NameConverterInterface $nameConverter, private readonly string $nestingSeparator, private readonly ?InflectorInterface $inflector = new Inflector())
53+
public function __construct(private readonly PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory, private readonly ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, private readonly ResourceClassResolverInterface $resourceClassResolver, private readonly TypesContainerInterface $typesContainer, ContextAwareTypeBuilderInterface $typeBuilder, private readonly TypeConverterInterface $typeConverter, private readonly ResolverFactoryInterface $resolverFactory, private readonly ContainerInterface $filterLocator, private readonly Pagination $pagination, private readonly ?NameConverterInterface $nameConverter, private readonly string $nestingSeparator, private readonly ?InflectorInterface $inflector = new Inflector())
5554
{
5655
$this->typeBuilder = $typeBuilder;
5756
}
@@ -66,7 +65,7 @@ public function getNodeQueryFields(): array
6665
'args' => [
6766
'id' => ['type' => GraphQLType::nonNull(GraphQLType::id())],
6867
],
69-
'resolve' => ($this->itemResolverFactory)(),
68+
'resolve' => ($this->resolverFactory)(),
7069
];
7170
}
7271

@@ -450,22 +449,10 @@ private function getResourceFieldConfiguration(?string $property, ?string $field
450449
$args = $this->getFilterArgs($args, $resourceClass, $rootResource, $resourceOperation, $rootOperation, $property, $depth);
451450
}
452451

453-
if ($this->itemResolverFactory instanceof ResolverFactory) {
454-
if ($isStandardGraphqlType || $input) {
455-
$resolve = null;
456-
} else {
457-
$resolve = ($this->itemResolverFactory)($resourceClass, $rootResource, $resourceOperation, $this->propertyMetadataFactory);
458-
}
452+
if ($isStandardGraphqlType || $input) {
453+
$resolve = null;
459454
} else {
460-
if ($isStandardGraphqlType || $input) {
461-
$resolve = null;
462-
} elseif (($rootOperation instanceof Mutation || $rootOperation instanceof Subscription) && $depth <= 0) {
463-
$resolve = $rootOperation instanceof Mutation ? ($this->itemMutationResolverFactory)($resourceClass, $rootResource, $resourceOperation) : ($this->itemSubscriptionResolverFactory)($resourceClass, $rootResource, $resourceOperation);
464-
} elseif ($this->typeBuilder->isCollection($type)) {
465-
$resolve = ($this->collectionResolverFactory)($resourceClass, $rootResource, $resourceOperation);
466-
} else {
467-
$resolve = ($this->itemResolverFactory)($resourceClass, $rootResource, $resourceOperation);
468-
}
455+
$resolve = ($this->resolverFactory)($resourceClass, $rootResource, $resourceOperation, $this->propertyMetadataFactory);
469456
}
470457

471458
return [

‎src/GraphQl/Type/TypeBuilder.php

+7-1
Original file line numberDiff line numberDiff line change
@@ -41,11 +41,17 @@ final class TypeBuilder implements ContextAwareTypeBuilderInterface
4141
{
4242
private $defaultFieldResolver;
4343

44-
public function __construct(private readonly TypesContainerInterface $typesContainer, callable $defaultFieldResolver, private readonly ContainerInterface $fieldsBuilderLocator, private readonly Pagination $pagination)
44+
public function __construct(private readonly TypesContainerInterface $typesContainer, callable $defaultFieldResolver, private ?ContainerInterface $fieldsBuilderLocator, private readonly Pagination $pagination)
4545
{
46+
$this->fieldsBuilderLocator = $fieldsBuilderLocator;
4647
$this->defaultFieldResolver = $defaultFieldResolver;
4748
}
4849

50+
public function setFieldsBuilderLocator(ContainerInterface $fieldsBuilderLocator): void
51+
{
52+
$this->fieldsBuilderLocator = $fieldsBuilderLocator;
53+
}
54+
4955
/**
5056
* {@inheritdoc}
5157
*/

‎src/GraphQl/composer.json

+3-1
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@
2424
"api-platform/metadata": "^3.2 || ^4.0",
2525
"api-platform/serializer": "^3.2 || ^4.0",
2626
"api-platform/state": "^3.2 || ^4.0",
27-
"api-platform/validator": "^3.2 || ^4.0",
2827
"symfony/property-info": "^6.4 || ^7.1",
2928
"symfony/serializer": "^6.4 || ^7.1",
3029
"webonyx/graphql-php": "^14.0 || ^15.0",
@@ -42,6 +41,9 @@
4241
"api-platform/doctrine-odm": "^3.2 || ^4.0",
4342
"api-platform/doctrine-orm": "^3.2 || ^4.0"
4443
},
44+
"suggest": {
45+
"api-platform/validator": "To support validation."
46+
},
4547
"autoload": {
4648
"psr-4": {
4749
"ApiPlatform\\GraphQl\\": ""

‎src/Laravel/ApiPlatformProvider.php

+259-11
Large diffs are not rendered by default.

‎src/Laravel/Eloquent/Metadata/Factory/Resource/EloquentResourceCollectionMetadataFactory.php

+19-1
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ public function create(string $resourceClass): ResourceMetadataCollection
5050

5151
foreach ($resourceMetadataCollection as $i => $resourceMetadata) {
5252
$operations = $resourceMetadata->getOperations();
53-
foreach ($operations as $operationName => $operation) {
53+
foreach ($operations ?? [] as $operationName => $operation) {
5454
if (!$operation->getProvider()) {
5555
$operation = $operation->withProvider($operation instanceof CollectionOperationInterface ? CollectionProvider::class : ItemProvider::class);
5656
}
@@ -63,6 +63,24 @@ public function create(string $resourceClass): ResourceMetadataCollection
6363
}
6464

6565
$resourceMetadataCollection[$i] = $resourceMetadata->withOperations($operations);
66+
67+
$graphQlOperations = $resourceMetadata->getGraphQlOperations();
68+
69+
foreach ($graphQlOperations ?? [] as $operationName => $graphQlOperation) {
70+
if (!$graphQlOperation->getProvider()) {
71+
$graphQlOperation = $graphQlOperation->withProvider($graphQlOperation instanceof CollectionOperationInterface ? CollectionProvider::class : ItemProvider::class);
72+
}
73+
74+
if (!$graphQlOperation->getProcessor()) {
75+
$graphQlOperation = $graphQlOperation->withProcessor($graphQlOperation instanceof DeleteOperationInterface ? RemoveProcessor::class : PersistProcessor::class);
76+
}
77+
78+
$graphQlOperations[$operationName] = $graphQlOperation;
79+
}
80+
81+
$resourceMetadata = $resourceMetadata->withGraphQlOperations($graphQlOperations);
82+
83+
$resourceMetadataCollection[$i] = $resourceMetadata;
6684
}
6785

6886
return $resourceMetadataCollection;

‎src/Laravel/Eloquent/State/CollectionProvider.php

-6
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,6 @@
1515

1616
use ApiPlatform\Laravel\Eloquent\Extension\QueryExtensionInterface;
1717
use ApiPlatform\Laravel\Eloquent\Paginator;
18-
use ApiPlatform\Metadata\Exception\RuntimeException;
19-
use ApiPlatform\Metadata\HttpOperation;
2018
use ApiPlatform\Metadata\Operation;
2119
use ApiPlatform\State\Pagination\Pagination;
2220
use ApiPlatform\State\ProviderInterface;
@@ -46,10 +44,6 @@ public function __construct(
4644

4745
public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null
4846
{
49-
if (!$operation instanceof HttpOperation) {
50-
throw new RuntimeException('Not an HTTP operation.');
51-
}
52-
5347
/** @var Model $model */
5448
$model = new ($operation->getClass())();
5549

‎src/Laravel/Eloquent/State/LinksHandler.php

+18-13
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,10 @@
1313

1414
namespace ApiPlatform\Laravel\Eloquent\State;
1515

16+
use ApiPlatform\Metadata\HttpOperation;
17+
use Illuminate\Contracts\Foundation\Application;
1618
use Illuminate\Database\Eloquent\Builder;
1719
use Illuminate\Database\Eloquent\Model;
18-
use Illuminate\Foundation\Application;
1920

2021
/**
2122
* @implements LinksHandlerInterface<Model>
@@ -30,24 +31,28 @@ public function __construct(
3031
public function handleLinks(Builder $builder, array $uriVariables, array $context): Builder
3132
{
3233
$operation = $context['operation'];
33-
// $lastQuery = null;
34-
foreach (array_reverse($operation->getUriVariables() ?? []) as $uriVariable => $link) {
35-
$identifier = $uriVariables[$uriVariable];
3634

37-
if ($to = $link->getToProperty()) {
38-
$builder = $builder->where($builder->getModel()->getTable().'.'.$builder->getModel()->{$to}()->getForeignKeyName(), $identifier);
35+
if ($operation instanceof HttpOperation) {
36+
foreach (array_reverse($operation->getUriVariables() ?? []) as $uriVariable => $link) {
37+
$identifier = $uriVariables[$uriVariable];
3938

40-
continue;
41-
}
39+
if ($to = $link->getToProperty()) {
40+
$builder = $builder->where($builder->getModel()->getTable().'.'.$builder->getModel()->{$to}()->getForeignKeyName(), $identifier);
41+
42+
continue;
43+
}
44+
45+
if ($from = $link->getFromProperty()) {
46+
$relation = $this->application->make($link->getFromClass());
47+
$builder = $builder->getModel()->where($builder->getModel()->getTable().'.'.$relation->{$from}()->getForeignKeyName(), $identifier);
4248

43-
if ($from = $link->getFromProperty()) {
44-
$relation = $this->application->make($link->getFromClass());
45-
$builder = $builder->getModel()->where($builder->getModel()->getTable().'.'.$relation->{$from}()->getForeignKeyName(), $identifier);
49+
continue;
50+
}
4651

47-
continue;
52+
$builder->where($builder->getModel()->getTable().'.'.$link->getIdentifiers()[0], $identifier);
4853
}
4954

50-
$builder->where($builder->getModel()->getTable().'.'.$link->getIdentifiers()[0], $identifier);
55+
return $builder;
5156
}
5257

5358
return $builder;

‎src/Laravel/Exception/ErrorHandler.php

+7-2
Original file line numberDiff line numberDiff line change
@@ -40,13 +40,17 @@ class ErrorHandler extends ExceptionsHandler
4040

4141
public static mixed $error;
4242

43+
/**
44+
* @param array<class-string, int> $exceptionToStatus
45+
*/
4346
public function __construct(
4447
Container $container,
4548
ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory,
4649
private readonly ApiPlatformController $apiPlatformController,
4750
private readonly ?IdentifiersExtractorInterface $identifiersExtractor = null,
4851
private readonly ?ResourceClassResolverInterface $resourceClassResolver = null,
49-
?Negotiator $negotiator = null
52+
?Negotiator $negotiator = null,
53+
private readonly ?array $exceptionToStatus = null
5054
) {
5155
$this->resourceMetadataCollectionFactory = $resourceMetadataCollectionFactory;
5256
$this->negotiator = $negotiator;
@@ -160,7 +164,8 @@ private function getStatusCode(?HttpOperation $apiOperation, ?HttpOperation $err
160164
{
161165
$exceptionToStatus = array_merge(
162166
$apiOperation ? $apiOperation->getExceptionToStatus() ?? [] : [],
163-
$errorOperation ? $errorOperation->getExceptionToStatus() ?? [] : []
167+
$errorOperation ? $errorOperation->getExceptionToStatus() ?? [] : [],
168+
$this->exceptionToStatus ?? []
164169
);
165170

166171
foreach ($exceptionToStatus as $class => $status) {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Laravel\GraphQl\Controller;
15+
16+
use ApiPlatform\GraphQl\Error\ErrorHandlerInterface;
17+
use ApiPlatform\GraphQl\ExecutorInterface;
18+
use ApiPlatform\GraphQl\Type\SchemaBuilderInterface;
19+
use ApiPlatform\Metadata\Util\ContentNegotiationTrait;
20+
use GraphQL\Error\DebugFlag;
21+
use GraphQL\Error\Error;
22+
use GraphQL\Executor\ExecutionResult;
23+
use Illuminate\Http\Request;
24+
use Negotiation\Negotiator;
25+
use Symfony\Component\HttpFoundation\JsonResponse;
26+
use Symfony\Component\HttpFoundation\Response;
27+
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
28+
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
29+
30+
final class EntrypointController
31+
{
32+
use ContentNegotiationTrait;
33+
private int $debug;
34+
35+
public function __construct(
36+
private readonly SchemaBuilderInterface $schemaBuilder,
37+
private readonly ExecutorInterface $executor,
38+
private readonly GraphiQlController $graphiQlAction,
39+
private readonly NormalizerInterface $normalizer,
40+
private readonly ErrorHandlerInterface $errorHandler,
41+
bool $debug = false,
42+
?Negotiator $negotiator = null
43+
) {
44+
$this->debug = $debug ? DebugFlag::INCLUDE_DEBUG_MESSAGE | DebugFlag::INCLUDE_TRACE : DebugFlag::NONE;
45+
$this->negotiator = $negotiator ?? new Negotiator();
46+
}
47+
48+
public function __invoke(Request $request): Response
49+
{
50+
$formats = ['json' => ['application/json'], 'html' => ['text/html']];
51+
$format = $this->getRequestFormat($request, $formats, false);
52+
53+
try {
54+
if ($request->isMethod('GET') && 'html' === $format) {
55+
return ($this->graphiQlAction)();
56+
}
57+
58+
[$query, $operationName, $variables] = $this->parseRequest($request);
59+
if (null === $query) {
60+
throw new BadRequestHttpException('GraphQL query is not valid.');
61+
}
62+
63+
$executionResult = $this->executor
64+
->executeQuery($this->schemaBuilder->getSchema(), $query, null, null, $variables, $operationName)
65+
->setErrorsHandler($this->errorHandler)
66+
->setErrorFormatter($this->normalizer->normalize(...));
67+
} catch (\Exception $exception) {
68+
$executionResult = (new ExecutionResult(null, [new Error($exception->getMessage(), null, null, [], null, $exception)]))
69+
->setErrorsHandler($this->errorHandler)
70+
->setErrorFormatter($this->normalizer->normalize(...));
71+
}
72+
73+
return new JsonResponse($executionResult->toArray($this->debug));
74+
}
75+
76+
/**
77+
* @throws BadRequestHttpException
78+
*
79+
* @return array{0: array<string, mixed>|null, 1: string, 2: array<string, mixed>}
80+
*/
81+
private function parseRequest(Request $request): array
82+
{
83+
$queryParameters = $request->query->all();
84+
$query = $queryParameters['query'] ?? null;
85+
$operationName = $queryParameters['operationName'] ?? null;
86+
if ($variables = $queryParameters['variables'] ?? []) {
87+
$variables = $this->decodeVariables($variables);
88+
}
89+
90+
if (!$request->isMethod('POST')) {
91+
return [$query, $operationName, $variables];
92+
}
93+
94+
$contentType = method_exists(Request::class, 'getContentTypeFormat') ? $request->getContentTypeFormat() : $request->getContentType();
95+
if ('json' === $contentType) {
96+
return $this->parseData($query, $operationName, $variables, $request->getContent());
97+
}
98+
99+
if ('graphql' === $contentType) {
100+
$query = $request->getContent();
101+
}
102+
103+
if (\in_array($contentType, ['multipart', 'form'], true)) {
104+
return $this->parseMultipartRequest($query, $operationName, $variables, $request->request->all(), $request->files->all());
105+
}
106+
107+
return [$query, $operationName, $variables];
108+
}
109+
110+
/**
111+
* @param array<string,mixed> $variables
112+
*
113+
* @throws BadRequestHttpException
114+
*
115+
* @return array{0: array<string, mixed>, 1: string, 2: array<string, mixed>}
116+
*/
117+
private function parseData(?string $query, ?string $operationName, array $variables, string $jsonContent): array
118+
{
119+
if (!\is_array($data = json_decode($jsonContent, true, 512, \JSON_ERROR_NONE))) {
120+
throw new BadRequestHttpException('GraphQL data is not valid JSON.');
121+
}
122+
123+
if (isset($data['query'])) {
124+
$query = $data['query'];
125+
}
126+
127+
if (isset($data['variables'])) {
128+
$variables = \is_array($data['variables']) ? $data['variables'] : $this->decodeVariables($data['variables']);
129+
}
130+
131+
if (isset($data['operationName'])) {
132+
$operationName = $data['operationName'];
133+
}
134+
135+
return [$query, $operationName, $variables];
136+
}
137+
138+
/**
139+
* @param array<string,mixed> $variables
140+
* @param array<string,mixed> $bodyParameters
141+
* @param array<string,mixed> $files
142+
*
143+
* @throws BadRequestHttpException
144+
*
145+
* @return array{0: array<string, mixed>, 1: string, 2: array<string, mixed>}
146+
*/
147+
private function parseMultipartRequest(?string $query, ?string $operationName, array $variables, array $bodyParameters, array $files): array
148+
{
149+
if ((null === $operations = $bodyParameters['operations'] ?? null) || (null === $map = $bodyParameters['map'] ?? null)) {
150+
throw new BadRequestHttpException('GraphQL multipart request does not respect the specification.');
151+
}
152+
153+
[$query, $operationName, $variables] = $this->parseData($query, $operationName, $variables, $operations);
154+
155+
/** @var string $map */
156+
if (!\is_array($decodedMap = json_decode($map, true, 512, \JSON_ERROR_NONE))) {
157+
throw new BadRequestHttpException('GraphQL multipart request map is not valid JSON.');
158+
}
159+
160+
$variables = $this->applyMapToVariables($decodedMap, $variables, $files);
161+
162+
return [$query, $operationName, $variables];
163+
}
164+
165+
/**
166+
* @param array<string,mixed> $map
167+
* @param array<string,mixed> $variables
168+
* @param array<string,mixed> $files
169+
*
170+
* @throws BadRequestHttpException
171+
*/
172+
private function applyMapToVariables(array $map, array $variables, array $files): array
173+
{
174+
foreach ($map as $key => $value) {
175+
if (null === $file = $files[$key] ?? null) {
176+
throw new BadRequestHttpException('GraphQL multipart request file has not been sent correctly.');
177+
}
178+
179+
foreach ($value as $mapValue) {
180+
$path = explode('.', (string) $mapValue);
181+
182+
if ('variables' !== $path[0]) {
183+
throw new BadRequestHttpException('GraphQL multipart request path in map is invalid.');
184+
}
185+
186+
unset($path[0]);
187+
188+
$mapPathExistsInVariables = array_reduce($path, static fn (array $inVariables, string $pathElement) => \array_key_exists($pathElement, $inVariables) ? $inVariables[$pathElement] : false, $variables);
189+
190+
if (false === $mapPathExistsInVariables) {
191+
throw new BadRequestHttpException('GraphQL multipart request path in map does not match the variables.');
192+
}
193+
194+
$variableFileValue = &$variables;
195+
foreach ($path as $pathValue) {
196+
$variableFileValue = &$variableFileValue[$pathValue];
197+
}
198+
$variableFileValue = $file;
199+
}
200+
}
201+
202+
return $variables;
203+
}
204+
205+
/**
206+
* @throws BadRequestHttpException
207+
*
208+
* @return array<string, mixed>
209+
*/
210+
private function decodeVariables(string $variables): array
211+
{
212+
if (!\is_array($decoded = json_decode($variables, true, 512, \JSON_ERROR_NONE))) {
213+
throw new BadRequestHttpException('GraphQL variables are not valid JSON.');
214+
}
215+
216+
return $decoded;
217+
}
218+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Laravel\GraphQl\Controller;
15+
16+
use Illuminate\Http\Response;
17+
18+
readonly class GraphiQlController
19+
{
20+
public function __construct(private readonly string $prefix)
21+
{
22+
}
23+
24+
public function __invoke(): Response
25+
{
26+
return new Response(view('api-platform::graphiql', ['graphiql_data' => ['entrypoint' => $this->prefix.'/graphql']]), 200);
27+
}
28+
}

‎src/Laravel/State/ValidateProvider.php

+1-1
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
use ApiPlatform\Metadata\Error;
1818
use ApiPlatform\Metadata\Operation;
1919
use ApiPlatform\State\ProviderInterface;
20-
use Illuminate\Foundation\Application;
20+
use Illuminate\Contracts\Foundation\Application;
2121
use Illuminate\Foundation\Http\FormRequest;
2222
use Illuminate\Support\Facades\Validator;
2323
use Illuminate\Validation\ValidationException;

‎src/Laravel/composer.json

+3-1
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,8 @@
5555
"doctrine/dbal": "^4.0",
5656
"larastan/larastan": "^2.0",
5757
"orchestra/testbench": "^9.1",
58-
"phpunit/phpunit": "^11.2"
58+
"phpunit/phpunit": "^11.2",
59+
"api-platform/graphql": "^4.0"
5960
},
6061
"autoload": {
6162
"psr-4": {
@@ -70,6 +71,7 @@
7071
"sort-packages": true
7172
},
7273
"suggest": {
74+
"api-platform/graphql": "Enable GraphQl support.",
7375
"phpdocumentor/reflection-docblock": ""
7476
},
7577
"extra": {

‎src/Laravel/config/api-platform.php

+14
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
<?php
22

3+
use Illuminate\Auth\Access\AuthorizationException;
4+
use Illuminate\Auth\AuthenticationException;
5+
36
return [
47
'title' => 'API Platform',
58
'description' => 'My awesome API',
@@ -56,4 +59,15 @@
5659
'parameter_name' => 'order',
5760
],
5861
],
62+
63+
'graphql' => [
64+
'enabled' => true,
65+
'nesting_separator' => '__',
66+
'introspection' => ['enabled' => true]
67+
],
68+
69+
'exception_to_status' => [
70+
AuthenticationException::class => 401,
71+
AuthorizationException::class => 403
72+
]
5973
];
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head>
4+
<meta charset="UTF-8">
5+
<title>{{ config('api-platform.title') }} - API Platform</title>
6+
7+
<link rel="stylesheet" href="/vendor/api-platform/graphiql/graphiql.css">
8+
<link rel="stylesheet" href="/vendor/api-platform/graphiql-style.css">
9+
10+
<script id="graphiql-data" type="application/json">{!! Illuminate\Support\Js::encode($graphiql_data) !!}</script>
11+
</head>
12+
13+
<body>
14+
<div id="graphiql">Loading...</div>
15+
<script src="/vendor/api-platform/react/react.production.min.js"></script>
16+
<script src="/vendor/api-platform/react/react-dom.production.min.js"></script>
17+
<script src="/vendor/api-platform/graphiql/graphiql.min.js"></script>
18+
<script src="/vendor/api-platform/init-graphiql.js"></script>
19+
20+
</body>
21+
</html>

‎src/Metadata/Resource/Factory/AttributesResourceMetadataCollectionFactory.php

-1
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313

1414
namespace ApiPlatform\Metadata\Resource\Factory;
1515

16-
use ApiPlatform\Metadata\ApiResource;
1716
use ApiPlatform\Metadata\Exception\ResourceClassNotFoundException;
1817
use ApiPlatform\Metadata\Resource\ResourceMetadataCollection;
1918

‎src/State/Util/ParameterParserTrait.php

+2-4
Original file line numberDiff line numberDiff line change
@@ -51,10 +51,8 @@ private function extractParameterValues(Parameter $parameter, array $values): st
5151
$key = $parsedKey[0];
5252
} elseif (str_contains($key, '[')) {
5353
preg_match_all('/[^\[\]]+/', $key, $matches);
54-
if (isset($matches[0])) {
55-
$key = array_shift($matches[0]);
56-
$accessors = $matches[0];
57-
}
54+
$key = array_shift($matches[0]);
55+
$accessors = $matches[0];
5856
}
5957

6058
$value = $values[$key] ?? new ParameterNotFound();

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

+2-14
Original file line numberDiff line numberDiff line change
@@ -119,11 +119,7 @@
119119
<argument type="service" id="api_platform.graphql.types_container" />
120120
<argument type="service" id="api_platform.graphql.type_builder" />
121121
<argument type="service" id="api_platform.graphql.type_converter" />
122-
<!-- Note that this one is replaced by the below "api_platform.graphql.resolver.factory" we'll only use one in API Platform 4 -->
123-
<argument type="service" id="api_platform.graphql.resolver.factory.item" />
124-
<argument type="service" id="api_platform.graphql.resolver.factory.collection" on-invalid="null" />
125-
<argument type="service" id="api_platform.graphql.resolver.factory.item_mutation" on-invalid="null" />
126-
<argument type="service" id="api_platform.graphql.resolver.factory.item_subscription" on-invalid="null" />
122+
<argument type="service" id="api_platform.graphql.resolver.factory" />
127123
<argument type="service" id="api_platform.filter_locator" />
128124
<argument type="service" id="api_platform.pagination" />
129125
<argument type="service" id="api_platform.name_converter" on-invalid="ignore" />
@@ -188,20 +184,12 @@
188184
<argument type="service" id="api_platform.pagination" />
189185
</service>
190186

191-
<service id="api_platform.graphql.resolver.factory.item" class="ApiPlatform\GraphQl\Resolver\Factory\ResolverFactory" public="false">
187+
<service id="api_platform.graphql.resolver.factory" class="ApiPlatform\GraphQl\Resolver\Factory\ResolverFactory" public="false">
192188
<argument type="service" id="api_platform.graphql.state_provider" />
193189
<argument type="service" id="api_platform.graphql.state_processor" />
194190
</service>
195191

196192
<!-- Resolver Stages -->
197-
198-
<service id="api_platform.graphql.provider.read" class="ApiPlatform\GraphQl\Provider\ReadProvider" public="false">
199-
<argument type="service" id="api_platform.symfony.iri_converter" />
200-
<argument type="service" id="api_platform.state_provider" />
201-
<argument type="service" id="api_platform.graphql.serializer.context_builder" />
202-
<argument>%api_platform.graphql.nesting_separator%</argument>
203-
</service>
204-
205193
<service id="api_platform.graphql.resolver.resource_field" class="ApiPlatform\GraphQl\Resolver\ResourceFieldResolver" public="false">
206194
<argument type="service" id="api_platform.symfony.iri_converter" />
207195
</service>

0 commit comments

Comments
 (0)
Please sign in to comment.