From 9e91c8b465186cb1892b31e2bca7bcd1aeaf7ac7 Mon Sep 17 00:00:00 2001 From: soyuka Date: Fri, 12 Jun 2026 14:44:08 +0200 Subject: [PATCH] fix(elasticsearch): coerce document _id to declared int identifier type Elasticsearch always exposes _id as a string; DocumentNormalizer wrote it verbatim into _source, so an int-typed resource identifier failed denormalization with NotNormalizableValueException. Coerce _id to int only when the declared native identifier type is int; string and UUID identifiers are left untouched. Fixes #8010 --- .../Serializer/DocumentNormalizer.php | 29 ++++++++++++-- .../Tests/Fixtures/IntegerIdentified.php | 27 +++++++++++++ .../Serializer/DocumentNormalizerTest.php | 40 +++++++++++++++++++ .../Bundle/Resources/config/elasticsearch.php | 1 + 4 files changed, 94 insertions(+), 3 deletions(-) create mode 100644 src/Elasticsearch/Tests/Fixtures/IntegerIdentified.php diff --git a/src/Elasticsearch/Serializer/DocumentNormalizer.php b/src/Elasticsearch/Serializer/DocumentNormalizer.php index d9a816cf87b..189561f800b 100644 --- a/src/Elasticsearch/Serializer/DocumentNormalizer.php +++ b/src/Elasticsearch/Serializer/DocumentNormalizer.php @@ -14,6 +14,7 @@ namespace ApiPlatform\Elasticsearch\Serializer; use ApiPlatform\Metadata\HttpOperation; +use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; use Symfony\Component\PropertyInfo\PropertyTypeExtractorInterface; @@ -26,6 +27,7 @@ use Symfony\Component\Serializer\Normalizer\ObjectNormalizer; use Symfony\Component\Serializer\SerializerAwareInterface; use Symfony\Component\Serializer\SerializerInterface; +use Symfony\Component\TypeInfo\TypeIdentifier; /** * Document denormalizer for Elasticsearch. @@ -49,6 +51,7 @@ public function __construct( ?ClassDiscriminatorResolverInterface $classDiscriminatorResolver = null, ?callable $objectClassResolver = null, array $defaultContext = [], + private readonly ?PropertyMetadataFactoryInterface $propertyMetadataFactory = null, ) { $this->decoratedNormalizer = new ObjectNormalizer($classMetadataFactory, $nameConverter, $propertyAccessor, $propertyTypeExtractor, $classDiscriminatorResolver, $objectClassResolver, $defaultContext); } @@ -109,15 +112,35 @@ private function populateIdentifier(array $data, string $class): array } } - $identifier = null === $this->nameConverter ? $identifier : $this->nameConverter->normalize($identifier, $class, self::FORMAT); + $sourceKey = null === $this->nameConverter ? $identifier : $this->nameConverter->normalize($identifier, $class, self::FORMAT); - if (!isset($data['_source'][$identifier])) { - $data['_source'][$identifier] = $data['_id']; + if (!isset($data['_source'][$sourceKey])) { + $data['_source'][$sourceKey] = $this->coerceIdentifier($class, $identifier, $data['_id']); } return $data; } + /** + * Elasticsearch always exposes the document identifier (`_id`) as a string. When the resource + * identifier is declared as an int, casting it back avoids a type mismatch in the inner + * ObjectNormalizer. String identifiers (e.g. UUIDs) are left untouched. + */ + private function coerceIdentifier(string $class, string $identifier, string $value): int|string + { + if (null === $this->propertyMetadataFactory || !is_numeric($value)) { + return $value; + } + + $nativeType = $this->propertyMetadataFactory->create($class, $identifier)->getNativeType(); + + if ($nativeType?->isIdentifiedBy(TypeIdentifier::INT) && !$nativeType->isIdentifiedBy(TypeIdentifier::STRING)) { + return (int) $value; + } + + return $value; + } + /** * {@inheritdoc} */ diff --git a/src/Elasticsearch/Tests/Fixtures/IntegerIdentified.php b/src/Elasticsearch/Tests/Fixtures/IntegerIdentified.php new file mode 100644 index 00000000000..6141a478e6b --- /dev/null +++ b/src/Elasticsearch/Tests/Fixtures/IntegerIdentified.php @@ -0,0 +1,27 @@ + + * + * 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\Elasticsearch\Tests\Fixtures; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; + +#[ApiResource(operations: [new Get()])] +class IntegerIdentified +{ + #[ApiProperty(identifier: true)] + public ?int $id = null; + + public ?string $name = null; +} diff --git a/src/Elasticsearch/Tests/Serializer/DocumentNormalizerTest.php b/src/Elasticsearch/Tests/Serializer/DocumentNormalizerTest.php index 8ae0afc9b27..becb8dce132 100644 --- a/src/Elasticsearch/Tests/Serializer/DocumentNormalizerTest.php +++ b/src/Elasticsearch/Tests/Serializer/DocumentNormalizerTest.php @@ -15,16 +15,21 @@ use ApiPlatform\Elasticsearch\Serializer\DocumentNormalizer; use ApiPlatform\Elasticsearch\Tests\Fixtures\Foo; +use ApiPlatform\Elasticsearch\Tests\Fixtures\IntegerIdentified; +use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\Operations; +use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; use PHPUnit\Framework\TestCase; use Prophecy\PhpUnit\ProphecyTrait; +use Symfony\Component\PropertyInfo\Extractor\ReflectionExtractor; use Symfony\Component\Serializer\Exception\LogicException; use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; +use Symfony\Component\TypeInfo\Type; final class DocumentNormalizerTest extends TestCase { @@ -85,6 +90,41 @@ public function testDenormalize(): void self::assertEquals($expectedFoo, $normalizer->denormalize($document, Foo::class, DocumentNormalizer::FORMAT)); } + public function testDenormalizeCoercesIdentifierToDeclaredType(): void + { + $document = [ + '_index' => 'test', + '_type' => '_doc', + '_id' => '1', + '_version' => 1, + 'found' => true, + '_source' => [ + 'name' => 'Caroline', + ], + ]; + + $resourceMetadataFactory = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataFactory->create(IntegerIdentified::class)->willReturn(new ResourceMetadataCollection(IntegerIdentified::class, [(new ApiResource())->withOperations(new Operations([new Get()]))])); + + $propertyMetadataFactory = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactory->create(IntegerIdentified::class, 'id')->willReturn((new ApiProperty())->withNativeType(Type::nullable(Type::int()))); + + // property_info is wired in production (elasticsearch.php), which makes the inner + // ObjectNormalizer enforce the declared "int" type on the id property: a raw string + // "_id" would be rejected. + $normalizer = new DocumentNormalizer( + $resourceMetadataFactory->reveal(), + propertyTypeExtractor: new ReflectionExtractor(), + propertyMetadataFactory: $propertyMetadataFactory->reveal(), + ); + + $item = $normalizer->denormalize($document, IntegerIdentified::class, DocumentNormalizer::FORMAT); + + self::assertInstanceOf(IntegerIdentified::class, $item); + self::assertSame(1, $item->id); + self::assertSame('Caroline', $item->name); + } + public function testSupportsNormalization(): void { $itemNormalizer = new DocumentNormalizer($this->prophesize(ResourceMetadataCollectionFactoryInterface::class)->reveal()); diff --git a/src/Symfony/Bundle/Resources/config/elasticsearch.php b/src/Symfony/Bundle/Resources/config/elasticsearch.php index 212fa22343b..04cdda7d736 100644 --- a/src/Symfony/Bundle/Resources/config/elasticsearch.php +++ b/src/Symfony/Bundle/Resources/config/elasticsearch.php @@ -46,6 +46,7 @@ service('serializer.mapping.class_discriminator_resolver')->ignoreOnInvalid(), null, '%api_platform.serializer.default_context%', + service('api_platform.metadata.property.metadata_factory'), ]) ->tag('serializer.normalizer', ['priority' => -922]);