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 = '')
118 if (! is_string($uri)) {
119 throw new InvalidArgumentException(sprintf(
120 'URI passed to constructor must be a string; received "%s"',
121 is_object($uri) ? get_class($uri) : gettype($uri)
125 $this->parseUri($uri);
129 * Operations to perform on clone.
131 * Since cloning usually is for purposes of mutation, we reset the
132 * $uriString property so it will be re-calculated.
134 public function __clone()
136 $this->uriString = null;
142 public function __toString()
144 if (null !== $this->uriString) {
145 return $this->uriString;
148 $this->uriString = static::createUriString(
150 $this->getAuthority(),
151 $this->getPath(), // Absolute URIs should use a "/" for an empty path
156 return $this->uriString;
162 public function getScheme()
164 return $this->scheme;
170 public function getAuthority()
172 if ('' === $this->host) {
176 $authority = $this->host;
177 if ('' !== $this->userInfo) {
178 $authority = $this->userInfo . '@' . $authority;
181 if ($this->isNonStandardPort($this->scheme, $this->host, $this->port)) {
182 $authority .= ':' . $this->port;
189 * Retrieve the user-info part of the URI.
191 * This value is percent-encoded, per RFC 3986 Section 3.2.1.
195 public function getUserInfo()
197 return $this->userInfo;
203 public function getHost()
211 public function getPort()
213 return $this->isNonStandardPort($this->scheme, $this->host, $this->port)
221 public function getPath()
229 public function getQuery()
237 public function getFragment()
239 return $this->fragment;
245 public function withScheme($scheme)
247 if (! is_string($scheme)) {
248 throw new InvalidArgumentException(sprintf(
249 '%s expects a string argument; received %s',
251 is_object($scheme) ? get_class($scheme) : gettype($scheme)
255 $scheme = $this->filterScheme($scheme);
257 if ($scheme === $this->scheme) {
258 // Do nothing if no change was made.
263 $new->scheme = $scheme;
269 * Create and return a new instance containing the provided user credentials.
271 * The value will be percent-encoded in the new instance, but with measures
272 * taken to prevent double-encoding.
276 public function withUserInfo($user, $password = null)
278 if (! is_string($user)) {
279 throw new InvalidArgumentException(sprintf(
280 '%s expects a string user argument; received %s',
282 is_object($user) ? get_class($user) : gettype($user)
285 if (null !== $password && ! is_string($password)) {
286 throw new InvalidArgumentException(sprintf(
287 '%s expects a string or null password argument; received %s',
289 is_object($password) ? get_class($password) : gettype($password)
293 $info = $this->filterUserInfoPart($user);
294 if (null !== $password) {
295 $info .= ':' . $this->filterUserInfoPart($password);
298 if ($info === $this->userInfo) {
299 // Do nothing if no change was made.
304 $new->userInfo = $info;
312 public function withHost($host)
314 if (! is_string($host)) {
315 throw new InvalidArgumentException(sprintf(
316 '%s expects a string argument; received %s',
318 is_object($host) ? get_class($host) : gettype($host)
322 if ($host === $this->host) {
323 // Do nothing if no change was made.
328 $new->host = strtolower($host);
336 public function withPort($port)
338 if ($port !== null) {
339 if (! is_numeric($port) || is_float($port)) {
340 throw new InvalidArgumentException(sprintf(
341 'Invalid port "%s" specified; must be an integer, an integer string, or null',
342 is_object($port) ? get_class($port) : gettype($port)
349 if ($port === $this->port) {
350 // Do nothing if no change was made.
354 if ($port !== null && ($port < 1 || $port > 65535)) {
355 throw new InvalidArgumentException(sprintf(
356 'Invalid port "%d" specified; must be a valid TCP/UDP port',
370 public function withPath($path)
372 if (! is_string($path)) {
373 throw new InvalidArgumentException(
374 'Invalid path provided; must be a string'
378 if (strpos($path, '?') !== false) {
379 throw new InvalidArgumentException(
380 'Invalid path provided; must not contain a query string'
384 if (strpos($path, '#') !== false) {
385 throw new InvalidArgumentException(
386 'Invalid path provided; must not contain a URI fragment'
390 $path = $this->filterPath($path);
392 if ($path === $this->path) {
393 // Do nothing if no change was made.
406 public function withQuery($query)
408 if (! is_string($query)) {
409 throw new InvalidArgumentException(
410 'Query string must be a string'
414 if (strpos($query, '#') !== false) {
415 throw new InvalidArgumentException(
416 'Query string must not include a URI fragment'
420 $query = $this->filterQuery($query);
422 if ($query === $this->query) {
423 // Do nothing if no change was made.
428 $new->query = $query;
436 public function withFragment($fragment)
438 if (! is_string($fragment)) {
439 throw new InvalidArgumentException(sprintf(
440 '%s expects a string argument; received %s',
442 is_object($fragment) ? get_class($fragment) : gettype($fragment)
446 $fragment = $this->filterFragment($fragment);
448 if ($fragment === $this->fragment) {
449 // Do nothing if no change was made.
454 $new->fragment = $fragment;
460 * Parse a URI into its parts, and set the properties
464 private function parseUri($uri)
466 $parts = parse_url($uri);
468 if (false === $parts) {
469 throw new \InvalidArgumentException(
470 'The source URI string appears to be malformed'
474 $this->scheme = isset($parts['scheme']) ? $this->filterScheme($parts['scheme']) : '';
475 $this->userInfo = isset($parts['user']) ? $this->filterUserInfoPart($parts['user']) : '';
476 $this->host = isset($parts['host']) ? strtolower($parts['host']) : '';
477 $this->port = isset($parts['port']) ? $parts['port'] : null;
478 $this->path = isset($parts['path']) ? $this->filterPath($parts['path']) : '';
479 $this->query = isset($parts['query']) ? $this->filterQuery($parts['query']) : '';
480 $this->fragment = isset($parts['fragment']) ? $this->filterFragment($parts['fragment']) : '';
482 if (isset($parts['pass'])) {
483 $this->userInfo .= ':' . $parts['pass'];
488 * Create a URI string from its various parts
490 * @param string $scheme
491 * @param string $authority
492 * @param string $path
493 * @param string $query
494 * @param string $fragment
497 private static function createUriString($scheme, $authority, $path, $query, $fragment)
501 if ('' !== $scheme) {
502 $uri .= sprintf('%s:', $scheme);
505 if ('' !== $authority) {
506 $uri .= '//' . $authority;
509 if ('' !== $path && '/' !== substr($path, 0, 1)) {
517 $uri .= sprintf('?%s', $query);
520 if ('' !== $fragment) {
521 $uri .= sprintf('#%s', $fragment);
528 * Is a given port non-standard for the current scheme?
530 * @param string $scheme
531 * @param string $host
535 private function isNonStandardPort($scheme, $host, $port)
537 if ('' === $scheme) {
538 return '' === $host || null !== $port;
541 if ('' === $host || null === $port) {
545 return ! isset($this->allowedSchemes[$scheme]) || $port !== $this->allowedSchemes[$scheme];
549 * Filters the scheme to ensure it is a valid scheme.
551 * @param string $scheme Scheme name.
553 * @return string Filtered scheme.
555 private function filterScheme($scheme)
557 $scheme = strtolower($scheme);
558 $scheme = preg_replace('#:(//)?$#', '', $scheme);
560 if ('' === $scheme) {
564 if (! isset($this->allowedSchemes[$scheme])) {
565 throw new InvalidArgumentException(sprintf(
566 'Unsupported scheme "%s"; must be any empty string or in the set (%s)',
568 implode(', ', array_keys($this->allowedSchemes))
576 * Filters a part of user info in a URI to ensure it is properly encoded.
578 * @param string $part
581 private function filterUserInfoPart($part)
583 // Note the addition of `%` to initial charset; this allows `|` portion
584 // to match and thus prevent double-encoding.
585 return preg_replace_callback(
586 '/(?:[^%' . self::CHAR_UNRESERVED . self::CHAR_SUB_DELIMS . ']+|%(?![A-Fa-f0-9]{2}))/u',
587 [$this, 'urlEncodeChar'],
593 * Filters the path of a URI to ensure it is properly encoded.
595 * @param string $path
598 private function filterPath($path)
600 $path = preg_replace_callback(
601 '/(?:[^' . self::CHAR_UNRESERVED . ')(:@&=\+\$,\/;%]+|%(?![A-Fa-f0-9]{2}))/u',
602 [$this, 'urlEncodeChar'],
611 if ($path[0] !== '/') {
616 // Ensure only one leading slash, to prevent XSS attempts.
617 return '/' . ltrim($path, '/');
621 * Filter a query string to ensure it is propertly encoded.
623 * Ensures that the values in the query string are properly urlencoded.
625 * @param string $query
628 private function filterQuery($query)
630 if ('' !== $query && strpos($query, '?') === 0) {
631 $query = substr($query, 1);
634 $parts = explode('&', $query);
635 foreach ($parts as $index => $part) {
636 list($key, $value) = $this->splitQueryValue($part);
637 if ($value === null) {
638 $parts[$index] = $this->filterQueryOrFragment($key);
641 $parts[$index] = sprintf(
643 $this->filterQueryOrFragment($key),
644 $this->filterQueryOrFragment($value)
648 return implode('&', $parts);
652 * Split a query value into a key/value tuple.
654 * @param string $value
655 * @return array A value with exactly two elements, key and value
657 private function splitQueryValue($value)
659 $data = explode('=', $value, 2);
660 if (! isset($data[1])) {
667 * Filter a fragment value to ensure it is properly encoded.
669 * @param string $fragment
672 private function filterFragment($fragment)
674 if ('' !== $fragment && strpos($fragment, '#') === 0) {
675 $fragment = '%23' . substr($fragment, 1);
678 return $this->filterQueryOrFragment($fragment);
682 * Filter a query string key or value, or a fragment.
684 * @param string $value
687 private function filterQueryOrFragment($value)
689 return preg_replace_callback(
690 '/(?:[^' . self::CHAR_UNRESERVED . self::CHAR_SUB_DELIMS . '%:@\/\?]+|%(?![A-Fa-f0-9]{2}))/u',
691 [$this, 'urlEncodeChar'],
697 * URL encode a character returned by a regex.
699 * @param array $matches
702 private function urlEncodeChar(array $matches)
704 return rawurlencode($matches[0]);