From 0fac2a0c9466b92fee2db965b2c3437e89b624ff Mon Sep 17 00:00:00 2001 From: Jarek Tkaczyk Date: Tue, 5 May 2020 13:40:47 +0800 Subject: [PATCH] init --- .gitignore | 2 + composer.json | 32 ++++++++++ src/Factory.php | 130 ++++++++++++++++++++++++++++++++++++++++ src/Formatter.php | 59 ++++++++++++++++++ tests/FactoryTest.php | 72 ++++++++++++++++++++++ tests/FormatterTest.php | 58 ++++++++++++++++++ 6 files changed, 353 insertions(+) create mode 100644 .gitignore create mode 100644 composer.json create mode 100644 src/Factory.php create mode 100644 src/Formatter.php create mode 100644 tests/FactoryTest.php create mode 100644 tests/FormatterTest.php diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3a9875b --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/vendor/ +composer.lock diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..36b5524 --- /dev/null +++ b/composer.json @@ -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": "jarek@softonsofa.com" + } + ], + "autoload": { + "psr-4": { + "Sofa\\HttpClient\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Sofa\\HttpClient\\Tests\\": "tests/" + } + }, + "minimum-stability": "stable" +} diff --git a/src/Factory.php b/src/Factory.php new file mode 100644 index 0000000..243d9fe --- /dev/null +++ b/src/Factory.php @@ -0,0 +1,130 @@ +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(); + } + } +} diff --git a/src/Formatter.php b/src/Formatter.php new file mode 100644 index 0000000..c93c3bc --- /dev/null +++ b/src/Formatter.php @@ -0,0 +1,59 @@ + $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 . ']'; + } +} diff --git a/tests/FactoryTest.php b/tests/FactoryTest.php new file mode 100644 index 0000000..4e2396c --- /dev/null +++ b/tests/FactoryTest.php @@ -0,0 +1,72 @@ +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')); + } +} diff --git a/tests/FormatterTest.php b/tests/FormatterTest.php new file mode 100644 index 0000000..05cb725 --- /dev/null +++ b/tests/FormatterTest.php @@ -0,0 +1,58 @@ + '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]}', + ], + ]; + } +}