Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature: use curl (or whatever else) as HttpAdapter #675

Open
wants to merge 22 commits into
base: v3-v2021-02-25
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
774dc86
feat: allow for http adapter param
saas786 Sep 28, 2021
e20809e
Merge remote-tracking branch 'github-saas/syed/v3-v2021-02-25/guzzle'…
Mar 4, 2022
1071e2f
Revert "Merge remote-tracking branch 'github-saas/syed/v3-v2021-02-25…
Mar 4, 2022
9414cbe
Added a connection error class, to start handling basic connection er…
Mar 4, 2022
054764a
WIP on error handling
Mar 7, 2022
d703f67
Modified base_client to accept HttpAdapter in "options" parameter.
Mar 7, 2022
e02ce35
Merge branch 'v3-v2021-02-25--connection-errors' into v3-v2021-02-25-…
Mar 7, 2022
0341e5b
Preliminary commit of untested, crappy curl adapter.
Mar 7, 2022
f50c1e5
Oops, dumb modification got left in, now removed.
Mar 7, 2022
1eec2cc
how did this typo get through
Mar 7, 2022
83de8ca
implements, not extends, dammit
Mar 7, 2022
8b9ab35
fixes for curl client
Mar 10, 2022
9b6b3e7
tests/mock_client now supports http_adapter replacements in options
Mar 14, 2022
11d8872
Fallback to guessing error type from HTTP status code even if "Conten…
Mar 14, 2022
8fe6944
Merge branch 'recurly:v3-v2021-02-25' into v3-v2021-02-25--httpadapter
SinusPi Mar 14, 2022
c66fd99
Check response validity always, not just on toResource,
Mar 14, 2022
7caf7ce
Fixed response test to verify assertion, not toResource erroring out,…
Mar 14, 2022
2f4f754
Merge branch 'v3-v2021-02-25--httpadapter' of https://github.com/Sinu…
Mar 14, 2022
4590c71
Merge branch 'v3-v2021-02-25--dev' into v3-v2021-02-25--httpadapter
Mar 14, 2022
c4423aa
Fixed gzip decoding by delegating it to curl
Mar 17, 2022
454d2c3
Added a test for the curl adapter. WARNING: uses live API. Safeguarde…
Mar 17, 2022
32bc00f
Reworked error reporting in Curl adapter, no longer throwing a Recurl…
Mar 17, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions lib/recurly/base_client.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,16 @@ public function __construct(string $api_key, LoggerInterface $logger = null, arr
$this->baseUrl = BaseClient::API_HOSTS[$options['region']];
}

$this->http = new HttpAdapter;
if (!isset($options['http_adapter'])) {
$options['http_adapter'] = new HttpAdapter;
}
if (!($options['http_adapter'] instanceof HttpAdapterInterface)) {
throw new \TypeError("http_adapter option must implement HttpAdapterInterface");
}
$this->http = $options['http_adapter'];

if (is_null($logger)) {
$logger = new \Recurly\Logger('Recurly', LogLevel::WARNING);
$logger = new \Recurly\Logger('Recurly', LogLevel::WARNING);
}
$this->_logger = $logger;

Expand Down Expand Up @@ -126,6 +133,7 @@ private function _getResponse(\Recurly\Request $request): \Recurly\Response
'response_headers' => $response->getHeaders()
]
);
$response->assertValid(); // throws \Recurly\RecurlyError if response is bad

return $response;
}
Expand Down
18 changes: 18 additions & 0 deletions lib/recurly/errors/connection_error.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php
namespace Recurly\Errors;

class ConnectionError extends \Error
{
protected $_data;

public function getData(): int
{
return $this->_data;
}

public function __construct(string $message, int $code = null, $data = null)
{
parent::__construct($message,$code);
$this->_data = $data;
}
}
4 changes: 2 additions & 2 deletions lib/recurly/http_adapter.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
/**
* @codeCoverageIgnore
*/
class HttpAdapter
class HttpAdapter implements HttpAdapterInterface
{
private static $_default_options = [
'ignore_errors' => true
Expand Down Expand Up @@ -44,7 +44,7 @@ public function execute($method, $url, $body, $headers): array
$options['header'] = $headers_str;
$context = stream_context_create(['http' => $options]);
$result = file_get_contents($url, false, $context);

if (!empty($result)) {
foreach($http_response_header as $h) {
if(preg_match('/Content-Encoding:.*gzip/i', $h)) {
Expand Down
102 changes: 102 additions & 0 deletions lib/recurly/http_adapter_curl.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
<?php
/**
* This class abstracts away all the PHP-level HTTP
* code. This allows us to easily mock out the HTTP
* calls in BaseClient by injecting a mocked version of
* this adapter.
*/

namespace Recurly;

/**
* @codeCoverageIgnore
*/
class HttpAdapterCurl implements HttpAdapterInterface
{

/**
* Performs HTTP request
*
* @param string $method HTTP method to use
* @param string $url Fully qualified URL
* @param string $body The request body
* @param array $headers HTTP headers
*
* @return array The API response as a string and the headers as an array
*/
public function execute($method, $url, $body, $headers): array
{
$curl = curl_init();
// borrowed from client for API v2
curl_setopt($curl, CURLOPT_URL, $url);
curl_setopt($curl, CURLOPT_SSL_VERIFYPEER, TRUE);
curl_setopt($curl, CURLOPT_SSL_VERIFYHOST, 2);
// curl_setopt($curl, CURLOPT_CAINFO, self::$CACertPath);

// Connection:
curl_setopt($curl, CURLOPT_CONNECTTIMEOUT, 10);
curl_setopt($curl, CURLOPT_TIMEOUT, 45);
curl_setopt($curl, CURLOPT_ENCODING , "gzip");

// Request:
if ($method == "POST") {
curl_setopt($curl, CURLOPT_POST, true);
curl_setopt($curl, CURLOPT_POSTFIELDS, $body);
} elseif ($method == "PUT") {
curl_setopt($curl, CURLOPT_CUSTOMREQUEST, $method);
curl_setopt($curl, CURLOPT_POSTFIELDS, $body);
} elseif ($method == "HEAD") {
curl_setopt($curl, CURLOPT_NOBODY, true);
} elseif ($method != "GET") {
curl_setopt($curl, CURLOPT_CUSTOMREQUEST, $method);
}
$req_headers = ['Content-Length: '.strlen($body)];
array_walk($headers,function($v,$k) use (&$req_headers) { $req_headers[] = "$k: $v"; });
curl_setopt($curl, CURLOPT_HTTPHEADER, $req_headers);

// Response:
curl_setopt($curl, CURLOPT_FOLLOWLOCATION, FALSE);
curl_setopt($curl, CURLOPT_MAXREDIRS, 1);
curl_setopt($curl, CURLOPT_HEADER, 0);
curl_setopt($curl, CURLOPT_RETURNTRANSFER, 1);
$response_header = [];
curl_setopt($curl, CURLOPT_HEADERFUNCTION, function($curl,$line) use (&$response_header) {
$response_header[]=$line;
return strlen($line);
});

// Debugging:
if (defined("RECURLY_CURL_DEBUG")) {
curl_setopt($curl, CURLOPT_VERBOSE, true);
$streamVerboseHandle = fopen('php://stdout', 'w+');
curl_setopt($curl, CURLOPT_STDERR, $streamVerboseHandle);
curl_setopt($curl, CURLOPT_RETURNTRANSFER, 0);
}

$result = curl_exec($curl);

$curl_errno = curl_errno($curl);
$curl_error = curl_error($curl);
$curl_info = curl_getinfo($curl);
curl_close($curl);

// Cram errors into curl_info to make it a complete diagnostic box
$curl_info['_errno'] = $curl_errno;
$curl_info['_error'] = $curl_error;

if (defined("RECURLY_CURL_DEBUG_2")) {
echo "\nHeaders:\n"; print_r($req_headers);
echo "\nResponse h:\n"; print_r($response_header);
echo "\nCurlinfo:\n"; print_r($curl_info);
echo "\nBody:\n"; print_r($result);
}

if ($curl_errno>0) {
throw new \Recurly\Errors\ConnectionError("Curl error: ".$curl_error, $curl_errno, $curl_info);
}

// Cram curl_info into the response header, for when the request is valid.
$response_header[]='_curl_info: '.serialize($curl_info);
return [$result, $response_header];
}
}
11 changes: 11 additions & 0 deletions lib/recurly/http_adapter_interface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php

namespace Recurly;

/**
* @codeCoverageIgnore
*/
interface HttpAdapterInterface
{
public function execute($method, $url, $body, $headers): array;
}
16 changes: 10 additions & 6 deletions lib/recurly/recurly_error.php
Original file line number Diff line number Diff line change
Expand Up @@ -54,13 +54,17 @@ public static function fromResponse(\Recurly\Response $response): \Recurly\Recur
$api_error = \Recurly\Resources\ErrorMayHaveTransaction::fromResponse($response, $error);
return new $klass($error->message, $api_error);
}
} else {
$error_type = static::errorFromStatus($response->getStatusCode());
$klass = static::titleize($error_type, '\\Recurly\\Errors\\');
if (class_exists($klass)) {
return new $klass('An unexpected error has occurred');
}
}

// "Content-type: application/json" may appear without a body after a HEAD request.
// If the above failed, try guessing from the status code.
$error_type = static::errorFromStatus($response->getStatusCode());
$klass = static::titleize($error_type, '\\Recurly\\Errors\\');
if (class_exists($klass)) {
return new $klass('An unexpected error has occurred');
}

// No valid error type was found, sorry.
$klass = static::_defaultErrorType($response);
return new $klass('An unexpected error has occurred');

Expand Down
24 changes: 15 additions & 9 deletions lib/recurly/response.php
Original file line number Diff line number Diff line change
Expand Up @@ -53,23 +53,29 @@ public function getRequest(): \Recurly\Request
return $this->_request;
}

/**
* Makes sure the response is valid. Throws RecurlyError otherwise.
*/
public function assertValid(): void
{
if ($this->_status_code < 200 || $this->_status_code >= 300) {
throw \Recurly\RecurlyError::fromResponse($this);
}
}

/**
* Converts the Response into a \Recurly\RecurlyResource
*
* @return \Recurly\RecurlyResource
*/
public function toResource(): \Recurly\RecurlyResource
{
if ($this->_status_code >= 200 && $this->_status_code < 300) {
if (empty($this->_response)) {
return \Recurly\RecurlyResource::fromEmpty($this);
} elseif (in_array($this->getContentType(), static::BINARY_TYPES)) {
return \Recurly\RecurlyResource::fromBinary($this->_response, $this);
} else {
return \Recurly\RecurlyResource::fromResponse($this);
}
if (empty($this->_response)) {
return \Recurly\RecurlyResource::fromEmpty($this);
} elseif (in_array($this->getContentType(), static::BINARY_TYPES)) {
return \Recurly\RecurlyResource::fromBinary($this->_response, $this);
} else {
throw \Recurly\RecurlyError::fromResponse($this);
return \Recurly\RecurlyResource::fromResponse($this);
}
}

Expand Down
81 changes: 81 additions & 0 deletions tests/AdapterCurl_Test.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
<?php

use PHPUnit\Framework\TestCase;
use Recurly\HttpAdapterCurl;
use Recurly\Response;

define("CURLE_COULDNT_RESOLVE_HOST",6);

final class AdapterCurlTest extends TestCase
{
public $logger;

protected function setUp(): void
{
// Tests using actual API requests are no longer "pure", so let's run them only when specifically requested.
if (!getenv("PERFORM_ACTUAL_REQUESTS_WITH_CURL")) {
$this->markTestSkipped("Tests performing actual (read-only!) API requests need to be enabled with env var PERFORM_ACTUAL_REQUESTS_WITH_CURL=1. Please consider VALID_RECURLY_API_KEY=11223344..., too.");
}
parent::setUp();
//$this->logger = new Recurly\Logger('Recurly');
}

function testInvalidHostname(): void
{
$this->client = new Recurly\Client("invalid-api-key", $this->logger, ['http_adapter'=>new HttpAdapterCurl_InvalidHost()]);
$this->expectException(\Recurly\Errors\ConnectionError::class);
$this->expectExceptionCode(CURLE_COULDNT_RESOLVE_HOST);
$this->client->listSites()->getCount();
}

/**
* Test an invalid API key used in a real HEAD request. Expected result: Errors\Unauthorized exception.
*/
function testHeadRequestUnauthorized(): void
{
$this->client = new Recurly\Client("invalid-api-key", $this->logger, ['http_adapter'=>new Recurly\HttpAdapterCurl()]);
$sites = $this->client->listSites();
$this->expectException(\Recurly\Errors\Unauthorized::class);
$sites->getCount();
}

/**
* Test a valid API key used in a HEAD request. Expected result: numeric.
*/
function testHeadRequestValid(): void
{
if (!getenv("VALID_RECURLY_API_KEY")) {
$this->markTestSkipped("Tests performing actual (read-only!) requests on an actual test site need an env var VALID_RECURLY_API_KEY=11223344... .");
return;
}
$this->client = new Recurly\Client(getenv("VALID_RECURLY_API_KEY"), $this->logger, ['http_adapter'=>new Recurly\HttpAdapterCurl()]);
$sites = $this->client->listSites();
$this->assertIsNumeric($sites->getCount());
}

/**
* Test a valid API key used in a GET request. Expected result: JSON object converted to a Recurly\Resource.
*/
function testGetRequestValid(): void
{
if (!getenv("VALID_RECURLY_API_KEY")) {
$this->markTestSkipped("Tests performing actual (read-only!) requests on an actual test site need an env var VALID_RECURLY_API_KEY=11223344... .");
return;
}
$this->client = new Recurly\Client(getenv("VALID_RECURLY_API_KEY"), $this->logger, ['http_adapter'=>new Recurly\HttpAdapterCurl()]);
$sites = $this->client->listSites();
$site = $sites->getFirst();
$this->assertIsObject($site);
$this->assertIsString($site->getObject());
$this->assertEquals("site",$site->getObject());
}
}

/**
* This mock of HttpAdapterCurl will try to connect to an invalid host.
*/
class HttpAdapterCurl_InvalidHost extends Recurly\HttpAdapterCurl {
function execute($method, $url, $body, $headers): array {
return parent::execute($method,"https://wrong.hostname.qwerty",$body,$headers);
}
}
4 changes: 2 additions & 2 deletions tests/Response_Test.php
Original file line number Diff line number Diff line change
Expand Up @@ -175,12 +175,12 @@ public function testToResourceEmpty(): void
$this->assertInstanceOf(\Recurly\EmptyResource::class, $result);
}

public function testToResourceError(): void
public function testAssertValid(): void
{
$this->expectException(\Recurly\RecurlyError::class);
$response = new Response('', $this->request);
$response->setHeaders(['HTTP/1.1 403 Forbidden']);
$result = $response->toResource();
$result = $response->assertValid();
}

public function testGetRawResponse(): void
Expand Down
2 changes: 1 addition & 1 deletion tests/mock_client.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ class MockClient extends BaseClient
public function __construct($logger, $options = [])
{
parent::__construct("apikey", $logger, $options);
$this->http = (new Generator())->getMock(HttpAdapter::class);
$this->http = $options['http_adapter'] ?: (new Generator())->getMock(HttpAdapter::class);
}

protected function apiVersion(): string
Expand Down