Skip to content

Commit

Permalink
Merge pull request #83 from Tobion/uri-delimiters
Browse files Browse the repository at this point in the history
remove magic trimming of URI component delimiters
  • Loading branch information
mtdowling committed May 18, 2016
2 parents 6fc3cf4 + 7f0fbde commit 99606eb
Show file tree
Hide file tree
Showing 2 changed files with 148 additions and 52 deletions.
83 changes: 49 additions & 34 deletions src/Uri.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@
use Psr\Http\Message\UriInterface;

/**
* Basic PSR-7 URI implementation.
* PSR-7 URI implementation.
*
* @link https://github.com/phly/http This class is based upon
* Matthew Weier O'Phinney's URI implementation in phly/http.
* @author Michael Dowling
* @author Tobias Schultze
* @author Matthew Weier O'Phinney
*/
class Uri implements UriInterface
{
Expand Down Expand Up @@ -86,7 +87,7 @@ public static function removeDotSegments($path)
$results = [];
$segments = explode('/', $path);
foreach ($segments as $segment) {
if ($segment == '..') {
if ($segment === '..') {
array_pop($results);
} elseif (!isset($ignoreSegments[$segment])) {
$results[] = $segment;
Expand All @@ -102,7 +103,7 @@ public static function removeDotSegments($path)
}

// Add the trailing slash if necessary
if ($newPath != '/' && isset($ignoreSegments[end($segments)])) {
if ($newPath !== '/' && isset($ignoreSegments[end($segments)])) {
$newPath .= '/';
}

Expand All @@ -112,8 +113,8 @@ public static function removeDotSegments($path)
/**
* Resolve a base URI with a relative URI and return a new URI.
*
* @param UriInterface $base Base URI
* @param string $rel Relative URI
* @param UriInterface $base Base URI
* @param string|UriInterface $rel Relative URI
*
* @return UriInterface
*/
Expand Down Expand Up @@ -351,6 +352,8 @@ public function withUserInfo($user, $password = null)

public function withHost($host)
{
$host = $this->filterHost($host);

if ($this->host === $host) {
return $this;
}
Expand All @@ -375,12 +378,6 @@ public function withPort($port)

public function withPath($path)
{
if (!is_string($path)) {
throw new \InvalidArgumentException(
'Invalid path provided; must be a string'
);
}

$path = $this->filterPath($path);

if ($this->path === $path) {
Expand All @@ -394,17 +391,6 @@ public function withPath($path)

public function withQuery($query)
{
if (!is_string($query) && !method_exists($query, '__toString')) {
throw new \InvalidArgumentException(
'Query string must be a string'
);
}

$query = (string) $query;
if (substr($query, 0, 1) === '?') {
$query = substr($query, 1);
}

$query = $this->filterQueryAndFragment($query);

if ($this->query === $query) {
Expand All @@ -418,10 +404,6 @@ public function withQuery($query)

public function withFragment($fragment)
{
if (substr($fragment, 0, 1) === '#') {
$fragment = substr($fragment, 1);
}

$fragment = $this->filterQueryAndFragment($fragment);

if ($this->fragment === $fragment) {
Expand All @@ -444,7 +426,9 @@ private function applyParts(array $parts)
? $this->filterScheme($parts['scheme'])
: '';
$this->userInfo = isset($parts['user']) ? $parts['user'] : '';
$this->host = isset($parts['host']) ? $parts['host'] : '';
$this->host = isset($parts['host'])
? $this->filterHost($parts['host'])
: '';
$this->port = isset($parts['port'])
? $this->filterPort($parts['port'])
: null;
Expand Down Expand Up @@ -529,13 +513,32 @@ private static function isNonStandardPort($scheme, $port)
* @param string $scheme
*
* @return string
*
* @throws \InvalidArgumentException If the scheme is invalid.
*/
private function filterScheme($scheme)
{
$scheme = strtolower($scheme);
$scheme = rtrim($scheme, ':/');
if (!is_string($scheme)) {
throw new \InvalidArgumentException('Scheme must be a string');
}

return strtolower($scheme);
}

/**
* @param string $host
*
* @return string
*
* @throws \InvalidArgumentException If the host is invalid.
*/
private function filterHost($host)
{
if (!is_string($host)) {
throw new \InvalidArgumentException('Host must be a string');
}

return $scheme;
return strtolower($host);
}

/**
Expand Down Expand Up @@ -567,11 +570,17 @@ private function filterPort($port)
* @param string $path
*
* @return string
*
* @throws \InvalidArgumentException If the path is invalid.
*/
private function filterPath($path)
{
if (!is_string($path)) {
throw new \InvalidArgumentException('Path must be a string');
}

return preg_replace_callback(
'/(?:[^' . self::$charUnreserved . self::$charSubDelims . ':@\/%]+|%(?![A-Fa-f0-9]{2}))/',
'/(?:[^' . self::$charUnreserved . self::$charSubDelims . '%:@\/]++|%(?![A-Fa-f0-9]{2}))/',
[$this, 'rawurlencodeMatchZero'],
$path
);
Expand All @@ -583,11 +592,17 @@ private function filterPath($path)
* @param string $str
*
* @return string
*
* @throws \InvalidArgumentException If the query or fragment is invalid.
*/
private function filterQueryAndFragment($str)
{
if (!is_string($str)) {
throw new \InvalidArgumentException('Query and fragment must be a string');
}

return preg_replace_callback(
'/(?:[^' . self::$charUnreserved . self::$charSubDelims . '%:@\/\?]+|%(?![A-Fa-f0-9]{2}))/',
'/(?:[^' . self::$charUnreserved . self::$charSubDelims . '%:@\/\?]++|%(?![A-Fa-f0-9]{2}))/',
[$this, 'rawurlencodeMatchZero'],
$str
);
Expand Down
117 changes: 99 additions & 18 deletions tests/UriTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -141,17 +141,41 @@ public function testParseUriPortCannotBeZero()
/**
* @expectedException \InvalidArgumentException
*/
public function testPathMustBeValid()
public function testSchemeMustHaveCorrectType()
{
(new Uri())->withScheme([]);
}

/**
* @expectedException \InvalidArgumentException
*/
public function testHostMustHaveCorrectType()
{
(new Uri())->withHost([]);
}

/**
* @expectedException \InvalidArgumentException
*/
public function testPathMustHaveCorrectType()
{
(new Uri())->withPath([]);
}

/**
* @expectedException \InvalidArgumentException
*/
public function testQueryMustBeValid()
public function testQueryMustHaveCorrectType()
{
(new Uri())->withQuery(new \stdClass);
(new Uri())->withQuery([]);
}

/**
* @expectedException \InvalidArgumentException
*/
public function testFragmentMustHaveCorrectType()
{
(new Uri())->withFragment([]);
}

public function testCanParseFalseyUriParts()
Expand Down Expand Up @@ -266,6 +290,32 @@ public function testAddAndRemoveQueryValues()
$this->assertSame('', $uri->getQuery());
}

public function testSchemeIsNormalizedToLowercase()
{
$uri = new Uri('HTTP://example.com');

$this->assertSame('http', $uri->getScheme());
$this->assertSame('http://example.com', (string) $uri);

$uri = (new Uri('//example.com'))->withScheme('HTTP');

$this->assertSame('http', $uri->getScheme());
$this->assertSame('http://example.com', (string) $uri);
}

public function testHostIsNormalizedToLowercase()
{
$uri = new Uri('//eXaMpLe.CoM');

$this->assertSame('example.com', $uri->getHost());
$this->assertSame('//example.com', (string) $uri);

$uri = (new Uri())->withHost('eXaMpLe.CoM');

$this->assertSame('example.com', $uri->getHost());
$this->assertSame('//example.com', (string) $uri);
}

public function testPortIsNullIfStandardPortForScheme()
{
// HTTPS standard port
Expand Down Expand Up @@ -329,35 +379,66 @@ public function testAuthorityWithUserInfoButWithoutHost()
$this->assertSame('', $uri->getAuthority());
}

public function pathEncodingProvider()
public function uriComponentsEncodingProvider()
{
$unreserved = 'a-zA-Z0-9.-_~!$&\'()*+,;=:@';

return [
// Percent encode spaces.
['/baz bar', '/baz%20bar'],
// Don't encoding something that's already encoded.
['/baz%20bar', '/baz%20bar'],
// Percent encode spaces
['/pa th?q=va lue#frag ment', '/pa%20th', 'q=va%20lue', 'frag%20ment', '/pa%20th?q=va%20lue#frag%20ment'],
// Percent encode multibyte
['/€?€#€', '/%E2%82%AC', '%E2%82%AC', '%E2%82%AC', '/%E2%82%AC?%E2%82%AC#%E2%82%AC'],
// Don't encode something that's already encoded
['/pa%20th?q=va%20lue#frag%20ment', '/pa%20th', 'q=va%20lue', 'frag%20ment', '/pa%20th?q=va%20lue#frag%20ment'],
// Percent encode invalid percent encodings
['/baz%2-bar', '/baz%252-bar'],
['/pa%2-th?q=va%2-lue#frag%2-ment', '/pa%252-th', 'q=va%252-lue', 'frag%252-ment', '/pa%252-th?q=va%252-lue#frag%252-ment'],
// Don't encode path segments
['/baz/bar/bam', '/baz/bar/bam'],
['/baz+bar', '/baz+bar'],
['/baz:bar', '/baz:bar'],
['/baz@bar', '/baz@bar'],
['/baz(bar);bam/', '/baz(bar);bam/'],
['/a-zA-Z0-9.-_~!$&\'()*+,;=:@', '/a-zA-Z0-9.-_~!$&\'()*+,;=:@'],
['/pa/th//two?q=va/lue#frag/ment', '/pa/th//two', 'q=va/lue', 'frag/ment', '/pa/th//two?q=va/lue#frag/ment'],
// Don't encode unreserved chars or sub-delimiters
["/$unreserved?$unreserved#$unreserved", "/$unreserved", $unreserved, $unreserved, "/$unreserved?$unreserved#$unreserved"],
// Encoded unreserved chars are not decoded
['/p%61th?q=v%61lue#fr%61gment', '/p%61th', 'q=v%61lue', 'fr%61gment', '/p%61th?q=v%61lue#fr%61gment'],
];
}

/**
* @dataProvider pathEncodingProvider
* @dataProvider uriComponentsEncodingProvider
*/
public function testUriEncodesPathProperly($input, $output)
public function testUriComponentsGetEncodedProperly($input, $path, $query, $fragment, $output)
{
$uri = new Uri($input);
$this->assertSame($output, $uri->getPath());
$this->assertSame($path, $uri->getPath());
$this->assertSame($query, $uri->getQuery());
$this->assertSame($fragment, $uri->getFragment());
$this->assertSame($output, (string) $uri);
}

public function testWithPathEncodesProperly()
{
$uri = (new Uri())->withPath('/baz?#€/b%61r');
// Query and fragment delimiters and multibyte chars are encoded.
$this->assertSame('/baz%3F%23%E2%82%AC/b%61r', $uri->getPath());
$this->assertSame('/baz%3F%23%E2%82%AC/b%61r', (string) $uri);
}

public function testWithQueryEncodesProperly()
{
$uri = (new Uri())->withQuery('?=#&€=/&b%61r');
// A query starting with a "?" is valid and must not be magically removed. Otherwise it would be impossible to
// construct such an URI. Also the "?" and "/" does not need to be encoded in the query.
$this->assertSame('?=%23&%E2%82%AC=/&b%61r', $uri->getQuery());
$this->assertSame('??=%23&%E2%82%AC=/&b%61r', (string) $uri);
}

public function testWithFragmentEncodesProperly()
{
$uri = (new Uri())->withFragment('#€?/b%61r');
// A fragment starting with a "#" is valid and must not be magically removed. Otherwise it would be impossible to
// construct such an URI. Also the "?" and "/" does not need to be encoded in the fragment.
$this->assertSame('%23%E2%82%AC?/b%61r', $uri->getFragment());
$this->assertSame('#%23%E2%82%AC?/b%61r', (string) $uri);
}

public function testAllowsForRelativeUri()
{
$uri = (new Uri)->withPath('foo');
Expand Down

0 comments on commit 99606eb

Please sign in to comment.