• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

codeigniter4 / CodeIgniter4 / 10121930357

27 Jul 2024 07:41AM UTC coverage: 84.478% (-0.002%) from 84.48%
10121930357

push

github

kenjis
Merge remote-tracking branch 'upstream/develop' into 4.6

3 of 3 new or added lines in 1 file covered. (100.0%)

1 existing line in 1 file now uncovered.

20475 of 24237 relevant lines covered (84.48%)

189.15 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

91.7
/system/Router/RouteCollection.php
1
<?php
2

3
declare(strict_types=1);
4

5
/**
6
 * This file is part of CodeIgniter 4 framework.
7
 *
8
 * (c) CodeIgniter Foundation <admin@codeigniter.com>
9
 *
10
 * For the full copyright and license information, please view
11
 * the LICENSE file that was distributed with this source code.
12
 */
13

14
namespace CodeIgniter\Router;
15

16
use Closure;
17
use CodeIgniter\Autoloader\FileLocatorInterface;
18
use CodeIgniter\Exceptions\InvalidArgumentException;
19
use CodeIgniter\HTTP\Method;
20
use CodeIgniter\HTTP\ResponseInterface;
21
use CodeIgniter\Router\Exceptions\RouterException;
22
use Config\App;
23
use Config\Modules;
24
use Config\Routing;
25
use Config\Services;
26

27
/**
28
 * @todo Implement nested resource routing (See CakePHP)
29
 * @see \CodeIgniter\Router\RouteCollectionTest
30
 */
31
class RouteCollection implements RouteCollectionInterface
32
{
33
    /**
34
     * The namespace to be added to any Controllers.
35
     * Defaults to the global namespaces (\).
36
     *
37
     * This must have a trailing backslash (\).
38
     *
39
     * @var string
40
     */
41
    protected $defaultNamespace = '\\';
42

43
    /**
44
     * The name of the default controller to use
45
     * when no other controller is specified.
46
     *
47
     * Not used here. Pass-thru value for Router class.
48
     *
49
     * @var string
50
     */
51
    protected $defaultController = 'Home';
52

53
    /**
54
     * The name of the default method to use
55
     * when no other method has been specified.
56
     *
57
     * Not used here. Pass-thru value for Router class.
58
     *
59
     * @var string
60
     */
61
    protected $defaultMethod = 'index';
62

63
    /**
64
     * The placeholder used when routing 'resources'
65
     * when no other placeholder has been specified.
66
     *
67
     * @var string
68
     */
69
    protected $defaultPlaceholder = 'any';
70

71
    /**
72
     * Whether to convert dashes to underscores in URI.
73
     *
74
     * Not used here. Pass-thru value for Router class.
75
     *
76
     * @var bool
77
     */
78
    protected $translateURIDashes = false;
79

80
    /**
81
     * Whether to match URI against Controllers
82
     * when it doesn't match defined routes.
83
     *
84
     * Not used here. Pass-thru value for Router class.
85
     *
86
     * @var bool
87
     */
88
    protected $autoRoute = false;
89

90
    /**
91
     * A callable that will be shown
92
     * when the route cannot be matched.
93
     *
94
     * @var (Closure(string): (ResponseInterface|string|void))|string
95
     */
96
    protected $override404;
97

98
    /**
99
     * An array of files that would contain route definitions.
100
     */
101
    protected array $routeFiles = [];
102

103
    /**
104
     * Defined placeholders that can be used
105
     * within the
106
     *
107
     * @var array<string, string>
108
     */
109
    protected $placeholders = [
110
        'any'      => '.*',
111
        'segment'  => '[^/]+',
112
        'alphanum' => '[a-zA-Z0-9]+',
113
        'num'      => '[0-9]+',
114
        'alpha'    => '[a-zA-Z]+',
115
        'hash'     => '[^/]+',
116
    ];
117

118
    /**
119
     * An array of all routes and their mappings.
120
     *
121
     * @var array
122
     *
123
     * [
124
     *     verb => [
125
     *         routeKey(regex) => [
126
     *             'name'    => routeName
127
     *             'handler' => handler,
128
     *             'from'    => from,
129
     *         ],
130
     *     ],
131
     *     // redirect route
132
     *     '*' => [
133
     *          routeKey(regex)(from) => [
134
     *             'name'     => routeName
135
     *             'handler'  => [routeKey(regex)(to) => handler],
136
     *             'from'     => from,
137
     *             'redirect' => statusCode,
138
     *         ],
139
     *     ],
140
     * ]
141
     */
142
    protected $routes = [
143
        '*'             => [],
144
        Method::OPTIONS => [],
145
        Method::GET     => [],
146
        Method::HEAD    => [],
147
        Method::POST    => [],
148
        Method::PATCH   => [],
149
        Method::PUT     => [],
150
        Method::DELETE  => [],
151
        Method::TRACE   => [],
152
        Method::CONNECT => [],
153
        'CLI'           => [],
154
    ];
155

156
    /**
157
     * Array of routes names
158
     *
159
     * @var array
160
     *
161
     * [
162
     *     verb => [
163
     *         routeName => routeKey(regex)
164
     *     ],
165
     * ]
166
     */
167
    protected $routesNames = [
168
        '*'             => [],
169
        Method::OPTIONS => [],
170
        Method::GET     => [],
171
        Method::HEAD    => [],
172
        Method::POST    => [],
173
        Method::PATCH   => [],
174
        Method::PUT     => [],
175
        Method::DELETE  => [],
176
        Method::TRACE   => [],
177
        Method::CONNECT => [],
178
        'CLI'           => [],
179
    ];
180

181
    /**
182
     * Array of routes options
183
     *
184
     * @var array
185
     *
186
     * [
187
     *     verb => [
188
     *         routeKey(regex) => [
189
     *             key => value,
190
     *         ]
191
     *     ],
192
     * ]
193
     */
194
    protected $routesOptions = [];
195

196
    /**
197
     * The current method that the script is being called by.
198
     *
199
     * @var string HTTP verb like `GET`,`POST` or `*` or `CLI`
200
     */
201
    protected $HTTPVerb = '*';
202

203
    /**
204
     * The default list of HTTP methods (and CLI for command line usage)
205
     * that is allowed if no other method is provided.
206
     *
207
     * @var list<string>
208
     */
209
    public $defaultHTTPMethods = Router::HTTP_METHODS;
210

211
    /**
212
     * The name of the current group, if any.
213
     *
214
     * @var string|null
215
     */
216
    protected $group;
217

218
    /**
219
     * The current subdomain.
220
     *
221
     * @var string|null
222
     */
223
    protected $currentSubdomain;
224

225
    /**
226
     * Stores copy of current options being
227
     * applied during creation.
228
     *
229
     * @var array|null
230
     */
231
    protected $currentOptions;
232

233
    /**
234
     * A little performance booster.
235
     *
236
     * @var bool
237
     */
238
    protected $didDiscover = false;
239

240
    /**
241
     * Handle to the file locator to use.
242
     *
243
     * @var FileLocatorInterface
244
     */
245
    protected $fileLocator;
246

247
    /**
248
     * Handle to the modules config.
249
     *
250
     * @var Modules
251
     */
252
    protected $moduleConfig;
253

254
    /**
255
     * Flag for sorting routes by priority.
256
     *
257
     * @var bool
258
     */
259
    protected $prioritize = false;
260

261
    /**
262
     * Route priority detection flag.
263
     *
264
     * @var bool
265
     */
266
    protected $prioritizeDetected = false;
267

268
    /**
269
     * The current hostname from $_SERVER['HTTP_HOST']
270
     */
271
    private ?string $httpHost = null;
272

273
    /**
274
     * Flag to limit or not the routes with {locale} placeholder to App::$supportedLocales
275
     */
276
    protected bool $useSupportedLocalesOnly = false;
277

278
    /**
279
     * Constructor
280
     */
281
    public function __construct(FileLocatorInterface $locator, Modules $moduleConfig, Routing $routing)
282
    {
283
        $this->fileLocator  = $locator;
468✔
284
        $this->moduleConfig = $moduleConfig;
468✔
285

286
        $this->httpHost = Services::request()->getServer('HTTP_HOST');
468✔
287

288
        // Setup based on config file. Let routes file override.
289
        $this->defaultNamespace   = rtrim($routing->defaultNamespace, '\\') . '\\';
468✔
290
        $this->defaultController  = $routing->defaultController;
468✔
291
        $this->defaultMethod      = $routing->defaultMethod;
468✔
292
        $this->translateURIDashes = $routing->translateURIDashes;
468✔
293
        $this->override404        = $routing->override404;
468✔
294
        $this->autoRoute          = $routing->autoRoute;
468✔
295
        $this->routeFiles         = $routing->routeFiles;
468✔
296
        $this->prioritize         = $routing->prioritize;
468✔
297

298
        // Normalize the path string in routeFiles array.
299
        foreach ($this->routeFiles as $routeKey => $routesFile) {
468✔
300
            $realpath                    = realpath($routesFile);
468✔
301
            $this->routeFiles[$routeKey] = ($realpath === false) ? $routesFile : $realpath;
468✔
302
        }
303
    }
304

305
    /**
306
     * Loads main routes file and discover routes.
307
     *
308
     * Loads only once unless reset.
309
     *
310
     * @return $this
311
     */
312
    public function loadRoutes(string $routesFile = APPPATH . 'Config/Routes.php')
313
    {
314
        if ($this->didDiscover) {
163✔
315
            return $this;
24✔
316
        }
317

318
        // Normalize the path string in routesFile
319
        $realpath   = realpath($routesFile);
142✔
320
        $routesFile = ($realpath === false) ? $routesFile : $realpath;
142✔
321

322
        // Include the passed in routesFile if it doesn't exist.
323
        // Only keeping that around for BC purposes for now.
324
        $routeFiles = $this->routeFiles;
142✔
325
        if (! in_array($routesFile, $routeFiles, true)) {
142✔
326
            $routeFiles[] = $routesFile;
×
327
        }
328

329
        // We need this var in local scope
330
        // so route files can access it.
331
        $routes = $this;
142✔
332

333
        foreach ($routeFiles as $routesFile) {
142✔
334
            if (! is_file($routesFile)) {
142✔
335
                log_message('warning', sprintf('Routes file not found: "%s"', $routesFile));
×
336

337
                continue;
×
338
            }
339

340
            require $routesFile;
142✔
341
        }
342

343
        $this->discoverRoutes();
142✔
344

345
        return $this;
142✔
346
    }
347

348
    /**
349
     * Will attempt to discover any additional routes, either through
350
     * the local PSR4 namespaces, or through selected Composer packages.
351
     *
352
     * @return void
353
     */
354
    protected function discoverRoutes()
355
    {
356
        if ($this->didDiscover) {
345✔
357
            return;
62✔
358
        }
359

360
        // We need this var in local scope
361
        // so route files can access it.
362
        $routes = $this;
342✔
363

364
        if ($this->moduleConfig->shouldDiscover('routes')) {
342✔
365
            $files = $this->fileLocator->search('Config/Routes.php');
200✔
366

367
            foreach ($files as $file) {
200✔
368
                // Don't include our main file again...
369
                if (in_array($file, $this->routeFiles, true)) {
200✔
370
                    continue;
200✔
371
                }
372

373
                include $file;
200✔
374
            }
375
        }
376

377
        $this->didDiscover = true;
342✔
378
    }
379

380
    /**
381
     * Registers a new constraint with the system. Constraints are used
382
     * by the routes as placeholders for regular expressions to make defining
383
     * the routes more human-friendly.
384
     *
385
     * You can pass an associative array as $placeholder, and have
386
     * multiple placeholders added at once.
387
     *
388
     * @param array|string $placeholder
389
     */
390
    public function addPlaceholder($placeholder, ?string $pattern = null): RouteCollectionInterface
391
    {
392
        if (! is_array($placeholder)) {
5✔
393
            $placeholder = [$placeholder => $pattern];
4✔
394
        }
395

396
        $this->placeholders = array_merge($this->placeholders, $placeholder);
5✔
397

398
        return $this;
5✔
399
    }
400

401
    /**
402
     * For `spark routes`
403
     *
404
     * @return array<string, string>
405
     *
406
     * @internal
407
     */
408
    public function getPlaceholders(): array
409
    {
410
        return $this->placeholders;
15✔
411
    }
412

413
    /**
414
     * Sets the default namespace to use for Controllers when no other
415
     * namespace has been specified.
416
     */
417
    public function setDefaultNamespace(string $value): RouteCollectionInterface
418
    {
419
        $this->defaultNamespace = esc(strip_tags($value));
27✔
420
        $this->defaultNamespace = rtrim($this->defaultNamespace, '\\') . '\\';
27✔
421

422
        return $this;
27✔
423
    }
424

425
    /**
426
     * Sets the default controller to use when no other controller has been
427
     * specified.
428
     */
429
    public function setDefaultController(string $value): RouteCollectionInterface
430
    {
431
        $this->defaultController = esc(strip_tags($value));
10✔
432

433
        return $this;
10✔
434
    }
435

436
    /**
437
     * Sets the default method to call on the controller when no other
438
     * method has been set in the route.
439
     */
440
    public function setDefaultMethod(string $value): RouteCollectionInterface
441
    {
442
        $this->defaultMethod = esc(strip_tags($value));
7✔
443

444
        return $this;
7✔
445
    }
446

447
    /**
448
     * Tells the system whether to convert dashes in URI strings into
449
     * underscores. In some search engines, including Google, dashes
450
     * create more meaning and make it easier for the search engine to
451
     * find words and meaning in the URI for better SEO. But it
452
     * doesn't work well with PHP method names....
453
     */
454
    public function setTranslateURIDashes(bool $value): RouteCollectionInterface
455
    {
456
        $this->translateURIDashes = $value;
1✔
457

458
        return $this;
1✔
459
    }
460

461
    /**
462
     * If TRUE, the system will attempt to match the URI against
463
     * Controllers by matching each segment against folders/files
464
     * in APPPATH/Controllers, when a match wasn't found against
465
     * defined routes.
466
     *
467
     * If FALSE, will stop searching and do NO automatic routing.
468
     */
469
    public function setAutoRoute(bool $value): RouteCollectionInterface
470
    {
471
        $this->autoRoute = $value;
39✔
472

473
        return $this;
39✔
474
    }
475

476
    /**
477
     * Sets the class/method that should be called if routing doesn't
478
     * find a match. It can be either a closure or the controller/method
479
     * name exactly like a route is defined: Users::index
480
     *
481
     * This setting is passed to the Router class and handled there.
482
     *
483
     * @param callable|string|null $callable
484
     */
485
    public function set404Override($callable = null): RouteCollectionInterface
486
    {
487
        $this->override404 = $callable;
6✔
488

489
        return $this;
6✔
490
    }
491

492
    /**
493
     * Returns the 404 Override setting, which can be null, a closure
494
     * or the controller/string.
495
     *
496
     * @return (Closure(string): (ResponseInterface|string|void))|string|null
497
     */
498
    public function get404Override()
499
    {
500
        return $this->override404;
14✔
501
    }
502

503
    /**
504
     * Sets the default constraint to be used in the system. Typically
505
     * for use with the 'resource' method.
506
     */
507
    public function setDefaultConstraint(string $placeholder): RouteCollectionInterface
508
    {
509
        if (array_key_exists($placeholder, $this->placeholders)) {
2✔
510
            $this->defaultPlaceholder = $placeholder;
1✔
511
        }
512

513
        return $this;
2✔
514
    }
515

516
    /**
517
     * Returns the name of the default controller. With Namespace.
518
     */
519
    public function getDefaultController(): string
520
    {
521
        return $this->defaultController;
221✔
522
    }
523

524
    /**
525
     * Returns the name of the default method to use within the controller.
526
     */
527
    public function getDefaultMethod(): string
528
    {
529
        return $this->defaultMethod;
221✔
530
    }
531

532
    /**
533
     * Returns the default namespace as set in the Routes config file.
534
     */
535
    public function getDefaultNamespace(): string
536
    {
537
        return $this->defaultNamespace;
34✔
538
    }
539

540
    /**
541
     * Returns the current value of the translateURIDashes setting.
542
     */
543
    public function shouldTranslateURIDashes(): bool
544
    {
545
        return $this->translateURIDashes;
181✔
546
    }
547

548
    /**
549
     * Returns the flag that tells whether to autoRoute URI against Controllers.
550
     */
551
    public function shouldAutoRoute(): bool
552
    {
553
        return $this->autoRoute;
184✔
554
    }
555

556
    /**
557
     * Returns the raw array of available routes.
558
     *
559
     * @param non-empty-string|null $verb            HTTP verb like `GET`,`POST` or `*` or `CLI`.
560
     * @param bool                  $includeWildcard Whether to include '*' routes.
561
     */
562
    public function getRoutes(?string $verb = null, bool $includeWildcard = true): array
563
    {
564
        if ($verb === null || $verb === '') {
244✔
565
            $verb = $this->getHTTPVerb();
57✔
566
        }
567

568
        // Since this is the entry point for the Router,
569
        // take a moment to do any route discovery
570
        // we might need to do.
571
        $this->discoverRoutes();
244✔
572

573
        $routes = [];
244✔
574

575
        if (isset($this->routes[$verb])) {
244✔
576
            // Keep current verb's routes at the beginning, so they're matched
577
            // before any of the generic, "add" routes.
578
            $collection = $includeWildcard ? $this->routes[$verb] + ($this->routes['*'] ?? []) : $this->routes[$verb];
244✔
579

580
            foreach ($collection as $routeKey => $r) {
244✔
581
                $routes[$routeKey] = $r['handler'];
222✔
582
            }
583
        }
584

585
        // sorting routes by priority
586
        if ($this->prioritizeDetected && $this->prioritize && $routes !== []) {
244✔
587
            $order = [];
1✔
588

589
            foreach ($routes as $key => $value) {
1✔
590
                $key                    = $key === '/' ? $key : ltrim($key, '/ ');
1✔
591
                $priority               = $this->getRoutesOptions($key, $verb)['priority'] ?? 0;
1✔
592
                $order[$priority][$key] = $value;
1✔
593
            }
594

595
            ksort($order);
1✔
596
            $routes = array_merge(...$order);
1✔
597
        }
598

599
        return $routes;
244✔
600
    }
601

602
    /**
603
     * Returns one or all routes options
604
     *
605
     * @param string|null $verb HTTP verb like `GET`,`POST` or `*` or `CLI`.
606
     *
607
     * @return array<string, int|string> [key => value]
608
     */
609
    public function getRoutesOptions(?string $from = null, ?string $verb = null): array
610
    {
611
        $options = $this->loadRoutesOptions($verb);
134✔
612

613
        return $from ? $options[$from] ?? [] : $options;
134✔
614
    }
615

616
    /**
617
     * Returns the current HTTP Verb being used.
618
     */
619
    public function getHTTPVerb(): string
620
    {
621
        return $this->HTTPVerb;
252✔
622
    }
623

624
    /**
625
     * Sets the current HTTP verb.
626
     * Used primarily for testing.
627
     *
628
     * @param string $verb HTTP verb
629
     *
630
     * @return $this
631
     */
632
    public function setHTTPVerb(string $verb)
633
    {
634
        if ($verb !== '*' && $verb === strtolower($verb)) {
302✔
635
            @trigger_error(
×
636
                'Passing lowercase HTTP method "' . $verb . '" is deprecated.'
×
637
                . ' Use uppercase HTTP method like "' . strtoupper($verb) . '".',
×
638
                E_USER_DEPRECATED
×
639
            );
×
640
        }
641

642
        /**
643
         * @deprecated 4.5.0
644
         * @TODO Remove strtoupper() in the future.
645
         */
646
        $this->HTTPVerb = strtoupper($verb);
302✔
647

648
        return $this;
302✔
649
    }
650

651
    /**
652
     * A shortcut method to add a number of routes at a single time.
653
     * It does not allow any options to be set on the route, or to
654
     * define the method used.
655
     */
656
    public function map(array $routes = [], ?array $options = null): RouteCollectionInterface
657
    {
658
        foreach ($routes as $from => $to) {
68✔
659
            $this->add($from, $to, $options);
68✔
660
        }
661

662
        return $this;
68✔
663
    }
664

665
    /**
666
     * Adds a single route to the collection.
667
     *
668
     * Example:
669
     *      $routes->add('news', 'Posts::index');
670
     *
671
     * @param array|(Closure(mixed...): (ResponseInterface|string|void))|string $to
672
     */
673
    public function add(string $from, $to, ?array $options = null): RouteCollectionInterface
674
    {
675
        $this->create('*', $from, $to, $options);
331✔
676

677
        return $this;
331✔
678
    }
679

680
    /**
681
     * Adds a temporary redirect from one route to another. Used for
682
     * redirecting traffic from old, non-existing routes to the new
683
     * moved routes.
684
     *
685
     * @param string $from   The pattern to match against
686
     * @param string $to     Either a route name or a URI to redirect to
687
     * @param int    $status The HTTP status code that should be returned with this redirect
688
     *
689
     * @return RouteCollection
690
     */
691
    public function addRedirect(string $from, string $to, int $status = 302)
692
    {
693
        // Use the named route's pattern if this is a named route.
694
        if (array_key_exists($to, $this->routesNames['*'])) {
16✔
695
            $routeName  = $to;
3✔
696
            $routeKey   = $this->routesNames['*'][$routeName];
3✔
697
            $redirectTo = [$routeKey => $this->routes['*'][$routeKey]['handler']];
3✔
698
        } elseif (array_key_exists($to, $this->routesNames[Method::GET])) {
13✔
699
            $routeName  = $to;
4✔
700
            $routeKey   = $this->routesNames[Method::GET][$routeName];
4✔
701
            $redirectTo = [$routeKey => $this->routes[Method::GET][$routeKey]['handler']];
4✔
702
        } else {
703
            // The named route is not found.
704
            $redirectTo = $to;
9✔
705
        }
706

707
        $this->create('*', $from, $redirectTo, ['redirect' => $status]);
16✔
708

709
        return $this;
16✔
710
    }
711

712
    /**
713
     * Determines if the route is a redirecting route.
714
     *
715
     * @param string $routeKey routeKey or route name
716
     */
717
    public function isRedirect(string $routeKey): bool
718
    {
719
        if (isset($this->routes['*'][$routeKey]['redirect'])) {
140✔
720
            return true;
16✔
721
        }
722

723
        // This logic is not used. Should be deprecated?
724
        $routeName = $this->routes['*'][$routeKey]['name'] ?? null;
124✔
725
        if ($routeName === $routeKey) {
124✔
726
            $routeKey = $this->routesNames['*'][$routeName];
36✔
727

728
            return isset($this->routes['*'][$routeKey]['redirect']);
36✔
729
        }
730

731
        return false;
89✔
732
    }
733

734
    /**
735
     * Grabs the HTTP status code from a redirecting Route.
736
     *
737
     * @param string $routeKey routeKey or route name
738
     */
739
    public function getRedirectCode(string $routeKey): int
740
    {
741
        if (isset($this->routes['*'][$routeKey]['redirect'])) {
16✔
742
            return $this->routes['*'][$routeKey]['redirect'];
16✔
743
        }
744

745
        // This logic is not used. Should be deprecated?
746
        $routeName = $this->routes['*'][$routeKey]['name'] ?? null;
1✔
747
        if ($routeName === $routeKey) {
1✔
748
            $routeKey = $this->routesNames['*'][$routeName];
×
749

750
            return $this->routes['*'][$routeKey]['redirect'];
×
751
        }
752

753
        return 0;
1✔
754
    }
755

756
    /**
757
     * Group a series of routes under a single URL segment. This is handy
758
     * for grouping items into an admin area, like:
759
     *
760
     * Example:
761
     *     // Creates route: admin/users
762
     *     $route->group('admin', function() {
763
     *            $route->resource('users');
764
     *     });
765
     *
766
     * @param string         $name      The name to group/prefix the routes with.
767
     * @param array|callable ...$params
768
     *
769
     * @return void
770
     */
771
    public function group(string $name, ...$params)
772
    {
773
        $oldGroup   = $this->group;
18✔
774
        $oldOptions = $this->currentOptions;
18✔
775

776
        // To register a route, we'll set a flag so that our router
777
        // will see the group name.
778
        // If the group name is empty, we go on using the previously built group name.
779
        $this->group = $name !== '' ? trim($oldGroup . '/' . $name, '/') : $oldGroup;
18✔
780

781
        $callback = array_pop($params);
18✔
782

783
        if ($params && is_array($params[0])) {
18✔
784
            $options = array_shift($params);
9✔
785

786
            if (isset($options['filter'])) {
9✔
787
                // Merge filters.
788
                $currentFilter     = (array) ($this->currentOptions['filter'] ?? []);
8✔
789
                $options['filter'] = array_merge($currentFilter, (array) $options['filter']);
8✔
790
            }
791

792
            // Merge options other than filters.
793
            $this->currentOptions = array_merge(
9✔
794
                $this->currentOptions ?? [],
9✔
795
                $options
9✔
796
            );
9✔
797
        }
798

799
        if (is_callable($callback)) {
18✔
800
            $callback($this);
18✔
801
        }
802

803
        $this->group          = $oldGroup;
18✔
804
        $this->currentOptions = $oldOptions;
18✔
805
    }
806

807
    /*
808
     * --------------------------------------------------------------------
809
     *  HTTP Verb-based routing
810
     * --------------------------------------------------------------------
811
     * Routing works here because, as the routes Config file is read in,
812
     * the various HTTP verb-based routes will only be added to the in-memory
813
     * routes if it is a call that should respond to that verb.
814
     *
815
     * The options array is typically used to pass in an 'as' or var, but may
816
     * be expanded in the future. See the docblock for 'add' method above for
817
     * current list of globally available options.
818
     */
819

820
    /**
821
     * Creates a collections of HTTP-verb based routes for a controller.
822
     *
823
     * Possible Options:
824
     *      'controller'    - Customize the name of the controller used in the 'to' route
825
     *      'placeholder'   - The regex used by the Router. Defaults to '(:any)'
826
     *      'websafe'   -        - '1' if only GET and POST HTTP verbs are supported
827
     *
828
     * Example:
829
     *
830
     *      $route->resource('photos');
831
     *
832
     *      // Generates the following routes:
833
     *      HTTP Verb | Path        | Action        | Used for...
834
     *      ----------+-------------+---------------+-----------------
835
     *      GET         /photos             index           an array of photo objects
836
     *      GET         /photos/new         new             an empty photo object, with default properties
837
     *      GET         /photos/{id}/edit   edit            a specific photo object, editable properties
838
     *      GET         /photos/{id}        show            a specific photo object, all properties
839
     *      POST        /photos             create          a new photo object, to add to the resource
840
     *      DELETE      /photos/{id}        delete          deletes the specified photo object
841
     *      PUT/PATCH   /photos/{id}        update          replacement properties for existing photo
842
     *
843
     *  If 'websafe' option is present, the following paths are also available:
844
     *
845
     *      POST                /photos/{id}/delete delete
846
     *      POST        /photos/{id}        update
847
     *
848
     * @param string     $name    The name of the resource/controller to route to.
849
     * @param array|null $options A list of possible ways to customize the routing.
850
     */
851
    public function resource(string $name, ?array $options = null): RouteCollectionInterface
852
    {
853
        // In order to allow customization of the route the
854
        // resources are sent to, we need to have a new name
855
        // to store the values in.
856
        $newName = implode('\\', array_map(ucfirst(...), explode('/', $name)));
18✔
857

858
        // If a new controller is specified, then we replace the
859
        // $name value with the name of the new controller.
860
        if (isset($options['controller'])) {
18✔
861
            $newName = ucfirst(esc(strip_tags($options['controller'])));
11✔
862
        }
863

864
        // In order to allow customization of allowed id values
865
        // we need someplace to store them.
866
        $id = $options['placeholder'] ?? $this->placeholders[$this->defaultPlaceholder] ?? '(:segment)';
18✔
867

868
        // Make sure we capture back-references
869
        $id = '(' . trim($id, '()') . ')';
18✔
870

871
        $methods = isset($options['only']) ? (is_string($options['only']) ? explode(',', $options['only']) : $options['only']) : ['index', 'show', 'create', 'update', 'delete', 'new', 'edit'];
18✔
872

873
        if (isset($options['except'])) {
18✔
874
            $options['except'] = is_array($options['except']) ? $options['except'] : explode(',', $options['except']);
1✔
875

876
            foreach ($methods as $i => $method) {
1✔
877
                if (in_array($method, $options['except'], true)) {
1✔
878
                    unset($methods[$i]);
1✔
879
                }
880
            }
881
        }
882

883
        if (in_array('index', $methods, true)) {
18✔
884
            $this->get($name, $newName . '::index', $options);
18✔
885
        }
886
        if (in_array('new', $methods, true)) {
18✔
887
            $this->get($name . '/new', $newName . '::new', $options);
16✔
888
        }
889
        if (in_array('edit', $methods, true)) {
18✔
890
            $this->get($name . '/' . $id . '/edit', $newName . '::edit/$1', $options);
16✔
891
        }
892
        if (in_array('show', $methods, true)) {
18✔
893
            $this->get($name . '/' . $id, $newName . '::show/$1', $options);
17✔
894
        }
895
        if (in_array('create', $methods, true)) {
18✔
896
            $this->post($name, $newName . '::create', $options);
17✔
897
        }
898
        if (in_array('update', $methods, true)) {
18✔
899
            $this->put($name . '/' . $id, $newName . '::update/$1', $options);
17✔
900
            $this->patch($name . '/' . $id, $newName . '::update/$1', $options);
17✔
901
        }
902
        if (in_array('delete', $methods, true)) {
18✔
903
            $this->delete($name . '/' . $id, $newName . '::delete/$1', $options);
17✔
904
        }
905

906
        // Web Safe? delete needs checking before update because of method name
907
        if (isset($options['websafe'])) {
18✔
908
            if (in_array('delete', $methods, true)) {
1✔
909
                $this->post($name . '/' . $id . '/delete', $newName . '::delete/$1', $options);
1✔
910
            }
911
            if (in_array('update', $methods, true)) {
1✔
912
                $this->post($name . '/' . $id, $newName . '::update/$1', $options);
1✔
913
            }
914
        }
915

916
        return $this;
18✔
917
    }
918

919
    /**
920
     * Creates a collections of HTTP-verb based routes for a presenter controller.
921
     *
922
     * Possible Options:
923
     *      'controller'    - Customize the name of the controller used in the 'to' route
924
     *      'placeholder'   - The regex used by the Router. Defaults to '(:any)'
925
     *
926
     * Example:
927
     *
928
     *      $route->presenter('photos');
929
     *
930
     *      // Generates the following routes:
931
     *      HTTP Verb | Path        | Action        | Used for...
932
     *      ----------+-------------+---------------+-----------------
933
     *      GET         /photos             index           showing all array of photo objects
934
     *      GET         /photos/show/{id}   show            showing a specific photo object, all properties
935
     *      GET         /photos/new         new             showing a form for an empty photo object, with default properties
936
     *      POST        /photos/create      create          processing the form for a new photo
937
     *      GET         /photos/edit/{id}   edit            show an editing form for a specific photo object, editable properties
938
     *      POST        /photos/update/{id} update          process the editing form data
939
     *      GET         /photos/remove/{id} remove          show a form to confirm deletion of a specific photo object
940
     *      POST        /photos/delete/{id} delete          deleting the specified photo object
941
     *
942
     * @param string     $name    The name of the controller to route to.
943
     * @param array|null $options A list of possible ways to customize the routing.
944
     */
945
    public function presenter(string $name, ?array $options = null): RouteCollectionInterface
946
    {
947
        // In order to allow customization of the route the
948
        // resources are sent to, we need to have a new name
949
        // to store the values in.
950
        $newName = implode('\\', array_map(ucfirst(...), explode('/', $name)));
9✔
951

952
        // If a new controller is specified, then we replace the
953
        // $name value with the name of the new controller.
954
        if (isset($options['controller'])) {
9✔
955
            $newName = ucfirst(esc(strip_tags($options['controller'])));
8✔
956
        }
957

958
        // In order to allow customization of allowed id values
959
        // we need someplace to store them.
960
        $id = $options['placeholder'] ?? $this->placeholders[$this->defaultPlaceholder] ?? '(:segment)';
9✔
961

962
        // Make sure we capture back-references
963
        $id = '(' . trim($id, '()') . ')';
9✔
964

965
        $methods = isset($options['only']) ? (is_string($options['only']) ? explode(',', $options['only']) : $options['only']) : ['index', 'show', 'new', 'create', 'edit', 'update', 'remove', 'delete'];
9✔
966

967
        if (isset($options['except'])) {
9✔
968
            $options['except'] = is_array($options['except']) ? $options['except'] : explode(',', $options['except']);
×
969

970
            foreach ($methods as $i => $method) {
×
971
                if (in_array($method, $options['except'], true)) {
×
972
                    unset($methods[$i]);
×
973
                }
974
            }
975
        }
976

977
        if (in_array('index', $methods, true)) {
9✔
978
            $this->get($name, $newName . '::index', $options);
9✔
979
        }
980
        if (in_array('show', $methods, true)) {
9✔
981
            $this->get($name . '/show/' . $id, $newName . '::show/$1', $options);
9✔
982
        }
983
        if (in_array('new', $methods, true)) {
9✔
984
            $this->get($name . '/new', $newName . '::new', $options);
9✔
985
        }
986
        if (in_array('create', $methods, true)) {
9✔
987
            $this->post($name . '/create', $newName . '::create', $options);
9✔
988
        }
989
        if (in_array('edit', $methods, true)) {
9✔
990
            $this->get($name . '/edit/' . $id, $newName . '::edit/$1', $options);
9✔
991
        }
992
        if (in_array('update', $methods, true)) {
9✔
993
            $this->post($name . '/update/' . $id, $newName . '::update/$1', $options);
9✔
994
        }
995
        if (in_array('remove', $methods, true)) {
9✔
996
            $this->get($name . '/remove/' . $id, $newName . '::remove/$1', $options);
9✔
997
        }
998
        if (in_array('delete', $methods, true)) {
9✔
999
            $this->post($name . '/delete/' . $id, $newName . '::delete/$1', $options);
9✔
1000
        }
1001
        if (in_array('show', $methods, true)) {
9✔
1002
            $this->get($name . '/' . $id, $newName . '::show/$1', $options);
9✔
1003
        }
1004
        if (in_array('create', $methods, true)) {
9✔
1005
            $this->post($name, $newName . '::create', $options);
9✔
1006
        }
1007

1008
        return $this;
9✔
1009
    }
1010

1011
    /**
1012
     * Specifies a single route to match for multiple HTTP Verbs.
1013
     *
1014
     * Example:
1015
     *  $route->match( ['GET', 'POST'], 'users/(:num)', 'users/$1);
1016
     *
1017
     * @param array|(Closure(mixed...): (ResponseInterface|string|void))|string $to
1018
     */
1019
    public function match(array $verbs = [], string $from = '', $to = '', ?array $options = null): RouteCollectionInterface
1020
    {
1021
        if ($from === '' || empty($to)) {
4✔
1022
            throw new InvalidArgumentException('You must supply the parameters: from, to.');
×
1023
        }
1024

1025
        foreach ($verbs as $verb) {
4✔
1026
            if ($verb === strtolower($verb)) {
4✔
1027
                @trigger_error(
×
1028
                    'Passing lowercase HTTP method "' . $verb . '" is deprecated.'
×
1029
                    . ' Use uppercase HTTP method like "' . strtoupper($verb) . '".',
×
1030
                    E_USER_DEPRECATED
×
1031
                );
×
1032
            }
1033

1034
            /**
1035
             * @TODO We should use correct uppercase verb.
1036
             * @deprecated 4.5.0
1037
             */
1038
            $verb = strtolower($verb);
4✔
1039

1040
            $this->{$verb}($from, $to, $options);
4✔
1041
        }
1042

1043
        return $this;
4✔
1044
    }
1045

1046
    /**
1047
     * Specifies a route that is only available to GET requests.
1048
     *
1049
     * @param array|(Closure(mixed...): (ResponseInterface|string|void))|string $to
1050
     */
1051
    public function get(string $from, $to, ?array $options = null): RouteCollectionInterface
1052
    {
1053
        $this->create(Method::GET, $from, $to, $options);
279✔
1054

1055
        return $this;
279✔
1056
    }
1057

1058
    /**
1059
     * Specifies a route that is only available to POST requests.
1060
     *
1061
     * @param array|(Closure(mixed...): (ResponseInterface|string|void))|string $to
1062
     */
1063
    public function post(string $from, $to, ?array $options = null): RouteCollectionInterface
1064
    {
1065
        $this->create(Method::POST, $from, $to, $options);
46✔
1066

1067
        return $this;
46✔
1068
    }
1069

1070
    /**
1071
     * Specifies a route that is only available to PUT requests.
1072
     *
1073
     * @param array|(Closure(mixed...): (ResponseInterface|string|void))|string $to
1074
     */
1075
    public function put(string $from, $to, ?array $options = null): RouteCollectionInterface
1076
    {
1077
        $this->create(Method::PUT, $from, $to, $options);
25✔
1078

1079
        return $this;
25✔
1080
    }
1081

1082
    /**
1083
     * Specifies a route that is only available to DELETE requests.
1084
     *
1085
     * @param array|(Closure(mixed...): (ResponseInterface|string|void))|string $to
1086
     */
1087
    public function delete(string $from, $to, ?array $options = null): RouteCollectionInterface
1088
    {
1089
        $this->create(Method::DELETE, $from, $to, $options);
19✔
1090

1091
        return $this;
19✔
1092
    }
1093

1094
    /**
1095
     * Specifies a route that is only available to HEAD requests.
1096
     *
1097
     * @param array|(Closure(mixed...): (ResponseInterface|string|void))|string $to
1098
     */
1099
    public function head(string $from, $to, ?array $options = null): RouteCollectionInterface
1100
    {
1101
        $this->create(Method::HEAD, $from, $to, $options);
1✔
1102

1103
        return $this;
1✔
1104
    }
1105

1106
    /**
1107
     * Specifies a route that is only available to PATCH requests.
1108
     *
1109
     * @param array|(Closure(mixed...): (ResponseInterface|string|void))|string $to
1110
     */
1111
    public function patch(string $from, $to, ?array $options = null): RouteCollectionInterface
1112
    {
1113
        $this->create(Method::PATCH, $from, $to, $options);
19✔
1114

1115
        return $this;
19✔
1116
    }
1117

1118
    /**
1119
     * Specifies a route that is only available to OPTIONS requests.
1120
     *
1121
     * @param array|(Closure(mixed...): (ResponseInterface|string|void))|string $to
1122
     */
1123
    public function options(string $from, $to, ?array $options = null): RouteCollectionInterface
1124
    {
1125
        $this->create(Method::OPTIONS, $from, $to, $options);
3✔
1126

1127
        return $this;
3✔
1128
    }
1129

1130
    /**
1131
     * Specifies a route that is only available to command-line requests.
1132
     *
1133
     * @param array|(Closure(mixed...): (ResponseInterface|string|void))|string $to
1134
     */
1135
    public function cli(string $from, $to, ?array $options = null): RouteCollectionInterface
1136
    {
1137
        $this->create('CLI', $from, $to, $options);
7✔
1138

1139
        return $this;
7✔
1140
    }
1141

1142
    /**
1143
     * Specifies a route that will only display a view.
1144
     * Only works for GET requests.
1145
     */
1146
    public function view(string $from, string $view, ?array $options = null): RouteCollectionInterface
1147
    {
1148
        $to = static fn (...$data) => service('renderer')
2✔
1149
            ->setData(['segments' => $data], 'raw')
2✔
1150
            ->render($view, $options);
2✔
1151

1152
        $routeOptions = $options ?? [];
2✔
1153
        $routeOptions = array_merge($routeOptions, ['view' => $view]);
2✔
1154

1155
        $this->create(Method::GET, $from, $to, $routeOptions);
2✔
1156

1157
        return $this;
2✔
1158
    }
1159

1160
    /**
1161
     * Limits the routes to a specified ENVIRONMENT or they won't run.
1162
     */
1163
    public function environment(string $env, Closure $callback): RouteCollectionInterface
1164
    {
1165
        if ($env === ENVIRONMENT) {
1✔
1166
            $callback($this);
1✔
1167
        }
1168

1169
        return $this;
1✔
1170
    }
1171

1172
    /**
1173
     * Attempts to look up a route based on its destination.
1174
     *
1175
     * If a route exists:
1176
     *
1177
     *      'path/(:any)/(:any)' => 'Controller::method/$1/$2'
1178
     *
1179
     * This method allows you to know the Controller and method
1180
     * and get the route that leads to it.
1181
     *
1182
     *      // Equals 'path/$param1/$param2'
1183
     *      reverseRoute('Controller::method', $param1, $param2);
1184
     *
1185
     * @param string     $search    Route name or Controller::method
1186
     * @param int|string ...$params One or more parameters to be passed to the route.
1187
     *                              The last parameter allows you to set the locale.
1188
     *
1189
     * @return false|string The route (URI path relative to baseURL) or false if not found.
1190
     */
1191
    public function reverseRoute(string $search, ...$params)
1192
    {
1193
        if ($search === '') {
52✔
1194
            return false;
2✔
1195
        }
1196

1197
        // Named routes get higher priority.
1198
        foreach ($this->routesNames as $verb => $collection) {
50✔
1199
            if (array_key_exists($search, $collection)) {
50✔
1200
                $routeKey = $collection[$search];
26✔
1201

1202
                $from = $this->routes[$verb][$routeKey]['from'];
26✔
1203

1204
                return $this->buildReverseRoute($from, $params);
26✔
1205
            }
1206
        }
1207

1208
        // Add the default namespace if needed.
1209
        $namespace = trim($this->defaultNamespace, '\\') . '\\';
24✔
1210
        if (
1211
            ! str_starts_with($search, '\\')
24✔
1212
            && ! str_starts_with($search, $namespace)
24✔
1213
        ) {
1214
            $search = $namespace . $search;
22✔
1215
        }
1216

1217
        // If it's not a named route, then loop over
1218
        // all routes to find a match.
1219
        foreach ($this->routes as $collection) {
24✔
1220
            foreach ($collection as $route) {
24✔
1221
                $to   = $route['handler'];
19✔
1222
                $from = $route['from'];
19✔
1223

1224
                // ignore closures
1225
                if (! is_string($to)) {
19✔
1226
                    continue;
3✔
1227
                }
1228

1229
                // Lose any namespace slash at beginning of strings
1230
                // to ensure more consistent match.
1231
                $to     = ltrim($to, '\\');
18✔
1232
                $search = ltrim($search, '\\');
18✔
1233

1234
                // If there's any chance of a match, then it will
1235
                // be with $search at the beginning of the $to string.
1236
                if (! str_starts_with($to, $search)) {
18✔
1237
                    continue;
6✔
1238
                }
1239

1240
                // Ensure that the number of $params given here
1241
                // matches the number of back-references in the route
1242
                if (substr_count($to, '$') !== count($params)) {
14✔
1243
                    continue;
1✔
1244
                }
1245

1246
                return $this->buildReverseRoute($from, $params);
13✔
1247
            }
1248
        }
1249

1250
        // If we're still here, then we did not find a match.
1251
        return false;
11✔
1252
    }
1253

1254
    /**
1255
     * Replaces the {locale} tag with the current application locale
1256
     *
1257
     * @deprecated Unused.
1258
     */
1259
    protected function localizeRoute(string $route): string
1260
    {
1261
        return strtr($route, ['{locale}' => service('request')->getLocale()]);
×
1262
    }
1263

1264
    /**
1265
     * Checks a route (using the "from") to see if it's filtered or not.
1266
     *
1267
     * @param string|null $verb HTTP verb like `GET`,`POST` or `*` or `CLI`.
1268
     */
1269
    public function isFiltered(string $search, ?string $verb = null): bool
1270
    {
1271
        $options = $this->loadRoutesOptions($verb);
123✔
1272

1273
        return isset($options[$search]['filter']);
123✔
1274
    }
1275

1276
    /**
1277
     * Returns the filters that should be applied for a single route, along
1278
     * with any parameters it might have. Parameters are found by splitting
1279
     * the parameter name on a colon to separate the filter name from the parameter list,
1280
     * and the splitting the result on commas. So:
1281
     *
1282
     *    'role:admin,manager'
1283
     *
1284
     * has a filter of "role", with parameters of ['admin', 'manager'].
1285
     *
1286
     * @param string      $search routeKey
1287
     * @param string|null $verb   HTTP verb like `GET`,`POST` or `*` or `CLI`.
1288
     *
1289
     * @return list<string> filter_name or filter_name:arguments like 'role:admin,manager'
1290
     */
1291
    public function getFiltersForRoute(string $search, ?string $verb = null): array
1292
    {
1293
        $options = $this->loadRoutesOptions($verb);
22✔
1294

1295
        if (! array_key_exists($search, $options) || ! array_key_exists('filter', $options[$search])) {
22✔
1296
            return [];
6✔
1297
        }
1298

1299
        if (is_string($options[$search]['filter'])) {
17✔
UNCOV
1300
            return [$options[$search]['filter']];
×
1301
        }
1302

1303
        return $options[$search]['filter'];
17✔
1304
    }
1305

1306
    /**
1307
     * Given a
1308
     *
1309
     * @throws RouterException
1310
     *
1311
     * @deprecated Unused. Now uses buildReverseRoute().
1312
     */
1313
    protected function fillRouteParams(string $from, ?array $params = null): string
1314
    {
1315
        // Find all of our back-references in the original route
1316
        preg_match_all('/\(([^)]+)\)/', $from, $matches);
×
1317

1318
        if (empty($matches[0])) {
×
1319
            return '/' . ltrim($from, '/');
×
1320
        }
1321

1322
        /**
1323
         * Build our resulting string, inserting the $params in
1324
         * the appropriate places.
1325
         *
1326
         * @var list<string> $patterns
1327
         */
1328
        $patterns = $matches[0];
×
1329

1330
        foreach ($patterns as $index => $pattern) {
×
1331
            if (! preg_match('#^' . $pattern . '$#u', $params[$index])) {
×
1332
                throw RouterException::forInvalidParameterType();
×
1333
            }
1334

1335
            // Ensure that the param we're inserting matches
1336
            // the expected param type.
1337
            $pos  = strpos($from, $pattern);
×
1338
            $from = substr_replace($from, $params[$index], $pos, strlen($pattern));
×
1339
        }
1340

1341
        return '/' . ltrim($from, '/');
×
1342
    }
1343

1344
    /**
1345
     * Builds reverse route
1346
     *
1347
     * @param array $params One or more parameters to be passed to the route.
1348
     *                      The last parameter allows you to set the locale.
1349
     */
1350
    protected function buildReverseRoute(string $from, array $params): string
1351
    {
1352
        $locale = null;
39✔
1353

1354
        // Find all of our back-references in the original route
1355
        preg_match_all('/\(([^)]+)\)/', $from, $matches);
39✔
1356

1357
        if (empty($matches[0])) {
39✔
1358
            if (str_contains($from, '{locale}')) {
12✔
1359
                $locale = $params[0] ?? null;
3✔
1360
            }
1361

1362
            $from = $this->replaceLocale($from, $locale);
12✔
1363

1364
            return '/' . ltrim($from, '/');
12✔
1365
        }
1366

1367
        // Locale is passed?
1368
        $placeholderCount = count($matches[0]);
27✔
1369
        if (count($params) > $placeholderCount) {
27✔
1370
            $locale = $params[$placeholderCount];
3✔
1371
        }
1372

1373
        /**
1374
         * Build our resulting string, inserting the $params in
1375
         * the appropriate places.
1376
         *
1377
         * @var list<string> $placeholders
1378
         */
1379
        $placeholders = $matches[0];
27✔
1380

1381
        foreach ($placeholders as $index => $placeholder) {
27✔
1382
            if (! isset($params[$index])) {
27✔
1383
                throw new InvalidArgumentException(
1✔
1384
                    'Missing argument for "' . $placeholder . '" in route "' . $from . '".'
1✔
1385
                );
1✔
1386
            }
1387

1388
            // Remove `(:` and `)` when $placeholder is a placeholder.
1389
            $placeholderName = substr($placeholder, 2, -1);
26✔
1390
            // or maybe $placeholder is not a placeholder, but a regex.
1391
            $pattern = $this->placeholders[$placeholderName] ?? $placeholder;
26✔
1392

1393
            if (! preg_match('#^' . $pattern . '$#u', (string) $params[$index])) {
26✔
1394
                throw RouterException::forInvalidParameterType();
1✔
1395
            }
1396

1397
            // Ensure that the param we're inserting matches
1398
            // the expected param type.
1399
            $pos  = strpos($from, $placeholder);
26✔
1400
            $from = substr_replace($from, (string) $params[$index], $pos, strlen($placeholder));
26✔
1401
        }
1402

1403
        $from = $this->replaceLocale($from, $locale);
25✔
1404

1405
        return '/' . ltrim($from, '/');
25✔
1406
    }
1407

1408
    /**
1409
     * Replaces the {locale} tag with the locale
1410
     */
1411
    private function replaceLocale(string $route, ?string $locale = null): string
1412
    {
1413
        if (! str_contains($route, '{locale}')) {
37✔
1414
            return $route;
28✔
1415
        }
1416

1417
        // Check invalid locale
1418
        if ($locale !== null) {
9✔
1419
            $config = config(App::class);
3✔
1420
            if (! in_array($locale, $config->supportedLocales, true)) {
3✔
1421
                $locale = null;
1✔
1422
            }
1423
        }
1424

1425
        if ($locale === null) {
9✔
1426
            $locale = service('request')->getLocale();
7✔
1427
        }
1428

1429
        return strtr($route, ['{locale}' => $locale]);
9✔
1430
    }
1431

1432
    /**
1433
     * Does the heavy lifting of creating an actual route. You must specify
1434
     * the request method(s) that this route will work for. They can be separated
1435
     * by a pipe character "|" if there is more than one.
1436
     *
1437
     * @param array|(Closure(mixed...): (ResponseInterface|string|void))|string $to
1438
     *
1439
     * @return void
1440
     */
1441
    protected function create(string $verb, string $from, $to, ?array $options = null)
1442
    {
1443
        $overwrite = false;
402✔
1444
        $prefix    = $this->group === null ? '' : $this->group . '/';
402✔
1445

1446
        $from = esc(strip_tags($prefix . $from));
402✔
1447

1448
        // While we want to add a route within a group of '/',
1449
        // it doesn't work with matching, so remove them...
1450
        if ($from !== '/') {
402✔
1451
            $from = trim($from, '/');
395✔
1452
        }
1453

1454
        // When redirecting to named route, $to is an array like `['zombies' => '\Zombies::index']`.
1455
        if (is_array($to) && isset($to[0])) {
402✔
1456
            $to = $this->processArrayCallableSyntax($from, $to);
3✔
1457
        }
1458

1459
        // Merge group filters.
1460
        if (isset($options['filter'])) {
402✔
1461
            $currentFilter     = (array) ($this->currentOptions['filter'] ?? []);
14✔
1462
            $options['filter'] = array_merge($currentFilter, (array) $options['filter']);
14✔
1463
        }
1464

1465
        $options = array_merge($this->currentOptions ?? [], $options ?? []);
402✔
1466

1467
        // Route priority detect
1468
        if (isset($options['priority'])) {
402✔
1469
            $options['priority'] = abs((int) $options['priority']);
3✔
1470

1471
            if ($options['priority'] > 0) {
3✔
1472
                $this->prioritizeDetected = true;
3✔
1473
            }
1474
        }
1475

1476
        // Hostname limiting?
1477
        if (! empty($options['hostname'])) {
402✔
1478
            // @todo determine if there's a way to whitelist hosts?
1479
            if (! $this->checkHostname($options['hostname'])) {
205✔
1480
                return;
202✔
1481
            }
1482

1483
            $overwrite = true;
4✔
1484
        }
1485
        // Limiting to subdomains?
1486
        elseif (! empty($options['subdomain'])) {
398✔
1487
            // If we don't match the current subdomain, then
1488
            // we don't need to add the route.
1489
            if (! $this->checkSubdomains($options['subdomain'])) {
218✔
1490
                return;
209✔
1491
            }
1492

1493
            $overwrite = true;
16✔
1494
        }
1495

1496
        // Are we offsetting the binds?
1497
        // If so, take care of them here in one
1498
        // fell swoop.
1499
        if (isset($options['offset']) && is_string($to)) {
396✔
1500
            // Get a constant string to work with.
1501
            $to = preg_replace('/(\$\d+)/', '$X', $to);
1✔
1502

1503
            for ($i = (int) $options['offset'] + 1; $i < (int) $options['offset'] + 7; $i++) {
1✔
1504
                $to = preg_replace_callback(
1✔
1505
                    '/\$X/',
1✔
1506
                    static fn ($m) => '$' . $i,
1✔
1507
                    $to,
1✔
1508
                    1
1✔
1509
                );
1✔
1510
            }
1511
        }
1512

1513
        $routeKey = $from;
396✔
1514

1515
        // Replace our regex pattern placeholders with the actual thing
1516
        // so that the Router doesn't need to know about any of this.
1517
        foreach ($this->placeholders as $tag => $pattern) {
396✔
1518
            $routeKey = str_ireplace(':' . $tag, $pattern, $routeKey);
396✔
1519
        }
1520

1521
        // If is redirect, No processing
1522
        if (! isset($options['redirect']) && is_string($to)) {
396✔
1523
            // If no namespace found, add the default namespace
1524
            if (! str_contains($to, '\\') || strpos($to, '\\') > 0) {
382✔
1525
                $namespace = $options['namespace'] ?? $this->defaultNamespace;
361✔
1526
                $to        = trim($namespace, '\\') . '\\' . $to;
361✔
1527
            }
1528
            // Always ensure that we escape our namespace so we're not pointing to
1529
            // \CodeIgniter\Routes\Controller::method.
1530
            $to = '\\' . ltrim($to, '\\');
382✔
1531
        }
1532

1533
        $name = $options['as'] ?? $routeKey;
396✔
1534

1535
        helper('array');
396✔
1536

1537
        // Don't overwrite any existing 'froms' so that auto-discovered routes
1538
        // do not overwrite any app/Config/Routes settings. The app
1539
        // routes should always be the "source of truth".
1540
        // this works only because discovered routes are added just prior
1541
        // to attempting to route the request.
1542
        $routeKeyExists = isset($this->routes[$verb][$routeKey]);
396✔
1543
        if ((isset($this->routesNames[$verb][$name]) || $routeKeyExists) && ! $overwrite) {
396✔
1544
            return;
10✔
1545
        }
1546

1547
        $this->routes[$verb][$routeKey] = [
396✔
1548
            'name'    => $name,
396✔
1549
            'handler' => $to,
396✔
1550
            'from'    => $from,
396✔
1551
        ];
396✔
1552
        $this->routesOptions[$verb][$routeKey] = $options;
396✔
1553
        $this->routesNames[$verb][$name]       = $routeKey;
396✔
1554

1555
        // Is this a redirect?
1556
        if (isset($options['redirect']) && is_numeric($options['redirect'])) {
396✔
1557
            $this->routes['*'][$routeKey]['redirect'] = $options['redirect'];
16✔
1558
        }
1559
    }
1560

1561
    /**
1562
     * Compares the hostname passed in against the current hostname
1563
     * on this page request.
1564
     *
1565
     * @param string $hostname Hostname in route options
1566
     */
1567
    private function checkHostname($hostname): bool
1568
    {
1569
        // CLI calls can't be on hostname.
1570
        if (! isset($this->httpHost)) {
205✔
1571
            return false;
193✔
1572
        }
1573

1574
        return strtolower($this->httpHost) === strtolower($hostname);
12✔
1575
    }
1576

1577
    private function processArrayCallableSyntax(string $from, array $to): string
1578
    {
1579
        // [classname, method]
1580
        // eg, [Home::class, 'index']
1581
        if (is_callable($to, true, $callableName)) {
3✔
1582
            // If the route has placeholders, add params automatically.
1583
            $params = $this->getMethodParams($from);
2✔
1584

1585
            return '\\' . $callableName . $params;
2✔
1586
        }
1587

1588
        // [[classname, method], params]
1589
        // eg, [[Home::class, 'index'], '$1/$2']
1590
        if (
1591
            isset($to[0], $to[1])
1✔
1592
            && is_callable($to[0], true, $callableName)
1✔
1593
            && is_string($to[1])
1✔
1594
        ) {
1595
            $to = '\\' . $callableName . '/' . $to[1];
1✔
1596
        }
1597

1598
        return $to;
1✔
1599
    }
1600

1601
    /**
1602
     * Returns the method param string like `/$1/$2` for placeholders
1603
     */
1604
    private function getMethodParams(string $from): string
1605
    {
1606
        preg_match_all('/\(.+?\)/', $from, $matches);
2✔
1607
        $count = is_countable($matches[0]) ? count($matches[0]) : 0;
2✔
1608

1609
        $params = '';
2✔
1610

1611
        for ($i = 1; $i <= $count; $i++) {
2✔
1612
            $params .= '/$' . $i;
1✔
1613
        }
1614

1615
        return $params;
2✔
1616
    }
1617

1618
    /**
1619
     * Compares the subdomain(s) passed in against the current subdomain
1620
     * on this page request.
1621
     *
1622
     * @param list<string>|string $subdomains
1623
     */
1624
    private function checkSubdomains($subdomains): bool
1625
    {
1626
        // CLI calls can't be on subdomain.
1627
        if (! isset($this->httpHost)) {
218✔
1628
            return false;
193✔
1629
        }
1630

1631
        if ($this->currentSubdomain === null) {
25✔
1632
            $this->currentSubdomain = $this->determineCurrentSubdomain();
25✔
1633
        }
1634

1635
        if (! is_array($subdomains)) {
25✔
1636
            $subdomains = [$subdomains];
25✔
1637
        }
1638

1639
        // Routes can be limited to any sub-domain. In that case, though,
1640
        // it does require a sub-domain to be present.
1641
        if (! empty($this->currentSubdomain) && in_array('*', $subdomains, true)) {
25✔
1642
            return true;
9✔
1643
        }
1644

1645
        return in_array($this->currentSubdomain, $subdomains, true);
23✔
1646
    }
1647

1648
    /**
1649
     * Examines the HTTP_HOST to get the best match for the subdomain. It
1650
     * won't be perfect, but should work for our needs.
1651
     *
1652
     * It's especially not perfect since it's possible to register a domain
1653
     * with a period (.) as part of the domain name.
1654
     *
1655
     * @return false|string the subdomain
1656
     */
1657
    private function determineCurrentSubdomain()
1658
    {
1659
        // We have to ensure that a scheme exists
1660
        // on the URL else parse_url will mis-interpret
1661
        // 'host' as the 'path'.
1662
        $url = $this->httpHost;
25✔
1663
        if (! str_starts_with($url, 'http')) {
25✔
1664
            $url = 'http://' . $url;
25✔
1665
        }
1666

1667
        $parsedUrl = parse_url($url);
25✔
1668

1669
        $host = explode('.', $parsedUrl['host']);
25✔
1670

1671
        if ($host[0] === 'www') {
25✔
1672
            unset($host[0]);
3✔
1673
        }
1674

1675
        // Get rid of any domains, which will be the last
1676
        unset($host[count($host) - 1]);
25✔
1677

1678
        // Account for .co.uk, .co.nz, etc. domains
1679
        if (end($host) === 'co') {
25✔
1680
            $host = array_slice($host, 0, -1);
×
1681
        }
1682

1683
        // If we only have 1 part left, then we don't have a sub-domain.
1684
        if (count($host) === 1) {
25✔
1685
            // Set it to false so we don't make it back here again.
1686
            return false;
6✔
1687
        }
1688

1689
        return array_shift($host);
19✔
1690
    }
1691

1692
    /**
1693
     * Reset the routes, so that a test case can provide the
1694
     * explicit ones needed for it.
1695
     *
1696
     * @return void
1697
     */
1698
    public function resetRoutes()
1699
    {
1700
        $this->routes = $this->routesNames = ['*' => []];
45✔
1701

1702
        foreach ($this->defaultHTTPMethods as $verb) {
45✔
1703
            $this->routes[$verb]      = [];
45✔
1704
            $this->routesNames[$verb] = [];
45✔
1705
        }
1706

1707
        $this->routesOptions = [];
45✔
1708

1709
        $this->prioritizeDetected = false;
45✔
1710
        $this->didDiscover        = false;
45✔
1711
    }
1712

1713
    /**
1714
     * Load routes options based on verb
1715
     *
1716
     * @return array<
1717
     *     string,
1718
     *     array{
1719
     *         filter?: string|list<string>, namespace?: string, hostname?: string,
1720
     *         subdomain?: string, offset?: int, priority?: int, as?: string,
1721
     *         redirect?: int
1722
     *     }
1723
     * >
1724
     */
1725
    protected function loadRoutesOptions(?string $verb = null): array
1726
    {
1727
        $verb ??= $this->getHTTPVerb();
141✔
1728

1729
        $options = $this->routesOptions[$verb] ?? [];
141✔
1730

1731
        if (isset($this->routesOptions['*'])) {
141✔
1732
            foreach ($this->routesOptions['*'] as $key => $val) {
122✔
1733
                if (isset($options[$key])) {
122✔
1734
                    $extraOptions  = array_diff_key($val, $options[$key]);
7✔
1735
                    $options[$key] = array_merge($options[$key], $extraOptions);
7✔
1736
                } else {
1737
                    $options[$key] = $val;
116✔
1738
                }
1739
            }
1740
        }
1741

1742
        return $options;
141✔
1743
    }
1744

1745
    /**
1746
     * Enable or Disable sorting routes by priority
1747
     *
1748
     * @param bool $enabled The value status
1749
     *
1750
     * @return $this
1751
     */
1752
    public function setPrioritize(bool $enabled = true)
1753
    {
1754
        $this->prioritize = $enabled;
1✔
1755

1756
        return $this;
1✔
1757
    }
1758

1759
    /**
1760
     * Get all controllers in Route Handlers
1761
     *
1762
     * @param string|null $verb HTTP verb like `GET`,`POST` or `*` or `CLI`.
1763
     *                          `'*'` returns all controllers in any verb.
1764
     *
1765
     * @return list<string> controller name list
1766
     *
1767
     * @interal
1768
     */
1769
    public function getRegisteredControllers(?string $verb = '*'): array
1770
    {
1771
        if ($verb !== '*' && $verb === strtolower($verb)) {
11✔
1772
            @trigger_error(
×
1773
                'Passing lowercase HTTP method "' . $verb . '" is deprecated.'
×
1774
                . ' Use uppercase HTTP method like "' . strtoupper($verb) . '".',
×
1775
                E_USER_DEPRECATED
×
1776
            );
×
1777
        }
1778

1779
        /**
1780
         * @deprecated 4.5.0
1781
         * @TODO Remove this in the future.
1782
         */
1783
        $verb = strtoupper($verb);
11✔
1784

1785
        $controllers = [];
11✔
1786

1787
        if ($verb === '*') {
11✔
1788
            foreach ($this->defaultHTTPMethods as $tmpVerb) {
7✔
1789
                foreach ($this->routes[$tmpVerb] as $route) {
7✔
1790
                    $controller = $this->getControllerName($route['handler']);
4✔
1791
                    if ($controller !== null) {
4✔
1792
                        $controllers[] = $controller;
3✔
1793
                    }
1794
                }
1795
            }
1796
        } else {
1797
            $routes = $this->getRoutes($verb);
4✔
1798

1799
            foreach ($routes as $handler) {
4✔
1800
                $controller = $this->getControllerName($handler);
4✔
1801
                if ($controller !== null) {
4✔
1802
                    $controllers[] = $controller;
4✔
1803
                }
1804
            }
1805
        }
1806

1807
        return array_unique($controllers);
11✔
1808
    }
1809

1810
    /**
1811
     * @param (Closure(mixed...): (ResponseInterface|string|void))|string $handler Handler
1812
     *
1813
     * @return string|null Controller classname
1814
     */
1815
    private function getControllerName($handler)
1816
    {
1817
        if (! is_string($handler)) {
8✔
1818
            return null;
2✔
1819
        }
1820

1821
        [$controller] = explode('::', $handler, 2);
7✔
1822

1823
        return $controller;
7✔
1824
    }
1825

1826
    /**
1827
     * Set The flag that limit or not the routes with {locale} placeholder to App::$supportedLocales
1828
     */
1829
    public function useSupportedLocalesOnly(bool $useOnly): self
1830
    {
1831
        $this->useSupportedLocalesOnly = $useOnly;
1✔
1832

1833
        return $this;
1✔
1834
    }
1835

1836
    /**
1837
     * Get the flag that limit or not the routes with {locale} placeholder to App::$supportedLocales
1838
     */
1839
    public function shouldUseSupportedLocalesOnly(): bool
1840
    {
1841
        return $this->useSupportedLocalesOnly;
2✔
1842
    }
1843
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc