From 64a0bfe5df6f805d08863be6e90bb0bb86368dc0 Mon Sep 17 00:00:00 2001 From: LaurentHuzard Date: Fri, 19 Sep 2025 16:24:56 +0200 Subject: [PATCH] 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 | 161 ++++++++++++++++++ .../Resources/config/state/processor.xml | 6 + .../Resources/config/symfony/events.xml | 6 + .../DummyGetPostDeleteOperation.php | 63 +++++++ tests/Functional/LinkDataPlatformTest.php | 78 +++++++++ 8 files changed, 423 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 2e8a8094cbe..416fdfe2cff 100644 --- a/src/Laravel/ApiPlatformProvider.php +++ b/src/Laravel/ApiPlatformProvider.php @@ -146,6 +146,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..c59e19d7b0d --- /dev/null +++ b/src/State/Tests/Processor/LinkedDataPlatformProcessorTest.php @@ -0,0 +1,161 @@ + + * + * 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 testHeadersAcceptPostIsReturnWhenPostAllowed(): 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); + + /** @var Response $response */ + $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.xml b/src/Symfony/Bundle/Resources/config/state/processor.xml index 627d3742957..b448f4c3cf6 100644 --- a/src/Symfony/Bundle/Resources/config/state/processor.xml +++ b/src/Symfony/Bundle/Resources/config/state/processor.xml @@ -26,5 +26,11 @@ + + + + + + diff --git a/src/Symfony/Bundle/Resources/config/symfony/events.xml b/src/Symfony/Bundle/Resources/config/symfony/events.xml index a28184af50a..c7ad2b091a3 100644 --- a/src/Symfony/Bundle/Resources/config/symfony/events.xml +++ b/src/Symfony/Bundle/Resources/config/symfony/events.xml @@ -73,6 +73,12 @@ + + + + + + diff --git a/tests/Fixtures/TestBundle/ApiResource/DummyGetPostDeleteOperation.php b/tests/Fixtures/TestBundle/ApiResource/DummyGetPostDeleteOperation.php new file mode 100644 index 00000000000..d46018fb298 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/DummyGetPostDeleteOperation.php @@ -0,0 +1,63 @@ + + * + * 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..6b5d2a92a60 --- /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 testAcceptPostHeaderDoesNotExistResourceWithoutPost(): void + { + $client = static::createClient(); + $client->request('GET', '/dummy_get_post_delete_operations/1', [ + 'headers' => [ + 'Content-Type' => 'application/ld+json', + ], + ]); + + $this->assertResponseNotHasHeader('accept-post'); + } +}