From 3cbd27f35a8204c2343ff4a1af2ad0b3e394b760 Mon Sep 17 00:00:00 2001 From: LaurentHuzard Date: Fri, 19 Sep 2025 16:24:56 +0200 Subject: [PATCH 1/3] feat(state): added "Accept-Post" and "Allow" headers To ensure compliance with the LDP specification (https://www.w3.org/TR/ldp/): Added the "Accept-Post" header containing the list of supported Post formats. Added the "Allow" header with values based on the allowed operations on the queried resources. --- src/Laravel/ApiPlatformProvider.php | 9 + .../Processor/LinkedDataPlatformProcessor.php | 78 +++++++++ .../Tests/Fixtures/ApiResource/Dummy.php | 22 +++ .../LinkedDataPlatformProcessorTest.php | 160 ++++++++++++++++++ .../Resources/config/state/processor.php | 8 + .../Resources/config/symfony/events.php | 8 + .../DummyGetPostDeleteOperation.php | 66 ++++++++ tests/Functional/LinkDataPlatformTest.php | 78 +++++++++ 8 files changed, 429 insertions(+) create mode 100644 src/State/Processor/LinkedDataPlatformProcessor.php create mode 100644 src/State/Tests/Fixtures/ApiResource/Dummy.php create mode 100644 src/State/Tests/Processor/LinkedDataPlatformProcessorTest.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/DummyGetPostDeleteOperation.php create mode 100644 tests/Functional/LinkDataPlatformTest.php diff --git a/src/Laravel/ApiPlatformProvider.php b/src/Laravel/ApiPlatformProvider.php index 388bb22c140..950dfbc26f1 100644 --- a/src/Laravel/ApiPlatformProvider.php +++ b/src/Laravel/ApiPlatformProvider.php @@ -148,6 +148,7 @@ use ApiPlatform\State\Pagination\Pagination; use ApiPlatform\State\Pagination\PaginationOptions; 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; @@ -424,6 +425,14 @@ public function register(): void return new AddLinkHeaderProcessor($decorated, 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)); }); diff --git a/src/State/Processor/LinkedDataPlatformProcessor.php b/src/State/Processor/LinkedDataPlatformProcessor.php new file mode 100644 index 00000000000..d2cfadb0e7a --- /dev/null +++ b/src/State/Processor/LinkedDataPlatformProcessor.php @@ -0,0 +1,78 @@ + + * + * 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 + */ +final class LinkedDataPlatformProcessor implements ProcessorInterface +{ + private const DEFAULT_ALLOWED_METHODS = ['OPTIONS', 'HEAD']; + + /** + * @param ProcessorInterface $decorated + */ + public function __construct( + private readonly ProcessorInterface $decorated, + private readonly ResourceClassResolverInterface $resourceClassResolver, + private readonly ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, + ) { + } + + 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 + || !$operation->getUriTemplate() + || !$this->resourceClassResolver->isResourceClass($operation->getClass()) + ) { + return $response; + } + + $acceptPost = null; + $allowedMethods = self::DEFAULT_ALLOWED_METHODS; + $resourceCollection = $this->resourceMetadataCollectionFactory->create($operation->getClass()); + 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())) { + $acceptPost = implode(', ', array_merge(...array_values($outputFormats))); + } + } + } + } + if ($acceptPost) { + $response->headers->set('Accept-Post', $acceptPost); + } + + $response->headers->set('Allow', implode(', ', $allowedMethods)); + + return $response; + } +} diff --git a/src/State/Tests/Fixtures/ApiResource/Dummy.php b/src/State/Tests/Fixtures/ApiResource/Dummy.php new file mode 100644 index 00000000000..5b2e478b91a --- /dev/null +++ b/src/State/Tests/Fixtures/ApiResource/Dummy.php @@ -0,0 +1,22 @@ + + * + * 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\Fixtures\ApiResource; + +use ApiPlatform\Metadata\ApiResource; + +#[ApiResource()] +class Dummy +{ + public int $id; +} diff --git a/src/State/Tests/Processor/LinkedDataPlatformProcessorTest.php b/src/State/Tests/Processor/LinkedDataPlatformProcessorTest.php new file mode 100644 index 00000000000..c5865b7b578 --- /dev/null +++ b/src/State/Tests/Processor/LinkedDataPlatformProcessorTest.php @@ -0,0 +1,160 @@ + + * + * 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\Hal\Tests\Fixtures\Dummy; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Delete; +use ApiPlatform\Metadata\Error; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +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\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 LinkedDataPlatformProcessorTest extends TestCase +{ + private ResourceMetadataCollectionFactoryInterface&MockObject $resourceMetadataCollectionFactory; + + private ResourceClassResolverInterface&MockObject $resourceClassResolver; + + private ProcessorInterface&MockObject $decorated; + + protected function setUp(): void + { + $this->resourceClassResolver = $this->createMock(ResourceClassResolverInterface::class); + $this->resourceClassResolver + ->method('isResourceClass') + ->willReturn(true); + + $this->resourceMetadataCollectionFactory = $this->createMock(ResourceMetadataCollectionFactoryInterface::class); + $this->resourceMetadataCollectionFactory + ->method('create') + ->willReturn( + new ResourceMetadataCollection(Dummy::class, [ + new ApiResource(operations: [ + new Get(uriTemplate: '/dummy/{dummyResourceId}{._format}', class: Dummy::class, name: 'get'), + new GetCollection(uriTemplate: '/dummy{._format}', class: Dummy::class, name: 'get_collections'), + new Post(uriTemplate: '/dummy{._format}', outputFormats: ['jsonld' => ['application/ld+json'], 'text/turtle' => ['text/turtle']], class: Dummy::class, name: 'post'), + new Delete(uriTemplate: '/dummy/{dummyResourceId}{._format}', class: Dummy::class, name: 'delete'), + new Put(uriTemplate: '/dummy/{dummyResourceId}{._format}', class: Dummy::class, name: 'put'), + ]), + ]) + ); + + $this->decorated = $this->createMock(ProcessorInterface::class); + $this->decorated->method('process')->willReturn(new Response()); + } + + public function testHeadersAcceptPostIsReturnedWhenPostAllowed(): void + { + $operation = new Get('/dummy{._format}', class: Dummy::class); + + $context = $this->getContext(); + + $processor = new LinkedDataPlatformProcessor( + $this->decorated, + $this->resourceClassResolver, + $this->resourceMetadataCollectionFactory + ); + /** @var Response $response */ + $response = $processor->process(null, $operation, [], $context); + + $this->assertSame('application/ld+json, text/turtle', $response->headers->get('Accept-Post')); + } + + public function testHeadersAcceptPostIsNotSetWhenPostIsNotAllowed(): void + { + $operation = new Get('/dummy/{dummyResourceId}{._format}', class: Dummy::class); + $context = $this->getContext(); + + $processor = new LinkedDataPlatformProcessor( + $this->decorated, + $this->resourceClassResolver, + $this->resourceMetadataCollectionFactory + ); + /** @var Response $response */ + $response = $processor->process(null, $operation, [], $context); + + $this->assertNull($response->headers->get('Accept-Post')); + } + + public function testHeaderAllowReflectsResourceAllowedMethods(): void + { + $operation = new Get('/dummy{._format}', class: Dummy::class); + $context = $this->getContext(); + + $processor = new LinkedDataPlatformProcessor( + $this->decorated, + $this->resourceClassResolver, + $this->resourceMetadataCollectionFactory + ); + /** @var Response $response */ + $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); + $operation = new Get('/dummy/{dummyResourceId}{._format}', class: Dummy::class); + + $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); + } + + public function testProcessorWithoutRequiredConditionReturnOriginalResponse(): void + { + // 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')); + } + + private function createGetRequest(): Request + { + $request = new Request(); + $request->setMethod('GET'); + $request->setRequestFormat('json'); + $request->headers->set('Accept', 'application/ld+json'); + + return $request; + } + + private function getContext(): array + { + return [ + 'resource_class' => Dummy::class, + 'request' => $this->createGetRequest(), + ]; + } +} diff --git a/src/Symfony/Bundle/Resources/config/state/processor.php b/src/Symfony/Bundle/Resources/config/state/processor.php index ef45e6edd12..39ae915bc61 100644 --- a/src/Symfony/Bundle/Resources/config/state/processor.php +++ b/src/Symfony/Bundle/Resources/config/state/processor.php @@ -43,4 +43,12 @@ $services->set('api_platform.state_processor.add_link_header', 'ApiPlatform\State\Processor\AddLinkHeaderProcessor') ->decorate('api_platform.state_processor.respond', null, 0) ->args([service('api_platform.state_processor.add_link_header.inner')]); + + $services->set('api_platform.state_processor.linked_data_platform', 'ApiPlatform\State\Processor\LinkedDataPlatformProcessor') + ->decorate('api_platform.state_processor.respond', null, -10) + ->args([ + service('api_platform.state_processor.linked_data_platform.inner'), + service('api_platform.resource_class_resolver'), + service('api_platform.metadata.resource.metadata_collection_factory'), + ]); }; diff --git a/src/Symfony/Bundle/Resources/config/symfony/events.php b/src/Symfony/Bundle/Resources/config/symfony/events.php index 0ec1a1daafa..5307d84e658 100644 --- a/src/Symfony/Bundle/Resources/config/symfony/events.php +++ b/src/Symfony/Bundle/Resources/config/symfony/events.php @@ -90,6 +90,14 @@ ->decorate('api_platform.state_processor.respond', null, 0) ->args([service('api_platform.state_processor.add_link_header.inner')]); + $services->set('api_platform.state_processor.linked_data_platform', 'ApiPlatform\State\Processor\LinkedDataPlatformProcessor') + ->decorate('api_platform.state_processor.add_link_header', null, 0) + ->args([ + service('api_platform.state_processor.linked_data_platform.inner'), + service('api_platform.resource_class_resolver'), + service('api_platform.metadata.resource.metadata_collection_factory'), + ]); + $services->set('api_platform.listener.view.write', 'ApiPlatform\Symfony\EventListener\WriteListener') ->args([ service('api_platform.state_processor.write'), diff --git a/tests/Fixtures/TestBundle/ApiResource/DummyGetPostDeleteOperation.php b/tests/Fixtures/TestBundle/ApiResource/DummyGetPostDeleteOperation.php new file mode 100644 index 00000000000..e107bbb460a --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/DummyGetPostDeleteOperation.php @@ -0,0 +1,66 @@ + + * + * 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\Operation; +use ApiPlatform\Metadata\Post; + +#[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/Functional/LinkDataPlatformTest.php b/tests/Functional/LinkDataPlatformTest.php new file mode 100644 index 00000000000..3e04883ea68 --- /dev/null +++ b/tests/Functional/LinkDataPlatformTest.php @@ -0,0 +1,78 @@ + + * + * 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; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\DummyGetPostDeleteOperation; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +class LinkDataPlatformTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [DummyGetPostDeleteOperation::class]; + } + + 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'); + + $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 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 testAcceptPostHeaderDoesNotExistForResourceWithoutPost(): void + { + $client = static::createClient(); + $client->request('GET', '/dummy_get_post_delete_operations/1', [ + 'headers' => [ + 'Content-Type' => 'application/ld+json', + ], + ]); + + $this->assertResponseNotHasHeader('accept-post'); + } +} From f4a12c5ae897649366413dbbdcfb52185cdb784e Mon Sep 17 00:00:00 2001 From: LaurentHuzard Date: Tue, 13 Jan 2026 02:29:19 +0100 Subject: [PATCH 2/3] fix AddLinkHeaderProcessor initialization for Laravel --- src/Laravel/ApiPlatformProvider.php | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/Laravel/ApiPlatformProvider.php b/src/Laravel/ApiPlatformProvider.php index 950dfbc26f1..36e1e3478d0 100644 --- a/src/Laravel/ApiPlatformProvider.php +++ b/src/Laravel/ApiPlatformProvider.php @@ -422,12 +422,10 @@ public function register(): void ); } - return new AddLinkHeaderProcessor($decorated, new HttpHeaderSerializer()); - }); + $decorated = new AddLinkHeaderProcessor($decorated, new HttpHeaderSerializer()); - $this->app->singleton(LinkedDataPlatformProcessor::class, function (Application $app) { return new LinkedDataPlatformProcessor( - $app->make(AddLinkHeaderProcessor::class), // Original service + $decorated, $app->make(ResourceClassResolverInterface::class), $app->make(ResourceMetadataCollectionFactoryInterface::class) ); From 5eca1f9c3f911f1d762154bcb98a2afe231ffaf0 Mon Sep 17 00:00:00 2001 From: soyuka Date: Sat, 17 Jan 2026 08:45:00 +0100 Subject: [PATCH 3/3] Review --- src/Hydra/State/JsonStreamerProcessor.php | 3 + src/Laravel/ApiPlatformProvider.php | 17 +- .../State/JsonStreamerProcessor.php | 3 + .../Processor/LinkedDataPlatformProcessor.php | 78 --------- src/State/Processor/RespondProcessor.php | 3 + .../LinkedDataPlatformProcessorTest.php | 160 ------------------ src/State/Util/HttpResponseHeadersTrait.php | 40 +++++ .../Resources/config/json_streamer/events.php | 2 + .../Resources/config/json_streamer/hydra.php | 1 + .../Resources/config/json_streamer/json.php | 1 + .../Resources/config/state/processor.php | 9 +- .../Resources/config/symfony/events.php | 9 +- ...ormTest.php => LinkedDataPlatformTest.php} | 2 +- tests/State/RespondProcessorTest.php | 59 +++++++ 14 files changed, 123 insertions(+), 264 deletions(-) delete mode 100644 src/State/Processor/LinkedDataPlatformProcessor.php delete mode 100644 src/State/Tests/Processor/LinkedDataPlatformProcessorTest.php rename tests/Functional/{LinkDataPlatformTest.php => LinkedDataPlatformTest.php} (97%) diff --git a/src/Hydra/State/JsonStreamerProcessor.php b/src/Hydra/State/JsonStreamerProcessor.php index 967316ccd8f..e2b787bd156 100644 --- a/src/Hydra/State/JsonStreamerProcessor.php +++ b/src/Hydra/State/JsonStreamerProcessor.php @@ -22,6 +22,7 @@ use ApiPlatform\Metadata\IriConverterInterface; use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Operation\Factory\OperationMetadataFactoryInterface; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\ResourceClassResolverInterface; use ApiPlatform\Metadata\UrlGeneratorInterface; use ApiPlatform\State\Pagination\PaginatorInterface; @@ -57,10 +58,12 @@ public function __construct( private readonly string $pageParameterName = 'page', private readonly string $enabledParameterName = 'pagination', private readonly int $urlGenerationStrategy = UrlGeneratorInterface::ABS_PATH, + ?ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory = null, ) { $this->resourceClassResolver = $resourceClassResolver; $this->iriConverter = $iriConverter; $this->operationMetadataFactory = $operationMetadataFactory; + $this->resourceMetadataCollectionFactory = $resourceMetadataCollectionFactory; } public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []) diff --git a/src/Laravel/ApiPlatformProvider.php b/src/Laravel/ApiPlatformProvider.php index 36e1e3478d0..4464c7cd1af 100644 --- a/src/Laravel/ApiPlatformProvider.php +++ b/src/Laravel/ApiPlatformProvider.php @@ -148,7 +148,6 @@ use ApiPlatform\State\Pagination\Pagination; use ApiPlatform\State\Pagination\PaginationOptions; 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; @@ -405,7 +404,13 @@ public function register(): void $this->app->bind(ProviderInterface::class, ContentNegotiationProvider::class); $this->app->singleton(RespondProcessor::class, function (Application $app) { - $decorated = new RespondProcessor(); + $decorated = new RespondProcessor( + $app->make(IriConverterInterface::class), + $app->make(ResourceClassResolverInterface::class), + $app->make(OperationMetadataFactoryInterface::class), + $app->make(ResourceMetadataCollectionFactoryInterface::class) + ); + if (class_exists(AddHeadersProcessor::class)) { /** @var ConfigRepository */ $config = $app['config']->get('api-platform.http_cache') ?? []; @@ -422,13 +427,7 @@ public function register(): void ); } - $decorated = new AddLinkHeaderProcessor($decorated, new HttpHeaderSerializer()); - - return new LinkedDataPlatformProcessor( - $decorated, - $app->make(ResourceClassResolverInterface::class), - $app->make(ResourceMetadataCollectionFactoryInterface::class) - ); + return new AddLinkHeaderProcessor($decorated, new HttpHeaderSerializer()); }); $this->app->singleton(SerializeProcessor::class, function (Application $app) { diff --git a/src/Serializer/State/JsonStreamerProcessor.php b/src/Serializer/State/JsonStreamerProcessor.php index d81651bfd7c..c91b43bcba2 100644 --- a/src/Serializer/State/JsonStreamerProcessor.php +++ b/src/Serializer/State/JsonStreamerProcessor.php @@ -19,6 +19,7 @@ use ApiPlatform\Metadata\IriConverterInterface; use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Operation\Factory\OperationMetadataFactoryInterface; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\ResourceClassResolverInterface; use ApiPlatform\State\ProcessorInterface; use ApiPlatform\State\Util\HttpResponseHeadersTrait; @@ -46,10 +47,12 @@ public function __construct( ?IriConverterInterface $iriConverter = null, ?ResourceClassResolverInterface $resourceClassResolver = null, ?OperationMetadataFactoryInterface $operationMetadataFactory = null, + ?ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory = null, ) { $this->resourceClassResolver = $resourceClassResolver; $this->iriConverter = $iriConverter; $this->operationMetadataFactory = $operationMetadataFactory; + $this->resourceMetadataCollectionFactory = $resourceMetadataCollectionFactory; } public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []) diff --git a/src/State/Processor/LinkedDataPlatformProcessor.php b/src/State/Processor/LinkedDataPlatformProcessor.php deleted file mode 100644 index d2cfadb0e7a..00000000000 --- a/src/State/Processor/LinkedDataPlatformProcessor.php +++ /dev/null @@ -1,78 +0,0 @@ - - * - * 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 - */ -final class LinkedDataPlatformProcessor implements ProcessorInterface -{ - private const DEFAULT_ALLOWED_METHODS = ['OPTIONS', 'HEAD']; - - /** - * @param ProcessorInterface $decorated - */ - public function __construct( - private readonly ProcessorInterface $decorated, - private readonly ResourceClassResolverInterface $resourceClassResolver, - private readonly ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, - ) { - } - - 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 - || !$operation->getUriTemplate() - || !$this->resourceClassResolver->isResourceClass($operation->getClass()) - ) { - return $response; - } - - $acceptPost = null; - $allowedMethods = self::DEFAULT_ALLOWED_METHODS; - $resourceCollection = $this->resourceMetadataCollectionFactory->create($operation->getClass()); - 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())) { - $acceptPost = implode(', ', array_merge(...array_values($outputFormats))); - } - } - } - } - if ($acceptPost) { - $response->headers->set('Accept-Post', $acceptPost); - } - - $response->headers->set('Allow', implode(', ', $allowedMethods)); - - return $response; - } -} diff --git a/src/State/Processor/RespondProcessor.php b/src/State/Processor/RespondProcessor.php index ad2195cb9a1..dff832858ff 100644 --- a/src/State/Processor/RespondProcessor.php +++ b/src/State/Processor/RespondProcessor.php @@ -17,6 +17,7 @@ use ApiPlatform\Metadata\IriConverterInterface; use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Operation\Factory\OperationMetadataFactoryInterface; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\ResourceClassResolverInterface; use ApiPlatform\State\ProcessorInterface; use ApiPlatform\State\StopwatchAwareInterface; @@ -40,10 +41,12 @@ public function __construct( ?IriConverterInterface $iriConverter = null, ?ResourceClassResolverInterface $resourceClassResolver = null, ?OperationMetadataFactoryInterface $operationMetadataFactory = null, + ?ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory = null, ) { $this->iriConverter = $iriConverter; $this->resourceClassResolver = $resourceClassResolver; $this->operationMetadataFactory = $operationMetadataFactory; + $this->resourceMetadataCollectionFactory = $resourceMetadataCollectionFactory; } public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []) diff --git a/src/State/Tests/Processor/LinkedDataPlatformProcessorTest.php b/src/State/Tests/Processor/LinkedDataPlatformProcessorTest.php deleted file mode 100644 index c5865b7b578..00000000000 --- a/src/State/Tests/Processor/LinkedDataPlatformProcessorTest.php +++ /dev/null @@ -1,160 +0,0 @@ - - * - * 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\Hal\Tests\Fixtures\Dummy; -use ApiPlatform\Metadata\ApiResource; -use ApiPlatform\Metadata\Delete; -use ApiPlatform\Metadata\Error; -use ApiPlatform\Metadata\Get; -use ApiPlatform\Metadata\GetCollection; -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\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 LinkedDataPlatformProcessorTest extends TestCase -{ - private ResourceMetadataCollectionFactoryInterface&MockObject $resourceMetadataCollectionFactory; - - private ResourceClassResolverInterface&MockObject $resourceClassResolver; - - private ProcessorInterface&MockObject $decorated; - - protected function setUp(): void - { - $this->resourceClassResolver = $this->createMock(ResourceClassResolverInterface::class); - $this->resourceClassResolver - ->method('isResourceClass') - ->willReturn(true); - - $this->resourceMetadataCollectionFactory = $this->createMock(ResourceMetadataCollectionFactoryInterface::class); - $this->resourceMetadataCollectionFactory - ->method('create') - ->willReturn( - new ResourceMetadataCollection(Dummy::class, [ - new ApiResource(operations: [ - new Get(uriTemplate: '/dummy/{dummyResourceId}{._format}', class: Dummy::class, name: 'get'), - new GetCollection(uriTemplate: '/dummy{._format}', class: Dummy::class, name: 'get_collections'), - new Post(uriTemplate: '/dummy{._format}', outputFormats: ['jsonld' => ['application/ld+json'], 'text/turtle' => ['text/turtle']], class: Dummy::class, name: 'post'), - new Delete(uriTemplate: '/dummy/{dummyResourceId}{._format}', class: Dummy::class, name: 'delete'), - new Put(uriTemplate: '/dummy/{dummyResourceId}{._format}', class: Dummy::class, name: 'put'), - ]), - ]) - ); - - $this->decorated = $this->createMock(ProcessorInterface::class); - $this->decorated->method('process')->willReturn(new Response()); - } - - public function testHeadersAcceptPostIsReturnedWhenPostAllowed(): void - { - $operation = new Get('/dummy{._format}', class: Dummy::class); - - $context = $this->getContext(); - - $processor = new LinkedDataPlatformProcessor( - $this->decorated, - $this->resourceClassResolver, - $this->resourceMetadataCollectionFactory - ); - /** @var Response $response */ - $response = $processor->process(null, $operation, [], $context); - - $this->assertSame('application/ld+json, text/turtle', $response->headers->get('Accept-Post')); - } - - public function testHeadersAcceptPostIsNotSetWhenPostIsNotAllowed(): void - { - $operation = new Get('/dummy/{dummyResourceId}{._format}', class: Dummy::class); - $context = $this->getContext(); - - $processor = new LinkedDataPlatformProcessor( - $this->decorated, - $this->resourceClassResolver, - $this->resourceMetadataCollectionFactory - ); - /** @var Response $response */ - $response = $processor->process(null, $operation, [], $context); - - $this->assertNull($response->headers->get('Accept-Post')); - } - - public function testHeaderAllowReflectsResourceAllowedMethods(): void - { - $operation = new Get('/dummy{._format}', class: Dummy::class); - $context = $this->getContext(); - - $processor = new LinkedDataPlatformProcessor( - $this->decorated, - $this->resourceClassResolver, - $this->resourceMetadataCollectionFactory - ); - /** @var Response $response */ - $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); - $operation = new Get('/dummy/{dummyResourceId}{._format}', class: Dummy::class); - - $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); - } - - public function testProcessorWithoutRequiredConditionReturnOriginalResponse(): void - { - // 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')); - } - - private function createGetRequest(): Request - { - $request = new Request(); - $request->setMethod('GET'); - $request->setRequestFormat('json'); - $request->headers->set('Accept', 'application/ld+json'); - - return $request; - } - - private function getContext(): array - { - return [ - 'resource_class' => Dummy::class, - 'request' => $this->createGetRequest(), - ]; - } -} diff --git a/src/State/Util/HttpResponseHeadersTrait.php b/src/State/Util/HttpResponseHeadersTrait.php index ec80d85b913..e3f4fb01a62 100644 --- a/src/State/Util/HttpResponseHeadersTrait.php +++ b/src/State/Util/HttpResponseHeadersTrait.php @@ -13,6 +13,7 @@ namespace ApiPlatform\State\Util; +use ApiPlatform\Metadata\Error; use ApiPlatform\Metadata\Exception\HttpExceptionInterface; use ApiPlatform\Metadata\Exception\InvalidArgumentException; use ApiPlatform\Metadata\Exception\ItemNotFoundException; @@ -20,6 +21,8 @@ use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\IriConverterInterface; use ApiPlatform\Metadata\Operation\Factory\OperationMetadataFactoryInterface; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\ResourceClassResolverInterface; use ApiPlatform\Metadata\UrlGeneratorInterface; use ApiPlatform\Metadata\Util\ClassInfoTrait; use ApiPlatform\Metadata\Util\CloneTrait; @@ -38,6 +41,8 @@ trait HttpResponseHeadersTrait use CloneTrait; private ?IriConverterInterface $iriConverter; private ?OperationMetadataFactoryInterface $operationMetadataFactory; + private ?ResourceClassResolverInterface $resourceClassResolver; + private ?ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory; /** * @param array $context @@ -122,6 +127,41 @@ private function getHeaders(Request $request, HttpOperation $operation, array $c } } + if ( + !$operation instanceof Error + && $operation->getUriTemplate() + && $this->resourceClassResolver?->isResourceClass($operation->getClass()) + ) { + $this->addLinkedDataPlatformHeaders($headers, $operation); + } + return $headers; } + + private function addLinkedDataPlatformHeaders(array &$headers, HttpOperation $operation): void + { + if (!$this->resourceMetadataCollectionFactory) { + return; + } + + $acceptPost = null; + $allowedMethods = ['OPTIONS', 'HEAD']; + $resourceCollection = $this->resourceMetadataCollectionFactory->create($operation->getClass()); + 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())) { + $acceptPost = implode(', ', array_merge(...array_values($outputFormats))); + } + } + } + } + + if ($acceptPost) { + $headers['Accept-Post'] = $acceptPost; + } + + $headers['Allow'] = implode(', ', $allowedMethods); + } } diff --git a/src/Symfony/Bundle/Resources/config/json_streamer/events.php b/src/Symfony/Bundle/Resources/config/json_streamer/events.php index a2a513a561d..1c2fcb9f36f 100644 --- a/src/Symfony/Bundle/Resources/config/json_streamer/events.php +++ b/src/Symfony/Bundle/Resources/config/json_streamer/events.php @@ -26,6 +26,7 @@ '%api_platform.collection.pagination.page_parameter_name%', '%api_platform.collection.pagination.enabled_parameter_name%', '%api_platform.url_generation_strategy%', + service('api_platform.metadata.resource.metadata_collection_factory'), ]); $services->set('api_platform.jsonld.state_provider.json_streamer', 'ApiPlatform\Hydra\State\JsonStreamerProvider') @@ -41,6 +42,7 @@ service('api_platform.iri_converter'), service('api_platform.resource_class_resolver'), service('api_platform.metadata.operation.metadata_factory'), + service('api_platform.metadata.resource.metadata_collection_factory'), ]); $services->set('api_platform.state_provider.json_streamer', 'ApiPlatform\Serializer\State\JsonStreamerProvider') diff --git a/src/Symfony/Bundle/Resources/config/json_streamer/hydra.php b/src/Symfony/Bundle/Resources/config/json_streamer/hydra.php index 43bb5d07f8d..1cf668b9872 100644 --- a/src/Symfony/Bundle/Resources/config/json_streamer/hydra.php +++ b/src/Symfony/Bundle/Resources/config/json_streamer/hydra.php @@ -27,6 +27,7 @@ '%api_platform.collection.pagination.page_parameter_name%', '%api_platform.collection.pagination.enabled_parameter_name%', '%api_platform.url_generation_strategy%', + service('api_platform.metadata.resource.metadata_collection_factory'), ]); $services->set('api_platform.jsonld.state_provider.json_streamer', 'ApiPlatform\Hydra\State\JsonStreamerProvider') diff --git a/src/Symfony/Bundle/Resources/config/json_streamer/json.php b/src/Symfony/Bundle/Resources/config/json_streamer/json.php index 88c3665c9d8..a848a6d97c3 100644 --- a/src/Symfony/Bundle/Resources/config/json_streamer/json.php +++ b/src/Symfony/Bundle/Resources/config/json_streamer/json.php @@ -24,6 +24,7 @@ service('api_platform.iri_converter'), service('api_platform.resource_class_resolver'), service('api_platform.metadata.operation.metadata_factory'), + service('api_platform.metadata.resource.metadata_collection_factory'), ]); $services->set('api_platform.state_provider.json_streamer', 'ApiPlatform\Serializer\State\JsonStreamerProvider') diff --git a/src/Symfony/Bundle/Resources/config/state/processor.php b/src/Symfony/Bundle/Resources/config/state/processor.php index 39ae915bc61..98abbf00f6a 100644 --- a/src/Symfony/Bundle/Resources/config/state/processor.php +++ b/src/Symfony/Bundle/Resources/config/state/processor.php @@ -38,17 +38,10 @@ service('api_platform.iri_converter'), service('api_platform.resource_class_resolver'), service('api_platform.metadata.operation.metadata_factory'), + service('api_platform.metadata.resource.metadata_collection_factory'), ]); $services->set('api_platform.state_processor.add_link_header', 'ApiPlatform\State\Processor\AddLinkHeaderProcessor') ->decorate('api_platform.state_processor.respond', null, 0) ->args([service('api_platform.state_processor.add_link_header.inner')]); - - $services->set('api_platform.state_processor.linked_data_platform', 'ApiPlatform\State\Processor\LinkedDataPlatformProcessor') - ->decorate('api_platform.state_processor.respond', null, -10) - ->args([ - service('api_platform.state_processor.linked_data_platform.inner'), - service('api_platform.resource_class_resolver'), - service('api_platform.metadata.resource.metadata_collection_factory'), - ]); }; diff --git a/src/Symfony/Bundle/Resources/config/symfony/events.php b/src/Symfony/Bundle/Resources/config/symfony/events.php index 5307d84e658..37adb50f0ff 100644 --- a/src/Symfony/Bundle/Resources/config/symfony/events.php +++ b/src/Symfony/Bundle/Resources/config/symfony/events.php @@ -84,20 +84,13 @@ service('api_platform.iri_converter'), service('api_platform.resource_class_resolver'), service('api_platform.metadata.operation.metadata_factory'), + service('api_platform.metadata.resource.metadata_collection_factory'), ]); $services->set('api_platform.state_processor.add_link_header', 'ApiPlatform\State\Processor\AddLinkHeaderProcessor') ->decorate('api_platform.state_processor.respond', null, 0) ->args([service('api_platform.state_processor.add_link_header.inner')]); - $services->set('api_platform.state_processor.linked_data_platform', 'ApiPlatform\State\Processor\LinkedDataPlatformProcessor') - ->decorate('api_platform.state_processor.add_link_header', null, 0) - ->args([ - service('api_platform.state_processor.linked_data_platform.inner'), - service('api_platform.resource_class_resolver'), - service('api_platform.metadata.resource.metadata_collection_factory'), - ]); - $services->set('api_platform.listener.view.write', 'ApiPlatform\Symfony\EventListener\WriteListener') ->args([ service('api_platform.state_processor.write'), diff --git a/tests/Functional/LinkDataPlatformTest.php b/tests/Functional/LinkedDataPlatformTest.php similarity index 97% rename from tests/Functional/LinkDataPlatformTest.php rename to tests/Functional/LinkedDataPlatformTest.php index 3e04883ea68..7a05b2cfddb 100644 --- a/tests/Functional/LinkDataPlatformTest.php +++ b/tests/Functional/LinkedDataPlatformTest.php @@ -17,7 +17,7 @@ use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\DummyGetPostDeleteOperation; use ApiPlatform\Tests\SetupClassResourcesTrait; -class LinkDataPlatformTest extends ApiTestCase +class LinkedDataPlatformTest extends ApiTestCase { use SetupClassResourcesTrait; diff --git a/tests/State/RespondProcessorTest.php b/tests/State/RespondProcessorTest.php index 7f17340c7a9..34db1b78173 100644 --- a/tests/State/RespondProcessorTest.php +++ b/tests/State/RespondProcessorTest.php @@ -13,9 +13,13 @@ namespace ApiPlatform\Tests\State; +use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\IriConverterInterface; use ApiPlatform\Metadata\Operation\Factory\OperationMetadataFactoryInterface; +use ApiPlatform\Metadata\Post; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; use ApiPlatform\Metadata\ResourceClassResolverInterface; use ApiPlatform\State\Processor\RespondProcessor; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Employee; @@ -125,4 +129,59 @@ public function testAddsHeaders(): void $this->assertSame('bar', $response->headers->get('foo')); } + + public function testAddsLinkedDataPlatformHeaders(): void + { + $getOperation = new Get(uriTemplate: '/employees/{id}', class: Employee::class); + $postOperation = new Post(uriTemplate: '/employees/{id}', class: Employee::class, outputFormats: ['jsonld' => ['application/ld+json']]); + + $resourceClassResolver = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolver->isResourceClass(Employee::class)->willReturn(true); + + $resourceMetadataCollectionFactory = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataCollectionFactory->create(Employee::class)->willReturn(new ResourceMetadataCollection(Employee::class, [ + new ApiResource(operations: [ + 'get' => $getOperation, + 'post' => $postOperation, + ]), + ])); + + $respondProcessor = new RespondProcessor( + null, + $resourceClassResolver->reveal(), + null, + $resourceMetadataCollectionFactory->reveal() + ); + + $req = new Request(); + $response = $respondProcessor->process('content', $getOperation, context: [ + 'request' => $req, + ]); + + $this->assertSame('OPTIONS, HEAD, GET, POST', $response->headers->get('Allow')); + $this->assertSame('application/ld+json', $response->headers->get('Accept-Post')); + } + + public function testDoesNotAddLinkedDataPlatformHeadersWithoutFactory(): void + { + $operation = new Get(uriTemplate: '/employees/{id}', class: Employee::class); + + $resourceClassResolver = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolver->isResourceClass(Employee::class)->willReturn(true); + + $respondProcessor = new RespondProcessor( + null, + $resourceClassResolver->reveal(), + null, + null // No ResourceMetadataCollectionFactory + ); + + $req = new Request(); + $response = $respondProcessor->process('content', $operation, context: [ + 'request' => $req, + ]); + + $this->assertNull($response->headers->get('Allow')); + $this->assertNull($response->headers->get('Accept-Post')); + } }