diff --git a/src/Command/Api/ApiBaseCommand.php b/src/Command/Api/ApiBaseCommand.php index 4d0cea76b..4a3eb4a67 100644 --- a/src/Command/Api/ApiBaseCommand.php +++ b/src/Command/Api/ApiBaseCommand.php @@ -245,7 +245,7 @@ private function castParamType(array $paramSpec, array|string|bool|int $value): if (in_array('integer', $types, true) && ctype_digit($value)) { return $this->doCastParamType('integer', $value); } - } elseif ($paramSpec['type'] === 'array') { + } elseif ($this->getParamType($paramSpec) === 'array') { if (is_array($value) && count($value) === 1) { return $this->castParamToArray($paramSpec, $value[0]); } diff --git a/src/Command/Api/ApiCommandHelper.php b/src/Command/Api/ApiCommandHelper.php index c8573e51d..aaa2236fd 100644 --- a/src/Command/Api/ApiCommandHelper.php +++ b/src/Command/Api/ApiCommandHelper.php @@ -93,7 +93,7 @@ private function addApiCommandParameters(array $schema, array $acquiaCloudSpec, $requestBodySchema = $this->getRequestBodyFromParameterSchema($schema, $acquiaCloudSpec); /** @var \Symfony\Component\Console\Input\InputOption|InputArgument $parameterDefinition */ foreach ($bodyInputDefinition as $parameterDefinition) { - $parameterSpecification = $this->getPropertySpecFromRequestBodyParam($requestBodySchema, $parameterDefinition); + $parameterSpecification = $this->getPropertySpecFromRequestBodyParam($requestBodySchema, $parameterDefinition, $acquiaCloudSpec); $command->addPostParameter($parameterDefinition->getName(), $parameterSpecification); } $usage .= $requestBodyParamUsageSuffix; @@ -125,6 +125,12 @@ private function addApiCommandParametersForRequestBody(array $schema, array $acq $requestBodySchema['properties'] = []; } foreach ($requestBodySchema['properties'] as $propKey => $paramDefinition) { + // Resolve $ref inside individual property definitions. + if (array_key_exists('$ref', $paramDefinition)) { + $parts = explode('/', $paramDefinition['$ref']); + $paramKey = end($parts); + $paramDefinition = $this->getParameterSchemaFromSpec($paramKey, $acquiaCloudSpec); + } $isRequired = array_key_exists('required', $requestBodySchema) && in_array($propKey, $requestBodySchema['required'], true); $propKey = self::renameParameter($propKey); @@ -141,7 +147,7 @@ private function addApiCommandParametersForRequestBody(array $schema, array $acq array_key_exists('type', $paramDefinition) && $paramDefinition['type'] === 'array' ? InputArgument::IS_ARRAY | InputArgument::REQUIRED : InputArgument::REQUIRED, $description ); - $usage = $this->addPostArgumentUsageToExample($schema['requestBody'], $propKey, $paramDefinition, 'argument', $usage, $acquiaCloudSpec); + $usage = $this->addPostArgumentUsageToExample($schema['requestBody'], $propKey, $paramDefinition, 'argument', $usage ? $usage . ' ' : '', $acquiaCloudSpec); } else { $inputDefinition[] = new InputOption( $propKey, @@ -149,7 +155,7 @@ private function addApiCommandParametersForRequestBody(array $schema, array $acq array_key_exists('type', $paramDefinition) && $paramDefinition['type'] === 'array' ? InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED : InputOption::VALUE_REQUIRED, array_key_exists('description', $paramDefinition) ? $paramDefinition['description'] : $propKey ); - $usage = $this->addPostArgumentUsageToExample($schema["requestBody"], $propKey, $paramDefinition, 'option', $usage, $acquiaCloudSpec); + $usage = $this->addPostArgumentUsageToExample($schema["requestBody"], $propKey, $paramDefinition, 'option', $usage ? $usage . ' ' : '', $acquiaCloudSpec); // @todo Add validator for $param['enum'] values? } } @@ -168,31 +174,42 @@ private function addApiCommandParametersForRequestBody(array $schema, array $acq private function addPostArgumentUsageToExample(mixed $requestBody, mixed $propKey, mixed $paramDefinition, string $type, string $usage, array $acquiaCloudSpec): string { $requestBodyContent = $this->getRequestBodyContent($requestBody, $acquiaCloudSpec); - + // Example may live directly on the content-type object (inline requestBody), + // or nested inside schema (e.g. $ref-resolved requestBodies). if (array_key_exists('example', $requestBodyContent)) { $example = $requestBodyContent['example']; + } elseif (array_key_exists('schema', $requestBodyContent) && array_key_exists('example', $requestBodyContent['schema'])) { + $example = $requestBodyContent['schema']['example']; + } else { + return $usage; + } + + if ($example) { $prefix = $type === 'argument' ? '' : "--$propKey="; if (array_key_exists($propKey, $example)) { if (!array_key_exists('type', $paramDefinition)) { return $usage; } + $parts = []; switch ($paramDefinition['type']) { case 'object': - $usage .= $prefix . '"' . json_encode($example[$propKey], JSON_THROW_ON_ERROR) . '"" '; + // Wrap JSON in single quotes so inner double quotes remain shell-safe. + $parts[] = sprintf("%s'%s'", $prefix, json_encode($example[$propKey], JSON_THROW_ON_ERROR)); break; case 'array': $isMultidimensional = count($example[$propKey]) !== count($example[$propKey], COUNT_RECURSIVE); if (!$isMultidimensional) { foreach ($example[$propKey] as $value) { - $usage .= $prefix . "\"$value\" "; + $parts[] = sprintf("%s'%s'", $prefix, $value); } } else { // @todo Pretty sure prevents the user from using the arguments. // Probably a bug. How can we allow users to specify a multidimensional array as an // argument? $value = json_encode($example[$propKey], JSON_THROW_ON_ERROR); - $usage .= $prefix . "\"$value\" "; + // Wrap JSON in single quotes so inner double quotes remain shell-safe. + $parts[] = sprintf("%s'%s'", $prefix, $value); } break; @@ -204,9 +221,12 @@ private function addPostArgumentUsageToExample(mixed $requestBody, mixed $propKe } else { $value = $example[$propKey]; } - $usage .= $prefix . "\"$value\" "; + $parts[] = sprintf("%s'%s'", $prefix, $value); break; } + if ($parts !== []) { + return $usage . implode(' ', $parts); + } } } return $usage; @@ -482,10 +502,20 @@ private function getRequestBodyFromParameterSchema(array $schema, array $acquiaC return $requestBodySchema; } - private function getPropertySpecFromRequestBodyParam(array $requestBodySchema, mixed $parameterDefinition): mixed + private function getPropertySpecFromRequestBodyParam(array $requestBodySchema, mixed $parameterDefinition, array $acquiaCloudSpec = []): mixed { $name = self::restoreRenamedParameter($parameterDefinition->getName()); - return $requestBodySchema['properties'][$name] ?? null; + $spec = $requestBodySchema['properties'][$name] ?? []; + + // Resolve $ref in the property spec so downstream code (e.g. castParamType) always + // receives a fully resolved spec with a 'type' key rather than a bare $ref object. + if (array_key_exists('$ref', $spec)) { + $parts = explode('/', $spec['$ref']); + $paramKey = end($parts); + $spec = $this->getParameterSchemaFromSpec($paramKey, $acquiaCloudSpec); + } + + return $spec; } /** diff --git a/tests/phpunit/src/Commands/Api/ApiCommandHelperTest.php b/tests/phpunit/src/Commands/Api/ApiCommandHelperTest.php index 407827157..1dd469e48 100644 --- a/tests/phpunit/src/Commands/Api/ApiCommandHelperTest.php +++ b/tests/phpunit/src/Commands/Api/ApiCommandHelperTest.php @@ -109,4 +109,41 @@ public function testNamespaceWithAllHiddenCommandsDoesNotGetListCommand(): void $listCommands = $this->generateApiListCommands($apiCommands); $this->assertArrayNotHasKey('api:baz', $listCommands); } + + /** + * Calls private or protected method of ApiCommandHelper class via reflection. + * + * @throws \ReflectionException + */ + private function invokeApiCommandHelperMethod(string $methodName, array $args = []): mixed + { + $commandHelper = new ApiCommandHelper($this->logger); + $refClass = new ReflectionMethod($commandHelper::class, $methodName); + return $refClass->invokeArgs($commandHelper, $args); + } + + /** + * Test that addPostArgumentUsageToExample correctly formats a flat array with a single item. + */ + public function testAddPostArgumentUsageToExampleFlatArraySingleItem(): void + { + $result = $this->invokeApiCommandHelperMethod( + 'addPostArgumentUsageToExample', + [ + [ + 'content' => [ + 'application/json' => [ + 'example' => ['tags' => ['drupal']], + ], + ], + ], + 'tags', + ['type' => 'array'], + 'option', + '', + [], + ] + ); + $this->assertSame("--tags='drupal'", $result); + } } diff --git a/tests/phpunit/src/Commands/Api/ApiCommandTest.php b/tests/phpunit/src/Commands/Api/ApiCommandTest.php index bf09a80be..d99be77a0 100644 --- a/tests/phpunit/src/Commands/Api/ApiCommandTest.php +++ b/tests/phpunit/src/Commands/Api/ApiCommandTest.php @@ -543,12 +543,49 @@ public static function providerTestApiCommandDefinitionRequestBody(): array [ 'api:accounts:ssh-key-create', 'post', - 'api:accounts:ssh-key-create "mykey" "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQChwPHzTTDKDpSbpa2+d22LcbQmsw92eLsUK3Fmei1fiGDkd34NsYCN8m7lsi3NbvdMS83CtPQPWiCveYPzFs1/hHc4PYj8opD2CNnr5iWVVbyaulCYHCgVv4aB/ojcexg8q483A4xJeF15TiCr/gu34rK6ucTvC/tn/rCwJBudczvEwt0klqYwv8Cl/ytaQboSuem5KgSjO3lMrb6CWtfSNhE43ZOw+UBFBqxIninN868vGMkIv9VY34Pwj54rPn/ItQd6Ef4B0KHHaGmzK0vfP+AK7FxNMoHnj3iYT33KZNqtDozdn5tYyH/bThPebEtgqUn+/w5l6wZIC/8zzvls/127ngHk+jNa0PlNyS2TxhPUK4NaPHIEnnrlp07JEYC4ImcBjaYCWAdcTcUkcJjwZQkN4bGmyO9cjICH98SdLD/HxqzTHeaYDbAX/Hu9HfaBb5dXLWsjw3Xc6hoVnUUZbMQyfgb0KgxDLh92eNGxJkpZiL0VDNOWCxDWsNpzwhLNkLqCvI6lyxiLaUzvJAk6dPaRhExmCbU1lDO2eR0FdSwC1TEhJOT9eDIK1r2hztZKs2oa5FNFfB/IFHVWasVFC9N2h/r/egB5zsRxC9MqBLRBq95NBxaRSFng6ML5WZSw41Qi4C/JWVm89rdj2WqScDHYyAdwyyppWU4T5c9Fmw== example@example.com"', + ['api:accounts:ssh-key-create \'mykey\' \'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQChwPHzTTDKDpSbpa2+d22LcbQmsw92eLsUK3Fmei1fiGDkd34NsYCN8m7lsi3NbvdMS83CtPQPWiCveYPzFs1/hHc4PYj8opD2CNnr5iWVVbyaulCYHCgVv4aB/ojcexg8q483A4xJeF15TiCr/gu34rK6ucTvC/tn/rCwJBudczvEwt0klqYwv8Cl/ytaQboSuem5KgSjO3lMrb6CWtfSNhE43ZOw+UBFBqxIninN868vGMkIv9VY34Pwj54rPn/ItQd6Ef4B0KHHaGmzK0vfP+AK7FxNMoHnj3iYT33KZNqtDozdn5tYyH/bThPebEtgqUn+/w5l6wZIC/8zzvls/127ngHk+jNa0PlNyS2TxhPUK4NaPHIEnnrlp07JEYC4ImcBjaYCWAdcTcUkcJjwZQkN4bGmyO9cjICH98SdLD/HxqzTHeaYDbAX/Hu9HfaBb5dXLWsjw3Xc6hoVnUUZbMQyfgb0KgxDLh92eNGxJkpZiL0VDNOWCxDWsNpzwhLNkLqCvI6lyxiLaUzvJAk6dPaRhExmCbU1lDO2eR0FdSwC1TEhJOT9eDIK1r2hztZKs2oa5FNFfB/IFHVWasVFC9N2h/r/egB5zsRxC9MqBLRBq95NBxaRSFng6ML5WZSw41Qi4C/JWVm89rdj2WqScDHYyAdwyyppWU4T5c9Fmw== example@example.com\''], ], [ 'api:environments:file-copy', 'post', - '12-d314739e-296f-11e9-b210-d663bd873d93 --source="14-0c7e79ab-1c4a-424e-8446-76ae8be7e851"', + [ + 'api:environments:file-copy 12-d314739e-296f-11e9-b210-d663bd873d93 --source=\'14-0c7e79ab-1c4a-424e-8446-76ae8be7e851\'', + 'api:environments:file-copy myapp.dev --source=\'14-0c7e79ab-1c4a-424e-8446-76ae8be7e851\'', + ], + ], + [ + 'api:private-networks:create', + 'post', + ['api:private-networks:create \'123e4567-e89b-12d3-a456-426614174000\' \'us-east-1\' \'customer-private-network\' --description=\'Private network for customer\' --label=\'anyLabel\' --isolation=\'{"dedicated_compute":false,"dedicated_network":false}\' --ingress=\'{"drupal_ssh":{"ingress_acls":["test-acls"]}}\' \'{"cidr":"114.7.55.1\/16","private_egress_access":{"drupal":true},"vpns":[{"name":"vpn1","gateway_ip":"10.10.10.10","routes":["127.0.0.1\/32","127.0.0.2\/32"],"tunnel1":{"shared_key":"sharedKey1","internal_cidr":"192.1.1.0\/24","ike_versions":"1","startup_action":"start","dpd_timeout_action":"stop"},"tunnel2":{"shared_key":"sharedKey2","internal_cidr":"192.1.1.0\/14","ike_versions":"1","startup_action":"start","dpd_timeout_action":"stop"}}],"vpc_peers":[{"name":"vpcPeer1","aws_account":"123456789012","vpc_id":"vpc-1234567890abcdef0","vpc_cidr":"120.24.16.1\/24"}]}\''], + ], + [ + 'api:private-networks:update-isolation', + 'put', + ['api:private-networks:update-isolation --dedicated_compute=\'1\' --dedicated_network=\'1\''], + ], + [ + 'api:environments:livedev-disable', + 'post', + [ + 'api:environments:livedev-disable 12-d314739e-296f-11e9-b210-d663bd873d93 --discard=\'1\'', + 'api:environments:livedev-disable myapp.dev --discard=\'1\'', + ], + ], + [ + 'api:environments:code-deploy', + 'post', + [ + 'api:environments:code-deploy 12-d314739e-296f-11e9-b210-d663bd873d93 \'14-0c7e79ab-1c4a-424e-8446-76ae8be7e851\' --message=\'Optional commit message\'', + 'api:environments:code-deploy myapp.dev \'14-0c7e79ab-1c4a-424e-8446-76ae8be7e851\' --message=\'Optional commit message\'', + ], + ], + [ + 'api:environments:code-switch', + 'post', + [ + 'api:environments:code-switch 12-d314739e-296f-11e9-b210-d663bd873d93 \'my-feature-branch\'', + 'api:environments:code-switch myapp.dev \'my-feature-branch\'', + ], ], ]; } @@ -559,11 +596,19 @@ public static function providerTestApiCommandDefinitionRequestBody(): array * @param $method * @param $usage */ - public function testApiCommandDefinitionRequestBody(string $commandName, string $method, string $usage): void + public function testApiCommandDefinitionRequestBody(string $commandName, string $method, array $usage): void { $this->command = $this->getApiCommandByName($commandName); $resource = self::getResourceFromSpec($this->command->getPath(), $method); - foreach ($resource['requestBody']['content']['application/hal+json']['example'] as $propKey => $value) { + if (array_key_exists('$ref', $resource['requestBody'])) { + $cloudApiSpec = self::getCloudApiSpec(); + $parts = explode('/', $resource['requestBody']['$ref']); + $paramKey = end($parts); + $resource['requestBody'] = $cloudApiSpec['components']['requestBodies'][$paramKey]; + } + $example = $resource['requestBody']['content']['application/hal+json']['example'] ?? $resource['requestBody']['content']['application/json']['schema']['example'] ?? $resource['requestBody']['content']['application/json']['example'] ?? null; + self::assertNotEmpty($example); + foreach ($example as $propKey => $value) { $this->assertTrue( $this->command->getDefinition() ->hasArgument($propKey) || $this->command->getDefinition() @@ -571,7 +616,7 @@ public function testApiCommandDefinitionRequestBody(string $commandName, string "Command {$this->command->getName()} does not have expected argument or option $propKey" ); } - $this->assertStringContainsString($usage, $this->command->getUsages()[0]); + $this->assertSame($usage, $this->command->getUsages()); } public function testGetApplicationUuidFromBltYml(): void