AbstractApi.php 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350
  1. <?php
  2. declare(strict_types=1);
  3. namespace AsyncAws\Core;
  4. use AsyncAws\Core\AwsError\AwsErrorFactoryInterface;
  5. use AsyncAws\Core\AwsError\ChainAwsErrorFactory;
  6. use AsyncAws\Core\Credentials\CacheProvider;
  7. use AsyncAws\Core\Credentials\ChainProvider;
  8. use AsyncAws\Core\Credentials\CredentialProvider;
  9. use AsyncAws\Core\EndpointDiscovery\EndpointCache;
  10. use AsyncAws\Core\Exception\InvalidArgument;
  11. use AsyncAws\Core\Exception\LogicException;
  12. use AsyncAws\Core\Exception\RuntimeException;
  13. use AsyncAws\Core\HttpClient\AwsRetryStrategy;
  14. use AsyncAws\Core\Signer\Signer;
  15. use AsyncAws\Core\Signer\SignerV4;
  16. use AsyncAws\Core\Stream\StringStream;
  17. use Psr\Log\LoggerInterface;
  18. use Psr\Log\NullLogger;
  19. use Symfony\Component\HttpClient\HttpClient;
  20. use Symfony\Component\HttpClient\RetryableHttpClient;
  21. use Symfony\Contracts\HttpClient\HttpClientInterface;
  22. /**
  23. * Base class all API clients are inheriting.
  24. *
  25. * @author Tobias Nyholm <tobias.nyholm@gmail.com>
  26. * @author Jérémy Derussé <jeremy@derusse.com>
  27. */
  28. abstract class AbstractApi
  29. {
  30. /**
  31. * @var HttpClientInterface
  32. */
  33. private $httpClient;
  34. /**
  35. * @var Configuration
  36. */
  37. private $configuration;
  38. /**
  39. * @var CredentialProvider
  40. */
  41. private $credentialProvider;
  42. /**
  43. * @var Signer[]
  44. */
  45. private $signers;
  46. /**
  47. * @var LoggerInterface
  48. */
  49. private $logger;
  50. /**
  51. * @var AwsErrorFactoryInterface
  52. */
  53. private $awsErrorFactory;
  54. /**
  55. * @var EndpointCache
  56. */
  57. private $endpointCache;
  58. /**
  59. * @param Configuration|array $configuration
  60. */
  61. public function __construct($configuration = [], ?CredentialProvider $credentialProvider = null, ?HttpClientInterface $httpClient = null, ?LoggerInterface $logger = null)
  62. {
  63. if (\is_array($configuration)) {
  64. $configuration = Configuration::create($configuration);
  65. } elseif (!$configuration instanceof Configuration) {
  66. throw new InvalidArgument(sprintf('First argument to "%s::__construct()" must be an array or an instance of "%s"', static::class, Configuration::class));
  67. }
  68. $this->logger = $logger ?? new NullLogger();
  69. $this->awsErrorFactory = $this->getAwsErrorFactory();
  70. $this->endpointCache = new EndpointCache();
  71. if (!isset($httpClient)) {
  72. $httpClient = HttpClient::create();
  73. if (class_exists(RetryableHttpClient::class)) {
  74. /** @psalm-suppress MissingDependency */
  75. $httpClient = new RetryableHttpClient(
  76. $httpClient,
  77. new AwsRetryStrategy(AwsRetryStrategy::DEFAULT_RETRY_STATUS_CODES, 1000, 2.0, 0, 0.1, $this->awsErrorFactory),
  78. 3,
  79. $this->logger
  80. );
  81. }
  82. }
  83. $this->httpClient = $httpClient;
  84. $this->configuration = $configuration;
  85. $this->credentialProvider = $credentialProvider ?? new CacheProvider(ChainProvider::createDefaultChain($this->httpClient, $this->logger));
  86. }
  87. final public function getConfiguration(): Configuration
  88. {
  89. return $this->configuration;
  90. }
  91. final public function presign(Input $input, ?\DateTimeImmutable $expires = null): string
  92. {
  93. $request = $input->request();
  94. $request->setEndpoint($this->getEndpoint($request->getUri(), $request->getQuery(), $input->getRegion()));
  95. if (null !== $credentials = $this->credentialProvider->getCredentials($this->configuration)) {
  96. $this->getSigner($input->getRegion())->presign($request, $credentials, new RequestContext(['expirationDate' => $expires]));
  97. }
  98. return $request->getEndpoint();
  99. }
  100. /**
  101. * @deprecated
  102. */
  103. protected function getServiceCode(): string
  104. {
  105. throw new LogicException(sprintf('The method "%s" should not be called. The Client "%s" must implement the "%s" method.', __FUNCTION__, \get_class($this), 'getEndpointMetadata'));
  106. }
  107. /**
  108. * @deprecated
  109. */
  110. protected function getSignatureVersion(): string
  111. {
  112. throw new LogicException(sprintf('The method "%s" should not be called. The Client "%s" must implement the "%s" method.', __FUNCTION__, \get_class($this), 'getEndpointMetadata'));
  113. }
  114. /**
  115. * @deprecated
  116. */
  117. protected function getSignatureScopeName(): string
  118. {
  119. throw new LogicException(sprintf('The method "%s" should not be called. The Client "%s" must implement the "%s" method.', __FUNCTION__, \get_class($this), 'getEndpointMetadata'));
  120. }
  121. final protected function getResponse(Request $request, ?RequestContext $context = null): Response
  122. {
  123. $request->setEndpoint($this->getDiscoveredEndpoint($request->getUri(), $request->getQuery(), $context ? $context->getRegion() : null, $context ? $context->usesEndpointDiscovery() : false, $context ? $context->requiresEndpointDiscovery() : false));
  124. if (null !== $credentials = $this->credentialProvider->getCredentials($this->configuration)) {
  125. $this->getSigner($context ? $context->getRegion() : null)->sign($request, $credentials, $context ?? new RequestContext());
  126. }
  127. $length = $request->getBody()->length();
  128. if (null !== $length && !$request->hasHeader('content-length')) {
  129. $request->setHeader('content-length', (string) $length);
  130. }
  131. // Some servers (like testing Docker Images) does not supports `Transfer-Encoding: chunked` requests.
  132. // The body is converted into string to prevent curl using `Transfer-Encoding: chunked` unless it really has to.
  133. if (($requestBody = $request->getBody()) instanceof StringStream) {
  134. $requestBody = $requestBody->stringify();
  135. }
  136. $response = $this->httpClient->request(
  137. $request->getMethod(),
  138. $request->getEndpoint(),
  139. [
  140. 'headers' => $request->getHeaders(),
  141. ] + (0 === $length ? [] : ['body' => $requestBody])
  142. );
  143. if ($debug = filter_var($this->configuration->get('debug'), \FILTER_VALIDATE_BOOLEAN)) {
  144. $this->logger->debug('AsyncAws HTTP request sent: {method} {endpoint}', [
  145. 'method' => $request->getMethod(),
  146. 'endpoint' => $request->getEndpoint(),
  147. 'headers' => json_encode($request->getHeaders()),
  148. 'body' => 0 === $length ? null : $requestBody,
  149. ]);
  150. }
  151. return new Response($response, $this->httpClient, $this->logger, $this->awsErrorFactory, $this->endpointCache, $request, $debug, $context ? $context->getExceptionMapping() : []);
  152. }
  153. /**
  154. * @return callable[]
  155. */
  156. protected function getSignerFactories(): array
  157. {
  158. return [
  159. 'v4' => static function (string $service, string $region) {
  160. return new SignerV4($service, $region);
  161. },
  162. ];
  163. }
  164. protected function getAwsErrorFactory(): AwsErrorFactoryInterface
  165. {
  166. return new ChainAwsErrorFactory();
  167. }
  168. /**
  169. * Returns the AWS endpoint metadata for the given region.
  170. * When user did not provide a region, the client have to either return a global endpoint or fallback to
  171. * the Configuration::DEFAULT_REGION constant.
  172. *
  173. * This implementation is a BC layer for client that does not require core:^1.2.
  174. *
  175. * @param ?string $region region provided by the user (without fallback to a default region)
  176. *
  177. * @return array{endpoint: string, signRegion: string, signService: string, signVersions: string[]}
  178. */
  179. protected function getEndpointMetadata(?string $region): array
  180. {
  181. /** @psalm-suppress TooManyArguments */
  182. trigger_deprecation('async-aws/core', '1.2', 'Extending "%s"" without overriding "%s" is deprecated. This method will be abstract in version 2.0.', __CLASS__, __FUNCTION__);
  183. /** @var string $endpoint */
  184. $endpoint = $this->configuration->get('endpoint');
  185. /** @var string $region */
  186. $region = $region ?? $this->configuration->get('region');
  187. return [
  188. 'endpoint' => strtr($endpoint, [
  189. '%region%' => $region,
  190. '%service%' => $this->getServiceCode(),
  191. ]),
  192. 'signRegion' => $region,
  193. 'signService' => $this->getSignatureScopeName(),
  194. 'signVersions' => [$this->getSignatureVersion()],
  195. ];
  196. }
  197. /**
  198. * Build the endpoint full uri.
  199. *
  200. * @param string $uri or path
  201. * @param array $query parameters that should go in the query string
  202. * @param ?string $region region provided by the user in the `@region` parameter of the Input
  203. */
  204. protected function getEndpoint(string $uri, array $query, ?string $region): string
  205. {
  206. /** @var string $region */
  207. $region = $region ?? ($this->configuration->isDefault('region') ? null : $this->configuration->get('region'));
  208. if (!$this->configuration->isDefault('endpoint')) {
  209. /** @var string $endpoint */
  210. $endpoint = $this->configuration->get('endpoint');
  211. } else {
  212. $metadata = $this->getEndpointMetadata($region);
  213. $endpoint = $metadata['endpoint'];
  214. }
  215. if (false !== strpos($endpoint, '%region%') || false !== strpos($endpoint, '%service%')) {
  216. /** @psalm-suppress TooManyArguments */
  217. trigger_deprecation('async-aws/core', '1.2', 'providing an endpoint with placeholder is deprecated and will be ignored in version 2.0. Provide full endpoint instead.');
  218. $endpoint = strtr($endpoint, [
  219. '%region%' => $region ?? $this->configuration->get('region'),
  220. '%service%' => $this->getServiceCode(), // if people provides a custom endpoint 'http://%service%.localhost/
  221. ]);
  222. }
  223. $endpoint .= $uri;
  224. if ([] === $query) {
  225. return $endpoint;
  226. }
  227. return $endpoint . (false === strpos($endpoint, '?') ? '?' : '&') . http_build_query($query, '', '&', \PHP_QUERY_RFC3986);
  228. }
  229. protected function discoverEndpoints(?string $region): array
  230. {
  231. throw new LogicException(sprintf('The Client "%s" must implement the "%s" method.', \get_class($this), 'discoverEndpoints'));
  232. }
  233. private function getDiscoveredEndpoint(string $uri, array $query, ?string $region, bool $usesEndpointDiscovery, bool $requiresEndpointDiscovery)
  234. {
  235. if (!$this->configuration->isDefault('endpoint')) {
  236. return $this->getEndpoint($uri, $query, $region);
  237. }
  238. $usesEndpointDiscovery = $requiresEndpointDiscovery || ($usesEndpointDiscovery && filter_var($this->configuration->get(Configuration::OPTION_ENDPOINT_DISCOVERY_ENABLED), \FILTER_VALIDATE_BOOLEAN));
  239. if (!$usesEndpointDiscovery) {
  240. return $this->getEndpoint($uri, $query, $region);
  241. }
  242. // 1. use an active endpoints
  243. if (null === $endpoint = $this->endpointCache->getActiveEndpoint($region)) {
  244. $previous = null;
  245. try {
  246. // 2. call API to fetch new endpoints
  247. $endpoints = $this->discoverEndpoints($region);
  248. $this->endpointCache->addEndpoints($region, $endpoints);
  249. // 3. use active endpoints that has just been injected
  250. $endpoint = $this->endpointCache->getActiveEndpoint($region);
  251. } catch (\Exception $previous) {
  252. }
  253. // 4. if endpoint is still null, fallback to expired endpoint
  254. if (null === $endpoint && null === $endpoint = $this->endpointCache->getExpiredEndpoint($region)) {
  255. if ($requiresEndpointDiscovery) {
  256. throw new RuntimeException(sprintf('The Client "%s" failed to fetch the endpoint.', \get_class($this)), 0, $previous);
  257. }
  258. return $this->getEndpoint($uri, $query, $region);
  259. }
  260. }
  261. $endpoint .= $uri;
  262. if (empty($query)) {
  263. return $endpoint;
  264. }
  265. return $endpoint . (false === strpos($endpoint, '?') ? '?' : '&') . http_build_query($query);
  266. }
  267. /**
  268. * @param ?string $region region provided by the user in the `@region` parameter of the Input
  269. */
  270. private function getSigner(?string $region)
  271. {
  272. /** @var string $region */
  273. $region = $region ?? ($this->configuration->isDefault('region') ? null : $this->configuration->get('region'));
  274. if (!isset($this->signers[$region])) {
  275. $factories = $this->getSignerFactories();
  276. $factory = null;
  277. if ($this->configuration->isDefault('endpoint') || $this->configuration->isDefault('region')) {
  278. $metadata = $this->getEndpointMetadata($region);
  279. } else {
  280. // Allow non-aws region with custom endpoint
  281. $metadata = $this->getEndpointMetadata(Configuration::DEFAULT_REGION);
  282. $metadata['signRegion'] = $region;
  283. }
  284. foreach ($metadata['signVersions'] as $signatureVersion) {
  285. if (isset($factories[$signatureVersion])) {
  286. $factory = $factories[$signatureVersion];
  287. break;
  288. }
  289. }
  290. if (null === $factory) {
  291. throw new InvalidArgument(sprintf('None of the signatures "%s" is implemented.', implode(', ', $metadata['signVersions'])));
  292. }
  293. $this->signers[$region] = $factory($metadata['signService'], $metadata['signRegion']);
  294. }
  295. /** @psalm-suppress PossiblyNullArrayOffset */
  296. return $this->signers[$region];
  297. }
  298. }