Verified Commit 5bc84e84 authored by catch's avatar catch Committed by godotislate
Browse files

feat: #3584793 Use PHP attributes for form route discovery

By: godotislate
By: longwave
By: niklan
parent 357be5a6
Loading
Loading
Loading
Loading
Loading
+127 −72
Original line number Diff line number Diff line
@@ -4,10 +4,11 @@

namespace Drupal\Core\Routing;

use Drupal\Component\Assertion\Inspector;
use Drupal\Core\Form\FormInterface;
use Symfony\Component\Routing\Attribute\DeprecatedAlias;
use Symfony\Component\Routing\Attribute\Route as RouteAttribute;
use Symfony\Component\Routing\Exception\InvalidArgumentException;
use Symfony\Component\Routing\Route;
use Symfony\Component\Routing\RouteCollection;

/**
@@ -37,12 +38,18 @@ protected static function getPriority(): int {
   * {@inheritdoc}
   */
  protected function collectRoutes(): iterable {
    $routeTypes = [
      'Controller' => $this->createControllerRouteCollection(...),
      'Form' => $this->createFormRouteCollection(...),
    ];

    foreach ($this->namespaces as $namespace => $directory) {
      $directory .= '/Controller';
      $namespace .= '\\Controller';
      if (is_dir($directory)) {
      foreach ($routeTypes as $routeType => $factory) {
        $routeDirectory = $directory . '/' . $routeType;
        $routeNamespace = $namespace . '\\' . $routeType;
        if (is_dir($routeDirectory)) {
          $iterator = new \RecursiveIteratorIterator(
          new \RecursiveDirectoryIterator($directory, \RecursiveDirectoryIterator::SKIP_DOTS)
            new \RecursiveDirectoryIterator($routeDirectory, \RecursiveDirectoryIterator::SKIP_DOTS)
          );
          foreach ($iterator as $fileinfo) {
            if ($fileinfo->getExtension() == 'php') {
@@ -53,8 +60,12 @@ protected function collectRoutes(): iterable {
              }
              $subPath = $iterator->getSubIterator()->getSubPath();
              $subPath = $subPath ? str_replace(DIRECTORY_SEPARATOR, '\\', $subPath) . '\\' : '';
            $class = $namespace . '\\' . $subPath . $fileinfo->getBasename('.php');
            yield $this->createRouteCollection($class);
              $class = $routeNamespace . '\\' . $subPath . $fileinfo->getBasename('.php');
              $reflectionClass = $this->getReflectionClass($class);
              if ($reflectionClass !== NULL) {
                yield $factory($reflectionClass);
              }
            }
          }
        }
      }
@@ -62,34 +73,16 @@ protected function collectRoutes(): iterable {
  }

  /**
   * Creates a route collection from a class's attributed methods.
   * Creates a route collection from a controller class's attributed methods.
   *
   * @param class-string $className
   *   The class to generate a route collection for.
   * @param \ReflectionClass<object> $class
   *   The reflection object of the class to generate a route collection for.
   *
   * @return \Symfony\Component\Routing\RouteCollection
   *   The route collection.
   */
  private function createRouteCollection(string $className): RouteCollection {
  private function createControllerRouteCollection(\ReflectionClass $class): RouteCollection {
    $collection = new RouteCollection();

    try {
      if (!class_exists($className)) {
        // In Symfony code this triggers an exception. It is removed here
        // because Drupal already has traits, interfaces and other things in
        // this folder. Alternatively, we could remove this if clause and then
        // check what the resulting reflection object is.
        return $collection;
      }
    }
    catch (\Error) {
      return $collection;
    }
    $class = new \ReflectionClass($className);
    if ($class->isAbstract()) {
      return $collection;
    }

    $globals = $this->getGlobals($class);
    $fqcnAlias = FALSE;

@@ -104,10 +97,13 @@ private function createRouteCollection(string $className): RouteCollection {
    foreach ($class->getMethods() as $method) {
      $routeNamesBefore = array_keys($collection->all());
      foreach ($this->getAttributes($method) as $attribute) {
        $this->addRoute($collection, $attribute, $globals, $class, $method);
        $controllerName = $class->getName() . '::' . $method->getName();
        if ($method->name === '__invoke') {
          $fqcnAlias = TRUE;
          $controllerName = $class->getName();
        }
        $attribute->defaults = ['_controller' => $controllerName] + $attribute->defaults;
        $this->addRoute($collection, $attribute, $globals, $class, $method);
      }

      if ($collection->count() - \count($routeNamesBefore) === 1) {
@@ -119,9 +115,10 @@ private function createRouteCollection(string $className): RouteCollection {
    }

    // See https://symfony.com/doc/current/controller/service.html#invokable-controllers.
    if ($collection->count() && $class->hasMethod('__invoke') === 0) {
    if ($collection->count() === 0 && $class->hasMethod('__invoke')) {
      $globals = $this->resetGlobals();
      foreach ($this->getAttributes($class) as $attribute) {
        $attribute->defaults = ['_controller' => $class->getName()] + $attribute->defaults;
        $this->addRoute($collection, $attribute, $globals, $class, $class->getMethod('__invoke'));
        $fqcnAlias = TRUE;
      }
@@ -141,6 +138,37 @@ private function createRouteCollection(string $className): RouteCollection {
    return $collection;
  }

  /**
   * Gets a reflection class from the class name.
   *
   * @param class-string $className
   *   The class to reflect.
   *
   * @return \ReflectionClass<object>|null
   *   The Reflection class, is the class is a valid to check for routes,
   *   otherwise NULL. A class is invalid if there is an error on Reflection or
   *   if it is abstract.
   */
  private function getReflectionClass(string $className): ?\ReflectionClass {
    try {
      $exists = class_exists($className);
    }
    catch (\Error) {
      // Ignore errors if a class extends a missing class, interface,
      // or trait.
      return NULL;
    }

    if ($exists) {
      $class = new \ReflectionClass($className);
      if (!$class->isAbstract()) {
        return $class;
      }
    }

    return NULL;
  }

  /**
   * Creates the default route settings for a class.
   *
@@ -185,11 +213,11 @@ private function getGlobals(\ReflectionClass $class): array {
        }
      }

      if ($attribute->schemes !== NULL) {
      if (!empty($attribute->schemes)) {
        $globals['schemes'] = $attribute->schemes;
      }

      if ($attribute->methods !== NULL) {
      if (!empty($attribute->methods)) {
        $globals['methods'] = $attribute->methods;
      }

@@ -213,41 +241,88 @@ private function getGlobals(\ReflectionClass $class): array {
    return $globals;
  }

  /**
   * Creates a route collection from a form class's attributed methods.
   *
   * @param \ReflectionClass<object> $class
   *   The reflection object of the class to generate a route collection for.
   *
   * @return \Symfony\Component\Routing\RouteCollection
   *   The route collection.
   */
  private function createFormRouteCollection(\ReflectionClass $class): RouteCollection {
    $collection = new RouteCollection();
    if (!$class->implementsInterface(FormInterface::class)) {
      return $collection;
    }

    foreach ($this->getAttributes($class) as $attribute) {
      $attribute->defaults = ['_form' => $class->getName()] + $attribute->defaults;
      $this->addRoute($collection, $attribute, $this->resetGlobals(), $class);
      $formRouteName = $attribute->name;
    }
    // If there is only one route defined for the form class, add the class name
    // as an alias for the route.
    if (count($collection) === 1 && isset($formRouteName)) {
      $collection->addAlias($class->getName(), $formRouteName);
    }

    // Route attributes on form class methods are not supported.
    assert(
      Inspector::assertAll(
        fn($method) => $this->getAttributes($method)->key() === NULL,
        $class->getMethods()
      ),
      sprintf('Route attributes can not target methods on class %s. Use the attribute on the form class itself.', $class->getName())
    );

    return $collection;
  }

  /**
   * Adds a route to the provided route collection.
   *
   * @param \Symfony\Component\Routing\RouteCollection $collection
   *   The route collection to add the route to.
   * @param \Symfony\Component\Routing\Annotation\Route $attribute
   * @param \Symfony\Component\Routing\Attribute\Route $attribute
   *   The attribute object that describes the route.
   * @param array $globals
   *   The defaults for the class.
   * @param \ReflectionClass $class
   *   The class.
   * @param \ReflectionMethod $method
   * @param \ReflectionMethod|null $method
   *   The attributed method.
   */
  private function addRoute(RouteCollection $collection, RouteAttribute $attribute, array $globals, \ReflectionClass $class, \ReflectionMethod $method): void {
  private function addRoute(RouteCollection $collection, RouteAttribute $attribute, array $globals, \ReflectionClass $class, ?\ReflectionMethod $method = NULL): void {
    if ($class->implementsInterface(FormInterface::class)) {
      $classMethod = $class->getName();
    }
    elseif ($method !== NULL) {
      $classMethod = $class->getName() . '::' . $method->getName() . '()';
    }
    else {
      throw new \InvalidArgumentException('Method must be specified on non-form routes.');
    }
    if ($attribute->name === NULL) {
      throw new UnsupportedRouteAttributePropertyException(sprintf('The Route attribute on "%s::%s()" is missing a required "name" property.', $class->getName(), $method->getName()));
      throw new UnsupportedRouteAttributePropertyException(sprintf('The Route attribute on "%s" is missing a required "name" property.', $classMethod));
    }
    $name = $globals['name'] . $attribute->name;

    if (is_array($attribute->path)) {
      throw new UnsupportedRouteAttributePropertyException(sprintf('The "%s" route attribute does not support arrays on route "%s" in "%s::%s()"', "path", $name, $class->getName(), $method->getName()));
      throw new UnsupportedRouteAttributePropertyException(sprintf('The "%s" route attribute does not support arrays on route "%s" in "%s"', "path", $name, $classMethod));
    }
    if (!empty($attribute->defaults['_locale'])) {
      throw new UnsupportedRouteAttributePropertyException(sprintf('The "%s" route attribute is not supported on route "%s" in "%s::%s()"', "locale", $name, $class->getName(), $method->getName()));
      throw new UnsupportedRouteAttributePropertyException(sprintf('The "%s" route attribute is not supported on route "%s" in "%s"', "locale", $name, $classMethod));
    }
    if ($attribute->condition !== NULL) {
      throw new UnsupportedRouteAttributePropertyException(sprintf('The "%s" route attribute is not supported on route "%s" in "%s::%s()"', "condition", $name, $class->getName(), $method->getName()));
      throw new UnsupportedRouteAttributePropertyException(sprintf('The "%s" route attribute is not supported on route "%s" in "%s"', "condition", $name, $classMethod));
    }

    $requirements = $attribute->requirements;

    foreach ($requirements as $placeholder => $requirement) {
      if (\is_int($placeholder)) {
        throw new \InvalidArgumentException(sprintf('A placeholder name must be a string (%d given). Did you forget to specify the placeholder key for the requirement "%s" of route "%s" in "%s::%s()"?', $placeholder, $requirement, $name, $class->getName(), $method->getName()));
        throw new \InvalidArgumentException(sprintf('A placeholder name must be a string (%d given). Did you forget to specify the placeholder key for the requirement "%s" of route "%s" in "%s"?', $placeholder, $requirement, $name, $classMethod));
      }
    }

@@ -264,7 +339,6 @@ private function addRoute(RouteCollection $collection, RouteAttribute $attribute
    $prefix = $globals['path'];

    $route = $this->createRoute($prefix . $path, $defaults, $requirements, $options, $host, $schemes, $methods, NULL);
    $this->configureRoute($route, $class, $method);
    $collection->add($name, $route, $priority);
    foreach ($attribute->aliases as $aliasAttribute) {
      if ($aliasAttribute instanceof DeprecatedAlias) {
@@ -290,29 +364,10 @@ private function addRoute(RouteCollection $collection, RouteAttribute $attribute
   * @return iterable<int, RouteAttribute>
   *   The attributes.
   */
  private function getAttributes(\ReflectionClass|\ReflectionMethod $reflection): iterable {
  private function getAttributes(\ReflectionClass|\ReflectionMethod $reflection): \Generator {
    foreach ($reflection->getAttributes(RouteAttribute::class, \ReflectionAttribute::IS_INSTANCEOF) as $attribute) {
      yield $attribute->newInstance();
    }
  }

  /**
   * Configures the _controller default parameter of a given Route instance.
   *
   * @param \Symfony\Component\Routing\Route $route
   *   The route to configure.
   * @param \ReflectionClass $class
   *   The class.
   * @param \ReflectionMethod $method
   *   The method.
   */
  private function configureRoute(Route $route, \ReflectionClass $class, \ReflectionMethod $method): void {
    if ($method->getName() === '__invoke') {
      $route->setDefault('_controller', $class->getName());
    }
    else {
      $route->setDefault('_controller', $class->getName() . '::' . $method->getName());
    }
  }

}
+10 −0
Original line number Diff line number Diff line
@@ -10,13 +10,23 @@
use Drupal\Core\Form\RedundantEditableConfigNamesTrait;
use Drupal\Core\Path\PathValidatorInterface;
use Drupal\Core\Routing\RequestContext;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\Routing\Attribute\Route;

/**
 * Configure site information settings for this site.
 *
 * @internal
 */
#[Route(
  path: '/admin/config/system/site-information',
  name: 'system.site_information_settings',
  requirements: [
    '_permission' => 'administer site configuration',
  ],
  defaults: ['_title' => new TranslatableMarkup('Basic site settings')],
)]
class SiteInformationForm extends ConfigFormBase {
  use RedundantEditableConfigNamesTrait;

+0 −8
Original line number Diff line number Diff line
@@ -7,14 +7,6 @@ system.cron:
  requirements:
    _access_system_cron: 'TRUE'

system.site_information_settings:
  path: '/admin/config/system/site-information'
  defaults:
    _form: '\Drupal\system\Form\SiteInformationForm'
    _title: 'Basic site settings'
  requirements:
    _permission: 'administer site configuration'

system.cron_settings:
  path: '/admin/config/system/cron'
  defaults:
+19 −0
Original line number Diff line number Diff line
<?php

declare(strict_types=1);

namespace Drupal\router_test\Form;

use Symfony\Component\Routing\Attribute\Route;

/**
 * Test class that does not implement FormInterface.
 *
 * Any class that is in the Form namespace and does not implement FormInterface
 * will not be discoverable for form routes, even with the attribute.
 */
#[Route(
  path: '/non-form-object-route',
  name: 'router_test.invalid_controller_route',
)]
class TestInvalidFormNamespaceController {}
+45 −0
Original line number Diff line number Diff line
<?php

declare(strict_types=1);

namespace Drupal\router_test\Form;

use Drupal\Core\Form\FormBase;
use Drupal\Core\Form\FormStateInterface;
use Symfony\Component\Routing\Attribute\Route;

/**
 * Test form object for Route attribute.
 */
#[Route(
  path: '/test-form-route',
  name: 'router_test.form_route',
  requirements: ['_access' => 'TRUE'],
)]
class TestRouteAttributeForm extends FormBase {

  /**
   * {@inheritdoc}
   */
  public function getFormId(): string {
    return 'router_test';
  }

  /**
   * {@inheritdoc}
   */
  public function buildForm(array $form, FormStateInterface $form_state): array {
    $form['button'] = [
      '#type' => 'submit',
      '#value' => 'Click here',
    ];
    return $form;
  }

  /**
   * {@inheritdoc}
   */
  public function submitForm(array &$form, FormStateInterface $form_state): void {
  }

}
Loading