Skip to content

Commit e0b50a7

Browse files
committed
feature #61563 [Serializer] Add #[ExtendsSerializationFor] to declare new serialization attributes for a class (nicolas-grekas)
This PR was merged into the 7.4 branch. Discussion ---------- [Serializer] Add `#[ExtendsSerializationFor]` to declare new serialization attributes for a class | Q | A | ------------- | --- | Branch? | 7.4 | Bug fix? | no | New feature? | yes | Deprecations? | no | Issues | - | License | MIT This PR builds on #61532 It's a sibling of #61545 I propose to add a `#[ExtendsSerializationFor]` attribute that allows adding serialization attributes to another class. This is typically needed for third party classes. For context, Sylius has a nice doc about this: https://docs.sylius.com/the-customization-guide/customizing-serialization-of-api At the moment, the only way to achieve this is by declaring the new attributes in the (hardcoded) `config/serialization/` folder, using either xml or yaml. No attributes. With this PR, one will be able to define those extra serialization attributes using PHP attributes, set on classes that'd mirror the properties/getters of the targeted class. The compiler pass will ensure that all properties/getters declared in these source classes also exist in the target class. (source = the app's class that declares the new serialization attributes; target = the existing class to add serialization attributes to.) ```php #[ExtendsSerializationFor(TargetClass::class)] abstract class SourceClass { #[Groups(['my_app'])] #[SerializedName('fullName')] public string $name = ''; #[Groups(['my_app'])] public string $email = ''; #[Groups(['my_app'])] #[MaxDepth(2)] public Category $category; } ``` (I made the class abstract because it's not supposed to be instantiated - but it's not mandatory.) Here are the basics of how this works: 1. During container compilation, classes marked with `#[ExtendsSerializationFor(Target::class)]` are collected and validated: the container checks that members declared on the source exist on the target. If not, a `MappingException` is thrown. 2. The serializer is configured to map the target to its source classes. 3. At runtime, when loading serialization metadata for the target, attributes (groups, serialized names, max depth, etc.) are read from both the target and its mapped source classes and applied accordingly. Commits ------- 386f949 [Serializer] Add `#[ExtendsSerializationFor]` to declare new serialization attributes for a class
2 parents f603472 + 386f949 commit e0b50a7

File tree

7 files changed

+225
-14
lines changed

7 files changed

+225
-14
lines changed

src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,7 @@
189189
use Symfony\Component\Semaphore\SemaphoreFactory;
190190
use Symfony\Component\Semaphore\Store\StoreFactory as SemaphoreStoreFactory;
191191
use Symfony\Component\Serializer\Attribute as SerializerMapping;
192+
use Symfony\Component\Serializer\Attribute\ExtendsSerializationFor;
192193
use Symfony\Component\Serializer\DependencyInjection\AttributeMetadataPass as SerializerAttributeMetadataPass;
193194
use Symfony\Component\Serializer\Encoder\DecoderInterface;
194195
use Symfony\Component\Serializer\Encoder\EncoderInterface;
@@ -2172,6 +2173,11 @@ private function registerSerializerConfiguration(array $config, ContainerBuilder
21722173
$container->getDefinition('serializer.normalizer.property')->setArgument(5, $defaultContext);
21732174

21742175
$container->setParameter('.serializer.named_serializers', $config['named_serializers'] ?? []);
2176+
2177+
$container->registerAttributeForAutoconfiguration(ExtendsSerializationFor::class, function (ChildDefinition $definition, ExtendsSerializationFor $attribute) {
2178+
$definition->addTag('serializer.attribute_metadata', ['for' => $attribute->class])
2179+
->addTag('container.excluded', ['source' => 'because it\'s a serializer metadata extension']);
2180+
});
21752181
}
21762182

21772183
private function registerJsonStreamerConfiguration(array $config, ContainerBuilder $container, PhpFileLoader $loader): void
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Serializer\Attribute;
13+
14+
/**
15+
* Declares that serialization attributes listed on the current class should be added to the given class.
16+
*
17+
* Classes that use this attribute should contain only properties and methods that
18+
* exist on the target class (not necessarily all of them).
19+
*
20+
* @author Nicolas Grekas <p@tchwork.com>
21+
*/
22+
#[\Attribute(\Attribute::TARGET_CLASS)]
23+
final class ExtendsSerializationFor
24+
{
25+
/**
26+
* @param class-string $class
27+
*/
28+
public function __construct(
29+
public string $class,
30+
) {
31+
}
32+
}

src/Symfony/Component/Serializer/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ CHANGELOG
44
7.4
55
---
66

7+
* Add `#[ExtendsSerializationFor]` to declare new serialization attributes for a class
78
* Add `AttributeMetadataPass` to declare compile-time constraint metadata using attributes
89
* Add `CDATA_WRAPPING_NAME_PATTERN` support to `XmlEncoder`
910
* Add support for `can*()` methods to `AttributeLoader`

src/Symfony/Component/Serializer/DependencyInjection/AttributeMetadataPass.php

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
1515
use Symfony\Component\DependencyInjection\ContainerBuilder;
1616
use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException;
17+
use Symfony\Component\Serializer\Exception\MappingException;
1718

1819
/**
1920
* @author Nicolas Grekas <p@tchwork.com>
@@ -35,14 +36,41 @@ public function process(ContainerBuilder $container): void
3536
if (!$definition->hasTag('container.excluded')) {
3637
throw new InvalidArgumentException(\sprintf('The resource "%s" tagged "serializer.attribute_metadata" is missing the "container.excluded" tag.', $id));
3738
}
38-
$taggedClasses[$resolve($definition->getClass())] = true;
39+
$class = $resolve($definition->getClass());
40+
foreach ($definition->getTag('serializer.attribute_metadata') as $attributes) {
41+
if ($class !== $for = $attributes['for'] ?? $class) {
42+
$this->checkSourceMapsToTarget($container, $class, $for);
43+
}
44+
45+
$taggedClasses[$for][$class] = true;
46+
}
47+
}
48+
49+
if (!$taggedClasses) {
50+
return;
3951
}
4052

4153
ksort($taggedClasses);
4254

43-
if ($taggedClasses) {
44-
$container->getDefinition('serializer.mapping.attribute_loader')
45-
->replaceArgument(1, array_keys($taggedClasses));
55+
$container->getDefinition('serializer.mapping.attribute_loader')
56+
->replaceArgument(1, array_map('array_keys', $taggedClasses));
57+
}
58+
59+
private function checkSourceMapsToTarget(ContainerBuilder $container, string $source, string $target): void
60+
{
61+
$source = $container->getReflectionClass($source);
62+
$target = $container->getReflectionClass($target);
63+
64+
foreach ($source->getProperties() as $p) {
65+
if ($p->class === $source->name && !($target->hasProperty($p->name) && $target->getProperty($p->name)->class === $target->name)) {
66+
throw new MappingException(\sprintf('The property "%s" on "%s" is not present on "%s".', $p->name, $source->name, $target->name));
67+
}
68+
}
69+
70+
foreach ($source->getMethods() as $m) {
71+
if ($m->class === $source->name && !($target->hasMethod($m->name) && $target->getMethod($m->name)->class === $target->name)) {
72+
throw new MappingException(\sprintf('The method "%s" on "%s" is not present on "%s".', $m->name, $source->name, $target->name));
73+
}
4674
}
4775
}
4876
}

src/Symfony/Component/Serializer/Mapping/Loader/AttributeLoader.php

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,8 @@ class AttributeLoader implements LoaderInterface
4444
];
4545

4646
/**
47-
* @param bool|null $allowAnyClass Null is allowed for BC with Symfony <= 6
48-
* @param class-string[] $mappedClasses
47+
* @param bool|null $allowAnyClass Null is allowed for BC with Symfony <= 6
48+
* @param array<class-string, class-string[]> $mappedClasses
4949
*/
5050
public function __construct(
5151
private ?bool $allowAnyClass = true,
@@ -59,16 +59,26 @@ public function __construct(
5959
*/
6060
public function getMappedClasses(): array
6161
{
62-
return $this->mappedClasses;
62+
return array_keys($this->mappedClasses);
6363
}
6464

6565
public function loadClassMetadata(ClassMetadataInterface $classMetadata): bool
6666
{
67-
if (!$this->allowAnyClass && !\in_array($classMetadata->getName(), $this->mappedClasses, true)) {
67+
if (!$sourceClasses = $this->mappedClasses[$classMetadata->getName()] ??= $this->allowAnyClass ? [$classMetadata->getName()] : []) {
6868
return false;
6969
}
7070

71-
$reflectionClass = $classMetadata->getReflectionClass();
71+
$success = false;
72+
foreach ($sourceClasses as $sourceClass) {
73+
$reflectionClass = $classMetadata->getName() === $sourceClass ? $classMetadata->getReflectionClass() : new \ReflectionClass($sourceClass);
74+
$success = $this->doLoadClassMetadata($reflectionClass, $classMetadata) || $success;
75+
}
76+
77+
return $success;
78+
}
79+
80+
public function doLoadClassMetadata(\ReflectionClass $reflectionClass, ClassMetadataInterface $classMetadata): bool
81+
{
7282
$className = $reflectionClass->name;
7383
$loaded = false;
7484
$classGroups = [];

src/Symfony/Component/Serializer/Tests/DependencyInjection/AttributeMetadataPassTest.php

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@
1313

1414
use PHPUnit\Framework\TestCase;
1515
use Symfony\Component\DependencyInjection\ContainerBuilder;
16+
use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException;
1617
use Symfony\Component\Serializer\DependencyInjection\AttributeMetadataPass;
18+
use Symfony\Component\Serializer\Exception\MappingException;
1719
use Symfony\Component\Serializer\Mapping\Loader\AttributeLoader;
1820

1921
class AttributeMetadataPassTest extends TestCase
@@ -68,7 +70,82 @@ public function testProcessWithTaggedServices()
6870
$arguments = $container->getDefinition('serializer.mapping.attribute_loader')->getArguments();
6971

7072
// Classes should be sorted alphabetically
71-
$expectedClasses = ['App\Entity\Order', 'App\Entity\Product', 'App\Entity\User'];
73+
$expectedClasses = [
74+
'App\Entity\Order' => ['App\Entity\Order'],
75+
'App\Entity\Product' => ['App\Entity\Product'],
76+
'App\Entity\User' => ['App\Entity\User'],
77+
];
7278
$this->assertSame([false, $expectedClasses], $arguments);
7379
}
80+
81+
public function testThrowsWhenMissingExcludedTag()
82+
{
83+
$container = new ContainerBuilder();
84+
$container->register('serializer.mapping.attribute_loader');
85+
86+
$container->register('service_without_excluded', 'App\\Entity\\User')
87+
->addTag('serializer.attribute_metadata');
88+
89+
$this->expectException(InvalidArgumentException::class);
90+
(new AttributeMetadataPass())->process($container);
91+
}
92+
93+
public function testProcessWithForOptionAndMatchingMembers()
94+
{
95+
$sourceClass = _AttrMeta_Source::class;
96+
$targetClass = _AttrMeta_Target::class;
97+
98+
$container = new ContainerBuilder();
99+
$container->register('serializer.mapping.attribute_loader', AttributeLoader::class)
100+
->setArguments([false, []]);
101+
102+
$container->register('service.source', $sourceClass)
103+
->addTag('serializer.attribute_metadata', ['for' => $targetClass])
104+
->addTag('container.excluded');
105+
106+
(new AttributeMetadataPass())->process($container);
107+
108+
$arguments = $container->getDefinition('serializer.mapping.attribute_loader')->getArguments();
109+
$this->assertSame([false, [$targetClass => [$sourceClass]]], $arguments);
110+
}
111+
112+
public function testProcessWithForOptionAndMissingMemberThrows()
113+
{
114+
$sourceClass = _AttrMeta_BadSource::class;
115+
$targetClass = _AttrMeta_Target::class;
116+
117+
$container = new ContainerBuilder();
118+
$container->register('serializer.mapping.attribute_loader', AttributeLoader::class)
119+
->setArguments([false, []]);
120+
121+
$container->register('service.source', $sourceClass)
122+
->addTag('serializer.attribute_metadata', ['for' => $targetClass])
123+
->addTag('container.excluded');
124+
125+
$this->expectException(MappingException::class);
126+
(new AttributeMetadataPass())->process($container);
127+
}
128+
}
129+
130+
class _AttrMeta_Source
131+
{
132+
public string $name;
133+
134+
public function getName()
135+
{
136+
}
137+
}
138+
139+
class _AttrMeta_Target
140+
{
141+
public string $name;
142+
143+
public function getName()
144+
{
145+
}
146+
}
147+
148+
class _AttrMeta_BadSource
149+
{
150+
public string $extra;
74151
}

src/Symfony/Component/Serializer/Tests/Mapping/Loader/AttributeLoaderTest.php

Lines changed: 61 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -249,31 +249,88 @@ public function testIgnoresAccessorishGetters()
249249

250250
public function testGetMappedClasses()
251251
{
252-
$mappedClasses = ['App\Entity\User', 'App\Entity\Product'];
252+
$mappedClasses = [
253+
'App\Entity\User' => ['App\Entity\User'],
254+
'App\Entity\Product' => ['App\Entity\Product'],
255+
];
253256
$loader = new AttributeLoader(false, $mappedClasses);
254257

255-
$this->assertSame($mappedClasses, $loader->getMappedClasses());
258+
$this->assertSame(['App\Entity\User', 'App\Entity\Product'], $loader->getMappedClasses());
256259
}
257260

258261
public function testLoadClassMetadataReturnsFalseForUnmappedClass()
259262
{
260-
$loader = new AttributeLoader(false, ['App\Entity\User']);
263+
$loader = new AttributeLoader(false, ['App\Entity\User' => ['App\Entity\User']]);
261264
$classMetadata = new ClassMetadata('App\Entity\Product');
262265

263266
$this->assertFalse($loader->loadClassMetadata($classMetadata));
264267
}
265268

266269
public function testLoadClassMetadataForMappedClassWithAttributes()
267270
{
268-
$loader = new AttributeLoader(false, [GroupDummy::class]);
271+
$loader = new AttributeLoader(false, [GroupDummy::class => [GroupDummy::class]]);
269272
$classMetadata = new ClassMetadata(GroupDummy::class);
270273

271274
$this->assertTrue($loader->loadClassMetadata($classMetadata));
272275
$this->assertNotEmpty($classMetadata->getAttributesMetadata());
273276
}
274277

278+
public function testLoadClassMetadataFromExplicitAttributeMappings()
279+
{
280+
$targetClass = _AttrMap_Target::class;
281+
$sourceClass = _AttrMap_Source::class;
282+
283+
$loader = new AttributeLoader(false, [$targetClass => [$sourceClass]]);
284+
$classMetadata = new ClassMetadata($targetClass);
285+
286+
$this->assertTrue($loader->loadClassMetadata($classMetadata));
287+
$this->assertContains('default', $classMetadata->getAttributesMetadata()['name']->getGroups());
288+
}
289+
290+
public function testLoadClassMetadataWithClassLevelAttributes()
291+
{
292+
$targetClass = _AttrMap_Target::class;
293+
$sourceClass = _AttrMap_ClassLevelSource::class;
294+
295+
$loader = new AttributeLoader(false, [$targetClass => [$sourceClass]]);
296+
$classMetadata = new ClassMetadata($targetClass);
297+
298+
$this->assertTrue($loader->loadClassMetadata($classMetadata));
299+
300+
// Check that property attributes are added to the target
301+
$this->assertContains('default', $classMetadata->getAttributesMetadata()['name']->getGroups());
302+
}
303+
275304
protected function getLoaderForContextMapping(): AttributeLoader
276305
{
277306
return $this->loader;
278307
}
279308
}
309+
310+
class _AttrMap_Target
311+
{
312+
public string $name;
313+
314+
public function getName()
315+
{
316+
return $this->name;
317+
}
318+
}
319+
320+
use Symfony\Component\Serializer\Attribute\ExtendsSerializationFor;
321+
use Symfony\Component\Serializer\Attribute\Groups;
322+
323+
#[ExtendsSerializationFor(_AttrMap_Target::class)]
324+
class _AttrMap_Source
325+
{
326+
#[Groups(['default'])]
327+
public string $name;
328+
}
329+
330+
#[ExtendsSerializationFor(_AttrMap_Target::class)]
331+
#[Groups(['class'])]
332+
class _AttrMap_ClassLevelSource
333+
{
334+
#[Groups(['default'])]
335+
public string $name = '';
336+
}

0 commit comments

Comments
 (0)