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

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.     /**
  53.      * {@inheritdoc}
  54.      */
  55.     public function supportsNormalization($data$format null, array $context = []): bool
  56.     {
  57.         return self::FORMAT === $format && parent::supportsNormalization($data$format$context);
  58.     }
  59.     /**
  60.      * {@inheritdoc}
  61.      *
  62.      * @return array|string|int|float|bool|\ArrayObject|null
  63.      */
  64.     public function normalize($object$format null, array $context = [])
  65.     {
  66.         $resourceClass $this->getObjectClass($object);
  67.         if ($this->getOutputClass($resourceClass$context)) {
  68.             return parent::normalize($object$format$context);
  69.         }
  70.         if (!isset($context['cache_key'])) {
  71.             $context['cache_key'] = $this->getCacheKey($format$context);
  72.         }
  73.         if ($isResourceClass $this->resourceClassResolver->isResourceClass($resourceClass)) {
  74.             $resourceClass $this->resourceClassResolver->getResourceClass($object$context['resource_class'] ?? null);
  75.         }
  76.         $context $this->initContext($resourceClass$context);
  77.         $iri $this->iriConverter instanceof LegacyIriConverterInterface $this->iriConverter->getIriFromItem($object) : $this->iriConverter->getIriFromResource($objectUrlGeneratorInterface::ABS_PATH$context['operation'] ?? null$context);
  78.         $context['iri'] = $iri;
  79.         $context['api_normalize'] = true;
  80.         $data parent::normalize($object$format$context);
  81.         if (!\is_array($data)) {
  82.             return $data;
  83.         }
  84.         // Get and populate relations
  85.         $allRelationshipsData $this->getComponents($object$format$context)['relationships'];
  86.         $populatedRelationContext $context;
  87.         $relationshipsData $this->getPopulatedRelations($object$format$populatedRelationContext$allRelationshipsData);
  88.         // Do not include primary resources
  89.         $context['api_included_resources'] = [$context['iri']];
  90.         $includedResourcesData $this->getRelatedResources($object$format$context$allRelationshipsData);
  91.         $resourceData = [
  92.             'id' => $context['iri'],
  93.             'type' => $this->getResourceShortName($resourceClass),
  94.         ];
  95.         if ($data) {
  96.             $resourceData['attributes'] = $data;
  97.         }
  98.         if ($relationshipsData) {
  99.             $resourceData['relationships'] = $relationshipsData;
  100.         }
  101.         $document = ['data' => $resourceData];
  102.         if ($includedResourcesData) {
  103.             $document['included'] = $includedResourcesData;
  104.         }
  105.         return $document;
  106.     }
  107.     /**
  108.      * {@inheritdoc}
  109.      */
  110.     public function supportsDenormalization($data$type$format null, array $context = []): bool
  111.     {
  112.         return self::FORMAT === $format && parent::supportsDenormalization($data$type$format$context);
  113.     }
  114.     /**
  115.      * {@inheritdoc}
  116.      *
  117.      * @throws NotNormalizableValueException
  118.      *
  119.      * @return mixed
  120.      */
  121.     public function denormalize($data$class$format null, array $context = [])
  122.     {
  123.         // Avoid issues with proxies if we populated the object
  124.         if (!isset($context[self::OBJECT_TO_POPULATE]) && isset($data['data']['id'])) {
  125.             if (true !== ($context['api_allow_update'] ?? true)) {
  126.                 throw new NotNormalizableValueException('Update is not allowed for this operation.');
  127.             }
  128.             $context[self::OBJECT_TO_POPULATE] = $this->iriConverter instanceof LegacyIriConverterInterface $this->iriConverter->getItemFromIri(
  129.                 $data['data']['id'],
  130.                 $context + ['fetch_data' => false]
  131.             ) : $this->iriConverter->getResourceFromIri(
  132.                 $data['data']['id'],
  133.                 $context + ['fetch_data' => false]
  134.             );
  135.         }
  136.         // Merge attributes and relationships, into format expected by the parent normalizer
  137.         $dataToDenormalize array_merge(
  138.             $data['data']['attributes'] ?? [],
  139.             $data['data']['relationships'] ?? []
  140.         );
  141.         return parent::denormalize(
  142.             $dataToDenormalize,
  143.             $class,
  144.             $format,
  145.             $context
  146.         );
  147.     }
  148.     /**
  149.      * {@inheritdoc}
  150.      */
  151.     protected function getAttributes($object$format null, array $context = []): array
  152.     {
  153.         return $this->getComponents($object$format$context)['attributes'];
  154.     }
  155.     /**
  156.      * {@inheritdoc}
  157.      */
  158.     protected function setAttributeValue($object$attribute$value$format null, array $context = []): void
  159.     {
  160.         parent::setAttributeValue($object$attribute, \is_array($value) && \array_key_exists('data'$value) ? $value['data'] : $value$format$context);
  161.     }
  162.     /**
  163.      * {@inheritdoc}
  164.      *
  165.      * @see http://jsonapi.org/format/#document-resource-object-linkage
  166.      *
  167.      * @param ApiProperty|PropertyMetadata $propertyMetadata
  168.      *
  169.      * @throws RuntimeException
  170.      * @throws NotNormalizableValueException
  171.      */
  172.     protected function denormalizeRelation(string $attributeName$propertyMetadatastring $className$value, ?string $format, array $context)
  173.     {
  174.         if (!\is_array($value) || !isset($value['id'], $value['type'])) {
  175.             throw new NotNormalizableValueException('Only resource linkage supported currently, see: http://jsonapi.org/format/#document-resource-object-linkage.');
  176.         }
  177.         try {
  178.             return $this->iriConverter instanceof LegacyIriConverterInterface $this->iriConverter->getItemFromIri($value['id'], $context + ['fetch_data' => true]) : $this->iriConverter->getResourceFromIri($value['id'], $context + ['fetch_data' => true]);
  179.         } catch (ItemNotFoundException $e) {
  180.             throw new RuntimeException($e->getMessage(), $e->getCode(), $e);
  181.         }
  182.     }
  183.     /**
  184.      * {@inheritdoc}
  185.      *
  186.      * @param ApiProperty|PropertyMetadata $propertyMetadata
  187.      *
  188.      * @see http://jsonapi.org/format/#document-resource-object-linkage
  189.      */
  190.     protected function normalizeRelation($propertyMetadata$relatedObjectstring $resourceClass, ?string $format, array $context)
  191.     {
  192.         if (null !== $relatedObject) {
  193.             $iri $this->iriConverter instanceof LegacyIriConverterInterface $this->iriConverter->getIriFromItem($relatedObject) : $this->iriConverter->getIriFromResource($relatedObject);
  194.             $context['iri'] = $iri;
  195.             if (isset($context['resources'])) {
  196.                 $context['resources'][$iri] = $iri;
  197.             }
  198.         }
  199.         if (null === $relatedObject || isset($context['api_included'])) {
  200.             if (!$this->serializer instanceof NormalizerInterface) {
  201.                 throw new LogicException(sprintf('The injected serializer must be an instance of "%s".'NormalizerInterface::class));
  202.             }
  203.             $normalizedRelatedObject $this->serializer->normalize($relatedObject$format$context);
  204.             // @phpstan-ignore-next-line throwing an explicit exception helps debugging
  205.             if (!\is_string($normalizedRelatedObject) && !\is_array($normalizedRelatedObject) && !$normalizedRelatedObject instanceof \ArrayObject && null !== $normalizedRelatedObject) {
  206.                 throw new UnexpectedValueException('Expected normalized relation to be an IRI, array, \ArrayObject or null');
  207.             }
  208.             return $normalizedRelatedObject;
  209.         }
  210.         return [
  211.             'data' => [
  212.                 'type' => $this->getResourceShortName($resourceClass),
  213.                 'id' => $iri,
  214.             ],
  215.         ];
  216.     }
  217.     /**
  218.      * {@inheritdoc}
  219.      */
  220.     protected function isAllowedAttribute($classOrObject$attribute$format null, array $context = []): bool
  221.     {
  222.         return preg_match('/^\\w[-\\w_]*$/'$attribute) && parent::isAllowedAttribute($classOrObject$attribute$format$context);
  223.     }
  224.     /**
  225.      * Gets JSON API components of the resource: attributes, relationships, meta and links.
  226.      *
  227.      * @param object $object
  228.      */
  229.     private function getComponents($object, ?string $format, array $context): array
  230.     {
  231.         $cacheKey $this->getObjectClass($object).'-'.$context['cache_key'];
  232.         if (isset($this->componentsCache[$cacheKey])) {
  233.             return $this->componentsCache[$cacheKey];
  234.         }
  235.         $attributes parent::getAttributes($object$format$context);
  236.         $options $this->getFactoryOptions($context);
  237.         $components = [
  238.             'links' => [],
  239.             'relationships' => [],
  240.             'attributes' => [],
  241.             'meta' => [],
  242.         ];
  243.         foreach ($attributes as $attribute) {
  244.             /** @var ApiProperty|PropertyMetadata */
  245.             $propertyMetadata $this
  246.                 ->propertyMetadataFactory
  247.                 ->create($context['resource_class'], $attribute$options);
  248.             // TODO: 3.0 support multiple types, default value of types will be [] instead of null
  249.             $type $propertyMetadata instanceof PropertyMetadata $propertyMetadata->getType() : ($propertyMetadata->getBuiltinTypes()[0] ?? null);
  250.             $isOne $isMany false;
  251.             if (null !== $type) {
  252.                 if ($type->isCollection()) {
  253.                     $collectionValueType method_exists(Type::class, 'getCollectionValueTypes') ? ($type->getCollectionValueTypes()[0] ?? null) : $type->getCollectionValueType();
  254.                     $isMany = ($collectionValueType && $className $collectionValueType->getClassName()) ? $this->resourceClassResolver->isResourceClass($className) : false;
  255.                 } else {
  256.                     $isOne = ($className $type->getClassName()) ? $this->resourceClassResolver->isResourceClass($className) : false;
  257.                 }
  258.             }
  259.             if (!isset($className) || !$isOne && !$isMany) {
  260.                 $components['attributes'][] = $attribute;
  261.                 continue;
  262.             }
  263.             $relation = [
  264.                 'name' => $attribute,
  265.                 'type' => $this->getResourceShortName($className),
  266.                 'cardinality' => $isOne 'one' 'many',
  267.             ];
  268.             $components['relationships'][] = $relation;
  269.         }
  270.         if (false !== $context['cache_key']) {
  271.             $this->componentsCache[$cacheKey] = $components;
  272.         }
  273.         return $components;
  274.     }
  275.     /**
  276.      * Populates relationships keys.
  277.      *
  278.      * @param object $object
  279.      *
  280.      * @throws UnexpectedValueException
  281.      */
  282.     private function getPopulatedRelations($object, ?string $format, array $context, array $relationships): array
  283.     {
  284.         $data = [];
  285.         if (!isset($context['resource_class'])) {
  286.             return $data;
  287.         }
  288.         unset($context['api_included']);
  289.         foreach ($relationships as $relationshipDataArray) {
  290.             $relationshipName $relationshipDataArray['name'];
  291.             $attributeValue $this->getAttributeValue($object$relationshipName$format$context);
  292.             if ($this->nameConverter) {
  293.                 $relationshipName $this->nameConverter->normalize($relationshipName$context['resource_class'], self::FORMAT$context);
  294.             }
  295.             if (!$attributeValue) {
  296.                 continue;
  297.             }
  298.             $data[$relationshipName] = [
  299.                 'data' => [],
  300.             ];
  301.             // Many to one relationship
  302.             if ('one' === $relationshipDataArray['cardinality']) {
  303.                 unset($attributeValue['data']['attributes']);
  304.                 $data[$relationshipName] = $attributeValue;
  305.                 continue;
  306.             }
  307.             // Many to many relationship
  308.             foreach ($attributeValue as $attributeValueElement) {
  309.                 if (!isset($attributeValueElement['data'])) {
  310.                     throw new UnexpectedValueException(sprintf('The JSON API attribute \'%s\' must contain a "data" key.'$relationshipName));
  311.                 }
  312.                 unset($attributeValueElement['data']['attributes']);
  313.                 $data[$relationshipName]['data'][] = $attributeValueElement['data'];
  314.             }
  315.         }
  316.         return $data;
  317.     }
  318.     /**
  319.      * Populates included keys.
  320.      *
  321.      * @param mixed $object
  322.      */
  323.     private function getRelatedResources($object, ?string $format, array $context, array $relationships): array
  324.     {
  325.         if (!isset($context['api_included'])) {
  326.             return [];
  327.         }
  328.         $included = [];
  329.         foreach ($relationships as $relationshipDataArray) {
  330.             $relationshipName $relationshipDataArray['name'];
  331.             if (!$this->shouldIncludeRelation($relationshipName$context)) {
  332.                 continue;
  333.             }
  334.             $relationContext $context;
  335.             $relationContext['api_included'] = $this->getIncludedNestedResources($relationshipName$context);
  336.             $attributeValue $this->getAttributeValue($object$relationshipName$format$relationContext);
  337.             if (!$attributeValue) {
  338.                 continue;
  339.             }
  340.             // Many to many relationship
  341.             $attributeValues $attributeValue;
  342.             // Many to one relationship
  343.             if ('one' === $relationshipDataArray['cardinality']) {
  344.                 $attributeValues = [$attributeValue];
  345.             }
  346.             foreach ($attributeValues as $attributeValueElement) {
  347.                 if (isset($attributeValueElement['data'])) {
  348.                     $this->addIncluded($attributeValueElement['data'], $included$context);
  349.                     if (isset($attributeValueElement['included']) && \is_array($attributeValueElement['included'])) {
  350.                         foreach ($attributeValueElement['included'] as $include) {
  351.                             $this->addIncluded($include$included$context);
  352.                         }
  353.                     }
  354.                 }
  355.             }
  356.         }
  357.         return $included;
  358.     }
  359.     /**
  360.      * Add data to included array if it's not already included.
  361.      */
  362.     private function addIncluded(array $data, array &$included, array &$context): void
  363.     {
  364.         if (isset($data['id']) && !\in_array($data['id'], $context['api_included_resources'], true)) {
  365.             $included[] = $data;
  366.             // Track already included resources
  367.             $context['api_included_resources'][] = $data['id'];
  368.         }
  369.     }
  370.     /**
  371.      * Figures out if the relationship is in the api_included hash or has included nested resources (path).
  372.      */
  373.     private function shouldIncludeRelation(string $relationshipName, array $context): bool
  374.     {
  375.         $normalizedName $this->nameConverter $this->nameConverter->normalize($relationshipName$context['resource_class'], self::FORMAT$context) : $relationshipName;
  376.         return \in_array($normalizedName$context['api_included'], true) || \count($this->getIncludedNestedResources($relationshipName$context)) > 0;
  377.     }
  378.     /**
  379.      * Returns the names of the nested resources from a path relationship.
  380.      */
  381.     private function getIncludedNestedResources(string $relationshipName, array $context): array
  382.     {
  383.         $normalizedName $this->nameConverter $this->nameConverter->normalize($relationshipName$context['resource_class'], self::FORMAT$context) : $relationshipName;
  384.         $filtered array_filter($context['api_included'] ?? [], static function (string $included) use ($normalizedName) {
  385.             return === strpos($included$normalizedName.'.');
  386.         });
  387.         return array_map(static function (string $nested) {
  388.             return substr($nestedstrpos($nested'.') + 1);
  389.         }, $filtered);
  390.     }
  391.     // TODO: 3.0 remove
  392.     private function getResourceShortName(string $resourceClass): string
  393.     {
  394.         /** @var ResourceMetadata|ResourceMetadataCollection */
  395.         $resourceMetadata $this->resourceMetadataFactory->create($resourceClass);
  396.         if ($resourceMetadata instanceof ResourceMetadata) {
  397.             return $resourceMetadata->getShortName();
  398.         }
  399.         return $resourceMetadata->getOperation()->getShortName();
  400.     }
  401. }
  402. class_alias(ItemNormalizer::class, \ApiPlatform\Core\JsonApi\Serializer\ItemNormalizer::class);