• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

nette / http / 22837209177

09 Mar 2026 03:32AM UTC coverage: 83.513% (-0.1%) from 83.62%
22837209177

push

github

dg
added CLAUDE.md

932 of 1116 relevant lines covered (83.51%)

0.84 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

91.39
/src/Http/Url.php
1
<?php declare(strict_types=1);
2

3
/**
4
 * This file is part of the Nette Framework (https://nette.org)
5
 * Copyright (c) 2004 David Grudl (https://davidgrudl.com)
6
 */
7

8
namespace Nette\Http;
9

10
use Nette;
11
use function array_pop, array_slice, bin2hex, chunk_split, defined, explode, function_exists, http_build_query, idn_to_utf8, implode, ini_get, ip2long, is_array, is_string, ksort, parse_str, parse_url, preg_match, preg_quote, preg_replace, preg_replace_callback, rawurldecode, rawurlencode, rtrim, str_contains, str_replace, str_starts_with, strcasecmp, strlen, strrpos, strtolower, strtoupper, substr;
12
use const PHP_QUERY_RFC3986;
13

14

15
/**
16
 * Mutable representation of a URL.
17
 *
18
 * <pre>
19
 * scheme  user  password  host  port      path        query    fragment
20
 *   |      |      |        |      |        |            |         |
21
 * /--\   /--\ /------\ /-------\ /--\/------------\ /--------\ /------\
22
 * http://john:x0y17575@nette.org:8042/en/manual.php?name=param#fragment  <-- absoluteUrl
23
 * \______\__________________________/
24
 *     |               |
25
 *  hostUrl        authority
26
 * </pre>
27
 *
28
 * @property   string $scheme
29
 * @property   string $user
30
 * @property   string $password
31
 * @property   string $host
32
 * @property   int $port
33
 * @property   string $path
34
 * @property   string $query
35
 * @property   string $fragment
36
 * @property-read string $absoluteUrl
37
 * @property-read string $authority
38
 * @property-read string $hostUrl
39
 * @property-read string $basePath
40
 * @property-read string $baseUrl
41
 * @property-read string $relativeUrl
42
 * @property-read array<string,mixed> $queryParameters
43
 */
44
class Url implements \JsonSerializable
45
{
46
        use Nette\SmartObject;
47

48
        /** @var array<string, int> */
49
        public static array $defaultPorts = [
50
                'http' => 80,
51
                'https' => 443,
52
                'ftp' => 21,
53
        ];
54

55
        private string $scheme = '';
56
        private string $user = '';
57
        private string $password = '';
58
        private string $host = '';
59
        private ?int $port = null;
60
        private string $path = '';
61

62
        /** @var mixed[] */
63
        private array $query = [];
64
        private string $fragment = '';
65

66

67
        /**
68
         * @throws Nette\InvalidArgumentException if URL is malformed
69
         */
70
        public function __construct(string|self|UrlImmutable|null $url = null)
1✔
71
        {
72
                if (is_string($url)) {
1✔
73
                        $p = @parse_url($url); // @ - is escalated to exception
1✔
74
                        if ($p === false) {
1✔
75
                                throw new Nette\InvalidArgumentException("Malformed or unsupported URI '$url'.");
1✔
76
                        }
77

78
                        $this->scheme = $p['scheme'] ?? '';
1✔
79
                        $this->port = $p['port'] ?? null;
1✔
80
                        $this->host = rawurldecode($p['host'] ?? '');
1✔
81
                        $this->user = rawurldecode($p['user'] ?? '');
1✔
82
                        $this->password = rawurldecode($p['pass'] ?? '');
1✔
83
                        $this->setPath($p['path'] ?? '');
1✔
84
                        $this->setQuery($p['query'] ?? []);
1✔
85
                        $this->fragment = rawurldecode($p['fragment'] ?? '');
1✔
86

87
                } elseif ($url instanceof UrlImmutable || $url instanceof self) {
1✔
88
                        [$this->scheme, $this->user, $this->password, $this->host, $this->port, $this->path, $this->query, $this->fragment] = $url->export();
1✔
89
                }
90
        }
1✔
91

92

93
        public function setScheme(string $scheme): static
1✔
94
        {
95
                $this->scheme = $scheme;
1✔
96
                return $this;
1✔
97
        }
98

99

100
        public function getScheme(): string
101
        {
102
                return $this->scheme;
1✔
103
        }
104

105

106
        /** @deprecated */
107
        public function setUser(string $user): static
108
        {
109
                $this->user = $user;
×
110
                return $this;
×
111
        }
112

113

114
        /** @deprecated */
115
        public function getUser(): string
116
        {
117
                return $this->user;
1✔
118
        }
119

120

121
        /** @deprecated */
122
        public function setPassword(string $password): static
123
        {
124
                $this->password = $password;
×
125
                return $this;
×
126
        }
127

128

129
        /** @deprecated */
130
        public function getPassword(): string
131
        {
132
                return $this->password;
1✔
133
        }
134

135

136
        public function setHost(string $host): static
1✔
137
        {
138
                $this->host = $host;
1✔
139
                $this->setPath($this->path);
1✔
140
                return $this;
1✔
141
        }
142

143

144
        public function getHost(): string
145
        {
146
                return $this->host;
1✔
147
        }
148

149

150
        /**
151
         * Returns the specified number of rightmost domain labels (e.g. level 2 of 'www.nette.org' -> 'nette.org').
152
         * Negative values trim from the right instead.
153
         */
154
        public function getDomain(int $level = 2): string
1✔
155
        {
156
                $parts = ip2long($this->host)
1✔
157
                        ? [$this->host]
1✔
158
                        : explode('.', $this->host);
1✔
159
                $parts = $level >= 0
1✔
160
                        ? array_slice($parts, -$level)
1✔
161
                        : array_slice($parts, 0, $level);
1✔
162
                return implode('.', $parts);
1✔
163
        }
164

165

166
        public function setPort(int $port): static
1✔
167
        {
168
                $this->port = $port;
1✔
169
                return $this;
1✔
170
        }
171

172

173
        /**
174
         * Returns the port number, falling back to the default port for the scheme if not explicitly set.
175
         */
176
        public function getPort(): ?int
177
        {
178
                return $this->port ?: $this->getDefaultPort();
1✔
179
        }
180

181

182
        /**
183
         * Returns the default port for the current scheme, or null if the scheme is not recognized.
184
         */
185
        public function getDefaultPort(): ?int
186
        {
187
                return self::$defaultPorts[$this->scheme] ?? null;
1✔
188
        }
189

190

191
        /**
192
         * Sets the path. Automatically prepends a leading slash when a host is set.
193
         */
194
        public function setPath(string $path): static
1✔
195
        {
196
                $this->path = $path;
1✔
197
                if ($this->host && !str_starts_with($this->path, '/')) {
1✔
198
                        $this->path = '/' . $this->path;
1✔
199
                }
200

201
                return $this;
1✔
202
        }
203

204

205
        public function getPath(): string
206
        {
207
                return $this->path;
1✔
208
        }
209

210

211
        /** @param string|mixed[] $query */
212
        public function setQuery(string|array $query): static
1✔
213
        {
214
                $this->query = is_array($query) ? $query : self::parseQuery($query);
1✔
215
                return $this;
1✔
216
        }
217

218

219
        /**
220
         * Merges query parameters into the existing query. Array values use union (existing keys are preserved);
221
         * string values are appended and reparsed.
222
         * @param string|mixed[] $query
223
         */
224
        public function appendQuery(string|array $query): static
1✔
225
        {
226
                $this->query = is_array($query)
1✔
227
                        ? $query + $this->query
1✔
228
                        : self::parseQuery($this->getQuery() . '&' . $query);
1✔
229
                return $this;
1✔
230
        }
231

232

233
        public function getQuery(): string
234
        {
235
                return http_build_query($this->query, '', '&', PHP_QUERY_RFC3986);
1✔
236
        }
237

238

239
        /** @return mixed[] */
240
        public function getQueryParameters(): array
241
        {
242
                return $this->query;
1✔
243
        }
244

245

246
        public function getQueryParameter(string $name): mixed
1✔
247
        {
248
                return $this->query[$name] ?? null;
1✔
249
        }
250

251

252
        public function setQueryParameter(string $name, mixed $value): static
1✔
253
        {
254
                $this->query[$name] = $value;
1✔
255
                return $this;
1✔
256
        }
257

258

259
        public function setFragment(string $fragment): static
260
        {
261
                $this->fragment = $fragment;
×
262
                return $this;
×
263
        }
264

265

266
        public function getFragment(): string
267
        {
268
                return $this->fragment;
1✔
269
        }
270

271

272
        public function getAbsoluteUrl(): string
273
        {
274
                return $this->getHostUrl() . $this->path
1✔
275
                        . (($tmp = $this->getQuery()) ? '?' . $tmp : '')
1✔
276
                        . ($this->fragment === '' ? '' : '#' . $this->fragment);
1✔
277
        }
278

279

280
        /**
281
         * Returns the [user[:pass]@]host[:port] part of URI.
282
         */
283
        public function getAuthority(): string
284
        {
285
                return $this->host === ''
1✔
286
                        ? ''
1✔
287
                        : ($this->user !== ''
1✔
288
                                ? rawurlencode($this->user) . ($this->password === '' ? '' : ':' . rawurlencode($this->password)) . '@'
1✔
289
                                : '')
1✔
290
                        . $this->host
1✔
291
                        . ($this->port && $this->port !== $this->getDefaultPort()
1✔
292
                                ? ':' . $this->port
1✔
293
                                : '');
1✔
294
        }
295

296

297
        /**
298
         * Returns the scheme and authority part of URI.
299
         */
300
        public function getHostUrl(): string
301
        {
302
                return ($this->scheme ? $this->scheme . ':' : '')
1✔
303
                        . (($authority = $this->getAuthority()) !== '' ? '//' . $authority : '');
1✔
304
        }
305

306

307
        /** @deprecated use UrlScript::getBasePath() instead */
308
        public function getBasePath(): string
309
        {
310
                $pos = strrpos($this->path, '/');
×
311
                return $pos === false ? '' : substr($this->path, 0, $pos + 1);
×
312
        }
313

314

315
        /** @deprecated use UrlScript::getBaseUrl() instead */
316
        public function getBaseUrl(): string
317
        {
318
                return $this->getHostUrl() . $this->getBasePath();
×
319
        }
320

321

322
        /** @deprecated use UrlScript::getRelativeUrl() instead */
323
        public function getRelativeUrl(): string
324
        {
325
                return substr($this->getAbsoluteUrl(), strlen($this->getBaseUrl()));
×
326
        }
327

328

329
        /**
330
         * Checks whether two URLs are equal, ignoring query parameter order and trailing dots in hostnames.
331
         */
332
        public function isEqual(string|self|UrlImmutable $url): bool
1✔
333
        {
334
                $url = new self($url);
1✔
335
                $query = $url->query;
1✔
336
                ksort($query);
1✔
337
                $query2 = $this->query;
1✔
338
                ksort($query2);
1✔
339
                $host = rtrim($url->host, '.');
1✔
340
                $host2 = rtrim($this->host, '.');
1✔
341
                return $url->scheme === $this->scheme
1✔
342
                        && (!strcasecmp($host, $host2)
1✔
343
                                || self::idnHostToUnicode($host) === self::idnHostToUnicode($host2))
1✔
344
                        && $url->getPort() === $this->getPort()
1✔
345
                        && $url->user === $this->user
1✔
346
                        && $url->password === $this->password
1✔
347
                        && self::unescape($url->path, '%/') === self::unescape($this->path, '%/')
1✔
348
                        && $query === $query2
1✔
349
                        && $url->fragment === $this->fragment;
1✔
350
        }
351

352

353
        /**
354
         * Normalizes the URL to canonical form: percent-encodes path, lowercases and trims the host,
355
         * and converts IDN ASCII to Unicode.
356
         */
357
        public function canonicalize(): static
358
        {
359
                $this->path = preg_replace_callback(
1✔
360
                        '#[^!$&\'()*+,/:;=@%"]+#',
1✔
361
                        fn(array $m): string => rawurlencode($m[0]),
1✔
362
                        self::unescape($this->path, '%/'),
1✔
363
                );
364
                $this->host = rtrim($this->host, '.');
1✔
365
                $this->host = self::idnHostToUnicode(strtolower($this->host));
1✔
366
                return $this;
1✔
367
        }
368

369

370
        public function __toString(): string
371
        {
372
                return $this->getAbsoluteUrl();
1✔
373
        }
374

375

376
        public function jsonSerialize(): string
377
        {
378
                return $this->getAbsoluteUrl();
1✔
379
        }
380

381

382
        /** @internal */
383
        final public function export(): array
384
        {
385
                return [$this->scheme, $this->user, $this->password, $this->host, $this->port, $this->path, $this->query, $this->fragment];
1✔
386
        }
387

388

389
        /**
390
         * Converts IDN ASCII host to UTF-8.
391
         */
392
        private static function idnHostToUnicode(string $host): string
1✔
393
        {
394
                if (!str_contains($host, '--')) { // host does not contain IDN
1✔
395
                        return $host;
1✔
396
                }
397

398
                if (function_exists('idn_to_utf8') && defined('INTL_IDNA_VARIANT_UTS46')) {
1✔
399
                        return idn_to_utf8($host, IDNA_DEFAULT, INTL_IDNA_VARIANT_UTS46) ?: $host;
1✔
400
                }
401

402
                trigger_error('PHP extension intl is not loaded or is too old', E_USER_WARNING);
×
403
                return $host;
×
404
        }
405

406

407
        /**
408
         * Decodes percent-encoded characters, but keeps reserved characters (specified in $reserved) encoded.
409
         */
410
        public static function unescape(string $s, string $reserved = '%;/?:@&=+$,'): string
1✔
411
        {
412
                // reserved (@see RFC 2396) = ";" | "/" | "?" | ":" | "@" | "&" | "=" | "+" | "$" | ","
413
                // within a path segment, the characters "/", ";", "=", "?" are reserved
414
                // within a query component, the characters ";", "/", "?", ":", "@", "&", "=", "+", ",", "$" are reserved.
415
                if ($reserved !== '') {
1✔
416
                        $s = preg_replace_callback(
1✔
417
                                '#%(' . substr(chunk_split(bin2hex($reserved), 2, '|'), 0, -1) . ')#i',
1✔
418
                                fn(array $m): string => '%25' . strtoupper($m[1]),
1✔
419
                                $s,
1✔
420
                        ) ?? throw new \LogicException('Regular expression failed in unescape()');
×
421
                }
422

423
                return rawurldecode($s);
1✔
424
        }
425

426

427
        /**
428
         * Parses query string. Is affected by directive arg_separator.input.
429
         * @return mixed[]
430
         */
431
        public static function parseQuery(string $s): array
1✔
432
        {
433
                $s = str_replace(['%5B', '%5b'], '[', $s);
1✔
434
                $sep = preg_quote(ini_get('arg_separator.input') ?: '&');
1✔
435
                $s = preg_replace("#([$sep])([^[$sep=]+)([^$sep]*)#", '&0[$2]$3', '&' . $s) ?? throw new \LogicException('Regular expression failed in parseQuery()');
1✔
436
                parse_str($s, $res);
1✔
437
                return (array) ($res[0] ?? []);
1✔
438
        }
439

440

441
        /**
442
         * Checks whether the URL is absolute, i.e. starts with a scheme followed by a colon.
443
         */
444
        public static function isAbsolute(string $url): bool
1✔
445
        {
446
                return (bool) preg_match('#^[a-z][a-z0-9+.-]*:#i', $url);
1✔
447
        }
448

449

450
        /**
451
         * Resolves dot segments (. and ..) in a URL path, as per RFC 3986.
452
         */
453
        public static function removeDotSegments(string $path): string
1✔
454
        {
455
                $prefix = $segment = '';
1✔
456
                if (str_starts_with($path, '/')) {
1✔
457
                        $prefix = '/';
1✔
458
                        $path = substr($path, 1);
1✔
459
                }
460
                $segments = explode('/', $path);
1✔
461
                $res = [];
1✔
462
                foreach ($segments as $segment) {
1✔
463
                        if ($segment === '..') {
1✔
464
                                array_pop($res);
1✔
465
                        } elseif ($segment !== '.') {
1✔
466
                                $res[] = $segment;
1✔
467
                        }
468
                }
469

470
                if ($segment === '.' || $segment === '..') {
1✔
471
                        $res[] = '';
1✔
472
                }
473
                return $prefix . implode('/', $res);
1✔
474
        }
475
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc