Completed
Push — master ( 444d64...4e01e9 )
by Aurimas
02:07
created

Uri::withAddedQueryValues()   B

Complexity

Conditions 5
Paths 4

Size

Total Lines 20
Code Lines 11

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 11
CRAP Score 5
Metric Value
dl 0
loc 20
ccs 11
cts 11
cp 1
rs 8.8571
cc 5
eloc 11
nc 4
nop 2
crap 5
1
<?php
2
3
namespace Thruster\Component\HttpMessage;
4
5
use Psr\Http\Message\UriInterface;
6
7
/**
8
 * Class Uri
9
 *
10
 * @package Thruster\Component\HttpMessage
11
 * @author  Aurimas Niekis <[email protected]>
12
 */
13
class Uri implements UriInterface
14
{
15
    const CHAR_UNRESERVED = 'a-zA-Z0-9_\-\.~';
16
    const CHAR_SUB_DELIMS = '!\$&\'\(\)\*\+,;=';
17
    const REPLACE_QUERY = ['=' => '%3D', '&' => '%26'];
18
    const SCHEMES = [
19
        'http'  => 80,
20
        'https' => 443,
21
    ];
22
23
    /**
24
     * @var string Uri scheme.
25
     */
26
    private $scheme;
27
28
    /**
29
     * @var string Uri user info.
30
     */
31
    private $userInfo;
32
33
    /**
34
     * @var string Uri host.
35
     */
36
    private $host;
37
38
    /**
39
     * @var int Uri port.
40
     */
41
    private $port;
42
43
    /**
44
     * @var string Uri path.
45
     */
46
    private $path;
47
48
    /**
49
     * @var string Uri query string.
50
     */
51
    private $query;
52
53
    /**
54
     * @var string Uri fragment.
55
     */
56
    private $fragment;
57
58
    /**
59
     * @param string $uri URI to parse and wrap.
60
     */
61 99
    public function __construct(string $uri = '')
62
    {
63 99
        $this->scheme   = '';
64 99
        $this->userInfo = '';
65 99
        $this->host     = '';
66 99
        $this->path     = '';
67 99
        $this->query    = '';
68 99
        $this->fragment = '';
69
70 99
        if (null !== $uri) {
71 99
            $parts = parse_url($uri);
72
73 99
            if (false === $parts) {
74 2
                throw new \InvalidArgumentException("Unable to parse URI: $uri");
75
            }
76
77 97
            $this->applyParts($parts);
78
        }
79 97
    }
80
81 59
    public function __toString() : string
82
    {
83 59
        return self::createUriString(
84 59
            $this->scheme,
85 59
            $this->getAuthority(),
86 59
            $this->getPath(),
87 59
            $this->query,
88 59
            $this->fragment
89
        );
90
    }
91
92
    /**
93
     * Removes dot segments from a path and returns the new path.
94
     *
95
     * @param string $path
96
     *
97
     * @return string
98
     * @link http://tools.ietf.org/html/rfc3986#section-5.2.4
99
     */
100 36
    public static function removeDotSegments(string $path) : string
101
    {
102 36
        static $noopPaths = ['' => true, '/' => true, '*' => true];
103 36
        static $ignoreSegments = ['.' => true, '..' => true];
104
105 36
        if (isset($noopPaths[$path])) {
106 1
            return $path;
107
        }
108
109 35
        $results  = [];
110 35
        $segments = explode('/', $path);
111 35
        foreach ($segments as $segment) {
112 35
            if ($segment == '..') {
113 12
                array_pop($results);
114 35
            } elseif (false === isset($ignoreSegments[$segment])) {
115 35
                $results[] = $segment;
116
            }
117
        }
118
119 35
        $newPath = implode('/', $results);
120
        // Add the leading slash if necessary
121 35
        if (substr($path, 0, 1) === '/' &&
122 35
            substr($newPath, 0, 1) !== '/'
123
        ) {
124 4
            $newPath = '/' . $newPath;
125
        }
126
127
        // Add the trailing slash if necessary
128 35
        if ($newPath != '/' && isset($ignoreSegments[end($segments)])) {
129 5
            $newPath .= '/';
130
        }
131
132 35
        return $newPath;
133
    }
134
135
    /**
136
     * Resolve a base URI with a relative URI and return a new URI.
137
     *
138
     * @param UriInterface $base Base URI
139
     * @param string       $rel  Relative URI
140
     *
141
     * @return UriInterface
142
     */
143 39
    public static function resolve(UriInterface $base, string $rel) : UriInterface
144
    {
145 39
        if (null === $rel || '' === $rel) {
146 1
            return $base;
147
        }
148
149 38
        if (false === ($rel instanceof UriInterface)) {
150 38
            $rel = new self($rel);
151
        }
152
153
        // Return the relative uri as-is if it has a scheme.
154 38
        if ($rel->getScheme()) {
155
            return $rel->withPath(static::removeDotSegments($rel->getPath()));
156
        }
157
158
        $relParts = [
159 38
            'scheme'    => $rel->getScheme(),
160 38
            'authority' => $rel->getAuthority(),
161 38
            'path'      => $rel->getPath(),
162 38
            'query'     => $rel->getQuery(),
163 38
            'fragment'  => $rel->getFragment()
164
        ];
165
166
        $parts = [
167 38
            'scheme'    => $base->getScheme(),
168 38
            'authority' => $base->getAuthority(),
169 38
            'path'      => $base->getPath(),
170 38
            'query'     => $base->getQuery(),
171 38
            'fragment'  => $base->getFragment()
172
        ];
173
174 38
        if (false === empty($relParts['authority'])) {
175 1
            $parts['authority'] = $relParts['authority'];
176 1
            $parts['path']      = self::removeDotSegments($relParts['path']);
177 1
            $parts['query']     = $relParts['query'];
178 1
            $parts['fragment']  = $relParts['fragment'];
179 37
        } elseif (false === empty($relParts['path'])) {
180 35
            if ('/' === substr($relParts['path'], 0, 1)) {
181 3
                $parts['path']     = self::removeDotSegments($relParts['path']);
182 3
                $parts['query']    = $relParts['query'];
183 3
                $parts['fragment'] = $relParts['fragment'];
184
            } else {
185 32
                if (false === empty($parts['authority']) && empty($parts['path'])) {
186
                    $mergedPath = '/';
187
                } else {
188 32
                    $mergedPath = substr($parts['path'], 0, strrpos($parts['path'], '/') + 1);
189
                }
190
191 32
                $parts['path']     = self::removeDotSegments($mergedPath . $relParts['path']);
192 32
                $parts['query']    = $relParts['query'];
193 35
                $parts['fragment'] = $relParts['fragment'];
194
            }
195 2
        } elseif (false === empty($relParts['query'])) {
196 1
            $parts['query'] = $relParts['query'];
197 1
        } elseif (null != $relParts['fragment']) {
198 1
            $parts['fragment'] = $relParts['fragment'];
199
        }
200
201 38
        return new self(static::createUriString(
0 ignored issues
show
Bug introduced by
Since createUriString() is declared private, calling it with static will lead to errors in possible sub-classes. You can either use self, or increase the visibility of createUriString() to at least protected.

Let’s assume you have a class which uses late-static binding:

class YourClass
{
    private static function getTemperature() {
        return "3422 °C";
}

public static function getSomeVariable()
{
    return static::getTemperature();
}

}

The code above will run fine in your PHP runtime. However, if you now create a sub-class and call the getSomeVariable() on that sub-class, you will receive a runtime error:

class YourSubClass extends YourClass {
      private static function getTemperature() {
        return "-182 °C";
    }
}

print YourSubClass::getSomeVariable(); // Will cause an access error.

In the case above, it makes sense to update SomeClass to use self instead:

class YourClass
{
    private static function getTemperature() {
        return "3422 °C";
    }

    public static function getSomeVariable()
    {
        return self::getTemperature();
    }
}
Loading history...
202 38
            $parts['scheme'],
203 38
            $parts['authority'],
204 38
            $parts['path'],
205 38
            $parts['query'],
206 38
            $parts['fragment']
207
        ));
208
    }
209
210
    /**
211
     * Create a new URI with a specific query string value removed.
212
     *
213
     * Any existing query string values that exactly match the provided key are
214
     * removed.
215
     *
216
     * Note: this function will convert "=" to "%3D" and "&" to "%26".
217
     *
218
     * @param UriInterface $uri URI to use as a base.
219
     * @param string       $key Query string key value pair to remove.
220
     *
221
     * @return UriInterface
222
     */
223 1
    public static function withoutQueryValue(UriInterface $uri, string $key) : UriInterface
224
    {
225 1
        $current = $uri->getQuery();
226 1
        if (!$current) {
227 1
            return $uri;
228
        }
229
230 1
        $result = [];
231 1 View Code Duplication
        foreach (explode('&', $current) as $part) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
232 1
            if ($key !== explode('=', $part)[0]) {
233 1
                $result[] = $part;
234
            };
235
        }
236
237 1
        return $uri->withQuery(implode('&', $result));
238
    }
239
240
    /**
241
     * Create a new URI with a specific query string value.
242
     *
243
     * Any existing query string values that exactly match the provided key are
244
     * removed and replaced with the given key value pair.
245
     *
246
     * Note: this function will convert "=" to "%3D" and "&" to "%26".
247
     *
248
     * @param UriInterface $uri   URI to use as a base.
249
     * @param string       $key   Key to set.
250
     * @param string       $value Value to set.
251
     *
252
     * @return UriInterface
253
     */
254 2
    public static function withQueryValue(UriInterface $uri, string $key, string $value = null) : UriInterface
255
    {
256 2
        $current = $uri->getQuery();
257 2
        $key     = strtr($key, static::REPLACE_QUERY);
258
259 2
        if (!$current) {
260 2
            $result = [];
261
        } else {
262 2
            $result = [];
263 2 View Code Duplication
            foreach (explode('&', $current) as $part) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
264 2
                if (explode('=', $part)[0] !== $key) {
265 2
                    $result[] = $part;
266
                };
267
            }
268
        }
269
270 2
        if ($value !== null) {
271 2
            $result[] = $key . '=' . strtr($value, static::REPLACE_QUERY);
272
        } else {
273 1
            $result[] = $key;
274
        }
275
276 2
        return $uri->withQuery(implode('&', $result));
277
    }
278
279 2
    public static function withAddedQueryValues(UriInterface $uri, array $queryValues) : UriInterface
280
    {
281 2
        $current = $uri->getQuery();
282 2
        if (false === empty($current)) {
283 2
            parse_str($current, $values);
284
285 2
            foreach ($values as $key => $value) {
0 ignored issues
show
Bug introduced by
The expression $values of type null|array is not guaranteed to be traversable. How about adding an additional type check?

There are different options of fixing this problem.

  1. If you want to be on the safe side, you can add an additional type-check:

    $collection = json_decode($data, true);
    if ( ! is_array($collection)) {
        throw new \RuntimeException('$collection must be an array.');
    }
    
    foreach ($collection as $item) { /** ... */ }
    
  2. If you are sure that the expression is traversable, you might want to add a doc comment cast to improve IDE auto-completion and static analysis:

    /** @var array $collection */
    $collection = json_decode($data, true);
    
    foreach ($collection as $item) { /** .. */ }
    
  3. Mark the issue as a false-positive: Just hover the remove button, in the top-right corner of this issue for more options.

Loading history...
286 2
                if (false === isset($queryValues[$key])) {
287 2
                    $queryValues[$key] = $value;
288
                }
289
            }
290
        }
291
292 2
        $result = [];
293 2
        foreach ($queryValues as $key => $value) {
294 2
            $result[] = $key . '=' . $value;
295
        }
296
297 2
        return $uri->withQuery(implode('&', $result));
298
    }
299
300
    /**
301
     * Create a URI from a hash of parse_url parts.
302
     *
303
     * @param array $parts
304
     *
305
     * @return self
306
     */
307
    public static function fromParts(array $parts) : self
308
    {
309
        $uri = new self();
310
        $uri->applyParts($parts);
311
312
        return $uri;
313
    }
314
315 40
    public function getScheme() : string
316
    {
317 40
        return $this->scheme;
318
    }
319
320 61
    public function getAuthority() : string
321
    {
322 61
        if (empty($this->host)) {
323 46
            return '';
324
        }
325
326 54
        $authority = $this->host;
327 54
        if (false === empty($this->userInfo)) {
328 4
            $authority = $this->userInfo . '@' . $authority;
329
        }
330
331 54
        if ($this->isNonStandardPort($this->scheme, $this->host, $this->port)) {
332 3
            $authority .= ':' . $this->port;
333
        }
334
335 54
        return $authority;
336
    }
337
338 2
    public function getUserInfo() : string
339
    {
340 2
        return $this->userInfo;
341
    }
342
343 29
    public function getHost() : string
344
    {
345 29
        return $this->host;
346
    }
347
348 14
    public function getPort()
349
    {
350 14
        return $this->port;
351
    }
352
353 63
    public function getPath() : string
354
    {
355 63
        return $this->path ?? '';
356
    }
357
358 47
    public function getQuery() : string
359
    {
360 47
        return $this->query;
361
    }
362
363 41
    public function getFragment() : string
364
    {
365 41
        return $this->fragment;
366
    }
367
368 7
    public function withScheme($scheme) : self
369
    {
370 7
        $scheme = $this->filterScheme($scheme);
371
372 7
        if ($scheme === $this->scheme) {
373
            return $this;
374
        }
375
376 7
        $new         = clone $this;
377 7
        $new->scheme = $scheme;
378 7
        $new->port   = $new->filterPort($new->scheme, $new->host, $new->port);
379
380 7
        return $new;
381
    }
382
383 1
    public function withUserInfo($user, $password = null) : self
384
    {
385 1
        $info = $user;
386 1
        if (null !== $password) {
387 1
            $info .= ':' . $password;
388
        }
389
390 1
        if ($info === $this->userInfo) {
391
            return $this;
392
        }
393
394 1
        $new           = clone $this;
395 1
        $new->userInfo = $info;
396
397 1
        return $new;
398
    }
399
400 8
    public function withHost($host) : self
401
    {
402 8
        if ($host === $this->host) {
403
            return $this;
404
        }
405
406 8
        $new       = clone $this;
407 8
        $new->host = $host;
408
409 8
        return $new;
410
    }
411
412 8
    public function withPort($port) : self
413
    {
414 8
        $port = $this->filterPort($this->scheme, $this->host, $port);
415
416 7
        if ($port === $this->port) {
417 5
            return $this;
418
        }
419
420 2
        $new       = clone $this;
421 2
        $new->port = $port;
422
423 2
        return $new;
424
    }
425
426 10
    public function withPath($path) : self
427
    {
428 10
        if (false === is_string($path)) {
429 1
            throw new \InvalidArgumentException(
430 1
                'Invalid path provided; must be a string'
431
            );
432
        }
433
434 9
        $path = $this->filterPath($path);
435
436 9
        if ($path === $this->path) {
437
            return $this;
438
        }
439
440 9
        $new       = clone $this;
441 9
        $new->path = $path;
442
443 9
        return $new;
444
    }
445
446 11
    public function withQuery($query) : self
447
    {
448 11
        if (false === is_string($query) && false === method_exists($query, '__toString')) {
449 1
            throw new \InvalidArgumentException(
450 1
                'Query string must be a string'
451
            );
452
        }
453
454 10
        $query = (string) $query;
455 10
        if ('?' === substr($query, 0, 1)) {
456 1
            $query = substr($query, 1);
457
        }
458
459 10
        $query = $this->filterQueryAndFragment($query);
460
461 10
        if ($query === $this->query) {
462 1
            return $this;
463
        }
464
465 9
        $new        = clone $this;
466 9
        $new->query = $query;
467
468 9
        return $new;
469
    }
470
471 1
    public function withFragment($fragment) : self
472
    {
473 1
        if ('#' === substr($fragment, 0, 1)) {
474 1
            $fragment = substr($fragment, 1);
475
        }
476
477 1
        $fragment = $this->filterQueryAndFragment($fragment);
478
479 1
        if ($fragment === $this->fragment) {
480
            return $this;
481
        }
482
483 1
        $new           = clone $this;
484 1
        $new->fragment = $fragment;
485
486 1
        return $new;
487
    }
488
489
    /**
490
     * Apply parse_url parts to a URI.
491
     *
492
     * @param array $parts Array of parse_url parts to apply.
493
     */
494 97
    private function applyParts(array $parts)
495
    {
496 97
        $this->scheme   = isset($parts['scheme'])
497 75
            ? $this->filterScheme($parts['scheme'])
498 70
            : '';
499 97
        $this->userInfo = isset($parts['user']) ? $parts['user'] : '';
500 97
        $this->host     = isset($parts['host']) ? $parts['host'] : '';
501 97
        $this->port     = !empty($parts['port'])
502 6
            ? $this->filterPort($this->scheme, $this->host, $parts['port'])
503 94
            : null;
504 97
        $this->path     = isset($parts['path'])
505 92
            ? $this->filterPath($parts['path'])
506 9
            : '';
507 97
        $this->query    = isset($parts['query'])
508 53
            ? $this->filterQueryAndFragment($parts['query'])
509 86
            : '';
510 97
        $this->fragment = isset($parts['fragment'])
511 6
            ? $this->filterQueryAndFragment($parts['fragment'])
512 96
            : '';
513 97
        if (isset($parts['pass'])) {
514 2
            $this->userInfo .= ':' . $parts['pass'];
515
        }
516 97
    }
517
518
    /**
519
     * Create a URI string from its various parts
520
     *
521
     * @param string $scheme
522
     * @param string $authority
523
     * @param string $path
524
     * @param string $query
525
     * @param string $fragment
526
     *
527
     * @return string
528
     */
529 59
    private static function createUriString(
530
        string $scheme,
531
        string $authority,
532
        string $path,
533
        string $query,
534
        string $fragment
535
    ) : string {
536 59
        $uri = '';
537
538 59
        if (false === empty($scheme)) {
539 54
            $uri .= $scheme . ':';
540
        }
541
542 59
        $hierPart = '';
543
544 59
        if (false === empty($authority)) {
545 52
            if (false === empty($scheme)) {
546 50
                $hierPart .= '//';
547
            }
548
549 52
            $hierPart .= $authority;
550
        }
551
552 59
        if (null != $path) {
553
            // Add a leading slash if necessary.
554 56
            if ($hierPart && '/' !== substr($path, 0, 1)) {
555 1
                $hierPart .= '/';
556
            }
557
558 56
            $hierPart .= $path;
559
        }
560
561 59
        $uri .= $hierPart;
562
563 59
        if (null != $query) {
564 9
            $uri .= '?' . $query;
565
        }
566
567 59
        if (null != $fragment) {
568 6
            $uri .= '#' . $fragment;
569
        }
570
571 59
        return $uri;
572
    }
573
574
    /**
575
     * Is a given port non-standard for the current scheme?
576
     *
577
     * @param string $scheme
578
     * @param string $host
579
     * @param int    $port
580
     *
581
     * @return bool
582
     */
583 62
    private static function isNonStandardPort(string $scheme = null, string $host = null, int $port = null) : bool
584
    {
585 62
        if (null === $scheme && $port) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $port of type null|integer is loosely compared to true; this is ambiguous if the integer can be zero. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For integer values, zero is a special case, in particular the following results might be unexpected:

0   == false // true
0   == null  // true
123 == false // false
123 == null  // false

// It is often better to use strict comparison
0 === false // false
0 === null  // false
Loading history...
586
            return true;
587
        }
588
589 62
        if (null === $host || null === $port) {
590 58
            return false;
591
        }
592
593 12
        return false === isset(static::SCHEMES[$scheme]) || $port !== static::SCHEMES[$scheme];
594
    }
595
596
    /**
597
     * @param string $scheme
598
     *
599
     * @return string
600
     */
601 76
    private function filterScheme(string $scheme) : string
602
    {
603 76
        $scheme = strtolower($scheme);
604 76
        $scheme = rtrim($scheme, ':/');
605
606 76
        return $scheme;
607
    }
608
609
    /**
610
     * @param string $scheme
611
     * @param string $host
612
     * @param int    $port
613
     *
614
     * @return int|null
615
     *
616
     * @throws \InvalidArgumentException If the port is invalid.
617
     */
618 13
    private function filterPort(string $scheme, string $host, int $port = null)
619
    {
620 13
        if (null !== $port) {
621 13
            $port = (int) $port;
622 13
            if (1 > $port || 0xffff < $port) {
623 1
                throw new \InvalidArgumentException(
624 1
                    sprintf('Invalid port: %d. Must be between 1 and 65535', $port)
625
                );
626
            }
627
        }
628
629 12
        return $this->isNonStandardPort($scheme, $host, $port) ? $port : null;
630
    }
631
632
    /**
633
     * Filters the path of a URI
634
     *
635
     * @param $path
636
     *
637
     * @return string
638
     */
639 92 View Code Duplication
    private function filterPath(string $path) : string
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
640
    {
641 92
        return preg_replace_callback(
642 92
            '/(?:[^' . static::CHAR_UNRESERVED . static::CHAR_SUB_DELIMS . ':@\/%]+|%(?![A-Fa-f0-9]{2}))/',
643 92
            [$this, 'rawurlencodeMatchZero'],
644
            $path
645
        );
646
    }
647
648
    /**
649
     * Filters the query string or fragment of a URI.
650
     *
651
     * @param $str
652
     *
653
     * @return string
654
     */
655 57 View Code Duplication
    private function filterQueryAndFragment(string $str) : string
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
656
    {
657 57
        return preg_replace_callback(
658 57
            '/(?:[^' . static::CHAR_UNRESERVED . static::CHAR_SUB_DELIMS . '%:@\/\?]+|%(?![A-Fa-f0-9]{2}))/',
659 57
            [$this, 'rawurlencodeMatchZero'],
660
            $str
661
        );
662
    }
663
664 4
    private function rawurlencodeMatchZero(array $match) : string
665
    {
666 4
        return rawurlencode($match[0]);
667
    }
668
}
669