From 5ddd46adce6ad621d604eeaf526100b8a0c4f587 Mon Sep 17 00:00:00 2001 From: Martin Ficzel Date: Sun, 3 Mar 2024 19:26:32 +0100 Subject: [PATCH 01/18] FEATURE: Add `Flow\Route` Attribute/Annotation The `Flow\Route` attribute allows to define routes directly on the affected method. This allows to avoid dealing with Routes.yaml in projects in simple cases where is sometimes is annoying to look up the exact syntax for that. Hint: While this is a very convenient way to add routes in project code it should not be used in libraries/packages that expect to be configured for the outside. In such cases the Routes.yaml is still preferred as it is easier to overwrite. Usage: ```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(): void { } } ``` The package: `WebSupply.RouteAnnotation` by @sorenmalling implemented similar ideas earlier. Resolves: #2059 --- Neos.Flow/Classes/Annotations/Route.php | 39 ++++++ .../Mvc/Routing/AnnotationRoutesProvider.php | 85 +++++++++++++ .../Mvc/Routing/CombinedRoutesProvider.php | 17 +++ Neos.Flow/Classes/Package.php | 2 + Neos.Flow/Configuration/Objects.yaml | 2 +- .../Routing/AnnotationRoutesProviderTest.php | 119 ++++++++++++++++++ 6 files changed, 263 insertions(+), 1 deletion(-) create mode 100644 Neos.Flow/Classes/Annotations/Route.php create mode 100644 Neos.Flow/Classes/Mvc/Routing/AnnotationRoutesProvider.php create mode 100644 Neos.Flow/Classes/Mvc/Routing/CombinedRoutesProvider.php create mode 100644 Neos.Flow/Tests/Unit/Mvc/Routing/AnnotationRoutesProviderTest.php diff --git a/Neos.Flow/Classes/Annotations/Route.php b/Neos.Flow/Classes/Annotations/Route.php new file mode 100644 index 0000000000..b593419546 --- /dev/null +++ b/Neos.Flow/Classes/Annotations/Route.php @@ -0,0 +1,39 @@ +reflectionService->getClassesContainingMethodsAnnotatedWith(Flow\Route::class); + foreach ($annotatedClasses as $className) { + $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'); + } + + $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 \Exception('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'); + } + $annotations = $this->reflectionService->getMethodAnnotations($className, $methodName, Flow\Route::class); + foreach ($annotations as $annotation) { + if ($annotation instanceof Flow\Route) { + $configuration = [ + 'uriPattern' => $annotation->uriPattern, + 'defaults' => Arrays::arrayMergeRecursiveOverrule( + [ + '@package' => $controllerPackageKey, + '@subpackage' => $subPackage, + '@controller' => substr($controllerName, 0, -10), + '@action' => substr($methodName, 0, -6), + '@format' => 'html' + ], + $annotation->defaults ?? [] + ) + ]; + if ($annotation->name !== null) { + $configuration['name'] = $annotation->name; + } + if ($annotation->httpMethods !== null) { + $configuration['httpMethods'] = $annotation->httpMethods; + } + $routes[] = Route::fromConfiguration($configuration); + } + } + } + } + return Routes::create(...$routes); + } +} diff --git a/Neos.Flow/Classes/Mvc/Routing/CombinedRoutesProvider.php b/Neos.Flow/Classes/Mvc/Routing/CombinedRoutesProvider.php new file mode 100644 index 0000000000..1aaa18f4e2 --- /dev/null +++ b/Neos.Flow/Classes/Mvc/Routing/CombinedRoutesProvider.php @@ -0,0 +1,17 @@ +annotationRoutesProvider->getRoutes()->merge($this->configurationRoutesProvider->getRoutes()); + } +} 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/Configuration/Objects.yaml b/Neos.Flow/Configuration/Objects.yaml index daae5adee2..fdf3a6d53e 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\ConfigurationRoutesProvider + className: Neos\Flow\Mvc\Routing\CombinedRoutesProvider 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 new file mode 100644 index 0000000000..37fd14f7ad --- /dev/null +++ b/Neos.Flow/Tests/Unit/Mvc/Routing/AnnotationRoutesProviderTest.php @@ -0,0 +1,119 @@ +mockReflectionService = $this->createMock(ReflectionService::class); + $this->mockObjectManager = $this->createMock(ObjectManagerInterface::class); + + $this->annotationRoutesProvider = new Routing\AnnotationRoutesProvider( + $this->mockReflectionService, + $this->mockObjectManager + ); + } + + /** + * @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 routesFromAnnotationAreCreated(): void + { + $this->mockReflectionService->expects($this->once()) + ->method('getClassesContainingMethodsAnnotatedWith') + ->with(Flow\Route::class) + ->willReturn(['Vendor\Example\Controller\ExampleController']); + + $this->mockReflectionService->expects($this->once()) + ->method('getMethodsAnnotatedWith') + ->with('Vendor\Example\Controller\ExampleController', Flow\Route::class) + ->willReturn(['specialAction']); + + $this->mockReflectionService->expects($this->once()) + ->method('getMethodAnnotations') + ->with('Vendor\Example\Controller\ExampleController', 'specialAction', Flow\Route::class) + ->willReturn([ + new Flow\Route(uriPattern: 'my/path'), + new Flow\Route(uriPattern: 'my/other/path', name: 'specialRoute', defaults: ['test' => 'foo'], httpMethods: ['get', 'post']) + ]); + + $this->mockObjectManager->expects($this->once()) + ->method('getCaseSensitiveObjectName') + ->with('Vendor\Example\Controller\ExampleController') + ->willReturn('Vendor\Example\Controller\ExampleController'); + + $this->mockObjectManager->expects($this->once()) + ->method('getPackageKeyByObjectName') + ->with('Vendor\Example\Controller\ExampleController') + ->willReturn('Vendor.Example'); + + $expectedRoute1 = new Route(); + $expectedRoute1->setUriPattern('my/path'); + $expectedRoute1->setDefaults([ + '@package' => 'Vendor.Example', + '@subpackage' => null, + '@controller' => 'Example', + '@action' => 'special', + '@format' => 'html' + ]); + + $expectedRoute2 = new Route(); + $expectedRoute2->setName('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() + ); + } +} From 6877a054c502cdf67a6418217eab1db3f7aed9f7 Mon Sep 17 00:00:00 2001 From: Martin Ficzel Date: Fri, 15 Mar 2024 15:46:03 +0100 Subject: [PATCH 02/18] TASK: Refactor to include annotation routes via `provider` and `providerOptions` in Settings `Neos.Flow.mvc.routes` ``` Neos: Flow: mvc: routes: Vendor.Example: provider: \Neos\Flow\Mvc\Routing\RouteAnnotationRoutesProvider providerOptions: classNames: - Vendor\Example\Controller\ExampleController ``` --- .../Configuration/Loader/RoutesLoader.php | 20 ++++-- .../Mvc/Routing/CombinedRoutesProvider.php | 17 ----- .../Routing/ConfigurationRoutesProvider.php | 25 ++++++-- ....php => RouteAnnotationRoutesProvider.php} | 62 +++++++++++++------ .../RoutesProviderWithOptionsInterface.php | 21 +++++++ .../Classes/Reflection/ReflectionService.php | 2 +- Neos.Flow/Configuration/Objects.yaml | 2 +- .../Routing/AnnotationRoutesProviderTest.php | 25 ++++++-- .../ConfigurationRoutesProviderTest.php | 7 ++- 9 files changed, 127 insertions(+), 54 deletions(-) delete mode 100644 Neos.Flow/Classes/Mvc/Routing/CombinedRoutesProvider.php rename Neos.Flow/Classes/Mvc/Routing/{AnnotationRoutesProvider.php => RouteAnnotationRoutesProvider.php} (61%) create mode 100644 Neos.Flow/Classes/Mvc/Routing/RoutesProviderWithOptionsInterface.php 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..0fbb95784d 100644 --- a/Neos.Flow/Classes/Mvc/Routing/ConfigurationRoutesProvider.php +++ b/Neos.Flow/Classes/Mvc/Routing/ConfigurationRoutesProvider.php @@ -6,22 +6,37 @@ 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") */ 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['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()); + } } diff --git a/Neos.Flow/Tests/Unit/Mvc/Routing/ConfigurationRoutesProviderTest.php b/Neos.Flow/Tests/Unit/Mvc/Routing/ConfigurationRoutesProviderTest.php index 08651da9ad..db107c364f 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,7 @@ 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()); } } From e0a8e4acacb5eb2033866ba3424bd9a05e35e021 Mon Sep 17 00:00:00 2001 From: Martin Ficzel Date: Mon, 18 Mar 2024 18:04:03 +0100 Subject: [PATCH 03/18] DOCS: Document `Flow\Route` annotations and the Settings `Neos.Flow.mvc.routes._key__.provider | providerOptions` --- .../TheDefinitiveGuide/PartIII/Routing.rst | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/Neos.Flow/Documentation/TheDefinitiveGuide/PartIII/Routing.rst b/Neos.Flow/Documentation/TheDefinitiveGuide/PartIII/Routing.rst index 83f72a1988..cd25af6c82 100644 --- a/Neos.Flow/Documentation/TheDefinitiveGuide/PartIII/Routing.rst +++ b/Neos.Flow/Documentation/TheDefinitiveGuide/PartIII/Routing.rst @@ -729,10 +729,66 @@ 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 `provider` and (optional) `providerOptions` to generate the subroutes via the +`Neos\Flow\Mvc\Routing\RoutesProviderInterface` or the ``Neos\Flow\Mvc\Routing\RoutesProviderWithOptionsInterface`. + +.. code-block:: yaml + + Neos: + Flow: + mvc: + routes: + Vendor.Example: + position: 'before Neos.Neos' + provider: \Neos\Flow\Mvc\Routing\RouteAnnotationRoutesProvider + 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. + +.. 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\RouteAnnotationRoutesProvider` in Setting `Neos.Flow.mvs.routes` + +.. code-block:: yaml + + Neos: + Flow: + mvc: + routes: + Vendor.Example: + position: 'before Neos.Neos' + provider: \Neos\Flow\Mvc\Routing\RouteAnnotationRoutesProvider + providerOptions: + classNames: + - Vendor\Example\Controller\ExampleController + Route Loading Order and the Flow Application Context ==================================================== From 4e635caeb958c7afa39adc31ebf6f5afc854a1db Mon Sep 17 00:00:00 2001 From: Martin Ficzel Date: Mon, 18 Mar 2024 18:29:26 +0100 Subject: [PATCH 04/18] TASK: Adjust tests for AnnotationRoutesProvider and ConfigurationRoutesProvider --- .../Routing/ConfigurationRoutesProvider.php | 12 +++-- .../Routing/RouteAnnotationRoutesProvider.php | 5 +- .../RoutesProviderWithOptionsInterface.php | 2 +- .../Routing/AnnotationRoutesProviderTest.php | 5 +- .../ConfigurationRoutesProviderTest.php | 53 +++++++++++++++++++ 5 files changed, 66 insertions(+), 11 deletions(-) diff --git a/Neos.Flow/Classes/Mvc/Routing/ConfigurationRoutesProvider.php b/Neos.Flow/Classes/Mvc/Routing/ConfigurationRoutesProvider.php index 0fbb95784d..084d53e718 100644 --- a/Neos.Flow/Classes/Mvc/Routing/ConfigurationRoutesProvider.php +++ b/Neos.Flow/Classes/Mvc/Routing/ConfigurationRoutesProvider.php @@ -6,7 +6,6 @@ use Neos\Flow\Annotations as Flow; use Neos\Flow\Configuration\ConfigurationManager; -use Neos\Flow\Configuration\Loader\RoutesLoader; use Neos\Flow\ObjectManagement\ObjectManagerInterface; /** @@ -26,12 +25,15 @@ public function getRoutes(): 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) { + if ($provider instanceof RoutesProviderWithOptionsInterface && array_key_exists('providerOptions',$routeConfiguration)) { $provider = $provider->withOptions($routeConfiguration['providerOptions']); } - assert($provider instanceof RoutesProviderInterface); - foreach ($provider->getRoutes() as $route) { - $routes[] = $route; + if ($provider instanceof RoutesProviderInterface) { + foreach ($provider->getRoutes() as $route) { + $routes[] = $route; + } + } else { + throw new \InvalidArgumentException(sprintf('configured route provider "%s" does not implent the "%s"', $routeConfiguration['provider'], RoutesProviderInterface::class), 1710784630); } } else { $routes[] = Route::fromConfiguration($routeConfiguration); diff --git a/Neos.Flow/Classes/Mvc/Routing/RouteAnnotationRoutesProvider.php b/Neos.Flow/Classes/Mvc/Routing/RouteAnnotationRoutesProvider.php index d6324817ad..f53b65278b 100644 --- a/Neos.Flow/Classes/Mvc/Routing/RouteAnnotationRoutesProvider.php +++ b/Neos.Flow/Classes/Mvc/Routing/RouteAnnotationRoutesProvider.php @@ -27,11 +27,10 @@ public function __construct( /** * @param array $options - * @return $this */ - public function withOptions(array $options): static + public function withOptions(array $options): RoutesProviderInterface { - return new static( + return new RouteAnnotationRoutesProvider ( $this->reflectionService, $this->objectManager, $options['classNames'] ?? [], diff --git a/Neos.Flow/Classes/Mvc/Routing/RoutesProviderWithOptionsInterface.php b/Neos.Flow/Classes/Mvc/Routing/RoutesProviderWithOptionsInterface.php index 8d3caaabed..84333a9403 100644 --- a/Neos.Flow/Classes/Mvc/Routing/RoutesProviderWithOptionsInterface.php +++ b/Neos.Flow/Classes/Mvc/Routing/RoutesProviderWithOptionsInterface.php @@ -17,5 +17,5 @@ interface RoutesProviderWithOptionsInterface extends RoutesProviderInterface /** * @param array $options */ - public function withOptions(array $options): static; + public function withOptions(array $options): RoutesProviderInterface; } diff --git a/Neos.Flow/Tests/Unit/Mvc/Routing/AnnotationRoutesProviderTest.php b/Neos.Flow/Tests/Unit/Mvc/Routing/AnnotationRoutesProviderTest.php index c8fecd9f33..69376b86eb 100644 --- a/Neos.Flow/Tests/Unit/Mvc/Routing/AnnotationRoutesProviderTest.php +++ b/Neos.Flow/Tests/Unit/Mvc/Routing/AnnotationRoutesProviderTest.php @@ -91,6 +91,7 @@ public function routesFromAnnotationAreCreatedWhenClassNamesMatch(): void ->willReturn('Vendor.Example'); $expectedRoute1 = new Route(); + $expectedRoute1->setName('Vendor.Example :: Example :: special'); $expectedRoute1->setUriPattern('my/path'); $expectedRoute1->setDefaults([ '@package' => 'Vendor.Example', @@ -101,7 +102,7 @@ public function routesFromAnnotationAreCreatedWhenClassNamesMatch(): void ]); $expectedRoute2 = new Route(); - $expectedRoute2->setName('specialRoute'); + $expectedRoute2->setName('Vendor.Example :: Example :: specialRoute'); $expectedRoute2->setUriPattern('my/other/path'); $expectedRoute2->setDefaults([ '@package' => 'Vendor.Example', @@ -131,6 +132,6 @@ public function annotationsOutsideClassNamesAreIgnored(): void ->with(Flow\Route::class) ->willReturn(['Vendor\Example\Controller\ExampleController']); - $this->assertEquals([], $annotationRoutesProvider->getRoutes()->getIterator()); + $this->assertEquals([], iterator_to_array($annotationRoutesProvider->getRoutes()->getIterator())); } } diff --git a/Neos.Flow/Tests/Unit/Mvc/Routing/ConfigurationRoutesProviderTest.php b/Neos.Flow/Tests/Unit/Mvc/Routing/ConfigurationRoutesProviderTest.php index db107c364f..b006542aec 100644 --- a/Neos.Flow/Tests/Unit/Mvc/Routing/ConfigurationRoutesProviderTest.php +++ b/Neos.Flow/Tests/Unit/Mvc/Routing/ConfigurationRoutesProviderTest.php @@ -77,4 +77,57 @@ public function configurationFomConfigurationManagerIsHandled(): void $configurationRoutesProvider = new Routing\ConfigurationRoutesProvider($mockConfigurationManager, $mockObjectManager); $this->assertEquals($expectedRoutes, $configurationRoutesProvider->getRoutes()); } + + /** + * @test + */ + public function configuredProvidersAreCalledToGenerateSubroutes(): void + { + $configuration = [ + [ + 'name' => 'Routes provider without options', + 'provider' => 'Vendor\Example\RoutesProvider', + ], + [ + 'name' => 'Routes provider with options', + 'provider' => 'Vendor\Example\RoutesProviderWithOptions', + 'providerOptions' => ['foo' => 'bar'], + ], + ]; + + $mockRoutesProvider = $this->createMock(Routing\RoutesProviderInterface::class); + $mockRoutesProviderWithOptions = $this->createMock(Routing\RoutesProviderWithOptionsInterface::class); + + $mockObjectManager = $this->createMock(ObjectManagerInterface::class); + $mockObjectManager->expects($this->exactly(2))->method('get')->willReturnCallback( + function (string $name) use ($mockRoutesProvider, $mockRoutesProviderWithOptions) { + return match ($name) { + 'Vendor\Example\RoutesProvider' => $mockRoutesProvider, + 'Vendor\Example\RoutesProviderWithOptions' => $mockRoutesProviderWithOptions + }; + } + ); + + $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('withOptions')->with(['foo' => 'bar'])->willReturn($mockRoutesProviderWithOptions); + $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()); + } } From adaf7867f43882d48eed6dd156a6209968f20802 Mon Sep 17 00:00:00 2001 From: Martin Ficzel Date: Mon, 18 Mar 2024 20:51:01 +0100 Subject: [PATCH 05/18] Task: Refactor RouteProviderWithOptions interface RouteProviderFactoryInterface --- .../Configuration/Loader/RoutesLoader.php | 4 +- ...ovider.php => AttributeRoutesProvider.php} | 26 ++++++------- .../AttributeRoutesProviderFactory.php | 39 +++++++++++++++++++ .../Routing/ConfigurationRoutesProvider.php | 17 ++++---- .../RoutesProviderFactoryInterface.php | 23 +++++++++++ .../Mvc/Routing/RoutesProviderInterface.php | 10 +++++ .../RoutesProviderWithOptionsInterface.php | 21 ---------- .../TheDefinitiveGuide/PartIII/Routing.rst | 10 ++--- ...st.php => AttributeRoutesProviderTest.php} | 19 ++++----- .../ConfigurationRoutesProviderTest.php | 26 +++++++++---- 10 files changed, 123 insertions(+), 72 deletions(-) rename Neos.Flow/Classes/Mvc/Routing/{RouteAnnotationRoutesProvider.php => AttributeRoutesProvider.php} (88%) create mode 100644 Neos.Flow/Classes/Mvc/Routing/AttributeRoutesProviderFactory.php create mode 100644 Neos.Flow/Classes/Mvc/Routing/RoutesProviderFactoryInterface.php delete mode 100644 Neos.Flow/Classes/Mvc/Routing/RoutesProviderWithOptionsInterface.php rename Neos.Flow/Tests/Unit/Mvc/Routing/{AnnotationRoutesProviderTest.php => AttributeRoutesProviderTest.php} (85%) diff --git a/Neos.Flow/Classes/Configuration/Loader/RoutesLoader.php b/Neos.Flow/Classes/Configuration/Loader/RoutesLoader.php index 5c9f882c6d..f029b08062 100644 --- a/Neos.Flow/Classes/Configuration/Loader/RoutesLoader.php +++ b/Neos.Flow/Classes/Configuration/Loader/RoutesLoader.php @@ -88,10 +88,10 @@ protected function includeSubRoutesFromSettings(array $routeDefinitions, array $ if ($routeFromSettings === false) { continue; } - if (isset($routeFromSettings['provider'])) { + if (isset($routeFromSettings['providerFactory'])) { $routeDefinitions[] = [ 'name' => $configurationKey, - 'provider' => $routeFromSettings['provider'], + 'providerFactory' => $routeFromSettings['providerFactory'], 'providerOptions' => $routeFromSettings['providerOptions'] ?? [], ]; continue; diff --git a/Neos.Flow/Classes/Mvc/Routing/RouteAnnotationRoutesProvider.php b/Neos.Flow/Classes/Mvc/Routing/AttributeRoutesProvider.php similarity index 88% rename from Neos.Flow/Classes/Mvc/Routing/RouteAnnotationRoutesProvider.php rename to Neos.Flow/Classes/Mvc/Routing/AttributeRoutesProvider.php index f53b65278b..2719829bc6 100644 --- a/Neos.Flow/Classes/Mvc/Routing/RouteAnnotationRoutesProvider.php +++ b/Neos.Flow/Classes/Mvc/Routing/AttributeRoutesProvider.php @@ -1,5 +1,15 @@ $classNames */ public function __construct( @@ -25,18 +33,6 @@ public function __construct( ) { } - /** - * @param array $options - */ - public function withOptions(array $options): RoutesProviderInterface - { - return new RouteAnnotationRoutesProvider ( - $this->reflectionService, - $this->objectManager, - $options['classNames'] ?? [], - ); - } - public function getRoutes(): Routes { $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 084d53e718..2e473e01dc 100644 --- a/Neos.Flow/Classes/Mvc/Routing/ConfigurationRoutesProvider.php +++ b/Neos.Flow/Classes/Mvc/Routing/ConfigurationRoutesProvider.php @@ -23,17 +23,14 @@ public function getRoutes(): 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 && array_key_exists('providerOptions',$routeConfiguration)) { - $provider = $provider->withOptions($routeConfiguration['providerOptions']); + 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['provider'], RoutesProviderFactoryInterface::class), 1710784630); } - if ($provider instanceof RoutesProviderInterface) { - foreach ($provider->getRoutes() as $route) { - $routes[] = $route; - } - } else { - throw new \InvalidArgumentException(sprintf('configured route provider "%s" does not implent the "%s"', $routeConfiguration['provider'], RoutesProviderInterface::class), 1710784630); + $provider = $providerFactory->createRoutesProvider($routeConfiguration['providerOptions'] ?? []); + foreach ($provider->getRoutes() as $route) { + $routes[] = $route; } } else { $routes[] = Route::fromConfiguration($routeConfiguration); 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 @@ $options - */ - public function withOptions(array $options): RoutesProviderInterface; -} diff --git a/Neos.Flow/Documentation/TheDefinitiveGuide/PartIII/Routing.rst b/Neos.Flow/Documentation/TheDefinitiveGuide/PartIII/Routing.rst index cd25af6c82..373c4e9cf7 100644 --- a/Neos.Flow/Documentation/TheDefinitiveGuide/PartIII/Routing.rst +++ b/Neos.Flow/Documentation/TheDefinitiveGuide/PartIII/Routing.rst @@ -729,8 +729,8 @@ 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 `provider` and (optional) `providerOptions` to generate the subroutes via the -`Neos\Flow\Mvc\Routing\RoutesProviderInterface` or the ``Neos\Flow\Mvc\Routing\RoutesProviderWithOptionsInterface`. +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 @@ -740,7 +740,7 @@ It also is possible to specify a `provider` and (optional) `providerOptions` to routes: Vendor.Example: position: 'before Neos.Neos' - provider: \Neos\Flow\Mvc\Routing\RouteAnnotationRoutesProvider + providerFactory: \Neos\Flow\Mvc\Routing\AttributeRoutesProviderFactory providerOptions: classNames: - Vendor\Example\Controller\ExampleController @@ -774,7 +774,7 @@ The ``Flow\Route`` attribute allows to define routes directly on the affected me } To find the annotation and tp specify the order of routes this has to be used together with the -`\Neos\Flow\Mvc\Routing\RouteAnnotationRoutesProvider` in Setting `Neos.Flow.mvs.routes` +`\Neos\Flow\Mvc\Routing\AttributeRoutesProviderFactory` as `providerFactory` in Setting `Neos.Flow.mvs.routes` .. code-block:: yaml @@ -784,7 +784,7 @@ To find the annotation and tp specify the order of routes this has to be used to routes: Vendor.Example: position: 'before Neos.Neos' - provider: \Neos\Flow\Mvc\Routing\RouteAnnotationRoutesProvider + providerFactory: \Neos\Flow\Mvc\Routing\AttributeRoutesProviderFactory providerOptions: classNames: - Vendor\Example\Controller\ExampleController diff --git a/Neos.Flow/Tests/Unit/Mvc/Routing/AnnotationRoutesProviderTest.php b/Neos.Flow/Tests/Unit/Mvc/Routing/AttributeRoutesProviderTest.php similarity index 85% rename from Neos.Flow/Tests/Unit/Mvc/Routing/AnnotationRoutesProviderTest.php rename to Neos.Flow/Tests/Unit/Mvc/Routing/AttributeRoutesProviderTest.php index 69376b86eb..b148e2c07c 100644 --- a/Neos.Flow/Tests/Unit/Mvc/Routing/AnnotationRoutesProviderTest.php +++ b/Neos.Flow/Tests/Unit/Mvc/Routing/AttributeRoutesProviderTest.php @@ -24,20 +24,21 @@ /** * Testcase for the MVC Web Routing Routes Class */ -class AnnotationRoutesProviderTest extends UnitTestCase +class AttributeRoutesProviderTest extends UnitTestCase { private ReflectionService|MockObject $mockReflectionService; private ObjectManagerInterface|MockObject $mockObjectManager; - private Routing\RouteAnnotationRoutesProvider $annotationRoutesProvider; + private Routing\AttributeRoutesProvider $annotationRoutesProvider; public function setUp(): void { $this->mockReflectionService = $this->createMock(ReflectionService::class); $this->mockObjectManager = $this->createMock(ObjectManagerInterface::class); - $this->annotationRoutesProvider = new Routing\RouteAnnotationRoutesProvider( + $this->annotationRoutesProvider = new Routing\AttributeRoutesProvider( $this->mockReflectionService, - $this->mockObjectManager + $this->mockObjectManager, + ['Vendor\Example\Controller\ExampleController'] ); } @@ -60,8 +61,6 @@ public function noAnnotationsYieldEmptyRoutes(): 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) @@ -116,7 +115,7 @@ public function routesFromAnnotationAreCreatedWhenClassNamesMatch(): void $this->assertEquals( Routes::create($expectedRoute1, $expectedRoute2), - $annotationRoutesProvider->getRoutes() + $this->annotationRoutesProvider->getRoutes() ); } @@ -125,13 +124,11 @@ public function routesFromAnnotationAreCreatedWhenClassNamesMatch(): void */ 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']); + ->willReturn(['Vendor\Other\Controller\ExampleController']); - $this->assertEquals([], iterator_to_array($annotationRoutesProvider->getRoutes()->getIterator())); + $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 b006542aec..190c10037e 100644 --- a/Neos.Flow/Tests/Unit/Mvc/Routing/ConfigurationRoutesProviderTest.php +++ b/Neos.Flow/Tests/Unit/Mvc/Routing/ConfigurationRoutesProviderTest.php @@ -86,24 +86,36 @@ public function configuredProvidersAreCalledToGenerateSubroutes(): void $configuration = [ [ 'name' => 'Routes provider without options', - 'provider' => 'Vendor\Example\RoutesProvider', + 'providerFactory' => 'Vendor\Example\RoutesProvider', ], [ 'name' => 'Routes provider with options', - 'provider' => 'Vendor\Example\RoutesProviderWithOptions', + 'providerFactory' => 'Vendor\Example\RoutesProviderWithOptions', 'providerOptions' => ['foo' => 'bar'], ], ]; $mockRoutesProvider = $this->createMock(Routing\RoutesProviderInterface::class); - $mockRoutesProviderWithOptions = $this->createMock(Routing\RoutesProviderWithOptionsInterface::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 ($mockRoutesProvider, $mockRoutesProviderWithOptions) { + function (string $name) use ($mockRoutesProviderFactory, $mockRoutesProviderWithOptionsFactory) { return match ($name) { - 'Vendor\Example\RoutesProvider' => $mockRoutesProvider, - 'Vendor\Example\RoutesProviderWithOptions' => $mockRoutesProviderWithOptions + 'Vendor\Example\RoutesProvider' => $mockRoutesProviderFactory, + 'Vendor\Example\RoutesProviderWithOptions' => $mockRoutesProviderWithOptionsFactory }; } ); @@ -121,8 +133,6 @@ function (string $name) use ($mockRoutesProvider, $mockRoutesProviderWithOptions $expectedRoute2->setUriPattern('route2/{@package}/{@controller}/{@action}(.{@format})'); $mockRoutesProvider->expects($this->once())->method('getRoutes')->willReturn(Routes::create($expectedRoute1)); - - $mockRoutesProviderWithOptions->expects($this->once())->method('withOptions')->with(['foo' => 'bar'])->willReturn($mockRoutesProviderWithOptions); $mockRoutesProviderWithOptions->expects($this->once())->method('getRoutes')->willReturn(Routes::create($expectedRoute2)); $expectedRoutes = Routes::create($expectedRoute1, $expectedRoute2); From 4236241c7b641c357ac291b23bab3b691834b83b Mon Sep 17 00:00:00 2001 From: Martin Ficzel Date: Mon, 18 Mar 2024 22:18:39 +0100 Subject: [PATCH 06/18] Apply suggestions from code review Co-authored-by: Bastian Waidelich --- Neos.Flow/Classes/Configuration/Loader/RoutesLoader.php | 2 +- Neos.Flow/Classes/Mvc/Routing/AttributeRoutesProvider.php | 2 +- Neos.Flow/Classes/Mvc/Routing/ConfigurationRoutesProvider.php | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Neos.Flow/Classes/Configuration/Loader/RoutesLoader.php b/Neos.Flow/Classes/Configuration/Loader/RoutesLoader.php index f029b08062..c3f5fb8da4 100644 --- a/Neos.Flow/Classes/Configuration/Loader/RoutesLoader.php +++ b/Neos.Flow/Classes/Configuration/Loader/RoutesLoader.php @@ -136,7 +136,7 @@ protected function mergeRoutesWithSubRoutes(array $packages, ApplicationContext } $mergedSubRoutesConfiguration = [$routeConfiguration]; foreach ($routeConfiguration['subRoutes'] as $subRouteKey => $subRouteOptions) { - if (isset($subRouteOptions['provider'])) { + if (isset($subRouteOptions['providerFactory'])) { $mergedRoutesConfiguration[] = $subRouteOptions; continue; } diff --git a/Neos.Flow/Classes/Mvc/Routing/AttributeRoutesProvider.php b/Neos.Flow/Classes/Mvc/Routing/AttributeRoutesProvider.php index 2719829bc6..288bc5de39 100644 --- a/Neos.Flow/Classes/Mvc/Routing/AttributeRoutesProvider.php +++ b/Neos.Flow/Classes/Mvc/Routing/AttributeRoutesProvider.php @@ -29,7 +29,7 @@ final class AttributeRoutesProvider implements RoutesProviderInterface public function __construct( public readonly ReflectionService $reflectionService, public readonly ObjectManagerInterface $objectManager, - public readonly array $classNames = [], + public readonly array $classNames, ) { } diff --git a/Neos.Flow/Classes/Mvc/Routing/ConfigurationRoutesProvider.php b/Neos.Flow/Classes/Mvc/Routing/ConfigurationRoutesProvider.php index 2e473e01dc..63d2d366f8 100644 --- a/Neos.Flow/Classes/Mvc/Routing/ConfigurationRoutesProvider.php +++ b/Neos.Flow/Classes/Mvc/Routing/ConfigurationRoutesProvider.php @@ -26,7 +26,7 @@ public function getRoutes(): Routes 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['provider'], RoutesProviderFactoryInterface::class), 1710784630); + 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) { From 95917f8388eaf0e2cfbf1071d94e196b10735117 Mon Sep 17 00:00:00 2001 From: Martin Ficzel Date: Fri, 22 Mar 2024 17:56:13 +0100 Subject: [PATCH 07/18] Apply suggestions from code review Co-authored-by: Marc Henry Schultz <85400359+mhsdesign@users.noreply.github.com> --- Neos.Flow/Classes/Annotations/Route.php | 4 +-- .../Mvc/Routing/AttributeRoutesProvider.php | 32 +++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/Neos.Flow/Classes/Annotations/Route.php b/Neos.Flow/Classes/Annotations/Route.php index b593419546..90d7621c7c 100644 --- a/Neos.Flow/Classes/Annotations/Route.php +++ b/Neos.Flow/Classes/Annotations/Route.php @@ -32,8 +32,8 @@ final class Route public function __construct( public readonly string $uriPattern, public readonly ?string $name = null, - public readonly ?array $httpMethods = null, - public readonly ?array $defaults = null, + public readonly array $httpMethods = [], + public readonly array $defaults = [], ) { } } diff --git a/Neos.Flow/Classes/Mvc/Routing/AttributeRoutesProvider.php b/Neos.Flow/Classes/Mvc/Routing/AttributeRoutesProvider.php index 288bc5de39..6d5582dd72 100644 --- a/Neos.Flow/Classes/Mvc/Routing/AttributeRoutesProvider.php +++ b/Neos.Flow/Classes/Mvc/Routing/AttributeRoutesProvider.php @@ -21,6 +21,37 @@ use Neos\Flow\Annotations as Flow; use Neos\Utility\Arrays; +/** + * Allows to annotate controller methods with route configurations + * + * Implementation: + * + * Flows routing configuration is declared via \@package, \@subpackage, \@controller and \@action (as well as the format \@format) + * 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. + * By convention and implementation of the default ActionController inside processRequest + * {@see \Neos\Flow\Mvc\Controller\ActionController::callActionMethod()} will be used to concatenate the "Action" suffix + * to the action name and invoke it internally with prepared method arguments. + * The \@action is just another routing value while the doest not really know about "actions" from the "outside" (dispatcher). + * + * Creating routes by annotation must make a few assumptions to work. + * 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. + * + * The routing annotation is placed at methods. + * It is validated that the annotated method ends with "Action" and a routing value with the suffix trimmed will be generated. + * Using the annotations on any controller makes the assumption that the controller will delegate the request to the dedicate + * action by depending "Action". + * This thesis is true for the ActionController. + * + * As discussed in https://discuss.neos.io/t/rfc-future-of-routing-mvc-in-flow/6535 we want to refactor the routing values + * to include the fully qualified controller name, so it can be easier generated without strong restrictions. + * Additionally, the action mapping should include its full name and be guaranteed to called. + * Either by invoking the action in the dispatcher or by documenting this feature as part of a implementation of a ControllerInterface + */ final class AttributeRoutesProvider implements RoutesProviderInterface { /** @@ -43,6 +74,7 @@ public function getRoutes(): Routes foreach ($this->classNames as $classNamePattern) { if (fnmatch($classNamePattern, $className, FNM_NOESCAPE)) { $includeClassName = true; + break; } } if (!$includeClassName) { From 8d073a8b902b9b590dc22ac02af53f8b162f8e6d Mon Sep 17 00:00:00 2001 From: Martin Ficzel Date: Fri, 22 Mar 2024 18:16:22 +0100 Subject: [PATCH 08/18] TASK: Remove `@format` from default route values --- Neos.Flow/Classes/Mvc/Routing/AttributeRoutesProvider.php | 5 ++--- .../Tests/Unit/Mvc/Routing/AttributeRoutesProviderTest.php | 2 -- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/Neos.Flow/Classes/Mvc/Routing/AttributeRoutesProvider.php b/Neos.Flow/Classes/Mvc/Routing/AttributeRoutesProvider.php index 6d5582dd72..edab1f73ae 100644 --- a/Neos.Flow/Classes/Mvc/Routing/AttributeRoutesProvider.php +++ b/Neos.Flow/Classes/Mvc/Routing/AttributeRoutesProvider.php @@ -26,7 +26,7 @@ * * Implementation: * - * Flows routing configuration is declared via \@package, \@subpackage, \@controller and \@action (as well as the format \@format) + * 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()} * @@ -34,7 +34,7 @@ * By convention and implementation of the default ActionController inside processRequest * {@see \Neos\Flow\Mvc\Controller\ActionController::callActionMethod()} will be used to concatenate the "Action" suffix * to the action name and invoke it internally with prepared method arguments. - * The \@action is just another routing value while the doest not really know about "actions" from the "outside" (dispatcher). + * The \@action is just another routing value while the dispatcher does not really know about "actions" from the "outside". * * Creating routes by annotation must make a few assumptions to work. * As not every FQ class name is representable via the routing configuration (e.g. the class has to end with "Controller"), @@ -119,7 +119,6 @@ public function getRoutes(): Routes '@subpackage' => $subPackage, '@controller' => $controller, '@action' => $action, - '@format' => 'html' ], $annotation->defaults ?? [] ) diff --git a/Neos.Flow/Tests/Unit/Mvc/Routing/AttributeRoutesProviderTest.php b/Neos.Flow/Tests/Unit/Mvc/Routing/AttributeRoutesProviderTest.php index b148e2c07c..824640d71a 100644 --- a/Neos.Flow/Tests/Unit/Mvc/Routing/AttributeRoutesProviderTest.php +++ b/Neos.Flow/Tests/Unit/Mvc/Routing/AttributeRoutesProviderTest.php @@ -97,7 +97,6 @@ public function routesFromAnnotationAreCreatedWhenClassNamesMatch(): void '@subpackage' => null, '@controller' => 'Example', '@action' => 'special', - '@format' => 'html' ]); $expectedRoute2 = new Route(); @@ -108,7 +107,6 @@ public function routesFromAnnotationAreCreatedWhenClassNamesMatch(): void '@subpackage' => null, '@controller' => 'Example', '@action' => 'special', - '@format' => 'html', 'test' => 'foo' ]); $expectedRoute2->setHttpMethods(['get', 'post']); From 1a0e2f34ec83b24cf58568f941f18aa3ef554650 Mon Sep 17 00:00:00 2001 From: Martin Ficzel Date: Fri, 22 Mar 2024 18:21:44 +0100 Subject: [PATCH 09/18] TASK: Filter out '@package', '@subpackage', '@controller', '@action' from defaults passed via annotation The @format option seems to be necessary --- .../Classes/Mvc/Routing/AttributeRoutesProvider.php | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/Neos.Flow/Classes/Mvc/Routing/AttributeRoutesProvider.php b/Neos.Flow/Classes/Mvc/Routing/AttributeRoutesProvider.php index edab1f73ae..18189aa4be 100644 --- a/Neos.Flow/Classes/Mvc/Routing/AttributeRoutesProvider.php +++ b/Neos.Flow/Classes/Mvc/Routing/AttributeRoutesProvider.php @@ -108,7 +108,11 @@ public function getRoutes(): Routes if ($annotation instanceof Flow\Route) { $controller = substr($controllerName, 0, -10); $action = substr($methodName, 0, -6); - + $defaults = array_filter( + $annotation->defaults ?? [], + fn($key) => !in_array($key, ['@package', '@subpackage', '@controller', '@action']), + ARRAY_FILTER_USE_KEY + ); $configuration = [ 'name' => $controllerPackageKey . ' :: ' . $controller . ' :: ' . ($annotation->name ?: $action), 'uriPattern' => $annotation->uriPattern, @@ -119,8 +123,9 @@ public function getRoutes(): Routes '@subpackage' => $subPackage, '@controller' => $controller, '@action' => $action, + '@format' => 'html' ], - $annotation->defaults ?? [] + $defaults ) ]; $routes[] = Route::fromConfiguration($configuration); From bceedfc5ac645e8a8b25831a3e29c06002248ea7 Mon Sep 17 00:00:00 2001 From: Martin Ficzel Date: Fri, 22 Mar 2024 18:41:32 +0100 Subject: [PATCH 10/18] TASK: Adjust documentation to use uppercase HTTP-Verbs and explain Attribute arguments --- Neos.Flow/Classes/Annotations/Route.php | 6 ++++++ .../Documentation/TheDefinitiveGuide/PartIII/Routing.rst | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/Neos.Flow/Classes/Annotations/Route.php b/Neos.Flow/Classes/Annotations/Route.php index 90d7621c7c..6a7d0a9a14 100644 --- a/Neos.Flow/Classes/Annotations/Route.php +++ b/Neos.Flow/Classes/Annotations/Route.php @@ -29,6 +29,12 @@ #[\Attribute(\Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)] final class Route { + /** + * @param string $uriPattern The uri-pattern for the route without leading '/' + * @param string|null $name (default null) The name ouf the route as it shows up in the route:list command + * @param array $httpMethods (default []) List of http verbs like 'GET', 'POST', 'PUT', 'DELETE', if not specified 'any' is used + * @param array $defaults (default []) Values to set for this route. Dan define arguments but also specify the `@format` if required. + */ public function __construct( public readonly string $uriPattern, public readonly ?string $name = null, diff --git a/Neos.Flow/Documentation/TheDefinitiveGuide/PartIII/Routing.rst b/Neos.Flow/Documentation/TheDefinitiveGuide/PartIII/Routing.rst index 373c4e9cf7..f03a44b18b 100644 --- a/Neos.Flow/Documentation/TheDefinitiveGuide/PartIII/Routing.rst +++ b/Neos.Flow/Documentation/TheDefinitiveGuide/PartIII/Routing.rst @@ -761,7 +761,7 @@ The ``Flow\Route`` attribute allows to define routes directly on the affected me class ExampleController extends ActionController { - #[Flow\Route(uriPattern:'my/path', httpMethods: ['get'])] + #[Flow\Route(uriPattern:'my/path', httpMethods: ['GET'])] public function someAction(): void { } From 43aa1c55794cf90be0bfa12c57e8a9a720076440 Mon Sep 17 00:00:00 2001 From: Martin Ficzel Date: Fri, 22 Mar 2024 18:50:25 +0100 Subject: [PATCH 11/18] TASK: Check for {@controller} or {@action} in path --- Neos.Flow/Classes/Annotations/Route.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Neos.Flow/Classes/Annotations/Route.php b/Neos.Flow/Classes/Annotations/Route.php index 6a7d0a9a14..a10893a5a3 100644 --- a/Neos.Flow/Classes/Annotations/Route.php +++ b/Neos.Flow/Classes/Annotations/Route.php @@ -30,7 +30,7 @@ final class Route { /** - * @param string $uriPattern The uri-pattern for the route without leading '/' + * @param string $uriPattern The uri-pattern for the route without leading '/'. Must not contain `{@action}` or `{@controller}`. * @param string|null $name (default null) The name ouf the route as it shows up in the route:list command * @param array $httpMethods (default []) List of http verbs like 'GET', 'POST', 'PUT', 'DELETE', if not specified 'any' is used * @param array $defaults (default []) Values to set for this route. Dan define arguments but also specify the `@format` if required. @@ -41,5 +41,8 @@ public function __construct( public readonly array $httpMethods = [], public readonly array $defaults = [], ) { + if (str_contains($uriPattern, '{@controller}') || str_contains($uriPattern, '{@action}')) { + throw new \DomainException(sprintf('It is not allowed to override {@controller} or {@action} in route annotations "%s"', $uriPattern), 1711129634); + } } } From ff5d05d1b00b420bd35b94ab589c12ed97cc71f4 Mon Sep 17 00:00:00 2001 From: Martin Ficzel Date: Fri, 22 Mar 2024 18:58:12 +0100 Subject: [PATCH 12/18] TASK: Move check for '@package', '@subpackage', '@controller', '@action' in defaults to routes class --- Neos.Flow/Classes/Annotations/Route.php | 3 +++ Neos.Flow/Classes/Mvc/Routing/AttributeRoutesProvider.php | 7 +------ 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/Neos.Flow/Classes/Annotations/Route.php b/Neos.Flow/Classes/Annotations/Route.php index a10893a5a3..24d182c17b 100644 --- a/Neos.Flow/Classes/Annotations/Route.php +++ b/Neos.Flow/Classes/Annotations/Route.php @@ -44,5 +44,8 @@ public function __construct( if (str_contains($uriPattern, '{@controller}') || str_contains($uriPattern, '{@action}')) { throw new \DomainException(sprintf('It is not allowed to override {@controller} or {@action} in route annotations "%s"', $uriPattern), 1711129634); } + if (in_array(array_keys($defaults), ['@package', '@subpackage', '@controller', '@action'])) { + throw new \DomainException(sprintf('It is not allowed to override @package, @controller, @subpackage and @action in route annotation defaults "%s"', $uriPattern), 1711129638); + } } } diff --git a/Neos.Flow/Classes/Mvc/Routing/AttributeRoutesProvider.php b/Neos.Flow/Classes/Mvc/Routing/AttributeRoutesProvider.php index 18189aa4be..0086f09c9f 100644 --- a/Neos.Flow/Classes/Mvc/Routing/AttributeRoutesProvider.php +++ b/Neos.Flow/Classes/Mvc/Routing/AttributeRoutesProvider.php @@ -108,11 +108,6 @@ public function getRoutes(): Routes if ($annotation instanceof Flow\Route) { $controller = substr($controllerName, 0, -10); $action = substr($methodName, 0, -6); - $defaults = array_filter( - $annotation->defaults ?? [], - fn($key) => !in_array($key, ['@package', '@subpackage', '@controller', '@action']), - ARRAY_FILTER_USE_KEY - ); $configuration = [ 'name' => $controllerPackageKey . ' :: ' . $controller . ' :: ' . ($annotation->name ?: $action), 'uriPattern' => $annotation->uriPattern, @@ -125,7 +120,7 @@ public function getRoutes(): Routes '@action' => $action, '@format' => 'html' ], - $defaults + $annotation->defaults ?? [] ) ]; $routes[] = Route::fromConfiguration($configuration); From 360a031237fa1c0d49b4b3d27c0f8f13fce0ade1 Mon Sep 17 00:00:00 2001 From: Martin Ficzel Date: Fri, 22 Mar 2024 18:59:53 +0100 Subject: [PATCH 13/18] TASK: Make test green again --- .../Tests/Unit/Mvc/Routing/AttributeRoutesProviderTest.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Neos.Flow/Tests/Unit/Mvc/Routing/AttributeRoutesProviderTest.php b/Neos.Flow/Tests/Unit/Mvc/Routing/AttributeRoutesProviderTest.php index 824640d71a..22b7de2560 100644 --- a/Neos.Flow/Tests/Unit/Mvc/Routing/AttributeRoutesProviderTest.php +++ b/Neos.Flow/Tests/Unit/Mvc/Routing/AttributeRoutesProviderTest.php @@ -97,6 +97,7 @@ public function routesFromAnnotationAreCreatedWhenClassNamesMatch(): void '@subpackage' => null, '@controller' => 'Example', '@action' => 'special', + '@format' => 'html', ]); $expectedRoute2 = new Route(); @@ -107,7 +108,8 @@ public function routesFromAnnotationAreCreatedWhenClassNamesMatch(): void '@subpackage' => null, '@controller' => 'Example', '@action' => 'special', - 'test' => 'foo' + '@format' => 'html', + 'test' => 'foo', ]); $expectedRoute2->setHttpMethods(['get', 'post']); From 158d4dcfe2d789bf50e062269af29da2cfd32232 Mon Sep 17 00:00:00 2001 From: mhsdesign <85400359+mhsdesign@users.noreply.github.com> Date: Wed, 27 Mar 2024 10:14:34 +0100 Subject: [PATCH 14/18] TASK: Add (failing) unit test for `#[Flow\Route]` constraints --- .../Mvc/Routing/Dto/RouteAnnotationTest.php | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 Neos.Flow/Tests/Unit/Mvc/Routing/Dto/RouteAnnotationTest.php 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..ae1b605c39 --- /dev/null +++ b/Neos.Flow/Tests/Unit/Mvc/Routing/Dto/RouteAnnotationTest.php @@ -0,0 +1,100 @@ +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 httpMethodIsUpperCase() + { + /** @see \Neos\Flow\Mvc\Routing\Route::matches() where we do case-sensitive comparison */ + $route = new Flow\Route( + uriPattern: 'my/other/path', + httpMethods: ['post'] + ); + self::assertSame(['POST'], $route->httpMethods); + } + + /** + * @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(1711529595); + + new Flow\Route(uriPattern: ''); + } + + /** + * @test + */ + public function httpMethodMustNotBeEmptyString() + { + $this->expectExceptionCode(1711530485); + + new Flow\Route(uriPattern: 'foo', httpMethods: ['']); + } +} From 794ac4a3fddae0a4df63571cee15588c5c06c8d6 Mon Sep 17 00:00:00 2001 From: mhsdesign <85400359+mhsdesign@users.noreply.github.com> Date: Wed, 27 Mar 2024 10:50:28 +0100 Subject: [PATCH 15/18] TASK: Fix constraints for `#[Flow\Route]` annotation --- Neos.Flow/Classes/Annotations/Route.php | 34 ++++++++++++++----- .../Routing/AttributeRoutesProviderTest.php | 9 +++-- .../Mvc/Routing/Dto/RouteAnnotationTest.php | 29 ++++++++-------- 3 files changed, 47 insertions(+), 25 deletions(-) diff --git a/Neos.Flow/Classes/Annotations/Route.php b/Neos.Flow/Classes/Annotations/Route.php index 24d182c17b..5875699867 100644 --- a/Neos.Flow/Classes/Annotations/Route.php +++ b/Neos.Flow/Classes/Annotations/Route.php @@ -30,22 +30,38 @@ final class Route { /** - * @param string $uriPattern The uri-pattern for the route without leading '/'. Must not contain `{@action}` or `{@controller}`. - * @param string|null $name (default null) The name ouf the route as it shows up in the route:list command - * @param array $httpMethods (default []) List of http verbs like 'GET', 'POST', 'PUT', 'DELETE', if not specified 'any' is used - * @param array $defaults (default []) Values to set for this route. Dan define arguments but also specify the `@format` if required. + * 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 '/'. + * @param string $name (default null) The name ouf the route as it shows up in the route:list command + * @param array $httpMethods (default []) List of http verbs like 'GET', 'POST', 'PUT', 'DELETE', if not specified any will be matched + * @param array $defaults (default []) Values to set for this route. */ public function __construct( public readonly string $uriPattern, - public readonly ?string $name = null, + public readonly string $name = '', public readonly array $httpMethods = [], public readonly array $defaults = [], ) { - if (str_contains($uriPattern, '{@controller}') || str_contains($uriPattern, '{@action}')) { - throw new \DomainException(sprintf('It is not allowed to override {@controller} or {@action} in route annotations "%s"', $uriPattern), 1711129634); + 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); + } } - if (in_array(array_keys($defaults), ['@package', '@subpackage', '@controller', '@action'])) { - throw new \DomainException(sprintf('It is not allowed to override @package, @controller, @subpackage and @action in route annotation defaults "%s"', $uriPattern), 1711129638); + 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/Tests/Unit/Mvc/Routing/AttributeRoutesProviderTest.php b/Neos.Flow/Tests/Unit/Mvc/Routing/AttributeRoutesProviderTest.php index 22b7de2560..0a63ca722b 100644 --- a/Neos.Flow/Tests/Unit/Mvc/Routing/AttributeRoutesProviderTest.php +++ b/Neos.Flow/Tests/Unit/Mvc/Routing/AttributeRoutesProviderTest.php @@ -76,7 +76,12 @@ public function routesFromAnnotationAreCreatedWhenClassNamesMatch(): void ->with('Vendor\Example\Controller\ExampleController', 'specialAction', Flow\Route::class) ->willReturn([ new Flow\Route(uriPattern: 'my/path'), - new Flow\Route(uriPattern: 'my/other/path', name: 'specialRoute', defaults: ['test' => 'foo'], httpMethods: ['get', 'post']) + new Flow\Route( + uriPattern: 'my/other/path', + name: 'specialRoute', + httpMethods: ['GET', 'POST'], + defaults: ['test' => 'foo'] + ) ]); $this->mockObjectManager->expects($this->once()) @@ -111,7 +116,7 @@ public function routesFromAnnotationAreCreatedWhenClassNamesMatch(): void '@format' => 'html', 'test' => 'foo', ]); - $expectedRoute2->setHttpMethods(['get', 'post']); + $expectedRoute2->setHttpMethods(['GET', 'POST']); $this->assertEquals( Routes::create($expectedRoute1, $expectedRoute2), diff --git a/Neos.Flow/Tests/Unit/Mvc/Routing/Dto/RouteAnnotationTest.php b/Neos.Flow/Tests/Unit/Mvc/Routing/Dto/RouteAnnotationTest.php index ae1b605c39..5069ab6e20 100644 --- a/Neos.Flow/Tests/Unit/Mvc/Routing/Dto/RouteAnnotationTest.php +++ b/Neos.Flow/Tests/Unit/Mvc/Routing/Dto/RouteAnnotationTest.php @@ -35,19 +35,6 @@ public function simpleRoutes() self::assertSame(['test' => 'foo'], $route->defaults); } - /** - * @test - */ - public function httpMethodIsUpperCase() - { - /** @see \Neos\Flow\Mvc\Routing\Route::matches() where we do case-sensitive comparison */ - $route = new Flow\Route( - uriPattern: 'my/other/path', - httpMethods: ['post'] - ); - self::assertSame(['POST'], $route->httpMethods); - } - /** * @test */ @@ -83,7 +70,7 @@ public function uriPatternMustNotStartWithLeadingSlash() */ public function uriPatternMustNotBeEmpty() { - $this->expectExceptionCode(1711529595); + $this->expectExceptionCode(1711529592); new Flow\Route(uriPattern: ''); } @@ -97,4 +84,18 @@ public function httpMethodMustNotBeEmptyString() 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'] + ); + } } From f8f90df75e1264f2286c2467817e6affa507bef7 Mon Sep 17 00:00:00 2001 From: mhsdesign <85400359+mhsdesign@users.noreply.github.com> Date: Wed, 27 Mar 2024 10:59:29 +0100 Subject: [PATCH 16/18] TASK: Adjust documentation of `#[Flow\Route]` --- Neos.Flow/Classes/Annotations/Route.php | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/Neos.Flow/Classes/Annotations/Route.php b/Neos.Flow/Classes/Annotations/Route.php index 5875699867..4d9e8d86d3 100644 --- a/Neos.Flow/Classes/Annotations/Route.php +++ b/Neos.Flow/Classes/Annotations/Route.php @@ -27,7 +27,7 @@ * @Target({"METHOD"}) */ #[\Attribute(\Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)] -final class Route +final readonly class Route { /** * Magic route values cannot be set as default nor be contained as segments like `{\@action}` or `{\@controller}` in the uriPattern. @@ -36,16 +36,16 @@ final class Route private const PRESERVED_DEFAULTS = ['@package', '@subpackage', '@controller', '@action']; /** - * @param string $uriPattern The uri-pattern for the route without leading '/'. - * @param string $name (default null) The name ouf the route as it shows up in the route:list command - * @param array $httpMethods (default []) List of http verbs like 'GET', 'POST', 'PUT', 'DELETE', if not specified any will be matched - * @param array $defaults (default []) Values to set for this route. + * @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 readonly string $uriPattern, - public readonly string $name = '', - public readonly array $httpMethods = [], - public readonly array $defaults = [], + 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); From ee8a8127e1d77908b5f44786a1ba4d6f2500c583 Mon Sep 17 00:00:00 2001 From: mhsdesign <85400359+mhsdesign@users.noreply.github.com> Date: Wed, 27 Mar 2024 11:52:46 +0100 Subject: [PATCH 17/18] TASK: Restrict `#[Flow\Route]` to `ActionController`'s see https://github.com/neos/flow-development-collection/pull/3325#issuecomment-2022198044 see https://github.com/neos/flow-development-collection/issues/3335 --- .../Mvc/Routing/AttributeRoutesProvider.php | 50 ++++++++++++------- .../TheDefinitiveGuide/PartIII/Routing.rst | 5 +- .../Routing/AttributeRoutesProviderTest.php | 21 +++++--- 3 files changed, 50 insertions(+), 26 deletions(-) diff --git a/Neos.Flow/Classes/Mvc/Routing/AttributeRoutesProvider.php b/Neos.Flow/Classes/Mvc/Routing/AttributeRoutesProvider.php index 0086f09c9f..df77ab0840 100644 --- a/Neos.Flow/Classes/Mvc/Routing/AttributeRoutesProvider.php +++ b/Neos.Flow/Classes/Mvc/Routing/AttributeRoutesProvider.php @@ -14,6 +14,7 @@ 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; @@ -24,33 +25,43 @@ /** * Allows to annotate controller methods with route configurations * - * Implementation: + * 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. - * By convention and implementation of the default ActionController inside processRequest - * {@see \Neos\Flow\Mvc\Controller\ActionController::callActionMethod()} will be used to concatenate the "Action" suffix - * to the action name and invoke it internally with prepared method arguments. - * The \@action is just another routing value while the dispatcher does not really know about "actions" from the "outside". + * 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. - * As not every FQ class name is representable via the routing configuration (e.g. the class has to end with "Controller"), + * 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. * - * The routing annotation is placed at methods. - * It is validated that the annotated method ends with "Action" and a routing value with the suffix trimmed will be generated. - * Using the annotations on any controller makes the assumption that the controller will delegate the request to the dedicate - * action by depending "Action". - * This thesis is true for the ActionController. + * 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 * - * As discussed in https://discuss.neos.io/t/rfc-future-of-routing-mvc-in-flow/6535 we want to refactor the routing values - * to include the fully qualified controller name, so it can be easier generated without strong restrictions. - * Additionally, the action mapping should include its full name and be guaranteed to called. - * Either by invoking the action in the dispatcher or by documenting this feature as part of a implementation of a ControllerInterface + * TODO for a future scope of `Flow\Action` see {@link https://github.com/neos/flow-development-collection/issues/3335} */ final class AttributeRoutesProvider implements RoutesProviderInterface { @@ -80,6 +91,11 @@ public function getRoutes(): Routes 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); diff --git a/Neos.Flow/Documentation/TheDefinitiveGuide/PartIII/Routing.rst b/Neos.Flow/Documentation/TheDefinitiveGuide/PartIII/Routing.rst index f03a44b18b..2c8c708b45 100644 --- a/Neos.Flow/Documentation/TheDefinitiveGuide/PartIII/Routing.rst +++ b/Neos.Flow/Documentation/TheDefinitiveGuide/PartIII/Routing.rst @@ -753,6 +753,7 @@ 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 @@ -774,7 +775,7 @@ The ``Flow\Route`` attribute allows to define routes directly on the affected me } 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.mvs.routes` +`\Neos\Flow\Mvc\Routing\AttributeRoutesProviderFactory` as `providerFactory` in Setting `Neos.Flow.mvc.routes` .. code-block:: yaml @@ -787,7 +788,7 @@ To find the annotation and tp specify the order of routes this has to be used to providerFactory: \Neos\Flow\Mvc\Routing\AttributeRoutesProviderFactory providerOptions: classNames: - - Vendor\Example\Controller\ExampleController + - 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 index 0a63ca722b..4ee9cd3459 100644 --- a/Neos.Flow/Tests/Unit/Mvc/Routing/AttributeRoutesProviderTest.php +++ b/Neos.Flow/Tests/Unit/Mvc/Routing/AttributeRoutesProviderTest.php @@ -38,7 +38,7 @@ public function setUp(): void $this->annotationRoutesProvider = new Routing\AttributeRoutesProvider( $this->mockReflectionService, $this->mockObjectManager, - ['Vendor\Example\Controller\ExampleController'] + ['Vendor\\Example\\Controller\\*'] ); } @@ -61,19 +61,26 @@ public function noAnnotationsYieldEmptyRoutes(): void */ 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(['Vendor\Example\Controller\ExampleController']); + ->willReturn([$exampleFqnControllerName]); $this->mockReflectionService->expects($this->once()) ->method('getMethodsAnnotatedWith') - ->with('Vendor\Example\Controller\ExampleController', Flow\Route::class) + ->with($exampleFqnControllerName, Flow\Route::class) ->willReturn(['specialAction']); $this->mockReflectionService->expects($this->once()) ->method('getMethodAnnotations') - ->with('Vendor\Example\Controller\ExampleController', 'specialAction', Flow\Route::class) + ->with($exampleFqnControllerName, 'specialAction', Flow\Route::class) ->willReturn([ new Flow\Route(uriPattern: 'my/path'), new Flow\Route( @@ -86,12 +93,12 @@ public function routesFromAnnotationAreCreatedWhenClassNamesMatch(): void $this->mockObjectManager->expects($this->once()) ->method('getCaseSensitiveObjectName') - ->with('Vendor\Example\Controller\ExampleController') - ->willReturn('Vendor\Example\Controller\ExampleController'); + ->with($exampleFqnControllerName) + ->willReturn($exampleFqnControllerName); $this->mockObjectManager->expects($this->once()) ->method('getPackageKeyByObjectName') - ->with('Vendor\Example\Controller\ExampleController') + ->with($exampleFqnControllerName) ->willReturn('Vendor.Example'); $expectedRoute1 = new Route(); From 77fcaeaffc85a9edf9299467769dbbf95a7db5de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20M=C3=BCller?= Date: Thu, 28 Mar 2024 11:44:59 +0100 Subject: [PATCH 18/18] Clarify configuration keys --- .../Documentation/TheDefinitiveGuide/PartIII/Routing.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Neos.Flow/Documentation/TheDefinitiveGuide/PartIII/Routing.rst b/Neos.Flow/Documentation/TheDefinitiveGuide/PartIII/Routing.rst index 2c8c708b45..7821c8aac8 100644 --- a/Neos.Flow/Documentation/TheDefinitiveGuide/PartIII/Routing.rst +++ b/Neos.Flow/Documentation/TheDefinitiveGuide/PartIII/Routing.rst @@ -738,7 +738,7 @@ It also is possible to specify a `providerFactory` and (optional) `providerOptio Flow: mvc: routes: - Vendor.Example: + Vendor.Example.attributes: position: 'before Neos.Neos' providerFactory: \Neos\Flow\Mvc\Routing\AttributeRoutesProviderFactory providerOptions: @@ -783,7 +783,7 @@ To find the annotation and tp specify the order of routes this has to be used to Flow: mvc: routes: - Vendor.Example: + Vendor.Example.attributes: position: 'before Neos.Neos' providerFactory: \Neos\Flow\Mvc\Routing\AttributeRoutesProviderFactory providerOptions: