Passed
Push — master ( 90372d...e80252 )
by Nikolay
25:24
created

Uri::parseUri()   C

Complexity

Conditions 10
Paths 257

Size

Total Lines 20
Code Lines 13

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 13
dl 0
loc 20
c 0
b 0
f 0
rs 6.1208
cc 10
nc 257
nop 1

How to fix   Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
/**
3
 * @see       https://github.com/zendframework/zend-diactoros for the canonical source repository
4
 * @copyright Copyright (c) 2015-2018 Zend Technologies USA Inc. (http://www.zend.com)
5
 * @license   https://github.com/zendframework/zend-diactoros/blob/master/LICENSE.md New BSD License
6
 */
7
8
declare(strict_types=1);
9
10
namespace Zend\Diactoros;
11
12
use Psr\Http\Message\UriInterface;
13
14
use function array_key_exists;
15
use function array_keys;
16
use function count;
17
use function explode;
18
use function get_class;
19
use function gettype;
20
use function implode;
21
use function is_numeric;
22
use function is_object;
23
use function is_string;
24
use function ltrim;
25
use function parse_url;
26
use function preg_replace;
27
use function preg_replace_callback;
28
use function rawurlencode;
29
use function sprintf;
30
use function strpos;
31
use function strtolower;
32
use function substr;
33
34
/**
35
 * Implementation of Psr\Http\UriInterface.
36
 *
37
 * Provides a value object representing a URI for HTTP requests.
38
 *
39
 * Instances of this class  are considered immutable; all methods that
40
 * might change state are implemented such that they retain the internal
41
 * state of the current instance and return a new instance that contains the
42
 * changed state.
43
 */
44
class Uri implements UriInterface
45
{
46
    /**
47
     * Sub-delimiters used in user info, query strings and fragments.
48
     *
49
     * @const string
50
     */
51
    const CHAR_SUB_DELIMS = '!\$&\'\(\)\*\+,;=';
52
53
    /**
54
     * Unreserved characters used in user info, paths, query strings, and fragments.
55
     *
56
     * @const string
57
     */
58
    const CHAR_UNRESERVED = 'a-zA-Z0-9_\-\.~\pL';
59
60
    /**
61
     * @var int[] Array indexed by valid scheme names to their corresponding ports.
62
     */
63
    protected $allowedSchemes = [
64
        'http'  => 80,
65
        'https' => 443,
66
    ];
67
68
    /**
69
     * @var string
70
     */
71
    private $scheme = '';
72
73
    /**
74
     * @var string
75
     */
76
    private $userInfo = '';
77
78
    /**
79
     * @var string
80
     */
81
    private $host = '';
82
83
    /**
84
     * @var int
85
     */
86
    private $port;
87
88
    /**
89
     * @var string
90
     */
91
    private $path = '';
92
93
    /**
94
     * @var string
95
     */
96
    private $query = '';
97
98
    /**
99
     * @var string
100
     */
101
    private $fragment = '';
102
103
    /**
104
     * generated uri string cache
105
     * @var string|null
106
     */
107
    private $uriString;
108
109
    public function __construct(string $uri = '')
110
    {
111
        if ('' === $uri) {
112
            return;
113
        }
114
115
        $this->parseUri($uri);
116
    }
117
118
    /**
119
     * Operations to perform on clone.
120
     *
121
     * Since cloning usually is for purposes of mutation, we reset the
122
     * $uriString property so it will be re-calculated.
123
     */
124
    public function __clone()
125
    {
126
        $this->uriString = null;
127
    }
128
129
    /**
130
     * {@inheritdoc}
131
     */
132
    public function __toString() : string
133
    {
134
        if (null !== $this->uriString) {
135
            return $this->uriString;
136
        }
137
138
        $this->uriString = static::createUriString(
139
            $this->scheme,
140
            $this->getAuthority(),
141
            $this->getPath(), // Absolute URIs should use a "/" for an empty path
142
            $this->query,
143
            $this->fragment
144
        );
145
146
        return $this->uriString;
147
    }
148
149
    /**
150
     * {@inheritdoc}
151
     */
152
    public function getScheme() : string
153
    {
154
        return $this->scheme;
155
    }
156
157
    /**
158
     * {@inheritdoc}
159
     */
160
    public function getAuthority() : string
161
    {
162
        if ('' === $this->host) {
163
            return '';
164
        }
165
166
        $authority = $this->host;
167
        if ('' !== $this->userInfo) {
168
            $authority = $this->userInfo . '@' . $authority;
169
        }
170
171
        if ($this->isNonStandardPort($this->scheme, $this->host, $this->port)) {
172
            $authority .= ':' . $this->port;
173
        }
174
175
        return $authority;
176
    }
177
178
    /**
179
     * Retrieve the user-info part of the URI.
180
     *
181
     * This value is percent-encoded, per RFC 3986 Section 3.2.1.
182
     *
183
     * {@inheritdoc}
184
     */
185
    public function getUserInfo() : string
186
    {
187
        return $this->userInfo;
188
    }
189
190
    /**
191
     * {@inheritdoc}
192
     */
193
    public function getHost() : string
194
    {
195
        return $this->host;
196
    }
197
198
    /**
199
     * {@inheritdoc}
200
     */
201
    public function getPort() : ?int
202
    {
203
        return $this->isNonStandardPort($this->scheme, $this->host, $this->port)
204
            ? $this->port
205
            : null;
206
    }
207
208
    /**
209
     * {@inheritdoc}
210
     */
211
    public function getPath() : string
212
    {
213
        return $this->path;
214
    }
215
216
    /**
217
     * {@inheritdoc}
218
     */
219
    public function getQuery() : string
220
    {
221
        return $this->query;
222
    }
223
224
    /**
225
     * {@inheritdoc}
226
     */
227
    public function getFragment() : string
228
    {
229
        return $this->fragment;
230
    }
231
232
    /**
233
     * {@inheritdoc}
234
     */
235
    public function withScheme($scheme) : UriInterface
236
    {
237
        if (! is_string($scheme)) {
238
            throw new Exception\InvalidArgumentException(sprintf(
239
                '%s expects a string argument; received %s',
240
                __METHOD__,
241
                is_object($scheme) ? get_class($scheme) : gettype($scheme)
242
            ));
243
        }
244
245
        $scheme = $this->filterScheme($scheme);
246
247
        if ($scheme === $this->scheme) {
248
            // Do nothing if no change was made.
249
            return $this;
250
        }
251
252
        $new = clone $this;
253
        $new->scheme = $scheme;
254
255
        return $new;
256
    }
257
258
    /**
259
     * Create and return a new instance containing the provided user credentials.
260
     *
261
     * The value will be percent-encoded in the new instance, but with measures
262
     * taken to prevent double-encoding.
263
     *
264
     * {@inheritdoc}
265
     */
266
    public function withUserInfo($user, $password = null) : UriInterface
267
    {
268
        if (! is_string($user)) {
269
            throw new Exception\InvalidArgumentException(sprintf(
270
                '%s expects a string user argument; received %s',
271
                __METHOD__,
272
                is_object($user) ? get_class($user) : gettype($user)
273
            ));
274
        }
275
        if (null !== $password && ! is_string($password)) {
276
            throw new Exception\InvalidArgumentException(sprintf(
277
                '%s expects a string or null password argument; received %s',
278
                __METHOD__,
279
                is_object($password) ? get_class($password) : gettype($password)
280
            ));
281
        }
282
283
        $info = $this->filterUserInfoPart($user);
284
        if (null !== $password) {
285
            $info .= ':' . $this->filterUserInfoPart($password);
286
        }
287
288
        if ($info === $this->userInfo) {
289
            // Do nothing if no change was made.
290
            return $this;
291
        }
292
293
        $new = clone $this;
294
        $new->userInfo = $info;
295
296
        return $new;
297
    }
298
299
    /**
300
     * {@inheritdoc}
301
     */
302
    public function withHost($host) : UriInterface
303
    {
304
        if (! is_string($host)) {
305
            throw new Exception\InvalidArgumentException(sprintf(
306
                '%s expects a string argument; received %s',
307
                __METHOD__,
308
                is_object($host) ? get_class($host) : gettype($host)
309
            ));
310
        }
311
312
        if ($host === $this->host) {
313
            // Do nothing if no change was made.
314
            return $this;
315
        }
316
317
        $new = clone $this;
318
        $new->host = strtolower($host);
319
320
        return $new;
321
    }
322
323
    /**
324
     * {@inheritdoc}
325
     */
326
    public function withPort($port) : UriInterface
327
    {
328
        if ($port !== null) {
329
            if (! is_numeric($port) || is_float($port)) {
330
                throw new Exception\InvalidArgumentException(sprintf(
331
                    'Invalid port "%s" specified; must be an integer, an integer string, or null',
332
                    is_object($port) ? get_class($port) : gettype($port)
333
                ));
334
            }
335
336
            $port = (int) $port;
337
        }
338
339
        if ($port === $this->port) {
340
            // Do nothing if no change was made.
341
            return $this;
342
        }
343
344
        if ($port !== null && ($port < 1 || $port > 65535)) {
345
            throw new Exception\InvalidArgumentException(sprintf(
346
                'Invalid port "%d" specified; must be a valid TCP/UDP port',
347
                $port
348
            ));
349
        }
350
351
        $new = clone $this;
352
        $new->port = $port;
353
354
        return $new;
355
    }
356
357
    /**
358
     * {@inheritdoc}
359
     */
360
    public function withPath($path) : UriInterface
361
    {
362
        if (! is_string($path)) {
363
            throw new Exception\InvalidArgumentException(
364
                'Invalid path provided; must be a string'
365
            );
366
        }
367
368
        if (strpos($path, '?') !== false) {
369
            throw new Exception\InvalidArgumentException(
370
                'Invalid path provided; must not contain a query string'
371
            );
372
        }
373
374
        if (strpos($path, '#') !== false) {
375
            throw new Exception\InvalidArgumentException(
376
                'Invalid path provided; must not contain a URI fragment'
377
            );
378
        }
379
380
        $path = $this->filterPath($path);
381
382
        if ($path === $this->path) {
383
            // Do nothing if no change was made.
384
            return $this;
385
        }
386
387
        $new = clone $this;
388
        $new->path = $path;
389
390
        return $new;
391
    }
392
393
    /**
394
     * {@inheritdoc}
395
     */
396
    public function withQuery($query) : UriInterface
397
    {
398
        if (! is_string($query)) {
399
            throw new Exception\InvalidArgumentException(
400
                'Query string must be a string'
401
            );
402
        }
403
404
        if (strpos($query, '#') !== false) {
405
            throw new Exception\InvalidArgumentException(
406
                'Query string must not include a URI fragment'
407
            );
408
        }
409
410
        $query = $this->filterQuery($query);
411
412
        if ($query === $this->query) {
413
            // Do nothing if no change was made.
414
            return $this;
415
        }
416
417
        $new = clone $this;
418
        $new->query = $query;
419
420
        return $new;
421
    }
422
423
    /**
424
     * {@inheritdoc}
425
     */
426
    public function withFragment($fragment) : UriInterface
427
    {
428
        if (! is_string($fragment)) {
429
            throw new Exception\InvalidArgumentException(sprintf(
430
                '%s expects a string argument; received %s',
431
                __METHOD__,
432
                is_object($fragment) ? get_class($fragment) : gettype($fragment)
433
            ));
434
        }
435
436
        $fragment = $this->filterFragment($fragment);
437
438
        if ($fragment === $this->fragment) {
439
            // Do nothing if no change was made.
440
            return $this;
441
        }
442
443
        $new = clone $this;
444
        $new->fragment = $fragment;
445
446
        return $new;
447
    }
448
449
    /**
450
     * Parse a URI into its parts, and set the properties
451
     */
452
    private function parseUri(string $uri) : void
453
    {
454
        $parts = parse_url($uri);
455
456
        if (false === $parts) {
457
            throw new Exception\InvalidArgumentException(
458
                'The source URI string appears to be malformed'
459
            );
460
        }
461
462
        $this->scheme    = isset($parts['scheme']) ? $this->filterScheme($parts['scheme']) : '';
463
        $this->userInfo  = isset($parts['user']) ? $this->filterUserInfoPart($parts['user']) : '';
464
        $this->host      = isset($parts['host']) ? strtolower($parts['host']) : '';
465
        $this->port      = isset($parts['port']) ? $parts['port'] : null;
466
        $this->path      = isset($parts['path']) ? $this->filterPath($parts['path']) : '';
467
        $this->query     = isset($parts['query']) ? $this->filterQuery($parts['query']) : '';
468
        $this->fragment  = isset($parts['fragment']) ? $this->filterFragment($parts['fragment']) : '';
469
470
        if (isset($parts['pass'])) {
471
            $this->userInfo .= ':' . $parts['pass'];
472
        }
473
    }
474
475
    /**
476
     * Create a URI string from its various parts
477
     */
478
    private static function createUriString(
479
        string $scheme,
480
        string $authority,
481
        string $path,
482
        string $query,
483
        string $fragment
484
    ) : string {
485
        $uri = '';
486
487
        if ('' !== $scheme) {
488
            $uri .= sprintf('%s:', $scheme);
489
        }
490
491
        if ('' !== $authority) {
492
            $uri .= '//' . $authority;
493
        }
494
495
        if ('' !== $path && '/' !== substr($path, 0, 1)) {
496
            $path = '/' . $path;
497
        }
498
499
        $uri .= $path;
500
501
502
        if ('' !== $query) {
503
            $uri .= sprintf('?%s', $query);
504
        }
505
506
        if ('' !== $fragment) {
507
            $uri .= sprintf('#%s', $fragment);
508
        }
509
510
        return $uri;
511
    }
512
513
    /**
514
     * Is a given port non-standard for the current scheme?
515
     */
516
    private function isNonStandardPort(string $scheme, string $host, ?int $port) : bool
517
    {
518
        if ('' === $scheme) {
519
            return '' === $host || null !== $port;
520
        }
521
522
        if ('' === $host || null === $port) {
523
            return false;
524
        }
525
526
        return ! isset($this->allowedSchemes[$scheme]) || $port !== $this->allowedSchemes[$scheme];
527
    }
528
529
    /**
530
     * Filters the scheme to ensure it is a valid scheme.
531
     *
532
     * @param string $scheme Scheme name.
533
     * @return string Filtered scheme.
534
     */
535
    private function filterScheme(string $scheme) : string
536
    {
537
        $scheme = strtolower($scheme);
538
        $scheme = preg_replace('#:(//)?$#', '', $scheme);
539
540
        if ('' === $scheme) {
541
            return '';
542
        }
543
544
        if (! isset($this->allowedSchemes[$scheme])) {
545
            throw new Exception\InvalidArgumentException(sprintf(
546
                'Unsupported scheme "%s"; must be any empty string or in the set (%s)',
547
                $scheme,
548
                implode(', ', array_keys($this->allowedSchemes))
549
            ));
550
        }
551
552
        return $scheme;
553
    }
554
555
    /**
556
     * Filters a part of user info in a URI to ensure it is properly encoded.
557
     *
558
     * @param string $part
559
     * @return string
560
     */
561
    private function filterUserInfoPart(string $part) : string
562
    {
563
        // Note the addition of `%` to initial charset; this allows `|` portion
564
        // to match and thus prevent double-encoding.
565
        return preg_replace_callback(
566
            '/(?:[^%' . self::CHAR_UNRESERVED . self::CHAR_SUB_DELIMS . ']+|%(?![A-Fa-f0-9]{2}))/u',
567
            [$this, 'urlEncodeChar'],
568
            $part
569
        );
570
    }
571
572
    /**
573
     * Filters the path of a URI to ensure it is properly encoded.
574
     */
575
    private function filterPath(string $path) : string
576
    {
577
        $path = preg_replace_callback(
578
            '/(?:[^' . self::CHAR_UNRESERVED . ')(:@&=\+\$,\/;%]+|%(?![A-Fa-f0-9]{2}))/u',
579
            [$this, 'urlEncodeChar'],
580
            $path
581
        );
582
583
        if ('' === $path) {
584
            // No path
585
            return $path;
586
        }
587
588
        if ($path[0] !== '/') {
589
            // Relative path
590
            return $path;
591
        }
592
593
        // Ensure only one leading slash, to prevent XSS attempts.
594
        return '/' . ltrim($path, '/');
595
    }
596
597
    /**
598
     * Filter a query string to ensure it is propertly encoded.
599
     *
600
     * Ensures that the values in the query string are properly urlencoded.
601
     */
602
    private function filterQuery(string $query) : string
603
    {
604
        if ('' !== $query && strpos($query, '?') === 0) {
605
            $query = substr($query, 1);
606
        }
607
608
        $parts = explode('&', $query);
609
        foreach ($parts as $index => $part) {
610
            [$key, $value] = $this->splitQueryValue($part);
611
            if ($value === null) {
612
                $parts[$index] = $this->filterQueryOrFragment($key);
613
                continue;
614
            }
615
            $parts[$index] = sprintf(
616
                '%s=%s',
617
                $this->filterQueryOrFragment($key),
618
                $this->filterQueryOrFragment($value)
619
            );
620
        }
621
622
        return implode('&', $parts);
623
    }
624
625
    /**
626
     * Split a query value into a key/value tuple.
627
     *
628
     * @param string $value
629
     * @return array A value with exactly two elements, key and value
630
     */
631
    private function splitQueryValue(string $value) : array
632
    {
633
        $data = explode('=', $value, 2);
634
        if (! isset($data[1])) {
635
            $data[] = null;
636
        }
637
        return $data;
638
    }
639
640
    /**
641
     * Filter a fragment value to ensure it is properly encoded.
642
     */
643
    private function filterFragment(string $fragment) : string
644
    {
645
        if ('' !== $fragment && strpos($fragment, '#') === 0) {
646
            $fragment = '%23' . substr($fragment, 1);
647
        }
648
649
        return $this->filterQueryOrFragment($fragment);
650
    }
651
652
    /**
653
     * Filter a query string key or value, or a fragment.
654
     */
655
    private function filterQueryOrFragment(string $value) : string
656
    {
657
        return preg_replace_callback(
658
            '/(?:[^' . self::CHAR_UNRESERVED . self::CHAR_SUB_DELIMS . '%:@\/\?]+|%(?![A-Fa-f0-9]{2}))/u',
659
            [$this, 'urlEncodeChar'],
660
            $value
661
        );
662
    }
663
664
    /**
665
     * URL encode a character returned by a regex.
666
     */
667
    private function urlEncodeChar(array $matches) : string
668
    {
669
        return rawurlencode($matches[0]);
670
    }
671
}
672