Uri::normalizePath()   A
last analyzed

Complexity

Conditions 3
Paths 2

Size

Total Lines 7
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 4
CRAP Score 3

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 3
eloc 3
c 1
b 0
f 0
nc 2
nop 1
dl 0
loc 7
ccs 4
cts 4
cp 1
crap 3
rs 10
1
<?php
2
3
declare(strict_types=1);
4
5
namespace HttpSoft\Message;
6
7
use InvalidArgumentException;
8
use Psr\Http\Message\UriInterface;
9
10
use function implode;
11
use function get_class;
12
use function gettype;
13
use function in_array;
14
use function is_float;
15
use function is_numeric;
16
use function is_object;
17
use function is_string;
18
use function ltrim;
19
use function parse_url;
20
use function preg_replace;
21
use function preg_replace_callback;
22
use function rawurlencode;
23
use function sprintf;
24
use function strtolower;
25
use function strtr;
26
27
final class Uri implements UriInterface
28
{
29
    /**
30
     * Standard ports and supported schemes.
31
     */
32
    private const SCHEMES = [80 => 'http', 443 => 'https'];
33
34
    /**
35
     * @var string
36
     */
37
    private string $scheme = '';
38
39
    /**
40
     * @var string
41
     */
42
    private string $userInfo = '';
43
44
    /**
45
     * @var string
46
     */
47
    private string $host = '';
48
49
    /**
50
     * @var int|null
51
     */
52
    private ?int $port = null;
53
54
    /**
55
     * @var string
56
     */
57
    private string $path = '';
58
59
    /**
60
     * @var string
61
     */
62
    private string $query = '';
63
64
    /**
65
     * @var string
66
     */
67
    private string $fragment = '';
68
69
    /**
70
     * @var string|null
71
     */
72
    private ?string $cache = null;
73
74
    /**
75
     * @param string $uri
76
     */
77 354
    public function __construct(string $uri = '')
78
    {
79 354
        if ($uri === '') {
80 246
            return;
81
        }
82
83 199
        if (($uri = parse_url($uri)) === false) {
84 6
            throw new InvalidArgumentException('The source URI string appears to be malformed.');
85
        }
86
87 193
        $this->scheme = isset($uri['scheme']) ? $this->normalizeScheme($uri['scheme']) : '';
88 193
        $this->userInfo = isset($uri['user']) ? $this->normalizeUserInfo($uri['user'], $uri['pass'] ?? null) : '';
89 193
        $this->host = isset($uri['host']) ? $this->normalizeHost($uri['host']) : '';
90 193
        $this->port = isset($uri['port']) ? $this->normalizePort($uri['port']) : null;
91 193
        $this->path = isset($uri['path']) ? $this->normalizePath($uri['path']) : '';
92 193
        $this->query = isset($uri['query']) ? $this->normalizeQuery($uri['query']) : '';
93 193
        $this->fragment = isset($uri['fragment']) ? $this->normalizeFragment($uri['fragment']) : '';
94
    }
95
96
    /**
97
     * When cloning resets the URI string representation cache.
98
     */
99 24
    public function __clone()
100
    {
101 24
        $this->cache = null;
102
    }
103
104
    /**
105
     * {@inheritDoc}
106
     */
107 19
    public function __toString(): string
108
    {
109 19
        if (is_string($this->cache)) {
110 4
            return $this->cache;
111
        }
112
113 19
        $this->cache = '';
114
115 19
        if ($this->scheme !== '') {
116 14
            $this->cache .= $this->scheme . ':';
117
        }
118
119 19
        if (($authority = $this->getAuthority()) !== '') {
120 16
            $this->cache .= '//' . $authority;
121
        }
122
123 19
        if ($this->path !== '') {
124 13
            if ($authority === '') {
125
                // If the path is starting with more than one "/" and no authority is present,
126
                // the starting slashes MUST be reduced to one.
127 3
                $this->cache .= $this->path[0] === '/' ? '/' . ltrim($this->path, '/') : $this->path;
128
            } else {
129
                // If the path is rootless and an authority is present, the path MUST be prefixed by "/".
130 12
                $this->cache .= $this->path[0] === '/' ? $this->path : '/' . $this->path;
131
            }
132
        }
133
134 19
        if ($this->query !== '') {
135 6
            $this->cache .= '?' . $this->query;
136
        }
137
138 19
        if ($this->fragment !== '') {
139 6
            $this->cache .= '#' . $this->fragment;
140
        }
141
142 19
        return $this->cache;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this->cache returns the type null which is incompatible with the type-hinted return string.
Loading history...
143
    }
144
145
    /**
146
     * {@inheritDoc}
147
     */
148 7
    public function getScheme(): string
149
    {
150 7
        return $this->scheme;
151
    }
152
153
    /**
154
     * {@inheritDoc}
155
     *
156
     * @psalm-suppress PossiblyNullOperand
157
     */
158 20
    public function getAuthority(): string
159
    {
160 20
        if (($authority = $this->host) === '') {
161 7
            return '';
162
        }
163
164 17
        if ($this->userInfo !== '') {
165 5
            $authority = $this->userInfo . '@' . $authority;
166
        }
167
168 17
        if ($this->isNotStandardPort()) {
169 5
            $authority .= ':' . $this->port;
170
        }
171
172 17
        return $authority;
173
    }
174
175
    /**
176
     * {@inheritDoc}
177
     */
178 8
    public function getUserInfo(): string
179
    {
180 8
        return $this->userInfo;
181
    }
182
183
    /**
184
     * {@inheritDoc}
185
     */
186 237
    public function getHost(): string
187
    {
188 237
        return $this->host;
189
    }
190
191
    /**
192
     * {@inheritDoc}
193
     */
194 92
    public function getPort(): ?int
195
    {
196 92
        return $this->isNotStandardPort() ? $this->port : null;
197
    }
198
199
    /**
200
     * {@inheritDoc}
201
     */
202 27
    public function getPath(): string
203
    {
204 27
        if ($this->path === '' || $this->path === '/') {
205 10
            return $this->path;
206
        }
207
208 18
        if ($this->path[0] !== '/') {
209
            // If the path is rootless and an authority is present, the path MUST be prefixed by "/".
210 3
            return $this->host === '' ? $this->path : '/' . $this->path;
211
        }
212
213 15
        return '/' . ltrim($this->path, '/');
214
    }
215
216
    /**
217
     * {@inheritDoc}
218
     */
219 21
    public function getQuery(): string
220
    {
221 21
        return $this->query;
222
    }
223
224
    /**
225
     * {@inheritDoc}
226
     */
227 10
    public function getFragment(): string
228
    {
229 10
        return $this->fragment;
230
    }
231
232
    /**
233
     * {@inheritDoc}
234
     */
235 25
    public function withScheme($scheme): UriInterface
236
    {
237 25
        $this->checkStringType($scheme, 'scheme', __METHOD__);
238 13
        $schema = $this->normalizeScheme($scheme);
239
240 6
        if ($schema === $this->scheme) {
241 1
            return $this;
242
        }
243
244 5
        $new = clone $this;
245 5
        $new->scheme = $schema;
246 5
        return $new;
247
    }
248
249
    /**
250
     * {@inheritDoc}
251
     */
252 21
    public function withUserInfo($user, $password = null): UriInterface
253
    {
254 21
        $this->checkStringType($user, 'user', __METHOD__);
255
256 13
        if ($password !== null) {
257 11
            $this->checkStringType($password, 'or null password', __METHOD__);
258
        }
259
260 6
        $userInfo = $this->normalizeUserInfo($user, $password);
261
262 6
        if ($userInfo === $this->userInfo) {
263 1
            return $this;
264
        }
265
266 5
        $new = clone $this;
267 5
        $new->userInfo = $userInfo;
268 5
        return $new;
269
    }
270
271
    /**
272
     * {@inheritDoc}
273
     */
274 16
    public function withHost($host): UriInterface
275
    {
276 16
        $this->checkStringType($host, 'host', __METHOD__);
277 8
        $host = $this->normalizeHost($host);
278
279 8
        if ($host === $this->host) {
280 1
            return $this;
281
        }
282
283 7
        $new = clone $this;
284 7
        $new->host = $host;
285 7
        return $new;
286
    }
287
288
    /**
289
     * {@inheritDoc}
290
     */
291 17
    public function withPort($port): UriInterface
292
    {
293 17
        $port = $this->normalizePort($port);
294
295 7
        if ($port === $this->port) {
296 1
            return $this;
297
        }
298
299 6
        $new = clone $this;
300 6
        $new->port = $port;
301 6
        return $new;
302
    }
303
304
    /**
305
     * {@inheritDoc}
306
     */
307 16
    public function withPath($path): UriInterface
308
    {
309 16
        $this->checkStringType($path, 'path', __METHOD__);
310 8
        $path = $this->normalizePath($path);
311
312 8
        if ($path === $this->path) {
313 2
            return $this;
314
        }
315
316 6
        $new = clone $this;
317 6
        $new->path = $path;
318 6
        return $new;
319
    }
320
321
    /**
322
     * {@inheritDoc}
323
     */
324 21
    public function withQuery($query): UriInterface
325
    {
326 21
        $this->checkStringType($query, 'query string', __METHOD__);
327 5
        $query = $this->normalizeQuery($query);
328
329 5
        if ($query === $this->query) {
330 2
            return $this;
331
        }
332
333 3
        $new = clone $this;
334 3
        $new->query = $query;
335 3
        return $new;
336
    }
337
338
    /**
339
     * {@inheritDoc}
340
     */
341 5
    public function withFragment($fragment): UriInterface
342
    {
343 5
        $this->checkStringType($fragment, 'URI fragment', __METHOD__);
344 5
        $fragment = $this->normalizeFragment($fragment);
345
346 5
        if ($fragment === $this->fragment) {
347 2
            return $this;
348
        }
349
350 3
        $new = clone $this;
351 3
        $new->fragment = $fragment;
352 3
        return $new;
353
    }
354
355
    /**
356
     * Normalize the scheme component of the URI.
357
     *
358
     * @param string $scheme
359
     * @return string
360
     * @throws InvalidArgumentException for invalid or unsupported schemes.
361
     */
362 111
    private function normalizeScheme(string $scheme): string
363
    {
364 111
        if (!$scheme = preg_replace('#:(//)?$#', '', strtolower($scheme))) {
365 1
            return '';
366
        }
367
368 110
        if (!in_array($scheme, self::SCHEMES, true)) {
369 7
            throw new InvalidArgumentException(sprintf(
370 7
                'Unsupported scheme "%s". It must be an empty string or any of "%s".',
371 7
                $scheme,
372 7
                implode('", "', self::SCHEMES)
373 7
            ));
374
        }
375
376 103
        return $scheme;
377
    }
378
379
    /**
380
     * Normalize the user information component of the URI.
381
     *
382
     * @param string $user
383
     * @param string|null $pass
384
     * @return string
385
     */
386 11
    private function normalizeUserInfo(string $user, ?string $pass = null): string
387
    {
388 11
        if ($user === '') {
389 1
            return '';
390
        }
391
392 10
        $pattern = '/(?:[^%a-zA-Z0-9_\-\.~\pL!\$&\'\(\)\*\+,;=]+|%(?![A-Fa-f0-9]{2}))/u';
393 10
        $userInfo = $this->encode($user, $pattern);
394
395 10
        if ($pass !== null) {
396 9
            $userInfo .= ':' . $this->encode($pass, $pattern);
397
        }
398
399 10
        return $userInfo;
400
    }
401
402
    /**
403
     * Normalize the host component of the URI.
404
     *
405
     * @param string $host
406
     * @return string
407
     */
408 105
    private function normalizeHost(string $host): string
409
    {
410 105
        return strtr($host, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz');
411
    }
412
413
    /**
414
     * Normalize the port component of the URI.
415
     *
416
     * @param mixed $port
417
     * @return int|null
418
     * @throws InvalidArgumentException for invalid ports.
419
     */
420 22
    private function normalizePort($port): ?int
421
    {
422 22
        if ($port === null) {
423 1
            return null;
424
        }
425
426 21
        if (!is_numeric($port) || is_float($port)) {
427 8
            throw new InvalidArgumentException(sprintf(
428 8
                'Invalid port "%s" specified. It must be an integer, an integer string, or null.',
429 8
                (is_object($port) ? get_class($port) : gettype($port))
430 8
            ));
431
        }
432
433 13
        $port = (int) $port;
434
435 13
        if ($port < 1 || $port > 65535) {
436 2
            throw new InvalidArgumentException(sprintf(
437 2
                'Invalid port "%d" specified. It must be a valid TCP/UDP port in range 1..65535.',
438 2
                $port
439 2
            ));
440
        }
441
442 11
        return $port;
443
    }
444
445
    /**
446
     * Normalize the path component of the URI.
447
     *
448
     * @param string $path
449
     * @return string
450
     * @throws InvalidArgumentException for invalid paths.
451
     */
452 196
    private function normalizePath(string $path): string
453
    {
454 196
        if ($path === '' || $path === '/') {
455 177
            return $path;
456
        }
457
458 29
        return $this->encode($path, '/(?:[^a-zA-Z0-9_\-\.~!\$&\'\(\)\*\+,;=%:@\/]++|%(?![A-Fa-f0-9]{2}))/');
459
    }
460
461
    /**
462
     * Normalize the query string of the URI.
463
     *
464
     * @param string $query
465
     * @return string
466
     * @throws InvalidArgumentException for invalid query strings.
467
     */
468 12
    private function normalizeQuery(string $query): string
469
    {
470 12
        if ($query === '' || $query === '?') {
471 1
            return '';
472
        }
473
474 11
        if ($query[0] === '?') {
475 1
            $query = ltrim($query, '?');
476
        }
477
478 11
        return $this->encode($query, '/(?:[^a-zA-Z0-9_\-\.~!\$&\'\(\)\*\+,;=%:@\/\?]++|%(?![A-Fa-f0-9]{2}))/');
479
    }
480
481
    /**
482
     * Normalize the fragment component of the URI.
483
     *
484
     * @param string $fragment
485
     * @return string
486
     */
487 9
    private function normalizeFragment(string $fragment): string
488
    {
489 9
        if ($fragment === '' || $fragment === '#') {
490 1
            return '';
491
        }
492
493 8
        if ($fragment[0] === '#') {
494 1
            $fragment = ltrim($fragment, '#');
495
        }
496
497 8
        return $this->encode($fragment, '/(?:[^a-zA-Z0-9_\-\.~!\$&\'\(\)\*\+,;=%:@\/\?]++|%(?![A-Fa-f0-9]{2}))/');
498
    }
499
500
    /**
501
     * Percent encodes all reserved characters in the provided string according to the provided pattern.
502
     * Characters that are already encoded as a percentage will not be re-encoded.
503
     *
504
     * @link https://tools.ietf.org/html/rfc3986
505
     *
506
     * @param string $string
507
     * @param string $pattern
508
     * @return string
509
     *
510
     * @psalm-param non-empty-string $pattern
511
     */
512 39
    private function encode(string $string, string $pattern): string
513
    {
514 39
        return (string) preg_replace_callback(
515 39
            $pattern,
516 39
            static fn (array $matches) => rawurlencode($matches[0]),
517 39
            $string,
518 39
        );
519
    }
520
521
    /**
522
     * Is this a non-standard port for the scheme.
523
     *
524
     * @return bool
525
     */
526 103
    private function isNotStandardPort(): bool
527
    {
528 103
        if ($this->port === null) {
529 96
            return false;
530
        }
531
532 11
        return (!isset(self::SCHEMES[$this->port]) || $this->scheme !== self::SCHEMES[$this->port]);
533
    }
534
535
    /**
536
     * Checks whether the value being passed is a string.
537
     *
538
     * @param mixed $value
539
     * @param string $phrase
540
     * @param string $method
541
     * @throws InvalidArgumentException for not string types.
542
     */
543 96
    private function checkStringType($value, string $phrase, string $method): void
544
    {
545 96
        if (!is_string($value)) {
546 59
            throw new InvalidArgumentException(sprintf(
547 59
                '"%s" method expects a string type %s. "%s" received.',
548 59
                $method,
549 59
                $phrase,
550 59
                (is_object($value) ? get_class($value) : gettype($value))
551 59
            ));
552
        }
553
    }
554
}
555