diff --git a/src/Elasticsearch/Serializer/DocumentDenormalizer.php b/src/Elasticsearch/Serializer/DocumentDenormalizer.php new file mode 100644 index 0000000000..3b5c09ac8d --- /dev/null +++ b/src/Elasticsearch/Serializer/DocumentDenormalizer.php @@ -0,0 +1,115 @@ + + * + * 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\Elasticsearch\Serializer; + +use ApiPlatform\Metadata\HttpOperation; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use Symfony\Component\PropertyAccess\PropertyAccessorInterface; +use Symfony\Component\PropertyInfo\PropertyTypeExtractorInterface; +use Symfony\Component\Serializer\Mapping\ClassDiscriminatorResolverInterface; +use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface; +use Symfony\Component\Serializer\NameConverter\NameConverterInterface; +use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; +use Symfony\Component\Serializer\Normalizer\ObjectNormalizer; +use Symfony\Component\Serializer\SerializerAwareInterface; +use Symfony\Component\Serializer\SerializerInterface; + +/** + * Document denormalizer for Elasticsearch. + * + * @experimental + * + * @author Baptiste Meyer + */ +final class DocumentDenormalizer implements DenormalizerInterface, SerializerAwareInterface +{ + public const FORMAT = 'elasticsearch'; + + private readonly ObjectNormalizer $decoratedDenormalizer; + + public function __construct( + private readonly ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, + ?ClassMetadataFactoryInterface $classMetadataFactory = null, + private readonly ?NameConverterInterface $nameConverter = null, + ?PropertyAccessorInterface $propertyAccessor = null, + ?PropertyTypeExtractorInterface $propertyTypeExtractor = null, + ?ClassDiscriminatorResolverInterface $classDiscriminatorResolver = null, + ?callable $objectClassResolver = null, + array $defaultContext = [], + ) { + $this->decoratedDenormalizer = new ObjectNormalizer($classMetadataFactory, $nameConverter, $propertyAccessor, $propertyTypeExtractor, $classDiscriminatorResolver, $objectClassResolver, $defaultContext); + } + + /** + * {@inheritdoc} + */ + public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool + { + return self::FORMAT === $format && $this->decoratedDenormalizer->supportsDenormalization($data, $type, $format, $context); + } + + /** + * {@inheritdoc} + */ + public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): mixed + { + if (\is_string($data['_id'] ?? null) && \is_array($data['_source'] ?? null)) { + $data = $this->populateIdentifier($data, $type)['_source']; + } + + return $this->decoratedDenormalizer->denormalize($data, $type, $format, $context); + } + + /** + * Populates the resource identifier with the document identifier if not present in the original JSON document. + */ + private function populateIdentifier(array $data, string $class): array + { + $identifier = 'id'; + $resourceMetadata = $this->resourceMetadataCollectionFactory->create($class); + + $operation = $resourceMetadata->getOperation(); + if ($operation instanceof HttpOperation) { + $uriVariable = $operation->getUriVariables()[0] ?? null; + + if ($uriVariable) { + $identifier = $uriVariable->getIdentifiers()[0] ?? 'id'; + } + } + + $identifier = null === $this->nameConverter ? $identifier : $this->nameConverter->normalize($identifier, $class, self::FORMAT); + + if (!isset($data['_source'][$identifier])) { + $data['_source'][$identifier] = $data['_id']; + } + + return $data; + } + + /** + * {@inheritdoc} + */ + public function setSerializer(SerializerInterface $serializer): void + { + $this->decoratedDenormalizer->setSerializer($serializer); + } + + /** + * {@inheritdoc} + */ + public function getSupportedTypes(?string $format): array + { + return self::FORMAT === $format ? ['object' => true] : []; + } +} diff --git a/src/Elasticsearch/Serializer/DocumentNormalizer.php b/src/Elasticsearch/Serializer/DocumentNormalizer.php index d9a816cf87..7d6d69c06e 100644 --- a/src/Elasticsearch/Serializer/DocumentNormalizer.php +++ b/src/Elasticsearch/Serializer/DocumentNormalizer.php @@ -13,35 +13,30 @@ namespace ApiPlatform\Elasticsearch\Serializer; -use ApiPlatform\Metadata\HttpOperation; -use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; use Symfony\Component\PropertyInfo\PropertyTypeExtractorInterface; -use Symfony\Component\Serializer\Exception\LogicException; use Symfony\Component\Serializer\Mapping\ClassDiscriminatorResolverInterface; use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; -use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; use Symfony\Component\Serializer\Normalizer\ObjectNormalizer; use Symfony\Component\Serializer\SerializerAwareInterface; use Symfony\Component\Serializer\SerializerInterface; /** - * Document denormalizer for Elasticsearch. + * Document normalizer for Elasticsearch. * * @experimental * * @author Baptiste Meyer */ -final class DocumentNormalizer implements NormalizerInterface, DenormalizerInterface, SerializerAwareInterface +final class DocumentNormalizer implements NormalizerInterface, SerializerAwareInterface { public const FORMAT = 'elasticsearch'; private readonly ObjectNormalizer $decoratedNormalizer; public function __construct( - private readonly ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, ?ClassMetadataFactoryInterface $classMetadataFactory = null, private readonly ?NameConverterInterface $nameConverter = null, ?PropertyAccessorInterface $propertyAccessor = null, @@ -56,66 +51,32 @@ public function __construct( /** * {@inheritdoc} */ - public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool - { - return self::FORMAT === $format && $this->decoratedNormalizer->supportsDenormalization($data, $type, $format, $context); - } - - /** - * {@inheritdoc} - */ - public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): mixed + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool { - if (\is_string($data['_id'] ?? null) && \is_array($data['_source'] ?? null)) { - $data = $this->populateIdentifier($data, $type)['_source']; + // Ensure that a resource is being normalized + if (!\is_object($data)) { + return false; } - return $this->decoratedNormalizer->denormalize($data, $type, $format, $context); - } - - /** - * {@inheritdoc} - */ - public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool - { - // prevent the use of lower priority normalizers (e.g. serializer.normalizer.object) for this format + // Only normalize for the elasticsearch format return self::FORMAT === $format; } /** * {@inheritdoc} - * - * @throws LogicException */ public function normalize(mixed $data, ?string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null { - throw new LogicException(\sprintf('%s is a write-only format.', self::FORMAT)); - } - - /** - * Populates the resource identifier with the document identifier if not present in the original JSON document. - */ - private function populateIdentifier(array $data, string $class): array - { - $identifier = 'id'; - $resourceMetadata = $this->resourceMetadataCollectionFactory->create($class); - - $operation = $resourceMetadata->getOperation(); - if ($operation instanceof HttpOperation) { - $uriVariable = $operation->getUriVariables()[0] ?? null; - - if ($uriVariable) { - $identifier = $uriVariable->getIdentifiers()[0] ?? 'id'; - } - } - - $identifier = null === $this->nameConverter ? $identifier : $this->nameConverter->normalize($identifier, $class, self::FORMAT); + $normalizedData = $this->decoratedNormalizer->normalize($data, $format, $context); - if (!isset($data['_source'][$identifier])) { - $data['_source'][$identifier] = $data['_id']; + // Add _id and _source if not already present + // This is a basic implementation and might need to be more sophisticated based on specific needs. + // It assumes 'id' is the primary identifier for the resource. + if (\is_array($normalizedData) && !isset($normalizedData['_id']) && isset($normalizedData['id'])) { + $normalizedData = ['_id' => (string) $normalizedData['id'], '_source' => $normalizedData]; } - return $data; + return $normalizedData; } /** diff --git a/src/JsonApi/Serializer/ItemDenormalizer.php b/src/JsonApi/Serializer/ItemDenormalizer.php new file mode 100644 index 0000000000..2727125549 --- /dev/null +++ b/src/JsonApi/Serializer/ItemDenormalizer.php @@ -0,0 +1,100 @@ + + * + * 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\JsonApi\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\Serializer\AbstractItemNormalizer; +use ApiPlatform\Serializer\TagCollectorInterface; +use Symfony\Component\PropertyAccess\PropertyAccessorInterface; +use Symfony\Component\Serializer\Exception\NotNormalizableValueException; +use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface; +use Symfony\Component\Serializer\NameConverter\NameConverterInterface; + +/** + * Converts JSON:API data to objects (denormalization only). + * + * @author Kévin Dunglas + * @author Amrouche Hamza + * @author Baptiste Meyer + */ +final class ItemDenormalizer extends AbstractItemNormalizer +{ + use ItemNormalizerTrait; + + 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, + protected ?TagCollectorInterface $tagCollector = null, + ) { + parent::__construct( + $propertyNameCollectionFactory, + $propertyMetadataFactory, + $iriConverter, + $resourceClassResolver, + $propertyAccessor, + $nameConverter, + $classMetadataFactory, + $defaultContext, + $resourceMetadataCollectionFactory, + $resourceAccessChecker, + $tagCollector + ); + } + + /** + * {@inheritdoc} + * + * @throws NotNormalizableValueException + */ + public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): mixed + { + // Avoid issues with proxies if we populated the object + if (!isset($context[self::OBJECT_TO_POPULATE]) && isset($data['data']['id'])) { + if (true !== ($context['api_allow_update'] ?? true)) { + throw new NotNormalizableValueException('Update is not allowed for this operation.'); + } + + $context[self::OBJECT_TO_POPULATE] = $this->iriConverter->getResourceFromIri( + $data['data']['id'], + $context + ['fetch_data' => false] + ); + } + + // Merge attributes and relationships, into format expected by the parent normalizer + $dataToDenormalize = array_merge( + $data['data']['attributes'] ?? [], + $data['data']['relationships'] ?? [] + ); + + return parent::denormalize( + $dataToDenormalize, + $type, + $format, + $context + ); + } +} diff --git a/src/JsonApi/Serializer/ItemNormalizer.php b/src/JsonApi/Serializer/ItemNormalizer.php index d5926d38eb..21a35026f1 100644 --- a/src/JsonApi/Serializer/ItemNormalizer.php +++ b/src/JsonApi/Serializer/ItemNormalizer.php @@ -22,11 +22,8 @@ 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\CacheKeyTrait; -use ApiPlatform\Serializer\ContextTrait; use ApiPlatform\Serializer\TagCollectorInterface; use Symfony\Component\ErrorHandler\Exception\FlattenException; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; @@ -48,16 +45,12 @@ * @author Kévin Dunglas * @author Amrouche Hamza * @author Baptiste Meyer + * + * @todo Denormalization methods should be deprecated in 5.x, use ItemDenormalizer instead */ final class ItemNormalizer extends AbstractItemNormalizer { - use CacheKeyTrait; - use ClassInfoTrait; - use ContextTrait; - - public const FORMAT = 'jsonapi'; - - private array $componentsCache = []; + use ItemNormalizerTrait; 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, protected ?TagCollectorInterface $tagCollector = null) { diff --git a/src/JsonApi/Serializer/ItemNormalizerTrait.php b/src/JsonApi/Serializer/ItemNormalizerTrait.php new file mode 100644 index 0000000000..8fb99c5456 --- /dev/null +++ b/src/JsonApi/Serializer/ItemNormalizerTrait.php @@ -0,0 +1,473 @@ + + * + * 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\JsonApi\Serializer; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\Exception\ItemNotFoundException; +use ApiPlatform\Metadata\UrlGeneratorInterface; +use ApiPlatform\Metadata\Util\ClassInfoTrait; +use ApiPlatform\Metadata\Util\TypeHelper; +use ApiPlatform\Serializer\CacheKeyTrait; +use ApiPlatform\Serializer\ContextTrait; +use Symfony\Component\ErrorHandler\Exception\FlattenException; +use Symfony\Component\PropertyInfo\PropertyInfoExtractor; +use Symfony\Component\Serializer\Exception\LogicException; +use Symfony\Component\Serializer\Exception\NotNormalizableValueException; +use Symfony\Component\Serializer\Exception\RuntimeException; +use Symfony\Component\Serializer\Exception\UnexpectedValueException; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; +use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\Type\CompositeTypeInterface; +use Symfony\Component\TypeInfo\Type\ObjectType; + +/** + * Shared functionality for JSON:API item normalization and denormalization. + * + * @author Kévin Dunglas + * @author Amrouche Hamza + * @author Baptiste Meyer + * + * @internal + */ +trait ItemNormalizerTrait +{ + use CacheKeyTrait; + use ClassInfoTrait; + use ContextTrait; + + public const FORMAT = 'jsonapi'; + + private array $componentsCache = []; + + /** + * {@inheritdoc} + */ + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool + { + return self::FORMAT === $format && parent::supportsNormalization($data, $format, $context) && !($data instanceof \Exception || $data instanceof FlattenException); + } + + /** + * {@inheritdoc} + */ + public function getSupportedTypes(?string $format): array + { + return self::FORMAT === $format ? parent::getSupportedTypes($format) : []; + } + + /** + * {@inheritdoc} + */ + public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool + { + return self::FORMAT === $format && parent::supportsDenormalization($data, $type, $format, $context); + } + + /** + * {@inheritdoc} + */ + protected function getAttributes(object $object, ?string $format = null, array $context = []): array + { + return $this->getComponents($object, $format, $context)['attributes']; + } + + /** + * {@inheritdoc} + */ + protected function setAttributeValue(object $object, string $attribute, mixed $value, ?string $format = null, array $context = []): void + { + parent::setAttributeValue($object, $attribute, \is_array($value) && \array_key_exists('data', $value) ? $value['data'] : $value, $format, $context); + } + + /** + * {@inheritdoc} + * + * @see http://jsonapi.org/format/#document-resource-object-linkage + * + * @throws RuntimeException + * @throws UnexpectedValueException + */ + protected function denormalizeRelation(string $attributeName, ApiProperty $propertyMetadata, string $className, mixed $value, ?string $format, array $context): ?object + { + if (!\is_array($value) || !isset($value['id'], $value['type'])) { + throw new UnexpectedValueException('Only resource linkage supported currently, see: http://jsonapi.org/format/#document-resource-object-linkage.'); + } + + try { + return $this->iriConverter->getResourceFromIri($value['id'], $context + ['fetch_data' => true]); + } catch (ItemNotFoundException $e) { + if (!isset($context['not_normalizable_value_exceptions'])) { + throw new RuntimeException($e->getMessage(), $e->getCode(), $e); + } + $context['not_normalizable_value_exceptions'][] = NotNormalizableValueException::createForUnexpectedDataType( + $e->getMessage(), + $value, + [$className], + $context['deserialization_path'] ?? null, + true, + $e->getCode(), + $e + ); + + return null; + } + } + + /** + * {@inheritdoc} + * + * @see http://jsonapi.org/format/#document-resource-object-linkage + */ + protected function normalizeRelation(ApiProperty $propertyMetadata, ?object $relatedObject, string $resourceClass, ?string $format, array $context): \ArrayObject|array|string|null + { + if (null !== $relatedObject) { + $iri = $this->iriConverter->getIriFromResource($relatedObject); + $context['iri'] = $iri; + + if (!$this->tagCollector && isset($context['resources'])) { + $context['resources'][$iri] = $iri; + } + } + + if (null === $relatedObject || isset($context['api_included'])) { + if (!$this->serializer instanceof NormalizerInterface) { + throw new LogicException(\sprintf('The injected serializer must be an instance of "%s".', NormalizerInterface::class)); + } + + $normalizedRelatedObject = $this->serializer->normalize($relatedObject, $format, $context); + if (!\is_string($normalizedRelatedObject) && !\is_array($normalizedRelatedObject) && !$normalizedRelatedObject instanceof \ArrayObject && null !== $normalizedRelatedObject) { + throw new UnexpectedValueException('Expected normalized relation to be an IRI, array, \ArrayObject or null'); + } + + return $normalizedRelatedObject; + } + + $context['data'] = [ + 'data' => [ + 'type' => $this->getResourceShortName($resourceClass), + 'id' => $iri, + ], + ]; + + $context['iri'] = $iri; + $context['object'] = $relatedObject; + unset($context['property_metadata']); + unset($context['api_attribute']); + + if ($this->tagCollector) { + $this->tagCollector->collect($context); + } + + return $context['data']; + } + + /** + * {@inheritdoc} + */ + protected function isAllowedAttribute(object|string $classOrObject, string $attribute, ?string $format = null, array $context = []): bool + { + return preg_match('/^\\w[-\\w_]*$/', $attribute) && parent::isAllowedAttribute($classOrObject, $attribute, $format, $context); + } + + /** + * Gets JSON API components of the resource: attributes, relationships, meta and links. + */ + 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 = [ + 'links' => [], + 'relationships' => [], + 'attributes' => [], + 'meta' => [], + ]; + + foreach ($attributes as $attribute) { + $propertyMetadata = $this + ->propertyMetadataFactory + ->create($context['resource_class'], $attribute, $options); + + // prevent declaring $attribute as attribute if it's already declared as relationship + $isRelationship = false; + + if (!method_exists(PropertyInfoExtractor::class, 'getType')) { + $types = $propertyMetadata->getBuiltinTypes() ?? []; + + foreach ($types as $type) { + $isOne = $isMany = false; + + if ($type->isCollection()) { + $collectionValueType = $type->getCollectionValueTypes()[0] ?? null; + $isMany = $collectionValueType && ($className = $collectionValueType->getClassName()) && $this->resourceClassResolver->isResourceClass($className); + } else { + $isOne = ($className = $type->getClassName()) && $this->resourceClassResolver->isResourceClass($className); + } + + if (!isset($className) || !$isOne && !$isMany) { + // don't declare it as an attribute too quick: maybe the next type is a valid resource + continue; + } + + $relation = [ + 'name' => $attribute, + 'type' => $this->getResourceShortName($className), + 'cardinality' => $isOne ? 'one' : 'many', + ]; + + // if we specify the uriTemplate, generates its value for link definition + // @see ApiPlatform\Serializer\AbstractItemNormalizer:getAttributeValue logic for intentional duplicate content + if ($itemUriTemplate = $propertyMetadata->getUriTemplate()) { + $attributeValue = $this->propertyAccessor->getValue($object, $attribute); + $resourceClass = $this->resourceClassResolver->getResourceClass($attributeValue, $className); + $childContext = $this->createChildContext($context, $attribute, $format); + unset($childContext['iri'], $childContext['uri_variables'], $childContext['resource_class'], $childContext['operation']); + + $operation = $this->resourceMetadataCollectionFactory->create($resourceClass)->getOperation( + operationName: $itemUriTemplate, + httpOperation: true + ); + + $components['links'][$attribute] = $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_PATH, $operation, $childContext); + } + + $components['relationships'][] = $relation; + $isRelationship = true; + } + } else { + if ($type = $propertyMetadata->getNativeType()) { + /** @var class-string|null $className */ + $className = null; + + $typeIsResourceClass = function (Type $type) use (&$className): bool { + return $type instanceof ObjectType && $this->resourceClassResolver->isResourceClass($className = $type->getClassName()); + }; + + foreach ($type instanceof CompositeTypeInterface ? $type->getTypes() : [$type] as $t) { + $isOne = $isMany = false; + + if (TypeHelper::getCollectionValueType($t)?->isSatisfiedBy($typeIsResourceClass)) { + $isMany = true; + } elseif ($t->isSatisfiedBy($typeIsResourceClass)) { + $isOne = true; + } + + if (!$className || (!$isOne && !$isMany)) { + // don't declare it as an attribute too quick: maybe the next type is a valid resource + continue; + } + + $relation = [ + 'name' => $attribute, + 'type' => $this->getResourceShortName($className), + 'cardinality' => $isOne ? 'one' : 'many', + ]; + + // if we specify the uriTemplate, generates its value for link definition + // @see ApiPlatform\Serializer\AbstractItemNormalizer:getAttributeValue logic for intentional duplicate content + if ($itemUriTemplate = $propertyMetadata->getUriTemplate()) { + $attributeValue = $this->propertyAccessor->getValue($object, $attribute); + $resourceClass = $this->resourceClassResolver->getResourceClass($attributeValue, $className); + $childContext = $this->createChildContext($context, $attribute, $format); + unset($childContext['iri'], $childContext['uri_variables'], $childContext['resource_class'], $childContext['operation']); + + $operation = $this->resourceMetadataCollectionFactory->create($resourceClass)->getOperation( + operationName: $itemUriTemplate, + httpOperation: true + ); + + $components['links'][$attribute] = $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_PATH, $operation, $childContext); + } + + $components['relationships'][] = $relation; + $isRelationship = true; + } + } + } + + // if all types are not relationships, declare it as an attribute + if (!$isRelationship) { + $components['attributes'][] = $attribute; + } + } + + if (false !== $context['cache_key']) { + $this->componentsCache[$cacheKey] = $components; + } + + return $components; + } + + /** + * Populates relationships keys. + * + * @throws UnexpectedValueException + */ + private function getPopulatedRelations(object $object, ?string $format, array $context, array $relationships): array + { + $data = []; + + if (!isset($context['resource_class'])) { + return $data; + } + + unset($context['api_included']); + foreach ($relationships as $relationshipDataArray) { + $relationshipName = $relationshipDataArray['name']; + + $attributeValue = $this->getAttributeValue($object, $relationshipName, $format, $context); + + if ($this->nameConverter) { + $relationshipName = $this->nameConverter->normalize($relationshipName, $context['resource_class'], self::FORMAT, $context); + } + + // Many to one relationship + if ('one' === $relationshipDataArray['cardinality']) { + $data[$relationshipName] = [ + 'data' => null, + ]; + + if (!$attributeValue) { + continue; + } + + unset($attributeValue['data']['attributes']); + $data[$relationshipName] = $attributeValue; + + continue; + } + + // Many to many relationship + $data[$relationshipName] = [ + 'data' => [], + ]; + + if (!$attributeValue) { + continue; + } + + foreach ($attributeValue as $attributeValueElement) { + if (!isset($attributeValueElement['data'])) { + throw new UnexpectedValueException(\sprintf('The JSON API attribute \'%s\' must contain a "data" key.', $relationshipName)); + } + unset($attributeValueElement['data']['attributes']); + $data[$relationshipName]['data'][] = $attributeValueElement['data']; + } + } + + return $data; + } + + /** + * Populates included keys. + */ + private function getRelatedResources(object $object, ?string $format, array $context, array $relationships): array + { + if (!isset($context['api_included'])) { + return []; + } + + $included = []; + foreach ($relationships as $relationshipDataArray) { + $relationshipName = $relationshipDataArray['name']; + + if (!$this->shouldIncludeRelation($relationshipName, $context)) { + continue; + } + + $relationContext = $context; + $relationContext['api_included'] = $this->getIncludedNestedResources($relationshipName, $context); + + $attributeValue = $this->getAttributeValue($object, $relationshipName, $format, $relationContext); + + if (!$attributeValue) { + continue; + } + + // Many to many relationship + $attributeValues = $attributeValue; + // Many to one relationship + if ('one' === $relationshipDataArray['cardinality']) { + $attributeValues = [$attributeValue]; + } + + foreach ($attributeValues as $attributeValueElement) { + if (isset($attributeValueElement['data'])) { + $this->addIncluded($attributeValueElement['data'], $included, $context); + if (isset($attributeValueElement['included']) && \is_array($attributeValueElement['included'])) { + foreach ($attributeValueElement['included'] as $include) { + $this->addIncluded($include, $included, $context); + } + } + } + } + } + + return $included; + } + + /** + * Add data to included array if it's not already included. + */ + private function addIncluded(array $data, array &$included, array &$context): void + { + if (isset($data['id']) && !\in_array($data['id'], $context['api_included_resources'], true)) { + $included[] = $data; + // Track already included resources + $context['api_included_resources'][] = $data['id']; + } + } + + /** + * Figures out if the relationship is in the api_included hash or has included nested resources (path). + */ + private function shouldIncludeRelation(string $relationshipName, array $context): bool + { + $normalizedName = $this->nameConverter ? $this->nameConverter->normalize($relationshipName, $context['resource_class'], self::FORMAT, $context) : $relationshipName; + + return \in_array($normalizedName, $context['api_included'], true) || \count($this->getIncludedNestedResources($relationshipName, $context)) > 0; + } + + /** + * Returns the names of the nested resources from a path relationship. + */ + private function getIncludedNestedResources(string $relationshipName, array $context): array + { + $normalizedName = $this->nameConverter ? $this->nameConverter->normalize($relationshipName, $context['resource_class'], self::FORMAT, $context) : $relationshipName; + + $filtered = array_filter($context['api_included'] ?? [], static fn (string $included): bool => str_starts_with($included, $normalizedName.'.')); + + return array_map(static fn (string $nested): string => substr($nested, strpos($nested, '.') + 1), $filtered); + } + + // TODO: this code is similar to the one used in JsonLd + private function getResourceShortName(string $resourceClass): string + { + if ($this->resourceClassResolver->isResourceClass($resourceClass)) { + $resourceMetadata = $this->resourceMetadataCollectionFactory->create($resourceClass); + + return $resourceMetadata->getOperation()->getShortName(); + } + + return (new \ReflectionClass($resourceClass))->getShortName(); + } +} diff --git a/src/JsonLd/Serializer/ItemDenormalizer.php b/src/JsonLd/Serializer/ItemDenormalizer.php new file mode 100644 index 0000000000..2fe61261fb --- /dev/null +++ b/src/JsonLd/Serializer/ItemDenormalizer.php @@ -0,0 +1,93 @@ + + * + * 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\JsonLd\Serializer; + +use ApiPlatform\Metadata\Exception\ItemNotFoundException; +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\Serializer\AbstractItemNormalizer; +use ApiPlatform\Serializer\TagCollectorInterface; +use Symfony\Component\PropertyAccess\PropertyAccessorInterface; +use Symfony\Component\Serializer\Exception\NotNormalizableValueException; +use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface; +use Symfony\Component\Serializer\NameConverter\NameConverterInterface; + +/** + * Converts JSON-LD data to objects (denormalization only). + * + * @author Kévin Dunglas + */ +final class ItemDenormalizer extends AbstractItemNormalizer +{ + use ItemNormalizerTrait; + + public function __construct( + ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, + PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, + PropertyMetadataFactoryInterface $propertyMetadataFactory, + IriConverterInterface $iriConverter, + ResourceClassResolverInterface $resourceClassResolver, + ?PropertyAccessorInterface $propertyAccessor = null, + ?NameConverterInterface $nameConverter = null, + ?ClassMetadataFactoryInterface $classMetadataFactory = null, + array $defaultContext = [], + ?ResourceAccessCheckerInterface $resourceAccessChecker = null, + protected ?TagCollectorInterface $tagCollector = null, + ) { + parent::__construct( + $propertyNameCollectionFactory, + $propertyMetadataFactory, + $iriConverter, + $resourceClassResolver, + $propertyAccessor, + $nameConverter, + $classMetadataFactory, + $defaultContext, + $resourceMetadataCollectionFactory, + $resourceAccessChecker, + $tagCollector + ); + } + + /** + * {@inheritdoc} + * + * @throws NotNormalizableValueException + */ + public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): mixed + { + // Avoid issues with proxies if we populated the object + if (isset($data['@id']) && !isset($context[self::OBJECT_TO_POPULATE])) { + if (true !== ($context['api_allow_update'] ?? true)) { + throw new NotNormalizableValueException('Update is not allowed for this operation.'); + } + + try { + $context[self::OBJECT_TO_POPULATE] = $this->iriConverter->getResourceFromIri($data['@id'], $context + ['fetch_data' => true], $context['operation'] ?? null); + } catch (ItemNotFoundException $e) { + $operation = $context['operation'] ?? null; + + if (!('PUT' === $operation?->getMethod() && ($operation->getExtraProperties()['standard_put'] ?? true))) { + throw $e; + } + } + } + + return parent::denormalize($data, $type, $format, $context); + } +} diff --git a/src/JsonLd/Serializer/ItemNormalizer.php b/src/JsonLd/Serializer/ItemNormalizer.php index 04443085ec..533b2bca4d 100644 --- a/src/JsonLd/Serializer/ItemNormalizer.php +++ b/src/JsonLd/Serializer/ItemNormalizer.php @@ -25,9 +25,7 @@ use ApiPlatform\Metadata\ResourceAccessCheckerInterface; use ApiPlatform\Metadata\ResourceClassResolverInterface; use ApiPlatform\Metadata\UrlGeneratorInterface; -use ApiPlatform\Metadata\Util\ClassInfoTrait; use ApiPlatform\Serializer\AbstractItemNormalizer; -use ApiPlatform\Serializer\ContextTrait; use ApiPlatform\Serializer\TagCollectorInterface; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; use Symfony\Component\Serializer\Exception\LogicException; @@ -39,59 +37,18 @@ * Converts between objects and array including JSON-LD and Hydra metadata. * * @author Kévin Dunglas + * + * @todo Denormalization methods should be deprecated in 5.x, use ItemDenormalizer instead */ final class ItemNormalizer extends AbstractItemNormalizer { - use ClassInfoTrait; - use ContextTrait; - use JsonLdContextTrait; - - public const FORMAT = 'jsonld'; - private const JSONLD_KEYWORDS = [ - '@context', - '@direction', - '@graph', - '@id', - '@import', - '@included', - '@index', - '@json', - '@language', - '@list', - '@nest', - '@none', - '@prefix', - '@propagate', - '@protected', - '@reverse', - '@set', - '@type', - '@value', - '@version', - '@vocab', - ]; + use ItemNormalizerTrait; public function __construct(ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface $iriConverter, ResourceClassResolverInterface $resourceClassResolver, private readonly ContextBuilderInterface $contextBuilder, ?PropertyAccessorInterface $propertyAccessor = null, ?NameConverterInterface $nameConverter = null, ?ClassMetadataFactoryInterface $classMetadataFactory = null, array $defaultContext = [], ?ResourceAccessCheckerInterface $resourceAccessChecker = null, protected ?TagCollectorInterface $tagCollector = null, private ?OperationMetadataFactoryInterface $operationMetadataFactory = null) { parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, $classMetadataFactory, $defaultContext, $resourceMetadataCollectionFactory, $resourceAccessChecker, $tagCollector); } - /** - * {@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} * @@ -168,18 +125,12 @@ public function normalize(mixed $data, ?string $format = null, array $context = return $metadata + $normalizedData; } - /** - * {@inheritdoc} - */ - public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool - { - return self::FORMAT === $format && parent::supportsDenormalization($data, $type, $format, $context); - } - /** * {@inheritdoc} * * @throws NotNormalizableValueException + * + * @todo This method should be deprecated in 5.x, use ItemDenormalizer instead */ public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): mixed { @@ -202,14 +153,4 @@ public function denormalize(mixed $data, string $type, ?string $format = null, a return parent::denormalize($data, $type, $format, $context); } - - protected function getAllowedAttributes(string|object $classOrObject, array $context, bool $attributesAsString = false): array|bool - { - $allowedAttributes = parent::getAllowedAttributes($classOrObject, $context, $attributesAsString); - if (\is_array($allowedAttributes) && ($context['api_denormalize'] ?? false)) { - $allowedAttributes = array_merge($allowedAttributes, self::JSONLD_KEYWORDS); - } - - return $allowedAttributes; - } } diff --git a/src/JsonLd/Serializer/ItemNormalizerTrait.php b/src/JsonLd/Serializer/ItemNormalizerTrait.php new file mode 100644 index 0000000000..51bdaaab15 --- /dev/null +++ b/src/JsonLd/Serializer/ItemNormalizerTrait.php @@ -0,0 +1,93 @@ + + * + * 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\JsonLd\Serializer; + +use ApiPlatform\Metadata\Util\ClassInfoTrait; +use ApiPlatform\Serializer\ContextTrait; + +/** + * Shared functionality for JSON-LD item normalization and denormalization. + * + * @author Kévin Dunglas + * + * @internal + */ +trait ItemNormalizerTrait +{ + use ClassInfoTrait; + use ContextTrait; + use JsonLdContextTrait; + + public const FORMAT = 'jsonld'; + private const JSONLD_KEYWORDS = [ + '@context', + '@direction', + '@graph', + '@id', + '@import', + '@included', + '@index', + '@json', + '@language', + '@list', + '@nest', + '@none', + '@prefix', + '@propagate', + '@protected', + '@reverse', + '@set', + '@type', + '@value', + '@version', + '@vocab', + ]; + + /** + * {@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 supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool + { + return self::FORMAT === $format && parent::supportsDenormalization($data, $type, $format, $context); + } + + /** + * Gets allowed attributes for denormalization, including JSON-LD keywords. + */ + protected function getAllowedAttributes(string|object $classOrObject, array $context, bool $attributesAsString = false): array|bool + { + $allowedAttributes = parent::getAllowedAttributes($classOrObject, $context, $attributesAsString); + if (\is_array($allowedAttributes) && ($context['api_denormalize'] ?? false)) { + $allowedAttributes = array_merge($allowedAttributes, self::JSONLD_KEYWORDS); + } + + return $allowedAttributes; + } +} diff --git a/src/Laravel/ApiPlatformProvider.php b/src/Laravel/ApiPlatformProvider.php index 8404741c22..588a39e138 100644 --- a/src/Laravel/ApiPlatformProvider.php +++ b/src/Laravel/ApiPlatformProvider.php @@ -62,12 +62,14 @@ use ApiPlatform\JsonApi\Serializer\CollectionNormalizer as JsonApiCollectionNormalizer; use ApiPlatform\JsonApi\Serializer\EntrypointNormalizer as JsonApiEntrypointNormalizer; use ApiPlatform\JsonApi\Serializer\ErrorNormalizer as JsonApiErrorNormalizer; +use ApiPlatform\JsonApi\Serializer\ItemDenormalizer as JsonApiItemDenormalizer; use ApiPlatform\JsonApi\Serializer\ItemNormalizer as JsonApiItemNormalizer; use ApiPlatform\JsonApi\Serializer\ObjectNormalizer as JsonApiObjectNormalizer; use ApiPlatform\JsonApi\Serializer\ReservedAttributeNameConverter; use ApiPlatform\JsonLd\AnonymousContextBuilderInterface; use ApiPlatform\JsonLd\ContextBuilder as JsonLdContextBuilder; use ApiPlatform\JsonLd\ContextBuilderInterface; +use ApiPlatform\JsonLd\Serializer\ItemDenormalizer as JsonLdItemDenormalizer; use ApiPlatform\JsonLd\Serializer\ItemNormalizer as JsonLdItemNormalizer; use ApiPlatform\JsonLd\Serializer\ObjectNormalizer as JsonLdObjectNormalizer; use ApiPlatform\JsonSchema\DefinitionNameFactory; @@ -139,6 +141,7 @@ use ApiPlatform\OpenApi\Factory\OpenApiFactoryInterface; use ApiPlatform\OpenApi\Options; use ApiPlatform\OpenApi\Serializer\OpenApiNormalizer; +use ApiPlatform\Serializer\ItemDenormalizer; use ApiPlatform\Serializer\ItemNormalizer; use ApiPlatform\Serializer\JsonEncoder; use ApiPlatform\Serializer\Mapping\Factory\ClassMetadataFactory as SerializerClassMetadataFactory; @@ -625,6 +628,27 @@ public function register(): void ); }); + $this->app->singleton(ItemDenormalizer::class, static function (Application $app) { + /** @var ConfigRepository */ + $config = $app['config']; + $defaultContext = $config->get('api-platform.serializer', []); + + return new ItemDenormalizer( + $app->make(PropertyNameCollectionFactoryInterface::class), + $app->make(PropertyMetadataFactoryInterface::class), + $app->make(IriConverterInterface::class), + $app->make(ResourceClassResolverInterface::class), + $app->make(PropertyAccessorInterface::class), + $app->make(NameConverterInterface::class), + $app->make(ClassMetadataFactoryInterface::class), + $app->make(LoggerInterface::class), + $app->make(ResourceMetadataCollectionFactoryInterface::class), + $app->make(ResourceAccessCheckerInterface::class), + $defaultContext, + // $app->make(TagCollectorInterface::class) + ); + }); + $this->app->bind(AnonymousContextBuilderInterface::class, JsonLdContextBuilder::class); $this->app->singleton(JsonLdObjectNormalizer::class, static function (Application $app) { @@ -922,6 +946,25 @@ public function register(): void ); }); + $this->app->singleton(JsonApiItemDenormalizer::class, static function (Application $app) { + $config = $app['config']; + $defaultContext = $config->get('api-platform.serializer', []); + + return new JsonApiItemDenormalizer( + $app->make(PropertyNameCollectionFactoryInterface::class), + $app->make(PropertyMetadataFactoryInterface::class), + $app->make(IriConverterInterface::class), + $app->make(ResourceClassResolverInterface::class), + $app->make(PropertyAccessorInterface::class), + $app->make(NameConverterInterface::class), + $app->make(ClassMetadataFactoryInterface::class), + $defaultContext, + $app->make(ResourceMetadataCollectionFactoryInterface::class), + $app->make(ResourceAccessCheckerInterface::class), + // $app->make(TagCollectorInterface::class), + ); + }); + $this->app->singleton(JsonApiErrorNormalizer::class, static function (Application $app) { return new JsonApiErrorNormalizer( $app->make(JsonApiItemNormalizer::class), @@ -946,6 +989,7 @@ public function register(): void $list->insert($app->make(HalObjectNormalizer::class), -995); $list->insert($app->make(HalItemNormalizer::class), -890); $list->insert($app->make(JsonLdItemNormalizer::class), -890); + $list->insert($app->make(JsonLdItemDenormalizer::class), -895); $list->insert($app->make(JsonLdObjectNormalizer::class), -995); $list->insert($app->make(ArrayDenormalizer::class), -990); $list->insert($app->make(DateTimeZoneNormalizer::class), -915); @@ -954,12 +998,14 @@ public function register(): void $list->insert($app->make(BackedEnumNormalizer::class), -910); $list->insert($app->make(ObjectNormalizer::class), -1000); $list->insert($app->make(ItemNormalizer::class), -895); + $list->insert($app->make(ItemDenormalizer::class), -900); $list->insert($app->make(OpenApiNormalizer::class), -780); $list->insert($app->make(HydraDocumentationNormalizer::class), -790); $list->insert($app->make(JsonApiEntrypointNormalizer::class), -800); $list->insert($app->make(JsonApiCollectionNormalizer::class), -985); $list->insert($app->make(JsonApiItemNormalizer::class), -890); + $list->insert($app->make(JsonApiItemDenormalizer::class), -895); $list->insert($app->make(JsonApiErrorNormalizer::class), -790); $list->insert($app->make(JsonApiObjectNormalizer::class), -995); @@ -1017,6 +1063,25 @@ public function register(): void ); }); + $this->app->singleton(JsonLdItemDenormalizer::class, static function (Application $app) { + $config = $app['config']; + $defaultContext = $config->get('api-platform.serializer', []); + + return new JsonLdItemDenormalizer( + $app->make(ResourceMetadataCollectionFactoryInterface::class), + $app->make(PropertyNameCollectionFactoryInterface::class), + $app->make(PropertyMetadataFactoryInterface::class), + $app->make(IriConverterInterface::class), + $app->make(ResourceClassResolverInterface::class), + $app->make(PropertyAccessorInterface::class), + $app->make(NameConverterInterface::class), + $app->make(ClassMetadataFactoryInterface::class), + $defaultContext, + $app->make(ResourceAccessCheckerInterface::class), + // $app->make(TagCollectorInterface::class) + ); + }); + $this->app->singleton(InflectorInterface::class, static function (Application $app) { return new Inflector(); }); diff --git a/src/Serializer/ItemDenormalizer.php b/src/Serializer/ItemDenormalizer.php new file mode 100644 index 0000000000..ce4206617e --- /dev/null +++ b/src/Serializer/ItemDenormalizer.php @@ -0,0 +1,56 @@ + + * + * 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\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 Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; +use Symfony\Component\PropertyAccess\PropertyAccessorInterface; +use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface; +use Symfony\Component\Serializer\NameConverter\NameConverterInterface; + +/** + * Converts data to objects (denormalization only). + * + * @author Kévin Dunglas + */ +class ItemDenormalizer extends AbstractItemNormalizer +{ + use ItemNormalizerTrait; + private readonly LoggerInterface $logger; + + public function __construct( + PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, + PropertyMetadataFactoryInterface $propertyMetadataFactory, + IriConverterInterface $iriConverter, + ResourceClassResolverInterface $resourceClassResolver, + ?PropertyAccessorInterface $propertyAccessor = null, + ?NameConverterInterface $nameConverter = null, + ?ClassMetadataFactoryInterface $classMetadataFactory = null, + ?LoggerInterface $logger = null, + ?ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory = null, + ?ResourceAccessCheckerInterface $resourceAccessChecker = null, + array $defaultContext = [], + protected ?TagCollectorInterface $tagCollector = null, + ) { + parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, $classMetadataFactory, $defaultContext, $resourceMetadataFactory, $resourceAccessChecker, $tagCollector); + + $this->logger = $logger ?: new NullLogger(); + } +} diff --git a/src/Serializer/ItemNormalizer.php b/src/Serializer/ItemNormalizer.php index ee0d7ddad3..ec513dad34 100644 --- a/src/Serializer/ItemNormalizer.php +++ b/src/Serializer/ItemNormalizer.php @@ -13,20 +13,15 @@ namespace ApiPlatform\Serializer; -use ApiPlatform\Metadata\Exception\InvalidArgumentException; -use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\IriConverterInterface; -use ApiPlatform\Metadata\Operation; 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 Psr\Log\LoggerInterface; use Psr\Log\NullLogger; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; -use Symfony\Component\Serializer\Exception\NotNormalizableValueException; use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; @@ -36,86 +31,30 @@ * TODO: do not hardcode "id" * * @author Kévin Dunglas + * + * @todo Denormalization methods should be deprecated in 5.x, use ItemDenormalizer instead */ class ItemNormalizer extends AbstractItemNormalizer { + use ItemNormalizerTrait; private readonly LoggerInterface $logger; - public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface $iriConverter, ResourceClassResolverInterface $resourceClassResolver, ?PropertyAccessorInterface $propertyAccessor = null, ?NameConverterInterface $nameConverter = null, ?ClassMetadataFactoryInterface $classMetadataFactory = null, ?LoggerInterface $logger = null, ?ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory = null, ?ResourceAccessCheckerInterface $resourceAccessChecker = null, array $defaultContext = [], protected ?TagCollectorInterface $tagCollector = null) - { + public function __construct( + PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, + PropertyMetadataFactoryInterface $propertyMetadataFactory, + IriConverterInterface $iriConverter, + ResourceClassResolverInterface $resourceClassResolver, + ?PropertyAccessorInterface $propertyAccessor = null, + ?NameConverterInterface $nameConverter = null, + ?ClassMetadataFactoryInterface $classMetadataFactory = null, + ?LoggerInterface $logger = null, + ?ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory = null, + ?ResourceAccessCheckerInterface $resourceAccessChecker = null, + array $defaultContext = [], + protected ?TagCollectorInterface $tagCollector = null, + ) { parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, $classMetadataFactory, $defaultContext, $resourceMetadataFactory, $resourceAccessChecker, $tagCollector); $this->logger = $logger ?: new NullLogger(); } - - /** - * {@inheritdoc} - * - * @throws NotNormalizableValueException - */ - public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): mixed - { - // Avoid issues with proxies if we populated the object - if (isset($data['id']) && !isset($context[self::OBJECT_TO_POPULATE])) { - if (isset($context['api_allow_update']) && true !== $context['api_allow_update']) { - throw new NotNormalizableValueException('Update is not allowed for this operation.'); - } - - if (isset($context['resource_class'])) { - if ($this->updateObjectToPopulate($data, $context)) { - unset($data['id']); - } - } else { - // See https://github.com/api-platform/core/pull/2326 to understand this message. - $this->logger->warning('The "resource_class" key is missing from the context.', [ - 'context' => $context, - ]); - } - } - - return parent::denormalize($data, $type, $format, $context); - } - - private function updateObjectToPopulate(array $data, array &$context): bool - { - try { - $context[self::OBJECT_TO_POPULATE] = $this->iriConverter->getResourceFromIri((string) $data['id'], $context + ['fetch_data' => true]); - - return true; - } catch (InvalidArgumentException) { - $operation = $this->resourceMetadataCollectionFactory?->create($context['resource_class'])->getOperation(); - if ( - !$operation || ( - null !== ($context['uri_variables'] ?? null) - && $operation instanceof HttpOperation - && \count($operation->getUriVariables() ?? []) > 1 - ) - ) { - throw new InvalidArgumentException('Cannot find object to populate, use JSON-LD or specify an IRI at path "id".'); - } - $uriVariables = $this->getContextUriVariables($data, $operation, $context); - $iri = $this->iriConverter->getIriFromResource($context['resource_class'], UrlGeneratorInterface::ABS_PATH, $operation, ['uri_variables' => $uriVariables]); - - $context[self::OBJECT_TO_POPULATE] = $this->iriConverter->getResourceFromIri($iri, $context + ['fetch_data' => true]); - } - - return false; - } - - private function getContextUriVariables(array $data, Operation $operation, array $context): array - { - $uriVariables = $context['uri_variables'] ?? []; - - if ($operation instanceof HttpOperation) { - $operationUriVariables = $operation->getUriVariables(); - if ((null !== $uriVariable = array_shift($operationUriVariables)) && \count($uriVariable->getIdentifiers())) { - $identifier = $uriVariable->getIdentifiers()[0]; - if (isset($data[$identifier])) { - $uriVariables[$uriVariable->getParameterName()] = $data[$identifier]; - } - } - } - - return $uriVariables; - } } diff --git a/src/Serializer/ItemNormalizerTrait.php b/src/Serializer/ItemNormalizerTrait.php new file mode 100644 index 0000000000..c3b03f721d --- /dev/null +++ b/src/Serializer/ItemNormalizerTrait.php @@ -0,0 +1,101 @@ + + * + * 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\Serializer; + +use ApiPlatform\Metadata\Exception\InvalidArgumentException; +use ApiPlatform\Metadata\HttpOperation; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\UrlGeneratorInterface; +use Symfony\Component\Serializer\Exception\NotNormalizableValueException; + +/** + * Shared functionality for generic item normalization and denormalization. + * + * @author Kévin Dunglas + * + * @internal + */ +trait ItemNormalizerTrait +{ + /** + * {@inheritdoc} + * + * @throws NotNormalizableValueException + */ + public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): mixed + { + // Avoid issues with proxies if we populated the object + if (isset($data['id']) && !isset($context[AbstractItemNormalizer::OBJECT_TO_POPULATE])) { + if (isset($context['api_allow_update']) && true !== $context['api_allow_update']) { + throw new NotNormalizableValueException('Update is not allowed for this operation.'); + } + + if (isset($context['resource_class'])) { + if ($this->updateObjectToPopulate($data, $context)) { + unset($data['id']); + } + } else { + // See https://github.com/api-platform/core/pull/2326 to understand this message. + $this->logger->warning('The "resource_class" key is missing from the context.', [ + 'context' => $context, + ]); + } + } + + return parent::denormalize($data, $type, $format, $context); + } + + private function updateObjectToPopulate(array $data, array &$context): bool + { + try { + $context[AbstractItemNormalizer::OBJECT_TO_POPULATE] = $this->iriConverter->getResourceFromIri((string) $data['id'], $context + ['fetch_data' => true]); + + return true; + } catch (InvalidArgumentException) { + $operation = $this->resourceMetadataCollectionFactory?->create($context['resource_class'])->getOperation(); + if ( + !$operation || ( + null !== ($context['uri_variables'] ?? null) + && $operation instanceof HttpOperation + && \count($operation->getUriVariables() ?? []) > 1 + ) + ) { + throw new InvalidArgumentException('Cannot find object to populate, use JSON-LD or specify an IRI at path "id".'); + } + $uriVariables = $this->getContextUriVariables($data, $operation, $context); + $iri = $this->iriConverter->getIriFromResource($context['resource_class'], UrlGeneratorInterface::ABS_PATH, $operation, ['uri_variables' => $uriVariables]); + + $context[AbstractItemNormalizer::OBJECT_TO_POPULATE] = $this->iriConverter->getResourceFromIri($iri, $context + ['fetch_data' => true]); + } + + return false; + } + + private function getContextUriVariables(array $data, Operation $operation, array $context): array + { + $uriVariables = $context['uri_variables'] ?? []; + + if ($operation instanceof HttpOperation) { + $operationUriVariables = $operation->getUriVariables(); + if ((null !== $uriVariable = array_shift($operationUriVariables)) && \count($uriVariable->getIdentifiers())) { + $identifier = $uriVariable->getIdentifiers()[0]; + if (isset($data[$identifier])) { + $uriVariables[$uriVariable->getParameterName()] = $data[$identifier]; + } + } + } + + return $uriVariables; + } +} diff --git a/src/Symfony/Bundle/Resources/config/api.php b/src/Symfony/Bundle/Resources/config/api.php index f3369dc43c..a618ec11b8 100644 --- a/src/Symfony/Bundle/Resources/config/api.php +++ b/src/Symfony/Bundle/Resources/config/api.php @@ -29,6 +29,7 @@ use ApiPlatform\Serializer\ConstraintViolationListNormalizer; use ApiPlatform\Serializer\Filter\GroupFilter; use ApiPlatform\Serializer\Filter\PropertyFilter; +use ApiPlatform\Serializer\ItemDenormalizer; use ApiPlatform\Serializer\ItemNormalizer; use ApiPlatform\Serializer\Mapping\Factory\ClassMetadataFactory; use ApiPlatform\Serializer\Mapping\Loader\PropertyMetadataLoader; @@ -131,6 +132,23 @@ ]) ->tag('serializer.normalizer', ['priority' => -895]); + $services->set('api_platform.serializer.normalizer.item_denormalizer', ItemDenormalizer::class) + ->args([ + service('api_platform.metadata.property.name_collection_factory'), + service('api_platform.metadata.property.metadata_factory'), + service('api_platform.iri_converter'), + service('api_platform.resource_class_resolver'), + service('api_platform.property_accessor'), + service('api_platform.name_converter')->ignoreOnInvalid(), + service('serializer.mapping.class_metadata_factory')->ignoreOnInvalid(), + null, + service('api_platform.metadata.resource.metadata_collection_factory')->ignoreOnInvalid(), + service('api_platform.security.resource_access_checker')->ignoreOnInvalid(), + [], + service('api_platform.http_cache.tag_collector')->ignoreOnInvalid(), + ]) + ->tag('serializer.normalizer', ['priority' => -900]); + $services->set('api_platform.serializer.mapping.class_metadata_factory', ClassMetadataFactory::class) ->decorate('serializer.mapping.class_metadata_factory', null, -1) ->args([service('api_platform.serializer.mapping.class_metadata_factory.inner')]); diff --git a/src/Symfony/Bundle/Resources/config/elasticsearch.php b/src/Symfony/Bundle/Resources/config/elasticsearch.php index 212fa22343..f693dfdace 100644 --- a/src/Symfony/Bundle/Resources/config/elasticsearch.php +++ b/src/Symfony/Bundle/Resources/config/elasticsearch.php @@ -20,6 +20,7 @@ use ApiPlatform\Elasticsearch\Filter\OrderFilter; use ApiPlatform\Elasticsearch\Filter\TermFilter; use ApiPlatform\Elasticsearch\Metadata\Resource\Factory\ElasticsearchProviderResourceMetadataCollectionFactory; +use ApiPlatform\Elasticsearch\Serializer\DocumentDenormalizer; use ApiPlatform\Elasticsearch\Serializer\DocumentNormalizer; use ApiPlatform\Elasticsearch\Serializer\ItemNormalizer; use ApiPlatform\Elasticsearch\Serializer\NameConverter\InnerFieldsNameConverter; @@ -36,7 +37,7 @@ ->decorate('api_platform.serializer.normalizer.item', null, 0) ->args([service('api_platform.elasticsearch.normalizer.item.inner')]); - $services->set('api_platform.elasticsearch.normalizer.document', DocumentNormalizer::class) + $services->set('api_platform.elasticsearch.denormalizer.document', DocumentDenormalizer::class) ->args([ service('api_platform.metadata.resource.metadata_collection_factory'), service('serializer.mapping.class_metadata_factory'), @@ -47,6 +48,18 @@ null, '%api_platform.serializer.default_context%', ]) + ->tag('serializer.normalizer', ['priority' => -895]); + + $services->set('api_platform.elasticsearch.normalizer.document', DocumentNormalizer::class) + ->args([ + service('serializer.mapping.class_metadata_factory'), + service('api_platform.elasticsearch.name_converter.inner_fields'), + service('serializer.property_accessor'), + service('property_info')->ignoreOnInvalid(), + service('serializer.mapping.class_discriminator_resolver')->ignoreOnInvalid(), + null, + '%api_platform.serializer.default_context%', + ]) ->tag('serializer.normalizer', ['priority' => -922]); $services->set('api_platform.elasticsearch.request_body_search_extension.filter') diff --git a/src/Symfony/Bundle/Resources/config/jsonapi.php b/src/Symfony/Bundle/Resources/config/jsonapi.php index e6a5522cb8..4bc3c412bd 100644 --- a/src/Symfony/Bundle/Resources/config/jsonapi.php +++ b/src/Symfony/Bundle/Resources/config/jsonapi.php @@ -18,6 +18,7 @@ use ApiPlatform\JsonApi\Serializer\ConstraintViolationListNormalizer; use ApiPlatform\JsonApi\Serializer\EntrypointNormalizer; use ApiPlatform\JsonApi\Serializer\ErrorNormalizer; +use ApiPlatform\JsonApi\Serializer\ItemDenormalizer; use ApiPlatform\JsonApi\Serializer\ItemNormalizer; use ApiPlatform\JsonApi\Serializer\ObjectNormalizer; use ApiPlatform\JsonApi\Serializer\ReservedAttributeNameConverter; @@ -75,6 +76,22 @@ ]) ->tag('serializer.normalizer', ['priority' => -890]); + $services->set('api_platform.jsonapi.normalizer.item_denormalizer', ItemDenormalizer::class) + ->args([ + service('api_platform.metadata.property.name_collection_factory'), + service('api_platform.metadata.property.metadata_factory'), + service('api_platform.iri_converter'), + service('api_platform.resource_class_resolver'), + service('api_platform.property_accessor'), + service('api_platform.jsonapi.name_converter.reserved_attribute_name'), + service('serializer.mapping.class_metadata_factory')->ignoreOnInvalid(), + [], + service('api_platform.metadata.resource.metadata_collection_factory'), + service('api_platform.security.resource_access_checker')->ignoreOnInvalid(), + service('api_platform.http_cache.tag_collector')->ignoreOnInvalid(), + ]) + ->tag('serializer.normalizer', ['priority' => -895]); + $services->set('api_platform.jsonapi.normalizer.object', ObjectNormalizer::class) ->args([ service('serializer.normalizer.object'), diff --git a/src/Symfony/Bundle/Resources/config/jsonld.php b/src/Symfony/Bundle/Resources/config/jsonld.php index 5f20d72239..3e5705b61e 100644 --- a/src/Symfony/Bundle/Resources/config/jsonld.php +++ b/src/Symfony/Bundle/Resources/config/jsonld.php @@ -15,6 +15,7 @@ use ApiPlatform\JsonLd\ContextBuilder; use ApiPlatform\JsonLd\Serializer\ErrorNormalizer; +use ApiPlatform\JsonLd\Serializer\ItemDenormalizer; use ApiPlatform\JsonLd\Serializer\ItemNormalizer; use ApiPlatform\JsonLd\Serializer\ObjectNormalizer; use ApiPlatform\Serializer\JsonEncoder; @@ -53,6 +54,23 @@ ]) ->tag('serializer.normalizer', ['priority' => -890]); + $services->set('api_platform.jsonld.normalizer.item_denormalizer', ItemDenormalizer::class) + ->args([ + service('api_platform.metadata.resource.metadata_collection_factory'), + service('api_platform.metadata.property.name_collection_factory'), + service('api_platform.metadata.property.metadata_factory'), + service('api_platform.iri_converter'), + service('api_platform.resource_class_resolver'), + service('api_platform.property_accessor'), + service('api_platform.name_converter')->ignoreOnInvalid(), + service('serializer.mapping.class_metadata_factory')->ignoreOnInvalid(), + '%api_platform.serializer.default_context%', + service('api_platform.security.resource_access_checker')->ignoreOnInvalid(), + service('api_platform.http_cache.tag_collector')->ignoreOnInvalid(), + service('api_platform.metadata.operation.metadata_factory')->ignoreOnInvalid(), + ]) + ->tag('serializer.normalizer', ['priority' => -895]); + $services->set('api_platform.jsonld.normalizer.error', ErrorNormalizer::class) ->args([ service('api_platform.jsonld.normalizer.item'),