biurad/flange

View on GitHub
src/Extensions/Symfony/HttpClientExtension.php

Summary

Maintainability
D
3 days
Test Coverage
<?php declare(strict_types=1);

/*
 * This file is part of Biurad opensource projects.
 *
 * @copyright 2019 Biurad Group (https://biurad.com/)
 * @license   https://opensource.org/licenses/BSD-3-Clause License
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace Flange\Extensions\Symfony;

use Flange\Extensions\Config\HttpClientRetrySection;
use Psr\Http\Client\ClientInterface;
use Rade\DI\Container;
use Rade\DI\Definition;
use Rade\DI\Definitions\Reference;
use Rade\DI\Definitions\Statement;
use Rade\DI\Extensions\AliasedInterface;
use Rade\DI\Extensions\ExtensionInterface;
use Symfony\Component\Config\Definition\Builder\TreeBuilder;
use Symfony\Component\Config\Definition\ConfigurationInterface;
use Symfony\Component\HttpClient\HttpClient;
use Symfony\Component\HttpClient\HttplugClient;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Psr18Client;
use Symfony\Component\HttpClient\Retry\GenericRetryStrategy;
use Symfony\Component\HttpClient\RetryableHttpClient;
use Symfony\Component\HttpClient\ScopingHttpClient;
use Symfony\Contracts\HttpClient\HttpClientInterface;

/**
 * Symfony component http client extension.
 *
 * @author Divine Niiquaye Ibok <divineibok@gmail.com>
 */
class HttpClientExtension implements AliasedInterface, ConfigurationInterface, ExtensionInterface
{
    /**
     * {@inheritdoc}
     */
    public function getAlias(): string
    {
        return 'http_client';
    }

    /**
     * {@inheritdoc}
     */
    public function getConfigTreeBuilder(): TreeBuilder
    {
        $treeBuilder = new TreeBuilder(__CLASS__);

        $treeBuilder->getRootNode()
            ->info('HTTP Client configuration')
            ->canBeEnabled()
            ->fixXmlConfig('scoped_client')
            ->beforeNormalization()
                ->always(function ($config) {
                    if (empty($config['scoped_clients']) || !\is_array($config['default_options']['retry_failed'] ?? null)) {
                        return $config;
                    }

                    foreach ($config['scoped_clients'] as &$scopedConfig) {
                        if (!isset($scopedConfig['retry_failed']) || true === $scopedConfig['retry_failed']) {
                            $scopedConfig['retry_failed'] = $config['default_options']['retry_failed'];

                            continue;
                        }

                        if (\is_array($scopedConfig['retry_failed'])) {
                            $scopedConfig['retry_failed'] = $scopedConfig['retry_failed'] + $config['default_options']['retry_failed'];
                        }
                    }

                    return $config;
                })
            ->end()
            ->children()
                ->integerNode('max_host_connections')
                    ->info('The maximum number of connections to a single host.')
                ->end()
                ->arrayNode('default_options')
                    ->fixXmlConfig('header')
                    ->children()
                        ->arrayNode('headers')
                            ->info('Associative array: header => value(s).')
                            ->useAttributeAsKey('name')
                            ->normalizeKeys(false)
                            ->variablePrototype()->end()
                        ->end()
                        ->integerNode('max_redirects')
                            ->info('The maximum number of redirects to follow.')
                        ->end()
                        ->scalarNode('http_version')
                            ->info('The default HTTP version, typically 1.1 or 2.0, leave to null for the best version.')
                        ->end()
                        ->arrayNode('resolve')
                            ->info('Associative array: domain => IP.')
                            ->useAttributeAsKey('host')
                            ->beforeNormalization()
                                ->always(function ($config) {
                                    if (!\is_array($config)) {
                                        return [];
                                    }

                                    if (!isset($config['host'], $config['value']) || \count($config) > 2) {
                                        return $config;
                                    }

                                    return [$config['host'] => $config['value']];
                                })
                            ->end()
                            ->normalizeKeys(false)
                            ->scalarPrototype()->end()
                        ->end()
                        ->scalarNode('proxy')
                            ->info('The URL of the proxy to pass requests through or null for automatic detection.')
                        ->end()
                        ->scalarNode('no_proxy')
                            ->info('A comma separated list of hosts that do not require a proxy to be reached.')
                        ->end()
                        ->floatNode('timeout')
                            ->info('The idle timeout, defaults to the "default_socket_timeout" ini parameter.')
                        ->end()
                        ->floatNode('max_duration')
                            ->info('The maximum execution time for the request+response as a whole.')
                        ->end()
                        ->scalarNode('bindto')
                            ->info('A network interface name, IP address, a host name or a UNIX socket to bind to.')
                        ->end()
                        ->booleanNode('verify_peer')
                            ->info('Indicates if the peer should be verified in an SSL/TLS context.')
                        ->end()
                        ->booleanNode('verify_host')
                            ->info('Indicates if the host should exist as a certificate common name.')
                        ->end()
                        ->scalarNode('cafile')
                            ->info('A certificate authority file.')
                        ->end()
                        ->scalarNode('capath')
                            ->info('A directory that contains multiple certificate authority files.')
                        ->end()
                        ->scalarNode('local_cert')
                            ->info('A PEM formatted certificate file.')
                        ->end()
                        ->scalarNode('local_pk')
                            ->info('A private key file.')
                        ->end()
                        ->scalarNode('passphrase')
                            ->info('The passphrase used to encrypt the "local_pk" file.')
                        ->end()
                        ->scalarNode('ciphers')
                            ->info('A list of SSL/TLS ciphers separated by colons, commas or spaces (e.g. "RC3-SHA:TLS13-AES-128-GCM-SHA256"...)')
                        ->end()
                        ->arrayNode('peer_fingerprint')
                            ->info('Associative array: hashing algorithm => hash(es).')
                            ->normalizeKeys(false)
                            ->children()
                                ->variableNode('sha1')->end()
                                ->variableNode('pin-sha256')->end()
                                ->variableNode('md5')->end()
                            ->end()
                        ->end()
                        ->append(HttpClientRetrySection::getConfigNode())
                    ->end()
                ->end()
                ->scalarNode('mock_response_factory')
                    ->info('The id of the service that should generate mock responses. It should be either an invokable or an iterable.')
                ->end()
                ->arrayNode('scoped_clients')
                    ->useAttributeAsKey('name')
                    ->normalizeKeys(false)
                    ->arrayPrototype()
                        ->fixXmlConfig('header')
                        ->beforeNormalization()
                            ->always()
                            ->then(function ($config) {
                                if (!\class_exists(HttpClient::class)) {
                                    throw new \LogicException('HttpClient support cannot be enabled as the component is not installed. Try running "composer require symfony/http-client".');
                                }

                                return \is_array($config) ? $config : ['base_uri' => $config];
                            })
                        ->end()
                        ->validate()
                            ->ifTrue(fn ($v) => !isset($v['scope']) && !isset($v['base_uri']))
                            ->thenInvalid('Either "scope" or "base_uri" should be defined.')
                        ->end()
                        ->validate()
                            ->ifTrue(fn ($v) => !empty($v['query']) && !isset($v['base_uri']))
                            ->thenInvalid('"query" applies to "base_uri" but no base URI is defined.')
                        ->end()
                        ->children()
                            ->scalarNode('scope')
                                ->info('The regular expression that the request URL must match before adding the other options. When none is provided, the base URI is used instead.')
                                ->cannotBeEmpty()
                            ->end()
                            ->scalarNode('base_uri')
                                ->info('The URI to resolve relative URLs, following rules in RFC 3985, section 2.')
                                ->cannotBeEmpty()
                            ->end()
                            ->scalarNode('auth_basic')
                                ->info('An HTTP Basic authentication "username:password".')
                            ->end()
                            ->scalarNode('auth_bearer')
                                ->info('A token enabling HTTP Bearer authorization.')
                            ->end()
                            ->scalarNode('auth_ntlm')
                                ->info('A "username:password" pair to use Microsoft NTLM authentication (requires the cURL extension).')
                            ->end()
                            ->arrayNode('query')
                                ->info('Associative array of query string values merged with the base URI.')
                                ->useAttributeAsKey('key')
                                ->beforeNormalization()
                                    ->always(function ($config) {
                                        if (!\is_array($config)) {
                                            return [];
                                        }

                                        if (!isset($config['key'], $config['value']) || \count($config) > 2) {
                                            return $config;
                                        }

                                        return [$config['key'] => $config['value']];
                                    })
                                ->end()
                                ->normalizeKeys(false)
                                ->scalarPrototype()->end()
                            ->end()
                            ->arrayNode('headers')
                                ->info('Associative array: header => value(s).')
                                ->useAttributeAsKey('name')
                                ->normalizeKeys(false)
                                ->variablePrototype()->end()
                            ->end()
                            ->integerNode('max_redirects')
                                ->info('The maximum number of redirects to follow.')
                            ->end()
                            ->scalarNode('http_version')
                                ->info('The default HTTP version, typically 1.1 or 2.0, leave to null for the best version.')
                            ->end()
                            ->arrayNode('resolve')
                                ->info('Associative array: domain => IP.')
                                ->useAttributeAsKey('host')
                                ->beforeNormalization()
                                    ->always(function ($config) {
                                        if (!\is_array($config)) {
                                            return [];
                                        }

                                        if (!isset($config['host'], $config['value']) || \count($config) > 2) {
                                            return $config;
                                        }

                                        return [$config['host'] => $config['value']];
                                    })
                                ->end()
                                ->normalizeKeys(false)
                                ->scalarPrototype()->end()
                            ->end()
                            ->scalarNode('proxy')
                                ->info('The URL of the proxy to pass requests through or null for automatic detection.')
                            ->end()
                            ->scalarNode('no_proxy')
                                ->info('A comma separated list of hosts that do not require a proxy to be reached.')
                            ->end()
                            ->floatNode('timeout')
                                ->info('The idle timeout, defaults to the "default_socket_timeout" ini parameter.')
                            ->end()
                            ->floatNode('max_duration')
                                ->info('The maximum execution time for the request+response as a whole.')
                            ->end()
                            ->scalarNode('bindto')
                                ->info('A network interface name, IP address, a host name or a UNIX socket to bind to.')
                            ->end()
                            ->booleanNode('verify_peer')
                                ->info('Indicates if the peer should be verified in an SSL/TLS context.')
                            ->end()
                            ->booleanNode('verify_host')
                                ->info('Indicates if the host should exist as a certificate common name.')
                            ->end()
                            ->scalarNode('cafile')
                                ->info('A certificate authority file.')
                            ->end()
                            ->scalarNode('capath')
                                ->info('A directory that contains multiple certificate authority files.')
                            ->end()
                            ->scalarNode('local_cert')
                                ->info('A PEM formatted certificate file.')
                            ->end()
                            ->scalarNode('local_pk')
                                ->info('A private key file.')
                            ->end()
                            ->scalarNode('passphrase')
                                ->info('The passphrase used to encrypt the "local_pk" file.')
                            ->end()
                            ->scalarNode('ciphers')
                                ->info('A list of SSL/TLS ciphers separated by colons, commas or spaces (e.g. "RC3-SHA:TLS13-AES-128-GCM-SHA256"...)')
                            ->end()
                            ->arrayNode('peer_fingerprint')
                                ->info('Associative array: hashing algorithm => hash(es).')
                                ->normalizeKeys(false)
                                ->children()
                                    ->variableNode('sha1')->end()
                                    ->variableNode('pin-sha256')->end()
                                    ->variableNode('md5')->end()
                                ->end()
                            ->end()
                            ->append(HttpClientRetrySection::getConfigNode())
                        ->end()
                    ->end()
                ->end()
            ->end()
        ;

        return $treeBuilder;
    }

    /**
     * {@inheritdoc}
     */
    public function register(Container $container, array $configs = []): void
    {
        if (!$configs['enabled']) {
            return;
        }

        if (!\class_exists(HttpClient::class)) {
            throw new \LogicException('HttpClient support cannot be enabled as the HttpClient component is not installed. Try running "composer require symfony/http-client".');
        }

        $options = $config['default_options'] ?? [];
        $retryOptions = $options['retry_failed'] ?? ['enabled' => false];
        unset($options['retry_failed']);

        $client = $container->set('http_client', new Definition(HttpClient::class.'::create', [$options, $configs['max_host_connections'] ?? 6]));

        if (\class_exists(\Http\Client\HttpClient::class)) {
            $container->set(\Http\Client\HttpClient::class, new Definition(HttplugClient::class, [new Reference('http_client')]));
        }

        if ($hasPsr18 = \interface_exists(ClientInterface::class)) {
            $client->public(false);
            $container->autowire('psr18.http_client', new Definition(Psr18Client::class, [new Reference('http_client')]));
        } else {
            $client->typed();
        }

        if (\array_key_exists('enabled', $retryOptions)) {
            $this->registerRetryableHttpClient($options, 'http_client', $container);
        }

        $httpClientId = ($retryOptions['enabled'] ?? false) ? 'http_client.retryable' : 'http_client';

        foreach ($configs['scoped_clients'] as $name => $scopeConfig) {
            if ('http_client' === $name) {
                throw new \InvalidArgumentException(\sprintf('Invalid scope name: "%s" is reserved.', $name));
            }

            $scope = $scopeConfig['scope'] ?? null;
            unset($scopeConfig['scope']);
            $retryOptions = $scopeConfig['retry_failed'] ?? ['enabled' => false];
            unset($scopeConfig['retry_failed']);

            if (null === $scope) {
                $baseUri = $scopeConfig['base_uri'];
                unset($scopeConfig['base_uri']);

                $container->set($name, new Definition(ScopingHttpClient::class.'::forBaseUri', [new Reference($httpClientId), $baseUri, $scopeConfig]));
            } else {
                $container->set($name, new Definition(ScopingHttpClient::class, [new Reference($httpClientId), [$scope => $scopeConfig], $scope]));
            }

            if (\array_key_exists('enabled', $retryOptions)) {
                $this->registerRetryableHttpClient($retryOptions, $name, $container);
            }

            $container->type($name, HttpClientInterface::class);

            if ($hasPsr18) {
                $container->set('psr18.'.$name, new Reference('psr18.http_client'))->arg(0, new Reference($name))->typed(ClientInterface::class);
            }
        }

        if ($responseFactoryId = $config['mock_response_factory'] ?? null) {
            $container->set($httpClientId.'.mock_client', new Definition(MockHttpClient::class, [new Reference($responseFactoryId)]));
        }
    }

    private function registerRetryableHttpClient(array $options, string $name, Container $container): void
    {
        if (!\class_exists(RetryableHttpClient::class)) {
            throw new \LogicException('Support for retrying failed requests requires symfony/http-client 5.2 or higher, try upgrading.');
        }

        if (null !== ($options['retry_strategy'] ?? null)) {
            $retryStrategy = new Reference($options['retry_strategy']);
        } else {
            $codes = [];

            foreach ($options['http_codes'] ?? [] as $code => $codeOptions) {
                if ($codeOptions['methods']) {
                    $codes[$code] = $codeOptions['methods'];
                } else {
                    $codes[] = $code;
                }
            }

            $retryArgs = [1 => $options['delay'] ?? 1000, 2 => $options['multiplier'] ?? 2.0, 3 => $options['max_delay'] ?? 0, 4 => $options['jitter'] ?? 0.1];
            $retryStrategy = new Statement(GenericRetryStrategy::class, (!empty($codes) ? [0 => $codes] : []) + $retryArgs);
        }

        $container->set($name.'.retryable', new Definition(RetryableHttpClient::class, [new Reference($name), $retryStrategy, $options['max_retries'] ?? 3]));
    }
}