Skip to content

Commit 786db29

Browse files
committed
[HttpClient] Add UriTemplateHttpClient
1 parent 383ff0b commit 786db29

File tree

7 files changed

+254
-2
lines changed

7 files changed

+254
-2
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

+43-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,38 @@ 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+
$uriTemplateExpanderId = null;
2361+
if (ContainerBuilder::willBeAvailable('guzzlehttp/uri-template', \GuzzleHttp\UriTemplate\UriTemplate::class, [])) {
2362+
$uriTemplateExpanderId = 'http_client.uri_template_expander.guzzle';
2363+
} elseif (ContainerBuilder::willBeAvailable('rize/uri-template', \Rize\UriTemplate::class, [])) {
2364+
$uriTemplateExpanderId = 'http_client.uri_template_expander.rize';
2365+
}
2366+
2367+
if ($uriTemplateExpanderId) {
2368+
$container->setAlias('http_client.uri_template_expander', $uriTemplateExpanderId);
2369+
}
2370+
2371+
$container
2372+
->getDefinition('http_client.uri_template')
2373+
->setArgument(2, $defaultUriTemplateVars);
2374+
} elseif ($defaultUriTemplateVars) {
2375+
throw new LogicException('Support for URI template requires symfony/http-client 6.3 or higher, try upgrading.');
2376+
}
2377+
2378+
$httpClientId = 'http_client';
2379+
if ($hasUriTemplate) {
2380+
$httpClientId = 'http_client.uri_template.inner';
2381+
} elseif ($hasRetryFailed) {
2382+
$httpClientId = 'http_client.retryable.inner';
2383+
} elseif ($this->isInitializedConfigEnabled('profiler')) {
2384+
$httpClientId = '.debug.http_client.inner';
2385+
}
2386+
23572387
foreach ($config['scoped_clients'] as $name => $scopeConfig) {
23582388
if ('http_client' === $name) {
23592389
throw new InvalidArgumentException(sprintf('Invalid scope name: "%s" is reserved.', $name));
@@ -2384,6 +2414,17 @@ private function registerHttpClientConfiguration(array $config, ContainerBuilder
23842414
$this->registerRetryableHttpClient($retryOptions, $name, $container);
23852415
}
23862416

2417+
if ($hasUriTemplate) {
2418+
$container
2419+
->register($name.'.uri_template', UriTemplateHttpClient::class)
2420+
->setDecoratedService($name, null, 7) // Between TraceableHttpClient (5) and RetryableHttpClient (10)
2421+
->setArguments([
2422+
new Reference('.inner'),
2423+
new Reference('http_client.uri_template_expander'),
2424+
$defaultUriTemplateVars,
2425+
]);
2426+
}
2427+
23872428
$container->registerAliasForArgument($name, HttpClientInterface::class);
23882429

23892430
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/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) {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
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 testVarsOptionIsNotPropagated()
89+
{
90+
$client = new UriTemplateHttpClient(
91+
new MockHttpClient(function (string $method, string $url, array $options): MockResponse {
92+
$this->assertArrayNotHasKey('vars', $options);
93+
94+
return new MockResponse();
95+
}),
96+
static fn (): string => 'ccc',
97+
);
98+
99+
$client->withOptions([
100+
'vars' => [
101+
'foo' => 'bar',
102+
],
103+
])->request('GET', 'https://foo.tld', [
104+
'vars' => [
105+
'foo2' => 'bar2',
106+
],
107+
]);
108+
}
109+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
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, ...$options['vars'] ?? []];
33+
unset($options['vars']);
34+
35+
return $this->client->request($method, $vars ? ($this->expander ??= $this->createExpanderFromPopularVendors())($url, $vars) : $url, $options);
36+
}
37+
38+
public function withOptions(array $options): static
39+
{
40+
$clone = clone $this;
41+
$clone->defaultVars = [...$clone->defaultVars, ...$options['vars'] ?? []];
42+
unset($options['vars']);
43+
$clone->client = $this->client->withOptions($options);
44+
45+
return $clone;
46+
}
47+
48+
/**
49+
* @return \Closure(string $url, array $vars): string
50+
*/
51+
private function createExpanderFromPopularVendors(): \Closure
52+
{
53+
if (class_exists(\GuzzleHttp\UriTemplate\UriTemplate::class)) {
54+
return \GuzzleHttp\UriTemplate\UriTemplate::expand(...);
55+
}
56+
57+
if (class_exists(\League\Uri\UriTemplate::class)) {
58+
return static fn (string $url, array $vars): string => (new \League\Uri\UriTemplate($url))->expand($vars);
59+
}
60+
61+
if (class_exists(\Rize\UriTemplate::class)) {
62+
return (new \Rize\UriTemplate())->expand(...);
63+
}
64+
65+
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.');
66+
}
67+
}

0 commit comments

Comments
 (0)