themichaelhall/datatypes

View on GitHub
src/Net/Hostname.php

Summary

Maintainability
A
3 hrs
Test Coverage
<?php

/**
 * This file is a part of the datatypes package.
 *
 * https://github.com/themichaelhall/datatypes
 */

declare(strict_types=1);

namespace DataTypes\Net;

use DataTypes\Net\Exceptions\HostnameInvalidArgumentException;
use InvalidArgumentException;

/**
 * Class representing a hostname.
 *
 * @since 1.0.0
 */
class Hostname implements HostnameInterface
{
    /**
     * Returns true if the hostname equals other hostname, false otherwise.
     *
     * @since 1.2.0
     *
     * @param HostnameInterface $hostname The other hostname.
     *
     * @return bool True if the hostname equals other hostname, false otherwise.
     */
    public function equals(HostnameInterface $hostname): bool
    {
        return $this->getDomainParts() === $hostname->getDomainParts() && $this->getTld() === $hostname->getTld();
    }

    /**
     * Returns the domain name including top-level domain.
     *
     * @since 1.0.0
     *
     * @return string The domain name including top-level domain.
     */
    public function getDomainName(): string
    {
        return $this->domainParts[count($this->domainParts) - 1] . ($this->tld !== null ? '.' . $this->tld : '');
    }

    /**
     * Returns the domain parts.
     *
     * @since 1.0.0
     *
     * @return string[] The domain parts.
     */
    public function getDomainParts(): array
    {
        return $this->domainParts;
    }

    /**
     * Returns the top-level domain of the hostname if hostname has a top-level domain, null otherwise.
     *
     * @since 1.0.0
     *
     * @return string|null The top-level domain of the hostname if hostname has a top-level domain, null otherwise.
     */
    public function getTld(): ?string
    {
        return $this->tld;
    }

    /**
     * Returns a copy of the Hostname instance with the specified top-level domain.
     *
     * @since 1.0.0
     *
     * @param string $tld The top-level domain.
     *
     * @throws HostnameInvalidArgumentException If the top-level domain parameter is not a valid top-level domain.
     *
     * @return HostnameInterface The Hostname instance.
     */
    public function withTld(string $tld): HostnameInterface
    {
        if (!self::validateTld($tld, $error)) {
            throw new HostnameInvalidArgumentException($error);
        }

        self::normalizeTld($tld);

        return new self($this->domainParts, $tld);
    }

    /**
     * Returns the hostname as a string.
     *
     * @since 1.0.0
     *
     * @return string The hostname as a string.
     */
    public function __toString(): string
    {
        return implode('.', $this->domainParts) . ($this->tld !== null ? '.' . $this->tld : '');
    }

    /**
     * Creates a hostname from hostname parts.
     *
     * @since 1.0.0
     *
     * @param string[]    $domainParts The domain parts.
     * @param string|null $tld         The top level domain or null if no top-level domain should be included.
     *
     * @throws HostnameInvalidArgumentException If any of the parameters are invalid.
     * @throws InvalidArgumentException         If the $domainParts parameter is not an array of strings.
     *
     * @return HostnameInterface The hostname instance.
     */
    public static function fromParts(array $domainParts, ?string $tld = null): HostnameInterface
    {
        if (count($domainParts) === 0) {
            throw new HostnameInvalidArgumentException('Domain parts [] is empty.');
        }

        if (!self::validateDomainParts($domainParts, $error)) {
            throw new HostnameInvalidArgumentException('Domain parts ["' . implode('", "', $domainParts) . '"] is invalid: ' . $error);
        }

        if (!self::validateTld($tld, $error)) {
            throw new HostnameInvalidArgumentException($error);
        }

        self::normalizeDomainParts($domainParts);
        self::normalizeTld($tld);

        return new self($domainParts, $tld);
    }

    /**
     * Checks if a hostname is valid.
     *
     * @since 1.0.0
     *
     * @param string $string The hostname.
     *
     * @return bool True if the hostname parameter is a valid hostname, false otherwise.
     */
    public static function isValid(string $string): bool
    {
        return self::doParse($string) !== null;
    }

    /**
     * Parses a hostname.
     *
     * @since 1.0.0
     *
     * @param string $string The hostname.
     *
     * @throws HostnameInvalidArgumentException If the hostname parameter is not a valid hostname.
     *
     * @return HostnameInterface The Hostname instance.
     */
    public static function parse(string $string): HostnameInterface
    {
        $result = self::doParse($string, $error);
        if ($result === null) {
            throw new HostnameInvalidArgumentException($error);
        }

        return $result;
    }

    /**
     * Parses a hostname.
     *
     * @since 1.0.0
     *
     * @param string $string The hostname.
     *
     * @return HostnameInterface|null The Hostname instance if the hostname parameter is a valid hostname, null otherwise.
     */
    public static function tryParse(string $string): ?HostnameInterface
    {
        return self::doParse($string);
    }

    /**
     * Constructs a hostname from hostname parts.
     *
     * @param string[]    $domainParts The domain parts.
     * @param string|null $tld         The top-level domain if top-level domain is present, null otherwise.
     */
    private function __construct(array $domainParts, ?string $tld)
    {
        $this->domainParts = $domainParts;
        $this->tld = $tld;
    }

    /**
     * Tries to parse a hostname and returns the result or error text.
     *
     * @param string      $str   The hostname to parse.
     * @param string|null $error The error text if parsing was not successful, undefined otherwise.
     *
     * @return self|null The hostname if parsing was successful, null otherwise.
     */
    private static function doParse(string $str, ?string &$error = null): ?self
    {
        if ($str === '') {
            $error = 'Hostname "' . $str . '" is empty.';

            return null;
        }

        if (strlen($str) > 255) {
            $error = 'Hostname "' . $str . '" is too long: Maximum allowed length is 255 characters."';

            return null;
        }

        $domainParts = explode(
            '.',
            str_ends_with($str, '.') ? substr($str, 0, -1) : $str // Remove trailing "." from hostname.
        );

        $tld = null;
        if (count($domainParts) > 1) {
            $tld = array_pop($domainParts);
        }

        if (!self::validateDomainParts($domainParts, $error)) {
            $error = 'Hostname "' . $str . '" is invalid: ' . $error;

            return null;
        }

        if (!self::validateTld($tld, $error)) {
            $error = 'Hostname "' . $str . '" is invalid: ' . $error;

            return null;
        }

        self::normalizeDomainParts($domainParts);
        self::normalizeTld($tld);

        return new self($domainParts, $tld);
    }

    /**
     * Validates a top-level domain.
     *
     * @param string|null $tld   The top-level domain.
     * @param string|null $error The error text if validation was not successful, undefined otherwise.
     *
     * @return bool True if validation was successful, false otherwise.
     */
    private static function validateTld(?string $tld, ?string &$error): bool
    {
        if ($tld === null) {
            return true;
        }

        if ($tld === '') {
            $error = 'Top-level domain "' . $tld . '" is empty.';

            return false;
        }

        if (strlen($tld) > 63) {
            $error = 'Top-level domain "' . $tld . '" is too long: Maximum allowed length is 63 characters.';

            return false;
        }

        if (preg_match('/[^a-zA-Z]/', $tld, $matches)) {
            $error = 'Top-level domain "' . $tld . '" contains invalid character "' . $matches[0] . '".';

            return false;
        }

        return true;
    }

    /**
     * Validates domain parts.
     *
     * @param string[]    $domainParts The domain parts.
     * @param string|null $error       The error text if validation was not successful, undefined otherwise.
     *
     * @throws InvalidArgumentException If the $domainParts parameter is not an array of strings.
     *
     * @return bool True if validation was successful, false otherwise.
     */
    private static function validateDomainParts(array $domainParts, ?string &$error): bool
    {
        foreach ($domainParts as $part) {
            if (!is_string($part)) {
                throw new InvalidArgumentException('$domainParts parameter is not an array of strings.');
            }

            if (!self::validateDomainPart($part, $error)) {
                return false;
            }
        }

        return true;
    }

    /**
     * Validates a domain part.
     *
     * @param string      $domainPart The domain part.
     * @param string|null $error      The error text if validation was not successful, undefined otherwise.
     *
     * @return bool True if validation was successful, false otherwise.
     */
    private static function validateDomainPart(string $domainPart, ?string &$error): bool
    {
        if ($domainPart === '') {
            $error = 'Part of domain "' . $domainPart . '" is empty.';

            return false;
        }

        if (strlen($domainPart) > 63) {
            $error = 'Part of domain "' . $domainPart . '" is too long: Maximum allowed length is 63 characters.';

            return false;
        }

        if (preg_match('/[^a-zA-Z0-9-]/', $domainPart, $matches)) {
            $error = 'Part of domain "' . $domainPart . '" contains invalid character "' . $matches[0] . '".';

            return false;
        }

        if (str_starts_with($domainPart, '-')) {
            $error = 'Part of domain "' . $domainPart . '" begins with "-".';

            return false;
        }

        if (str_ends_with($domainPart, '-')) {
            $error = 'Part of domain "' . $domainPart . '" ends with "-".';

            return false;
        }

        return true;
    }

    /**
     * Normalizes domain parts.
     *
     * @param string[] $domainParts The domain parts.
     */
    private static function normalizeDomainParts(array &$domainParts): void
    {
        array_walk($domainParts, function (&$part) {
            $part = strtolower($part);
        });
    }

    /**
     * Normalizes a top-level domain.
     *
     * @param string|null $tld The top-level domain.
     */
    private static function normalizeTld(?string &$tld = null): void
    {
        if ($tld === null) {
            return;
        }

        $tld = strtolower($tld);
    }

    /**
     * @var string[] The domain parts.
     */
    private array $domainParts;

    /**
     * @var string|null The top-level domain if this hostname has a top-level domain, null otherwise.
     */
    private ?string $tld;
}