Skip to content

Commit 098d527

Browse files
authored
fix(elasticsearch): coerce document _id to declared int identifier type (#8296)
Fixes #8010
1 parent cc021e4 commit 098d527

4 files changed

Lines changed: 94 additions & 3 deletions

File tree

src/Elasticsearch/Serializer/DocumentNormalizer.php

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
namespace ApiPlatform\Elasticsearch\Serializer;
1515

1616
use ApiPlatform\Metadata\HttpOperation;
17+
use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
1718
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
1819
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
1920
use Symfony\Component\PropertyInfo\PropertyTypeExtractorInterface;
@@ -26,6 +27,7 @@
2627
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
2728
use Symfony\Component\Serializer\SerializerAwareInterface;
2829
use Symfony\Component\Serializer\SerializerInterface;
30+
use Symfony\Component\TypeInfo\TypeIdentifier;
2931

3032
/**
3133
* Document denormalizer for Elasticsearch.
@@ -49,6 +51,7 @@ public function __construct(
4951
?ClassDiscriminatorResolverInterface $classDiscriminatorResolver = null,
5052
?callable $objectClassResolver = null,
5153
array $defaultContext = [],
54+
private readonly ?PropertyMetadataFactoryInterface $propertyMetadataFactory = null,
5255
) {
5356
$this->decoratedNormalizer = new ObjectNormalizer($classMetadataFactory, $nameConverter, $propertyAccessor, $propertyTypeExtractor, $classDiscriminatorResolver, $objectClassResolver, $defaultContext);
5457
}
@@ -109,15 +112,35 @@ private function populateIdentifier(array $data, string $class): array
109112
}
110113
}
111114

112-
$identifier = null === $this->nameConverter ? $identifier : $this->nameConverter->normalize($identifier, $class, self::FORMAT);
115+
$sourceKey = null === $this->nameConverter ? $identifier : $this->nameConverter->normalize($identifier, $class, self::FORMAT);
113116

114-
if (!isset($data['_source'][$identifier])) {
115-
$data['_source'][$identifier] = $data['_id'];
117+
if (!isset($data['_source'][$sourceKey])) {
118+
$data['_source'][$sourceKey] = $this->coerceIdentifier($class, $identifier, $data['_id']);
116119
}
117120

118121
return $data;
119122
}
120123

124+
/**
125+
* Elasticsearch always exposes the document identifier (`_id`) as a string. When the resource
126+
* identifier is declared as an int, casting it back avoids a type mismatch in the inner
127+
* ObjectNormalizer. String identifiers (e.g. UUIDs) are left untouched.
128+
*/
129+
private function coerceIdentifier(string $class, string $identifier, string $value): int|string
130+
{
131+
if (null === $this->propertyMetadataFactory || !is_numeric($value)) {
132+
return $value;
133+
}
134+
135+
$nativeType = $this->propertyMetadataFactory->create($class, $identifier)->getNativeType();
136+
137+
if ($nativeType?->isIdentifiedBy(TypeIdentifier::INT) && !$nativeType->isIdentifiedBy(TypeIdentifier::STRING)) {
138+
return (int) $value;
139+
}
140+
141+
return $value;
142+
}
143+
121144
/**
122145
* {@inheritdoc}
123146
*/
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
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+
14+
namespace ApiPlatform\Elasticsearch\Tests\Fixtures;
15+
16+
use ApiPlatform\Metadata\ApiProperty;
17+
use ApiPlatform\Metadata\ApiResource;
18+
use ApiPlatform\Metadata\Get;
19+
20+
#[ApiResource(operations: [new Get()])]
21+
class IntegerIdentified
22+
{
23+
#[ApiProperty(identifier: true)]
24+
public ?int $id = null;
25+
26+
public ?string $name = null;
27+
}

src/Elasticsearch/Tests/Serializer/DocumentNormalizerTest.php

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,21 @@
1515

1616
use ApiPlatform\Elasticsearch\Serializer\DocumentNormalizer;
1717
use ApiPlatform\Elasticsearch\Tests\Fixtures\Foo;
18+
use ApiPlatform\Elasticsearch\Tests\Fixtures\IntegerIdentified;
19+
use ApiPlatform\Metadata\ApiProperty;
1820
use ApiPlatform\Metadata\ApiResource;
1921
use ApiPlatform\Metadata\Get;
2022
use ApiPlatform\Metadata\Operations;
23+
use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
2124
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
2225
use ApiPlatform\Metadata\Resource\ResourceMetadataCollection;
2326
use PHPUnit\Framework\TestCase;
2427
use Prophecy\PhpUnit\ProphecyTrait;
28+
use Symfony\Component\PropertyInfo\Extractor\ReflectionExtractor;
2529
use Symfony\Component\Serializer\Exception\LogicException;
2630
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
2731
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
32+
use Symfony\Component\TypeInfo\Type;
2833

2934
final class DocumentNormalizerTest extends TestCase
3035
{
@@ -85,6 +90,41 @@ public function testDenormalize(): void
8590
self::assertEquals($expectedFoo, $normalizer->denormalize($document, Foo::class, DocumentNormalizer::FORMAT));
8691
}
8792

93+
public function testDenormalizeCoercesIdentifierToDeclaredType(): void
94+
{
95+
$document = [
96+
'_index' => 'test',
97+
'_type' => '_doc',
98+
'_id' => '1',
99+
'_version' => 1,
100+
'found' => true,
101+
'_source' => [
102+
'name' => 'Caroline',
103+
],
104+
];
105+
106+
$resourceMetadataFactory = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class);
107+
$resourceMetadataFactory->create(IntegerIdentified::class)->willReturn(new ResourceMetadataCollection(IntegerIdentified::class, [(new ApiResource())->withOperations(new Operations([new Get()]))]));
108+
109+
$propertyMetadataFactory = $this->prophesize(PropertyMetadataFactoryInterface::class);
110+
$propertyMetadataFactory->create(IntegerIdentified::class, 'id')->willReturn((new ApiProperty())->withNativeType(Type::nullable(Type::int())));
111+
112+
// property_info is wired in production (elasticsearch.php), which makes the inner
113+
// ObjectNormalizer enforce the declared "int" type on the id property: a raw string
114+
// "_id" would be rejected.
115+
$normalizer = new DocumentNormalizer(
116+
$resourceMetadataFactory->reveal(),
117+
propertyTypeExtractor: new ReflectionExtractor(),
118+
propertyMetadataFactory: $propertyMetadataFactory->reveal(),
119+
);
120+
121+
$item = $normalizer->denormalize($document, IntegerIdentified::class, DocumentNormalizer::FORMAT);
122+
123+
self::assertInstanceOf(IntegerIdentified::class, $item);
124+
self::assertSame(1, $item->id);
125+
self::assertSame('Caroline', $item->name);
126+
}
127+
88128
public function testSupportsNormalization(): void
89129
{
90130
$itemNormalizer = new DocumentNormalizer($this->prophesize(ResourceMetadataCollectionFactoryInterface::class)->reveal());

src/Symfony/Bundle/Resources/config/elasticsearch.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
service('serializer.mapping.class_discriminator_resolver')->ignoreOnInvalid(),
4747
null,
4848
'%api_platform.serializer.default_context%',
49+
service('api_platform.metadata.property.metadata_factory'),
4950
])
5051
->tag('serializer.normalizer', ['priority' => -922]);
5152

0 commit comments

Comments
 (0)