From 28c4ab197cb338037d2841bb5ff7b1d55962b9dc Mon Sep 17 00:00:00 2001 From: LaurentHuzard <laurent@les-tilleuls.coop> Date: Tue, 14 Jan 2025 15:37:20 +0100 Subject: [PATCH 1/9] feat(state): add headers to comply with LDP specification To ensure compliance with the LDP specification (https://www.w3.org/TR/ldp/): - Added the "Accept-Post" header with the value "text/turtle, application/ld+json". - Added the "Allow" header with values based on the allowed operations on the queried resources. --- features/main/ldp_resources.feature | 11 ++ src/State/Processor/RespondProcessor.php | 22 +++ .../Tests/Processor/RespondProcessorTest.php | 165 ++++++++++++++++++ .../Resources/config/state/processor.xml | 1 + .../DummyGetPostDeleteOperation.php | 28 +++ .../Document/DummyGetPostDeleteOperation.php | 52 ++++++ .../Entity/DummyGetPostDeleteOperation.php | 57 ++++++ 7 files changed, 336 insertions(+) create mode 100644 features/main/ldp_resources.feature create mode 100644 src/State/Tests/Processor/RespondProcessorTest.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/DummyGetPostDeleteOperation.php create mode 100644 tests/Fixtures/TestBundle/Document/DummyGetPostDeleteOperation.php create mode 100644 tests/Fixtures/TestBundle/Entity/DummyGetPostDeleteOperation.php diff --git a/features/main/ldp_resources.feature b/features/main/ldp_resources.feature new file mode 100644 index 00000000000..3ea65ea8eeb --- /dev/null +++ b/features/main/ldp_resources.feature @@ -0,0 +1,11 @@ +Feature: LDP Resources + In order to use an API compliant with the LDP specification (https://www.w3.org/TR/ldp/) + As a client software developer + I must have some specific headers returned by the API + + @createSchema + Scenario: Test Accept-Post and Allow headers for a LDP resource + When I add "Content-Type" header equal to "application/ld+json" + And I send a "GET" request to "/dummy_get_post_delete_operations" + Then the header "Accept-Post" should be equal to "text/turtle, application/ld+json" + And the header "Allow" should be equal to "OPTIONS, HEAD, GET, POST, DELETE" diff --git a/src/State/Processor/RespondProcessor.php b/src/State/Processor/RespondProcessor.php index 58941e437c3..1c910bf2ee0 100644 --- a/src/State/Processor/RespondProcessor.php +++ b/src/State/Processor/RespondProcessor.php @@ -22,6 +22,7 @@ use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Operation\Factory\OperationMetadataFactoryInterface; use ApiPlatform\Metadata\Put; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\ResourceClassResolverInterface; use ApiPlatform\Metadata\UrlGeneratorInterface; use ApiPlatform\Metadata\Util\ClassInfoTrait; @@ -45,10 +46,13 @@ final class RespondProcessor implements ProcessorInterface 'DELETE' => Response::HTTP_NO_CONTENT, ]; + private const DEFAULT_ALLOWED_METHOD = ['OPTIONS', 'HEAD']; + public function __construct( private ?IriConverterInterface $iriConverter = null, private readonly ?ResourceClassResolverInterface $resourceClassResolver = null, private readonly ?OperationMetadataFactoryInterface $operationMetadataFactory = null, + private readonly ?ResourceMetadataCollectionFactoryInterface $resourceCollectionMetadataFactory = null, ) { } @@ -88,6 +92,9 @@ public function process(mixed $data, Operation $operation, array $uriVariables = $headers['Accept-Patch'] = $acceptPatch; } + $headers['Accept-Post'] = 'text/turtle, application/ld+json'; + $headers['Allow'] = $this->getAllowedMethods($context['resource_class'] ?? null); + $method = $request->getMethod(); $originalData = $context['original_data'] ?? null; @@ -150,4 +157,19 @@ public function process(mixed $data, Operation $operation, array $uriVariables = $headers ); } + + private function getAllowedMethods(?string $resourceClass): string + { + $allowedMethods = self::DEFAULT_ALLOWED_METHOD; + if (null !== $resourceClass && $this->resourceClassResolver->isResourceClass($resourceClass)) { + $resourceMetadataCollection = $this->resourceCollectionMetadataFactory->create($resourceClass); + foreach ($resourceMetadataCollection as $resource) { + foreach ($resource->getOperations() as $operation) { + $allowedMethods[] = $operation->getMethod(); + } + } + } + + return implode(', ', array_unique($allowedMethods)); + } } diff --git a/src/State/Tests/Processor/RespondProcessorTest.php b/src/State/Tests/Processor/RespondProcessorTest.php new file mode 100644 index 00000000000..ecda76f95ff --- /dev/null +++ b/src/State/Tests/Processor/RespondProcessorTest.php @@ -0,0 +1,165 @@ +<?php + +/* + * This file is part of the API Platform project. + * + * (c) Kévin Dunglas <dunglas@gmail.com> + * + * 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\State\Tests\Processor; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Delete; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\HttpOperation; +use ApiPlatform\Metadata\Patch; +use ApiPlatform\Metadata\Post; +use ApiPlatform\Metadata\Put; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; +use ApiPlatform\Metadata\ResourceClassResolverInterface; +use ApiPlatform\State\Processor\RespondProcessor; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; + +class RespondProcessorTest extends TestCase +{ + private ResourceMetadataCollectionFactoryInterface&MockObject $resourceMetadataCollectionFactory; + private RespondProcessor $processor; + + protected function setUp(): void + { + $resourceClassResolver = $this->createMock(ResourceClassResolverInterface::class); + $resourceClassResolver + ->method('isResourceClass') + ->willReturn(true); + + $this->resourceMetadataCollectionFactory = $this->createMock(ResourceMetadataCollectionFactoryInterface::class); + + $this->processor = new RespondProcessor( + null, + $resourceClassResolver, + null, + $this->resourceMetadataCollectionFactory + ); + } + + public function testHeadersAcceptPostIsSetCorrectly(): void + { + $this->resourceMetadataCollectionFactory + ->method('create') + ->willReturn(new ResourceMetadataCollection('DummyResourceClass')); + + $operation = new HttpOperation('GET'); + $context = [ + 'resource_class' => 'SomeResourceClass', + 'request' => $this->createGetRequest(), + ]; + + /** @var Response $response */ + $response = $this->processor->process(null, $operation, [], $context); + + $this->assertSame('text/turtle, application/ld+json', $response->headers->get('Accept-Post')); + } + + public function testHeaderAllowHasHeadOptionsByDefault(): void + { + $this->resourceMetadataCollectionFactory + ->method('create') + ->willReturn(new ResourceMetadataCollection('DummyResourceClass')); + + $operation = new HttpOperation('GET'); + $context = [ + 'resource_class' => 'SomeResourceClass', + 'request' => $this->createGetRequest(), + ]; + + /** @var Response $response */ + $response = $this->processor->process(null, $operation, [], $context); + + $this->assertSame('OPTIONS, HEAD', $response->headers->get('Allow')); + } + + public function testHeaderAllowReflectsResourceAllowedMethods(): void + { + $this->resourceMetadataCollectionFactory + ->method('create') + ->willReturn( + new ResourceMetadataCollection('DummyResource', [ + new ApiResource(operations: [ + 'get' => new Get(name: 'get'), + 'post' => new Post(name: 'post'), + 'delete' => new Delete(name: 'delete'), + ]), + ]) + ); + + $operation = new HttpOperation('GET'); + $context = [ + 'resource_class' => 'SomeResourceClass', + 'request' => $this->createGetRequest(), + ]; + + /** @var Response $response */ + $response = $this->processor->process(null, $operation, [], $context); + + $allowHeader = $response->headers->get('Allow'); + $this->assertStringContainsString('OPTIONS', $allowHeader); + $this->assertStringContainsString('HEAD', $allowHeader); + $this->assertStringContainsString('GET', $allowHeader); + $this->assertStringContainsString('POST', $allowHeader); + $this->assertStringContainsString('DELETE', $allowHeader); + $this->assertStringNotContainsString('PATCH', $allowHeader); + $this->assertStringNotContainsString('PUT', $allowHeader); + } + + public function testHeaderAllowReflectsAllowedResourcesGetPutPatch(): void + { + $this->resourceMetadataCollectionFactory + ->method('create') + ->willReturn( + new ResourceMetadataCollection('DummyResource', [ + new ApiResource(operations: [ + 'get' => new Get(name: 'get'), + 'patch' => new Patch(name: 'patch'), + 'put' => new Put(name: 'put'), + ]), + ]) + ); + + $operation = new HttpOperation('GET'); + $context = [ + 'resource_class' => 'SomeResourceClass', + 'request' => $this->createGetRequest(), + ]; + + /** @var Response $response */ + $response = $this->processor->process(null, $operation, [], $context); + + $allowHeader = $response->headers->get('Allow'); + $this->assertStringContainsString('OPTIONS', $allowHeader); + $this->assertStringContainsString('HEAD', $allowHeader); + $this->assertStringContainsString('GET', $allowHeader); + $this->assertStringContainsString('PATCH', $allowHeader); + $this->assertStringContainsString('PUT', $allowHeader); + $this->assertStringNotContainsString('POST', $allowHeader); + $this->assertStringNotContainsString('DELETE', $allowHeader); + } + + private function createGetRequest(): Request + { + $request = new Request(); + $request->setMethod('GET'); + $request->setRequestFormat('json'); + $request->headers->set('Accept', 'application/ld+json'); + + return $request; + } +} diff --git a/src/Symfony/Bundle/Resources/config/state/processor.xml b/src/Symfony/Bundle/Resources/config/state/processor.xml index 627d3742957..e5dd2792e5f 100644 --- a/src/Symfony/Bundle/Resources/config/state/processor.xml +++ b/src/Symfony/Bundle/Resources/config/state/processor.xml @@ -21,6 +21,7 @@ <argument type="service" id="api_platform.iri_converter" /> <argument type="service" id="api_platform.resource_class_resolver" /> <argument type="service" id="api_platform.metadata.operation.metadata_factory" /> + <argument type="service" id="api_platform.metadata.resource.metadata_collection_factory" /> </service> <service id="api_platform.state_processor.add_link_header" class="ApiPlatform\State\Processor\AddLinkHeaderProcessor" decorates="api_platform.state_processor.respond"> diff --git a/tests/Fixtures/TestBundle/ApiResource/DummyGetPostDeleteOperation.php b/tests/Fixtures/TestBundle/ApiResource/DummyGetPostDeleteOperation.php new file mode 100644 index 00000000000..954bcb29217 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/DummyGetPostDeleteOperation.php @@ -0,0 +1,28 @@ +<?php + +/* + * This file is part of the API Platform project. + * + * (c) Kévin Dunglas <dunglas@gmail.com> + * + * 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\ApiResource; +use ApiPlatform\Metadata\Delete; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Post; + +#[ApiResource(operations: [new Get(), new GetCollection(), new Post(), new Delete()])] +class DummyGetPostDeleteOperation +{ + public ?int $id; + + public ?string $name = null; +} diff --git a/tests/Fixtures/TestBundle/Document/DummyGetPostDeleteOperation.php b/tests/Fixtures/TestBundle/Document/DummyGetPostDeleteOperation.php new file mode 100644 index 00000000000..5ac27a1c728 --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/DummyGetPostDeleteOperation.php @@ -0,0 +1,52 @@ +<?php + +/* + * This file is part of the API Platform project. + * + * (c) Kévin Dunglas <dunglas@gmail.com> + * + * 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\Document; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Delete; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Post; +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; + +#[ApiResource(operations: [new Get(), new GetCollection(), new Post(), new Delete()])] +#[ODM\Document] +class DummyGetPostDeleteOperation +{ + #[ODM\Id(strategy: 'INCREMENT', type: 'int')] + private ?int $id; + + #[ODM\Field(type: 'string')] + private ?string $name = null; + + public function getId(): ?int + { + return $this->id; + } + + public function setId(?int $id): void + { + $this->id = $id; + } + + public function getName(): string + { + return $this->name; + } + + public function setName(string $name): void + { + $this->name = $name; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/DummyGetPostDeleteOperation.php b/tests/Fixtures/TestBundle/Entity/DummyGetPostDeleteOperation.php new file mode 100644 index 00000000000..bf86bebaacd --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/DummyGetPostDeleteOperation.php @@ -0,0 +1,57 @@ +<?php + +/* + * This file is part of the API Platform project. + * + * (c) Kévin Dunglas <dunglas@gmail.com> + * + * 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\Entity; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Delete; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Post; +use Doctrine\ORM\Mapping as ORM; + +#[ApiResource(operations: [new Get(), new GetCollection(), new Post(), new Delete()])] +#[ORM\Entity] +class DummyGetPostDeleteOperation +{ + /** + * @var int|null The id + */ + #[ORM\Column(type: 'integer')] + #[ORM\Id] + #[ORM\GeneratedValue(strategy: 'AUTO')] + private ?int $id = null; + + #[ORM\Column] + private string $name; + + public function getId(): ?int + { + return $this->id; + } + + public function setId(?int $id): void + { + $this->id = $id; + } + + public function getName(): string + { + return $this->name; + } + + public function setName(string $name): void + { + $this->name = $name; + } +} From eeaf80f32be84a3aeec5a15c66150e3dce193682 Mon Sep 17 00:00:00 2001 From: LaurentHuzard <laurent@les-tilleuls.coop> Date: Tue, 14 Jan 2025 16:22:56 +0100 Subject: [PATCH 2/9] fix: handle case injected services are null --- src/State/Processor/RespondProcessor.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/State/Processor/RespondProcessor.php b/src/State/Processor/RespondProcessor.php index 1c910bf2ee0..4d7e10f3611 100644 --- a/src/State/Processor/RespondProcessor.php +++ b/src/State/Processor/RespondProcessor.php @@ -161,7 +161,7 @@ public function process(mixed $data, Operation $operation, array $uriVariables = private function getAllowedMethods(?string $resourceClass): string { $allowedMethods = self::DEFAULT_ALLOWED_METHOD; - if (null !== $resourceClass && $this->resourceClassResolver->isResourceClass($resourceClass)) { + if (null !== $resourceClass && null !== $this->resourceClassResolver && null !== $this->resourceCollectionMetadataFactory && $this->resourceClassResolver->isResourceClass($resourceClass)) { $resourceMetadataCollection = $this->resourceCollectionMetadataFactory->create($resourceClass); foreach ($resourceMetadataCollection as $resource) { foreach ($resource->getOperations() as $operation) { From 8e798186a57501ea8adcd5fec4a76f48bbfdde44 Mon Sep 17 00:00:00 2001 From: LaurentHuzard <laurent@les-tilleuls.coop> Date: Tue, 14 Jan 2025 16:44:16 +0100 Subject: [PATCH 3/9] fix: handle case injected services are null --- src/State/Processor/RespondProcessor.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/State/Processor/RespondProcessor.php b/src/State/Processor/RespondProcessor.php index 4d7e10f3611..3e92c81bda3 100644 --- a/src/State/Processor/RespondProcessor.php +++ b/src/State/Processor/RespondProcessor.php @@ -23,6 +23,7 @@ use ApiPlatform\Metadata\Operation\Factory\OperationMetadataFactoryInterface; use ApiPlatform\Metadata\Put; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; use ApiPlatform\Metadata\ResourceClassResolverInterface; use ApiPlatform\Metadata\UrlGeneratorInterface; use ApiPlatform\Metadata\Util\ClassInfoTrait; @@ -161,8 +162,8 @@ public function process(mixed $data, Operation $operation, array $uriVariables = private function getAllowedMethods(?string $resourceClass): string { $allowedMethods = self::DEFAULT_ALLOWED_METHOD; - if (null !== $resourceClass && null !== $this->resourceClassResolver && null !== $this->resourceCollectionMetadataFactory && $this->resourceClassResolver->isResourceClass($resourceClass)) { - $resourceMetadataCollection = $this->resourceCollectionMetadataFactory->create($resourceClass); + if (null !== $resourceClass && null !== $this->resourceClassResolver && $this->resourceClassResolver->isResourceClass($resourceClass)) { + $resourceMetadataCollection = $this->resourceCollectionMetadataFactory ? $this->resourceCollectionMetadataFactory->create($resourceClass) : new ResourceMetadataCollection($resourceClass); foreach ($resourceMetadataCollection as $resource) { foreach ($resource->getOperations() as $operation) { $allowedMethods[] = $operation->getMethod(); From aa82e5b95670af564ebeb37a150d077cbf124ab3 Mon Sep 17 00:00:00 2001 From: LaurentHuzard <laurent@les-tilleuls.coop> Date: Wed, 15 Jan 2025 18:31:26 +0100 Subject: [PATCH 4/9] fix: added header "allow-post" only on resources with POST and header "allow" has now the right method. --- features/main/ldp_resources.feature | 11 -- src/State/Processor/RespondProcessor.php | 21 ++- .../Tests/Processor/RespondProcessorTest.php | 78 ++++------- .../Entity/DummyGetPostDeleteOperation.php | 3 - .../Functional/State/RespondProcessorTest.php | 125 ++++++++++++++++++ 5 files changed, 165 insertions(+), 73 deletions(-) delete mode 100644 features/main/ldp_resources.feature create mode 100644 tests/Functional/State/RespondProcessorTest.php diff --git a/features/main/ldp_resources.feature b/features/main/ldp_resources.feature deleted file mode 100644 index 3ea65ea8eeb..00000000000 --- a/features/main/ldp_resources.feature +++ /dev/null @@ -1,11 +0,0 @@ -Feature: LDP Resources - In order to use an API compliant with the LDP specification (https://www.w3.org/TR/ldp/) - As a client software developer - I must have some specific headers returned by the API - - @createSchema - Scenario: Test Accept-Post and Allow headers for a LDP resource - When I add "Content-Type" header equal to "application/ld+json" - And I send a "GET" request to "/dummy_get_post_delete_operations" - Then the header "Accept-Post" should be equal to "text/turtle, application/ld+json" - And the header "Allow" should be equal to "OPTIONS, HEAD, GET, POST, DELETE" diff --git a/src/State/Processor/RespondProcessor.php b/src/State/Processor/RespondProcessor.php index 3e92c81bda3..bfc63a17556 100644 --- a/src/State/Processor/RespondProcessor.php +++ b/src/State/Processor/RespondProcessor.php @@ -93,8 +93,13 @@ public function process(mixed $data, Operation $operation, array $uriVariables = $headers['Accept-Patch'] = $acceptPatch; } - $headers['Accept-Post'] = 'text/turtle, application/ld+json'; - $headers['Allow'] = $this->getAllowedMethods($context['resource_class'] ?? null); + $allowedMethods = $this->getAllowedMethods($context['resource_class'] ?? null, $operation->getUriTemplate()); + $headers['Allow'] = implode(', ', $allowedMethods); + + $outputFormats = $operation->getOutputFormats(); + if (\is_array($outputFormats) && [] !== $outputFormats && \in_array('POST', $allowedMethods, true)) { + $headers['Accept-Post'] = implode(', ', array_merge(...array_values($outputFormats))); + } $method = $request->getMethod(); $originalData = $context['original_data'] ?? null; @@ -159,18 +164,20 @@ public function process(mixed $data, Operation $operation, array $uriVariables = ); } - private function getAllowedMethods(?string $resourceClass): string + private function getAllowedMethods(?string $resourceClass, ?string $currentUriTemplate): array { $allowedMethods = self::DEFAULT_ALLOWED_METHOD; - if (null !== $resourceClass && null !== $this->resourceClassResolver && $this->resourceClassResolver->isResourceClass($resourceClass)) { - $resourceMetadataCollection = $this->resourceCollectionMetadataFactory ? $this->resourceCollectionMetadataFactory->create($resourceClass) : new ResourceMetadataCollection($resourceClass); + if (null !== $currentUriTemplate && null !== $resourceClass && $this->resourceClassResolver->isResourceClass($resourceClass)) { + $resourceMetadataCollection =$this->resourceCollectionMetadataFactory ? $this->resourceCollectionMetadataFactory->create($resourceClass) : new ResourceMetadataCollection($resourceClass); foreach ($resourceMetadataCollection as $resource) { foreach ($resource->getOperations() as $operation) { - $allowedMethods[] = $operation->getMethod(); + if ($operation->getUriTemplate() === $currentUriTemplate) { + $allowedMethods[] = $operation->getMethod(); + } } } } - return implode(', ', array_unique($allowedMethods)); + return array_unique($allowedMethods); } } diff --git a/src/State/Tests/Processor/RespondProcessorTest.php b/src/State/Tests/Processor/RespondProcessorTest.php index ecda76f95ff..b4b92c898a2 100644 --- a/src/State/Tests/Processor/RespondProcessorTest.php +++ b/src/State/Tests/Processor/RespondProcessorTest.php @@ -16,8 +16,8 @@ use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Delete; use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\HttpOperation; -use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Put; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; @@ -42,6 +42,19 @@ protected function setUp(): void ->willReturn(true); $this->resourceMetadataCollectionFactory = $this->createMock(ResourceMetadataCollectionFactoryInterface::class); + $this->resourceMetadataCollectionFactory + ->method('create') + ->willReturn( + new ResourceMetadataCollection('DummyResource', [ + new ApiResource(operations: [ + new Get(uriTemplate: '/dummy_resources/{dummyResourceId}{._format}', name: 'get'), + new GetCollection(uriTemplate: '/dummy_resources{._format}', name: 'get_collections'), + new Post(uriTemplate: '/dummy_resources{._format}', name: 'post'), + new Delete(uriTemplate: '/dummy_resources/{dummyResourceId}{._format}', name: 'delete'), + new Put(uriTemplate: '/dummy_resources/{dummyResourceId}{._format}', name: 'put'), + ]), + ]) + ); $this->processor = new RespondProcessor( null, @@ -51,57 +64,36 @@ protected function setUp(): void ); } - public function testHeadersAcceptPostIsSetCorrectly(): void + public function testHeadersAcceptPostIsReturnWhenPostAllowed(): void { - $this->resourceMetadataCollectionFactory - ->method('create') - ->willReturn(new ResourceMetadataCollection('DummyResourceClass')); - - $operation = new HttpOperation('GET'); + $operation = (new HttpOperation('GET', '/dummy_resources{._format}', outputFormats: ['jsonld' => ['application/ld+json'], 'json' => ['application/json']])); $context = [ - 'resource_class' => 'SomeResourceClass', + 'resource_class' => 'DummyResource', 'request' => $this->createGetRequest(), ]; /** @var Response $response */ $response = $this->processor->process(null, $operation, [], $context); - - $this->assertSame('text/turtle, application/ld+json', $response->headers->get('Accept-Post')); + $this->assertSame('application/ld+json, application/json', $response->headers->get('Accept-Post')); } - public function testHeaderAllowHasHeadOptionsByDefault(): void + public function testHeadersAcceptPostIsNotSetWhenPostIsNotAllowed(): void { - $this->resourceMetadataCollectionFactory - ->method('create') - ->willReturn(new ResourceMetadataCollection('DummyResourceClass')); - - $operation = new HttpOperation('GET'); + $operation = (new HttpOperation('GET', '/dummy_resources/{dummyResourceId}{._format}')); $context = [ - 'resource_class' => 'SomeResourceClass', + 'resource_class' => 'DummyResource', 'request' => $this->createGetRequest(), ]; /** @var Response $response */ $response = $this->processor->process(null, $operation, [], $context); - $this->assertSame('OPTIONS, HEAD', $response->headers->get('Allow')); + $this->assertNull($response->headers->get('Accept-Post')); } public function testHeaderAllowReflectsResourceAllowedMethods(): void { - $this->resourceMetadataCollectionFactory - ->method('create') - ->willReturn( - new ResourceMetadataCollection('DummyResource', [ - new ApiResource(operations: [ - 'get' => new Get(name: 'get'), - 'post' => new Post(name: 'post'), - 'delete' => new Delete(name: 'delete'), - ]), - ]) - ); - - $operation = new HttpOperation('GET'); + $operation = (new HttpOperation('GET', '/dummy_resources{._format}')); $context = [ 'resource_class' => 'SomeResourceClass', 'request' => $this->createGetRequest(), @@ -115,30 +107,13 @@ public function testHeaderAllowReflectsResourceAllowedMethods(): void $this->assertStringContainsString('HEAD', $allowHeader); $this->assertStringContainsString('GET', $allowHeader); $this->assertStringContainsString('POST', $allowHeader); - $this->assertStringContainsString('DELETE', $allowHeader); - $this->assertStringNotContainsString('PATCH', $allowHeader); - $this->assertStringNotContainsString('PUT', $allowHeader); - } - - public function testHeaderAllowReflectsAllowedResourcesGetPutPatch(): void - { - $this->resourceMetadataCollectionFactory - ->method('create') - ->willReturn( - new ResourceMetadataCollection('DummyResource', [ - new ApiResource(operations: [ - 'get' => new Get(name: 'get'), - 'patch' => new Patch(name: 'patch'), - 'put' => new Put(name: 'put'), - ]), - ]) - ); + $this->assertStringNotContainsString('DELETE', $allowHeader); - $operation = new HttpOperation('GET'); $context = [ 'resource_class' => 'SomeResourceClass', 'request' => $this->createGetRequest(), ]; + $operation = (new HttpOperation('GET', '/dummy_resources/{dummyResourceId}{._format}')); /** @var Response $response */ $response = $this->processor->process(null, $operation, [], $context); @@ -147,10 +122,9 @@ public function testHeaderAllowReflectsAllowedResourcesGetPutPatch(): void $this->assertStringContainsString('OPTIONS', $allowHeader); $this->assertStringContainsString('HEAD', $allowHeader); $this->assertStringContainsString('GET', $allowHeader); - $this->assertStringContainsString('PATCH', $allowHeader); $this->assertStringContainsString('PUT', $allowHeader); + $this->assertStringContainsString('DELETE', $allowHeader); $this->assertStringNotContainsString('POST', $allowHeader); - $this->assertStringNotContainsString('DELETE', $allowHeader); } private function createGetRequest(): Request diff --git a/tests/Fixtures/TestBundle/Entity/DummyGetPostDeleteOperation.php b/tests/Fixtures/TestBundle/Entity/DummyGetPostDeleteOperation.php index bf86bebaacd..ad5e370e450 100644 --- a/tests/Fixtures/TestBundle/Entity/DummyGetPostDeleteOperation.php +++ b/tests/Fixtures/TestBundle/Entity/DummyGetPostDeleteOperation.php @@ -24,9 +24,6 @@ #[ORM\Entity] class DummyGetPostDeleteOperation { - /** - * @var int|null The id - */ #[ORM\Column(type: 'integer')] #[ORM\Id] #[ORM\GeneratedValue(strategy: 'AUTO')] diff --git a/tests/Functional/State/RespondProcessorTest.php b/tests/Functional/State/RespondProcessorTest.php new file mode 100644 index 00000000000..787b938238f --- /dev/null +++ b/tests/Functional/State/RespondProcessorTest.php @@ -0,0 +1,125 @@ +<?php + +/* + * This file is part of the API Platform project. + * + * (c) Kévin Dunglas <dunglas@gmail.com> + * + * 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\Functional\State; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyGetPostDeleteOperation; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM\Tools\SchemaTool; + +class RespondProcessorTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [DummyGetPostDeleteOperation::class]; + } + + public function testAllowHeadersForSingleResourceWithGetDelete(): void + { + $client = static::createClient(); + $client->request('GET', '/dummy_get_post_delete_operations/1', [ + 'headers' => [ + 'Content-Type' => 'application/ld+json', + ], + ]); + + $this->assertResponseHeaderSame('Allow', 'OPTIONS, HEAD, GET, DELETE'); + } + + public function testAllowHeadersForResourceCollectionReflectsAllowedMethods(): void + { + $client = static::createClient(); + $client->request('GET', '/dummy_get_post_delete_operations', [ + 'headers' => [ + 'Content-Type' => 'application/ld+json', + ], + ]); + + $this->assertResponseHeaderSame('allow', 'OPTIONS, HEAD, GET, POST'); + } + + public function testAcceptPostHeaderForResourceWithPostReflectsAllowedTypes(): void + { + $client = static::createClient(); + $client->request('GET', '/dummy_get_post_delete_operations', [ + 'headers' => [ + 'Content-Type' => 'application/ld+json', + ], + ]); + + $this->assertResponseHeaderSame('accept-post', 'application/ld+json, application/hal+json, application/vnd.api+json, application/xml, text/xml, application/json, text/html, application/graphql, multipart/form-data'); + } + + public function testAcceptPostHeaderDoesNotExistResourceWithoutPost(): void + { + $client = static::createClient(); + $client->request('OPTIONS', '/dummy_get_post_delete_operations/1', [ + 'headers' => [ + 'Content-Type' => 'application/ld+json', + ], + ]); + + $this->assertResponseNotHasHeader('accept-post'); + } + + protected function setUp(): void + { + self::bootKernel(); + + $container = static::getContainer(); + $registry = $container->get('doctrine'); + $manager = $registry->getManager(); + if (!$manager instanceof EntityManagerInterface) { + return; + } + + $classes = [$manager->getClassMetadata(DummyGetPostDeleteOperation::class)]; + + try { + $schemaTool = new SchemaTool($manager); + @$schemaTool->createSchema($classes); + } catch (\Exception $e) { + return; + } + + $dummy = new DummyGetPostDeleteOperation(); + $dummy->setName('Dummy'); + $manager->persist($dummy); + $manager->flush(); + } + + protected function tearDown(): void + { + $container = static::getContainer(); + $registry = $container->get('doctrine'); + $manager = $registry->getManager(); + if (!$manager instanceof EntityManagerInterface) { + return; + } + + $classes = [$manager->getClassMetadata(DummyGetPostDeleteOperation::class)]; + + $schemaTool = new SchemaTool($manager); + @$schemaTool->dropSchema($classes); + parent::tearDown(); + } +} From d560311ac05e79c26953f26820c2741bde856cb5 Mon Sep 17 00:00:00 2001 From: LaurentHuzard <laurent@les-tilleuls.coop> Date: Wed, 15 Jan 2025 18:48:18 +0100 Subject: [PATCH 5/9] style: apply php-cs-fixer --- src/State/Processor/RespondProcessor.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/State/Processor/RespondProcessor.php b/src/State/Processor/RespondProcessor.php index bfc63a17556..495ae59dfcf 100644 --- a/src/State/Processor/RespondProcessor.php +++ b/src/State/Processor/RespondProcessor.php @@ -168,7 +168,7 @@ private function getAllowedMethods(?string $resourceClass, ?string $currentUriTe { $allowedMethods = self::DEFAULT_ALLOWED_METHOD; if (null !== $currentUriTemplate && null !== $resourceClass && $this->resourceClassResolver->isResourceClass($resourceClass)) { - $resourceMetadataCollection =$this->resourceCollectionMetadataFactory ? $this->resourceCollectionMetadataFactory->create($resourceClass) : new ResourceMetadataCollection($resourceClass); + $resourceMetadataCollection = $this->resourceCollectionMetadataFactory ? $this->resourceCollectionMetadataFactory->create($resourceClass) : new ResourceMetadataCollection($resourceClass); foreach ($resourceMetadataCollection as $resource) { foreach ($resource->getOperations() as $operation) { if ($operation->getUriTemplate() === $currentUriTemplate) { From b7cd2854846ebfb7e0493b285b7446cc09da037d Mon Sep 17 00:00:00 2001 From: LaurentHuzard <laurent@les-tilleuls.coop> Date: Wed, 15 Jan 2025 19:00:19 +0100 Subject: [PATCH 6/9] Headers "Allow" and "Accept-Post" are not added if we return an error to avoid sending incorrect values. --- src/State/Processor/RespondProcessor.php | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/State/Processor/RespondProcessor.php b/src/State/Processor/RespondProcessor.php index 495ae59dfcf..3be404f63e0 100644 --- a/src/State/Processor/RespondProcessor.php +++ b/src/State/Processor/RespondProcessor.php @@ -93,12 +93,14 @@ public function process(mixed $data, Operation $operation, array $uriVariables = $headers['Accept-Patch'] = $acceptPatch; } - $allowedMethods = $this->getAllowedMethods($context['resource_class'] ?? null, $operation->getUriTemplate()); - $headers['Allow'] = implode(', ', $allowedMethods); + if (!$exception) { + $allowedMethods = $this->getAllowedMethods($context['resource_class'] ?? null, $operation->getUriTemplate()); + $headers['Allow'] = implode(', ', $allowedMethods); - $outputFormats = $operation->getOutputFormats(); - if (\is_array($outputFormats) && [] !== $outputFormats && \in_array('POST', $allowedMethods, true)) { - $headers['Accept-Post'] = implode(', ', array_merge(...array_values($outputFormats))); + $outputFormats = $operation->getOutputFormats(); + if (\is_array($outputFormats) && [] !== $outputFormats && \in_array('POST', $allowedMethods, true)) { + $headers['Accept-Post'] = implode(', ', array_merge(...array_values($outputFormats))); + } } $method = $request->getMethod(); From a6d97c413be6ddf8107b0a9d8d786dadc7722e57 Mon Sep 17 00:00:00 2001 From: LaurentHuzard <laurent@les-tilleuls.coop> Date: Thu, 16 Jan 2025 08:27:18 +0100 Subject: [PATCH 7/9] misc fixes and optimization --- src/State/Processor/RespondProcessor.php | 35 ++++++++----------- .../Resources/config/symfony/events.xml | 1 + .../Document/DummyGetPostDeleteOperation.php | 5 +++ 3 files changed, 20 insertions(+), 21 deletions(-) diff --git a/src/State/Processor/RespondProcessor.php b/src/State/Processor/RespondProcessor.php index 3be404f63e0..a897938a9fa 100644 --- a/src/State/Processor/RespondProcessor.php +++ b/src/State/Processor/RespondProcessor.php @@ -23,7 +23,6 @@ use ApiPlatform\Metadata\Operation\Factory\OperationMetadataFactoryInterface; use ApiPlatform\Metadata\Put; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; -use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; use ApiPlatform\Metadata\ResourceClassResolverInterface; use ApiPlatform\Metadata\UrlGeneratorInterface; use ApiPlatform\Metadata\Util\ClassInfoTrait; @@ -94,11 +93,22 @@ public function process(mixed $data, Operation $operation, array $uriVariables = } if (!$exception) { - $allowedMethods = $this->getAllowedMethods($context['resource_class'] ?? null, $operation->getUriTemplate()); + $isPostAllowed = false; + $allowedMethods = self::DEFAULT_ALLOWED_METHOD; + if (null !== ($context['resource_class'] ?? null) && null !== $this->resourceCollectionMetadataFactory && null !== ($currentUriTemplate = $operation->getUriTemplate()) && $this->resourceClassResolver?->isResourceClass($context['resource_class'])) { + $resourceMetadataCollection = $this->resourceCollectionMetadataFactory->create($context['resource_class']); + foreach ($resourceMetadataCollection as $resource) { + foreach ($resource->getOperations() as $resourceOperation) { + if ($resourceOperation->getUriTemplate() === $currentUriTemplate) { + $allowedMethods[] = $operationMethod = $resourceOperation->getMethod(); + $isPostAllowed = $isPostAllowed || ('POST' === $operationMethod); + } + } + } + } $headers['Allow'] = implode(', ', $allowedMethods); - $outputFormats = $operation->getOutputFormats(); - if (\is_array($outputFormats) && [] !== $outputFormats && \in_array('POST', $allowedMethods, true)) { + if ($isPostAllowed && \is_array($outputFormats = ($outputFormats = $operation->getOutputFormats())) && [] !== $outputFormats) { $headers['Accept-Post'] = implode(', ', array_merge(...array_values($outputFormats))); } } @@ -165,21 +175,4 @@ public function process(mixed $data, Operation $operation, array $uriVariables = $headers ); } - - private function getAllowedMethods(?string $resourceClass, ?string $currentUriTemplate): array - { - $allowedMethods = self::DEFAULT_ALLOWED_METHOD; - if (null !== $currentUriTemplate && null !== $resourceClass && $this->resourceClassResolver->isResourceClass($resourceClass)) { - $resourceMetadataCollection = $this->resourceCollectionMetadataFactory ? $this->resourceCollectionMetadataFactory->create($resourceClass) : new ResourceMetadataCollection($resourceClass); - foreach ($resourceMetadataCollection as $resource) { - foreach ($resource->getOperations() as $operation) { - if ($operation->getUriTemplate() === $currentUriTemplate) { - $allowedMethods[] = $operation->getMethod(); - } - } - } - } - - return array_unique($allowedMethods); - } } diff --git a/src/Symfony/Bundle/Resources/config/symfony/events.xml b/src/Symfony/Bundle/Resources/config/symfony/events.xml index 4ecdb892921..66fd7278657 100644 --- a/src/Symfony/Bundle/Resources/config/symfony/events.xml +++ b/src/Symfony/Bundle/Resources/config/symfony/events.xml @@ -61,6 +61,7 @@ <argument type="service" id="api_platform.iri_converter" /> <argument type="service" id="api_platform.resource_class_resolver" /> <argument type="service" id="api_platform.metadata.operation.metadata_factory" /> + <argument type="service" id="api_platform.metadata.operation.metadata_collection_factory" /> </service> <service id="api_platform.state_processor.add_link_header" class="ApiPlatform\State\Processor\AddLinkHeaderProcessor" decorates="api_platform.state_processor.respond"> diff --git a/tests/Fixtures/TestBundle/Document/DummyGetPostDeleteOperation.php b/tests/Fixtures/TestBundle/Document/DummyGetPostDeleteOperation.php index 5ac27a1c728..f6d6a6244a0 100644 --- a/tests/Fixtures/TestBundle/Document/DummyGetPostDeleteOperation.php +++ b/tests/Fixtures/TestBundle/Document/DummyGetPostDeleteOperation.php @@ -49,4 +49,9 @@ public function setName(string $name): void { $this->name = $name; } + + public function __toString(): string + { + return (string) $this->getId(); + } } From 7d27c1ccf6a01b7d262cb6c48db7975a2a0f9af7 Mon Sep 17 00:00:00 2001 From: LaurentHuzard <laurent@les-tilleuls.coop> Date: Tue, 21 Jan 2025 08:46:18 +0100 Subject: [PATCH 8/9] feat(state): improvements and fixes for LinkedDataPlatform support - use LinkedDataPlatformProcessor instead of RespondProcessor to handle the allow and allow-post headers - add new Processor in symfony events and Laravel --- src/Laravel/ApiPlatformProvider.php | 5 + src/Laravel/Tests/LinkedDataPlatformTest.php | 68 ++++++++++ .../Processor/LinkedDataPlatformProcessor.php | 77 +++++++++++ src/State/Processor/RespondProcessor.php | 25 ---- ...hp => LinkedDataPlatformProcessorTest.php} | 120 ++++++++++++------ .../Resources/config/state/processor.xml | 7 +- .../Resources/config/symfony/events.xml | 5 +- .../DummyGetPostDeleteOperation.php | 37 +++++- .../Document/DummyGetPostDeleteOperation.php | 57 --------- .../Entity/DummyGetPostDeleteOperation.php | 54 -------- .../Functional/State/RespondProcessorTest.php | 63 +-------- 11 files changed, 285 insertions(+), 233 deletions(-) create mode 100644 src/Laravel/Tests/LinkedDataPlatformTest.php create mode 100644 src/State/Processor/LinkedDataPlatformProcessor.php rename src/State/Tests/Processor/{RespondProcessorTest.php => LinkedDataPlatformProcessorTest.php} (53%) delete mode 100644 tests/Fixtures/TestBundle/Document/DummyGetPostDeleteOperation.php delete mode 100644 tests/Fixtures/TestBundle/Entity/DummyGetPostDeleteOperation.php diff --git a/src/Laravel/ApiPlatformProvider.php b/src/Laravel/ApiPlatformProvider.php index 2338ea4b766..5c15cfd2211 100644 --- a/src/Laravel/ApiPlatformProvider.php +++ b/src/Laravel/ApiPlatformProvider.php @@ -183,6 +183,7 @@ use ApiPlatform\State\Pagination\PaginationOptions; use ApiPlatform\State\ParameterProviderInterface; use ApiPlatform\State\Processor\AddLinkHeaderProcessor; +use ApiPlatform\State\Processor\LinkedDataPlatformProcessor; use ApiPlatform\State\Processor\RespondProcessor; use ApiPlatform\State\Processor\SerializeProcessor; use ApiPlatform\State\Processor\WriteProcessor; @@ -558,6 +559,10 @@ public function register(): void return new AddLinkHeaderProcessor(new RespondProcessor(), new HttpHeaderSerializer()); }); + $this->app->singleton(RespondProcessor::class, function (Application $app) { + return new LinkedDataPlatformProcessor(new RespondProcessor(), $app->make(ResourceClassResolverInterface::class), $app->make(ResourceMetadataCollectionFactoryInterface::class)); + }); + $this->app->singleton(SerializeProcessor::class, function (Application $app) { return new SerializeProcessor($app->make(RespondProcessor::class), $app->make(Serializer::class), $app->make(SerializerContextBuilderInterface::class)); }); diff --git a/src/Laravel/Tests/LinkedDataPlatformTest.php b/src/Laravel/Tests/LinkedDataPlatformTest.php new file mode 100644 index 00000000000..f8123f1d9a4 --- /dev/null +++ b/src/Laravel/Tests/LinkedDataPlatformTest.php @@ -0,0 +1,68 @@ +<?php + +/* + * This file is part of the API Platform project. + * + * (c) Kévin Dunglas <dunglas@gmail.com> + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +use ApiPlatform\Laravel\Test\ApiTestAssertionsTrait; +use Illuminate\Contracts\Config\Repository; +use Illuminate\Foundation\Application; +use Illuminate\Foundation\Testing\RefreshDatabase; +use Orchestra\Testbench\Concerns\WithWorkbench; +use Orchestra\Testbench\TestCase; +use Workbench\App\Models\Book; +use Workbench\Database\Factories\BookFactory; + +class LinkedDataPlatformTest extends TestCase +{ + use ApiTestAssertionsTrait; + use RefreshDatabase; + use WithWorkbench; + + /** + * @param Application $app + */ + protected function defineEnvironment($app): void + { + tap($app['config'], function (Repository $config): void { + $config->set('api-platform.formats', ['jsonld' => ['application/ld+json'], 'turtle' => ['text/turtle']]); + $config->set('api-platform.resources', [app_path('Models'), app_path('ApiResource')]); + $config->set('app.debug', true); + }); + } + + public function testHeadersAcceptPostIsReturnWhenPostAllowed(): void + { + $response = $this->get('/api/books', ['accept' => ['application/ld+json']]); + $response->assertStatus(200); + $response->assertHeader('accept-post', 'application/ld+json, text/turtle, text/html'); + } + + public function testHeadersAcceptPostIsNotSetWhenPostIsNotAllowed(): void + { + BookFactory::new()->createOne(); + $book = Book::first(); + $response = $this->get($this->getIriFromResource($book), ['accept' => ['application/ld+json']]); + $response->assertStatus(200); + $response->assertHeaderMissing('accept-post'); + } + + public function testHeaderAllowReflectsResourceAllowedMethods(): void + { + $response = $this->get('/api/books', ['accept' => ['application/ld+json']]); + $response->assertHeader('allow', 'OPTIONS, HEAD, POST, GET'); + + BookFactory::new()->createOne(); + $book = Book::first(); + $response = $this->get($this->getIriFromResource($book), ['accept' => ['application/ld+json']]); + $response->assertStatus(200); + $response->assertHeader('allow', 'OPTIONS, HEAD, PUT, PATCH, GET, DELETE'); + } +} diff --git a/src/State/Processor/LinkedDataPlatformProcessor.php b/src/State/Processor/LinkedDataPlatformProcessor.php new file mode 100644 index 00000000000..223b400a872 --- /dev/null +++ b/src/State/Processor/LinkedDataPlatformProcessor.php @@ -0,0 +1,77 @@ +<?php + +/* + * This file is part of the API Platform project. + * + * (c) Kévin Dunglas <dunglas@gmail.com> + * + * 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\State\Processor; + +use ApiPlatform\Metadata\Error; +use ApiPlatform\Metadata\HttpOperation; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\ResourceClassResolverInterface; +use ApiPlatform\State\ProcessorInterface; +use Symfony\Component\HttpFoundation\Response; + +/** + * @template T1 + * @template T2 + * + * @implements ProcessorInterface<T1, T2> + */ +final class LinkedDataPlatformProcessor implements ProcessorInterface +{ + private const DEFAULT_ALLOWED_METHOD = ['OPTIONS', 'HEAD']; + + /** + * @param ProcessorInterface<T1, T2> $decorated + */ + public function __construct( + private readonly ProcessorInterface $decorated, // todo is processor interface nullable + private readonly ?ResourceClassResolverInterface $resourceClassResolver = null, + private readonly ?ResourceMetadataCollectionFactoryInterface $resourceCollectionMetadataFactory = null, + ) { + } + + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed + { + $response = $this->decorated->process($data, $operation, $uriVariables, $context); + if ( + !$response instanceof Response + || !$operation instanceof HttpOperation + || $operation instanceof Error + || null === $this->resourceCollectionMetadataFactory + || !($context['resource_class'] ?? null) + || null === $operation->getUriTemplate() + || !$this->resourceClassResolver?->isResourceClass($context['resource_class']) + ) { + return $response; + } + + $allowedMethods = self::DEFAULT_ALLOWED_METHOD; + $resourceMetadataCollection = $this->resourceCollectionMetadataFactory->create($context['resource_class']); + foreach ($resourceMetadataCollection as $resource) { + foreach ($resource->getOperations() as $resourceOperation) { + if ($resourceOperation->getUriTemplate() === $operation->getUriTemplate()) { + $operationMethod = $resourceOperation->getMethod(); + $allowedMethods[] = $operationMethod; + if ('POST' === $operationMethod && \is_array($outputFormats = $operation->getOutputFormats())) { + $response->headers->set('Accept-Post', implode(', ', array_merge(...array_values($outputFormats)))); + } + } + } + } + + $response->headers->set('Allow', implode(', ', $allowedMethods)); + + return $response; + } +} diff --git a/src/State/Processor/RespondProcessor.php b/src/State/Processor/RespondProcessor.php index a897938a9fa..58941e437c3 100644 --- a/src/State/Processor/RespondProcessor.php +++ b/src/State/Processor/RespondProcessor.php @@ -22,7 +22,6 @@ use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Operation\Factory\OperationMetadataFactoryInterface; use ApiPlatform\Metadata\Put; -use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\ResourceClassResolverInterface; use ApiPlatform\Metadata\UrlGeneratorInterface; use ApiPlatform\Metadata\Util\ClassInfoTrait; @@ -46,13 +45,10 @@ final class RespondProcessor implements ProcessorInterface 'DELETE' => Response::HTTP_NO_CONTENT, ]; - private const DEFAULT_ALLOWED_METHOD = ['OPTIONS', 'HEAD']; - public function __construct( private ?IriConverterInterface $iriConverter = null, private readonly ?ResourceClassResolverInterface $resourceClassResolver = null, private readonly ?OperationMetadataFactoryInterface $operationMetadataFactory = null, - private readonly ?ResourceMetadataCollectionFactoryInterface $resourceCollectionMetadataFactory = null, ) { } @@ -92,27 +88,6 @@ public function process(mixed $data, Operation $operation, array $uriVariables = $headers['Accept-Patch'] = $acceptPatch; } - if (!$exception) { - $isPostAllowed = false; - $allowedMethods = self::DEFAULT_ALLOWED_METHOD; - if (null !== ($context['resource_class'] ?? null) && null !== $this->resourceCollectionMetadataFactory && null !== ($currentUriTemplate = $operation->getUriTemplate()) && $this->resourceClassResolver?->isResourceClass($context['resource_class'])) { - $resourceMetadataCollection = $this->resourceCollectionMetadataFactory->create($context['resource_class']); - foreach ($resourceMetadataCollection as $resource) { - foreach ($resource->getOperations() as $resourceOperation) { - if ($resourceOperation->getUriTemplate() === $currentUriTemplate) { - $allowedMethods[] = $operationMethod = $resourceOperation->getMethod(); - $isPostAllowed = $isPostAllowed || ('POST' === $operationMethod); - } - } - } - } - $headers['Allow'] = implode(', ', $allowedMethods); - - if ($isPostAllowed && \is_array($outputFormats = ($outputFormats = $operation->getOutputFormats())) && [] !== $outputFormats) { - $headers['Accept-Post'] = implode(', ', array_merge(...array_values($outputFormats))); - } - } - $method = $request->getMethod(); $originalData = $context['original_data'] ?? null; diff --git a/src/State/Tests/Processor/RespondProcessorTest.php b/src/State/Tests/Processor/LinkedDataPlatformProcessorTest.php similarity index 53% rename from src/State/Tests/Processor/RespondProcessorTest.php rename to src/State/Tests/Processor/LinkedDataPlatformProcessorTest.php index b4b92c898a2..75361a41723 100644 --- a/src/State/Tests/Processor/RespondProcessorTest.php +++ b/src/State/Tests/Processor/LinkedDataPlatformProcessorTest.php @@ -15,6 +15,7 @@ use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Delete; +use ApiPlatform\Metadata\Error; use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\HttpOperation; @@ -23,21 +24,24 @@ use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; use ApiPlatform\Metadata\ResourceClassResolverInterface; -use ApiPlatform\State\Processor\RespondProcessor; +use ApiPlatform\State\Processor\LinkedDataPlatformProcessor; +use ApiPlatform\State\ProcessorInterface; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; -class RespondProcessorTest extends TestCase +class LinkedDataPlatformProcessorTest extends TestCase { private ResourceMetadataCollectionFactoryInterface&MockObject $resourceMetadataCollectionFactory; - private RespondProcessor $processor; + private ResourceClassResolverInterface&MockObject $resourceClassResolver; + + private ProcessorInterface&MockObject $decorated; protected function setUp(): void { - $resourceClassResolver = $this->createMock(ResourceClassResolverInterface::class); - $resourceClassResolver + $this->resourceClassResolver = $this->createMock(ResourceClassResolverInterface::class); + $this->resourceClassResolver ->method('isResourceClass') ->willReturn(true); @@ -45,7 +49,7 @@ protected function setUp(): void $this->resourceMetadataCollectionFactory ->method('create') ->willReturn( - new ResourceMetadataCollection('DummyResource', [ + new ResourceMetadataCollection('DummyResource', [ // todo mock $dummy_resource new ApiResource(operations: [ new Get(uriTemplate: '/dummy_resources/{dummyResourceId}{._format}', name: 'get'), new GetCollection(uriTemplate: '/dummy_resources{._format}', name: 'get_collections'), @@ -56,37 +60,39 @@ protected function setUp(): void ]) ); - $this->processor = new RespondProcessor( - null, - $resourceClassResolver, - null, - $this->resourceMetadataCollectionFactory - ); + $this->decorated = $this->createMock(ProcessorInterface::class); + $this->decorated->method('process')->willReturn(new Response()); } public function testHeadersAcceptPostIsReturnWhenPostAllowed(): void { - $operation = (new HttpOperation('GET', '/dummy_resources{._format}', outputFormats: ['jsonld' => ['application/ld+json'], 'json' => ['application/json']])); - $context = [ - 'resource_class' => 'DummyResource', - 'request' => $this->createGetRequest(), - ]; + $operation = (new HttpOperation('GET', '/dummy_resources{._format}', outputFormats: ['jsonld' => ['application/ld+json'], 'text/turtle' => ['text/turtle']])); + $context = $this->getContext(); + + $processor = new LinkedDataPlatformProcessor( + $this->decorated, + $this->resourceClassResolver, + $this->resourceMetadataCollectionFactory + ); /** @var Response $response */ - $response = $this->processor->process(null, $operation, [], $context); - $this->assertSame('application/ld+json, application/json', $response->headers->get('Accept-Post')); + $response = $processor->process(null, $operation, [], $context); + + $this->assertSame('application/ld+json, text/turtle', $response->headers->get('Accept-Post')); } public function testHeadersAcceptPostIsNotSetWhenPostIsNotAllowed(): void { $operation = (new HttpOperation('GET', '/dummy_resources/{dummyResourceId}{._format}')); - $context = [ - 'resource_class' => 'DummyResource', - 'request' => $this->createGetRequest(), - ]; + $context = $this->getContext(); + $processor = new LinkedDataPlatformProcessor( + $this->decorated, + $this->resourceClassResolver, + $this->resourceMetadataCollectionFactory + ); /** @var Response $response */ - $response = $this->processor->process(null, $operation, [], $context); + $response = $processor->process(null, $operation, [], $context); $this->assertNull($response->headers->get('Accept-Post')); } @@ -94,37 +100,67 @@ public function testHeadersAcceptPostIsNotSetWhenPostIsNotAllowed(): void public function testHeaderAllowReflectsResourceAllowedMethods(): void { $operation = (new HttpOperation('GET', '/dummy_resources{._format}')); - $context = [ - 'resource_class' => 'SomeResourceClass', - 'request' => $this->createGetRequest(), - ]; + $context = $this->getContext(); + $processor = new LinkedDataPlatformProcessor( + $this->decorated, + $this->resourceClassResolver, + $this->resourceMetadataCollectionFactory + ); /** @var Response $response */ - $response = $this->processor->process(null, $operation, [], $context); - + $response = $processor->process(null, $operation, [], $context); $allowHeader = $response->headers->get('Allow'); $this->assertStringContainsString('OPTIONS', $allowHeader); $this->assertStringContainsString('HEAD', $allowHeader); $this->assertStringContainsString('GET', $allowHeader); $this->assertStringContainsString('POST', $allowHeader); - $this->assertStringNotContainsString('DELETE', $allowHeader); - $context = [ - 'resource_class' => 'SomeResourceClass', - 'request' => $this->createGetRequest(), - ]; $operation = (new HttpOperation('GET', '/dummy_resources/{dummyResourceId}{._format}')); /** @var Response $response */ - $response = $this->processor->process(null, $operation, [], $context); - + $processor = new LinkedDataPlatformProcessor( + $this->decorated, + $this->resourceClassResolver, + $this->resourceMetadataCollectionFactory + ); + /** @var Response $response */ + $response = $processor->process('data', $operation, [], $this->getContext()); $allowHeader = $response->headers->get('Allow'); $this->assertStringContainsString('OPTIONS', $allowHeader); $this->assertStringContainsString('HEAD', $allowHeader); $this->assertStringContainsString('GET', $allowHeader); $this->assertStringContainsString('PUT', $allowHeader); $this->assertStringContainsString('DELETE', $allowHeader); - $this->assertStringNotContainsString('POST', $allowHeader); + } + + public function testProcessorWithoutRequiredConditionReturnOriginalResponse(): void + { + $operation = (new HttpOperation('GET', '/dummy_resources/{dummyResourceId}{._format}')); + + // No collection factory + $processor = new LinkedDataPlatformProcessor($this->decorated, $this->resourceClassResolver, null); + /** @var Response $response */ + $response = $processor->process(null, $operation, [], $this->getContext()); + + $this->assertNull($response->headers->get('Allow')); + + // No uri variable in context + $processor = new LinkedDataPlatformProcessor($this->decorated, null, $this->resourceMetadataCollectionFactory); + $response = $processor->process(null, $operation, [], $this->getContext()); + $this->assertNull($response->headers->get('Allow')); + + // Operation is an Error + $processor = new LinkedDataPlatformProcessor($this->decorated, $this->resourceClassResolver, $this->resourceMetadataCollectionFactory); + $response = $processor->process(null, new Error(), $this->getContext()); + $this->assertNull($response->headers->get('Allow')); + + // Not a resource class + $this->resourceClassResolver + ->method('isResourceClass') + ->willReturn(false); + $processor = new LinkedDataPlatformProcessor($this->decorated, $this->resourceClassResolver, $this->resourceMetadataCollectionFactory); + $response = $processor->process(null, $operation, []); + $this->assertNull($response->headers->get('Allow')); } private function createGetRequest(): Request @@ -136,4 +172,12 @@ private function createGetRequest(): Request return $request; } + + private function getContext(): array + { + return [ + 'resource_class' => 'DummyResource', + 'request' => $this->createGetRequest(), + ]; + } } diff --git a/src/Symfony/Bundle/Resources/config/state/processor.xml b/src/Symfony/Bundle/Resources/config/state/processor.xml index e5dd2792e5f..b448f4c3cf6 100644 --- a/src/Symfony/Bundle/Resources/config/state/processor.xml +++ b/src/Symfony/Bundle/Resources/config/state/processor.xml @@ -21,11 +21,16 @@ <argument type="service" id="api_platform.iri_converter" /> <argument type="service" id="api_platform.resource_class_resolver" /> <argument type="service" id="api_platform.metadata.operation.metadata_factory" /> - <argument type="service" id="api_platform.metadata.resource.metadata_collection_factory" /> </service> <service id="api_platform.state_processor.add_link_header" class="ApiPlatform\State\Processor\AddLinkHeaderProcessor" decorates="api_platform.state_processor.respond"> <argument type="service" id="api_platform.state_processor.add_link_header.inner" /> </service> + + <service id="api_platform.state_processor.linked_data_platform" class="ApiPlatform\State\Processor\LinkedDataPlatformProcessor" decorates="api_platform.state_processor.respond"> + <argument type="service" id="api_platform.state_processor.linked_data_platform.inner" /> + <argument type="service" id="api_platform.resource_class_resolver" /> + <argument type="service" id="api_platform.metadata.resource.metadata_collection_factory" /> + </service> </services> </container> diff --git a/src/Symfony/Bundle/Resources/config/symfony/events.xml b/src/Symfony/Bundle/Resources/config/symfony/events.xml index 66fd7278657..fbd905bf15b 100644 --- a/src/Symfony/Bundle/Resources/config/symfony/events.xml +++ b/src/Symfony/Bundle/Resources/config/symfony/events.xml @@ -61,13 +61,16 @@ <argument type="service" id="api_platform.iri_converter" /> <argument type="service" id="api_platform.resource_class_resolver" /> <argument type="service" id="api_platform.metadata.operation.metadata_factory" /> - <argument type="service" id="api_platform.metadata.operation.metadata_collection_factory" /> </service> <service id="api_platform.state_processor.add_link_header" class="ApiPlatform\State\Processor\AddLinkHeaderProcessor" decorates="api_platform.state_processor.respond"> <argument type="service" id="api_platform.state_processor.add_link_header.inner" /> </service> + <service id="api_platform.state_processor.linked_data_platform" class="ApiPlatform\State\Processor\LinkedDataPlatformProcessor" decorates="api_platform.state_processor.respond"> + <argument type="service" id="api_platform.state_processor.linked_data_platform.inner" /> + </service> + <service id="api_platform.listener.view.write" class="ApiPlatform\Symfony\EventListener\WriteListener"> <argument type="service" id="api_platform.state_processor.write" /> <argument type="service" id="api_platform.metadata.resource.metadata_collection_factory" /> diff --git a/tests/Fixtures/TestBundle/ApiResource/DummyGetPostDeleteOperation.php b/tests/Fixtures/TestBundle/ApiResource/DummyGetPostDeleteOperation.php index 954bcb29217..d46018fb298 100644 --- a/tests/Fixtures/TestBundle/ApiResource/DummyGetPostDeleteOperation.php +++ b/tests/Fixtures/TestBundle/ApiResource/DummyGetPostDeleteOperation.php @@ -17,12 +17,47 @@ use ApiPlatform\Metadata\Delete; use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Post; -#[ApiResource(operations: [new Get(), new GetCollection(), new Post(), new Delete()])] +#[ApiResource(operations: [ + new Get( + uriTemplate: '/dummy_get_post_delete_operations/{id}', + provider: [self::class, 'provideItem'], + ), + new GetCollection( + uriTemplate: '/dummy_get_post_delete_operations', + provider: [self::class, 'provide'], ), + new Post( + uriTemplate: '/dummy_get_post_delete_operations', + provider: [self::class, 'provide'], ), + new Delete( + uriTemplate: '/dummy_get_post_delete_operations/{id}', + provider: [self::class, 'provideItem'], ), +])] class DummyGetPostDeleteOperation { public ?int $id; public ?string $name = null; + + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): array + { + $dummyResource = new self(); + $dummyResource->id = 1; + $dummyResource->name = 'Dummy name'; + + return [ + $dummyResource, + ]; + } + + public static function provideItem(Operation $operation, array $uriVariables = [], array $context = []): self + { + $dummyResource = new self(); + $dummyResource->id = $uriVariables['id']; + $dummyResource->name = 'Dummy name'; + + return $dummyResource; + } } diff --git a/tests/Fixtures/TestBundle/Document/DummyGetPostDeleteOperation.php b/tests/Fixtures/TestBundle/Document/DummyGetPostDeleteOperation.php deleted file mode 100644 index f6d6a6244a0..00000000000 --- a/tests/Fixtures/TestBundle/Document/DummyGetPostDeleteOperation.php +++ /dev/null @@ -1,57 +0,0 @@ -<?php - -/* - * This file is part of the API Platform project. - * - * (c) Kévin Dunglas <dunglas@gmail.com> - * - * 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\Document; - -use ApiPlatform\Metadata\ApiResource; -use ApiPlatform\Metadata\Delete; -use ApiPlatform\Metadata\Get; -use ApiPlatform\Metadata\GetCollection; -use ApiPlatform\Metadata\Post; -use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; - -#[ApiResource(operations: [new Get(), new GetCollection(), new Post(), new Delete()])] -#[ODM\Document] -class DummyGetPostDeleteOperation -{ - #[ODM\Id(strategy: 'INCREMENT', type: 'int')] - private ?int $id; - - #[ODM\Field(type: 'string')] - private ?string $name = null; - - public function getId(): ?int - { - return $this->id; - } - - public function setId(?int $id): void - { - $this->id = $id; - } - - public function getName(): string - { - return $this->name; - } - - public function setName(string $name): void - { - $this->name = $name; - } - - public function __toString(): string - { - return (string) $this->getId(); - } -} diff --git a/tests/Fixtures/TestBundle/Entity/DummyGetPostDeleteOperation.php b/tests/Fixtures/TestBundle/Entity/DummyGetPostDeleteOperation.php deleted file mode 100644 index ad5e370e450..00000000000 --- a/tests/Fixtures/TestBundle/Entity/DummyGetPostDeleteOperation.php +++ /dev/null @@ -1,54 +0,0 @@ -<?php - -/* - * This file is part of the API Platform project. - * - * (c) Kévin Dunglas <dunglas@gmail.com> - * - * 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\Entity; - -use ApiPlatform\Metadata\ApiResource; -use ApiPlatform\Metadata\Delete; -use ApiPlatform\Metadata\Get; -use ApiPlatform\Metadata\GetCollection; -use ApiPlatform\Metadata\Post; -use Doctrine\ORM\Mapping as ORM; - -#[ApiResource(operations: [new Get(), new GetCollection(), new Post(), new Delete()])] -#[ORM\Entity] -class DummyGetPostDeleteOperation -{ - #[ORM\Column(type: 'integer')] - #[ORM\Id] - #[ORM\GeneratedValue(strategy: 'AUTO')] - private ?int $id = null; - - #[ORM\Column] - private string $name; - - public function getId(): ?int - { - return $this->id; - } - - public function setId(?int $id): void - { - $this->id = $id; - } - - public function getName(): string - { - return $this->name; - } - - public function setName(string $name): void - { - $this->name = $name; - } -} diff --git a/tests/Functional/State/RespondProcessorTest.php b/tests/Functional/State/RespondProcessorTest.php index 787b938238f..f529023e588 100644 --- a/tests/Functional/State/RespondProcessorTest.php +++ b/tests/Functional/State/RespondProcessorTest.php @@ -14,15 +14,11 @@ namespace ApiPlatform\Tests\Functional\State; use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyGetPostDeleteOperation; -use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\DummyGetPostDeleteOperation; use ApiPlatform\Tests\SetupClassResourcesTrait; -use Doctrine\ORM\EntityManagerInterface; -use Doctrine\ORM\Tools\SchemaTool; class RespondProcessorTest extends ApiTestCase { - use RecreateSchemaTrait; use SetupClassResourcesTrait; /** @@ -33,28 +29,25 @@ public static function getResources(): array return [DummyGetPostDeleteOperation::class]; } - public function testAllowHeadersForSingleResourceWithGetDelete(): void + public function testAllowHeadersForResourceCollectionReflectsAllowedMethods(): void { $client = static::createClient(); - $client->request('GET', '/dummy_get_post_delete_operations/1', [ + $client->request('GET', '/dummy_get_post_delete_operations', [ 'headers' => [ 'Content-Type' => 'application/ld+json', ], ]); - $this->assertResponseHeaderSame('Allow', 'OPTIONS, HEAD, GET, DELETE'); - } + $this->assertResponseHeaderSame('allow', 'OPTIONS, HEAD, GET, POST'); - public function testAllowHeadersForResourceCollectionReflectsAllowedMethods(): void - { $client = static::createClient(); - $client->request('GET', '/dummy_get_post_delete_operations', [ + $client->request('GET', '/dummy_get_post_delete_operations/1', [ 'headers' => [ 'Content-Type' => 'application/ld+json', ], ]); - $this->assertResponseHeaderSame('allow', 'OPTIONS, HEAD, GET, POST'); + $this->assertResponseHeaderSame('allow', 'OPTIONS, HEAD, GET, DELETE'); } public function testAcceptPostHeaderForResourceWithPostReflectsAllowedTypes(): void @@ -72,7 +65,7 @@ public function testAcceptPostHeaderForResourceWithPostReflectsAllowedTypes(): v public function testAcceptPostHeaderDoesNotExistResourceWithoutPost(): void { $client = static::createClient(); - $client->request('OPTIONS', '/dummy_get_post_delete_operations/1', [ + $client->request('GET', '/dummy_get_post_delete_operations/1', [ 'headers' => [ 'Content-Type' => 'application/ld+json', ], @@ -80,46 +73,4 @@ public function testAcceptPostHeaderDoesNotExistResourceWithoutPost(): void $this->assertResponseNotHasHeader('accept-post'); } - - protected function setUp(): void - { - self::bootKernel(); - - $container = static::getContainer(); - $registry = $container->get('doctrine'); - $manager = $registry->getManager(); - if (!$manager instanceof EntityManagerInterface) { - return; - } - - $classes = [$manager->getClassMetadata(DummyGetPostDeleteOperation::class)]; - - try { - $schemaTool = new SchemaTool($manager); - @$schemaTool->createSchema($classes); - } catch (\Exception $e) { - return; - } - - $dummy = new DummyGetPostDeleteOperation(); - $dummy->setName('Dummy'); - $manager->persist($dummy); - $manager->flush(); - } - - protected function tearDown(): void - { - $container = static::getContainer(); - $registry = $container->get('doctrine'); - $manager = $registry->getManager(); - if (!$manager instanceof EntityManagerInterface) { - return; - } - - $classes = [$manager->getClassMetadata(DummyGetPostDeleteOperation::class)]; - - $schemaTool = new SchemaTool($manager); - @$schemaTool->dropSchema($classes); - parent::tearDown(); - } } From d072fd558ba887728974712eb85e1c4a3dd6b9ac Mon Sep 17 00:00:00 2001 From: LaurentHuzard <laurent@les-tilleuls.coop> Date: Mon, 27 Jan 2025 06:08:14 +0100 Subject: [PATCH 9/9] feat(state): fixes --- src/Laravel/ApiPlatformProvider.php | 16 +++++++++--- .../Processor/LinkedDataPlatformProcessor.php | 25 +++++++++---------- .../LinkedDataPlatformProcessorTest.php | 4 +-- .../Resources/config/symfony/events.xml | 2 ++ ...essorTest.php => LinkDataPlatformTest.php} | 4 +-- 5 files changed, 30 insertions(+), 21 deletions(-) rename tests/Functional/{State/RespondProcessorTest.php => LinkDataPlatformTest.php} (96%) diff --git a/src/Laravel/ApiPlatformProvider.php b/src/Laravel/ApiPlatformProvider.php index 5c15cfd2211..d909169a500 100644 --- a/src/Laravel/ApiPlatformProvider.php +++ b/src/Laravel/ApiPlatformProvider.php @@ -556,15 +556,23 @@ public function register(): void }); $this->app->singleton(RespondProcessor::class, function () { - return new AddLinkHeaderProcessor(new RespondProcessor(), new HttpHeaderSerializer()); + return new RespondProcessor(); }); - $this->app->singleton(RespondProcessor::class, function (Application $app) { - return new LinkedDataPlatformProcessor(new RespondProcessor(), $app->make(ResourceClassResolverInterface::class), $app->make(ResourceMetadataCollectionFactoryInterface::class)); + $this->app->singleton(AddLinkHeaderProcessor::class, function (Application $app) { + return new AddLinkHeaderProcessor($app->make(RespondProcessor::class), new HttpHeaderSerializer()); + }); + + $this->app->singleton(LinkedDataPlatformProcessor::class, function (Application $app) { + return new LinkedDataPlatformProcessor( + $app->make(AddLinkHeaderProcessor::class), // Original service + $app->make(ResourceClassResolverInterface::class), + $app->make(ResourceMetadataCollectionFactoryInterface::class) + ); }); $this->app->singleton(SerializeProcessor::class, function (Application $app) { - return new SerializeProcessor($app->make(RespondProcessor::class), $app->make(Serializer::class), $app->make(SerializerContextBuilderInterface::class)); + return new SerializeProcessor($app->make(LinkedDataPlatformProcessor::class), $app->make(Serializer::class), $app->make(SerializerContextBuilderInterface::class)); }); $this->app->singleton(WriteProcessor::class, function (Application $app) { diff --git a/src/State/Processor/LinkedDataPlatformProcessor.php b/src/State/Processor/LinkedDataPlatformProcessor.php index 223b400a872..a9fddb85a38 100644 --- a/src/State/Processor/LinkedDataPlatformProcessor.php +++ b/src/State/Processor/LinkedDataPlatformProcessor.php @@ -29,15 +29,15 @@ */ final class LinkedDataPlatformProcessor implements ProcessorInterface { - private const DEFAULT_ALLOWED_METHOD = ['OPTIONS', 'HEAD']; + private const DEFAULT_ALLOWED_METHODS = ['OPTIONS', 'HEAD']; /** * @param ProcessorInterface<T1, T2> $decorated */ public function __construct( - private readonly ProcessorInterface $decorated, // todo is processor interface nullable + private readonly ProcessorInterface $decorated, private readonly ?ResourceClassResolverInterface $resourceClassResolver = null, - private readonly ?ResourceMetadataCollectionFactoryInterface $resourceCollectionMetadataFactory = null, + private readonly ?ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory = null, ) { } @@ -48,22 +48,21 @@ public function process(mixed $data, Operation $operation, array $uriVariables = !$response instanceof Response || !$operation instanceof HttpOperation || $operation instanceof Error - || null === $this->resourceCollectionMetadataFactory + || !$this->resourceMetadataCollectionFactory || !($context['resource_class'] ?? null) - || null === $operation->getUriTemplate() + || !$operation->getUriTemplate() || !$this->resourceClassResolver?->isResourceClass($context['resource_class']) ) { return $response; } - $allowedMethods = self::DEFAULT_ALLOWED_METHOD; - $resourceMetadataCollection = $this->resourceCollectionMetadataFactory->create($context['resource_class']); - foreach ($resourceMetadataCollection as $resource) { - foreach ($resource->getOperations() as $resourceOperation) { - if ($resourceOperation->getUriTemplate() === $operation->getUriTemplate()) { - $operationMethod = $resourceOperation->getMethod(); - $allowedMethods[] = $operationMethod; - if ('POST' === $operationMethod && \is_array($outputFormats = $operation->getOutputFormats())) { + $allowedMethods = self::DEFAULT_ALLOWED_METHODS; + $resourceCollection = $this->resourceMetadataCollectionFactory->create($context['resource_class']); + foreach ($resourceCollection as $resource) { + foreach ($resource->getOperations() as $op) { + if ($op->getUriTemplate() === $operation->getUriTemplate()) { + $allowedMethods[] = $method = $op->getMethod(); + if ('POST' === $method && \is_array($outputFormats = $op->getOutputFormats())) { $response->headers->set('Accept-Post', implode(', ', array_merge(...array_values($outputFormats)))); } } diff --git a/src/State/Tests/Processor/LinkedDataPlatformProcessorTest.php b/src/State/Tests/Processor/LinkedDataPlatformProcessorTest.php index 75361a41723..8018b8ba23d 100644 --- a/src/State/Tests/Processor/LinkedDataPlatformProcessorTest.php +++ b/src/State/Tests/Processor/LinkedDataPlatformProcessorTest.php @@ -53,7 +53,7 @@ protected function setUp(): void new ApiResource(operations: [ new Get(uriTemplate: '/dummy_resources/{dummyResourceId}{._format}', name: 'get'), new GetCollection(uriTemplate: '/dummy_resources{._format}', name: 'get_collections'), - new Post(uriTemplate: '/dummy_resources{._format}', name: 'post'), + new Post(uriTemplate: '/dummy_resources{._format}', outputFormats: ['jsonld' => ['application/ld+json'], 'text/turtle' => ['text/turtle']], name: 'post'), new Delete(uriTemplate: '/dummy_resources/{dummyResourceId}{._format}', name: 'delete'), new Put(uriTemplate: '/dummy_resources/{dummyResourceId}{._format}', name: 'put'), ]), @@ -66,7 +66,7 @@ protected function setUp(): void public function testHeadersAcceptPostIsReturnWhenPostAllowed(): void { - $operation = (new HttpOperation('GET', '/dummy_resources{._format}', outputFormats: ['jsonld' => ['application/ld+json'], 'text/turtle' => ['text/turtle']])); + $operation = (new HttpOperation('GET', '/dummy_resources{._format}')); $context = $this->getContext(); diff --git a/src/Symfony/Bundle/Resources/config/symfony/events.xml b/src/Symfony/Bundle/Resources/config/symfony/events.xml index fbd905bf15b..2ee684128f7 100644 --- a/src/Symfony/Bundle/Resources/config/symfony/events.xml +++ b/src/Symfony/Bundle/Resources/config/symfony/events.xml @@ -69,6 +69,8 @@ <service id="api_platform.state_processor.linked_data_platform" class="ApiPlatform\State\Processor\LinkedDataPlatformProcessor" decorates="api_platform.state_processor.respond"> <argument type="service" id="api_platform.state_processor.linked_data_platform.inner" /> + <argument type="service" id="api_platform.resource_class_resolver" /> + <argument type="service" id="api_platform.metadata.resource.metadata_collection_factory" /> </service> <service id="api_platform.listener.view.write" class="ApiPlatform\Symfony\EventListener\WriteListener"> diff --git a/tests/Functional/State/RespondProcessorTest.php b/tests/Functional/LinkDataPlatformTest.php similarity index 96% rename from tests/Functional/State/RespondProcessorTest.php rename to tests/Functional/LinkDataPlatformTest.php index f529023e588..b6ef9c608ba 100644 --- a/tests/Functional/State/RespondProcessorTest.php +++ b/tests/Functional/LinkDataPlatformTest.php @@ -11,13 +11,13 @@ declare(strict_types=1); -namespace ApiPlatform\Tests\Functional\State; +namespace ApiPlatform\Tests\Functional; use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\DummyGetPostDeleteOperation; use ApiPlatform\Tests\SetupClassResourcesTrait; -class RespondProcessorTest extends ApiTestCase +class LinkDataPlatformTest extends ApiTestCase { use SetupClassResourcesTrait;