diff --git a/src/State/Util/HttpResponseHeadersTrait.php b/src/State/Util/HttpResponseHeadersTrait.php index e3f4fb01a62..6b0190c50b6 100644 --- a/src/State/Util/HttpResponseHeadersTrait.php +++ b/src/State/Util/HttpResponseHeadersTrait.php @@ -52,13 +52,26 @@ trait HttpResponseHeadersTrait private function getHeaders(Request $request, HttpOperation $operation, array $context): array { $status = $this->getStatus($request, $operation, $context); + $method = $request->getMethod(); + $output = $operation->getOutput(); + $outputMetadata = $output ?? ['class' => $operation->getClass()]; + $hasOutput = \is_array($outputMetadata) && \array_key_exists('class', $outputMetadata) && null !== $outputMetadata['class']; + $outputExplicitlyDisabled = \is_array($output) && \array_key_exists('class', $output) && null === $output['class']; + // RFC 7230 §3.3.2 / §3.3.3: 204, 205 and 304 responses MUST NOT include a payload body, + // and a sender MUST NOT generate a Content-Type field for a message without a body. + $isBodylessStatus = \in_array($status, [Response::HTTP_NO_CONTENT, Response::HTTP_RESET_CONTENT, Response::HTTP_NOT_MODIFIED], true); + $hasBody = !$outputExplicitlyDisabled && !$isBodylessStatus; + $headers = [ - 'Content-Type' => \sprintf('%s; charset=utf-8', $request->getMimeType($request->getRequestFormat())), 'Vary' => 'Accept', 'X-Content-Type-Options' => 'nosniff', 'X-Frame-Options' => 'deny', ]; + if ($hasBody) { + $headers['Content-Type'] = \sprintf('%s; charset=utf-8', $request->getMimeType($request->getRequestFormat())); + } + $exception = $request->attributes->get('exception'); if (($exception instanceof HttpExceptionInterface || $exception instanceof SymfonyHttpExceptionInterface) && $exceptionHeaders = $exception->getHeaders()) { $headers = array_merge($headers, $exceptionHeaders); @@ -76,10 +89,7 @@ private function getHeaders(Request $request, HttpOperation $operation, array $c $headers['Accept-Patch'] = $acceptPatch; } - $method = $request->getMethod(); $originalData = $context['original_data'] ?? null; - $outputMetadata = $operation->getOutput() ?? ['class' => $operation->getClass()]; - $hasOutput = \is_array($outputMetadata) && \array_key_exists('class', $outputMetadata) && null !== $outputMetadata['class']; $hasData = !$hasOutput ? false : ($this->resourceClassResolver && $originalData && \is_object($originalData) && $this->resourceClassResolver->isResourceClass($this->getObjectClass($originalData))); if ($hasData) { diff --git a/tests/State/RespondProcessorTest.php b/tests/State/RespondProcessorTest.php index 34db1b78173..92d29c31167 100644 --- a/tests/State/RespondProcessorTest.php +++ b/tests/State/RespondProcessorTest.php @@ -14,6 +14,7 @@ namespace ApiPlatform\Tests\State; use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Delete; use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\IriConverterInterface; use ApiPlatform\Metadata\Operation\Factory\OperationMetadataFactoryInterface; @@ -162,6 +163,81 @@ public function testAddsLinkedDataPlatformHeaders(): void $this->assertSame('application/ld+json', $response->headers->get('Accept-Post')); } + public function testDoesNotSetContentTypeWhenOutputIsFalse(): void + { + $operation = new Post(class: Employee::class, output: ['class' => null], status: 204); + + $resourceClassResolver = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolver->isResourceClass(Employee::class)->willReturn(true); + + $respondProcessor = new RespondProcessor(null, $resourceClassResolver->reveal()); + + $req = new Request(); + $req->setMethod('POST'); + $response = $respondProcessor->process(null, $operation, context: [ + 'request' => $req, + 'original_data' => new Employee(), + ]); + + $this->assertSame(204, $response->getStatusCode()); + $this->assertFalse($response->headers->has('Content-Type')); + } + + public function testDoesNotSetContentTypeWhenOutputIsFalseWithCreatedStatus(): void + { + $operation = new Post(class: Employee::class, output: ['class' => null], status: 201); + + $resourceClassResolver = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolver->isResourceClass(Employee::class)->willReturn(true); + + $respondProcessor = new RespondProcessor(null, $resourceClassResolver->reveal()); + + $req = new Request(); + $req->setMethod('POST'); + $response = $respondProcessor->process(null, $operation, context: [ + 'request' => $req, + 'original_data' => new Employee(), + ]); + + $this->assertSame(201, $response->getStatusCode()); + $this->assertFalse($response->headers->has('Content-Type')); + } + + public function testDoesNotSetContentTypeOnBodylessStatusCodes(): void + { + $operation = new Delete(class: Employee::class); + + $resourceClassResolver = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolver->isResourceClass(Employee::class)->willReturn(true); + + $respondProcessor = new RespondProcessor(null, $resourceClassResolver->reveal()); + + $req = new Request(); + $req->setMethod('DELETE'); + $response = $respondProcessor->process(null, $operation, context: [ + 'request' => $req, + ]); + + $this->assertSame(204, $response->getStatusCode()); + $this->assertFalse($response->headers->has('Content-Type')); + } + + public function testKeepsContentTypeForOperationWithoutOutputAndClass(): void + { + $operation = new Get(outputFormats: ['jsonld' => ['application/ld+json']], serialize: false, read: true); + + $respondProcessor = new RespondProcessor(); + + $req = new Request(); + $req->setRequestFormat('jsonld'); + $response = $respondProcessor->process('{"@context":{}}', $operation, context: [ + 'request' => $req, + ]); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('application/ld+json; charset=utf-8', $response->headers->get('Content-Type')); + } + public function testDoesNotAddLinkedDataPlatformHeadersWithoutFactory(): void { $operation = new Get(uriTemplate: '/employees/{id}', class: Employee::class);