Skip to content

Commit 803a54e

Browse files
fancywebnicolas-grekas
authored andcommitted
[HttpClient] Add UriTemplateHttpClient
1 parent 383ff0b commit 803a54e

File tree

9 files changed

+297
-4
lines changed

9 files changed

+297
-4
lines changed

src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php

+5
Original file line numberDiff line numberDiff line change
@@ -1655,6 +1655,11 @@ private function addHttpClientSection(ArrayNodeDefinition $rootNode, callable $e
16551655
->normalizeKeys(false)
16561656
->variablePrototype()->end()
16571657
->end()
1658+
->arrayNode('vars')
1659+
->info('Associative array: the default vars used to expand the templated URI.')
1660+
->normalizeKeys(false)
1661+
->variablePrototype()->end()
1662+
->end()
16581663
->integerNode('max_redirects')
16591664
->info('The maximum number of redirects to follow.')
16601665
->end()

src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php

+36-2
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@
8080
use Symfony\Component\HttpClient\Retry\GenericRetryStrategy;
8181
use Symfony\Component\HttpClient\RetryableHttpClient;
8282
use Symfony\Component\HttpClient\ScopingHttpClient;
83+
use Symfony\Component\HttpClient\UriTemplateHttpClient;
8384
use Symfony\Component\HttpFoundation\Request;
8485
use Symfony\Component\HttpKernel\Attribute\AsController;
8586
use Symfony\Component\HttpKernel\CacheClearer\CacheClearerInterface;
@@ -2338,6 +2339,8 @@ private function registerHttpClientConfiguration(array $config, ContainerBuilder
23382339
$options = $config['default_options'] ?? [];
23392340
$retryOptions = $options['retry_failed'] ?? ['enabled' => false];
23402341
unset($options['retry_failed']);
2342+
$defaultUriTemplateVars = $options['vars'] ?? [];
2343+
unset($options['vars']);
23412344
$container->getDefinition('http_client')->setArguments([$options, $config['max_host_connections'] ?? 6]);
23422345

23432346
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
23492352
$container->removeDefinition(HttpClient::class);
23502353
}
23512354

2352-
if ($this->readConfigEnabled('http_client.retry_failed', $container, $retryOptions)) {
2355+
if ($hasRetryFailed = $this->readConfigEnabled('http_client.retry_failed', $container, $retryOptions)) {
23532356
$this->registerRetryableHttpClient($retryOptions, 'http_client', $container);
23542357
}
23552358

2356-
$httpClientId = ($retryOptions['enabled'] ?? false) ? 'http_client.retryable.inner' : ($this->isInitializedConfigEnabled('profiler') ? '.debug.http_client.inner' : 'http_client');
2359+
if ($hasUriTemplate = class_exists(UriTemplateHttpClient::class)) {
2360+
if (ContainerBuilder::willBeAvailable('guzzlehttp/uri-template', \GuzzleHttp\UriTemplate\UriTemplate::class, [])) {
2361+
$container->setAlias('http_client.uri_template_expander', 'http_client.uri_template_expander.guzzle');
2362+
} elseif (ContainerBuilder::willBeAvailable('rize/uri-template', \Rize\UriTemplate::class, [])) {
2363+
$container->setAlias('http_client.uri_template_expander', 'http_client.uri_template_expander.rize');
2364+
}
2365+
2366+
$container
2367+
->getDefinition('http_client.uri_template')
2368+
->setArgument(2, $defaultUriTemplateVars);
2369+
} elseif ($defaultUriTemplateVars) {
2370+
throw new LogicException('Support for URI template requires symfony/http-client 6.3 or higher, try upgrading.');
2371+
}
2372+
2373+
$httpClientId = match (true) {
2374+
$hasUriTemplate => 'http_client.uri_template.inner',
2375+
$hasRetryFailed => 'http_client.retryable.inner',
2376+
$this->isInitializedConfigEnabled('profiler') => '.debug.http_client.inner',
2377+
default => 'http_client',
2378+
};
2379+
23572380
foreach ($config['scoped_clients'] as $name => $scopeConfig) {
23582381
if ('http_client' === $name) {
23592382
throw new InvalidArgumentException(sprintf('Invalid scope name: "%s" is reserved.', $name));
@@ -2384,6 +2407,17 @@ private function registerHttpClientConfiguration(array $config, ContainerBuilder
23842407
$this->registerRetryableHttpClient($retryOptions, $name, $container);
23852408
}
23862409

2410+
if ($hasUriTemplate) {
2411+
$container
2412+
->register($name.'.uri_template', UriTemplateHttpClient::class)
2413+
->setDecoratedService($name, null, 7) // Between TraceableHttpClient (5) and RetryableHttpClient (10)
2414+
->setArguments([
2415+
new Reference('.inner'),
2416+
new Reference('http_client.uri_template_expander', ContainerInterface::NULL_ON_INVALID_REFERENCE),
2417+
$defaultUriTemplateVars,
2418+
]);
2419+
}
2420+
23872421
$container->registerAliasForArgument($name, HttpClientInterface::class);
23882422

23892423
if ($hasPsr18) {

src/Symfony/Bundle/FrameworkBundle/Resources/config/http_client.php

+21
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
use Symfony\Component\HttpClient\HttplugClient;
1919
use Symfony\Component\HttpClient\Psr18Client;
2020
use Symfony\Component\HttpClient\Retry\GenericRetryStrategy;
21+
use Symfony\Component\HttpClient\UriTemplateHttpClient;
2122
use Symfony\Contracts\HttpClient\HttpClientInterface;
2223

2324
return static function (ContainerConfigurator $container) {
@@ -60,5 +61,25 @@
6061
abstract_arg('max delay ms'),
6162
abstract_arg('jitter'),
6263
])
64+
65+
->set('http_client.uri_template', UriTemplateHttpClient::class)
66+
->decorate('http_client', null, 7) // Between TraceableHttpClient (5) and RetryableHttpClient (10)
67+
->args([
68+
service('.inner'),
69+
service('http_client.uri_template_expander')->nullOnInvalid(),
70+
abstract_arg('default vars'),
71+
])
72+
73+
->set('http_client.uri_template_expander.guzzle', \Closure::class)
74+
->factory([\Closure::class, 'fromCallable'])
75+
->args([
76+
[\GuzzleHttp\UriTemplate\UriTemplate::class, 'expand'],
77+
])
78+
79+
->set('http_client.uri_template_expander.rize', \Closure::class)
80+
->factory([\Closure::class, 'fromCallable'])
81+
->args([
82+
[inline_service(\Rize\UriTemplate::class), 'expand'],
83+
])
6384
;
6485
};

src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTestCase.php

+3-2
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@
5555
use Symfony\Component\HttpClient\MockHttpClient;
5656
use Symfony\Component\HttpClient\RetryableHttpClient;
5757
use Symfony\Component\HttpClient\ScopingHttpClient;
58+
use Symfony\Component\HttpClient\UriTemplateHttpClient;
5859
use Symfony\Component\HttpKernel\DependencyInjection\LoggerPass;
5960
use Symfony\Component\HttpKernel\Fragment\FragmentUriGeneratorInterface;
6061
use Symfony\Component\Messenger\Transport\TransportFactory;
@@ -2003,15 +2004,15 @@ public function testHttpClientMockResponseFactory()
20032004
{
20042005
$container = $this->createContainerFromFile('http_client_mock_response_factory');
20052006

2006-
$definition = $container->getDefinition('http_client.mock_client');
2007+
$definition = $container->getDefinition(($uriTemplateHttpClientExists = class_exists(UriTemplateHttpClient::class)) ? 'http_client.uri_template.inner.mock_client' : 'http_client.mock_client');
20072008

20082009
$this->assertSame(MockHttpClient::class, $definition->getClass());
20092010
$this->assertCount(1, $definition->getArguments());
20102011

20112012
$argument = $definition->getArgument(0);
20122013

20132014
$this->assertInstanceOf(Reference::class, $argument);
2014-
$this->assertSame('http_client', current($definition->getDecoratedService()));
2015+
$this->assertSame($uriTemplateHttpClientExists ? 'http_client.uri_template.inner' : 'http_client', current($definition->getDecoratedService()));
20152016
$this->assertSame('my_response_factory', (string) $argument);
20162017
}
20172018

src/Symfony/Component/HttpClient/CHANGELOG.md

+5
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
CHANGELOG
22
=========
33

4+
6.3
5+
---
6+
7+
* Add `UriTemplateHttpClient` to use URI templates as specified in the RFC 6570
8+
49
6.2
510
---
611

src/Symfony/Component/HttpClient/HttpClientTrait.php

+4
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,10 @@ private static function mergeDefaultOptions(array $options, array $defaultOption
245245
throw new InvalidArgumentException(sprintf('Option "auth_ntlm" is not supported by "%s", '.$msg, __CLASS__, CurlHttpClient::class));
246246
}
247247

248+
if ('vars' === $name) {
249+
throw new InvalidArgumentException(sprintf('Option "vars" is not supported by "%s", try using "%s" instead.', __CLASS__, UriTemplateHttpClient::class));
250+
}
251+
248252
$alternatives = [];
249253

250254
foreach ($defaultOptions as $k => $v) {

src/Symfony/Component/HttpClient/HttpOptions.php

+10
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,16 @@ public function setBaseUri(string $uri): static
135135
return $this;
136136
}
137137

138+
/**
139+
* @return $this
140+
*/
141+
public function setVars(array $vars): static
142+
{
143+
$this->options['vars'] = $vars;
144+
145+
return $this;
146+
}
147+
138148
/**
139149
* @return $this
140150
*/
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\HttpClient\Tests;
13+
14+
use PHPUnit\Framework\TestCase;
15+
use Symfony\Component\HttpClient\MockHttpClient;
16+
use Symfony\Component\HttpClient\Response\MockResponse;
17+
use Symfony\Component\HttpClient\UriTemplateHttpClient;
18+
19+
final class UriTemplateHttpClientTest extends TestCase
20+
{
21+
public function testExpanderIsCalled()
22+
{
23+
$client = new UriTemplateHttpClient(
24+
new MockHttpClient(),
25+
function (string $url, array $vars): string {
26+
$this->assertSame('https://foo.tld/{version}/{resource}{?page}', $url);
27+
$this->assertSame([
28+
'version' => 'v2',
29+
'resource' => 'users',
30+
'page' => 33,
31+
], $vars);
32+
33+
return 'https://foo.tld/v2/users?page=33';
34+
},
35+
[
36+
'version' => 'v2',
37+
],
38+
);
39+
$this->assertSame('https://foo.tld/v2/users?page=33', $client->request('GET', 'https://foo.tld/{version}/{resource}{?page}', [
40+
'vars' => [
41+
'resource' => 'users',
42+
'page' => 33,
43+
],
44+
])->getInfo('url'));
45+
}
46+
47+
public function testWithOptionsAppendsVarsToDefaultVars()
48+
{
49+
$client = new UriTemplateHttpClient(
50+
new MockHttpClient(),
51+
function (string $url, array $vars): string {
52+
$this->assertSame('https://foo.tld/{bar}', $url);
53+
$this->assertSame([
54+
'bar' => 'ccc',
55+
], $vars);
56+
57+
return 'https://foo.tld/ccc';
58+
},
59+
);
60+
$this->assertSame('https://foo.tld/{bar}', $client->request('GET', 'https://foo.tld/{bar}')->getInfo('url'));
61+
62+
$client = $client->withOptions([
63+
'vars' => [
64+
'bar' => 'ccc',
65+
],
66+
]);
67+
$this->assertSame('https://foo.tld/ccc', $client->request('GET', 'https://foo.tld/{bar}')->getInfo('url'));
68+
}
69+
70+
public function testExpanderIsNotCalledWithEmptyVars()
71+
{
72+
$this->expectNotToPerformAssertions();
73+
74+
$client = new UriTemplateHttpClient(new MockHttpClient(), $this->fail(...));
75+
$client->request('GET', 'https://foo.tld/bar', [
76+
'vars' => [],
77+
]);
78+
}
79+
80+
public function testExpanderIsNotCalledWithNoVarsAtAll()
81+
{
82+
$this->expectNotToPerformAssertions();
83+
84+
$client = new UriTemplateHttpClient(new MockHttpClient(), $this->fail(...));
85+
$client->request('GET', 'https://foo.tld/bar');
86+
}
87+
88+
public function testRequestWithNonArrayVarsOption()
89+
{
90+
$this->expectException(\InvalidArgumentException::class);
91+
$this->expectExceptionMessage('The "vars" option must be an array.');
92+
93+
(new UriTemplateHttpClient(new MockHttpClient()))->request('GET', 'https://foo.tld', [
94+
'vars' => 'should be an array',
95+
]);
96+
}
97+
98+
public function testWithOptionsWithNonArrayVarsOption()
99+
{
100+
$this->expectException(\InvalidArgumentException::class);
101+
$this->expectExceptionMessage('The "vars" option must be an array.');
102+
103+
(new UriTemplateHttpClient(new MockHttpClient()))->withOptions([
104+
'vars' => new \stdClass(),
105+
]);
106+
}
107+
108+
public function testVarsOptionIsNotPropagated()
109+
{
110+
$client = new UriTemplateHttpClient(
111+
new MockHttpClient(function (string $method, string $url, array $options): MockResponse {
112+
$this->assertArrayNotHasKey('vars', $options);
113+
114+
return new MockResponse();
115+
}),
116+
static fn (): string => 'ccc',
117+
);
118+
119+
$client->withOptions([
120+
'vars' => [
121+
'foo' => 'bar',
122+
],
123+
])->request('GET', 'https://foo.tld', [
124+
'vars' => [
125+
'foo2' => 'bar2',
126+
],
127+
]);
128+
}
129+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\HttpClient;
13+
14+
use Symfony\Contracts\HttpClient\HttpClientInterface;
15+
use Symfony\Contracts\HttpClient\ResponseInterface;
16+
use Symfony\Contracts\Service\ResetInterface;
17+
18+
class UriTemplateHttpClient implements HttpClientInterface, ResetInterface
19+
{
20+
use DecoratorTrait;
21+
22+
/**
23+
* @param (\Closure(string $url, array $vars): string)|null $expander
24+
*/
25+
public function __construct(HttpClientInterface $client = null, private ?\Closure $expander = null, private array $defaultVars = [])
26+
{
27+
$this->client = $client ?? HttpClient::create();
28+
}
29+
30+
public function request(string $method, string $url, array $options = []): ResponseInterface
31+
{
32+
$vars = $this->defaultVars;
33+
34+
if (\array_key_exists('vars', $options)) {
35+
if (!\is_array($options['vars'])) {
36+
throw new \InvalidArgumentException('The "vars" option must be an array.');
37+
}
38+
39+
$vars = [...$vars, ...$options['vars']];
40+
unset($options['vars']);
41+
}
42+
43+
if ($vars) {
44+
$url = ($this->expander ??= $this->createExpanderFromPopularVendors())($url, $vars);
45+
}
46+
47+
return $this->client->request($method, $url, $options);
48+
}
49+
50+
public function withOptions(array $options): static
51+
{
52+
if (!\is_array($options['vars'] ?? [])) {
53+
throw new \InvalidArgumentException('The "vars" option must be an array.');
54+
}
55+
56+
$clone = clone $this;
57+
$clone->defaultVars = [...$clone->defaultVars, ...$options['vars'] ?? []];
58+
unset($options['vars']);
59+
60+
$clone->client = $this->client->withOptions($options);
61+
62+
return $clone;
63+
}
64+
65+
/**
66+
* @return \Closure(string $url, array $vars): string
67+
*/
68+
private function createExpanderFromPopularVendors(): \Closure
69+
{
70+
if (class_exists(\GuzzleHttp\UriTemplate\UriTemplate::class)) {
71+
return \GuzzleHttp\UriTemplate\UriTemplate::expand(...);
72+
}
73+
74+
if (class_exists(\League\Uri\UriTemplate::class)) {
75+
return static fn (string $url, array $vars): string => (new \League\Uri\UriTemplate($url))->expand($vars);
76+
}
77+
78+
if (class_exists(\Rize\UriTemplate::class)) {
79+
return (new \Rize\UriTemplate())->expand(...);
80+
}
81+
82+
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.');
83+
}
84+
}

0 commit comments

Comments
 (0)