361 lines
14 KiB
PHP
361 lines
14 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Unirest;
|
|
|
|
use CoreInterfaces\Core\Request\RequestInterface;
|
|
use CoreInterfaces\Core\Request\RequestMethod;
|
|
use CoreInterfaces\Core\Response\ResponseInterface;
|
|
use CoreInterfaces\Http\HttpClientInterface;
|
|
use CoreInterfaces\Http\RetryOption;
|
|
use DateTime;
|
|
use Unirest\Request\Request;
|
|
|
|
class HttpClient implements HttpClientInterface
|
|
{
|
|
private $handle = null;
|
|
protected $totalNumberOfConnections = 0;
|
|
|
|
/**
|
|
* @var Configuration
|
|
*/
|
|
protected $config;
|
|
|
|
/**
|
|
* @param Configuration|null $configurations
|
|
*/
|
|
public function __construct(?Configuration $configurations = null)
|
|
{
|
|
$this->config = $configurations ?? Configuration::init();
|
|
}
|
|
|
|
public function execute(RequestInterface $request): ResponseInterface
|
|
{
|
|
if ($this->handle == null) {
|
|
$this->initializeHandle();
|
|
} else {
|
|
curl_reset($this->handle);
|
|
}
|
|
$this->setCurlOptions($this->handle, $request);
|
|
$retryCount = 0; // current retry count
|
|
$waitTime = 0.0; // wait time in secs before current api call
|
|
$allowedWaitTime = $this->config->getMaximumRetryWaitTime(); // remaining allowed wait time in seconds
|
|
$httpCode = null;
|
|
$headers = [];
|
|
do {
|
|
// If Retrying i.e. retryCount >= 1
|
|
if ($retryCount > 0) {
|
|
$this->sleep($waitTime);
|
|
// calculate remaining allowed wait Time
|
|
$allowedWaitTime -= $waitTime;
|
|
}
|
|
|
|
// Execution of api call
|
|
$response = curl_exec($this->handle);
|
|
$error = curl_error($this->handle);
|
|
$info = $this->getInfo();
|
|
if (empty($error)) {
|
|
$header_size = $info['header_size'];
|
|
$httpCode = (int)$info['http_code'];
|
|
$headers = $this->parseHeaders(substr($response, 0, $header_size));
|
|
}
|
|
|
|
if ($this->shouldRetryRequest($request)) {
|
|
// calculate wait time for retry, and should not retry when wait time becomes 0
|
|
$waitTime = $this->getRetryWaitTime($httpCode, $headers, $error, $allowedWaitTime, $retryCount);
|
|
$retryCount++;
|
|
}
|
|
} while ($waitTime > 0.0);
|
|
|
|
if (!empty($error) || !isset($header_size, $headers, $httpCode)) {
|
|
throw $request->toApiException($error);
|
|
}
|
|
// get response body
|
|
$body = substr($response, $header_size);
|
|
|
|
$this->totalNumberOfConnections += $this->getInfo(CURLINFO_NUM_CONNECTS);
|
|
|
|
return new Response($httpCode, $body, $headers, $this->config->getJsonOpts());
|
|
}
|
|
|
|
protected function initializeHandle()
|
|
{
|
|
$this->handle = curl_init();
|
|
$this->totalNumberOfConnections = 0;
|
|
}
|
|
|
|
protected function getBody(RequestInterface $request)
|
|
{
|
|
if (empty($request->getParameters())) {
|
|
return $request->getBody();
|
|
}
|
|
// special handling for form parameters i.e.
|
|
// returning flatten array with encoded keys if any multipart parameter exists
|
|
// OR returning concatenated encoded parameters string
|
|
$encodedBody = join('&', $request->getEncodedParameters());
|
|
$multipartParameters = $request->getMultipartParameters();
|
|
if (empty($multipartParameters)) {
|
|
return $encodedBody;
|
|
}
|
|
if (empty($encodedBody)) {
|
|
return $multipartParameters;
|
|
}
|
|
foreach (explode('&', $encodedBody) as $param) {
|
|
$keyValue = explode('=', $param);
|
|
$multipartParameters[urldecode($keyValue[0])] = urldecode($keyValue[1]);
|
|
}
|
|
return $multipartParameters;
|
|
}
|
|
|
|
protected function setCurlOptions($handle, RequestInterface $request): void
|
|
{
|
|
$queryUrl = $request->getQueryUrl();
|
|
$body = $this->getBody($request);
|
|
if ($request->getHttpMethod() !== RequestMethod::GET) {
|
|
if ($request->getHttpMethod() === RequestMethod::POST) {
|
|
curl_setopt($handle, CURLOPT_POST, true);
|
|
} else {
|
|
if ($request->getHttpMethod() === RequestMethod::HEAD) {
|
|
curl_setopt($handle, CURLOPT_NOBODY, true);
|
|
}
|
|
curl_setopt($handle, CURLOPT_CUSTOMREQUEST, strtoupper($request->getHttpMethod()));
|
|
}
|
|
|
|
if (!is_null($body)) {
|
|
curl_setopt($handle, CURLOPT_POSTFIELDS, $body);
|
|
}
|
|
} elseif (is_array($body)) {
|
|
if (strpos($queryUrl, '?') !== false) {
|
|
$queryUrl .= '&';
|
|
} else {
|
|
$queryUrl .= '?';
|
|
}
|
|
$queryUrl .= http_build_query(Request::buildHTTPCurlQuery($body));
|
|
}
|
|
|
|
$curl_base_options = [
|
|
CURLOPT_URL => $queryUrl,
|
|
CURLOPT_RETURNTRANSFER => true,
|
|
CURLOPT_FOLLOWLOCATION => true,
|
|
CURLOPT_MAXREDIRS => 10,
|
|
CURLOPT_HTTPHEADER => $this->getFormattedHeaders($request),
|
|
CURLOPT_HEADER => true,
|
|
CURLOPT_SSL_VERIFYPEER => $this->config->shouldVerifyPeer(),
|
|
// CURLOPT_SSL_VERIFYHOST accepts only 0 (false) or 2 (true).
|
|
// Future versions of libcurl will treat values 1 and 2 as equals
|
|
CURLOPT_SSL_VERIFYHOST => $this->config->shouldVerifyHost() === false ? 0 : 2,
|
|
// If an empty string, '', is set, a header containing all supported encoding types is sent
|
|
CURLOPT_ENCODING => ''
|
|
];
|
|
|
|
curl_setopt_array($handle, $this->mergeCurlOptions($curl_base_options, $this->config->getCurlOpts()));
|
|
|
|
if ($this->config->getTimeout() > 0) {
|
|
curl_setopt($handle, CURLOPT_TIMEOUT, $this->config->getTimeout());
|
|
}
|
|
|
|
if ($this->config->getCookie() !== null) {
|
|
curl_setopt($handle, CURLOPT_COOKIE, $this->config->getCookie());
|
|
}
|
|
|
|
if ($this->config->getCookieFile() !== null) {
|
|
curl_setopt($handle, CURLOPT_COOKIEFILE, $this->config->getCookieFile());
|
|
curl_setopt($handle, CURLOPT_COOKIEJAR, $this->config->getCookieFile());
|
|
}
|
|
|
|
if (!empty($this->config->getAuth()['user'])) {
|
|
curl_setopt_array($handle, [
|
|
CURLOPT_HTTPAUTH => $this->config->getAuth()['method'],
|
|
CURLOPT_USERPWD => $this->config->getAuth()['user'] . ':' . $this->config->getAuth()['pass']
|
|
]);
|
|
}
|
|
|
|
if ($this->config->getProxy()['address'] !== false) {
|
|
$proxy = $this->config->getProxy();
|
|
curl_setopt_array($handle, [
|
|
CURLOPT_PROXYTYPE => $proxy['type'],
|
|
CURLOPT_PROXY => $proxy['address'],
|
|
CURLOPT_PROXYPORT => $proxy['port'],
|
|
CURLOPT_HTTPPROXYTUNNEL => $proxy['tunnel'],
|
|
CURLOPT_PROXYAUTH => $proxy['auth']['method'],
|
|
CURLOPT_PROXYUSERPWD => $proxy['auth']['user'] . ':' . $proxy['auth']['pass']
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Halts program flow for given number of seconds, and microseconds
|
|
*
|
|
* @param float $seconds Seconds with upto 6 decimal places, here decimal part will be converted into microseconds
|
|
*/
|
|
protected function sleep(float $seconds)
|
|
{
|
|
$secs = (int) $seconds;
|
|
// the fraction part of the $seconds will always be less than 1 sec, extracting micro seconds
|
|
$microSecs = (int) (($seconds - $secs) * 1000000);
|
|
sleep($secs);
|
|
usleep($microSecs);
|
|
}
|
|
|
|
/**
|
|
* Check if retries are enabled at global and request level,
|
|
* also check whitelisted httpMethods, if retries are only enabled globally.
|
|
*/
|
|
protected function shouldRetryRequest(RequestInterface $request): bool
|
|
{
|
|
switch ($request->getRetryOption()) {
|
|
case RetryOption::ENABLE_RETRY:
|
|
return $this->config->shouldEnableRetries();
|
|
case RetryOption::USE_GLOBAL_SETTINGS:
|
|
return $this->config->shouldEnableRetries()
|
|
&& in_array(strtoupper($request->getHttpMethod()), $this->config->getHttpMethodsToRetry(), true);
|
|
case RetryOption::DISABLE_RETRY:
|
|
return false;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Generate calculated wait time, and 0.0 if api should not be retried
|
|
*
|
|
* @param int|null $httpCode Http status code in response
|
|
* @param array $headers Response headers
|
|
* @param string $error Error returned by server
|
|
* @param float $allowedWaitTime Remaining allowed wait time
|
|
* @param int $retryCount Attempt number
|
|
* @return float Wait time before sending the next apiCall
|
|
*/
|
|
protected function getRetryWaitTime(
|
|
?int $httpCode,
|
|
array $headers,
|
|
string $error,
|
|
float $allowedWaitTime,
|
|
int $retryCount
|
|
): float {
|
|
$retryWaitTime = 0.0;
|
|
$retry_after = 0;
|
|
if (empty($error)) {
|
|
// Successful apiCall with some status code or with Retry-After header
|
|
$headers_lower_keys = array_change_key_case($headers);
|
|
$retry_after_val = key_exists('retry-after', $headers_lower_keys) ?
|
|
$headers_lower_keys['retry-after'] : null;
|
|
$retry_after = $this->getRetryAfterInSeconds($retry_after_val);
|
|
$retry = isset($retry_after_val)
|
|
|| in_array($httpCode, $this->config->getHttpStatusCodesToRetry(), true);
|
|
} else {
|
|
$retry = $this->config->shouldRetryOnTimeout() && curl_errno($this->handle) == CURLE_OPERATION_TIMEDOUT;
|
|
}
|
|
// Calculate wait time only if max number of retries are not already attempted
|
|
if ($retry && $retryCount < $this->config->getNumberOfRetries()) {
|
|
// noise between 0 and 0.1 secs upto 6 decimal places
|
|
$noise = rand(0, 100000) / 1000000;
|
|
// calculate wait time with exponential backoff and noise in seconds
|
|
$waitTime = ($this->config->getRetryInterval() * pow($this->config->getBackOffFactor(), $retryCount))
|
|
+ $noise;
|
|
// select maximum of waitTime and retry_after
|
|
$waitTime = floatval(max($waitTime, $retry_after));
|
|
if ($waitTime <= $allowedWaitTime) {
|
|
// set retry wait time for next api call, only if its under allowed time
|
|
$retryWaitTime = $waitTime;
|
|
}
|
|
}
|
|
return $retryWaitTime;
|
|
}
|
|
|
|
/**
|
|
* Returns the number of seconds by extracting them from $retry-after parameter
|
|
*
|
|
* @param int|string $retry_after Some numeric value in seconds, or it could be RFC1123
|
|
* formatted datetime string
|
|
* @return int Number of seconds specified by retry-after param
|
|
*/
|
|
protected function getRetryAfterInSeconds($retry_after): int
|
|
{
|
|
if (isset($retry_after)) {
|
|
if (is_numeric($retry_after)) {
|
|
return (int)$retry_after; // if value is already in seconds
|
|
} else {
|
|
// if value is a date time string in format RFC1123
|
|
$retry_after_date = DateTime::createFromFormat('D, d M Y H:i:s O', $retry_after);
|
|
// retry_after_date could either be undefined, or false, or a DateTime object (if valid format string)
|
|
return !$retry_after_date ? 0 : $retry_after_date->getTimestamp() - time();
|
|
}
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
/**
|
|
* if PECL_HTTP is not available use a fallback function
|
|
*
|
|
* thanks to ricardovermeltfoort@gmail.com
|
|
* http://php.net/manual/en/function.http-parse-headers.php#112986
|
|
*/
|
|
private function parseHeaders(string $raw_headers): array
|
|
{
|
|
if (function_exists('http_parse_headers')) {
|
|
return http_parse_headers($raw_headers);
|
|
} else {
|
|
$key = '';
|
|
$headers = [];
|
|
|
|
foreach (explode("\n", $raw_headers) as $i => $h) {
|
|
$h = explode(':', $h, 2);
|
|
|
|
if (isset($h[1])) {
|
|
if (!isset($headers[$h[0]])) {
|
|
$headers[$h[0]] = trim($h[1]);
|
|
} elseif (is_array($headers[$h[0]])) {
|
|
$headers[$h[0]] = array_merge($headers[$h[0]], [trim($h[1])]);
|
|
} else {
|
|
$headers[$h[0]] = array_merge([$headers[$h[0]]], [trim($h[1])]);
|
|
}
|
|
|
|
$key = $h[0];
|
|
} else {
|
|
if (substr($h[0], 0, 1) == "\t") {
|
|
$headers[$key] .= "\r\n\t" . trim($h[0]);
|
|
} elseif (empty($key)) {
|
|
$headers[0] = trim($h[0]);
|
|
}
|
|
}
|
|
}
|
|
|
|
return $headers;
|
|
}
|
|
}
|
|
|
|
public function getInfo(?int $option = null)
|
|
{
|
|
if (is_null($option)) {
|
|
return curl_getinfo($this->handle);
|
|
}
|
|
return curl_getinfo($this->handle, $option);
|
|
}
|
|
|
|
protected function getFormattedHeaders(RequestInterface $request): array
|
|
{
|
|
$combinedHeaders = array_change_key_case(array_merge(
|
|
['user-agent' => 'unirest-php/4.0', 'expect' => ''],
|
|
$this->config->getDefaultHeaders(),
|
|
$request->getHeaders()
|
|
));
|
|
$formattedHeaders = [];
|
|
foreach ($combinedHeaders as $key => $val) {
|
|
$key = trim($key);
|
|
if (!empty($request->getParameters()) && $key == 'content-type') {
|
|
// special handling for form parameters i.e. removing content-type header
|
|
// As, Curl will automatically add content-type for form params
|
|
continue;
|
|
}
|
|
$formattedHeaders[] = "$key: $val";
|
|
}
|
|
return $formattedHeaders;
|
|
}
|
|
|
|
private function mergeCurlOptions(array &$existing_options, array $new_options): array
|
|
{
|
|
$existing_options = $new_options + $existing_options;
|
|
return $existing_options;
|
|
}
|
|
}
|