Skip to content

Instantly share code, notes, and snippets.

@lancegliser
Last active December 2, 2019 03:01
Show Gist options
  • Save lancegliser/6b935978d5d658f6ad26c119c645ea21 to your computer and use it in GitHub Desktop.
Save lancegliser/6b935978d5d658f6ad26c119c645ea21 to your computer and use it in GitHub Desktop.
An base example, without real end points of creating a reusable trait for PHP that allows for API clients to quickly wrap and dispatch standard Guzzle HTTP client behaviors.
<?php
namespace App\Traits\ApiLibrary;
use DateTimeImmutable;
use DateTimeInterface;
use DateTimeZone;
use Exception;
use GuzzleHttp\Middleware;
use Psr\Http\Message\RequestInterface;
use RuntimeException;
use Psr\Http\Message\ResponseInterface;
use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\Exception\RequestException;
use GuzzleHttp\Psr7\Request;
use GuzzleHttp\Psr7\Response;
use function GuzzleHttp\choose_handler;
use GuzzleHttp\RequestOptions;
use GuzzleHttp\Client as GuzzleClient;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\TransferStats;
use Common_Profiler;
use App\Traits\ApiLibrary\Models\Call;
/**
* Trait ApiLibrary
* Provides wrapping functionality and utility interface methods of Guzzler HTTP
*
* @package App\Traits\ApiLibrary
*/
Trait Client
{
/** @var GuzzleClient */
public $client;
/** @var \App\Traits\ApiLibrary\Models\Call[] */
public $calls = [];
/** @var int Defines the number of attempts Guzzle will use when the retry middleware is used */
protected $retriesDefault = 2;
/** @var int Defines the number of milliseconds until an attempt times out */
protected $retryDelayDefault = 1000;
/**
* Defines if the Call array should include the request and response objects.
* This can take considerable memory for large responses.
*
* @var bool
*/
protected $isExtendedLoggingEnabled = FALSE;
/** @var callable[] */
protected $callSubscribers = [];
/** @var callable[] */
protected $middleware = [];
/**
* Provider constructor.
*
* @param array $config
*/
//public function __construct(){
// $this->initializeClient($baseUrl, $options);
// $this->addCallSubscriber($CI->profiler->addApiLibraryCall);
//}
/**
* To be run when the service is constructed
*
* @param string $baseUrl
* @param array $options See: http://docs.guzzlephp.org/en/stable/request-options.html
* @return self
* @throws \RuntimeException
*/
public function initializeClient($baseUrl, array $options = [])
{
// Construct the internal client using Guzzler against a base uri and merged options
$options += [
'base_uri' => $baseUrl,
] + $this->getDefaultClientOptions();
$this->client = new GuzzleClient($options);
$this->addProfilerCallSubscription();
return $this;
}
/**
* @return array
* @throws \RuntimeException
*/
protected function getDefaultClientOptions()
{
return [
RequestOptions::CONNECT_TIMEOUT => 5,
RequestOptions::TIMEOUT => 60,
RequestOptions::VERIFY => FALSE,
// Shouldn't do this! Instead install https://raw.githubusercontent.com/bagder/ca-bundle/master/ca-bundle.crt
RequestOptions::ON_STATS => function (TransferStats $stats) {
$this->onCallStats($stats, TRUE);
},
'handler' => $this->getMiddlewareHandlerStack(),
];
}
/**
* Returns the base handler stack, without any client middleware.
*
* @return \GuzzleHttp\HandlerStack
* @see http://docs.guzzlephp.org/en/stable/handlers-and-middleware.html
* @throws \RuntimeException
*/
final protected function getBaseHandlerStack()
{
$handler = choose_handler();
$stack = HandlerStack::create($handler);
$stack->push(Middleware::mapRequest($this->addRequestStartTime()));
return $stack;
}
/**
* @return \Closure
*/
final protected function addRequestStartTime()
{
/**
* @param Request $request
* @return Request
*/
return function (Request $request) {
$request->startTime = $this->getCurrentDateTimeImmutable();
return $request;
};
}
/**
* Returns a handler stack with all of the defined middleware for the client included.
*
* @return \GuzzleHttp\HandlerStack
* @see http://docs.guzzlephp.org/en/stable/handlers-and-middleware.html
* @throws \RuntimeException
*/
final protected function getMiddlewareHandlerStack()
{
$stack = $this->getBaseHandlerStack();
foreach ($this->middleware as $callback)
{
$stack->push($callback);
}
return $stack;
}
/**
* @param callable $callback
* @see http://docs.guzzlephp.org/en/stable/handlers-and-middleware.html
*/
public function addMiddleware(callable $callback)
{
$this->middleware[] = $callback;
}
/**
* @return \Closure
*/
protected function retryDecider()
{
/**
* @param int $retries
* @param Request $request
* @param Response $response
* @param RequestException $exception
* @return boolean
*/
return function (
$retries,
Request $request,
Response $response = NULL,
RequestException $exception = NULL
){
unset($request);
if ($retries >= $this->retriesDefault)
{
return FALSE;
}
// Retry connection exceptions
if ($exception instanceof ConnectException)
{
return TRUE;
}
if ($response)
{
// Retry on server errors
if ($response->getStatusCode() >= 500)
{
return TRUE;
}
}
return FALSE;
};
}
/**
* Provides a delay calculator for when to reattempt a call.
*
* @return \Closure
*/
function retryDelay()
{
/**
* @param int $numberOfRetries
* @return int
*/
return function ($numberOfRetries) {
return $this->retryDelayDefault * $numberOfRetries;
};
}
/**
* Provides an automatic subscription to the profiler to push Api Call
*
* @throws \RuntimeException
*/
protected function addProfilerCallSubscription()
{
if(!function_exists('get_instance') || !class_exists('CI_Controller')){
throw new RuntimeException('CodeIgniter is not yet registered to handle subscriptions.');
}
$CI = get_instance();
$CI->load->library('profiler');
/** @var \Common_Profiler $profiler */
$profiler = $CI->profiler;
if (!is_a($profiler, Common_Profiler::class))
{
throw new RuntimeException(get_class($profiler) . ' is not an instance of Common_Profiler.');
}
$this->addCallSubscriber([$profiler, 'add_api_call']);
}
/**
* @param callable $callable
*/
public function addCallSubscriber(callable $callable)
{
$this->callSubscribers[] = $callable;
}
/**
* @param TransferStats $stats
* @param boolean $sendNotifications
*/
public function onCallStats(TransferStats $stats, $sendNotifications = TRUE)
{
try
{
$endTime = $this->getCurrentDateTimeImmutable();
$handlerStats = $stats->getHandlerStats();
$request = $stats->getRequest();
// The TransferStats object can be unreliable for total time statistics.
// Calculate our own instead using our middleware hooks if possible.
if(!empty($request->startTime)){
/** @var DateTimeImmutable $startTime */
$startTime = $request->startTime;
$totalTime = $this->getMicrotimeDiff($endTime, $startTime);
} else {
$startTime = NULL;
$totalTime = $stats->getTransferTime();
}
$properties = [
'method' => $request->getMethod(),
'uri' => $stats->getEffectiveUri(),
'startTime' => $startTime,
'endTime' => $endTime,
'totalTime' => $totalTime,
'contentType' => $handlerStats['content_type'],
'httpCode' => $handlerStats['http_code'],
'headerSize' => $handlerStats['header_size'],
'requestSize' => $handlerStats['request_size'],
'filetime' => $handlerStats['filetime'],
'sslVerifyResult' => $handlerStats['ssl_verify_result'],
'redirectCount' => $handlerStats['redirect_count'],
'namelookupTime' => $handlerStats['namelookup_time'],
'connectTime' => $handlerStats['connect_time'],
'pretransferTime' => $handlerStats['pretransfer_time'],
'sizeUpload' => $handlerStats['size_upload'],
'sizeDownload' => $handlerStats['size_download'],
'speedDownload' => $handlerStats['speed_download'],
'speedUpload' => $handlerStats['speed_upload'],
'downloadContentLength' => $handlerStats['download_content_length'],
'uploadContentLength' => $handlerStats['upload_content_length'],
'starttransferTime' => $handlerStats['starttransfer_time'],
'redirectTime' => $handlerStats['redirect_time'],
'redirectUrl' => $handlerStats['redirect_url'],
'primaryIp' => $handlerStats['primary_ip'],
'certinfo' => $handlerStats['certinfo'],
'primaryPort' => $handlerStats['primary_port'],
'localIp' => $handlerStats['local_ip'],
'localPort' => $handlerStats['local_port'],
];
if ($this->isExtendedLoggingEnabled)
{
$properties += [
'request' => $request,
'response' => $stats->hasResponse() ? $stats->getResponse() : NULL,
];
}
$call = new Call($properties);
$this->calls[] = $call;
if ($sendNotifications)
{
$this->notifySubscribers($call);
}
// Log the really bad items for processing
if($call->getTotalTime() >= 3){
log_warning("Long api call: " . $call->getTotalTime() . ' ' . $call->getProfilerContent() . "\n" . print_r($call, TRUE));
}
} catch (Exception $exception)
{
log_exception($exception);
}
}
/**
* Call this handler to avoid notifying the clients initially, then notify them if everything goes well in your own code:
* $this->notifySubscribers($this->getLastCall());
*
* Disable call stats default handler. We're going to have to go sync.
* If we don't, the performance and other logging systems will fire causing a loop of log in attempts.
*
* @param TransferStats $stats
*/
public function onCallStatsWithoutNotifications(TransferStats $stats)
{
$this->onCallStats($stats, FALSE);
}
/**
* @param \App\Traits\ApiLibrary\Models\Call $call
*/
protected function notifySubscribers(Call $call)
{
foreach ($this->callSubscribers as $subscriber)
{
$subscriber($call);
}
}
/**
* @return \App\Traits\ApiLibrary\Models\Call|NULL
*/
public function getLastCall()
{
if (empty($this->calls))
{
return NULL;
}
return $this->calls[ count($this->calls) - 1 ];
}
/**
* @return \App\Traits\ApiLibrary\Models\Call[]
*/
public function getAllCalls()
{
return $this->calls;
}
/**
* Provides the current Date Time properly including microseconds in UTC
*
* @return \DateTimeImmutable
* @throws \Exception
*/
protected function getCurrentDateTimeImmutable()
{
if (is_php('7.1'))
{
return new DateTimeImmutable('now', new DateTimeZone('UTC'));
}
list($usec) = explode(' ', microtime());
$micro = sprintf("%06d", $usec * 1000000);
return new DateTimeImmutable(date('Y-m-d H:i:s.' . $micro), new DateTimeZone('UTC'));
}
/**
* Returns the absolute difference between dates with microsecond precision
*
* @param \DateTimeInterface|\DateTime|DateTimeImmutable $date1
* @param \DateTimeInterface|\DateTime|DateTimeImmutable $date2
* @return float
*/
protected function getMicrotimeDiff(DateTimeInterface $date1, DateTimeInterface $date2)
{
//Absolute val of Date 1 in seconds from (EPOCH Time) - Date 2 in seconds from (EPOCH Time)
$diff = abs(strtotime($date1->format('d-m-Y H:i:s.u')) - strtotime($date2->format('d-m-Y H:i:s.u')));
//Creates variables for the microseconds of date1 and date2
$micro1 = $date1->format("u");
$micro2 = $date2->format("u");
//Absolute difference between these micro seconds:
$micro = abs($micro1 - $micro2);
//Creates the variable that will hold the seconds (?):
$difference = $diff . "." . $micro;
return floatval($difference);
}
/**
* @param \Psr\Http\Message\ResponseInterface $response
* @return mixed
* @throws \Exception
*/
public function getJsonResponseData(ResponseInterface $response)
{
$response->getBody()->rewind();
$bodyContents = $response->getBody()->getContents();
// A bit of header checking - Don't attempt to decode anything that declares it's not JSON.
$contentTypeHeader = $response->getHeaderLine('Content-Type');
if(!empty($contentTypeHeader) && !$this->isContentTypeJson($contentTypeHeader))
{
$bodySnippet = $this->getResponseBodySnippet($response);
$message = "Response Content-Type is invalid for json decoding: {$contentTypeHeader} '{$bodySnippet}'";
throw new Exception($message);
}
$data = json_decode($bodyContents);
if ($data === NULL)
{
$bodySnippet = $this->getResponseBodySnippet($response);
throw new Exception("Failed to decode service response body: {$bodySnippet}");
}
return $data;
}
/**
* @param \Psr\Http\Message\ResponseInterface $response
* @return boolean
*/
protected function isResponseContentTypeJson(ResponseInterface $response)
{
$contentTypeHeader = $response->getHeaderLine('Content-Type');
return $this->isContentTypeJson($contentTypeHeader);
}
/**
* @param string $contentType
* @return boolean
*/
protected function isContentTypeJson($contentType)
{
if(empty($contentType)){
return FALSE;
}
list($contentType) = explode(';', $contentType);
return in_array(
$contentType,
[
'application/json', // Standard JSON
'application/javascript', // JSON P for executable javascript required in some cross-domain fun
'application/geo+json',
]
);
}
/**
* @param \Psr\Http\Message\ResponseInterface $response
* @return bool|string
* @throws \RuntimeException
*/
protected function getResponseBodySnippet(ResponseInterface $response)
{
$response->getBody()->rewind();
$bodyContents = $response->getBody()->getContents();
return !empty($bodyContents) ? substr($bodyContents, 0, 200) : '{No contents}';
}
/**
* @param \Psr\Http\Message\RequestInterface $request
* @param \Psr\Http\Message\ResponseInterface $response
* @return string[]
* @throws \RuntimeException
* @throws \Exception
*/
protected function getRequestAndResponseDetailStrings(RequestInterface $request, ResponseInterface $response)
{
$requestBody = $request->getBody();
$requestBody->rewind();
$requestDetails = json_encode([
'method' => $request->getMethod(),
'url' => (string)$request->getUri(),
'headers' => $request->getHeaders(),
'contents' => json_decode($requestBody->getContents()),
]
);
$responseBody = $request->getBody();
$responseBody->rewind();
$responseContents = $responseBody->getContents();
if($this->isResponseContentTypeJson($response))
{
$responseContents = $this->getJsonResponseData($response);
}
$responseDetails = json_encode([
'headers' => $response->getHeaders(),
'contents' => $responseContents,
]
);
$messages = [
$response->getStatusCode() . ': ' . $response->getReasonPhrase(),
"Request:",
$requestDetails,
"Response:",
$responseDetails,
] ;
return $messages;
}
}
<?php
namespace App\GuzzleApiClient;
use App\GuzzleApiClient\Exceptions\NotFoundException;
use DateTime;
use Exception;
use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\Exception\RequestException;
use GuzzleHttp\Psr7\Request;
use GuzzleHttp\Psr7\Response;
use Psr\SimpleCache\InvalidArgumentException;
use Symfony\Component\Cache\Simple\FilesystemCache;
use GuzzleHttp\Middleware;
use GuzzleHttp\RequestOptions;
use Psr\Http\Message\RequestInterface;
use App\Traits\Singleton;
use App\ApiLibrary\Client;
/**
* Class AbstractProvider
*
* @package App\GuzzleApiClient
* @link API documentation https://api.example.com/
*/
class GuzzleApiClient
{
use Singleton;
use Client
{
initializeClient as protected traitInitializeClient;
}
const TEST_API_HOST = 'https://dev-api.example.com/';
const PROD_API_HOST = 'https://api.example.com/';
const ENDPOINT_LOGIN = 'user/login';
const ENDPOINT_LOGOUT = 'user/logout';
const LOGIN_CACHE_KEY = 'login.passportKey';
/** @var string */
protected $username;
/** @var string */
protected $password;
/** @var Models\Login */
protected $login;
/** @var FilesystemCache */
protected $cache;
/**
* @param string $username
* @param string $password
* @param string $cachePath
* @return \App\GuzzleApiClient\Provider
*/
public static function getInstance($username, $password, $cachePath)
{
return self::getStaticInstance($username, $password, $cachePath);
}
/**
* @param string|\DateTimeInterface $date A date-like string, or a real date time
* @return string|mixed
*/
public static function formatDate($date)
{
if(empty($date)){
return '';
}
// Get a valid DateTime object
if(is_string($date))
{
try
{
$date = empty($timezone) ? new DateTime($date) : new DateTime($date, $timezone);
} catch (Exception $e)
{
return $date;
}
}
// Format the string
$string = $date->format(self::DATE_FORMAT);
return $string;
}
/**
* Provider constructor.
*
* @param string $username
* @param string $password
* @param string $cachePath
*/
protected function __construct($username, $password, $cachePath)
{
$this->username = $username;
$this->password = $password;
//$this->isExtendedLoggingEnabled = TRUE;
$cacheNamespace = preg_replace('/[^\w\d]/', '.', __CLASS__);
$this->cache = new FilesystemCache($cacheNamespace, 0, $cachePath);
// You'll want to do something similar to this
$this->initializeClient(self::PROD_API_HOST);
}
/**
* To be run when the service is constructed
*
* @param string $baseUrl
* @param array $options See: http://docs.guzzlephp.org/en/stable/request-options.html
* @return self
* @throws \RuntimeException
*/
public function initializeClient($baseUrl, array $options = [])
{
// Retry middleware
$this->addMiddleware(Middleware::retry(
$this->retryDecider(),
$this->retryDelay()
)
);
// Authorization middleware
$this->addMiddleware(Middleware::mapRequest(function (RequestInterface $request){
return $this->addAuthorization($request);
}
)
);
//$options[ RequestOptions::TIMEOUT ] = 15; // In case an api is really painfully slow
return $this->traitInitializeClient($baseUrl, $options);
}
/**
* @return self
*/
public function clearCache()
{
$this->login = NULL;
try
{
$this->cache->deleteMultiple([self::LOGIN_CACHE_KEY]);
} catch (InvalidArgumentException $exception)
{
}
return $this;
}
/**
* @param \Psr\Http\Message\RequestInterface $request
* @return \Psr\Http\Message\RequestInterface
* @throws \Exception
*/
protected function addAuthorization(RequestInterface $request)
{
$login = $this->getLogin();
// Add the header to the request
$request = $request->withHeader('Authentication', $login->accessToken);
return $request;
}
/**
* @return Models\Login
* @throws \Exception
*/
public function getLogin()
{
// Check the cache
if (empty($this->login))
{
try
{
$this->login = $this->getLoginFromCache();
} catch (InvalidArgumentException $exception)
{
log_exception($exception);
}
}
if (empty($this->login))
{
$this->login = $this->getLoginFromClient();
try
{
$this->setLoginCache($this->login);
} catch (InvalidArgumentException $exception)
{
log_exception($exception);
}
}
return $this->login;
}
/**
* @return Models\Login|null
* @throws \Psr\SimpleCache\InvalidArgumentException
*/
protected function getLoginFromCache()
{
return $this->cache->get(self::LOGIN_CACHE_KEY);
}
/**
* @param Models\Login $login
* @return mixed|null
* @throws \Psr\SimpleCache\InvalidArgumentException
*/
protected function setLoginCache(Models\Login $login)
{
return $this->cache->set(self::LOGIN_CACHE_KEY, $login);
}
/**
* @return \App\GuzzleApiClient\Models\Login
* @throws \Exception
*/
protected function getLoginFromClient()
{
$data = [
'Username' => $this->username,
'Password' => $this->password,
];
$response = $this->client->get(
self::ENDPOINT_LOGIN,
[
RequestOptions::QUERY => $data,
// We'll use the default handler so we don't rerun our middleware
'handler' => $this->getBaseHandlerStack()
]
+ $this->getDefaultClientOptions()
);
$data = $this->getJsonResponseData($response);
return new Models\Login($data);
}
/**
* @return self
* @throws \Exception
*/
public function logout()
{
$this->client->get(self::ENDPOINT_LOGOUT);
$this->clearCache();
return $this;
}
/**
* @return \Closure
*/
protected function retryDecider()
{
/**
* @param int $retries
* @param Request $request
* @param Response $response
* @param RequestException $exception
* @return boolean
*/
return function (
$retries,
Request $request,
Response $response = NULL,
RequestException $exception = NULL
){
unset($request);
// Limit the number of retries to 5
if ($retries >= $this->retriesDefault)
{
return FALSE;
}
// Retry connection exceptions
if ($exception instanceof ConnectException)
{
return TRUE;
}
if ($response)
{
// Retry on server errors
if ($response->getStatusCode() >= 500)
{
return TRUE;
}
// Retry on authentication errors after clearing the login
if ($response->getStatusCode() === HTTP_UNAUTHORIZED)
{
$this->clearCache();
return TRUE;
}
}
return FALSE;
};
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment