Passed
Push — main ( cd5116...99c066 )
by Dimitri
12:52
created

Uri::setURI()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 13
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 4
CRAP Score 3

Importance

Changes 0
Metric Value
cc 3
eloc 6
nc 3
nop 1
dl 0
loc 13
ccs 4
cts 4
cp 1
crap 3
rs 10
c 0
b 0
f 0
1
<?php
2
3
/**
4
 * This file is part of Blitz PHP framework.
5
 *
6
 * (c) 2022 Dimitri Sitchet Tomkeu <[email protected]>
7
 *
8
 * For the full copyright and license information, please view
9
 * the LICENSE file that was distributed with this source code.
10
 */
11
12
namespace BlitzPHP\Http;
13
14
use BlitzPHP\Exceptions\FrameworkException;
15
use InvalidArgumentException;
16
use Psr\Http\Message\UriInterface;
17
18
/**
19
 * Abstraction pour un identificateur de ressource uniforme (URI).
20
 *
21
 * @credit CodeIgniter 4 <a href="https://codeigniter.com">CodeIgniter\HTTP\URI</a>
22
 */
23
class Uri implements UriInterface
24
{
25
    /**
26
     * Sous-délimiteurs utilisés dans les chaînes de requête et les fragments.
27
     */
28
    public const CHAR_SUB_DELIMS = '!\$&\'\(\)\*\+,;=';
29
30
    /**
31
     * Caractères non réservés utilisés dans les chemins, les chaînes de requête et les fragments.
32
     */
33
    public const CHAR_UNRESERVED = 'a-zA-Z0-9_\-\.~';
34
35
    /**
36
     * Chaîne d'URI actuelle
37
     *
38
     * @var string
39
     */
40
    protected $uriString;
41
42
    /**
43
     * Liste des segments d'URI.
44
     *
45
     * Commence à 1 au lieu de 0
46
     *
47
     * @var array
48
     */
49
    protected $segments = [];
50
51
    /**
52
     * Schéma
53
     *
54
     * @var string
55
     */
56
    protected $scheme = 'http';
57
58
    /**
59
     * Informations utilisateur
60
     *
61
     * @var string
62
     */
63
    protected $user = '';
64
65
    /**
66
     * Mot de passe
67
     *
68
     * @var string
69
     */
70
    protected $password = '';
71
72
    /**
73
     * Hôte
74
     *
75
     * @var string
76
     */
77
    protected $host = '';
78
79
    /**
80
     * Port
81
     *
82
     * @var int
83
     */
84
    protected $port = 80;
85
86
    /**
87
     * Chemin.
88
     *
89
     * @var string
90
     */
91
    protected $path = '';
92
93
    /**
94
     * Le nom de n'importe quel fragment.
95
     *
96
     * @var string
97
     */
98
    protected $fragment = '';
99
100
    /**
101
     * La chaîne de requête.
102
     *
103
     * @var array
104
     */
105
    protected $query = [];
106
107
    /**
108
     * Default schemes/ports.
109
     *
110
     * @var array
111
     */
112
    protected $defaultPorts = [
113
        'http'  => 80,
114
        'https' => 443,
115
        'ftp'   => 21,
116
        'sftp'  => 22,
117
    ];
118
119
    /**
120
     * Indique si les mots de passe doivent être affichés dans les appels userInfo/authority.
121
     * La valeur par défaut est false car les URI apparaissent souvent dans les journaux
122
     *
123
     * @var bool
124
     */
125
    protected $showPassword = false;
126
127
    /**
128
     * Constructeur.
129
     *
130
     * @throws InvalidArgumentException
131
     */
132
    public function __construct(?string $uri = null)
133
    {
134 6
        $this->setURI($uri);
135 6
        $this->port = $_SERVER['SERVER_PORT'] ?? 80;
136
    }
137
138
    /**
139
     * Définit et écrase toute information URI actuelle.
140
     */
141
    public function setURI(?string $uri = null): self
142
    {
143
        if (null !== $uri) {
144 6
            $parts = parse_url($uri);
145
146
            if ($parts === false) {
147 6
                throw new FrameworkException('Impossible de parser l\'URI "' . $uri . '"');
148
            }
149
150 6
            $this->applyParts($parts);
151
        }
152
153 6
        return $this;
154
    }
155
156
    /**
157
     * {@inheritDoc}
158
     */
159
    public function getScheme(): string
160
    {
161 6
        return $this->scheme;
162
    }
163
164
    /**
165
     * {@inheritDoc}
166
     */
167
    public function getAuthority(bool $ignorePort = false): string
168
    {
169
        if (empty($this->host)) {
170 6
            return '';
171
        }
172
173 6
        $authority = $this->host;
174
175
        if (! empty($this->getUserInfo())) {
176 6
            $authority = $this->getUserInfo() . '@' . $authority;
177
        }
178
179
        if (! empty($this->port) && ! $ignorePort) {
180
            // N'ajoute pas de port s'il s'agit d'un port standard pour ce schéma
181
            if ($this->port !== $this->defaultPorts[$this->scheme]) {
182 6
                $authority .= ':' . $this->port;
183
            }
184
        }
185
186 6
        $this->showPassword = false;
187
188 6
        return $authority;
189
    }
190
191
    /**
192
     * {@inheritDoc}
193
     */
194
    public function getUserInfo(): string
195
    {
196 6
        $userInfo = $this->user;
197
198
        if ($this->showPassword === true && ! empty($this->password)) {
199 6
            $userInfo .= ':' . $this->password;
200
        }
201
202 6
        return $userInfo;
203
    }
204
205
    /**
206
     * Définit temporairement l'URI pour afficher un mot de passe dans userInfo.
207
     * Se réinitialisera après le premier appel à l'autorité().
208
     */
209
    public function showPassword(bool $val = true): self
210
    {
211
        $this->showPassword = $val;
212
213
        return $this;
214
    }
215
216
    /**
217
     * {@inheritDoc}
218
     */
219
    public function getHost(): string
220
    {
221
        return $this->host;
222
    }
223
224
    /**
225
     * {@inheritDoc}
226
     */
227
    public function getPort(): int
228
    {
229
        return $this->port;
230
    }
231
232
    /**
233
     * {@inheritDoc}
234
     */
235
    public function getPath(): string
236
    {
237 6
        return (null === $this->path) ? '' : $this->path;
238
    }
239
240
    /**
241
     * {@inheritDoc}
242
     */
243
    public function getQuery(array $options = []): string
244
    {
245 6
        $vars = $this->query;
246
247
        if (array_key_exists('except', $options)) {
248
            if (! is_array($options['except'])) {
249
                $options['except'] = [$options['except']];
250
            }
251
252
            foreach ($options['except'] as $var) {
253
                unset($vars[$var]);
254
            }
255
        } elseif (array_key_exists('only', $options)) {
256 6
            $temp = [];
257
258
            if (! is_array($options['only'])) {
259
                $options['only'] = [$options['only']];
260
            }
261
262
            foreach ($options['only'] as $var) {
263
                if (array_key_exists($var, $vars)) {
264
                    $temp[$var] = $vars[$var];
265
                }
266
            }
267
268
            $vars = $temp;
269
        }
270
271 6
        return empty($vars) ? '' : http_build_query($vars);
272
    }
273
274
    /**
275
     * {@inheritDoc}
276
     */
277
    public function getFragment(): string
278
    {
279 6
        return null === $this->fragment ? '' : $this->fragment;
280
    }
281
282
    /**
283
     * Renvoie les segments du chemin sous forme de tableau.
284
     */
285
    public function getSegments(): array
286
    {
287
        return $this->segments;
288
    }
289
290
    /**
291
     * Renvoie la valeur d'un segment spécifique du chemin URI.
292
     *
293
     * @return string La valeur du segment. Si aucun segment n'est trouvé, lance InvalidArgumentError
294
     */
295
    public function getSegment(int $number): string
296
    {
297
        // Le segment doit traiter le tableau comme basé sur 1 pour l'utilisateur
298
        // mais nous devons encore gérer un tableau de base zéro.
299
        $number--;
300
301
        if ($number > count($this->segments)) {
302
            throw new FrameworkException('Le segment "' . $number . '" n\'est pas dans l\'interval de segment disponible');
303
        }
304
305
        return $this->segments[$number] ?? '';
306
    }
307
308
    /**
309
     * Définissez la valeur d'un segment spécifique du chemin URI.
310
     * Permet de définir uniquement des segments existants ou d'en ajouter un nouveau.
311
     *
312
     * @param mixed $value (string ou int)
313
     */
314
    public function setSegment(int $number, $value)
315
    {
316
        // Le segment doit traiter le tableau comme basé sur 1 pour l'utilisateur
317
        // mais nous devons encore gérer un tableau de base zéro.
318
        $number--;
319
320
        if ($number > count($this->segments) + 1) {
321
            throw new FrameworkException('Le segment "' . $number . '" n\'est pas dans l\'interval de segment disponible');
322
        }
323
324
        $this->segments[$number] = $value;
325
        $this->refreshPath();
326
327
        return $this;
328
    }
329
330
    /**
331
     * Renvoie le nombre total de segments.
332
     */
333
    public function getTotalSegments(): int
334
    {
335
        return count($this->segments);
336
    }
337
338
    /**
339
     * Autoriser la sortie de l'URI sous forme de chaîne en le convertissant simplement en chaîne
340
     * ou en écho.
341
     */
342
    public function __toString(): string
343
    {
344
        return static::createURIString(
345
            $this->getScheme(),
346
            $this->getAuthority(),
347
            $this->getPath(), // Les URI absolus doivent utiliser un "/" pour un chemin vide
348
            $this->getQuery(),
349
            $this->getFragment()
350
        );
351
    }
352
353
    /**
354
     * Construit une représentation de la chaîne à partir des parties du composant.
355
     */
356
    public static function createURIString(?string $scheme = null, ?string $authority = null, ?string $path = null, ?string $query = null, ?string $fragment = null): string
357
    {
358 6
        $uri = '';
359
        if (! empty($scheme)) {
360 6
            $uri .= $scheme . '://';
361
        }
362
363
        if (! empty($authority)) {
364 6
            $uri .= $authority;
365
        }
366
367
        if ($path) {
368 6
            $uri .= substr($uri, -1, 1) !== '/' ? '/' . ltrim($path, '/') : $path;
369
        }
370
371
        if ($query) {
372 6
            $uri .= '?' . $query;
373
        }
374
375
        if ($fragment) {
376 6
            $uri .= '#' . $fragment;
377
        }
378
379 6
        return $uri;
380
    }
381
382
    /**
383
     * Analyse la chaîne donnée et enregistre les pièces d'autorité appropriées.
384
     */
385
    public function setAuthority(string $str): self
386
    {
387
        $parts = parse_url($str);
388
389
        if (empty($parts['host']) && ! empty($parts['path'])) {
390
            $parts['host'] = $parts['path'];
391
            unset($parts['path']);
392
        }
393
394
        $this->applyParts($parts);
395
396
        return $this;
397
    }
398
399
    /**
400
     * Définit le schéma pour cet URI.
401
     *
402
     * En raison du grand nombre de schémas valides, nous ne pouvons pas limiter ce
403
     * uniquement sur http ou https.
404
     *
405
     * @see https://www.iana.org/assignments/uri-schemes/uri-schemes.xhtml
406
     */
407
    public function setScheme(string $str): self
408
    {
409 6
        $str = strtolower($str);
410 6
        $str = preg_replace('#:(//)?$#', '', $str);
411
412 6
        $this->scheme = $str;
413
414 6
        return $this;
415
    }
416
417
    /**
418
     * {@inheritDoc}
419
     */
420
    public function withScheme(string $scheme): self
421
    {
422
        return $this->setScheme($scheme);
423
    }
424
425
    /**
426
     * Définit la partie userInfo/Authority de l'URI.
427
     *
428
     * @param string $user Le nom d'utilisateur de l'utilisateur
429
     * @param string $pass Le mot de passe de l'utilisateur
430
     */
431
    public function setUserInfo(string $user, string $pass): self
432
    {
433
        $this->user     = trim($user);
434
        $this->password = trim($pass);
435
436
        return $this;
437
    }
438
439
    /**
440
     * {@inheritDoc}
441
     */
442
    public function withUserInfo(string $user, ?string $password = null): self
443
    {
444
        return $this->setUserInfo($user, $password);
0 ignored issues
show
Bug introduced by
It seems like $password can also be of type null; however, parameter $pass of BlitzPHP\Http\Uri::setUserInfo() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

444
        return $this->setUserInfo($user, /** @scrutinizer ignore-type */ $password);
Loading history...
445
    }
446
447
    /**
448
     * Définit le nom d'hôte à utiliser.
449
     */
450
    public function setHost(string $str): self
451
    {
452
        $this->host = trim($str);
453
454
        return $this;
455
    }
456
457
    /**
458
     * {@inheritDoc}
459
     */
460
    public function withHost(string $host): self
461
    {
462
        return $this->setHost($host);
463
    }
464
465
    /**
466
     * Définit la partie port de l'URI.
467
     */
468
    public function setPort(?int $port = null): self
469
    {
470
        if (null === $port) {
471
            return $this;
472
        }
473
474
        if ($port <= 0 || $port > 65535) {
475
            throw new FrameworkException('Le port "' . $port . '" est invalide');
476
        }
477
478
        $this->port = $port;
479
480
        return $this;
481
    }
482
483
    /**
484
     * {@inheritDoc}
485
     */
486
    public function withPort(?int $port): self
487
    {
488
        return $this->setPort($port);
489
    }
490
491
    /**
492
     * Définit la partie chemin de l'URI.
493
     */
494
    public function setPath(string $path): self
495
    {
496
        $this->path = $this->filterPath($path);
497
498
        $this->segments = explode('/', $this->path);
499
500
        return $this;
501
    }
502
503
    /**
504
     * {@inheritDoc}
505
     */
506
    public function withPath(string $path): self
507
    {
508
        return $this->setPath($path);
509
    }
510
511
    /**
512
     * Définit la partie chemin de l'URI en fonction des segments.
513
     */
514
    public function refreshPath(): self
515
    {
516
        $this->path = $this->filterPath(implode('/', $this->segments));
517
518
        $this->segments = explode('/', $this->path);
519
520
        return $this;
521
    }
522
523
    /**
524
     * Définit la partie requête de l'URI, tout en essayant
525
     * de nettoyer les différentes parties des clés et des valeurs de la requête.
526
     */
527
    public function setQuery(string $query): self
528
    {
529
        if (str_contains($query, '#')) {
530
            throw new FrameworkException('La chaine de requete est mal formée');
531
        }
532
533
        // Ne peut pas avoir de début ?
534
        if (! empty($query) && str_starts_with($query, '?')) {
535
            $query = substr($query, 1);
536
        }
537
538
        parse_str($query, $this->query);
539
540
        return $this;
541
    }
542
543
    /**
544
     * {@inheritDoc}
545
     */
546
    public function withQuery(string $query): self
547
    {
548
        return $this->setQuery($query);
549
    }
550
551
    /**
552
     * Une méthode pratique pour transmettre un tableau d'éléments en tant que requête
553
     * partie de l'URI.
554
     */
555
    public function setQueryArray(array $query): self
556
    {
557
        $query = http_build_query($query);
558
559
        return $this->setQuery($query);
560
    }
561
562
    /**
563
     * Ajoute un seul nouvel élément à la requête vars.
564
     */
565
    public function addQuery(string $key, mixed $value = null): self
566
    {
567
        $this->query[$key] = $value;
568
569
        return $this;
570
    }
571
572
    /**
573
     * Supprime une ou plusieurs variables de requête de l'URI.
574
     */
575
    public function stripQuery(...$params): self
576
    {
577
        foreach ($params as $param) {
578
            unset($this->query[$param]);
579
        }
580
581
        return $this;
582
    }
583
584
    /**
585
     * Filtre les variables de requête afin que seules les clés transmises
586
     * sont gardés. Le reste est supprimé de l'objet.
587
     */
588
    public function keepQuery(...$params): self
589
    {
590
        $temp = [];
591
592
        foreach ($this->query as $key => $value) {
593
            if (! in_array($key, $params, true)) {
594
                continue;
595
            }
596
597
            $temp[$key] = $value;
598
        }
599
600
        $this->query = $temp;
601
602
        return $this;
603
    }
604
605
    /**
606
     * Définit la partie fragment de l'URI.
607
     *
608
     * @see https://tools.ietf.org/html/rfc3986#section-3.5
609
     */
610
    public function setFragment(string $string): self
611
    {
612
        $this->fragment = trim($string, '# ');
613
614
        return $this;
615
    }
616
617
    /**
618
     * {@inheritDoc}
619
     */
620
    public function withFragment(string $fragment): self
621
    {
622
        return $this->setFragment($fragment);
623
    }
624
625
    /**
626
     * Encode tous les caractères dangereux et supprime les segments de points.
627
     * Bien que les segments de points aient des utilisations valides selon la spécification,
628
     * cette classe ne les autorise pas.
629
     */
630
    protected function filterPath(?string $path = null): string
631
    {
632 6
        $orig = $path;
633
634
        // Décode/normalise les caractères codés en pourcentage afin que
635
        // nous pouissions toujours avoir une correspondance pour les routes, etc.
636 6
        $path = urldecode($path);
0 ignored issues
show
Bug introduced by
It seems like $path can also be of type null; however, parameter $string of urldecode() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

636
        $path = urldecode(/** @scrutinizer ignore-type */ $path);
Loading history...
637
638
        // Supprimer les segments de points
639 6
        $path = $this->removeDotSegments($path);
640
641
        // Correction de certains cas de bord de barre oblique...
642
        if (str_starts_with($orig, './')) {
0 ignored issues
show
Bug introduced by
It seems like $orig can also be of type null; however, parameter $haystack of str_starts_with() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

642
        if (str_starts_with(/** @scrutinizer ignore-type */ $orig, './')) {
Loading history...
643 6
            $path = '/' . $path;
644
        }
645
        if (str_starts_with($orig, '../')) {
646 6
            $path = '/' . $path;
647
        }
648
649
        // Encode les caractères
650
        $path = preg_replace_callback(
651
            '/(?:[^' . static::CHAR_UNRESERVED . ':@&=\+\$,\/;%]+|%(?![A-Fa-f0-9]{2}))/',
652
            static fn (array $matches) => rawurlencode($matches[0]),
653
            $path
654 6
        );
655
656 6
        return $path;
657
    }
658
659
    /**
660
     * Enregistre nos pièces à partir d'un appel parse_url.
661
     */
662
    protected function applyParts(array $parts)
663
    {
664
        if (! empty($parts['host'])) {
665 6
            $this->host = $parts['host'];
666
        }
667
        if (! empty($parts['user'])) {
668 6
            $this->user = $parts['user'];
669
        }
670
        if (! empty($parts['path'])) {
671 6
            $this->path = $this->filterPath($parts['path']);
672
        }
673
        if (! empty($parts['query'])) {
674 6
            $this->setQuery($parts['query']);
675
        }
676
        if (! empty($parts['fragment'])) {
677 6
            $this->fragment = $parts['fragment'];
678
        }
679
680
        if (isset($parts['scheme'])) {
681 6
            $this->setScheme(rtrim($parts['scheme'], ':/'));
682
        } else {
683
            $this->setScheme('http');
684
        }
685
686
        if (isset($parts['port'])) {
687
            if (null !== $parts['port']) {
688
                // Les numéros de port valides sont appliqués par les précédents parse_url ou setPort()
689
                $port       = $parts['port'];
690
                $this->port = $port;
691
            }
692
        }
693
694
        if (isset($parts['pass'])) {
695 6
            $this->password = $parts['pass'];
696
        }
697
698
        if (! empty($parts['path'])) {
699 6
            $this->segments = explode('/', trim($parts['path'], '/'));
700
        }
701
    }
702
703
    /**
704
     * Combine une chaîne d'URI avec celle-ci en fonction des règles définies dans
705
     * RFC 3986 Section 2
706
     *
707
     * @see http://tools.ietf.org/html/rfc3986#section-5.2
708
     */
709
    public function resolveRelativeURI(string $uri): self
710
    {
711
        /*
712
         * REMARQUE : Nous n'utilisons pas removeDotSegments dans cet
713
         * algorithme puisque c'est déjà fait par cette ligne !
714
         */
715
        $relative = new self();
716
        $relative->setURI($uri);
717
718
        if ($relative->getScheme() === $this->getScheme()) {
719
            $relative->setScheme('');
720
        }
721
722
        $transformed = clone $relative;
723
724
        // 5.2.2 Transformer les références dans une méthode non stricte (pas de schéma)
725
        if (! empty($relative->getAuthority())) {
726
            $transformed->setAuthority($relative->getAuthority())
727
                ->setPath($relative->getPath())
728
                ->setQuery($relative->getQuery());
729
        } else {
730
            if ($relative->getPath() === '') {
731
                $transformed->setPath($this->getPath());
732
733
                if ($relative->getQuery()) {
734
                    $transformed->setQuery($relative->getQuery());
735
                } else {
736
                    $transformed->setQuery($this->getQuery());
737
                }
738
            } else {
739
                if (str_starts_with($relative->getPath(), '/')) {
740
                    $transformed->setPath($relative->getPath());
741
                } else {
742
                    $transformed->setPath($this->mergePaths($this, $relative));
743
                }
744
745
                $transformed->setQuery($relative->getQuery());
746
            }
747
748
            $transformed->setAuthority($this->getAuthority());
749
        }
750
751
        $transformed->setScheme($this->getScheme());
752
753
        $transformed->setFragment($relative->getFragment());
754
755
        return $transformed;
756
    }
757
758
    /**
759
     * Étant donné 2 chemins, les fusionnera conformément aux règles énoncées dans RFC 2986, section 5.2
760
     *
761
     * @see http://tools.ietf.org/html/rfc3986#section-5.2.3
762
     */
763
    protected function mergePaths(self $base, self $reference): string
764
    {
765
        if (! empty($base->getAuthority()) && empty($base->getPath())) {
766
            return '/' . ltrim($reference->getPath(), '/ ');
767
        }
768
769
        $path = explode('/', $base->getPath());
770
771
        if (empty($path[0])) {
772
            unset($path[0]);
773
        }
774
775
        array_pop($path);
776
        $path[] = $reference->getPath();
777
778
        return implode('/', $path);
779
    }
780
781
    /**
782
     * Utilisé lors de la résolution et de la fusion de chemins pour interpréter et
783
     * supprimer correctement les segments à un ou deux points du chemin selon RFC 3986 Section 5.2.4
784
     *
785
     * @see http://tools.ietf.org/html/rfc3986#section-5.2.4
786
     */
787
    public static function removeDotSegments(string $path): string
788
    {
789
        if (empty($path) || $path === '/') {
790 4
            return $path;
791
        }
792
793 6
        $output = [];
794
795 6
        $input = explode('/', $path);
796
797
        if (empty($input[0])) {
798 6
            unset($input[0]);
799 6
            $input = array_values($input);
800
        }
801
802
        // Ce n'est pas une représentation parfaite de la
803
        // RFC, mais correspond à la plupart des cas et est joli
804
        // beaucoup ce que Guzzle utilise. Devrait être assez bon
805
        // pour presque tous les cas d'utilisation réels.
806
        foreach ($input as $segment) {
807
            if ($segment === '..') {
808 6
                array_pop($output);
809
            } elseif ($segment !== '.' && $segment !== '') {
810 6
                $output[] = $segment;
811
            }
812
        }
813
814 6
        $output = implode('/', $output);
815 6
        $output = ltrim($output, '/ ');
816
817
        if ($output !== '/') {
818
            // Ajouter une barre oblique au début si nécessaire
819
            if (str_starts_with($path, '/')) {
820 6
                $output = '/' . $output;
821
            }
822
823
            // Ajouter une barre oblique à la fin si nécessaire
824
            if (substr($path, -1, 1) === '/') {
825 2
                $output .= '/';
826
            }
827
        }
828
829 6
        return $output;
830
    }
831
}
832