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 Sep 27, 2024
1 parent 2b4937a commit ed1ed40
Show file tree
Hide file tree
Showing 12 changed files with 368 additions and 21 deletions.
13 changes: 11 additions & 2 deletions src/Laravel/ApiPlatformProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@
use ApiPlatform\Serializer\ItemNormalizer;
use ApiPlatform\Serializer\JsonEncoder;
use ApiPlatform\Serializer\Mapping\Factory\ClassMetadataFactory as SerializerClassMetadataFactory;
use ApiPlatform\Serializer\Mapping\Loader\PropertyMetadataLoader;
use ApiPlatform\Serializer\Parameter\SerializerFilterParameterProvider;
use ApiPlatform\Serializer\SerializerContextBuilder;
use ApiPlatform\State\CallableProcessor;
Expand Down Expand Up @@ -206,6 +207,7 @@
use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactory;
use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface;
use Symfony\Component\Serializer\Mapping\Loader\AttributeLoader;
use Symfony\Component\Serializer\Mapping\Loader\LoaderChain;
use Symfony\Component\Serializer\Mapping\Loader\LoaderInterface;
use Symfony\Component\Serializer\NameConverter\CamelCaseToSnakeCaseNameConverter;
use Symfony\Component\Serializer\NameConverter\MetadataAwareNameConverter;
Expand Down Expand Up @@ -244,8 +246,15 @@ public function register(): void

$this->app->bind(LoaderInterface::class, AttributeLoader::class);
$this->app->bind(ClassMetadataFactoryInterface::class, ClassMetadataFactory::class);
$this->app->singleton(ClassMetadataFactory::class, function () {
return new ClassMetadataFactory(new AttributeLoader());
$this->app->singleton(ClassMetadataFactory::class, function (Application $app) {
return new ClassMetadataFactory(
new LoaderChain([
new PropertyMetadataLoader(
$app->make(PropertyNameCollectionFactoryInterface::class),
),
new AttributeLoader(),
])
);
});

$this->app->singleton(SerializerClassMetadataFactory::class, function (Application $app) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,12 @@ public function create(string $resourceClass, array $options = []): PropertyName
return $this->decorated?->create($resourceClass, $options) ?? new PropertyNameCollection();
}

$refl = new \ReflectionClass($resourceClass);
try {
$refl = new \ReflectionClass($resourceClass);
if ($refl->isAbstract()) {
return $this->decorated?->create($resourceClass, $options) ?? new PropertyNameCollection();
}

$model = $refl->newInstanceWithoutConstructor();
} catch (\ReflectionException) {
return $this->decorated?->create($resourceClass, $options) ?? new PropertyNameCollection();
Expand Down
9 changes: 9 additions & 0 deletions src/Laravel/Tests/JsonApiTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -188,4 +188,13 @@ public function testDeleteBook(): void
$response->assertStatus(204);
$this->assertNull(Book::find($book->id));
}

public function testRelationWithGroups(): void
{
$response = $this->get('/api/with_accessors/1', ['accept' => 'application/vnd.api+json']);
$content = $response->json();
$this->assertArrayHasKey('relationships', $content);
$this->assertArrayHasKey('relation', $content['relationships']);
$this->assertArrayHasKey('data', $content['relationships']['relation']);
}
}
8 changes: 8 additions & 0 deletions src/Laravel/Tests/JsonLdTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -298,4 +298,12 @@ public function testError(): void
$content = $response->json();
$this->assertArrayHasKey('trace', $content);
}

public function testRelationWithGroups(): void
{
$response = $this->get('/api/with_accessors/1', ['accept' => 'application/ld+json']);
$content = $response->json();
$this->assertArrayHasKey('relation', $content);
$this->assertArrayHasKey('name', $content['relation']);
}
}
11 changes: 10 additions & 1 deletion src/Laravel/workbench/app/Models/WithAccessor.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,18 +13,27 @@

namespace Workbench\App\Models;

use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Symfony\Component\Serializer\Attribute\Groups;

#[ApiResource]
#[ApiResource(normalizationContext: ['groups' => ['read']])]
class WithAccessor extends Model
{
use HasFactory;

protected $hidden = ['created_at', 'updated_at', 'id'];

#[ApiProperty(serialize: [new Groups(['read'])])]
public function relation(): BelongsTo
{
return $this->belongsTo(WithAccessorRelation::class);
}

protected function name(): Attribute
{
return Attribute::make(
Expand Down
26 changes: 26 additions & 0 deletions src/Laravel/workbench/app/Models/WithAccessorRelation.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 <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 Workbench\App\Models;

use ApiPlatform\Metadata\ApiResource;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Symfony\Component\Serializer\Attribute\Groups;

#[Groups(['read'])]
#[ApiResource(operations: [])]
class WithAccessorRelation extends Model
{
use HasFactory;
}
58 changes: 41 additions & 17 deletions src/Metadata/ApiProperty.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@
namespace ApiPlatform\Metadata;

use Symfony\Component\PropertyInfo\Type;
use Symfony\Component\Serializer\Attribute\Context;
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;

/**
* ApiProperty annotation.
Expand All @@ -24,23 +30,24 @@
final class ApiProperty
{
/**
* @param bool|null $readableLink https://api-platform.com/docs/core/serialization/#force-iri-with-relations-of-the-same-type-parentchilds-relations
* @param bool|null $writableLink https://api-platform.com/docs/core/serialization/#force-iri-with-relations-of-the-same-type-parentchilds-relations
* @param bool|null $required https://api-platform.com/docs/admin/validation/#client-side-validation
* @param bool|null $identifier https://api-platform.com/docs/core/identifiers/
* @param mixed $example https://api-platform.com/docs/core/openapi/#using-the-openapi-and-swagger-contexts
* @param string|null $deprecationReason https://api-platform.com/docs/core/deprecations/#deprecating-resource-classes-operations-and-properties
* @param bool|null $fetchEager https://api-platform.com/docs/core/performance/#eager-loading
* @param array|null $jsonldContext https://api-platform.com/docs/core/extending-jsonld-context/#extending-json-ld-and-hydra-contexts
* @param array|null $openapiContext https://api-platform.com/docs/core/openapi/#using-the-openapi-and-swagger-contexts
* @param bool|null $push https://api-platform.com/docs/core/push-relations/
* @param string|\Stringable|null $security https://api-platform.com/docs/core/security
* @param string|\Stringable|null $securityPostDenormalize https://api-platform.com/docs/core/security/#executing-access-control-rules-after-denormalization
* @param string[] $types the RDF types of this property
* @param string[] $iris
* @param Type[] $builtinTypes
* @param string|null $uriTemplate (experimental) whether to return the subRessource collection IRI instead of an iterable of IRI
* @param string|null $property The property name
* @param bool|null $readableLink https://api-platform.com/docs/core/serialization/#force-iri-with-relations-of-the-same-type-parentchilds-relations
* @param bool|null $writableLink https://api-platform.com/docs/core/serialization/#force-iri-with-relations-of-the-same-type-parentchilds-relations
* @param bool|null $required https://api-platform.com/docs/admin/validation/#client-side-validation
* @param bool|null $identifier https://api-platform.com/docs/core/identifiers/
* @param mixed $example https://api-platform.com/docs/core/openapi/#using-the-openapi-and-swagger-contexts
* @param string|null $deprecationReason https://api-platform.com/docs/core/deprecations/#deprecating-resource-classes-operations-and-properties
* @param bool|null $fetchEager https://api-platform.com/docs/core/performance/#eager-loading
* @param array|null $jsonldContext https://api-platform.com/docs/core/extending-jsonld-context/#extending-json-ld-and-hydra-contexts
* @param array|null $openapiContext https://api-platform.com/docs/core/openapi/#using-the-openapi-and-swagger-contexts
* @param bool|null $push https://api-platform.com/docs/core/push-relations/
* @param string|\Stringable|null $security https://api-platform.com/docs/core/security
* @param string|\Stringable|null $securityPostDenormalize https://api-platform.com/docs/core/security/#executing-access-control-rules-after-denormalization
* @param string[] $types the RDF types of this property
* @param string[] $iris
* @param Type[] $builtinTypes
* @param string|null $uriTemplate (experimental) whether to return the subRessource collection IRI instead of an iterable of IRI
* @param string|null $property The property name
* @param array<int, Groups|SerializedName|SerializedPath|MaxDepth|Ignore|Context> $serialize
*/
public function __construct(
private ?string $description = null,
Expand Down Expand Up @@ -205,6 +212,7 @@ public function __construct(
private ?string $uriTemplate = null,
private ?string $property = null,
private ?string $policy = null,
private ?array $serialize = null,
private array $extraProperties = [],
) {
if (\is_string($types)) {
Expand Down Expand Up @@ -600,4 +608,20 @@ public function withPolicy(?string $policy): static

return $self;
}

public function getSerialize(): ?array
{
return $this->serialize;
}

/**
* @param array<int, Groups|SerializedName|SerializedPath|MaxDepth|Ignore|Context> $serialize
*/
public function withSerialize(array $serialize): static
{
$self = clone $this;
$self->serialize = $serialize;

return $self;
}
}
159 changes: 159 additions & 0 deletions src/Serializer/Mapping/Loader/PropertyMetadataLoader.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
<?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\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();
$refl = $classMetadata->getReflectionClass();
$attributes = [];
$classGroups = [];
$classContextAnnotation = null;

// It's very weird to grab an 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;
}

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;
}

foreach ($attributes[$propertyName] as $attr) {
if ($attr instanceof Groups) {
foreach ($attr->getGroups() as $group) {
$attributesMetadata[$propertyName]->addGroup($group);
}
} elseif ($attr instanceof MaxDepth) {
$attributesMetadata[$propertyName]->setMaxDepth($attr->getMaxDepth());
} elseif ($attr instanceof SerializedName) {
$attributesMetadata[$propertyName]->setSerializedName($attr->getSerializedName());
} elseif ($attr instanceof SerializedPath) {
$attributesMetadata[$propertyName]->setSerializedPath($attr->getSerializedPath());
} elseif ($attr instanceof Ignore) {
$attributesMetadata[$propertyName]->setIgnore(true);
} elseif ($attr instanceof Context) {
$this->setAttributeContextsForGroups($attr, $attributesMetadata[$propertyName]);
}
}
}

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
{
if ($annotation->getContext()) {
$attributeMetadata->setNormalizationContextForGroups($annotation->getContext(), $annotation->getGroups());
$attributeMetadata->setDenormalizationContextForGroups($annotation->getContext(), $annotation->getGroups());
}

if ($annotation->getNormalizationContext()) {
$attributeMetadata->setNormalizationContextForGroups($annotation->getNormalizationContext(), $annotation->getGroups());
}

if ($annotation->getDenormalizationContext()) {
$attributeMetadata->setDenormalizationContextForGroups($annotation->getDenormalizationContext(), $annotation->getGroups());
}
}
}
Loading

0 comments on commit ed1ed40

Please sign in to comment.