diff --git a/system/Commands/Utilities/Routes.php b/system/Commands/Utilities/Routes.php index 210d1e11c5f7..26ad9b2a689e 100644 --- a/system/Commands/Utilities/Routes.php +++ b/system/Commands/Utilities/Routes.php @@ -13,12 +13,13 @@ use CodeIgniter\CLI\BaseCommand; use CodeIgniter\CLI\CLI; +use CodeIgniter\Commands\Utilities\Routes\AutoRouteCollector; use Config\Services; /** - * Lists all of the user-defined routes. This will include any Routes files - * that can be discovered, but will NOT include any routes that are not defined - * in a routes file, but are instead discovered through auto-routing. + * Lists all the routes. This will include any Routes files + * that can be discovered, and will include routes that are not defined + * in routes files, but are instead discovered through auto-routing. */ class Routes extends BaseCommand { @@ -101,6 +102,15 @@ public function run(array $params) } } + if ($collection->shouldAutoRoute()) { + $autoRouteCollector = new AutoRouteCollector( + $collection->getDefaultNamespace(), + $collection->getDefaultController(), + $collection->getDefaultMethod() + ); + $tbody = [...$tbody, ...$autoRouteCollector->get()]; + } + $thead = [ 'Method', 'Route', diff --git a/system/Commands/Utilities/Routes/AutoRouteCollector.php b/system/Commands/Utilities/Routes/AutoRouteCollector.php new file mode 100644 index 000000000000..a3254adb6a57 --- /dev/null +++ b/system/Commands/Utilities/Routes/AutoRouteCollector.php @@ -0,0 +1,65 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Commands\Utilities\Routes; + +/** + * Collects data for auto route listing. + */ +final class AutoRouteCollector +{ + /** + * @var string namespace to search + */ + private string $namespace; + + private string $defaultController; + private string $defaultMethod; + + /** + * @param string $namespace namespace to search + */ + public function __construct(string $namespace, string $defaultController, string $defaultMethod) + { + $this->namespace = $namespace; + $this->defaultController = $defaultController; + $this->defaultMethod = $defaultMethod; + } + + /** + * @return list> + */ + public function get(): array + { + $finder = new ControllerFinder($this->namespace); + $reader = new ControllerMethodReader($this->namespace); + + $tbody = []; + + foreach ($finder->find() as $class) { + $output = $reader->read( + $class, + $this->defaultController, + $this->defaultMethod + ); + + foreach ($output as $item) { + $tbody[] = [ + 'auto', + $item['route'], + $item['handler'], + ]; + } + } + + return $tbody; + } +} diff --git a/system/Commands/Utilities/Routes/ControllerFinder.php b/system/Commands/Utilities/Routes/ControllerFinder.php new file mode 100644 index 000000000000..9e6dd5ca2005 --- /dev/null +++ b/system/Commands/Utilities/Routes/ControllerFinder.php @@ -0,0 +1,75 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Commands\Utilities\Routes; + +use CodeIgniter\Autoloader\FileLocator; +use CodeIgniter\Config\Services; + +/** + * Finds all controllers in a namespace for auto route listing. + */ +final class ControllerFinder +{ + /** + * @var string namespace to search + */ + private string $namespace; + + private FileLocator $locator; + + /** + * @param string $namespace namespace to search + */ + public function __construct(string $namespace) + { + $this->namespace = $namespace; + $this->locator = Services::locator(); + } + + /** + * @return class-string[] + */ + public function find(): array + { + $nsArray = explode('\\', trim($this->namespace, '\\')); + $count = count($nsArray); + $ns = ''; + + for ($i = 0; $i < $count; $i++) { + $ns .= '\\' . array_shift($nsArray); + $path = implode('\\', $nsArray); + + $files = $this->locator->listNamespaceFiles($ns, $path); + + if ($files !== []) { + break; + } + } + + $classes = []; + + foreach ($files as $file) { + if (\is_file($file)) { + $classnameOrEmpty = $this->locator->getClassname($file); + + if ($classnameOrEmpty !== '') { + /** @var class-string $classname */ + $classname = $classnameOrEmpty; + + $classes[] = $classname; + } + } + } + + return $classes; + } +} diff --git a/system/Commands/Utilities/Routes/ControllerMethodReader.php b/system/Commands/Utilities/Routes/ControllerMethodReader.php new file mode 100644 index 000000000000..0e9549f2b88a --- /dev/null +++ b/system/Commands/Utilities/Routes/ControllerMethodReader.php @@ -0,0 +1,175 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Commands\Utilities\Routes; + +use ReflectionClass; +use ReflectionMethod; + +/** + * Reads a controller and returns a list of auto route listing. + */ +final class ControllerMethodReader +{ + /** + * @var string the default namespace + */ + private string $namespace; + + /** + * @param string $namespace the default namespace + */ + public function __construct(string $namespace) + { + $this->namespace = $namespace; + } + + /** + * @param class-string $class + * + * @return list + */ + public function read(string $class, string $defaultController = 'Home', string $defaultMethod = 'index'): array + { + $reflection = new ReflectionClass($class); + + if ($reflection->isAbstract()) { + return []; + } + + $classname = $reflection->getName(); + $classShortname = $reflection->getShortName(); + + $output = []; + $uriByClass = $this->getUriByClass($classname); + + if ($this->hasRemap($reflection)) { + $methodName = '_remap'; + + $routeWithoutController = $this->getRouteWithoutController( + $classShortname, + $defaultController, + $uriByClass, + $classname, + $methodName + ); + $output = [...$output, ...$routeWithoutController]; + + $output[] = [ + 'route' => $uriByClass . '[/...]', + 'handler' => '\\' . $classname . '::' . $methodName, + ]; + + return $output; + } + + foreach ($reflection->getMethods(ReflectionMethod::IS_PUBLIC) as $method) { + $methodName = $method->getName(); + + $route = $uriByClass . '/' . $methodName; + + // Exclude BaseController and initController + // See system/Config/Routes.php + if (preg_match('#\AbaseController.*#', $route) === 1) { + continue; + } + if (preg_match('#.*/initController\z#', $route) === 1) { + continue; + } + + if ($methodName === $defaultMethod) { + $routeWithoutController = $this->getRouteWithoutController( + $classShortname, + $defaultController, + $uriByClass, + $classname, + $methodName + ); + $output = [...$output, ...$routeWithoutController]; + + $output[] = [ + 'route' => $uriByClass, + 'handler' => '\\' . $classname . '::' . $methodName, + ]; + } + + $output[] = [ + 'route' => $route . '[/...]', + 'handler' => '\\' . $classname . '::' . $methodName, + ]; + } + + return $output; + } + + /** + * Whether the class has a _remap() method. + */ + private function hasRemap(ReflectionClass $class): bool + { + if ($class->hasMethod('_remap')) { + $remap = $class->getMethod('_remap'); + + return $remap->isPublic(); + } + + return false; + } + + /** + * @param class-string $classname + * + * @return string URI path part from the folder(s) and controller + */ + private function getUriByClass(string $classname): string + { + // remove the namespace + $pattern = '/' . preg_quote($this->namespace, '/') . '/'; + $class = ltrim(preg_replace($pattern, '', $classname), '\\'); + + $classParts = explode('\\', $class); + $classPath = ''; + + foreach ($classParts as $part) { + // make the first letter lowercase, because auto routing makes + // the URI path's first letter uppercase and search the controller + $classPath .= lcfirst($part) . '/'; + } + + return rtrim($classPath, '/'); + } + + /** + * Gets a route without default controller. + */ + private function getRouteWithoutController( + string $classShortname, + string $defaultController, + string $uriByClass, + string $classname, + string $methodName + ): array { + $output = []; + + if ($classShortname === $defaultController) { + $pattern = '#' . preg_quote(lcfirst($defaultController), '#') . '\z#'; + $routeWithoutController = rtrim(preg_replace($pattern, '', $uriByClass), '/'); + $routeWithoutController = $routeWithoutController ?: '/'; + + $output[] = [ + 'route' => $routeWithoutController, + 'handler' => '\\' . $classname . '::' . $methodName, + ]; + } + + return $output; + } +} diff --git a/tests/_support/Controllers/Hello.php b/tests/_support/Controllers/Hello.php index cc63814d7e09..ac9f421b55d1 100644 --- a/tests/_support/Controllers/Hello.php +++ b/tests/_support/Controllers/Hello.php @@ -9,7 +9,7 @@ * the LICENSE file that was distributed with this source code. */ -namespace App\Controllers; +namespace Tests\Support\Controllers; use CodeIgniter\Controller; diff --git a/tests/_support/Controllers/Remap.php b/tests/_support/Controllers/Remap.php new file mode 100644 index 000000000000..1489a80aa776 --- /dev/null +++ b/tests/_support/Controllers/Remap.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Support\Controllers; + +use CodeIgniter\Controller; + +class Remap extends Controller +{ + public function _remap($method, ...$params) + { + if ($method === 'xyz') { + return $this->abc(); + } + + return $this->index(); + } + + public function index() + { + return 'index'; + } + + public function abc() + { + return 'abc'; + } +} diff --git a/tests/system/Commands/Utilities/Routes/AutoRouteCollectorTest.php b/tests/system/Commands/Utilities/Routes/AutoRouteCollectorTest.php new file mode 100644 index 000000000000..dd1c5db46b5f --- /dev/null +++ b/tests/system/Commands/Utilities/Routes/AutoRouteCollectorTest.php @@ -0,0 +1,121 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Commands\Utilities\Routes; + +use CodeIgniter\Test\CIUnitTestCase; + +/** + * @internal + */ +final class AutoRouteCollectorTest extends CIUnitTestCase +{ + public function testGet() + { + $namespace = 'Tests\Support\Controllers'; + $collector = new AutoRouteCollector( + $namespace, + 'Home', + 'index', + ); + + $routes = $collector->get(); + + $expected = [ + 0 => [ + 0 => 'auto', + 1 => 'hello', + 2 => '\\Tests\\Support\\Controllers\\Hello::index', + ], + 1 => [ + 0 => 'auto', + 1 => 'hello/index[/...]', + 2 => '\\Tests\\Support\\Controllers\\Hello::index', + ], + 2 => [ + 0 => 'auto', + 1 => 'popcorn', + 2 => '\\Tests\\Support\\Controllers\\Popcorn::index', + ], + 3 => [ + 0 => 'auto', + 1 => 'popcorn/index[/...]', + 2 => '\\Tests\\Support\\Controllers\\Popcorn::index', + ], + 4 => [ + 0 => 'auto', + 1 => 'popcorn/pop[/...]', + 2 => '\\Tests\\Support\\Controllers\\Popcorn::pop', + ], + 5 => [ + 0 => 'auto', + 1 => 'popcorn/popper[/...]', + 2 => '\\Tests\\Support\\Controllers\\Popcorn::popper', + ], + 6 => [ + 0 => 'auto', + 1 => 'popcorn/weasel[/...]', + 2 => '\\Tests\\Support\\Controllers\\Popcorn::weasel', + ], + 7 => [ + 0 => 'auto', + 1 => 'popcorn/oops[/...]', + 2 => '\\Tests\\Support\\Controllers\\Popcorn::oops', + ], + 8 => [ + 0 => 'auto', + 1 => 'popcorn/goaway[/...]', + 2 => '\\Tests\\Support\\Controllers\\Popcorn::goaway', + ], + 9 => [ + 0 => 'auto', + 1 => 'popcorn/index3[/...]', + 2 => '\\Tests\\Support\\Controllers\\Popcorn::index3', + ], + 10 => [ + 0 => 'auto', + 1 => 'popcorn/canyon[/...]', + 2 => '\\Tests\\Support\\Controllers\\Popcorn::canyon', + ], + 11 => [ + 0 => 'auto', + 1 => 'popcorn/cat[/...]', + 2 => '\\Tests\\Support\\Controllers\\Popcorn::cat', + ], + 12 => [ + 0 => 'auto', + 1 => 'popcorn/json[/...]', + 2 => '\\Tests\\Support\\Controllers\\Popcorn::json', + ], + 13 => [ + 0 => 'auto', + 1 => 'popcorn/xml[/...]', + 2 => '\\Tests\\Support\\Controllers\\Popcorn::xml', + ], + 14 => [ + 0 => 'auto', + 1 => 'popcorn/toindex[/...]', + 2 => '\\Tests\\Support\\Controllers\\Popcorn::toindex', + ], + 15 => [ + 0 => 'auto', + 1 => 'popcorn/echoJson[/...]', + 2 => '\\Tests\\Support\\Controllers\\Popcorn::echoJson', + ], + 16 => [ + 0 => 'auto', + 1 => 'remap[/...]', + 2 => '\\Tests\\Support\\Controllers\\Remap::_remap', + ], + ]; + $this->assertSame($expected, $routes); + } +} diff --git a/tests/system/Commands/Utilities/Routes/ControllerFinderTest.php b/tests/system/Commands/Utilities/Routes/ControllerFinderTest.php new file mode 100644 index 000000000000..676a1869b1b3 --- /dev/null +++ b/tests/system/Commands/Utilities/Routes/ControllerFinderTest.php @@ -0,0 +1,31 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Commands\Utilities\Routes; + +use CodeIgniter\Test\CIUnitTestCase; + +/** + * @internal + */ +final class ControllerFinderTest extends CIUnitTestCase +{ + public function testFind() + { + $namespace = 'Tests\Support\Controllers'; + $finder = new ControllerFinder($namespace); + + $controllers = $finder->find(); + + $this->assertCount(3, $controllers); + $this->assertSame('Tests\Support\Controllers\Hello', $controllers[0]); + } +} diff --git a/tests/system/Commands/Utilities/Routes/ControllerMethodReaderTest.php b/tests/system/Commands/Utilities/Routes/ControllerMethodReaderTest.php new file mode 100644 index 000000000000..a899b9c44b73 --- /dev/null +++ b/tests/system/Commands/Utilities/Routes/ControllerMethodReaderTest.php @@ -0,0 +1,106 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Commands\Utilities\Routes; + +use CodeIgniter\Test\CIUnitTestCase; +use Tests\Support\Controllers\Popcorn; +use Tests\Support\Controllers\Remap; + +/** + * @internal + */ +final class ControllerMethodReaderTest extends CIUnitTestCase +{ + public function testRead() + { + $namespace = 'Tests\Support\Controllers'; + $reader = new ControllerMethodReader($namespace); + + $routes = $reader->read(Popcorn::class); + + $expected = [ + 0 => [ + 'route' => 'popcorn', + 'handler' => '\\Tests\\Support\\Controllers\\Popcorn::index', + ], + 1 => [ + 'route' => 'popcorn/index[/...]', + 'handler' => '\\Tests\\Support\\Controllers\\Popcorn::index', + ], + 2 => [ + 'route' => 'popcorn/pop[/...]', + 'handler' => '\\Tests\\Support\\Controllers\\Popcorn::pop', + ], + 3 => [ + 'route' => 'popcorn/popper[/...]', + 'handler' => '\\Tests\\Support\\Controllers\\Popcorn::popper', + ], + 4 => [ + 'route' => 'popcorn/weasel[/...]', + 'handler' => '\\Tests\\Support\\Controllers\\Popcorn::weasel', + ], + 5 => [ + 'route' => 'popcorn/oops[/...]', + 'handler' => '\\Tests\\Support\\Controllers\\Popcorn::oops', + ], + 6 => [ + 'route' => 'popcorn/goaway[/...]', + 'handler' => '\\Tests\\Support\\Controllers\\Popcorn::goaway', + ], + 7 => [ + 'route' => 'popcorn/index3[/...]', + 'handler' => '\\Tests\\Support\\Controllers\\Popcorn::index3', + ], + 8 => [ + 'route' => 'popcorn/canyon[/...]', + 'handler' => '\\Tests\\Support\\Controllers\\Popcorn::canyon', + ], + 9 => [ + 'route' => 'popcorn/cat[/...]', + 'handler' => '\\Tests\\Support\\Controllers\\Popcorn::cat', + ], + 10 => [ + 'route' => 'popcorn/json[/...]', + 'handler' => '\\Tests\\Support\\Controllers\\Popcorn::json', + ], + 11 => [ + 'route' => 'popcorn/xml[/...]', + 'handler' => '\\Tests\\Support\\Controllers\\Popcorn::xml', + ], + 12 => [ + 'route' => 'popcorn/toindex[/...]', + 'handler' => '\\Tests\\Support\\Controllers\\Popcorn::toindex', + ], + 13 => [ + 'route' => 'popcorn/echoJson[/...]', + 'handler' => '\\Tests\\Support\\Controllers\\Popcorn::echoJson', + ], + ]; + $this->assertSame($expected, $routes); + } + + public function testReadControllerWithRemap() + { + $namespace = 'Tests\Support\Controllers'; + $reader = new ControllerMethodReader($namespace); + + $routes = $reader->read(Remap::class); + + $expected = [ + 0 => [ + 'route' => 'remap[/...]', + 'handler' => '\\Tests\\Support\\Controllers\\Remap::_remap', + ], + ]; + $this->assertSame($expected, $routes); + } +}