* * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ declare(strict_types=1); namespace ApiPlatform\Hal\Serializer; use ApiPlatform\Metadata\IriConverterInterface; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\ResourceAccessCheckerInterface; use ApiPlatform\Metadata\ResourceClassResolverInterface; use ApiPlatform\Metadata\UrlGeneratorInterface; use ApiPlatform\Metadata\Util\ClassInfoTrait; use ApiPlatform\Metadata\Util\TypeHelper; use ApiPlatform\Serializer\AbstractItemNormalizer; use ApiPlatform\Serializer\ContextTrait; use ApiPlatform\Serializer\OperationResourceClassResolverInterface; use ApiPlatform\Serializer\TagCollectorInterface; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; use Symfony\Component\Serializer\Exception\CircularReferenceException; use Symfony\Component\Serializer\Exception\LogicException; use Symfony\Component\Serializer\Exception\UnexpectedValueException; use Symfony\Component\Serializer\Mapping\AttributeMetadataInterface; use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; use Symfony\Component\TypeInfo\Type; use Symfony\Component\TypeInfo\Type\CollectionType; use Symfony\Component\TypeInfo\Type\CompositeTypeInterface; use Symfony\Component\TypeInfo\Type\ObjectType; /** * Converts between objects and array including HAL metadata. * * @author Kévin Dunglas */ final class ItemNormalizer extends AbstractItemNormalizer { use ClassInfoTrait; use ContextTrait; public const FORMAT = 'jsonhal'; protected const HAL_CIRCULAR_REFERENCE_LIMIT_COUNTERS = 'hal_circular_reference_limit_counters'; private array $componentsCache = []; private array $attributesMetadataCache = []; public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface $iriConverter, ResourceClassResolverInterface $resourceClassResolver, ?PropertyAccessorInterface $propertyAccessor = null, ?NameConverterInterface $nameConverter = null, ?ClassMetadataFactoryInterface $classMetadataFactory = null, array $defaultContext = [], ?ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory = null, ?ResourceAccessCheckerInterface $resourceAccessChecker = null, ?TagCollectorInterface $tagCollector = null, ?OperationResourceClassResolverInterface $operationResourceResolver = null) { $defaultContext[AbstractNormalizer::CIRCULAR_REFERENCE_HANDLER] = function ($object): ?array { $iri = $this->iriConverter->getIriFromResource($object); if (null === $iri) { return null; } return ['_links' => ['self' => ['href' => $iri]]]; }; parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, $classMetadataFactory, $defaultContext, $resourceMetadataCollectionFactory, $resourceAccessChecker, $tagCollector, $operationResourceResolver); } /** * {@inheritdoc} */ public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool { return self::FORMAT === $format && parent::supportsNormalization($data, $format, $context); } /** * {@inheritdoc} */ public function getSupportedTypes(?string $format): array { return self::FORMAT === $format ? parent::getSupportedTypes($format) : []; } /** * {@inheritdoc} */ public function normalize(mixed $data, ?string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null { $resourceClass = $this->getObjectClass($data); if ($this->getOutputClass($context)) { return parent::normalize($data, $format, $context); } $previousResourceClass = $context['resource_class'] ?? null; if ($this->resourceClassResolver->isResourceClass($resourceClass) && (null === $previousResourceClass || $this->resourceClassResolver->isResourceClass($previousResourceClass))) { $resourceClass = $this->resourceClassResolver->getResourceClass($data, $previousResourceClass); } $context = $this->initContext($resourceClass, $context); $iri = $context['iri'] ??= $this->iriConverter->getIriFromResource($data, UrlGeneratorInterface::ABS_PATH, $context['operation'] ?? null, $context); $context['object'] = $data; $context['format'] = $format; $context['api_normalize'] = true; if (!isset($context['cache_key'])) { $context['cache_key'] = $this->isCacheKeySafe($context) ? $this->getCacheKey($format, $context) : false; } $normalizedData = parent::normalize($data, $format, $context); if (!\is_array($normalizedData)) { return $normalizedData; } $metadata = [ '_links' => [ 'self' => [ 'href' => $iri, ], ], ]; $components = $this->getComponents($data, $format, $context); $metadata = $this->populateRelation($metadata, $data, $format, $context, $components, 'links'); $metadata = $this->populateRelation($metadata, $data, $format, $context, $components, 'embedded'); return $metadata + $normalizedData; } /** * {@inheritdoc} */ public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool { // prevent the use of lower priority normalizers (e.g. serializer.normalizer.object) for this format return self::FORMAT === $format; } /** * {@inheritdoc} * * @throws LogicException */ public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): never { throw new LogicException(\sprintf('%s is a read-only format.', self::FORMAT)); } /** * {@inheritdoc} */ protected function getAttributes(object $object, ?string $format = null, array $context = []): array { return $this->getComponents($object, $format, $context)['states']; } /** * Gets HAL components of the resource: states, links and embedded. */ private function getComponents(object $object, ?string $format, array $context): array { $cacheKey = $this->getObjectClass($object).'-'.$context['cache_key']; if (isset($this->componentsCache[$cacheKey])) { return $this->componentsCache[$cacheKey]; } $attributes = parent::getAttributes($object, $format, $context); $options = $this->getFactoryOptions($context); $components = [ 'states' => [], 'links' => [], 'embedded' => [], ]; foreach ($attributes as $attribute) { $propertyMetadata = $this->propertyMetadataFactory->create($context['resource_class'], $attribute, $options); $type = $propertyMetadata->getNativeType(); $types = $type instanceof CompositeTypeInterface ? $type->getTypes() : (null === $type ? [] : [$type]); /** @var class-string|null $className */ $className = null; // prevent declaring $attribute as attribute if it's already declared as relationship $isRelationship = false; $typeIsResourceClass = function (Type $type) use (&$className): bool { return $type instanceof ObjectType && $this->resourceClassResolver->isResourceClass($className = $type->getClassName()); }; foreach ($types as $type) { $isOne = $isMany = false; if ($type->isSatisfiedBy(static fn ($t) => $t instanceof CollectionType)) { $isMany = TypeHelper::getCollectionValueType($type)?->isSatisfiedBy($typeIsResourceClass); } else { $isOne = $type->isSatisfiedBy($typeIsResourceClass); } if (!$isOne && !$isMany) { // don't declare it as an attribute too quick: maybe the next type is a valid resource continue; } $relation = ['name' => $attribute, 'cardinality' => $isOne ? 'one' : 'many', 'iri' => null, 'operation' => null]; // if we specify the uriTemplate, generates its value for link definition // @see ApiPlatform\Serializer\AbstractItemNormalizer:getAttributeValue logic for intentional duplicate content if (($className ?? false) && $uriTemplate = $propertyMetadata->getUriTemplate()) { $childContext = $this->createChildContext($context, $attribute, $format); unset($childContext['iri'], $childContext['uri_variables'], $childContext['resource_class'], $childContext['operation'], $childContext['operation_name']); $operation = $this->resourceMetadataCollectionFactory->create($className)->getOperation( operationName: $uriTemplate, httpOperation: true ); $relation['iri'] = $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_PATH, $operation, $childContext); $relation['operation'] = $operation; $cacheKey = null; } if ($propertyMetadata->isReadableLink()) { $components['embedded'][] = $relation; } $components['links'][] = $relation; $isRelationship = true; } // if all types are not relationships, declare it as an attribute if (!$isRelationship) { $components['states'][] = $attribute; } } if ($cacheKey && false !== $context['cache_key']) { $this->componentsCache[$cacheKey] = $components; } return $components; } /** * Populates _links and _embedded keys. */ private function populateRelation(array $data, object $object, ?string $format, array $context, array $components, string $type): array { $class = $this->getObjectClass($object); if ($this->isHalCircularReference($object, $context)) { return $this->handleHalCircularReference($object, $format, $context); } $attributesMetadata = \array_key_exists($class, $this->attributesMetadataCache) ? $this->attributesMetadataCache[$class] : $this->attributesMetadataCache[$class] = $this->classMetadataFactory ? $this->classMetadataFactory->getMetadataFor($class)->getAttributesMetadata() : null; $key = '_'.$type; foreach ($components[$type] as $relation) { if (null !== $attributesMetadata && $this->isMaxDepthReached($attributesMetadata, $class, $relation['name'], $context)) { continue; } $relationName = $relation['name']; if ($this->nameConverter) { $relationName = $this->nameConverter->normalize($relationName, $class, $format, $context); } // if we specify the uriTemplate, then the link takes the uriTemplate defined. if ('links' === $type && $iri = $relation['iri']) { $data[$key][$relationName]['href'] = $iri; continue; } $childContext = $this->createChildContext($context, $relationName, $format); unset($childContext['iri'], $childContext['uri_variables'], $childContext['operation'], $childContext['operation_name']); if ($operation = $relation['operation']) { $childContext['operation'] = $operation; $childContext['operation_name'] = $operation->getName(); } $attributeValue = $this->getAttributeValue($object, $relation['name'], $format, $childContext); if (empty($attributeValue) && ($context[self::SKIP_NULL_TO_ONE_RELATIONS] ?? $this->defaultContext[self::SKIP_NULL_TO_ONE_RELATIONS] ?? true)) { continue; } if ('one' === $relation['cardinality']) { if ('links' === $type) { if (null !== $attributeValue) { $data[$key][$relationName]['href'] = $this->getRelationIri($attributeValue); continue; } } $data[$key][$relationName] = $attributeValue; continue; } // many $data[$key][$relationName] = []; foreach ($attributeValue as $rel) { if ('links' === $type) { $rel = ['href' => $this->getRelationIri($rel)]; } $data[$key][$relationName][] = $rel; } } return $data; } /** * Gets the IRI of the given relation. * * @throws UnexpectedValueException */ private function getRelationIri(mixed $rel): string { if (!(\is_array($rel) || \is_string($rel))) { throw new UnexpectedValueException('Expected relation to be an IRI or array'); } return \is_string($rel) ? $rel : $rel['_links']['self']['href']; } /** * Is the max depth reached for the given attribute? * * @param AttributeMetadataInterface[] $attributesMetadata */ private function isMaxDepthReached(array $attributesMetadata, string $class, string $attribute, array &$context): bool { if ( !($context[self::ENABLE_MAX_DEPTH] ?? false) || !isset($attributesMetadata[$attribute]) || null === $maxDepth = $attributesMetadata[$attribute]->getMaxDepth() ) { return false; } $key = \sprintf(self::DEPTH_KEY_PATTERN, $class, $attribute); if (!isset($context[$key])) { $context[$key] = 1; return false; } if ($context[$key] === $maxDepth) { return true; } ++$context[$key]; return false; } /** * Detects if the configured circular reference limit is reached. * * @throws CircularReferenceException */ protected function isHalCircularReference(object $object, array &$context): bool { $objectId = spl_object_id($object); $circularReferenceLimit = $context[AbstractNormalizer::CIRCULAR_REFERENCE_LIMIT] ?? $this->defaultContext[AbstractNormalizer::CIRCULAR_REFERENCE_LIMIT]; if (isset($context[self::HAL_CIRCULAR_REFERENCE_LIMIT_COUNTERS][$objectId])) { if ($context[self::HAL_CIRCULAR_REFERENCE_LIMIT_COUNTERS][$objectId] >= $circularReferenceLimit) { unset($context[self::HAL_CIRCULAR_REFERENCE_LIMIT_COUNTERS][$objectId]); return true; } ++$context[self::HAL_CIRCULAR_REFERENCE_LIMIT_COUNTERS][$objectId]; } else { $context[self::HAL_CIRCULAR_REFERENCE_LIMIT_COUNTERS][$objectId] = 1; } return false; } /** * Handles a circular reference. * * If a circular reference handler is set, it will be called. Otherwise, a * {@class CircularReferenceException} will be thrown. * * @final * * @throws CircularReferenceException */ protected function handleHalCircularReference(object $object, ?string $format = null, array $context = []): mixed { $circularReferenceHandler = $context[AbstractNormalizer::CIRCULAR_REFERENCE_HANDLER] ?? $this->defaultContext[AbstractNormalizer::CIRCULAR_REFERENCE_HANDLER]; if ($circularReferenceHandler) { return $circularReferenceHandler($object, $format, $context); } throw new CircularReferenceException(\sprintf('A circular reference has been detected when serializing the object of class "%s" (configured limit: %d).', get_debug_type($object), $context[AbstractNormalizer::CIRCULAR_REFERENCE_LIMIT] ?? $this->defaultContext[AbstractNormalizer::CIRCULAR_REFERENCE_LIMIT])); } }