Skip to content

Commit

Permalink
Merge pull request #3325 from mficzel/feature/routeAnnotations
Browse files Browse the repository at this point in the history
FEATURE: Add `Flow\Route` Attribute/Annotation
  • Loading branch information
kitsunet authored Mar 28, 2024
2 parents 05348b6 + 77fcaea commit 7000d15
Show file tree
Hide file tree
Showing 13 changed files with 698 additions and 12 deletions.
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;
}
$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;

/**
* 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 ?? []
)
];
$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

0 comments on commit 7000d15

Please sign in to comment.