Skip to content

Commit

Permalink
init
Browse files Browse the repository at this point in the history
  • Loading branch information
jarektkaczyk committed May 5, 2020
1 parent 659d7ac commit 0fac2a0
Show file tree
Hide file tree
Showing 6 changed files with 353 additions and 0 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/vendor/
composer.lock
32 changes: 32 additions & 0 deletions composer.json
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"
}
130 changes: 130 additions & 0 deletions src/Factory.php
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();
}
}
}
59 changes: 59 additions & 0 deletions src/Formatter.php
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 . ']';
}
}
72 changes: 72 additions & 0 deletions tests/FactoryTest.php
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'));
}
}
58 changes: 58 additions & 0 deletions tests/FormatterTest.php
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]}',
],
];
}
}

0 comments on commit 0fac2a0

Please sign in to comment.