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

api-platform / core / 9710836697

28 Jun 2024 09:35AM UTC coverage: 63.285% (+1.2%) from 62.122%
9710836697

push

github

soyuka
docs: changelog v3.3.7

11104 of 17546 relevant lines covered (63.29%)

52.26 hits per line

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

82.54
/src/Hal/Serializer/ItemNormalizer.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\Hal\Serializer;
15

16
use ApiPlatform\Metadata\UrlGeneratorInterface;
17
use ApiPlatform\Metadata\Util\ClassInfoTrait;
18
use ApiPlatform\Serializer\AbstractItemNormalizer;
19
use ApiPlatform\Serializer\CacheKeyTrait;
20
use ApiPlatform\Serializer\ContextTrait;
21
use Symfony\Component\Serializer\Exception\LogicException;
22
use Symfony\Component\Serializer\Exception\UnexpectedValueException;
23
use Symfony\Component\Serializer\Mapping\AttributeMetadataInterface;
24

25
/**
26
 * Converts between objects and array including HAL metadata.
27
 *
28
 * @author Kévin Dunglas <dunglas@gmail.com>
29
 */
30
final class ItemNormalizer extends AbstractItemNormalizer
31
{
32
    use CacheKeyTrait;
33
    use ClassInfoTrait;
34
    use ContextTrait;
35

36
    public const FORMAT = 'jsonhal';
37

38
    private array $componentsCache = [];
39
    private array $attributesMetadataCache = [];
40

41
    /**
42
     * {@inheritdoc}
43
     */
44
    public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool
45
    {
46
        return self::FORMAT === $format && parent::supportsNormalization($data, $format, $context);
32✔
47
    }
48

49
    public function getSupportedTypes($format): array
50
    {
51
        return self::FORMAT === $format ? parent::getSupportedTypes($format) : [];
219✔
52
    }
53

54
    /**
55
     * {@inheritdoc}
56
     */
57
    public function normalize(mixed $object, ?string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null
58
    {
59
        $resourceClass = $this->getObjectClass($object);
40✔
60
        if ($this->getOutputClass($context)) {
40✔
61
            return parent::normalize($object, $format, $context);
4✔
62
        }
63

64
        $previousResourceClass = $context['resource_class'] ?? null;
40✔
65
        if ($this->resourceClassResolver->isResourceClass($resourceClass) && (null === $previousResourceClass || $this->resourceClassResolver->isResourceClass($previousResourceClass))) {
40✔
66
            $resourceClass = $this->resourceClassResolver->getResourceClass($object, $previousResourceClass);
36✔
67
        }
68

69
        $context = $this->initContext($resourceClass, $context);
40✔
70
        $iri = $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_PATH, $context['operation'] ?? null, $context);
40✔
71

72
        $context['iri'] = $iri;
40✔
73
        $context['object'] = $object;
40✔
74
        $context['format'] = $format;
40✔
75
        $context['api_normalize'] = true;
40✔
76

77
        if (!isset($context['cache_key'])) {
40✔
78
            $context['cache_key'] = $this->getCacheKey($format, $context);
40✔
79
        }
80

81
        $data = parent::normalize($object, $format, $context);
40✔
82

83
        if (!\is_array($data)) {
40✔
84
            return $data;
×
85
        }
86

87
        $metadata = [
40✔
88
            '_links' => [
40✔
89
                'self' => [
40✔
90
                    'href' => $iri,
40✔
91
                ],
40✔
92
            ],
40✔
93
        ];
40✔
94
        $components = $this->getComponents($object, $format, $context);
40✔
95
        $metadata = $this->populateRelation($metadata, $object, $format, $context, $components, 'links');
40✔
96
        $metadata = $this->populateRelation($metadata, $object, $format, $context, $components, 'embedded');
40✔
97

98
        return $metadata + $data;
40✔
99
    }
100

101
    /**
102
     * {@inheritdoc}
103
     */
104
    public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool
105
    {
106
        // prevent the use of lower priority normalizers (e.g. serializer.normalizer.object) for this format
107
        return self::FORMAT === $format;
4✔
108
    }
109

110
    /**
111
     * {@inheritdoc}
112
     *
113
     * @throws LogicException
114
     */
115
    public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): never
116
    {
117
        throw new LogicException(sprintf('%s is a read-only format.', self::FORMAT));
4✔
118
    }
119

120
    /**
121
     * {@inheritdoc}
122
     */
123
    protected function getAttributes($object, $format = null, array $context = []): array
124
    {
125
        return $this->getComponents($object, $format, $context)['states'];
40✔
126
    }
127

128
    /**
129
     * Gets HAL components of the resource: states, links and embedded.
130
     */
131
    private function getComponents(object $object, ?string $format, array $context): array
132
    {
133
        $cacheKey = $this->getObjectClass($object).'-'.$context['cache_key'];
40✔
134

135
        if (isset($this->componentsCache[$cacheKey])) {
40✔
136
            return $this->componentsCache[$cacheKey];
36✔
137
        }
138

139
        $attributes = parent::getAttributes($object, $format, $context);
40✔
140
        $options = $this->getFactoryOptions($context);
40✔
141

142
        $components = [
40✔
143
            'states' => [],
40✔
144
            'links' => [],
40✔
145
            'embedded' => [],
40✔
146
        ];
40✔
147

148
        foreach ($attributes as $attribute) {
40✔
149
            $propertyMetadata = $this->propertyMetadataFactory->create($context['resource_class'], $attribute, $options);
36✔
150

151
            $types = $propertyMetadata->getBuiltinTypes() ?? [];
36✔
152

153
            // prevent declaring $attribute as attribute if it's already declared as relationship
154
            $isRelationship = false;
36✔
155

156
            foreach ($types as $type) {
36✔
157
                $isOne = $isMany = false;
36✔
158

159
                if (null !== $type) {
36✔
160
                    if ($type->isCollection()) {
36✔
161
                        $valueType = $type->getCollectionValueTypes()[0] ?? null;
8✔
162
                        $isMany = null !== $valueType && ($className = $valueType->getClassName()) && $this->resourceClassResolver->isResourceClass($className);
8✔
163
                    } else {
164
                        $className = $type->getClassName();
36✔
165
                        $isOne = $className && $this->resourceClassResolver->isResourceClass($className);
36✔
166
                    }
167
                }
168

169
                if (!$isOne && !$isMany) {
36✔
170
                    // don't declare it as an attribute too quick: maybe the next type is a valid resource
171
                    continue;
36✔
172
                }
173

174
                $relation = ['name' => $attribute, 'cardinality' => $isOne ? 'one' : 'many', 'iri' => null, 'operation' => null];
16✔
175

176
                // if we specify the uriTemplate, generates its value for link definition
177
                // @see ApiPlatform\Serializer\AbstractItemNormalizer:getAttributeValue logic for intentional duplicate content
178
                if (($className ?? false) && $uriTemplate = $propertyMetadata->getUriTemplate()) {
16✔
179
                    $childContext = $this->createChildContext($context, $attribute, $format);
×
180
                    unset($childContext['iri'], $childContext['uri_variables'], $childContext['resource_class'], $childContext['operation'], $childContext['operation_name']);
×
181

182
                    $operation = $this->resourceMetadataCollectionFactory->create($className)->getOperation(
×
183
                        operationName: $uriTemplate,
×
184
                        httpOperation: true
×
185
                    );
×
186

187
                    $relation['iri'] = $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_PATH, $operation, $childContext);
×
188
                    $relation['operation'] = $operation;
×
189
                    $cacheKey = null;
×
190
                }
191

192
                if ($propertyMetadata->isReadableLink()) {
16✔
193
                    $components['embedded'][] = $relation;
8✔
194
                }
195

196
                $components['links'][] = $relation;
16✔
197
                $isRelationship = true;
16✔
198
            }
199

200
            // if all types are not relationships, declare it as an attribute
201
            if (!$isRelationship) {
36✔
202
                $components['states'][] = $attribute;
36✔
203
            }
204
        }
205

206
        if ($cacheKey && false !== $context['cache_key']) {
40✔
207
            $this->componentsCache[$cacheKey] = $components;
36✔
208
        }
209

210
        return $components;
40✔
211
    }
212

213
    /**
214
     * Populates _links and _embedded keys.
215
     */
216
    private function populateRelation(array $data, object $object, ?string $format, array $context, array $components, string $type): array
217
    {
218
        $class = $this->getObjectClass($object);
40✔
219

220
        $attributesMetadata = \array_key_exists($class, $this->attributesMetadataCache) ?
40✔
221
            $this->attributesMetadataCache[$class] :
40✔
222
            $this->attributesMetadataCache[$class] = $this->classMetadataFactory ? $this->classMetadataFactory->getMetadataFor($class)->getAttributesMetadata() : null;
40✔
223

224
        $key = '_'.$type;
40✔
225
        foreach ($components[$type] as $relation) {
40✔
226
            if (null !== $attributesMetadata && $this->isMaxDepthReached($attributesMetadata, $class, $relation['name'], $context)) {
16✔
227
                continue;
4✔
228
            }
229

230
            $relationName = $relation['name'];
16✔
231
            if ($this->nameConverter) {
16✔
232
                $relationName = $this->nameConverter->normalize($relationName, $class, $format, $context);
12✔
233
            }
234

235
            // if we specify the uriTemplate, then the link takes the uriTemplate defined.
236
            if ('links' === $type && $iri = $relation['iri']) {
16✔
237
                $data[$key][$relationName]['href'] = $iri;
×
238
                continue;
×
239
            }
240

241
            $childContext = $this->createChildContext($context, $relationName, $format);
16✔
242
            unset($childContext['iri'], $childContext['uri_variables'], $childContext['operation'], $childContext['operation_name']);
16✔
243

244
            if ($operation = $relation['operation']) {
16✔
245
                $childContext['operation'] = $operation;
×
246
                $childContext['operation_name'] = $operation->getName();
×
247
            }
248

249
            $attributeValue = $this->getAttributeValue($object, $relation['name'], $format, $childContext);
16✔
250

251
            if (empty($attributeValue)) {
16✔
252
                continue;
8✔
253
            }
254

255
            if ('one' === $relation['cardinality']) {
12✔
256
                if ('links' === $type) {
12✔
257
                    $data[$key][$relationName]['href'] = $this->getRelationIri($attributeValue);
12✔
258
                    continue;
12✔
259
                }
260

261
                $data[$key][$relationName] = $attributeValue;
4✔
262
                continue;
4✔
263
            }
264

265
            // many
266
            $data[$key][$relationName] = [];
×
267
            foreach ($attributeValue as $rel) {
×
268
                if ('links' === $type) {
×
269
                    $rel = ['href' => $this->getRelationIri($rel)];
×
270
                }
271

272
                $data[$key][$relationName][] = $rel;
×
273
            }
274
        }
275

276
        return $data;
40✔
277
    }
278

279
    /**
280
     * Gets the IRI of the given relation.
281
     *
282
     * @throws UnexpectedValueException
283
     */
284
    private function getRelationIri(mixed $rel): string
285
    {
286
        if (!(\is_array($rel) || \is_string($rel))) {
12✔
287
            throw new UnexpectedValueException('Expected relation to be an IRI or array');
×
288
        }
289

290
        return \is_string($rel) ? $rel : $rel['_links']['self']['href'];
12✔
291
    }
292

293
    /**
294
     * Is the max depth reached for the given attribute?
295
     *
296
     * @param AttributeMetadataInterface[] $attributesMetadata
297
     */
298
    private function isMaxDepthReached(array $attributesMetadata, string $class, string $attribute, array &$context): bool
299
    {
300
        if (
301
            !($context[self::ENABLE_MAX_DEPTH] ?? false)
8✔
302
            || !isset($attributesMetadata[$attribute])
8✔
303
            || null === $maxDepth = $attributesMetadata[$attribute]->getMaxDepth()
8✔
304
        ) {
305
            return false;
8✔
306
        }
307

308
        $key = sprintf(self::DEPTH_KEY_PATTERN, $class, $attribute);
4✔
309
        if (!isset($context[$key])) {
4✔
310
            $context[$key] = 1;
4✔
311

312
            return false;
4✔
313
        }
314

315
        if ($context[$key] === $maxDepth) {
4✔
316
            return true;
4✔
317
        }
318

319
        ++$context[$key];
×
320

321
        return false;
×
322
    }
323
}
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

© 2025 Coveralls, Inc