new Required([ new Type('string'), new NotBlank(), // Matching `type: label` in core.data_types.schema.yml. new RegexConstraint( pattern: '/([^\PC])/u', message: 'Recipe names cannot span multiple lines or contain control characters.', match: FALSE, ), ]), 'description' => new Optional([ new NotBlank(), // Matching `type: text` in core.data_types.schema.yml. new RegexConstraint( pattern: '/([^\PC\x09\x0a\x0d])/u', message: 'The recipe description cannot contain control characters, only visible characters.', match: FALSE, ), ]), 'type' => new Optional([ new Type('string'), new NotBlank(), // Matching `type: label` in core.data_types.schema.yml. new RegexConstraint( pattern: '/([^\PC])/u', message: 'Recipe type cannot span multiple lines or contain control characters.', match: FALSE, ), ]), 'recipes' => new Optional([ new All([ new Type('string'), new NotBlank(), // If recipe depends on itself, ::validateRecipeExists() will set off // an infinite loop. We can avoid that by skipping that validation if // the recipe depends on itself, which is what Sequentially does. new Sequentially([ new NotIdenticalTo( value: basename(dirname($file)), message: 'The {{ compared_value }} recipe cannot depend on itself.', ), new Callback( callback: self::validateRecipeExists(...), payload: $include_path, ), ]), ]), ]), // @todo https://www.drupal.org/i/3424603 Validate the corresponding // import. 'install' => new Optional([ new All([ new Type('string'), new Sequentially([ new NotBlank(), new Callback(self::validateExtensionIsAvailable(...)), ]), ]), ]), '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, ]), ], // The `prompt` and `form` elements, though optional, have their // own sets of constraints, 'prompt' => new Optional([ new Collection([ 'method' => [ new Choice(['ask', 'askHidden', 'confirm', 'choice']), ], 'arguments' => new Optional([ new Type('associative_array'), ]), ]), ]), 'form' => new Optional([ new Sequentially([ new Type('associative_array'), // Every element in the `form` array has to be a form API // property, prefixed with `#`. Because recipe inputs can only // be primitive data types, child elements aren't allowed. new Callback(function (array $element, ExecutionContextInterface $context): void { if (Element::children($element)) { $context->addViolation('Form elements for recipe inputs cannot have child elements.'); } }), ]), ]), // 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 // the extension's config), or a list of config names to import from // the extension. // @todo https://www.drupal.org/i/3439716 Validate config file name, // if given. 'import' => new Optional([ new All([ new AtLeastOneOf([ new IdenticalTo('*'), new All([ new Type('string'), new NotBlank(), new Regex('/^.+\./'), ]), ]), ]), ]), 'strict' => new Optional([ new AtLeastOneOf([ new Type('boolean'), new All([ new Type('string'), new NotBlank(), new Regex('/^.+\./'), ]), ], message: 'This value must be a boolean, or a list of config names.', includeInternalMessages: FALSE), ]), 'actions' => new Optional([ new All([ new Type('array'), new NotBlank(), new Callback( callback: self::validateConfigActions(...), payload: $include_path, ), ]), ]), ]), ]), 'content' => new Optional([ new Type('array'), ]), 'extra' => new Optional([ new Sequentially([ new Type('associative_array'), new Callback(self::validateKeysAreValidExtensionNames(...)), ]), ]), ]); $recipe_data = Yaml::decode($recipe_contents); /** @var \Symfony\Component\Validator\ConstraintViolationList $violations */ $violations = Validation::createValidator()->validate($recipe_data, $constraints); if (count($violations) > 0) { throw RecipeFileException::fromViolationList($file, $violations); } $recipe_data += [ 'description' => '', 'type' => '', 'recipes' => [], 'install' => [], 'config' => [], 'content' => [], ]; 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). * * @param string $value * The value to validate. * @param \Symfony\Component\Validator\Context\ExecutionContextInterface $context * The validator execution context. * * @see \Drupal\Core\Extension\ExtensionList::getAllAvailableInfo() */ private static function validateExtensionIsAvailable(string $value, ExecutionContextInterface $context): void { $name = Dependency::createFromString($value)->getName(); $all_available = \Drupal::service(ModuleExtensionList::class)->getAllAvailableInfo() + \Drupal::service(ThemeExtensionList::class)->getAllAvailableInfo(); if (!array_key_exists($name, $all_available)) { $context->addViolation('"%extension" is not a known module or theme.', [ '%extension' => $name, ]); } } /** * Validates that a recipe exists. * * @param string $name * The machine name of the recipe to look for. * @param \Symfony\Component\Validator\Context\ExecutionContextInterface $context * The validator execution context. * @param string $include_path * The recipe's include path. */ private static function validateRecipeExists(string $name, ExecutionContextInterface $context, string $include_path): void { if (empty($name)) { return; } try { RecipeConfigurator::getIncludedRecipe($include_path, $name); } catch (UnknownRecipeException) { $context->addViolation('The %name recipe does not exist.', ['%name' => $name]); } } /** * Validates that the corresponding extension is enabled for a config action. * * @param mixed $value * The config action; not used. * @param \Symfony\Component\Validator\Context\ExecutionContextInterface $context * The validator execution context. * @param string $include_path * The recipe's include path. */ private static function validateConfigActions(mixed $value, ExecutionContextInterface $context, string $include_path): void { $config_name = str_replace(['[config][actions]', '[', ']'], '', $context->getPropertyPath()); [$config_provider] = explode('.', $config_name); if ($config_provider === 'core') { return; } $recipe_being_validated = $context->getRoot(); assert(is_array($recipe_being_validated)); $configurator = new RecipeConfigurator($recipe_being_validated['recipes'] ?? [], $include_path); /** @var \Drupal\Core\Extension\ModuleExtensionList $module_list */ $module_list = \Drupal::service('extension.list.module'); // The config provider must either be an already-installed module or theme, // or an extension being installed by this recipe or a recipe it depends on. $all_extensions = [ ...array_keys($module_list->getAllInstalledInfo()), ...array_keys(\Drupal::service('extension.list.theme')->getAllInstalledInfo()), ...$recipe_being_validated['install'] ?? [], ...$configurator->listAllExtensions(), ]; // Explicitly treat required modules as installed, even if Drupal isn't // installed yet, because we know they WILL be installed. foreach ($module_list->getAllAvailableInfo() as $name => $info) { if (!empty($info['required'])) { $all_extensions[] = $name; } } if (!in_array($config_provider, $all_extensions, TRUE)) { $context->addViolation('Config actions cannot be applied to %config_name because the %config_provider extension is not installed, and is not installed by this recipe or any of the recipes it depends on.', [ '%config_name' => $config_name, '%config_provider' => $config_provider, ]); } } /** * Validates that the keys of an array are valid extension names. * * Note that the keys do not have to be the names of extensions that are * installed, or even extensions that exist. They just have to follow the * form of a valid extension name. * * @param array $value * The array being validated. * @param \Symfony\Component\Validator\Context\ExecutionContextInterface $context * The validator execution context. */ private static function validateKeysAreValidExtensionNames(array $value, ExecutionContextInterface $context): void { $keys = array_keys($value); foreach ($keys as $key) { if (!preg_match(ExtensionDiscovery::PHP_FUNCTION_PATTERN, $key)) { $context->addViolation('%name is not a valid extension name.', [ '%name' => $key, ]); } } } /** * Returns extra information to expose to a particular extension. * * @param string $extension_name * The name of a Drupal extension. * * @return mixed * The extra data exposed to the given extension, or NULL if there is none. */ public function getExtra(string $extension_name): mixed { return $this->extra[$extension_name] ?? NULL; } }