Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

FEATURE: Add Flow\Route Attribute/Annotation #3325

Merged
merged 18 commits into from
Mar 28, 2024
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
5ddd46a
FEATURE: Add `Flow\Route` Attribute/Annotation
mficzel Mar 3, 2024
6877a05
TASK: Refactor to include annotation routes via `provider` and `provi…
mficzel Mar 15, 2024
e0a8e4a
DOCS: Document `Flow\Route` annotations and the Settings `Neos.Flow.m…
mficzel Mar 18, 2024
4e635ca
TASK: Adjust tests for AnnotationRoutesProvider and ConfigurationRout…
mficzel Mar 18, 2024
adaf786
Task: Refactor RouteProviderWithOptions interface RouteProviderFactor…
mficzel Mar 18, 2024
4236241
Apply suggestions from code review
mficzel Mar 18, 2024
95917f8
Apply suggestions from code review
mficzel Mar 22, 2024
8d073a8
TASK: Remove `@format` from default route values
mficzel Mar 22, 2024
1a0e2f3
TASK: Filter out '@package', '@subpackage', '@controller', '@action' …
mficzel Mar 22, 2024
bceedfc
TASK: Adjust documentation to use uppercase HTTP-Verbs and explain At…
mficzel Mar 22, 2024
43aa1c5
TASK: Check for {@controller} or {@action} in path
mficzel Mar 22, 2024
ff5d05d
TASK: Move check for '@package', '@subpackage', '@controller', '@acti…
mficzel Mar 22, 2024
360a031
TASK: Make test green again
mficzel Mar 22, 2024
158d4dc
TASK: Add (failing) unit test for `#[Flow\Route]` constraints
mhsdesign Mar 27, 2024
794ac4a
TASK: Fix constraints for `#[Flow\Route]` annotation
mhsdesign Mar 27, 2024
f8f90df
TASK: Adjust documentation of `#[Flow\Route]`
mhsdesign Mar 27, 2024
ee8a812
TASK: Restrict `#[Flow\Route]` to `ActionController`'s
mhsdesign Mar 27, 2024
77fcaea
Clarify configuration keys
kitsunet Mar 28, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions Neos.Flow/Classes/Annotations/Route.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);

namespace Neos\Flow\Annotations;

/*
* This file is part of the Neos.Flow package.
*
* (c) Contributors of the Neos Project - www.neos.io
*
* This package is Open Source Software. For the full copyright and license
* information, please view the LICENSE file which was distributed with this
* source code.
*/

use Doctrine\Common\Annotations\Annotation\NamedArgumentConstructor;

/**
* Adds a route configuration to a method
*
* This is a convenient way to add routes in project code
* but should not be used in libraries/packages that shall be
* configured for different use cases.
*
* @Annotation
* @NamedArgumentConstructor
* @Target({"METHOD"})
*/
#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
final 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.
mhsdesign marked this conversation as resolved.
Show resolved Hide resolved
*/
public function __construct(
public readonly string $uriPattern,
public readonly ?string $name = null,
mficzel marked this conversation as resolved.
Show resolved Hide resolved
public readonly array $httpMethods = [],
public readonly array $defaults = [],
) {
if (str_contains($uriPattern, '{@controller}') || str_contains($uriPattern, '{@action}')) {
mhsdesign marked this conversation as resolved.
Show resolved Hide resolved
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);
}
}
mhsdesign marked this conversation as resolved.
Show resolved Hide resolved
}
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['providerFactory'])) {
$routeDefinitions[] = [
'name' => $configurationKey,
'providerFactory' => $routeFromSettings['providerFactory'],
'providerOptions' => $routeFromSettings['providerOptions'] ?? [],
];
continue;
mhsdesign marked this conversation as resolved.
Show resolved Hide resolved
}
$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['providerFactory'])) {
$mergedRoutesConfiguration[] = $subRouteOptions;
continue;
}
if (!isset($subRouteOptions['package'])) {
throw new ParseErrorException(sprintf('Missing package configuration for SubRoute in Route "%s".', ($routeConfiguration['name'] ?? 'unnamed Route')), 1318414040);
}
Expand Down
133 changes: 133 additions & 0 deletions Neos.Flow/Classes/Mvc/Routing/AttributeRoutesProvider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
<?php

/*
* This file is part of the Neos.Flow package.
*
* (c) Contributors of the Neos Project - www.neos.io
*
* This package is Open Source Software. For the full copyright and license
* information, please view the LICENSE file which was distributed with this
* source code.
*/

declare(strict_types=1);

namespace Neos\Flow\Mvc\Routing;

use Neos\Flow\Mvc\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;

mficzel marked this conversation as resolved.
Show resolved Hide resolved
/**
* Allows to annotate controller methods with route configurations
*
* 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".
*
* 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
{
/**
* @param array<string> $classNames
*/
public function __construct(
public readonly ReflectionService $reflectionService,
public readonly ObjectManagerInterface $objectManager,
public readonly array $classNames,
) {
}

public function getRoutes(): Routes
{
$routes = [];
$annotatedClasses = $this->reflectionService->getClassesContainingMethodsAnnotatedWith(Flow\Route::class);

foreach ($annotatedClasses as $className) {
$includeClassName = false;
foreach ($this->classNames as $classNamePattern) {
if (fnmatch($classNamePattern, $className, FNM_NOESCAPE)) {
$includeClassName = true;
break;
}
}
if (!$includeClassName) {
continue;
}
$controllerObjectName = $this->objectManager->getCaseSensitiveObjectName($className);
$controllerPackageKey = $this->objectManager->getPackageKeyByObjectName($controllerObjectName);
$controllerPackageNamespace = str_replace('.', '\\', $controllerPackageKey);
if (!str_ends_with($className, 'Controller')) {
throw new InvalidControllerException('Only for controller classes');
}

$localClassName = substr($className, strlen($controllerPackageNamespace) + 1);

if (str_starts_with($localClassName, 'Controller\\')) {
$controllerName = substr($localClassName, 11);
$subPackage = null;
} elseif (str_contains($localClassName, '\\Controller\\')) {
list($subPackage, $controllerName) = explode('\\Controller\\', $localClassName);
} else {
throw new InvalidControllerException('Unknown controller pattern');
}

$annotatedMethods = $this->reflectionService->getMethodsAnnotatedWith($className, Flow\Route::class);
foreach ($annotatedMethods as $methodName) {
if (!str_ends_with($methodName, 'Action')) {
throw new InvalidActionNameException('Only for action methods');
}
$annotations = $this->reflectionService->getMethodAnnotations($className, $methodName, Flow\Route::class);
foreach ($annotations as $annotation) {
if ($annotation instanceof Flow\Route) {
$controller = substr($controllerName, 0, -10);
$action = substr($methodName, 0, -6);
$configuration = [
'name' => $controllerPackageKey . ' :: ' . $controller . ' :: ' . ($annotation->name ?: $action),
'uriPattern' => $annotation->uriPattern,
'httpMethods' => $annotation->httpMethods,
'defaults' => Arrays::arrayMergeRecursiveOverrule(
[
'@package' => $controllerPackageKey,
'@subpackage' => $subPackage,
'@controller' => $controller,
'@action' => $action,
'@format' => 'html'
],
$annotation->defaults ?? []
)
mficzel marked this conversation as resolved.
Show resolved Hide resolved
];
$routes[] = Route::fromConfiguration($configuration);
}
}
}
}
return Routes::create(...$routes);
}
}
39 changes: 39 additions & 0 deletions Neos.Flow/Classes/Mvc/Routing/AttributeRoutesProviderFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php

/*
* This file is part of the Neos.Flow package.
*
* (c) Contributors of the Neos Project - www.neos.io
*
* This package is Open Source Software. For the full copyright and license
* information, please view the LICENSE file which was distributed with this
* source code.
*/

declare(strict_types=1);

namespace Neos\Flow\Mvc\Routing;

use Neos\Flow\ObjectManagement\ObjectManagerInterface;
use Neos\Flow\Reflection\ReflectionService;

class AttributeRoutesProviderFactory implements RoutesProviderFactoryInterface
{
public function __construct(
public readonly ReflectionService $reflectionService,
public readonly ObjectManagerInterface $objectManager,
) {
}

/**
* @param array<string, mixed> $options
*/
public function createRoutesProvider(array $options): RoutesProviderInterface
{
return new AttributeRoutesProvider(
$this->reflectionService,
$this->objectManager,
$options['classNames'] ?? [],
);
}
}
24 changes: 19 additions & 5 deletions Neos.Flow/Classes/Mvc/Routing/ConfigurationRoutesProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,36 @@

use Neos\Flow\Annotations as Flow;
use Neos\Flow\Configuration\ConfigurationManager;
use Neos\Flow\ObjectManagement\ObjectManagerInterface;

/**
* @Flow\Scope("singleton")
*/
final class ConfigurationRoutesProvider implements RoutesProviderInterface
{
private ConfigurationManager $configurationManager;

public function __construct(
ConfigurationManager $configurationManager
private ConfigurationManager $configurationManager,
private ObjectManagerInterface $objectManager,
) {
$this->configurationManager = $configurationManager;
}

public function getRoutes(): Routes
{
return Routes::fromConfiguration($this->configurationManager->getConfiguration(ConfigurationManager::CONFIGURATION_TYPE_ROUTES));
$routes = [];
foreach ($this->configurationManager->getConfiguration(ConfigurationManager::CONFIGURATION_TYPE_ROUTES) as $routeConfiguration) {
if (isset($routeConfiguration['providerFactory'])) {
$providerFactory = $this->objectManager->get($routeConfiguration['providerFactory']);
if (!$providerFactory instanceof RoutesProviderFactoryInterface) {
throw new \InvalidArgumentException(sprintf('The configured route providerFactory "%s" does not implement the "%s"', $routeConfiguration['providerFactory'], RoutesProviderFactoryInterface::class), 1710784630);
}
$provider = $providerFactory->createRoutesProvider($routeConfiguration['providerOptions'] ?? []);
foreach ($provider->getRoutes() as $route) {
$routes[] = $route;
}
} else {
$routes[] = Route::fromConfiguration($routeConfiguration);
}
}
return Routes::create(...$routes);
}
}
23 changes: 23 additions & 0 deletions Neos.Flow/Classes/Mvc/Routing/RoutesProviderFactoryInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

/*
* This file is part of the Neos.Flow package.
*
* (c) Contributors of the Neos Project - www.neos.io
*
* This package is Open Source Software. For the full copyright and license
* information, please view the LICENSE file which was distributed with this
* source code.
*/

declare(strict_types=1);

namespace Neos\Flow\Mvc\Routing;

interface RoutesProviderFactoryInterface
{
/**
* @param array<string, mixed> $options
*/
public function createRoutesProvider(array $options): RoutesProviderInterface;
}
10 changes: 10 additions & 0 deletions Neos.Flow/Classes/Mvc/Routing/RoutesProviderInterface.php
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
<?php

/*
* This file is part of the Neos.Flow package.
*
* (c) Contributors of the Neos Project - www.neos.io
*
* This package is Open Source Software. For the full copyright and license
* information, please view the LICENSE file which was distributed with this
* source code.
*/

declare(strict_types=1);

namespace Neos\Flow\Mvc\Routing;
Expand Down
2 changes: 2 additions & 0 deletions Neos.Flow/Classes/Package.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
});
}
Expand Down
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;
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change ensures that methods with multiple annotations do not appear multiple times in methodsAnnotatedWith ... pretty sure this ha no unwanted side effects

}

$returnType = $method->getDeclaredReturnType();
Expand Down
Loading
Loading