Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
11 changes: 11 additions & 0 deletions src/Hydra/State/JsonStreamerProcessor.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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();
Expand Down
2 changes: 1 addition & 1 deletion src/Laravel/ApiPlatformProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
4 changes: 4 additions & 0 deletions src/Laravel/config/api-platform.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down
11 changes: 11 additions & 0 deletions src/Serializer/State/JsonStreamerProcessor.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
Expand Down
7 changes: 7 additions & 0 deletions src/State/Processor/SerializeProcessor.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ public function __construct(
private readonly ?ProcessorInterface $processor,
private readonly SerializerInterface $serializer,
private readonly SerializerContextBuilderInterface $serializerContextBuilder,
private readonly bool $enableHeadRequestOptimization = true,
) {
}

Expand All @@ -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,
Expand Down
63 changes: 63 additions & 0 deletions src/State/Tests/Processor/SerializeProcessorTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

declare(strict_types=1);

namespace ApiPlatform\State\Tests\Processor;

use ApiPlatform\Metadata\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]));
}
}
5 changes: 3 additions & 2 deletions src/State/Util/HttpResponseHeadersTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
Expand Down
1 change: 1 addition & 0 deletions src/Symfony/Bundle/DependencyInjection/Configuration.php
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
2 changes: 2 additions & 0 deletions src/Symfony/Bundle/Resources/config/json_streamer/events.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions src/Symfony/Bundle/Resources/config/json_streamer/json.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions src/Symfony/Bundle/Resources/config/state/processor.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions src/Symfony/Bundle/Resources/config/symfony/events.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
43 changes: 43 additions & 0 deletions tests/Fixtures/TestBundle/ApiResource/HeadSpyResource.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

declare(strict_types=1);

namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource;

use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\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();
}
}
59 changes: 59 additions & 0 deletions tests/Fixtures/TestBundle/State/SpyPaginator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

declare(strict_types=1);

namespace ApiPlatform\Tests\Fixtures\TestBundle\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<object>
* @implements \IteratorAggregate<object>
*/
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');
}
}
Loading
Loading