3 * @see https://github.com/zendframework/zend-diactoros for the canonical source repository
4 * @copyright Copyright (c) 2015-2017 Zend Technologies USA Inc. (http://www.zend.com)
5 * @license https://github.com/zendframework/zend-diactoros/blob/master/LICENSE.md New BSD License
8 namespace Zend\Diactoros;
10 use InvalidArgumentException;
11 use Psr\Http\Message\UriInterface;
14 * Implementation of Psr\Http\UriInterface.
16 * Provides a value object representing a URI for HTTP requests.
18 * Instances of this class are considered immutable; all methods that
19 * might change state are implemented such that they retain the internal
20 * state of the current instance and return a new instance that contains the
23 class Uri implements UriInterface
26 * Sub-delimiters used in user info, query strings and fragments.
30 const CHAR_SUB_DELIMS = '!\$&\'\(\)\*\+,;=';
33 * Unreserved characters used in user info, paths, query strings, and fragments.
37 const CHAR_UNRESERVED = 'a-zA-Z0-9_\-\.~\pL';
40 * @var int[] Array indexed by valid scheme names to their corresponding ports.
42 protected $allowedSchemes = [
55 private $userInfo = '';
80 private $fragment = '';
83 * generated uri string cache
90 * @throws InvalidArgumentException on non-string $uri argument
92 public function __construct($uri = '')
94 if (! is_string($uri)) {
95 throw new InvalidArgumentException(sprintf(
96 'URI passed to constructor must be a string; received "%s"',
97 (is_object($uri) ? get_class($uri) : gettype($uri))
102 $this->parseUri($uri);
107 * Operations to perform on clone.
109 * Since cloning usually is for purposes of mutation, we reset the
110 * $uriString property so it will be re-calculated.
112 public function __clone()
114 $this->uriString = null;
120 public function __toString()
122 if (null !== $this->uriString) {
123 return $this->uriString;
126 $this->uriString = static::createUriString(
128 $this->getAuthority(),
129 $this->getPath(), // Absolute URIs should use a "/" for an empty path
134 return $this->uriString;
140 public function getScheme()
142 return $this->scheme;
148 public function getAuthority()
150 if (empty($this->host)) {
154 $authority = $this->host;
155 if (! empty($this->userInfo)) {
156 $authority = $this->userInfo . '@' . $authority;
159 if ($this->isNonStandardPort($this->scheme, $this->host, $this->port)) {
160 $authority .= ':' . $this->port;
167 * Retrieve the user-info part of the URI.
169 * This value is percent-encoded, per RFC 3986 Section 3.2.1.
173 public function getUserInfo()
175 return $this->userInfo;
181 public function getHost()
189 public function getPort()
191 return $this->isNonStandardPort($this->scheme, $this->host, $this->port)
199 public function getPath()
207 public function getQuery()
215 public function getFragment()
217 return $this->fragment;
223 public function withScheme($scheme)
225 if (! is_string($scheme)) {
226 throw new InvalidArgumentException(sprintf(
227 '%s expects a string argument; received %s',
229 (is_object($scheme) ? get_class($scheme) : gettype($scheme))
233 $scheme = $this->filterScheme($scheme);
235 if ($scheme === $this->scheme) {
236 // Do nothing if no change was made.
241 $new->scheme = $scheme;
247 * Create and return a new instance containing the provided user credentials.
249 * The value will be percent-encoded in the new instance, but with measures
250 * taken to prevent double-encoding.
254 public function withUserInfo($user, $password = null)
256 if (! is_string($user)) {
257 throw new InvalidArgumentException(sprintf(
258 '%s expects a string user argument; received %s',
260 (is_object($user) ? get_class($user) : gettype($user))
263 if (null !== $password && ! is_string($password)) {
264 throw new InvalidArgumentException(sprintf(
265 '%s expects a string password argument; received %s',
267 (is_object($password) ? get_class($password) : gettype($password))
271 $info = $this->filterUserInfoPart($user);
273 $info .= ':' . $this->filterUserInfoPart($password);
276 if ($info === $this->userInfo) {
277 // Do nothing if no change was made.
282 $new->userInfo = $info;
290 public function withHost($host)
292 if (! is_string($host)) {
293 throw new InvalidArgumentException(sprintf(
294 '%s expects a string argument; received %s',
296 (is_object($host) ? get_class($host) : gettype($host))
300 if ($host === $this->host) {
301 // Do nothing if no change was made.
306 $new->host = strtolower($host);
314 public function withPort($port)
316 if (! is_numeric($port) && $port !== null) {
317 throw new InvalidArgumentException(sprintf(
318 'Invalid port "%s" specified; must be an integer, an integer string, or null',
319 (is_object($port) ? get_class($port) : gettype($port))
323 if ($port !== null) {
327 if ($port === $this->port) {
328 // Do nothing if no change was made.
332 if ($port !== null && ($port < 1 || $port > 65535)) {
333 throw new InvalidArgumentException(sprintf(
334 'Invalid port "%d" specified; must be a valid TCP/UDP port',
348 public function withPath($path)
350 if (! is_string($path)) {
351 throw new InvalidArgumentException(
352 'Invalid path provided; must be a string'
356 if (strpos($path, '?') !== false) {
357 throw new InvalidArgumentException(
358 'Invalid path provided; must not contain a query string'
362 if (strpos($path, '#') !== false) {
363 throw new InvalidArgumentException(
364 'Invalid path provided; must not contain a URI fragment'
368 $path = $this->filterPath($path);
370 if ($path === $this->path) {
371 // Do nothing if no change was made.
384 public function withQuery($query)
386 if (! is_string($query)) {
387 throw new InvalidArgumentException(
388 'Query string must be a string'
392 if (strpos($query, '#') !== false) {
393 throw new InvalidArgumentException(
394 'Query string must not include a URI fragment'
398 $query = $this->filterQuery($query);
400 if ($query === $this->query) {
401 // Do nothing if no change was made.
406 $new->query = $query;
414 public function withFragment($fragment)
416 if (! is_string($fragment)) {
417 throw new InvalidArgumentException(sprintf(
418 '%s expects a string argument; received %s',
420 (is_object($fragment) ? get_class($fragment) : gettype($fragment))
424 $fragment = $this->filterFragment($fragment);
426 if ($fragment === $this->fragment) {
427 // Do nothing if no change was made.
432 $new->fragment = $fragment;
438 * Parse a URI into its parts, and set the properties
442 private function parseUri($uri)
444 $parts = parse_url($uri);
446 if (false === $parts) {
447 throw new \InvalidArgumentException(
448 'The source URI string appears to be malformed'
452 $this->scheme = isset($parts['scheme']) ? $this->filterScheme($parts['scheme']) : '';
453 $this->userInfo = isset($parts['user']) ? $this->filterUserInfoPart($parts['user']) : '';
454 $this->host = isset($parts['host']) ? strtolower($parts['host']) : '';
455 $this->port = isset($parts['port']) ? $parts['port'] : null;
456 $this->path = isset($parts['path']) ? $this->filterPath($parts['path']) : '';
457 $this->query = isset($parts['query']) ? $this->filterQuery($parts['query']) : '';
458 $this->fragment = isset($parts['fragment']) ? $this->filterFragment($parts['fragment']) : '';
460 if (isset($parts['pass'])) {
461 $this->userInfo .= ':' . $parts['pass'];
466 * Create a URI string from its various parts
468 * @param string $scheme
469 * @param string $authority
470 * @param string $path
471 * @param string $query
472 * @param string $fragment
475 private static function createUriString($scheme, $authority, $path, $query, $fragment)
479 if (! empty($scheme)) {
480 $uri .= sprintf('%s:', $scheme);
483 if (! empty($authority)) {
484 $uri .= '//' . $authority;
488 if (empty($path) || '/' !== substr($path, 0, 1)) {
496 $uri .= sprintf('?%s', $query);
500 $uri .= sprintf('#%s', $fragment);
507 * Is a given port non-standard for the current scheme?
509 * @param string $scheme
510 * @param string $host
514 private function isNonStandardPort($scheme, $host, $port)
517 if ($host && ! $port) {
523 if (! $host || ! $port) {
527 return ! isset($this->allowedSchemes[$scheme]) || $port !== $this->allowedSchemes[$scheme];
531 * Filters the scheme to ensure it is a valid scheme.
533 * @param string $scheme Scheme name.
535 * @return string Filtered scheme.
537 private function filterScheme($scheme)
539 $scheme = strtolower($scheme);
540 $scheme = preg_replace('#:(//)?$#', '', $scheme);
542 if (empty($scheme)) {
546 if (! array_key_exists($scheme, $this->allowedSchemes)) {
547 throw new InvalidArgumentException(sprintf(
548 'Unsupported scheme "%s"; must be any empty string or in the set (%s)',
550 implode(', ', array_keys($this->allowedSchemes))
558 * Filters a part of user info in a URI to ensure it is properly encoded.
560 * @param string $part
563 private function filterUserInfoPart($part)
565 // Note the addition of `%` to initial charset; this allows `|` portion
566 // to match and thus prevent double-encoding.
567 return preg_replace_callback(
568 '/(?:[^%' . self::CHAR_UNRESERVED . self::CHAR_SUB_DELIMS . ']+|%(?![A-Fa-f0-9]{2}))/u',
569 [$this, 'urlEncodeChar'],
575 * Filters the path of a URI to ensure it is properly encoded.
577 * @param string $path
580 private function filterPath($path)
582 $path = preg_replace_callback(
583 '/(?:[^' . self::CHAR_UNRESERVED . ')(:@&=\+\$,\/;%]+|%(?![A-Fa-f0-9]{2}))/u',
584 [$this, 'urlEncodeChar'],
593 if ($path[0] !== '/') {
598 // Ensure only one leading slash, to prevent XSS attempts.
599 return '/' . ltrim($path, '/');
603 * Filter a query string to ensure it is propertly encoded.
605 * Ensures that the values in the query string are properly urlencoded.
607 * @param string $query
610 private function filterQuery($query)
612 if (! empty($query) && strpos($query, '?') === 0) {
613 $query = substr($query, 1);
616 $parts = explode('&', $query);
617 foreach ($parts as $index => $part) {
618 list($key, $value) = $this->splitQueryValue($part);
619 if ($value === null) {
620 $parts[$index] = $this->filterQueryOrFragment($key);
623 $parts[$index] = sprintf(
625 $this->filterQueryOrFragment($key),
626 $this->filterQueryOrFragment($value)
630 return implode('&', $parts);
634 * Split a query value into a key/value tuple.
636 * @param string $value
637 * @return array A value with exactly two elements, key and value
639 private function splitQueryValue($value)
641 $data = explode('=', $value, 2);
642 if (1 === count($data)) {
649 * Filter a fragment value to ensure it is properly encoded.
651 * @param string $fragment
654 private function filterFragment($fragment)
656 if (! empty($fragment) && strpos($fragment, '#') === 0) {
657 $fragment = '%23' . substr($fragment, 1);
660 return $this->filterQueryOrFragment($fragment);
664 * Filter a query string key or value, or a fragment.
666 * @param string $value
669 private function filterQueryOrFragment($value)
671 return preg_replace_callback(
672 '/(?:[^' . self::CHAR_UNRESERVED . self::CHAR_SUB_DELIMS . '%:@\/\?]+|%(?![A-Fa-f0-9]{2}))/u',
673 [$this, 'urlEncodeChar'],
679 * URL encode a character returned by a regex.
681 * @param array $matches
684 private function urlEncodeChar(array $matches)
686 return rawurlencode($matches[0]);