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 388bb22c140..4464c7cd1af 100644 --- a/src/Laravel/ApiPlatformProvider.php +++ b/src/Laravel/ApiPlatformProvider.php @@ -404,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') ?? []; 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/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/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/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 ef45e6edd12..98abbf00f6a 100644 --- a/src/Symfony/Bundle/Resources/config/state/processor.php +++ b/src/Symfony/Bundle/Resources/config/state/processor.php @@ -38,6 +38,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_processor.add_link_header', 'ApiPlatform\State\Processor\AddLinkHeaderProcessor') diff --git a/src/Symfony/Bundle/Resources/config/symfony/events.php b/src/Symfony/Bundle/Resources/config/symfony/events.php index 0ec1a1daafa..37adb50f0ff 100644 --- a/src/Symfony/Bundle/Resources/config/symfony/events.php +++ b/src/Symfony/Bundle/Resources/config/symfony/events.php @@ -84,6 +84,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_processor.add_link_header', 'ApiPlatform\State\Processor\AddLinkHeaderProcessor') 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/LinkedDataPlatformTest.php b/tests/Functional/LinkedDataPlatformTest.php new file mode 100644 index 00000000000..7a05b2cfddb --- /dev/null +++ b/tests/Functional/LinkedDataPlatformTest.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 LinkedDataPlatformTest 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'); + } +} 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')); + } }