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
69 changes: 15 additions & 54 deletions src/Elasticsearch/Serializer/DocumentNormalizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,37 +13,32 @@

namespace ApiPlatform\Elasticsearch\Serializer;

use ApiPlatform\Metadata\HttpOperation;
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
use Symfony\Component\PropertyInfo\PropertyTypeExtractorInterface;
use Symfony\Component\Serializer\Exception\LogicException;
use Symfony\Component\Serializer\Mapping\ClassDiscriminatorResolverInterface;
use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface;
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
use Symfony\Component\Serializer\SerializerAwareInterface;
use Symfony\Component\Serializer\SerializerInterface;

/**
* Document denormalizer for Elasticsearch.
* Document normalizer for Elasticsearch.
*
* @experimental
*
* @author Baptiste Meyer <baptiste.meyer@gmail.com>
*/
final class DocumentNormalizer implements NormalizerInterface, DenormalizerInterface, SerializerAwareInterface
final class DocumentNormalizer implements NormalizerInterface, SerializerAwareInterface
{
public const FORMAT = 'elasticsearch';

private readonly ObjectNormalizer $decoratedNormalizer;

public function __construct(
private readonly ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory,
?ClassMetadataFactoryInterface $classMetadataFactory = null,
private readonly ?NameConverterInterface $nameConverter = null,
?NameConverterInterface $nameConverter = null,
?PropertyAccessorInterface $propertyAccessor = null,
?PropertyTypeExtractorInterface $propertyTypeExtractor = null,
?ClassDiscriminatorResolverInterface $classDiscriminatorResolver = null,
Expand All @@ -56,66 +51,32 @@ public function __construct(
/**
* {@inheritdoc}
*/
public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool
{
return self::FORMAT === $format && $this->decoratedNormalizer->supportsDenormalization($data, $type, $format, $context);
}

/**
* {@inheritdoc}
*/
public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): mixed
public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool
{
if (\is_string($data['_id'] ?? null) && \is_array($data['_source'] ?? null)) {
$data = $this->populateIdentifier($data, $type)['_source'];
// Ensure that a resource is being normalized
if (!\is_object($data)) {
return false;
}

return $this->decoratedNormalizer->denormalize($data, $type, $format, $context);
}

/**
* {@inheritdoc}
*/
public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool
{
// prevent the use of lower priority normalizers (e.g. serializer.normalizer.object) for this format
// Only normalize for the elasticsearch format
return self::FORMAT === $format;
}

/**
* {@inheritdoc}
*
* @throws LogicException
*/
public function normalize(mixed $data, ?string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null
{
throw new LogicException(\sprintf('%s is a write-only format.', self::FORMAT));
}

/**
* Populates the resource identifier with the document identifier if not present in the original JSON document.
*/
private function populateIdentifier(array $data, string $class): array
{
$identifier = 'id';
$resourceMetadata = $this->resourceMetadataCollectionFactory->create($class);

$operation = $resourceMetadata->getOperation();
if ($operation instanceof HttpOperation) {
$uriVariable = $operation->getUriVariables()[0] ?? null;

if ($uriVariable) {
$identifier = $uriVariable->getIdentifiers()[0] ?? 'id';
}
}

$identifier = null === $this->nameConverter ? $identifier : $this->nameConverter->normalize($identifier, $class, self::FORMAT);
$normalizedData = $this->decoratedNormalizer->normalize($data, $format, $context);

if (!isset($data['_source'][$identifier])) {
$data['_source'][$identifier] = $data['_id'];
// Add _id and _source if not already present
// This is a basic implementation and might need to be more sophisticated based on specific needs.
// It assumes 'id' is the primary identifier for the resource.
if (\is_array($normalizedData) && !isset($normalizedData['_id']) && isset($normalizedData['id'])) {
$normalizedData = ['_id' => (string) $normalizedData['id'], '_source' => $normalizedData];
}

return $data;
return $normalizedData;
}

/**
Expand Down
147 changes: 147 additions & 0 deletions src/Elasticsearch/Serializer/ItemDenormalizer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
<?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\Serializer;

use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\HttpOperation;
use ApiPlatform\Metadata\IriConverterInterface;
use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface;
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
use ApiPlatform\Metadata\ResourceAccessCheckerInterface;
use ApiPlatform\Metadata\ResourceClassResolverInterface;
use ApiPlatform\Serializer\AbstractItemNormalizer;
use ApiPlatform\Serializer\TagCollectorInterface;
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
use Symfony\Component\Serializer\Exception\LogicException;
use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface;
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;

/**
* Item denormalizer for Elasticsearch.
*
* @experimental
*
* @author Baptiste Meyer <baptiste.meyer@gmail.com>
*/
final class ItemDenormalizer extends AbstractItemNormalizer
{
public const FORMAT = 'elasticsearch';

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,
) {
parent::__construct(
$propertyNameCollectionFactory,
$propertyMetadataFactory,
$iriConverter,
$resourceClassResolver,
$propertyAccessor,
$nameConverter,
$classMetadataFactory,
$defaultContext,
$resourceMetadataCollectionFactory,
$resourceAccessChecker,
$tagCollector
);
}

/**
* {@inheritdoc}
*/
public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool
{
return self::FORMAT === $format && parent::supportsDenormalization($data, $type, $format, $context);
}

/**
* {@inheritdoc}
*/
public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): mixed
{
// Handle Elasticsearch document structure with _id and _source
if (\is_array($data) && \is_string($data['_id'] ?? null) && \is_array($data['_source'] ?? null)) {
$data = $this->populateIdentifier($data, $type)['_source'];
}

return parent::denormalize($data, $type, $format, $context);
}

/**
* Populates the resource identifier with the document identifier if not present in the original JSON document.
*/
private function populateIdentifier(array $data, string $class): array
{
$identifier = 'id';
$resourceMetadata = $this->resourceMetadataCollectionFactory->create($class);

$operation = $resourceMetadata->getOperation();
if ($operation instanceof HttpOperation) {
$uriVariable = $operation->getUriVariables()[0] ?? null;

if ($uriVariable) {
$identifier = $uriVariable->getIdentifiers()[0] ?? 'id';
}
}

$identifier = null === $this->nameConverter ? $identifier : $this->nameConverter->normalize($identifier, $class, self::FORMAT);

if (!isset($data['_source'][$identifier])) {
$data['_source'][$identifier] = $data['_id'];
}

return $data;
}

/**
* {@inheritdoc}
*/
public function getSupportedTypes(?string $format): array
{
return self::FORMAT === $format ? ['object' => true] : [];
}

/**
* {@inheritdoc}
*
* For Elasticsearch, we always allow nested documents because that's how ES stores and returns data.
*/
protected function denormalizeRelation(string $attributeName, ApiProperty $propertyMetadata, string $className, mixed $value, ?string $format, array $context): ?object
{
// For Elasticsearch, always allow nested documents
$context['api_allow_update'] = true;

if (!$this->serializer instanceof DenormalizerInterface) {
throw new LogicException(\sprintf('The injected serializer must be an instance of "%s".', DenormalizerInterface::class));
}

$item = $this->serializer->denormalize($value, $className, $format, $context);
if (!\is_object($item) && null !== $item) {
throw new \UnexpectedValueException('Expected item to be an object or null.');
}

return $item;
}
}
105 changes: 43 additions & 62 deletions src/Elasticsearch/Tests/Serializer/DocumentNormalizerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,88 +15,69 @@

use ApiPlatform\Elasticsearch\Serializer\DocumentNormalizer;
use ApiPlatform\Elasticsearch\Tests\Fixtures\Foo;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\Operations;
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
use ApiPlatform\Metadata\Resource\ResourceMetadataCollection;
use PHPUnit\Framework\TestCase;
use Prophecy\PhpUnit\ProphecyTrait;
use Symfony\Component\Serializer\Exception\LogicException;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
use Symfony\Component\Serializer\SerializerAwareInterface;

final class DocumentNormalizerTest extends TestCase
{
use ProphecyTrait;

public function testConstruct(): void
{
$itemNormalizer = new DocumentNormalizer($this->prophesize(ResourceMetadataCollectionFactoryInterface::class)->reveal());
$normalizer = new DocumentNormalizer();

self::assertInstanceOf(DenormalizerInterface::class, $itemNormalizer);
self::assertInstanceOf(NormalizerInterface::class, $itemNormalizer);
self::assertInstanceOf(NormalizerInterface::class, $normalizer);
self::assertInstanceOf(SerializerAwareInterface::class, $normalizer);
}

public function testSupportsDenormalization(): void
public function testSupportsNormalization(): void
{
$document = [
'_index' => 'test',
'_type' => '_doc',
'_id' => '1',
'_version' => 1,
'found' => true,
'_source' => [
'id' => 1,
'name' => 'Caroline',
'bar' => 'Chaverot',
],
];

$itemNormalizer = new DocumentNormalizer($this->prophesize(ResourceMetadataCollectionFactoryInterface::class)->reveal());

self::assertTrue($itemNormalizer->supportsDenormalization($document, Foo::class, DocumentNormalizer::FORMAT));
self::assertFalse($itemNormalizer->supportsDenormalization($document, Foo::class, 'text/coffee'));
$normalizer = new DocumentNormalizer();

self::assertTrue($normalizer->supportsNormalization(new Foo(), DocumentNormalizer::FORMAT));
self::assertFalse($normalizer->supportsNormalization(new Foo(), 'json'));
self::assertFalse($normalizer->supportsNormalization('not an object', DocumentNormalizer::FORMAT));
}

public function testDenormalize(): void
public function testNormalize(): void
{
$document = [
'_index' => 'test',
'_type' => '_doc',
'_id' => '1',
'_version' => 1,
'found' => true,
'_source' => [
'name' => 'Caroline',
'bar' => 'Chaverot',
],
];

$resourceMetadataFactory = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class);
$resourceMetadataFactory->create(Foo::class)->willReturn(new ResourceMetadataCollection(Foo::class, [(new ApiResource())->withOperations(new Operations([new Get()]))]));

$normalizer = new DocumentNormalizer($resourceMetadataFactory->reveal());

$expectedFoo = new Foo();
$expectedFoo->setName('Caroline');
$expectedFoo->setBar('Chaverot');

self::assertEquals($expectedFoo, $normalizer->denormalize($document, Foo::class, DocumentNormalizer::FORMAT));
$normalizer = new DocumentNormalizer();

$foo = new Foo();
$foo->setName('Test');
$foo->setBar('Value');

$result = $normalizer->normalize($foo, DocumentNormalizer::FORMAT);

self::assertIsArray($result);
self::assertSame('Test', $result['name']);
self::assertSame('Value', $result['bar']);
}

public function testSupportsNormalization(): void
public function testNormalizeWithId(): void
{
$itemNormalizer = new DocumentNormalizer($this->prophesize(ResourceMetadataCollectionFactoryInterface::class)->reveal());

self::assertTrue($itemNormalizer->supportsNormalization(new Foo(), DocumentNormalizer::FORMAT));
$normalizer = new DocumentNormalizer();

// Use anonymous class with id to test _id/_source wrapping
$object = new class {
public int $id = 1;
public string $name = 'Test';
};

$result = $normalizer->normalize($object, DocumentNormalizer::FORMAT);

self::assertIsArray($result);
self::assertArrayHasKey('_id', $result);
self::assertArrayHasKey('_source', $result);
self::assertSame('1', $result['_id']);
self::assertSame(1, $result['_source']['id']);
self::assertSame('Test', $result['_source']['name']);
}

public function testNormalize(): void
public function testGetSupportedTypes(): void
{
$this->expectException(LogicException::class);
$this->expectExceptionMessage(\sprintf('%s is a write-only format.', DocumentNormalizer::FORMAT));
$normalizer = new DocumentNormalizer();

(new DocumentNormalizer($this->prophesize(ResourceMetadataCollectionFactoryInterface::class)->reveal()))->normalize(new Foo(), DocumentNormalizer::FORMAT);
self::assertSame(['object' => true], $normalizer->getSupportedTypes(DocumentNormalizer::FORMAT));
self::assertSame([], $normalizer->getSupportedTypes('json'));
}
}
Loading
Loading