Passed
Push — main ( 1fe2cd...c1deb1 )
by Dimitri
04:10
created

Router::replaceBackReferences()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 12
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 2

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 1
eloc 7
c 1
b 0
f 0
nc 1
nop 2
dl 0
loc 12
ccs 0
cts 3
cp 0
crap 2
rs 10
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\Router;
13
14
use BlitzPHP\Container\Services;
15
use BlitzPHP\Contracts\Router\AutoRouterInterface;
16
use BlitzPHP\Contracts\Router\RouteCollectionInterface;
17
use BlitzPHP\Contracts\Router\RouterInterface;
18
use BlitzPHP\Enums\Method;
19
use BlitzPHP\Exceptions\PageNotFoundException;
20
use BlitzPHP\Exceptions\RedirectException;
21
use BlitzPHP\Exceptions\RouterException;
22
use BlitzPHP\Http\Request;
23
use BlitzPHP\Utilities\String\Text;
24
use Closure;
25
use Psr\Http\Message\ServerRequestInterface;
26
27
/**
28
 * Analyse l'URL de la requête dans le contrôleur, action et paramètres. Utilise les routes connectées
29
 * pour faire correspondre la chaîne d'URL entrante aux paramètres qui permettront à la requête d'être envoyée. Aussi
30
 * gère la conversion des listes de paramètres en chaînes d'URL, en utilisant les routes connectées. Le routage vous permet de découpler
31
 * la façon dont le monde interagit avec votre application (URL) et l'implémentation (contrôleurs et actions).
32
 */
33
class Router implements RouterInterface
34
{
35
    /**
36
     * List of allowed HTTP methods (and CLI for command line use).
37
     */
38
    public const HTTP_METHODS = [
39
        Method::GET,
40
        Method::HEAD,
41
        Method::POST,
42
        Method::PATCH,
43
        Method::PUT,
44
        Method::DELETE,
45
        Method::OPTIONS,
46
        Method::TRACE,
47
        Method::CONNECT,
48
        'CLI',
49
    ];
50
51
    /**
52
     * Une instance de la classe RouteCollection.
53
     *
54
     * @var RouteCollection
55
     */
56
    protected $collection;
57
58
    /**
59
     * Sous-répertoire contenant la classe de contrôleur demandée.
60
     * Principalement utilisé par 'autoRoute'.
61
     */
62
    protected ?string $directory = null;
63
64
    /**
65
     * Le nom de la classe contrôleur
66
     *
67
     * @var Closure|string
68
     */
69
    protected $controller;
70
71
    /**
72
     * Le nom de la méthode à utiliser
73
     */
74
    protected string $method = '';
75
76
    /**
77
     * Un tableau de liens qui ont été collectés afin
78
     * qu'ils puissent être envoyés aux routes de fermeture.
79
     */
80
    protected array $params = [];
81
82
    /**
83
     * Le nom du du front-controller.
84
     */
85
    protected string $indexPage = 'index.php';
86
87
    /**
88
     * Si les tirets dans les URI doivent être convertis
89
     * pour les traits de soulignement lors de la détermination des noms de méthode.
90
     */
91
    protected bool $translateURIDashes = true;
92
93
    /**
94
     * Les routes trouvées pour la requête courrante
95
     */
96
    protected ?array $matchedRoute = null;
97
98
    /**
99
     * Les options de la route matchée.
100
     */
101
    protected ?array $matchedRouteOptions = null;
102
103
    /**
104
     * Le locale (langue) qui a été detectée dans la route.
105
     *
106
     * @var string
107
     */
108
    protected $detectedLocale;
109
110
    /**
111
     * Les informations des middlewares à executer
112
     * Si la route matchée necessite des filtres.
113
     *
114
     * @var string[]
115
     */
116
    protected array $middlewaresInfo = [];
117
118
    protected ?AutoRouterInterface $autoRouter = null;
119
120
    /**
121
     * @param RouteCollection $routes
122
     * @param Request         $request
123
     */
124
    public function __construct(RouteCollectionInterface $routes, ServerRequestInterface $request)
125
    {
126 16
        $this->collection = $routes;
0 ignored issues
show
Documentation Bug introduced by
$routes is of type BlitzPHP\Contracts\Router\RouteCollectionInterface, but the property $collection was declared to be of type BlitzPHP\Router\RouteCollection. Are you sure that you always receive this specific sub-class here, or does it make sense to add an instanceof check?

Our type inference engine has found a suspicous assignment of a value to a property. This check raises an issue when a value that can be of a given class or a super-class is assigned to a property that is type hinted more strictly.

Either this assignment is in error or an instanceof check should be added for that assignment.

class Alien {}

class Dalek extends Alien {}

class Plot
{
    /** @var  Dalek */
    public $villain;
}

$alien = new Alien();
$plot = new Plot();
if ($alien instanceof Dalek) {
    $plot->villain = $alien;
}
Loading history...
127
128 16
        $this->setController($this->collection->getDefaultController());
129 16
        $this->setMethod($this->collection->getDefaultMethod());
130
131 16
        $this->collection->setHTTPVerb($request->getMethod());
132
133 16
        $this->translateURIDashes = $this->collection->shouldTranslateURIDashes();
134
135
        if ($this->collection->shouldAutoRoute()) {
136
            $this->autoRouter = new AutoRouter(
137
                $this->collection->getRegisteredControllers('*'),
138
                $this->collection->getDefaultNamespace(),
139
                $this->collection->getDefaultController(),
140
                $this->collection->getDefaultMethod(),
141
                $this->translateURIDashes
142 16
            );
143
        }
144
    }
145
146
    /**
147
     * @return Closure|string Nom de classe du contrôleur ou closure
148
     *
149
     * @throws PageNotFoundException
150
     * @throws RedirectException
151
     */
152
    public function handle(?string $uri = null)
153
    {
154
        // Si nous ne trouvons pas d'URI à comparer, alors
155
        // tout fonctionne à partir de ses paramètres par défaut.
156
        if ($uri === null || $uri === '') {
157 2
            $uri = '/';
158
        }
159
160 16
        $uri                   = urldecode($uri);
161 16
        $this->middlewaresInfo = [];
162
163
        if ($this->checkRoutes($uri)) {
164
            if ($this->collection->isFiltered($this->matchedRoute[0])) {
165 2
                $this->middlewaresInfo = $this->collection->getFiltersForRoute($this->matchedRoute[0]);
166
            }
167
168
            // met a jour le routeur dans le conteneur car permet notament de recupere les bonnes
169
            // info du routing (route actuelle, controleur et methode mappés)
170 16
            Services::set(static::class, $this);
171
172 16
            return $this->controllerName();
173
        }
174
175
        // Toujours là ? Ensuite, nous pouvons essayer de faire correspondre l'URI avec
176
        // Contrôleurs/répertoires, mais l'application peut ne pas
177
        // vouloir ceci, comme dans le cas des API.
178
        if (! $this->collection->shouldAutoRoute()) {
179 4
            throw new PageNotFoundException("Impossible de trouver une route pour '{$this->collection->getHTTPVerb()}: {$uri}'.");
180
        }
181
182 2
        $this->autoRoute($uri);
183
184
        // met a jour le routeur dans le conteneur car permet notament de recupere les bonnes
185
        // info du routing (route actuelle, controleur et methode mappés)
186 2
        Services::set(static::class, $this);
187
188 2
        return $this->controllerName();
189
    }
190
191
    /**
192
     * Renvoie les informations des middlewares de la routes matchée
193
     *
194
     * @return string[]
195
     */
196
    public function getMiddlewares(): array
197
    {
198 2
        return $this->middlewaresInfo;
199
    }
200
201
    /**
202
     * Renvoie le nom du contrôleur matché
203
     *
204
     * @return Closure|string
205
     */
206
    public function controllerName()
207
    {
208
        if (! is_string($this->controller)) {
209 4
            return $this->controller;
210
        }
211
212
        $controller = str_contains($this->controller, '\\')
213
            ? $this->controller
214
            : trim($this->collection->getDefaultNamespace(), '\\') . '\\' . $this->controller;
215
216
        $controller = preg_replace(
217
            ['#(\_)?Controller$#i', '#' . /** @scrutinizer ignore-type */ config('app.url_suffix') . '$#i'],
218
            '',
219
            ucfirst($controller)
220 16
        );
221
222 16
        $controller = trim($controller, '/\\');
223
        $controller = $this->translateURIDashes
224
            ? str_replace('-', '_', $controller)
225 2
            : Text::convertTo($controller, 'pascal');
226
227 16
        return $controller . 'Controller';
228
    }
229
230
    /**
231
     * Retourne le nom de la méthode à exécuter
232
     */
233
    public function methodName(): string
234
    {
235 10
        return str_replace('-', '_', $this->method);
236
    }
237
238
    /**
239
     * Renvoie les paramètres de remplacement 404 de la collection.
240
     * Si le remplacement est une chaîne, sera divisé en tableau contrôleur/index.
241
     *
242
     * @return array|callable|null
243
     */
244
    public function get404Override()
245
    {
246
        $route = $this->collection->get404Override();
247
248
        if (is_string($route)) {
249
            $routeArray = explode('::', $route);
250
251
            return [
252
                $routeArray[0], // Controller
253
                $routeArray[1] ?? 'index',   // Method
254
            ];
255
        }
256
257
        if (is_callable($route)) {
258
            return $route;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $route also could return the type mixed which is incompatible with the documented return type array|callable|null.
Loading history...
259
        }
260
261
        return null;
262
    }
263
264
    /**
265
     * Renvoie les liaisons qui ont été mises en correspondance et collectées
266
     * pendant le processus d'analyse sous forme de tableau, prêt à être envoyé à
267
     * instance->method(...$params).
268
     */
269
    public function params(): array
270
    {
271 8
        return $this->params;
272
    }
273
274
    /**
275
     * Renvoie le nom du sous-répertoire dans lequel se trouve le contrôleur.
276
     * Relatif à APPPATH.'Controllers'.
277
     *
278
     * Uniquement utilisé lorsque le routage automatique est activé.
279
     */
280
    public function directory(): string
281
    {
282
        if ($this->autoRouter instanceof AutoRouter) {
283
            return $this->autoRouter->directory();
0 ignored issues
show
Bug introduced by
The method directory() does not exist on null. ( Ignorable by Annotation )

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

283
            return $this->autoRouter->/** @scrutinizer ignore-call */ directory();

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
284
        }
285
286
        return '';
287
    }
288
289
    /**
290
     * Renvoie les informations de routage qui correspondaient à ce
291
     * requête, si une route a été définie.
292
     */
293
    public function getMatchedRoute(): ?array
294
    {
295
        return $this->matchedRoute;
296
    }
297
298
    /**
299
     * Renvoie toutes les options définies pour la route correspondante
300
     */
301
    public function getMatchedRouteOptions(): ?array
302
    {
303 2
        return $this->matchedRouteOptions;
304
    }
305
306
    /**
307
     * Définit la valeur qui doit être utilisée pour correspondre au fichier index.php. Valeurs par défaut
308
     * à index.php mais cela vous permet de le modifier au cas où vous utilisez
309
     * quelque chose comme mod_rewrite pour supprimer la page. Vous pourriez alors le définir comme une chaine vide=
310
     */
311
    public function setIndexPage(string $page): self
312
    {
313
        $this->indexPage = $page;
314
315
        return $this;
316
    }
317
318
    /**
319
     * Renvoie vrai/faux selon que la route actuelle contient ou non
320
     * un placeholder {locale}.
321
     */
322
    public function hasLocale(): bool
323
    {
324 2
        return (bool) $this->detectedLocale;
325
    }
326
327
    /**
328
     * Renvoie la locale (langue) détectée, le cas échéant, ou null.
329
     */
330
    public function getLocale(): ?string
331
    {
332 2
        return $this->detectedLocale;
333
    }
334
335
    /**
336
     * Compare la chaîne uri aux routes que la
337
     * classe RouteCollection a définie pour nous, essayant de trouver une correspondance.
338
     * Cette méthode modifiera $this->controller, si nécessaire.
339
     *
340
     * @param string $uri Le chemin URI à comparer aux routes
341
     *
342
     * @return bool Si la route a été mis en correspondance ou non.
343
     *
344
     * @throws RedirectException
345
     */
346
    protected function checkRoutes(string $uri): bool
347
    {
348 16
        $routes = $this->collection->getRoutes($this->collection->getHTTPVerb());
349
350
        // S'il n'y a pas de routes definies pour la methode HTTP, c'est pas la peine d'aller plus loin
351
        if (empty($routes)) {
352 2
            return false;
353
        }
354
355
        $uri = $uri === '/'
356
            ? $uri
357 16
            : trim($uri, '/ ');
358
359
        // Boucle dans le tableau de routes à la recherche de caractères génériques
360
        foreach ($routes as $routeKey => $handler) {
361
            $routeKey = $routeKey === '/'
362
                ? $routeKey
363 16
                : ltrim((string) $routeKey, '/ ');
364
365 16
            $matchedKey = $routeKey;
366
367
            // A-t-on affaire à une locale ?
368
            if (str_contains($routeKey, '{locale}')) {
369 8
                $routeKey = str_replace('{locale}', '[^/]+', $routeKey);
370
            }
371
372
            // Est-ce que RegEx correspond ?
373
            if (preg_match('#^' . $routeKey . '$#u', $uri, $matches)) {
374
                // Cette route est-elle censée rediriger vers une autre ?
375
                if ($this->collection->isRedirect($routeKey)) {
376
                    // remplacement des groupes de routes correspondants par des références : post/([0-9]+) -> post/$1
377
                    $redirectTo = preg_replace_callback('/(\([^\(]+\))/', static function () {
378
                        static $i = 1;
379
380
                        return '$' . $i++;
381
                    }, is_array($handler) ? key($handler) : $handler);
382
383
                    throw new RedirectException(
384
                        preg_replace('#^' . $routeKey . '$#u', $redirectTo, $uri),
385
                        $this->collection->getRedirectCode($routeKey)
386
                    );
387
                }
388
                // Stocke nos paramètres régionaux afin que l'objet CodeIgniter puisse l'affecter à la requête.
389
                if (str_contains($matchedKey, '{locale}')) {
390
                    preg_match(
391
                        '#^' . str_replace('{locale}', '(?<locale>[^/]+)', $matchedKey) . '$#u',
392
                        $uri,
393
                        $matched
394 16
                    );
395
396
                    if ($this->collection->shouldUseSupportedLocalesOnly()
397
                        && ! in_array($matched['locale'], config('app.supported_locales'), true)) {
0 ignored issues
show
Bug introduced by
config('app.supported_locales') of type BlitzPHP\Config\Config|null is incompatible with the type array expected by parameter $haystack of in_array(). ( Ignorable by Annotation )

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

397
                        && ! in_array($matched['locale'], /** @scrutinizer ignore-type */ config('app.supported_locales'), true)) {
Loading history...
398
                        // Lancer une exception pour empêcher l'autorouteur,
399
                        // si activé, essayer de trouver une route
400 2
                        throw PageNotFoundException::localeNotSupported($matched['locale']);
401
                    }
402
403 4
                    $this->detectedLocale = $matched['locale'];
404 4
                    unset($matched);
405
                }
406
407
                // Utilisons-nous Closures ? Si tel est le cas, nous devons collecter les paramètres dans un tableau
408
                // afin qu'ils puissent être transmis ultérieurement à la méthode du contrôleur.
409
                if (! is_string($handler) && is_callable($handler)) {
410 4
                    $this->controller = $handler;
411
412
                    // Supprime la chaîne d'origine du tableau matches
413 4
                    array_shift($matches);
414
415 4
                    $this->params = $matches;
416
417 4
                    $this->setMatchedRoute($matchedKey, $handler);
418
419 4
                    return true;
420
                }
421
422
                if (is_array($handler)) {
423 16
                    $handler = implode('::', $handler);
424
                }
425
426
                if (str_contains($handler, '::')) {
427 14
                    [$controller, $methodAndParams] = explode('::', $handler);
428
                } else {
429 2
                    $controller      = $handler;
430 2
                    $methodAndParams = '';
431
                }
432
433
                // Vérifie `/` dans le nom du contrôleur
434
                if (str_contains($controller, '/')) {
435 2
                    throw RouterException::invalidControllerName($handler);
436
                }
437
438
                if (str_contains($handler, '$') && str_contains($routeKey, '(')) {
439
                    // Vérifie le contrôleur dynamique
440
                    if (str_contains($controller, '$')) {
441 2
                        throw RouterException::dynamicController($handler);
442
                    }
443
444
                    if (config('routing.multiple_segments_one_param') === false) {
445
                        // Utilisation de back-references
446 10
                        $segments = explode('/', preg_replace('#^' . $routeKey . '$#u', $handler, $uri));
447
                    } else {
448
                        if (str_contains($methodAndParams, '/')) {
449
                            [$method, $handlerParams] = explode('/', $methodAndParams, 2);
450
                            $params                   = explode('/', $handlerParams);
451
                            $handlerSegments          = array_merge([$controller . '::' . $method], $params);
452
                        } else {
453
                            $handlerSegments = [$handler];
454
                        }
455
456
                        $segments = [];
457
458
                        foreach ($handlerSegments as $segment) {
459 10
                            $segments[] = $this->replaceBackReferences($segment, $matches);
460
                        }
461
                    }
462
                } else {
463 14
                    $segments = explode('/', $handler);
464
                }
465
466 16
                $this->setRequest($segments);
467
468 16
                $this->setMatchedRoute($matchedKey, $handler);
469
470 16
                return true;
471
            }
472
        }
473
474 4
        return false;
475
    }
476
477
    /**
478
     * Replace string `$n` with `$matches[n]` value.
479
     */
480
    private function replaceBackReferences(string $input, array $matches): string
481
    {
482
        $pattern = '/\$([1-' . count($matches) . '])/u';
483
484
        return preg_replace_callback(
485
            $pattern,
486
            static function ($match) use ($matches) {
487
                $index = (int) $match[1];
488
489
                return $matches[$index] ?? '';
490
            },
491
            $input
492
        );
493
    }
494
495
    /**
496
     * Tente de faire correspondre un chemin d'URI avec des contrôleurs et des répertoires
497
     * trouvé dans CONTROLLER_PATH, pour trouver une route correspondante.
498
     */
499
    public function autoRoute(string $uri)
500
    {
501
        [$this->directory, $this->controller, $this->method, $this->params]
502 2
            = $this->autoRouter->getRoute($uri, $this->collection->getHTTPVerb());
503
    }
504
505
    /**
506
     * Définir la route de la requête
507
     *
508
     * Prend un tableau de segments URI en entrée et définit la classe/méthode
509
     * être appelé.
510
     *
511
     * @param array $segments segments d'URI
512
     */
513
    protected function setRequest(array $segments = [])
514
    {
515
        // Si nous n'avons aucun segment - essayez le contrôleur par défaut ;
516
        if (empty($segments)) {
517
            return;
518
        }
519
520 16
        [$controller, $method] = array_pad(explode('::', $segments[0]), 2, null);
521
522 16
        $this->setController($controller);
523
524
        // $this->method contient déjà le nom de la méthode par défaut,
525
        // donc ne l'écrasez pas avec le vide.
526
        if (! empty($method)) {
527 14
            $this->setMethod($method);
528
        }
529
530 16
        array_shift($segments);
531
532 16
        $this->params = $segments;
533
    }
534
535
    /**
536
     * Modifie le nom du controleur
537
     */
538
    private function setController(string $name): void
539
    {
540 16
        $this->controller = $this->makeController($name);
541
    }
542
543
    /**
544
     * Construit un nom de contrôleur valide
545
     */
546
    private function makeController(string $name): string
547
    {
548
        if ($this->autoRouter instanceof AutoRouter) {
549 16
            return $this->autoRouter->makeController($name);
550
        }
551
552 16
        return $name;
553
    }
554
555
    /**
556
     * Modifie le nom de la méthode
557
     */
558
    private function setMethod(string $name): void
559
    {
560 16
        $this->method = preg_replace('#' . config('app.url_suffix') . '$#i', '', $name);
0 ignored issues
show
Bug introduced by
Are you sure config('app.url_suffix') of type BlitzPHP\Config\Config|null can be used in concatenation? ( Ignorable by Annotation )

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

560
        $this->method = preg_replace('#' . /** @scrutinizer ignore-type */ config('app.url_suffix') . '$#i', '', $name);
Loading history...
561
    }
562
563
    /**
564
     * @param callable|string $handler
565
     */
566
    protected function setMatchedRoute(string $route, $handler): void
567
    {
568 16
        $this->matchedRoute = [$route, $handler];
569
570 16
        $this->matchedRouteOptions = $this->collection->getRoutesOptions($route);
571
    }
572
}
573