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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/Doctrine/Common/State/LinksHandlerTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,13 @@ private function getLinks(string $resourceClass, Operation $operation, array $co
$links = $this->getOperationLinks($operation);

if (!($linkClass = $context['linkClass'] ?? false)) {
// Root item lookup: GraphQl Query.links carries relation links (for nested traversal)
// and the identifier-self link. Keep only the identifier-self / self-references so
// handleLinks applies WHERE id=X without consuming identifiers via relation joins.
if ($operation instanceof GraphQlOperation) {
return array_values(array_filter($links, static fn ($l) => null === $l->getToClass() || $l->getToClass() === $resourceClass));
}

return $links;
}

Expand Down
7 changes: 6 additions & 1 deletion src/GraphQl/State/Provider/ReadProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
use ApiPlatform\Metadata\Exception\ItemNotFoundException;
use ApiPlatform\Metadata\GraphQl\Mutation;
use ApiPlatform\Metadata\GraphQl\Operation as GraphQlOperation;
use ApiPlatform\Metadata\GraphQl\Query;
use ApiPlatform\Metadata\GraphQl\QueryCollection;
use ApiPlatform\Metadata\GraphQl\Subscription;
use ApiPlatform\Metadata\IriConverterInterface;
Expand Down Expand Up @@ -63,7 +64,11 @@ public function provide(Operation $operation, array $uriVariables = [], array $c
}

try {
$item = $this->iriConverter->getResourceFromIri($identifier, $context);
// For item Query carrying its own provider, dispatch through that provider.
// Mutation/Subscription keep the route-matched HTTP op so ReadProvider's class-mismatch
// diagnostics below stay accurate.
$dispatchOperation = ($operation instanceof Query && null !== $operation->getProvider()) ? $operation : null;
$item = $this->iriConverter->getResourceFromIri($identifier, $context, $dispatchOperation);
} catch (ItemNotFoundException) {
$item = null;
}
Expand Down
16 changes: 16 additions & 0 deletions src/Laravel/Eloquent/State/LinksHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,22 @@ public function handleLinks(Builder $builder, array $uriVariables, array $contex
}

if (!($linkClass = $context['linkClass'] ?? false)) {
// GraphQl root item: walk the identifier-self link to apply WHERE id=X.
// Mirror of Doctrine\Common\State\LinksHandlerTrait::getLinks root-mode filter.
$resourceClass = $builder->getModel()::class;
foreach ($operation->getLinks() ?? [] as $link) {
if ($link->getFromProperty() || $link->getToProperty()) {
continue;
}
if (null !== $link->getToClass() && $resourceClass !== $link->getToClass()) {
continue;
}
$parameterName = $link->getParameterName() ?? ($link->getIdentifiers()[0] ?? null);
if (null !== $parameterName && isset($uriVariables[$parameterName])) {
$builder = $this->buildQuery($builder, $link, $uriVariables[$parameterName]);
}
}

return $builder;
}

Expand Down
13 changes: 9 additions & 4 deletions src/Laravel/Routing/IriConverter.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
use ApiPlatform\Metadata\Exception\RuntimeException;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\GraphQl\Operation as GraphQlOperation;
use ApiPlatform\Metadata\HttpOperation;
use ApiPlatform\Metadata\IdentifiersExtractorInterface;
use ApiPlatform\Metadata\IriConverterInterface;
Expand Down Expand Up @@ -72,17 +73,21 @@ public function getResourceFromIri(string $iri, array $context = [], ?Operation
throw new InvalidArgumentException(\sprintf('No resource associated to "%s".', $iri));
}

$operation = $this->resourceMetadataCollectionFactory->create($parameters['_api_resource_class'])->getOperation($parameters['_api_operation_name']);
$routeOperation = $this->resourceMetadataCollectionFactory->create($parameters['_api_resource_class'])->getOperation($parameters['_api_operation_name']);

if ($operation instanceof CollectionOperationInterface) {
if ($routeOperation instanceof CollectionOperationInterface) {
throw new InvalidArgumentException(\sprintf('The iri "%s" references a collection not an item.', $iri));
}

if (!$operation instanceof HttpOperation) {
if (!$routeOperation instanceof HttpOperation) {
throw new RuntimeException(\sprintf('The iri "%s" does not reference an HTTP operation.', $iri));
}

if ($item = $this->provider->provide($operation, $parameters['uri_variables'], $context)) {
$dispatchOperation = ($operation instanceof GraphQlOperation && null !== $operation->getProvider())
? $operation
: $routeOperation;

if ($item = $this->provider->provide($dispatchOperation, $parameters['uri_variables'], $context)) {
return $item; // @phpstan-ignore-line
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

namespace ApiPlatform\Metadata\Resource\Factory;

use ApiPlatform\Metadata\CollectionOperationInterface;
use ApiPlatform\Metadata\Link;
use ApiPlatform\Metadata\Resource\ResourceMetadataCollection;

Expand Down Expand Up @@ -52,6 +53,13 @@ public function create(string $resourceClass): ResourceMetadataCollection
}
$links = $this->mergeLinks($relationLinks, $links);

// Item Query/Mutation/Subscription need the identifier-self link so Doctrine
// LinksHandler can apply `WHERE id = X` when dispatched through the GraphQl op
// (see Doctrine\Common\State\LinksHandlerTrait::getLinks root-mode filter).
if (!$graphQlOperation instanceof CollectionOperationInterface) {
$links = $this->mergeLinks($this->linkFactory->createLinksFromIdentifiers($graphQlOperation), $links);
}

$graphQlOperations[$graphQlOperation->getName()] = $graphQlOperation->withLinks($links);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ class: AttributeResource::class,
class: AttributeResource::class,
graphQlOperations: [
'item_query' => (new Query(shortName: 'AttributeResource', class: AttributeResource::class))->withLinks([
(new Link())->withFromClass(AttributeResource::class)->withIdentifiers(['id'])->withParameterName('id'),
(new Link())->withFromProperty('foo')->withFromClass(AttributeResource::class)->withToClass(Dummy::class)->withIdentifiers(['id']),
(new Link())->withFromProperty('foo2')->withFromClass(AttributeResource::class)->withToClass(Dummy::class)->withIdentifiers(['id']),
(new Link())->withFromProperty('bar')->withFromClass(AttributeResource::class)->withToClass(RelatedDummy::class)->withIdentifiers(['id']),
Expand Down Expand Up @@ -132,6 +133,7 @@ class: AttributeResource::class,
class: AttributeResource::class,
graphQlOperations: [
'item_query' => (new Query(shortName: 'AttributeResource', class: AttributeResource::class))->withLinks([
(new Link())->withFromClass(AttributeResource::class)->withIdentifiers(['identifier'])->withParameterName('identifier'),
(new Link())->withParameterName('dummyId')->withFromProperty('dummy')->withFromClass(AttributeResource::class)->withToClass(Dummy::class)->withIdentifiers(['identifier']),
]),
]
Expand Down
17 changes: 12 additions & 5 deletions src/Symfony/Routing/IriConverter.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
use ApiPlatform\Metadata\Exception\RuntimeException;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\GraphQl\Operation as GraphQlOperation;
use ApiPlatform\Metadata\HttpOperation;
use ApiPlatform\Metadata\IdentifiersExtractorInterface;
use ApiPlatform\Metadata\IriConverterInterface;
Expand Down Expand Up @@ -87,24 +88,30 @@ public function getResourceFromIri(string $iri, array $context = [], ?Operation
throw new InvalidArgumentException(\sprintf('The iri "%s" does not reference the correct resource.', $iri));
}

$operation = $parameters['_api_operation'] = $this->resourceMetadataCollectionFactory->create($parameters['_api_resource_class'])->getOperation($parameters['_api_operation_name']);
$routeOperation = $parameters['_api_operation'] = $this->resourceMetadataCollectionFactory->create($parameters['_api_resource_class'])->getOperation($parameters['_api_operation_name']);

if ($operation instanceof CollectionOperationInterface) {
if ($routeOperation instanceof CollectionOperationInterface) {
throw new InvalidArgumentException(\sprintf('The iri "%s" references a collection not an item.', $iri));
}

if (!$operation instanceof HttpOperation) {
if (!$routeOperation instanceof HttpOperation) {
throw new RuntimeException(\sprintf('The iri "%s" does not reference an HTTP operation.', $iri));
}
$attributes = AttributesExtractor::extractAttributes($parameters);

try {
$uriVariables = $this->getOperationUriVariables($operation, $parameters, $attributes['resource_class']);
$uriVariables = $this->getOperationUriVariables($routeOperation, $parameters, $attributes['resource_class']);
} catch (InvalidIdentifierException|InvalidUriVariableException $e) {
throw new InvalidArgumentException($e->getMessage(), $e->getCode(), $e);
}

if ($item = $this->provider->provide($operation, $uriVariables, $context)) {
// If a caller-provided GraphQl operation carries its own provider, dispatch through it
// so the user-defined Query(provider: X) wins over the route-matched HTTP operation.
$dispatchOperation = ($operation instanceof GraphQlOperation && null !== $operation->getProvider())
? $operation
: $routeOperation;

if ($item = $this->provider->provide($dispatchOperation, $uriVariables, $context)) {
return $item;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* 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\Tests\Fixtures\TestBundle\ApiResource\GraphQlCustomQueryProvider;

use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\GraphQl\Query;
use ApiPlatform\Metadata\Operation;
use Symfony\Component\Serializer\Attribute\Groups;

#[ApiResource(
operations: [],
graphQlOperations: [
new Query(provider: [Account::class, 'provide']),
],
normalizationContext: ['groups' => ['account:read']],
)]
final class Account
{
public function __construct(
#[ApiProperty(identifier: true)]
#[Groups(['account:read'])]
public string $id = '1',
/**
* @var list<array{key: string}>
*/
#[Groups(['account:read'])]
public array $credentials = [],
) {
}

public static function provide(Operation $operation, array $uriVariables = [], array $context = []): self
{
return new self(id: (string) ($uriVariables['id'] ?? '1'), credentials: [['key' => 'static-value']]);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* 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\Tests\Fixtures\TestBundle\ApiResource\GraphQlCustomQueryProvider;

use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GraphQl\Query;
use ApiPlatform\Metadata\Operation;
use Symfony\Component\Serializer\Attribute\Groups;

#[ApiResource(
operations: [
new Get(provider: [AccountWithGet::class, 'provideForGet']),
],
graphQlOperations: [
new Query(provider: [AccountWithGet::class, 'provideForQuery']),
],
normalizationContext: ['groups' => ['account_with_get:read']],
)]
final class AccountWithGet
{
public function __construct(
#[ApiProperty(identifier: true)]
#[Groups(['account_with_get:read'])]
public string $id = '1',
#[Groups(['account_with_get:read'])]
public string $source = '',
) {
}

public static function provideForGet(Operation $operation, array $uriVariables = [], array $context = []): self
{
return new self(id: (string) ($uriVariables['id'] ?? '1'), source: 'http-get');
}

public static function provideForQuery(Operation $operation, array $uriVariables = [], array $context = []): self
{
return new self(id: (string) ($uriVariables['id'] ?? '1'), source: 'graphql-query');
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
new Get(provider: Availability::class.'::getCase'),
],
graphQlOperations: [
new Query(provider: Availability::class.'getCase'),
new Query(provider: Availability::class.'::getCase'),
]
)]
enum Availability: int
Expand Down
88 changes: 88 additions & 0 deletions tests/Functional/GraphQl/GraphQlCustomQueryProviderTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* 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\Tests\Functional\GraphQl;

use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\GraphQlCustomQueryProvider\Account;
use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\GraphQlCustomQueryProvider\AccountWithGet;
use ApiPlatform\Tests\SetupClassResourcesTrait;

/**
* A resource declaring a GraphQl Query with a custom `provider:` must invoke that
* provider on the root item query — independently of any HTTP item operation.
*
* @see https://github.com/api-platform/core/issues/5805
*/
final class GraphQlCustomQueryProviderTest extends ApiTestCase
{
use SetupClassResourcesTrait;

protected static ?bool $alwaysBootKernel = false;

/**
* @return class-string[]
*/
public static function getResources(): array
{
return [Account::class, AccountWithGet::class];
}

public function testGraphQlQueryUsesCustomProviderWhenNoHttpGetIsDeclared(): void
{
$response = self::createClient()->request('POST', '/graphql', ['json' => [
'query' => <<<'GRAPHQL'
{
account(id: "/accounts/1") {
id
credentials
}
}
GRAPHQL,
]]);

$this->assertResponseIsSuccessful();
$json = $response->toArray(false);
$this->assertArrayNotHasKey('errors', $json, json_encode($json['errors'] ?? null));
$this->assertSame('/accounts/1', $json['data']['account']['id']);
$this->assertSame([['key' => 'static-value']], $json['data']['account']['credentials']);
}

public function testGraphQlQueryUsesItsOwnProviderWhenHttpGetHasDifferentOne(): void
{
$response = self::createClient()->request('POST', '/graphql', ['json' => [
'query' => <<<'GRAPHQL'
{
accountWithGet(id: "/account_with_gets/1") {
id
source
}
}
GRAPHQL,
]]);

$this->assertResponseIsSuccessful();
$json = $response->toArray(false);
$this->assertArrayNotHasKey('errors', $json, json_encode($json['errors'] ?? null));
$this->assertSame('/account_with_gets/1', $json['data']['accountWithGet']['id']);
$this->assertSame('graphql-query', $json['data']['accountWithGet']['source']);
}

public function testHttpGetStillUsesItsOwnProvider(): void
{
$response = self::createClient()->request('GET', '/account_with_gets/1');

$this->assertResponseIsSuccessful();
$this->assertJsonContains(['source' => 'http-get']);
}
}
Loading