diff --git a/CHANGELOG.md b/CHANGELOG.md index f8539b44569..3ab7c9bea97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +HEAD requests no longer build the response body: the collection is never +iterated (no row SELECT) and serialization is skipped. Two observable changes: + +- `Content-Length` is no longer set on HEAD (RFC 9110 §9.3.2 permits this). +- Cache-tag/xkey headers are not emitted on HEAD. Previously HEAD carried the + same tags as GET, so cached HEAD responses were tag-purgeable; now they + invalidate by TTL only. Body-less, so impact is limited to stale headers. + +Restore the previous GET-equivalent behavior with `api_platform.enable_head_request_optimization: false`. + ## v4.3.14 ### Bug fixes diff --git a/src/Hydra/State/JsonStreamerProcessor.php b/src/Hydra/State/JsonStreamerProcessor.php index e2b787bd156..6b461c187fc 100644 --- a/src/Hydra/State/JsonStreamerProcessor.php +++ b/src/Hydra/State/JsonStreamerProcessor.php @@ -59,6 +59,7 @@ public function __construct( private readonly string $enabledParameterName = 'pagination', private readonly int $urlGenerationStrategy = UrlGeneratorInterface::ABS_PATH, ?ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory = null, + private readonly bool $enableHeadRequestOptimization = true, ) { $this->resourceClassResolver = $resourceClassResolver; $this->iriConverter = $iriConverter; @@ -79,6 +80,16 @@ public function process(mixed $data, Operation $operation, array $uriVariables = return $this->processor?->process($data, $operation, $uriVariables, $context); } + if ($this->enableHeadRequestOptimization && $request->isMethod('HEAD')) { + $response = new Response( + null, + $this->getStatus($request, $operation, $context), + $this->getHeaders($request, $operation, $context) + ); + + return $this->processor ? $this->processor->process($response, $operation, $uriVariables, $context) : $response; + } + if ($operation instanceof CollectionOperationInterface) { $requestUri = $request->getRequestUri() ?? ''; $collection = new Collection(); diff --git a/src/Laravel/ApiPlatformProvider.php b/src/Laravel/ApiPlatformProvider.php index 9a87f5a6fd6..da535596d59 100644 --- a/src/Laravel/ApiPlatformProvider.php +++ b/src/Laravel/ApiPlatformProvider.php @@ -553,7 +553,7 @@ public function register(): void }); $this->app->singleton(SerializeProcessor::class, static function (Application $app) { - return new SerializeProcessor($app->make(RespondProcessor::class), $app->make(Serializer::class), $app->make(SerializerContextBuilderInterface::class)); + return new SerializeProcessor($app->make(RespondProcessor::class), $app->make(Serializer::class), $app->make(SerializerContextBuilderInterface::class), $app['config']->get('api-platform.enable_head_request_optimization', true)); }); $this->app->singleton(WriteProcessor::class, static function (Application $app) { diff --git a/src/Laravel/config/api-platform.php b/src/Laravel/config/api-platform.php index 8dea04c4eb3..c89c853be27 100644 --- a/src/Laravel/config/api-platform.php +++ b/src/Laravel/config/api-platform.php @@ -49,6 +49,10 @@ // on PATCH operations, allowing partial updates without requiring all fields. 'partial_patch_validation' => false, + // When true (default), HEAD requests skip response body construction so + // collections are not iterated. Set to false to process HEAD like GET. + 'enable_head_request_optimization' => true, + 'docs_formats' => [ 'jsonld' => ['application/ld+json'], // 'jsonapi' => ['application/vnd.api+json'], diff --git a/src/Serializer/State/JsonStreamerProcessor.php b/src/Serializer/State/JsonStreamerProcessor.php index c91b43bcba2..be1a3d3d201 100644 --- a/src/Serializer/State/JsonStreamerProcessor.php +++ b/src/Serializer/State/JsonStreamerProcessor.php @@ -48,6 +48,7 @@ public function __construct( ?ResourceClassResolverInterface $resourceClassResolver = null, ?OperationMetadataFactoryInterface $operationMetadataFactory = null, ?ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory = null, + private readonly bool $enableHeadRequestOptimization = true, ) { $this->resourceClassResolver = $resourceClassResolver; $this->iriConverter = $iriConverter; @@ -68,6 +69,16 @@ public function process(mixed $data, Operation $operation, array $uriVariables = return $this->processor?->process($data, $operation, $uriVariables, $context); } + if ($this->enableHeadRequestOptimization && $request->isMethod('HEAD')) { + $response = new Response( + null, + $this->getStatus($request, $operation, $context), + $this->getHeaders($request, $operation, $context) + ); + + return $this->processor ? $this->processor->process($response, $operation, $uriVariables, $context) : $response; + } + if ($operation instanceof CollectionOperationInterface) { $data = $this->jsonStreamer->write( $data, diff --git a/src/State/Processor/SerializeProcessor.php b/src/State/Processor/SerializeProcessor.php index 8047a384899..4e206fb3cc1 100644 --- a/src/State/Processor/SerializeProcessor.php +++ b/src/State/Processor/SerializeProcessor.php @@ -46,6 +46,7 @@ public function __construct( private readonly ?ProcessorInterface $processor, private readonly SerializerInterface $serializer, private readonly SerializerContextBuilderInterface $serializerContextBuilder, + private readonly bool $enableHeadRequestOptimization = true, ) { } @@ -60,6 +61,12 @@ public function process(mixed $data, Operation $operation, array $uriVariables = // @see ApiPlatform\State\Processor\RespondProcessor $context['original_data'] = $data; + if ($this->enableHeadRequestOptimization && $request->isMethod('HEAD')) { + $this->stopwatch?->stop('api_platform.processor.serialize'); + + return $this->processor?->process(null, $operation, $uriVariables, $context); + } + $class = $operation->getClass(); $serializerContext = $this->serializerContextBuilder->createFromRequest($request, true, [ 'resource_class' => $class, diff --git a/src/State/Tests/Processor/SerializeProcessorTest.php b/src/State/Tests/Processor/SerializeProcessorTest.php new file mode 100644 index 00000000000..7fbf6111507 --- /dev/null +++ b/src/State/Tests/Processor/SerializeProcessorTest.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\State\Tests\Processor; + +use ApiPlatform\Metadata\Get; +use ApiPlatform\State\Processor\SerializeProcessor; +use ApiPlatform\State\ProcessorInterface; +use ApiPlatform\State\SerializerContextBuilderInterface; +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\Serializer\SerializerInterface; + +class SerializeProcessorTest extends TestCase +{ + public function testHeadRequestSkipsSerializationAndForwardsNull(): void + { + $request = Request::create('/foos', 'HEAD'); + + $serializer = $this->createMock(SerializerInterface::class); + $serializer->expects($this->never())->method('serialize'); + + $inner = $this->createMock(ProcessorInterface::class); + $inner->expects($this->once()) + ->method('process') + ->with($this->isNull()) + ->willReturn(null); + + $processor = new SerializeProcessor($inner, $serializer, $this->createStub(SerializerContextBuilderInterface::class)); + $operation = (new Get())->withSerialize(true); + + $this->assertNull($processor->process(new \stdClass(), $operation, [], ['request' => $request])); + } + + public function testHeadRequestSerializesWhenOptimizationDisabled(): void + { + $request = Request::create('/foos', 'HEAD'); + + $serializer = $this->createMock(SerializerInterface::class); + $serializer->expects($this->once())->method('serialize')->willReturn(''); + + $inner = $this->createMock(ProcessorInterface::class); + $inner->method('process')->willReturn('forwarded'); + + $contextBuilder = $this->createStub(SerializerContextBuilderInterface::class); + $contextBuilder->method('createFromRequest')->willReturn([]); + + $processor = new SerializeProcessor($inner, $serializer, $contextBuilder, false); + $operation = (new Get())->withSerialize(true); + + $this->assertSame('forwarded', $processor->process(new \stdClass(), $operation, [], ['request' => $request])); + } +} diff --git a/src/State/Util/HttpResponseHeadersTrait.php b/src/State/Util/HttpResponseHeadersTrait.php index 6b0190c50b6..a608706fa0d 100644 --- a/src/State/Util/HttpResponseHeadersTrait.php +++ b/src/State/Util/HttpResponseHeadersTrait.php @@ -155,7 +155,7 @@ private function addLinkedDataPlatformHeaders(array &$headers, HttpOperation $op } $acceptPost = null; - $allowedMethods = ['OPTIONS', 'HEAD']; + $allowedMethods = []; $resourceCollection = $this->resourceMetadataCollectionFactory->create($operation->getClass()); foreach ($resourceCollection as $resource) { foreach ($resource->getOperations() as $op) { @@ -172,6 +172,7 @@ private function addLinkedDataPlatformHeaders(array &$headers, HttpOperation $op $headers['Accept-Post'] = $acceptPost; } - $headers['Allow'] = implode(', ', $allowedMethods); + $head = \in_array('GET', $allowedMethods, true) ? ['HEAD'] : []; + $headers['Allow'] = implode(', ', array_merge(['OPTIONS'], $head, $allowedMethods)); } } diff --git a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php index a8140778d67..7d1552cca50 100644 --- a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -355,6 +355,7 @@ private function registerCommonConfiguration(ContainerBuilder $container, array $container->setParameter('api_platform.enable_entrypoint', $config['enable_entrypoint']); $container->setParameter('api_platform.enable_docs', $config['enable_docs']); + $container->setParameter('api_platform.enable_head_request_optimization', $config['enable_head_request_optimization']); $container->setParameter('api_platform.title', $config['title']); $container->setParameter('api_platform.description', $config['description']); $container->setParameter('api_platform.version', $config['version']); diff --git a/src/Symfony/Bundle/DependencyInjection/Configuration.php b/src/Symfony/Bundle/DependencyInjection/Configuration.php index 27f5cfbd2be..ffdbb0ba31a 100644 --- a/src/Symfony/Bundle/DependencyInjection/Configuration.php +++ b/src/Symfony/Bundle/DependencyInjection/Configuration.php @@ -130,6 +130,7 @@ public function getConfigTreeBuilder(): TreeBuilder ->booleanNode('enable_scalar')->defaultValue(class_exists(TwigBundle::class))->info('Enable Scalar API Reference')->end() ->booleanNode('enable_entrypoint')->defaultTrue()->info('Enable the entrypoint')->end() ->booleanNode('enable_docs')->defaultTrue()->info('Enable the docs')->end() + ->booleanNode('enable_head_request_optimization')->defaultTrue()->info('Skip response body construction on HEAD requests so collections are not iterated. Disable to process HEAD identically to GET.')->end() ->booleanNode('enable_profiler')->defaultTrue()->info('Enable the data collector and the WebProfilerBundle integration.')->end() ->booleanNode('enable_phpdoc_parser')->defaultTrue()->info('Enable resource metadata collector using PHPStan PhpDocParser.')->end() ->booleanNode('enable_link_security') diff --git a/src/Symfony/Bundle/Resources/config/json_streamer/events.php b/src/Symfony/Bundle/Resources/config/json_streamer/events.php index e5addb83c23..a1d0b2a7b25 100644 --- a/src/Symfony/Bundle/Resources/config/json_streamer/events.php +++ b/src/Symfony/Bundle/Resources/config/json_streamer/events.php @@ -34,6 +34,7 @@ '%api_platform.collection.pagination.enabled_parameter_name%', '%api_platform.url_generation_strategy%', service('api_platform.metadata.resource.metadata_collection_factory'), + '%api_platform.enable_head_request_optimization%', ]); $services->set('api_platform.jsonld.state_provider.json_streamer', HydraJsonStreamerProvider::class) @@ -50,6 +51,7 @@ service('api_platform.resource_class_resolver'), service('api_platform.metadata.operation.metadata_factory'), service('api_platform.metadata.resource.metadata_collection_factory'), + '%api_platform.enable_head_request_optimization%', ]); $services->set('api_platform.state_provider.json_streamer', JsonStreamerProvider::class) diff --git a/src/Symfony/Bundle/Resources/config/json_streamer/hydra.php b/src/Symfony/Bundle/Resources/config/json_streamer/hydra.php index 17fe3e72c08..e5e8a9feeb9 100644 --- a/src/Symfony/Bundle/Resources/config/json_streamer/hydra.php +++ b/src/Symfony/Bundle/Resources/config/json_streamer/hydra.php @@ -31,6 +31,7 @@ '%api_platform.collection.pagination.enabled_parameter_name%', '%api_platform.url_generation_strategy%', service('api_platform.metadata.resource.metadata_collection_factory'), + '%api_platform.enable_head_request_optimization%', ]); $services->set('api_platform.jsonld.state_provider.json_streamer', JsonStreamerProvider::class) diff --git a/src/Symfony/Bundle/Resources/config/json_streamer/json.php b/src/Symfony/Bundle/Resources/config/json_streamer/json.php index 40831d878fa..59e17a94149 100644 --- a/src/Symfony/Bundle/Resources/config/json_streamer/json.php +++ b/src/Symfony/Bundle/Resources/config/json_streamer/json.php @@ -28,6 +28,7 @@ service('api_platform.resource_class_resolver'), service('api_platform.metadata.operation.metadata_factory'), service('api_platform.metadata.resource.metadata_collection_factory'), + '%api_platform.enable_head_request_optimization%', ]); $services->set('api_platform.state_provider.json_streamer', JsonStreamerProvider::class) diff --git a/src/Symfony/Bundle/Resources/config/state/processor.php b/src/Symfony/Bundle/Resources/config/state/processor.php index f44dfb20d8f..b07670d45a8 100644 --- a/src/Symfony/Bundle/Resources/config/state/processor.php +++ b/src/Symfony/Bundle/Resources/config/state/processor.php @@ -29,6 +29,7 @@ service('api_platform.state_processor.serialize.inner'), service('api_platform.serializer'), service('api_platform.serializer.context_builder'), + '%api_platform.enable_head_request_optimization%', ]); $services->set('api_platform.state_processor.write', WriteProcessor::class) diff --git a/src/Symfony/Bundle/Resources/config/symfony/events.php b/src/Symfony/Bundle/Resources/config/symfony/events.php index 23105235373..eb43a677d1d 100644 --- a/src/Symfony/Bundle/Resources/config/symfony/events.php +++ b/src/Symfony/Bundle/Resources/config/symfony/events.php @@ -101,6 +101,7 @@ null, service('api_platform.serializer'), service('api_platform.serializer.context_builder'), + '%api_platform.enable_head_request_optimization%', ]); $services->set('api_platform.state_processor.write', WriteProcessor::class) @@ -162,6 +163,7 @@ service('api_platform.state_processor.documentation.serialize.inner'), service('api_platform.serializer'), service('api_platform.serializer.context_builder'), + '%api_platform.enable_head_request_optimization%', ]); $services->set('api_platform.state_processor.documentation.write', WriteProcessor::class) diff --git a/src/Symfony/Tests/Bundle/DependencyInjection/ApiPlatformExtensionTest.php b/src/Symfony/Tests/Bundle/DependencyInjection/ApiPlatformExtensionTest.php index 5505f07337a..8ce2bf76784 100644 --- a/src/Symfony/Tests/Bundle/DependencyInjection/ApiPlatformExtensionTest.php +++ b/src/Symfony/Tests/Bundle/DependencyInjection/ApiPlatformExtensionTest.php @@ -261,6 +261,9 @@ public function testCommonConfiguration(): void foreach ($services as $service) { $this->assertNotContainerHasService($service); } + + $this->assertTrue($this->container->hasParameter('api_platform.enable_head_request_optimization')); + $this->assertTrue($this->container->getParameter('api_platform.enable_head_request_optimization')); } public function testSwaggerUiDisabledConfiguration(): void diff --git a/tests/Fixtures/TestBundle/ApiResource/HeadSpyResource.php b/tests/Fixtures/TestBundle/ApiResource/HeadSpyResource.php new file mode 100644 index 00000000000..6c63337130f --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/HeadSpyResource.php @@ -0,0 +1,43 @@ + + * + * 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\GetCollection; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Tests\Fixtures\TestBundle\State\SpyPaginator; + +#[ApiResource( + shortName: 'HeadSpyResource', + operations: [ + new GetCollection( + uriTemplate: '/head_spy_resources', + provider: [self::class, 'provide'], + ), + new GetCollection( + uriTemplate: '/head_spy_stream_resources', + provider: [self::class, 'provide'], + jsonStream: true, + ), + ], +)] +final class HeadSpyResource +{ + public string $id = ''; + + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): SpyPaginator + { + return new SpyPaginator(); + } +} diff --git a/tests/Fixtures/TestBundle/State/SpyPaginator.php b/tests/Fixtures/TestBundle/State/SpyPaginator.php new file mode 100644 index 00000000000..0cb05130953 --- /dev/null +++ b/tests/Fixtures/TestBundle/State/SpyPaginator.php @@ -0,0 +1,59 @@ + + * + * 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\State; + +use ApiPlatform\State\Pagination\PaginatorInterface; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Exception\HttpException; + +/** + * A paginator whose scalar metadata is canned but whose rows are never meant to be + * read: getIterator() and count() throw. A HEAD request must return without iterating, + * proving no row SELECT was issued. + * + * @implements PaginatorInterface + * @implements \IteratorAggregate + */ +final class SpyPaginator implements PaginatorInterface, \IteratorAggregate +{ + public function getCurrentPage(): float + { + return 1.; + } + + public function getItemsPerPage(): float + { + return 30.; + } + + public function getLastPage(): float + { + return 1.; + } + + public function getTotalItems(): float + { + return 42.; + } + + public function count(): int + { + throw new HttpException(Response::HTTP_I_AM_A_TEAPOT, 'iterated on HEAD'); + } + + public function getIterator(): \Iterator + { + throw new HttpException(Response::HTTP_I_AM_A_TEAPOT, 'iterated on HEAD'); + } +} diff --git a/tests/Functional/HeadAllowWithoutGetTest.php b/tests/Functional/HeadAllowWithoutGetTest.php new file mode 100644 index 00000000000..09e9112ad53 --- /dev/null +++ b/tests/Functional/HeadAllowWithoutGetTest.php @@ -0,0 +1,56 @@ + + * + * 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\JsonLd\PostNoOutputResource; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +/** + * RFC 9110 §10.2.1: the Allow header must advertise only methods that are actually + * valid for the target resource. HEAD is defined as GET-without-body (§9.3.2), so a + * resource that declares no GET operation does not support HEAD — a real HEAD request + * returns 405. The advertised Allow header must therefore not claim HEAD either. + */ +final class HeadAllowWithoutGetTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [PostNoOutputResource::class]; + } + + public function testHeadIsNotAdvertisedWithoutGetOperation(): void + { + $client = self::createClient(); + + $client->request('HEAD', '/jsonld_post_no_output', ['headers' => ['Accept' => 'application/ld+json']]); + $this->assertResponseStatusCodeSame(405); + + $response = $client->request('POST', '/jsonld_post_no_output', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['lorem' => 'x'], + ]); + + $headers = array_change_key_case($response->getHeaders(false)); + $this->assertArrayHasKey('allow', $headers); + $this->assertStringNotContainsString('HEAD', $headers['allow'][0]); + } +} diff --git a/tests/Functional/HeadRequestTest.php b/tests/Functional/HeadRequestTest.php new file mode 100644 index 00000000000..1ec42d0e22f --- /dev/null +++ b/tests/Functional/HeadRequestTest.php @@ -0,0 +1,95 @@ + + * + * 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\HeadSpyResource; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use Symfony\Bundle\FrameworkBundle\Controller\ControllerHelper; +use Symfony\Component\JsonStreamer\JsonStreamWriter; + +/** + * On a HEAD request, API Platform must skip body construction so that the (lazy) + * collection is never iterated: zero row SELECT. The spy paginator throws on + * getIterator()/count(); a HEAD that does not throw proves no iteration occurred. + */ +final class HeadRequestTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [HeadSpyResource::class]; + } + + public function testHeadDoesNotIterateCollection(): void + { + $response = self::createClient()->request('HEAD', '/head_spy_resources', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertEmpty($response->getContent(false)); + + $headers = array_change_key_case($response->getHeaders(false)); + $this->assertArrayHasKey('content-type', $headers); + $this->assertStringStartsWith('application/ld+json', $headers['content-type'][0]); + $this->assertArrayHasKey('vary', $headers); + $this->assertStringContainsString('Accept', $headers['vary'][0]); + } + + public function testGetIteratesCollection(): void + { + self::createClient()->request('GET', '/head_spy_resources', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(418); + } + + public function testOptionsIsUnaffected(): void + { + $response = self::createClient()->request('OPTIONS', '/head_spy_resources', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $headers = array_change_key_case($response->getHeaders(false)); + $this->assertArrayHasKey('allow', $headers); + $this->assertStringContainsString('GET', $headers['allow'][0]); + } + + public function testHeadDoesNotIterateJsonStreamCollection(): void + { + if (false === (class_exists(ControllerHelper::class) && class_exists(JsonStreamWriter::class))) { + $this->markTestSkipped('JsonStreamer component not installed.'); + } + + $response = self::createClient()->request('HEAD', '/head_spy_stream_resources', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertEmpty($response->getContent(false)); + + $headers = array_change_key_case($response->getHeaders(false)); + $this->assertArrayHasKey('content-type', $headers); + $this->assertArrayHasKey('vary', $headers); + $this->assertStringContainsString('Accept', $headers['vary'][0]); + } +} diff --git a/tests/Functional/HeadRequestWithoutOptimizationTest.php b/tests/Functional/HeadRequestWithoutOptimizationTest.php new file mode 100644 index 00000000000..5c1ad9de14d --- /dev/null +++ b/tests/Functional/HeadRequestWithoutOptimizationTest.php @@ -0,0 +1,79 @@ + + * + * 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\HeadSpyResource; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use Symfony\Component\Config\Loader\LoaderInterface; +use Symfony\Component\DependencyInjection\ContainerBuilder; + +class HeadRequestWithoutOptimizationAppKernel extends \AppKernel +{ + public function getCacheDir(): string + { + return parent::getCacheDir().'/head_no_opt'; + } + + public function getLogDir(): string + { + return parent::getLogDir().'/head_no_opt'; + } + + protected function configureContainer(ContainerBuilder $c, LoaderInterface $loader): void + { + parent::configureContainer($c, $loader); + + $loader->load(static function (ContainerBuilder $container): void { + $container->loadFromExtension('api_platform', [ + 'enable_head_request_optimization' => false, + ]); + }); + } +} + +/** + * Opt-out: with enable_head_request_optimization disabled, a HEAD request must + * behave like GET again — the body is built, so the (lazy) collection IS iterated. + * The spy paginator throws a fixed 418 on iteration; seeing it proves the flag + * restores the previous GET-equivalent behavior. + */ +final class HeadRequestWithoutOptimizationTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [HeadSpyResource::class]; + } + + protected static function getKernelClass(): string + { + return HeadRequestWithoutOptimizationAppKernel::class; + } + + public function testHeadIteratesCollectionWhenOptimizationDisabled(): void + { + self::createClient()->request('HEAD', '/head_spy_resources', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(418); + } +} diff --git a/tests/State/RespondProcessorTest.php b/tests/State/RespondProcessorTest.php index 9e417221a76..c9ea58073af 100644 --- a/tests/State/RespondProcessorTest.php +++ b/tests/State/RespondProcessorTest.php @@ -163,6 +163,35 @@ public function testAddsLinkedDataPlatformHeaders(): void $this->assertSame('application/ld+json', $response->headers->get('Accept-Post')); } + public function testDoesNotAdvertiseHeadWithoutGetOperation(): void + { + $postOperation = new Post(uriTemplate: '/employees', class: Employee::class); + + $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: [ + 'post' => $postOperation, + ]), + ])); + + $respondProcessor = new RespondProcessor( + null, + $resourceClassResolver->reveal(), + null, + $resourceMetadataCollectionFactory->reveal() + ); + + $response = $respondProcessor->process('content', $postOperation, context: [ + 'request' => new Request(), + ]); + + $this->assertNotNull($response->headers->get('Allow')); + $this->assertStringNotContainsString('HEAD', $response->headers->get('Allow')); + } + public function testDynamicResponseStatusFromRequestAttribute(): void { $operation = new Post(class: Employee::class);