From a23a084077e24cb9f4d35186999979f6c9c276aa Mon Sep 17 00:00:00 2001 From: soyuka Date: Tue, 5 May 2026 11:09:10 +0200 Subject: [PATCH 1/5] refactor: split normalizer/denormalizer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit | Q | A | ------------- | --- | Branch? | main | Tickets | prerequisite for #7710 | License | MIT | Doc PR | ∅ Extract a dedicated ItemDenormalizer alongside each ItemNormalizer (generic Serializer, JSON-LD, JSON:API) with shared logic in an ItemNormalizerTrait. Existing normalizers keep their denormalize support via the trait for BC. Symfony and Laravel DI register the new denormalizers. --- src/JsonApi/Serializer/ItemDenormalizer.php | 64 ++++++ src/JsonApi/Serializer/ItemNormalizer.php | 209 +----------------- .../Serializer/ItemNormalizerTrait.php | 149 +++++++++++++ src/JsonLd/Serializer/ItemDenormalizer.php | 59 +++++ src/JsonLd/Serializer/ItemNormalizer.php | 63 +----- src/JsonLd/Serializer/ItemNormalizerTrait.php | 87 ++++++++ src/Laravel/ApiPlatformProvider.php | 66 ++++++ src/Serializer/ItemDenormalizer.php | 50 +++++ src/Serializer/ItemNormalizer.php | 78 +------ src/Serializer/ItemNormalizerTrait.php | 99 +++++++++ .../ApiPlatformExtension.php | 3 + src/Symfony/Bundle/Resources/config/api.php | 19 ++ .../Bundle/Resources/config/elasticsearch.php | 4 + .../Bundle/Resources/config/jsonapi.php | 18 ++ .../Bundle/Resources/config/jsonld.php | 18 ++ 15 files changed, 648 insertions(+), 338 deletions(-) create mode 100644 src/JsonApi/Serializer/ItemDenormalizer.php create mode 100644 src/JsonApi/Serializer/ItemNormalizerTrait.php create mode 100644 src/JsonLd/Serializer/ItemDenormalizer.php create mode 100644 src/JsonLd/Serializer/ItemNormalizerTrait.php create mode 100644 src/Serializer/ItemDenormalizer.php create mode 100644 src/Serializer/ItemNormalizerTrait.php diff --git a/src/JsonApi/Serializer/ItemDenormalizer.php b/src/JsonApi/Serializer/ItemDenormalizer.php new file mode 100644 index 00000000000..8ed3a0ac319 --- /dev/null +++ b/src/JsonApi/Serializer/ItemDenormalizer.php @@ -0,0 +1,64 @@ + + * + * 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\OperationResourceClassResolverInterface; +use ApiPlatform\Serializer\TagCollectorInterface; +use Symfony\Component\PropertyAccess\PropertyAccessorInterface; +use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface; +use Symfony\Component\Serializer\NameConverter\NameConverterInterface; + +/** + * Converts JSON:API documents to objects (denormalization only). + * + * @author Kévin Dunglas + * @author Amrouche Hamza + * @author Baptiste Meyer + */ +final class ItemDenormalizer extends AbstractItemNormalizer +{ + use ItemNormalizerTrait; + + public const FORMAT = 'jsonapi'; + + 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, + ?OperationResourceClassResolverInterface $operationResourceResolver = null, + private readonly bool $useIriAsId = true, + ) { + parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, $classMetadataFactory, $defaultContext, $resourceMetadataCollectionFactory, $resourceAccessChecker, $tagCollector, $operationResourceResolver); + } + + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool + { + return false; + } +} diff --git a/src/JsonApi/Serializer/ItemNormalizer.php b/src/JsonApi/Serializer/ItemNormalizer.php index b97c1411dc2..bd885c84ed7 100644 --- a/src/JsonApi/Serializer/ItemNormalizer.php +++ b/src/JsonApi/Serializer/ItemNormalizer.php @@ -14,8 +14,6 @@ namespace ApiPlatform\JsonApi\Serializer; use ApiPlatform\Metadata\ApiProperty; -use ApiPlatform\Metadata\Exception\ItemNotFoundException; -use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\IdentifiersExtractorInterface; use ApiPlatform\Metadata\IriConverterInterface; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; @@ -36,8 +34,6 @@ use Symfony\Component\PropertyAccess\PropertyAccessorInterface; 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\Mapping\Factory\ClassMetadataFactoryInterface; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; @@ -47,7 +43,7 @@ use Symfony\Component\TypeInfo\Type\ObjectType; /** - * Converts between objects and array. + * Converts objects to JSON:API documents (normalization only). * * @author Kévin Dunglas * @author Amrouche Hamza @@ -58,11 +54,11 @@ final class ItemNormalizer extends AbstractItemNormalizer use CacheKeyTrait; use ClassInfoTrait; use ContextTrait; + use ItemNormalizerTrait; public const FORMAT = 'jsonapi'; private array $componentsCache = []; - private bool $useIriAsId; public function __construct( PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, @@ -78,31 +74,16 @@ public function __construct( protected ?TagCollectorInterface $tagCollector = null, ?OperationResourceClassResolverInterface $operationResourceResolver = null, private readonly ?IdentifiersExtractorInterface $identifiersExtractor = null, - bool $useIriAsId = true, + private readonly bool $useIriAsId = true, ) { parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, $classMetadataFactory, $defaultContext, $resourceMetadataCollectionFactory, $resourceAccessChecker, $tagCollector, $operationResourceResolver); - $this->useIriAsId = $useIriAsId; } - /** - * {@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 normalize(mixed $data, ?string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null { $resourceClass = $this->getObjectClass($data); @@ -135,7 +116,6 @@ public function normalize(mixed $data, ?string $format = null, array $context = return $normalizedData; } - // Get and populate relations ['relationships' => $allRelationshipsData, 'links' => $links] = $this->getComponents($data, $format, $context); $populatedRelationContext = $context; $relationshipsData = $this->getPopulatedRelations($data, $format, $populatedRelationContext, $allRelationshipsData); @@ -158,7 +138,6 @@ public function normalize(mixed $data, ?string $format = null, array $context = 'type' => $resourceShortName, ]; - // TODO: consider always adding links.self — it's valid per the JSON:API spec even when id is the IRI if (!$this->useIriAsId) { $resourceData['links'] = ['self' => $iri]; } @@ -186,62 +165,6 @@ public function normalize(mixed $data, ?string $format = null, array $context = return $document; } - /** - * {@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 - */ - public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): mixed - { - // When re-entering for input DTO denormalization, data has already been - // unwrapped from the JSON:API structure by the first pass. Skip extraction. - if (isset($context['api_platform_input'])) { - return parent::denormalize($data, $type, $format, $context); - } - - // 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 += ['fetch_data' => false]; - if ($this->useIriAsId) { - $context[self::OBJECT_TO_POPULATE] = $this->iriConverter->getResourceFromIri( - $data['data']['id'], - $context - ); - } else { - $operation = $context['operation'] ?? null; - if ($operation instanceof HttpOperation) { - $iri = $this->reconstructIri($type, (string) $data['data']['id'], $operation); - $context[self::OBJECT_TO_POPULATE] = $this->iriConverter->getResourceFromIri($iri, $context); - } - } - } - - // 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 - ); - } - /** * {@inheritdoc} */ @@ -251,59 +174,6 @@ protected function getAttributes(object $object, ?string $format = null, array $ } /** - * {@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 { - $context += ['fetch_data' => true]; - if ($this->useIriAsId) { - return $this->iriConverter->getResourceFromIri($value['id'], $context); - } - - /** @var HttpOperation $getOperation */ - $getOperation = $this->resourceMetadataCollectionFactory->create($className)->getOperation(httpOperation: true); - $iri = $this->reconstructIri($className, (string) $value['id'], $getOperation); - - return $this->iriConverter->getResourceFromIri($iri, $context); - } 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 @@ -336,13 +206,11 @@ protected function normalizeRelation(ApiProperty $propertyMetadata, ?object $rel $id = $this->getIdStringFromIdentifiers($identifiers); } - $relationData = [ - 'type' => $this->getResourceShortName($resourceClass), - 'id' => $id, - ]; - $context['data'] = [ - 'data' => $relationData, + 'data' => [ + 'type' => $this->getResourceShortName($resourceClass), + 'id' => $id, + ], ]; $context['iri'] = $iri; @@ -357,14 +225,6 @@ protected function normalizeRelation(ApiProperty $propertyMetadata, ?object $rel 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. */ @@ -392,7 +252,6 @@ private function getComponents(object $object, ?string $format, array $context): ->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')) { @@ -409,7 +268,6 @@ private function getComponents(object $object, ?string $format, array $context): } if (!isset($className) || !$isOne && !$isMany) { - // don't declare it as an attribute too quick: maybe the next type is a valid resource continue; } @@ -419,8 +277,6 @@ private function getComponents(object $object, ?string $format, array $context): '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); @@ -457,7 +313,6 @@ private function getComponents(object $object, ?string $format, array $context): } if (!$className || (!$isOne && !$isMany)) { - // don't declare it as an attribute too quick: maybe the next type is a valid resource continue; } @@ -467,8 +322,6 @@ private function getComponents(object $object, ?string $format, array $context): '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); @@ -489,7 +342,6 @@ private function getComponents(object $object, ?string $format, array $context): } } - // if all types are not relationships, declare it as an attribute if (!$isRelationship) { $components['attributes'][] = $attribute; } @@ -503,8 +355,6 @@ private function getComponents(object $object, ?string $format, array $context): } /** - * Populates relationships keys. - * * @throws UnexpectedValueException */ private function getPopulatedRelations(object $object, ?string $format, array $context, array $relationships): array @@ -525,11 +375,8 @@ private function getPopulatedRelations(object $object, ?string $format, array $c $relationshipName = $this->nameConverter->normalize($relationshipName, $context['resource_class'], self::FORMAT, $context); } - // Many to one relationship if ('one' === $relationshipDataArray['cardinality']) { - $data[$relationshipName] = [ - 'data' => null, - ]; + $data[$relationshipName] = ['data' => null]; if (!$attributeValue) { continue; @@ -541,10 +388,7 @@ private function getPopulatedRelations(object $object, ?string $format, array $c continue; } - // Many to many relationship - $data[$relationshipName] = [ - 'data' => [], - ]; + $data[$relationshipName] = ['data' => []]; if (!$attributeValue) { continue; @@ -562,9 +406,6 @@ private function getPopulatedRelations(object $object, ?string $format, array $c return $data; } - /** - * Populates included keys. - */ private function getRelatedResources(object $object, ?string $format, array $context, array $relationships): array { if (!isset($context['api_included'])) { @@ -588,9 +429,7 @@ private function getRelatedResources(object $object, ?string $format, array $con continue; } - // Many to many relationship $attributeValues = $attributeValue; - // Many to one relationship if ('one' === $relationshipDataArray['cardinality']) { $attributeValues = [$attributeValue]; } @@ -610,9 +449,6 @@ private function getRelatedResources(object $object, ?string $format, array $con return $included; } - /** - * Add data to included array if it's not already included. - */ private function addIncluded(array $data, array &$included, array &$context): void { $trackingKey = ($data['type'] ?? '').':'.($data['id'] ?? ''); @@ -622,9 +458,6 @@ private function addIncluded(array $data, array &$included, array &$context): vo } } - /** - * 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; @@ -632,9 +465,6 @@ private function shouldIncludeRelation(string $relationshipName, array $context) 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; @@ -653,27 +483,6 @@ private function getIdStringFromIdentifiers(array $identifiers): string return CompositeIdentifierParser::stringify($identifiers); } - /** - * Reconstructs an IRI from a resource class and a raw JSON:API id string. - * - * Maps the id to the operation's single URI variable parameter name and generates - * the IRI via IriConverter. Composite identifiers on a single Link work naturally - * since the composite string (e.g. "field1=val1;field2=val2") is passed as-is. - */ - private function reconstructIri(string $resourceClass, string $id, HttpOperation $operation): string - { - $uriVariables = $operation->getUriVariables() ?? []; - - if (\count($uriVariables) > 1) { - throw new UnexpectedValueException(\sprintf('JSON:API entity identifier mode requires operations with a single URI variable, operation "%s" has %d. Consider adding a NotExposed Get operation on the resource.', $operation->getName() ?? $operation->getUriTemplate(), \count($uriVariables))); - } - - $parameterName = array_key_first($uriVariables) ?? 'id'; - - return $this->iriConverter->getIriFromResource($resourceClass, UrlGeneratorInterface::ABS_PATH, $operation, ['uri_variables' => [$parameterName => $id]]); - } - - // TODO: this code is similar to the one used in JsonLd private function getResourceShortName(string $resourceClass): string { if ($this->resourceClassResolver->isResourceClass($resourceClass)) { diff --git a/src/JsonApi/Serializer/ItemNormalizerTrait.php b/src/JsonApi/Serializer/ItemNormalizerTrait.php new file mode 100644 index 00000000000..5b00aa13ce0 --- /dev/null +++ b/src/JsonApi/Serializer/ItemNormalizerTrait.php @@ -0,0 +1,149 @@ + + * + * 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\HttpOperation; +use ApiPlatform\Metadata\UrlGeneratorInterface; +use ApiPlatform\Serializer\AbstractItemNormalizer; +use Symfony\Component\Serializer\Exception\NotNormalizableValueException; +use Symfony\Component\Serializer\Exception\RuntimeException; +use Symfony\Component\Serializer\Exception\UnexpectedValueException; + +/** + * Shared support gates and denormalization logic for the JSON:API item (de)normalizer. + * + * @author Kévin Dunglas + * + * @internal + */ +trait ItemNormalizerTrait +{ + public function getSupportedTypes(?string $format): array + { + return self::FORMAT === $format ? parent::getSupportedTypes($format) : []; + } + + public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool + { + return self::FORMAT === $format && parent::supportsDenormalization($data, $type, $format, $context); + } + + /** + * @throws NotNormalizableValueException + */ + public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): mixed + { + // When re-entering for input DTO denormalization, data has already been + // unwrapped from the JSON:API structure by the first pass. Skip extraction. + if (isset($context['api_platform_input'])) { + return parent::denormalize($data, $type, $format, $context); + } + + // Avoid issues with proxies if we populated the object + if (!isset($context[AbstractItemNormalizer::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 += ['fetch_data' => false]; + if ($this->useIriAsId) { + $context[AbstractItemNormalizer::OBJECT_TO_POPULATE] = $this->iriConverter->getResourceFromIri($data['data']['id'], $context); + } else { + $operation = $context['operation'] ?? null; + if ($operation instanceof HttpOperation) { + $iri = $this->reconstructIri($type, (string) $data['data']['id'], $operation); + $context[AbstractItemNormalizer::OBJECT_TO_POPULATE] = $this->iriConverter->getResourceFromIri($iri, $context); + } + } + } + + $dataToDenormalize = array_merge( + $data['data']['attributes'] ?? [], + $data['data']['relationships'] ?? [] + ); + + return parent::denormalize($dataToDenormalize, $type, $format, $context); + } + + 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); + } + + 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); + } + + /** + * @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 { + $context += ['fetch_data' => true]; + if ($this->useIriAsId) { + return $this->iriConverter->getResourceFromIri($value['id'], $context); + } + + /** @var HttpOperation $getOperation */ + $getOperation = $this->resourceMetadataCollectionFactory->create($className)->getOperation(httpOperation: true); + $iri = $this->reconstructIri($className, (string) $value['id'], $getOperation); + + return $this->iriConverter->getResourceFromIri($iri, $context); + } 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; + } + } + + /** + * Maps the id to the operation's single URI variable parameter and generates the IRI. + * Composite identifiers on a single Link work naturally since the composite string + * (e.g. "field1=val1;field2=val2") is passed as-is. + */ + private function reconstructIri(string $resourceClass, string $id, HttpOperation $operation): string + { + $uriVariables = $operation->getUriVariables() ?? []; + + if (\count($uriVariables) > 1) { + throw new UnexpectedValueException(\sprintf('JSON:API entity identifier mode requires operations with a single URI variable, operation "%s" has %d. Consider adding a NotExposed Get operation on the resource.', $operation->getName() ?? $operation->getUriTemplate(), \count($uriVariables))); + } + + $parameterName = array_key_first($uriVariables) ?? 'id'; + + return $this->iriConverter->getIriFromResource($resourceClass, UrlGeneratorInterface::ABS_PATH, $operation, ['uri_variables' => [$parameterName => $id]]); + } +} diff --git a/src/JsonLd/Serializer/ItemDenormalizer.php b/src/JsonLd/Serializer/ItemDenormalizer.php new file mode 100644 index 00000000000..c1f3d53acd0 --- /dev/null +++ b/src/JsonLd/Serializer/ItemDenormalizer.php @@ -0,0 +1,59 @@ + + * + * 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\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\OperationResourceClassResolverInterface; +use ApiPlatform\Serializer\TagCollectorInterface; +use Symfony\Component\PropertyAccess\PropertyAccessorInterface; +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 const FORMAT = 'jsonld'; + + 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, ?OperationResourceClassResolverInterface $operationResourceResolver = null) + { + parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, $classMetadataFactory, $defaultContext, $resourceMetadataCollectionFactory, $resourceAccessChecker, $tagCollector, $operationResourceResolver); + } + + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool + { + return false; + } + + public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool + { + return self::FORMAT === $format && parent::supportsDenormalization($data, $type, $format, $context); + } + + public function getSupportedTypes(?string $format): array + { + return self::FORMAT === $format ? parent::getSupportedTypes($format) : []; + } +} diff --git a/src/JsonLd/Serializer/ItemNormalizer.php b/src/JsonLd/Serializer/ItemNormalizer.php index 888fa9e5058..3d4419006bb 100644 --- a/src/JsonLd/Serializer/ItemNormalizer.php +++ b/src/JsonLd/Serializer/ItemNormalizer.php @@ -15,7 +15,6 @@ use ApiPlatform\JsonLd\AnonymousContextBuilderInterface; use ApiPlatform\JsonLd\ContextBuilderInterface; -use ApiPlatform\Metadata\Exception\ItemNotFoundException; use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\IriConverterInterface; use ApiPlatform\Metadata\Operation\Factory\OperationMetadataFactoryInterface; @@ -32,7 +31,6 @@ use ApiPlatform\Serializer\TagCollectorInterface; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; use Symfony\Component\Serializer\Exception\LogicException; -use Symfony\Component\Serializer\Exception\NotNormalizableValueException; use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; @@ -45,32 +43,10 @@ final class ItemNormalizer extends AbstractItemNormalizer { use ClassInfoTrait; use ContextTrait; + use ItemNormalizerTrait; 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', - ]; 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, ?OperationResourceClassResolverInterface $operationResourceResolver = null) { @@ -195,41 +171,4 @@ public function supportsDenormalization(mixed $data, string $type, ?string $form { return self::FORMAT === $format && parent::supportsDenormalization($data, $type, $format, $context); } - - /** - * {@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); - } - - 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 00000000000..6bc141f410b --- /dev/null +++ b/src/JsonLd/Serializer/ItemNormalizerTrait.php @@ -0,0 +1,87 @@ + + * + * 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\Serializer\AbstractItemNormalizer; +use Symfony\Component\Serializer\Exception\NotNormalizableValueException; + +/** + * Shared denormalization logic for the JSON-LD item (de)normalizer. + * + * @author Kévin Dunglas + * + * @internal + */ +trait ItemNormalizerTrait +{ + 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', + ]; + + /** + * @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 (true !== ($context['api_allow_update'] ?? true)) { + throw new NotNormalizableValueException('Update is not allowed for this operation.'); + } + + try { + $context[AbstractItemNormalizer::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); + } + + 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 b187494e0c7..1e498344502 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; @@ -146,6 +148,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; @@ -668,6 +671,28 @@ 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, + null, + $app->make(OperationResourceClassResolverInterface::class), + ); + }); + $this->app->bind(AnonymousContextBuilderInterface::class, JsonLdContextBuilder::class); $this->app->singleton(JsonLdObjectNormalizer::class, static function (Application $app) { @@ -986,6 +1011,24 @@ 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), + ); + }); + $this->app->singleton(JsonApiErrorNormalizer::class, static function (Application $app) { return new JsonApiErrorNormalizer( $app->make(JsonApiItemNormalizer::class), @@ -1010,6 +1053,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), -889); $list->insert($app->make(JsonLdObjectNormalizer::class), -995); $list->insert($app->make(ArrayDenormalizer::class), -990); $list->insert($app->make(DateTimeZoneNormalizer::class), -915); @@ -1018,12 +1062,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), -894); $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), -889); $list->insert($app->make(JsonApiErrorNormalizer::class), -790); $list->insert($app->make(JsonApiObjectNormalizer::class), -995); @@ -1084,6 +1130,26 @@ 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), + null, + $app->make(OperationResourceClassResolverInterface::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 00000000000..288bf3e20ec --- /dev/null +++ b/src/Serializer/ItemDenormalizer.php @@ -0,0 +1,50 @@ + + * + * 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; + +/** + * Generic item denormalizer. + * + * @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, ?OperationResourceClassResolverInterface $operationResourceResolver = null) + { + parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, $classMetadataFactory, $defaultContext, $resourceMetadataFactory, $resourceAccessChecker, $tagCollector, $operationResourceResolver); + + $this->logger = $logger ?: new NullLogger(); + } + + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool + { + return false; + } +} diff --git a/src/Serializer/ItemNormalizer.php b/src/Serializer/ItemNormalizer.php index 051171bbe5d..affc76be905 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; @@ -39,6 +34,8 @@ */ 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, ?OperationResourceClassResolverInterface $operationResourceResolver = null) @@ -47,75 +44,4 @@ public function __construct(PropertyNameCollectionFactoryInterface $propertyName $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 00000000000..1334f598148 --- /dev/null +++ b/src/Serializer/ItemNormalizerTrait.php @@ -0,0 +1,99 @@ + + * + * 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 denormalization logic for the generic item (de)normalizer. + * + * @author Kévin Dunglas + * + * @internal + */ +trait ItemNormalizerTrait +{ + /** + * @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/DependencyInjection/ApiPlatformExtension.php b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php index 01c696c4e74..57b2cdfaac3 100644 --- a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -703,6 +703,9 @@ private function registerJsonApiConfiguration(ContainerBuilder $container, array $container->getDefinition('api_platform.jsonapi.normalizer.item') ->addArgument($config['jsonapi']['use_iri_as_id']); + + $container->getDefinition('api_platform.jsonapi.denormalizer.item') + ->addArgument($config['jsonapi']['use_iri_as_id']); } private function registerJsonLdHydraConfiguration(ContainerBuilder $container, array $formats, PhpFileLoader $loader, array $config): void diff --git a/src/Symfony/Bundle/Resources/config/api.php b/src/Symfony/Bundle/Resources/config/api.php index c1685a0c4ae..19c91120836 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; @@ -138,6 +139,24 @@ ]) ->tag('serializer.normalizer', ['priority' => -895]); + $services->set('api_platform.serializer.denormalizer.item', 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(), + service('api_platform.serializer.operation_resource_resolver'), + ]) + ->tag('serializer.normalizer', ['priority' => -894]); + $services->set('api_platform.normalizer.object', ObjectNormalizer::class) ->args([ service('serializer.mapping.class_metadata_factory'), diff --git a/src/Symfony/Bundle/Resources/config/elasticsearch.php b/src/Symfony/Bundle/Resources/config/elasticsearch.php index 212fa22343b..b1fba6f6f1b 100644 --- a/src/Symfony/Bundle/Resources/config/elasticsearch.php +++ b/src/Symfony/Bundle/Resources/config/elasticsearch.php @@ -36,6 +36,10 @@ ->decorate('api_platform.serializer.normalizer.item', null, 0) ->args([service('api_platform.elasticsearch.normalizer.item.inner')]); + $services->set('api_platform.elasticsearch.denormalizer.item', ItemNormalizer::class) + ->decorate('api_platform.serializer.denormalizer.item', null, 0) + ->args([service('api_platform.elasticsearch.denormalizer.item.inner')]); + $services->set('api_platform.elasticsearch.normalizer.document', DocumentNormalizer::class) ->args([ service('api_platform.metadata.resource.metadata_collection_factory'), diff --git a/src/Symfony/Bundle/Resources/config/jsonapi.php b/src/Symfony/Bundle/Resources/config/jsonapi.php index 6ad6d49ab4c..0270591f0d0 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; @@ -77,6 +78,23 @@ ]) ->tag('serializer.normalizer', ['priority' => -890]); + $services->set('api_platform.jsonapi.denormalizer.item', 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(), + service('api_platform.serializer.operation_resource_resolver'), + ]) + ->tag('serializer.normalizer', ['priority' => -889]); + $services->set('api_platform.jsonapi.normalizer.object', ObjectNormalizer::class) ->args([ service('api_platform.normalizer.object'), diff --git a/src/Symfony/Bundle/Resources/config/jsonld.php b/src/Symfony/Bundle/Resources/config/jsonld.php index 33859c30723..2bfc88269a5 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; @@ -54,6 +55,23 @@ ]) ->tag('serializer.normalizer', ['priority' => -890]); + $services->set('api_platform.jsonld.denormalizer.item', 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.serializer.operation_resource_resolver'), + ]) + ->tag('serializer.normalizer', ['priority' => -889]); + $services->set('api_platform.jsonld.normalizer.error', ErrorNormalizer::class) ->args([ service('api_platform.jsonld.normalizer.item'), From 9df0a0c1aed38913b5813277fc387c6f995d4d1d Mon Sep 17 00:00:00 2001 From: soyuka Date: Tue, 5 May 2026 15:11:03 +0200 Subject: [PATCH 2/5] deprecation path --- src/JsonApi/Serializer/ItemNormalizer.php | 11 +- .../Tests/Serializer/ItemDenormalizerTest.php | 80 ++++++ .../Tests/Serializer/ItemNormalizerTest.php | 2 + src/JsonLd/Serializer/ItemNormalizer.php | 11 +- src/Serializer/ItemNormalizer.php | 11 +- src/Serializer/Tests/ItemDenormalizerTest.php | 271 ++++++++++++++++++ src/Serializer/Tests/ItemNormalizerTest.php | 207 +------------ 7 files changed, 393 insertions(+), 200 deletions(-) create mode 100644 src/JsonApi/Tests/Serializer/ItemDenormalizerTest.php create mode 100644 src/Serializer/Tests/ItemDenormalizerTest.php diff --git a/src/JsonApi/Serializer/ItemNormalizer.php b/src/JsonApi/Serializer/ItemNormalizer.php index bd885c84ed7..14cc183060f 100644 --- a/src/JsonApi/Serializer/ItemNormalizer.php +++ b/src/JsonApi/Serializer/ItemNormalizer.php @@ -54,7 +54,9 @@ final class ItemNormalizer extends AbstractItemNormalizer use CacheKeyTrait; use ClassInfoTrait; use ContextTrait; - use ItemNormalizerTrait; + use ItemNormalizerTrait { + denormalize as private doDenormalize; + } public const FORMAT = 'jsonapi'; @@ -84,6 +86,13 @@ public function supportsNormalization(mixed $data, ?string $format = null, array return self::FORMAT === $format && parent::supportsNormalization($data, $format, $context) && !($data instanceof \Exception || $data instanceof FlattenException); } + public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): mixed + { + trigger_deprecation('api-platform/core', '4.4', 'Calling "denormalize()" on "%s" is deprecated, use "%s" instead.', self::class, ItemDenormalizer::class); + + return $this->doDenormalize($data, $type, $format, $context); + } + public function normalize(mixed $data, ?string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null { $resourceClass = $this->getObjectClass($data); diff --git a/src/JsonApi/Tests/Serializer/ItemDenormalizerTest.php b/src/JsonApi/Tests/Serializer/ItemDenormalizerTest.php new file mode 100644 index 00000000000..82910722ba5 --- /dev/null +++ b/src/JsonApi/Tests/Serializer/ItemDenormalizerTest.php @@ -0,0 +1,80 @@ + + * + * 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\Tests\Serializer; + +use ApiPlatform\JsonApi\Serializer\ItemDenormalizer; +use ApiPlatform\JsonApi\Serializer\ItemNormalizer; +use ApiPlatform\JsonApi\Tests\Fixtures\Dummy; +use ApiPlatform\Metadata\IriConverterInterface; +use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; +use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; +use ApiPlatform\Metadata\ResourceClassResolverInterface; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\TestCase; +use Prophecy\PhpUnit\ProphecyTrait; +use Symfony\Component\Serializer\Exception\NotNormalizableValueException; + +class ItemDenormalizerTest extends TestCase +{ + use ProphecyTrait; + + public function testSupportsDenormalizationOnlyForJsonApiFormat(): void + { + $dummy = new Dummy(); + + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true); + + $denormalizer = new ItemDenormalizer( + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceClassResolverProphecy->reveal() + ); + + $this->assertFalse($denormalizer->supportsNormalization($dummy, ItemNormalizer::FORMAT)); + $this->assertTrue($denormalizer->supportsDenormalization($dummy, Dummy::class, ItemNormalizer::FORMAT)); + $this->assertFalse($denormalizer->supportsDenormalization($dummy, Dummy::class, 'jsonld')); + } + + #[Group('legacy')] + public function testDenormalizeOnLegacyItemNormalizerIsDeprecated(): void + { + $this->expectUserDeprecationMessage('Since api-platform/core 4.4: Calling "denormalize()" on "ApiPlatform\JsonApi\Serializer\ItemNormalizer" is deprecated, use "ApiPlatform\JsonApi\Serializer\ItemDenormalizer" instead.'); + $this->expectException(NotNormalizableValueException::class); + + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + + $normalizer = new ItemNormalizer( + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceClassResolverProphecy->reveal() + ); + + $normalizer->denormalize( + ['data' => ['id' => '/dummies/1']], + Dummy::class, + ItemNormalizer::FORMAT, + ['api_allow_update' => false] + ); + } +} diff --git a/src/JsonApi/Tests/Serializer/ItemNormalizerTest.php b/src/JsonApi/Tests/Serializer/ItemNormalizerTest.php index fd4b2ea12ef..2809e2fed9b 100644 --- a/src/JsonApi/Tests/Serializer/ItemNormalizerTest.php +++ b/src/JsonApi/Tests/Serializer/ItemNormalizerTest.php @@ -34,6 +34,7 @@ use ApiPlatform\Metadata\ResourceClassResolverInterface; use ApiPlatform\Metadata\UrlGeneratorInterface; use Doctrine\Common\Collections\ArrayCollection; +use PHPUnit\Framework\Attributes\IgnoreDeprecations; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; @@ -51,6 +52,7 @@ /** * @author Amrouche Hamza */ +#[IgnoreDeprecations] class ItemNormalizerTest extends TestCase { use ProphecyTrait; diff --git a/src/JsonLd/Serializer/ItemNormalizer.php b/src/JsonLd/Serializer/ItemNormalizer.php index 3d4419006bb..7d981bbf7d6 100644 --- a/src/JsonLd/Serializer/ItemNormalizer.php +++ b/src/JsonLd/Serializer/ItemNormalizer.php @@ -43,7 +43,9 @@ final class ItemNormalizer extends AbstractItemNormalizer { use ClassInfoTrait; use ContextTrait; - use ItemNormalizerTrait; + use ItemNormalizerTrait { + denormalize as private doDenormalize; + } use JsonLdContextTrait; public const FORMAT = 'jsonld'; @@ -171,4 +173,11 @@ public function supportsDenormalization(mixed $data, string $type, ?string $form { return self::FORMAT === $format && parent::supportsDenormalization($data, $type, $format, $context); } + + public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): mixed + { + trigger_deprecation('api-platform/core', '4.4', 'Calling "denormalize()" on "%s" is deprecated, use "%s" instead.', self::class, ItemDenormalizer::class); + + return $this->doDenormalize($data, $type, $format, $context); + } } diff --git a/src/Serializer/ItemNormalizer.php b/src/Serializer/ItemNormalizer.php index affc76be905..0d683eca5da 100644 --- a/src/Serializer/ItemNormalizer.php +++ b/src/Serializer/ItemNormalizer.php @@ -34,7 +34,9 @@ */ class ItemNormalizer extends AbstractItemNormalizer { - use ItemNormalizerTrait; + use ItemNormalizerTrait { + denormalize as private doDenormalize; + } private readonly LoggerInterface $logger; @@ -44,4 +46,11 @@ public function __construct(PropertyNameCollectionFactoryInterface $propertyName $this->logger = $logger ?: new NullLogger(); } + + public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): mixed + { + trigger_deprecation('api-platform/core', '4.4', 'Calling "denormalize()" on "%s" is deprecated, use "%s" instead.', self::class, ItemDenormalizer::class); + + return $this->doDenormalize($data, $type, $format, $context); + } } diff --git a/src/Serializer/Tests/ItemDenormalizerTest.php b/src/Serializer/Tests/ItemDenormalizerTest.php new file mode 100644 index 00000000000..9fd567ca530 --- /dev/null +++ b/src/Serializer/Tests/ItemDenormalizerTest.php @@ -0,0 +1,271 @@ + + * + * 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\Tests; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Exception\InvalidArgumentException; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\IriConverterInterface; +use ApiPlatform\Metadata\Link; +use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; +use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; +use ApiPlatform\Metadata\Property\PropertyNameCollection; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; +use ApiPlatform\Metadata\ResourceClassResolverInterface; +use ApiPlatform\Metadata\UrlGeneratorInterface; +use ApiPlatform\Serializer\ItemDenormalizer; +use ApiPlatform\Serializer\Tests\Fixtures\ApiResource\Dummy; +use PHPUnit\Framework\TestCase; +use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; +use Symfony\Component\Serializer\Exception\NotNormalizableValueException; +use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; +use Symfony\Component\Serializer\SerializerInterface; + +class ItemDenormalizerTest extends TestCase +{ + use ProphecyTrait; + + public function testSupportsDenormalization(): void + { + $dummy = new Dummy(); + $std = new \stdClass(); + + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true); + $resourceClassResolverProphecy->isResourceClass(\stdClass::class)->willReturn(false); + + $denormalizer = new ItemDenormalizer( + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceClassResolverProphecy->reveal() + ); + + $this->assertFalse($denormalizer->supportsNormalization($dummy)); + $this->assertTrue($denormalizer->supportsDenormalization($dummy, Dummy::class)); + $this->assertFalse($denormalizer->supportsDenormalization($std, \stdClass::class)); + } + + public function testDenormalize(): void + { + $context = ['resource_class' => Dummy::class, 'api_allow_update' => true]; + + $propertyNameCollection = new PropertyNameCollection(['name']); + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::type('array'))->willReturn($propertyNameCollection)->shouldBeCalled(); + + $propertyMetadata = (new ApiProperty())->withReadable(true)->withWritable(true); + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::type('array'))->willReturn($propertyMetadata)->shouldBeCalled(); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass(null, Dummy::class)->willReturn(Dummy::class); + $resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + $serializerProphecy->willImplement(DenormalizerInterface::class); + + $denormalizer = new ItemDenormalizer( + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceClassResolverProphecy->reveal() + ); + $denormalizer->setSerializer($serializerProphecy->reveal()); + + $this->assertInstanceOf(Dummy::class, $denormalizer->denormalize(['name' => 'hello'], Dummy::class, null, $context)); + } + + public function testDenormalizeWithIri(): void + { + $context = ['resource_class' => Dummy::class, 'api_allow_update' => true]; + + $propertyNameCollection = new PropertyNameCollection(['id', 'name']); + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::type('array'))->willReturn($propertyNameCollection)->shouldBeCalled(); + + $propertyMetadata = (new ApiProperty())->withReadable(true)->withWritable(true); + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'id', Argument::type('array'))->willReturn($propertyMetadata)->shouldBeCalled(); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::type('array'))->willReturn($propertyMetadata)->shouldBeCalled(); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $iriConverterProphecy->getResourceFromIri('/dummies/12', ['resource_class' => Dummy::class, 'api_allow_update' => true, 'fetch_data' => true])->shouldBeCalled(); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass(null, Dummy::class)->willReturn(Dummy::class); + $resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + $serializerProphecy->willImplement(DenormalizerInterface::class); + + $denormalizer = new ItemDenormalizer( + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceClassResolverProphecy->reveal() + ); + $denormalizer->setSerializer($serializerProphecy->reveal()); + + $this->assertInstanceOf(Dummy::class, $denormalizer->denormalize(['id' => '/dummies/12', 'name' => 'hello'], Dummy::class, null, $context)); + } + + public function testDenormalizeWithIdAndUpdateNotAllowed(): void + { + $this->expectException(NotNormalizableValueException::class); + $this->expectExceptionMessage('Update is not allowed for this operation.'); + + $context = ['resource_class' => Dummy::class, 'api_allow_update' => false]; + + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + $serializerProphecy->willImplement(DenormalizerInterface::class); + + $denormalizer = new ItemDenormalizer( + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceClassResolverProphecy->reveal() + ); + $denormalizer->setSerializer($serializerProphecy->reveal()); + $denormalizer->denormalize(['id' => '12', 'name' => 'hello'], Dummy::class, null, $context); + } + + public function testDenormalizeWithIdAndNoResourceClass(): void + { + $context = []; + + $propertyNameCollection = new PropertyNameCollection(['id', 'name']); + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::type('array'))->willReturn($propertyNameCollection)->shouldBeCalled(); + + $propertyMetadata = (new ApiProperty())->withReadable(true)->withWritable(true); + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'id', Argument::type('array'))->willReturn($propertyMetadata)->shouldBeCalled(); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::type('array'))->willReturn($propertyMetadata)->shouldBeCalled(); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass(null, Dummy::class)->willReturn(Dummy::class); + $resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + $serializerProphecy->willImplement(DenormalizerInterface::class); + + $denormalizer = new ItemDenormalizer( + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceClassResolverProphecy->reveal() + ); + $denormalizer->setSerializer($serializerProphecy->reveal()); + + $object = $denormalizer->denormalize(['id' => '42', 'name' => 'hello'], Dummy::class, null, $context); + $this->assertInstanceOf(Dummy::class, $object); + $this->assertSame('42', $object->getId()); + $this->assertSame('hello', $object->getName()); + } + + public function testDenormalizeWithWrongIdAndNoResourceMetadataFactory(): void + { + $this->expectException(InvalidArgumentException::class); + $context = ['resource_class' => Dummy::class, 'api_allow_update' => true]; + + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $iriConverterProphecy->getResourceFromIri('fail', $context + ['fetch_data' => true])->willThrow(new InvalidArgumentException()); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass(null, Dummy::class)->willReturn(Dummy::class); + $resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + $serializerProphecy->willImplement(DenormalizerInterface::class); + + $denormalizer = new ItemDenormalizer( + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceClassResolverProphecy->reveal() + ); + $denormalizer->setSerializer($serializerProphecy->reveal()); + + $this->assertInstanceOf(Dummy::class, $denormalizer->denormalize(['name' => 'hello', 'id' => 'fail'], Dummy::class, null, $context)); + } + + public function testDenormalizeWithWrongId(): void + { + $context = ['resource_class' => Dummy::class, 'api_allow_update' => true]; + $operation = new Get(uriVariables: ['id' => new Link(identifiers: ['id'], parameterName: 'id')]); + $obj = new Dummy(); + + $propertyNameCollection = new PropertyNameCollection(['id', 'name']); + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::type('array'))->willReturn($propertyNameCollection)->shouldBeCalled(); + + $propertyMetadata = (new ApiProperty())->withReadable(true)->withWritable(true); + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::type('array'))->willReturn($propertyMetadata)->shouldBeCalled(); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'id', Argument::type('array'))->willReturn((new ApiProperty())->withIdentifier(true))->shouldBeCalled(); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $iriConverterProphecy->getResourceFromIri('fail', $context + ['fetch_data' => true])->willThrow(new InvalidArgumentException()); + $iriConverterProphecy->getIriFromResource(Dummy::class, UrlGeneratorInterface::ABS_PATH, $operation, ['uri_variables' => ['id' => 'fail']])->willReturn('/dummies/fail'); + $iriConverterProphecy->getResourceFromIri('/dummies/fail', $context + ['fetch_data' => true])->willReturn($obj); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass(null, Dummy::class)->willReturn(Dummy::class); + $resourceClassResolverProphecy->getResourceClass($obj, Dummy::class)->willReturn(Dummy::class); + $resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true); + + $resourceMetadataCollectionFactory = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataCollectionFactory->create(Dummy::class)->willReturn(new ResourceMetadataCollection(Dummy::class, [ + new ApiResource(operations: [$operation]), + ])); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + $serializerProphecy->willImplement(DenormalizerInterface::class); + + $denormalizer = new ItemDenormalizer( + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceClassResolverProphecy->reveal(), + null, + null, + null, + null, + $resourceMetadataCollectionFactory->reveal() + ); + $denormalizer->setSerializer($serializerProphecy->reveal()); + + $this->assertInstanceOf(Dummy::class, $denormalizer->denormalize(['name' => 'hello', 'id' => 'fail'], Dummy::class, null, $context)); + } +} diff --git a/src/Serializer/Tests/ItemNormalizerTest.php b/src/Serializer/Tests/ItemNormalizerTest.php index 3c2f06346a8..c22364efc90 100644 --- a/src/Serializer/Tests/ItemNormalizerTest.php +++ b/src/Serializer/Tests/ItemNormalizerTest.php @@ -14,25 +14,18 @@ namespace ApiPlatform\Serializer\Tests; use ApiPlatform\Metadata\ApiProperty; -use ApiPlatform\Metadata\ApiResource; -use ApiPlatform\Metadata\Exception\InvalidArgumentException; -use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\IriConverterInterface; -use ApiPlatform\Metadata\Link; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Metadata\Property\PropertyNameCollection; -use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; -use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; use ApiPlatform\Metadata\ResourceClassResolverInterface; -use ApiPlatform\Metadata\UrlGeneratorInterface; use ApiPlatform\Serializer\ItemNormalizer; use ApiPlatform\Serializer\Tests\Fixtures\ApiResource\Dummy; +use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; use Symfony\Component\Serializer\Exception\NotNormalizableValueException; -use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; use Symfony\Component\Serializer\SerializerInterface; @@ -113,100 +106,7 @@ public function testNormalize(): void $this->assertEquals(['name' => 'hello'], $normalizer->normalize($dummy, null, ['resources' => []])); } - public function testDenormalize(): void - { - $context = ['resource_class' => Dummy::class, 'api_allow_update' => true]; - - $propertyNameCollection = new PropertyNameCollection(['name']); - $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); - $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::type('array'))->willReturn($propertyNameCollection)->shouldBeCalled(); - - $propertyMetadata = (new ApiProperty())->withReadable(true)->withWritable(true); - $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::type('array'))->willReturn($propertyMetadata)->shouldBeCalled(); - - $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); - - $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); - $resourceClassResolverProphecy->getResourceClass(null, Dummy::class)->willReturn(Dummy::class); - $resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true); - - $serializerProphecy = $this->prophesize(SerializerInterface::class); - $serializerProphecy->willImplement(DenormalizerInterface::class); - $normalizer = new ItemNormalizer( - $propertyNameCollectionFactoryProphecy->reveal(), - $propertyMetadataFactoryProphecy->reveal(), - $iriConverterProphecy->reveal(), - $resourceClassResolverProphecy->reveal() - ); - $normalizer->setSerializer($serializerProphecy->reveal()); - - $this->assertInstanceOf(Dummy::class, $normalizer->denormalize(['name' => 'hello'], Dummy::class, null, $context)); - } - - public function testDenormalizeWithIri(): void - { - $context = ['resource_class' => Dummy::class, 'api_allow_update' => true]; - - $propertyNameCollection = new PropertyNameCollection(['id', 'name']); - $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); - $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::type('array'))->willReturn($propertyNameCollection)->shouldBeCalled(); - - $propertyMetadata = (new ApiProperty())->withReadable(true)->withWritable(true); - $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'id', Argument::type('array'))->willReturn($propertyMetadata)->shouldBeCalled(); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::type('array'))->willReturn($propertyMetadata)->shouldBeCalled(); - - $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); - $iriConverterProphecy->getResourceFromIri('/dummies/12', ['resource_class' => Dummy::class, 'api_allow_update' => true, 'fetch_data' => true])->shouldBeCalled(); - - $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); - $resourceClassResolverProphecy->getResourceClass(null, Dummy::class)->willReturn(Dummy::class); - $resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true); - - $serializerProphecy = $this->prophesize(SerializerInterface::class); - $serializerProphecy->willImplement(DenormalizerInterface::class); - - $normalizer = new ItemNormalizer( - $propertyNameCollectionFactoryProphecy->reveal(), - $propertyMetadataFactoryProphecy->reveal(), - $iriConverterProphecy->reveal(), - $resourceClassResolverProphecy->reveal() - ); - $normalizer->setSerializer($serializerProphecy->reveal()); - - $this->assertInstanceOf(Dummy::class, $normalizer->denormalize(['id' => '/dummies/12', 'name' => 'hello'], Dummy::class, null, $context)); - } - - public function testDenormalizeWithIdAndUpdateNotAllowed(): void - { - $this->expectException(NotNormalizableValueException::class); - $this->expectExceptionMessage('Update is not allowed for this operation.'); - - $context = ['resource_class' => Dummy::class, 'api_allow_update' => false]; - - $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); - - $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - - $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); - - $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); - - $serializerProphecy = $this->prophesize(SerializerInterface::class); - $serializerProphecy->willImplement(DenormalizerInterface::class); - - $normalizer = new ItemNormalizer( - $propertyNameCollectionFactoryProphecy->reveal(), - $propertyMetadataFactoryProphecy->reveal(), - $iriConverterProphecy->reveal(), - $resourceClassResolverProphecy->reveal() - ); - $normalizer->setSerializer($serializerProphecy->reveal()); - $normalizer->denormalize(['id' => '12', 'name' => 'hello'], Dummy::class, null, $context); - } - - public function testDenormalizeWithDefinedIri(): void + public function testNormalizeWithDefinedIri(): void { $dummy = new Dummy(); $dummy->setName('hello'); @@ -245,116 +145,29 @@ public function testDenormalizeWithDefinedIri(): void $this->assertEquals(['name' => 'hello'], $normalizer->normalize($dummy, null, ['resources' => [], 'iri' => '/custom'])); } - public function testDenormalizeWithIdAndNoResourceClass(): void - { - $context = []; - - $propertyNameCollection = new PropertyNameCollection(['id', 'name']); - $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); - $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::type('array'))->willReturn($propertyNameCollection)->shouldBeCalled(); - - $propertyMetadata = (new ApiProperty())->withReadable(true)->withWritable(true); - $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'id', Argument::type('array'))->willReturn($propertyMetadata)->shouldBeCalled(); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::type('array'))->willReturn($propertyMetadata)->shouldBeCalled(); - - $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); - - $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); - $resourceClassResolverProphecy->getResourceClass(null, Dummy::class)->willReturn(Dummy::class); - $resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true); - - $serializerProphecy = $this->prophesize(SerializerInterface::class); - $serializerProphecy->willImplement(DenormalizerInterface::class); - - $normalizer = new ItemNormalizer( - $propertyNameCollectionFactoryProphecy->reveal(), - $propertyMetadataFactoryProphecy->reveal(), - $iriConverterProphecy->reveal(), - $resourceClassResolverProphecy->reveal() - ); - $normalizer->setSerializer($serializerProphecy->reveal()); - - $object = $normalizer->denormalize(['id' => '42', 'name' => 'hello'], Dummy::class, null, $context); - $this->assertInstanceOf(Dummy::class, $object); - $this->assertSame('42', $object->getId()); - $this->assertSame('hello', $object->getName()); - } - - public function testDenormalizeWithWrongIdAndNoResourceMetadataFactory(): void + #[Group('legacy')] + public function testDenormalizeIsDeprecated(): void { - $this->expectException(InvalidArgumentException::class); - $context = ['resource_class' => Dummy::class, 'api_allow_update' => true]; + $this->expectUserDeprecationMessage('Since api-platform/core 4.4: Calling "denormalize()" on "ApiPlatform\Serializer\ItemNormalizer" is deprecated, use "ApiPlatform\Serializer\ItemDenormalizer" instead.'); + $this->expectException(NotNormalizableValueException::class); $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); - $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); - $iriConverterProphecy->getResourceFromIri('fail', $context + ['fetch_data' => true])->willThrow(new InvalidArgumentException()); - $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); - $resourceClassResolverProphecy->getResourceClass(null, Dummy::class)->willReturn(Dummy::class); - $resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true); - $serializerProphecy = $this->prophesize(SerializerInterface::class); - $serializerProphecy->willImplement(DenormalizerInterface::class); $normalizer = new ItemNormalizer( $propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), $iriConverterProphecy->reveal(), $resourceClassResolverProphecy->reveal() ); - $normalizer->setSerializer($serializerProphecy->reveal()); - - $this->assertInstanceOf(Dummy::class, $normalizer->denormalize(['name' => 'hello', 'id' => 'fail'], Dummy::class, null, $context)); - } - - public function testDenormalizeWithWrongId(): void - { - $context = ['resource_class' => Dummy::class, 'api_allow_update' => true]; - $operation = new Get(uriVariables: ['id' => new Link(identifiers: ['id'], parameterName: 'id')]); - $obj = new Dummy(); - - $propertyNameCollection = new PropertyNameCollection(['id', 'name']); - $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); - $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::type('array'))->willReturn($propertyNameCollection)->shouldBeCalled(); - - $propertyMetadata = (new ApiProperty())->withReadable(true)->withWritable(true); - $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::type('array'))->willReturn($propertyMetadata)->shouldBeCalled(); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'id', Argument::type('array'))->willReturn((new ApiProperty())->withIdentifier(true))->shouldBeCalled(); - - $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); - $iriConverterProphecy->getResourceFromIri('fail', $context + ['fetch_data' => true])->willThrow(new InvalidArgumentException()); - $iriConverterProphecy->getIriFromResource(Dummy::class, UrlGeneratorInterface::ABS_PATH, $operation, ['uri_variables' => ['id' => 'fail']])->willReturn('/dummies/fail'); - $iriConverterProphecy->getResourceFromIri('/dummies/fail', $context + ['fetch_data' => true])->willReturn($obj); - - $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); - $resourceClassResolverProphecy->getResourceClass(null, Dummy::class)->willReturn(Dummy::class); - $resourceClassResolverProphecy->getResourceClass($obj, Dummy::class)->willReturn(Dummy::class); - $resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true); - - $resourceMetadataCollectionFactory = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); - $resourceMetadataCollectionFactory->create(Dummy::class)->willReturn(new ResourceMetadataCollection(Dummy::class, [ - new ApiResource(operations: [$operation]), - ])); - $serializerProphecy = $this->prophesize(SerializerInterface::class); - $serializerProphecy->willImplement(DenormalizerInterface::class); - $normalizer = new ItemNormalizer( - $propertyNameCollectionFactoryProphecy->reveal(), - $propertyMetadataFactoryProphecy->reveal(), - $iriConverterProphecy->reveal(), - $resourceClassResolverProphecy->reveal(), - null, + $normalizer->denormalize( + ['id' => '12', 'name' => 'hello'], + Dummy::class, null, - null, - null, - $resourceMetadataCollectionFactory->reveal() + ['resource_class' => Dummy::class, 'api_allow_update' => false] ); - $normalizer->setSerializer($serializerProphecy->reveal()); - - $this->assertInstanceOf(Dummy::class, $normalizer->denormalize(['name' => 'hello', 'id' => 'fail'], Dummy::class, null, $context)); } } From 6772b9ed867ac16da9d4f3eda6bfa14eb576440c Mon Sep 17 00:00:00 2001 From: soyuka Date: Wed, 6 May 2026 10:18:39 +0200 Subject: [PATCH 3/5] fix(jsonapi): restore getSupportedTypes override in ItemNormalizer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The normalizer/denormalizer split accidentally dropped this override. Without it, JsonApi\Serializer\ItemNormalizer inherits the parent's generic implementation (returns ['object' => false] for any format), which breaks Symfony Serializer fast-path routing — a more generic normalizer can win for formats that should land on the JSON:API one. Restored to the same shape kept by JsonLd\Serializer\ItemNormalizer post-split, matching the pre-split behavior. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/JsonApi/Serializer/ItemNormalizer.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/JsonApi/Serializer/ItemNormalizer.php b/src/JsonApi/Serializer/ItemNormalizer.php index 14cc183060f..cd40caedcca 100644 --- a/src/JsonApi/Serializer/ItemNormalizer.php +++ b/src/JsonApi/Serializer/ItemNormalizer.php @@ -86,6 +86,11 @@ public function supportsNormalization(mixed $data, ?string $format = null, array return self::FORMAT === $format && parent::supportsNormalization($data, $format, $context) && !($data instanceof \Exception || $data instanceof FlattenException); } + public function getSupportedTypes(?string $format): array + { + return self::FORMAT === $format ? parent::getSupportedTypes($format) : []; + } + public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): mixed { trigger_deprecation('api-platform/core', '4.4', 'Calling "denormalize()" on "%s" is deprecated, use "%s" instead.', self::class, ItemDenormalizer::class); From 6b0c9f7d60e8e0cd10ca05446e78732b5e1b6095 Mon Sep 17 00:00:00 2001 From: soyuka Date: Tue, 27 Jan 2026 09:29:54 +0100 Subject: [PATCH 4/5] feat(toon): toon encoder component --- .commitlintrc | 2 +- composer.json | 3 + src/Hydra/Serializer/CollectionNormalizer.php | 5 + .../Serializer/CollectionNormalizer.php | 5 + src/Laravel/ApiPlatformProvider.php | 19 + src/Laravel/Tests/ToonTest.php | 202 ++++++++ src/Laravel/composer.json | 1 + .../ApiPlatformExtension.php | 14 + src/Symfony/Bundle/Resources/config/toon.php | 58 +++ src/Toon/README.md | 56 +++ src/Toon/Serializer/ToonEncoder.php | 72 +++ .../ToonHydraCollectionNormalizer.php | 56 +++ .../ToonHydraEntrypointNormalizer.php | 58 +++ .../ToonJsonApiCollectionNormalizer.php | 57 +++ .../ToonJsonApiEntrypointNormalizer.php | 55 +++ .../Serializer/ToonJsonApiItemNormalizer.php | 71 +++ .../Serializer/ToonJsonLdItemNormalizer.php | 71 +++ src/Toon/Tests/.gitkeep | 0 src/Toon/composer.json | 72 +++ src/Toon/phpunit.xml.dist | 32 ++ .../Fixtures/TestBundle/Document/ToonBook.php | 56 +++ tests/Fixtures/TestBundle/Entity/ToonBook.php | 70 +++ tests/Fixtures/app/config/config_common.yml | 36 +- tests/Functional/ToonTest.php | 436 ++++++++++++++++++ 24 files changed, 1497 insertions(+), 10 deletions(-) create mode 100644 src/Laravel/Tests/ToonTest.php create mode 100644 src/Symfony/Bundle/Resources/config/toon.php create mode 100644 src/Toon/README.md create mode 100644 src/Toon/Serializer/ToonEncoder.php create mode 100644 src/Toon/Serializer/ToonHydraCollectionNormalizer.php create mode 100644 src/Toon/Serializer/ToonHydraEntrypointNormalizer.php create mode 100644 src/Toon/Serializer/ToonJsonApiCollectionNormalizer.php create mode 100644 src/Toon/Serializer/ToonJsonApiEntrypointNormalizer.php create mode 100644 src/Toon/Serializer/ToonJsonApiItemNormalizer.php create mode 100644 src/Toon/Serializer/ToonJsonLdItemNormalizer.php create mode 100644 src/Toon/Tests/.gitkeep create mode 100644 src/Toon/composer.json create mode 100644 src/Toon/phpunit.xml.dist create mode 100644 tests/Fixtures/TestBundle/Document/ToonBook.php create mode 100644 tests/Fixtures/TestBundle/Entity/ToonBook.php create mode 100644 tests/Functional/ToonTest.php diff --git a/.commitlintrc b/.commitlintrc index 1755beee8b8..d8422c6672b 100644 --- a/.commitlintrc +++ b/.commitlintrc @@ -11,7 +11,6 @@ "graphql", "hal", "httpcache", - "httpcache", "hydra", "jsonapi", "jsonld", @@ -26,6 +25,7 @@ "state", "symfony", "test", + "toon", "validator", ] ], diff --git a/composer.json b/composer.json index c1c92405403..6b0e6a15462 100644 --- a/composer.json +++ b/composer.json @@ -104,6 +104,7 @@ "api-platform/serializer": "self.version", "api-platform/state": "self.version", "api-platform/symfony": "self.version", + "api-platform/toon": "self.version", "api-platform/validator": "self.version" }, "require": { @@ -137,6 +138,7 @@ "friends-of-behat/symfony-extension": "^2.1", "friendsofphp/php-cs-fixer": "^3.93", "guzzlehttp/guzzle": "^6.0 || ^7.0", + "helgesverre/toon": "^3.1", "illuminate/config": "^11.0 || ^12.0 || ^13.0", "illuminate/contracts": "^11.0 || ^12.0 || ^13.0", "illuminate/database": "^11.0 || ^12.0 || ^13.0", @@ -202,6 +204,7 @@ "suggest": { "doctrine/mongodb-odm-bundle": "To support MongoDB. Only versions 4.0 and later are supported.", "elasticsearch/elasticsearch": "To support Elasticsearch.", + "helgesverre/toon": "To support Toon format serialization.", "opensearch-project/opensearch-php": "To support OpenSearch (^2.5).", "phpstan/phpdoc-parser": "To support extracting metadata from PHPDoc.", "psr/cache-implementation": "To use metadata caching.", diff --git a/src/Hydra/Serializer/CollectionNormalizer.php b/src/Hydra/Serializer/CollectionNormalizer.php index e882d3aef05..de6e94de9eb 100644 --- a/src/Hydra/Serializer/CollectionNormalizer.php +++ b/src/Hydra/Serializer/CollectionNormalizer.php @@ -107,4 +107,9 @@ protected function initContext(string $resourceClass, array $context): array return $context; } + + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool + { + return self::FORMAT === $format && \is_iterable($data); + } } diff --git a/src/JsonApi/Serializer/CollectionNormalizer.php b/src/JsonApi/Serializer/CollectionNormalizer.php index 1c1f362ce0d..c21f932a6ca 100644 --- a/src/JsonApi/Serializer/CollectionNormalizer.php +++ b/src/JsonApi/Serializer/CollectionNormalizer.php @@ -109,4 +109,9 @@ protected function getItemsData(iterable $object, ?string $format = null, array return $data; } + + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool + { + return self::FORMAT === $format && \is_iterable($data); + } } diff --git a/src/Laravel/ApiPlatformProvider.php b/src/Laravel/ApiPlatformProvider.php index 1e498344502..550a0742bdd 100644 --- a/src/Laravel/ApiPlatformProvider.php +++ b/src/Laravel/ApiPlatformProvider.php @@ -174,6 +174,13 @@ use ApiPlatform\State\Provider\ReadProvider; use ApiPlatform\State\ProviderInterface; use ApiPlatform\State\SerializerContextBuilderInterface; +use ApiPlatform\Toon\Serializer\ToonEncoder; +use ApiPlatform\Toon\Serializer\ToonHydraCollectionNormalizer; +use ApiPlatform\Toon\Serializer\ToonHydraEntrypointNormalizer; +use ApiPlatform\Toon\Serializer\ToonJsonApiCollectionNormalizer; +use ApiPlatform\Toon\Serializer\ToonJsonApiEntrypointNormalizer; +use ApiPlatform\Toon\Serializer\ToonJsonApiItemNormalizer; +use ApiPlatform\Toon\Serializer\ToonJsonLdItemNormalizer; use Http\Discovery\Psr17Factory; use Illuminate\Config\Repository as ConfigRepository; use Illuminate\Contracts\Foundation\Application; @@ -1073,6 +1080,16 @@ public function register(): void $list->insert($app->make(JsonApiErrorNormalizer::class), -790); $list->insert($app->make(JsonApiObjectNormalizer::class), -995); + $list->insert(new ToonHydraCollectionNormalizer($app->make(HydraCollectionNormalizer::class)), -880); + $list->insert(new ToonHydraEntrypointNormalizer( + $app->make(HydraEntrypointNormalizer::class), + $app->make(ResourceMetadataCollectionFactoryInterface::class) + ), -880); + $list->insert(new ToonJsonApiCollectionNormalizer($app->make(JsonApiCollectionNormalizer::class)), -880); + $list->insert(new ToonJsonApiEntrypointNormalizer($app->make(JsonApiEntrypointNormalizer::class)), -880); + $list->insert(new ToonJsonApiItemNormalizer($app->make(JsonApiItemNormalizer::class)), -880); + $list->insert(new ToonJsonLdItemNormalizer($app->make(JsonLdItemNormalizer::class)), -880); + if (interface_exists(FieldsBuilderEnumInterface::class)) { $list->insert($app->make(GraphQlItemNormalizer::class), -890); $list->insert($app->make(GraphQlObjectNormalizer::class), -995); @@ -1097,6 +1114,8 @@ public function register(): void return new Serializer( iterator_to_array($app->make('api_platform_normalizer_list')), [ + // ToonEncoder must come first to handle Toon-encoded formats before JSON encoder + new ToonEncoder(), new JsonEncoder('json'), $app->make(JsonEncoder::class), new JsonEncoder('jsonopenapi'), diff --git a/src/Laravel/Tests/ToonTest.php b/src/Laravel/Tests/ToonTest.php new file mode 100644 index 00000000000..f275feee6f0 --- /dev/null +++ b/src/Laravel/Tests/ToonTest.php @@ -0,0 +1,202 @@ + + * + * 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\Laravel\Tests; + +use ApiPlatform\Laravel\Test\ApiTestAssertionsTrait; +use HelgeSverre\Toon\Toon; +use Illuminate\Contracts\Config\Repository; +use Illuminate\Foundation\Application; +use Illuminate\Foundation\Testing\RefreshDatabase; +use Orchestra\Testbench\Concerns\WithWorkbench; +use Orchestra\Testbench\TestCase; +use Workbench\App\Models\Author; +use Workbench\App\Models\Book; +use Workbench\Database\Factories\AuthorFactory; +use Workbench\Database\Factories\BookFactory; + +class ToonTest extends TestCase +{ + use ApiTestAssertionsTrait; + use RefreshDatabase; + use WithWorkbench; + + /** + * @param Application $app + */ + protected function defineEnvironment($app): void + { + tap($app['config'], static function (Repository $config): void { + // Add Toon format as separate format (uses JSON-LD normalizers with Toon encoder) + $formats = $config->get('api-platform.formats', []); + $formats['toon'] = ['text/ld+toon']; + $formats['jsonld_toon'] = ['text/ld+toon']; // Explicitly add jsonld_toon + $formats['hydra_toon'] = ['text/ld+toon']; // Explicitly add hydra_toon + $formats['jsonapi_toon'] = ['text/vnd.api+toon']; // Explicitly add jsonapi_toon + $config->set('api-platform.formats', $formats); + + $patchFormats = $config->get('api-platform.patch_formats', []); + $patchFormats['toon'] = ['text/ld+toon']; + $patchFormats['jsonld_toon'] = ['text/ld+toon']; // Explicitly add jsonld_toon + $patchFormats['hydra_toon'] = ['text/ld+toon']; // Explicitly add hydra_toon + $patchFormats['jsonapi_toon'] = ['text/vnd.api+toon']; // Explicitly add jsonapi_toon + $config->set('api-platform.patch_formats', $patchFormats); + + $docsFormats = $config->get('api-platform.docs_formats', []); + $docsFormats['toon'] = ['text/ld+toon']; + $docsFormats['jsonld_toon'] = ['text/ld+toon']; // Explicitly add jsonld_toon + $docsFormats['hydra_toon'] = ['text/ld+toon']; // Explicitly add hydra_toon + $docsFormats['jsonapi_toon'] = ['text/vnd.api+toon']; // Explicitly add jsonapi_toon + $config->set('api-platform.docs_formats', $docsFormats); + + $config->set('app.debug', true); + }); + } + + public function testGetEntrypoint(): void + { + $response = $this->get('/api/', ['accept' => ['text/ld+toon']]); + $response->assertStatus(200); + $response->assertHeader('content-type', 'text/ld+toon; charset=utf-8'); + + $content = $response->getContent(); + + // Decode the Toon content to check structure + $decoded = \HelgeSverre\Toon\Toon::decode($content); + + $this->assertIsArray($decoded); + // Laravel entrypoint might have different structure, just check it's an array with resources + $this->assertNotEmpty($decoded); + // The response should contain book-related information + $contentLower = strtolower($content); + $this->assertTrue( + str_contains($contentLower, 'book') || str_contains($contentLower, 'api'), + 'Entrypoint should contain resource information' + ); + } + + public function testGetCollection(): void + { + BookFactory::new()->has(AuthorFactory::new())->count(10)->create(); + $response = $this->get('/api/books', ['accept' => 'text/ld+toon']); + $response->assertStatus(200); + $response->assertHeader('content-type', 'text/ld+toon; charset=utf-8'); + + $content = $response->getContent(); + + // Decode to verify structure + $decoded = \HelgeSverre\Toon\Toon::decode($content); + + // Check for collection structure (Laravel doesn't use hydra prefix) + $this->assertIsArray($decoded); + + // Check if it's a proper collection or a plain array + if (isset($decoded['@id'])) { + // Full collection structure + $this->assertArrayHasKey('totalItems', $decoded); + $this->assertEquals(10, $decoded['totalItems']); + $this->assertArrayHasKey('member', $decoded); + $this->assertCount(5, $decoded['member']); // Default page size + } else { + // Plain array of items + $this->assertIsArray($decoded); + $this->assertNotEmpty($decoded); + // Just verify we got items back + $this->assertGreaterThan(0, count($decoded)); + } + } + + public function testGetBook(): void + { + BookFactory::new()->has(AuthorFactory::new())->count(10)->create(); + $book = Book::first(); + $iri = $this->getIriFromResource($book); + $response = $this->get($iri, ['accept' => ['text/ld+toon']]); + $response->assertStatus(200); + $response->assertHeader('content-type', 'text/ld+toon; charset=utf-8'); + + $content = $response->getContent(); + + $this->assertStringContainsString('id:', $content); + $this->assertStringContainsString('name: '.$book->name, $content); // @phpstan-ignore-line + // ISBN may be quoted in output + $this->assertStringContainsString($book->isbn, $content); // @phpstan-ignore-line + } + + public function testCreateBook(): void + { + AuthorFactory::new()->count(10)->create(); + $author = Author::find(1); + + $isbn = fake()->isbn13(); + + $response = $this->postJson( + '/api/books', + [ + 'name' => 'The Pragmatic Programmer', + 'isbn' => $isbn, + 'publicationDate' => fake()->optional()->date(), + 'author' => $this->getIriFromResource($author), + ], + [ + 'accept' => 'text/ld+toon', + 'content-type' => 'application/ld+json', + ] + ); + + $response->assertStatus(201); + $response->assertHeader('content-type', 'text/ld+toon; charset=utf-8'); + + $content = $response->getContent(); + + $this->assertStringContainsString('name: The Pragmatic Programmer', $content); + $this->assertStringContainsString('id:', $content); + } + + public function testUpdateBook(): void + { + BookFactory::new()->has(AuthorFactory::new())->count(10)->create(); + $book = Book::first(); + $iri = $this->getIriFromResource($book); + + $response = $this->patchJson( + $iri, + [ + 'name' => 'Updated Title', + ], + [ + 'accept' => 'text/ld+toon', + 'content-type' => 'application/merge-patch+json', + ] + ); + + $response->assertStatus(200); + $response->assertHeader('content-type', 'text/ld+toon; charset=utf-8'); + + $content = $response->getContent(); + + $this->assertStringContainsString('name: Updated Title', $content); + // ISBN may be quoted in output + $this->assertStringContainsString($book->isbn, $content); // @phpstan-ignore-line unchanged + } + + public function testDeleteBook(): void + { + BookFactory::new()->has(AuthorFactory::new())->count(10)->create(); + $book = Book::first(); + $iri = $this->getIriFromResource($book); + $response = $this->delete($iri, headers: ['accept' => 'text/ld+toon']); + $response->assertStatus(204); + $this->assertNull(Book::find($book->id)); + } +} diff --git a/src/Laravel/composer.json b/src/Laravel/composer.json index 46307219b34..ef4511be4ef 100644 --- a/src/Laravel/composer.json +++ b/src/Laravel/composer.json @@ -38,6 +38,7 @@ "api-platform/openapi": "^4.3", "api-platform/serializer": "^4.3", "api-platform/state": "^4.3", + "api-platform/toon": "^4.3", "illuminate/config": "^11.0 || ^12.0 || ^13.0", "illuminate/container": "^11.0 || ^12.0 || ^13.0", "illuminate/contracts": "^11.0 || ^12.0 || ^13.0", diff --git a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php index 57b2cdfaac3..47a4b6d5f9f 100644 --- a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -184,6 +184,7 @@ public function load(array $configs, ContainerBuilder $container): void $this->registerJsonApiConfiguration($container, $formats, $loader, $config); $this->registerJsonLdHydraConfiguration($container, $formats, $loader, $config); $this->registerJsonHalConfiguration($formats, $loader); + $this->registerToonConfiguration($formats, $loader); $this->registerJsonProblemConfiguration($errorFormats, $loader); $this->registerGraphQlConfiguration($container, $config, $loader); $this->registerCacheConfiguration($container); @@ -742,6 +743,19 @@ private function registerJsonHalConfiguration(array $formats, PhpFileLoader $loa $loader->load('hal.php'); } + private function registerToonConfiguration(array $formats, PhpFileLoader $loader): void + { + if (!isset($formats['toon'])) { + return; + } + + if (!InstalledVersions::isInstalled('api-platform/toon')) { + throw new \LogicException('Toon support cannot be enabled as the Toon component is not installed. Try running "composer require api-platform/toon".'); + } + + $loader->load('toon.php'); + } + private function registerJsonProblemConfiguration(array $errorFormats, PhpFileLoader $loader): void { if (!isset($errorFormats['jsonproblem'])) { diff --git a/src/Symfony/Bundle/Resources/config/toon.php b/src/Symfony/Bundle/Resources/config/toon.php new file mode 100644 index 00000000000..1f5218f84d6 --- /dev/null +++ b/src/Symfony/Bundle/Resources/config/toon.php @@ -0,0 +1,58 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Symfony\Component\DependencyInjection\Loader\Configurator; + +return static function (ContainerConfigurator $container) { + $services = $container->services(); + + // Toon encoder - can be used with any representation format (JSON-LD, JSON:API, HAL, Hydra) + // Priority: 10 to ensure it's checked before JsonEncoder for jsonld format + $services->set('api_platform.toon.encoder', 'ApiPlatform\Toon\Serializer\ToonEncoder') + ->tag('serializer.encoder', ['priority' => 10]); + + // Toon Normalizers + + $services->set('api_platform.toon.normalizer.hydra.collection', 'ApiPlatform\Toon\Serializer\ToonHydraCollectionNormalizer') + ->decorate('api_platform.hydra.normalizer.collection', null, 0) + ->args([ + service('.inner'), + ]) + ->tag('serializer.normalizer', ['priority' => 8]); + + $services->set('api_platform.toon.normalizer.hydra.entrypoint', 'ApiPlatform\Toon\Serializer\ToonHydraEntrypointNormalizer') + ->decorate('api_platform.hydra.normalizer.entrypoint') + ->args([ + service('.inner'), + service('api_platform.metadata.resource.metadata_collection_factory') + ]) + ->tag('serializer.normalizer', ['priority' => 8]); + + $services->set('api_platform.toon.normalizer.jsonapi.collection', 'ApiPlatform\Toon\Serializer\ToonJsonApiCollectionNormalizer') + ->decorate('api_platform.jsonapi.normalizer.collection', null, 0) + ->args([service('.inner')]) + ->tag('serializer.normalizer', ['priority' => 8]); + + $services->set('api_platform.toon.normalizer.jsonapi.entrypoint', 'ApiPlatform\Toon\Serializer\ToonJsonApiEntrypointNormalizer') + ->decorate('api_platform.jsonapi.normalizer.entrypoint') + ->args([service('.inner')]) + ->tag('serializer.normalizer', ['priority' => 8]); + $services->set('api_platform.toon.normalizer.jsonapi.item', 'ApiPlatform\Toon\Serializer\ToonJsonApiItemNormalizer') + ->decorate('api_platform.jsonapi.normalizer.item') + ->args([service('.inner')]) + ->tag('serializer.normalizer', ['priority' => 8]); + $services->set('api_platform.toon.normalizer.jsonld.item', 'ApiPlatform\Toon\Serializer\ToonJsonLdItemNormalizer') + ->decorate('api_platform.jsonld.normalizer.item') + ->args([service('.inner')]) + ->tag('serializer.normalizer', ['priority' => 8]); +}; diff --git a/src/Toon/README.md b/src/Toon/README.md new file mode 100644 index 00000000000..7b6ca9d20b0 --- /dev/null +++ b/src/Toon/README.md @@ -0,0 +1,56 @@ +# API Platform Toon Format Support + +This component provides [Toon format](https://github.com/toon-format/spec) support for API Platform. + +## Features + +- Toon format encoder/decoder using [helgesverre/toon](https://packagist.org/packages/helgesverre/toon) +- Item and collection normalization +- Entrypoint support +- Compatible with both Symfony and Laravel + +## Installation + +```bash +composer require api-platform/toon +``` + +## Configuration + +### Symfony + +Add the format to your API Platform configuration: + +```yaml +# config/packages/api_platform.yaml +api_platform: + formats: + toon: ['application/x-toon'] +``` + +### Laravel + +Add the format to your configuration: + +```php +// config/api-platform.php +return [ + 'formats' => [ + 'toon' => ['application/x-toon'], + ], +]; +``` + +## About Toon Format + +TOON (Token-Oriented Object Notation) is a line-oriented, indentation-based text format that encodes the JSON data model with minimal quoting. It's designed for compact representation of structured data, especially uniform object arrays. + +Example: + +```toon +user: Alice +score: 95 +tags[3]: api,platform,toon +``` + +See the [official specification](https://github.com/toon-format/spec/blob/main/SPEC.md) for more details. diff --git a/src/Toon/Serializer/ToonEncoder.php b/src/Toon/Serializer/ToonEncoder.php new file mode 100644 index 00000000000..3a4acc74aa4 --- /dev/null +++ b/src/Toon/Serializer/ToonEncoder.php @@ -0,0 +1,72 @@ + + * + * 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\Toon\Serializer; + +use HelgeSverre\Toon\Toon; +use Symfony\Component\Serializer\Encoder\DecoderInterface; +use Symfony\Component\Serializer\Encoder\EncoderInterface; + +/** + * Encodes and decodes data in Toon format. + * + * Toon is an encoding format (like JSON/XML) that can be used with any representation format + * (JSON-LD, JSON:API, HAL, Hydra). This encoder works with normalized data from those formats. + * + * @author API Platform Community + */ +final class ToonEncoder implements EncoderInterface, DecoderInterface +{ + public const FORMAT = 'toon'; + + // Supported format combinations: representation+encoding + private const SUPPORTED_FORMATS = [ + 'toon', // JSON-LD structure with Toon encoding (text/ld+toon) + 'jsonhal_toon', // HAL + Toon (text/hal+toon) + 'jsonapi_toon', // JSON:API + Toon (text/vnd.api+toon) + 'hydra_toon', // Hydra + Toon + 'jsonopenapi_toon', // OpenAPI + Toon (text/vnd.openapi+toon) + ]; + + /** + * {@inheritdoc} + */ + public function encode(mixed $data, string $format, array $context = []): string + { + return Toon::encode($data); + } + + /** + * {@inheritdoc} + */ + public function supportsEncoding(string $format, array $context = []): bool + { + return \in_array($format, self::SUPPORTED_FORMATS, true); + } + + /** + * {@inheritdoc} + */ + public function decode(string $data, string $format, array $context = []): mixed + { + return Toon::decode($data); + } + + /** + * {@inheritdoc} + */ + public function supportsDecoding(string $format, array $context = []): bool + { + return \in_array($format, self::SUPPORTED_FORMATS, true); + } +} diff --git a/src/Toon/Serializer/ToonHydraCollectionNormalizer.php b/src/Toon/Serializer/ToonHydraCollectionNormalizer.php new file mode 100644 index 00000000000..a686e93c5b8 --- /dev/null +++ b/src/Toon/Serializer/ToonHydraCollectionNormalizer.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\Toon\Serializer; + +use ApiPlatform\Hydra\Serializer\CollectionNormalizer as DecoratedCollectionNormalizer; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; +use Symfony\Component\Serializer\SerializerAwareInterface; +use Symfony\Component\Serializer\SerializerAwareTrait; + +/** + * Normalizes collections in the Toon Hydra format through composition. + */ +final class ToonHydraCollectionNormalizer implements NormalizerInterface, SerializerAwareInterface +{ + use SerializerAwareTrait; + + public const FORMAT = 'hydra'; + + public function __construct(private NormalizerInterface $decorated) + { + } + + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool + { + return (\in_array($format, [self::FORMAT.'_toon', 'toon'], true) || 'text/ld+toon' === $format) && $this->decorated->supportsNormalization($data, self::FORMAT, $context); + } + + public function normalize(mixed $object, ?string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null + { + return $this->decorated->normalize($object, self::FORMAT, $context); + } + + public function hasCacheableSupportsMethod(): bool + { + return $this->decorated->hasCacheableSupportsMethod(); + } + + /** + * @return array + */ + public function getSupportedTypes(?string $format): array + { + return (\in_array($format, [self::FORMAT.'_toon', 'toon'], true) || 'text/ld+toon' === $format) ? $this->decorated->getSupportedTypes(self::FORMAT) : []; + } +} \ No newline at end of file diff --git a/src/Toon/Serializer/ToonHydraEntrypointNormalizer.php b/src/Toon/Serializer/ToonHydraEntrypointNormalizer.php new file mode 100644 index 00000000000..a9f4d63643b --- /dev/null +++ b/src/Toon/Serializer/ToonHydraEntrypointNormalizer.php @@ -0,0 +1,58 @@ + + * + * 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\Toon\Serializer; + +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; +use Symfony\Component\Serializer\SerializerAwareInterface; +use Symfony\Component\Serializer\SerializerAwareTrait; + +/** + * Normalizes an Hydra Entrypoint in the Toon Hydra format through composition. + */ +final class ToonHydraEntrypointNormalizer implements NormalizerInterface, SerializerAwareInterface +{ + use SerializerAwareTrait; + + public const FORMAT = 'hydra'; + + public function __construct( + private NormalizerInterface $decorated, + private ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory + ) { + } + + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool + { + return (\in_array($format, [self::FORMAT.'_toon', 'toon'], true) || 'text/ld+toon' === $format) && $this->decorated->supportsNormalization($data, self::FORMAT, $context); + } + + public function normalize(mixed $object, ?string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null + { + return $this->decorated->normalize($object, self::FORMAT, $context); + } + + public function hasCacheableSupportsMethod(): bool + { + return $this->decorated->hasCacheableSupportsMethod(); + } + + /** + * @return array + */ + public function getSupportedTypes(?string $format): array + { + return (\in_array($format, [self::FORMAT.'_toon', 'toon'], true) || 'text/ld+toon' === $format) ? $this->decorated->getSupportedTypes(self::FORMAT) : []; + } +} diff --git a/src/Toon/Serializer/ToonJsonApiCollectionNormalizer.php b/src/Toon/Serializer/ToonJsonApiCollectionNormalizer.php new file mode 100644 index 00000000000..d31a532e0f1 --- /dev/null +++ b/src/Toon/Serializer/ToonJsonApiCollectionNormalizer.php @@ -0,0 +1,57 @@ + + * + * 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\Toon\Serializer; + +use ApiPlatform\JsonApi\Serializer\CollectionNormalizer as DecoratedCollectionNormalizer; +use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; +use Symfony\Component\Serializer\SerializerAwareInterface; +use Symfony\Component\Serializer\SerializerAwareTrait; + +/** + * Normalizes collections in the Toon JSON:API format through composition. + */ +final class ToonJsonApiCollectionNormalizer implements NormalizerInterface, SerializerAwareInterface +{ + use SerializerAwareTrait; + + public const FORMAT = 'jsonapi'; + + public function __construct(private NormalizerInterface $decorated) + { + } + + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool + { + return (\in_array($format, [self::FORMAT.'_toon', 'toon'], true) || 'text/vnd.api+toon' === $format) && $this->decorated->supportsNormalization($data, self::FORMAT, $context); + } + + public function normalize(mixed $object, ?string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null + { + return $this->decorated->normalize($object, self::FORMAT, $context); + } + + public function hasCacheableSupportsMethod(): bool + { + return $this->decorated->hasCacheableSupportsMethod(); + } + + /** + * @return array + */ + public function getSupportedTypes(?string $format): array + { + return (\in_array($format, [self::FORMAT.'_toon', 'toon'], true) || 'text/vnd.api+toon' === $format) ? $this->decorated->getSupportedTypes(self::FORMAT) : []; + } +} diff --git a/src/Toon/Serializer/ToonJsonApiEntrypointNormalizer.php b/src/Toon/Serializer/ToonJsonApiEntrypointNormalizer.php new file mode 100644 index 00000000000..10abf87a927 --- /dev/null +++ b/src/Toon/Serializer/ToonJsonApiEntrypointNormalizer.php @@ -0,0 +1,55 @@ + + * + * 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\Toon\Serializer; + +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; +use Symfony\Component\Serializer\SerializerAwareInterface; +use Symfony\Component\Serializer\SerializerAwareTrait; + +/** + * Normalizes an JSON:API Entrypoint in the Toon JSON:API format through composition. + */ +final class ToonJsonApiEntrypointNormalizer implements NormalizerInterface, SerializerAwareInterface +{ + use SerializerAwareTrait; + + public const FORMAT = 'jsonapi'; + + public function __construct(private NormalizerInterface $decorated) + { + } + + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool + { + return (\in_array($format, [self::FORMAT.'_toon', 'toon'], true) || 'text/vnd.api+toon' === $format) && $this->decorated->supportsNormalization($data, self::FORMAT, $context); + } + + public function normalize(mixed $object, ?string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null + { + return $this->decorated->normalize($object, self::FORMAT, $context); + } + + public function hasCacheableSupportsMethod(): bool + { + return $this->decorated->hasCacheableSupportsMethod(); + } + + /** + * @return array + */ + public function getSupportedTypes(?string $format): array + { + return (\in_array($format, [self::FORMAT.'_toon', 'toon'], true) || 'text/vnd.api+toon' === $format) ? $this->decorated->getSupportedTypes(self::FORMAT) : []; + } +} diff --git a/src/Toon/Serializer/ToonJsonApiItemNormalizer.php b/src/Toon/Serializer/ToonJsonApiItemNormalizer.php new file mode 100644 index 00000000000..fefde4fe378 --- /dev/null +++ b/src/Toon/Serializer/ToonJsonApiItemNormalizer.php @@ -0,0 +1,71 @@ + + * + * 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\Toon\Serializer; + +use ApiPlatform\JsonApi\Serializer\ItemNormalizer as DecoratedItemNormalizer; +use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; +use Symfony\Component\Serializer\SerializerAwareInterface; +use Symfony\Component\Serializer\SerializerAwareTrait; + +/** + * Normalizes an JSON:API Item in the Toon JSON:API format through composition. + */ +final class ToonJsonApiItemNormalizer implements NormalizerInterface, DenormalizerInterface, SerializerAwareInterface +{ + use SerializerAwareTrait; + + public const FORMAT = 'jsonapi'; + + public function __construct(private NormalizerInterface & DenormalizerInterface $decorated) + { + } + + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool + { + return (\in_array($format, [self::FORMAT.'_toon', 'toon'], true) || 'text/vnd.api+toon' === $format) && $this->decorated->supportsNormalization($data, self::FORMAT, $context); + } + + public function normalize(mixed $object, ?string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null + { + return $this->decorated->normalize($object, self::FORMAT, $context); + } + + public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool + { + return (\in_array($format, [self::FORMAT.'_toon', 'toon'], true) || 'text/vnd.api+toon' === $format) && $this->decorated->supportsDenormalization($data, $type, self::FORMAT, $context); + } + + public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): mixed + { + if (\in_array($format, [self::FORMAT.'_toon', 'toon'], true) || 'text/vnd.api+toon' === $format) { + $format = self::FORMAT; + } + + return $this->decorated->denormalize($data, $type, $format, $context); + } + + public function hasCacheableSupportsMethod(): bool + { + return $this->decorated->hasCacheableSupportsMethod(); + } + + /** + * @return array + */ + public function getSupportedTypes(?string $format): array + { + return (\in_array($format, [self::FORMAT.'_toon', 'toon'], true) || 'text/vnd.api+toon' === $format) ? $this->decorated->getSupportedTypes(self::FORMAT) : []; + } +} diff --git a/src/Toon/Serializer/ToonJsonLdItemNormalizer.php b/src/Toon/Serializer/ToonJsonLdItemNormalizer.php new file mode 100644 index 00000000000..8cd12019abb --- /dev/null +++ b/src/Toon/Serializer/ToonJsonLdItemNormalizer.php @@ -0,0 +1,71 @@ + + * + * 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\Toon\Serializer; + +use ApiPlatform\JsonLd\Serializer\ItemNormalizer as DecoratedItemNormalizer; +use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; +use Symfony\Component\Serializer\SerializerAwareInterface; +use Symfony\Component\Serializer\SerializerAwareTrait; + +/** + * Normalizes an JSON-LD Item in the Toon JSON-LD format through composition. + */ +final class ToonJsonLdItemNormalizer implements NormalizerInterface, DenormalizerInterface, SerializerAwareInterface +{ + use SerializerAwareTrait; + + public const FORMAT = 'jsonld'; + + public function __construct(private NormalizerInterface & DenormalizerInterface $decorated) + { + } + + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool + { + return (\in_array($format, [self::FORMAT.'_toon', 'toon'], true) || 'text/ld+toon' === $format) && $this->decorated->supportsNormalization($data, self::FORMAT, $context); + } + + public function normalize(mixed $object, ?string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null + { + return $this->decorated->normalize($object, self::FORMAT, $context); + } + + public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool + { + return (\in_array($format, [self::FORMAT.'_toon', 'toon'], true) || 'text/ld+toon' === $format) && $this->decorated->supportsDenormalization($data, $type, self::FORMAT, $context); + } + + public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): mixed + { + if (\in_array($format, [self::FORMAT.'_toon', 'toon'], true) || 'text/ld+toon' === $format) { + $format = self::FORMAT; + } + + return $this->decorated->denormalize($data, $type, $format, $context); + } + + public function hasCacheableSupportsMethod(): bool + { + return $this->decorated->hasCacheableSupportsMethod(); + } + + /** + * @return array + */ + public function getSupportedTypes(?string $format): array + { + return (\in_array($format, [self::FORMAT.'_toon', 'toon'], true) || 'text/ld+toon' === $format) ? $this->decorated->getSupportedTypes(self::FORMAT) : []; + } +} diff --git a/src/Toon/Tests/.gitkeep b/src/Toon/Tests/.gitkeep new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/Toon/composer.json b/src/Toon/composer.json new file mode 100644 index 00000000000..a127ca9e077 --- /dev/null +++ b/src/Toon/composer.json @@ -0,0 +1,72 @@ +{ + "name": "api-platform/toon", + "description": "API Toon format support", + "type": "library", + "keywords": [ + "REST", + "API", + "TOON" + ], + "homepage": "https://api-platform.com", + "license": "MIT", + "authors": [ + { + "name": "Kévin Dunglas", + "email": "kevin@dunglas.fr", + "homepage": "https://dunglas.fr" + }, + { + "name": "API Platform Community", + "homepage": "https://api-platform.com/community/contributors" + } + ], + "require": { + "php": ">=8.2", + "api-platform/state": "@dev", + "api-platform/metadata": "@dev", + "api-platform/documentation": "@dev", + "api-platform/serializer": "@dev", + "helgesverre/toon": "^1.0", + "symfony/type-info": "^7.3 || ^8.0" + }, + "autoload": { + "psr-4": { + "ApiPlatform\\Toon\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "config": { + "preferred-install": { + "*": "dist" + }, + "sort-packages": true, + "allow-plugins": { + "composer/package-versions-deprecated": true, + "phpstan/extension-installer": true + } + }, + "extra": { + "branch-alias": { + "dev-main": "4.3.x-dev", + "dev-4.2": "4.2.x-dev", + "dev-3.4": "3.4.x-dev", + "dev-4.1": "4.1.x-dev" + }, + "symfony": { + "require": "^6.4 || ^7.0 || ^8.0" + }, + "thanks": { + "name": "api-platform/api-platform", + "url": "https://github.com/api-platform/api-platform" + } + }, + "scripts": { + "test": "./vendor/bin/phpunit" + }, + "require-dev": { + "phpunit/phpunit": "^12.2", + "api-platform/json-schema": "@dev" + } +} diff --git a/src/Toon/phpunit.xml.dist b/src/Toon/phpunit.xml.dist new file mode 100644 index 00000000000..bc5eb1dc7e5 --- /dev/null +++ b/src/Toon/phpunit.xml.dist @@ -0,0 +1,32 @@ + + + + + + + + + + ./Tests/ + + + + + + . + + + ./Tests + ./vendor + + + diff --git a/tests/Fixtures/TestBundle/Document/ToonBook.php b/tests/Fixtures/TestBundle/Document/ToonBook.php new file mode 100644 index 00000000000..38f7754db17 --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/ToonBook.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\Tests\Fixtures\TestBundle\Document; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Delete; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Patch; +use ApiPlatform\Metadata\Post; +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; + +#[ApiResource( + operations: [ + new Get(), + new GetCollection(), + new Post(), + new Patch(inputFormats: ['jsonld' => ['application/ld+json', 'application/merge-patch+json'], 'toon' => ['text/ld+toon']]), + new Delete(), + ], + formats: ['jsonld' => ['application/ld+json'], 'toon' => ['text/ld+toon']] +)] +#[ODM\Document] +class ToonBook +{ + #[ODM\Id(strategy: 'INCREMENT', type: 'int')] + private ?int $id = null; + + #[ODM\Field(type: 'string')] + public string $title; + + #[ODM\Field(type: 'string')] + public string $author; + + #[ODM\Field(type: 'int')] + public int $pages = 0; + + #[ODM\Field(type: 'bool')] + public bool $available = true; + + public function getId(): ?int + { + return $this->id; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/ToonBook.php b/tests/Fixtures/TestBundle/Entity/ToonBook.php new file mode 100644 index 00000000000..6e8d5b68b14 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/ToonBook.php @@ -0,0 +1,70 @@ + + * + * 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\Tests\Fixtures\TestBundle\Entity; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Delete; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Patch; +use ApiPlatform\Metadata\Post; +use Doctrine\ORM\Mapping as ORM; + +#[ApiResource( + operations: [ + new Get(), + new GetCollection(), + new Post(), + new Patch(inputFormats: [ + 'jsonld' => ['application/ld+json', 'application/merge-patch+json'], + 'toon' => ['text/ld+toon'], // for jsonld base + 'jsonld_toon' => ['text/ld+toon'], // explicit for jsonld base + 'jsonapi_toon' => ['text/vnd.api+toon'] + ]), + ], + formats: [ + 'jsonld' => ['application/ld+json'], + 'toon' => ['text/ld+toon'], // for jsonld base + 'jsonld_toon' => ['text/ld+toon'], // explicit for jsonld base + 'jsonapi' => ['application/vnd.api+json'], + 'jsonapi_toon' => ['text/vnd.api+toon'], + 'hydra' => ['application/ld+json'], + 'hydra_toon' => ['text/ld+toon'] + ] +)] +#[ORM\Entity] +class ToonBook +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column(type: 'integer')] + private ?int $id = null; + + #[ORM\Column(type: 'string', length: 255)] + public string $title; + + #[ORM\Column(type: 'string', length: 255)] + public string $author; + + #[ORM\Column(type: 'integer')] + public int $pages = 0; + + #[ORM\Column(type: 'boolean')] + public bool $available = true; + + public function getId(): ?int + { + return $this->id; + } +} diff --git a/tests/Fixtures/app/config/config_common.yml b/tests/Fixtures/app/config/config_common.yml index 1320c1e2637..04c65d8920a 100644 --- a/tests/Fixtures/app/config/config_common.yml +++ b/tests/Fixtures/app/config/config_common.yml @@ -39,22 +39,40 @@ api_platform: enable_swagger: true enable_swagger_ui: true formats: - jsonld: ['application/ld+json'] - jsonhal: ['application/hal+json'] - jsonapi: ['application/vnd.api+json'] - xml: ['application/xml', 'text/xml'] - json: ['application/json'] - html: ['text/html'] graphql: ['application/graphql'] + html: ['text/html'] + hydra: ['application/ld+json'] # Explicitly adding hydra + hydra_toon: ['text/ld+toon'] # Added explicit hydra_toon + json: ['application/json'] + jsonapi: ['application/vnd.api+json'] + jsonapi_toon: ['text/vnd.api+toon'] + jsonhal: ['application/hal+json'] + jsonhal_toon: ['text/hal+toon'] + jsonld: ['application/ld+json'] + jsonld_toon: ['text/ld+toon'] # Added explicit jsonld_toon multipart: ['multipart/form-data'] + toon: ['text/ld+toon'] + xml: ['application/xml', 'text/xml'] + patch_formats: + jsonhal: ['application/hal+json'] + jsonhal_toon: ['text/hal+toon'] + jsonapi_toon: ['text/vnd.api+toon'] + jsonld: ['application/ld+json', 'application/merge-patch+json'] + jsonld_toon: ['text/ld+toon'] # Added explicit jsonld_toon + toon: ['text/ld+toon'] + hydra_toon: ['text/ld+toon'] # Added explicit hydra_toon docs_formats: html: ['text/html'] json: ['application/json'] + jsonapi: ['application/vnd.api+json'] + jsonhal: ['application/hal+json'] + jsonld: ['application/ld+json'] + jsonld_toon: ['text/ld+toon'] # Added explicit jsonld_toon jsonopenapi: ['application/vnd.openapi+json'] + jsonopenapi_toon: ['text/vnd.openapi+toon'] + hydra_toon: ['text/ld+toon'] # Added explicit hydra_toon + toon: ['text/ld+toon'] yamlopenapi: ['application/vnd.openapi+yaml'] - jsonld: ['application/ld+json'] - jsonhal: ['application/hal+json'] - jsonapi: ['application/vnd.api+json'] error_formats: jsonproblem: ['application/problem+json'] jsonld: ['application/ld+json'] diff --git a/tests/Functional/ToonTest.php b/tests/Functional/ToonTest.php new file mode 100644 index 00000000000..77ddc8c4ae9 --- /dev/null +++ b/tests/Functional/ToonTest.php @@ -0,0 +1,436 @@ + + * + * 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\Tests\Functional; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ToonBook; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use HelgeSverre\Toon\Toon; + +class ToonTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [ + ToonBook::class, + ]; + } + + public function testCreateResourceWithToonFormat(): void + { + $this->recreateSchema(self::getResources()); + + // Send JSON-LD, expect Toon response + $response = self::createClient()->request('POST', '/toon_books', [ + 'json' => [ + 'title' => 'The Pragmatic Programmer', + 'author' => 'Andy Hunt', + 'pages' => 352, + 'available' => true, + ], + 'headers' => [ + 'Content-Type' => 'application/ld+json', + 'Accept' => 'text/ld+toon', + ], + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('content-type', 'text/ld+toon; charset=utf-8'); + + $responseContent = $response->getContent(); + $decodedData = Toon::decode($responseContent); + + $this->assertIsArray($decodedData); + $this->assertArrayHasKey('@id', $decodedData); + $this->assertEquals('The Pragmatic Programmer', $decodedData['title']); + $this->assertEquals('Andy Hunt', $decodedData['author']); + $this->assertEquals(352, $decodedData['pages']); + $this->assertTrue($decodedData['available']); + } + + public function testGetResourceWithToonFormat(): void + { + $this->recreateSchema(self::getResources()); + + // First, create a resource - send JSON-LD, expect Toon response + $createResponse = self::createClient()->request('POST', '/toon_books', [ + 'json' => [ + 'title' => 'Clean Code', + 'author' => 'Robert C. Martin', + 'pages' => 464, + 'available' => true, + ], + 'headers' => [ + 'Content-Type' => 'application/ld+json', + 'Accept' => 'text/ld+toon', + ], + ]); + + $createdData = Toon::decode($createResponse->getContent()); + $resourceIri = $createdData['@id']; + + // Now retrieve it + $response = self::createClient()->request('GET', $resourceIri, [ + 'headers' => [ + 'Accept' => 'text/ld+toon', + ], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('content-type', 'text/ld+toon; charset=utf-8'); + + $responseContent = $response->getContent(); + $decodedData = Toon::decode($responseContent); + + $this->assertIsArray($decodedData); + $this->assertEquals('Clean Code', $decodedData['title']); + $this->assertEquals('Robert C. Martin', $decodedData['author']); + $this->assertEquals(464, $decodedData['pages']); + $this->assertTrue($decodedData['available']); + } + + public function testGetCollectionWithToonFormat(): void + { + $this->recreateSchema(self::getResources()); + + // Create multiple resources - send JSON-LD, expect Toon response + $books = [ + ['title' => 'Design Patterns', 'author' => 'Gang of Four', 'pages' => 395, 'available' => true], + ['title' => 'Refactoring', 'author' => 'Martin Fowler', 'pages' => 448, 'available' => false], + ['title' => 'Domain-Driven Design', 'author' => 'Eric Evans', 'pages' => 560, 'available' => true], + ]; + + foreach ($books as $book) { + self::createClient()->request('POST', '/toon_books', [ + 'json' => $book, + 'headers' => [ + 'Content-Type' => 'application/ld+json', + 'Accept' => 'text/ld+toon', + ], + ]); + } + + // Retrieve collection + $response = self::createClient()->request('GET', '/toon_books', [ + 'headers' => [ + 'Accept' => 'text/ld+toon', + ], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('content-type', 'text/ld+toon; charset=utf-8'); + + $responseContent = $response->getContent(); + $decodedData = Toon::decode($responseContent); + + $this->assertIsArray($decodedData); + + // Collection structure varies (may have hydra:member or member, or be a plain array) + if (isset($decodedData['hydra:member'])) { + // Hydra format with prefix + $this->assertCount(3, $decodedData['hydra:member']); + $this->assertEquals('Design Patterns', $decodedData['hydra:member'][0]['title']); + $this->assertEquals('Gang of Four', $decodedData['hydra:member'][0]['author']); + $this->assertArrayHasKey('hydra:totalItems', $decodedData); + $this->assertEquals(3, $decodedData['hydra:totalItems']); + } elseif (isset($decodedData['member'])) { + // JSON-LD format without prefix + $this->assertCount(3, $decodedData['member']); + $this->assertEquals('Design Patterns', $decodedData['member'][0]['title']); + $this->assertEquals('Gang of Four', $decodedData['member'][0]['author']); + $this->assertArrayHasKey('totalItems', $decodedData); + $this->assertEquals(3, $decodedData['totalItems']); + } else { + // Plain array + $this->assertGreaterThanOrEqual(3, count($decodedData)); + } + } + + public function testUpdateResourceWithToonFormat(): void + { + $this->recreateSchema(self::getResources()); + + // Create a resource - send JSON-LD, expect Toon response + $createResponse = self::createClient()->request('POST', '/toon_books', [ + 'json' => [ + 'title' => 'Original Title', + 'author' => 'Original Author', + 'pages' => 100, + 'available' => true, + ], + 'headers' => [ + 'Content-Type' => 'application/ld+json', + 'Accept' => 'text/ld+toon', + ], + ]); + + $createdData = Toon::decode($createResponse->getContent()); + $resourceIri = $createdData['@id']; + + // Update the resource - send JSON-LD, expect Toon response + $response = self::createClient()->request('PATCH', $resourceIri, [ + 'json' => [ + 'title' => 'Updated Title', + 'pages' => 200, + ], + 'headers' => [ + 'Content-Type' => 'application/merge-patch+json', + 'Accept' => 'text/ld+toon', + ], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('content-type', 'text/ld+toon; charset=utf-8'); + + $responseContent = $response->getContent(); + $decodedData = Toon::decode($responseContent); + + $this->assertEquals('Updated Title', $decodedData['title']); + $this->assertEquals('Original Author', $decodedData['author']); // Unchanged + $this->assertEquals(200, $decodedData['pages']); + $this->assertTrue($decodedData['available']); // Unchanged + } + + public function testDeleteResourceWithToonFormat(): void + { + $this->recreateSchema(self::getResources()); + + // Create a resource - send JSON-LD, expect Toon response + $createResponse = self::createClient()->request('POST', '/toon_books', [ + 'json' => [ + 'title' => 'To Be Deleted', + 'author' => 'Test Author', + 'pages' => 123, + 'available' => true, + ], + 'headers' => [ + 'Content-Type' => 'application/ld+json', + 'Accept' => 'text/ld+toon', + ], + ]); + + $createdData = Toon::decode($createResponse->getContent()); + $resourceIri = $createdData['@id']; + + // Delete the resource + self::createClient()->request('DELETE', $resourceIri, [ + 'headers' => [ + 'Accept' => 'text/ld+toon', + ], + ]); + + $this->assertResponseStatusCodeSame(204); + + // Verify it's deleted + self::createClient()->request('GET', $resourceIri, [ + 'headers' => [ + 'Accept' => 'text/ld+toon', + ], + ]); + + $this->assertResponseStatusCodeSame(404); + } + + public function testEntrypointWithToonFormat(): void + { + self::bootKernel(); + + $response = self::createClient()->request('GET', '/', [ + 'headers' => [ + 'Accept' => 'text/ld+toon', // This should be handled by hydra_toon + ], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('content-type', 'text/ld+toon; charset=utf-8'); + + $responseContent = $response->getContent(); + $decodedData = Toon::decode($responseContent); + + $this->assertIsArray($decodedData); + // Verify entrypoint contains resource information + $this->assertNotEmpty($decodedData); + // Check that ToonBook resource is listed in the entrypoint, and that hydra:member is present + $contentLower = strtolower($responseContent); + $this->assertTrue( + str_contains($contentLower, 'toonbook') || str_contains($contentLower, 'toon_book'), + 'Entrypoint should contain ToonBook resource' + ); + $this->assertStringContainsString('hydra:member', $contentLower); // Ensure Hydra specific key + } + + public function testToonFormatEncodesSimpleStructures(): void + { + // Test that the Toon encoder works correctly for simple data + $data = [ + 'name' => 'Alice', + 'score' => 95, + 'active' => true, + ]; + + $encoded = Toon::encode($data); + $this->assertStringContainsString('name: Alice', $encoded); + $this->assertStringContainsString('score: 95', $encoded); + $this->assertStringContainsString('active: true', $encoded); + + $decoded = Toon::decode($encoded); + $this->assertEquals($data, $decoded); + } + + public function testToonFormatEncodesArrays(): void + { + // Test that arrays are encoded properly in Toon format + $data = [ + 'tags' => ['api', 'platform', 'toon'], + 'count' => 3, + ]; + + $encoded = Toon::encode($data); + $decoded = Toon::decode($encoded); + + $this->assertEquals($data['tags'], $decoded['tags']); + $this->assertEquals($data['count'], $decoded['count']); + } + + public function testPostWithToonContentType(): void + { + $this->recreateSchema(self::getResources()); + + // Create Toon-encoded request body + $toonData = Toon::encode([ + 'title' => 'Posted via Toon', + 'author' => 'Toon Author', + 'pages' => 999, + 'available' => true, + ]); + + // POST with Content-Type: text/ld+toon + $response = self::createClient()->request('POST', '/toon_books', [ + 'body' => $toonData, + 'headers' => [ + 'Content-Type' => 'text/ld+toon', + 'Accept' => 'text/ld+toon', + ], + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('content-type', 'text/ld+toon; charset=utf-8'); + + $responseContent = $response->getContent(); + $decodedData = Toon::decode($responseContent); + + $this->assertIsArray($decodedData); + $this->assertArrayHasKey('@id', $decodedData); + $this->assertEquals('Posted via Toon', $decodedData['title']); + $this->assertEquals('Toon Author', $decodedData['author']); + $this->assertEquals(999, $decodedData['pages']); + $this->assertTrue($decodedData['available']); + } + + public function testJsonApiWithToonFormat(): void + { + $this->recreateSchema(self::getResources()); + + // Create a ToonBook to test JSON:API + Toon format + $response = self::createClient()->request('POST', '/toon_books', [ + 'json' => [ + 'title' => 'JSON:API Book', + 'author' => 'JSON:API Author', + 'pages' => 555, + 'available' => true, + ], + 'headers' => [ + 'Content-Type' => 'application/ld+json', + 'Accept' => 'text/ld+toon', + ], + ]); + + $this->assertResponseStatusCodeSame(201); + $createdData = Toon::decode($response->getContent()); + $resourceIri = $createdData['@id']; + + // Now request the resource with JSON:API + Toon format (text/vnd.api+toon) + $jsonApiResponse = self::createClient()->request('GET', $resourceIri, [ + 'headers' => [ + 'Accept' => 'text/vnd.api+toon', + ], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('content-type', 'text/vnd.api+toon; charset=utf-8'); + + $responseContent = $jsonApiResponse->getContent(); + $decodedData = Toon::decode($responseContent); + + // Verify JSON:API structure + $this->assertIsArray($decodedData); + $this->assertArrayHasKey('data', $decodedData); + $this->assertArrayHasKey('attributes', $decodedData['data']); + $this->assertEquals('JSON:API Book', $decodedData['data']['attributes']['title']); + $this->assertEquals('JSON:API Author', $decodedData['data']['attributes']['author']); + $this->assertEquals(555, $decodedData['data']['attributes']['pages']); + } + + public function testJsonLdToonFormat(): void + { + $this->recreateSchema(self::getResources()); + + // Create a ToonBook to test JSON-LD + Toon format + $response = self::createClient()->request('POST', '/toon_books', [ + 'json' => [ + 'title' => 'JSON-LD Toon Book', + 'author' => 'JSON-LD Toon Author', + 'pages' => 666, + 'available' => true, + ], + 'headers' => [ + 'Content-Type' => 'application/ld+json', + 'Accept' => 'text/ld+toon', + ], + ]); + + $this->assertResponseStatusCodeSame(201); + $createdData = Toon::decode($response->getContent()); + $resourceIri = $createdData['@id']; + + // Now request the resource with JSON-LD + Toon format (text/ld+toon, but explicitly using jsonld_toon in config) + $jsonLdToonResponse = self::createClient()->request('GET', $resourceIri, [ + 'headers' => [ + 'Accept' => 'text/ld+toon', // This will map to jsonld_toon via formats config + ], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('content-type', 'text/ld+toon; charset=utf-8'); + + $responseContent = $jsonLdToonResponse->getContent(); + $decodedData = Toon::decode($responseContent); + + // Verify JSON-LD structure + $this->assertIsArray($decodedData); + $this->assertArrayHasKey('@id', $decodedData); + $this->assertEquals('JSON-LD Toon Book', $decodedData['title']); + $this->assertEquals('JSON-LD Toon Author', $decodedData['author']); + $this->assertEquals(666, $decodedData['pages']); + } +} From 2909b58428ed73ed9a405480154fa1abdb845251 Mon Sep 17 00:00:00 2001 From: soyuka Date: Wed, 28 Jan 2026 13:09:46 +0100 Subject: [PATCH 5/5] okay --- src/Hydra/Serializer/CollectionNormalizer.php | 5 ----- src/JsonApi/Serializer/CollectionNormalizer.php | 5 ----- 2 files changed, 10 deletions(-) diff --git a/src/Hydra/Serializer/CollectionNormalizer.php b/src/Hydra/Serializer/CollectionNormalizer.php index de6e94de9eb..e882d3aef05 100644 --- a/src/Hydra/Serializer/CollectionNormalizer.php +++ b/src/Hydra/Serializer/CollectionNormalizer.php @@ -107,9 +107,4 @@ protected function initContext(string $resourceClass, array $context): array return $context; } - - public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool - { - return self::FORMAT === $format && \is_iterable($data); - } } diff --git a/src/JsonApi/Serializer/CollectionNormalizer.php b/src/JsonApi/Serializer/CollectionNormalizer.php index c21f932a6ca..1c1f362ce0d 100644 --- a/src/JsonApi/Serializer/CollectionNormalizer.php +++ b/src/JsonApi/Serializer/CollectionNormalizer.php @@ -109,9 +109,4 @@ protected function getItemsData(iterable $object, ?string $format = null, array return $data; } - - public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool - { - return self::FORMAT === $format && \is_iterable($data); - } }