diff --git a/Application.php b/Application.php index e8f4c205..6634cd8f 100644 --- a/Application.php +++ b/Application.php @@ -112,6 +112,11 @@ class Application implements ResetInterface $this->dispatcher = $dispatcher; } + public function getDispatcher(): ?EventDispatcherInterface + { + return $this->dispatcher; + } + /** * @final */ diff --git a/Attribute/Ask.php b/Attribute/Ask.php index 39726de7..722f4176 100644 --- a/Attribute/Ask.php +++ b/Attribute/Ask.php @@ -20,6 +20,7 @@ use Symfony\Component\Console\Question\ConfirmationQuestion; use Symfony\Component\Console\Question\FileQuestion; use Symfony\Component\Console\Question\Question; use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Component\Validator\Constraint; #[\Attribute(\Attribute::TARGET_PARAMETER | \Attribute::TARGET_PROPERTY)] class Ask implements InteractiveAttributeInterface @@ -49,6 +50,8 @@ class Ask implements InteractiveAttributeInterface ?callable $normalizer = null, ?callable $validator = null, public ?int $maxAttempts = null, + /** @var Constraint[] */ + public array $constraints = [], ) { $this->normalizer = $normalizer ? $normalizer(...) : null; $this->validator = $validator ? $validator(...) : null; @@ -86,6 +89,7 @@ class Ask implements InteractiveAttributeInterface $question = new FileQuestion($self->question); $question->setValidator($self->validator); $question->setMaxAttempts($self->maxAttempts); + $question->setConstraints($self->constraints); $value = $io->askQuestion($question); if (null === $value && !$reflection->isNullable()) { @@ -123,6 +127,7 @@ class Ask implements InteractiveAttributeInterface $question->setValidator($self->validator); $question->setMaxAttempts($self->maxAttempts); + $question->setConstraints($self->constraints); if ($self->normalizer) { $question->setNormalizer($self->normalizer); diff --git a/CHANGELOG.md b/CHANGELOG.md index b58fa2f4..7589e740 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,8 +5,9 @@ CHANGELOG --- * [BC BREAK] Add `object` support to input options and arguments' default by changing the `$default` type to `mixed` in `InputArgument`, `InputOption`, `#[Argument]` and `#[Option]` - * Support pasting images with `#[Ask]` on `InputFile` types, supporting Kitty Graphics and iTerm2 protocols + * Add support for pasting images with `#[Ask]` on `InputFile` types, supporting Kitty Graphics and iTerm2 protocols * Add `FileQuestion`, `InputFile`, `InputFileValueResolver`, and `SymfonyStyle::askFile()` for file input handling + * Add `Question::setConstraints()` and `ValidateQuestionInputListener` to validate question input using Validator constraints * Add `#[AskChoice]` attribute for interactive choice questions in invokable commands * Add support for method-based commands with `#[AsCommand]` attribute * Add argument resolver support diff --git a/Command/InvokableCommand.php b/Command/InvokableCommand.php index 07c0d977..2259224b 100644 --- a/Command/InvokableCommand.php +++ b/Command/InvokableCommand.php @@ -142,7 +142,7 @@ class InvokableCommand implements SignalableCommandInterface $argument = match ($type->getName()) { InputInterface::class => $input, OutputInterface::class => $output, - SymfonyStyle::class => new SymfonyStyle($input, $output), + SymfonyStyle::class => new SymfonyStyle($input, $output, $this->command->getApplication()?->getDispatcher()), Cursor::class => new Cursor($output), Application::class => $this->command->getApplication(), Command::class, self::class => $this->command, diff --git a/ConsoleEvents.php b/ConsoleEvents.php index 6ae8f32b..d3b2101d 100644 --- a/ConsoleEvents.php +++ b/ConsoleEvents.php @@ -15,6 +15,7 @@ use Symfony\Component\Console\Event\ConsoleCommandEvent; use Symfony\Component\Console\Event\ConsoleErrorEvent; use Symfony\Component\Console\Event\ConsoleSignalEvent; use Symfony\Component\Console\Event\ConsoleTerminateEvent; +use Symfony\Component\Console\Event\QuestionAnsweredEvent; /** * Contains all events dispatched by an Application. @@ -58,6 +59,14 @@ final class ConsoleEvents */ public const ERROR = 'console.error'; + /** + * The QUESTION_ANSWERED event allows you to validate user input + * using Symfony Validator constraints. + * + * @Event("Symfony\Component\Console\Event\QuestionAnsweredEvent") + */ + public const QUESTION_ANSWERED = 'console.question_answered'; + /** * Event aliases. * @@ -68,5 +77,6 @@ final class ConsoleEvents ConsoleErrorEvent::class => self::ERROR, ConsoleSignalEvent::class => self::SIGNAL, ConsoleTerminateEvent::class => self::TERMINATE, + QuestionAnsweredEvent::class => self::QUESTION_ANSWERED, ]; } diff --git a/Event/QuestionAnsweredEvent.php b/Event/QuestionAnsweredEvent.php new file mode 100644 index 00000000..c3b173d6 --- /dev/null +++ b/Event/QuestionAnsweredEvent.php @@ -0,0 +1,45 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Console\Event; + +use Symfony\Contracts\EventDispatcher\Event; + +/** + * Event dispatched when constraint validation is needed for a question. + * + * @author Robin Chalas + */ +class QuestionAnsweredEvent extends Event +{ + private array $violations = []; + + public function __construct( + public readonly mixed $value, + public readonly array $constraints, + ) { + } + + public function addViolation(string $message): void + { + $this->violations[] = $message; + } + + public function getViolations(): array + { + return $this->violations; + } + + public function hasViolations(): bool + { + return (bool) $this->violations; + } +} diff --git a/EventListener/ValidateQuestionInputListener.php b/EventListener/ValidateQuestionInputListener.php new file mode 100644 index 00000000..800a4d6a --- /dev/null +++ b/EventListener/ValidateQuestionInputListener.php @@ -0,0 +1,44 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Console\EventListener; + +use Symfony\Component\Console\ConsoleEvents; +use Symfony\Component\Console\Event\QuestionAnsweredEvent; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\Validator\Validator\ValidatorInterface; + +/** + * Validates Question answers (user input) using the Validator component. + * + * @author Robin Chalas + */ +final class ValidateQuestionInputListener implements EventSubscriberInterface +{ + public function __construct( + private readonly ValidatorInterface $validator, + ) { + } + + public function onQuestionAnswered(QuestionAnsweredEvent $event): void + { + $violations = $this->validator->validate($event->value, $event->constraints); + + foreach ($violations as $violation) { + $event->addViolation($violation->getMessage()); + } + } + + public static function getSubscribedEvents(): array + { + return [ConsoleEvents::QUESTION_ANSWERED => 'onQuestionAnswered']; + } +} diff --git a/Helper/FileInputHelper.php b/Helper/FileInputHelper.php index 3016347e..be2a3be0 100644 --- a/Helper/FileInputHelper.php +++ b/Helper/FileInputHelper.php @@ -57,7 +57,7 @@ final class FileInputHelper $file = $this->readWithPasteDetection($inputStream, $output, $question, $inputHelper); } elseif ($question->isPathAllowed()) { - $file = $this->readPathInput($inputStream, $output, $question); + $file = $this->readPathInput($inputStream); } else { throw new MissingInputException('Terminal does not support image paste and path input is disabled.'); } @@ -68,7 +68,10 @@ final class FileInputHelper } } - $this->validateFile($file, $question); + if (!$file->isValid()) { + throw new InvalidFileException(\sprintf('File "%s" is not valid or readable.', $file->getPathname())); + } + $this->displayFile($output, $file); return $file; @@ -153,7 +156,7 @@ final class FileInputHelper /** * @param resource $inputStream */ - private function readPathInput($inputStream, OutputInterface $output, FileQuestion $question): InputFile + private function readPathInput($inputStream): InputFile { if (!$isBlocked = stream_get_meta_data($inputStream)['blocked'] ?? true) { stream_set_blocking($inputStream, true); @@ -176,27 +179,6 @@ final class FileInputHelper return InputFile::fromPath($path); } - private function validateFile(InputFile $file, FileQuestion $question): void - { - if (!$file->isValid()) { - throw new InvalidFileException(\sprintf('File "%s" is not valid or readable.', $file->getPathname())); - } - - if (null !== $question->getMaxFileSize() && $file->getSize() > $question->getMaxFileSize()) { - throw new InvalidFileException(\sprintf('File "%s" is too large (%s). Maximum allowed size is %s.', $file->getFilename(), $file->getHumanReadableSize(), $this->formatBytes($question->getMaxFileSize()))); - } - - if (!$question->getAllowedMimeTypes()) { - return; - } - - $mimeType = $file->getMimeType(); - - if (null === $mimeType || !$question->isMimeTypeAllowed($mimeType)) { - throw new InvalidFileException(\sprintf('File "%s" has MIME type "%s" which is not allowed. Allowed types: %s.', $file->getFilename(), $mimeType ?? 'unknown', implode(', ', $question->getAllowedMimeTypes()))); - } - } - private function detectProtocol(): ?ImageProtocolInterface { if (Terminal::supportsKittyGraphics()) { @@ -232,13 +214,4 @@ final class FileInputHelper $output->write($protocol->encode($contents, 16)); $output->writeln(''); } - - private function formatBytes(int $bytes): string - { - $units = ['B', 'KB', 'MB', 'GB']; - $power = $bytes > 0 ? floor(log($bytes, 1024)) : 0; - $power = min($power, \count($units) - 1); - - return \sprintf('%.1f %s', $bytes / (1024 ** $power), $units[$power]); - } } diff --git a/Helper/QuestionHelper.php b/Helper/QuestionHelper.php index ebcd27a1..4dfbc1f3 100644 --- a/Helper/QuestionHelper.php +++ b/Helper/QuestionHelper.php @@ -11,7 +11,10 @@ namespace Symfony\Component\Console\Helper; +use Symfony\Component\Console\ConsoleEvents; use Symfony\Component\Console\Cursor; +use Symfony\Component\Console\Event\QuestionAnsweredEvent; +use Symfony\Component\Console\Exception\InvalidArgumentException; use Symfony\Component\Console\Exception\MissingInputException; use Symfony\Component\Console\Exception\RuntimeException; use Symfony\Component\Console\Formatter\OutputFormatter; @@ -25,6 +28,8 @@ use Symfony\Component\Console\Question\ChoiceQuestion; use Symfony\Component\Console\Question\FileQuestion; use Symfony\Component\Console\Question\Question; use Symfony\Component\Console\Terminal; +use Symfony\Component\Validator\Validation; +use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; use function Symfony\Component\String\s; @@ -38,6 +43,11 @@ class QuestionHelper extends Helper private static bool $stty = true; private static bool $stdinIsInteractive; + public function __construct( + private ?EventDispatcherInterface $dispatcher = null, + ) { + } + /** * Asks a question to the user. * @@ -59,7 +69,7 @@ class QuestionHelper extends Helper $inputStream ??= \STDIN; try { - if (!$question->getValidator()) { + if (!$question->getValidator() && !$question->getConstraints()) { return $this->doAsk($inputStream, $output, $question); } @@ -471,7 +481,17 @@ class QuestionHelper extends Helper } try { - return $question->getValidator()($interviewer()); + $value = $interviewer(); + + if ($constraints = $question->getConstraints()) { + $this->validateConstraints($value, $constraints); + } + + if ($validator = $question->getValidator()) { + return $validator($value); + } + + return $value; } catch (RuntimeException $e) { throw $e; } catch (\Exception $error) { @@ -481,6 +501,27 @@ class QuestionHelper extends Helper throw $error; } + private function validateConstraints(mixed $value, array $constraints): void + { + if ($this->dispatcher) { + $event = new QuestionAnsweredEvent($value, $constraints); + $this->dispatcher->dispatch($event, ConsoleEvents::QUESTION_ANSWERED); + + if ($event->hasViolations()) { + throw new InvalidArgumentException($event->getViolations()[0]); + } + + return; + } + + $validator = Validation::createValidator(); + $violations = $validator->validate($value, $constraints); + + if (\count($violations) > 0) { + throw new InvalidArgumentException($violations[0]->getMessage()); + } + } + private function isInteractiveInput($inputStream): bool { if ('php://stdin' !== (stream_get_meta_data($inputStream)['uri'] ?? null)) { diff --git a/Question/FileQuestion.php b/Question/FileQuestion.php index 29b5d977..8d2bf3bc 100644 --- a/Question/FileQuestion.php +++ b/Question/FileQuestion.php @@ -20,18 +20,10 @@ use Symfony\Component\Console\Exception\InvalidArgumentException; */ class FileQuestion extends Question { - /** @var string[] */ - private array $allowedMimeTypes; - private ?int $maxFileSize; - private bool $allowPaste; - private bool $allowPath; - public function __construct( string $question, - array $allowedMimeTypes = [], - ?int $maxFileSize = 5 * 1024 * 1024, - bool $allowPaste = true, - bool $allowPath = true, + private bool $allowPaste = true, + private bool $allowPath = true, ) { parent::__construct($question); @@ -39,27 +31,9 @@ class FileQuestion extends Question throw new InvalidArgumentException('At least one of allowPaste or allowPath must be true.'); } - $this->allowedMimeTypes = $allowedMimeTypes; - $this->maxFileSize = $maxFileSize; - $this->allowPaste = $allowPaste; - $this->allowPath = $allowPath; - $this->setTrimmable(false); } - /** - * @return string[] - */ - public function getAllowedMimeTypes(): array - { - return $this->allowedMimeTypes; - } - - public function getMaxFileSize(): ?int - { - return $this->maxFileSize; - } - public function isPasteAllowed(): bool { return $this->allowPaste; @@ -69,26 +43,4 @@ class FileQuestion extends Question { return $this->allowPath; } - - public function isMimeTypeAllowed(string $mimeType): bool - { - if (!$this->allowedMimeTypes) { - return true; - } - - foreach ($this->allowedMimeTypes as $allowedType) { - if ($mimeType === $allowedType) { - return true; - } - - if (str_ends_with($allowedType, '/*')) { - $prefix = substr($allowedType, 0, -1); - if (str_starts_with($mimeType, $prefix)) { - return true; - } - } - } - - return false; - } } diff --git a/Question/Question.php b/Question/Question.php index 000c8916..196d5309 100644 --- a/Question/Question.php +++ b/Question/Question.php @@ -13,6 +13,7 @@ namespace Symfony\Component\Console\Question; use Symfony\Component\Console\Exception\InvalidArgumentException; use Symfony\Component\Console\Exception\LogicException; +use Symfony\Component\Validator\Constraint; /** * Represents a Question. @@ -39,6 +40,10 @@ class Question private bool $trimmable = true; private bool $multiline = false; private ?int $timeout = null; + /** + * @var Constraint[] + */ + private array $constraints = []; /** * @param string $question The question to ask to the user @@ -316,4 +321,24 @@ class Question return $this; } + + /** + * @param Constraint[] $constraints + * + * @return $this + */ + public function setConstraints(array $constraints): static + { + $this->constraints = $constraints; + + return $this; + } + + /** + * @return Constraint[] + */ + public function getConstraints(): array + { + return $this->constraints; + } } diff --git a/Style/SymfonyStyle.php b/Style/SymfonyStyle.php index 14befbdf..8c47cc45 100644 --- a/Style/SymfonyStyle.php +++ b/Style/SymfonyStyle.php @@ -35,6 +35,7 @@ use Symfony\Component\Console\Question\ConfirmationQuestion; use Symfony\Component\Console\Question\FileQuestion; use Symfony\Component\Console\Question\Question; use Symfony\Component\Console\Terminal; +use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; /** * Output decorator helpers for the Symfony Style Guide. @@ -53,6 +54,7 @@ class SymfonyStyle extends OutputStyle public function __construct( private InputInterface $input, private OutputInterface $output, + private ?EventDispatcherInterface $dispatcher = null, ) { $this->bufferedOutput = new TrimmedBufferOutput(\DIRECTORY_SEPARATOR === '\\' ? 4 : 2, $output->getVerbosity(), false, clone $output->getFormatter()); // Windows cmd wraps lines as soon as the terminal width is reached, whether there are following chars or not. @@ -249,9 +251,9 @@ class SymfonyStyle extends OutputStyle return $this->askQuestion($questionChoice); } - public function askFile(string $question, array $allowedMimeTypes = []): ?InputFile + public function askFile(string $question): ?InputFile { - return $this->askQuestion(new FileQuestion($question, $allowedMimeTypes)); + return $this->askQuestion(new FileQuestion($question)); } public function progressStart(int $max = 0): void @@ -309,7 +311,7 @@ class SymfonyStyle extends OutputStyle $this->autoPrependBlock(); } - $this->questionHelper ??= new SymfonyQuestionHelper(); + $this->questionHelper ??= new SymfonyQuestionHelper($this->dispatcher); $answer = $this->questionHelper->ask($this->input, $this, $question); diff --git a/Tests/Command/InvokableCommandTest.php b/Tests/Command/InvokableCommandTest.php index 349242e3..bab3ae58 100644 --- a/Tests/Command/InvokableCommandTest.php +++ b/Tests/Command/InvokableCommandTest.php @@ -35,6 +35,8 @@ use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; use Symfony\Component\Console\Tester\CommandTester; use Symfony\Component\Console\Tests\Fixtures\InvokableTestCommand; +use Symfony\Component\Console\Tests\Fixtures\InvokableWithCustomValidatorTestCommand; +use Symfony\Component\Console\Tests\Fixtures\InvokableWithInputFileAndConstraintsTestCommand; class InvokableCommandTest extends TestCase { @@ -555,6 +557,42 @@ class InvokableCommandTest extends TestCase { return ['ROLE_ADMIN', 'ROLE_USER']; } + + public function testAskWithInputFileAndConstraints() + { + if (!\extension_loaded('fileinfo')) { + $this->markTestSkipped('The "fileinfo" extension is required for this test.'); + } + + $pngData = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='); + $tempFile = sys_get_temp_dir().'/test_image_'.uniqid().'.png'; + file_put_contents($tempFile, $pngData); + + try { + $tester = new CommandTester(new InvokableWithInputFileAndConstraintsTestCommand()); + $tester->setInputs([$tempFile]); + $tester->execute([], ['interactive' => true]); + $tester->assertCommandIsSuccessful(); + + self::assertStringContainsString('Provide an image file:', $tester->getDisplay()); + self::assertStringContainsString('Filename:', $tester->getDisplay()); + self::assertStringContainsString('Valid: yes', $tester->getDisplay()); + } finally { + @unlink($tempFile); + } + } + + public function testAskWithCustomValidatorIsNotOverwritten() + { + $tester = new CommandTester(new InvokableWithCustomValidatorTestCommand()); + $tester->setInputs(['invalid', 'valid']); + $tester->execute([], ['interactive' => true]); + $tester->assertCommandIsSuccessful(); + + self::assertStringContainsString('Enter a value:', $tester->getDisplay()); + self::assertStringContainsString('Value must be "valid"', $tester->getDisplay()); + self::assertStringContainsString('Value: valid', $tester->getDisplay()); + } } enum StringEnum: string diff --git a/Tests/Fixtures/InvokableWithCustomValidatorTestCommand.php b/Tests/Fixtures/InvokableWithCustomValidatorTestCommand.php new file mode 100644 index 00000000..7284c815 --- /dev/null +++ b/Tests/Fixtures/InvokableWithCustomValidatorTestCommand.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Console\Tests\Fixtures; + +use Symfony\Component\Console\Attribute\Argument; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Attribute\Ask; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Output\OutputInterface; + +#[AsCommand(name: 'app:custom-validator')] +class InvokableWithCustomValidatorTestCommand +{ + public function __invoke( + OutputInterface $output, + #[Argument] + #[Ask('Enter a value:', validator: [self::class, 'validate'])] + string $value, + ): int { + $output->writeln('Value: '.$value); + + return Command::SUCCESS; + } + + public static function validate(string $value): string + { + if ('valid' !== $value) { + throw new \RuntimeException('Value must be "valid"'); + } + + return $value; + } +} diff --git a/Tests/Fixtures/InvokableWithInputFileAndConstraintsTestCommand.php b/Tests/Fixtures/InvokableWithInputFileAndConstraintsTestCommand.php new file mode 100644 index 00000000..11f7a590 --- /dev/null +++ b/Tests/Fixtures/InvokableWithInputFileAndConstraintsTestCommand.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Console\Tests\Fixtures; + +use Symfony\Component\Console\Attribute\Argument; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Attribute\Ask; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\File\InputFile; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Validator\Constraints\File; + +#[AsCommand(name: 'app:input-file-with-constraints')] +class InvokableWithInputFileAndConstraintsTestCommand +{ + public function __invoke( + OutputInterface $output, + #[Argument] + #[Ask('Provide an image file:', constraints: [new File(mimeTypes: ['image/png', 'image/jpeg'])])] + InputFile $file, + ): int { + $output->writeln('Filename: '.$file->getFilename()); + $output->writeln('Valid: '.($file->isValid() ? 'yes' : 'no')); + + return Command::SUCCESS; + } +} diff --git a/Tests/Helper/QuestionHelperTest.php b/Tests/Helper/QuestionHelperTest.php index 49639286..7f46925f 100644 --- a/Tests/Helper/QuestionHelperTest.php +++ b/Tests/Helper/QuestionHelperTest.php @@ -29,8 +29,11 @@ use Symfony\Component\Console\Question\ConfirmationQuestion; use Symfony\Component\Console\Question\Question; use Symfony\Component\Console\Terminal; use Symfony\Component\Console\Tester\ApplicationTester; +use Symfony\Component\EventDispatcher\EventDispatcher; use Symfony\Component\Process\Exception\ProcessSignaledException; use Symfony\Component\Process\Process; +use Symfony\Component\Validator\Constraints\Url; +use Symfony\Component\Validator\Validation; #[Group('tty')] class QuestionHelperTest extends AbstractQuestionHelperTestCase @@ -1022,6 +1025,73 @@ class QuestionHelperTest extends AbstractQuestionHelperTestCase return $mock; } + + public function testAskWithConstraintsWithoutDispatcherValidValue() + { + $dialog = new QuestionHelper(); + + $question = new Question('Enter a URL'); + $question->setConstraints([new Url()]); + + $inputStream = $this->getInputStream("https://symfony.com\n"); + + $this->assertEquals('https://symfony.com', $dialog->ask($this->createStreamableInputInterfaceMock($inputStream), $this->createOutputInterface(), $question)); + } + + public function testAskWithConstraintsWithoutDispatcherInvalidThenValid() + { + $dialog = new QuestionHelper(); + + $question = new Question('Enter a URL'); + $question->setConstraints([new Url()]); + + $inputStream = $this->getInputStream("not-a-url\nhttps://symfony.com\n"); + + $output = $this->createOutputInterface(); + $this->assertEquals('https://symfony.com', $dialog->ask($this->createStreamableInputInterfaceMock($inputStream), $output, $question)); + + rewind($output->getStream()); + $outputContent = stream_get_contents($output->getStream()); + $this->assertStringContainsString('This value is not a valid URL.', $outputContent); + } + + public function testAskWithConstraintsWithoutDispatcherExceedsMaxAttempts() + { + $dialog = new QuestionHelper(); + + $question = new Question('Enter a URL'); + $question->setConstraints([new Url()]); + $question->setMaxAttempts(1); + + $inputStream = $this->getInputStream("not-a-url\n"); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('This value is not a valid URL.'); + + $dialog->ask($this->createStreamableInputInterfaceMock($inputStream), $this->createOutputInterface(), $question); + } + + public function testAskWithConstraintsWithDispatcher() + { + $dispatcher = new EventDispatcher(); + $dispatcher->addSubscriber(new \Symfony\Component\Console\EventListener\ValidateQuestionInputListener( + Validation::createValidator() + )); + + $dialog = new QuestionHelper($dispatcher); + + $question = new Question('Enter a URL'); + $question->setConstraints([new Url()]); + + $inputStream = $this->getInputStream("not-a-url\nhttps://symfony.com\n"); + + $output = $this->createOutputInterface(); + $this->assertEquals('https://symfony.com', $dialog->ask($this->createStreamableInputInterfaceMock($inputStream), $output, $question)); + + rewind($output->getStream()); + $outputContent = stream_get_contents($output->getStream()); + $this->assertStringContainsString('This value is not a valid URL.', $outputContent); + } } class AutocompleteValues implements \IteratorAggregate diff --git a/Tests/Question/FileQuestionTest.php b/Tests/Question/FileQuestionTest.php index 84df4447..4b636630 100644 --- a/Tests/Question/FileQuestionTest.php +++ b/Tests/Question/FileQuestionTest.php @@ -11,10 +11,10 @@ namespace Symfony\Component\Console\Tests\Question; -use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Symfony\Component\Console\Exception\InvalidArgumentException; use Symfony\Component\Console\Question\FileQuestion; +use Symfony\Component\Validator\Constraints\File; class FileQuestionTest extends TestCase { @@ -29,37 +29,21 @@ class FileQuestionTest extends TestCase { $question = new FileQuestion('Provide a file:'); - $this->assertSame([], $question->getAllowedMimeTypes()); - $this->assertSame(5 * 1024 * 1024, $question->getMaxFileSize()); $this->assertTrue($question->isPasteAllowed()); $this->assertTrue($question->isPathAllowed()); $this->assertFalse($question->isTrimmable()); } - public function testWithAllowedMimeTypes() - { - $question = new FileQuestion('Provide a file:', ['image/png', 'image/jpeg']); - - $this->assertSame(['image/png', 'image/jpeg'], $question->getAllowedMimeTypes()); - } - - public function testWithMaxFileSize() - { - $question = new FileQuestion('Provide a file:', [], 1024 * 1024); - - $this->assertSame(1024 * 1024, $question->getMaxFileSize()); - } - public function testWithAllowPasteFalse() { - $question = new FileQuestion('Provide a file:', [], null, false); + $question = new FileQuestion('Provide a file:', false); $this->assertFalse($question->isPasteAllowed()); } public function testWithAllowPathFalse() { - $question = new FileQuestion('Provide a file:', [], null, true, false); + $question = new FileQuestion('Provide a file:', true, false); $this->assertFalse($question->isPathAllowed()); } @@ -69,43 +53,7 @@ class FileQuestionTest extends TestCase $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('At least one of allowPaste or allowPath must be true.'); - new FileQuestion('Provide a file:', [], null, false, false); - } - - #[DataProvider('provideMimeTypeMatches')] - public function testIsMimeTypeAllowed(array $allowedTypes, string $mimeType, bool $expected) - { - $question = new FileQuestion('Provide a file:', $allowedTypes); - - $this->assertSame($expected, $question->isMimeTypeAllowed($mimeType)); - } - - public static function provideMimeTypeMatches(): iterable - { - // Empty allowed types allows all - yield 'empty allows all' => [[], 'image/png', true]; - yield 'empty allows all - text' => [[], 'text/plain', true]; - - // Exact matches - yield 'exact match png' => [['image/png'], 'image/png', true]; - yield 'exact match jpeg' => [['image/jpeg'], 'image/jpeg', true]; - yield 'no match' => [['image/png'], 'image/jpeg', false]; - - // Multiple allowed types - yield 'multiple - first match' => [['image/png', 'image/jpeg'], 'image/png', true]; - yield 'multiple - second match' => [['image/png', 'image/jpeg'], 'image/jpeg', true]; - yield 'multiple - no match' => [['image/png', 'image/jpeg'], 'image/gif', false]; - - // Wildcard matches - yield 'wildcard image/*' => [['image/*'], 'image/png', true]; - yield 'wildcard image/* - jpeg' => [['image/*'], 'image/jpeg', true]; - yield 'wildcard image/* - gif' => [['image/*'], 'image/gif', true]; - yield 'wildcard no match' => [['image/*'], 'application/pdf', false]; - - // Mixed exact and wildcard - yield 'mixed - exact match' => [['application/pdf', 'image/*'], 'application/pdf', true]; - yield 'mixed - wildcard match' => [['application/pdf', 'image/*'], 'image/png', true]; - yield 'mixed - no match' => [['application/pdf', 'image/*'], 'text/plain', false]; + new FileQuestion('Provide a file:', false, false); } public function testIsNotTrimmableByDefault() @@ -114,4 +62,18 @@ class FileQuestionTest extends TestCase $this->assertFalse($question->isTrimmable()); } + + public function testSupportsConstraints() + { + if (!class_exists(File::class)) { + $this->markTestSkipped('Validator component not available.'); + } + + $question = new FileQuestion('Provide a file:'); + $constraint = new File(maxSize: '5M', mimeTypes: ['image/png']); + + $question->setConstraints([$constraint]); + + $this->assertSame([$constraint], $question->getConstraints()); + } } diff --git a/composer.json b/composer.json index 03904bf9..0d816fcf 100644 --- a/composer.json +++ b/composer.json @@ -31,9 +31,11 @@ "symfony/http-kernel": "^7.4|^8.0", "symfony/lock": "^7.4|^8.0", "symfony/messenger": "^7.4|^8.0", + "symfony/mime": "^7.4|^8.0", "symfony/process": "^7.4|^8.0", "symfony/stopwatch": "^7.4|^8.0", "symfony/uid": "^7.4|^8.0", + "symfony/validator": "^7.4|^8.0", "symfony/var-dumper": "^7.4|^8.0" }, "provide": {