Skip to content

Commit

Permalink
fix(serializer): hal format detecting circular reference
Browse files Browse the repository at this point in the history
  • Loading branch information
Valentin Dassonville authored and valentin-dassonville committed Oct 28, 2024
1 parent 0b6ea3b commit d3efc4a
Show file tree
Hide file tree
Showing 5 changed files with 135 additions and 23 deletions.
77 changes: 77 additions & 0 deletions src/Hal/Serializer/ItemNormalizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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}
*/
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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]));
}
}
33 changes: 23 additions & 10 deletions tests/Fixtures/TestBundle/ApiResource/Issue4358/ResourceA.php
Original file line number Diff line number Diff line change
@@ -1,7 +1,17 @@
<?php

namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue4358;
/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <[email protected]>
*
* 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;
Expand All @@ -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;
}

}
29 changes: 20 additions & 9 deletions tests/Fixtures/TestBundle/ApiResource/Issue4358/ResourceB.php
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <[email protected]>
*
* 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;
Expand All @@ -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;
}

}
2 changes: 1 addition & 1 deletion tests/Fixtures/app/AppKernel.php
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ public function registerBundles(): array
}

if (class_exists(DoctrineMongoDBBundle::class)) {
//$bundles[] = new DoctrineMongoDBBundle();
$bundles[] = new DoctrineMongoDBBundle();
}

$bundles[] = new TestBundle();
Expand Down
17 changes: 14 additions & 3 deletions tests/Functional/HALCircularReference.php
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <[email protected]>
*
* 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;
Expand All @@ -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
Expand Down

0 comments on commit d3efc4a

Please sign in to comment.