diff --git a/.gitignore b/.gitignore index 16b4a20..e1c991d 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ /phpspec.yml /phpunit.xml /vendor/ +.phpunit.result.cache diff --git a/.styleci.yml b/.styleci.yml index 5328b61..f187ec0 100644 --- a/.styleci.yml +++ b/.styleci.yml @@ -7,8 +7,5 @@ finder: - "src" - "tests" -enabled: - - short_array_syntax - disabled: - phpdoc_annotation_without_dot # This is still buggy: https://github.com/symfony/symfony/pull/19198 diff --git a/CHANGELOG.md b/CHANGELOG.md index 939c513..4da1360 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Work with HTTPlug 2, drop HTTPlug 1 support +- Move to `react/http` library instead of `react/http-client` ## [2.3.0] - 2019-07-30 diff --git a/composer.json b/composer.json index c902900..50614e7 100644 --- a/composer.json +++ b/composer.json @@ -7,23 +7,20 @@ "authors": [ { "name": "Stéphane HULARD", - "email": "s.hulard@gmail.com" + "email": "s.hulard@chstudio.fr" } ], "require": { "php": "^7.1", "php-http/httplug": "^2.0", - "react/http-client": "~0.5.9", - "react/dns": "^1.0|^0.4.15", - "react/socket": "^1.0", - "react/stream": "^1.0", + "react/http": "^1.0", "react/event-loop": "^1.0", "php-http/discovery": "^1.0" }, "require-dev": { - "php-http/client-integration-tests": "^2.0", + "php-http/client-integration-tests": "^3.0", "php-http/message": "^1.0", - "phpunit/phpunit": "^4.8.36 || ^5.4" + "nyholm/psr7": "^1.3" }, "provide": { "php-http/client-implementation": "1.0", @@ -45,7 +42,7 @@ }, "extra": { "branch-alias": { - "dev-master": "2.4-dev" + "dev-master": "3.x-dev" } } } diff --git a/src/Client.php b/src/Client.php index 946807d..34570c5 100644 --- a/src/Client.php +++ b/src/Client.php @@ -4,31 +4,22 @@ use Http\Client\HttpClient; use Http\Client\HttpAsyncClient; -use Http\Client\Exception\HttpException; -use Http\Client\Exception\RequestException; -use Http\Discovery\MessageFactoryDiscovery; -use Http\Discovery\StreamFactoryDiscovery; -use Http\Message\ResponseFactory; -use Http\Message\StreamFactory; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; -use Psr\Http\Message\StreamInterface; use React\EventLoop\LoopInterface; -use React\HttpClient\Client as ReactClient; -use React\HttpClient\Request as ReactRequest; -use React\HttpClient\Response as ReactResponse; +use React\Http\Browser as ReactBrowser; /** * Client for the React promise implementation. * - * @author Stéphane Hulard + * @author Stéphane Hulard */ class Client implements HttpClient, HttpAsyncClient { /** * React HTTP client. * - * @var Client + * @var ReactBrowser */ private $client; @@ -39,41 +30,19 @@ class Client implements HttpClient, HttpAsyncClient */ private $loop; - /** - * @var ResponseFactory - */ - private $responseFactory; - - /** - * @var StreamFactory - */ - private $streamFactory; - /** * Initialize the React client. - * - * @param ResponseFactory|null $responseFactory - * @param LoopInterface|null $loop - * @param ReactClient|null $client - * @param StreamFactory|null $streamFactory */ public function __construct( - ResponseFactory $responseFactory = null, LoopInterface $loop = null, - ReactClient $client = null, - StreamFactory $streamFactory = null + ReactBrowser $client = null ) { if (null !== $client && null === $loop) { - throw new \RuntimeException( - 'You must give a LoopInterface instance with the Client' - ); + throw new \RuntimeException('You must give a LoopInterface instance with the Client'); } $this->loop = $loop ?: ReactFactory::buildEventLoop(); $this->client = $client ?: ReactFactory::buildHttpClient($this->loop); - - $this->responseFactory = $responseFactory ?: MessageFactoryDiscovery::find(); - $this->streamFactory = $streamFactory ?: StreamFactoryDiscovery::find(); } /** @@ -91,91 +60,17 @@ public function sendRequest(RequestInterface $request): ResponseInterface */ public function sendAsyncRequest(RequestInterface $request) { - $reactRequest = $this->buildReactRequest($request); - $promise = new Promise($this->loop); - - $reactRequest->on('error', function (\Exception $error) use ($promise, $request) { - $promise->reject(new RequestException( - $error->getMessage(), - $request, - $error - )); - }); - - $reactRequest->on('response', function (ReactResponse $reactResponse = null) use ($promise, $request) { - $bodyStream = $this->streamFactory->createStream(); - $reactResponse->on('data', function ($data) use (&$bodyStream) { - $bodyStream->write((string) $data); - }); - - $reactResponse->on('end', function (\Exception $error = null) use ($promise, $request, $reactResponse, &$bodyStream) { - $response = $this->buildResponse( - $reactResponse, - $bodyStream - ); - if (null !== $error) { - $promise->reject(new HttpException( - $error->getMessage(), - $request, - $response, - $error - )); - } else { - $promise->resolve($response); - } - }); - }); - - $reactRequest->end((string) $request->getBody()); - - return $promise; - } - - /** - * Build a React request from the PSR7 RequestInterface. - * - * @param RequestInterface $request - * - * @return ReactRequest - */ - private function buildReactRequest(RequestInterface $request) - { - $headers = []; - - foreach ($request->getHeaders() as $name => $value) { - $headers[$name] = (is_array($value) ? $value[0] : $value); - } - - $reactRequest = $this->client->request( - $request->getMethod(), - (string) $request->getUri(), - $headers, - $request->getProtocolVersion() + $promise = new Promise( + $this->client->request( + $request->getMethod(), + $request->getUri(), + $request->getHeaders(), + $request->getBody() + ), + $this->loop, + $request ); - return $reactRequest; - } - - /** - * Transform a React Response to a valid PSR7 ResponseInterface instance. - * - * @param ReactResponse $response - * @param StreamInterface $body - * - * @return ResponseInterface - */ - private function buildResponse( - ReactResponse $response, - StreamInterface $body - ) { - $body->rewind(); - - return $this->responseFactory->createResponse( - $response->getCode(), - $response->getReasonPhrase(), - $response->getHeaders(), - $body, - $response->getVersion() - ); + return $promise; } } diff --git a/src/Promise.php b/src/Promise.php index ac629a8..f4df4cc 100644 --- a/src/Promise.php +++ b/src/Promise.php @@ -2,15 +2,19 @@ namespace Http\Adapter\React; -use React\EventLoop\LoopInterface; -use Http\Client\Exception; +use Http\Client\Exception as HttplugException; use Http\Promise\Promise as HttpPromise; +use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; +use React\EventLoop\LoopInterface; +use React\Promise\PromiseInterface; +use RuntimeException; +use UnexpectedValueException; /** * React promise adapter implementation. * - * @author Stéphane Hulard + * @author Stéphane Hulard * * @internal */ @@ -38,128 +42,62 @@ final class Promise implements HttpPromise private $exception; /** - * @var callable|null - */ - private $onFulfilled; - - /** - * @var callable|null - */ - private $onRejected; - - /** - * React Event Loop used for synchronous processing. + * HTTP Request. * - * @var LoopInterface + * @var RequestInterface */ - private $loop; - - public function __construct(LoopInterface $loop) - { - $this->loop = $loop; - } + private $request; /** - * Allow to apply callable when the promise resolve. - * - * @param callable|null $onFulfilled - * @param callable|null $onRejected + * Adapted ReactPHP promise. * - * @return Promise + * @var PromiseInterface */ - public function then(callable $onFulfilled = null, callable $onRejected = null) - { - $newPromise = new self($this->loop); - - $onFulfilled = null !== $onFulfilled ? $onFulfilled : function (ResponseInterface $response) { - return $response; - }; - - $onRejected = null !== $onRejected ? $onRejected : function (Exception $exception) { - throw $exception; - }; - - $this->onFulfilled = function (ResponseInterface $response) use ($onFulfilled, $newPromise) { - try { - $return = $onFulfilled($response); - - $newPromise->resolve(null !== $return ? $return : $response); - } catch (Exception $exception) { - $newPromise->reject($exception); - } - }; - - $this->onRejected = function (Exception $exception) use ($onRejected, $newPromise) { - try { - $newPromise->resolve($onRejected($exception)); - } catch (Exception $exception) { - $newPromise->reject($exception); - } - }; - - if (HttpPromise::FULFILLED === $this->state) { - $this->doResolve($this->response); - } - - if (HttpPromise::REJECTED === $this->state) { - $this->doReject($this->exception); - } - - return $newPromise; - } + private $promise; /** - * Resolve this promise. + * ReactPHP LoopInterface. * - * @param ResponseInterface $response - * - * @internal + * @var LoopInterface */ - public function resolve(ResponseInterface $response) - { - if (HttpPromise::PENDING !== $this->state) { - throw new \RuntimeException('Promise is already resolved'); - } - - $this->state = HttpPromise::FULFILLED; - $this->response = $response; - $this->doResolve($response); - } + private $loop; - private function doResolve(ResponseInterface $response) + public function __construct(PromiseInterface $promise, LoopInterface $loop, RequestInterface $request) { - $onFulfilled = $this->onFulfilled; + $this->state = self::PENDING; - if (null !== $onFulfilled) { - $onFulfilled($response); - } - } - - /** - * Reject this promise. - * - * @param Exception $exception - * - * @internal - */ - public function reject(Exception $exception) - { - if (HttpPromise::PENDING !== $this->state) { - throw new \RuntimeException('Promise is already resolved'); - } + $this->request = $request; + $this->loop = $loop; + $this->promise = $promise->then( + function (?ResponseInterface $response): ResponseInterface { + $this->response = $response; + $this->state = self::FULFILLED; + + return $response; + }, + /** + * @param mixed $reason + */ + function ($reason): void { + $this->state = self::REJECTED; + + if ($reason instanceof HttplugException) { + $this->exception = $reason; + } elseif ($reason instanceof RuntimeException) { + $this->exception = new HttplugException\NetworkException($reason->getMessage(), $this->request, $reason); + } elseif ($reason instanceof \Throwable) { + $this->exception = new HttplugException\TransferException('Invalid exception returned from ReactPHP', 0, $reason); + } else { + $this->exception = new UnexpectedValueException('Reason returned from ReactPHP must be an Exception'); + } - $this->state = HttpPromise::REJECTED; - $this->exception = $exception; - $this->doReject($exception); + throw $this->exception; + }); } - private function doReject(Exception $exception) + public function then(?callable $onFulfilled = null, ?callable $onRejected = null) { - $onRejected = $this->onRejected; - - if (null !== $onRejected) { - $onRejected($exception); - } + return new self($this->promise->then($onFulfilled, $onRejected), $this->loop, $this->request); } /** diff --git a/src/ReactFactory.php b/src/ReactFactory.php index cbd0dde..08085c0 100644 --- a/src/ReactFactory.php +++ b/src/ReactFactory.php @@ -4,143 +4,36 @@ use React\EventLoop\LoopInterface; use React\EventLoop\Factory as EventLoopFactory; -use React\Dns\Resolver\Resolver as DnsResolver; -use React\Dns\Resolver\Factory as DnsResolverFactory; -use React\HttpClient\Client as HttpClient; -use React\HttpClient\Factory as HttpClientFactory; -use React\Socket\Connector; +use React\Http\Browser; use React\Socket\ConnectorInterface; /** * Factory wrapper for React instances. * - * @author Stéphane Hulard + * @author Stéphane Hulard */ class ReactFactory { /** * Build a react Event Loop. - * - * @return LoopInterface */ - public static function buildEventLoop() + public static function buildEventLoop(): LoopInterface { return EventLoopFactory::create(); } - /** - * Build a React Dns Resolver. - * - * @param LoopInterface $loop - * @param string $dns - * - * @return DnsResolver - */ - public static function buildDnsResolver( - LoopInterface $loop, - $dns = '8.8.8.8' - ) { - $factory = new DnsResolverFactory(); - - return $factory->createCached($dns, $loop); - } - - /** - * @param LoopInterface $loop - * @param DnsResolver|null $dns - * - * @return ConnectorInterface - */ - public static function buildConnector( - LoopInterface $loop, - DnsResolver $dns = null - ) { - return null !== $dns - ? new Connector($loop, ['dns' => $dns]) - : new Connector($loop); - } - /** * Build a React Http Client. * - * @param LoopInterface $loop - * @param ConnectorInterface|DnsResolver|null $connector Only pass this argument if you need to customize DNS - * behaviour. With react http client v0.5, pass a connector, - * with v0.4 this must be a DnsResolver. - * - * @return HttpClient + * @param ConnectorInterface|null $connector Only pass this argument if you need to customize DNS + * behaviour. */ public static function buildHttpClient( LoopInterface $loop, - $connector = null - ) { - if (class_exists(HttpClientFactory::class)) { - // if HttpClientFactory class exists, use old behavior for backwards compatibility - return static::buildHttpClient04($loop, $connector); - } else { - return static::buildHttpClient05($loop, $connector); - } - } - - /** - * Builds a React Http client v0.4 style. - * - * @param LoopInterface $loop - * @param DnsResolver|null $dns - * - * @return HttpClient - */ - protected static function buildHttpClient04( - LoopInterface $loop, - $dns = null - ) { - // create dns resolver if one isn't provided - if (null === $dns) { - $dns = static::buildDnsResolver($loop); - } - - // validate connector instance for proper error reporting - if (!$dns instanceof DnsResolver) { - throw new \InvalidArgumentException('For react http client v0.4, $dns must be an instance of DnsResolver'); - } - - $factory = new HttpClientFactory(); - - return $factory->create($loop, $dns); - } - - /** - * Builds a React Http client v0.5 style. - * - * @param LoopInterface $loop - * @param DnsResolver|ConnectorInterface|null $connector - * - * @return HttpClient - */ - protected static function buildHttpClient05( - LoopInterface $loop, - $connector = null - ) { - // build a connector with given DnsResolver if provided (old deprecated behavior) - if ($connector instanceof DnsResolver) { - @trigger_error( - sprintf( - 'Passing a %s to buildHttpClient is deprecated since version 2.1.0 and will be removed in 3.0. If you need no specific behaviour, omit the $dns argument, otherwise pass a %s', - DnsResolver::class, - ConnectorInterface::class - ), - E_USER_DEPRECATED - ); - $connector = static::buildConnector($loop, $connector); - } - - // validate connector instance for proper error reporting - if (null !== $connector && !$connector instanceof ConnectorInterface) { - throw new \InvalidArgumentException( - '$connector must be an instance of DnsResolver or ConnectorInterface' - ); - } - - return new HttpClient($loop, $connector); + ConnectorInterface $connector = null + ): Browser { + return (new Browser($loop, $connector)) + ->withRejectErrorResponse(false) + ->withFollowRedirects(false); } } diff --git a/tests/AsyncClientTest.php b/tests/AsyncClientTest.php index e64d3ad..bcf9540 100644 --- a/tests/AsyncClientTest.php +++ b/tests/AsyncClientTest.php @@ -2,21 +2,17 @@ namespace Http\Adapter\React\Tests; -use Http\Client\HttpClient; use Http\Client\Tests\HttpAsyncClientTest; use Http\Adapter\React\Client; -use Http\Message\MessageFactory\GuzzleMessageFactory; +use Http\Client\HttpAsyncClient; /** - * @author Stéphane Hulard + * @author Stéphane Hulard */ class AsyncClientTest extends HttpAsyncClientTest { - /** - * @return HttpClient - */ - protected function createHttpAsyncClient() + protected function createHttpAsyncClient(): HttpAsyncClient { - return new Client(new GuzzleMessageFactory()); + return new Client(); } } diff --git a/tests/ClientTest.php b/tests/ClientTest.php index edc998f..57c6614 100644 --- a/tests/ClientTest.php +++ b/tests/ClientTest.php @@ -2,21 +2,17 @@ namespace Http\Adapter\React\Tests; -use Http\Client\HttpClient; use Http\Client\Tests\HttpClientTest; use Http\Adapter\React\Client; -use Http\Message\MessageFactory\GuzzleMessageFactory; +use Psr\Http\Client\ClientInterface; /** - * @author Stéphane Hulard + * @author Stéphane Hulard */ class ClientTest extends HttpClientTest { - /** - * @return HttpClient - */ - protected function createHttpAdapter() + protected function createHttpAdapter(): ClientInterface { - return new Client(new GuzzleMessageFactory()); + return new Client(); } } diff --git a/tests/PromiseTest.php b/tests/PromiseTest.php index 39a6bb3..85b63a4 100644 --- a/tests/PromiseTest.php +++ b/tests/PromiseTest.php @@ -2,55 +2,80 @@ namespace Http\Adapter\React\Tests; -use GuzzleHttp\Psr7\Response; use Http\Adapter\React\Promise; use Http\Adapter\React\ReactFactory; +use Http\Client\Exception\HttpException; +use Http\Client\Exception\NetworkException; +use Http\Client\Exception\TransferException; +use InvalidArgumentException; +use Nyholm\Psr7\Factory\Psr17Factory; use PHPUnit\Framework\TestCase; +use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; +use React\Promise\Promise as ReactPromise; +use RuntimeException; +use UnexpectedValueException; class PromiseTest extends TestCase { private $loop; - public function setUp() + public function setUp(): void { $this->loop = ReactFactory::buildEventLoop(); } public function testChain() { - $promise = new Promise($this->loop); - $response = new Response(200); + $factory = new Psr17Factory(); + $request = $factory->createRequest('GET', 'http://example.org'); + $response = $factory->createResponse(200, "I'm OK…"); - $lastPromise = $promise->then(function (Response $response) { - return $response->withStatus(300); + $reactPromise = new ReactPromise(function ($resolve, $reject) use ($response) { + $resolve($response); + }); + + $promise = new Promise($reactPromise, $this->loop, $request); + + $lastPromise = $promise->then(function (ResponseInterface $response) use ($factory) { + return $factory->createResponse(300, $response->getReasonPhrase()); }); - $promise->resolve($response); $updatedResponse = $lastPromise->wait(); self::assertEquals(200, $response->getStatusCode()); self::assertEquals(300, $updatedResponse->getStatusCode()); + self::assertEquals("I'm OK…", $updatedResponse->getReasonPhrase()); } - public function testOnFulfilledOptionalReturn() - { - $promise = new Promise($this->loop); - $response = new Response(200); - - // create a random mock so we can assert $onFulfilled is called with the correct response - /** @var \SplObjectStorage|\PHPUnit_Framework_MockObject_MockObject $mock */ - $mock = $this->getMockBuilder(\SplObjectStorage::class)->getMock(); - $mock->expects(self::once())->method('attach')->with($response); - - $lastPromise = $promise->then(function (ResponseInterface $response) use ($mock) { - $mock->attach($response); + /** + * @dataProvider exceptionThatIsThrownFromReactProvider + */ + public function testPromiseExceptionsAreTranslatedToHttplug( + RequestInterface $request, + $reason, + string $adapterExceptionClass + ) { + $reactPromise = new ReactPromise(function ($resolve, $reject) use ($reason) { + $reject($reason); }); - $promise->resolve($response); - $lastResponse = $lastPromise->wait(); + $promise = new Promise($reactPromise, $this->loop, $request); + $this->expectException($adapterExceptionClass); + $promise->wait(); + } + + public function exceptionThatIsThrownFromReactProvider() + { + $request = $this->getMockBuilder(RequestInterface::class)->getMock(); + $response = $this->getMockBuilder(ResponseInterface::class)->getMock(); - // even though our $onFulfilled doesn't return a value, we expect the promise to unwrap the original response - self::assertSame($response, $lastResponse); + return [ + 'string' => [$request, 'whatever', UnexpectedValueException::class], + 'InvalidArgumentException' => [$request, new InvalidArgumentException('Something went wrong'), TransferException::class], + 'RuntimeException' => [$request, new RuntimeException('Something happened inside ReactPHP engine'), NetworkException::class], + 'NetworkException' => [$request, new NetworkException('Something happened inside ReactPHP engine', $request), NetworkException::class], + 'HttpException' => [$request, new HttpException('Something happened inside ReactPHP engine', $request, $response), HttpException::class], + ]; } } diff --git a/tests/ReactFactoryTest.php b/tests/ReactFactoryTest.php index 67aadf2..f0f7b03 100644 --- a/tests/ReactFactoryTest.php +++ b/tests/ReactFactoryTest.php @@ -4,10 +4,8 @@ use Http\Adapter\React\ReactFactory; use PHPUnit\Framework\TestCase; -use React\Dns\Resolver\Resolver; use React\EventLoop\LoopInterface; -use React\HttpClient\Client; -use React\HttpClient\Factory; +use React\Http\Browser; use React\Socket\ConnectorInterface; /** @@ -23,45 +21,22 @@ class ReactFactoryTest extends TestCase */ private $loop; - protected function setUp() + protected function setUp(): void { $this->loop = $this->getMockBuilder(LoopInterface::class)->getMock(); } public function testBuildHttpClientWithConnector() { - if (class_exists(Factory::class)) { - $this->markTestSkipped('This test only runs with react http client v0.5 and above'); - } - + /** @var ConnectorInterface $connector */ $connector = $this->getMockBuilder(ConnectorInterface::class)->getMock(); $client = ReactFactory::buildHttpClient($this->loop, $connector); - $this->assertInstanceOf(Client::class, $client); - } - - /** - * @deprecated Building HTTP client passing a DnsResolver instance is deprecated. Should pass a ConnectorInterface - * instance instead. - */ - public function testBuildHttpClientWithDnsResolver() - { - $connector = $this->getMockBuilder(Resolver::class)->disableOriginalConstructor()->getMock(); - $client = ReactFactory::buildHttpClient($this->loop, $connector); - $this->assertInstanceOf(Client::class, $client); + $this->assertInstanceOf(Browser::class, $client); } public function testBuildHttpClientWithoutConnector() { $client = ReactFactory::buildHttpClient($this->loop); - $this->assertInstanceOf(Client::class, $client); - } - - /** - * @expectedException \InvalidArgumentException - */ - public function testBuildHttpClientWithInvalidConnectorThrowsException() - { - $connector = $this->getMockBuilder(LoopInterface::class)->getMock(); - ReactFactory::buildHttpClient($this->loop, $connector); + $this->assertInstanceOf(Browser::class, $client); } }