Passed
Push — master ( df82e6...21de27 )
by Evgeniy
01:40
created

src/Uri.php (1 issue)

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