Passed
Push — master ( ad65b5...642648 )
by Evgeniy
14:43
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 171
    public function __construct(string $uri = '')
77
    {
78 171
        if ($uri === '') {
79 168
            return;
80
        }
81
82 17
        if (($uri = parse_url($uri)) === false) {
83 6
            throw new InvalidArgumentException('The source URI string appears to be malformed.');
84
        }
85
86 11
        $this->scheme = isset($uri['scheme']) ? $this->normalizeScheme($uri['scheme']) : '';
87 11
        $this->userInfo = isset($uri['user']) ? $this->normalizeUserInfo($uri['user'], $uri['pass'] ?? null) : '';
88 11
        $this->host = isset($uri['host']) ? $this->normalizeHost($uri['host']) : '';
89 11
        $this->port = isset($uri['port']) ? $this->normalizePort($uri['port']) : null;
90 11
        $this->path = isset($uri['path']) ? $this->normalizePath($uri['path']) : '';
91 11
        $this->query = isset($uri['query']) ? $this->normalizeQuery($uri['query']) : '';
92 11
        $this->fragment = isset($uri['fragment']) ? $this->normalizeFragment($uri['fragment']) : '';
93 11
    }
94
95
    /**
96
     * When cloning resets the URI string representation cache.
97
     */
98 18
    public function __clone()
99
    {
100 18
        $this->cache = null;
101 18
    }
102
103
    /**
104
     * {@inheritDoc}
105
     */
106 11
    public function __toString(): string
107
    {
108 11
        if (is_string($this->cache)) {
109 4
            return $this->cache;
110
        }
111
112 11
        $this->cache = '';
113
114 11
        if ($this->scheme) {
115 8
            $this->cache .= $this->scheme . ':';
116
        }
117
118 11
        if ($authority = $this->getAuthority()) {
119 9
            $this->cache .= '//' . $authority;
120
        }
121
122 11
        if ($this->path) {
123 5
            $this->cache .= $authority ? '/' . ltrim($this->path, '/') : $this->path;
124
        }
125
126 11
        if ($this->query) {
127 4
            $this->cache .= '?' . $this->query;
128
        }
129
130 11
        if ($this->fragment) {
131 4
            $this->cache .= '#' . $this->fragment;
132
        }
133
134 11
        return $this->cache;
0 ignored issues
show
Bug Best Practice introduced by devanych
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 6
    public function getScheme(): string
141
    {
142 6
        return $this->scheme;
143
    }
144
145
    /**
146
     * {@inheritDoc}
147
     *
148
     * @psalm-suppress PossiblyNullOperand
149
     */
150 11
    public function getAuthority(): string
151
    {
152 11
        if (!$authority = $this->host) {
153 3
            return '';
154
        }
155
156 9
        if ($this->userInfo) {
157 1
            $authority = $this->userInfo . '@' . $authority;
158
        }
159
160 9
        if ($this->isNotStandardPort()) {
161 2
            $authority .= ':' . $this->port;
162
        }
163
164 9
        return $authority;
165
    }
166
167
    /**
168
     * {@inheritDoc}
169
     */
170 5
    public function getUserInfo(): string
171
    {
172 5
        return $this->userInfo;
173
    }
174
175
    /**
176
     * {@inheritDoc}
177
     */
178 70
    public function getHost(): string
179
    {
180 70
        return $this->host;
181
    }
182
183
    /**
184
     * {@inheritDoc}
185
     */
186 10
    public function getPort(): ?int
187
    {
188 10
        return $this->isNotStandardPort() ? $this->port : null;
189
    }
190
191
    /**
192
     * {@inheritDoc}
193
     */
194 11
    public function getPath(): string
195
    {
196 11
        return $this->path;
197
    }
198
199
    /**
200
     * {@inheritDoc}
201
     */
202 6
    public function getQuery(): string
203
    {
204 6
        return $this->query;
205
    }
206
207
    /**
208
     * {@inheritDoc}
209
     */
210 6
    public function getFragment(): string
211
    {
212 6
        return $this->fragment;
213
    }
214
215
    /**
216
     * {@inheritDoc}
217
     */
218 19
    public function withScheme($scheme): self
219
    {
220 19
        $this->checkStringType($scheme, 'scheme', __METHOD__);
221 11
        $schema = $this->normalizeScheme($scheme);
222
223 4
        if ($schema === $this->scheme) {
224 1
            return $this;
225
        }
226
227 3
        $new = clone $this;
228 3
        $new->scheme = $schema;
229 3
        return $new;
230
    }
231
232
    /**
233
     * {@inheritDoc}
234
     */
235 18
    public function withUserInfo($user, $password = null): self
236
    {
237 18
        $this->checkStringType($user, 'user', __METHOD__);
238
239 10
        if ($password !== null) {
240 8
            $this->checkStringType($password, 'or null password', __METHOD__);
241
        }
242
243 3
        $userInfo = $this->normalizeUserInfo($user, $password);
244
245 3
        if ($userInfo === $this->userInfo) {
246 1
            return $this;
247
        }
248
249 2
        $new = clone $this;
250 2
        $new->userInfo = $userInfo;
251 2
        return $new;
252
    }
253
254
    /**
255
     * {@inheritDoc}
256
     */
257 14
    public function withHost($host): self
258
    {
259 14
        $this->checkStringType($host, 'host', __METHOD__);
260 6
        $host = $this->normalizeHost($host);
261
262 6
        if ($host === $this->host) {
263 1
            return $this;
264
        }
265
266 5
        $new = clone $this;
267 5
        $new->host = $host;
268 5
        return $new;
269
    }
270
271
    /**
272
     * {@inheritDoc}
273
     */
274 16
    public function withPort($port): self
275
    {
276 16
        $port = $this->normalizePort($port);
277
278 6
        if ($port === $this->port) {
279 1
            return $this;
280
        }
281
282 5
        $new = clone $this;
283 5
        $new->port = $port;
284 5
        return $new;
285
    }
286
287
    /**
288
     * {@inheritDoc}
289
     */
290 13
    public function withPath($path): self
291
    {
292 13
        $this->checkStringType($path, 'path', __METHOD__);
293 5
        $path = $this->normalizePath($path);
294
295 5
        if ($path === $this->path) {
296 2
            return $this;
297
        }
298
299 3
        $new = clone $this;
300 3
        $new->path = $path;
301 3
        return $new;
302
    }
303
304
    /**
305
     * {@inheritDoc}
306
     */
307 20
    public function withQuery($query): self
308
    {
309 20
        $this->checkStringType($query, 'query string', __METHOD__);
310 4
        $query = $this->normalizeQuery($query);
311
312 4
        if ($query === $this->query) {
313 2
            return $this;
314
        }
315
316 2
        $new = clone $this;
317 2
        $new->query = $query;
318 2
        return $new;
319
    }
320
321
    /**
322
     * {@inheritDoc}
323
     */
324 4
    public function withFragment($fragment): self
325
    {
326 4
        $this->checkStringType($fragment, 'URI fragment', __METHOD__);
327 4
        $fragment = $this->normalizeFragment($fragment);
328
329 4
        if ($fragment === $this->fragment) {
330 2
            return $this;
331
        }
332
333 2
        $new = clone $this;
334 2
        $new->fragment = $fragment;
335 2
        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 20
    private function normalizeScheme(string $scheme): string
346
    {
347 20
        if (!$scheme = preg_replace('#:(//)?$#', '', strtolower($scheme))) {
348 1
            return '';
349
        }
350
351 19
        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 12
        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 4
    private function normalizeUserInfo(string $user, ?string $pass = null): string
370
    {
371 4
        if (!$user) {
372 1
            return '';
373
        }
374
375 3
        $pattern = '/(?:[^a-zA-Z0-9_\-\.~!\$&\'\(\)\*\+,;=]+|%(?![A-Fa-f0-9]{2}))/u';
376 3
        $userInfo = $this->encode($user, $pattern);
377
378 3
        if ($pass !== null) {
379 2
            $userInfo .= ':' . $this->encode($pass, $pattern);
380
        }
381
382 3
        return $userInfo;
383
    }
384
385
    /**
386
     * Normalize the host component of the URI.
387
     *
388
     * @param string $host
389
     * @return string
390
     */
391 14
    private function normalizeHost(string $host): string
392
    {
393 14
        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 17
    private function normalizePort($port): ?int
404
    {
405 17
        if ($port === null) {
406 1
            return null;
407
        }
408
409 16
        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 8
        $port = (int) $port;
417
418 8
        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 6
        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 11
    private function normalizePath(string $path): string
436
    {
437 11
        if ($path === '' || $path === '/') {
438 1
            return $path;
439
        }
440
441 10
        $path = $this->encode($path, '/(?:[^a-zA-Z0-9_\-\.~:@&=\+\$,\/;%]+|%(?![A-Fa-f0-9]{2}))/');
442 10
        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 7
    private function normalizeQuery(string $query): string
453
    {
454 7
        if (!$query = ltrim($query, '?')) {
455 1
            return '';
456
        }
457
458 6
        return $this->encode($query, '/(?:[^a-zA-Z0-9_\-\.~!\$&\'\(\)\*\+,;=%:@\/\?]+|%(?![A-Fa-f0-9]{2}))/');
459
    }
460
461
    /**
462
     * Normalize the fragment component of the URI.
463
     *
464
     * @param string $fragment
465
     * @return string
466
     */
467 7
    private function normalizeFragment(string $fragment): string
468
    {
469 7
        if (!$fragment = ltrim($fragment, '#')) {
470 1
            return '';
471
        }
472
473 6
        return $this->encode($fragment, '/(?:[^a-zA-Z0-9_\-\.~!\$&\'\(\)\*\+,;=%:@\/\?]+|%(?![A-Fa-f0-9]{2}))/');
474
    }
475
476
    /**
477
     * Percent encodes all reserved characters in the provided string according to the provided pattern.
478
     * Characters that are already encoded as a percentage will not be re-encoded.
479
     *
480
     * @link https://tools.ietf.org/html/rfc3986
481
     *
482
     * @param string $string
483
     * @param string $pattern
484
     * @return string
485
     * @psalm-suppress MixedArgument
486
     */
487 16
    private function encode(string $string, string $pattern): string
488
    {
489 16
        return (string) preg_replace_callback($pattern, fn(array $matches) => rawurlencode($matches[0]), $string);
490
    }
491
492
    /**
493
     * Is this a non-standard port for the scheme.
494
     *
495
     * @return bool
496
     */
497 15
    private function isNotStandardPort(): bool
498
    {
499 15
        if ($this->port === null) {
500 11
            return false;
501
        }
502
503 6
        return (!isset(self::SCHEMES[$this->port]) || $this->scheme !== self::SCHEMES[$this->port]);
504
    }
505
506
    /**
507
     * Checks whether the value being passed is a string.
508
     *
509
     * @param mixed $value
510
     * @param string $phrase
511
     * @param string $method
512
     * @throws InvalidArgumentException for not string types.
513
     */
514 86
    private function checkStringType($value, string $phrase, string $method): void
515
    {
516 86
        if (!is_string($value)) {
517 55
            throw new InvalidArgumentException(sprintf(
518
                '"%s" method expects a string type %s. "%s" received.',
519 55
                $method,
520 55
                $phrase,
521 55
                (is_object($value) ? get_class($value) : gettype($value))
522
            ));
523
        }
524 38
    }
525
}
526