From d7d831d0a6c485a155249b08aec025eb1ee93bae Mon Sep 17 00:00:00 2001 From: Matthew Grasmick Date: Mon, 11 Apr 2022 13:05:35 -0400 Subject: [PATCH] Fixes #600: For API commands, automatically prompt for missing arguments. (#899) * Fixes #600: For API commands, automatically prompt for missing arguments. * Support enum. * Pattern. * Coverage. * Tests * Change. * Coverage * Fewer. * Refactor. * Clean up. * Move alias conversion to interact(). * Tests. * Goodness. * More test. * Clean up language. --- src/Command/Api/ApiCommandBase.php | 300 ++++++++++++++---- src/Command/CommandBase.php | 109 ++++--- .../src/Commands/Api/ApiCommandTest.php | 45 +++ .../phpunit/src/Commands/CommandBaseTest.php | 4 +- 4 files changed, 363 insertions(+), 95 deletions(-) diff --git a/src/Command/Api/ApiCommandBase.php b/src/Command/Api/ApiCommandBase.php index ba952920a..097b8328f 100644 --- a/src/Command/Api/ApiCommandBase.php +++ b/src/Command/Api/ApiCommandBase.php @@ -3,10 +3,19 @@ namespace Acquia\Cli\Command\Api; use Acquia\Cli\Command\CommandBase; +use AcquiaCloudApi\Connector\Client; use AcquiaCloudApi\Exception\ApiErrorException; use GuzzleHttp\Psr7\Utils; +use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Question\Question; +use Symfony\Component\Validator\Constraints\Length; +use Symfony\Component\Validator\Constraints\NotBlank; +use Symfony\Component\Validator\Constraints\Regex; +use Symfony\Component\Validator\Constraints\Type; +use Symfony\Component\Validator\Exception\ValidatorException; +use Symfony\Component\Validator\Validation; /** * Class ApiCommandBase. @@ -57,6 +66,35 @@ protected function initialize(InputInterface $input, OutputInterface $output) { parent::initialize($input, $output); } + /** + * @param \Symfony\Component\Console\Input\InputInterface $input + * @param \Symfony\Component\Console\Output\OutputInterface $output + */ + public function interact(InputInterface $input, OutputInterface $output) { + $params = array_merge($this->queryParams, $this->postParams, $this->pathParams); + foreach ($this->getDefinition()->getArguments() as $argument) { + if ($argument->isRequired() && !$input->getArgument($argument->getName())) { + $this->io->note([ + "{$argument->getName()} is a required argument.", + $argument->getDescription(), + ]); + // Choice question. + if (array_key_exists($argument->getName(), $params) + && array_key_exists('schema', $params[$argument->getName()]) + && array_key_exists('enum', $params[$argument->getName()]['schema'])) { + $choices = $params[$argument->getName()]['schema']['enum']; + $answer = $this->io->choice("Please select a value for {$argument->getName()}", $choices, $argument->getDefault()); + } + // Free form. + else { + $answer = $this->askFreeFormQuestion($argument, $params); + } + $input->setArgument($argument->getName(), $answer); + } + } + parent::interact($input, $output); + } + /** * @param \Symfony\Component\Console\Input\InputInterface $input * @param \Symfony\Component\Console\Output\OutputInterface $output @@ -67,41 +105,8 @@ protected function initialize(InputInterface $input, OutputInterface $output) { protected function execute(InputInterface $input, OutputInterface $output) { // Build query from non-null options. $acquia_cloud_client = $this->cloudApiClientService->getClient(); - if ($this->queryParams) { - foreach ($this->queryParams as $key => $param_spec) { - // We may have a queryParam that is used in the path rather than the query string. - if ($input->hasOption($key) && $input->getOption($key) !== NULL) { - $acquia_cloud_client->addQuery($key, $input->getOption($key)); - } - elseif ($input->hasArgument($key) && $input->getArgument($key) !== NULL) { - $acquia_cloud_client->addQuery($key, $input->getArgument($key)); - } - } - } - if ($this->postParams) { - foreach ($this->postParams as $param_name => $param_spec) { - $param = $this->getParamFromInput($input, $param_name); - if (!is_null($param)) { - $param_name = ApiCommandHelper::restoreRenamedParameter($param_name); - if ($param_spec) { - $param = $this->castParamType($param_spec, $param); - } - if ($param_spec && array_key_exists('format', $param_spec) && $param_spec["format"] === 'binary') { - $acquia_cloud_client->addOption('multipart', [ - [ - 'name' => $param_name, - 'contents' => Utils::tryFopen($param, 'r'), - ], - ]); - } - else { - $acquia_cloud_client->addOption('json', [$param_name => $param]); - } - } - } - } - - $path = $this->getRequestPath($input); + $this->addQueryParamsToClient($input, $acquia_cloud_client); + $this->addPostParamsToClient($input, $acquia_cloud_client); $acquia_cloud_client->addOption('headers', [ 'Accept' => 'application/json', ]); @@ -110,6 +115,7 @@ protected function execute(InputInterface $input, OutputInterface $output) { if ($this->output->isVeryVerbose()) { $acquia_cloud_client->addOption('debug', $this->output); } + $path = $this->getRequestPath($input); $response = $acquia_cloud_client->request($this->method, $path); $exit_code = 0; } @@ -117,7 +123,7 @@ protected function execute(InputInterface $input, OutputInterface $output) { $response = $exception->getResponseBody(); $exit_code = 1; } - // @todo Add syntax highlighting to json output. + $contents = json_encode($response, JSON_PRETTY_PRINT); $this->output->writeln($contents); @@ -127,28 +133,28 @@ protected function execute(InputInterface $input, OutputInterface $output) { /** * @param string $method */ - public function setMethod($method): void { + public function setMethod(string $method): void { $this->method = $method; } /** * @param array $responses */ - public function setResponses($responses): void { + public function setResponses(array $responses): void { $this->responses = $responses; } /** * @param array $servers */ - public function setServers($servers): void { + public function setServers(array $servers): void { $this->servers = $servers; } /** * @param string $path */ - public function setPath($path): void { + public function setPath(string $path): void { $this->path = $path; } @@ -204,7 +210,7 @@ public function getPath(): string { } /** - * @param $param_name + * @param string $param_name * @param $value */ public function addPathParameter($param_name, $value): void { @@ -213,11 +219,11 @@ public function addPathParameter($param_name, $value): void { /** * @param \Symfony\Component\Console\Input\InputInterface $input - * @param $param_name + * @param string $param_name * * @return bool|string|string[]|null */ - protected function getParamFromInput(InputInterface $input, $param_name) { + protected function getParamFromInput(InputInterface $input, string $param_name) { if ($input->hasArgument($param_name)) { $param = $input->getArgument($param_name); } @@ -228,20 +234,14 @@ protected function getParamFromInput(InputInterface $input, $param_name) { } /** - * @param $param_spec - * @param $value + * @param array $param_spec + * @param string $value * - * @return mixed + * @return bool|int|string */ - protected function castParamType($param_spec, $value) { - // @todo File a CXAPI ticket regarding the inconsistent nesting of the 'type' property. - if (array_key_exists('type', $param_spec)) { - $type = $param_spec['type']; - } - elseif (array_key_exists('schema', $param_spec) && array_key_exists('type', $param_spec['schema'])) { - $type = $param_spec['schema']['type']; - } - else { + protected function castParamType(array $param_spec, string $value) { + $type = $this->getParamType($param_spec); + if (!$type) { return $value; } @@ -260,4 +260,194 @@ protected function castParamType($param_spec, $value) { return $value; } + /** + * @param array $param_spec + * + * @return null|string + */ + protected function getParamType(array $param_spec): ?string { + // @todo File a CXAPI ticket regarding the inconsistent nesting of the 'type' property. + if (array_key_exists('type', $param_spec)) { + return $param_spec['type']; + } + elseif (array_key_exists('schema', $param_spec) && array_key_exists('type', $param_spec['schema'])) { + return $param_spec['schema']['type']; + } + return NULL; + } + + /** + * @param \Symfony\Component\Console\Input\InputArgument $argument + * @param array $params + * + * @return callable|null + */ + protected function createCallableValidator(InputArgument $argument, array $params): ?callable { + $validator = NULL; + if (array_key_exists($argument->getName(), $params)) { + $param_spec = $params[$argument->getName()]; + $constraints = [ + new NotBlank(), + ]; + if ($type = $this->getParamType($param_spec)) { + $constraints[] = new Type($type); + } + if (array_key_exists('schema', $param_spec)) { + $schema = $param_spec['schema']; + $constraints = $this->createLengthConstraint($schema, $constraints); + $constraints = $this->createRegexConstraint($schema, $constraints); + } + $validator = $this->createValidatorFromConstraints($constraints); + } + return $validator; + } + + /** + * @param array $schema + * @param array $constraints + * + * @return array + */ + protected function createLengthConstraint($schema, array $constraints): array { + if (array_key_exists('minLength', $schema) || array_key_exists('maxLength', $schema)) { + $length_options = []; + if (array_key_exists('minLength', $schema)) { + $length_options['min'] = $schema['minLength']; + } + if (array_key_exists('maxLength', $schema)) { + $length_options['max'] = $schema['maxLength']; + } + $constraints[] = new Length($length_options); + } + return $constraints; + } + + /** + * @param array $schema + * @param array $constraints + * + * @return array + */ + protected function createRegexConstraint($schema, array $constraints): array { + if (array_key_exists('format', $schema)) { + switch ($schema['format']) { + case 'uuid'; + $constraints[] = CommandBase::getUuidRegexConstraint(); + break; + } + } + elseif (array_key_exists('pattern', $schema)) { + $constraints[] = new Regex([ + 'pattern' => '/' . $schema['pattern'] . '/', + 'message' => 'It must match the pattern ' . $schema['pattern'], + ]); + } + return $constraints; + } + + /** + * @param array $constraints + * + * @return \Closure + */ + protected function createValidatorFromConstraints(array $constraints): \Closure { + return function ($value) use ($constraints) { + $violations = Validation::createValidator() + ->validate($value, $constraints); + if (count($violations)) { + throw new ValidatorException($violations->get(0)->getMessage()); + } + return $value; + }; + } + + /** + * @param \Symfony\Component\Console\Input\InputInterface $input + * @param \AcquiaCloudApi\Connector\Client $acquia_cloud_client + */ + protected function addQueryParamsToClient(InputInterface $input, Client $acquia_cloud_client) { + if ($this->queryParams) { + foreach ($this->queryParams as $key => $param_spec) { + // We may have a queryParam that is used in the path rather than the query string. + if ($input->hasOption($key) && $input->getOption($key) !== NULL) { + $acquia_cloud_client->addQuery($key, $input->getOption($key)); + } + elseif ($input->hasArgument($key) && $input->getArgument($key) !== NULL) { + $acquia_cloud_client->addQuery($key, $input->getArgument($key)); + } + } + } + } + + /** + * @param \Symfony\Component\Console\Input\InputInterface $input + * @param \AcquiaCloudApi\Connector\Client $acquia_cloud_client + */ + protected function addPostParamsToClient(InputInterface $input, Client $acquia_cloud_client): void { + if ($this->postParams) { + foreach ($this->postParams as $param_name => $param_spec) { + $param_value = $this->getParamFromInput($input, $param_name); + if (!is_null($param_value)) { + $this->addPostParamToClient($param_name, $param_spec, $param_value, $acquia_cloud_client); + } + } + } + } + + /** + * @param string $param_name + * @param array|null $param_spec + * @param mixed $param_value + * @param \AcquiaCloudApi\Connector\Client $acquia_cloud_client + */ + protected function addPostParamToClient(string $param_name, $param_spec, $param_value, Client $acquia_cloud_client) { + $param_name = ApiCommandHelper::restoreRenamedParameter($param_name); + if ($param_spec) { + $param_value = $this->castParamType($param_spec, $param_value); + } + if ($param_spec && array_key_exists('format', $param_spec) && $param_spec["format"] === 'binary') { + $acquia_cloud_client->addOption('multipart', [ + [ + 'name' => $param_name, + 'contents' => Utils::tryFopen($param_value, 'r'), + ], + ]); + } + else { + $acquia_cloud_client->addOption('json', [$param_name => $param_value]); + } + } + + /** + * @param \Symfony\Component\Console\Input\InputArgument $argument + * @param array $params + * + * @return mixed + */ + protected function askFreeFormQuestion(InputArgument $argument, array $params) { + $question = new Question("Please enter a value for {$argument->getName()}", $argument->getDefault()); + switch ($argument->getName()) { + case 'applicationUuid': + $question->setValidator(function ($value) { + return $this->validateApplicationUuid($value); + }); + break; + case 'environmentId': + case 'source': + $question->setValidator(function ($value) use ($argument) { + return $this->validateEnvironmentUuid($value, $argument->getName()); + }); + break; + + default: + $validator = $this->createCallableValidator($argument, $params); + $question->setValidator($validator); + break; + } + + // Allow unlimited attempts. + $question->setMaxAttempts(NULL); + return $this->io->askQuestion($question); + } + } diff --git a/src/Command/CommandBase.php b/src/Command/CommandBase.php index 2b912f704..948debb91 100644 --- a/src/Command/CommandBase.php +++ b/src/Command/CommandBase.php @@ -218,6 +218,16 @@ public function __construct( parent::__construct(); } + /** + * @return \Symfony\Component\Validator\Constraints\Regex + */ + protected static function getUuidRegexConstraint(): Regex { + return new Regex([ + 'pattern' => '/^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$/i', + 'message' => 'This is not a valid UUID.', + ]); + } + /** * @param string $repoRoot */ @@ -345,10 +355,11 @@ protected function initialize(InputInterface $input, OutputInterface $output) { throw new AcquiaCliException('This machine is not yet authenticated with the Cloud Platform. Please run `acli auth:login`'); } - $this->convertApplicationAliasToUuid($input); $this->fillMissingRequiredApplicationUuid($input, $output); + $this->convertApplicationAliasToUuid($input); $this->convertEnvironmentAliasToUuid($input, 'environmentId'); $this->convertEnvironmentAliasToUuid($input, 'source'); + if ($latest = $this->checkForNewVersion()) { $this->output->writeln("Acquia CLI {$latest} is available. Run acli self-update to update."); } @@ -912,15 +923,12 @@ protected function getCloudApplicationUuidFromBltYaml() { * * @return string */ - public static function validateUuid($uuid) { + public static function validateUuid(string $uuid) { $violations = Validation::createValidator()->validate($uuid, [ new Length([ 'value' => 36, ]), - new Regex([ - 'pattern' => '/^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$/i', - 'message' => 'This is not a valid UUID.', - ]), + self::getUuidRegexConstraint(), ]); if (count($violations)) { throw new ValidatorException($violations->get(0)->getMessage()); @@ -1037,7 +1045,7 @@ public static function validateEnvironmentAlias($alias): string { $violations = Validation::createValidator()->validate($alias, [ new Length(['min' => 5]), new NotBlank(), - new Regex(['pattern' => '/.+\..+/', 'message' => 'Environment alias must match the pattern [app-name].[env]']), + new Regex(['pattern' => '/.+\..+/', 'message' => 'You must enter either an environment ID or alias. Environment aliases must match the pattern [app-name].[env]']), ]); if (count($violations)) { throw new ValidatorException($violations->get(0)->getMessage()); @@ -1307,18 +1315,8 @@ protected function fillMissingRequiredApplicationUuid(InputInterface $input, Out protected function convertApplicationAliasToUuid(InputInterface $input): void { if ($input->hasArgument('applicationUuid') && $input->getArgument('applicationUuid')) { $application_uuid_argument = $input->getArgument('applicationUuid'); - try { - self::validateUuid($application_uuid_argument); - } catch (ValidatorException $validator_exception) { - // Since this isn't a valid UUID, let's see if it's a valid alias. - $alias = $this->normalizeAlias($application_uuid_argument); - try { - $customer_application = $this->getApplicationFromAlias($alias); - $input->setArgument('applicationUuid', $customer_application->uuid); - } catch (AcquiaCliException $exception) { - throw new AcquiaCliException("The {applicationUuid} argument must be a valid UUID or application alias that is accessible to your Cloud user."); - } - } + $application_uuid = $this->validateApplicationUuid($application_uuid_argument); + $input->setArgument('applicationUuid', $application_uuid); } } @@ -1333,25 +1331,8 @@ protected function convertApplicationAliasToUuid(InputInterface $input): void { protected function convertEnvironmentAliasToUuid(InputInterface $input, $argument_name): void { if ($input->hasArgument($argument_name) && $input->getArgument($argument_name)) { $env_uuid_argument = $input->getArgument($argument_name); - try { - // Environment IDs take the form of [env-num]-[app-uuid]. - $uuid_parts = explode('-', $env_uuid_argument); - $env_id = $uuid_parts[0]; - unset($uuid_parts[0]); - $application_uuid = implode('-', $uuid_parts); - self::validateUuid($application_uuid); - } catch (ValidatorException $validator_exception) { - try { - // Since this isn't a valid environment ID, let's see if it's a valid alias. - $alias = $env_uuid_argument; - $alias = $this->normalizeAlias($alias); - $alias = self::validateEnvironmentAlias($alias); - $environment = $this->getEnvironmentFromAliasArg($alias); - $input->setArgument($argument_name, $environment->uuid); - } catch (AcquiaCliException $exception) { - throw new AcquiaCliException("{{$argument_name}} must be a valid UUID or site alias."); - } - } + $environment_uuid = $this->validateEnvironmentUuid($env_uuid_argument, $argument_name); + $input->setArgument($argument_name, $environment_uuid); } } @@ -1760,4 +1741,56 @@ protected function getAnyNonProdAhEnvironment(string $cloud_app_uuid): ?Environm return NULL; } + /** + * @param $application_uuid_argument + * + * @throws \Acquia\Cli\Exception\AcquiaCliException + * @throws \Psr\Cache\InvalidArgumentException + */ + protected function validateApplicationUuid($application_uuid_argument) { + try { + self::validateUuid($application_uuid_argument); + } catch (ValidatorException $validator_exception) { + // Since this isn't a valid UUID, let's see if it's a valid alias. + $alias = $this->normalizeAlias($application_uuid_argument); + try { + $customer_application = $this->getApplicationFromAlias($alias); + return $customer_application->uuid; + } catch (AcquiaCliException $exception) { + throw new AcquiaCliException("The {applicationUuid} argument must be a valid UUID or application alias that is accessible to your Cloud user."); + } + } + return $application_uuid_argument; + } + + /** + * @param $env_uuid_argument + * @param $argument_name + * + * @throws \Acquia\Cli\Exception\AcquiaCliException + * @throws \Psr\Cache\InvalidArgumentException + */ + protected function validateEnvironmentUuid($env_uuid_argument, $argument_name) { + try { + // Environment IDs take the form of [env-num]-[app-uuid]. + $uuid_parts = explode('-', $env_uuid_argument); + $env_id = $uuid_parts[0]; + unset($uuid_parts[0]); + $application_uuid = implode('-', $uuid_parts); + self::validateUuid($application_uuid); + } catch (ValidatorException $validator_exception) { + try { + // Since this isn't a valid environment ID, let's see if it's a valid alias. + $alias = $env_uuid_argument; + $alias = $this->normalizeAlias($alias); + $alias = self::validateEnvironmentAlias($alias); + $environment = $this->getEnvironmentFromAliasArg($alias); + return $environment->uuid; + } catch (AcquiaCliException $exception) { + throw new AcquiaCliException("{{$argument_name}} must be a valid UUID or site alias."); + } + } + return $env_uuid_argument; + } + } diff --git a/tests/phpunit/src/Commands/Api/ApiCommandTest.php b/tests/phpunit/src/Commands/Api/ApiCommandTest.php index 656a008ad..31fe766bd 100644 --- a/tests/phpunit/src/Commands/Api/ApiCommandTest.php +++ b/tests/phpunit/src/Commands/Api/ApiCommandTest.php @@ -8,6 +8,7 @@ use Acquia\Cli\Tests\CommandTestBase; use AcquiaCloudApi\Exception\ApiErrorException; use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Exception\MissingInputException; use Symfony\Component\Filesystem\Path; use Symfony\Component\Yaml\Yaml; @@ -32,6 +33,50 @@ protected function createCommand(): Command { return $this->injectCommand(ApiCommandBase::class); } + public function testArgumentsInteraction() { + $this->command = $this->getApiCommandByName('api:environments:log-download'); + $this->executeCommand([], [ + '289576-53785bca-1946-4adc-a022-e50d24686c20', + 'apache-access', + ]); + $output = $this->getDisplay(); + $this->assertStringContainsString('Please enter a value for environmentId', $output); + $this->assertStringContainsString('logType is a required argument', $output); + $this->assertStringContainsString('An ID that uniquely identifies a log type.', $output); + $this->assertStringContainsString('apache-access', $output); + $this->assertStringContainsString('Please select a value for logType', $output); + } + + public function testArgumentsInteractionValidation() { + $this->command = $this->getApiCommandByName('api:environments:variable-update'); + try { + $this->executeCommand([], [ + '289576-53785bca-1946-4adc-a022-e50d24686c20', + 'AH_SOMETHING', + 'AH_SOMETHING', + ]); + } + catch (MissingInputException $exception) { + + } + $output = $this->getDisplay(); + $this->assertStringContainsString('It must match the pattern', $output); + } + + public function testArgumentsInteractionValdationFormat() { + $this->command = $this->getApiCommandByName('api:notifications:find'); + try { + $this->executeCommand([], [ + 'test' + ]); + } + catch (MissingInputException $exception) { + + } + $output = $this->getDisplay(); + $this->assertStringContainsString('This is not a valid UUID', $output); + } + /** * Tests invalid UUID. */ diff --git a/tests/phpunit/src/Commands/CommandBaseTest.php b/tests/phpunit/src/Commands/CommandBaseTest.php index 753fb5750..d0f61355f 100644 --- a/tests/phpunit/src/Commands/CommandBaseTest.php +++ b/tests/phpunit/src/Commands/CommandBaseTest.php @@ -104,8 +104,8 @@ public function testInvalidCloudAppUuidArg($uuid, $message): void { public function providerTestInvalidCloudEnvironmentAlias(): array { return [ ['bl.a', 'This value is too short. It should have 5 characters or more.'], - ['blarg', 'Environment alias must match the pattern [app-name].[env]'], - ['12345', 'Environment alias must match the pattern [app-name].[env]'], + ['blarg', 'You must enter either an environment ID or alias. Environment aliases must match the pattern [app-name].[env]'], + ['12345', 'You must enter either an environment ID or alias. Environment aliases must match the pattern [app-name].[env]'], ]; }