From 4ba56553ad752a46409bb18c4fd34cc9b169ba9d Mon Sep 17 00:00:00 2001 From: soyuka Date: Mon, 25 May 2026 17:24:43 +0200 Subject: [PATCH 1/3] fix(serializer): gate cache_key in JsonApi and Hal with isCacheKeySafe `#[ApiProperty(security: ...)]` makes allowed attributes per-user, but the local `componentsCache` in JsonApi and Hal item normalizers is not user-aware: a cached component map can leak attributes across users. Move `isCacheKeySafe()` from `GraphQl/Serializer/ItemNormalizer` into `Serializer/AbstractItemNormalizer` so it is shared across all formats, and gate `$context['cache_key']` with it in JsonApi and Hal. Promote `CacheKeyTrait::getCacheKey()` from `private` to `protected` so subclasses can reuse the inherited trait method. Supersedes #7854. --- src/GraphQl/Serializer/ItemNormalizer.php | 38 ------- src/Hal/Serializer/ItemNormalizer.php | 4 +- .../Tests/Serializer/ItemNormalizerTest.php | 46 +++++++++ src/JsonApi/Serializer/ItemNormalizer.php | 4 +- .../Tests/Serializer/ItemNormalizerTest.php | 58 ++++++++++- src/Serializer/AbstractItemNormalizer.php | 36 +++++++ src/Serializer/CacheKeyTrait.php | 2 +- .../Tests/AbstractItemNormalizerTest.php | 98 +++++++++++++++++++ 8 files changed, 240 insertions(+), 46 deletions(-) diff --git a/src/GraphQl/Serializer/ItemNormalizer.php b/src/GraphQl/Serializer/ItemNormalizer.php index 1d94476c787..f3685aeed64 100644 --- a/src/GraphQl/Serializer/ItemNormalizer.php +++ b/src/GraphQl/Serializer/ItemNormalizer.php @@ -24,7 +24,6 @@ use ApiPlatform\Metadata\ResourceAccessCheckerInterface; use ApiPlatform\Metadata\ResourceClassResolverInterface; use ApiPlatform\Metadata\Util\ClassInfoTrait; -use ApiPlatform\Serializer\CacheKeyTrait; use ApiPlatform\Serializer\ItemNormalizer as BaseItemNormalizer; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; @@ -40,15 +39,12 @@ */ final class ItemNormalizer extends BaseItemNormalizer { - use CacheKeyTrait; use ClassInfoTrait; public const FORMAT = 'graphql'; public const ITEM_RESOURCE_CLASS_KEY = '#itemResourceClass'; public const ITEM_IDENTIFIERS_KEY = '#itemIdentifiers'; - private array $safeCacheKeysCache = []; - public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface $iriConverter, private readonly IdentifiersExtractorInterface $identifiersExtractor, ResourceClassResolverInterface $resourceClassResolver, ?PropertyAccessorInterface $propertyAccessor = null, ?NameConverterInterface $nameConverter = null, ?ClassMetadataFactoryInterface $classMetadataFactory = null, ?LoggerInterface $logger = null, ?ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory = null, ?ResourceAccessCheckerInterface $resourceAccessChecker = null) { parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, $classMetadataFactory, $logger ?: new NullLogger(), $resourceMetadataCollectionFactory, $resourceAccessChecker); @@ -90,12 +86,6 @@ public function normalize(mixed $data, ?string $format = null, array $context = return parent::normalize($data, $format, $context); } - if ($this->isCacheKeySafe($context)) { - $context['cache_key'] = $this->getCacheKey($format, $context); - } else { - $context['cache_key'] = false; - } - unset($context['operation_name'], $context['operation']); // Remove operation and operation_name only when cache key has been created $normalizedData = parent::normalize($data, $format, $context); if (!\is_array($normalizedData)) { @@ -161,32 +151,4 @@ protected function setAttributeValue(object $object, string $attribute, mixed $v parent::setAttributeValue($object, $attribute, $value, $format, $context); } - - /** - * Check if any property contains a security grants, which makes the cache key not safe, - * as allowed_properties can differ for 2 instances of the same object. - */ - private function isCacheKeySafe(array $context): bool - { - if (!isset($context['resource_class']) || !$this->resourceClassResolver->isResourceClass($context['resource_class'])) { - return false; - } - $resourceClass = $this->resourceClassResolver->getResourceClass(null, $context['resource_class']); - if (isset($this->safeCacheKeysCache[$resourceClass])) { - return $this->safeCacheKeysCache[$resourceClass]; - } - $options = $this->getFactoryOptions($context); - $propertyNames = $this->propertyNameCollectionFactory->create($resourceClass, $options); - - $this->safeCacheKeysCache[$resourceClass] = true; - foreach ($propertyNames as $propertyName) { - $propertyMetadata = $this->propertyMetadataFactory->create($resourceClass, $propertyName, $options); - if (null !== $propertyMetadata->getSecurity()) { - $this->safeCacheKeysCache[$resourceClass] = false; - break; - } - } - - return $this->safeCacheKeysCache[$resourceClass]; - } } diff --git a/src/Hal/Serializer/ItemNormalizer.php b/src/Hal/Serializer/ItemNormalizer.php index b4ff28d5ce7..61af0539b0e 100644 --- a/src/Hal/Serializer/ItemNormalizer.php +++ b/src/Hal/Serializer/ItemNormalizer.php @@ -23,7 +23,6 @@ use ApiPlatform\Metadata\Util\ClassInfoTrait; use ApiPlatform\Metadata\Util\TypeHelper; use ApiPlatform\Serializer\AbstractItemNormalizer; -use ApiPlatform\Serializer\CacheKeyTrait; use ApiPlatform\Serializer\ContextTrait; use ApiPlatform\Serializer\OperationResourceClassResolverInterface; use ApiPlatform\Serializer\TagCollectorInterface; @@ -49,7 +48,6 @@ */ final class ItemNormalizer extends AbstractItemNormalizer { - use CacheKeyTrait; use ClassInfoTrait; use ContextTrait; @@ -113,7 +111,7 @@ public function normalize(mixed $data, ?string $format = null, array $context = $context['api_normalize'] = true; if (!isset($context['cache_key'])) { - $context['cache_key'] = $this->getCacheKey($format, $context); + $context['cache_key'] = $this->isCacheKeySafe($context) ? $this->getCacheKey($format, $context) : false; } $normalizedData = parent::normalize($data, $format, $context); diff --git a/src/Hal/Tests/Serializer/ItemNormalizerTest.php b/src/Hal/Tests/Serializer/ItemNormalizerTest.php index cd4775ff466..f45d91623b6 100644 --- a/src/Hal/Tests/Serializer/ItemNormalizerTest.php +++ b/src/Hal/Tests/Serializer/ItemNormalizerTest.php @@ -170,6 +170,52 @@ public function testNormalize(): void $this->assertEquals($expected, $normalizer->normalize($dummy)); } + public function testCacheKeyIsFalseWhenAPropertyHasSecurity(): void + { + $dummy = new Dummy(); + $dummy->setName('hello'); + + $propertyNameCollection = new PropertyNameCollection(['name']); + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::type('array'))->willReturn($propertyNameCollection); + + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::type('array'))->willReturn( + (new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(true)->withSecurity('is_granted(\'ROLE_ADMIN\')') + ); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $iriConverterProphecy->getIriFromResource($dummy, Argument::cetera())->willReturn('/dummies/1'); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true); + $resourceClassResolverProphecy->getResourceClass($dummy, null)->willReturn(Dummy::class); + $resourceClassResolverProphecy->getResourceClass(null, Dummy::class)->willReturn(Dummy::class); + $resourceClassResolverProphecy->getResourceClass($dummy, Dummy::class)->willReturn(Dummy::class); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + $serializerProphecy->willImplement(NormalizerInterface::class); + $serializerProphecy->normalize('hello', null, Argument::type('array'))->willReturn('hello'); + + $nameConverter = $this->prophesize(NameConverterInterface::class); + $nameConverter->normalize('name', Argument::any(), Argument::any(), Argument::any())->willReturn('name'); + + $normalizer = new ItemNormalizer( + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceClassResolverProphecy->reveal(), + null, + $nameConverter->reveal() + ); + $normalizer->setSerializer($serializerProphecy->reveal()); + + $normalizer->normalize($dummy); + + $componentsCacheRef = new \ReflectionProperty(ItemNormalizer::class, 'componentsCache'); + $this->assertSame([], $componentsCacheRef->getValue($normalizer), 'componentsCache must not be populated when a property has security set'); + } + public function testNormalizeWithUnionIntersectTypes(): void { $author = new Author(id: 2, name: 'Isaac Asimov'); diff --git a/src/JsonApi/Serializer/ItemNormalizer.php b/src/JsonApi/Serializer/ItemNormalizer.php index b97c1411dc2..d3e59b2ebe8 100644 --- a/src/JsonApi/Serializer/ItemNormalizer.php +++ b/src/JsonApi/Serializer/ItemNormalizer.php @@ -28,7 +28,6 @@ use ApiPlatform\Metadata\Util\CompositeIdentifierParser; use ApiPlatform\Metadata\Util\TypeHelper; use ApiPlatform\Serializer\AbstractItemNormalizer; -use ApiPlatform\Serializer\CacheKeyTrait; use ApiPlatform\Serializer\ContextTrait; use ApiPlatform\Serializer\OperationResourceClassResolverInterface; use ApiPlatform\Serializer\TagCollectorInterface; @@ -55,7 +54,6 @@ */ final class ItemNormalizer extends AbstractItemNormalizer { - use CacheKeyTrait; use ClassInfoTrait; use ContextTrait; @@ -127,7 +125,7 @@ public function normalize(mixed $data, ?string $format = null, array $context = $context['api_normalize'] = true; if (!isset($context['cache_key'])) { - $context['cache_key'] = $this->getCacheKey($format, $context); + $context['cache_key'] = $this->isCacheKeySafe($context) ? $this->getCacheKey($format, $context) : false; } $normalizedData = parent::normalize($data, $format, $context); diff --git a/src/JsonApi/Tests/Serializer/ItemNormalizerTest.php b/src/JsonApi/Tests/Serializer/ItemNormalizerTest.php index fd4b2ea12ef..c6c1f35e15b 100644 --- a/src/JsonApi/Tests/Serializer/ItemNormalizerTest.php +++ b/src/JsonApi/Tests/Serializer/ItemNormalizerTest.php @@ -126,6 +126,62 @@ public function testNormalize(): void $this->assertEquals($expected, $normalizer->normalize($dummy, ItemNormalizer::FORMAT)); } + public function testCacheKeyIsFalseWhenAPropertyHasSecurity(): void + { + $dummy = new Dummy(); + $dummy->setId(11); + $dummy->setName('hello'); + + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::type('array'))->willReturn(new PropertyNameCollection(['id', 'name'])); + + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'id', Argument::type('array'))->willReturn((new ApiProperty())->withReadable(true)->withIdentifier(true)); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::type('array'))->willReturn((new ApiProperty())->withReadable(true)->withSecurity('is_granted(\'ROLE_ADMIN\')')); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $iriConverterProphecy->getIriFromResource($dummy, Argument::cetera())->willReturn('/dummies/11'); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass($dummy, null)->willReturn(Dummy::class); + $resourceClassResolverProphecy->getResourceClass(null, Dummy::class)->willReturn(Dummy::class); + $resourceClassResolverProphecy->getResourceClass($dummy, Dummy::class)->willReturn(Dummy::class); + $resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true); + + $propertyAccessorProphecy = $this->prophesize(PropertyAccessorInterface::class); + $propertyAccessorProphecy->getValue($dummy, 'id')->willReturn(11); + $propertyAccessorProphecy->getValue($dummy, 'name')->willReturn('hello'); + + $resourceMetadataCollectionFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataCollectionFactoryProphecy->create(Dummy::class)->willReturn(new ResourceMetadataCollection('Dummy', [ + (new ApiResource()) + ->withShortName('Dummy') + ->withOperations(new Operations(['get' => (new Get())->withShortName('Dummy')])), + ])); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + $serializerProphecy->willImplement(NormalizerInterface::class); + $serializerProphecy->normalize(Argument::any(), ItemNormalizer::FORMAT, Argument::type('array'))->will(fn ($args) => $args[0]); + + $normalizer = new ItemNormalizer( + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceClassResolverProphecy->reveal(), + $propertyAccessorProphecy->reveal(), + new ReservedAttributeNameConverter(), + null, + [], + $resourceMetadataCollectionFactoryProphecy->reveal(), + ); + $normalizer->setSerializer($serializerProphecy->reveal()); + + $normalizer->normalize($dummy, ItemNormalizer::FORMAT); + + $componentsCacheRef = new \ReflectionProperty(ItemNormalizer::class, 'componentsCache'); + $this->assertSame([], $componentsCacheRef->getValue($normalizer), 'componentsCache must not be populated when a property has security set'); + } + public function testNormalizeCircularReference(): void { $circularReferenceEntity = new CircularReference(); @@ -144,7 +200,7 @@ public function testNormalizeCircularReference(): void $resourceMetadataCollectionFactoryProphecy->create(CircularReference::class)->willReturn(new ResourceMetadataCollection('CircularReference')); $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); - $propertyNameCollectionFactoryProphecy->create(CircularReference::class, [])->willReturn(new PropertyNameCollection()); + $propertyNameCollectionFactoryProphecy->create(CircularReference::class, Argument::type('array'))->willReturn(new PropertyNameCollection()); $normalizer = new ItemNormalizer( $propertyNameCollectionFactoryProphecy->reveal(), diff --git a/src/Serializer/AbstractItemNormalizer.php b/src/Serializer/AbstractItemNormalizer.php index 3c14013ebc2..4ef86f8e24c 100644 --- a/src/Serializer/AbstractItemNormalizer.php +++ b/src/Serializer/AbstractItemNormalizer.php @@ -63,6 +63,7 @@ */ abstract class AbstractItemNormalizer extends AbstractObjectNormalizer { + use CacheKeyTrait; use ClassInfoTrait; use CloneTrait; use ContextTrait; @@ -77,6 +78,7 @@ abstract class AbstractItemNormalizer extends AbstractObjectNormalizer protected PropertyAccessorInterface $propertyAccessor; protected array $localCache = []; protected array $localFactoryOptionsCache = []; + protected array $safeCacheKeysCache = []; protected ?ResourceAccessCheckerInterface $resourceAccessChecker; public function __construct(protected PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, protected PropertyMetadataFactoryInterface $propertyMetadataFactory, protected IriConverterInterface $iriConverter, protected ResourceClassResolverInterface $resourceClassResolver, ?PropertyAccessorInterface $propertyAccessor = null, ?NameConverterInterface $nameConverter = null, ?ClassMetadataFactoryInterface $classMetadataFactory = null, array $defaultContext = [], ?ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory = null, ?ResourceAccessCheckerInterface $resourceAccessChecker = null, protected ?TagCollectorInterface $tagCollector = null, protected ?OperationResourceClassResolverInterface $operationResourceResolver = null) @@ -191,6 +193,10 @@ public function normalize(mixed $data, ?string $format = null, array $context = $context['resources'][$iri] = $iri; } + if (!isset($context['cache_key'])) { + $context['cache_key'] = $this->isCacheKeySafe($context) ? $this->getCacheKey($format, $context) : false; + } + $context['object'] = $data; $context['format'] = $format; @@ -525,6 +531,36 @@ protected function canAccessAttribute(?object $object, string $attribute, array return true; } + /** + * Check if any property contains a security grant, which makes the cache key not safe, + * as allowed_properties can differ for two instances of the same object. + */ + protected function isCacheKeySafe(array $context): bool + { + if (!isset($context['resource_class']) || !$this->resourceClassResolver->isResourceClass($context['resource_class'])) { + return false; + } + + $resourceClass = $this->resourceClassResolver->getResourceClass(null, $context['resource_class']); + if (isset($this->safeCacheKeysCache[$resourceClass])) { + return $this->safeCacheKeysCache[$resourceClass]; + } + + $options = $this->getFactoryOptions($context); + $propertyNames = $this->propertyNameCollectionFactory->create($resourceClass, $options); + + $this->safeCacheKeysCache[$resourceClass] = true; + foreach ($propertyNames as $propertyName) { + $propertyMetadata = $this->propertyMetadataFactory->create($resourceClass, $propertyName, $options); + if (null !== $propertyMetadata->getSecurity()) { + $this->safeCacheKeysCache[$resourceClass] = false; + break; + } + } + + return $this->safeCacheKeysCache[$resourceClass]; + } + /** * Check if access to the attribute is granted. */ diff --git a/src/Serializer/CacheKeyTrait.php b/src/Serializer/CacheKeyTrait.php index 1acdeb45e24..3d85f73fd94 100644 --- a/src/Serializer/CacheKeyTrait.php +++ b/src/Serializer/CacheKeyTrait.php @@ -21,7 +21,7 @@ */ trait CacheKeyTrait { - private function getCacheKey(?string $format, array $context): string|bool + protected function getCacheKey(?string $format, array $context): string|bool { foreach ($context[self::EXCLUDE_FROM_CACHE_KEY] ?? $this->defaultContext[self::EXCLUDE_FROM_CACHE_KEY] as $key) { unset($context[$key]); diff --git a/src/Serializer/Tests/AbstractItemNormalizerTest.php b/src/Serializer/Tests/AbstractItemNormalizerTest.php index 091eee30f88..b12bcea3005 100644 --- a/src/Serializer/Tests/AbstractItemNormalizerTest.php +++ b/src/Serializer/Tests/AbstractItemNormalizerTest.php @@ -222,6 +222,104 @@ public function testNormalizeWithSecuredProperty(): void ])); } + public function testIsCacheKeySafeReturnsFalseWhenAPropertyHasSecurity(): void + { + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(SecuredDummy::class, Argument::type('array'))->willReturn(new PropertyNameCollection(['title', 'adminOnlyProperty'])); + + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'title', Argument::type('array'))->willReturn((new ApiProperty())->withReadable(true)); + $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'adminOnlyProperty', Argument::type('array'))->willReturn((new ApiProperty())->withReadable(true)->withSecurity('is_granted(\'ROLE_ADMIN\')')); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->isResourceClass(SecuredDummy::class)->willReturn(true); + $resourceClassResolverProphecy->getResourceClass(null, SecuredDummy::class)->willReturn(SecuredDummy::class); + + $normalizer = $this->createCacheKeySafeProbe( + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $resourceClassResolverProphecy->reveal() + ); + + $this->assertFalse($normalizer->probeIsCacheKeySafe(['resource_class' => SecuredDummy::class])); + } + + public function testIsCacheKeySafeReturnsTrueWhenNoPropertyHasSecurity(): void + { + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::type('array'))->willReturn(new PropertyNameCollection(['name', 'alias'])); + + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::type('array'))->willReturn((new ApiProperty())->withReadable(true)); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'alias', Argument::type('array'))->willReturn((new ApiProperty())->withReadable(true)); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true); + $resourceClassResolverProphecy->getResourceClass(null, Dummy::class)->willReturn(Dummy::class); + + $normalizer = $this->createCacheKeySafeProbe( + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $resourceClassResolverProphecy->reveal() + ); + + $this->assertTrue($normalizer->probeIsCacheKeySafe(['resource_class' => Dummy::class])); + } + + public function testIsCacheKeySafeReturnsFalseWithoutResourceClass(): void + { + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->isResourceClass(\stdClass::class)->willReturn(false); + + $normalizer = $this->createCacheKeySafeProbe( + $this->prophesize(PropertyNameCollectionFactoryInterface::class)->reveal(), + $this->prophesize(PropertyMetadataFactoryInterface::class)->reveal(), + $resourceClassResolverProphecy->reveal() + ); + + $this->assertFalse($normalizer->probeIsCacheKeySafe([])); + $this->assertFalse($normalizer->probeIsCacheKeySafe(['resource_class' => \stdClass::class])); + } + + public function testIsCacheKeySafeCachesPerResourceClass(): void + { + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(SecuredDummy::class, Argument::type('array')) + ->shouldBeCalledOnce() + ->willReturn(new PropertyNameCollection(['adminOnlyProperty'])); + + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'adminOnlyProperty', Argument::type('array')) + ->shouldBeCalledOnce() + ->willReturn((new ApiProperty())->withReadable(true)->withSecurity('is_granted(\'ROLE_ADMIN\')')); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->isResourceClass(SecuredDummy::class)->willReturn(true); + $resourceClassResolverProphecy->getResourceClass(null, SecuredDummy::class)->willReturn(SecuredDummy::class); + + $normalizer = $this->createCacheKeySafeProbe( + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $resourceClassResolverProphecy->reveal() + ); + + $this->assertFalse($normalizer->probeIsCacheKeySafe(['resource_class' => SecuredDummy::class])); + $this->assertFalse($normalizer->probeIsCacheKeySafe(['resource_class' => SecuredDummy::class])); + } + + private function createCacheKeySafeProbe( + PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, + PropertyMetadataFactoryInterface $propertyMetadataFactory, + ResourceClassResolverInterface $resourceClassResolver, + ): AbstractItemNormalizer { + return new class($propertyNameCollectionFactory, $propertyMetadataFactory, $this->prophesize(IriConverterInterface::class)->reveal(), $resourceClassResolver, $this->prophesize(PropertyAccessorInterface::class)->reveal(), null, null, [], null, null) extends AbstractItemNormalizer { + public function probeIsCacheKeySafe(array $context): bool + { + return $this->isCacheKeySafe($context); + } + }; + } + public function testNormalizePropertyAsIriWithUriTemplate(): void { $propertyCollectionIriOnlyRelation = new PropertyCollectionIriOnlyRelation(); From 3b249874205f93bde08d56eddc1b125d144e88d9 Mon Sep 17 00:00:00 2001 From: soyuka Date: Tue, 26 May 2026 08:16:12 +0200 Subject: [PATCH 2/3] fix(jsonapi): cs-fixer static closure --- src/JsonApi/Tests/Serializer/ItemNormalizerTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/JsonApi/Tests/Serializer/ItemNormalizerTest.php b/src/JsonApi/Tests/Serializer/ItemNormalizerTest.php index c6c1f35e15b..374675a7e65 100644 --- a/src/JsonApi/Tests/Serializer/ItemNormalizerTest.php +++ b/src/JsonApi/Tests/Serializer/ItemNormalizerTest.php @@ -161,7 +161,7 @@ public function testCacheKeyIsFalseWhenAPropertyHasSecurity(): void $serializerProphecy = $this->prophesize(SerializerInterface::class); $serializerProphecy->willImplement(NormalizerInterface::class); - $serializerProphecy->normalize(Argument::any(), ItemNormalizer::FORMAT, Argument::type('array'))->will(fn ($args) => $args[0]); + $serializerProphecy->normalize(Argument::any(), ItemNormalizer::FORMAT, Argument::type('array'))->will(static fn ($args) => $args[0]); $normalizer = new ItemNormalizer( $propertyNameCollectionFactoryProphecy->reveal(), From da0a4e66a83ab774758ad2e4a7520561020aa062 Mon Sep 17 00:00:00 2001 From: soyuka Date: Tue, 26 May 2026 08:29:35 +0200 Subject: [PATCH 3/3] fix(serializer): bump min serializer dep and fix phpstan probe typing - Bump api-platform/serializer min to ^4.3.7 in JsonApi, Hal and GraphQl composer.json so they pull a serializer release that exposes the new protected isCacheKeySafe() method on AbstractItemNormalizer. - Change createCacheKeySafeProbe() return type to object so PHPStan can resolve probeIsCacheKeySafe() on the anonymous AbstractItemNormalizer subclass. --- src/GraphQl/composer.json | 2 +- src/Hal/composer.json | 2 +- src/JsonApi/composer.json | 2 +- src/Serializer/Tests/AbstractItemNormalizerTest.php | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/GraphQl/composer.json b/src/GraphQl/composer.json index 64ff8c40a0f..41ec3eb4269 100644 --- a/src/GraphQl/composer.json +++ b/src/GraphQl/composer.json @@ -23,7 +23,7 @@ "php": ">=8.2", "api-platform/metadata": "^4.3", "api-platform/state": "^4.3", - "api-platform/serializer": "^4.3", + "api-platform/serializer": "^4.3.7", "symfony/property-info": "^7.1 || ^8.0", "symfony/serializer": "^6.4 || ^7.1 || ^8.0", "symfony/type-info": "^7.3 || ^8.0", diff --git a/src/Hal/composer.json b/src/Hal/composer.json index 10d9e3f163b..3a7015ff2c1 100644 --- a/src/Hal/composer.json +++ b/src/Hal/composer.json @@ -25,7 +25,7 @@ "api-platform/state": "^4.3", "api-platform/metadata": "^4.3", "api-platform/documentation": "^4.3", - "api-platform/serializer": "^4.3", + "api-platform/serializer": "^4.3.7", "symfony/type-info": "^7.3 || ^8.0" }, "autoload": { diff --git a/src/JsonApi/composer.json b/src/JsonApi/composer.json index 56d2c52a8d6..0b6c7b34196 100644 --- a/src/JsonApi/composer.json +++ b/src/JsonApi/composer.json @@ -25,7 +25,7 @@ "api-platform/documentation": "^4.3", "api-platform/json-schema": "^4.3", "api-platform/metadata": "^4.3", - "api-platform/serializer": "^4.3", + "api-platform/serializer": "^4.3.7", "api-platform/state": "^4.3", "symfony/error-handler": "^6.4 || ^7.0 || ^8.0", "symfony/http-foundation": "^6.4.14 || ^7.0 || ^8.0", diff --git a/src/Serializer/Tests/AbstractItemNormalizerTest.php b/src/Serializer/Tests/AbstractItemNormalizerTest.php index b12bcea3005..c9c7c097a47 100644 --- a/src/Serializer/Tests/AbstractItemNormalizerTest.php +++ b/src/Serializer/Tests/AbstractItemNormalizerTest.php @@ -311,7 +311,7 @@ private function createCacheKeySafeProbe( PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, ResourceClassResolverInterface $resourceClassResolver, - ): AbstractItemNormalizer { + ): object { return new class($propertyNameCollectionFactory, $propertyMetadataFactory, $this->prophesize(IriConverterInterface::class)->reveal(), $resourceClassResolver, $this->prophesize(PropertyAccessorInterface::class)->reveal(), null, null, [], null, null) extends AbstractItemNormalizer { public function probeIsCacheKeySafe(array $context): bool {