Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 26 additions & 3 deletions src/Elasticsearch/Serializer/DocumentNormalizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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.
Expand All @@ -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);
}
Expand Down Expand Up @@ -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}
*/
Expand Down
27 changes: 27 additions & 0 deletions src/Elasticsearch/Tests/Fixtures/IntegerIdentified.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* 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;
}
40 changes: 40 additions & 0 deletions src/Elasticsearch/Tests/Serializer/DocumentNormalizerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down Expand Up @@ -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());
Expand Down
1 change: 1 addition & 0 deletions src/Symfony/Bundle/Resources/config/elasticsearch.php
Original file line number Diff line number Diff line change
Expand Up @@ -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]);

Expand Down
Loading