From 538648840dc7439b12033ed6f413f3167705da4d Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Wed, 28 Aug 2024 11:10:23 +0200 Subject: [PATCH] feat(laravel): enable graphQl support (#6550) --- src/GraphQl/Tests/Type/FieldsBuilderTest.php | 20 +- src/GraphQl/Type/FieldsBuilder.php | 23 +- src/GraphQl/Type/TypeBuilder.php | 8 +- src/GraphQl/composer.json | 4 +- src/Laravel/ApiPlatformProvider.php | 270 +++++++++++++++++- ...quentResourceCollectionMetadataFactory.php | 20 +- .../Eloquent/State/CollectionProvider.php | 6 - src/Laravel/Eloquent/State/LinksHandler.php | 31 +- src/Laravel/Exception/ErrorHandler.php | 9 +- .../Controller/EntrypointController.php | 218 ++++++++++++++ .../GraphQl/Controller/GraphiQlController.php | 28 ++ src/Laravel/State/ValidateProvider.php | 2 +- src/Laravel/composer.json | 4 +- src/Laravel/config/api-platform.php | 14 + .../resources/views/graphiql.blade.php | 21 ++ ...butesResourceMetadataCollectionFactory.php | 1 - src/State/Util/ParameterParserTrait.php | 6 +- .../Bundle/Resources/config/graphql.xml | 16 +- 18 files changed, 614 insertions(+), 87 deletions(-) create mode 100644 src/Laravel/GraphQl/Controller/EntrypointController.php create mode 100644 src/Laravel/GraphQl/Controller/GraphiQlController.php create mode 100644 src/Laravel/resources/views/graphiql.blade.php diff --git a/src/GraphQl/Tests/Type/FieldsBuilderTest.php b/src/GraphQl/Tests/Type/FieldsBuilderTest.php index cea64b6b1f3..489f78086f2 100644 --- a/src/GraphQl/Tests/Type/FieldsBuilderTest.php +++ b/src/GraphQl/Tests/Type/FieldsBuilderTest.php @@ -63,9 +63,6 @@ class FieldsBuilderTest extends TestCase private ObjectProphecy $typeBuilderProphecy; private ObjectProphecy $typeConverterProphecy; private ObjectProphecy $itemResolverFactoryProphecy; - private ObjectProphecy $collectionResolverFactoryProphecy; - private ObjectProphecy $itemMutationResolverFactoryProphecy; - private ObjectProphecy $itemSubscriptionResolverFactoryProphecy; private ObjectProphecy $filterLocatorProphecy; private ObjectProphecy $resourceClassResolverProphecy; private FieldsBuilder $fieldsBuilder; @@ -82,9 +79,6 @@ protected function setUp(): void $this->typeBuilderProphecy = $this->prophesize(ContextAwareTypeBuilderInterface::class); $this->typeConverterProphecy = $this->prophesize(TypeConverterInterface::class); $this->itemResolverFactoryProphecy = $this->prophesize(ResolverFactoryInterface::class); - $this->collectionResolverFactoryProphecy = $this->prophesize(ResolverFactoryInterface::class); - $this->itemMutationResolverFactoryProphecy = $this->prophesize(ResolverFactoryInterface::class); - $this->itemSubscriptionResolverFactoryProphecy = $this->prophesize(ResolverFactoryInterface::class); $this->filterLocatorProphecy = $this->prophesize(ContainerInterface::class); $this->resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); $this->fieldsBuilder = $this->buildFieldsBuilder(); @@ -92,7 +86,7 @@ protected function setUp(): void private function buildFieldsBuilder(?AdvancedNameConverterInterface $advancedNameConverter = null): FieldsBuilder { - 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(), '__'); + 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(), '__'); } public function testGetNodeQueryFields(): void @@ -126,7 +120,7 @@ public function testGetItemQueryFields(string $resourceClass, Operation $operati $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); $this->typeConverterProphecy->resolveType(Argument::type('string'))->willReturn(GraphQLType::string()); $this->typeBuilderProphecy->isCollection(Argument::type(Type::class))->willReturn(false); - $this->itemResolverFactoryProphecy->__invoke($resourceClass, $resourceClass, $operation)->willReturn($resolver); + $this->itemResolverFactoryProphecy->__invoke($resourceClass, $resourceClass, $operation, Argument::any())->willReturn($resolver); $queryFields = $this->fieldsBuilder->getItemQueryFields($resourceClass, $operation, $configuration); @@ -206,7 +200,7 @@ public function testGetCollectionQueryFields(string $resourceClass, Operation $o $this->typeConverterProphecy->resolveType(Argument::type('string'))->willReturn(GraphQLType::string()); $this->typeBuilderProphecy->isCollection(Argument::type(Type::class))->willReturn(true); $this->typeBuilderProphecy->getPaginatedCollectionType($graphqlType, $operation)->willReturn($graphqlType); - $this->collectionResolverFactoryProphecy->__invoke($resourceClass, $resourceClass, $operation)->willReturn($resolver); + $this->itemResolverFactoryProphecy->__invoke($resourceClass, $resourceClass, $operation, Argument::any())->willReturn($resolver); $this->filterLocatorProphecy->has('my_filter')->willReturn(true); $filterProphecy = $this->prophesize(FilterInterface::class); $filterProphecy->getDescription($resourceClass)->willReturn([ @@ -356,7 +350,7 @@ public function testGetMutationFields(string $resourceClass, Operation $operatio $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); $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); $this->typeBuilderProphecy->isCollection(Argument::type(Type::class))->willReturn(false); - $this->itemMutationResolverFactoryProphecy->__invoke($resourceClass, $resourceClass, $operation)->willReturn($mutationResolver); + $this->itemResolverFactoryProphecy->__invoke($resourceClass, $resourceClass, $operation, Argument::any())->willReturn($mutationResolver); $mutationFields = $this->fieldsBuilder->getMutationFields($resourceClass, $operation); @@ -417,7 +411,7 @@ public function testGetSubscriptionFields(string $resourceClass, Operation $oper $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); $this->typeBuilderProphecy->isCollection(Argument::type(Type::class))->willReturn(false); $this->resourceMetadataCollectionFactoryProphecy->create($resourceClass)->willReturn(new ResourceMetadataCollection($resourceClass, [(new ApiResource())->withGraphQlOperations([$operation->getName() => $operation])])); - $this->itemSubscriptionResolverFactoryProphecy->__invoke($resourceClass, $resourceClass, $operation)->willReturn($subscriptionResolver); + $this->itemResolverFactoryProphecy->__invoke($resourceClass, $resourceClass, $operation, Argument::any())->willReturn($subscriptionResolver); $subscriptionFields = $this->fieldsBuilder->getSubscriptionFields($resourceClass, $operation); @@ -489,14 +483,14 @@ public function testGetResourceObjectTypeFields(string $resourceClass, Operation if ('propertyObject' === $propertyName) { $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' => []])); - $this->itemResolverFactoryProphecy->__invoke('objectClass', $resourceClass, $operation)->willReturn(static function (): void { + $this->itemResolverFactoryProphecy->__invoke('objectClass', $resourceClass, $operation, Argument::any())->willReturn(static function (): void { }); } if ('propertyNestedResource' === $propertyName) { $nestedResourceQueryOperation = new Query(); $this->resourceMetadataCollectionFactoryProphecy->create('nestedResourceClass')->willReturn(new ResourceMetadataCollection('nestedResourceClass', [(new ApiResource())->withGraphQlOperations(['item_query' => $nestedResourceQueryOperation])])); $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' => []])); - $this->itemResolverFactoryProphecy->__invoke('nestedResourceClass', $resourceClass, $nestedResourceQueryOperation)->willReturn(static function (): void { + $this->itemResolverFactoryProphecy->__invoke('nestedResourceClass', $resourceClass, $nestedResourceQueryOperation, Argument::any())->willReturn(static function (): void { }); } } diff --git a/src/GraphQl/Type/FieldsBuilder.php b/src/GraphQl/Type/FieldsBuilder.php index 3811e11e22d..56d1b7b206d 100644 --- a/src/GraphQl/Type/FieldsBuilder.php +++ b/src/GraphQl/Type/FieldsBuilder.php @@ -15,7 +15,6 @@ use ApiPlatform\Doctrine\Odm\State\Options as ODMOptions; use ApiPlatform\Doctrine\Orm\State\Options; -use ApiPlatform\GraphQl\Resolver\Factory\ResolverFactory; use ApiPlatform\GraphQl\Resolver\Factory\ResolverFactoryInterface; use ApiPlatform\GraphQl\Type\Definition\TypeInterface; use ApiPlatform\Metadata\GraphQl\Mutation; @@ -51,7 +50,7 @@ final class FieldsBuilder implements FieldsBuilderEnumInterface { private readonly ContextAwareTypeBuilderInterface $typeBuilder; - 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()) + 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()) { $this->typeBuilder = $typeBuilder; } @@ -66,7 +65,7 @@ public function getNodeQueryFields(): array 'args' => [ 'id' => ['type' => GraphQLType::nonNull(GraphQLType::id())], ], - 'resolve' => ($this->itemResolverFactory)(), + 'resolve' => ($this->resolverFactory)(), ]; } @@ -450,22 +449,10 @@ private function getResourceFieldConfiguration(?string $property, ?string $field $args = $this->getFilterArgs($args, $resourceClass, $rootResource, $resourceOperation, $rootOperation, $property, $depth); } - if ($this->itemResolverFactory instanceof ResolverFactory) { - if ($isStandardGraphqlType || $input) { - $resolve = null; - } else { - $resolve = ($this->itemResolverFactory)($resourceClass, $rootResource, $resourceOperation, $this->propertyMetadataFactory); - } + if ($isStandardGraphqlType || $input) { + $resolve = null; } else { - if ($isStandardGraphqlType || $input) { - $resolve = null; - } elseif (($rootOperation instanceof Mutation || $rootOperation instanceof Subscription) && $depth <= 0) { - $resolve = $rootOperation instanceof Mutation ? ($this->itemMutationResolverFactory)($resourceClass, $rootResource, $resourceOperation) : ($this->itemSubscriptionResolverFactory)($resourceClass, $rootResource, $resourceOperation); - } elseif ($this->typeBuilder->isCollection($type)) { - $resolve = ($this->collectionResolverFactory)($resourceClass, $rootResource, $resourceOperation); - } else { - $resolve = ($this->itemResolverFactory)($resourceClass, $rootResource, $resourceOperation); - } + $resolve = ($this->resolverFactory)($resourceClass, $rootResource, $resourceOperation, $this->propertyMetadataFactory); } return [ diff --git a/src/GraphQl/Type/TypeBuilder.php b/src/GraphQl/Type/TypeBuilder.php index 95feac2bb7b..0ffac895eeb 100644 --- a/src/GraphQl/Type/TypeBuilder.php +++ b/src/GraphQl/Type/TypeBuilder.php @@ -41,11 +41,17 @@ final class TypeBuilder implements ContextAwareTypeBuilderInterface { private $defaultFieldResolver; - public function __construct(private readonly TypesContainerInterface $typesContainer, callable $defaultFieldResolver, private readonly ContainerInterface $fieldsBuilderLocator, private readonly Pagination $pagination) + public function __construct(private readonly TypesContainerInterface $typesContainer, callable $defaultFieldResolver, private ?ContainerInterface $fieldsBuilderLocator, private readonly Pagination $pagination) { + $this->fieldsBuilderLocator = $fieldsBuilderLocator; $this->defaultFieldResolver = $defaultFieldResolver; } + public function setFieldsBuilderLocator(ContainerInterface $fieldsBuilderLocator): void + { + $this->fieldsBuilderLocator = $fieldsBuilderLocator; + } + /** * {@inheritdoc} */ diff --git a/src/GraphQl/composer.json b/src/GraphQl/composer.json index b774fc19aa5..6dd6513f8a7 100644 --- a/src/GraphQl/composer.json +++ b/src/GraphQl/composer.json @@ -24,7 +24,6 @@ "api-platform/metadata": "^3.2 || ^4.0", "api-platform/serializer": "^3.2 || ^4.0", "api-platform/state": "^3.2 || ^4.0", - "api-platform/validator": "^3.2 || ^4.0", "symfony/property-info": "^6.4 || ^7.1", "symfony/serializer": "^6.4 || ^7.1", "webonyx/graphql-php": "^14.0 || ^15.0", @@ -42,6 +41,9 @@ "api-platform/doctrine-odm": "^3.2 || ^4.0", "api-platform/doctrine-orm": "^3.2 || ^4.0" }, + "suggest": { + "api-platform/validator": "To support validation." + }, "autoload": { "psr-4": { "ApiPlatform\\GraphQl\\": "" diff --git a/src/Laravel/ApiPlatformProvider.php b/src/Laravel/ApiPlatformProvider.php index 9797de1f5fd..40de5b4642e 100644 --- a/src/Laravel/ApiPlatformProvider.php +++ b/src/Laravel/ApiPlatformProvider.php @@ -15,6 +15,36 @@ use ApiPlatform\Documentation\Action\DocumentationAction; use ApiPlatform\Documentation\Action\EntrypointAction; +use ApiPlatform\GraphQl\Error\ErrorHandler as GraphQlErrorHandler; +use ApiPlatform\GraphQl\Error\ErrorHandlerInterface; +use ApiPlatform\GraphQl\Executor; +use ApiPlatform\GraphQl\ExecutorInterface; +use ApiPlatform\GraphQl\Resolver\Factory\ResolverFactory; +use ApiPlatform\GraphQl\Resolver\Factory\ResolverFactoryInterface; +use ApiPlatform\GraphQl\Resolver\ResourceFieldResolver; +use ApiPlatform\GraphQl\Serializer\Exception\ErrorNormalizer as GraphQlErrorNormalizer; +use ApiPlatform\GraphQl\Serializer\Exception\HttpExceptionNormalizer as GraphQlHttpExceptionNormalizer; +use ApiPlatform\GraphQl\Serializer\Exception\RuntimeExceptionNormalizer as GraphQlRuntimeExceptionNormalizer; +use ApiPlatform\GraphQl\Serializer\Exception\ValidationExceptionNormalizer as GraphQlValidationExceptionNormalizer; +use ApiPlatform\GraphQl\Serializer\ItemNormalizer as GraphQlItemNormalizer; +use ApiPlatform\GraphQl\Serializer\ObjectNormalizer as GraphQlObjectNormalizer; +use ApiPlatform\GraphQl\Serializer\SerializerContextBuilder as GraphQlSerializerContextBuilder; +use ApiPlatform\GraphQl\State\Processor\NormalizeProcessor; +use ApiPlatform\GraphQl\State\Provider\DenormalizeProvider as GraphQlDenormalizeProvider; +use ApiPlatform\GraphQl\State\Provider\ReadProvider as GraphQlReadProvider; +use ApiPlatform\GraphQl\State\Provider\ResolverProvider; +use ApiPlatform\GraphQl\Type\ContextAwareTypeBuilderInterface; +use ApiPlatform\GraphQl\Type\FieldsBuilder; +use ApiPlatform\GraphQl\Type\FieldsBuilderEnumInterface; +use ApiPlatform\GraphQl\Type\SchemaBuilder; +use ApiPlatform\GraphQl\Type\SchemaBuilderInterface; +use ApiPlatform\GraphQl\Type\TypeBuilder; +use ApiPlatform\GraphQl\Type\TypeConverter; +use ApiPlatform\GraphQl\Type\TypeConverterInterface; +use ApiPlatform\GraphQl\Type\TypesContainer; +use ApiPlatform\GraphQl\Type\TypesContainerInterface; +use ApiPlatform\GraphQl\Type\TypesFactory; +use ApiPlatform\GraphQl\Type\TypesFactoryInterface; use ApiPlatform\Hydra\JsonSchema\SchemaFactory as HydraSchemaFactory; use ApiPlatform\Hydra\Serializer\CollectionNormalizer as HydraCollectionNormalizer; use ApiPlatform\Hydra\Serializer\DocumentationNormalizer as HydraDocumentationNormalizer; @@ -61,6 +91,8 @@ use ApiPlatform\Laravel\Eloquent\State\PersistProcessor; use ApiPlatform\Laravel\Eloquent\State\RemoveProcessor; use ApiPlatform\Laravel\Exception\ErrorHandler; +use ApiPlatform\Laravel\GraphQl\Controller\EntrypointController as GraphQlEntrypointController; +use ApiPlatform\Laravel\GraphQl\Controller\GraphiQlController; use ApiPlatform\Laravel\Metadata\ConcernsPropertyNameCollectionMetadataFactory; use ApiPlatform\Laravel\Metadata\ConcernsResourceMetadataCollectionFactory; use ApiPlatform\Laravel\Metadata\ConcernsResourceNameCollectionFactory; @@ -76,6 +108,7 @@ use ApiPlatform\Metadata\FilterInterface; use ApiPlatform\Metadata\IdentifiersExtractor; use ApiPlatform\Metadata\IdentifiersExtractorInterface; +use ApiPlatform\Metadata\InflectorInterface; use ApiPlatform\Metadata\IriConverterInterface; use ApiPlatform\Metadata\Operation\Factory\OperationMetadataFactory; use ApiPlatform\Metadata\Operation\Factory\OperationMetadataFactoryInterface; @@ -107,6 +140,7 @@ use ApiPlatform\Metadata\ResourceClassResolver; use ApiPlatform\Metadata\ResourceClassResolverInterface; use ApiPlatform\Metadata\UrlGeneratorInterface; +use ApiPlatform\Metadata\Util\Inflector; use ApiPlatform\OpenApi\Factory\OpenApiFactory; use ApiPlatform\OpenApi\Factory\OpenApiFactoryInterface; use ApiPlatform\OpenApi\Options; @@ -201,6 +235,10 @@ public function register(): void return new ClassMetadataFactory(new AttributeLoader()); }); + $this->app->singleton(SerializerClassMetadataFactory::class, function (Application $app) { + return new SerializerClassMetadataFactory($app->make(ClassMetadataFactoryInterface::class)); + }); + $this->app->bind(PathSegmentNameGeneratorInterface::class, UnderscorePathSegmentNameGenerator::class); $this->app->singleton(ResourceNameCollectionFactoryInterface::class, function () use ($config) { @@ -229,7 +267,7 @@ public function register(): void return new SchemaPropertyMetadataFactory( $app->make(ResourceClassResolverInterface::class), new SerializerPropertyMetadataFactory( - new SerializerClassMetadataFactory($app->make(ClassMetadataFactoryInterface::class)), + $app->make(SerializerClassMetadataFactory::class), new AttributePropertyMetadataFactory( new EloquentAttributePropertyMetadataFactory( $inner, @@ -285,13 +323,13 @@ public function register(): void [ 'routePrefix' => $config->get('api-platform.routes.prefix') ?? '/', ], - false, + $config->get('api-platform.graphql.enabled'), ), $app->make(LoggerInterface::class), [ 'routePrefix' => $config->get('api-platform.routes.prefix') ?? '/', ], - false, + $config->get('api-platform.graphql.enabled'), ) ) ) @@ -389,8 +427,9 @@ public function register(): void return new AccessCheckerProvider($app->make(ParameterProvider::class), $app->make(ResourceAccessCheckerInterface::class)); }); + $this->app->singleton(Negotiator::class, function (Application $app) { return new Negotiator(); }); $this->app->singleton(ContentNegotiationProvider::class, function (Application $app) use ($config) { - return new ContentNegotiationProvider($app->make(AccessCheckerProvider::class), new Negotiator(), $config->get('api-platform.formats'), $config->get('api-platform.error_formats')); + return new ContentNegotiationProvider($app->make(AccessCheckerProvider::class), $app->make(Negotiator::class), $config->get('api-platform.formats'), $config->get('api-platform.error_formats')); }); $this->app->bind(ProviderInterface::class, ContentNegotiationProvider::class); @@ -693,9 +732,51 @@ public function register(): void ); }); + if ($config->get('api-platform.graphql.enabled')) { + $this->app->singleton(GraphQlItemNormalizer::class, function (Application $app) { + return new GraphQlItemNormalizer( + $app->make(PropertyNameCollectionFactoryInterface::class), + $app->make(PropertyMetadataFactoryInterface::class), + $app->make(IriConverterInterface::class), + $app->make(IdentifiersExtractorInterface::class), + $app->make(ResourceClassResolverInterface::class), + $app->make(PropertyAccessorInterface::class), + $app->make(NameConverterInterface::class), + $app->make(SerializerClassMetadataFactory::class), + null, + $app->make(ResourceMetadataCollectionFactoryInterface::class), + $app->make(ResourceAccessCheckerInterface::class) + ); + }); + + $this->app->singleton(GraphQlObjectNormalizer::class, function (Application $app) { + return new GraphQlObjectNormalizer( + $app->make(ObjectNormalizer::class), + $app->make(IriConverterInterface::class), + $app->make(IdentifiersExtractorInterface::class), + ); + }); + } + + $this->app->singleton(GraphQlErrorNormalizer::class, function () { + return new GraphQlErrorNormalizer(); + }); + + $this->app->singleton(GraphQlValidationExceptionNormalizer::class, function () use ($config) { + return new GraphQlValidationExceptionNormalizer($config->get('api-platform.exception_to_status')); + }); + + $this->app->singleton(GraphQlHttpExceptionNormalizer::class, function () { + return new GraphQlHttpExceptionNormalizer(); + }); + + $this->app->singleton(GraphQlRuntimeExceptionNormalizer::class, function () { + return new GraphQlHttpExceptionNormalizer(); + }); + $this->app->bind(SerializerInterface::class, Serializer::class); $this->app->bind(NormalizerInterface::class, Serializer::class); - $this->app->singleton(Serializer::class, function (Application $app) { + $this->app->singleton(Serializer::class, function (Application $app) use ($config) { $list = new \SplPriorityQueue(); $list->insert($app->make(HydraEntrypointNormalizer::class), -800); $list->insert($app->make(HydraCollectionNormalizer::class), -800); @@ -714,6 +795,16 @@ public function register(): void $list->insert($app->make(JsonApiCollectionNormalizer::class), -985); $list->insert($app->make(JsonApiItemNormalizer::class), -890); $list->insert($app->make(JsonApiObjectNormalizer::class), -995); + + if ($config->get('api-platform.graphql.enabled')) { + $list->insert($app->make(GraphQlItemNormalizer::class), -890); + $list->insert($app->make(GraphQlObjectNormalizer::class), -995); + $list->insert($app->make(GraphQlErrorNormalizer::class), -790); + $list->insert($app->make(GraphQlValidationExceptionNormalizer::class), -780); + $list->insert($app->make(GraphQlHttpExceptionNormalizer::class), -780); + $list->insert($app->make(GraphQlRuntimeExceptionNormalizer::class), -780); + } + // TODO: unused + implement hal/jsonapi ? // $list->insert($dataUriNormalizer, -920); // $list->insert($unwrappingDenormalizer, 1000); @@ -745,22 +836,158 @@ public function register(): void $this->app->singleton( ExceptionHandlerInterface::class, - function (Application $app) { + function (Application $app) use ($config) { return new ErrorHandler( $app, $app->make(ResourceMetadataCollectionFactoryInterface::class), $app->make(ApiPlatformController::class), $app->make(IdentifiersExtractorInterface::class), $app->make(ResourceClassResolverInterface::class), - $app->make(Negotiator::class) + $app->make(Negotiator::class), + $config->get('api-platform.exception_to_status') ); } ); - // $this->app->afterResolving( - // \Illuminate\Foundation\Exceptions\Handler::class, - // fn ($handler) => $using(new Exceptions($handler)), - // ); + $this->app->singleton(InflectorInterface::class, function (Application $app) { + return new Inflector(); + }); + + if ($config->get('api-platform.graphql.enabled')) { + $this->registerGraphQl($this->app, $config); + } + } + + private function registerGraphQl(Application $app, Repository $config): void + { + $app->singleton('api_platform.graphql.type_locator', function (Application $app) { + $tagged = iterator_to_array($app->tagged('api_platform.graphql.type')); + + return new ServiceLocator($tagged); + }); + + $app->singleton(TypesFactoryInterface::class, function (Application $app) { + $tagged = iterator_to_array($app->tagged('api_platform.graphql.type')); + + return new TypesFactory($app->make('api_platform.graphql.type_locator'), array_keys($tagged)); + }); + $app->singleton(TypesContainerInterface::class, function () { + return new TypesContainer(); + }); + + $app->singleton(ResourceFieldResolver::class, function (Application $app) { + return new ResourceFieldResolver($app->make(IriConverterInterface::class)); + }); + + $app->singleton(ContextAwareTypeBuilderInterface::class, function (Application $app) { + return new TypeBuilder( + $app->make(TypesContainerInterface::class), + $app->make(ResourceFieldResolver::class), + null, + $app->make(Pagination::class) + ); + }); + + $app->singleton(TypeConverterInterface::class, function (Application $app) { + return new TypeConverter( + $app->make(ContextAwareTypeBuilderInterface::class), + $app->make(TypesContainerInterface::class), + $app->make(ResourceMetadataCollectionFactoryInterface::class), + $app->make(PropertyMetadataFactoryInterface::class), + ); + }); + + $app->singleton(GraphQlSerializerContextBuilder::class, function (Application $app) { + return new GraphQlSerializerContextBuilder($app->make(NameConverterInterface::class)); + }); + + $app->singleton('api_platform.graphql.state_provider', function (Application $app) use ($config) { + $tagged = iterator_to_array($app->tagged(ParameterProviderInterface::class)); + $resolvers = iterator_to_array($app->tagged('api_platform.graphql.resolver')); + + return new GraphQlReadProvider( + new GraphQlDenormalizeProvider( + new ResolverProvider( + new ParameterProvider( + $app->make(CallableProvider::class), + new ServiceLocator($tagged) + ), + new ServiceLocator($resolvers), + ), + $app->make(SerializerInterface::class), + $app->make(GraphQlSerializerContextBuilder::class) + ), + $app->make(IriConverterInterface::class), + $app->make(GraphQlSerializerContextBuilder::class), + $config->get('api-platform.graphql.nesting_separator') ?? '__' + ); + }); + + $app->singleton('api_platform.graphql.state_processor', function (Application $app) { + return new WriteProcessor( + new NormalizeProcessor( + $app->make(SerializerInterface::class), + $app->make(GraphQlSerializerContextBuilder::class), + $app->make(Pagination::class) + ), + $app->make(CallableProcessor::class), + ); + }); + + $app->singleton(ResolverFactoryInterface::class, function (Application $app) { + return new ResolverFactory( + $app->make('api_platform.graphql.state_provider'), + $app->make('api_platform.graphql.state_processor') + ); + }); + + $app->singleton(FieldsBuilderEnumInterface::class, function (Application $app) use ($config) { + return new FieldsBuilder( + $app->make(PropertyNameCollectionFactoryInterface::class), + $app->make(PropertyMetadataFactoryInterface::class), + $app->make(ResourceMetadataCollectionFactoryInterface::class), + $app->make(ResourceClassResolverInterface::class), + $app->make(TypesContainerInterface::class), + $app->make(ContextAwareTypeBuilderInterface::class), + $app->make(TypeConverterInterface::class), + $app->make(ResolverFactoryInterface::class), + $app->make(FilterInterface::class), + $app->make(Pagination::class), + $app->make(NameConverterInterface::class), + $config->get('api-platform.graphql.nesting_separator') ?? '__', + $app->make(InflectorInterface::class) + ); + }); + + $app->singleton(SchemaBuilderInterface::class, function (Application $app) { + return new SchemaBuilder($app->make(ResourceNameCollectionFactoryInterface::class), $app->make(ResourceMetadataCollectionFactoryInterface::class), $app->make(TypesFactoryInterface::class), $app->make(TypesContainerInterface::class), $app->make(FieldsBuilderEnumInterface::class)); + }); + + $app->singleton(ErrorHandlerInterface::class, function () { + return new GraphQlErrorHandler(); + }); + + $app->singleton(ExecutorInterface::class, function () use ($config) { + return new Executor($config->get('api-platform.graphql.introspection.enabled') ?? false); + }); + + $app->singleton(GraphiQlController::class, function () use ($config) { + $prefix = $config->get('api-platform.routes.prefix') ?? ''; + + return new GraphiQlController($prefix); + }); + + $app->singleton(GraphQlEntrypointController::class, function (Application $app) { + return new GraphQlEntrypointController( + $app->make(SchemaBuilderInterface::class), + $app->make(ExecutorInterface::class), + $app->make(GraphiQlController::class), + $app->make(SerializerInterface::class), + $app->make(ErrorHandlerInterface::class), + debug: true, + negotiator: $app->make(Negotiator::class) + ); + }); } /** @@ -780,6 +1007,10 @@ public function boot(ResourceNameCollectionFactoryInterface $resourceNameCollect $this->loadViewsFrom(__DIR__.'/resources/views', 'api-platform'); + $fieldsBuilder = $this->app->make(FieldsBuilderEnumInterface::class); + $typeBuilder = $this->app->make(ContextAwareTypeBuilderInterface::class); + $typeBuilder->setFieldsBuilderLocator(new ServiceLocator(['api_platform.graphql.fields_builder' => $fieldsBuilder])); + if (!$this->shouldRegisterRoutes()) { return; } @@ -831,6 +1062,23 @@ public function boot(ResourceNameCollectionFactoryInterface $resourceNameCollect }); $route->name('api_genid')->middleware(ApiPlatformMiddleware::class); $routeCollection->add($route); + + if ($config->get('api-platform.graphql.enabled')) { + $route = new Route(['POST', 'GET'], $prefix.'/graphql', function (Application $app, Request $request) { + $entrypointAction = $app->make(GraphQlEntrypointController::class); + + return $entrypointAction->__invoke($request); + }); + $routeCollection->add($route); + + $route = new Route(['GET'], $prefix.'/graphiql', function (Application $app) { + $controller = $app->make(GraphiQlController::class); + + return $controller->__invoke(); + }); + $routeCollection->add($route); + } + $router->setRoutes($routeCollection); } diff --git a/src/Laravel/Eloquent/Metadata/Factory/Resource/EloquentResourceCollectionMetadataFactory.php b/src/Laravel/Eloquent/Metadata/Factory/Resource/EloquentResourceCollectionMetadataFactory.php index 5517988146e..5a7ddb822bb 100644 --- a/src/Laravel/Eloquent/Metadata/Factory/Resource/EloquentResourceCollectionMetadataFactory.php +++ b/src/Laravel/Eloquent/Metadata/Factory/Resource/EloquentResourceCollectionMetadataFactory.php @@ -50,7 +50,7 @@ public function create(string $resourceClass): ResourceMetadataCollection foreach ($resourceMetadataCollection as $i => $resourceMetadata) { $operations = $resourceMetadata->getOperations(); - foreach ($operations as $operationName => $operation) { + foreach ($operations ?? [] as $operationName => $operation) { if (!$operation->getProvider()) { $operation = $operation->withProvider($operation instanceof CollectionOperationInterface ? CollectionProvider::class : ItemProvider::class); } @@ -63,6 +63,24 @@ public function create(string $resourceClass): ResourceMetadataCollection } $resourceMetadataCollection[$i] = $resourceMetadata->withOperations($operations); + + $graphQlOperations = $resourceMetadata->getGraphQlOperations(); + + foreach ($graphQlOperations ?? [] as $operationName => $graphQlOperation) { + if (!$graphQlOperation->getProvider()) { + $graphQlOperation = $graphQlOperation->withProvider($graphQlOperation instanceof CollectionOperationInterface ? CollectionProvider::class : ItemProvider::class); + } + + if (!$graphQlOperation->getProcessor()) { + $graphQlOperation = $graphQlOperation->withProcessor($graphQlOperation instanceof DeleteOperationInterface ? RemoveProcessor::class : PersistProcessor::class); + } + + $graphQlOperations[$operationName] = $graphQlOperation; + } + + $resourceMetadata = $resourceMetadata->withGraphQlOperations($graphQlOperations); + + $resourceMetadataCollection[$i] = $resourceMetadata; } return $resourceMetadataCollection; diff --git a/src/Laravel/Eloquent/State/CollectionProvider.php b/src/Laravel/Eloquent/State/CollectionProvider.php index 8b7807db3cc..d3bd0635c29 100644 --- a/src/Laravel/Eloquent/State/CollectionProvider.php +++ b/src/Laravel/Eloquent/State/CollectionProvider.php @@ -15,8 +15,6 @@ use ApiPlatform\Laravel\Eloquent\Extension\QueryExtensionInterface; use ApiPlatform\Laravel\Eloquent\Paginator; -use ApiPlatform\Metadata\Exception\RuntimeException; -use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\Operation; use ApiPlatform\State\Pagination\Pagination; use ApiPlatform\State\ProviderInterface; @@ -46,10 +44,6 @@ public function __construct( public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null { - if (!$operation instanceof HttpOperation) { - throw new RuntimeException('Not an HTTP operation.'); - } - /** @var Model $model */ $model = new ($operation->getClass())(); diff --git a/src/Laravel/Eloquent/State/LinksHandler.php b/src/Laravel/Eloquent/State/LinksHandler.php index 792f07523bf..c1038eb371b 100644 --- a/src/Laravel/Eloquent/State/LinksHandler.php +++ b/src/Laravel/Eloquent/State/LinksHandler.php @@ -13,9 +13,10 @@ namespace ApiPlatform\Laravel\Eloquent\State; +use ApiPlatform\Metadata\HttpOperation; +use Illuminate\Contracts\Foundation\Application; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; -use Illuminate\Foundation\Application; /** * @implements LinksHandlerInterface @@ -30,24 +31,28 @@ public function __construct( public function handleLinks(Builder $builder, array $uriVariables, array $context): Builder { $operation = $context['operation']; - // $lastQuery = null; - foreach (array_reverse($operation->getUriVariables() ?? []) as $uriVariable => $link) { - $identifier = $uriVariables[$uriVariable]; - if ($to = $link->getToProperty()) { - $builder = $builder->where($builder->getModel()->getTable().'.'.$builder->getModel()->{$to}()->getForeignKeyName(), $identifier); + if ($operation instanceof HttpOperation) { + foreach (array_reverse($operation->getUriVariables() ?? []) as $uriVariable => $link) { + $identifier = $uriVariables[$uriVariable]; - continue; - } + if ($to = $link->getToProperty()) { + $builder = $builder->where($builder->getModel()->getTable().'.'.$builder->getModel()->{$to}()->getForeignKeyName(), $identifier); + + continue; + } + + if ($from = $link->getFromProperty()) { + $relation = $this->application->make($link->getFromClass()); + $builder = $builder->getModel()->where($builder->getModel()->getTable().'.'.$relation->{$from}()->getForeignKeyName(), $identifier); - if ($from = $link->getFromProperty()) { - $relation = $this->application->make($link->getFromClass()); - $builder = $builder->getModel()->where($builder->getModel()->getTable().'.'.$relation->{$from}()->getForeignKeyName(), $identifier); + continue; + } - continue; + $builder->where($builder->getModel()->getTable().'.'.$link->getIdentifiers()[0], $identifier); } - $builder->where($builder->getModel()->getTable().'.'.$link->getIdentifiers()[0], $identifier); + return $builder; } return $builder; diff --git a/src/Laravel/Exception/ErrorHandler.php b/src/Laravel/Exception/ErrorHandler.php index 99b223e4d26..4a82cc4a3b6 100644 --- a/src/Laravel/Exception/ErrorHandler.php +++ b/src/Laravel/Exception/ErrorHandler.php @@ -40,13 +40,17 @@ class ErrorHandler extends ExceptionsHandler public static mixed $error; + /** + * @param array $exceptionToStatus + */ public function __construct( Container $container, ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, private readonly ApiPlatformController $apiPlatformController, private readonly ?IdentifiersExtractorInterface $identifiersExtractor = null, private readonly ?ResourceClassResolverInterface $resourceClassResolver = null, - ?Negotiator $negotiator = null + ?Negotiator $negotiator = null, + private readonly ?array $exceptionToStatus = null ) { $this->resourceMetadataCollectionFactory = $resourceMetadataCollectionFactory; $this->negotiator = $negotiator; @@ -160,7 +164,8 @@ private function getStatusCode(?HttpOperation $apiOperation, ?HttpOperation $err { $exceptionToStatus = array_merge( $apiOperation ? $apiOperation->getExceptionToStatus() ?? [] : [], - $errorOperation ? $errorOperation->getExceptionToStatus() ?? [] : [] + $errorOperation ? $errorOperation->getExceptionToStatus() ?? [] : [], + $this->exceptionToStatus ?? [] ); foreach ($exceptionToStatus as $class => $status) { diff --git a/src/Laravel/GraphQl/Controller/EntrypointController.php b/src/Laravel/GraphQl/Controller/EntrypointController.php new file mode 100644 index 00000000000..7e0367ff604 --- /dev/null +++ b/src/Laravel/GraphQl/Controller/EntrypointController.php @@ -0,0 +1,218 @@ + + * + * 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\Laravel\GraphQl\Controller; + +use ApiPlatform\GraphQl\Error\ErrorHandlerInterface; +use ApiPlatform\GraphQl\ExecutorInterface; +use ApiPlatform\GraphQl\Type\SchemaBuilderInterface; +use ApiPlatform\Metadata\Util\ContentNegotiationTrait; +use GraphQL\Error\DebugFlag; +use GraphQL\Error\Error; +use GraphQL\Executor\ExecutionResult; +use Illuminate\Http\Request; +use Negotiation\Negotiator; +use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; + +final class EntrypointController +{ + use ContentNegotiationTrait; + private int $debug; + + public function __construct( + private readonly SchemaBuilderInterface $schemaBuilder, + private readonly ExecutorInterface $executor, + private readonly GraphiQlController $graphiQlAction, + private readonly NormalizerInterface $normalizer, + private readonly ErrorHandlerInterface $errorHandler, + bool $debug = false, + ?Negotiator $negotiator = null + ) { + $this->debug = $debug ? DebugFlag::INCLUDE_DEBUG_MESSAGE | DebugFlag::INCLUDE_TRACE : DebugFlag::NONE; + $this->negotiator = $negotiator ?? new Negotiator(); + } + + public function __invoke(Request $request): Response + { + $formats = ['json' => ['application/json'], 'html' => ['text/html']]; + $format = $this->getRequestFormat($request, $formats, false); + + try { + if ($request->isMethod('GET') && 'html' === $format) { + return ($this->graphiQlAction)(); + } + + [$query, $operationName, $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, $operationName) + ->setErrorsHandler($this->errorHandler) + ->setErrorFormatter($this->normalizer->normalize(...)); + } catch (\Exception $exception) { + $executionResult = (new ExecutionResult(null, [new Error($exception->getMessage(), null, null, [], null, $exception)])) + ->setErrorsHandler($this->errorHandler) + ->setErrorFormatter($this->normalizer->normalize(...)); + } + + return new JsonResponse($executionResult->toArray($this->debug)); + } + + /** + * @throws BadRequestHttpException + * + * @return array{0: array|null, 1: string, 2: array} + */ + private function parseRequest(Request $request): array + { + $queryParameters = $request->query->all(); + $query = $queryParameters['query'] ?? null; + $operationName = $queryParameters['operationName'] ?? null; + if ($variables = $queryParameters['variables'] ?? []) { + $variables = $this->decodeVariables($variables); + } + + if (!$request->isMethod('POST')) { + return [$query, $operationName, $variables]; + } + + $contentType = method_exists(Request::class, 'getContentTypeFormat') ? $request->getContentTypeFormat() : $request->getContentType(); + if ('json' === $contentType) { + return $this->parseData($query, $operationName, $variables, $request->getContent()); + } + + if ('graphql' === $contentType) { + $query = $request->getContent(); + } + + if (\in_array($contentType, ['multipart', 'form'], true)) { + return $this->parseMultipartRequest($query, $operationName, $variables, $request->request->all(), $request->files->all()); + } + + return [$query, $operationName, $variables]; + } + + /** + * @param array $variables + * + * @throws BadRequestHttpException + * + * @return array{0: array, 1: string, 2: array} + */ + private function parseData(?string $query, ?string $operationName, array $variables, string $jsonContent): array + { + if (!\is_array($data = json_decode($jsonContent, true, 512, \JSON_ERROR_NONE))) { + throw new BadRequestHttpException('GraphQL data is not valid JSON.'); + } + + if (isset($data['query'])) { + $query = $data['query']; + } + + if (isset($data['variables'])) { + $variables = \is_array($data['variables']) ? $data['variables'] : $this->decodeVariables($data['variables']); + } + + if (isset($data['operationName'])) { + $operationName = $data['operationName']; + } + + return [$query, $operationName, $variables]; + } + + /** + * @param array $variables + * @param array $bodyParameters + * @param array $files + * + * @throws BadRequestHttpException + * + * @return array{0: array, 1: string, 2: array} + */ + private function parseMultipartRequest(?string $query, ?string $operationName, array $variables, array $bodyParameters, array $files): array + { + if ((null === $operations = $bodyParameters['operations'] ?? null) || (null === $map = $bodyParameters['map'] ?? null)) { + throw new BadRequestHttpException('GraphQL multipart request does not respect the specification.'); + } + + [$query, $operationName, $variables] = $this->parseData($query, $operationName, $variables, $operations); + + /** @var string $map */ + if (!\is_array($decodedMap = json_decode($map, true, 512, \JSON_ERROR_NONE))) { + throw new BadRequestHttpException('GraphQL multipart request map is not valid JSON.'); + } + + $variables = $this->applyMapToVariables($decodedMap, $variables, $files); + + return [$query, $operationName, $variables]; + } + + /** + * @param array $map + * @param array $variables + * @param array $files + * + * @throws BadRequestHttpException + */ + private function applyMapToVariables(array $map, array $variables, array $files): array + { + foreach ($map as $key => $value) { + if (null === $file = $files[$key] ?? null) { + throw new BadRequestHttpException('GraphQL multipart request file has not been sent correctly.'); + } + + foreach ($value as $mapValue) { + $path = explode('.', (string) $mapValue); + + if ('variables' !== $path[0]) { + throw new BadRequestHttpException('GraphQL multipart request path in map is invalid.'); + } + + unset($path[0]); + + $mapPathExistsInVariables = array_reduce($path, static fn (array $inVariables, string $pathElement) => \array_key_exists($pathElement, $inVariables) ? $inVariables[$pathElement] : false, $variables); + + if (false === $mapPathExistsInVariables) { + throw new BadRequestHttpException('GraphQL multipart request path in map does not match the variables.'); + } + + $variableFileValue = &$variables; + foreach ($path as $pathValue) { + $variableFileValue = &$variableFileValue[$pathValue]; + } + $variableFileValue = $file; + } + } + + return $variables; + } + + /** + * @throws BadRequestHttpException + * + * @return array + */ + private function decodeVariables(string $variables): array + { + if (!\is_array($decoded = json_decode($variables, true, 512, \JSON_ERROR_NONE))) { + throw new BadRequestHttpException('GraphQL variables are not valid JSON.'); + } + + return $decoded; + } +} diff --git a/src/Laravel/GraphQl/Controller/GraphiQlController.php b/src/Laravel/GraphQl/Controller/GraphiQlController.php new file mode 100644 index 00000000000..514d7a337e2 --- /dev/null +++ b/src/Laravel/GraphQl/Controller/GraphiQlController.php @@ -0,0 +1,28 @@ + + * + * 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\Laravel\GraphQl\Controller; + +use Illuminate\Http\Response; + +readonly class GraphiQlController +{ + public function __construct(private readonly string $prefix) + { + } + + public function __invoke(): Response + { + return new Response(view('api-platform::graphiql', ['graphiql_data' => ['entrypoint' => $this->prefix.'/graphql']]), 200); + } +} diff --git a/src/Laravel/State/ValidateProvider.php b/src/Laravel/State/ValidateProvider.php index 1978b7c8e44..14dbbe80100 100644 --- a/src/Laravel/State/ValidateProvider.php +++ b/src/Laravel/State/ValidateProvider.php @@ -17,7 +17,7 @@ use ApiPlatform\Metadata\Error; use ApiPlatform\Metadata\Operation; use ApiPlatform\State\ProviderInterface; -use Illuminate\Foundation\Application; +use Illuminate\Contracts\Foundation\Application; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Support\Facades\Validator; use Illuminate\Validation\ValidationException; diff --git a/src/Laravel/composer.json b/src/Laravel/composer.json index 392236ff5b2..f8693f681b8 100644 --- a/src/Laravel/composer.json +++ b/src/Laravel/composer.json @@ -55,7 +55,8 @@ "doctrine/dbal": "^4.0", "larastan/larastan": "^2.0", "orchestra/testbench": "^9.1", - "phpunit/phpunit": "^11.2" + "phpunit/phpunit": "^11.2", + "api-platform/graphql": "^4.0" }, "autoload": { "psr-4": { @@ -70,6 +71,7 @@ "sort-packages": true }, "suggest": { + "api-platform/graphql": "Enable GraphQl support.", "phpdocumentor/reflection-docblock": "" }, "extra": { diff --git a/src/Laravel/config/api-platform.php b/src/Laravel/config/api-platform.php index 779039c810a..57b36f16d03 100644 --- a/src/Laravel/config/api-platform.php +++ b/src/Laravel/config/api-platform.php @@ -1,5 +1,8 @@ 'API Platform', 'description' => 'My awesome API', @@ -56,4 +59,15 @@ 'parameter_name' => 'order', ], ], + + 'graphql' => [ + 'enabled' => true, + 'nesting_separator' => '__', + 'introspection' => ['enabled' => true] + ], + + 'exception_to_status' => [ + AuthenticationException::class => 401, + AuthorizationException::class => 403 + ] ]; diff --git a/src/Laravel/resources/views/graphiql.blade.php b/src/Laravel/resources/views/graphiql.blade.php new file mode 100644 index 00000000000..2621ff7b756 --- /dev/null +++ b/src/Laravel/resources/views/graphiql.blade.php @@ -0,0 +1,21 @@ + + + + + {{ config('api-platform.title') }} - API Platform + + + + + + + + +
Loading...
+ + + + + + + diff --git a/src/Metadata/Resource/Factory/AttributesResourceMetadataCollectionFactory.php b/src/Metadata/Resource/Factory/AttributesResourceMetadataCollectionFactory.php index 398d311b42b..8ab497dca59 100644 --- a/src/Metadata/Resource/Factory/AttributesResourceMetadataCollectionFactory.php +++ b/src/Metadata/Resource/Factory/AttributesResourceMetadataCollectionFactory.php @@ -13,7 +13,6 @@ namespace ApiPlatform\Metadata\Resource\Factory; -use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Exception\ResourceClassNotFoundException; use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; diff --git a/src/State/Util/ParameterParserTrait.php b/src/State/Util/ParameterParserTrait.php index c35f6db590c..6db86bfa463 100644 --- a/src/State/Util/ParameterParserTrait.php +++ b/src/State/Util/ParameterParserTrait.php @@ -51,10 +51,8 @@ private function extractParameterValues(Parameter $parameter, array $values): st $key = $parsedKey[0]; } elseif (str_contains($key, '[')) { preg_match_all('/[^\[\]]+/', $key, $matches); - if (isset($matches[0])) { - $key = array_shift($matches[0]); - $accessors = $matches[0]; - } + $key = array_shift($matches[0]); + $accessors = $matches[0]; } $value = $values[$key] ?? new ParameterNotFound(); diff --git a/src/Symfony/Bundle/Resources/config/graphql.xml b/src/Symfony/Bundle/Resources/config/graphql.xml index f532c6ab500..c58b39b1c23 100644 --- a/src/Symfony/Bundle/Resources/config/graphql.xml +++ b/src/Symfony/Bundle/Resources/config/graphql.xml @@ -119,11 +119,7 @@ - - - - - + @@ -188,20 +184,12 @@ - + - - - - - - %api_platform.graphql.nesting_separator% - -