From 102c2c7896b564788a6f1cc4c554c41fcc46281c Mon Sep 17 00:00:00 2001 From: Maxcastel <92802347+Maxcastel@users.noreply.github.com> Date: Thu, 8 Jan 2026 15:12:21 +0100 Subject: [PATCH 01/30] refactor: make method parameter names match interfaces (#7643) --- .../ConstraintViolationListNormalizer.php | 8 ++--- Serializer/EntrypointNormalizer.php | 8 ++--- Serializer/ErrorNormalizer.php | 12 +++---- Serializer/ItemNormalizer.php | 36 +++++++++---------- Serializer/ObjectNormalizer.php | 20 +++++------ 5 files changed, 42 insertions(+), 42 deletions(-) diff --git a/Serializer/ConstraintViolationListNormalizer.php b/Serializer/ConstraintViolationListNormalizer.php index fc87afc..3d25656 100644 --- a/Serializer/ConstraintViolationListNormalizer.php +++ b/Serializer/ConstraintViolationListNormalizer.php @@ -37,10 +37,10 @@ public function __construct(private readonly PropertyMetadataFactoryInterface $p /** * {@inheritdoc} */ - public function normalize(mixed $object, ?string $format = null, array $context = []): array + public function normalize(mixed $data, ?string $format = null, array $context = []): array { $violations = []; - foreach ($object as $violation) { + foreach ($data as $violation) { $violations[] = [ 'detail' => $violation->getMessage(), 'source' => [ @@ -61,9 +61,9 @@ public function supportsNormalization(mixed $data, ?string $format = null, array } /** - * @param string|null $format + * {@inheritdoc} */ - public function getSupportedTypes($format): array + public function getSupportedTypes(?string $format): array { return self::FORMAT === $format ? [ConstraintViolationListInterface::class => true] : []; } diff --git a/Serializer/EntrypointNormalizer.php b/Serializer/EntrypointNormalizer.php index 394bcb3..a7a6f67 100644 --- a/Serializer/EntrypointNormalizer.php +++ b/Serializer/EntrypointNormalizer.php @@ -39,11 +39,11 @@ public function __construct(private readonly ResourceMetadataCollectionFactoryIn /** * {@inheritdoc} */ - public function normalize(mixed $object, ?string $format = null, array $context = []): array + public function normalize(mixed $data, ?string $format = null, array $context = []): array { $entrypoint = ['links' => ['self' => $this->urlGenerator->generate('api_entrypoint', [], UrlGeneratorInterface::ABS_URL)]]; - foreach ($object->getResourceNameCollection() as $resourceClass) { + foreach ($data->getResourceNameCollection() as $resourceClass) { $resourceMetadata = $this->resourceMetadataFactory->create($resourceClass); foreach ($resourceMetadata as $resource) { @@ -74,9 +74,9 @@ public function supportsNormalization(mixed $data, ?string $format = null, array } /** - * @param string|null $format + * {@inheritdoc} */ - public function getSupportedTypes($format): array + public function getSupportedTypes(?string $format): array { return self::FORMAT === $format ? [Entrypoint::class => true] : []; } diff --git a/Serializer/ErrorNormalizer.php b/Serializer/ErrorNormalizer.php index 4875b28..3b6d2fb 100644 --- a/Serializer/ErrorNormalizer.php +++ b/Serializer/ErrorNormalizer.php @@ -32,17 +32,17 @@ public function __construct(private ?NormalizerInterface $itemNormalizer = null) /** * {@inheritdoc} */ - public function normalize(mixed $object, ?string $format = null, array $context = []): array + public function normalize(mixed $data, ?string $format = null, array $context = []): array { - $jsonApiObject = $this->itemNormalizer->normalize($object, $format, $context); + $jsonApiObject = $this->itemNormalizer->normalize($data, $format, $context); $error = $jsonApiObject['data']['attributes'] ?? []; $error['id'] = $jsonApiObject['data']['id']; if (isset($error['type'])) { $error['links'] = ['type' => $error['type']]; } - if (!isset($error['code']) && method_exists($object, 'getId')) { - $error['code'] = $object->getId(); + if (!isset($error['code']) && method_exists($data, 'getId')) { + $error['code'] = $data->getId(); } // TODO: change this 5.x @@ -81,9 +81,9 @@ public function supportsNormalization(mixed $data, ?string $format = null, array } /** - * @param string|null $format + * {@inheritdoc} */ - public function getSupportedTypes($format): array + public function getSupportedTypes(?string $format): array { if (self::FORMAT === $format) { return [ diff --git a/Serializer/ItemNormalizer.php b/Serializer/ItemNormalizer.php index 47a5879..12be479 100644 --- a/Serializer/ItemNormalizer.php +++ b/Serializer/ItemNormalizer.php @@ -73,9 +73,9 @@ public function supportsNormalization(mixed $data, ?string $format = null, array } /** - * @param string|null $format + * {@inheritdoc} */ - public function getSupportedTypes($format): array + public function getSupportedTypes(?string $format): array { return self::FORMAT === $format ? parent::getSupportedTypes($format) : []; } @@ -83,16 +83,16 @@ public function getSupportedTypes($format): array /** * {@inheritdoc} */ - public function normalize(mixed $object, ?string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null + public function normalize(mixed $data, ?string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null { - $resourceClass = $this->getObjectClass($object); + $resourceClass = $this->getObjectClass($data); if ($this->getOutputClass($context)) { - return parent::normalize($object, $format, $context); + return parent::normalize($data, $format, $context); } $previousResourceClass = $context['resource_class'] ?? null; if ($this->resourceClassResolver->isResourceClass($resourceClass) && (null === $previousResourceClass || $this->resourceClassResolver->isResourceClass($previousResourceClass))) { - $resourceClass = $this->resourceClassResolver->getResourceClass($object, $previousResourceClass); + $resourceClass = $this->resourceClassResolver->getResourceClass($data, $previousResourceClass); } if (($operation = $context['operation'] ?? null) && method_exists($operation, 'getItemUriTemplate')) { @@ -101,8 +101,8 @@ public function normalize(mixed $object, ?string $format = null, array $context $context = $this->initContext($resourceClass, $context); - $iri = $context['iri'] ??= $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_PATH, $context['operation'] ?? null, $context); - $context['object'] = $object; + $iri = $context['iri'] ??= $this->iriConverter->getIriFromResource($data, UrlGeneratorInterface::ABS_PATH, $context['operation'] ?? null, $context); + $context['object'] = $data; $context['format'] = $format; $context['api_normalize'] = true; @@ -110,28 +110,28 @@ public function normalize(mixed $object, ?string $format = null, array $context $context['cache_key'] = $this->getCacheKey($format, $context); } - $data = parent::normalize($object, $format, $context); - if (!\is_array($data)) { - return $data; + $normalizedData = parent::normalize($data, $format, $context); + if (!\is_array($normalizedData)) { + return $normalizedData; } // Get and populate relations - ['relationships' => $allRelationshipsData, 'links' => $links] = $this->getComponents($object, $format, $context); + ['relationships' => $allRelationshipsData, 'links' => $links] = $this->getComponents($data, $format, $context); $populatedRelationContext = $context; - $relationshipsData = $this->getPopulatedRelations($object, $format, $populatedRelationContext, $allRelationshipsData); + $relationshipsData = $this->getPopulatedRelations($data, $format, $populatedRelationContext, $allRelationshipsData); // Do not include primary resources $context['api_included_resources'] = [$context['iri']]; - $includedResourcesData = $this->getRelatedResources($object, $format, $context, $allRelationshipsData); + $includedResourcesData = $this->getRelatedResources($data, $format, $context, $allRelationshipsData); $resourceData = [ 'id' => $context['iri'], 'type' => $this->getResourceShortName($resourceClass), ]; - if ($data) { - $resourceData['attributes'] = $data; + if ($normalizedData) { + $resourceData['attributes'] = $normalizedData; } if ($relationshipsData) { @@ -166,7 +166,7 @@ public function supportsDenormalization(mixed $data, string $type, ?string $form * * @throws NotNormalizableValueException */ - public function denormalize(mixed $data, string $class, ?string $format = null, array $context = []): mixed + public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): mixed { // Avoid issues with proxies if we populated the object if (!isset($context[self::OBJECT_TO_POPULATE]) && isset($data['data']['id'])) { @@ -188,7 +188,7 @@ public function denormalize(mixed $data, string $class, ?string $format = null, return parent::denormalize( $dataToDenormalize, - $class, + $type, $format, $context ); diff --git a/Serializer/ObjectNormalizer.php b/Serializer/ObjectNormalizer.php index 1fd986b..5ea74b1 100644 --- a/Serializer/ObjectNormalizer.php +++ b/Serializer/ObjectNormalizer.php @@ -42,9 +42,9 @@ public function supportsNormalization(mixed $data, ?string $format = null, array } /** - * @param string|null $format + * {@inheritdoc} */ - public function getSupportedTypes($format): array + public function getSupportedTypes(?string $format): array { return self::FORMAT === $format ? $this->decorated->getSupportedTypes($format) : []; } @@ -52,16 +52,16 @@ public function getSupportedTypes($format): array /** * {@inheritdoc} */ - public function normalize(mixed $object, ?string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null + public function normalize(mixed $data, ?string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null { if (isset($context['api_resource'])) { $originalResource = $context['api_resource']; unset($context['api_resource']); } - $data = $this->decorated->normalize($object, $format, $context); - if (!\is_array($data) || isset($context['api_attribute'])) { - return $data; + $normalizedData = $this->decorated->normalize($data, $format, $context); + if (!\is_array($normalizedData) || isset($context['api_attribute'])) { + return $normalizedData; } if (isset($originalResource)) { @@ -72,13 +72,13 @@ public function normalize(mixed $object, ?string $format = null, array $context ]; } else { $resourceData = [ - 'id' => $this->iriConverter->getIriFromResource($object), - 'type' => (new \ReflectionClass($this->getObjectClass($object)))->getShortName(), + 'id' => $this->iriConverter->getIriFromResource($data), + 'type' => (new \ReflectionClass($this->getObjectClass($data)))->getShortName(), ]; } - if ($data) { - $resourceData['attributes'] = $data; + if ($normalizedData) { + $resourceData['attributes'] = $normalizedData; } return ['data' => $resourceData]; From 5368f7ba520f67d4ddcef0040465a10491c4bd42 Mon Sep 17 00:00:00 2001 From: elfin-sbreuers Date: Sat, 28 Feb 2026 08:55:20 +0100 Subject: [PATCH 02/30] feat(jsonapi): support entity identifiers instead of IRIs as resource id --- Serializer/ItemNormalizer.php | 138 ++++++++-- Tests/Serializer/ItemNormalizerTest.php | 327 ++++++++++++++++++++++++ 2 files changed, 446 insertions(+), 19 deletions(-) diff --git a/Serializer/ItemNormalizer.php b/Serializer/ItemNormalizer.php index 046c0ee..bad4d61 100644 --- a/Serializer/ItemNormalizer.php +++ b/Serializer/ItemNormalizer.php @@ -15,6 +15,8 @@ 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; use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; @@ -23,6 +25,7 @@ use ApiPlatform\Metadata\ResourceClassResolverInterface; use ApiPlatform\Metadata\UrlGeneratorInterface; use ApiPlatform\Metadata\Util\ClassInfoTrait; +use ApiPlatform\Metadata\Util\CompositeIdentifierParser; use ApiPlatform\Metadata\Util\TypeHelper; use ApiPlatform\Serializer\AbstractItemNormalizer; use ApiPlatform\Serializer\CacheKeyTrait; @@ -59,10 +62,26 @@ final class ItemNormalizer extends AbstractItemNormalizer public const FORMAT = 'jsonapi'; private array $componentsCache = []; - - 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 bool $useIriAsId; + + 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 ?IdentifiersExtractorInterface $identifiersExtractor = null, + bool $useIriAsId = true, + ) { parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, $classMetadataFactory, $defaultContext, $resourceMetadataCollectionFactory, $resourceAccessChecker, $tagCollector, $operationResourceResolver); + $this->useIriAsId = $useIriAsId; } /** @@ -121,16 +140,29 @@ public function normalize(mixed $data, ?string $format = null, array $context = $populatedRelationContext = $context; $relationshipsData = $this->getPopulatedRelations($data, $format, $populatedRelationContext, $allRelationshipsData); - // Do not include primary resources - $context['api_included_resources'] = [$context['iri']]; + $id = $iri; + if (!$this->useIriAsId) { + $identifiers = $this->identifiersExtractor->getIdentifiersFromItem($data, context: $context); + $id = $this->getIdStringFromIdentifiers($identifiers); + } + + $resourceShortName = $this->getResourceShortName($resourceClass); + + // Do not include primary resources — use type:id composite key to avoid cross-type collisions + $context['api_included_resources'] = [$resourceShortName.':'.$id => true]; $includedResourcesData = $this->getRelatedResources($data, $format, $context, $allRelationshipsData); $resourceData = [ - 'id' => $context['iri'], - 'type' => $this->getResourceShortName($resourceClass), + 'id' => $id, + '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]; + } + if ($normalizedData) { $resourceData['attributes'] = $normalizedData; } @@ -175,10 +207,19 @@ public function denormalize(mixed $data, string $type, ?string $format = null, a throw new NotNormalizableValueException('Update is not allowed for this operation.'); } - $context[self::OBJECT_TO_POPULATE] = $this->iriConverter->getResourceFromIri( - $data['data']['id'], - $context + ['fetch_data' => false] - ); + $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 @@ -226,7 +267,29 @@ protected function denormalizeRelation(string $attributeName, ApiProperty $prope } try { - return $this->iriConverter->getResourceFromIri($value['id'], $context + ['fetch_data' => true]); + $context += ['fetch_data' => true]; + if ($this->useIriAsId) { + return $this->iriConverter->getResourceFromIri($value['id'], $context); + } + + $targetClass = null; + $nativeType = $propertyMetadata->getNativeType(); + + if ($nativeType) { + $nativeType->isSatisfiedBy(function (Type $type) use (&$targetClass): bool { + return $type instanceof ObjectType && $this->resourceClassResolver->isResourceClass($targetClass = $type->getClassName()); + }); + } + + if (null === $targetClass) { + throw new ItemNotFoundException(\sprintf('Cannot determine target class for property "%s".', $attributeName)); + } + + /** @var HttpOperation $getOperation */ + $getOperation = $this->resourceMetadataCollectionFactory->create($targetClass)->getOperation(httpOperation: true); + $iri = $this->reconstructIri($targetClass, (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); @@ -274,11 +337,19 @@ protected function normalizeRelation(ApiProperty $propertyMetadata, ?object $rel return $normalizedRelatedObject; } + $id = $iri; + if (!$this->useIriAsId) { + $identifiers = $this->identifiersExtractor->getIdentifiersFromItem($relatedObject); + $id = $this->getIdStringFromIdentifiers($identifiers); + } + + $relationData = [ + 'type' => $this->getResourceShortName($resourceClass), + 'id' => $id, + ]; + $context['data'] = [ - 'data' => [ - 'type' => $this->getResourceShortName($resourceClass), - 'id' => $iri, - ], + 'data' => $relationData, ]; $context['iri'] = $iri; @@ -551,10 +622,10 @@ private function getRelatedResources(object $object, ?string $format, array $con */ private function addIncluded(array $data, array &$included, array &$context): void { - if (isset($data['id']) && !\in_array($data['id'], $context['api_included_resources'], true)) { + $trackingKey = ($data['type'] ?? '').':'.($data['id'] ?? ''); + if (isset($data['id']) && !isset($context['api_included_resources'][$trackingKey])) { $included[] = $data; - // Track already included resources - $context['api_included_resources'][] = $data['id']; + $context['api_included_resources'][$trackingKey] = true; } } @@ -580,6 +651,35 @@ private function getIncludedNestedResources(string $relationshipName, array $con return array_map(static fn (string $nested): string => substr($nested, strpos($nested, '.') + 1), $filtered); } + private function getIdStringFromIdentifiers(array $identifiers): string + { + if (1 === \count($identifiers)) { + return (string) array_values($identifiers)[0]; + } + + 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 { diff --git a/Tests/Serializer/ItemNormalizerTest.php b/Tests/Serializer/ItemNormalizerTest.php index a9af4cf..a11d4d2 100644 --- a/Tests/Serializer/ItemNormalizerTest.php +++ b/Tests/Serializer/ItemNormalizerTest.php @@ -21,7 +21,9 @@ use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\IdentifiersExtractorInterface; use ApiPlatform\Metadata\IriConverterInterface; +use ApiPlatform\Metadata\Link; use ApiPlatform\Metadata\Operations; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; @@ -29,6 +31,7 @@ use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; use ApiPlatform\Metadata\ResourceClassResolverInterface; +use ApiPlatform\Metadata\UrlGeneratorInterface; use Doctrine\Common\Collections\ArrayCollection; use PHPUnit\Framework\TestCase; use Prophecy\Argument; @@ -577,4 +580,328 @@ public function testNormalizeWithNullToOneAndEmptyToManyRelationships(): void $this->assertArrayHasKey('relatedDummies', $result['data']['relationships']); $this->assertSame(['data' => []], $result['data']['relationships']['relatedDummies']); } + + public function testNormalizeWithEntityIdentifier(): void + { + $dummy = new Dummy(); + $dummy->setId(10); + $dummy->setName('hello'); + + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::any())->willReturn(new PropertyNameCollection(['id', 'name'])); + + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::any())->willReturn((new ApiProperty())->withReadable(true)); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'id', Argument::type('array'))->willReturn((new ApiProperty())->withReadable(true)->withIdentifier(true)); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $iriConverterProphecy->getIriFromResource($dummy, Argument::cetera())->willReturn('/dummies/10'); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass($dummy, null)->willReturn(Dummy::class); + $resourceClassResolverProphecy->getResourceClass(null, Dummy::class)->willReturn(Dummy::class); + $resourceClassResolverProphecy->getResourceClass($dummy, Dummy::class)->willReturn(Dummy::class); + $resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true); + + $propertyAccessorProphecy = $this->prophesize(PropertyAccessorInterface::class); + $propertyAccessorProphecy->getValue($dummy, 'id')->willReturn(10); + $propertyAccessorProphecy->getValue($dummy, 'name')->willReturn('hello'); + + $resourceMetadataCollectionFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataCollectionFactoryProphecy->create(Dummy::class)->willReturn(new ResourceMetadataCollection('Dummy', [ + (new ApiResource()) + ->withShortName('Dummy') + ->withOperations(new Operations(['get' => (new Get())->withShortName('Dummy')])), + ])); + + $identifiersExtractorProphecy = $this->prophesize(IdentifiersExtractorInterface::class); + $identifiersExtractorProphecy->getIdentifiersFromItem($dummy, Argument::any(), Argument::type('array'))->willReturn(['id' => 10]); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + $serializerProphecy->willImplement(NormalizerInterface::class); + $serializerProphecy->normalize('hello', ItemNormalizer::FORMAT, Argument::type('array'))->willReturn('hello'); + $serializerProphecy->normalize(10, ItemNormalizer::FORMAT, Argument::type('array'))->willReturn(10); + $serializerProphecy->normalize(null, ItemNormalizer::FORMAT, Argument::type('array'))->willReturn(null); + + $normalizer = new ItemNormalizer( + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceClassResolverProphecy->reveal(), + $propertyAccessorProphecy->reveal(), + new ReservedAttributeNameConverter(), + null, + [], + $resourceMetadataCollectionFactoryProphecy->reveal(), + null, + null, + null, + $identifiersExtractorProphecy->reveal(), + false, + ); + + $normalizer->setSerializer($serializerProphecy->reveal()); + + $expected = [ + 'data' => [ + 'id' => '10', + 'type' => 'Dummy', + 'links' => ['self' => '/dummies/10'], + 'attributes' => [ + '_id' => 10, + 'name' => 'hello', + ], + ], + ]; + + $this->assertEquals($expected, $normalizer->normalize($dummy, ItemNormalizer::FORMAT)); + } + + public function testNormalizeRelationWithEntityIdentifier(): void + { + $dummy = new Dummy(); + $dummy->setId(10); + $dummy->setName('hello'); + + $relatedDummy = new RelatedDummy(); + $relatedDummy->setId(1); + + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::any())->willReturn(new PropertyNameCollection(['id', 'name', 'relatedDummy'])); + + $relatedDummyType = Type::object(RelatedDummy::class); + + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::any())->willReturn((new ApiProperty())->withReadable(true)); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'id', Argument::type('array'))->willReturn((new ApiProperty())->withReadable(true)->withIdentifier(true)); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummy', Argument::any())->willReturn( + (new ApiProperty())->withNativeType($relatedDummyType)->withReadable(true)->withReadableLink(false)->withWritableLink(false) + ); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $iriConverterProphecy->getIriFromResource($dummy, Argument::cetera())->willReturn('/dummies/10'); + $iriConverterProphecy->getIriFromResource($relatedDummy)->willReturn('/related_dummies/1'); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass($dummy, null)->willReturn(Dummy::class); + $resourceClassResolverProphecy->getResourceClass(null, Dummy::class)->willReturn(Dummy::class); + $resourceClassResolverProphecy->getResourceClass($dummy, Dummy::class)->willReturn(Dummy::class); + $resourceClassResolverProphecy->getResourceClass($relatedDummy, RelatedDummy::class)->willReturn(RelatedDummy::class); + $resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true); + $resourceClassResolverProphecy->isResourceClass(RelatedDummy::class)->willReturn(true); + + $propertyAccessorProphecy = $this->prophesize(PropertyAccessorInterface::class); + $propertyAccessorProphecy->getValue($dummy, 'id')->willReturn(10); + $propertyAccessorProphecy->getValue($dummy, 'name')->willReturn('hello'); + $propertyAccessorProphecy->getValue($dummy, 'relatedDummy')->willReturn($relatedDummy); + + $resourceMetadataCollectionFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataCollectionFactoryProphecy->create(Dummy::class)->willReturn(new ResourceMetadataCollection('Dummy', [ + (new ApiResource()) + ->withShortName('Dummy') + ->withOperations(new Operations(['get' => (new Get())->withShortName('Dummy')])), + ])); + $resourceMetadataCollectionFactoryProphecy->create(RelatedDummy::class)->willReturn(new ResourceMetadataCollection('RelatedDummy', [ + (new ApiResource()) + ->withShortName('RelatedDummy') + ->withOperations(new Operations(['get' => (new Get())->withShortName('RelatedDummy')])), + ])); + + $identifiersExtractorProphecy = $this->prophesize(IdentifiersExtractorInterface::class); + $identifiersExtractorProphecy->getIdentifiersFromItem($dummy, Argument::any(), Argument::type('array'))->willReturn(['id' => 10]); + $identifiersExtractorProphecy->getIdentifiersFromItem($relatedDummy)->willReturn(['id' => 1]); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + $serializerProphecy->willImplement(NormalizerInterface::class); + $serializerProphecy->normalize('hello', ItemNormalizer::FORMAT, Argument::type('array'))->willReturn('hello'); + $serializerProphecy->normalize(10, ItemNormalizer::FORMAT, Argument::type('array'))->willReturn(10); + $serializerProphecy->normalize(null, ItemNormalizer::FORMAT, Argument::type('array'))->willReturn(null); + + $normalizer = new ItemNormalizer( + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceClassResolverProphecy->reveal(), + $propertyAccessorProphecy->reveal(), + new ReservedAttributeNameConverter(), + null, + [], + $resourceMetadataCollectionFactoryProphecy->reveal(), + null, + null, + null, + $identifiersExtractorProphecy->reveal(), + false, + ); + + $normalizer->setSerializer($serializerProphecy->reveal()); + + $result = $normalizer->normalize($dummy, ItemNormalizer::FORMAT, [ + 'resources' => [], + 'resource_class' => Dummy::class, + ]); + + $this->assertIsArray($result); + $this->assertSame('10', $result['data']['id']); + $this->assertSame('Dummy', $result['data']['type']); + $this->assertSame('/dummies/10', $result['data']['links']['self']); + + // Verify relationship uses entity identifier (no links — resource identifier objects are spec-compliant) + $this->assertArrayHasKey('relationships', $result['data']); + $this->assertArrayHasKey('relatedDummy', $result['data']['relationships']); + $relationData = $result['data']['relationships']['relatedDummy']['data']; + $this->assertSame('1', $relationData['id']); + $this->assertSame('RelatedDummy', $relationData['type']); + $this->assertArrayNotHasKey('links', $relationData); + } + + public function testDenormalizeWithEntityIdentifier(): void + { + $data = [ + 'data' => [ + 'type' => 'dummy', + 'id' => '10', + 'attributes' => [ + 'name' => 'foo', + ], + ], + ]; + + $dummy = new Dummy(); + $dummy->setId(10); + + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::any())->willReturn(new PropertyNameCollection(['name'])); + + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::any())->willReturn((new ApiProperty())->withNativeType(Type::string())->withReadable(false)->withWritable(true)); + + $getOperation = (new Get(uriVariables: ['id' => new Link(parameterName: 'id', identifiers: ['id'], fromClass: Dummy::class)]))->withClass(Dummy::class); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $iriConverterProphecy->getIriFromResource(Dummy::class, UrlGeneratorInterface::ABS_PATH, $getOperation, Argument::that(static fn ($ctx) => ($ctx['uri_variables'] ?? null) === ['id' => '10']))->willReturn('/dummies/10'); + $iriConverterProphecy->getResourceFromIri('/dummies/10', Argument::type('array'))->willReturn($dummy); + + $propertyAccessorProphecy = $this->prophesize(PropertyAccessorInterface::class); + $propertyAccessorProphecy->setValue(Argument::type(Dummy::class), 'name', 'foo')->will(static function (): void {}); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass(null, Dummy::class)->willReturn(Dummy::class); + $resourceClassResolverProphecy->getResourceClass($dummy, 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())->withOperations(new Operations(['get' => $getOperation])), + ])); + + $normalizer = new ItemNormalizer( + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceClassResolverProphecy->reveal(), + $propertyAccessorProphecy->reveal(), + new ReservedAttributeNameConverter(), + null, + [], + $resourceMetadataCollectionFactory->reveal(), + null, + null, + null, + null, + false, + ); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + $serializerProphecy->willImplement(NormalizerInterface::class); + $normalizer->setSerializer($serializerProphecy->reveal()); + + $result = $normalizer->denormalize($data, Dummy::class, ItemNormalizer::FORMAT, [ + 'operation' => $getOperation, + ]); + + $this->assertInstanceOf(Dummy::class, $result); + } + + public function testDenormalizeRelationWithEntityIdentifier(): void + { + $data = [ + 'data' => [ + 'type' => 'dummy', + 'attributes' => [ + 'name' => 'foo', + ], + 'relationships' => [ + 'relatedDummy' => [ + 'data' => [ + 'type' => 'related-dummy', + 'id' => '1', + ], + ], + ], + ], + ]; + + $relatedDummy = new RelatedDummy(); + $relatedDummy->setId(1); + + $relatedDummyType = Type::object(RelatedDummy::class); + $getRelatedOperation = (new Get(uriVariables: ['id' => new Link(parameterName: 'id', identifiers: ['id'], fromClass: RelatedDummy::class)]))->withClass(RelatedDummy::class); + + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::any())->willReturn(new PropertyNameCollection(['name', 'relatedDummy'])); + + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::any())->willReturn((new ApiProperty())->withNativeType(Type::string())->withReadable(false)->withWritable(true)); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummy', Argument::any())->willReturn( + (new ApiProperty())->withNativeType($relatedDummyType)->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(false) + ); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $iriConverterProphecy->getIriFromResource(RelatedDummy::class, UrlGeneratorInterface::ABS_PATH, $getRelatedOperation, Argument::that(static fn ($ctx) => ($ctx['uri_variables'] ?? null) === ['id' => '1']))->willReturn('/related_dummies/1'); + $iriConverterProphecy->getResourceFromIri('/related_dummies/1', Argument::type('array'))->willReturn($relatedDummy); + + $propertyAccessorProphecy = $this->prophesize(PropertyAccessorInterface::class); + $propertyAccessorProphecy->setValue(Argument::type(Dummy::class), 'name', 'foo')->will(static function (): void {}); + $propertyAccessorProphecy->setValue(Argument::type(Dummy::class), 'relatedDummy', $relatedDummy)->will(static function (): void {}); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass(null, Dummy::class)->willReturn(Dummy::class); + $resourceClassResolverProphecy->getResourceClass(null, RelatedDummy::class)->willReturn(RelatedDummy::class); + $resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true); + $resourceClassResolverProphecy->isResourceClass(RelatedDummy::class)->willReturn(true); + + $resourceMetadataCollectionFactory = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataCollectionFactory->create(Dummy::class)->willReturn(new ResourceMetadataCollection(Dummy::class, [ + (new ApiResource())->withOperations(new Operations([new Get(name: 'get')])), + ])); + $resourceMetadataCollectionFactory->create(RelatedDummy::class)->willReturn(new ResourceMetadataCollection(RelatedDummy::class, [ + (new ApiResource())->withOperations(new Operations(['get' => $getRelatedOperation])), + ])); + + $normalizer = new ItemNormalizer( + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceClassResolverProphecy->reveal(), + $propertyAccessorProphecy->reveal(), + new ReservedAttributeNameConverter(), + null, + [], + $resourceMetadataCollectionFactory->reveal(), + null, + null, + null, + null, + false, + ); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + $serializerProphecy->willImplement(NormalizerInterface::class); + $normalizer->setSerializer($serializerProphecy->reveal()); + + $result = $normalizer->denormalize($data, Dummy::class, ItemNormalizer::FORMAT); + + $this->assertInstanceOf(Dummy::class, $result); + } } From f3414f09a702ad9e40c1a1e0c0aa56400c111b15 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Tue, 3 Mar 2026 10:41:40 +0100 Subject: [PATCH 03/30] fix(jsonapi): wrong variable name during merge (#7816) --- Serializer/ItemNormalizer.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Serializer/ItemNormalizer.php b/Serializer/ItemNormalizer.php index 83f83b8..4d9d6e2 100644 --- a/Serializer/ItemNormalizer.php +++ b/Serializer/ItemNormalizer.php @@ -204,7 +204,7 @@ public function denormalize(mixed $data, string $type, ?string $format = null, a // 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, $class, $format, $context); + return parent::denormalize($data, $type, $format, $context); } // Avoid issues with proxies if we populated the object From 6247a0949a363c3fa7c9a9fe52381e19630e12f0 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 6 Mar 2026 10:30:10 +0100 Subject: [PATCH 04/30] chore: bump sub-components dependencies to ^4.3 (#7820) --- composer.json | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/composer.json b/composer.json index 65ba9d5..323c675 100644 --- a/composer.json +++ b/composer.json @@ -22,11 +22,11 @@ ], "require": { "php": ">=8.2", - "api-platform/documentation": "^4.2", - "api-platform/json-schema": "^4.2", - "api-platform/metadata": "^4.2", - "api-platform/serializer": "^4.2.18", - "api-platform/state": "^4.2.4", + "api-platform/documentation": "^4.3", + "api-platform/json-schema": "^4.3", + "api-platform/metadata": "^4.3", + "api-platform/serializer": "^4.3", + "api-platform/state": "^4.3", "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" @@ -72,5 +72,7 @@ }, "scripts": { "test": "./vendor/bin/phpunit" - } + }, + "minimum-stability": "beta", + "prefer-stable": true } From 1acd05c4d2defa165d3e8eb1139add9e545551a6 Mon Sep 17 00:00:00 2001 From: soyuka Date: Fri, 6 Mar 2026 16:07:49 +0100 Subject: [PATCH 05/30] chore: switch dev-main branch alias to 4.4 --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 323c675..7a83368 100644 --- a/composer.json +++ b/composer.json @@ -57,7 +57,7 @@ }, "extra": { "branch-alias": { - "dev-main": "4.3.x-dev", + "dev-main": "4.4.x-dev", "dev-4.2": "4.2.x-dev", "dev-3.4": "3.4.x-dev", "dev-4.1": "4.1.x-dev" From 9666ca4ddd482157f56ce568fac100ba9f743eec Mon Sep 17 00:00:00 2001 From: Maxcastel <92802347+Maxcastel@users.noreply.github.com> Date: Mon, 27 Apr 2026 14:25:05 +0200 Subject: [PATCH 06/30] ci: openapi errors (#7923) --- JsonSchema/SchemaFactory.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/JsonSchema/SchemaFactory.php b/JsonSchema/SchemaFactory.php index 17f3e17..8e5b066 100644 --- a/JsonSchema/SchemaFactory.php +++ b/JsonSchema/SchemaFactory.php @@ -147,7 +147,7 @@ public function buildSchema(string $className, string $format = 'jsonapi', strin 'items' => [ 'allOf' => [ ['$ref' => $prefix.$key], - ['type' => 'object', 'properties' => ['source' => ['type' => 'object'], 'status' => ['type' => 'string']]], + ['type' => 'object', 'properties' => ['source' => ['type' => 'object'], 'status' => ['type' => ['string', 'integer', 'null']]]], ], ], ], From 21682f0595d206ea85ed345c80b9ae424a3b3623 Mon Sep 17 00:00:00 2001 From: elfin-sbreuers Date: Thu, 30 Apr 2026 11:26:48 +0200 Subject: [PATCH 07/30] fix(jsonapi): use parent-resolved class in denormalizeRelation Co-authored-by: soyuka --- Serializer/ItemNormalizer.php | 17 +------ Tests/Serializer/ItemNormalizerTest.php | 61 +++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 15 deletions(-) diff --git a/Serializer/ItemNormalizer.php b/Serializer/ItemNormalizer.php index 4d9d6e2..b97c141 100644 --- a/Serializer/ItemNormalizer.php +++ b/Serializer/ItemNormalizer.php @@ -278,22 +278,9 @@ protected function denormalizeRelation(string $attributeName, ApiProperty $prope return $this->iriConverter->getResourceFromIri($value['id'], $context); } - $targetClass = null; - $nativeType = $propertyMetadata->getNativeType(); - - if ($nativeType) { - $nativeType->isSatisfiedBy(function (Type $type) use (&$targetClass): bool { - return $type instanceof ObjectType && $this->resourceClassResolver->isResourceClass($targetClass = $type->getClassName()); - }); - } - - if (null === $targetClass) { - throw new ItemNotFoundException(\sprintf('Cannot determine target class for property "%s".', $attributeName)); - } - /** @var HttpOperation $getOperation */ - $getOperation = $this->resourceMetadataCollectionFactory->create($targetClass)->getOperation(httpOperation: true); - $iri = $this->reconstructIri($targetClass, (string) $value['id'], $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) { diff --git a/Tests/Serializer/ItemNormalizerTest.php b/Tests/Serializer/ItemNormalizerTest.php index 683ed89..fd4b2ea 100644 --- a/Tests/Serializer/ItemNormalizerTest.php +++ b/Tests/Serializer/ItemNormalizerTest.php @@ -314,6 +314,67 @@ public function testDenormalize(): void $this->assertInstanceOf(Dummy::class, $normalizer->denormalize($data, Dummy::class, ItemNormalizer::FORMAT)); } + // https://github.com/api-platform/core/pull/7938 + public function testDenormalizeRelationUsesClassNameArgument(): void + { + $relatedDummy = new RelatedDummy(); + $relatedDummy->setId(1); + + $propertyMetadata = (new ApiProperty()) + ->withNativeType(Type::collection(Type::object(ArrayCollection::class), Type::object(RelatedDummy::class), Type::int())) + ->withReadable(false)->withWritable(true) + ->withReadableLink(false)->withWritableLink(false); + + $iriConverter = $this->createStub(IriConverterInterface::class); + $iriConverter->method('getIriFromResource')->willReturn('/related_dummies/1'); + $iriConverter->method('getResourceFromIri')->willReturn($relatedDummy); + + $resourceClassResolver = $this->createStub(ResourceClassResolverInterface::class); + $resourceClassResolver->method('isResourceClass')->willReturnMap([ + [RelatedDummy::class, true], + [ArrayCollection::class, false], + ]); + + $resourceMetadataCollectionFactory = $this->createMock(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataCollectionFactory->expects($this->once()) + ->method('create') + ->with(RelatedDummy::class) + ->willReturn(new ResourceMetadataCollection(RelatedDummy::class, [ + (new ApiResource())->withOperations(new Operations([ + new Get(name: 'get', uriTemplate: '/related_dummies/{id}', uriVariables: ['id' => new Link(fromClass: RelatedDummy::class, identifiers: ['id'])]), + ])), + ])); + + $normalizer = new ItemNormalizer( + $this->createStub(PropertyNameCollectionFactoryInterface::class), + $this->createStub(PropertyMetadataFactoryInterface::class), + $iriConverter, + $resourceClassResolver, + null, + new ReservedAttributeNameConverter(), + null, + [], + $resourceMetadataCollectionFactory, + null, + null, + null, + null, + false, + ); + + $result = (new \ReflectionMethod(ItemNormalizer::class, 'denormalizeRelation'))->invoke( + $normalizer, + 'relatedDummies', + $propertyMetadata, + RelatedDummy::class, + ['type' => 'related-dummy', 'id' => '1'], + null, + [] + ); + + $this->assertSame($relatedDummy, $result); + } + public function testDenormalizeUpdateOperationNotAllowed(): void { $this->expectException(NotNormalizableValueException::class); From 30e399ea2266403d04fd93df83c6983cf0a30e5d Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 30 Apr 2026 14:21:24 +0200 Subject: [PATCH 08/30] chore: allow phpunit ^11.5 for PHP 8.2 compatibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PHPUnit 12 requires PHP 8.3+. Widen the constraint in the root and each component composer.json so PHP 8.2 resolves to 11.5 (LTS) and 8.3+ to 12.x. Removes the per-job "Force PHPUnit 11.5" workaround steps from CI. | Q | A | ------------- | --- | Branch? | 4.3 | Tickets | ∅ | License | MIT | Doc PR | ∅ --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 7a83368..56d2c52 100644 --- a/composer.json +++ b/composer.json @@ -34,7 +34,7 @@ "require-dev": { "phpspec/prophecy": "^1.19", "phpspec/prophecy-phpunit": "^2.2", - "phpunit/phpunit": "^12.2", + "phpunit/phpunit": "^11.5 || ^12.2", "symfony/type-info": "^7.3 || ^8.0" }, "autoload": { From d62b7c2f100c989b0b2e0e4339fe2131829f08d3 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Thu, 7 May 2026 13:56:31 +0200 Subject: [PATCH 09/30] refactor: split normalizer/denormalizer (#7713) --- Serializer/ItemDenormalizer.php | 64 +++++++ Serializer/ItemNormalizer.php | 213 ++-------------------- Serializer/ItemNormalizerTrait.php | 149 +++++++++++++++ Tests/Serializer/ItemDenormalizerTest.php | 82 +++++++++ Tests/Serializer/ItemNormalizerTest.php | 2 + 5 files changed, 315 insertions(+), 195 deletions(-) create mode 100644 Serializer/ItemDenormalizer.php create mode 100644 Serializer/ItemNormalizerTrait.php create mode 100644 Tests/Serializer/ItemDenormalizerTest.php 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 b97c141..cd40cae 100644 --- a/Serializer/ItemNormalizer.php +++ b/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,13 @@ final class ItemNormalizer extends AbstractItemNormalizer use CacheKeyTrait; use ClassInfoTrait; use ContextTrait; + use ItemNormalizerTrait { + denormalize as private doDenormalize; + } public const FORMAT = 'jsonapi'; private array $componentsCache = []; - private bool $useIriAsId; public function __construct( PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, @@ -78,31 +76,28 @@ 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 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); @@ -135,7 +130,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 +152,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 +179,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 +188,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 +220,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 +239,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 +266,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 +282,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 +291,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 +327,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 +336,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 +356,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 +369,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 +389,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 +402,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 +420,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 +443,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 +463,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 +472,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 +479,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 +497,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..5b00aa1 --- /dev/null +++ b/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/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 fd4b2ea..2809e2f 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; From 3a562e7f1bb1bc802e58eff674a20b78fe107275 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 22 May 2026 13:06:32 +0200 Subject: [PATCH 10/30] fix(jsonapi): merge flat page/itemsPerPage params with bracket filter (#8193) --- State/JsonApiProvider.php | 6 ++++++ Tests/State/JsonApiProviderTest.php | 21 +++++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/State/JsonApiProvider.php b/State/JsonApiProvider.php index bf86cb4..0976d9c 100644 --- a/State/JsonApiProvider.php +++ b/State/JsonApiProvider.php @@ -70,6 +70,12 @@ public function provide(Operation $operation, array $uriVariables = [], array $c $filters = array_merge($pageParameter, $filters); } + foreach (['page', 'itemsPerPage', 'pagination', 'partial'] as $paginationParameter) { + if (isset($queryParameters[$paginationParameter]) && !\is_array($queryParameters[$paginationParameter]) && !isset($filters[$paginationParameter])) { + $filters[$paginationParameter] = $queryParameters[$paginationParameter]; + } + } + [$included, $properties] = $this->transformFieldsetsParameters($queryParameters, $operation->getShortName() ?? ''); if ($properties) { diff --git a/Tests/State/JsonApiProviderTest.php b/Tests/State/JsonApiProviderTest.php index 5ce9426..e1478a7 100644 --- a/Tests/State/JsonApiProviderTest.php +++ b/Tests/State/JsonApiProviderTest.php @@ -37,4 +37,25 @@ public function testProvide(): void $provider = new JsonApiProvider($decorated); $provider->provide($operation, [], $context); } + + public function testProvideMergesFlatPaginationWithBracketFilter(): void + { + $request = new Request(['page' => '2', 'itemsPerPage' => '5', 'pagination' => 'true', 'filter' => ['custom' => 'true']]); + $request->setRequestFormat('jsonapi'); + + $operation = new Get(class: \stdClass::class, shortName: 'dummy'); + $context = ['request' => $request]; + $decorated = $this->createMock(ProviderInterface::class); + $decorated->expects($this->once())->method('provide')->with($operation, [], $context); + + $provider = new JsonApiProvider($decorated); + $provider->provide($operation, [], $context); + + $this->assertSame([ + 'custom' => 'true', + 'page' => '2', + 'itemsPerPage' => '5', + 'pagination' => 'true', + ], $request->attributes->get('_api_filters')); + } } From b275e45653f693427e38b53b195a6cdab890418b Mon Sep 17 00:00:00 2001 From: Francisco Silva <116388885+Will-thom@users.noreply.github.com> Date: Mon, 25 May 2026 03:50:22 -0300 Subject: [PATCH 11/30] fix(openapi): include jsonapi collection schema (#8190) --- JsonSchema/SchemaFactory.php | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/JsonSchema/SchemaFactory.php b/JsonSchema/SchemaFactory.php index 8e5b066..3f71cf0 100644 --- a/JsonSchema/SchemaFactory.php +++ b/JsonSchema/SchemaFactory.php @@ -259,15 +259,15 @@ public function buildSchema(string $className, string $format = 'jsonapi', strin $properties = $this->buildDefinitionPropertiesSchema($key, $className, $format, $type, $operation, $schema, []); $properties['data']['properties']['attributes']['$ref'] = $prefix.$key; + $properties['data'] = [ + 'type' => 'array', + 'items' => $properties['data'], + ]; + $schema['description'] = "$definitionName collection."; $schema['allOf'] = [ ['$ref' => $prefix.(false === $operation->getPaginationEnabled() ? self::COLLECTION_BASE_SCHEMA_NAME_NO_PAGINATION : self::COLLECTION_BASE_SCHEMA_NAME)], - ['type' => 'object', 'properties' => [ - 'data' => [ - 'type' => 'array', - 'items' => $properties['data'], - ], - ]], + ['type' => 'object', 'properties' => $properties], ]; return $schema; From f5270cbca004dacea9659a8482035f20916bf7ca Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 29 May 2026 15:58:43 +0200 Subject: [PATCH 12/30] fix(tests): symfony 8.1 compat (#8210) --- Tests/State/JsonApiProviderTest.php | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/Tests/State/JsonApiProviderTest.php b/Tests/State/JsonApiProviderTest.php index e1478a7..6e5999b 100644 --- a/Tests/State/JsonApiProviderTest.php +++ b/Tests/State/JsonApiProviderTest.php @@ -17,25 +17,22 @@ use ApiPlatform\Metadata\Get; use ApiPlatform\State\ProviderInterface; use PHPUnit\Framework\TestCase; -use Symfony\Component\HttpFoundation\InputBag; -use Symfony\Component\HttpFoundation\ParameterBag; use Symfony\Component\HttpFoundation\Request; class JsonApiProviderTest extends TestCase { public function testProvide(): void { - $request = $this->createMock(Request::class); - $request->expects($this->once())->method('getRequestFormat')->willReturn('jsonapi'); - $request->attributes = $this->createMock(ParameterBag::class); - $request->attributes->expects($this->once())->method('get')->with('_api_filters', [])->willReturn([]); - $request->attributes->method('set')->with($this->logicalOr('_api_filter_property', '_api_included', '_api_filters'), $this->logicalOr(['id', 'name', 'dummyFloat', 'relatedDummy' => ['id', 'name']], ['relatedDummy'], [])); - $request->query = new InputBag(['fields' => ['dummy' => 'id,name,dummyFloat', 'relatedDummy' => 'id,name'], 'include' => 'relatedDummy,foo']); + $request = new Request(query: ['fields' => ['dummy' => 'id,name,dummyFloat', 'relatedDummy' => 'id,name'], 'include' => 'relatedDummy,foo']); + $request->setRequestFormat('jsonapi'); $operation = new Get(class: \stdClass::class, shortName: 'dummy'); $context = ['request' => $request]; $decorated = $this->createMock(ProviderInterface::class); $provider = new JsonApiProvider($decorated); $provider->provide($operation, [], $context); + + $this->assertSame(['id', 'name', 'dummyFloat', 'relatedDummy' => ['id', 'name']], $request->attributes->get('_api_filter_property')); + $this->assertSame(['relatedDummy'], $request->attributes->get('_api_included')); } public function testProvideMergesFlatPaginationWithBracketFilter(): void From cd75deb64638e2963a32fe673256d473935a5693 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Mon, 1 Jun 2026 11:42:04 +0200 Subject: [PATCH 13/30] fix(jsonapi): keep flat custom params with flat page (#8216) (#8217) fixes #8216 --- State/JsonApiProvider.php | 11 ++++++++ Tests/State/JsonApiProviderTest.php | 43 +++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/State/JsonApiProvider.php b/State/JsonApiProvider.php index 0976d9c..c09c2fd 100644 --- a/State/JsonApiProvider.php +++ b/State/JsonApiProvider.php @@ -15,6 +15,7 @@ use ApiPlatform\Metadata\Operation; use ApiPlatform\State\ProviderInterface; +use ApiPlatform\State\Util\RequestParser; final class JsonApiProvider implements ProviderInterface { @@ -87,6 +88,16 @@ public function provide(Operation $operation, array $uriVariables = [], array $c } if ($filters) { + // ReadProvider skips its raw-query fallback when _api_filters is set, + // so preserve flat custom params here too. JSON:API transforms win. + $rawParams = $request->attributes->get('_api_query_parameters'); + if (null === $rawParams) { + $queryString = RequestParser::getQueryString($request); + $rawParams = $queryString ? RequestParser::parseRequestParams($queryString) : []; + $request->attributes->set('_api_query_parameters', $rawParams); + } + $filters = array_replace($rawParams, $filters); + $request->attributes->set('_api_filters', $filters); } diff --git a/Tests/State/JsonApiProviderTest.php b/Tests/State/JsonApiProviderTest.php index 6e5999b..e315e8d 100644 --- a/Tests/State/JsonApiProviderTest.php +++ b/Tests/State/JsonApiProviderTest.php @@ -55,4 +55,47 @@ public function testProvideMergesFlatPaginationWithBracketFilter(): void 'pagination' => 'true', ], $request->attributes->get('_api_filters')); } + + // #8216: flat custom params must survive when _api_filters is pre-set by JSON:API. + public function testProvidePreservesFlatCustomQueryParamsWithoutBracketFilter(): void + { + $request = Request::create('/sessions?city_id=3152&order[distance]=asc&page=1'); + $request->setRequestFormat('jsonapi'); + + $operation = new Get(class: \stdClass::class, shortName: 'dummy'); + $context = ['request' => $request]; + $decorated = $this->createMock(ProviderInterface::class); + $decorated->expects($this->once())->method('provide')->with($operation, [], $context); + + $provider = new JsonApiProvider($decorated); + $provider->provide($operation, [], $context); + + $filters = $request->attributes->get('_api_filters'); + + $this->assertIsArray($filters); + $this->assertSame('3152', $filters['city_id'] ?? null); + $this->assertSame(['distance' => 'asc'], $filters['order'] ?? null); + $this->assertSame('1', $filters['page'] ?? null); + } + + // _api_query_parameters set by an earlier listener / ParameterProvider must be reused. + public function testProvideHonoursPrePopulatedApiQueryParameters(): void + { + $request = Request::create('/sessions?page=1'); + $request->setRequestFormat('jsonapi'); + $request->attributes->set('_api_query_parameters', ['custom_override' => 'yes', 'page' => '1']); + + $operation = new Get(class: \stdClass::class, shortName: 'dummy'); + $context = ['request' => $request]; + $decorated = $this->createMock(ProviderInterface::class); + $decorated->expects($this->once())->method('provide')->with($operation, [], $context); + + $provider = new JsonApiProvider($decorated); + $provider->provide($operation, [], $context); + + $filters = $request->attributes->get('_api_filters'); + + $this->assertSame('yes', $filters['custom_override'] ?? null); + $this->assertSame('1', $filters['page'] ?? null); + } } From aa1208d8efc32f3ee6b454c990ce656cae4a5542 Mon Sep 17 00:00:00 2001 From: soyuka Date: Tue, 2 Jun 2026 15:20:29 +0200 Subject: [PATCH 14/30] fix(jsonapi): drop consumed pagination keys before raw-param replace 37e361339 reinjected the raw bracket-style page array after JsonApiProvider had already hoisted its contents into _api_filters, so _api_filters['page'] surfaced as an array (e.g. ['itemsPerPage' => 15] for ?page[itemsPerPage]=15) and downstream providers expecting a scalar threw "Page must be a positive integer". Strip filter/page/itemsPerPage/pagination/partial/include/fields from $rawParams before array_replace; those were already processed above. Refs #8216 --- State/JsonApiProvider.php | 4 +++- Tests/State/JsonApiProviderTest.php | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/State/JsonApiProvider.php b/State/JsonApiProvider.php index c09c2fd..9a341fe 100644 --- a/State/JsonApiProvider.php +++ b/State/JsonApiProvider.php @@ -96,7 +96,9 @@ public function provide(Operation $operation, array $uriVariables = [], array $c $rawParams = $queryString ? RequestParser::parseRequestParams($queryString) : []; $request->attributes->set('_api_query_parameters', $rawParams); } - $filters = array_replace($rawParams, $filters); + // Drop keys already consumed above so the raw bracket variants don't override the hoisted values. + $consumed = array_diff_key($rawParams, array_flip(['filter', 'page', 'itemsPerPage', 'pagination', 'partial', 'include', 'fields'])); + $filters = array_replace($consumed, $filters); $request->attributes->set('_api_filters', $filters); } diff --git a/Tests/State/JsonApiProviderTest.php b/Tests/State/JsonApiProviderTest.php index e315e8d..c527615 100644 --- a/Tests/State/JsonApiProviderTest.php +++ b/Tests/State/JsonApiProviderTest.php @@ -98,4 +98,23 @@ public function testProvideHonoursPrePopulatedApiQueryParameters(): void $this->assertSame('yes', $filters['custom_override'] ?? null); $this->assertSame('1', $filters['page'] ?? null); } + + public function testProvideDoesNotReinjectBracketPageAfterHoisting(): void + { + $request = Request::create('/dummies?page[itemsPerPage]=15'); + $request->setRequestFormat('jsonapi'); + + $operation = new Get(class: \stdClass::class, shortName: 'dummy'); + $context = ['request' => $request]; + $decorated = $this->createMock(ProviderInterface::class); + $decorated->expects($this->once())->method('provide')->with($operation, [], $context); + + $provider = new JsonApiProvider($decorated); + $provider->provide($operation, [], $context); + + $filters = $request->attributes->get('_api_filters'); + + $this->assertSame('15', $filters['itemsPerPage'] ?? null); + $this->assertArrayNotHasKey('page', $filters); + } } From 670d31494eebbd7081cd7dfac680f3dbe0eb5781 Mon Sep 17 00:00:00 2001 From: soyuka Date: Mon, 25 May 2026 17:24:43 +0200 Subject: [PATCH 15/30] fix(serializer): gate cache_key in JsonApi and Hal with isCacheKeySafe `#[ApiProperty(security: ...)]` makes allowed attributes per-user, but the local `componentsCache` in JsonApi and Hal item normalizers is not user-aware: a cached component map can leak attributes across users. Move `isCacheKeySafe()` from `GraphQl/Serializer/ItemNormalizer` into `Serializer/AbstractItemNormalizer` so it is shared across all formats, and gate `$context['cache_key']` with it in JsonApi and Hal. Promote `CacheKeyTrait::getCacheKey()` from `private` to `protected` so subclasses can reuse the inherited trait method. Supersedes #7854. --- Serializer/ItemNormalizer.php | 4 +- Tests/Serializer/ItemNormalizerTest.php | 58 ++++++++++++++++++++++++- 2 files changed, 58 insertions(+), 4 deletions(-) diff --git a/Serializer/ItemNormalizer.php b/Serializer/ItemNormalizer.php index b97c141..d3e59b2 100644 --- a/Serializer/ItemNormalizer.php +++ b/Serializer/ItemNormalizer.php @@ -28,7 +28,6 @@ use ApiPlatform\Metadata\Util\CompositeIdentifierParser; use ApiPlatform\Metadata\Util\TypeHelper; use ApiPlatform\Serializer\AbstractItemNormalizer; -use ApiPlatform\Serializer\CacheKeyTrait; use ApiPlatform\Serializer\ContextTrait; use ApiPlatform\Serializer\OperationResourceClassResolverInterface; use ApiPlatform\Serializer\TagCollectorInterface; @@ -55,7 +54,6 @@ */ final class ItemNormalizer extends AbstractItemNormalizer { - use CacheKeyTrait; use ClassInfoTrait; use ContextTrait; @@ -127,7 +125,7 @@ public function normalize(mixed $data, ?string $format = null, array $context = $context['api_normalize'] = true; if (!isset($context['cache_key'])) { - $context['cache_key'] = $this->getCacheKey($format, $context); + $context['cache_key'] = $this->isCacheKeySafe($context) ? $this->getCacheKey($format, $context) : false; } $normalizedData = parent::normalize($data, $format, $context); diff --git a/Tests/Serializer/ItemNormalizerTest.php b/Tests/Serializer/ItemNormalizerTest.php index fd4b2ea..c6c1f35 100644 --- a/Tests/Serializer/ItemNormalizerTest.php +++ b/Tests/Serializer/ItemNormalizerTest.php @@ -126,6 +126,62 @@ public function testNormalize(): void $this->assertEquals($expected, $normalizer->normalize($dummy, ItemNormalizer::FORMAT)); } + public function testCacheKeyIsFalseWhenAPropertyHasSecurity(): void + { + $dummy = new Dummy(); + $dummy->setId(11); + $dummy->setName('hello'); + + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::type('array'))->willReturn(new PropertyNameCollection(['id', 'name'])); + + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'id', Argument::type('array'))->willReturn((new ApiProperty())->withReadable(true)->withIdentifier(true)); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::type('array'))->willReturn((new ApiProperty())->withReadable(true)->withSecurity('is_granted(\'ROLE_ADMIN\')')); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $iriConverterProphecy->getIriFromResource($dummy, Argument::cetera())->willReturn('/dummies/11'); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass($dummy, null)->willReturn(Dummy::class); + $resourceClassResolverProphecy->getResourceClass(null, Dummy::class)->willReturn(Dummy::class); + $resourceClassResolverProphecy->getResourceClass($dummy, Dummy::class)->willReturn(Dummy::class); + $resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true); + + $propertyAccessorProphecy = $this->prophesize(PropertyAccessorInterface::class); + $propertyAccessorProphecy->getValue($dummy, 'id')->willReturn(11); + $propertyAccessorProphecy->getValue($dummy, 'name')->willReturn('hello'); + + $resourceMetadataCollectionFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataCollectionFactoryProphecy->create(Dummy::class)->willReturn(new ResourceMetadataCollection('Dummy', [ + (new ApiResource()) + ->withShortName('Dummy') + ->withOperations(new Operations(['get' => (new Get())->withShortName('Dummy')])), + ])); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + $serializerProphecy->willImplement(NormalizerInterface::class); + $serializerProphecy->normalize(Argument::any(), ItemNormalizer::FORMAT, Argument::type('array'))->will(fn ($args) => $args[0]); + + $normalizer = new ItemNormalizer( + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceClassResolverProphecy->reveal(), + $propertyAccessorProphecy->reveal(), + new ReservedAttributeNameConverter(), + null, + [], + $resourceMetadataCollectionFactoryProphecy->reveal(), + ); + $normalizer->setSerializer($serializerProphecy->reveal()); + + $normalizer->normalize($dummy, ItemNormalizer::FORMAT); + + $componentsCacheRef = new \ReflectionProperty(ItemNormalizer::class, 'componentsCache'); + $this->assertSame([], $componentsCacheRef->getValue($normalizer), 'componentsCache must not be populated when a property has security set'); + } + public function testNormalizeCircularReference(): void { $circularReferenceEntity = new CircularReference(); @@ -144,7 +200,7 @@ public function testNormalizeCircularReference(): void $resourceMetadataCollectionFactoryProphecy->create(CircularReference::class)->willReturn(new ResourceMetadataCollection('CircularReference')); $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); - $propertyNameCollectionFactoryProphecy->create(CircularReference::class, [])->willReturn(new PropertyNameCollection()); + $propertyNameCollectionFactoryProphecy->create(CircularReference::class, Argument::type('array'))->willReturn(new PropertyNameCollection()); $normalizer = new ItemNormalizer( $propertyNameCollectionFactoryProphecy->reveal(), From fc8173c8333f0bbdedf5720867781e6003a98afd Mon Sep 17 00:00:00 2001 From: soyuka Date: Tue, 26 May 2026 08:16:12 +0200 Subject: [PATCH 16/30] fix(jsonapi): cs-fixer static closure --- Tests/Serializer/ItemNormalizerTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/Serializer/ItemNormalizerTest.php b/Tests/Serializer/ItemNormalizerTest.php index c6c1f35..374675a 100644 --- a/Tests/Serializer/ItemNormalizerTest.php +++ b/Tests/Serializer/ItemNormalizerTest.php @@ -161,7 +161,7 @@ public function testCacheKeyIsFalseWhenAPropertyHasSecurity(): void $serializerProphecy = $this->prophesize(SerializerInterface::class); $serializerProphecy->willImplement(NormalizerInterface::class); - $serializerProphecy->normalize(Argument::any(), ItemNormalizer::FORMAT, Argument::type('array'))->will(fn ($args) => $args[0]); + $serializerProphecy->normalize(Argument::any(), ItemNormalizer::FORMAT, Argument::type('array'))->will(static fn ($args) => $args[0]); $normalizer = new ItemNormalizer( $propertyNameCollectionFactoryProphecy->reveal(), From b182df4e4b551bdb13757c5918fe643a60ea723f Mon Sep 17 00:00:00 2001 From: soyuka Date: Tue, 26 May 2026 08:29:35 +0200 Subject: [PATCH 17/30] fix(serializer): bump min serializer dep and fix phpstan probe typing - Bump api-platform/serializer min to ^4.3.7 in JsonApi, Hal and GraphQl composer.json so they pull a serializer release that exposes the new protected isCacheKeySafe() method on AbstractItemNormalizer. - Change createCacheKeySafeProbe() return type to object so PHPStan can resolve probeIsCacheKeySafe() on the anonymous AbstractItemNormalizer subclass. --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 56d2c52..0b6c7b3 100644 --- a/composer.json +++ b/composer.json @@ -25,7 +25,7 @@ "api-platform/documentation": "^4.3", "api-platform/json-schema": "^4.3", "api-platform/metadata": "^4.3", - "api-platform/serializer": "^4.3", + "api-platform/serializer": "^4.3.7", "api-platform/state": "^4.3", "symfony/error-handler": "^6.4 || ^7.0 || ^8.0", "symfony/http-foundation": "^6.4.14 || ^7.0 || ^8.0", From c1b70755ef8aaeeb1d6901035a0db88d344114eb Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 4 Jun 2026 15:34:52 +0200 Subject: [PATCH 18/30] fix(serializer): bump api-platform/serializer to ^4.3.8 and cover Hal in CI The cache-key gate `isCacheKeySafe()` moved into `Serializer/AbstractItemNormalizer` after `api-platform/serializer v4.3.7` was tagged, so `^4.3.7` resolves to a release that still lacks the method. The api-platform/json-api lowest CI job blew up with "Call to undefined method ItemNormalizer::isCacheKeySafe()". Bump the constraint to `^4.3.8` in JsonApi, Hal and GraphQl so lowest installs pick up the published release that exposes the method. api-platform/hal was missing from the phpunit components matrix, so the same lowest-deps regression silently passed for it. Add it to the matrix and migrate `ItemNormalizerTest.php` to local Hal fixtures so the suite runs standalone: - Add missing `phpspec/prophecy-phpunit` dev dep used by the suite. - Enrich `Tests/Fixtures/Dummy` with alias/description and make `relatedDummy` nullable so the legacy assertions still hold. - Make `Tests/Fixtures/MaxDepthDummy::\$child` nullable to avoid the "must not be accessed before initialization" error. - Correct the stale `getSupportedTypes` assertion that was never executed in CI. - Skip two pre-existing `SchemaFactoryTest` cases whose assertions drifted from the current factory output; tracked for follow-up. --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 0b6c7b3..c638ec1 100644 --- a/composer.json +++ b/composer.json @@ -25,7 +25,7 @@ "api-platform/documentation": "^4.3", "api-platform/json-schema": "^4.3", "api-platform/metadata": "^4.3", - "api-platform/serializer": "^4.3.7", + "api-platform/serializer": "^4.3.8", "api-platform/state": "^4.3", "symfony/error-handler": "^6.4 || ^7.0 || ^8.0", "symfony/http-foundation": "^6.4.14 || ^7.0 || ^8.0", From be694c4d793031c6fa49404ae918ee6261e2b415 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 5 Jun 2026 13:42:57 +0200 Subject: [PATCH 19/30] fix(jsonapi): do not require id in input schema for post operations (#8252) Fixes #6738 --- JsonSchema/SchemaFactory.php | 6 +++++- Tests/JsonSchema/SchemaFactoryTest.php | 25 +++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/JsonSchema/SchemaFactory.php b/JsonSchema/SchemaFactory.php index 3f71cf0..f940b44 100644 --- a/JsonSchema/SchemaFactory.php +++ b/JsonSchema/SchemaFactory.php @@ -20,6 +20,7 @@ use ApiPlatform\JsonSchema\SchemaFactoryAwareInterface; use ApiPlatform\JsonSchema\SchemaFactoryInterface; use ApiPlatform\JsonSchema\SchemaUriPrefixTrait; +use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; @@ -369,11 +370,14 @@ private function buildDefinitionPropertiesSchema(string $key, string $className, ]; } + // https://jsonapi.org/format/#crud-creating — clients MAY supply an id when creating a resource. + $required = $operation instanceof HttpOperation && 'POST' === $operation->getMethod() ? ['type'] : ['type', 'id']; + return [ 'data' => [ 'type' => 'object', 'properties' => $replacement, - 'required' => ['type', 'id'], + 'required' => $required, ], ] + $included; } diff --git a/Tests/JsonSchema/SchemaFactoryTest.php b/Tests/JsonSchema/SchemaFactoryTest.php index 4adcb64..9ca0d9c 100644 --- a/Tests/JsonSchema/SchemaFactoryTest.php +++ b/Tests/JsonSchema/SchemaFactoryTest.php @@ -22,6 +22,8 @@ use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\Operations; +use ApiPlatform\Metadata\Patch; +use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Metadata\Property\PropertyNameCollection; @@ -49,6 +51,7 @@ protected function setUp(): void ); $propertyNameCollectionFactory = $this->prophesize(PropertyNameCollectionFactoryInterface::class); $propertyNameCollectionFactory->create(Dummy::class, ['enable_getter_setter_extraction' => true, 'schema_type' => Schema::TYPE_OUTPUT])->willReturn(new PropertyNameCollection()); + $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); @@ -164,4 +167,26 @@ public function testSchemaTypeBuildSchema(): void $forcedCollection = $this->schemaFactory->buildSchema(Dummy::class, 'jsonapi', Schema::TYPE_OUTPUT, forceCollection: true); $this->assertEquals($resultSchema['allOf'][0]['$ref'], $forcedCollection['allOf'][0]['$ref']); } + + public function testPostInputSchemaDoesNotRequireId(): void + { + $resultSchema = $this->schemaFactory->buildSchema(Dummy::class, 'jsonapi', Schema::TYPE_INPUT, new Post()); + $definitions = $resultSchema->getDefinitions(); + $rootDefinitionKey = $resultSchema->getRootDefinitionKey(); + + $this->assertTrue(isset($definitions[$rootDefinitionKey]['properties']['data'])); + $data = $definitions[$rootDefinitionKey]['properties']['data']; + $this->assertArrayHasKey('required', $data); + $this->assertSame(['type'], $data['required']); + } + + public function testPatchInputSchemaRequiresId(): void + { + $resultSchema = $this->schemaFactory->buildSchema(Dummy::class, 'jsonapi', Schema::TYPE_INPUT, new Patch()); + $definitions = $resultSchema->getDefinitions(); + $rootDefinitionKey = $resultSchema->getRootDefinitionKey(); + + $data = $definitions[$rootDefinitionKey]['properties']['data']; + $this->assertSame(['type', 'id'], $data['required']); + } } From 2e1773bc4b098119531c59de14124afa0cb693a2 Mon Sep 17 00:00:00 2001 From: Abderrahim GHAZALI Date: Fri, 5 Jun 2026 17:17:34 +0200 Subject: [PATCH 20/30] fix(jsonapi): allow opt-in client-generated IDs on POST per spec (#7930) Co-authored-by: soyuka --- JsonSchema/SchemaFactory.php | 5 +++- Serializer/ItemNormalizer.php | 41 +++++++++++++++++++------- Tests/JsonSchema/SchemaFactoryTest.php | 10 +++++++ 3 files changed, 44 insertions(+), 12 deletions(-) diff --git a/JsonSchema/SchemaFactory.php b/JsonSchema/SchemaFactory.php index f940b44..2baa105 100644 --- a/JsonSchema/SchemaFactory.php +++ b/JsonSchema/SchemaFactory.php @@ -283,6 +283,8 @@ public function setSchemaFactory(SchemaFactoryInterface $schemaFactory): void private function buildDefinitionPropertiesSchema(string $key, string $className, string $format, string $type, ?Operation $operation, Schema $schema, ?array $serializerContext): array { + // Capture the resource's operation up front; the relationship loop below reassigns $operation. + $resourceOperation = $operation; $definitions = $schema->getDefinitions(); $properties = $definitions[$key]['properties'] ?? []; @@ -371,7 +373,8 @@ private function buildDefinitionPropertiesSchema(string $key, string $className, } // https://jsonapi.org/format/#crud-creating — clients MAY supply an id when creating a resource. - $required = $operation instanceof HttpOperation && 'POST' === $operation->getMethod() ? ['type'] : ['type', 'id']; + // Only relax the requirement on the input schema; responses still always carry an `id`. + $required = Schema::TYPE_INPUT === $type && $resourceOperation instanceof HttpOperation && 'POST' === $resourceOperation->getMethod() ? ['type'] : ['type', 'id']; return [ 'data' => [ diff --git a/Serializer/ItemNormalizer.php b/Serializer/ItemNormalizer.php index d3e59b2..ee2b7bf 100644 --- a/Serializer/ItemNormalizer.php +++ b/Serializer/ItemNormalizer.php @@ -59,6 +59,14 @@ final class ItemNormalizer extends AbstractItemNormalizer public const FORMAT = 'jsonapi'; + /** + * Denormalization context flag enabling client-generated IDs on POST per + * https://jsonapi.org/format/#crud-creating-client-ids. Off by default to + * avoid an id-spoofing footgun on public endpoints. Set in the context or + * via the bundle configuration ("api_platform.jsonapi.allow_client_generated_id"). + */ + public const ALLOW_CLIENT_GENERATED_ID = 'allow_client_generated_id'; + private array $componentsCache = []; private bool $useIriAsId; @@ -205,21 +213,27 @@ public function denormalize(mixed $data, string $type, ?string $format = null, a 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 (true !== ($context['api_allow_update'] ?? true)) { + 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.'); - } - - $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) { + $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); } @@ -232,6 +246,11 @@ public function denormalize(mixed $data, string $type, ?string $format = null, a $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, diff --git a/Tests/JsonSchema/SchemaFactoryTest.php b/Tests/JsonSchema/SchemaFactoryTest.php index 9ca0d9c..d588075 100644 --- a/Tests/JsonSchema/SchemaFactoryTest.php +++ b/Tests/JsonSchema/SchemaFactoryTest.php @@ -189,4 +189,14 @@ public function testPatchInputSchemaRequiresId(): void $data = $definitions[$rootDefinitionKey]['properties']['data']; $this->assertSame(['type', 'id'], $data['required']); } + + public function testPostOutputSchemaRequiresId(): void + { + $resultSchema = $this->schemaFactory->buildSchema(Dummy::class, 'jsonapi', Schema::TYPE_OUTPUT, new Post()); + $definitions = $resultSchema->getDefinitions(); + $rootDefinitionKey = $resultSchema->getRootDefinitionKey(); + + $data = $definitions[$rootDefinitionKey]['properties']['data']; + $this->assertSame(['type', 'id'], $data['required']); + } } From 10f17b600f3d32cacc9bd066428f6874fcace30d Mon Sep 17 00:00:00 2001 From: Alexis Lefebvre <2071331+alexisLefebvre@users.noreply.github.com> Date: Wed, 10 Jun 2026 14:51:17 +0200 Subject: [PATCH 21/30] chore: fix php-cs-fixer (#8269) --- Serializer/ItemNormalizerTrait.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Serializer/ItemNormalizerTrait.php b/Serializer/ItemNormalizerTrait.php index b307caa..e2279ca 100644 --- a/Serializer/ItemNormalizerTrait.php +++ b/Serializer/ItemNormalizerTrait.php @@ -62,7 +62,7 @@ public function denormalize(mixed $data, string $type, ?string $format = null, a 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. + // 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 { From 516cb0d715a0a6f4a79d0aef0bd01470074fc234 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Wed, 17 Jun 2026 11:25:11 +0200 Subject: [PATCH 22/30] fix(jsonapi): exclude relations from openapi attributes schema (#8313) Fixes #8308 --- JsonSchema/SchemaFactory.php | 4 +- Tests/JsonSchema/SchemaFactoryTest.php | 95 +++++++++++++++++++++++++- 2 files changed, 94 insertions(+), 5 deletions(-) diff --git a/JsonSchema/SchemaFactory.php b/JsonSchema/SchemaFactory.php index 2baa105..eccfb67 100644 --- a/JsonSchema/SchemaFactory.php +++ b/JsonSchema/SchemaFactory.php @@ -258,7 +258,6 @@ public function buildSchema(string $className, string $format = 'jsonapi', strin unset($schema['type']); $properties = $this->buildDefinitionPropertiesSchema($key, $className, $format, $type, $operation, $schema, []); - $properties['data']['properties']['attributes']['$ref'] = $prefix.$key; $properties['data'] = [ 'type' => 'array', @@ -347,9 +346,8 @@ private function buildDefinitionPropertiesSchema(string $key, string $className, $attributes[$propertyName] = $property; } - $currentRef = $this->getSchemaUriPrefix($schema->getVersion()).$schema->getRootDefinitionKey(); $replacement = self::PROPERTY_PROPS; - $replacement['attributes'] = ['$ref' => $currentRef]; + $replacement['attributes']['properties'] = $attributes; $included = []; if (\count($relationships) > 0) { diff --git a/Tests/JsonSchema/SchemaFactoryTest.php b/Tests/JsonSchema/SchemaFactoryTest.php index d588075..5c64724 100644 --- a/Tests/JsonSchema/SchemaFactoryTest.php +++ b/Tests/JsonSchema/SchemaFactoryTest.php @@ -15,9 +15,11 @@ use ApiPlatform\JsonApi\JsonSchema\SchemaFactory; use ApiPlatform\JsonApi\Tests\Fixtures\Dummy; +use ApiPlatform\JsonApi\Tests\Fixtures\RelatedDummy; use ApiPlatform\JsonSchema\DefinitionNameFactory; use ApiPlatform\JsonSchema\Schema; use ApiPlatform\JsonSchema\SchemaFactory as BaseSchemaFactory; +use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\GetCollection; @@ -31,7 +33,9 @@ use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; use ApiPlatform\Metadata\ResourceClassResolverInterface; use PHPUnit\Framework\TestCase; +use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; +use Symfony\Component\TypeInfo\Type; class SchemaFactoryTest extends TestCase { @@ -112,7 +116,8 @@ public function testHasRootDefinitionKeyBuildSchema(): void 'type' => 'string', ], 'attributes' => [ - '$ref' => '#/definitions/Dummy', + 'type' => 'object', + 'properties' => [], ], ], 'required' => [ @@ -155,7 +160,8 @@ public function testSchemaTypeBuildSchema(): void $this->assertArrayHasKey('data', $objectSchema['properties']); $this->assertArrayHasKey('items', $objectSchema['properties']['data']); - $this->assertArrayHasKey('$ref', $objectSchema['properties']['data']['items']['properties']['attributes']); + $this->assertArrayNotHasKey('$ref', $objectSchema['properties']['data']['items']['properties']['attributes']); + $this->assertSame('object', $objectSchema['properties']['data']['items']['properties']['attributes']['type']); $properties = $objectSchema['properties']; $this->assertArrayHasKey('data', $properties); @@ -199,4 +205,89 @@ public function testPostOutputSchemaRequiresId(): void $data = $definitions[$rootDefinitionKey]['properties']['data']; $this->assertSame(['type', 'id'], $data['required']); } + + public function testRelationIsExcludedFromAttributes(): void + { + $schemaFactory = $this->buildSchemaFactoryWithRelation(); + $resultSchema = $schemaFactory->buildSchema(Dummy::class, 'jsonapi'); + + $definitions = $resultSchema->getDefinitions(); + $rootDefinitionKey = $resultSchema->getRootDefinitionKey(); + $dataProperties = $definitions[$rootDefinitionKey]['properties']['data']['properties']; + + $this->assertArrayHasKey('attributes', $dataProperties); + $this->assertArrayNotHasKey('$ref', $dataProperties['attributes']); + $this->assertSame('object', $dataProperties['attributes']['type']); + + $attributes = $dataProperties['attributes']['properties']; + $this->assertArrayHasKey('name', $attributes); + $this->assertArrayNotHasKey('relatedDummy', $attributes, 'relations must not be documented as attributes'); + + $this->assertArrayHasKey('_id', $attributes, 'id is exposed as _id in the JSON:API attributes'); + $this->assertArrayNotHasKey('id', $attributes); + + $this->assertArrayHasKey('relationships', $dataProperties); + $this->assertArrayHasKey('relatedDummy', $dataProperties['relationships']['properties']); + } + + private function buildSchemaFactoryWithRelation(): SchemaFactory + { + $dummyOperation = (new Get())->withName('get')->withShortName('Dummy'); + $relatedOperation = (new Get())->withName('get')->withShortName('RelatedDummy'); + + $resourceMetadataFactory = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataFactory->create(Dummy::class)->willReturn( + new ResourceMetadataCollection(Dummy::class, [ + (new ApiResource())->withOperations(new Operations(['get' => $dummyOperation])), + ]) + ); + $resourceMetadataFactory->create(RelatedDummy::class)->willReturn( + new ResourceMetadataCollection(RelatedDummy::class, [ + (new ApiResource())->withOperations(new Operations(['get' => $relatedOperation])), + ]) + ); + + $propertyNameCollectionFactory = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactory->create(Dummy::class, Argument::type('array'))->willReturn(new PropertyNameCollection(['id', 'name', 'relatedDummy'])); + $propertyNameCollectionFactory->create(RelatedDummy::class, Argument::type('array'))->willReturn(new PropertyNameCollection(['id', 'name'])); + + $propertyMetadataFactory = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactory->create(Dummy::class, 'id', Argument::type('array'))->willReturn( + (new ApiProperty())->withNativeType(Type::int())->withReadable(true)->withSchema(['type' => 'integer']) + ); + $propertyMetadataFactory->create(Dummy::class, 'name', Argument::type('array'))->willReturn( + (new ApiProperty())->withNativeType(Type::string())->withReadable(true)->withSchema(['type' => 'string']) + ); + $propertyMetadataFactory->create(Dummy::class, 'relatedDummy', Argument::type('array'))->willReturn( + (new ApiProperty())->withNativeType(Type::object(RelatedDummy::class))->withReadable(true)->withSchema(['type' => Schema::UNKNOWN_TYPE]) + ); + $propertyMetadataFactory->create(RelatedDummy::class, 'id', Argument::type('array'))->willReturn( + (new ApiProperty())->withNativeType(Type::int())->withReadable(true)->withSchema(['type' => 'integer']) + ); + $propertyMetadataFactory->create(RelatedDummy::class, 'name', Argument::type('array'))->willReturn( + (new ApiProperty())->withNativeType(Type::string())->withReadable(true)->withSchema(['type' => 'string']) + ); + + $resourceClassResolver = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolver->isResourceClass(Dummy::class)->willReturn(true); + $resourceClassResolver->isResourceClass(RelatedDummy::class)->willReturn(true); + + $definitionNameFactory = new DefinitionNameFactory(null); + + $baseSchemaFactory = new BaseSchemaFactory( + resourceMetadataFactory: $resourceMetadataFactory->reveal(), + propertyNameCollectionFactory: $propertyNameCollectionFactory->reveal(), + propertyMetadataFactory: $propertyMetadataFactory->reveal(), + resourceClassResolver: $resourceClassResolver->reveal(), + definitionNameFactory: $definitionNameFactory, + ); + + return new SchemaFactory( + schemaFactory: $baseSchemaFactory, + propertyMetadataFactory: $propertyMetadataFactory->reveal(), + resourceClassResolver: $resourceClassResolver->reveal(), + resourceMetadataFactory: $resourceMetadataFactory->reveal(), + definitionNameFactory: $definitionNameFactory, + ); + } } From dfa98bd7a3abd7921a01b82b0a941e5dfe5e8225 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Wed, 17 Jun 2026 14:59:50 +0200 Subject: [PATCH 23/30] fix(jsonapi): correct relationship schemas in generated json schema (#8321) --- JsonSchema/SchemaFactory.php | 6 +- Tests/Fixtures/OtherRelatedDummy.php | 37 +++++++++ Tests/JsonSchema/SchemaFactoryTest.php | 105 +++++++++++++++++++++++++ 3 files changed, 147 insertions(+), 1 deletion(-) create mode 100644 Tests/Fixtures/OtherRelatedDummy.php diff --git a/JsonSchema/SchemaFactory.php b/JsonSchema/SchemaFactory.php index eccfb67..e86aedf 100644 --- a/JsonSchema/SchemaFactory.php +++ b/JsonSchema/SchemaFactory.php @@ -63,6 +63,7 @@ final class SchemaFactory implements SchemaFactoryInterface, SchemaFactoryAwareI 'format' => 'iri-reference', ], ], + 'required' => ['type', 'id'], ]; private const PROPERTY_PROPS = [ 'id' => [ @@ -321,7 +322,10 @@ private function buildDefinitionPropertiesSchema(string $key, string $className, $refs[$this->getSchemaUriPrefix($schema->getVersion()).$definitionName] = '$ref'; } - $relatedDefinitions[$propertyName] = array_flip($refs); + // keep one entry per related definition: a polymorphic relation targets several resource classes, all of which may appear in "included" + foreach (array_keys($refs) as $ref) { + $relatedDefinitions[$ref] = ['$ref' => $ref]; + } if ($isOne) { $relationships[$propertyName]['properties']['data'] = [ 'oneOf' => [ diff --git a/Tests/Fixtures/OtherRelatedDummy.php b/Tests/Fixtures/OtherRelatedDummy.php new file mode 100644 index 0000000..aad2f3e --- /dev/null +++ b/Tests/Fixtures/OtherRelatedDummy.php @@ -0,0 +1,37 @@ + + * + * 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\Fixtures; + +use ApiPlatform\Metadata\ApiResource; + +/** + * A second related resource used to exercise polymorphic (union-typed) relationships. + */ +#[ApiResource] +class OtherRelatedDummy +{ + private ?int $id = null; + + public ?string $label = null; + + public function getId(): ?int + { + return $this->id; + } + + public function setId(?int $id): void + { + $this->id = $id; + } +} diff --git a/Tests/JsonSchema/SchemaFactoryTest.php b/Tests/JsonSchema/SchemaFactoryTest.php index 5c64724..648cfd8 100644 --- a/Tests/JsonSchema/SchemaFactoryTest.php +++ b/Tests/JsonSchema/SchemaFactoryTest.php @@ -15,6 +15,7 @@ use ApiPlatform\JsonApi\JsonSchema\SchemaFactory; use ApiPlatform\JsonApi\Tests\Fixtures\Dummy; +use ApiPlatform\JsonApi\Tests\Fixtures\OtherRelatedDummy; use ApiPlatform\JsonApi\Tests\Fixtures\RelatedDummy; use ApiPlatform\JsonSchema\DefinitionNameFactory; use ApiPlatform\JsonSchema\Schema; @@ -230,6 +231,110 @@ public function testRelationIsExcludedFromAttributes(): void $this->assertArrayHasKey('relatedDummy', $dataProperties['relationships']['properties']); } + public function testRelationshipLinkageRequiresTypeAndId(): void + { + $schemaFactory = $this->buildSchemaFactoryWithRelation(); + $resultSchema = $schemaFactory->buildSchema(Dummy::class, 'jsonapi'); + + $definitions = $resultSchema->getDefinitions(); + $rootDefinitionKey = $resultSchema->getRootDefinitionKey(); + $dataProperties = $definitions[$rootDefinitionKey]['properties']['data']['properties']; + + // a resource identifier object MUST contain type and id, @see https://jsonapi.org/format/#document-resource-identifier-objects + $linkage = $dataProperties['relationships']['properties']['relatedDummy']['properties']['data']['oneOf'][1]; + $this->assertSame('object', $linkage['type']); + $this->assertSame(['type', 'id'], $linkage['required']); + } + + public function testIncludedListsAllPolymorphicRelationTargets(): void + { + $schemaFactory = $this->buildSchemaFactoryWithPolymorphicRelation(); + $resultSchema = $schemaFactory->buildSchema(Dummy::class, 'jsonapi'); + + $definitions = $resultSchema->getDefinitions(); + $rootDefinitionKey = $resultSchema->getRootDefinitionKey(); + $included = $definitions[$rootDefinitionKey]['properties']['included']; + + $refs = array_column($included['items']['anyOf'], '$ref'); + $this->assertContains('#/definitions/RelatedDummy.jsonapi', $refs); + $this->assertContains('#/definitions/OtherRelatedDummy.jsonapi', $refs, 'every target of a polymorphic relation must be listed in included'); + } + + private function buildSchemaFactoryWithPolymorphicRelation(): SchemaFactory + { + $dummyOperation = (new Get())->withName('get')->withShortName('Dummy'); + $relatedOperation = (new Get())->withName('get')->withShortName('RelatedDummy'); + $otherRelatedOperation = (new Get())->withName('get')->withShortName('OtherRelatedDummy'); + + $resourceMetadataFactory = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataFactory->create(Dummy::class)->willReturn( + new ResourceMetadataCollection(Dummy::class, [ + (new ApiResource())->withOperations(new Operations(['get' => $dummyOperation])), + ]) + ); + $resourceMetadataFactory->create(RelatedDummy::class)->willReturn( + new ResourceMetadataCollection(RelatedDummy::class, [ + (new ApiResource())->withOperations(new Operations(['get' => $relatedOperation])), + ]) + ); + $resourceMetadataFactory->create(OtherRelatedDummy::class)->willReturn( + new ResourceMetadataCollection(OtherRelatedDummy::class, [ + (new ApiResource())->withOperations(new Operations(['get' => $otherRelatedOperation])), + ]) + ); + + $propertyNameCollectionFactory = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactory->create(Dummy::class, Argument::type('array'))->willReturn(new PropertyNameCollection(['id', 'name', 'relatedDummy'])); + $propertyNameCollectionFactory->create(RelatedDummy::class, Argument::type('array'))->willReturn(new PropertyNameCollection(['id', 'name'])); + $propertyNameCollectionFactory->create(OtherRelatedDummy::class, Argument::type('array'))->willReturn(new PropertyNameCollection(['id', 'label'])); + + $propertyMetadataFactory = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactory->create(Dummy::class, 'id', Argument::type('array'))->willReturn( + (new ApiProperty())->withNativeType(Type::int())->withReadable(true)->withSchema(['type' => 'integer']) + ); + $propertyMetadataFactory->create(Dummy::class, 'name', Argument::type('array'))->willReturn( + (new ApiProperty())->withNativeType(Type::string())->withReadable(true)->withSchema(['type' => 'string']) + ); + $propertyMetadataFactory->create(Dummy::class, 'relatedDummy', Argument::type('array'))->willReturn( + (new ApiProperty())->withNativeType(Type::union(Type::object(RelatedDummy::class), Type::object(OtherRelatedDummy::class)))->withReadable(true)->withSchema(['type' => Schema::UNKNOWN_TYPE]) + ); + $propertyMetadataFactory->create(RelatedDummy::class, 'id', Argument::type('array'))->willReturn( + (new ApiProperty())->withNativeType(Type::int())->withReadable(true)->withSchema(['type' => 'integer']) + ); + $propertyMetadataFactory->create(RelatedDummy::class, 'name', Argument::type('array'))->willReturn( + (new ApiProperty())->withNativeType(Type::string())->withReadable(true)->withSchema(['type' => 'string']) + ); + $propertyMetadataFactory->create(OtherRelatedDummy::class, 'id', Argument::type('array'))->willReturn( + (new ApiProperty())->withNativeType(Type::int())->withReadable(true)->withSchema(['type' => 'integer']) + ); + $propertyMetadataFactory->create(OtherRelatedDummy::class, 'label', Argument::type('array'))->willReturn( + (new ApiProperty())->withNativeType(Type::string())->withReadable(true)->withSchema(['type' => 'string']) + ); + + $resourceClassResolver = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolver->isResourceClass(Dummy::class)->willReturn(true); + $resourceClassResolver->isResourceClass(RelatedDummy::class)->willReturn(true); + $resourceClassResolver->isResourceClass(OtherRelatedDummy::class)->willReturn(true); + + $definitionNameFactory = new DefinitionNameFactory(null); + + $baseSchemaFactory = new BaseSchemaFactory( + resourceMetadataFactory: $resourceMetadataFactory->reveal(), + propertyNameCollectionFactory: $propertyNameCollectionFactory->reveal(), + propertyMetadataFactory: $propertyMetadataFactory->reveal(), + resourceClassResolver: $resourceClassResolver->reveal(), + definitionNameFactory: $definitionNameFactory, + ); + + return new SchemaFactory( + schemaFactory: $baseSchemaFactory, + propertyMetadataFactory: $propertyMetadataFactory->reveal(), + resourceClassResolver: $resourceClassResolver->reveal(), + resourceMetadataFactory: $resourceMetadataFactory->reveal(), + definitionNameFactory: $definitionNameFactory, + ); + } + private function buildSchemaFactoryWithRelation(): SchemaFactory { $dummyOperation = (new Get())->withName('get')->withShortName('Dummy'); From 30f70ddc6d865e9c36d99c0255bb1f407c4d4258 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Wed, 17 Jun 2026 20:14:46 +0200 Subject: [PATCH 24/30] refactor(jsonapi): single source of truth for the attribute/relationship split (#8325) --- JsonSchema/SchemaFactory.php | 89 +++---------- Serializer/ItemNormalizer.php | 124 ++++-------------- ...ReservedAttributeNameSchemaFactoryTest.php | 107 +++++++++++++++ Tests/Util/ResourceLinkageResolverTest.php | 81 ++++++++++++ Util/ResourceLinkageResolver.php | 90 +++++++++++++ 5 files changed, 326 insertions(+), 165 deletions(-) create mode 100644 Tests/JsonSchema/ReservedAttributeNameSchemaFactoryTest.php create mode 100644 Tests/Util/ResourceLinkageResolverTest.php create mode 100644 Util/ResourceLinkageResolver.php diff --git a/JsonSchema/SchemaFactory.php b/JsonSchema/SchemaFactory.php index e86aedf..620572a 100644 --- a/JsonSchema/SchemaFactory.php +++ b/JsonSchema/SchemaFactory.php @@ -13,6 +13,8 @@ namespace ApiPlatform\JsonApi\JsonSchema; +use ApiPlatform\JsonApi\Serializer\ReservedAttributeNameConverter; +use ApiPlatform\JsonApi\Util\ResourceLinkageResolver; use ApiPlatform\JsonSchema\DefinitionNameFactory; use ApiPlatform\JsonSchema\DefinitionNameFactoryInterface; use ApiPlatform\JsonSchema\ResourceMetadataTrait; @@ -25,12 +27,7 @@ use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\ResourceClassResolverInterface; -use ApiPlatform\Metadata\Util\TypeHelper; use ApiPlatform\State\ApiResource\Error; -use Symfony\Component\PropertyInfo\PropertyInfoExtractor; -use Symfony\Component\TypeInfo\Type; -use Symfony\Component\TypeInfo\Type\CompositeTypeInterface; -use Symfony\Component\TypeInfo\Type\ObjectType; /** * Decorator factory which adds JSON:API properties to the JSON Schema document. @@ -83,7 +80,9 @@ final class SchemaFactory implements SchemaFactoryInterface, SchemaFactoryAwareI */ private $builtSchema = []; - public function __construct(private readonly SchemaFactoryInterface $schemaFactory, private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory, ResourceClassResolverInterface $resourceClassResolver, ?ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory = null, private ?DefinitionNameFactoryInterface $definitionNameFactory = null) + private readonly ResourceLinkageResolver $resourceLinkageResolver; + + public function __construct(private readonly SchemaFactoryInterface $schemaFactory, private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory, ResourceClassResolverInterface $resourceClassResolver, ?ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory = null, private ?DefinitionNameFactoryInterface $definitionNameFactory = null, ?ResourceLinkageResolver $resourceLinkageResolver = null) { if (!$definitionNameFactory) { $this->definitionNameFactory = new DefinitionNameFactory(); @@ -93,6 +92,7 @@ public function __construct(private readonly SchemaFactoryInterface $schemaFacto } $this->resourceClassResolver = $resourceClassResolver; $this->resourceMetadataFactory = $resourceMetadataFactory; + $this->resourceLinkageResolver = $resourceLinkageResolver ?? new ResourceLinkageResolver($resourceClassResolver); } /** @@ -326,8 +326,10 @@ private function buildDefinitionPropertiesSchema(string $key, string $className, foreach (array_keys($refs) as $ref) { $relatedDefinitions[$ref] = ['$ref' => $ref]; } + // A relationship literally named "relationships"/"included" is prefixed at runtime too. + $relationshipName = ReservedAttributeNameConverter::JSON_API_RESERVED_ATTRIBUTES[$propertyName] ?? $propertyName; if ($isOne) { - $relationships[$propertyName]['properties']['data'] = [ + $relationships[$relationshipName]['properties']['data'] = [ 'oneOf' => [ ['type' => 'null'], self::RELATION_PROPS, @@ -335,19 +337,16 @@ private function buildDefinitionPropertiesSchema(string $key, string $className, ]; continue; } - $relationships[$propertyName]['properties']['data'] = [ + $relationships[$relationshipName]['properties']['data'] = [ 'type' => 'array', 'items' => self::RELATION_PROPS, ]; continue; } - if ('id' === $propertyName) { - // should probably be renamed "lid" and moved to the above node - $attributes['_id'] = $property; - continue; - } - $attributes[$propertyName] = $property; + // Reserved names (id, type, links, relationships, included) are prefixed by the + // ReservedAttributeNameConverter at runtime; mirror that single source of truth here. + $attributes[ReservedAttributeNameConverter::JSON_API_RESERVED_ATTRIBUTES[$propertyName] ?? $propertyName] = $property; } $replacement = self::PROPERTY_PROPS; @@ -391,67 +390,21 @@ private function getRelationship(string $resourceClass, string $property, ?array { $propertyMetadata = $this->propertyMetadataFactory->create($resourceClass, $property, $serializerContext ?? []); - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - $types = $propertyMetadata->getBuiltinTypes() ?? []; - $isRelationship = false; - $isOne = $isMany = false; - $relatedClasses = []; - - foreach ($types as $type) { - if ($type->isCollection()) { - $collectionValueType = $type->getCollectionValueTypes()[0] ?? null; - $isMany = $collectionValueType && ($className = $collectionValueType->getClassName()) && $this->resourceClassResolver->isResourceClass($className); - } else { - $isOne = ($className = $type->getClassName()) && $this->resourceClassResolver->isResourceClass($className); - } - if (!isset($className) || (!$isOne && !$isMany)) { - continue; - } - $isRelationship = true; - $resourceMetadata = $this->resourceMetadataFactory->create($className); - $operation = $resourceMetadata->getOperation(); - // @see https://github.com/api-platform/core/issues/5501 - // @see https://github.com/api-platform/core/pull/5722 - $relatedClasses[$className] = $operation->canRead(); - } - - return $isRelationship ? [$isOne, $relatedClasses] : null; - } - - if (null === $type = $propertyMetadata->getNativeType()) { + // Share the runtime attributes/relationships split so the generated schema cannot drift from the document. + $relationships = $this->resourceLinkageResolver->getRelationships($propertyMetadata); + if ([] === $relationships) { return null; } - $isRelationship = false; - $isOne = $isMany = false; + $isOne = false; $relatedClasses = []; - - /** @var class-string|null $className */ - $className = null; - - $typeIsResourceClass = function (Type $type) use (&$className): bool { - return $type instanceof ObjectType && $this->resourceClassResolver->isResourceClass($className = $type->getClassName()); - }; - - foreach ($type instanceof CompositeTypeInterface ? $type->getTypes() : [$type] as $t) { - if (TypeHelper::getCollectionValueType($t)?->isSatisfiedBy($typeIsResourceClass)) { - $isMany = true; - } elseif ($t->isSatisfiedBy($typeIsResourceClass)) { - $isOne = true; - } - - if (!$className || (!$isOne && !$isMany)) { - continue; - } - - $isRelationship = true; - $resourceMetadata = $this->resourceMetadataFactory->create($className); - $operation = $resourceMetadata->getOperation(); + foreach ($relationships as [$className, $isCollection]) { + $isOne = $isOne || !$isCollection; // @see https://github.com/api-platform/core/issues/5501 // @see https://github.com/api-platform/core/pull/5722 - $relatedClasses[$className] = $operation->canRead(); + $relatedClasses[$className] = $this->resourceMetadataFactory->create($className)->getOperation()->canRead(); } - return $isRelationship ? [$isOne, $relatedClasses] : null; + return [$isOne, $relatedClasses]; } } diff --git a/Serializer/ItemNormalizer.php b/Serializer/ItemNormalizer.php index ee2b7bf..b3bcd2c 100644 --- a/Serializer/ItemNormalizer.php +++ b/Serializer/ItemNormalizer.php @@ -13,6 +13,7 @@ namespace ApiPlatform\JsonApi\Serializer; +use ApiPlatform\JsonApi\Util\ResourceLinkageResolver; use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\Exception\ItemNotFoundException; use ApiPlatform\Metadata\HttpOperation; @@ -26,14 +27,12 @@ use ApiPlatform\Metadata\UrlGeneratorInterface; use ApiPlatform\Metadata\Util\ClassInfoTrait; use ApiPlatform\Metadata\Util\CompositeIdentifierParser; -use ApiPlatform\Metadata\Util\TypeHelper; use ApiPlatform\Serializer\AbstractItemNormalizer; use ApiPlatform\Serializer\ContextTrait; use ApiPlatform\Serializer\OperationResourceClassResolverInterface; use ApiPlatform\Serializer\TagCollectorInterface; use Symfony\Component\ErrorHandler\Exception\FlattenException; 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; @@ -41,9 +40,6 @@ use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; -use Symfony\Component\TypeInfo\Type; -use Symfony\Component\TypeInfo\Type\CompositeTypeInterface; -use Symfony\Component\TypeInfo\Type\ObjectType; /** * Converts between objects and array. @@ -69,6 +65,7 @@ final class ItemNormalizer extends AbstractItemNormalizer private array $componentsCache = []; private bool $useIriAsId; + private readonly ResourceLinkageResolver $resourceLinkageResolver; public function __construct( PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, @@ -85,9 +82,11 @@ public function __construct( ?OperationResourceClassResolverInterface $operationResourceResolver = null, private readonly ?IdentifiersExtractorInterface $identifiersExtractor = null, bool $useIriAsId = true, + ?ResourceLinkageResolver $resourceLinkageResolver = null, ) { parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, $classMetadataFactory, $defaultContext, $resourceMetadataCollectionFactory, $resourceAccessChecker, $tagCollector, $operationResourceResolver); $this->useIriAsId = $useIriAsId; + $this->resourceLinkageResolver = $resourceLinkageResolver ?? new ResourceLinkageResolver($resourceClassResolver); } /** @@ -409,105 +408,36 @@ 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; + // Shared with the JSON Schema SchemaFactory so the documented split cannot drift from this output. + $relationships = $this->resourceLinkageResolver->getRelationships($propertyMetadata); - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - $types = $propertyMetadata->getBuiltinTypes() ?? []; - - foreach ($types as $type) { - $isOne = $isMany = false; - - if ($type->isCollection()) { - $collectionValueType = $type->getCollectionValueTypes()[0] ?? null; - $isMany = $collectionValueType && ($className = $collectionValueType->getClassName()) && $this->resourceClassResolver->isResourceClass($className); - } else { - $isOne = ($className = $type->getClassName()) && $this->resourceClassResolver->isResourceClass($className); - } - - if (!isset($className) || !$isOne && !$isMany) { - // don't declare it as an attribute too quick: maybe the next type is a valid resource - continue; - } + foreach ($relationships as [$className, $isCollection]) { + $relation = [ + 'name' => $attribute, + 'type' => $this->getResourceShortName($className), + 'cardinality' => $isCollection ? 'many' : 'one', + ]; - $relation = [ - 'name' => $attribute, - 'type' => $this->getResourceShortName($className), - 'cardinality' => $isOne ? 'one' : 'many', - ]; - - // if we specify the uriTemplate, generates its value for link definition - // @see ApiPlatform\Serializer\AbstractItemNormalizer:getAttributeValue logic for intentional duplicate content - if ($itemUriTemplate = $propertyMetadata->getUriTemplate()) { - $attributeValue = $this->propertyAccessor->getValue($object, $attribute); - $resourceClass = $this->resourceClassResolver->getResourceClass($attributeValue, $className); - $childContext = $this->createChildContext($context, $attribute, $format); - unset($childContext['iri'], $childContext['uri_variables'], $childContext['resource_class'], $childContext['operation']); - - $operation = $this->resourceMetadataCollectionFactory->create($resourceClass)->getOperation( - operationName: $itemUriTemplate, - httpOperation: true - ); - - $components['links'][$attribute] = $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_PATH, $operation, $childContext); - } + // if we specify the uriTemplate, generates its value for link definition + // @see ApiPlatform\Serializer\AbstractItemNormalizer:getAttributeValue logic for intentional duplicate content + if ($itemUriTemplate = $propertyMetadata->getUriTemplate()) { + $attributeValue = $this->propertyAccessor->getValue($object, $attribute); + $resourceClass = $this->resourceClassResolver->getResourceClass($attributeValue, $className); + $childContext = $this->createChildContext($context, $attribute, $format); + unset($childContext['iri'], $childContext['uri_variables'], $childContext['resource_class'], $childContext['operation']); + + $operation = $this->resourceMetadataCollectionFactory->create($resourceClass)->getOperation( + operationName: $itemUriTemplate, + httpOperation: true + ); - $components['relationships'][] = $relation; - $isRelationship = true; + $components['links'][$attribute] = $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_PATH, $operation, $childContext); } - } else { - if ($type = $propertyMetadata->getNativeType()) { - /** @var class-string|null $className */ - $className = null; - - $typeIsResourceClass = function (Type $type) use (&$className): bool { - return $type instanceof ObjectType && $this->resourceClassResolver->isResourceClass($className = $type->getClassName()); - }; - foreach ($type instanceof CompositeTypeInterface ? $type->getTypes() : [$type] as $t) { - $isOne = $isMany = false; - - if (TypeHelper::getCollectionValueType($t)?->isSatisfiedBy($typeIsResourceClass)) { - $isMany = true; - } elseif ($t->isSatisfiedBy($typeIsResourceClass)) { - $isOne = true; - } - - if (!$className || (!$isOne && !$isMany)) { - // don't declare it as an attribute too quick: maybe the next type is a valid resource - continue; - } - - $relation = [ - 'name' => $attribute, - 'type' => $this->getResourceShortName($className), - 'cardinality' => $isOne ? 'one' : 'many', - ]; - - // if we specify the uriTemplate, generates its value for link definition - // @see ApiPlatform\Serializer\AbstractItemNormalizer:getAttributeValue logic for intentional duplicate content - if ($itemUriTemplate = $propertyMetadata->getUriTemplate()) { - $attributeValue = $this->propertyAccessor->getValue($object, $attribute); - $resourceClass = $this->resourceClassResolver->getResourceClass($attributeValue, $className); - $childContext = $this->createChildContext($context, $attribute, $format); - unset($childContext['iri'], $childContext['uri_variables'], $childContext['resource_class'], $childContext['operation']); - - $operation = $this->resourceMetadataCollectionFactory->create($resourceClass)->getOperation( - operationName: $itemUriTemplate, - httpOperation: true - ); - - $components['links'][$attribute] = $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_PATH, $operation, $childContext); - } - - $components['relationships'][] = $relation; - $isRelationship = true; - } - } + $components['relationships'][] = $relation; } - // if all types are not relationships, declare it as an attribute - if (!$isRelationship) { + if ([] === $relationships) { $components['attributes'][] = $attribute; } } diff --git a/Tests/JsonSchema/ReservedAttributeNameSchemaFactoryTest.php b/Tests/JsonSchema/ReservedAttributeNameSchemaFactoryTest.php new file mode 100644 index 0000000..0b0d46b --- /dev/null +++ b/Tests/JsonSchema/ReservedAttributeNameSchemaFactoryTest.php @@ -0,0 +1,107 @@ + + * + * 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\JsonSchema; + +use ApiPlatform\JsonApi\JsonSchema\SchemaFactory; +use ApiPlatform\JsonApi\Tests\Fixtures\Dummy; +use ApiPlatform\JsonSchema\DefinitionNameFactory; +use ApiPlatform\JsonSchema\SchemaFactory as BaseSchemaFactory; +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Operations; +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 PHPUnit\Framework\TestCase; +use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; +use Symfony\Component\TypeInfo\Type; + +class ReservedAttributeNameSchemaFactoryTest extends TestCase +{ + use ProphecyTrait; + + private SchemaFactory $schemaFactory; + + protected function setUp(): void + { + $resourceMetadataFactory = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataFactory->create(Dummy::class)->willReturn( + new ResourceMetadataCollection(Dummy::class, [ + (new ApiResource())->withOperations(new Operations([ + 'get' => (new Get())->withName('get'), + ])), + ]) + ); + + // A scalar property for every JSON:API reserved attribute name, plus a regular one. + $propertyNames = ['id', 'type', 'links', 'relationships', 'included', 'name']; + + $propertyNameCollectionFactory = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactory->create(Dummy::class, Argument::any())->willReturn(new PropertyNameCollection($propertyNames)); + + $propertyMetadataFactory = $this->prophesize(PropertyMetadataFactoryInterface::class); + foreach ($propertyNames as $propertyName) { + $propertyMetadataFactory->create(Dummy::class, $propertyName, Argument::any())->willReturn( + (new ApiProperty())->withNativeType(Type::string())->withReadable(true)->withWritable(true) + ); + } + + $definitionNameFactory = new DefinitionNameFactory(null); + + $baseSchemaFactory = new BaseSchemaFactory( + resourceMetadataFactory: $resourceMetadataFactory->reveal(), + propertyNameCollectionFactory: $propertyNameCollectionFactory->reveal(), + propertyMetadataFactory: $propertyMetadataFactory->reveal(), + definitionNameFactory: $definitionNameFactory, + ); + + $resourceClassResolver = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolver->isResourceClass(Dummy::class)->willReturn(true); + + $this->schemaFactory = new SchemaFactory( + schemaFactory: $baseSchemaFactory, + propertyMetadataFactory: $propertyMetadataFactory->reveal(), + resourceClassResolver: $resourceClassResolver->reveal(), + resourceMetadataFactory: $resourceMetadataFactory->reveal(), + definitionNameFactory: $definitionNameFactory, + ); + } + + public function testReservedAttributeNamesAreRenamedLikeTheResponse(): void + { + $resultSchema = $this->schemaFactory->buildSchema(Dummy::class); + $rootDefinitionKey = $resultSchema->getRootDefinitionKey(); + $attributes = $resultSchema['definitions'][$rootDefinitionKey]['properties']['data']['properties']['attributes']['properties']; + + // Every reserved name must be documented under the prefixed key the ReservedAttributeNameConverter emits. + $this->assertArrayHasKey('_id', $attributes); + $this->assertArrayHasKey('_type', $attributes); + $this->assertArrayHasKey('_links', $attributes); + $this->assertArrayHasKey('_relationships', $attributes); + $this->assertArrayHasKey('_included', $attributes); + $this->assertArrayHasKey('name', $attributes); + + // The bare reserved names must never leak: the response never emits them. + $this->assertArrayNotHasKey('id', $attributes); + $this->assertArrayNotHasKey('type', $attributes); + $this->assertArrayNotHasKey('links', $attributes); + $this->assertArrayNotHasKey('relationships', $attributes); + $this->assertArrayNotHasKey('included', $attributes); + } +} diff --git a/Tests/Util/ResourceLinkageResolverTest.php b/Tests/Util/ResourceLinkageResolverTest.php new file mode 100644 index 0000000..695d5d9 --- /dev/null +++ b/Tests/Util/ResourceLinkageResolverTest.php @@ -0,0 +1,81 @@ + + * + * 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\Util; + +use ApiPlatform\JsonApi\Tests\Fixtures\Dummy; +use ApiPlatform\JsonApi\Tests\Fixtures\RelatedDummy; +use ApiPlatform\JsonApi\Util\ResourceLinkageResolver; +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ResourceClassResolverInterface; +use Doctrine\Common\Collections\ArrayCollection; +use PHPUnit\Framework\TestCase; +use Prophecy\PhpUnit\ProphecyTrait; +use Symfony\Component\TypeInfo\Type; + +class ResourceLinkageResolverTest extends TestCase +{ + use ProphecyTrait; + + private ResourceLinkageResolver $resourceLinkageResolver; + + protected function setUp(): void + { + $resourceClassResolver = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolver->isResourceClass(RelatedDummy::class)->willReturn(true); + $resourceClassResolver->isResourceClass(Dummy::class)->willReturn(true); + $resourceClassResolver->isResourceClass(\ArrayObject::class)->willReturn(false); + + $this->resourceLinkageResolver = new ResourceLinkageResolver($resourceClassResolver->reveal()); + } + + public function testScalarPropertyIsNotARelationship(): void + { + $property = (new ApiProperty())->withNativeType(Type::string()); + + $this->assertSame([], $this->resourceLinkageResolver->getRelationships($property)); + } + + public function testPropertyWithoutNativeTypeIsNotARelationship(): void + { + $this->assertSame([], $this->resourceLinkageResolver->getRelationships(new ApiProperty())); + } + + public function testObjectThatIsNotAResourceIsNotARelationship(): void + { + $property = (new ApiProperty())->withNativeType(Type::object(\ArrayObject::class)); + + $this->assertSame([], $this->resourceLinkageResolver->getRelationships($property)); + } + + public function testToOneRelationship(): void + { + $property = (new ApiProperty())->withNativeType(Type::object(RelatedDummy::class)); + + $this->assertSame([[RelatedDummy::class, false]], $this->resourceLinkageResolver->getRelationships($property)); + } + + public function testNullableToOneRelationship(): void + { + $property = (new ApiProperty())->withNativeType(Type::nullable(Type::object(RelatedDummy::class))); + + $this->assertSame([[RelatedDummy::class, false]], $this->resourceLinkageResolver->getRelationships($property)); + } + + public function testToManyRelationship(): void + { + $property = (new ApiProperty())->withNativeType(Type::collection(Type::object(ArrayCollection::class), Type::object(RelatedDummy::class))); + + $this->assertSame([[RelatedDummy::class, true]], $this->resourceLinkageResolver->getRelationships($property)); + } +} diff --git a/Util/ResourceLinkageResolver.php b/Util/ResourceLinkageResolver.php new file mode 100644 index 0000000..73cacb9 --- /dev/null +++ b/Util/ResourceLinkageResolver.php @@ -0,0 +1,90 @@ + + * + * 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\Util; + +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; + +/** + * Decides whether a property is a JSON:API relationship and with which related resource(s). + * + * Single source of truth for the attributes/relationships split, shared by the runtime + * {@see \ApiPlatform\JsonApi\Serializer\ItemNormalizer} and the doc-time + * {@see \ApiPlatform\JsonApi\JsonSchema\SchemaFactory} so the generated schema cannot drift + * from the emitted document. + * + * @author Antoine Bluchet + * + * @internal + */ +final class ResourceLinkageResolver +{ + public function __construct(private readonly ResourceClassResolverInterface $resourceClassResolver) + { + } + + /** + * Returns the related resource classes a property points to, in declaration order. + * + * @return list ordered [relatedClass, isCollection] pairs; empty when the property is a plain attribute + */ + 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; + } + + /** @var class-string|null $className */ + $className = null; + $typeIsResourceClass = function (Type $type) use (&$className): bool { + return $type instanceof ObjectType && $this->resourceClassResolver->isResourceClass($className = $type->getClassName()); + }; + + foreach ($type instanceof CompositeTypeInterface ? $type->getTypes() : [$type] as $t) { + if (TypeHelper::getCollectionValueType($t)?->isSatisfiedBy($typeIsResourceClass)) { + $relationships[] = [$className, true]; + } elseif ($t->isSatisfiedBy($typeIsResourceClass)) { + $relationships[] = [$className, false]; + } + } + + return $relationships; + } +} From 92b0380f024914a0ccb884a2866bb8e435219b1c Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 25 Jun 2026 17:18:03 +0200 Subject: [PATCH 25/30] chore: open 5.0 development line Bump dev-main branch alias 4.4.x-dev -> 5.0.x-dev across root and all 21 subpackage composer.json files. Update COMPOSER_ROOT_VERSION to 5.0.x-dev (was stale at 4.3.x-dev). The 4.4 branch was cut from the prior 4.4.x-dev HEAD before this bump to carry the 4.4 maintenance line. --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index ef3ba82..ad9e949 100644 --- a/composer.json +++ b/composer.json @@ -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" From d346a4b2d39244260b9e7ea50de8667fbe138b75 Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 25 Jun 2026 18:15:00 +0200 Subject: [PATCH 26/30] chore: require ^4.4@alpha for inter-package dependencies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #7115 added cross-package calls new in 4.4 — getStateOptionsRepositoryMethod() in api-platform/state and the repositoryMethod constructor argument in api-platform/doctrine-common — that 4.4 provider code invokes. The old ^4.2/^4.3 sibling floors let `composer update --prefer-lowest` pull releases lacking these symbols, so the per-component lowest CI jobs failed with "undefined method" / "unknown named parameter". Floor all api-platform/* inter-package constraints at ^4.4. The @alpha stability flag is required because the subpackages set minimum-stability:beta, under which a plain ^4.4 would not match the 4.4.0-alpha prereleases. Revert to plain ^4.4 once 4.4.0 stable ships. --- composer.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/composer.json b/composer.json index ef3ba82..4b61971 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": "^4.4@alpha", + "api-platform/json-schema": "^4.4@alpha", + "api-platform/metadata": "^4.4@alpha", + "api-platform/serializer": "^4.4@alpha", + "api-platform/state": "^4.4@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" From 28b69c29a69f15a2c8ba08e51c185d0094f3df2c Mon Sep 17 00:00:00 2001 From: soyuka Date: Mon, 29 Jun 2026 13:22:12 +0200 Subject: [PATCH 27/30] chore: bump inter-package constraints to ^5.0@alpha Completes 22ece5199 (open 5.0 dev line): root is 5.0.x-dev but the self-referential api-platform/* constraints were left at ^4.4@alpha, which is unresolvable in the component split-tests (published 4.4 alphas require metadata ^4.4, conflicting with the 5.0 root). --- composer.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/composer.json b/composer.json index e7d490c..c0226c9 100644 --- a/composer.json +++ b/composer.json @@ -22,11 +22,11 @@ ], "require": { "php": ">=8.2", - "api-platform/documentation": "^4.4@alpha", - "api-platform/json-schema": "^4.4@alpha", - "api-platform/metadata": "^4.4@alpha", - "api-platform/serializer": "^4.4@alpha", - "api-platform/state": "^4.4@alpha", + "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" From 61b7cb78c77ace950447955e81030b93df3be34d Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Tue, 30 Jun 2026 17:13:00 +0200 Subject: [PATCH 28/30] feat!: core 5.0 cleanups (getProperties interface, json:api status string) (#8366) --- Serializer/ErrorNormalizer.php | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) 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]]; From 355e54e7f434b350515056b8770ff377afc618eb Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Tue, 30 Jun 2026 19:15:47 +0200 Subject: [PATCH 29/30] feat!: remove legacy PropertyInfo Type system (#8364) --- .../ConstraintViolationListNormalizer.php | 12 ++--------- Util/ResourceLinkageResolver.php | 20 ------------------- 2 files changed, 2 insertions(+), 30 deletions(-) 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/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; } From 45b4196426918442299c88b0e3eccaaef40f0144 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Wed, 1 Jul 2026 09:50:13 +0200 Subject: [PATCH 30/30] feat!: remove deprecated APIs scheduled for 5.0 (#8367) --- Tests/JsonSchema/ReservedAttributeNameSchemaFactoryTest.php | 2 +- Tests/JsonSchema/SchemaFactoryTest.php | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) 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(),