diff --git a/features/main/crud.feature b/features/main/crud.feature index d38d1870f25..c9ae98e444e 100644 --- a/features/main/crud.feature +++ b/features/main/crud.feature @@ -22,6 +22,8 @@ Feature: Create-Retrieve-Update-Delete Then the response status code should be 201 And the response should be in JSON And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the header "Content-Location" should be equal to "/dummies/1" + And the header "Location" should be equal to "/dummies/1" And the JSON should be equal to: """ { @@ -420,6 +422,7 @@ Feature: Create-Retrieve-Update-Delete 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 header "Content-Location" should be equal to "/dummies/1" And the JSON should be equal to: """ { @@ -457,6 +460,7 @@ Feature: Create-Retrieve-Update-Delete 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 header "Content-Location" should be equal to "/dummies/1" And the JSON should be equal to: """ { diff --git a/features/main/crud_abstract.feature b/features/main/crud_abstract.feature index f0c3d66b3d9..c7c15a31821 100644 --- a/features/main/crud_abstract.feature +++ b/features/main/crud_abstract.feature @@ -16,6 +16,8 @@ Feature: Create-Retrieve-Update-Delete on abstract resource Then the response status code should be 201 And the response should be in JSON And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the header "Content-Location" should be equal to "/concrete_dummies/1" + And the header "Location" should be equal to "/concrete_dummies/1" And the JSON should be equal to: """ { @@ -90,6 +92,7 @@ Feature: Create-Retrieve-Update-Delete on abstract resource 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 header "Content-Location" should be equal to "/concrete_dummies/1" And the JSON should be equal to: """ { diff --git a/features/main/custom_normalized.feature b/features/main/custom_normalized.feature index 852fe7d2ccc..f9d20c12176 100644 --- a/features/main/custom_normalized.feature +++ b/features/main/custom_normalized.feature @@ -16,6 +16,8 @@ Feature: Using custom normalized entity Then the response status code should be 201 And the response should be in JSON And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the header "Content-Location" should be equal to "/custom_normalized_dummies/1" + And the header "Location" should be equal to "/custom_normalized_dummies/1" And the JSON should be equal to: """ { @@ -40,6 +42,8 @@ Feature: Using custom normalized entity Then the response status code should be 201 And the response should be in JSON And the header "Content-Type" should be equal to "application/json; charset=utf-8" + And the header "Content-Location" should be equal to "/related_normalized_dummies/1" + And the header "Location" should be equal to "/related_normalized_dummies/1" And the JSON should be equal to: """ { @@ -68,6 +72,7 @@ Feature: Using custom normalized entity 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/json; charset=utf-8" + And the header "Content-Location" should be equal to "/related_normalized_dummies/1" And the JSON should be equal to: """ { @@ -134,6 +139,7 @@ Feature: Using custom normalized entity 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 header "Content-Location" should be equal to "/custom_normalized_dummies/1" And the JSON should be equal to: """ { diff --git a/features/main/custom_writable_identifier.feature b/features/main/custom_writable_identifier.feature index 98e005c95ed..9e141a563db 100644 --- a/features/main/custom_writable_identifier.feature +++ b/features/main/custom_writable_identifier.feature @@ -16,6 +16,8 @@ Feature: Using custom writable identifier on resource Then the response status code should be 201 And the response should be in JSON And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the header "Content-Location" should be equal to "/custom_writable_identifier_dummies/my_slug" + And the header "Location" should be equal to "/custom_writable_identifier_dummies/my_slug" And the JSON should be equal to: """ { @@ -78,6 +80,7 @@ Feature: Using custom writable identifier on resource 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 header "Content-Location" should be equal to "/custom_writable_identifier_dummies/slug_modified" And the JSON should be equal to: """ { diff --git a/features/main/uuid.feature b/features/main/uuid.feature index d444dad8900..2a599e49cd8 100644 --- a/features/main/uuid.feature +++ b/features/main/uuid.feature @@ -16,6 +16,8 @@ Feature: Using uuid identifier on resource Then the response status code should be 201 And the response should be in JSON And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the header "Content-Location" should be equal to "/uuid_identifier_dummies/41b29566-144b-11e6-a148-3e1d05defe78" + And the header "Location" should be equal to "/uuid_identifier_dummies/41b29566-144b-11e6-a148-3e1d05defe78" Scenario: Get a resource When I send a "GET" request to "/uuid_identifier_dummies/41b29566-144b-11e6-a148-3e1d05defe78" @@ -67,6 +69,7 @@ Feature: Using uuid identifier on resource 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 header "Content-Location" should be equal to "/uuid_identifier_dummies/41b29566-144b-11e6-a148-3e1d05defe78" And the JSON should be equal to: """ { @@ -87,6 +90,8 @@ Feature: Using uuid identifier on resource Then the response status code should be 201 And the response should be in JSON And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the header "Content-Location" should be equal to "/custom_generated_identifiers/foo" + And the header "Location" should be equal to "/custom_generated_identifiers/foo" And the JSON should be equal to: """ { diff --git a/src/Bridge/Symfony/Bundle/Resources/config/api.xml b/src/Bridge/Symfony/Bundle/Resources/config/api.xml index 69fab2d6f40..1a2ec454d35 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/api.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/api.xml @@ -152,6 +152,7 @@ + diff --git a/src/EventListener/RespondListener.php b/src/EventListener/RespondListener.php index 99c9e350d6f..280a330cffe 100644 --- a/src/EventListener/RespondListener.php +++ b/src/EventListener/RespondListener.php @@ -42,15 +42,29 @@ public function onKernelView(GetResponseForControllerResultEvent $event) return; } + $headers = [ + 'Content-Type' => sprintf('%s; charset=utf-8', $request->getMimeType($request->getRequestFormat())), + 'Vary' => 'Accept', + 'X-Content-Type-Options' => 'nosniff', + 'X-Frame-Options' => 'deny', + ]; + + if (\in_array($request->getMethod(), ['POST', 'PUT', 'PATCH'])) { + if ($request->attributes->has('_api_write_item_iri')) { + $headers['Content-Location'] = $request->attributes->get('_api_write_item_iri'); + + if ($request->isMethod('POST')) { + $headers['Location'] = $request->attributes->get('_api_write_item_iri'); + } + } else { + @trigger_error(sprintf('No request attribute from `_api_write_item_iri` key is deprecated since API Platform 2.3 and will not be supported in API Platform 3, an string should always be returned. see deprecated into %s constructor for more details.', WriteListener::class), E_USER_DEPRECATED); + } + } + $event->setResponse(new Response( $controllerResult, self::METHOD_TO_CODE[$request->getMethod()] ?? Response::HTTP_OK, - [ - 'Content-Type' => sprintf('%s; charset=utf-8', $request->getMimeType($request->getRequestFormat())), - 'Vary' => 'Accept', - 'X-Content-Type-Options' => 'nosniff', - 'X-Frame-Options' => 'deny', - ] + $headers )); } } diff --git a/src/EventListener/WriteListener.php b/src/EventListener/WriteListener.php index c2aec1c922e..5ab4fab1bd7 100644 --- a/src/EventListener/WriteListener.php +++ b/src/EventListener/WriteListener.php @@ -13,22 +13,30 @@ namespace ApiPlatform\Core\EventListener; +use ApiPlatform\Core\Api\IriConverterInterface; use ApiPlatform\Core\DataPersister\DataPersisterInterface; use Symfony\Component\HttpKernel\Event\GetResponseForControllerResultEvent; /** * Bridges persistense and the API system. * + * @final * @author Kévin Dunglas * @author Baptiste Meyer */ class WriteListener { private $dataPersister; + private $iriConverter; - public function __construct(DataPersisterInterface $dataPersister) + public function __construct(DataPersisterInterface $dataPersister, IriConverterInterface $iriConverter = null) { $this->dataPersister = $dataPersister; + $this->iriConverter = $iriConverter; + + if (null === $iriConverter) { + @trigger_error(sprintf('Class %s will have a second `IriConverterInterface $iriConverter` argument in version 3.0. Not defining it is deprecated since 2.3.', __CLASS__), E_USER_DEPRECATED); + } } /** @@ -57,6 +65,10 @@ public function onKernelView(GetResponseForControllerResultEvent $event) } $event->setControllerResult($persistResult ?? $controllerResult); + + if (null !== $this->iriConverter) { + $request->attributes->set('_api_write_item_iri', $this->iriConverter->getIriFromItem($controllerResult)); + } break; case 'DELETE': $this->dataPersister->remove($controllerResult); diff --git a/tests/EventListener/RespondListenerTest.php b/tests/EventListener/RespondListenerTest.php index 464fd856215..feae684cfb1 100644 --- a/tests/EventListener/RespondListenerTest.php +++ b/tests/EventListener/RespondListenerTest.php @@ -67,7 +67,7 @@ public function testCreate201Response() { $kernelProphecy = $this->prophesize(HttpKernelInterface::class); - $request = new Request([], [], ['_api_respond' => true]); + $request = new Request([], [], ['_api_respond' => true, '_api_write_item_iri' => '/dummy_entities/1']); $request->setMethod('POST'); $request->setRequestFormat('xml'); @@ -88,6 +88,8 @@ public function testCreate201Response() $this->assertEquals('Accept', $response->headers->get('Vary')); $this->assertEquals('nosniff', $response->headers->get('X-Content-Type-Options')); $this->assertEquals('deny', $response->headers->get('X-Frame-Options')); + $this->assertEquals('/dummy_entities/1', $response->headers->get('Location')); + $this->assertEquals('/dummy_entities/1', $response->headers->get('Content-Location')); } public function testCreate204Response() diff --git a/tests/EventListener/WriteListenerTest.php b/tests/EventListener/WriteListenerTest.php index ce705dcd3a5..dcb16cc80df 100644 --- a/tests/EventListener/WriteListenerTest.php +++ b/tests/EventListener/WriteListenerTest.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Core\Tests\EventListener; +use ApiPlatform\Core\Api\IriConverterInterface; use ApiPlatform\Core\DataPersister\DataPersisterInterface; use ApiPlatform\Core\EventListener\WriteListener; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy; @@ -35,6 +36,9 @@ public function testOnKernelViewWithControllerResultAndPersist() $dataPersisterProphecy->supports($dummy)->willReturn(true)->shouldBeCalled(); $dataPersisterProphecy->persist($dummy)->willReturn($dummy)->shouldBeCalled(); + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $iriConverterProphecy->getIriFromItem($dummy)->willReturn('/dummy/1')->shouldBeCalled(); + $request = new Request(); $request->attributes->set('_api_resource_class', Dummy::class); @@ -48,8 +52,9 @@ public function testOnKernelViewWithControllerResultAndPersist() foreach (['PATCH', 'PUT', 'POST'] as $httpMethod) { $request->setMethod($httpMethod); - (new WriteListener($dataPersisterProphecy->reveal()))->onKernelView($event); + (new WriteListener($dataPersisterProphecy->reveal(), $iriConverterProphecy->reveal()))->onKernelView($event); $this->assertSame($dummy, $event->getControllerResult()); + $this->assertEquals('/dummy/1', $request->attributes->get('_api_write_item_iri')); } } @@ -98,6 +103,9 @@ public function testOnKernelViewWithControllerResultAndPersistWithImmutableResou $dataPersisterProphecy = $this->prophesize(DataPersisterInterface::class); $dataPersisterProphecy->supports($dummy)->willReturn(true)->shouldBeCalled(); + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $iriConverterProphecy->getIriFromItem($dummy)->willReturn('/dummy/1')->shouldBeCalled(); + $dataPersisterProphecy ->persist($dummy) ->willReturn($dummy2) // Persist is not mutating $dummy, but return a brand new technically unrelated object instead @@ -117,9 +125,10 @@ public function testOnKernelViewWithControllerResultAndPersistWithImmutableResou $request->setMethod($httpMethod); - (new WriteListener($dataPersisterProphecy->reveal()))->onKernelView($event); + (new WriteListener($dataPersisterProphecy->reveal(), $iriConverterProphecy->reveal()))->onKernelView($event); $this->assertSame($dummy2, $event->getControllerResult()); + $this->assertEquals('/dummy/1', $request->attributes->get('_api_write_item_iri')); } } @@ -132,6 +141,9 @@ public function testOnKernelViewWithControllerResultAndRemove() $dataPersisterProphecy->supports($dummy)->willReturn(true)->shouldBeCalled(); $dataPersisterProphecy->remove($dummy)->shouldBeCalled(); + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $iriConverterProphecy->getIriFromItem($dummy)->shouldNotBeCalled(); + $request = new Request(); $request->setMethod('DELETE'); $request->attributes->set('_api_resource_class', Dummy::class); @@ -143,7 +155,7 @@ public function testOnKernelViewWithControllerResultAndRemove() $dummy ); - (new WriteListener($dataPersisterProphecy->reveal()))->onKernelView($event); + (new WriteListener($dataPersisterProphecy->reveal(), $iriConverterProphecy->reveal()))->onKernelView($event); } public function testOnKernelViewWithSafeMethod() @@ -156,6 +168,9 @@ public function testOnKernelViewWithSafeMethod() $dataPersisterProphecy->persist($dummy)->shouldNotBeCalled(); $dataPersisterProphecy->remove($dummy)->shouldNotBeCalled(); + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $iriConverterProphecy->getIriFromItem($dummy)->shouldNotBeCalled(); + $request = new Request(); $request->setMethod('HEAD'); $request->attributes->set('_api_resource_class', Dummy::class); @@ -167,7 +182,7 @@ public function testOnKernelViewWithSafeMethod() $dummy ); - (new WriteListener($dataPersisterProphecy->reveal()))->onKernelView($event); + (new WriteListener($dataPersisterProphecy->reveal(), $iriConverterProphecy->reveal()))->onKernelView($event); } public function testOnKernelViewWithNoResourceClass() @@ -180,6 +195,9 @@ public function testOnKernelViewWithNoResourceClass() $dataPersisterProphecy->persist($dummy)->shouldNotBeCalled(); $dataPersisterProphecy->remove($dummy)->shouldNotBeCalled(); + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $iriConverterProphecy->getIriFromItem($dummy)->shouldNotBeCalled(); + $request = new Request(); $request->setMethod('POST'); @@ -190,7 +208,7 @@ public function testOnKernelViewWithNoResourceClass() $dummy ); - (new WriteListener($dataPersisterProphecy->reveal()))->onKernelView($event); + (new WriteListener($dataPersisterProphecy->reveal(), $iriConverterProphecy->reveal()))->onKernelView($event); } public function testOnKernelViewWithNoDataPersisterSupport() @@ -203,6 +221,9 @@ public function testOnKernelViewWithNoDataPersisterSupport() $dataPersisterProphecy->persist($dummy)->shouldNotBeCalled(); $dataPersisterProphecy->remove($dummy)->shouldNotBeCalled(); + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $iriConverterProphecy->getIriFromItem($dummy)->shouldNotBeCalled(); + $request = new Request(); $request->setMethod('POST'); $request->attributes->set('_api_resource_class', 'Dummy'); @@ -214,6 +235,6 @@ public function testOnKernelViewWithNoDataPersisterSupport() $dummy ); - (new WriteListener($dataPersisterProphecy->reveal()))->onKernelView($event); + (new WriteListener($dataPersisterProphecy->reveal(), $iriConverterProphecy->reveal()))->onKernelView($event); } }