diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php index 24b06a79e3f4e..4a0cc846d224a 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php @@ -1655,6 +1655,11 @@ private function addHttpClientSection(ArrayNodeDefinition $rootNode, callable $e ->normalizeKeys(false) ->variablePrototype()->end() ->end() + ->arrayNode('vars') + ->info('Associative array: the default vars used to expand the templated URI.') + ->normalizeKeys(false) + ->variablePrototype()->end() + ->end() ->integerNode('max_redirects') ->info('The maximum number of redirects to follow.') ->end() diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index 842eb8eab4425..f734612cd5a31 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -80,6 +80,7 @@ use Symfony\Component\HttpClient\Retry\GenericRetryStrategy; use Symfony\Component\HttpClient\RetryableHttpClient; use Symfony\Component\HttpClient\ScopingHttpClient; +use Symfony\Component\HttpClient\UriTemplateHttpClient; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Attribute\AsController; use Symfony\Component\HttpKernel\CacheClearer\CacheClearerInterface; @@ -2338,6 +2339,8 @@ private function registerHttpClientConfiguration(array $config, ContainerBuilder $options = $config['default_options'] ?? []; $retryOptions = $options['retry_failed'] ?? ['enabled' => false]; unset($options['retry_failed']); + $defaultUriTemplateVars = $options['vars'] ?? []; + unset($options['vars']); $container->getDefinition('http_client')->setArguments([$options, $config['max_host_connections'] ?? 6]); if (!$hasPsr18 = ContainerBuilder::willBeAvailable('psr/http-client', ClientInterface::class, ['symfony/framework-bundle', 'symfony/http-client'])) { @@ -2349,11 +2352,31 @@ private function registerHttpClientConfiguration(array $config, ContainerBuilder $container->removeDefinition(HttpClient::class); } - if ($this->readConfigEnabled('http_client.retry_failed', $container, $retryOptions)) { + if ($hasRetryFailed = $this->readConfigEnabled('http_client.retry_failed', $container, $retryOptions)) { $this->registerRetryableHttpClient($retryOptions, 'http_client', $container); } - $httpClientId = ($retryOptions['enabled'] ?? false) ? 'http_client.retryable.inner' : ($this->isInitializedConfigEnabled('profiler') ? '.debug.http_client.inner' : 'http_client'); + if ($hasUriTemplate = class_exists(UriTemplateHttpClient::class)) { + if (ContainerBuilder::willBeAvailable('guzzlehttp/uri-template', \GuzzleHttp\UriTemplate\UriTemplate::class, [])) { + $container->setAlias('http_client.uri_template_expander', 'http_client.uri_template_expander.guzzle'); + } elseif (ContainerBuilder::willBeAvailable('rize/uri-template', \Rize\UriTemplate::class, [])) { + $container->setAlias('http_client.uri_template_expander', 'http_client.uri_template_expander.rize'); + } + + $container + ->getDefinition('http_client.uri_template') + ->setArgument(2, $defaultUriTemplateVars); + } elseif ($defaultUriTemplateVars) { + throw new LogicException('Support for URI template requires symfony/http-client 6.3 or higher, try upgrading.'); + } + + $httpClientId = match (true) { + $hasUriTemplate => 'http_client.uri_template.inner', + $hasRetryFailed => 'http_client.retryable.inner', + $this->isInitializedConfigEnabled('profiler') => '.debug.http_client.inner', + default => 'http_client', + }; + foreach ($config['scoped_clients'] as $name => $scopeConfig) { if ('http_client' === $name) { throw new InvalidArgumentException(sprintf('Invalid scope name: "%s" is reserved.', $name)); @@ -2384,6 +2407,17 @@ private function registerHttpClientConfiguration(array $config, ContainerBuilder $this->registerRetryableHttpClient($retryOptions, $name, $container); } + if ($hasUriTemplate) { + $container + ->register($name.'.uri_template', UriTemplateHttpClient::class) + ->setDecoratedService($name, null, 7) // Between TraceableHttpClient (5) and RetryableHttpClient (10) + ->setArguments([ + new Reference('.inner'), + new Reference('http_client.uri_template_expander', ContainerInterface::NULL_ON_INVALID_REFERENCE), + $defaultUriTemplateVars, + ]); + } + $container->registerAliasForArgument($name, HttpClientInterface::class); if ($hasPsr18) { diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/http_client.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/http_client.php index ba70b90ad654b..7a9f2e3b14c92 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/http_client.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/http_client.php @@ -18,6 +18,7 @@ use Symfony\Component\HttpClient\HttplugClient; use Symfony\Component\HttpClient\Psr18Client; use Symfony\Component\HttpClient\Retry\GenericRetryStrategy; +use Symfony\Component\HttpClient\UriTemplateHttpClient; use Symfony\Contracts\HttpClient\HttpClientInterface; return static function (ContainerConfigurator $container) { @@ -60,5 +61,25 @@ abstract_arg('max delay ms'), abstract_arg('jitter'), ]) + + ->set('http_client.uri_template', UriTemplateHttpClient::class) + ->decorate('http_client', null, 7) // Between TraceableHttpClient (5) and RetryableHttpClient (10) + ->args([ + service('.inner'), + service('http_client.uri_template_expander')->nullOnInvalid(), + abstract_arg('default vars'), + ]) + + ->set('http_client.uri_template_expander.guzzle', \Closure::class) + ->factory([\Closure::class, 'fromCallable']) + ->args([ + [\GuzzleHttp\UriTemplate\UriTemplate::class, 'expand'], + ]) + + ->set('http_client.uri_template_expander.rize', \Closure::class) + ->factory([\Closure::class, 'fromCallable']) + ->args([ + [inline_service(\Rize\UriTemplate::class), 'expand'], + ]) ; }; diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTestCase.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTestCase.php index 848481ee59669..e53bedf6ee497 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTestCase.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTestCase.php @@ -55,6 +55,7 @@ use Symfony\Component\HttpClient\MockHttpClient; use Symfony\Component\HttpClient\RetryableHttpClient; use Symfony\Component\HttpClient\ScopingHttpClient; +use Symfony\Component\HttpClient\UriTemplateHttpClient; use Symfony\Component\HttpKernel\DependencyInjection\LoggerPass; use Symfony\Component\HttpKernel\Fragment\FragmentUriGeneratorInterface; use Symfony\Component\Messenger\Transport\TransportFactory; @@ -2003,7 +2004,7 @@ public function testHttpClientMockResponseFactory() { $container = $this->createContainerFromFile('http_client_mock_response_factory'); - $definition = $container->getDefinition('http_client.mock_client'); + $definition = $container->getDefinition(($uriTemplateHttpClientExists = class_exists(UriTemplateHttpClient::class)) ? 'http_client.uri_template.inner.mock_client' : 'http_client.mock_client'); $this->assertSame(MockHttpClient::class, $definition->getClass()); $this->assertCount(1, $definition->getArguments()); @@ -2011,7 +2012,7 @@ public function testHttpClientMockResponseFactory() $argument = $definition->getArgument(0); $this->assertInstanceOf(Reference::class, $argument); - $this->assertSame('http_client', current($definition->getDecoratedService())); + $this->assertSame($uriTemplateHttpClientExists ? 'http_client.uri_template.inner' : 'http_client', current($definition->getDecoratedService())); $this->assertSame('my_response_factory', (string) $argument); } diff --git a/src/Symfony/Component/HttpClient/CHANGELOG.md b/src/Symfony/Component/HttpClient/CHANGELOG.md index d6d50d2d5f9d7..2523499e752c9 100644 --- a/src/Symfony/Component/HttpClient/CHANGELOG.md +++ b/src/Symfony/Component/HttpClient/CHANGELOG.md @@ -1,6 +1,11 @@ CHANGELOG ========= +6.3 +--- + + * Add `UriTemplateHttpClient` to use URI templates as specified in the RFC 6570 + 6.2 --- diff --git a/src/Symfony/Component/HttpClient/HttpClientTrait.php b/src/Symfony/Component/HttpClient/HttpClientTrait.php index 38c544b4b1234..767893bf4bcae 100644 --- a/src/Symfony/Component/HttpClient/HttpClientTrait.php +++ b/src/Symfony/Component/HttpClient/HttpClientTrait.php @@ -245,6 +245,10 @@ private static function mergeDefaultOptions(array $options, array $defaultOption throw new InvalidArgumentException(sprintf('Option "auth_ntlm" is not supported by "%s", '.$msg, __CLASS__, CurlHttpClient::class)); } + if ('vars' === $name) { + throw new InvalidArgumentException(sprintf('Option "vars" is not supported by "%s", try using "%s" instead.', __CLASS__, UriTemplateHttpClient::class)); + } + $alternatives = []; foreach ($defaultOptions as $k => $v) { diff --git a/src/Symfony/Component/HttpClient/HttpOptions.php b/src/Symfony/Component/HttpClient/HttpOptions.php index a07fac7eda833..57590d3c131fc 100644 --- a/src/Symfony/Component/HttpClient/HttpOptions.php +++ b/src/Symfony/Component/HttpClient/HttpOptions.php @@ -135,6 +135,16 @@ public function setBaseUri(string $uri): static return $this; } + /** + * @return $this + */ + public function setVars(array $vars): static + { + $this->options['vars'] = $vars; + + return $this; + } + /** * @return $this */ diff --git a/src/Symfony/Component/HttpClient/Tests/UriTemplateHttpClientTest.php b/src/Symfony/Component/HttpClient/Tests/UriTemplateHttpClientTest.php new file mode 100644 index 0000000000000..23e48b50d8c03 --- /dev/null +++ b/src/Symfony/Component/HttpClient/Tests/UriTemplateHttpClientTest.php @@ -0,0 +1,129 @@ + + * + * 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\UriTemplateHttpClient; + +final class UriTemplateHttpClientTest extends TestCase +{ + public function testExpanderIsCalled() + { + $client = new UriTemplateHttpClient( + new MockHttpClient(), + function (string $url, array $vars): string { + $this->assertSame('https://foo.tld/{version}/{resource}{?page}', $url); + $this->assertSame([ + 'version' => 'v2', + 'resource' => 'users', + 'page' => 33, + ], $vars); + + return 'https://foo.tld/v2/users?page=33'; + }, + [ + 'version' => 'v2', + ], + ); + $this->assertSame('https://foo.tld/v2/users?page=33', $client->request('GET', 'https://foo.tld/{version}/{resource}{?page}', [ + 'vars' => [ + 'resource' => 'users', + 'page' => 33, + ], + ])->getInfo('url')); + } + + public function testWithOptionsAppendsVarsToDefaultVars() + { + $client = new UriTemplateHttpClient( + new MockHttpClient(), + function (string $url, array $vars): string { + $this->assertSame('https://foo.tld/{bar}', $url); + $this->assertSame([ + 'bar' => 'ccc', + ], $vars); + + return 'https://foo.tld/ccc'; + }, + ); + $this->assertSame('https://foo.tld/{bar}', $client->request('GET', 'https://foo.tld/{bar}')->getInfo('url')); + + $client = $client->withOptions([ + 'vars' => [ + 'bar' => 'ccc', + ], + ]); + $this->assertSame('https://foo.tld/ccc', $client->request('GET', 'https://foo.tld/{bar}')->getInfo('url')); + } + + public function testExpanderIsNotCalledWithEmptyVars() + { + $this->expectNotToPerformAssertions(); + + $client = new UriTemplateHttpClient(new MockHttpClient(), $this->fail(...)); + $client->request('GET', 'https://foo.tld/bar', [ + 'vars' => [], + ]); + } + + public function testExpanderIsNotCalledWithNoVarsAtAll() + { + $this->expectNotToPerformAssertions(); + + $client = new UriTemplateHttpClient(new MockHttpClient(), $this->fail(...)); + $client->request('GET', 'https://foo.tld/bar'); + } + + public function testRequestWithNonArrayVarsOption() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('The "vars" option must be an array.'); + + (new UriTemplateHttpClient(new MockHttpClient()))->request('GET', 'https://foo.tld', [ + 'vars' => 'should be an array', + ]); + } + + public function testWithOptionsWithNonArrayVarsOption() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('The "vars" option must be an array.'); + + (new UriTemplateHttpClient(new MockHttpClient()))->withOptions([ + 'vars' => new \stdClass(), + ]); + } + + public function testVarsOptionIsNotPropagated() + { + $client = new UriTemplateHttpClient( + new MockHttpClient(function (string $method, string $url, array $options): MockResponse { + $this->assertArrayNotHasKey('vars', $options); + + return new MockResponse(); + }), + static fn (): string => 'ccc', + ); + + $client->withOptions([ + 'vars' => [ + 'foo' => 'bar', + ], + ])->request('GET', 'https://foo.tld', [ + 'vars' => [ + 'foo2' => 'bar2', + ], + ]); + } +} diff --git a/src/Symfony/Component/HttpClient/UriTemplateHttpClient.php b/src/Symfony/Component/HttpClient/UriTemplateHttpClient.php new file mode 100644 index 0000000000000..55ae724f12207 --- /dev/null +++ b/src/Symfony/Component/HttpClient/UriTemplateHttpClient.php @@ -0,0 +1,84 @@ + + * + * 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\Service\ResetInterface; + +class UriTemplateHttpClient implements HttpClientInterface, ResetInterface +{ + use DecoratorTrait; + + /** + * @param (\Closure(string $url, array $vars): string)|null $expander + */ + public function __construct(HttpClientInterface $client = null, private ?\Closure $expander = null, private array $defaultVars = []) + { + $this->client = $client ?? HttpClient::create(); + } + + public function request(string $method, string $url, array $options = []): ResponseInterface + { + $vars = $this->defaultVars; + + if (\array_key_exists('vars', $options)) { + if (!\is_array($options['vars'])) { + throw new \InvalidArgumentException('The "vars" option must be an array.'); + } + + $vars = [...$vars, ...$options['vars']]; + unset($options['vars']); + } + + if ($vars) { + $url = ($this->expander ??= $this->createExpanderFromPopularVendors())($url, $vars); + } + + return $this->client->request($method, $url, $options); + } + + public function withOptions(array $options): static + { + if (!\is_array($options['vars'] ?? [])) { + throw new \InvalidArgumentException('The "vars" option must be an array.'); + } + + $clone = clone $this; + $clone->defaultVars = [...$clone->defaultVars, ...$options['vars'] ?? []]; + unset($options['vars']); + + $clone->client = $this->client->withOptions($options); + + return $clone; + } + + /** + * @return \Closure(string $url, array $vars): string + */ + private function createExpanderFromPopularVendors(): \Closure + { + if (class_exists(\GuzzleHttp\UriTemplate\UriTemplate::class)) { + return \GuzzleHttp\UriTemplate\UriTemplate::expand(...); + } + + if (class_exists(\League\Uri\UriTemplate::class)) { + return static fn (string $url, array $vars): string => (new \League\Uri\UriTemplate($url))->expand($vars); + } + + if (class_exists(\Rize\UriTemplate::class)) { + return (new \Rize\UriTemplate())->expand(...); + } + + throw new \LogicException('Support for URI template requires a vendor to expand the URI. Run "composer require guzzlehttp/uri-template" or pass your own expander \Closure implementation.'); + } +}