Issue #2920682 by phenaproxima, alexpott, Sam152, borisson_, Wim Leers, larowlan: Add config validation for plugin IDs

merge-requests/3202/head
Lee Rowlands 2023-01-10 09:00:04 +10:00
parent 9cc7cfaa1c
commit 529550a4b2
No known key found for this signature in database
GPG Key ID: 2B829A3DF9204DC4
8 changed files with 311 additions and 0 deletions

View File

@ -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'];
}
}

View File

@ -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,
]);
}
}
}

View File

@ -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:

View File

@ -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."]);
}
}

View File

@ -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:

View File

@ -62,4 +62,12 @@ class EditorValidationTest extends ConfigEntityValidationTestBase {
]);
}
/**
* 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."]);
}
}

View File

@ -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());
}
}

View File

@ -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);
}
}