Skip to content

Commit dcaa4eb

Browse files
committed
fix(state): omit Content-Type when response has no body
Per RFC 7230 §3.3.2, a sender MUST NOT generate a Content-Type field in a message containing no payload body, and per §3.3.3, 204, 205 and 304 responses MUST NOT include a body. HttpResponseHeadersTrait::getHeaders() now gates Content-Type behind a $hasBody check derived from output['class'] (false when output: false) and from the resolved status code (skipped on 204, 205, 304). Fixes #2929
1 parent 6fc55c2 commit dcaa4eb

2 files changed

Lines changed: 71 additions & 4 deletions

File tree

src/State/Util/HttpResponseHeadersTrait.php

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -52,13 +52,23 @@ trait HttpResponseHeadersTrait
5252
private function getHeaders(Request $request, HttpOperation $operation, array $context): array
5353
{
5454
$status = $this->getStatus($request, $operation, $context);
55+
$method = $request->getMethod();
56+
$outputMetadata = $operation->getOutput() ?? ['class' => $operation->getClass()];
57+
$hasOutput = \is_array($outputMetadata) && \array_key_exists('class', $outputMetadata) && null !== $outputMetadata['class'];
58+
// RFC 7230 §3.3.2 / §3.3.3: 204, 205 and 304 responses MUST NOT include a payload body,
59+
// and a sender MUST NOT generate a Content-Type field for a message without a body.
60+
$hasBody = $hasOutput && !\in_array($status, [Response::HTTP_NO_CONTENT, Response::HTTP_RESET_CONTENT, Response::HTTP_NOT_MODIFIED], true);
61+
5562
$headers = [
56-
'Content-Type' => \sprintf('%s; charset=utf-8', $request->getMimeType($request->getRequestFormat())),
5763
'Vary' => 'Accept',
5864
'X-Content-Type-Options' => 'nosniff',
5965
'X-Frame-Options' => 'deny',
6066
];
6167

68+
if ($hasBody) {
69+
$headers['Content-Type'] = \sprintf('%s; charset=utf-8', $request->getMimeType($request->getRequestFormat()));
70+
}
71+
6272
$exception = $request->attributes->get('exception');
6373
if (($exception instanceof HttpExceptionInterface || $exception instanceof SymfonyHttpExceptionInterface) && $exceptionHeaders = $exception->getHeaders()) {
6474
$headers = array_merge($headers, $exceptionHeaders);
@@ -76,10 +86,7 @@ private function getHeaders(Request $request, HttpOperation $operation, array $c
7686
$headers['Accept-Patch'] = $acceptPatch;
7787
}
7888

79-
$method = $request->getMethod();
8089
$originalData = $context['original_data'] ?? null;
81-
$outputMetadata = $operation->getOutput() ?? ['class' => $operation->getClass()];
82-
$hasOutput = \is_array($outputMetadata) && \array_key_exists('class', $outputMetadata) && null !== $outputMetadata['class'];
8390
$hasData = !$hasOutput ? false : ($this->resourceClassResolver && $originalData && \is_object($originalData) && $this->resourceClassResolver->isResourceClass($this->getObjectClass($originalData)));
8491

8592
if ($hasData) {

tests/State/RespondProcessorTest.php

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
namespace ApiPlatform\Tests\State;
1515

1616
use ApiPlatform\Metadata\ApiResource;
17+
use ApiPlatform\Metadata\Delete;
1718
use ApiPlatform\Metadata\Get;
1819
use ApiPlatform\Metadata\IriConverterInterface;
1920
use ApiPlatform\Metadata\Operation\Factory\OperationMetadataFactoryInterface;
@@ -162,6 +163,65 @@ public function testAddsLinkedDataPlatformHeaders(): void
162163
$this->assertSame('application/ld+json', $response->headers->get('Accept-Post'));
163164
}
164165

166+
public function testDoesNotSetContentTypeWhenOutputIsFalse(): void
167+
{
168+
$operation = new Post(class: Employee::class, output: ['class' => null], status: 204);
169+
170+
$resourceClassResolver = $this->prophesize(ResourceClassResolverInterface::class);
171+
$resourceClassResolver->isResourceClass(Employee::class)->willReturn(true);
172+
173+
$respondProcessor = new RespondProcessor(null, $resourceClassResolver->reveal());
174+
175+
$req = new Request();
176+
$req->setMethod('POST');
177+
$response = $respondProcessor->process(null, $operation, context: [
178+
'request' => $req,
179+
'original_data' => new Employee(),
180+
]);
181+
182+
$this->assertSame(204, $response->getStatusCode());
183+
$this->assertFalse($response->headers->has('Content-Type'));
184+
}
185+
186+
public function testDoesNotSetContentTypeWhenOutputIsFalseWithCreatedStatus(): void
187+
{
188+
$operation = new Post(class: Employee::class, output: ['class' => null], status: 201);
189+
190+
$resourceClassResolver = $this->prophesize(ResourceClassResolverInterface::class);
191+
$resourceClassResolver->isResourceClass(Employee::class)->willReturn(true);
192+
193+
$respondProcessor = new RespondProcessor(null, $resourceClassResolver->reveal());
194+
195+
$req = new Request();
196+
$req->setMethod('POST');
197+
$response = $respondProcessor->process(null, $operation, context: [
198+
'request' => $req,
199+
'original_data' => new Employee(),
200+
]);
201+
202+
$this->assertSame(201, $response->getStatusCode());
203+
$this->assertFalse($response->headers->has('Content-Type'));
204+
}
205+
206+
public function testDoesNotSetContentTypeOnBodylessStatusCodes(): void
207+
{
208+
$operation = new Delete(class: Employee::class);
209+
210+
$resourceClassResolver = $this->prophesize(ResourceClassResolverInterface::class);
211+
$resourceClassResolver->isResourceClass(Employee::class)->willReturn(true);
212+
213+
$respondProcessor = new RespondProcessor(null, $resourceClassResolver->reveal());
214+
215+
$req = new Request();
216+
$req->setMethod('DELETE');
217+
$response = $respondProcessor->process(null, $operation, context: [
218+
'request' => $req,
219+
]);
220+
221+
$this->assertSame(204, $response->getStatusCode());
222+
$this->assertFalse($response->headers->has('Content-Type'));
223+
}
224+
165225
public function testDoesNotAddLinkedDataPlatformHeadersWithoutFactory(): void
166226
{
167227
$operation = new Get(uriTemplate: '/employees/{id}', class: Employee::class);

0 commit comments

Comments
 (0)