Skip to content

Commit

Permalink
fix(laravel): allow serializer attributes through ApiProperty
Browse files Browse the repository at this point in the history
  • Loading branch information
soyuka committed Oct 11, 2024
1 parent 924649e commit a88247b
Show file tree
Hide file tree
Showing 4 changed files with 255 additions and 0 deletions.
161 changes: 161 additions & 0 deletions Mapping/Loader/PropertyMetadataLoader.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <[email protected]>
*
* 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\Serializer\Mapping\Loader;

use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface;
use Illuminate\Database\Eloquent\Model;
use Symfony\Component\Serializer\Attribute\Context;
use Symfony\Component\Serializer\Attribute\DiscriminatorMap;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Serializer\Attribute\Ignore;
use Symfony\Component\Serializer\Attribute\MaxDepth;
use Symfony\Component\Serializer\Attribute\SerializedName;
use Symfony\Component\Serializer\Attribute\SerializedPath;
use Symfony\Component\Serializer\Mapping\AttributeMetadata;
use Symfony\Component\Serializer\Mapping\AttributeMetadataInterface;
use Symfony\Component\Serializer\Mapping\ClassDiscriminatorMapping;
use Symfony\Component\Serializer\Mapping\ClassMetadataInterface;
use Symfony\Component\Serializer\Mapping\Loader\LoaderInterface;

/**
* Loader for PHP attributes using ApiProperty.
*/
final class PropertyMetadataLoader implements LoaderInterface
{
public function __construct(private readonly PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory)
{
}

public function loadClassMetadata(ClassMetadataInterface $classMetadata): bool
{
$attributesMetadata = $classMetadata->getAttributesMetadata();
// It's very weird to grab Eloquent's properties in that case as they're never serialized
// the Serializer makes a call on the abstract class, let's save some unneeded work with a condition
if (Model::class === $classMetadata->getName()) {
return false;
}

$refl = $classMetadata->getReflectionClass();
$attributes = [];
$classGroups = [];
$classContextAnnotation = null;

foreach ($refl->getAttributes(ApiProperty::class) as $clAttr) {
$this->addAttributeMetadata($clAttr->newInstance(), $attributes);
}

$attributesMetadata = $classMetadata->getAttributesMetadata();

foreach ($refl->getAttributes() as $a) {
$attribute = $a->newInstance();
if ($attribute instanceof DiscriminatorMap) {
$classMetadata->setClassDiscriminatorMapping(new ClassDiscriminatorMapping(
$attribute->getTypeProperty(),
$attribute->getMapping()
));
continue;
}

if ($attribute instanceof Groups) {
$classGroups = $attribute->getGroups();

continue;
}

if ($attribute instanceof Context) {
$classContextAnnotation = $attribute;
}
}

foreach ($refl->getProperties() as $reflProperty) {
foreach ($reflProperty->getAttributes(ApiProperty::class) as $propAttr) {
$this->addAttributeMetadata($propAttr->newInstance()->withProperty($reflProperty->name), $attributes);
}
}

foreach ($refl->getMethods() as $reflMethod) {
foreach ($reflMethod->getAttributes(ApiProperty::class) as $methodAttr) {
$this->addAttributeMetadata($methodAttr->newInstance()->withProperty($reflMethod->getName()), $attributes);
}
}

foreach ($this->propertyNameCollectionFactory->create($classMetadata->getName()) as $propertyName) {
if (!isset($attributesMetadata[$propertyName])) {
$attributesMetadata[$propertyName] = new AttributeMetadata($propertyName);
$classMetadata->addAttributeMetadata($attributesMetadata[$propertyName]);
}

foreach ($classGroups as $group) {
$attributesMetadata[$propertyName]->addGroup($group);
}

if ($classContextAnnotation) {
$this->setAttributeContextsForGroups($classContextAnnotation, $attributesMetadata[$propertyName]);
}

if (!isset($attributes[$propertyName])) {
continue;
}

$attributeMetadata = $attributesMetadata[$propertyName];

// This code is adapted from Symfony\Component\Serializer\Mapping\Loader\AttributeLoader
foreach ($attributes[$propertyName] as $attr) {
if ($attr instanceof Groups) {
foreach ($attr->getGroups() as $group) {
$attributeMetadata->addGroup($group);
}
continue;
}

match (true) {
$attr instanceof MaxDepth => $attributeMetadata->setMaxDepth($attr->getMaxDepth()),
$attr instanceof SerializedName => $attributeMetadata->setSerializedName($attr->getSerializedName()),
$attr instanceof SerializedPath => $attributeMetadata->setSerializedPath($attr->getSerializedPath()),
$attr instanceof Ignore => $attributeMetadata->setIgnore(true),
$attr instanceof Context => $this->setAttributeContextsForGroups($attr, $attributeMetadata),
default => null,
};
}
}

return true;
}

/**
* @param ApiProperty[] $attributes
*/
private function addAttributeMetadata(ApiProperty $attribute, array &$attributes): void
{
if (($prop = $attribute->getProperty()) && ($value = $attribute->getSerialize())) {
$attributes[$prop] = $value;
}
}

private function setAttributeContextsForGroups(Context $annotation, AttributeMetadataInterface $attributeMetadata): void
{
$context = $annotation->getContext();
$groups = $annotation->getGroups();
$normalizationContext = $annotation->getNormalizationContext();
$denormalizationContext = $annotation->getDenormalizationContext();
if ($normalizationContext || $context) {
$attributeMetadata->setNormalizationContextForGroups($normalizationContext ?: $context, $groups);
}

if ($denormalizationContext || $context) {
$attributeMetadata->setDenormalizationContextForGroups($denormalizationContext ?: $context, $groups);
}
}
}
26 changes: 26 additions & 0 deletions Tests/Fixtures/Model/HasRelation.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <[email protected]>
*
* 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\Serializer\Tests\Fixtures\Model;

use ApiPlatform\Metadata\ApiProperty;
use Symfony\Component\Serializer\Attribute\Groups;

class HasRelation
{
#[ApiProperty(serialize: [new Groups(['read'])])]
public function relation(): Relation
{
return new Relation();
}
}
21 changes: 21 additions & 0 deletions Tests/Fixtures/Model/Relation.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <[email protected]>
*
* 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\Serializer\Tests\Fixtures\Model;

use Symfony\Component\Serializer\Attribute\Groups;

#[Groups(['read'])]
class Relation
{
}
47 changes: 47 additions & 0 deletions Tests/Mapping/Loader/PropertyMetadataLoaderTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <[email protected]>
*
* 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\Serializer\Tests\Mapping\Loader;

use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface;
use ApiPlatform\Metadata\Property\PropertyNameCollection;
use ApiPlatform\Serializer\Mapping\Loader\PropertyMetadataLoader;
use ApiPlatform\Serializer\Tests\Fixtures\Model\HasRelation;
use ApiPlatform\Serializer\Tests\Fixtures\Model\Relation;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Serializer\Mapping\ClassMetadata;

final class PropertyMetadataLoaderTest extends TestCase
{
public function testCreateMappingForASetOfProperties(): void
{
$coll = $this->createStub(PropertyNameCollectionFactoryInterface::class);
$coll->method('create')->willReturn(new PropertyNameCollection(['relation']));
$loader = new PropertyMetadataLoader($coll);
$classMetadata = new ClassMetadata(HasRelation::class);
$loader->loadClassMetadata($classMetadata);
$this->assertArrayHasKey('relation', $classMetadata->attributesMetadata);
$this->assertEquals(['read'], $classMetadata->attributesMetadata['relation']->getGroups());
}

public function testCreateMappingForAClass(): void
{
$coll = $this->createStub(PropertyNameCollectionFactoryInterface::class);
$coll->method('create')->willReturn(new PropertyNameCollection(['name']));
$loader = new PropertyMetadataLoader($coll);
$classMetadata = new ClassMetadata(Relation::class);
$loader->loadClassMetadata($classMetadata);
$this->assertArrayHasKey('name', $classMetadata->attributesMetadata);
$this->assertEquals(['read'], $classMetadata->attributesMetadata['name']->getGroups());
}
}

0 comments on commit a88247b

Please sign in to comment.