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 @@ +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 @@ + $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 @@ + $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 @@ + $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 @@ 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 @@ +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 @@ +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'] + ); + } +}