Skip to content

Commit

Permalink
feature #30419 [FrameworkBundle] Add integration of http-client compo…
Browse files Browse the repository at this point in the history
…nent (Ioni14, nicoweb)

This PR was merged into the 4.3-dev branch.

Discussion
----------

[FrameworkBundle] Add integration of http-client component

| Q             | A
| ------------- | ---
| Branch?       | master
| Bug fix?      | no
| New feature?  | yes
| BC breaks?    | no
| Deprecations? | no
| Tests pass?   | yes
| Fixed tickets | -
| License       | MIT
| Doc PR        | -

This PR adds the integration of the HttpClient component on FrameworkBundle.
By default, two services are provided, one implementing SFC-HttpClient, and another PSR18:
* `http_client` + its autowiring alias for `Symfony\Contracts\HttpClient\HttpClientInterface`)
This service is automatically set to the best HTTP client available with the configuration given under the `framework.http_client` key.
* `psr18.http_client` + its autowiring alias for `Psr\Http\Client\ClientInterface`). To make it work, one needs to provide autowiring aliases for `ResponseFactoryInterface` and `StreamFactoryInterface`, which are provided by [the recipe](https://github.com/symfony/recipes-contrib/blob/master/nyholm/psr7/1.0/config/packages/nyholm_psr7.yaml) for `nyholm/psr7` (but could be overriden by apps when using something else).

* one can also configure the default options, and "scoped" clients. For example:
```yaml
http_client:
    default_options:
        capath: '...'
    clients:
        github_client:
            default_options:
                base_uri: 'https://api.github.com'
```

This definition create a `github_client` service implementing SFC-HttpClient and a `psr18.github_client` one implementing PSR18, +2 corresponding named autowiring aliases: `HttpClientInterface $githubClient`,  and `ClientInterface $githubClient`.

Commits
-------

f2d2cf3 work with attributes for xml config
0023a71 [FrameworkBundle] Add integration of http-client component
  • Loading branch information
nicolas-grekas committed Mar 17, 2019
2 parents f88cb07 + f2d2cf3 commit 401c1d3
Show file tree
Hide file tree
Showing 19 changed files with 541 additions and 3 deletions.
Expand Up @@ -17,10 +17,12 @@
use Symfony\Bundle\FullStack;
use Symfony\Component\Asset\Package;
use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition;
use Symfony\Component\Config\Definition\Builder\NodeBuilder;
use Symfony\Component\Config\Definition\Builder\TreeBuilder;
use Symfony\Component\Config\Definition\ConfigurationInterface;
use Symfony\Component\DependencyInjection\Exception\LogicException;
use Symfony\Component\Form\Form;
use Symfony\Component\HttpClient\HttpClient;
use Symfony\Component\HttpFoundation\Cookie;
use Symfony\Component\Lock\Lock;
use Symfony\Component\Lock\Store\SemaphoreStore;
Expand Down Expand Up @@ -109,6 +111,7 @@ public function getConfigTreeBuilder()
$this->addLockSection($rootNode);
$this->addMessengerSection($rootNode);
$this->addRobotsIndexSection($rootNode);
$this->addHttpClientSection($rootNode);

return $treeBuilder;
}
Expand Down Expand Up @@ -1170,4 +1173,151 @@ private function addRobotsIndexSection(ArrayNodeDefinition $rootNode)
->end()
;
}

private function addHttpClientSection(ArrayNodeDefinition $rootNode)
{
$subNode = $rootNode
->children()
->arrayNode('http_client')
->info('HTTP Client configuration')
->{!class_exists(FullStack::class) && class_exists(HttpClient::class) ? 'canBeDisabled' : 'canBeEnabled'}()
->fixXmlConfig('client')
->children();

$this->addHttpClientOptionsSection($subNode);

$subNode = $subNode
->arrayNode('clients')
->useAttributeAsKey('name')
->normalizeKeys(false)
->arrayPrototype()
->children();

$this->addHttpClientOptionsSection($subNode);

$subNode = $subNode
->end()
->end()
->end()
->end()
->end()
->end()
;
}

private function addHttpClientOptionsSection(NodeBuilder $rootNode)
{
$rootNode
->integerNode('max_host_connections')
->info('The maximum number of connections to a single host.')
->end()
->arrayNode('default_options')
->fixXmlConfig('header')
->children()
->scalarNode('auth_basic')
->info('An HTTP Basic authentication "username:password".')
->end()
->scalarNode('auth_bearer')
->info('A token enabling HTTP Bearer authorization.')
->end()
->arrayNode('query')
->info('Associative array of query string values merged with URL parameters.')
->useAttributeAsKey('key')
->beforeNormalization()
->always(function ($config) {
if (!\is_array($config)) {
return [];
}
if (!isset($config['key'])) {
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()
->scalarNode('base_uri')
->info('The URI to resolve relative URLs, following rules in RFC 3986, section 2.')
->end()
->arrayNode('resolve')
->info('Associative array: domain => IP.')
->useAttributeAsKey('host')
->beforeNormalization()
->always(function ($config) {
if (!\is_array($config)) {
return [];
}
if (!isset($config['host'])) {
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('Defaults to "default_socket_timeout" ini parameter.')
->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 a 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. "RC4-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()
->end()
->end()
;
}
}
Expand Up @@ -15,6 +15,7 @@
use Doctrine\Common\Annotations\Reader;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Container\ContainerInterface as PsrContainerInterface;
use Psr\Http\Client\ClientInterface;
use Psr\Log\LoggerAwareInterface;
use Symfony\Bridge\Monolog\Processor\DebugProcessor;
use Symfony\Bridge\Twig\Extension\CsrfExtension;
Expand Down Expand Up @@ -57,6 +58,9 @@
use Symfony\Component\Form\FormTypeExtensionInterface;
use Symfony\Component\Form\FormTypeGuesserInterface;
use Symfony\Component\Form\FormTypeInterface;
use Symfony\Component\HttpClient\HttpClient;
use Symfony\Component\HttpClient\HttpClientTrait;
use Symfony\Component\HttpClient\Psr18Client;
use Symfony\Component\HttpKernel\CacheClearer\CacheClearerInterface;
use Symfony\Component\HttpKernel\CacheWarmer\CacheWarmerInterface;
use Symfony\Component\HttpKernel\Controller\ArgumentValueResolverInterface;
Expand Down Expand Up @@ -110,6 +114,8 @@
use Symfony\Component\Yaml\Command\LintCommand as BaseYamlLintCommand;
use Symfony\Component\Yaml\Yaml;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\Service\ResetInterface;
use Symfony\Contracts\Service\ServiceSubscriberInterface;

Expand Down Expand Up @@ -301,6 +307,10 @@ public function load(array $configs, ContainerBuilder $container)
$this->registerLockConfiguration($config['lock'], $container, $loader);
}

if ($this->isConfigEnabled($container, $config['http_client'])) {
$this->registerHttpClientConfiguration($config['http_client'], $container, $loader);
}

if ($this->isConfigEnabled($container, $config['web_link'])) {
if (!class_exists(HttpHeaderSerializer::class)) {
throw new LogicException('WebLink support cannot be enabled as the WebLink component is not installed. Try running "composer require symfony/weblink".');
Expand Down Expand Up @@ -1747,6 +1757,63 @@ private function registerCacheConfiguration(array $config, ContainerBuilder $con
}
}

private function registerHttpClientConfiguration(array $config, ContainerBuilder $container, XmlFileLoader $loader)
{
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".');
}

$loader->load('http_client.xml');

$merger = new class() {
use HttpClientTrait;

public function merge(array $options, array $defaultOptions)
{
try {
[, $mergedOptions] = $this->prepareRequest(null, null, $options, $defaultOptions);

foreach ($mergedOptions as $k => $v) {
if (!isset($options[$k]) && !isset($defaultOptions[$k])) {
// Remove options added by prepareRequest()
unset($mergedOptions[$k]);
}
}

return $mergedOptions;
} catch (TransportExceptionInterface $e) {
throw new InvalidArgumentException($e->getMessage(), 0, $e);
}
}
};

$defaultOptions = $merger->merge($config['default_options'] ?? [], []);
$container->getDefinition('http_client')->setArguments([$defaultOptions, $config['max_host_connections'] ?? 6]);

if (!$hasPsr18 = interface_exists(ClientInterface::class)) {
$container->removeDefinition('psr18.http_client');
$container->removeAlias(ClientInterface::class);
}

foreach ($config['clients'] as $name => $clientConfig) {
$options = $merger->merge($clientConfig['default_options'] ?? [], $defaultOptions);

$container->register($name, HttpClientInterface::class)
->setFactory([HttpClient::class, 'create'])
->setArguments([$options, $clientConfig['max_host_connections'] ?? $config['max_host_connections'] ?? 6]);

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

if ($hasPsr18) {
$container->register('psr18.'.$name, Psr18Client::class)
->setAutowired(true)
->setArguments([new Reference($name)]);

$container->registerAliasForArgument('psr18.'.$name, ClientInterface::class, $name);
}
}
}

/**
* Returns the base path for the XSD files.
*
Expand Down
@@ -0,0 +1,20 @@
<?xml version="1.0" ?>

<container xmlns="http://symfony.com/schema/dic/services"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd">

<services>
<service id="http_client" class="Symfony\Contracts\HttpClient\HttpClientInterface">
<factory class="Symfony\Component\HttpClient\HttpClient" method="create" />
<argument type="collection" /> <!-- default options -->
<argument /> <!-- max host connections -->
</service>
<service id="Symfony\Contracts\HttpClient\HttpClientInterface" alias="http_client" />

<service id="psr18.http_client" class="Symfony\Component\HttpClient\Psr18Client" autowire="true">
<argument type="service" id="http_client" />
</service>
<service id="Psr\Http\Client\ClientInterface" alias="psr18.http_client" />
</services>
</container>
Expand Up @@ -32,6 +32,7 @@
<xsd:element name="php-errors" type="php-errors" minOccurs="0" maxOccurs="1" />
<xsd:element name="lock" type="lock" minOccurs="0" maxOccurs="1" />
<xsd:element name="messenger" type="messenger" minOccurs="0" maxOccurs="1" />
<xsd:element name="http-client" type="http_client" minOccurs="0" maxOccurs="1" />
</xsd:choice>

<xsd:attribute name="http-method-override" type="xsd:boolean" />
Expand Down Expand Up @@ -444,4 +445,66 @@
</xsd:sequence>
<xsd:attribute name="id" type="xsd:string" use="required"/>
</xsd:complexType>

<xsd:complexType name="http_client">
<xsd:sequence>
<xsd:element name="default-options" type="http_client_options" minOccurs="0" />
<xsd:element name="client" type="http_client_client" minOccurs="0" maxOccurs="unbounded" />
</xsd:sequence>
<xsd:attribute name="enabled" type="xsd:boolean" />
<xsd:attribute name="max-host-connections" type="xsd:integer" />
</xsd:complexType>

<xsd:complexType name="http_client_options" mixed="true">
<xsd:choice maxOccurs="unbounded">
<xsd:element name="query" type="http_query" minOccurs="0" maxOccurs="unbounded" />
<xsd:element name="resolve" type="http_resolve" minOccurs="0" maxOccurs="unbounded" />
<xsd:element name="header" type="http_header" minOccurs="0" maxOccurs="unbounded" />
<xsd:element name="peer-fingerprint" type="fingerprint" minOccurs="0" maxOccurs="unbounded" />
</xsd:choice>
<xsd:attribute name="proxy" type="xsd:string" />
<xsd:attribute name="timeout" type="xsd:float" />
<xsd:attribute name="bindto" type="xsd:string" />
<xsd:attribute name="verify-peer" type="xsd:boolean" />
<xsd:attribute name="auth-basic" type="xsd:string" />
<xsd:attribute name="auth-bearer" type="xsd:string" />
<xsd:attribute name="max-redirects" type="xsd:integer" />
<xsd:attribute name="http-version" type="xsd:string" />
<xsd:attribute name="base-uri" type="xsd:string" />
<xsd:attribute name="no-proxy" type="xsd:string" />
<xsd:attribute name="verify-host" type="xsd:boolean" />
<xsd:attribute name="cafile" type="xsd:string" />
<xsd:attribute name="capath" type="xsd:string" />
<xsd:attribute name="local-cert" type="xsd:string" />
<xsd:attribute name="local-pk" type="xsd:string" />
<xsd:attribute name="passphrase" type="xsd:string" />
<xsd:attribute name="ciphers" type="xsd:string" />
</xsd:complexType>

<xsd:complexType name="http_client_client">
<xsd:sequence>
<xsd:element name="default-options" type="http_client_options" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>

<xsd:complexType name="fingerprint">
<xsd:choice maxOccurs="unbounded">
<xsd:element name="pin-sha256" type="xsd:string" minOccurs="0" />
<xsd:element name="sha1" type="xsd:string" minOccurs="0" />
<xsd:element name="md5" type="xsd:string" minOccurs="0" />
</xsd:choice>
</xsd:complexType>

<xsd:complexType name="http_query" mixed="true">
<xsd:attribute name="key" type="xsd:string" />
</xsd:complexType>

<xsd:complexType name="http_resolve" mixed="true">
<xsd:attribute name="host" type="xsd:string" />
</xsd:complexType>

<xsd:complexType name="http_header" mixed="true">
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:schema>
Expand Up @@ -17,6 +17,7 @@
use Symfony\Bundle\FullStack;
use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException;
use Symfony\Component\Config\Definition\Processor;
use Symfony\Component\HttpClient\HttpClient;
use Symfony\Component\Lock\Store\SemaphoreStore;
use Symfony\Component\Messenger\MessageBusInterface;

Expand Down Expand Up @@ -331,6 +332,10 @@ class_exists(SemaphoreStore::class) && SemaphoreStore::isSupported() ? 'semaphor
'buses' => ['messenger.bus.default' => ['default_middleware' => true, 'middleware' => []]],
],
'disallow_search_engine_index' => true,
'http_client' => [
'enabled' => !class_exists(FullStack::class) && class_exists(HttpClient::class),
'clients' => [],
],
];
}
}

0 comments on commit 401c1d3

Please sign in to comment.