Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 9c1c93b

Browse files
committedMay 3, 2016
Move plugins from php-http/plugins to common
Update todo Remove options resolver dependency Revert: Remove options resolver dependency Add AddHostPlugin Add Authentication plugins Add content length plugin Add final warning to plugins Add cookie plugin Add decoder plugin Add error plugin Add header plugins Add history plugin Fix namespace import order Add redirect plugin Add retry plugin Make plugin classes final, related #18 Fix throw keyword Manually apply php-http/plugins#68 Manually apply and close php-http/plugins#65
1 parent 3e25b9f commit 9c1c93b

40 files changed

+2837
-1
lines changed
 

‎CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
- Add a flexible http client providing both contract, and only emulating what's necessary
88
- HTTP Client Router: route requests to underlying clients
9+
- Plugin client and core plugins moved here from `php-http/plugins`
910

1011
### Deprecated
1112

‎composer.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@
1414
"php": ">=5.4",
1515
"php-http/httplug": "^1.0",
1616
"php-http/message-factory": "^1.0",
17-
"php-http/message": "^1.2"
17+
"php-http/message": "^1.2",
18+
"symfony/options-resolver": "^2.6|^3.0"
1819
},
1920
"require-dev": {
2021
"phpspec/phpspec": "^2.4",

‎spec/FlexibleHttpClientSpec.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ function it_does_not_emulate_a_client($client, RequestInterface $syncRequest, Re
7979
{
8080
$client->implement('Http\Client\HttpClient');
8181
$client->implement('Http\Client\HttpAsyncClient');
82+
8283
$client->sendRequest($syncRequest)->shouldBeCalled();
8384
$client->sendRequest($asyncRequest)->shouldNotBeCalled();
8485
$client->sendAsyncRequest($asyncRequest)->shouldBeCalled();

‎spec/Plugin/AddHostPluginSpec.php

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
<?php
2+
3+
namespace spec\Http\Client\Common\Plugin;
4+
5+
use Http\Message\StreamFactory;
6+
use Http\Message\UriFactory;
7+
use Http\Promise\FulfilledPromise;
8+
use Psr\Http\Message\RequestInterface;
9+
use Psr\Http\Message\ResponseInterface;
10+
use Psr\Http\Message\StreamInterface;
11+
use Psr\Http\Message\UriInterface;
12+
use PhpSpec\ObjectBehavior;
13+
14+
class AddHostPluginSpec extends ObjectBehavior
15+
{
16+
function let(UriInterface $uri)
17+
{
18+
$this->beConstructedWith($uri);
19+
}
20+
21+
function it_is_initializable(UriInterface $uri)
22+
{
23+
$uri->getHost()->shouldBeCalled()->willReturn('example.com');
24+
25+
$this->shouldHaveType('Http\Client\Common\Plugin\AddHostPlugin');
26+
}
27+
28+
function it_is_a_plugin(UriInterface $uri)
29+
{
30+
$uri->getHost()->shouldBeCalled()->willReturn('example.com');
31+
32+
$this->shouldImplement('Http\Client\Common\Plugin');
33+
}
34+
35+
function it_adds_domain(
36+
RequestInterface $request,
37+
UriInterface $host,
38+
UriInterface $uri
39+
) {
40+
$host->getScheme()->shouldBeCalled()->willReturn('http://');
41+
$host->getHost()->shouldBeCalled()->willReturn('example.com');
42+
43+
$request->getUri()->shouldBeCalled()->willReturn($uri);
44+
$request->withUri($uri)->shouldBeCalled()->willReturn($request);
45+
46+
$uri->withScheme('http://')->shouldBeCalled()->willReturn($uri);
47+
$uri->withHost('example.com')->shouldBeCalled()->willReturn($uri);
48+
$uri->getHost()->shouldBeCalled()->willReturn('');
49+
50+
$this->beConstructedWith($host);
51+
$this->handleRequest($request, function () {}, function () {});
52+
}
53+
54+
function it_replaces_domain(
55+
RequestInterface $request,
56+
UriInterface $host,
57+
UriInterface $uri
58+
) {
59+
$host->getScheme()->shouldBeCalled()->willReturn('http://');
60+
$host->getHost()->shouldBeCalled()->willReturn('example.com');
61+
62+
$request->getUri()->shouldBeCalled()->willReturn($uri);
63+
$request->withUri($uri)->shouldBeCalled()->willReturn($request);
64+
65+
$uri->withScheme('http://')->shouldBeCalled()->willReturn($uri);
66+
$uri->withHost('example.com')->shouldBeCalled()->willReturn($uri);
67+
68+
69+
$this->beConstructedWith($host, ['replace' => true]);
70+
$this->handleRequest($request, function () {}, function () {});
71+
}
72+
73+
function it_does_nothing_when_domain_exists(
74+
RequestInterface $request,
75+
UriInterface $host,
76+
UriInterface $uri
77+
) {
78+
$request->getUri()->shouldBeCalled()->willReturn($uri);
79+
$uri->getHost()->shouldBeCalled()->willReturn('default.com');
80+
81+
$this->beConstructedWith($host);
82+
$this->handleRequest($request, function () {}, function () {});
83+
}
84+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
<?php
2+
3+
namespace spec\Http\Client\Common\Plugin;
4+
5+
use Http\Message\Authentication;
6+
use Http\Promise\Promise;
7+
use Psr\Http\Message\RequestInterface;
8+
use PhpSpec\ObjectBehavior;
9+
use Prophecy\Argument;
10+
11+
class AuthenticationPluginSpec extends ObjectBehavior
12+
{
13+
function let(Authentication $authentication)
14+
{
15+
$this->beConstructedWith($authentication);
16+
}
17+
18+
function it_is_initializable(Authentication $authentication)
19+
{
20+
$this->shouldHaveType('Http\Client\Common\Plugin\AuthenticationPlugin');
21+
}
22+
23+
function it_is_a_plugin()
24+
{
25+
$this->shouldImplement('Http\Client\Common\Plugin');
26+
}
27+
28+
function it_sends_an_authenticated_request(Authentication $authentication, RequestInterface $notAuthedRequest, RequestInterface $authedRequest, Promise $promise)
29+
{
30+
$authentication->authenticate($notAuthedRequest)->willReturn($authedRequest);
31+
32+
$next = function (RequestInterface $request) use($authedRequest, $promise) {
33+
if (Argument::is($authedRequest->getWrappedObject())->scoreArgument($request)) {
34+
return $promise->getWrappedObject();
35+
}
36+
};
37+
38+
$this->handleRequest($notAuthedRequest, $next, function () {})->shouldReturn($promise);
39+
}
40+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
<?php
2+
3+
namespace spec\Http\Client\Common\Plugin;
4+
5+
use PhpSpec\Exception\Example\SkippingException;
6+
use Psr\Http\Message\RequestInterface;
7+
use Psr\Http\Message\StreamInterface;
8+
use PhpSpec\ObjectBehavior;
9+
use Prophecy\Argument;
10+
11+
class ContentLengthPluginSpec extends ObjectBehavior
12+
{
13+
function it_is_initializable()
14+
{
15+
$this->shouldHaveType('Http\Client\Common\Plugin\ContentLengthPlugin');
16+
}
17+
18+
function it_is_a_plugin()
19+
{
20+
$this->shouldImplement('Http\Client\Common\Plugin');
21+
}
22+
23+
function it_adds_content_length_header(RequestInterface $request, StreamInterface $stream)
24+
{
25+
$request->hasHeader('Content-Length')->shouldBeCalled()->willReturn(false);
26+
$request->getBody()->shouldBeCalled()->willReturn($stream);
27+
$stream->getSize()->shouldBeCalled()->willReturn(100);
28+
$request->withHeader('Content-Length', 100)->shouldBeCalled()->willReturn($request);
29+
30+
$this->handleRequest($request, function () {}, function () {});
31+
}
32+
33+
function it_streams_chunked_if_no_size(RequestInterface $request, StreamInterface $stream)
34+
{
35+
if(defined('HHVM_VERSION')) {
36+
throw new SkippingException('Skipping test on hhvm, as there is no chunk encoding on hhvm');
37+
}
38+
39+
$request->hasHeader('Content-Length')->shouldBeCalled()->willReturn(false);
40+
$request->getBody()->shouldBeCalled()->willReturn($stream);
41+
42+
$stream->getSize()->shouldBeCalled()->willReturn(null);
43+
$request->withBody(Argument::type('Http\Message\Encoding\ChunkStream'))->shouldBeCalled()->willReturn($request);
44+
$request->withAddedHeader('Transfer-Encoding', 'chunked')->shouldBeCalled()->willReturn($request);
45+
46+
$this->handleRequest($request, function () {}, function () {});
47+
}
48+
}

‎spec/Plugin/CookiePluginSpec.php

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
<?php
2+
3+
namespace spec\Http\Client\Common\Plugin;
4+
5+
use Http\Promise\FulfilledPromise;
6+
use Http\Message\Cookie;
7+
use Http\Message\CookieJar;
8+
use Http\Promise\Promise;
9+
use Psr\Http\Message\RequestInterface;
10+
use Psr\Http\Message\ResponseInterface;
11+
use Psr\Http\Message\UriInterface;
12+
use PhpSpec\ObjectBehavior;
13+
use Prophecy\Argument;
14+
15+
class CookiePluginSpec extends ObjectBehavior
16+
{
17+
private $cookieJar;
18+
19+
function let()
20+
{
21+
$this->cookieJar = new CookieJar();
22+
23+
$this->beConstructedWith($this->cookieJar);
24+
}
25+
26+
function it_is_initializable()
27+
{
28+
$this->shouldHaveType('Http\Client\Common\Plugin\CookiePlugin');
29+
}
30+
31+
function it_is_a_plugin()
32+
{
33+
$this->shouldImplement('Http\Client\Common\Plugin');
34+
}
35+
36+
function it_loads_cookie(RequestInterface $request, UriInterface $uri, Promise $promise)
37+
{
38+
$cookie = new Cookie('name', 'value', 86400, 'test.com');
39+
$this->cookieJar->addCookie($cookie);
40+
41+
$request->getUri()->willReturn($uri);
42+
$uri->getHost()->willReturn('test.com');
43+
$uri->getPath()->willReturn('/');
44+
45+
$request->withAddedHeader('Cookie', 'name=value')->willReturn($request);
46+
47+
$this->handleRequest($request, function (RequestInterface $requestReceived) use ($request, $promise) {
48+
if (Argument::is($requestReceived)->scoreArgument($request->getWrappedObject())) {
49+
return $promise->getWrappedObject();
50+
}
51+
}, function () {});
52+
}
53+
54+
function it_does_not_load_cookie_if_expired(RequestInterface $request, UriInterface $uri, Promise $promise)
55+
{
56+
$cookie = new Cookie('name', 'value', null, 'test.com', false, false, null, (new \DateTime())->modify('-1 day'));
57+
$this->cookieJar->addCookie($cookie);
58+
59+
$request->withAddedHeader('Cookie', 'name=value')->shouldNotBeCalled();
60+
61+
$this->handleRequest($request, function (RequestInterface $requestReceived) use ($request, $promise) {
62+
if (Argument::is($requestReceived)->scoreArgument($request->getWrappedObject())) {
63+
return $promise->getWrappedObject();
64+
}
65+
}, function () {});
66+
}
67+
68+
function it_does_not_load_cookie_if_domain_does_not_match(RequestInterface $request, UriInterface $uri, Promise $promise)
69+
{
70+
$cookie = new Cookie('name', 'value', 86400, 'test2.com');
71+
$this->cookieJar->addCookie($cookie);
72+
73+
$request->getUri()->willReturn($uri);
74+
$uri->getHost()->willReturn('test.com');
75+
76+
$request->withAddedHeader('Cookie', 'name=value')->shouldNotBeCalled();
77+
78+
$this->handleRequest($request, function (RequestInterface $requestReceived) use ($request, $promise) {
79+
if (Argument::is($requestReceived)->scoreArgument($request->getWrappedObject())) {
80+
return $promise->getWrappedObject();
81+
}
82+
}, function () {});
83+
}
84+
85+
function it_does_not_load_cookie_if_path_does_not_match(RequestInterface $request, UriInterface $uri, Promise $promise)
86+
{
87+
$cookie = new Cookie('name', 'value', 86400, 'test.com', '/sub');
88+
$this->cookieJar->addCookie($cookie);
89+
90+
$request->getUri()->willReturn($uri);
91+
$uri->getHost()->willReturn('test.com');
92+
$uri->getPath()->willReturn('/');
93+
94+
$request->withAddedHeader('Cookie', 'name=value')->shouldNotBeCalled();
95+
96+
$this->handleRequest($request, function (RequestInterface $requestReceived) use ($request, $promise) {
97+
if (Argument::is($requestReceived)->scoreArgument($request->getWrappedObject())) {
98+
return $promise->getWrappedObject();
99+
}
100+
}, function () {});
101+
}
102+
103+
function it_does_not_load_cookie_when_cookie_is_secure(RequestInterface $request, UriInterface $uri, Promise $promise)
104+
{
105+
$cookie = new Cookie('name', 'value', 86400, 'test.com', null, true);
106+
$this->cookieJar->addCookie($cookie);
107+
108+
$request->getUri()->willReturn($uri);
109+
$uri->getHost()->willReturn('test.com');
110+
$uri->getPath()->willReturn('/');
111+
$uri->getScheme()->willReturn('http');
112+
113+
$request->withAddedHeader('Cookie', 'name=value')->shouldNotBeCalled();
114+
115+
$this->handleRequest($request, function (RequestInterface $requestReceived) use ($request, $promise) {
116+
if (Argument::is($requestReceived)->scoreArgument($request->getWrappedObject())) {
117+
return $promise->getWrappedObject();
118+
}
119+
}, function () {});
120+
}
121+
122+
function it_loads_cookie_when_cookie_is_secure(RequestInterface $request, UriInterface $uri, Promise $promise)
123+
{
124+
$cookie = new Cookie('name', 'value', 86400, 'test.com', null, true);
125+
$this->cookieJar->addCookie($cookie);
126+
127+
$request->getUri()->willReturn($uri);
128+
$uri->getHost()->willReturn('test.com');
129+
$uri->getPath()->willReturn('/');
130+
$uri->getScheme()->willReturn('https');
131+
132+
$request->withAddedHeader('Cookie', 'name=value')->willReturn($request);
133+
134+
$this->handleRequest($request, function (RequestInterface $requestReceived) use ($request, $promise) {
135+
if (Argument::is($requestReceived)->scoreArgument($request->getWrappedObject())) {
136+
return $promise->getWrappedObject();
137+
}
138+
}, function () {});
139+
}
140+
141+
function it_saves_cookie(RequestInterface $request, ResponseInterface $response, UriInterface $uri)
142+
{
143+
$next = function () use ($response) {
144+
return new FulfilledPromise($response->getWrappedObject());
145+
};
146+
147+
$response->hasHeader('Set-Cookie')->willReturn(true);
148+
$response->getHeader('Set-Cookie')->willReturn([
149+
'cookie=value; expires=Tuesday, 31-Mar-99 07:42:12 GMT; Max-Age=60; path=/; domain=test.com; secure; HttpOnly'
150+
]);
151+
152+
$request->getUri()->willReturn($uri);
153+
$uri->getHost()->willReturn('test.com');
154+
$uri->getPath()->willReturn('/');
155+
156+
$promise = $this->handleRequest($request, $next, function () {});
157+
$promise->shouldHaveType('Http\Promise\Promise');
158+
$promise->wait()->shouldReturnAnInstanceOf('Psr\Http\Message\ResponseInterface');
159+
}
160+
161+
function it_throws_exception_on_invalid_expires_date(
162+
RequestInterface $request,
163+
ResponseInterface $response,
164+
UriInterface $uri
165+
) {
166+
$next = function () use ($response) {
167+
return new FulfilledPromise($response->getWrappedObject());
168+
};
169+
170+
$response->hasHeader('Set-Cookie')->willReturn(true);
171+
$response->getHeader('Set-Cookie')->willReturn([
172+
'cookie=value; expires=i-am-an-invalid-date;'
173+
]);
174+
175+
$request->getUri()->willReturn($uri);
176+
$uri->getHost()->willReturn('test.com');
177+
$uri->getPath()->willReturn('/');
178+
179+
$promise = $this->handleRequest($request, $next, function () {});
180+
$promise->shouldReturnAnInstanceOf('Http\Promise\RejectedPromise');
181+
$promise->shouldThrow('Http\Client\Exception\TransferException')->duringWait();
182+
}
183+
}

‎spec/Plugin/DecoderPluginSpec.php

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
<?php
2+
3+
namespace spec\Http\Client\Common\Plugin;
4+
5+
use Http\Promise\FulfilledPromise;
6+
use Psr\Http\Message\RequestInterface;
7+
use Psr\Http\Message\ResponseInterface;
8+
use Psr\Http\Message\StreamInterface;
9+
use PhpSpec\Exception\Example\SkippingException;
10+
use PhpSpec\ObjectBehavior;
11+
use Prophecy\Argument;
12+
13+
class DecoderPluginSpec extends ObjectBehavior
14+
{
15+
function it_is_initializable()
16+
{
17+
$this->shouldHaveType('Http\Client\Common\Plugin\DecoderPlugin');
18+
}
19+
20+
function it_is_a_plugin()
21+
{
22+
$this->shouldImplement('Http\Client\Common\Plugin');
23+
}
24+
25+
function it_decodes(RequestInterface $request, ResponseInterface $response, StreamInterface $stream)
26+
{
27+
if(defined('HHVM_VERSION')) {
28+
throw new SkippingException('Skipping test on hhvm, as there is no chunk encoding on hhvm');
29+
}
30+
31+
$request->withHeader('TE', ['gzip', 'deflate', 'compress', 'chunked'])->shouldBeCalled()->willReturn($request);
32+
$request->withHeader('Accept-Encoding', ['gzip', 'deflate', 'compress'])->shouldBeCalled()->willReturn($request);
33+
$next = function () use($response) {
34+
return new FulfilledPromise($response->getWrappedObject());
35+
};
36+
37+
$response->hasHeader('Transfer-Encoding')->willReturn(true);
38+
$response->getHeader('Transfer-Encoding')->willReturn(['chunked']);
39+
$response->getBody()->willReturn($stream);
40+
$response->withBody(Argument::type('Http\Message\Encoding\DechunkStream'))->willReturn($response);
41+
$response->withHeader('Transfer-Encoding', [])->willReturn($response);
42+
$response->hasHeader('Content-Encoding')->willReturn(false);
43+
44+
$stream->isReadable()->willReturn(true);
45+
$stream->isWritable()->willReturn(false);
46+
$stream->eof()->willReturn(false);
47+
48+
$this->handleRequest($request, $next, function () {});
49+
}
50+
51+
function it_decodes_gzip(RequestInterface $request, ResponseInterface $response, StreamInterface $stream)
52+
{
53+
$request->withHeader('TE', ['gzip', 'deflate', 'compress', 'chunked'])->shouldBeCalled()->willReturn($request);
54+
$request->withHeader('Accept-Encoding', ['gzip', 'deflate', 'compress'])->shouldBeCalled()->willReturn($request);
55+
$next = function () use($response) {
56+
return new FulfilledPromise($response->getWrappedObject());
57+
};
58+
59+
$response->hasHeader('Transfer-Encoding')->willReturn(false);
60+
$response->hasHeader('Content-Encoding')->willReturn(true);
61+
$response->getHeader('Content-Encoding')->willReturn(['gzip']);
62+
$response->getBody()->willReturn($stream);
63+
$response->withBody(Argument::type('Http\Message\Encoding\GzipDecodeStream'))->willReturn($response);
64+
$response->withHeader('Content-Encoding', [])->willReturn($response);
65+
66+
$stream->isReadable()->willReturn(true);
67+
$stream->isWritable()->willReturn(false);
68+
$stream->eof()->willReturn(false);
69+
70+
$this->handleRequest($request, $next, function () {});
71+
}
72+
73+
function it_decodes_deflate(RequestInterface $request, ResponseInterface $response, StreamInterface $stream)
74+
{
75+
$request->withHeader('TE', ['gzip', 'deflate', 'compress', 'chunked'])->shouldBeCalled()->willReturn($request);
76+
$request->withHeader('Accept-Encoding', ['gzip', 'deflate', 'compress'])->shouldBeCalled()->willReturn($request);
77+
$next = function () use($response) {
78+
return new FulfilledPromise($response->getWrappedObject());
79+
};
80+
81+
$response->hasHeader('Transfer-Encoding')->willReturn(false);
82+
$response->hasHeader('Content-Encoding')->willReturn(true);
83+
$response->getHeader('Content-Encoding')->willReturn(['deflate']);
84+
$response->getBody()->willReturn($stream);
85+
$response->withBody(Argument::type('Http\Message\Encoding\InflateStream'))->willReturn($response);
86+
$response->withHeader('Content-Encoding', [])->willReturn($response);
87+
88+
$stream->isReadable()->willReturn(true);
89+
$stream->isWritable()->willReturn(false);
90+
$stream->eof()->willReturn(false);
91+
92+
$this->handleRequest($request, $next, function () {});
93+
}
94+
95+
function it_decodes_inflate(RequestInterface $request, ResponseInterface $response, StreamInterface $stream)
96+
{
97+
$request->withHeader('TE', ['gzip', 'deflate', 'compress', 'chunked'])->shouldBeCalled()->willReturn($request);
98+
$request->withHeader('Accept-Encoding', ['gzip', 'deflate', 'compress'])->shouldBeCalled()->willReturn($request);
99+
$next = function () use($response) {
100+
return new FulfilledPromise($response->getWrappedObject());
101+
};
102+
103+
$response->hasHeader('Transfer-Encoding')->willReturn(false);
104+
$response->hasHeader('Content-Encoding')->willReturn(true);
105+
$response->getHeader('Content-Encoding')->willReturn(['compress']);
106+
$response->getBody()->willReturn($stream);
107+
$response->withBody(Argument::type('Http\Message\Encoding\DecompressStream'))->willReturn($response);
108+
$response->withHeader('Content-Encoding', [])->willReturn($response);
109+
110+
$stream->isReadable()->willReturn(true);
111+
$stream->isWritable()->willReturn(false);
112+
$stream->eof()->willReturn(false);
113+
114+
$this->handleRequest($request, $next, function () {});
115+
}
116+
117+
function it_does_not_decode_with_content_encoding(RequestInterface $request, ResponseInterface $response)
118+
{
119+
$this->beConstructedWith(['use_content_encoding' => false]);
120+
121+
$request->withHeader('TE', ['gzip', 'deflate', 'compress', 'chunked'])->shouldBeCalled()->willReturn($request);
122+
$request->withHeader('Accept-Encoding', ['gzip', 'deflate', 'compress'])->shouldNotBeCalled();
123+
$next = function () use($response) {
124+
return new FulfilledPromise($response->getWrappedObject());
125+
};
126+
127+
$response->hasHeader('Transfer-Encoding')->willReturn(false);
128+
$response->hasHeader('Content-Encoding')->shouldNotBeCalled();
129+
130+
$this->handleRequest($request, $next, function () {});
131+
}
132+
}

‎spec/Plugin/ErrorPluginSpec.php

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
<?php
2+
3+
namespace spec\Http\Client\Common\Plugin;
4+
5+
use Http\Promise\FulfilledPromise;
6+
use Psr\Http\Message\RequestInterface;
7+
use Psr\Http\Message\ResponseInterface;
8+
use PhpSpec\ObjectBehavior;
9+
use Prophecy\Argument;
10+
11+
class ErrorPluginSpec extends ObjectBehavior
12+
{
13+
function it_is_initializable()
14+
{
15+
$this->beAnInstanceOf('Http\Client\Common\Plugin\ErrorPlugin');
16+
}
17+
18+
function it_is_a_plugin()
19+
{
20+
$this->shouldImplement('Http\Client\Common\Plugin');
21+
}
22+
23+
function it_throw_client_error_exception_on_4xx_error(RequestInterface $request, ResponseInterface $response)
24+
{
25+
$response->getStatusCode()->willReturn('400');
26+
$response->getReasonPhrase()->willReturn('Bad request');
27+
28+
$next = function (RequestInterface $receivedRequest) use($request, $response) {
29+
if (Argument::is($request->getWrappedObject())->scoreArgument($receivedRequest)) {
30+
return new FulfilledPromise($response->getWrappedObject());
31+
}
32+
};
33+
34+
$promise = $this->handleRequest($request, $next, function () {});
35+
$promise->shouldReturnAnInstanceOf('Http\Promise\RejectedPromise');
36+
$promise->shouldThrow('Http\Client\Common\Exception\ClientErrorException')->duringWait();
37+
}
38+
39+
function it_throw_server_error_exception_on_5xx_error(RequestInterface $request, ResponseInterface $response)
40+
{
41+
$response->getStatusCode()->willReturn('500');
42+
$response->getReasonPhrase()->willReturn('Server error');
43+
44+
$next = function (RequestInterface $receivedRequest) use($request, $response) {
45+
if (Argument::is($request->getWrappedObject())->scoreArgument($receivedRequest)) {
46+
return new FulfilledPromise($response->getWrappedObject());
47+
}
48+
};
49+
50+
$promise = $this->handleRequest($request, $next, function () {});
51+
$promise->shouldReturnAnInstanceOf('Http\Promise\RejectedPromise');
52+
$promise->shouldThrow('Http\Client\Common\Exception\ServerErrorException')->duringWait();
53+
}
54+
55+
function it_returns_response(RequestInterface $request, ResponseInterface $response)
56+
{
57+
$response->getStatusCode()->willReturn('200');
58+
59+
$next = function (RequestInterface $receivedRequest) use($request, $response) {
60+
if (Argument::is($request->getWrappedObject())->scoreArgument($receivedRequest)) {
61+
return new FulfilledPromise($response->getWrappedObject());
62+
}
63+
};
64+
65+
$this->handleRequest($request, $next, function () {})->shouldReturnAnInstanceOf('Http\Promise\FulfilledPromise');
66+
}
67+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<?php
2+
3+
namespace spec\Http\Client\Common\Plugin;
4+
5+
use PhpSpec\Exception\Example\SkippingException;
6+
use Psr\Http\Message\RequestInterface;
7+
use Psr\Http\Message\StreamInterface;
8+
use PhpSpec\ObjectBehavior;
9+
use Prophecy\Argument;
10+
11+
class HeaderAppendPluginSpec extends ObjectBehavior
12+
{
13+
public function it_is_initializable()
14+
{
15+
$this->beConstructedWith([]);
16+
$this->shouldHaveType('Http\Client\Common\Plugin\HeaderAppendPlugin');
17+
}
18+
19+
public function it_is_a_plugin()
20+
{
21+
$this->beConstructedWith([]);
22+
$this->shouldImplement('Http\Client\Common\Plugin');
23+
}
24+
25+
public function it_appends_the_header(RequestInterface $request)
26+
{
27+
$this->beConstructedWith([
28+
'foo'=>'bar',
29+
'baz'=>'qux'
30+
]);
31+
32+
$request->withAddedHeader('foo', 'bar')->shouldBeCalled()->willReturn($request);
33+
$request->withAddedHeader('baz', 'qux')->shouldBeCalled()->willReturn($request);
34+
35+
$this->handleRequest($request, function () {}, function () {});
36+
}
37+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
<?php
2+
3+
namespace spec\Http\Client\Common\Plugin;
4+
5+
use PhpSpec\Exception\Example\SkippingException;
6+
use Psr\Http\Message\RequestInterface;
7+
use Psr\Http\Message\StreamInterface;
8+
use PhpSpec\ObjectBehavior;
9+
use Prophecy\Argument;
10+
11+
class HeaderDefaultsPluginSpec extends ObjectBehavior
12+
{
13+
public function it_is_initializable()
14+
{
15+
$this->beConstructedWith([]);
16+
$this->shouldHaveType('Http\Client\Common\Plugin\HeaderDefaultsPlugin');
17+
}
18+
19+
public function it_is_a_plugin()
20+
{
21+
$this->beConstructedWith([]);
22+
$this->shouldImplement('Http\Client\Common\Plugin');
23+
}
24+
25+
public function it_sets_the_default_header(RequestInterface $request)
26+
{
27+
$this->beConstructedWith([
28+
'foo' => 'bar',
29+
'baz' => 'qux'
30+
]);
31+
32+
$request->hasHeader('foo')->shouldBeCalled()->willReturn(false);
33+
$request->withHeader('foo', 'bar')->shouldBeCalled()->willReturn($request);
34+
$request->hasHeader('baz')->shouldBeCalled()->willReturn(true);
35+
36+
$this->handleRequest($request, function () {}, function () {});
37+
}
38+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
<?php
2+
3+
namespace spec\Http\Client\Common\Plugin;
4+
5+
use PhpSpec\Exception\Example\SkippingException;
6+
use Psr\Http\Message\RequestInterface;
7+
use Psr\Http\Message\StreamInterface;
8+
use PhpSpec\ObjectBehavior;
9+
use Prophecy\Argument;
10+
11+
class HeaderRemovePluginSpec extends ObjectBehavior
12+
{
13+
public function it_is_initializable()
14+
{
15+
$this->beConstructedWith([]);
16+
$this->shouldHaveType('Http\Client\Common\Plugin\HeaderRemovePlugin');
17+
}
18+
19+
public function it_is_a_plugin()
20+
{
21+
$this->beConstructedWith([]);
22+
$this->shouldImplement('Http\Client\Common\Plugin');
23+
}
24+
25+
public function it_removes_the_header(RequestInterface $request)
26+
{
27+
$this->beConstructedWith([
28+
'foo',
29+
'baz'
30+
]);
31+
32+
$request->hasHeader('foo')->shouldBeCalled()->willReturn(false);
33+
34+
$request->hasHeader('baz')->shouldBeCalled()->willReturn(true);
35+
$request->withoutHeader('baz')->shouldBeCalled()->willReturn($request);
36+
37+
$this->handleRequest($request, function () {}, function () {});
38+
}
39+
}

‎spec/Plugin/HeaderSetPluginSpec.php

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<?php
2+
3+
namespace spec\Http\Client\Common\Plugin;
4+
5+
use PhpSpec\Exception\Example\SkippingException;
6+
use Psr\Http\Message\RequestInterface;
7+
use Psr\Http\Message\StreamInterface;
8+
use PhpSpec\ObjectBehavior;
9+
use Prophecy\Argument;
10+
11+
class HeaderSetPluginSpec extends ObjectBehavior
12+
{
13+
public function it_is_initializable()
14+
{
15+
$this->beConstructedWith([]);
16+
$this->shouldHaveType('Http\Client\Common\Plugin\HeaderSetPlugin');
17+
}
18+
19+
public function it_is_a_plugin()
20+
{
21+
$this->beConstructedWith([]);
22+
$this->shouldImplement('Http\Client\Common\Plugin');
23+
}
24+
25+
public function it_set_the_header(RequestInterface $request)
26+
{
27+
$this->beConstructedWith([
28+
'foo'=>'bar',
29+
'baz'=>'qux'
30+
]);
31+
32+
$request->withHeader('foo', 'bar')->shouldBeCalled()->willReturn($request);
33+
$request->withHeader('baz', 'qux')->shouldBeCalled()->willReturn($request);
34+
35+
$this->handleRequest($request, function () {}, function () {});
36+
}
37+
}

‎spec/Plugin/HistoryPluginSpec.php

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
<?php
2+
3+
namespace spec\Http\Client\Common\Plugin;
4+
5+
use Http\Client\Exception\TransferException;
6+
use Http\Client\Common\Plugin\Journal;
7+
use Http\Promise\FulfilledPromise;
8+
use Http\Promise\RejectedPromise;
9+
use Psr\Http\Message\RequestInterface;
10+
use Psr\Http\Message\ResponseInterface;
11+
use PhpSpec\ObjectBehavior;
12+
use Prophecy\Argument;
13+
14+
class HistoryPluginSpec extends ObjectBehavior
15+
{
16+
function let(Journal $journal)
17+
{
18+
$this->beConstructedWith($journal);
19+
}
20+
21+
function it_is_initializable()
22+
{
23+
$this->beAnInstanceOf('Http\Client\Common\Plugin\JournalPlugin');
24+
}
25+
26+
function it_is_a_plugin()
27+
{
28+
$this->shouldImplement('Http\Client\Common\Plugin');
29+
}
30+
31+
function it_records_success(Journal $journal, RequestInterface $request, ResponseInterface $response)
32+
{
33+
$next = function (RequestInterface $receivedRequest) use($request, $response) {
34+
if (Argument::is($request->getWrappedObject())->scoreArgument($receivedRequest)) {
35+
return new FulfilledPromise($response->getWrappedObject());
36+
}
37+
};
38+
39+
$journal->addSuccess($request, $response)->shouldBeCalled();
40+
41+
$this->handleRequest($request, $next, function () {});
42+
}
43+
44+
function it_records_failure(Journal $journal, RequestInterface $request)
45+
{
46+
$exception = new TransferException();
47+
$next = function (RequestInterface $receivedRequest) use($request, $exception) {
48+
if (Argument::is($request->getWrappedObject())->scoreArgument($receivedRequest)) {
49+
return new RejectedPromise($exception);
50+
}
51+
};
52+
53+
$journal->addFailure($request, $exception)->shouldBeCalled();
54+
55+
$this->handleRequest($request, $next, function () {});
56+
}
57+
}

‎spec/Plugin/RedirectPluginSpec.php

Lines changed: 406 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
<?php
2+
3+
namespace spec\Http\Client\Common\Plugin;
4+
5+
use Http\Client\Common\Plugin;
6+
use Http\Message\RequestMatcher;
7+
use Http\Promise\Promise;
8+
use Psr\Http\Message\RequestInterface;
9+
use PhpSpec\ObjectBehavior;
10+
use Prophecy\Argument;
11+
12+
class RequestMatcherPluginSpec extends ObjectBehavior
13+
{
14+
function let(RequestMatcher $requestMatcher, Plugin $plugin)
15+
{
16+
$this->beConstructedWith($requestMatcher, $plugin);
17+
}
18+
19+
function it_is_initializable()
20+
{
21+
$this->shouldHaveType('Http\Client\Common\Plugin\RequestMatcherPlugin');
22+
}
23+
24+
function it_is_a_plugin()
25+
{
26+
$this->shouldImplement('Http\Client\Common\Plugin');
27+
}
28+
29+
function it_matches_a_request_and_delegates_to_plugin(
30+
RequestInterface $request,
31+
RequestMatcher $requestMatcher,
32+
Plugin $plugin
33+
) {
34+
$requestMatcher->matches($request)->willReturn(true);
35+
$plugin->handleRequest($request, Argument::type('callable'), Argument::type('callable'))->shouldBeCalled();
36+
37+
$this->handleRequest($request, function () {}, function () {});
38+
}
39+
40+
function it_does_not_match_a_request(
41+
RequestInterface $request,
42+
RequestMatcher $requestMatcher,
43+
Plugin $plugin,
44+
Promise $promise
45+
) {
46+
$requestMatcher->matches($request)->willReturn(false);
47+
$plugin->handleRequest($request, Argument::type('callable'), Argument::type('callable'))->shouldNotBeCalled();
48+
49+
$next = function (RequestInterface $request) use($promise) {
50+
return $promise->getWrappedObject();
51+
};
52+
53+
$this->handleRequest($request, $next, function () {})->shouldReturn($promise);
54+
}
55+
}

‎spec/Plugin/RetryPluginSpec.php

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
<?php
2+
3+
namespace spec\Http\Client\Common\Plugin;
4+
5+
use Http\Client\Exception;
6+
use Http\Promise\FulfilledPromise;
7+
use Http\Promise\RejectedPromise;
8+
use Psr\Http\Message\RequestInterface;
9+
use Psr\Http\Message\ResponseInterface;
10+
use PhpSpec\ObjectBehavior;
11+
use Prophecy\Argument;
12+
13+
class RetryPluginSpec extends ObjectBehavior
14+
{
15+
function it_is_initializable()
16+
{
17+
$this->shouldHaveType('Http\Client\Common\Plugin\RetryPlugin');
18+
}
19+
20+
function it_is_a_plugin()
21+
{
22+
$this->shouldImplement('Http\Client\Common\Plugin');
23+
}
24+
25+
function it_returns_response(RequestInterface $request, ResponseInterface $response)
26+
{
27+
$next = function (RequestInterface $receivedRequest) use($request, $response) {
28+
if (Argument::is($request->getWrappedObject())->scoreArgument($receivedRequest)) {
29+
return new FulfilledPromise($response->getWrappedObject());
30+
}
31+
};
32+
33+
$this->handleRequest($request, $next, function () {})->shouldReturnAnInstanceOf('Http\Promise\FulfilledPromise');
34+
}
35+
36+
function it_throws_exception_on_multiple_exceptions(RequestInterface $request)
37+
{
38+
$exception1 = new Exception\NetworkException('Exception 1', $request->getWrappedObject());
39+
$exception2 = new Exception\NetworkException('Exception 2', $request->getWrappedObject());
40+
41+
$count = 0;
42+
$next = function (RequestInterface $receivedRequest) use($request, $exception1, $exception2, &$count) {
43+
$count++;
44+
if (Argument::is($request->getWrappedObject())->scoreArgument($receivedRequest)) {
45+
if ($count == 1) {
46+
return new RejectedPromise($exception1);
47+
}
48+
49+
if ($count == 2) {
50+
return new RejectedPromise($exception2);
51+
}
52+
}
53+
};
54+
55+
$promise = $this->handleRequest($request, $next, function () {});
56+
$promise->shouldReturnAnInstanceOf('Http\Promise\RejectedPromise');
57+
$promise->shouldThrow($exception2)->duringWait();
58+
}
59+
60+
function it_returns_response_on_second_try(RequestInterface $request, ResponseInterface $response)
61+
{
62+
$exception = new Exception\NetworkException('Exception 1', $request->getWrappedObject());
63+
64+
$count = 0;
65+
$next = function (RequestInterface $receivedRequest) use($request, $exception, $response, &$count) {
66+
$count++;
67+
if (Argument::is($request->getWrappedObject())->scoreArgument($receivedRequest)) {
68+
if ($count == 1) {
69+
return new RejectedPromise($exception);
70+
}
71+
72+
if ($count == 2) {
73+
return new FulfilledPromise($response->getWrappedObject());
74+
}
75+
}
76+
};
77+
78+
$promise = $this->handleRequest($request, $next, function () {});
79+
$promise->shouldReturnAnInstanceOf('Http\Promise\FulfilledPromise');
80+
$promise->wait()->shouldReturn($response);
81+
}
82+
83+
function it_does_not_keep_history_of_old_failure(RequestInterface $request, ResponseInterface $response)
84+
{
85+
$exception = new Exception\NetworkException('Exception 1', $request->getWrappedObject());
86+
87+
$count = 0;
88+
$next = function (RequestInterface $receivedRequest) use($request, $exception, $response, &$count) {
89+
$count++;
90+
if (Argument::is($request->getWrappedObject())->scoreArgument($receivedRequest)) {
91+
if ($count % 2 == 1) {
92+
return new RejectedPromise($exception);
93+
}
94+
95+
if ($count % 2 == 0) {
96+
return new FulfilledPromise($response->getWrappedObject());
97+
}
98+
}
99+
};
100+
101+
$this->handleRequest($request, $next, function () {})->shouldReturnAnInstanceOf('Http\Promise\FulfilledPromise');
102+
$this->handleRequest($request, $next, function () {})->shouldReturnAnInstanceOf('Http\Promise\FulfilledPromise');
103+
}
104+
}

‎spec/PluginClientSpec.php

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
<?php
2+
3+
namespace spec\Http\Client\Common;
4+
5+
use Http\Client\HttpAsyncClient;
6+
use Http\Client\HttpClient;
7+
use Http\Client\Common\FlexibleHttpClient;
8+
use Http\Client\Common\Plugin;
9+
use Http\Promise\Promise;
10+
use Prophecy\Argument;
11+
use Psr\Http\Message\RequestInterface;
12+
use Psr\Http\Message\ResponseInterface;
13+
use PhpSpec\ObjectBehavior;
14+
15+
class PluginClientSpec extends ObjectBehavior
16+
{
17+
function let(HttpClient $httpClient)
18+
{
19+
$this->beConstructedWith($httpClient);
20+
}
21+
22+
function it_is_initializable()
23+
{
24+
$this->shouldHaveType('Http\Client\Common\PluginClient');
25+
}
26+
27+
function it_is_an_http_client()
28+
{
29+
$this->shouldImplement('Http\Client\HttpClient');
30+
}
31+
32+
function it_is_an_http_async_client()
33+
{
34+
$this->shouldImplement('Http\Client\HttpAsyncClient');
35+
}
36+
37+
function it_sends_request_with_underlying_client(HttpClient $httpClient, RequestInterface $request, ResponseInterface $response)
38+
{
39+
$httpClient->sendRequest($request)->willReturn($response);
40+
41+
$this->sendRequest($request)->shouldReturn($response);
42+
}
43+
44+
function it_sends_async_request_with_underlying_client(HttpAsyncClient $httpAsyncClient, RequestInterface $request, Promise $promise)
45+
{
46+
$httpAsyncClient->sendAsyncRequest($request)->willReturn($promise);
47+
48+
$this->beConstructedWith($httpAsyncClient);
49+
$this->sendAsyncRequest($request)->shouldReturn($promise);
50+
}
51+
52+
function it_sends_async_request_if_no_send_request(HttpAsyncClient $httpAsyncClient, RequestInterface $request, ResponseInterface $response, Promise $promise)
53+
{
54+
$this->beConstructedWith($httpAsyncClient);
55+
$httpAsyncClient->sendAsyncRequest($request)->willReturn($promise);
56+
$promise->wait()->willReturn($response);
57+
58+
$this->sendRequest($request)->shouldReturn($response);
59+
}
60+
61+
function it_prefers_send_request($client, RequestInterface $request, ResponseInterface $response)
62+
{
63+
$client->implement('Http\Client\HttpClient');
64+
$client->implement('Http\Client\HttpAsyncClient');
65+
66+
$client->sendRequest($request)->willReturn($response);
67+
68+
$this->beConstructedWith($client);
69+
70+
$this->sendRequest($request)->shouldReturn($response);
71+
}
72+
73+
function it_throws_loop_exception(HttpClient $httpClient, RequestInterface $request, Plugin $plugin)
74+
{
75+
$plugin
76+
->handleRequest(
77+
$request,
78+
Argument::type('callable'),
79+
Argument::type('callable')
80+
)
81+
->will(function ($args) {
82+
return $args[2]($args[0]);
83+
})
84+
;
85+
86+
$this->beConstructedWith($httpClient, [$plugin]);
87+
88+
$this->shouldThrow('Http\Client\Common\Exception\LoopException')->duringSendRequest($request);
89+
}
90+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<?php
2+
3+
namespace Http\Client\Common\Exception;
4+
5+
use Http\Client\Exception\HttpException;
6+
7+
/**
8+
* Thrown when circular redirection is detected.
9+
*
10+
* @author Joel Wurtz <joel.wurtz@gmail.com>
11+
*/
12+
class CircularRedirectionException extends HttpException
13+
{
14+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<?php
2+
3+
namespace Http\Client\Common\Exception;
4+
5+
use Http\Client\Exception\HttpException;
6+
7+
/**
8+
* Thrown when there is a client error (4xx).
9+
*
10+
* @author Joel Wurtz <joel.wurtz@gmail.com>
11+
*/
12+
class ClientErrorException extends HttpException
13+
{
14+
}

‎src/Exception/LoopException.php

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<?php
2+
3+
namespace Http\Client\Common\Exception;
4+
5+
use Http\Client\Exception\RequestException;
6+
7+
/**
8+
* Thrown when the Plugin Client detects an endless loop.
9+
*
10+
* @author Joel Wurtz <joel.wurtz@gmail.com>
11+
*/
12+
class LoopException extends RequestException
13+
{
14+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<?php
2+
3+
namespace Http\Client\Common\Exception;
4+
5+
use Http\Client\Exception\HttpException;
6+
7+
/**
8+
* Redirect location cannot be chosen.
9+
*
10+
* @author Joel Wurtz <joel.wurtz@gmail.com>
11+
*/
12+
class MultipleRedirectionException extends HttpException
13+
{
14+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<?php
2+
3+
namespace Http\Client\Common\Exception;
4+
5+
use Http\Client\Exception\HttpException;
6+
7+
/**
8+
* Thrown when there is a server error (5xx).
9+
*
10+
* @author Joel Wurtz <joel.wurtz@gmail.com>
11+
*/
12+
class ServerErrorException extends HttpException
13+
{
14+
}

‎src/Plugin.php

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
<?php
2+
3+
namespace Http\Client\Common;
4+
5+
use Http\Promise\Promise;
6+
use Psr\Http\Message\RequestInterface;
7+
8+
/**
9+
* A plugin is a middleware to transform the request and/or the response.
10+
*
11+
* The plugin can:
12+
* - break the chain and return a response
13+
* - dispatch the request to the next middleware
14+
* - restart the request
15+
*
16+
* @author Joel Wurtz <joel.wurtz@gmail.com>
17+
*/
18+
interface Plugin
19+
{
20+
/**
21+
* Handle the request and return the response coming from the next callable.
22+
*
23+
* @param RequestInterface $request
24+
* @param callable $next Next middleware in the chain, the request is passed as the first argument
25+
* @param callable $first First middleware in the chain, used to to restart a request
26+
*
27+
* @return Promise
28+
*/
29+
public function handleRequest(RequestInterface $request, callable $next, callable $first);
30+
}

‎src/Plugin/AddHostPlugin.php

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
<?php
2+
3+
namespace Http\Client\Common\Plugin;
4+
5+
use Http\Client\Common\Plugin;
6+
use Psr\Http\Message\RequestInterface;
7+
use Psr\Http\Message\UriInterface;
8+
use Symfony\Component\OptionsResolver\OptionsResolver;
9+
10+
/**
11+
* Add schema and host to a request. Can be set to overwrite the schema and host if desired.
12+
*
13+
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
14+
*/
15+
final class AddHostPlugin implements Plugin
16+
{
17+
/**
18+
* @var UriInterface
19+
*/
20+
private $host;
21+
22+
/**
23+
* @var bool
24+
*/
25+
private $replace;
26+
27+
/**
28+
* @param UriInterface $host
29+
* @param array $config {
30+
*
31+
* @var bool $replace True will replace all hosts, false will only add host when none is specified.
32+
* }
33+
*/
34+
public function __construct(UriInterface $host, array $config = [])
35+
{
36+
if ($host->getHost() === '') {
37+
throw new \LogicException('Host can not be empty');
38+
}
39+
40+
$this->host = $host;
41+
42+
$resolver = new OptionsResolver();
43+
$this->configureOptions($resolver);
44+
$options = $resolver->resolve($config);
45+
46+
$this->replace = $options['replace'];
47+
}
48+
49+
/**
50+
* {@inheritdoc}
51+
*/
52+
public function handleRequest(RequestInterface $request, callable $next, callable $first)
53+
{
54+
if ($this->replace || $request->getUri()->getHost() === '') {
55+
$uri = $request->getUri()->withHost($this->host->getHost());
56+
$uri = $uri->withScheme($this->host->getScheme());
57+
58+
$request = $request->withUri($uri);
59+
}
60+
61+
return $next($request);
62+
}
63+
64+
/**
65+
* @param OptionsResolver $resolver
66+
*/
67+
private function configureOptions(OptionsResolver $resolver)
68+
{
69+
$resolver->setDefaults([
70+
'replace' => false,
71+
]);
72+
$resolver->setAllowedTypes('replace', 'bool');
73+
}
74+
}

‎src/Plugin/AuthenticationPlugin.php

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
<?php
2+
3+
namespace Http\Client\Common\Plugin;
4+
5+
use Http\Client\Common\Plugin;
6+
use Http\Message\Authentication;
7+
use Psr\Http\Message\RequestInterface;
8+
9+
/**
10+
* Send an authenticated request.
11+
*
12+
* @author Joel Wurtz <joel.wurtz@gmail.com>
13+
*/
14+
final class AuthenticationPlugin implements Plugin
15+
{
16+
/**
17+
* @var Authentication An authentication system
18+
*/
19+
private $authentication;
20+
21+
/**
22+
* @param Authentication $authentication
23+
*/
24+
public function __construct(Authentication $authentication)
25+
{
26+
$this->authentication = $authentication;
27+
}
28+
29+
/**
30+
* {@inheritdoc}
31+
*/
32+
public function handleRequest(RequestInterface $request, callable $next, callable $first)
33+
{
34+
$request = $this->authentication->authenticate($request);
35+
36+
return $next($request);
37+
}
38+
}

‎src/Plugin/ContentLengthPlugin.php

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
<?php
2+
3+
namespace Http\Client\Common\Plugin;
4+
5+
use Http\Client\Common\Plugin;
6+
use Http\Message\Encoding\ChunkStream;
7+
use Psr\Http\Message\RequestInterface;
8+
9+
/**
10+
* Allow to set the correct content length header on the request or to transfer it as a chunk if not possible.
11+
*
12+
* @author Joel Wurtz <joel.wurtz@gmail.com>
13+
*/
14+
final class ContentLengthPlugin implements Plugin
15+
{
16+
/**
17+
* {@inheritdoc}
18+
*/
19+
public function handleRequest(RequestInterface $request, callable $next, callable $first)
20+
{
21+
if (!$request->hasHeader('Content-Length')) {
22+
$stream = $request->getBody();
23+
24+
// Cannot determine the size so we use a chunk stream
25+
if (null === $stream->getSize()) {
26+
$stream = new ChunkStream($stream);
27+
$request = $request->withBody($stream);
28+
$request = $request->withAddedHeader('Transfer-Encoding', 'chunked');
29+
} else {
30+
$request = $request->withHeader('Content-Length', $stream->getSize());
31+
}
32+
}
33+
34+
return $next($request);
35+
}
36+
}

‎src/Plugin/CookiePlugin.php

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
<?php
2+
3+
namespace Http\Client\Common\Plugin;
4+
5+
use Http\Client\Common\Plugin;
6+
use Http\Client\Exception\TransferException;
7+
use Http\Message\Cookie;
8+
use Http\Message\CookieJar;
9+
use Psr\Http\Message\RequestInterface;
10+
use Psr\Http\Message\ResponseInterface;
11+
12+
/**
13+
* Handle request cookies.
14+
*
15+
* @author Joel Wurtz <joel.wurtz@gmail.com>
16+
*/
17+
final class CookiePlugin implements Plugin
18+
{
19+
/**
20+
* Cookie storage.
21+
*
22+
* @var CookieJar
23+
*/
24+
private $cookieJar;
25+
26+
/**
27+
* @param CookieJar $cookieJar
28+
*/
29+
public function __construct(CookieJar $cookieJar)
30+
{
31+
$this->cookieJar = $cookieJar;
32+
}
33+
34+
/**
35+
* {@inheritdoc}
36+
*/
37+
public function handleRequest(RequestInterface $request, callable $next, callable $first)
38+
{
39+
foreach ($this->cookieJar->getCookies() as $cookie) {
40+
if ($cookie->isExpired()) {
41+
continue;
42+
}
43+
44+
if (!$cookie->matchDomain($request->getUri()->getHost())) {
45+
continue;
46+
}
47+
48+
if (!$cookie->matchPath($request->getUri()->getPath())) {
49+
continue;
50+
}
51+
52+
if ($cookie->isSecure() && ($request->getUri()->getScheme() !== 'https')) {
53+
continue;
54+
}
55+
56+
$request = $request->withAddedHeader('Cookie', sprintf('%s=%s', $cookie->getName(), $cookie->getValue()));
57+
}
58+
59+
return $next($request)->then(function (ResponseInterface $response) use ($request) {
60+
if ($response->hasHeader('Set-Cookie')) {
61+
$setCookies = $response->getHeader('Set-Cookie');
62+
63+
foreach ($setCookies as $setCookie) {
64+
$cookie = $this->createCookie($request, $setCookie);
65+
66+
// Cookie invalid do not use it
67+
if (null === $cookie) {
68+
continue;
69+
}
70+
71+
// Restrict setting cookie from another domain
72+
if (false === strpos($cookie->getDomain(), $request->getUri()->getHost())) {
73+
continue;
74+
}
75+
76+
$this->cookieJar->addCookie($cookie);
77+
}
78+
}
79+
80+
return $response;
81+
});
82+
}
83+
84+
/**
85+
* Creates a cookie from a string.
86+
*
87+
* @param RequestInterface $request
88+
* @param $setCookie
89+
*
90+
* @return Cookie|null
91+
*
92+
* @throws TransferException
93+
*/
94+
private function createCookie(RequestInterface $request, $setCookie)
95+
{
96+
$parts = array_map('trim', explode(';', $setCookie));
97+
98+
if (empty($parts) || !strpos($parts[0], '=')) {
99+
return;
100+
}
101+
102+
list($name, $cookieValue) = $this->createValueKey(array_shift($parts));
103+
104+
$maxAge = null;
105+
$expires = null;
106+
$domain = $request->getUri()->getHost();
107+
$path = $request->getUri()->getPath();
108+
$secure = false;
109+
$httpOnly = false;
110+
111+
// Add the cookie pieces into the parsed data array
112+
foreach ($parts as $part) {
113+
list($key, $value) = $this->createValueKey($part);
114+
115+
switch (strtolower($key)) {
116+
case 'expires':
117+
$expires = \DateTime::createFromFormat(\DateTime::COOKIE, $value);
118+
119+
if (true !== ($expires instanceof \DateTime)) {
120+
throw new TransferException(
121+
sprintf(
122+
'Cookie header `%s` expires value `%s` could not be converted to date',
123+
$name,
124+
$value
125+
)
126+
);
127+
}
128+
break;
129+
130+
case 'max-age':
131+
$maxAge = (int) $value;
132+
break;
133+
134+
case 'domain':
135+
$domain = $value;
136+
break;
137+
138+
case 'path':
139+
$path = $value;
140+
break;
141+
142+
case 'secure':
143+
$secure = true;
144+
break;
145+
146+
case 'httponly':
147+
$httpOnly = true;
148+
break;
149+
}
150+
}
151+
152+
return new Cookie($name, $cookieValue, $maxAge, $domain, $path, $secure, $httpOnly, $expires);
153+
}
154+
155+
/**
156+
* Separates key/value pair from cookie.
157+
*
158+
* @param $part
159+
*
160+
* @return array
161+
*/
162+
private function createValueKey($part)
163+
{
164+
$parts = explode('=', $part, 2);
165+
$key = trim($parts[0]);
166+
$value = isset($parts[1]) ? trim($parts[1]) : true;
167+
168+
return [$key, $value];
169+
}
170+
}

‎src/Plugin/DecoderPlugin.php

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
<?php
2+
3+
namespace Http\Client\Common\Plugin;
4+
5+
use Http\Client\Common\Plugin;
6+
use Http\Message\Encoding;
7+
use Psr\Http\Message\RequestInterface;
8+
use Psr\Http\Message\ResponseInterface;
9+
use Psr\Http\Message\StreamInterface;
10+
use Symfony\Component\OptionsResolver\OptionsResolver;
11+
12+
/**
13+
* Allow to decode response body with a chunk, deflate, compress or gzip encoding.
14+
*
15+
* If zlib is not installed, only chunked encoding can be handled.
16+
*
17+
* If Content-Encoding is not disabled, the plugin will add an Accept-Encoding header for the encoding methods it supports.
18+
*
19+
* @author Joel Wurtz <joel.wurtz@gmail.com>
20+
*/
21+
final class DecoderPlugin implements Plugin
22+
{
23+
/**
24+
* @var bool Whether this plugin decode stream with value in the Content-Encoding header (default to true).
25+
*
26+
* If set to false only the Transfer-Encoding header will be used.
27+
*/
28+
private $useContentEncoding;
29+
30+
/**
31+
* @param array $config {
32+
*
33+
* @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).
34+
* }
35+
*/
36+
public function __construct(array $config = [])
37+
{
38+
$resolver = new OptionsResolver();
39+
$resolver->setDefaults([
40+
'use_content_encoding' => true,
41+
]);
42+
$resolver->setAllowedTypes('use_content_encoding', 'bool');
43+
$options = $resolver->resolve($config);
44+
45+
$this->useContentEncoding = $options['use_content_encoding'];
46+
}
47+
48+
/**
49+
* {@inheritdoc}
50+
*/
51+
public function handleRequest(RequestInterface $request, callable $next, callable $first)
52+
{
53+
$encodings = extension_loaded('zlib') ? ['gzip', 'deflate', 'compress'] : ['identity'];
54+
55+
if ($this->useContentEncoding) {
56+
$request = $request->withHeader('Accept-Encoding', $encodings);
57+
}
58+
$encodings[] = 'chunked';
59+
$request = $request->withHeader('TE', $encodings);
60+
61+
return $next($request)->then(function (ResponseInterface $response) {
62+
return $this->decodeResponse($response);
63+
});
64+
}
65+
66+
/**
67+
* Decode a response body given its Transfer-Encoding or Content-Encoding value.
68+
*
69+
* @param ResponseInterface $response Response to decode
70+
*
71+
* @return ResponseInterface New response decoded
72+
*/
73+
private function decodeResponse(ResponseInterface $response)
74+
{
75+
$response = $this->decodeOnEncodingHeader('Transfer-Encoding', $response);
76+
77+
if ($this->useContentEncoding) {
78+
$response = $this->decodeOnEncodingHeader('Content-Encoding', $response);
79+
}
80+
81+
return $response;
82+
}
83+
84+
/**
85+
* Decode a response on a specific header (content encoding or transfer encoding mainly).
86+
*
87+
* @param string $headerName Name of the header
88+
* @param ResponseInterface $response Response
89+
*
90+
* @return ResponseInterface A new instance of the response decoded
91+
*/
92+
private function decodeOnEncodingHeader($headerName, ResponseInterface $response)
93+
{
94+
if ($response->hasHeader($headerName)) {
95+
$encodings = $response->getHeader($headerName);
96+
$newEncodings = [];
97+
98+
while ($encoding = array_pop($encodings)) {
99+
$stream = $this->decorateStream($encoding, $response->getBody());
100+
101+
if (false === $stream) {
102+
array_unshift($newEncodings, $encoding);
103+
104+
continue;
105+
}
106+
107+
$response = $response->withBody($stream);
108+
}
109+
110+
$response = $response->withHeader($headerName, $newEncodings);
111+
}
112+
113+
return $response;
114+
}
115+
116+
/**
117+
* Decorate a stream given an encoding.
118+
*
119+
* @param string $encoding
120+
* @param StreamInterface $stream
121+
*
122+
* @return StreamInterface|false A new stream interface or false if encoding is not supported
123+
*/
124+
private function decorateStream($encoding, StreamInterface $stream)
125+
{
126+
if (strtolower($encoding) == 'chunked') {
127+
return new Encoding\DechunkStream($stream);
128+
}
129+
130+
if (strtolower($encoding) == 'compress') {
131+
return new Encoding\DecompressStream($stream);
132+
}
133+
134+
if (strtolower($encoding) == 'deflate') {
135+
return new Encoding\InflateStream($stream);
136+
}
137+
138+
if (strtolower($encoding) == 'gzip') {
139+
return new Encoding\GzipDecodeStream($stream);
140+
}
141+
142+
return false;
143+
}
144+
}

‎src/Plugin/ErrorPlugin.php

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
<?php
2+
3+
namespace Http\Client\Common\Plugin;
4+
5+
use Http\Client\Common\Exception\ClientErrorException;
6+
use Http\Client\Common\Exception\ServerErrorException;
7+
use Http\Client\Common\Plugin;
8+
use Psr\Http\Message\RequestInterface;
9+
use Psr\Http\Message\ResponseInterface;
10+
11+
/**
12+
* Throw exception when the response of a request is not acceptable.
13+
*
14+
* By default an exception will be thrown for all status codes from 400 to 599.
15+
*
16+
* @author Joel Wurtz <joel.wurtz@gmail.com>
17+
*/
18+
final class ErrorPlugin implements Plugin
19+
{
20+
/**
21+
* {@inheritdoc}
22+
*/
23+
public function handleRequest(RequestInterface $request, callable $next, callable $first)
24+
{
25+
$promise = $next($request);
26+
27+
return $promise->then(function (ResponseInterface $response) use ($request) {
28+
return $this->transformResponseToException($request, $response);
29+
});
30+
}
31+
32+
/**
33+
* Transform response to an error if possible.
34+
*
35+
* @param RequestInterface $request Request of the call
36+
* @param ResponseInterface $response Response of the call
37+
*
38+
* @throws ClientErrorException If response status code is a 4xx
39+
* @throws ServerErrorException If response status code is a 5xx
40+
*
41+
* @return ResponseInterface If status code is not in 4xx or 5xx return response
42+
*/
43+
protected function transformResponseToException(RequestInterface $request, ResponseInterface $response)
44+
{
45+
if ($response->getStatusCode() >= 400 && $response->getStatusCode() < 500) {
46+
throw new ClientErrorException($response->getReasonPhrase(), $request, $response);
47+
}
48+
49+
if ($response->getStatusCode() >= 500 && $response->getStatusCode() < 600) {
50+
throw new ServerErrorException($response->getReasonPhrase(), $request, $response);
51+
}
52+
53+
return $response;
54+
}
55+
}

‎src/Plugin/HeaderAppendPlugin.php

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
<?php
2+
3+
namespace Http\Client\Common\Plugin;
4+
5+
use Http\Client\Common\Plugin;
6+
use Psr\Http\Message\RequestInterface;
7+
8+
/**
9+
* Adds headers to the request.
10+
* If the header already exists the value will be appended to the current value.
11+
*
12+
* This only makes sense for headers that can have multiple values like 'Forwarded'
13+
*
14+
* @link https://en.wikipedia.org/wiki/List_of_HTTP_header_fields
15+
*
16+
* @author Soufiane Ghzal <sghzal@gmail.com>
17+
*/
18+
final class HeaderAppendPlugin implements Plugin
19+
{
20+
/**
21+
* @var array
22+
*/
23+
private $headers = [];
24+
25+
/**
26+
* @param array $headers headers to add to the request
27+
*/
28+
public function __construct(array $headers)
29+
{
30+
$this->headers = $headers;
31+
}
32+
33+
/**
34+
* {@inheritdoc}
35+
*/
36+
public function handleRequest(RequestInterface $request, callable $next, callable $first)
37+
{
38+
foreach ($this->headers as $header => $headerValue) {
39+
$request = $request->withAddedHeader($header, $headerValue);
40+
}
41+
42+
return $next($request);
43+
}
44+
}

‎src/Plugin/HeaderDefaultsPlugin.php

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
<?php
2+
3+
namespace Http\Client\Common\Plugin;
4+
5+
use Http\Client\Common\Plugin;
6+
use Psr\Http\Message\RequestInterface;
7+
8+
/**
9+
* Set default values for the request headers.
10+
* If a given header already exists the value wont be replaced and the request wont be changed.
11+
*
12+
* @author Soufiane Ghzal <sghzal@gmail.com>
13+
*/
14+
final class HeaderDefaultsPlugin implements Plugin
15+
{
16+
/**
17+
* @var array
18+
*/
19+
private $headers = [];
20+
21+
/**
22+
* @param array $headers headers to set to the request
23+
*/
24+
public function __construct(array $headers)
25+
{
26+
$this->headers = $headers;
27+
}
28+
29+
/**
30+
* {@inheritdoc}
31+
*/
32+
public function handleRequest(RequestInterface $request, callable $next, callable $first)
33+
{
34+
foreach ($this->headers as $header => $headerValue) {
35+
if (!$request->hasHeader($header)) {
36+
$request = $request->withHeader($header, $headerValue);
37+
}
38+
}
39+
40+
return $next($request);
41+
}
42+
}

‎src/Plugin/HeaderRemovePlugin.php

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<?php
2+
3+
namespace Http\Client\Common\Plugin;
4+
5+
use Http\Client\Common\Plugin;
6+
use Psr\Http\Message\RequestInterface;
7+
8+
/**
9+
* Removes headers from the request.
10+
*
11+
* @author Soufiane Ghzal <sghzal@gmail.com>
12+
*/
13+
final class HeaderRemovePlugin implements Plugin
14+
{
15+
/**
16+
* @var array
17+
*/
18+
private $headers = [];
19+
20+
/**
21+
* @param array $headers headers to remove from the request
22+
*/
23+
public function __construct(array $headers)
24+
{
25+
$this->headers = $headers;
26+
}
27+
28+
/**
29+
* {@inheritdoc}
30+
*/
31+
public function handleRequest(RequestInterface $request, callable $next, callable $first)
32+
{
33+
foreach ($this->headers as $header) {
34+
if ($request->hasHeader($header)) {
35+
$request = $request->withoutHeader($header);
36+
}
37+
}
38+
39+
return $next($request);
40+
}
41+
}

‎src/Plugin/HeaderSetPlugin.php

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
<?php
2+
3+
namespace Http\Client\Common\Plugin;
4+
5+
use Http\Client\Common\Plugin;
6+
use Psr\Http\Message\RequestInterface;
7+
8+
/**
9+
* Set headers to the request.
10+
* If the header does not exist it wil be set, if the header already exists it will be replaced.
11+
*
12+
* @author Soufiane Ghzal <sghzal@gmail.com>
13+
*/
14+
final class HeaderSetPlugin implements Plugin
15+
{
16+
/**
17+
* @var array
18+
*/
19+
private $headers = [];
20+
21+
/**
22+
* @param array $headers headers to set to the request
23+
*/
24+
public function __construct(array $headers)
25+
{
26+
$this->headers = $headers;
27+
}
28+
29+
/**
30+
* {@inheritdoc}
31+
*/
32+
public function handleRequest(RequestInterface $request, callable $next, callable $first)
33+
{
34+
foreach ($this->headers as $header => $headerValue) {
35+
$request = $request->withHeader($header, $headerValue);
36+
}
37+
38+
return $next($request);
39+
}
40+
}

‎src/Plugin/HistoryPlugin.php

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
<?php
2+
3+
namespace Http\Client\Common\Plugin;
4+
5+
use Http\Client\Common\Plugin;
6+
use Http\Client\Exception;
7+
use Psr\Http\Message\RequestInterface;
8+
use Psr\Http\Message\ResponseInterface;
9+
10+
/**
11+
* Record HTTP calls.
12+
*
13+
* @author Joel Wurtz <joel.wurtz@gmail.com>
14+
*/
15+
final class HistoryPlugin implements Plugin
16+
{
17+
/**
18+
* Journal use to store request / responses / exception.
19+
*
20+
* @var Journal
21+
*/
22+
private $journal;
23+
24+
/**
25+
* @param Journal $journal
26+
*/
27+
public function __construct(Journal $journal)
28+
{
29+
$this->journal = $journal;
30+
}
31+
32+
/**
33+
* {@inheritdoc}
34+
*/
35+
public function handleRequest(RequestInterface $request, callable $next, callable $first)
36+
{
37+
$journal = $this->journal;
38+
39+
return $next($request)->then(function (ResponseInterface $response) use ($request, $journal) {
40+
$journal->addSuccess($request, $response);
41+
42+
return $response;
43+
}, function (Exception $exception) use ($request, $journal) {
44+
$journal->addFailure($request, $exception);
45+
46+
throw $exception;
47+
});
48+
}
49+
}

‎src/Plugin/Journal.php

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<?php
2+
3+
namespace Http\Client\Common\Plugin;
4+
5+
use Http\Client\Exception;
6+
use Psr\Http\Message\RequestInterface;
7+
use Psr\Http\Message\ResponseInterface;
8+
9+
/**
10+
* Records history of HTTP calls.
11+
*
12+
* @author Joel Wurtz <joel.wurtz@gmail.com>
13+
*/
14+
interface Journal
15+
{
16+
/**
17+
* Record a successful call.
18+
*
19+
* @param RequestInterface $request Request use to make the call
20+
* @param ResponseInterface $response Response returned by the call
21+
*/
22+
public function addSuccess(RequestInterface $request, ResponseInterface $response);
23+
24+
/**
25+
* Record a failed call.
26+
*
27+
* @param RequestInterface $request Request use to make the call
28+
* @param Exception $exception Exception returned by the call
29+
*/
30+
public function addFailure(RequestInterface $request, Exception $exception);
31+
}

‎src/Plugin/RedirectPlugin.php

Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
1+
<?php
2+
3+
namespace Http\Client\Common\Plugin;
4+
5+
use Http\Client\Common\Exception\CircularRedirectionException;
6+
use Http\Client\Common\Exception\MultipleRedirectionException;
7+
use Http\Client\Common\Plugin;
8+
use Http\Client\Exception\HttpException;
9+
use Psr\Http\Message\MessageInterface;
10+
use Psr\Http\Message\RequestInterface;
11+
use Psr\Http\Message\ResponseInterface;
12+
use Psr\Http\Message\UriInterface;
13+
use Symfony\Component\OptionsResolver\OptionsResolver;
14+
15+
/**
16+
* Follow redirections.
17+
*
18+
* @author Joel Wurtz <joel.wurtz@gmail.com>
19+
*/
20+
class RedirectPlugin implements Plugin
21+
{
22+
/**
23+
* Rule on how to redirect, change method for the new request.
24+
*
25+
* @var array
26+
*/
27+
protected $redirectCodes = [
28+
300 => [
29+
'switch' => [
30+
'unless' => ['GET', 'HEAD'],
31+
'to' => 'GET',
32+
],
33+
'multiple' => true,
34+
'permanent' => false,
35+
],
36+
301 => [
37+
'switch' => [
38+
'unless' => ['GET', 'HEAD'],
39+
'to' => 'GET',
40+
],
41+
'multiple' => false,
42+
'permanent' => true,
43+
],
44+
302 => [
45+
'switch' => [
46+
'unless' => ['GET', 'HEAD'],
47+
'to' => 'GET',
48+
],
49+
'multiple' => false,
50+
'permanent' => false,
51+
],
52+
303 => [
53+
'switch' => [
54+
'unless' => ['GET', 'HEAD'],
55+
'to' => 'GET',
56+
],
57+
'multiple' => false,
58+
'permanent' => false,
59+
],
60+
307 => [
61+
'switch' => false,
62+
'multiple' => false,
63+
'permanent' => false,
64+
],
65+
308 => [
66+
'switch' => false,
67+
'multiple' => false,
68+
'permanent' => true,
69+
],
70+
];
71+
72+
/**
73+
* Determine how header should be preserved from old request.
74+
*
75+
* @var bool|array
76+
*
77+
* true will keep all previous headers (default value)
78+
* false will ditch all previous headers
79+
* string[] will keep only headers with the specified names
80+
*/
81+
protected $preserveHeader;
82+
83+
/**
84+
* Store all previous redirect from 301 / 308 status code.
85+
*
86+
* @var array
87+
*/
88+
protected $redirectStorage = [];
89+
90+
/**
91+
* Whether the location header must be directly used for a multiple redirection status code (300).
92+
*
93+
* @var bool
94+
*/
95+
protected $useDefaultForMultiple;
96+
97+
/**
98+
* @var array
99+
*/
100+
protected $circularDetection = [];
101+
102+
/**
103+
* @param array $config {
104+
*
105+
* @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.
106+
* @var bool $use_default_for_multiple Whether the location header must be directly used for a multiple redirection status code (300).
107+
* }
108+
*/
109+
public function __construct(array $config = [])
110+
{
111+
$resolver = new OptionsResolver();
112+
$resolver->setDefaults([
113+
'preserve_header' => true,
114+
'use_default_for_multiple' => true,
115+
]);
116+
$resolver->setAllowedTypes('preserve_header', ['bool', 'array']);
117+
$resolver->setAllowedTypes('use_default_for_multiple', 'bool');
118+
$resolver->setNormalizer('preserve_header', function (OptionsResolver $resolver, $value) {
119+
if (is_bool($value) && false === $value) {
120+
return [];
121+
}
122+
123+
return $value;
124+
});
125+
$options = $resolver->resolve($config);
126+
127+
$this->preserveHeader = $options['preserve_header'];
128+
$this->useDefaultForMultiple = $options['use_default_for_multiple'];
129+
}
130+
131+
/**
132+
* {@inheritdoc}
133+
*/
134+
public function handleRequest(RequestInterface $request, callable $next, callable $first)
135+
{
136+
// Check in storage
137+
if (array_key_exists($request->getRequestTarget(), $this->redirectStorage)) {
138+
$uri = $this->redirectStorage[$request->getRequestTarget()]['uri'];
139+
$statusCode = $this->redirectStorage[$request->getRequestTarget()]['status'];
140+
$redirectRequest = $this->buildRedirectRequest($request, $uri, $statusCode);
141+
142+
return $first($redirectRequest);
143+
}
144+
145+
return $next($request)->then(function (ResponseInterface $response) use ($request, $first) {
146+
$statusCode = $response->getStatusCode();
147+
148+
if (!array_key_exists($statusCode, $this->redirectCodes)) {
149+
return $response;
150+
}
151+
152+
$uri = $this->createUri($response, $request);
153+
$redirectRequest = $this->buildRedirectRequest($request, $uri, $statusCode);
154+
$chainIdentifier = spl_object_hash((object) $first);
155+
156+
if (!array_key_exists($chainIdentifier, $this->circularDetection)) {
157+
$this->circularDetection[$chainIdentifier] = [];
158+
}
159+
160+
$this->circularDetection[$chainIdentifier][] = $request->getRequestTarget();
161+
162+
if (in_array($redirectRequest->getRequestTarget(), $this->circularDetection[$chainIdentifier])) {
163+
throw new CircularRedirectionException('Circular redirection detected', $request, $response);
164+
}
165+
166+
if ($this->redirectCodes[$statusCode]['permanent']) {
167+
$this->redirectStorage[$request->getRequestTarget()] = [
168+
'uri' => $uri,
169+
'status' => $statusCode,
170+
];
171+
}
172+
173+
// Call redirect request in synchrone
174+
$redirectPromise = $first($redirectRequest);
175+
176+
return $redirectPromise->wait();
177+
});
178+
}
179+
180+
/**
181+
* Builds the redirect request.
182+
*
183+
* @param RequestInterface $request Original request
184+
* @param UriInterface $uri New uri
185+
* @param int $statusCode Status code from the redirect response
186+
*
187+
* @return MessageInterface|RequestInterface
188+
*/
189+
protected function buildRedirectRequest(RequestInterface $request, UriInterface $uri, $statusCode)
190+
{
191+
$request = $request->withUri($uri);
192+
193+
if (false !== $this->redirectCodes[$statusCode]['switch'] && !in_array($request->getMethod(), $this->redirectCodes[$statusCode]['switch']['unless'])) {
194+
$request = $request->withMethod($this->redirectCodes[$statusCode]['switch']['to']);
195+
}
196+
197+
if (is_array($this->preserveHeader)) {
198+
$headers = array_keys($request->getHeaders());
199+
200+
foreach ($headers as $name) {
201+
if (!in_array($name, $this->preserveHeader)) {
202+
$request = $request->withoutHeader($name);
203+
}
204+
}
205+
}
206+
207+
return $request;
208+
}
209+
210+
/**
211+
* Creates a new Uri from the old request and the location header.
212+
*
213+
* @param ResponseInterface $response The redirect response
214+
* @param RequestInterface $request The original request
215+
*
216+
* @throws HttpException If location header is not usable (missing or incorrect)
217+
* @throws MultipleRedirectionException If a 300 status code is received and default location cannot be resolved (doesn't use the location header or not present)
218+
*
219+
* @return UriInterface
220+
*/
221+
private function createUri(ResponseInterface $response, RequestInterface $request)
222+
{
223+
if ($this->redirectCodes[$response->getStatusCode()]['multiple'] && (!$this->useDefaultForMultiple || !$response->hasHeader('Location'))) {
224+
throw new MultipleRedirectionException('Cannot choose a redirection', $request, $response);
225+
}
226+
227+
if (!$response->hasHeader('Location')) {
228+
throw new HttpException('Redirect status code, but no location header present in the response', $request, $response);
229+
}
230+
231+
$location = $response->getHeaderLine('Location');
232+
$parsedLocation = parse_url($location);
233+
234+
if (false === $parsedLocation) {
235+
throw new HttpException(sprintf('Location %s could not be parsed', $location), $request, $response);
236+
}
237+
238+
$uri = $request->getUri();
239+
240+
if (array_key_exists('scheme', $parsedLocation)) {
241+
$uri = $uri->withScheme($parsedLocation['scheme']);
242+
}
243+
244+
if (array_key_exists('host', $parsedLocation)) {
245+
$uri = $uri->withHost($parsedLocation['host']);
246+
}
247+
248+
if (array_key_exists('port', $parsedLocation)) {
249+
$uri = $uri->withPort($parsedLocation['port']);
250+
}
251+
252+
if (array_key_exists('path', $parsedLocation)) {
253+
$uri = $uri->withPath($parsedLocation['path']);
254+
}
255+
256+
if (array_key_exists('query', $parsedLocation)) {
257+
$uri = $uri->withQuery($parsedLocation['query']);
258+
} else {
259+
$uri = $uri->withQuery('');
260+
}
261+
262+
if (array_key_exists('fragment', $parsedLocation)) {
263+
$uri = $uri->withFragment($parsedLocation['fragment']);
264+
} else {
265+
$uri = $uri->withFragment('');
266+
}
267+
268+
return $uri;
269+
}
270+
}

‎src/Plugin/RequestMatcherPlugin.php

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
<?php
2+
3+
namespace Http\Client\Common\Plugin;
4+
5+
use Http\Client\Common\Plugin;
6+
use Http\Message\RequestMatcher;
7+
use Psr\Http\Message\RequestInterface;
8+
9+
/**
10+
* Apply a delegated plugin based on a request match.
11+
*
12+
* @author Márk Sági-Kazár <mark.sagikazar@gmail.com>
13+
*/
14+
final class RequestMatcherPlugin implements Plugin
15+
{
16+
/**
17+
* @var RequestMatcher
18+
*/
19+
private $requestMatcher;
20+
21+
/**
22+
* @var Plugin
23+
*/
24+
private $delegatedPlugin;
25+
26+
/**
27+
* @param RequestMatcher $requestMatcher
28+
* @param Plugin $delegatedPlugin
29+
*/
30+
public function __construct(RequestMatcher $requestMatcher, Plugin $delegatedPlugin)
31+
{
32+
$this->requestMatcher = $requestMatcher;
33+
$this->delegatedPlugin = $delegatedPlugin;
34+
}
35+
36+
/**
37+
* {@inheritdoc}
38+
*/
39+
public function handleRequest(RequestInterface $request, callable $next, callable $first)
40+
{
41+
if ($this->requestMatcher->matches($request)) {
42+
return $this->delegatedPlugin->handleRequest($request, $next, $first);
43+
}
44+
45+
return $next($request);
46+
}
47+
}

‎src/Plugin/RetryPlugin.php

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
<?php
2+
3+
namespace Http\Client\Common\Plugin;
4+
5+
use Http\Client\Common\Plugin;
6+
use Http\Client\Exception;
7+
use Psr\Http\Message\RequestInterface;
8+
use Psr\Http\Message\ResponseInterface;
9+
use Symfony\Component\OptionsResolver\OptionsResolver;
10+
11+
/**
12+
* Retry the request if an exception is thrown.
13+
*
14+
* By default will retry only one time.
15+
*
16+
* @author Joel Wurtz <joel.wurtz@gmail.com>
17+
*/
18+
final class RetryPlugin implements Plugin
19+
{
20+
/**
21+
* Number of retry before sending an exception.
22+
*
23+
* @var int
24+
*/
25+
private $retry;
26+
27+
/**
28+
* Store the retry counter for each request.
29+
*
30+
* @var array
31+
*/
32+
private $retryStorage = [];
33+
34+
/**
35+
* @param array $config {
36+
*
37+
* @var int $retries Number of retries to attempt if an exception occurs before letting the exception bubble up.
38+
* }
39+
*/
40+
public function __construct(array $config = [])
41+
{
42+
$resolver = new OptionsResolver();
43+
$resolver->setDefaults([
44+
'retries' => 1,
45+
]);
46+
$resolver->setAllowedTypes('retries', 'int');
47+
$options = $resolver->resolve($config);
48+
49+
$this->retry = $options['retries'];
50+
}
51+
52+
/**
53+
* {@inheritdoc}
54+
*/
55+
public function handleRequest(RequestInterface $request, callable $next, callable $first)
56+
{
57+
$chainIdentifier = spl_object_hash((object) $first);
58+
59+
return $next($request)->then(function (ResponseInterface $response) use ($request, $chainIdentifier) {
60+
if (array_key_exists($chainIdentifier, $this->retryStorage)) {
61+
unset($this->retryStorage[$chainIdentifier]);
62+
}
63+
64+
return $response;
65+
}, function (Exception $exception) use ($request, $next, $first, $chainIdentifier) {
66+
if (!array_key_exists($chainIdentifier, $this->retryStorage)) {
67+
$this->retryStorage[$chainIdentifier] = 0;
68+
}
69+
70+
if ($this->retryStorage[$chainIdentifier] >= $this->retry) {
71+
unset($this->retryStorage[$chainIdentifier]);
72+
73+
throw $exception;
74+
}
75+
76+
++$this->retryStorage[$chainIdentifier];
77+
78+
// Retry in synchrone
79+
$promise = $this->handleRequest($request, $next, $first);
80+
81+
return $promise->wait();
82+
});
83+
}
84+
}

‎src/PluginClient.php

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
<?php
2+
3+
namespace Http\Client\Common;
4+
5+
use Http\Client\Common\Exception\LoopException;
6+
use Http\Client\Exception as HttplugException;
7+
use Http\Client\HttpAsyncClient;
8+
use Http\Client\HttpClient;
9+
use Http\Promise\FulfilledPromise;
10+
use Http\Promise\RejectedPromise;
11+
use Psr\Http\Message\RequestInterface;
12+
use Symfony\Component\OptionsResolver\OptionsResolver;
13+
14+
/**
15+
* The client managing plugins and providing a decorator around HTTP Clients.
16+
*
17+
* @author Joel Wurtz <joel.wurtz@gmail.com>
18+
*/
19+
final class PluginClient implements HttpClient, HttpAsyncClient
20+
{
21+
/**
22+
* An HTTP async client.
23+
*
24+
* @var HttpAsyncClient
25+
*/
26+
private $client;
27+
28+
/**
29+
* The plugin chain.
30+
*
31+
* @var Plugin[]
32+
*/
33+
private $plugins;
34+
35+
/**
36+
* A list of options.
37+
*
38+
* @var array
39+
*/
40+
private $options;
41+
42+
/**
43+
* @param HttpClient|HttpAsyncClient $client
44+
* @param Plugin[] $plugins
45+
* @param array $options {
46+
*
47+
* @var int $max_restarts
48+
* }
49+
*
50+
* @throws \RuntimeException if client is not an instance of HttpClient or HttpAsyncClient
51+
*/
52+
public function __construct($client, array $plugins = [], array $options = [])
53+
{
54+
if ($client instanceof HttpAsyncClient) {
55+
$this->client = $client;
56+
} elseif ($client instanceof HttpClient) {
57+
$this->client = new EmulatedHttpAsyncClient($client);
58+
} else {
59+
throw new \RuntimeException('Client must be an instance of Http\\Client\\HttpClient or Http\\Client\\HttpAsyncClient');
60+
}
61+
62+
$this->plugins = $plugins;
63+
$this->options = $this->configure($options);
64+
}
65+
66+
/**
67+
* {@inheritdoc}
68+
*/
69+
public function sendRequest(RequestInterface $request)
70+
{
71+
// If we don't have an http client, use the async call
72+
if (!($this->client instanceof HttpClient)) {
73+
return $this->sendAsyncRequest($request)->wait();
74+
}
75+
76+
// Else we want to use the synchronous call of the underlying client, and not the async one in the case
77+
// we have both an async and sync call
78+
$pluginChain = $this->createPluginChain($this->plugins, function (RequestInterface $request) {
79+
try {
80+
return new FulfilledPromise($this->client->sendRequest($request));
81+
} catch (HttplugException $exception) {
82+
return new RejectedPromise($exception);
83+
}
84+
});
85+
86+
return $pluginChain($request)->wait();
87+
}
88+
89+
/**
90+
* {@inheritdoc}
91+
*/
92+
public function sendAsyncRequest(RequestInterface $request)
93+
{
94+
$pluginChain = $this->createPluginChain($this->plugins, function (RequestInterface $request) {
95+
return $this->client->sendAsyncRequest($request);
96+
});
97+
98+
return $pluginChain($request);
99+
}
100+
101+
/**
102+
* Configure the plugin client.
103+
*
104+
* @param array $options
105+
*
106+
* @return array
107+
*/
108+
private function configure(array $options = [])
109+
{
110+
$resolver = new OptionsResolver();
111+
$resolver->setDefaults([
112+
'max_restarts' => 10,
113+
]);
114+
115+
return $resolver->resolve($options);
116+
}
117+
118+
/**
119+
* Create the plugin chain.
120+
*
121+
* @param Plugin[] $pluginList A list of plugins
122+
* @param callable $clientCallable Callable making the HTTP call
123+
*
124+
* @return callable
125+
*/
126+
private function createPluginChain($pluginList, callable $clientCallable)
127+
{
128+
$firstCallable = $lastCallable = $clientCallable;
129+
130+
while ($plugin = array_pop($pluginList)) {
131+
$lastCallable = function (RequestInterface $request) use ($plugin, $lastCallable, &$firstCallable) {
132+
return $plugin->handleRequest($request, $lastCallable, $firstCallable);
133+
};
134+
135+
$firstCallable = $lastCallable;
136+
}
137+
138+
$firstCalls = 0;
139+
$firstCallable = function (RequestInterface $request) use ($lastCallable, &$firstCalls) {
140+
if ($firstCalls > $this->options['max_restarts']) {
141+
throw new LoopException('Too many restarts in plugin client', $request);
142+
}
143+
144+
++$firstCalls;
145+
146+
return $lastCallable($request);
147+
};
148+
149+
return $firstCallable;
150+
}
151+
}

0 commit comments

Comments
 (0)
Please sign in to comment.