3
declare(strict_types=1);
6
* This file is part of CodeIgniter 4 framework.
8
* (c) CodeIgniter Foundation <admin@codeigniter.com>
10
* For the full copyright and license information, please view
11
* the LICENSE file that was distributed with this source code.
14
namespace CodeIgniter\Router;
17
use CodeIgniter\Exceptions\PageNotFoundException;
18
use CodeIgniter\HTTP\Exceptions\BadRequestException;
19
use CodeIgniter\HTTP\Exceptions\RedirectException;
20
use CodeIgniter\HTTP\Method;
21
use CodeIgniter\HTTP\Request;
22
use CodeIgniter\HTTP\ResponseInterface;
23
use CodeIgniter\Router\Exceptions\RouterException;
31
* @see \CodeIgniter\Router\RouterTest
33
class Router implements RouterInterface
36
* List of allowed HTTP methods (and CLI for command line use).
38
public const HTTP_METHODS = [
52
* A RouteCollection instance.
54
* @var RouteCollectionInterface
56
protected $collection;
59
* Sub-directory that contains the requested controller class.
60
* Primarily used by 'autoRoute'.
67
* The name of the controller class.
69
* @var (Closure(mixed...): (ResponseInterface|string|void))|string
71
protected $controller;
74
* The name of the method to use.
81
* An array of binds that were collected
82
* so they can be sent to closure routes.
86
protected $params = [];
89
* The name of the front controller.
93
protected $indexPage = 'index.php';
96
* Whether dashes in URI's should be converted
97
* to underscores when determining method names.
101
protected $translateURIDashes = false;
104
* The route that was matched for this request.
108
protected $matchedRoute;
111
* The options set for the matched route.
115
protected $matchedRouteOptions;
118
* The locale that was detected in a route.
122
protected $detectedLocale;
125
* The filter info from Route Collection
126
* if the matched route should be filtered.
130
protected $filtersInfo = [];
132
protected ?AutoRouterInterface $autoRouter = null;
135
* Permitted URI chars
137
* The default value is `''` (do not check) for backward compatibility.
139
protected string $permittedURIChars = '';
142
* Stores a reference to the RouteCollection object.
144
public function __construct(RouteCollectionInterface $routes, ?Request $request = null)
146
$config = config(App::class);
147
if (isset($config->permittedURIChars)) {
148
$this->permittedURIChars = $config->permittedURIChars;
151
$this->collection = $routes;
153
// These are only for auto-routing
154
$this->controller = $this->collection->getDefaultController();
155
$this->method = $this->collection->getDefaultMethod();
157
$this->collection->setHTTPVerb($request->getMethod() ?? $_SERVER['REQUEST_METHOD']);
159
$this->translateURIDashes = $this->collection->shouldTranslateURIDashes();
161
if ($this->collection->shouldAutoRoute()) {
162
$autoRoutesImproved = config(Feature::class)->autoRoutesImproved ?? false;
163
if ($autoRoutesImproved) {
164
$this->autoRouter = new AutoRouterImproved(
165
$this->collection->getRegisteredControllers('*'),
166
$this->collection->getDefaultNamespace(),
167
$this->collection->getDefaultController(),
168
$this->collection->getDefaultMethod(),
169
$this->translateURIDashes
172
$this->autoRouter = new AutoRouter(
173
$this->collection->getRoutes('CLI', false), // @phpstan-ignore-line
174
$this->collection->getDefaultNamespace(),
175
$this->collection->getDefaultController(),
176
$this->collection->getDefaultMethod(),
177
$this->translateURIDashes
184
* Finds the controller corresponding to the URI.
186
* @param string|null $uri URI path relative to baseURL
188
* @return (Closure(mixed...): (ResponseInterface|string|void))|string Controller classname or Closure
190
* @throws BadRequestException
191
* @throws PageNotFoundException
192
* @throws RedirectException
194
public function handle(?string $uri = null)
196
// If we cannot find a URI to match against, then set it to root (`/`).
197
if ($uri === null || $uri === '') {
201
// Decode URL-encoded string
202
$uri = urldecode($uri);
204
$this->checkDisallowedChars($uri);
206
// Restart filterInfo
207
$this->filtersInfo = [];
209
// Checks defined routes
210
if ($this->checkRoutes($uri)) {
211
if ($this->collection->isFiltered($this->matchedRoute[0])) {
212
$this->filtersInfo = $this->collection->getFiltersForRoute($this->matchedRoute[0]);
215
return $this->controller;
218
// Still here? Then we can try to match the URI against
219
// Controllers/directories, but the application may not
220
// want this, like in the case of API's.
221
if (! $this->collection->shouldAutoRoute()) {
222
throw new PageNotFoundException(
223
"Can't find a route for '{$this->collection->getHTTPVerb()}: {$uri}'."
227
// Checks auto routes
228
$this->autoRoute($uri);
230
return $this->controllerName();
234
* Returns the filter info for the matched route, if any.
236
* @return list<string>
238
public function getFilters(): array
240
return $this->filtersInfo;
244
* Returns the name of the matched controller or closure.
246
* @return (Closure(mixed...): (ResponseInterface|string|void))|string Controller classname or Closure
248
public function controllerName()
250
return $this->translateURIDashes
251
? str_replace('-', '_', $this->controller)
256
* Returns the name of the method to run in the
259
public function methodName(): string
261
return $this->translateURIDashes
262
? str_replace('-', '_', $this->method)
267
* Returns the 404 Override settings from the Collection.
268
* If the override is a string, will split to controller/index array.
270
public function get404Override()
272
$route = $this->collection->get404Override();
274
if (is_string($route)) {
275
$routeArray = explode('::', $route);
278
$routeArray[0], // Controller
279
$routeArray[1] ?? 'index', // Method
283
if (is_callable($route)) {
291
* Returns the binds that have been matched and collected
292
* during the parsing process as an array, ready to send to
293
* instance->method(...$params).
295
public function params(): array
297
return $this->params;
301
* Returns the name of the sub-directory the controller is in,
302
* if any. Relative to APPPATH.'Controllers'.
304
* Only used when auto-routing is turned on.
306
public function directory(): string
308
if ($this->autoRouter instanceof AutoRouter) {
309
return $this->autoRouter->directory();
316
* Returns the routing information that was matched for this
317
* request, if a route was defined.
321
public function getMatchedRoute()
323
return $this->matchedRoute;
327
* Returns all options set for the matched route
331
public function getMatchedRouteOptions()
333
return $this->matchedRouteOptions;
337
* Sets the value that should be used to match the index.php file. Defaults
338
* to index.php but this allows you to modify it in case you are using
339
* something like mod_rewrite to remove the page. This allows you to set
342
* @param string $page
344
public function setIndexPage($page): self
346
$this->indexPage = $page;
352
* Tells the system whether we should translate URI dashes or not
353
* in the URI from a dash to an underscore.
355
* @deprecated This method should be removed.
357
public function setTranslateURIDashes(bool $val = false): self
359
if ($this->autoRouter instanceof AutoRouter) {
360
$this->autoRouter->setTranslateURIDashes($val);
369
* Returns true/false based on whether the current route contained
370
* a {locale} placeholder.
374
public function hasLocale()
376
return (bool) $this->detectedLocale;
380
* Returns the detected locale, if any, or null.
384
public function getLocale()
386
return $this->detectedLocale;
390
* Checks Defined Routes.
392
* Compares the uri string against the routes that the
393
* RouteCollection class defined for us, attempting to find a match.
394
* This method will modify $this->controller, etal as needed.
396
* @param string $uri The URI path to compare against the routes
398
* @return bool Whether the route was matched or not.
400
* @throws RedirectException
402
protected function checkRoutes(string $uri): bool
404
// @phpstan-ignore-next-line
405
$routes = $this->collection->getRoutes($this->collection->getHTTPVerb());
407
// Don't waste any time
408
if ($routes === []) {
416
// Loop through the route array looking for wildcards
417
foreach ($routes as $routeKey => $handler) {
418
$routeKey = $routeKey === '/'
420
// $routeKey may be int, because it is an array key,
421
// and the URI `/1` is valid. The leading `/` is removed.
422
: ltrim((string) $routeKey, '/ ');
424
$matchedKey = $routeKey;
426
// Are we dealing with a locale?
427
if (str_contains($routeKey, '{locale}')) {
428
$routeKey = str_replace('{locale}', '[^/]+', $routeKey);
431
// Does the RegEx match?
432
if (preg_match('#^' . $routeKey . '$#u', $uri, $matches)) {
433
// Is this route supposed to redirect to another?
434
if ($this->collection->isRedirect($routeKey)) {
435
// replacing matched route groups with references: post/([0-9]+) -> post/$1
436
$redirectTo = preg_replace_callback('/(\([^\(]+\))/', static function () {
440
}, is_array($handler) ? key($handler) : $handler);
442
throw new RedirectException(
443
preg_replace('#\A' . $routeKey . '\z#u', $redirectTo, $uri),
444
$this->collection->getRedirectCode($routeKey)
447
// Store our locale so CodeIgniter object can
448
// assign it to the Request.
449
if (str_contains($matchedKey, '{locale}')) {
451
'#^' . str_replace('{locale}', '(?<locale>[^/]+)', $matchedKey) . '$#u',
456
if ($this->collection->shouldUseSupportedLocalesOnly()
457
&& ! in_array($matched['locale'], config(App::class)->supportedLocales, true)) {
458
// Throw exception to prevent the autorouter, if enabled,
459
// from trying to find a route
460
throw PageNotFoundException::forLocaleNotSupported($matched['locale']);
463
$this->detectedLocale = $matched['locale'];
467
// Are we using Closures? If so, then we need
468
// to collect the params into an array
469
// so it can be passed to the controller method later.
470
if (! is_string($handler) && is_callable($handler)) {
471
$this->controller = $handler;
473
// Remove the original string from the matches array
474
array_shift($matches);
476
$this->params = $matches;
478
$this->setMatchedRoute($matchedKey, $handler);
483
if (str_contains($handler, '::')) {
484
[$controller, $methodAndParams] = explode('::', $handler);
486
$controller = $handler;
487
$methodAndParams = '';
490
// Checks `/` in controller name
491
if (str_contains($controller, '/')) {
492
throw RouterException::forInvalidControllerName($handler);
495
if (str_contains($handler, '$') && str_contains($routeKey, '(')) {
496
// Checks dynamic controller
497
if (str_contains($controller, '$')) {
498
throw RouterException::forDynamicController($handler);
501
if (config(Routing::class)->multipleSegmentsOneParam === false) {
502
// Using back-references
503
$segments = explode('/', preg_replace('#\A' . $routeKey . '\z#u', $handler, $uri));
505
if (str_contains($methodAndParams, '/')) {
506
[$method, $handlerParams] = explode('/', $methodAndParams, 2);
507
$params = explode('/', $handlerParams);
508
$handlerSegments = array_merge([$controller . '::' . $method], $params);
510
$handlerSegments = [$handler];
515
foreach ($handlerSegments as $segment) {
516
$segments[] = $this->replaceBackReferences($segment, $matches);
520
$segments = explode('/', $handler);
523
$this->setRequest($segments);
525
$this->setMatchedRoute($matchedKey, $handler);
535
* Replace string `$n` with `$matches[n]` value.
537
private function replaceBackReferences(string $input, array $matches): string
539
$pattern = '/\$([1-' . count($matches) . '])/u';
541
return preg_replace_callback(
543
static function ($match) use ($matches) {
544
$index = (int) $match[1];
546
return $matches[$index] ?? '';
553
* Checks Auto Routes.
555
* Attempts to match a URI path against Controllers and directories
556
* found in APPPATH/Controllers, to find a matching route.
560
public function autoRoute(string $uri)
562
[$this->directory, $this->controller, $this->method, $this->params]
563
= $this->autoRouter->getRoute($uri, $this->collection->getHTTPVerb());
567
* Scans the controller directory, attempting to locate a controller matching the supplied uri $segments
569
* @param array $segments URI segments
571
* @return array returns an array of remaining uri segments that don't map onto a directory
573
* @deprecated this function name does not properly describe its behavior so it has been deprecated
575
* @codeCoverageIgnore
577
protected function validateRequest(array $segments): array
579
return $this->scanControllers($segments);
583
* Scans the controller directory, attempting to locate a controller matching the supplied uri $segments
585
* @param array $segments URI segments
587
* @return array returns an array of remaining uri segments that don't map onto a directory
589
* @deprecated Not used. Moved to AutoRouter class.
591
protected function scanControllers(array $segments): array
593
$segments = array_filter($segments, static fn ($segment) => $segment !== '');
594
// numerically reindex the array, removing gaps
595
$segments = array_values($segments);
597
// if a prior directory value has been set, just return segments and get out of here
598
if (isset($this->directory)) {
602
// Loop through our segments and return as soon as a controller
603
// is found or when such a directory doesn't exist
604
$c = count($segments);
607
$segmentConvert = ucfirst($this->translateURIDashes === true ? str_replace('-', '_', $segments[0]) : $segments[0]);
608
// as soon as we encounter any segment that is not PSR-4 compliant, stop searching
609
if (! $this->isValidSegment($segmentConvert)) {
613
$test = APPPATH . 'Controllers/' . $this->directory . $segmentConvert;
615
// as long as each segment is *not* a controller file but does match a directory, add it to $this->directory
616
if (! is_file($test . '.php') && is_dir($test)) {
617
$this->setDirectory($segmentConvert, true, false);
618
array_shift($segments);
626
// This means that all segments were actually directories
631
* Sets the sub-directory that the controller is in.
633
* @param bool $validate if true, checks to make sure $dir consists of only PSR4 compliant segments
637
* @deprecated This method should be removed.
639
public function setDirectory(?string $dir = null, bool $append = false, bool $validate = true)
641
if ($dir === null || $dir === '') {
642
$this->directory = null;
645
if ($this->autoRouter instanceof AutoRouter) {
646
$this->autoRouter->setDirectory($dir, $append, $validate);
651
* Returns true if the supplied $segment string represents a valid PSR-4 compliant namespace/directory segment
653
* regex comes from https://www.php.net/manual/en/language.variables.basics.php
655
* @deprecated Moved to AutoRouter class.
657
private function isValidSegment(string $segment): bool
659
return (bool) preg_match('/^[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*$/', $segment);
665
* Takes an array of URI segments as input and sets the class/method
668
* @param array $segments URI segments
672
protected function setRequest(array $segments = [])
674
// If we don't have any segments - use the default controller;
675
if ($segments === []) {
679
[$controller, $method] = array_pad(explode('::', $segments[0]), 2, null);
681
$this->controller = $controller;
683
// $this->method already contains the default method name,
684
// so don't overwrite it with emptiness.
685
if (! empty($method)) {
686
$this->method = $method;
689
array_shift($segments);
691
$this->params = $segments;
695
* Sets the default controller based on the info set in the RouteCollection.
697
* @deprecated This was an unnecessary method, so it is no longer used.
701
protected function setDefaultController()
703
if (empty($this->controller)) {
704
throw RouterException::forMissingDefaultRoute();
707
sscanf($this->controller, '%[^/]/%s', $class, $this->method);
709
if (! is_file(APPPATH . 'Controllers/' . $this->directory . ucfirst($class) . '.php')) {
713
$this->controller = ucfirst($class);
715
log_message('info', 'Used the default controller.');
719
* @param callable|string $handler
721
protected function setMatchedRoute(string $route, $handler): void
723
$this->matchedRoute = [$route, $handler];
725
$this->matchedRouteOptions = $this->collection->getRoutesOptions($route);
729
* Checks disallowed characters
731
private function checkDisallowedChars(string $uri): void
733
foreach (explode('/', $uri) as $segment) {
734
if ($segment !== '' && $this->permittedURIChars !== ''
735
&& preg_match('/\A[' . $this->permittedURIChars . ']+\z/iu', $segment) !== 1
737
throw new BadRequestException(
738
'The URI you submitted has disallowed characters: "' . $segment . '"'