From 5f4e84a096287a41c46db48e7c7fa57222ef6d50 Mon Sep 17 00:00:00 2001 From: Jasper Smet Date: Sun, 18 Oct 2026 09:41:40 +0200 Subject: [PATCH 1/3] Generate controller routing attributes by default --- docs/en/development.md | 19 ++ docs/en/usage.md | 67 +++++ src/Command/AllCommand.php | 12 +- src/Command/BakeCommand.php | 51 ++++ src/Command/ControllerAllCommand.php | 4 + src/Command/ControllerCommand.php | 53 +++- src/Command/PluginCommand.php | 7 +- templates/bake/Controller/controller.twig | 8 +- templates/bake/Plugin/src/Plugin.php.twig | 2 + templates/bake/element/Controller/add.twig | 3 + templates/bake/element/Controller/delete.twig | 3 + templates/bake/element/Controller/edit.twig | 3 + templates/bake/element/Controller/index.twig | 4 + templates/bake/element/Controller/login.twig | 3 + templates/bake/element/Controller/view.twig | 3 + tests/TestCase/Command/AllCommandTest.php | 24 +- .../TestCase/Command/AttributeRoutingTest.php | 257 ++++++++++++++++++ .../Command/ControllerAllCommandTest.php | 23 +- .../Command/ControllerCommandTest.php | 58 ++++ tests/TestCase/Command/PluginCommandTest.php | 25 ++ .../Controller/testBakeActions.php | 10 + .../testBakeActionsAuthenticationPlugin.php | 11 + .../testBakeActionsAuthorizationPlugin.php | 10 + .../Controller/testBakeActionsContent.php | 10 + .../Controller/testBakeActionsOption.php | 6 + ...eAuthenticationWithoutAttributeRouting.php | 134 +++++++++ .../Controller/testBakeWithPlugin.php | 9 + .../testBakeWithoutAttributeRouting.php | 105 +++++++ .../Company/Example/src/ExamplePlugin.php | 2 - .../SimpleExample/src/SimpleExamplePlugin.php | 2 - 30 files changed, 916 insertions(+), 12 deletions(-) create mode 100644 tests/TestCase/Command/AttributeRoutingTest.php create mode 100644 tests/comparisons/Controller/testBakeAuthenticationWithoutAttributeRouting.php create mode 100644 tests/comparisons/Controller/testBakeWithoutAttributeRouting.php diff --git a/docs/en/development.md b/docs/en/development.md index c5363ec76..d6debd0b0 100644 --- a/docs/en/development.md +++ b/docs/en/development.md @@ -93,6 +93,25 @@ EventManager::instance()->on( By scoping event listeners to specific bake templates, you can simplify your bake-related event logic and provide callbacks that are easier to test. +## Routing Attributes in Custom Themes + +Controller templates receive the resolved `attributeRouting` boolean and routing +metadata: `routingActions`, `routingImports`, `routingScopePath`, +`routingPrefixName`, and `routingPrefixPath`. The default controller template uses +this metadata for its imports and class attributes. Built-in action elements +render their own `Route` attributes without HTTP method restrictions. Themes can +override those elements to customize paths or add method restrictions; the +default templates preserve method checks inside the controller actions. + +Custom action elements must define their own routes when attribute routing is +enabled. Use fully qualified attribute names or add their imports in the theme's +controller template. Overriding the entire controller template also makes the +theme responsible for rendering class attributes and routing imports. Honor +`attributeRouting` when supporting both routing modes. + +Plugin templates receive `attributeRouting` too. Attribute-enabled plugin +skeletons omit fallback routes. + ## Bake Template Syntax Bake template files use the [Twig](https://twig.symfony.com/) template syntax. diff --git a/docs/en/usage.md b/docs/en/usage.md index da4c18633..c314ac67d 100644 --- a/docs/en/usage.md +++ b/docs/en/usage.md @@ -45,6 +45,73 @@ To get help on a specific command, type `cake command_name --help` To see full descriptions and plugin grouping, use `cake --help -v` ``` +## Controller Routing + +For CakePHP 6, Bake generates routing attributes on controllers by default. +This applies to `bake controller`, `bake controller all`, and `bake all`. +Generated plugin skeletons also omit fallback routes by default. + +### Generated URLs and Method Checks + +For `ArticlesController`, Bake generates these routes: + +| Action | Path | +| --- | --- | +| index | `/articles` and `/articles/index` | +| view | `/articles/view/{id}` | +| add | `/articles/add` | +| edit | `/articles/edit/{id}` | +| delete | `/articles/delete/{id}` | +| login, when generated | `/users/login` for `UsersController` | + +Routes use `#[Route]` attributes without HTTP method restrictions; index has an +additional attribute for its `/index` alias. Method handling remains in the +controller actions, preserving the previous behavior. The generated delete action +uses `allowMethod(['post', 'delete'])`, and the login action uses +`allowMethod(['get', 'post'])`. Unsupported methods for those actions receive the +existing 405 response. Other actions retain their existing request handling. + +Existing array-based links, form actions, and redirects work without fallbacks or +additional `_method` routing hints, with `/articles` as the index URL: + +```php +$this->Form->postLink('Delete', ['action' => 'delete', $article->id]); +``` + +Identifiers are not restricted to integers, so UUIDs and other string keys work too. +Only actions selected for generation receive routes. Empty controllers receive no +routing attributes. + +Prefixes retain their namespace paths: `--prefix Admin/Management` produces +`/admin/management/articles`. Plugin routes include the conventional plugin path: +`Blog.Articles` produces `/blog/articles`, and adding `--prefix Admin` produces +`/blog/admin/articles`. Customize the generated `Scope` or `Prefix` attributes if +your plugin uses a different mount path. + +### Project Default and Command Overrides + +Projects managing their routes in configuration can disable attribute generation +in their CLI bootstrap: + +```php +use Cake\Core\Configure; + +Configure::write('Bake.attributeRouting', false); +``` + +Override either project default for an individual invocation: + +```bash +bin/cake bake controller Articles --attribute-routing +bin/cake bake all Articles --no-attribute-routing +bin/cake bake controller all --no-attribute-routing +bin/cake bake plugin Blog --attribute-routing +``` + +`--attribute-routing` and `--no-attribute-routing` cannot be combined. Disabling +attributes preserves the previous generated output, including the plugin +skeleton's fallback route. It does not change existing files' routing setup. + ## Bake Models Models are generically baked from existing database tables. diff --git a/src/Command/AllCommand.php b/src/Command/AllCommand.php index d2421b169..0cb7e2b99 100644 --- a/src/Command/AllCommand.php +++ b/src/Command/AllCommand.php @@ -61,7 +61,7 @@ protected function buildOptionParser(ConsoleOptionParser $parser): ConsoleOption 'default' => false, ]); - return $parser; + return $this->addAttributeRoutingOptions($parser); } /** @@ -71,6 +71,10 @@ protected function buildOptionParser(ConsoleOptionParser $parser): ConsoleOption */ public function execute(): ?int { + if (!$this->validateAttributeRoutingOptions()) { + return static::CODE_ERROR; + } + $this->extractCommonProperties($this->args); $name = $this->args->getArgument('name') ?? ''; $name = $this->getNameWithoutPrefix($name); @@ -97,6 +101,12 @@ public function execute(): ?int $command = new $commandName($this->factory); $options = $this->args->getOptions(); + if ($command instanceof ControllerCommand) { + $options['attribute-routing'] = $this->useAttributeRouting(); + $options['no-attribute-routing'] = !$this->useAttributeRouting(); + } else { + unset($options['attribute-routing'], $options['no-attribute-routing']); + } if ( $this->args->hasOption('prefix') && !($command instanceof ControllerCommand) && diff --git a/src/Command/BakeCommand.php b/src/Command/BakeCommand.php index 77de8da91..e16b5306e 100644 --- a/src/Command/BakeCommand.php +++ b/src/Command/BakeCommand.php @@ -23,6 +23,7 @@ use Cake\Command\Command; use Cake\Console\Arguments; use Cake\Console\ConsoleIoInterface; +use Cake\Console\ConsoleOptionParser; use Cake\Core\Configure; use Cake\Core\ConventionsTrait; use Cake\Event\Event; @@ -93,6 +94,56 @@ public function setArgs(Arguments $args): void $this->args = $args; } + /** + * Add the per-command overrides for the project routing default. + * + * @param \Cake\Console\ConsoleOptionParser $parser Option parser. + * @return \Cake\Console\ConsoleOptionParser + */ + protected function addAttributeRoutingOptions(ConsoleOptionParser $parser): ConsoleOptionParser + { + return $parser->addOption('attribute-routing', [ + 'boolean' => true, + 'help' => 'Generate routing attributes, overriding Bake.attributeRouting.', + ])->addOption('no-attribute-routing', [ + 'boolean' => true, + 'help' => 'Generate without routing attributes, overriding Bake.attributeRouting.', + ]); + } + + /** + * Reject conflicting overrides before generating any files. + * + * @return bool + */ + protected function validateAttributeRoutingOptions(): bool + { + if ($this->args->getOption('attribute-routing') && $this->args->getOption('no-attribute-routing')) { + $this->io->error('The --attribute-routing and --no-attribute-routing options cannot be used together.'); + + return false; + } + + return true; + } + + /** + * Resolve explicit command options before the project default. + * + * @return bool + */ + protected function useAttributeRouting(): bool + { + if ($this->args->getOption('no-attribute-routing')) { + return false; + } + if ($this->args->getOption('attribute-routing')) { + return true; + } + + return (bool)Configure::read('Bake.attributeRouting', true); + } + /** * Handles splitting up the plugin prefix and classname. * diff --git a/src/Command/ControllerAllCommand.php b/src/Command/ControllerAllCommand.php index ac3b0bdda..4ad808bd1 100644 --- a/src/Command/ControllerAllCommand.php +++ b/src/Command/ControllerAllCommand.php @@ -54,6 +54,10 @@ public static function defaultName(): string */ public function execute(): ?int { + if (!$this->validateAttributeRoutingOptions()) { + return static::CODE_ERROR; + } + $this->extractCommonProperties($this->args); /** @var \Cake\Database\Connection $connection */ $connection = ConnectionManager::get($this->connection); diff --git a/src/Command/ControllerCommand.php b/src/Command/ControllerCommand.php index 544d406ee..5b69696a6 100644 --- a/src/Command/ControllerCommand.php +++ b/src/Command/ControllerCommand.php @@ -22,6 +22,7 @@ use Cake\Core\Configure; use Cake\Core\Plugin; use Cake\Datasource\ConnectionManager; +use Cake\Utility\Inflector; /** * Task class for creating and updating controller files. @@ -48,6 +49,10 @@ public static function getDescription(): string */ public function execute(): ?int { + if (!$this->validateAttributeRoutingOptions()) { + return static::CODE_ERROR; + } + $this->extractCommonProperties($this->args); $name = $this->args->getArgument('name') ?? ''; $name = $this->getNameWithoutPrefix($name); @@ -184,6 +189,8 @@ public function bakeController(string $controllerName, array $data): void 'pluginPath' => null, ]; + $data += $this->getAttributeRoutingData($controllerName, $data); + $contents = $this->createTemplateRenderer() ->set($data) ->generate('Bake.Controller/controller'); @@ -193,6 +200,50 @@ public function bakeController(string $controllerName, array $data): void $this->io->createFile($filename, $contents, $this->force); } + /** + * Prepare routing metadata while leaving the PHP source customizable in templates. + * + * @param string $controllerName Controller name. + * @param array $data Controller template variables. + * @return array + */ + protected function getAttributeRoutingData(string $controllerName, array $data): array + { + $enabled = $data['attributeRouting'] ?? $this->useAttributeRouting(); + $actions = $enabled ? array_values(array_intersect( + $data['actions'] ?? [], + ['index', 'view', 'add', 'edit', 'delete', 'login'], + )) : []; + $imports = []; + $scopePath = '/' . Inflector::dasherize($controllerName); + $prefixName = str_replace('\\', '/', ltrim($data['prefix'] ?? '', '\\')); + $prefixPath = null; + if ($actions) { + $imports = ['Cake\\Routing\\Attribute\\Route', 'Cake\\Routing\\Attribute\\Scope']; + if ($data['plugin']) { + $pluginPath = '/' . Inflector::dasherize($data['plugin']); + if ($prefixName) { + $imports[] = 'Cake\\Routing\\Attribute\\Prefix'; + $prefixPath = $pluginPath . '/' . implode('/', array_map( + Inflector::dasherize(...), + explode('/', $prefixName), + )); + } else { + $scopePath = $pluginPath . $scopePath; + } + } + } + + return [ + 'attributeRouting' => $enabled, + 'routingActions' => $actions, + 'routingImports' => $imports, + 'routingScopePath' => $scopePath, + 'routingPrefixName' => $prefixName, + 'routingPrefixPath' => $prefixPath, + ]; + } + /** * Assembles and writes a unit test file * @@ -280,6 +331,6 @@ protected function buildOptionParser(ConsoleOptionParser $parser): ConsoleOption 'help' => 'Do not generate basic CRUD action methods.', ]); - return $parser; + return $this->addAttributeRoutingOptions($parser); } } diff --git a/src/Command/PluginCommand.php b/src/Command/PluginCommand.php index e12240783..a2726908b 100644 --- a/src/Command/PluginCommand.php +++ b/src/Command/PluginCommand.php @@ -56,6 +56,10 @@ public static function getDescription(): string */ public function execute(): ?int { + if (!$this->validateAttributeRoutingOptions()) { + return static::CODE_ERROR; + } + $name = $this->args->getArgument('name'); if (empty($name)) { $this->io->error('You must provide a plugin name in CamelCase format.'); @@ -204,6 +208,7 @@ protected function generateFiles( $renderer = $this->createTemplateRenderer() ->set([ + 'attributeRouting' => $this->useAttributeRouting(), 'name' => $name, 'package' => $package, 'namespace' => $namespace, @@ -379,7 +384,7 @@ protected function buildOptionParser(ConsoleOptionParser $parser): ConsoleOption 'help' => 'Generate only the plugin class.', ]); - return $parser; + return $this->addAttributeRoutingOptions($parser); } /** diff --git a/templates/bake/Controller/controller.twig b/templates/bake/Controller/controller.twig index 3212114f6..394dc11e8 100644 --- a/templates/bake/Controller/controller.twig +++ b/templates/bake/Controller/controller.twig @@ -19,7 +19,7 @@ #} {{ element('Bake.file_header', { namespace: "#{namespace}\\Controller#{prefix}", - classImports: (plugin or prefix) ? ["#{baseNamespace}\\Controller\\AppController"] : [], + classImports: ((plugin or prefix) ? ["#{baseNamespace}\\Controller\\AppController"] : [])|merge(routingImports), }) }} {% set has_login = 'login' in actions and Bake.hasPlugin('Authentication') %} {% set include_initialize = components or helpers or has_login %} @@ -36,6 +36,12 @@ * @property {{ classInfo.fqn }} ${{ classInfo.name }} {% endfor %} */ +{% if routingActions %} +#[Scope('{{ routingScopePath }}')] +{% if routingPrefixPath %} +#[Prefix('{{ routingPrefixName }}', path: '{{ routingPrefixPath }}')] +{% endif %} +{% endif %} class {{ name }}Controller extends AppController { {% if include_initialize %} diff --git a/templates/bake/Plugin/src/Plugin.php.twig b/templates/bake/Plugin/src/Plugin.php.twig index 885402bd9..58cebd94c 100644 --- a/templates/bake/Plugin/src/Plugin.php.twig +++ b/templates/bake/Plugin/src/Plugin.php.twig @@ -61,8 +61,10 @@ class {{ name }}Plugin extends BasePlugin ['path' => '/{{ routePath }}'], function (RouteBuilder $builder) { // Add custom routes here +{% if not attributeRouting %} $builder->fallbacks(); +{% endif %} } ); parent::routes($routes); diff --git a/templates/bake/element/Controller/add.twig b/templates/bake/element/Controller/add.twig index 4846dfdb7..ad7bdd937 100644 --- a/templates/bake/element/Controller/add.twig +++ b/templates/bake/element/Controller/add.twig @@ -19,6 +19,9 @@ * * @return \Cake\Http\Response|null|void Redirects on successful add, renders view otherwise. */ +{% if attributeRouting %} + #[Route('/add')] +{% endif %} public function add() { ${{ singularName }} = $this->{{ currentModelName }}->newEmptyEntity(); diff --git a/templates/bake/element/Controller/delete.twig b/templates/bake/element/Controller/delete.twig index 70be262f1..6945ecbb6 100644 --- a/templates/bake/element/Controller/delete.twig +++ b/templates/bake/element/Controller/delete.twig @@ -20,6 +20,9 @@ * @return \Cake\Http\Response|null Redirects to index. * @throws \Cake\Datasource\Exception\RecordNotFoundException When record not found. */ +{% if attributeRouting %} + #[Route('/delete/{id}')] +{% endif %} public function delete($id = null) { $this->request->allowMethod(['post', 'delete']); diff --git a/templates/bake/element/Controller/edit.twig b/templates/bake/element/Controller/edit.twig index d502a071c..0790f6706 100644 --- a/templates/bake/element/Controller/edit.twig +++ b/templates/bake/element/Controller/edit.twig @@ -23,6 +23,9 @@ * @return \Cake\Http\Response|null|void Redirects on successful edit, renders view otherwise. * @throws \Cake\Datasource\Exception\RecordNotFoundException When record not found. */ +{% if attributeRouting %} + #[Route('/edit/{id}')] +{% endif %} public function edit($id = null) { ${{ singularName }} = $this->{{ currentModelName }}->get($id, contain: {{ Bake.exportArray(belongsToMany)|raw }}); diff --git a/templates/bake/element/Controller/index.twig b/templates/bake/element/Controller/index.twig index 0394d9663..0f0effbcf 100644 --- a/templates/bake/element/Controller/index.twig +++ b/templates/bake/element/Controller/index.twig @@ -18,6 +18,10 @@ * * @return \Cake\Http\Response|null|void Renders view */ +{% if attributeRouting %} + #[Route('/')] + #[Route('/index')] +{% endif %} public function index() { {% set belongsTo = Bake.aliasExtractor(modelObj, 'BelongsTo') %} diff --git a/templates/bake/element/Controller/login.twig b/templates/bake/element/Controller/login.twig index 84e116246..41ae04c1e 100644 --- a/templates/bake/element/Controller/login.twig +++ b/templates/bake/element/Controller/login.twig @@ -18,6 +18,9 @@ * * @return \Cake\Http\Response|null|void Redirects on successful login, renders view otherwise. */ +{% if attributeRouting %} + #[Route('/login')] +{% endif %} public function login() { {% if Bake.hasPlugin('Authorization') %} diff --git a/templates/bake/element/Controller/view.twig b/templates/bake/element/Controller/view.twig index d85bd17a0..f12fa9e9d 100644 --- a/templates/bake/element/Controller/view.twig +++ b/templates/bake/element/Controller/view.twig @@ -24,6 +24,9 @@ * @return \Cake\Http\Response|null|void Renders view * @throws \Cake\Datasource\Exception\RecordNotFoundException When record not found. */ +{% if attributeRouting %} + #[Route('/view/{id}')] +{% endif %} public function view($id = null) { ${{ singularName }} = $this->{{ currentModelName }}->get($id, contain: {{ Bake.exportArray(allAssociations)|raw }}); diff --git a/tests/TestCase/Command/AllCommandTest.php b/tests/TestCase/Command/AllCommandTest.php index 49caf221f..ebdb1994d 100644 --- a/tests/TestCase/Command/AllCommandTest.php +++ b/tests/TestCase/Command/AllCommandTest.php @@ -19,7 +19,9 @@ use Bake\Test\TestCase\TestCase; use Bake\Utility\SubsetSchemaCollection; use Cake\Console\CommandInterface; +use Cake\Core\Configure; use Cake\Datasource\ConnectionManager; +use PHPUnit\Framework\Attributes\DataProvider; /** * AllCommand Test @@ -69,13 +71,28 @@ protected function tearDown(): void $connection->setSchemaCollection($connection->getSchemaCollection()->getInnerCollection()); } + /** + * @return array + */ + public static function attributeRoutingProvider(): array + { + return [ + 'enabled' => [true, '', true], + 'disabled' => [false, '', false], + 'enable override' => [false, '--attribute-routing', true], + 'disable override' => [true, '--no-attribute-routing', false], + ]; + } + /** * Test execute() generating a full stack * * @return void */ - public function testExecute(): void + #[DataProvider('attributeRoutingProvider')] + public function testExecute(bool $configured, string $option, bool $enabled): void { + Configure::write('Bake.attributeRouting', $configured); $path = APP; $testsPath = ROOT . 'tests' . DS; $templatesPath = ROOT . 'templates' . DS; @@ -92,10 +109,13 @@ public function testExecute(): void $testsPath . 'TestCase/Model/Table/ProductsTableTest.php', $testsPath . 'TestCase/Controller/ProductsControllerTest.php', ]; - $this->exec('bake all --connection test Products', ['y']); + $this->exec('bake all --connection test ' . $option . ' Products', ['y']); $this->assertExitCode(CommandInterface::CODE_SUCCESS); $this->assertFilesExist($this->generatedFiles); + $controller = file_get_contents(APP . 'Controller/ProductsController.php'); + $this->assertSame($enabled, str_contains($controller, '#[Scope(')); + $this->assertOutputContains('Bake All complete'); } diff --git a/tests/TestCase/Command/AttributeRoutingTest.php b/tests/TestCase/Command/AttributeRoutingTest.php new file mode 100644 index 000000000..fb2bf1482 --- /dev/null +++ b/tests/TestCase/Command/AttributeRoutingTest.php @@ -0,0 +1,257 @@ + + */ + protected array $fixtures = [ + 'plugin.Bake.BakeArticles', + 'plugin.Bake.BakeArticlesBakeTags', + 'plugin.Bake.BakeComments', + 'plugin.Bake.BakeTags', + 'plugin.Bake.Users', + ]; + + protected function setUp(): void + { + parent::setUp(); + $this->setAppNamespace('Bake\\Test\\App'); + $this->getTableLocator()->get('BakeArticles', ['className' => BakeArticlesTable::class]); + } + + protected function tearDown(): void + { + AttributeResolver::drop('bake-routing-test'); + $this->getTableLocator()->clear(); + $this->removePlugins(['BakeTest', 'Company/Pastry', 'Authentication']); + parent::tearDown(); + } + + /** + * @return array + */ + public static function conflictingOptionsProvider(): array + { + return [ + 'controller' => ['controller BakeArticles', APP . 'Controller/BakeArticlesController.php'], + 'bulk controllers' => ['controller all', APP . 'Controller/BakeArticlesController.php'], + 'all' => ['all RoutingExamples', APP . 'Model/Table/RoutingExamplesTable.php'], + 'plugin' => ['plugin RoutingExample', TMP . 'plugins/RoutingExample/src/RoutingExamplePlugin.php'], + ]; + } + + #[DataProvider('conflictingOptionsProvider')] + public function testConflictingOptionsDoNotGenerateFiles(string $command, string $path): void + { + $this->exec('bake ' . $command . ' --attribute-routing --no-attribute-routing'); + + $this->assertExitError(); + $this->assertErrorContains('cannot be used together'); + $this->assertFileDoesNotExist($path); + $this->assertOutputNotContains('Creating file'); + } + + public function testGeneratedRoutes(): void + { + $this->loadPlugins([ + 'BakeTest' => ['path' => ROOT . 'Plugin/BakeTest/'], + 'Company/Pastry' => ['path' => ROOT . 'Plugin/Company/Pastry/'], + ]); + $controllers = [ + ['', 'bake-articles', null, null], + ['--prefix admin/management', 'admin/management/bake-articles', null, 'Admin/Management'], + ['--plugin BakeTest', 'bake-test/bake-articles', 'BakeTest', null], + ['--plugin BakeTest --prefix admin', 'bake-test/admin/bake-articles', 'BakeTest', 'Admin'], + ['--plugin Company/Pastry --prefix admin/management', + 'company/pastry/admin/management/bake-articles', 'Company/Pastry', 'Admin/Management'], + ]; + foreach ($controllers as [$options, , $plugin, $prefix]) { + $path = $plugin ? Plugin::path($plugin) . 'src/' : APP; + $path .= 'Controller/' . ($prefix ? $prefix . '/' : '') . 'BakeArticlesController.php'; + $this->generatedFiles[] = $path; + $this->exec('bake controller --connection test --no-test ' . $options . ' BakeArticles'); + $this->assertExitSuccess(); + } + + $collection = $this->connectGeneratedRoutes(); + $actions = [ + ['index', '', []], + ['index', '/index', []], + ['view', '/view/a1b2-uuid', ['a1b2-uuid']], + ['add', '/add', []], + ['edit', '/edit/42', ['42']], + ['delete', '/delete/42', ['42']], + ]; + foreach ($controllers as [, $base, $plugin, $prefix]) { + foreach ($actions as [$action, $suffix, $pass]) { + foreach (['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'] as $method) { + $result = $collection->parseRequest(new ServerRequest([ + 'url' => '/' . $base . $suffix, + 'environment' => ['REQUEST_METHOD' => $method], + ])); + $this->assertSame('BakeArticles', $result['controller']); + $this->assertSame($action, $result['action']); + $this->assertSame($plugin, $result['plugin']); + $this->assertSame($prefix, $result['prefix'] ?? null); + $this->assertSame($pass, $result['pass']); + } + $url = ['controller' => 'BakeArticles', 'action' => $action, + 'plugin' => $plugin, 'prefix' => $prefix] + $pass; + $expected = $action === 'index' ? $base : $base . $suffix; + $this->assertSame($expected, $collection->match($url, [])); + } + } + // Routing accepts every method; the existing controller guard still rejects deletion via GET. + $class = 'Bake\\Test\\App\\Controller\\BakeArticlesController'; + $controller = new $class(new ServerRequest(['environment' => ['REQUEST_METHOD' => 'GET']])); + try { + $controller->delete('42'); + $this->fail('The generated delete action must reject GET requests.'); + } catch (MethodNotAllowedException $exception) { + $this->assertSame(405, $exception->getCode()); + } + + $request = new ServerRequest(['url' => '/bake-articles', 'params' => [ + 'controller' => 'BakeArticles', 'action' => 'index', 'plugin' => null, 'prefix' => null, + ]]); + Router::setRequest($request); + $form = new FormHelper(new View($request)); + foreach (['POST', 'DELETE'] as $method) { + $link = $form->postLink('Delete', [ + 'action' => 'delete', '42', + ], ['method' => strtolower($method)]); + $this->assertStringContainsString('action="/bake-articles/delete/42"', $link); + $this->assertStringContainsString('value="' . $method . '"', $link); + } + $edit = $form->create(null, [ + 'url' => ['action' => 'edit', '42'], + 'type' => 'put', + ]); + $this->assertStringContainsString('action="/bake-articles/edit/42"', $edit); + $this->assertStringContainsString('value="PUT"', $edit); + $form->end(); + + foreach ( + ['GET /bake-articles/view', 'GET /bake-articles/missing'] as $request + ) { + [$method, $url] = explode(' ', $request); + try { + $collection->parseRequest(new ServerRequest([ + 'url' => $url, + 'environment' => ['REQUEST_METHOD' => $method], + ])); + $this->fail('Unexpected route match for ' . $request); + } catch (MissingRouteException) { + $this->addToAssertionCount(1); + } + } + } + + public function testOnlySelectedActionsAreConnected(): void + { + $this->generatedFile = APP . 'Controller/Selected/BakeCommentsController.php'; + $this->exec('bake controller --connection test --no-test --prefix Selected --actions view BakeComments'); + $this->assertExitSuccess(); + + $collection = $this->connectGeneratedRoutes(); + $this->assertCount(1, $collection->routes()); + $result = $collection->parseRequest(new ServerRequest([ + 'url' => '/selected/bake-comments/view/42', + 'environment' => ['REQUEST_METHOD' => 'GET'], + ])); + $this->assertSame('view', $result['action']); + $this->assertSame(['42'], $result['pass']); + } + + public function testAuthenticationLoginRoute(): void + { + $this->loadTestPlugin('Authentication'); + $this->generatedFile = APP . 'Controller/LoginRouting/UsersController.php'; + $this->exec('bake controller --connection test --no-test --prefix LoginRouting Users'); + $this->assertExitSuccess(); + $source = file_get_contents($this->generatedFile); + $this->assertStringContainsString("allowMethod(['get', 'post'])", $source); + $this->assertStringNotContainsString('methods:', $source); + + $collection = $this->connectGeneratedRoutes(); + foreach (['GET', 'HEAD', 'POST'] as $method) { + $result = $collection->parseRequest(new ServerRequest([ + 'url' => '/login-routing/users/login', + 'environment' => ['REQUEST_METHOD' => $method], + ])); + $this->assertSame('login', $result['action']); + $this->assertSame('LoginRouting', $result['prefix']); + } + $class = new ReflectionClass('Bake\\Test\\App\\Controller\\LoginRouting\\UsersController'); + // Check the action guard without initializing the fixture's stub Authentication component. + $controller = $class->newInstanceWithoutConstructor(); + foreach (['HEAD', 'PUT', 'DELETE'] as $method) { + $controller->setRequest(new ServerRequest(['environment' => ['REQUEST_METHOD' => $method]])); + try { + $controller->login(); + $this->fail('The generated login action must reject ' . $method . ' requests.'); + } catch (MethodNotAllowedException $exception) { + $this->assertSame(405, $exception->getCode()); + } + } + } + + protected function connectGeneratedRoutes(): RouteCollection + { + AttributeResolver::setConfig('bake-routing-test', [ + 'paths' => ['Controller/*Controller.php', 'Controller/**/*Controller.php', + 'src/Controller/*Controller.php', 'src/Controller/**/*Controller.php'], + 'basePath' => APP, + 'cache' => false, + ]); + $collection = Router::getRouteCollection(); + $routes = new RouteBuilder($collection, '/'); + // The test application lives inside Bake's plugin directory. Hide that outer + // plugin while scanning so application controllers are not classified as Bake controllers. + $plugins = Plugin::getCollection(); + $bake = $plugins->get('Bake'); + $plugins->remove('Bake'); + try { + $routes->connectAttributes('bake-routing-test'); + } finally { + $plugins->add($bake); + } + + return $collection; + } +} diff --git a/tests/TestCase/Command/ControllerAllCommandTest.php b/tests/TestCase/Command/ControllerAllCommandTest.php index bca852fe7..43eb91dc5 100644 --- a/tests/TestCase/Command/ControllerAllCommandTest.php +++ b/tests/TestCase/Command/ControllerAllCommandTest.php @@ -22,9 +22,11 @@ use Bake\Utility\SubsetSchemaCollection; use Cake\Console\CommandInterface; use Cake\Console\ConsoleOptionParser; +use Cake\Core\Configure; use Cake\Core\Plugin; use Cake\Datasource\ConnectionManager; use Cake\Utility\Inflector; +use PHPUnit\Framework\Attributes\DataProvider; /** * ControllerAllCommand test @@ -80,22 +82,39 @@ protected function tearDown(): void $this->getTableLocator()->clear(); } + /** + * @return array + */ + public static function attributeRoutingProvider(): array + { + return [ + 'enabled' => [true, '', true], + 'disabled' => [false, '', false], + 'enable override' => [false, '--attribute-routing', true], + 'disable override' => [true, '--no-attribute-routing', false], + ]; + } + /** * test that execute runs all when the first arg == all * * @return void */ - public function testExecute(): void + #[DataProvider('attributeRoutingProvider')] + public function testExecute(bool $configured, string $option, bool $enabled): void { + Configure::write('Bake.attributeRouting', $configured); foreach ($this->tables as $table) { $plural = Inflector::camelize($table); $this->generatedFiles[] = APP . "Controller/{$plural}Controller.php"; } - $this->exec('bake controller all --connection test --no-test --quiet'); + $this->exec('bake controller all --connection test --no-test ' . $option); $this->assertExitCode(CommandInterface::CODE_SUCCESS); $this->assertFilesExist($this->generatedFiles); + $controller = file_get_contents(APP . 'Controller/BakeArticlesController.php'); + $this->assertSame($enabled, str_contains($controller, '#[Scope(')); $this->assertFileDoesNotExist( ROOT . 'tests/TestCase/Controller/BakeArticlesControllerTest.php', diff --git a/tests/TestCase/Command/ControllerCommandTest.php b/tests/TestCase/Command/ControllerCommandTest.php index e3e65f7bc..7eac2af5c 100644 --- a/tests/TestCase/Command/ControllerCommandTest.php +++ b/tests/TestCase/Command/ControllerCommandTest.php @@ -21,6 +21,7 @@ use Bake\Test\TestCase\TestCase; use Cake\Console\Arguments; use Cake\Console\CommandInterface; +use Cake\Core\Configure; use Cake\Core\Plugin; use PHPUnit\Framework\Attributes\DataProvider; @@ -319,6 +320,63 @@ public function testBakeActionsContent(): void $this->assertSameAsFile(__FUNCTION__ . '.php', $result); } + /** + * @return array + */ + public static function attributeRoutingProvider(): array + { + return [ + 'default' => [null, '', true], + 'project enabled' => [true, '', true], + 'project disabled' => [false, '', false], + 'enable override' => [false, '--attribute-routing', true], + 'disable override' => [true, '--no-attribute-routing', false], + ]; + } + + #[DataProvider('attributeRoutingProvider')] + public function testAttributeRoutingOptions(?bool $configured, string $option, bool $enabled): void + { + Configure::delete('Bake.attributeRouting'); + if ($configured !== null) { + Configure::write('Bake.attributeRouting', $configured); + } + $this->generatedFile = APP . 'Controller/BakeArticlesController.php'; + $this->exec('bake controller --connection test --no-test ' . $option . ' BakeArticles'); + + $this->assertExitSuccess(); + $result = file_get_contents($this->generatedFile); + if ($enabled) { + $this->assertStringContainsString("#[Scope('/bake-articles')]", $result); + } else { + $this->assertSameAsFile('testBakeWithoutAttributeRouting.php', $result); + } + } + + public function testBakeAuthenticationWithoutAttributeRouting(): void + { + $this->loadTestPlugin('Authentication'); + $this->generatedFile = APP . 'Controller/UsersController.php'; + $original = file_get_contents($this->generatedFile); + try { + $this->exec('bake controller --connection test --no-test --force --no-attribute-routing Users'); + $this->assertExitSuccess(); + $this->assertSameAsFile(__FUNCTION__ . '.php', file_get_contents($this->generatedFile)); + } finally { + file_put_contents($this->generatedFile, $original); + $this->generatedFile = ''; + } + } + + public function testNoActionsOmitsRoutingAttributes(): void + { + $this->generatedFile = APP . 'Controller/BakeArticlesController.php'; + $this->exec('bake controller --connection test --no-test --no-actions BakeArticles'); + + $this->assertExitSuccess(); + $this->assertStringNotContainsString('Cake\\Routing\\Attribute', file_get_contents($this->generatedFile)); + } + /** * test baking a test * diff --git a/tests/TestCase/Command/PluginCommandTest.php b/tests/TestCase/Command/PluginCommandTest.php index 86b3c564a..c6b36bc31 100644 --- a/tests/TestCase/Command/PluginCommandTest.php +++ b/tests/TestCase/Command/PluginCommandTest.php @@ -25,6 +25,7 @@ use Cake\Core\Configure; use Cake\Core\Plugin; use Cake\Utility\Filesystem; +use PHPUnit\Framework\Attributes\DataProvider; use SplFileInfo; /** @@ -244,6 +245,30 @@ public function testMainClassOnlyOption(): void $this->assertFileDoesNotExist($bakedRoot . 'ClassOnly/webroot'); } + /** + * @return array + */ + public static function attributeRoutingProvider(): array + { + return [ + 'enabled' => [true, '', true], + 'disabled' => [false, '', false], + 'enable override' => [false, '--attribute-routing', true], + 'disable override' => [true, '--no-attribute-routing', false], + ]; + } + + #[DataProvider('attributeRoutingProvider')] + public function testAttributeRoutingOptions(bool $configured, string $option, bool $enabled): void + { + Configure::write('Bake.attributeRouting', $configured); + $this->exec(rtrim('bake plugin SimpleExample ' . $option), ['y', 'n']); + + $this->assertExitSuccess(); + $result = file_get_contents($this->pluginsPath . 'SimpleExample/src/SimpleExamplePlugin.php'); + $this->assertSame(!$enabled, str_contains($result, '$builder->fallbacks();')); + } + /** * Check the baked plugin matches the expected output * diff --git a/tests/comparisons/Controller/testBakeActions.php b/tests/comparisons/Controller/testBakeActions.php index ff9249910..103e60a8b 100644 --- a/tests/comparisons/Controller/testBakeActions.php +++ b/tests/comparisons/Controller/testBakeActions.php @@ -3,6 +3,9 @@ namespace Bake\Test\App\Controller; +use Cake\Routing\Attribute\Route; +use Cake\Routing\Attribute\Scope; + /** * BakeArticles Controller * @@ -10,6 +13,7 @@ * @property \Cake\Controller\Component\FormProtectionComponent $FormProtection * @property \Cake\Controller\Component\FlashComponent $Flash */ +#[Scope('/bake-articles')] class BakeArticlesController extends AppController { /** @@ -31,6 +35,8 @@ public function initialize(): void * * @return \Cake\Http\Response|null|void Renders view */ + #[Route('/')] + #[Route('/index')] public function index() { $query = $this->BakeArticles->find() @@ -47,6 +53,7 @@ public function index() * @return \Cake\Http\Response|null|void Renders view * @throws \Cake\Datasource\Exception\RecordNotFoundException When record not found. */ + #[Route('/view/{id}')] public function view($id = null) { $bakeArticle = $this->BakeArticles->get($id, contain: ['BakeUsers', 'BakeTags', 'BakeComments']); @@ -58,6 +65,7 @@ public function view($id = null) * * @return \Cake\Http\Response|null|void Redirects on successful add, renders view otherwise. */ + #[Route('/add')] public function add() { $bakeArticle = $this->BakeArticles->newEmptyEntity(); @@ -82,6 +90,7 @@ public function add() * @return \Cake\Http\Response|null|void Redirects on successful edit, renders view otherwise. * @throws \Cake\Datasource\Exception\RecordNotFoundException When record not found. */ + #[Route('/edit/{id}')] public function edit($id = null) { $bakeArticle = $this->BakeArticles->get($id, contain: ['BakeTags']); @@ -106,6 +115,7 @@ public function edit($id = null) * @return \Cake\Http\Response|null Redirects to index. * @throws \Cake\Datasource\Exception\RecordNotFoundException When record not found. */ + #[Route('/delete/{id}')] public function delete($id = null) { $this->request->allowMethod(['post', 'delete']); diff --git a/tests/comparisons/Controller/testBakeActionsAuthenticationPlugin.php b/tests/comparisons/Controller/testBakeActionsAuthenticationPlugin.php index d163ca7de..98b9d2997 100644 --- a/tests/comparisons/Controller/testBakeActionsAuthenticationPlugin.php +++ b/tests/comparisons/Controller/testBakeActionsAuthenticationPlugin.php @@ -3,10 +3,14 @@ namespace Bake\Test\App\Controller; +use Cake\Routing\Attribute\Route; +use Cake\Routing\Attribute\Scope; + /** * Users Controller * */ +#[Scope('/users')] class UsersController extends AppController { /** @@ -26,6 +30,8 @@ public function initialize(): void * * @return \Cake\Http\Response|null|void Renders view */ + #[Route('/')] + #[Route('/index')] public function index() { $query = $this->Users->find(); @@ -41,6 +47,7 @@ public function index() * @return \Cake\Http\Response|null|void Renders view * @throws \Cake\Datasource\Exception\RecordNotFoundException When record not found. */ + #[Route('/view/{id}')] public function view($id = null) { $user = $this->Users->get($id, contain: []); @@ -52,6 +59,7 @@ public function view($id = null) * * @return \Cake\Http\Response|null|void Redirects on successful add, renders view otherwise. */ + #[Route('/add')] public function add() { $user = $this->Users->newEmptyEntity(); @@ -74,6 +82,7 @@ public function add() * @return \Cake\Http\Response|null|void Redirects on successful edit, renders view otherwise. * @throws \Cake\Datasource\Exception\RecordNotFoundException When record not found. */ + #[Route('/edit/{id}')] public function edit($id = null) { $user = $this->Users->get($id, contain: []); @@ -96,6 +105,7 @@ public function edit($id = null) * @return \Cake\Http\Response|null Redirects to index. * @throws \Cake\Datasource\Exception\RecordNotFoundException When record not found. */ + #[Route('/delete/{id}')] public function delete($id = null) { $this->request->allowMethod(['post', 'delete']); @@ -114,6 +124,7 @@ public function delete($id = null) * * @return \Cake\Http\Response|null|void Redirects on successful login, renders view otherwise. */ + #[Route('/login')] public function login() { $this->request->allowMethod(['get', 'post']); diff --git a/tests/comparisons/Controller/testBakeActionsAuthorizationPlugin.php b/tests/comparisons/Controller/testBakeActionsAuthorizationPlugin.php index b37de7f29..9c6f52771 100644 --- a/tests/comparisons/Controller/testBakeActionsAuthorizationPlugin.php +++ b/tests/comparisons/Controller/testBakeActionsAuthorizationPlugin.php @@ -3,12 +3,16 @@ namespace Bake\Test\App\Controller; +use Cake\Routing\Attribute\Route; +use Cake\Routing\Attribute\Scope; + /** * BakeArticles Controller * * @property \Bake\Test\App\Model\Table\BakeArticlesTable $BakeArticles * @property \Authorization\Controller\Component\AuthorizationComponent $Authorization */ +#[Scope('/bake-articles')] class BakeArticlesController extends AppController { /** @@ -28,6 +32,8 @@ public function initialize(): void * * @return \Cake\Http\Response|null|void Renders view */ + #[Route('/')] + #[Route('/index')] public function index() { $query = $this->BakeArticles->find() @@ -45,6 +51,7 @@ public function index() * @return \Cake\Http\Response|null|void Renders view * @throws \Cake\Datasource\Exception\RecordNotFoundException When record not found. */ + #[Route('/view/{id}')] public function view($id = null) { $bakeArticle = $this->BakeArticles->get($id, contain: ['BakeUsers', 'BakeTags', 'BakeComments']); @@ -57,6 +64,7 @@ public function view($id = null) * * @return \Cake\Http\Response|null|void Redirects on successful add, renders view otherwise. */ + #[Route('/add')] public function add() { $bakeArticle = $this->BakeArticles->newEmptyEntity(); @@ -82,6 +90,7 @@ public function add() * @return \Cake\Http\Response|null|void Redirects on successful edit, renders view otherwise. * @throws \Cake\Datasource\Exception\RecordNotFoundException When record not found. */ + #[Route('/edit/{id}')] public function edit($id = null) { $bakeArticle = $this->BakeArticles->get($id, contain: ['BakeTags']); @@ -107,6 +116,7 @@ public function edit($id = null) * @return \Cake\Http\Response|null Redirects to index. * @throws \Cake\Datasource\Exception\RecordNotFoundException When record not found. */ + #[Route('/delete/{id}')] public function delete($id = null) { $this->request->allowMethod(['post', 'delete']); diff --git a/tests/comparisons/Controller/testBakeActionsContent.php b/tests/comparisons/Controller/testBakeActionsContent.php index 8f3df0942..7d0101c6e 100644 --- a/tests/comparisons/Controller/testBakeActionsContent.php +++ b/tests/comparisons/Controller/testBakeActionsContent.php @@ -3,11 +3,15 @@ namespace Bake\Test\App\Controller; +use Cake\Routing\Attribute\Route; +use Cake\Routing\Attribute\Scope; + /** * BakeArticles Controller * * @property \Bake\Test\App\Model\Table\BakeArticlesTable $BakeArticles */ +#[Scope('/bake-articles')] class BakeArticlesController extends AppController { /** @@ -15,6 +19,8 @@ class BakeArticlesController extends AppController * * @return \Cake\Http\Response|null|void Renders view */ + #[Route('/')] + #[Route('/index')] public function index() { $query = $this->BakeArticles->find() @@ -31,6 +37,7 @@ public function index() * @return \Cake\Http\Response|null|void Renders view * @throws \Cake\Datasource\Exception\RecordNotFoundException When record not found. */ + #[Route('/view/{id}')] public function view($id = null) { $bakeArticle = $this->BakeArticles->get($id, contain: ['BakeUsers', 'BakeTags', 'BakeComments']); @@ -42,6 +49,7 @@ public function view($id = null) * * @return \Cake\Http\Response|null|void Redirects on successful add, renders view otherwise. */ + #[Route('/add')] public function add() { $bakeArticle = $this->BakeArticles->newEmptyEntity(); @@ -66,6 +74,7 @@ public function add() * @return \Cake\Http\Response|null|void Redirects on successful edit, renders view otherwise. * @throws \Cake\Datasource\Exception\RecordNotFoundException When record not found. */ + #[Route('/edit/{id}')] public function edit($id = null) { $bakeArticle = $this->BakeArticles->get($id, contain: ['BakeTags']); @@ -90,6 +99,7 @@ public function edit($id = null) * @return \Cake\Http\Response|null Redirects to index. * @throws \Cake\Datasource\Exception\RecordNotFoundException When record not found. */ + #[Route('/delete/{id}')] public function delete($id = null) { $this->request->allowMethod(['post', 'delete']); diff --git a/tests/comparisons/Controller/testBakeActionsOption.php b/tests/comparisons/Controller/testBakeActionsOption.php index d3ec91039..b647f51ad 100644 --- a/tests/comparisons/Controller/testBakeActionsOption.php +++ b/tests/comparisons/Controller/testBakeActionsOption.php @@ -3,6 +3,9 @@ namespace Bake\Test\App\Controller; +use Cake\Routing\Attribute\Route; +use Cake\Routing\Attribute\Scope; + /** * BakeArticles Controller * @@ -10,6 +13,7 @@ * @property \Cake\Controller\Component\FormProtectionComponent $FormProtection * @property \Cake\Controller\Component\FlashComponent $Flash */ +#[Scope('/bake-articles')] class BakeArticlesController extends AppController { /** @@ -31,6 +35,8 @@ public function initialize(): void * * @return \Cake\Http\Response|null|void Renders view */ + #[Route('/')] + #[Route('/index')] public function index() { $query = $this->BakeArticles->find() diff --git a/tests/comparisons/Controller/testBakeAuthenticationWithoutAttributeRouting.php b/tests/comparisons/Controller/testBakeAuthenticationWithoutAttributeRouting.php new file mode 100644 index 000000000..d163ca7de --- /dev/null +++ b/tests/comparisons/Controller/testBakeAuthenticationWithoutAttributeRouting.php @@ -0,0 +1,134 @@ +Authentication->allowUnauthenticated(['login']); + } + + /** + * Index method + * + * @return \Cake\Http\Response|null|void Renders view + */ + public function index() + { + $query = $this->Users->find(); + $users = $this->paginate($query); + + $this->set(compact('users')); + } + + /** + * View method + * + * @param string|null $id User id. + * @return \Cake\Http\Response|null|void Renders view + * @throws \Cake\Datasource\Exception\RecordNotFoundException When record not found. + */ + public function view($id = null) + { + $user = $this->Users->get($id, contain: []); + $this->set(compact('user')); + } + + /** + * Add method + * + * @return \Cake\Http\Response|null|void Redirects on successful add, renders view otherwise. + */ + public function add() + { + $user = $this->Users->newEmptyEntity(); + if ($this->request->is('post')) { + $user = $this->Users->patchEntity($user, $this->request->getData()); + if ($this->Users->save($user)) { + $this->Flash->success(__('The user has been saved.')); + + return $this->redirect(['action' => 'index']); + } + $this->Flash->error(__('The user could not be saved. Please, try again.')); + } + $this->set(compact('user')); + } + + /** + * Edit method + * + * @param string|null $id User id. + * @return \Cake\Http\Response|null|void Redirects on successful edit, renders view otherwise. + * @throws \Cake\Datasource\Exception\RecordNotFoundException When record not found. + */ + public function edit($id = null) + { + $user = $this->Users->get($id, contain: []); + if ($this->request->is(['patch', 'post', 'put'])) { + $user = $this->Users->patchEntity($user, $this->request->getData()); + if ($this->Users->save($user)) { + $this->Flash->success(__('The user has been saved.')); + + return $this->redirect(['action' => 'index']); + } + $this->Flash->error(__('The user could not be saved. Please, try again.')); + } + $this->set(compact('user')); + } + + /** + * Delete method + * + * @param string|null $id User id. + * @return \Cake\Http\Response|null Redirects to index. + * @throws \Cake\Datasource\Exception\RecordNotFoundException When record not found. + */ + public function delete($id = null) + { + $this->request->allowMethod(['post', 'delete']); + $user = $this->Users->get($id); + if ($this->Users->delete($user)) { + $this->Flash->success(__('The user has been deleted.')); + } else { + $this->Flash->error(__('The user could not be deleted. Please, try again.')); + } + + return $this->redirect(['action' => 'index']); + } + + /** + * Login method + * + * @return \Cake\Http\Response|null|void Redirects on successful login, renders view otherwise. + */ + public function login() + { + $this->request->allowMethod(['get', 'post']); + $result = $this->Authentication->getResult(); + if ($result->isValid()) { + $this->Flash->success(__('Login successful')); + $redirect = $this->Authentication->getLoginRedirect(); + if ($redirect) { + return $this->redirect($redirect); + } + } + + // Display error if user submitted and authentication failed + if ($this->request->is('post')) { + $this->Flash->error(__('Invalid username or password')); + } + } +} diff --git a/tests/comparisons/Controller/testBakeWithPlugin.php b/tests/comparisons/Controller/testBakeWithPlugin.php index a3f091547..7f5d13063 100644 --- a/tests/comparisons/Controller/testBakeWithPlugin.php +++ b/tests/comparisons/Controller/testBakeWithPlugin.php @@ -4,12 +4,15 @@ namespace BakeTest\Controller; use Bake\Test\App\Controller\AppController; +use Cake\Routing\Attribute\Route; +use Cake\Routing\Attribute\Scope; /** * BakeArticles Controller * * @property \BakeTest\Model\Table\BakeArticlesTable $BakeArticles */ +#[Scope('/bake-test/bake-articles')] class BakeArticlesController extends AppController { /** @@ -17,6 +20,8 @@ class BakeArticlesController extends AppController * * @return \Cake\Http\Response|null|void Renders view */ + #[Route('/')] + #[Route('/index')] public function index() { $query = $this->BakeArticles->find() @@ -33,6 +38,7 @@ public function index() * @return \Cake\Http\Response|null|void Renders view * @throws \Cake\Datasource\Exception\RecordNotFoundException When record not found. */ + #[Route('/view/{id}')] public function view($id = null) { $bakeArticle = $this->BakeArticles->get($id, contain: ['BakeUsers', 'BakeTags', 'BakeComments']); @@ -44,6 +50,7 @@ public function view($id = null) * * @return \Cake\Http\Response|null|void Redirects on successful add, renders view otherwise. */ + #[Route('/add')] public function add() { $bakeArticle = $this->BakeArticles->newEmptyEntity(); @@ -68,6 +75,7 @@ public function add() * @return \Cake\Http\Response|null|void Redirects on successful edit, renders view otherwise. * @throws \Cake\Datasource\Exception\RecordNotFoundException When record not found. */ + #[Route('/edit/{id}')] public function edit($id = null) { $bakeArticle = $this->BakeArticles->get($id, contain: ['BakeTags']); @@ -92,6 +100,7 @@ public function edit($id = null) * @return \Cake\Http\Response|null Redirects to index. * @throws \Cake\Datasource\Exception\RecordNotFoundException When record not found. */ + #[Route('/delete/{id}')] public function delete($id = null) { $this->request->allowMethod(['post', 'delete']); diff --git a/tests/comparisons/Controller/testBakeWithoutAttributeRouting.php b/tests/comparisons/Controller/testBakeWithoutAttributeRouting.php new file mode 100644 index 000000000..8f3df0942 --- /dev/null +++ b/tests/comparisons/Controller/testBakeWithoutAttributeRouting.php @@ -0,0 +1,105 @@ +BakeArticles->find() + ->contain(['BakeUsers']); + $bakeArticles = $this->paginate($query); + + $this->set(compact('bakeArticles')); + } + + /** + * View method + * + * @param string|null $id Bake Article id. + * @return \Cake\Http\Response|null|void Renders view + * @throws \Cake\Datasource\Exception\RecordNotFoundException When record not found. + */ + public function view($id = null) + { + $bakeArticle = $this->BakeArticles->get($id, contain: ['BakeUsers', 'BakeTags', 'BakeComments']); + $this->set(compact('bakeArticle')); + } + + /** + * Add method + * + * @return \Cake\Http\Response|null|void Redirects on successful add, renders view otherwise. + */ + public function add() + { + $bakeArticle = $this->BakeArticles->newEmptyEntity(); + if ($this->request->is('post')) { + $bakeArticle = $this->BakeArticles->patchEntity($bakeArticle, $this->request->getData()); + if ($this->BakeArticles->save($bakeArticle)) { + $this->Flash->success(__('The bake article has been saved.')); + + return $this->redirect(['action' => 'index']); + } + $this->Flash->error(__('The bake article could not be saved. Please, try again.')); + } + $bakeUsers = $this->BakeArticles->BakeUsers->find('list', limit: 200)->all(); + $bakeTags = $this->BakeArticles->BakeTags->find('list', limit: 200)->all(); + $this->set(compact('bakeArticle', 'bakeUsers', 'bakeTags')); + } + + /** + * Edit method + * + * @param string|null $id Bake Article id. + * @return \Cake\Http\Response|null|void Redirects on successful edit, renders view otherwise. + * @throws \Cake\Datasource\Exception\RecordNotFoundException When record not found. + */ + public function edit($id = null) + { + $bakeArticle = $this->BakeArticles->get($id, contain: ['BakeTags']); + if ($this->request->is(['patch', 'post', 'put'])) { + $bakeArticle = $this->BakeArticles->patchEntity($bakeArticle, $this->request->getData()); + if ($this->BakeArticles->save($bakeArticle)) { + $this->Flash->success(__('The bake article has been saved.')); + + return $this->redirect(['action' => 'index']); + } + $this->Flash->error(__('The bake article could not be saved. Please, try again.')); + } + $bakeUsers = $this->BakeArticles->BakeUsers->find('list', limit: 200)->all(); + $bakeTags = $this->BakeArticles->BakeTags->find('list', limit: 200)->all(); + $this->set(compact('bakeArticle', 'bakeUsers', 'bakeTags')); + } + + /** + * Delete method + * + * @param string|null $id Bake Article id. + * @return \Cake\Http\Response|null Redirects to index. + * @throws \Cake\Datasource\Exception\RecordNotFoundException When record not found. + */ + public function delete($id = null) + { + $this->request->allowMethod(['post', 'delete']); + $bakeArticle = $this->BakeArticles->get($id); + if ($this->BakeArticles->delete($bakeArticle)) { + $this->Flash->success(__('The bake article has been deleted.')); + } else { + $this->Flash->error(__('The bake article could not be deleted. Please, try again.')); + } + + return $this->redirect(['action' => 'index']); + } +} diff --git a/tests/comparisons/Plugin/Company/Example/src/ExamplePlugin.php b/tests/comparisons/Plugin/Company/Example/src/ExamplePlugin.php index 10a8315f3..3fb4ba43a 100644 --- a/tests/comparisons/Plugin/Company/Example/src/ExamplePlugin.php +++ b/tests/comparisons/Plugin/Company/Example/src/ExamplePlugin.php @@ -46,8 +46,6 @@ public function routes(RouteBuilder $routes): void ['path' => '/company/example'], function (RouteBuilder $builder) { // Add custom routes here - - $builder->fallbacks(); } ); parent::routes($routes); diff --git a/tests/comparisons/Plugin/SimpleExample/src/SimpleExamplePlugin.php b/tests/comparisons/Plugin/SimpleExample/src/SimpleExamplePlugin.php index 473850619..ab8b4e76d 100644 --- a/tests/comparisons/Plugin/SimpleExample/src/SimpleExamplePlugin.php +++ b/tests/comparisons/Plugin/SimpleExample/src/SimpleExamplePlugin.php @@ -46,8 +46,6 @@ public function routes(RouteBuilder $routes): void ['path' => '/simple-example'], function (RouteBuilder $builder) { // Add custom routes here - - $builder->fallbacks(); } ); parent::routes($routes); From ba256741765bab78f8dfa66b2704b82c84c24988 Mon Sep 17 00:00:00 2001 From: Jasper Smet Date: Sun, 18 Oct 2026 09:43:57 +0200 Subject: [PATCH 2/3] Use an explicit empty-array comparison for route actions --- src/Command/ControllerCommand.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Command/ControllerCommand.php b/src/Command/ControllerCommand.php index 5b69696a6..6b0fe6041 100644 --- a/src/Command/ControllerCommand.php +++ b/src/Command/ControllerCommand.php @@ -218,7 +218,7 @@ protected function getAttributeRoutingData(string $controllerName, array $data): $scopePath = '/' . Inflector::dasherize($controllerName); $prefixName = str_replace('\\', '/', ltrim($data['prefix'] ?? '', '\\')); $prefixPath = null; - if ($actions) { + if ($actions !== []) { $imports = ['Cake\\Routing\\Attribute\\Route', 'Cake\\Routing\\Attribute\\Scope']; if ($data['plugin']) { $pluginPath = '/' . Inflector::dasherize($data['plugin']); From c6ed738b729e8cc0e38dd41e052e9f1ae90c5aa4 Mon Sep 17 00:00:00 2001 From: Jasper Smet Date: Sun, 18 Oct 2026 18:25:25 +0200 Subject: [PATCH 3/3] Simplify routing documentation and generation tests --- docs/en/development.md | 3 +- docs/en/usage.md | 20 +- .../TestCase/Command/AttributeRoutingTest.php | 203 +----------------- .../Command/ControllerCommandTest.php | 28 +++ 4 files changed, 36 insertions(+), 218 deletions(-) diff --git a/docs/en/development.md b/docs/en/development.md index d6debd0b0..0d57eb7e2 100644 --- a/docs/en/development.md +++ b/docs/en/development.md @@ -100,8 +100,7 @@ metadata: `routingActions`, `routingImports`, `routingScopePath`, `routingPrefixName`, and `routingPrefixPath`. The default controller template uses this metadata for its imports and class attributes. Built-in action elements render their own `Route` attributes without HTTP method restrictions. Themes can -override those elements to customize paths or add method restrictions; the -default templates preserve method checks inside the controller actions. +override those elements to customize paths or add method restrictions. Custom action elements must define their own routes when attribute routing is enabled. Use fully qualified attribute names or add their imports in the theme's diff --git a/docs/en/usage.md b/docs/en/usage.md index c314ac67d..eeed8e940 100644 --- a/docs/en/usage.md +++ b/docs/en/usage.md @@ -51,7 +51,7 @@ For CakePHP 6, Bake generates routing attributes on controllers by default. This applies to `bake controller`, `bake controller all`, and `bake all`. Generated plugin skeletons also omit fallback routes by default. -### Generated URLs and Method Checks +### Generated URLs For `ArticlesController`, Bake generates these routes: @@ -65,11 +65,7 @@ For `ArticlesController`, Bake generates these routes: | login, when generated | `/users/login` for `UsersController` | Routes use `#[Route]` attributes without HTTP method restrictions; index has an -additional attribute for its `/index` alias. Method handling remains in the -controller actions, preserving the previous behavior. The generated delete action -uses `allowMethod(['post', 'delete'])`, and the login action uses -`allowMethod(['get', 'post'])`. Unsupported methods for those actions receive the -existing 405 response. Other actions retain their existing request handling. +additional attribute for its `/index` alias. Existing array-based links, form actions, and redirects work without fallbacks or additional `_method` routing hints, with `/articles` as the index URL: @@ -79,14 +75,10 @@ $this->Form->postLink('Delete', ['action' => 'delete', $article->id]); ``` Identifiers are not restricted to integers, so UUIDs and other string keys work too. -Only actions selected for generation receive routes. Empty controllers receive no -routing attributes. - -Prefixes retain their namespace paths: `--prefix Admin/Management` produces -`/admin/management/articles`. Plugin routes include the conventional plugin path: -`Blog.Articles` produces `/blog/articles`, and adding `--prefix Admin` produces -`/blog/admin/articles`. Customize the generated `Scope` or `Prefix` attributes if -your plugin uses a different mount path. + +When generating prefixed controllers with `--prefix Admin/Management`, routing +attributes will include both the plugin and prefix path components. You can customize +the `Scope` or `Prefix` attributes as required. ### Project Default and Command Overrides diff --git a/tests/TestCase/Command/AttributeRoutingTest.php b/tests/TestCase/Command/AttributeRoutingTest.php index fb2bf1482..d6152f322 100644 --- a/tests/TestCase/Command/AttributeRoutingTest.php +++ b/tests/TestCase/Command/AttributeRoutingTest.php @@ -16,50 +16,18 @@ */ namespace Bake\Test\TestCase\Command; -use Bake\Test\App\Model\Table\BakeArticlesTable; use Bake\Test\TestCase\TestCase; -use Cake\AttributeResolver\AttributeResolver; -use Cake\Core\Plugin; -use Cake\Http\Exception\MethodNotAllowedException; -use Cake\Http\ServerRequest; -use Cake\Routing\Exception\MissingRouteException; -use Cake\Routing\RouteBuilder; -use Cake\Routing\RouteCollection; -use Cake\Routing\Router; -use Cake\View\Helper\FormHelper; -use Cake\View\View; use PHPUnit\Framework\Attributes\DataProvider; -use ReflectionClass; /** - * Exercise routes discovered from generated controller source without fallback routes. + * Check routing option validation before Bake generates files. */ class AttributeRoutingTest extends TestCase { - /** - * @var array - */ - protected array $fixtures = [ - 'plugin.Bake.BakeArticles', - 'plugin.Bake.BakeArticlesBakeTags', - 'plugin.Bake.BakeComments', - 'plugin.Bake.BakeTags', - 'plugin.Bake.Users', - ]; - protected function setUp(): void { parent::setUp(); $this->setAppNamespace('Bake\\Test\\App'); - $this->getTableLocator()->get('BakeArticles', ['className' => BakeArticlesTable::class]); - } - - protected function tearDown(): void - { - AttributeResolver::drop('bake-routing-test'); - $this->getTableLocator()->clear(); - $this->removePlugins(['BakeTest', 'Company/Pastry', 'Authentication']); - parent::tearDown(); } /** @@ -85,173 +53,4 @@ public function testConflictingOptionsDoNotGenerateFiles(string $command, string $this->assertFileDoesNotExist($path); $this->assertOutputNotContains('Creating file'); } - - public function testGeneratedRoutes(): void - { - $this->loadPlugins([ - 'BakeTest' => ['path' => ROOT . 'Plugin/BakeTest/'], - 'Company/Pastry' => ['path' => ROOT . 'Plugin/Company/Pastry/'], - ]); - $controllers = [ - ['', 'bake-articles', null, null], - ['--prefix admin/management', 'admin/management/bake-articles', null, 'Admin/Management'], - ['--plugin BakeTest', 'bake-test/bake-articles', 'BakeTest', null], - ['--plugin BakeTest --prefix admin', 'bake-test/admin/bake-articles', 'BakeTest', 'Admin'], - ['--plugin Company/Pastry --prefix admin/management', - 'company/pastry/admin/management/bake-articles', 'Company/Pastry', 'Admin/Management'], - ]; - foreach ($controllers as [$options, , $plugin, $prefix]) { - $path = $plugin ? Plugin::path($plugin) . 'src/' : APP; - $path .= 'Controller/' . ($prefix ? $prefix . '/' : '') . 'BakeArticlesController.php'; - $this->generatedFiles[] = $path; - $this->exec('bake controller --connection test --no-test ' . $options . ' BakeArticles'); - $this->assertExitSuccess(); - } - - $collection = $this->connectGeneratedRoutes(); - $actions = [ - ['index', '', []], - ['index', '/index', []], - ['view', '/view/a1b2-uuid', ['a1b2-uuid']], - ['add', '/add', []], - ['edit', '/edit/42', ['42']], - ['delete', '/delete/42', ['42']], - ]; - foreach ($controllers as [, $base, $plugin, $prefix]) { - foreach ($actions as [$action, $suffix, $pass]) { - foreach (['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'] as $method) { - $result = $collection->parseRequest(new ServerRequest([ - 'url' => '/' . $base . $suffix, - 'environment' => ['REQUEST_METHOD' => $method], - ])); - $this->assertSame('BakeArticles', $result['controller']); - $this->assertSame($action, $result['action']); - $this->assertSame($plugin, $result['plugin']); - $this->assertSame($prefix, $result['prefix'] ?? null); - $this->assertSame($pass, $result['pass']); - } - $url = ['controller' => 'BakeArticles', 'action' => $action, - 'plugin' => $plugin, 'prefix' => $prefix] + $pass; - $expected = $action === 'index' ? $base : $base . $suffix; - $this->assertSame($expected, $collection->match($url, [])); - } - } - // Routing accepts every method; the existing controller guard still rejects deletion via GET. - $class = 'Bake\\Test\\App\\Controller\\BakeArticlesController'; - $controller = new $class(new ServerRequest(['environment' => ['REQUEST_METHOD' => 'GET']])); - try { - $controller->delete('42'); - $this->fail('The generated delete action must reject GET requests.'); - } catch (MethodNotAllowedException $exception) { - $this->assertSame(405, $exception->getCode()); - } - - $request = new ServerRequest(['url' => '/bake-articles', 'params' => [ - 'controller' => 'BakeArticles', 'action' => 'index', 'plugin' => null, 'prefix' => null, - ]]); - Router::setRequest($request); - $form = new FormHelper(new View($request)); - foreach (['POST', 'DELETE'] as $method) { - $link = $form->postLink('Delete', [ - 'action' => 'delete', '42', - ], ['method' => strtolower($method)]); - $this->assertStringContainsString('action="/bake-articles/delete/42"', $link); - $this->assertStringContainsString('value="' . $method . '"', $link); - } - $edit = $form->create(null, [ - 'url' => ['action' => 'edit', '42'], - 'type' => 'put', - ]); - $this->assertStringContainsString('action="/bake-articles/edit/42"', $edit); - $this->assertStringContainsString('value="PUT"', $edit); - $form->end(); - - foreach ( - ['GET /bake-articles/view', 'GET /bake-articles/missing'] as $request - ) { - [$method, $url] = explode(' ', $request); - try { - $collection->parseRequest(new ServerRequest([ - 'url' => $url, - 'environment' => ['REQUEST_METHOD' => $method], - ])); - $this->fail('Unexpected route match for ' . $request); - } catch (MissingRouteException) { - $this->addToAssertionCount(1); - } - } - } - - public function testOnlySelectedActionsAreConnected(): void - { - $this->generatedFile = APP . 'Controller/Selected/BakeCommentsController.php'; - $this->exec('bake controller --connection test --no-test --prefix Selected --actions view BakeComments'); - $this->assertExitSuccess(); - - $collection = $this->connectGeneratedRoutes(); - $this->assertCount(1, $collection->routes()); - $result = $collection->parseRequest(new ServerRequest([ - 'url' => '/selected/bake-comments/view/42', - 'environment' => ['REQUEST_METHOD' => 'GET'], - ])); - $this->assertSame('view', $result['action']); - $this->assertSame(['42'], $result['pass']); - } - - public function testAuthenticationLoginRoute(): void - { - $this->loadTestPlugin('Authentication'); - $this->generatedFile = APP . 'Controller/LoginRouting/UsersController.php'; - $this->exec('bake controller --connection test --no-test --prefix LoginRouting Users'); - $this->assertExitSuccess(); - $source = file_get_contents($this->generatedFile); - $this->assertStringContainsString("allowMethod(['get', 'post'])", $source); - $this->assertStringNotContainsString('methods:', $source); - - $collection = $this->connectGeneratedRoutes(); - foreach (['GET', 'HEAD', 'POST'] as $method) { - $result = $collection->parseRequest(new ServerRequest([ - 'url' => '/login-routing/users/login', - 'environment' => ['REQUEST_METHOD' => $method], - ])); - $this->assertSame('login', $result['action']); - $this->assertSame('LoginRouting', $result['prefix']); - } - $class = new ReflectionClass('Bake\\Test\\App\\Controller\\LoginRouting\\UsersController'); - // Check the action guard without initializing the fixture's stub Authentication component. - $controller = $class->newInstanceWithoutConstructor(); - foreach (['HEAD', 'PUT', 'DELETE'] as $method) { - $controller->setRequest(new ServerRequest(['environment' => ['REQUEST_METHOD' => $method]])); - try { - $controller->login(); - $this->fail('The generated login action must reject ' . $method . ' requests.'); - } catch (MethodNotAllowedException $exception) { - $this->assertSame(405, $exception->getCode()); - } - } - } - - protected function connectGeneratedRoutes(): RouteCollection - { - AttributeResolver::setConfig('bake-routing-test', [ - 'paths' => ['Controller/*Controller.php', 'Controller/**/*Controller.php', - 'src/Controller/*Controller.php', 'src/Controller/**/*Controller.php'], - 'basePath' => APP, - 'cache' => false, - ]); - $collection = Router::getRouteCollection(); - $routes = new RouteBuilder($collection, '/'); - // The test application lives inside Bake's plugin directory. Hide that outer - // plugin while scanning so application controllers are not classified as Bake controllers. - $plugins = Plugin::getCollection(); - $bake = $plugins->get('Bake'); - $plugins->remove('Bake'); - try { - $routes->connectAttributes('bake-routing-test'); - } finally { - $plugins->add($bake); - } - - return $collection; - } } diff --git a/tests/TestCase/Command/ControllerCommandTest.php b/tests/TestCase/Command/ControllerCommandTest.php index 7eac2af5c..d82cea846 100644 --- a/tests/TestCase/Command/ControllerCommandTest.php +++ b/tests/TestCase/Command/ControllerCommandTest.php @@ -306,6 +306,34 @@ public function testBakeWithPlugin(): void $this->assertSameAsFile(__FUNCTION__ . '.php', $result); } + /** + * @return array + */ + public static function prefixedPluginRoutingProvider(): array + { + return [ + 'plugin' => ['BakeTest', 'Admin', '/bake-test/admin'], + 'vendor plugin' => ['Company/Pastry', 'Admin/Management', '/company/pastry/admin/management'], + ]; + } + + #[DataProvider('prefixedPluginRoutingProvider')] + public function testBakePrefixedPluginRouting(string $plugin, string $prefix, string $path): void + { + $this->loadTestPlugin($plugin); + $this->generatedFile = Plugin::path($plugin) . 'src/Controller/' . $prefix . '/BakeArticlesController.php'; + $this->exec('bake controller --connection test --no-test --plugin ' . $plugin . + ' --prefix ' . $prefix . ' BakeArticles'); + + $this->assertExitSuccess(); + $this->assertFileContains("#[Scope('/bake-articles')]", $this->generatedFile); + $this->assertFileContains('use Cake\\Routing\\Attribute\\Prefix;', $this->generatedFile); + $this->assertFileContains( + "#[Prefix('" . $prefix . "', path: '" . $path . "')]", + $this->generatedFile, + ); + } + /** * test that bakeActions is creating the correct controller Code. (Using sessions) *