From 51640012f14a3533d9d1887be68470a2edeededa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9my=20Romey?= Date: Fri, 8 Mar 2019 17:59:13 +0100 Subject: [PATCH] [HttpClient] Added TraceableHttpClient and WebProfiler panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Jérémy Romey Co-authored-by: Timothée Barray --- .../Compiler/UnusedTagsPass.php | 1 + .../FrameworkExtension.php | 25 ++- .../FrameworkBundle/FrameworkBundle.php | 2 + .../Resources/config/http_client.xml | 1 + .../Resources/config/http_client_debug.xml | 12 ++ .../Bundle/FrameworkBundle/composer.json | 3 +- .../Bundle/WebProfilerBundle/CHANGELOG.md | 1 + .../views/Collector/http_client.html.twig | 98 ++++++++++ .../Resources/views/Icon/http-client.svg | 1 + src/Symfony/Component/HttpClient/CHANGELOG.md | 3 +- .../DataCollector/HttpClientDataCollector.php | 142 ++++++++++++++ .../DependencyInjection/HttpClientPass.php | 45 +++++ .../HttpClientDataCollectorTest.php | 178 ++++++++++++++++++ .../HttpClientPassTest.php | 65 +++++++ .../Tests/TraceableHttpClientTest.php | 82 ++++++++ .../HttpClient/TraceableHttpClient.php | 73 +++++++ .../Component/HttpClient/composer.json | 5 +- 17 files changed, 727 insertions(+), 10 deletions(-) create mode 100644 src/Symfony/Bundle/FrameworkBundle/Resources/config/http_client_debug.xml create mode 100644 src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/http_client.html.twig create mode 100644 src/Symfony/Bundle/WebProfilerBundle/Resources/views/Icon/http-client.svg create mode 100644 src/Symfony/Component/HttpClient/DataCollector/HttpClientDataCollector.php create mode 100755 src/Symfony/Component/HttpClient/DependencyInjection/HttpClientPass.php create mode 100755 src/Symfony/Component/HttpClient/Tests/DataCollector/HttpClientDataCollectorTest.php create mode 100755 src/Symfony/Component/HttpClient/Tests/DependencyInjection/HttpClientPassTest.php create mode 100755 src/Symfony/Component/HttpClient/Tests/TraceableHttpClientTest.php create mode 100644 src/Symfony/Component/HttpClient/TraceableHttpClient.php diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php index 00fc826cf2..532b0af819 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php @@ -35,6 +35,7 @@ class UnusedTagsPass implements CompilerPassInterface 'form.type', 'form.type_extension', 'form.type_guesser', + 'http_client.client', 'kernel.cache_clearer', 'kernel.cache_warmer', 'kernel.event_listener', diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index e458a0b623..268643556d 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -150,6 +150,7 @@ class FrameworkExtension extends Extension private $validatorConfigEnabled = false; private $messengerConfigEnabled = false; private $mailerConfigEnabled = false; + private $httpClientConfigEnabled = false; /** * Responds to the app.config configuration parameter. @@ -311,6 +312,10 @@ class FrameworkExtension extends Extension $container->removeDefinition('console.command.messenger_failed_messages_remove'); } + if ($this->httpClientConfigEnabled = $this->isConfigEnabled($container, $config['http_client'])) { + $this->registerHttpClientConfiguration($config['http_client'], $container, $loader, $config['profiler']); + } + $propertyInfoEnabled = $this->isConfigEnabled($container, $config['property_info']); $this->registerValidationConfiguration($config['validation'], $container, $loader, $propertyInfoEnabled); $this->registerEsiConfiguration($config['esi'], $container, $loader); @@ -341,10 +346,6 @@ class FrameworkExtension extends Extension $this->registerLockConfiguration($config['lock'], $container, $loader); } - if ($this->isConfigEnabled($container, $config['http_client'])) { - $this->registerHttpClientConfiguration($config['http_client'], $container, $loader); - } - if ($this->mailerConfigEnabled = $this->isConfigEnabled($container, $config['mailer'])) { $this->registerMailerConfiguration($config['mailer'], $container, $loader); } @@ -562,6 +563,10 @@ class FrameworkExtension extends Extension $loader->load('mailer_debug.xml'); } + if ($this->httpClientConfigEnabled) { + $loader->load('http_client_debug.xml'); + } + $container->setParameter('profiler_listener.only_exceptions', $config['only_exceptions']); $container->setParameter('profiler_listener.only_master_requests', $config['only_master_requests']); @@ -1915,7 +1920,7 @@ class FrameworkExtension extends Extension } } - private function registerHttpClientConfiguration(array $config, ContainerBuilder $container, XmlFileLoader $loader) + private function registerHttpClientConfiguration(array $config, ContainerBuilder $container, XmlFileLoader $loader, array $profilerConfig) { $loader->load('http_client.xml'); @@ -1930,6 +1935,8 @@ class FrameworkExtension extends Extension $container->removeDefinition(HttpClient::class); } + $httpClientId = $this->isConfigEnabled($container, $profilerConfig) ? '.debug.http_client.inner' : 'http_client'; + foreach ($config['scoped_clients'] as $name => $scopeConfig) { if ('http_client' === $name) { throw new InvalidArgumentException(sprintf('Invalid scope name: "%s" is reserved.', $name)); @@ -1941,10 +1948,14 @@ class FrameworkExtension extends Extension if (null === $scope) { $container->register($name, ScopingHttpClient::class) ->setFactory([ScopingHttpClient::class, 'forBaseUri']) - ->setArguments([new Reference('http_client'), $scopeConfig['base_uri'], $scopeConfig]); + ->setArguments([new Reference($httpClientId), $scopeConfig['base_uri'], $scopeConfig]) + ->addTag('http_client.client') + ; } else { $container->register($name, ScopingHttpClient::class) - ->setArguments([new Reference('http_client'), [$scope => $scopeConfig], $scope]); + ->setArguments([new Reference($httpClientId), [$scope => $scopeConfig], $scope]) + ->addTag('http_client.client') + ; } $container->registerAliasForArgument($name, HttpClientInterface::class); diff --git a/src/Symfony/Bundle/FrameworkBundle/FrameworkBundle.php b/src/Symfony/Bundle/FrameworkBundle/FrameworkBundle.php index f1cb0fe14d..173165b03e 100644 --- a/src/Symfony/Bundle/FrameworkBundle/FrameworkBundle.php +++ b/src/Symfony/Bundle/FrameworkBundle/FrameworkBundle.php @@ -36,6 +36,7 @@ use Symfony\Component\ErrorHandler\ErrorHandler; use Symfony\Component\ErrorRenderer\DependencyInjection\ErrorRendererPass; use Symfony\Component\EventDispatcher\DependencyInjection\RegisterListenersPass; use Symfony\Component\Form\DependencyInjection\FormPass; +use Symfony\Component\HttpClient\DependencyInjection\HttpClientPass; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Bundle\Bundle; use Symfony\Component\HttpKernel\DependencyInjection\ControllerArgumentValueResolverPass; @@ -129,6 +130,7 @@ class FrameworkBundle extends Bundle $container->addCompilerPass(new TestServiceContainerRealRefPass(), PassConfig::TYPE_AFTER_REMOVING); $this->addCompilerPassIfExists($container, AddMimeTypeGuesserPass::class); $this->addCompilerPassIfExists($container, MessengerPass::class); + $this->addCompilerPassIfExists($container, HttpClientPass::class); $this->addCompilerPassIfExists($container, AddAutoMappingConfigurationPass::class); $container->addCompilerPass(new RegisterReverseContainerPass(true)); $container->addCompilerPass(new RegisterReverseContainerPass(false), PassConfig::TYPE_AFTER_REMOVING); diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/http_client.xml b/src/Symfony/Bundle/FrameworkBundle/Resources/config/http_client.xml index a3f0884365..766e9f6d33 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/http_client.xml +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/http_client.xml @@ -7,6 +7,7 @@ + diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/http_client_debug.xml b/src/Symfony/Bundle/FrameworkBundle/Resources/config/http_client_debug.xml new file mode 100644 index 0000000000..6d6ae4b729 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/http_client_debug.xml @@ -0,0 +1,12 @@ + + + + + + + + + + diff --git a/src/Symfony/Bundle/FrameworkBundle/composer.json b/src/Symfony/Bundle/FrameworkBundle/composer.json index 4c8a5ef302..7807c8aac1 100644 --- a/src/Symfony/Bundle/FrameworkBundle/composer.json +++ b/src/Symfony/Bundle/FrameworkBundle/composer.json @@ -40,7 +40,7 @@ "symfony/polyfill-intl-icu": "~1.0", "symfony/form": "^4.3.4|^5.0", "symfony/expression-language": "^3.4|^4.0|^5.0", - "symfony/http-client": "^4.3|^5.0", + "symfony/http-client": "^4.4|^5.0", "symfony/lock": "^4.4|^5.0", "symfony/mailer": "^4.4|^5.0", "symfony/messenger": "^4.3|^5.0", @@ -71,6 +71,7 @@ "symfony/console": "<4.3", "symfony/dotenv": "<4.2", "symfony/dom-crawler": "<4.3", + "symfony/http-client": "<4.4", "symfony/form": "<4.3", "symfony/lock": "<4.4", "symfony/mailer": "<4.4", diff --git a/src/Symfony/Bundle/WebProfilerBundle/CHANGELOG.md b/src/Symfony/Bundle/WebProfilerBundle/CHANGELOG.md index ebf52ea3e2..257924f0aa 100644 --- a/src/Symfony/Bundle/WebProfilerBundle/CHANGELOG.md +++ b/src/Symfony/Bundle/WebProfilerBundle/CHANGELOG.md @@ -5,6 +5,7 @@ CHANGELOG ----- * added support for the Mailer component + * added support for the HttpClient component * added button to clear the ajax request tab * deprecated the `ExceptionController::templateExists()` method * deprecated the `TemplateManager::templateExists()` method diff --git a/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/http_client.html.twig b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/http_client.html.twig new file mode 100644 index 0000000000..68716153da --- /dev/null +++ b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/http_client.html.twig @@ -0,0 +1,98 @@ +{% extends '@WebProfiler/Profiler/layout.html.twig' %} + +{% block toolbar %} + {% if collector.requestCount %} + {% set icon %} + {{ include('@WebProfiler/Icon/http-client.svg') }} + {% set status_color = '' %} + {{ collector.requestCount }} + {% endset %} + + {{ include('@WebProfiler/Profiler/toolbar_item.html.twig', { link: profiler_url, status: status_color }) }} + {% endif %} +{% endblock %} + +{% block menu %} + + {{ include('@WebProfiler/Icon/http-client.svg') }} + HTTP Client + {% if collector.requestCount %} + + {{ collector.requestCount }} + + {% endif %} + +{% endblock %} + +{% block panel %} +

HTTP Client

+ {% if collector.requestCount == 0 %} +
+

No HTTP requests were made.

+
+ {% else %} +
+
+ {{ collector.requestCount }} + Total requests +
+
+ {{ collector.errorCount }} + HTTP errors +
+
+

Clients

+
+ {% for name, client in collector.clients %} +
+

{{ name }} {{ client.traces|length }}

+
+ {% if client.traces|length == 0 %} +
+

No requests were made with the "{{ name }}" service.

+
+ {% else %} +

Requests

+ {% for trace in client.traces %} + + + + + + + + + + + + + +
+ {{ trace.method }} + + {{ trace.url }} + {% if trace.options is not empty %} + {{ profiler_dump(trace.options, maxDepth=1) }} + {% endif %} +
+ {% if trace.http_code >= 500 %} + {% set responseStatus = 'error' %} + {% elseif trace.http_code >= 400 %} + {% set responseStatus = 'warning' %} + {% else %} + {% set responseStatus = 'success' %} + {% endif %} + + {{ trace.http_code }} + + + {{ profiler_dump(trace.info, maxDepth=1) }} +
+ {% endfor %} + {% endif %} +
+
+ {% endfor %} + {% endif %} +
+{% endblock %} diff --git a/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Icon/http-client.svg b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Icon/http-client.svg new file mode 100644 index 0000000000..e6b1fb2fe9 --- /dev/null +++ b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Icon/http-client.svg @@ -0,0 +1 @@ + diff --git a/src/Symfony/Component/HttpClient/CHANGELOG.md b/src/Symfony/Component/HttpClient/CHANGELOG.md index b61ceddf75..9fcfa7ee9a 100644 --- a/src/Symfony/Component/HttpClient/CHANGELOG.md +++ b/src/Symfony/Component/HttpClient/CHANGELOG.md @@ -6,10 +6,11 @@ CHANGELOG * added `StreamWrapper` * added `HttplugClient` + * added `max_duration` option * added support for NTLM authentication * added `$response->toStream()` to cast responses to regular PHP streams * made `Psr18Client` implement relevant PSR-17 factories and have streaming responses - * added `max_duration` option + * added `TraceableHttpClient`, `HttpClientDataCollector` and `HttpClientPass` to integrate with the web profiler 4.3.0 ----- diff --git a/src/Symfony/Component/HttpClient/DataCollector/HttpClientDataCollector.php b/src/Symfony/Component/HttpClient/DataCollector/HttpClientDataCollector.php new file mode 100644 index 0000000000..f9fb1af2a3 --- /dev/null +++ b/src/Symfony/Component/HttpClient/DataCollector/HttpClientDataCollector.php @@ -0,0 +1,142 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpClient\DataCollector; + +use Symfony\Component\HttpClient\TraceableHttpClient; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\DataCollector\DataCollector; + +/** + * @author Jérémy Romey + */ +final class HttpClientDataCollector extends DataCollector +{ + /** + * @var TraceableHttpClient[] + */ + private $clients = []; + + public function registerClient(string $name, TraceableHttpClient $client) + { + $this->clients[$name] = $client; + } + + /** + * {@inheritdoc} + */ + public function collect(Request $request, Response $response, \Exception $exception = null) + { + $this->initData(); + + foreach ($this->clients as $name => $client) { + [$errorCount, $traces] = $this->collectOnClient($client); + + $this->data['clients'][$name] = [ + 'traces' => $traces, + 'error_count' => $errorCount, + ]; + + $this->data['request_count'] += \count($traces); + $this->data['error_count'] += $errorCount; + } + } + + public function getClients(): array + { + return $this->data['clients'] ?? []; + } + + public function getRequestCount(): int + { + return $this->data['request_count'] ?? 0; + } + + public function getErrorCount(): int + { + return $this->data['error_count'] ?? 0; + } + + /** + * {@inheritdoc} + */ + public function reset() + { + $this->initData(); + foreach ($this->clients as $client) { + $client->reset(); + } + } + + /** + * {@inheritdoc} + */ + public function getName(): string + { + return 'http_client'; + } + + private function initData() + { + $this->data = [ + 'clients' => [], + 'request_count' => 0, + 'error_count' => 0, + ]; + } + + private function collectOnClient(TraceableHttpClient $client): array + { + $traces = $client->getTracedRequests(); + $errorCount = 0; + $baseInfo = [ + 'response_headers' => 1, + 'redirect_count' => 1, + 'redirect_url' => 1, + 'user_data' => 1, + 'error' => 1, + 'url' => 1, + ]; + + foreach ($traces as $i => $trace) { + if (400 <= ($trace['info']['http_code'] ?? 0)) { + ++$errorCount; + } + + $info = $trace['info']; + $traces[$i]['http_code'] = $info['http_code']; + + unset($info['filetime'], $info['http_code'], $info['ssl_verify_result'], $info['content_type']); + + if ($trace['method'] === $info['http_method']) { + unset($info['http_method']); + } + + if ($trace['url'] === $info['url']) { + unset($info['url']); + } + + foreach ($info as $k => $v) { + if (!$v || (is_numeric($v) && 0 > $v)) { + unset($info[$k]); + } + } + + $debugInfo = array_diff_key($info, $baseInfo); + $info = array_diff_key($info, $debugInfo) + ['debug_info' => $debugInfo]; + $traces[$i]['info'] = $this->cloneVar($info); + $traces[$i]['options'] = $this->cloneVar($trace['options']); + } + + return [$errorCount, $traces]; + } +} diff --git a/src/Symfony/Component/HttpClient/DependencyInjection/HttpClientPass.php b/src/Symfony/Component/HttpClient/DependencyInjection/HttpClientPass.php new file mode 100755 index 0000000000..e19779786b --- /dev/null +++ b/src/Symfony/Component/HttpClient/DependencyInjection/HttpClientPass.php @@ -0,0 +1,45 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpClient\DependencyInjection; + +use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Reference; +use Symfony\Component\HttpClient\TraceableHttpClient; + +final class HttpClientPass implements CompilerPassInterface +{ + private $clientTag; + + public function __construct(string $clientTag = 'http_client.client') + { + $this->clientTag = $clientTag; + } + + /** + * {@inheritdoc} + */ + public function process(ContainerBuilder $container) + { + if (!$container->hasDefinition('data_collector.http_client')) { + return; + } + + foreach ($container->findTaggedServiceIds($this->clientTag) as $id => $tags) { + $container->register('.debug.'.$id, TraceableHttpClient::class) + ->setArguments([new Reference('.debug.'.$id.'.inner')]) + ->setDecoratedService($id); + $container->getDefinition('data_collector.http_client') + ->addMethodCall('registerClient', [$id, new Reference('.debug.'.$id)]); + } + } +} diff --git a/src/Symfony/Component/HttpClient/Tests/DataCollector/HttpClientDataCollectorTest.php b/src/Symfony/Component/HttpClient/Tests/DataCollector/HttpClientDataCollectorTest.php new file mode 100755 index 0000000000..f4f94156a3 --- /dev/null +++ b/src/Symfony/Component/HttpClient/Tests/DataCollector/HttpClientDataCollectorTest.php @@ -0,0 +1,178 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpClient\DataCollector\HttpClientDataCollector; +use Symfony\Component\HttpClient\NativeHttpClient; +use Symfony\Component\HttpClient\TraceableHttpClient; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Contracts\HttpClient\Test\TestHttpServer; + +class HttpClientDataCollectorTest extends TestCase +{ + public function testItCollectsRequestCount() + { + TestHttpServer::start(); + $httpClient1 = $this->httpClientThatHasTracedRequests([ + [ + 'method' => 'GET', + 'url' => 'http://localhost:8057/', + ], + [ + 'method' => 'GET', + 'url' => 'http://localhost:8057/301', + ], + ]); + $httpClient2 = $this->httpClientThatHasTracedRequests([ + [ + 'method' => 'GET', + 'url' => 'http://localhost:8057/404', + ], + ]); + $httpClient3 = $this->httpClientThatHasTracedRequests([]); + $sut = new HttpClientDataCollector(); + $sut->registerClient('http_client1', $httpClient1); + $sut->registerClient('http_client2', $httpClient2); + $sut->registerClient('http_client3', $httpClient3); + $this->assertEquals(0, $sut->getRequestCount()); + $sut->collect(new Request(), new Response()); + $this->assertEquals(3, $sut->getRequestCount()); + } + + public function testItCollectsErrorCount() + { + TestHttpServer::start(); + $httpClient1 = $this->httpClientThatHasTracedRequests([ + [ + 'method' => 'GET', + 'url' => 'http://localhost:8057/', + ], + [ + 'method' => 'GET', + 'url' => 'http://localhost:8057/301', + ], + ]); + $httpClient2 = $this->httpClientThatHasTracedRequests([ + [ + 'method' => 'GET', + 'url' => '/404', + 'options' => ['base_uri' => 'http://localhost:8057/'], + ], + ]); + $httpClient3 = $this->httpClientThatHasTracedRequests([]); + $sut = new HttpClientDataCollector(); + $sut->registerClient('http_client1', $httpClient1); + $sut->registerClient('http_client2', $httpClient2); + $sut->registerClient('http_client3', $httpClient3); + $this->assertEquals(0, $sut->getErrorCount()); + $sut->collect(new Request(), new Response()); + $this->assertEquals(1, $sut->getErrorCount()); + } + + public function testItCollectsErrorCountByClient() + { + TestHttpServer::start(); + $httpClient1 = $this->httpClientThatHasTracedRequests([ + [ + 'method' => 'GET', + 'url' => 'http://localhost:8057/', + ], + [ + 'method' => 'GET', + 'url' => 'http://localhost:8057/301', + ], + ]); + $httpClient2 = $this->httpClientThatHasTracedRequests([ + [ + 'method' => 'GET', + 'url' => '/404', + 'options' => ['base_uri' => 'http://localhost:8057/'], + ], + ]); + $httpClient3 = $this->httpClientThatHasTracedRequests([]); + $sut = new HttpClientDataCollector(); + $sut->registerClient('http_client1', $httpClient1); + $sut->registerClient('http_client2', $httpClient2); + $sut->registerClient('http_client3', $httpClient3); + $this->assertEquals([], $sut->getClients()); + $sut->collect(new Request(), new Response()); + $collectedData = $sut->getClients(); + $this->assertEquals(0, $collectedData['http_client1']['error_count']); + $this->assertEquals(1, $collectedData['http_client2']['error_count']); + $this->assertEquals(0, $collectedData['http_client3']['error_count']); + } + + public function testItCollectsTracesByClient() + { + TestHttpServer::start(); + $httpClient1 = $this->httpClientThatHasTracedRequests([ + [ + 'method' => 'GET', + 'url' => 'http://localhost:8057/', + ], + [ + 'method' => 'GET', + 'url' => 'http://localhost:8057/301', + ], + ]); + $httpClient2 = $this->httpClientThatHasTracedRequests([ + [ + 'method' => 'GET', + 'url' => '/404', + 'options' => ['base_uri' => 'http://localhost:8057/'], + ], + ]); + $httpClient3 = $this->httpClientThatHasTracedRequests([]); + $sut = new HttpClientDataCollector(); + $sut->registerClient('http_client1', $httpClient1); + $sut->registerClient('http_client2', $httpClient2); + $sut->registerClient('http_client3', $httpClient3); + $this->assertEquals([], $sut->getClients()); + $sut->collect(new Request(), new Response()); + $collectedData = $sut->getClients(); + $this->assertCount(2, $collectedData['http_client1']['traces']); + $this->assertCount(1, $collectedData['http_client2']['traces']); + $this->assertCount(0, $collectedData['http_client3']['traces']); + } + + public function testItIsEmptyAfterReset() + { + TestHttpServer::start(); + $httpClient1 = $this->httpClientThatHasTracedRequests([ + [ + 'method' => 'GET', + 'url' => 'http://localhost:8057/', + ], + ]); + $sut = new HttpClientDataCollector(); + $sut->registerClient('http_client1', $httpClient1); + $sut->collect(new Request(), new Response()); + $collectedData = $sut->getClients(); + $this->assertCount(1, $collectedData['http_client1']['traces']); + $sut->reset(); + $this->assertEquals([], $sut->getClients()); + $this->assertEquals(0, $sut->getErrorCount()); + $this->assertEquals(0, $sut->getRequestCount()); + } + + private function httpClientThatHasTracedRequests($tracedRequests) + { + $httpClient = new TraceableHttpClient(new NativeHttpClient()); + + foreach ($tracedRequests as $request) { + $response = $httpClient->request($request['method'], $request['url'], $request['options'] ?? []); + $response->getContent(false); // To avoid exception in ResponseTrait::doDestruct + } + + return $httpClient; + } +} diff --git a/src/Symfony/Component/HttpClient/Tests/DependencyInjection/HttpClientPassTest.php b/src/Symfony/Component/HttpClient/Tests/DependencyInjection/HttpClientPassTest.php new file mode 100755 index 0000000000..eb04f88226 --- /dev/null +++ b/src/Symfony/Component/HttpClient/Tests/DependencyInjection/HttpClientPassTest.php @@ -0,0 +1,65 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpClient\Tests\DependencyInjection; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Reference; +use Symfony\Component\HttpClient\DataCollector\HttpClientDataCollector; +use Symfony\Component\HttpClient\DependencyInjection\HttpClientPass; +use Symfony\Component\HttpClient\TraceableHttpClient; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +class HttpClientPassTest extends TestCase +{ + public function testItRequiresDataCollector() + { + $container = $this->buildContainerBuilder('http_client'); + $sut = new HttpClientPass(); + $sut->process($container); + + $this->assertFalse($container->hasDefinition('.debug.http_client')); + } + + public function testItDecoratesHttpClientWithTraceableHttpClient() + { + $container = $this->buildContainerBuilder('foo'); + $container->register('data_collector.http_client', HttpClientDataCollector::class); + $sut = new HttpClientPass(); + $sut->process($container); + $this->assertTrue($container->hasDefinition('.debug.foo')); + $this->assertSame(TraceableHttpClient::class, $container->getDefinition('.debug.foo')->getClass()); + $this->assertSame(['foo', null, 0], $container->getDefinition('.debug.foo')->getDecoratedService()); + } + + public function testItRegistersDebugHttpClientToCollector() + { + $container = $this->buildContainerBuilder('foo_client'); + $container->register('data_collector.http_client', HttpClientDataCollector::class); + $sut = new HttpClientPass(); + $sut->process($container); + $this->assertEquals( + [['registerClient', ['foo_client', new Reference('.debug.foo_client')]]], + $container->getDefinition('data_collector.http_client')->getMethodCalls() + ); + } + + private function buildContainerBuilder(string $clientId = 'http_client'): ContainerBuilder + { + $container = new ContainerBuilder(); + $container->setParameter('kernel.debug', true); + + $container->register($clientId, HttpClientInterface::class)->addTag('http_client.client')->setArgument(0, []); + + return $container; + } +} diff --git a/src/Symfony/Component/HttpClient/Tests/TraceableHttpClientTest.php b/src/Symfony/Component/HttpClient/Tests/TraceableHttpClientTest.php new file mode 100755 index 0000000000..949d8afcff --- /dev/null +++ b/src/Symfony/Component/HttpClient/Tests/TraceableHttpClientTest.php @@ -0,0 +1,82 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpClient\Tests; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\HttpClient\Response\MockResponse; +use Symfony\Component\HttpClient\TraceableHttpClient; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +class TraceableHttpClientTest extends TestCase +{ + public function testItTracesRequest() + { + $httpClient = $this->getMockBuilder(HttpClientInterface::class)->getMock(); + $httpClient + ->expects($this->once()) + ->method('request') + ->with( + 'GET', + '/foo/bar', + $this->callback(function ($subject) { + $onprogress = $subject['on_progress']; + unset($subject['on_progress']); + $this->assertEquals(['options1' => 'foo'], $subject); + + return true; + }) + ) + ->willReturn(MockResponse::fromRequest('GET', '/foo/bar', ['options1' => 'foo'], new MockResponse())) + ; + $sut = new TraceableHttpClient($httpClient); + $sut->request('GET', '/foo/bar', ['options1' => 'foo']); + $this->assertCount(1, $tracedRequests = $sut->getTracedRequests()); + $actualTracedRequest = $tracedRequests[0]; + $this->assertEquals([ + 'method' => 'GET', + 'url' => '/foo/bar', + 'options' => ['options1' => 'foo'], + 'info' => [], + ], $actualTracedRequest); + } + + public function testItCollectsInfoOnRealRequest() + { + $sut = new TraceableHttpClient(new MockHttpClient()); + $sut->request('GET', 'http://localhost:8057'); + $this->assertCount(1, $tracedRequests = $sut->getTracedRequests()); + $actualTracedRequest = $tracedRequests[0]; + $this->assertSame('GET', $actualTracedRequest['info']['http_method']); + $this->assertSame('http://localhost:8057/', $actualTracedRequest['info']['url']); + } + + public function testItExecutesOnProgressOption() + { + $sut = new TraceableHttpClient(new MockHttpClient()); + $foo = 0; + $sut->request('GET', 'http://localhost:8057', ['on_progress' => function (int $dlNow, int $dlSize, array $info) use (&$foo) { + ++$foo; + }]); + $this->assertCount(1, $tracedRequests = $sut->getTracedRequests()); + $actualTracedRequest = $tracedRequests[0]; + $this->assertGreaterThan(0, $foo); + } + + public function testItResetsTraces() + { + $sut = new TraceableHttpClient(new MockHttpClient()); + $sut->request('GET', 'https://example.com/foo/bar'); + $sut->reset(); + $this->assertCount(0, $sut->getTracedRequests()); + } +} diff --git a/src/Symfony/Component/HttpClient/TraceableHttpClient.php b/src/Symfony/Component/HttpClient/TraceableHttpClient.php new file mode 100644 index 0000000000..4acbc8ee42 --- /dev/null +++ b/src/Symfony/Component/HttpClient/TraceableHttpClient.php @@ -0,0 +1,73 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpClient; + +use Symfony\Contracts\HttpClient\HttpClientInterface; +use Symfony\Contracts\HttpClient\ResponseInterface; +use Symfony\Contracts\HttpClient\ResponseStreamInterface; + +/** + * @author Jérémy Romey + */ +final class TraceableHttpClient implements HttpClientInterface +{ + private $client; + private $tracedRequests = []; + + public function __construct(HttpClientInterface $client) + { + $this->client = $client; + } + + /** + * {@inheritdoc} + */ + public function request(string $method, string $url, array $options = []): ResponseInterface + { + $traceInfo = []; + $this->tracedRequests[] = [ + 'method' => $method, + 'url' => $url, + 'options' => $options, + 'info' => &$traceInfo, + ]; + $onProgress = $options['on_progress'] ?? null; + + $options['on_progress'] = function (int $dlNow, int $dlSize, array $info) use (&$traceInfo, $onProgress) { + $traceInfo = $info; + + if (null !== $onProgress) { + $onProgress($dlNow, $dlSize, $info); + } + }; + + return $this->client->request($method, $url, $options); + } + + /** + * {@inheritdoc} + */ + public function stream($responses, float $timeout = null): ResponseStreamInterface + { + return $this->client->stream($responses, $timeout); + } + + public function getTracedRequests(): array + { + return $this->tracedRequests; + } + + public function reset() + { + $this->tracedRequests = []; + } +} diff --git a/src/Symfony/Component/HttpClient/composer.json b/src/Symfony/Component/HttpClient/composer.json index 9ecab88de6..c010dc06ba 100644 --- a/src/Symfony/Component/HttpClient/composer.json +++ b/src/Symfony/Component/HttpClient/composer.json @@ -29,8 +29,11 @@ "nyholm/psr7": "^1.0", "php-http/httplug": "^1.0|^2.0", "psr/http-client": "^1.0", + "symfony/dependency-injection": "^4.3|^5.0", "symfony/http-kernel": "^4.3|^5.0", - "symfony/process": "^4.2|^5.0" + "symfony/process": "^4.2|^5.0", + "symfony/service-contracts": "^1.0", + "symfony/var-dumper": "^4.3|^5.0" }, "autoload": { "psr-4": { "Symfony\\Component\\HttpClient\\": "" },