diff --git a/README.md b/README.md index ac06dbf..aa54e34 100644 --- a/README.md +++ b/README.md @@ -22,42 +22,77 @@ order to complete: ## Usage In order to use this project, you'll need the following react boilerplate code -to initialize the main loop and select your DNS server if you have not already -set it up anyway. +to initialize the main loop. ```php $loop = React\EventLoop\Factory::create(); - -$dnsResolverFactory = new React\Dns\Resolver\Factory(); -$dns = $dnsResolverFactory->createCached('8.8.8.8', $loop); ``` ### Async TCP/IP connections -The `React\SocketClient\Connector` provides a single promise-based -`create($host, $ip)` method which resolves as soon as the connection +The `React\SocketClient\TcpConnector` provides a single promise-based +`create($ip, $port)` method which resolves as soon as the connection succeeds or fails. ```php -$connector = new React\SocketClient\Connector($loop, $dns); +$tcpConnector = new React\SocketClient\TcpConnector($loop); -$connector->create('www.google.com', 80)->then(function (React\Stream\Stream $stream) { +$tcpConnector->create('127.0.0.1', 80)->then(function (React\Stream\Stream $stream) { $stream->write('...'); - $stream->close(); + $stream->end(); }); $loop->run(); ``` +Note that this class only allows you to connect to IP/port combinations. +If you want to connect to hostname/port combinations, see also the following chapter. + +### DNS resolution + +The `DnsConnector` class decorates a given `TcpConnector` instance by first +looking up the given domain name and then establishing the underlying TCP/IP +connection to the resolved IP address. + +It provides the same promise-based `create($host, $port)` method which resolves with +a `Stream` instance that can be used just like above. + +Make sure to set up your DNS resolver and underlying TCP connector like this: + +```php +$dnsResolverFactory = new React\Dns\Resolver\Factory(); +$dns = $dnsResolverFactory->createCached('8.8.8.8', $loop); + +$dnsConnector = new React\SocketClient\DnsConnector($tcpConnector, $dns); + +$dnsConnector->create('www.google.com', 80)->then(function (React\Stream\Stream $stream) { + $stream->write('...'); + $stream->end(); +}); + +$loop->run(); +``` + +The legacy `Connector` class can be used for backwards-compatiblity reasons. +It works very much like the newer `DnsConnector` but instead has to be +set up like this: + +```php +$connector = new React\SocketClient\Connector($loop, $dns); + +$connector->create('www.google.com', 80)->then($callback); +``` + ### Async SSL/TLS connections The `SecureConnector` class decorates a given `Connector` instance by enabling -SSL/TLS encryption as soon as the raw TCP/IP connection succeeds. It provides -the same promise- based `create($host, $ip)` method which resolves with -a `Stream` instance that can be used just like any non-encrypted stream. +SSL/TLS encryption as soon as the raw TCP/IP connection succeeds. + +It provides the same promise- based `create($host, $port)` method which resolves with +a `Stream` instance that can be used just like any non-encrypted stream: ```php -$secureConnector = new React\SocketClient\SecureConnector($connector, $loop); +$secureConnector = new React\SocketClient\SecureConnector($dnsConnector, $loop); $secureConnector->create('www.google.com', 443)->then(function (React\Stream\Stream $stream) { $stream->write("GET / HTTP/1.0\r\nHost: www.google.com\r\n\r\n"); diff --git a/src/Connector.php b/src/Connector.php index eed8d91..6cf991c 100644 --- a/src/Connector.php +++ b/src/Connector.php @@ -4,100 +4,21 @@ use React\EventLoop\LoopInterface; use React\Dns\Resolver\Resolver; -use React\Stream\Stream; -use React\Promise; -use React\Promise\Deferred; +/** + * @deprecated Exists for BC only, consider using the newer DnsConnector instead + */ class Connector implements ConnectorInterface { - private $loop; - private $resolver; + private $connector; public function __construct(LoopInterface $loop, Resolver $resolver) { - $this->loop = $loop; - $this->resolver = $resolver; + $this->connector = new DnsConnector(new TcpConnector($loop), $resolver); } public function create($host, $port) { - return $this - ->resolveHostname($host) - ->then(function ($address) use ($port) { - return $this->createSocketForAddress($address, $port); - }); - } - - public function createSocketForAddress($address, $port) - { - $url = $this->getSocketUrl($address, $port); - - $flags = STREAM_CLIENT_CONNECT | STREAM_CLIENT_ASYNC_CONNECT; - $socket = stream_socket_client($url, $errno, $errstr, 0, $flags); - - if (!$socket) { - return Promise\reject(new \RuntimeException( - sprintf("connection to %s:%d failed: %s", $address, $port, $errstr), - $errno - )); - } - - stream_set_blocking($socket, 0); - - // wait for connection - - return $this - ->waitForStreamOnce($socket) - ->then(array($this, 'checkConnectedSocket')) - ->then(array($this, 'handleConnectedSocket')); - } - - protected function waitForStreamOnce($stream) - { - $deferred = new Deferred(); - - $loop = $this->loop; - - $this->loop->addWriteStream($stream, function ($stream) use ($loop, $deferred) { - $loop->removeWriteStream($stream); - - $deferred->resolve($stream); - }); - - return $deferred->promise(); - } - - public function checkConnectedSocket($socket) - { - // The following hack looks like the only way to - // detect connection refused errors with PHP's stream sockets. - if (false === stream_socket_get_name($socket, true)) { - return Promise\reject(new ConnectionException('Connection refused')); - } - - return Promise\resolve($socket); - } - - public function handleConnectedSocket($socket) - { - return new Stream($socket, $this->loop); - } - - protected function getSocketUrl($host, $port) - { - if (strpos($host, ':') !== false) { - // enclose IPv6 addresses in square brackets before appending port - $host = '[' . $host . ']'; - } - return sprintf('tcp://%s:%s', $host, $port); - } - - protected function resolveHostname($host) - { - if (false !== filter_var($host, FILTER_VALIDATE_IP)) { - return Promise\resolve($host); - } - - return $this->resolver->resolve($host); + return $this->connector->create($host, $port); } } diff --git a/src/DnsConnector.php b/src/DnsConnector.php new file mode 100644 index 0000000..6611995 --- /dev/null +++ b/src/DnsConnector.php @@ -0,0 +1,41 @@ +connector = $connector; + $this->resolver = $resolver; + } + + public function create($host, $port) + { + $connector = $this->connector; + + return $this + ->resolveHostname($host) + ->then(function ($address) use ($connector, $port) { + return $connector->create($address, $port); + }); + } + + private function resolveHostname($host) + { + if (false !== filter_var($host, FILTER_VALIDATE_IP)) { + return Promise\resolve($host); + } + + return $this->resolver->resolve($host); + } +} diff --git a/src/TcpConnector.php b/src/TcpConnector.php new file mode 100644 index 0000000..ddd5bd3 --- /dev/null +++ b/src/TcpConnector.php @@ -0,0 +1,88 @@ +loop = $loop; + } + + public function create($ip, $port) + { + if (false === filter_var($ip, FILTER_VALIDATE_IP)) { + return Promise\reject(new \InvalidArgumentException('Given parameter "' . $ip . '" is not a valid IP')); + } + + $url = $this->getSocketUrl($ip, $port); + + $socket = stream_socket_client($url, $errno, $errstr, 0, STREAM_CLIENT_CONNECT | STREAM_CLIENT_ASYNC_CONNECT); + + if (!$socket) { + return Promise\reject(new \RuntimeException( + sprintf("Connection to %s:%d failed: %s", $ip, $port, $errstr), + $errno + )); + } + + stream_set_blocking($socket, 0); + + // wait for connection + + return $this + ->waitForStreamOnce($socket) + ->then(array($this, 'checkConnectedSocket')) + ->then(array($this, 'handleConnectedSocket')); + } + + private function waitForStreamOnce($stream) + { + $deferred = new Deferred(); + + $loop = $this->loop; + + $this->loop->addWriteStream($stream, function ($stream) use ($loop, $deferred) { + $loop->removeWriteStream($stream); + + $deferred->resolve($stream); + }); + + return $deferred->promise(); + } + + /** @internal */ + public function checkConnectedSocket($socket) + { + // The following hack looks like the only way to + // detect connection refused errors with PHP's stream sockets. + if (false === stream_socket_get_name($socket, true)) { + return Promise\reject(new ConnectionException('Connection refused')); + } + + return Promise\resolve($socket); + } + + /** @internal */ + public function handleConnectedSocket($socket) + { + return new Stream($socket, $this->loop); + } + + private function getSocketUrl($ip, $port) + { + if (strpos($ip, ':') !== false) { + // enclose IPv6 addresses in square brackets before appending port + $ip = '[' . $ip . ']'; + } + return sprintf('tcp://%s:%s', $ip, $port); + } +} diff --git a/tests/DnsConnectorTest.php b/tests/DnsConnectorTest.php new file mode 100644 index 0000000..f8ab96e --- /dev/null +++ b/tests/DnsConnectorTest.php @@ -0,0 +1,45 @@ +tcp = $this->getMock('React\SocketClient\ConnectorInterface'); + $this->resolver = $this->getMockBuilder('React\Dns\Resolver\Resolver')->disableOriginalConstructor()->getMock(); + + $this->connector = new DnsConnector($this->tcp, $this->resolver); + } + + public function testPassByResolverIfGivenIp() + { + $this->resolver->expects($this->never())->method('resolve'); + $this->tcp->expects($this->once())->method('create')->with($this->equalTo('127.0.0.1'), $this->equalTo(80)); + + $this->connector->create('127.0.0.1', 80); + } + + public function testPassThroughResolverIfGivenHost() + { + $this->resolver->expects($this->once())->method('resolve')->with($this->equalTo('google.com'))->will($this->returnValue(Promise\resolve('1.2.3.4'))); + $this->tcp->expects($this->once())->method('create')->with($this->equalTo('1.2.3.4'), $this->equalTo(80)); + + $this->connector->create('google.com', 80); + } + + public function testSkipConnectionIfDnsFails() + { + $this->resolver->expects($this->once())->method('resolve')->with($this->equalTo('example.invalid'))->will($this->returnValue(Promise\reject())); + $this->tcp->expects($this->never())->method('create'); + + $this->connector->create('example.invalid', 80); + } +} diff --git a/tests/IntegrationTest.php b/tests/IntegrationTest.php index 889a8cb..a2183b4 100644 --- a/tests/IntegrationTest.php +++ b/tests/IntegrationTest.php @@ -18,11 +18,11 @@ public function gettingStuffFromGoogleShouldWork() $factory = new Factory(); $dns = $factory->create('8.8.8.8', $loop); + $connector = new Connector($loop, $dns); $connected = false; $response = null; - $connector = new Connector($loop, $dns); $connector->create('google.com', 80) ->then(function ($conn) use (&$connected) { $connected = true; diff --git a/tests/ConnectorTest.php b/tests/TcpConnectorTest.php similarity index 76% rename from tests/ConnectorTest.php rename to tests/TcpConnectorTest.php index a34b7aa..b949c60 100644 --- a/tests/ConnectorTest.php +++ b/tests/TcpConnectorTest.php @@ -4,18 +4,16 @@ use React\EventLoop\StreamSelectLoop; use React\Socket\Server; -use React\SocketClient\Connector; +use React\SocketClient\TcpConnector; -class ConnectorTest extends TestCase +class TcpConnectorTest extends TestCase { /** @test */ public function connectionToEmptyPortShouldFail() { $loop = new StreamSelectLoop(); - $dns = $this->createResolverMock(); - - $connector = new Connector($loop, $dns); + $connector = new TcpConnector($loop); $connector->create('127.0.0.1', 9999) ->then($this->expectCallableNever(), $this->expectCallableOnce()); @@ -36,9 +34,7 @@ public function connectionToTcpServerShouldSucceed() }); $server->listen(9999); - $dns = $this->createResolverMock(); - - $connector = new Connector($loop, $dns); + $connector = new TcpConnector($loop); $connector->create('127.0.0.1', 9999) ->then(function ($stream) use (&$capturedStream) { $capturedStream = $stream; @@ -55,9 +51,7 @@ public function connectionToEmptyIp6PortShouldFail() { $loop = new StreamSelectLoop(); - $dns = $this->createResolverMock(); - - $connector = new Connector($loop, $dns); + $connector = new TcpConnector($loop); $connector ->create('::1', 9999) ->then($this->expectCallableNever(), $this->expectCallableOnce()); @@ -77,9 +71,7 @@ public function connectionToIp6TcpServerShouldSucceed() $server->on('connection', array($server, 'shutdown')); $server->listen(9999, '::1'); - $dns = $this->createResolverMock(); - - $connector = new Connector($loop, $dns); + $connector = new TcpConnector($loop); $connector ->create('::1', 9999) ->then(function ($stream) use (&$capturedStream) { @@ -92,10 +84,15 @@ public function connectionToIp6TcpServerShouldSucceed() $this->assertInstanceOf('React\Stream\Stream', $capturedStream); } - private function createResolverMock() + /** @test */ + public function connectionToHostnameShouldFailImmediately() { - return $this->getMockBuilder('React\Dns\Resolver\Resolver') - ->disableOriginalConstructor() - ->getMock(); + $loop = $this->getMock('React\EventLoop\LoopInterface'); + + $connector = new TcpConnector($loop); + $connector->create('www.google.com', 80)->then( + $this->expectCallableNever(), + $this->expectCallableOnce() + ); } }