diff --git a/src/GraphQl/Serializer/ItemNormalizer.php b/src/GraphQl/Serializer/ItemNormalizer.php index f3685aeed6..fddc30a711 100644 --- a/src/GraphQl/Serializer/ItemNormalizer.php +++ b/src/GraphQl/Serializer/ItemNormalizer.php @@ -88,6 +88,17 @@ public function normalize(mixed $data, ?string $format = null, array $context = unset($context['operation_name'], $context['operation']); // Remove operation and operation_name only when cache key has been created $normalizedData = parent::normalize($data, $format, $context); + + // The default circular_reference_handler returns the visited object's IRI + // as a string; return an identifier-only node so the GraphQL field shape + // is preserved instead of crashing. + if (\is_string($normalizedData)) { + return [ + self::ITEM_RESOURCE_CLASS_KEY => $resourceClass, + self::ITEM_IDENTIFIERS_KEY => $this->identifiersExtractor->getIdentifiersFromItem($data), + ]; + } + if (!\is_array($normalizedData)) { throw new UnexpectedValueException('Expected data to be an array.'); } diff --git a/src/GraphQl/Serializer/ObjectNormalizer.php b/src/GraphQl/Serializer/ObjectNormalizer.php index 08525475f5..f87768afb5 100644 --- a/src/GraphQl/Serializer/ObjectNormalizer.php +++ b/src/GraphQl/Serializer/ObjectNormalizer.php @@ -64,6 +64,17 @@ public function normalize(mixed $data, ?string $format = null, array $context = } $normalizedData = $this->decorated->normalize($data, $format, $context); + + // The default circular_reference_handler returns the visited object's IRI + // as a string; return an identifier-only node so the GraphQL field shape + // is preserved instead of crashing. + if (\is_string($normalizedData) && isset($originalResource)) { + return [ + self::ITEM_RESOURCE_CLASS_KEY => $this->getObjectClass($originalResource), + self::ITEM_IDENTIFIERS_KEY => $this->identifiersExtractor->getIdentifiersFromItem($originalResource), + ]; + } + if (!\is_array($normalizedData)) { throw new UnexpectedValueException('Expected data to be an array.'); } diff --git a/tests/Fixtures/TestBundle/Entity/GraphQlCircularReference/CircularReferenceAddress.php b/tests/Fixtures/TestBundle/Entity/GraphQlCircularReference/CircularReferenceAddress.php new file mode 100644 index 0000000000..e512dd6de7 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/GraphQlCircularReference/CircularReferenceAddress.php @@ -0,0 +1,34 @@ + + * + * 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\Entity\GraphQlCircularReference; + +use ApiPlatform\Metadata\ApiResource; +use Doctrine\ORM\Mapping as ORM; + +#[ApiResource] +#[ORM\Entity] +class CircularReferenceAddress +{ + #[ORM\Id] + #[ORM\Column(type: 'integer')] + #[ORM\GeneratedValue(strategy: 'AUTO')] + public ?int $id = null; + + #[ORM\Column(type: 'string')] + public ?string $street = null; + + #[ORM\ManyToOne(targetEntity: CircularReferenceCustomer::class, inversedBy: 'addresses')] + #[ORM\JoinColumn(nullable: false)] + public ?CircularReferenceCustomer $owner = null; +} diff --git a/tests/Fixtures/TestBundle/Entity/GraphQlCircularReference/CircularReferenceCustomer.php b/tests/Fixtures/TestBundle/Entity/GraphQlCircularReference/CircularReferenceCustomer.php new file mode 100644 index 0000000000..830ad897b1 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/GraphQlCircularReference/CircularReferenceCustomer.php @@ -0,0 +1,44 @@ + + * + * 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\Entity\GraphQlCircularReference; + +use ApiPlatform\Metadata\ApiResource; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; +use Doctrine\ORM\Mapping as ORM; + +#[ApiResource] +#[ORM\Entity] +class CircularReferenceCustomer +{ + #[ORM\Id] + #[ORM\Column(type: 'integer')] + #[ORM\GeneratedValue(strategy: 'AUTO')] + public ?int $id = null; + + #[ORM\Column(type: 'string')] + public ?string $name = null; + + #[ORM\OneToMany(targetEntity: CircularReferenceAddress::class, mappedBy: 'owner', cascade: ['persist'])] + public Collection $addresses; + + #[ORM\ManyToOne(targetEntity: CircularReferenceAddress::class)] + #[ORM\JoinColumn(nullable: true)] + public ?CircularReferenceAddress $invoiceAddress = null; + + public function __construct() + { + $this->addresses = new ArrayCollection(); + } +} diff --git a/tests/Functional/GraphQl/CircularReferenceNormalizationTest.php b/tests/Functional/GraphQl/CircularReferenceNormalizationTest.php new file mode 100644 index 0000000000..5bae0e8468 --- /dev/null +++ b/tests/Functional/GraphQl/CircularReferenceNormalizationTest.php @@ -0,0 +1,92 @@ + + * + * 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\Entity\GraphQlCircularReference\CircularReferenceAddress; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\GraphQlCircularReference\CircularReferenceCustomer; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +/** + * Two ApiResource entities with mutual ManyToOne/OneToMany references must not crash + * the GraphQL ItemNormalizer when the traversal hits a circular reference. The default + * circular_reference_handler returns an IRI string; the GraphQL normalizer used to + * assert the result was an array and threw "Expected data to be an array.". + * + * @see https://github.com/api-platform/core/issues/8080 + */ +final class CircularReferenceNormalizationTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [CircularReferenceCustomer::class, CircularReferenceAddress::class]; + } + + public function testCircularReferenceTraversalDoesNotCrash(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('CircularReference entities are ORM-only.'); + } + + $this->recreateSchema([CircularReferenceCustomer::class, CircularReferenceAddress::class]); + + $manager = $this->getManager(); + $customer = new CircularReferenceCustomer(); + $customer->name = 'Acme'; + + $address = new CircularReferenceAddress(); + $address->street = '1 Test Street'; + $address->owner = $customer; + $customer->addresses->add($address); + $customer->invoiceAddress = $address; + + $manager->persist($customer); + $manager->persist($address); + $manager->flush(); + + $iri = '/circular_reference_addresses/'.$address->id; + + $response = self::createClient()->request('POST', '/graphql', ['json' => [ + 'query' => <<assertResponseIsSuccessful(); + $json = $response->toArray(false); + // Pre-fix the inner normalization triggered "Expected data to be an array." + // because the circular_reference_handler returned the IRI string. + $this->assertArrayNotHasKey('errors', $json, json_encode($json['errors'] ?? null)); + $this->assertSame('1 Test Street', $json['data']['circularReferenceAddress']['street']); + $this->assertSame('Acme', $json['data']['circularReferenceAddress']['owner']['name']); + } +}