Talesoft/tale-uri

View on GitHub
src/Uri.php

Summary

Maintainability
A
35 mins
Test Coverage
<?php declare(strict_types=1);

namespace Tale;

use InvalidArgumentException;
use Psr\Http\Message\UriInterface;
use Tale\Uri\Renderer\SimpleRenderer;
use Tale\Uri\RendererInterface;

/**
 * {@inheritdoc}
 */
final class Uri implements UriInterface
{
    /**
     * The scheme this URI contains
     *
     * e.g. {{https}}://example.com
     *
     * @var string
     */
    private $scheme;

    /**
     * The user this URI is associated with
     *
     * e.g. {{user}}@example.com
     *
     * @var string
     */
    private $user = '';

    /**
     * The password this URI is associated with
     *
     * e.g. user:{{password}}@example.com
     *
     * @var string
     */
    private $password = '';

    /**
     * The host this URI points to
     *
     * e.g. http://user@{{example.com}}/test
     *
     * @var string
     */
    private $host;

    /**
     * The port this URI points to
     *
     * e.g. http://example.com:{{8080}}/test
     *
     * @var int|null
     */
    private $port;

    /**
     * The path this URI points to
     *
     * e.g. http://example.com{{/some/sub/path}}
     *
     * @var string
     */
    private $path = '';

    /**
     * The query string this URI contains
     *
     * e.g. http://example.com/test?{{var1=val1&var2=val2}}
     *
     * @var string
     */
    private $query = '';

    /**
     * The fragment the URI contains
     *
     * e.g. http://example.com/test#{{someFragment}}
     *
     * @var string
     */
    private $fragment = '';

    /** @var RendererInterface */
    private $renderer;

    /**
     * A cache for the fully generated URI string
     *
     * @var string|null
     */
    private $uriString;


    /**
     * @param string $scheme
     * @param string $host
     * @param int|null $port
     * @param RendererInterface|null $renderer
     */
    public function __construct(
        string $scheme = '',
        string $host = '',
        ?int $port = null,
        RendererInterface $renderer = null
    ) {

        $this->scheme = $this->filterScheme($scheme);
        $this->host = $this->filterHost($host);
        $this->port = $this->filterPort($port);
        $this->renderer = $renderer ?? new SimpleRenderer();
    }

    /**
     * {@inheritdoc}
     */
    public function getScheme(): string
    {
        return $this->scheme;
    }

    /**
     * {@inheritdoc}
     *
     * @return $this
     */
    public function withScheme($scheme): self
    {
        $uri = clone $this;
        $uri->scheme = $this->filterScheme($scheme);
        return $uri;
    }

    /**
     * {@inheritdoc}
     */
    public function getAuthority(): string
    {
        if ($this->host === '') {
            return '';
        }

        $authority = '';
        $userInfo = $this->getUserInfo();
        if ($userInfo !== '') {
            $authority .= "{$userInfo}@";
        }

        $authority .= $this->host;
        if ($this->port !== null) {
            $authority .= ":{$this->port}";
        }
        return $authority;
    }

    /**
     * {@inheritdoc}
     */
    public function getUserInfo(): string
    {
        $userInfo = $this->user;
        if ($this->password !== '') {
            $userInfo .= ":{$this->password}";
        }
        return $userInfo;
    }

    /**
     * {@inheritdoc}
     *
     * @return $this
     */
    public function withUserInfo($user, $password = null): self
    {
        $user = $this->filterUser($user);
        $password = $password !== null ? $this->filterPassword($password) : '';

        $uri = clone $this;
        if ($user === '') {
            $uri->user = '';
            $uri->password = '';
            return $uri;
        }
        $uri->user = $user;
        $uri->password = $password;
        return $uri;
    }

    /**
     * {@inheritdoc}
     */
    public function getHost(): string
    {
        return $this->host;
    }

    /**
     * {@inheritdoc}
     */
    public function withHost($host): self
    {
        $uri = clone $this;
        $uri->host = $this->filterHost($host);
        return $uri;
    }

    /**
     * {@inheritdoc}
     */
    public function getPort(): ?int
    {
        return $this->port;
    }

    /**
     * {@inheritdoc}
     *
     * @return $this
     */
    public function withPort($port): self
    {
        $uri = clone $this;
        $uri->port = $this->filterPort($port);
        return $uri;
    }

    /**
     * {@inheritdoc}
     */
    public function getPath(): string
    {
        return $this->path;
    }

    /**
     * {@inheritdoc}
     *
     * @return $this
     */
    public function withPath($path): self
    {
        $uri = clone $this;
        $uri->path = $this->filterPath($path);
        return $uri;
    }

    /**
     * {@inheritdoc}
     */
    public function getQuery(): string
    {
        return $this->query;
    }

    /**
     * {@inheritdoc}
     *
     * @return $this
     */
    public function withQuery($query): self
    {
        $uri = clone $this;
        $uri->query = $this->filterQuery($query);
        return $uri;
    }

    /**
     * {@inheritdoc}
     */
    public function getFragment(): string
    {
        return $this->fragment;
    }

    /**
     * {@inheritdoc}
     *
     * @return $this
     */
    public function withFragment($fragment): self
    {
        $uri = clone $this;
        $uri->fragment = $this->filterFragment($fragment);
        return $uri;
    }

    private function filterScheme($scheme): string
    {
        if (!\is_string($scheme)) {
            throw new \InvalidArgumentException('Uri scheme must be a string');
        }
        return strtolower(rtrim($scheme, ':'));
    }

    public function filterUser($user): string
    {
        if (!\is_string($user)) {
            throw new \InvalidArgumentException('Uri user must be a string');
        }
        return $this->encode($user);
    }

    public function filterPassword($password): string
    {
        if (!\is_string($password)) {
            throw new \InvalidArgumentException('Uri password must be a string');
        }
        return $this->encode($password);
    }

    private function filterHost($host): ?string
    {
        if (!\is_string($host)) {
            throw new \InvalidArgumentException('Uri host must be a string');
        }
        return strtolower($host);
    }

    private function filterPort($port): ?int
    {
        if ($port !== null && !\is_int($port)) {
            throw new InvalidArgumentException('Port needs to be valid integer or null');
        }
        if ($port === null || $port === 0) {
            return null;
        }
        if ($port < 1 || $port > 65535) {
            throw new InvalidArgumentException('Port needs to be a valid TCP/UDP port between 1 and 65535');
        }
        return $port;
    }

    private function filterPath($path): string
    {
        if (!\is_string($path)) {
            throw new \InvalidArgumentException('Path must be a string');
        }

        if (strpos($path, '#') !== false || strpos($path, '?') !== false) {
            throw new InvalidArgumentException('The passed path shouldn\'t contain a query or fragment');
        }

        if ($path === '') {
            return $path;
        }

        return implode('/', array_map([$this, 'encode'], explode('/', $path)));
    }

    private function filterQuery($query): string
    {
        if (!\is_string($query)) {
            throw new \InvalidArgumentException('Query must be a string');
        }

        if ($query === '') {
            return $query;
        }

        if (strpos($query, '?') === 0) {
            $query = substr($query, 1);
        }

        $pairs = explode('&', $query);
        //We don't resolve [ and ] in parameters, this is something the application should do
        foreach ($pairs as $i => $pair) {
            [$key, $value] = array_pad(explode('=', $pair), 2, null);
            if ($value === null) {
                $pairs[$i] = $this->encode($key, true);
                continue;
            }
            $pairs[$i] = $this->encode($key, true).'='.$this->encode($value, true);
        }
        return implode('&', $pairs);
    }

    private function filterFragment($fragment): string
    {
        if (!\is_string($fragment)) {
            throw new \InvalidArgumentException('Fragment must be a string');
        }
        if ($fragment === '') {
            return $fragment;
        }

        if (strpos($fragment, '#') === 0) {
            $fragment = substr($fragment, 1);
        }
        return $this->encode($fragment);
    }

    /**
     * Encodes a value and makes sure it's not double-encoded
     *
     * The following characters DON'T get encoded:
     * a-z, A-Z, 0-9, _, -, ., ~, +, ;, ,, =, $, &, %, :, @, /, ?
     *
     * If the second parameter is passed, the characters
     * !, ', (, ) and * won't be encoded as well
     *
     * @param string $value The value to encode.
     * @param bool|false $withDelimeters Allow extended delimeters.
     *
     * @return string the encoded value
     */
    private function encode(string $value, bool $withDelimeters = false): string
    {
        $delims = $withDelimeters ? '!\'\(\)\*' : '';
        return preg_replace_callback(
            '/(?:[^a-zA-Z0-9_\-\.~\+;,=\$&%:@\/\?'.$delims.']+|%(?![A-Fa-f0-9]{2}))/',
            function ($matches) {
                return rawurlencode($matches[0]);
            },
            $value
        );
    }

    /**
     * {@inheritdoc}
     */
    public function __toString()
    {
        if ($this->uriString === null) {
            $this->uriString = $this->renderer->render($this);
        }
        return (string)$this->uriString;
    }

    /**
     * Makes sure that the cached string-representation
     * of the current URI instance is reset upon cloning.
     */
    public function __clone()
    {
        $this->uriString = null;
    }
}