Skip to content

Commit c88d8ac

Browse files
committed
perf(state): skip response body on HEAD requests
1 parent 3caae14 commit c88d8ac

8 files changed

Lines changed: 277 additions & 0 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: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,16 @@ public function process(mixed $data, Operation $operation, array $uriVariables =
7979
return $this->processor?->process($data, $operation, $uriVariables, $context);
8080
}
8181

82+
if ($request->isMethod('HEAD')) {
83+
$response = new Response(
84+
null,
85+
$this->getStatus($request, $operation, $context),
86+
$this->getHeaders($request, $operation, $context)
87+
);
88+
89+
return $this->processor ? $this->processor->process($response, $operation, $uriVariables, $context) : $response;
90+
}
91+
8292
if ($operation instanceof CollectionOperationInterface) {
8393
$requestUri = $request->getRequestUri() ?? '';
8494
$collection = new Collection();

src/Serializer/State/JsonStreamerProcessor.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,16 @@ public function process(mixed $data, Operation $operation, array $uriVariables =
6868
return $this->processor?->process($data, $operation, $uriVariables, $context);
6969
}
7070

71+
if ($request->isMethod('HEAD')) {
72+
$response = new Response(
73+
null,
74+
$this->getStatus($request, $operation, $context),
75+
$this->getHeaders($request, $operation, $context)
76+
);
77+
78+
return $this->processor ? $this->processor->process($response, $operation, $uriVariables, $context) : $response;
79+
}
80+
7181
if ($operation instanceof CollectionOperationInterface) {
7282
$data = $this->jsonStreamer->write(
7383
$data,

src/State/Processor/SerializeProcessor.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,12 @@ public function process(mixed $data, Operation $operation, array $uriVariables =
6060
// @see ApiPlatform\State\Processor\RespondProcessor
6161
$context['original_data'] = $data;
6262

63+
if ($request->isMethod('HEAD')) {
64+
$this->stopwatch?->stop('api_platform.processor.serialize');
65+
66+
return $this->processor?->process(null, $operation, $uriVariables, $context);
67+
}
68+
6369
$class = $operation->getClass();
6470
$serializerContext = $this->serializerContextBuilder->createFromRequest($request, true, [
6571
'resource_class' => $class,
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\State\Tests\Processor;
15+
16+
use ApiPlatform\Metadata\Get;
17+
use ApiPlatform\State\Processor\SerializeProcessor;
18+
use ApiPlatform\State\ProcessorInterface;
19+
use ApiPlatform\State\SerializerContextBuilderInterface;
20+
use PHPUnit\Framework\TestCase;
21+
use Symfony\Component\HttpFoundation\Request;
22+
use Symfony\Component\Serializer\SerializerInterface;
23+
24+
class SerializeProcessorTest extends TestCase
25+
{
26+
public function testHeadRequestSkipsSerializationAndForwardsNull(): void
27+
{
28+
$request = Request::create('/foos', 'HEAD');
29+
30+
$serializer = $this->createMock(SerializerInterface::class);
31+
$serializer->expects($this->never())->method('serialize');
32+
33+
$inner = $this->createMock(ProcessorInterface::class);
34+
$inner->expects($this->once())
35+
->method('process')
36+
->with($this->isNull())
37+
->willReturn(null);
38+
39+
$processor = new SerializeProcessor($inner, $serializer, $this->createStub(SerializerContextBuilderInterface::class));
40+
$operation = (new Get())->withSerialize(true);
41+
42+
$this->assertNull($processor->process(new \stdClass(), $operation, [], ['request' => $request]));
43+
}
44+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource;
15+
16+
use ApiPlatform\Metadata\ApiResource;
17+
use ApiPlatform\Metadata\GetCollection;
18+
use ApiPlatform\Metadata\Operation;
19+
use ApiPlatform\Tests\Fixtures\TestBundle\State\SpyPaginator;
20+
21+
#[ApiResource(
22+
shortName: 'HeadSpyResource',
23+
operations: [
24+
new GetCollection(
25+
uriTemplate: '/head_spy_resources',
26+
provider: [self::class, 'provide'],
27+
),
28+
new GetCollection(
29+
uriTemplate: '/head_spy_stream_resources',
30+
provider: [self::class, 'provide'],
31+
jsonStream: true,
32+
),
33+
],
34+
)]
35+
final class HeadSpyResource
36+
{
37+
public string $id = '';
38+
39+
public static function provide(Operation $operation, array $uriVariables = [], array $context = []): SpyPaginator
40+
{
41+
return new SpyPaginator();
42+
}
43+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Tests\Fixtures\TestBundle\State;
15+
16+
use ApiPlatform\State\Pagination\PaginatorInterface;
17+
18+
/**
19+
* A paginator whose scalar metadata is canned but whose rows are never meant to be
20+
* read: getIterator() and count() throw. A HEAD request must return without iterating,
21+
* proving no row SELECT was issued.
22+
*
23+
* @implements PaginatorInterface<object>
24+
* @implements \IteratorAggregate<object>
25+
*/
26+
final class SpyPaginator implements PaginatorInterface, \IteratorAggregate
27+
{
28+
public function getCurrentPage(): float
29+
{
30+
return 1.;
31+
}
32+
33+
public function getItemsPerPage(): float
34+
{
35+
return 30.;
36+
}
37+
38+
public function getLastPage(): float
39+
{
40+
return 1.;
41+
}
42+
43+
public function getTotalItems(): float
44+
{
45+
return 42.;
46+
}
47+
48+
public function count(): int
49+
{
50+
throw new \LogicException('iterated on HEAD');
51+
}
52+
53+
public function getIterator(): \Iterator
54+
{
55+
throw new \LogicException('iterated on HEAD');
56+
}
57+
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Tests\Functional;
15+
16+
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
17+
use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\HeadSpyResource;
18+
use ApiPlatform\Tests\SetupClassResourcesTrait;
19+
use Symfony\Bundle\FrameworkBundle\Controller\ControllerHelper;
20+
use Symfony\Component\JsonStreamer\JsonStreamWriter;
21+
22+
/**
23+
* On a HEAD request, API Platform must skip body construction so that the (lazy)
24+
* collection is never iterated: zero row SELECT. The spy paginator throws on
25+
* getIterator()/count(); a HEAD that does not throw proves no iteration occurred.
26+
*/
27+
final class HeadRequestTest extends ApiTestCase
28+
{
29+
use SetupClassResourcesTrait;
30+
31+
protected static ?bool $alwaysBootKernel = false;
32+
33+
/**
34+
* @return class-string[]
35+
*/
36+
public static function getResources(): array
37+
{
38+
return [HeadSpyResource::class];
39+
}
40+
41+
public function testHeadDoesNotIterateCollection(): void
42+
{
43+
$response = self::createClient()->request('HEAD', '/head_spy_resources', [
44+
'headers' => ['Accept' => 'application/ld+json'],
45+
]);
46+
47+
$this->assertResponseStatusCodeSame(200);
48+
$this->assertEmpty($response->getContent(false));
49+
50+
$headers = array_change_key_case($response->getHeaders(false));
51+
$this->assertArrayHasKey('content-type', $headers);
52+
$this->assertStringStartsWith('application/ld+json', $headers['content-type'][0]);
53+
$this->assertArrayHasKey('vary', $headers);
54+
$this->assertStringContainsString('Accept', $headers['vary'][0]);
55+
}
56+
57+
public function testGetIteratesCollection(): void
58+
{
59+
// Sanity check: the spy throws when iterated, so GET (which builds the body)
60+
// must surface a 500. Without this, the HEAD assertion above would be vacuous.
61+
self::createClient()->request('GET', '/head_spy_resources', [
62+
'headers' => ['Accept' => 'application/ld+json'],
63+
]);
64+
65+
$this->assertResponseStatusCodeSame(500);
66+
}
67+
68+
public function testOptionsIsUnaffected(): void
69+
{
70+
$response = self::createClient()->request('OPTIONS', '/head_spy_resources', [
71+
'headers' => ['Accept' => 'application/ld+json'],
72+
]);
73+
74+
$headers = array_change_key_case($response->getHeaders(false));
75+
$this->assertArrayHasKey('allow', $headers);
76+
$this->assertStringContainsString('GET', $headers['allow'][0]);
77+
}
78+
79+
public function testHeadDoesNotIterateJsonStreamCollection(): void
80+
{
81+
if (false === (class_exists(ControllerHelper::class) && class_exists(JsonStreamWriter::class))) {
82+
$this->markTestSkipped('JsonStreamer component not installed.');
83+
}
84+
85+
$response = self::createClient()->request('HEAD', '/head_spy_stream_resources', [
86+
'headers' => ['Accept' => 'application/ld+json'],
87+
]);
88+
89+
$this->assertResponseStatusCodeSame(200);
90+
$this->assertEmpty($response->getContent(false));
91+
92+
$headers = array_change_key_case($response->getHeaders(false));
93+
$this->assertArrayHasKey('content-type', $headers);
94+
$this->assertArrayHasKey('vary', $headers);
95+
$this->assertStringContainsString('Accept', $headers['vary'][0]);
96+
}
97+
}

0 commit comments

Comments
 (0)