Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
123 / 123
100.00% covered (success)
100.00%
14 / 14
CRAP
100.00% covered (success)
100.00%
1 / 1
Url
100.00% covered (success)
100.00%
123 / 123
100.00% covered (success)
100.00%
14 / 14
28
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getDecodedValue
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 tryParse
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
6
 parse
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 changeScheme
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 changeAuthentication
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 clearAuthentication
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 changeHost
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 changePort
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 changePath
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 changeQuery
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 changeFragment
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 toString
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
8
 __toString
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5namespace PeServer\Core\Web;
6
7use Stringable;
8use PeServer\Core\Binary;
9use PeServer\Core\Collection\Arr;
10use PeServer\Core\Collection\Collections;
11use PeServer\Core\Encoding;
12use PeServer\Core\Text;
13use PeServer\Core\Throws\ArgumentException;
14use PeServer\Core\Throws\ParseException;
15use PeServer\Core\Web\UrlEncoding;
16use PeServer\Core\Web\UrlPath;
17use PeServer\Core\Web\UrlQuery;
18
19/**
20 * URL。
21 */
22
23readonly class Url implements Stringable
24{
25    /**
26     * 生成。
27     *
28     * 基本的には `tryParse`/`parse` を使用する想定。
29     *
30     * @param non-empty-string $scheme
31     * @param string $user
32     * @param string $password
33     * @param non-empty-string $host
34     * @param int|null $port `null` は未指定
35     * @phpstan-param int<0,65535>|null $port
36     * @param UrlPath $path
37     * @param UrlQuery $query `null` は未指定
38     * @param string|null $fragment `null` は未指定
39     */
40    public function __construct(
41        public string $scheme,
42        public string $user,
43        public string $password,
44        public string $host,
45        public ?int $port,
46        public UrlPath $path,
47        public UrlQuery $query,
48        public ?string $fragment
49    ) {
50    }
51
52    #region
53
54    /**
55     * 配列からURL・文字列をデコードした値を取得。
56     *
57     * @param array<array-key,mixed> $elements
58     * @param string $key
59     * @param string|null $default
60     * @param UrlEncoding $urlEncoding
61     * @return string|null デコードされた値か、取得失敗時のデフォルト値
62     */
63    private static function getDecodedValue(array $elements, string $key, ?string $default, UrlEncoding $urlEncoding): ?string
64    {
65        $rawElement = $elements[$key] ?? $default;
66        if (Text::isNullOrWhiteSpace($rawElement)) {
67            return $rawElement;
68        }
69
70        return $urlEncoding->decode($rawElement);
71    }
72
73    /**
74     * 文字列から生成。
75     *
76     * `parse_url` ラッパー。
77     *
78     * @param string $url URL 文字列。
79     * @param self|null $result 結果格納。成功時に格納される。
80     * @param UrlEncoding|null $urlEncoding URLエンコーディング。
81     * @return bool 成功。
82     * @phpstan-assert-if-true self $result
83     * @phpstan-assert-if-false null $result
84     */
85    public static function tryParse(string $url, ?self &$result, ?UrlEncoding $urlEncoding = null): bool
86    {
87        $urlEncoding ??= UrlEncoding::createDefault();
88
89        $elements = parse_url($url);
90        if ($elements === false) {
91            return false;
92        }
93
94        // さすがにこの二点は保証しておきたい(scheme は微妙と思いつつ)
95        if (!isset($elements['scheme']) || Text::isNullOrWhiteSpace($elements['scheme'])) {
96            return false;
97        }
98        if (!array_key_exists('host', $elements) || Text::isNullOrWhiteSpace($elements['host'])) {
99            return false;
100        }
101
102        /** @var string */
103        $user = self::getDecodedValue($elements, 'user', Text::EMPTY, $urlEncoding);
104        /** @var string */
105        $pass = self::getDecodedValue($elements, 'pass', Text::EMPTY, $urlEncoding);
106        $fragment = self::getDecodedValue($elements, 'fragment', null, $urlEncoding);
107
108        $result = new self(
109            $elements['scheme'],
110            $user,
111            $pass,
112            $elements['host'],
113            $elements['port'] ?? null,
114            new UrlPath($elements['path'] ?? Text::EMPTY),
115            new UrlQuery($elements['query'] ?? null, $urlEncoding),
116            $fragment
117        );
118
119        return true;
120    }
121
122    /**
123     * 文字列から生成。
124     *
125     * 失敗時に例外。
126     *
127     * @param string $url URL 文字列。
128     * @param UrlEncoding|null $urlEncoding URLエンコーディング。
129     * @return self
130     */
131    public static function parse(string $url, ?UrlEncoding $urlEncoding = null): self
132    {
133        if (self::tryParse($url, $result, $urlEncoding)) {
134            return $result;
135        }
136        throw new ParseException();
137    }
138
139    /**
140     * スキームを変更。
141     *
142     * @param non-empty-string $scheme
143     * @return self 変更された新規URL
144     */
145    public function changeScheme(string $scheme): self
146    {
147        return new self(
148            $scheme,
149            $this->user,
150            $this->password,
151            $this->host,
152            $this->port,
153            $this->path,
154            $this->query,
155            $this->fragment
156        );
157    }
158
159    /**
160     * 認証情報を変更。
161     *
162     * @param string $user
163     * @param string $password
164     * @return self 変更された新規URL
165     */
166    public function changeAuthentication(string $user, string $password): self
167    {
168        return new self(
169            $this->scheme,
170            $user,
171            $password,
172            $this->host,
173            $this->port,
174            $this->path,
175            $this->query,
176            $this->fragment
177        );
178    }
179
180    /**
181     * 認証情報を破棄。
182     *
183     * @return self 変更された新規URL
184     */
185    public function clearAuthentication(): self
186    {
187        return $this->changeAuthentication(Text::EMPTY, Text::EMPTY);
188    }
189
190
191    /**
192     * ホストを変更。
193     *
194     * @param non-empty-string $host
195     * @return self 変更された新規URL
196     */
197    public function changeHost(string $host): self
198    {
199        return new self(
200            $this->scheme,
201            $this->user,
202            $this->password,
203            $host,
204            $this->port,
205            $this->path,
206            $this->query,
207            $this->fragment
208        );
209    }
210
211    /**
212     * ポートを変更。
213     *
214     * @param int $port
215     * @phpstan-param int<0,65535>|null $port
216     * @return self 変更された新規URL
217     */
218    public function changePort(?int $port): self
219    {
220        return new self(
221            $this->scheme,
222            $this->user,
223            $this->password,
224            $this->host,
225            $port,
226            $this->path,
227            $this->query,
228            $this->fragment
229        );
230    }
231
232    /**
233     * パスを変更。
234     *
235     * @param UrlPath $path
236     * @return self 変更された新規URL
237     */
238    public function changePath(UrlPath $path): self
239    {
240        return new self(
241            $this->scheme,
242            $this->user,
243            $this->password,
244            $this->host,
245            $this->port,
246            $path,
247            $this->query,
248            $this->fragment
249        );
250    }
251
252    /**
253     * クエリを変更。
254     *
255     * @param UrlQuery $query
256     * @return self 変更された新規URL
257     */
258    public function changeQuery(UrlQuery $query): self
259    {
260        return new self(
261            $this->scheme,
262            $this->user,
263            $this->password,
264            $this->host,
265            $this->port,
266            $this->path,
267            $query,
268            $this->fragment
269        );
270    }
271
272    /**
273     * フラグメントを変更。
274     *
275     * @param string|null $fragment
276     * @return self 変更された新規URL
277     */
278    public function changeFragment(?string $fragment): self
279    {
280        return new self(
281            $this->scheme,
282            $this->user,
283            $this->password,
284            $this->host,
285            $this->port,
286            $this->path,
287            $this->query,
288            $fragment
289        );
290    }
291
292    public function toString(?UrlEncoding $urlEncoding = null, bool $trailingSlash = false): string
293    {
294        $urlEncoding ??= UrlEncoding::createDefault();
295
296        $work = $this->scheme . '://';
297
298        $hasAuth = false;
299        if (!Text::isNullOrEmpty($this->user)) {
300            $work .= $urlEncoding->encode($this->user);
301            $hasAuth = true;
302        }
303        if (!Text::isNullOrEmpty($this->password)) {
304            $work .= ':' . $urlEncoding->encode($this->password);
305            $hasAuth = true;
306        }
307        if ($hasAuth) {
308            $work .= '@';
309        }
310
311        $work .= $this->host;
312        if ($this->port !== null) {
313            $work .= ':' . (string)$this->port;
314        }
315        if ($this->path->isEmpty() && $trailingSlash) {
316            $work .= '/';
317        } else {
318            $work .= $this->path->toString($trailingSlash);
319        }
320        $work .= $this->query->toString($urlEncoding);
321
322        if ($this->fragment !== null) {
323            $work .= '#' . $urlEncoding->encode($this->fragment);
324        }
325
326        return $work;
327    }
328
329    #endregion
330
331    #region Stringable
332
333    public function __toString(): string
334    {
335        return $this->toString();
336    }
337
338    #endregion
339}