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; + } +}