diff --git a/src/Hal/Serializer/ItemNormalizer.php b/src/Hal/Serializer/ItemNormalizer.php index f128e15090..2474880417 100644 --- a/src/Hal/Serializer/ItemNormalizer.php +++ b/src/Hal/Serializer/ItemNormalizer.php @@ -13,14 +13,26 @@ namespace ApiPlatform\Hal\Serializer; +use ApiPlatform\Metadata\IriConverterInterface; +use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; +use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\ResourceAccessCheckerInterface; +use ApiPlatform\Metadata\ResourceClassResolverInterface; use ApiPlatform\Metadata\UrlGeneratorInterface; use ApiPlatform\Metadata\Util\ClassInfoTrait; use ApiPlatform\Serializer\AbstractItemNormalizer; use ApiPlatform\Serializer\CacheKeyTrait; use ApiPlatform\Serializer\ContextTrait; +use ApiPlatform\Serializer\TagCollectorInterface; +use Symfony\Component\PropertyAccess\PropertyAccessorInterface; +use Symfony\Component\Serializer\Exception\CircularReferenceException; use Symfony\Component\Serializer\Exception\LogicException; use Symfony\Component\Serializer\Exception\UnexpectedValueException; use Symfony\Component\Serializer\Mapping\AttributeMetadataInterface; +use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface; +use Symfony\Component\Serializer\NameConverter\NameConverterInterface; +use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; /** * Converts between objects and array including HAL metadata. @@ -35,9 +47,25 @@ final class ItemNormalizer extends AbstractItemNormalizer public const FORMAT = 'jsonhal'; + protected const HAL_CIRCULAR_REFERENCE_LIMIT_COUNTERS = 'hal_circular_reference_limit_counters'; + private array $componentsCache = []; private array $attributesMetadataCache = []; + public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface $iriConverter, ResourceClassResolverInterface $resourceClassResolver, ?PropertyAccessorInterface $propertyAccessor = null, ?NameConverterInterface $nameConverter = null, ?ClassMetadataFactoryInterface $classMetadataFactory = null, array $defaultContext = [], ?ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory = null, ?ResourceAccessCheckerInterface $resourceAccessChecker = null, ?TagCollectorInterface $tagCollector = null) + { + $defaultContext[AbstractNormalizer::CIRCULAR_REFERENCE_HANDLER] = function ($object): ?array { + $iri = $this->iriConverter->getIriFromResource($object); + if (null === $iri) { + return null; + } + + return ['_links' => ['self' => ['href' => $iri]]]; + }; + + parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, $classMetadataFactory, $defaultContext, $resourceMetadataCollectionFactory, $resourceAccessChecker, $tagCollector); + } + /** * {@inheritdoc} */ @@ -216,6 +244,10 @@ private function populateRelation(array $data, object $object, ?string $format, { $class = $this->getObjectClass($object); + if ($this->isHalCircularReference($object, $context)) { + return $this->handleHalCircularReference($object, $format, $context); + } + $attributesMetadata = \array_key_exists($class, $this->attributesMetadataCache) ? $this->attributesMetadataCache[$class] : $this->attributesMetadataCache[$class] = $this->classMetadataFactory ? $this->classMetadataFactory->getMetadataFor($class)->getAttributesMetadata() : null; @@ -319,4 +351,49 @@ private function isMaxDepthReached(array $attributesMetadata, string $class, str return false; } + + /** + * Detects if the configured circular reference limit is reached. + * + * @throws CircularReferenceException + */ + protected function isHalCircularReference(object $object, array &$context): bool + { + $objectHash = spl_object_hash($object); + + $circularReferenceLimit = $context[AbstractNormalizer::CIRCULAR_REFERENCE_LIMIT] ?? $this->defaultContext[AbstractNormalizer::CIRCULAR_REFERENCE_LIMIT]; + if (isset($context[self::HAL_CIRCULAR_REFERENCE_LIMIT_COUNTERS][$objectHash])) { + if ($context[self::HAL_CIRCULAR_REFERENCE_LIMIT_COUNTERS][$objectHash] >= $circularReferenceLimit) { + unset($context[self::HAL_CIRCULAR_REFERENCE_LIMIT_COUNTERS][$objectHash]); + + return true; + } + + ++$context[self::HAL_CIRCULAR_REFERENCE_LIMIT_COUNTERS][$objectHash]; + } else { + $context[self::HAL_CIRCULAR_REFERENCE_LIMIT_COUNTERS][$objectHash] = 1; + } + + return false; + } + + /** + * Handles a circular reference. + * + * If a circular reference handler is set, it will be called. Otherwise, a + * {@class CircularReferenceException} will be thrown. + * + * @final + * + * @throws CircularReferenceException + */ + protected function handleHalCircularReference(object $object, ?string $format = null, array $context = []): mixed + { + $circularReferenceHandler = $context[AbstractNormalizer::CIRCULAR_REFERENCE_HANDLER] ?? $this->defaultContext[AbstractNormalizer::CIRCULAR_REFERENCE_HANDLER]; + if ($circularReferenceHandler) { + return $circularReferenceHandler($object, $format, $context); + } + + throw new CircularReferenceException(\sprintf('A circular reference has been detected when serializing the object of class "%s" (configured limit: %d).', get_debug_type($object), $context[AbstractNormalizer::CIRCULAR_REFERENCE_LIMIT] ?? $this->defaultContext[AbstractNormalizer::CIRCULAR_REFERENCE_LIMIT])); + } } diff --git a/tests/Fixtures/TestBundle/ApiResource/Issue4358/ResourceA.php b/tests/Fixtures/TestBundle/ApiResource/Issue4358/ResourceA.php index 41568c5c4c..a86482bc2e 100644 --- a/tests/Fixtures/TestBundle/ApiResource/Issue4358/ResourceA.php +++ b/tests/Fixtures/TestBundle/ApiResource/Issue4358/ResourceA.php @@ -1,7 +1,17 @@ + * + * 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\Issue4358; use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\Get; @@ -19,33 +29,36 @@ final class ResourceA #[ApiProperty(readableLink: true)] #[Groups(['ResourceA:read', 'ResourceB:read'])] - #[MaxDepth(3)] + #[MaxDepth(6)] public ResourceB $b; + public function __construct(?ResourceB $b = null) { - if ($b !== null) { + if (null !== $b) { $this->b = $b; } } - public static function provide(): ResourceA + public static function provide(): self { return self::provideWithResource(); } - public static function provideWithResource(?ResourceB $b = null): ResourceA { - if(!isset(self::$resourceA)) { - self::$resourceA = new ResourceA($b); + public static function provideWithResource(?ResourceB $b = null): self + { + if (!isset(self::$resourceA)) { + self::$resourceA = new self($b); - if(ResourceB::getInstance() === null) { + if (null === ResourceB::getInstance()) { self::$resourceA->b = ResourceB::provideWithResource(self::$resourceA); } } + return self::$resourceA; } - public static function getInstance(): ?ResourceA { + public static function getInstance(): ?self + { return self::$resourceA; } - } diff --git a/tests/Fixtures/TestBundle/ApiResource/Issue4358/ResourceB.php b/tests/Fixtures/TestBundle/ApiResource/Issue4358/ResourceB.php index d1cce6fe7b..cd5ba29d3c 100644 --- a/tests/Fixtures/TestBundle/ApiResource/Issue4358/ResourceB.php +++ b/tests/Fixtures/TestBundle/ApiResource/Issue4358/ResourceB.php @@ -1,5 +1,16 @@ + * + * 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\Issue4358; use ApiPlatform\Metadata\ApiProperty; @@ -18,36 +29,36 @@ final class ResourceB #[ApiProperty(readableLink: true)] #[Groups(['ResourceA:read', 'ResourceB:read'])] - #[MaxDepth(3)] + #[MaxDepth(6)] public ResourceA $a; public function __construct(?ResourceA $a = null) { - if ($a !== null) { + if (null !== $a) { $this->a = $a; } } - public static function provide(): ResourceB + public static function provide(): self { return self::provideWithResource(); } - public static function provideWithResource(?ResourceA $a = null): ResourceB + public static function provideWithResource(?ResourceA $a = null): self { - if(!isset(self::$resourceB)) { - self::$resourceB = new ResourceB($a); + if (!isset(self::$resourceB)) { + self::$resourceB = new self($a); - if(ResourceA::getInstance() === null) { + if (null === ResourceA::getInstance()) { self::$resourceB->a = ResourceA::provideWithResource(self::$resourceB); } } + return self::$resourceB; } - public static function getInstance(): ?ResourceB + public static function getInstance(): ?self { return self::$resourceB; } - } diff --git a/tests/Fixtures/app/AppKernel.php b/tests/Fixtures/app/AppKernel.php index 9a3cdb9d32..7d55824f4d 100644 --- a/tests/Fixtures/app/AppKernel.php +++ b/tests/Fixtures/app/AppKernel.php @@ -81,7 +81,7 @@ public function registerBundles(): array } if (class_exists(DoctrineMongoDBBundle::class)) { - //$bundles[] = new DoctrineMongoDBBundle(); + $bundles[] = new DoctrineMongoDBBundle(); } $bundles[] = new TestBundle(); diff --git a/tests/Functional/HALCircularReference.php b/tests/Functional/HALCircularReference.php index 802578a19e..1e02758dd9 100644 --- a/tests/Functional/HALCircularReference.php +++ b/tests/Functional/HALCircularReference.php @@ -1,5 +1,16 @@ + * + * 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; use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; @@ -11,11 +22,11 @@ class HALCircularReference extends ApiTestCase { use SetupClassResourcesTrait; - public function testIssue4358() + public function testIssue4358(): void { $r1 = self::createClient()->request('GET', '/resource_a', ['headers' => ['Accept' => 'application/hal+json']]); - $this->assertResponseIsSuccessful(); - echo $r1->getContent(); + self::assertResponseIsSuccessful(); + self::assertEquals('{"_links":{"self":{"href":"\/resource_a"},"b":{"href":"\/resource_b"}},"_embedded":{"b":{"_links":{"self":{"href":"\/resource_b"},"a":{"href":"\/resource_a"}},"_embedded":{"a":{"_links":{"self":{"href":"\/resource_a"}}}}}}}', $r1->getContent()); } public static function getResources(): array