• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

api-platform / core / 20528769615

26 Dec 2025 08:17PM UTC coverage: 25.119%. First build
20528769615

Pull #7629

github

web-flow
Merge 4691c25d0 into 38d474d1b
Pull Request #7629: fix: add support for normalization/denormalization with attributes

24 of 236 new or added lines in 8 files covered. (10.17%)

14638 of 58274 relevant lines covered (25.12%)

29.5 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

63.83
/src/Metadata/Property/Factory/SerializerPropertyMetadataFactory.php
1
<?php
2

3
/*
4
 * This file is part of the API Platform project.
5
 *
6
 * (c) Kévin Dunglas <dunglas@gmail.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
declare(strict_types=1);
13

14
namespace ApiPlatform\Metadata\Property\Factory;
15

16
use ApiPlatform\Metadata\ApiProperty;
17
use ApiPlatform\Metadata\Exception\ResourceClassNotFoundException;
18
use ApiPlatform\Metadata\ResourceClassResolverInterface;
19
use ApiPlatform\Metadata\Util\ResourceClassInfoTrait;
20
use ApiPlatform\Metadata\Util\TypeHelper;
21
use Symfony\Component\PropertyInfo\PropertyInfoExtractor;
22
use Symfony\Component\Serializer\Mapping\AttributeMetadataInterface;
23
use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface as SerializerClassMetadataFactoryInterface;
24
use Symfony\Component\TypeInfo\Type;
25
use Symfony\Component\TypeInfo\Type\CollectionType;
26

27
/**
28
 * Populates read/write and link status using serialization groups.
29
 *
30
 * @author Kévin Dunglas <dunglas@gmail.com>
31
 * @author Teoh Han Hui <teohhanhui@gmail.com>
32
 */
33
final class SerializerPropertyMetadataFactory implements PropertyMetadataFactoryInterface
34
{
35
    use ResourceClassInfoTrait;
36

37
    public function __construct(private readonly SerializerClassMetadataFactoryInterface $serializerClassMetadataFactory, private readonly PropertyMetadataFactoryInterface $decorated, ?ResourceClassResolverInterface $resourceClassResolver = null)
38
    {
39
        $this->resourceClassResolver = $resourceClassResolver;
848✔
40
    }
41

42
    /**
43
     * {@inheritdoc}
44
     */
45
    public function create(string $resourceClass, string $property, array $options = []): ApiProperty
46
    {
47
        $propertyMetadata = $this->decorated->create($resourceClass, $property, $options);
255✔
48

49
        try {
50
            [$normalizationGroups, $denormalizationGroups] = $this->getEffectiveSerializerGroups($options);
255✔
51

52
            if ($normalizationGroups && !\is_array($normalizationGroups)) {
255✔
53
                $normalizationGroups = [$normalizationGroups];
×
54
            }
55

56
            if ($denormalizationGroups && !\is_array($denormalizationGroups)) {
255✔
57
                $denormalizationGroups = [$denormalizationGroups];
×
58
            }
59

60
            [$normalizationAttributes, $denormalizationAttributes] = $this->getEffectiveSerializerAttributes($options);
255✔
61

62
            if ($normalizationAttributes && !\is_array($normalizationAttributes)) {
255✔
NEW
63
                $normalizationAttributes = [$normalizationAttributes];
×
64
            }
65

66
            if ($denormalizationAttributes && !\is_array($denormalizationAttributes)) {
255✔
NEW
67
                $denormalizationAttributes = [$denormalizationAttributes];
×
68
            }
69

70
            $ignoredAttributes = $options['ignored_attributes'] ?? [];
255✔
71
        } catch (ResourceClassNotFoundException) {
×
72
            // TODO: for input/output classes, the serializer groups must be read from the actual resource class
73
            return $propertyMetadata;
×
74
        }
75

76
        $propertyMetadata = $this->transformReadWrite($propertyMetadata, $resourceClass, $property, $normalizationGroups, $denormalizationGroups, $normalizationAttributes, $denormalizationAttributes, $ignoredAttributes);
255✔
77

78
        // TODO: remove in 5.x
79
        if (!method_exists(PropertyInfoExtractor::class, 'getType')) {
255✔
80
            $types = $propertyMetadata->getBuiltinTypes() ?? [];
×
81

82
            if (!$this->isResourceClass($resourceClass) && $types) {
×
83
                foreach ($types as $builtinType) {
×
84
                    if ($builtinType->isCollection()) {
×
85
                        return $propertyMetadata->withReadableLink(true)->withWritableLink(true);
×
86
                    }
87
                }
88
            }
89

NEW
90
            return $this->transformLinkStatusLegacy($propertyMetadata, $property, $normalizationGroups, $denormalizationGroups, $normalizationAttributes, $denormalizationAttributes, $types);
×
91
        }
92
        $type = $propertyMetadata->getNativeType();
255✔
93
        if (null !== $type && !$this->isResourceClass($resourceClass) && $type->isSatisfiedBy(static fn (Type $t): bool => $t instanceof CollectionType)) {
255✔
94
            return $propertyMetadata->withReadableLink(true)->withWritableLink(true);
24✔
95
        }
96

97
        return $this->transformLinkStatus($propertyMetadata, $property, $normalizationGroups, $denormalizationGroups, $normalizationAttributes, $denormalizationAttributes, $type);
255✔
98
    }
99

100
    /**
101
     * Sets readable/writable based on matching normalization/denormalization groups/attributes and property's ignorance.
102
     *
103
     * A false value is never reset as it could be unreadable/unwritable for other reasons.
104
     * If normalization/denormalization groups are not specified and the property is not ignored, the property is implicitly readable/writable.
105
     *
106
     * @param string[]|null $normalizationGroups
107
     * @param string[]|null $denormalizationGroups
108
     */
109
    private function transformReadWrite(ApiProperty $propertyMetadata, string $resourceClass, string $propertyName, ?array $normalizationGroups = null, ?array $denormalizationGroups = null, ?array $normalizationAttributes = null, ?array $denormalizationAttributes = null, array $ignoredAttributes = []): ApiProperty
110
    {
111
        if (\in_array($propertyName, $ignoredAttributes, true)) {
255✔
112
            return $propertyMetadata->withWritable(false)->withReadable(false);
2✔
113
        }
114

115
        $serializerAttributeMetadata = $this->getSerializerAttributeMetadata($resourceClass, $propertyName);
255✔
116
        $groups = $serializerAttributeMetadata?->getGroups() ?? [];
255✔
117
        $ignored = $serializerAttributeMetadata?->isIgnored() ?? false;
255✔
118

119
        if (false !== $propertyMetadata->isReadable()) {
255✔
120
            $propertyMetadata = $propertyMetadata->withReadable(!$ignored && (null === $normalizationGroups || array_intersect($normalizationGroups, $groups)) && (null === $normalizationAttributes || $this->isPropertyInAttributes($propertyName, $normalizationAttributes)));
255✔
121
        }
122

123
        if (false !== $propertyMetadata->isWritable()) {
255✔
124
            $propertyMetadata = $propertyMetadata->withWritable(!$ignored && (null === $denormalizationGroups || array_intersect($denormalizationGroups, $groups)) && (null === $denormalizationAttributes || $this->isPropertyInAttributes($propertyName, $denormalizationAttributes)));
221✔
125
        }
126

127
        return $propertyMetadata;
255✔
128
    }
129

130
    /**
131
     * Sets readableLink/writableLink based on matching normalization/denormalization groups/attributes.
132
     *
133
     * If normalization/denormalization groups/attributes are not specified,
134
     * set link status to false since embedding of resource must be explicitly enabled
135
     *
136
     * @param string[]|null $normalizationGroups
137
     * @param string[]|null $denormalizationGroups
138
     */
139
    private function transformLinkStatusLegacy(ApiProperty $propertyMetadata, string $propertyName, ?array $normalizationGroups = null, ?array $denormalizationGroups = null, ?array $normalizationAttributes = null, ?array $denormalizationAttributes = null, ?array $types = null): ApiProperty
140
    {
141
        // No need to check link status if property is not readable and not writable
142
        if (false === $propertyMetadata->isReadable() && false === $propertyMetadata->isWritable()) {
×
143
            return $propertyMetadata;
×
144
        }
145

146
        foreach ($types as $type) {
×
147
            if (
148
                $type->isCollection()
×
149
                && $collectionValueType = $type->getCollectionValueTypes()[0] ?? null
×
150
            ) {
151
                $relatedClass = $collectionValueType->getClassName();
×
152
            } else {
153
                $relatedClass = $type->getClassName();
×
154
            }
155

156
            // if property is not a resource relation, don't set link status (as it would have no meaning)
157
            if (null === $relatedClass || !$this->isResourceClass($relatedClass)) {
×
158
                continue;
×
159
            }
160

161
            // find the resource class
162
            // this prevents serializer groups on non-resource child class from incorrectly influencing the decision
163
            if (null !== $this->resourceClassResolver) {
×
164
                $relatedClass = $this->resourceClassResolver->getResourceClass(null, $relatedClass);
×
165
            }
166

167
            $relatedGroups = $this->getClassSerializerGroups($relatedClass);
×
168

169
            if (null === $propertyMetadata->isReadableLink()) {
×
NEW
170
                $propertyMetadata = $propertyMetadata->withReadableLink((null !== $normalizationGroups && !empty(array_intersect($normalizationGroups, $relatedGroups))) || (null !== $normalizationAttributes && $this->isPropertyInAttributes($propertyName, $normalizationAttributes)));
×
171
            }
172

173
            if (null === $propertyMetadata->isWritableLink()) {
×
NEW
174
                $propertyMetadata = $propertyMetadata->withWritableLink((null !== $denormalizationGroups && !empty(array_intersect($denormalizationGroups, $relatedGroups))) || (null !== $denormalizationAttributes && $this->isPropertyInAttributes($propertyName, $denormalizationAttributes)));
×
175
            }
176

177
            return $propertyMetadata;
×
178
        }
179

180
        return $propertyMetadata;
×
181
    }
182

183
    /**
184
     * Sets readableLink/writableLink based on matching normalization/denormalization groups/attributes.
185
     *
186
     * If normalization/denormalization groups/attributes are not specified,
187
     * set link status to false since embedding of resource must be explicitly enabled
188
     *
189
     * @param string[]|null $normalizationGroups
190
     * @param string[]|null $denormalizationGroups
191
     */
192
    private function transformLinkStatus(ApiProperty $propertyMetadata, string $propertyName, ?array $normalizationGroups = null, ?array $denormalizationGroups = null, ?array $normalizationAttributes = null, ?array $denormalizationAttributes = null, ?Type $type = null): ApiProperty
193
    {
194
        // No need to check link status if property is not readable and not writable
195
        if (false === $propertyMetadata->isReadable() && false === $propertyMetadata->isWritable()) {
255✔
196
            return $propertyMetadata;
70✔
197
        }
198

199
        if (!$type) {
253✔
200
            return $propertyMetadata;
10✔
201
        }
202

203
        $collectionValueType = TypeHelper::getCollectionValueType($type);
253✔
204
        $className = $collectionValueType ? TypeHelper::getClassName($collectionValueType) : TypeHelper::getClassName($type);
253✔
205

206
        // if property is not a resource relation, don't set link status (as it would have no meaning)
207
        if (!$className || !$this->isResourceClass($className)) {
253✔
208
            return $propertyMetadata;
253✔
209
        }
210

211
        // find the resource class
212
        // this prevents serializer groups on non-resource child class from incorrectly influencing the decision
213
        if (null !== $this->resourceClassResolver) {
66✔
214
            $className = $this->resourceClassResolver->getResourceClass(null, $className);
66✔
215
        }
216

217
        $relatedGroups = $this->getClassSerializerGroups($className);
66✔
218

219
        if (null === $propertyMetadata->isReadableLink()) {
66✔
220
            $propertyMetadata = $propertyMetadata->withReadableLink((null !== $normalizationGroups && !empty(array_intersect($normalizationGroups, $relatedGroups))) || (null !== $normalizationAttributes && $this->isPropertyInAttributes($propertyName, $normalizationAttributes)));
64✔
221
        }
222

223
        if (null === $propertyMetadata->isWritableLink()) {
66✔
224
            $propertyMetadata = $propertyMetadata->withWritableLink((null !== $denormalizationGroups && !empty(array_intersect($denormalizationGroups, $relatedGroups))) || (null !== $denormalizationAttributes && $this->isPropertyInAttributes($propertyName, $denormalizationAttributes)));
66✔
225
        }
226

227
        return $propertyMetadata;
66✔
228
    }
229

230
    /**
231
     * Gets the effective serializer groups used in normalization/denormalization.
232
     *
233
     * Groups are extracted in the following order:
234
     *
235
     * - From the "serializer_groups" key of the $options array.
236
     * - From metadata of the given operation ("operation_name" key).
237
     * - From metadata of the current resource.
238
     *
239
     * @return (string[]|string|null)[]
240
     */
241
    private function getEffectiveSerializerGroups(array $options): array
242
    {
243
        if (isset($options['serializer_groups'])) {
255✔
244
            $groups = (array) $options['serializer_groups'];
56✔
245

246
            return [$groups, $groups];
56✔
247
        }
248

249
        if (\array_key_exists('normalization_groups', $options) && \array_key_exists('denormalization_groups', $options)) {
225✔
250
            return [$options['normalization_groups'] ?? null, $options['denormalization_groups'] ?? null];
104✔
251
        }
252

253
        return [null, null];
183✔
254
    }
255

256
    /**
257
     * Gets the effective serializer attributes used in normalization/denormalization.
258
     *
259
     * Attributes are extracted in the following order:
260
     *
261
     * - From the "serializer_attributes" key of the $options array.
262
     * - From metadata of the given operation ("operation_name" key).
263
     * - From metadata of the current resource.
264
     *
265
     * @return (array|null)[]
266
     */
267
    private function getEffectiveSerializerAttributes(array $options): array
268
    {
269
        if (isset($options['serializer_attributes'])) {
255✔
NEW
270
            $attributes = (array) $options['serializer_attributes'];
×
271

NEW
272
            return [$attributes, $attributes];
×
273
        }
274

275
        if (\array_key_exists('normalization_attributes', $options) && \array_key_exists('denormalization_attributes', $options)) {
255✔
NEW
276
            return [$options['normalization_attributes'] ?? null, $options['denormalization_attributes'] ?? null];
×
277
        }
278

279
        return [null, null];
255✔
280
    }
281

282
    private function getSerializerAttributeMetadata(string $class, string $attribute): ?AttributeMetadataInterface
283
    {
284
        $serializerClassMetadata = $this->serializerClassMetadataFactory->getMetadataFor($class);
255✔
285

286
        foreach ($serializerClassMetadata->getAttributesMetadata() as $serializerAttributeMetadata) {
255✔
287
            if ($attribute === $serializerAttributeMetadata->getName()) {
255✔
288
                return $serializerAttributeMetadata;
255✔
289
            }
290
        }
291

292
        return null;
12✔
293
    }
294

295
    /**
296
     * Gets all serializer groups used in a class.
297
     *
298
     * @return string[]
299
     */
300
    private function getClassSerializerGroups(string $class): array
301
    {
302
        $serializerClassMetadata = $this->serializerClassMetadataFactory->getMetadataFor($class);
66✔
303

304
        $groups = [];
66✔
305
        foreach ($serializerClassMetadata->getAttributesMetadata() as $serializerAttributeMetadata) {
66✔
306
            $groups[] = $serializerAttributeMetadata->getGroups();
66✔
307
        }
308

309
        return array_unique(array_merge(...$groups));
66✔
310
    }
311

312
    private function isPropertyInAttributes(string $propertyName, array $attributes): bool
313
    {
NEW
314
        return \in_array($propertyName, $attributes, true) || \array_key_exists($propertyName, $attributes);
×
315
    }
316
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc