diff --git a/Serializer/ConstraintViolationListNormalizer.php b/Serializer/ConstraintViolationListNormalizer.php index c20fedf..ce0c728 100644 --- a/Serializer/ConstraintViolationListNormalizer.php +++ b/Serializer/ConstraintViolationListNormalizer.php @@ -14,7 +14,6 @@ namespace ApiPlatform\JsonApi\Serializer; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; -use Symfony\Component\PropertyInfo\PropertyInfoExtractor; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; use Symfony\Component\TypeInfo\Type\ObjectType; @@ -94,15 +93,8 @@ private function getSourcePointerFromViolation(ConstraintViolationInterface $vio $fieldName = $this->nameConverter->normalize($fieldName, $class, self::FORMAT); } - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - $type = $propertyMetadata->getBuiltinTypes()[0] ?? null; - if ($type && null !== $type->getClassName()) { - return "data/relationships/$fieldName"; - } - } else { - if ($propertyMetadata->getNativeType()?->isSatisfiedBy(static fn ($t) => $t instanceof ObjectType)) { - return "data/relationships/$fieldName"; - } + if ($propertyMetadata->getNativeType()?->isSatisfiedBy(static fn ($t) => $t instanceof ObjectType)) { + return "data/relationships/$fieldName"; } return "data/attributes/$fieldName"; diff --git a/Serializer/ErrorNormalizer.php b/Serializer/ErrorNormalizer.php index 3b6d2fb..2076a1c 100644 --- a/Serializer/ErrorNormalizer.php +++ b/Serializer/ErrorNormalizer.php @@ -45,10 +45,9 @@ public function normalize(mixed $data, ?string $format = null, array $context = $error['code'] = $data->getId(); } - // TODO: change this 5.x - // if (isset($error['status'])) { - // $error['status'] = (string) $error['status']; - // } + if (isset($error['status'])) { + $error['status'] = (string) $error['status']; + } if (!isset($error['violations'])) { return ['errors' => [$error]]; diff --git a/Serializer/ItemDenormalizer.php b/Serializer/ItemDenormalizer.php new file mode 100644 index 0000000..8ed3a0a --- /dev/null +++ b/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/Serializer/ItemNormalizer.php b/Serializer/ItemNormalizer.php index b3bcd2c..fbe92ff 100644 --- a/Serializer/ItemNormalizer.php +++ b/Serializer/ItemNormalizer.php @@ -15,8 +15,6 @@ use ApiPlatform\JsonApi\Util\ResourceLinkageResolver; 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; @@ -34,15 +32,13 @@ use Symfony\Component\ErrorHandler\Exception\FlattenException; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; 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; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; /** - * Converts between objects and array. + * Converts objects to JSON:API documents (normalization only). * * @author Kévin Dunglas * @author Amrouche Hamza @@ -52,6 +48,9 @@ final class ItemNormalizer extends AbstractItemNormalizer { use ClassInfoTrait; use ContextTrait; + use ItemNormalizerTrait { + denormalize as private doDenormalize; + } public const FORMAT = 'jsonapi'; @@ -89,25 +88,23 @@ public function __construct( $this->resourceLinkageResolver = $resourceLinkageResolver ?? new ResourceLinkageResolver($resourceClassResolver); } - /** - * {@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 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); @@ -140,7 +137,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); @@ -163,7 +159,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]; } @@ -191,135 +186,12 @@ 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); - } - - $operation = $context['operation'] ?? null; - $isPostOperation = $operation instanceof HttpOperation && 'POST' === $operation->getMethod(); - $allowClientGeneratedId = true === ($context[self::ALLOW_CLIENT_GENERATED_ID] ?? $this->defaultContext[self::ALLOW_CLIENT_GENERATED_ID] ?? false); - - // Avoid issues with proxies if we populated the object - if (!isset($context[self::OBJECT_TO_POPULATE]) && isset($data['data']['id'])) { - if ($isPostOperation) { - if (!$allowClientGeneratedId) { - throw new NotNormalizableValueException(\sprintf('Client-generated IDs are not allowed on this operation. Set the "%s" denormalization context flag (or the bundle "allow_client_generated_id" configuration) to enable it.', self::ALLOW_CLIENT_GENERATED_ID)); - } - // Fall through: client id is merged into the denormalized payload below. - } elseif (true !== ($context['api_allow_update'] ?? true)) { - throw new NotNormalizableValueException('Update is not allowed for this operation.'); - } else { - $context += ['fetch_data' => false]; - if ($this->useIriAsId) { - $context[self::OBJECT_TO_POPULATE] = $this->iriConverter->getResourceFromIri( - $data['data']['id'], - $context - ); - } elseif ($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'] ?? [] - ); - - // Surface the client-generated id so the entity setter receives it. - if ($isPostOperation && $allowClientGeneratedId && isset($data['data']['id'])) { - $dataToDenormalize['id'] = $data['data']['id']; - } - - return parent::denormalize( - $dataToDenormalize, - $type, - $format, - $context - ); - } - - /** - * {@inheritdoc} - */ protected function getAttributes(object $object, ?string $format = null, array $context = []): array { return $this->getComponents($object, $format, $context)['attributes']; } /** - * {@inheritdoc} - */ - protected function setAttributeValue(object $object, string $attribute, mixed $value, ?string $format = null, array $context = []): void - { - parent::setAttributeValue($object, $attribute, \is_array($value) && \array_key_exists('data', $value) ? $value['data'] : $value, $format, $context); - } - - /** - * {@inheritdoc} - * - * @see http://jsonapi.org/format/#document-resource-object-linkage - * - * @throws RuntimeException - * @throws UnexpectedValueException - */ - protected function denormalizeRelation(string $attributeName, ApiProperty $propertyMetadata, string $className, mixed $value, ?string $format, array $context): ?object - { - if (!\is_array($value) || !isset($value['id'], $value['type'])) { - throw new UnexpectedValueException('Only resource linkage supported currently, see: http://jsonapi.org/format/#document-resource-object-linkage.'); - } - - try { - $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 @@ -352,13 +224,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; @@ -373,14 +243,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. */ @@ -450,8 +312,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 @@ -472,11 +332,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; @@ -488,10 +345,7 @@ private function getPopulatedRelations(object $object, ?string $format, array $c continue; } - // Many to many relationship - $data[$relationshipName] = [ - 'data' => [], - ]; + $data[$relationshipName] = ['data' => []]; if (!$attributeValue) { continue; @@ -509,9 +363,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'])) { @@ -535,9 +386,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]; } @@ -557,9 +406,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'] ?? ''); @@ -569,9 +415,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; @@ -579,9 +422,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; @@ -600,27 +440,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/Serializer/ItemNormalizerTrait.php b/Serializer/ItemNormalizerTrait.php new file mode 100644 index 0000000..e2279ca --- /dev/null +++ b/Serializer/ItemNormalizerTrait.php @@ -0,0 +1,160 @@ + + * + * 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); + } + + $operation = $context['operation'] ?? null; + $isPostOperation = $operation instanceof HttpOperation && 'POST' === $operation->getMethod(); + $allowClientGeneratedId = true === ($context[ItemNormalizer::ALLOW_CLIENT_GENERATED_ID] ?? $this->defaultContext[ItemNormalizer::ALLOW_CLIENT_GENERATED_ID] ?? false); + + // Avoid issues with proxies if we populated the object + if (!isset($context[AbstractItemNormalizer::OBJECT_TO_POPULATE]) && isset($data['data']['id'])) { + if ($isPostOperation) { + if (!$allowClientGeneratedId) { + throw new NotNormalizableValueException(\sprintf('Client-generated IDs are not allowed on this operation. Set the "%s" denormalization context flag (or the bundle "allow_client_generated_id" configuration) to enable it.', ItemNormalizer::ALLOW_CLIENT_GENERATED_ID)); + } + // Fall through: client id is merged into the denormalized payload below. + } elseif (true !== ($context['api_allow_update'] ?? true)) { + throw new NotNormalizableValueException('Update is not allowed for this operation.'); + } else { + $context += ['fetch_data' => false]; + if ($this->useIriAsId) { + $context[AbstractItemNormalizer::OBJECT_TO_POPULATE] = $this->iriConverter->getResourceFromIri($data['data']['id'], $context); + } elseif ($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'] ?? [] + ); + + // Surface the client-generated id so the entity setter receives it. + if ($isPostOperation && $allowClientGeneratedId && isset($data['data']['id'])) { + $dataToDenormalize['id'] = $data['data']['id']; + } + + 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/Tests/JsonSchema/ReservedAttributeNameSchemaFactoryTest.php b/Tests/JsonSchema/ReservedAttributeNameSchemaFactoryTest.php index 0b0d46b..a1865ab 100644 --- a/Tests/JsonSchema/ReservedAttributeNameSchemaFactoryTest.php +++ b/Tests/JsonSchema/ReservedAttributeNameSchemaFactoryTest.php @@ -62,7 +62,7 @@ protected function setUp(): void ); } - $definitionNameFactory = new DefinitionNameFactory(null); + $definitionNameFactory = new DefinitionNameFactory(); $baseSchemaFactory = new BaseSchemaFactory( resourceMetadataFactory: $resourceMetadataFactory->reveal(), diff --git a/Tests/JsonSchema/SchemaFactoryTest.php b/Tests/JsonSchema/SchemaFactoryTest.php index 648cfd8..2c11df8 100644 --- a/Tests/JsonSchema/SchemaFactoryTest.php +++ b/Tests/JsonSchema/SchemaFactoryTest.php @@ -59,7 +59,7 @@ protected function setUp(): void $propertyNameCollectionFactory->create(Dummy::class, ['enable_getter_setter_extraction' => true, 'schema_type' => Schema::TYPE_INPUT])->willReturn(new PropertyNameCollection()); $propertyMetadataFactory = $this->prophesize(PropertyMetadataFactoryInterface::class); - $definitionNameFactory = new DefinitionNameFactory(null); + $definitionNameFactory = new DefinitionNameFactory(); $baseSchemaFactory = new BaseSchemaFactory( resourceMetadataFactory: $resourceMetadataFactory->reveal(), @@ -316,7 +316,7 @@ private function buildSchemaFactoryWithPolymorphicRelation(): SchemaFactory $resourceClassResolver->isResourceClass(RelatedDummy::class)->willReturn(true); $resourceClassResolver->isResourceClass(OtherRelatedDummy::class)->willReturn(true); - $definitionNameFactory = new DefinitionNameFactory(null); + $definitionNameFactory = new DefinitionNameFactory(); $baseSchemaFactory = new BaseSchemaFactory( resourceMetadataFactory: $resourceMetadataFactory->reveal(), @@ -377,7 +377,7 @@ private function buildSchemaFactoryWithRelation(): SchemaFactory $resourceClassResolver->isResourceClass(Dummy::class)->willReturn(true); $resourceClassResolver->isResourceClass(RelatedDummy::class)->willReturn(true); - $definitionNameFactory = new DefinitionNameFactory(null); + $definitionNameFactory = new DefinitionNameFactory(); $baseSchemaFactory = new BaseSchemaFactory( resourceMetadataFactory: $resourceMetadataFactory->reveal(), diff --git a/Tests/Serializer/ItemDenormalizerTest.php b/Tests/Serializer/ItemDenormalizerTest.php new file mode 100644 index 0000000..24956ee --- /dev/null +++ b/Tests/Serializer/ItemDenormalizerTest.php @@ -0,0 +1,82 @@ + + * + * 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\Attributes\IgnoreDeprecations; +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')] + #[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.'); + $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/Tests/Serializer/ItemNormalizerTest.php b/Tests/Serializer/ItemNormalizerTest.php index 374675a..744aefc 100644 --- a/Tests/Serializer/ItemNormalizerTest.php +++ b/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/Util/ResourceLinkageResolver.php b/Util/ResourceLinkageResolver.php index 73cacb9..9c47573 100644 --- a/Util/ResourceLinkageResolver.php +++ b/Util/ResourceLinkageResolver.php @@ -16,7 +16,6 @@ use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\ResourceClassResolverInterface; use ApiPlatform\Metadata\Util\TypeHelper; -use Symfony\Component\PropertyInfo\PropertyInfoExtractor; use Symfony\Component\TypeInfo\Type; use Symfony\Component\TypeInfo\Type\CompositeTypeInterface; use Symfony\Component\TypeInfo\Type\ObjectType; @@ -48,25 +47,6 @@ public function getRelationships(ApiProperty $propertyMetadata): array { $relationships = []; - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - foreach ($propertyMetadata->getBuiltinTypes() ?? [] as $type) { - if ($type->isCollection()) { - $collectionValueType = $type->getCollectionValueTypes()[0] ?? null; - if ($collectionValueType && ($className = $collectionValueType->getClassName()) && $this->resourceClassResolver->isResourceClass($className)) { - $relationships[] = [$className, true]; - } - - continue; - } - - if (($className = $type->getClassName()) && $this->resourceClassResolver->isResourceClass($className)) { - $relationships[] = [$className, false]; - } - } - - return $relationships; - } - if (null === $type = $propertyMetadata->getNativeType()) { return $relationships; } diff --git a/composer.json b/composer.json index ef3ba82..c0226c9 100644 --- a/composer.json +++ b/composer.json @@ -22,11 +22,11 @@ ], "require": { "php": ">=8.2", - "api-platform/documentation": "^4.3", - "api-platform/json-schema": "^4.3", - "api-platform/metadata": "^4.3", - "api-platform/serializer": "^4.3.12", - "api-platform/state": "^4.3", + "api-platform/documentation": "^5.0@alpha", + "api-platform/json-schema": "^5.0@alpha", + "api-platform/metadata": "^5.0@alpha", + "api-platform/serializer": "^5.0@alpha", + "api-platform/state": "^5.0@alpha", "symfony/error-handler": "^6.4 || ^7.0 || ^8.0", "symfony/http-foundation": "^6.4.14 || ^7.0 || ^8.0", "symfony/type-info": "^7.3 || ^8.0" @@ -57,7 +57,7 @@ }, "extra": { "branch-alias": { - "dev-main": "4.4.x-dev", + "dev-main": "5.0.x-dev", "dev-4.2": "4.2.x-dev", "dev-3.4": "3.4.x-dev", "dev-4.1": "4.1.x-dev"