diff --git a/Neos.Flow/Classes/Configuration/Loader/RoutesLoader.php b/Neos.Flow/Classes/Configuration/Loader/RoutesLoader.php index a02a5c152f..5c9f882c6d 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['provider'])) { + $routeDefinitions[] = [ + 'name' => $configurationKey, + 'provider' => $routeFromSettings['provider'], + '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['provider'])) { + $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/CombinedRoutesProvider.php b/Neos.Flow/Classes/Mvc/Routing/CombinedRoutesProvider.php deleted file mode 100644 index 1aaa18f4e2..0000000000 --- a/Neos.Flow/Classes/Mvc/Routing/CombinedRoutesProvider.php +++ /dev/null @@ -1,17 +0,0 @@ -annotationRoutesProvider->getRoutes()->merge($this->configurationRoutesProvider->getRoutes()); - } -} diff --git a/Neos.Flow/Classes/Mvc/Routing/ConfigurationRoutesProvider.php b/Neos.Flow/Classes/Mvc/Routing/ConfigurationRoutesProvider.php index f876008e8c..637ef91e7f 100644 --- a/Neos.Flow/Classes/Mvc/Routing/ConfigurationRoutesProvider.php +++ b/Neos.Flow/Classes/Mvc/Routing/ConfigurationRoutesProvider.php @@ -6,6 +6,8 @@ use Neos\Flow\Annotations as Flow; use Neos\Flow\Configuration\ConfigurationManager; +use Neos\Flow\Configuration\Loader\RoutesLoader; +use Neos\Flow\ObjectManagement\ObjectManagerInterface; /** * @Flow\Scope("singleton") @@ -15,13 +17,29 @@ final class ConfigurationRoutesProvider implements RoutesProviderInterface private ConfigurationManager $configurationManager; public function __construct( - ConfigurationManager $configurationManager + 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['provider'])) { + $provider = $this->objectManager->get($routeConfiguration['provider']); + if ($provider instanceof RoutesProviderWithOptionsInterface) { + $provider = $provider->withOptions($routeConfiguration['providerOptions']); + } + assert($provider instanceof RoutesProviderInterface); + foreach ($provider->getRoutes() as $route) { + $routes[] = $route; + } + } else { + $routes[] = Route::fromConfiguration($routeConfiguration); + } + } + return Routes::create(...$routes); } } diff --git a/Neos.Flow/Classes/Mvc/Routing/AnnotationRoutesProvider.php b/Neos.Flow/Classes/Mvc/Routing/RouteAnnotationRoutesProvider.php similarity index 61% rename from Neos.Flow/Classes/Mvc/Routing/AnnotationRoutesProvider.php rename to Neos.Flow/Classes/Mvc/Routing/RouteAnnotationRoutesProvider.php index 002855de8c..d6324817ad 100644 --- a/Neos.Flow/Classes/Mvc/Routing/AnnotationRoutesProvider.php +++ b/Neos.Flow/Classes/Mvc/Routing/RouteAnnotationRoutesProvider.php @@ -4,35 +4,60 @@ namespace Neos\Flow\Mvc\Routing; +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; -/** - * @Flow\Scope("singleton") - */ -class AnnotationRoutesProvider implements RoutesProviderInterface +class RouteAnnotationRoutesProvider implements RoutesProviderWithOptionsInterface { + /** + * @param ReflectionService $reflectionService + * @param ObjectManagerInterface $objectManager + * @param array $classNames + */ public function __construct( public readonly ReflectionService $reflectionService, public readonly ObjectManagerInterface $objectManager, + public readonly array $classNames = [], ) { } + /** + * @param array $options + * @return $this + */ + public function withOptions(array $options): static + { + return new static( + $this->reflectionService, + $this->objectManager, + $options['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; + } + } + if (!$includeClassName) { + continue; + } $controllerObjectName = $this->objectManager->getCaseSensitiveObjectName($className); $controllerPackageKey = $this->objectManager->getPackageKeyByObjectName($controllerObjectName); $controllerPackageNamespace = str_replace('.', '\\', $controllerPackageKey); if (!str_ends_with($className, 'Controller')) { - throw new \Exception('only for controller classes'); - } - if (!str_starts_with($className, $controllerPackageNamespace . '\\')) { - throw new \Exception('only for classes in package namespace'); + throw new InvalidControllerException('Only for controller classes'); } $localClassName = substr($className, strlen($controllerPackageNamespace) + 1); @@ -43,38 +68,35 @@ public function getRoutes(): Routes } elseif (str_contains($localClassName, '\\Controller\\')) { list($subPackage, $controllerName) = explode('\\Controller\\', $localClassName); } else { - throw new \Exception('unknown controller pattern'); + throw new InvalidControllerException('Unknown controller pattern'); } $annotatedMethods = $this->reflectionService->getMethodsAnnotatedWith($className, Flow\Route::class); - // @todo remove once reflectionService handles multiple annotations properly - $annotatedMethods = array_unique($annotatedMethods); foreach ($annotatedMethods as $methodName) { if (!str_ends_with($methodName, 'Action')) { - throw new \Exception('only for action methods'); + 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' => substr($controllerName, 0, -10), - '@action' => substr($methodName, 0, -6), + '@controller' => $controller, + '@action' => $action, '@format' => 'html' ], $annotation->defaults ?? [] ) ]; - if ($annotation->name !== null) { - $configuration['name'] = $annotation->name; - } - if ($annotation->httpMethods !== null) { - $configuration['httpMethods'] = $annotation->httpMethods; - } $routes[] = Route::fromConfiguration($configuration); } } diff --git a/Neos.Flow/Classes/Mvc/Routing/RoutesProviderWithOptionsInterface.php b/Neos.Flow/Classes/Mvc/Routing/RoutesProviderWithOptionsInterface.php new file mode 100644 index 0000000000..8d3caaabed --- /dev/null +++ b/Neos.Flow/Classes/Mvc/Routing/RoutesProviderWithOptionsInterface.php @@ -0,0 +1,21 @@ + $options + */ + public function withOptions(array $options): static; +} 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/Configuration/Objects.yaml b/Neos.Flow/Configuration/Objects.yaml index fdf3a6d53e..daae5adee2 100644 --- a/Neos.Flow/Configuration/Objects.yaml +++ b/Neos.Flow/Configuration/Objects.yaml @@ -258,7 +258,7 @@ Neos\Flow\Mvc\Routing\RouterInterface: className: Neos\Flow\Mvc\Routing\Router Neos\Flow\Mvc\Routing\RoutesProviderInterface: - className: Neos\Flow\Mvc\Routing\CombinedRoutesProvider + className: Neos\Flow\Mvc\Routing\ConfigurationRoutesProvider Neos\Flow\Mvc\Routing\RouterCachingService: properties: diff --git a/Neos.Flow/Tests/Unit/Mvc/Routing/AnnotationRoutesProviderTest.php b/Neos.Flow/Tests/Unit/Mvc/Routing/AnnotationRoutesProviderTest.php index 37fd14f7ad..c8fecd9f33 100644 --- a/Neos.Flow/Tests/Unit/Mvc/Routing/AnnotationRoutesProviderTest.php +++ b/Neos.Flow/Tests/Unit/Mvc/Routing/AnnotationRoutesProviderTest.php @@ -28,14 +28,14 @@ class AnnotationRoutesProviderTest extends UnitTestCase { private ReflectionService|MockObject $mockReflectionService; private ObjectManagerInterface|MockObject $mockObjectManager; - private Routing\AnnotationRoutesProvider $annotationRoutesProvider; + private Routing\RouteAnnotationRoutesProvider $annotationRoutesProvider; public function setUp(): void { $this->mockReflectionService = $this->createMock(ReflectionService::class); $this->mockObjectManager = $this->createMock(ObjectManagerInterface::class); - $this->annotationRoutesProvider = new Routing\AnnotationRoutesProvider( + $this->annotationRoutesProvider = new Routing\RouteAnnotationRoutesProvider( $this->mockReflectionService, $this->mockObjectManager ); @@ -58,8 +58,10 @@ public function noAnnotationsYieldEmptyRoutes(): void /** * @test */ - public function routesFromAnnotationAreCreated(): void + public function routesFromAnnotationAreCreatedWhenClassNamesMatch(): void { + $annotationRoutesProvider = $this->annotationRoutesProvider->withOptions(['classNames' => ['Vendor\Example\Controller\ExampleController']]); + $this->mockReflectionService->expects($this->once()) ->method('getClassesContainingMethodsAnnotatedWith') ->with(Flow\Route::class) @@ -113,7 +115,22 @@ public function routesFromAnnotationAreCreated(): void $this->assertEquals( Routes::create($expectedRoute1, $expectedRoute2), - $this->annotationRoutesProvider->getRoutes() + $annotationRoutesProvider->getRoutes() ); } + + /** + * @test + */ + public function annotationsOutsideClassNamesAreIgnored(): void + { + $annotationRoutesProvider = $this->annotationRoutesProvider->withOptions(['classNames' => []]); + + $this->mockReflectionService->expects($this->once()) + ->method('getClassesContainingMethodsAnnotatedWith') + ->with(Flow\Route::class) + ->willReturn(['Vendor\Example\Controller\ExampleController']); + + $this->assertEquals([], $annotationRoutesProvider->getRoutes()->getIterator()); + } }