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

codeigniter4 / CodeIgniter4 / 7293561159

21 Dec 2023 09:55PM UTC coverage: 85.237% (+0.004%) from 85.233%
7293561159

push

github

web-flow
Merge pull request #8355 from paulbalandan/replace

Add `replace` to composer.json

18597 of 21818 relevant lines covered (85.24%)

199.84 hits per line

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

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

3
/**
4
 * This file is part of CodeIgniter 4 framework.
5
 *
6
 * (c) CodeIgniter Foundation <admin@codeigniter.com>
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 CodeIgniter\Router;
13

14
use Closure;
15
use CodeIgniter\Autoloader\FileLocator;
16
use CodeIgniter\Router\Exceptions\RouterException;
17
use Config\App;
18
use Config\Modules;
19
use Config\Routing;
20
use Config\Services;
21
use InvalidArgumentException;
22

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

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

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

59
    /**
60
     * The placeholder used when routing 'resources'
61
     * when no other placeholder has been specified.
62
     *
63
     * @var string
64
     */
65
    protected $defaultPlaceholder = 'any';
66

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

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

86
    /**
87
     * A callable that will be shown
88
     * when the route cannot be matched.
89
     *
90
     * @var Closure|string
91
     */
92
    protected $override404;
93

94
    /**
95
     * An array of files that would contain route definitions.
96
     */
97
    protected array $routeFiles = [];
98

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

114
    /**
115
     * An array of all routes and their mappings.
116
     *
117
     * @var array
118
     *
119
     * [
120
     *     verb => [
121
     *         routeKey(regex) => [
122
     *             'name'    => routeName
123
     *             'handler' => handler,
124
     *             'from'    => from,
125
     *         ],
126
     *     ],
127
     *     // redirect route
128
     *     '*' => [
129
     *          routeKey(regex)(from) => [
130
     *             'name'     => routeName
131
     *             'handler'  => [routeKey(regex)(to) => handler],
132
     *             'from'     => from,
133
     *             'redirect' => statusCode,
134
     *         ],
135
     *     ],
136
     * ]
137
     */
138
    protected $routes = [
139
        '*'       => [],
140
        'options' => [],
141
        'get'     => [],
142
        'head'    => [],
143
        'post'    => [],
144
        'put'     => [],
145
        'delete'  => [],
146
        'trace'   => [],
147
        'connect' => [],
148
        'cli'     => [],
149
    ];
150

151
    /**
152
     * Array of routes names
153
     *
154
     * @var array
155
     *
156
     * [
157
     *     verb => [
158
     *         routeName => routeKey(regex)
159
     *     ],
160
     * ]
161
     */
162
    protected $routesNames = [
163
        '*'       => [],
164
        'options' => [],
165
        'get'     => [],
166
        'head'    => [],
167
        'post'    => [],
168
        'put'     => [],
169
        'delete'  => [],
170
        'trace'   => [],
171
        'connect' => [],
172
        'cli'     => [],
173
    ];
174

175
    /**
176
     * Array of routes options
177
     *
178
     * @var array
179
     *
180
     * [
181
     *     verb => [
182
     *         routeKey(regex) => [
183
     *             key => value,
184
     *         ]
185
     *     ],
186
     * ]
187
     */
188
    protected $routesOptions = [];
189

190
    /**
191
     * The current method that the script is being called by.
192
     *
193
     * @var string HTTP verb (lower case) like `get`,`post` or `*`
194
     */
195
    protected $HTTPVerb = '*';
196

197
    /**
198
     * The default list of HTTP methods (and CLI for command line usage)
199
     * that is allowed if no other method is provided.
200
     *
201
     * @var array
202
     */
203
    protected $defaultHTTPMethods = [
204
        'options',
205
        'get',
206
        'head',
207
        'post',
208
        'put',
209
        'delete',
210
        'trace',
211
        'connect',
212
        'cli',
213
    ];
214

215
    /**
216
     * The name of the current group, if any.
217
     *
218
     * @var string|null
219
     */
220
    protected $group;
221

222
    /**
223
     * The current subdomain.
224
     *
225
     * @var string|null
226
     */
227
    protected $currentSubdomain;
228

229
    /**
230
     * Stores copy of current options being
231
     * applied during creation.
232
     *
233
     * @var array|null
234
     */
235
    protected $currentOptions;
236

237
    /**
238
     * A little performance booster.
239
     *
240
     * @var bool
241
     */
242
    protected $didDiscover = false;
243

244
    /**
245
     * Handle to the file locator to use.
246
     *
247
     * @var FileLocator
248
     */
249
    protected $fileLocator;
250

251
    /**
252
     * Handle to the modules config.
253
     *
254
     * @var Modules
255
     */
256
    protected $moduleConfig;
257

258
    /**
259
     * Flag for sorting routes by priority.
260
     *
261
     * @var bool
262
     */
263
    protected $prioritize = false;
264

265
    /**
266
     * Route priority detection flag.
267
     *
268
     * @var bool
269
     */
270
    protected $prioritizeDetected = false;
271

272
    /**
273
     * The current hostname from $_SERVER['HTTP_HOST']
274
     */
275
    private ?string $httpHost = null;
276

277
    /**
278
     * Flag to limit or not the routes with {locale} placeholder to App::$supportedLocales
279
     */
280
    protected bool $useSupportedLocalesOnly = false;
281

282
    /**
283
     * Constructor
284
     */
285
    public function __construct(FileLocator $locator, Modules $moduleConfig, Routing $routing)
286
    {
287
        $this->fileLocator  = $locator;
444✔
288
        $this->moduleConfig = $moduleConfig;
444✔
289

290
        $this->httpHost = Services::request()->getServer('HTTP_HOST');
444✔
291

292
        // Setup based on config file. Let routes file override.
293
        $this->defaultNamespace   = rtrim($routing->defaultNamespace, '\\') . '\\';
444✔
294
        $this->defaultController  = $routing->defaultController;
444✔
295
        $this->defaultMethod      = $routing->defaultMethod;
444✔
296
        $this->translateURIDashes = $routing->translateURIDashes;
444✔
297
        $this->override404        = $routing->override404;
444✔
298
        $this->autoRoute          = $routing->autoRoute;
444✔
299
        $this->routeFiles         = $routing->routeFiles;
444✔
300
        $this->prioritize         = $routing->prioritize;
444✔
301

302
        // Normalize the path string in routeFiles array.
303
        foreach ($this->routeFiles as $routeKey => $routesFile) {
444✔
304
            $realpath                    = realpath($routesFile);
444✔
305
            $this->routeFiles[$routeKey] = ($realpath === false) ? $routesFile : $realpath;
444✔
306
        }
307
    }
308

309
    /**
310
     * Loads main routes file and discover routes.
311
     *
312
     * Loads only once unless reset.
313
     *
314
     * @return $this
315
     */
316
    public function loadRoutes(string $routesFile = APPPATH . 'Config/Routes.php')
317
    {
318
        if ($this->didDiscover) {
160✔
319
            return $this;
22✔
320
        }
321

322
        // Normalize the path string in routesFile
323
        $realpath   = realpath($routesFile);
141✔
324
        $routesFile = ($realpath === false) ? $routesFile : $realpath;
141✔
325

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

333
        // We need this var in local scope
334
        // so route files can access it.
335
        $routes = $this;
141✔
336

337
        foreach ($routeFiles as $routesFile) {
141✔
338
            if (! is_file($routesFile)) {
141✔
339
                log_message('warning', sprintf('Routes file not found: "%s"', $routesFile));
×
340

341
                continue;
×
342
            }
343

344
            require $routesFile;
141✔
345
        }
346

347
        $this->discoverRoutes();
141✔
348

349
        return $this;
141✔
350
    }
351

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

364
        // We need this var in local scope
365
        // so route files can access it.
366
        $routes = $this;
334✔
367

368
        if ($this->moduleConfig->shouldDiscover('routes')) {
334✔
369
            $files = $this->fileLocator->search('Config/Routes.php');
198✔
370

371
            foreach ($files as $file) {
198✔
372
                // Don't include our main file again...
373
                if (in_array($file, $this->routeFiles, true)) {
198✔
374
                    continue;
198✔
375
                }
376

377
                include $file;
198✔
378
            }
379
        }
380

381
        $this->didDiscover = true;
334✔
382
    }
383

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

400
        $this->placeholders = array_merge($this->placeholders, $placeholder);
5✔
401

402
        return $this;
5✔
403
    }
404

405
    /**
406
     * For `spark routes`
407
     *
408
     * @return array<string, string>
409
     *
410
     * @internal
411
     */
412
    public function getPlaceholders(): array
413
    {
414
        return $this->placeholders;
14✔
415
    }
416

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

426
        return $this;
27✔
427
    }
428

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

437
        return $this;
10✔
438
    }
439

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

448
        return $this;
7✔
449
    }
450

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

462
        return $this;
1✔
463
    }
464

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

477
        return $this;
38✔
478
    }
479

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

493
        return $this;
6✔
494
    }
495

496
    /**
497
     * Returns the 404 Override setting, which can be null, a closure
498
     * or the controller/string.
499
     *
500
     * @return Closure|string|null
501
     */
502
    public function get404Override()
503
    {
504
        return $this->override404;
13✔
505
    }
506

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

517
        return $this;
2✔
518
    }
519

520
    /**
521
     * Returns the name of the default controller. With Namespace.
522
     */
523
    public function getDefaultController(): string
524
    {
525
        return $this->defaultController;
201✔
526
    }
527

528
    /**
529
     * Returns the name of the default method to use within the controller.
530
     */
531
    public function getDefaultMethod(): string
532
    {
533
        return $this->defaultMethod;
201✔
534
    }
535

536
    /**
537
     * Returns the default namespace as set in the Routes config file.
538
     */
539
    public function getDefaultNamespace(): string
540
    {
541
        return $this->defaultNamespace;
33✔
542
    }
543

544
    /**
545
     * Returns the current value of the translateURIDashes setting.
546
     */
547
    public function shouldTranslateURIDashes(): bool
548
    {
549
        return $this->translateURIDashes;
172✔
550
    }
551

552
    /**
553
     * Returns the flag that tells whether to autoRoute URI against Controllers.
554
     */
555
    public function shouldAutoRoute(): bool
556
    {
557
        return $this->autoRoute;
175✔
558
    }
559

560
    /**
561
     * Returns the raw array of available routes.
562
     *
563
     * @param non-empty-string|null $verb
564
     * @param bool                  $includeWildcard Whether to include '*' routes.
565
     */
566
    public function getRoutes(?string $verb = null, bool $includeWildcard = true): array
567
    {
568
        if ($verb === null || $verb === '') {
236✔
569
            $verb = $this->getHTTPVerb();
57✔
570
        }
571

572
        // Since this is the entry point for the Router,
573
        // take a moment to do any route discovery
574
        // we might need to do.
575
        $this->discoverRoutes();
236✔
576

577
        $routes = [];
236✔
578

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

584
            foreach ($collection as $routeKey => $r) {
236✔
585
                $routes[$routeKey] = $r['handler'];
214✔
586
            }
587
        }
588

589
        // sorting routes by priority
590
        if ($this->prioritizeDetected && $this->prioritize && $routes !== []) {
236✔
591
            $order = [];
1✔
592

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

599
            ksort($order);
1✔
600
            $routes = array_merge(...$order);
1✔
601
        }
602

603
        return $routes;
236✔
604
    }
605

606
    /**
607
     * Returns one or all routes options
608
     *
609
     * @return array<string, int|string> [key => value]
610
     */
611
    public function getRoutesOptions(?string $from = null, ?string $verb = null): array
612
    {
613
        $options = $this->loadRoutesOptions($verb);
123✔
614

615
        return $from ? $options[$from] ?? [] : $options;
123✔
616
    }
617

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

626
    /**
627
     * Sets the current HTTP verb.
628
     * Used primarily for testing.
629
     *
630
     * @param string $verb HTTP verb
631
     *
632
     * @return $this
633
     */
634
    public function setHTTPVerb(string $verb)
635
    {
636
        $this->HTTPVerb = strtolower($verb);
289✔
637

638
        return $this;
289✔
639
    }
640

641
    /**
642
     * A shortcut method to add a number of routes at a single time.
643
     * It does not allow any options to be set on the route, or to
644
     * define the method used.
645
     */
646
    public function map(array $routes = [], ?array $options = null): RouteCollectionInterface
647
    {
648
        foreach ($routes as $from => $to) {
61✔
649
            $this->add($from, $to, $options);
61✔
650
        }
651

652
        return $this;
61✔
653
    }
654

655
    /**
656
     * Adds a single route to the collection.
657
     *
658
     * Example:
659
     *      $routes->add('news', 'Posts::index');
660
     *
661
     * @param array|Closure|string $to
662
     */
663
    public function add(string $from, $to, ?array $options = null): RouteCollectionInterface
664
    {
665
        $this->create('*', $from, $to, $options);
322✔
666

667
        return $this;
322✔
668
    }
669

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

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

699
        return $this;
16✔
700
    }
701

702
    /**
703
     * Determines if the route is a redirecting route.
704
     *
705
     * @param string $routeKey routeKey or route name
706
     */
707
    public function isRedirect(string $routeKey): bool
708
    {
709
        if (isset($this->routes['*'][$routeKey]['redirect'])) {
133✔
710
            return true;
16✔
711
        }
712

713
        // This logic is not used. Should be deprecated?
714
        $routeName = $this->routes['*'][$routeKey]['name'] ?? null;
117✔
715
        if ($routeName === $routeKey) {
117✔
716
            $routeKey = $this->routesNames['*'][$routeName];
36✔
717

718
            return isset($this->routes['*'][$routeKey]['redirect']);
36✔
719
        }
720

721
        return false;
82✔
722
    }
723

724
    /**
725
     * Grabs the HTTP status code from a redirecting Route.
726
     *
727
     * @param string $routeKey routeKey or route name
728
     */
729
    public function getRedirectCode(string $routeKey): int
730
    {
731
        if (isset($this->routes['*'][$routeKey]['redirect'])) {
16✔
732
            return $this->routes['*'][$routeKey]['redirect'];
16✔
733
        }
734

735
        // This logic is not used. Should be deprecated?
736
        $routeName = $this->routes['*'][$routeKey]['name'] ?? null;
1✔
737
        if ($routeName === $routeKey) {
1✔
738
            $routeKey = $this->routesNames['*'][$routeName];
×
739

740
            return $this->routes['*'][$routeKey]['redirect'];
×
741
        }
742

743
        return 0;
1✔
744
    }
745

746
    /**
747
     * Group a series of routes under a single URL segment. This is handy
748
     * for grouping items into an admin area, like:
749
     *
750
     * Example:
751
     *     // Creates route: admin/users
752
     *     $route->group('admin', function() {
753
     *            $route->resource('users');
754
     *     });
755
     *
756
     * @param string         $name      The name to group/prefix the routes with.
757
     * @param array|callable ...$params
758
     *
759
     * @return void
760
     */
761
    public function group(string $name, ...$params)
762
    {
763
        $oldGroup   = $this->group;
14✔
764
        $oldOptions = $this->currentOptions;
14✔
765

766
        // To register a route, we'll set a flag so that our router
767
        // will see the group name.
768
        // If the group name is empty, we go on using the previously built group name.
769
        $this->group = $name ? trim($oldGroup . '/' . $name, '/') : $oldGroup;
14✔
770

771
        $callback = array_pop($params);
14✔
772

773
        if ($params && is_array($params[0])) {
14✔
774
            $this->currentOptions = array_shift($params);
5✔
775
        }
776

777
        if (is_callable($callback)) {
14✔
778
            $callback($this);
14✔
779
        }
780

781
        $this->group          = $oldGroup;
14✔
782
        $this->currentOptions = $oldOptions;
14✔
783
    }
784

785
    /*
786
     * --------------------------------------------------------------------
787
     *  HTTP Verb-based routing
788
     * --------------------------------------------------------------------
789
     * Routing works here because, as the routes Config file is read in,
790
     * the various HTTP verb-based routes will only be added to the in-memory
791
     * routes if it is a call that should respond to that verb.
792
     *
793
     * The options array is typically used to pass in an 'as' or var, but may
794
     * be expanded in the future. See the docblock for 'add' method above for
795
     * current list of globally available options.
796
     */
797

798
    /**
799
     * Creates a collections of HTTP-verb based routes for a controller.
800
     *
801
     * Possible Options:
802
     *      'controller'    - Customize the name of the controller used in the 'to' route
803
     *      'placeholder'   - The regex used by the Router. Defaults to '(:any)'
804
     *      'websafe'   -        - '1' if only GET and POST HTTP verbs are supported
805
     *
806
     * Example:
807
     *
808
     *      $route->resource('photos');
809
     *
810
     *      // Generates the following routes:
811
     *      HTTP Verb | Path        | Action        | Used for...
812
     *      ----------+-------------+---------------+-----------------
813
     *      GET         /photos             index           an array of photo objects
814
     *      GET         /photos/new         new             an empty photo object, with default properties
815
     *      GET         /photos/{id}/edit   edit            a specific photo object, editable properties
816
     *      GET         /photos/{id}        show            a specific photo object, all properties
817
     *      POST        /photos             create          a new photo object, to add to the resource
818
     *      DELETE      /photos/{id}        delete          deletes the specified photo object
819
     *      PUT/PATCH   /photos/{id}        update          replacement properties for existing photo
820
     *
821
     *  If 'websafe' option is present, the following paths are also available:
822
     *
823
     *      POST                /photos/{id}/delete delete
824
     *      POST        /photos/{id}        update
825
     *
826
     * @param string     $name    The name of the resource/controller to route to.
827
     * @param array|null $options A list of possible ways to customize the routing.
828
     */
829
    public function resource(string $name, ?array $options = null): RouteCollectionInterface
830
    {
831
        // In order to allow customization of the route the
832
        // resources are sent to, we need to have a new name
833
        // to store the values in.
834
        $newName = implode('\\', array_map('ucfirst', explode('/', $name)));
18✔
835

836
        // If a new controller is specified, then we replace the
837
        // $name value with the name of the new controller.
838
        if (isset($options['controller'])) {
18✔
839
            $newName = ucfirst(esc(strip_tags($options['controller'])));
11✔
840
        }
841

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

846
        // Make sure we capture back-references
847
        $id = '(' . trim($id, '()') . ')';
18✔
848

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

851
        if (isset($options['except'])) {
18✔
852
            $options['except'] = is_array($options['except']) ? $options['except'] : explode(',', $options['except']);
1✔
853

854
            foreach ($methods as $i => $method) {
1✔
855
                if (in_array($method, $options['except'], true)) {
1✔
856
                    unset($methods[$i]);
1✔
857
                }
858
            }
859
        }
860

861
        if (in_array('index', $methods, true)) {
18✔
862
            $this->get($name, $newName . '::index', $options);
18✔
863
        }
864
        if (in_array('new', $methods, true)) {
18✔
865
            $this->get($name . '/new', $newName . '::new', $options);
16✔
866
        }
867
        if (in_array('edit', $methods, true)) {
18✔
868
            $this->get($name . '/' . $id . '/edit', $newName . '::edit/$1', $options);
16✔
869
        }
870
        if (in_array('show', $methods, true)) {
18✔
871
            $this->get($name . '/' . $id, $newName . '::show/$1', $options);
17✔
872
        }
873
        if (in_array('create', $methods, true)) {
18✔
874
            $this->post($name, $newName . '::create', $options);
17✔
875
        }
876
        if (in_array('update', $methods, true)) {
18✔
877
            $this->put($name . '/' . $id, $newName . '::update/$1', $options);
17✔
878
            $this->patch($name . '/' . $id, $newName . '::update/$1', $options);
17✔
879
        }
880
        if (in_array('delete', $methods, true)) {
18✔
881
            $this->delete($name . '/' . $id, $newName . '::delete/$1', $options);
17✔
882
        }
883

884
        // Web Safe? delete needs checking before update because of method name
885
        if (isset($options['websafe'])) {
18✔
886
            if (in_array('delete', $methods, true)) {
1✔
887
                $this->post($name . '/' . $id . '/delete', $newName . '::delete/$1', $options);
1✔
888
            }
889
            if (in_array('update', $methods, true)) {
1✔
890
                $this->post($name . '/' . $id, $newName . '::update/$1', $options);
1✔
891
            }
892
        }
893

894
        return $this;
18✔
895
    }
896

897
    /**
898
     * Creates a collections of HTTP-verb based routes for a presenter controller.
899
     *
900
     * Possible Options:
901
     *      'controller'    - Customize the name of the controller used in the 'to' route
902
     *      'placeholder'   - The regex used by the Router. Defaults to '(:any)'
903
     *
904
     * Example:
905
     *
906
     *      $route->presenter('photos');
907
     *
908
     *      // Generates the following routes:
909
     *      HTTP Verb | Path        | Action        | Used for...
910
     *      ----------+-------------+---------------+-----------------
911
     *      GET         /photos             index           showing all array of photo objects
912
     *      GET         /photos/show/{id}   show            showing a specific photo object, all properties
913
     *      GET         /photos/new         new             showing a form for an empty photo object, with default properties
914
     *      POST        /photos/create      create          processing the form for a new photo
915
     *      GET         /photos/edit/{id}   edit            show an editing form for a specific photo object, editable properties
916
     *      POST        /photos/update/{id} update          process the editing form data
917
     *      GET         /photos/remove/{id} remove          show a form to confirm deletion of a specific photo object
918
     *      POST        /photos/delete/{id} delete          deleting the specified photo object
919
     *
920
     * @param string     $name    The name of the controller to route to.
921
     * @param array|null $options A list of possible ways to customize the routing.
922
     */
923
    public function presenter(string $name, ?array $options = null): RouteCollectionInterface
924
    {
925
        // In order to allow customization of the route the
926
        // resources are sent to, we need to have a new name
927
        // to store the values in.
928
        $newName = implode('\\', array_map('ucfirst', explode('/', $name)));
9✔
929

930
        // If a new controller is specified, then we replace the
931
        // $name value with the name of the new controller.
932
        if (isset($options['controller'])) {
9✔
933
            $newName = ucfirst(esc(strip_tags($options['controller'])));
8✔
934
        }
935

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

940
        // Make sure we capture back-references
941
        $id = '(' . trim($id, '()') . ')';
9✔
942

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

945
        if (isset($options['except'])) {
9✔
946
            $options['except'] = is_array($options['except']) ? $options['except'] : explode(',', $options['except']);
×
947

948
            foreach ($methods as $i => $method) {
×
949
                if (in_array($method, $options['except'], true)) {
×
950
                    unset($methods[$i]);
×
951
                }
952
            }
953
        }
954

955
        if (in_array('index', $methods, true)) {
9✔
956
            $this->get($name, $newName . '::index', $options);
9✔
957
        }
958
        if (in_array('show', $methods, true)) {
9✔
959
            $this->get($name . '/show/' . $id, $newName . '::show/$1', $options);
9✔
960
        }
961
        if (in_array('new', $methods, true)) {
9✔
962
            $this->get($name . '/new', $newName . '::new', $options);
9✔
963
        }
964
        if (in_array('create', $methods, true)) {
9✔
965
            $this->post($name . '/create', $newName . '::create', $options);
9✔
966
        }
967
        if (in_array('edit', $methods, true)) {
9✔
968
            $this->get($name . '/edit/' . $id, $newName . '::edit/$1', $options);
9✔
969
        }
970
        if (in_array('update', $methods, true)) {
9✔
971
            $this->post($name . '/update/' . $id, $newName . '::update/$1', $options);
9✔
972
        }
973
        if (in_array('remove', $methods, true)) {
9✔
974
            $this->get($name . '/remove/' . $id, $newName . '::remove/$1', $options);
9✔
975
        }
976
        if (in_array('delete', $methods, true)) {
9✔
977
            $this->post($name . '/delete/' . $id, $newName . '::delete/$1', $options);
9✔
978
        }
979
        if (in_array('show', $methods, true)) {
9✔
980
            $this->get($name . '/' . $id, $newName . '::show/$1', $options);
9✔
981
        }
982
        if (in_array('create', $methods, true)) {
9✔
983
            $this->post($name, $newName . '::create', $options);
9✔
984
        }
985

986
        return $this;
9✔
987
    }
988

989
    /**
990
     * Specifies a single route to match for multiple HTTP Verbs.
991
     *
992
     * Example:
993
     *  $route->match( ['get', 'post'], 'users/(:num)', 'users/$1);
994
     *
995
     * @param array|Closure|string $to
996
     */
997
    public function match(array $verbs = [], string $from = '', $to = '', ?array $options = null): RouteCollectionInterface
998
    {
999
        if ($from === '' || empty($to)) {
4✔
1000
            throw new InvalidArgumentException('You must supply the parameters: from, to.');
×
1001
        }
1002

1003
        foreach ($verbs as $verb) {
4✔
1004
            $verb = strtolower($verb);
4✔
1005

1006
            $this->{$verb}($from, $to, $options);
4✔
1007
        }
1008

1009
        return $this;
4✔
1010
    }
1011

1012
    /**
1013
     * Specifies a route that is only available to GET requests.
1014
     *
1015
     * @param array|Closure|string $to
1016
     */
1017
    public function get(string $from, $to, ?array $options = null): RouteCollectionInterface
1018
    {
1019
        $this->create('get', $from, $to, $options);
267✔
1020

1021
        return $this;
267✔
1022
    }
1023

1024
    /**
1025
     * Specifies a route that is only available to POST requests.
1026
     *
1027
     * @param array|Closure|string $to
1028
     */
1029
    public function post(string $from, $to, ?array $options = null): RouteCollectionInterface
1030
    {
1031
        $this->create('post', $from, $to, $options);
46✔
1032

1033
        return $this;
46✔
1034
    }
1035

1036
    /**
1037
     * Specifies a route that is only available to PUT requests.
1038
     *
1039
     * @param array|Closure|string $to
1040
     */
1041
    public function put(string $from, $to, ?array $options = null): RouteCollectionInterface
1042
    {
1043
        $this->create('put', $from, $to, $options);
25✔
1044

1045
        return $this;
25✔
1046
    }
1047

1048
    /**
1049
     * Specifies a route that is only available to DELETE requests.
1050
     *
1051
     * @param array|Closure|string $to
1052
     */
1053
    public function delete(string $from, $to, ?array $options = null): RouteCollectionInterface
1054
    {
1055
        $this->create('delete', $from, $to, $options);
19✔
1056

1057
        return $this;
19✔
1058
    }
1059

1060
    /**
1061
     * Specifies a route that is only available to HEAD requests.
1062
     *
1063
     * @param array|Closure|string $to
1064
     */
1065
    public function head(string $from, $to, ?array $options = null): RouteCollectionInterface
1066
    {
1067
        $this->create('head', $from, $to, $options);
1✔
1068

1069
        return $this;
1✔
1070
    }
1071

1072
    /**
1073
     * Specifies a route that is only available to PATCH requests.
1074
     *
1075
     * @param array|Closure|string $to
1076
     */
1077
    public function patch(string $from, $to, ?array $options = null): RouteCollectionInterface
1078
    {
1079
        $this->create('patch', $from, $to, $options);
19✔
1080

1081
        return $this;
19✔
1082
    }
1083

1084
    /**
1085
     * Specifies a route that is only available to OPTIONS requests.
1086
     *
1087
     * @param array|Closure|string $to
1088
     */
1089
    public function options(string $from, $to, ?array $options = null): RouteCollectionInterface
1090
    {
1091
        $this->create('options', $from, $to, $options);
2✔
1092

1093
        return $this;
2✔
1094
    }
1095

1096
    /**
1097
     * Specifies a route that is only available to command-line requests.
1098
     *
1099
     * @param array|Closure|string $to
1100
     */
1101
    public function cli(string $from, $to, ?array $options = null): RouteCollectionInterface
1102
    {
1103
        $this->create('cli', $from, $to, $options);
7✔
1104

1105
        return $this;
7✔
1106
    }
1107

1108
    /**
1109
     * Specifies a route that will only display a view.
1110
     * Only works for GET requests.
1111
     */
1112
    public function view(string $from, string $view, ?array $options = null): RouteCollectionInterface
1113
    {
1114
        $to = static fn (...$data) => Services::renderer()
2✔
1115
            ->setData(['segments' => $data], 'raw')
2✔
1116
            ->render($view, $options);
2✔
1117

1118
        $routeOptions = $options ?? [];
2✔
1119
        $routeOptions = array_merge($routeOptions, ['view' => $view]);
2✔
1120

1121
        $this->create('get', $from, $to, $routeOptions);
2✔
1122

1123
        return $this;
2✔
1124
    }
1125

1126
    /**
1127
     * Limits the routes to a specified ENVIRONMENT or they won't run.
1128
     */
1129
    public function environment(string $env, Closure $callback): RouteCollectionInterface
1130
    {
1131
        if ($env === ENVIRONMENT) {
1✔
1132
            $callback($this);
1✔
1133
        }
1134

1135
        return $this;
1✔
1136
    }
1137

1138
    /**
1139
     * Attempts to look up a route based on its destination.
1140
     *
1141
     * If a route exists:
1142
     *
1143
     *      'path/(:any)/(:any)' => 'Controller::method/$1/$2'
1144
     *
1145
     * This method allows you to know the Controller and method
1146
     * and get the route that leads to it.
1147
     *
1148
     *      // Equals 'path/$param1/$param2'
1149
     *      reverseRoute('Controller::method', $param1, $param2);
1150
     *
1151
     * @param string     $search    Route name or Controller::method
1152
     * @param int|string ...$params One or more parameters to be passed to the route.
1153
     *                              The last parameter allows you to set the locale.
1154
     *
1155
     * @return false|string The route (URI path relative to baseURL) or false if not found.
1156
     */
1157
    public function reverseRoute(string $search, ...$params)
1158
    {
1159
        if ($search === '') {
52✔
1160
            return false;
2✔
1161
        }
1162

1163
        // Named routes get higher priority.
1164
        foreach ($this->routesNames as $verb => $collection) {
50✔
1165
            if (array_key_exists($search, $collection)) {
50✔
1166
                $routeKey = $collection[$search];
26✔
1167

1168
                $from = $this->routes[$verb][$routeKey]['from'];
26✔
1169

1170
                return $this->buildReverseRoute($from, $params);
26✔
1171
            }
1172
        }
1173

1174
        // Add the default namespace if needed.
1175
        $namespace = trim($this->defaultNamespace, '\\') . '\\';
24✔
1176
        if (
1177
            substr($search, 0, 1) !== '\\'
24✔
1178
            && substr($search, 0, strlen($namespace)) !== $namespace
24✔
1179
        ) {
1180
            $search = $namespace . $search;
22✔
1181
        }
1182

1183
        // If it's not a named route, then loop over
1184
        // all routes to find a match.
1185
        foreach ($this->routes as $collection) {
24✔
1186
            foreach ($collection as $route) {
24✔
1187
                $to   = $route['handler'];
19✔
1188
                $from = $route['from'];
19✔
1189

1190
                // ignore closures
1191
                if (! is_string($to)) {
19✔
1192
                    continue;
3✔
1193
                }
1194

1195
                // Lose any namespace slash at beginning of strings
1196
                // to ensure more consistent match.
1197
                $to     = ltrim($to, '\\');
18✔
1198
                $search = ltrim($search, '\\');
18✔
1199

1200
                // If there's any chance of a match, then it will
1201
                // be with $search at the beginning of the $to string.
1202
                if (strpos($to, $search) !== 0) {
18✔
1203
                    continue;
6✔
1204
                }
1205

1206
                // Ensure that the number of $params given here
1207
                // matches the number of back-references in the route
1208
                if (substr_count($to, '$') !== count($params)) {
14✔
1209
                    continue;
1✔
1210
                }
1211

1212
                return $this->buildReverseRoute($from, $params);
13✔
1213
            }
1214
        }
1215

1216
        // If we're still here, then we did not find a match.
1217
        return false;
11✔
1218
    }
1219

1220
    /**
1221
     * Replaces the {locale} tag with the current application locale
1222
     *
1223
     * @deprecated Unused.
1224
     */
1225
    protected function localizeRoute(string $route): string
1226
    {
1227
        return strtr($route, ['{locale}' => Services::request()->getLocale()]);
×
1228
    }
1229

1230
    /**
1231
     * Checks a route (using the "from") to see if it's filtered or not.
1232
     */
1233
    public function isFiltered(string $search, ?string $verb = null): bool
1234
    {
1235
        $options = $this->loadRoutesOptions($verb);
116✔
1236

1237
        return isset($options[$search]['filter']);
116✔
1238
    }
1239

1240
    /**
1241
     * Returns the filter that should be applied for a single route, along
1242
     * with any parameters it might have. Parameters are found by splitting
1243
     * the parameter name on a colon to separate the filter name from the parameter list,
1244
     * and the splitting the result on commas. So:
1245
     *
1246
     *    'role:admin,manager'
1247
     *
1248
     * has a filter of "role", with parameters of ['admin', 'manager'].
1249
     *
1250
     * @deprecated Use getFiltersForRoute()
1251
     */
1252
    public function getFilterForRoute(string $search, ?string $verb = null): string
1253
    {
1254
        $options = $this->loadRoutesOptions($verb);
3✔
1255

1256
        return $options[$search]['filter'] ?? '';
3✔
1257
    }
1258

1259
    /**
1260
     * Returns the filters that should be applied for a single route, along
1261
     * with any parameters it might have. Parameters are found by splitting
1262
     * the parameter name on a colon to separate the filter name from the parameter list,
1263
     * and the splitting the result on commas. So:
1264
     *
1265
     *    'role:admin,manager'
1266
     *
1267
     * has a filter of "role", with parameters of ['admin', 'manager'].
1268
     *
1269
     * @param string $search routeKey
1270
     *
1271
     * @return list<string> filter_name or filter_name:arguments like 'role:admin,manager'
1272
     */
1273
    public function getFiltersForRoute(string $search, ?string $verb = null): array
1274
    {
1275
        $options = $this->loadRoutesOptions($verb);
9✔
1276

1277
        if (! array_key_exists($search, $options) || ! array_key_exists('filter', $options[$search])) {
9✔
1278
            return [];
6✔
1279
        }
1280

1281
        if (is_string($options[$search]['filter'])) {
4✔
1282
            return [$options[$search]['filter']];
2✔
1283
        }
1284

1285
        return $options[$search]['filter'];
2✔
1286
    }
1287

1288
    /**
1289
     * Given a
1290
     *
1291
     * @throws RouterException
1292
     *
1293
     * @deprecated Unused. Now uses buildReverseRoute().
1294
     */
1295
    protected function fillRouteParams(string $from, ?array $params = null): string
1296
    {
1297
        // Find all of our back-references in the original route
1298
        preg_match_all('/\(([^)]+)\)/', $from, $matches);
×
1299

1300
        if (empty($matches[0])) {
×
1301
            return '/' . ltrim($from, '/');
×
1302
        }
1303

1304
        /**
1305
         * Build our resulting string, inserting the $params in
1306
         * the appropriate places.
1307
         *
1308
         * @var list<string> $patterns
1309
         */
1310
        $patterns = $matches[0];
×
1311

1312
        foreach ($patterns as $index => $pattern) {
×
1313
            if (! preg_match('#^' . $pattern . '$#u', $params[$index])) {
×
1314
                throw RouterException::forInvalidParameterType();
×
1315
            }
1316

1317
            // Ensure that the param we're inserting matches
1318
            // the expected param type.
1319
            $pos  = strpos($from, $pattern);
×
1320
            $from = substr_replace($from, $params[$index], $pos, strlen($pattern));
×
1321
        }
1322

1323
        return '/' . ltrim($from, '/');
×
1324
    }
1325

1326
    /**
1327
     * Builds reverse route
1328
     *
1329
     * @param array $params One or more parameters to be passed to the route.
1330
     *                      The last parameter allows you to set the locale.
1331
     */
1332
    protected function buildReverseRoute(string $from, array $params): string
1333
    {
1334
        $locale = null;
39✔
1335

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

1339
        if (empty($matches[0])) {
39✔
1340
            if (strpos($from, '{locale}') !== false) {
12✔
1341
                $locale = $params[0] ?? null;
3✔
1342
            }
1343

1344
            $from = $this->replaceLocale($from, $locale);
12✔
1345

1346
            return '/' . ltrim($from, '/');
12✔
1347
        }
1348

1349
        // Locale is passed?
1350
        $placeholderCount = count($matches[0]);
27✔
1351
        if (count($params) > $placeholderCount) {
27✔
1352
            $locale = $params[$placeholderCount];
3✔
1353
        }
1354

1355
        /**
1356
         * Build our resulting string, inserting the $params in
1357
         * the appropriate places.
1358
         *
1359
         * @var list<string> $placeholders
1360
         */
1361
        $placeholders = $matches[0];
27✔
1362

1363
        foreach ($placeholders as $index => $placeholder) {
27✔
1364
            if (! isset($params[$index])) {
27✔
1365
                throw new InvalidArgumentException(
1✔
1366
                    'Missing argument for "' . $placeholder . '" in route "' . $from . '".'
1✔
1367
                );
1✔
1368
            }
1369

1370
            // Remove `(:` and `)` when $placeholder is a placeholder.
1371
            $placeholderName = substr($placeholder, 2, -1);
26✔
1372
            // or maybe $placeholder is not a placeholder, but a regex.
1373
            $pattern = $this->placeholders[$placeholderName] ?? $placeholder;
26✔
1374

1375
            if (! preg_match('#^' . $pattern . '$#u', $params[$index])) {
26✔
1376
                throw RouterException::forInvalidParameterType();
1✔
1377
            }
1378

1379
            // Ensure that the param we're inserting matches
1380
            // the expected param type.
1381
            $pos  = strpos($from, $placeholder);
26✔
1382
            $from = substr_replace($from, $params[$index], $pos, strlen($placeholder));
26✔
1383
        }
1384

1385
        $from = $this->replaceLocale($from, $locale);
25✔
1386

1387
        return '/' . ltrim($from, '/');
25✔
1388
    }
1389

1390
    /**
1391
     * Replaces the {locale} tag with the locale
1392
     */
1393
    private function replaceLocale(string $route, ?string $locale = null): string
1394
    {
1395
        if (strpos($route, '{locale}') === false) {
37✔
1396
            return $route;
28✔
1397
        }
1398

1399
        // Check invalid locale
1400
        if ($locale !== null) {
9✔
1401
            $config = config(App::class);
3✔
1402
            if (! in_array($locale, $config->supportedLocales, true)) {
3✔
1403
                $locale = null;
1✔
1404
            }
1405
        }
1406

1407
        if ($locale === null) {
9✔
1408
            $locale = Services::request()->getLocale();
7✔
1409
        }
1410

1411
        return strtr($route, ['{locale}' => $locale]);
9✔
1412
    }
1413

1414
    /**
1415
     * Does the heavy lifting of creating an actual route. You must specify
1416
     * the request method(s) that this route will work for. They can be separated
1417
     * by a pipe character "|" if there is more than one.
1418
     *
1419
     * @param array|Closure|string $to
1420
     *
1421
     * @return void
1422
     */
1423
    protected function create(string $verb, string $from, $to, ?array $options = null)
1424
    {
1425
        $overwrite = false;
389✔
1426
        $prefix    = $this->group === null ? '' : $this->group . '/';
389✔
1427

1428
        $from = esc(strip_tags($prefix . $from));
389✔
1429

1430
        // While we want to add a route within a group of '/',
1431
        // it doesn't work with matching, so remove them...
1432
        if ($from !== '/') {
389✔
1433
            $from = trim($from, '/');
382✔
1434
        }
1435

1436
        // When redirecting to named route, $to is an array like `['zombies' => '\Zombies::index']`.
1437
        if (is_array($to) && isset($to[0])) {
389✔
1438
            $to = $this->processArrayCallableSyntax($from, $to);
3✔
1439
        }
1440

1441
        $options = array_merge($this->currentOptions ?? [], $options ?? []);
389✔
1442

1443
        // Route priority detect
1444
        if (isset($options['priority'])) {
389✔
1445
            $options['priority'] = abs((int) $options['priority']);
3✔
1446

1447
            if ($options['priority'] > 0) {
3✔
1448
                $this->prioritizeDetected = true;
3✔
1449
            }
1450
        }
1451

1452
        // Hostname limiting?
1453
        if (! empty($options['hostname'])) {
389✔
1454
            // @todo determine if there's a way to whitelist hosts?
1455
            if (! $this->checkHostname($options['hostname'])) {
203✔
1456
                return;
200✔
1457
            }
1458

1459
            $overwrite = true;
4✔
1460
        }
1461
        // Limiting to subdomains?
1462
        elseif (! empty($options['subdomain'])) {
385✔
1463
            // If we don't match the current subdomain, then
1464
            // we don't need to add the route.
1465
            if (! $this->checkSubdomains($options['subdomain'])) {
216✔
1466
                return;
207✔
1467
            }
1468

1469
            $overwrite = true;
16✔
1470
        }
1471

1472
        // Are we offsetting the binds?
1473
        // If so, take care of them here in one
1474
        // fell swoop.
1475
        if (isset($options['offset']) && is_string($to)) {
383✔
1476
            // Get a constant string to work with.
1477
            $to = preg_replace('/(\$\d+)/', '$X', $to);
1✔
1478

1479
            for ($i = (int) $options['offset'] + 1; $i < (int) $options['offset'] + 7; $i++) {
1✔
1480
                $to = preg_replace_callback(
1✔
1481
                    '/\$X/',
1✔
1482
                    static fn ($m) => '$' . $i,
1✔
1483
                    $to,
1✔
1484
                    1
1✔
1485
                );
1✔
1486
            }
1487
        }
1488

1489
        $routeKey = $from;
383✔
1490

1491
        // Replace our regex pattern placeholders with the actual thing
1492
        // so that the Router doesn't need to know about any of this.
1493
        foreach ($this->placeholders as $tag => $pattern) {
383✔
1494
            $routeKey = str_ireplace(':' . $tag, $pattern, $routeKey);
383✔
1495
        }
1496

1497
        // If is redirect, No processing
1498
        if (! isset($options['redirect']) && is_string($to)) {
383✔
1499
            // If no namespace found, add the default namespace
1500
            if (strpos($to, '\\') === false || strpos($to, '\\') > 0) {
372✔
1501
                $namespace = $options['namespace'] ?? $this->defaultNamespace;
351✔
1502
                $to        = trim($namespace, '\\') . '\\' . $to;
351✔
1503
            }
1504
            // Always ensure that we escape our namespace so we're not pointing to
1505
            // \CodeIgniter\Routes\Controller::method.
1506
            $to = '\\' . ltrim($to, '\\');
372✔
1507
        }
1508

1509
        $name = $options['as'] ?? $routeKey;
383✔
1510

1511
        helper('array');
383✔
1512

1513
        // Don't overwrite any existing 'froms' so that auto-discovered routes
1514
        // do not overwrite any app/Config/Routes settings. The app
1515
        // routes should always be the "source of truth".
1516
        // this works only because discovered routes are added just prior
1517
        // to attempting to route the request.
1518
        $routeKeyExists = isset($this->routes[$verb][$routeKey]);
383✔
1519
        if ((isset($this->routesNames[$verb][$name]) || $routeKeyExists) && ! $overwrite) {
383✔
1520
            return;
10✔
1521
        }
1522

1523
        $this->routes[$verb][$routeKey] = [
383✔
1524
            'name'    => $name,
383✔
1525
            'handler' => $to,
383✔
1526
            'from'    => $from,
383✔
1527
        ];
383✔
1528
        $this->routesOptions[$verb][$routeKey] = $options;
383✔
1529
        $this->routesNames[$verb][$name]       = $routeKey;
383✔
1530

1531
        // Is this a redirect?
1532
        if (isset($options['redirect']) && is_numeric($options['redirect'])) {
383✔
1533
            $this->routes['*'][$routeKey]['redirect'] = $options['redirect'];
16✔
1534
        }
1535
    }
1536

1537
    /**
1538
     * Compares the hostname passed in against the current hostname
1539
     * on this page request.
1540
     *
1541
     * @param string $hostname Hostname in route options
1542
     */
1543
    private function checkHostname($hostname): bool
1544
    {
1545
        // CLI calls can't be on hostname.
1546
        if (! isset($this->httpHost)) {
203✔
1547
            return false;
191✔
1548
        }
1549

1550
        return strtolower($this->httpHost) === strtolower($hostname);
12✔
1551
    }
1552

1553
    private function processArrayCallableSyntax(string $from, array $to): string
1554
    {
1555
        // [classname, method]
1556
        // eg, [Home::class, 'index']
1557
        if (is_callable($to, true, $callableName)) {
3✔
1558
            // If the route has placeholders, add params automatically.
1559
            $params = $this->getMethodParams($from);
2✔
1560

1561
            return '\\' . $callableName . $params;
2✔
1562
        }
1563

1564
        // [[classname, method], params]
1565
        // eg, [[Home::class, 'index'], '$1/$2']
1566
        if (
1567
            isset($to[0], $to[1])
1✔
1568
            && is_callable($to[0], true, $callableName)
1✔
1569
            && is_string($to[1])
1✔
1570
        ) {
1571
            $to = '\\' . $callableName . '/' . $to[1];
1✔
1572
        }
1573

1574
        return $to;
1✔
1575
    }
1576

1577
    /**
1578
     * Returns the method param string like `/$1/$2` for placeholders
1579
     */
1580
    private function getMethodParams(string $from): string
1581
    {
1582
        preg_match_all('/\(.+?\)/', $from, $matches);
2✔
1583
        $count = is_countable($matches[0]) ? count($matches[0]) : 0;
2✔
1584

1585
        $params = '';
2✔
1586

1587
        for ($i = 1; $i <= $count; $i++) {
2✔
1588
            $params .= '/$' . $i;
1✔
1589
        }
1590

1591
        return $params;
2✔
1592
    }
1593

1594
    /**
1595
     * Compares the subdomain(s) passed in against the current subdomain
1596
     * on this page request.
1597
     *
1598
     * @param string|string[] $subdomains
1599
     */
1600
    private function checkSubdomains($subdomains): bool
1601
    {
1602
        // CLI calls can't be on subdomain.
1603
        if (! isset($this->httpHost)) {
216✔
1604
            return false;
191✔
1605
        }
1606

1607
        if ($this->currentSubdomain === null) {
25✔
1608
            $this->currentSubdomain = $this->determineCurrentSubdomain();
25✔
1609
        }
1610

1611
        if (! is_array($subdomains)) {
25✔
1612
            $subdomains = [$subdomains];
25✔
1613
        }
1614

1615
        // Routes can be limited to any sub-domain. In that case, though,
1616
        // it does require a sub-domain to be present.
1617
        if (! empty($this->currentSubdomain) && in_array('*', $subdomains, true)) {
25✔
1618
            return true;
9✔
1619
        }
1620

1621
        return in_array($this->currentSubdomain, $subdomains, true);
23✔
1622
    }
1623

1624
    /**
1625
     * Examines the HTTP_HOST to get the best match for the subdomain. It
1626
     * won't be perfect, but should work for our needs.
1627
     *
1628
     * It's especially not perfect since it's possible to register a domain
1629
     * with a period (.) as part of the domain name.
1630
     *
1631
     * @return false|string the subdomain
1632
     */
1633
    private function determineCurrentSubdomain()
1634
    {
1635
        // We have to ensure that a scheme exists
1636
        // on the URL else parse_url will mis-interpret
1637
        // 'host' as the 'path'.
1638
        $url = $this->httpHost;
25✔
1639
        if (strpos($url, 'http') !== 0) {
25✔
1640
            $url = 'http://' . $url;
25✔
1641
        }
1642

1643
        $parsedUrl = parse_url($url);
25✔
1644

1645
        $host = explode('.', $parsedUrl['host']);
25✔
1646

1647
        if ($host[0] === 'www') {
25✔
1648
            unset($host[0]);
3✔
1649
        }
1650

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

1654
        // Account for .co.uk, .co.nz, etc. domains
1655
        if (end($host) === 'co') {
25✔
1656
            $host = array_slice($host, 0, -1);
×
1657
        }
1658

1659
        // If we only have 1 part left, then we don't have a sub-domain.
1660
        if (count($host) === 1) {
25✔
1661
            // Set it to false so we don't make it back here again.
1662
            return false;
6✔
1663
        }
1664

1665
        return array_shift($host);
19✔
1666
    }
1667

1668
    /**
1669
     * Reset the routes, so that a test case can provide the
1670
     * explicit ones needed for it.
1671
     *
1672
     * @return void
1673
     */
1674
    public function resetRoutes()
1675
    {
1676
        $this->routes = $this->routesNames = ['*' => []];
44✔
1677

1678
        foreach ($this->defaultHTTPMethods as $verb) {
44✔
1679
            $this->routes[$verb]      = [];
44✔
1680
            $this->routesNames[$verb] = [];
44✔
1681
        }
1682

1683
        $this->routesOptions = [];
44✔
1684

1685
        $this->prioritizeDetected = false;
44✔
1686
        $this->didDiscover        = false;
44✔
1687
    }
1688

1689
    /**
1690
     * Load routes options based on verb
1691
     *
1692
     * @return array<
1693
     *     string,
1694
     *     array{
1695
     *         filter?: string|list<string>, namespace?: string, hostname?: string,
1696
     *         subdomain?: string, offset?: int, priority?: int, as?: string,
1697
     *         redirect?: int
1698
     *     }
1699
     * >
1700
     */
1701
    protected function loadRoutesOptions(?string $verb = null): array
1702
    {
1703
        $verb ??= $this->getHTTPVerb();
130✔
1704

1705
        $options = $this->routesOptions[$verb] ?? [];
130✔
1706

1707
        if (isset($this->routesOptions['*'])) {
130✔
1708
            foreach ($this->routesOptions['*'] as $key => $val) {
115✔
1709
                if (isset($options[$key])) {
115✔
1710
                    $extraOptions  = array_diff_key($val, $options[$key]);
7✔
1711
                    $options[$key] = array_merge($options[$key], $extraOptions);
7✔
1712
                } else {
1713
                    $options[$key] = $val;
109✔
1714
                }
1715
            }
1716
        }
1717

1718
        return $options;
130✔
1719
    }
1720

1721
    /**
1722
     * Enable or Disable sorting routes by priority
1723
     *
1724
     * @param bool $enabled The value status
1725
     *
1726
     * @return $this
1727
     */
1728
    public function setPrioritize(bool $enabled = true)
1729
    {
1730
        $this->prioritize = $enabled;
1✔
1731

1732
        return $this;
1✔
1733
    }
1734

1735
    /**
1736
     * Get all controllers in Route Handlers
1737
     *
1738
     * @param string|null $verb HTTP verb. `'*'` returns all controllers in any verb.
1739
     *
1740
     * @return list<string> controller name list
1741
     */
1742
    public function getRegisteredControllers(?string $verb = '*'): array
1743
    {
1744
        $controllers = [];
9✔
1745

1746
        if ($verb === '*') {
9✔
1747
            foreach ($this->defaultHTTPMethods as $tmpVerb) {
5✔
1748
                foreach ($this->routes[$tmpVerb] as $route) {
5✔
1749
                    $controller = $this->getControllerName($route['handler']);
3✔
1750
                    if ($controller !== null) {
3✔
1751
                        $controllers[] = $controller;
2✔
1752
                    }
1753
                }
1754
            }
1755
        } else {
1756
            $routes = $this->getRoutes($verb);
4✔
1757

1758
            foreach ($routes as $handler) {
4✔
1759
                $controller = $this->getControllerName($handler);
4✔
1760
                if ($controller !== null) {
4✔
1761
                    $controllers[] = $controller;
4✔
1762
                }
1763
            }
1764
        }
1765

1766
        return array_unique($controllers);
9✔
1767
    }
1768

1769
    /**
1770
     * @param Closure|string $handler Handler
1771
     *
1772
     * @return string|null Controller classname
1773
     */
1774
    private function getControllerName($handler)
1775
    {
1776
        if (! is_string($handler)) {
7✔
1777
            return null;
2✔
1778
        }
1779

1780
        [$controller] = explode('::', $handler, 2);
6✔
1781

1782
        return $controller;
6✔
1783
    }
1784

1785
    /**
1786
     * Set The flag that limit or not the routes with {locale} placeholder to App::$supportedLocales
1787
     */
1788
    public function useSupportedLocalesOnly(bool $useOnly): self
1789
    {
1790
        $this->useSupportedLocalesOnly = $useOnly;
1✔
1791

1792
        return $this;
1✔
1793
    }
1794

1795
    /**
1796
     * Get the flag that limit or not the routes with {locale} placeholder to App::$supportedLocales
1797
     */
1798
    public function shouldUseSupportedLocalesOnly(): bool
1799
    {
1800
        return $this->useSupportedLocalesOnly;
2✔
1801
    }
1802
}
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