-
-
Notifications
You must be signed in to change notification settings - Fork 3
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
659d7ac
commit 0fac2a0
Showing
6 changed files
with
353 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
/vendor/ | ||
composer.lock |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,32 @@ | ||
{ | ||
"name": "sofa/http-client", | ||
"description": "Factory wrapper around Guzzle HTTP Client to simplify things for lazy dev", | ||
"type": "library", | ||
"require": { | ||
"php": "^7.4", | ||
"ext-json": "*", | ||
"guzzlehttp/guzzle": "^6.5", | ||
"psr/log": "^1.0" | ||
}, | ||
"require-dev": { | ||
"phpunit/phpunit": "^9.1" | ||
}, | ||
"license": "MIT", | ||
"authors": [ | ||
{ | ||
"name": "Jarek Tkaczyk", | ||
"email": "[email protected]" | ||
} | ||
], | ||
"autoload": { | ||
"psr-4": { | ||
"Sofa\\HttpClient\\": "src/" | ||
} | ||
}, | ||
"autoload-dev": { | ||
"psr-4": { | ||
"Sofa\\HttpClient\\Tests\\": "tests/" | ||
} | ||
}, | ||
"minimum-stability": "stable" | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,130 @@ | ||
<?php | ||
|
||
namespace Sofa\HttpClient; | ||
|
||
use GuzzleHttp\Client; | ||
use GuzzleHttp\ClientInterface; | ||
use GuzzleHttp\Handler\MockHandler; | ||
use GuzzleHttp\HandlerStack; | ||
use GuzzleHttp\Middleware; | ||
use GuzzleHttp\Psr7\Response; | ||
use LogicException; | ||
use Psr\Http\Message\RequestInterface; | ||
use Psr\Http\Message\ResponseInterface; | ||
use Psr\Log\LoggerInterface; | ||
|
||
/** | ||
* A bit of syntax sugar on top of the Guzzle with sensible defaults plus | ||
* automatic MockHandler to fake requests, eg. in 'testing' environment. | ||
*/ | ||
class Factory | ||
{ | ||
private ?LoggerInterface $logger; | ||
private bool $fakeRequests; | ||
|
||
private array $options; | ||
private HandlerStack $handler; | ||
public array $history = []; | ||
|
||
public function __construct(bool $fakeRequests, LoggerInterface $logger = null) | ||
{ | ||
$this->fakeRequests = $fakeRequests; | ||
$this->logger = $logger; | ||
$this->reset(); | ||
} | ||
|
||
/** | ||
* @link http://docs.guzzlephp.org/en/stable/request-options.html | ||
* @param array $options | ||
* @return $this | ||
*/ | ||
public function withOptions(array $options): self | ||
{ | ||
$this->options = $options; | ||
|
||
return $this; | ||
} | ||
|
||
public function make(): ClientInterface | ||
{ | ||
$client = new Client(['handler' => $this->handler] + $this->options); | ||
|
||
if ($this->fakeRequests) { | ||
$this->history[$id = spl_object_id($client)] = []; | ||
$this->withMiddleware( | ||
Middleware::history($this->history[$id]), | ||
'fake_history' | ||
); | ||
} | ||
|
||
$this->reset(); | ||
|
||
return $client; | ||
} | ||
|
||
public function getHistory(ClientInterface $client): array | ||
{ | ||
return $this->history[spl_object_id($client)] ?? []; | ||
} | ||
|
||
public function enableLogging(string $format = Formatter::DEFAULT_FORMAT): self | ||
{ | ||
if ($this->logger === null) { | ||
throw new LogicException('In order to use logging a Logger instance must be provided to the Factory'); | ||
} | ||
|
||
return $this->withMiddleware( | ||
Middleware::log($this->logger, new Formatter($format)), | ||
'log' | ||
); | ||
} | ||
|
||
public function enableRetries(int $maxRetries = 3, int $delayInSec = 1, int $minErrorCode = 500): self | ||
{ | ||
$decider = function ($retries, $_, $response) use ($maxRetries, $minErrorCode) { | ||
return $retries < $maxRetries | ||
&& $response instanceof ResponseInterface | ||
&& $response->getStatusCode() >= $minErrorCode; | ||
}; | ||
|
||
if ($this->fakeRequests) { | ||
$delayInSec = 0.0001; // this is so we don't actually wait seconds in tests | ||
} | ||
|
||
$increasingDelay = fn ($attempt) => $attempt * $delayInSec * 1000; | ||
|
||
return $this->withMiddleware( | ||
Middleware::retry($decider, $increasingDelay), | ||
'retry' | ||
); | ||
} | ||
|
||
public function withMiddleware(callable $middleware, string $name = ''): self | ||
{ | ||
$this->handler->push($middleware, $name); | ||
|
||
return $this; | ||
} | ||
|
||
private function reset(): void | ||
{ | ||
$this->options = []; | ||
|
||
if ($this->fakeRequests) { | ||
$mockHandler = new MockHandler; | ||
$responseCallback = function (RequestInterface $request) use ($mockHandler, &$responseCallback) { | ||
$mockHandler->append($responseCallback); | ||
|
||
return new Response(200, ['Content-Type' => 'text/plain'], sprintf( | ||
'Fake test response for request: %s %s', | ||
$request->getMethod(), | ||
$request->getUri(), | ||
)); | ||
}; | ||
$mockHandler->append($responseCallback); | ||
$this->handler = HandlerStack::create($mockHandler); | ||
} else { | ||
$this->handler = HandlerStack::create(); | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,59 @@ | ||
<?php | ||
|
||
namespace Sofa\HttpClient; | ||
|
||
use Exception; | ||
use GuzzleHttp\MessageFormatter; | ||
use Psr\Http\Message\MessageInterface; | ||
use Psr\Http\Message\RequestInterface; | ||
use Psr\Http\Message\ResponseInterface; | ||
|
||
/** | ||
* Extension of the Guzzle formatter providing additional substitution: | ||
* | ||
* - {\req_body}: Request body if it's a string (eg. skips binary) | ||
* - {\res_body}: Response body if it's a string (eg. skips binary) | ||
*/ | ||
class Formatter extends MessageFormatter | ||
{ | ||
const DEFAULT_FORMAT = '{method} {uri} HTTP/{version} {code} ({res_header_Content-Length} {res_header_Content-Type}) {"request": {\req_body}, "response": {\res_body}}'; | ||
const ALLOWED_CONTENT_TYPES = [ | ||
'application/json', | ||
'application/ld+json', | ||
'application/xml', | ||
'multipart/form-data', | ||
'text/plain', | ||
'text/xml', | ||
'text/html', | ||
]; | ||
|
||
public function format(RequestInterface $request, ResponseInterface $response = null, Exception $error = null) | ||
{ | ||
return preg_replace_callback_array([ | ||
preg_quote('/{\req_body}/') => $this->formatter($request), | ||
preg_quote('/{\res_body}/') => $this->formatter($response), | ||
], parent::format($request, $response, $error)); | ||
} | ||
|
||
private function formatter(?MessageInterface $message): callable | ||
{ | ||
if ($message === null || (string)$message->getBody() === '') { | ||
return fn () => ''; | ||
} | ||
|
||
$contentType = $message->getHeader('Content-Type')[0] ?? ''; | ||
|
||
foreach (self::ALLOWED_CONTENT_TYPES as $allowed) { | ||
if (strpos($contentType, $allowed) !== false) { | ||
return function () use ($message) { | ||
$body = (string)$message->getBody(); | ||
$message->getBody()->rewind(); | ||
|
||
return $body; | ||
}; | ||
} | ||
} | ||
|
||
return fn () => '[stripped body: ' . $contentType . ']'; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,72 @@ | ||
<?php | ||
|
||
|
||
namespace Sofa\HttpClient\Tests; | ||
|
||
use PHPUnit\Framework\TestCase; | ||
use Psr\Http\Message\RequestInterface; | ||
use Psr\Http\Message\ResponseInterface; | ||
use Psr\Log\LoggerInterface; | ||
use Psr\Log\LogLevel; | ||
use Sofa\HttpClient\Factory; | ||
|
||
class FactoryTest extends TestCase | ||
{ | ||
public function testClientForFakeRequests() | ||
{ | ||
$logger = $this->createMock(LoggerInterface::class); | ||
$factory = new Factory(true, $logger); | ||
$client = $factory->enableLogging('log request: {method} {uri}')->make(); | ||
|
||
$logger->expects($this->once()) | ||
->method('log') | ||
->with(LogLevel::INFO, 'log request: GET https://some.url'); | ||
|
||
$client->request('get', 'https://some.url'); | ||
} | ||
|
||
public function testFakeClientWithHistoryForTestingPurposes() | ||
{ | ||
$factory = new Factory(true); | ||
$client = $factory->withOptions([ | ||
'base_uri' => 'https://some.url', | ||
])->make(); | ||
|
||
$client->request('get', 'path'); | ||
|
||
$history = $factory->getHistory($client); | ||
$this->assertNotEmpty($history[0]); | ||
/** @var RequestInterface $request */ | ||
$request = $history[0]['request']; | ||
/** @var ResponseInterface $response */ | ||
$response = $history[0]['response']; | ||
|
||
$this->assertEquals('GET', $request->getMethod()); | ||
$this->assertEquals('https://some.url/path', $request->getUri()); | ||
$this->assertEquals(200, $response->getStatusCode()); | ||
$this->assertEquals('Fake test response for request: GET https://some.url/path', $response->getBody()); | ||
} | ||
|
||
public function testRetryingClient() | ||
{ | ||
$factory = new Factory(true); | ||
$client = $factory->enableRetries(2, 0.001, 200)->make(); | ||
|
||
$client->request('get', 'https://some.url'); | ||
|
||
$history = $factory->getHistory($client); | ||
$this->assertEquals(3, count($history)); | ||
} | ||
|
||
public function testMakeStandardClient() | ||
{ | ||
$factory = new Factory(true); | ||
$client = $factory->withOptions([ | ||
'base_uri' => 'https://some.url', | ||
'auth' => ['user', 'secret'], | ||
])->make(); | ||
|
||
$this->assertEquals('https://some.url', $client->getConfig('base_uri')); | ||
$this->assertEquals(['user', 'secret'], $client->getConfig('auth')); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,58 @@ | ||
<?php | ||
|
||
namespace Sofa\HttpClient\Tests; | ||
|
||
use GuzzleHttp\Psr7\Request; | ||
use GuzzleHttp\Psr7\Response; | ||
use PHPUnit\Framework\TestCase; | ||
use Sofa\HttpClient\Formatter; | ||
|
||
class FormatterTest extends TestCase | ||
{ | ||
public function testFormatDoesntChangeStateOfTheStream() | ||
{ | ||
$request = new Request('get', 'uri'); | ||
$response = new Response(200, ['Content-Type' => 'application/json'], json_encode(['one' => 'two'])); | ||
|
||
$formatter = new Formatter('{\res_body}'); | ||
$formatter->format($request, $response); | ||
|
||
$this->assertSame(0, $response->getBody()->tell()); | ||
$this->assertSame('{"one":"two"}', $response->getBody()->getContents()); | ||
} | ||
|
||
/** | ||
* @dataProvider requests | ||
*/ | ||
public function testFormat($request, $response, $log) | ||
{ | ||
$formatter = new Formatter('{method} HTTP/{version} {"request": {\req_body}, "response": {\res_body}}'); | ||
$this->assertSame($log, $formatter->format($request, $response)); | ||
} | ||
|
||
public function requests() | ||
{ | ||
return [ | ||
'json request' => [ | ||
new Request('get', 'uri', ['Content-Type' => 'application/json'], json_encode(['key' => 'value'])), | ||
null, | ||
'GET HTTP/1.1 {"request": {"key":"value"}, "response": }', | ||
], | ||
'json response' => [ | ||
new Request('get', 'uri'), | ||
new Response(200, ['Content-Type' => 'application/json; charset=utf-8'], json_encode(['one' => 'two'])), | ||
'GET HTTP/1.1 {"request": , "response": {"one":"two"}}', | ||
], | ||
'image' => [ | ||
new Request('get', 'uri', ['Content-Type' => 'application/json'], json_encode(['key' => 'value'])), | ||
new Response(200, ['Content-Type' => 'image/png'], 'binary data here'), | ||
'GET HTTP/1.1 {"request": {"key":"value"}, "response": [stripped body: image/png]}', | ||
], | ||
'pdf' => [ | ||
new Request('get', 'uri'), | ||
new Response(200, ['Content-Type' => 'application/pdf'], 'binary data here'), | ||
'GET HTTP/1.1 {"request": , "response": [stripped body: application/pdf]}', | ||
], | ||
]; | ||
} | ||
} |