diff --git a/docs/en/development.md b/docs/en/development.md index c5363ec76..0d57eb7e2 100644 --- a/docs/en/development.md +++ b/docs/en/development.md @@ -93,6 +93,24 @@ 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. + +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..eeed8e940 100644 --- a/docs/en/usage.md +++ b/docs/en/usage.md @@ -45,6 +45,65 @@ 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 + +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. + +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. + +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 + +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..6b0fe6041 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..d6152f322 --- /dev/null +++ b/tests/TestCase/Command/AttributeRoutingTest.php @@ -0,0 +1,56 @@ +setAppNamespace('Bake\\Test\\App'); + } + + /** + * @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'); + } +} 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..d82cea846 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; @@ -305,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) * @@ -319,6 +348,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);