diff --git a/Neos.Flow/Classes/Annotations/Route.php b/Neos.Flow/Classes/Annotations/Route.php new file mode 100644 index 0000000000..4d9e8d86d3 --- /dev/null +++ b/Neos.Flow/Classes/Annotations/Route.php @@ -0,0 +1,67 @@ +<?php +declare(strict_types=1); + +namespace Neos\Flow\Annotations; + +/* + * This file is part of the Neos.Flow package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ + +use Doctrine\Common\Annotations\Annotation\NamedArgumentConstructor; + +/** + * Adds a route configuration to a method + * + * This is a convenient way to add routes in project code + * but should not be used in libraries/packages that shall be + * configured for different use cases. + * + * @Annotation + * @NamedArgumentConstructor + * @Target({"METHOD"}) + */ +#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)] +final readonly class Route +{ + /** + * Magic route values cannot be set as default nor be contained as segments like `{\@action}` or `{\@controller}` in the uriPattern. + * The magic route value `\@format` is allowed if necessary. + */ + private const PRESERVED_DEFAULTS = ['@package', '@subpackage', '@controller', '@action']; + + /** + * @param string $uriPattern The uri-pattern for the route without leading '/'. Might contain route values in the form of `path/{foo}` + * @param string $name The suffix of the route name as shown in `route:list` (defaults to the action name: "My.Package :: Site :: index") + * @param array $httpMethods List of uppercase http verbs like 'GET', 'POST', 'PUT', 'DELETE', if not specified any request method will be matched + * @param array $defaults Values to set for this route + */ + public function __construct( + public string $uriPattern, + public string $name = '', + public array $httpMethods = [], + public array $defaults = [], + ) { + if ($uriPattern === '' || str_starts_with($uriPattern, '/')) { + throw new \DomainException(sprintf('Uri pattern must not be empty or begin with a slash: "%s"', $uriPattern), 1711529592); + } + foreach ($httpMethods as $httpMethod) { + if ($httpMethod === '' || ctype_lower($httpMethod)) { + throw new \DomainException(sprintf('Http method must not be empty or be lower case: "%s"', $httpMethod), 1711530485); + } + } + foreach (self::PRESERVED_DEFAULTS as $preservedDefaultName) { + if (str_contains($uriPattern, sprintf('{%s}', $preservedDefaultName))) { + throw new \DomainException(sprintf('It is not allowed to use "%s" in the uri pattern "%s"', $preservedDefaultName, $uriPattern), 1711129634); + } + if (array_key_exists($preservedDefaultName, $defaults)) { + throw new \DomainException(sprintf('It is not allowed to override "%s" as default', $preservedDefaultName), 1711129638); + } + } + } +} diff --git a/Neos.Flow/Classes/Configuration/Loader/RoutesLoader.php b/Neos.Flow/Classes/Configuration/Loader/RoutesLoader.php index a02a5c152f..c3f5fb8da4 100644 --- a/Neos.Flow/Classes/Configuration/Loader/RoutesLoader.php +++ b/Neos.Flow/Classes/Configuration/Loader/RoutesLoader.php @@ -84,12 +84,20 @@ public function load(array $packages, ApplicationContext $context): array protected function includeSubRoutesFromSettings(array $routeDefinitions, array $routeSettings): array { $sortedRouteSettings = (new PositionalArraySorter($routeSettings))->toArray(); - foreach ($sortedRouteSettings as $packageKey => $routeFromSettings) { + foreach ($sortedRouteSettings as $configurationKey => $routeFromSettings) { if ($routeFromSettings === false) { continue; } - $subRoutesName = $packageKey . 'SubRoutes'; - $subRoutesConfiguration = ['package' => $packageKey]; + if (isset($routeFromSettings['providerFactory'])) { + $routeDefinitions[] = [ + 'name' => $configurationKey, + 'providerFactory' => $routeFromSettings['providerFactory'], + 'providerOptions' => $routeFromSettings['providerOptions'] ?? [], + ]; + continue; + } + $subRoutesName = $configurationKey . 'SubRoutes'; + $subRoutesConfiguration = ['package' => $configurationKey]; if (isset($routeFromSettings['variables'])) { $subRoutesConfiguration['variables'] = $routeFromSettings['variables']; } @@ -97,7 +105,7 @@ protected function includeSubRoutesFromSettings(array $routeDefinitions, array $ $subRoutesConfiguration['suffix'] = $routeFromSettings['suffix']; } $routeDefinitions[] = [ - 'name' => $packageKey, + 'name' => $configurationKey, 'uriPattern' => '<' . $subRoutesName . '>', 'subRoutes' => [ $subRoutesName => $subRoutesConfiguration @@ -128,6 +136,10 @@ protected function mergeRoutesWithSubRoutes(array $packages, ApplicationContext } $mergedSubRoutesConfiguration = [$routeConfiguration]; foreach ($routeConfiguration['subRoutes'] as $subRouteKey => $subRouteOptions) { + if (isset($subRouteOptions['providerFactory'])) { + $mergedRoutesConfiguration[] = $subRouteOptions; + continue; + } if (!isset($subRouteOptions['package'])) { throw new ParseErrorException(sprintf('Missing package configuration for SubRoute in Route "%s".', ($routeConfiguration['name'] ?? 'unnamed Route')), 1318414040); } diff --git a/Neos.Flow/Classes/Mvc/Routing/AttributeRoutesProvider.php b/Neos.Flow/Classes/Mvc/Routing/AttributeRoutesProvider.php new file mode 100644 index 0000000000..df77ab0840 --- /dev/null +++ b/Neos.Flow/Classes/Mvc/Routing/AttributeRoutesProvider.php @@ -0,0 +1,149 @@ +<?php + +/* + * This file is part of the Neos.Flow package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ + +declare(strict_types=1); + +namespace Neos\Flow\Mvc\Routing; + +use Neos\Flow\Mvc\Controller\ActionController; +use Neos\Flow\Mvc\Exception\InvalidActionNameException; +use Neos\Flow\Mvc\Routing\Exception\InvalidControllerException; +use Neos\Flow\ObjectManagement\ObjectManagerInterface; +use Neos\Flow\Reflection\ReflectionService; +use Neos\Flow\Annotations as Flow; +use Neos\Utility\Arrays; + +/** + * Allows to annotate controller methods with route configurations + * + * Internal implementation: + * ----------------------- + * + * Flows routing configuration is declared via \@package, \@subpackage, \@controller and \@action + * The first three options will resolve to a fully qualified class name {@see \Neos\Flow\Mvc\ActionRequest::getControllerObjectName()} + * which is instantiated in the dispatcher {@see \Neos\Flow\Mvc\Dispatcher::dispatch()} + * + * The latter \@action option will be treated internally by each controller. From the perspective of the dispatcher \@action is just another routing value. + * By convention during processRequest in the default ActionController {@see \ActionController::resolveActionMethodName()} will be used + * to concatenate the "Action" suffix to the action name + * and {@see ActionController::callActionMethod()} will invoke the method internally with prepared method arguments. + * + * Creating routes by annotation must make a few assumptions to work: + * + * 1. As not every FQ class name is representable via the routing configuration (e.g. the class has to end with "Controller"), + * only classes can be annotated that reside in a correct location and have the correct suffix. + * Otherwise, an exception will be thrown as the class is not discoverable by the dispatcher. + * + * 2. As the ActionController requires a little magic and is the main use case we currently only support this controller. + * For that reason it is validated that the annotation is inside an ActionController and the method ends with "Action". + * The routing value with the suffix trimmed will be generated: + * + * class MyThingController extends ActionController + * { + * #[Flow\Route(path: 'foo')] + * public function someAction() + * { + * } + * } + * + * The example will genrate the configuration: + * + * \@package My.Package + * \@controller MyThing + * \@action some + * + * TODO for a future scope of `Flow\Action` see {@link https://github.com/neos/flow-development-collection/issues/3335} + */ +final class AttributeRoutesProvider implements RoutesProviderInterface +{ + /** + * @param array<string> $classNames + */ + public function __construct( + public readonly ReflectionService $reflectionService, + public readonly ObjectManagerInterface $objectManager, + public readonly array $classNames, + ) { + } + + public function getRoutes(): Routes + { + $routes = []; + $annotatedClasses = $this->reflectionService->getClassesContainingMethodsAnnotatedWith(Flow\Route::class); + + foreach ($annotatedClasses as $className) { + $includeClassName = false; + foreach ($this->classNames as $classNamePattern) { + if (fnmatch($classNamePattern, $className, FNM_NOESCAPE)) { + $includeClassName = true; + break; + } + } + if (!$includeClassName) { + continue; + } + + if (!in_array(ActionController::class, class_parents($className), true)) { + throw new InvalidControllerException('TODO: Currently #[Flow\Route] is only supported for ActionController. See https://github.com/neos/flow-development-collection/issues/3335.'); + } + + $controllerObjectName = $this->objectManager->getCaseSensitiveObjectName($className); + $controllerPackageKey = $this->objectManager->getPackageKeyByObjectName($controllerObjectName); + $controllerPackageNamespace = str_replace('.', '\\', $controllerPackageKey); + if (!str_ends_with($className, 'Controller')) { + throw new InvalidControllerException('Only for controller classes'); + } + + $localClassName = substr($className, strlen($controllerPackageNamespace) + 1); + + if (str_starts_with($localClassName, 'Controller\\')) { + $controllerName = substr($localClassName, 11); + $subPackage = null; + } elseif (str_contains($localClassName, '\\Controller\\')) { + list($subPackage, $controllerName) = explode('\\Controller\\', $localClassName); + } else { + throw new InvalidControllerException('Unknown controller pattern'); + } + + $annotatedMethods = $this->reflectionService->getMethodsAnnotatedWith($className, Flow\Route::class); + foreach ($annotatedMethods as $methodName) { + if (!str_ends_with($methodName, 'Action')) { + throw new InvalidActionNameException('Only for action methods'); + } + $annotations = $this->reflectionService->getMethodAnnotations($className, $methodName, Flow\Route::class); + foreach ($annotations as $annotation) { + if ($annotation instanceof Flow\Route) { + $controller = substr($controllerName, 0, -10); + $action = substr($methodName, 0, -6); + $configuration = [ + 'name' => $controllerPackageKey . ' :: ' . $controller . ' :: ' . ($annotation->name ?: $action), + 'uriPattern' => $annotation->uriPattern, + 'httpMethods' => $annotation->httpMethods, + 'defaults' => Arrays::arrayMergeRecursiveOverrule( + [ + '@package' => $controllerPackageKey, + '@subpackage' => $subPackage, + '@controller' => $controller, + '@action' => $action, + '@format' => 'html' + ], + $annotation->defaults ?? [] + ) + ]; + $routes[] = Route::fromConfiguration($configuration); + } + } + } + } + return Routes::create(...$routes); + } +} diff --git a/Neos.Flow/Classes/Mvc/Routing/AttributeRoutesProviderFactory.php b/Neos.Flow/Classes/Mvc/Routing/AttributeRoutesProviderFactory.php new file mode 100644 index 0000000000..ff6aa1b81c --- /dev/null +++ b/Neos.Flow/Classes/Mvc/Routing/AttributeRoutesProviderFactory.php @@ -0,0 +1,39 @@ +<?php + +/* + * This file is part of the Neos.Flow package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ + +declare(strict_types=1); + +namespace Neos\Flow\Mvc\Routing; + +use Neos\Flow\ObjectManagement\ObjectManagerInterface; +use Neos\Flow\Reflection\ReflectionService; + +class AttributeRoutesProviderFactory implements RoutesProviderFactoryInterface +{ + public function __construct( + public readonly ReflectionService $reflectionService, + public readonly ObjectManagerInterface $objectManager, + ) { + } + + /** + * @param array<string, mixed> $options + */ + public function createRoutesProvider(array $options): RoutesProviderInterface + { + return new AttributeRoutesProvider( + $this->reflectionService, + $this->objectManager, + $options['classNames'] ?? [], + ); + } +} diff --git a/Neos.Flow/Classes/Mvc/Routing/ConfigurationRoutesProvider.php b/Neos.Flow/Classes/Mvc/Routing/ConfigurationRoutesProvider.php index f876008e8c..63d2d366f8 100644 --- a/Neos.Flow/Classes/Mvc/Routing/ConfigurationRoutesProvider.php +++ b/Neos.Flow/Classes/Mvc/Routing/ConfigurationRoutesProvider.php @@ -6,22 +6,36 @@ use Neos\Flow\Annotations as Flow; use Neos\Flow\Configuration\ConfigurationManager; +use Neos\Flow\ObjectManagement\ObjectManagerInterface; /** * @Flow\Scope("singleton") */ final class ConfigurationRoutesProvider implements RoutesProviderInterface { - private ConfigurationManager $configurationManager; - public function __construct( - ConfigurationManager $configurationManager + private ConfigurationManager $configurationManager, + private ObjectManagerInterface $objectManager, ) { - $this->configurationManager = $configurationManager; } public function getRoutes(): Routes { - return Routes::fromConfiguration($this->configurationManager->getConfiguration(ConfigurationManager::CONFIGURATION_TYPE_ROUTES)); + $routes = []; + foreach ($this->configurationManager->getConfiguration(ConfigurationManager::CONFIGURATION_TYPE_ROUTES) as $routeConfiguration) { + if (isset($routeConfiguration['providerFactory'])) { + $providerFactory = $this->objectManager->get($routeConfiguration['providerFactory']); + if (!$providerFactory instanceof RoutesProviderFactoryInterface) { + throw new \InvalidArgumentException(sprintf('The configured route providerFactory "%s" does not implement the "%s"', $routeConfiguration['providerFactory'], RoutesProviderFactoryInterface::class), 1710784630); + } + $provider = $providerFactory->createRoutesProvider($routeConfiguration['providerOptions'] ?? []); + foreach ($provider->getRoutes() as $route) { + $routes[] = $route; + } + } else { + $routes[] = Route::fromConfiguration($routeConfiguration); + } + } + return Routes::create(...$routes); } } diff --git a/Neos.Flow/Classes/Mvc/Routing/RoutesProviderFactoryInterface.php b/Neos.Flow/Classes/Mvc/Routing/RoutesProviderFactoryInterface.php new file mode 100644 index 0000000000..642d984c3b --- /dev/null +++ b/Neos.Flow/Classes/Mvc/Routing/RoutesProviderFactoryInterface.php @@ -0,0 +1,23 @@ +<?php + +/* + * This file is part of the Neos.Flow package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ + +declare(strict_types=1); + +namespace Neos\Flow\Mvc\Routing; + +interface RoutesProviderFactoryInterface +{ + /** + * @param array<string, mixed> $options + */ + public function createRoutesProvider(array $options): RoutesProviderInterface; +} diff --git a/Neos.Flow/Classes/Mvc/Routing/RoutesProviderInterface.php b/Neos.Flow/Classes/Mvc/Routing/RoutesProviderInterface.php index 5cd3f98ae6..61221fb15c 100644 --- a/Neos.Flow/Classes/Mvc/Routing/RoutesProviderInterface.php +++ b/Neos.Flow/Classes/Mvc/Routing/RoutesProviderInterface.php @@ -1,5 +1,15 @@ <?php +/* + * This file is part of the Neos.Flow package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ + declare(strict_types=1); namespace Neos\Flow\Mvc\Routing; diff --git a/Neos.Flow/Classes/Package.php b/Neos.Flow/Classes/Package.php index 94405c7a34..49808ac35c 100644 --- a/Neos.Flow/Classes/Package.php +++ b/Neos.Flow/Classes/Package.php @@ -11,6 +11,7 @@ * source code. */ +use Neos\Flow\Annotations\Route; use Neos\Flow\Cache\AnnotationsCacheFlusher; use Neos\Flow\Configuration\Loader\AppendLoader; use Neos\Flow\Configuration\Source\YamlSource; @@ -158,6 +159,7 @@ public function boot(Core\Bootstrap $bootstrap) $dispatcher->connect(Proxy\Compiler::class, 'compiledClasses', function (array $classNames) use ($bootstrap) { $annotationsCacheFlusher = $bootstrap->getObjectManager()->get(AnnotationsCacheFlusher::class); + $annotationsCacheFlusher->registerAnnotation(Route::class, ['Flow_Mvc_Routing_Route', 'Flow_Mvc_Routing_Resolve']); $annotationsCacheFlusher->flushConfigurationCachesByCompiledClass($classNames); }); } diff --git a/Neos.Flow/Classes/Reflection/ReflectionService.php b/Neos.Flow/Classes/Reflection/ReflectionService.php index 72bb218fe9..1a94d10339 100644 --- a/Neos.Flow/Classes/Reflection/ReflectionService.php +++ b/Neos.Flow/Classes/Reflection/ReflectionService.php @@ -1273,7 +1273,7 @@ protected function reflectClassMethod(string $className, MethodReflection $metho if (!isset($this->classesByMethodAnnotations[$annotationClassName][$className])) { $this->classesByMethodAnnotations[$annotationClassName][$className] = []; } - $this->classesByMethodAnnotations[$annotationClassName][$className][] = $methodName; + $this->classesByMethodAnnotations[$annotationClassName][$className][$methodName] = $methodName; } $returnType = $method->getDeclaredReturnType(); diff --git a/Neos.Flow/Documentation/TheDefinitiveGuide/PartIII/Routing.rst b/Neos.Flow/Documentation/TheDefinitiveGuide/PartIII/Routing.rst index 83f72a1988..7821c8aac8 100644 --- a/Neos.Flow/Documentation/TheDefinitiveGuide/PartIII/Routing.rst +++ b/Neos.Flow/Documentation/TheDefinitiveGuide/PartIII/Routing.rst @@ -729,10 +729,67 @@ two more options you can use: With ``suffix`` you can specify a custom filename suffix for the SubRoute. The ``variables`` option allows you to specify placeholders in the SubRoutes (see `Nested Subroutes`_). +It also is possible to specify a `providerFactory` and (optional) `providerOptions` to generate the subroutes via the +`Neos\Flow\Mvc\Routing\RoutesProviderFactoryInterface` and the ``Neos\Flow\Mvc\Routing\RoutesProviderInterface`. + +.. code-block:: yaml + + Neos: + Flow: + mvc: + routes: + Vendor.Example.attributes: + position: 'before Neos.Neos' + providerFactory: \Neos\Flow\Mvc\Routing\AttributeRoutesProviderFactory + providerOptions: + classNames: + - Vendor\Example\Controller\ExampleController + .. tip:: You can use the ``flow:routing:list`` command to list all routes which are currently active, see `CLI`_ +Subroutes from Annotations +-------------------------- + +The ``Flow\Route`` attribute allows to define routes directly on the affected method. +(Currently only ActionController are supported https://github.com/neos/flow-development-collection/issues/3335) + +.. code-block:: php + + use Neos\Flow\Mvc\Controller\ActionController; + use Neos\Flow\Annotations as Flow; + + class ExampleController extends ActionController + { + #[Flow\Route(uriPattern:'my/path', httpMethods: ['GET'])] + public function someAction(): void + { + } + + #[Flow\Route(uriPattern:'my/other/b-path', defaults: ['test' => 'b'])] + #[Flow\Route(uriPattern:'my/other/c-path', defaults: ['test' => 'c'])] + public function otherAction(string $test): void + { + } + } + +To find the annotation and tp specify the order of routes this has to be used together with the +`\Neos\Flow\Mvc\Routing\AttributeRoutesProviderFactory` as `providerFactory` in Setting `Neos.Flow.mvc.routes` + +.. code-block:: yaml + + Neos: + Flow: + mvc: + routes: + Vendor.Example.attributes: + position: 'before Neos.Neos' + providerFactory: \Neos\Flow\Mvc\Routing\AttributeRoutesProviderFactory + providerOptions: + classNames: + - Vendor\Example\Controller\* + Route Loading Order and the Flow Application Context ==================================================== diff --git a/Neos.Flow/Tests/Unit/Mvc/Routing/AttributeRoutesProviderTest.php b/Neos.Flow/Tests/Unit/Mvc/Routing/AttributeRoutesProviderTest.php new file mode 100644 index 0000000000..4ee9cd3459 --- /dev/null +++ b/Neos.Flow/Tests/Unit/Mvc/Routing/AttributeRoutesProviderTest.php @@ -0,0 +1,146 @@ +<?php + +namespace Neos\Flow\Tests\Unit\Mvc\Routing; + +/* + * This file is part of the Neos.Flow package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ + +use Neos\Flow\Mvc\Routing; +use Neos\Flow\Mvc\Routing\Route; +use Neos\Flow\Mvc\Routing\Routes; +use Neos\Flow\ObjectManagement\ObjectManagerInterface; +use Neos\Flow\Reflection\ReflectionService; +use Neos\Flow\Tests\UnitTestCase; +use PHPUnit\Framework\MockObject\MockObject; +use Neos\Flow\Annotations as Flow; + +/** + * Testcase for the MVC Web Routing Routes Class + */ +class AttributeRoutesProviderTest extends UnitTestCase +{ + private ReflectionService|MockObject $mockReflectionService; + private ObjectManagerInterface|MockObject $mockObjectManager; + private Routing\AttributeRoutesProvider $annotationRoutesProvider; + + public function setUp(): void + { + $this->mockReflectionService = $this->createMock(ReflectionService::class); + $this->mockObjectManager = $this->createMock(ObjectManagerInterface::class); + + $this->annotationRoutesProvider = new Routing\AttributeRoutesProvider( + $this->mockReflectionService, + $this->mockObjectManager, + ['Vendor\\Example\\Controller\\*'] + ); + } + + /** + * @test + */ + public function noAnnotationsYieldEmptyRoutes(): void + { + $this->mockReflectionService->expects($this->once()) + ->method('getClassesContainingMethodsAnnotatedWith') + ->with(\Neos\Flow\Annotations\Route::class) + ->willReturn([]); + + $routes = $this->annotationRoutesProvider->getRoutes(); + $this->assertEquals(Routes::empty(), $routes); + } + + /** + * @test + */ + public function routesFromAnnotationAreCreatedWhenClassNamesMatch(): void + { + $exampleFqnControllerName = 'Vendor\\Example\\Controller\\ExampleController'; + eval(' + namespace Vendor\Example\Controller; + class ExampleController extends \Neos\Flow\Mvc\Controller\ActionController { + }' + ); + + $this->mockReflectionService->expects($this->once()) + ->method('getClassesContainingMethodsAnnotatedWith') + ->with(Flow\Route::class) + ->willReturn([$exampleFqnControllerName]); + + $this->mockReflectionService->expects($this->once()) + ->method('getMethodsAnnotatedWith') + ->with($exampleFqnControllerName, Flow\Route::class) + ->willReturn(['specialAction']); + + $this->mockReflectionService->expects($this->once()) + ->method('getMethodAnnotations') + ->with($exampleFqnControllerName, 'specialAction', Flow\Route::class) + ->willReturn([ + new Flow\Route(uriPattern: 'my/path'), + new Flow\Route( + uriPattern: 'my/other/path', + name: 'specialRoute', + httpMethods: ['GET', 'POST'], + defaults: ['test' => 'foo'] + ) + ]); + + $this->mockObjectManager->expects($this->once()) + ->method('getCaseSensitiveObjectName') + ->with($exampleFqnControllerName) + ->willReturn($exampleFqnControllerName); + + $this->mockObjectManager->expects($this->once()) + ->method('getPackageKeyByObjectName') + ->with($exampleFqnControllerName) + ->willReturn('Vendor.Example'); + + $expectedRoute1 = new Route(); + $expectedRoute1->setName('Vendor.Example :: Example :: special'); + $expectedRoute1->setUriPattern('my/path'); + $expectedRoute1->setDefaults([ + '@package' => 'Vendor.Example', + '@subpackage' => null, + '@controller' => 'Example', + '@action' => 'special', + '@format' => 'html', + ]); + + $expectedRoute2 = new Route(); + $expectedRoute2->setName('Vendor.Example :: Example :: specialRoute'); + $expectedRoute2->setUriPattern('my/other/path'); + $expectedRoute2->setDefaults([ + '@package' => 'Vendor.Example', + '@subpackage' => null, + '@controller' => 'Example', + '@action' => 'special', + '@format' => 'html', + 'test' => 'foo', + ]); + $expectedRoute2->setHttpMethods(['GET', 'POST']); + + $this->assertEquals( + Routes::create($expectedRoute1, $expectedRoute2), + $this->annotationRoutesProvider->getRoutes() + ); + } + + /** + * @test + */ + public function annotationsOutsideClassNamesAreIgnored(): void + { + $this->mockReflectionService->expects($this->once()) + ->method('getClassesContainingMethodsAnnotatedWith') + ->with(Flow\Route::class) + ->willReturn(['Vendor\Other\Controller\ExampleController']); + + $this->assertEquals(Routes::empty(), $this->annotationRoutesProvider->getRoutes()); + } +} diff --git a/Neos.Flow/Tests/Unit/Mvc/Routing/ConfigurationRoutesProviderTest.php b/Neos.Flow/Tests/Unit/Mvc/Routing/ConfigurationRoutesProviderTest.php index 08651da9ad..190c10037e 100644 --- a/Neos.Flow/Tests/Unit/Mvc/Routing/ConfigurationRoutesProviderTest.php +++ b/Neos.Flow/Tests/Unit/Mvc/Routing/ConfigurationRoutesProviderTest.php @@ -15,6 +15,7 @@ use Neos\Flow\Mvc\Routing; use Neos\Flow\Mvc\Routing\Route; use Neos\Flow\Mvc\Routing\Routes; +use Neos\Flow\ObjectManagement\ObjectManagerInterface; use Neos\Flow\Tests\UnitTestCase; /** @@ -27,9 +28,10 @@ class ConfigurationRoutesProviderTest extends UnitTestCase */ public function configurationManagerIsNotCalledInConstructor(): void { + $mockObjectManager = $this->createMock(ObjectManagerInterface::class); $mockConfigurationManager = $this->createMock(ConfigurationManager::class); $mockConfigurationManager->expects($this->never())->method('getConfiguration'); - $configurationRoutesProvider = new Routing\ConfigurationRoutesProvider($mockConfigurationManager); + $configurationRoutesProvider = new Routing\ConfigurationRoutesProvider($mockConfigurationManager, $mockObjectManager); $this->assertInstanceOf(Routing\ConfigurationRoutesProvider::class, $configurationRoutesProvider); } @@ -53,6 +55,7 @@ public function configurationFomConfigurationManagerIsHandled(): void ], ]; + $mockObjectManager = $this->createMock(ObjectManagerInterface::class); $mockConfigurationManager = $this->createMock(ConfigurationManager::class); $mockConfigurationManager->expects($this->once())->method('getConfiguration')->with(ConfigurationManager::CONFIGURATION_TYPE_ROUTES)->willReturn($configuration); @@ -71,7 +74,70 @@ public function configurationFomConfigurationManagerIsHandled(): void $expectedRoutes = Routes::create($expectedRoute1, $expectedRoute2); - $configurationRoutesProvider = new Routing\ConfigurationRoutesProvider($mockConfigurationManager); + $configurationRoutesProvider = new Routing\ConfigurationRoutesProvider($mockConfigurationManager, $mockObjectManager); + $this->assertEquals($expectedRoutes, $configurationRoutesProvider->getRoutes()); + } + + /** + * @test + */ + public function configuredProvidersAreCalledToGenerateSubroutes(): void + { + $configuration = [ + [ + 'name' => 'Routes provider without options', + 'providerFactory' => 'Vendor\Example\RoutesProvider', + ], + [ + 'name' => 'Routes provider with options', + 'providerFactory' => 'Vendor\Example\RoutesProviderWithOptions', + 'providerOptions' => ['foo' => 'bar'], + ], + ]; + + $mockRoutesProvider = $this->createMock(Routing\RoutesProviderInterface::class); + $mockRoutesProviderWithOptions = $this->createMock(Routing\RoutesProviderInterface::class); + + $mockRoutesProviderFactory = $this->createMock(Routing\RoutesProviderFactoryInterface::class); + $mockRoutesProviderFactory->expects($this->once()) + ->method('createRoutesProvider') + ->with([]) + ->willReturn($mockRoutesProvider); + + $mockRoutesProviderWithOptionsFactory = $this->createMock(Routing\RoutesProviderFactoryInterface::class); + $mockRoutesProviderWithOptionsFactory->expects($this->once()) + ->method('createRoutesProvider') + ->with(['foo' => 'bar']) + ->willReturn($mockRoutesProviderWithOptions); + + $mockObjectManager = $this->createMock(ObjectManagerInterface::class); + $mockObjectManager->expects($this->exactly(2))->method('get')->willReturnCallback( + function (string $name) use ($mockRoutesProviderFactory, $mockRoutesProviderWithOptionsFactory) { + return match ($name) { + 'Vendor\Example\RoutesProvider' => $mockRoutesProviderFactory, + 'Vendor\Example\RoutesProviderWithOptions' => $mockRoutesProviderWithOptionsFactory + }; + } + ); + + $mockConfigurationManager = $this->createMock(ConfigurationManager::class); + $mockConfigurationManager->expects($this->once())->method('getConfiguration')->with(ConfigurationManager::CONFIGURATION_TYPE_ROUTES)->willReturn($configuration); + + $expectedRoute1 = new Route(); + $expectedRoute1->setName('Route 1'); + $expectedRoute1->setUriPattern('route1/{@package}/{@controller}/{@action}(.{@format})'); + $expectedRoute1->setDefaults(['@format' => 'html']); + + $expectedRoute2 = new Route(); + $expectedRoute2->setName('Route 2'); + $expectedRoute2->setUriPattern('route2/{@package}/{@controller}/{@action}(.{@format})'); + + $mockRoutesProvider->expects($this->once())->method('getRoutes')->willReturn(Routes::create($expectedRoute1)); + $mockRoutesProviderWithOptions->expects($this->once())->method('getRoutes')->willReturn(Routes::create($expectedRoute2)); + + $expectedRoutes = Routes::create($expectedRoute1, $expectedRoute2); + + $configurationRoutesProvider = new Routing\ConfigurationRoutesProvider($mockConfigurationManager, $mockObjectManager); $this->assertEquals($expectedRoutes, $configurationRoutesProvider->getRoutes()); } } diff --git a/Neos.Flow/Tests/Unit/Mvc/Routing/Dto/RouteAnnotationTest.php b/Neos.Flow/Tests/Unit/Mvc/Routing/Dto/RouteAnnotationTest.php new file mode 100644 index 0000000000..5069ab6e20 --- /dev/null +++ b/Neos.Flow/Tests/Unit/Mvc/Routing/Dto/RouteAnnotationTest.php @@ -0,0 +1,101 @@ +<?php + +declare(strict_types=1); + +namespace Neos\Flow\Tests\Unit\Mvc\Routing\Dto; + +use Neos\Flow\Annotations as Flow; +use PHPUnit\Framework\TestCase; + +/** + * Tests for #[Flow\Route] + */ +class RouteAnnotationTest extends TestCase +{ + /** + * @test + */ + public function simpleRoutes() + { + $route = new Flow\Route(uriPattern: 'my/path'); + self::assertSame('my/path', $route->uriPattern); + self::assertSame('', $route->name); + self::assertSame([], $route->httpMethods); + self::assertSame([], $route->defaults); + + $route = new Flow\Route( + uriPattern: 'my/other/path', + name: 'specialRoute', + httpMethods: ['POST'], + defaults: ['test' => 'foo'] + ); + self::assertSame('my/other/path', $route->uriPattern); + self::assertSame('specialRoute', $route->name); + self::assertSame(['POST'], $route->httpMethods); + self::assertSame(['test' => 'foo'], $route->defaults); + } + + /** + * @test + */ + public function preservedDefaults() + { + $this->expectExceptionCode(1711129638); + + new Flow\Route(uriPattern: 'my/path', defaults: ['@action' => 'index']); + } + + /** + * @test + */ + public function preservedInUriPattern() + { + $this->expectExceptionCode(1711129634); + + new Flow\Route(uriPattern: 'my/{@package}'); + } + + /** + * @test + */ + public function uriPatternMustNotStartWithLeadingSlash() + { + $this->expectExceptionCode(1711529592); + + new Flow\Route(uriPattern: '/absolute'); + } + + /** + * @test + */ + public function uriPatternMustNotBeEmpty() + { + $this->expectExceptionCode(1711529592); + + new Flow\Route(uriPattern: ''); + } + + /** + * @test + */ + public function httpMethodMustNotBeEmptyString() + { + $this->expectExceptionCode(1711530485); + + new Flow\Route(uriPattern: 'foo', httpMethods: ['']); + } + + /** + * @test + */ + public function httpMethodMustBeUpperCase() + { + $this->expectExceptionCode(1711530485); + + /** @see \Neos\Flow\Mvc\Routing\Route::matches() where we do case-sensitive comparison against uppercase */ + new Flow\Route( + uriPattern: 'my/other/path', + httpMethods: ['post'] + ); + } +}