From 3037dfbbb18ae289d28c0d7fbd2627d47045e0d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Mon, 27 Jul 2015 00:44:14 +0200 Subject: [PATCH] Resolve relative URIs, add withBase() and resolve() --- README.md | 76 +++++++++++++++++++++++++++++++++- src/Browser.php | 66 ++++++++++++++++++++++++++---- src/Message/Uri.php | 52 +++++++++++++++++++++++ tests/BrowserTest.php | 86 ++++++++++++++++++++++++++++++++++++++- tests/Message/UriTest.php | 57 ++++++++++++++++++++++++++ 5 files changed, 328 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index d294fbd..4001d0b 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,80 @@ actually returns a *new* [`Browser`](#browser) instance with the given [`Sender` See [`Sender`](#sender) for more details. +#### withBase() + +The `withBase($baseUri)` method can be used to change the base URI used to +resolve relative URIs to. + +```php +$newBrowser = $browser->withBase('http://api.example.com/v3'); +``` + +Notice that the [`Browser`](#browser) is an immutable object, i.e. the `withBase()` method +actually returns a *new* [`Browser`](#browser) instance with the given base URI applied. + +Any requests to relative URIs will then be processed by first prepending the +base URI. +Please note that this merely prepends the base URI and does *not* resolve any +relative path references. +This is mostly useful for API calls where all endpoints (URIs) are located +under a common base URI scheme. + +```php +// will request http://api.example.com/v3/example +$newBrowser->get('/example')->then(…); +``` + +See also [`resolve()`](#resolve). + +#### withoutBase() + +The `withoutBase()` method can be used to remove the base URI. + +```php +$newBrowser = $browser->withoutBase(); +``` + +Notice that the [`Browser`](#browser) is an immutable object, i.e. the `withoutBase()` method +actually returns a *new* [`Browser`](#browser) instance without any base URI applied. + +See also [`withBase()`](#withbase). + +#### resolve() + +The `resolve($uri)` method can be used to resolve the given relative URI to +an absolute URI by appending it behind the configured base URI. +It returns a new [`Uri`](#uri) instace which can then be passed +to the [HTTP methods](#methods). + +Please note that this merely prepends the base URI and does *not* resolve any +relative path references. +This is mostly useful for API calls where all endpoints (URIs) are located +under a common base URI: + +```php +$newBrowser = $browser->withBase('http://api.example.com/v3'); + +echo $newBrowser->resolve('/example'); +// http://api.example.com/v3/example +``` + +Trying to resolve anything that does not live under the same base URI will +result in an `UnexpectedValueException`: + +```php +$newBrowser->resolve('http://www.example.com/'); +// throws UnexpectedValueException +``` + +Similarily, if you do not have a base URI configured, passing a relative URI +will result in an `InvalidArgumentException`: + +```php +$browser->resolve('/example'); +// throws InvalidArgumentException +``` + ### Message The `Message` is an abstract base class for the [`Response`](#response) and [`Request`](#request). @@ -147,7 +221,7 @@ See its [class outline](src/Message/Request.php) for more details. #### getUri() -The `getUri()` method can be used to get its [`Uri`](#sender) instance. +The `getUri()` method can be used to get its [`Uri`](#uri) instance. ### Uri diff --git a/src/Browser.php b/src/Browser.php index 2afc810..ec94d6f 100644 --- a/src/Browser.php +++ b/src/Browser.php @@ -8,11 +8,13 @@ use Clue\React\Buzz\Message\Body; use Clue\React\Buzz\Message\Headers; use Clue\React\Buzz\Io\Sender; +use Clue\React\Buzz\Message\Uri; class Browser { private $sender; private $loop; + private $baseUri = null; private $options = array(); public function __construct(LoopInterface $loop, Sender $sender = null) @@ -26,32 +28,32 @@ public function __construct(LoopInterface $loop, Sender $sender = null) public function get($url, $headers = array()) { - return $this->send(new Request('GET', $url, $headers)); + return $this->send(new Request('GET', $this->resolve($url), $headers)); } public function post($url, $headers = array(), $content = '') { - return $this->send(new Request('POST', $url, $headers, $content)); + return $this->send(new Request('POST', $this->resolve($url), $headers, $content)); } public function head($url, $headers = array()) { - return $this->send(new Request('HEAD', $url, $headers)); + return $this->send(new Request('HEAD', $this->resolve($url), $headers)); } public function patch($url, $headers = array(), $content = '') { - return $this->send(new Request('PATCH', $url , $headers, $content)); + return $this->send(new Request('PATCH', $this->resolve($url) , $headers, $content)); } public function put($url, $headers = array(), $content = '') { - return $this->send(new Request('PUT', $url, $headers, $content)); + return $this->send(new Request('PUT', $this->resolve($url), $headers, $content)); } public function delete($url, $headers = array(), $content = '') { - return $this->send(new Request('DELETE', $url, $headers, $content)); + return $this->send(new Request('DELETE', $this->resolve($url), $headers, $content)); } public function submit($url, array $fields, $headers = array(), $method = 'POST') @@ -59,7 +61,7 @@ public function submit($url, array $fields, $headers = array(), $method = 'POST' $headers['Content-Type'] = 'application/x-www-form-urlencoded'; $content = http_build_query($fields); - return $this->send(new Request($method, $url, $headers, $content)); + return $this->send(new Request($method, $this->resolve($url), $headers, $content)); } public function send(Request $request) @@ -69,6 +71,56 @@ public function send(Request $request) return $transaction->send(); } + /** + * Returns an absolute URI by processing the given relative URI + * + * @param string|Uri $uri relative or absolute URI + * @return Uri absolute URI + * @see self::withBase() + */ + public function resolve($uri) + { + if ($this->baseUri !== null) { + return $this->baseUri->expandBase($uri); + } + + return new Uri($uri); + } + + /** + * Creates a new Browser instance with the given absolute base URI + * + * This is mostly useful for use with the `resolve()` method. + * Any relative URI passed to `uri()` will simply be appended behind the given + * `$baseUrl`. + * + * @param string|Uri $baseUri absolute base URI + * @return self + * @see self::url() + * @see self::withoutBase() + */ + public function withBase($baseUri) + { + $browser = clone $this; + $browser->baseUri = new Uri($baseUri); + + return $browser; + } + + /** + * Creates a new Browser instance *without* a base URL + * + * @return self + * @see self::withBase() + */ + public function withoutBase() + { + $browser = clone $this; + $browser->baseUri = null; + + return $browser; + } + public function withOptions(array $options) { $browser = clone $this; diff --git a/src/Message/Uri.php b/src/Message/Uri.php index ad7a541..ac7c1a5 100644 --- a/src/Message/Uri.php +++ b/src/Message/Uri.php @@ -8,6 +8,7 @@ class Uri { private $scheme; private $host; + private $port; private $path; private $query; @@ -63,4 +64,55 @@ public function getQuery() { return $this->query; } + + /** + * Reolves the given $uri by appending it behind $this base URI + * + * @param unknown $uri + * @return Uri + * @throws UnexpectedValueException + * @internal + * @see Browser::resolve() + */ + public function expandBase($uri) + { + if ($uri instanceof self) { + return $this->assertBase($uri); + } + + try { + return $this->assertBase(new self($uri)); + } catch (\InvalidArgumentException $e) { + // not an absolute URI + } + + $new = clone $this; + + $pos = strpos($uri, '?'); + if ($pos !== false) { + $new->query = substr($uri, $pos + 1); + $uri = substr($uri, 0, $pos); + } + + if ($uri !== '' && substr($new->path, -1) !== '/') { + $new->path .= '/'; + } + + if (isset($uri[0]) && $uri[0] === '/') { + $uri = substr($uri, 1); + } + + $new->path .= $uri; + + return $new; + } + + private function assertBase(Uri $new) + { + if ($new->scheme !== $this->scheme || $new->host !== $this->host || $new->port !== $this->port || strpos($new->path, $this->path) !== 0) { + throw new \UnexpectedValueException('Invalid base, "' . $new . '" does not appear to be below "' . $this . '"'); + } + + return $new; + } } diff --git a/tests/BrowserTest.php b/tests/BrowserTest.php index 46113f2..5109747 100644 --- a/tests/BrowserTest.php +++ b/tests/BrowserTest.php @@ -1,7 +1,7 @@ assertNotSame($this->browser, $browser); } + + public function testResolveAbsoluteReturnsSame() + { + $this->assertEquals('http://example.com/', $this->browser->resolve('http://example.com/')); + } + + public function testResolveUriInstance() + { + $this->assertEquals('http://example.com/', $this->browser->resolve(new Uri('http://example.com/'))); + } + + /** + * @expectedException InvalidArgumentException + */ + public function testResolveRelativeWithoutBaseFails() + { + $this->browser->resolve('example'); + } + + public function testWithBase() + { + $browser = $this->browser->withBase('http://example.com/root'); + + $this->assertInstanceOf('Clue\React\Buzz\Browser', $browser); + $this->assertNotSame($this->browser, $browser); + + return $browser; + } + + /** + * @depends testWithBase + * @param Browser $browser + */ + public function testResolveRootWithBase(Browser $browser) + { + $this->assertEquals('http://example.com/root/test', $browser->resolve('/test')); + } + + /** + * @depends testWithBase + * @param Browser $browser + */ + public function testResolveRelativeWithBase(Browser $browser) + { + $this->assertEquals('http://example.com/root/test', $browser->resolve('test')); + } + + /** + * @depends testWithBase + * @param Browser $browser + * @expectedException UnexpectedValueException + */ + public function testResolveWithOtherBaseFails(Browser $browser) + { + $browser->resolve('http://www.example.org/other'); + } + + /** + * @depends testWithBase + * @param Browser $browser + */ + public function testResolveWithSameBaseInstance(Browser $browser) + { + $this->assertEquals('http://example.com/root/test', $browser->resolve(new Uri('http://example.com/root/test'))); + } + + /** + * @depends testWithBase + * @param Browser $browser + * @expectedException UnexpectedValueException + */ + public function testResolveWithOtherBaseInstanceFails(Browser $browser) + { + $browser->resolve(new Uri('http://example.org/other')); + } + + /** + * @depends testWithBase + * @param Browser $browser + */ + public function testResolveEmptyReturnsBase(Browser $browser) + { + $this->assertEquals('http://example.com/root', $browser->resolve('')); + } } diff --git a/tests/Message/UriTest.php b/tests/Message/UriTest.php index 52e25c1..8c65d8f 100644 --- a/tests/Message/UriTest.php +++ b/tests/Message/UriTest.php @@ -34,4 +34,61 @@ public function testInvalidUri() { new Uri('invalid'); } + + public function testUriExpandBaseEndsWithoutSlash() + { + $base = new Uri('http://example.com/base'); + + $this->assertEquals('http://example.com/base', $base->expandBase('')); + $this->assertEquals('http://example.com/base/', $base->expandBase('/')); + $this->assertEquals('http://example.com/base/test', $base->expandBase('test')); + $this->assertEquals('http://example.com/base/test', $base->expandBase('/test')); + + $this->assertEquals('http://example.com/base?key=value', $base->expandBase('?key=value')); + $this->assertEquals('http://example.com/base/?key=value', $base->expandBase('/?key=value')); + + $this->assertEquals('http://example.com/base', $base->expandBase('http://example.com/base')); + $this->assertEquals('http://example.com/base/another', $base->expandBase('http://example.com/base/another')); + + return $base; + } + + public function provideOtherBaseUris() + { + return array( + 'other domain' => array('http://example.org/base'), + 'other scheme' => array('https://example.com/base'), + 'other port' => array('http://example.com:81/base'), + + 'other domain instance' => array(new Uri('http://example.org/base')) + ); + } + + /** + * @param string|Uri $other + * @param Uri $base + * @dataProvider provideOtherBaseUris + * @depends testUriExpandBaseEndsWithoutSlash + * @expectedException UnexpectedValueException + */ + public function testUriExpandBaseWithOtherBase($other, Uri $base) + { + $base->expandBase($other); + } + + public function testUriExpandBaseEndsWithSlash() + { + $base = new Uri('http://example.com/base/'); + + $this->assertEquals('http://example.com/base/', $base->expandBase('')); + $this->assertEquals('http://example.com/base/', $base->expandBase('/')); + $this->assertEquals('http://example.com/base/test', $base->expandBase('test')); + $this->assertEquals('http://example.com/base/test', $base->expandBase('/test')); + + $this->assertEquals('http://example.com/base/?key=value', $base->expandBase('?key=value')); + $this->assertEquals('http://example.com/base/?key=value', $base->expandBase('/?key=value')); + + $this->assertEquals('http://example.com/base/', $base->expandBase('http://example.com/base/')); + $this->assertEquals('http://example.com/base/another', $base->expandBase('http://example.com/base/another')); + } }