[FrameworkBundle] change the way http clients are configured by leveraging ScopingHttpClient

This commit is contained in:
Nicolas Grekas 2019-03-24 20:39:17 +01:00
parent 755f41192f
commit f1a26b9aea
17 changed files with 309 additions and 222 deletions

View File

@ -17,12 +17,12 @@ use Doctrine\DBAL\Connection;
use Symfony\Bundle\FullStack; use Symfony\Bundle\FullStack;
use Symfony\Component\Asset\Package; use Symfony\Component\Asset\Package;
use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition; 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\Builder\TreeBuilder;
use Symfony\Component\Config\Definition\ConfigurationInterface; use Symfony\Component\Config\Definition\ConfigurationInterface;
use Symfony\Component\DependencyInjection\Exception\LogicException; use Symfony\Component\DependencyInjection\Exception\LogicException;
use Symfony\Component\Form\Form; use Symfony\Component\Form\Form;
use Symfony\Component\HttpClient\HttpClient; use Symfony\Component\HttpClient\HttpClient;
use Symfony\Component\HttpClient\HttpClientTrait;
use Symfony\Component\HttpFoundation\Cookie; use Symfony\Component\HttpFoundation\Cookie;
use Symfony\Component\Lock\Lock; use Symfony\Component\Lock\Lock;
use Symfony\Component\Lock\Store\SemaphoreStore; use Symfony\Component\Lock\Store\SemaphoreStore;
@ -43,6 +43,8 @@ use Symfony\Component\WebLink\HttpHeaderSerializer;
*/ */
class Configuration implements ConfigurationInterface class Configuration implements ConfigurationInterface
{ {
use HttpClientTrait;
private $debug; private $debug;
/** /**
@ -1232,144 +1234,231 @@ class Configuration implements ConfigurationInterface
private function addHttpClientSection(ArrayNodeDefinition $rootNode) private function addHttpClientSection(ArrayNodeDefinition $rootNode)
{ {
$subNode = $rootNode $rootNode
->children() ->children()
->arrayNode('http_client') ->arrayNode('http_client')
->info('HTTP Client configuration') ->info('HTTP Client configuration')
->{!class_exists(FullStack::class) && class_exists(HttpClient::class) ? 'canBeDisabled' : 'canBeEnabled'}() ->{!class_exists(FullStack::class) && class_exists(HttpClient::class) ? 'canBeDisabled' : 'canBeEnabled'}()
->fixXmlConfig('client') ->fixXmlConfig('scoped_client')
->children(); ->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'])) {
return $config;
}
$this->addHttpClientOptionsSection($subNode); return [$config['host'] => $config['value']];
})
$subNode = $subNode ->end()
->arrayNode('clients') ->normalizeKeys(false)
->useAttributeAsKey('name') ->scalarPrototype()->end()
->normalizeKeys(false) ->end()
->arrayPrototype() ->scalarNode('proxy')
->children(); ->info('The URL of the proxy to pass requests through or null for automatic detection.')
->end()
$this->addHttpClientOptionsSection($subNode); ->scalarNode('no_proxy')
->info('A comma separated list of hosts that do not require a proxy to be reached.')
$subNode = $subNode ->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. "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() ->end()
->end() ->end()
->end() ->end()
->end() ->arrayNode('scoped_clients')
->end() ->useAttributeAsKey('name')
->end() ->normalizeKeys(false)
; ->arrayPrototype()
} ->fixXmlConfig('header')
->beforeNormalization()
->always()
->then(function ($config) {
$config = \is_array($config) ? $config : ['base_uri' => $config];
private function addHttpClientOptionsSection(NodeBuilder $rootNode) if (!isset($config['scope']) && isset($config['base_uri'])) {
{ $config['scope'] = preg_quote(implode('', self::resolveUrl(self::parseUrl('.'), self::parseUrl($config['base_uri']))));
$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']]; return $config;
}) })
->end() ->end()
->normalizeKeys(false) ->validate()
->scalarPrototype()->end() ->ifTrue(function ($v) { return !isset($v['scope']); })
->end() ->thenInvalid('either "scope" or "base_uri" should be defined.')
->arrayNode('headers') ->end()
->info('Associative array: header => value(s).') ->validate()
->useAttributeAsKey('name') ->ifTrue(function ($v) { return isset($v['query']) && !isset($v['base_uri']); })
->normalizeKeys(false) ->thenInvalid('"query" applies to "base_uri" but no base URI is defined.')
->variablePrototype()->end() ->end()
->end() ->children()
->integerNode('max_redirects') ->scalarNode('scope')
->info('The maximum number of redirects to follow.') ->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.')
->end() ->cannotBeEmpty()
->scalarNode('http_version') ->end()
->info('The default HTTP version, typically 1.1 or 2.0. Leave to null for the best version.') ->scalarNode('base_uri')
->end() ->info('The URI to resolve relative URLs, following rules in RFC 3985, section 2.')
->scalarNode('base_uri') ->cannotBeEmpty()
->info('The URI to resolve relative URLs, following rules in RFC 3986, section 2.') ->end()
->end() ->scalarNode('auth_basic')
->arrayNode('resolve') ->info('An HTTP Basic authentication "username:password".')
->info('Associative array: domain => IP.') ->end()
->useAttributeAsKey('host') ->scalarNode('auth_bearer')
->beforeNormalization() ->info('A token enabling HTTP Bearer authorization.')
->always(function ($config) { ->end()
if (!\is_array($config)) { ->arrayNode('query')
return []; ->info('Associative array of query string values merged with the base URI.')
} ->useAttributeAsKey('key')
if (!isset($config['host'])) { ->beforeNormalization()
return $config; ->always(function ($config) {
} if (!\is_array($config)) {
return [];
}
if (!isset($config['key'])) {
return $config;
}
return [$config['host'] => $config['value']]; return [$config['key'] => $config['value']];
}) })
->end() ->end()
->normalizeKeys(false) ->normalizeKeys(false)
->scalarPrototype()->end() ->scalarPrototype()->end()
->end() ->end()
->scalarNode('proxy') ->arrayNode('headers')
->info('The URL of the proxy to pass requests through or null for automatic detection.') ->info('Associative array: header => value(s).')
->end() ->useAttributeAsKey('name')
->scalarNode('no_proxy') ->normalizeKeys(false)
->info('A comma separated list of hosts that do not require a proxy to be reached.') ->variablePrototype()->end()
->end() ->end()
->floatNode('timeout') ->integerNode('max_redirects')
->info('Defaults to "default_socket_timeout" ini parameter.') ->info('The maximum number of redirects to follow.')
->end() ->end()
->scalarNode('bindto') ->scalarNode('http_version')
->info('A network interface name, IP address, a host name or a UNIX socket to bind to.') ->info('The default HTTP version, typically 1.1 or 2.0, leave to null for the best version.')
->end() ->end()
->booleanNode('verify_peer') ->arrayNode('resolve')
->info('Indicates if the peer should be verified in a SSL/TLS context.') ->info('Associative array: domain => IP.')
->end() ->useAttributeAsKey('host')
->booleanNode('verify_host') ->beforeNormalization()
->info('Indicates if the host should exist as a certificate common name.') ->always(function ($config) {
->end() if (!\is_array($config)) {
->scalarNode('cafile') return [];
->info('A certificate authority file.') }
->end() if (!isset($config['host'])) {
->scalarNode('capath') return $config;
->info('A directory that contains multiple certificate authority files.') }
->end()
->scalarNode('local_cert') return [$config['host'] => $config['value']];
->info('A PEM formatted certificate file.') })
->end() ->end()
->scalarNode('local_pk') ->normalizeKeys(false)
->info('A private key file.') ->scalarPrototype()->end()
->end() ->end()
->scalarNode('passphrase') ->scalarNode('proxy')
->info('The passphrase used to encrypt the "local_pk" file.') ->info('The URL of the proxy to pass requests through or null for automatic detection.')
->end() ->end()
->scalarNode('ciphers') ->scalarNode('no_proxy')
->info('A list of SSL/TLS ciphers separated by colons, commas or spaces (e.g. "RC4-SHA:TLS13-AES-128-GCM-SHA256"...)') ->info('A comma separated list of hosts that do not require a proxy to be reached.')
->end() ->end()
->arrayNode('peer_fingerprint') ->floatNode('timeout')
->info('Associative array: hashing algorithm => hash(es).') ->info('Defaults to "default_socket_timeout" ini parameter.')
->normalizeKeys(false) ->end()
->children() ->scalarNode('bindto')
->variableNode('sha1')->end() ->info('A network interface name, IP address, a host name or a UNIX socket to bind to.')
->variableNode('pin-sha256')->end() ->end()
->variableNode('md5')->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. "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()
->end()
->end()
->end() ->end()
->end() ->end()
->end() ->end()

View File

@ -61,8 +61,8 @@ use Symfony\Component\Form\FormTypeExtensionInterface;
use Symfony\Component\Form\FormTypeGuesserInterface; use Symfony\Component\Form\FormTypeGuesserInterface;
use Symfony\Component\Form\FormTypeInterface; use Symfony\Component\Form\FormTypeInterface;
use Symfony\Component\HttpClient\HttpClient; use Symfony\Component\HttpClient\HttpClient;
use Symfony\Component\HttpClient\HttpClientTrait;
use Symfony\Component\HttpClient\Psr18Client; use Symfony\Component\HttpClient\Psr18Client;
use Symfony\Component\HttpClient\ScopingHttpClient;
use Symfony\Component\HttpKernel\CacheClearer\CacheClearerInterface; use Symfony\Component\HttpKernel\CacheClearer\CacheClearerInterface;
use Symfony\Component\HttpKernel\CacheWarmer\CacheWarmerInterface; use Symfony\Component\HttpKernel\CacheWarmer\CacheWarmerInterface;
use Symfony\Component\HttpKernel\Controller\ArgumentValueResolverInterface; use Symfony\Component\HttpKernel\Controller\ArgumentValueResolverInterface;
@ -117,7 +117,6 @@ use Symfony\Component\Workflow\WorkflowInterface;
use Symfony\Component\Yaml\Command\LintCommand as BaseYamlLintCommand; use Symfony\Component\Yaml\Command\LintCommand as BaseYamlLintCommand;
use Symfony\Component\Yaml\Yaml; use Symfony\Component\Yaml\Yaml;
use Symfony\Contracts\Cache\CacheInterface; use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface; use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\Service\ResetInterface; use Symfony\Contracts\Service\ResetInterface;
use Symfony\Contracts\Service\ServiceSubscriberInterface; use Symfony\Contracts\Service\ServiceSubscriberInterface;
@ -1803,42 +1802,23 @@ class FrameworkExtension extends Extension
$loader->load('http_client.xml'); $loader->load('http_client.xml');
$merger = new class() { $container->getDefinition('http_client')->setArguments([$config['default_options'] ?? [], $config['max_host_connections'] ?? 6]);
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)) { if (!$hasPsr18 = interface_exists(ClientInterface::class)) {
$container->removeDefinition('psr18.http_client'); $container->removeDefinition('psr18.http_client');
$container->removeAlias(ClientInterface::class); $container->removeAlias(ClientInterface::class);
} }
foreach ($config['clients'] as $name => $clientConfig) { foreach ($config['scoped_clients'] as $name => $scopeConfig) {
$options = $merger->merge($clientConfig['default_options'] ?? [], $defaultOptions); if ('http_client' === $name) {
throw new InvalidArgumentException(sprintf('Invalid scope name: "%s" is reserved.', $name));
}
$container->register($name, HttpClientInterface::class) $scope = $scopeConfig['scope'];
->setFactory([HttpClient::class, 'create']) unset($scopeConfig['scope']);
->setArguments([$options, $clientConfig['max_host_connections'] ?? $config['max_host_connections'] ?? 6]);
$container->register($name, ScopingHttpClient::class)
->setArguments([new Reference('http_client'), [$scope => $scopeConfig], $scope]);
$container->registerAliasForArgument($name, HttpClientInterface::class); $container->registerAliasForArgument($name, HttpClientInterface::class);

View File

@ -458,30 +458,26 @@
<xsd:complexType name="http_client"> <xsd:complexType name="http_client">
<xsd:sequence> <xsd:sequence>
<xsd:element name="default-options" type="http_client_options" minOccurs="0" /> <xsd:element name="default-options" type="http_client_default_options" minOccurs="0" />
<xsd:element name="client" type="http_client_client" minOccurs="0" maxOccurs="unbounded" /> <xsd:element name="scoped-client" type="http_client_scope_options" minOccurs="0" maxOccurs="unbounded" />
</xsd:sequence> </xsd:sequence>
<xsd:attribute name="enabled" type="xsd:boolean" /> <xsd:attribute name="enabled" type="xsd:boolean" />
<xsd:attribute name="max-host-connections" type="xsd:integer" /> <xsd:attribute name="max-host-connections" type="xsd:integer" />
</xsd:complexType> </xsd:complexType>
<xsd:complexType name="http_client_options" mixed="true"> <xsd:complexType name="http_client_default_options" mixed="true">
<xsd:choice maxOccurs="unbounded"> <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="resolve" type="http_resolve" minOccurs="0" maxOccurs="unbounded" />
<xsd:element name="header" type="http_header" 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:element name="peer-fingerprint" type="fingerprint" minOccurs="0" maxOccurs="unbounded" />
</xsd:choice> </xsd:choice>
<xsd:attribute name="max-redirects" type="xsd:integer" />
<xsd:attribute name="http-version" type="xsd:string" />
<xsd:attribute name="proxy" type="xsd:string" /> <xsd:attribute name="proxy" type="xsd:string" />
<xsd:attribute name="no-proxy" type="xsd:string" />
<xsd:attribute name="timeout" type="xsd:float" /> <xsd:attribute name="timeout" type="xsd:float" />
<xsd:attribute name="bindto" type="xsd:string" /> <xsd:attribute name="bindto" type="xsd:string" />
<xsd:attribute name="verify-peer" type="xsd:boolean" /> <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="verify-host" type="xsd:boolean" />
<xsd:attribute name="cafile" type="xsd:string" /> <xsd:attribute name="cafile" type="xsd:string" />
<xsd:attribute name="capath" type="xsd:string" /> <xsd:attribute name="capath" type="xsd:string" />
@ -489,14 +485,35 @@
<xsd:attribute name="local-pk" type="xsd:string" /> <xsd:attribute name="local-pk" type="xsd:string" />
<xsd:attribute name="passphrase" type="xsd:string" /> <xsd:attribute name="passphrase" type="xsd:string" />
<xsd:attribute name="ciphers" type="xsd:string" /> <xsd:attribute name="ciphers" type="xsd:string" />
</xsd:complexType> </xsd:complexType>
<xsd:complexType name="http_client_client"> <xsd:complexType name="http_client_scope_options" mixed="true">
<xsd:sequence> <xsd:choice maxOccurs="unbounded">
<xsd:element name="default-options" type="http_client_options" minOccurs="0" /> <xsd:element name="query" type="http_query" minOccurs="0" maxOccurs="unbounded" />
</xsd:sequence> <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="name" type="xsd:string" /> <xsd:attribute name="name" type="xsd:string" />
<xsd:attribute name="max-host-connections" type="xsd:integer" /> <xsd:attribute name="scope" type="xsd:string" />
<xsd:attribute name="base-uri" type="xsd:string" />
<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="proxy" type="xsd:string" />
<xsd:attribute name="no-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="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>
<xsd:complexType name="fingerprint"> <xsd:complexType name="fingerprint">

View File

@ -336,7 +336,7 @@ class ConfigurationTest extends TestCase
'disallow_search_engine_index' => true, 'disallow_search_engine_index' => true,
'http_client' => [ 'http_client' => [
'enabled' => !class_exists(FullStack::class) && class_exists(HttpClient::class), 'enabled' => !class_exists(FullStack::class) && class_exists(HttpClient::class),
'clients' => [], 'scoped_clients' => [],
], ],
'mailer' => [ 'mailer' => [
'dsn' => 'smtp://null', 'dsn' => 'smtp://null',

View File

@ -4,9 +4,9 @@ $container->loadFromExtension('framework', [
'http_client' => [ 'http_client' => [
'max_host_connections' => 4, 'max_host_connections' => 4,
'default_options' => null, 'default_options' => null,
'clients' => [ 'scoped_clients' => [
'foo' => [ 'foo' => [
'default_options' => null, 'base_uri' => 'http://example.com',
], ],
], ],
], ],

View File

@ -3,12 +3,9 @@
$container->loadFromExtension('framework', [ $container->loadFromExtension('framework', [
'http_client' => [ 'http_client' => [
'default_options' => [ 'default_options' => [
'auth_basic' => 'foo:bar',
'query' => ['foo' => 'bar', 'bar' => 'baz'],
'headers' => ['X-powered' => 'PHP'], 'headers' => ['X-powered' => 'PHP'],
'max_redirects' => 2, 'max_redirects' => 2,
'http_version' => '2.0', 'http_version' => '2.0',
'base_uri' => 'http://example.com',
'resolve' => ['localhost' => '127.0.0.1'], 'resolve' => ['localhost' => '127.0.0.1'],
'proxy' => 'proxy.org', 'proxy' => 'proxy.org',
'timeout' => 3.5, 'timeout' => 3.5,

View File

@ -6,12 +6,10 @@ $container->loadFromExtension('framework', [
'default_options' => [ 'default_options' => [
'headers' => ['foo' => 'bar'], 'headers' => ['foo' => 'bar'],
], ],
'clients' => [ 'scoped_clients' => [
'foo' => [ 'foo' => [
'max_host_connections' => 5, 'base_uri' => 'http://example.com',
'default_options' => [ 'headers' => ['bar' => 'baz'],
'headers' => ['bar' => 'baz'],
],
], ],
], ],
], ],

View File

@ -8,9 +8,10 @@
<framework:config> <framework:config>
<framework:http-client max-host-connections="4"> <framework:http-client max-host-connections="4">
<framework:default-options /> <framework:default-options />
<framework:client name="foo"> <framework:scoped-client
<framework:default-options /> name="foo"
</framework:client> base-uri="http://example.com"
/>
</framework:http-client> </framework:http-client>
</framework:config> </framework:config>
</container> </container>

View File

@ -12,10 +12,8 @@
bindto="127.0.0.1" bindto="127.0.0.1"
timeout="3.5" timeout="3.5"
verify-peer="true" verify-peer="true"
auth-basic="foo:bar"
max-redirects="2" max-redirects="2"
http-version="2.0" http-version="2.0"
base-uri="http://example.com"
verify-host="true" verify-host="true"
cafile="/etc/ssl/cafile" cafile="/etc/ssl/cafile"
capath="/etc/ssl" capath="/etc/ssl"
@ -24,8 +22,6 @@
passphrase="password123456" passphrase="password123456"
ciphers="RC4-SHA:TLS13-AES-128-GCM-SHA256" ciphers="RC4-SHA:TLS13-AES-128-GCM-SHA256"
> >
<framework:query key="foo">bar</framework:query>
<framework:query key="bar">baz</framework:query>
<framework:header name="X-powered">PHP</framework:header> <framework:header name="X-powered">PHP</framework:header>
<framework:resolve host="localhost">127.0.0.1</framework:resolve> <framework:resolve host="localhost">127.0.0.1</framework:resolve>
<framework:peer-fingerprint> <framework:peer-fingerprint>

View File

@ -10,11 +10,9 @@
<framework:default-options> <framework:default-options>
<framework:header name="foo">bar</framework:header> <framework:header name="foo">bar</framework:header>
</framework:default-options> </framework:default-options>
<framework:client name="foo" max-host-connections="5"> <framework:scoped-client name="foo" base-uri="http://example.com">
<framework:default-options> <framework:header name="bar">baz</framework:header>
<framework:header name="bar">baz</framework:header> </framework:scoped-client>
</framework:default-options>
</framework:client>
</framework:http-client> </framework:http-client>
</framework:config> </framework:config>
</container> </container>

View File

@ -2,6 +2,6 @@ framework:
http_client: http_client:
max_host_connections: 4 max_host_connections: 4
default_options: ~ default_options: ~
clients: scoped_clients:
foo: foo:
default_options: ~ base_uri: http://example.com

View File

@ -1,13 +1,10 @@
framework: framework:
http_client: http_client:
default_options: default_options:
auth_basic: foo:bar
query: {'foo': 'bar', 'bar': 'baz'}
headers: headers:
X-powered: PHP X-powered: PHP
max_redirects: 2 max_redirects: 2
http_version: 2.0 http_version: 2.0
base_uri: 'http://example.com'
resolve: {'localhost': '127.0.0.1'} resolve: {'localhost': '127.0.0.1'}
proxy: proxy.org proxy: proxy.org
timeout: 3.5 timeout: 3.5

View File

@ -3,8 +3,7 @@ framework:
max_host_connections: 4 max_host_connections: 4
default_options: default_options:
headers: {'foo': 'bar'} headers: {'foo': 'bar'}
clients: scoped_clients:
foo: foo:
max_host_connections: 5 base_uri: http://example.com
default_options: headers: {'bar': 'baz'}
headers: {'bar': 'baz'}

View File

@ -35,6 +35,7 @@ use Symfony\Component\DependencyInjection\Loader\ClosureLoader;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBag; use Symfony\Component\DependencyInjection\ParameterBag\ParameterBag;
use Symfony\Component\DependencyInjection\Reference; use Symfony\Component\DependencyInjection\Reference;
use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpClient\ScopingHttpClient;
use Symfony\Component\HttpKernel\DependencyInjection\LoggerPass; use Symfony\Component\HttpKernel\DependencyInjection\LoggerPass;
use Symfony\Component\Messenger\Tests\Fixtures\DummyMessage; use Symfony\Component\Messenger\Tests\Fixtures\DummyMessage;
use Symfony\Component\Messenger\Tests\Fixtures\SecondMessage; use Symfony\Component\Messenger\Tests\Fixtures\SecondMessage;
@ -53,7 +54,6 @@ use Symfony\Component\Validator\DependencyInjection\AddConstraintValidatorsPass;
use Symfony\Component\Validator\Mapping\Loader\PropertyInfoLoader; use Symfony\Component\Validator\Mapping\Loader\PropertyInfoLoader;
use Symfony\Component\Validator\Validation; use Symfony\Component\Validator\Validation;
use Symfony\Component\Workflow; use Symfony\Component\Workflow;
use Symfony\Contracts\HttpClient\HttpClientInterface;
abstract class FrameworkExtensionTest extends TestCase abstract class FrameworkExtensionTest extends TestCase
{ {
@ -1406,25 +1406,34 @@ abstract class FrameworkExtensionTest extends TestCase
$this->assertTrue($container->hasDefinition('http_client'), '->registerHttpClientConfiguration() loads http_client.xml'); $this->assertTrue($container->hasDefinition('http_client'), '->registerHttpClientConfiguration() loads http_client.xml');
$defaultOptions = [ $defaultOptions = [
'query' => [],
'headers' => [], 'headers' => [],
'resolve' => [], 'resolve' => [],
]; ];
$this->assertSame([$defaultOptions, 4], $container->getDefinition('http_client')->getArguments()); $this->assertSame([$defaultOptions, 4], $container->getDefinition('http_client')->getArguments());
$this->assertTrue($container->hasDefinition('foo'), 'should have the "foo" service.'); $this->assertTrue($container->hasDefinition('foo'), 'should have the "foo" service.');
$this->assertSame(HttpClientInterface::class, $container->getDefinition('foo')->getClass()); $this->assertSame(ScopingHttpClient::class, $container->getDefinition('foo')->getClass());
$this->assertSame([$defaultOptions, 4], $container->getDefinition('foo')->getArguments());
} }
public function testHttpClientOverrideDefaultOptions() public function testHttpClientOverrideDefaultOptions()
{ {
$container = $this->createContainerFromFile('http_client_override_default_options'); $container = $this->createContainerFromFile('http_client_override_default_options');
$this->assertSame(['foo' => ['bar']], $container->getDefinition('http_client')->getArgument(0)['headers']); $this->assertSame(['foo' => 'bar'], $container->getDefinition('http_client')->getArgument(0)['headers']);
$this->assertSame(4, $container->getDefinition('http_client')->getArgument(1)); $this->assertSame(4, $container->getDefinition('http_client')->getArgument(1));
$this->assertSame(['bar' => ['baz'], 'foo' => ['bar']], $container->getDefinition('foo')->getArgument(0)['headers']);
$this->assertSame(5, $container->getDefinition('foo')->getArgument(1)); $expected = [
'http\://example\.com/' => [
'base_uri' => 'http://example.com',
'headers' => [
'bar' => 'baz',
],
'query' => [],
'resolve' => [],
],
];
$this->assertSame($expected, $container->getDefinition('foo')->getArgument(1));
} }
public function testHttpClientFullDefaultOptions() public function testHttpClientFullDefaultOptions()
@ -1433,12 +1442,9 @@ abstract class FrameworkExtensionTest extends TestCase
$defaultOptions = $container->getDefinition('http_client')->getArgument(0); $defaultOptions = $container->getDefinition('http_client')->getArgument(0);
$this->assertSame('foo:bar', $defaultOptions['auth_basic']); $this->assertSame(['X-powered' => 'PHP'], $defaultOptions['headers']);
$this->assertSame(['foo' => 'bar', 'bar' => 'baz'], $defaultOptions['query']);
$this->assertSame(['x-powered' => ['PHP']], $defaultOptions['headers']);
$this->assertSame(2, $defaultOptions['max_redirects']); $this->assertSame(2, $defaultOptions['max_redirects']);
$this->assertSame(2.0, (float) $defaultOptions['http_version']); $this->assertSame(2.0, (float) $defaultOptions['http_version']);
$this->assertSame('http://example.com', $defaultOptions['base_uri']);
$this->assertSame(['localhost' => '127.0.0.1'], $defaultOptions['resolve']); $this->assertSame(['localhost' => '127.0.0.1'], $defaultOptions['resolve']);
$this->assertSame('proxy.org', $defaultOptions['proxy']); $this->assertSame('proxy.org', $defaultOptions['proxy']);
$this->assertSame(3.5, $defaultOptions['timeout']); $this->assertSame(3.5, $defaultOptions['timeout']);

View File

@ -240,7 +240,7 @@ class MockResponse implements ResponseInterface
$info = $mock->getInfo() ?: []; $info = $mock->getInfo() ?: [];
$response->info['http_code'] = ($info['http_code'] ?? 0) ?: $mock->getStatusCode(false) ?: 200; $response->info['http_code'] = ($info['http_code'] ?? 0) ?: $mock->getStatusCode(false) ?: 200;
$response->addResponseHeaders($info['response_headers'] ?? [], $response->info, $response->headers); $response->addResponseHeaders($info['response_headers'] ?? [], $response->info, $response->headers);
$dlSize = (int) ($response->headers['content-length'][0] ?? 0); $dlSize = isset($response->headers['content-encoding']) ? 0 : (int) ($response->headers['content-length'][0] ?? 0);
$response->info = [ $response->info = [
'start_time' => $response->info['start_time'], 'start_time' => $response->info['start_time'],
@ -282,7 +282,7 @@ class MockResponse implements ResponseInterface
// "notify" completion // "notify" completion
$onProgress($offset, $dlSize, $response->info); $onProgress($offset, $dlSize, $response->info);
if (isset($response->headers['content-length']) && $offset !== $dlSize) { if ($dlSize && $offset !== $dlSize) {
throw new TransportException(sprintf('Transfer closed with %s bytes remaining to read.', $dlSize - $offset)); throw new TransportException(sprintf('Transfer closed with %s bytes remaining to read.', $dlSize - $offset));
} }
} }

View File

@ -122,8 +122,7 @@ class ResponseHeaderBag extends HeaderBag
parent::set($key, $values, $replace); parent::set($key, $values, $replace);
// ensure the cache-control header has sensible defaults // ensure the cache-control header has sensible defaults
if (\in_array($uniqueKey, ['cache-control', 'etag', 'last-modified', 'expires'], true)) { if (\in_array($uniqueKey, ['cache-control', 'etag', 'last-modified', 'expires'], true) && '' !== $computed = $this->computeCacheControlValue()) {
$computed = $this->computeCacheControlValue();
$this->headers['cache-control'] = [$computed]; $this->headers['cache-control'] = [$computed];
$this->headerNames['cache-control'] = 'Cache-Control'; $this->headerNames['cache-control'] = 'Cache-Control';
$this->computedCacheControl = $this->parseCacheControl($computed); $this->computedCacheControl = $this->parseCacheControl($computed);

View File

@ -16,6 +16,7 @@ use Psr\Log\NullLogger;
use Symfony\Component\HttpClient\HttpClient; use Symfony\Component\HttpClient\HttpClient;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\Mime\Part\AbstractPart; use Symfony\Component\Mime\Part\AbstractPart;
use Symfony\Component\Mime\Part\DataPart; use Symfony\Component\Mime\Part\DataPart;
use Symfony\Component\Mime\Part\Multipart\FormDataPart; use Symfony\Component\Mime\Part\Multipart\FormDataPart;
@ -60,7 +61,16 @@ final class HttpClientKernel implements HttpKernelInterface
$this->logger->debug(sprintf('Response: %s %s', $response->getStatusCode(), $request->getUri())); $this->logger->debug(sprintf('Response: %s %s', $response->getStatusCode(), $request->getUri()));
return new Response($response->getContent(!$catch), $response->getStatusCode(), $response->getHeaders(!$catch)); $response = new Response($response->getContent(!$catch), $response->getStatusCode(), $response->getHeaders(!$catch));
$response->headers = new class($response->headers->all()) extends ResponseHeaderBag {
protected function computeCacheControlValue()
{
return $this->getCacheControlHeader(); // preserve the original value
}
};
return $response;
} }
private function getBody(Request $request): ?AbstractPart private function getBody(Request $request): ?AbstractPart