Lines 96.50% 138 / 143
Methods 88.46% 23 / 26
Classes 0.00% 0 / 1
Name Lines Methods CRAP
 __construct 100.00% 1 / 1 100.00% 1 / 1 1
 fromString 100.00% 14 / 14 100.00% 1 / 1 6
 fromServer 88.88% 16 / 18 0.00% 0 / 1 10.14
 isValid 89.47% 17 / 19 0.00% 0 / 1 16.30
 getScheme 100.00% 1 / 1 100.00% 1 / 1 1
 getAuthority 100.00% 8 / 8 100.00% 1 / 1 4
 getUserInfo 100.00% 1 / 1 100.00% 1 / 1 1
 getHost 100.00% 1 / 1 100.00% 1 / 1 1
 getPort 100.00% 1 / 1 100.00% 1 / 1 1
 getPath 100.00% 1 / 1 100.00% 1 / 1 1
 getQuery 100.00% 1 / 1 100.00% 1 / 1 1
 getFragment 100.00% 1 / 1 100.00% 1 / 1 1
 withScheme 100.00% 7 / 7 100.00% 1 / 1 3
 withUserInfo 100.00% 5 / 5 100.00% 1 / 1 3
 withHost 100.00% 5 / 5 100.00% 1 / 1 3
 withPort 100.00% 5 / 5 100.00% 1 / 1 4
 withPath 100.00% 4 / 4 100.00% 1 / 1 1
 withQuery 100.00% 4 / 4 100.00% 1 / 1 1
 withFragment 100.00% 4 / 4 100.00% 1 / 1 1
 __toString 94.11% 16 / 17 0.00% 0 / 1 10.02
 filterPort 100.00% 5 / 5 100.00% 1 / 1 5
 encodePath 100.00% 1 / 1 100.00% 1 / 1 1
 encodeQueryOrFragment 100.00% 1 / 1 100.00% 1 / 1 1
 encodeComponent 100.00% 10 / 10 100.00% 1 / 1 3
 isValidHost 100.00% 6 / 6 100.00% 1 / 1 4
 assertNoControlChars 100.00% 2 / 2 100.00% 1 / 1 2
14final class Uri implements UriInterface
15{
16    private string $scheme = '';
17    private string $userInfo = '';
18    private string $host = '';
19    private ?int $port = null;
20    private string $path = '';
21    private string $query = '';
22    private string $fragment = '';
23
24    private const STANDARD_PORTS = [
25        'http' => 80,
26        'https' => 443,
27    ];
28
29    /**
30     * Validation flags for isValid().
31     *
32     * By default (flags = 0) relative references such as "/users/123",
33     * "?page=2" or "#top" are accepted, matching how Uri represents URIs.
34     */
35    public const VALIDATE_RELATIVE = 0b0001; // accept path-only / relative references
36    public const VALIDATE_ABSOLUTE = 0b0010; // require a scheme (e.g. http:, https:)
37    public const VALIDATE_HOST     = 0b0100; // require a non-empty host
38    public const VALIDATE_STRICT   = 0b1000; // reject non-standard forms
39
40    /**
41     * Sensible default for validating a full absolute URL.
42     *
43     * Resolves to VALIDATE_HOST | VALIDATE_ABSOLUTE — requires a scheme and a
44     * non-empty host, but allows any scheme (http, https, ftp, mailto, ...).
45     */
46    public const VALIDATE_DEFAULT = self::VALIDATE_HOST | self::VALIDATE_ABSOLUTE;
47
48    /**
49     * Unreserved characters (RFC 3986 §2.3) plus sub-delims (§2.2) that are
50     * always allowed unencoded in path/query/fragment components.
51     */
52    private const CHAR_UNRESERVED = 'A-Za-z0-9\-._~!$&\'()*+,;=';
53
54    /**
55     * Private constructor — use fromString() or fromServer() instead.
56     */
57    private function __construct()
58    {
59    }
60
61    /**
62     * Create a URI from a string.
63     *
64     * @param string $uri The URI string to parse
65     * @return self
66     * @throws \InvalidArgumentException If the URI cannot be parsed
67     */
68    public static function fromString(string $uri): self
69    {
70        if (!self::isValid($uri)) {
71            throw new \InvalidArgumentException("Unable to parse URI: $uri");
72        }
73
74        $parts = parse_url($uri);
75
76        $instance = new self();
77        $instance->scheme = isset($parts['scheme']) ? strtolower($parts['scheme']) : '';
78        $instance->host = isset($parts['host']) ? strtolower($parts['host']) : '';
79        $instance->port = isset($parts['port']) ? $instance->filterPort((int) $parts['port']) : null;
80        $instance->path = self::encodePath($parts['path'] ?? '');
81        $instance->query = self::encodeQueryOrFragment($parts['query'] ?? '');
82        $instance->fragment = self::encodeQueryOrFragment($parts['fragment'] ?? '');
83        $instance->userInfo = $parts['user'] ?? '';
84
85        if (isset($parts['pass'])) {
86            $instance->userInfo .= ':' . $parts['pass'];
87        }
88
89        return $instance;
90    }
91
92    /**
93     * Create a URI from PHP superglobals ($_SERVER).
94     *
95     * @param array $server Typically $_SERVER
96     * @return self
97     */
98    public static function fromServer(array $server): self
99    {
100        $instance = new self();
101
102        // Scheme
103        $https = $server['HTTPS'] ?? '';
104        $instance->scheme = (!empty($https) && $https !== 'off') ? 'https' : 'http';
105
106        // Host
107        $host = $server['HTTP_HOST'] ?? $server['SERVER_NAME'] ?? '';
108        if (str_contains($host, ':')) {
109            [$instance->host, $portPart] = explode(':', $host, 2);
110            $instance->port = (int) $portPart;
111        } else {
112            $instance->host = $host;
113        }
114
115        // Port
116        $serverPort = $server['SERVER_PORT'] ?? null;
117        if ($serverPort !== null && $instance->port === null) {
118            $instance->port = (int) $serverPort;
119        }
120
121        // Path
122        $requestUri = $server['REQUEST_URI'] ?? '/';
123        $pathPart = parse_url($requestUri, PHP_URL_PATH);
124        $instance->path = self::encodePath($pathPart !== false && $pathPart !== null ? $pathPart : '/');
125
126        // Query
127        $queryPart = parse_url($requestUri, PHP_URL_QUERY);
128        $instance->query = self::encodeQueryOrFragment($queryPart !== false && $queryPart !== null ? $queryPart : ($server['QUERY_STRING'] ?? ''));
129
130        // Strip standard port
131        $instance->port = $instance->filterPort($instance->port);
132
133        return $instance;
134    }
135
136    /**
137     * Validate a URI string without throwing.
138     *
139     * By default (flags = 0) relative references such as "/users/123",
140     * "?page=2" or "#top" are accepted. Combine the VALIDATE_* flags to
141     * narrow the check:
142     *
143     *   Uri::isValid('https://example.com/path', Uri::VALIDATE_ABSOLUTE | Uri::VALIDATE_HOST)
144     *
145     * @param string $uri   The URI string to validate
146     * @param int    $flags Bitmask of VALIDATE_* constants
147     * @return bool Whether the URI is well-formed and satisfies the flags
148     */
149    public static function isValid(string $uri, int $flags = 0): bool
150    {
151        // Reject control characters in the raw URI. parse_url() silently
152        // converts them to '_', so they must be checked before parsing.
153        if (preg_match('/[\x00-\x1F\x7F]/', $uri)) {
154            return false;
155        }
156
157        $parts = parse_url($uri);
158        if ($parts === false) {
159            return false;
160        }
161
162        // Validate the port range when present.
163        if (isset($parts['port']) && ($parts['port'] < 0 || $parts['port'] > 65535)) {
164            return false;
165        }
166
167        // Validate the host when present.
168        if (isset($parts['host']) && !self::isValidHost($parts['host'])) {
169            return false;
170        }
171
172        // VALIDATE_ABSOLUTE: require a scheme.
173        if (($flags & self::VALIDATE_ABSOLUTE) && !isset($parts['scheme'])) {
174            return false;
175        }
176
177        // VALIDATE_HOST: require a non-empty host.
178        if (($flags & self::VALIDATE_HOST) && empty($parts['host'])) {
179            return false;
180        }
181
182        // VALIDATE_STRICT: reject non-standard forms.
183        if ($flags & self::VALIDATE_STRICT) {
184            // A scheme must be present and be http/https.
185            if (isset($parts['scheme']) && !in_array(strtolower($parts['scheme']), ['http', 'https'], true)) {
186                return false;
187            }
188
189            // A host must be present.
190            if (empty($parts['host'])) {
191                return false;
192            }
193        }
194
195        return true;
196    }
197
198    public function getScheme(): string
199    {
200        return $this->scheme;
201    }
202
203    public function getAuthority(): string
204    {
205        if ($this->host === '') {
206            return '';
207        }
208
209        $authority = $this->host;
210
211        if ($this->userInfo !== '') {
212            $authority = $this->userInfo . '@' . $authority;
213        }
214
215        if ($this->port !== null) {
216            $authority .= ':' . $this->port;
217        }
218
219        return $authority;
220    }
221
222    public function getUserInfo(): string
223    {
224        return $this->userInfo;
225    }
226
227    public function getHost(): string
228    {
229        return $this->host;
230    }
231
232    public function getPort(): ?int
233    {
234        return $this->port;
235    }
236
237    public function getPath(): string
238    {
239        return $this->path;
240    }
241
242    public function getQuery(): string
243    {
244        return $this->query;
245    }
246
247    public function getFragment(): string
248    {
249        return $this->fragment;
250    }
251
252    /**
253     * @throws \InvalidArgumentException for invalid or unsupported schemes
254     */
255    public function withScheme(string $scheme): static
256    {
257        $scheme = strtolower($scheme);
258        if ($scheme !== '' && !isset(self::STANDARD_PORTS[$scheme])) {
259            throw new \InvalidArgumentException("Unsupported scheme: '$scheme' (only http and https are supported)");
260        }
261
262        $new = clone $this;
263        $new->scheme = $scheme;
264        $new->port = $new->filterPort($new->port);
265        return $new;
266    }
267
268    public function withUserInfo(string $user, ?string $password = null): static
269    {
270        $new = clone $this;
271        $new->userInfo = $user;
272        if ($password !== null && $password !== '') {
273            $new->userInfo .= ':' . $password;
274        }
275        return $new;
276    }
277
278    /**
279     * @throws \InvalidArgumentException for invalid hostnames
280     */
281    public function withHost(string $host): static
282    {
283        if ($host !== '' && !self::isValidHost($host)) {
284            throw new \InvalidArgumentException("Invalid host: '$host'");
285        }
286
287        $new = clone $this;
288        $new->host = strtolower($host);
289        return $new;
290    }
291
292    /**
293     * @throws \InvalidArgumentException for ports outside the TCP/UDP range (0–65535)
294     */
295    public function withPort(?int $port): static
296    {
297        if ($port !== null && ($port < 0 || $port > 65535)) {
298            throw new \InvalidArgumentException("Invalid port: $port (must be 0-65535)");
299        }
300
301        $new = clone $this;
302        $new->port = $new->filterPort($port);
303        return $new;
304    }
305
306    /**
307     * @throws \InvalidArgumentException for invalid paths
308     */
309    public function withPath(string $path): static
310    {
311        self::assertNoControlChars($path, 'path');
312
313        $new = clone $this;
314        $new->path = self::encodePath($path);
315        return $new;
316    }
317
318    /**
319     * @throws \InvalidArgumentException for invalid query strings
320     */
321    public function withQuery(string $query): static
322    {
323        self::assertNoControlChars($query, 'query');
324
325        $new = clone $this;
326        $new->query = self::encodeQueryOrFragment(ltrim($query, '?'));
327        return $new;
328    }
329
330    /**
331     * @throws \InvalidArgumentException for invalid fragments
332     */
333    public function withFragment(string $fragment): static
334    {
335        self::assertNoControlChars($fragment, 'fragment');
336
337        $new = clone $this;
338        $new->fragment = self::encodeQueryOrFragment($fragment);
339        return $new;
340    }
341
342    public function __toString(): string
343    {
344        $uri = '';
345
346        if ($this->scheme !== '') {
347            $uri .= $this->scheme . ':';
348        }
349
350        $authority = $this->getAuthority();
351        if ($authority !== '') {
352            $uri .= '//' . $authority;
353        }
354
355        $path = $this->path;
356        if ($authority !== '' && $path !== '' && $path[0] !== '/') {
357            // Rootless path with an authority must be prefixed by "/".
358            $path = '/' . $path;
359        }
360        if ($authority === '' && str_starts_with($path, '//')) {
361            // A path starting with more than one "/" and no authority is
362            // reduced to a single leading slash. Note: a network-path
363            // reference ("//host/path") parses its leading segment into the
364            // host, so this only triggers for paths set via withPath().
365            $path = '/' . ltrim($path, '/');
366        }
367        $uri .= $path;
368
369        if ($this->query !== '') {
370            $uri .= '?' . $this->query;
371        }
372
373        if ($this->fragment !== '') {
374            $uri .= '#' . $this->fragment;
375        }
376
377        return $uri;
378    }
379
380    /**
381     * Strip standard ports (80 for http, 443 for https).
382     *
383     * @param int|null $port The port to filter
384     * @return int|null The filtered port, or null if it's a standard port
385     */
386    private function filterPort(?int $port): ?int
387    {
388        if ($port === null) {
389            return null;
390        }
391
392        if ($this->scheme !== '' && isset(self::STANDARD_PORTS[$this->scheme]) && self::STANDARD_PORTS[$this->scheme] === $port) {
393            return null;
394        }
395
396        return $port;
397    }
398
399    /**
400     * Percent-encode a path component per RFC 3986 §3.3.
401     *
402     * Existing percent-encoded triplets are preserved (no double-encoding).
403     *
404     * @param string $path The raw path
405     * @return string The percent-encoded path
406     */
407    private static function encodePath(string $path): string
408    {
409        return self::encodeComponent($path, self::CHAR_UNRESERVED . ':@\/');
410    }
411
412    /**
413     * Percent-encode a query or fragment component per RFC 3986 §3.4/§3.5.
414     *
415     * Existing percent-encoded triplets are preserved (no double-encoding).
416     *
417     * @param string $value The raw query or fragment
418     * @return string The percent-encoded value
419     */
420    private static function encodeQueryOrFragment(string $value): string
421    {
422        return self::encodeComponent($value, self::CHAR_UNRESERVED . ':@\/\?');
423    }
424
425    /**
426     * Percent-encode any character outside the allowed set, preserving
427     * existing valid %XX triplets.
428     *
429     * @param string $value The raw component value
430     * @param string $allowedChars Regex character class content for allowed chars
431     * @return string The encoded component
432     */
433    private static function encodeComponent(string $value, string $allowedChars): string
434    {
435        return (string) preg_replace_callback(
436            '/(?:%[0-9A-Fa-f]{2})|[^' . $allowedChars . ']/',
437            static function (array $matches): string {
438                $char = $matches[0];
439                // Preserve existing valid percent-encoded triplets.
440                if (strlen($char) === 3 && $char[0] === '%') {
441                    return $char;
442                }
443                return rawurlencode($char);
444            },
445            $value
446        );
447    }
448
449    /**
450     * Validate a hostname (DNS name, IPv4, or bracketed IPv6 literal).
451     */
452    private static function isValidHost(string $host): bool
453    {
454        // Bracketed IPv6 literal, e.g. [::1]
455        if (str_starts_with($host, '[')) {
456            return str_ends_with($host, ']')
457                && filter_var(substr($host, 1, -1), FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false;
458        }
459
460        // IPv4
461        if (filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false) {
462            return true;
463        }
464
465        // DNS hostname (labels of alphanumerics and hyphens, dot-separated)
466        return (bool) preg_match('/^[a-zA-Z0-9]([a-zA-Z0-9\-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]*[a-zA-Z0-9])?)*\.?$/', $host);
467    }
468
469    /**
470     * Reject control characters (including null bytes, CR, LF) in a component.
471     *
472     * @throws \InvalidArgumentException
473     */
474    private static function assertNoControlChars(string $value, string $component): void
475    {
476        if (preg_match('/[\x00-\x1F\x7F]/', $value)) {
477            throw new \InvalidArgumentException("Invalid URI $component: contains control characters");
478        }
479    }
480}