Skip to content

Instantly share code, notes, and snippets.

@DaveRandom
Last active August 29, 2015 13:56
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save DaveRandom/8933019 to your computer and use it in GitHub Desktop.
Save DaveRandom/8933019 to your computer and use it in GitHub Desktop.
<?php
/**
* Class URL
*
* @property string $scheme
* @property string $user
* @property string $pass
* @property string $host
* @property string $port
* @property string $path
* @property string $fragment
*/
class URL
{
/**
* @var string
*/
private $scheme;
/**
* @var string
*/
private $user;
/**
* @var string
*/
private $pass;
/**
* @var string
*/
private $host;
/**
* @var int
*/
private $port;
/**
* @var string
*/
private $path;
/**
* @var array|string
*
* NOTE: do not attempt to validate this on access because it's too difficult to do sensibly
*/
public $query = [];
/**
* @var string
*/
private $fragment;
/**
* @var string[]
*/
private static $stringProperties = ['scheme', 'user', 'pass', 'host', 'path', '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,
true
);
}
/**
* Resolve $target as a relative URL against this, using the same rules as a browser
*
* @param string|URL $target
* @return URL
*/
public function resolve($target)
{
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($this->scheme)) {
$result->scheme = $this->scheme;
}
} else { // host/scheme portion not specified, inherit from source
foreach (['scheme', 'user', 'pass', 'host', 'port'] as $prop) {
if (isset($this->{$prop})) {
$result->{$prop} = $this->{$prop};
}
}
if ($target->path === '') {
$target->path = '/';
}
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($this->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 string $path
* @param string[] $target
* @return string[]
*/
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;
}
/**
* Replacement for parse_str() because it sucks
*
* @param string $str
* @return array
*/
private function parseQueryString($str)
{
$result = [];
foreach (explode('&', $str) as $element) {
$parts = explode('=', $element, 2);
$key = urldecode(array_shift($parts));
$value = $parts ? urldecode(array_shift($parts)) : '';
if (false === $pos = strpos($key, '[')) {
$result[$key] = $value;
} else {
$base = substr($key, 0, $pos++);
if (!isset($result[$base]) || !is_array($result[$base])) {
$result[$base] = [];
}
$target = &$result[$base];
$length = strlen($key);
do {
$end = strpos($key, ']', $pos);
$index = substr($key, $pos, $end - $pos);
$pos = $end + 1;
if (!isset($key[$pos])) {
$target[$index] = $value;
break;
}
if ($key[$pos] !== '[') {
$target[$index] = [substr($key, $pos) => $value];
break;
}
if (!isset($target[$index]) || !is_array($target[$index])) {
$target[$index] = [];
}
$target = &$target[$index];
} while(++$pos < $length);
}
}
return $result;
}
/**
* Build a query string from a set of data
*
* Assume that any scalar data is a query string literal, cast vectors to array and build as form data
*
* @param mixed $data
* @return string
*/
private function buildQueryString($data)
{
return is_scalar($data) ? (string) $data : http_build_query((array) $data);
}
/**
* Normalize slashes in a path and URL-encode without encoding slashes
*
* @param string $path
* @return string
*/
private function urlEncodePath($path)
{
return implode('/', array_map('urlencode', preg_split('#[\\\\/]+#', $path)));
}
/**
* Set the port number
*
* @param int $port
* @throws \LogicException
*/
private function setPort($port)
{
if ($port !== null) {
$port = (string) $port;
if (!ctype_digit($port)) {
// IMPORTANT NOTE: the range of the port is *not* limited to the bounds of any specific integer type
// RFC 3896 sec 3.2.3 simply specifies *DIGIT
// DO NOT VALIDATE THIS VALUE ANY MORE THAN THIS!
throw new \LogicException('Port can only contain digits');
}
}
$this->port = $port;
}
/**
* Constructor takes URL-encoded 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 (self::$stringProperties as $property) {
if (${$property} !== null) {
$this->{$property} = urldecode((string) ${$property});
}
}
// This is a special case. We do not attempt to validate any other component as they can contain more
// or less anything due to multilingual transformations, but this *must* be all digits at all times
$this->setPort($port);
if ($query !== null) {
$this->query = is_scalar($query) ? $this->parseQueryString($query) : (array) $query;
}
}
/**
* Magic getter
*
* @param string $name
* @return mixed
*/
public function __get($name)
{
if (isset($this->{$name})) {
return $this->{$name};
}
trigger_error('Attempt to access unknown property URL::$' . $name, E_USER_NOTICE);
return null;
}
/**
* Magic setter
*
* @param string $name
* @param mixed $value
*/
public function __set($name, $value)
{
if (in_array($name, self::$stringProperties)) {
$this->{$name} = $value !== null ? (string) $value : null;
} else if ($name === 'port') {
$this->setPort($value);
} else {
trigger_error('Attempt to access unknown property URL::' . $name, E_USER_NOTICE);
}
}
/**
* Forms all non-null components into a URL
*
* @return string
*/
public function __toString()
{
$result = '';
if (isset($this->host)) {
if (isset($this->scheme)) {
$result = $this->scheme . ':';
}
$result .= '//';
if (isset($this->user)) {
$result .= urlencode($this->user);
if (isset($this->pass)) {
$result .= ':' . urlencode($this->pass);
}
$result .= '@';
}
$result .= $this->host;
if (isset($this->port)) {
$result .= ':' . $this->port;
}
if (isset($this->path) && !in_array($this->path[0], ['\\', '/'])) {
$result .= '/';
}
}
if (isset($this->path)) {
$result .= $this->urlEncodePath($this->path);
}
if (isset($this->query) && $this->query !== [] && $this->query !== '') {
$result .= '?' . $this->buildQueryString($this->query);
}
if (isset($this->fragment)) {
$result .= '#' . urlencode($this->fragment);
}
return $result;
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment