Skip to content

Add Plugin Client #14

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
May 3, 2016
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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

3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
@@ -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"
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is one of my concerns. Adding this dependency here. Maybe we should rethink option handling in the plugin client?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the big benefit of options-resolver is that we are much more flexible to add things without a BC break. adding new constructor parameters can easily lead to BC break, and for these options things, its tricky to decide on the order of them - with separate parameters, the order can not be changed to make logical sense. the array is so much easier.

hm. on the other hand we have only one option for now. maybe we could instead do that manually instead of using OptionsResolver - we can add a requirement for the resolver when we add more options. for just one, its indeed overkill if its not a free include. wdyt?

  • remove the resolver dependency
  • keep $options an array
  • resolve manually for now
  • add a comment next to the options that if we add more options, we should introduce the OptionsResolver

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is exactly what I had in my mind.

},
"require-dev": {
"phpspec/phpspec": "^2.4",
1 change: 1 addition & 0 deletions spec/FlexibleHttpClientSpec.php
Original file line number Diff line number Diff line change
@@ -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();
84 changes: 84 additions & 0 deletions spec/Plugin/AddHostPluginSpec.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
<?php

namespace spec\Http\Client\Common\Plugin;

use Http\Message\StreamFactory;
use Http\Message\UriFactory;
use Http\Promise\FulfilledPromise;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\StreamInterface;
use Psr\Http\Message\UriInterface;
use PhpSpec\ObjectBehavior;

class AddHostPluginSpec extends ObjectBehavior
{
function let(UriInterface $uri)
{
$this->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 () {});
}
}
40 changes: 40 additions & 0 deletions spec/Plugin/AuthenticationPluginSpec.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php

namespace spec\Http\Client\Common\Plugin;

use Http\Message\Authentication;
use Http\Promise\Promise;
use Psr\Http\Message\RequestInterface;
use PhpSpec\ObjectBehavior;
use Prophecy\Argument;

class AuthenticationPluginSpec extends ObjectBehavior
{
function let(Authentication $authentication)
{
$this->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);
}
}
48 changes: 48 additions & 0 deletions spec/Plugin/ContentLengthPluginSpec.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<?php

namespace spec\Http\Client\Common\Plugin;

use PhpSpec\Exception\Example\SkippingException;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\StreamInterface;
use PhpSpec\ObjectBehavior;
use Prophecy\Argument;

class ContentLengthPluginSpec extends ObjectBehavior
{
function it_is_initializable()
{
$this->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 () {});
}
}
183 changes: 183 additions & 0 deletions spec/Plugin/CookiePluginSpec.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
<?php

namespace spec\Http\Client\Common\Plugin;

use Http\Promise\FulfilledPromise;
use Http\Message\Cookie;
use Http\Message\CookieJar;
use Http\Promise\Promise;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\UriInterface;
use PhpSpec\ObjectBehavior;
use Prophecy\Argument;

class CookiePluginSpec extends ObjectBehavior
{
private $cookieJar;

function let()
{
$this->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();
}
}
132 changes: 132 additions & 0 deletions spec/Plugin/DecoderPluginSpec.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
<?php

namespace spec\Http\Client\Common\Plugin;

use Http\Promise\FulfilledPromise;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\StreamInterface;
use PhpSpec\Exception\Example\SkippingException;
use PhpSpec\ObjectBehavior;
use Prophecy\Argument;

class DecoderPluginSpec extends ObjectBehavior
{
function it_is_initializable()
{
$this->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 () {});
}
}
67 changes: 67 additions & 0 deletions spec/Plugin/ErrorPluginSpec.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
<?php

namespace spec\Http\Client\Common\Plugin;

use Http\Promise\FulfilledPromise;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use PhpSpec\ObjectBehavior;
use Prophecy\Argument;

class ErrorPluginSpec extends ObjectBehavior
{
function it_is_initializable()
{
$this->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');
}
}
37 changes: 37 additions & 0 deletions spec/Plugin/HeaderAppendPluginSpec.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

namespace spec\Http\Client\Common\Plugin;

use PhpSpec\Exception\Example\SkippingException;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\StreamInterface;
use PhpSpec\ObjectBehavior;
use Prophecy\Argument;

class HeaderAppendPluginSpec extends ObjectBehavior
{
public function it_is_initializable()
{
$this->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 () {});
}
}
38 changes: 38 additions & 0 deletions spec/Plugin/HeaderDefaultsPluginSpec.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php

namespace spec\Http\Client\Common\Plugin;

use PhpSpec\Exception\Example\SkippingException;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\StreamInterface;
use PhpSpec\ObjectBehavior;
use Prophecy\Argument;

class HeaderDefaultsPluginSpec extends ObjectBehavior
{
public function it_is_initializable()
{
$this->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 () {});
}
}
39 changes: 39 additions & 0 deletions spec/Plugin/HeaderRemovePluginSpec.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php

namespace spec\Http\Client\Common\Plugin;

use PhpSpec\Exception\Example\SkippingException;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\StreamInterface;
use PhpSpec\ObjectBehavior;
use Prophecy\Argument;

class HeaderRemovePluginSpec extends ObjectBehavior
{
public function it_is_initializable()
{
$this->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 () {});
}
}
37 changes: 37 additions & 0 deletions spec/Plugin/HeaderSetPluginSpec.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

namespace spec\Http\Client\Common\Plugin;

use PhpSpec\Exception\Example\SkippingException;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\StreamInterface;
use PhpSpec\ObjectBehavior;
use Prophecy\Argument;

class HeaderSetPluginSpec extends ObjectBehavior
{
public function it_is_initializable()
{
$this->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 () {});
}
}
57 changes: 57 additions & 0 deletions spec/Plugin/HistoryPluginSpec.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<?php

namespace spec\Http\Client\Common\Plugin;

use Http\Client\Exception\TransferException;
use Http\Client\Common\Plugin\Journal;
use Http\Promise\FulfilledPromise;
use Http\Promise\RejectedPromise;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use PhpSpec\ObjectBehavior;
use Prophecy\Argument;

class HistoryPluginSpec extends ObjectBehavior
{
function let(Journal $journal)
{
$this->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 () {});
}
}
406 changes: 406 additions & 0 deletions spec/Plugin/RedirectPluginSpec.php

Large diffs are not rendered by default.

55 changes: 55 additions & 0 deletions spec/Plugin/RequestMatcherPluginSpec.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<?php

namespace spec\Http\Client\Common\Plugin;

use Http\Client\Common\Plugin;
use Http\Message\RequestMatcher;
use Http\Promise\Promise;
use Psr\Http\Message\RequestInterface;
use PhpSpec\ObjectBehavior;
use Prophecy\Argument;

class RequestMatcherPluginSpec extends ObjectBehavior
{
function let(RequestMatcher $requestMatcher, Plugin $plugin)
{
$this->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);
}
}
104 changes: 104 additions & 0 deletions spec/Plugin/RetryPluginSpec.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
<?php

namespace spec\Http\Client\Common\Plugin;

use Http\Client\Exception;
use Http\Promise\FulfilledPromise;
use Http\Promise\RejectedPromise;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use PhpSpec\ObjectBehavior;
use Prophecy\Argument;

class RetryPluginSpec extends ObjectBehavior
{
function it_is_initializable()
{
$this->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');
}
}
90 changes: 90 additions & 0 deletions spec/PluginClientSpec.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
<?php

namespace spec\Http\Client\Common;

use Http\Client\HttpAsyncClient;
use Http\Client\HttpClient;
use Http\Client\Common\FlexibleHttpClient;
use Http\Client\Common\Plugin;
use Http\Promise\Promise;
use Prophecy\Argument;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use PhpSpec\ObjectBehavior;

class PluginClientSpec extends ObjectBehavior
{
function let(HttpClient $httpClient)
{
$this->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);
}
}
14 changes: 14 additions & 0 deletions src/Exception/CircularRedirectionException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php

namespace Http\Client\Common\Exception;

use Http\Client\Exception\HttpException;

/**
* Thrown when circular redirection is detected.
*
* @author Joel Wurtz <joel.wurtz@gmail.com>
*/
class CircularRedirectionException extends HttpException
{
}
14 changes: 14 additions & 0 deletions src/Exception/ClientErrorException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php

namespace Http\Client\Common\Exception;

use Http\Client\Exception\HttpException;

/**
* Thrown when there is a client error (4xx).
*
* @author Joel Wurtz <joel.wurtz@gmail.com>
*/
class ClientErrorException extends HttpException
{
}
14 changes: 14 additions & 0 deletions src/Exception/LoopException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php

namespace Http\Client\Common\Exception;

use Http\Client\Exception\RequestException;

/**
* Thrown when the Plugin Client detects an endless loop.
*
* @author Joel Wurtz <joel.wurtz@gmail.com>
*/
class LoopException extends RequestException
{
}
14 changes: 14 additions & 0 deletions src/Exception/MultipleRedirectionException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php

namespace Http\Client\Common\Exception;

use Http\Client\Exception\HttpException;

/**
* Redirect location cannot be chosen.
*
* @author Joel Wurtz <joel.wurtz@gmail.com>
*/
class MultipleRedirectionException extends HttpException
{
}
14 changes: 14 additions & 0 deletions src/Exception/ServerErrorException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php

namespace Http\Client\Common\Exception;

use Http\Client\Exception\HttpException;

/**
* Thrown when there is a server error (5xx).
*
* @author Joel Wurtz <joel.wurtz@gmail.com>
*/
class ServerErrorException extends HttpException
{
}
30 changes: 30 additions & 0 deletions src/Plugin.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

namespace Http\Client\Common;

use Http\Promise\Promise;
use Psr\Http\Message\RequestInterface;

/**
* A plugin is a middleware to transform the request and/or the response.
*
* The plugin can:
* - break the chain and return a response
* - dispatch the request to the next middleware
* - restart the request
*
* @author Joel Wurtz <joel.wurtz@gmail.com>
*/
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);
}
74 changes: 74 additions & 0 deletions src/Plugin/AddHostPlugin.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
<?php

namespace Http\Client\Common\Plugin;

use Http\Client\Common\Plugin;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\UriInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

/**
* Add schema and host to a request. Can be set to overwrite the schema and host if desired.
*
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
*/
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');
}
}
38 changes: 38 additions & 0 deletions src/Plugin/AuthenticationPlugin.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php

namespace Http\Client\Common\Plugin;

use Http\Client\Common\Plugin;
use Http\Message\Authentication;
use Psr\Http\Message\RequestInterface;

/**
* Send an authenticated request.
*
* @author Joel Wurtz <joel.wurtz@gmail.com>
*/
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);
}
}
36 changes: 36 additions & 0 deletions src/Plugin/ContentLengthPlugin.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php

namespace Http\Client\Common\Plugin;

use Http\Client\Common\Plugin;
use Http\Message\Encoding\ChunkStream;
use Psr\Http\Message\RequestInterface;

/**
* Allow to set the correct content length header on the request or to transfer it as a chunk if not possible.
*
* @author Joel Wurtz <joel.wurtz@gmail.com>
*/
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);
}
}
170 changes: 170 additions & 0 deletions src/Plugin/CookiePlugin.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
<?php

namespace Http\Client\Common\Plugin;

use Http\Client\Common\Plugin;
use Http\Client\Exception\TransferException;
use Http\Message\Cookie;
use Http\Message\CookieJar;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;

/**
* Handle request cookies.
*
* @author Joel Wurtz <joel.wurtz@gmail.com>
*/
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];
}
}
144 changes: 144 additions & 0 deletions src/Plugin/DecoderPlugin.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
<?php

namespace Http\Client\Common\Plugin;

use Http\Client\Common\Plugin;
use Http\Message\Encoding;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\StreamInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

/**
* Allow to decode response body with a chunk, deflate, compress or gzip encoding.
*
* If zlib is not installed, only chunked encoding can be handled.
*
* If Content-Encoding is not disabled, the plugin will add an Accept-Encoding header for the encoding methods it supports.
*
* @author Joel Wurtz <joel.wurtz@gmail.com>
*/
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;
}
}
55 changes: 55 additions & 0 deletions src/Plugin/ErrorPlugin.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<?php

namespace Http\Client\Common\Plugin;

use Http\Client\Common\Exception\ClientErrorException;
use Http\Client\Common\Exception\ServerErrorException;
use Http\Client\Common\Plugin;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;

/**
* Throw exception when the response of a request is not acceptable.
*
* By default an exception will be thrown for all status codes from 400 to 599.
*
* @author Joel Wurtz <joel.wurtz@gmail.com>
*/
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;
}
}
44 changes: 44 additions & 0 deletions src/Plugin/HeaderAppendPlugin.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?php

namespace Http\Client\Common\Plugin;

use Http\Client\Common\Plugin;
use Psr\Http\Message\RequestInterface;

/**
* Adds headers to the request.
* If the header already exists the value will be appended to the current value.
*
* This only makes sense for headers that can have multiple values like 'Forwarded'
*
* @link https://en.wikipedia.org/wiki/List_of_HTTP_header_fields
*
* @author Soufiane Ghzal <sghzal@gmail.com>
*/
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);
}
}
42 changes: 42 additions & 0 deletions src/Plugin/HeaderDefaultsPlugin.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php

namespace Http\Client\Common\Plugin;

use Http\Client\Common\Plugin;
use Psr\Http\Message\RequestInterface;

/**
* Set default values for the request headers.
* If a given header already exists the value wont be replaced and the request wont be changed.
*
* @author Soufiane Ghzal <sghzal@gmail.com>
*/
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);
}
}
41 changes: 41 additions & 0 deletions src/Plugin/HeaderRemovePlugin.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php

namespace Http\Client\Common\Plugin;

use Http\Client\Common\Plugin;
use Psr\Http\Message\RequestInterface;

/**
* Removes headers from the request.
*
* @author Soufiane Ghzal <sghzal@gmail.com>
*/
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);
}
}
40 changes: 40 additions & 0 deletions src/Plugin/HeaderSetPlugin.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php

namespace Http\Client\Common\Plugin;

use Http\Client\Common\Plugin;
use Psr\Http\Message\RequestInterface;

/**
* Set headers to the request.
* If the header does not exist it wil be set, if the header already exists it will be replaced.
*
* @author Soufiane Ghzal <sghzal@gmail.com>
*/
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);
}
}
49 changes: 49 additions & 0 deletions src/Plugin/HistoryPlugin.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php

namespace Http\Client\Common\Plugin;

use Http\Client\Common\Plugin;
use Http\Client\Exception;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;

/**
* Record HTTP calls.
*
* @author Joel Wurtz <joel.wurtz@gmail.com>
*/
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;
});
}
}
31 changes: 31 additions & 0 deletions src/Plugin/Journal.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

namespace Http\Client\Common\Plugin;

use Http\Client\Exception;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;

/**
* Records history of HTTP calls.
*
* @author Joel Wurtz <joel.wurtz@gmail.com>
*/
interface Journal
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we move this to message instead? Are there any use cases where this might make sense?

Copy link
Contributor

@dbu dbu Mar 30, 2016 via email

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reason I suggested message is that it is mostly message related. Out of four/three dependencies three/two are messages. Implementations will probably never be in any of our repositories, because (as you said) they are mostly framework specific.

Maybe we could move the Journal together with the history plugin to it's own repository? It depends if we find any use cases for Journal apart from History plugin. Since it is relatively simple, I would say we don't necessarily need to think about any scenario.

Copy link
Contributor

@dbu dbu Mar 30, 2016 via email

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

{
/**
* 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);
}
270 changes: 270 additions & 0 deletions src/Plugin/RedirectPlugin.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,270 @@
<?php

namespace Http\Client\Common\Plugin;

use Http\Client\Common\Exception\CircularRedirectionException;
use Http\Client\Common\Exception\MultipleRedirectionException;
use Http\Client\Common\Plugin;
use Http\Client\Exception\HttpException;
use Psr\Http\Message\MessageInterface;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\UriInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

/**
* Follow redirections.
*
* @author Joel Wurtz <joel.wurtz@gmail.com>
*/
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;
}
}
47 changes: 47 additions & 0 deletions src/Plugin/RequestMatcherPlugin.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php

namespace Http\Client\Common\Plugin;

use Http\Client\Common\Plugin;
use Http\Message\RequestMatcher;
use Psr\Http\Message\RequestInterface;

/**
* Apply a delegated plugin based on a request match.
*
* @author Márk Sági-Kazár <mark.sagikazar@gmail.com>
*/
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);
}
}
84 changes: 84 additions & 0 deletions src/Plugin/RetryPlugin.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
<?php

namespace Http\Client\Common\Plugin;

use Http\Client\Common\Plugin;
use Http\Client\Exception;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

/**
* Retry the request if an exception is thrown.
*
* By default will retry only one time.
*
* @author Joel Wurtz <joel.wurtz@gmail.com>
*/
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();
});
}
}
151 changes: 151 additions & 0 deletions src/PluginClient.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
<?php

namespace Http\Client\Common;

use Http\Client\Common\Exception\LoopException;
use Http\Client\Exception as HttplugException;
use Http\Client\HttpAsyncClient;
use Http\Client\HttpClient;
use Http\Promise\FulfilledPromise;
use Http\Promise\RejectedPromise;
use Psr\Http\Message\RequestInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

/**
* The client managing plugins and providing a decorator around HTTP Clients.
*
* @author Joel Wurtz <joel.wurtz@gmail.com>
*/
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;
}
}