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 all 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
67 changes: 67 additions & 0 deletions Neos.Flow/Classes/Annotations/Route.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
<?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 readonly class Route
{
/**
* 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 '/'. 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 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);
}
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);
}
}
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);
}
}
}
}
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
149 changes: 149 additions & 0 deletions Neos.Flow/Classes/Mvc/Routing/AttributeRoutesProvider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
<?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\Controller\ActionController;
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
*
* 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. 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:
*
* 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.
*
* 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
*
* TODO for a future scope of `Flow\Action` see {@link https://github.com/neos/flow-development-collection/issues/3335}
*/
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;
}

if (!in_array(ActionController::class, class_parents($className), true)) {
throw new InvalidControllerException('TODO: Currently #[Flow\Route] is only supported for ActionController. See https://github.com/neos/flow-development-collection/issues/3335.');
}

$controllerObjectName = $this->objectManager->getCaseSensitiveObjectName($className);
$controllerPackageKey = $this->objectManager->getPackageKeyByObjectName($controllerObjectName);
$controllerPackageNamespace = str_replace('.', '\\', $controllerPackageKey);
if (!str_ends_with($className, 'Controller')) {
throw new InvalidControllerException('Only for controller classes');
}

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

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

$annotatedMethods = $this->reflectionService->getMethodsAnnotatedWith($className, Flow\Route::class);
foreach ($annotatedMethods as $methodName) {
if (!str_ends_with($methodName, 'Action')) {
throw new InvalidActionNameException('Only for action methods');
}
$annotations = $this->reflectionService->getMethodAnnotations($className, $methodName, Flow\Route::class);
foreach ($annotations as $annotation) {
if ($annotation instanceof Flow\Route) {
$controller = substr($controllerName, 0, -10);
$action = substr($methodName, 0, -6);
$configuration = [
'name' => $controllerPackageKey . ' :: ' . $controller . ' :: ' . ($annotation->name ?: $action),
'uriPattern' => $annotation->uriPattern,
'httpMethods' => $annotation->httpMethods,
'defaults' => Arrays::arrayMergeRecursiveOverrule(
[
'@package' => $controllerPackageKey,
'@subpackage' => $subPackage,
'@controller' => $controller,
'@action' => $action,
'@format' => 'html'
],
$annotation->defaults ?? []
)
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
Loading
Loading