From 8fbdc0cec0cd754a6cdeb3e29e501d8dbd28b591 Mon Sep 17 00:00:00 2001 From: Jorrit Schippers Date: Tue, 5 Apr 2022 23:01:57 +0200 Subject: [PATCH 01/58] README.md: fix typo --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 154f4901..56acb365 100644 --- a/README.md +++ b/README.md @@ -389,7 +389,7 @@ try { $response = Block\await($promise, Loop::get()); // response successfully received } catch (Exception $e) { - // an error occured while performing the request + // an error occurred while performing the request } ``` From dd2cb536e7dc071bebe6d78e93f4f58de6f931a4 Mon Sep 17 00:00:00 2001 From: Simon Frings Date: Tue, 12 Apr 2022 09:41:48 +0200 Subject: [PATCH 02/58] Fix legacy HHVM build by downgrading Composer --- .github/workflows/ci.yml | 1 + README.md | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c64ef6ab..27577838 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,5 +43,6 @@ jobs: - uses: azjezz/setup-hhvm@v1 with: version: lts-3.30 + - run: composer self-update --2.2 # downgrade Composer for HHVM - run: hhvm $(which composer) install - run: hhvm vendor/bin/phpunit diff --git a/README.md b/README.md index 154f4901..33ec6967 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # HTTP -[![CI status](https://github.com/reactphp/http/workflows/CI/badge.svg)](https://github.com/reactphp/http/actions) +[![CI status](https://github.com/reactphp/http/actions/workflows/ci.yml/badge.svg)](https://github.com/reactphp/http/actions) [![installs on Packagist](https://img.shields.io/packagist/dt/react/http?color=blue&label=installs%20on%20Packagist)](https://packagist.org/packages/react/http) Event-driven, streaming HTTP client and server implementation for [ReactPHP](https://reactphp.org/). From a6bc13dfd46eee82fc45796f0350135a06123f65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Thu, 14 Apr 2022 13:28:22 +0200 Subject: [PATCH 03/58] Improve documentation for closing response stream --- README.md | 10 +++++++++- examples/58-server-stream-response.php | 8 +++++--- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 56acb365..aa26deb3 100644 --- a/README.md +++ b/README.md @@ -1426,15 +1426,23 @@ may only support strings. $http = new React\Http\HttpServer(function (Psr\Http\Message\ServerRequestInterface $request) { $stream = new ThroughStream(); + // send some data every once in a while with periodic timer $timer = Loop::addPeriodicTimer(0.5, function () use ($stream) { $stream->write(microtime(true) . PHP_EOL); }); - Loop::addTimer(5, function() use ($timer, $stream) { + // end stream after a few seconds + $timeout = Loop::addTimer(5.0, function() use ($stream, $timer) { Loop::cancelTimer($timer); $stream->end(); }); + // stop timer if stream is closed (such as when connection is closed) + $stream->on('close', function () use ($timer, $timeout) { + Loop::cancelTimer($timer); + Loop::cancelTimer($timeout); + }); + return new React\Http\Message\Response( React\Http\Message\Response::STATUS_OK, array( diff --git a/examples/58-server-stream-response.php b/examples/58-server-stream-response.php index cf65a3bf..9d12461a 100644 --- a/examples/58-server-stream-response.php +++ b/examples/58-server-stream-response.php @@ -18,14 +18,16 @@ $stream->write(microtime(true) . PHP_EOL); }); - // demo for ending stream after a few seconds - Loop::addTimer(5.0, function() use ($stream) { + // end stream after a few seconds + $timeout = Loop::addTimer(5.0, function() use ($stream, $timer) { + Loop::cancelTimer($timer); $stream->end(); }); // stop timer if stream is closed (such as when connection is closed) - $stream->on('close', function () use ($timer) { + $stream->on('close', function () use ($timer, $timeout) { Loop::cancelTimer($timer); + Loop::cancelTimer($timeout); }); return new Response( From 9c2d98f1f5b590082faa1a74aba5549cd0107977 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Thu, 19 May 2022 19:30:38 +0200 Subject: [PATCH 04/58] Improve performance, add internal `Clock`, reuse clock in same tick --- src/Io/Clock.php | 54 ++++++++++ src/Io/RequestHeaderParser.php | 12 ++- src/Io/StreamingServer.php | 11 +- tests/HttpServerTest.php | 8 +- tests/Io/ClockTest.php | 43 ++++++++ tests/Io/RequestHeaderParserTest.php | 156 +++++++++++++++++++-------- tests/Io/StreamingServerTest.php | 33 +++--- 7 files changed, 254 insertions(+), 63 deletions(-) create mode 100644 src/Io/Clock.php create mode 100644 tests/Io/ClockTest.php diff --git a/src/Io/Clock.php b/src/Io/Clock.php new file mode 100644 index 00000000..92c1cb09 --- /dev/null +++ b/src/Io/Clock.php @@ -0,0 +1,54 @@ +loop = $loop; + } + + /** @return float */ + public function now() + { + if ($this->now === null) { + $this->now = \microtime(true); + + // remember clock for current loop tick only and update on next tick + $now =& $this->now; + $this->loop->futureTick(function () use (&$now) { + assert($now !== null); + $now = null; + }); + } + + return $this->now; + } +} diff --git a/src/Io/RequestHeaderParser.php b/src/Io/RequestHeaderParser.php index e5554c46..6930afaf 100644 --- a/src/Io/RequestHeaderParser.php +++ b/src/Io/RequestHeaderParser.php @@ -24,6 +24,14 @@ class RequestHeaderParser extends EventEmitter { private $maxSize = 8192; + /** @var Clock */ + private $clock; + + public function __construct(Clock $clock) + { + $this->clock = $clock; + } + public function handle(ConnectionInterface $conn) { $buffer = ''; @@ -155,8 +163,8 @@ public function parseRequest($headers, $remoteSocketUri, $localSocketUri) // create new obj implementing ServerRequestInterface by preserving all // previous properties and restoring original request-target $serverParams = array( - 'REQUEST_TIME' => \time(), - 'REQUEST_TIME_FLOAT' => \microtime(true) + 'REQUEST_TIME' => (int) ($now = $this->clock->now()), + 'REQUEST_TIME_FLOAT' => $now ); // scheme is `http` unless TLS is used diff --git a/src/Io/StreamingServer.php b/src/Io/StreamingServer.php index d73d527d..a054be3d 100644 --- a/src/Io/StreamingServer.php +++ b/src/Io/StreamingServer.php @@ -84,7 +84,9 @@ final class StreamingServer extends EventEmitter { private $callback; private $parser; - private $loop; + + /** @var Clock */ + private $clock; /** * Creates an HTTP server that invokes the given callback for each incoming HTTP request @@ -104,10 +106,9 @@ public function __construct(LoopInterface $loop, $requestHandler) throw new \InvalidArgumentException('Invalid request handler given'); } - $this->loop = $loop; - $this->callback = $requestHandler; - $this->parser = new RequestHeaderParser(); + $this->clock = new Clock($loop); + $this->parser = new RequestHeaderParser($this->clock); $that = $this; $this->parser->on('headers', function (ServerRequestInterface $request, ConnectionInterface $conn) use ($that) { @@ -255,7 +256,7 @@ public function handleResponse(ConnectionInterface $connection, ServerRequestInt // assign default "Date" header from current time automatically if (!$response->hasHeader('Date')) { // IMF-fixdate = day-name "," SP date1 SP time-of-day SP GMT - $response = $response->withHeader('Date', gmdate('D, d M Y H:i:s') . ' GMT'); + $response = $response->withHeader('Date', gmdate('D, d M Y H:i:s', (int) $this->clock->now()) . ' GMT'); } elseif ($response->getHeaderLine('Date') === ''){ $response = $response->withoutHeader('Date'); } diff --git a/tests/HttpServerTest.php b/tests/HttpServerTest.php index a6d8057b..4d00fcef 100644 --- a/tests/HttpServerTest.php +++ b/tests/HttpServerTest.php @@ -54,9 +54,13 @@ public function testConstructWithoutLoopAssignsLoopAutomatically() $ref->setAccessible(true); $streamingServer = $ref->getValue($http); - $ref = new \ReflectionProperty($streamingServer, 'loop'); + $ref = new \ReflectionProperty($streamingServer, 'clock'); $ref->setAccessible(true); - $loop = $ref->getValue($streamingServer); + $clock = $ref->getValue($streamingServer); + + $ref = new \ReflectionProperty($clock, 'loop'); + $ref->setAccessible(true); + $loop = $ref->getValue($clock); $this->assertInstanceOf('React\EventLoop\LoopInterface', $loop); } diff --git a/tests/Io/ClockTest.php b/tests/Io/ClockTest.php new file mode 100644 index 00000000..8f4b90fa --- /dev/null +++ b/tests/Io/ClockTest.php @@ -0,0 +1,43 @@ +getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + + $clock = new Clock($loop); + + $now = $clock->now(); + $this->assertTrue(is_float($now)); // assertIsFloat() on PHPUnit 8+ + $this->assertEquals($now, $clock->now()); + } + + public function testNowResetsMemoizedTimestampOnFutureTick() + { + $tick = null; + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $loop->expects($this->once())->method('futureTick')->with($this->callback(function ($cb) use (&$tick) { + $tick = $cb; + return true; + })); + + $clock = new Clock($loop); + + $now = $clock->now(); + + $ref = new \ReflectionProperty($clock, 'now'); + $ref->setAccessible(true); + $this->assertEquals($now, $ref->getValue($clock)); + + $this->assertNotNull($tick); + $tick(); + + $this->assertNull($ref->getValue($clock)); + } +} diff --git a/tests/Io/RequestHeaderParserTest.php b/tests/Io/RequestHeaderParserTest.php index 356443fb..7ba7fe01 100644 --- a/tests/Io/RequestHeaderParserTest.php +++ b/tests/Io/RequestHeaderParserTest.php @@ -10,7 +10,9 @@ class RequestHeaderParserTest extends TestCase { public function testSplitShouldHappenOnDoubleCrlf() { - $parser = new RequestHeaderParser(); + $clock = $this->getMockBuilder('React\Http\Io\Clock')->disableOriginalConstructor()->getMock(); + + $parser = new RequestHeaderParser($clock); $parser->on('headers', $this->expectCallableNever()); $connection = $this->getMockBuilder('React\Socket\Connection')->disableOriginalConstructor()->setMethods(null)->getMock(); @@ -29,7 +31,9 @@ public function testSplitShouldHappenOnDoubleCrlf() public function testFeedInOneGo() { - $parser = new RequestHeaderParser(); + $clock = $this->getMockBuilder('React\Http\Io\Clock')->disableOriginalConstructor()->getMock(); + + $parser = new RequestHeaderParser($clock); $parser->on('headers', $this->expectCallableOnce()); $connection = $this->getMockBuilder('React\Socket\Connection')->disableOriginalConstructor()->setMethods(null)->getMock(); @@ -41,7 +45,9 @@ public function testFeedInOneGo() public function testFeedTwoRequestsOnSeparateConnections() { - $parser = new RequestHeaderParser(); + $clock = $this->getMockBuilder('React\Http\Io\Clock')->disableOriginalConstructor()->getMock(); + + $parser = new RequestHeaderParser($clock); $called = 0; $parser->on('headers', function () use (&$called) { @@ -65,7 +71,9 @@ public function testHeadersEventShouldEmitRequestAndConnection() $request = null; $conn = null; - $parser = new RequestHeaderParser(); + $clock = $this->getMockBuilder('React\Http\Io\Clock')->disableOriginalConstructor()->getMock(); + + $parser = new RequestHeaderParser($clock); $parser->on('headers', function ($parsedRequest, $connection) use (&$request, &$conn) { $request = $parsedRequest; $conn = $connection; @@ -88,7 +96,9 @@ public function testHeadersEventShouldEmitRequestAndConnection() public function testHeadersEventShouldEmitRequestWhichShouldEmitEndForStreamingBodyWithoutContentLengthFromInitialRequestBody() { - $parser = new RequestHeaderParser(); + $clock = $this->getMockBuilder('React\Http\Io\Clock')->disableOriginalConstructor()->getMock(); + + $parser = new RequestHeaderParser($clock); $ended = false; $that = $this; @@ -112,7 +122,9 @@ public function testHeadersEventShouldEmitRequestWhichShouldEmitEndForStreamingB public function testHeadersEventShouldEmitRequestWhichShouldEmitStreamingBodyDataFromInitialRequestBody() { - $parser = new RequestHeaderParser(); + $clock = $this->getMockBuilder('React\Http\Io\Clock')->disableOriginalConstructor()->getMock(); + + $parser = new RequestHeaderParser($clock); $buffer = ''; $that = $this; @@ -140,7 +152,9 @@ public function testHeadersEventShouldEmitRequestWhichShouldEmitStreamingBodyDat public function testHeadersEventShouldEmitRequestWhichShouldEmitStreamingBodyWithPlentyOfDataFromInitialRequestBody() { - $parser = new RequestHeaderParser(); + $clock = $this->getMockBuilder('React\Http\Io\Clock')->disableOriginalConstructor()->getMock(); + + $parser = new RequestHeaderParser($clock); $buffer = ''; $that = $this; @@ -166,7 +180,9 @@ public function testHeadersEventShouldEmitRequestWhichShouldEmitStreamingBodyWit public function testHeadersEventShouldEmitRequestWhichShouldNotEmitStreamingBodyDataWithoutContentLengthFromInitialRequestBody() { - $parser = new RequestHeaderParser(); + $clock = $this->getMockBuilder('React\Http\Io\Clock')->disableOriginalConstructor()->getMock(); + + $parser = new RequestHeaderParser($clock); $buffer = ''; $that = $this; @@ -191,7 +207,9 @@ public function testHeadersEventShouldEmitRequestWhichShouldNotEmitStreamingBody public function testHeadersEventShouldEmitRequestWhichShouldEmitStreamingBodyDataUntilContentLengthBoundaryFromInitialRequestBody() { - $parser = new RequestHeaderParser(); + $clock = $this->getMockBuilder('React\Http\Io\Clock')->disableOriginalConstructor()->getMock(); + + $parser = new RequestHeaderParser($clock); $buffer = ''; $that = $this; @@ -218,7 +236,9 @@ public function testHeadersEventShouldParsePathAndQueryString() { $request = null; - $parser = new RequestHeaderParser(); + $clock = $this->getMockBuilder('React\Http\Io\Clock')->disableOriginalConstructor()->getMock(); + + $parser = new RequestHeaderParser($clock); $parser->on('headers', function ($parsedRequest) use (&$request) { $request = $parsedRequest; }); @@ -245,7 +265,9 @@ public function testHeaderEventWithShouldApplyDefaultAddressFromLocalConnectionA { $request = null; - $parser = new RequestHeaderParser(); + $clock = $this->getMockBuilder('React\Http\Io\Clock')->disableOriginalConstructor()->getMock(); + + $parser = new RequestHeaderParser($clock); $parser->on('headers', function ($parsedRequest) use (&$request) { $request = $parsedRequest; }); @@ -264,7 +286,9 @@ public function testHeaderEventViaHttpsShouldApplyHttpsSchemeFromLocalTlsConnect { $request = null; - $parser = new RequestHeaderParser(); + $clock = $this->getMockBuilder('React\Http\Io\Clock')->disableOriginalConstructor()->getMock(); + + $parser = new RequestHeaderParser($clock); $parser->on('headers', function ($parsedRequest) use (&$request) { $request = $parsedRequest; }); @@ -284,7 +308,9 @@ public function testHeaderOverflowShouldEmitError() $error = null; $passedConnection = null; - $parser = new RequestHeaderParser(); + $clock = $this->getMockBuilder('React\Http\Io\Clock')->disableOriginalConstructor()->getMock(); + + $parser = new RequestHeaderParser($clock); $parser->on('headers', $this->expectCallableNever()); $parser->on('error', function ($message, $connection) use (&$error, &$passedConnection) { $error = $message; @@ -306,7 +332,9 @@ public function testInvalidEmptyRequestHeadersParseException() { $error = null; - $parser = new RequestHeaderParser(); + $clock = $this->getMockBuilder('React\Http\Io\Clock')->disableOriginalConstructor()->getMock(); + + $parser = new RequestHeaderParser($clock); $parser->on('headers', $this->expectCallableNever()); $parser->on('error', function ($message) use (&$error) { $error = $message; @@ -325,7 +353,9 @@ public function testInvalidMalformedRequestLineParseException() { $error = null; - $parser = new RequestHeaderParser(); + $clock = $this->getMockBuilder('React\Http\Io\Clock')->disableOriginalConstructor()->getMock(); + + $parser = new RequestHeaderParser($clock); $parser->on('headers', $this->expectCallableNever()); $parser->on('error', function ($message) use (&$error) { $error = $message; @@ -344,7 +374,9 @@ public function testInvalidMalformedRequestHeadersThrowsParseException() { $error = null; - $parser = new RequestHeaderParser(); + $clock = $this->getMockBuilder('React\Http\Io\Clock')->disableOriginalConstructor()->getMock(); + + $parser = new RequestHeaderParser($clock); $parser->on('headers', $this->expectCallableNever()); $parser->on('error', function ($message) use (&$error) { $error = $message; @@ -363,7 +395,9 @@ public function testInvalidMalformedRequestHeadersWhitespaceThrowsParseException { $error = null; - $parser = new RequestHeaderParser(); + $clock = $this->getMockBuilder('React\Http\Io\Clock')->disableOriginalConstructor()->getMock(); + + $parser = new RequestHeaderParser($clock); $parser->on('headers', $this->expectCallableNever()); $parser->on('error', function ($message) use (&$error) { $error = $message; @@ -382,7 +416,9 @@ public function testInvalidAbsoluteFormSchemeEmitsError() { $error = null; - $parser = new RequestHeaderParser(); + $clock = $this->getMockBuilder('React\Http\Io\Clock')->disableOriginalConstructor()->getMock(); + + $parser = new RequestHeaderParser($clock); $parser->on('headers', $this->expectCallableNever()); $parser->on('error', function ($message) use (&$error) { $error = $message; @@ -401,7 +437,9 @@ public function testOriginFormWithSchemeSeparatorInParam() { $request = null; - $parser = new RequestHeaderParser(); + $clock = $this->getMockBuilder('React\Http\Io\Clock')->disableOriginalConstructor()->getMock(); + + $parser = new RequestHeaderParser($clock); $parser->on('error', $this->expectCallableNever()); $parser->on('headers', function ($parsedRequest, $parsedBodyBuffer) use (&$request) { $request = $parsedRequest; @@ -426,7 +464,9 @@ public function testUriStartingWithColonSlashSlashFails() { $error = null; - $parser = new RequestHeaderParser(); + $clock = $this->getMockBuilder('React\Http\Io\Clock')->disableOriginalConstructor()->getMock(); + + $parser = new RequestHeaderParser($clock); $parser->on('headers', $this->expectCallableNever()); $parser->on('error', function ($message) use (&$error) { $error = $message; @@ -445,7 +485,9 @@ public function testInvalidAbsoluteFormWithFragmentEmitsError() { $error = null; - $parser = new RequestHeaderParser(); + $clock = $this->getMockBuilder('React\Http\Io\Clock')->disableOriginalConstructor()->getMock(); + + $parser = new RequestHeaderParser($clock); $parser->on('headers', $this->expectCallableNever()); $parser->on('error', function ($message) use (&$error) { $error = $message; @@ -464,7 +506,9 @@ public function testInvalidHeaderContainsFullUri() { $error = null; - $parser = new RequestHeaderParser(); + $clock = $this->getMockBuilder('React\Http\Io\Clock')->disableOriginalConstructor()->getMock(); + + $parser = new RequestHeaderParser($clock); $parser->on('headers', $this->expectCallableNever()); $parser->on('error', function ($message) use (&$error) { $error = $message; @@ -483,7 +527,9 @@ public function testInvalidAbsoluteFormWithHostHeaderEmpty() { $error = null; - $parser = new RequestHeaderParser(); + $clock = $this->getMockBuilder('React\Http\Io\Clock')->disableOriginalConstructor()->getMock(); + + $parser = new RequestHeaderParser($clock); $parser->on('headers', $this->expectCallableNever()); $parser->on('error', function ($message) use (&$error) { $error = $message; @@ -502,7 +548,9 @@ public function testInvalidConnectRequestWithNonAuthorityForm() { $error = null; - $parser = new RequestHeaderParser(); + $clock = $this->getMockBuilder('React\Http\Io\Clock')->disableOriginalConstructor()->getMock(); + + $parser = new RequestHeaderParser($clock); $parser->on('headers', $this->expectCallableNever()); $parser->on('error', function ($message) use (&$error) { $error = $message; @@ -521,7 +569,9 @@ public function testInvalidHttpVersion() { $error = null; - $parser = new RequestHeaderParser(); + $clock = $this->getMockBuilder('React\Http\Io\Clock')->disableOriginalConstructor()->getMock(); + + $parser = new RequestHeaderParser($clock); $parser->on('headers', $this->expectCallableNever()); $parser->on('error', function ($message) use (&$error) { $error = $message; @@ -541,7 +591,9 @@ public function testInvalidContentLengthRequestHeaderWillEmitError() { $error = null; - $parser = new RequestHeaderParser(); + $clock = $this->getMockBuilder('React\Http\Io\Clock')->disableOriginalConstructor()->getMock(); + + $parser = new RequestHeaderParser($clock); $parser->on('headers', $this->expectCallableNever()); $parser->on('error', function ($message) use (&$error) { $error = $message; @@ -561,7 +613,9 @@ public function testInvalidRequestWithMultipleContentLengthRequestHeadersWillEmi { $error = null; - $parser = new RequestHeaderParser(); + $clock = $this->getMockBuilder('React\Http\Io\Clock')->disableOriginalConstructor()->getMock(); + + $parser = new RequestHeaderParser($clock); $parser->on('headers', $this->expectCallableNever()); $parser->on('error', function ($message) use (&$error) { $error = $message; @@ -581,7 +635,9 @@ public function testInvalidTransferEncodingRequestHeaderWillEmitError() { $error = null; - $parser = new RequestHeaderParser(); + $clock = $this->getMockBuilder('React\Http\Io\Clock')->disableOriginalConstructor()->getMock(); + + $parser = new RequestHeaderParser($clock); $parser->on('headers', $this->expectCallableNever()); $parser->on('error', function ($message) use (&$error) { $error = $message; @@ -601,7 +657,9 @@ public function testInvalidRequestWithBothTransferEncodingAndContentLengthWillEm { $error = null; - $parser = new RequestHeaderParser(); + $clock = $this->getMockBuilder('React\Http\Io\Clock')->disableOriginalConstructor()->getMock(); + + $parser = new RequestHeaderParser($clock); $parser->on('headers', $this->expectCallableNever()); $parser->on('error', function ($message) use (&$error) { $error = $message; @@ -621,7 +679,10 @@ public function testServerParamsWillBeSetOnHttpsRequest() { $request = null; - $parser = new RequestHeaderParser(); + $clock = $this->getMockBuilder('React\Http\Io\Clock')->disableOriginalConstructor()->getMock(); + $clock->expects($this->once())->method('now')->willReturn(1652972091.3958); + + $parser = new RequestHeaderParser($clock); $parser->on('headers', function ($parsedRequest) use (&$request) { $request = $parsedRequest; @@ -637,8 +698,8 @@ public function testServerParamsWillBeSetOnHttpsRequest() $serverParams = $request->getServerParams(); $this->assertEquals('on', $serverParams['HTTPS']); - $this->assertNotEmpty($serverParams['REQUEST_TIME']); - $this->assertNotEmpty($serverParams['REQUEST_TIME_FLOAT']); + $this->assertEquals(1652972091, $serverParams['REQUEST_TIME']); + $this->assertEquals(1652972091.3958, $serverParams['REQUEST_TIME_FLOAT']); $this->assertEquals('127.1.1.1', $serverParams['SERVER_ADDR']); $this->assertEquals('8000', $serverParams['SERVER_PORT']); @@ -651,7 +712,10 @@ public function testServerParamsWillBeSetOnHttpRequest() { $request = null; - $parser = new RequestHeaderParser(); + $clock = $this->getMockBuilder('React\Http\Io\Clock')->disableOriginalConstructor()->getMock(); + $clock->expects($this->once())->method('now')->willReturn(1652972091.3958); + + $parser = new RequestHeaderParser($clock); $parser->on('headers', function ($parsedRequest) use (&$request) { $request = $parsedRequest; @@ -667,8 +731,8 @@ public function testServerParamsWillBeSetOnHttpRequest() $serverParams = $request->getServerParams(); $this->assertArrayNotHasKey('HTTPS', $serverParams); - $this->assertNotEmpty($serverParams['REQUEST_TIME']); - $this->assertNotEmpty($serverParams['REQUEST_TIME_FLOAT']); + $this->assertEquals(1652972091, $serverParams['REQUEST_TIME']); + $this->assertEquals(1652972091.3958, $serverParams['REQUEST_TIME_FLOAT']); $this->assertEquals('127.1.1.1', $serverParams['SERVER_ADDR']); $this->assertEquals('8000', $serverParams['SERVER_PORT']); @@ -681,7 +745,10 @@ public function testServerParamsWillNotSetRemoteAddressForUnixDomainSockets() { $request = null; - $parser = new RequestHeaderParser(); + $clock = $this->getMockBuilder('React\Http\Io\Clock')->disableOriginalConstructor()->getMock(); + $clock->expects($this->once())->method('now')->willReturn(1652972091.3958); + + $parser = new RequestHeaderParser($clock); $parser->on('headers', function ($parsedRequest) use (&$request) { $request = $parsedRequest; @@ -697,8 +764,8 @@ public function testServerParamsWillNotSetRemoteAddressForUnixDomainSockets() $serverParams = $request->getServerParams(); $this->assertArrayNotHasKey('HTTPS', $serverParams); - $this->assertNotEmpty($serverParams['REQUEST_TIME']); - $this->assertNotEmpty($serverParams['REQUEST_TIME_FLOAT']); + $this->assertEquals(1652972091, $serverParams['REQUEST_TIME']); + $this->assertEquals(1652972091.3958, $serverParams['REQUEST_TIME_FLOAT']); $this->assertArrayNotHasKey('SERVER_ADDR', $serverParams); $this->assertArrayNotHasKey('SERVER_PORT', $serverParams); @@ -715,7 +782,10 @@ public function testServerParamsWontBeSetOnMissingUrls() $request = null; - $parser = new RequestHeaderParser(); + $clock = $this->getMockBuilder('React\Http\Io\Clock')->disableOriginalConstructor()->getMock(); + $clock->expects($this->once())->method('now')->willReturn(1652972091.3958); + + $parser = new RequestHeaderParser($clock); $parser->on('headers', function ($parsedRequest) use (&$request) { $request = $parsedRequest; @@ -728,8 +798,8 @@ public function testServerParamsWontBeSetOnMissingUrls() $serverParams = $request->getServerParams(); - $this->assertNotEmpty($serverParams['REQUEST_TIME']); - $this->assertNotEmpty($serverParams['REQUEST_TIME_FLOAT']); + $this->assertEquals(1652972091, $serverParams['REQUEST_TIME']); + $this->assertEquals(1652972091.3958, $serverParams['REQUEST_TIME_FLOAT']); $this->assertArrayNotHasKey('SERVER_ADDR', $serverParams); $this->assertArrayNotHasKey('SERVER_PORT', $serverParams); @@ -742,7 +812,9 @@ public function testQueryParmetersWillBeSet() { $request = null; - $parser = new RequestHeaderParser(); + $clock = $this->getMockBuilder('React\Http\Io\Clock')->disableOriginalConstructor()->getMock(); + + $parser = new RequestHeaderParser($clock); $parser->on('headers', function ($parsedRequest) use (&$request) { $request = $parsedRequest; diff --git a/tests/Io/StreamingServerTest.php b/tests/Io/StreamingServerTest.php index 45729f2b..2703362a 100644 --- a/tests/Io/StreamingServerTest.php +++ b/tests/Io/StreamingServerTest.php @@ -2292,12 +2292,20 @@ function ($data) use (&$buffer) { $this->assertContainsString("5\r\nhello\r\n", $buffer); } - public function testResponseWithoutExplicitDateHeaderWillAddCurrentDate() + public function testResponseWithoutExplicitDateHeaderWillAddCurrentDateFromClock() { $server = new StreamingServer(Loop::get(), function (ServerRequestInterface $request) { return new Response(); }); + $ref = new \ReflectionProperty($server, 'clock'); + $ref->setAccessible(true); + $clock = $ref->getValue($server); + + $ref = new \ReflectionProperty($clock, 'now'); + $ref->setAccessible(true); + $ref->setValue($clock, 1652972091.3958); + $buffer = ''; $this->connection ->expects($this->any()) @@ -2318,11 +2326,11 @@ function ($data) use (&$buffer) { $this->connection->emit('data', array($data)); $this->assertContainsString("HTTP/1.1 200 OK\r\n", $buffer); - $this->assertContainsString("Date:", $buffer); + $this->assertContainsString("Date: Thu, 19 May 2022 14:54:51 GMT\r\n", $buffer); $this->assertContainsString("\r\n\r\n", $buffer); } - public function testResponseWIthCustomDateHeaderOverwritesDefault() + public function testResponseWithCustomDateHeaderOverwritesDefault() { $server = new StreamingServer(Loop::get(), function (ServerRequestInterface $request) { return new Response( @@ -3022,7 +3030,8 @@ public function testRequestCookieWithSeparatorWillBeAddedToServerRequest() $this->assertEquals(array('hello' => 'world', 'test' => 'abc'), $requestValidation->getCookieParams()); } - public function testRequestCookieWithCommaValueWillBeAddedToServerRequest() { + public function testRequestCookieWithCommaValueWillBeAddedToServerRequest() + { $requestValidation = null; $server = new StreamingServer(Loop::get(), function (ServerRequestInterface $request) use (&$requestValidation) { $requestValidation = $request; @@ -3045,7 +3054,7 @@ public function testNewConnectionWillInvokeParserOnce() { $server = new StreamingServer(Loop::get(), $this->expectCallableNever()); - $parser = $this->getMockBuilder('React\Http\Io\RequestHeaderParser')->getMock(); + $parser = $this->getMockBuilder('React\Http\Io\RequestHeaderParser')->disableOriginalConstructor()->getMock(); $parser->expects($this->once())->method('handle'); $ref = new \ReflectionProperty($server, 'parser'); @@ -3062,7 +3071,7 @@ public function testNewConnectionWillInvokeParserOnceAndInvokeRequestHandlerWhen $server = new StreamingServer(Loop::get(), $this->expectCallableOnceWith($request)); - $parser = $this->getMockBuilder('React\Http\Io\RequestHeaderParser')->getMock(); + $parser = $this->getMockBuilder('React\Http\Io\RequestHeaderParser')->disableOriginalConstructor()->getMock(); $parser->expects($this->once())->method('handle'); $ref = new \ReflectionProperty($server, 'parser'); @@ -3085,7 +3094,7 @@ public function testNewConnectionWillInvokeParserOnceAndInvokeRequestHandlerWhen $server = new StreamingServer(Loop::get(), $this->expectCallableOnceWith($request)); - $parser = $this->getMockBuilder('React\Http\Io\RequestHeaderParser')->getMock(); + $parser = $this->getMockBuilder('React\Http\Io\RequestHeaderParser')->disableOriginalConstructor()->getMock(); $parser->expects($this->once())->method('handle'); $ref = new \ReflectionProperty($server, 'parser'); @@ -3110,7 +3119,7 @@ public function testNewConnectionWillInvokeParserOnceAndInvokeRequestHandlerWhen return new Response(200, array('Connection' => 'close')); }); - $parser = $this->getMockBuilder('React\Http\Io\RequestHeaderParser')->getMock(); + $parser = $this->getMockBuilder('React\Http\Io\RequestHeaderParser')->disableOriginalConstructor()->getMock(); $parser->expects($this->once())->method('handle'); $ref = new \ReflectionProperty($server, 'parser'); @@ -3135,7 +3144,7 @@ public function testNewConnectionWillInvokeParserTwiceAfterInvokingRequestHandle return new Response(); }); - $parser = $this->getMockBuilder('React\Http\Io\RequestHeaderParser')->getMock(); + $parser = $this->getMockBuilder('React\Http\Io\RequestHeaderParser')->disableOriginalConstructor()->getMock(); $parser->expects($this->exactly(2))->method('handle'); $ref = new \ReflectionProperty($server, 'parser'); @@ -3160,7 +3169,7 @@ public function testNewConnectionWillInvokeParserTwiceAfterInvokingRequestHandle return new Response(); }); - $parser = $this->getMockBuilder('React\Http\Io\RequestHeaderParser')->getMock(); + $parser = $this->getMockBuilder('React\Http\Io\RequestHeaderParser')->disableOriginalConstructor()->getMock(); $parser->expects($this->exactly(2))->method('handle'); $ref = new \ReflectionProperty($server, 'parser'); @@ -3186,7 +3195,7 @@ public function testNewConnectionWillInvokeParserOnceAfterInvokingRequestHandler return new Response(200, array(), $body); }); - $parser = $this->getMockBuilder('React\Http\Io\RequestHeaderParser')->getMock(); + $parser = $this->getMockBuilder('React\Http\Io\RequestHeaderParser')->disableOriginalConstructor()->getMock(); $parser->expects($this->once())->method('handle'); $ref = new \ReflectionProperty($server, 'parser'); @@ -3212,7 +3221,7 @@ public function testNewConnectionWillInvokeParserTwiceAfterInvokingRequestHandle return new Response(200, array(), $body); }); - $parser = $this->getMockBuilder('React\Http\Io\RequestHeaderParser')->getMock(); + $parser = $this->getMockBuilder('React\Http\Io\RequestHeaderParser')->disableOriginalConstructor()->getMock(); $parser->expects($this->exactly(2))->method('handle'); $ref = new \ReflectionProperty($server, 'parser'); From dc8ca43cc58dd3555d107d89bd5a9e36c13d4e7d Mon Sep 17 00:00:00 2001 From: Nicolas Hedger <649677+nhedger@users.noreply.github.com> Date: Tue, 14 Jun 2022 20:29:41 +0200 Subject: [PATCH 05/58] chore(docs): remove $ sign from shell commands --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index d3a93ceb..2d2eb4ce 100644 --- a/README.md +++ b/README.md @@ -2920,7 +2920,7 @@ This project follows [SemVer](https://semver.org/). This will install the latest supported version: ```bash -$ composer require react/http:^1.6 +composer require react/http:^1.6 ``` See also the [CHANGELOG](CHANGELOG.md) for details about version upgrades. @@ -2936,13 +2936,13 @@ To run the test suite, you first need to clone this repo and then install all dependencies [through Composer](https://getcomposer.org/): ```bash -$ composer install +composer install ``` To run the test suite, go to the project root and run: ```bash -$ vendor/bin/phpunit +vendor/bin/phpunit ``` The test suite also contains a number of functional integration tests that rely @@ -2950,7 +2950,7 @@ on a stable internet connection. If you do not want to run these, they can simply be skipped like this: ```bash -$ vendor/bin/phpunit --exclude-group internet +vendor/bin/phpunit --exclude-group internet ``` ## License From 8b2c5c899ba47ac357cd451752a1079a4386d158 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Sat, 18 Jun 2022 12:10:39 +0200 Subject: [PATCH 06/58] Refactor internal `Transaction` to avoid assigning dynamic properties --- src/Io/ClientRequestState.php | 16 +++++++ src/Io/Transaction.php | 79 +++++++++++++++----------------- tests/HttpServerTest.php | 3 ++ tests/Io/StreamingServerTest.php | 3 ++ 4 files changed, 59 insertions(+), 42 deletions(-) create mode 100644 src/Io/ClientRequestState.php diff --git a/src/Io/ClientRequestState.php b/src/Io/ClientRequestState.php new file mode 100644 index 00000000..73a63a14 --- /dev/null +++ b/src/Io/ClientRequestState.php @@ -0,0 +1,16 @@ +pending)) { - $deferred->pending->cancel(); - unset($deferred->pending); + $state = new ClientRequestState(); + $deferred = new Deferred(function () use ($state) { + if ($state->pending !== null) { + $state->pending->cancel(); + $state->pending = null; } }); - $deferred->numRequests = 0; - // use timeout from options or default to PHP's default_socket_timeout (60) $timeout = (float)($this->timeout !== null ? $this->timeout : ini_get("default_socket_timeout")); $loop = $this->loop; - $this->next($request, $deferred)->then( - function (ResponseInterface $response) use ($deferred, $loop, &$timeout) { - if (isset($deferred->timeout)) { - $loop->cancelTimer($deferred->timeout); - unset($deferred->timeout); + $this->next($request, $deferred, $state)->then( + function (ResponseInterface $response) use ($state, $deferred, $loop, &$timeout) { + if ($state->timeout !== null) { + $loop->cancelTimer($state->timeout); + $state->timeout = null; } $timeout = -1; $deferred->resolve($response); }, - function ($e) use ($deferred, $loop, &$timeout) { - if (isset($deferred->timeout)) { - $loop->cancelTimer($deferred->timeout); - unset($deferred->timeout); + function ($e) use ($state, $deferred, $loop, &$timeout) { + if ($state->timeout !== null) { + $loop->cancelTimer($state->timeout); + $state->timeout = null; } $timeout = -1; $deferred->reject($e); @@ -106,13 +105,13 @@ function ($e) use ($deferred, $loop, &$timeout) { $body = $request->getBody(); if ($body instanceof ReadableStreamInterface && $body->isReadable()) { $that = $this; - $body->on('close', function () use ($that, $deferred, &$timeout) { + $body->on('close', function () use ($that, $deferred, $state, &$timeout) { if ($timeout >= 0) { - $that->applyTimeout($deferred, $timeout); + $that->applyTimeout($deferred, $state, $timeout); } }); } else { - $this->applyTimeout($deferred, $timeout); + $this->applyTimeout($deferred, $state, $timeout); } return $deferred->promise(); @@ -120,53 +119,51 @@ function ($e) use ($deferred, $loop, &$timeout) { /** * @internal - * @param Deferred $deferred - * @param number $timeout + * @param number $timeout * @return void */ - public function applyTimeout(Deferred $deferred, $timeout) + public function applyTimeout(Deferred $deferred, ClientRequestState $state, $timeout) { - $deferred->timeout = $this->loop->addTimer($timeout, function () use ($timeout, $deferred) { + $state->timeout = $this->loop->addTimer($timeout, function () use ($timeout, $deferred, $state) { $deferred->reject(new \RuntimeException( 'Request timed out after ' . $timeout . ' seconds' )); - if (isset($deferred->pending)) { - $deferred->pending->cancel(); - unset($deferred->pending); + if ($state->pending !== null) { + $state->pending->cancel(); + $state->pending = null; } }); } - private function next(RequestInterface $request, Deferred $deferred) + private function next(RequestInterface $request, Deferred $deferred, ClientRequestState $state) { $this->progress('request', array($request)); $that = $this; - ++$deferred->numRequests; + ++$state->numRequests; $promise = $this->sender->send($request); if (!$this->streaming) { - $promise = $promise->then(function ($response) use ($deferred, $that) { - return $that->bufferResponse($response, $deferred); + $promise = $promise->then(function ($response) use ($deferred, $state, $that) { + return $that->bufferResponse($response, $deferred, $state); }); } - $deferred->pending = $promise; + $state->pending = $promise; return $promise->then( - function (ResponseInterface $response) use ($request, $that, $deferred) { - return $that->onResponse($response, $request, $deferred); + function (ResponseInterface $response) use ($request, $that, $deferred, $state) { + return $that->onResponse($response, $request, $deferred, $state); } ); } /** * @internal - * @param ResponseInterface $response * @return PromiseInterface Promise */ - public function bufferResponse(ResponseInterface $response, $deferred) + public function bufferResponse(ResponseInterface $response, Deferred $deferred, ClientRequestState $state) { $stream = $response->getBody(); @@ -205,26 +202,24 @@ function ($e) use ($stream, $maximumSize) { } ); - $deferred->pending = $promise; + $state->pending = $promise; return $promise; } /** * @internal - * @param ResponseInterface $response - * @param RequestInterface $request * @throws ResponseException * @return ResponseInterface|PromiseInterface */ - public function onResponse(ResponseInterface $response, RequestInterface $request, $deferred) + public function onResponse(ResponseInterface $response, RequestInterface $request, Deferred $deferred, ClientRequestState $state) { $this->progress('response', array($response, $request)); // follow 3xx (Redirection) response status codes if Location header is present and not explicitly disabled // @link https://tools.ietf.org/html/rfc7231#section-6.4 if ($this->followRedirects && ($response->getStatusCode() >= 300 && $response->getStatusCode() < 400) && $response->hasHeader('Location')) { - return $this->onResponseRedirect($response, $request, $deferred); + return $this->onResponseRedirect($response, $request, $deferred, $state); } // only status codes 200-399 are considered to be valid, reject otherwise @@ -242,7 +237,7 @@ public function onResponse(ResponseInterface $response, RequestInterface $reques * @return PromiseInterface * @throws \RuntimeException */ - private function onResponseRedirect(ResponseInterface $response, RequestInterface $request, Deferred $deferred) + private function onResponseRedirect(ResponseInterface $response, RequestInterface $request, Deferred $deferred, ClientRequestState $state) { // resolve location relative to last request URI $location = Uri::resolve($request->getUri(), $response->getHeaderLine('Location')); @@ -250,11 +245,11 @@ private function onResponseRedirect(ResponseInterface $response, RequestInterfac $request = $this->makeRedirectRequest($request, $location); $this->progress('redirect', array($request)); - if ($deferred->numRequests >= $this->maxRedirects) { + if ($state->numRequests >= $this->maxRedirects) { throw new \RuntimeException('Maximum number of redirects (' . $this->maxRedirects . ') exceeded'); } - return $this->next($request, $deferred); + return $this->next($request, $deferred, $state); } /** diff --git a/tests/HttpServerTest.php b/tests/HttpServerTest.php index 4d00fcef..31bc32ee 100644 --- a/tests/HttpServerTest.php +++ b/tests/HttpServerTest.php @@ -17,6 +17,9 @@ final class HttpServerTest extends TestCase private $connection; private $socket; + /** @var ?int */ + private $called = null; + /** * @before */ diff --git a/tests/Io/StreamingServerTest.php b/tests/Io/StreamingServerTest.php index 2703362a..a2700b86 100644 --- a/tests/Io/StreamingServerTest.php +++ b/tests/Io/StreamingServerTest.php @@ -17,6 +17,9 @@ class StreamingServerTest extends TestCase private $connection; private $socket; + /** @var ?int */ + private $called = null; + /** * @before */ From 90413fb088d701f77f12e50e5f471e3f000aca2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Tue, 9 Aug 2022 10:50:19 +0200 Subject: [PATCH 07/58] Avoid referencing unneeded explicit loop instance --- README.md | 4 ++-- tests/Io/SenderTest.php | 32 ++++++++++++++++++++++++-------- tests/Io/TransactionTest.php | 26 ++++++++++++++++---------- 3 files changed, 42 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 2d2eb4ce..752c01ff 100644 --- a/README.md +++ b/README.md @@ -386,7 +386,7 @@ $browser = new React\Http\Browser(); $promise = $browser->get('http://example.com/'); try { - $response = Block\await($promise, Loop::get()); + $response = Block\await($promise); // response successfully received } catch (Exception $e) { // an error occurred while performing the request @@ -401,7 +401,7 @@ $promises = array( $browser->get('http://www.example.org/'), ); -$responses = Block\awaitAll($promises, Loop::get()); +$responses = Block\awaitAll($promises); ``` Please refer to [clue/reactphp-block](https://github.com/clue/reactphp-block#readme) for more details. diff --git a/tests/Io/SenderTest.php b/tests/Io/SenderTest.php index 1c6d1d6b..8597f17a 100644 --- a/tests/Io/SenderTest.php +++ b/tests/Io/SenderTest.php @@ -42,8 +42,12 @@ public function testSenderRejectsInvalidUri() $promise = $sender->send($request); - $this->setExpectedException('InvalidArgumentException'); - Block\await($promise, $this->loop); + $exception = null; + $promise->then(null, function ($e) use (&$exception) { + $exception = $e; + }); + + $this->assertInstanceOf('InvalidArgumentException', $exception); } public function testSenderConnectorRejection() @@ -57,8 +61,12 @@ public function testSenderConnectorRejection() $promise = $sender->send($request); - $this->setExpectedException('RuntimeException'); - Block\await($promise, $this->loop); + $exception = null; + $promise->then(null, function ($e) use (&$exception) { + $exception = $e; + }); + + $this->assertInstanceOf('RuntimeException', $exception); } public function testSendPostWillAutomaticallySendContentLengthHeader() @@ -318,8 +326,12 @@ public function testCancelRequestWillCancelConnector() $promise = $sender->send($request); $promise->cancel(); - $this->setExpectedException('RuntimeException'); - Block\await($promise, $this->loop); + $exception = null; + $promise->then(null, function ($e) use (&$exception) { + $exception = $e; + }); + + $this->assertInstanceOf('RuntimeException', $exception); } public function testCancelRequestWillCloseConnection() @@ -337,8 +349,12 @@ public function testCancelRequestWillCloseConnection() $promise = $sender->send($request); $promise->cancel(); - $this->setExpectedException('RuntimeException'); - Block\await($promise, $this->loop); + $exception = null; + $promise->then(null, function ($e) use (&$exception) { + $exception = $e; + }); + + $this->assertInstanceOf('RuntimeException', $exception); } public function provideRequestProtocolVersion() diff --git a/tests/Io/TransactionTest.php b/tests/Io/TransactionTest.php index d62147b5..835e299c 100644 --- a/tests/Io/TransactionTest.php +++ b/tests/Io/TransactionTest.php @@ -5,7 +5,8 @@ use Clue\React\Block; use PHPUnit\Framework\MockObject\MockObject; use Psr\Http\Message\RequestInterface; -use RingCentral\Psr7\Response; +use Psr\Http\Message\ResponseInterface; +use React\Http\Io\ReadableBodyStream; use React\Http\Io\Transaction; use React\Http\Message\ResponseException; use React\EventLoop\Loop; @@ -14,7 +15,7 @@ use React\Stream\ThroughStream; use React\Tests\Http\TestCase; use RingCentral\Psr7\Request; -use React\Http\Io\ReadableBodyStream; +use RingCentral\Psr7\Response; class TransactionTest extends TestCase { @@ -372,13 +373,14 @@ public function testReceivingErrorResponseWillRejectWithResponseException() $transaction = $transaction->withOptions(array('timeout' => -1)); $promise = $transaction->send($request); - try { - Block\await($promise, $loop); - $this->fail(); - } catch (ResponseException $exception) { - $this->assertEquals(404, $exception->getCode()); - $this->assertSame($response, $exception->getResponse()); - } + $exception = null; + $promise->then(null, function ($reason) use (&$exception) { + $exception = $reason; + }); + + assert($exception instanceof ResponseException); + $this->assertEquals(404, $exception->getCode()); + $this->assertSame($response, $exception->getResponse()); } public function testReceivingStreamingBodyWillResolveWithBufferedResponseByDefault() @@ -461,8 +463,12 @@ public function testReceivingStreamingBodyWillResolveWithStreamingResponseIfStre $transaction = $transaction->withOptions(array('streaming' => true, 'timeout' => -1)); $promise = $transaction->send($request); - $response = Block\await($promise, $loop); + $response = null; + $promise->then(function ($value) use (&$response) { + $response = $value; + }); + assert($response instanceof ResponseInterface); $this->assertEquals(200, $response->getStatusCode()); $this->assertEquals('', (string)$response->getBody()); } From a2ae0f1eb655ec9766888c121f4182faba44dac1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Mon, 8 Aug 2022 19:20:55 +0200 Subject: [PATCH 08/58] Avoid using deprecated functions from clue/reactphp-block --- composer.json | 3 +- tests/Client/FunctionalIntegrationTest.php | 10 ++--- tests/FunctionalBrowserTest.php | 2 +- tests/FunctionalHttpServerTest.php | 46 +++++++++++----------- tests/Io/TransactionTest.php | 4 +- 5 files changed, 33 insertions(+), 32 deletions(-) diff --git a/composer.json b/composer.json index 4c9a0383..0d23e281 100644 --- a/composer.json +++ b/composer.json @@ -42,7 +42,8 @@ "clue/http-proxy-react": "^1.7", "clue/reactphp-ssh-proxy": "^1.3", "clue/socks-react": "^1.3", - "phpunit/phpunit": "^9.3 || ^5.7 || ^4.8.35" + "phpunit/phpunit": "^9.3 || ^5.7 || ^4.8.35", + "react/promise-timer": "^1.9" }, "autoload": { "psr-4": { "React\\Http\\": "src" } diff --git a/tests/Client/FunctionalIntegrationTest.php b/tests/Client/FunctionalIntegrationTest.php index 64a3ea8a..40b40a54 100644 --- a/tests/Client/FunctionalIntegrationTest.php +++ b/tests/Client/FunctionalIntegrationTest.php @@ -51,7 +51,7 @@ public function testRequestToLocalhostEmitsSingleRemoteConnection() $promise = Stream\first($request, 'close'); $request->end(); - Block\await($promise, null, self::TIMEOUT_LOCAL); + Block\await(\React\Promise\Timer\timeout($promise, self::TIMEOUT_LOCAL)); } public function testRequestLegacyHttpServerWithOnlyLineFeedReturnsSuccessfulResponse() @@ -73,7 +73,7 @@ public function testRequestLegacyHttpServerWithOnlyLineFeedReturnsSuccessfulResp $promise = Stream\first($request, 'close'); $request->end(); - Block\await($promise, null, self::TIMEOUT_LOCAL); + Block\await(\React\Promise\Timer\timeout($promise, self::TIMEOUT_LOCAL)); } /** @group internet */ @@ -94,7 +94,7 @@ public function testSuccessfulResponseEmitsEnd() $promise = Stream\first($request, 'close'); $request->end(); - Block\await($promise, null, self::TIMEOUT_REMOTE); + Block\await(\React\Promise\Timer\timeout($promise, self::TIMEOUT_REMOTE)); } /** @group internet */ @@ -122,7 +122,7 @@ public function testPostDataReturnsData() $request->end($data); - $buffer = Block\await($deferred->promise(), null, self::TIMEOUT_REMOTE); + $buffer = Block\await(\React\Promise\Timer\timeout($deferred->promise(), self::TIMEOUT_REMOTE)); $this->assertNotEquals('', $buffer); @@ -154,7 +154,7 @@ public function testPostJsonReturnsData() $request->end($data); - $buffer = Block\await($deferred->promise(), null, self::TIMEOUT_REMOTE); + $buffer = Block\await(\React\Promise\Timer\timeout($deferred->promise(), self::TIMEOUT_REMOTE)); $this->assertNotEquals('', $buffer); diff --git a/tests/FunctionalBrowserTest.php b/tests/FunctionalBrowserTest.php index 2b7bd58c..a9cc7244 100644 --- a/tests/FunctionalBrowserTest.php +++ b/tests/FunctionalBrowserTest.php @@ -531,7 +531,7 @@ public function testReceiveStreamAndExplicitlyCloseConnectionEvenWhenServerKeeps $response = Block\await($this->browser->get($this->base . 'get', array())); $this->assertEquals('hello', (string)$response->getBody()); - $ret = Block\await($closed->promise(), null, 0.1); + $ret = Block\await(\React\Promise\Timer\timeout($closed->promise(), 0.1)); $this->assertTrue($ret); $socket->close(); diff --git a/tests/FunctionalHttpServerTest.php b/tests/FunctionalHttpServerTest.php index 6fa85903..f543fb55 100644 --- a/tests/FunctionalHttpServerTest.php +++ b/tests/FunctionalHttpServerTest.php @@ -37,7 +37,7 @@ public function testPlainHttpOnRandomPort() return Stream\buffer($conn); }); - $response = Block\await($result, null, 1.0); + $response = Block\await(\React\Promise\Timer\timeout($result, 1.0)); $this->assertContainsString("HTTP/1.0 200 OK", $response); $this->assertContainsString('http://' . noScheme($socket->getAddress()) . '/', $response); @@ -64,7 +64,7 @@ function () { return Stream\buffer($conn); }); - $response = Block\await($result, null, 1.0); + $response = Block\await(\React\Promise\Timer\timeout($result, 1.0)); $this->assertContainsString("HTTP/1.0 404 Not Found", $response); @@ -88,7 +88,7 @@ public function testPlainHttpOnRandomPortWithoutHostHeaderUsesSocketUri() return Stream\buffer($conn); }); - $response = Block\await($result, null, 1.0); + $response = Block\await(\React\Promise\Timer\timeout($result, 1.0)); $this->assertContainsString("HTTP/1.0 200 OK", $response); $this->assertContainsString('http://' . noScheme($socket->getAddress()) . '/', $response); @@ -113,7 +113,7 @@ public function testPlainHttpOnRandomPortWithOtherHostHeaderTakesPrecedence() return Stream\buffer($conn); }); - $response = Block\await($result, null, 1.0); + $response = Block\await(\React\Promise\Timer\timeout($result, 1.0)); $this->assertContainsString("HTTP/1.0 200 OK", $response); $this->assertContainsString('http://localhost:1000/', $response); @@ -146,7 +146,7 @@ public function testSecureHttpsOnRandomPort() return Stream\buffer($conn); }); - $response = Block\await($result, null, 1.0); + $response = Block\await(\React\Promise\Timer\timeout($result, 1.0)); $this->assertContainsString("HTTP/1.0 200 OK", $response); $this->assertContainsString('https://' . noScheme($socket->getAddress()) . '/', $response); @@ -183,7 +183,7 @@ public function testSecureHttpsReturnsData() return Stream\buffer($conn); }); - $response = Block\await($result, null, 1.0); + $response = Block\await(\React\Promise\Timer\timeout($result, 1.0)); $this->assertContainsString("HTTP/1.0 200 OK", $response); $this->assertContainsString("\r\nContent-Length: 33000\r\n", $response); @@ -217,7 +217,7 @@ public function testSecureHttpsOnRandomPortWithoutHostHeaderUsesSocketUri() return Stream\buffer($conn); }); - $response = Block\await($result, null, 1.0); + $response = Block\await(\React\Promise\Timer\timeout($result, 1.0)); $this->assertContainsString("HTTP/1.0 200 OK", $response); $this->assertContainsString('https://' . noScheme($socket->getAddress()) . '/', $response); @@ -246,7 +246,7 @@ public function testPlainHttpOnStandardPortReturnsUriWithNoPort() return Stream\buffer($conn); }); - $response = Block\await($result, null, 1.0); + $response = Block\await(\React\Promise\Timer\timeout($result, 1.0)); $this->assertContainsString("HTTP/1.0 200 OK", $response); $this->assertContainsString('http://127.0.0.1/', $response); @@ -275,7 +275,7 @@ public function testPlainHttpOnStandardPortWithoutHostHeaderReturnsUriWithNoPort return Stream\buffer($conn); }); - $response = Block\await($result, null, 1.0); + $response = Block\await(\React\Promise\Timer\timeout($result, 1.0)); $this->assertContainsString("HTTP/1.0 200 OK", $response); $this->assertContainsString('http://127.0.0.1/', $response); @@ -313,7 +313,7 @@ public function testSecureHttpsOnStandardPortReturnsUriWithNoPort() return Stream\buffer($conn); }); - $response = Block\await($result, null, 1.0); + $response = Block\await(\React\Promise\Timer\timeout($result, 1.0)); $this->assertContainsString("HTTP/1.0 200 OK", $response); $this->assertContainsString('https://127.0.0.1/', $response); @@ -351,7 +351,7 @@ public function testSecureHttpsOnStandardPortWithoutHostHeaderUsesSocketUri() return Stream\buffer($conn); }); - $response = Block\await($result, null, 1.0); + $response = Block\await(\React\Promise\Timer\timeout($result, 1.0)); $this->assertContainsString("HTTP/1.0 200 OK", $response); $this->assertContainsString('https://127.0.0.1/', $response); @@ -380,7 +380,7 @@ public function testPlainHttpOnHttpsStandardPortReturnsUriWithPort() return Stream\buffer($conn); }); - $response = Block\await($result, null, 1.0); + $response = Block\await(\React\Promise\Timer\timeout($result, 1.0)); $this->assertContainsString("HTTP/1.0 200 OK", $response); $this->assertContainsString('http://127.0.0.1:443/', $response); @@ -418,7 +418,7 @@ public function testSecureHttpsOnHttpStandardPortReturnsUriWithPort() return Stream\buffer($conn); }); - $response = Block\await($result, null, 1.0); + $response = Block\await(\React\Promise\Timer\timeout($result, 1.0)); $this->assertContainsString("HTTP/1.0 200 OK", $response); $this->assertContainsString('https://127.0.0.1:80/', $response); @@ -446,7 +446,7 @@ public function testClosedStreamFromRequestHandlerWillSendEmptyBody() return Stream\buffer($conn); }); - $response = Block\await($result, null, 1.0); + $response = Block\await(\React\Promise\Timer\timeout($result, 1.0)); $this->assertStringStartsWith("HTTP/1.0 200 OK", $response); $this->assertStringEndsWith("\r\n\r\n", $response); @@ -477,7 +477,7 @@ function (RequestInterface $request) use ($once) { }); }); - Block\sleep(0.1); + \Clue\React\Block\await(\React\Promise\Timer\sleep(0.1)); $socket->close(); } @@ -507,7 +507,7 @@ function (RequestInterface $request) use ($stream) { }); // stream will be closed within 0.1s - $ret = Block\await(Stream\first($stream, 'close'), null, 0.1); + $ret = Block\await(\React\Promise\Timer\timeout(Stream\first($stream, 'close'), 0.1)); $socket->close(); @@ -536,7 +536,7 @@ public function testStreamFromRequestHandlerWillBeClosedIfConnectionCloses() }); // await response stream to be closed - $ret = Block\await(Stream\first($stream, 'close'), null, 1.0); + $ret = Block\await(\React\Promise\Timer\timeout(Stream\first($stream, 'close'), 1.0)); $socket->close(); @@ -571,7 +571,7 @@ public function testUpgradeWithThroughStreamReturnsDataAsGiven() return Stream\buffer($conn); }); - $response = Block\await($result, null, 1.0); + $response = Block\await(\React\Promise\Timer\timeout($result, 1.0)); $this->assertStringStartsWith("HTTP/1.1 101 Switching Protocols\r\n", $response); $this->assertStringEndsWith("\r\n\r\nhelloworld", $response); @@ -608,7 +608,7 @@ public function testUpgradeWithRequestBodyAndThroughStreamReturnsDataAsGiven() return Stream\buffer($conn); }); - $response = Block\await($result, null, 1.0); + $response = Block\await(\React\Promise\Timer\timeout($result, 1.0)); $this->assertStringStartsWith("HTTP/1.1 101 Switching Protocols\r\n", $response); $this->assertStringEndsWith("\r\n\r\nhelloworld", $response); @@ -644,7 +644,7 @@ public function testConnectWithThroughStreamReturnsDataAsGiven() return Stream\buffer($conn); }); - $response = Block\await($result, null, 1.0); + $response = Block\await(\React\Promise\Timer\timeout($result, 1.0)); $this->assertStringStartsWith("HTTP/1.1 200 OK\r\n", $response); $this->assertStringEndsWith("\r\n\r\nhelloworld", $response); @@ -684,7 +684,7 @@ public function testConnectWithThroughStreamReturnedFromPromiseReturnsDataAsGive return Stream\buffer($conn); }); - $response = Block\await($result, null, 1.0); + $response = Block\await(\React\Promise\Timer\timeout($result, 1.0)); $this->assertStringStartsWith("HTTP/1.1 200 OK\r\n", $response); $this->assertStringEndsWith("\r\n\r\nhelloworld", $response); @@ -717,7 +717,7 @@ public function testConnectWithClosedThroughStreamReturnsNoData() return Stream\buffer($conn); }); - $response = Block\await($result, null, 1.0); + $response = Block\await(\React\Promise\Timer\timeout($result, 1.0)); $this->assertStringStartsWith("HTTP/1.1 200 OK\r\n", $response); $this->assertStringEndsWith("\r\n\r\n", $response); @@ -760,7 +760,7 @@ function (ServerRequestInterface $request) { }); } - $responses = Block\await(Promise\all($result), null, 1.0); + $responses = Block\await(\React\Promise\Timer\timeout(Promise\all($result), 1.0)); foreach ($responses as $response) { $this->assertContainsString("HTTP/1.0 200 OK", $response, $response); diff --git a/tests/Io/TransactionTest.php b/tests/Io/TransactionTest.php index 835e299c..56b9d4f4 100644 --- a/tests/Io/TransactionTest.php +++ b/tests/Io/TransactionTest.php @@ -424,7 +424,7 @@ public function testReceivingStreamingBodyWithSizeExceedingMaximumResponseBuffer $promise = $transaction->send($request); $this->setExpectedException('OverflowException'); - Block\await($promise, null, 0.001); + Block\await(\React\Promise\Timer\timeout($promise, 0.001)); } public function testCancelBufferingResponseWillCloseStreamAndReject() @@ -445,7 +445,7 @@ public function testCancelBufferingResponseWillCloseStreamAndReject() $promise->cancel(); $this->setExpectedException('RuntimeException'); - Block\await($promise, null, 0.001); + Block\await(\React\Promise\Timer\timeout($promise, 0.001)); } public function testReceivingStreamingBodyWillResolveWithStreamingResponseIfStreamingIsEnabled() From 9946ba7bb7330cf72f96d744608fd0acea6fc7d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Tue, 9 Aug 2022 10:55:31 +0200 Subject: [PATCH 09/58] Update to use new reactphp/async package instead of clue/reactphp-block --- README.md | 20 +-- composer.json | 2 +- tests/BrowserTest.php | 1 - tests/Client/FunctionalIntegrationTest.php | 11 +- tests/FunctionalBrowserTest.php | 123 ++++++++++-------- tests/FunctionalHttpServerTest.php | 47 ++++--- tests/HttpServerTest.php | 7 +- tests/Io/MiddlewareRunnerTest.php | 5 +- tests/Io/SenderTest.php | 1 - tests/Io/TransactionTest.php | 7 +- .../RequestBodyBufferMiddlewareTest.php | 9 +- 11 files changed, 123 insertions(+), 110 deletions(-) diff --git a/README.md b/README.md index 752c01ff..ba8d8330 100644 --- a/README.md +++ b/README.md @@ -373,20 +373,19 @@ See also [`withFollowRedirects()`](#withfollowredirects) for more details. As stated above, this library provides you a powerful, async API by default. -If, however, you want to integrate this into your traditional, blocking environment, -you should look into also using [clue/reactphp-block](https://github.com/clue/reactphp-block). - -The resulting blocking code could look something like this: +You can also integrate this into your traditional, blocking environment by using +[reactphp/async](https://github.com/reactphp/async). This allows you to simply +await async HTTP requests like this: ```php -use Clue\React\Block; +use function React\Async\await; $browser = new React\Http\Browser(); $promise = $browser->get('http://example.com/'); try { - $response = Block\await($promise); + $response = await($promise); // response successfully received } catch (Exception $e) { // an error occurred while performing the request @@ -396,15 +395,20 @@ try { Similarly, you can also process multiple requests concurrently and await an array of `Response` objects: ```php +use function React\Async\await; +use function React\Promise\all; + $promises = array( $browser->get('http://example.com/'), $browser->get('http://www.example.org/'), ); -$responses = Block\awaitAll($promises); +$responses = await(all($promises)); ``` -Please refer to [clue/reactphp-block](https://github.com/clue/reactphp-block#readme) for more details. +This is made possible thanks to fibers available in PHP 8.1+ and our +compatibility API that also works on all supported PHP versions. +Please refer to [reactphp/async](https://github.com/reactphp/async#readme) for more details. Keep in mind the above remark about buffering the whole response message in memory. As an alternative, you may also see one of the following chapters for the diff --git a/composer.json b/composer.json index 0d23e281..57adfc2c 100644 --- a/composer.json +++ b/composer.json @@ -38,11 +38,11 @@ "ringcentral/psr7": "^1.2" }, "require-dev": { - "clue/block-react": "^1.5", "clue/http-proxy-react": "^1.7", "clue/reactphp-ssh-proxy": "^1.3", "clue/socks-react": "^1.3", "phpunit/phpunit": "^9.3 || ^5.7 || ^4.8.35", + "react/async": "^4 || ^3 || ^2", "react/promise-timer": "^1.9" }, "autoload": { diff --git a/tests/BrowserTest.php b/tests/BrowserTest.php index 39be453a..f14b9ee6 100644 --- a/tests/BrowserTest.php +++ b/tests/BrowserTest.php @@ -2,7 +2,6 @@ namespace React\Tests\Http; -use Clue\React\Block; use Psr\Http\Message\RequestInterface; use React\Http\Browser; use React\Promise\Promise; diff --git a/tests/Client/FunctionalIntegrationTest.php b/tests/Client/FunctionalIntegrationTest.php index 40b40a54..d95bf828 100644 --- a/tests/Client/FunctionalIntegrationTest.php +++ b/tests/Client/FunctionalIntegrationTest.php @@ -2,7 +2,6 @@ namespace React\Tests\Http\Client; -use Clue\React\Block; use Psr\Http\Message\ResponseInterface; use React\EventLoop\Loop; use React\Http\Client\Client; @@ -51,7 +50,7 @@ public function testRequestToLocalhostEmitsSingleRemoteConnection() $promise = Stream\first($request, 'close'); $request->end(); - Block\await(\React\Promise\Timer\timeout($promise, self::TIMEOUT_LOCAL)); + \React\Async\await(\React\Promise\Timer\timeout($promise, self::TIMEOUT_LOCAL)); } public function testRequestLegacyHttpServerWithOnlyLineFeedReturnsSuccessfulResponse() @@ -73,7 +72,7 @@ public function testRequestLegacyHttpServerWithOnlyLineFeedReturnsSuccessfulResp $promise = Stream\first($request, 'close'); $request->end(); - Block\await(\React\Promise\Timer\timeout($promise, self::TIMEOUT_LOCAL)); + \React\Async\await(\React\Promise\Timer\timeout($promise, self::TIMEOUT_LOCAL)); } /** @group internet */ @@ -94,7 +93,7 @@ public function testSuccessfulResponseEmitsEnd() $promise = Stream\first($request, 'close'); $request->end(); - Block\await(\React\Promise\Timer\timeout($promise, self::TIMEOUT_REMOTE)); + \React\Async\await(\React\Promise\Timer\timeout($promise, self::TIMEOUT_REMOTE)); } /** @group internet */ @@ -122,7 +121,7 @@ public function testPostDataReturnsData() $request->end($data); - $buffer = Block\await(\React\Promise\Timer\timeout($deferred->promise(), self::TIMEOUT_REMOTE)); + $buffer = \React\Async\await(\React\Promise\Timer\timeout($deferred->promise(), self::TIMEOUT_REMOTE)); $this->assertNotEquals('', $buffer); @@ -154,7 +153,7 @@ public function testPostJsonReturnsData() $request->end($data); - $buffer = Block\await(\React\Promise\Timer\timeout($deferred->promise(), self::TIMEOUT_REMOTE)); + $buffer = \React\Async\await(\React\Promise\Timer\timeout($deferred->promise(), self::TIMEOUT_REMOTE)); $this->assertNotEquals('', $buffer); diff --git a/tests/FunctionalBrowserTest.php b/tests/FunctionalBrowserTest.php index a9cc7244..95092ac1 100644 --- a/tests/FunctionalBrowserTest.php +++ b/tests/FunctionalBrowserTest.php @@ -2,7 +2,6 @@ namespace React\Tests\Http; -use Clue\React\Block; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use React\EventLoop\Loop; @@ -24,6 +23,9 @@ class FunctionalBrowserTest extends TestCase private $browser; private $base; + /** @var ?SocketServer */ + private $socket; + /** * @before */ @@ -88,14 +90,17 @@ public function setUpBrowserAndServer() } if ($path === '/delay/10') { - return new Promise(function ($resolve) { - Loop::addTimer(10, function () use ($resolve) { + $timer = null; + return new Promise(function ($resolve) use (&$timer) { + $timer = Loop::addTimer(10, function () use ($resolve) { $resolve(new Response( 200, array(), 'hello' )); }); + }, function () use (&$timer) { + Loop::cancelTimer($timer); }); } @@ -140,10 +145,20 @@ public function setUpBrowserAndServer() var_dump($path); }); - $socket = new SocketServer('127.0.0.1:0'); - $http->listen($socket); - $this->base = str_replace('tcp:', 'http:', $socket->getAddress()) . '/'; + $this->socket = new SocketServer('127.0.0.1:0'); + $http->listen($this->socket); + + $this->base = str_replace('tcp:', 'http:', $this->socket->getAddress()) . '/'; + } + + /** + * @after + */ + public function cleanUpSocketServer() + { + $this->socket->close(); + $this->socket = null; } /** @@ -151,7 +166,7 @@ public function setUpBrowserAndServer() */ public function testSimpleRequest() { - Block\await($this->browser->get($this->base . 'get')); + \React\Async\await($this->browser->get($this->base . 'get')); } public function testGetRequestWithRelativeAddressRejects() @@ -159,7 +174,7 @@ public function testGetRequestWithRelativeAddressRejects() $promise = $this->browser->get('delay'); $this->setExpectedException('InvalidArgumentException', 'Invalid request URL given'); - Block\await($promise); + \React\Async\await($promise); } /** @@ -167,7 +182,7 @@ public function testGetRequestWithRelativeAddressRejects() */ public function testGetRequestWithBaseAndRelativeAddressResolves() { - Block\await($this->browser->withBase($this->base)->get('get')); + \React\Async\await($this->browser->withBase($this->base)->get('get')); } /** @@ -175,7 +190,7 @@ public function testGetRequestWithBaseAndRelativeAddressResolves() */ public function testGetRequestWithBaseAndFullAddressResolves() { - Block\await($this->browser->withBase('http://example.com/')->get($this->base . 'get')); + \React\Async\await($this->browser->withBase('http://example.com/')->get($this->base . 'get')); } public function testCancelGetRequestWillRejectRequest() @@ -184,7 +199,7 @@ public function testCancelGetRequestWillRejectRequest() $promise->cancel(); $this->setExpectedException('RuntimeException'); - Block\await($promise); + \React\Async\await($promise); } public function testCancelRequestWithPromiseFollowerWillRejectRequest() @@ -195,13 +210,13 @@ public function testCancelRequestWithPromiseFollowerWillRejectRequest() $promise->cancel(); $this->setExpectedException('RuntimeException'); - Block\await($promise); + \React\Async\await($promise); } public function testRequestWithoutAuthenticationFails() { $this->setExpectedException('RuntimeException'); - Block\await($this->browser->get($this->base . 'basic-auth/user/pass')); + \React\Async\await($this->browser->get($this->base . 'basic-auth/user/pass')); } /** @@ -211,7 +226,7 @@ public function testRequestWithAuthenticationSucceeds() { $base = str_replace('://', '://user:pass@', $this->base); - Block\await($this->browser->get($base . 'basic-auth/user/pass')); + \React\Async\await($this->browser->get($base . 'basic-auth/user/pass')); } /** @@ -225,7 +240,7 @@ public function testRedirectToPageWithAuthenticationSendsAuthenticationFromLocat { $target = str_replace('://', '://user:pass@', $this->base) . 'basic-auth/user/pass'; - Block\await($this->browser->get($this->base . 'redirect-to?url=' . urlencode($target))); + \React\Async\await($this->browser->get($this->base . 'redirect-to?url=' . urlencode($target))); } /** @@ -240,7 +255,7 @@ public function testRedirectFromPageWithInvalidAuthToPageWithCorrectAuthenticati $base = str_replace('://', '://unknown:invalid@', $this->base); $target = str_replace('://', '://user:pass@', $this->base) . 'basic-auth/user/pass'; - Block\await($this->browser->get($base . 'redirect-to?url=' . urlencode($target))); + \React\Async\await($this->browser->get($base . 'redirect-to?url=' . urlencode($target))); } public function testCancelRedirectedRequestShouldReject() @@ -252,7 +267,7 @@ public function testCancelRedirectedRequestShouldReject() }); $this->setExpectedException('RuntimeException', 'Request cancelled'); - Block\await($promise); + \React\Async\await($promise); } public function testTimeoutDelayedResponseShouldReject() @@ -260,7 +275,7 @@ public function testTimeoutDelayedResponseShouldReject() $promise = $this->browser->withTimeout(0.1)->get($this->base . 'delay/10'); $this->setExpectedException('RuntimeException', 'Request timed out after 0.1 seconds'); - Block\await($promise); + \React\Async\await($promise); } public function testTimeoutDelayedResponseAfterStreamingRequestShouldReject() @@ -270,7 +285,7 @@ public function testTimeoutDelayedResponseAfterStreamingRequestShouldReject() $stream->end(); $this->setExpectedException('RuntimeException', 'Request timed out after 0.1 seconds'); - Block\await($promise); + \React\Async\await($promise); } /** @@ -278,7 +293,7 @@ public function testTimeoutDelayedResponseAfterStreamingRequestShouldReject() */ public function testTimeoutFalseShouldResolveSuccessfully() { - Block\await($this->browser->withTimeout(false)->get($this->base . 'get')); + \React\Async\await($this->browser->withTimeout(false)->get($this->base . 'get')); } /** @@ -286,7 +301,7 @@ public function testTimeoutFalseShouldResolveSuccessfully() */ public function testRedirectRequestRelative() { - Block\await($this->browser->get($this->base . 'redirect-to?url=get')); + \React\Async\await($this->browser->get($this->base . 'redirect-to?url=get')); } /** @@ -294,7 +309,7 @@ public function testRedirectRequestRelative() */ public function testRedirectRequestAbsolute() { - Block\await($this->browser->get($this->base . 'redirect-to?url=' . urlencode($this->base . 'get'))); + \React\Async\await($this->browser->get($this->base . 'redirect-to?url=' . urlencode($this->base . 'get'))); } /** @@ -304,7 +319,7 @@ public function testFollowingRedirectsFalseResolvesWithRedirectResult() { $browser = $this->browser->withFollowRedirects(false); - Block\await($browser->get($this->base . 'redirect-to?url=get')); + \React\Async\await($browser->get($this->base . 'redirect-to?url=get')); } public function testFollowRedirectsZeroRejectsOnRedirect() @@ -312,12 +327,12 @@ public function testFollowRedirectsZeroRejectsOnRedirect() $browser = $this->browser->withFollowRedirects(0); $this->setExpectedException('RuntimeException'); - Block\await($browser->get($this->base . 'redirect-to?url=get')); + \React\Async\await($browser->get($this->base . 'redirect-to?url=get')); } public function testResponseStatus204ShouldResolveWithEmptyBody() { - $response = Block\await($this->browser->get($this->base . 'status/204')); + $response = \React\Async\await($this->browser->get($this->base . 'status/204')); $this->assertFalse($response->hasHeader('Content-Length')); $body = $response->getBody(); @@ -327,7 +342,7 @@ public function testResponseStatus204ShouldResolveWithEmptyBody() public function testResponseStatus304ShouldResolveWithEmptyBodyButContentLengthResponseHeader() { - $response = Block\await($this->browser->get($this->base . 'status/304')); + $response = \React\Async\await($this->browser->get($this->base . 'status/304')); $this->assertEquals('12', $response->getHeaderLine('Content-Length')); $body = $response->getBody(); @@ -342,7 +357,7 @@ public function testGetRequestWithResponseBufferMatchedExactlyResolves() { $promise = $this->browser->withResponseBuffer(5)->get($this->base . 'get'); - Block\await($promise); + \React\Async\await($promise); } public function testGetRequestWithResponseBufferExceededRejects() @@ -354,7 +369,7 @@ public function testGetRequestWithResponseBufferExceededRejects() 'Response body size of 5 bytes exceeds maximum of 4 bytes', defined('SOCKET_EMSGSIZE') ? SOCKET_EMSGSIZE : 0 ); - Block\await($promise); + \React\Async\await($promise); } public function testGetRequestWithResponseBufferExceededDuringStreamingRejects() @@ -366,7 +381,7 @@ public function testGetRequestWithResponseBufferExceededDuringStreamingRejects() 'Response body size exceeds maximum of 4 bytes', defined('SOCKET_EMSGSIZE') ? SOCKET_EMSGSIZE : 0 ); - Block\await($promise); + \React\Async\await($promise); } /** @@ -379,7 +394,7 @@ public function testCanAccessHttps() $this->markTestSkipped('Not supported on HHVM'); } - Block\await($this->browser->get('https://www.google.com/')); + \React\Async\await($this->browser->get('https://www.google.com/')); } /** @@ -400,7 +415,7 @@ public function testVerifyPeerEnabledForBadSslRejects() $browser = new Browser($connector); $this->setExpectedException('RuntimeException'); - Block\await($browser->get('https://self-signed.badssl.com/')); + \React\Async\await($browser->get('https://self-signed.badssl.com/')); } /** @@ -421,7 +436,7 @@ public function testVerifyPeerDisabledForBadSslResolves() $browser = new Browser($connector); - Block\await($browser->get('https://self-signed.badssl.com/')); + \React\Async\await($browser->get('https://self-signed.badssl.com/')); } /** @@ -430,13 +445,13 @@ public function testVerifyPeerDisabledForBadSslResolves() public function testInvalidPort() { $this->setExpectedException('RuntimeException'); - Block\await($this->browser->get('http://www.google.com:443/')); + \React\Async\await($this->browser->get('http://www.google.com:443/')); } public function testErrorStatusCodeRejectsWithResponseException() { try { - Block\await($this->browser->get($this->base . 'status/404')); + \React\Async\await($this->browser->get($this->base . 'status/404')); $this->fail(); } catch (ResponseException $e) { $this->assertEquals(404, $e->getCode()); @@ -448,14 +463,14 @@ public function testErrorStatusCodeRejectsWithResponseException() public function testErrorStatusCodeDoesNotRejectWithRejectErrorResponseFalse() { - $response = Block\await($this->browser->withRejectErrorResponse(false)->get($this->base . 'status/404')); + $response = \React\Async\await($this->browser->withRejectErrorResponse(false)->get($this->base . 'status/404')); $this->assertEquals(404, $response->getStatusCode()); } public function testPostString() { - $response = Block\await($this->browser->post($this->base . 'post', array(), 'hello world')); + $response = \React\Async\await($this->browser->post($this->base . 'post', array(), 'hello world')); $data = json_decode((string)$response->getBody(), true); $this->assertEquals('hello world', $data['data']); @@ -463,7 +478,7 @@ public function testPostString() public function testRequestStreamReturnsResponseBodyUntilConnectionsEndsForHttp10() { - $response = Block\await($this->browser->withProtocolVersion('1.0')->get($this->base . 'stream/1')); + $response = \React\Async\await($this->browser->withProtocolVersion('1.0')->get($this->base . 'stream/1')); $this->assertEquals('1.0', $response->getProtocolVersion()); $this->assertFalse($response->hasHeader('Transfer-Encoding')); @@ -474,7 +489,7 @@ public function testRequestStreamReturnsResponseBodyUntilConnectionsEndsForHttp1 public function testRequestStreamReturnsResponseWithTransferEncodingChunkedAndResponseBodyDecodedForHttp11() { - $response = Block\await($this->browser->get($this->base . 'stream/1')); + $response = \React\Async\await($this->browser->get($this->base . 'stream/1')); $this->assertEquals('1.1', $response->getProtocolVersion()); @@ -486,7 +501,7 @@ public function testRequestStreamReturnsResponseWithTransferEncodingChunkedAndRe public function testRequestStreamWithHeadRequestReturnsEmptyResponseBodWithTransferEncodingChunkedForHttp11() { - $response = Block\await($this->browser->head($this->base . 'stream/1')); + $response = \React\Async\await($this->browser->head($this->base . 'stream/1')); $this->assertEquals('1.1', $response->getProtocolVersion()); @@ -505,7 +520,9 @@ public function testRequestStreamReturnsResponseWithResponseBodyUndecodedWhenRes $this->base = str_replace('tcp:', 'http:', $socket->getAddress()) . '/'; - $response = Block\await($this->browser->get($this->base . 'stream/1')); + $response = \React\Async\await($this->browser->get($this->base . 'stream/1')); + + $socket->close(); $this->assertEquals('1.1', $response->getProtocolVersion()); @@ -528,10 +545,10 @@ public function testReceiveStreamAndExplicitlyCloseConnectionEvenWhenServerKeeps $this->base = str_replace('tcp:', 'http:', $socket->getAddress()) . '/'; - $response = Block\await($this->browser->get($this->base . 'get', array())); + $response = \React\Async\await($this->browser->get($this->base . 'get', array())); $this->assertEquals('hello', (string)$response->getBody()); - $ret = Block\await(\React\Promise\Timer\timeout($closed->promise(), 0.1)); + $ret = \React\Async\await(\React\Promise\Timer\timeout($closed->promise(), 0.1)); $this->assertTrue($ret); $socket->close(); @@ -545,7 +562,7 @@ public function testPostStreamChunked() $stream->end('hello world'); }); - $response = Block\await($this->browser->post($this->base . 'post', array(), $stream)); + $response = \React\Async\await($this->browser->post($this->base . 'post', array(), $stream)); $data = json_decode((string)$response->getBody(), true); $this->assertEquals('hello world', $data['data']); @@ -561,7 +578,7 @@ public function testPostStreamKnownLength() $stream->end('hello world'); }); - $response = Block\await($this->browser->post($this->base . 'post', array('Content-Length' => 11), $stream)); + $response = \React\Async\await($this->browser->post($this->base . 'post', array('Content-Length' => 11), $stream)); $data = json_decode((string)$response->getBody(), true); $this->assertEquals('hello world', $data['data']); @@ -581,7 +598,7 @@ public function testPostStreamWillStartSendingRequestEvenWhenBodyDoesNotEmitData $this->base = str_replace('tcp:', 'http:', $socket->getAddress()) . '/'; $stream = new ThroughStream(); - Block\await($this->browser->post($this->base . 'post', array(), $stream)); + \React\Async\await($this->browser->post($this->base . 'post', array(), $stream)); $socket->close(); } @@ -591,7 +608,7 @@ public function testPostStreamClosed() $stream = new ThroughStream(); $stream->close(); - $response = Block\await($this->browser->post($this->base . 'post', array(), $stream)); + $response = \React\Async\await($this->browser->post($this->base . 'post', array(), $stream)); $data = json_decode((string)$response->getBody(), true); $this->assertEquals('', $data['data']); @@ -611,7 +628,7 @@ public function testSendsHttp11ByDefault() $this->base = str_replace('tcp:', 'http:', $socket->getAddress()) . '/'; - $response = Block\await($this->browser->get($this->base)); + $response = \React\Async\await($this->browser->get($this->base)); $this->assertEquals('1.1', (string)$response->getBody()); $socket->close(); @@ -631,7 +648,7 @@ public function testSendsExplicitHttp10Request() $this->base = str_replace('tcp:', 'http:', $socket->getAddress()) . '/'; - $response = Block\await($this->browser->withProtocolVersion('1.0')->get($this->base)); + $response = \React\Async\await($this->browser->withProtocolVersion('1.0')->get($this->base)); $this->assertEquals('1.0', (string)$response->getBody()); $socket->close(); @@ -639,7 +656,7 @@ public function testSendsExplicitHttp10Request() public function testHeadRequestReceivesResponseWithEmptyBodyButWithContentLengthResponseHeader() { - $response = Block\await($this->browser->head($this->base . 'get')); + $response = \React\Async\await($this->browser->head($this->base . 'get')); $this->assertEquals('5', $response->getHeaderLine('Content-Length')); $body = $response->getBody(); @@ -649,7 +666,7 @@ public function testHeadRequestReceivesResponseWithEmptyBodyButWithContentLength public function testRequestStreamingGetReceivesResponseWithStreamingBodyAndKnownSize() { - $response = Block\await($this->browser->requestStreaming('GET', $this->base . 'get')); + $response = \React\Async\await($this->browser->requestStreaming('GET', $this->base . 'get')); $this->assertEquals('5', $response->getHeaderLine('Content-Length')); $body = $response->getBody(); @@ -660,7 +677,7 @@ public function testRequestStreamingGetReceivesResponseWithStreamingBodyAndKnown public function testRequestStreamingGetReceivesResponseWithStreamingBodyAndUnknownSizeFromStreamingEndpoint() { - $response = Block\await($this->browser->requestStreaming('GET', $this->base . 'stream/1')); + $response = \React\Async\await($this->browser->requestStreaming('GET', $this->base . 'stream/1')); $this->assertFalse($response->hasHeader('Content-Length')); $body = $response->getBody(); @@ -671,7 +688,7 @@ public function testRequestStreamingGetReceivesResponseWithStreamingBodyAndUnkno public function testRequestStreamingGetReceivesStreamingResponseBody() { - $buffer = Block\await( + $buffer = \React\Async\await( $this->browser->requestStreaming('GET', $this->base . 'get')->then(function (ResponseInterface $response) { return Stream\buffer($response->getBody()); }) @@ -682,7 +699,7 @@ public function testRequestStreamingGetReceivesStreamingResponseBody() public function testRequestStreamingGetReceivesStreamingResponseBodyEvenWhenResponseBufferExceeded() { - $buffer = Block\await( + $buffer = \React\Async\await( $this->browser->withResponseBuffer(4)->requestStreaming('GET', $this->base . 'get')->then(function (ResponseInterface $response) { return Stream\buffer($response->getBody()); }) diff --git a/tests/FunctionalHttpServerTest.php b/tests/FunctionalHttpServerTest.php index f543fb55..eb3b448c 100644 --- a/tests/FunctionalHttpServerTest.php +++ b/tests/FunctionalHttpServerTest.php @@ -2,7 +2,6 @@ namespace React\Tests\Http; -use Clue\React\Block; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ServerRequestInterface; use React\EventLoop\Loop; @@ -37,7 +36,7 @@ public function testPlainHttpOnRandomPort() return Stream\buffer($conn); }); - $response = Block\await(\React\Promise\Timer\timeout($result, 1.0)); + $response = \React\Async\await(\React\Promise\Timer\timeout($result, 1.0)); $this->assertContainsString("HTTP/1.0 200 OK", $response); $this->assertContainsString('http://' . noScheme($socket->getAddress()) . '/', $response); @@ -64,7 +63,7 @@ function () { return Stream\buffer($conn); }); - $response = Block\await(\React\Promise\Timer\timeout($result, 1.0)); + $response = \React\Async\await(\React\Promise\Timer\timeout($result, 1.0)); $this->assertContainsString("HTTP/1.0 404 Not Found", $response); @@ -88,7 +87,7 @@ public function testPlainHttpOnRandomPortWithoutHostHeaderUsesSocketUri() return Stream\buffer($conn); }); - $response = Block\await(\React\Promise\Timer\timeout($result, 1.0)); + $response = \React\Async\await(\React\Promise\Timer\timeout($result, 1.0)); $this->assertContainsString("HTTP/1.0 200 OK", $response); $this->assertContainsString('http://' . noScheme($socket->getAddress()) . '/', $response); @@ -113,7 +112,7 @@ public function testPlainHttpOnRandomPortWithOtherHostHeaderTakesPrecedence() return Stream\buffer($conn); }); - $response = Block\await(\React\Promise\Timer\timeout($result, 1.0)); + $response = \React\Async\await(\React\Promise\Timer\timeout($result, 1.0)); $this->assertContainsString("HTTP/1.0 200 OK", $response); $this->assertContainsString('http://localhost:1000/', $response); @@ -146,7 +145,7 @@ public function testSecureHttpsOnRandomPort() return Stream\buffer($conn); }); - $response = Block\await(\React\Promise\Timer\timeout($result, 1.0)); + $response = \React\Async\await(\React\Promise\Timer\timeout($result, 1.0)); $this->assertContainsString("HTTP/1.0 200 OK", $response); $this->assertContainsString('https://' . noScheme($socket->getAddress()) . '/', $response); @@ -183,7 +182,7 @@ public function testSecureHttpsReturnsData() return Stream\buffer($conn); }); - $response = Block\await(\React\Promise\Timer\timeout($result, 1.0)); + $response = \React\Async\await(\React\Promise\Timer\timeout($result, 1.0)); $this->assertContainsString("HTTP/1.0 200 OK", $response); $this->assertContainsString("\r\nContent-Length: 33000\r\n", $response); @@ -217,7 +216,7 @@ public function testSecureHttpsOnRandomPortWithoutHostHeaderUsesSocketUri() return Stream\buffer($conn); }); - $response = Block\await(\React\Promise\Timer\timeout($result, 1.0)); + $response = \React\Async\await(\React\Promise\Timer\timeout($result, 1.0)); $this->assertContainsString("HTTP/1.0 200 OK", $response); $this->assertContainsString('https://' . noScheme($socket->getAddress()) . '/', $response); @@ -246,7 +245,7 @@ public function testPlainHttpOnStandardPortReturnsUriWithNoPort() return Stream\buffer($conn); }); - $response = Block\await(\React\Promise\Timer\timeout($result, 1.0)); + $response = \React\Async\await(\React\Promise\Timer\timeout($result, 1.0)); $this->assertContainsString("HTTP/1.0 200 OK", $response); $this->assertContainsString('http://127.0.0.1/', $response); @@ -275,7 +274,7 @@ public function testPlainHttpOnStandardPortWithoutHostHeaderReturnsUriWithNoPort return Stream\buffer($conn); }); - $response = Block\await(\React\Promise\Timer\timeout($result, 1.0)); + $response = \React\Async\await(\React\Promise\Timer\timeout($result, 1.0)); $this->assertContainsString("HTTP/1.0 200 OK", $response); $this->assertContainsString('http://127.0.0.1/', $response); @@ -313,7 +312,7 @@ public function testSecureHttpsOnStandardPortReturnsUriWithNoPort() return Stream\buffer($conn); }); - $response = Block\await(\React\Promise\Timer\timeout($result, 1.0)); + $response = \React\Async\await(\React\Promise\Timer\timeout($result, 1.0)); $this->assertContainsString("HTTP/1.0 200 OK", $response); $this->assertContainsString('https://127.0.0.1/', $response); @@ -351,7 +350,7 @@ public function testSecureHttpsOnStandardPortWithoutHostHeaderUsesSocketUri() return Stream\buffer($conn); }); - $response = Block\await(\React\Promise\Timer\timeout($result, 1.0)); + $response = \React\Async\await(\React\Promise\Timer\timeout($result, 1.0)); $this->assertContainsString("HTTP/1.0 200 OK", $response); $this->assertContainsString('https://127.0.0.1/', $response); @@ -380,7 +379,7 @@ public function testPlainHttpOnHttpsStandardPortReturnsUriWithPort() return Stream\buffer($conn); }); - $response = Block\await(\React\Promise\Timer\timeout($result, 1.0)); + $response = \React\Async\await(\React\Promise\Timer\timeout($result, 1.0)); $this->assertContainsString("HTTP/1.0 200 OK", $response); $this->assertContainsString('http://127.0.0.1:443/', $response); @@ -418,7 +417,7 @@ public function testSecureHttpsOnHttpStandardPortReturnsUriWithPort() return Stream\buffer($conn); }); - $response = Block\await(\React\Promise\Timer\timeout($result, 1.0)); + $response = \React\Async\await(\React\Promise\Timer\timeout($result, 1.0)); $this->assertContainsString("HTTP/1.0 200 OK", $response); $this->assertContainsString('https://127.0.0.1:80/', $response); @@ -446,7 +445,7 @@ public function testClosedStreamFromRequestHandlerWillSendEmptyBody() return Stream\buffer($conn); }); - $response = Block\await(\React\Promise\Timer\timeout($result, 1.0)); + $response = \React\Async\await(\React\Promise\Timer\timeout($result, 1.0)); $this->assertStringStartsWith("HTTP/1.0 200 OK", $response); $this->assertStringEndsWith("\r\n\r\n", $response); @@ -477,7 +476,7 @@ function (RequestInterface $request) use ($once) { }); }); - \Clue\React\Block\await(\React\Promise\Timer\sleep(0.1)); + \React\Async\await(\React\Promise\Timer\sleep(0.1)); $socket->close(); } @@ -507,7 +506,7 @@ function (RequestInterface $request) use ($stream) { }); // stream will be closed within 0.1s - $ret = Block\await(\React\Promise\Timer\timeout(Stream\first($stream, 'close'), 0.1)); + $ret = \React\Async\await(\React\Promise\Timer\timeout(Stream\first($stream, 'close'), 0.1)); $socket->close(); @@ -536,7 +535,7 @@ public function testStreamFromRequestHandlerWillBeClosedIfConnectionCloses() }); // await response stream to be closed - $ret = Block\await(\React\Promise\Timer\timeout(Stream\first($stream, 'close'), 1.0)); + $ret = \React\Async\await(\React\Promise\Timer\timeout(Stream\first($stream, 'close'), 1.0)); $socket->close(); @@ -571,7 +570,7 @@ public function testUpgradeWithThroughStreamReturnsDataAsGiven() return Stream\buffer($conn); }); - $response = Block\await(\React\Promise\Timer\timeout($result, 1.0)); + $response = \React\Async\await(\React\Promise\Timer\timeout($result, 1.0)); $this->assertStringStartsWith("HTTP/1.1 101 Switching Protocols\r\n", $response); $this->assertStringEndsWith("\r\n\r\nhelloworld", $response); @@ -608,7 +607,7 @@ public function testUpgradeWithRequestBodyAndThroughStreamReturnsDataAsGiven() return Stream\buffer($conn); }); - $response = Block\await(\React\Promise\Timer\timeout($result, 1.0)); + $response = \React\Async\await(\React\Promise\Timer\timeout($result, 1.0)); $this->assertStringStartsWith("HTTP/1.1 101 Switching Protocols\r\n", $response); $this->assertStringEndsWith("\r\n\r\nhelloworld", $response); @@ -644,7 +643,7 @@ public function testConnectWithThroughStreamReturnsDataAsGiven() return Stream\buffer($conn); }); - $response = Block\await(\React\Promise\Timer\timeout($result, 1.0)); + $response = \React\Async\await(\React\Promise\Timer\timeout($result, 1.0)); $this->assertStringStartsWith("HTTP/1.1 200 OK\r\n", $response); $this->assertStringEndsWith("\r\n\r\nhelloworld", $response); @@ -684,7 +683,7 @@ public function testConnectWithThroughStreamReturnedFromPromiseReturnsDataAsGive return Stream\buffer($conn); }); - $response = Block\await(\React\Promise\Timer\timeout($result, 1.0)); + $response = \React\Async\await(\React\Promise\Timer\timeout($result, 1.0)); $this->assertStringStartsWith("HTTP/1.1 200 OK\r\n", $response); $this->assertStringEndsWith("\r\n\r\nhelloworld", $response); @@ -717,7 +716,7 @@ public function testConnectWithClosedThroughStreamReturnsNoData() return Stream\buffer($conn); }); - $response = Block\await(\React\Promise\Timer\timeout($result, 1.0)); + $response = \React\Async\await(\React\Promise\Timer\timeout($result, 1.0)); $this->assertStringStartsWith("HTTP/1.1 200 OK\r\n", $response); $this->assertStringEndsWith("\r\n\r\n", $response); @@ -760,7 +759,7 @@ function (ServerRequestInterface $request) { }); } - $responses = Block\await(\React\Promise\Timer\timeout(Promise\all($result), 1.0)); + $responses = \React\Async\await(\React\Promise\Timer\timeout(Promise\all($result), 1.0)); foreach ($responses as $response) { $this->assertContainsString("HTTP/1.0 200 OK", $response, $response); diff --git a/tests/HttpServerTest.php b/tests/HttpServerTest.php index 31bc32ee..72d48468 100644 --- a/tests/HttpServerTest.php +++ b/tests/HttpServerTest.php @@ -2,7 +2,6 @@ namespace React\Tests\Http; -use Clue\React\Block; use Psr\Http\Message\ServerRequestInterface; use React\EventLoop\Loop; use React\Http\HttpServer; @@ -142,7 +141,7 @@ public function testPostFormData() $this->socket->emit('connection', array($this->connection)); $this->connection->emit('data', array("POST / HTTP/1.0\r\nContent-Type: application/x-www-form-urlencoded\r\nContent-Length: 7\r\n\r\nfoo=bar")); - $request = Block\await($deferred->promise()); + $request = \React\Async\await($deferred->promise()); assert($request instanceof ServerRequestInterface); $form = $request->getParsedBody(); @@ -180,7 +179,7 @@ public function testPostFileUpload() } }); - $request = Block\await($deferred->promise()); + $request = \React\Async\await($deferred->promise()); assert($request instanceof ServerRequestInterface); $this->assertEmpty($request->getParsedBody()); @@ -213,7 +212,7 @@ public function testPostJsonWillNotBeParsedByDefault() $this->socket->emit('connection', array($this->connection)); $this->connection->emit('data', array("POST / HTTP/1.0\r\nContent-Type: application/json\r\nContent-Length: 6\r\n\r\n[true]")); - $request = Block\await($deferred->promise()); + $request = \React\Async\await($deferred->promise()); assert($request instanceof ServerRequestInterface); $this->assertNull($request->getParsedBody()); diff --git a/tests/Io/MiddlewareRunnerTest.php b/tests/Io/MiddlewareRunnerTest.php index d8f5f232..1f49facd 100644 --- a/tests/Io/MiddlewareRunnerTest.php +++ b/tests/Io/MiddlewareRunnerTest.php @@ -2,7 +2,6 @@ namespace React\Tests\Http\Io; -use Clue\React\Block; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; @@ -161,7 +160,7 @@ public function testProcessStack(array $middlewares, $expectedCallCount) $response = $middlewareStack($request); $this->assertTrue($response instanceof PromiseInterface); - $response = Block\await($response); + $response = \React\Async\await($response); $this->assertTrue($response instanceof ResponseInterface); $this->assertSame(200, $response->getStatusCode()); @@ -228,7 +227,7 @@ function () use ($errorHandler, &$called, $response, $exception) { $request = new ServerRequest('GET', 'https://example.com/'); - $this->assertSame($response, Block\await($runner($request))); + $this->assertSame($response, \React\Async\await($runner($request))); $this->assertSame(1, $retryCalled); $this->assertSame(2, $called); $this->assertSame($exception, $error); diff --git a/tests/Io/SenderTest.php b/tests/Io/SenderTest.php index 8597f17a..587ba0c2 100644 --- a/tests/Io/SenderTest.php +++ b/tests/Io/SenderTest.php @@ -2,7 +2,6 @@ namespace React\Tests\Http\Io; -use Clue\React\Block; use React\Http\Client\Client as HttpClient; use React\Http\Client\RequestData; use React\Http\Io\ReadableBodyStream; diff --git a/tests/Io/TransactionTest.php b/tests/Io/TransactionTest.php index 56b9d4f4..83d218c7 100644 --- a/tests/Io/TransactionTest.php +++ b/tests/Io/TransactionTest.php @@ -2,7 +2,6 @@ namespace React\Tests\Http\Io; -use Clue\React\Block; use PHPUnit\Framework\MockObject\MockObject; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; @@ -401,7 +400,7 @@ public function testReceivingStreamingBodyWillResolveWithBufferedResponseByDefau $transaction = new Transaction($sender, Loop::get()); $promise = $transaction->send($request); - $response = Block\await($promise); + $response = \React\Async\await($promise); $this->assertEquals(200, $response->getStatusCode()); $this->assertEquals('hello world', (string)$response->getBody()); @@ -424,7 +423,7 @@ public function testReceivingStreamingBodyWithSizeExceedingMaximumResponseBuffer $promise = $transaction->send($request); $this->setExpectedException('OverflowException'); - Block\await(\React\Promise\Timer\timeout($promise, 0.001)); + \React\Async\await(\React\Promise\Timer\timeout($promise, 0.001)); } public function testCancelBufferingResponseWillCloseStreamAndReject() @@ -445,7 +444,7 @@ public function testCancelBufferingResponseWillCloseStreamAndReject() $promise->cancel(); $this->setExpectedException('RuntimeException'); - Block\await(\React\Promise\Timer\timeout($promise, 0.001)); + \React\Async\await(\React\Promise\Timer\timeout($promise, 0.001)); } public function testReceivingStreamingBodyWillResolveWithStreamingResponseIfStreamingIsEnabled() diff --git a/tests/Middleware/RequestBodyBufferMiddlewareTest.php b/tests/Middleware/RequestBodyBufferMiddlewareTest.php index e073e1f0..0edec7da 100644 --- a/tests/Middleware/RequestBodyBufferMiddlewareTest.php +++ b/tests/Middleware/RequestBodyBufferMiddlewareTest.php @@ -2,7 +2,6 @@ namespace React\Tests\Http\Middleware; -use Clue\React\Block; use Psr\Http\Message\ServerRequestInterface; use React\EventLoop\Loop; use React\Http\Io\HttpBodyStream; @@ -128,7 +127,7 @@ public function testKnownExcessiveSizedBodyIsDisgardedTheRequestIsPassedDownToTh ); $buffer = new RequestBodyBufferMiddleware(1); - $response = Block\await($buffer( + $response = \React\Async\await($buffer( $serverRequest, function (ServerRequestInterface $request) { return new Response(200, array(), $request->getBody()->getContents()); @@ -153,7 +152,7 @@ public function testKnownExcessiveSizedWithIniLikeSize() ); $buffer = new RequestBodyBufferMiddleware('1K'); - $response = Block\await($buffer( + $response = \React\Async\await($buffer( $serverRequest, function (ServerRequestInterface $request) { return new Response(200, array(), $request->getBody()->getContents()); @@ -206,7 +205,7 @@ function (ServerRequestInterface $request) { $stream->end('aa'); - $exposedResponse = Block\await($promise->then( + $exposedResponse = \React\Async\await($promise->then( null, $this->expectCallableNever() )); @@ -236,7 +235,7 @@ function (ServerRequestInterface $request) { $stream->emit('error', array(new \RuntimeException())); $this->setExpectedException('RuntimeException'); - Block\await($promise); + \React\Async\await($promise); } public function testFullBodyStreamedBeforeCallingNextMiddleware() From 663c9a3b77b71463fa7fcb76a6676ffd16979dd6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Tue, 16 Aug 2022 18:30:57 +0200 Subject: [PATCH 10/58] Do not decode cookie names anymore --- README.md | 4 ++-- examples/55-server-cookie-handling.php | 4 ++-- src/Message/ServerRequest.php | 2 +- tests/Message/ServerRequestTest.php | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index ba8d8330..1070349c 100644 --- a/README.md +++ b/README.md @@ -1304,7 +1304,7 @@ get all cookies sent with the current request. ```php $http = new React\Http\HttpServer(function (Psr\Http\Message\ServerRequestInterface $request) { - $key = 'react\php'; + $key = 'greeting'; if (isset($request->getCookieParams()[$key])) { $body = "Your cookie value is: " . $request->getCookieParams()[$key] . "\n"; @@ -1316,7 +1316,7 @@ $http = new React\Http\HttpServer(function (Psr\Http\Message\ServerRequestInterf return React\Http\Message\Response::plaintext( "Your cookie has been set.\n" - )->withHeader('Set-Cookie', urlencode($key) . '=' . urlencode('test;more')); + )->withHeader('Set-Cookie', $key . '=' . urlencode('Hello world!')); }); ``` diff --git a/examples/55-server-cookie-handling.php b/examples/55-server-cookie-handling.php index 796da24d..b5e68862 100644 --- a/examples/55-server-cookie-handling.php +++ b/examples/55-server-cookie-handling.php @@ -3,7 +3,7 @@ require __DIR__ . '/../vendor/autoload.php'; $http = new React\Http\HttpServer(function (Psr\Http\Message\ServerRequestInterface $request) { - $key = 'react\php'; + $key = 'greeting'; if (isset($request->getCookieParams()[$key])) { $body = "Your cookie value is: " . $request->getCookieParams()[$key] . "\n"; @@ -15,7 +15,7 @@ return React\Http\Message\Response::plaintext( "Your cookie has been set.\n" - )->withHeader('Set-Cookie', urlencode($key) . '=' . urlencode('test;more')); + )->withHeader('Set-Cookie', $key . '=' . urlencode('Hello world!')); }); $socket = new React\Socket\SocketServer(isset($argv[1]) ? $argv[1] : '0.0.0.0:0'); diff --git a/src/Message/ServerRequest.php b/src/Message/ServerRequest.php index f446f24e..fdb3ec5e 100644 --- a/src/Message/ServerRequest.php +++ b/src/Message/ServerRequest.php @@ -186,7 +186,7 @@ private function parseCookie($cookie) $nameValuePair = \explode('=', $pair, 2); if (\count($nameValuePair) === 2) { - $key = \urldecode($nameValuePair[0]); + $key = $nameValuePair[0]; $value = \urldecode($nameValuePair[1]); $result[$key] = $value; } diff --git a/tests/Message/ServerRequestTest.php b/tests/Message/ServerRequestTest.php index 37cc1879..a5919f64 100644 --- a/tests/Message/ServerRequestTest.php +++ b/tests/Message/ServerRequestTest.php @@ -251,7 +251,7 @@ public function testUrlEncodingForKeyWillReturnValidArray() ); $cookies = $this->request->getCookieParams(); - $this->assertEquals(array('react;php' => 'is great'), $cookies); + $this->assertEquals(array('react%3Bphp' => 'is great'), $cookies); } public function testCookieWithoutSpaceAfterSeparatorWillBeAccepted() From d92e564a80e349661abba0d9d80a9a82bf120d49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Thu, 16 Jun 2022 22:16:25 +0200 Subject: [PATCH 11/58] Improve performance, reuse server params for same connection --- src/Io/RequestHeaderParser.php | 84 +++++++++++++++++----------- tests/Io/RequestHeaderParserTest.php | 58 ++++++++++++++++++- 2 files changed, 108 insertions(+), 34 deletions(-) diff --git a/src/Io/RequestHeaderParser.php b/src/Io/RequestHeaderParser.php index 6930afaf..b8336f5b 100644 --- a/src/Io/RequestHeaderParser.php +++ b/src/Io/RequestHeaderParser.php @@ -27,6 +27,9 @@ class RequestHeaderParser extends EventEmitter /** @var Clock */ private $clock; + /** @var array> */ + private $connectionParams = array(); + public function __construct(Clock $clock) { $this->clock = $clock; @@ -66,8 +69,7 @@ public function handle(ConnectionInterface $conn) try { $request = $that->parseRequest( (string)\substr($buffer, 0, $endOfHeader + 2), - $conn->getRemoteAddress(), - $conn->getLocalAddress() + $conn ); } catch (Exception $exception) { $buffer = ''; @@ -119,13 +121,12 @@ public function handle(ConnectionInterface $conn) /** * @param string $headers buffer string containing request headers only - * @param ?string $remoteSocketUri - * @param ?string $localSocketUri + * @param ConnectionInterface $connection * @return ServerRequestInterface * @throws \InvalidArgumentException * @internal */ - public function parseRequest($headers, $remoteSocketUri, $localSocketUri) + public function parseRequest($headers, ConnectionInterface $connection) { // additional, stricter safe-guard for request line // because request parser doesn't properly cope with invalid ones @@ -160,26 +161,59 @@ public function parseRequest($headers, $remoteSocketUri, $localSocketUri) } } + // reuse same connection params for all server params for this connection + $cid = \PHP_VERSION_ID < 70200 ? \spl_object_hash($connection) : \spl_object_id($connection); + if (isset($this->connectionParams[$cid])) { + $serverParams = $this->connectionParams[$cid]; + } else { + // assign new server params for new connection + $serverParams = array(); + + // scheme is `http` unless TLS is used + $localSocketUri = $connection->getLocalAddress(); + $localParts = $localSocketUri === null ? array() : \parse_url(https://github.com/$localSocketUri); + if (isset($localParts['scheme']) && $localParts['scheme'] === 'tls') { + $serverParams['HTTPS'] = 'on'; + } + + // apply SERVER_ADDR and SERVER_PORT if server address is known + // address should always be known, even for Unix domain sockets (UDS) + // but skip UDS as it doesn't have a concept of host/port. + if ($localSocketUri !== null && isset($localParts['host'], $localParts['port'])) { + $serverParams['SERVER_ADDR'] = $localParts['host']; + $serverParams['SERVER_PORT'] = $localParts['port']; + } + + // apply REMOTE_ADDR and REMOTE_PORT if source address is known + // address should always be known, unless this is over Unix domain sockets (UDS) + $remoteSocketUri = $connection->getRemoteAddress(); + if ($remoteSocketUri !== null) { + $remoteAddress = \parse_url(https://github.com/$remoteSocketUri); + $serverParams['REMOTE_ADDR'] = $remoteAddress['host']; + $serverParams['REMOTE_PORT'] = $remoteAddress['port']; + } + + // remember server params for all requests from this connection, reset on connection close + $this->connectionParams[$cid] = $serverParams; + $params =& $this->connectionParams; + $connection->on('close', function () use (&$params, $cid) { + assert(\is_array($params)); + unset($params[$cid]); + }); + } + // create new obj implementing ServerRequestInterface by preserving all // previous properties and restoring original request-target - $serverParams = array( - 'REQUEST_TIME' => (int) ($now = $this->clock->now()), - 'REQUEST_TIME_FLOAT' => $now - ); + $serverParams['REQUEST_TIME'] = (int) ($now = $this->clock->now()); + $serverParams['REQUEST_TIME_FLOAT'] = $now; // scheme is `http` unless TLS is used - $localParts = $localSocketUri === null ? array() : \parse_url(https://github.com/$localSocketUri); - if (isset($localParts['scheme']) && $localParts['scheme'] === 'tls') { - $scheme = 'https://'; - $serverParams['HTTPS'] = 'on'; - } else { - $scheme = 'http://'; - } + $scheme = isset($serverParams['HTTPS']) ? 'https://' : 'http://'; // default host if unset comes from local socket address or defaults to localhost $hasHost = $host !== null; if ($host === null) { - $host = isset($localParts['host'], $localParts['port']) ? $localParts['host'] . ':' . $localParts['port'] : '127.0.0.1'; + $host = isset($serverParams['SERVER_ADDR'], $serverParams['SERVER_PORT']) ? $serverParams['SERVER_ADDR'] . ':' . $serverParams['SERVER_PORT'] : '127.0.0.1'; } if ($start['method'] === 'OPTIONS' && $start['target'] === '*') { @@ -210,22 +244,6 @@ public function parseRequest($headers, $remoteSocketUri, $localSocketUri) } } - // apply REMOTE_ADDR and REMOTE_PORT if source address is known - // address should always be known, unless this is over Unix domain sockets (UDS) - if ($remoteSocketUri !== null) { - $remoteAddress = \parse_url(https://github.com/$remoteSocketUri); - $serverParams['REMOTE_ADDR'] = $remoteAddress['host']; - $serverParams['REMOTE_PORT'] = $remoteAddress['port']; - } - - // apply SERVER_ADDR and SERVER_PORT if server address is known - // address should always be known, even for Unix domain sockets (UDS) - // but skip UDS as it doesn't have a concept of host/port. - if ($localSocketUri !== null && isset($localParts['host'], $localParts['port'])) { - $serverParams['SERVER_ADDR'] = $localParts['host']; - $serverParams['SERVER_PORT'] = $localParts['port']; - } - $request = new ServerRequest( $start['method'], $uri, diff --git a/tests/Io/RequestHeaderParserTest.php b/tests/Io/RequestHeaderParserTest.php index 7ba7fe01..87d6bf1b 100644 --- a/tests/Io/RequestHeaderParserTest.php +++ b/tests/Io/RequestHeaderParserTest.php @@ -2,9 +2,9 @@ namespace React\Tests\Http\Io; +use Psr\Http\Message\ServerRequestInterface; use React\Http\Io\RequestHeaderParser; use React\Tests\Http\TestCase; -use Psr\Http\Message\ServerRequestInterface; class RequestHeaderParserTest extends TestCase { @@ -808,6 +808,62 @@ public function testServerParamsWontBeSetOnMissingUrls() $this->assertArrayNotHasKey('REMOTE_PORT', $serverParams); } + public function testServerParamsWillBeReusedForMultipleRequestsFromSameConnection() + { + $clock = $this->getMockBuilder('React\Http\Io\Clock')->disableOriginalConstructor()->getMock(); + $clock->expects($this->exactly(2))->method('now')->willReturn(1652972091.3958); + + $parser = new RequestHeaderParser($clock); + + $connection = $this->getMockBuilder('React\Socket\Connection')->disableOriginalConstructor()->setMethods(array('getLocalAddress', 'getRemoteAddress'))->getMock(); + $connection->expects($this->once())->method('getLocalAddress')->willReturn('tcp://127.1.1.1:8000'); + $connection->expects($this->once())->method('getRemoteAddress')->willReturn('tcp://192.168.1.1:8001'); + + $parser->handle($connection); + $connection->emit('data', array("GET /foo HTTP/1.0\r\nHost: example.com\r\n\r\n")); + + $request = null; + $parser->on('headers', function ($parsedRequest) use (&$request) { + $request = $parsedRequest; + }); + + $parser->handle($connection); + $connection->emit('data', array("GET /foo HTTP/1.0\r\nHost: example.com\r\n\r\n")); + + assert($request instanceof ServerRequestInterface); + $serverParams = $request->getServerParams(); + + $this->assertArrayNotHasKey('HTTPS', $serverParams); + $this->assertEquals(1652972091, $serverParams['REQUEST_TIME']); + $this->assertEquals(1652972091.3958, $serverParams['REQUEST_TIME_FLOAT']); + + $this->assertEquals('127.1.1.1', $serverParams['SERVER_ADDR']); + $this->assertEquals('8000', $serverParams['SERVER_PORT']); + + $this->assertEquals('192.168.1.1', $serverParams['REMOTE_ADDR']); + $this->assertEquals('8001', $serverParams['REMOTE_PORT']); + } + + public function testServerParamsWillBeRememberedUntilConnectionIsClosed() + { + $clock = $this->getMockBuilder('React\Http\Io\Clock')->disableOriginalConstructor()->getMock(); + + $parser = new RequestHeaderParser($clock); + + $connection = $this->getMockBuilder('React\Socket\Connection')->disableOriginalConstructor()->setMethods(array('getLocalAddress', 'getRemoteAddress'))->getMock(); + + $parser->handle($connection); + $connection->emit('data', array("GET /foo HTTP/1.0\r\nHost: example.com\r\n\r\n")); + + $ref = new \ReflectionProperty($parser, 'connectionParams'); + $ref->setAccessible(true); + + $this->assertCount(1, $ref->getValue($parser)); + + $connection->emit('close'); + $this->assertEquals(array(), $ref->getValue($parser)); + } + public function testQueryParmetersWillBeSet() { $request = null; From f0b4859d9f1728e6df3877b40956f0e3afef2d34 Mon Sep 17 00:00:00 2001 From: Cees-Jan Kiewiet Date: Sun, 14 Aug 2022 00:29:58 +0200 Subject: [PATCH 12/58] Test on PHP 8.2 With PHP 8.2 coming out later this year, we should be reading for it's release to ensure all out code works on it. Refs: https://github.com/reactphp/event-loop/pull/258 --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 27577838..0724232c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,6 +11,7 @@ jobs: strategy: matrix: php: + - 8.2 - 8.1 - 8.0 - 7.4 From 4a1e85382e8c2a9e0fdb8ac04e94585da2083bfa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Tue, 23 Aug 2022 14:31:28 +0200 Subject: [PATCH 13/58] Prepare v1.7.0 release --- CHANGELOG.md | 26 +++++++++++++++++++++++++- README.md | 2 +- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 41079cdb..5bf17e9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,30 @@ # Changelog +## 1.7.0 (2022-08-23) + +This is a **SECURITY** and feature release for the 1.x series of ReactPHP's HTTP component. + +* Security fix: This release fixes a medium severity security issue in ReactPHP's HTTP server component + that affects all versions between `v0.7.0` and `v1.6.0`. All users are encouraged to upgrade immediately. + Special thanks to Marco Squarcina (TU Wien) for reporting this and working with us to coordinate this release. + (CVE-2022-36032 reported by @lavish and fixed by @clue) + +* Feature: Improve HTTP server performance by ~20%, reuse syscall values for clock time and socket addresses. + (#457 and #467 by @clue) + +* Feature: Full PHP 8.2+ compatibility, refactor internal `Transaction` to avoid assigning dynamic properties. + (#459 by @clue and #466 by @WyriHaximus) + +* Feature / Fix: Allow explicit `Content-Length` response header on `HEAD` requests. + (#444 by @mrsimonbennett) + +* Minor documentation improvements. + (#452 by @clue, #458 by @nhedger, #448 by @jorrit and #446 by @SimonFrings + +* Improve test suite, update to use new reactphp/async package instead of clue/reactphp-block, + skip memory tests when lowering memory limit fails and fix legacy HHVM build. + (#464 and #440 by @clue and #450 by @SimonFrings) + ## 1.6.0 (2022-02-03) * Feature: Add factory methods for common HTML/JSON/plaintext/XML response types. @@ -10,7 +35,6 @@ $response = React\Http\Response\json(['message' => 'Hello wörld!']); $response = React\Http\Response\plaintext("Hello wörld!\n"); $response = React\Http\Response\xml("Hello wörld!\n"); - $response = React\Http\Response\redirect('https://reactphp.org/'); ``` * Feature: Expose all status code constants via `Response` class. diff --git a/README.md b/README.md index 1070349c..659855b0 100644 --- a/README.md +++ b/README.md @@ -2924,7 +2924,7 @@ This project follows [SemVer](https://semver.org/). This will install the latest supported version: ```bash -composer require react/http:^1.6 +composer require react/http:^1.7 ``` See also the [CHANGELOG](CHANGELOG.md) for details about version upgrades. From 8ec53f525b7fcfe66cc14007415346458d63f1f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Sun, 12 Jun 2022 18:56:25 +0200 Subject: [PATCH 14/58] Forward compatibility with upcoming Promise v3 --- .github/workflows/ci.yml | 3 ++ composer.json | 28 ++++++++++++++----- src/Io/StreamingServer.php | 3 +- .../LimitConcurrentRequestsMiddleware.php | 2 +- tests/FunctionalHttpServerTest.php | 4 +++ tests/Io/MiddlewareRunnerTest.php | 3 +- tests/Io/TransactionTest.php | 14 +++++++--- .../LimitConcurrentRequestsMiddlewareTest.php | 8 +++--- 8 files changed, 45 insertions(+), 20 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0724232c..fafa0ff0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,6 +29,8 @@ jobs: with: php-version: ${{ matrix.php }} coverage: xdebug + env: + COMPOSER_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: composer install - run: vendor/bin/phpunit --coverage-text if: ${{ matrix.php >= 7.3 }} @@ -39,6 +41,7 @@ jobs: name: PHPUnit (HHVM) runs-on: ubuntu-18.04 continue-on-error: true + if: false # temporarily skipped until https://github.com/azjezz/setup-hhvm/issues/3 is addressed steps: - uses: actions/checkout@v2 - uses: azjezz/setup-hhvm@v1 diff --git a/composer.json b/composer.json index 57adfc2c..3ba8a606 100644 --- a/composer.json +++ b/composer.json @@ -31,16 +31,16 @@ "fig/http-message-util": "^1.1", "psr/http-message": "^1.0", "react/event-loop": "^1.2", - "react/promise": "^2.3 || ^1.2.1", - "react/promise-stream": "^1.1", - "react/socket": "^1.9", + "react/promise": "^3@dev || ^2.3 || ^1.2.1", + "react/promise-stream": "^1.4", + "react/socket": "^1.12", "react/stream": "^1.2", "ringcentral/psr7": "^1.2" }, "require-dev": { - "clue/http-proxy-react": "^1.7", - "clue/reactphp-ssh-proxy": "^1.3", - "clue/socks-react": "^1.3", + "clue/http-proxy-react": "dev-promise-v3 as 1.8.0", + "clue/reactphp-ssh-proxy": "dev-promise-v3 as 1.4.0", + "clue/socks-react": "dev-promise-v3 as 1.4.0", "phpunit/phpunit": "^9.3 || ^5.7 || ^4.8.35", "react/async": "^4 || ^3 || ^2", "react/promise-timer": "^1.9" @@ -50,5 +50,19 @@ }, "autoload-dev": { "psr-4": { "React\\Tests\\Http\\": "tests" } - } + }, + "repositories": [ + { + "type": "vcs", + "url": "https://github.com/clue-labs/reactphp-http-proxy" + }, + { + "type": "vcs", + "url": "https://github.com/clue-labs/reactphp-socks" + }, + { + "type": "vcs", + "url": "https://github.com/clue-labs/reactphp-ssh-proxy" + } + ] } diff --git a/src/Io/StreamingServer.php b/src/Io/StreamingServer.php index a054be3d..13f0b0c4 100644 --- a/src/Io/StreamingServer.php +++ b/src/Io/StreamingServer.php @@ -9,7 +9,6 @@ use React\Http\Message\Response; use React\Http\Message\ServerRequest; use React\Promise; -use React\Promise\CancellablePromiseInterface; use React\Promise\PromiseInterface; use React\Socket\ConnectionInterface; use React\Socket\ServerInterface; @@ -158,7 +157,7 @@ public function handleRequest(ConnectionInterface $conn, ServerRequestInterface } // cancel pending promise once connection closes - if ($response instanceof CancellablePromiseInterface) { + if ($response instanceof PromiseInterface && \method_exists($response, 'cancel')) { $conn->on('close', function () use ($response) { $response->cancel(); }); diff --git a/src/Middleware/LimitConcurrentRequestsMiddleware.php b/src/Middleware/LimitConcurrentRequestsMiddleware.php index 53338100..b1c00da0 100644 --- a/src/Middleware/LimitConcurrentRequestsMiddleware.php +++ b/src/Middleware/LimitConcurrentRequestsMiddleware.php @@ -206,6 +206,6 @@ public function processQueue() $first = \reset($this->queue); unset($this->queue[key($this->queue)]); - $first->resolve(); + $first->resolve(null); } } diff --git a/tests/FunctionalHttpServerTest.php b/tests/FunctionalHttpServerTest.php index eb3b448c..dcd79b3e 100644 --- a/tests/FunctionalHttpServerTest.php +++ b/tests/FunctionalHttpServerTest.php @@ -726,6 +726,10 @@ public function testConnectWithClosedThroughStreamReturnsNoData() public function testLimitConcurrentRequestsMiddlewareRequestStreamPausing() { + if (defined('HHVM_VERSION') && !interface_exists('React\Promise\PromisorInterface')) { + $this->markTestSkipped('Not supported on legacy HHVM with Promise v3'); + } + $connector = new Connector(); $http = new HttpServer( diff --git a/tests/Io/MiddlewareRunnerTest.php b/tests/Io/MiddlewareRunnerTest.php index 1f49facd..ac836f03 100644 --- a/tests/Io/MiddlewareRunnerTest.php +++ b/tests/Io/MiddlewareRunnerTest.php @@ -8,7 +8,6 @@ use React\Http\Io\MiddlewareRunner; use React\Http\Message\ServerRequest; use React\Promise; -use React\Promise\CancellablePromiseInterface; use React\Promise\PromiseInterface; use React\Tests\Http\Middleware\ProcessStack; use React\Tests\Http\TestCase; @@ -479,7 +478,7 @@ function (RequestInterface $request) use ($once) { $promise = $middleware($request); - $this->assertTrue($promise instanceof CancellablePromiseInterface); + $this->assertTrue($promise instanceof PromiseInterface && \method_exists($promise, 'cancel')); $promise->cancel(); } } diff --git a/tests/Io/TransactionTest.php b/tests/Io/TransactionTest.php index 83d218c7..d9ac2178 100644 --- a/tests/Io/TransactionTest.php +++ b/tests/Io/TransactionTest.php @@ -436,11 +436,14 @@ public function testCancelBufferingResponseWillCloseStreamAndReject() $response = new Response(200, array(), new ReadableBodyStream($stream)); // mock sender to resolve promise with the given $response in response to the given $request + $deferred = new Deferred(); $sender = $this->makeSenderMock(); - $sender->expects($this->once())->method('send')->with($this->equalTo($request))->willReturn(Promise\resolve($response)); + $sender->expects($this->once())->method('send')->with($this->equalTo($request))->willReturn($deferred->promise()); $transaction = new Transaction($sender, Loop::get()); $promise = $transaction->send($request); + + $deferred->resolve($response); $promise->cancel(); $this->setExpectedException('RuntimeException'); @@ -778,13 +781,16 @@ public function testCancelTransactionWillCloseBufferingStream() $body = new ThroughStream(); $body->on('close', $this->expectCallableOnce()); - // mock sender to resolve promise with the given $redirectResponse in - $redirectResponse = new Response(301, array('Location' => 'http://example.com/new'), new ReadableBodyStream($body)); - $sender->expects($this->once())->method('send')->willReturn(Promise\resolve($redirectResponse)); + // mock sender to resolve promise with the given $redirectResponse + $deferred = new Deferred(); + $sender->expects($this->once())->method('send')->willReturn($deferred->promise()); $transaction = new Transaction($sender, $loop); $promise = $transaction->send($request); + $redirectResponse = new Response(301, array('Location' => 'http://example.com/new'), new ReadableBodyStream($body)); + $deferred->resolve($redirectResponse); + $promise->cancel(); } diff --git a/tests/Middleware/LimitConcurrentRequestsMiddlewareTest.php b/tests/Middleware/LimitConcurrentRequestsMiddlewareTest.php index 7e537391..6c63a94f 100644 --- a/tests/Middleware/LimitConcurrentRequestsMiddlewareTest.php +++ b/tests/Middleware/LimitConcurrentRequestsMiddlewareTest.php @@ -79,7 +79,7 @@ public function testLimitOneRequestConcurrently() /** * Ensure resolve frees up a slot */ - $deferredA->resolve(); + $deferredA->resolve(null); $this->assertTrue($calledA); $this->assertTrue($calledB); @@ -88,7 +88,7 @@ public function testLimitOneRequestConcurrently() /** * Ensure reject also frees up a slot */ - $deferredB->reject(); + $deferredB->reject(new \RuntimeException()); $this->assertTrue($calledA); $this->assertTrue($calledB); @@ -194,7 +194,7 @@ public function testStreamDoesPauseAndThenResumeWhenDequeued() $limitHandlers(new ServerRequest('GET', 'https://example.com/', array(), $body), function () {}); - $deferred->reject(); + $deferred->reject(new \RuntimeException()); } public function testReceivesBufferedRequestSameInstance() @@ -452,7 +452,7 @@ public function testReceivesStreamingBodyChangesInstanceWithCustomBodyButSameDat $req = $request; }); - $deferred->reject(); + $deferred->reject(new \RuntimeException()); $this->assertNotSame($request, $req); $this->assertInstanceOf('Psr\Http\Message\ServerRequestInterface', $req); From c556187f9ad466a241212adcefdcc4fa345ac2b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Fri, 2 Sep 2022 16:19:03 +0200 Subject: [PATCH 15/58] Update to stable dev dependencies --- .github/workflows/ci.yml | 3 --- composer.json | 24 +++++------------------- 2 files changed, 5 insertions(+), 22 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fafa0ff0..0724232c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,8 +29,6 @@ jobs: with: php-version: ${{ matrix.php }} coverage: xdebug - env: - COMPOSER_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: composer install - run: vendor/bin/phpunit --coverage-text if: ${{ matrix.php >= 7.3 }} @@ -41,7 +39,6 @@ jobs: name: PHPUnit (HHVM) runs-on: ubuntu-18.04 continue-on-error: true - if: false # temporarily skipped until https://github.com/azjezz/setup-hhvm/issues/3 is addressed steps: - uses: actions/checkout@v2 - uses: azjezz/setup-hhvm@v1 diff --git a/composer.json b/composer.json index 3ba8a606..d92ac820 100644 --- a/composer.json +++ b/composer.json @@ -31,16 +31,16 @@ "fig/http-message-util": "^1.1", "psr/http-message": "^1.0", "react/event-loop": "^1.2", - "react/promise": "^3@dev || ^2.3 || ^1.2.1", + "react/promise": "^3 || ^2.3 || ^1.2.1", "react/promise-stream": "^1.4", "react/socket": "^1.12", "react/stream": "^1.2", "ringcentral/psr7": "^1.2" }, "require-dev": { - "clue/http-proxy-react": "dev-promise-v3 as 1.8.0", - "clue/reactphp-ssh-proxy": "dev-promise-v3 as 1.4.0", - "clue/socks-react": "dev-promise-v3 as 1.4.0", + "clue/http-proxy-react": "^1.8", + "clue/reactphp-ssh-proxy": "^1.4", + "clue/socks-react": "^1.4", "phpunit/phpunit": "^9.3 || ^5.7 || ^4.8.35", "react/async": "^4 || ^3 || ^2", "react/promise-timer": "^1.9" @@ -50,19 +50,5 @@ }, "autoload-dev": { "psr-4": { "React\\Tests\\Http\\": "tests" } - }, - "repositories": [ - { - "type": "vcs", - "url": "https://github.com/clue-labs/reactphp-http-proxy" - }, - { - "type": "vcs", - "url": "https://github.com/clue-labs/reactphp-socks" - }, - { - "type": "vcs", - "url": "https://github.com/clue-labs/reactphp-ssh-proxy" - } - ] + } } From 0c27d679a64231343563bfb879c8fe89164093cd Mon Sep 17 00:00:00 2001 From: 51imyyy Date: Mon, 12 Sep 2022 09:52:39 +0200 Subject: [PATCH 16/58] added support for default headers in Browser PHP and moved default header user-agent to the default headers. --- README.md | 32 +++++++++++ src/Browser.php | 72 ++++++++++++++++++++++++ src/Client/RequestData.php | 1 - tests/BrowserTest.php | 97 ++++++++++++++++++++++++++++++++ tests/Client/RequestDataTest.php | 8 --- tests/Client/RequestTest.php | 10 ++-- 6 files changed, 206 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index d3a93ceb..0cdbe74e 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,8 @@ multiple concurrent HTTP requests without blocking. * [withBase()](#withbase) * [withProtocolVersion()](#withprotocolversion) * [withResponseBuffer()](#withresponsebuffer) + * [withHeader()](#withheader) + * [withoutHeader()](#withoutheader) * [React\Http\Message](#reacthttpmessage) * [Response](#response) * [html()](#html) @@ -2381,6 +2383,36 @@ Notice that the [`Browser`](#browser) is an immutable object, i.e. this method actually returns a *new* [`Browser`](#browser) instance with the given setting applied. +#### withHeader() + +The `withHeader(string $header, string $value): Browser` method can be used to +add a request header for all following requests. + +```php +$browser = $browser->withHeader('User-Agent', 'ACME'); + +$browser->get($url)->then(…); +``` + +Note that the new header will overwrite any headers previously set with +the same name (case-insensitive). Following requests will use these headers +by default unless they are explicitly set for any requests. + +#### withoutHeader() + +The `withoutHeader(string $header): Browser` method can be used to +remove any default request headers previously set via +the [`withHeader()` method](#withheader). + +```php +$browser = $browser->withoutHeader('User-Agent'); + +$browser->get($url)->then(…); +``` + +Note that this method only affects the headers which were set with the +method `withHeader(string $header, string $value): Browser` + ### React\Http\Message #### Response diff --git a/src/Browser.php b/src/Browser.php index 72847f66..16c98fb3 100644 --- a/src/Browser.php +++ b/src/Browser.php @@ -23,6 +23,9 @@ class Browser private $transaction; private $baseUrl; private $protocolVersion = '1.1'; + private $defaultHeaders = array( + 'User-Agent' => 'ReactPHP/1' + ); /** * The `Browser` is responsible for sending HTTP requests to your HTTP server @@ -725,6 +728,62 @@ public function withResponseBuffer($maximumSize) )); } + /** + * Add a request header for all following requests. + * + * ```php + * $browser = $browser->withHeader('User-Agent', 'ACME'); + * + * $browser->get($url)->then(…); + * ``` + * + * Note that the new header will overwrite any headers previously set with + * the same name (case-insensitive). Following requests will use these headers + * by default unless they are explicitly set for any requests. + * + * @param string $header + * @param string $value + * @return Browser + */ + public function withHeader($header, $value) + { + $browser = $this->withoutHeader($header); + $browser->defaultHeaders[$header] = $value; + + return $browser; + } + + /** + * Remove any default request headers previously set via + * the [`withHeader()` method](#withheader). + * + * ```php + * $browser = $browser->withoutHeader('User-Agent'); + * + * $browser->get($url)->then(…); + * ``` + * + * Note that this method only affects the headers which were set with the + * method `withHeader(string $header, string $value): Browser` + * + * @param string $header + * @return Browser + */ + public function withoutHeader($header) + { + $browser = clone $this; + + /** @var string|int $key */ + foreach (\array_keys($browser->defaultHeaders) as $key) { + if (\strcasecmp($key, $header) === 0) { + unset($browser->defaultHeaders[$key]); + break; + } + } + + return $browser; + } + /** * Changes the [options](#options) to use: * @@ -783,6 +842,19 @@ private function requestMayBeStreaming($method, $url, array $headers = array(), $body = new ReadableBodyStream($body); } + foreach ($this->defaultHeaders as $key => $value) { + $explicitHeaderExists = false; + foreach (\array_keys($headers) as $headerKey) { + if (\strcasecmp($headerKey, $key) === 0) { + $explicitHeaderExists = true; + break; + } + } + if (!$explicitHeaderExists) { + $headers[$key] = $value; + } + } + return $this->transaction->send( new Request($method, $url, $headers, $body, $this->protocolVersion) ); diff --git a/src/Client/RequestData.php b/src/Client/RequestData.php index a5908a08..04bb4cad 100644 --- a/src/Client/RequestData.php +++ b/src/Client/RequestData.php @@ -29,7 +29,6 @@ private function mergeDefaultheaders(array $headers) $defaults = array_merge( array( 'Host' => $this->getHost().$port, - 'User-Agent' => 'ReactPHP/1', ), $connectionHeaders, $authHeaders diff --git a/tests/BrowserTest.php b/tests/BrowserTest.php index 39be453a..75717169 100644 --- a/tests/BrowserTest.php +++ b/tests/BrowserTest.php @@ -503,4 +503,101 @@ public function testCancelGetRequestShouldCancelUnderlyingSocketConnection() $promise = $this->browser->get('http://example.com/'); $promise->cancel(); } + + public function testWithHeaderShouldOverwriteExistingHeader() + { + $this->browser = $this->browser->withHeader('User-Agent', 'ACMC'); //should be overwritten + $this->browser = $this->browser->withHeader('user-agent', 'ABC'); //should be the user-agent + + $that = $this; + $this->sender->expects($this->once())->method('send')->with($this->callback(function (RequestInterface $request) use ($that) { + $that->assertEquals(array('ABC'), $request->getHeader('UsEr-AgEnT')); + return true; + }))->willReturn(new Promise(function () { })); + + $this->browser->get('http://example.com/'); + } + + public function testWithHeaderShouldBeOverwrittenByExplicitHeaderInGetMethod() + { + $this->browser = $this->browser->withHeader('User-Agent', 'ACMC'); + + $that = $this; + $this->sender->expects($this->once())->method('send')->with($this->callback(function (RequestInterface $request) use ($that) { + $that->assertEquals(array('ABC'), $request->getHeader('UsEr-AgEnT')); + return true; + }))->willReturn(new Promise(function () { })); + + $this->browser->get('http://example.com/', array('user-Agent' => 'ABC')); //should win + } + + public function testWithMultipleHeadersShouldBeMergedCorrectlyWithMultipleDefaultHeaders() + { + $this->browser = $this->browser->withHeader('User-Agent', 'ACMC'); + $this->browser = $this->browser->withHeader('User-Test', 'Test'); + $this->browser = $this->browser->withHeader('Custom-HEADER', 'custom'); + $this->browser = $this->browser->withHeader('just-a-header', 'header-value'); + + $that = $this; + $this->sender->expects($this->once())->method('send')->with($this->callback(function (RequestInterface $request) use ($that) { + $expectedHeaders = array( + 'Host' => array('example.com'), + + 'User-Test' => array('Test'), + 'just-a-header' => array('header-value'), + + 'user-Agent' => array('ABC'), + 'another-header' => array('value'), + 'custom-header' => array('data'), + ); + + $that->assertEquals($expectedHeaders, $request->getHeaders()); + return true; + }))->willReturn(new Promise(function () { })); + + $headers = array( + 'user-Agent' => 'ABC', //should overwrite: 'User-Agent', 'ACMC' + 'another-header' => 'value', + 'custom-header' => 'data', //should overwrite: 'Custom-header', 'custom' + ); + $this->browser->get('http://example.com/', $headers); + } + + public function testWithoutHeaderShouldRemoveExistingHeader() + { + $this->browser = $this->browser->withHeader('User-Agent', 'ACMC'); + $this->browser = $this->browser->withoutHeader('UsEr-AgEnT'); //should remove case-insensitive header + + $that = $this; + $this->sender->expects($this->once())->method('send')->with($this->callback(function (RequestInterface $request) use ($that) { + $that->assertEquals(array(), $request->getHeader('user-agent')); + return true; + }))->willReturn(new Promise(function () { })); + + $this->browser->get('http://example.com/'); + } + + public function testBrowserShouldSendDefaultUserAgentHeader() + { + $that = $this; + $this->sender->expects($this->once())->method('send')->with($this->callback(function (RequestInterface $request) use ($that) { + $that->assertEquals(array(0 => 'ReactPHP/1'), $request->getHeader('user-agent')); + return true; + }))->willReturn(new Promise(function () { })); + + $this->browser->get('http://example.com/'); + } + + public function testBrowserShouldNotSendDefaultUserAgentHeaderIfWithoutHeaderRemovesUserAgent() + { + $this->browser = $this->browser->withoutHeader('UsEr-AgEnT'); + + $that = $this; + $this->sender->expects($this->once())->method('send')->with($this->callback(function (RequestInterface $request) use ($that) { + $that->assertEquals(array(), $request->getHeader('User-Agent')); + return true; + }))->willReturn(new Promise(function () { })); + + $this->browser->get('http://example.com/'); + } } diff --git a/tests/Client/RequestDataTest.php b/tests/Client/RequestDataTest.php index 7f96e152..f6713e85 100644 --- a/tests/Client/RequestDataTest.php +++ b/tests/Client/RequestDataTest.php @@ -14,7 +14,6 @@ public function toStringReturnsHTTPRequestMessage() $expected = "GET / HTTP/1.0\r\n" . "Host: www.example.com\r\n" . - "User-Agent: ReactPHP/1\r\n" . "\r\n"; $this->assertSame($expected, $requestData->__toString()); @@ -27,7 +26,6 @@ public function toStringReturnsHTTPRequestMessageWithEmptyQueryString() $expected = "GET /path?hello=world HTTP/1.0\r\n" . "Host: www.example.com\r\n" . - "User-Agent: ReactPHP/1\r\n" . "\r\n"; $this->assertSame($expected, $requestData->__toString()); @@ -40,7 +38,6 @@ public function toStringReturnsHTTPRequestMessageWithZeroQueryStringAndRootPath( $expected = "GET /?0 HTTP/1.0\r\n" . "Host: www.example.com\r\n" . - "User-Agent: ReactPHP/1\r\n" . "\r\n"; $this->assertSame($expected, $requestData->__toString()); @@ -53,7 +50,6 @@ public function toStringReturnsHTTPRequestMessageWithOptionsAbsoluteRequestForm( $expected = "OPTIONS / HTTP/1.0\r\n" . "Host: www.example.com\r\n" . - "User-Agent: ReactPHP/1\r\n" . "\r\n"; $this->assertSame($expected, $requestData->__toString()); @@ -66,7 +62,6 @@ public function toStringReturnsHTTPRequestMessageWithOptionsAsteriskRequestForm( $expected = "OPTIONS * HTTP/1.0\r\n" . "Host: www.example.com\r\n" . - "User-Agent: ReactPHP/1\r\n" . "\r\n"; $this->assertSame($expected, $requestData->__toString()); @@ -80,7 +75,6 @@ public function toStringReturnsHTTPRequestMessageWithProtocolVersion() $expected = "GET / HTTP/1.1\r\n" . "Host: www.example.com\r\n" . - "User-Agent: ReactPHP/1\r\n" . "Connection: close\r\n" . "\r\n"; @@ -131,7 +125,6 @@ public function toStringReturnsHTTPRequestMessageWithProtocolVersionThroughConst $expected = "GET / HTTP/1.1\r\n" . "Host: www.example.com\r\n" . - "User-Agent: ReactPHP/1\r\n" . "Connection: close\r\n" . "\r\n"; @@ -145,7 +138,6 @@ public function toStringUsesUserPassFromURL() $expected = "GET / HTTP/1.0\r\n" . "Host: www.example.com\r\n" . - "User-Agent: ReactPHP/1\r\n" . "Authorization: Basic am9objpkdW1teQ==\r\n" . "\r\n"; diff --git a/tests/Client/RequestTest.php b/tests/Client/RequestTest.php index fb2dc884..cdb209cf 100644 --- a/tests/Client/RequestTest.php +++ b/tests/Client/RequestTest.php @@ -181,7 +181,7 @@ public function postRequestShouldSendAPostRequest() $this->stream ->expects($this->once()) ->method('write') - ->with($this->matchesRegularExpression("#^POST / HTTP/1\.0\r\nHost: www.example.com\r\nUser-Agent:.*\r\n\r\nsome post data$#")); + ->with($this->matchesRegularExpression("#^POST / HTTP/1\.0\r\nHost: www.example.com\r\n\r\nsome post data$#")); $request->end('some post data'); @@ -199,7 +199,7 @@ public function writeWithAPostRequestShouldSendToTheStream() $this->successfulConnectionMock(); $this->stream->expects($this->exactly(3))->method('write')->withConsecutive( - array($this->matchesRegularExpression("#^POST / HTTP/1\.0\r\nHost: www.example.com\r\nUser-Agent:.*\r\n\r\nsome$#")), + array($this->matchesRegularExpression("#^POST / HTTP/1\.0\r\nHost: www.example.com\r\n\r\nsome$#")), array($this->identicalTo("post")), array($this->identicalTo("data")) ); @@ -222,7 +222,7 @@ public function writeWithAPostRequestShouldSendBodyAfterHeadersAndEmitDrainEvent $resolveConnection = $this->successfulAsyncConnectionMock(); $this->stream->expects($this->exactly(2))->method('write')->withConsecutive( - array($this->matchesRegularExpression("#^POST / HTTP/1\.0\r\nHost: www.example.com\r\nUser-Agent:.*\r\n\r\nsomepost$#")), + array($this->matchesRegularExpression("#^POST / HTTP/1\.0\r\nHost: www.example.com\r\n\r\nsomepost$#")), array($this->identicalTo("data")) )->willReturn( true @@ -258,7 +258,7 @@ public function writeWithAPostRequestShouldForwardDrainEventIfFirstChunkExceedsB $resolveConnection = $this->successfulAsyncConnectionMock(); $this->stream->expects($this->exactly(2))->method('write')->withConsecutive( - array($this->matchesRegularExpression("#^POST / HTTP/1\.0\r\nHost: www.example.com\r\nUser-Agent:.*\r\n\r\nsomepost$#")), + array($this->matchesRegularExpression("#^POST / HTTP/1\.0\r\nHost: www.example.com\r\n\r\nsomepost$#")), array($this->identicalTo("data")) )->willReturn( false @@ -290,7 +290,7 @@ public function pipeShouldPipeDataIntoTheRequestBody() $this->successfulConnectionMock(); $this->stream->expects($this->exactly(3))->method('write')->withConsecutive( - array($this->matchesRegularExpression("#^POST / HTTP/1\.0\r\nHost: www.example.com\r\nUser-Agent:.*\r\n\r\nsome$#")), + array($this->matchesRegularExpression("#^POST / HTTP/1\.0\r\nHost: www.example.com\r\n\r\nsome$#")), array($this->identicalTo("post")), array($this->identicalTo("data")) ); From 2290723e79dcd0fa17d8c9432727d3e17dde37b0 Mon Sep 17 00:00:00 2001 From: Fabian Meyer Date: Mon, 19 Sep 2022 11:24:47 +0200 Subject: [PATCH 17/58] Preserve method on redirect --- README.md | 7 ++- src/Io/Transaction.php | 33 ++++++---- tests/Io/TransactionTest.php | 118 ++++++++++++++++++++++++++++++++++- 3 files changed, 143 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 66ea34a8..f1f6d7cd 100644 --- a/README.md +++ b/README.md @@ -342,9 +342,10 @@ $browser->get($url, $headers)->then(function (Psr\Http\Message\ResponseInterface Any redirected requests will follow the semantics of the original request and will include the same request headers as the original request except for those listed below. -If the original request contained a request body, this request body will never -be passed to the redirected request. Accordingly, each redirected request will -remove any `Content-Length` and `Content-Type` request headers. +If the original request is a temporary (307) or a permanent (308) redirect, request +body and headers will be passed to the redirected request. Otherwise, the request +body will never be passed to the redirected request. Accordingly, each redirected +request will remove any `Content-Length` and `Content-Type` request headers. If the original request used HTTP authentication with an `Authorization` request header, this request header will only be passed as part of the redirected diff --git a/src/Io/Transaction.php b/src/Io/Transaction.php index 330ffed0..b64622a8 100644 --- a/src/Io/Transaction.php +++ b/src/Io/Transaction.php @@ -5,6 +5,7 @@ use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\UriInterface; +use React\Http\Message\Response; use RingCentral\Psr7\Request; use RingCentral\Psr7\Uri; use React\EventLoop\LoopInterface; @@ -234,6 +235,8 @@ public function onResponse(ResponseInterface $response, RequestInterface $reques /** * @param ResponseInterface $response * @param RequestInterface $request + * @param Deferred $deferred + * @param ClientRequestState $state * @return PromiseInterface * @throws \RuntimeException */ @@ -242,7 +245,7 @@ private function onResponseRedirect(ResponseInterface $response, RequestInterfac // resolve location relative to last request URI $location = Uri::resolve($request->getUri(), $response->getHeaderLine('Location')); - $request = $this->makeRedirectRequest($request, $location); + $request = $this->makeRedirectRequest($request, $location, $response->getStatusCode()); $this->progress('redirect', array($request)); if ($state->numRequests >= $this->maxRedirects) { @@ -255,25 +258,33 @@ private function onResponseRedirect(ResponseInterface $response, RequestInterfac /** * @param RequestInterface $request * @param UriInterface $location + * @param int $statusCode * @return RequestInterface + * @throws \RuntimeException */ - private function makeRedirectRequest(RequestInterface $request, UriInterface $location) + private function makeRedirectRequest(RequestInterface $request, UriInterface $location, $statusCode) { - $originalHost = $request->getUri()->getHost(); - $request = $request - ->withoutHeader('Host') - ->withoutHeader('Content-Type') - ->withoutHeader('Content-Length'); - // Remove authorization if changing hostnames (but not if just changing ports or protocols). + $originalHost = $request->getUri()->getHost(); if ($location->getHost() !== $originalHost) { $request = $request->withoutHeader('Authorization'); } - // naïve approach.. - $method = ($request->getMethod() === 'HEAD') ? 'HEAD' : 'GET'; + $request = $request->withoutHeader('Host')->withUri($location); + + if ($statusCode === Response::STATUS_TEMPORARY_REDIRECT || $statusCode === Response::STATUS_PERMANENT_REDIRECT) { + if ($request->getBody() instanceof ReadableStreamInterface) { + throw new \RuntimeException('Unable to redirect request with streaming body'); + } + } else { + $request = $request + ->withMethod($request->getMethod() === 'HEAD' ? 'HEAD' : 'GET') + ->withoutHeader('Content-Type') + ->withoutHeader('Content-Length') + ->withBody(new EmptyBodyStream()); + } - return new Request($method, $location, $request->getHeaders()); + return $request; } private function progress($name, array $args = array()) diff --git a/tests/Io/TransactionTest.php b/tests/Io/TransactionTest.php index d9ac2178..05022009 100644 --- a/tests/Io/TransactionTest.php +++ b/tests/Io/TransactionTest.php @@ -663,7 +663,7 @@ public function testSomeRequestHeadersShouldBeRemovedWhenRedirecting() array($this->callback(function (RequestInterface $request) use ($that) { $that->assertFalse($request->hasHeader('Content-Type')); $that->assertFalse($request->hasHeader('Content-Length')); - return true;; + return true; })) )->willReturnOnConsecutiveCalls( Promise\resolve($redirectResponse), @@ -674,6 +674,122 @@ public function testSomeRequestHeadersShouldBeRemovedWhenRedirecting() $transaction->send($requestWithCustomHeaders); } + public function testRequestMethodShouldBeChangedWhenRedirectingWithSeeOther() + { + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + + $customHeaders = array( + 'Content-Type' => 'text/html; charset=utf-8', + 'Content-Length' => '111', + ); + + $request = new Request('POST', 'http://example.com/', $customHeaders); + $sender = $this->makeSenderMock(); + + // mock sender to resolve promise with the given $redirectResponse in + // response to the given $request + $redirectResponse = new Response(303, array('Location' => 'http://example.com/new')); + + // mock sender to resolve promise with the given $okResponse in + // response to the given $request + $okResponse = new Response(200); + $that = $this; + $sender->expects($this->exactly(2))->method('send')->withConsecutive( + array($this->anything()), + array($this->callback(function (RequestInterface $request) use ($that) { + $that->assertEquals('GET', $request->getMethod()); + $that->assertFalse($request->hasHeader('Content-Type')); + $that->assertFalse($request->hasHeader('Content-Length')); + return true; + })) + )->willReturnOnConsecutiveCalls( + Promise\resolve($redirectResponse), + Promise\resolve($okResponse) + ); + + $transaction = new Transaction($sender, $loop); + $transaction->send($request); + } + + public function testRequestMethodAndBodyShouldNotBeChangedWhenRedirectingWith307Or308() + { + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + + $customHeaders = array( + 'Content-Type' => 'text/html; charset=utf-8', + 'Content-Length' => '111', + ); + + $request = new Request('POST', 'http://example.com/', $customHeaders, '{"key":"value"}'); + $sender = $this->makeSenderMock(); + + // mock sender to resolve promise with the given $redirectResponse in + // response to the given $request + $redirectResponse = new Response(307, array('Location' => 'http://example.com/new')); + + // mock sender to resolve promise with the given $okResponse in + // response to the given $request + $okResponse = new Response(200); + $that = $this; + $sender->expects($this->exactly(2))->method('send')->withConsecutive( + array($this->anything()), + array($this->callback(function (RequestInterface $request) use ($that) { + $that->assertEquals('POST', $request->getMethod()); + $that->assertEquals('{"key":"value"}', (string)$request->getBody()); + $that->assertEquals( + array( + 'Content-Type' => array('text/html; charset=utf-8'), + 'Content-Length' => array('111'), + 'Host' => array('example.com') + ), + $request->getHeaders() + ); + return true; + })) + )->willReturnOnConsecutiveCalls( + Promise\resolve($redirectResponse), + Promise\resolve($okResponse) + ); + + $transaction = new Transaction($sender, $loop); + $transaction->send($request); + } + + public function testRedirectingStreamingBodyWith307Or308ShouldThrowCantRedirectStreamException() + { + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + + $customHeaders = array( + 'Content-Type' => 'text/html; charset=utf-8', + 'Content-Length' => '111', + ); + + $stream = new ThroughStream(); + $request = new Request('POST', 'http://example.com/', $customHeaders, new ReadableBodyStream($stream)); + $sender = $this->makeSenderMock(); + + // mock sender to resolve promise with the given $redirectResponse in + // response to the given $request + $redirectResponse = new Response(307, array('Location' => 'http://example.com/new')); + + $sender->expects($this->once())->method('send')->withConsecutive( + array($this->anything()) + )->willReturnOnConsecutiveCalls( + Promise\resolve($redirectResponse) + ); + + $transaction = new Transaction($sender, $loop); + $promise = $transaction->send($request); + + $exception = null; + $promise->then(null, function ($reason) use (&$exception) { + $exception = $reason; + }); + + assert($exception instanceof \RuntimeException); + $this->assertEquals('Unable to redirect request with streaming body', $exception->getMessage()); + } + public function testCancelTransactionWillCancelRequest() { $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); From aa7512ee17258c88466de30f9cb44ec5f9df3ff3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Thu, 29 Sep 2022 14:55:52 +0200 Subject: [PATCH 18/58] Prepare v1.8.0 release --- CHANGELOG.md | 17 ++++++++++++++++- README.md | 2 +- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5bf17e9f..00e2d07e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## 1.8.0 (2022-09-29) + +* Feature: Support for default request headers. + (#461 by @51imyy) + + ```php + $browser = new React\Http\Browser(); + $browser = $browser->withHeader('User-Agent', 'ACME'); + + $browser->get($url)->then(…); + ``` + +* Feature: Forward compatibility with upcoming Promise v3. + (#460 by @clue) + ## 1.7.0 (2022-08-23) This is a **SECURITY** and feature release for the 1.x series of ReactPHP's HTTP component. @@ -19,7 +34,7 @@ This is a **SECURITY** and feature release for the 1.x series of ReactPHP's HTTP (#444 by @mrsimonbennett) * Minor documentation improvements. - (#452 by @clue, #458 by @nhedger, #448 by @jorrit and #446 by @SimonFrings + (#452 by @clue, #458 by @nhedger, #448 by @jorrit and #446 by @SimonFrings) * Improve test suite, update to use new reactphp/async package instead of clue/reactphp-block, skip memory tests when lowering memory limit fails and fix legacy HHVM build. diff --git a/README.md b/README.md index 66ea34a8..1df8873f 100644 --- a/README.md +++ b/README.md @@ -2956,7 +2956,7 @@ This project follows [SemVer](https://semver.org/). This will install the latest supported version: ```bash -composer require react/http:^1.7 +composer require react/http:^1.8 ``` See also the [CHANGELOG](CHANGELOG.md) for details about version upgrades. From f2a1446f0d735d2ae2eb5faaa08bc2725e6a9c9d Mon Sep 17 00:00:00 2001 From: Simon Frings Date: Tue, 6 Sep 2022 16:13:29 +0200 Subject: [PATCH 19/58] Add issue template for better orientation --- .github/ISSUE_TEMPLATE/bug.md | 11 +++++++++++ .github/ISSUE_TEMPLATE/config.yml | 11 +++++++++++ 2 files changed, 22 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/bug.md create mode 100644 .github/ISSUE_TEMPLATE/config.yml diff --git a/.github/ISSUE_TEMPLATE/bug.md b/.github/ISSUE_TEMPLATE/bug.md new file mode 100644 index 00000000..d26fe152 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.md @@ -0,0 +1,11 @@ +--- +name: Bug report +about: Found a bug in our project? Create a report to help us improve. +labels: bug +--- + + + +```php +// Please add code examples if possible, so we can reproduce your steps +``` diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..4b4a0ea6 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,11 @@ +blank_issues_enabled: false +contact_links: + - name: Report a security vulnerability + url: https://reactphp.org/#support + about: 'If you discover a security vulnerability, please send us an email. Do not disclose security-related issues publicly.' + - name: Feature request + url: https://github.com/orgs/reactphp/discussions/categories/ideas + about: 'You have ideas to improve our project? Start a new discussion in our "Ideas" category.' + - name: Questions + url: https://github.com/orgs/reactphp/discussions/categories/q-a + about: 'We are happy to answer your questions! Start a new discussion in our "Q&A" category.' From 44f0a80f7a1616249cc6c817d30921993d5776cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Sat, 12 Nov 2022 17:19:44 +0100 Subject: [PATCH 20/58] Update test suite and report failed assertions --- .github/workflows/ci.yml | 22 +++++++++++++--------- composer.json | 10 +++++++--- phpunit.xml.dist | 14 +++++++++++--- phpunit.xml.legacy | 10 +++++++++- 4 files changed, 40 insertions(+), 16 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0724232c..55bbaa5b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,7 +7,7 @@ on: jobs: PHPUnit: name: PHPUnit (PHP ${{ matrix.php }}) - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 strategy: matrix: php: @@ -24,11 +24,12 @@ jobs: - 5.4 - 5.3 steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php }} coverage: xdebug + ini-file: development - run: composer install - run: vendor/bin/phpunit --coverage-text if: ${{ matrix.php >= 7.3 }} @@ -37,13 +38,16 @@ jobs: PHPUnit-hhvm: name: PHPUnit (HHVM) - runs-on: ubuntu-18.04 + runs-on: ubuntu-22.04 continue-on-error: true steps: - - uses: actions/checkout@v2 - - uses: azjezz/setup-hhvm@v1 + - uses: actions/checkout@v3 + - run: cp "$(which composer)" composer.phar && ./composer.phar self-update --2.2 # downgrade Composer for HHVM + - name: Run hhvm composer.phar install + uses: docker://hhvm/hhvm:3.30-lts-latest with: - version: lts-3.30 - - run: composer self-update --2.2 # downgrade Composer for HHVM - - run: hhvm $(which composer) install - - run: hhvm vendor/bin/phpunit + args: hhvm composer.phar install + - name: Run hhvm vendor/bin/phpunit + uses: docker://hhvm/hhvm:3.30-lts-latest + with: + args: hhvm vendor/bin/phpunit diff --git a/composer.json b/composer.json index d92ac820..aeee592b 100644 --- a/composer.json +++ b/composer.json @@ -41,14 +41,18 @@ "clue/http-proxy-react": "^1.8", "clue/reactphp-ssh-proxy": "^1.4", "clue/socks-react": "^1.4", - "phpunit/phpunit": "^9.3 || ^5.7 || ^4.8.35", + "phpunit/phpunit": "^9.5 || ^5.7 || ^4.8.35", "react/async": "^4 || ^3 || ^2", "react/promise-timer": "^1.9" }, "autoload": { - "psr-4": { "React\\Http\\": "src" } + "psr-4": { + "React\\Http\\": "src/" + } }, "autoload-dev": { - "psr-4": { "React\\Tests\\Http\\": "tests" } + "psr-4": { + "React\\Tests\\Http\\": "tests/" + } } } diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 93a36f6b..7a9577e9 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,8 +1,8 @@ - - +./src/ + + + + + + + + diff --git a/phpunit.xml.legacy b/phpunit.xml.legacy index fbb43e85..ac5600ae 100644 --- a/phpunit.xml.legacy +++ b/phpunit.xml.legacy @@ -1,6 +1,6 @@ - + ./src/ + + + + + + + + From b8f6efa7225e3da486606ca757554174b8f40ebe Mon Sep 17 00:00:00 2001 From: Simon Frings Date: Thu, 17 Nov 2022 15:27:56 +0100 Subject: [PATCH 21/58] Revert issue template changes to use organisation issue template --- .github/ISSUE_TEMPLATE/bug.md | 11 ----------- .github/ISSUE_TEMPLATE/config.yml | 11 ----------- 2 files changed, 22 deletions(-) delete mode 100644 .github/ISSUE_TEMPLATE/bug.md delete mode 100644 .github/ISSUE_TEMPLATE/config.yml diff --git a/.github/ISSUE_TEMPLATE/bug.md b/.github/ISSUE_TEMPLATE/bug.md deleted file mode 100644 index d26fe152..00000000 --- a/.github/ISSUE_TEMPLATE/bug.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -name: Bug report -about: Found a bug in our project? Create a report to help us improve. -labels: bug ---- - - - -```php -// Please add code examples if possible, so we can reproduce your steps -``` diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml deleted file mode 100644 index 4b4a0ea6..00000000 --- a/.github/ISSUE_TEMPLATE/config.yml +++ /dev/null @@ -1,11 +0,0 @@ -blank_issues_enabled: false -contact_links: - - name: Report a security vulnerability - url: https://reactphp.org/#support - about: 'If you discover a security vulnerability, please send us an email. Do not disclose security-related issues publicly.' - - name: Feature request - url: https://github.com/orgs/reactphp/discussions/categories/ideas - about: 'You have ideas to improve our project? Start a new discussion in our "Ideas" category.' - - name: Questions - url: https://github.com/orgs/reactphp/discussions/categories/q-a - about: 'We are happy to answer your questions! Start a new discussion in our "Q&A" category.' From 7a27c49ec600940ab062d7f378a7fed7d3fb54ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Tue, 22 Nov 2022 14:40:33 +0100 Subject: [PATCH 22/58] Add `Request` class to represent outgoing HTTP request message --- README.md | 19 ++++++++++ src/Browser.php | 7 +--- src/Io/Transaction.php | 5 ++- src/Message/Request.php | 58 ++++++++++++++++++++++++++++++ src/Message/ServerRequest.php | 4 +-- tests/FunctionalBrowserTest.php | 3 +- tests/Io/SenderTest.php | 2 +- tests/Io/TransactionTest.php | 4 +-- tests/Message/RequestTest.php | 63 +++++++++++++++++++++++++++++++++ 9 files changed, 149 insertions(+), 16 deletions(-) create mode 100644 src/Message/Request.php create mode 100644 tests/Message/RequestTest.php diff --git a/README.md b/README.md index 55ebb8e7..271f5e87 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,7 @@ multiple concurrent HTTP requests without blocking. * [json()](#json) * [plaintext()](#plaintext) * [xml()](#xml) + * [Request](#request-1) * [ServerRequest](#serverrequest) * [ResponseException](#responseexception) * [React\Http\Middleware](#reacthttpmiddleware) @@ -2628,6 +2629,24 @@ $response = React\Http\Message\Response::xml( )->withStatus(React\Http\Message\Response::STATUS_BAD_REQUEST); ``` +#### Request + +The `React\Http\Message\Request` class can be used to +respresent an outgoing HTTP request message. + +This class implements the +[PSR-7 `RequestInterface`](https://www.php-fig.org/psr/psr-7/#32-psrhttpmessagerequestinterface) +which extends the +[PSR-7 `MessageInterface`](https://www.php-fig.org/psr/psr-7/#31-psrhttpmessagemessageinterface). + +This is mostly used internally to represent each outgoing HTTP request +message for the HTTP client implementation. Likewise, you can also use this +class with other HTTP client implementations and for tests. + +> Internally, this implementation builds on top of an existing outgoing + request message and only adds support for streaming. This base class is + considered an implementation detail that may change in the future. + #### ServerRequest The `React\Http\Message\ServerRequest` class can be used to diff --git a/src/Browser.php b/src/Browser.php index 16c98fb3..3e3458af 100644 --- a/src/Browser.php +++ b/src/Browser.php @@ -3,13 +3,12 @@ namespace React\Http; use Psr\Http\Message\ResponseInterface; -use RingCentral\Psr7\Request; use RingCentral\Psr7\Uri; use React\EventLoop\Loop; use React\EventLoop\LoopInterface; -use React\Http\Io\ReadableBodyStream; use React\Http\Io\Sender; use React\Http\Io\Transaction; +use React\Http\Message\Request; use React\Promise\PromiseInterface; use React\Socket\ConnectorInterface; use React\Stream\ReadableStreamInterface; @@ -838,10 +837,6 @@ private function requestMayBeStreaming($method, $url, array $headers = array(), $url = Uri::resolve($this->baseUrl, $url); } - if ($body instanceof ReadableStreamInterface) { - $body = new ReadableBodyStream($body); - } - foreach ($this->defaultHeaders as $key => $value) { $explicitHeaderExists = false; foreach (\array_keys($headers) as $headerKey) { diff --git a/src/Io/Transaction.php b/src/Io/Transaction.php index b64622a8..cbf8f3eb 100644 --- a/src/Io/Transaction.php +++ b/src/Io/Transaction.php @@ -5,14 +5,13 @@ use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\UriInterface; -use React\Http\Message\Response; -use RingCentral\Psr7\Request; -use RingCentral\Psr7\Uri; use React\EventLoop\LoopInterface; +use React\Http\Message\Response; use React\Http\Message\ResponseException; use React\Promise\Deferred; use React\Promise\PromiseInterface; use React\Stream\ReadableStreamInterface; +use RingCentral\Psr7\Uri; /** * @internal diff --git a/src/Message/Request.php b/src/Message/Request.php new file mode 100644 index 00000000..cf59641e --- /dev/null +++ b/src/Message/Request.php @@ -0,0 +1,58 @@ + Internally, this implementation builds on top of an existing outgoing + * request message and only adds support for streaming. This base class is + * considered an implementation detail that may change in the future. + * + * @see RequestInterface + */ +final class Request extends BaseRequest implements RequestInterface +{ + /** + * @param string $method HTTP method for the request. + * @param string|UriInterface $url URL for the request. + * @param array $headers Headers for the message. + * @param string|ReadableStreamInterface|StreamInterface $body Message body. + * @param string $version HTTP protocol version. + * @throws \InvalidArgumentException for an invalid URL or body + */ + public function __construct( + $method, + $url, + array $headers = array(), + $body = '', + $version = '1.1' + ) { + if (\is_string($body)) { + $body = new BufferedBody($body); + } elseif ($body instanceof ReadableStreamInterface && !$body instanceof StreamInterface) { + $body = new ReadableBodyStream($body); + } elseif (!$body instanceof StreamInterface) { + throw new \InvalidArgumentException('Invalid request body given'); + } + + parent::__construct($method, $url, $headers, $body, $version); + } +} diff --git a/src/Message/ServerRequest.php b/src/Message/ServerRequest.php index fdb3ec5e..25532cf4 100644 --- a/src/Message/ServerRequest.php +++ b/src/Message/ServerRequest.php @@ -8,7 +8,7 @@ use React\Http\Io\BufferedBody; use React\Http\Io\HttpBodyStream; use React\Stream\ReadableStreamInterface; -use RingCentral\Psr7\Request; +use RingCentral\Psr7\Request as BaseRequest; /** * Respresents an incoming server request message. @@ -30,7 +30,7 @@ * * @see ServerRequestInterface */ -final class ServerRequest extends Request implements ServerRequestInterface +final class ServerRequest extends BaseRequest implements ServerRequestInterface { private $attributes = array(); diff --git a/tests/FunctionalBrowserTest.php b/tests/FunctionalBrowserTest.php index 95092ac1..6def2ecc 100644 --- a/tests/FunctionalBrowserTest.php +++ b/tests/FunctionalBrowserTest.php @@ -7,16 +7,15 @@ use React\EventLoop\Loop; use React\Http\Browser; use React\Http\HttpServer; +use React\Http\Message\Response; use React\Http\Message\ResponseException; use React\Http\Middleware\StreamingRequestMiddleware; -use React\Http\Message\Response; use React\Promise\Promise; use React\Promise\Stream; use React\Socket\Connector; use React\Socket\SocketServer; use React\Stream\ReadableStreamInterface; use React\Stream\ThroughStream; -use RingCentral\Psr7\Request; class FunctionalBrowserTest extends TestCase { diff --git a/tests/Io/SenderTest.php b/tests/Io/SenderTest.php index 587ba0c2..91b87b30 100644 --- a/tests/Io/SenderTest.php +++ b/tests/Io/SenderTest.php @@ -6,10 +6,10 @@ use React\Http\Client\RequestData; use React\Http\Io\ReadableBodyStream; use React\Http\Io\Sender; +use React\Http\Message\Request; use React\Promise; use React\Stream\ThroughStream; use React\Tests\Http\TestCase; -use RingCentral\Psr7\Request; class SenderTest extends TestCase { diff --git a/tests/Io/TransactionTest.php b/tests/Io/TransactionTest.php index 05022009..e0d04e39 100644 --- a/tests/Io/TransactionTest.php +++ b/tests/Io/TransactionTest.php @@ -7,14 +7,14 @@ use Psr\Http\Message\ResponseInterface; use React\Http\Io\ReadableBodyStream; use React\Http\Io\Transaction; +use React\Http\Message\Request; +use React\Http\Message\Response; use React\Http\Message\ResponseException; use React\EventLoop\Loop; use React\Promise; use React\Promise\Deferred; use React\Stream\ThroughStream; use React\Tests\Http\TestCase; -use RingCentral\Psr7\Request; -use RingCentral\Psr7\Response; class TransactionTest extends TestCase { diff --git a/tests/Message/RequestTest.php b/tests/Message/RequestTest.php new file mode 100644 index 00000000..29baf8a7 --- /dev/null +++ b/tests/Message/RequestTest.php @@ -0,0 +1,63 @@ +getBody(); + $this->assertSame(3, $body->getSize()); + $this->assertEquals('foo', (string) $body); + } + + public function testConstructWithStreamingRequestBodyReturnsBodyWhichImplementsReadableStreamInterfaceWithUnknownSize() + { + $request = new Request( + 'GET', + 'http://localhost/', + array(), + new ThroughStream() + ); + + $body = $request->getBody(); + $this->assertInstanceOf('Psr\Http\Message\StreamInterface', $body); + $this->assertInstanceOf('React\Stream\ReadableStreamInterface', $body); + $this->assertNull($body->getSize()); + } + + public function testConstructWithHttpBodyStreamReturnsBodyAsIs() + { + $request = new Request( + 'GET', + 'http://localhost/', + array(), + $body = new HttpBodyStream(new ThroughStream(), 100) + ); + + $this->assertSame($body, $request->getBody()); + } + + public function testConstructWithNullBodyThrows() + { + $this->setExpectedException('InvalidArgumentException', 'Invalid request body given'); + new Request( + 'GET', + 'http://localhost/', + array(), + null + ); + } +} From 01228fa89454b00695f68c84e177b400715ff081 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Wed, 23 Nov 2022 14:06:26 +0100 Subject: [PATCH 23/58] Rename internal `Request` to `ClientRequestStream` --- src/Client/Client.php | 6 ++- .../ClientRequestStream.php} | 7 +-- .../ClientRequestStreamTest.php} | 48 +++++++++---------- tests/Io/SenderTest.php | 26 +++++----- 4 files changed, 45 insertions(+), 42 deletions(-) rename src/{Client/Request.php => Io/ClientRequestStream.php} (96%) rename tests/{Client/RequestTest.php => Io/ClientRequestStreamTest.php} (89%) diff --git a/src/Client/Client.php b/src/Client/Client.php index 7a97349c..62caed5f 100644 --- a/src/Client/Client.php +++ b/src/Client/Client.php @@ -3,8 +3,9 @@ namespace React\Http\Client; use React\EventLoop\LoopInterface; -use React\Socket\ConnectorInterface; +use React\Http\Io\ClientRequestStream; use React\Socket\Connector; +use React\Socket\ConnectorInterface; /** * @internal @@ -22,10 +23,11 @@ public function __construct(LoopInterface $loop, ConnectorInterface $connector = $this->connector = $connector; } + /** @return ClientRequestStream */ public function request($method, $url, array $headers = array(), $protocolVersion = '1.0') { $requestData = new RequestData($method, $url, $headers, $protocolVersion); - return new Request($this->connector, $requestData); + return new ClientRequestStream($this->connector, $requestData); } } diff --git a/src/Client/Request.php b/src/Io/ClientRequestStream.php similarity index 96% rename from src/Client/Request.php rename to src/Io/ClientRequestStream.php index 51e03313..2513a89a 100644 --- a/src/Client/Request.php +++ b/src/Io/ClientRequestStream.php @@ -1,8 +1,9 @@ write($headers . $pendingWrites); - $stateRef = Request::STATE_HEAD_WRITTEN; + $stateRef = ClientRequestStream::STATE_HEAD_WRITTEN; // clear pending writes if non-empty if ($pendingWrites !== '') { diff --git a/tests/Client/RequestTest.php b/tests/Io/ClientRequestStreamTest.php similarity index 89% rename from tests/Client/RequestTest.php rename to tests/Io/ClientRequestStreamTest.php index cdb209cf..6e3e16b8 100644 --- a/tests/Client/RequestTest.php +++ b/tests/Io/ClientRequestStreamTest.php @@ -1,16 +1,16 @@ connector, $requestData); + $request = new ClientRequestStream($this->connector, $requestData); $this->successfulConnectionMock(); @@ -67,7 +67,7 @@ public function requestShouldBindToStreamEventsAndUseconnector() public function requestShouldConnectViaTlsIfUrlUsesHttpsScheme() { $requestData = new RequestData('GET', 'https://www.example.com/'); - $request = new Request($this->connector, $requestData); + $request = new ClientRequestStream($this->connector, $requestData); $this->connector->expects($this->once())->method('connect')->with('tls://www.example.com:443')->willReturn(new Promise(function () { })); @@ -78,7 +78,7 @@ public function requestShouldConnectViaTlsIfUrlUsesHttpsScheme() public function requestShouldEmitErrorIfConnectionFails() { $requestData = new RequestData('GET', 'http://www.example.com/'); - $request = new Request($this->connector, $requestData); + $request = new ClientRequestStream($this->connector, $requestData); $this->connector->expects($this->once())->method('connect')->willReturn(\React\Promise\reject(new \RuntimeException())); @@ -94,7 +94,7 @@ public function requestShouldEmitErrorIfConnectionFails() public function requestShouldEmitErrorIfConnectionClosesBeforeResponseIsParsed() { $requestData = new RequestData('GET', 'http://www.example.com/'); - $request = new Request($this->connector, $requestData); + $request = new ClientRequestStream($this->connector, $requestData); $this->successfulConnectionMock(); @@ -111,7 +111,7 @@ public function requestShouldEmitErrorIfConnectionClosesBeforeResponseIsParsed() public function requestShouldEmitErrorIfConnectionEmitsError() { $requestData = new RequestData('GET', 'http://www.example.com/'); - $request = new Request($this->connector, $requestData); + $request = new ClientRequestStream($this->connector, $requestData); $this->successfulConnectionMock(); @@ -128,7 +128,7 @@ public function requestShouldEmitErrorIfConnectionEmitsError() public function requestShouldEmitErrorIfRequestParserThrowsException() { $requestData = new RequestData('GET', 'http://www.example.com/'); - $request = new Request($this->connector, $requestData); + $request = new ClientRequestStream($this->connector, $requestData); $this->successfulConnectionMock(); @@ -144,7 +144,7 @@ public function requestShouldEmitErrorIfRequestParserThrowsException() public function requestShouldEmitErrorIfUrlIsInvalid() { $requestData = new RequestData('GET', 'ftp://www.example.com'); - $request = new Request($this->connector, $requestData); + $request = new ClientRequestStream($this->connector, $requestData); $request->on('error', $this->expectCallableOnceWith($this->isInstanceOf('InvalidArgumentException'))); @@ -160,7 +160,7 @@ public function requestShouldEmitErrorIfUrlIsInvalid() public function requestShouldEmitErrorIfUrlHasNoScheme() { $requestData = new RequestData('GET', 'www.example.com'); - $request = new Request($this->connector, $requestData); + $request = new ClientRequestStream($this->connector, $requestData); $request->on('error', $this->expectCallableOnceWith($this->isInstanceOf('InvalidArgumentException'))); @@ -174,7 +174,7 @@ public function requestShouldEmitErrorIfUrlHasNoScheme() public function postRequestShouldSendAPostRequest() { $requestData = new RequestData('POST', 'http://www.example.com/'); - $request = new Request($this->connector, $requestData); + $request = new ClientRequestStream($this->connector, $requestData); $this->successfulConnectionMock(); @@ -194,7 +194,7 @@ public function postRequestShouldSendAPostRequest() public function writeWithAPostRequestShouldSendToTheStream() { $requestData = new RequestData('POST', 'http://www.example.com/'); - $request = new Request($this->connector, $requestData); + $request = new ClientRequestStream($this->connector, $requestData); $this->successfulConnectionMock(); @@ -217,7 +217,7 @@ public function writeWithAPostRequestShouldSendToTheStream() public function writeWithAPostRequestShouldSendBodyAfterHeadersAndEmitDrainEvent() { $requestData = new RequestData('POST', 'http://www.example.com/'); - $request = new Request($this->connector, $requestData); + $request = new ClientRequestStream($this->connector, $requestData); $resolveConnection = $this->successfulAsyncConnectionMock(); @@ -248,7 +248,7 @@ public function writeWithAPostRequestShouldSendBodyAfterHeadersAndEmitDrainEvent public function writeWithAPostRequestShouldForwardDrainEventIfFirstChunkExceedsBuffer() { $requestData = new RequestData('POST', 'http://www.example.com/'); - $request = new Request($this->connector, $requestData); + $request = new ClientRequestStream($this->connector, $requestData); $this->stream = $this->getMockBuilder('React\Socket\Connection') ->disableOriginalConstructor() @@ -285,7 +285,7 @@ public function writeWithAPostRequestShouldForwardDrainEventIfFirstChunkExceedsB public function pipeShouldPipeDataIntoTheRequestBody() { $requestData = new RequestData('POST', 'http://www.example.com/'); - $request = new Request($this->connector, $requestData); + $request = new ClientRequestStream($this->connector, $requestData); $this->successfulConnectionMock(); @@ -318,7 +318,7 @@ public function pipeShouldPipeDataIntoTheRequestBody() public function writeShouldStartConnecting() { $requestData = new RequestData('POST', 'http://www.example.com/'); - $request = new Request($this->connector, $requestData); + $request = new ClientRequestStream($this->connector, $requestData); $this->connector->expects($this->once()) ->method('connect') @@ -334,7 +334,7 @@ public function writeShouldStartConnecting() public function endShouldStartConnectingAndChangeStreamIntoNonWritableMode() { $requestData = new RequestData('POST', 'http://www.example.com/'); - $request = new Request($this->connector, $requestData); + $request = new ClientRequestStream($this->connector, $requestData); $this->connector->expects($this->once()) ->method('connect') @@ -352,7 +352,7 @@ public function endShouldStartConnectingAndChangeStreamIntoNonWritableMode() public function closeShouldEmitCloseEvent() { $requestData = new RequestData('POST', 'http://www.example.com/'); - $request = new Request($this->connector, $requestData); + $request = new ClientRequestStream($this->connector, $requestData); $request->on('close', $this->expectCallableOnce()); $request->close(); @@ -364,7 +364,7 @@ public function closeShouldEmitCloseEvent() public function writeAfterCloseReturnsFalse() { $requestData = new RequestData('POST', 'http://www.example.com/'); - $request = new Request($this->connector, $requestData); + $request = new ClientRequestStream($this->connector, $requestData); $request->close(); @@ -378,7 +378,7 @@ public function writeAfterCloseReturnsFalse() public function endAfterCloseIsNoOp() { $requestData = new RequestData('POST', 'http://www.example.com/'); - $request = new Request($this->connector, $requestData); + $request = new ClientRequestStream($this->connector, $requestData); $this->connector->expects($this->never()) ->method('connect'); @@ -393,7 +393,7 @@ public function endAfterCloseIsNoOp() public function closeShouldCancelPendingConnectionAttempt() { $requestData = new RequestData('POST', 'http://www.example.com/'); - $request = new Request($this->connector, $requestData); + $request = new ClientRequestStream($this->connector, $requestData); $promise = new Promise(function () {}, function () { throw new \RuntimeException(); @@ -417,7 +417,7 @@ public function closeShouldCancelPendingConnectionAttempt() public function requestShouldRemoveAllListenerAfterClosed() { $requestData = new RequestData('GET', 'http://www.example.com/'); - $request = new Request($this->connector, $requestData); + $request = new ClientRequestStream($this->connector, $requestData); $request->on('close', function () {}); $this->assertCount(1, $request->listeners('close')); @@ -451,7 +451,7 @@ private function successfulAsyncConnectionMock() public function multivalueHeader() { $requestData = new RequestData('GET', 'http://www.example.com/'); - $request = new Request($this->connector, $requestData); + $request = new ClientRequestStream($this->connector, $requestData); $this->successfulConnectionMock(); diff --git a/tests/Io/SenderTest.php b/tests/Io/SenderTest.php index 91b87b30..6d8c3b5f 100644 --- a/tests/Io/SenderTest.php +++ b/tests/Io/SenderTest.php @@ -76,7 +76,7 @@ public function testSendPostWillAutomaticallySendContentLengthHeader() 'http://www.google.com/', array('Host' => 'www.google.com', 'Content-Length' => '5'), '1.1' - )->willReturn($this->getMockBuilder('React\Http\Client\Request')->disableOriginalConstructor()->getMock()); + )->willReturn($this->getMockBuilder('React\Http\Io\ClientRequestStream')->disableOriginalConstructor()->getMock()); $sender = new Sender($client); @@ -92,7 +92,7 @@ public function testSendPostWillAutomaticallySendContentLengthZeroHeaderForEmpty 'http://www.google.com/', array('Host' => 'www.google.com', 'Content-Length' => '0'), '1.1' - )->willReturn($this->getMockBuilder('React\Http\Client\Request')->disableOriginalConstructor()->getMock()); + )->willReturn($this->getMockBuilder('React\Http\Io\ClientRequestStream')->disableOriginalConstructor()->getMock()); $sender = new Sender($client); @@ -102,7 +102,7 @@ public function testSendPostWillAutomaticallySendContentLengthZeroHeaderForEmpty public function testSendPostStreamWillAutomaticallySendTransferEncodingChunked() { - $outgoing = $this->getMockBuilder('React\Http\Client\Request')->disableOriginalConstructor()->getMock(); + $outgoing = $this->getMockBuilder('React\Http\Io\ClientRequestStream')->disableOriginalConstructor()->getMock(); $outgoing->expects($this->once())->method('write')->with(""); $client = $this->getMockBuilder('React\Http\Client\Client')->disableOriginalConstructor()->getMock(); @@ -122,7 +122,7 @@ public function testSendPostStreamWillAutomaticallySendTransferEncodingChunked() public function testSendPostStreamWillAutomaticallyPipeChunkEncodeBodyForWriteAndRespectRequestThrottling() { - $outgoing = $this->getMockBuilder('React\Http\Client\Request')->disableOriginalConstructor()->getMock(); + $outgoing = $this->getMockBuilder('React\Http\Io\ClientRequestStream')->disableOriginalConstructor()->getMock(); $outgoing->expects($this->once())->method('isWritable')->willReturn(true); $outgoing->expects($this->exactly(2))->method('write')->withConsecutive(array(""), array("5\r\nhello\r\n"))->willReturn(false); @@ -141,7 +141,7 @@ public function testSendPostStreamWillAutomaticallyPipeChunkEncodeBodyForWriteAn public function testSendPostStreamWillAutomaticallyPipeChunkEncodeBodyForEnd() { - $outgoing = $this->getMockBuilder('React\Http\Client\Request')->disableOriginalConstructor()->getMock(); + $outgoing = $this->getMockBuilder('React\Http\Io\ClientRequestStream')->disableOriginalConstructor()->getMock(); $outgoing->expects($this->once())->method('isWritable')->willReturn(true); $outgoing->expects($this->exactly(2))->method('write')->withConsecutive(array(""), array("0\r\n\r\n"))->willReturn(false); $outgoing->expects($this->once())->method('end')->with(null); @@ -160,7 +160,7 @@ public function testSendPostStreamWillAutomaticallyPipeChunkEncodeBodyForEnd() public function testSendPostStreamWillRejectWhenRequestBodyEmitsErrorEvent() { - $outgoing = $this->getMockBuilder('React\Http\Client\Request')->disableOriginalConstructor()->getMock(); + $outgoing = $this->getMockBuilder('React\Http\Io\ClientRequestStream')->disableOriginalConstructor()->getMock(); $outgoing->expects($this->once())->method('isWritable')->willReturn(true); $outgoing->expects($this->once())->method('write')->with("")->willReturn(false); $outgoing->expects($this->never())->method('end'); @@ -190,7 +190,7 @@ public function testSendPostStreamWillRejectWhenRequestBodyEmitsErrorEvent() public function testSendPostStreamWillRejectWhenRequestBodyClosesWithoutEnd() { - $outgoing = $this->getMockBuilder('React\Http\Client\Request')->disableOriginalConstructor()->getMock(); + $outgoing = $this->getMockBuilder('React\Http\Io\ClientRequestStream')->disableOriginalConstructor()->getMock(); $outgoing->expects($this->once())->method('isWritable')->willReturn(true); $outgoing->expects($this->once())->method('write')->with("")->willReturn(false); $outgoing->expects($this->never())->method('end'); @@ -218,7 +218,7 @@ public function testSendPostStreamWillRejectWhenRequestBodyClosesWithoutEnd() public function testSendPostStreamWillNotRejectWhenRequestBodyClosesAfterEnd() { - $outgoing = $this->getMockBuilder('React\Http\Client\Request')->disableOriginalConstructor()->getMock(); + $outgoing = $this->getMockBuilder('React\Http\Io\ClientRequestStream')->disableOriginalConstructor()->getMock(); $outgoing->expects($this->once())->method('isWritable')->willReturn(true); $outgoing->expects($this->exactly(2))->method('write')->withConsecutive(array(""), array("0\r\n\r\n"))->willReturn(false); $outgoing->expects($this->once())->method('end'); @@ -252,7 +252,7 @@ public function testSendPostStreamWithExplicitContentLengthWillSendHeaderAsIs() 'http://www.google.com/', array('Host' => 'www.google.com', 'Content-Length' => '100'), '1.1' - )->willReturn($this->getMockBuilder('React\Http\Client\Request')->disableOriginalConstructor()->getMock()); + )->willReturn($this->getMockBuilder('React\Http\Io\ClientRequestStream')->disableOriginalConstructor()->getMock()); $sender = new Sender($client); @@ -269,7 +269,7 @@ public function testSendGetWillNotPassContentLengthHeaderForEmptyRequestBody() 'http://www.google.com/', array('Host' => 'www.google.com'), '1.1' - )->willReturn($this->getMockBuilder('React\Http\Client\Request')->disableOriginalConstructor()->getMock()); + )->willReturn($this->getMockBuilder('React\Http\Io\ClientRequestStream')->disableOriginalConstructor()->getMock()); $sender = new Sender($client); @@ -285,7 +285,7 @@ public function testSendCustomMethodWillNotPassContentLengthHeaderForEmptyReques 'http://www.google.com/', array('Host' => 'www.google.com'), '1.1' - )->willReturn($this->getMockBuilder('React\Http\Client\Request')->disableOriginalConstructor()->getMock()); + )->willReturn($this->getMockBuilder('React\Http\Io\ClientRequestStream')->disableOriginalConstructor()->getMock()); $sender = new Sender($client); @@ -301,7 +301,7 @@ public function testSendCustomMethodWithExplicitContentLengthZeroWillBePassedAsI 'http://www.google.com/', array('Host' => 'www.google.com', 'Content-Length' => '0'), '1.1' - )->willReturn($this->getMockBuilder('React\Http\Client\Request')->disableOriginalConstructor()->getMock()); + )->willReturn($this->getMockBuilder('React\Http\Io\ClientRequestStream')->disableOriginalConstructor()->getMock()); $sender = new Sender($client); @@ -393,7 +393,7 @@ public function testRequestProtocolVersion(Request $Request, $method, $uri, $hea $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(), ))->getMock(); - $request = $this->getMockBuilder('React\Http\Client\Request') + $request = $this->getMockBuilder('React\Http\Io\ClientRequestStream') ->setMethods(array()) ->setConstructorArgs(array( $this->getMockBuilder('React\Socket\ConnectorInterface')->getMock(), From 212a3bba511f307eb4efb311e5afafb68b3ecd05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Thu, 24 Nov 2022 16:45:11 +0100 Subject: [PATCH 24/58] Refactor to remove internal `RequestData` --- src/Client/Client.php | 7 +- src/Client/RequestData.php | 127 -------------- src/Io/ClientRequestStream.php | 39 +++-- src/Io/Sender.php | 16 +- tests/Client/FunctionalIntegrationTest.php | 13 +- tests/Client/RequestDataTest.php | 146 ---------------- tests/Io/ClientRequestStreamTest.php | 87 +++++++--- tests/Io/SenderTest.php | 185 ++++++++++----------- 8 files changed, 205 insertions(+), 415 deletions(-) delete mode 100644 src/Client/RequestData.php delete mode 100644 tests/Client/RequestDataTest.php diff --git a/src/Client/Client.php b/src/Client/Client.php index 62caed5f..c3fd4570 100644 --- a/src/Client/Client.php +++ b/src/Client/Client.php @@ -2,6 +2,7 @@ namespace React\Http\Client; +use Psr\Http\Message\RequestInterface; use React\EventLoop\LoopInterface; use React\Http\Io\ClientRequestStream; use React\Socket\Connector; @@ -24,10 +25,8 @@ public function __construct(LoopInterface $loop, ConnectorInterface $connector = } /** @return ClientRequestStream */ - public function request($method, $url, array $headers = array(), $protocolVersion = '1.0') + public function request(RequestInterface $request) { - $requestData = new RequestData($method, $url, $headers, $protocolVersion); - - return new ClientRequestStream($this->connector, $requestData); + return new ClientRequestStream($this->connector, $request); } } diff --git a/src/Client/RequestData.php b/src/Client/RequestData.php deleted file mode 100644 index 04bb4cad..00000000 --- a/src/Client/RequestData.php +++ /dev/null @@ -1,127 +0,0 @@ -method = $method; - $this->url = $url; - $this->headers = $headers; - $this->protocolVersion = $protocolVersion; - } - - private function mergeDefaultheaders(array $headers) - { - $port = ($this->getDefaultPort() === $this->getPort()) ? '' : ":{$this->getPort()}"; - $connectionHeaders = ('1.1' === $this->protocolVersion) ? array('Connection' => 'close') : array(); - $authHeaders = $this->getAuthHeaders(); - - $defaults = array_merge( - array( - 'Host' => $this->getHost().$port, - ), - $connectionHeaders, - $authHeaders - ); - - // remove all defaults that already exist in $headers - $lower = array_change_key_case($headers, CASE_LOWER); - foreach ($defaults as $key => $_) { - if (isset($lower[strtolower($key)])) { - unset($defaults[$key]); - } - } - - return array_merge($defaults, $headers); - } - - public function getScheme() - { - return parse_url(https://github.com/$this-%3Eurl,%20PHP_URL_SCHEME); - } - - public function getHost() - { - return parse_url(https://github.com/$this-%3Eurl,%20PHP_URL_HOST); - } - - public function getPort() - { - return (int) parse_url(https://github.com/$this-%3Eurl,%20PHP_URL_PORT) ?: $this->getDefaultPort(); - } - - public function getDefaultPort() - { - return ('https' === $this->getScheme()) ? 443 : 80; - } - - public function getPath() - { - $path = parse_url(https://github.com/$this-%3Eurl,%20PHP_URL_PATH); - $queryString = parse_url(https://github.com/$this-%3Eurl,%20PHP_URL_QUERY); - - // assume "/" path by default, but allow "OPTIONS *" - if ($path === null) { - $path = ($this->method === 'OPTIONS' && $queryString === null) ? '*': '/'; - } - if ($queryString !== null) { - $path .= '?' . $queryString; - } - - return $path; - } - - public function setProtocolVersion($version) - { - $this->protocolVersion = $version; - } - - public function __toString() - { - $headers = $this->mergeDefaultheaders($this->headers); - - $data = ''; - $data .= "{$this->method} {$this->getPath()} HTTP/{$this->protocolVersion}\r\n"; - foreach ($headers as $name => $values) { - foreach ((array)$values as $value) { - $data .= "$name: $value\r\n"; - } - } - $data .= "\r\n"; - - return $data; - } - - private function getUrlUserPass() - { - $components = parse_url(https://github.com/$this-%3Eurl); - - if (isset($components['user'])) { - return array( - 'user' => $components['user'], - 'pass' => isset($components['pass']) ? $components['pass'] : null, - ); - } - } - - private function getAuthHeaders() - { - if (null !== $auth = $this->getUrlUserPass()) { - return array( - 'Authorization' => 'Basic ' . base64_encode($auth['user'].':'.$auth['pass']), - ); - } - - return array(); - } -} diff --git a/src/Io/ClientRequestStream.php b/src/Io/ClientRequestStream.php index 2513a89a..29536e88 100644 --- a/src/Io/ClientRequestStream.php +++ b/src/Io/ClientRequestStream.php @@ -3,7 +3,7 @@ namespace React\Http\Io; use Evenement\EventEmitter; -use React\Http\Client\RequestData; +use Psr\Http\Message\RequestInterface; use React\Promise; use React\Socket\ConnectionInterface; use React\Socket\ConnectorInterface; @@ -24,10 +24,15 @@ class ClientRequestStream extends EventEmitter implements WritableStreamInterfac const STATE_HEAD_WRITTEN = 2; const STATE_END = 3; + /** @var ConnectorInterface */ private $connector; - private $requestData; + /** @var RequestInterface */ + private $request; + + /** @var ?ConnectionInterface */ private $stream; + private $buffer; private $responseFactory; private $state = self::STATE_INIT; @@ -35,10 +40,10 @@ class ClientRequestStream extends EventEmitter implements WritableStreamInterfac private $pendingWrites = ''; - public function __construct(ConnectorInterface $connector, RequestData $requestData) + public function __construct(ConnectorInterface $connector, RequestInterface $request) { $this->connector = $connector; - $this->requestData = $requestData; + $this->request = $request; } public function isWritable() @@ -50,7 +55,7 @@ private function writeHead() { $this->state = self::STATE_WRITING_HEAD; - $requestData = $this->requestData; + $request = $this->request; $streamRef = &$this->stream; $stateRef = &$this->state; $pendingWrites = &$this->pendingWrites; @@ -58,8 +63,9 @@ private function writeHead() $promise = $this->connect(); $promise->then( - function (ConnectionInterface $stream) use ($requestData, &$streamRef, &$stateRef, &$pendingWrites, $that) { + function (ConnectionInterface $stream) use ($request, &$streamRef, &$stateRef, &$pendingWrites, $that) { $streamRef = $stream; + assert($streamRef instanceof ConnectionInterface); $stream->on('drain', array($that, 'handleDrain')); $stream->on('data', array($that, 'handleData')); @@ -67,10 +73,17 @@ function (ConnectionInterface $stream) use ($requestData, &$streamRef, &$stateRe $stream->on('error', array($that, 'handleError')); $stream->on('close', array($that, 'handleClose')); - $headers = (string) $requestData; + assert($request instanceof RequestInterface); + $headers = "{$request->getMethod()} {$request->getRequestTarget()} HTTP/{$request->getProtocolVersion()}\r\n"; + foreach ($request->getHeaders() as $name => $values) { + foreach ($values as $value) { + $headers .= "$name: $value\r\n"; + } + } - $more = $stream->write($headers . $pendingWrites); + $more = $stream->write($headers . "\r\n" . $pendingWrites); + assert($stateRef === ClientRequestStream::STATE_WRITING_HEAD); $stateRef = ClientRequestStream::STATE_HEAD_WRITTEN; // clear pending writes if non-empty @@ -218,20 +231,24 @@ public function close() protected function connect() { - $scheme = $this->requestData->getScheme(); + $scheme = $this->request->getUri()->getScheme(); if ($scheme !== 'https' && $scheme !== 'http') { return Promise\reject( new \InvalidArgumentException('Invalid request URL given') ); } - $host = $this->requestData->getHost(); - $port = $this->requestData->getPort(); + $host = $this->request->getUri()->getHost(); + $port = $this->request->getUri()->getPort(); if ($scheme === 'https') { $host = 'tls://' . $host; } + if ($port === null) { + $port = $scheme === 'https' ? 443 : 80; + } + return $this->connector ->connect($host . ':' . $port); } diff --git a/src/Io/Sender.php b/src/Io/Sender.php index 2f04c797..2e821f5a 100644 --- a/src/Io/Sender.php +++ b/src/Io/Sender.php @@ -74,6 +74,9 @@ public function __construct(HttpClient $http) */ public function send(RequestInterface $request) { + // support HTTP/1.1 and HTTP/1.0 only, ensured by `Browser` already + assert(\in_array($request->getProtocolVersion(), array('1.0', '1.1'), true)); + $body = $request->getBody(); $size = $body->getSize(); @@ -91,12 +94,17 @@ public function send(RequestInterface $request) $size = 0; } - $headers = array(); - foreach ($request->getHeaders() as $name => $values) { - $headers[$name] = implode(', ', $values); + // automatically add `Connection: close` request header for HTTP/1.1 requests to avoid connection reuse + if ($request->getProtocolVersion() === '1.1' && !$request->hasHeader('Connection')) { + $request = $request->withHeader('Connection', 'close'); + } + + // automatically add `Authorization: Basic …` request header if URL includes `user:pass@host` + if ($request->getUri()->getUserInfo() !== '' && !$request->hasHeader('Authorization')) { + $request = $request->withHeader('Authorization', 'Basic ' . \base64_encode($request->getUri()->getUserInfo())); } - $requestStream = $this->http->request($request->getMethod(), (string)$request->getUri(), $headers, $request->getProtocolVersion()); + $requestStream = $this->http->request($request); $deferred = new Deferred(function ($_, $reject) use ($requestStream) { // close request stream if request is cancelled diff --git a/tests/Client/FunctionalIntegrationTest.php b/tests/Client/FunctionalIntegrationTest.php index d95bf828..90d8444b 100644 --- a/tests/Client/FunctionalIntegrationTest.php +++ b/tests/Client/FunctionalIntegrationTest.php @@ -5,6 +5,7 @@ use Psr\Http\Message\ResponseInterface; use React\EventLoop\Loop; use React\Http\Client\Client; +use React\Http\Message\Request; use React\Promise\Deferred; use React\Promise\Stream; use React\Socket\ConnectionInterface; @@ -45,7 +46,7 @@ public function testRequestToLocalhostEmitsSingleRemoteConnection() $port = parse_url(https://github.com/$socket-%3EgetAddress(), PHP_URL_PORT); $client = new Client(Loop::get()); - $request = $client->request('GET', 'http://localhost/' . $port); + $request = $client->request(new Request('GET', 'http://localhost/' . $port, array(), '', '1.0')); $promise = Stream\first($request, 'close'); $request->end(); @@ -62,7 +63,7 @@ public function testRequestLegacyHttpServerWithOnlyLineFeedReturnsSuccessfulResp }); $client = new Client(Loop::get()); - $request = $client->request('GET', str_replace('tcp:', 'http:', $socket->getAddress())); + $request = $client->request(new Request('GET', str_replace('tcp:', 'http:', $socket->getAddress()), array(), '', '1.0')); $once = $this->expectCallableOnceWith('body'); $request->on('response', function (ResponseInterface $response, ReadableStreamInterface $body) use ($once) { @@ -83,7 +84,7 @@ public function testSuccessfulResponseEmitsEnd() $client = new Client(Loop::get()); - $request = $client->request('GET', 'http://www.google.com/'); + $request = $client->request(new Request('GET', 'http://www.google.com/', array(), '', '1.0')); $once = $this->expectCallableOnce(); $request->on('response', function (ResponseInterface $response, ReadableStreamInterface $body) use ($once) { @@ -109,7 +110,7 @@ public function testPostDataReturnsData() $client = new Client(Loop::get()); $data = str_repeat('.', 33000); - $request = $client->request('POST', 'https://' . (mt_rand(0, 1) === 0 ? 'eu.' : '') . 'httpbin.org/post', array('Content-Length' => strlen($data))); + $request = $client->request(new Request('POST', 'https://' . (mt_rand(0, 1) === 0 ? 'eu.' : '') . 'httpbin.org/post', array('Content-Length' => strlen($data)), '', '1.0')); $deferred = new Deferred(); $request->on('response', function (ResponseInterface $response, ReadableStreamInterface $body) use ($deferred) { @@ -141,7 +142,7 @@ public function testPostJsonReturnsData() $client = new Client(Loop::get()); $data = json_encode(array('numbers' => range(1, 50))); - $request = $client->request('POST', 'https://httpbin.org/post', array('Content-Length' => strlen($data), 'Content-Type' => 'application/json')); + $request = $client->request(new Request('POST', 'https://httpbin.org/post', array('Content-Length' => strlen($data), 'Content-Type' => 'application/json'), '', '1.0')); $deferred = new Deferred(); $request->on('response', function (ResponseInterface $response, ReadableStreamInterface $body) use ($deferred) { @@ -170,7 +171,7 @@ public function testCancelPendingConnectionEmitsClose() $client = new Client(Loop::get()); - $request = $client->request('GET', 'http://www.google.com/'); + $request = $client->request(new Request('GET', 'http://www.google.com/', array(), '', '1.0')); $request->on('error', $this->expectCallableNever()); $request->on('close', $this->expectCallableOnce()); $request->end(); diff --git a/tests/Client/RequestDataTest.php b/tests/Client/RequestDataTest.php deleted file mode 100644 index f6713e85..00000000 --- a/tests/Client/RequestDataTest.php +++ /dev/null @@ -1,146 +0,0 @@ -assertSame($expected, $requestData->__toString()); - } - - /** @test */ - public function toStringReturnsHTTPRequestMessageWithEmptyQueryString() - { - $requestData = new RequestData('GET', 'http://www.example.com/path?hello=world'); - - $expected = "GET /path?hello=world HTTP/1.0\r\n" . - "Host: www.example.com\r\n" . - "\r\n"; - - $this->assertSame($expected, $requestData->__toString()); - } - - /** @test */ - public function toStringReturnsHTTPRequestMessageWithZeroQueryStringAndRootPath() - { - $requestData = new RequestData('GET', 'http://www.example.com/?0'); - - $expected = "GET /?0 HTTP/1.0\r\n" . - "Host: www.example.com\r\n" . - "\r\n"; - - $this->assertSame($expected, $requestData->__toString()); - } - - /** @test */ - public function toStringReturnsHTTPRequestMessageWithOptionsAbsoluteRequestForm() - { - $requestData = new RequestData('OPTIONS', 'http://www.example.com/'); - - $expected = "OPTIONS / HTTP/1.0\r\n" . - "Host: www.example.com\r\n" . - "\r\n"; - - $this->assertSame($expected, $requestData->__toString()); - } - - /** @test */ - public function toStringReturnsHTTPRequestMessageWithOptionsAsteriskRequestForm() - { - $requestData = new RequestData('OPTIONS', 'http://www.example.com/'); - - $expected = "OPTIONS * HTTP/1.0\r\n" . - "Host: www.example.com\r\n" . - "\r\n"; - - $this->assertSame($expected, $requestData->__toString()); - } - - /** @test */ - public function toStringReturnsHTTPRequestMessageWithProtocolVersion() - { - $requestData = new RequestData('GET', 'http://www.example.com/'); - $requestData->setProtocolVersion('1.1'); - - $expected = "GET / HTTP/1.1\r\n" . - "Host: www.example.com\r\n" . - "Connection: close\r\n" . - "\r\n"; - - $this->assertSame($expected, $requestData->__toString()); - } - - /** @test */ - public function toStringReturnsHTTPRequestMessageWithHeaders() - { - $requestData = new RequestData('GET', 'http://www.example.com/', array( - 'User-Agent' => array(), - 'Via' => array( - 'first', - 'second' - ) - )); - - $expected = "GET / HTTP/1.0\r\n" . - "Host: www.example.com\r\n" . - "Via: first\r\n" . - "Via: second\r\n" . - "\r\n"; - - $this->assertSame($expected, $requestData->__toString()); - } - - /** @test */ - public function toStringReturnsHTTPRequestMessageWithHeadersInCustomCase() - { - $requestData = new RequestData('GET', 'http://www.example.com/', array( - 'user-agent' => 'Hello', - 'LAST' => 'World' - )); - - $expected = "GET / HTTP/1.0\r\n" . - "Host: www.example.com\r\n" . - "user-agent: Hello\r\n" . - "LAST: World\r\n" . - "\r\n"; - - $this->assertSame($expected, $requestData->__toString()); - } - - /** @test */ - public function toStringReturnsHTTPRequestMessageWithProtocolVersionThroughConstructor() - { - $requestData = new RequestData('GET', 'http://www.example.com/', array(), '1.1'); - - $expected = "GET / HTTP/1.1\r\n" . - "Host: www.example.com\r\n" . - "Connection: close\r\n" . - "\r\n"; - - $this->assertSame($expected, $requestData->__toString()); - } - - /** @test */ - public function toStringUsesUserPassFromURL() - { - $requestData = new RequestData('GET', 'http://john:dummy@www.example.com/'); - - $expected = "GET / HTTP/1.0\r\n" . - "Host: www.example.com\r\n" . - "Authorization: Basic am9objpkdW1teQ==\r\n" . - "\r\n"; - - $this->assertSame($expected, $requestData->__toString()); - } -} diff --git a/tests/Io/ClientRequestStreamTest.php b/tests/Io/ClientRequestStreamTest.php index 6e3e16b8..07a4eb73 100644 --- a/tests/Io/ClientRequestStreamTest.php +++ b/tests/Io/ClientRequestStreamTest.php @@ -2,12 +2,11 @@ namespace React\Tests\Http\Io; -use React\Http\Client\RequestData; use React\Http\Io\ClientRequestStream; -use React\Stream\DuplexResourceStream; -use React\Promise\RejectedPromise; +use React\Http\Message\Request; use React\Promise\Deferred; use React\Promise\Promise; +use React\Stream\DuplexResourceStream; use React\Tests\Http\TestCase; class ClientRequestStreamTest extends TestCase @@ -31,7 +30,7 @@ public function setUpStream() /** @test */ public function requestShouldBindToStreamEventsAndUseconnector() { - $requestData = new RequestData('GET', 'http://www.example.com/'); + $requestData = new Request('GET', 'http://www.example.com/'); $request = new ClientRequestStream($this->connector, $requestData); $this->successfulConnectionMock(); @@ -66,7 +65,7 @@ public function requestShouldBindToStreamEventsAndUseconnector() */ public function requestShouldConnectViaTlsIfUrlUsesHttpsScheme() { - $requestData = new RequestData('GET', 'https://www.example.com/'); + $requestData = new Request('GET', 'https://www.example.com/'); $request = new ClientRequestStream($this->connector, $requestData); $this->connector->expects($this->once())->method('connect')->with('tls://www.example.com:443')->willReturn(new Promise(function () { })); @@ -77,7 +76,7 @@ public function requestShouldConnectViaTlsIfUrlUsesHttpsScheme() /** @test */ public function requestShouldEmitErrorIfConnectionFails() { - $requestData = new RequestData('GET', 'http://www.example.com/'); + $requestData = new Request('GET', 'http://www.example.com/'); $request = new ClientRequestStream($this->connector, $requestData); $this->connector->expects($this->once())->method('connect')->willReturn(\React\Promise\reject(new \RuntimeException())); @@ -93,7 +92,7 @@ public function requestShouldEmitErrorIfConnectionFails() /** @test */ public function requestShouldEmitErrorIfConnectionClosesBeforeResponseIsParsed() { - $requestData = new RequestData('GET', 'http://www.example.com/'); + $requestData = new Request('GET', 'http://www.example.com/'); $request = new ClientRequestStream($this->connector, $requestData); $this->successfulConnectionMock(); @@ -110,7 +109,7 @@ public function requestShouldEmitErrorIfConnectionClosesBeforeResponseIsParsed() /** @test */ public function requestShouldEmitErrorIfConnectionEmitsError() { - $requestData = new RequestData('GET', 'http://www.example.com/'); + $requestData = new Request('GET', 'http://www.example.com/'); $request = new ClientRequestStream($this->connector, $requestData); $this->successfulConnectionMock(); @@ -127,7 +126,7 @@ public function requestShouldEmitErrorIfConnectionEmitsError() /** @test */ public function requestShouldEmitErrorIfRequestParserThrowsException() { - $requestData = new RequestData('GET', 'http://www.example.com/'); + $requestData = new Request('GET', 'http://www.example.com/'); $request = new ClientRequestStream($this->connector, $requestData); $this->successfulConnectionMock(); @@ -143,7 +142,7 @@ public function requestShouldEmitErrorIfRequestParserThrowsException() */ public function requestShouldEmitErrorIfUrlIsInvalid() { - $requestData = new RequestData('GET', 'ftp://www.example.com'); + $requestData = new Request('GET', 'ftp://www.example.com'); $request = new ClientRequestStream($this->connector, $requestData); $request->on('error', $this->expectCallableOnceWith($this->isInstanceOf('InvalidArgumentException'))); @@ -159,7 +158,7 @@ public function requestShouldEmitErrorIfUrlIsInvalid() */ public function requestShouldEmitErrorIfUrlHasNoScheme() { - $requestData = new RequestData('GET', 'www.example.com'); + $requestData = new Request('GET', 'www.example.com'); $request = new ClientRequestStream($this->connector, $requestData); $request->on('error', $this->expectCallableOnceWith($this->isInstanceOf('InvalidArgumentException'))); @@ -170,10 +169,50 @@ public function requestShouldEmitErrorIfUrlHasNoScheme() $request->end(); } + /** @test */ + public function getRequestShouldSendAGetRequest() + { + $requestData = new Request('GET', 'http://www.example.com/', array(), '', '1.0'); + $request = new ClientRequestStream($this->connector, $requestData); + + $this->successfulConnectionMock(); + + $this->stream->expects($this->once())->method('write')->with("GET / HTTP/1.0\r\nHost: www.example.com\r\n\r\n"); + + $request->end(); + } + + /** @test */ + public function getHttp11RequestShouldSendAGetRequestWithGivenConnectionCloseHeader() + { + $requestData = new Request('GET', 'http://www.example.com/', array('Connection' => 'close'), '', '1.1'); + $request = new ClientRequestStream($this->connector, $requestData); + + $this->successfulConnectionMock(); + + $this->stream->expects($this->once())->method('write')->with("GET / HTTP/1.1\r\nHost: www.example.com\r\nConnection: close\r\n\r\n"); + + $request->end(); + } + + /** @test */ + public function getOptionsAsteriskShouldSendAOptionsRequestAsteriskRequestTarget() + { + $requestData = new Request('OPTIONS', 'http://www.example.com/', array('Connection' => 'close'), '', '1.1'); + $requestData = $requestData->withRequestTarget('*'); + $request = new ClientRequestStream($this->connector, $requestData); + + $this->successfulConnectionMock(); + + $this->stream->expects($this->once())->method('write')->with("OPTIONS * HTTP/1.1\r\nHost: www.example.com\r\nConnection: close\r\n\r\n"); + + $request->end(); + } + /** @test */ public function postRequestShouldSendAPostRequest() { - $requestData = new RequestData('POST', 'http://www.example.com/'); + $requestData = new Request('POST', 'http://www.example.com/', array(), '', '1.0'); $request = new ClientRequestStream($this->connector, $requestData); $this->successfulConnectionMock(); @@ -193,7 +232,7 @@ public function postRequestShouldSendAPostRequest() /** @test */ public function writeWithAPostRequestShouldSendToTheStream() { - $requestData = new RequestData('POST', 'http://www.example.com/'); + $requestData = new Request('POST', 'http://www.example.com/', array(), '', '1.0'); $request = new ClientRequestStream($this->connector, $requestData); $this->successfulConnectionMock(); @@ -216,7 +255,7 @@ public function writeWithAPostRequestShouldSendToTheStream() /** @test */ public function writeWithAPostRequestShouldSendBodyAfterHeadersAndEmitDrainEvent() { - $requestData = new RequestData('POST', 'http://www.example.com/'); + $requestData = new Request('POST', 'http://www.example.com/', array(), '', '1.0'); $request = new ClientRequestStream($this->connector, $requestData); $resolveConnection = $this->successfulAsyncConnectionMock(); @@ -247,7 +286,7 @@ public function writeWithAPostRequestShouldSendBodyAfterHeadersAndEmitDrainEvent /** @test */ public function writeWithAPostRequestShouldForwardDrainEventIfFirstChunkExceedsBuffer() { - $requestData = new RequestData('POST', 'http://www.example.com/'); + $requestData = new Request('POST', 'http://www.example.com/', array(), '', '1.0'); $request = new ClientRequestStream($this->connector, $requestData); $this->stream = $this->getMockBuilder('React\Socket\Connection') @@ -284,7 +323,7 @@ public function writeWithAPostRequestShouldForwardDrainEventIfFirstChunkExceedsB /** @test */ public function pipeShouldPipeDataIntoTheRequestBody() { - $requestData = new RequestData('POST', 'http://www.example.com/'); + $requestData = new Request('POST', 'http://www.example.com/', array(), '', '1.0'); $request = new ClientRequestStream($this->connector, $requestData); $this->successfulConnectionMock(); @@ -317,7 +356,7 @@ public function pipeShouldPipeDataIntoTheRequestBody() */ public function writeShouldStartConnecting() { - $requestData = new RequestData('POST', 'http://www.example.com/'); + $requestData = new Request('POST', 'http://www.example.com/'); $request = new ClientRequestStream($this->connector, $requestData); $this->connector->expects($this->once()) @@ -333,7 +372,7 @@ public function writeShouldStartConnecting() */ public function endShouldStartConnectingAndChangeStreamIntoNonWritableMode() { - $requestData = new RequestData('POST', 'http://www.example.com/'); + $requestData = new Request('POST', 'http://www.example.com/'); $request = new ClientRequestStream($this->connector, $requestData); $this->connector->expects($this->once()) @@ -351,7 +390,7 @@ public function endShouldStartConnectingAndChangeStreamIntoNonWritableMode() */ public function closeShouldEmitCloseEvent() { - $requestData = new RequestData('POST', 'http://www.example.com/'); + $requestData = new Request('POST', 'http://www.example.com/'); $request = new ClientRequestStream($this->connector, $requestData); $request->on('close', $this->expectCallableOnce()); @@ -363,7 +402,7 @@ public function closeShouldEmitCloseEvent() */ public function writeAfterCloseReturnsFalse() { - $requestData = new RequestData('POST', 'http://www.example.com/'); + $requestData = new Request('POST', 'http://www.example.com/'); $request = new ClientRequestStream($this->connector, $requestData); $request->close(); @@ -377,7 +416,7 @@ public function writeAfterCloseReturnsFalse() */ public function endAfterCloseIsNoOp() { - $requestData = new RequestData('POST', 'http://www.example.com/'); + $requestData = new Request('POST', 'http://www.example.com/'); $request = new ClientRequestStream($this->connector, $requestData); $this->connector->expects($this->never()) @@ -392,7 +431,7 @@ public function endAfterCloseIsNoOp() */ public function closeShouldCancelPendingConnectionAttempt() { - $requestData = new RequestData('POST', 'http://www.example.com/'); + $requestData = new Request('POST', 'http://www.example.com/'); $request = new ClientRequestStream($this->connector, $requestData); $promise = new Promise(function () {}, function () { @@ -416,7 +455,7 @@ public function closeShouldCancelPendingConnectionAttempt() /** @test */ public function requestShouldRemoveAllListenerAfterClosed() { - $requestData = new RequestData('GET', 'http://www.example.com/'); + $requestData = new Request('GET', 'http://www.example.com/'); $request = new ClientRequestStream($this->connector, $requestData); $request->on('close', function () {}); @@ -450,7 +489,7 @@ private function successfulAsyncConnectionMock() /** @test */ public function multivalueHeader() { - $requestData = new RequestData('GET', 'http://www.example.com/'); + $requestData = new Request('GET', 'http://www.example.com/'); $request = new ClientRequestStream($this->connector, $requestData); $this->successfulConnectionMock(); diff --git a/tests/Io/SenderTest.php b/tests/Io/SenderTest.php index 6d8c3b5f..c2357a1a 100644 --- a/tests/Io/SenderTest.php +++ b/tests/Io/SenderTest.php @@ -2,8 +2,8 @@ namespace React\Tests\Http\Io; +use Psr\Http\Message\RequestInterface; use React\Http\Client\Client as HttpClient; -use React\Http\Client\RequestData; use React\Http\Io\ReadableBodyStream; use React\Http\Io\Sender; use React\Http\Message\Request; @@ -71,12 +71,9 @@ public function testSenderConnectorRejection() public function testSendPostWillAutomaticallySendContentLengthHeader() { $client = $this->getMockBuilder('React\Http\Client\Client')->disableOriginalConstructor()->getMock(); - $client->expects($this->once())->method('request')->with( - 'POST', - 'http://www.google.com/', - array('Host' => 'www.google.com', 'Content-Length' => '5'), - '1.1' - )->willReturn($this->getMockBuilder('React\Http\Io\ClientRequestStream')->disableOriginalConstructor()->getMock()); + $client->expects($this->once())->method('request')->with($this->callback(function (RequestInterface $request) { + return $request->getHeaderLine('Content-Length') === '5'; + }))->willReturn($this->getMockBuilder('React\Http\Io\ClientRequestStream')->disableOriginalConstructor()->getMock()); $sender = new Sender($client); @@ -87,12 +84,9 @@ public function testSendPostWillAutomaticallySendContentLengthHeader() public function testSendPostWillAutomaticallySendContentLengthZeroHeaderForEmptyRequestBody() { $client = $this->getMockBuilder('React\Http\Client\Client')->disableOriginalConstructor()->getMock(); - $client->expects($this->once())->method('request')->with( - 'POST', - 'http://www.google.com/', - array('Host' => 'www.google.com', 'Content-Length' => '0'), - '1.1' - )->willReturn($this->getMockBuilder('React\Http\Io\ClientRequestStream')->disableOriginalConstructor()->getMock()); + $client->expects($this->once())->method('request')->with($this->callback(function (RequestInterface $request) { + return $request->getHeaderLine('Content-Length') === '0'; + }))->willReturn($this->getMockBuilder('React\Http\Io\ClientRequestStream')->disableOriginalConstructor()->getMock()); $sender = new Sender($client); @@ -106,12 +100,9 @@ public function testSendPostStreamWillAutomaticallySendTransferEncodingChunked() $outgoing->expects($this->once())->method('write')->with(""); $client = $this->getMockBuilder('React\Http\Client\Client')->disableOriginalConstructor()->getMock(); - $client->expects($this->once())->method('request')->with( - 'POST', - 'http://www.google.com/', - array('Host' => 'www.google.com', 'Transfer-Encoding' => 'chunked'), - '1.1' - )->willReturn($outgoing); + $client->expects($this->once())->method('request')->with($this->callback(function (RequestInterface $request) { + return $request->getHeaderLine('Transfer-Encoding') === 'chunked'; + }))->willReturn($outgoing); $sender = new Sender($client); @@ -247,12 +238,9 @@ public function testSendPostStreamWillNotRejectWhenRequestBodyClosesAfterEnd() public function testSendPostStreamWithExplicitContentLengthWillSendHeaderAsIs() { $client = $this->getMockBuilder('React\Http\Client\Client')->disableOriginalConstructor()->getMock(); - $client->expects($this->once())->method('request')->with( - 'POST', - 'http://www.google.com/', - array('Host' => 'www.google.com', 'Content-Length' => '100'), - '1.1' - )->willReturn($this->getMockBuilder('React\Http\Io\ClientRequestStream')->disableOriginalConstructor()->getMock()); + $client->expects($this->once())->method('request')->with($this->callback(function (RequestInterface $request) { + return $request->getHeaderLine('Content-Length') === '100'; + }))->willReturn($this->getMockBuilder('React\Http\Io\ClientRequestStream')->disableOriginalConstructor()->getMock()); $sender = new Sender($client); @@ -264,12 +252,9 @@ public function testSendPostStreamWithExplicitContentLengthWillSendHeaderAsIs() public function testSendGetWillNotPassContentLengthHeaderForEmptyRequestBody() { $client = $this->getMockBuilder('React\Http\Client\Client')->disableOriginalConstructor()->getMock(); - $client->expects($this->once())->method('request')->with( - 'GET', - 'http://www.google.com/', - array('Host' => 'www.google.com'), - '1.1' - )->willReturn($this->getMockBuilder('React\Http\Io\ClientRequestStream')->disableOriginalConstructor()->getMock()); + $client->expects($this->once())->method('request')->with($this->callback(function (RequestInterface $request) { + return !$request->hasHeader('Content-Length'); + }))->willReturn($this->getMockBuilder('React\Http\Io\ClientRequestStream')->disableOriginalConstructor()->getMock()); $sender = new Sender($client); @@ -280,12 +265,9 @@ public function testSendGetWillNotPassContentLengthHeaderForEmptyRequestBody() public function testSendCustomMethodWillNotPassContentLengthHeaderForEmptyRequestBody() { $client = $this->getMockBuilder('React\Http\Client\Client')->disableOriginalConstructor()->getMock(); - $client->expects($this->once())->method('request')->with( - 'CUSTOM', - 'http://www.google.com/', - array('Host' => 'www.google.com'), - '1.1' - )->willReturn($this->getMockBuilder('React\Http\Io\ClientRequestStream')->disableOriginalConstructor()->getMock()); + $client->expects($this->once())->method('request')->with($this->callback(function (RequestInterface $request) { + return !$request->hasHeader('Content-Length'); + }))->willReturn($this->getMockBuilder('React\Http\Io\ClientRequestStream')->disableOriginalConstructor()->getMock()); $sender = new Sender($client); @@ -296,12 +278,9 @@ public function testSendCustomMethodWillNotPassContentLengthHeaderForEmptyReques public function testSendCustomMethodWithExplicitContentLengthZeroWillBePassedAsIs() { $client = $this->getMockBuilder('React\Http\Client\Client')->disableOriginalConstructor()->getMock(); - $client->expects($this->once())->method('request')->with( - 'CUSTOM', - 'http://www.google.com/', - array('Host' => 'www.google.com', 'Content-Length' => '0'), - '1.1' - )->willReturn($this->getMockBuilder('React\Http\Io\ClientRequestStream')->disableOriginalConstructor()->getMock()); + $client->expects($this->once())->method('request')->with($this->callback(function (RequestInterface $request) { + return $request->getHeaderLine('Content-Length') === '0'; + }))->willReturn($this->getMockBuilder('React\Http\Io\ClientRequestStream')->disableOriginalConstructor()->getMock()); $sender = new Sender($client); @@ -309,6 +288,76 @@ public function testSendCustomMethodWithExplicitContentLengthZeroWillBePassedAsI $sender->send($request); } + /** @test */ + public function getHttp10RequestShouldSendAGetRequestWithoutConnectionHeaderByDefault() + { + $client = $this->getMockBuilder('React\Http\Client\Client')->disableOriginalConstructor()->getMock(); + $client->expects($this->once())->method('request')->with($this->callback(function (RequestInterface $request) { + return !$request->hasHeader('Connection'); + }))->willReturn($this->getMockBuilder('React\Http\Io\ClientRequestStream')->disableOriginalConstructor()->getMock()); + + $sender = new Sender($client); + + $request = new Request('GET', 'http://www.example.com/', array(), '', '1.0'); + $sender->send($request); + } + + /** @test */ + public function getHttp11RequestShouldSendAGetRequestWithConnectionCloseHeaderByDefault() + { + $client = $this->getMockBuilder('React\Http\Client\Client')->disableOriginalConstructor()->getMock(); + $client->expects($this->once())->method('request')->with($this->callback(function (RequestInterface $request) { + return $request->getHeaderLine('Connection') === 'close'; + }))->willReturn($this->getMockBuilder('React\Http\Io\ClientRequestStream')->disableOriginalConstructor()->getMock()); + + $sender = new Sender($client); + + $request = new Request('GET', 'http://www.example.com/', array(), '', '1.1'); + $sender->send($request); + } + + /** @test */ + public function getHttp11RequestShouldSendAGetRequestWithGivenConnectionUpgradeHeader() + { + $client = $this->getMockBuilder('React\Http\Client\Client')->disableOriginalConstructor()->getMock(); + $client->expects($this->once())->method('request')->with($this->callback(function (RequestInterface $request) { + return $request->getHeaderLine('Connection') === 'upgrade'; + }))->willReturn($this->getMockBuilder('React\Http\Io\ClientRequestStream')->disableOriginalConstructor()->getMock()); + + $sender = new Sender($client); + + $request = new Request('GET', 'http://www.example.com/', array('Connection' => 'upgrade'), '', '1.1'); + $sender->send($request); + } + + /** @test */ + public function getRequestWithUserAndPassShouldSendAGetRequestWithBasicAuthorizationHeader() + { + $client = $this->getMockBuilder('React\Http\Client\Client')->disableOriginalConstructor()->getMock(); + $client->expects($this->once())->method('request')->with($this->callback(function (RequestInterface $request) { + return $request->getHeaderLine('Authorization') === 'Basic am9objpkdW1teQ=='; + }))->willReturn($this->getMockBuilder('React\Http\Io\ClientRequestStream')->disableOriginalConstructor()->getMock()); + + $sender = new Sender($client); + + $request = new Request('GET', 'http://john:dummy@www.example.com/'); + $sender->send($request); + } + + /** @test */ + public function getRequestWithUserAndPassShouldSendAGetRequestWithGivenAuthorizationHeaderBasicAuthorizationHeader() + { + $client = $this->getMockBuilder('React\Http\Client\Client')->disableOriginalConstructor()->getMock(); + $client->expects($this->once())->method('request')->with($this->callback(function (RequestInterface $request) { + return $request->getHeaderLine('Authorization') === 'bearer abc123'; + }))->willReturn($this->getMockBuilder('React\Http\Io\ClientRequestStream')->disableOriginalConstructor()->getMock()); + + $sender = new Sender($client); + + $request = new Request('GET', 'http://john:dummy@www.example.com/', array('Authorization' => 'bearer abc123')); + $sender->send($request); + } + public function testCancelRequestWillCancelConnector() { $promise = new \React\Promise\Promise(function () { }, function () { @@ -355,54 +404,4 @@ public function testCancelRequestWillCloseConnection() $this->assertInstanceOf('RuntimeException', $exception); } - - public function provideRequestProtocolVersion() - { - return array( - array( - new Request('GET', 'http://www.google.com/'), - 'GET', - 'http://www.google.com/', - array( - 'Host' => 'www.google.com', - ), - '1.1', - ), - array( - new Request('GET', 'http://www.google.com/', array(), '', '1.0'), - 'GET', - 'http://www.google.com/', - array( - 'Host' => 'www.google.com', - ), - '1.0', - ), - ); - } - - /** - * @dataProvider provideRequestProtocolVersion - */ - public function testRequestProtocolVersion(Request $Request, $method, $uri, $headers, $protocolVersion) - { - $http = $this->getMockBuilder('React\Http\Client\Client') - ->setMethods(array( - 'request', - )) - ->setConstructorArgs(array( - $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(), - ))->getMock(); - - $request = $this->getMockBuilder('React\Http\Io\ClientRequestStream') - ->setMethods(array()) - ->setConstructorArgs(array( - $this->getMockBuilder('React\Socket\ConnectorInterface')->getMock(), - new RequestData($method, $uri, $headers, $protocolVersion), - ))->getMock(); - - $http->expects($this->once())->method('request')->with($method, $uri, $headers, $protocolVersion)->willReturn($request); - - $sender = new Sender($http); - $sender->send($Request); - } } From bafa2afaacc813ff2bef8a9e5066adf72876d617 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Sat, 3 Sep 2022 11:44:55 +0200 Subject: [PATCH 25/58] Include buffer logic to avoid dependency on reactphp/promise-stream --- composer.json | 2 +- src/Io/Transaction.php | 68 +++++--- .../RequestBodyBufferMiddleware.php | 75 ++++++-- tests/Io/TransactionTest.php | 94 +++++++++- .../RequestBodyBufferMiddlewareTest.php | 164 ++++++++++++++++-- 5 files changed, 346 insertions(+), 57 deletions(-) diff --git a/composer.json b/composer.json index aeee592b..59736ddd 100644 --- a/composer.json +++ b/composer.json @@ -32,7 +32,6 @@ "psr/http-message": "^1.0", "react/event-loop": "^1.2", "react/promise": "^3 || ^2.3 || ^1.2.1", - "react/promise-stream": "^1.4", "react/socket": "^1.12", "react/stream": "^1.2", "ringcentral/psr7": "^1.2" @@ -43,6 +42,7 @@ "clue/socks-react": "^1.4", "phpunit/phpunit": "^9.5 || ^5.7 || ^4.8.35", "react/async": "^4 || ^3 || ^2", + "react/promise-stream": "^1.4", "react/promise-timer": "^1.9" }, "autoload": { diff --git a/src/Io/Transaction.php b/src/Io/Transaction.php index cbf8f3eb..bfa42241 100644 --- a/src/Io/Transaction.php +++ b/src/Io/Transaction.php @@ -9,6 +9,7 @@ use React\Http\Message\Response; use React\Http\Message\ResponseException; use React\Promise\Deferred; +use React\Promise\Promise; use React\Promise\PromiseInterface; use React\Stream\ReadableStreamInterface; use RingCentral\Psr7\Uri; @@ -165,46 +166,67 @@ function (ResponseInterface $response) use ($request, $that, $deferred, $state) */ public function bufferResponse(ResponseInterface $response, Deferred $deferred, ClientRequestState $state) { - $stream = $response->getBody(); + $body = $response->getBody(); + $size = $body->getSize(); - $size = $stream->getSize(); if ($size !== null && $size > $this->maximumSize) { - $stream->close(); + $body->close(); return \React\Promise\reject(new \OverflowException( 'Response body size of ' . $size . ' bytes exceeds maximum of ' . $this->maximumSize . ' bytes', - \defined('SOCKET_EMSGSIZE') ? \SOCKET_EMSGSIZE : 0 + \defined('SOCKET_EMSGSIZE') ? \SOCKET_EMSGSIZE : 90 )); } // body is not streaming => already buffered - if (!$stream instanceof ReadableStreamInterface) { + if (!$body instanceof ReadableStreamInterface) { return \React\Promise\resolve($response); } - // buffer stream and resolve with buffered body + /** @var ?\Closure $closer */ + $closer = null; $maximumSize = $this->maximumSize; - $promise = \React\Promise\Stream\buffer($stream, $maximumSize)->then( - function ($body) use ($response) { - return $response->withBody(new BufferedBody($body)); - }, - function ($e) use ($stream, $maximumSize) { - // try to close stream if buffering fails (or is cancelled) - $stream->close(); - if ($e instanceof \OverflowException) { - $e = new \OverflowException( + return $state->pending = new Promise(function ($resolve, $reject) use ($body, $maximumSize, $response, &$closer) { + // resolve with current buffer when stream closes successfully + $buffer = ''; + $body->on('close', $closer = function () use (&$buffer, $response, $maximumSize, $resolve, $reject) { + $resolve($response->withBody(new BufferedBody($buffer))); + }); + + // buffer response body data in memory + $body->on('data', function ($data) use (&$buffer, $maximumSize, $body, $closer, $reject) { + $buffer .= $data; + + // close stream and reject promise if limit is exceeded + if (isset($buffer[$maximumSize])) { + $buffer = ''; + assert($closer instanceof \Closure); + $body->removeListener('close', $closer); + $body->close(); + + $reject(new \OverflowException( 'Response body size exceeds maximum of ' . $maximumSize . ' bytes', - \defined('SOCKET_EMSGSIZE') ? \SOCKET_EMSGSIZE : 0 - ); + \defined('SOCKET_EMSGSIZE') ? \SOCKET_EMSGSIZE : 90 + )); } + }); - throw $e; - } - ); - - $state->pending = $promise; + // reject buffering if body emits error + $body->on('error', function (\Exception $e) use ($reject) { + $reject(new \RuntimeException( + 'Error while buffering response body: ' . $e->getMessage(), + $e->getCode(), + $e + )); + }); + }, function () use ($body, &$closer) { + // cancelled buffering: remove close handler to avoid resolving, then close and reject + assert($closer instanceof \Closure); + $body->removeListener('close', $closer); + $body->close(); - return $promise; + throw new \RuntimeException('Cancelled buffering response body'); + }); } /** diff --git a/src/Middleware/RequestBodyBufferMiddleware.php b/src/Middleware/RequestBodyBufferMiddleware.php index c13a5dec..ddb39f5e 100644 --- a/src/Middleware/RequestBodyBufferMiddleware.php +++ b/src/Middleware/RequestBodyBufferMiddleware.php @@ -6,7 +6,7 @@ use Psr\Http\Message\ServerRequestInterface; use React\Http\Io\BufferedBody; use React\Http\Io\IniUtil; -use React\Promise\Stream; +use React\Promise\Promise; use React\Stream\ReadableStreamInterface; final class RequestBodyBufferMiddleware @@ -29,19 +29,19 @@ public function __construct($sizeLimit = null) $this->sizeLimit = IniUtil::iniSizeToBytes($sizeLimit); } - public function __invoke(ServerRequestInterface $request, $stack) + public function __invoke(ServerRequestInterface $request, $next) { $body = $request->getBody(); $size = $body->getSize(); // happy path: skip if body is known to be empty (or is already buffered) - if ($size === 0 || !$body instanceof ReadableStreamInterface) { + if ($size === 0 || !$body instanceof ReadableStreamInterface || !$body->isReadable()) { // replace with empty body if body is streaming (or buffered size exceeds limit) if ($body instanceof ReadableStreamInterface || $size > $this->sizeLimit) { $request = $request->withBody(new BufferedBody('')); } - return $stack($request); + return $next($request); } // request body of known size exceeding limit @@ -50,21 +50,60 @@ public function __invoke(ServerRequestInterface $request, $stack) $sizeLimit = 0; } - return Stream\buffer($body, $sizeLimit)->then(function ($buffer) use ($request, $stack) { - $request = $request->withBody(new BufferedBody($buffer)); - - return $stack($request); - }, function ($error) use ($stack, $request, $body) { - // On buffer overflow keep the request body stream in, - // but ignore the contents and wait for the close event - // before passing the request on to the next middleware. - if ($error instanceof OverflowException) { - return Stream\first($body, 'close')->then(function () use ($stack, $request) { - return $stack($request); - }); - } + /** @var ?\Closure $closer */ + $closer = null; + + return new Promise(function ($resolve, $reject) use ($body, &$closer, $sizeLimit, $request, $next) { + // buffer request body data in memory, discard but keep buffering if limit is reached + $buffer = ''; + $bufferer = null; + $body->on('data', $bufferer = function ($data) use (&$buffer, $sizeLimit, $body, &$bufferer) { + $buffer .= $data; + + // On buffer overflow keep the request body stream in, + // but ignore the contents and wait for the close event + // before passing the request on to the next middleware. + if (isset($buffer[$sizeLimit])) { + assert($bufferer instanceof \Closure); + $body->removeListener('data', $bufferer); + $bufferer = null; + $buffer = ''; + } + }); + + // call $next with current buffer and resolve or reject with its results + $body->on('close', $closer = function () use (&$buffer, $request, $resolve, $reject, $next) { + try { + // resolve with result of next handler + $resolve($next($request->withBody(new BufferedBody($buffer)))); + } catch (\Exception $e) { + $reject($e); + } catch (\Throwable $e) { // @codeCoverageIgnoreStart + // reject Errors just like Exceptions (PHP 7+) + $reject($e); // @codeCoverageIgnoreEnd + } + }); + + // reject buffering if body emits error + $body->on('error', function (\Exception $e) use ($reject, $body, $closer) { + // remove close handler to avoid resolving, then close and reject + assert($closer instanceof \Closure); + $body->removeListener('close', $closer); + $body->close(); + + $reject(new \RuntimeException( + 'Error while buffering request body: ' . $e->getMessage(), + $e->getCode(), + $e + )); + }); + }, function () use ($body, &$closer) { + // cancelled buffering: remove close handler to avoid resolving, then close and reject + assert($closer instanceof \Closure); + $body->removeListener('close', $closer); + $body->close(); - throw $error; + throw new \RuntimeException('Cancelled buffering request body'); }); } } diff --git a/tests/Io/TransactionTest.php b/tests/Io/TransactionTest.php index e0d04e39..140c53e0 100644 --- a/tests/Io/TransactionTest.php +++ b/tests/Io/TransactionTest.php @@ -406,7 +406,7 @@ public function testReceivingStreamingBodyWillResolveWithBufferedResponseByDefau $this->assertEquals('hello world', (string)$response->getBody()); } - public function testReceivingStreamingBodyWithSizeExceedingMaximumResponseBufferWillRejectAndCloseResponseStream() + public function testReceivingStreamingBodyWithContentLengthExceedingMaximumResponseBufferWillRejectAndCloseResponseStreamImmediately() { $stream = new ThroughStream(); $stream->on('close', $this->expectCallableOnce()); @@ -419,11 +419,87 @@ public function testReceivingStreamingBodyWithSizeExceedingMaximumResponseBuffer $sender = $this->makeSenderMock(); $sender->expects($this->once())->method('send')->with($this->equalTo($request))->willReturn(Promise\resolve($response)); + $transaction = new Transaction($sender, Loop::get()); + + $promise = $transaction->send($request); + + $exception = null; + $promise->then(null, function ($e) use (&$exception) { + $exception = $e; + }); + + $this->assertFalse($stream->isWritable()); + + assert($exception instanceof \OverflowException); + $this->assertInstanceOf('OverflowException', $exception); + $this->assertEquals('Response body size of 100000000 bytes exceeds maximum of 16777216 bytes', $exception->getMessage()); + $this->assertEquals(defined('SOCKET_EMSGSIZE') ? \SOCKET_EMSGSIZE : 90, $exception->getCode()); + $this->assertNull($exception->getPrevious()); + } + + public function testReceivingStreamingBodyWithContentsExceedingMaximumResponseBufferWillRejectAndCloseResponseStreamWhenBufferExceedsLimit() + { + $stream = new ThroughStream(); + $stream->on('close', $this->expectCallableOnce()); + + $request = $this->getMockBuilder('Psr\Http\Message\RequestInterface')->getMock(); + + $response = new Response(200, array(), new ReadableBodyStream($stream)); + + // mock sender to resolve promise with the given $response in response to the given $request + $sender = $this->makeSenderMock(); + $sender->expects($this->once())->method('send')->with($this->equalTo($request))->willReturn(Promise\resolve($response)); + + $transaction = new Transaction($sender, Loop::get()); + $transaction = $transaction->withOptions(array('maximumSize' => 10)); + $promise = $transaction->send($request); + + $exception = null; + $promise->then(null, function ($e) use (&$exception) { + $exception = $e; + }); + + $this->assertTrue($stream->isWritable()); + $stream->write('hello wörld'); + $this->assertFalse($stream->isWritable()); + + assert($exception instanceof \OverflowException); + $this->assertInstanceOf('OverflowException', $exception); + $this->assertEquals('Response body size exceeds maximum of 10 bytes', $exception->getMessage()); + $this->assertEquals(defined('SOCKET_EMSGSIZE') ? \SOCKET_EMSGSIZE : 90, $exception->getCode()); + $this->assertNull($exception->getPrevious()); + } + + public function testReceivingStreamingBodyWillRejectWhenStreamEmitsError() + { + $stream = new ThroughStream(function ($data) { + throw new \UnexpectedValueException('Unexpected ' . $data, 42); + }); + + $request = $this->getMockBuilder('Psr\Http\Message\RequestInterface')->getMock(); + $response = new Response(200, array(), new ReadableBodyStream($stream)); + + // mock sender to resolve promise with the given $response in response to the given $request + $sender = $this->makeSenderMock(); + $sender->expects($this->once())->method('send')->with($this->equalTo($request))->willReturn(Promise\resolve($response)); + $transaction = new Transaction($sender, Loop::get()); $promise = $transaction->send($request); - $this->setExpectedException('OverflowException'); - \React\Async\await(\React\Promise\Timer\timeout($promise, 0.001)); + $exception = null; + $promise->then(null, function ($e) use (&$exception) { + $exception = $e; + }); + + $this->assertTrue($stream->isWritable()); + $stream->write('Foo'); + $this->assertFalse($stream->isWritable()); + + assert($exception instanceof \RuntimeException); + $this->assertInstanceOf('RuntimeException', $exception); + $this->assertEquals('Error while buffering response body: Unexpected Foo', $exception->getMessage()); + $this->assertEquals(42, $exception->getCode()); + $this->assertInstanceOf('UnexpectedValueException', $exception->getPrevious()); } public function testCancelBufferingResponseWillCloseStreamAndReject() @@ -446,8 +522,16 @@ public function testCancelBufferingResponseWillCloseStreamAndReject() $deferred->resolve($response); $promise->cancel(); - $this->setExpectedException('RuntimeException'); - \React\Async\await(\React\Promise\Timer\timeout($promise, 0.001)); + $exception = null; + $promise->then(null, function ($e) use (&$exception) { + $exception = $e; + }); + + assert($exception instanceof \RuntimeException); + $this->assertInstanceOf('RuntimeException', $exception); + $this->assertEquals('Cancelled buffering response body', $exception->getMessage()); + $this->assertEquals(0, $exception->getCode()); + $this->assertNull($exception->getPrevious()); } public function testReceivingStreamingBodyWillResolveWithStreamingResponseIfStreamingIsEnabled() diff --git a/tests/Middleware/RequestBodyBufferMiddlewareTest.php b/tests/Middleware/RequestBodyBufferMiddlewareTest.php index 0edec7da..fd818a8c 100644 --- a/tests/Middleware/RequestBodyBufferMiddlewareTest.php +++ b/tests/Middleware/RequestBodyBufferMiddlewareTest.php @@ -115,10 +115,11 @@ function (ServerRequestInterface $request) use (&$exposedRequest) { $this->assertSame($body, $exposedRequest->getBody()); } - public function testKnownExcessiveSizedBodyIsDisgardedTheRequestIsPassedDownToTheNextMiddleware() + public function testClosedStreamResolvesImmediatelyWithEmptyBody() { $stream = new ThroughStream(); - $stream->end('aa'); + $stream->close(); + $serverRequest = new ServerRequest( 'GET', 'https://example.com/', @@ -126,13 +127,41 @@ public function testKnownExcessiveSizedBodyIsDisgardedTheRequestIsPassedDownToTh new HttpBodyStream($stream, 2) ); + $exposedRequest = null; $buffer = new RequestBodyBufferMiddleware(1); - $response = \React\Async\await($buffer( + $buffer( + $serverRequest, + function (ServerRequestInterface $request) use (&$exposedRequest) { + $exposedRequest = $request; + } + ); + + $this->assertSame(0, $exposedRequest->getBody()->getSize()); + $this->assertSame('', $exposedRequest->getBody()->getContents()); + } + + public function testKnownExcessiveSizedBodyIsDiscardedAndRequestIsPassedDownToTheNextMiddleware() + { + $stream = new ThroughStream(); + $serverRequest = new ServerRequest( + 'GET', + 'https://example.com/', + array(), + new HttpBodyStream($stream, 2) + ); + + $buffer = new RequestBodyBufferMiddleware(1); + + $promise = $buffer( $serverRequest, function (ServerRequestInterface $request) { return new Response(200, array(), $request->getBody()->getContents()); } - )); + ); + + $stream->end('aa'); + + $response = \React\Async\await($promise); $this->assertSame(200, $response->getStatusCode()); $this->assertSame('', $response->getBody()->getContents()); @@ -214,9 +243,10 @@ function (ServerRequestInterface $request) { $this->assertSame('', $exposedResponse->getBody()->getContents()); } - public function testBufferingErrorThrows() + public function testBufferingRejectsWhenNextHandlerThrowsWhenStreamEnds() { $stream = new ThroughStream(); + $serverRequest = new ServerRequest( 'GET', 'https://example.com/', @@ -224,18 +254,101 @@ public function testBufferingErrorThrows() new HttpBodyStream($stream, null) ); - $buffer = new RequestBodyBufferMiddleware(1); + $buffer = new RequestBodyBufferMiddleware(100); $promise = $buffer( $serverRequest, function (ServerRequestInterface $request) { - return $request; + throw new \RuntimeException('Buffered ' . $request->getBody()->getSize(), 42); } ); - $stream->emit('error', array(new \RuntimeException())); + $exception = null; + $promise->then(null, function ($e) use (&$exception) { + $exception = $e; + }); + + $this->assertTrue($stream->isWritable()); + $stream->end('Foo'); + $this->assertFalse($stream->isWritable()); - $this->setExpectedException('RuntimeException'); - \React\Async\await($promise); + assert($exception instanceof \RuntimeException); + $this->assertInstanceOf('RuntimeException', $exception); + $this->assertEquals('Buffered 3', $exception->getMessage()); + $this->assertEquals(42, $exception->getCode()); + $this->assertNull($exception->getPrevious()); + } + + /** + * @requires PHP 7 + */ + public function testBufferingRejectsWhenNextHandlerThrowsErrorWhenStreamEnds() + { + $stream = new ThroughStream(); + + $serverRequest = new ServerRequest( + 'GET', + 'https://example.com/', + array(), + new HttpBodyStream($stream, null) + ); + + $buffer = new RequestBodyBufferMiddleware(100); + $promise = $buffer( + $serverRequest, + function (ServerRequestInterface $request) { + throw new \Error('Buffered ' . $request->getBody()->getSize(), 42); + } + ); + + $exception = null; + $promise->then(null, function ($e) use (&$exception) { + $exception = $e; + }); + + $this->assertTrue($stream->isWritable()); + $stream->end('Foo'); + $this->assertFalse($stream->isWritable()); + + assert($exception instanceof \Error); + $this->assertInstanceOf('Error', $exception); + $this->assertEquals('Buffered 3', $exception->getMessage()); + $this->assertEquals(42, $exception->getCode()); + $this->assertNull($exception->getPrevious()); + } + + public function testBufferingRejectsWhenStreamEmitsError() + { + $stream = new ThroughStream(function ($data) { + throw new \UnexpectedValueException('Unexpected ' . $data, 42); + }); + + $serverRequest = new ServerRequest( + 'GET', + 'https://example.com/', + array(), + new HttpBodyStream($stream, null) + ); + + $buffer = new RequestBodyBufferMiddleware(1); + $promise = $buffer( + $serverRequest, + $this->expectCallableNever() + ); + + $exception = null; + $promise->then(null, function ($e) use (&$exception) { + $exception = $e; + }); + + $this->assertTrue($stream->isWritable()); + $stream->write('Foo'); + $this->assertFalse($stream->isWritable()); + + assert($exception instanceof \RuntimeException); + $this->assertInstanceOf('RuntimeException', $exception); + $this->assertEquals('Error while buffering request body: Unexpected Foo', $exception->getMessage()); + $this->assertEquals(42, $exception->getCode()); + $this->assertInstanceOf('UnexpectedValueException', $exception->getPrevious()); } public function testFullBodyStreamedBeforeCallingNextMiddleware() @@ -263,4 +376,35 @@ public function testFullBodyStreamedBeforeCallingNextMiddleware() $stream->end('aaa'); $this->assertTrue($promiseResolved); } + + public function testCancelBufferingClosesStreamAndRejectsPromise() + { + $stream = new ThroughStream(); + $stream->on('close', $this->expectCallableOnce()); + + $serverRequest = new ServerRequest( + 'GET', + 'https://example.com/', + array(), + new HttpBodyStream($stream, 2) + ); + + $buffer = new RequestBodyBufferMiddleware(2); + + $promise = $buffer($serverRequest, $this->expectCallableNever()); + $promise->cancel(); + + $this->assertFalse($stream->isReadable()); + + $exception = null; + $promise->then(null, function ($e) use (&$exception) { + $exception = $e; + }); + + assert($exception instanceof \RuntimeException); + $this->assertInstanceOf('RuntimeException', $exception); + $this->assertEquals('Cancelled buffering request body', $exception->getMessage()); + $this->assertEquals(0, $exception->getCode()); + $this->assertNull($exception->getPrevious()); + } } From 1fbe922458ccb9233bb78191a8a160bf510799b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Wed, 11 Jan 2023 15:35:07 +0100 Subject: [PATCH 26/58] Refactor to move response body handling to `ClientRequestStream` --- src/Io/ClientRequestStream.php | 22 +++++++++++++++++++++- src/Io/Sender.php | 15 ++------------- tests/Io/ClientRequestStreamTest.php | 3 ++- 3 files changed, 25 insertions(+), 15 deletions(-) diff --git a/src/Io/ClientRequestStream.php b/src/Io/ClientRequestStream.php index 29536e88..04671354 100644 --- a/src/Io/ClientRequestStream.php +++ b/src/Io/ClientRequestStream.php @@ -4,6 +4,8 @@ use Evenement\EventEmitter; use Psr\Http\Message\RequestInterface; +use Psr\Http\Message\ResponseInterface; +use React\Http\Message\Response; use React\Promise; use React\Socket\ConnectionInterface; use React\Socket\ConnectorInterface; @@ -172,8 +174,26 @@ public function handleData($data) $this->stream->on('close', array($this, 'handleClose')); - $this->emit('response', array($response, $this->stream)); + assert($response instanceof ResponseInterface); + assert($this->stream instanceof ConnectionInterface); + $body = $this->stream; + + // determine length of response body + $length = null; + $code = $response->getStatusCode(); + if ($this->request->getMethod() === 'HEAD' || ($code >= 100 && $code < 200) || $code == Response::STATUS_NO_CONTENT || $code == Response::STATUS_NOT_MODIFIED) { + $length = 0; + } elseif (\strtolower($response->getHeaderLine('Transfer-Encoding')) === 'chunked') { + $body = new ChunkedDecoder($body); + } elseif ($response->hasHeader('Content-Length')) { + $length = (int) $response->getHeaderLine('Content-Length'); + } + $response = $response->withBody($body = new ReadableBodyStream($body, $length)); + + // emit response with streaming response body (see `Sender`) + $this->emit('response', array($response, $body)); + // re-emit HTTP response body to trigger body parsing if parts of it are buffered $this->stream->emit('data', array($bodyChunk)); } } diff --git a/src/Io/Sender.php b/src/Io/Sender.php index 2e821f5a..0894c574 100644 --- a/src/Io/Sender.php +++ b/src/Io/Sender.php @@ -6,7 +6,6 @@ use Psr\Http\Message\ResponseInterface; use React\EventLoop\LoopInterface; use React\Http\Client\Client as HttpClient; -use React\Http\Message\Response; use React\Promise\PromiseInterface; use React\Promise\Deferred; use React\Socket\ConnectorInterface; @@ -116,18 +115,8 @@ public function send(RequestInterface $request) $deferred->reject($error); }); - $requestStream->on('response', function (ResponseInterface $response, ReadableStreamInterface $body) use ($deferred, $request) { - $length = null; - $code = $response->getStatusCode(); - if ($request->getMethod() === 'HEAD' || ($code >= 100 && $code < 200) || $code == Response::STATUS_NO_CONTENT || $code == Response::STATUS_NOT_MODIFIED) { - $length = 0; - } elseif (\strtolower($response->getHeaderLine('Transfer-Encoding')) === 'chunked') { - $body = new ChunkedDecoder($body); - } elseif ($response->hasHeader('Content-Length')) { - $length = (int) $response->getHeaderLine('Content-Length'); - } - - $deferred->resolve($response->withBody(new ReadableBodyStream($body, $length))); + $requestStream->on('response', function (ResponseInterface $response) use ($deferred, $request) { + $deferred->resolve($response); }); if ($body instanceof ReadableStreamInterface) { diff --git a/tests/Io/ClientRequestStreamTest.php b/tests/Io/ClientRequestStreamTest.php index 07a4eb73..06c650ef 100644 --- a/tests/Io/ClientRequestStreamTest.php +++ b/tests/Io/ClientRequestStreamTest.php @@ -35,11 +35,12 @@ public function requestShouldBindToStreamEventsAndUseconnector() $this->successfulConnectionMock(); - $this->stream->expects($this->exactly(6))->method('on')->withConsecutive( + $this->stream->expects($this->atLeast(6))->method('on')->withConsecutive( array('drain', $this->identicalTo(array($request, 'handleDrain'))), array('data', $this->identicalTo(array($request, 'handleData'))), array('end', $this->identicalTo(array($request, 'handleEnd'))), array('error', $this->identicalTo(array($request, 'handleError'))), + array('close', $this->identicalTo(array($request, 'handleClose'))), array('close', $this->identicalTo(array($request, 'handleClose'))) ); From 165e5b5e2c1cf9704dd2b89defdb58e49db5beaf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Mon, 16 Jan 2023 14:20:11 +0100 Subject: [PATCH 27/58] Consistently close underlying connection when response stream closes --- src/Io/ClientRequestStream.php | 84 +++-- tests/Io/ClientRequestStreamTest.php | 523 ++++++++++++++++++++------- tests/TestCase.php | 6 +- 3 files changed, 448 insertions(+), 165 deletions(-) diff --git a/src/Io/ClientRequestStream.php b/src/Io/ClientRequestStream.php index 04671354..bdaa54f1 100644 --- a/src/Io/ClientRequestStream.php +++ b/src/Io/ClientRequestStream.php @@ -16,7 +16,7 @@ * @event response * @event drain * @event error - * @event end + * @event close * @internal */ class ClientRequestStream extends EventEmitter implements WritableStreamInterface @@ -33,9 +33,11 @@ class ClientRequestStream extends EventEmitter implements WritableStreamInterfac private $request; /** @var ?ConnectionInterface */ - private $stream; + private $connection; + + /** @var string */ + private $buffer = ''; - private $buffer; private $responseFactory; private $state = self::STATE_INIT; private $ended = false; @@ -58,22 +60,22 @@ private function writeHead() $this->state = self::STATE_WRITING_HEAD; $request = $this->request; - $streamRef = &$this->stream; + $connectionRef = &$this->connection; $stateRef = &$this->state; $pendingWrites = &$this->pendingWrites; $that = $this; $promise = $this->connect(); $promise->then( - function (ConnectionInterface $stream) use ($request, &$streamRef, &$stateRef, &$pendingWrites, $that) { - $streamRef = $stream; - assert($streamRef instanceof ConnectionInterface); + function (ConnectionInterface $connection) use ($request, &$connectionRef, &$stateRef, &$pendingWrites, $that) { + $connectionRef = $connection; + assert($connectionRef instanceof ConnectionInterface); - $stream->on('drain', array($that, 'handleDrain')); - $stream->on('data', array($that, 'handleData')); - $stream->on('end', array($that, 'handleEnd')); - $stream->on('error', array($that, 'handleError')); - $stream->on('close', array($that, 'handleClose')); + $connection->on('drain', array($that, 'handleDrain')); + $connection->on('data', array($that, 'handleData')); + $connection->on('end', array($that, 'handleEnd')); + $connection->on('error', array($that, 'handleError')); + $connection->on('close', array($that, 'close')); assert($request instanceof RequestInterface); $headers = "{$request->getMethod()} {$request->getRequestTarget()} HTTP/{$request->getProtocolVersion()}\r\n"; @@ -83,7 +85,7 @@ function (ConnectionInterface $stream) use ($request, &$streamRef, &$stateRef, & } } - $more = $stream->write($headers . "\r\n" . $pendingWrites); + $more = $connection->write($headers . "\r\n" . $pendingWrites); assert($stateRef === ClientRequestStream::STATE_WRITING_HEAD); $stateRef = ClientRequestStream::STATE_HEAD_WRITTEN; @@ -113,7 +115,7 @@ public function write($data) // write directly to connection stream if already available if (self::STATE_HEAD_WRITTEN <= $this->state) { - return $this->stream->write($data); + return $this->connection->write($data); } // otherwise buffer and try to establish connection @@ -157,26 +159,28 @@ public function handleData($data) $response = gPsr\parse_response($this->buffer); $bodyChunk = (string) $response->getBody(); } catch (\InvalidArgumentException $exception) { - $this->emit('error', array($exception)); - } - - $this->buffer = null; - - $this->stream->removeListener('drain', array($this, 'handleDrain')); - $this->stream->removeListener('data', array($this, 'handleData')); - $this->stream->removeListener('end', array($this, 'handleEnd')); - $this->stream->removeListener('error', array($this, 'handleError')); - $this->stream->removeListener('close', array($this, 'handleClose')); - - if (!isset($response)) { + $this->closeError($exception); return; } - $this->stream->on('close', array($this, 'handleClose')); - - assert($response instanceof ResponseInterface); - assert($this->stream instanceof ConnectionInterface); - $body = $this->stream; + // response headers successfully received => remove listeners for connection events + $connection = $this->connection; + assert($connection instanceof ConnectionInterface); + $connection->removeListener('drain', array($this, 'handleDrain')); + $connection->removeListener('data', array($this, 'handleData')); + $connection->removeListener('end', array($this, 'handleEnd')); + $connection->removeListener('error', array($this, 'handleError')); + $connection->removeListener('close', array($this, 'close')); + $this->connection = null; + $this->buffer = ''; + + // take control over connection handling and close connection once response body closes + $that = $this; + $input = $body = new CloseProtectionStream($connection); + $input->on('close', function () use ($connection, $that) { + $connection->close(); + $that->close(); + }); // determine length of response body $length = null; @@ -194,7 +198,11 @@ public function handleData($data) $this->emit('response', array($response, $body)); // re-emit HTTP response body to trigger body parsing if parts of it are buffered - $this->stream->emit('data', array($bodyChunk)); + if ($bodyChunk !== '') { + $input->handleData($bodyChunk); + } elseif ($length === 0) { + $input->handleEnd(); + } } } @@ -216,12 +224,6 @@ public function handleError(\Exception $error) )); } - /** @internal */ - public function handleClose() - { - $this->close(); - } - /** @internal */ public function closeError(\Exception $error) { @@ -240,9 +242,11 @@ public function close() $this->state = self::STATE_END; $this->pendingWrites = ''; + $this->buffer = ''; - if ($this->stream) { - $this->stream->close(); + if ($this->connection instanceof ConnectionInterface) { + $this->connection->close(); + $this->connection = null; } $this->emit('close'); diff --git a/tests/Io/ClientRequestStreamTest.php b/tests/Io/ClientRequestStreamTest.php index 06c650ef..93220d10 100644 --- a/tests/Io/ClientRequestStreamTest.php +++ b/tests/Io/ClientRequestStreamTest.php @@ -2,27 +2,24 @@ namespace React\Tests\Http\Io; +use Psr\Http\Message\ResponseInterface; use React\Http\Io\ClientRequestStream; use React\Http\Message\Request; use React\Promise\Deferred; use React\Promise\Promise; use React\Stream\DuplexResourceStream; +use React\Stream\ReadableStreamInterface; use React\Tests\Http\TestCase; class ClientRequestStreamTest extends TestCase { private $connector; - private $stream; /** * @before */ public function setUpStream() { - $this->stream = $this->getMockBuilder('React\Socket\ConnectionInterface') - ->disableOriginalConstructor() - ->getMock(); - $this->connector = $this->getMockBuilder('React\Socket\ConnectorInterface') ->getMock(); } @@ -30,30 +27,29 @@ public function setUpStream() /** @test */ public function requestShouldBindToStreamEventsAndUseconnector() { + $connection = $this->getMockBuilder('React\Socket\ConnectionInterface')->getMock(); + + $this->connector->expects($this->once())->method('connect')->with('www.example.com:80')->willReturn(\React\Promise\resolve($connection)); + $requestData = new Request('GET', 'http://www.example.com/'); $request = new ClientRequestStream($this->connector, $requestData); - $this->successfulConnectionMock(); - - $this->stream->expects($this->atLeast(6))->method('on')->withConsecutive( + $connection->expects($this->atLeast(5))->method('on')->withConsecutive( array('drain', $this->identicalTo(array($request, 'handleDrain'))), array('data', $this->identicalTo(array($request, 'handleData'))), array('end', $this->identicalTo(array($request, 'handleEnd'))), array('error', $this->identicalTo(array($request, 'handleError'))), - array('close', $this->identicalTo(array($request, 'handleClose'))), - array('close', $this->identicalTo(array($request, 'handleClose'))) + array('close', $this->identicalTo(array($request, 'close'))) ); - $this->stream->expects($this->exactly(5))->method('removeListener')->withConsecutive( + $connection->expects($this->exactly(5))->method('removeListener')->withConsecutive( array('drain', $this->identicalTo(array($request, 'handleDrain'))), array('data', $this->identicalTo(array($request, 'handleData'))), array('end', $this->identicalTo(array($request, 'handleEnd'))), array('error', $this->identicalTo(array($request, 'handleError'))), - array('close', $this->identicalTo(array($request, 'handleClose'))) + array('close', $this->identicalTo(array($request, 'close'))) ); - $request->on('end', $this->expectCallableNever()); - $request->end(); $request->handleData("HTTP/1.0 200 OK\r\n"); @@ -66,26 +62,24 @@ public function requestShouldBindToStreamEventsAndUseconnector() */ public function requestShouldConnectViaTlsIfUrlUsesHttpsScheme() { + $this->connector->expects($this->once())->method('connect')->with('tls://www.example.com:443')->willReturn(new Promise(function () { })); + $requestData = new Request('GET', 'https://www.example.com/'); $request = new ClientRequestStream($this->connector, $requestData); - $this->connector->expects($this->once())->method('connect')->with('tls://www.example.com:443')->willReturn(new Promise(function () { })); - $request->end(); } /** @test */ public function requestShouldEmitErrorIfConnectionFails() { + $this->connector->expects($this->once())->method('connect')->willReturn(\React\Promise\reject(new \RuntimeException())); + $requestData = new Request('GET', 'http://www.example.com/'); $request = new ClientRequestStream($this->connector, $requestData); - $this->connector->expects($this->once())->method('connect')->willReturn(\React\Promise\reject(new \RuntimeException())); - $request->on('error', $this->expectCallableOnceWith($this->isInstanceOf('RuntimeException'))); - $request->on('close', $this->expectCallableOnce()); - $request->on('end', $this->expectCallableNever()); $request->end(); } @@ -93,15 +87,15 @@ public function requestShouldEmitErrorIfConnectionFails() /** @test */ public function requestShouldEmitErrorIfConnectionClosesBeforeResponseIsParsed() { + $connection = $this->getMockBuilder('React\Socket\ConnectionInterface')->getMock(); + + $this->connector->expects($this->once())->method('connect')->with('www.example.com:80')->willReturn(\React\Promise\resolve($connection)); + $requestData = new Request('GET', 'http://www.example.com/'); $request = new ClientRequestStream($this->connector, $requestData); - $this->successfulConnectionMock(); - $request->on('error', $this->expectCallableOnceWith($this->isInstanceOf('RuntimeException'))); - $request->on('close', $this->expectCallableOnce()); - $request->on('end', $this->expectCallableNever()); $request->end(); $request->handleEnd(); @@ -110,15 +104,15 @@ public function requestShouldEmitErrorIfConnectionClosesBeforeResponseIsParsed() /** @test */ public function requestShouldEmitErrorIfConnectionEmitsError() { + $connection = $this->getMockBuilder('React\Socket\ConnectionInterface')->getMock(); + + $this->connector->expects($this->once())->method('connect')->with('www.example.com:80')->willReturn(\React\Promise\resolve($connection)); + $requestData = new Request('GET', 'http://www.example.com/'); $request = new ClientRequestStream($this->connector, $requestData); - $this->successfulConnectionMock(); - $request->on('error', $this->expectCallableOnceWith($this->isInstanceOf('Exception'))); - $request->on('close', $this->expectCallableOnce()); - $request->on('end', $this->expectCallableNever()); $request->end(); $request->handleError(new \Exception('test')); @@ -127,12 +121,15 @@ public function requestShouldEmitErrorIfConnectionEmitsError() /** @test */ public function requestShouldEmitErrorIfRequestParserThrowsException() { + $connection = $this->getMockBuilder('React\Socket\ConnectionInterface')->getMock(); + + $this->connector->expects($this->once())->method('connect')->with('www.example.com:80')->willReturn(\React\Promise\resolve($connection)); + $requestData = new Request('GET', 'http://www.example.com/'); $request = new ClientRequestStream($this->connector, $requestData); - $this->successfulConnectionMock(); - $request->on('error', $this->expectCallableOnceWith($this->isInstanceOf('InvalidArgumentException'))); + $request->on('close', $this->expectCallableOnce()); $request->end(); $request->handleData("\r\n\r\n"); @@ -143,13 +140,13 @@ public function requestShouldEmitErrorIfRequestParserThrowsException() */ public function requestShouldEmitErrorIfUrlIsInvalid() { + $this->connector->expects($this->never())->method('connect'); + $requestData = new Request('GET', 'ftp://www.example.com'); $request = new ClientRequestStream($this->connector, $requestData); $request->on('error', $this->expectCallableOnceWith($this->isInstanceOf('InvalidArgumentException'))); - - $this->connector->expects($this->never()) - ->method('connect'); + $request->on('close', $this->expectCallableOnce()); $request->end(); } @@ -159,13 +156,13 @@ public function requestShouldEmitErrorIfUrlIsInvalid() */ public function requestShouldEmitErrorIfUrlHasNoScheme() { + $this->connector->expects($this->never())->method('connect'); + $requestData = new Request('GET', 'www.example.com'); $request = new ClientRequestStream($this->connector, $requestData); $request->on('error', $this->expectCallableOnceWith($this->isInstanceOf('InvalidArgumentException'))); - - $this->connector->expects($this->never()) - ->method('connect'); + $request->on('close', $this->expectCallableOnce()); $request->end(); } @@ -173,12 +170,13 @@ public function requestShouldEmitErrorIfUrlHasNoScheme() /** @test */ public function getRequestShouldSendAGetRequest() { - $requestData = new Request('GET', 'http://www.example.com/', array(), '', '1.0'); - $request = new ClientRequestStream($this->connector, $requestData); + $connection = $this->getMockBuilder('React\Socket\ConnectionInterface')->getMock(); + $connection->expects($this->once())->method('write')->with("GET / HTTP/1.0\r\nHost: www.example.com\r\n\r\n"); - $this->successfulConnectionMock(); + $this->connector->expects($this->once())->method('connect')->with('www.example.com:80')->willReturn(\React\Promise\resolve($connection)); - $this->stream->expects($this->once())->method('write')->with("GET / HTTP/1.0\r\nHost: www.example.com\r\n\r\n"); + $requestData = new Request('GET', 'http://www.example.com/', array(), '', '1.0'); + $request = new ClientRequestStream($this->connector, $requestData); $request->end(); } @@ -186,12 +184,13 @@ public function getRequestShouldSendAGetRequest() /** @test */ public function getHttp11RequestShouldSendAGetRequestWithGivenConnectionCloseHeader() { - $requestData = new Request('GET', 'http://www.example.com/', array('Connection' => 'close'), '', '1.1'); - $request = new ClientRequestStream($this->connector, $requestData); + $connection = $this->getMockBuilder('React\Socket\ConnectionInterface')->getMock(); + $connection->expects($this->once())->method('write')->with("GET / HTTP/1.1\r\nHost: www.example.com\r\nConnection: close\r\n\r\n"); - $this->successfulConnectionMock(); + $this->connector->expects($this->once())->method('connect')->with('www.example.com:80')->willReturn(\React\Promise\resolve($connection)); - $this->stream->expects($this->once())->method('write')->with("GET / HTTP/1.1\r\nHost: www.example.com\r\nConnection: close\r\n\r\n"); + $requestData = new Request('GET', 'http://www.example.com/', array('Connection' => 'close'), '', '1.1'); + $request = new ClientRequestStream($this->connector, $requestData); $request->end(); } @@ -199,29 +198,331 @@ public function getHttp11RequestShouldSendAGetRequestWithGivenConnectionCloseHea /** @test */ public function getOptionsAsteriskShouldSendAOptionsRequestAsteriskRequestTarget() { + $connection = $this->getMockBuilder('React\Socket\ConnectionInterface')->getMock(); + $connection->expects($this->once())->method('write')->with("OPTIONS * HTTP/1.1\r\nHost: www.example.com\r\nConnection: close\r\n\r\n"); + + $this->connector->expects($this->once())->method('connect')->with('www.example.com:80')->willReturn(\React\Promise\resolve($connection)); + $requestData = new Request('OPTIONS', 'http://www.example.com/', array('Connection' => 'close'), '', '1.1'); $requestData = $requestData->withRequestTarget('*'); $request = new ClientRequestStream($this->connector, $requestData); - $this->successfulConnectionMock(); + $request->end(); + } + + public function testStreamShouldEmitResponseWithEmptyBodyWhenResponseContainsContentLengthZero() + { + $connection = $this->getMockBuilder('React\Socket\ConnectionInterface')->getMock(); + $connection->expects($this->once())->method('write')->with("GET / HTTP/1.1\r\nHost: www.example.com\r\nConnection: close\r\n\r\n"); + $connection->expects($this->once())->method('close'); + + $this->connector->expects($this->once())->method('connect')->with('www.example.com:80')->willReturn(\React\Promise\resolve($connection)); + + $requestData = new Request('GET', 'http://www.example.com/', array('Connection' => 'close'), '', '1.1'); + $request = new ClientRequestStream($this->connector, $requestData); + + $that = $this; + $request->on('response', function (ResponseInterface $response, ReadableStreamInterface $body) use ($that) { + $body->on('data', $that->expectCallableNever()); + $body->on('end', $that->expectCallableOnce()); + $body->on('close', $that->expectCallableOnce()); + }); + $request->on('close', $this->expectCallableOnce()); + + $request->end(); + + $request->handleData("HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n"); + } + + public function testStreamShouldEmitResponseWithEmptyBodyWhenResponseContainsStatusNoContent() + { + $connection = $this->getMockBuilder('React\Socket\ConnectionInterface')->getMock(); + $connection->expects($this->once())->method('write')->with("GET / HTTP/1.1\r\nHost: www.example.com\r\nConnection: close\r\n\r\n"); + $connection->expects($this->once())->method('close'); + + $this->connector->expects($this->once())->method('connect')->with('www.example.com:80')->willReturn(\React\Promise\resolve($connection)); + + $requestData = new Request('GET', 'http://www.example.com/', array('Connection' => 'close'), '', '1.1'); + $request = new ClientRequestStream($this->connector, $requestData); + + $that = $this; + $request->on('response', function (ResponseInterface $response, ReadableStreamInterface $body) use ($that) { + $body->on('data', $that->expectCallableNever()); + $body->on('end', $that->expectCallableOnce()); + $body->on('close', $that->expectCallableOnce()); + }); + $request->on('close', $this->expectCallableOnce()); + + $request->end(); + + $request->handleData("HTTP/1.1 204 No Content\r\n\r\n"); + } + + public function testStreamShouldEmitResponseWithEmptyBodyWhenResponseContainsStatusNotModifiedWithContentLengthGiven() + { + $connection = $this->getMockBuilder('React\Socket\ConnectionInterface')->getMock(); + $connection->expects($this->once())->method('write')->with("GET / HTTP/1.1\r\nHost: www.example.com\r\nConnection: close\r\n\r\n"); + $connection->expects($this->once())->method('close'); + + $this->connector->expects($this->once())->method('connect')->with('www.example.com:80')->willReturn(\React\Promise\resolve($connection)); + + $requestData = new Request('GET', 'http://www.example.com/', array('Connection' => 'close'), '', '1.1'); + $request = new ClientRequestStream($this->connector, $requestData); + + $that = $this; + $request->on('response', function (ResponseInterface $response, ReadableStreamInterface $body) use ($that) { + $body->on('data', $that->expectCallableNever()); + $body->on('end', $that->expectCallableOnce()); + $body->on('close', $that->expectCallableOnce()); + }); + $request->on('close', $this->expectCallableOnce()); + + $request->end(); + + $request->handleData("HTTP/1.1 304 Not Modified\r\nContent-Length: 100\r\n\r\n"); + } + + public function testStreamShouldEmitResponseWithEmptyBodyWhenRequestMethodIsHead() + { + $connection = $this->getMockBuilder('React\Socket\ConnectionInterface')->getMock(); + $connection->expects($this->once())->method('write')->with("HEAD / HTTP/1.1\r\nHost: www.example.com\r\nConnection: close\r\n\r\n"); + $connection->expects($this->once())->method('close'); + + $this->connector->expects($this->once())->method('connect')->with('www.example.com:80')->willReturn(\React\Promise\resolve($connection)); + + $requestData = new Request('HEAD', 'http://www.example.com/', array('Connection' => 'close'), '', '1.1'); + $request = new ClientRequestStream($this->connector, $requestData); + + $that = $this; + $request->on('response', function (ResponseInterface $response, ReadableStreamInterface $body) use ($that) { + $body->on('data', $that->expectCallableNever()); + $body->on('end', $that->expectCallableOnce()); + $body->on('close', $that->expectCallableOnce()); + }); + $request->on('close', $this->expectCallableOnce()); + + $request->end(); + + $request->handleData("HTTP/1.1 200 OK\r\nContent-Length: 100\r\n\r\n"); + } + + public function testStreamShouldEmitResponseWithStreamingBodyUntilEndWhenResponseContainsContentLengthAndResponseBody() + { + $connection = $this->getMockBuilder('React\Socket\ConnectionInterface')->getMock(); + $connection->expects($this->once())->method('write')->with("GET / HTTP/1.1\r\nHost: www.example.com\r\nConnection: close\r\n\r\n"); + $connection->expects($this->once())->method('close'); + + $this->connector->expects($this->once())->method('connect')->with('www.example.com:80')->willReturn(\React\Promise\resolve($connection)); + + $requestData = new Request('GET', 'http://www.example.com/', array('Connection' => 'close'), '', '1.1'); + $request = new ClientRequestStream($this->connector, $requestData); + + $that = $this; + $request->on('response', function (ResponseInterface $response, ReadableStreamInterface $body) use ($that) { + $body->on('data', $that->expectCallableOnceWith('OK')); + $body->on('end', $that->expectCallableOnce()); + $body->on('close', $that->expectCallableOnce()); + }); + $request->on('close', $this->expectCallableOnce()); + + $request->end(); + + $request->handleData("HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nOK"); + } + + public function testStreamShouldEmitResponseWithStreamingBodyWithoutDataWhenResponseContainsContentLengthWithoutResponseBody() + { + $connection = $this->getMockBuilder('React\Socket\ConnectionInterface')->getMock(); + $connection->expects($this->once())->method('write')->with("GET / HTTP/1.1\r\nHost: www.example.com\r\nConnection: close\r\n\r\n"); + $connection->expects($this->never())->method('close'); + + $this->connector->expects($this->once())->method('connect')->with('www.example.com:80')->willReturn(\React\Promise\resolve($connection)); + + $requestData = new Request('GET', 'http://www.example.com/', array('Connection' => 'close'), '', '1.1'); + $request = new ClientRequestStream($this->connector, $requestData); + + $that = $this; + $request->on('response', function (ResponseInterface $response, ReadableStreamInterface $body) use ($that) { + $body->on('data', $that->expectCallableNever()); + $body->on('end', $that->expectCallableNever()); + $body->on('close', $that->expectCallableNever()); + }); + $request->on('close', $this->expectCallableNever()); + + $request->end(); + + $request->handleData("HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n"); + } + + public function testStreamShouldEmitResponseWithStreamingBodyWithDataWithoutEndWhenResponseContainsContentLengthWithIncompleteResponseBody() + { + $connection = $this->getMockBuilder('React\Socket\ConnectionInterface')->getMock(); + $connection->expects($this->once())->method('write')->with("GET / HTTP/1.1\r\nHost: www.example.com\r\nConnection: close\r\n\r\n"); + $connection->expects($this->never())->method('close'); + + $this->connector->expects($this->once())->method('connect')->with('www.example.com:80')->willReturn(\React\Promise\resolve($connection)); + + $requestData = new Request('GET', 'http://www.example.com/', array('Connection' => 'close'), '', '1.1'); + $request = new ClientRequestStream($this->connector, $requestData); + + $that = $this; + $request->on('response', function (ResponseInterface $response, ReadableStreamInterface $body) use ($that) { + $body->on('data', $that->expectCallableOnce('O')); + $body->on('end', $that->expectCallableNever()); + $body->on('close', $that->expectCallableNever()); + }); + $request->on('close', $this->expectCallableNever()); + + $request->end(); + + $request->handleData("HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nO"); + } + + public function testStreamShouldEmitResponseWithStreamingBodyUntilEndWhenResponseContainsTransferEncodingChunkedAndResponseBody() + { + $connection = $this->getMockBuilder('React\Socket\ConnectionInterface')->getMock(); + $connection->expects($this->once())->method('write')->with("GET / HTTP/1.1\r\nHost: www.example.com\r\nConnection: close\r\n\r\n"); + $connection->expects($this->once())->method('close'); + + $this->connector->expects($this->once())->method('connect')->with('www.example.com:80')->willReturn(\React\Promise\resolve($connection)); + + $requestData = new Request('GET', 'http://www.example.com/', array('Connection' => 'close'), '', '1.1'); + $request = new ClientRequestStream($this->connector, $requestData); + + $that = $this; + $request->on('response', function (ResponseInterface $response, ReadableStreamInterface $body) use ($that) { + $body->on('data', $that->expectCallableOnceWith('OK')); + $body->on('end', $that->expectCallableOnce()); + $body->on('close', $that->expectCallableOnce()); + }); + $request->on('close', $this->expectCallableOnce()); + + $request->end(); + + $request->handleData("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n2\r\nOK\r\n0\r\n\r\n"); + } + + public function testStreamShouldEmitResponseWithStreamingBodyWithoutDataWhenResponseContainsTransferEncodingChunkedWithoutResponseBody() + { + $connection = $this->getMockBuilder('React\Socket\ConnectionInterface')->getMock(); + $connection->expects($this->once())->method('write')->with("GET / HTTP/1.1\r\nHost: www.example.com\r\nConnection: close\r\n\r\n"); + $connection->expects($this->never())->method('close'); + + $this->connector->expects($this->once())->method('connect')->with('www.example.com:80')->willReturn(\React\Promise\resolve($connection)); + + $requestData = new Request('GET', 'http://www.example.com/', array('Connection' => 'close'), '', '1.1'); + $request = new ClientRequestStream($this->connector, $requestData); + + $that = $this; + $request->on('response', function (ResponseInterface $response, ReadableStreamInterface $body) use ($that) { + $body->on('data', $that->expectCallableNever()); + $body->on('end', $that->expectCallableNever()); + $body->on('close', $that->expectCallableNever()); + }); + $request->on('close', $this->expectCallableNever()); + + $request->end(); + + $request->handleData("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n"); + } + + public function testStreamShouldEmitResponseWithStreamingBodyWithDataWithoutEndWhenResponseContainsTransferEncodingChunkedWithIncompleteResponseBody() + { + $connection = $this->getMockBuilder('React\Socket\ConnectionInterface')->getMock(); + $connection->expects($this->once())->method('write')->with("GET / HTTP/1.1\r\nHost: www.example.com\r\nConnection: close\r\n\r\n"); + $connection->expects($this->never())->method('close'); + + $this->connector->expects($this->once())->method('connect')->with('www.example.com:80')->willReturn(\React\Promise\resolve($connection)); + + $requestData = new Request('GET', 'http://www.example.com/', array('Connection' => 'close'), '', '1.1'); + $request = new ClientRequestStream($this->connector, $requestData); + + $that = $this; + $request->on('response', function (ResponseInterface $response, ReadableStreamInterface $body) use ($that) { + $body->on('data', $that->expectCallableOnceWith('O')); + $body->on('end', $that->expectCallableNever()); + $body->on('close', $that->expectCallableNever()); + }); + $request->on('close', $this->expectCallableNever()); + + $request->end(); + + $request->handleData("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n2\r\nO"); + } + + public function testStreamShouldEmitResponseWithStreamingBodyWithDataWithoutEndWhenResponseContainsNoContentLengthAndIncompleteResponseBody() + { + $connection = $this->getMockBuilder('React\Socket\ConnectionInterface')->getMock(); + $connection->expects($this->once())->method('write')->with("GET / HTTP/1.1\r\nHost: www.example.com\r\nConnection: close\r\n\r\n"); + $connection->expects($this->never())->method('close'); - $this->stream->expects($this->once())->method('write')->with("OPTIONS * HTTP/1.1\r\nHost: www.example.com\r\nConnection: close\r\n\r\n"); + $this->connector->expects($this->once())->method('connect')->with('www.example.com:80')->willReturn(\React\Promise\resolve($connection)); + + $requestData = new Request('GET', 'http://www.example.com/', array('Connection' => 'close'), '', '1.1'); + $request = new ClientRequestStream($this->connector, $requestData); + + $that = $this; + $request->on('response', function (ResponseInterface $response, ReadableStreamInterface $body) use ($that) { + $body->on('data', $that->expectCallableOnce('O')); + $body->on('end', $that->expectCallableNever()); + $body->on('close', $that->expectCallableNever()); + }); + $request->on('close', $this->expectCallableNever()); $request->end(); + + $request->handleData("HTTP/1.1 200 OK\r\n\r\nO"); + } + + public function testStreamShouldEmitResponseWithStreamingBodyUntilEndWhenResponseContainsNoContentLengthAndResponseBodyTerminatedByConnectionEndEvent() + { + $connection = $this->getMockBuilder('React\Socket\ConnectionInterface')->getMock(); + $connection->expects($this->once())->method('write')->with("GET / HTTP/1.1\r\nHost: www.example.com\r\nConnection: close\r\n\r\n"); + $connection->expects($this->once())->method('close'); + + $endEvent = null; + $eventName = null; + $connection->expects($this->any())->method('on')->with($this->callback(function ($name) use (&$eventName) { + $eventName = $name; + return true; + }), $this->callback(function ($cb) use (&$endEvent, &$eventName) { + if ($eventName === 'end') { + $endEvent = $cb; + } + return true; + })); + + $this->connector->expects($this->once())->method('connect')->with('www.example.com:80')->willReturn(\React\Promise\resolve($connection)); + + $requestData = new Request('GET', 'http://www.example.com/', array('Connection' => 'close'), '', '1.1'); + $request = new ClientRequestStream($this->connector, $requestData); + + $that = $this; + $request->on('response', function (ResponseInterface $response, ReadableStreamInterface $body) use ($that) { + $body->on('data', $that->expectCallableOnce('OK')); + $body->on('end', $that->expectCallableOnce()); + $body->on('close', $that->expectCallableOnce()); + }); + $request->on('close', $this->expectCallableOnce()); + + $request->end(); + + $request->handleData("HTTP/1.1 200 OK\r\n\r\nOK"); + + $this->assertNotNull($endEvent); + call_user_func($endEvent); // $endEvent() (PHP 5.4+) } /** @test */ public function postRequestShouldSendAPostRequest() { - $requestData = new Request('POST', 'http://www.example.com/', array(), '', '1.0'); - $request = new ClientRequestStream($this->connector, $requestData); + $connection = $this->getMockBuilder('React\Socket\ConnectionInterface')->getMock(); + $connection->expects($this->once())->method('write')->with($this->matchesRegularExpression("#^POST / HTTP/1\.0\r\nHost: www.example.com\r\n\r\nsome post data$#")); - $this->successfulConnectionMock(); + $this->connector->expects($this->once())->method('connect')->with('www.example.com:80')->willReturn(\React\Promise\resolve($connection)); - $this->stream - ->expects($this->once()) - ->method('write') - ->with($this->matchesRegularExpression("#^POST / HTTP/1\.0\r\nHost: www.example.com\r\n\r\nsome post data$#")); + $requestData = new Request('POST', 'http://www.example.com/', array(), '', '1.0'); + $request = new ClientRequestStream($this->connector, $requestData); $request->end('some post data'); @@ -233,17 +534,18 @@ public function postRequestShouldSendAPostRequest() /** @test */ public function writeWithAPostRequestShouldSendToTheStream() { - $requestData = new Request('POST', 'http://www.example.com/', array(), '', '1.0'); - $request = new ClientRequestStream($this->connector, $requestData); - - $this->successfulConnectionMock(); - - $this->stream->expects($this->exactly(3))->method('write')->withConsecutive( + $connection = $this->getMockBuilder('React\Socket\ConnectionInterface')->getMock(); + $connection->expects($this->exactly(3))->method('write')->withConsecutive( array($this->matchesRegularExpression("#^POST / HTTP/1\.0\r\nHost: www.example.com\r\n\r\nsome$#")), array($this->identicalTo("post")), array($this->identicalTo("data")) ); + $this->connector->expects($this->once())->method('connect')->with('www.example.com:80')->willReturn(\React\Promise\resolve($connection)); + + $requestData = new Request('POST', 'http://www.example.com/', array(), '', '1.0'); + $request = new ClientRequestStream($this->connector, $requestData); + $request->write("some"); $request->write("post"); $request->end("data"); @@ -256,18 +558,20 @@ public function writeWithAPostRequestShouldSendToTheStream() /** @test */ public function writeWithAPostRequestShouldSendBodyAfterHeadersAndEmitDrainEvent() { - $requestData = new Request('POST', 'http://www.example.com/', array(), '', '1.0'); - $request = new ClientRequestStream($this->connector, $requestData); - - $resolveConnection = $this->successfulAsyncConnectionMock(); - - $this->stream->expects($this->exactly(2))->method('write')->withConsecutive( + $connection = $this->getMockBuilder('React\Socket\ConnectionInterface')->getMock(); + $connection->expects($this->exactly(2))->method('write')->withConsecutive( array($this->matchesRegularExpression("#^POST / HTTP/1\.0\r\nHost: www.example.com\r\n\r\nsomepost$#")), array($this->identicalTo("data")) )->willReturn( true ); + $deferred = new Deferred(); + $this->connector->expects($this->once())->method('connect')->with('www.example.com:80')->willReturn($deferred->promise()); + + $requestData = new Request('POST', 'http://www.example.com/', array(), '', '1.0'); + $request = new ClientRequestStream($this->connector, $requestData); + $this->assertFalse($request->write("some")); $this->assertFalse($request->write("post")); @@ -277,7 +581,7 @@ public function writeWithAPostRequestShouldSendBodyAfterHeadersAndEmitDrainEvent $request->end(); }); - $resolveConnection(); + $deferred->resolve($connection); $request->handleData("HTTP/1.0 200 OK\r\n"); $request->handleData("Content-Type: text/plain\r\n"); @@ -287,23 +591,24 @@ public function writeWithAPostRequestShouldSendBodyAfterHeadersAndEmitDrainEvent /** @test */ public function writeWithAPostRequestShouldForwardDrainEventIfFirstChunkExceedsBuffer() { - $requestData = new Request('POST', 'http://www.example.com/', array(), '', '1.0'); - $request = new ClientRequestStream($this->connector, $requestData); - - $this->stream = $this->getMockBuilder('React\Socket\Connection') + $connection = $this->getMockBuilder('React\Socket\Connection') ->disableOriginalConstructor() ->setMethods(array('write')) ->getMock(); - $resolveConnection = $this->successfulAsyncConnectionMock(); - - $this->stream->expects($this->exactly(2))->method('write')->withConsecutive( + $connection->expects($this->exactly(2))->method('write')->withConsecutive( array($this->matchesRegularExpression("#^POST / HTTP/1\.0\r\nHost: www.example.com\r\n\r\nsomepost$#")), array($this->identicalTo("data")) )->willReturn( false ); + $deferred = new Deferred(); + $this->connector->expects($this->once())->method('connect')->with('www.example.com:80')->willReturn($deferred->promise()); + + $requestData = new Request('POST', 'http://www.example.com/', array(), '', '1.0'); + $request = new ClientRequestStream($this->connector, $requestData); + $this->assertFalse($request->write("some")); $this->assertFalse($request->write("post")); @@ -313,8 +618,8 @@ public function writeWithAPostRequestShouldForwardDrainEventIfFirstChunkExceedsB $request->end(); }); - $resolveConnection(); - $this->stream->emit('drain'); + $deferred->resolve($connection); + $connection->emit('drain'); $request->handleData("HTTP/1.0 200 OK\r\n"); $request->handleData("Content-Type: text/plain\r\n"); @@ -324,17 +629,18 @@ public function writeWithAPostRequestShouldForwardDrainEventIfFirstChunkExceedsB /** @test */ public function pipeShouldPipeDataIntoTheRequestBody() { - $requestData = new Request('POST', 'http://www.example.com/', array(), '', '1.0'); - $request = new ClientRequestStream($this->connector, $requestData); - - $this->successfulConnectionMock(); - - $this->stream->expects($this->exactly(3))->method('write')->withConsecutive( + $connection = $this->getMockBuilder('React\Socket\ConnectionInterface')->getMock(); + $connection->expects($this->exactly(3))->method('write')->withConsecutive( array($this->matchesRegularExpression("#^POST / HTTP/1\.0\r\nHost: www.example.com\r\n\r\nsome$#")), array($this->identicalTo("post")), array($this->identicalTo("data")) ); + $this->connector->expects($this->once())->method('connect')->with('www.example.com:80')->willReturn(\React\Promise\resolve($connection)); + + $requestData = new Request('POST', 'http://www.example.com/', array(), '', '1.0'); + $request = new ClientRequestStream($this->connector, $requestData); + $loop = $this ->getMockBuilder('React\EventLoop\LoopInterface') ->getMock(); @@ -357,14 +663,14 @@ public function pipeShouldPipeDataIntoTheRequestBody() */ public function writeShouldStartConnecting() { - $requestData = new Request('POST', 'http://www.example.com/'); - $request = new ClientRequestStream($this->connector, $requestData); - $this->connector->expects($this->once()) ->method('connect') ->with('www.example.com:80') ->willReturn(new Promise(function () { })); + $requestData = new Request('POST', 'http://www.example.com/'); + $request = new ClientRequestStream($this->connector, $requestData); + $request->write('test'); } @@ -373,14 +679,11 @@ public function writeShouldStartConnecting() */ public function endShouldStartConnectingAndChangeStreamIntoNonWritableMode() { + $this->connector->expects($this->once())->method('connect')->with('www.example.com:80')->willReturn(new Promise(function () { })); + $requestData = new Request('POST', 'http://www.example.com/'); $request = new ClientRequestStream($this->connector, $requestData); - $this->connector->expects($this->once()) - ->method('connect') - ->with('www.example.com:80') - ->willReturn(new Promise(function () { })); - $request->end(); $this->assertFalse($request->isWritable()); @@ -417,12 +720,11 @@ public function writeAfterCloseReturnsFalse() */ public function endAfterCloseIsNoOp() { + $this->connector->expects($this->never())->method('connect'); + $requestData = new Request('POST', 'http://www.example.com/'); $request = new ClientRequestStream($this->connector, $requestData); - $this->connector->expects($this->never()) - ->method('connect'); - $request->close(); $request->end(); } @@ -432,17 +734,13 @@ public function endAfterCloseIsNoOp() */ public function closeShouldCancelPendingConnectionAttempt() { - $requestData = new Request('POST', 'http://www.example.com/'); - $request = new ClientRequestStream($this->connector, $requestData); - $promise = new Promise(function () {}, function () { throw new \RuntimeException(); }); + $this->connector->expects($this->once())->method('connect')->with('www.example.com:80')->willReturn($promise); - $this->connector->expects($this->once()) - ->method('connect') - ->with('www.example.com:80') - ->willReturn($promise); + $requestData = new Request('POST', 'http://www.example.com/'); + $request = new ClientRequestStream($this->connector, $requestData); $request->end(); @@ -466,35 +764,16 @@ public function requestShouldRemoveAllListenerAfterClosed() $this->assertCount(0, $request->listeners('close')); } - private function successfulConnectionMock() - { - call_user_func($this->successfulAsyncConnectionMock()); - } - - private function successfulAsyncConnectionMock() - { - $deferred = new Deferred(); - - $this->connector - ->expects($this->once()) - ->method('connect') - ->with('www.example.com:80') - ->will($this->returnValue($deferred->promise())); - - $stream = $this->stream; - return function () use ($deferred, $stream) { - $deferred->resolve($stream); - }; - } - /** @test */ public function multivalueHeader() { + $connection = $this->getMockBuilder('React\Socket\ConnectionInterface')->getMock(); + + $this->connector->expects($this->once())->method('connect')->with('www.example.com:80')->willReturn(\React\Promise\resolve($connection)); + $requestData = new Request('GET', 'http://www.example.com/'); $request = new ClientRequestStream($this->connector, $requestData); - $this->successfulConnectionMock(); - $response = null; $request->on('response', $this->expectCallableOnce()); $request->on('response', function ($value) use (&$response) { diff --git a/tests/TestCase.php b/tests/TestCase.php index 1938ed89..72b7be8d 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -6,7 +6,7 @@ class TestCase extends BaseTestCase { - protected function expectCallableOnce() + public function expectCallableOnce() // protected (PHP 5.4+) { $mock = $this->createCallableMock(); $mock @@ -16,7 +16,7 @@ protected function expectCallableOnce() return $mock; } - protected function expectCallableOnceWith($value) + public function expectCallableOnceWith($value) // protected (PHP 5.4+) { $mock = $this->createCallableMock(); $mock @@ -27,7 +27,7 @@ protected function expectCallableOnceWith($value) return $mock; } - protected function expectCallableNever() + public function expectCallableNever() // protected (PHP 5.4+) { $mock = $this->createCallableMock(); $mock From 28b598ab09109da412e1935d9560888887cfb86a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Thu, 12 Jan 2023 10:58:56 +0100 Subject: [PATCH 28/58] Send `Connection: close` for HTTP/1.1 and no `Connection` for HTTP/1.0 --- src/Io/Sender.php | 4 +++- tests/Io/SenderTest.php | 20 +++++++++++++++++--- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/src/Io/Sender.php b/src/Io/Sender.php index 0894c574..acbb6e7d 100644 --- a/src/Io/Sender.php +++ b/src/Io/Sender.php @@ -94,8 +94,10 @@ public function send(RequestInterface $request) } // automatically add `Connection: close` request header for HTTP/1.1 requests to avoid connection reuse - if ($request->getProtocolVersion() === '1.1' && !$request->hasHeader('Connection')) { + if ($request->getProtocolVersion() === '1.1') { $request = $request->withHeader('Connection', 'close'); + } else { + $request = $request->withoutHeader('Connection'); } // automatically add `Authorization: Basic …` request header if URL includes `user:pass@host` diff --git a/tests/Io/SenderTest.php b/tests/Io/SenderTest.php index c2357a1a..4ef06442 100644 --- a/tests/Io/SenderTest.php +++ b/tests/Io/SenderTest.php @@ -302,6 +302,20 @@ public function getHttp10RequestShouldSendAGetRequestWithoutConnectionHeaderByDe $sender->send($request); } + /** @test */ + public function getHttp10RequestShouldSendAGetRequestWithoutConnectionHeaderEvenWhenConnectionKeepAliveHeaderIsSpecified() + { + $client = $this->getMockBuilder('React\Http\Client\Client')->disableOriginalConstructor()->getMock(); + $client->expects($this->once())->method('request')->with($this->callback(function (RequestInterface $request) { + return !$request->hasHeader('Connection'); + }))->willReturn($this->getMockBuilder('React\Http\Io\ClientRequestStream')->disableOriginalConstructor()->getMock()); + + $sender = new Sender($client); + + $request = new Request('GET', 'http://www.example.com/', array('Connection' => 'keep-alive'), '', '1.0'); + $sender->send($request); + } + /** @test */ public function getHttp11RequestShouldSendAGetRequestWithConnectionCloseHeaderByDefault() { @@ -317,16 +331,16 @@ public function getHttp11RequestShouldSendAGetRequestWithConnectionCloseHeaderBy } /** @test */ - public function getHttp11RequestShouldSendAGetRequestWithGivenConnectionUpgradeHeader() + public function getHttp11RequestShouldSendAGetRequestWithConnectionCloseHeaderEvenWhenConnectionKeepAliveHeaderIsSpecified() { $client = $this->getMockBuilder('React\Http\Client\Client')->disableOriginalConstructor()->getMock(); $client->expects($this->once())->method('request')->with($this->callback(function (RequestInterface $request) { - return $request->getHeaderLine('Connection') === 'upgrade'; + return $request->getHeaderLine('Connection') === 'close'; }))->willReturn($this->getMockBuilder('React\Http\Io\ClientRequestStream')->disableOriginalConstructor()->getMock()); $sender = new Sender($client); - $request = new Request('GET', 'http://www.example.com/', array('Connection' => 'upgrade'), '', '1.1'); + $request = new Request('GET', 'http://www.example.com/', array('Connection' => 'keep-alive'), '', '1.1'); $sender->send($request); } From d8566953c6b699618a500a4589833c19d6985f2b Mon Sep 17 00:00:00 2001 From: Cees-Jan Kiewiet Date: Sat, 21 Jan 2023 14:52:26 +0100 Subject: [PATCH 29/58] Template params can only have one argument The fact that a promise can also be rejected with a Throwable and/or Exception is implied and there is no need to also define that here. Refs: https://github.com/reactphp/promise/pull/223 --- src/Browser.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Browser.php b/src/Browser.php index 3e3458af..b7bf4425 100644 --- a/src/Browser.php +++ b/src/Browser.php @@ -344,7 +344,7 @@ public function delete($url, array $headers = array(), $body = '') * @param string $url URL for the request * @param array $headers Additional request headers * @param string|ReadableStreamInterface $body HTTP request body contents - * @return PromiseInterface + * @return PromiseInterface */ public function request($method, $url, array $headers = array(), $body = '') { @@ -417,7 +417,7 @@ public function request($method, $url, array $headers = array(), $body = '') * @param string $url URL for the request * @param array $headers Additional request headers * @param string|ReadableStreamInterface $body HTTP request body contents - * @return PromiseInterface + * @return PromiseInterface */ public function requestStreaming($method, $url, $headers = array(), $body = '') { @@ -828,7 +828,7 @@ private function withOptions(array $options) * @param string $url * @param array $headers * @param string|ReadableStreamInterface $body - * @return PromiseInterface + * @return PromiseInterface */ private function requestMayBeStreaming($method, $url, array $headers = array(), $body = '') { From 1c911d2fff297278d74815008dfe95b0036379b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Tue, 10 Jan 2023 15:49:44 +0100 Subject: [PATCH 30/58] Refactor to add new `ClientConnectionManager` to manage HTTP connections --- src/Client/Client.php | 17 +- src/Io/ClientConnectionManager.php | 45 ++++ src/Io/ClientRequestStream.php | 37 +--- src/Io/Sender.php | 7 +- tests/BrowserTest.php | 16 +- tests/Client/FunctionalIntegrationTest.php | 15 +- tests/Io/ClientConnectionManagerTest.php | 83 ++++++++ tests/Io/ClientRequestStreamTest.php | 227 ++++++++++----------- tests/Io/SenderTest.php | 23 +-- 9 files changed, 276 insertions(+), 194 deletions(-) create mode 100644 src/Io/ClientConnectionManager.php create mode 100644 tests/Io/ClientConnectionManagerTest.php diff --git a/src/Client/Client.php b/src/Client/Client.php index c3fd4570..7a5180ab 100644 --- a/src/Client/Client.php +++ b/src/Client/Client.php @@ -3,30 +3,25 @@ namespace React\Http\Client; use Psr\Http\Message\RequestInterface; -use React\EventLoop\LoopInterface; +use React\Http\Io\ClientConnectionManager; use React\Http\Io\ClientRequestStream; -use React\Socket\Connector; -use React\Socket\ConnectorInterface; /** * @internal */ class Client { - private $connector; + /** @var ClientConnectionManager */ + private $connectionManager; - public function __construct(LoopInterface $loop, ConnectorInterface $connector = null) + public function __construct(ClientConnectionManager $connectionManager) { - if ($connector === null) { - $connector = new Connector(array(), $loop); - } - - $this->connector = $connector; + $this->connectionManager = $connectionManager; } /** @return ClientRequestStream */ public function request(RequestInterface $request) { - return new ClientRequestStream($this->connector, $request); + return new ClientRequestStream($this->connectionManager, $request); } } diff --git a/src/Io/ClientConnectionManager.php b/src/Io/ClientConnectionManager.php new file mode 100644 index 00000000..51f937e4 --- /dev/null +++ b/src/Io/ClientConnectionManager.php @@ -0,0 +1,45 @@ +connector = $connector; + } + + /** + * @return PromiseInterface + */ + public function connect(UriInterface $uri) + { + $scheme = $uri->getScheme(); + if ($scheme !== 'https' && $scheme !== 'http') { + return \React\Promise\reject(new \InvalidArgumentException( + 'Invalid request URL given' + )); + } + + $port = $uri->getPort(); + if ($port === null) { + $port = $scheme === 'https' ? 443 : 80; + } + + return $this->connector->connect(($scheme === 'https' ? 'tls://' : '') . $uri->getHost() . ':' . $port); + } +} diff --git a/src/Io/ClientRequestStream.php b/src/Io/ClientRequestStream.php index bdaa54f1..e5eaf298 100644 --- a/src/Io/ClientRequestStream.php +++ b/src/Io/ClientRequestStream.php @@ -4,11 +4,8 @@ use Evenement\EventEmitter; use Psr\Http\Message\RequestInterface; -use Psr\Http\Message\ResponseInterface; use React\Http\Message\Response; -use React\Promise; use React\Socket\ConnectionInterface; -use React\Socket\ConnectorInterface; use React\Stream\WritableStreamInterface; use RingCentral\Psr7 as gPsr; @@ -26,8 +23,8 @@ class ClientRequestStream extends EventEmitter implements WritableStreamInterfac const STATE_HEAD_WRITTEN = 2; const STATE_END = 3; - /** @var ConnectorInterface */ - private $connector; + /** @var ClientConnectionManager */ + private $connectionManager; /** @var RequestInterface */ private $request; @@ -44,9 +41,9 @@ class ClientRequestStream extends EventEmitter implements WritableStreamInterfac private $pendingWrites = ''; - public function __construct(ConnectorInterface $connector, RequestInterface $request) + public function __construct(ClientConnectionManager $connectionManager, RequestInterface $request) { - $this->connector = $connector; + $this->connectionManager = $connectionManager; $this->request = $request; } @@ -65,7 +62,7 @@ private function writeHead() $pendingWrites = &$this->pendingWrites; $that = $this; - $promise = $this->connect(); + $promise = $this->connectionManager->connect($this->request->getUri()); $promise->then( function (ConnectionInterface $connection) use ($request, &$connectionRef, &$stateRef, &$pendingWrites, $that) { $connectionRef = $connection; @@ -252,28 +249,4 @@ public function close() $this->emit('close'); $this->removeAllListeners(); } - - protected function connect() - { - $scheme = $this->request->getUri()->getScheme(); - if ($scheme !== 'https' && $scheme !== 'http') { - return Promise\reject( - new \InvalidArgumentException('Invalid request URL given') - ); - } - - $host = $this->request->getUri()->getHost(); - $port = $this->request->getUri()->getPort(); - - if ($scheme === 'https') { - $host = 'tls://' . $host; - } - - if ($port === null) { - $port = $scheme === 'https' ? 443 : 80; - } - - return $this->connector - ->connect($host . ':' . $port); - } } diff --git a/src/Io/Sender.php b/src/Io/Sender.php index acbb6e7d..68c09322 100644 --- a/src/Io/Sender.php +++ b/src/Io/Sender.php @@ -8,6 +8,7 @@ use React\Http\Client\Client as HttpClient; use React\Promise\PromiseInterface; use React\Promise\Deferred; +use React\Socket\Connector; use React\Socket\ConnectorInterface; use React\Stream\ReadableStreamInterface; @@ -49,7 +50,11 @@ class Sender */ public static function createFromLoop(LoopInterface $loop, ConnectorInterface $connector = null) { - return new self(new HttpClient($loop, $connector)); + if ($connector === null) { + $connector = new Connector(array(), $loop); + } + + return new self(new HttpClient(new ClientConnectionManager($connector))); } private $http; diff --git a/tests/BrowserTest.php b/tests/BrowserTest.php index ad61cf9b..21242a5d 100644 --- a/tests/BrowserTest.php +++ b/tests/BrowserTest.php @@ -60,9 +60,13 @@ public function testConstructWithConnectorAssignsGivenConnector() $ref->setAccessible(true); $client = $ref->getValue($sender); - $ref = new \ReflectionProperty($client, 'connector'); + $ref = new \ReflectionProperty($client, 'connectionManager'); $ref->setAccessible(true); - $ret = $ref->getValue($client); + $connectionManager = $ref->getValue($client); + + $ref = new \ReflectionProperty($connectionManager, 'connector'); + $ref->setAccessible(true); + $ret = $ref->getValue($connectionManager); $this->assertSame($connector, $ret); } @@ -85,9 +89,13 @@ public function testConstructWithConnectorWithLegacySignatureAssignsGivenConnect $ref->setAccessible(true); $client = $ref->getValue($sender); - $ref = new \ReflectionProperty($client, 'connector'); + $ref = new \ReflectionProperty($client, 'connectionManager'); + $ref->setAccessible(true); + $connectionManager = $ref->getValue($client); + + $ref = new \ReflectionProperty($connectionManager, 'connector'); $ref->setAccessible(true); - $ret = $ref->getValue($client); + $ret = $ref->getValue($connectionManager); $this->assertSame($connector, $ret); } diff --git a/tests/Client/FunctionalIntegrationTest.php b/tests/Client/FunctionalIntegrationTest.php index 90d8444b..d5015fd1 100644 --- a/tests/Client/FunctionalIntegrationTest.php +++ b/tests/Client/FunctionalIntegrationTest.php @@ -3,12 +3,13 @@ namespace React\Tests\Http\Client; use Psr\Http\Message\ResponseInterface; -use React\EventLoop\Loop; use React\Http\Client\Client; +use React\Http\Io\ClientConnectionManager; use React\Http\Message\Request; use React\Promise\Deferred; use React\Promise\Stream; use React\Socket\ConnectionInterface; +use React\Socket\Connector; use React\Socket\SocketServer; use React\Stream\ReadableStreamInterface; use React\Tests\Http\TestCase; @@ -45,7 +46,7 @@ public function testRequestToLocalhostEmitsSingleRemoteConnection() }); $port = parse_url(https://github.com/$socket-%3EgetAddress(), PHP_URL_PORT); - $client = new Client(Loop::get()); + $client = new Client(new ClientConnectionManager(new Connector())); $request = $client->request(new Request('GET', 'http://localhost/' . $port, array(), '', '1.0')); $promise = Stream\first($request, 'close'); @@ -62,7 +63,7 @@ public function testRequestLegacyHttpServerWithOnlyLineFeedReturnsSuccessfulResp $socket->close(); }); - $client = new Client(Loop::get()); + $client = new Client(new ClientConnectionManager(new Connector())); $request = $client->request(new Request('GET', str_replace('tcp:', 'http:', $socket->getAddress()), array(), '', '1.0')); $once = $this->expectCallableOnceWith('body'); @@ -82,7 +83,7 @@ public function testSuccessfulResponseEmitsEnd() // max_nesting_level was set to 100 for PHP Versions < 5.4 which resulted in failing test for legacy PHP ini_set('xdebug.max_nesting_level', 256); - $client = new Client(Loop::get()); + $client = new Client(new ClientConnectionManager(new Connector())); $request = $client->request(new Request('GET', 'http://www.google.com/', array(), '', '1.0')); @@ -107,7 +108,7 @@ public function testPostDataReturnsData() // max_nesting_level was set to 100 for PHP Versions < 5.4 which resulted in failing test for legacy PHP ini_set('xdebug.max_nesting_level', 256); - $client = new Client(Loop::get()); + $client = new Client(new ClientConnectionManager(new Connector())); $data = str_repeat('.', 33000); $request = $client->request(new Request('POST', 'https://' . (mt_rand(0, 1) === 0 ? 'eu.' : '') . 'httpbin.org/post', array('Content-Length' => strlen($data)), '', '1.0')); @@ -139,7 +140,7 @@ public function testPostJsonReturnsData() $this->markTestSkipped('Not supported on HHVM'); } - $client = new Client(Loop::get()); + $client = new Client(new ClientConnectionManager(new Connector())); $data = json_encode(array('numbers' => range(1, 50))); $request = $client->request(new Request('POST', 'https://httpbin.org/post', array('Content-Length' => strlen($data), 'Content-Type' => 'application/json'), '', '1.0')); @@ -169,7 +170,7 @@ public function testCancelPendingConnectionEmitsClose() // max_nesting_level was set to 100 for PHP Versions < 5.4 which resulted in failing test for legacy PHP ini_set('xdebug.max_nesting_level', 256); - $client = new Client(Loop::get()); + $client = new Client(new ClientConnectionManager(new Connector())); $request = $client->request(new Request('GET', 'http://www.google.com/', array(), '', '1.0')); $request->on('error', $this->expectCallableNever()); diff --git a/tests/Io/ClientConnectionManagerTest.php b/tests/Io/ClientConnectionManagerTest.php new file mode 100644 index 00000000..5774e47d --- /dev/null +++ b/tests/Io/ClientConnectionManagerTest.php @@ -0,0 +1,83 @@ +getMockBuilder('React\Socket\ConnectorInterface')->getMock(); + $connector->expects($this->once())->method('connect')->with('tls://reactphp.org:443')->willReturn($promise); + + $connectionManager = new ClientConnectionManager($connector); + + $ret = $connectionManager->connect(new Uri('https://reactphp.org/')); + $this->assertSame($promise, $ret); + } + + public function testConnectWithHttpUriShouldConnectToTcpWithDefaultPort() + { + $promise = new Promise(function () { }); + $connector = $this->getMockBuilder('React\Socket\ConnectorInterface')->getMock(); + $connector->expects($this->once())->method('connect')->with('reactphp.org:80')->willReturn($promise); + + $connectionManager = new ClientConnectionManager($connector); + + $ret = $connectionManager->connect(new Uri('http://reactphp.org/')); + $this->assertSame($promise, $ret); + } + + public function testConnectWithExplicitPortShouldConnectWithGivenPort() + { + $promise = new Promise(function () { }); + $connector = $this->getMockBuilder('React\Socket\ConnectorInterface')->getMock(); + $connector->expects($this->once())->method('connect')->with('reactphp.org:8080')->willReturn($promise); + + $connectionManager = new ClientConnectionManager($connector); + + $ret = $connectionManager->connect(new Uri('http://reactphp.org:8080/')); + $this->assertSame($promise, $ret); + } + + public function testConnectWithInvalidSchemeShouldRejectWithException() + { + $connector = $this->getMockBuilder('React\Socket\ConnectorInterface')->getMock(); + $connector->expects($this->never())->method('connect'); + + $connectionManager = new ClientConnectionManager($connector); + + $promise = $connectionManager->connect(new Uri('ftp://reactphp.org/')); + + $exception = null; + $promise->then(null, function ($reason) use (&$exception) { + $exception = $reason; + }); + + $this->assertInstanceOf('InvalidArgumentException', $exception); + $this->assertEquals('Invalid request URL given', $exception->getMessage()); + } + + public function testConnectWithoutSchemeShouldRejectWithException() + { + $connector = $this->getMockBuilder('React\Socket\ConnectorInterface')->getMock(); + $connector->expects($this->never())->method('connect'); + + $connectionManager = new ClientConnectionManager($connector); + + $promise = $connectionManager->connect(new Uri('reactphp.org')); + + $exception = null; + $promise->then(null, function ($reason) use (&$exception) { + $exception = $reason; + }); + + $this->assertInstanceOf('InvalidArgumentException', $exception); + $this->assertEquals('Invalid request URL given', $exception->getMessage()); + } +} diff --git a/tests/Io/ClientRequestStreamTest.php b/tests/Io/ClientRequestStreamTest.php index 93220d10..da26b4e5 100644 --- a/tests/Io/ClientRequestStreamTest.php +++ b/tests/Io/ClientRequestStreamTest.php @@ -3,6 +3,7 @@ namespace React\Tests\Http\Io; use Psr\Http\Message\ResponseInterface; +use RingCentral\Psr7\Uri; use React\Http\Io\ClientRequestStream; use React\Http\Message\Request; use React\Promise\Deferred; @@ -13,26 +14,17 @@ class ClientRequestStreamTest extends TestCase { - private $connector; - - /** - * @before - */ - public function setUpStream() - { - $this->connector = $this->getMockBuilder('React\Socket\ConnectorInterface') - ->getMock(); - } - /** @test */ - public function requestShouldBindToStreamEventsAndUseconnector() + public function testRequestShouldUseConnectionManagerWithUriFromRequestAndBindToStreamEvents() { $connection = $this->getMockBuilder('React\Socket\ConnectionInterface')->getMock(); - $this->connector->expects($this->once())->method('connect')->with('www.example.com:80')->willReturn(\React\Promise\resolve($connection)); + $uri = new Uri('http://www.example.com/'); + $connectionManager = $this->getMockBuilder('React\Http\Io\ClientConnectionManager')->disableOriginalConstructor()->getMock(); + $connectionManager->expects($this->once())->method('connect')->with($uri)->willReturn(\React\Promise\resolve($connection)); - $requestData = new Request('GET', 'http://www.example.com/'); - $request = new ClientRequestStream($this->connector, $requestData); + $requestData = new Request('GET', $uri); + $request = new ClientRequestStream($connectionManager, $requestData); $connection->expects($this->atLeast(5))->method('on')->withConsecutive( array('drain', $this->identicalTo(array($request, 'handleDrain'))), @@ -57,26 +49,14 @@ public function requestShouldBindToStreamEventsAndUseconnector() $request->handleData("\r\nbody"); } - /** - * @test - */ - public function requestShouldConnectViaTlsIfUrlUsesHttpsScheme() - { - $this->connector->expects($this->once())->method('connect')->with('tls://www.example.com:443')->willReturn(new Promise(function () { })); - - $requestData = new Request('GET', 'https://www.example.com/'); - $request = new ClientRequestStream($this->connector, $requestData); - - $request->end(); - } - /** @test */ public function requestShouldEmitErrorIfConnectionFails() { - $this->connector->expects($this->once())->method('connect')->willReturn(\React\Promise\reject(new \RuntimeException())); + $connectionManager = $this->getMockBuilder('React\Http\Io\ClientConnectionManager')->disableOriginalConstructor()->getMock(); + $connectionManager->expects($this->once())->method('connect')->willReturn(\React\Promise\reject(new \RuntimeException())); $requestData = new Request('GET', 'http://www.example.com/'); - $request = new ClientRequestStream($this->connector, $requestData); + $request = new ClientRequestStream($connectionManager, $requestData); $request->on('error', $this->expectCallableOnceWith($this->isInstanceOf('RuntimeException'))); $request->on('close', $this->expectCallableOnce()); @@ -89,10 +69,11 @@ public function requestShouldEmitErrorIfConnectionClosesBeforeResponseIsParsed() { $connection = $this->getMockBuilder('React\Socket\ConnectionInterface')->getMock(); - $this->connector->expects($this->once())->method('connect')->with('www.example.com:80')->willReturn(\React\Promise\resolve($connection)); + $connectionManager = $this->getMockBuilder('React\Http\Io\ClientConnectionManager')->disableOriginalConstructor()->getMock(); + $connectionManager->expects($this->once())->method('connect')->willReturn(\React\Promise\resolve($connection)); $requestData = new Request('GET', 'http://www.example.com/'); - $request = new ClientRequestStream($this->connector, $requestData); + $request = new ClientRequestStream($connectionManager, $requestData); $request->on('error', $this->expectCallableOnceWith($this->isInstanceOf('RuntimeException'))); $request->on('close', $this->expectCallableOnce()); @@ -106,10 +87,11 @@ public function requestShouldEmitErrorIfConnectionEmitsError() { $connection = $this->getMockBuilder('React\Socket\ConnectionInterface')->getMock(); - $this->connector->expects($this->once())->method('connect')->with('www.example.com:80')->willReturn(\React\Promise\resolve($connection)); + $connectionManager = $this->getMockBuilder('React\Http\Io\ClientConnectionManager')->disableOriginalConstructor()->getMock(); + $connectionManager->expects($this->once())->method('connect')->willReturn(\React\Promise\resolve($connection)); $requestData = new Request('GET', 'http://www.example.com/'); - $request = new ClientRequestStream($this->connector, $requestData); + $request = new ClientRequestStream($connectionManager, $requestData); $request->on('error', $this->expectCallableOnceWith($this->isInstanceOf('Exception'))); $request->on('close', $this->expectCallableOnce()); @@ -123,10 +105,11 @@ public function requestShouldEmitErrorIfRequestParserThrowsException() { $connection = $this->getMockBuilder('React\Socket\ConnectionInterface')->getMock(); - $this->connector->expects($this->once())->method('connect')->with('www.example.com:80')->willReturn(\React\Promise\resolve($connection)); + $connectionManager = $this->getMockBuilder('React\Http\Io\ClientConnectionManager')->disableOriginalConstructor()->getMock(); + $connectionManager->expects($this->once())->method('connect')->willReturn(\React\Promise\resolve($connection)); $requestData = new Request('GET', 'http://www.example.com/'); - $request = new ClientRequestStream($this->connector, $requestData); + $request = new ClientRequestStream($connectionManager, $requestData); $request->on('error', $this->expectCallableOnceWith($this->isInstanceOf('InvalidArgumentException'))); $request->on('close', $this->expectCallableOnce()); @@ -135,48 +118,17 @@ public function requestShouldEmitErrorIfRequestParserThrowsException() $request->handleData("\r\n\r\n"); } - /** - * @test - */ - public function requestShouldEmitErrorIfUrlIsInvalid() - { - $this->connector->expects($this->never())->method('connect'); - - $requestData = new Request('GET', 'ftp://www.example.com'); - $request = new ClientRequestStream($this->connector, $requestData); - - $request->on('error', $this->expectCallableOnceWith($this->isInstanceOf('InvalidArgumentException'))); - $request->on('close', $this->expectCallableOnce()); - - $request->end(); - } - - /** - * @test - */ - public function requestShouldEmitErrorIfUrlHasNoScheme() - { - $this->connector->expects($this->never())->method('connect'); - - $requestData = new Request('GET', 'www.example.com'); - $request = new ClientRequestStream($this->connector, $requestData); - - $request->on('error', $this->expectCallableOnceWith($this->isInstanceOf('InvalidArgumentException'))); - $request->on('close', $this->expectCallableOnce()); - - $request->end(); - } - /** @test */ public function getRequestShouldSendAGetRequest() { $connection = $this->getMockBuilder('React\Socket\ConnectionInterface')->getMock(); $connection->expects($this->once())->method('write')->with("GET / HTTP/1.0\r\nHost: www.example.com\r\n\r\n"); - $this->connector->expects($this->once())->method('connect')->with('www.example.com:80')->willReturn(\React\Promise\resolve($connection)); + $connectionManager = $this->getMockBuilder('React\Http\Io\ClientConnectionManager')->disableOriginalConstructor()->getMock(); + $connectionManager->expects($this->once())->method('connect')->willReturn(\React\Promise\resolve($connection)); $requestData = new Request('GET', 'http://www.example.com/', array(), '', '1.0'); - $request = new ClientRequestStream($this->connector, $requestData); + $request = new ClientRequestStream($connectionManager, $requestData); $request->end(); } @@ -187,10 +139,11 @@ public function getHttp11RequestShouldSendAGetRequestWithGivenConnectionCloseHea $connection = $this->getMockBuilder('React\Socket\ConnectionInterface')->getMock(); $connection->expects($this->once())->method('write')->with("GET / HTTP/1.1\r\nHost: www.example.com\r\nConnection: close\r\n\r\n"); - $this->connector->expects($this->once())->method('connect')->with('www.example.com:80')->willReturn(\React\Promise\resolve($connection)); + $connectionManager = $this->getMockBuilder('React\Http\Io\ClientConnectionManager')->disableOriginalConstructor()->getMock(); + $connectionManager->expects($this->once())->method('connect')->willReturn(\React\Promise\resolve($connection)); $requestData = new Request('GET', 'http://www.example.com/', array('Connection' => 'close'), '', '1.1'); - $request = new ClientRequestStream($this->connector, $requestData); + $request = new ClientRequestStream($connectionManager, $requestData); $request->end(); } @@ -201,11 +154,12 @@ public function getOptionsAsteriskShouldSendAOptionsRequestAsteriskRequestTarget $connection = $this->getMockBuilder('React\Socket\ConnectionInterface')->getMock(); $connection->expects($this->once())->method('write')->with("OPTIONS * HTTP/1.1\r\nHost: www.example.com\r\nConnection: close\r\n\r\n"); - $this->connector->expects($this->once())->method('connect')->with('www.example.com:80')->willReturn(\React\Promise\resolve($connection)); + $connectionManager = $this->getMockBuilder('React\Http\Io\ClientConnectionManager')->disableOriginalConstructor()->getMock(); + $connectionManager->expects($this->once())->method('connect')->willReturn(\React\Promise\resolve($connection)); $requestData = new Request('OPTIONS', 'http://www.example.com/', array('Connection' => 'close'), '', '1.1'); $requestData = $requestData->withRequestTarget('*'); - $request = new ClientRequestStream($this->connector, $requestData); + $request = new ClientRequestStream($connectionManager, $requestData); $request->end(); } @@ -216,10 +170,11 @@ public function testStreamShouldEmitResponseWithEmptyBodyWhenResponseContainsCon $connection->expects($this->once())->method('write')->with("GET / HTTP/1.1\r\nHost: www.example.com\r\nConnection: close\r\n\r\n"); $connection->expects($this->once())->method('close'); - $this->connector->expects($this->once())->method('connect')->with('www.example.com:80')->willReturn(\React\Promise\resolve($connection)); + $connectionManager = $this->getMockBuilder('React\Http\Io\ClientConnectionManager')->disableOriginalConstructor()->getMock(); + $connectionManager->expects($this->once())->method('connect')->willReturn(\React\Promise\resolve($connection)); $requestData = new Request('GET', 'http://www.example.com/', array('Connection' => 'close'), '', '1.1'); - $request = new ClientRequestStream($this->connector, $requestData); + $request = new ClientRequestStream($connectionManager, $requestData); $that = $this; $request->on('response', function (ResponseInterface $response, ReadableStreamInterface $body) use ($that) { @@ -240,10 +195,11 @@ public function testStreamShouldEmitResponseWithEmptyBodyWhenResponseContainsSta $connection->expects($this->once())->method('write')->with("GET / HTTP/1.1\r\nHost: www.example.com\r\nConnection: close\r\n\r\n"); $connection->expects($this->once())->method('close'); - $this->connector->expects($this->once())->method('connect')->with('www.example.com:80')->willReturn(\React\Promise\resolve($connection)); + $connectionManager = $this->getMockBuilder('React\Http\Io\ClientConnectionManager')->disableOriginalConstructor()->getMock(); + $connectionManager->expects($this->once())->method('connect')->willReturn(\React\Promise\resolve($connection)); $requestData = new Request('GET', 'http://www.example.com/', array('Connection' => 'close'), '', '1.1'); - $request = new ClientRequestStream($this->connector, $requestData); + $request = new ClientRequestStream($connectionManager, $requestData); $that = $this; $request->on('response', function (ResponseInterface $response, ReadableStreamInterface $body) use ($that) { @@ -264,10 +220,11 @@ public function testStreamShouldEmitResponseWithEmptyBodyWhenResponseContainsSta $connection->expects($this->once())->method('write')->with("GET / HTTP/1.1\r\nHost: www.example.com\r\nConnection: close\r\n\r\n"); $connection->expects($this->once())->method('close'); - $this->connector->expects($this->once())->method('connect')->with('www.example.com:80')->willReturn(\React\Promise\resolve($connection)); + $connectionManager = $this->getMockBuilder('React\Http\Io\ClientConnectionManager')->disableOriginalConstructor()->getMock(); + $connectionManager->expects($this->once())->method('connect')->willReturn(\React\Promise\resolve($connection)); $requestData = new Request('GET', 'http://www.example.com/', array('Connection' => 'close'), '', '1.1'); - $request = new ClientRequestStream($this->connector, $requestData); + $request = new ClientRequestStream($connectionManager, $requestData); $that = $this; $request->on('response', function (ResponseInterface $response, ReadableStreamInterface $body) use ($that) { @@ -288,10 +245,11 @@ public function testStreamShouldEmitResponseWithEmptyBodyWhenRequestMethodIsHead $connection->expects($this->once())->method('write')->with("HEAD / HTTP/1.1\r\nHost: www.example.com\r\nConnection: close\r\n\r\n"); $connection->expects($this->once())->method('close'); - $this->connector->expects($this->once())->method('connect')->with('www.example.com:80')->willReturn(\React\Promise\resolve($connection)); + $connectionManager = $this->getMockBuilder('React\Http\Io\ClientConnectionManager')->disableOriginalConstructor()->getMock(); + $connectionManager->expects($this->once())->method('connect')->willReturn(\React\Promise\resolve($connection)); $requestData = new Request('HEAD', 'http://www.example.com/', array('Connection' => 'close'), '', '1.1'); - $request = new ClientRequestStream($this->connector, $requestData); + $request = new ClientRequestStream($connectionManager, $requestData); $that = $this; $request->on('response', function (ResponseInterface $response, ReadableStreamInterface $body) use ($that) { @@ -312,10 +270,11 @@ public function testStreamShouldEmitResponseWithStreamingBodyUntilEndWhenRespons $connection->expects($this->once())->method('write')->with("GET / HTTP/1.1\r\nHost: www.example.com\r\nConnection: close\r\n\r\n"); $connection->expects($this->once())->method('close'); - $this->connector->expects($this->once())->method('connect')->with('www.example.com:80')->willReturn(\React\Promise\resolve($connection)); + $connectionManager = $this->getMockBuilder('React\Http\Io\ClientConnectionManager')->disableOriginalConstructor()->getMock(); + $connectionManager->expects($this->once())->method('connect')->willReturn(\React\Promise\resolve($connection)); $requestData = new Request('GET', 'http://www.example.com/', array('Connection' => 'close'), '', '1.1'); - $request = new ClientRequestStream($this->connector, $requestData); + $request = new ClientRequestStream($connectionManager, $requestData); $that = $this; $request->on('response', function (ResponseInterface $response, ReadableStreamInterface $body) use ($that) { @@ -336,10 +295,11 @@ public function testStreamShouldEmitResponseWithStreamingBodyWithoutDataWhenResp $connection->expects($this->once())->method('write')->with("GET / HTTP/1.1\r\nHost: www.example.com\r\nConnection: close\r\n\r\n"); $connection->expects($this->never())->method('close'); - $this->connector->expects($this->once())->method('connect')->with('www.example.com:80')->willReturn(\React\Promise\resolve($connection)); + $connectionManager = $this->getMockBuilder('React\Http\Io\ClientConnectionManager')->disableOriginalConstructor()->getMock(); + $connectionManager->expects($this->once())->method('connect')->willReturn(\React\Promise\resolve($connection)); $requestData = new Request('GET', 'http://www.example.com/', array('Connection' => 'close'), '', '1.1'); - $request = new ClientRequestStream($this->connector, $requestData); + $request = new ClientRequestStream($connectionManager, $requestData); $that = $this; $request->on('response', function (ResponseInterface $response, ReadableStreamInterface $body) use ($that) { @@ -360,10 +320,11 @@ public function testStreamShouldEmitResponseWithStreamingBodyWithDataWithoutEndW $connection->expects($this->once())->method('write')->with("GET / HTTP/1.1\r\nHost: www.example.com\r\nConnection: close\r\n\r\n"); $connection->expects($this->never())->method('close'); - $this->connector->expects($this->once())->method('connect')->with('www.example.com:80')->willReturn(\React\Promise\resolve($connection)); + $connectionManager = $this->getMockBuilder('React\Http\Io\ClientConnectionManager')->disableOriginalConstructor()->getMock(); + $connectionManager->expects($this->once())->method('connect')->willReturn(\React\Promise\resolve($connection)); $requestData = new Request('GET', 'http://www.example.com/', array('Connection' => 'close'), '', '1.1'); - $request = new ClientRequestStream($this->connector, $requestData); + $request = new ClientRequestStream($connectionManager, $requestData); $that = $this; $request->on('response', function (ResponseInterface $response, ReadableStreamInterface $body) use ($that) { @@ -384,10 +345,11 @@ public function testStreamShouldEmitResponseWithStreamingBodyUntilEndWhenRespons $connection->expects($this->once())->method('write')->with("GET / HTTP/1.1\r\nHost: www.example.com\r\nConnection: close\r\n\r\n"); $connection->expects($this->once())->method('close'); - $this->connector->expects($this->once())->method('connect')->with('www.example.com:80')->willReturn(\React\Promise\resolve($connection)); + $connectionManager = $this->getMockBuilder('React\Http\Io\ClientConnectionManager')->disableOriginalConstructor()->getMock(); + $connectionManager->expects($this->once())->method('connect')->willReturn(\React\Promise\resolve($connection)); $requestData = new Request('GET', 'http://www.example.com/', array('Connection' => 'close'), '', '1.1'); - $request = new ClientRequestStream($this->connector, $requestData); + $request = new ClientRequestStream($connectionManager, $requestData); $that = $this; $request->on('response', function (ResponseInterface $response, ReadableStreamInterface $body) use ($that) { @@ -408,10 +370,11 @@ public function testStreamShouldEmitResponseWithStreamingBodyWithoutDataWhenResp $connection->expects($this->once())->method('write')->with("GET / HTTP/1.1\r\nHost: www.example.com\r\nConnection: close\r\n\r\n"); $connection->expects($this->never())->method('close'); - $this->connector->expects($this->once())->method('connect')->with('www.example.com:80')->willReturn(\React\Promise\resolve($connection)); + $connectionManager = $this->getMockBuilder('React\Http\Io\ClientConnectionManager')->disableOriginalConstructor()->getMock(); + $connectionManager->expects($this->once())->method('connect')->willReturn(\React\Promise\resolve($connection)); $requestData = new Request('GET', 'http://www.example.com/', array('Connection' => 'close'), '', '1.1'); - $request = new ClientRequestStream($this->connector, $requestData); + $request = new ClientRequestStream($connectionManager, $requestData); $that = $this; $request->on('response', function (ResponseInterface $response, ReadableStreamInterface $body) use ($that) { @@ -432,10 +395,11 @@ public function testStreamShouldEmitResponseWithStreamingBodyWithDataWithoutEndW $connection->expects($this->once())->method('write')->with("GET / HTTP/1.1\r\nHost: www.example.com\r\nConnection: close\r\n\r\n"); $connection->expects($this->never())->method('close'); - $this->connector->expects($this->once())->method('connect')->with('www.example.com:80')->willReturn(\React\Promise\resolve($connection)); + $connectionManager = $this->getMockBuilder('React\Http\Io\ClientConnectionManager')->disableOriginalConstructor()->getMock(); + $connectionManager->expects($this->once())->method('connect')->willReturn(\React\Promise\resolve($connection)); $requestData = new Request('GET', 'http://www.example.com/', array('Connection' => 'close'), '', '1.1'); - $request = new ClientRequestStream($this->connector, $requestData); + $request = new ClientRequestStream($connectionManager, $requestData); $that = $this; $request->on('response', function (ResponseInterface $response, ReadableStreamInterface $body) use ($that) { @@ -456,10 +420,11 @@ public function testStreamShouldEmitResponseWithStreamingBodyWithDataWithoutEndW $connection->expects($this->once())->method('write')->with("GET / HTTP/1.1\r\nHost: www.example.com\r\nConnection: close\r\n\r\n"); $connection->expects($this->never())->method('close'); - $this->connector->expects($this->once())->method('connect')->with('www.example.com:80')->willReturn(\React\Promise\resolve($connection)); + $connectionManager = $this->getMockBuilder('React\Http\Io\ClientConnectionManager')->disableOriginalConstructor()->getMock(); + $connectionManager->expects($this->once())->method('connect')->willReturn(\React\Promise\resolve($connection)); $requestData = new Request('GET', 'http://www.example.com/', array('Connection' => 'close'), '', '1.1'); - $request = new ClientRequestStream($this->connector, $requestData); + $request = new ClientRequestStream($connectionManager, $requestData); $that = $this; $request->on('response', function (ResponseInterface $response, ReadableStreamInterface $body) use ($that) { @@ -492,10 +457,11 @@ public function testStreamShouldEmitResponseWithStreamingBodyUntilEndWhenRespons return true; })); - $this->connector->expects($this->once())->method('connect')->with('www.example.com:80')->willReturn(\React\Promise\resolve($connection)); + $connectionManager = $this->getMockBuilder('React\Http\Io\ClientConnectionManager')->disableOriginalConstructor()->getMock(); + $connectionManager->expects($this->once())->method('connect')->willReturn(\React\Promise\resolve($connection)); $requestData = new Request('GET', 'http://www.example.com/', array('Connection' => 'close'), '', '1.1'); - $request = new ClientRequestStream($this->connector, $requestData); + $request = new ClientRequestStream($connectionManager, $requestData); $that = $this; $request->on('response', function (ResponseInterface $response, ReadableStreamInterface $body) use ($that) { @@ -519,10 +485,11 @@ public function postRequestShouldSendAPostRequest() $connection = $this->getMockBuilder('React\Socket\ConnectionInterface')->getMock(); $connection->expects($this->once())->method('write')->with($this->matchesRegularExpression("#^POST / HTTP/1\.0\r\nHost: www.example.com\r\n\r\nsome post data$#")); - $this->connector->expects($this->once())->method('connect')->with('www.example.com:80')->willReturn(\React\Promise\resolve($connection)); + $connectionManager = $this->getMockBuilder('React\Http\Io\ClientConnectionManager')->disableOriginalConstructor()->getMock(); + $connectionManager->expects($this->once())->method('connect')->willReturn(\React\Promise\resolve($connection)); $requestData = new Request('POST', 'http://www.example.com/', array(), '', '1.0'); - $request = new ClientRequestStream($this->connector, $requestData); + $request = new ClientRequestStream($connectionManager, $requestData); $request->end('some post data'); @@ -541,10 +508,11 @@ public function writeWithAPostRequestShouldSendToTheStream() array($this->identicalTo("data")) ); - $this->connector->expects($this->once())->method('connect')->with('www.example.com:80')->willReturn(\React\Promise\resolve($connection)); + $connectionManager = $this->getMockBuilder('React\Http\Io\ClientConnectionManager')->disableOriginalConstructor()->getMock(); + $connectionManager->expects($this->once())->method('connect')->willReturn(\React\Promise\resolve($connection)); $requestData = new Request('POST', 'http://www.example.com/', array(), '', '1.0'); - $request = new ClientRequestStream($this->connector, $requestData); + $request = new ClientRequestStream($connectionManager, $requestData); $request->write("some"); $request->write("post"); @@ -567,10 +535,11 @@ public function writeWithAPostRequestShouldSendBodyAfterHeadersAndEmitDrainEvent ); $deferred = new Deferred(); - $this->connector->expects($this->once())->method('connect')->with('www.example.com:80')->willReturn($deferred->promise()); + $connectionManager = $this->getMockBuilder('React\Http\Io\ClientConnectionManager')->disableOriginalConstructor()->getMock(); + $connectionManager->expects($this->once())->method('connect')->willReturn($deferred->promise()); $requestData = new Request('POST', 'http://www.example.com/', array(), '', '1.0'); - $request = new ClientRequestStream($this->connector, $requestData); + $request = new ClientRequestStream($connectionManager, $requestData); $this->assertFalse($request->write("some")); $this->assertFalse($request->write("post")); @@ -604,10 +573,11 @@ public function writeWithAPostRequestShouldForwardDrainEventIfFirstChunkExceedsB ); $deferred = new Deferred(); - $this->connector->expects($this->once())->method('connect')->with('www.example.com:80')->willReturn($deferred->promise()); + $connectionManager = $this->getMockBuilder('React\Http\Io\ClientConnectionManager')->disableOriginalConstructor()->getMock(); + $connectionManager->expects($this->once())->method('connect')->willReturn($deferred->promise()); $requestData = new Request('POST', 'http://www.example.com/', array(), '', '1.0'); - $request = new ClientRequestStream($this->connector, $requestData); + $request = new ClientRequestStream($connectionManager, $requestData); $this->assertFalse($request->write("some")); $this->assertFalse($request->write("post")); @@ -636,10 +606,11 @@ public function pipeShouldPipeDataIntoTheRequestBody() array($this->identicalTo("data")) ); - $this->connector->expects($this->once())->method('connect')->with('www.example.com:80')->willReturn(\React\Promise\resolve($connection)); + $connectionManager = $this->getMockBuilder('React\Http\Io\ClientConnectionManager')->disableOriginalConstructor()->getMock(); + $connectionManager->expects($this->once())->method('connect')->willReturn(\React\Promise\resolve($connection)); $requestData = new Request('POST', 'http://www.example.com/', array(), '', '1.0'); - $request = new ClientRequestStream($this->connector, $requestData); + $request = new ClientRequestStream($connectionManager, $requestData); $loop = $this ->getMockBuilder('React\EventLoop\LoopInterface') @@ -663,13 +634,11 @@ public function pipeShouldPipeDataIntoTheRequestBody() */ public function writeShouldStartConnecting() { - $this->connector->expects($this->once()) - ->method('connect') - ->with('www.example.com:80') - ->willReturn(new Promise(function () { })); + $connectionManager = $this->getMockBuilder('React\Http\Io\ClientConnectionManager')->disableOriginalConstructor()->getMock(); + $connectionManager->expects($this->once())->method('connect')->willReturn(new Promise(function () { })); $requestData = new Request('POST', 'http://www.example.com/'); - $request = new ClientRequestStream($this->connector, $requestData); + $request = new ClientRequestStream($connectionManager, $requestData); $request->write('test'); } @@ -679,10 +648,11 @@ public function writeShouldStartConnecting() */ public function endShouldStartConnectingAndChangeStreamIntoNonWritableMode() { - $this->connector->expects($this->once())->method('connect')->with('www.example.com:80')->willReturn(new Promise(function () { })); + $connectionManager = $this->getMockBuilder('React\Http\Io\ClientConnectionManager')->disableOriginalConstructor()->getMock(); + $connectionManager->expects($this->once())->method('connect')->willReturn(new Promise(function () { })); $requestData = new Request('POST', 'http://www.example.com/'); - $request = new ClientRequestStream($this->connector, $requestData); + $request = new ClientRequestStream($connectionManager, $requestData); $request->end(); @@ -694,8 +664,10 @@ public function endShouldStartConnectingAndChangeStreamIntoNonWritableMode() */ public function closeShouldEmitCloseEvent() { + $connectionManager = $this->getMockBuilder('React\Http\Io\ClientConnectionManager')->disableOriginalConstructor()->getMock(); + $requestData = new Request('POST', 'http://www.example.com/'); - $request = new ClientRequestStream($this->connector, $requestData); + $request = new ClientRequestStream($connectionManager, $requestData); $request->on('close', $this->expectCallableOnce()); $request->close(); @@ -706,8 +678,10 @@ public function closeShouldEmitCloseEvent() */ public function writeAfterCloseReturnsFalse() { + $connectionManager = $this->getMockBuilder('React\Http\Io\ClientConnectionManager')->disableOriginalConstructor()->getMock(); + $requestData = new Request('POST', 'http://www.example.com/'); - $request = new ClientRequestStream($this->connector, $requestData); + $request = new ClientRequestStream($connectionManager, $requestData); $request->close(); @@ -720,10 +694,11 @@ public function writeAfterCloseReturnsFalse() */ public function endAfterCloseIsNoOp() { - $this->connector->expects($this->never())->method('connect'); + $connectionManager = $this->getMockBuilder('React\Http\Io\ClientConnectionManager')->disableOriginalConstructor()->getMock(); + $connectionManager->expects($this->never())->method('connect'); $requestData = new Request('POST', 'http://www.example.com/'); - $request = new ClientRequestStream($this->connector, $requestData); + $request = new ClientRequestStream($connectionManager, $requestData); $request->close(); $request->end(); @@ -737,10 +712,11 @@ public function closeShouldCancelPendingConnectionAttempt() $promise = new Promise(function () {}, function () { throw new \RuntimeException(); }); - $this->connector->expects($this->once())->method('connect')->with('www.example.com:80')->willReturn($promise); + $connectionManager = $this->getMockBuilder('React\Http\Io\ClientConnectionManager')->disableOriginalConstructor()->getMock(); + $connectionManager->expects($this->once())->method('connect')->willReturn($promise); $requestData = new Request('POST', 'http://www.example.com/'); - $request = new ClientRequestStream($this->connector, $requestData); + $request = new ClientRequestStream($connectionManager, $requestData); $request->end(); @@ -754,8 +730,10 @@ public function closeShouldCancelPendingConnectionAttempt() /** @test */ public function requestShouldRemoveAllListenerAfterClosed() { + $connectionManager = $this->getMockBuilder('React\Http\Io\ClientConnectionManager')->disableOriginalConstructor()->getMock(); + $requestData = new Request('GET', 'http://www.example.com/'); - $request = new ClientRequestStream($this->connector, $requestData); + $request = new ClientRequestStream($connectionManager, $requestData); $request->on('close', function () {}); $this->assertCount(1, $request->listeners('close')); @@ -769,10 +747,11 @@ public function multivalueHeader() { $connection = $this->getMockBuilder('React\Socket\ConnectionInterface')->getMock(); - $this->connector->expects($this->once())->method('connect')->with('www.example.com:80')->willReturn(\React\Promise\resolve($connection)); + $connectionManager = $this->getMockBuilder('React\Http\Io\ClientConnectionManager')->disableOriginalConstructor()->getMock(); + $connectionManager->expects($this->once())->method('connect')->willReturn(\React\Promise\resolve($connection)); $requestData = new Request('GET', 'http://www.example.com/'); - $request = new ClientRequestStream($this->connector, $requestData); + $request = new ClientRequestStream($connectionManager, $requestData); $response = null; $request->on('response', $this->expectCallableOnce()); diff --git a/tests/Io/SenderTest.php b/tests/Io/SenderTest.php index 4ef06442..220424e9 100644 --- a/tests/Io/SenderTest.php +++ b/tests/Io/SenderTest.php @@ -4,6 +4,7 @@ use Psr\Http\Message\RequestInterface; use React\Http\Client\Client as HttpClient; +use React\Http\Io\ClientConnectionManager; use React\Http\Io\ReadableBodyStream; use React\Http\Io\Sender; use React\Http\Message\Request; @@ -13,19 +14,11 @@ class SenderTest extends TestCase { - private $loop; - - /** - * @before - */ - public function setUpLoop() - { - $this->loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); - } - public function testCreateFromLoop() { - $sender = Sender::createFromLoop($this->loop, null); + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + + $sender = Sender::createFromLoop($loop, null); $this->assertInstanceOf('React\Http\Io\Sender', $sender); } @@ -35,7 +28,7 @@ public function testSenderRejectsInvalidUri() $connector = $this->getMockBuilder('React\Socket\ConnectorInterface')->getMock(); $connector->expects($this->never())->method('connect'); - $sender = new Sender(new HttpClient($this->loop, $connector)); + $sender = new Sender(new HttpClient(new ClientConnectionManager($connector))); $request = new Request('GET', 'www.google.com'); @@ -54,7 +47,7 @@ public function testSenderConnectorRejection() $connector = $this->getMockBuilder('React\Socket\ConnectorInterface')->getMock(); $connector->expects($this->once())->method('connect')->willReturn(Promise\reject(new \RuntimeException('Rejected'))); - $sender = new Sender(new HttpClient($this->loop, $connector)); + $sender = new Sender(new HttpClient(new ClientConnectionManager($connector))); $request = new Request('GET', 'http://www.google.com/'); @@ -381,7 +374,7 @@ public function testCancelRequestWillCancelConnector() $connector = $this->getMockBuilder('React\Socket\ConnectorInterface')->getMock(); $connector->expects($this->once())->method('connect')->willReturn($promise); - $sender = new Sender(new HttpClient($this->loop, $connector)); + $sender = new Sender(new HttpClient(new ClientConnectionManager($connector))); $request = new Request('GET', 'http://www.google.com/'); @@ -404,7 +397,7 @@ public function testCancelRequestWillCloseConnection() $connector = $this->getMockBuilder('React\Socket\ConnectorInterface')->getMock(); $connector->expects($this->once())->method('connect')->willReturn(Promise\resolve($connection)); - $sender = new Sender(new HttpClient($this->loop, $connector)); + $sender = new Sender(new HttpClient(new ClientConnectionManager($connector))); $request = new Request('GET', 'http://www.google.com/'); From ab3bfee58c16cfb51691c136f0ec2f5e53268e79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Tue, 17 Jan 2023 10:24:28 +0100 Subject: [PATCH 31/58] Prepare to hand back connections when keep-alive is possible --- src/Io/ClientConnectionManager.php | 8 + src/Io/ClientRequestStream.php | 44 +++- tests/Client/FunctionalIntegrationTest.php | 25 +++ tests/Io/ClientConnectionManagerTest.php | 12 ++ tests/Io/ClientRequestStreamTest.php | 234 +++++++++++++++++++++ 5 files changed, 320 insertions(+), 3 deletions(-) diff --git a/src/Io/ClientConnectionManager.php b/src/Io/ClientConnectionManager.php index 51f937e4..eda2ea44 100644 --- a/src/Io/ClientConnectionManager.php +++ b/src/Io/ClientConnectionManager.php @@ -42,4 +42,12 @@ public function connect(UriInterface $uri) return $this->connector->connect(($scheme === 'https' ? 'tls://' : '') . $uri->getHost() . ':' . $port); } + + /** + * @return void + */ + public function handBack(ConnectionInterface $connection) + { + $connection->close(); + } } diff --git a/src/Io/ClientRequestStream.php b/src/Io/ClientRequestStream.php index e5eaf298..e9716b45 100644 --- a/src/Io/ClientRequestStream.php +++ b/src/Io/ClientRequestStream.php @@ -3,6 +3,7 @@ namespace React\Http\Io; use Evenement\EventEmitter; +use Psr\Http\Message\MessageInterface; use Psr\Http\Message\RequestInterface; use React\Http\Message\Response; use React\Socket\ConnectionInterface; @@ -171,11 +172,20 @@ public function handleData($data) $this->connection = null; $this->buffer = ''; - // take control over connection handling and close connection once response body closes + // take control over connection handling and check if we can reuse the connection once response body closes $that = $this; + $request = $this->request; + $connectionManager = $this->connectionManager; + $successfulEndReceived = false; $input = $body = new CloseProtectionStream($connection); - $input->on('close', function () use ($connection, $that) { - $connection->close(); + $input->on('close', function () use ($connection, $that, $connectionManager, $request, $response, &$successfulEndReceived) { + // only reuse connection after successful response and both request and response allow keep alive + if ($successfulEndReceived && $connection->isReadable() && $that->hasMessageKeepAliveEnabled($response) && $that->hasMessageKeepAliveEnabled($request)) { + $connectionManager->handBack($connection); + } else { + $connection->close(); + } + $that->close(); }); @@ -190,6 +200,9 @@ public function handleData($data) $length = (int) $response->getHeaderLine('Content-Length'); } $response = $response->withBody($body = new ReadableBodyStream($body, $length)); + $body->on('end', function () use (&$successfulEndReceived) { + $successfulEndReceived = true; + }); // emit response with streaming response body (see `Sender`) $this->emit('response', array($response, $body)); @@ -249,4 +262,29 @@ public function close() $this->emit('close'); $this->removeAllListeners(); } + + /** + * @internal + * @return bool + * @link https://www.rfc-editor.org/rfc/rfc9112#section-9.3 + * @link https://www.rfc-editor.org/rfc/rfc7230#section-6.1 + */ + public function hasMessageKeepAliveEnabled(MessageInterface $message) + { + $connectionOptions = \RingCentral\Psr7\normalize_header(\strtolower($message->getHeaderLine('Connection'))); + + if (\in_array('close', $connectionOptions, true)) { + return false; + } + + if ($message->getProtocolVersion() === '1.1') { + return true; + } + + if (\in_array('keep-alive', $connectionOptions, true)) { + return true; + } + + return false; + } } diff --git a/tests/Client/FunctionalIntegrationTest.php b/tests/Client/FunctionalIntegrationTest.php index d5015fd1..1c37d897 100644 --- a/tests/Client/FunctionalIntegrationTest.php +++ b/tests/Client/FunctionalIntegrationTest.php @@ -7,6 +7,7 @@ use React\Http\Io\ClientConnectionManager; use React\Http\Message\Request; use React\Promise\Deferred; +use React\Promise\Promise; use React\Promise\Stream; use React\Socket\ConnectionInterface; use React\Socket\Connector; @@ -55,6 +56,30 @@ public function testRequestToLocalhostEmitsSingleRemoteConnection() \React\Async\await(\React\Promise\Timer\timeout($promise, self::TIMEOUT_LOCAL)); } + public function testRequestToLocalhostWillConnectAndCloseConnectionAfterResponseUntilKeepAliveIsActuallySupported() + { + $socket = new SocketServer('127.0.0.1:0'); + $socket->on('connection', $this->expectCallableOnce()); + + $promise = new Promise(function ($resolve) use ($socket) { + $socket->on('connection', function (ConnectionInterface $conn) use ($socket, $resolve) { + $conn->write("HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nOK"); + $conn->on('close', function () use ($resolve) { + $resolve(null); + }); + $socket->close(); + }); + }); + $port = parse_url(https://github.com/$socket-%3EgetAddress(), PHP_URL_PORT); + + $client = new Client(new ClientConnectionManager(new Connector())); + $request = $client->request(new Request('GET', 'http://localhost/' . $port, array(), '', '1.1')); + + $request->end(); + + \React\Async\await(\React\Promise\Timer\timeout($promise, self::TIMEOUT_LOCAL)); + } + public function testRequestLegacyHttpServerWithOnlyLineFeedReturnsSuccessfulResponse() { $socket = new SocketServer('127.0.0.1:0'); diff --git a/tests/Io/ClientConnectionManagerTest.php b/tests/Io/ClientConnectionManagerTest.php index 5774e47d..143676e4 100644 --- a/tests/Io/ClientConnectionManagerTest.php +++ b/tests/Io/ClientConnectionManagerTest.php @@ -80,4 +80,16 @@ public function testConnectWithoutSchemeShouldRejectWithException() $this->assertInstanceOf('InvalidArgumentException', $exception); $this->assertEquals('Invalid request URL given', $exception->getMessage()); } + + public function testHandBackWillCloseGivenConnectionUntilKeepAliveIsActuallySupported() + { + $connector = $this->getMockBuilder('React\Socket\ConnectorInterface')->getMock(); + + $connectionManager = new ClientConnectionManager($connector); + + $connection = $this->getMockBuilder('React\Socket\ConnectionInterface')->getMock(); + $connection->expects($this->once())->method('close'); + + $connectionManager->handBack($connection); + } } diff --git a/tests/Io/ClientRequestStreamTest.php b/tests/Io/ClientRequestStreamTest.php index da26b4e5..d257994b 100644 --- a/tests/Io/ClientRequestStreamTest.php +++ b/tests/Io/ClientRequestStreamTest.php @@ -479,6 +479,240 @@ public function testStreamShouldEmitResponseWithStreamingBodyUntilEndWhenRespons call_user_func($endEvent); // $endEvent() (PHP 5.4+) } + public function testStreamShouldReuseConnectionForHttp11ByDefault() + { + $connection = $this->getMockBuilder('React\Socket\ConnectionInterface')->getMock(); + $connection->expects($this->once())->method('write')->with("GET / HTTP/1.1\r\nHost: www.example.com\r\n\r\n"); + $connection->expects($this->once())->method('isReadable')->willReturn(true); + $connection->expects($this->never())->method('close'); + + $connectionManager = $this->getMockBuilder('React\Http\Io\ClientConnectionManager')->disableOriginalConstructor()->getMock(); + $connectionManager->expects($this->once())->method('connect')->willReturn(\React\Promise\resolve($connection)); + $connectionManager->expects($this->once())->method('handBack')->with($connection); + + $requestData = new Request('GET', 'http://www.example.com/', array(), '', '1.1'); + $request = new ClientRequestStream($connectionManager, $requestData); + + $request->on('close', $this->expectCallableOnce()); + + $request->end(); + + $request->handleData("HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n"); + } + + public function testStreamShouldNotReuseConnectionWhenResponseContainsConnectionClose() + { + $connection = $this->getMockBuilder('React\Socket\ConnectionInterface')->getMock(); + $connection->expects($this->once())->method('write')->with("GET / HTTP/1.1\r\nHost: www.example.com\r\n\r\n"); + $connection->expects($this->once())->method('isReadable')->willReturn(true); + $connection->expects($this->once())->method('close'); + + $connectionManager = $this->getMockBuilder('React\Http\Io\ClientConnectionManager')->disableOriginalConstructor()->getMock(); + $connectionManager->expects($this->once())->method('connect')->willReturn(\React\Promise\resolve($connection)); + + $requestData = new Request('GET', 'http://www.example.com/', array(), '', '1.1'); + $request = new ClientRequestStream($connectionManager, $requestData); + + $request->on('close', $this->expectCallableOnce()); + + $request->end(); + + $request->handleData("HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); + } + + public function testStreamShouldNotReuseConnectionWhenRequestContainsConnectionCloseWithAdditionalOptions() + { + $connection = $this->getMockBuilder('React\Socket\ConnectionInterface')->getMock(); + $connection->expects($this->once())->method('write')->with("GET / HTTP/1.1\r\nHost: www.example.com\r\nConnection: FOO, CLOSE, BAR\r\n\r\n"); + $connection->expects($this->once())->method('isReadable')->willReturn(true); + $connection->expects($this->once())->method('close'); + + $connectionManager = $this->getMockBuilder('React\Http\Io\ClientConnectionManager')->disableOriginalConstructor()->getMock(); + $connectionManager->expects($this->once())->method('connect')->willReturn(\React\Promise\resolve($connection)); + + $requestData = new Request('GET', 'http://www.example.com/', array('Connection' => 'FOO, CLOSE, BAR'), '', '1.1'); + $request = new ClientRequestStream($connectionManager, $requestData); + + $request->on('close', $this->expectCallableOnce()); + + $request->end(); + + $request->handleData("HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: Foo, Close, Bar\r\n\r\n"); + } + + public function testStreamShouldNotReuseConnectionForHttp10ByDefault() + { + $connection = $this->getMockBuilder('React\Socket\ConnectionInterface')->getMock(); + $connection->expects($this->once())->method('write')->with("GET / HTTP/1.0\r\nHost: www.example.com\r\n\r\n"); + $connection->expects($this->once())->method('isReadable')->willReturn(true); + $connection->expects($this->once())->method('close'); + + $connectionManager = $this->getMockBuilder('React\Http\Io\ClientConnectionManager')->disableOriginalConstructor()->getMock(); + $connectionManager->expects($this->once())->method('connect')->willReturn(\React\Promise\resolve($connection)); + + $requestData = new Request('GET', 'http://www.example.com/', array(), '', '1.0'); + $request = new ClientRequestStream($connectionManager, $requestData); + + $request->on('close', $this->expectCallableOnce()); + + $request->end(); + + $request->handleData("HTTP/1.0 200 OK\r\nContent-Length: 0\r\n\r\n"); + } + + public function testStreamShouldReuseConnectionForHttp10WhenBothRequestAndResponseContainConnectionKeepAlive() + { + $connection = $this->getMockBuilder('React\Socket\ConnectionInterface')->getMock(); + $connection->expects($this->once())->method('write')->with("GET / HTTP/1.0\r\nHost: www.example.com\r\nConnection: keep-alive\r\n\r\n"); + $connection->expects($this->once())->method('isReadable')->willReturn(true); + $connection->expects($this->never())->method('close'); + + $connectionManager = $this->getMockBuilder('React\Http\Io\ClientConnectionManager')->disableOriginalConstructor()->getMock(); + $connectionManager->expects($this->once())->method('connect')->willReturn(\React\Promise\resolve($connection)); + $connectionManager->expects($this->once())->method('handBack')->with($connection); + + $requestData = new Request('GET', 'http://www.example.com/', array('Connection' => 'keep-alive'), '', '1.0'); + $request = new ClientRequestStream($connectionManager, $requestData); + + $request->on('close', $this->expectCallableOnce()); + + $request->end(); + + $request->handleData("HTTP/1.0 200 OK\r\nContent-Length: 0\r\nConnection: keep-alive\r\n\r\n"); + } + + public function testStreamShouldReuseConnectionForHttp10WhenBothRequestAndResponseContainConnectionKeepAliveWithAdditionalOptions() + { + $connection = $this->getMockBuilder('React\Socket\ConnectionInterface')->getMock(); + $connection->expects($this->once())->method('write')->with("GET / HTTP/1.0\r\nHost: www.example.com\r\nConnection: FOO, KEEP-ALIVE, BAR\r\n\r\n"); + $connection->expects($this->once())->method('isReadable')->willReturn(true); + $connection->expects($this->never())->method('close'); + + $connectionManager = $this->getMockBuilder('React\Http\Io\ClientConnectionManager')->disableOriginalConstructor()->getMock(); + $connectionManager->expects($this->once())->method('connect')->willReturn(\React\Promise\resolve($connection)); + $connectionManager->expects($this->once())->method('handBack')->with($connection); + + $requestData = new Request('GET', 'http://www.example.com/', array('Connection' => 'FOO, KEEP-ALIVE, BAR'), '', '1.0'); + $request = new ClientRequestStream($connectionManager, $requestData); + + $request->on('close', $this->expectCallableOnce()); + + $request->end(); + + $request->handleData("HTTP/1.0 200 OK\r\nContent-Length: 0\r\nConnection: Foo, Keep-Alive, Bar\r\n\r\n"); + } + + public function testStreamShouldNotReuseConnectionWhenResponseContainsNoContentLengthAndResponseBodyTerminatedByConnectionEndEvent() + { + $connection = $this->getMockBuilder('React\Socket\ConnectionInterface')->getMock(); + $connection->expects($this->once())->method('write')->with("GET / HTTP/1.1\r\nHost: www.example.com\r\n\r\n"); + $connection->expects($this->once())->method('isReadable')->willReturn(false); + $connection->expects($this->once())->method('close'); + + $endEvent = null; + $eventName = null; + $connection->expects($this->any())->method('on')->with($this->callback(function ($name) use (&$eventName) { + $eventName = $name; + return true; + }), $this->callback(function ($cb) use (&$endEvent, &$eventName) { + if ($eventName === 'end') { + $endEvent = $cb; + } + return true; + })); + + $connectionManager = $this->getMockBuilder('React\Http\Io\ClientConnectionManager')->disableOriginalConstructor()->getMock(); + $connectionManager->expects($this->once())->method('connect')->willReturn(\React\Promise\resolve($connection)); + + $requestData = new Request('GET', 'http://www.example.com/', array(), '', '1.1'); + $request = new ClientRequestStream($connectionManager, $requestData); + + $request->on('close', $this->expectCallableOnce()); + + $request->end(); + + $request->handleData("HTTP/1.1 200 OK\r\n\r\n"); + + $this->assertNotNull($endEvent); + call_user_func($endEvent); // $endEvent() (PHP 5.4+) + } + + public function testStreamShouldNotReuseConnectionWhenResponseContainsContentLengthButIsTerminatedByUnexpectedCloseEvent() + { + $connection = $this->getMockBuilder('React\Socket\ConnectionInterface')->getMock(); + $connection->expects($this->once())->method('write')->with("GET / HTTP/1.1\r\nHost: www.example.com\r\n\r\n"); + $connection->expects($this->atMost(1))->method('isReadable')->willReturn(false); + $connection->expects($this->once())->method('close'); + + $closeEvent = null; + $eventName = null; + $connection->expects($this->any())->method('on')->with($this->callback(function ($name) use (&$eventName) { + $eventName = $name; + return true; + }), $this->callback(function ($cb) use (&$closeEvent, &$eventName) { + if ($eventName === 'close') { + $closeEvent = $cb; + } + return true; + })); + + $connectionManager = $this->getMockBuilder('React\Http\Io\ClientConnectionManager')->disableOriginalConstructor()->getMock(); + $connectionManager->expects($this->once())->method('connect')->willReturn(\React\Promise\resolve($connection)); + + $requestData = new Request('GET', 'http://www.example.com/', array(), '', '1.1'); + $request = new ClientRequestStream($connectionManager, $requestData); + + $request->on('close', $this->expectCallableOnce()); + + $request->end(); + + $request->handleData("HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n"); + + $this->assertNotNull($closeEvent); + call_user_func($closeEvent); // $closeEvent() (PHP 5.4+) + } + + public function testStreamShouldReuseConnectionWhenResponseContainsTransferEncodingChunkedAndResponseBody() + { + $connection = $this->getMockBuilder('React\Socket\ConnectionInterface')->getMock(); + $connection->expects($this->once())->method('write')->with("GET / HTTP/1.1\r\nHost: www.example.com\r\n\r\n"); + $connection->expects($this->once())->method('isReadable')->willReturn(true); + $connection->expects($this->never())->method('close'); + + $connectionManager = $this->getMockBuilder('React\Http\Io\ClientConnectionManager')->disableOriginalConstructor()->getMock(); + $connectionManager->expects($this->once())->method('connect')->willReturn(\React\Promise\resolve($connection)); + $connectionManager->expects($this->once())->method('handBack')->with($connection); + + $requestData = new Request('GET', 'http://www.example.com/', array(), '', '1.1'); + $request = new ClientRequestStream($connectionManager, $requestData); + + $request->on('close', $this->expectCallableOnce()); + + $request->end(); + + $request->handleData("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n2\r\nOK\r\n0\r\n\r\n"); + } + + public function testStreamShouldNotReuseConnectionWhenResponseContainsTransferEncodingChunkedAndResponseBodyContainsInvalidData() + { + $connection = $this->getMockBuilder('React\Socket\ConnectionInterface')->getMock(); + $connection->expects($this->once())->method('write')->with("GET / HTTP/1.1\r\nHost: www.example.com\r\n\r\n"); + $connection->expects($this->atMost(1))->method('isReadable')->willReturn(true); + $connection->expects($this->once())->method('close'); + + $connectionManager = $this->getMockBuilder('React\Http\Io\ClientConnectionManager')->disableOriginalConstructor()->getMock(); + $connectionManager->expects($this->once())->method('connect')->willReturn(\React\Promise\resolve($connection)); + + $requestData = new Request('GET', 'http://www.example.com/', array(), '', '1.1'); + $request = new ClientRequestStream($connectionManager, $requestData); + + $request->on('close', $this->expectCallableOnce()); + + $request->end(); + + $request->handleData("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\nINVALID\r\n"); + } + /** @test */ public function postRequestShouldSendAPostRequest() { From 28943f443a54ba4c2576ce68ee76274d190b6162 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Wed, 11 Jan 2023 13:28:15 +0100 Subject: [PATCH 32/58] Reuse existing connections for HTTP keep-alive --- src/Io/ClientConnectionManager.php | 90 +++++- src/Io/ClientRequestStream.php | 2 +- src/Io/Sender.php | 2 +- tests/Client/FunctionalIntegrationTest.php | 45 ++- tests/Io/ClientConnectionManagerTest.php | 310 ++++++++++++++++++++- tests/Io/ClientRequestStreamTest.php | 8 +- tests/Io/SenderTest.php | 23 +- 7 files changed, 448 insertions(+), 32 deletions(-) diff --git a/src/Io/ClientConnectionManager.php b/src/Io/ClientConnectionManager.php index eda2ea44..faac98b6 100644 --- a/src/Io/ClientConnectionManager.php +++ b/src/Io/ClientConnectionManager.php @@ -3,6 +3,8 @@ namespace React\Http\Io; use Psr\Http\Message\UriInterface; +use React\EventLoop\LoopInterface; +use React\EventLoop\TimerInterface; use React\Promise\PromiseInterface; use React\Socket\ConnectionInterface; use React\Socket\ConnectorInterface; @@ -18,9 +20,28 @@ class ClientConnectionManager /** @var ConnectorInterface */ private $connector; - public function __construct(ConnectorInterface $connector) + /** @var LoopInterface */ + private $loop; + + /** @var string[] */ + private $idleUris = array(); + + /** @var ConnectionInterface[] */ + private $idleConnections = array(); + + /** @var TimerInterface[] */ + private $idleTimers = array(); + + /** @var \Closure[] */ + private $idleStreamHandlers = array(); + + /** @var float */ + private $maximumTimeToKeepAliveIdleConnection = 0.001; + + public function __construct(ConnectorInterface $connector, LoopInterface $loop) { $this->connector = $connector; + $this->loop = $loop; } /** @@ -39,15 +60,78 @@ public function connect(UriInterface $uri) if ($port === null) { $port = $scheme === 'https' ? 443 : 80; } + $uri = ($scheme === 'https' ? 'tls://' : '') . $uri->getHost() . ':' . $port; + + // Reuse idle connection for same URI if available + foreach ($this->idleConnections as $id => $connection) { + if ($this->idleUris[$id] === $uri) { + assert($this->idleStreamHandlers[$id] instanceof \Closure); + $connection->removeListener('close', $this->idleStreamHandlers[$id]); + $connection->removeListener('data', $this->idleStreamHandlers[$id]); + $connection->removeListener('error', $this->idleStreamHandlers[$id]); + + assert($this->idleTimers[$id] instanceof TimerInterface); + $this->loop->cancelTimer($this->idleTimers[$id]); + unset($this->idleUris[$id], $this->idleConnections[$id], $this->idleTimers[$id], $this->idleStreamHandlers[$id]); - return $this->connector->connect(($scheme === 'https' ? 'tls://' : '') . $uri->getHost() . ':' . $port); + return \React\Promise\resolve($connection); + } + } + + // Create new connection if no idle connection to same URI is available + return $this->connector->connect($uri); } /** + * Hands back an idle connection to the connection manager for possible future reuse. + * * @return void */ - public function handBack(ConnectionInterface $connection) + public function keepAlive(UriInterface $uri, ConnectionInterface $connection) { + $scheme = $uri->getScheme(); + assert($scheme === 'https' || $scheme === 'http'); + + $port = $uri->getPort(); + if ($port === null) { + $port = $scheme === 'https' ? 443 : 80; + } + + $this->idleUris[] = ($scheme === 'https' ? 'tls://' : '') . $uri->getHost() . ':' . $port; + $this->idleConnections[] = $connection; + + $that = $this; + $cleanUp = function () use ($connection, $that) { + // call public method to support legacy PHP 5.3 + $that->cleanUpConnection($connection); + }; + + // clean up and close connection when maximum time to keep-alive idle connection has passed + $this->idleTimers[] = $this->loop->addTimer($this->maximumTimeToKeepAliveIdleConnection, $cleanUp); + + // clean up and close connection when unexpected close/data/error event happens during idle time + $this->idleStreamHandlers[] = $cleanUp; + $connection->on('close', $cleanUp); + $connection->on('data', $cleanUp); + $connection->on('error', $cleanUp); + } + + /** + * @internal + * @return void + */ + public function cleanUpConnection(ConnectionInterface $connection) // private (PHP 5.4+) + { + $id = \array_search($connection, $this->idleConnections, true); + if ($id === false) { + return; + } + + assert(\is_int($id)); + assert($this->idleTimers[$id] instanceof TimerInterface); + $this->loop->cancelTimer($this->idleTimers[$id]); + unset($this->idleUris[$id], $this->idleConnections[$id], $this->idleTimers[$id], $this->idleStreamHandlers[$id]); + $connection->close(); } } diff --git a/src/Io/ClientRequestStream.php b/src/Io/ClientRequestStream.php index e9716b45..0220f008 100644 --- a/src/Io/ClientRequestStream.php +++ b/src/Io/ClientRequestStream.php @@ -181,7 +181,7 @@ public function handleData($data) $input->on('close', function () use ($connection, $that, $connectionManager, $request, $response, &$successfulEndReceived) { // only reuse connection after successful response and both request and response allow keep alive if ($successfulEndReceived && $connection->isReadable() && $that->hasMessageKeepAliveEnabled($response) && $that->hasMessageKeepAliveEnabled($request)) { - $connectionManager->handBack($connection); + $connectionManager->keepAlive($request->getUri(), $connection); } else { $connection->close(); } diff --git a/src/Io/Sender.php b/src/Io/Sender.php index 68c09322..c117d87d 100644 --- a/src/Io/Sender.php +++ b/src/Io/Sender.php @@ -54,7 +54,7 @@ public static function createFromLoop(LoopInterface $loop, ConnectorInterface $c $connector = new Connector(array(), $loop); } - return new self(new HttpClient(new ClientConnectionManager($connector))); + return new self(new HttpClient(new ClientConnectionManager($connector, $loop))); } private $http; diff --git a/tests/Client/FunctionalIntegrationTest.php b/tests/Client/FunctionalIntegrationTest.php index 1c37d897..4925239c 100644 --- a/tests/Client/FunctionalIntegrationTest.php +++ b/tests/Client/FunctionalIntegrationTest.php @@ -3,6 +3,7 @@ namespace React\Tests\Http\Client; use Psr\Http\Message\ResponseInterface; +use React\EventLoop\Loop; use React\Http\Client\Client; use React\Http\Io\ClientConnectionManager; use React\Http\Message\Request; @@ -47,7 +48,7 @@ public function testRequestToLocalhostEmitsSingleRemoteConnection() }); $port = parse_url(https://github.com/$socket-%3EgetAddress(), PHP_URL_PORT); - $client = new Client(new ClientConnectionManager(new Connector())); + $client = new Client(new ClientConnectionManager(new Connector(), Loop::get())); $request = $client->request(new Request('GET', 'http://localhost/' . $port, array(), '', '1.0')); $promise = Stream\first($request, 'close'); @@ -56,7 +57,7 @@ public function testRequestToLocalhostEmitsSingleRemoteConnection() \React\Async\await(\React\Promise\Timer\timeout($promise, self::TIMEOUT_LOCAL)); } - public function testRequestToLocalhostWillConnectAndCloseConnectionAfterResponseUntilKeepAliveIsActuallySupported() + public function testRequestToLocalhostWillConnectAndCloseConnectionAfterResponseWhenKeepAliveTimesOut() { $socket = new SocketServer('127.0.0.1:0'); $socket->on('connection', $this->expectCallableOnce()); @@ -72,7 +73,7 @@ public function testRequestToLocalhostWillConnectAndCloseConnectionAfterResponse }); $port = parse_url(https://github.com/$socket-%3EgetAddress(), PHP_URL_PORT); - $client = new Client(new ClientConnectionManager(new Connector())); + $client = new Client(new ClientConnectionManager(new Connector(), Loop::get())); $request = $client->request(new Request('GET', 'http://localhost/' . $port, array(), '', '1.1')); $request->end(); @@ -80,6 +81,34 @@ public function testRequestToLocalhostWillConnectAndCloseConnectionAfterResponse \React\Async\await(\React\Promise\Timer\timeout($promise, self::TIMEOUT_LOCAL)); } + public function testRequestToLocalhostWillReuseExistingConnectionForSecondRequest() + { + $socket = new SocketServer('127.0.0.1:0'); + $socket->on('connection', $this->expectCallableOnce()); + + $socket->on('connection', function (ConnectionInterface $connection) use ($socket) { + $connection->on('data', function () use ($connection) { + $connection->write("HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nOK"); + }); + $socket->close(); + }); + $port = parse_url(https://github.com/$socket-%3EgetAddress(), PHP_URL_PORT); + + $client = new Client(new ClientConnectionManager(new Connector(), Loop::get())); + + $request = $client->request(new Request('GET', 'http://localhost/' . $port, array(), '', '1.1')); + $promise = Stream\first($request, 'close'); + $request->end(); + + \React\Async\await(\React\Promise\Timer\timeout($promise, self::TIMEOUT_LOCAL)); + + $request = $client->request(new Request('GET', 'http://localhost/' . $port, array(), '', '1.1')); + $promise = Stream\first($request, 'close'); + $request->end(); + + \React\Async\await(\React\Promise\Timer\timeout($promise, self::TIMEOUT_LOCAL)); + } + public function testRequestLegacyHttpServerWithOnlyLineFeedReturnsSuccessfulResponse() { $socket = new SocketServer('127.0.0.1:0'); @@ -88,7 +117,7 @@ public function testRequestLegacyHttpServerWithOnlyLineFeedReturnsSuccessfulResp $socket->close(); }); - $client = new Client(new ClientConnectionManager(new Connector())); + $client = new Client(new ClientConnectionManager(new Connector(), Loop::get())); $request = $client->request(new Request('GET', str_replace('tcp:', 'http:', $socket->getAddress()), array(), '', '1.0')); $once = $this->expectCallableOnceWith('body'); @@ -108,7 +137,7 @@ public function testSuccessfulResponseEmitsEnd() // max_nesting_level was set to 100 for PHP Versions < 5.4 which resulted in failing test for legacy PHP ini_set('xdebug.max_nesting_level', 256); - $client = new Client(new ClientConnectionManager(new Connector())); + $client = new Client(new ClientConnectionManager(new Connector(), Loop::get())); $request = $client->request(new Request('GET', 'http://www.google.com/', array(), '', '1.0')); @@ -133,7 +162,7 @@ public function testPostDataReturnsData() // max_nesting_level was set to 100 for PHP Versions < 5.4 which resulted in failing test for legacy PHP ini_set('xdebug.max_nesting_level', 256); - $client = new Client(new ClientConnectionManager(new Connector())); + $client = new Client(new ClientConnectionManager(new Connector(), Loop::get())); $data = str_repeat('.', 33000); $request = $client->request(new Request('POST', 'https://' . (mt_rand(0, 1) === 0 ? 'eu.' : '') . 'httpbin.org/post', array('Content-Length' => strlen($data)), '', '1.0')); @@ -165,7 +194,7 @@ public function testPostJsonReturnsData() $this->markTestSkipped('Not supported on HHVM'); } - $client = new Client(new ClientConnectionManager(new Connector())); + $client = new Client(new ClientConnectionManager(new Connector(), Loop::get())); $data = json_encode(array('numbers' => range(1, 50))); $request = $client->request(new Request('POST', 'https://httpbin.org/post', array('Content-Length' => strlen($data), 'Content-Type' => 'application/json'), '', '1.0')); @@ -195,7 +224,7 @@ public function testCancelPendingConnectionEmitsClose() // max_nesting_level was set to 100 for PHP Versions < 5.4 which resulted in failing test for legacy PHP ini_set('xdebug.max_nesting_level', 256); - $client = new Client(new ClientConnectionManager(new Connector())); + $client = new Client(new ClientConnectionManager(new Connector(), Loop::get())); $request = $client->request(new Request('GET', 'http://www.google.com/', array(), '', '1.0')); $request->on('error', $this->expectCallableNever()); diff --git a/tests/Io/ClientConnectionManagerTest.php b/tests/Io/ClientConnectionManagerTest.php index 143676e4..b28c7964 100644 --- a/tests/Io/ClientConnectionManagerTest.php +++ b/tests/Io/ClientConnectionManagerTest.php @@ -5,6 +5,7 @@ use RingCentral\Psr7\Uri; use React\Http\Io\ClientConnectionManager; use React\Promise\Promise; +use React\Promise\PromiseInterface; use React\Tests\Http\TestCase; class ClientConnectionManagerTest extends TestCase @@ -15,9 +16,13 @@ public function testConnectWithHttpsUriShouldConnectToTlsWithDefaultPort() $connector = $this->getMockBuilder('React\Socket\ConnectorInterface')->getMock(); $connector->expects($this->once())->method('connect')->with('tls://reactphp.org:443')->willReturn($promise); - $connectionManager = new ClientConnectionManager($connector); + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + + $connectionManager = new ClientConnectionManager($connector, $loop); $ret = $connectionManager->connect(new Uri('https://reactphp.org/')); + + assert($ret instanceof PromiseInterface); $this->assertSame($promise, $ret); } @@ -27,7 +32,9 @@ public function testConnectWithHttpUriShouldConnectToTcpWithDefaultPort() $connector = $this->getMockBuilder('React\Socket\ConnectorInterface')->getMock(); $connector->expects($this->once())->method('connect')->with('reactphp.org:80')->willReturn($promise); - $connectionManager = new ClientConnectionManager($connector); + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + + $connectionManager = new ClientConnectionManager($connector, $loop); $ret = $connectionManager->connect(new Uri('http://reactphp.org/')); $this->assertSame($promise, $ret); @@ -39,7 +46,9 @@ public function testConnectWithExplicitPortShouldConnectWithGivenPort() $connector = $this->getMockBuilder('React\Socket\ConnectorInterface')->getMock(); $connector->expects($this->once())->method('connect')->with('reactphp.org:8080')->willReturn($promise); - $connectionManager = new ClientConnectionManager($connector); + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + + $connectionManager = new ClientConnectionManager($connector, $loop); $ret = $connectionManager->connect(new Uri('http://reactphp.org:8080/')); $this->assertSame($promise, $ret); @@ -50,7 +59,9 @@ public function testConnectWithInvalidSchemeShouldRejectWithException() $connector = $this->getMockBuilder('React\Socket\ConnectorInterface')->getMock(); $connector->expects($this->never())->method('connect'); - $connectionManager = new ClientConnectionManager($connector); + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + + $connectionManager = new ClientConnectionManager($connector, $loop); $promise = $connectionManager->connect(new Uri('ftp://reactphp.org/')); @@ -68,7 +79,9 @@ public function testConnectWithoutSchemeShouldRejectWithException() $connector = $this->getMockBuilder('React\Socket\ConnectorInterface')->getMock(); $connector->expects($this->never())->method('connect'); - $connectionManager = new ClientConnectionManager($connector); + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + + $connectionManager = new ClientConnectionManager($connector, $loop); $promise = $connectionManager->connect(new Uri('reactphp.org')); @@ -81,15 +94,296 @@ public function testConnectWithoutSchemeShouldRejectWithException() $this->assertEquals('Invalid request URL given', $exception->getMessage()); } - public function testHandBackWillCloseGivenConnectionUntilKeepAliveIsActuallySupported() + public function testConnectReusesIdleConnectionFromPreviousKeepAliveCallWithoutUsingConnectorAndWillAddAndRemoveStreamEventsAndAddAndCancelIdleTimer() + { + $connectionToReuse = $this->getMockBuilder('React\Socket\ConnectionInterface')->getMock(); + + $streamHandler = null; + $connectionToReuse->expects($this->exactly(3))->method('on')->withConsecutive( + array( + 'close', + $this->callback(function ($cb) use (&$streamHandler) { + $streamHandler = $cb; + return true; + }) + ), + array( + 'data', + $this->callback(function ($cb) use (&$streamHandler) { + assert($streamHandler instanceof \Closure); + return $cb === $streamHandler; + }) + ), + array( + 'error', + $this->callback(function ($cb) use (&$streamHandler) { + assert($streamHandler instanceof \Closure); + return $cb === $streamHandler; + }) + ) + ); + + $connectionToReuse->expects($this->exactly(3))->method('removeListener')->withConsecutive( + array( + 'close', + $this->callback(function ($cb) use (&$streamHandler) { + assert($streamHandler instanceof \Closure); + return $cb === $streamHandler; + }) + ), + array( + 'data', + $this->callback(function ($cb) use (&$streamHandler) { + assert($streamHandler instanceof \Closure); + return $cb === $streamHandler; + }) + ), + array( + 'error', + $this->callback(function ($cb) use (&$streamHandler) { + assert($streamHandler instanceof \Closure); + return $cb === $streamHandler; + }) + ) + ); + + $connector = $this->getMockBuilder('React\Socket\ConnectorInterface')->getMock(); + $connector->expects($this->never())->method('connect'); + + $timer = $this->getMockBuilder('React\EventLoop\TimerInterface')->getMock(); + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $loop->expects($this->once())->method('addTimer')->willReturn($timer); + $loop->expects($this->once())->method('cancelTimer')->with($timer); + + $connectionManager = new ClientConnectionManager($connector, $loop); + + $connectionManager->keepAlive(new Uri('https://reactphp.org/'), $connectionToReuse); + + $promise = $connectionManager->connect(new Uri('https://reactphp.org/')); + assert($promise instanceof PromiseInterface); + + $connection = null; + $promise->then(function ($value) use (&$connection) { + $connection = $value; + }); + + $this->assertSame($connectionToReuse, $connection); + } + + public function testConnectReusesIdleConnectionFromPreviousKeepAliveCallWithoutUsingConnectorAlsoWhenUriPathAndQueryAndFragmentIsDifferent() { + $connectionToReuse = $this->getMockBuilder('React\Socket\ConnectionInterface')->getMock(); + $connector = $this->getMockBuilder('React\Socket\ConnectorInterface')->getMock(); + $connector->expects($this->never())->method('connect'); + + $timer = $this->getMockBuilder('React\EventLoop\TimerInterface')->getMock(); + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $loop->expects($this->once())->method('addTimer')->willReturn($timer); + $loop->expects($this->once())->method('cancelTimer')->with($timer); + + $connectionManager = new ClientConnectionManager($connector, $loop); + + $connectionManager->keepAlive(new Uri('https://reactphp.org/http?foo#bar'), $connectionToReuse); + + $promise = $connectionManager->connect(new Uri('https://reactphp.org/http/')); + assert($promise instanceof PromiseInterface); + + $connection = null; + $promise->then(function ($value) use (&$connection) { + $connection = $value; + }); + + $this->assertSame($connectionToReuse, $connection); + } + + public function testConnectUsesConnectorWithSameUriAndReturnsPromiseForNewConnectionFromConnectorWhenPreviousKeepAliveCallUsedDifferentUri() + { + $connectionToReuse = $this->getMockBuilder('React\Socket\ConnectionInterface')->getMock(); + + $promise = new Promise(function () { }); + $connector = $this->getMockBuilder('React\Socket\ConnectorInterface')->getMock(); + $connector->expects($this->once())->method('connect')->with('tls://reactphp.org:443')->willReturn($promise); + + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + + $connectionManager = new ClientConnectionManager($connector, $loop); + + $connectionManager->keepAlive(new Uri('http://reactphp.org/'), $connectionToReuse); + + $ret = $connectionManager->connect(new Uri('https://reactphp.org/')); + + assert($ret instanceof PromiseInterface); + $this->assertSame($promise, $ret); + } + + public function testConnectUsesConnectorForNewConnectionWhenPreviousConnectReusedIdleConnectionFromPreviousKeepAliveCall() + { + $firstConnection = $this->getMockBuilder('React\Socket\ConnectionInterface')->getMock(); + $secondConnection = $this->getMockBuilder('React\Socket\ConnectionInterface')->getMock(); + + $connector = $this->getMockBuilder('React\Socket\ConnectorInterface')->getMock(); + $connector->expects($this->once())->method('connect')->with('tls://reactphp.org:443')->willReturn(\React\Promise\resolve($secondConnection)); + + $timer = $this->getMockBuilder('React\EventLoop\TimerInterface')->getMock(); + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $loop->expects($this->once())->method('addTimer')->willReturn($timer); + $loop->expects($this->once())->method('cancelTimer')->with($timer); + + $connectionManager = new ClientConnectionManager($connector, $loop); - $connectionManager = new ClientConnectionManager($connector); + $connectionManager->keepAlive(new Uri('https://reactphp.org/'), $firstConnection); + $promise = $connectionManager->connect(new Uri('https://reactphp.org/')); + assert($promise instanceof PromiseInterface); + + $promise = $connectionManager->connect(new Uri('https://reactphp.org/')); + assert($promise instanceof PromiseInterface); + + $connection = null; + $promise->then(function ($value) use (&$connection) { + $connection = $value; + }); + + $this->assertSame($secondConnection, $connection); + } + + public function testKeepAliveAddsTimerAndDoesNotCloseConnectionImmediately() + { + $connection = $this->getMockBuilder('React\Socket\ConnectionInterface')->getMock(); + $connection->expects($this->never())->method('close'); + + $connector = $this->getMockBuilder('React\Socket\ConnectorInterface')->getMock(); + + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $loop->expects($this->once())->method('addTimer')->with(0.001, $this->anything()); + + $connectionManager = new ClientConnectionManager($connector, $loop); + + $connectionManager->keepAlive(new Uri('https://reactphp.org/'), $connection); + } + + public function testKeepAliveClosesConnectionAfterIdleTimeout() + { $connection = $this->getMockBuilder('React\Socket\ConnectionInterface')->getMock(); $connection->expects($this->once())->method('close'); - $connectionManager->handBack($connection); + $connector = $this->getMockBuilder('React\Socket\ConnectorInterface')->getMock(); + + $timerCallback = null; + $timer = $this->getMockBuilder('React\EventLoop\TimerInterface')->getMock(); + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $loop->expects($this->once())->method('addTimer')->with($this->anything(), $this->callback(function ($cb) use (&$timerCallback) { + $timerCallback = $cb; + return true; + }))->willReturn($timer); + $loop->expects($this->once())->method('cancelTimer')->with($timer); + + $connectionManager = new ClientConnectionManager($connector, $loop); + + $connectionManager->keepAlive(new Uri('https://reactphp.org/'), $connection); + + // manually invoker timer function to emulate time has passed + $this->assertNotNull($timerCallback); + call_user_func($timerCallback); // $timerCallback() (PHP 5.4+) + } + + public function testConnectUsesConnectorForNewConnectionWhenIdleConnectionFromPreviousKeepAliveCallHasAlreadyTimedOut() + { + $firstConnection = $this->getMockBuilder('React\Socket\ConnectionInterface')->getMock(); + $firstConnection->expects($this->once())->method('close'); + + $secondConnection = $this->getMockBuilder('React\Socket\ConnectionInterface')->getMock(); + $secondConnection->expects($this->never())->method('close'); + + $connector = $this->getMockBuilder('React\Socket\ConnectorInterface')->getMock(); + $connector->expects($this->once())->method('connect')->with('tls://reactphp.org:443')->willReturn(\React\Promise\resolve($secondConnection)); + + $timerCallback = null; + $timer = $this->getMockBuilder('React\EventLoop\TimerInterface')->getMock(); + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $loop->expects($this->once())->method('addTimer')->with($this->anything(), $this->callback(function ($cb) use (&$timerCallback) { + $timerCallback = $cb; + return true; + }))->willReturn($timer); + $loop->expects($this->once())->method('cancelTimer')->with($timer); + + $connectionManager = new ClientConnectionManager($connector, $loop); + + $connectionManager->keepAlive(new Uri('https://reactphp.org/'), $firstConnection); + + // manually invoker timer function to emulate time has passed + $this->assertNotNull($timerCallback); + call_user_func($timerCallback); // $timerCallback() (PHP 5.4+) + + $promise = $connectionManager->connect(new Uri('https://reactphp.org/')); + assert($promise instanceof PromiseInterface); + + $connection = null; + $promise->then(function ($value) use (&$connection) { + $connection = $value; + }); + + $this->assertSame($secondConnection, $connection); + } + + public function testConnectUsesConnectorForNewConnectionWhenIdleConnectionFromPreviousKeepAliveCallHasAlreadyFiredUnexpectedStreamEventBeforeIdleTimeoutThatClosesConnection() + { + $firstConnection = $this->getMockBuilder('React\Socket\ConnectionInterface')->getMock(); + $firstConnection->expects($this->once())->method('close'); + + $streamHandler = null; + $firstConnection->expects($this->exactly(3))->method('on')->withConsecutive( + array( + 'close', + $this->callback(function ($cb) use (&$streamHandler) { + $streamHandler = $cb; + return true; + }) + ), + array( + 'data', + $this->callback(function ($cb) use (&$streamHandler) { + assert($streamHandler instanceof \Closure); + return $cb === $streamHandler; + }) + ), + array( + 'error', + $this->callback(function ($cb) use (&$streamHandler) { + assert($streamHandler instanceof \Closure); + return $cb === $streamHandler; + }) + ) + ); + + $secondConnection = $this->getMockBuilder('React\Socket\ConnectionInterface')->getMock(); + $secondConnection->expects($this->never())->method('close'); + + $connector = $this->getMockBuilder('React\Socket\ConnectorInterface')->getMock(); + $connector->expects($this->once())->method('connect')->with('tls://reactphp.org:443')->willReturn(\React\Promise\resolve($secondConnection)); + + $timer = $this->getMockBuilder('React\EventLoop\TimerInterface')->getMock(); + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $loop->expects($this->once())->method('addTimer')->willReturn($timer); + $loop->expects($this->once())->method('cancelTimer')->with($timer); + + $connectionManager = new ClientConnectionManager($connector, $loop); + + $connectionManager->keepAlive(new Uri('https://reactphp.org/'), $firstConnection); + + // manually invoke connection close to emulate server closing idle connection before idle timeout + $this->assertNotNull($streamHandler); + call_user_func($streamHandler); // $streamHandler() (PHP 5.4+) + + $promise = $connectionManager->connect(new Uri('https://reactphp.org/')); + assert($promise instanceof PromiseInterface); + + $connection = null; + $promise->then(function ($value) use (&$connection) { + $connection = $value; + }); + + $this->assertSame($secondConnection, $connection); } } diff --git a/tests/Io/ClientRequestStreamTest.php b/tests/Io/ClientRequestStreamTest.php index d257994b..4649087a 100644 --- a/tests/Io/ClientRequestStreamTest.php +++ b/tests/Io/ClientRequestStreamTest.php @@ -488,7 +488,7 @@ public function testStreamShouldReuseConnectionForHttp11ByDefault() $connectionManager = $this->getMockBuilder('React\Http\Io\ClientConnectionManager')->disableOriginalConstructor()->getMock(); $connectionManager->expects($this->once())->method('connect')->willReturn(\React\Promise\resolve($connection)); - $connectionManager->expects($this->once())->method('handBack')->with($connection); + $connectionManager->expects($this->once())->method('keepAlive')->with(new Uri('http://www.example.com/'), $connection); $requestData = new Request('GET', 'http://www.example.com/', array(), '', '1.1'); $request = new ClientRequestStream($connectionManager, $requestData); @@ -569,7 +569,7 @@ public function testStreamShouldReuseConnectionForHttp10WhenBothRequestAndRespon $connectionManager = $this->getMockBuilder('React\Http\Io\ClientConnectionManager')->disableOriginalConstructor()->getMock(); $connectionManager->expects($this->once())->method('connect')->willReturn(\React\Promise\resolve($connection)); - $connectionManager->expects($this->once())->method('handBack')->with($connection); + $connectionManager->expects($this->once())->method('keepAlive')->with(new Uri('http://www.example.com/'), $connection); $requestData = new Request('GET', 'http://www.example.com/', array('Connection' => 'keep-alive'), '', '1.0'); $request = new ClientRequestStream($connectionManager, $requestData); @@ -590,7 +590,7 @@ public function testStreamShouldReuseConnectionForHttp10WhenBothRequestAndRespon $connectionManager = $this->getMockBuilder('React\Http\Io\ClientConnectionManager')->disableOriginalConstructor()->getMock(); $connectionManager->expects($this->once())->method('connect')->willReturn(\React\Promise\resolve($connection)); - $connectionManager->expects($this->once())->method('handBack')->with($connection); + $connectionManager->expects($this->once())->method('keepAlive')->with(new Uri('http://www.example.com/'), $connection); $requestData = new Request('GET', 'http://www.example.com/', array('Connection' => 'FOO, KEEP-ALIVE, BAR'), '', '1.0'); $request = new ClientRequestStream($connectionManager, $requestData); @@ -681,7 +681,7 @@ public function testStreamShouldReuseConnectionWhenResponseContainsTransferEncod $connectionManager = $this->getMockBuilder('React\Http\Io\ClientConnectionManager')->disableOriginalConstructor()->getMock(); $connectionManager->expects($this->once())->method('connect')->willReturn(\React\Promise\resolve($connection)); - $connectionManager->expects($this->once())->method('handBack')->with($connection); + $connectionManager->expects($this->once())->method('keepAlive')->with(new Uri('http://www.example.com/'), $connection); $requestData = new Request('GET', 'http://www.example.com/', array(), '', '1.1'); $request = new ClientRequestStream($connectionManager, $requestData); diff --git a/tests/Io/SenderTest.php b/tests/Io/SenderTest.php index 220424e9..0f555f9c 100644 --- a/tests/Io/SenderTest.php +++ b/tests/Io/SenderTest.php @@ -14,11 +14,20 @@ class SenderTest extends TestCase { - public function testCreateFromLoop() + /** @var \React\EventLoop\LoopInterface */ + private $loop; + + /** + * @before + */ + public function setUpLoop() { - $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $this->loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + } - $sender = Sender::createFromLoop($loop, null); + public function testCreateFromLoop() + { + $sender = Sender::createFromLoop($this->loop, null); $this->assertInstanceOf('React\Http\Io\Sender', $sender); } @@ -28,7 +37,7 @@ public function testSenderRejectsInvalidUri() $connector = $this->getMockBuilder('React\Socket\ConnectorInterface')->getMock(); $connector->expects($this->never())->method('connect'); - $sender = new Sender(new HttpClient(new ClientConnectionManager($connector))); + $sender = new Sender(new HttpClient(new ClientConnectionManager($connector, $this->loop))); $request = new Request('GET', 'www.google.com'); @@ -47,7 +56,7 @@ public function testSenderConnectorRejection() $connector = $this->getMockBuilder('React\Socket\ConnectorInterface')->getMock(); $connector->expects($this->once())->method('connect')->willReturn(Promise\reject(new \RuntimeException('Rejected'))); - $sender = new Sender(new HttpClient(new ClientConnectionManager($connector))); + $sender = new Sender(new HttpClient(new ClientConnectionManager($connector, $this->loop))); $request = new Request('GET', 'http://www.google.com/'); @@ -374,7 +383,7 @@ public function testCancelRequestWillCancelConnector() $connector = $this->getMockBuilder('React\Socket\ConnectorInterface')->getMock(); $connector->expects($this->once())->method('connect')->willReturn($promise); - $sender = new Sender(new HttpClient(new ClientConnectionManager($connector))); + $sender = new Sender(new HttpClient(new ClientConnectionManager($connector, $this->loop))); $request = new Request('GET', 'http://www.google.com/'); @@ -397,7 +406,7 @@ public function testCancelRequestWillCloseConnection() $connector = $this->getMockBuilder('React\Socket\ConnectorInterface')->getMock(); $connector->expects($this->once())->method('connect')->willReturn(Promise\resolve($connection)); - $sender = new Sender(new HttpClient(new ClientConnectionManager($connector))); + $sender = new Sender(new HttpClient(new ClientConnectionManager($connector, $this->loop))); $request = new Request('GET', 'http://www.google.com/'); From ebaf6f132cd821230447a2287be79d3c3c23d625 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Thu, 19 Jan 2023 19:16:28 +0100 Subject: [PATCH 33/58] Add `Connection: close` default header to allow toggling keep-alive --- src/Browser.php | 1 + src/Io/Sender.php | 7 ----- src/Io/Transaction.php | 2 +- tests/BrowserTest.php | 28 +++++++++++++++++ tests/FunctionalBrowserTest.php | 54 +++++++++++++++++++++++++++++++ tests/Io/SenderTest.php | 56 --------------------------------- 6 files changed, 84 insertions(+), 64 deletions(-) diff --git a/src/Browser.php b/src/Browser.php index 3e3458af..12bce6b5 100644 --- a/src/Browser.php +++ b/src/Browser.php @@ -23,6 +23,7 @@ class Browser private $baseUrl; private $protocolVersion = '1.1'; private $defaultHeaders = array( + 'Connection' => 'close', 'User-Agent' => 'ReactPHP/1' ); diff --git a/src/Io/Sender.php b/src/Io/Sender.php index c117d87d..3598d31a 100644 --- a/src/Io/Sender.php +++ b/src/Io/Sender.php @@ -98,13 +98,6 @@ public function send(RequestInterface $request) $size = 0; } - // automatically add `Connection: close` request header for HTTP/1.1 requests to avoid connection reuse - if ($request->getProtocolVersion() === '1.1') { - $request = $request->withHeader('Connection', 'close'); - } else { - $request = $request->withoutHeader('Connection'); - } - // automatically add `Authorization: Basic …` request header if URL includes `user:pass@host` if ($request->getUri()->getUserInfo() !== '' && !$request->hasHeader('Authorization')) { $request = $request->withHeader('Authorization', 'Basic ' . \base64_encode($request->getUri()->getUserInfo())); diff --git a/src/Io/Transaction.php b/src/Io/Transaction.php index bfa42241..b93c490c 100644 --- a/src/Io/Transaction.php +++ b/src/Io/Transaction.php @@ -302,7 +302,7 @@ private function makeRedirectRequest(RequestInterface $request, UriInterface $lo ->withMethod($request->getMethod() === 'HEAD' ? 'HEAD' : 'GET') ->withoutHeader('Content-Type') ->withoutHeader('Content-Length') - ->withBody(new EmptyBodyStream()); + ->withBody(new BufferedBody('')); } return $request; diff --git a/tests/BrowserTest.php b/tests/BrowserTest.php index 21242a5d..d01de9c5 100644 --- a/tests/BrowserTest.php +++ b/tests/BrowserTest.php @@ -556,6 +556,8 @@ public function testWithMultipleHeadersShouldBeMergedCorrectlyWithMultipleDefaul 'user-Agent' => array('ABC'), 'another-header' => array('value'), 'custom-header' => array('data'), + + 'Connection' => array('close') ); $that->assertEquals($expectedHeaders, $request->getHeaders()); @@ -584,6 +586,32 @@ public function testWithoutHeaderShouldRemoveExistingHeader() $this->browser->get('http://example.com/'); } + public function testWithoutHeaderConnectionShouldRemoveDefaultConnectionHeader() + { + $this->browser = $this->browser->withoutHeader('Connection'); + + $that = $this; + $this->sender->expects($this->once())->method('send')->with($this->callback(function (RequestInterface $request) use ($that) { + $that->assertEquals(array(), $request->getHeader('Connection')); + return true; + }))->willReturn(new Promise(function () { })); + + $this->browser->get('http://example.com/'); + } + + public function testWithHeaderConnectionShouldOverwriteDefaultConnectionHeader() + { + $this->browser = $this->browser->withHeader('Connection', 'keep-alive'); + + $that = $this; + $this->sender->expects($this->once())->method('send')->with($this->callback(function (RequestInterface $request) use ($that) { + $that->assertEquals(array('keep-alive'), $request->getHeader('Connection')); + return true; + }))->willReturn(new Promise(function () { })); + + $this->browser->get('http://example.com/'); + } + public function testBrowserShouldSendDefaultUserAgentHeader() { $that = $this; diff --git a/tests/FunctionalBrowserTest.php b/tests/FunctionalBrowserTest.php index 6def2ecc..7ab909de 100644 --- a/tests/FunctionalBrowserTest.php +++ b/tests/FunctionalBrowserTest.php @@ -553,6 +553,60 @@ public function testReceiveStreamAndExplicitlyCloseConnectionEvenWhenServerKeeps $socket->close(); } + public function testRequestWillCreateNewConnectionForSecondRequestByDefaultEvenWhenServerKeepsConnectionOpen() + { + $twice = $this->expectCallableOnce(); + $socket = new SocketServer('127.0.0.1:0'); + $socket->on('connection', function (\React\Socket\ConnectionInterface $connection) use ($socket, $twice) { + $connection->on('data', function () use ($connection) { + $connection->write("HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello"); + }); + + $socket->on('connection', $twice); + $socket->on('connection', function () use ($socket) { + $socket->close(); + }); + }); + + $this->base = str_replace('tcp:', 'http:', $socket->getAddress()) . '/'; + + $response = \React\Async\await($this->browser->get($this->base . 'get')); + assert($response instanceof ResponseInterface); + $this->assertEquals('hello', (string)$response->getBody()); + + $response = \React\Async\await($this->browser->get($this->base . 'get')); + assert($response instanceof ResponseInterface); + $this->assertEquals('hello', (string)$response->getBody()); + } + + public function testRequestWithoutConnectionHeaderWillReuseExistingConnectionForSecondRequest() + { + $this->socket->on('connection', $this->expectCallableOnce()); + + // remove default `Connection: close` request header to enable keep-alive + $this->browser = $this->browser->withoutHeader('Connection'); + + $response = \React\Async\await($this->browser->get($this->base . 'get')); + assert($response instanceof ResponseInterface); + $this->assertEquals('hello', (string)$response->getBody()); + + $response = \React\Async\await($this->browser->get($this->base . 'get')); + assert($response instanceof ResponseInterface); + $this->assertEquals('hello', (string)$response->getBody()); + } + + public function testRequestWithoutConnectionHeaderWillReuseExistingConnectionForRedirectedRequest() + { + $this->socket->on('connection', $this->expectCallableOnce()); + + // remove default `Connection: close` request header to enable keep-alive + $this->browser = $this->browser->withoutHeader('Connection'); + + $response = \React\Async\await($this->browser->get($this->base . 'redirect-to?url=get')); + assert($response instanceof ResponseInterface); + $this->assertEquals('hello', (string)$response->getBody()); + } + public function testPostStreamChunked() { $stream = new ThroughStream(); diff --git a/tests/Io/SenderTest.php b/tests/Io/SenderTest.php index 0f555f9c..3c8c4761 100644 --- a/tests/Io/SenderTest.php +++ b/tests/Io/SenderTest.php @@ -290,62 +290,6 @@ public function testSendCustomMethodWithExplicitContentLengthZeroWillBePassedAsI $sender->send($request); } - /** @test */ - public function getHttp10RequestShouldSendAGetRequestWithoutConnectionHeaderByDefault() - { - $client = $this->getMockBuilder('React\Http\Client\Client')->disableOriginalConstructor()->getMock(); - $client->expects($this->once())->method('request')->with($this->callback(function (RequestInterface $request) { - return !$request->hasHeader('Connection'); - }))->willReturn($this->getMockBuilder('React\Http\Io\ClientRequestStream')->disableOriginalConstructor()->getMock()); - - $sender = new Sender($client); - - $request = new Request('GET', 'http://www.example.com/', array(), '', '1.0'); - $sender->send($request); - } - - /** @test */ - public function getHttp10RequestShouldSendAGetRequestWithoutConnectionHeaderEvenWhenConnectionKeepAliveHeaderIsSpecified() - { - $client = $this->getMockBuilder('React\Http\Client\Client')->disableOriginalConstructor()->getMock(); - $client->expects($this->once())->method('request')->with($this->callback(function (RequestInterface $request) { - return !$request->hasHeader('Connection'); - }))->willReturn($this->getMockBuilder('React\Http\Io\ClientRequestStream')->disableOriginalConstructor()->getMock()); - - $sender = new Sender($client); - - $request = new Request('GET', 'http://www.example.com/', array('Connection' => 'keep-alive'), '', '1.0'); - $sender->send($request); - } - - /** @test */ - public function getHttp11RequestShouldSendAGetRequestWithConnectionCloseHeaderByDefault() - { - $client = $this->getMockBuilder('React\Http\Client\Client')->disableOriginalConstructor()->getMock(); - $client->expects($this->once())->method('request')->with($this->callback(function (RequestInterface $request) { - return $request->getHeaderLine('Connection') === 'close'; - }))->willReturn($this->getMockBuilder('React\Http\Io\ClientRequestStream')->disableOriginalConstructor()->getMock()); - - $sender = new Sender($client); - - $request = new Request('GET', 'http://www.example.com/', array(), '', '1.1'); - $sender->send($request); - } - - /** @test */ - public function getHttp11RequestShouldSendAGetRequestWithConnectionCloseHeaderEvenWhenConnectionKeepAliveHeaderIsSpecified() - { - $client = $this->getMockBuilder('React\Http\Client\Client')->disableOriginalConstructor()->getMock(); - $client->expects($this->once())->method('request')->with($this->callback(function (RequestInterface $request) { - return $request->getHeaderLine('Connection') === 'close'; - }))->willReturn($this->getMockBuilder('React\Http\Io\ClientRequestStream')->disableOriginalConstructor()->getMock()); - - $sender = new Sender($client); - - $request = new Request('GET', 'http://www.example.com/', array('Connection' => 'keep-alive'), '', '1.1'); - $sender->send($request); - } - /** @test */ public function getRequestWithUserAndPassShouldSendAGetRequestWithBasicAuthorizationHeader() { From b3594f7936b92f9fc2d5f9e84dc01bdb95a72167 Mon Sep 17 00:00:00 2001 From: Cees-Jan Kiewiet Date: Thu, 16 Feb 2023 17:25:39 +0100 Subject: [PATCH 34/58] Stop parsing multipart request bodies once the configured limit of form fields and files has been reached This fix is inspired by how PHP is handling it but without following the ini setting. Such setting isn't needed as the limits on files and form fields are enough. --- src/Io/MultipartParser.php | 15 +++++++++++++++ tests/Io/MultipartParserTest.php | 27 ++++++++++++++++++++++++++- 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/src/Io/MultipartParser.php b/src/Io/MultipartParser.php index 536694fd..6a874336 100644 --- a/src/Io/MultipartParser.php +++ b/src/Io/MultipartParser.php @@ -26,6 +26,13 @@ final class MultipartParser */ private $maxFileSize; + /** + * Based on $maxInputVars and $maxFileUploads + * + * @var int + */ + private $maxMultipartBodyParts; + /** * ini setting "max_input_vars" * @@ -62,6 +69,7 @@ final class MultipartParser */ private $maxFileUploads; + private $multipartBodyPartCount = 0; private $postCount = 0; private $filesCount = 0; private $emptyCount = 0; @@ -87,6 +95,8 @@ public function __construct($uploadMaxFilesize = null, $maxFileUploads = null) $this->uploadMaxFilesize = IniUtil::iniSizeToBytes($uploadMaxFilesize); $this->maxFileUploads = $maxFileUploads === null ? (\ini_get('file_uploads') === '' ? 0 : (int)\ini_get('max_file_uploads')) : (int)$maxFileUploads; + + $this->maxMultipartBodyParts = $this->maxInputVars + $this->maxFileUploads; } public function parse(ServerRequestInterface $request) @@ -101,6 +111,7 @@ public function parse(ServerRequestInterface $request) $request = $this->request; $this->request = null; + $this->multipartBodyPartCount = 0; $this->postCount = 0; $this->filesCount = 0; $this->emptyCount = 0; @@ -128,6 +139,10 @@ private function parseBody($boundary, $buffer) // parse one part and continue searching for next $this->parsePart(\substr($buffer, $start, $end - $start)); $start = $end; + + if (++$this->multipartBodyPartCount > $this->maxMultipartBodyParts) { + break; + } } } diff --git a/tests/Io/MultipartParserTest.php b/tests/Io/MultipartParserTest.php index 14550f57..5dfd6e43 100644 --- a/tests/Io/MultipartParserTest.php +++ b/tests/Io/MultipartParserTest.php @@ -1026,4 +1026,29 @@ public function testPostMaxFileSizeIgnoredByFilesComingBeforeIt() $this->assertTrue(isset($files['file4'])); $this->assertSame(UPLOAD_ERR_OK, $files['file4']->getError()); } -} \ No newline at end of file + + public function testWeOnlyParseTheAmountOfMultiPartChunksWeConfigured() + { + $boundary = "---------------------------12758086162038677464950549563"; + + $chunk = "--$boundary\r\n"; + $chunk .= "Content-Disposition: form-data; name=\"f\"\r\n"; + $chunk .= "\r\n"; + $chunk .= "u\r\n"; + $data = ''; + for ($i = 0; $i < 5000000; $i++) { + $data .= $chunk; + } + $data .= "--$boundary--\r\n"; + + $request = new ServerRequest('POST', 'http://example.com/', array( + 'Content-Type' => 'multipart/form-data; boundary=' . $boundary, + ), $data, 1.1); + + $parser = new MultipartParser(); + $startTime = microtime(true); + $parser->parse($request); + $runTime = microtime(true) - $startTime; + $this->assertLessThan(1, $runTime); + } +} From 2942434617ebf896209901748f97083b454e01a0 Mon Sep 17 00:00:00 2001 From: Cees-Jan Kiewiet Date: Thu, 2 Mar 2023 16:51:50 +0100 Subject: [PATCH 35/58] Improve multipart limits test The PR introducing this test assumed time would be enough to accurately predict behavior. This commit changes it to using introspection to check the exact state of the parser and expected state once finished parsing a multipart request body. To accomplish that it was required to make the cursor in the file an object property, so it can be inspected using reflection. --- src/Io/MultipartParser.php | 14 ++++++++------ tests/Io/MultipartParserTest.php | 29 ++++++++++++++++++++++------- 2 files changed, 30 insertions(+), 13 deletions(-) diff --git a/src/Io/MultipartParser.php b/src/Io/MultipartParser.php index 6a874336..539107ae 100644 --- a/src/Io/MultipartParser.php +++ b/src/Io/MultipartParser.php @@ -73,6 +73,7 @@ final class MultipartParser private $postCount = 0; private $filesCount = 0; private $emptyCount = 0; + private $cursor = 0; /** * @param int|string|null $uploadMaxFilesize @@ -112,6 +113,7 @@ public function parse(ServerRequestInterface $request) $request = $this->request; $this->request = null; $this->multipartBodyPartCount = 0; + $this->cursor = 0; $this->postCount = 0; $this->filesCount = 0; $this->emptyCount = 0; @@ -125,20 +127,20 @@ private function parseBody($boundary, $buffer) $len = \strlen($boundary); // ignore everything before initial boundary (SHOULD be empty) - $start = \strpos($buffer, $boundary . "\r\n"); + $this->cursor = \strpos($buffer, $boundary . "\r\n"); - while ($start !== false) { + while ($this->cursor !== false) { // search following boundary (preceded by newline) // ignore last if not followed by boundary (SHOULD end with "--") - $start += $len + 2; - $end = \strpos($buffer, "\r\n" . $boundary, $start); + $this->cursor += $len + 2; + $end = \strpos($buffer, "\r\n" . $boundary, $this->cursor); if ($end === false) { break; } // parse one part and continue searching for next - $this->parsePart(\substr($buffer, $start, $end - $start)); - $start = $end; + $this->parsePart(\substr($buffer, $this->cursor, $end - $this->cursor)); + $this->cursor = $end; if (++$this->multipartBodyPartCount > $this->maxMultipartBodyParts) { break; diff --git a/tests/Io/MultipartParserTest.php b/tests/Io/MultipartParserTest.php index 5dfd6e43..7f1ec667 100644 --- a/tests/Io/MultipartParserTest.php +++ b/tests/Io/MultipartParserTest.php @@ -1029,6 +1029,7 @@ public function testPostMaxFileSizeIgnoredByFilesComingBeforeIt() public function testWeOnlyParseTheAmountOfMultiPartChunksWeConfigured() { + $chunkCount = 5000000; $boundary = "---------------------------12758086162038677464950549563"; $chunk = "--$boundary\r\n"; @@ -1036,9 +1037,7 @@ public function testWeOnlyParseTheAmountOfMultiPartChunksWeConfigured() $chunk .= "\r\n"; $chunk .= "u\r\n"; $data = ''; - for ($i = 0; $i < 5000000; $i++) { - $data .= $chunk; - } + $data .= str_repeat($chunk, $chunkCount); $data .= "--$boundary--\r\n"; $request = new ServerRequest('POST', 'http://example.com/', array( @@ -1046,9 +1045,25 @@ public function testWeOnlyParseTheAmountOfMultiPartChunksWeConfigured() ), $data, 1.1); $parser = new MultipartParser(); - $startTime = microtime(true); - $parser->parse($request); - $runTime = microtime(true) - $startTime; - $this->assertLessThan(1, $runTime); + + $reflectecClass = new \ReflectionClass('\React\Http\Io\MultipartParser'); + $requestProperty = $reflectecClass->getProperty('request'); + $requestProperty->setAccessible(true); + $cursorProperty = $reflectecClass->getProperty('cursor'); + $cursorProperty->setAccessible(true); + $multipartBodyPartCountProperty = $reflectecClass->getProperty('multipartBodyPartCount'); + $multipartBodyPartCountProperty->setAccessible(true); + $maxMultipartBodyPartsProperty = $reflectecClass->getProperty('maxMultipartBodyParts'); + $maxMultipartBodyPartsProperty->setAccessible(true); + $parseBodyMethod = $reflectecClass->getMethod('parseBody'); + $parseBodyMethod->setAccessible(true); + + $this->assertSame(0, $cursorProperty->getValue($parser)); + + $requestProperty->setValue($parser, $request); + $parseBodyMethod->invoke($parser, '--' . $boundary, $data); + + $this->assertSame(strlen(str_repeat($chunk, $multipartBodyPartCountProperty->getValue($parser))), $cursorProperty->getValue($parser) + 2); + $this->assertSame($multipartBodyPartCountProperty->getValue($parser), $maxMultipartBodyPartsProperty->getValue($parser) + 1); } } From 684421f5d09afaaa0dc1c896f9d17244544a39d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Tue, 18 Apr 2023 21:04:41 +0200 Subject: [PATCH 36/58] Enable HTTP keep-alive by default for HTTP client --- src/Browser.php | 1 - tests/BrowserTest.php | 2 -- tests/FunctionalBrowserTest.php | 53 ++++++++++++++++++++++++++++++--- 3 files changed, 49 insertions(+), 7 deletions(-) diff --git a/src/Browser.php b/src/Browser.php index ad9187a6..b7bf4425 100644 --- a/src/Browser.php +++ b/src/Browser.php @@ -23,7 +23,6 @@ class Browser private $baseUrl; private $protocolVersion = '1.1'; private $defaultHeaders = array( - 'Connection' => 'close', 'User-Agent' => 'ReactPHP/1' ); diff --git a/tests/BrowserTest.php b/tests/BrowserTest.php index d01de9c5..b7958016 100644 --- a/tests/BrowserTest.php +++ b/tests/BrowserTest.php @@ -556,8 +556,6 @@ public function testWithMultipleHeadersShouldBeMergedCorrectlyWithMultipleDefaul 'user-Agent' => array('ABC'), 'another-header' => array('value'), 'custom-header' => array('data'), - - 'Connection' => array('close') ); $that->assertEquals($expectedHeaders, $request->getHeaders()); diff --git a/tests/FunctionalBrowserTest.php b/tests/FunctionalBrowserTest.php index 7ab909de..35b96eb6 100644 --- a/tests/FunctionalBrowserTest.php +++ b/tests/FunctionalBrowserTest.php @@ -553,7 +553,7 @@ public function testReceiveStreamAndExplicitlyCloseConnectionEvenWhenServerKeeps $socket->close(); } - public function testRequestWillCreateNewConnectionForSecondRequestByDefaultEvenWhenServerKeepsConnectionOpen() + public function testRequestWithConnectionCloseHeaderWillCreateNewConnectionForSecondRequestEvenWhenServerKeepsConnectionOpen() { $twice = $this->expectCallableOnce(); $socket = new SocketServer('127.0.0.1:0'); @@ -570,6 +570,9 @@ public function testRequestWillCreateNewConnectionForSecondRequestByDefaultEvenW $this->base = str_replace('tcp:', 'http:', $socket->getAddress()) . '/'; + // add `Connection: close` request header to disable HTTP keep-alive + $this->browser = $this->browser->withHeader('Connection', 'close'); + $response = \React\Async\await($this->browser->get($this->base . 'get')); assert($response instanceof ResponseInterface); $this->assertEquals('hello', (string)$response->getBody()); @@ -579,12 +582,54 @@ public function testRequestWillCreateNewConnectionForSecondRequestByDefaultEvenW $this->assertEquals('hello', (string)$response->getBody()); } - public function testRequestWithoutConnectionHeaderWillReuseExistingConnectionForSecondRequest() + public function testRequestWithHttp10WillCreateNewConnectionForSecondRequestEvenWhenServerKeepsConnectionOpen() + { + $twice = $this->expectCallableOnce(); + $socket = new SocketServer('127.0.0.1:0'); + $socket->on('connection', function (\React\Socket\ConnectionInterface $connection) use ($socket, $twice) { + $connection->on('data', function () use ($connection) { + $connection->write("HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello"); + }); + + $socket->on('connection', $twice); + $socket->on('connection', function () use ($socket) { + $socket->close(); + }); + }); + + $this->base = str_replace('tcp:', 'http:', $socket->getAddress()) . '/'; + + // use HTTP/1.0 to disable HTTP keep-alive + $this->browser = $this->browser->withProtocolVersion('1.0'); + + $response = \React\Async\await($this->browser->get($this->base . 'get')); + assert($response instanceof ResponseInterface); + $this->assertEquals('hello', (string)$response->getBody()); + + $response = \React\Async\await($this->browser->get($this->base . 'get')); + assert($response instanceof ResponseInterface); + $this->assertEquals('hello', (string)$response->getBody()); + } + + public function testRequestWillReuseExistingConnectionForSecondRequestByDefault() { $this->socket->on('connection', $this->expectCallableOnce()); - // remove default `Connection: close` request header to enable keep-alive - $this->browser = $this->browser->withoutHeader('Connection'); + $response = \React\Async\await($this->browser->get($this->base . 'get')); + assert($response instanceof ResponseInterface); + $this->assertEquals('hello', (string)$response->getBody()); + + $response = \React\Async\await($this->browser->get($this->base . 'get')); + assert($response instanceof ResponseInterface); + $this->assertEquals('hello', (string)$response->getBody()); + } + + public function testRequestWithHttp10AndConnectionKeepAliveHeaderWillReuseExistingConnectionForSecondRequest() + { + $this->socket->on('connection', $this->expectCallableOnce()); + + $this->browser = $this->browser->withProtocolVersion('1.0'); + $this->browser = $this->browser->withHeader('Connection', 'keep-alive'); $response = \React\Async\await($this->browser->get($this->base . 'get')); assert($response instanceof ResponseInterface); From bb3154dbaf2dfe3f0467f956a05f614a69d5f1d0 Mon Sep 17 00:00:00 2001 From: Simon Frings Date: Wed, 26 Apr 2023 12:29:24 +0200 Subject: [PATCH 37/58] Prepare v1.9.0 release --- CHANGELOG.md | 35 +++++++++++++++++++++++++++++++++++ README.md | 2 +- 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 00e2d07e..d19639d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,40 @@ # Changelog +## 1.9.0 (2023-04-26) + +This is a **SECURITY** and feature release for the 1.x series of ReactPHP's HTTP component. + +* Security fix: This release fixes a medium severity security issue in ReactPHP's HTTP server component + that affects all versions between `v0.8.0` and `v1.8.0`. All users are encouraged to upgrade immediately. + (CVE-2023-26044 reported and fixed by @WyriHaximus) + +* Feature: Support HTTP keep-alive for HTTP client (reusing persistent connections). + (#481, #484, #486 and #495 by @clue) + + This feature offers significant performance improvements when sending many + requests to the same host as it avoids recreating the underlying TCP/IP + connection and repeating the TLS handshake for secure HTTPS requests. + + ```php + $browser = new React\Http\Browser(); + + // Up to 300% faster! HTTP keep-alive is enabled by default + $response = React\Async\await($browser->get('https://httpbingo.org/redirect/6')); + assert($response instanceof Psr\Http\Message\ResponseInterface); + ``` + +* Feature: Add `Request` class to represent outgoing HTTP request message. + (#480 by @clue) + +* Feature: Preserve request method and body for `307 Temporary Redirect` and `308 Permanent Redirect`. + (#442 by @dinooo13) + +* Feature: Include buffer logic to avoid dependency on reactphp/promise-stream. + (#482 by @clue) + +* Improve test suite and project setup and report failed assertions. + (#478 by @clue, #487 and #491 by @WyriHaximus and #475 and #479 by @SimonFrings) + ## 1.8.0 (2022-09-29) * Feature: Support for default request headers. diff --git a/README.md b/README.md index 271f5e87..955e0a99 100644 --- a/README.md +++ b/README.md @@ -2976,7 +2976,7 @@ This project follows [SemVer](https://semver.org/). This will install the latest supported version: ```bash -composer require react/http:^1.8 +composer require react/http:^1.9 ``` See also the [CHANGELOG](CHANGELOG.md) for details about version upgrades. From 94222ad193ac03265da34124e5681f03e73558c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Thu, 29 Jun 2023 00:45:47 +0200 Subject: [PATCH 38/58] Update test suite to avoid unhandled promise rejections --- tests/Io/TransactionTest.php | 2 + .../LimitConcurrentRequestsMiddlewareTest.php | 45 +++++++++++++++---- 2 files changed, 38 insertions(+), 9 deletions(-) diff --git a/tests/Io/TransactionTest.php b/tests/Io/TransactionTest.php index 140c53e0..284d059f 100644 --- a/tests/Io/TransactionTest.php +++ b/tests/Io/TransactionTest.php @@ -321,6 +321,8 @@ public function testTimeoutExplicitOptionWillNotStartTimeoutTimerWhenStreamingRe $stream->close(); $this->assertInstanceOf('React\Promise\PromiseInterface', $promise); + + $promise->then(null, $this->expectCallableOnce()); // avoid reporting unhandled rejection } public function testTimeoutExplicitOptionWillRejectWhenTimerFiresAfterStreamingRequestBodyCloses() diff --git a/tests/Middleware/LimitConcurrentRequestsMiddlewareTest.php b/tests/Middleware/LimitConcurrentRequestsMiddlewareTest.php index 6c63a94f..faf27cb6 100644 --- a/tests/Middleware/LimitConcurrentRequestsMiddlewareTest.php +++ b/tests/Middleware/LimitConcurrentRequestsMiddlewareTest.php @@ -64,7 +64,10 @@ public function testLimitOneRequestConcurrently() $this->assertFalse($calledB); $this->assertFalse($calledC); - $limitHandlers($requestB, $nextB); + $promise = $limitHandlers($requestB, $nextB); + + assert($promise instanceof PromiseInterface); + $promise->then(null, $this->expectCallableOnce()); // avoid reporting unhandled rejection $this->assertTrue($calledA); $this->assertFalse($calledB); @@ -188,10 +191,13 @@ public function testStreamDoesPauseAndThenResumeWhenDequeued() $limitHandlers = new LimitConcurrentRequestsMiddleware(1); $deferred = new Deferred(); - $limitHandlers(new ServerRequest('GET', 'https://example.com/'), function () use ($deferred) { + $promise = $limitHandlers(new ServerRequest('GET', 'https://example.com/'), function () use ($deferred) { return $deferred->promise(); }); + assert($promise instanceof PromiseInterface); + $promise->then(null, $this->expectCallableOnce()); // avoid reporting unhandled rejection + $limitHandlers(new ServerRequest('GET', 'https://example.com/', array(), $body), function () {}); $deferred->reject(new \RuntimeException()); @@ -283,10 +289,13 @@ public function testReceivesNextRequestAfterPreviousHandlerIsSettled() $deferred = new Deferred(); $middleware = new LimitConcurrentRequestsMiddleware(1); - $middleware($request, function () use ($deferred) { + $promise = $middleware($request, function () use ($deferred) { return $deferred->promise(); }); + assert($promise instanceof PromiseInterface); + $promise->then(null, $this->expectCallableOnce()); // avoid reporting unhandled rejection + $deferred->reject(new \RuntimeException()); $middleware($request, $this->expectCallableOnceWith($request)); @@ -303,10 +312,13 @@ public function testReceivesNextRequestWhichThrowsAfterPreviousHandlerIsSettled( $deferred = new Deferred(); $middleware = new LimitConcurrentRequestsMiddleware(1); - $middleware($request, function () use ($deferred) { + $promise = $middleware($request, function () use ($deferred) { return $deferred->promise(); }); + assert($promise instanceof PromiseInterface); + $promise->then(null, $this->expectCallableOnce()); // avoid reporting unhandled rejection + $second = $middleware($request, function () { throw new \RuntimeException(); }); @@ -443,10 +455,13 @@ public function testReceivesStreamingBodyChangesInstanceWithCustomBodyButSameDat $middleware = new LimitConcurrentRequestsMiddleware(1); $deferred = new Deferred(); - $middleware(new ServerRequest('GET', 'https://example.com/'), function () use ($deferred) { + $promise = $middleware(new ServerRequest('GET', 'https://example.com/'), function () use ($deferred) { return $deferred->promise(); }); + assert($promise instanceof PromiseInterface); + $promise->then(null, $this->expectCallableOnce()); // avoid reporting unhandled rejection + $req = null; $middleware($request, function (ServerRequestInterface $request) use (&$req) { $req = $request; @@ -471,10 +486,13 @@ public function testReceivesNextStreamingBodyWithBufferedDataAfterPreviousHandle { $deferred = new Deferred(); $middleware = new LimitConcurrentRequestsMiddleware(1); - $middleware(new ServerRequest('GET', 'http://example.com/'), function () use ($deferred) { + $promise = $middleware(new ServerRequest('GET', 'http://example.com/'), function () use ($deferred) { return $deferred->promise(); }); + assert($promise instanceof PromiseInterface); + $promise->then(null, $this->expectCallableOnce()); // avoid reporting unhandled rejection + $stream = new ThroughStream(); $request = new ServerRequest( 'POST', @@ -498,10 +516,13 @@ public function testReceivesNextStreamingBodyAndDoesNotEmitDataIfExplicitlyClose { $deferred = new Deferred(); $middleware = new LimitConcurrentRequestsMiddleware(1); - $middleware(new ServerRequest('GET', 'http://example.com/'), function () use ($deferred) { + $promise = $middleware(new ServerRequest('GET', 'http://example.com/'), function () use ($deferred) { return $deferred->promise(); }); + assert($promise instanceof PromiseInterface); + $promise->then(null, $this->expectCallableOnce()); // avoid reporting unhandled rejection + $stream = new ThroughStream(); $request = new ServerRequest( 'POST', @@ -526,10 +547,13 @@ public function testReceivesNextStreamingBodyAndDoesNotEmitDataIfExplicitlyPause { $deferred = new Deferred(); $middleware = new LimitConcurrentRequestsMiddleware(1); - $middleware(new ServerRequest('GET', 'http://example.com/'), function () use ($deferred) { + $promise = $middleware(new ServerRequest('GET', 'http://example.com/'), function () use ($deferred) { return $deferred->promise(); }); + assert($promise instanceof PromiseInterface); + $promise->then(null, $this->expectCallableOnce()); // avoid reporting unhandled rejection + $stream = new ThroughStream(); $request = new ServerRequest( 'POST', @@ -554,10 +578,13 @@ public function testReceivesNextStreamingBodyAndDoesEmitDataImmediatelyIfExplici { $deferred = new Deferred(); $middleware = new LimitConcurrentRequestsMiddleware(1); - $middleware(new ServerRequest('GET', 'http://example.com/'), function () use ($deferred) { + $promise = $middleware(new ServerRequest('GET', 'http://example.com/'), function () use ($deferred) { return $deferred->promise(); }); + assert($promise instanceof PromiseInterface); + $promise->then(null, $this->expectCallableOnce()); // avoid reporting unhandled rejection + $stream = new ThroughStream(); $request = new ServerRequest( 'POST', From 7a5b57c2f6e458cb8788c3692486e0756c9fc390 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Fri, 7 Jul 2023 13:01:17 +0200 Subject: [PATCH 39/58] Update tests to remove defunct httpbin.org --- examples/03-client-request-any.php | 4 +- examples/04-client-post-json.php | 2 +- examples/05-client-put-xml.php | 2 +- .../22-client-stream-upload-from-stdin.php | 4 +- examples/91-client-benchmark-download.php | 2 +- tests/Client/FunctionalIntegrationTest.php | 66 ------------------- tests/FunctionalBrowserTest.php | 4 +- 7 files changed, 9 insertions(+), 75 deletions(-) diff --git a/examples/03-client-request-any.php b/examples/03-client-request-any.php index 0c96b684..d7558bd6 100644 --- a/examples/03-client-request-any.php +++ b/examples/03-client-request-any.php @@ -12,10 +12,10 @@ $promises = array( $client->head('http://www.github.com/clue/http-react'), - $client->get('https://httpbin.org/'), + $client->get('https://httpbingo.org/'), $client->get('https://google.com/'), $client->get('http://www.lueck.tv/psocksd'), - $client->get('http://www.httpbin.org/absolute-redirect/5') + $client->get('http://httpbingo.org/absolute-redirect/5') ); React\Promise\any($promises)->then(function (ResponseInterface $response) use ($promises) { diff --git a/examples/04-client-post-json.php b/examples/04-client-post-json.php index b01ada13..477c3426 100644 --- a/examples/04-client-post-json.php +++ b/examples/04-client-post-json.php @@ -16,7 +16,7 @@ ); $client->post( - 'https://httpbin.org/post', + 'https://httpbingo.org/post', array( 'Content-Type' => 'application/json' ), diff --git a/examples/05-client-put-xml.php b/examples/05-client-put-xml.php index 231e2ca4..6055363a 100644 --- a/examples/05-client-put-xml.php +++ b/examples/05-client-put-xml.php @@ -13,7 +13,7 @@ $child->name = 'Christian Lück'; $client->put( - 'https://httpbin.org/put', + 'https://httpbingo.org/put', array( 'Content-Type' => 'text/xml' ), diff --git a/examples/22-client-stream-upload-from-stdin.php b/examples/22-client-stream-upload-from-stdin.php index b00fbc5e..f29b08ab 100644 --- a/examples/22-client-stream-upload-from-stdin.php +++ b/examples/22-client-stream-upload-from-stdin.php @@ -16,10 +16,10 @@ $in = new ReadableResourceStream(STDIN); -$url = isset($argv[1]) ? $argv[1] : 'https://httpbin.org/post'; +$url = isset($argv[1]) ? $argv[1] : 'https://httpbingo.org/post'; echo 'Sending STDIN as POST to ' . $url . '…' . PHP_EOL; -$client->post($url, array(), $in)->then(function (ResponseInterface $response) { +$client->post($url, array('Content-Type' => 'text/plain'), $in)->then(function (ResponseInterface $response) { echo 'Received' . PHP_EOL . Psr7\str($response); }, function (Exception $e) { echo 'Error: ' . $e->getMessage() . PHP_EOL; diff --git a/examples/91-client-benchmark-download.php b/examples/91-client-benchmark-download.php index 49693baf..44e99087 100644 --- a/examples/91-client-benchmark-download.php +++ b/examples/91-client-benchmark-download.php @@ -1,7 +1,7 @@ markTestSkipped('Not supported on HHVM'); - } - - // max_nesting_level was set to 100 for PHP Versions < 5.4 which resulted in failing test for legacy PHP - ini_set('xdebug.max_nesting_level', 256); - - $client = new Client(new ClientConnectionManager(new Connector(), Loop::get())); - - $data = str_repeat('.', 33000); - $request = $client->request(new Request('POST', 'https://' . (mt_rand(0, 1) === 0 ? 'eu.' : '') . 'httpbin.org/post', array('Content-Length' => strlen($data)), '', '1.0')); - - $deferred = new Deferred(); - $request->on('response', function (ResponseInterface $response, ReadableStreamInterface $body) use ($deferred) { - $deferred->resolve(Stream\buffer($body)); - }); - - $request->on('error', 'printf'); - $request->on('error', $this->expectCallableNever()); - - $request->end($data); - - $buffer = \React\Async\await(\React\Promise\Timer\timeout($deferred->promise(), self::TIMEOUT_REMOTE)); - - $this->assertNotEquals('', $buffer); - - $parsed = json_decode($buffer, true); - $this->assertTrue(is_array($parsed) && isset($parsed['data'])); - $this->assertEquals(strlen($data), strlen($parsed['data'])); - $this->assertEquals($data, $parsed['data']); - } - - /** @group internet */ - public function testPostJsonReturnsData() - { - if (defined('HHVM_VERSION')) { - $this->markTestSkipped('Not supported on HHVM'); - } - - $client = new Client(new ClientConnectionManager(new Connector(), Loop::get())); - - $data = json_encode(array('numbers' => range(1, 50))); - $request = $client->request(new Request('POST', 'https://httpbin.org/post', array('Content-Length' => strlen($data), 'Content-Type' => 'application/json'), '', '1.0')); - - $deferred = new Deferred(); - $request->on('response', function (ResponseInterface $response, ReadableStreamInterface $body) use ($deferred) { - $deferred->resolve(Stream\buffer($body)); - }); - - $request->on('error', 'printf'); - $request->on('error', $this->expectCallableNever()); - - $request->end($data); - - $buffer = \React\Async\await(\React\Promise\Timer\timeout($deferred->promise(), self::TIMEOUT_REMOTE)); - - $this->assertNotEquals('', $buffer); - - $parsed = json_decode($buffer, true); - $this->assertTrue(is_array($parsed) && isset($parsed['json'])); - $this->assertEquals(json_decode($data, true), $parsed['json']); - } - /** @group internet */ public function testCancelPendingConnectionEmitsClose() { diff --git a/tests/FunctionalBrowserTest.php b/tests/FunctionalBrowserTest.php index 35b96eb6..7b8ff84b 100644 --- a/tests/FunctionalBrowserTest.php +++ b/tests/FunctionalBrowserTest.php @@ -230,7 +230,7 @@ public function testRequestWithAuthenticationSucceeds() /** * ```bash - * $ curl -vL "http://httpbin.org/redirect-to?url=http://user:pass@httpbin.org/basic-auth/user/pass" + * $ curl -vL "http://httpbingo.org/redirect-to?url=http://user:pass@httpbingo.org/basic-auth/user/pass" * ``` * * @doesNotPerformAssertions @@ -244,7 +244,7 @@ public function testRedirectToPageWithAuthenticationSendsAuthenticationFromLocat /** * ```bash - * $ curl -vL "http://unknown:invalid@httpbin.org/redirect-to?url=http://user:pass@httpbin.org/basic-auth/user/pass" + * $ curl -vL "http://unknown:invalid@httpbingo.org/redirect-to?url=http://user:pass@httpbingo.org/basic-auth/user/pass" * ``` * * @doesNotPerformAssertions From eb83eb06bd5ea052638266396f363fb02891cfac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Tue, 19 Sep 2023 17:45:06 +0200 Subject: [PATCH 40/58] Test on PHP 8.3 and update test environment --- .github/workflows/ci.yml | 5 +++-- composer.json | 2 +- phpunit.xml.dist | 6 +++--- phpunit.xml.legacy | 2 +- 4 files changed, 8 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 55bbaa5b..3666cd47 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,6 +11,7 @@ jobs: strategy: matrix: php: + - 8.3 - 8.2 - 8.1 - 8.0 @@ -24,7 +25,7 @@ jobs: - 5.4 - 5.3 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php }} @@ -41,7 +42,7 @@ jobs: runs-on: ubuntu-22.04 continue-on-error: true steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - run: cp "$(which composer)" composer.phar && ./composer.phar self-update --2.2 # downgrade Composer for HHVM - name: Run hhvm composer.phar install uses: docker://hhvm/hhvm:3.30-lts-latest diff --git a/composer.json b/composer.json index 59736ddd..5198470e 100644 --- a/composer.json +++ b/composer.json @@ -40,7 +40,7 @@ "clue/http-proxy-react": "^1.8", "clue/reactphp-ssh-proxy": "^1.4", "clue/socks-react": "^1.4", - "phpunit/phpunit": "^9.5 || ^5.7 || ^4.8.35", + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", "react/async": "^4 || ^3 || ^2", "react/promise-stream": "^1.4", "react/promise-timer": "^1.9" diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 7a9577e9..ac542e77 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,8 +1,8 @@ - + - + diff --git a/phpunit.xml.legacy b/phpunit.xml.legacy index ac5600ae..89161168 100644 --- a/phpunit.xml.legacy +++ b/phpunit.xml.legacy @@ -18,7 +18,7 @@ - + From 9bf5456d95a0c4607fac0ab2f4c2926c341029d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Thu, 22 Feb 2024 15:11:43 +0100 Subject: [PATCH 41/58] Fix empty streaming request body, omit `Transfer-Encoding: chunked` --- src/Io/Sender.php | 2 +- tests/Io/SenderTest.php | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/Io/Sender.php b/src/Io/Sender.php index 3598d31a..1d563891 100644 --- a/src/Io/Sender.php +++ b/src/Io/Sender.php @@ -90,7 +90,7 @@ public function send(RequestInterface $request) } elseif ($size === 0 && \in_array($request->getMethod(), array('POST', 'PUT', 'PATCH'))) { // only assign a "Content-Length: 0" request header if the body is expected for certain methods $request = $request->withHeader('Content-Length', '0'); - } elseif ($body instanceof ReadableStreamInterface && $body->isReadable() && !$request->hasHeader('Content-Length')) { + } elseif ($body instanceof ReadableStreamInterface && $size !== 0 && $body->isReadable() && !$request->hasHeader('Content-Length')) { // use "Transfer-Encoding: chunked" when this is a streaming body and body size is unknown $request = $request->withHeader('Transfer-Encoding', 'chunked'); } else { diff --git a/tests/Io/SenderTest.php b/tests/Io/SenderTest.php index 3c8c4761..03a9b56e 100644 --- a/tests/Io/SenderTest.php +++ b/tests/Io/SenderTest.php @@ -5,6 +5,7 @@ use Psr\Http\Message\RequestInterface; use React\Http\Client\Client as HttpClient; use React\Http\Io\ClientConnectionManager; +use React\Http\Io\EmptyBodyStream; use React\Http\Io\ReadableBodyStream; use React\Http\Io\Sender; use React\Http\Message\Request; @@ -264,6 +265,21 @@ public function testSendGetWillNotPassContentLengthHeaderForEmptyRequestBody() $sender->send($request); } + public function testSendGetWithEmptyBodyStreamWillNotPassContentLengthOrTransferEncodingHeader() + { + $client = $this->getMockBuilder('React\Http\Client\Client')->disableOriginalConstructor()->getMock(); + $client->expects($this->once())->method('request')->with($this->callback(function (RequestInterface $request) { + return !$request->hasHeader('Content-Length') && !$request->hasHeader('Transfer-Encoding'); + }))->willReturn($this->getMockBuilder('React\Http\Io\ClientRequestStream')->disableOriginalConstructor()->getMock()); + + $sender = new Sender($client); + + $body = new EmptyBodyStream(); + $request = new Request('GET', 'http://www.google.com/', array(), $body); + + $sender->send($request); + } + public function testSendCustomMethodWillNotPassContentLengthHeaderForEmptyRequestBody() { $client = $this->getMockBuilder('React\Http\Client\Client')->disableOriginalConstructor()->getMock(); From 638c5dddd6f4c51c4025844ca3fbebf26e9919a9 Mon Sep 17 00:00:00 2001 From: Cees-Jan Kiewiet Date: Sat, 17 Feb 2024 11:17:31 +0100 Subject: [PATCH 42/58] [1.x] Ensure connection close handler is cleaned up for each request This changeset resolves a small memory leak that causes roughly 1KB per connection tops. Which isn't a big issue but will make memory fluctuate more. The changeset doesn't introduce any performance degradation. Resolves: #514 Builds on top of: #405, #467, and many others --- src/Io/StreamingServer.php | 13 +++++++--- tests/Io/StreamingServerTest.php | 42 ++++++++++++++++++++++++++------ 2 files changed, 45 insertions(+), 10 deletions(-) diff --git a/src/Io/StreamingServer.php b/src/Io/StreamingServer.php index 13f0b0c4..790c8cc1 100644 --- a/src/Io/StreamingServer.php +++ b/src/Io/StreamingServer.php @@ -157,10 +157,17 @@ public function handleRequest(ConnectionInterface $conn, ServerRequestInterface } // cancel pending promise once connection closes + $connectionOnCloseResponseCancelerHandler = function () {}; if ($response instanceof PromiseInterface && \method_exists($response, 'cancel')) { - $conn->on('close', function () use ($response) { + $connectionOnCloseResponseCanceler = function () use ($response) { $response->cancel(); - }); + }; + $connectionOnCloseResponseCancelerHandler = function () use ($connectionOnCloseResponseCanceler, $conn) { + if ($connectionOnCloseResponseCanceler !== null) { + $conn->removeListener('close', $connectionOnCloseResponseCanceler); + } + }; + $conn->on('close', $connectionOnCloseResponseCanceler); } // happy path: response returned, handle and return immediately @@ -201,7 +208,7 @@ function ($error) use ($that, $conn, $request) { $that->emit('error', array($exception)); return $that->writeError($conn, Response::STATUS_INTERNAL_SERVER_ERROR, $request); } - ); + )->then($connectionOnCloseResponseCancelerHandler, $connectionOnCloseResponseCancelerHandler); } /** @internal */ diff --git a/tests/Io/StreamingServerTest.php b/tests/Io/StreamingServerTest.php index a2700b86..a578797e 100644 --- a/tests/Io/StreamingServerTest.php +++ b/tests/Io/StreamingServerTest.php @@ -25,9 +25,17 @@ class StreamingServerTest extends TestCase */ public function setUpConnectionMockAndSocket() { - $this->connection = $this->getMockBuilder('React\Socket\Connection') + $this->connection = $this->mockConnection(); + + $this->socket = new SocketServerStub(); + } + + + private function mockConnection(array $additionalMethods = null) + { + $connection = $this->getMockBuilder('React\Socket\Connection') ->disableOriginalConstructor() - ->setMethods( + ->setMethods(array_merge( array( 'write', 'end', @@ -39,14 +47,15 @@ public function setUpConnectionMockAndSocket() 'getRemoteAddress', 'getLocalAddress', 'pipe' - ) - ) + ), + (is_array($additionalMethods) ? $additionalMethods : array()) + )) ->getMock(); - $this->connection->method('isWritable')->willReturn(true); - $this->connection->method('isReadable')->willReturn(true); + $connection->method('isWritable')->willReturn(true); + $connection->method('isReadable')->willReturn(true); - $this->socket = new SocketServerStub(); + return $connection; } public function testRequestEventWillNotBeEmittedForIncompleteHeaders() @@ -3245,6 +3254,25 @@ public function testNewConnectionWillInvokeParserTwiceAfterInvokingRequestHandle $this->assertCount(1, $this->connection->listeners('close')); } + public function testCompletingARequestWillRemoveConnectionOnCloseListener() + { + $connection = $this->mockConnection(array('removeListener')); + + $request = new ServerRequest('GET', 'http://localhost/'); + + $server = new StreamingServer(Loop::get(), function () { + return \React\Promise\resolve(new Response()); + }); + + $server->listen($this->socket); + $this->socket->emit('connection', array($connection)); + + $connection->expects($this->once())->method('removeListener'); + + // pretend parser just finished parsing + $server->handleRequest($connection, $request); + } + private function createGetRequest() { $data = "GET / HTTP/1.1\r\n"; From 1bbd7f921f6a762852c9566742c20efba4672ac2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Fri, 18 Nov 2022 16:40:33 +0100 Subject: [PATCH 43/58] Update `Response` class to build on top of abstract message class --- src/Message/Response.php | 102 ++++++++++++++++++++++++++++--- tests/Io/StreamingServerTest.php | 4 +- tests/Message/ResponseTest.php | 33 ++++++++++ 3 files changed, 128 insertions(+), 11 deletions(-) diff --git a/src/Message/Response.php b/src/Message/Response.php index edd6245b..c50d0cee 100644 --- a/src/Message/Response.php +++ b/src/Message/Response.php @@ -3,11 +3,12 @@ namespace React\Http\Message; use Fig\Http\Message\StatusCodeInterface; +use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\StreamInterface; use React\Http\Io\BufferedBody; use React\Http\Io\HttpBodyStream; use React\Stream\ReadableStreamInterface; -use RingCentral\Psr7\Response as Psr7Response; +use RingCentral\Psr7\MessageTrait; /** * Represents an outgoing server response message. @@ -40,7 +41,7 @@ * * @see \Psr\Http\Message\ResponseInterface */ -final class Response extends Psr7Response implements StatusCodeInterface +final class Response extends MessageTrait implements ResponseInterface, StatusCodeInterface { /** * Create an HTML response @@ -257,6 +258,41 @@ public static function xml($xml) return new self(self::STATUS_OK, array('Content-Type' => 'application/xml'), $xml); } + /** + * @var bool + * @see self::$phrasesMap + */ + private static $phrasesInitialized = false; + + /** + * Map of standard HTTP status codes to standard reason phrases. + * + * This map will be fully populated with all standard reason phrases on + * first access. By default, it only contains a subset of HTTP status codes + * that have a custom mapping to reason phrases (such as those with dashes + * and all caps words). See `self::STATUS_*` for all possible status code + * constants. + * + * @var array + * @see self::STATUS_* + * @see self::getReasonPhraseForStatusCode() + */ + private static $phrasesMap = array( + 200 => 'OK', + 203 => 'Non-Authoritative Information', + 207 => 'Multi-Status', + 226 => 'IM Used', + 414 => 'URI Too Large', + 418 => 'I\'m a teapot', + 505 => 'HTTP Version Not Supported' + ); + + /** @var int */ + private $statusCode; + + /** @var string */ + private $reasonPhrase; + /** * @param int $status HTTP status code (e.g. 200/404), see `self::STATUS_*` constants * @param array $headers additional response headers @@ -280,12 +316,60 @@ public function __construct( throw new \InvalidArgumentException('Invalid response body given'); } - parent::__construct( - $status, - $headers, - $body, - $version, - $reason - ); + $this->protocol = (string) $version; + $this->setHeaders($headers); + $this->stream = $body; + + $this->statusCode = (int) $status; + $this->reasonPhrase = ($reason !== '' && $reason !== null) ? (string) $reason : self::getReasonPhraseForStatusCode($status); + } + + public function getStatusCode() + { + return $this->statusCode; + } + + public function withStatus($code, $reasonPhrase = '') + { + if ((string) $reasonPhrase === '') { + $reasonPhrase = self::getReasonPhraseForStatusCode($code); + } + + if ($this->statusCode === (int) $code && $this->reasonPhrase === (string) $reasonPhrase) { + return $this; + } + + $response = clone $this; + $response->statusCode = (int) $code; + $response->reasonPhrase = (string) $reasonPhrase; + + return $response; + } + + public function getReasonPhrase() + { + return $this->reasonPhrase; + } + + /** + * @param int $code + * @return string default reason phrase for given status code or empty string if unknown + */ + private static function getReasonPhraseForStatusCode($code) + { + if (!self::$phrasesInitialized) { + self::$phrasesInitialized = true; + + // map all `self::STATUS_` constants from status code to reason phrase + // e.g. `self::STATUS_NOT_FOUND = 404` will be mapped to `404 Not Found` + $ref = new \ReflectionClass(__CLASS__); + foreach ($ref->getConstants() as $name => $value) { + if (!isset(self::$phrasesMap[$value]) && \strpos($name, 'STATUS_') === 0) { + self::$phrasesMap[$value] = \ucwords(\strtolower(\str_replace('_', ' ', \substr($name, 7)))); + } + } + } + + return isset(self::$phrasesMap[$code]) ? self::$phrasesMap[$code] : ''; } } diff --git a/tests/Io/StreamingServerTest.php b/tests/Io/StreamingServerTest.php index a578797e..64566ddc 100644 --- a/tests/Io/StreamingServerTest.php +++ b/tests/Io/StreamingServerTest.php @@ -1567,9 +1567,9 @@ function ($data) use (&$buffer) { $this->assertInstanceOf('InvalidArgumentException', $error); - $this->assertContainsString("HTTP/1.1 505 HTTP Version not supported\r\n", $buffer); + $this->assertContainsString("HTTP/1.1 505 HTTP Version Not Supported\r\n", $buffer); $this->assertContainsString("\r\n\r\n", $buffer); - $this->assertContainsString("Error 505: HTTP Version not supported", $buffer); + $this->assertContainsString("Error 505: HTTP Version Not Supported", $buffer); } public function testRequestOverflowWillEmitErrorAndSendErrorResponse() diff --git a/tests/Message/ResponseTest.php b/tests/Message/ResponseTest.php index ed21cdc2..88b56945 100644 --- a/tests/Message/ResponseTest.php +++ b/tests/Message/ResponseTest.php @@ -54,6 +54,39 @@ public function testResourceBodyWillThrow() new Response(200, array(), tmpfile()); } + public function testWithStatusReturnsNewInstanceWhenStatusIsChanged() + { + $response = new Response(200); + + $new = $response->withStatus(404); + $this->assertNotSame($response, $new); + $this->assertEquals(404, $new->getStatusCode()); + $this->assertEquals('Not Found', $new->getReasonPhrase()); + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals('OK', $response->getReasonPhrase()); + } + + public function testWithStatusReturnsSameInstanceWhenStatusIsUnchanged() + { + $response = new Response(200); + + $new = $response->withStatus(200); + $this->assertSame($response, $new); + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals('OK', $response->getReasonPhrase()); + } + + public function testWithStatusReturnsNewInstanceWhenStatusIsUnchangedButReasonIsChanged() + { + $response = new Response(200); + + $new = $response->withStatus(200, 'Quite Ok'); + $this->assertNotSame($response, $new); + $this->assertEquals(200, $new->getStatusCode()); + $this->assertEquals('Quite Ok', $new->getReasonPhrase()); + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals('OK', $response->getReasonPhrase()); + } public function testHtmlMethodReturnsHtmlResponse() { From 518ca68ca8f03f61e9e662dedb1ce2383307a677 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Fri, 15 Sep 2023 20:08:07 +0200 Subject: [PATCH 44/58] Add internal `AbstractMessage` base class (PSR-7) --- README.md | 3 +- src/Io/AbstractMessage.php | 164 +++++++++++++++++ src/Message/Response.php | 11 +- tests/Io/AbstractMessageTest.php | 222 ++++++++++++++++++++++++ tests/Io/MiddlewareRunnerTest.php | 2 +- tests/Message/ResponseExceptionTest.php | 2 +- 6 files changed, 393 insertions(+), 11 deletions(-) create mode 100644 src/Io/AbstractMessage.php create mode 100644 tests/Io/AbstractMessageTest.php diff --git a/README.md b/README.md index 955e0a99..31d0430a 100644 --- a/README.md +++ b/README.md @@ -2448,8 +2448,7 @@ constants with the `STATUS_*` prefix. For instance, the `200 OK` and `404 Not Found` status codes can used as `Response::STATUS_OK` and `Response::STATUS_NOT_FOUND` respectively. -> Internally, this implementation builds on top of an existing incoming - response message and only adds required streaming support. This base class is +> Internally, this implementation builds on top of a base class which is considered an implementation detail that may change in the future. ##### html() diff --git a/src/Io/AbstractMessage.php b/src/Io/AbstractMessage.php new file mode 100644 index 00000000..8523d6cd --- /dev/null +++ b/src/Io/AbstractMessage.php @@ -0,0 +1,164 @@ + */ + private $headers = array(); + + /** @var array */ + private $headerNamesLowerCase = array(); + + /** @var string */ + private $protocolVersion; + + /** @var StreamInterface */ + private $body; + + /** + * @param string $protocolVersion + * @param array $headers + * @param StreamInterface $body + */ + protected function __construct($protocolVersion, array $headers, StreamInterface $body) + { + foreach ($headers as $name => $value) { + if ($value !== array()) { + if (\is_array($value)) { + foreach ($value as &$one) { + $one = (string) $one; + } + } else { + $value = array((string) $value); + } + + $lower = \strtolower($name); + if (isset($this->headerNamesLowerCase[$lower])) { + $value = \array_merge($this->headers[$this->headerNamesLowerCase[$lower]], $value); + unset($this->headers[$this->headerNamesLowerCase[$lower]]); + } + + $this->headers[$name] = $value; + $this->headerNamesLowerCase[$lower] = $name; + } + } + + $this->protocolVersion = (string) $protocolVersion; + $this->body = $body; + } + + public function getProtocolVersion() + { + return $this->protocolVersion; + } + + public function withProtocolVersion($version) + { + if ((string) $version === $this->protocolVersion) { + return $this; + } + + $message = clone $this; + $message->protocolVersion = (string) $version; + + return $message; + } + + public function getHeaders() + { + return $this->headers; + } + + public function hasHeader($name) + { + return isset($this->headerNamesLowerCase[\strtolower($name)]); + } + + public function getHeader($name) + { + $lower = \strtolower($name); + return isset($this->headerNamesLowerCase[$lower]) ? $this->headers[$this->headerNamesLowerCase[$lower]] : array(); + } + + public function getHeaderLine($name) + { + return \implode(', ', $this->getHeader($name)); + } + + public function withHeader($name, $value) + { + if ($value === array()) { + return $this->withoutHeader($name); + } elseif (\is_array($value)) { + foreach ($value as &$one) { + $one = (string) $one; + } + } else { + $value = array((string) $value); + } + + $lower = \strtolower($name); + if (isset($this->headerNamesLowerCase[$lower]) && $this->headerNamesLowerCase[$lower] === (string) $name && $this->headers[$this->headerNamesLowerCase[$lower]] === $value) { + return $this; + } + + $message = clone $this; + if (isset($message->headerNamesLowerCase[$lower])) { + unset($message->headers[$message->headerNamesLowerCase[$lower]]); + } + + $message->headers[$name] = $value; + $message->headerNamesLowerCase[$lower] = $name; + + return $message; + } + + public function withAddedHeader($name, $value) + { + if ($value === array()) { + return $this; + } + + return $this->withHeader($name, \array_merge($this->getHeader($name), \is_array($value) ? $value : array($value))); + } + + public function withoutHeader($name) + { + $lower = \strtolower($name); + if (!isset($this->headerNamesLowerCase[$lower])) { + return $this; + } + + $message = clone $this; + unset($message->headers[$message->headerNamesLowerCase[$lower]], $message->headerNamesLowerCase[$lower]); + + return $message; + } + + public function getBody() + { + return $this->body; + } + + public function withBody(StreamInterface $body) + { + if ($body === $this->body) { + return $this; + } + + $message = clone $this; + $message->body = $body; + + return $message; + } +} diff --git a/src/Message/Response.php b/src/Message/Response.php index c50d0cee..95c82ec8 100644 --- a/src/Message/Response.php +++ b/src/Message/Response.php @@ -5,10 +5,10 @@ use Fig\Http\Message\StatusCodeInterface; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\StreamInterface; +use React\Http\Io\AbstractMessage; use React\Http\Io\BufferedBody; use React\Http\Io\HttpBodyStream; use React\Stream\ReadableStreamInterface; -use RingCentral\Psr7\MessageTrait; /** * Represents an outgoing server response message. @@ -35,13 +35,12 @@ * `404 Not Found` status codes can used as `Response::STATUS_OK` and * `Response::STATUS_NOT_FOUND` respectively. * - * > Internally, this implementation builds on top of an existing incoming - * response message and only adds required streaming support. This base class is + * > Internally, this implementation builds on top a base class which is * considered an implementation detail that may change in the future. * * @see \Psr\Http\Message\ResponseInterface */ -final class Response extends MessageTrait implements ResponseInterface, StatusCodeInterface +final class Response extends AbstractMessage implements ResponseInterface, StatusCodeInterface { /** * Create an HTML response @@ -316,9 +315,7 @@ public function __construct( throw new \InvalidArgumentException('Invalid response body given'); } - $this->protocol = (string) $version; - $this->setHeaders($headers); - $this->stream = $body; + parent::__construct($version, $headers, $body); $this->statusCode = (int) $status; $this->reasonPhrase = ($reason !== '' && $reason !== null) ? (string) $reason : self::getReasonPhraseForStatusCode($status); diff --git a/tests/Io/AbstractMessageTest.php b/tests/Io/AbstractMessageTest.php new file mode 100644 index 00000000..9e2c7d32 --- /dev/null +++ b/tests/Io/AbstractMessageTest.php @@ -0,0 +1,222 @@ + $headers + * @param StreamInterface $body + */ + public function __construct($protocolVersion, array $headers, StreamInterface $body) + { + return parent::__construct($protocolVersion, $headers, $body); + } +} + +class AbstractMessageTest extends TestCase +{ + public function testWithProtocolVersionReturnsNewInstanceWhenProtocolVersionIsChanged() + { + $message = new MessageMock( + '1.1', + array(), + $this->getMockBuilder('Psr\Http\Message\StreamInterface')->getMock() + ); + + $new = $message->withProtocolVersion('1.0'); + $this->assertNotSame($message, $new); + $this->assertEquals('1.0', $new->getProtocolVersion()); + $this->assertEquals('1.1', $message->getProtocolVersion()); + } + + public function testWithProtocolVersionReturnsSameInstanceWhenProtocolVersionIsUnchanged() + { + $message = new MessageMock( + '1.1', + array(), + $this->getMockBuilder('Psr\Http\Message\StreamInterface')->getMock() + ); + + $new = $message->withProtocolVersion('1.1'); + $this->assertSame($message, $new); + $this->assertEquals('1.1', $message->getProtocolVersion()); + } + + public function testHeaderWithStringValue() + { + $message = new MessageMock( + '1.1', + array( + 'Content-Type' => 'text/plain' + ), + $this->getMockBuilder('Psr\Http\Message\StreamInterface')->getMock() + ); + + $this->assertEquals(array('Content-Type' => array('text/plain')), $message->getHeaders()); + + $this->assertEquals(array('text/plain'), $message->getHeader('Content-Type')); + $this->assertEquals(array('text/plain'), $message->getHeader('CONTENT-type')); + + $this->assertEquals('text/plain', $message->getHeaderLine('Content-Type')); + $this->assertEquals('text/plain', $message->getHeaderLine('CONTENT-Type')); + + $this->assertTrue($message->hasHeader('Content-Type')); + $this->assertTrue($message->hasHeader('content-TYPE')); + + $new = $message->withHeader('Content-Type', 'text/plain'); + $this->assertSame($message, $new); + + $new = $message->withHeader('Content-Type', array('text/plain')); + $this->assertSame($message, $new); + + $new = $message->withHeader('content-type', 'text/plain'); + $this->assertNotSame($message, $new); + $this->assertEquals(array('content-type' => array('text/plain')), $new->getHeaders()); + $this->assertEquals(array('Content-Type' => array('text/plain')), $message->getHeaders()); + + $new = $message->withHeader('Content-Type', 'text/html'); + $this->assertNotSame($message, $new); + $this->assertEquals(array('Content-Type' => array('text/html')), $new->getHeaders()); + $this->assertEquals(array('Content-Type' => array('text/plain')), $message->getHeaders()); + + $new = $message->withHeader('Content-Type', array('text/html')); + $this->assertNotSame($message, $new); + $this->assertEquals(array('Content-Type' => array('text/html')), $new->getHeaders()); + $this->assertEquals(array('Content-Type' => array('text/plain')), $message->getHeaders()); + + $new = $message->withAddedHeader('Content-Type', array()); + $this->assertSame($message, $new); + + $new = $message->withoutHeader('Content-Type'); + $this->assertNotSame($message, $new); + $this->assertEquals(array(), $new->getHeaders()); + $this->assertEquals(array('Content-Type' => array('text/plain')), $message->getHeaders()); + } + + public function testHeaderWithMultipleValues() + { + $message = new MessageMock( + '1.1', + array( + 'Set-Cookie' => array( + 'a=1', + 'b=2' + ) + ), + $this->getMockBuilder('Psr\Http\Message\StreamInterface')->getMock() + ); + + $this->assertEquals(array('Set-Cookie' => array('a=1', 'b=2')), $message->getHeaders()); + + $this->assertEquals(array('a=1', 'b=2'), $message->getHeader('Set-Cookie')); + $this->assertEquals(array('a=1', 'b=2'), $message->getHeader('Set-Cookie')); + + $this->assertEquals('a=1, b=2', $message->getHeaderLine('Set-Cookie')); + $this->assertEquals('a=1, b=2', $message->getHeaderLine('Set-Cookie')); + + $this->assertTrue($message->hasHeader('Set-Cookie')); + $this->assertTrue($message->hasHeader('Set-Cookie')); + + $new = $message->withHeader('Set-Cookie', array('a=1', 'b=2')); + $this->assertSame($message, $new); + + $new = $message->withHeader('Set-Cookie', array('a=1', 'b=2', 'c=3')); + $this->assertNotSame($message, $new); + $this->assertEquals(array('Set-Cookie' => array('a=1', 'b=2', 'c=3')), $new->getHeaders()); + $this->assertEquals(array('Set-Cookie' => array('a=1', 'b=2')), $message->getHeaders()); + + $new = $message->withAddedHeader('Set-Cookie', array()); + $this->assertSame($message, $new); + + $new = $message->withAddedHeader('Set-Cookie', 'c=3'); + $this->assertNotSame($message, $new); + $this->assertEquals(array('Set-Cookie' => array('a=1', 'b=2', 'c=3')), $new->getHeaders()); + $this->assertEquals(array('Set-Cookie' => array('a=1', 'b=2')), $message->getHeaders()); + + $new = $message->withAddedHeader('Set-Cookie', array('c=3')); + $this->assertNotSame($message, $new); + $this->assertEquals(array('Set-Cookie' => array('a=1', 'b=2', 'c=3')), $new->getHeaders()); + $this->assertEquals(array('Set-Cookie' => array('a=1', 'b=2')), $message->getHeaders()); + } + + public function testHeaderWithEmptyValue() + { + $message = new MessageMock( + '1.1', + array( + 'Content-Type' => array() + ), + $this->getMockBuilder('Psr\Http\Message\StreamInterface')->getMock() + ); + + $this->assertEquals(array(), $message->getHeaders()); + + $this->assertEquals(array(), $message->getHeader('Content-Type')); + $this->assertEquals('', $message->getHeaderLine('Content-Type')); + $this->assertFalse($message->hasHeader('Content-Type')); + + $new = $message->withHeader('Empty', array()); + $this->assertSame($message, $new); + $this->assertFalse($new->hasHeader('Empty')); + + $new = $message->withAddedHeader('Empty', array()); + $this->assertSame($message, $new); + $this->assertFalse($new->hasHeader('Empty')); + + $new = $message->withoutHeader('Empty'); + $this->assertSame($message, $new); + $this->assertFalse($new->hasHeader('Empty')); + } + + public function testHeaderWithMultipleValuesAcrossMixedCaseNamesInConstructorMergesAllValuesWithNameFromLastNonEmptyValue() + { + $message = new MessageMock( + '1.1', + array( + 'SET-Cookie' => 'a=1', + 'set-cookie' => array('b=2'), + 'set-COOKIE' => array() + ), + $this->getMockBuilder('Psr\Http\Message\StreamInterface')->getMock() + ); + + $this->assertEquals(array('set-cookie' => array('a=1', 'b=2')), $message->getHeaders()); + $this->assertEquals(array('a=1', 'b=2'), $message->getHeader('Set-Cookie')); + } + + public function testWithBodyReturnsNewInstanceWhenBodyIsChanged() + { + $body = $this->getMockBuilder('Psr\Http\Message\StreamInterface')->getMock(); + $message = new MessageMock( + '1.1', + array(), + $body + ); + + $body2 = $this->getMockBuilder('Psr\Http\Message\StreamInterface')->getMock(); + $new = $message->withBody($body2); + $this->assertNotSame($message, $new); + $this->assertSame($body2, $new->getBody()); + $this->assertSame($body, $message->getBody()); + } + + public function testWithBodyReturnsSameInstanceWhenBodyIsUnchanged() + { + $body = $this->getMockBuilder('Psr\Http\Message\StreamInterface')->getMock(); + $message = new MessageMock( + '1.1', + array(), + $body + ); + + $new = $message->withBody($body); + $this->assertSame($message, $new); + $this->assertEquals($body, $message->getBody()); + } +} diff --git a/tests/Io/MiddlewareRunnerTest.php b/tests/Io/MiddlewareRunnerTest.php index ac836f03..762d7bdb 100644 --- a/tests/Io/MiddlewareRunnerTest.php +++ b/tests/Io/MiddlewareRunnerTest.php @@ -6,12 +6,12 @@ use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use React\Http\Io\MiddlewareRunner; +use React\Http\Message\Response; use React\Http\Message\ServerRequest; use React\Promise; use React\Promise\PromiseInterface; use React\Tests\Http\Middleware\ProcessStack; use React\Tests\Http\TestCase; -use RingCentral\Psr7\Response; final class MiddlewareRunnerTest extends TestCase { diff --git a/tests/Message/ResponseExceptionTest.php b/tests/Message/ResponseExceptionTest.php index 33eeea9e..b2eaccd3 100644 --- a/tests/Message/ResponseExceptionTest.php +++ b/tests/Message/ResponseExceptionTest.php @@ -2,9 +2,9 @@ namespace React\Tests\Http\Message; +use React\Http\Message\Response; use React\Http\Message\ResponseException; use PHPUnit\Framework\TestCase; -use RingCentral\Psr7\Response; class ResponseExceptionTest extends TestCase { From 3313e1fb7c9a47416458944f7988db3f476892fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Tue, 12 Mar 2024 15:24:13 +0100 Subject: [PATCH 45/58] Add internal `AbstractRequest` base class for `Request` class (PSR-7) --- README.md | 3 +- src/Io/AbstractRequest.php | 156 +++++++++++ src/Message/Request.php | 7 +- tests/Io/AbstractRequestTest.php | 449 +++++++++++++++++++++++++++++++ 4 files changed, 609 insertions(+), 6 deletions(-) create mode 100644 src/Io/AbstractRequest.php create mode 100644 tests/Io/AbstractRequestTest.php diff --git a/README.md b/README.md index 31d0430a..067e5b9f 100644 --- a/README.md +++ b/README.md @@ -2642,8 +2642,7 @@ This is mostly used internally to represent each outgoing HTTP request message for the HTTP client implementation. Likewise, you can also use this class with other HTTP client implementations and for tests. -> Internally, this implementation builds on top of an existing outgoing - request message and only adds support for streaming. This base class is +> Internally, this implementation builds on top of a base class which is considered an implementation detail that may change in the future. #### ServerRequest diff --git a/src/Io/AbstractRequest.php b/src/Io/AbstractRequest.php new file mode 100644 index 00000000..51059ac5 --- /dev/null +++ b/src/Io/AbstractRequest.php @@ -0,0 +1,156 @@ + $headers + * @param StreamInterface $body + * @param string unknown $protocolVersion + */ + protected function __construct( + $method, + $uri, + array $headers, + StreamInterface $body, + $protocolVersion + ) { + if (\is_string($uri)) { + $uri = new Uri($uri); + } elseif (!$uri instanceof UriInterface) { + throw new \InvalidArgumentException( + 'Argument #2 ($uri) expected string|Psr\Http\Message\UriInterface' + ); + } + + // assign default `Host` request header from URI unless already given explicitly + $host = $uri->getHost(); + if ($host !== '') { + foreach ($headers as $name => $value) { + if (\strtolower($name) === 'host' && $value !== array()) { + $host = ''; + break; + } + } + if ($host !== '') { + $port = $uri->getPort(); + if ($port !== null && (!($port === 80 && $uri->getScheme() === 'http') || !($port === 443 && $uri->getScheme() === 'https'))) { + $host .= ':' . $port; + } + + $headers = array('Host' => $host) + $headers; + } + } + + parent::__construct($protocolVersion, $headers, $body); + + $this->method = $method; + $this->uri = $uri; + } + + public function getRequestTarget() + { + if ($this->requestTarget !== null) { + return $this->requestTarget; + } + + $target = $this->uri->getPath(); + if ($target === '') { + $target = '/'; + } + if (($query = $this->uri->getQuery()) !== '') { + $target .= '?' . $query; + } + + return $target; + } + + public function withRequestTarget($requestTarget) + { + if ((string) $requestTarget === $this->requestTarget) { + return $this; + } + + $request = clone $this; + $request->requestTarget = (string) $requestTarget; + + return $request; + } + + public function getMethod() + { + return $this->method; + } + + public function withMethod($method) + { + if ((string) $method === $this->method) { + return $this; + } + + $request = clone $this; + $request->method = (string) $method; + + return $request; + } + + public function getUri() + { + return $this->uri; + } + + public function withUri(UriInterface $uri, $preserveHost = false) + { + if ($uri === $this->uri) { + return $this; + } + + $request = clone $this; + $request->uri = $uri; + + $host = $uri->getHost(); + $port = $uri->getPort(); + if ($port !== null && $host !== '' && (!($port === 80 && $uri->getScheme() === 'http') || !($port === 443 && $uri->getScheme() === 'https'))) { + $host .= ':' . $port; + } + + // update `Host` request header if URI contains a new host and `$preserveHost` is false + if ($host !== '' && (!$preserveHost || $request->getHeaderLine('Host') === '')) { + // first remove all headers before assigning `Host` header to ensure it always comes first + foreach (\array_keys($request->getHeaders()) as $name) { + $request = $request->withoutHeader($name); + } + + // add `Host` header first, then all other original headers + $request = $request->withHeader('Host', $host); + foreach ($this->withoutHeader('Host')->getHeaders() as $name => $value) { + $request = $request->withHeader($name, $value); + } + } + + return $request; + } +} diff --git a/src/Message/Request.php b/src/Message/Request.php index cf59641e..3de8c1b3 100644 --- a/src/Message/Request.php +++ b/src/Message/Request.php @@ -5,10 +5,10 @@ use Psr\Http\Message\RequestInterface; use Psr\Http\Message\StreamInterface; use Psr\Http\Message\UriInterface; +use React\Http\Io\AbstractRequest; use React\Http\Io\BufferedBody; use React\Http\Io\ReadableBodyStream; use React\Stream\ReadableStreamInterface; -use RingCentral\Psr7\Request as BaseRequest; /** * Respresents an outgoing HTTP request message. @@ -22,13 +22,12 @@ * message for the HTTP client implementation. Likewise, you can also use this * class with other HTTP client implementations and for tests. * - * > Internally, this implementation builds on top of an existing outgoing - * request message and only adds support for streaming. This base class is + * > Internally, this implementation builds on top of a base class which is * considered an implementation detail that may change in the future. * * @see RequestInterface */ -final class Request extends BaseRequest implements RequestInterface +final class Request extends AbstractRequest implements RequestInterface { /** * @param string $method HTTP method for the request. diff --git a/tests/Io/AbstractRequestTest.php b/tests/Io/AbstractRequestTest.php new file mode 100644 index 00000000..28c9eaf1 --- /dev/null +++ b/tests/Io/AbstractRequestTest.php @@ -0,0 +1,449 @@ + $headers + * @param StreamInterface $body + * @param string $protocolVersion + */ + public function __construct( + $method, + $uri, + array $headers, + StreamInterface $body, + $protocolVersion + ) { + parent::__construct($method, $uri, $headers, $body, $protocolVersion); + } +} + +class AbstractRequestTest extends TestCase +{ + public function testCtorWithInvalidUriThrows() + { + $this->setExpectedException('InvalidArgumentException'); + new RequestMock( + 'GET', + null, + array(), + $this->getMockBuilder('Psr\Http\Message\StreamInterface')->getMock(), + '1.1' + ); + } + + public function testGetHeadersReturnsHostHeaderFromUri() + { + $request = new RequestMock( + 'GET', + 'http://example.com/', + array(), + $this->getMockBuilder('Psr\Http\Message\StreamInterface')->getMock(), + '1.1' + ); + + $this->assertEquals(array('Host' => array('example.com')), $request->getHeaders()); + } + + public function testGetHeadersReturnsHostHeaderFromUriWithCustomHttpPort() + { + $request = new RequestMock( + 'GET', + 'http://example.com:8080/', + array(), + $this->getMockBuilder('Psr\Http\Message\StreamInterface')->getMock(), + '1.1' + ); + + $this->assertEquals(array('Host' => array('example.com:8080')), $request->getHeaders()); + } + + public function testGetHeadersReturnsHostHeaderFromUriWithCustomPortHttpOnHttpsPort() + { + $request = new RequestMock( + 'GET', + 'http://example.com:443/', + array(), + $this->getMockBuilder('Psr\Http\Message\StreamInterface')->getMock(), + '1.1' + ); + + $this->assertEquals(array('Host' => array('example.com:443')), $request->getHeaders()); + } + + public function testGetHeadersReturnsHostHeaderFromUriWithCustomPortHttpsOnHttpPort() + { + $request = new RequestMock( + 'GET', + 'https://example.com:80/', + array(), + $this->getMockBuilder('Psr\Http\Message\StreamInterface')->getMock(), + '1.1' + ); + + $this->assertEquals(array('Host' => array('example.com:80')), $request->getHeaders()); + } + + public function testGetHeadersReturnsHostHeaderFromUriWithoutDefaultHttpPort() + { + $request = new RequestMock( + 'GET', + 'http://example.com/', + array(), + $this->getMockBuilder('Psr\Http\Message\StreamInterface')->getMock(), + '1.1' + ); + + $this->assertEquals(array('Host' => array('example.com')), $request->getHeaders()); + } + + public function testGetHeadersReturnsHostHeaderFromUriWithoutDefaultHttpsPort() + { + $request = new RequestMock( + 'GET', + 'https://example.com/', + array(), + $this->getMockBuilder('Psr\Http\Message\StreamInterface')->getMock(), + '1.1' + ); + + $this->assertEquals(array('Host' => array('example.com')), $request->getHeaders()); + } + + public function testGetHeadersReturnsHostHeaderFromUriBeforeOtherHeadersExplicitlyGiven() + { + $request = new RequestMock( + 'GET', + 'http://example.com/', + array( + 'User-Agent' => 'demo' + ), + $this->getMockBuilder('Psr\Http\Message\StreamInterface')->getMock(), + '1.1' + ); + + $this->assertEquals(array('Host' => array('example.com'), 'User-Agent' => array('demo')), $request->getHeaders()); + } + + public function testGetHeadersReturnsHostHeaderFromHeadersExplicitlyGiven() + { + $request = new RequestMock( + 'GET', + 'http://localhost/', + array( + 'Host' => 'example.com:8080' + ), + $this->getMockBuilder('Psr\Http\Message\StreamInterface')->getMock(), + '1.1' + ); + + $this->assertEquals(array('Host' => array('example.com:8080')), $request->getHeaders()); + } + + public function testGetHeadersReturnsHostHeaderFromUriWhenHeadersExplicitlyGivenContainEmptyHostArray() + { + $request = new RequestMock( + 'GET', + 'https://example.com/', + array( + 'Host' => array() + ), + $this->getMockBuilder('Psr\Http\Message\StreamInterface')->getMock(), + '1.1' + ); + + $this->assertEquals(array('Host' => array('example.com')), $request->getHeaders()); + } + + public function testGetRequestTargetReturnsPathAndQueryFromUri() + { + $request = new RequestMock( + 'GET', + 'http://example.com/demo?name=Alice', + array(), + $this->getMockBuilder('Psr\Http\Message\StreamInterface')->getMock(), + '1.1' + ); + + $this->assertEquals('/demo?name=Alice', $request->getRequestTarget()); + } + + public function testGetRequestTargetReturnsSlashOnlyIfUriHasNoPathOrQuery() + { + $request = new RequestMock( + 'GET', + 'http://example.com/', + array(), + $this->getMockBuilder('Psr\Http\Message\StreamInterface')->getMock(), + '1.1' + ); + + $this->assertEquals('/', $request->getRequestTarget()); + } + + public function testGetRequestTargetReturnsRequestTargetInAbsoluteFormIfGivenExplicitly() + { + $request = new RequestMock( + 'GET', + 'http://example.com/demo?name=Alice', + array(), + $this->getMockBuilder('Psr\Http\Message\StreamInterface')->getMock(), + '1.1' + ); + $request = $request->withRequestTarget('http://example.com/demo?name=Alice'); + + $this->assertEquals('http://example.com/demo?name=Alice', $request->getRequestTarget()); + } + + public function testWithRequestTargetReturnsNewInstanceWhenRequestTargetIsChanged() + { + $request = new RequestMock( + 'GET', + 'http://example.com/', + array(), + $this->getMockBuilder('Psr\Http\Message\StreamInterface')->getMock(), + '1.1' + ); + + $new = $request->withRequestTarget('http://example.com/'); + $this->assertNotSame($request, $new); + $this->assertEquals('http://example.com/', $new->getRequestTarget()); + $this->assertEquals('/', $request->getRequestTarget()); + } + + public function testWithRequestTargetReturnsSameInstanceWhenRequestTargetIsUnchanged() + { + $request = new RequestMock( + 'GET', + 'http://example.com/', + array(), + $this->getMockBuilder('Psr\Http\Message\StreamInterface')->getMock(), + '1.1' + ); + $request = $request->withRequestTarget('/'); + + $new = $request->withRequestTarget('/'); + $this->assertSame($request, $new); + $this->assertEquals('/', $request->getRequestTarget()); + } + + public function testWithMethodReturnsNewInstanceWhenMethodIsChanged() + { + $request = new RequestMock( + 'GET', + 'http://example.com/', + array(), + $this->getMockBuilder('Psr\Http\Message\StreamInterface')->getMock(), + '1.1' + ); + + $new = $request->withMethod('POST'); + $this->assertNotSame($request, $new); + $this->assertEquals('POST', $new->getMethod()); + $this->assertEquals('GET', $request->getMethod()); + } + + public function testWithMethodReturnsSameInstanceWhenMethodIsUnchanged() + { + $request = new RequestMock( + 'GET', + 'http://example.com/', + array(), + $this->getMockBuilder('Psr\Http\Message\StreamInterface')->getMock(), + '1.1' + ); + + $new = $request->withMethod('GET'); + $this->assertSame($request, $new); + $this->assertEquals('GET', $request->getMethod()); + } + + public function testGetUriReturnsUriInstanceGivenToCtor() + { + $uri = $this->getMockBuilder('Psr\Http\Message\UriInterface')->getMock(); + + $request = new RequestMock( + 'GET', + $uri, + array(), + $this->getMockBuilder('Psr\Http\Message\StreamInterface')->getMock(), + '1.1' + ); + + $this->assertSame($uri, $request->getUri()); + } + + public function testGetUriReturnsUriInstanceForUriStringGivenToCtor() + { + $request = new RequestMock( + 'GET', + 'http://example.com/', + array(), + $this->getMockBuilder('Psr\Http\Message\StreamInterface')->getMock(), + '1.1' + ); + + $uri = $request->getUri(); + $this->assertInstanceOf('Psr\Http\Message\UriInterface', $uri); + $this->assertEquals('http://example.com/', (string) $uri); + } + + public function testWithUriReturnsNewInstanceWhenUriIsChanged() + { + $request = new RequestMock( + 'GET', + 'http://example.com/', + array(), + $this->getMockBuilder('Psr\Http\Message\StreamInterface')->getMock(), + '1.1' + ); + + $uri = $this->getMockBuilder('Psr\Http\Message\UriInterface')->getMock(); + $new = $request->withUri($uri); + + $this->assertNotSame($request, $new); + $this->assertEquals($uri, $new->getUri()); + $this->assertEquals('http://example.com/', (string) $request->getUri()); + } + + public function testWithUriReturnsSameInstanceWhenUriIsUnchanged() + { + $uri = $this->getMockBuilder('Psr\Http\Message\UriInterface')->getMock(); + + $request = new RequestMock( + 'GET', + $uri, + array(), + $this->getMockBuilder('Psr\Http\Message\StreamInterface')->getMock(), + '1.1' + ); + + $new = $request->withUri($uri); + $this->assertSame($request, $new); + $this->assertEquals($uri, $request->getUri()); + } + + public function testWithUriReturnsNewInstanceWithHostHeaderChangedIfUriContainsHost() + { + $request = new RequestMock( + 'GET', + 'http://example.com/', + array(), + $this->getMockBuilder('Psr\Http\Message\StreamInterface')->getMock(), + '1.1' + ); + + $uri = new Uri('http://localhost/'); + $new = $request->withUri($uri); + + $this->assertNotSame($request, $new); + $this->assertEquals('http://localhost/', (string) $new->getUri()); + $this->assertEquals(array('Host' => array('localhost')), $new->getHeaders()); + } + + public function testWithUriReturnsNewInstanceWithHostHeaderChangedIfUriContainsHostWithCustomPort() + { + $request = new RequestMock( + 'GET', + 'http://example.com/', + array(), + $this->getMockBuilder('Psr\Http\Message\StreamInterface')->getMock(), + '1.1' + ); + + $uri = new Uri('http://localhost:8080/'); + $new = $request->withUri($uri); + + $this->assertNotSame($request, $new); + $this->assertEquals('http://localhost:8080/', (string) $new->getUri()); + $this->assertEquals(array('Host' => array('localhost:8080')), $new->getHeaders()); + } + + public function testWithUriReturnsNewInstanceWithHostHeaderAddedAsFirstHeaderBeforeOthersIfUriContainsHost() + { + $request = new RequestMock( + 'GET', + 'http://example.com/', + array( + 'User-Agent' => 'test' + ), + $this->getMockBuilder('Psr\Http\Message\StreamInterface')->getMock(), + '1.1' + ); + $request = $request->withoutHeader('Host'); + + $uri = new Uri('http://localhost/'); + $new = $request->withUri($uri); + + $this->assertNotSame($request, $new); + $this->assertEquals('http://localhost/', (string) $new->getUri()); + $this->assertEquals(array('Host' => array('localhost'), 'User-Agent' => array('test')), $new->getHeaders()); + } + + public function testWithUriReturnsNewInstanceWithHostHeaderUnchangedIfUriContainsNoHost() + { + $request = new RequestMock( + 'GET', + 'http://example.com/', + array(), + $this->getMockBuilder('Psr\Http\Message\StreamInterface')->getMock(), + '1.1' + ); + + $uri = new Uri('/path'); + $new = $request->withUri($uri); + + $this->assertNotSame($request, $new); + $this->assertEquals('/path', (string) $new->getUri()); + $this->assertEquals(array('Host' => array('example.com')), $new->getHeaders()); + } + + public function testWithUriReturnsNewInstanceWithHostHeaderUnchangedIfPreserveHostIsTrue() + { + $request = new RequestMock( + 'GET', + 'http://example.com/', + array(), + $this->getMockBuilder('Psr\Http\Message\StreamInterface')->getMock(), + '1.1' + ); + + $uri = new Uri('http://localhost/'); + $new = $request->withUri($uri, true); + + $this->assertNotSame($request, $new); + $this->assertEquals('http://localhost/', (string) $new->getUri()); + $this->assertEquals(array('Host' => array('example.com')), $new->getHeaders()); + } + + public function testWithUriReturnsNewInstanceWithHostHeaderAddedAsFirstHeaderNoMatterIfPreserveHostIsTrue() + { + $request = new RequestMock( + 'GET', + 'http://example.com/', + array( + 'User-Agent' => 'test' + ), + $this->getMockBuilder('Psr\Http\Message\StreamInterface')->getMock(), + '1.1' + ); + $request = $request->withoutHeader('Host'); + + $uri = new Uri('http://example.com/'); + $new = $request->withUri($uri, true); + + $this->assertNotSame($request, $new); + $this->assertEquals('http://example.com/', (string) $new->getUri()); + $this->assertEquals(array('Host' => array('example.com'), 'User-Agent' => array('test')), $new->getHeaders()); + } +} From 0638dcdbea657c3226b90dfadd1737d706ca84e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Tue, 12 Mar 2024 18:20:42 +0100 Subject: [PATCH 46/58] Update `ServerRequest` class to build on top of abstract request class --- README.md | 3 +-- src/Message/ServerRequest.php | 25 ++++++++++--------------- tests/Io/StreamingServerTest.php | 32 ++++++++++++++++---------------- 3 files changed, 27 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 067e5b9f..47003770 100644 --- a/README.md +++ b/README.md @@ -2661,8 +2661,7 @@ This is mostly used internally to represent each incoming request message. Likewise, you can also use this class in test cases to test how your web application reacts to certain HTTP requests. -> Internally, this implementation builds on top of an existing outgoing - request message and only adds required server methods. This base class is +> Internally, this implementation builds on top of a base class which is considered an implementation detail that may change in the future. #### ResponseException diff --git a/src/Message/ServerRequest.php b/src/Message/ServerRequest.php index 25532cf4..b5c41413 100644 --- a/src/Message/ServerRequest.php +++ b/src/Message/ServerRequest.php @@ -5,10 +5,10 @@ use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\StreamInterface; use Psr\Http\Message\UriInterface; +use React\Http\Io\AbstractRequest; use React\Http\Io\BufferedBody; use React\Http\Io\HttpBodyStream; use React\Stream\ReadableStreamInterface; -use RingCentral\Psr7\Request as BaseRequest; /** * Respresents an incoming server request message. @@ -24,13 +24,12 @@ * Likewise, you can also use this class in test cases to test how your web * application reacts to certain HTTP requests. * - * > Internally, this implementation builds on top of an existing outgoing - * request message and only adds required server methods. This base class is + * > Internally, this implementation builds on top of a base class which is * considered an implementation detail that may change in the future. * * @see ServerRequestInterface */ -final class ServerRequest extends BaseRequest implements ServerRequestInterface +final class ServerRequest extends AbstractRequest implements ServerRequestInterface { private $attributes = array(); @@ -57,26 +56,22 @@ public function __construct( $version = '1.1', $serverParams = array() ) { - $stream = null; if (\is_string($body)) { $body = new BufferedBody($body); } elseif ($body instanceof ReadableStreamInterface && !$body instanceof StreamInterface) { - $stream = $body; - $body = null; + $temp = new self($method, '', $headers); + $size = (int) $temp->getHeaderLine('Content-Length'); + if (\strtolower($temp->getHeaderLine('Transfer-Encoding')) === 'chunked') { + $size = null; + } + $body = new HttpBodyStream($body, $size); } elseif (!$body instanceof StreamInterface) { throw new \InvalidArgumentException('Invalid server request body given'); } - $this->serverParams = $serverParams; parent::__construct($method, $url, $headers, $body, $version); - if ($stream !== null) { - $size = (int) $this->getHeaderLine('Content-Length'); - if (\strtolower($this->getHeaderLine('Transfer-Encoding')) === 'chunked') { - $size = null; - } - $this->stream = new HttpBodyStream($stream, $size); - } + $this->serverParams = $serverParams; $query = $this->getUri()->getQuery(); if ($query !== '') { diff --git a/tests/Io/StreamingServerTest.php b/tests/Io/StreamingServerTest.php index 64566ddc..afab371e 100644 --- a/tests/Io/StreamingServerTest.php +++ b/tests/Io/StreamingServerTest.php @@ -126,7 +126,7 @@ public function testRequestEvent() $serverParams = $requestAssertion->getServerParams(); $this->assertSame(1, $i); - $this->assertInstanceOf('RingCentral\Psr7\Request', $requestAssertion); + $this->assertInstanceOf('Psr\Http\Message\ServerRequestInterface', $requestAssertion); $this->assertSame('GET', $requestAssertion->getMethod()); $this->assertSame('/', $requestAssertion->getRequestTarget()); $this->assertSame('/', $requestAssertion->getUri()->getPath()); @@ -159,7 +159,7 @@ public function testRequestEventWithSingleRequestHandlerArray() $serverParams = $requestAssertion->getServerParams(); $this->assertSame(1, $i); - $this->assertInstanceOf('RingCentral\Psr7\Request', $requestAssertion); + $this->assertInstanceOf('Psr\Http\Message\ServerRequestInterface', $requestAssertion); $this->assertSame('GET', $requestAssertion->getMethod()); $this->assertSame('/', $requestAssertion->getRequestTarget()); $this->assertSame('/', $requestAssertion->getUri()->getPath()); @@ -182,7 +182,7 @@ public function testRequestGetWithHostAndCustomPort() $data = "GET / HTTP/1.1\r\nHost: example.com:8080\r\n\r\n"; $this->connection->emit('data', array($data)); - $this->assertInstanceOf('RingCentral\Psr7\Request', $requestAssertion); + $this->assertInstanceOf('Psr\Http\Message\ServerRequestInterface', $requestAssertion); $this->assertSame('GET', $requestAssertion->getMethod()); $this->assertSame('/', $requestAssertion->getRequestTarget()); $this->assertSame('/', $requestAssertion->getUri()->getPath()); @@ -204,7 +204,7 @@ public function testRequestGetWithHostAndHttpsPort() $data = "GET / HTTP/1.1\r\nHost: example.com:443\r\n\r\n"; $this->connection->emit('data', array($data)); - $this->assertInstanceOf('RingCentral\Psr7\Request', $requestAssertion); + $this->assertInstanceOf('Psr\Http\Message\ServerRequestInterface', $requestAssertion); $this->assertSame('GET', $requestAssertion->getMethod()); $this->assertSame('/', $requestAssertion->getRequestTarget()); $this->assertSame('/', $requestAssertion->getUri()->getPath()); @@ -226,7 +226,7 @@ public function testRequestGetWithHostAndDefaultPortWillBeIgnored() $data = "GET / HTTP/1.1\r\nHost: example.com\r\n\r\n"; $this->connection->emit('data', array($data)); - $this->assertInstanceOf('RingCentral\Psr7\Request', $requestAssertion); + $this->assertInstanceOf('Psr\Http\Message\ServerRequestInterface', $requestAssertion); $this->assertSame('GET', $requestAssertion->getMethod()); $this->assertSame('/', $requestAssertion->getRequestTarget()); $this->assertSame('/', $requestAssertion->getUri()->getPath()); @@ -248,7 +248,7 @@ public function testRequestGetHttp10WithoutHostWillBeIgnored() $data = "GET / HTTP/1.0\r\n\r\n"; $this->connection->emit('data', array($data)); - $this->assertInstanceOf('RingCentral\Psr7\Request', $requestAssertion); + $this->assertInstanceOf('Psr\Http\Message\ServerRequestInterface', $requestAssertion); $this->assertSame('GET', $requestAssertion->getMethod()); $this->assertSame('/', $requestAssertion->getRequestTarget()); $this->assertSame('/', $requestAssertion->getUri()->getPath()); @@ -283,7 +283,7 @@ public function testRequestOptionsAsterisk() $data = "OPTIONS * HTTP/1.1\r\nHost: example.com\r\n\r\n"; $this->connection->emit('data', array($data)); - $this->assertInstanceOf('RingCentral\Psr7\Request', $requestAssertion); + $this->assertInstanceOf('Psr\Http\Message\ServerRequestInterface', $requestAssertion); $this->assertSame('OPTIONS', $requestAssertion->getMethod()); $this->assertSame('*', $requestAssertion->getRequestTarget()); $this->assertSame('', $requestAssertion->getUri()->getPath()); @@ -316,7 +316,7 @@ public function testRequestConnectAuthorityForm() $data = "CONNECT example.com:443 HTTP/1.1\r\nHost: example.com:443\r\n\r\n"; $this->connection->emit('data', array($data)); - $this->assertInstanceOf('RingCentral\Psr7\Request', $requestAssertion); + $this->assertInstanceOf('Psr\Http\Message\ServerRequestInterface', $requestAssertion); $this->assertSame('CONNECT', $requestAssertion->getMethod()); $this->assertSame('example.com:443', $requestAssertion->getRequestTarget()); $this->assertSame('', $requestAssertion->getUri()->getPath()); @@ -338,7 +338,7 @@ public function testRequestConnectWithoutHostWillBePassesAsIs() $data = "CONNECT example.com:443 HTTP/1.1\r\n\r\n"; $this->connection->emit('data', array($data)); - $this->assertInstanceOf('RingCentral\Psr7\Request', $requestAssertion); + $this->assertInstanceOf('Psr\Http\Message\ServerRequestInterface', $requestAssertion); $this->assertSame('CONNECT', $requestAssertion->getMethod()); $this->assertSame('example.com:443', $requestAssertion->getRequestTarget()); $this->assertSame('', $requestAssertion->getUri()->getPath()); @@ -360,7 +360,7 @@ public function testRequestConnectAuthorityFormWithDefaultPortWillBePassedAsIs() $data = "CONNECT example.com:80 HTTP/1.1\r\nHost: example.com:80\r\n\r\n"; $this->connection->emit('data', array($data)); - $this->assertInstanceOf('RingCentral\Psr7\Request', $requestAssertion); + $this->assertInstanceOf('Psr\Http\Message\ServerRequestInterface', $requestAssertion); $this->assertSame('CONNECT', $requestAssertion->getMethod()); $this->assertSame('example.com:80', $requestAssertion->getRequestTarget()); $this->assertSame('', $requestAssertion->getUri()->getPath()); @@ -382,7 +382,7 @@ public function testRequestConnectAuthorityFormNonMatchingHostWillBePassedAsIs() $data = "CONNECT example.com:80 HTTP/1.1\r\nHost: other.example.org\r\n\r\n"; $this->connection->emit('data', array($data)); - $this->assertInstanceOf('RingCentral\Psr7\Request', $requestAssertion); + $this->assertInstanceOf('Psr\Http\Message\ServerRequestInterface', $requestAssertion); $this->assertSame('CONNECT', $requestAssertion->getMethod()); $this->assertSame('example.com:80', $requestAssertion->getRequestTarget()); $this->assertSame('', $requestAssertion->getUri()->getPath()); @@ -434,7 +434,7 @@ public function testRequestWithoutHostEventUsesSocketAddress() $data = "GET /test HTTP/1.0\r\n\r\n"; $this->connection->emit('data', array($data)); - $this->assertInstanceOf('RingCentral\Psr7\Request', $requestAssertion); + $this->assertInstanceOf('Psr\Http\Message\ServerRequestInterface', $requestAssertion); $this->assertSame('GET', $requestAssertion->getMethod()); $this->assertSame('/test', $requestAssertion->getRequestTarget()); $this->assertEquals('http://127.0.0.1/test', $requestAssertion->getUri()); @@ -455,7 +455,7 @@ public function testRequestAbsoluteEvent() $data = "GET http://example.com/test HTTP/1.1\r\nHost: example.com\r\n\r\n"; $this->connection->emit('data', array($data)); - $this->assertInstanceOf('RingCentral\Psr7\Request', $requestAssertion); + $this->assertInstanceOf('Psr\Http\Message\ServerRequestInterface', $requestAssertion); $this->assertSame('GET', $requestAssertion->getMethod()); $this->assertSame('http://example.com/test', $requestAssertion->getRequestTarget()); $this->assertEquals('http://example.com/test', $requestAssertion->getUri()); @@ -477,7 +477,7 @@ public function testRequestAbsoluteNonMatchingHostWillBePassedAsIs() $data = "GET http://example.com/test HTTP/1.1\r\nHost: other.example.org\r\n\r\n"; $this->connection->emit('data', array($data)); - $this->assertInstanceOf('RingCentral\Psr7\Request', $requestAssertion); + $this->assertInstanceOf('Psr\Http\Message\ServerRequestInterface', $requestAssertion); $this->assertSame('GET', $requestAssertion->getMethod()); $this->assertSame('http://example.com/test', $requestAssertion->getRequestTarget()); $this->assertEquals('http://example.com/test', $requestAssertion->getUri()); @@ -511,7 +511,7 @@ public function testRequestOptionsAsteriskEvent() $data = "OPTIONS * HTTP/1.1\r\nHost: example.com\r\n\r\n"; $this->connection->emit('data', array($data)); - $this->assertInstanceOf('RingCentral\Psr7\Request', $requestAssertion); + $this->assertInstanceOf('Psr\Http\Message\ServerRequestInterface', $requestAssertion); $this->assertSame('OPTIONS', $requestAssertion->getMethod()); $this->assertSame('*', $requestAssertion->getRequestTarget()); $this->assertEquals('http://example.com/', $requestAssertion->getUri()); @@ -533,7 +533,7 @@ public function testRequestOptionsAbsoluteEvent() $data = "OPTIONS http://example.com HTTP/1.1\r\nHost: example.com\r\n\r\n"; $this->connection->emit('data', array($data)); - $this->assertInstanceOf('RingCentral\Psr7\Request', $requestAssertion); + $this->assertInstanceOf('Psr\Http\Message\ServerRequestInterface', $requestAssertion); $this->assertSame('OPTIONS', $requestAssertion->getMethod()); $this->assertSame('http://example.com/', $requestAssertion->getRequestTarget()); $this->assertEquals('http://example.com/', $requestAssertion->getUri()); From 5896f81bbd6a024581c08330ca619060f23656a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Fri, 15 Mar 2024 17:19:54 +0100 Subject: [PATCH 47/58] Move parsing incoming HTTP request message to `ServerRequest` --- src/Io/AbstractMessage.php | 8 ++ src/Io/RequestHeaderParser.php | 130 +------------------------- src/Message/ServerRequest.php | 139 ++++++++++++++++++++++++++++ tests/Message/ServerRequestTest.php | 122 ++++++++++++++++++++++++ 4 files changed, 270 insertions(+), 129 deletions(-) diff --git a/src/Io/AbstractMessage.php b/src/Io/AbstractMessage.php index 8523d6cd..ab023304 100644 --- a/src/Io/AbstractMessage.php +++ b/src/Io/AbstractMessage.php @@ -13,6 +13,14 @@ */ abstract class AbstractMessage implements MessageInterface { + /** + * [Internal] Regex used to match all request header fields into an array, thanks to @kelunik for checking the HTTP specs and coming up with this regex + * + * @internal + * @var string + */ + const REGEX_HEADERS = '/^([^()<>@,;:\\\"\/\[\]?={}\x01-\x20\x7F]++):[\x20\x09]*+((?:[\x20\x09]*+[\x21-\x7E\x80-\xFF]++)*+)[\x20\x09]*+[\r]?+\n/m'; + /** @var array */ private $headers = array(); diff --git a/src/Io/RequestHeaderParser.php b/src/Io/RequestHeaderParser.php index b8336f5b..8975ce57 100644 --- a/src/Io/RequestHeaderParser.php +++ b/src/Io/RequestHeaderParser.php @@ -128,39 +128,6 @@ public function handle(ConnectionInterface $conn) */ public function parseRequest($headers, ConnectionInterface $connection) { - // additional, stricter safe-guard for request line - // because request parser doesn't properly cope with invalid ones - $start = array(); - if (!\preg_match('#^(?[^ ]+) (?[^ ]+) HTTP/(?\d\.\d)#m', $headers, $start)) { - throw new \InvalidArgumentException('Unable to parse invalid request-line'); - } - - // only support HTTP/1.1 and HTTP/1.0 requests - if ($start['version'] !== '1.1' && $start['version'] !== '1.0') { - throw new \InvalidArgumentException('Received request with invalid protocol version', Response::STATUS_VERSION_NOT_SUPPORTED); - } - - // match all request header fields into array, thanks to @kelunik for checking the HTTP specs and coming up with this regex - $matches = array(); - $n = \preg_match_all('/^([^()<>@,;:\\\"\/\[\]?={}\x01-\x20\x7F]++):[\x20\x09]*+((?:[\x20\x09]*+[\x21-\x7E\x80-\xFF]++)*+)[\x20\x09]*+[\r]?+\n/m', $headers, $matches, \PREG_SET_ORDER); - - // check number of valid header fields matches number of lines + request line - if (\substr_count($headers, "\n") !== $n + 1) { - throw new \InvalidArgumentException('Unable to parse invalid request header fields'); - } - - // format all header fields into associative array - $host = null; - $fields = array(); - foreach ($matches as $match) { - $fields[$match[1]][] = $match[2]; - - // match `Host` request header - if ($host === null && \strtolower($match[1]) === 'host') { - $host = $match[2]; - } - } - // reuse same connection params for all server params for this connection $cid = \PHP_VERSION_ID < 70200 ? \spl_object_hash($connection) : \spl_object_id($connection); if (isset($this->connectionParams[$cid])) { @@ -207,101 +174,6 @@ public function parseRequest($headers, ConnectionInterface $connection) $serverParams['REQUEST_TIME'] = (int) ($now = $this->clock->now()); $serverParams['REQUEST_TIME_FLOAT'] = $now; - // scheme is `http` unless TLS is used - $scheme = isset($serverParams['HTTPS']) ? 'https://' : 'http://'; - - // default host if unset comes from local socket address or defaults to localhost - $hasHost = $host !== null; - if ($host === null) { - $host = isset($serverParams['SERVER_ADDR'], $serverParams['SERVER_PORT']) ? $serverParams['SERVER_ADDR'] . ':' . $serverParams['SERVER_PORT'] : '127.0.0.1'; - } - - if ($start['method'] === 'OPTIONS' && $start['target'] === '*') { - // support asterisk-form for `OPTIONS *` request line only - $uri = $scheme . $host; - } elseif ($start['method'] === 'CONNECT') { - $parts = \parse_url('tcp://' . $start['target']); - - // check this is a valid authority-form request-target (host:port) - if (!isset($parts['scheme'], $parts['host'], $parts['port']) || \count($parts) !== 3) { - throw new \InvalidArgumentException('CONNECT method MUST use authority-form request target'); - } - $uri = $scheme . $start['target']; - } else { - // support absolute-form or origin-form for proxy requests - if ($start['target'][0] === '/') { - $uri = $scheme . $host . $start['target']; - } else { - // ensure absolute-form request-target contains a valid URI - $parts = \parse_url(https://github.com/$start['target']); - - // make sure value contains valid host component (IP or hostname), but no fragment - if (!isset($parts['scheme'], $parts['host']) || $parts['scheme'] !== 'http' || isset($parts['fragment'])) { - throw new \InvalidArgumentException('Invalid absolute-form request-target'); - } - - $uri = $start['target']; - } - } - - $request = new ServerRequest( - $start['method'], - $uri, - $fields, - '', - $start['version'], - $serverParams - ); - - // only assign request target if it is not in origin-form (happy path for most normal requests) - if ($start['target'][0] !== '/') { - $request = $request->withRequestTarget($start['target']); - } - - if ($hasHost) { - // Optional Host request header value MUST be valid (host and optional port) - $parts = \parse_url('http://' . $request->getHeaderLine('Host')); - - // make sure value contains valid host component (IP or hostname) - if (!$parts || !isset($parts['scheme'], $parts['host'])) { - $parts = false; - } - - // make sure value does not contain any other URI component - if (\is_array($parts)) { - unset($parts['scheme'], $parts['host'], $parts['port']); - } - if ($parts === false || $parts) { - throw new \InvalidArgumentException('Invalid Host header value'); - } - } elseif (!$hasHost && $start['version'] === '1.1' && $start['method'] !== 'CONNECT') { - // require Host request header for HTTP/1.1 (except for CONNECT method) - throw new \InvalidArgumentException('Missing required Host request header'); - } elseif (!$hasHost) { - // remove default Host request header for HTTP/1.0 when not explicitly given - $request = $request->withoutHeader('Host'); - } - - // ensure message boundaries are valid according to Content-Length and Transfer-Encoding request headers - if ($request->hasHeader('Transfer-Encoding')) { - if (\strtolower($request->getHeaderLine('Transfer-Encoding')) !== 'chunked') { - throw new \InvalidArgumentException('Only chunked-encoding is allowed for Transfer-Encoding', Response::STATUS_NOT_IMPLEMENTED); - } - - // Transfer-Encoding: chunked and Content-Length header MUST NOT be used at the same time - // as per https://tools.ietf.org/html/rfc7230#section-3.3.3 - if ($request->hasHeader('Content-Length')) { - throw new \InvalidArgumentException('Using both `Transfer-Encoding: chunked` and `Content-Length` is not allowed', Response::STATUS_BAD_REQUEST); - } - } elseif ($request->hasHeader('Content-Length')) { - $string = $request->getHeaderLine('Content-Length'); - - if ((string)(int)$string !== $string) { - // Content-Length value is not an integer or not a single integer - throw new \InvalidArgumentException('The value of `Content-Length` is not valid', Response::STATUS_BAD_REQUEST); - } - } - - return $request; + return ServerRequest::parseMessage($headers, $serverParams); } } diff --git a/src/Message/ServerRequest.php b/src/Message/ServerRequest.php index b5c41413..32a0f62f 100644 --- a/src/Message/ServerRequest.php +++ b/src/Message/ServerRequest.php @@ -189,4 +189,143 @@ private function parseCookie($cookie) return $result; } + + /** + * [Internal] Parse incoming HTTP protocol message + * + * @internal + * @param string $message + * @param array $serverParams + * @return self + * @throws \InvalidArgumentException if given $message is not a valid HTTP request message + */ + public static function parseMessage($message, array $serverParams) + { + // parse request line like "GET /path HTTP/1.1" + $start = array(); + if (!\preg_match('#^(?[^ ]+) (?[^ ]+) HTTP/(?\d\.\d)#m', $message, $start)) { + throw new \InvalidArgumentException('Unable to parse invalid request-line'); + } + + // only support HTTP/1.1 and HTTP/1.0 requests + if ($start['version'] !== '1.1' && $start['version'] !== '1.0') { + throw new \InvalidArgumentException('Received request with invalid protocol version', Response::STATUS_VERSION_NOT_SUPPORTED); + } + + // check number of valid header fields matches number of lines + request line + $matches = array(); + $n = \preg_match_all(self::REGEX_HEADERS, $message, $matches, \PREG_SET_ORDER); + if (\substr_count($message, "\n") !== $n + 1) { + throw new \InvalidArgumentException('Unable to parse invalid request header fields'); + } + + // format all header fields into associative array + $host = null; + $headers = array(); + foreach ($matches as $match) { + $headers[$match[1]][] = $match[2]; + + // match `Host` request header + if ($host === null && \strtolower($match[1]) === 'host') { + $host = $match[2]; + } + } + + // scheme is `http` unless TLS is used + $scheme = isset($serverParams['HTTPS']) ? 'https://' : 'http://'; + + // default host if unset comes from local socket address or defaults to localhost + $hasHost = $host !== null; + if ($host === null) { + $host = isset($serverParams['SERVER_ADDR'], $serverParams['SERVER_PORT']) ? $serverParams['SERVER_ADDR'] . ':' . $serverParams['SERVER_PORT'] : '127.0.0.1'; + } + + if ($start['method'] === 'OPTIONS' && $start['target'] === '*') { + // support asterisk-form for `OPTIONS *` request line only + $uri = $scheme . $host; + } elseif ($start['method'] === 'CONNECT') { + $parts = \parse_url('tcp://' . $start['target']); + + // check this is a valid authority-form request-target (host:port) + if (!isset($parts['scheme'], $parts['host'], $parts['port']) || \count($parts) !== 3) { + throw new \InvalidArgumentException('CONNECT method MUST use authority-form request target'); + } + $uri = $scheme . $start['target']; + } else { + // support absolute-form or origin-form for proxy requests + if ($start['target'][0] === '/') { + $uri = $scheme . $host . $start['target']; + } else { + // ensure absolute-form request-target contains a valid URI + $parts = \parse_url(https://github.com/$start['target']); + + // make sure value contains valid host component (IP or hostname), but no fragment + if (!isset($parts['scheme'], $parts['host']) || $parts['scheme'] !== 'http' || isset($parts['fragment'])) { + throw new \InvalidArgumentException('Invalid absolute-form request-target'); + } + + $uri = $start['target']; + } + } + + $request = new self( + $start['method'], + $uri, + $headers, + '', + $start['version'], + $serverParams + ); + + // only assign request target if it is not in origin-form (happy path for most normal requests) + if ($start['target'][0] !== '/') { + $request = $request->withRequestTarget($start['target']); + } + + if ($hasHost) { + // Optional Host request header value MUST be valid (host and optional port) + $parts = \parse_url('http://' . $request->getHeaderLine('Host')); + + // make sure value contains valid host component (IP or hostname) + if (!$parts || !isset($parts['scheme'], $parts['host'])) { + $parts = false; + } + + // make sure value does not contain any other URI component + if (\is_array($parts)) { + unset($parts['scheme'], $parts['host'], $parts['port']); + } + if ($parts === false || $parts) { + throw new \InvalidArgumentException('Invalid Host header value'); + } + } elseif (!$hasHost && $start['version'] === '1.1' && $start['method'] !== 'CONNECT') { + // require Host request header for HTTP/1.1 (except for CONNECT method) + throw new \InvalidArgumentException('Missing required Host request header'); + } elseif (!$hasHost) { + // remove default Host request header for HTTP/1.0 when not explicitly given + $request = $request->withoutHeader('Host'); + } + + // ensure message boundaries are valid according to Content-Length and Transfer-Encoding request headers + if ($request->hasHeader('Transfer-Encoding')) { + if (\strtolower($request->getHeaderLine('Transfer-Encoding')) !== 'chunked') { + throw new \InvalidArgumentException('Only chunked-encoding is allowed for Transfer-Encoding', Response::STATUS_NOT_IMPLEMENTED); + } + + // Transfer-Encoding: chunked and Content-Length header MUST NOT be used at the same time + // as per https://tools.ietf.org/html/rfc7230#section-3.3.3 + if ($request->hasHeader('Content-Length')) { + throw new \InvalidArgumentException('Using both `Transfer-Encoding: chunked` and `Content-Length` is not allowed', Response::STATUS_BAD_REQUEST); + } + } elseif ($request->hasHeader('Content-Length')) { + $string = $request->getHeaderLine('Content-Length'); + + if ((string)(int)$string !== $string) { + // Content-Length value is not an integer or not a single integer + throw new \InvalidArgumentException('The value of `Content-Length` is not valid', Response::STATUS_BAD_REQUEST); + } + } + + return $request; + } } diff --git a/tests/Message/ServerRequestTest.php b/tests/Message/ServerRequestTest.php index a5919f64..f82d60f8 100644 --- a/tests/Message/ServerRequestTest.php +++ b/tests/Message/ServerRequestTest.php @@ -362,4 +362,126 @@ public function testConstructWithResourceRequestBodyThrows() tmpfile() ); } + + public function testParseMessageWithSimpleGetRequest() + { + $request = ServerRequest::parseMessage("GET / HTTP/1.1\r\nHost: example.com\r\n", array()); + + $this->assertEquals('GET', $request->getMethod()); + $this->assertEquals('http://example.com/', (string) $request->getUri()); + $this->assertEquals('1.1', $request->getProtocolVersion()); + } + + public function testParseMessageWithHttp10RequestWithoutHost() + { + $request = ServerRequest::parseMessage("GET / HTTP/1.0\r\n", array()); + + $this->assertEquals('GET', $request->getMethod()); + $this->assertEquals('http://127.0.0.1/', (string) $request->getUri()); + $this->assertEquals('1.0', $request->getProtocolVersion()); + } + + public function testParseMessageWithOptionsMethodWithAsteriskFormRequestTarget() + { + $request = ServerRequest::parseMessage("OPTIONS * HTTP/1.1\r\nHost: example.com\r\n", array()); + + $this->assertEquals('OPTIONS', $request->getMethod()); + $this->assertEquals('*', $request->getRequestTarget()); + $this->assertEquals('1.1', $request->getProtocolVersion()); + $this->assertEquals('http://example.com/', (string) $request->getUri()); + } + + public function testParseMessageWithConnectMethodWithAuthorityFormRequestTarget() + { + $request = ServerRequest::parseMessage("CONNECT example.com:80 HTTP/1.1\r\nHost: example.com:80\r\n", array()); + + $this->assertEquals('CONNECT', $request->getMethod()); + $this->assertEquals('example.com:80', $request->getRequestTarget()); + $this->assertEquals('1.1', $request->getProtocolVersion()); + $this->assertEquals('http://example.com/', (string) $request->getUri()); + } + + public function testParseMessageWithInvalidHttp11RequestWithoutHostThrows() + { + $this->setExpectedException('InvalidArgumentException'); + ServerRequest::parseMessage("GET / HTTP/1.1\r\n", array()); + } + + public function testParseMessageWithInvalidHttpProtocolVersionThrows() + { + $this->setExpectedException('InvalidArgumentException'); + ServerRequest::parseMessage("GET / HTTP/1.2\r\n", array()); + } + + public function testParseMessageWithInvalidProtocolThrows() + { + $this->setExpectedException('InvalidArgumentException'); + ServerRequest::parseMessage("GET / CUSTOM/1.1\r\n", array()); + } + + public function testParseMessageWithInvalidHostHeaderWithoutValueThrows() + { + $this->setExpectedException('InvalidArgumentException'); + ServerRequest::parseMessage("GET / HTTP/1.1\r\nHost\r\n", array()); + } + + public function testParseMessageWithInvalidHostHeaderSyntaxThrows() + { + $this->setExpectedException('InvalidArgumentException'); + ServerRequest::parseMessage("GET / HTTP/1.1\r\nHost: ///\r\n", array()); + } + + public function testParseMessageWithInvalidHostHeaderWithSchemeThrows() + { + $this->setExpectedException('InvalidArgumentException'); + ServerRequest::parseMessage("GET / HTTP/1.1\r\nHost: http://localhost\r\n", array()); + } + + public function testParseMessageWithInvalidHostHeaderWithQueryThrows() + { + $this->setExpectedException('InvalidArgumentException'); + ServerRequest::parseMessage("GET / HTTP/1.1\r\nHost: localhost?foo\r\n", array()); + } + + public function testParseMessageWithInvalidHostHeaderWithFragmentThrows() + { + $this->setExpectedException('InvalidArgumentException'); + ServerRequest::parseMessage("GET / HTTP/1.1\r\nHost: localhost#foo\r\n", array()); + } + + public function testParseMessageWithInvalidContentLengthHeaderThrows() + { + $this->setExpectedException('InvalidArgumentException'); + ServerRequest::parseMessage("GET / HTTP/1.1\r\nHost: localhost\r\nContent-Length:\r\n", array()); + } + + public function testParseMessageWithInvalidTransferEncodingHeaderThrows() + { + $this->setExpectedException('InvalidArgumentException'); + ServerRequest::parseMessage("GET / HTTP/1.1\r\nHost: localhost\r\nTransfer-Encoding:\r\n", array()); + } + + public function testParseMessageWithInvalidBothContentLengthHeaderAndTransferEncodingHeaderThrows() + { + $this->setExpectedException('InvalidArgumentException'); + ServerRequest::parseMessage("GET / HTTP/1.1\r\nHost: localhost\r\nContent-Length: 0\r\nTransfer-Encoding: chunked\r\n", array()); + } + + public function testParseMessageWithInvalidEmptyHostHeaderWithAbsoluteFormRequestTargetThrows() + { + $this->setExpectedException('InvalidArgumentException'); + ServerRequest::parseMessage("GET http://example.com/ HTTP/1.1\r\nHost: \r\n", array()); + } + + public function testParseMessageWithInvalidConnectMethodNotUsingAuthorityFormThrows() + { + $this->setExpectedException('InvalidArgumentException'); + ServerRequest::parseMessage("CONNECT / HTTP/1.1\r\nHost: localhost\r\n", array()); + } + + public function testParseMessageWithInvalidRequestTargetAsteriskFormThrows() + { + $this->setExpectedException('InvalidArgumentException'); + ServerRequest::parseMessage("GET * HTTP/1.1\r\nHost: localhost\r\n", array()); + } } From c0e1f4d90b27b57b839a2cc2fa8ca315d39d8c73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Thu, 21 Mar 2024 15:08:15 +0100 Subject: [PATCH 48/58] Move parsing incoming HTTP response message to `Response` --- src/Io/ClientRequestStream.php | 14 +++-- src/Message/Response.php | 42 +++++++++++++++ tests/Message/ResponseTest.php | 94 ++++++++++++++++++++++++++++++++++ 3 files changed, 146 insertions(+), 4 deletions(-) diff --git a/src/Io/ClientRequestStream.php b/src/Io/ClientRequestStream.php index 0220f008..25c96ea8 100644 --- a/src/Io/ClientRequestStream.php +++ b/src/Io/ClientRequestStream.php @@ -8,7 +8,6 @@ use React\Http\Message\Response; use React\Socket\ConnectionInterface; use React\Stream\WritableStreamInterface; -use RingCentral\Psr7 as gPsr; /** * @event response @@ -152,10 +151,17 @@ public function handleData($data) $this->buffer .= $data; // buffer until double CRLF (or double LF for compatibility with legacy servers) - if (false !== strpos($this->buffer, "\r\n\r\n") || false !== strpos($this->buffer, "\n\n")) { + $eom = \strpos($this->buffer, "\r\n\r\n"); + $eomLegacy = \strpos($this->buffer, "\n\n"); + if ($eom !== false || $eomLegacy !== false) { try { - $response = gPsr\parse_response($this->buffer); - $bodyChunk = (string) $response->getBody(); + if ($eom !== false && ($eomLegacy === false || $eom < $eomLegacy)) { + $response = Response::parseMessage(\substr($this->buffer, 0, $eom + 2)); + $bodyChunk = (string) \substr($this->buffer, $eom + 4); + } else { + $response = Response::parseMessage(\substr($this->buffer, 0, $eomLegacy + 1)); + $bodyChunk = (string) \substr($this->buffer, $eomLegacy + 2); + } } catch (\InvalidArgumentException $exception) { $this->closeError($exception); return; diff --git a/src/Message/Response.php b/src/Message/Response.php index 95c82ec8..fa6366ed 100644 --- a/src/Message/Response.php +++ b/src/Message/Response.php @@ -369,4 +369,46 @@ private static function getReasonPhraseForStatusCode($code) return isset(self::$phrasesMap[$code]) ? self::$phrasesMap[$code] : ''; } + + /** + * [Internal] Parse incoming HTTP protocol message + * + * @internal + * @param string $message + * @return self + * @throws \InvalidArgumentException if given $message is not a valid HTTP response message + */ + public static function parseMessage($message) + { + $start = array(); + if (!\preg_match('#^HTTP/(?\d\.\d) (?\d{3})(?: (?[^\r\n]*+))?[\r]?+\n#m', $message, $start)) { + throw new \InvalidArgumentException('Unable to parse invalid status-line'); + } + + // only support HTTP/1.1 and HTTP/1.0 requests + if ($start['version'] !== '1.1' && $start['version'] !== '1.0') { + throw new \InvalidArgumentException('Received response with invalid protocol version'); + } + + // check number of valid header fields matches number of lines + status line + $matches = array(); + $n = \preg_match_all(self::REGEX_HEADERS, $message, $matches, \PREG_SET_ORDER); + if (\substr_count($message, "\n") !== $n + 1) { + throw new \InvalidArgumentException('Unable to parse invalid response header fields'); + } + + // format all header fields into associative array + $headers = array(); + foreach ($matches as $match) { + $headers[$match[1]][] = $match[2]; + } + + return new self( + (int) $start['status'], + $headers, + '', + $start['version'], + isset($start['reason']) ? $start['reason'] : '' + ); + } } diff --git a/tests/Message/ResponseTest.php b/tests/Message/ResponseTest.php index 88b56945..a9a244c2 100644 --- a/tests/Message/ResponseTest.php +++ b/tests/Message/ResponseTest.php @@ -157,4 +157,98 @@ public function testXmlMethodReturnsXmlResponse() $this->assertEquals('application/xml', $response->getHeaderLine('Content-Type')); $this->assertEquals('Hello wörld!', (string) $response->getBody()); } + + public function testParseMessageWithMinimalOkResponse() + { + $response = Response::parseMessage("HTTP/1.1 200 OK\r\n"); + + $this->assertEquals('1.1', $response->getProtocolVersion()); + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals('OK', $response->getReasonPhrase()); + $this->assertEquals(array(), $response->getHeaders()); + } + + public function testParseMessageWithSimpleOkResponse() + { + $response = Response::parseMessage("HTTP/1.1 200 OK\r\nServer: demo\r\n"); + + $this->assertEquals('1.1', $response->getProtocolVersion()); + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals('OK', $response->getReasonPhrase()); + $this->assertEquals(array('Server' => array('demo')), $response->getHeaders()); + } + + public function testParseMessageWithSimpleOkResponseWithCustomReasonPhrase() + { + $response = Response::parseMessage("HTTP/1.1 200 Mostly Okay\r\nServer: demo\r\n"); + + $this->assertEquals('1.1', $response->getProtocolVersion()); + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals('Mostly Okay', $response->getReasonPhrase()); + $this->assertEquals(array('Server' => array('demo')), $response->getHeaders()); + } + + public function testParseMessageWithSimpleOkResponseWithEmptyReasonPhraseAppliesDefault() + { + $response = Response::parseMessage("HTTP/1.1 200 \r\nServer: demo\r\n"); + + $this->assertEquals('1.1', $response->getProtocolVersion()); + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals('OK', $response->getReasonPhrase()); + $this->assertEquals(array('Server' => array('demo')), $response->getHeaders()); + } + + public function testParseMessageWithSimpleOkResponseWithoutReasonPhraseAndWhitespaceSeparatorAppliesDefault() + { + $response = Response::parseMessage("HTTP/1.1 200\r\nServer: demo\r\n"); + + $this->assertEquals('1.1', $response->getProtocolVersion()); + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals('OK', $response->getReasonPhrase()); + $this->assertEquals(array('Server' => array('demo')), $response->getHeaders()); + } + + public function testParseMessageWithHttp10SimpleOkResponse() + { + $response = Response::parseMessage("HTTP/1.0 200 OK\r\nServer: demo\r\n"); + + $this->assertEquals('1.0', $response->getProtocolVersion()); + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals('OK', $response->getReasonPhrase()); + $this->assertEquals(array('Server' => array('demo')), $response->getHeaders()); + } + + public function testParseMessageWithHttp10SimpleOkResponseWithLegacyNewlines() + { + $response = Response::parseMessage("HTTP/1.0 200 OK\nServer: demo\r\n"); + + $this->assertEquals('1.0', $response->getProtocolVersion()); + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals('OK', $response->getReasonPhrase()); + $this->assertEquals(array('Server' => array('demo')), $response->getHeaders()); + } + + public function testParseMessageWithInvalidHttpProtocolVersion12Throws() + { + $this->setExpectedException('InvalidArgumentException'); + Response::parseMessage("HTTP/1.2 200 OK\r\n"); + } + + public function testParseMessageWithInvalidHttpProtocolVersion2Throws() + { + $this->setExpectedException('InvalidArgumentException'); + Response::parseMessage("HTTP/2 200 OK\r\n"); + } + + public function testParseMessageWithInvalidStatusCodeUnderflowThrows() + { + $this->setExpectedException('InvalidArgumentException'); + Response::parseMessage("HTTP/1.1 99 OK\r\n"); + } + + public function testParseMessageWithInvalidResponseHeaderFieldThrows() + { + $this->setExpectedException('InvalidArgumentException'); + Response::parseMessage("HTTP/1.1 200 OK\r\nServer\r\n"); + } } From a73e9f78ab31b3e8876371236f26e9d559ea59c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Thu, 14 Mar 2024 11:43:50 +0100 Subject: [PATCH 49/58] Add new `Uri` class for new PSR-7 implementation --- README.md | 13 + src/Io/AbstractRequest.php | 2 +- src/Message/Uri.php | 292 ++++++++++++ tests/BrowserTest.php | 1 - tests/Io/AbstractRequestTest.php | 2 +- tests/Io/ClientConnectionManagerTest.php | 2 +- tests/Io/ClientRequestStreamTest.php | 2 +- tests/Message/UriTest.php | 581 +++++++++++++++++++++++ 8 files changed, 890 insertions(+), 5 deletions(-) create mode 100644 src/Message/Uri.php create mode 100644 tests/Message/UriTest.php diff --git a/README.md b/README.md index 47003770..18089464 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,7 @@ multiple concurrent HTTP requests without blocking. * [xml()](#xml) * [Request](#request-1) * [ServerRequest](#serverrequest) + * [Uri](#uri) * [ResponseException](#responseexception) * [React\Http\Middleware](#reacthttpmiddleware) * [StreamingRequestMiddleware](#streamingrequestmiddleware) @@ -2664,6 +2665,18 @@ application reacts to certain HTTP requests. > Internally, this implementation builds on top of a base class which is considered an implementation detail that may change in the future. +#### Uri + +The `React\Http\Message\Uri` class can be used to +respresent a URI (or URL). + +This class implements the +[PSR-7 `UriInterface`](https://www.php-fig.org/psr/psr-7/#35-psrhttpmessageuriinterface). + +This is mostly used internally to represent the URI of each HTTP request +message for our HTTP client and server implementations. Likewise, you may +also use this class with other HTTP implementations and for tests. + #### ResponseException The `React\Http\Message\ResponseException` is an `Exception` sub-class that will be used to reject diff --git a/src/Io/AbstractRequest.php b/src/Io/AbstractRequest.php index 51059ac5..f32307f7 100644 --- a/src/Io/AbstractRequest.php +++ b/src/Io/AbstractRequest.php @@ -5,7 +5,7 @@ use Psr\Http\Message\RequestInterface; use Psr\Http\Message\StreamInterface; use Psr\Http\Message\UriInterface; -use RingCentral\Psr7\Uri; +use React\Http\Message\Uri; /** * [Internal] Abstract HTTP request base class (PSR-7) diff --git a/src/Message/Uri.php b/src/Message/Uri.php new file mode 100644 index 00000000..f2cf7d99 --- /dev/null +++ b/src/Message/Uri.php @@ -0,0 +1,292 @@ +scheme = \strtolower($parts['scheme']); + } + + if (isset($parts['user']) || isset($parts['pass'])) { + $this->userInfo = $this->encode(isset($parts['user']) ? $parts['user'] : '', \PHP_URL_USER) . (isset($parts['pass']) ? ':' . $this->encode($parts['pass'], \PHP_URL_PASS) : ''); + } + + if (isset($parts['host'])) { + $this->host = \strtolower($parts['host']); + } + + if (isset($parts['port']) && !(($parts['port'] === 80 && $this->scheme === 'http') || ($parts['port'] === 443 && $this->scheme === 'https'))) { + $this->port = $parts['port']; + } + + if (isset($parts['path'])) { + $this->path = $this->encode($parts['path'], \PHP_URL_PATH); + } + + if (isset($parts['query'])) { + $this->query = $this->encode($parts['query'], \PHP_URL_QUERY); + } + + if (isset($parts['fragment'])) { + $this->fragment = $this->encode($parts['fragment'], \PHP_URL_FRAGMENT); + } + } + + public function getScheme() + { + return $this->scheme; + } + + public function getAuthority() + { + if ($this->host === '') { + return ''; + } + + return ($this->userInfo !== '' ? $this->userInfo . '@' : '') . $this->host . ($this->port !== null ? ':' . $this->port : ''); + } + + public function getUserInfo() + { + return $this->userInfo; + } + + public function getHost() + { + return $this->host; + } + + public function getPort() + { + return $this->port; + } + + public function getPath() + { + return $this->path; + } + + public function getQuery() + { + return $this->query; + } + + public function getFragment() + { + return $this->fragment; + } + + public function withScheme($scheme) + { + $scheme = \strtolower($scheme); + if ($scheme === $this->scheme) { + return $this; + } + + if (!\preg_match('#^[a-z]*$#', $scheme)) { + throw new \InvalidArgumentException('Invalid URI scheme given'); + } + + $new = clone $this; + $new->scheme = $scheme; + + if (($this->port === 80 && $scheme === 'http') || ($this->port === 443 && $scheme === 'https')) { + $new->port = null; + } + + return $new; + } + + public function withUserInfo($user, $password = null) + { + $userInfo = $this->encode($user, \PHP_URL_USER) . ($password !== null ? ':' . $this->encode($password, \PHP_URL_PASS) : ''); + if ($userInfo === $this->userInfo) { + return $this; + } + + $new = clone $this; + $new->userInfo = $userInfo; + + return $new; + } + + public function withHost($host) + { + $host = \strtolower($host); + if ($host === $this->host) { + return $this; + } + + if (\preg_match('#[\s_%+]#', $host) || ($host !== '' && \parse_url('http://' . $host, \PHP_URL_HOST) !== $host)) { + throw new \InvalidArgumentException('Invalid URI host given'); + } + + $new = clone $this; + $new->host = $host; + + return $new; + } + + public function withPort($port) + { + $port = $port === null ? null : (int) $port; + if (($port === 80 && $this->scheme === 'http') || ($port === 443 && $this->scheme === 'https')) { + $port = null; + } + + if ($port === $this->port) { + return $this; + } + + if ($port !== null && ($port < 1 || $port > 0xffff)) { + throw new \InvalidArgumentException('Invalid URI port given'); + } + + $new = clone $this; + $new->port = $port; + + return $new; + } + + public function withPath($path) + { + $path = $this->encode($path, \PHP_URL_PATH); + if ($path === $this->path) { + return $this; + } + + $new = clone $this; + $new->path = $path; + + return $new; + } + + public function withQuery($query) + { + $query = $this->encode($query, \PHP_URL_QUERY); + if ($query === $this->query) { + return $this; + } + + $new = clone $this; + $new->query = $query; + + return $new; + } + + public function withFragment($fragment) + { + $fragment = $this->encode($fragment, \PHP_URL_FRAGMENT); + if ($fragment === $this->fragment) { + return $this; + } + + $new = clone $this; + $new->fragment = $fragment; + + return $new; + } + + public function __toString() + { + $uri = ''; + if ($this->scheme !== '') { + $uri .= $this->scheme . ':'; + } + + $authority = $this->getAuthority(); + if ($authority !== '') { + $uri .= '//' . $authority; + } + + if ($authority !== '' && isset($this->path[0]) && $this->path[0] !== '/') { + $uri .= '/' . $this->path; + } elseif ($authority === '' && isset($this->path[0]) && $this->path[0] === '/') { + $uri .= '/' . \ltrim($this->path, '/'); + } else { + $uri .= $this->path; + } + + if ($this->query !== '') { + $uri .= '?' . $this->query; + } + + if ($this->fragment !== '') { + $uri .= '#' . $this->fragment; + } + + return $uri; + } + + /** + * @param string $part + * @param int $component + * @return string + */ + private function encode($part, $component) + { + return \preg_replace_callback( + '/(?:[^a-z0-9_\-\.~!\$&\'\(\)\*\+,;=' . ($component === \PHP_URL_PATH ? ':@\/' : ($component === \PHP_URL_QUERY || $component === \PHP_URL_FRAGMENT ? ':@\/\?' : '')) . '%]++|%(?![a-f0-9]{2}))/i', + function (array $match) { + return \rawurlencode($match[0]); + }, + $part + ); + } +} diff --git a/tests/BrowserTest.php b/tests/BrowserTest.php index b7958016..fdd338d9 100644 --- a/tests/BrowserTest.php +++ b/tests/BrowserTest.php @@ -5,7 +5,6 @@ use Psr\Http\Message\RequestInterface; use React\Http\Browser; use React\Promise\Promise; -use RingCentral\Psr7\Uri; class BrowserTest extends TestCase { diff --git a/tests/Io/AbstractRequestTest.php b/tests/Io/AbstractRequestTest.php index 28c9eaf1..7ff4a9a5 100644 --- a/tests/Io/AbstractRequestTest.php +++ b/tests/Io/AbstractRequestTest.php @@ -5,8 +5,8 @@ use Psr\Http\Message\StreamInterface; use Psr\Http\Message\UriInterface; use React\Http\Io\AbstractRequest; +use React\Http\Message\Uri; use React\Tests\Http\TestCase; -use RingCentral\Psr7\Uri; class RequestMock extends AbstractRequest { diff --git a/tests/Io/ClientConnectionManagerTest.php b/tests/Io/ClientConnectionManagerTest.php index b28c7964..6aafa6db 100644 --- a/tests/Io/ClientConnectionManagerTest.php +++ b/tests/Io/ClientConnectionManagerTest.php @@ -2,8 +2,8 @@ namespace React\Tests\Http\Io; -use RingCentral\Psr7\Uri; use React\Http\Io\ClientConnectionManager; +use React\Http\Message\Uri; use React\Promise\Promise; use React\Promise\PromiseInterface; use React\Tests\Http\TestCase; diff --git a/tests/Io/ClientRequestStreamTest.php b/tests/Io/ClientRequestStreamTest.php index 4649087a..181db173 100644 --- a/tests/Io/ClientRequestStreamTest.php +++ b/tests/Io/ClientRequestStreamTest.php @@ -3,9 +3,9 @@ namespace React\Tests\Http\Io; use Psr\Http\Message\ResponseInterface; -use RingCentral\Psr7\Uri; use React\Http\Io\ClientRequestStream; use React\Http\Message\Request; +use React\Http\Message\Uri; use React\Promise\Deferred; use React\Promise\Promise; use React\Stream\DuplexResourceStream; diff --git a/tests/Message/UriTest.php b/tests/Message/UriTest.php new file mode 100644 index 00000000..95c7fa4e --- /dev/null +++ b/tests/Message/UriTest.php @@ -0,0 +1,581 @@ +setExpectedException('InvalidArgumentException'); + new Uri('///'); + } + + public function testCtorWithInvalidSchemeThrows() + { + $this->setExpectedException('InvalidArgumentException'); + new Uri('not+a+scheme://localhost'); + } + + public function testCtorWithInvalidHostThrows() + { + $this->setExpectedException('InvalidArgumentException'); + new Uri('http://not a host/'); + } + + public function testCtorWithInvalidPortThrows() + { + $this->setExpectedException('InvalidArgumentException'); + new Uri('http://localhost:80000/'); + } + + public static function provideValidUris() + { + return array( + array( + 'http://localhost/' + ), + array( + 'http://localhost/' + ), + array( + 'http://localhost:8080/' + ), + array( + 'http://127.0.0.1/' + ), + array( + 'http://[::1]:8080/' + ), + array( + 'http://localhost/path' + ), + array( + 'http://localhost/sub/path' + ), + array( + 'http://localhost/with%20space' + ), + array( + 'http://localhost/with%2fslash' + ), + array( + 'http://localhost/?name=Alice' + ), + array( + 'http://localhost/?name=John+Doe' + ), + array( + 'http://localhost/?name=John%20Doe' + ), + array( + 'http://localhost/?name=Alice&age=42' + ), + array( + 'http://localhost/?name=Alice&' + ), + array( + 'http://localhost/?choice=A%26B' + ), + array( + 'http://localhost/?safe=Yes!?' + ), + array( + 'http://localhost/?alias=@home' + ), + array( + 'http://localhost/?assign:=true' + ), + array( + 'http://localhost/?name=' + ), + array( + 'http://localhost/?name' + ), + array( + '' + ), + array( + '/' + ), + array( + '/path' + ), + array( + 'path' + ), + array( + 'http://user@localhost/' + ), + array( + 'http://user@localhost/' + ), + array( + 'http://:pass@localhost/' + ), + array( + 'http://user:pass@localhost/path?query#fragment' + ), + array( + 'http://user%20name:pass%20word@localhost/path%20name?query%20name#frag%20ment' + ) + ); + } + + /** + * @dataProvider provideValidUris + * @param string $string + */ + public function testToStringReturnsOriginalUriGivenToCtor($string) + { + if (PHP_VERSION_ID < 50519 || (PHP_VERSION_ID < 50603 && PHP_VERSION_ID >= 50606)) { + // @link https://3v4l.org/HdoPG + $this->markTestSkipped('Empty password not supported on legacy PHP'); + } + + $uri = new Uri($string); + + $this->assertEquals($string, (string) $uri); + } + + public static function provideValidUrisThatWillBeTransformed() + { + return array( + array( + 'http://localhost:8080/?', + 'http://localhost:8080/' + ), + array( + 'http://localhost:8080/#', + 'http://localhost:8080/' + ), + array( + 'http://localhost:8080/?#', + 'http://localhost:8080/' + ), + array( + 'http://localhost:8080/', + 'http://localhost:8080/' + ), + array( + 'http://localhost:8080/?percent=50%', + 'http://localhost:8080/?percent=50%25' + ), + array( + 'http://user%20name:pass%20word@localhost/path%20name?query%20name#frag%20ment', + 'http://user%20name:pass%20word@localhost/path%20name?query%20name#frag%20ment' + ), + array( + 'HTTP://USER:PASS@LOCALHOST:8080/PATH?QUERY#FRAGMENT', + 'http://USER:PASS@localhost:8080/PATH?QUERY#FRAGMENT' + ) + ); + } + + /** + * @dataProvider provideValidUrisThatWillBeTransformed + * @param string $string + * @param string $escaped + */ + public function testToStringReturnsTransformedUriFromUriGivenToCtor($string, $escaped = null) + { + $uri = new Uri($string); + + $this->assertEquals($escaped, (string) $uri); + } + + public function testToStringReturnsUriWithPathPrefixedWithSlashWhenPathDoesNotStartWithSlash() + { + $uri = new Uri('http://localhost:8080/'); + $uri = $uri->withPath('path'); + + $this->assertEquals('http://localhost:8080/path', (string) $uri); + } + + public function testWithSchemeReturnsNewInstanceWhenSchemeIsChanged() + { + $uri = new Uri('http://localhost/'); + + $new = $uri->withScheme('https'); + $this->assertNotSame($uri, $new); + $this->assertEquals('https', $new->getScheme()); + $this->assertEquals('http', $uri->getScheme()); + } + + public function testWithSchemeReturnsNewInstanceWithSchemeToLowerCaseWhenSchemeIsChangedWithUpperCase() + { + $uri = new Uri('http://localhost/'); + + $new = $uri->withScheme('HTTPS'); + $this->assertNotSame($uri, $new); + $this->assertEquals('https', $new->getScheme()); + $this->assertEquals('http', $uri->getScheme()); + } + + public function testWithSchemeReturnsNewInstanceWithDefaultPortRemovedWhenSchemeIsChangedToDefaultPortForHttp() + { + $uri = new Uri('https://localhost:80/'); + + $new = $uri->withScheme('http'); + $this->assertNotSame($uri, $new); + $this->assertNull($new->getPort()); + $this->assertEquals(80, $uri->getPort()); + } + + public function testWithSchemeReturnsNewInstanceWithDefaultPortRemovedWhenSchemeIsChangedToDefaultPortForHttps() + { + $uri = new Uri('http://localhost:443/'); + + $new = $uri->withScheme('https'); + $this->assertNotSame($uri, $new); + $this->assertNull($new->getPort()); + $this->assertEquals(443, $uri->getPort()); + } + + public function testWithSchemeReturnsSameInstanceWhenSchemeIsUnchanged() + { + $uri = new Uri('http://localhost/'); + + $new = $uri->withScheme('http'); + $this->assertSame($uri, $new); + $this->assertEquals('http', $uri->getScheme()); + } + + public function testWithSchemeReturnsSameInstanceWhenSchemeToLowerCaseIsUnchanged() + { + $uri = new Uri('http://localhost/'); + + $new = $uri->withScheme('HTTP'); + $this->assertSame($uri, $new); + $this->assertEquals('http', $uri->getScheme()); + } + + public function testWithSchemeThrowsWhenSchemeIsInvalid() + { + $uri = new Uri('http://localhost/'); + + $this->setExpectedException('InvalidArgumentException'); + $uri->withScheme('invalid+scheme'); + } + + public function testWithUserInfoReturnsNewInstanceWhenUserInfoIsChangedWithNameAndPassword() + { + $uri = new Uri('http://localhost/'); + + $new = $uri->withUserInfo('user', 'pass'); + $this->assertNotSame($uri, $new); + $this->assertEquals('user:pass', $new->getUserInfo()); + $this->assertEquals('', $uri->getUserInfo()); + } + + public function testWithUserInfoReturnsNewInstanceWhenUserInfoIsChangedWithNameOnly() + { + $uri = new Uri('http://localhost/'); + + $new = $uri->withUserInfo('user'); + $this->assertNotSame($uri, $new); + $this->assertEquals('user', $new->getUserInfo()); + $this->assertEquals('', $uri->getUserInfo()); + } + + public function testWithUserInfoReturnsNewInstanceWhenUserInfoIsChangedWithNameAndEmptyPassword() + { + $uri = new Uri('http://localhost/'); + + $new = $uri->withUserInfo('user', ''); + $this->assertNotSame($uri, $new); + $this->assertEquals('user:', $new->getUserInfo()); + $this->assertEquals('', $uri->getUserInfo()); + } + + public function testWithUserInfoReturnsNewInstanceWhenUserInfoIsChangedWithPasswordOnly() + { + $uri = new Uri('http://localhost/'); + + $new = $uri->withUserInfo('', 'pass'); + $this->assertNotSame($uri, $new); + $this->assertEquals(':pass', $new->getUserInfo()); + $this->assertEquals('', $uri->getUserInfo()); + } + + public function testWithUserInfoReturnsNewInstanceWhenUserInfoIsChangedWithNameAndPasswordEncoded() + { + $uri = new Uri('http://localhost/'); + + $new = $uri->withUserInfo('user:alice', 'pass%20word'); + $this->assertNotSame($uri, $new); + $this->assertEquals('user%3Aalice:pass%20word', $new->getUserInfo()); + $this->assertEquals('', $uri->getUserInfo()); + } + + public function testWithSchemeReturnsSameInstanceWhenSchemeIsUnchangedEmpty() + { + $uri = new Uri('http://localhost/'); + + $new = $uri->withUserInfo(''); + $this->assertSame($uri, $new); + $this->assertEquals('', $uri->getUserInfo()); + } + + public function testWithSchemeReturnsSameInstanceWhenSchemeIsUnchangedWithNameAndPassword() + { + $uri = new Uri('http://user:pass@localhost/'); + + $new = $uri->withUserInfo('user', 'pass'); + $this->assertSame($uri, $new); + $this->assertEquals('user:pass', $uri->getUserInfo()); + } + + public function testWithHostReturnsNewInstanceWhenHostIsChanged() + { + $uri = new Uri('http://localhost/'); + + $new = $uri->withHost('example.com'); + $this->assertNotSame($uri, $new); + $this->assertEquals('example.com', $new->getHost()); + $this->assertEquals('localhost', $uri->getHost()); + } + + public function testWithHostReturnsNewInstanceWithHostToLowerCaseWhenHostIsChangedWithUpperCase() + { + $uri = new Uri('http://localhost/'); + + $new = $uri->withHost('EXAMPLE.COM'); + $this->assertNotSame($uri, $new); + $this->assertEquals('example.com', $new->getHost()); + $this->assertEquals('localhost', $uri->getHost()); + } + + public function testWithHostReturnsNewInstanceWhenHostIsChangedToEmptyString() + { + $uri = new Uri('http://localhost/'); + + $new = $uri->withHost(''); + $this->assertNotSame($uri, $new); + $this->assertEquals('', $new->getHost()); + $this->assertEquals('localhost', $uri->getHost()); + } + + public function testWithHostReturnsSameInstanceWhenHostIsUnchanged() + { + $uri = new Uri('http://localhost/'); + + $new = $uri->withHost('localhost'); + $this->assertSame($uri, $new); + $this->assertEquals('localhost', $uri->getHost()); + } + + public function testWithHostReturnsSameInstanceWhenHostToLowerCaseIsUnchanged() + { + $uri = new Uri('http://localhost/'); + + $new = $uri->withHost('LOCALHOST'); + $this->assertSame($uri, $new); + $this->assertEquals('localhost', $uri->getHost()); + } + + public function testWithHostThrowsWhenHostIsInvalidWithPlus() + { + $uri = new Uri('http://localhost/'); + + $this->setExpectedException('InvalidArgumentException'); + $uri->withHost('invalid+host'); + } + + public function testWithHostThrowsWhenHostIsInvalidWithSpace() + { + $uri = new Uri('http://localhost/'); + + $this->setExpectedException('InvalidArgumentException'); + $uri->withHost('invalid host'); + } + + public function testWithPortReturnsNewInstanceWhenPortIsChanged() + { + $uri = new Uri('http://localhost/'); + + $new = $uri->withPort(8080); + $this->assertNotSame($uri, $new); + $this->assertEquals(8080, $new->getPort()); + $this->assertNull($uri->getPort()); + } + + public function testWithPortReturnsNewInstanceWithDefaultPortRemovedWhenPortIsChangedToDefaultPortForHttp() + { + $uri = new Uri('http://localhost:8080/'); + + $new = $uri->withPort(80); + $this->assertNotSame($uri, $new); + $this->assertNull($new->getPort()); + $this->assertEquals(8080, $uri->getPort()); + } + + public function testWithPortReturnsNewInstanceWithDefaultPortRemovedWhenPortIsChangedToDefaultPortForHttps() + { + $uri = new Uri('https://localhost:8080/'); + + $new = $uri->withPort(443); + $this->assertNotSame($uri, $new); + $this->assertNull($new->getPort()); + $this->assertEquals(8080, $uri->getPort()); + } + + public function testWithPortReturnsSameInstanceWhenPortIsUnchanged() + { + $uri = new Uri('http://localhost:8080/'); + + $new = $uri->withPort(8080); + $this->assertSame($uri, $new); + $this->assertEquals(8080, $uri->getPort()); + } + + public function testWithPortReturnsSameInstanceWhenPortIsUnchangedDefaultPortForHttp() + { + $uri = new Uri('http://localhost/'); + + $new = $uri->withPort(80); + $this->assertSame($uri, $new); + $this->assertNull($uri->getPort()); + } + + public function testWithPortReturnsSameInstanceWhenPortIsUnchangedDefaultPortForHttps() + { + $uri = new Uri('https://localhost/'); + + $new = $uri->withPort(443); + $this->assertSame($uri, $new); + $this->assertNull($uri->getPort()); + } + + public function testWithPortThrowsWhenPortIsInvalidUnderflow() + { + $uri = new Uri('http://localhost/'); + + $this->setExpectedException('InvalidArgumentException'); + $uri->withPort(0); + } + + public function testWithPortThrowsWhenPortIsInvalidOverflow() + { + $uri = new Uri('http://localhost/'); + + $this->setExpectedException('InvalidArgumentException'); + $uri->withPort(65536); + } + + public function testWithPathReturnsNewInstanceWhenPathIsChanged() + { + $uri = new Uri('http://localhost/'); + + $new = $uri->withPath('/path'); + $this->assertNotSame($uri, $new); + $this->assertEquals('/path', $new->getPath()); + $this->assertEquals('/', $uri->getPath()); + } + + public function testWithPathReturnsNewInstanceWhenPathIsChangedEncoded() + { + $uri = new Uri('http://localhost/'); + + $new = $uri->withPath('/a new/path%20here!'); + $this->assertNotSame($uri, $new); + $this->assertEquals('/a%20new/path%20here!', $new->getPath()); + $this->assertEquals('/', $uri->getPath()); + } + + public function testWithPathReturnsSameInstanceWhenPathIsUnchanged() + { + $uri = new Uri('http://localhost/path'); + + $new = $uri->withPath('/path'); + $this->assertSame($uri, $new); + $this->assertEquals('/path', $uri->getPath()); + } + + public function testWithPathReturnsSameInstanceWhenPathIsUnchangedEncoded() + { + $uri = new Uri('http://localhost/a%20new/path%20here!'); + + $new = $uri->withPath('/a new/path%20here!'); + $this->assertSame($uri, $new); + $this->assertEquals('/a%20new/path%20here!', $uri->getPath()); + } + + public function testWithQueryReturnsNewInstanceWhenQueryIsChanged() + { + $uri = new Uri('http://localhost/'); + + $new = $uri->withQuery('foo=bar'); + $this->assertNotSame($uri, $new); + $this->assertEquals('foo=bar', $new->getQuery()); + $this->assertEquals('', $uri->getQuery()); + } + + public function testWithQueryReturnsNewInstanceWhenQueryIsChangedEncoded() + { + $uri = new Uri('http://localhost/'); + + $new = $uri->withQuery('foo=a new%20text!'); + $this->assertNotSame($uri, $new); + $this->assertEquals('foo=a%20new%20text!', $new->getQuery()); + $this->assertEquals('', $uri->getQuery()); + } + + public function testWithQueryReturnsSameInstanceWhenQueryIsUnchanged() + { + $uri = new Uri('http://localhost/?foo=bar'); + + $new = $uri->withQuery('foo=bar'); + $this->assertSame($uri, $new); + $this->assertEquals('foo=bar', $uri->getQuery()); + } + + public function testWithQueryReturnsSameInstanceWhenQueryIsUnchangedEncoded() + { + $uri = new Uri('http://localhost/?foo=a%20new%20text!'); + + $new = $uri->withQuery('foo=a new%20text!'); + $this->assertSame($uri, $new); + $this->assertEquals('foo=a%20new%20text!', $uri->getQuery()); + } + + public function testWithFragmentReturnsNewInstanceWhenFragmentIsChanged() + { + $uri = new Uri('http://localhost/'); + + $new = $uri->withFragment('section'); + $this->assertNotSame($uri, $new); + $this->assertEquals('section', $new->getFragment()); + $this->assertEquals('', $uri->getFragment()); + } + + public function testWithFragmentReturnsNewInstanceWhenFragmentIsChangedEncoded() + { + $uri = new Uri('http://localhost/'); + + $new = $uri->withFragment('section new%20text!'); + $this->assertNotSame($uri, $new); + $this->assertEquals('section%20new%20text!', $new->getFragment()); + $this->assertEquals('', $uri->getFragment()); + } + + public function testWithFragmentReturnsSameInstanceWhenFragmentIsUnchanged() + { + $uri = new Uri('http://localhost/#section'); + + $new = $uri->withFragment('section'); + $this->assertSame($uri, $new); + $this->assertEquals('section', $uri->getFragment()); + } + + public function testWithFragmentReturnsSameInstanceWhenFragmentIsUnchangedEncoded() + { + $uri = new Uri('http://localhost/#section%20new%20text!'); + + $new = $uri->withFragment('section new%20text!'); + $this->assertSame($uri, $new); + $this->assertEquals('section%20new%20text!', $uri->getFragment()); + } +} From c6caa1240307f13e7a678332ad0beae7fb909160 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Sat, 23 Mar 2024 17:15:23 +0100 Subject: [PATCH 50/58] Add internal `Uri::resolve()` to resolve URIs relative to base URI --- src/Browser.php | 4 +- src/Io/Transaction.php | 4 +- src/Message/Uri.php | 64 ++++++++++++++++++++ tests/Message/UriTest.php | 124 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 192 insertions(+), 4 deletions(-) diff --git a/src/Browser.php b/src/Browser.php index b7bf4425..01a266ca 100644 --- a/src/Browser.php +++ b/src/Browser.php @@ -3,12 +3,12 @@ namespace React\Http; use Psr\Http\Message\ResponseInterface; -use RingCentral\Psr7\Uri; use React\EventLoop\Loop; use React\EventLoop\LoopInterface; use React\Http\Io\Sender; use React\Http\Io\Transaction; use React\Http\Message\Request; +use React\Http\Message\Uri; use React\Promise\PromiseInterface; use React\Socket\ConnectorInterface; use React\Stream\ReadableStreamInterface; @@ -834,7 +834,7 @@ private function requestMayBeStreaming($method, $url, array $headers = array(), { if ($this->baseUrl !== null) { // ensure we're actually below the base URL - $url = Uri::resolve($this->baseUrl, $url); + $url = Uri::resolve($this->baseUrl, new Uri($url)); } foreach ($this->defaultHeaders as $key => $value) { diff --git a/src/Io/Transaction.php b/src/Io/Transaction.php index b93c490c..64738f56 100644 --- a/src/Io/Transaction.php +++ b/src/Io/Transaction.php @@ -8,11 +8,11 @@ use React\EventLoop\LoopInterface; use React\Http\Message\Response; use React\Http\Message\ResponseException; +use React\Http\Message\Uri; use React\Promise\Deferred; use React\Promise\Promise; use React\Promise\PromiseInterface; use React\Stream\ReadableStreamInterface; -use RingCentral\Psr7\Uri; /** * @internal @@ -264,7 +264,7 @@ public function onResponse(ResponseInterface $response, RequestInterface $reques private function onResponseRedirect(ResponseInterface $response, RequestInterface $request, Deferred $deferred, ClientRequestState $state) { // resolve location relative to last request URI - $location = Uri::resolve($request->getUri(), $response->getHeaderLine('Location')); + $location = Uri::resolve($request->getUri(), new Uri($response->getHeaderLine('Location'))); $request = $this->makeRedirectRequest($request, $location, $response->getStatusCode()); $this->progress('redirect', array($request)); diff --git a/src/Message/Uri.php b/src/Message/Uri.php index f2cf7d99..4309bbed 100644 --- a/src/Message/Uri.php +++ b/src/Message/Uri.php @@ -289,4 +289,68 @@ function (array $match) { $part ); } + + /** + * [Internal] Resolve URI relative to base URI and return new absolute URI + * + * @internal + * @param UriInterface $base + * @param UriInterface $rel + * @return UriInterface + * @throws void + */ + public static function resolve(UriInterface $base, UriInterface $rel) + { + if ($rel->getScheme() !== '') { + return $rel->getPath() === '' ? $rel : $rel->withPath(self::removeDotSegments($rel->getPath())); + } + + $reset = false; + $new = $base; + if ($rel->getAuthority() !== '') { + $reset = true; + $userInfo = \explode(':', $rel->getUserInfo(), 2); + $new = $base->withUserInfo($userInfo[0], isset($userInfo[1]) ? $userInfo[1]: null)->withHost($rel->getHost())->withPort($rel->getPort()); + } + + if ($reset && $rel->getPath() === '') { + $new = $new->withPath(''); + } elseif (($path = $rel->getPath()) !== '') { + $start = ''; + if ($path === '' || $path[0] !== '/') { + $start = $base->getPath(); + if (\substr($start, -1) !== '/') { + $start .= '/../'; + } + } + $reset = true; + $new = $new->withPath(self::removeDotSegments($start . $path)); + } + if ($reset || $rel->getQuery() !== '') { + $reset = true; + $new = $new->withQuery($rel->getQuery()); + } + if ($reset || $rel->getFragment() !== '') { + $new = $new->withFragment($rel->getFragment()); + } + + return $new; + } + + /** + * @param string $path + * @return string + */ + private static function removeDotSegments($path) + { + $segments = array(); + foreach (\explode('/', $path) as $segment) { + if ($segment === '..') { + \array_pop($segments); + } elseif ($segment !== '.' && $segment !== '') { + $segments[] = $segment; + } + } + return '/' . \implode('/', $segments) . ($path !== '/' && \substr($path, -1) === '/' ? '/' : ''); + } } diff --git a/tests/Message/UriTest.php b/tests/Message/UriTest.php index 95c7fa4e..05eec723 100644 --- a/tests/Message/UriTest.php +++ b/tests/Message/UriTest.php @@ -578,4 +578,128 @@ public function testWithFragmentReturnsSameInstanceWhenFragmentIsUnchangedEncode $this->assertSame($uri, $new); $this->assertEquals('section%20new%20text!', $uri->getFragment()); } + + public static function provideResolveUris() + { + return array( + array( + 'http://localhost/', + '', + 'http://localhost/' + ), + array( + 'http://localhost/', + 'http://example.com/', + 'http://example.com/' + ), + array( + 'http://localhost/', + 'path', + 'http://localhost/path' + ), + array( + 'http://localhost/', + 'path/', + 'http://localhost/path/' + ), + array( + 'http://localhost/', + 'path//', + 'http://localhost/path/' + ), + array( + 'http://localhost/', + 'path', + 'http://localhost/path' + ), + array( + 'http://localhost/a/b', + '/path', + 'http://localhost/path' + ), + array( + 'http://localhost/', + '/a/b/c', + 'http://localhost/a/b/c' + ), + array( + 'http://localhost/a/path', + 'b/c', + 'http://localhost/a/b/c' + ), + array( + 'http://localhost/a/path', + '/b/c', + 'http://localhost/b/c' + ), + array( + 'http://localhost/a/path/', + 'b/c', + 'http://localhost/a/path/b/c' + ), + array( + 'http://localhost/a/path/', + '../b/c', + 'http://localhost/a/b/c' + ), + array( + 'http://localhost/', + '../../../a/b', + 'http://localhost/a/b' + ), + array( + 'http://localhost/path', + '?query', + 'http://localhost/path?query' + ), + array( + 'http://localhost/path', + '#fragment', + 'http://localhost/path#fragment' + ), + array( + 'http://localhost/path', + 'http://localhost/', + 'http://localhost/' + ), + array( + 'http://localhost/path', + 'http://localhost/?query#fragment', + 'http://localhost/?query#fragment' + ), + array( + 'http://localhost/path/?a#fragment', + '?b', + 'http://localhost/path/?b' + ), + array( + 'http://localhost/path', + '//localhost', + 'http://localhost/' + ), + array( + 'http://localhost/path', + '//localhost/a?query', + 'http://localhost/a?query' + ), + array( + 'http://localhost/path', + '//LOCALHOST', + 'http://localhost/' + ) + ); + } + + /** + * @dataProvider provideResolveUris + * @param string $base + * @param string $rel + * @param string $expected + */ + public function testResolveReturnsResolvedUri($base, $rel, $expected) + { + $uri = Uri::resolve(new Uri($base), new Uri($rel)); + + $this->assertEquals($expected, (string) $uri); + } } From 30c802c9db77d90dc0d6ef43d9a60573863a0969 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Sat, 23 Mar 2024 17:42:14 +0100 Subject: [PATCH 51/58] Drop leftover RingCentral PSR-7 dependency, use own PSR-7 implementation --- composer.json | 3 +-- examples/01-client-get-request.php | 2 +- examples/02-client-concurrent-requests.php | 6 +++--- examples/04-client-post-json.php | 2 +- examples/05-client-put-xml.php | 2 +- examples/11-client-http-proxy.php | 2 +- examples/12-client-socks-proxy.php | 2 +- examples/13-client-ssh-proxy.php | 2 +- examples/14-client-unix-domain-sockets.php | 3 +-- examples/21-client-request-streaming-to-stdout.php | 3 +-- examples/22-client-stream-upload-from-stdin.php | 3 +-- examples/71-server-http-proxy.php | 2 +- examples/91-client-benchmark-download.php | 3 +-- src/Io/ClientRequestStream.php | 3 ++- tests/Middleware/RequestBodyBufferMiddlewareTest.php | 5 ++--- 15 files changed, 19 insertions(+), 24 deletions(-) diff --git a/composer.json b/composer.json index 5198470e..23783c0c 100644 --- a/composer.json +++ b/composer.json @@ -33,8 +33,7 @@ "react/event-loop": "^1.2", "react/promise": "^3 || ^2.3 || ^1.2.1", "react/socket": "^1.12", - "react/stream": "^1.2", - "ringcentral/psr7": "^1.2" + "react/stream": "^1.2" }, "require-dev": { "clue/http-proxy-react": "^1.8", diff --git a/examples/01-client-get-request.php b/examples/01-client-get-request.php index 34a79bbb..278f6597 100644 --- a/examples/01-client-get-request.php +++ b/examples/01-client-get-request.php @@ -8,7 +8,7 @@ $client = new Browser(); $client->get('http://google.com/')->then(function (ResponseInterface $response) { - var_dump($response->getHeaders(), (string)$response->getBody()); + echo (string) $response->getBody(); }, function (Exception $e) { echo 'Error: ' . $e->getMessage() . PHP_EOL; }); diff --git a/examples/02-client-concurrent-requests.php b/examples/02-client-concurrent-requests.php index 7b1b77a0..e372515c 100644 --- a/examples/02-client-concurrent-requests.php +++ b/examples/02-client-concurrent-requests.php @@ -8,19 +8,19 @@ $client = new Browser(); $client->head('http://www.github.com/clue/http-react')->then(function (ResponseInterface $response) { - var_dump($response->getHeaders(), (string)$response->getBody()); + echo (string) $response->getBody(); }, function (Exception $e) { echo 'Error: ' . $e->getMessage() . PHP_EOL; }); $client->get('http://google.com/')->then(function (ResponseInterface $response) { - var_dump($response->getHeaders(), (string)$response->getBody()); + echo (string) $response->getBody(); }, function (Exception $e) { echo 'Error: ' . $e->getMessage() . PHP_EOL; }); $client->get('http://www.lueck.tv/psocksd')->then(function (ResponseInterface $response) { - var_dump($response->getHeaders(), (string)$response->getBody()); + echo (string) $response->getBody(); }, function (Exception $e) { echo 'Error: ' . $e->getMessage() . PHP_EOL; }); diff --git a/examples/04-client-post-json.php b/examples/04-client-post-json.php index 477c3426..18fa596d 100644 --- a/examples/04-client-post-json.php +++ b/examples/04-client-post-json.php @@ -22,7 +22,7 @@ ), json_encode($data) )->then(function (ResponseInterface $response) { - echo (string)$response->getBody(); + echo (string) $response->getBody(); }, function (Exception $e) { echo 'Error: ' . $e->getMessage() . PHP_EOL; }); diff --git a/examples/05-client-put-xml.php b/examples/05-client-put-xml.php index 6055363a..10ee46fc 100644 --- a/examples/05-client-put-xml.php +++ b/examples/05-client-put-xml.php @@ -19,7 +19,7 @@ ), $xml->asXML() )->then(function (ResponseInterface $response) { - echo (string)$response->getBody(); + echo (string) $response->getBody(); }, function (Exception $e) { echo 'Error: ' . $e->getMessage() . PHP_EOL; }); diff --git a/examples/11-client-http-proxy.php b/examples/11-client-http-proxy.php index f450fbc2..ec7fc2b6 100644 --- a/examples/11-client-http-proxy.php +++ b/examples/11-client-http-proxy.php @@ -25,7 +25,7 @@ // demo fetching HTTP headers (or bail out otherwise) $browser->get('https://www.google.com/')->then(function (ResponseInterface $response) { - echo RingCentral\Psr7\str($response); + echo (string) $response->getBody(); }, function (Exception $e) { echo 'Error: ' . $e->getMessage() . PHP_EOL; }); diff --git a/examples/12-client-socks-proxy.php b/examples/12-client-socks-proxy.php index ecedf242..8c525509 100644 --- a/examples/12-client-socks-proxy.php +++ b/examples/12-client-socks-proxy.php @@ -25,7 +25,7 @@ // demo fetching HTTP headers (or bail out otherwise) $browser->get('https://www.google.com/')->then(function (ResponseInterface $response) { - echo RingCentral\Psr7\str($response); + echo (string) $response->getBody(); }, function (Exception $e) { echo 'Error: ' . $e->getMessage() . PHP_EOL; }); diff --git a/examples/13-client-ssh-proxy.php b/examples/13-client-ssh-proxy.php index 64d0c282..93e6e256 100644 --- a/examples/13-client-ssh-proxy.php +++ b/examples/13-client-ssh-proxy.php @@ -21,7 +21,7 @@ // demo fetching HTTP headers (or bail out otherwise) $browser->get('https://www.google.com/')->then(function (ResponseInterface $response) { - echo RingCentral\Psr7\str($response); + echo (string) $response->getBody(); }, function (Exception $e) { echo 'Error: ' . $e->getMessage() . PHP_EOL; }); diff --git a/examples/14-client-unix-domain-sockets.php b/examples/14-client-unix-domain-sockets.php index e9718141..5af0394d 100644 --- a/examples/14-client-unix-domain-sockets.php +++ b/examples/14-client-unix-domain-sockets.php @@ -4,7 +4,6 @@ use React\Http\Browser; use React\Socket\FixedUriConnector; use React\Socket\UnixConnector; -use RingCentral\Psr7; require __DIR__ . '/../vendor/autoload.php'; @@ -18,7 +17,7 @@ // demo fetching HTTP headers (or bail out otherwise) $browser->get('http://localhost/info')->then(function (ResponseInterface $response) { - echo Psr7\str($response); + echo (string) $response->getBody(); }, function (Exception $e) { echo 'Error: ' . $e->getMessage() . PHP_EOL; }); diff --git a/examples/21-client-request-streaming-to-stdout.php b/examples/21-client-request-streaming-to-stdout.php index 2f24d035..b3cbbe39 100644 --- a/examples/21-client-request-streaming-to-stdout.php +++ b/examples/21-client-request-streaming-to-stdout.php @@ -4,7 +4,6 @@ use Psr\Http\Message\ResponseInterface; use React\Stream\ReadableStreamInterface; use React\Stream\WritableResourceStream; -use RingCentral\Psr7; require __DIR__ . '/../vendor/autoload.php'; @@ -22,7 +21,7 @@ $info->write('Requesting ' . $url . '…' . PHP_EOL); $client->requestStreaming('GET', $url)->then(function (ResponseInterface $response) use ($info, $out) { - $info->write('Received' . PHP_EOL . Psr7\str($response)); + $info->write('Received ' . $response->getStatusCode() . ' ' . $response->getReasonPhrase() . PHP_EOL); $body = $response->getBody(); assert($body instanceof ReadableStreamInterface); diff --git a/examples/22-client-stream-upload-from-stdin.php b/examples/22-client-stream-upload-from-stdin.php index f29b08ab..f0a68c5f 100644 --- a/examples/22-client-stream-upload-from-stdin.php +++ b/examples/22-client-stream-upload-from-stdin.php @@ -3,7 +3,6 @@ use Psr\Http\Message\ResponseInterface; use React\Http\Browser; use React\Stream\ReadableResourceStream; -use RingCentral\Psr7; require __DIR__ . '/../vendor/autoload.php'; @@ -20,7 +19,7 @@ echo 'Sending STDIN as POST to ' . $url . '…' . PHP_EOL; $client->post($url, array('Content-Type' => 'text/plain'), $in)->then(function (ResponseInterface $response) { - echo 'Received' . PHP_EOL . Psr7\str($response); + echo (string) $response->getBody(); }, function (Exception $e) { echo 'Error: ' . $e->getMessage() . PHP_EOL; }); diff --git a/examples/71-server-http-proxy.php b/examples/71-server-http-proxy.php index cf63c4ae..de9fa10b 100644 --- a/examples/71-server-http-proxy.php +++ b/examples/71-server-http-proxy.php @@ -28,7 +28,7 @@ // left up as an exercise: use an HTTP client to send the outgoing request // and forward the incoming response to the original client request return React\Http\Message\Response::plaintext( - RingCentral\Psr7\str($outgoing) + $outgoing->getMethod() . ' ' . $outgoing->getRequestTarget() . ' HTTP/' . $outgoing->getProtocolVersion() . "\r\n\r\n" . (string) $outgoing->getBody() ); }); diff --git a/examples/91-client-benchmark-download.php b/examples/91-client-benchmark-download.php index 44e99087..712d9f10 100644 --- a/examples/91-client-benchmark-download.php +++ b/examples/91-client-benchmark-download.php @@ -29,8 +29,7 @@ echo 'Requesting ' . $url . '…' . PHP_EOL; $client->requestStreaming('GET', $url)->then(function (ResponseInterface $response) { - echo 'Headers received' . PHP_EOL; - echo RingCentral\Psr7\str($response); + echo 'Received ' . $response->getStatusCode() . ' ' . $response->getReasonPhrase() . PHP_EOL; $stream = $response->getBody(); assert($stream instanceof ReadableStreamInterface); diff --git a/src/Io/ClientRequestStream.php b/src/Io/ClientRequestStream.php index 25c96ea8..12c15caf 100644 --- a/src/Io/ClientRequestStream.php +++ b/src/Io/ClientRequestStream.php @@ -277,7 +277,8 @@ public function close() */ public function hasMessageKeepAliveEnabled(MessageInterface $message) { - $connectionOptions = \RingCentral\Psr7\normalize_header(\strtolower($message->getHeaderLine('Connection'))); + // @link https://www.rfc-editor.org/rfc/rfc9110#section-7.6.1 + $connectionOptions = \array_map('trim', \explode(',', \strtolower($message->getHeaderLine('Connection')))); if (\in_array('close', $connectionOptions, true)) { return false; diff --git a/tests/Middleware/RequestBodyBufferMiddlewareTest.php b/tests/Middleware/RequestBodyBufferMiddlewareTest.php index fd818a8c..40c23378 100644 --- a/tests/Middleware/RequestBodyBufferMiddlewareTest.php +++ b/tests/Middleware/RequestBodyBufferMiddlewareTest.php @@ -4,13 +4,13 @@ use Psr\Http\Message\ServerRequestInterface; use React\EventLoop\Loop; +use React\Http\Io\BufferedBody; use React\Http\Io\HttpBodyStream; use React\Http\Message\Response; use React\Http\Message\ServerRequest; use React\Http\Middleware\RequestBodyBufferMiddleware; use React\Stream\ThroughStream; use React\Tests\Http\TestCase; -use RingCentral\Psr7\BufferStream; final class RequestBodyBufferMiddlewareTest extends TestCase { @@ -45,8 +45,7 @@ public function testAlreadyBufferedResolvesImmediately() { $size = 1024; $body = str_repeat('x', $size); - $stream = new BufferStream(1024); - $stream->write($body); + $stream = new BufferedBody($body); $serverRequest = new ServerRequest( 'GET', 'https://example.com/', From 27d2e74c0626acb0f6f504d8ba25632036b79818 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Wed, 27 Mar 2024 10:48:52 +0100 Subject: [PATCH 52/58] Validate outgoing HTTP message headers and reject invalid messages --- src/Io/AbstractMessage.php | 2 +- src/Io/ClientRequestStream.php | 30 ++++++---- src/Io/StreamingServer.php | 13 ++++ tests/Io/ClientRequestStreamTest.php | 62 ++++++++++++++++++++ tests/Io/StreamingServerTest.php | 88 +++++++++++++++++++++++++++- 5 files changed, 183 insertions(+), 12 deletions(-) diff --git a/src/Io/AbstractMessage.php b/src/Io/AbstractMessage.php index ab023304..a0706bb1 100644 --- a/src/Io/AbstractMessage.php +++ b/src/Io/AbstractMessage.php @@ -19,7 +19,7 @@ abstract class AbstractMessage implements MessageInterface * @internal * @var string */ - const REGEX_HEADERS = '/^([^()<>@,;:\\\"\/\[\]?={}\x01-\x20\x7F]++):[\x20\x09]*+((?:[\x20\x09]*+[\x21-\x7E\x80-\xFF]++)*+)[\x20\x09]*+[\r]?+\n/m'; + const REGEX_HEADERS = '/^([^()<>@,;:\\\"\/\[\]?={}\x00-\x20\x7F]++):[\x20\x09]*+((?:[\x20\x09]*+[\x21-\x7E\x80-\xFF]++)*+)[\x20\x09]*+[\r]?+\n/m'; /** @var array */ private $headers = array(); diff --git a/src/Io/ClientRequestStream.php b/src/Io/ClientRequestStream.php index 25c96ea8..ee0ec760 100644 --- a/src/Io/ClientRequestStream.php +++ b/src/Io/ClientRequestStream.php @@ -56,7 +56,25 @@ private function writeHead() { $this->state = self::STATE_WRITING_HEAD; - $request = $this->request; + $expected = 0; + $headers = "{$this->request->getMethod()} {$this->request->getRequestTarget()} HTTP/{$this->request->getProtocolVersion()}\r\n"; + foreach ($this->request->getHeaders() as $name => $values) { + if (\strpos($name, ':') !== false) { + $expected = -1; + break; + } + foreach ($values as $value) { + $headers .= "$name: $value\r\n"; + ++$expected; + } + } + + /** @var array $m legacy PHP 5.3 only */ + if (!\preg_match('#^\S+ \S+ HTTP/1\.[01]\r\n#m', $headers) || \substr_count($headers, "\n") !== ($expected + 1) || (\PHP_VERSION_ID >= 50400 ? \preg_match_all(AbstractMessage::REGEX_HEADERS, $headers) : \preg_match_all(AbstractMessage::REGEX_HEADERS, $headers, $m)) !== $expected) { + $this->closeError(new \InvalidArgumentException('Unable to send request with invalid request headers')); + return; + } + $connectionRef = &$this->connection; $stateRef = &$this->state; $pendingWrites = &$this->pendingWrites; @@ -64,7 +82,7 @@ private function writeHead() $promise = $this->connectionManager->connect($this->request->getUri()); $promise->then( - function (ConnectionInterface $connection) use ($request, &$connectionRef, &$stateRef, &$pendingWrites, $that) { + function (ConnectionInterface $connection) use ($headers, &$connectionRef, &$stateRef, &$pendingWrites, $that) { $connectionRef = $connection; assert($connectionRef instanceof ConnectionInterface); @@ -74,14 +92,6 @@ function (ConnectionInterface $connection) use ($request, &$connectionRef, &$sta $connection->on('error', array($that, 'handleError')); $connection->on('close', array($that, 'close')); - assert($request instanceof RequestInterface); - $headers = "{$request->getMethod()} {$request->getRequestTarget()} HTTP/{$request->getProtocolVersion()}\r\n"; - foreach ($request->getHeaders() as $name => $values) { - foreach ($values as $value) { - $headers .= "$name: $value\r\n"; - } - } - $more = $connection->write($headers . "\r\n" . $pendingWrites); assert($stateRef === ClientRequestStream::STATE_WRITING_HEAD); diff --git a/src/Io/StreamingServer.php b/src/Io/StreamingServer.php index 790c8cc1..143edaa8 100644 --- a/src/Io/StreamingServer.php +++ b/src/Io/StreamingServer.php @@ -333,13 +333,26 @@ public function handleResponse(ConnectionInterface $connection, ServerRequestInt } // build HTTP response header by appending status line and header fields + $expected = 0; $headers = "HTTP/" . $version . " " . $code . " " . $response->getReasonPhrase() . "\r\n"; foreach ($response->getHeaders() as $name => $values) { + if (\strpos($name, ':') !== false) { + $expected = -1; + break; + } foreach ($values as $value) { $headers .= $name . ": " . $value . "\r\n"; + ++$expected; } } + /** @var array $m legacy PHP 5.3 only */ + if ($code < 100 || $code > 999 || \substr_count($headers, "\n") !== ($expected + 1) || (\PHP_VERSION_ID >= 50400 ? \preg_match_all(AbstractMessage::REGEX_HEADERS, $headers) : \preg_match_all(AbstractMessage::REGEX_HEADERS, $headers, $m)) !== $expected) { + $this->emit('error', array(new \InvalidArgumentException('Unable to send response with invalid response headers'))); + $this->writeError($connection, Response::STATUS_INTERNAL_SERVER_ERROR, $request); + return; + } + // response to HEAD and 1xx, 204 and 304 responses MUST NOT include a body // exclude status 101 (Switching Protocols) here for Upgrade request handling above if ($method === 'HEAD' || ($code >= 100 && $code < 200 && $code !== Response::STATUS_SWITCHING_PROTOCOLS) || $code === Response::STATUS_NO_CONTENT || $code === Response::STATUS_NOT_MODIFIED) { diff --git a/tests/Io/ClientRequestStreamTest.php b/tests/Io/ClientRequestStreamTest.php index 181db173..9a5373a1 100644 --- a/tests/Io/ClientRequestStreamTest.php +++ b/tests/Io/ClientRequestStreamTest.php @@ -2,6 +2,7 @@ namespace React\Tests\Http\Io; +use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; use React\Http\Io\ClientRequestStream; use React\Http\Message\Request; @@ -100,6 +101,67 @@ public function requestShouldEmitErrorIfConnectionEmitsError() $request->handleError(new \Exception('test')); } + public static function provideInvalidRequest() + { + $request = new Request('GET' , "http://localhost/"); + + return array( + array( + $request->withMethod("INVA\r\nLID", '') + ), + array( + $request->withRequestTarget('/inva lid') + ), + array( + $request->withHeader('Invalid', "Yes\r\n") + ), + array( + $request->withHeader('Invalid', "Yes\n") + ), + array( + $request->withHeader('Invalid', "Yes\r") + ), + array( + $request->withHeader("Inva\r\nlid", 'Yes') + ), + array( + $request->withHeader("Inva\nlid", 'Yes') + ), + array( + $request->withHeader("Inva\rlid", 'Yes') + ), + array( + $request->withHeader('Inva Lid', 'Yes') + ), + array( + $request->withHeader('Inva:Lid', 'Yes') + ), + array( + $request->withHeader('Invalid', "Val\0ue") + ), + array( + $request->withHeader("Inva\0lid", 'Yes') + ) + ); + } + + /** + * @dataProvider provideInvalidRequest + * @param RequestInterface $request + */ + public function testStreamShouldEmitErrorBeforeCreatingConnectionWhenRequestIsInvalid(RequestInterface $request) + { + $connectionManager = $this->getMockBuilder('React\Http\Io\ClientConnectionManager')->disableOriginalConstructor()->getMock(); + $connectionManager->expects($this->never())->method('connect'); + + $stream = new ClientRequestStream($connectionManager, $request); + + $stream->on('error', $this->expectCallableOnceWith($this->isInstanceOf('InvalidArgumentException'))); + $stream->on('close', $this->expectCallableOnce()); + + $stream->end(); + } + /** @test */ public function requestShouldEmitErrorIfRequestParserThrowsException() { diff --git a/tests/Io/StreamingServerTest.php b/tests/Io/StreamingServerTest.php index afab371e..b4e3f2f8 100644 --- a/tests/Io/StreamingServerTest.php +++ b/tests/Io/StreamingServerTest.php @@ -2,6 +2,7 @@ namespace React\Tests\Http\Io; +use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use React\EventLoop\Loop; use React\Http\Io\StreamingServer; @@ -2511,7 +2512,7 @@ function ($data) use (&$buffer) { public function testInvalidCallbackFunctionLeadsToException() { $this->setExpectedException('InvalidArgumentException'); - $server = new StreamingServer(Loop::get(), 'invalid'); + new StreamingServer(Loop::get(), 'invalid'); } public function testResponseBodyStreamWillStreamDataWithChunkedTransferEncoding() @@ -2926,6 +2927,91 @@ function ($data) use (&$buffer) { $this->assertInstanceOf('RuntimeException', $exception); } + public static function provideInvalidResponse() + { + $response = new Response(200, array(), '', '1.1', 'OK'); + + return array( + array( + $response->withStatus(99, 'OK') + ), + array( + $response->withStatus(1000, 'OK') + ), + array( + $response->withStatus(200, "Invald\r\nReason: Yes") + ), + array( + $response->withHeader('Invalid', "Yes\r\n") + ), + array( + $response->withHeader('Invalid', "Yes\n") + ), + array( + $response->withHeader('Invalid', "Yes\r") + ), + array( + $response->withHeader("Inva\r\nlid", 'Yes') + ), + array( + $response->withHeader("Inva\nlid", 'Yes') + ), + array( + $response->withHeader("Inva\rlid", 'Yes') + ), + array( + $response->withHeader('Inva Lid', 'Yes') + ), + array( + $response->withHeader('Inva:Lid', 'Yes') + ), + array( + $response->withHeader('Invalid', "Val\0ue") + ), + array( + $response->withHeader("Inva\0lid", 'Yes') + ) + ); + } + + /** + * @dataProvider provideInvalidResponse + * @param ResponseInterface $response + */ + public function testInvalidResponseObjectWillResultInErrorMessage(ResponseInterface $response) + { + $server = new StreamingServer(Loop::get(), function (ServerRequestInterface $request) use ($response) { + return $response; + }); + + $exception = null; + $server->on('error', function (\Exception $ex) use (&$exception) { + $exception = $ex; + }); + + $buffer = ''; + $this->connection + ->expects($this->any()) + ->method('write') + ->will( + $this->returnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ) + ); + + $server->listen($this->socket); + $this->socket->emit('connection', array($this->connection)); + + $data = $this->createGetRequest(); + + $this->connection->emit('data', array($data)); + + $this->assertContainsString("HTTP/1.1 500 Internal Server Error\r\n", $buffer); + $this->assertInstanceOf('InvalidArgumentException', $exception); + } + public function testRequestServerRequestParams() { $requestValidation = null; From 8111281ee57f22b7194f5dba225e609ba7ce4d20 Mon Sep 17 00:00:00 2001 From: Simon Frings Date: Wed, 27 Mar 2024 18:20:46 +0100 Subject: [PATCH 53/58] Prepare v1.10.0 release --- CHANGELOG.md | 32 ++++++++++++++++++++++++++++++++ README.md | 2 +- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d19639d8..f69779c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,37 @@ # Changelog +## 1.10.0 (2024-03-27) + +* Feature: Add new PSR-7 implementation and remove dated RingCentral PSR-7 dependency. + (#518, #519, #520 and #522 by @clue) + + This changeset allows us to maintain our own PSR-7 implementation and reduce + dependencies on external projects. It also improves performance slightly and + does not otherwise affect our public API. If you want to explicitly install + the old RingCentral PSR-7 dependency, you can still install it like this: + + ```bash + composer require ringcentral/psr7 + ``` + +* Feature: Add new `Uri` class for new PSR-7 implementation. + (#521 by @clue) + +* Feature: Validate outgoing HTTP message headers and reject invalid messages. + (#523 by @clue) + +* Feature: Full PHP 8.3 compatibility. + (#508 by @clue) + +* Fix: Fix HTTP client to omit `Transfer-Encoding: chunked` when streaming empty request body. + (#516 by @clue) + +* Fix: Ensure connection close handler is cleaned up for each request. + (#515 by @WyriHaximus) + +* Update test suite and avoid unhandled promise rejections. + (#501 and #502 by @clue) + ## 1.9.0 (2023-04-26) This is a **SECURITY** and feature release for the 1.x series of ReactPHP's HTTP component. diff --git a/README.md b/README.md index 18089464..d0550642 100644 --- a/README.md +++ b/README.md @@ -2986,7 +2986,7 @@ This project follows [SemVer](https://semver.org/). This will install the latest supported version: ```bash -composer require react/http:^1.9 +composer require react/http:^1.10 ``` See also the [CHANGELOG](CHANGELOG.md) for details about version upgrades. From 88128aca31b9a84b1dd1c87cce3477e497cb6e7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Fri, 5 Jul 2024 21:49:14 +0200 Subject: [PATCH 54/58] Improve PHP 8.4+ support by avoiding implicitly nullable types --- composer.json | 10 +++++----- src/Browser.php | 3 ++- src/Io/Sender.php | 7 +------ tests/Io/SenderTest.php | 4 +++- tests/Io/StreamingServerTest.php | 4 ++-- 5 files changed, 13 insertions(+), 15 deletions(-) diff --git a/composer.json b/composer.json index 23783c0c..4234210a 100644 --- a/composer.json +++ b/composer.json @@ -31,18 +31,18 @@ "fig/http-message-util": "^1.1", "psr/http-message": "^1.0", "react/event-loop": "^1.2", - "react/promise": "^3 || ^2.3 || ^1.2.1", - "react/socket": "^1.12", - "react/stream": "^1.2" + "react/promise": "^3.2 || ^2.3 || ^1.2.1", + "react/socket": "^1.16", + "react/stream": "^1.4" }, "require-dev": { "clue/http-proxy-react": "^1.8", "clue/reactphp-ssh-proxy": "^1.4", "clue/socks-react": "^1.4", "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", - "react/async": "^4 || ^3 || ^2", + "react/async": "^4.2 || ^3 || ^2", "react/promise-stream": "^1.4", - "react/promise-timer": "^1.9" + "react/promise-timer": "^1.11" }, "autoload": { "psr-4": { diff --git a/src/Browser.php b/src/Browser.php index 01a266ca..9da0dcab 100644 --- a/src/Browser.php +++ b/src/Browser.php @@ -10,6 +10,7 @@ use React\Http\Message\Request; use React\Http\Message\Uri; use React\Promise\PromiseInterface; +use React\Socket\Connector; use React\Socket\ConnectorInterface; use React\Stream\ReadableStreamInterface; use InvalidArgumentException; @@ -88,7 +89,7 @@ public function __construct($connector = null, $loop = null) $loop = $loop ?: Loop::get(); $this->transaction = new Transaction( - Sender::createFromLoop($loop, $connector), + Sender::createFromLoop($loop, $connector ?: new Connector(array(), $loop)), $loop ); } diff --git a/src/Io/Sender.php b/src/Io/Sender.php index 1d563891..5f456b2f 100644 --- a/src/Io/Sender.php +++ b/src/Io/Sender.php @@ -8,7 +8,6 @@ use React\Http\Client\Client as HttpClient; use React\Promise\PromiseInterface; use React\Promise\Deferred; -use React\Socket\Connector; use React\Socket\ConnectorInterface; use React\Stream\ReadableStreamInterface; @@ -48,12 +47,8 @@ class Sender * @param ConnectorInterface|null $connector * @return self */ - public static function createFromLoop(LoopInterface $loop, ConnectorInterface $connector = null) + public static function createFromLoop(LoopInterface $loop, ConnectorInterface $connector) { - if ($connector === null) { - $connector = new Connector(array(), $loop); - } - return new self(new HttpClient(new ClientConnectionManager($connector, $loop))); } diff --git a/tests/Io/SenderTest.php b/tests/Io/SenderTest.php index 03a9b56e..a65b13b3 100644 --- a/tests/Io/SenderTest.php +++ b/tests/Io/SenderTest.php @@ -28,7 +28,9 @@ public function setUpLoop() public function testCreateFromLoop() { - $sender = Sender::createFromLoop($this->loop, null); + $connector = $this->getMockBuilder('React\Socket\ConnectorInterface')->getMock(); + + $sender = Sender::createFromLoop($this->loop, $connector); $this->assertInstanceOf('React\Http\Io\Sender', $sender); } diff --git a/tests/Io/StreamingServerTest.php b/tests/Io/StreamingServerTest.php index b4e3f2f8..b1410fad 100644 --- a/tests/Io/StreamingServerTest.php +++ b/tests/Io/StreamingServerTest.php @@ -32,7 +32,7 @@ public function setUpConnectionMockAndSocket() } - private function mockConnection(array $additionalMethods = null) + private function mockConnection(array $additionalMethods = array()) { $connection = $this->getMockBuilder('React\Socket\Connection') ->disableOriginalConstructor() @@ -49,7 +49,7 @@ private function mockConnection(array $additionalMethods = null) 'getLocalAddress', 'pipe' ), - (is_array($additionalMethods) ? $additionalMethods : array()) + $additionalMethods )) ->getMock(); From e0ab1743cddc80774f36019641b0a5b2a133677f Mon Sep 17 00:00:00 2001 From: lucasnetau Date: Tue, 27 Aug 2024 08:10:36 +0200 Subject: [PATCH 55/58] Fix expected error code in tests when ext-sockets is not enabled This is a backport of #532 and corrects an oversight introduced in #482. --- tests/FunctionalBrowserTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/FunctionalBrowserTest.php b/tests/FunctionalBrowserTest.php index 7b8ff84b..ef1b3936 100644 --- a/tests/FunctionalBrowserTest.php +++ b/tests/FunctionalBrowserTest.php @@ -366,7 +366,7 @@ public function testGetRequestWithResponseBufferExceededRejects() $this->setExpectedException( 'OverflowException', 'Response body size of 5 bytes exceeds maximum of 4 bytes', - defined('SOCKET_EMSGSIZE') ? SOCKET_EMSGSIZE : 0 + defined('SOCKET_EMSGSIZE') ? SOCKET_EMSGSIZE : 90 ); \React\Async\await($promise); } @@ -378,7 +378,7 @@ public function testGetRequestWithResponseBufferExceededDuringStreamingRejects() $this->setExpectedException( 'OverflowException', 'Response body size exceeds maximum of 4 bytes', - defined('SOCKET_EMSGSIZE') ? SOCKET_EMSGSIZE : 0 + defined('SOCKET_EMSGSIZE') ? SOCKET_EMSGSIZE : 90 ); \React\Async\await($promise); } From 888e3c090f135b11f7ecdb5448d89cb8d444fc15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ana=C3=AFs=20Babel?= Date: Mon, 22 Apr 2024 13:22:45 +0200 Subject: [PATCH 56/58] Allow underscore character in Uri host I don't understand why this validity check has be added. Has per rfc 2181 (https://datatracker.ietf.org/doc/html/rfc2181#section-11), underscore are valid character to use in an uri host. For my specific usage, it broke for requests using docker internal hostnames. added test to prevent regression on URI containing underscore in host --- src/Message/Uri.php | 4 ++-- tests/Message/UriTest.php | 13 +++++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/Message/Uri.php b/src/Message/Uri.php index 4309bbed..1eaf24fe 100644 --- a/src/Message/Uri.php +++ b/src/Message/Uri.php @@ -55,7 +55,7 @@ public function __construct($uri) } // @codeCoverageIgnoreEnd - if ($parts === false || (isset($parts['scheme']) && !\preg_match('#^[a-z]+$#i', $parts['scheme'])) || (isset($parts['host']) && \preg_match('#[\s_%+]#', $parts['host']))) { + if ($parts === false || (isset($parts['scheme']) && !\preg_match('#^[a-z]+$#i', $parts['scheme'])) || (isset($parts['host']) && \preg_match('#[\s%+]#', $parts['host']))) { throw new \InvalidArgumentException('Invalid URI given'); } @@ -173,7 +173,7 @@ public function withHost($host) return $this; } - if (\preg_match('#[\s_%+]#', $host) || ($host !== '' && \parse_url('http://' . $host, \PHP_URL_HOST) !== $host)) { + if (\preg_match('#[\s%+]#', $host) || ($host !== '' && \parse_url('http://' . $host, \PHP_URL_HOST) !== $host)) { throw new \InvalidArgumentException('Invalid URI host given'); } diff --git a/tests/Message/UriTest.php b/tests/Message/UriTest.php index 05eec723..cc4b16f1 100644 --- a/tests/Message/UriTest.php +++ b/tests/Message/UriTest.php @@ -120,6 +120,9 @@ public static function provideValidUris() ), array( 'http://user%20name:pass%20word@localhost/path%20name?query%20name#frag%20ment' + ), + array( + 'http://docker_container/' ) ); } @@ -338,6 +341,16 @@ public function testWithHostReturnsNewInstanceWhenHostIsChanged() $this->assertEquals('localhost', $uri->getHost()); } + public function testWithHostReturnsNewInstanceWhenHostIsChangedWithUnderscore() + { + $uri = new Uri('http://localhost/'); + + $new = $uri->withHost('docker_container'); + $this->assertNotSame($uri, $new); + $this->assertEquals('docker_container', $new->getHost()); + $this->assertEquals('localhost', $uri->getHost()); + } + public function testWithHostReturnsNewInstanceWithHostToLowerCaseWhenHostIsChangedWithUpperCase() { $uri = new Uri('http://localhost/'); From 8db02de41dcca82037367f67a2d4be365b1c4db9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Wed, 20 Nov 2024 16:24:08 +0100 Subject: [PATCH 57/58] Prepare v1.11.0 release --- CHANGELOG.md | 11 +++++++++++ README.md | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f69779c6..07a4cc66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## 1.11.0 (2024-11-20) + +* Feature: Improve PHP 8.4+ support by avoiding implicitly nullable types. + (#537 by @clue) + +* Feature: Allow underscore character in Uri host. + (#524 by @lulhum) + +* Improve test suite to fix expected error code when ext-sockets is not enabled. + (#539 by @WyriHaximus) + ## 1.10.0 (2024-03-27) * Feature: Add new PSR-7 implementation and remove dated RingCentral PSR-7 dependency. diff --git a/README.md b/README.md index d0550642..fd9ba46e 100644 --- a/README.md +++ b/README.md @@ -2986,7 +2986,7 @@ This project follows [SemVer](https://semver.org/). This will install the latest supported version: ```bash -composer require react/http:^1.10 +composer require react/http:^1.11 ``` See also the [CHANGELOG](CHANGELOG.md) for details about version upgrades. From 1b6fce34b1aea75ad994fa6baec6357109b6b857 Mon Sep 17 00:00:00 2001 From: Cees-Jan Kiewiet Date: Fri, 10 Oct 2025 08:09:08 +0200 Subject: [PATCH 58/58] [1.x] Run tests on PHP 8.4 and update test environment Builds on top of #508 by porting #543 to v1. --- .github/workflows/ci.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3666cd47..b0005012 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,10 +7,11 @@ on: jobs: PHPUnit: name: PHPUnit (PHP ${{ matrix.php }}) - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 strategy: matrix: php: + - 8.4 - 8.3 - 8.2 - 8.1