Skip to content

Commit 279d9d7

Browse files
authored
Add support for endpoint discovery in core (#1247)
1 parent be84dc1 commit 279d9d7

File tree

9 files changed

+338
-3
lines changed

9 files changed

+338
-3
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
## NOT RELEASED
44

5+
### Added
6+
7+
- Added support for endpoint discovery
8+
59
## 1.15.0
610

711
### Added

src/AbstractApi.php

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,10 @@
99
use AsyncAws\Core\Credentials\CacheProvider;
1010
use AsyncAws\Core\Credentials\ChainProvider;
1111
use AsyncAws\Core\Credentials\CredentialProvider;
12+
use AsyncAws\Core\EndpointDiscovery\EndpointCache;
1213
use AsyncAws\Core\Exception\InvalidArgument;
1314
use AsyncAws\Core\Exception\LogicException;
15+
use AsyncAws\Core\Exception\RuntimeException;
1416
use AsyncAws\Core\HttpClient\AwsRetryStrategy;
1517
use AsyncAws\Core\Signer\Signer;
1618
use AsyncAws\Core\Signer\SignerV4;
@@ -59,6 +61,11 @@ abstract class AbstractApi
5961
*/
6062
private $awsErrorFactory;
6163

64+
/**
65+
* @var EndpointCache
66+
*/
67+
private $endpointCache;
68+
6269
/**
6370
* @param Configuration|array $configuration
6471
*/
@@ -72,6 +79,7 @@ public function __construct($configuration = [], ?CredentialProvider $credential
7279

7380
$this->logger = $logger ?? new NullLogger();
7481
$this->awsErrorFactory = $this->getAwsErrorFactory();
82+
$this->endpointCache = new EndpointCache();
7583
if (!isset($httpClient)) {
7684
$httpClient = HttpClient::create();
7785
if (class_exists(RetryableHttpClient::class)) {
@@ -132,7 +140,7 @@ protected function getSignatureScopeName(): string
132140

133141
final protected function getResponse(Request $request, ?RequestContext $context = null): Response
134142
{
135-
$request->setEndpoint($this->getEndpoint($request->getUri(), $request->getQuery(), $context ? $context->getRegion() : null));
143+
$request->setEndpoint($this->getDiscoveredEndpoint($request->getUri(), $request->getQuery(), $context ? $context->getRegion() : null, $context ? $context->usesEndpointDiscovery() : false, $context ? $context->requiresEndpointDiscovery() : false));
136144

137145
if (null !== $credentials = $this->credentialProvider->getCredentials($this->configuration)) {
138146
$this->getSigner($context ? $context->getRegion() : null)->sign($request, $credentials, $context ?? new RequestContext());
@@ -166,7 +174,7 @@ final protected function getResponse(Request $request, ?RequestContext $context
166174
]);
167175
}
168176

169-
return new Response($response, $this->httpClient, $this->logger, $this->awsErrorFactory, $debug, $context ? $context->getExceptionMapping() : []);
177+
return new Response($response, $this->httpClient, $this->logger, $this->awsErrorFactory, $this->endpointCache, $request, $debug, $context ? $context->getExceptionMapping() : []);
170178
}
171179

172180
/**
@@ -255,6 +263,49 @@ protected function getEndpoint(string $uri, array $query, ?string $region): stri
255263
return $endpoint . (false === strpos($endpoint, '?') ? '?' : '&') . http_build_query($query);
256264
}
257265

266+
protected function discoverEndpoints(?string $region): array
267+
{
268+
throw new LogicException(sprintf('The Client "%s" must implement the "%s" method.', \get_class($this), 'discoverEndpoints'));
269+
}
270+
271+
private function getDiscoveredEndpoint(string $uri, array $query, ?string $region, bool $usesEndpointDiscovery, bool $requiresEndpointDiscovery)
272+
{
273+
if (!$this->configuration->isDefault('endpoint')) {
274+
return $this->getEndpoint($uri, $query, $region);
275+
}
276+
277+
$usesEndpointDiscovery = $requiresEndpointDiscovery || ($usesEndpointDiscovery && filter_var($this->configuration->get(Configuration::OPTION_ENDPOINT_DISCOVERY_ENABLED), \FILTER_VALIDATE_BOOLEAN));
278+
if (!$usesEndpointDiscovery) {
279+
return $this->getEndpoint($uri, $query, $region);
280+
}
281+
282+
// 1. use an active endpoints
283+
if (null === $endpoint = $this->endpointCache->getActiveEndpoint($region)) {
284+
$previous = null;
285+
286+
try {
287+
// 2. call API to fetch new endpoints
288+
$endpoints = $this->discoverEndpoints($region);
289+
$this->endpointCache->addEndpoints($region, $endpoints);
290+
291+
// 3. use active endpoints that has just been injected
292+
$endpoint = $this->endpointCache->getActiveEndpoint($region);
293+
} catch (\Exception $previous) {
294+
}
295+
296+
// 4. if endpoint is still null, fallback to expired endpoint
297+
if (null === $endpoint && null === $endpoint = $this->endpointCache->getExpiredEndpoint($region)) {
298+
if ($requiresEndpointDiscovery) {
299+
throw new RuntimeException(sprintf('The Client "%s" failed to fetch the endpoint.', \get_class($this)), 0, $previous);
300+
}
301+
302+
return $this->getEndpoint($uri, $query, $region);
303+
}
304+
}
305+
306+
return $endpoint;
307+
}
308+
258309
/**
259310
* @param ?string $region region provided by the user in the `@region` parameter of the Input
260311
*/

src/Configuration.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ final class Configuration
3030
public const OPTION_WEB_IDENTITY_TOKEN_FILE = 'webIdentityTokenFile';
3131
public const OPTION_ROLE_SESSION_NAME = 'roleSessionName';
3232
public const OPTION_CONTAINER_CREDENTIALS_RELATIVE_URI = 'containerCredentialsRelativeUri';
33+
public const OPTION_ENDPOINT_DISCOVERY_ENABLED = 'endpointDiscoveryEnabled';
3334

3435
// S3 specific option
3536
public const OPTION_PATH_STYLE_ENDPOINT = 'pathStyleEndpoint';
@@ -49,6 +50,7 @@ final class Configuration
4950
self::OPTION_WEB_IDENTITY_TOKEN_FILE => true,
5051
self::OPTION_ROLE_SESSION_NAME => true,
5152
self::OPTION_CONTAINER_CREDENTIALS_RELATIVE_URI => true,
53+
self::OPTION_ENDPOINT_DISCOVERY_ENABLED => true,
5254
self::OPTION_PATH_STYLE_ENDPOINT => true,
5355
self::OPTION_SEND_CHUNKED_BODY => true,
5456
];
@@ -70,6 +72,7 @@ final class Configuration
7072
self::OPTION_ROLE_SESSION_NAME => 'AWS_ROLE_SESSION_NAME',
7173
],
7274
[self::OPTION_CONTAINER_CREDENTIALS_RELATIVE_URI => 'AWS_CONTAINER_CREDENTIALS_RELATIVE_URI'],
75+
[self::OPTION_ENDPOINT_DISCOVERY_ENABLED => ['AWS_ENDPOINT_DISCOVERY_ENABLED', 'AWS_ENABLE_ENDPOINT_DISCOVERY']],
7376
];
7477

7578
private const DEFAULT_OPTIONS = [
@@ -82,6 +85,7 @@ final class Configuration
8285
self::OPTION_ENDPOINT => 'https://%service%.%region%.amazonaws.com',
8386
self::OPTION_PATH_STYLE_ENDPOINT => 'false',
8487
self::OPTION_SEND_CHUNKED_BODY => 'false',
88+
self::OPTION_ENDPOINT_DISCOVERY_ENABLED => 'false',
8589
];
8690

8791
private $data = [];
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
<?php
2+
3+
namespace AsyncAws\Core\EndpointDiscovery;
4+
5+
use AsyncAws\Core\Exception\LogicException;
6+
7+
/**
8+
* @author Jérémy Derussé <[email protected]>
9+
*
10+
* @internal
11+
*/
12+
class EndpointCache
13+
{
14+
private $endpoints = [];
15+
16+
private $expired = [];
17+
18+
public function addEndpoints(?string $region, array $endpoints): void
19+
{
20+
$now = time();
21+
22+
if (null === $region) {
23+
$region = '';
24+
}
25+
if (!isset($this->endpoints[$region])) {
26+
$this->endpoints[$region] = [];
27+
}
28+
29+
/** @var EndpointInterface $endpoint */
30+
foreach ($endpoints as $endpoint) {
31+
$this->endpoints[$region][$this->sanitizeEndpoint($endpoint->getAddress())] = $now + ($endpoint->getCachePeriodInMinutes() * 60);
32+
}
33+
arsort($this->endpoints[$region]);
34+
}
35+
36+
public function removeEndpoint(string $endpoint): void
37+
{
38+
$endpoint = $this->sanitizeEndpoint($endpoint);
39+
foreach ($this->endpoints as &$endpoints) {
40+
unset($endpoints[$endpoint]);
41+
}
42+
unset($endpoints);
43+
foreach ($this->expired as &$endpoints) {
44+
unset($endpoints[$endpoint]);
45+
}
46+
47+
unset($endpoints);
48+
}
49+
50+
public function getActiveEndpoint(?string $region): ?string
51+
{
52+
if (null === $region) {
53+
$region = '';
54+
}
55+
$now = time();
56+
57+
foreach ($this->endpoints[$region] ?? [] as $endpoint => $expiresAt) {
58+
if ($expiresAt < $now) {
59+
$this->expired[$region] = \array_slice($this->expired[$region] ?? [], -100); // keep only the last 100 items
60+
unset($this->endpoints[$region][$endpoint]);
61+
$this->expired[$region][$endpoint] = $expiresAt;
62+
63+
continue;
64+
}
65+
66+
return $endpoint;
67+
}
68+
69+
return null;
70+
}
71+
72+
public function getExpiredEndpoint(?string $region): ?string
73+
{
74+
if (null === $region) {
75+
$region = '';
76+
}
77+
if (empty($this->expired[$region])) {
78+
return null;
79+
}
80+
81+
return array_key_last($this->expired[$region]);
82+
}
83+
84+
private function sanitizeEndpoint(string $address): string
85+
{
86+
$parsed = parse_url($address);
87+
88+
// parse_url() will correctly parse full URIs with schemes
89+
if (isset($parsed['host'])) {
90+
return rtrim(sprintf(
91+
'%s://%s/%s',
92+
$parsed['scheme'] ?? 'https',
93+
$parsed['host'],
94+
ltrim($parsed['path'] ?? '/', '/')
95+
), '/');
96+
}
97+
98+
// parse_url() will put host & path in 'path' if scheme is not provided
99+
if (isset($parsed['path'])) {
100+
$split = explode('/', $parsed['path'], 2);
101+
$parsed['host'] = $split[0];
102+
if (isset($split[1])) {
103+
$parsed['path'] = $split[1];
104+
} else {
105+
$parsed['path'] = '';
106+
}
107+
108+
return rtrim(sprintf(
109+
'%s://%s/%s',
110+
$parsed['scheme'] ?? 'https',
111+
$parsed['host'],
112+
ltrim($parsed['path'], '/')
113+
), '/');
114+
}
115+
116+
throw new LogicException(sprintf('The supplied endpoint "%s" is invalid.', $address));
117+
}
118+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<?php
2+
3+
namespace AsyncAws\Core\EndpointDiscovery;
4+
5+
interface EndpointInterface
6+
{
7+
public function getAddress(): string;
8+
9+
public function getCachePeriodInMinutes(): int;
10+
}

src/RequestContext.php

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,25 @@ class RequestContext
1919
'expirationDate' => true,
2020
'currentDate' => true,
2121
'exceptionMapping' => true,
22+
'usesEndpointDiscovery' => true,
23+
'requiresEndpointDiscovery' => true,
2224
];
2325

2426
/**
2527
* @var string|null
2628
*/
2729
private $operation;
2830

31+
/**
32+
* @var bool
33+
*/
34+
private $usesEndpointDiscovery = false;
35+
36+
/**
37+
* @var bool
38+
*/
39+
private $requiresEndpointDiscovery = false;
40+
2941
/**
3042
* @var string|null
3143
*/
@@ -53,6 +65,8 @@ class RequestContext
5365
* expirationDate?: null|\DateTimeImmutable
5466
* currentDate?: null|\DateTimeImmutable
5567
* exceptionMapping?: string[]
68+
* usesEndpointDiscovery?: bool
69+
* requiresEndpointDiscovery?: bool
5670
* }
5771
*/
5872
public function __construct(array $options = [])
@@ -90,4 +104,14 @@ public function getExceptionMapping(): array
90104
{
91105
return $this->exceptionMapping;
92106
}
107+
108+
public function usesEndpointDiscovery(): bool
109+
{
110+
return $this->usesEndpointDiscovery;
111+
}
112+
113+
public function requiresEndpointDiscovery(): bool
114+
{
115+
return $this->requiresEndpointDiscovery;
116+
}
93117
}

src/Response.php

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
use AsyncAws\Core\AwsError\AwsErrorFactoryInterface;
88
use AsyncAws\Core\AwsError\ChainAwsErrorFactory;
9+
use AsyncAws\Core\EndpointDiscovery\EndpointCache;
910
use AsyncAws\Core\Exception\Exception;
1011
use AsyncAws\Core\Exception\Http\ClientException;
1112
use AsyncAws\Core\Exception\Http\HttpException;
@@ -79,6 +80,16 @@ class Response
7980
*/
8081
private $awsErrorFactory;
8182

83+
/**
84+
* @var ?EndpointCache
85+
*/
86+
private $endpointCache;
87+
88+
/**
89+
* @var ?Request
90+
*/
91+
private $request;
92+
8293
/**
8394
* @var bool
8495
*/
@@ -89,12 +100,14 @@ class Response
89100
*/
90101
private $exceptionMapping;
91102

92-
public function __construct(ResponseInterface $response, HttpClientInterface $httpClient, LoggerInterface $logger, AwsErrorFactoryInterface $awsErrorFactory = null, bool $debug = false, array $exceptionMapping = [])
103+
public function __construct(ResponseInterface $response, HttpClientInterface $httpClient, LoggerInterface $logger, AwsErrorFactoryInterface $awsErrorFactory = null, EndpointCache $endpointCache = null, Request $request = null, bool $debug = false, array $exceptionMapping = [])
93104
{
94105
$this->httpResponse = $response;
95106
$this->httpClient = $httpClient;
96107
$this->logger = $logger;
97108
$this->awsErrorFactory = $awsErrorFactory ?? new ChainAwsErrorFactory();
109+
$this->endpointCache = $endpointCache;
110+
$this->request = $request;
98111
$this->debug = $debug;
99112
$this->exceptionMapping = $exceptionMapping;
100113
}
@@ -385,6 +398,9 @@ private function defineResolveStatus(): void
385398
if (300 <= $statusCode) {
386399
try {
387400
$awsError = $this->awsErrorFactory->createFromResponse($this->httpResponse);
401+
if ($this->request && $this->endpointCache && (400 === $statusCode || 'InvalidEndpointException' === $awsError->getCode())) {
402+
$this->endpointCache->removeEndpoint($this->request->getEndpoint());
403+
}
388404
} catch (UnparsableResponse $e) {
389405
$awsError = null;
390406
}

0 commit comments

Comments
 (0)