Skip to content

Commit 9d44cc7

Browse files
fix(serializer): hal format detecting circular reference
1 parent 0b6ea3b commit 9d44cc7

File tree

5 files changed

+135
-23
lines changed

5 files changed

+135
-23
lines changed

src/Hal/Serializer/ItemNormalizer.php

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,26 @@
1313

1414
namespace ApiPlatform\Hal\Serializer;
1515

16+
use ApiPlatform\Metadata\IriConverterInterface;
17+
use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
18+
use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface;
19+
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
20+
use ApiPlatform\Metadata\ResourceAccessCheckerInterface;
21+
use ApiPlatform\Metadata\ResourceClassResolverInterface;
1622
use ApiPlatform\Metadata\UrlGeneratorInterface;
1723
use ApiPlatform\Metadata\Util\ClassInfoTrait;
1824
use ApiPlatform\Serializer\AbstractItemNormalizer;
1925
use ApiPlatform\Serializer\CacheKeyTrait;
2026
use ApiPlatform\Serializer\ContextTrait;
27+
use ApiPlatform\Serializer\TagCollectorInterface;
28+
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
29+
use Symfony\Component\Serializer\Exception\CircularReferenceException;
2130
use Symfony\Component\Serializer\Exception\LogicException;
2231
use Symfony\Component\Serializer\Exception\UnexpectedValueException;
2332
use Symfony\Component\Serializer\Mapping\AttributeMetadataInterface;
33+
use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface;
34+
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
35+
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
2436

2537
/**
2638
* Converts between objects and array including HAL metadata.
@@ -35,9 +47,25 @@ final class ItemNormalizer extends AbstractItemNormalizer
3547

3648
public const FORMAT = 'jsonhal';
3749

50+
protected const HAL_CIRCULAR_REFERENCE_LIMIT_COUNTERS = 'hal_circular_reference_limit_counters';
51+
3852
private array $componentsCache = [];
3953
private array $attributesMetadataCache = [];
4054

55+
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)
56+
{
57+
$defaultContext[AbstractNormalizer::CIRCULAR_REFERENCE_HANDLER] = function ($object): ?array {
58+
$iri = $this->iriConverter->getIriFromResource($object);
59+
if (null === $iri) {
60+
return null;
61+
}
62+
63+
return ['_links' => ['self' => ['href' => $iri]]];
64+
};
65+
66+
parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, $classMetadataFactory, $defaultContext, $resourceMetadataCollectionFactory, $resourceAccessChecker, $tagCollector);
67+
}
68+
4169
/**
4270
* {@inheritdoc}
4371
*/
@@ -56,6 +84,10 @@ public function getSupportedTypes($format): array
5684
*/
5785
public function normalize(mixed $object, ?string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null
5886
{
87+
if ($this->isHalCircularReference($object, $context)) {
88+
return $this->handleHalCircularReference($object, $format, $context);
89+
}
90+
5991
$resourceClass = $this->getObjectClass($object);
6092
if ($this->getOutputClass($context)) {
6193
return parent::normalize($object, $format, $context);
@@ -319,4 +351,49 @@ private function isMaxDepthReached(array $attributesMetadata, string $class, str
319351

320352
return false;
321353
}
354+
355+
/**
356+
* Detects if the configured circular reference limit is reached.
357+
*
358+
* @throws CircularReferenceException
359+
*/
360+
protected function isHalCircularReference(object $object, array &$context): bool
361+
{
362+
$objectHash = spl_object_hash($object);
363+
364+
$circularReferenceLimit = $context[AbstractNormalizer::CIRCULAR_REFERENCE_LIMIT] ?? $this->defaultContext[AbstractNormalizer::CIRCULAR_REFERENCE_LIMIT];
365+
if (isset($context[self::HAL_CIRCULAR_REFERENCE_LIMIT_COUNTERS][$objectHash])) {
366+
if ($context[self::HAL_CIRCULAR_REFERENCE_LIMIT_COUNTERS][$objectHash] >= $circularReferenceLimit) {
367+
unset($context[self::HAL_CIRCULAR_REFERENCE_LIMIT_COUNTERS][$objectHash]);
368+
369+
return true;
370+
}
371+
372+
++$context[self::HAL_CIRCULAR_REFERENCE_LIMIT_COUNTERS][$objectHash];
373+
} else {
374+
$context[self::HAL_CIRCULAR_REFERENCE_LIMIT_COUNTERS][$objectHash] = 1;
375+
}
376+
377+
return false;
378+
}
379+
380+
/**
381+
* Handles a circular reference.
382+
*
383+
* If a circular reference handler is set, it will be called. Otherwise, a
384+
* {@class CircularReferenceException} will be thrown.
385+
*
386+
* @final
387+
*
388+
* @throws CircularReferenceException
389+
*/
390+
protected function handleHalCircularReference(object $object, ?string $format = null, array $context = []): mixed
391+
{
392+
$circularReferenceHandler = $context[AbstractNormalizer::CIRCULAR_REFERENCE_HANDLER] ?? $this->defaultContext[AbstractNormalizer::CIRCULAR_REFERENCE_HANDLER];
393+
if ($circularReferenceHandler) {
394+
return $circularReferenceHandler($object, $format, $context);
395+
}
396+
397+
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]));
398+
}
322399
}
Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,17 @@
11
<?php
22

3-
namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue4358;
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
413

14+
namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue4358;
515

616
use ApiPlatform\Metadata\ApiProperty;
717
use ApiPlatform\Metadata\Get;
@@ -19,33 +29,36 @@ final class ResourceA
1929

2030
#[ApiProperty(readableLink: true)]
2131
#[Groups(['ResourceA:read', 'ResourceB:read'])]
22-
#[MaxDepth(3)]
32+
#[MaxDepth(6)]
2333
public ResourceB $b;
34+
2435
public function __construct(?ResourceB $b = null)
2536
{
26-
if ($b !== null) {
37+
if (null !== $b) {
2738
$this->b = $b;
2839
}
2940
}
3041

31-
public static function provide(): ResourceA
42+
public static function provide(): self
3243
{
3344
return self::provideWithResource();
3445
}
3546

36-
public static function provideWithResource(?ResourceB $b = null): ResourceA {
37-
if(!isset(self::$resourceA)) {
38-
self::$resourceA = new ResourceA($b);
47+
public static function provideWithResource(?ResourceB $b = null): self
48+
{
49+
if (!isset(self::$resourceA)) {
50+
self::$resourceA = new self($b);
3951

40-
if(ResourceB::getInstance() === null) {
52+
if (null === ResourceB::getInstance()) {
4153
self::$resourceA->b = ResourceB::provideWithResource(self::$resourceA);
4254
}
4355
}
56+
4457
return self::$resourceA;
4558
}
4659

47-
public static function getInstance(): ?ResourceA {
60+
public static function getInstance(): ?self
61+
{
4862
return self::$resourceA;
4963
}
50-
5164
}

tests/Fixtures/TestBundle/ApiResource/Issue4358/ResourceB.php

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,16 @@
11
<?php
22

3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
314
namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue4358;
415

516
use ApiPlatform\Metadata\ApiProperty;
@@ -18,36 +29,36 @@ final class ResourceB
1829

1930
#[ApiProperty(readableLink: true)]
2031
#[Groups(['ResourceA:read', 'ResourceB:read'])]
21-
#[MaxDepth(3)]
32+
#[MaxDepth(6)]
2233
public ResourceA $a;
2334

2435
public function __construct(?ResourceA $a = null)
2536
{
26-
if ($a !== null) {
37+
if (null !== $a) {
2738
$this->a = $a;
2839
}
2940
}
3041

31-
public static function provide(): ResourceB
42+
public static function provide(): self
3243
{
3344
return self::provideWithResource();
3445
}
3546

36-
public static function provideWithResource(?ResourceA $a = null): ResourceB
47+
public static function provideWithResource(?ResourceA $a = null): self
3748
{
38-
if(!isset(self::$resourceB)) {
39-
self::$resourceB = new ResourceB($a);
49+
if (!isset(self::$resourceB)) {
50+
self::$resourceB = new self($a);
4051

41-
if(ResourceA::getInstance() === null) {
52+
if (null === ResourceA::getInstance()) {
4253
self::$resourceB->a = ResourceA::provideWithResource(self::$resourceB);
4354
}
4455
}
56+
4557
return self::$resourceB;
4658
}
4759

48-
public static function getInstance(): ?ResourceB
60+
public static function getInstance(): ?self
4961
{
5062
return self::$resourceB;
5163
}
52-
5364
}

tests/Fixtures/app/AppKernel.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ public function registerBundles(): array
8181
}
8282

8383
if (class_exists(DoctrineMongoDBBundle::class)) {
84-
//$bundles[] = new DoctrineMongoDBBundle();
84+
$bundles[] = new DoctrineMongoDBBundle();
8585
}
8686

8787
$bundles[] = new TestBundle();

tests/Functional/HALCircularReference.php

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,16 @@
11
<?php
22

3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
314
namespace ApiPlatform\Tests\Functional;
415

516
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
@@ -11,11 +22,11 @@ class HALCircularReference extends ApiTestCase
1122
{
1223
use SetupClassResourcesTrait;
1324

14-
public function testIssue4358()
25+
public function testIssue4358(): void
1526
{
1627
$r1 = self::createClient()->request('GET', '/resource_a', ['headers' => ['Accept' => 'application/hal+json']]);
17-
$this->assertResponseIsSuccessful();
18-
echo $r1->getContent();
28+
self::assertResponseIsSuccessful();
29+
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());
1930
}
2031

2132
public static function getResources(): array

0 commit comments

Comments
 (0)