Skip to content

Commit 7726f2e

Browse files
committed
feat(symfony): add enable_head_request_optimization config flag
HEAD responses now omit some observable headers (Content-Length, per-item cache tags); per semver that is a backward- incompatible behavior change, so this commit ships an opt-out flag — set `enable_head_request_optimization: false` to restore the prior GET-equivalent behavior.
1 parent 11e2d91 commit 7726f2e

16 files changed

Lines changed: 131 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
11
# Changelog
22

3+
HEAD requests no longer build the response body: the collection is never
4+
iterated (no row SELECT) and serialization is skipped. Two observable changes:
5+
6+
- `Content-Length` is no longer set on HEAD (RFC 9110 §9.3.2 permits this).
7+
- Cache-tag/xkey headers are not emitted on HEAD. Previously HEAD carried the
8+
same tags as GET, so cached HEAD responses were tag-purgeable; now they
9+
invalidate by TTL only. Body-less, so impact is limited to stale headers.
10+
11+
Restore the previous GET-equivalent behavior with `api_platform.enable_head_request_optimization: false`.
12+
313
## v4.3.14
414

515
### Bug fixes

src/Hydra/State/JsonStreamerProcessor.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ public function __construct(
5959
private readonly string $enabledParameterName = 'pagination',
6060
private readonly int $urlGenerationStrategy = UrlGeneratorInterface::ABS_PATH,
6161
?ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory = null,
62+
private readonly bool $enableHeadRequestOptimization = true,
6263
) {
6364
$this->resourceClassResolver = $resourceClassResolver;
6465
$this->iriConverter = $iriConverter;
@@ -79,7 +80,7 @@ public function process(mixed $data, Operation $operation, array $uriVariables =
7980
return $this->processor?->process($data, $operation, $uriVariables, $context);
8081
}
8182

82-
if ($request->isMethod('HEAD')) {
83+
if ($this->enableHeadRequestOptimization && $request->isMethod('HEAD')) {
8384
$response = new Response(
8485
null,
8586
$this->getStatus($request, $operation, $context),

src/Laravel/ApiPlatformProvider.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -553,7 +553,7 @@ public function register(): void
553553
});
554554

555555
$this->app->singleton(SerializeProcessor::class, static function (Application $app) {
556-
return new SerializeProcessor($app->make(RespondProcessor::class), $app->make(Serializer::class), $app->make(SerializerContextBuilderInterface::class));
556+
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));
557557
});
558558

559559
$this->app->singleton(WriteProcessor::class, static function (Application $app) {

src/Laravel/config/api-platform.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,10 @@
4949
// on PATCH operations, allowing partial updates without requiring all fields.
5050
'partial_patch_validation' => false,
5151

52+
// When true (default), HEAD requests skip response body construction so
53+
// collections are not iterated. Set to false to process HEAD like GET.
54+
'enable_head_request_optimization' => true,
55+
5256
'docs_formats' => [
5357
'jsonld' => ['application/ld+json'],
5458
// 'jsonapi' => ['application/vnd.api+json'],

src/Serializer/State/JsonStreamerProcessor.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ public function __construct(
4848
?ResourceClassResolverInterface $resourceClassResolver = null,
4949
?OperationMetadataFactoryInterface $operationMetadataFactory = null,
5050
?ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory = null,
51+
private readonly bool $enableHeadRequestOptimization = true,
5152
) {
5253
$this->resourceClassResolver = $resourceClassResolver;
5354
$this->iriConverter = $iriConverter;
@@ -68,7 +69,7 @@ public function process(mixed $data, Operation $operation, array $uriVariables =
6869
return $this->processor?->process($data, $operation, $uriVariables, $context);
6970
}
7071

71-
if ($request->isMethod('HEAD')) {
72+
if ($this->enableHeadRequestOptimization && $request->isMethod('HEAD')) {
7273
$response = new Response(
7374
null,
7475
$this->getStatus($request, $operation, $context),

src/State/Processor/SerializeProcessor.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ public function __construct(
4646
private readonly ?ProcessorInterface $processor,
4747
private readonly SerializerInterface $serializer,
4848
private readonly SerializerContextBuilderInterface $serializerContextBuilder,
49+
private readonly bool $enableHeadRequestOptimization = true,
4950
) {
5051
}
5152

@@ -60,7 +61,7 @@ public function process(mixed $data, Operation $operation, array $uriVariables =
6061
// @see ApiPlatform\State\Processor\RespondProcessor
6162
$context['original_data'] = $data;
6263

63-
if ($request->isMethod('HEAD')) {
64+
if ($this->enableHeadRequestOptimization && $request->isMethod('HEAD')) {
6465
$this->stopwatch?->stop('api_platform.processor.serialize');
6566

6667
return $this->processor?->process(null, $operation, $uriVariables, $context);

src/State/Tests/Processor/SerializeProcessorTest.php

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,4 +41,23 @@ public function testHeadRequestSkipsSerializationAndForwardsNull(): void
4141

4242
$this->assertNull($processor->process(new \stdClass(), $operation, [], ['request' => $request]));
4343
}
44+
45+
public function testHeadRequestSerializesWhenOptimizationDisabled(): void
46+
{
47+
$request = Request::create('/foos', 'HEAD');
48+
49+
$serializer = $this->createMock(SerializerInterface::class);
50+
$serializer->expects($this->once())->method('serialize')->willReturn('');
51+
52+
$inner = $this->createMock(ProcessorInterface::class);
53+
$inner->method('process')->willReturn('forwarded');
54+
55+
$contextBuilder = $this->createStub(SerializerContextBuilderInterface::class);
56+
$contextBuilder->method('createFromRequest')->willReturn([]);
57+
58+
$processor = new SerializeProcessor($inner, $serializer, $contextBuilder, false);
59+
$operation = (new Get())->withSerialize(true);
60+
61+
$this->assertSame('forwarded', $processor->process(new \stdClass(), $operation, [], ['request' => $request]));
62+
}
4463
}

src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,7 @@ private function registerCommonConfiguration(ContainerBuilder $container, array
355355

356356
$container->setParameter('api_platform.enable_entrypoint', $config['enable_entrypoint']);
357357
$container->setParameter('api_platform.enable_docs', $config['enable_docs']);
358+
$container->setParameter('api_platform.enable_head_request_optimization', $config['enable_head_request_optimization']);
358359
$container->setParameter('api_platform.title', $config['title']);
359360
$container->setParameter('api_platform.description', $config['description']);
360361
$container->setParameter('api_platform.version', $config['version']);

src/Symfony/Bundle/DependencyInjection/Configuration.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@ public function getConfigTreeBuilder(): TreeBuilder
130130
->booleanNode('enable_scalar')->defaultValue(class_exists(TwigBundle::class))->info('Enable Scalar API Reference')->end()
131131
->booleanNode('enable_entrypoint')->defaultTrue()->info('Enable the entrypoint')->end()
132132
->booleanNode('enable_docs')->defaultTrue()->info('Enable the docs')->end()
133+
->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()
133134
->booleanNode('enable_profiler')->defaultTrue()->info('Enable the data collector and the WebProfilerBundle integration.')->end()
134135
->booleanNode('enable_phpdoc_parser')->defaultTrue()->info('Enable resource metadata collector using PHPStan PhpDocParser.')->end()
135136
->booleanNode('enable_link_security')

src/Symfony/Bundle/Resources/config/json_streamer/events.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
'%api_platform.collection.pagination.enabled_parameter_name%',
3535
'%api_platform.url_generation_strategy%',
3636
service('api_platform.metadata.resource.metadata_collection_factory'),
37+
'%api_platform.enable_head_request_optimization%',
3738
]);
3839

3940
$services->set('api_platform.jsonld.state_provider.json_streamer', HydraJsonStreamerProvider::class)
@@ -50,6 +51,7 @@
5051
service('api_platform.resource_class_resolver'),
5152
service('api_platform.metadata.operation.metadata_factory'),
5253
service('api_platform.metadata.resource.metadata_collection_factory'),
54+
'%api_platform.enable_head_request_optimization%',
5355
]);
5456

5557
$services->set('api_platform.state_provider.json_streamer', JsonStreamerProvider::class)

0 commit comments

Comments
 (0)