<?php
class URL
{
/*
* In the native implementation, all the properties would be coerced to the correct type when setting them
* You can't do this in userland because of the query element - in order for the array elements to be writable
* without overwriting the whole array, you cannot use accessors :-(
*/
/**
* @var string
*/
public $scheme;
/**
* @var string
*/
public $user;
/**
* @var string
*/
public $pass;
/**
* @var string
*/
public $host;
/**
* @var int
*/
public $port;
/**
* @var string
*/
public $path;
/**
* @var array
*/
public $query = [];
/**
* @var string
*/
public $fragment;
/**
* Wrapper for parse_url()
*
* @param string $string
* @return URL
* @throws InvalidArgumentException
*/
public static function createFromString($string)
{
if (false === $parts = parse_url($string)) {
throw new InvalidArgumentException($string . ' could not be parsed as a valid URL');
}
return new static(
isset($parts['scheme']) ? $parts['scheme'] : null,
isset($parts['user']) ? $parts['user'] : null,
isset($parts['pass']) ? $parts['pass'] : null,
isset($parts['host']) ? $parts['host'] : null,
isset($parts['port']) ? $parts['port'] : null,
isset($parts['path']) ? $parts['path'] : null,
isset($parts['query']) ? $parts['query'] : null,
isset($parts['fragment']) ? $parts['fragment'] : null
);
}
/**
* Resolve $target as a relative URL against $source, using the same rules as a browser, so for example
*
* $source = http://google.com/ $target = /foo result = http://google.com/foo
* $source = http://google.com/foo $target = bar result = http://google.com/bar
* $source = http://google.com/foo $target = http://google.com/baz result = http://google.com/baz
*
* @param string|URL $source
* @param string|URL $target
* @return URL
*/
public static function resolve($source, $target)
{
if (!($source instanceof static)) {
$source = static::createFromString((string) $source);
}
if (!($target instanceof static)) {
$target = static::createFromString((string) $target);
}
// returning the same instance sometimes but not others would be confusing
$result = clone $target;
if (!isset($target->scheme)) { // anything with a scheme is considered absolute
if (isset($target->host)) { // similarly anything with a host is absolute, just add a scheme is we have one
if (isset($source->scheme)) {
$result->scheme = $source->scheme;
}
} else { // host/scheme portion not specified, inherit from source
foreach (['scheme', 'user', 'pass', 'host', 'port'] as $prop) {
if (isset($source->{$prop})) {
$result->{$prop} = $source->{$prop};
}
}
if ($target->path[0] === '/') { // If the target path is absolute, canonicalize it and use it
$resultPath = self::resolveCanonicalPathComponents($target->path);
} else { // Target path is relative
// First we resolve the source path to a canonical and remove the file name component
$sourcePath = self::resolveCanonicalPathComponents($source->path);
array_pop($sourcePath);
// Now resolve the target path against the source
$resultPath = self::resolveCanonicalPathComponents($target->path, $sourcePath);
}
$result->path = '/' . implode('/', $resultPath);
// The query and fragment elements are not inheritable so we don't touch them
}
}
return $result;
}
/**
* Normalise a path, resolving empty, . and .. components, optionally against another path
*
* @param $path
* @param array $target
* @return array
*/
private static function resolveCanonicalPathComponents($path, array $target = [])
{
// strip empty components and resolve . and ..
foreach (preg_split('#[\\\\/]+#', $path, -1, PREG_SPLIT_NO_EMPTY) as $component) {
switch ($component) {
case '.': // current directory - do nothing
break;
case '..': // up a level
array_pop($target);
break;
default:
array_push($target, $component);
break;
}
}
// add a trailing empty element if path refers to a directory
$lastChar = $path[strlen($path) - 1];
if ($lastChar === '/' || $lastChar === '\\') {
array_push($target, '');
}
return $target;
}
/**
* Constructor takes components as individual arguments
*
* @param string $scheme
* @param string $user
* @param string $pass
* @param string $host
* @param int $port
* @param string $path
* @param string|array $query
* @param string $fragment
*/
public function __construct($scheme = null, $user = null, $pass = null, $host = null, $port = null, $path = null, $query = null, $fragment = null)
{
foreach (['scheme', 'user', 'pass', 'host', 'path', 'fragment'] as $stringProp) {
if (${$stringProp} !== null) {
$this->{$stringProp} = (string) ${$stringProp};
}
}
if ($port !== null) {
$this->port = (int) $port;
}
if ($query !== null) {
if (is_scalar($query)) {
parse_str((string) $query, $queryParsed);
} else {
$queryParsed = (array) $query;
}
$this->query = $queryParsed;
}
}
/**
* Forms all non-null components into a URL
*
* @return string
*/
public function __toString()
{
$result = '';
if (isset($this->scheme)) {
$result = $this->scheme . ':';
}
if (isset($this->host)) {
$result .= '//';
if (isset($this->user)) {
$result .= $this->user;
if (isset($this->pass)) {
$result .= ':' . $this->pass;
}
$result .= '@';
}
$result .= $this->host;
if (isset($this->port)) {
$result .= ':' . $this->port;
}
}
if (isset($this->path)) {
$result .= $this->path;
}
if (isset($this->query) && $this->query !== []) {
$result .= '?' . http_build_query($this->query);
}
if (isset($this->fragment)) {
$result .= '#' . $this->fragment;
}
return $result;
}
}
$url = URL::createFromString('http://google.com/');
$url->query['foo'] = [1, 2, 3];
echo $url . "\n";
echo URL::resolve('http://google.com/', '/foo') . "\n";
echo URL::resolve('http://google.com/foo', 'bar') . "\n";
echo URL::resolve('http://google.com/foo', 'http://google.com/baz') . "\n";