From 5be52b2b771a53aeb563a7c2e63141d31a8fc8ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Dunglas?= Date: Sun, 6 Nov 2022 21:23:24 +0100 Subject: [PATCH] [HttpFoundation] Add support for the 103 status code (Early Hints) and other 1XX statuses --- UPGRADE-6.3.md | 5 ++ .../Bundle/FrameworkBundle/CHANGELOG.md | 1 + .../Controller/AbstractController.php | 29 ++++++++++ .../Resources/config/web_link.php | 8 +++ .../Controller/AbstractControllerTest.php | 18 +++++++ .../Bundle/FrameworkBundle/composer.json | 2 +- .../Component/HttpFoundation/CHANGELOG.md | 1 + .../Component/HttpFoundation/Response.php | 54 +++++++++++++++++-- .../HttpFoundation/StreamedResponse.php | 11 ++-- .../HttpFoundation/Tests/ResponseTest.php | 11 ++++ .../Tests/StreamedResponseTest.php | 11 ++++ .../EventListener/AddLinkHeaderListener.php | 8 ++- 12 files changed, 146 insertions(+), 13 deletions(-) diff --git a/UPGRADE-6.3.md b/UPGRADE-6.3.md index 703db80cccdb3..914f9b677eda6 100644 --- a/UPGRADE-6.3.md +++ b/UPGRADE-6.3.md @@ -57,6 +57,11 @@ FrameworkBundle * Deprecate the `notifier.logger_notification_listener` service, use the `notifier.notification_logger_listener` service instead +HttpFoundation +-------------- + + * `Response::sendHeaders()` now takes an optional `$statusCode` parameter + HttpKernel ---------- diff --git a/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md b/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md index 15f0060b1ca6c..f11f5fe12e25e 100644 --- a/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md +++ b/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md @@ -17,6 +17,7 @@ CHANGELOG * Allow setting `debug.container.dump` to `false` to disable dumping the container to XML * Add `framework.http_cache.skip_response_headers` option * Display warmers duration on debug verbosity for `cache:clear` command + * Add `AbstractController::sendEarlyHints()` to send HTTP Early Hints 6.2 --- diff --git a/src/Symfony/Bundle/FrameworkBundle/Controller/AbstractController.php b/src/Symfony/Bundle/FrameworkBundle/Controller/AbstractController.php index 78351af162d9d..2a2c1983166b0 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Controller/AbstractController.php +++ b/src/Symfony/Bundle/FrameworkBundle/Controller/AbstractController.php @@ -12,6 +12,7 @@ namespace Symfony\Bundle\FrameworkBundle\Controller; use Psr\Container\ContainerInterface; +use Psr\Link\EvolvableLinkInterface; use Psr\Link\LinkInterface; use Symfony\Component\DependencyInjection\Exception\ServiceNotFoundException; use Symfony\Component\DependencyInjection\ParameterBag\ContainerBagInterface; @@ -42,6 +43,7 @@ use Symfony\Component\Serializer\SerializerInterface; use Symfony\Component\WebLink\EventListener\AddLinkHeaderListener; use Symfony\Component\WebLink\GenericLinkProvider; +use Symfony\Component\WebLink\HttpHeaderSerializer; use Symfony\Contracts\Service\Attribute\Required; use Symfony\Contracts\Service\ServiceSubscriberInterface; use Twig\Environment; @@ -92,6 +94,7 @@ public static function getSubscribedServices(): array 'security.token_storage' => '?'.TokenStorageInterface::class, 'security.csrf.token_manager' => '?'.CsrfTokenManagerInterface::class, 'parameter_bag' => '?'.ContainerBagInterface::class, + 'web_link.http_header_serializer' => '?'.HttpHeaderSerializer::class, ]; } @@ -402,4 +405,30 @@ protected function addLink(Request $request, LinkInterface $link): void $request->attributes->set('_links', $linkProvider->withLink($link)); } + + /** + * @param LinkInterface[] $links + */ + protected function sendEarlyHints(iterable $links, Response $response = null): Response + { + if (!$this->container->has('web_link.http_header_serializer')) { + throw new \LogicException('You cannot use the "sendEarlyHints" method if the WebLink component is not available. Try running "composer require symfony/web-link".'); + } + + $response ??= new Response(); + + $populatedLinks = []; + foreach ($links as $link) { + if ($link instanceof EvolvableLinkInterface && !$link->getRels()) { + $link = $link->withRel('preload'); + } + + $populatedLinks[] = $link; + } + + $response->headers->set('Link', $this->container->get('web_link.http_header_serializer')->serialize($populatedLinks), false); + $response->sendHeaders(103); + + return $response; + } } diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/web_link.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/web_link.php index 0b0e79db8c1bf..64345cc997717 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/web_link.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/web_link.php @@ -12,10 +12,18 @@ namespace Symfony\Component\DependencyInjection\Loader\Configurator; use Symfony\Component\WebLink\EventListener\AddLinkHeaderListener; +use Symfony\Component\WebLink\HttpHeaderSerializer; return static function (ContainerConfigurator $container) { $container->services() + + ->set('web_link.http_header_serializer', HttpHeaderSerializer::class) + ->alias(HttpHeaderSerializer::class, 'web_link.http_header_serializer') + ->set('web_link.add_link_header_listener', AddLinkHeaderListener::class) + ->args([ + service('web_link.http_header_serializer'), + ]) ->tag('kernel.event_subscriber') ; }; diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Controller/AbstractControllerTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/Controller/AbstractControllerTest.php index 66fc713fb8a57..efa9c7becab59 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/Controller/AbstractControllerTest.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Controller/AbstractControllerTest.php @@ -45,6 +45,7 @@ use Symfony\Component\Security\Core\User\InMemoryUser; use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface; use Symfony\Component\Serializer\SerializerInterface; +use Symfony\Component\WebLink\HttpHeaderSerializer; use Symfony\Component\WebLink\Link; use Twig\Environment; @@ -72,6 +73,7 @@ public function testSubscribedServices() 'parameter_bag' => '?Symfony\\Component\\DependencyInjection\\ParameterBag\\ContainerBagInterface', 'security.token_storage' => '?Symfony\\Component\\Security\\Core\\Authentication\\Token\\Storage\\TokenStorageInterface', 'security.csrf.token_manager' => '?Symfony\\Component\\Security\\Csrf\\CsrfTokenManagerInterface', + 'web_link.http_header_serializer' => '?Symfony\\Component\\WebLink\\HttpHeaderSerializer', ]; $this->assertEquals($expectedServices, $subscribed, 'Subscribed core services in AbstractController have changed'); @@ -677,4 +679,20 @@ public function testAddLink() $this->assertContains($link1, $links); $this->assertContains($link2, $links); } + + public function testSendEarlyHints() + { + $container = new Container(); + $container->set('web_link.http_header_serializer', new HttpHeaderSerializer()); + + $controller = $this->createController(); + $controller->setContainer($container); + + $response = $controller->sendEarlyHints([ + (new Link(href: '/style.css'))->withAttribute('as', 'stylesheet'), + (new Link(href: '/script.js'))->withAttribute('as', 'script'), + ]); + + $this->assertSame('; rel="preload"; as="stylesheet",; rel="preload"; as="script"', $response->headers->get('Link')); + } } diff --git a/src/Symfony/Bundle/FrameworkBundle/composer.json b/src/Symfony/Bundle/FrameworkBundle/composer.json index d1afd4e9c04cd..cf9aa6345893e 100644 --- a/src/Symfony/Bundle/FrameworkBundle/composer.json +++ b/src/Symfony/Bundle/FrameworkBundle/composer.json @@ -25,7 +25,7 @@ "symfony/deprecation-contracts": "^2.5|^3", "symfony/error-handler": "^6.1", "symfony/event-dispatcher": "^5.4|^6.0", - "symfony/http-foundation": "^6.2", + "symfony/http-foundation": "^6.3", "symfony/http-kernel": "^6.3", "symfony/polyfill-mbstring": "~1.0", "symfony/filesystem": "^5.4|^6.0", diff --git a/src/Symfony/Component/HttpFoundation/CHANGELOG.md b/src/Symfony/Component/HttpFoundation/CHANGELOG.md index 8e70070fc8723..9b2c0bcf4e4cf 100644 --- a/src/Symfony/Component/HttpFoundation/CHANGELOG.md +++ b/src/Symfony/Component/HttpFoundation/CHANGELOG.md @@ -7,6 +7,7 @@ CHANGELOG * Add `ParameterBag::getEnum()` * Create migration for session table when pdo handler is used * Add support for Relay PHP extension for Redis + * The `Response::sendHeaders()` method now takes an optional HTTP status code as parameter, allowing to send informational responses such as Early Hints responses (103 status code) 6.2 --- diff --git a/src/Symfony/Component/HttpFoundation/Response.php b/src/Symfony/Component/HttpFoundation/Response.php index c141cbc0bb280..888c6ad858aaa 100644 --- a/src/Symfony/Component/HttpFoundation/Response.php +++ b/src/Symfony/Component/HttpFoundation/Response.php @@ -211,6 +211,11 @@ class Response 511 => 'Network Authentication Required', // RFC6585 ]; + /** + * Tracks headers already sent in informational responses. + */ + private array $sentHeaders; + /** * @param int $status The HTTP status code (200 "OK" by default) * @@ -326,21 +331,54 @@ public function prepare(Request $request): static /** * Sends HTTP headers. * + * @param null|positive-int $statusCode The status code to use, override the statusCode property if set and not null + * * @return $this */ - public function sendHeaders(): static + public function sendHeaders(/* int $statusCode = null */): static { // headers have already been sent by the developer if (headers_sent()) { return $this; } + $statusCode = \func_num_args() > 0 ? func_get_arg(0) : null; + $informationalResponse = $statusCode >= 100 && $statusCode < 200; + if ($informationalResponse && !\function_exists('headers_send')) { + // skip informational responses if not supported by the SAPI + return $this; + } + // headers foreach ($this->headers->allPreserveCaseWithoutCookies() as $name => $values) { - $replace = 0 === strcasecmp($name, 'Content-Type'); - foreach ($values as $value) { + $newValues = $values; + $replace = false; + + // As recommended by RFC 8297, PHP automatically copies headers from previous 103 responses, we need to deal with that if headers changed + if (103 === $statusCode) { + $previousValues = $this->sentHeaders[$name] ?? null; + if ($previousValues === $values) { + // Header already sent in a previous response, it will be automatically copied in this response by PHP + continue; + } + + $replace = 0 === strcasecmp($name, 'Content-Type'); + + if (null !== $previousValues && array_diff($previousValues, $values)) { + header_remove($name); + $previousValues = null; + } + + $newValues = null === $previousValues ? $values : array_diff($values, $previousValues); + } + + foreach ($newValues as $value) { header($name.': '.$value, $replace, $this->statusCode); } + + if ($informationalResponse) { + $this->sentHeaders[$name] = $values; + } } // cookies @@ -348,8 +386,16 @@ public function sendHeaders(): static header('Set-Cookie: '.$cookie, false, $this->statusCode); } + if ($informationalResponse) { + headers_send($statusCode); + + return $this; + } + + $statusCode ??= $this->statusCode; + // status - header(sprintf('HTTP/%s %s %s', $this->version, $this->statusCode, $this->statusText), true, $this->statusCode); + header(sprintf('HTTP/%s %s %s', $this->version, $statusCode, $this->statusText), true, $statusCode); return $this; } diff --git a/src/Symfony/Component/HttpFoundation/StreamedResponse.php b/src/Symfony/Component/HttpFoundation/StreamedResponse.php index 0bddcdc9bb731..2c8ff15f3650e 100644 --- a/src/Symfony/Component/HttpFoundation/StreamedResponse.php +++ b/src/Symfony/Component/HttpFoundation/StreamedResponse.php @@ -59,17 +59,22 @@ public function setCallback(callable $callback): static /** * This method only sends the headers once. * + * @param null|positive-int $statusCode The status code to use, override the statusCode property if set and not null + * * @return $this */ - public function sendHeaders(): static + public function sendHeaders(/* int $statusCode = null */): static { if ($this->headersSent) { return $this; } - $this->headersSent = true; + $statusCode = \func_num_args() > 0 ? func_get_arg(0) : null; + if ($statusCode < 100 || $statusCode >= 200) { + $this->headersSent = true; + } - return parent::sendHeaders(); + return parent::sendHeaders($statusCode); } /** diff --git a/src/Symfony/Component/HttpFoundation/Tests/ResponseTest.php b/src/Symfony/Component/HttpFoundation/Tests/ResponseTest.php index 7ab060ec19142..bf126489d45aa 100644 --- a/src/Symfony/Component/HttpFoundation/Tests/ResponseTest.php +++ b/src/Symfony/Component/HttpFoundation/Tests/ResponseTest.php @@ -42,6 +42,17 @@ public function testSendHeaders() $this->assertSame($response, $headers); } + public function testSendInformationalResponse() + { + $response = new Response(); + $response->sendHeaders(103); + + // Informational responses must not override the main status code + $this->assertSame(200, $response->getStatusCode()); + + $response->sendHeaders(); + } + public function testSend() { $response = new Response(); diff --git a/src/Symfony/Component/HttpFoundation/Tests/StreamedResponseTest.php b/src/Symfony/Component/HttpFoundation/Tests/StreamedResponseTest.php index 1ca1bb92ae377..2a2b7e7318b2e 100644 --- a/src/Symfony/Component/HttpFoundation/Tests/StreamedResponseTest.php +++ b/src/Symfony/Component/HttpFoundation/Tests/StreamedResponseTest.php @@ -124,4 +124,15 @@ public function testSetNotModified() $string = ob_get_clean(); $this->assertEmpty($string); } + + public function testSendInformationalResponse() + { + $response = new StreamedResponse(); + $response->sendHeaders(103); + + // Informational responses must not override the main status code + $this->assertSame(200, $response->getStatusCode()); + + $response->sendHeaders(); + } } diff --git a/src/Symfony/Component/WebLink/EventListener/AddLinkHeaderListener.php b/src/Symfony/Component/WebLink/EventListener/AddLinkHeaderListener.php index e769591ead767..4e344cd2427cc 100644 --- a/src/Symfony/Component/WebLink/EventListener/AddLinkHeaderListener.php +++ b/src/Symfony/Component/WebLink/EventListener/AddLinkHeaderListener.php @@ -29,11 +29,9 @@ class_exists(HttpHeaderSerializer::class); */ class AddLinkHeaderListener implements EventSubscriberInterface { - private HttpHeaderSerializer $serializer; - - public function __construct() - { - $this->serializer = new HttpHeaderSerializer(); + public function __construct( + private readonly HttpHeaderSerializer $serializer = new HttpHeaderSerializer(), + ) { } public function onKernelResponse(ResponseEvent $event): void