Verified Commit 1384a2e1 authored by Alex Pott's avatar Alex Pott
Browse files

Issue #3303126 by phenaproxima, narendrar, thejimbirch, alexpott, aangel, Wim...

Issue #3303126 by phenaproxima, narendrar, thejimbirch, alexpott, aangel, Wim Leers, b_sharpe, longwave, akhil babu: Make it possible for recipes to prompt for input values
parent 276df7ec
Loading
Loading
Loading
Loading
Loading
+56 −0
Original line number Diff line number Diff line
<?php

declare(strict_types=1);

namespace Drupal\Core\Command;

use Drupal\Core\DrupalKernel;
use Drupal\Core\DrupalKernelInterface;
use Drupal\Core\Site\Settings;
use Symfony\Component\HttpFoundation\Request;

/**
 * Contains helper methods for console commands that boot up Drupal.
 */
trait BootableCommandTrait {

  /**
   * The class loader.
   *
   * @var object
   */
  protected object $classLoader;

  /**
   * Boots up a Drupal environment.
   *
   * @return \Drupal\Core\DrupalKernelInterface
   *   The Drupal kernel.
   *
   * @throws \Exception
   *   Exception thrown if kernel does not boot.
   */
  protected function boot(): DrupalKernelInterface {
    $kernel = new DrupalKernel('prod', $this->classLoader);
    $kernel::bootEnvironment();
    $kernel->setSitePath($this->getSitePath());
    Settings::initialize($kernel->getAppRoot(), $kernel->getSitePath(), $this->classLoader);
    $kernel->boot();
    $kernel->preHandle(Request::createFromGlobals());
    return $kernel;
  }

  /**
   * Gets the site path.
   *
   * Defaults to 'sites/default'. For testing purposes this can be overridden
   * using the DRUPAL_DEV_SITE_PATH environment variable.
   *
   * @return string
   *   The site path to use.
   */
  protected function getSitePath(): string {
    return getenv('DRUPAL_DEV_SITE_PATH') ?: 'sites/default';
  }

}
+108 −0
Original line number Diff line number Diff line
<?php

declare(strict_types=1);

namespace Drupal\Core\Recipe;

use Drupal\Core\TypedData\DataDefinitionInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Exception\InvalidArgumentException;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Style\StyleInterface;

/**
 * Collects input values for recipes from the command line.
 *
 * @internal
 *   This API is experimental.
 */
final class ConsoleInputCollector implements InputCollectorInterface {

  /**
   * The name of the command-line option for passing input values.
   *
   * @var string
   */
  public const INPUT_OPTION = 'input';

  public function __construct(
    private readonly InputInterface $input,
    private readonly StyleInterface $io,
  ) {}

  /**
   * Configures a console command to support the `--input` option.
   *
   * This should be called by a command's configure() method.
   *
   * @param \Symfony\Component\Console\Command\Command $command
   *   The command being configured.
   */
  public static function configureCommand(Command $command): void {
    $command->addOption(
      static::INPUT_OPTION,
      'i',
      InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED,
      sprintf('An input value to pass to the recipe or one of its dependencies, in the form `--%s=RECIPE_NAME.INPUT_NAME=VALUE`.', static::INPUT_OPTION),
      [],
    );
  }

  /**
   * Returns the `--input` options passed to the command.
   *
   * @return string[]
   *   The values from the `--input` options passed to the command, keyed by
   *   fully qualified name (i.e., prefixed with the name of their defining
   *   recipe).
   */
  private function getInputFromOptions(): array {
    $options = [];
    try {
      foreach ($this->input->getOption(static::INPUT_OPTION) ?? [] as $option) {
        [$key, $value] = explode('=', $option, 2);
        $options[$key] = $value;
      }
    }
    catch (InvalidArgumentException) {
      // The option is undefined; there's nothing we need to do.
    }
    return $options;
  }

  /**
   * {@inheritdoc}
   */
  public function collectValue(string $name, DataDefinitionInterface $definition, mixed $default_value): mixed {
    $option_values = $this->getInputFromOptions();

    // If the value was passed as a `--input` option, return that.
    if (array_key_exists($name, $option_values)) {
      return $option_values[$name];
    }

    /** @var array{method: string, arguments?: array<mixed>}|null $settings */
    $settings = $definition->getSetting('prompt');
    // If there's no information on how to prompt the user, there's nothing else
    // for us to do; return the default value.
    if (empty($settings)) {
      return $default_value;
    }

    $method = $settings['method'];
    $arguments = $settings['arguments'] ?? [];

    // Most of the input-collecting methods of StyleInterface have a `default`
    // parameter.
    $arguments += [
      'default' => $default_value,
    ];
    // We don't support using Symfony Console's inline validation; instead,
    // input definitions should define constraints.
    unset($arguments['validator']);

    return $this->io->$method(...$arguments);
  }

}
+13 −0
Original line number Diff line number Diff line
<?php

declare(strict_types=1);

namespace Drupal\Core\Recipe;

use Drupal\Core\TypedData\DataDefinitionInterface;

interface InputCollectorInterface {

  public function collectValue(string $name, DataDefinitionInterface $definition, mixed $default_value): mixed;

}
+183 −0
Original line number Diff line number Diff line
<?php

declare(strict_types=1);

namespace Drupal\Core\Recipe;

use Drupal\Core\TypedData\DataDefinition;
use Drupal\Core\TypedData\TypedDataManagerInterface;
use Symfony\Component\Validator\Exception\ValidationFailedException;

/**
 * Collects and validates input values for a recipe.
 *
 * @internal
 *   This API is experimental.
 */
final class InputConfigurator {

  /**
   * The input data.
   *
   * @var \Drupal\Core\TypedData\TypedDataInterface[]
   */
  private array $data = [];

  /**
   * The collected input values, or NULL if none have been collected yet.
   *
   * @var mixed[]|null
   */
  private ?array $values = NULL;

  /**
   * @param array<string, array<string, mixed>> $definitions
   *   The recipe's input definitions, keyed by name. This is an array of arrays
   *   where each sub-array has, at minimum:
   *   - `description`: A short, human-readable description of the input (e.g.,
   *      what the recipe uses it for).
   *   - `data_type`: A primitive data type known to the typed data system.
   *   - `constraints`: An optional array of validation constraints to apply
   *     to the value. This should be an associative array of arrays, keyed by
   *     constraint name, where each sub-array is a set of options for that
   *     constraint (identical to the way validation constraints are defined in
   *     config schema).
   *   - `default`: A default value for the input, if it cannot be collected
   *     the user. See ::getDefaultValue() for more information.
   * @param \Drupal\Core\Recipe\RecipeConfigurator $dependencies
   *   The recipes that this recipe depends on.
   * @param string $prefix
   *   A prefix for each input definition, to give each one a unique name
   *   when collecting input for multiple recipes. Usually this is the unique
   *   name of the recipe.
   * @param \Drupal\Core\TypedData\TypedDataManagerInterface $typedDataManager
   *   The typed data manager service.
   */
  public function __construct(
    array $definitions,
    private readonly RecipeConfigurator $dependencies,
    private readonly string $prefix,
    TypedDataManagerInterface $typedDataManager,
  ) {
    // Convert the input definitions to typed data definitions.
    foreach ($definitions as $name => $definition) {
      $data_definition = DataDefinition::create($definition['data_type'])
        ->setDescription($definition['description'])
        ->setConstraints($definition['constraints'] ?? []);

      unset(
        $definition['data_type'],
        $definition['description'],
        $definition['constraints'],
      );
      $data_definition->setSettings($definition);
      $this->data[$name] = $typedDataManager->create($data_definition);
    }
  }

  /**
   * Returns the collected input values, keyed by name.
   *
   * @return mixed[]
   *   The collected input values, keyed by name.
   */
  public function getValues(): array {
    return $this->values ?? [];
  }

  /**
   * Returns the description for all inputs of this recipe and its dependencies.
   *
   * @return string[]
   *   The descriptions of every input defined by the recipe and its
   *   dependencies, keyed by the input's fully qualified name (i.e., prefixed
   *   by the name of the recipe that defines it).
   */
  public function describeAll(): array {
    $descriptions = [];
    foreach ($this->dependencies->recipes as $dependency) {
      $descriptions = array_merge($descriptions, $dependency->input->describeAll());
    }
    foreach ($this->data as $key => $data) {
      $name = $this->prefix . '.' . $key;
      $descriptions[$name] = $data->getDataDefinition()->getDescription();
    }
    return $descriptions;
  }

  /**
   * Collects input values for this recipe and its dependencies.
   *
   * @param \Drupal\Core\Recipe\InputCollectorInterface $collector
   *   The input collector to use.
   *
   * @throws \Symfony\Component\Validator\Exception\ValidationFailedException
   *   Thrown if any of the collected values violate their validation
   *   constraints.
   */
  public function collectAll(InputCollectorInterface $collector): void {
    if (is_array($this->values)) {
      throw new \LogicException('Input values cannot be changed once they have been set.');
    }

    // Don't bother collecting values for a recipe we've already seen.
    static $processed = [];
    if (in_array($this->prefix, $processed, TRUE)) {
      return;
    }

    // First, collect values for the recipe's dependencies.
    /** @var \Drupal\Core\Recipe\Recipe $dependency */
    foreach ($this->dependencies->recipes as $dependency) {
      $dependency->input->collectAll($collector);
    }

    $this->values = [];
    foreach ($this->data as $key => $data) {
      $definition = $data->getDataDefinition();

      $value = $collector->collectValue(
        $this->prefix . '.' . $key,
        $definition,
        $this->getDefaultValue($definition),
      );
      $data->setValue($value, FALSE);

      $violations = $data->validate();
      if (count($violations) > 0) {
        throw new ValidationFailedException($value, $violations);
      }
      $this->values[$key] = $data->getCastedValue();
    }
    $processed[] = $this->prefix;
  }

  /**
   * Returns the default value for an input definition.
   *
   * @param array $definition
   *   An input definition. Must contain a `source` element, which can be either
   *   'config' or 'value'. If `source` is 'config', then there must also be a
   *   `config` element, which is a two-element indexed array containing
   *   (in order) the name of an extant config object, and a property path
   *   within that object. If `source` is 'value', then there must be a `value`
   *   element, which will be returned as-is.
   *
   * @return mixed
   *   The default value.
   */
  private function getDefaultValue(DataDefinition $definition): mixed {
    $settings = $definition->getSetting('default');

    if ($settings['source'] === 'config') {
      [$name, $key] = $settings['config'];
      $config = \Drupal::config($name);
      if ($config->isNew()) {
        throw new \RuntimeException("The '$name' config object does not exist.");
      }
      return $config->get($key);
    }
    return $settings['value'];
  }

}
+108 −3
Original line number Diff line number Diff line
@@ -9,11 +9,14 @@
use Drupal\Core\Extension\ModuleExtensionList;
use Drupal\Core\Extension\ThemeExtensionList;
use Drupal\Component\Serialization\Yaml;
use Drupal\Core\TypedData\PrimitiveInterface;
use Drupal\Core\Validation\Plugin\Validation\Constraint\RegexConstraint;
use Symfony\Component\Validator\Constraints\All;
use Symfony\Component\Validator\Constraints\AtLeastOneOf;
use Symfony\Component\Validator\Constraints\Callback;
use Symfony\Component\Validator\Constraints\Choice;
use Symfony\Component\Validator\Constraints\Collection;
use Symfony\Component\Validator\Constraints\Count;
use Symfony\Component\Validator\Constraints\IdenticalTo;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Validator\Constraints\NotIdenticalTo;
@@ -33,6 +36,30 @@ final class Recipe {

  const COMPOSER_PROJECT_TYPE = 'drupal-recipe';

  /**
   * @param string $name
   *   The human-readable name of the recipe.
   * @param string $description
   *   A short description of the recipe.
   * @param string $type
   *   The recipe type.
   * @param \Drupal\Core\Recipe\RecipeConfigurator $recipes
   *   The recipe configurator, which lists the recipes that will be applied
   *   before this one.
   * @param \Drupal\Core\Recipe\InstallConfigurator $install
   *   The install configurator, which lists the extensions this recipe will
   *   install.
   * @param \Drupal\Core\Recipe\ConfigConfigurator $config
   *   The config configurator, which lists the config that this recipe will
   *   install, and what config actions will be taken.
   * @param \Drupal\Core\Recipe\InputConfigurator $input
   *   The input configurator, which collects any input values used by the
   *   recipe.
   * @param \Drupal\Core\DefaultContent\Finder $content
   *   The default content finder.
   * @param string $path
   *   The recipe's path.
   */
  public function __construct(
    public readonly string $name,
    public readonly string $description,
@@ -40,10 +67,10 @@ public function __construct(
    public readonly RecipeConfigurator $recipes,
    public readonly InstallConfigurator $install,
    public readonly ConfigConfigurator $config,
    public readonly InputConfigurator $input,
    public readonly Finder $content,
    public readonly string $path,
  ) {
  }
  ) {}

  /**
   * Creates a recipe object from the provided path.
@@ -60,8 +87,9 @@ public static function createFromDirectory(string $path): static {
    $recipes = new RecipeConfigurator(is_array($recipe_data['recipes']) ? $recipe_data['recipes'] : [], dirname($path));
    $install = new InstallConfigurator($recipe_data['install'], \Drupal::service('extension.list.module'), \Drupal::service('extension.list.theme'));
    $config = new ConfigConfigurator($recipe_data['config'], $path, \Drupal::service('config.storage'));
    $input = new InputConfigurator($recipe_data['input'] ?? [], $recipes, basename($path), \Drupal::typedDataManager());
    $content = new Finder($path . '/content');
    return new static($recipe_data['name'], $recipe_data['description'], $recipe_data['type'], $recipes, $install, $config, $content, $path);
    return new static($recipe_data['name'], $recipe_data['description'], $recipe_data['type'], $recipes, $install, $config, $input, $content, $path);
  }

  /**
@@ -151,6 +179,65 @@ private static function parse(string $file): array {
          ]),
        ]),
      ]),
      'input' => new Optional([
        new Type('associative_array'),
        new All([
          new Collection(
            fields: [
              // Every input definition must have a description.
              'description' => [
                new Type('string'),
                new NotBlank(),
              ],
              // There can be an optional set of constraints, which is an
              // associative array of arrays, as in config schema.
              'constraints' => new Optional([
                new Type('associative_array'),
              ]),
              'data_type' => [
                // The data type must be known to the typed data system.
                \Drupal::service('validation.constraint')->createInstance('PluginExists', [
                  'manager' => 'typed_data_manager',
                  // Only primitives are supported because it's not always clear
                  // how to collect, validate, and cast complex structures.
                  'interface' => PrimitiveInterface::class,
                ]),
              ],
              // If there is a `prompt` element, it has its own set of
              // constraints.
              'prompt' => new Optional([
                new Collection([
                  'method' => [
                    new Choice(['ask', 'askHidden', 'confirm', 'choice']),
                  ],
                  'arguments' => new Optional([
                    new Type('associative_array'),
                  ]),
                ]),
              ]),
              // Every input must define a default value.
              'default' => new Required([
                new Collection([
                  'source' => new Required([
                    new Choice(['value', 'config']),
                  ]),
                  'value' => new Optional(),
                  'config' => new Optional([
                    new Sequentially([
                      new Type('list'),
                      new Count(2),
                      new All([
                        new Type('string'),
                        new NotBlank(),
                      ]),
                    ]),
                  ]),
                ]),
                new Callback(self::validateDefaultValueDefinition(...)),
              ]),
            ]),
        ]),
      ]),
      'config' => new Optional([
        new Collection([
          // Each entry in the `import` list can either be `*` (import all of
@@ -204,6 +291,24 @@ private static function parse(string $file): array {
    return $recipe_data;
  }

  /**
   * Validates the definition of an input's default value.
   *
   * @param array $definition
   *   The array to validate (part of a single input definition).
   * @param \Symfony\Component\Validator\Context\ExecutionContextInterface $context
   *   The validator execution context.
   *
   * @see ::parse()
   */
  public static function validateDefaultValueDefinition(array $definition, ExecutionContextInterface $context): void {
    $source = $definition['source'];

    if (!array_key_exists($source, $definition)) {
      $context->addViolation("The '$source' key is required.");
    }
  }

  /**
   * Validates that the value is an available module/theme (installed or not).
   *
Loading