diff --git a/.changes/nextrelease/endpointv2-updates.json b/.changes/nextrelease/endpointv2-updates.json new file mode 100644 index 0000000000..fe59718a01 --- /dev/null +++ b/.changes/nextrelease/endpointv2-updates.json @@ -0,0 +1,7 @@ +[ + { + "type": "enhancement", + "category": "EndpointV2", + "description": "Adds support for `StringArray` endpoint parameters and `operationContextParams`" + } +] diff --git a/.changes/nextrelease/expires-customization.json b/.changes/nextrelease/expires-customization.json deleted file mode 100644 index 2761e2a8ec..0000000000 --- a/.changes/nextrelease/expires-customization.json +++ /dev/null @@ -1,7 +0,0 @@ -[ - { - "type": "feature", - "category": "S3", - "description": "Adds customization to output structures for `Expires` parsing which adds an additional shape `ExpiresString`" - } -] diff --git a/src/Api/Operation.php b/src/Api/Operation.php index cee94ac7c8..36ba3950cb 100644 --- a/src/Api/Operation.php +++ b/src/Api/Operation.php @@ -11,6 +11,7 @@ class Operation extends AbstractModel private $errors; private $staticContextParams = []; private $contextParams; + private $operationContextParams = []; public function __construct(array $definition, ShapeMap $shapeMap) { @@ -28,6 +29,10 @@ public function __construct(array $definition, ShapeMap $shapeMap) $this->staticContextParams = $definition['staticContextParams']; } + if (isset($definition['operationContextParams'])) { + $this->operationContextParams = $definition['operationContextParams']; + } + parent::__construct($definition, $shapeMap); $this->contextParams = $this->setContextParams(); } @@ -124,6 +129,17 @@ public function getContextParams() return $this->contextParams; } + /** + * Gets definition of modeled dynamic values used + * for endpoint resolution + * + * @return array + */ + public function getOperationContextParams(): array + { + return $this->operationContextParams; + } + private function setContextParams() { $members = $this->getInput()->getMembers(); diff --git a/src/ClientSideMonitoring/ApiCallMonitoringMiddleware.php b/src/ClientSideMonitoring/ApiCallMonitoringMiddleware.php index 0a5abdac3a..420807384d 100644 --- a/src/ClientSideMonitoring/ApiCallMonitoringMiddleware.php +++ b/src/ClientSideMonitoring/ApiCallMonitoringMiddleware.php @@ -13,7 +13,6 @@ */ class ApiCallMonitoringMiddleware extends AbstractMonitoringMiddleware { - /** * Api Call Attempt event keys for each Api Call event key * diff --git a/src/EndpointV2/EndpointV2Middleware.php b/src/EndpointV2/EndpointV2Middleware.php index e3070a7d90..6556c92b57 100644 --- a/src/EndpointV2/EndpointV2Middleware.php +++ b/src/EndpointV2/EndpointV2Middleware.php @@ -7,6 +7,7 @@ use Aws\CommandInterface; use Closure; use GuzzleHttp\Promise\Promise; +use function JmesPath\search; /** * Handles endpoint rule evaluation and endpoint resolution. @@ -137,9 +138,13 @@ private function resolveArgs(array $commandArgs, Operation $operation): array $contextParams = $this->bindContextParams( $commandArgs, $operation->getContextParams() ); + $operationContextParams = $this->bindOperationContextParams( + $commandArgs, $operation->getOperationContextParams() + ); return array_merge( $this->clientArgs, + $operationContextParams, $contextParams, $staticContextParams, $endpointCommandArgs @@ -231,6 +236,35 @@ private function bindContextParams( return $scopedParams; } + /** + * Binds context params to their corresponding values found in + * command arguments. + * + * @param array $commandArgs + * @param array $contextParams + * + * @return array + */ + private function bindOperationContextParams( + array $commandArgs, + array $operationContextParams + ): array + { + $scopedParams = []; + + foreach($operationContextParams as $name => $spec) { + if (isset($commandArgs[$name])) { + $scopedValue = search($spec['path'], $commandArgs[$name]); + + if ($scopedValue) { + $scopedParams[$name] = $scopedValue; + } + } + } + + return $scopedParams; + } + /** * Applies resolved auth schemes to the command object. * diff --git a/src/EndpointV2/Ruleset/RulesetParameter.php b/src/EndpointV2/Ruleset/RulesetParameter.php index b7cd107616..1a3322cd1b 100644 --- a/src/EndpointV2/Ruleset/RulesetParameter.php +++ b/src/EndpointV2/Ruleset/RulesetParameter.php @@ -3,6 +3,7 @@ namespace Aws\EndpointV2\Ruleset; use Aws\Exception\UnresolvedEndpointException; +use function \Aws\is_associative; /** * Houses properties of an individual parameter definition. @@ -30,6 +31,13 @@ class RulesetParameter /** @var boolean */ private $deprecated; + /** @var array */ + private static $typeMap = [ + 'String' => 'is_string', + 'Boolean' => 'is_bool', + 'StringArray' => 'isStringArray' + ]; + public function __construct($name, array $definition) { $type = ucfirst($definition['type']); @@ -38,18 +46,16 @@ public function __construct($name, array $definition) } else { throw new UnresolvedEndpointException( 'Unknown parameter type ' . "`{$type}`" . - '. Parameters must be of type `String` or `Boolean`.' + '. Parameters must be of type `String`, `Boolean` or `StringArray.' ); } + $this->name = $name; - $this->builtIn = isset($definition['builtIn']) ? $definition['builtIn'] : null; - $this->default = isset($definition['default']) ? $definition['default'] : null; - $this->required = isset($definition['required']) ? - $definition['required'] : false; - $this->documentation = isset($definition['documentation']) ? - $definition['documentation'] : null; - $this->deprecated = isset($definition['deprecated']) ? - $definition['deprecated'] : false; + $this->builtIn = $definition['builtIn'] ?? null; + $this->default = $definition['default'] ?? null; + $this->required = $definition['required'] ?? false; + $this->documentation = $definition['documentation'] ?? null; + $this->deprecated = $definition['deprecated'] ?? false; } /** @@ -116,12 +122,7 @@ public function getDeprecated() */ public function validateInputParam($inputParam) { - $typeMap = [ - 'String' => 'is_string', - 'Boolean' => 'is_bool' - ]; - - if ($typeMap[$this->type]($inputParam) === false) { + if (!$this->isValidInput($inputParam)) { throw new UnresolvedEndpointException( "Input parameter `{$this->name}` is the wrong type. Must be a {$this->type}." ); @@ -130,12 +131,15 @@ public function validateInputParam($inputParam) if ($this->deprecated) { $deprecated = $this->deprecated; $deprecationString = "{$this->name} has been deprecated "; - $msg = isset($deprecated['message']) ? $deprecated['message'] : null; - $since = isset($deprecated['since']) ? $deprecated['since'] : null; + $msg = $deprecated['message'] ?? null; + $since = $deprecated['since'] ?? null; - if (!is_null($since)) $deprecationString = $deprecationString - . 'since '. $since . '. '; - if (!is_null($msg)) $deprecationString = $deprecationString . $msg; + if (!is_null($since)){ + $deprecationString .= 'since ' . $since . '. '; + } + if (!is_null($msg)) { + $deprecationString .= $msg; + } trigger_error($deprecationString, E_USER_WARNING); } @@ -143,6 +147,33 @@ public function validateInputParam($inputParam) private function isValidType($type) { - return in_array($type, ['String', 'Boolean']); + return isset(self::$typeMap[$type]); + } + + private function isValidInput($inputParam): bool + { + $method = self::$typeMap[$this->type]; + if (is_callable($method)) { + return $method($inputParam); + } elseif (method_exists($this, $method)) { + return $this->$method($inputParam); + } + + return false; + } + + private function isStringArray(array $array): bool + { + if (is_associative($array)) { + return false; + } + + foreach($array as $value) { + if (!is_string($value)) { + return false; + } + } + + return true; } } diff --git a/src/EndpointV2/Ruleset/RulesetStandardLibrary.php b/src/EndpointV2/Ruleset/RulesetStandardLibrary.php index 2705ff997e..92711f7a4a 100644 --- a/src/EndpointV2/Ruleset/RulesetStandardLibrary.php +++ b/src/EndpointV2/Ruleset/RulesetStandardLibrary.php @@ -61,6 +61,14 @@ public function not($value) */ public function getAttr($from, $path) { + // Handles the case where "[ ["shape" => "ListOfObjectsOperationRequest"], + "operationContextParams" => [ + "stringArrayParam" => [ + "path" => "nested.listOfObjects[*].key" + ] + ], + "http" => [ + "method" => "POST", + "requestUri" => "/" + ] + ]; + $operation = new Operation( + $definition, + new ShapeMap([ + "ListOfObjectsOperationRequest" => [ + "type" => "structure", + "members" => [ + "nested" => ["shape" => "Nested"] + ] + ], + "Nested" => [ + "type" => "structure", + "members" => [ + "listOfObjects" => ["shape" => "ListOfObjects"] + ] + ], + "ListOfObjects" => [ + "type" => "list", + "member" => ["shape" => "ObjectMember"] + ] + ]) + ); + + $operationContextParams = $operation->getOperationContextParams(); + $this->assertSame($definition['operationContextParams'], $operationContextParams); + } } diff --git a/tests/EndpointV2/EndpointProviderV2Test.php b/tests/EndpointV2/EndpointProviderV2Test.php index 09e5b7fcf4..17b7cd023f 100644 --- a/tests/EndpointV2/EndpointProviderV2Test.php +++ b/tests/EndpointV2/EndpointProviderV2Test.php @@ -1,7 +1,9 @@ resolveEndpoint(['Region' => 'us-west-2']); $endpointProvider->resolveEndpoint(['Region' => 'us-west-2']); } + + /** + * @return void + * @dataProvider stringArrayOperationInputsProvider + */ + public function testStringArrayOperationInputs( + $params, + $expected, + $operationInputs + ) + { + if (isset($expected['error'])) { + $this->expectException(UnresolvedEndpointException::class); + $this->expectExceptionMessage($expected['error']); + } + + $serviceDefinition = json_decode(file_get_contents( + __DIR__ . '/service-models/string-array.json' + ), true); + $service = new Service($serviceDefinition, function () { + return []; + }); + $client = new AwsClient([ + 'service' => 'foo', + 'api_provider' => function () use ($service) { + return $service->toArray(); + }, + 'region' => 'bar', + 'endpoint_provider' => new EndpointProviderV2( + json_decode( + file_get_contents( + __DIR__ . '/valid-rules/string-array.json'), + true + ), + EndpointDefinitionProvider::getPartitions() + ) + ]); + + $list = $client->getHandlerList(); + if (!isset($expected['error'])) { + $list->appendSign(Middleware::tap(function($cmd, $req) use ($service, $expected) { + $expectedEndpoint = $expected['endpoint']; + $expectedUri = new Uri($expected['endpoint']['url']); + $this->assertStringContainsString( + $expectedUri->getHost(), + $req->getUri()->getHost() + ); + })); + } + + foreach($operationInputs as $operation) { + $this->addMockResults($client, [[]]); + $command = $client->getCommand( + $operation['operationName'], + $operation['operationParams'] ?? [] + ); + $client->execute($command); + } + } + + public function stringArrayOperationInputsProvider() + { + $cases = json_decode( + file_get_contents(__DIR__ . '/test-cases/string-array.json'), + true + ); + $providerCases = []; + + foreach ($cases['testCases'] as $case) { + unset($case['documentation']); + $providerCases[] = $case; + } + + return $providerCases; + } } diff --git a/tests/EndpointV2/RulesetParameterTest.php b/tests/EndpointV2/RulesetParameterTest.php index 2300c8d430..aeab9340df 100644 --- a/tests/EndpointV2/RulesetParameterTest.php +++ b/tests/EndpointV2/RulesetParameterTest.php @@ -10,21 +10,6 @@ */ class RulesetParameterTest extends TestCase { - private $rulesetParameter; - - protected function set_up() - { - $spec = [ - "type" => "string", - "builtIn" => "AWS::Region", - "deprecated" => [ - "since" => 'then', - "message" => 'There is a new parameter.' - ] - ]; - $this->rulesetParameter = new RulesetParameter('Region' ,$spec); - } - public function wrongParameterTypeProvider() { return [ @@ -46,7 +31,11 @@ public function testWrongParameterTypeThrowsException($inputParameter) "Input parameter `Region` is the wrong type. Must be a String." ); - $this->rulesetParameter->validateInputParam($inputParameter); + $parameter = $this->createTestParameter('Region', [ + "type" => "string", + "builtIn" => "AWS::Region" + ]); + $parameter->validateInputParam($inputParameter); } public function testDeprecatedParameterLogsError() @@ -55,19 +44,67 @@ public function testDeprecatedParameterLogsError() $this->expectExceptionMessage( 'Region has been deprecated since then. There is a new parameter.' ); - $this->rulesetParameter->validateInputParam('us-east-1'); + $parameter = new RulesetParameter('Region', [ + "type" => "string", + "builtIn" => "AWS::Region", + "deprecated" => [ + "since" => 'then', + "message" => 'There is a new parameter.' + ] + ]); + $parameter->validateInputParam('us-east-1'); } - public function testUnknownTypeThrowsException() { + public function testUnknownTypeThrowsException() + { $parameterSpec = [ 'type' => 'tuple' ]; $this->expectException(UnresolvedEndpointException::class); $this->expectExceptionMessage( 'Unknown parameter type `Tuple`. ' . - 'Parameters must be of type `String` or `Boolean`.' + 'Parameters must be of type `String`, `Boolean` or `StringArray.' ); $rulesetParameter = new RulesetParameter('invalidType', $parameterSpec); } + + public function testGetDefault() + { + $spec = [ + "type" => "stringArray", + "default" => ['foo', 'bar'] + ]; + $ruleset = new RulesetParameter('FooStringArray', $spec); + $this->assertSame($spec['default'], $ruleset->getDefault()); + } + + /** + * @dataProvider validTypesProvider + * @doesNotPerformAssertions + */ + public function testRulesetCreationWithValidTypes($spec) + { + new RulesetParameter('FooParam', $spec); + } + + public function validTypesProvider() + { + return [ + [ + ["type" => "string",] + ], + [ + ["type" => "boolean",] + ], + [ + ["type" => "stringArray",] + ], + ]; + } + + private function createTestParameter($name, $spec) + { + return new RulesetParameter($name, $spec); + } } diff --git a/tests/EndpointV2/RulesetStandardLibraryTest.php b/tests/EndpointV2/RulesetStandardLibraryTest.php index 35c156a533..da3275c169 100644 --- a/tests/EndpointV2/RulesetStandardLibraryTest.php +++ b/tests/EndpointV2/RulesetStandardLibraryTest.php @@ -69,9 +69,10 @@ public function testNot($input, $expected) public function getAttrProvider() { return [ - ["Thing1", "foo"], - ["Thing2[0]", "index0"], - ["Thing3.SubThing", 42], + ['{"Thing1": "foo", "Thing2": ["index0", "index1"], "Thing3": {"SubThing": 42}}', "Thing1", "foo"], + ['{"Thing1": "foo", "Thing2": ["index0", "index1"], "Thing3": {"SubThing": 42}}', "Thing2[0]", "index0"], + ['{"Thing1": "foo", "Thing2": ["index0", "index1"], "Thing3": {"SubThing": 42}}', "Thing3.SubThing", 42], + ['["index0", "index1"]', "[0]", 'index0'] ]; } @@ -81,9 +82,9 @@ public function getAttrProvider() * @param $path * @param $expected */ - public function testGetAttr($path, $expected) + public function testGetAttr($from, $path, $expected) { - $from = json_decode('{"Thing1": "foo", "Thing2": ["index0", "index1"], "Thing3": {"SubThing": 42}}', true); + $from = json_decode($from, true); $this->assertSame($expected, $this->standardLibrary->getAttr($from, $path)); } diff --git a/tests/EndpointV2/service-models/string-array.json b/tests/EndpointV2/service-models/string-array.json new file mode 100644 index 0000000000..74dadae687 --- /dev/null +++ b/tests/EndpointV2/service-models/string-array.json @@ -0,0 +1,107 @@ +{ + "metadata": { + "endpointPrefix": "svcname", + "serviceId": "sample_svc", + "jsonVersion":"1.1", + "protocol":"json" + }, + "operations": { + "NoBindingsOperation": { + "input": { "shape": "EmptyOperationRequest" }, + "http": { + "method": "POST", + "requestUri": "/" + } + }, + "EmptyStaticContextOperation": { + "input": { "shape": "EmptyOperationRequest" }, + "staticContextParams": { + "stringArrayParam": { + "value": [] + } + }, + "http": { + "method": "POST", + "requestUri": "/" + } + }, + "StaticContextOperation": { + "input": { "shape": "EmptyOperationRequest" }, + "staticContextParams": { + "stringArrayParam": { + "value": ["staticValue1"] + } + }, + "http": { + "method": "POST", + "requestUri": "/" + } + }, + "ListOfObjectsOperation": { + "input": { "shape": "ListOfObjectsOperationRequest" }, + "operationContextParams": { + "stringArrayParam": { + "path": "nested.listOfObjects[*].key" + } + }, + "http": { + "method": "POST", + "requestUri": "/" + } + }, + "MapOperation": { + "input": { "shape": "MapOperationRequest" }, + "operationContextParams": { + "stringArrayParam": { + "path": "keys(map)" + } + }, + "http": { + "method": "POST", + "requestUri": "/" + } + } + }, + "shapes": { + "EmptyOperationRequest": { + "type": "structure", + "members": {} + }, + "ListOfObjectsOperationRequest": { + "type": "structure", + "members": { + "nested":{"shape":"Nested"} + } + }, + "Nested": { + "type": "structure", + "members": { + "listOfObjects":{"shape":"ListOfObjects"} + } + }, + "ListOfObjects": { + "type": "list", + "member":{"shape":"ObjectMember"} + }, + "ObjectMember": { + "type": "structure", + "members": { + "key":{"shape":"String"} + } + }, + "MapOperationRequest": { + "type": "structure", + "members": { + "map":{"shape":"Map"} + } + }, + "Map":{ + "type":"map", + "key":{"shape":"String"}, + "value":{"shape":"String"} + }, + "String":{ + "type":"string" + } + } +} diff --git a/tests/EndpointV2/test-cases/string-array.json b/tests/EndpointV2/test-cases/string-array.json new file mode 100644 index 0000000000..31272fb114 --- /dev/null +++ b/tests/EndpointV2/test-cases/string-array.json @@ -0,0 +1,82 @@ +{ + "version": "1.0", + "testCases": [ + { + "documentation": "Default array values used", + "params": {}, + "expect": { + "endpoint": { + "url": "https://example.com/defaultValue1" + } + }, + "operationInputs": [ + { + "operationName": "NoBindingsOperation" + } + ] + }, + { + "documentation": "Empty array", + "params": { + "stringArrayParam": [] + }, + "expect": { + "error": "no array values set" + }, + "operationInputs": [ + { + "operationName": "EmptyStaticContextOperation" + } + ] + }, + { + "documentation": "Static value", + "params": { + "stringArrayParam": ["staticValue1"] + }, + "expect": { + "endpoint": { + "url": "https://example.com/staticValue1" + } + }, + "operationInputs": [ + { + "operationName": "StaticContextOperation" + } + ] + }, + { + "documentation": "bound value from input", + "params": { + "stringArrayParam": ["key1"] + }, + "expect": { + "endpoint": { + "url": "https://example.com/key1" + } + }, + "operationInputs": [ + { + "operationName": "ListOfObjectsOperation", + "operationParams": { + "nested": { + "listOfObjects": [ + { + "key": "key1" + } + ] + } + } + }, + { + "operationName": "MapOperation", + "operationParams": { + "map": { + "key1": "value1" + } + } + } + ] + } + ] +} diff --git a/tests/EndpointV2/valid-rules/string-array.json b/tests/EndpointV2/valid-rules/string-array.json new file mode 100644 index 0000000000..913b557be7 --- /dev/null +++ b/tests/EndpointV2/valid-rules/string-array.json @@ -0,0 +1,38 @@ +{ + "version": "1.0", + "parameters": { + "stringArrayParam": { + "type": "stringArray", + "required": true, + "default": ["defaultValue1", "defaultValue2"], + "documentation": "docs" + } + }, + "rules": [ + { + "documentation": "Template first array value into URI if set", + "conditions": [ + { + "fn": "getAttr", + "argv": [ + { + "ref": "stringArrayParam" + }, + "[0]" + ], + "assign": "arrayValue" + } + ], + "endpoint": { + "url": "https://example.com/{arrayValue}" + }, + "type": "endpoint" + }, + { + "conditions": [], + "documentation": "error fallthrough", + "error": "no array values set", + "type": "error" + } + ] +} diff --git a/tests/FunctionsTest.php b/tests/FunctionsTest.php index f430f10272..a4d838096a 100644 --- a/tests/FunctionsTest.php +++ b/tests/FunctionsTest.php @@ -516,4 +516,30 @@ public function getIniFileServiceTestCases() ] ]; } + + /** + * @param $array + * @param $expected + * + * @dataProvider isAssociativeProvider + */ + public function testIsAssociative($array, $expected) + { + $result = Aws\is_associative($array); + $this->assertEquals($expected, $result); + } + + public function isAssociativeProvider() + { + return [ + [[], false], + [['foo' => 'bar'], true], + [[1, 2, 3, 5], false], + [['foo', 'bar', 'baz'], false], + [['1' => 1, '2' => 2, '3'], true], + [['0' => 0, '1' => 2], false], + [[0 => 1, 1 => 2], false], + [[1 => 0, 2 => 2], true], + ]; + } }