mirror of
https://github.com/symfony/symfony.git
synced 2026-03-24 00:32:15 +01:00
[Console] Add validation constraints support to #[MapInput]
This commit is contained in:
committed by
Nicolas Grekas
parent
0fed979575
commit
9ceb0fb59c
@@ -459,6 +459,7 @@ return static function (ContainerConfigurator $container) {
|
||||
service('console.argument_resolver.builtin_type'),
|
||||
service('console.argument_resolver.backed_enum'),
|
||||
service('console.argument_resolver.datetime'),
|
||||
service('validator')->nullOnInvalid(),
|
||||
])
|
||||
->tag('console.argument_value_resolver', ['priority' => 100, 'name' => MapInputValueResolver::class])
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\Bundle\FrameworkBundle\Tests\Fixtures\Console;
|
||||
|
||||
use Symfony\Component\Console\Attribute\Argument;
|
||||
use Symfony\Component\Console\Attribute\Option;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
class ValidatedInput
|
||||
{
|
||||
#[Argument]
|
||||
#[Assert\NotBlank]
|
||||
public string $name;
|
||||
|
||||
#[Option]
|
||||
#[Assert\Email]
|
||||
public ?string $email = null;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\Bundle\FrameworkBundle\Tests\Fixtures\Console;
|
||||
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Attribute\MapInput;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
#[AsCommand(name: 'app:validated-input', description: 'Tests validated MapInput')]
|
||||
class ValidatedInputCommand
|
||||
{
|
||||
public function __invoke(
|
||||
OutputInterface $output,
|
||||
#[MapInput]
|
||||
ValidatedInput $input,
|
||||
): int {
|
||||
$output->writeln('Name: '.$input->name);
|
||||
$output->writeln('Email: '.$input->email);
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -81,6 +81,44 @@ class ConsoleArgumentResolverTest extends AbstractWebTestCase
|
||||
$this->assertStringContainsString('Name: test-advanced', $output);
|
||||
}
|
||||
|
||||
public function testValidatedMapInputWithValidData()
|
||||
{
|
||||
$application = new Application(static::$kernel);
|
||||
$application->setAutoExit(false);
|
||||
|
||||
$tester = new ApplicationTester($application);
|
||||
$tester->run([
|
||||
'command' => 'app:validated-input',
|
||||
'name' => 'John',
|
||||
'--email' => 'john@example.com',
|
||||
]);
|
||||
|
||||
$tester->assertCommandIsSuccessful();
|
||||
$output = $tester->getDisplay();
|
||||
|
||||
$this->assertStringContainsString('Name: John', $output);
|
||||
$this->assertStringContainsString('Email: john@example.com', $output);
|
||||
}
|
||||
|
||||
public function testValidatedMapInputWithInvalidData()
|
||||
{
|
||||
$application = new Application(static::$kernel);
|
||||
$application->setAutoExit(false);
|
||||
|
||||
$tester = new ApplicationTester($application);
|
||||
$tester->run([
|
||||
'command' => 'app:validated-input',
|
||||
'name' => '',
|
||||
'--email' => 'not-an-email',
|
||||
]);
|
||||
|
||||
$this->assertNotSame(0, $tester->getStatusCode());
|
||||
$output = $tester->getDisplay();
|
||||
|
||||
$this->assertStringContainsString('name:', $output);
|
||||
$this->assertStringContainsString('email:', $output);
|
||||
}
|
||||
|
||||
public function testValueResolverAutoconfiguration()
|
||||
{
|
||||
$application = new Application(static::$kernel);
|
||||
|
||||
@@ -35,3 +35,7 @@ services:
|
||||
Symfony\Bundle\FrameworkBundle\Tests\Fixtures\Console\ResolverTestCommand:
|
||||
autowire: true
|
||||
autoconfigure: true
|
||||
|
||||
Symfony\Bundle\FrameworkBundle\Tests\Fixtures\Console\ValidatedInputCommand:
|
||||
autowire: true
|
||||
autoconfigure: true
|
||||
|
||||
@@ -15,7 +15,9 @@ use Symfony\Component\Console\Attribute\Argument;
|
||||
use Symfony\Component\Console\Attribute\MapInput;
|
||||
use Symfony\Component\Console\Attribute\Option;
|
||||
use Symfony\Component\Console\Attribute\Reflection\ReflectionMember;
|
||||
use Symfony\Component\Console\Exception\InputValidationFailedException;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Validator\Validator\ValidatorInterface;
|
||||
|
||||
/**
|
||||
* Resolves the value of a input argument/option to an object holding the #[MapInput] attribute.
|
||||
@@ -29,6 +31,7 @@ final class MapInputValueResolver implements ValueResolverInterface
|
||||
private readonly ValueResolverInterface $builtinTypeResolver,
|
||||
private readonly ValueResolverInterface $backedEnumResolver,
|
||||
private readonly ValueResolverInterface $dateTimeResolver,
|
||||
private readonly ?ValidatorInterface $validator = null,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -38,7 +41,22 @@ final class MapInputValueResolver implements ValueResolverInterface
|
||||
return [];
|
||||
}
|
||||
|
||||
return [$this->resolveMapInput($attribute, $input)];
|
||||
$instance = $this->resolveMapInput($attribute, $input);
|
||||
$violations = $this->validator?->validate($instance, null, $attribute->validationGroups) ?? [];
|
||||
|
||||
if (!\count($violations)) {
|
||||
return [$instance];
|
||||
}
|
||||
|
||||
$map = $this->buildPropertyToInputMap($attribute);
|
||||
$messages = [];
|
||||
foreach ($violations as $violation) {
|
||||
$path = $violation->getPropertyPath();
|
||||
$label = $map[$path] ?? $path;
|
||||
$messages[] = $label.': '.$violation->getMessage();
|
||||
}
|
||||
|
||||
throw new InputValidationFailedException(implode("\n", $messages), $violations);
|
||||
}
|
||||
|
||||
private function resolveMapInput(MapInput $mapInput, InputInterface $input): object
|
||||
@@ -61,6 +79,27 @@ final class MapInputValueResolver implements ValueResolverInterface
|
||||
return $instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function buildPropertyToInputMap(MapInput $mapInput, string $prefix = ''): array
|
||||
{
|
||||
$map = [];
|
||||
foreach ($mapInput->getDefinition() as $propertyName => $spec) {
|
||||
$path = $prefix.$propertyName;
|
||||
$map[$path] = match (true) {
|
||||
$spec instanceof Argument => $spec->name,
|
||||
$spec instanceof Option => '--'.$spec->name,
|
||||
default => $path,
|
||||
};
|
||||
if ($spec instanceof MapInput) {
|
||||
$map += $this->buildPropertyToInputMap($spec, $path.'.');
|
||||
}
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
private function resolveArgumentSpec(Argument $argument, \ReflectionProperty $property, InputInterface $input): mixed
|
||||
{
|
||||
if (is_subclass_of($argument->typeName, \BackedEnum::class)) {
|
||||
|
||||
@@ -34,6 +34,14 @@ final class MapInput
|
||||
*/
|
||||
private array $interactiveAttributes = [];
|
||||
|
||||
/**
|
||||
* @param string[]|null $validationGroups
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly ?array $validationGroups = null,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
|
||||
@@ -14,6 +14,7 @@ CHANGELOG
|
||||
* Add support for method-based commands with `#[AsCommand]` attribute
|
||||
* Add argument resolver support
|
||||
* Add `BackedEnum` and `DateTimeInterface` support to `#[MapInput]`
|
||||
* Add validation constraints support to `#[MapInput]` along with optional `validationGroups` to control which groups are validated
|
||||
* Add `TesterTrait::assertCommandFailed()` to test command
|
||||
* Add `TesterTrait::assertCommandIsInvalid()` to test command
|
||||
* Add a result-based testing API with `CommandTester::run()`, `ExecutionResult`, and `ConsoleAssertionsTrait` to assert output and error streams together
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\Component\Console\Exception;
|
||||
|
||||
use Symfony\Component\Validator\ConstraintViolationListInterface;
|
||||
|
||||
/**
|
||||
* @author Robin Chalas <robin.chalas@gmail.com>
|
||||
*/
|
||||
final class InputValidationFailedException extends RuntimeException
|
||||
{
|
||||
public function __construct(
|
||||
string $message,
|
||||
private readonly ConstraintViolationListInterface $violations,
|
||||
) {
|
||||
parent::__construct($message);
|
||||
}
|
||||
|
||||
public function getViolations(): ConstraintViolationListInterface
|
||||
{
|
||||
return $this->violations;
|
||||
}
|
||||
}
|
||||
@@ -20,10 +20,12 @@ use Symfony\Component\Console\Attribute\Argument;
|
||||
use Symfony\Component\Console\Attribute\MapInput;
|
||||
use Symfony\Component\Console\Attribute\Option;
|
||||
use Symfony\Component\Console\Attribute\Reflection\ReflectionMember;
|
||||
use Symfony\Component\Console\Exception\InputValidationFailedException;
|
||||
use Symfony\Component\Console\Input\ArrayInput;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputDefinition;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Validator\Validation;
|
||||
|
||||
class MapInputValueResolverTest extends TestCase
|
||||
{
|
||||
@@ -122,6 +124,139 @@ class MapInputValueResolverTest extends TestCase
|
||||
$this->assertSame('2024-01-15', $result[0]->createdAt->format('Y-m-d'));
|
||||
$this->assertSame(DummyStatus::Active, $result[0]->status);
|
||||
}
|
||||
|
||||
public function testValidationPassesWithValidInput()
|
||||
{
|
||||
$validator = Validation::createValidatorBuilder()->enableAttributeMapping()->getValidator();
|
||||
$resolver = new MapInputValueResolver(new BuiltinTypeValueResolver(), new BackedEnumValueResolver(), new DateTimeValueResolver(), $validator);
|
||||
|
||||
$input = new ArrayInput(['username' => 'john', '--email' => 'john@example.com'], new InputDefinition([
|
||||
new InputArgument('username'),
|
||||
new InputOption('email'),
|
||||
]));
|
||||
|
||||
$command = new class {
|
||||
public function __invoke(
|
||||
#[MapInput]
|
||||
DummyValidatedInput $input,
|
||||
) {
|
||||
}
|
||||
};
|
||||
$reflection = new \ReflectionMethod($command, '__invoke');
|
||||
$member = new ReflectionMember($reflection->getParameters()[0]);
|
||||
|
||||
$result = $resolver->resolve('input', $input, $member);
|
||||
|
||||
$this->assertCount(1, $result);
|
||||
$this->assertSame('john', $result[0]->username);
|
||||
$this->assertSame('john@example.com', $result[0]->email);
|
||||
}
|
||||
|
||||
public function testValidationFailsWithInvalidInput()
|
||||
{
|
||||
$validator = Validation::createValidatorBuilder()->enableAttributeMapping()->getValidator();
|
||||
$resolver = new MapInputValueResolver(new BuiltinTypeValueResolver(), new BackedEnumValueResolver(), new DateTimeValueResolver(), $validator);
|
||||
|
||||
$input = new ArrayInput(['username' => '', '--email' => 'not-an-email'], new InputDefinition([
|
||||
new InputArgument('username'),
|
||||
new InputOption('email'),
|
||||
]));
|
||||
|
||||
$command = new class {
|
||||
public function __invoke(
|
||||
#[MapInput]
|
||||
DummyValidatedInput $input,
|
||||
) {
|
||||
}
|
||||
};
|
||||
$reflection = new \ReflectionMethod($command, '__invoke');
|
||||
$member = new ReflectionMember($reflection->getParameters()[0]);
|
||||
|
||||
try {
|
||||
$resolver->resolve('input', $input, $member);
|
||||
$this->fail('Expected InputValidationFailedException was not thrown.');
|
||||
} catch (InputValidationFailedException $e) {
|
||||
$this->assertGreaterThan(0, \count($e->getViolations()));
|
||||
$this->assertStringContainsString('username:', $e->getMessage());
|
||||
$this->assertStringContainsString('--email:', $e->getMessage());
|
||||
$this->assertStringNotContainsString('Object(', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function testValidationSkippedWhenNoValidator()
|
||||
{
|
||||
$resolver = new MapInputValueResolver(new BuiltinTypeValueResolver(), new BackedEnumValueResolver(), new DateTimeValueResolver());
|
||||
|
||||
$input = new ArrayInput(['username' => '', '--email' => 'not-an-email'], new InputDefinition([
|
||||
new InputArgument('username'),
|
||||
new InputOption('email'),
|
||||
]));
|
||||
|
||||
$command = new class {
|
||||
public function __invoke(
|
||||
#[MapInput]
|
||||
DummyValidatedInput $input,
|
||||
) {
|
||||
}
|
||||
};
|
||||
$reflection = new \ReflectionMethod($command, '__invoke');
|
||||
$member = new ReflectionMember($reflection->getParameters()[0]);
|
||||
|
||||
$result = $resolver->resolve('input', $input, $member);
|
||||
|
||||
$this->assertCount(1, $result);
|
||||
$this->assertSame('', $result[0]->username);
|
||||
}
|
||||
|
||||
public function testValidationWithGroupsSkipsNonMatchingConstraints()
|
||||
{
|
||||
$validator = Validation::createValidatorBuilder()->enableAttributeMapping()->getValidator();
|
||||
$resolver = new MapInputValueResolver(new BuiltinTypeValueResolver(), new BackedEnumValueResolver(), new DateTimeValueResolver(), $validator);
|
||||
|
||||
$input = new ArrayInput(['username' => '', '--email' => 'john@example.com'], new InputDefinition([
|
||||
new InputArgument('username'),
|
||||
new InputOption('email'),
|
||||
]));
|
||||
|
||||
$command = new class {
|
||||
public function __invoke(
|
||||
#[MapInput(validationGroups: ['strict'])]
|
||||
DummyGroupValidatedInput $input,
|
||||
) {
|
||||
}
|
||||
};
|
||||
$reflection = new \ReflectionMethod($command, '__invoke');
|
||||
$member = new ReflectionMember($reflection->getParameters()[0]);
|
||||
|
||||
$result = $resolver->resolve('input', $input, $member);
|
||||
|
||||
$this->assertCount(1, $result);
|
||||
$this->assertSame('', $result[0]->username);
|
||||
}
|
||||
|
||||
public function testValidationWithGroupsEnforcesMatchingConstraints()
|
||||
{
|
||||
$validator = Validation::createValidatorBuilder()->enableAttributeMapping()->getValidator();
|
||||
$resolver = new MapInputValueResolver(new BuiltinTypeValueResolver(), new BackedEnumValueResolver(), new DateTimeValueResolver(), $validator);
|
||||
|
||||
$input = new ArrayInput(['username' => '', '--email' => 'not-an-email'], new InputDefinition([
|
||||
new InputArgument('username'),
|
||||
new InputOption('email'),
|
||||
]));
|
||||
|
||||
$command = new class {
|
||||
public function __invoke(
|
||||
#[MapInput(validationGroups: ['strict'])]
|
||||
DummyGroupValidatedInput $input,
|
||||
) {
|
||||
}
|
||||
};
|
||||
$reflection = new \ReflectionMethod($command, '__invoke');
|
||||
$member = new ReflectionMember($reflection->getParameters()[0]);
|
||||
|
||||
$this->expectException(InputValidationFailedException::class);
|
||||
$resolver->resolve('input', $input, $member);
|
||||
}
|
||||
}
|
||||
|
||||
class DummyInput
|
||||
@@ -148,3 +283,25 @@ enum DummyStatus: string
|
||||
case Active = 'active';
|
||||
case Inactive = 'inactive';
|
||||
}
|
||||
|
||||
class DummyValidatedInput
|
||||
{
|
||||
#[Argument]
|
||||
#[\Symfony\Component\Validator\Constraints\NotBlank]
|
||||
public string $username;
|
||||
|
||||
#[Option]
|
||||
#[\Symfony\Component\Validator\Constraints\Email]
|
||||
public ?string $email = null;
|
||||
}
|
||||
|
||||
class DummyGroupValidatedInput
|
||||
{
|
||||
#[Argument]
|
||||
#[\Symfony\Component\Validator\Constraints\NotBlank]
|
||||
public string $username;
|
||||
|
||||
#[Option]
|
||||
#[\Symfony\Component\Validator\Constraints\Email(groups: ['strict'])]
|
||||
public ?string $email = null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user