From 9c1c93b157346c5203058c79fe36eab5a9926799 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rk=20S=C3=A1gi-Kaz=C3=A1r?= Date: Thu, 3 Mar 2016 22:49:49 +0100 Subject: [PATCH] Move plugins from php-http/plugins to common Update todo Remove options resolver dependency Revert: Remove options resolver dependency Add AddHostPlugin Add Authentication plugins Add content length plugin Add final warning to plugins Add cookie plugin Add decoder plugin Add error plugin Add header plugins Add history plugin Fix namespace import order Add redirect plugin Add retry plugin Make plugin classes final, related #18 Fix throw keyword Manually apply php-http/plugins#68 Manually apply and close php-http/plugins#65 --- CHANGELOG.md | 1 + composer.json | 3 +- spec/FlexibleHttpClientSpec.php | 1 + spec/Plugin/AddHostPluginSpec.php | 84 ++++ spec/Plugin/AuthenticationPluginSpec.php | 40 ++ spec/Plugin/ContentLengthPluginSpec.php | 48 +++ spec/Plugin/CookiePluginSpec.php | 183 ++++++++ spec/Plugin/DecoderPluginSpec.php | 132 ++++++ spec/Plugin/ErrorPluginSpec.php | 67 +++ spec/Plugin/HeaderAppendPluginSpec.php | 37 ++ spec/Plugin/HeaderDefaultsPluginSpec.php | 38 ++ spec/Plugin/HeaderRemovePluginSpec.php | 39 ++ spec/Plugin/HeaderSetPluginSpec.php | 37 ++ spec/Plugin/HistoryPluginSpec.php | 57 +++ spec/Plugin/RedirectPluginSpec.php | 406 ++++++++++++++++++ spec/Plugin/RequestMatcherPluginSpec.php | 55 +++ spec/Plugin/RetryPluginSpec.php | 104 +++++ spec/PluginClientSpec.php | 90 ++++ .../CircularRedirectionException.php | 14 + src/Exception/ClientErrorException.php | 14 + src/Exception/LoopException.php | 14 + .../MultipleRedirectionException.php | 14 + src/Exception/ServerErrorException.php | 14 + src/Plugin.php | 30 ++ src/Plugin/AddHostPlugin.php | 74 ++++ src/Plugin/AuthenticationPlugin.php | 38 ++ src/Plugin/ContentLengthPlugin.php | 36 ++ src/Plugin/CookiePlugin.php | 170 ++++++++ src/Plugin/DecoderPlugin.php | 144 +++++++ src/Plugin/ErrorPlugin.php | 55 +++ src/Plugin/HeaderAppendPlugin.php | 44 ++ src/Plugin/HeaderDefaultsPlugin.php | 42 ++ src/Plugin/HeaderRemovePlugin.php | 41 ++ src/Plugin/HeaderSetPlugin.php | 40 ++ src/Plugin/HistoryPlugin.php | 49 +++ src/Plugin/Journal.php | 31 ++ src/Plugin/RedirectPlugin.php | 270 ++++++++++++ src/Plugin/RequestMatcherPlugin.php | 47 ++ src/Plugin/RetryPlugin.php | 84 ++++ src/PluginClient.php | 151 +++++++ 40 files changed, 2837 insertions(+), 1 deletion(-) create mode 100644 spec/Plugin/AddHostPluginSpec.php create mode 100644 spec/Plugin/AuthenticationPluginSpec.php create mode 100644 spec/Plugin/ContentLengthPluginSpec.php create mode 100644 spec/Plugin/CookiePluginSpec.php create mode 100644 spec/Plugin/DecoderPluginSpec.php create mode 100644 spec/Plugin/ErrorPluginSpec.php create mode 100644 spec/Plugin/HeaderAppendPluginSpec.php create mode 100644 spec/Plugin/HeaderDefaultsPluginSpec.php create mode 100644 spec/Plugin/HeaderRemovePluginSpec.php create mode 100644 spec/Plugin/HeaderSetPluginSpec.php create mode 100644 spec/Plugin/HistoryPluginSpec.php create mode 100644 spec/Plugin/RedirectPluginSpec.php create mode 100644 spec/Plugin/RequestMatcherPluginSpec.php create mode 100644 spec/Plugin/RetryPluginSpec.php create mode 100644 spec/PluginClientSpec.php create mode 100644 src/Exception/CircularRedirectionException.php create mode 100644 src/Exception/ClientErrorException.php create mode 100644 src/Exception/LoopException.php create mode 100644 src/Exception/MultipleRedirectionException.php create mode 100644 src/Exception/ServerErrorException.php create mode 100644 src/Plugin.php create mode 100644 src/Plugin/AddHostPlugin.php create mode 100644 src/Plugin/AuthenticationPlugin.php create mode 100644 src/Plugin/ContentLengthPlugin.php create mode 100644 src/Plugin/CookiePlugin.php create mode 100644 src/Plugin/DecoderPlugin.php create mode 100644 src/Plugin/ErrorPlugin.php create mode 100644 src/Plugin/HeaderAppendPlugin.php create mode 100644 src/Plugin/HeaderDefaultsPlugin.php create mode 100644 src/Plugin/HeaderRemovePlugin.php create mode 100644 src/Plugin/HeaderSetPlugin.php create mode 100644 src/Plugin/HistoryPlugin.php create mode 100644 src/Plugin/Journal.php create mode 100644 src/Plugin/RedirectPlugin.php create mode 100644 src/Plugin/RequestMatcherPlugin.php create mode 100644 src/Plugin/RetryPlugin.php create mode 100644 src/PluginClient.php diff --git a/CHANGELOG.md b/CHANGELOG.md index ea10080..ebe1f99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Add a flexible http client providing both contract, and only emulating what's necessary - HTTP Client Router: route requests to underlying clients +- Plugin client and core plugins moved here from `php-http/plugins` ### Deprecated diff --git a/composer.json b/composer.json index 6fc8d18..e5c2382 100644 --- a/composer.json +++ b/composer.json @@ -14,7 +14,8 @@ "php": ">=5.4", "php-http/httplug": "^1.0", "php-http/message-factory": "^1.0", - "php-http/message": "^1.2" + "php-http/message": "^1.2", + "symfony/options-resolver": "^2.6|^3.0" }, "require-dev": { "phpspec/phpspec": "^2.4", diff --git a/spec/FlexibleHttpClientSpec.php b/spec/FlexibleHttpClientSpec.php index 0df9e1e..70e6e4d 100644 --- a/spec/FlexibleHttpClientSpec.php +++ b/spec/FlexibleHttpClientSpec.php @@ -79,6 +79,7 @@ function it_does_not_emulate_a_client($client, RequestInterface $syncRequest, Re { $client->implement('Http\Client\HttpClient'); $client->implement('Http\Client\HttpAsyncClient'); + $client->sendRequest($syncRequest)->shouldBeCalled(); $client->sendRequest($asyncRequest)->shouldNotBeCalled(); $client->sendAsyncRequest($asyncRequest)->shouldBeCalled(); diff --git a/spec/Plugin/AddHostPluginSpec.php b/spec/Plugin/AddHostPluginSpec.php new file mode 100644 index 0000000..9df6248 --- /dev/null +++ b/spec/Plugin/AddHostPluginSpec.php @@ -0,0 +1,84 @@ +beConstructedWith($uri); + } + + function it_is_initializable(UriInterface $uri) + { + $uri->getHost()->shouldBeCalled()->willReturn('example.com'); + + $this->shouldHaveType('Http\Client\Common\Plugin\AddHostPlugin'); + } + + function it_is_a_plugin(UriInterface $uri) + { + $uri->getHost()->shouldBeCalled()->willReturn('example.com'); + + $this->shouldImplement('Http\Client\Common\Plugin'); + } + + function it_adds_domain( + RequestInterface $request, + UriInterface $host, + UriInterface $uri + ) { + $host->getScheme()->shouldBeCalled()->willReturn('http://'); + $host->getHost()->shouldBeCalled()->willReturn('example.com'); + + $request->getUri()->shouldBeCalled()->willReturn($uri); + $request->withUri($uri)->shouldBeCalled()->willReturn($request); + + $uri->withScheme('http://')->shouldBeCalled()->willReturn($uri); + $uri->withHost('example.com')->shouldBeCalled()->willReturn($uri); + $uri->getHost()->shouldBeCalled()->willReturn(''); + + $this->beConstructedWith($host); + $this->handleRequest($request, function () {}, function () {}); + } + + function it_replaces_domain( + RequestInterface $request, + UriInterface $host, + UriInterface $uri + ) { + $host->getScheme()->shouldBeCalled()->willReturn('http://'); + $host->getHost()->shouldBeCalled()->willReturn('example.com'); + + $request->getUri()->shouldBeCalled()->willReturn($uri); + $request->withUri($uri)->shouldBeCalled()->willReturn($request); + + $uri->withScheme('http://')->shouldBeCalled()->willReturn($uri); + $uri->withHost('example.com')->shouldBeCalled()->willReturn($uri); + + + $this->beConstructedWith($host, ['replace' => true]); + $this->handleRequest($request, function () {}, function () {}); + } + + function it_does_nothing_when_domain_exists( + RequestInterface $request, + UriInterface $host, + UriInterface $uri + ) { + $request->getUri()->shouldBeCalled()->willReturn($uri); + $uri->getHost()->shouldBeCalled()->willReturn('default.com'); + + $this->beConstructedWith($host); + $this->handleRequest($request, function () {}, function () {}); + } +} diff --git a/spec/Plugin/AuthenticationPluginSpec.php b/spec/Plugin/AuthenticationPluginSpec.php new file mode 100644 index 0000000..02d1187 --- /dev/null +++ b/spec/Plugin/AuthenticationPluginSpec.php @@ -0,0 +1,40 @@ +beConstructedWith($authentication); + } + + function it_is_initializable(Authentication $authentication) + { + $this->shouldHaveType('Http\Client\Common\Plugin\AuthenticationPlugin'); + } + + function it_is_a_plugin() + { + $this->shouldImplement('Http\Client\Common\Plugin'); + } + + function it_sends_an_authenticated_request(Authentication $authentication, RequestInterface $notAuthedRequest, RequestInterface $authedRequest, Promise $promise) + { + $authentication->authenticate($notAuthedRequest)->willReturn($authedRequest); + + $next = function (RequestInterface $request) use($authedRequest, $promise) { + if (Argument::is($authedRequest->getWrappedObject())->scoreArgument($request)) { + return $promise->getWrappedObject(); + } + }; + + $this->handleRequest($notAuthedRequest, $next, function () {})->shouldReturn($promise); + } +} diff --git a/spec/Plugin/ContentLengthPluginSpec.php b/spec/Plugin/ContentLengthPluginSpec.php new file mode 100644 index 0000000..75e913e --- /dev/null +++ b/spec/Plugin/ContentLengthPluginSpec.php @@ -0,0 +1,48 @@ +shouldHaveType('Http\Client\Common\Plugin\ContentLengthPlugin'); + } + + function it_is_a_plugin() + { + $this->shouldImplement('Http\Client\Common\Plugin'); + } + + function it_adds_content_length_header(RequestInterface $request, StreamInterface $stream) + { + $request->hasHeader('Content-Length')->shouldBeCalled()->willReturn(false); + $request->getBody()->shouldBeCalled()->willReturn($stream); + $stream->getSize()->shouldBeCalled()->willReturn(100); + $request->withHeader('Content-Length', 100)->shouldBeCalled()->willReturn($request); + + $this->handleRequest($request, function () {}, function () {}); + } + + function it_streams_chunked_if_no_size(RequestInterface $request, StreamInterface $stream) + { + if(defined('HHVM_VERSION')) { + throw new SkippingException('Skipping test on hhvm, as there is no chunk encoding on hhvm'); + } + + $request->hasHeader('Content-Length')->shouldBeCalled()->willReturn(false); + $request->getBody()->shouldBeCalled()->willReturn($stream); + + $stream->getSize()->shouldBeCalled()->willReturn(null); + $request->withBody(Argument::type('Http\Message\Encoding\ChunkStream'))->shouldBeCalled()->willReturn($request); + $request->withAddedHeader('Transfer-Encoding', 'chunked')->shouldBeCalled()->willReturn($request); + + $this->handleRequest($request, function () {}, function () {}); + } +} diff --git a/spec/Plugin/CookiePluginSpec.php b/spec/Plugin/CookiePluginSpec.php new file mode 100644 index 0000000..0ae5500 --- /dev/null +++ b/spec/Plugin/CookiePluginSpec.php @@ -0,0 +1,183 @@ +cookieJar = new CookieJar(); + + $this->beConstructedWith($this->cookieJar); + } + + function it_is_initializable() + { + $this->shouldHaveType('Http\Client\Common\Plugin\CookiePlugin'); + } + + function it_is_a_plugin() + { + $this->shouldImplement('Http\Client\Common\Plugin'); + } + + function it_loads_cookie(RequestInterface $request, UriInterface $uri, Promise $promise) + { + $cookie = new Cookie('name', 'value', 86400, 'test.com'); + $this->cookieJar->addCookie($cookie); + + $request->getUri()->willReturn($uri); + $uri->getHost()->willReturn('test.com'); + $uri->getPath()->willReturn('/'); + + $request->withAddedHeader('Cookie', 'name=value')->willReturn($request); + + $this->handleRequest($request, function (RequestInterface $requestReceived) use ($request, $promise) { + if (Argument::is($requestReceived)->scoreArgument($request->getWrappedObject())) { + return $promise->getWrappedObject(); + } + }, function () {}); + } + + function it_does_not_load_cookie_if_expired(RequestInterface $request, UriInterface $uri, Promise $promise) + { + $cookie = new Cookie('name', 'value', null, 'test.com', false, false, null, (new \DateTime())->modify('-1 day')); + $this->cookieJar->addCookie($cookie); + + $request->withAddedHeader('Cookie', 'name=value')->shouldNotBeCalled(); + + $this->handleRequest($request, function (RequestInterface $requestReceived) use ($request, $promise) { + if (Argument::is($requestReceived)->scoreArgument($request->getWrappedObject())) { + return $promise->getWrappedObject(); + } + }, function () {}); + } + + function it_does_not_load_cookie_if_domain_does_not_match(RequestInterface $request, UriInterface $uri, Promise $promise) + { + $cookie = new Cookie('name', 'value', 86400, 'test2.com'); + $this->cookieJar->addCookie($cookie); + + $request->getUri()->willReturn($uri); + $uri->getHost()->willReturn('test.com'); + + $request->withAddedHeader('Cookie', 'name=value')->shouldNotBeCalled(); + + $this->handleRequest($request, function (RequestInterface $requestReceived) use ($request, $promise) { + if (Argument::is($requestReceived)->scoreArgument($request->getWrappedObject())) { + return $promise->getWrappedObject(); + } + }, function () {}); + } + + function it_does_not_load_cookie_if_path_does_not_match(RequestInterface $request, UriInterface $uri, Promise $promise) + { + $cookie = new Cookie('name', 'value', 86400, 'test.com', '/sub'); + $this->cookieJar->addCookie($cookie); + + $request->getUri()->willReturn($uri); + $uri->getHost()->willReturn('test.com'); + $uri->getPath()->willReturn('/'); + + $request->withAddedHeader('Cookie', 'name=value')->shouldNotBeCalled(); + + $this->handleRequest($request, function (RequestInterface $requestReceived) use ($request, $promise) { + if (Argument::is($requestReceived)->scoreArgument($request->getWrappedObject())) { + return $promise->getWrappedObject(); + } + }, function () {}); + } + + function it_does_not_load_cookie_when_cookie_is_secure(RequestInterface $request, UriInterface $uri, Promise $promise) + { + $cookie = new Cookie('name', 'value', 86400, 'test.com', null, true); + $this->cookieJar->addCookie($cookie); + + $request->getUri()->willReturn($uri); + $uri->getHost()->willReturn('test.com'); + $uri->getPath()->willReturn('/'); + $uri->getScheme()->willReturn('http'); + + $request->withAddedHeader('Cookie', 'name=value')->shouldNotBeCalled(); + + $this->handleRequest($request, function (RequestInterface $requestReceived) use ($request, $promise) { + if (Argument::is($requestReceived)->scoreArgument($request->getWrappedObject())) { + return $promise->getWrappedObject(); + } + }, function () {}); + } + + function it_loads_cookie_when_cookie_is_secure(RequestInterface $request, UriInterface $uri, Promise $promise) + { + $cookie = new Cookie('name', 'value', 86400, 'test.com', null, true); + $this->cookieJar->addCookie($cookie); + + $request->getUri()->willReturn($uri); + $uri->getHost()->willReturn('test.com'); + $uri->getPath()->willReturn('/'); + $uri->getScheme()->willReturn('https'); + + $request->withAddedHeader('Cookie', 'name=value')->willReturn($request); + + $this->handleRequest($request, function (RequestInterface $requestReceived) use ($request, $promise) { + if (Argument::is($requestReceived)->scoreArgument($request->getWrappedObject())) { + return $promise->getWrappedObject(); + } + }, function () {}); + } + + function it_saves_cookie(RequestInterface $request, ResponseInterface $response, UriInterface $uri) + { + $next = function () use ($response) { + return new FulfilledPromise($response->getWrappedObject()); + }; + + $response->hasHeader('Set-Cookie')->willReturn(true); + $response->getHeader('Set-Cookie')->willReturn([ + 'cookie=value; expires=Tuesday, 31-Mar-99 07:42:12 GMT; Max-Age=60; path=/; domain=test.com; secure; HttpOnly' + ]); + + $request->getUri()->willReturn($uri); + $uri->getHost()->willReturn('test.com'); + $uri->getPath()->willReturn('/'); + + $promise = $this->handleRequest($request, $next, function () {}); + $promise->shouldHaveType('Http\Promise\Promise'); + $promise->wait()->shouldReturnAnInstanceOf('Psr\Http\Message\ResponseInterface'); + } + + function it_throws_exception_on_invalid_expires_date( + RequestInterface $request, + ResponseInterface $response, + UriInterface $uri + ) { + $next = function () use ($response) { + return new FulfilledPromise($response->getWrappedObject()); + }; + + $response->hasHeader('Set-Cookie')->willReturn(true); + $response->getHeader('Set-Cookie')->willReturn([ + 'cookie=value; expires=i-am-an-invalid-date;' + ]); + + $request->getUri()->willReturn($uri); + $uri->getHost()->willReturn('test.com'); + $uri->getPath()->willReturn('/'); + + $promise = $this->handleRequest($request, $next, function () {}); + $promise->shouldReturnAnInstanceOf('Http\Promise\RejectedPromise'); + $promise->shouldThrow('Http\Client\Exception\TransferException')->duringWait(); + } +} diff --git a/spec/Plugin/DecoderPluginSpec.php b/spec/Plugin/DecoderPluginSpec.php new file mode 100644 index 0000000..bde3cf9 --- /dev/null +++ b/spec/Plugin/DecoderPluginSpec.php @@ -0,0 +1,132 @@ +shouldHaveType('Http\Client\Common\Plugin\DecoderPlugin'); + } + + function it_is_a_plugin() + { + $this->shouldImplement('Http\Client\Common\Plugin'); + } + + function it_decodes(RequestInterface $request, ResponseInterface $response, StreamInterface $stream) + { + if(defined('HHVM_VERSION')) { + throw new SkippingException('Skipping test on hhvm, as there is no chunk encoding on hhvm'); + } + + $request->withHeader('TE', ['gzip', 'deflate', 'compress', 'chunked'])->shouldBeCalled()->willReturn($request); + $request->withHeader('Accept-Encoding', ['gzip', 'deflate', 'compress'])->shouldBeCalled()->willReturn($request); + $next = function () use($response) { + return new FulfilledPromise($response->getWrappedObject()); + }; + + $response->hasHeader('Transfer-Encoding')->willReturn(true); + $response->getHeader('Transfer-Encoding')->willReturn(['chunked']); + $response->getBody()->willReturn($stream); + $response->withBody(Argument::type('Http\Message\Encoding\DechunkStream'))->willReturn($response); + $response->withHeader('Transfer-Encoding', [])->willReturn($response); + $response->hasHeader('Content-Encoding')->willReturn(false); + + $stream->isReadable()->willReturn(true); + $stream->isWritable()->willReturn(false); + $stream->eof()->willReturn(false); + + $this->handleRequest($request, $next, function () {}); + } + + function it_decodes_gzip(RequestInterface $request, ResponseInterface $response, StreamInterface $stream) + { + $request->withHeader('TE', ['gzip', 'deflate', 'compress', 'chunked'])->shouldBeCalled()->willReturn($request); + $request->withHeader('Accept-Encoding', ['gzip', 'deflate', 'compress'])->shouldBeCalled()->willReturn($request); + $next = function () use($response) { + return new FulfilledPromise($response->getWrappedObject()); + }; + + $response->hasHeader('Transfer-Encoding')->willReturn(false); + $response->hasHeader('Content-Encoding')->willReturn(true); + $response->getHeader('Content-Encoding')->willReturn(['gzip']); + $response->getBody()->willReturn($stream); + $response->withBody(Argument::type('Http\Message\Encoding\GzipDecodeStream'))->willReturn($response); + $response->withHeader('Content-Encoding', [])->willReturn($response); + + $stream->isReadable()->willReturn(true); + $stream->isWritable()->willReturn(false); + $stream->eof()->willReturn(false); + + $this->handleRequest($request, $next, function () {}); + } + + function it_decodes_deflate(RequestInterface $request, ResponseInterface $response, StreamInterface $stream) + { + $request->withHeader('TE', ['gzip', 'deflate', 'compress', 'chunked'])->shouldBeCalled()->willReturn($request); + $request->withHeader('Accept-Encoding', ['gzip', 'deflate', 'compress'])->shouldBeCalled()->willReturn($request); + $next = function () use($response) { + return new FulfilledPromise($response->getWrappedObject()); + }; + + $response->hasHeader('Transfer-Encoding')->willReturn(false); + $response->hasHeader('Content-Encoding')->willReturn(true); + $response->getHeader('Content-Encoding')->willReturn(['deflate']); + $response->getBody()->willReturn($stream); + $response->withBody(Argument::type('Http\Message\Encoding\InflateStream'))->willReturn($response); + $response->withHeader('Content-Encoding', [])->willReturn($response); + + $stream->isReadable()->willReturn(true); + $stream->isWritable()->willReturn(false); + $stream->eof()->willReturn(false); + + $this->handleRequest($request, $next, function () {}); + } + + function it_decodes_inflate(RequestInterface $request, ResponseInterface $response, StreamInterface $stream) + { + $request->withHeader('TE', ['gzip', 'deflate', 'compress', 'chunked'])->shouldBeCalled()->willReturn($request); + $request->withHeader('Accept-Encoding', ['gzip', 'deflate', 'compress'])->shouldBeCalled()->willReturn($request); + $next = function () use($response) { + return new FulfilledPromise($response->getWrappedObject()); + }; + + $response->hasHeader('Transfer-Encoding')->willReturn(false); + $response->hasHeader('Content-Encoding')->willReturn(true); + $response->getHeader('Content-Encoding')->willReturn(['compress']); + $response->getBody()->willReturn($stream); + $response->withBody(Argument::type('Http\Message\Encoding\DecompressStream'))->willReturn($response); + $response->withHeader('Content-Encoding', [])->willReturn($response); + + $stream->isReadable()->willReturn(true); + $stream->isWritable()->willReturn(false); + $stream->eof()->willReturn(false); + + $this->handleRequest($request, $next, function () {}); + } + + function it_does_not_decode_with_content_encoding(RequestInterface $request, ResponseInterface $response) + { + $this->beConstructedWith(['use_content_encoding' => false]); + + $request->withHeader('TE', ['gzip', 'deflate', 'compress', 'chunked'])->shouldBeCalled()->willReturn($request); + $request->withHeader('Accept-Encoding', ['gzip', 'deflate', 'compress'])->shouldNotBeCalled(); + $next = function () use($response) { + return new FulfilledPromise($response->getWrappedObject()); + }; + + $response->hasHeader('Transfer-Encoding')->willReturn(false); + $response->hasHeader('Content-Encoding')->shouldNotBeCalled(); + + $this->handleRequest($request, $next, function () {}); + } +} diff --git a/spec/Plugin/ErrorPluginSpec.php b/spec/Plugin/ErrorPluginSpec.php new file mode 100644 index 0000000..e9130d3 --- /dev/null +++ b/spec/Plugin/ErrorPluginSpec.php @@ -0,0 +1,67 @@ +beAnInstanceOf('Http\Client\Common\Plugin\ErrorPlugin'); + } + + function it_is_a_plugin() + { + $this->shouldImplement('Http\Client\Common\Plugin'); + } + + function it_throw_client_error_exception_on_4xx_error(RequestInterface $request, ResponseInterface $response) + { + $response->getStatusCode()->willReturn('400'); + $response->getReasonPhrase()->willReturn('Bad request'); + + $next = function (RequestInterface $receivedRequest) use($request, $response) { + if (Argument::is($request->getWrappedObject())->scoreArgument($receivedRequest)) { + return new FulfilledPromise($response->getWrappedObject()); + } + }; + + $promise = $this->handleRequest($request, $next, function () {}); + $promise->shouldReturnAnInstanceOf('Http\Promise\RejectedPromise'); + $promise->shouldThrow('Http\Client\Common\Exception\ClientErrorException')->duringWait(); + } + + function it_throw_server_error_exception_on_5xx_error(RequestInterface $request, ResponseInterface $response) + { + $response->getStatusCode()->willReturn('500'); + $response->getReasonPhrase()->willReturn('Server error'); + + $next = function (RequestInterface $receivedRequest) use($request, $response) { + if (Argument::is($request->getWrappedObject())->scoreArgument($receivedRequest)) { + return new FulfilledPromise($response->getWrappedObject()); + } + }; + + $promise = $this->handleRequest($request, $next, function () {}); + $promise->shouldReturnAnInstanceOf('Http\Promise\RejectedPromise'); + $promise->shouldThrow('Http\Client\Common\Exception\ServerErrorException')->duringWait(); + } + + function it_returns_response(RequestInterface $request, ResponseInterface $response) + { + $response->getStatusCode()->willReturn('200'); + + $next = function (RequestInterface $receivedRequest) use($request, $response) { + if (Argument::is($request->getWrappedObject())->scoreArgument($receivedRequest)) { + return new FulfilledPromise($response->getWrappedObject()); + } + }; + + $this->handleRequest($request, $next, function () {})->shouldReturnAnInstanceOf('Http\Promise\FulfilledPromise'); + } +} diff --git a/spec/Plugin/HeaderAppendPluginSpec.php b/spec/Plugin/HeaderAppendPluginSpec.php new file mode 100644 index 0000000..24b8565 --- /dev/null +++ b/spec/Plugin/HeaderAppendPluginSpec.php @@ -0,0 +1,37 @@ +beConstructedWith([]); + $this->shouldHaveType('Http\Client\Common\Plugin\HeaderAppendPlugin'); + } + + public function it_is_a_plugin() + { + $this->beConstructedWith([]); + $this->shouldImplement('Http\Client\Common\Plugin'); + } + + public function it_appends_the_header(RequestInterface $request) + { + $this->beConstructedWith([ + 'foo'=>'bar', + 'baz'=>'qux' + ]); + + $request->withAddedHeader('foo', 'bar')->shouldBeCalled()->willReturn($request); + $request->withAddedHeader('baz', 'qux')->shouldBeCalled()->willReturn($request); + + $this->handleRequest($request, function () {}, function () {}); + } +} diff --git a/spec/Plugin/HeaderDefaultsPluginSpec.php b/spec/Plugin/HeaderDefaultsPluginSpec.php new file mode 100644 index 0000000..341f1a5 --- /dev/null +++ b/spec/Plugin/HeaderDefaultsPluginSpec.php @@ -0,0 +1,38 @@ +beConstructedWith([]); + $this->shouldHaveType('Http\Client\Common\Plugin\HeaderDefaultsPlugin'); + } + + public function it_is_a_plugin() + { + $this->beConstructedWith([]); + $this->shouldImplement('Http\Client\Common\Plugin'); + } + + public function it_sets_the_default_header(RequestInterface $request) + { + $this->beConstructedWith([ + 'foo' => 'bar', + 'baz' => 'qux' + ]); + + $request->hasHeader('foo')->shouldBeCalled()->willReturn(false); + $request->withHeader('foo', 'bar')->shouldBeCalled()->willReturn($request); + $request->hasHeader('baz')->shouldBeCalled()->willReturn(true); + + $this->handleRequest($request, function () {}, function () {}); + } +} diff --git a/spec/Plugin/HeaderRemovePluginSpec.php b/spec/Plugin/HeaderRemovePluginSpec.php new file mode 100644 index 0000000..9ea2752 --- /dev/null +++ b/spec/Plugin/HeaderRemovePluginSpec.php @@ -0,0 +1,39 @@ +beConstructedWith([]); + $this->shouldHaveType('Http\Client\Common\Plugin\HeaderRemovePlugin'); + } + + public function it_is_a_plugin() + { + $this->beConstructedWith([]); + $this->shouldImplement('Http\Client\Common\Plugin'); + } + + public function it_removes_the_header(RequestInterface $request) + { + $this->beConstructedWith([ + 'foo', + 'baz' + ]); + + $request->hasHeader('foo')->shouldBeCalled()->willReturn(false); + + $request->hasHeader('baz')->shouldBeCalled()->willReturn(true); + $request->withoutHeader('baz')->shouldBeCalled()->willReturn($request); + + $this->handleRequest($request, function () {}, function () {}); + } +} diff --git a/spec/Plugin/HeaderSetPluginSpec.php b/spec/Plugin/HeaderSetPluginSpec.php new file mode 100644 index 0000000..f4a340c --- /dev/null +++ b/spec/Plugin/HeaderSetPluginSpec.php @@ -0,0 +1,37 @@ +beConstructedWith([]); + $this->shouldHaveType('Http\Client\Common\Plugin\HeaderSetPlugin'); + } + + public function it_is_a_plugin() + { + $this->beConstructedWith([]); + $this->shouldImplement('Http\Client\Common\Plugin'); + } + + public function it_set_the_header(RequestInterface $request) + { + $this->beConstructedWith([ + 'foo'=>'bar', + 'baz'=>'qux' + ]); + + $request->withHeader('foo', 'bar')->shouldBeCalled()->willReturn($request); + $request->withHeader('baz', 'qux')->shouldBeCalled()->willReturn($request); + + $this->handleRequest($request, function () {}, function () {}); + } +} diff --git a/spec/Plugin/HistoryPluginSpec.php b/spec/Plugin/HistoryPluginSpec.php new file mode 100644 index 0000000..b15742b --- /dev/null +++ b/spec/Plugin/HistoryPluginSpec.php @@ -0,0 +1,57 @@ +beConstructedWith($journal); + } + + function it_is_initializable() + { + $this->beAnInstanceOf('Http\Client\Common\Plugin\JournalPlugin'); + } + + function it_is_a_plugin() + { + $this->shouldImplement('Http\Client\Common\Plugin'); + } + + function it_records_success(Journal $journal, RequestInterface $request, ResponseInterface $response) + { + $next = function (RequestInterface $receivedRequest) use($request, $response) { + if (Argument::is($request->getWrappedObject())->scoreArgument($receivedRequest)) { + return new FulfilledPromise($response->getWrappedObject()); + } + }; + + $journal->addSuccess($request, $response)->shouldBeCalled(); + + $this->handleRequest($request, $next, function () {}); + } + + function it_records_failure(Journal $journal, RequestInterface $request) + { + $exception = new TransferException(); + $next = function (RequestInterface $receivedRequest) use($request, $exception) { + if (Argument::is($request->getWrappedObject())->scoreArgument($receivedRequest)) { + return new RejectedPromise($exception); + } + }; + + $journal->addFailure($request, $exception)->shouldBeCalled(); + + $this->handleRequest($request, $next, function () {}); + } +} diff --git a/spec/Plugin/RedirectPluginSpec.php b/spec/Plugin/RedirectPluginSpec.php new file mode 100644 index 0000000..4310da2 --- /dev/null +++ b/spec/Plugin/RedirectPluginSpec.php @@ -0,0 +1,406 @@ +shouldHaveType('Http\Client\Common\Plugin\RedirectPlugin'); + } + + function it_is_a_plugin() + { + $this->shouldImplement('Http\Client\Common\Plugin'); + } + + function it_redirects_on_302( + UriInterface $uri, + UriInterface $uriRedirect, + RequestInterface $request, + ResponseInterface $responseRedirect, + RequestInterface $modifiedRequest, + ResponseInterface $finalResponse, + Promise $promise + ) { + $responseRedirect->getStatusCode()->willReturn('302'); + $responseRedirect->hasHeader('Location')->willReturn(true); + $responseRedirect->getHeaderLine('Location')->willReturn('/redirect'); + + $request->getRequestTarget()->willReturn('/original'); + $request->getUri()->willReturn($uri); + $request->withUri($uriRedirect)->willReturn($modifiedRequest); + + $uri->withPath('/redirect')->willReturn($uriRedirect); + $uriRedirect->withFragment('')->willReturn($uriRedirect); + $uriRedirect->withQuery('')->willReturn($uriRedirect); + + $modifiedRequest->getRequestTarget()->willReturn('/redirect'); + $modifiedRequest->getMethod()->willReturn('GET'); + + $next = function (RequestInterface $receivedRequest) use($request, $responseRedirect) { + if (Argument::is($request->getWrappedObject())->scoreArgument($receivedRequest)) { + return new FulfilledPromise($responseRedirect->getWrappedObject()); + } + }; + + $first = function (RequestInterface $receivedRequest) use($modifiedRequest, $promise) { + if (Argument::is($modifiedRequest->getWrappedObject())->scoreArgument($receivedRequest)) { + return $promise->getWrappedObject(); + } + }; + + $promise->getState()->willReturn(Promise::FULFILLED); + $promise->wait()->shouldBeCalled()->willReturn($finalResponse); + + $finalPromise = $this->handleRequest($request, $next, $first); + $finalPromise->shouldReturnAnInstanceOf('Http\Promise\FulfilledPromise'); + $finalPromise->wait()->shouldReturn($finalResponse); + } + + function it_use_storage_on_301(UriInterface $uriRedirect, RequestInterface $request, RequestInterface $modifiedRequest) + { + $this->beAnInstanceOf('spec\Http\Client\Common\Plugin\RedirectPluginStub'); + $this->beConstructedWith($uriRedirect, '/original', '301'); + + $next = function () { + throw new \Exception('Must not be called'); + }; + + $request->getRequestTarget()->willReturn('/original'); + $request->withUri($uriRedirect)->willReturn($modifiedRequest); + + $modifiedRequest->getRequestTarget()->willReturn('/redirect'); + $modifiedRequest->getMethod()->willReturn('GET'); + + $this->handleRequest($request, $next, function () {}); + } + + function it_stores_a_301( + UriInterface $uri, + UriInterface $uriRedirect, + RequestInterface $request, + ResponseInterface $responseRedirect, + RequestInterface $modifiedRequest, + ResponseInterface $finalResponse, + Promise $promise + ) { + + $this->beAnInstanceOf('spec\Http\Client\Common\Plugin\RedirectPluginStub'); + $this->beConstructedWith($uriRedirect, '', '301'); + + $request->getRequestTarget()->willReturn('/301-url'); + $request->getUri()->willReturn($uri); + + $responseRedirect->getStatusCode()->willReturn('301'); + $responseRedirect->hasHeader('Location')->willReturn(true); + $responseRedirect->getHeaderLine('Location')->willReturn('/redirect'); + + $uri->withPath('/redirect')->willReturn($uriRedirect); + $uriRedirect->withFragment('')->willReturn($uriRedirect); + $uriRedirect->withQuery('')->willReturn($uriRedirect); + + $request->withUri($uriRedirect)->willReturn($modifiedRequest); + + $modifiedRequest->getRequestTarget()->willReturn('/redirect'); + $modifiedRequest->getMethod()->willReturn('GET'); + + $next = function (RequestInterface $receivedRequest) use($request, $responseRedirect) { + if (Argument::is($request->getWrappedObject())->scoreArgument($receivedRequest)) { + return new FulfilledPromise($responseRedirect->getWrappedObject()); + } + }; + + $first = function (RequestInterface $receivedRequest) use($modifiedRequest, $promise) { + if (Argument::is($modifiedRequest->getWrappedObject())->scoreArgument($receivedRequest)) { + return $promise->getWrappedObject(); + } + }; + + $promise->getState()->willReturn(Promise::FULFILLED); + $promise->wait()->shouldBeCalled()->willReturn($finalResponse); + + $this->handleRequest($request, $next, $first); + $this->hasStorage('/301-url')->shouldReturn(true); + } + + function it_replace_full_url( + UriInterface $uri, + UriInterface $uriRedirect, + RequestInterface $request, + ResponseInterface $responseRedirect, + RequestInterface $modifiedRequest, + ResponseInterface $finalResponse, + Promise $promise + ) { + $request->getRequestTarget()->willReturn('/original'); + + $responseRedirect->getStatusCode()->willReturn('302'); + $responseRedirect->hasHeader('Location')->willReturn(true); + $responseRedirect->getHeaderLine('Location')->willReturn('https://server.com:8000/redirect?query#fragment'); + + $request->getUri()->willReturn($uri); + $uri->withScheme('https')->willReturn($uriRedirect); + $uriRedirect->withHost('server.com')->willReturn($uriRedirect); + $uriRedirect->withPort('8000')->willReturn($uriRedirect); + $uriRedirect->withPath('/redirect')->willReturn($uriRedirect); + $uriRedirect->withQuery('query')->willReturn($uriRedirect); + $uriRedirect->withFragment('fragment')->willReturn($uriRedirect); + + $request->withUri($uriRedirect)->willReturn($modifiedRequest); + + $modifiedRequest->getRequestTarget()->willReturn('/redirect'); + $modifiedRequest->getMethod()->willReturn('GET'); + + $next = function (RequestInterface $receivedRequest) use($request, $responseRedirect) { + if (Argument::is($request->getWrappedObject())->scoreArgument($receivedRequest)) { + return new FulfilledPromise($responseRedirect->getWrappedObject()); + } + }; + + $first = function (RequestInterface $receivedRequest) use($modifiedRequest, $promise) { + if (Argument::is($modifiedRequest->getWrappedObject())->scoreArgument($receivedRequest)) { + return $promise->getWrappedObject(); + } + }; + + $promise->getState()->willReturn(Promise::FULFILLED); + $promise->wait()->shouldBeCalled()->willReturn($finalResponse); + + $this->handleRequest($request, $next, $first); + } + + function it_throws_http_exception_on_no_location(RequestInterface $request, ResponseInterface $responseRedirect) + { + $next = function (RequestInterface $receivedRequest) use($request, $responseRedirect) { + if (Argument::is($request->getWrappedObject())->scoreArgument($receivedRequest)) { + return new FulfilledPromise($responseRedirect->getWrappedObject()); + } + }; + + $request->getRequestTarget()->willReturn('/original'); + $responseRedirect->getStatusCode()->willReturn('302'); + $responseRedirect->hasHeader('Location')->willReturn(false); + + $promise = $this->handleRequest($request, $next, function () {}); + $promise->shouldReturnAnInstanceOf('Http\Promise\RejectedPromise'); + $promise->shouldThrow('Http\Client\Exception\HttpException')->duringWait(); + } + + function it_throws_http_exception_on_invalid_location(RequestInterface $request, ResponseInterface $responseRedirect) + { + $next = function (RequestInterface $receivedRequest) use($request, $responseRedirect) { + if (Argument::is($request->getWrappedObject())->scoreArgument($receivedRequest)) { + return new FulfilledPromise($responseRedirect->getWrappedObject()); + } + }; + + $request->getRequestTarget()->willReturn('/original'); + $responseRedirect->getHeaderLine('Location')->willReturn('scheme:///invalid'); + + $responseRedirect->getStatusCode()->willReturn('302'); + $responseRedirect->hasHeader('Location')->willReturn(true); + + $promise = $this->handleRequest($request, $next, function () {}); + $promise->shouldReturnAnInstanceOf('Http\Promise\RejectedPromise'); + $promise->shouldThrow('Http\Client\Exception\HttpException')->duringWait(); + } + + function it_throw_multi_redirect_exception_on_300(RequestInterface $request, ResponseInterface $responseRedirect) + { + $next = function (RequestInterface $receivedRequest) use($request, $responseRedirect) { + if (Argument::is($request->getWrappedObject())->scoreArgument($receivedRequest)) { + return new FulfilledPromise($responseRedirect->getWrappedObject()); + } + }; + + $this->beConstructedWith(['preserve_header' => true, 'use_default_for_multiple' => false]); + $responseRedirect->getStatusCode()->willReturn('300'); + + $promise = $this->handleRequest($request, $next, function () {}); + $promise->shouldReturnAnInstanceOf('Http\Promise\RejectedPromise'); + $promise->shouldThrow('Http\Client\Common\Exception\MultipleRedirectionException')->duringWait(); + } + + function it_throw_multi_redirect_exception_on_300_if_no_location(RequestInterface $request, ResponseInterface $responseRedirect) + { + $next = function (RequestInterface $receivedRequest) use($request, $responseRedirect) { + if (Argument::is($request->getWrappedObject())->scoreArgument($receivedRequest)) { + return new FulfilledPromise($responseRedirect->getWrappedObject()); + } + }; + + $responseRedirect->getStatusCode()->willReturn('300'); + $responseRedirect->hasHeader('Location')->willReturn(false); + + $promise = $this->handleRequest($request, $next, function () {}); + $promise->shouldReturnAnInstanceOf('Http\Promise\RejectedPromise'); + $promise->shouldThrow('Http\Client\Common\Exception\MultipleRedirectionException')->duringWait(); + } + + function it_switch_method_for_302( + UriInterface $uri, + UriInterface $uriRedirect, + RequestInterface $request, + ResponseInterface $responseRedirect, + RequestInterface $modifiedRequest, + ResponseInterface $finalResponse, + Promise $promise + ) { + $request->getRequestTarget()->willReturn('/original'); + + $responseRedirect->getStatusCode()->willReturn('302'); + $responseRedirect->hasHeader('Location')->willReturn(true); + $responseRedirect->getHeaderLine('Location')->willReturn('/redirect'); + + $request->getUri()->willReturn($uri); + $uri->withPath('/redirect')->willReturn($uriRedirect); + $uriRedirect->withFragment('')->willReturn($uriRedirect); + $uriRedirect->withQuery('')->willReturn($uriRedirect); + + $request->withUri($uriRedirect)->willReturn($modifiedRequest); + $modifiedRequest->getUri()->willReturn($uriRedirect); + + $modifiedRequest->getRequestTarget()->willReturn('/redirect'); + $modifiedRequest->getMethod()->willReturn('POST'); + $modifiedRequest->withMethod('GET')->willReturn($modifiedRequest); + + $next = function (RequestInterface $receivedRequest) use($request, $responseRedirect) { + if (Argument::is($request->getWrappedObject())->scoreArgument($receivedRequest)) { + return new FulfilledPromise($responseRedirect->getWrappedObject()); + } + }; + + $first = function (RequestInterface $receivedRequest) use($modifiedRequest, $promise) { + if (Argument::is($modifiedRequest->getWrappedObject())->scoreArgument($receivedRequest)) { + return $promise->getWrappedObject(); + } + }; + + $promise->getState()->willReturn(Promise::FULFILLED); + $promise->wait()->shouldBeCalled()->willReturn($finalResponse); + + $this->handleRequest($request, $next, $first); + } + + function it_clears_headers( + UriInterface $uri, + UriInterface $uriRedirect, + RequestInterface $request, + ResponseInterface $responseRedirect, + RequestInterface $modifiedRequest, + ResponseInterface $finalResponse, + Promise $promise + ) { + $this->beConstructedWith(['preserve_header' => ['Accept']]); + + $request->getRequestTarget()->willReturn('/original'); + + $responseRedirect->getStatusCode()->willReturn('302'); + $responseRedirect->hasHeader('Location')->willReturn(true); + $responseRedirect->getHeaderLine('Location')->willReturn('/redirect'); + + $request->getUri()->willReturn($uri); + $uri->withPath('/redirect')->willReturn($uriRedirect); + $uriRedirect->withFragment('')->willReturn($uriRedirect); + $uriRedirect->withQuery('')->willReturn($uriRedirect); + + $request->withUri($uriRedirect)->willReturn($modifiedRequest); + + $modifiedRequest->getRequestTarget()->willReturn('/redirect'); + $modifiedRequest->getMethod()->willReturn('GET'); + $modifiedRequest->getHeaders()->willReturn(['Accept' => 'value', 'Cookie' => 'value']); + $modifiedRequest->withoutHeader('Cookie')->willReturn($modifiedRequest); + $modifiedRequest->getUri()->willReturn($uriRedirect); + + $next = function (RequestInterface $receivedRequest) use($request, $responseRedirect) { + if (Argument::is($request->getWrappedObject())->scoreArgument($receivedRequest)) { + return new FulfilledPromise($responseRedirect->getWrappedObject()); + } + }; + + $first = function (RequestInterface $receivedRequest) use($modifiedRequest, $promise) { + if (Argument::is($modifiedRequest->getWrappedObject())->scoreArgument($receivedRequest)) { + return $promise->getWrappedObject(); + } + }; + + $promise->getState()->willReturn(Promise::FULFILLED); + $promise->wait()->shouldBeCalled()->willReturn($finalResponse); + + $this->handleRequest($request, $next, $first); + } + + function it_throws_circular_redirection_exception(UriInterface $uri, UriInterface $uriRedirect, RequestInterface $request, ResponseInterface $responseRedirect, RequestInterface $modifiedRequest) + { + $first = function() {}; + + $this->beAnInstanceOf('spec\Http\Client\Common\Plugin\RedirectPluginStubCircular'); + $this->beConstructedWith(spl_object_hash((object)$first)); + + $request->getRequestTarget()->willReturn('/original'); + $request->getUri()->willReturn($uri); + + $responseRedirect->getStatusCode()->willReturn('302'); + $responseRedirect->hasHeader('Location')->willReturn(true); + $responseRedirect->getHeaderLine('Location')->willReturn('/redirect'); + + $uri->withPath('/redirect')->willReturn($uriRedirect); + $uriRedirect->withFragment('')->willReturn($uriRedirect); + $uriRedirect->withQuery('')->willReturn($uriRedirect); + + $request->withUri($uriRedirect)->willReturn($modifiedRequest); + $modifiedRequest->getUri()->willReturn($uriRedirect); + $modifiedRequest->getRequestTarget()->willReturn('/redirect'); + $modifiedRequest->getMethod()->willReturn('GET'); + + $next = function (RequestInterface $receivedRequest) use($request, $responseRedirect) { + if (Argument::is($request->getWrappedObject())->scoreArgument($receivedRequest)) { + return new FulfilledPromise($responseRedirect->getWrappedObject()); + } + }; + + $promise = $this->handleRequest($request, $next, $first); + $promise->shouldReturnAnInstanceOf('Http\Promise\RejectedPromise'); + $promise->shouldThrow('Http\Client\Common\Exception\CircularRedirectionException')->duringWait(); + } +} + +class RedirectPluginStub extends RedirectPlugin +{ + public function __construct(UriInterface $uri, $storedUrl, $status, array $config = []) + { + parent::__construct($config); + + $this->redirectStorage[$storedUrl] = [ + 'uri' => $uri, + 'status' => $status + ]; + } + + public function hasStorage($url) + { + return isset($this->redirectStorage[$url]); + } +} + +class RedirectPluginStubCircular extends RedirectPlugin +{ + public function __construct($chainHash) + { + $this->circularDetection = [ + $chainHash => [ + '/redirect' + ] + ]; + } +} diff --git a/spec/Plugin/RequestMatcherPluginSpec.php b/spec/Plugin/RequestMatcherPluginSpec.php new file mode 100644 index 0000000..4fe9aea --- /dev/null +++ b/spec/Plugin/RequestMatcherPluginSpec.php @@ -0,0 +1,55 @@ +beConstructedWith($requestMatcher, $plugin); + } + + function it_is_initializable() + { + $this->shouldHaveType('Http\Client\Common\Plugin\RequestMatcherPlugin'); + } + + function it_is_a_plugin() + { + $this->shouldImplement('Http\Client\Common\Plugin'); + } + + function it_matches_a_request_and_delegates_to_plugin( + RequestInterface $request, + RequestMatcher $requestMatcher, + Plugin $plugin + ) { + $requestMatcher->matches($request)->willReturn(true); + $plugin->handleRequest($request, Argument::type('callable'), Argument::type('callable'))->shouldBeCalled(); + + $this->handleRequest($request, function () {}, function () {}); + } + + function it_does_not_match_a_request( + RequestInterface $request, + RequestMatcher $requestMatcher, + Plugin $plugin, + Promise $promise + ) { + $requestMatcher->matches($request)->willReturn(false); + $plugin->handleRequest($request, Argument::type('callable'), Argument::type('callable'))->shouldNotBeCalled(); + + $next = function (RequestInterface $request) use($promise) { + return $promise->getWrappedObject(); + }; + + $this->handleRequest($request, $next, function () {})->shouldReturn($promise); + } +} diff --git a/spec/Plugin/RetryPluginSpec.php b/spec/Plugin/RetryPluginSpec.php new file mode 100644 index 0000000..ee1d001 --- /dev/null +++ b/spec/Plugin/RetryPluginSpec.php @@ -0,0 +1,104 @@ +shouldHaveType('Http\Client\Common\Plugin\RetryPlugin'); + } + + function it_is_a_plugin() + { + $this->shouldImplement('Http\Client\Common\Plugin'); + } + + function it_returns_response(RequestInterface $request, ResponseInterface $response) + { + $next = function (RequestInterface $receivedRequest) use($request, $response) { + if (Argument::is($request->getWrappedObject())->scoreArgument($receivedRequest)) { + return new FulfilledPromise($response->getWrappedObject()); + } + }; + + $this->handleRequest($request, $next, function () {})->shouldReturnAnInstanceOf('Http\Promise\FulfilledPromise'); + } + + function it_throws_exception_on_multiple_exceptions(RequestInterface $request) + { + $exception1 = new Exception\NetworkException('Exception 1', $request->getWrappedObject()); + $exception2 = new Exception\NetworkException('Exception 2', $request->getWrappedObject()); + + $count = 0; + $next = function (RequestInterface $receivedRequest) use($request, $exception1, $exception2, &$count) { + $count++; + if (Argument::is($request->getWrappedObject())->scoreArgument($receivedRequest)) { + if ($count == 1) { + return new RejectedPromise($exception1); + } + + if ($count == 2) { + return new RejectedPromise($exception2); + } + } + }; + + $promise = $this->handleRequest($request, $next, function () {}); + $promise->shouldReturnAnInstanceOf('Http\Promise\RejectedPromise'); + $promise->shouldThrow($exception2)->duringWait(); + } + + function it_returns_response_on_second_try(RequestInterface $request, ResponseInterface $response) + { + $exception = new Exception\NetworkException('Exception 1', $request->getWrappedObject()); + + $count = 0; + $next = function (RequestInterface $receivedRequest) use($request, $exception, $response, &$count) { + $count++; + if (Argument::is($request->getWrappedObject())->scoreArgument($receivedRequest)) { + if ($count == 1) { + return new RejectedPromise($exception); + } + + if ($count == 2) { + return new FulfilledPromise($response->getWrappedObject()); + } + } + }; + + $promise = $this->handleRequest($request, $next, function () {}); + $promise->shouldReturnAnInstanceOf('Http\Promise\FulfilledPromise'); + $promise->wait()->shouldReturn($response); + } + + function it_does_not_keep_history_of_old_failure(RequestInterface $request, ResponseInterface $response) + { + $exception = new Exception\NetworkException('Exception 1', $request->getWrappedObject()); + + $count = 0; + $next = function (RequestInterface $receivedRequest) use($request, $exception, $response, &$count) { + $count++; + if (Argument::is($request->getWrappedObject())->scoreArgument($receivedRequest)) { + if ($count % 2 == 1) { + return new RejectedPromise($exception); + } + + if ($count % 2 == 0) { + return new FulfilledPromise($response->getWrappedObject()); + } + } + }; + + $this->handleRequest($request, $next, function () {})->shouldReturnAnInstanceOf('Http\Promise\FulfilledPromise'); + $this->handleRequest($request, $next, function () {})->shouldReturnAnInstanceOf('Http\Promise\FulfilledPromise'); + } +} diff --git a/spec/PluginClientSpec.php b/spec/PluginClientSpec.php new file mode 100644 index 0000000..88406ae --- /dev/null +++ b/spec/PluginClientSpec.php @@ -0,0 +1,90 @@ +beConstructedWith($httpClient); + } + + function it_is_initializable() + { + $this->shouldHaveType('Http\Client\Common\PluginClient'); + } + + function it_is_an_http_client() + { + $this->shouldImplement('Http\Client\HttpClient'); + } + + function it_is_an_http_async_client() + { + $this->shouldImplement('Http\Client\HttpAsyncClient'); + } + + function it_sends_request_with_underlying_client(HttpClient $httpClient, RequestInterface $request, ResponseInterface $response) + { + $httpClient->sendRequest($request)->willReturn($response); + + $this->sendRequest($request)->shouldReturn($response); + } + + function it_sends_async_request_with_underlying_client(HttpAsyncClient $httpAsyncClient, RequestInterface $request, Promise $promise) + { + $httpAsyncClient->sendAsyncRequest($request)->willReturn($promise); + + $this->beConstructedWith($httpAsyncClient); + $this->sendAsyncRequest($request)->shouldReturn($promise); + } + + function it_sends_async_request_if_no_send_request(HttpAsyncClient $httpAsyncClient, RequestInterface $request, ResponseInterface $response, Promise $promise) + { + $this->beConstructedWith($httpAsyncClient); + $httpAsyncClient->sendAsyncRequest($request)->willReturn($promise); + $promise->wait()->willReturn($response); + + $this->sendRequest($request)->shouldReturn($response); + } + + function it_prefers_send_request($client, RequestInterface $request, ResponseInterface $response) + { + $client->implement('Http\Client\HttpClient'); + $client->implement('Http\Client\HttpAsyncClient'); + + $client->sendRequest($request)->willReturn($response); + + $this->beConstructedWith($client); + + $this->sendRequest($request)->shouldReturn($response); + } + + function it_throws_loop_exception(HttpClient $httpClient, RequestInterface $request, Plugin $plugin) + { + $plugin + ->handleRequest( + $request, + Argument::type('callable'), + Argument::type('callable') + ) + ->will(function ($args) { + return $args[2]($args[0]); + }) + ; + + $this->beConstructedWith($httpClient, [$plugin]); + + $this->shouldThrow('Http\Client\Common\Exception\LoopException')->duringSendRequest($request); + } +} diff --git a/src/Exception/CircularRedirectionException.php b/src/Exception/CircularRedirectionException.php new file mode 100644 index 0000000..73ec521 --- /dev/null +++ b/src/Exception/CircularRedirectionException.php @@ -0,0 +1,14 @@ + + */ +class CircularRedirectionException extends HttpException +{ +} diff --git a/src/Exception/ClientErrorException.php b/src/Exception/ClientErrorException.php new file mode 100644 index 0000000..b1f6cc8 --- /dev/null +++ b/src/Exception/ClientErrorException.php @@ -0,0 +1,14 @@ + + */ +class ClientErrorException extends HttpException +{ +} diff --git a/src/Exception/LoopException.php b/src/Exception/LoopException.php new file mode 100644 index 0000000..e834124 --- /dev/null +++ b/src/Exception/LoopException.php @@ -0,0 +1,14 @@ + + */ +class LoopException extends RequestException +{ +} diff --git a/src/Exception/MultipleRedirectionException.php b/src/Exception/MultipleRedirectionException.php new file mode 100644 index 0000000..ae514cd --- /dev/null +++ b/src/Exception/MultipleRedirectionException.php @@ -0,0 +1,14 @@ + + */ +class MultipleRedirectionException extends HttpException +{ +} diff --git a/src/Exception/ServerErrorException.php b/src/Exception/ServerErrorException.php new file mode 100644 index 0000000..665d724 --- /dev/null +++ b/src/Exception/ServerErrorException.php @@ -0,0 +1,14 @@ + + */ +class ServerErrorException extends HttpException +{ +} diff --git a/src/Plugin.php b/src/Plugin.php new file mode 100644 index 0000000..d53a670 --- /dev/null +++ b/src/Plugin.php @@ -0,0 +1,30 @@ + + */ +interface Plugin +{ + /** + * Handle the request and return the response coming from the next callable. + * + * @param RequestInterface $request + * @param callable $next Next middleware in the chain, the request is passed as the first argument + * @param callable $first First middleware in the chain, used to to restart a request + * + * @return Promise + */ + public function handleRequest(RequestInterface $request, callable $next, callable $first); +} diff --git a/src/Plugin/AddHostPlugin.php b/src/Plugin/AddHostPlugin.php new file mode 100644 index 0000000..021554e --- /dev/null +++ b/src/Plugin/AddHostPlugin.php @@ -0,0 +1,74 @@ + + */ +final class AddHostPlugin implements Plugin +{ + /** + * @var UriInterface + */ + private $host; + + /** + * @var bool + */ + private $replace; + + /** + * @param UriInterface $host + * @param array $config { + * + * @var bool $replace True will replace all hosts, false will only add host when none is specified. + * } + */ + public function __construct(UriInterface $host, array $config = []) + { + if ($host->getHost() === '') { + throw new \LogicException('Host can not be empty'); + } + + $this->host = $host; + + $resolver = new OptionsResolver(); + $this->configureOptions($resolver); + $options = $resolver->resolve($config); + + $this->replace = $options['replace']; + } + + /** + * {@inheritdoc} + */ + public function handleRequest(RequestInterface $request, callable $next, callable $first) + { + if ($this->replace || $request->getUri()->getHost() === '') { + $uri = $request->getUri()->withHost($this->host->getHost()); + $uri = $uri->withScheme($this->host->getScheme()); + + $request = $request->withUri($uri); + } + + return $next($request); + } + + /** + * @param OptionsResolver $resolver + */ + private function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'replace' => false, + ]); + $resolver->setAllowedTypes('replace', 'bool'); + } +} diff --git a/src/Plugin/AuthenticationPlugin.php b/src/Plugin/AuthenticationPlugin.php new file mode 100644 index 0000000..194712f --- /dev/null +++ b/src/Plugin/AuthenticationPlugin.php @@ -0,0 +1,38 @@ + + */ +final class AuthenticationPlugin implements Plugin +{ + /** + * @var Authentication An authentication system + */ + private $authentication; + + /** + * @param Authentication $authentication + */ + public function __construct(Authentication $authentication) + { + $this->authentication = $authentication; + } + + /** + * {@inheritdoc} + */ + public function handleRequest(RequestInterface $request, callable $next, callable $first) + { + $request = $this->authentication->authenticate($request); + + return $next($request); + } +} diff --git a/src/Plugin/ContentLengthPlugin.php b/src/Plugin/ContentLengthPlugin.php new file mode 100644 index 0000000..a740c19 --- /dev/null +++ b/src/Plugin/ContentLengthPlugin.php @@ -0,0 +1,36 @@ + + */ +final class ContentLengthPlugin implements Plugin +{ + /** + * {@inheritdoc} + */ + public function handleRequest(RequestInterface $request, callable $next, callable $first) + { + if (!$request->hasHeader('Content-Length')) { + $stream = $request->getBody(); + + // Cannot determine the size so we use a chunk stream + if (null === $stream->getSize()) { + $stream = new ChunkStream($stream); + $request = $request->withBody($stream); + $request = $request->withAddedHeader('Transfer-Encoding', 'chunked'); + } else { + $request = $request->withHeader('Content-Length', $stream->getSize()); + } + } + + return $next($request); + } +} diff --git a/src/Plugin/CookiePlugin.php b/src/Plugin/CookiePlugin.php new file mode 100644 index 0000000..af306e5 --- /dev/null +++ b/src/Plugin/CookiePlugin.php @@ -0,0 +1,170 @@ + + */ +final class CookiePlugin implements Plugin +{ + /** + * Cookie storage. + * + * @var CookieJar + */ + private $cookieJar; + + /** + * @param CookieJar $cookieJar + */ + public function __construct(CookieJar $cookieJar) + { + $this->cookieJar = $cookieJar; + } + + /** + * {@inheritdoc} + */ + public function handleRequest(RequestInterface $request, callable $next, callable $first) + { + foreach ($this->cookieJar->getCookies() as $cookie) { + if ($cookie->isExpired()) { + continue; + } + + if (!$cookie->matchDomain($request->getUri()->getHost())) { + continue; + } + + if (!$cookie->matchPath($request->getUri()->getPath())) { + continue; + } + + if ($cookie->isSecure() && ($request->getUri()->getScheme() !== 'https')) { + continue; + } + + $request = $request->withAddedHeader('Cookie', sprintf('%s=%s', $cookie->getName(), $cookie->getValue())); + } + + return $next($request)->then(function (ResponseInterface $response) use ($request) { + if ($response->hasHeader('Set-Cookie')) { + $setCookies = $response->getHeader('Set-Cookie'); + + foreach ($setCookies as $setCookie) { + $cookie = $this->createCookie($request, $setCookie); + + // Cookie invalid do not use it + if (null === $cookie) { + continue; + } + + // Restrict setting cookie from another domain + if (false === strpos($cookie->getDomain(), $request->getUri()->getHost())) { + continue; + } + + $this->cookieJar->addCookie($cookie); + } + } + + return $response; + }); + } + + /** + * Creates a cookie from a string. + * + * @param RequestInterface $request + * @param $setCookie + * + * @return Cookie|null + * + * @throws TransferException + */ + private function createCookie(RequestInterface $request, $setCookie) + { + $parts = array_map('trim', explode(';', $setCookie)); + + if (empty($parts) || !strpos($parts[0], '=')) { + return; + } + + list($name, $cookieValue) = $this->createValueKey(array_shift($parts)); + + $maxAge = null; + $expires = null; + $domain = $request->getUri()->getHost(); + $path = $request->getUri()->getPath(); + $secure = false; + $httpOnly = false; + + // Add the cookie pieces into the parsed data array + foreach ($parts as $part) { + list($key, $value) = $this->createValueKey($part); + + switch (strtolower($key)) { + case 'expires': + $expires = \DateTime::createFromFormat(\DateTime::COOKIE, $value); + + if (true !== ($expires instanceof \DateTime)) { + throw new TransferException( + sprintf( + 'Cookie header `%s` expires value `%s` could not be converted to date', + $name, + $value + ) + ); + } + break; + + case 'max-age': + $maxAge = (int) $value; + break; + + case 'domain': + $domain = $value; + break; + + case 'path': + $path = $value; + break; + + case 'secure': + $secure = true; + break; + + case 'httponly': + $httpOnly = true; + break; + } + } + + return new Cookie($name, $cookieValue, $maxAge, $domain, $path, $secure, $httpOnly, $expires); + } + + /** + * Separates key/value pair from cookie. + * + * @param $part + * + * @return array + */ + private function createValueKey($part) + { + $parts = explode('=', $part, 2); + $key = trim($parts[0]); + $value = isset($parts[1]) ? trim($parts[1]) : true; + + return [$key, $value]; + } +} diff --git a/src/Plugin/DecoderPlugin.php b/src/Plugin/DecoderPlugin.php new file mode 100644 index 0000000..eea4445 --- /dev/null +++ b/src/Plugin/DecoderPlugin.php @@ -0,0 +1,144 @@ + + */ +final class DecoderPlugin implements Plugin +{ + /** + * @var bool Whether this plugin decode stream with value in the Content-Encoding header (default to true). + * + * If set to false only the Transfer-Encoding header will be used. + */ + private $useContentEncoding; + + /** + * @param array $config { + * + * @var bool $use_content_encoding Whether this plugin should look at the Content-Encoding header first or only at the Transfer-Encoding (defaults to true). + * } + */ + public function __construct(array $config = []) + { + $resolver = new OptionsResolver(); + $resolver->setDefaults([ + 'use_content_encoding' => true, + ]); + $resolver->setAllowedTypes('use_content_encoding', 'bool'); + $options = $resolver->resolve($config); + + $this->useContentEncoding = $options['use_content_encoding']; + } + + /** + * {@inheritdoc} + */ + public function handleRequest(RequestInterface $request, callable $next, callable $first) + { + $encodings = extension_loaded('zlib') ? ['gzip', 'deflate', 'compress'] : ['identity']; + + if ($this->useContentEncoding) { + $request = $request->withHeader('Accept-Encoding', $encodings); + } + $encodings[] = 'chunked'; + $request = $request->withHeader('TE', $encodings); + + return $next($request)->then(function (ResponseInterface $response) { + return $this->decodeResponse($response); + }); + } + + /** + * Decode a response body given its Transfer-Encoding or Content-Encoding value. + * + * @param ResponseInterface $response Response to decode + * + * @return ResponseInterface New response decoded + */ + private function decodeResponse(ResponseInterface $response) + { + $response = $this->decodeOnEncodingHeader('Transfer-Encoding', $response); + + if ($this->useContentEncoding) { + $response = $this->decodeOnEncodingHeader('Content-Encoding', $response); + } + + return $response; + } + + /** + * Decode a response on a specific header (content encoding or transfer encoding mainly). + * + * @param string $headerName Name of the header + * @param ResponseInterface $response Response + * + * @return ResponseInterface A new instance of the response decoded + */ + private function decodeOnEncodingHeader($headerName, ResponseInterface $response) + { + if ($response->hasHeader($headerName)) { + $encodings = $response->getHeader($headerName); + $newEncodings = []; + + while ($encoding = array_pop($encodings)) { + $stream = $this->decorateStream($encoding, $response->getBody()); + + if (false === $stream) { + array_unshift($newEncodings, $encoding); + + continue; + } + + $response = $response->withBody($stream); + } + + $response = $response->withHeader($headerName, $newEncodings); + } + + return $response; + } + + /** + * Decorate a stream given an encoding. + * + * @param string $encoding + * @param StreamInterface $stream + * + * @return StreamInterface|false A new stream interface or false if encoding is not supported + */ + private function decorateStream($encoding, StreamInterface $stream) + { + if (strtolower($encoding) == 'chunked') { + return new Encoding\DechunkStream($stream); + } + + if (strtolower($encoding) == 'compress') { + return new Encoding\DecompressStream($stream); + } + + if (strtolower($encoding) == 'deflate') { + return new Encoding\InflateStream($stream); + } + + if (strtolower($encoding) == 'gzip') { + return new Encoding\GzipDecodeStream($stream); + } + + return false; + } +} diff --git a/src/Plugin/ErrorPlugin.php b/src/Plugin/ErrorPlugin.php new file mode 100644 index 0000000..b632327 --- /dev/null +++ b/src/Plugin/ErrorPlugin.php @@ -0,0 +1,55 @@ + + */ +final class ErrorPlugin implements Plugin +{ + /** + * {@inheritdoc} + */ + public function handleRequest(RequestInterface $request, callable $next, callable $first) + { + $promise = $next($request); + + return $promise->then(function (ResponseInterface $response) use ($request) { + return $this->transformResponseToException($request, $response); + }); + } + + /** + * Transform response to an error if possible. + * + * @param RequestInterface $request Request of the call + * @param ResponseInterface $response Response of the call + * + * @throws ClientErrorException If response status code is a 4xx + * @throws ServerErrorException If response status code is a 5xx + * + * @return ResponseInterface If status code is not in 4xx or 5xx return response + */ + protected function transformResponseToException(RequestInterface $request, ResponseInterface $response) + { + if ($response->getStatusCode() >= 400 && $response->getStatusCode() < 500) { + throw new ClientErrorException($response->getReasonPhrase(), $request, $response); + } + + if ($response->getStatusCode() >= 500 && $response->getStatusCode() < 600) { + throw new ServerErrorException($response->getReasonPhrase(), $request, $response); + } + + return $response; + } +} diff --git a/src/Plugin/HeaderAppendPlugin.php b/src/Plugin/HeaderAppendPlugin.php new file mode 100644 index 0000000..f9db2e9 --- /dev/null +++ b/src/Plugin/HeaderAppendPlugin.php @@ -0,0 +1,44 @@ + + */ +final class HeaderAppendPlugin implements Plugin +{ + /** + * @var array + */ + private $headers = []; + + /** + * @param array $headers headers to add to the request + */ + public function __construct(array $headers) + { + $this->headers = $headers; + } + + /** + * {@inheritdoc} + */ + public function handleRequest(RequestInterface $request, callable $next, callable $first) + { + foreach ($this->headers as $header => $headerValue) { + $request = $request->withAddedHeader($header, $headerValue); + } + + return $next($request); + } +} diff --git a/src/Plugin/HeaderDefaultsPlugin.php b/src/Plugin/HeaderDefaultsPlugin.php new file mode 100644 index 0000000..fcfa1ed --- /dev/null +++ b/src/Plugin/HeaderDefaultsPlugin.php @@ -0,0 +1,42 @@ + + */ +final class HeaderDefaultsPlugin implements Plugin +{ + /** + * @var array + */ + private $headers = []; + + /** + * @param array $headers headers to set to the request + */ + public function __construct(array $headers) + { + $this->headers = $headers; + } + + /** + * {@inheritdoc} + */ + public function handleRequest(RequestInterface $request, callable $next, callable $first) + { + foreach ($this->headers as $header => $headerValue) { + if (!$request->hasHeader($header)) { + $request = $request->withHeader($header, $headerValue); + } + } + + return $next($request); + } +} diff --git a/src/Plugin/HeaderRemovePlugin.php b/src/Plugin/HeaderRemovePlugin.php new file mode 100644 index 0000000..74be14f --- /dev/null +++ b/src/Plugin/HeaderRemovePlugin.php @@ -0,0 +1,41 @@ + + */ +final class HeaderRemovePlugin implements Plugin +{ + /** + * @var array + */ + private $headers = []; + + /** + * @param array $headers headers to remove from the request + */ + public function __construct(array $headers) + { + $this->headers = $headers; + } + + /** + * {@inheritdoc} + */ + public function handleRequest(RequestInterface $request, callable $next, callable $first) + { + foreach ($this->headers as $header) { + if ($request->hasHeader($header)) { + $request = $request->withoutHeader($header); + } + } + + return $next($request); + } +} diff --git a/src/Plugin/HeaderSetPlugin.php b/src/Plugin/HeaderSetPlugin.php new file mode 100644 index 0000000..f1b38d0 --- /dev/null +++ b/src/Plugin/HeaderSetPlugin.php @@ -0,0 +1,40 @@ + + */ +final class HeaderSetPlugin implements Plugin +{ + /** + * @var array + */ + private $headers = []; + + /** + * @param array $headers headers to set to the request + */ + public function __construct(array $headers) + { + $this->headers = $headers; + } + + /** + * {@inheritdoc} + */ + public function handleRequest(RequestInterface $request, callable $next, callable $first) + { + foreach ($this->headers as $header => $headerValue) { + $request = $request->withHeader($header, $headerValue); + } + + return $next($request); + } +} diff --git a/src/Plugin/HistoryPlugin.php b/src/Plugin/HistoryPlugin.php new file mode 100644 index 0000000..5abddbd --- /dev/null +++ b/src/Plugin/HistoryPlugin.php @@ -0,0 +1,49 @@ + + */ +final class HistoryPlugin implements Plugin +{ + /** + * Journal use to store request / responses / exception. + * + * @var Journal + */ + private $journal; + + /** + * @param Journal $journal + */ + public function __construct(Journal $journal) + { + $this->journal = $journal; + } + + /** + * {@inheritdoc} + */ + public function handleRequest(RequestInterface $request, callable $next, callable $first) + { + $journal = $this->journal; + + return $next($request)->then(function (ResponseInterface $response) use ($request, $journal) { + $journal->addSuccess($request, $response); + + return $response; + }, function (Exception $exception) use ($request, $journal) { + $journal->addFailure($request, $exception); + + throw $exception; + }); + } +} diff --git a/src/Plugin/Journal.php b/src/Plugin/Journal.php new file mode 100644 index 0000000..15f3095 --- /dev/null +++ b/src/Plugin/Journal.php @@ -0,0 +1,31 @@ + + */ +interface Journal +{ + /** + * Record a successful call. + * + * @param RequestInterface $request Request use to make the call + * @param ResponseInterface $response Response returned by the call + */ + public function addSuccess(RequestInterface $request, ResponseInterface $response); + + /** + * Record a failed call. + * + * @param RequestInterface $request Request use to make the call + * @param Exception $exception Exception returned by the call + */ + public function addFailure(RequestInterface $request, Exception $exception); +} diff --git a/src/Plugin/RedirectPlugin.php b/src/Plugin/RedirectPlugin.php new file mode 100644 index 0000000..f2b06a0 --- /dev/null +++ b/src/Plugin/RedirectPlugin.php @@ -0,0 +1,270 @@ + + */ +class RedirectPlugin implements Plugin +{ + /** + * Rule on how to redirect, change method for the new request. + * + * @var array + */ + protected $redirectCodes = [ + 300 => [ + 'switch' => [ + 'unless' => ['GET', 'HEAD'], + 'to' => 'GET', + ], + 'multiple' => true, + 'permanent' => false, + ], + 301 => [ + 'switch' => [ + 'unless' => ['GET', 'HEAD'], + 'to' => 'GET', + ], + 'multiple' => false, + 'permanent' => true, + ], + 302 => [ + 'switch' => [ + 'unless' => ['GET', 'HEAD'], + 'to' => 'GET', + ], + 'multiple' => false, + 'permanent' => false, + ], + 303 => [ + 'switch' => [ + 'unless' => ['GET', 'HEAD'], + 'to' => 'GET', + ], + 'multiple' => false, + 'permanent' => false, + ], + 307 => [ + 'switch' => false, + 'multiple' => false, + 'permanent' => false, + ], + 308 => [ + 'switch' => false, + 'multiple' => false, + 'permanent' => true, + ], + ]; + + /** + * Determine how header should be preserved from old request. + * + * @var bool|array + * + * true will keep all previous headers (default value) + * false will ditch all previous headers + * string[] will keep only headers with the specified names + */ + protected $preserveHeader; + + /** + * Store all previous redirect from 301 / 308 status code. + * + * @var array + */ + protected $redirectStorage = []; + + /** + * Whether the location header must be directly used for a multiple redirection status code (300). + * + * @var bool + */ + protected $useDefaultForMultiple; + + /** + * @var array + */ + protected $circularDetection = []; + + /** + * @param array $config { + * + * @var bool|string[] $preserve_header True keeps all headers, false remove all of them, an array is interpreted as a list of header names to keep. + * @var bool $use_default_for_multiple Whether the location header must be directly used for a multiple redirection status code (300). + * } + */ + public function __construct(array $config = []) + { + $resolver = new OptionsResolver(); + $resolver->setDefaults([ + 'preserve_header' => true, + 'use_default_for_multiple' => true, + ]); + $resolver->setAllowedTypes('preserve_header', ['bool', 'array']); + $resolver->setAllowedTypes('use_default_for_multiple', 'bool'); + $resolver->setNormalizer('preserve_header', function (OptionsResolver $resolver, $value) { + if (is_bool($value) && false === $value) { + return []; + } + + return $value; + }); + $options = $resolver->resolve($config); + + $this->preserveHeader = $options['preserve_header']; + $this->useDefaultForMultiple = $options['use_default_for_multiple']; + } + + /** + * {@inheritdoc} + */ + public function handleRequest(RequestInterface $request, callable $next, callable $first) + { + // Check in storage + if (array_key_exists($request->getRequestTarget(), $this->redirectStorage)) { + $uri = $this->redirectStorage[$request->getRequestTarget()]['uri']; + $statusCode = $this->redirectStorage[$request->getRequestTarget()]['status']; + $redirectRequest = $this->buildRedirectRequest($request, $uri, $statusCode); + + return $first($redirectRequest); + } + + return $next($request)->then(function (ResponseInterface $response) use ($request, $first) { + $statusCode = $response->getStatusCode(); + + if (!array_key_exists($statusCode, $this->redirectCodes)) { + return $response; + } + + $uri = $this->createUri($response, $request); + $redirectRequest = $this->buildRedirectRequest($request, $uri, $statusCode); + $chainIdentifier = spl_object_hash((object) $first); + + if (!array_key_exists($chainIdentifier, $this->circularDetection)) { + $this->circularDetection[$chainIdentifier] = []; + } + + $this->circularDetection[$chainIdentifier][] = $request->getRequestTarget(); + + if (in_array($redirectRequest->getRequestTarget(), $this->circularDetection[$chainIdentifier])) { + throw new CircularRedirectionException('Circular redirection detected', $request, $response); + } + + if ($this->redirectCodes[$statusCode]['permanent']) { + $this->redirectStorage[$request->getRequestTarget()] = [ + 'uri' => $uri, + 'status' => $statusCode, + ]; + } + + // Call redirect request in synchrone + $redirectPromise = $first($redirectRequest); + + return $redirectPromise->wait(); + }); + } + + /** + * Builds the redirect request. + * + * @param RequestInterface $request Original request + * @param UriInterface $uri New uri + * @param int $statusCode Status code from the redirect response + * + * @return MessageInterface|RequestInterface + */ + protected function buildRedirectRequest(RequestInterface $request, UriInterface $uri, $statusCode) + { + $request = $request->withUri($uri); + + if (false !== $this->redirectCodes[$statusCode]['switch'] && !in_array($request->getMethod(), $this->redirectCodes[$statusCode]['switch']['unless'])) { + $request = $request->withMethod($this->redirectCodes[$statusCode]['switch']['to']); + } + + if (is_array($this->preserveHeader)) { + $headers = array_keys($request->getHeaders()); + + foreach ($headers as $name) { + if (!in_array($name, $this->preserveHeader)) { + $request = $request->withoutHeader($name); + } + } + } + + return $request; + } + + /** + * Creates a new Uri from the old request and the location header. + * + * @param ResponseInterface $response The redirect response + * @param RequestInterface $request The original request + * + * @throws HttpException If location header is not usable (missing or incorrect) + * @throws MultipleRedirectionException If a 300 status code is received and default location cannot be resolved (doesn't use the location header or not present) + * + * @return UriInterface + */ + private function createUri(ResponseInterface $response, RequestInterface $request) + { + if ($this->redirectCodes[$response->getStatusCode()]['multiple'] && (!$this->useDefaultForMultiple || !$response->hasHeader('Location'))) { + throw new MultipleRedirectionException('Cannot choose a redirection', $request, $response); + } + + if (!$response->hasHeader('Location')) { + throw new HttpException('Redirect status code, but no location header present in the response', $request, $response); + } + + $location = $response->getHeaderLine('Location'); + $parsedLocation = parse_url($location); + + if (false === $parsedLocation) { + throw new HttpException(sprintf('Location %s could not be parsed', $location), $request, $response); + } + + $uri = $request->getUri(); + + if (array_key_exists('scheme', $parsedLocation)) { + $uri = $uri->withScheme($parsedLocation['scheme']); + } + + if (array_key_exists('host', $parsedLocation)) { + $uri = $uri->withHost($parsedLocation['host']); + } + + if (array_key_exists('port', $parsedLocation)) { + $uri = $uri->withPort($parsedLocation['port']); + } + + if (array_key_exists('path', $parsedLocation)) { + $uri = $uri->withPath($parsedLocation['path']); + } + + if (array_key_exists('query', $parsedLocation)) { + $uri = $uri->withQuery($parsedLocation['query']); + } else { + $uri = $uri->withQuery(''); + } + + if (array_key_exists('fragment', $parsedLocation)) { + $uri = $uri->withFragment($parsedLocation['fragment']); + } else { + $uri = $uri->withFragment(''); + } + + return $uri; + } +} diff --git a/src/Plugin/RequestMatcherPlugin.php b/src/Plugin/RequestMatcherPlugin.php new file mode 100644 index 0000000..5f72b02 --- /dev/null +++ b/src/Plugin/RequestMatcherPlugin.php @@ -0,0 +1,47 @@ + + */ +final class RequestMatcherPlugin implements Plugin +{ + /** + * @var RequestMatcher + */ + private $requestMatcher; + + /** + * @var Plugin + */ + private $delegatedPlugin; + + /** + * @param RequestMatcher $requestMatcher + * @param Plugin $delegatedPlugin + */ + public function __construct(RequestMatcher $requestMatcher, Plugin $delegatedPlugin) + { + $this->requestMatcher = $requestMatcher; + $this->delegatedPlugin = $delegatedPlugin; + } + + /** + * {@inheritdoc} + */ + public function handleRequest(RequestInterface $request, callable $next, callable $first) + { + if ($this->requestMatcher->matches($request)) { + return $this->delegatedPlugin->handleRequest($request, $next, $first); + } + + return $next($request); + } +} diff --git a/src/Plugin/RetryPlugin.php b/src/Plugin/RetryPlugin.php new file mode 100644 index 0000000..bbb1ffa --- /dev/null +++ b/src/Plugin/RetryPlugin.php @@ -0,0 +1,84 @@ + + */ +final class RetryPlugin implements Plugin +{ + /** + * Number of retry before sending an exception. + * + * @var int + */ + private $retry; + + /** + * Store the retry counter for each request. + * + * @var array + */ + private $retryStorage = []; + + /** + * @param array $config { + * + * @var int $retries Number of retries to attempt if an exception occurs before letting the exception bubble up. + * } + */ + public function __construct(array $config = []) + { + $resolver = new OptionsResolver(); + $resolver->setDefaults([ + 'retries' => 1, + ]); + $resolver->setAllowedTypes('retries', 'int'); + $options = $resolver->resolve($config); + + $this->retry = $options['retries']; + } + + /** + * {@inheritdoc} + */ + public function handleRequest(RequestInterface $request, callable $next, callable $first) + { + $chainIdentifier = spl_object_hash((object) $first); + + return $next($request)->then(function (ResponseInterface $response) use ($request, $chainIdentifier) { + if (array_key_exists($chainIdentifier, $this->retryStorage)) { + unset($this->retryStorage[$chainIdentifier]); + } + + return $response; + }, function (Exception $exception) use ($request, $next, $first, $chainIdentifier) { + if (!array_key_exists($chainIdentifier, $this->retryStorage)) { + $this->retryStorage[$chainIdentifier] = 0; + } + + if ($this->retryStorage[$chainIdentifier] >= $this->retry) { + unset($this->retryStorage[$chainIdentifier]); + + throw $exception; + } + + ++$this->retryStorage[$chainIdentifier]; + + // Retry in synchrone + $promise = $this->handleRequest($request, $next, $first); + + return $promise->wait(); + }); + } +} diff --git a/src/PluginClient.php b/src/PluginClient.php new file mode 100644 index 0000000..9aa9e6c --- /dev/null +++ b/src/PluginClient.php @@ -0,0 +1,151 @@ + + */ +final class PluginClient implements HttpClient, HttpAsyncClient +{ + /** + * An HTTP async client. + * + * @var HttpAsyncClient + */ + private $client; + + /** + * The plugin chain. + * + * @var Plugin[] + */ + private $plugins; + + /** + * A list of options. + * + * @var array + */ + private $options; + + /** + * @param HttpClient|HttpAsyncClient $client + * @param Plugin[] $plugins + * @param array $options { + * + * @var int $max_restarts + * } + * + * @throws \RuntimeException if client is not an instance of HttpClient or HttpAsyncClient + */ + public function __construct($client, array $plugins = [], array $options = []) + { + if ($client instanceof HttpAsyncClient) { + $this->client = $client; + } elseif ($client instanceof HttpClient) { + $this->client = new EmulatedHttpAsyncClient($client); + } else { + throw new \RuntimeException('Client must be an instance of Http\\Client\\HttpClient or Http\\Client\\HttpAsyncClient'); + } + + $this->plugins = $plugins; + $this->options = $this->configure($options); + } + + /** + * {@inheritdoc} + */ + public function sendRequest(RequestInterface $request) + { + // If we don't have an http client, use the async call + if (!($this->client instanceof HttpClient)) { + return $this->sendAsyncRequest($request)->wait(); + } + + // Else we want to use the synchronous call of the underlying client, and not the async one in the case + // we have both an async and sync call + $pluginChain = $this->createPluginChain($this->plugins, function (RequestInterface $request) { + try { + return new FulfilledPromise($this->client->sendRequest($request)); + } catch (HttplugException $exception) { + return new RejectedPromise($exception); + } + }); + + return $pluginChain($request)->wait(); + } + + /** + * {@inheritdoc} + */ + public function sendAsyncRequest(RequestInterface $request) + { + $pluginChain = $this->createPluginChain($this->plugins, function (RequestInterface $request) { + return $this->client->sendAsyncRequest($request); + }); + + return $pluginChain($request); + } + + /** + * Configure the plugin client. + * + * @param array $options + * + * @return array + */ + private function configure(array $options = []) + { + $resolver = new OptionsResolver(); + $resolver->setDefaults([ + 'max_restarts' => 10, + ]); + + return $resolver->resolve($options); + } + + /** + * Create the plugin chain. + * + * @param Plugin[] $pluginList A list of plugins + * @param callable $clientCallable Callable making the HTTP call + * + * @return callable + */ + private function createPluginChain($pluginList, callable $clientCallable) + { + $firstCallable = $lastCallable = $clientCallable; + + while ($plugin = array_pop($pluginList)) { + $lastCallable = function (RequestInterface $request) use ($plugin, $lastCallable, &$firstCallable) { + return $plugin->handleRequest($request, $lastCallable, $firstCallable); + }; + + $firstCallable = $lastCallable; + } + + $firstCalls = 0; + $firstCallable = function (RequestInterface $request) use ($lastCallable, &$firstCalls) { + if ($firstCalls > $this->options['max_restarts']) { + throw new LoopException('Too many restarts in plugin client', $request); + } + + ++$firstCalls; + + return $lastCallable($request); + }; + + return $firstCallable; + } +}