diff --git a/README.md b/README.md index a6c17ba..4fd1b97 100644 --- a/README.md +++ b/README.md @@ -107,11 +107,11 @@ $factory = new Factory($loop, $connector); #### createClient() The `createClient($redisUri = null)` method can be used to create a new [`Client`](#client). -It helps with establishing a plain TCP/IP connection to Redis +It helps with establishing a plain TCP/IP or secure TLS connection to Redis and optionally authenticating (AUTH) and selecting the right database (SELECT). ```php -$factory->createClient('localhost:6379')->then( +$factory->createClient('redis://localhost:6379')->then( function (Client $client) { // client connected (and authenticated) }, @@ -121,28 +121,56 @@ $factory->createClient('localhost:6379')->then( ); ``` -You can omit the complete URI if you want to connect to the default address `localhost:6379`: +The `$redisUri` can be given in the +[standard](https://www.iana.org/assignments/uri-schemes/prov/redis) form +`[redis[s]://][:auth@]host[:port][/db]`. +You can omit the URI scheme and port if you're connecting to the default port 6379: ```php -$factory->createClient(); +// both are equivalent due to defaults being applied +$factory->createClient('localhost'); +$factory->createClient('redis://localhost:6379'); ``` -You can omit the port if you're connecting to the default port 6379: +Redis supports password-based authentication (`AUTH` command). Note that Redis' +authentication mechanism does not employ a username, so you can pass the +password `h@llo` URL-encoded (percent-encoded) as part of the URI like this: ```php -$factory->createClient('localhost'); +// all forms are equivalent +$factory->createClient('redis://:h%40llo@localhost'); +$factory->createClient('redis://ignored:h%40llo@localhost'); +$factory->createClient('redis://localhost?password=h%40llo'); ``` -You can optionally include a password that will be used to authenticate (AUTH command) the client: +> Legacy notice: The `redis://` scheme is defined and preferred as of `v1.2.0`. + For BC reasons, the `Factory` defaults to the `tcp://` scheme in which case + the authentication details would include the otherwise unused username. + This legacy API will be removed in a future `v2.0.0` version, so it's highly + recommended to upgrade to the above API. + +You can optionally include a path that will be used to select (SELECT command) the right database: ```php -$factory->createClient('auth@localhost'); +// both forms are equivalent +$factory->createClient('redis://localhost/2'); +$factory->createClient('redis://localhost?db=2'); ``` -You can optionally include a path that will be used to select (SELECT command) the right database: +You can use the [standard](https://www.iana.org/assignments/uri-schemes/prov/rediss) +`rediss://` URI scheme if you're using a secure TLS proxy in front of Redis: + +```php +$factory->createClient('rediss://redis.example.com:6340'); +``` + +[Deprecated] You can omit the complete URI if you want to connect to the default +address `redis://localhost:6379`. This legacy API will be removed in a future +`v2.0.0` version, so it's highly recommended to upgrade to the above API. ```php -$factory->createClient('localhost/2'); +// deprecated +$factory->createClient(); ``` ### Client diff --git a/src/Factory.php b/src/Factory.php index 93b3cd2..13aa461 100644 --- a/src/Factory.php +++ b/src/Factory.php @@ -44,7 +44,9 @@ public function __construct(LoopInterface $loop, $connector = null, ProtocolFact /** * create redis client connected to address of given redis instance * - * @param string|null $target + * @param string|null $target Redis server URI to connect to. Not passing + * this parameter is deprecated and only supported for BC reasons and + * will be removed in future versions. * @return \React\Promise\PromiseInterface resolves with Client or rejects with \Exception */ public function createClient($target = null) @@ -107,7 +109,7 @@ private function parseUrl($target) } $parts = parse_url($target); - if ($parts === false || !isset($parts['host']) || $parts['scheme'] !== 'tcp') { + if ($parts === false || !isset($parts['scheme'], $parts['host']) || !in_array($parts['scheme'], array('tcp', 'redis', 'rediss'))) { throw new InvalidArgumentException('Given URL can not be parsed'); } @@ -120,11 +122,11 @@ private function parseUrl($target) } $auth = null; - if (isset($parts['user'])) { - $auth = $parts['user']; + if (isset($parts['user']) && $parts['scheme'] === 'tcp') { + $auth = rawurldecode($parts['user']); } if (isset($parts['pass'])) { - $auth .= ':' . $parts['pass']; + $auth .= ($parts['scheme'] === 'tcp' ? ':' : '') . rawurldecode($parts['pass']); } if ($auth !== null) { $parts['auth'] = $auth; @@ -135,6 +137,23 @@ private function parseUrl($target) $parts['db'] = substr($parts['path'], 1); } + if ($parts['scheme'] === 'rediss') { + $parts['host'] = 'tls://' . $parts['host']; + } + + if (isset($parts['query'])) { + $args = array(); + parse_str($parts['query'], $args); + + if (isset($args['password'])) { + $parts['auth'] = $args['password']; + } + + if (isset($args['db'])) { + $parts['db'] = $args['db']; + } + } + unset($parts['scheme'], $parts['user'], $parts['pass'], $parts['path']); return $parts; diff --git a/tests/FactoryTest.php b/tests/FactoryTest.php index acb0d3f..a2027cb 100644 --- a/tests/FactoryTest.php +++ b/tests/FactoryTest.php @@ -65,10 +65,64 @@ public function testWillWriteSelectCommandIfTargetContainsPath() $stream->expects($this->once())->method('write')->with("*2\r\n$6\r\nselect\r\n$4\r\ndemo\r\n"); $this->connector->expects($this->once())->method('connect')->willReturn(Promise\resolve($stream)); - $this->factory->createClient('tcp://127.0.0.1/demo'); + $this->factory->createClient('redis://127.0.0.1/demo'); } - public function testWillWriteAuthCommandIfTargetContainsUserInfo() + public function testWillWriteSelectCommandIfTargetContainsDbQueryParameter() + { + $stream = $this->getMockBuilder('React\Socket\ConnectionInterface')->getMock(); + $stream->expects($this->once())->method('write')->with("*2\r\n$6\r\nselect\r\n$1\r\n4\r\n"); + + $this->connector->expects($this->once())->method('connect')->willReturn(Promise\resolve($stream)); + $this->factory->createClient('redis://127.0.0.1?db=4'); + } + + public function testWillWriteAuthCommandIfRedisUriContainsUserInfo() + { + $stream = $this->getMockBuilder('React\Socket\ConnectionInterface')->getMock(); + $stream->expects($this->once())->method('write')->with("*2\r\n$4\r\nauth\r\n$5\r\nworld\r\n"); + + $this->connector->expects($this->once())->method('connect')->with('example.com:6379')->willReturn(Promise\resolve($stream)); + $this->factory->createClient('redis://hello:world@example.com'); + } + + public function testWillWriteAuthCommandIfRedisUriContainsEncodedUserInfo() + { + $stream = $this->getMockBuilder('React\Socket\ConnectionInterface')->getMock(); + $stream->expects($this->once())->method('write')->with("*2\r\n$4\r\nauth\r\n$5\r\nh@llo\r\n"); + + $this->connector->expects($this->once())->method('connect')->with('example.com:6379')->willReturn(Promise\resolve($stream)); + $this->factory->createClient('redis://:h%40llo@example.com'); + } + + public function testWillWriteAuthCommandIfTargetContainsPasswordQueryParameter() + { + $stream = $this->getMockBuilder('React\Socket\ConnectionInterface')->getMock(); + $stream->expects($this->once())->method('write')->with("*2\r\n$4\r\nauth\r\n$6\r\nsecret\r\n"); + + $this->connector->expects($this->once())->method('connect')->with('example.com:6379')->willReturn(Promise\resolve($stream)); + $this->factory->createClient('redis://example.com?password=secret'); + } + + public function testWillWriteAuthCommandIfTargetContainsEncodedPasswordQueryParameter() + { + $stream = $this->getMockBuilder('React\Socket\ConnectionInterface')->getMock(); + $stream->expects($this->once())->method('write')->with("*2\r\n$4\r\nauth\r\n$5\r\nh@llo\r\n"); + + $this->connector->expects($this->once())->method('connect')->with('example.com:6379')->willReturn(Promise\resolve($stream)); + $this->factory->createClient('redis://example.com?password=h%40llo'); + } + + public function testWillWriteAuthCommandIfRedissUriContainsUserInfo() + { + $stream = $this->getMockBuilder('React\Socket\ConnectionInterface')->getMock(); + $stream->expects($this->once())->method('write')->with("*2\r\n$4\r\nauth\r\n$5\r\nworld\r\n"); + + $this->connector->expects($this->once())->method('connect')->with('tls://example.com:6379')->willReturn(Promise\resolve($stream)); + $this->factory->createClient('rediss://hello:world@example.com'); + } + + public function testWillWriteAuthCommandIfTcpUriContainsUserInfo() { $stream = $this->getMockBuilder('React\Socket\ConnectionInterface')->getMock(); $stream->expects($this->once())->method('write')->with("*2\r\n$4\r\nauth\r\n$11\r\nhello:world\r\n");