From 21131ad23914d70b3a5b056603a9acf7b238fdfd Mon Sep 17 00:00:00 2001 From: Javier Eguiluz Date: Thu, 27 Jul 2017 10:38:55 +0200 Subject: [PATCH 1/3] Added a validator service for commands --- src/AppBundle/Command/AddUserCommand.php | 155 ++++++------------ src/AppBundle/Command/DeleteUserCommand.php | 52 +++--- src/AppBundle/Command/ListUsersCommand.php | 42 ++--- src/AppBundle/Utils/Validator | 63 +++++++ .../AppBundle/Command/AddUserCommandTest.php | 17 +- tests/AppBundle/Utils/ValidatorTest.php | 96 +++++++++++ 6 files changed, 261 insertions(+), 164 deletions(-) create mode 100644 src/AppBundle/Utils/Validator create mode 100644 tests/AppBundle/Utils/ValidatorTest.php diff --git a/src/AppBundle/Command/AddUserCommand.php b/src/AppBundle/Command/AddUserCommand.php index d263434fe..8fcf739a8 100644 --- a/src/AppBundle/Command/AddUserCommand.php +++ b/src/AppBundle/Command/AddUserCommand.php @@ -12,18 +12,19 @@ namespace AppBundle\Command; use AppBundle\Entity\User; +use AppBundle\Utils\Validator; use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; -use Symfony\Component\Console\Question\Question; use Symfony\Component\Console\Style\SymfonyStyle; use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface; +use Symfony\Component\Stopwatch\Stopwatch; /** - * A command console that creates users and stores them in the database. + * A console command that creates users and stores them in the database. * * To use this command, open a terminal window, enter into your project * directory and execute the following: @@ -45,15 +46,18 @@ class AddUserCommand extends Command { const MAX_ATTEMPTS = 5; + private $io; private $entityManager; private $passwordEncoder; + private $validator; - public function __construct(EntityManagerInterface $em, UserPasswordEncoderInterface $encoder) + public function __construct(EntityManagerInterface $em, UserPasswordEncoderInterface $encoder, Validator $validator) { parent::__construct(); $this->entityManager = $em; $this->passwordEncoder = $encoder; + $this->validator = $validator; } /** @@ -76,6 +80,18 @@ protected function configure() ; } + /** + * This optional method is the first one executed for a command after configure() + * and is useful to initialize properties based on the input arguments and options. + */ + protected function initialize(InputInterface $input, OutputInterface $output) + { + // SymfonyStyle is an optional feature that Symfony provides so you can + // apply a consistent look to the commands of your application. + // See https://symfony.com/doc/current/console/style.html + $this->io = new SymfonyStyle($input, $output); + } + /** * This method is executed after initialize() and before execute(). Its purpose * is to check if some of the options/arguments are missing and interactively @@ -92,18 +108,10 @@ protected function interact(InputInterface $input, OutputInterface $output) return; } - // See: http://symfony.com/doc/current/console/style.html - $io = new SymfonyStyle($input, $output); - - // Use the title() method to display the title - $io->title('Add User Command Interactive Wizard'); - - // multi-line messages can be displayed this way... - $io->text('If you prefer to not use this interactive wizard, provide the'); - $io->text('arguments required by this command as follows:'); - - // ...but you can also pass an array of strings to the text() method - $io->text([ + $this->io->title('Add User Command Interactive Wizard'); + $this->io->text([ + 'If you prefer to not use this interactive wizard, provide the', + 'arguments required by this command as follows:', '', ' $ php bin/console app:add-user username password email@example.com', '', @@ -112,61 +120,38 @@ protected function interact(InputInterface $input, OutputInterface $output) // Ask for the username if it's not defined $username = $input->getArgument('username'); - if (null === $username) { - $question = new Question('Username'); - $question->setValidator(function ($answer) { - if (empty($answer)) { - throw new \RuntimeException('The username cannot be empty'); - } - - return $answer; - }); - $question->setMaxAttempts(self::MAX_ATTEMPTS); - - $username = $io->askQuestion($question); - $input->setArgument('username', $username); + if (null !== $username) { + $this->io->text(' > Username: '.$username); } else { - $io->text(' > Username: '.$username); + $username = $this->io->ask('Username', null, [$this->validator, 'validateUsername']); + $input->setArgument('username', $username); } // Ask for the password if it's not defined $password = $input->getArgument('password'); - if (null === $password) { - $question = new Question('Password (your type will be hidden)'); - $question->setValidator([$this, 'passwordValidator']); - $question->setHidden(true); - $question->setMaxAttempts(self::MAX_ATTEMPTS); - - $password = $io->askQuestion($question); - $input->setArgument('password', $password); + if (null !== $password) { + $this->io->text(' > Password: '.str_repeat('*', mb_strlen($password))); } else { - $io->text(' > Password: '.str_repeat('*', mb_strlen($password))); + $password = $this->io->askHidden('Password (your type will be hidden)', null, [$this, 'passwordValidator']); + $input->setArgument('password', $password); } // Ask for the email if it's not defined $email = $input->getArgument('email'); - if (null === $email) { - $question = new Question('Email'); - $question->setValidator([$this, 'emailValidator']); - $question->setMaxAttempts(self::MAX_ATTEMPTS); - - $email = $io->askQuestion($question); - $input->setArgument('email', $email); + if (null !== $email) { + $this->io->text(' > Email: '.$email); } else { - $io->text(' > Email: '.$email); + $email = $this->io->ask('Email', null, [$this, 'emailValidator']); + $input->setArgument('email', $email); } // Ask for the full name if it's not defined $fullName = $input->getArgument('full-name'); - if (null === $fullName) { - $question = new Question('Full Name'); - $question->setValidator([$this, 'fullNameValidator']); - $question->setMaxAttempts(self::MAX_ATTEMPTS); - - $fullName = $io->askQuestion($question); - $input->setArgument('full-name', $fullName); + if (null !== $fullName) { + $this->io->text(' > Full Name: '.$fullName); } else { - $io->text(' > Full Name: '.$fullName); + $fullName = $this->io->ask('Full Name', null, [$this, 'fullNameValidator']); + $input->setArgument('full-name', $fullName); } } @@ -176,8 +161,8 @@ protected function interact(InputInterface $input, OutputInterface $output) */ protected function execute(InputInterface $input, OutputInterface $output) { - $startTime = microtime(true); - $io = new SymfonyStyle($input, $output); + $stopwatch = new Stopwatch(); + $stopwatch->start('add-user-command'); $username = $input->getArgument('username'); $plainPassword = $input->getArgument('password'); @@ -202,60 +187,14 @@ protected function execute(InputInterface $input, OutputInterface $output) $this->entityManager->persist($user); $this->entityManager->flush(); - $io->success(sprintf('%s was successfully created: %s (%s)', $isAdmin ? 'Administrator user' : 'User', $user->getUsername(), $user->getEmail())); + $this->io->success(sprintf('%s was successfully created: %s (%s)', $isAdmin ? 'Administrator user' : 'User', $user->getUsername(), $user->getEmail())); + $event = $stopwatch->stop('add-user-command'); if ($output->isVerbose()) { - $finishTime = microtime(true); - $elapsedTime = $finishTime - $startTime; - - $io->note(sprintf('New user database id: %d / Elapsed time: %.2f ms', $user->getId(), $elapsedTime * 1000)); + $this->io->comment(sprintf('New user database id: %d / Elapsed time: %.2f ms / Consumed memory: %.2f MB', $user->getId(), $event->getDuration(), $event->getMemory() / pow(1024, 2))); } } - /** - * @internal - */ - public function passwordValidator($plainPassword) - { - if (empty($plainPassword)) { - throw new \Exception('The password can not be empty.'); - } - - if (mb_strlen(trim($plainPassword)) < 6) { - throw new \Exception('The password must be at least 6 characters long.'); - } - - return $plainPassword; - } - - /** - * @internal - */ - public function emailValidator($email) - { - if (empty($email)) { - throw new \Exception('The email can not be empty.'); - } - - if (false === mb_strpos($email, '@')) { - throw new \Exception('The email should look like a real email.'); - } - - return $email; - } - - /** - * @internal - */ - public function fullNameValidator($fullName) - { - if (empty($fullName)) { - throw new \Exception('The full name can not be empty.'); - } - - return $fullName; - } - private function validateUserData($username, $plainPassword, $email, $fullName) { $userRepository = $this->entityManager->getRepository(User::class); @@ -268,9 +207,9 @@ private function validateUserData($username, $plainPassword, $email, $fullName) } // validate password and email if is not this input means interactive. - $this->passwordValidator($plainPassword); - $this->emailValidator($email); - $this->fullNameValidator($fullName); + $this->validator->validatePassword($plainPassword); + $this->validator->validateEmail($email); + $this->validator->validateFullName($fullName); // check if a user with the same email already exists. $existingEmail = $userRepository->findOneBy(['email' => $email]); diff --git a/src/AppBundle/Command/DeleteUserCommand.php b/src/AppBundle/Command/DeleteUserCommand.php index 060357908..a3a287e71 100644 --- a/src/AppBundle/Command/DeleteUserCommand.php +++ b/src/AppBundle/Command/DeleteUserCommand.php @@ -12,6 +12,7 @@ namespace AppBundle\Command; use AppBundle\Entity\User; +use AppBundle\Utils\Validator; use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; @@ -20,7 +21,7 @@ use Symfony\Component\Console\Style\SymfonyStyle; /** - * A command console that deletes users from the database. + * A console command that deletes users from the database. * * To use this command, open a terminal window, enter into your project * directory and execute the following: @@ -40,13 +41,16 @@ class DeleteUserCommand extends Command { const MAX_ATTEMPTS = 5; + private $io; private $entityManager; + private $validator; - public function __construct(EntityManagerInterface $em) + public function __construct(EntityManagerInterface $em, Validator $validator) { parent::__construct(); $this->entityManager = $em; + $this->validator = $validator; } /** @@ -71,18 +75,22 @@ protected function configure() ); } + protected function initialize(InputInterface $input, OutputInterface $output) + { + // SymfonyStyle is an optional feature that Symfony provides so you can + // apply a consistent look to the commands of your application. + // See https://symfony.com/doc/current/console/style.html + $this->io = new SymfonyStyle($input, $output); + } + protected function interact(InputInterface $input, OutputInterface $output) { if (null !== $input->getArgument('username')) { return; } - // See: http://symfony.com/doc/current/console/style.html - $io = new SymfonyStyle($input, $output); - - $io->title('Delete User Command Interactive Wizard'); - - $io->text([ + $this->io->title('Delete User Command Interactive Wizard'); + $this->io->text([ 'If you prefer to not use this interactive wizard, provide the', 'arguments required by this command as follows:', '', @@ -92,15 +100,13 @@ protected function interact(InputInterface $input, OutputInterface $output) '', ]); - $username = $io->ask('Username', null, [$this, 'usernameValidator']); - + $username = $this->io->ask('Username', null, [$this, 'usernameValidator']); $input->setArgument('username', $username); } protected function execute(InputInterface $input, OutputInterface $output) { - $username = $input->getArgument('username'); - $this->usernameValidator($username); + $username = $this->validator->validateUsername($input->getArgument('username')); $repository = $this->entityManager->getRepository(User::class); /** @var User $user */ @@ -118,26 +124,6 @@ protected function execute(InputInterface $input, OutputInterface $output) $this->entityManager->remove($user); $this->entityManager->flush(); - (new SymfonyStyle($input, $output)) - ->success(sprintf('User "%s" (ID: %d, email: %s) was successfully deleted.', $user->getUsername(), $userId, $user->getEmail())); - } - - /** - * This internal method should be private, but it's declared public to - * maintain PHP 5.3 compatibility when using it in a callback. - * - * @internal - */ - public function usernameValidator($username) - { - if (empty($username)) { - throw new \Exception('The username can not be empty.'); - } - - if (1 !== preg_match('/^[a-z_]+$/', $username)) { - throw new \Exception('The username must contain only lowercase latin characters and underscores.'); - } - - return $username; + $this->io->success(sprintf('User "%s" (ID: %d, email: %s) was successfully deleted.', $user->getUsername(), $userId, $user->getEmail())); } } diff --git a/src/AppBundle/Command/ListUsersCommand.php b/src/AppBundle/Command/ListUsersCommand.php index 11e699088..bd0794369 100644 --- a/src/AppBundle/Command/ListUsersCommand.php +++ b/src/AppBundle/Command/ListUsersCommand.php @@ -14,14 +14,14 @@ use AppBundle\Entity\User; use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\Helper\Table; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\BufferedOutput; use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; /** - * A command console that lists all the existing users. + * A console command that lists all the existing users. * * To use this command, open a terminal window, enter into your project directory * and execute the following: @@ -94,34 +94,34 @@ protected function execute(InputInterface $input, OutputInterface $output) // Doctrine query returns an array of objects and we need an array of plain arrays $usersAsPlainArrays = array_map(function (User $user) { - return [$user->getId(), $user->getFullName(), $user->getUsername(), $user->getEmail(), implode(', ', $user->getRoles())]; + return [ + $user->getId(), + $user->getFullName(), + $user->getUsername(), + $user->getEmail(), + implode(', ', $user->getRoles()), + ]; }, $users); // In your console commands you should always use the regular output type, // which outputs contents directly in the console window. However, this - // particular command uses the BufferedOutput type instead. - // The reason is that the table displaying the list of users can be sent - // via email if the '--send-to' option is provided. Instead of complicating - // things, the BufferedOutput allows to get the command output and store - // it in a variable before displaying it. + // command uses the BufferedOutput type instead, to be able to get the output + // contents before displaying them. This is needed because the command allows + // to send the list of users via email with the '--send-to' option $bufferedOutput = new BufferedOutput(); + $io = new SymfonyStyle($input, $bufferedOutput); + $io->table( + ['ID', 'Full Name', 'Username', 'Email', 'Roles'], + $usersAsPlainArrays + ); - $table = new Table($bufferedOutput); - $table - ->setHeaders(['ID', 'Full Name', 'Username', 'Email', 'Roles']) - ->setRows($usersAsPlainArrays) - ->setStyle(clone Table::getStyleDefinition('symfony-style-guide')) - ; - $table->render(); - - // instead of displaying the table of users, store it in a variable - $tableContents = $bufferedOutput->fetch(); + // instead of just displaying the table of users, store its contents in a variable + $usersAsATable = $bufferedOutput->fetch(); + $output->write($usersAsATable); if (null !== $email = $input->getOption('send-to')) { - $this->sendReport($tableContents, $email); + $this->sendReport($usersAsATable, $email); } - - $output->writeln($tableContents); } /** diff --git a/src/AppBundle/Utils/Validator b/src/AppBundle/Utils/Validator new file mode 100644 index 000000000..770117b10 --- /dev/null +++ b/src/AppBundle/Utils/Validator @@ -0,0 +1,63 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace AppBundle\Utils; + +class Validator +{ + public function validateUsername($username) + { + if (empty($username)) { + throw new \Exception('The username can not be empty.'); + } + + if (1 !== preg_match('/^[a-z_]+$/', $username)) { + throw new \Exception('The username must contain only lowercase latin characters and underscores.'); + } + + return $username; + } + + public function validatePassword($plainPassword) + { + if (empty($plainPassword)) { + throw new \Exception('The password can not be empty.'); + } + + if (mb_strlen(trim($plainPassword)) < 6) { + throw new \Exception('The password must be at least 6 characters long.'); + } + + return $plainPassword; + } + + public function validateEmail($email) + { + if (empty($email)) { + throw new \Exception('The email can not be empty.'); + } + + if (false === mb_strpos($email, '@')) { + throw new \Exception('The email should look like a real email.'); + } + + return $email; + } + + public function validateFullName($fullName) + { + if (empty($fullName)) { + throw new \Exception('The full name can not be empty.'); + } + + return $fullName; + } +} diff --git a/tests/AppBundle/Command/AddUserCommandTest.php b/tests/AppBundle/Command/AddUserCommandTest.php index 6c67f0619..d372e60ea 100644 --- a/tests/AppBundle/Command/AddUserCommandTest.php +++ b/tests/AppBundle/Command/AddUserCommandTest.php @@ -13,6 +13,7 @@ use AppBundle\Command\AddUserCommand; use AppBundle\Entity\User; +use AppBundle\Utils\Validator; use Symfony\Bundle\FrameworkBundle\Console\Application; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; use Symfony\Component\Console\Tester\CommandTester; @@ -26,6 +27,18 @@ class AddUserCommandTest extends KernelTestCase 'full-name' => 'Chuck Norris', ]; + protected function setUp() + { + exec('stty 2>&1', $output, $exitcode); + $isSttySupported = 0 === $exitcode; + + $isWindows = '\\' === DIRECTORY_SEPARATOR; + + if ($isWindows || !$isSttySupported) { + $this->markTestSkipped('`stty` is required to test this command.'); + } + } + /** * @dataProvider isAdminDataProvider * @@ -54,7 +67,7 @@ public function testCreateUserNonInteractive($isAdmin) public function testCreateUserInteractive($isAdmin) { $this->executeCommand( - // these are the arguments (only 1 is passed, the rest are missing) + // these are the arguments (only 1 is passed, the rest are missing) $isAdmin ? ['--admin' => 1] : [], // these are the responses given to the questions asked by the command // to get the value of the missing required arguments @@ -104,7 +117,7 @@ private function executeCommand(array $arguments, array $inputs = []) self::bootKernel(); $container = self::$kernel->getContainer(); - $command = new AddUserCommand($container->get('doctrine')->getManager(), $container->get('security.password_encoder')); + $command = new AddUserCommand($container->get('doctrine')->getManager(), $container->get('security.password_encoder'), new Validator()); $command->setApplication(new Application(self::$kernel)); $commandTester = new CommandTester($command); diff --git a/tests/AppBundle/Utils/ValidatorTest.php b/tests/AppBundle/Utils/ValidatorTest.php new file mode 100644 index 000000000..0da2315ea --- /dev/null +++ b/tests/AppBundle/Utils/ValidatorTest.php @@ -0,0 +1,96 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tests\AppBundle\Utils; + +use AppBundle\Utils\Validator; + +class ValidatorTest extends \PHPUnit_Framework_TestCase +{ + private $object; + + public function __construct() + { + parent::__construct(); + + $this->object = new Validator(); + } + + public function testValidateUsername() + { + $test = 'username'; + + $this->assertSame($test, $this->object->validateUsername($test)); + } + + public function testValidateUsernameEmpty() + { + $this->setExpectedException('Exception', 'The username can not be empty.'); + $this->object->validateUsername(null); + } + + public function testValidateUsernameInvalid() + { + $this->setExpectedException('Exception', 'The username must contain only lowercase latin characters and underscores.'); + $this->object->validateUsername('INVALID'); + } + + public function testValidatePassword() + { + $test = 'password'; + + $this->assertSame($test, $this->object->validatePassword($test)); + } + + public function testValidatePasswordEmpty() + { + $this->setExpectedException('Exception', 'The password can not be empty.'); + $this->object->validatePassword(null); + } + + public function testValidatePasswordInvalid() + { + $this->setExpectedException('Exception', 'The password must be at least 6 characters long.'); + $this->object->validatePassword('12345'); + } + + public function testValidateEmail() + { + $test = '@'; + + $this->assertSame($test, $this->object->validateEmail($test)); + } + + public function testValidateEmailEmpty() + { + $this->setExpectedException('Exception', 'The email can not be empty.'); + $this->object->validateEmail(null); + } + + public function testValidateEmailInvalid() + { + $this->setExpectedException('Exception', 'The email should look like a real email.'); + $this->object->validateEmail('invalid'); + } + + public function testValidateFullName() + { + $test = 'Full Name'; + + $this->assertSame($test, $this->object->validateFullName($test)); + } + + public function testValidateEmailFullName() + { + $this->setExpectedException('Exception', 'The full name can not be empty.'); + $this->object->validateFullName(null); + } +} From bca06d323c0123872bda88940733a60a02c97b8d Mon Sep 17 00:00:00 2001 From: Javier Eguiluz Date: Thu, 27 Jul 2017 10:59:08 +0200 Subject: [PATCH 2/3] Fixed a file name --- src/AppBundle/Utils/{Validator => Validator.php} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/AppBundle/Utils/{Validator => Validator.php} (100%) diff --git a/src/AppBundle/Utils/Validator b/src/AppBundle/Utils/Validator.php similarity index 100% rename from src/AppBundle/Utils/Validator rename to src/AppBundle/Utils/Validator.php From 3501c099b99289db6a2e737f95911b123e800f5f Mon Sep 17 00:00:00 2001 From: Javier Eguiluz Date: Thu, 27 Jul 2017 11:25:20 +0200 Subject: [PATCH 3/3] Fixed some validator callables --- src/AppBundle/Command/AddUserCommand.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/AppBundle/Command/AddUserCommand.php b/src/AppBundle/Command/AddUserCommand.php index 8fcf739a8..994829261 100644 --- a/src/AppBundle/Command/AddUserCommand.php +++ b/src/AppBundle/Command/AddUserCommand.php @@ -132,7 +132,7 @@ protected function interact(InputInterface $input, OutputInterface $output) if (null !== $password) { $this->io->text(' > Password: '.str_repeat('*', mb_strlen($password))); } else { - $password = $this->io->askHidden('Password (your type will be hidden)', null, [$this, 'passwordValidator']); + $password = $this->io->askHidden('Password (your type will be hidden)', null, [$this, 'validatePassword']); $input->setArgument('password', $password); } @@ -141,7 +141,7 @@ protected function interact(InputInterface $input, OutputInterface $output) if (null !== $email) { $this->io->text(' > Email: '.$email); } else { - $email = $this->io->ask('Email', null, [$this, 'emailValidator']); + $email = $this->io->ask('Email', null, [$this->validator, 'validateEmail']); $input->setArgument('email', $email); } @@ -150,7 +150,7 @@ protected function interact(InputInterface $input, OutputInterface $output) if (null !== $fullName) { $this->io->text(' > Full Name: '.$fullName); } else { - $fullName = $this->io->ask('Full Name', null, [$this, 'fullNameValidator']); + $fullName = $this->io->ask('Full Name', null, [$this->validator, 'validateFullName']); $input->setArgument('full-name', $fullName); } }