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 | ||
| 14 | final 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 | } |