[WebProfilerBundle] add debugbar on StreamedResponse

This commit is contained in:
Damien Fernandes
2024-10-11 17:37:30 +02:00
committed by Nicolas Grekas
parent 4182b66a45
commit 0d882aaa7d
4 changed files with 184 additions and 22 deletions

View File

@@ -6,6 +6,7 @@ CHANGELOG
* Add error indicator to profiler list view for profiles with errors
* Add cURL copy paste button in the Request/Response tab
* Add support for streamed responses in the debug toolbar
8.0
---

View File

@@ -19,6 +19,7 @@ use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\ServerEvent;
use Symfony\Component\HttpFoundation\Session\Flash\AutoExpireFlashBag;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\HttpKernel\DataCollector\DumpDataCollector;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;
@@ -112,7 +113,15 @@ class WebDebugToolbarListener implements EventSubscriberInterface
$session->getFlashBag()->setAll($session->getFlashBag()->peekAll());
}
$response->setContent($this->twig->render('@WebProfiler/Profiler/toolbar_redirect.html.twig', ['location' => $response->headers->get('Location'), 'host' => $request->getSchemeAndHttpHost()]));
$content = $this->twig->render('@WebProfiler/Profiler/toolbar_redirect.html.twig', ['location' => $response->headers->get('Location'), 'host' => $request->getSchemeAndHttpHost()]);
if ($response instanceof StreamedResponse) {
$response->setCallback(static function () use ($content): void {
echo $content;
});
} else {
$response->setContent($content);
}
$response->setStatusCode(200);
$response->headers->remove('Location');
}
@@ -156,24 +165,46 @@ class WebDebugToolbarListener implements EventSubscriberInterface
*/
protected function injectToolbar(Response $response, Request $request, array $nonces): void
{
$content = $response->getContent();
$pos = strripos($content, '</body>');
$responseRef = \WeakReference::create($response);
$injectToolbar = function (string $buffer) use ($request, $responseRef, $nonces): string {
if (false !== $pos = strripos($buffer, '</body>')) {
$toolbar = "\n".str_replace("\n", '', $this->getToolbarHTML($request, $responseRef->get()->headers->get('X-Debug-Token'), $nonces))."\n";
$buffer = substr($buffer, 0, $pos).$toolbar.substr($buffer, $pos);
}
if (false !== $pos) {
$toolbar = "\n".str_replace("\n", '', $this->twig->render(
'@WebProfiler/Profiler/toolbar_js.html.twig',
[
'full_stack' => class_exists(FullStack::class),
'excluded_ajax_paths' => $this->excludedAjaxPaths,
'token' => $response->headers->get('X-Debug-Token'),
'request' => $request,
'csp_script_nonce' => $nonces['csp_script_nonce'] ?? null,
'csp_style_nonce' => $nonces['csp_style_nonce'] ?? null,
]
))."\n";
$content = substr($content, 0, $pos).$toolbar.substr($content, $pos);
$response->setContent($content);
return $buffer;
};
if (!$response instanceof StreamedResponse) {
$response->setContent($injectToolbar($response->getContent()));
return;
}
$callback = $response->getCallback();
$response->setCallback(static function () use ($callback, $injectToolbar): void {
ob_start($injectToolbar, 8); // length of '</body>'
try {
$callback(...\func_get_args());
} finally {
ob_end_flush();
}
});
}
private function getToolbarHTML(Request $request, ?string $debugToken, array $nonces): string
{
return $this->twig->render(
'@WebProfiler/Profiler/toolbar_js.html.twig',
[
'full_stack' => class_exists(FullStack::class),
'excluded_ajax_paths' => $this->excludedAjaxPaths,
'token' => $debugToken,
'request' => $request,
'csp_script_nonce' => $nonces['csp_script_nonce'] ?? null,
'csp_style_nonce' => $nonces['csp_style_nonce'] ?? null,
]
);
}
public static function getSubscribedEvents(): array

View File

@@ -30,7 +30,7 @@ use Twig\Environment;
class WebDebugToolbarListenerTest extends TestCase
{
#[DataProvider('getInjectToolbarTests')]
public function testInjectToolbar($content, $expected)
public function testInjectToolbar(string $content, string $expected)
{
$listener = new WebDebugToolbarListener($this->getTwigMock());
$m = new \ReflectionMethod($listener, 'injectToolbar');
@@ -60,7 +60,7 @@ class WebDebugToolbarListenerTest extends TestCase
}
#[DataProvider('provideRedirects')]
public function testHtmlRedirectionIsIntercepted($statusCode)
public function testHtmlRedirectionIsIntercepted(int $statusCode)
{
$response = new Response('Some content', $statusCode);
$response->headers->set('Location', 'https://example.com/');
@@ -76,7 +76,7 @@ class WebDebugToolbarListenerTest extends TestCase
public function testNonHtmlRedirectionIsNotIntercepted()
{
$response = new Response('Some content', '301');
$response = new Response('Some content', 301);
$response->headers->set('Location', 'https://example.com/');
$response->headers->set('X-Debug-Token', 'xxxxxxxx');
$event = new ResponseEvent($this->createStub(KernelInterface::class), new Request([], [], ['_format' => 'json']), HttpKernelInterface::MAIN_REQUEST, $response);
@@ -131,7 +131,7 @@ class WebDebugToolbarListenerTest extends TestCase
#[DataProvider('provideRedirects')]
#[Depends('testToolbarIsInjected')]
public function testToolbarIsNotInjectedOnRedirection($statusCode)
public function testToolbarIsNotInjectedOnRedirection(int $statusCode)
{
$response = new Response('<html><head></head><body></body></html>', $statusCode);
$response->headers->set('Location', 'https://example.com/');
@@ -485,7 +485,7 @@ class WebDebugToolbarListenerTest extends TestCase
$response->send(false);
}
protected function getTwigMock($render = 'WDT')
protected function getTwigMock(string $render = 'WDT')
{
$templating = $this->createStub(Environment::class);
$templating

View File

@@ -0,0 +1,130 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Bundle\WebProfilerBundle\Tests\EventListener;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Symfony\Bundle\WebProfilerBundle\EventListener\WebDebugToolbarListener;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\HttpKernelInterface;
use Symfony\Component\HttpKernel\Kernel;
use Twig\Environment;
class WebDebugToolbarStreamedResponseListenerTest extends TestCase
{
#[DataProvider('provideInjectedToolbarHtml')]
public function testInjectToolbar(string $content, string $expected)
{
$listener = new WebDebugToolbarListener($this->getTwigStub());
$m = new \ReflectionMethod($listener, 'injectToolbar');
$response = new StreamedResponse($this->createCallbackFromContent($content));
$m->invoke($listener, $response, Request::create('/'), ['csp_script_nonce' => 'scripto', 'csp_style_nonce' => 'stylo']);
$this->assertSame($expected, $this->getContentFromStreamedResponse($response));
}
public static function provideInjectedToolbarHtml(): array
{
return [
['<html><head></head><body></body></html>', "<html><head></head><body>\nWDT\n</body></html>"],
['<html>
<head></head>
<body>
<textarea><html><head></head><body></body></html></textarea>
</body>
</html>', "<html>
<head></head>
<body>
<textarea><html><head></head><body></body></html></textarea>
\nWDT\n</body>
</html>"],
];
}
#[DataProvider('provideRedirects')]
public function testHtmlRedirectionIsIntercepted(int $statusCode)
{
$response = new StreamedResponse($this->createCallbackFromContent('Some content'), $statusCode);
$response->headers->set('Location', 'https://example.com/');
$response->headers->set('X-Debug-Token', 'xxxxxxxx');
$event = new ResponseEvent($this->createStub(Kernel::class), new Request(), HttpKernelInterface::MAIN_REQUEST, $response);
$listener = new WebDebugToolbarListener($this->getTwigStub('Redirection'), true);
$listener->onKernelResponse($event);
$this->assertSame(200, $response->getStatusCode());
$this->assertSame('Redirection', $this->getContentFromStreamedResponse($response));
}
public static function provideRedirects(): array
{
return [
[301],
[302],
];
}
public function testToolbarIsInjected()
{
$response = new StreamedResponse($this->createCallbackFromContent('<html><head></head><body></body></html>'));
$response->headers->set('X-Debug-Token', 'xxxxxxxx');
$event = new ResponseEvent($this->createStub(Kernel::class), new Request(), HttpKernelInterface::MAIN_REQUEST, $response);
$listener = new WebDebugToolbarListener($this->getTwigStub());
$listener->onKernelResponse($event);
$this->assertSame("<html><head></head><body>\nWDT\n</body></html>", $this->getContentFromStreamedResponse($response));
}
public function testToolbarIsNotInjectedOnIncompleteHtmlResponses()
{
$response = new StreamedResponse($this->createCallbackFromContent('<div>Some content</div>'));
$response->headers->set('X-Debug-Token', 'xxxxxxxx');
$event = new ResponseEvent($this->createStub(Kernel::class), new Request(), HttpKernelInterface::MAIN_REQUEST, $response);
$listener = new WebDebugToolbarListener($this->getTwigStub());
$listener->onKernelResponse($event);
$this->assertSame('<div>Some content</div>', $this->getContentFromStreamedResponse($response));
}
private function getTwigStub(string $render = 'WDT'): Environment
{
$templating = $this->createStub(Environment::class);
$templating->method('render')
->willReturn($render);
return $templating;
}
private function createCallbackFromContent(string $content): callable
{
return static function () use ($content): void {
echo $content;
};
}
private function getContentFromStreamedResponse(StreamedResponse $response): string
{
ob_start();
$response->sendContent();
$content = ob_get_contents();
ob_end_clean();
return $content;
}
}