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 1/2] 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 name:pass word@localhost/path name?query name#frag ment', + '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 2/2] 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); + } }