src/ShipEngineClient.php
<?php
namespace BluefynInternational\ShipEngine;
use BluefynInternational\ShipEngine\Message\RateLimitExceededException;
use BluefynInternational\ShipEngine\Message\ShipEngineException;
use BluefynInternational\ShipEngine\Models\RequestLog;
use DateInterval;
use Exception;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
use GuzzleHttp\Psr7\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Cache;
use Throwable;
class ShipEngineClient
{
/**
* @param string $path
* @param ShipEngineConfig $config
* @param array|null $params
*
* @return array|null
* @throws GuzzleException
*/
public static function get(string $path, ShipEngineConfig $config, null|array $params = null): array|null
{
if ($params) {
$path .=
(parse_url($path, PHP_URL_QUERY) ? '&' : '?')
. http_build_query($params);
}
return self::sendRequestWithRetries('GET', $path, [], $config);
}
/**
* Implement a POST request and return output
*
* @param string $path
* @param ShipEngineConfig $config
* @param array|null $body
*
* @return array|null
* @throws GuzzleException
*/
public static function post(string $path, ShipEngineConfig $config, array $body = null): array|null
{
return self::sendRequestWithRetries('POST', $path, $body, $config);
}
/**
* Implement a POST request and return output
*
* @param string $path
* @param ShipEngineConfig $config
* @param array|null $body
*
* @return array|null
* @throws GuzzleException
*/
public static function patch(string $path, ShipEngineConfig $config, array $body = null): array|null
{
return self::sendRequestWithRetries('PATCH', $path, $body, $config);
}
/**
* Implement a PUT request and return output
*
* @param string $path
* @param ShipEngineConfig $config
* @param array|null $body
*
* @return array|null
* @throws GuzzleException
*/
public static function put(string $path, ShipEngineConfig $config, array $body = null): array|null
{
return self::sendRequestWithRetries('PUT', $path, $body, $config);
}
/**
* Implement a DELETE request and return output
*
* @param string $path
* @param ShipEngineConfig $config
*
* @return array|null
* @throws GuzzleException
*/
public static function delete(string $path, ShipEngineConfig $config): array|null
{
return self::sendRequestWithRetries('DELETE', $path, null, $config);
}
/**
* Send a `REST` request via *ShipEngineClient*.
*
* @param string $method
* @param string $path
* @param array|null $body
* @param ShipEngineConfig $config
*
* @return array|null
*
* @throws GuzzleException
*/
private static function sendRequestWithRetries(
string $method,
string $path,
?array $body,
ShipEngineConfig $config
): array|null {
$response = [];
$retry = 0;
$needs_retry = true;
do {
try {
$response = self::sendRequest($method, $path, $body, $config);
$needs_retry = false;
} catch (\RuntimeException $err) {
if ($retry < $config->retries &&
$err instanceof RateLimitExceededException &&
$err->retryAfter->s < $config->timeout->s
) {
// The request was blocked due to exceeding the rate limit.
// So wait the specified amount of time and then retry.
sleep($err->retryAfter->s);
} else {
throw $err;
}
}
} while ($needs_retry && ++$retry <= $config->retries);
return $response;
}
/**
* Send a `REST` request via HTTP Messages to ShipEngine API. If the response
* is successful, the result is returned. Otherwise, an error is thrown.
*
* @param string $method
* @param string $path
* @param array|null $body
* @param ShipEngineConfig $config
*
* @return array|null
*
* @throws GuzzleException|ShipEngineException
*/
private static function sendRequest(
string $method,
string $path,
?array $body,
ShipEngineConfig $config
): array|null {
$requestHeaders = [
'api-key' => $config->apiKey,
'User-Agent' => self::deriveUserAgent(),
'Content-Type' => 'application/json',
'Accept' => 'application/json',
];
$client = new Client([
'base_uri' => $config->baseUrl,
'timeout' => $config->timeoutTotal->s,
'max_retry_attempts' => $config->retries,
]);
$versioned_path = self::buildVersionedUrlPath($path);
$encoded_body = json_encode($body, JSON_UNESCAPED_SLASHES);
$request = new Request(
$method,
$versioned_path,
$requestHeaders,
$encoded_body,
);
$requestLog = RequestLog::makeFromGuzzle($request);
if (! is_null($requestLog->body)) {
$requestLog->body = json_encode($requestLog->body);
}
try {
self::incrementRequestCount($config);
$response = $client->send(
$request,
['timeout' => $config->timeout->s, 'http_errors' => false]
);
$requestLogResponse = json_decode((string)$response->getBody(), true);
if (self::responseIsRateLimit($requestLogResponse)) {
throw new RateLimitExceededException(retryAfter: new DateInterval('PT1S'));
}
} catch (Exception|Throwable $err) {
if (config('shipengine.track_requests')) {
$requestLog->exception = substr($err->getMessage(), 0, config('shipengine.request_log_table_exception_length'));
$requestLog->save();
}
throw new ShipEngineException(
"An unknown error occurred while calling the ShipEngine $method API:\n" .
$err->getMessage(),
null,
'ShipEngine',
'System',
'Unspecified'
);
}
$requestLog->response_code = $response->getStatusCode();
$requestLog->response = $requestLogResponse;
if (config('shipengine.track_requests')) {
$requestLog->save();
}
return self::handleResponse($requestLog->response, $requestLog->response_code);
}
private static function buildVersionedUrlPath(string $path) : string
{
$api_version = config('shipengine.endpoint.version', 'v1');
if (! in_array(stripos($path, $api_version), [0 ,1], true)) {
$path = str_replace('//', '/', '/' . $api_version . '/' . $path);
}
return $path;
}
/**
* Handles the response from ShipEngine API.
*
* @param array|null $response
* @param int $responseCode
* @return array|null
*/
private static function handleResponse(array|null $response, int $responseCode): array|null
{
if (is_null($response) && $responseCode === Response::HTTP_NO_CONTENT) {
return null;
}
if (! isset($response['errors']) || (count($response['errors']) == 0)) {
return $response;
}
$error = $response['errors'][0];
throw new ShipEngineException(
$error['message'],
$response['request_id'],
$error['error_source'],
$error['error_type'],
$error['error_code']
);
}
/**
* Derive a User-Agent header from the environment. This is the user-agent that will be set on every request
* via the ShipEngine Client.
*
* @returns string
*/
private static function deriveUserAgent(): string
{
$sdk_version = 'shipengine-php/' . ShipEngine::VERSION;
$os = explode(' ', php_uname());
$os_kernel = $os[0] . '/' . $os[2];
$php_version = 'PHP/' . phpversion();
return $sdk_version . ' ' . $os_kernel . ' ' . $php_version;
}
private static function responseIsRateLimit(array $response) : bool
{
return 'API rate limit exceeded' === ($response['message'] ?? null);
}
private static function incrementRequestCount(ShipEngineConfig $config) : void
{
$lock = tap(Cache::lock('shipengine.api-request.lock', 10))->block(10);
try {
$count = Cache::get('shipengine.api-request.count', 0);
$nextExpire = now()->seconds(0)->addMinute();
if ($count > $config->requestLimitPerMinute) {
throw new RateLimitExceededException(retryAfter: new DateInterval('PT1S'));
}
Cache::put('shipengine.api-request.count', $count + 1, $nextExpire);
} finally {
$lock->release();
}
}
}