1 | <?php |
||
2 | |||
3 | /* |
||
4 | * This file is part of the API Platform project. |
||
5 | * |
||
6 | * (c) Kévin Dunglas <[email protected]> |
||
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\Core\JsonApi\Serializer; |
||
15 | |||
16 | use ApiPlatform\Core\Api\IriConverterInterface; |
||
17 | use ApiPlatform\Core\Api\OperationType; |
||
18 | use ApiPlatform\Core\Api\ResourceClassResolverInterface; |
||
19 | use ApiPlatform\Core\Exception\ItemNotFoundException; |
||
20 | use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface; |
||
21 | use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; |
||
22 | use ApiPlatform\Core\Metadata\Property\PropertyMetadata; |
||
23 | use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; |
||
24 | use ApiPlatform\Core\Serializer\AbstractItemNormalizer; |
||
25 | use ApiPlatform\Core\Serializer\ContextTrait; |
||
26 | use ApiPlatform\Core\Util\ClassInfoTrait; |
||
27 | use Symfony\Component\PropertyAccess\PropertyAccessorInterface; |
||
28 | use Symfony\Component\Serializer\Exception\LogicException; |
||
29 | use Symfony\Component\Serializer\Exception\NotNormalizableValueException; |
||
30 | use Symfony\Component\Serializer\Exception\RuntimeException; |
||
31 | use Symfony\Component\Serializer\Exception\UnexpectedValueException; |
||
32 | use Symfony\Component\Serializer\NameConverter\NameConverterInterface; |
||
33 | use Symfony\Component\Serializer\Normalizer\NormalizerInterface; |
||
34 | |||
35 | /** |
||
36 | * Converts between objects and array. |
||
37 | * |
||
38 | * @author Kévin Dunglas <[email protected]> |
||
39 | * @author Amrouche Hamza <[email protected]> |
||
40 | * @author Baptiste Meyer <[email protected]> |
||
41 | */ |
||
42 | final class ItemNormalizer extends AbstractItemNormalizer |
||
43 | { |
||
44 | use ClassInfoTrait; |
||
45 | use ContextTrait; |
||
46 | |||
47 | public const FORMAT = 'jsonapi'; |
||
48 | |||
49 | private $componentsCache = []; |
||
50 | |||
51 | public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface $iriConverter, ResourceClassResolverInterface $resourceClassResolver, ?PropertyAccessorInterface $propertyAccessor, ?NameConverterInterface $nameConverter, ResourceMetadataFactoryInterface $resourceMetadataFactory, array $defaultContext = [], iterable $dataTransformers = []) |
||
52 | { |
||
53 | parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, null, null, false, $defaultContext, $dataTransformers, $resourceMetadataFactory); |
||
54 | } |
||
55 | |||
56 | /** |
||
57 | * {@inheritdoc} |
||
58 | */ |
||
59 | public function supportsNormalization($data, $format = null): bool |
||
60 | { |
||
61 | return self::FORMAT === $format && parent::supportsNormalization($data, $format); |
||
62 | } |
||
63 | |||
64 | /** |
||
65 | * {@inheritdoc} |
||
66 | */ |
||
67 | public function normalize($object, $format = null, array $context = []) |
||
68 | { |
||
69 | if (null !== $this->getOutputClass($this->getObjectClass($object), $context)) { |
||
70 | return parent::normalize($object, $format, $context); |
||
71 | } |
||
72 | |||
73 | if (!isset($context['cache_key'])) { |
||
74 | $context['cache_key'] = $this->getJsonApiCacheKey($format, $context); |
||
75 | } |
||
76 | |||
77 | $resourceClass = $this->resourceClassResolver->getResourceClass($object, $context['resource_class'] ?? null); |
||
78 | $context = $this->initContext($resourceClass, $context); |
||
79 | $iri = $this->iriConverter->getIriFromItem($object); |
||
80 | $context['iri'] = $iri; |
||
81 | $context['api_normalize'] = true; |
||
82 | |||
83 | $data = parent::normalize($object, $format, $context); |
||
84 | if (!\is_array($data)) { |
||
85 | return $data; |
||
86 | } |
||
87 | |||
88 | $resourceMetadata = $this->resourceMetadataFactory->create($resourceClass); |
||
89 | |||
90 | // Get and populate relations |
||
91 | $allRelationshipsData = $this->getComponents($object, $format, $context)['relationships']; |
||
92 | $populatedRelationContext = $context; |
||
93 | $relationshipsData = $this->getPopulatedRelations($object, $format, $populatedRelationContext, $allRelationshipsData); |
||
94 | $includedResourcesData = $this->getRelatedResources($object, $format, $context, $allRelationshipsData); |
||
95 | |||
96 | $resourceData = [ |
||
97 | 'id' => $context['iri'], |
||
98 | 'type' => $resourceMetadata->getShortName(), |
||
99 | ]; |
||
100 | |||
101 | if ($data) { |
||
0 ignored issues
–
show
|
|||
102 | $resourceData['attributes'] = $data; |
||
103 | } |
||
104 | |||
105 | if ($relationshipsData) { |
||
106 | $resourceData['relationships'] = $relationshipsData; |
||
107 | } |
||
108 | |||
109 | $document = ['data' => $resourceData]; |
||
110 | |||
111 | if ($includedResourcesData) { |
||
112 | $document['included'] = $includedResourcesData; |
||
113 | } |
||
114 | |||
115 | return $document; |
||
116 | } |
||
117 | |||
118 | /** |
||
119 | * {@inheritdoc} |
||
120 | */ |
||
121 | public function supportsDenormalization($data, $type, $format = null): bool |
||
122 | { |
||
123 | return self::FORMAT === $format && parent::supportsDenormalization($data, $type, $format); |
||
124 | } |
||
125 | |||
126 | /** |
||
127 | * {@inheritdoc} |
||
128 | * |
||
129 | * @throws NotNormalizableValueException |
||
130 | */ |
||
131 | public function denormalize($data, $class, $format = null, array $context = []) |
||
132 | { |
||
133 | // Avoid issues with proxies if we populated the object |
||
134 | if (!isset($context[self::OBJECT_TO_POPULATE]) && isset($data['data']['id'])) { |
||
135 | if (true !== ($context['api_allow_update'] ?? true)) { |
||
136 | throw new NotNormalizableValueException('Update is not allowed for this operation.'); |
||
137 | } |
||
138 | |||
139 | $context[self::OBJECT_TO_POPULATE] = $this->iriConverter->getItemFromIri( |
||
140 | $data['data']['id'], |
||
141 | $context + ['fetch_data' => false] |
||
142 | ); |
||
143 | } |
||
144 | |||
145 | // Merge attributes and relationships, into format expected by the parent normalizer |
||
146 | $dataToDenormalize = array_merge( |
||
147 | $data['data']['attributes'] ?? [], |
||
148 | $data['data']['relationships'] ?? [] |
||
149 | ); |
||
150 | |||
151 | return parent::denormalize( |
||
152 | $dataToDenormalize, |
||
153 | $class, |
||
154 | $format, |
||
155 | $context |
||
156 | ); |
||
157 | } |
||
158 | |||
159 | /** |
||
160 | * {@inheritdoc} |
||
161 | */ |
||
162 | protected function getAttributes($object, $format = null, array $context): array |
||
163 | { |
||
164 | return $this->getComponents($object, $format, $context)['attributes']; |
||
165 | } |
||
166 | |||
167 | /** |
||
168 | * {@inheritdoc} |
||
169 | */ |
||
170 | protected function setAttributeValue($object, $attribute, $value, $format = null, array $context = []): void |
||
171 | { |
||
172 | parent::setAttributeValue($object, $attribute, \is_array($value) && \array_key_exists('data', $value) ? $value['data'] : $value, $format, $context); |
||
173 | } |
||
174 | |||
175 | /** |
||
176 | * {@inheritdoc} |
||
177 | * |
||
178 | * @see http://jsonapi.org/format/#document-resource-object-linkage |
||
179 | * |
||
180 | * @throws RuntimeException |
||
181 | * @throws NotNormalizableValueException |
||
182 | */ |
||
183 | protected function denormalizeRelation(string $attributeName, PropertyMetadata $propertyMetadata, string $className, $value, ?string $format, array $context) |
||
184 | { |
||
185 | if (!\is_array($value) || !isset($value['id'], $value['type'])) { |
||
186 | throw new NotNormalizableValueException('Only resource linkage supported currently, see: http://jsonapi.org/format/#document-resource-object-linkage.'); |
||
187 | } |
||
188 | |||
189 | try { |
||
190 | return $this->iriConverter->getItemFromIri($value['id'], $context + ['fetch_data' => true]); |
||
191 | } catch (ItemNotFoundException $e) { |
||
192 | throw new RuntimeException($e->getMessage(), $e->getCode(), $e); |
||
193 | } |
||
194 | } |
||
195 | |||
196 | /** |
||
197 | * {@inheritdoc} |
||
198 | * |
||
199 | * @see http://jsonapi.org/format/#document-resource-object-linkage |
||
200 | * |
||
201 | * @throws LogicException |
||
202 | */ |
||
203 | protected function normalizeRelation(PropertyMetadata $propertyMetadata, $relatedObject, string $resourceClass, ?string $format, array $context) |
||
204 | { |
||
205 | if (null === $relatedObject) { |
||
206 | if (isset($context['operation_type'], $context['subresource_resources'][$resourceClass]) && OperationType::SUBRESOURCE === $context['operation_type']) { |
||
207 | $iri = $this->iriConverter->getItemIriFromResourceClass($resourceClass, $context['subresource_resources'][$resourceClass]); |
||
208 | } else { |
||
209 | if ($this->serializer instanceof NormalizerInterface) { |
||
210 | return $this->serializer->normalize($relatedObject, $format, $context); |
||
211 | } |
||
212 | throw new LogicException(sprintf('The injected serializer must be an instance of "%s".', NormalizerInterface::class)); |
||
213 | } |
||
214 | } else { |
||
215 | $iri = $this->iriConverter->getIriFromItem($relatedObject); |
||
216 | $context['iri'] = $iri; |
||
217 | |||
218 | if (isset($context['resources'])) { |
||
219 | $context['resources'][$iri] = $iri; |
||
220 | } |
||
221 | if (isset($context['api_included'])) { |
||
222 | if (!$this->serializer instanceof NormalizerInterface) { |
||
223 | throw new LogicException(sprintf('The injected serializer must be an instance of "%s".', NormalizerInterface::class)); |
||
224 | } |
||
225 | |||
226 | return $this->serializer->normalize($relatedObject, $format, $context); |
||
227 | } |
||
228 | } |
||
229 | |||
230 | return [ |
||
231 | 'data' => [ |
||
232 | 'type' => $this->resourceMetadataFactory->create($resourceClass)->getShortName(), |
||
233 | 'id' => $iri, |
||
234 | ], |
||
235 | ]; |
||
236 | } |
||
237 | |||
238 | /** |
||
239 | * {@inheritdoc} |
||
240 | */ |
||
241 | protected function isAllowedAttribute($classOrObject, $attribute, $format = null, array $context = []): bool |
||
242 | { |
||
243 | return preg_match('/^\\w[-\\w_]*$/', $attribute) && parent::isAllowedAttribute($classOrObject, $attribute, $format, $context); |
||
244 | } |
||
245 | |||
246 | /** |
||
247 | * Gets JSON API components of the resource: attributes, relationships, meta and links. |
||
248 | * |
||
249 | * @param object $object |
||
250 | */ |
||
251 | private function getComponents($object, ?string $format, array $context): array |
||
252 | { |
||
253 | $cacheKey = $this->getObjectClass($object).'-'.$context['cache_key']; |
||
254 | |||
255 | if (isset($this->componentsCache[$cacheKey])) { |
||
256 | return $this->componentsCache[$cacheKey]; |
||
257 | } |
||
258 | |||
259 | $attributes = parent::getAttributes($object, $format, $context); |
||
260 | |||
261 | $options = $this->getFactoryOptions($context); |
||
262 | |||
263 | $components = [ |
||
264 | 'links' => [], |
||
265 | 'relationships' => [], |
||
266 | 'attributes' => [], |
||
267 | 'meta' => [], |
||
268 | ]; |
||
269 | |||
270 | foreach ($attributes as $attribute) { |
||
271 | $propertyMetadata = $this |
||
272 | ->propertyMetadataFactory |
||
273 | ->create($context['resource_class'], $attribute, $options); |
||
274 | |||
275 | $type = $propertyMetadata->getType(); |
||
276 | $isOne = $isMany = false; |
||
277 | |||
278 | if (null !== $type) { |
||
279 | if ($type->isCollection()) { |
||
280 | $isMany = ($type->getCollectionValueType() && $className = $type->getCollectionValueType()->getClassName()) ? $this->resourceClassResolver->isResourceClass($className) : false; |
||
281 | } else { |
||
282 | $isOne = ($className = $type->getClassName()) ? $this->resourceClassResolver->isResourceClass($className) : false; |
||
283 | } |
||
284 | } |
||
285 | |||
286 | if (!isset($className) || !$isOne && !$isMany) { |
||
287 | $components['attributes'][] = $attribute; |
||
288 | |||
289 | continue; |
||
290 | } |
||
291 | |||
292 | $relation = [ |
||
293 | 'name' => $attribute, |
||
294 | 'type' => $this->resourceMetadataFactory->create($className)->getShortName(), |
||
295 | 'cardinality' => $isOne ? 'one' : 'many', |
||
296 | ]; |
||
297 | |||
298 | $components['relationships'][] = $relation; |
||
299 | } |
||
300 | |||
301 | if (false !== $context['cache_key']) { |
||
302 | $this->componentsCache[$cacheKey] = $components; |
||
303 | } |
||
304 | |||
305 | return $components; |
||
306 | } |
||
307 | |||
308 | /** |
||
309 | * Populates relationships keys. |
||
310 | * |
||
311 | * @param object $object |
||
312 | * |
||
313 | * @throws UnexpectedValueException |
||
314 | */ |
||
315 | private function getPopulatedRelations($object, ?string $format, array $context, array $relationships): array |
||
316 | { |
||
317 | $data = []; |
||
318 | |||
319 | if (!isset($context['resource_class'])) { |
||
320 | return $data; |
||
321 | } |
||
322 | |||
323 | unset($context['api_included']); |
||
324 | foreach ($relationships as $relationshipDataArray) { |
||
325 | $relationshipName = $relationshipDataArray['name']; |
||
326 | |||
327 | $attributeValue = $this->getAttributeValue($object, $relationshipName, $format, $context); |
||
328 | |||
329 | if ($this->nameConverter) { |
||
330 | $relationshipName = $this->nameConverter->normalize($relationshipName, $context['resource_class'], self::FORMAT, $context); |
||
331 | } |
||
332 | |||
333 | if (!$attributeValue) { |
||
334 | continue; |
||
335 | } |
||
336 | |||
337 | $data[$relationshipName] = [ |
||
338 | 'data' => [], |
||
339 | ]; |
||
340 | |||
341 | // Many to one relationship |
||
342 | if ('one' === $relationshipDataArray['cardinality']) { |
||
343 | unset($attributeValue['data']['attributes']); |
||
344 | $data[$relationshipName] = $attributeValue; |
||
345 | |||
346 | continue; |
||
347 | } |
||
348 | |||
349 | // Many to many relationship |
||
350 | foreach ($attributeValue as $attributeValueElement) { |
||
351 | if (!isset($attributeValueElement['data'])) { |
||
352 | throw new UnexpectedValueException(sprintf('The JSON API attribute \'%s\' must contain a "data" key.', $relationshipName)); |
||
353 | } |
||
354 | unset($attributeValueElement['data']['attributes']); |
||
355 | $data[$relationshipName]['data'][] = $attributeValueElement['data']; |
||
356 | } |
||
357 | } |
||
358 | |||
359 | return $data; |
||
360 | } |
||
361 | |||
362 | /** |
||
363 | * Populates included keys. |
||
364 | */ |
||
365 | private function getRelatedResources($object, ?string $format, array $context, array $relationships): array |
||
366 | { |
||
367 | if (!isset($context['api_included'])) { |
||
368 | return []; |
||
369 | } |
||
370 | |||
371 | $included = []; |
||
372 | foreach ($relationships as $relationshipDataArray) { |
||
373 | if (!\in_array($relationshipDataArray['name'], $context['api_included'], true)) { |
||
374 | continue; |
||
375 | } |
||
376 | |||
377 | $relationshipName = $relationshipDataArray['name']; |
||
378 | $relationContext = $context; |
||
379 | $attributeValue = $this->getAttributeValue($object, $relationshipName, $format, $relationContext); |
||
380 | |||
381 | if (!$attributeValue) { |
||
382 | continue; |
||
383 | } |
||
384 | |||
385 | // Many to one relationship |
||
386 | if ('one' === $relationshipDataArray['cardinality']) { |
||
387 | $included[] = $attributeValue['data']; |
||
388 | |||
389 | continue; |
||
390 | } |
||
391 | // Many to many relationship |
||
392 | foreach ($attributeValue as $attributeValueElement) { |
||
393 | if (isset($attributeValueElement['data'])) { |
||
394 | $included[] = $attributeValueElement['data']; |
||
395 | } |
||
396 | } |
||
397 | } |
||
398 | |||
399 | return $included; |
||
400 | } |
||
401 | |||
402 | /** |
||
403 | * Gets the cache key to use. |
||
404 | * |
||
405 | * @return bool|string |
||
406 | */ |
||
407 | private function getJsonApiCacheKey(?string $format, array $context) |
||
408 | { |
||
409 | try { |
||
410 | return md5($format.serialize($context)); |
||
411 | } catch (\Exception $exception) { |
||
412 | // The context cannot be serialized, skip the cache |
||
413 | return false; |
||
414 | } |
||
415 | } |
||
416 | } |
||
417 |
This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.
Consider making the comparison explicit by using
empty(..)
or! empty(...)
instead.