From da8fa9a041bb71d4dfd67fe6f4f659458d35d702 Mon Sep 17 00:00:00 2001 From: soyuka Date: Tue, 5 May 2026 11:09:10 +0200 Subject: [PATCH 1/6] 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 15c5c6bd3aa..0f68552e01b 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) { @@ -991,6 +1016,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), @@ -1015,6 +1058,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); @@ -1023,12 +1067,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); @@ -1089,6 +1135,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 77d9964741b2dcf6e5c9cb7d361c9c64d2140475 Mon Sep 17 00:00:00 2001 From: soyuka Date: Tue, 5 May 2026 15:11:03 +0200 Subject: [PATCH 2/6] 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 e579a5865dee84e6831e4b06de741199c438b3b9 Mon Sep 17 00:00:00 2001 From: soyuka Date: Wed, 6 May 2026 10:18:39 +0200 Subject: [PATCH 3/6] 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. --- 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 e0e54e079e7ac4773e5c7ad7fff418e0532c5487 Mon Sep 17 00:00:00 2001 From: soyuka Date: Wed, 6 May 2026 16:29:59 +0200 Subject: [PATCH 4/6] test: ignore deprecations on legacy denormalize tests #[IgnoreDeprecations] is required alongside expectUserDeprecationMessage() so --fail-on-deprecation does not flag the asserted deprecation in serializer/json-api ItemNormalizer/ItemDenormalizer tests. --- src/JsonApi/Tests/Serializer/ItemDenormalizerTest.php | 2 ++ src/Serializer/Tests/ItemNormalizerTest.php | 2 ++ 2 files changed, 4 insertions(+) diff --git a/src/JsonApi/Tests/Serializer/ItemDenormalizerTest.php b/src/JsonApi/Tests/Serializer/ItemDenormalizerTest.php index 82910722ba5..24956eea3cb 100644 --- a/src/JsonApi/Tests/Serializer/ItemDenormalizerTest.php +++ b/src/JsonApi/Tests/Serializer/ItemDenormalizerTest.php @@ -21,6 +21,7 @@ use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Metadata\ResourceClassResolverInterface; use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\IgnoreDeprecations; use PHPUnit\Framework\TestCase; use Prophecy\PhpUnit\ProphecyTrait; use Symfony\Component\Serializer\Exception\NotNormalizableValueException; @@ -53,6 +54,7 @@ public function testSupportsDenormalizationOnlyForJsonApiFormat(): void } #[Group('legacy')] + #[IgnoreDeprecations] 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.'); diff --git a/src/Serializer/Tests/ItemNormalizerTest.php b/src/Serializer/Tests/ItemNormalizerTest.php index c22364efc90..8ed4b0364b4 100644 --- a/src/Serializer/Tests/ItemNormalizerTest.php +++ b/src/Serializer/Tests/ItemNormalizerTest.php @@ -22,6 +22,7 @@ use ApiPlatform\Serializer\ItemNormalizer; use ApiPlatform\Serializer\Tests\Fixtures\ApiResource\Dummy; use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\IgnoreDeprecations; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; @@ -146,6 +147,7 @@ public function testNormalizeWithDefinedIri(): void } #[Group('legacy')] + #[IgnoreDeprecations] public function testDenormalizeIsDeprecated(): void { $this->expectUserDeprecationMessage('Since api-platform/core 4.4: Calling "denormalize()" on "ApiPlatform\Serializer\ItemNormalizer" is deprecated, use "ApiPlatform\Serializer\ItemDenormalizer" instead.'); From 453b368a6fd8d6819826c7293f9c3b07265fbece Mon Sep 17 00:00:00 2001 From: soyuka Date: Wed, 6 May 2026 16:30:04 +0200 Subject: [PATCH 5/6] refactor(graphql): split ItemNormalizer/ItemDenormalizer Mirrors the split done for Serializer/JsonApi/JsonLd: introduces GraphQl\Serializer\ItemDenormalizer registered at priority -889 so Symfony's serializer dispatches mutation denormalization to it, avoiding the legacy ItemNormalizer::denormalize() deprecation. --- src/GraphQl/Serializer/ItemDenormalizer.php | 62 +++++++++++++ .../State/Provider/DenormalizeProvider.php | 4 +- .../Tests/Serializer/ItemDenormalizerTest.php | 86 +++++++++++++++++++ .../Tests/Serializer/ItemNormalizerTest.php | 36 -------- src/Laravel/ApiPlatformProvider.php | 17 ++++ .../Bundle/Resources/config/graphql.php | 16 ++++ 6 files changed, 183 insertions(+), 38 deletions(-) create mode 100644 src/GraphQl/Serializer/ItemDenormalizer.php create mode 100644 src/GraphQl/Tests/Serializer/ItemDenormalizerTest.php diff --git a/src/GraphQl/Serializer/ItemDenormalizer.php b/src/GraphQl/Serializer/ItemDenormalizer.php new file mode 100644 index 00000000000..cd7aa0b3a1b --- /dev/null +++ b/src/GraphQl/Serializer/ItemDenormalizer.php @@ -0,0 +1,62 @@ + + * + * 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\GraphQl\Serializer; + +use ApiPlatform\Serializer\AbstractItemNormalizer; + +/** + * Converts GraphQL inputs to objects (denormalization only). + * + * @author Kévin Dunglas + */ +final class ItemDenormalizer extends AbstractItemNormalizer +{ + public const FORMAT = 'graphql'; + + 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) : []; + } + + protected function getAllowedAttributes(string|object $classOrObject, array $context, bool $attributesAsString = false): array|bool + { + $allowedAttributes = parent::getAllowedAttributes($classOrObject, $context, $attributesAsString); + + if (($context['api_denormalize'] ?? false) && \is_array($allowedAttributes) && false !== ($indexId = array_search('id', $allowedAttributes, true))) { + $allowedAttributes[] = '_id'; + array_splice($allowedAttributes, (int) $indexId, 1); + } + + return $allowedAttributes; + } + + protected function setAttributeValue(object $object, string $attribute, mixed $value, ?string $format = null, array $context = []): void + { + if ('_id' === $attribute) { + $attribute = 'id'; + } + + parent::setAttributeValue($object, $attribute, $value, $format, $context); + } +} diff --git a/src/GraphQl/State/Provider/DenormalizeProvider.php b/src/GraphQl/State/Provider/DenormalizeProvider.php index 481bb8bb6b0..45743f5fc64 100644 --- a/src/GraphQl/State/Provider/DenormalizeProvider.php +++ b/src/GraphQl/State/Provider/DenormalizeProvider.php @@ -13,7 +13,7 @@ namespace ApiPlatform\GraphQl\State\Provider; -use ApiPlatform\GraphQl\Serializer\ItemNormalizer; +use ApiPlatform\GraphQl\Serializer\ItemDenormalizer; use ApiPlatform\GraphQl\Serializer\SerializerContextBuilderInterface; use ApiPlatform\Metadata\GraphQl\Mutation; use ApiPlatform\Metadata\Operation; @@ -47,7 +47,7 @@ public function provide(Operation $operation, array $uriVariables = [], array $c $denormalizationContext[AbstractNormalizer::OBJECT_TO_POPULATE] = $data; } - $item = $this->denormalizer->denormalize($context['args']['input'], $operation->getClass(), ItemNormalizer::FORMAT, $denormalizationContext); + $item = $this->denormalizer->denormalize($context['args']['input'], $operation->getClass(), ItemDenormalizer::FORMAT, $denormalizationContext); if (!\is_object($item)) { throw new \UnexpectedValueException('Expected item to be an object.'); diff --git a/src/GraphQl/Tests/Serializer/ItemDenormalizerTest.php b/src/GraphQl/Tests/Serializer/ItemDenormalizerTest.php new file mode 100644 index 00000000000..d36a23f8fbf --- /dev/null +++ b/src/GraphQl/Tests/Serializer/ItemDenormalizerTest.php @@ -0,0 +1,86 @@ + + * + * 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\GraphQl\Tests\Serializer; + +use ApiPlatform\GraphQl\Serializer\ItemDenormalizer; +use ApiPlatform\GraphQl\Tests\Fixtures\ApiResource\Dummy; +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\IriConverterInterface; +use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; +use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; +use ApiPlatform\Metadata\Property\PropertyNameCollection; +use ApiPlatform\Metadata\ResourceClassResolverInterface; +use PHPUnit\Framework\TestCase; +use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; +use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; +use Symfony\Component\Serializer\SerializerInterface; + +class ItemDenormalizerTest extends TestCase +{ + use ProphecyTrait; + + public function testSupportsDenormalizationOnlyForGraphQlFormat(): void + { + $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(new Dummy(), ItemDenormalizer::FORMAT)); + $this->assertTrue($denormalizer->supportsDenormalization([], Dummy::class, ItemDenormalizer::FORMAT)); + $this->assertFalse($denormalizer->supportsDenormalization([], Dummy::class, 'jsonld')); + } + + 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())->withWritable(true)->withReadable(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, ItemDenormalizer::FORMAT, $context)); + } +} diff --git a/src/GraphQl/Tests/Serializer/ItemNormalizerTest.php b/src/GraphQl/Tests/Serializer/ItemNormalizerTest.php index e528ee4e941..94ad5540266 100644 --- a/src/GraphQl/Tests/Serializer/ItemNormalizerTest.php +++ b/src/GraphQl/Tests/Serializer/ItemNormalizerTest.php @@ -28,7 +28,6 @@ use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; -use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; use Symfony\Component\Serializer\SerializerInterface; @@ -253,39 +252,4 @@ public function testNormalizeNoResolverData(): void 'no_resolver_data' => true, ])); } - - 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())->withWritable(true)->withReadable(true); - $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::type('array'))->willReturn($propertyMetadata)->shouldBeCalled(); - - $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); - - $identifiersExtractorProphecy = $this->prophesize(IdentifiersExtractorInterface::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(), - $identifiersExtractorProphecy->reveal(), - $resourceClassResolverProphecy->reveal() - ); - $normalizer->setSerializer($serializerProphecy->reveal()); - - $this->assertInstanceOf(Dummy::class, $normalizer->denormalize(['name' => 'hello'], Dummy::class, ItemNormalizer::FORMAT, $context)); - } } diff --git a/src/Laravel/ApiPlatformProvider.php b/src/Laravel/ApiPlatformProvider.php index 0f68552e01b..d47d577f739 100644 --- a/src/Laravel/ApiPlatformProvider.php +++ b/src/Laravel/ApiPlatformProvider.php @@ -27,6 +27,7 @@ use ApiPlatform\GraphQl\Serializer\Exception\HttpExceptionNormalizer as GraphQlHttpExceptionNormalizer; use ApiPlatform\GraphQl\Serializer\Exception\RuntimeExceptionNormalizer as GraphQlRuntimeExceptionNormalizer; use ApiPlatform\GraphQl\Serializer\Exception\ValidationExceptionNormalizer as GraphQlValidationExceptionNormalizer; +use ApiPlatform\GraphQl\Serializer\ItemDenormalizer as GraphQlItemDenormalizer; use ApiPlatform\GraphQl\Serializer\ItemNormalizer as GraphQlItemNormalizer; use ApiPlatform\GraphQl\Serializer\ObjectNormalizer as GraphQlObjectNormalizer; use ApiPlatform\GraphQl\Serializer\SerializerContextBuilder as GraphQlSerializerContextBuilder; @@ -1080,6 +1081,7 @@ public function register(): void if (interface_exists(FieldsBuilderEnumInterface::class)) { $list->insert($app->make(GraphQlItemNormalizer::class), -890); + $list->insert($app->make(GraphQlItemDenormalizer::class), -889); $list->insert($app->make(GraphQlObjectNormalizer::class), -995); $list->insert($app->make(GraphQlErrorNormalizer::class), -790); $list->insert($app->make(GraphQlValidationExceptionNormalizer::class), -780); @@ -1310,6 +1312,21 @@ private function registerGraphQl(): void ); }); + $this->app->singleton(GraphQlItemDenormalizer::class, static function (Application $app) { + return new GraphQlItemDenormalizer( + $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(SerializerClassMetadataFactory::class), + null, + $app->make(ResourceMetadataCollectionFactoryInterface::class), + $app->make(ResourceAccessCheckerInterface::class) + ); + }); + $this->app->singleton(GraphQlObjectNormalizer::class, static function (Application $app) { return new GraphQlObjectNormalizer( $app->make(ObjectNormalizer::class), diff --git a/src/Symfony/Bundle/Resources/config/graphql.php b/src/Symfony/Bundle/Resources/config/graphql.php index 0453cc84485..9b36964d88f 100644 --- a/src/Symfony/Bundle/Resources/config/graphql.php +++ b/src/Symfony/Bundle/Resources/config/graphql.php @@ -24,6 +24,7 @@ use ApiPlatform\GraphQl\Serializer\Exception\HttpExceptionNormalizer; use ApiPlatform\GraphQl\Serializer\Exception\RuntimeExceptionNormalizer; use ApiPlatform\GraphQl\Serializer\Exception\ValidationExceptionNormalizer; +use ApiPlatform\GraphQl\Serializer\ItemDenormalizer; use ApiPlatform\GraphQl\Serializer\ItemNormalizer; use ApiPlatform\GraphQl\Serializer\ObjectNormalizer; use ApiPlatform\GraphQl\Serializer\SerializerContextBuilder; @@ -250,6 +251,21 @@ ]) ->tag('serializer.normalizer', ['priority' => -890]); + $services->set('api_platform.graphql.denormalizer.item', ItemDenormalizer::class) + ->args([ + service('api_platform.metadata.property.name_collection_factory'), + service('api_platform.metadata.property.metadata_factory'), + service('api_platform.symfony.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(), + ]) + ->tag('serializer.normalizer', ['priority' => -889]); + $services->set('api_platform.graphql.normalizer.object', ObjectNormalizer::class) ->args([ service('api_platform.normalizer.object'), From 9f6715126ca1a0f47f603d03e7ae82047d7535af Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 7 May 2026 08:21:25 +0200 Subject: [PATCH 6/6] fix --- src/Laravel/ApiPlatformProvider.php | 2 +- src/Laravel/Tests/McpTest.php.orig | 411 ++++++++++++++++++ .../Bundle/Resources/config/graphql.php | 2 +- 3 files changed, 413 insertions(+), 2 deletions(-) create mode 100644 src/Laravel/Tests/McpTest.php.orig diff --git a/src/Laravel/ApiPlatformProvider.php b/src/Laravel/ApiPlatformProvider.php index d47d577f739..1ffe86f980f 100644 --- a/src/Laravel/ApiPlatformProvider.php +++ b/src/Laravel/ApiPlatformProvider.php @@ -1321,7 +1321,7 @@ private function registerGraphQl(): void $app->make(PropertyAccessorInterface::class), $app->make(NameConverterInterface::class), $app->make(SerializerClassMetadataFactory::class), - null, + [], $app->make(ResourceMetadataCollectionFactoryInterface::class), $app->make(ResourceAccessCheckerInterface::class) ); diff --git a/src/Laravel/Tests/McpTest.php.orig b/src/Laravel/Tests/McpTest.php.orig new file mode 100644 index 00000000000..a25cf181fda --- /dev/null +++ b/src/Laravel/Tests/McpTest.php.orig @@ -0,0 +1,411 @@ + + * + * 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 Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Testing\TestResponse; +use Orchestra\Testbench\Concerns\WithWorkbench; +use Orchestra\Testbench\TestCase; +use Symfony\AI\McpBundle\McpBundle; +use Symfony\Component\HttpFoundation\Response; + +class McpTest extends TestCase +{ + use RefreshDatabase; + use WithWorkbench; + + private function isPsr17FactoryAvailable(): bool + { + try { + if (!class_exists('Http\Discovery\Psr17FactoryDiscovery')) { + return false; + } + + \Http\Discovery\Psr17FactoryDiscovery::findServerRequestFactory(); + + return true; + } catch (\Throwable) { + return false; + } + } + + /** + * @param array $arguments + * + * @return TestResponse + */ + private function callTool(string $sessionId, string $toolName, array $arguments = []): TestResponse + { + return $this->postJson('/mcp', [ + 'jsonrpc' => '2.0', + 'id' => 2, + 'method' => 'tools/call', + 'params' => [ + 'name' => $toolName, + 'arguments' => $arguments, + ], + ], [ + 'Accept' => 'application/json, text/event-stream', + 'Content-Type' => 'application/json', + 'mcp-session-id' => $sessionId, + ]); + } + + private function initializeMcpSession(): string + { + $response = $this->postJson('/mcp', [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'initialize', + 'params' => [ + 'protocolVersion' => '2024-11-05', + 'clientInfo' => [ + 'name' => 'ApiPlatform Test Suite', + 'version' => '1.0', + ], + 'capabilities' => [], + ], + ], [ + 'Accept' => 'application/json, text/event-stream', + 'Content-Type' => 'application/json', + ]); + + $response->assertStatus(200); + + return $response->headers->get('mcp-session-id'); + } + + public function testBasicProvider(): void + { + if (!class_exists(McpBundle::class)) { + $this->markTestSkipped('MCP bundle is not installed'); + } + + if (!$this->isPsr17FactoryAvailable()) { + $this->markTestSkipped('PSR-17 HTTP factory implementation not available (required for MCP)'); + } + + $sessionId = $this->initializeMcpSession(); + $response = $this->callTool($sessionId, 'get_book_info'); + + $response->assertStatus(200); + $result = $response->json(); + $this->assertArrayHasKey('result', $result); + $content = $result['result']['content'][0]['text'] ?? null; + $this->assertNotNull($content); + $this->assertStringContainsString('API Platform Guide', $content); + $this->assertStringContainsString('978-1234567890', $content); + } + + public function testBasicProcessor(): void + { + if (!class_exists(McpBundle::class)) { + $this->markTestSkipped('MCP bundle is not installed'); + } + + if (!$this->isPsr17FactoryAvailable()) { + $this->markTestSkipped('PSR-17 HTTP factory implementation not available (required for MCP)'); + } + + $sessionId = $this->initializeMcpSession(); + $response = $this->callTool($sessionId, 'update_book_status', [ + 'id' => null, + 'isbn' => '123', + 'title' => 'Test Book', + 'status' => 'pending', + ]); + + $result = $response->json(); + if (isset($result['error'])) { + $this->fail('MCP Error: '.json_encode($result['error'])); + } + $response->assertStatus(200); + $this->assertArrayHasKey('result', $result); + } + + public function testCustomResultWithoutMetadata(): void + { + if (!class_exists(McpBundle::class)) { + $this->markTestSkipped('MCP bundle is not installed'); + } + + if (!$this->isPsr17FactoryAvailable()) { + $this->markTestSkipped('PSR-17 HTTP factory implementation not available (required for MCP)'); + } + + $sessionId = $this->initializeMcpSession(); + $response = $this->callTool($sessionId, 'custom_result', [ + 'text' => 'Test content', + 'includeMetadata' => false, + 'name' => null, + 'email' => null, + 'age' => null, + ]); + + $result = $response->json(); + if (isset($result['error'])) { + $this->fail('MCP Error: '.json_encode($result['error'])); + } + $response->assertStatus(200); + $this->assertArrayHasKey('result', $result); + $content = $result['result']['content'][0]['text'] ?? null; + $this->assertEquals('Custom result: Test content', $content); + $this->assertNull($result['result']['_meta'] ?? null); + } + + public function testCustomResultWithMetadata(): void + { + if (!class_exists(McpBundle::class)) { + $this->markTestSkipped('MCP bundle is not installed'); + } + + if (!$this->isPsr17FactoryAvailable()) { + $this->markTestSkipped('PSR-17 HTTP factory implementation not available (required for MCP)'); + } + + $sessionId = $this->initializeMcpSession(); + $response = $this->callTool($sessionId, 'custom_result', [ + 'text' => 'Test with metadata', + 'includeMetadata' => true, + 'name' => null, + 'email' => null, + 'age' => null, + ]); + + $response->assertStatus(200); + $result = $response->json(); + $this->assertArrayHasKey('result', $result); + $content = $result['result']['content'][0]['text'] ?? null; + $this->assertEquals('Custom result: Test with metadata', $content); + $hasMeta = isset($result['result']['_meta']) || isset($result['result']['meta']) || isset($result['result']['structuredContent']); + $this->assertTrue($hasMeta, 'No metadata found in: '.json_encode(array_keys($result['result']))); + } + + public function testValidationFailure(): void + { + if (!class_exists(McpBundle::class)) { + $this->markTestSkipped('MCP bundle is not installed'); + } + + if (!$this->isPsr17FactoryAvailable()) { + $this->markTestSkipped('PSR-17 HTTP factory implementation not available (required for MCP)'); + } + + $sessionId = $this->initializeMcpSession(); + $response = $this->callTool($sessionId, 'validate_input', [ + 'name' => 'ab', + 'email' => 'invalid-email', + 'age' => -5, + 'text' => null, + 'includeMetadata' => null, + ]); + + $result = $response->json(); + if (422 === $response->getStatusCode()) { + $this->assertArrayHasKey('error', $result); + } else { + $response->assertStatus(200); + } + } + + public function testValidationSuccess(): void + { + if (!class_exists(McpBundle::class)) { + $this->markTestSkipped('MCP bundle is not installed'); + } + + if (!$this->isPsr17FactoryAvailable()) { + $this->markTestSkipped('PSR-17 HTTP factory implementation not available (required for MCP)'); + } + + $sessionId = $this->initializeMcpSession(); + $response = $this->callTool($sessionId, 'validate_input', [ + 'name' => 'John Doe', + 'email' => 'john@example.com', + 'age' => 30, + 'text' => null, + 'includeMetadata' => null, + ]); + + $response->assertStatus(200); + $result = $response->json(); + $this->assertArrayHasKey('result', $result); + $content = $result['result']['content'][0]['text'] ?? null; + $this->assertNotNull($content); + $this->assertStringContainsString('Valid: John Doe', $content); + } + + public function testMarkdownWithoutCodeBlock(): void + { + if (!class_exists(McpBundle::class)) { + $this->markTestSkipped('MCP bundle is not installed'); + } + + if (!$this->isPsr17FactoryAvailable()) { + $this->markTestSkipped('PSR-17 HTTP factory implementation not available (required for MCP)'); + } + + $sessionId = $this->initializeMcpSession(); + $response = $this->callTool($sessionId, 'generate_markdown', [ + 'title' => 'API Platform Guide', + 'content' => 'This is a comprehensive guide to using API Platform.', + 'includeCodeBlock' => false, + ]); + + $response->assertStatus(200); + $result = $response->json(); + $this->assertArrayHasKey('result', $result); + $content = $result['result']['content'][0]['text'] ?? null; + $this->assertNotNull($content, 'No text content in result'); + $this->assertStringContainsString('# API Platform Guide', $content); + $this->assertStringContainsString('This is a comprehensive guide to using API Platform.', $content); + $this->assertStringNotContainsString('```', $content); + $this->assertNull($result['result']['_meta'] ?? null); + $this->assertArrayNotHasKey('structuredContent', $result['result']); + } + + public function testMarkdownWithCodeBlock(): void + { + if (!class_exists(McpBundle::class)) { + $this->markTestSkipped('MCP bundle is not installed'); + } + + if (!$this->isPsr17FactoryAvailable()) { + $this->markTestSkipped('PSR-17 HTTP factory implementation not available (required for MCP)'); + } + + $sessionId = $this->initializeMcpSession(); + $response = $this->callTool($sessionId, 'generate_markdown', [ + 'title' => 'Code Example', + 'content' => 'Here is how to use the feature:', + 'includeCodeBlock' => true, + ]); + + $response->assertStatus(200); + $result = $response->json(); + $this->assertArrayHasKey('result', $result); + $content = $result['result']['content'][0]['text'] ?? null; + $this->assertNotNull($content); + $this->assertStringContainsString('# Code Example', $content); + $this->assertStringContainsString('Here is how to use the feature:', $content); + $this->assertStringContainsString('```php', $content); + $this->assertStringContainsString("echo 'Hello, World!';", $content); + $this->assertStringContainsString('```', $content); + $this->assertNull($result['result']['_meta'] ?? null); + $this->assertArrayNotHasKey('structuredContent', $result['result']); + } + + public function testToolsList(): void + { + if (!class_exists(McpBundle::class)) { + $this->markTestSkipped('MCP bundle is not installed'); + } + + if (!$this->isPsr17FactoryAvailable()) { + $this->markTestSkipped('PSR-17 HTTP factory implementation not available (required for MCP)'); + } + + $sessionId = $this->initializeMcpSession(); + $response = $this->postJson('/mcp', [ + 'jsonrpc' => '2.0', + 'id' => 2, + 'method' => 'tools/list', + ], [ + 'Accept' => 'application/json, text/event-stream', + 'Content-Type' => 'application/json', + 'mcp-session-id' => $sessionId, + ]); + + $data = $response->json(); + $this->assertArrayHasKey('result', $data); + $this->assertArrayHasKey('tools', $data['result']); + + $tools = $data['result']['tools']; + $toolNames = array_column($tools, 'name'); + + $this->assertContains('get_book_info', $toolNames); + $this->assertContains('update_book_status', $toolNames); + $this->assertContains('custom_result', $toolNames); + $this->assertContains('validate_input', $toolNames); + $this->assertContains('generate_markdown', $toolNames); + $this->assertContains('process_message', $toolNames); + + foreach ($tools as $tool) { + $this->assertArrayHasKey('name', $tool); + $this->assertArrayHasKey('inputSchema', $tool); + $this->assertEquals('object', $tool['inputSchema']['type']); + } + + $response->assertStatus(200); + } + + public function testMcpToolAttribute(): void + { + if (!class_exists(McpBundle::class)) { + $this->markTestSkipped('MCP bundle is not installed'); + } + + if (!$this->isPsr17FactoryAvailable()) { + $this->markTestSkipped('PSR-17 HTTP factory implementation not available (required for MCP)'); + } + + $sessionId = $this->initializeMcpSession(); + $response = $this->postJson('/mcp', [ + 'jsonrpc' => '2.0', + 'id' => 2, + 'method' => 'tools/list', + ], [ + 'Accept' => 'application/json, text/event-stream', + 'Content-Type' => 'application/json', + 'mcp-session-id' => $sessionId, + ]); + + $data = $response->json(); + $tools = $data['result']['tools']; + $processMessageTool = null; + foreach ($tools as $tool) { + if ('process_message' === $tool['name']) { + $processMessageTool = $tool; + break; + } + } + + $this->assertNotNull($processMessageTool); + $this->assertEquals('process_message', $processMessageTool['name']); + $this->assertEquals('Process a message with priority', $processMessageTool['description'] ?? null); + $this->assertArrayHasKey('inputSchema', $processMessageTool); + $this->assertEquals('object', $processMessageTool['inputSchema']['type']); + + $response = $this->postJson('/mcp', [ + 'jsonrpc' => '2.0', + 'id' => 3, + 'method' => 'tools/call', + 'params' => [ + 'name' => 'process_message', + 'arguments' => [ + 'message' => 'Hello World', + 'priority' => 5, + ], + ], + ], [ + 'Accept' => 'application/json, text/event-stream', + 'Content-Type' => 'application/json', + 'mcp-session-id' => $sessionId, + ]); + + $response->assertStatus(200); + $result = $response->json(); + $this->assertArrayHasKey('result', $result); + } +} diff --git a/src/Symfony/Bundle/Resources/config/graphql.php b/src/Symfony/Bundle/Resources/config/graphql.php index 9b36964d88f..4ed76ada9f5 100644 --- a/src/Symfony/Bundle/Resources/config/graphql.php +++ b/src/Symfony/Bundle/Resources/config/graphql.php @@ -260,7 +260,7 @@ 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(), ])