vendor/api-platform/core/src/JsonApi/Serializer/ItemNormalizer.php line 56

Open in your IDE?
  1. <?php
  2. /*
  3.  * This file is part of the API Platform project.
  4.  *
  5.  * (c) Kévin Dunglas <dunglas@gmail.com>
  6.  *
  7.  * For the full copyright and license information, please view the LICENSE
  8.  * file that was distributed with this source code.
  9.  */
  10. declare(strict_types=1);
  11. namespace ApiPlatform\JsonApi\Serializer;
  12. use ApiPlatform\Api\ResourceClassResolverInterface;
  13. use ApiPlatform\Api\UrlGeneratorInterface;
  14. use ApiPlatform\Core\Api\IriConverterInterface as LegacyIriConverterInterface;
  15. use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface;
  16. use ApiPlatform\Core\Metadata\Property\PropertyMetadata;
  17. use ApiPlatform\Core\Metadata\Resource\ResourceMetadata;
  18. use ApiPlatform\Exception\ItemNotFoundException;
  19. use ApiPlatform\Metadata\ApiProperty;
  20. use ApiPlatform\Metadata\Resource\ResourceMetadataCollection;
  21. use ApiPlatform\Serializer\AbstractItemNormalizer;
  22. use ApiPlatform\Serializer\CacheKeyTrait;
  23. use ApiPlatform\Serializer\ContextTrait;
  24. use ApiPlatform\Symfony\Security\ResourceAccessCheckerInterface;
  25. use ApiPlatform\Util\ClassInfoTrait;
  26. use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
  27. use Symfony\Component\PropertyInfo\Type;
  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.  * Converts between objects and array.
  36.  *
  37.  * @author Kévin Dunglas <dunglas@gmail.com>
  38.  * @author Amrouche Hamza <hamza.simperfit@gmail.com>
  39.  * @author Baptiste Meyer <baptiste.meyer@gmail.com>
  40.  */
  41. final class ItemNormalizer extends AbstractItemNormalizer
  42. {
  43.     use CacheKeyTrait;
  44.     use ClassInfoTrait;
  45.     use ContextTrait;
  46.     public const FORMAT 'jsonapi';
  47.     private $componentsCache = [];
  48.     public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory$propertyMetadataFactory$iriConverterResourceClassResolverInterface $resourceClassResolver, ?PropertyAccessorInterface $propertyAccessor, ?NameConverterInterface $nameConverter$resourceMetadataFactory, array $defaultContext = [], iterable $dataTransformers = [], ResourceAccessCheckerInterface $resourceAccessChecker null)
  49.     {
  50.         parent::__construct($propertyNameCollectionFactory$propertyMetadataFactory$iriConverter$resourceClassResolver$propertyAccessor$nameConverternullnullfalse$defaultContext$dataTransformers$resourceMetadataFactory$resourceAccessChecker);
  51.     }
  52.     public function supportsNormalization($data$format null, array $context = []): bool
  53.     {
  54.         return self::FORMAT === $format && parent::supportsNormalization($data$format$context);
  55.     }
  56.     /**
  57.      * @param mixed|null $format
  58.      *
  59.      * @return array|string|int|float|bool|\ArrayObject|null
  60.      */
  61.     public function normalize($object$format null, array $context = [])
  62.     {
  63.         $resourceClass $this->getObjectClass($object);
  64.         if ($this->getOutputClass($resourceClass$context)) {
  65.             return parent::normalize($object$format$context);
  66.         }
  67.         if ($this->resourceClassResolver->isResourceClass($resourceClass)) {
  68.             $resourceClass $this->resourceClassResolver->getResourceClass($object$context['resource_class'] ?? null);
  69.         }
  70.         $context $this->initContext($resourceClass$context);
  71.         $iri $this->iriConverter instanceof LegacyIriConverterInterface $this->iriConverter->getIriFromItem($object) : $this->iriConverter->getIriFromResource($objectUrlGeneratorInterface::ABS_PATH$context['operation'] ?? null$context);
  72.         $context['iri'] = $iri;
  73.         $context['api_normalize'] = true;
  74.         if (!isset($context['cache_key'])) {
  75.             $context['cache_key'] = $this->getCacheKey($format$context);
  76.         }
  77.         $data parent::normalize($object$format$context);
  78.         if (!\is_array($data)) {
  79.             return $data;
  80.         }
  81.         // Get and populate relations
  82.         $allRelationshipsData $this->getComponents($object$format$context)['relationships'];
  83.         $populatedRelationContext $context;
  84.         $relationshipsData $this->getPopulatedRelations($object$format$populatedRelationContext$allRelationshipsData);
  85.         // Do not include primary resources
  86.         $context['api_included_resources'] = [$context['iri']];
  87.         $includedResourcesData $this->getRelatedResources($object$format$context$allRelationshipsData);
  88.         $resourceData = [
  89.             'id' => $context['iri'],
  90.             'type' => $this->getResourceShortName($resourceClass),
  91.         ];
  92.         if ($data) {
  93.             $resourceData['attributes'] = $data;
  94.         }
  95.         if ($relationshipsData) {
  96.             $resourceData['relationships'] = $relationshipsData;
  97.         }
  98.         $document = ['data' => $resourceData];
  99.         if ($includedResourcesData) {
  100.             $document['included'] = $includedResourcesData;
  101.         }
  102.         return $document;
  103.     }
  104.     public function supportsDenormalization($data$type$format null, array $context = []): bool
  105.     {
  106.         return self::FORMAT === $format && parent::supportsDenormalization($data$type$format$context);
  107.     }
  108.     /**
  109.      * @param mixed|null $format
  110.      *
  111.      * @throws NotNormalizableValueException
  112.      */
  113.     public function denormalize($data$class$format null, array $context = [])
  114.     {
  115.         // Avoid issues with proxies if we populated the object
  116.         if (!isset($context[self::OBJECT_TO_POPULATE]) && isset($data['data']['id'])) {
  117.             if (true !== ($context['api_allow_update'] ?? true)) {
  118.                 throw new NotNormalizableValueException('Update is not allowed for this operation.');
  119.             }
  120.             $context[self::OBJECT_TO_POPULATE] = $this->iriConverter instanceof LegacyIriConverterInterface $this->iriConverter->getItemFromIri(
  121.                 $data['data']['id'],
  122.                 $context + ['fetch_data' => false]
  123.             ) : $this->iriConverter->getResourceFromIri(
  124.                 $data['data']['id'],
  125.                 $context + ['fetch_data' => false]
  126.             );
  127.         }
  128.         // Merge attributes and relationships, into format expected by the parent normalizer
  129.         $dataToDenormalize array_merge(
  130.             $data['data']['attributes'] ?? [],
  131.             $data['data']['relationships'] ?? []
  132.         );
  133.         return parent::denormalize(
  134.             $dataToDenormalize,
  135.             $class,
  136.             $format,
  137.             $context
  138.         );
  139.     }
  140.     protected function getAttributes($object$format null, array $context = []): array
  141.     {
  142.         return $this->getComponents($object$format$context)['attributes'];
  143.     }
  144.     protected function setAttributeValue($object$attribute$value$format null, array $context = []): void
  145.     {
  146.         parent::setAttributeValue($object$attribute\is_array($value) && \array_key_exists('data'$value) ? $value['data'] : $value$format$context);
  147.     }
  148.     /**
  149.      * @see http://jsonapi.org/format/#document-resource-object-linkage
  150.      *
  151.      * @param ApiProperty|PropertyMetadata $propertyMetadata
  152.      *
  153.      * @throws RuntimeException
  154.      * @throws NotNormalizableValueException
  155.      */
  156.     protected function denormalizeRelation(string $attributeName$propertyMetadatastring $className$value, ?string $format, array $context)
  157.     {
  158.         if (!\is_array($value) || !isset($value['id'], $value['type'])) {
  159.             throw new NotNormalizableValueException('Only resource linkage supported currently, see: http://jsonapi.org/format/#document-resource-object-linkage.');
  160.         }
  161.         try {
  162.             return $this->iriConverter instanceof LegacyIriConverterInterface $this->iriConverter->getItemFromIri($value['id'], $context + ['fetch_data' => true]) : $this->iriConverter->getResourceFromIri($value['id'], $context + ['fetch_data' => true]);
  163.         } catch (ItemNotFoundException $e) {
  164.             throw new RuntimeException($e->getMessage(), $e->getCode(), $e);
  165.         }
  166.     }
  167.     /**
  168.      * @param ApiProperty|PropertyMetadata $propertyMetadata
  169.      *
  170.      * @see http://jsonapi.org/format/#document-resource-object-linkage
  171.      */
  172.     protected function normalizeRelation($propertyMetadata$relatedObjectstring $resourceClass, ?string $format, array $context)
  173.     {
  174.         if (null !== $relatedObject) {
  175.             $iri $this->iriConverter instanceof LegacyIriConverterInterface $this->iriConverter->getIriFromItem($relatedObject) : $this->iriConverter->getIriFromResource($relatedObject);
  176.             $context['iri'] = $iri;
  177.             if (isset($context['resources'])) {
  178.                 $context['resources'][$iri] = $iri;
  179.             }
  180.         }
  181.         if (null === $relatedObject || isset($context['api_included'])) {
  182.             if (!$this->serializer instanceof NormalizerInterface) {
  183.                 throw new LogicException(sprintf('The injected serializer must be an instance of "%s".'NormalizerInterface::class));
  184.             }
  185.             $normalizedRelatedObject $this->serializer->normalize($relatedObject$format$context);
  186.             if (!\is_string($normalizedRelatedObject) && !\is_array($normalizedRelatedObject) && !$normalizedRelatedObject instanceof \ArrayObject && null !== $normalizedRelatedObject) {
  187.                 throw new UnexpectedValueException('Expected normalized relation to be an IRI, array, \ArrayObject or null');
  188.             }
  189.             return $normalizedRelatedObject;
  190.         }
  191.         return [
  192.             'data' => [
  193.                 'type' => $this->getResourceShortName($resourceClass),
  194.                 'id' => $iri,
  195.             ],
  196.         ];
  197.     }
  198.     protected function isAllowedAttribute($classOrObject$attribute$format null, array $context = []): bool
  199.     {
  200.         return preg_match('/^\\w[-\\w_]*$/'$attribute) && parent::isAllowedAttribute($classOrObject$attribute$format$context);
  201.     }
  202.     /**
  203.      * Gets JSON API components of the resource: attributes, relationships, meta and links.
  204.      *
  205.      * @param object $object
  206.      */
  207.     private function getComponents($object, ?string $format, array $context): array
  208.     {
  209.         $cacheKey $this->getObjectClass($object).'-'.$context['cache_key'];
  210.         if (isset($this->componentsCache[$cacheKey])) {
  211.             return $this->componentsCache[$cacheKey];
  212.         }
  213.         $attributes parent::getAttributes($object$format$context);
  214.         $options $this->getFactoryOptions($context);
  215.         $components = [
  216.             'links' => [],
  217.             'relationships' => [],
  218.             'attributes' => [],
  219.             'meta' => [],
  220.         ];
  221.         foreach ($attributes as $attribute) {
  222.             /** @var ApiProperty|PropertyMetadata */
  223.             $propertyMetadata $this
  224.                 ->propertyMetadataFactory
  225.                 ->create($context['resource_class'], $attribute$options);
  226.             // TODO: 3.0 support multiple types, default value of types will be [] instead of null
  227.             $type $propertyMetadata instanceof PropertyMetadata $propertyMetadata->getType() : ($propertyMetadata->getBuiltinTypes()[0] ?? null);
  228.             $isOne $isMany false;
  229.             if (null !== $type) {
  230.                 if ($type->isCollection()) {
  231.                     $collectionValueType method_exists(Type::class, 'getCollectionValueTypes') ? ($type->getCollectionValueTypes()[0] ?? null) : $type->getCollectionValueType();
  232.                     $isMany = ($collectionValueType && $className $collectionValueType->getClassName()) ? $this->resourceClassResolver->isResourceClass($className) : false;
  233.                 } else {
  234.                     $isOne = ($className $type->getClassName()) ? $this->resourceClassResolver->isResourceClass($className) : false;
  235.                 }
  236.             }
  237.             if (!isset($className) || !$isOne && !$isMany) {
  238.                 $components['attributes'][] = $attribute;
  239.                 continue;
  240.             }
  241.             $relation = [
  242.                 'name' => $attribute,
  243.                 'type' => $this->getResourceShortName($className),
  244.                 'cardinality' => $isOne 'one' 'many',
  245.             ];
  246.             $components['relationships'][] = $relation;
  247.         }
  248.         if (false !== $context['cache_key']) {
  249.             $this->componentsCache[$cacheKey] = $components;
  250.         }
  251.         return $components;
  252.     }
  253.     /**
  254.      * Populates relationships keys.
  255.      *
  256.      * @param object $object
  257.      *
  258.      * @throws UnexpectedValueException
  259.      */
  260.     private function getPopulatedRelations($object, ?string $format, array $context, array $relationships): array
  261.     {
  262.         $data = [];
  263.         if (!isset($context['resource_class'])) {
  264.             return $data;
  265.         }
  266.         unset($context['api_included']);
  267.         foreach ($relationships as $relationshipDataArray) {
  268.             $relationshipName $relationshipDataArray['name'];
  269.             $attributeValue $this->getAttributeValue($object$relationshipName$format$context);
  270.             if ($this->nameConverter) {
  271.                 $relationshipName $this->nameConverter->normalize($relationshipName$context['resource_class'], self::FORMAT$context);
  272.             }
  273.             if (!$attributeValue) {
  274.                 continue;
  275.             }
  276.             $data[$relationshipName] = [
  277.                 'data' => [],
  278.             ];
  279.             // Many to one relationship
  280.             if ('one' === $relationshipDataArray['cardinality']) {
  281.                 unset($attributeValue['data']['attributes']);
  282.                 $data[$relationshipName] = $attributeValue;
  283.                 continue;
  284.             }
  285.             // Many to many relationship
  286.             foreach ($attributeValue as $attributeValueElement) {
  287.                 if (!isset($attributeValueElement['data'])) {
  288.                     throw new UnexpectedValueException(sprintf('The JSON API attribute \'%s\' must contain a "data" key.'$relationshipName));
  289.                 }
  290.                 unset($attributeValueElement['data']['attributes']);
  291.                 $data[$relationshipName]['data'][] = $attributeValueElement['data'];
  292.             }
  293.         }
  294.         return $data;
  295.     }
  296.     /**
  297.      * Populates included keys.
  298.      */
  299.     private function getRelatedResources($object, ?string $format, array $context, array $relationships): array
  300.     {
  301.         if (!isset($context['api_included'])) {
  302.             return [];
  303.         }
  304.         $included = [];
  305.         foreach ($relationships as $relationshipDataArray) {
  306.             $relationshipName $relationshipDataArray['name'];
  307.             if (!$this->shouldIncludeRelation($relationshipName$context)) {
  308.                 continue;
  309.             }
  310.             $relationContext $context;
  311.             $relationContext['api_included'] = $this->getIncludedNestedResources($relationshipName$context);
  312.             $attributeValue $this->getAttributeValue($object$relationshipName$format$relationContext);
  313.             if (!$attributeValue) {
  314.                 continue;
  315.             }
  316.             // Many to many relationship
  317.             $attributeValues $attributeValue;
  318.             // Many to one relationship
  319.             if ('one' === $relationshipDataArray['cardinality']) {
  320.                 $attributeValues = [$attributeValue];
  321.             }
  322.             foreach ($attributeValues as $attributeValueElement) {
  323.                 if (isset($attributeValueElement['data'])) {
  324.                     $this->addIncluded($attributeValueElement['data'], $included$context);
  325.                     if (isset($attributeValueElement['included']) && \is_array($attributeValueElement['included'])) {
  326.                         foreach ($attributeValueElement['included'] as $include) {
  327.                             $this->addIncluded($include$included$context);
  328.                         }
  329.                     }
  330.                 }
  331.             }
  332.         }
  333.         return $included;
  334.     }
  335.     /**
  336.      * Add data to included array if it's not already included.
  337.      */
  338.     private function addIncluded(array $data, array &$included, array &$context): void
  339.     {
  340.         if (isset($data['id']) && !\in_array($data['id'], $context['api_included_resources'], true)) {
  341.             $included[] = $data;
  342.             // Track already included resources
  343.             $context['api_included_resources'][] = $data['id'];
  344.         }
  345.     }
  346.     /**
  347.      * Figures out if the relationship is in the api_included hash or has included nested resources (path).
  348.      */
  349.     private function shouldIncludeRelation(string $relationshipName, array $context): bool
  350.     {
  351.         $normalizedName $this->nameConverter $this->nameConverter->normalize($relationshipName$context['resource_class'], self::FORMAT$context) : $relationshipName;
  352.         return \in_array($normalizedName$context['api_included'], true) || \count($this->getIncludedNestedResources($relationshipName$context)) > 0;
  353.     }
  354.     /**
  355.      * Returns the names of the nested resources from a path relationship.
  356.      */
  357.     private function getIncludedNestedResources(string $relationshipName, array $context): array
  358.     {
  359.         $normalizedName $this->nameConverter $this->nameConverter->normalize($relationshipName$context['resource_class'], self::FORMAT$context) : $relationshipName;
  360.         $filtered array_filter($context['api_included'] ?? [], static function (string $included) use ($normalizedName) {
  361.             return str_starts_with($included$normalizedName.'.');
  362.         });
  363.         return array_map(static function (string $nested) {
  364.             return substr($nestedstrpos($nested'.') + 1);
  365.         }, $filtered);
  366.     }
  367.     // TODO: 3.0 remove
  368.     private function getResourceShortName(string $resourceClass): string
  369.     {
  370.         /** @var ResourceMetadata|ResourceMetadataCollection */
  371.         $resourceMetadata $this->resourceMetadataFactory->create($resourceClass);
  372.         if ($resourceMetadata instanceof ResourceMetadata) {
  373.             return $resourceMetadata->getShortName();
  374.         }
  375.         return $resourceMetadata->getOperation()->getShortName();
  376.     }
  377. }
  378. class_alias(ItemNormalizer::class, \ApiPlatform\Core\JsonApi\Serializer\ItemNormalizer::class);