diff --git a/src/JsonApi/Serializer/ItemNormalizer.php b/src/JsonApi/Serializer/ItemNormalizer.php index 046c0ee5310..bad4d6110ad 100644 --- a/src/JsonApi/Serializer/ItemNormalizer.php +++ b/src/JsonApi/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/src/JsonApi/Tests/Serializer/ItemNormalizerTest.php b/src/JsonApi/Tests/Serializer/ItemNormalizerTest.php index a9af4cf4b5d..a11d4d2b8f1 100644 --- a/src/JsonApi/Tests/Serializer/ItemNormalizerTest.php +++ b/src/JsonApi/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); + } } diff --git a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php index 1f2f6689b96..42b625cf8ee 100644 --- a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -176,7 +176,7 @@ public function load(array $configs, ContainerBuilder $container): void $this->registerOAuthConfiguration($container, $config); $this->registerOpenApiConfiguration($container, $config, $loader); $this->registerSwaggerConfiguration($container, $config, $loader); - $this->registerJsonApiConfiguration($formats, $loader, $config); + $this->registerJsonApiConfiguration($container, $formats, $loader, $config); $this->registerJsonLdHydraConfiguration($container, $formats, $loader, $config); $this->registerJsonHalConfiguration($formats, $loader); $this->registerJsonProblemConfiguration($errorFormats, $loader); @@ -676,7 +676,7 @@ private function registerSwaggerConfiguration(ContainerBuilder $container, array $container->setParameter('api_platform.swagger_ui.extra_configuration', $config['openapi']['swagger_ui_extra_configuration'] ?: $config['swagger']['swagger_ui_extra_configuration']); } - private function registerJsonApiConfiguration(array $formats, PhpFileLoader $loader, array $config): void + private function registerJsonApiConfiguration(ContainerBuilder $container, array $formats, PhpFileLoader $loader, array $config): void { if (!isset($formats['jsonapi'])) { return; @@ -688,6 +688,9 @@ private function registerJsonApiConfiguration(array $formats, PhpFileLoader $loa $loader->load('jsonapi.php'); $loader->load('state/jsonapi.php'); + + $container->getDefinition('api_platform.jsonapi.normalizer.item') + ->addArgument($config['jsonapi']['use_iri_as_id']); } private function registerJsonLdHydraConfiguration(ContainerBuilder $container, array $formats, PhpFileLoader $loader, array $config): void diff --git a/src/Symfony/Bundle/DependencyInjection/Configuration.php b/src/Symfony/Bundle/DependencyInjection/Configuration.php index f97df37ad5d..43954615e50 100644 --- a/src/Symfony/Bundle/DependencyInjection/Configuration.php +++ b/src/Symfony/Bundle/DependencyInjection/Configuration.php @@ -99,6 +99,16 @@ public function getConfigTreeBuilder(): TreeBuilder ->end() ->end() ->end() + // TODO 4.4: deprecate use_iri_as_id defaulting to true + ->arrayNode('jsonapi') + ->addDefaultsIfNotSet() + ->children() + ->booleanNode('use_iri_as_id') + ->defaultTrue() + ->info('Set to false to use entity identifiers instead of IRIs as the "id" field in JSON:API responses.') + ->end() + ->end() + ->end() ->arrayNode('eager_loading') ->canBeDisabled() ->addDefaultsIfNotSet() diff --git a/src/Symfony/Bundle/Resources/config/jsonapi.php b/src/Symfony/Bundle/Resources/config/jsonapi.php index c4e7c895bb7..6ad6d49ab4c 100644 --- a/src/Symfony/Bundle/Resources/config/jsonapi.php +++ b/src/Symfony/Bundle/Resources/config/jsonapi.php @@ -73,6 +73,7 @@ service('api_platform.security.resource_access_checker')->ignoreOnInvalid(), service('api_platform.http_cache.tag_collector')->ignoreOnInvalid(), service('api_platform.serializer.operation_resource_resolver'), + service('api_platform.api.identifiers_extractor'), ]) ->tag('serializer.normalizer', ['priority' => -890]); diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonApiDummy.php b/tests/Fixtures/TestBundle/ApiResource/JsonApiDummy.php new file mode 100644 index 00000000000..10f930bb7e7 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonApiDummy.php @@ -0,0 +1,62 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource; + +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Operation; + +#[Get( + uriTemplate: '/jsonapi_dummies/{id}', + formats: ['jsonapi' => ['application/vnd.api+json']], + provider: [self::class, 'provide'], +)] +#[GetCollection( + uriTemplate: '/jsonapi_dummies', + formats: ['jsonapi' => ['application/vnd.api+json']], + provider: [self::class, 'provideCollection'], +)] +class JsonApiDummy +{ + public function __construct( + public int $id = 0, + public string $name = '', + public ?JsonApiRelatedDummy $relatedDummy = null, + public ?JsonApiNotExposedRelation $notExposedRelation = null, + ) { + } + + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): self + { + $id = (int) ($uriVariables['id'] ?? 0); + + return new self( + id: $id, + name: 'Dummy #'.$id, + relatedDummy: new JsonApiRelatedDummy(id: 1, title: 'Related #1'), + notExposedRelation: new JsonApiNotExposedRelation(id: 5, label: 'NotExposed #5'), + ); + } + + /** + * @return list + */ + public static function provideCollection(Operation $operation, array $uriVariables = [], array $context = []): array + { + return [ + new self(id: 1, name: 'Dummy #1', relatedDummy: new JsonApiRelatedDummy(id: 1, title: 'Related #1')), + new self(id: 2, name: 'Dummy #2'), + ]; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonApiNotExposedRelation.php b/tests/Fixtures/TestBundle/ApiResource/JsonApiNotExposedRelation.php new file mode 100644 index 00000000000..c793b07c25d --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonApiNotExposedRelation.php @@ -0,0 +1,60 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource; + +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Link; +use ApiPlatform\Metadata\NotExposed; +use ApiPlatform\Metadata\Operation; + +/** + * A resource with no public item operation — only a NotExposed GET. + * Also exposed as a subresource of JsonApiDummy to verify that links.self + * in entity identifier mode uses the NotExposed IRI, not the subresource URI. + */ +#[NotExposed( + uriTemplate: '/jsonapi_not_exposed_relations/{id}', + formats: ['jsonapi' => ['application/vnd.api+json']], + provider: [self::class, 'provide'], +)] +#[Get( + uriTemplate: '/jsonapi_dummies/{dummyId}/not_exposed_relation', + uriVariables: [ + 'dummyId' => new Link(fromClass: JsonApiDummy::class, fromProperty: 'notExposedRelation'), + ], + formats: ['jsonapi' => ['application/vnd.api+json']], + provider: [self::class, 'provide'], +)] +class JsonApiNotExposedRelation +{ + public function __construct( + public int $id = 0, + public string $label = '', + ) { + } + + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): self + { + if (isset($uriVariables['dummyId'])) { + // Subresource: resolve from parent dummy + $dummy = JsonApiDummy::provide($operation, ['id' => $uriVariables['dummyId']], $context); + + return $dummy->notExposedRelation ?? new self(); + } + + $id = (int) ($uriVariables['id'] ?? 0); + + return new self(id: $id, label: 'NotExposed #'.$id); + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonApiRelatedDummy.php b/tests/Fixtures/TestBundle/ApiResource/JsonApiRelatedDummy.php new file mode 100644 index 00000000000..54c900f08b7 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonApiRelatedDummy.php @@ -0,0 +1,38 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource; + +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Operation; + +#[Get( + uriTemplate: '/jsonapi_related_dummies/{id}', + formats: ['jsonapi' => ['application/vnd.api+json']], + provider: [self::class, 'provide'], +)] +class JsonApiRelatedDummy +{ + public function __construct( + public int $id = 0, + public string $title = '', + ) { + } + + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): self + { + $id = (int) ($uriVariables['id'] ?? 0); + + return new self(id: $id, title: 'Related #'.$id); + } +} diff --git a/tests/Fixtures/app/config/config_jsonapi.yml b/tests/Fixtures/app/config/config_jsonapi.yml new file mode 100644 index 00000000000..16eab860316 --- /dev/null +++ b/tests/Fixtures/app/config/config_jsonapi.yml @@ -0,0 +1,6 @@ +imports: + - { resource: config_test.yml } + +api_platform: + jsonapi: + use_iri_as_id: false diff --git a/tests/Fixtures/app/config/config_jsonapi_mongodb.yml b/tests/Fixtures/app/config/config_jsonapi_mongodb.yml new file mode 100644 index 00000000000..e720a07a5fb --- /dev/null +++ b/tests/Fixtures/app/config/config_jsonapi_mongodb.yml @@ -0,0 +1,6 @@ +imports: + - { resource: config_mongodb.yml } + +api_platform: + jsonapi: + use_iri_as_id: false diff --git a/tests/Fixtures/app/config/routing_jsonapi.yml b/tests/Fixtures/app/config/routing_jsonapi.yml new file mode 100644 index 00000000000..8f00918cd14 --- /dev/null +++ b/tests/Fixtures/app/config/routing_jsonapi.yml @@ -0,0 +1,2 @@ +_main: + resource: routing_test.php diff --git a/tests/Fixtures/app/config/routing_jsonapi_mongodb.yml b/tests/Fixtures/app/config/routing_jsonapi_mongodb.yml new file mode 100644 index 00000000000..691266c01db --- /dev/null +++ b/tests/Fixtures/app/config/routing_jsonapi_mongodb.yml @@ -0,0 +1,2 @@ +_main: + resource: routing_mongodb.php diff --git a/tests/Functional/JsonApiTest.php b/tests/Functional/JsonApiTest.php index 9b97c347a28..227a6b8e232 100644 --- a/tests/Functional/JsonApiTest.php +++ b/tests/Functional/JsonApiTest.php @@ -14,7 +14,10 @@ namespace ApiPlatform\Tests\Functional; use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonApiDummy; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonApiErrorTestResource; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonApiNotExposedRelation; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonApiRelatedDummy; use ApiPlatform\Tests\SetupClassResourcesTrait; class JsonApiTest extends ApiTestCase @@ -29,6 +32,9 @@ public static function getResources(): array { return [ JsonApiErrorTestResource::class, + JsonApiDummy::class, + JsonApiRelatedDummy::class, + JsonApiNotExposedRelation::class, ]; } @@ -50,4 +56,134 @@ public function testError(): void ], ]); } + + public function testGetSingleResourceIdentifierMode(): void + { + $this->bootJsonApiKernel(); + self::createClient()->request('GET', '/jsonapi_dummies/10', [ + 'headers' => ['accept' => 'application/vnd.api+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('content-type', 'application/vnd.api+json; charset=utf-8'); + $this->assertJsonContains([ + 'data' => [ + 'id' => '10', + 'type' => 'JsonApiDummy', + 'links' => [ + 'self' => '/jsonapi_dummies/10', + ], + 'attributes' => [ + 'name' => 'Dummy #10', + ], + ], + ]); + } + + public function testGetCollectionIdentifierMode(): void + { + $this->bootJsonApiKernel(); + self::createClient()->request('GET', '/jsonapi_dummies', [ + 'headers' => ['accept' => 'application/vnd.api+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('content-type', 'application/vnd.api+json; charset=utf-8'); + $this->assertJsonContains([ + 'data' => [ + [ + 'id' => '1', + 'type' => 'JsonApiDummy', + 'links' => [ + 'self' => '/jsonapi_dummies/1', + ], + ], + [ + 'id' => '2', + 'type' => 'JsonApiDummy', + 'links' => [ + 'self' => '/jsonapi_dummies/2', + ], + ], + ], + ]); + } + + public function testRelationWithNotExposedOperationIdentifierMode(): void + { + $this->bootJsonApiKernel(); + self::createClient()->request('GET', '/jsonapi_dummies/10', [ + 'headers' => ['accept' => 'application/vnd.api+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertJsonContains([ + 'data' => [ + 'id' => '10', + 'type' => 'JsonApiDummy', + 'relationships' => [ + 'notExposedRelation' => [ + 'data' => [ + 'id' => '5', + 'type' => 'JsonApiNotExposedRelation', + ], + ], + ], + ], + ]); + } + + public function testSubresourceNotExposedIdentifierMode(): void + { + $this->bootJsonApiKernel(); + self::createClient()->request('GET', '/jsonapi_dummies/10/not_exposed_relation', [ + 'headers' => ['accept' => 'application/vnd.api+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertJsonContains([ + 'data' => [ + 'id' => '5', + 'type' => 'JsonApiNotExposedRelation', + // links.self uses the subresource URI — the only publicly accessible route + 'links' => ['self' => '/jsonapi_dummies/10/not_exposed_relation'], + ], + ]); + } + + public function testGetSingleResourceDefaultIriMode(): void + { + // Default mode (use_iri_as_id: true) — id should be the IRI, no links.self + self::createClient()->request('GET', '/jsonapi_dummies/10', [ + 'headers' => ['accept' => 'application/vnd.api+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertJsonContains([ + 'data' => [ + 'id' => '/jsonapi_dummies/10', + 'type' => 'JsonApiDummy', + ], + ]); + + // Verify no links.self is present on the data object + $json = json_decode(self::getClient()->getResponse()->getContent(), true); + $this->assertArrayNotHasKey('links', $json['data']); + } + + private function bootJsonApiKernel(): void + { + $baseEnv = $_SERVER['APP_ENV'] ?? 'test'; + $jsonApiEnv = 'mongodb' === $baseEnv ? 'jsonapi_mongodb' : 'jsonapi'; + + // AppKernel overrides environment with $_SERVER['APP_ENV'] (behat compat), + // so we must temporarily set it to our target environment. + $_SERVER['APP_ENV'] = $jsonApiEnv; + + try { + self::bootKernel(['environment' => $jsonApiEnv]); + } finally { + $_SERVER['APP_ENV'] = $baseEnv; + } + } } diff --git a/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php b/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php index 10f3a129406..52b3d33f3fa 100644 --- a/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php +++ b/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php @@ -246,6 +246,9 @@ private function runDefaultConfigTests(array $doctrineIntegrationsToLoad = ['orm 'mcp' => [ 'enabled' => true, ], + 'jsonapi' => [ + 'use_iri_as_id' => true, + ], ], $config); }