diff --git a/features/bootstrap/DoctrineContext.php b/features/bootstrap/DoctrineContext.php index b451fef9745..5fbc3eee41e 100644 --- a/features/bootstrap/DoctrineContext.php +++ b/features/bootstrap/DoctrineContext.php @@ -54,6 +54,7 @@ use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\FourthLevel as FourthLevelDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\Greeting as GreetingDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\InitializeInput as InitializeInputDocument; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\IriOnlyDummy as IriOnlyDummyDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\MaxDepthDummy as MaxDepthDummyDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\NetworkPathDummy as NetworkPathDummyDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\NetworkPathRelationDummy as NetworkPathRelationDummyDocument; @@ -120,6 +121,7 @@ use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Greeting; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\InitializeInput; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\InternalUser; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\IriOnlyDummy; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\MaxDepthDummy; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\NetworkPathDummy; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\NetworkPathRelationDummy; @@ -1585,6 +1587,20 @@ public function thereAreDummyMercureObjects(int $nb) $this->manager->flush(); } + /** + * @Given there are :nb iriOnlyDummies + */ + public function thereAreIriOnlyDummies(int $nb) + { + for ($i = 1; $i <= $nb; ++$i) { + $iriOnlyDummy = $this->buildIriOnlyDummy(); + $iriOnlyDummy->setFoo('bar'.$nb); + $this->manager->persist($iriOnlyDummy); + } + + $this->manager->flush(); + } + /** * @Given there are :nb absoluteUrlDummy objects with a related absoluteUrlRelationDummy */ @@ -1867,6 +1883,14 @@ private function buildGreeting() return $this->isOrm() ? new Greeting() : new GreetingDocument(); } + /** + * @return IriOnlyDummy|IriOnlyDummyDocument + */ + private function buildIriOnlyDummy() + { + return $this->isOrm() ? new IriOnlyDummy() : new IriOnlyDummyDocument(); + } + /** * @return MaxDepthDummy|MaxDepthDummyDocument */ diff --git a/features/jsonld/iri_only.feature b/features/jsonld/iri_only.feature new file mode 100644 index 00000000000..2e2e3ad5915 --- /dev/null +++ b/features/jsonld/iri_only.feature @@ -0,0 +1,50 @@ +Feature: JSON-LD using iri_only parameter + In order to improve Vulcain support + As a Vulcain user and as a developer + I should be able to only get an IRI list when I ask a resource. + + Scenario: Retrieve Dummy's resource context with iri_only + When I send a "GET" request to "/contexts/IriOnlyDummy" + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the JSON should be equal to: + """ + { + "@context": { + "@vocab": "http://example.com/docs.jsonld#", + "hydra": "http://www.w3.org/ns/hydra/core#", + "hydra:member": { + "@type": "@id" + } + } + } + """ + + @createSchema + Scenario: Retrieve Dummies with iri_only and jsonld_embed_context + Given there are 3 iriOnlyDummies + When I send a "GET" request to "/iri_only_dummies" + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the JSON should be equal to: + """ + { + "@context": { + "@vocab": "http://example.com/docs.jsonld#", + "hydra": "http://www.w3.org/ns/hydra/core#", + "hydra:member": { + "@type": "@id" + } + }, + "@id": "/iri_only_dummies", + "@type": "hydra:Collection", + "hydra:member": [ + "/iri_only_dummies/1", + "/iri_only_dummies/2", + "/iri_only_dummies/3" + ], + "hydra:totalItems": 3 + } + """ diff --git a/src/Hydra/Serializer/CollectionNormalizer.php b/src/Hydra/Serializer/CollectionNormalizer.php index 60d61499a67..dcfc7ab7c21 100644 --- a/src/Hydra/Serializer/CollectionNormalizer.php +++ b/src/Hydra/Serializer/CollectionNormalizer.php @@ -86,11 +86,10 @@ public function normalize($object, $format = null, array $context = []) } $data['@type'] = 'hydra:Collection'; - $data['hydra:member'] = []; $iriOnly = $context[self::IRI_ONLY] ?? $this->defaultContext[self::IRI_ONLY]; foreach ($object as $obj) { - $data['hydra:member'][] = $iriOnly ? ['@id' => $this->iriConverter->getIriFromItem($obj)] : $this->normalizer->normalize($obj, $format, $context); + $data['hydra:member'][] = $iriOnly ? $this->iriConverter->getIriFromItem($obj) : $this->normalizer->normalize($obj, $format, $context); } if ($object instanceof PaginatorInterface) { diff --git a/src/JsonLd/ContextBuilder.php b/src/JsonLd/ContextBuilder.php index b0d544f94df..6d5e715abd8 100644 --- a/src/JsonLd/ContextBuilder.php +++ b/src/JsonLd/ContextBuilder.php @@ -95,6 +95,13 @@ public function getResourceContext(string $resourceClass, int $referenceType = U return []; } + if ($resourceMetadata->getAttribute('normalization_context')['iri_only'] ?? false) { + $context = $this->getBaseContext($referenceType); + $context['hydra:member']['@type'] = '@id'; + + return $context; + } + return $this->getResourceContextWithShortname($resourceClass, $referenceType, $shortName); } diff --git a/src/Serializer/SerializerContextBuilder.php b/src/Serializer/SerializerContextBuilder.php index 6cab2e7265f..fc93897d72f 100644 --- a/src/Serializer/SerializerContextBuilder.php +++ b/src/Serializer/SerializerContextBuilder.php @@ -72,6 +72,7 @@ public function createFromRequest(Request $request, bool $normalization, array $ } $context['resource_class'] = $attributes['resource_class']; + $context['iri_only'] = $resourceMetadata->getAttribute('normalization_context')['iri_only'] ?? false; $context['input'] = $resourceMetadata->getTypedOperationAttribute($operationType, $attributes[$operationKey], 'input', null, true); $context['output'] = $resourceMetadata->getTypedOperationAttribute($operationType, $attributes[$operationKey], 'output', null, true); $context['request_uri'] = $request->getRequestUri(); diff --git a/tests/Fixtures/TestBundle/Document/IriOnlyDummy.php b/tests/Fixtures/TestBundle/Document/IriOnlyDummy.php new file mode 100644 index 00000000000..b278a71fd20 --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/IriOnlyDummy.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\Core\Tests\Fixtures\TestBundle\Document; + +use ApiPlatform\Core\Annotation\ApiResource; +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; + +/** + * Dummy with iri_only. + * + * @author Pierre Thibaudeau + * + * @ApiResource( + * normalizationContext={ + * "iri_only"=true, + * "jsonld_embed_context"=true + * } + * ) + * @ODM\Document + */ +class IriOnlyDummy +{ + /** + * @var int The id + * + * @ODM\Id(strategy="INCREMENT", type="integer") + */ + private $id; + + /** + * @var string + * + * @ODM\Field(type="string") + */ + private $foo; + + public function getId(): int + { + return $this->id; + } + + public function getFoo(): string + { + return $this->foo; + } + + public function setFoo(string $foo): void + { + $this->foo = $foo; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/IriOnlyDummy.php b/tests/Fixtures/TestBundle/Entity/IriOnlyDummy.php new file mode 100644 index 00000000000..ffb6cbd8d6b --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/IriOnlyDummy.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\Core\Tests\Fixtures\TestBundle\Entity; + +use ApiPlatform\Core\Annotation\ApiResource; +use Doctrine\ORM\Mapping as ORM; + +/** + * Dummy with iri_only. + * + * @author Pierre Thibaudeau + * + * @ApiResource( + * normalizationContext={ + * "iri_only"=true, + * "jsonld_embed_context"=true + * } + * ) + * @ORM\Entity + */ +class IriOnlyDummy +{ + /** + * @var int The id + * + * @ORM\Column(type="integer") + * @ORM\Id + * @ORM\GeneratedValue(strategy="AUTO") + */ + private $id; + + /** + * @var string + * + * @ORM\Column(type="string") + */ + private $foo; + + public function getId(): int + { + return $this->id; + } + + public function getFoo(): string + { + return $this->foo; + } + + public function setFoo(string $foo): void + { + $this->foo = $foo; + } +} diff --git a/tests/Hydra/Serializer/CollectionNormalizerTest.php b/tests/Hydra/Serializer/CollectionNormalizerTest.php index 72a74cd17ce..352d5e60cb3 100644 --- a/tests/Hydra/Serializer/CollectionNormalizerTest.php +++ b/tests/Hydra/Serializer/CollectionNormalizerTest.php @@ -360,12 +360,12 @@ public function testNormalizeIriOnlyResourceCollection(): void $delegateNormalizerProphecy = $this->prophesize(NormalizerInterface::class); - $normalizer = new CollectionNormalizer($contextBuilderProphecy->reveal(), $resourceClassResolverProphecy->reveal(), $iriConverterProphecy->reveal(), [CollectionNormalizer::IRI_ONLY => true]); + $normalizer = new CollectionNormalizer($contextBuilderProphecy->reveal(), $resourceClassResolverProphecy->reveal(), $iriConverterProphecy->reveal()); $normalizer->setNormalizer($delegateNormalizerProphecy->reveal()); $actual = $normalizer->normalize($data, CollectionNormalizer::FORMAT, [ 'collection_operation_name' => 'get', - 'operation_type' => OperationType::COLLECTION, + 'iri_only' => true, 'resource_class' => Foo::class, ]); @@ -374,8 +374,67 @@ public function testNormalizeIriOnlyResourceCollection(): void '@id' => '/foos', '@type' => 'hydra:Collection', 'hydra:member' => [ - ['@id' => '/foos/1'], - ['@id' => '/foos/3'], + '/foos/1', + '/foos/3', + ], + 'hydra:totalItems' => 2, + ], $actual); + } + + public function testNormalizeIriOnlyEmbedContextResourceCollection(): void + { + $fooOne = new Foo(); + $fooOne->id = 1; + $fooOne->bar = 'baz'; + + $fooThree = new Foo(); + $fooThree->id = 3; + $fooThree->bar = 'bzz'; + + $data = [$fooOne, $fooThree]; + + $contextBuilderProphecy = $this->prophesize(ContextBuilderInterface::class); + $contextBuilderProphecy->getResourceContext(Foo::class)->willReturn([ + '@vocab' => 'http://localhost:8080/docs.jsonld#', + 'hydra' => 'http://www.w3.org/ns/hydra/core#', + 'hydra:member' => [ + '@type' => '@id', + ], + ]); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass($data, Foo::class)->willReturn(Foo::class); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $iriConverterProphecy->getIriFromResourceClass(Foo::class)->willReturn('/foos'); + $iriConverterProphecy->getIriFromItem($fooOne)->willReturn('/foos/1'); + $iriConverterProphecy->getIriFromItem($fooThree)->willReturn('/foos/3'); + + $delegateNormalizerProphecy = $this->prophesize(NormalizerInterface::class); + + $normalizer = new CollectionNormalizer($contextBuilderProphecy->reveal(), $resourceClassResolverProphecy->reveal(), $iriConverterProphecy->reveal()); + $normalizer->setNormalizer($delegateNormalizerProphecy->reveal()); + + $actual = $normalizer->normalize($data, CollectionNormalizer::FORMAT, [ + 'collection_operation_name' => 'get', + 'iri_only' => true, + 'jsonld_embed_context' => true, + 'resource_class' => Foo::class, + ]); + + $this->assertSame([ + '@context' => [ + '@vocab' => 'http://localhost:8080/docs.jsonld#', + 'hydra' => 'http://www.w3.org/ns/hydra/core#', + 'hydra:member' => [ + '@type' => '@id', + ], + ], + '@id' => '/foos', + '@type' => 'hydra:Collection', + 'hydra:member' => [ + '/foos/1', + '/foos/3', ], 'hydra:totalItems' => 2, ], $actual); diff --git a/tests/JsonLd/ContextBuilderTest.php b/tests/JsonLd/ContextBuilderTest.php index 4176bd5ee46..54103706cb8 100644 --- a/tests/JsonLd/ContextBuilderTest.php +++ b/tests/JsonLd/ContextBuilderTest.php @@ -70,6 +70,25 @@ public function testResourceContext() $this->assertEquals($expected, $contextBuilder->getResourceContext($this->entityClass)); } + public function testIriOnlyResourceContext() + { + $this->resourceMetadataFactoryProphecy->create($this->entityClass)->willReturn(new ResourceMetadata('DummyEntity', null, null, null, null, ['normalization_context' => ['iri_only' => true]])); + $this->propertyNameCollectionFactoryProphecy->create($this->entityClass)->willReturn(new PropertyNameCollection(['dummyPropertyA'])); + $this->propertyMetadataFactoryProphecy->create($this->entityClass, 'dummyPropertyA')->willReturn(new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING), 'Dummy property A', true, true, true, true, false, false)); + + $contextBuilder = new ContextBuilder($this->resourceNameCollectionFactoryProphecy->reveal(), $this->resourceMetadataFactoryProphecy->reveal(), $this->propertyNameCollectionFactoryProphecy->reveal(), $this->propertyMetadataFactoryProphecy->reveal(), $this->urlGeneratorProphecy->reveal()); + + $expected = [ + '@vocab' => '#', + 'hydra' => 'http://www.w3.org/ns/hydra/core#', + 'hydra:member' => [ + '@type' => '@id', + ], + ]; + + $this->assertEquals($expected, $contextBuilder->getResourceContext($this->entityClass)); + } + public function testResourceContextWithJsonldContext() { $this->resourceMetadataFactoryProphecy->create($this->entityClass)->willReturn(new ResourceMetadata('DummyEntity')); diff --git a/tests/Serializer/SerializerContextBuilderTest.php b/tests/Serializer/SerializerContextBuilderTest.php index 9c963e77233..a022a53454c 100644 --- a/tests/Serializer/SerializerContextBuilderTest.php +++ b/tests/Serializer/SerializerContextBuilderTest.php @@ -58,32 +58,32 @@ public function testCreateFromRequest() { $request = Request::create('/foos/1'); $request->attributes->replace(['_api_resource_class' => 'Foo', '_api_item_operation_name' => 'get', '_api_format' => 'xml', '_api_mime_type' => 'text/xml']); - $expected = ['foo' => 'bar', 'item_operation_name' => 'get', 'resource_class' => 'Foo', 'request_uri' => '/foos/1', 'operation_type' => 'item', 'uri' => 'http://localhost/foos/1', 'output' => null, 'input' => null]; + $expected = ['foo' => 'bar', 'item_operation_name' => 'get', 'resource_class' => 'Foo', 'request_uri' => '/foos/1', 'operation_type' => 'item', 'uri' => 'http://localhost/foos/1', 'output' => null, 'input' => null, 'iri_only' => false]; $this->assertEquals($expected, $this->builder->createFromRequest($request, true)); $request = Request::create('/foos'); $request->attributes->replace(['_api_resource_class' => 'Foo', '_api_collection_operation_name' => 'pot', '_api_format' => 'xml', '_api_mime_type' => 'text/xml']); - $expected = ['foo' => 'bar', 'collection_operation_name' => 'pot', 'resource_class' => 'Foo', 'request_uri' => '/foos', 'operation_type' => 'collection', 'uri' => 'http://localhost/foos', 'output' => null, 'input' => null]; + $expected = ['foo' => 'bar', 'collection_operation_name' => 'pot', 'resource_class' => 'Foo', 'request_uri' => '/foos', 'operation_type' => 'collection', 'uri' => 'http://localhost/foos', 'output' => null, 'input' => null, 'iri_only' => false]; $this->assertEquals($expected, $this->builder->createFromRequest($request, true)); $request = Request::create('/foos/1'); $request->attributes->replace(['_api_resource_class' => 'Foo', '_api_item_operation_name' => 'get', '_api_format' => 'xml', '_api_mime_type' => 'text/xml']); - $expected = ['bar' => 'baz', 'item_operation_name' => 'get', 'resource_class' => 'Foo', 'request_uri' => '/foos/1', 'api_allow_update' => false, 'operation_type' => 'item', 'uri' => 'http://localhost/foos/1', 'output' => null, 'input' => null]; + $expected = ['bar' => 'baz', 'item_operation_name' => 'get', 'resource_class' => 'Foo', 'request_uri' => '/foos/1', 'api_allow_update' => false, 'operation_type' => 'item', 'uri' => 'http://localhost/foos/1', 'output' => null, 'input' => null, 'iri_only' => false]; $this->assertEquals($expected, $this->builder->createFromRequest($request, false)); $request = Request::create('/foos', 'POST'); $request->attributes->replace(['_api_resource_class' => 'Foo', '_api_collection_operation_name' => 'post', '_api_format' => 'xml', '_api_mime_type' => 'text/xml']); - $expected = ['bar' => 'baz', 'collection_operation_name' => 'post', 'resource_class' => 'Foo', 'request_uri' => '/foos', 'api_allow_update' => false, 'operation_type' => 'collection', 'uri' => 'http://localhost/foos', 'output' => null, 'input' => null]; + $expected = ['bar' => 'baz', 'collection_operation_name' => 'post', 'resource_class' => 'Foo', 'request_uri' => '/foos', 'api_allow_update' => false, 'operation_type' => 'collection', 'uri' => 'http://localhost/foos', 'output' => null, 'input' => null, 'iri_only' => false]; $this->assertEquals($expected, $this->builder->createFromRequest($request, false)); $request = Request::create('/foos', 'PUT'); $request->attributes->replace(['_api_resource_class' => 'Foo', '_api_collection_operation_name' => 'put', '_api_format' => 'xml', '_api_mime_type' => 'text/xml']); - $expected = ['bar' => 'baz', 'collection_operation_name' => 'put', 'resource_class' => 'Foo', 'request_uri' => '/foos', 'api_allow_update' => true, 'operation_type' => 'collection', 'uri' => 'http://localhost/foos', 'output' => null, 'input' => null]; + $expected = ['bar' => 'baz', 'collection_operation_name' => 'put', 'resource_class' => 'Foo', 'request_uri' => '/foos', 'api_allow_update' => true, 'operation_type' => 'collection', 'uri' => 'http://localhost/foos', 'output' => null, 'input' => null, 'iri_only' => false]; $this->assertEquals($expected, $this->builder->createFromRequest($request, false)); $request = Request::create('/bars/1/foos'); $request->attributes->replace(['_api_resource_class' => 'Foo', '_api_subresource_operation_name' => 'get', '_api_format' => 'xml', '_api_mime_type' => 'text/xml']); - $expected = ['bar' => 'baz', 'subresource_operation_name' => 'get', 'resource_class' => 'Foo', 'request_uri' => '/bars/1/foos', 'operation_type' => 'subresource', 'api_allow_update' => false, 'uri' => 'http://localhost/bars/1/foos', 'output' => null, 'input' => null]; + $expected = ['bar' => 'baz', 'subresource_operation_name' => 'get', 'resource_class' => 'Foo', 'request_uri' => '/bars/1/foos', 'operation_type' => 'subresource', 'api_allow_update' => false, 'uri' => 'http://localhost/bars/1/foos', 'output' => null, 'input' => null, 'iri_only' => false]; $this->assertEquals($expected, $this->builder->createFromRequest($request, false)); } @@ -96,7 +96,7 @@ public function testThrowExceptionOnInvalidRequest() public function testReuseExistingAttributes() { - $expected = ['bar' => 'baz', 'item_operation_name' => 'get', 'resource_class' => 'Foo', 'request_uri' => '/foos/1', 'api_allow_update' => false, 'operation_type' => 'item', 'uri' => 'http://localhost/foos/1', 'output' => null, 'input' => null]; + $expected = ['bar' => 'baz', 'item_operation_name' => 'get', 'resource_class' => 'Foo', 'request_uri' => '/foos/1', 'api_allow_update' => false, 'operation_type' => 'item', 'uri' => 'http://localhost/foos/1', 'output' => null, 'input' => null, 'iri_only' => false]; $this->assertEquals($expected, $this->builder->createFromRequest(Request::create('/foos/1'), false, ['resource_class' => 'Foo', 'item_operation_name' => 'get'])); } }