Skip to content

Commit 7a82d04

Browse files
committed
fix(state): do not list HEAD in Allow header without GET operation
1 parent 7726f2e commit 7a82d04

3 files changed

Lines changed: 88 additions & 2 deletions

File tree

src/State/Util/HttpResponseHeadersTrait.php

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,7 @@ private function addLinkedDataPlatformHeaders(array &$headers, HttpOperation $op
155155
}
156156

157157
$acceptPost = null;
158-
$allowedMethods = ['OPTIONS', 'HEAD'];
158+
$allowedMethods = [];
159159
$resourceCollection = $this->resourceMetadataCollectionFactory->create($operation->getClass());
160160
foreach ($resourceCollection as $resource) {
161161
foreach ($resource->getOperations() as $op) {
@@ -172,6 +172,7 @@ private function addLinkedDataPlatformHeaders(array &$headers, HttpOperation $op
172172
$headers['Accept-Post'] = $acceptPost;
173173
}
174174

175-
$headers['Allow'] = implode(', ', $allowedMethods);
175+
$head = \in_array('GET', $allowedMethods, true) ? ['HEAD'] : [];
176+
$headers['Allow'] = implode(', ', array_merge(['OPTIONS'], $head, $allowedMethods));
176177
}
177178
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
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\JsonLd\PostNoOutputResource;
18+
use ApiPlatform\Tests\SetupClassResourcesTrait;
19+
20+
/**
21+
* RFC 9110 §10.2.1: the Allow header must advertise only methods that are actually
22+
* valid for the target resource. HEAD is defined as GET-without-body (§9.3.2), so a
23+
* resource that declares no GET operation does not support HEAD — a real HEAD request
24+
* returns 405. The advertised Allow header must therefore not claim HEAD either.
25+
*/
26+
final class HeadAllowWithoutGetTest extends ApiTestCase
27+
{
28+
use SetupClassResourcesTrait;
29+
30+
protected static ?bool $alwaysBootKernel = false;
31+
32+
/**
33+
* @return class-string[]
34+
*/
35+
public static function getResources(): array
36+
{
37+
return [PostNoOutputResource::class];
38+
}
39+
40+
public function testHeadIsNotAdvertisedWithoutGetOperation(): void
41+
{
42+
$client = self::createClient();
43+
44+
$client->request('HEAD', '/jsonld_post_no_output', ['headers' => ['Accept' => 'application/ld+json']]);
45+
$this->assertResponseStatusCodeSame(405);
46+
47+
$response = $client->request('POST', '/jsonld_post_no_output', [
48+
'headers' => ['Content-Type' => 'application/ld+json'],
49+
'json' => ['lorem' => 'x'],
50+
]);
51+
52+
$headers = array_change_key_case($response->getHeaders(false));
53+
$this->assertArrayHasKey('allow', $headers);
54+
$this->assertStringNotContainsString('HEAD', $headers['allow'][0]);
55+
}
56+
}

tests/State/RespondProcessorTest.php

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,35 @@ public function testAddsLinkedDataPlatformHeaders(): void
163163
$this->assertSame('application/ld+json', $response->headers->get('Accept-Post'));
164164
}
165165

166+
public function testDoesNotAdvertiseHeadWithoutGetOperation(): void
167+
{
168+
$postOperation = new Post(uriTemplate: '/employees', class: Employee::class);
169+
170+
$resourceClassResolver = $this->prophesize(ResourceClassResolverInterface::class);
171+
$resourceClassResolver->isResourceClass(Employee::class)->willReturn(true);
172+
173+
$resourceMetadataCollectionFactory = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class);
174+
$resourceMetadataCollectionFactory->create(Employee::class)->willReturn(new ResourceMetadataCollection(Employee::class, [
175+
new ApiResource(operations: [
176+
'post' => $postOperation,
177+
]),
178+
]));
179+
180+
$respondProcessor = new RespondProcessor(
181+
null,
182+
$resourceClassResolver->reveal(),
183+
null,
184+
$resourceMetadataCollectionFactory->reveal()
185+
);
186+
187+
$response = $respondProcessor->process('content', $postOperation, context: [
188+
'request' => new Request(),
189+
]);
190+
191+
$this->assertNotNull($response->headers->get('Allow'));
192+
$this->assertStringNotContainsString('HEAD', $response->headers->get('Allow'));
193+
}
194+
166195
public function testDynamicResponseStatusFromRequestAttribute(): void
167196
{
168197
$operation = new Post(class: Employee::class);

0 commit comments

Comments
 (0)