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
;
13 use function array_key_exists
;
14 use function array_keys
;
17 use function get_class
;
20 use function is_numeric
;
21 use function is_object
;
22 use function is_string
;
24 use function parse_url
;
25 use function preg_replace
;
26 use function preg_replace_callback
;
27 use function rawurlencode
;
30 use function strtolower
;
34 * Implementation of Psr\Http\UriInterface.
36 * Provides a value object representing a URI for HTTP requests.
38 * Instances of this class are considered immutable; all methods that
39 * might change state are implemented such that they retain the internal
40 * state of the current instance and return a new instance that contains the
43 class Uri
implements UriInterface
46 * Sub-delimiters used in user info, query strings and fragments.
50 const CHAR_SUB_DELIMS
= '!\$&\'\(\)\*\+,;=';
53 * Unreserved characters used in user info, paths, query strings, and fragments.
57 const CHAR_UNRESERVED
= 'a-zA-Z0-9_\-\.~\pL';
60 * @var int[] Array indexed by valid scheme names to their corresponding ports.
62 protected $allowedSchemes = [
75 private $userInfo = '';
100 private $fragment = '';
103 * generated uri string cache
110 * @throws InvalidArgumentException on non-string $uri argument
112 public function __construct($uri = '')
114 if (! is_string($uri)) {
115 throw new InvalidArgumentException(sprintf(
116 'URI passed to constructor must be a string; received "%s"',
117 (is_object($uri) ?
get_class($uri) : gettype($uri))
122 $this->parseUri($uri);
127 * Operations to perform on clone.
129 * Since cloning usually is for purposes of mutation, we reset the
130 * $uriString property so it will be re-calculated.
132 public function __clone()
134 $this->uriString
= null;
140 public function __toString()
142 if (null !== $this->uriString
) {
143 return $this->uriString
;
146 $this->uriString
= static::createUriString(
148 $this->getAuthority(),
149 $this->getPath(), // Absolute URIs should use a "/" for an empty path
154 return $this->uriString
;
160 public function getScheme()
162 return $this->scheme
;
168 public function getAuthority()
170 if ('' === $this->host
) {
174 $authority = $this->host
;
175 if ('' !== $this->userInfo
) {
176 $authority = $this->userInfo
. '@' . $authority;
179 if ($this->isNonStandardPort($this->scheme
, $this->host
, $this->port
)) {
180 $authority .= ':' . $this->port
;
187 * Retrieve the user-info part of the URI.
189 * This value is percent-encoded, per RFC 3986 Section 3.2.1.
193 public function getUserInfo()
195 return $this->userInfo
;
201 public function getHost()
209 public function getPort()
211 return $this->isNonStandardPort($this->scheme
, $this->host
, $this->port
)
219 public function getPath()
227 public function getQuery()
235 public function getFragment()
237 return $this->fragment
;
243 public function withScheme($scheme)
245 if (! is_string($scheme)) {
246 throw new InvalidArgumentException(sprintf(
247 '%s expects a string argument; received %s',
249 (is_object($scheme) ?
get_class($scheme) : gettype($scheme))
253 $scheme = $this->filterScheme($scheme);
255 if ($scheme === $this->scheme
) {
256 // Do nothing if no change was made.
261 $new->scheme
= $scheme;
267 * Create and return a new instance containing the provided user credentials.
269 * The value will be percent-encoded in the new instance, but with measures
270 * taken to prevent double-encoding.
274 public function withUserInfo($user, $password = null)
276 if (! is_string($user)) {
277 throw new InvalidArgumentException(sprintf(
278 '%s expects a string user argument; received %s',
280 (is_object($user) ?
get_class($user) : gettype($user))
283 if (null !== $password && ! is_string($password)) {
284 throw new InvalidArgumentException(sprintf(
285 '%s expects a string password argument; received %s',
287 (is_object($password) ?
get_class($password) : gettype($password))
291 $info = $this->filterUserInfoPart($user);
293 $info .= ':' . $this->filterUserInfoPart($password);
296 if ($info === $this->userInfo
) {
297 // Do nothing if no change was made.
302 $new->userInfo
= $info;
310 public function withHost($host)
312 if (! is_string($host)) {
313 throw new InvalidArgumentException(sprintf(
314 '%s expects a string argument; received %s',
316 (is_object($host) ?
get_class($host) : gettype($host))
320 if ($host === $this->host
) {
321 // Do nothing if no change was made.
326 $new->host
= strtolower($host);
334 public function withPort($port)
336 if (! is_numeric($port) && $port !== null) {
337 throw new InvalidArgumentException(sprintf(
338 'Invalid port "%s" specified; must be an integer, an integer string, or null',
339 (is_object($port) ?
get_class($port) : gettype($port))
343 if ($port !== null) {
347 if ($port === $this->port
) {
348 // Do nothing if no change was made.
352 if ($port !== null && ($port < 1 ||
$port > 65535)) {
353 throw new InvalidArgumentException(sprintf(
354 'Invalid port "%d" specified; must be a valid TCP/UDP port',
368 public function withPath($path)
370 if (! is_string($path)) {
371 throw new InvalidArgumentException(
372 'Invalid path provided; must be a string'
376 if (strpos($path, '?') !== false) {
377 throw new InvalidArgumentException(
378 'Invalid path provided; must not contain a query string'
382 if (strpos($path, '#') !== false) {
383 throw new InvalidArgumentException(
384 'Invalid path provided; must not contain a URI fragment'
388 $path = $this->filterPath($path);
390 if ($path === $this->path
) {
391 // Do nothing if no change was made.
404 public function withQuery($query)
406 if (! is_string($query)) {
407 throw new InvalidArgumentException(
408 'Query string must be a string'
412 if (strpos($query, '#') !== false) {
413 throw new InvalidArgumentException(
414 'Query string must not include a URI fragment'
418 $query = $this->filterQuery($query);
420 if ($query === $this->query
) {
421 // Do nothing if no change was made.
426 $new->query
= $query;
434 public function withFragment($fragment)
436 if (! is_string($fragment)) {
437 throw new InvalidArgumentException(sprintf(
438 '%s expects a string argument; received %s',
440 (is_object($fragment) ?
get_class($fragment) : gettype($fragment))
444 $fragment = $this->filterFragment($fragment);
446 if ($fragment === $this->fragment
) {
447 // Do nothing if no change was made.
452 $new->fragment
= $fragment;
458 * Parse a URI into its parts, and set the properties
462 private function parseUri($uri)
464 $parts = parse_url($uri);
466 if (false === $parts) {
467 throw new \
InvalidArgumentException(
468 'The source URI string appears to be malformed'
472 $this->scheme
= isset($parts['scheme']) ?
$this->filterScheme($parts['scheme']) : '';
473 $this->userInfo
= isset($parts['user']) ?
$this->filterUserInfoPart($parts['user']) : '';
474 $this->host
= isset($parts['host']) ?
strtolower($parts['host']) : '';
475 $this->port
= isset($parts['port']) ?
$parts['port'] : null;
476 $this->path
= isset($parts['path']) ?
$this->filterPath($parts['path']) : '';
477 $this->query
= isset($parts['query']) ?
$this->filterQuery($parts['query']) : '';
478 $this->fragment
= isset($parts['fragment']) ?
$this->filterFragment($parts['fragment']) : '';
480 if (isset($parts['pass'])) {
481 $this->userInfo
.= ':' . $parts['pass'];
486 * Create a URI string from its various parts
488 * @param string $scheme
489 * @param string $authority
490 * @param string $path
491 * @param string $query
492 * @param string $fragment
495 private static function createUriString($scheme, $authority, $path, $query, $fragment)
499 if ('' !== $scheme) {
500 $uri .= sprintf('%s:', $scheme);
503 if ('' !== $authority) {
504 $uri .= '//' . $authority;
507 if ('' !== $path && '/' !== substr($path, 0, 1)) {
515 $uri .= sprintf('?%s', $query);
518 if ('' !== $fragment) {
519 $uri .= sprintf('#%s', $fragment);
526 * Is a given port non-standard for the current scheme?
528 * @param string $scheme
529 * @param string $host
533 private function isNonStandardPort($scheme, $host, $port)
535 if ('' === $scheme) {
536 return '' === $host ||
null !== $port;
539 if ('' === $host ||
null === $port) {
543 return ! isset($this->allowedSchemes
[$scheme]) ||
$port !== $this->allowedSchemes
[$scheme];
547 * Filters the scheme to ensure it is a valid scheme.
549 * @param string $scheme Scheme name.
551 * @return string Filtered scheme.
553 private function filterScheme($scheme)
555 $scheme = strtolower($scheme);
556 $scheme = preg_replace('#:(//)?$#', '', $scheme);
558 if ('' === $scheme) {
562 if (! array_key_exists($scheme, $this->allowedSchemes
)) {
563 throw new InvalidArgumentException(sprintf(
564 'Unsupported scheme "%s"; must be any empty string or in the set (%s)',
566 implode(', ', array_keys($this->allowedSchemes
))
574 * Filters a part of user info in a URI to ensure it is properly encoded.
576 * @param string $part
579 private function filterUserInfoPart($part)
581 // Note the addition of `%` to initial charset; this allows `|` portion
582 // to match and thus prevent double-encoding.
583 return preg_replace_callback(
584 '/(?:[^%' . self
::CHAR_UNRESERVED
. self
::CHAR_SUB_DELIMS
. ']+|%(?![A-Fa-f0-9]{2}))/u',
585 [$this, 'urlEncodeChar'],
591 * Filters the path of a URI to ensure it is properly encoded.
593 * @param string $path
596 private function filterPath($path)
598 $path = preg_replace_callback(
599 '/(?:[^' . self
::CHAR_UNRESERVED
. ')(:@&=\+\$,\/;%]+|%(?![A-Fa-f0-9]{2}))/u',
600 [$this, 'urlEncodeChar'],
609 if ($path[0] !== '/') {
614 // Ensure only one leading slash, to prevent XSS attempts.
615 return '/' . ltrim($path, '/');
619 * Filter a query string to ensure it is propertly encoded.
621 * Ensures that the values in the query string are properly urlencoded.
623 * @param string $query
626 private function filterQuery($query)
628 if ('' !== $query && strpos($query, '?') === 0) {
629 $query = substr($query, 1);
632 $parts = explode('&', $query);
633 foreach ($parts as $index => $part) {
634 list($key, $value) = $this->splitQueryValue($part);
635 if ($value === null) {
636 $parts[$index] = $this->filterQueryOrFragment($key);
639 $parts[$index] = sprintf(
641 $this->filterQueryOrFragment($key),
642 $this->filterQueryOrFragment($value)
646 return implode('&', $parts);
650 * Split a query value into a key/value tuple.
652 * @param string $value
653 * @return array A value with exactly two elements, key and value
655 private function splitQueryValue($value)
657 $data = explode('=', $value, 2);
658 if (1 === count($data)) {
665 * Filter a fragment value to ensure it is properly encoded.
667 * @param string $fragment
670 private function filterFragment($fragment)
672 if ('' !== $fragment && strpos($fragment, '#') === 0) {
673 $fragment = '%23' . substr($fragment, 1);
676 return $this->filterQueryOrFragment($fragment);
680 * Filter a query string key or value, or a fragment.
682 * @param string $value
685 private function filterQueryOrFragment($value)
687 return preg_replace_callback(
688 '/(?:[^' . self
::CHAR_UNRESERVED
. self
::CHAR_SUB_DELIMS
. '%:@\/\?]+|%(?![A-Fa-f0-9]{2}))/u',
689 [$this, 'urlEncodeChar'],
695 * URL encode a character returned by a regex.
697 * @param array $matches
700 private function urlEncodeChar(array $matches)
702 return rawurlencode($matches[0]);