EndpointDiscoveryMiddleware.php 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423
  1. <?php
  2. namespace Aws\EndpointDiscovery;
  3. use Aws\AwsClient;
  4. use Aws\CacheInterface;
  5. use Aws\CommandInterface;
  6. use Aws\Credentials\CredentialsInterface;
  7. use Aws\Exception\AwsException;
  8. use Aws\Exception\UnresolvedEndpointException;
  9. use Aws\LruArrayCache;
  10. use Aws\Middleware;
  11. use Psr\Http\Message\RequestInterface;
  12. use Psr\Http\Message\UriInterface;
  13. class EndpointDiscoveryMiddleware
  14. {
  15. /**
  16. * @var CacheInterface
  17. */
  18. private static $cache;
  19. private static $discoveryCooldown = 60;
  20. private $args;
  21. private $client;
  22. private $config;
  23. private $discoveryTimes = [];
  24. private $nextHandler;
  25. private $service;
  26. public static function wrap(
  27. $client,
  28. $args,
  29. $config
  30. ) {
  31. return function (callable $handler) use (
  32. $client,
  33. $args,
  34. $config
  35. ) {
  36. return new static(
  37. $handler,
  38. $client,
  39. $args,
  40. $config
  41. );
  42. };
  43. }
  44. public function __construct(
  45. callable $handler,
  46. AwsClient $client,
  47. array $args,
  48. $config
  49. ) {
  50. $this->nextHandler = $handler;
  51. $this->client = $client;
  52. $this->args = $args;
  53. $this->service = $client->getApi();
  54. $this->config = $config;
  55. }
  56. public function __invoke(CommandInterface $cmd, RequestInterface $request)
  57. {
  58. $nextHandler = $this->nextHandler;
  59. $op = $this->service->getOperation($cmd->getName())->toArray();
  60. // Continue only if endpointdiscovery trait is set
  61. if (isset($op['endpointdiscovery'])) {
  62. $config = ConfigurationProvider::unwrap($this->config);
  63. $isRequired = !empty($op['endpointdiscovery']['required']);
  64. if ($isRequired && !($config->isEnabled())) {
  65. throw new UnresolvedEndpointException('This operation '
  66. . 'requires the use of endpoint discovery, but this has '
  67. . 'been disabled in the configuration. Enable endpoint '
  68. . 'discovery or use a different operation.');
  69. }
  70. // Continue only if enabled by config
  71. if ($config->isEnabled()) {
  72. if (isset($op['endpointoperation'])) {
  73. throw new UnresolvedEndpointException('This operation is '
  74. . 'contradictorily marked both as using endpoint discovery '
  75. . 'and being the endpoint discovery operation. Please '
  76. . 'verify the accuracy of your model files.');
  77. }
  78. // Original endpoint may be used if discovery optional
  79. $originalUri = $request->getUri();
  80. $identifiers = $this->getIdentifiers($op);
  81. $cacheKey = $this->getCacheKey(
  82. $this->client->getCredentials()->wait(),
  83. $cmd,
  84. $identifiers
  85. );
  86. // Check/create cache
  87. if (!isset(self::$cache)) {
  88. self::$cache = new LruArrayCache($config->getCacheLimit());
  89. }
  90. if (empty($endpointList = self::$cache->get($cacheKey))) {
  91. $endpointList = new EndpointList([]);
  92. }
  93. $endpoint = $endpointList->getActive();
  94. // Retrieve endpoints if there is no active endpoint
  95. if (empty($endpoint)) {
  96. try {
  97. $endpoint = $this->discoverEndpoint(
  98. $cacheKey,
  99. $cmd,
  100. $identifiers
  101. );
  102. } catch (\Exception $e) {
  103. // Use cached endpoint, expired or active, if any remain
  104. $endpoint = $endpointList->getEndpoint();
  105. if (empty($endpoint)) {
  106. return $this->handleDiscoveryException(
  107. $isRequired,
  108. $originalUri,
  109. $e,
  110. $cmd,
  111. $request
  112. );
  113. }
  114. }
  115. }
  116. $request = $this->modifyRequest($request, $endpoint);
  117. $g = function ($value) use (
  118. $cacheKey,
  119. $cmd,
  120. $identifiers,
  121. $isRequired,
  122. $originalUri,
  123. $request,
  124. &$endpoint,
  125. &$g
  126. ) {
  127. if ($value instanceof AwsException
  128. && (
  129. $value->getAwsErrorCode() == 'InvalidEndpointException'
  130. || $value->getStatusCode() == 421
  131. )
  132. ) {
  133. return $this->handleInvalidEndpoint(
  134. $cacheKey,
  135. $cmd,
  136. $identifiers,
  137. $isRequired,
  138. $originalUri,
  139. $request,
  140. $value,
  141. $endpoint,
  142. $g
  143. );
  144. }
  145. return $value;
  146. };
  147. return $nextHandler($cmd, $request)->otherwise($g);
  148. }
  149. }
  150. return $nextHandler($cmd, $request);
  151. }
  152. private function discoverEndpoint(
  153. $cacheKey,
  154. CommandInterface $cmd,
  155. array $identifiers
  156. ) {
  157. $discCmd = $this->getDiscoveryCommand($cmd, $identifiers);
  158. $this->discoveryTimes[$cacheKey] = time();
  159. $result = $this->client->execute($discCmd);
  160. if (isset($result['Endpoints'])) {
  161. $endpointData = [];
  162. foreach ($result['Endpoints'] as $datum) {
  163. $endpointData[$datum['Address']] = time()
  164. + ($datum['CachePeriodInMinutes'] * 60);
  165. }
  166. $endpointList = new EndpointList($endpointData);
  167. self::$cache->set($cacheKey, $endpointList);
  168. return $endpointList->getEndpoint();
  169. }
  170. throw new UnresolvedEndpointException('The endpoint discovery operation '
  171. . 'yielded a response that did not contain properly formatted '
  172. . 'endpoint data.');
  173. }
  174. private function getCacheKey(
  175. CredentialsInterface $creds,
  176. CommandInterface $cmd,
  177. array $identifiers
  178. ) {
  179. $key = $this->service->getServiceName() . '_' . $creds->getAccessKeyId();
  180. if (!empty($identifiers)) {
  181. $key .= '_' . $cmd->getName();
  182. foreach ($identifiers as $identifier) {
  183. $key .= "_{$cmd[$identifier]}";
  184. }
  185. }
  186. return $key;
  187. }
  188. private function getDiscoveryCommand(
  189. CommandInterface $cmd,
  190. array $identifiers
  191. ) {
  192. foreach ($this->service->getOperations() as $op) {
  193. if (isset($op['endpointoperation'])) {
  194. $endpointOperation = $op->toArray()['name'];
  195. break;
  196. }
  197. }
  198. if (!isset($endpointOperation)) {
  199. throw new UnresolvedEndpointException('This command is set to use '
  200. . 'endpoint discovery, but no endpoint discovery operation was '
  201. . 'found. Please verify the accuracy of your model files.');
  202. }
  203. $params = [];
  204. if (!empty($identifiers)) {
  205. $params['Operation'] = $cmd->getName();
  206. $params['Identifiers'] = [];
  207. foreach ($identifiers as $identifier) {
  208. $params['Identifiers'][$identifier] = $cmd[$identifier];
  209. }
  210. }
  211. $command = $this->client->getCommand($endpointOperation, $params);
  212. $command->getHandlerList()->appendBuild(
  213. Middleware::mapRequest(function (RequestInterface $r) {
  214. return $r->withHeader(
  215. 'x-amz-api-version',
  216. $this->service->getApiVersion()
  217. );
  218. }),
  219. 'x-amz-api-version-header'
  220. );
  221. return $command;
  222. }
  223. private function getIdentifiers(array $operation)
  224. {
  225. $inputShape = $this->service->getShapeMap()
  226. ->resolve($operation['input'])
  227. ->toArray();
  228. $identifiers = [];
  229. foreach ($inputShape['members'] as $key => $member) {
  230. if (!empty($member['endpointdiscoveryid'])) {
  231. $identifiers[] = $key;
  232. }
  233. }
  234. return $identifiers;
  235. }
  236. private function handleDiscoveryException(
  237. $isRequired,
  238. $originalUri,
  239. \Exception $e,
  240. CommandInterface $cmd,
  241. RequestInterface $request
  242. ) {
  243. // If no cached endpoints and discovery required,
  244. // throw exception
  245. if ($isRequired) {
  246. $message = 'The endpoint required for this service is currently '
  247. . 'unable to be retrieved, and your request can not be fulfilled '
  248. . 'unless you manually specify an endpoint.';
  249. throw new AwsException(
  250. $message,
  251. $cmd,
  252. [
  253. 'code' => 'EndpointDiscoveryException',
  254. 'message' => $message
  255. ],
  256. $e
  257. );
  258. }
  259. // If discovery isn't required, use original endpoint
  260. return $this->useOriginalUri(
  261. $originalUri,
  262. $cmd,
  263. $request
  264. );
  265. }
  266. private function handleInvalidEndpoint(
  267. $cacheKey,
  268. $cmd,
  269. $identifiers,
  270. $isRequired,
  271. $originalUri,
  272. $request,
  273. $value,
  274. &$endpoint,
  275. &$g
  276. ) {
  277. $nextHandler = $this->nextHandler;
  278. $endpointList = self::$cache->get($cacheKey);
  279. if ($endpointList instanceof EndpointList) {
  280. // Remove invalid endpoint from cached list
  281. $endpointList->remove($endpoint);
  282. // If possible, get another cached endpoint
  283. $newEndpoint = $endpointList->getEndpoint();
  284. }
  285. if (empty($newEndpoint)) {
  286. // If no more cached endpoints, make discovery call
  287. // if none made within cooldown for given key
  288. if (time() - $this->discoveryTimes[$cacheKey]
  289. < self::$discoveryCooldown
  290. ) {
  291. // If no more cached endpoints and it's required,
  292. // fail with original exception
  293. if ($isRequired) {
  294. return $value;
  295. }
  296. // Use original endpoint if not required
  297. return $this->useOriginalUri(
  298. $originalUri,
  299. $cmd,
  300. $request
  301. );
  302. }
  303. $newEndpoint = $this->discoverEndpoint(
  304. $cacheKey,
  305. $cmd,
  306. $identifiers
  307. );
  308. }
  309. $endpoint = $newEndpoint;
  310. $request = $this->modifyRequest($request, $endpoint);
  311. return $nextHandler($cmd, $request)->otherwise($g);
  312. }
  313. private function modifyRequest(RequestInterface $request, $endpoint)
  314. {
  315. $parsed = $this->parseEndpoint($endpoint);
  316. if (!empty($request->getHeader('User-Agent'))) {
  317. $userAgent = $request->getHeader('User-Agent')[0];
  318. if (strpos($userAgent, 'endpoint-discovery') === false) {
  319. $userAgent = $userAgent . ' endpoint-discovery';
  320. }
  321. } else {
  322. $userAgent = 'endpoint-discovery';
  323. }
  324. return $request
  325. ->withUri(
  326. $request->getUri()
  327. ->withHost($parsed['host'])
  328. ->withPath($parsed['path'])
  329. )
  330. ->withHeader('User-Agent', $userAgent);
  331. }
  332. /**
  333. * Parses an endpoint returned from the discovery API into an array with
  334. * 'host' and 'path' keys.
  335. *
  336. * @param $endpoint
  337. * @return array
  338. */
  339. private function parseEndpoint($endpoint)
  340. {
  341. $parsed = parse_url($endpoint);
  342. // parse_url() will correctly parse full URIs with schemes
  343. if (isset($parsed['host'])) {
  344. return $parsed;
  345. }
  346. // parse_url() will put host & path in 'path' if scheme is not provided
  347. if (isset($parsed['path'])) {
  348. $split = explode('/', $parsed['path'], 2);
  349. $parsed['host'] = $split[0];
  350. if (isset($split[1])) {
  351. if (substr($split[1], 0 , 1) !== '/') {
  352. $split[1] = '/' . $split[1];
  353. }
  354. $parsed['path'] = $split[1];
  355. } else {
  356. $parsed['path'] = '';
  357. }
  358. return $parsed;
  359. }
  360. throw new UnresolvedEndpointException("The supplied endpoint '"
  361. . "{$endpoint}' is invalid.");
  362. }
  363. private function useOriginalUri(
  364. UriInterface $uri,
  365. CommandInterface $cmd,
  366. RequestInterface $request
  367. ) {
  368. $nextHandler = $this->nextHandler;
  369. $endpoint = $uri->getHost() . $uri->getPath();
  370. $request = $this->modifyRequest(
  371. $request,
  372. $endpoint
  373. );
  374. return $nextHandler($cmd, $request);
  375. }
  376. }