Skip to content

Commit

Permalink
TASK: Refactor to include annotation routes via provider and `provi…
Browse files Browse the repository at this point in the history
…derOptions` in Settings `Neos.Flow.mvc.routes`

```
Neos:
  Flow:
    mvc:
      routes:
        Vendor.Example:
          provider: \Neos\Flow\Mvc\Routing\RouteAnnotationRoutesProvider
          providerOptions:
            classNames:
              - Vendor\Example\Controller\ExampleController
```
  • Loading branch information
mficzel committed Mar 15, 2024
1 parent 5ddd46a commit 0d17819
Show file tree
Hide file tree
Showing 8 changed files with 122 additions and 49 deletions.
20 changes: 16 additions & 4 deletions Neos.Flow/Classes/Configuration/Loader/RoutesLoader.php
Original file line number Diff line number Diff line change
Expand Up @@ -84,20 +84,28 @@ 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'];
}
if (isset($routeFromSettings['suffix'])) {
$subRoutesConfiguration['suffix'] = $routeFromSettings['suffix'];
}
$routeDefinitions[] = [
'name' => $packageKey,
'name' => $configurationKey,
'uriPattern' => '<' . $subRoutesName . '>',
'subRoutes' => [
$subRoutesName => $subRoutesConfiguration
Expand Down Expand Up @@ -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);
}
Expand Down
17 changes: 0 additions & 17 deletions Neos.Flow/Classes/Mvc/Routing/CombinedRoutesProvider.php

This file was deleted.

22 changes: 20 additions & 2 deletions Neos.Flow/Classes/Mvc/Routing/ConfigurationRoutesProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> $classNames
*/
public function __construct(
public readonly ReflectionService $reflectionService,
public readonly ObjectManagerInterface $objectManager,
public readonly array $classNames = [],
) {
}

/**
* @param array<string, mixed> $options
* @return $this
*/
public function withOptions(array $options): static
{
return new static(

Check failure on line 34 in Neos.Flow/Classes/Mvc/Routing/RouteAnnotationRoutesProvider.php

View workflow job for this annotation

GitHub Actions / PHP 8.2 Test static analysis (deps: highest)

Method Neos\Flow\Mvc\Routing\RouteAnnotationRoutesProvider::withOptions() should return $this(Neos\Flow\Mvc\Routing\RouteAnnotationRoutesProvider) but returns static(Neos\Flow\Mvc\Routing\RouteAnnotationRoutesProvider).

Check failure on line 34 in Neos.Flow/Classes/Mvc/Routing/RouteAnnotationRoutesProvider.php

View workflow job for this annotation

GitHub Actions / PHP 8.2 Test static analysis (deps: highest)

Unsafe usage of 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);
Expand All @@ -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);
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

declare(strict_types=1);

namespace Neos\Flow\Mvc\Routing;

/**
* Supplier for lazily fetching the routes for the router.
*
* This layer of abstraction avoids having to parse the routes for every request.
* The router will only request the routes if it comes across a route it hasn't seen (i.e. cached) before.
*
* @internal
*/
interface RoutesProviderWithOptionsInterface extends RoutesProviderInterface
{
/**
* @param array<string, mixed> $options
*/
public function withOptions(array $options): static;
}
2 changes: 1 addition & 1 deletion Neos.Flow/Classes/Reflection/ReflectionService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
2 changes: 1 addition & 1 deletion Neos.Flow/Configuration/Objects.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
25 changes: 21 additions & 4 deletions Neos.Flow/Tests/Unit/Mvc/Routing/AnnotationRoutesProviderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
Expand All @@ -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)
Expand Down Expand Up @@ -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());
}
}

0 comments on commit 0d17819

Please sign in to comment.