mirror of
https://github.com/symfony/console.git
synced 2026-03-24 01:12:13 +01:00
[Console] Allow passing Validator constraints to QuestionHelper and #[Ask]
This commit is contained in:
@@ -112,6 +112,11 @@ class Application implements ResetInterface
|
||||
$this->dispatcher = $dispatcher;
|
||||
}
|
||||
|
||||
public function getDispatcher(): ?EventDispatcherInterface
|
||||
{
|
||||
return $this->dispatcher;
|
||||
}
|
||||
|
||||
/**
|
||||
* @final
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
45
Event/QuestionAnsweredEvent.php
Normal file
45
Event/QuestionAnsweredEvent.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?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\Event;
|
||||
|
||||
use Symfony\Contracts\EventDispatcher\Event;
|
||||
|
||||
/**
|
||||
* Event dispatched when constraint validation is needed for a question.
|
||||
*
|
||||
* @author Robin Chalas <robin.chalas@gmail.com>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
44
EventListener/ValidateQuestionInputListener.php
Normal file
44
EventListener/ValidateQuestionInputListener.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?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\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 <robin.chalas@gmail.com>
|
||||
*/
|
||||
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'];
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
42
Tests/Fixtures/InvokableWithCustomValidatorTestCommand.php
Normal file
42
Tests/Fixtures/InvokableWithCustomValidatorTestCommand.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?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\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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?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\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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user