Lines 100.00% 25 / 25
Functions and Methods 100.00% 7 / 7
Classes and Traits 100.00% 1 / 1
Name Lines Functions and Methods CRAP Classes and Traits
HomeAssistantUrl 100.00% 25 / 25 100.00% 7 / 7 16 100.00% 1 / 1
 __construct 100.00% 1 / 1 100.00% 1 / 1 1
 parse 100.00% 4 / 4 100.00% 1 / 1 1
 tryParse 100.00% 12 / 12 100.00% 1 / 1 5
 reveal 100.00% 1 / 1 100.00% 1 / 1 1
 __toString 100.00% 1 / 1 100.00% 1 / 1 1
 prefixScheme 100.00% 1 / 1 100.00% 1 / 1 2
 appendWebsocketPath 100.00% 5 / 5 100.00% 1 / 1 5
1<?php
2
3declare(strict_types=1);
4
5namespace Stewart\Client\Connection;
6
7use SensitiveParameter;
8use Stewart\Client\Exception\HaClientError;
9use Stewart\Client\Exception\HaClientException;
10use Stewart\Support\Url\UrlRedactor;
11use Stringable;
12
13final readonly class HomeAssistantUrl implements Stringable
14{
15    private const string SCHEME = '~\A([a-z][a-z0-9+.-]*)://~i';
16
17    private const array WEBSOCKET_SCHEMES = ['http' => 'ws', 'https' => 'wss', 'ws' => 'ws', 'wss' => 'wss'];
18
19    private const string DEFAULT_SCHEME = 'ws';
20
21    private const string WEBSOCKET_PATH = '/api/websocket';
22
23    private const string WEBSOCKET_PATH_ENDING = '/websocket';
24
25    private function __construct(
26        #[SensitiveParameter]
27        private string $url,
28        private string $redacted,
29    ) {}
30
31    /** @throws HaClientException */
32    public static function parse(#[SensitiveParameter] string $address): self
33    {
34        return self::tryParse($address) ?? throw HaClientException::createForReason(
35            HaClientError::UrlInvalid,
36            url: UrlRedactor::redactCredentials(self::prefixScheme(trim($address))),
37        );
38    }
39
40    public static function tryParse(#[SensitiveParameter] string $address): ?self
41    {
42        $address = trim($address);
43
44        if (preg_match(self::SCHEME, $address, $match) === 1) {
45            $scheme = self::WEBSOCKET_SCHEMES[strtolower($match[1])] ?? null;
46
47            if ($scheme === null) {
48                return null;
49            }
50
51            $address = $scheme . '://' . substr($address, \strlen($match[0]));
52        }
53
54        $url = rtrim(self::prefixScheme($address), '/');
55        $parts = parse_url($url);
56
57        if (!\is_array($parts) || ($parts['host'] ?? '') === '') {
58            return null;
59        }
60
61        $url = self::appendWebsocketPath($url, $parts['path'] ?? '', $parts['query'] ?? null, $parts['fragment'] ?? null);
62
63        return new self($url, UrlRedactor::redactCredentials($url));
64    }
65
66    public function reveal(): string
67    {
68        return $this->url;
69    }
70
71    public function __toString(): string
72    {
73        return $this->redacted;
74    }
75
76    private static function prefixScheme(string $address): string
77    {
78        return preg_match(self::SCHEME, $address) === 1 ? $address : self::DEFAULT_SCHEME . '://' . $address;
79    }
80
81    // Appended to the path: appending to the whole URL would put it after the query string.
82    private static function appendWebsocketPath(string $url, string $path, ?string $query, ?string $fragment): string
83    {
84        if (str_ends_with(rtrim($path, '/'), self::WEBSOCKET_PATH_ENDING)) {
85            return $url;
86        }
87
88        $tail = ($query === null ? '' : '?' . $query) . ($fragment === null ? '' : '#' . $fragment);
89        $head = $tail === '' ? $url : substr($url, 0, \strlen($url) - \strlen($tail));
90
91        return rtrim($head, '/') . self::WEBSOCKET_PATH . $tail;
92    }
93}