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
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
<?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\Doctrine\Common\Metadata\Property;

use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
use ApiPlatform\Metadata\ResourceClassResolverInterface;
use ApiPlatform\Metadata\Util\ResourceClassInfoTrait;
use Doctrine\Persistence\ManagerRegistry;
use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface as SerializerClassMetadataFactoryInterface;

/**
* Makes the serializer groups declared on Doctrine inheritance subclasses (JOINED / SINGLE_TABLE)
* count when deciding whether a relation should be embedded.
*
* The link status of a relation is computed by the serializer property metadata factory from the
* groups declared on the *related* resource class. For a Doctrine inheritance hierarchy the related
* class is the abstract parent, so groups declared only on a discriminator subclass are never seen
* and the relation is emitted as an IRI instead of being embedded. This decorator augments the link
* status using the discriminator map exposed by the Doctrine ClassMetadata, which only the doctrine
* layer has access to.
*
* It runs before the serializer property metadata factory: it sets readableLink/writableLink to true
* when a subclass declares a matching group and leaves them untouched (null) otherwise, so the
* serializer factory keeps its default behavior for every other case.
*
* @author Antoine Bluchet <soyuka@gmail.com>
*/
final class DoctrineDiscriminatorSerializerPropertyMetadataFactory implements PropertyMetadataFactoryInterface
{
use ResourceClassInfoTrait;

public function __construct(
private readonly ManagerRegistry $managerRegistry,
private readonly PropertyMetadataFactoryInterface $decorated,
private readonly ?SerializerClassMetadataFactoryInterface $serializerClassMetadataFactory = null,
?ResourceClassResolverInterface $resourceClassResolver = null,
) {
$this->resourceClassResolver = $resourceClassResolver;
}

public function create(string $resourceClass, string $property, array $options = []): ApiProperty
{
$propertyMetadata = $this->decorated->create($resourceClass, $property, $options);

if (null === $this->serializerClassMetadataFactory || null === $this->resourceClassResolver) {
return $propertyMetadata;
}

// The link status is only meaningful once at least one serializer group is involved.
[$normalizationGroups, $denormalizationGroups] = $this->getEffectiveSerializerGroups($options);
if (null === $normalizationGroups && null === $denormalizationGroups) {
return $propertyMetadata;
}

// Only act when the serializer factory has not already decided to embed the relation.
if (true === $propertyMetadata->isReadableLink() && true === $propertyMetadata->isWritableLink()) {
return $propertyMetadata;
}

$relatedClass = $this->getClassNameFromProperty($propertyMetadata);
if (null === $relatedClass || !$this->isResourceClass($relatedClass)) {
return $propertyMetadata;
}

$relatedClass = $this->resourceClassResolver->getResourceClass(null, $relatedClass);

$subclasses = $this->getDiscriminatorSubclasses($relatedClass);
if (!$subclasses) {
return $propertyMetadata;
}

$subclassGroups = [];
foreach ($subclasses as $subclass) {
$subclassGroups[] = $this->getClassSerializerGroups($subclass);
}
$subclassGroups = array_unique(array_merge(...$subclassGroups));

if (!$subclassGroups) {
return $propertyMetadata;
}

if (null === $propertyMetadata->isReadableLink() && null !== $normalizationGroups && array_intersect($normalizationGroups, $subclassGroups)) {
$propertyMetadata = $propertyMetadata->withReadableLink(true);
}

if (null === $propertyMetadata->isWritableLink() && null !== $denormalizationGroups && array_intersect($denormalizationGroups, $subclassGroups)) {
$propertyMetadata = $propertyMetadata->withWritableLink(true);
}

return $propertyMetadata;
}

/**
* @return class-string[]
*/
private function getDiscriminatorSubclasses(string $resourceClass): array
{
$manager = $this->managerRegistry->getManagerForClass($resourceClass);
if (null === $manager) {
return [];
}

$classMetadata = $manager->getClassMetadata($resourceClass);
if (!isset($classMetadata->discriminatorMap) || !\is_array($classMetadata->discriminatorMap)) {
return [];
}

return array_values(array_filter(
$classMetadata->discriminatorMap,
static fn (string $class): bool => $class !== $resourceClass,
));
}

/**
* @return array{0: string[]|null, 1: string[]|null}
*/
private function getEffectiveSerializerGroups(array $options): array
{
if (isset($options['serializer_groups'])) {
$groups = (array) $options['serializer_groups'];

return [$groups, $groups];
}

if (\array_key_exists('normalization_groups', $options) && \array_key_exists('denormalization_groups', $options)) {
return [
null !== $options['normalization_groups'] ? (array) $options['normalization_groups'] : null,
null !== $options['denormalization_groups'] ? (array) $options['denormalization_groups'] : null,
];
}

return [null, null];
}

/**
* @return string[]
*/
private function getClassSerializerGroups(string $class): array
{
try {
$serializerClassMetadata = $this->serializerClassMetadataFactory->getMetadataFor($class);
} catch (\InvalidArgumentException) {
return [];
}

$groups = [];
foreach ($serializerClassMetadata->getAttributesMetadata() as $serializerAttributeMetadata) {
$groups[] = $serializerAttributeMetadata->getGroups();
}

return $groups ? array_unique(array_merge(...$groups)) : [];
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
<?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\Doctrine\Common\Tests\Metadata\Property;

use ApiPlatform\Doctrine\Common\Metadata\Property\DoctrineDiscriminatorSerializerPropertyMetadataFactory;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
use ApiPlatform\Metadata\ResourceClassResolverInterface;
use Doctrine\Persistence\ManagerRegistry;
use Doctrine\Persistence\Mapping\ClassMetadata;
use Doctrine\Persistence\ObjectManager;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Symfony\Component\Serializer\Mapping\AttributeMetadata as SerializerAttributeMetadata;
use Symfony\Component\Serializer\Mapping\ClassMetadata as SerializerClassMetadata;
use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface as SerializerClassMetadataFactoryInterface;
use Symfony\Component\TypeInfo\Type;

final class DoctrineDiscriminatorSerializerPropertyMetadataFactoryTest extends TestCase
{
use ProphecyTrait;

public function testItEnablesReadableLinkWhenADiscriminatorSubclassDeclaresAMatchingGroup(): void
{
$factory = $this->createFactory(
propertyMetadata: (new ApiProperty())->withReadable(true)->withNativeType(Type::object(ParentResource::class)),
discriminatorMap: ['a' => ChildA::class, 'b' => ChildB::class],
subclassGroups: [ChildA::class => ['foo'], ChildB::class => ['foo']],
);

$result = $factory->create(OwnerResource::class, 'relation', [
'normalization_groups' => ['foo'],
'denormalization_groups' => null,
]);

$this->assertTrue($result->isReadableLink());
}

public function testItLeavesLinkStatusUntouchedWhenNoSubclassGroupMatches(): void
{
$factory = $this->createFactory(
propertyMetadata: (new ApiProperty())->withReadable(true)->withNativeType(Type::object(ParentResource::class)),
discriminatorMap: ['a' => ChildA::class],
subclassGroups: [ChildA::class => ['bar']],
);

$result = $factory->create(OwnerResource::class, 'relation', [
'normalization_groups' => ['foo'],
'denormalization_groups' => null,
]);

$this->assertNull($result->isReadableLink());
}

public function testItLeavesLinkStatusUntouchedWithoutADiscriminatorMap(): void
{
$factory = $this->createFactory(
propertyMetadata: (new ApiProperty())->withReadable(true)->withNativeType(Type::object(ParentResource::class)),
discriminatorMap: [],
subclassGroups: [],
);

$result = $factory->create(OwnerResource::class, 'relation', [
'normalization_groups' => ['foo'],
'denormalization_groups' => null,
]);

$this->assertNull($result->isReadableLink());
}

public function testItDoesNotDowngradeAnAlreadyEmbeddedRelation(): void
{
$factory = $this->createFactory(
propertyMetadata: (new ApiProperty())->withReadable(true)->withReadableLink(true)->withWritableLink(true)->withNativeType(Type::object(ParentResource::class)),
discriminatorMap: ['a' => ChildA::class],
subclassGroups: [ChildA::class => ['bar']],
);

$result = $factory->create(OwnerResource::class, 'relation', [
'normalization_groups' => ['foo'],
'denormalization_groups' => null,
]);

$this->assertTrue($result->isReadableLink());
}

/**
* @param array<string, class-string> $discriminatorMap
* @param array<class-string, string[]> $subclassGroups
*/
private function createFactory(ApiProperty $propertyMetadata, array $discriminatorMap, array $subclassGroups): DoctrineDiscriminatorSerializerPropertyMetadataFactory
{
$decorated = $this->prophesize(PropertyMetadataFactoryInterface::class);
$decorated->create(OwnerResource::class, 'relation', Argument::any())->willReturn($propertyMetadata);

$resourceClassResolver = $this->prophesize(ResourceClassResolverInterface::class);
$resourceClassResolver->isResourceClass(ParentResource::class)->willReturn(true);
$resourceClassResolver->getResourceClass(null, ParentResource::class)->willReturn(ParentResource::class);

$classMetadata = $this->prophesize(ClassMetadata::class);
$classMetadata->discriminatorMap = $discriminatorMap;

$objectManager = $this->prophesize(ObjectManager::class);
$objectManager->getClassMetadata(ParentResource::class)->willReturn($classMetadata->reveal());

$managerRegistry = $this->prophesize(ManagerRegistry::class);
$managerRegistry->getManagerForClass(ParentResource::class)->willReturn($objectManager->reveal());

$serializerClassMetadataFactory = $this->prophesize(SerializerClassMetadataFactoryInterface::class);
foreach ($subclassGroups as $class => $groups) {
$serializerClassMetadata = new SerializerClassMetadata($class);
foreach ($groups as $i => $group) {
$attribute = new SerializerAttributeMetadata('prop'.$i);
$attribute->addGroup($group);
$serializerClassMetadata->addAttributeMetadata($attribute);
}
$serializerClassMetadataFactory->getMetadataFor($class)->willReturn($serializerClassMetadata);
}

return new DoctrineDiscriminatorSerializerPropertyMetadataFactory(
$managerRegistry->reveal(),
$decorated->reveal(),
$serializerClassMetadataFactory->reveal(),
$resourceClassResolver->reveal(),
);
}
}

class OwnerResource
{
}

class ParentResource
{
}

class ChildA extends ParentResource
{
}

class ChildB extends ParentResource
{
}
10 changes: 10 additions & 0 deletions src/Symfony/Bundle/Resources/config/doctrine_mongodb_odm.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

namespace Symfony\Component\DependencyInjection\Loader\Configurator;

use ApiPlatform\Doctrine\Common\Metadata\Property\DoctrineDiscriminatorSerializerPropertyMetadataFactory;
use ApiPlatform\Doctrine\Common\State\PersistProcessor;
use ApiPlatform\Doctrine\Common\State\RemoveProcessor;
use ApiPlatform\Doctrine\Odm\Extension\FilterExtension;
Expand Down Expand Up @@ -195,6 +196,15 @@
service('api_platform.doctrine_mongodb.odm.metadata.property.metadata_factory.inner'),
]);

$services->set('api_platform.doctrine_mongodb.odm.metadata.property.metadata_factory.discriminator_serializer', DoctrineDiscriminatorSerializerPropertyMetadataFactory::class)
->decorate('api_platform.metadata.property.metadata_factory', null, 31)
->args([
service('doctrine_mongodb'),
service('api_platform.doctrine_mongodb.odm.metadata.property.metadata_factory.discriminator_serializer.inner'),
service('serializer.mapping.class_metadata_factory')->nullOnInvalid(),
service('api_platform.resource_class_resolver'),
]);

$services->set('api_platform.doctrine_mongodb.odm.state.collection_provider', CollectionProvider::class)
->args([
service('api_platform.metadata.resource.metadata_collection_factory'),
Expand Down
10 changes: 10 additions & 0 deletions src/Symfony/Bundle/Resources/config/doctrine_orm.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

namespace Symfony\Component\DependencyInjection\Loader\Configurator;

use ApiPlatform\Doctrine\Common\Metadata\Property\DoctrineDiscriminatorSerializerPropertyMetadataFactory;
use ApiPlatform\Doctrine\Common\State\PersistProcessor;
use ApiPlatform\Doctrine\Common\State\RemoveProcessor;
use ApiPlatform\Doctrine\Orm\Extension\EagerLoadingExtension;
Expand Down Expand Up @@ -213,6 +214,15 @@
service('api_platform.doctrine.orm.metadata.property.metadata_factory.inner'),
]);

$services->set('api_platform.doctrine.orm.metadata.property.metadata_factory.discriminator_serializer', DoctrineDiscriminatorSerializerPropertyMetadataFactory::class)
->decorate('api_platform.metadata.property.metadata_factory', null, 31)
->args([
service('doctrine'),
service('api_platform.doctrine.orm.metadata.property.metadata_factory.discriminator_serializer.inner'),
service('serializer.mapping.class_metadata_factory')->nullOnInvalid(),
service('api_platform.resource_class_resolver'),
]);

$services->set('api_platform.doctrine.orm.state.collection_provider', CollectionProvider::class)
->args([
service('api_platform.metadata.resource.metadata_collection_factory'),
Expand Down
Loading
Loading