diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index 19386d9721ae9..2002f589bc90d 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -62,6 +62,7 @@ use Symfony\Component\Form\FormTypeInterface; use Symfony\Component\HttpClient\Psr18Client; use Symfony\Component\HttpClient\ScopingHttpClient; +use Symfony\Component\HttpClient\TraceableHttpClient; use Symfony\Component\HttpKernel\CacheClearer\CacheClearerInterface; use Symfony\Component\HttpKernel\CacheWarmer\CacheWarmerInterface; use Symfony\Component\HttpKernel\Controller\ArgumentValueResolverInterface; @@ -1874,6 +1875,15 @@ private function registerHttpClientConfiguration(array $config, ContainerBuilder { $loader->load('http_client.xml'); + $collectorDefinition = $container->getDefinition('data_collector.http_client'); + + if (!$debug = $container->getParameter('kernel.debug')) { + $container->removeDefinition('debug.http_client'); + $collectorDefinition + ->setMethodCalls([]) + ->clearTag('data_collector'); + } + $container->getDefinition('http_client')->setArguments([$config['default_options'] ?? [], $config['max_host_connections'] ?? 6]); if (!$hasPsr18 = interface_exists(ClientInterface::class)) { @@ -1898,6 +1908,19 @@ private function registerHttpClientConfiguration(array $config, ContainerBuilder ->setArguments([new Reference('http_client'), [$scope => $scopeConfig], $scope]); } + if ($debug) { + $innerId = '.'.$name.'.inner'; + + $container->setDefinition($innerId, $container->getDefinition($name) + ->replaceArgument(0, new Reference('debug.http_client.inner')) + ); + + $container->register($name, TraceableHttpClient::class) + ->setArguments([new Reference($innerId)]); + + $collectorDefinition->addMethodCall('addClient', [$name, new Reference($name)]); + } + $container->registerAliasForArgument($name, HttpClientInterface::class); if ($hasPsr18) { diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/http_client.xml b/src/Symfony/Bundle/FrameworkBundle/Resources/config/http_client.xml index aa29944c472d3..e3094863ec517 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/http_client.xml +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/http_client.xml @@ -22,5 +22,17 @@ + + + + + http_client + + + + + + + diff --git a/src/Symfony/Bundle/FrameworkBundle/composer.json b/src/Symfony/Bundle/FrameworkBundle/composer.json index de007cd0b5f98..9d8654e19e7c9 100644 --- a/src/Symfony/Bundle/FrameworkBundle/composer.json +++ b/src/Symfony/Bundle/FrameworkBundle/composer.json @@ -39,7 +39,7 @@ "symfony/polyfill-intl-icu": "~1.0", "symfony/form": "^4.3|^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/mailer": "^4.3|^5.0", "symfony/messenger": "^4.3|^5.0", "symfony/mime": "^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/messenger": "<4.3", "symfony/property-info": "<3.4", 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 0000000000000..ad847f12f22f5 --- /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 0000000000000..e6b1fb2fe903c --- /dev/null +++ b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Icon/http-client.svg @@ -0,0 +1 @@ + diff --git a/src/Symfony/Component/HttpClient/DataCollector/HttpClientDataCollector.php b/src/Symfony/Component/HttpClient/DataCollector/HttpClientDataCollector.php new file mode 100644 index 0000000000000..a139756b8ed97 --- /dev/null +++ b/src/Symfony/Component/HttpClient/DataCollector/HttpClientDataCollector.php @@ -0,0 +1,120 @@ + + * + * 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 addClient(string $name, TraceableHttpClient $client) + { + $this->clients[$name] = $client; + } + + /** + * {@inheritdoc} + */ + public function collect(Request $request, Response $response, \Exception $exception = null) + { + $this->data = [ + 'clients' => [], + 'request_count' => 0, + 'error_count' => 0, + ]; + + foreach ($this->clients as $name => $client) { + $traces = $client->getTraces(); + + $this->data['request_count'] += \count($traces); + $errorCount = 0; + + 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]); + } + } + + $traces[$i]['info'] = $this->cloneVar($info); + $traces[$i]['options'] = $this->cloneVar($trace['options']); + } + + $this->data['clients'][$name] = [ + 'traces' => $traces, + 'error_count' => $errorCount, + ]; + $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->data = []; + foreach ($this->clients as $client) { + $client->clearTraces(); + } + } + + /** + * {@inheritdoc} + */ + public function getName(): string + { + return 'http_client'; + } +} diff --git a/src/Symfony/Component/HttpClient/TraceableHttpClient.php b/src/Symfony/Component/HttpClient/TraceableHttpClient.php new file mode 100644 index 0000000000000..7d3bc04fb151d --- /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 $traces = []; + + public function __construct(HttpClientInterface $client) + { + $this->client = $client; + } + + /** + * {@inheritdoc} + */ + public function request(string $method, string $url, array $options = []): ResponseInterface + { + $traceInfo = []; + $this->traces[] = [ + '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 ($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 getTraces(): array + { + return $this->traces; + } + + public function clearTraces() + { + $this->traces = []; + } +}