diff --git a/core/lib/Drupal/Core/Plugin/Plugin/Validation/Constraint/PluginExistsConstraint.php b/core/lib/Drupal/Core/Plugin/Plugin/Validation/Constraint/PluginExistsConstraint.php new file mode 100644 index 0000000000000000000000000000000000000000..b2baceedb7cadedb36b8d765fb9f7b523dbc152a --- /dev/null +++ b/core/lib/Drupal/Core/Plugin/Plugin/Validation/Constraint/PluginExistsConstraint.php @@ -0,0 +1,93 @@ +<?php + +declare(strict_types = 1); + +namespace Drupal\Core\Plugin\Plugin\Validation\Constraint; + +use Drupal\Component\Plugin\PluginManagerInterface; +use Drupal\Core\Plugin\ContainerFactoryPluginInterface; +use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\Exception\MissingOptionsException; + +/** + * Checks if a plugin exists and optionally implements a particular interface. + * + * @Constraint( + * id = "PluginExists", + * label = @Translation("Plugin exists", context = "Validation"), + * ) + */ +class PluginExistsConstraint extends Constraint implements ContainerFactoryPluginInterface { + + /** + * The error message if a plugin does not exist. + * + * @var string + */ + public string $unknownPluginMessage = "The '@plugin_id' plugin does not exist."; + + /** + * The error message if a plugin does not implement the expected interface. + * + * @var string + */ + public string $invalidInterfaceMessage = "The '@plugin_id' plugin must implement or extend @interface."; + + /** + * The ID of the plugin manager service. + * + * @var string + */ + protected string $manager; + + /** + * Optional name of the interface that the plugin must implement. + * + * @var string|null + */ + public ?string $interface = NULL; + + /** + * Constructs a PluginExistsConstraint. + * + * @param \Drupal\Component\Plugin\PluginManagerInterface $pluginManager + * The plugin manager associated with the constraint. + * @param mixed|null $options + * The options (as associative array) or the value for the default option + * (any other type). + * @param array|null $groups + * An array of validation groups. + * @param mixed|null $payload + * Domain-specific data attached to a constraint. + */ + public function __construct(public readonly PluginManagerInterface $pluginManager, mixed $options = NULL, array $groups = NULL, mixed $payload = NULL) { + parent::__construct($options, $groups, $payload); + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + $plugin_manager_id = $configuration['manager'] ?? $configuration['value'] ?? NULL; + if ($plugin_manager_id === NULL) { + throw new MissingOptionsException(sprintf('The option "manager" must be set for constraint "%s".', static::class), ['manager']); + } + return new static($container->get($plugin_manager_id), $configuration); + } + + /** + * {@inheritdoc} + */ + public function getDefaultOption(): ?string { + return 'manager'; + } + + /** + * {@inheritdoc} + */ + public function getRequiredOptions(): array { + return ['manager']; + } + +} diff --git a/core/lib/Drupal/Core/Plugin/Plugin/Validation/Constraint/PluginExistsConstraintValidator.php b/core/lib/Drupal/Core/Plugin/Plugin/Validation/Constraint/PluginExistsConstraintValidator.php new file mode 100644 index 0000000000000000000000000000000000000000..3e09bd229f75e2d9789ec3a8a8a98af60fa7cdeb --- /dev/null +++ b/core/lib/Drupal/Core/Plugin/Plugin/Validation/Constraint/PluginExistsConstraintValidator.php @@ -0,0 +1,43 @@ +<?php + +declare(strict_types = 1); + +namespace Drupal\Core\Plugin\Plugin\Validation\Constraint; + +use Drupal\Component\Plugin\Factory\DefaultFactory; +use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\ConstraintValidator; + +/** + * Validates the PluginExists constraint. + */ +class PluginExistsConstraintValidator extends ConstraintValidator { + + /** + * {@inheritdoc} + */ + public function validate(mixed $plugin_id, Constraint $constraint) { + assert($constraint instanceof PluginExistsConstraint); + + $definition = $constraint->pluginManager->getDefinition($plugin_id, FALSE); + if (empty($definition)) { + $this->context->addViolation($constraint->unknownPluginMessage, [ + '@plugin_id' => $plugin_id, + ]); + return; + } + + // If we don't need to validate the plugin class's interface, we're done. + if (empty($constraint->interface)) { + return; + } + + if (!is_a(DefaultFactory::getPluginClass($plugin_id, $definition), $constraint->interface, TRUE)) { + $this->context->addViolation($constraint->invalidInterfaceMessage, [ + '@plugin_id' => $plugin_id, + '@interface' => $constraint->interface, + ]); + } + } + +} diff --git a/core/modules/block/config/schema/block.schema.yml b/core/modules/block/config/schema/block.schema.yml index 7dc4f53d1ccf5ccb30dcb585ae17fc426981f1ea..d768a9619503220ad3f28260bfb04466023823e8 100644 --- a/core/modules/block/config/schema/block.schema.yml +++ b/core/modules/block/config/schema/block.schema.yml @@ -22,6 +22,10 @@ block.block.*: plugin: type: string label: 'Plugin' + constraints: + PluginExists: + manager: plugin.manager.block + interface: Drupal\Core\Block\BlockPluginInterface settings: type: block.settings.[%parent.plugin] visibility: diff --git a/core/modules/block/tests/src/Kernel/BlockValidationTest.php b/core/modules/block/tests/src/Kernel/BlockValidationTest.php new file mode 100644 index 0000000000000000000000000000000000000000..8d9e07ae1afc3a7f7f033a826466241ae0fa0280 --- /dev/null +++ b/core/modules/block/tests/src/Kernel/BlockValidationTest.php @@ -0,0 +1,42 @@ +<?php + +namespace Drupal\Tests\block\Kernel; + +use Drupal\block\Entity\Block; +use Drupal\KernelTests\Core\Config\ConfigEntityValidationTestBase; + +/** + * Tests validation of block entities. + * + * @group block + */ +class BlockValidationTest extends ConfigEntityValidationTestBase { + + /** + * {@inheritdoc} + */ + protected static $modules = ['block']; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + + $this->entity = Block::create([ + 'id' => 'test_block', + 'theme' => 'stark', + 'plugin' => 'system_powered_by_block', + ]); + $this->entity->save(); + } + + /** + * Tests validating a block with an unknown plugin ID. + */ + public function testInvalidPluginId(): void { + $this->entity->set('plugin', 'non_existent'); + $this->assertValidationErrors(["The 'non_existent' plugin does not exist."]); + } + +} diff --git a/core/modules/editor/config/schema/editor.schema.yml b/core/modules/editor/config/schema/editor.schema.yml index f68cb82056af4711467f788987373e779c46545d..0c0278b71551b5ad793255abc1619009e0771f0b 100644 --- a/core/modules/editor/config/schema/editor.schema.yml +++ b/core/modules/editor/config/schema/editor.schema.yml @@ -10,6 +10,10 @@ editor.editor.*: editor: type: string label: 'Text editor' + constraints: + PluginExists: + manager: plugin.manager.editor + interface: Drupal\editor\Plugin\EditorPluginInterface settings: type: editor.settings.[%parent.editor] image_upload: diff --git a/core/modules/editor/tests/src/Kernel/EditorValidationTest.php b/core/modules/editor/tests/src/Kernel/EditorValidationTest.php index 696060ffbf6a99f2ac99347d97217f181c4e2cb6..c4f0562c4fdd492a9459e6a83c763b22f69ee072 100644 --- a/core/modules/editor/tests/src/Kernel/EditorValidationTest.php +++ b/core/modules/editor/tests/src/Kernel/EditorValidationTest.php @@ -62,4 +62,12 @@ public function testInvalidDependencies(): void { ]); } + /** + * Tests validating an editor with an unknown plugin ID. + */ + public function testInvalidPluginId(): void { + $this->entity->setEditor('non_existent'); + $this->assertValidationErrors(["The 'non_existent' plugin does not exist."]); + } + } diff --git a/core/tests/Drupal/KernelTests/Core/Plugin/PluginExistsConstraintValidatorTest.php b/core/tests/Drupal/KernelTests/Core/Plugin/PluginExistsConstraintValidatorTest.php new file mode 100644 index 0000000000000000000000000000000000000000..6c34c8f033806140b8b1451e1bcadea998a225b5 --- /dev/null +++ b/core/tests/Drupal/KernelTests/Core/Plugin/PluginExistsConstraintValidatorTest.php @@ -0,0 +1,66 @@ +<?php + +namespace Drupal\KernelTests\Core\Plugin; + +use Drupal\Core\Action\ActionInterface; +use Drupal\Core\TypedData\DataDefinition; +use Drupal\KernelTests\KernelTestBase; +use Drupal\system\MenuInterface; + +/** + * @group Plugin + * @group Validation + * + * @covers \Drupal\Core\Plugin\Plugin\Validation\Constraint\PluginExistsConstraint + * @covers \Drupal\Core\Plugin\Plugin\Validation\Constraint\PluginExistsConstraintValidator + */ +class PluginExistsConstraintValidatorTest extends KernelTestBase { + + /** + * {@inheritdoc} + */ + protected static $modules = ['action_test', 'system']; + + /** + * Tests validation of plugin existence. + */ + public function testValidation(): void { + $definition = DataDefinition::create('string') + ->addConstraint('PluginExists', 'plugin.manager.action'); + + // An existing action plugin should pass validation. + $data = $this->container->get('typed_data_manager')->create($definition); + $data->setValue('action_test_save_entity'); + $this->assertCount(0, $data->validate()); + + // It should also pass validation if we check for an interface it actually + // implements. + $definition->setConstraints([ + 'PluginExists' => [ + 'manager' => 'plugin.manager.action', + 'interface' => ActionInterface::class, + ], + ]); + $this->assertCount(0, $data->validate()); + + // A non-existent plugin should be invalid, regardless of interface. + $data->setValue('non_existent_plugin'); + $violations = $data->validate(); + $this->assertCount(1, $violations); + $this->assertSame("The 'non_existent_plugin' plugin does not exist.", (string) $violations->get(0)->getMessage()); + + // An existing plugin that doesn't implement the specified interface should + // raise an error. + $definition->setConstraints([ + 'PluginExists' => [ + 'manager' => 'plugin.manager.action', + 'interface' => MenuInterface::class, + ], + ]); + $data->setValue('action_test_save_entity'); + $violations = $data->validate(); + $this->assertCount(1, $violations); + $this->assertSame("The 'action_test_save_entity' plugin must implement or extend " . MenuInterface::class . '.', (string) $violations->get(0)->getMessage()); + } + +} diff --git a/core/tests/Drupal/Tests/Core/Plugin/PluginExistsConstraintTest.php b/core/tests/Drupal/Tests/Core/Plugin/PluginExistsConstraintTest.php new file mode 100644 index 0000000000000000000000000000000000000000..d5210d2e0963aafae5b18ff76e1f7ac336619daa --- /dev/null +++ b/core/tests/Drupal/Tests/Core/Plugin/PluginExistsConstraintTest.php @@ -0,0 +1,51 @@ +<?php + +namespace Drupal\Tests\Core\Plugin; + +use Drupal\Component\DependencyInjection\ContainerInterface; +use Drupal\Component\Plugin\PluginManagerInterface; +use Drupal\Core\Plugin\Plugin\Validation\Constraint\PluginExistsConstraint; +use Drupal\Tests\UnitTestCase; +use Symfony\Component\Validator\Exception\MissingOptionsException; + +/** + * @group Plugin + * @group Validation + * + * @coversDefaultClass \Drupal\Core\Plugin\Plugin\Validation\Constraint\PluginExistsConstraint + */ +class PluginExistsConstraintTest extends UnitTestCase { + + /** + * Tests missing option. + * + * @covers ::create + */ + public function testMissingOption(): void { + $this->expectException(MissingOptionsException::class); + $this->expectExceptionMessage('The option "manager" must be set for constraint "Drupal\Core\Plugin\Plugin\Validation\Constraint\PluginExistsConstraint".'); + $container = $this->createMock(ContainerInterface::class); + PluginExistsConstraint::create($container, [], 'test_plugin_id', []); + } + + /** + * Tests with different option keys. + * + * @testWith ["value"] + * ["manager"] + * + * @covers ::create + * @covers ::__construct + */ + public function testOption(string $option_key): void { + $container = $this->createMock(ContainerInterface::class); + $manager = $this->createMock(PluginManagerInterface::class); + $container->expects($this->any()) + ->method('get') + ->with('plugin.manager.mock') + ->willReturn($manager); + $constraint = PluginExistsConstraint::create($container, [$option_key => 'plugin.manager.mock'], 'test_plugin_id', []); + $this->assertSame($manager, $constraint->pluginManager); + } + +}