Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
99.11% covered (success)
99.11%
111 / 112
97.14% covered (success)
97.14%
34 / 35
CRAP
0.00% covered (danger)
0.00%
0 / 1
URL
99.11% covered (success)
99.11%
111 / 112
97.14% covered (success)
97.14%
34 / 35
60
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 __toString
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 addQuery
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 addQueries
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 filterQuery
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getBaseUrl
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 getFragment
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getHost
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 getHostname
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getOrigin
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getParsedUrl
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 getPass
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getPath
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getPathSegments
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getPathSegment
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getPort
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getPortPart
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 getQuery
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 getQueryData
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 getScheme
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getAsString
n/a
0 / 0
n/a
0 / 0
1
 toString
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
5
 getUser
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 removeQueryData
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setFragment
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setHostname
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 setPass
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setPath
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setPathSegments
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setPort
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 setQuery
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setQueryData
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 setScheme
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setUrl
95.24% covered (success)
95.24%
20 / 21
0.00% covered (danger)
0.00%
0 / 1
9
 setUser
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 jsonSerialize
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php declare(strict_types=1);
2/*
3 * This file is part of Aplus Framework HTTP Library.
4 *
5 * (c) Natan Felles <natanfelles@gmail.com>
6 *
7 * For the full copyright and license information, please view the LICENSE
8 * file that was distributed with this source code.
9 */
10namespace Framework\HTTP;
11
12use InvalidArgumentException;
13use JetBrains\PhpStorm\ArrayShape;
14use JetBrains\PhpStorm\Deprecated;
15use JetBrains\PhpStorm\Pure;
16use RuntimeException;
17
18/**
19 * Class URL.
20 *
21 * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Identifying_resources_on_the_Web#urls
22 * @see https://developer.mozilla.org/en-US/docs/Web/API/URL
23 * @see https://datatracker.ietf.org/doc/html/rfc3986#section-3
24 *
25 * @package http
26 */
27class URL implements \JsonSerializable, \Stringable
28{
29    /**
30     * The #fragment (id).
31     */
32    protected ?string $fragment = null;
33    protected ?string $hostname = null;
34    protected ?string $pass = null;
35    /**
36     * The /paths/of/url.
37     *
38     * @var array<int,string>
39     */
40    protected array $pathSegments = [];
41    protected ?int $port = null;
42    /**
43     *  The ?queries.
44     *
45     * @var array<string,mixed>
46     */
47    protected array $queryData = [];
48    protected ?string $scheme = null;
49    protected ?string $user = null;
50
51    /**
52     * URL constructor.
53     *
54     * @param string $url
55     */
56    public function __construct(string $url)
57    {
58        $this->setUrl($url);
59    }
60
61    /**
62     * @return string
63     */
64    #[Pure]
65    public function __toString() : string
66    {
67        return $this->toString();
68    }
69
70    /**
71     * @param string $query
72     * @param int|string|null $value
73     *
74     * @return static
75     */
76    public function addQuery(string $query, $value = null) : static
77    {
78        $this->queryData[$query] = $value;
79        return $this;
80    }
81
82    /**
83     * @param array<string,int|string|null> $queries
84     *
85     * @return static
86     */
87    public function addQueries(array $queries) : static
88    {
89        foreach ($queries as $name => $value) {
90            $this->addQuery($name, $value);
91        }
92        return $this;
93    }
94
95    /**
96     * @param array<int,string> $allowed
97     *
98     * @return array<string,mixed>
99     */
100    #[Pure]
101    protected function filterQuery(array $allowed) : array
102    {
103        return $this->queryData ?
104            \array_intersect_key($this->queryData, \array_flip($allowed))
105            : [];
106    }
107
108    #[Pure]
109    public function getBaseUrl(string $path = '/') : string
110    {
111        if ($path && $path !== '/') {
112            $path = '/' . \ltrim($path, '/');
113        }
114        return $this->getOrigin() . $path;
115    }
116
117    /**
118     * @return string|null
119     */
120    public function getFragment() : ?string
121    {
122        return $this->fragment;
123    }
124
125    /**
126     * @return string|null
127     */
128    #[Pure]
129    public function getHost() : ?string
130    {
131        return $this->hostname === null ? null : $this->hostname . $this->getPortPart();
132    }
133
134    #[Pure]
135    public function getHostname() : ?string
136    {
137        return $this->hostname;
138    }
139
140    #[Pure]
141    public function getOrigin() : string
142    {
143        return $this->getScheme() . '://' . $this->getHost();
144    }
145
146    /**
147     * @return array<string,mixed>
148     */
149    #[ArrayShape([
150        'scheme' => 'string',
151        'user' => 'null|string',
152        'pass' => 'null|string',
153        'hostname' => 'string',
154        'port' => 'int|null',
155        'path' => 'string[]',
156        'query' => 'mixed[]',
157        'fragment' => 'null|string',
158    ])]
159    #[Pure]
160    public function getParsedUrl() : array
161    {
162        return [
163            'scheme' => $this->getScheme(),
164            'user' => $this->getUser(),
165            'pass' => $this->getPass(),
166            'hostname' => $this->getHostname(),
167            'port' => $this->getPort(),
168            'path' => $this->getPathSegments(),
169            'query' => $this->getQueryData(),
170            'fragment' => $this->getFragment(),
171        ];
172    }
173
174    /**
175     * @return string|null
176     */
177    #[Pure]
178    public function getPass() : ?string
179    {
180        return $this->pass;
181    }
182
183    #[Pure]
184    public function getPath() : string
185    {
186        return '/' . \implode('/', $this->pathSegments);
187    }
188
189    /**
190     * @return array<int,string>
191     */
192    #[Pure]
193    public function getPathSegments() : array
194    {
195        return $this->pathSegments;
196    }
197
198    #[Pure]
199    public function getPathSegment(int $index) : ?string
200    {
201        return $this->pathSegments[$index] ?? null;
202    }
203
204    /**
205     * @return int|null
206     */
207    #[Pure]
208    public function getPort() : ?int
209    {
210        return $this->port;
211    }
212
213    #[Pure]
214    protected function getPortPart() : string
215    {
216        $part = $this->getPort();
217        if ( ! \in_array($part, [
218            null,
219            80,
220            443,
221        ], true)) {
222            return ':' . $part;
223        }
224        return '';
225    }
226
227    /**
228     * Get the "Query" part of the URL.
229     *
230     * @param array<int,string> $allowedKeys Allowed query keys
231     *
232     * @return string|null
233     */
234    #[Pure]
235    public function getQuery(array $allowedKeys = []) : ?string
236    {
237        $query = $this->getQueryData($allowedKeys);
238        return $query ? \http_build_query($query) : null;
239    }
240
241    /**
242     * @param array<int,string> $allowedKeys
243     *
244     * @return array<string,mixed>
245     */
246    #[Pure]
247    public function getQueryData(array $allowedKeys = []) : array
248    {
249        return $allowedKeys ? $this->filterQuery($allowedKeys) : $this->queryData;
250    }
251
252    /**
253     * @return string|null
254     */
255    #[Pure]
256    public function getScheme() : ?string
257    {
258        return $this->scheme;
259    }
260
261    /**
262     * @return string
263     *
264     * @deprecated Use {@see URL::toString()}
265     *
266     * @codeCoverageIgnore
267     */
268    #[Deprecated(
269        reason: 'since HTTP Library version 5.3, use toString() instead',
270        replacement: '%class%->toString()'
271    )]
272    public function getAsString() : string
273    {
274        \trigger_error(
275            'Method ' . __METHOD__ . ' is deprecated',
276            \E_USER_DEPRECATED
277        );
278        return $this->toString();
279    }
280
281    /**
282     * @since 5.3
283     *
284     * @return string
285     */
286    #[Pure]
287    public function toString() : string
288    {
289        $url = $this->getScheme() . '://';
290        $part = $this->getUser();
291        if ($part !== null) {
292            $url .= $part;
293            $part = $this->getPass();
294            if ($part !== null) {
295                $url .= ':' . $part;
296            }
297            $url .= '@';
298        }
299        $url .= $this->getHost();
300        $url .= $this->getPath();
301        $part = $this->getQuery();
302        if ($part !== null) {
303            $url .= '?' . $part;
304        }
305        $part = $this->getFragment();
306        if ($part !== null) {
307            $url .= '#' . $part;
308        }
309        return $url;
310    }
311
312    /**
313     * @return string|null
314     */
315    #[Pure]
316    public function getUser() : ?string
317    {
318        return $this->user;
319    }
320
321    /**
322     * @param string $key
323     *
324     * @return static
325     */
326    public function removeQueryData(string $key) : static
327    {
328        unset($this->queryData[$key]);
329        return $this;
330    }
331
332    /**
333     * @param string $fragment
334     *
335     * @return static
336     */
337    public function setFragment(string $fragment) : static
338    {
339        $this->fragment = \ltrim($fragment, '#');
340        return $this;
341    }
342
343    /**
344     * @param string $hostname
345     *
346     * @throws InvalidArgumentException for invalid URL Hostname
347     *
348     * @return static
349     */
350    public function setHostname(string $hostname) : static
351    {
352        $filtered = \filter_var($hostname, \FILTER_VALIDATE_DOMAIN, \FILTER_FLAG_HOSTNAME);
353        if ( ! $filtered) {
354            throw new InvalidArgumentException("Invalid URL Hostname: {$hostname}");
355        }
356        $this->hostname = $filtered;
357        return $this;
358    }
359
360    /**
361     * @param string $pass
362     *
363     * @return static
364     */
365    public function setPass(string $pass) : static
366    {
367        $this->pass = $pass;
368        return $this;
369    }
370
371    /**
372     * @param string $segments
373     *
374     * @return static
375     */
376    public function setPath(string $segments) : static
377    {
378        return $this->setPathSegments(\explode('/', \ltrim($segments, '/')));
379    }
380
381    /**
382     * @param array<int,string> $segments
383     *
384     * @return static
385     */
386    public function setPathSegments(array $segments) : static
387    {
388        $this->pathSegments = $segments;
389        return $this;
390    }
391
392    /**
393     * @param int $port
394     *
395     * @throws InvalidArgumentException for invalid URL Port
396     *
397     * @return static
398     */
399    public function setPort(int $port) : static
400    {
401        if ($port < 1 || $port > 65535) {
402            throw new InvalidArgumentException("Invalid URL Port: {$port}");
403        }
404        $this->port = $port;
405        return $this;
406    }
407
408    /**
409     * @param string $data
410     * @param array<string> $only
411     *
412     * @return static
413     */
414    public function setQuery(string $data, array $only = []) : static
415    {
416        \parse_str(\ltrim($data, '?'), $data);
417        return $this->setQueryData($data, $only);
418    }
419
420    /**
421     * @param array<mixed> $data
422     * @param array<string> $only
423     *
424     * @return static
425     */
426    public function setQueryData(array $data, array $only = []) : static
427    {
428        if ($only) {
429            $data = \array_intersect_key($data, \array_flip($only));
430        }
431        $this->queryData = $data;
432        return $this;
433    }
434
435    /**
436     * @param string $scheme
437     *
438     * @return static
439     */
440    public function setScheme(string $scheme) : static
441    {
442        $this->scheme = $scheme;
443        return $this;
444    }
445
446    /**
447     * @param string $url
448     *
449     * @throws InvalidArgumentException for invalid URL
450     *
451     * @return static
452     */
453    protected function setUrl(string $url) : static
454    {
455        $filteredUrl = \filter_var($url, \FILTER_VALIDATE_URL);
456        if ( ! $filteredUrl) {
457            throw new InvalidArgumentException("Invalid URL: {$url}");
458        }
459        $url = \parse_url($filteredUrl);
460        if ($url === false) {
461            throw new RuntimeException("URL could not be parsed: {$filteredUrl}");
462        }
463        $this->setScheme($url['scheme']); // @phpstan-ignore-line
464        if (isset($url['user'])) {
465            $this->setUser($url['user']);
466        }
467        if (isset($url['pass'])) {
468            $this->setPass($url['pass']);
469        }
470        $this->setHostname($url['host']); // @phpstan-ignore-line
471        if (isset($url['port'])) {
472            $this->setPort($url['port']);
473        }
474        if (isset($url['path'])) {
475            $this->setPath($url['path']);
476        }
477        if (isset($url['query'])) {
478            $this->setQuery($url['query']);
479        }
480        if (isset($url['fragment'])) {
481            $this->setFragment($url['fragment']);
482        }
483        return $this;
484    }
485
486    /**
487     * @param string $user
488     *
489     * @return static
490     */
491    public function setUser(string $user) : static
492    {
493        $this->user = $user;
494        return $this;
495    }
496
497    #[Pure]
498    public function jsonSerialize() : string
499    {
500        return $this->toString();
501    }
502}