diff --git a/src/Api/DateTimeResult.php b/src/Api/DateTimeResult.php index b296abc491..3744261ff5 100644 --- a/src/Api/DateTimeResult.php +++ b/src/Api/DateTimeResult.php @@ -30,11 +30,6 @@ public static function fromEpoch($unixTimestamp) throw new ParserException('Invalid timestamp value passed to DateTimeResult::fromEpoch'); } - // PHP 5.5 does not support sub-second precision - if (\PHP_VERSION_ID < 56000) { - return new self(gmdate('c', $unixTimestamp)); - } - $decimalSeparator = isset(localeconv()['decimal_point']) ? localeconv()['decimal_point'] : "."; $formatString = "U" . $decimalSeparator . "u"; $dateTime = DateTime::createFromFormat( diff --git a/src/Api/ErrorParser/AbstractRpcV2ErrorParser.php b/src/Api/ErrorParser/AbstractRpcV2ErrorParser.php new file mode 100644 index 0000000000..e899e34e88 --- /dev/null +++ b/src/Api/ErrorParser/AbstractRpcV2ErrorParser.php @@ -0,0 +1,161 @@ +parseError($response); + if ($data['parsed']) { + $data['parsed'] = array_change_key_case($data['parsed']); + } + + if (isset($data['parsed']['__type'])) { + $data['code'] ??= $this->extractErrorCode($data['parsed']['__type']); + $data['message'] = $data['parsed']['message'] ?? null; + } + + $this->populateShape($data, $response, $command); + + return $data; + } + + /** + * @param ResponseInterface $response + * @param StructureShape $member + * + * @return array + */ + abstract protected function payload( + ResponseInterface $response, + StructureShape $member + ): array; + + /** + * @param StreamInterface $body + * @param ResponseInterface $response + * + * @return mixed + */ + abstract protected function parseBody( + StreamInterface $body, + ResponseInterface $response + ): mixed; + + /** + * @param ResponseInterface $response + * + * @return array + */ + private function parseError(ResponseInterface $response): array + { + $statusCode = (string) $response->getStatusCode(); + $errorCode = null; + $errorType = null; + + if ($this->api?->getMetadata('awsQueryCompatible') !== null + && $response->hasHeader(self::HEADER_QUERY_ERROR) + && $awsQueryError = $this->parseQueryCompatibleHeader($response) + ) { + $errorCode = $awsQueryError['code']; + $errorType = $awsQueryError['type']; + } + + if (!$errorCode && $response->hasHeader(self::HEADER_ERROR_TYPE)) { + $errorCode = $this->extractErrorCode( + $response->getHeaderLine(self::HEADER_ERROR_TYPE) + ); + } + + $parsedBody = null; + $body = $response->getBody(); + if (!$body->isSeekable() || $body->getSize()) { + $parsedBody = $this->parseBody($body, $response); + } + + if (!$errorCode && $parsedBody) { + $errorCode = $this->extractErrorCode( + $parsedBody['code'] ?? $parsedBody['__type'] ?? '' + ); + } + + return [ + 'request_id' => $response->getHeaderLine(self::HEADER_REQUEST_ID), + 'code' => $errorCode ?: null, + 'message' => null, + 'type' => $errorType ?? ($statusCode[0] === '4' ? 'client' : 'server'), + 'parsed' => $parsedBody, + ]; + } + + /** + * Parse AWS Query Compatible error from header + * + * @param ResponseInterface $response + * + * @return array|null Returns ['code' => string, 'type' => string] or null + */ + private function parseQueryCompatibleHeader(ResponseInterface $response): ?array + { + $parts = explode(';', $response->getHeaderLine(self::HEADER_QUERY_ERROR)); + if (count($parts) === 2 && $parts[0] && $parts[1]) { + return [ + 'code' => $parts[0], + 'type' => $parts[1], + ]; + } + + return null; + } + + /** + * Extract error code from raw error string containing # and/or : delimiters + * + * @param string $rawErrorCode + * @return string + */ + private function extractErrorCode(string $rawErrorCode): string + { + // Handle format with both # and uri (e.g., "namespace#ErrorCode:http://foo-bar") + if (str_contains($rawErrorCode, ':') && str_contains($rawErrorCode, '#')) { + $start = strpos($rawErrorCode, '#') + 1; + $end = strpos($rawErrorCode, ':', $start); + return substr($rawErrorCode, $start, $end - $start); + } + + // Handle format with uri only : (e.g., "ErrorCode:http://foo-bar.com/baz") + if (str_contains($rawErrorCode, ':')) { + return substr($rawErrorCode, 0, strpos($rawErrorCode, ':')); + } + + // Handle format with only # (e.g., "namespace#ErrorCode") + if (str_contains($rawErrorCode, '#')) { + return substr($rawErrorCode, strpos($rawErrorCode, '#') + 1); + } + + return $rawErrorCode; + } +} diff --git a/src/Api/ErrorParser/RpcV2CborErrorParser.php b/src/Api/ErrorParser/RpcV2CborErrorParser.php new file mode 100644 index 0000000000..9f34b464ff --- /dev/null +++ b/src/Api/ErrorParser/RpcV2CborErrorParser.php @@ -0,0 +1,68 @@ +decoder = $decoder ?: new CborDecoder(); + parent::__construct($api); + } + + /** + * @param ResponseInterface $response + * @param StructureShape $member + * + * @return array + * @throws \Exception + */ + protected function payload( + ResponseInterface $response, + StructureShape $member + ): array + { + $body = $response->getBody(); + $cborBody = $this->parseCbor($body, $response); + + return $this->resolveOutputShape($member, $cborBody); + } + + /** + * @param StreamInterface $body + * @param ResponseInterface $response + * + * @return mixed + */ + protected function parseBody( + StreamInterface $body, + ResponseInterface $response + ): mixed + { + return $this->parseCbor($body, $response); + } +} diff --git a/src/Api/Parser/AbstractRpcV2Parser.php b/src/Api/Parser/AbstractRpcV2Parser.php new file mode 100644 index 0000000000..cef92fb03f --- /dev/null +++ b/src/Api/Parser/AbstractRpcV2Parser.php @@ -0,0 +1,83 @@ + static::$smithyProtocol + * + * @internal + */ +abstract class AbstractRpcV2Parser extends AbstractParser +{ + private const HEADER_SMITHY_PROTOCOL = 'Smithy-Protocol'; + + /** @var string */ + protected static string $smithyProtocol; + + public function __invoke( + CommandInterface $command, + ResponseInterface $response + ) { + $operation = $this->api->getOperation($command->getName()); + + return $this->parseResponse($response, $operation); + } + + /** + * Parses a response according to Smithy RPC V2 protocol standards. + * + * @param ResponseInterface $response the response to parse. + * @param Operation $operation the operation which holds information for + * parsing the response. + * + * @return Result + */ + private function parseResponse( + ResponseInterface $response, + Operation $operation + ): Result + { + $smithyProtocolHeader = $response->getHeaderLine(self::HEADER_SMITHY_PROTOCOL); + if ($smithyProtocolHeader !== static::$smithyProtocol) { + $statusCode = $response->getStatusCode(); + throw new ParserException( + "Malformed response: Smithy-Protocol header mismatch (HTTP {$statusCode}). " + . 'Expected ' . static::$smithyProtocol + ); + } + + if ($operation['output'] === null) { + return new Result([]); + } + + $outputShape = $operation->getOutput(); + foreach ($outputShape->getMembers() as $memberName => $memberProps) { + if (!empty($memberProps['eventstream'])) { + return new Result([ + $memberName => new EventParsingIterator( + $response->getBody(), + $outputShape->getMember($memberName), + $this + ) + ]); + } + } + + $result = $this->parseMemberFromStream( + $response->getBody(), + $outputShape, + $response + ); + + return new Result(is_null($result) ? [] : $result); + } +} diff --git a/src/Api/Parser/PayloadParserTrait.php b/src/Api/Parser/PayloadParserTrait.php index 43d3d56764..cc4872ebe9 100644 --- a/src/Api/Parser/PayloadParserTrait.php +++ b/src/Api/Parser/PayloadParserTrait.php @@ -2,7 +2,6 @@ namespace Aws\Api\Parser; use Aws\Api\Parser\Exception\ParserException; -use Psr\Http\Message\ResponseInterface; trait PayloadParserTrait { diff --git a/src/Api/Parser/RpcV2CborParser.php b/src/Api/Parser/RpcV2CborParser.php new file mode 100644 index 0000000000..efb2fd4ae1 --- /dev/null +++ b/src/Api/Parser/RpcV2CborParser.php @@ -0,0 +1,54 @@ +decoder = $decoder ?? new CborDecoder(); + parent::__construct($api); + } + + /** + * @param StreamInterface $stream + * @param StructureShape $member + * @param $response + * + * @return mixed + */ + public function parseMemberFromStream( + StreamInterface $stream, + StructureShape $member, + $response + ): mixed + { + return $this->resolveOutputShape($member, $this->parseCbor($stream, $response)); + } +} diff --git a/src/Api/Parser/RpcV2ParserTrait.php b/src/Api/Parser/RpcV2ParserTrait.php new file mode 100644 index 0000000000..95c2b4cb8d --- /dev/null +++ b/src/Api/Parser/RpcV2ParserTrait.php @@ -0,0 +1,105 @@ +getMembers() as $name => $member) { + $locationName = $member['locationName'] ?: $name; + if (isset($value[$locationName])) { + $target[$name] = $this->resolveOutputShape($member, $value[$locationName]); + } + } + return $target; + + case 'list': + $target = []; + foreach ($value as $v) { + $target[] = $this->resolveOutputShape($shape->getMember(), $v); + } + return $target; + + case 'map': + $target = []; + foreach ($value as $k => $v) { + if ($v !== null) { + $target[$k] = $this->resolveOutputShape($shape->getValue(), $v); + } + } + return $target; + + case 'timestamp': + try { + $value = DateTimeResult::fromEpoch($value); + } catch (\Exception $e) { + trigger_error( + 'Unable to parse timestamp value for ' + . $shape->getName() + . ': ' . $e->getMessage(), + E_USER_WARNING + ); + } + + return $value; + + default: + return $value; + } + } + + /** + * Parses CBOR-encoded response data from RPC V2 CBOR services. + * + * @param StreamInterface $stream + * @param ResponseInterface $response + * + * @return mixed + */ + protected function parseCbor( + StreamInterface $stream, + ResponseInterface $response + ): mixed + { + try { + $cborString = (string) $stream; + return empty($cborString) + ? null + : $this->decoder->decode($cborString); + } catch (CborException $e) { + throw new ParserException( + "Malformed Response: error parsing CBOR: {$e->getMessage()}", + 0, + $e, + ['response' => $response] + ); + } + } +} diff --git a/src/Api/Serializer/AbstractRpcV2Serializer.php b/src/Api/Serializer/AbstractRpcV2Serializer.php new file mode 100644 index 0000000000..61bba8b513 --- /dev/null +++ b/src/Api/Serializer/AbstractRpcV2Serializer.php @@ -0,0 +1,226 @@ + static::SMITHY_PROTOCOL, + * self::HEADER_CONTENT_TYPE => static::DEFAULT_CONTENT_TYPE, + * self::HEADER_ACCEPT => static::DEFAULT_ACCEPT + * + * Implementers must also implement `serialize()` and `resolveBlob()` according to + * their respective protocol specifications. + * + * @internal + */ +abstract class AbstractRpcV2Serializer +{ + protected const HEADER_SMITHY_PROTOCOL = 'Smithy-Protocol'; + protected const HEADER_CONTENT_TYPE = 'Content-Type'; + protected const HEADER_ACCEPT = 'Accept'; + + /** @var array */ + protected static array $defaultHeaders; + + /** @var Service */ + private Service $api; + + /** @var string|Uri */ + private string|Uri $endpoint; + + /** @var bool */ + private bool $isUseEndpointV2; + + use EndpointV2SerializerTrait; + + /** + * @param Service $api Service API description + * @param string $endpoint Endpoint to connect to + */ + public function __construct(Service $api, string|Uri $endpoint) + { + $this->api = $api; + $this->endpoint = Psr7\Utils::uriFor($endpoint); + } + + /** + * @param CommandInterface $command Command to serialize into a request. + * @param mixed|null $endpoint + * + * @return RequestInterface + */ + public function __invoke( + CommandInterface $command, + mixed $endpoint = null + ) + { + $commandArgs = $command->toArray(); + $commandName = $command->getName(); + $operation = $this->api->getOperation($commandName); + $headers = static::$defaultHeaders; + + // Operations with no defined input type must not contain bodies + // Content-Type must not be set + if ($operation['input'] !== null) { + $body = $this->serialize($operation->getInput(), $commandArgs); + $headers['Content-Length'] = strlen($body); + } else { + unset($headers['Content-Type']); + } + + if ($endpoint instanceof RulesetEndpoint) { + $this->isUseEndpointV2 = true; + $this->setEndpointV2RequestOptions($endpoint, $headers); + $this->endpoint = $endpoint->getUrl(); + } + + $requestTarget = $this->buildRequestTarget( + $commandName, + $operation['http']['requestUri'] ?? '' + ); + $uri = new Uri($this->endpoint . $requestTarget); + + return new Request( + $operation['http']['method'], + $uri, + $headers, + $body ?? null + ); + } + + /** + * @param StructureShape $inputShape + * @param array $commandArgs + * + * @return string + */ + abstract public function serialize( + StructureShape $inputShape, + array $commandArgs + ): string; + + /** + * Resolves arguments for blob shapes present in the request arguments + * into a protocol-specific format. + * + * @param mixed $value + * + * @return array + */ + abstract protected function resolveBlob(mixed $value): array; + + /** + * Resolves input shape fields that are present in the request arguments + * + * @param Shape $shape + * @param mixed $value + * + * @return mixed + */ + protected function resolveInputShape(Shape $shape, mixed $value): mixed + { + switch ($shape->getType()) { + case 'structure': + $data = []; + foreach ($value as $k => $v) { + if ($v !== null && $shape->hasMember($k)) { + $valueShape = $shape->getMember($k); + $data[$valueShape['locationName'] ?: $k] + = $this->resolveInputShape($valueShape, $v); + } + } + + return $data; + + case 'list': + $items = $shape->getMember(); + foreach ($value as $k => $v) { + $value[$k] = $this->resolveInputShape($items, $v); + } + + return $value; + + case 'map': + $values = $shape->getValue(); + foreach ($value as $k => $v) { + $value[$k] = $this->resolveInputShape($values, $v); + } + + return $value; + + case 'timestamp': + if (!($value instanceof DateTimeInterface)) { + if (is_numeric($value)) { + $value = '@' . $value; + } + + try { + $value = new DateTime($value); + } catch (\Exception $e) { + throw new AwsException( + 'Request serialization failed: Invalid date/time: ' . $e->getMessage(), + $e->getCode(), + $e + ); + } + } + + return $value; + + case 'string': + return (string) $value; + + case 'integer': + case 'long': + return (int) $value; + + case 'double': + case 'float': + return (float) $value; + + case 'blob': + return $this->resolveBlob($value); + + default: + return $value; + } + } + + /** + * Builds request URI absolute path + * + * @param string $commandName + * @param string $requestUri + * + * @return string + */ + private function buildRequestTarget( + string $commandName, + string $requestUri + ): string + { + $requestUri = str_ends_with($requestUri, '/') + ? $requestUri + : $requestUri . '/'; + $targetPrefix = $this->api->getMetadata('targetPrefix'); + + return "{$requestUri}service/{$targetPrefix}/operation/{$commandName}"; + } +} diff --git a/src/Api/Serializer/RpcV2CborSerializer.php b/src/Api/Serializer/RpcV2CborSerializer.php new file mode 100644 index 0000000000..26b159c811 --- /dev/null +++ b/src/Api/Serializer/RpcV2CborSerializer.php @@ -0,0 +1,92 @@ + 'rpc-v2-cbor', + self::HEADER_CONTENT_TYPE => 'application/cbor', + self::HEADER_ACCEPT => 'application/cbor', + ]; + + /** @var CborEncoder */ + private CborEncoder $encoder; + + /** + * @param Service $api Service API description + * @param string $endpoint Endpoint to connect to + * @param CborEncoder|null $encoder Used to CBOR-encode PHP values + */ + public function __construct( + Service $api, + string $endpoint, + ?CborEncoder $encoder = null + ) { + $this->encoder = $encoder ?? new CborEncoder(); + parent::__construct($api, $endpoint); + } + + /** + * @param StructureShape $inputShape + * @param array $commandArgs + * + * @return string + * @throws InvalidCborException + */ + public function serialize( + StructureShape $inputShape, + array $commandArgs + ): string + { + try { + $resolvedInput = $this->resolveInputShape($inputShape, $commandArgs); + return !empty($resolvedInput) + ? $this->encoder->encode($resolvedInput) + : $this->encoder->encodeEmptyIndefiniteMap(); + } catch (CborException $e) { + throw new InvalidCborException( + 'Unable to encode CBOR document ' . $inputShape->getName() . ': ' . + $e->getMessage() . PHP_EOL + ); + } + } + + /** + * Wraps blob values in order to be encoded properly into + * byte strings. + * + * @param mixed $value + * + * @return string[] + */ + protected function resolveBlob(mixed $value): array + { + if (!is_string($value)) { + if (is_resource($value)) { + $value = stream_get_contents($value); + } elseif ($value instanceof StreamInterface) { + $value = $value->getContents(); + } else { + $value = (string) $value; + } + } + + // Wrapper to differentiate byte string values during encoding + return ['__cbor_bytes' => $value]; + } +} diff --git a/src/Api/Service.php b/src/Api/Service.php index 38bd4513c5..2d080755cd 100644 --- a/src/Api/Service.php +++ b/src/Api/Service.php @@ -91,7 +91,8 @@ public static function createSerializer(Service $api, $endpoint) 'json' => Serializer\JsonRpcSerializer::class, 'query' => Serializer\QuerySerializer::class, 'rest-json' => Serializer\RestJsonSerializer::class, - 'rest-xml' => Serializer\RestXmlSerializer::class + 'rest-xml' => Serializer\RestXmlSerializer::class, + 'smithy-rpc-v2-cbor' => Serializer\RpcV2CborSerializer::class ]; $proto = $api->getProtocol(); @@ -126,7 +127,8 @@ public static function createErrorParser($protocol, ?Service $api = null) 'query' => ErrorParser\XmlErrorParser::class, 'rest-json' => ErrorParser\RestJsonErrorParser::class, 'rest-xml' => ErrorParser\XmlErrorParser::class, - 'ec2' => ErrorParser\XmlErrorParser::class + 'ec2' => ErrorParser\XmlErrorParser::class, + 'smithy-rpc-v2-cbor' => ErrorParser\RpcV2CborErrorParser::class ]; if (isset($mapping[$protocol])) { @@ -149,7 +151,8 @@ public static function createParser(Service $api) 'json' => Parser\JsonRpcParser::class, 'query' => Parser\QueryParser::class, 'rest-json' => Parser\RestJsonParser::class, - 'rest-xml' => Parser\RestXmlParser::class + 'rest-xml' => Parser\RestXmlParser::class, + 'smithy-rpc-v2-cbor' => Parser\RpcV2CborParser::class ]; $proto = $api->getProtocol(); diff --git a/src/Api/SupportedProtocols.php b/src/Api/SupportedProtocols.php index 8a06bd0e59..4cc0a3786f 100644 --- a/src/Api/SupportedProtocols.php +++ b/src/Api/SupportedProtocols.php @@ -12,6 +12,7 @@ enum SupportedProtocols: string case REST_XML = 'rest-xml'; case QUERY = 'query'; case EC2 = 'ec2'; + case CBOR = 'smithy-rpc-v2-cbor'; /** * Check if a protocol is valid. diff --git a/src/Cbor/CborDecoder.php b/src/Cbor/CborDecoder.php new file mode 100644 index 0000000000..0bcdc23616 --- /dev/null +++ b/src/Cbor/CborDecoder.php @@ -0,0 +1,664 @@ +offset = 0; + $this->length = strlen($data); + + return $this->decodeValue($data); + } + + /** + * Decode multiple CBOR values from sequential binary data + * + * @param string $data The CBOR-encoded binary data containing multiple values + * + * @return array Array of decoded PHP values in the order they appear in the data + * @throws CborException If data is malformed CBOR + */ + public function decodeAll(string $data): array + { + $this->length = strlen($data); + $this->offset = 0; + $values = []; + + while ($this->offset < $this->length) { + $values[] = $this->decodeValue($data); + } + + return $values; + } + + /** + * Decodes a single CBOR value at the current offset + * + * @param string $data Reference to the CBOR data being decoded + * + * @return mixed The decoded value + * @throws CborException If unexpected end of data or invalid CBOR format + */ + private function decodeValue(string &$data): mixed + { + $offset = $this->offset; + $length = $this->length; + + if ($offset >= $length) { + throw new CborException("Unexpected end of data"); + } + + $byte = ord($data[$offset++]); + $majorType = $byte >> 5; + $info = $byte & 0x1F; + + switch ($majorType) { + case 0: // Unsigned integer + if ($info < 24) { + $this->offset = $offset; + + return $info; + } + + switch ($info) { + case 24: + if ($offset >= $length) { + throw new CborException("Not enough data"); + } + + $this->offset = $offset + 1; + + return ord($data[$offset]); + + case 25: + if ($offset + 2 > $length) { + throw new CborException("Not enough data"); + } + + $this->offset = $offset + 2; + + return (ord($data[$offset]) << 8) | ord($data[$offset + 1]); + + case 26: + if ($offset + 4 > $length) { + throw new CborException("Not enough data"); + } + + $this->offset = $offset + 4; + + return unpack('N', $data, $offset)[1]; + + case 27: + if ($offset + 8 > $length) { + throw new CborException("Not enough data"); + } + + $this->offset = $offset + 8; + + return unpack('J', $data, $offset)[1]; + + default: + throw new CborException("Invalid additional info for integer: $info"); + } + + case 1: // Negative integer + if ($info < 24) { + $this->offset = $offset; + + return -1 - $info; + } + + switch ($info) { + case 24: + if ($offset >= $length) { + throw new CborException("Not enough data"); + } + + $this->offset = $offset + 1; + + return -1 - ord($data[$offset]); + + case 25: + if ($offset + 2 > $length) { + throw new CborException("Not enough data"); + } + + $this->offset = $offset + 2; + + return -1 - ((ord($data[$offset]) << 8) | ord($data[$offset + 1])); + + case 26: + if ($offset + 4 > $length) { + throw new CborException("Not enough data"); + } + + $this->offset = $offset + 4; + + return -1 - unpack('N', $data, $offset)[1]; + + case 27: + if ($offset + 8 > $length) { + throw new CborException("Not enough data"); + } + + $this->offset = $offset + 8; + $unsigned = unpack('J', $data, $offset)[1]; + + return ($unsigned === 9223372036854775807) ? PHP_INT_MIN : -1 - $unsigned; + + default: + throw new CborException("Invalid additional info for integer: $info"); + } + + case 2: // Byte string + if ($info < 24) { + $len = $info; + } else { + switch ($info) { + case 24: + if ($offset >= $length) { + throw new CborException("Not enough data"); + } + + $len = ord($data[$offset++]); + break; + + case 25: + if ($offset + 2 > $length) { + throw new CborException("Not enough data"); + } + + $len = (ord($data[$offset]) << 8) | ord($data[$offset + 1]); + $offset += 2; + break; + + case 26: + if ($offset + 4 > $length) { + throw new CborException("Not enough data"); + } + + $len = unpack('N', $data, $offset)[1]; + $offset += 4; + break; + + case 27: + if ($offset + 8 > $length) { + throw new CborException("Not enough data"); + } + + $len = unpack('J', $data, $offset)[1]; + $offset += 8; + break; + + case 31: + $this->offset = $offset; + + return $this->decodeIndefiniteString($data, 0x40); + + default: + throw new CborException("Invalid additional info for byte string: $info"); + } + } + + if ($offset + $len > $length) { + throw new CborException("Not enough data"); + } + + $this->offset = $offset + $len; + + return substr($data, $offset, $len); + + case 3: // Text string + if ($info < 24) { + $len = $info; + } else { + switch ($info) { + case 24: + if ($offset >= $length) { + throw new CborException("Not enough data"); + } + + $len = ord($data[$offset++]); + break; + + case 25: + if ($offset + 2 > $length) { + throw new CborException("Not enough data"); + } + + $len = (ord($data[$offset]) << 8) | ord($data[$offset + 1]); + $offset += 2; + break; + + case 26: + if ($offset + 4 > $length) { + throw new CborException("Not enough data"); + } + + $len = unpack('N', $data, $offset)[1]; + $offset += 4; + break; + + case 27: + if ($offset + 8 > $length) { + throw new CborException("Not enough data"); + } + + $len = unpack('J', $data, $offset)[1]; + $offset += 8; + break; + + case 31: + $this->offset = $offset; + + return $this->decodeIndefiniteString($data, 0x60); + + default: + throw new CborException("Invalid additional info for text string: $info"); + } + } + + if ($offset + $len > $length) { + throw new CborException("Not enough data"); + } + + $this->offset = $offset + $len; + + return substr($data, $offset, $len); + + case 4: // Array + if ($info < 24) { + $count = $info; + } else { + switch ($info) { + case 24: + if ($offset >= $length) { + throw new CborException("Not enough data"); + } + + $count = ord($data[$offset++]); + break; + + case 25: + if ($offset + 2 > $length) { + throw new CborException("Not enough data"); + } + + $count = (ord($data[$offset]) << 8) | ord($data[$offset + 1]); + $offset += 2; + break; + + case 26: + if ($offset + 4 > $length) { + throw new CborException("Not enough data"); + } + + $count = unpack('N', $data, $offset)[1]; + $offset += 4; + break; + + case 27: + if ($offset + 8 > $length) { + throw new CborException("Not enough data"); + } + + $count = unpack('J', $data, $offset)[1]; + $offset += 8; + break; + + case 31: + $this->offset = $offset; + + return $this->decodeIndefiniteArray($data); + + default: + throw new CborException("Invalid additional info for array: $info"); + } + } + + $this->offset = $offset; + $arr = []; + + for ($i = 0; $i < $count; $i++) { + $arr[] = $this->decodeValue($data); + } + + return $arr; + + case 5: // Map + if ($info < 24) { + $count = $info; + } else { + switch ($info) { + case 24: + if ($offset >= $length) { + throw new CborException("Not enough data"); + } + + $count = ord($data[$offset++]); + break; + + case 25: + if ($offset + 2 > $length) { + throw new CborException("Not enough data"); + } + + $count = (ord($data[$offset]) << 8) | ord($data[$offset + 1]); + $offset += 2; + break; + + case 26: + if ($offset + 4 > $length) { + throw new CborException("Not enough data"); + } + + $count = unpack('N', $data, $offset)[1]; + $offset += 4; + break; + + case 27: + if ($offset + 8 > $length) { + throw new CborException("Not enough data"); + } + + $count = unpack('J', $data, $offset)[1]; + $offset += 8; + break; + + case 31: + $this->offset = $offset; + + return $this->decodeIndefiniteMap($data); + + default: + throw new CborException("Invalid additional info for map: $info"); + } + } + + $this->offset = $offset; + $map = []; + + for ($i = 0; $i < $count; $i++) { + $key = $this->decodeValue($data); + $map[$key] = $this->decodeValue($data); + } + + return $map; + + case 6: // Tag + switch ($info) { + case 24: + $offset++; + break; + + case 25: + $offset += 2; + break; + + case 26: + $offset += 4; + break; + + case 27: + $offset += 8; + break; + } + + $this->offset = $offset; + + return $this->decodeValue($data); + + case 7: // Simple/float + switch ($info) { + case 20: + $this->offset = $offset; + + return false; + + case 21: + $this->offset = $offset; + + return true; + + case 22: + case 23: + $this->offset = $offset; + + return null; + + case 25: // Half-precision float + if ($offset + 2 > $length) { + throw new CborException("Not enough data"); + } + + $this->offset = $offset + 2; + $half = (ord($data[$offset]) << 8) | ord($data[$offset + 1]); + $sign = ($half >> 15) & 0x01; + $exp = ($half >> 10) & 0x1F; + $mant = $half & 0x3FF; + + if ($exp === 0) { + return $mant === 0 + ? ($sign ? -0.0 : 0.0) + : ($sign ? -1 : 1) * pow(2, -14) * ($mant / 1024); + } + + if ($exp === 31) { + return $mant === 0 ? ($sign ? -INF : INF) : NAN; + } + + return (float) (($sign ? -1 : 1) * pow(2, $exp - 15) * (1 + $mant / 1024)); + + case 26: // Single-precision float + if ($offset + 4 > $length) { + throw new CborException("Not enough data"); + } + + $this->offset = $offset + 4; + + return unpack('G', $data, $offset)[1]; + + case 27: // Double-precision float + if ($offset + 8 > $length) { + throw new CborException("Not enough data"); + } + + $this->offset = $offset + 8; + + return unpack('E', $data, $offset)[1]; + + case 31: + throw new CborException("Unexpected break"); + + default: + throw new CborException("Unknown simple value: $info"); + } + + default: + throw new CborException("Unknown major type: $majorType"); + } + } + + /** + * Decode indefinite-length string (byte or text) + * + * @param string $data Reference to the CBOR data being decoded + * @param int $expectedMajor Expected major type (0x40 for byte string, 0x60 for text string) + * + * @return string The concatenated string from all chunks + * @throws CborException If invalid chunk format or unexpected end of data + */ + private function decodeIndefiniteString(string &$data, int $expectedMajor): string + { + $chunks = []; + + while (true) { + $offset = $this->offset; + $length = $this->length; + + if ($offset >= $length) { + throw new CborException("Unexpected end of data"); + } + + $byte = ord($data[$offset++]); + + if ($byte === 0xFF) { + $this->offset = $offset; + + return implode('', $chunks); + } + + if (($byte & 0xE0) !== $expectedMajor) { + throw new CborException("Invalid chunk in indefinite string"); + } + + $info = $byte & 0x1F; + + if ($info === 31) { + throw new CborException("Nested indefinite string"); + } + + if ($info < 24) { + $len = $info; + } else { + switch ($info) { + case 24: + if ($offset >= $length) { + throw new CborException("Not enough data"); + } + + $len = ord($data[$offset++]); + break; + + case 25: + if ($offset + 2 > $length) { + throw new CborException("Not enough data"); + } + + $len = (ord($data[$offset]) << 8) | ord($data[$offset + 1]); + $offset += 2; + break; + + case 26: + if ($offset + 4 > $length) { + throw new CborException("Not enough data"); + } + + $len = unpack('N', $data, $offset)[1]; + $offset += 4; + break; + + case 27: + if ($offset + 8 > $length) { + throw new CborException("Not enough data"); + } + + $len = unpack('J', $data, $offset)[1]; + $offset += 8; + break; + + default: + throw new CborException("Invalid chunk length info: $info"); + } + } + + if ($offset + $len > $length) { + throw new CborException("Not enough data for chunk"); + } + + $chunks[] = substr($data, $offset, $len); + $this->offset = $offset + $len; + } + } + + /** + * Decode indefinite-length array + * + * @param string $data Reference to the CBOR data being decoded + * + * @return array The decoded array elements + * @throws CborException If unexpected end of data + */ + private function decodeIndefiniteArray(string &$data): array + { + $result = []; + + while (true) { + if ($this->offset >= $this->length) { + throw new CborException("Unexpected end of data"); + } + + if (ord($data[$this->offset]) === 0xFF) { + $this->offset++; + + return $result; + } + + $result[] = $this->decodeValue($data); + } + } + + /** + * Decode indefinite-length map + * + * @param string $data Reference to the CBOR data being decoded + * + * @return array The decoded map as associative array + * @throws CborException If unexpected end of data or odd number of items + */ + private function decodeIndefiniteMap(string &$data): array + { + $result = []; + + while (true) { + if ($this->offset >= $this->length) { + throw new CborException("Unexpected end of data"); + } + + if (ord($data[$this->offset]) === 0xFF) { + $this->offset++; + + return $result; + } + + $key = $this->decodeValue($data); + $result[$key] = $this->decodeValue($data); + } + } +} diff --git a/src/Cbor/CborEncoder.php b/src/Cbor/CborEncoder.php new file mode 100644 index 0000000000..3addad740a --- /dev/null +++ b/src/Cbor/CborEncoder.php @@ -0,0 +1,357 @@ + $data] wrappers) + * - Type 3: Text strings (UTF-8) + * - Type 4: Arrays + * - Type 5: Maps + * - Type 6: Tagged values (timestamps) + * - Type 7: Simple values (null, bool, float) + * + * @internal + */ +final class CborEncoder +{ + /** + * Pre-encoded integers 0-23 (single byte) and common larger values + * CBOR major type 0 (unsigned integer) + */ + private const INT_CACHE = [ + 0 => "\x00", 1 => "\x01", 2 => "\x02", 3 => "\x03", + 4 => "\x04", 5 => "\x05", 6 => "\x06", 7 => "\x07", + 8 => "\x08", 9 => "\x09", 10 => "\x0A", 11 => "\x0B", + 12 => "\x0C", 13 => "\x0D", 14 => "\x0E", 15 => "\x0F", + 16 => "\x10", 17 => "\x11", 18 => "\x12", 19 => "\x13", + 20 => "\x14", 21 => "\x15", 22 => "\x16", 23 => "\x17", + 24 => "\x18\x18", 25 => "\x18\x19", 26 => "\x18\x1A", + 32 => "\x18\x20", 50 => "\x18\x32", 64 => "\x18\x40", + 100 => "\x18\x64", 128 => "\x18\x80", 200 => "\x18\xC8", + 255 => "\x18\xFF", 256 => "\x19\x01\x00", 500 => "\x19\x01\xF4", + 1000 => "\x19\x03\xE8", 1023 => "\x19\x03\xFF", + ]; + + /** + * Pre-encoded negative integers -1 to -24 and common larger values + * CBOR major type 1 (negative integer) + */ + private const NEG_CACHE = [ + -1 => "\x20", -2 => "\x21", -3 => "\x22", -4 => "\x23", + -5 => "\x24", -10 => "\x29", -20 => "\x33", -24 => "\x37", + -25 => "\x38\x18", -50 => "\x38\x31", -100 => "\x38\x63", + ]; + + /** + * Encode a PHP value to CBOR binary string + * + * @param mixed $value The value to encode + * + * @return string + */ + public function encode(mixed $value): string + { + return $this->encodeValue($value); + } + + /** + * Recursively encode a value to CBOR + * + * @param mixed $value Value to encode + * @return string Encoded CBOR bytes + */ + private function encodeValue(mixed $value): string + { + switch (gettype($value)) { + case 'string': + $len = strlen($value); + if ($len < 24) { + return chr(0x60 | $len) . $value; + } + + if ($len < 0x100) { + return "\x78" . chr($len) . $value; + } + + return $this->encodeTextString($value); + + case 'array': + // Encode a byte string (major type 2) + if (isset($value['__cbor_bytes'])) { + $bytes = $value['__cbor_bytes']; + $len = strlen($bytes); + if ($len < 24) { + return chr(0x40 | $len) . $bytes; + } + + if ($len < 0x100) { + return "\x58" . chr($len) . $bytes; + } + + if ($len < 0x10000) { + return "\x59" . pack('n', $len) . $bytes; + } + + return "\x5A" . pack('N', $len) . $bytes; + } + + if (array_is_list($value)) { + return $this->encodeArray($value); + } + + return $this->encodeMap($value); + + case 'integer': + if (isset(self::INT_CACHE[$value])) { + return self::INT_CACHE[$value]; + } + + if (isset(self::NEG_CACHE[$value])) { + return self::NEG_CACHE[$value]; + } + + // Fast path for positive integers + // Major type 0: unsigned integer + if ($value >= 0) { + if ($value < 24) { + return chr($value); + } + + if ($value < 0x100) { + return "\x18" . chr($value); + } + + if ($value < 0x10000) { + return "\x19" . pack('n', $value); + } + + if ($value < 0x100000000) { + return "\x1A" . pack('N', $value); + } + + return "\x1B" . pack('J', $value); + } + + return $this->encodeInteger($value); + + case 'double': + // Encode a float (major type 7, float 64) + return "\xFB" . pack('E', $value); + + case 'boolean': + // Encode a boolean (major type 7, simple) + return $value ? "\xF5" : "\xF4"; + + case 'NULL': + // Encode null (major type 7, simple) + return "\xF6"; + + case 'object': + // Encode timestamp (major type 6, tag 1) + if ($value instanceof DateTimeInterface) { + $timestamp = $value->getTimestamp(); + $micro = (int) $value->format('u'); + if ($micro === 0) { + if ($timestamp >= 0 && $timestamp < 0x100000000) { + return "\xC1\x1A" . pack('N', $timestamp); + } + + return "\xC1" . $this->encodeInteger($timestamp); + } + + return "\xC1\xFB" . pack('E', $timestamp + $micro / 1e6); + } + + throw new CborException("Cannot encode object of type: " . get_class($value)); + + default: + throw new CborException("Cannot encode value of type: " . gettype($value)); + } + } + + /** + * Encode an integer (major type 0 or 1) + * + * @param int $value + * @return string + */ + private function encodeInteger(int $value): string + { + if (isset(self::INT_CACHE[$value])) { + return self::INT_CACHE[$value]; + } + + if (isset(self::NEG_CACHE[$value])) { + return self::NEG_CACHE[$value]; + } + + if ($value >= 0) { + // Major type 0: unsigned integer + if ($value < 24) { + return chr($value); + } + + if ($value < 0x100) { + return "\x18" . chr($value); + } + + if ($value < 0x10000) { + return "\x19" . pack('n', $value); + } + + if ($value < 0x100000000) { + return "\x1A" . pack('N', $value); + } + + return "\x1B" . pack('J', $value); + } + + // Major type 1: negative integer (-1 - n) + $value = -1 - $value; + if ($value < 24) { + return chr(0x20 | $value); + } + + if ($value < 0x100) { + return "\x38" . chr($value); + } + + if ($value < 0x10000) { + return "\x39" . pack('n', $value); + } + + if ($value < 0x100000000) { + return "\x3A" . pack('N', $value); + } + + return "\x3B" . pack('J', $value); + } + + /** + * Encode a text string (major type 3) + * + * @param string $value + * @return string + */ + private function encodeTextString(string $value): string + { + $len = strlen($value); + + if ($len < 24) { + return chr(0x60 | $len) . $value; + } + + if ($len < 0x100) { + return "\x78" . chr($len) . $value; + } + + if ($len < 0x10000) { + return "\x79" . pack('n', $len) . $value; + } + + if ($len < 0x100000000) { + return "\x7A" . pack('N', $len) . $value; + } + + return "\x7B" . pack('J', $len) . $value; + } + + /** + * Encode an array (major type 4) + * + * @param array $value + * @return string + */ + private function encodeArray(array $value): string + { + $count = count($value); + + if ($count < 24) { + $result = chr(0x80 | $count); + } elseif ($count < 0x100) { + $result = "\x98" . chr($count); + } elseif ($count < 0x10000) { + $result = "\x99" . pack('n', $count); + } elseif ($count < 0x100000000) { + $result = "\x9A" . pack('N', $count); + } else { + $result = "\x9B" . pack('J', $count); + } + + foreach ($value as $item) { + $result .= $this->encodeValue($item); + } + + return $result; + } + + /** + * Encode a map (major type 5) + * + * @param array $value + * @return string + */ + private function encodeMap(array $value): string + { + $count = count($value); + + if ($count < 24) { + $result = chr(0xA0 | $count); + } elseif ($count < 0x100) { + $result = "\xB8" . chr($count); + } elseif ($count < 0x10000) { + $result = "\xB9" . pack('n', $count); + } elseif ($count < 0x100000000) { + $result = "\xBA" . pack('N', $count); + } else { + $result = "\xBB" . pack('J', $count); + } + + foreach ($value as $k => $v) { + if (is_int($k)) { + $result .= $this->encodeInteger($k); + } else { + $len = strlen($k); + if ($len < 24) { + $result .= chr(0x60 | $len) . $k; + } elseif ($len < 0x100) { + $result .= "\x78" . chr($len) . $k; + } else { + $result .= "\x79" . pack('n', $len) . $k; + } + } + + $result .= $this->encodeValue($v); + } + + return $result; + } + + /** + * Create an empty map (major type 5 with 0 elements) + * + * @return string + */ + public function encodeEmptyMap(): string + { + return "\xA0"; + } + + /** + * Create an empty indefinite map (major type 5 indefinite length) + * + * @return string + */ + public function encodeEmptyIndefiniteMap(): string + { + return "\xBF\xFF"; + } +} diff --git a/src/Cbor/Exception/CborException.php b/src/Cbor/Exception/CborException.php new file mode 100644 index 0000000000..abbcec5dfa --- /dev/null +++ b/src/Cbor/Exception/CborException.php @@ -0,0 +1,6 @@ +encoder = new CborEncoder(); + } + + /** + * @dataProvider errorResponsesProvider + */ + public function testParsesErrorResponses( + $response, + $command, + $parser, + $expected + ): void + { + $parsed = $parser($response, $command); + + // Special handling for error_shape comparison + if (isset($expected['error_shape'])) { + $this->assertEquals( + $expected['error_shape']->toArray(), + $parsed['error_shape']->toArray() + ); + unset($expected['error_shape'], $parsed['error_shape']); + } + + // Compare the rest of the array + $this->assertEquals($expected, $parsed); + } + + public function errorResponsesProvider(): array + { + $service = $this->generateTestService('smithy-rpc-v2-cbor'); + $shapes = $service->getErrorShapes(); + $errorShape = $shapes[0]; + $client = $this->generateTestClient($service); + $command = $client->getCommand('TestOperation', []); + $encoder = new CborEncoder(); + $parser = new RpcV2CborErrorParser(); + $parserWithService = new RpcV2CborErrorParser($service); + + return [ + 'Error code in CBOR body' => [ + new Response( + 400, + ['x-amzn-requestid' => 'xyz'], + $encoder->encode([ + '__type' => 'BadRequestException', + 'message' => 'lorem ipsum', + ]) + ), + null, + $parser, + [ + 'code' => 'BadRequestException', + 'message' => 'lorem ipsum', + 'type' => 'client', + 'request_id' => 'xyz', + 'parsed' => [ + '__type' => 'BadRequestException', + 'message' => 'lorem ipsum', + ], + 'body' => [], + ] + ], + 'Error code with # suffix' => [ + new Response( + 400, + ['x-amzn-requestid' => 'xyz'], + $encoder->encode([ + '__type' => 'BadRequestException#', + 'message' => 'lorem ipsum', + ]) + ), + null, + $parser, + [ + 'code' => '', + 'message' => 'lorem ipsum', + 'type' => 'client', + 'request_id' => 'xyz', + 'parsed' => [ + '__type' => 'BadRequestException#', + 'message' => 'lorem ipsum', + ], + 'body' => [], + ] + ], + 'Error code with namespace prefix' => [ + new Response( + 400, + ['x-amzn-requestid' => 'xyz'], + $encoder->encode([ + '__type' => 'com.amazon.service#BadRequestException', + 'message' => 'lorem ipsum', + ]) + ), + null, + $parser, + [ + 'code' => 'BadRequestException', + 'message' => 'lorem ipsum', + 'type' => 'client', + 'request_id' => 'xyz', + 'parsed' => [ + '__type' => 'com.amazon.service#BadRequestException', + 'message' => 'lorem ipsum', + ], + 'body' => [], + ] + ], + 'Modeled exception with service' => [ + new Response( + 400, + [ + 'TestHeader' => 'foo-header', + 'x-meta-foo' => 'foo-meta', + 'x-meta-bar' => 'bar-meta', + 'x-amzn-requestid' => 'xyz', + ], + $encoder->encode([ + '__type' => 'TestException', + 'TestString' => 'foo', + 'TestInt' => 123, + 'NotModeled' => 'bar', + ]) + ), + $command, + $parserWithService, + [ + 'code' => 'TestException', + 'type' => 'client', + 'request_id' => 'xyz', + 'parsed' => [ + '__type' => 'TestException', + 'teststring' => 'foo', + 'testint' => 123, + 'notmodeled' => 'bar', + ], + 'body' => [ + 'TestString' => 'foo', + 'TestInt' => 123, + 'TestHeaderMember' => 'foo-header', + 'TestHeaders' => [ + 'foo' => 'foo-meta', + 'bar' => 'bar-meta', + ], + 'TestStatus' => 400, + ], + 'message' => null, + 'error_shape' => $errorShape, + ] + ], + 'Error code using capital Message' => [ + new Response( + 400, + ['x-amzn-requestid' => 'xyz'], + $encoder->encode([ + '__type' => 'BadRequestException', + 'Message' => 'lorem ipsum', + ]) + ), + null, + $parser, + [ + 'code' => 'BadRequestException', + 'message' => 'lorem ipsum', + 'type' => 'client', + 'request_id' => 'xyz', + 'parsed' => [ + '__type' => 'BadRequestException', + 'message' => 'lorem ipsum', + ], + 'body' => [], + ] + ], + 'Missing __type field' => [ + new Response( + 400, + ['x-amzn-requestid' => 'xyz'], + $encoder->encode([ + 'message' => 'lorem ipsum', + ]) + ), + null, + $parser, + [ + 'code' => null, + 'message' => null, + 'type' => 'client', + 'request_id' => 'xyz', + 'parsed' => [ + 'message' => 'lorem ipsum', + ], + 'body' => [], + ] + ], + 'Server error (5xx)' => [ + new Response( + 500, + ['x-amzn-requestid' => 'xyz'], + $encoder->encode([ + '__type' => 'InternalServerError', + 'message' => 'Something went wrong', + ]) + ), + null, + $parser, + [ + 'code' => 'InternalServerError', + 'message' => 'Something went wrong', + 'type' => 'server', + 'request_id' => 'xyz', + 'parsed' => [ + '__type' => 'InternalServerError', + 'message' => 'Something went wrong', + ], + 'body' => [], + ] + ], + 'Empty body' => [ + new Response( + 400, + ['x-amzn-requestid' => 'xyz'], + '' + ), + null, + $parser, + [ + 'code' => null, + 'message' => null, + 'type' => 'client', + 'request_id' => 'xyz', + 'parsed' => null, + 'body' => [], + ] + ], + 'Zero values in headers (should be preserved)' => [ + new Response( + 400, + [ + 'TestHeader' => '0', + 'x-amzn-requestid' => 'xyz', + ], + $encoder->encode([ + '__type' => 'TestException', + ]) + ), + $command, + $parserWithService, + [ + 'code' => 'TestException', + 'type' => 'client', + 'request_id' => 'xyz', + 'parsed' => [ + '__type' => 'TestException', + ], + 'body' => [ + 'TestHeaderMember' => '0', + 'TestHeaders' => [], + 'TestStatus' => 400, + ], + 'message' => null, + 'error_shape' => $errorShape, + ] + ], + 'False value in header' => [ + new Response( + 400, + [ + 'TestHeader' => 'false', + 'x-amzn-requestid' => 'xyz', + ], + $encoder->encode([ + '__type' => 'TestException', + ]) + ), + $command, + $parserWithService, + [ + 'code' => 'TestException', + 'type' => 'client', + 'request_id' => 'xyz', + 'parsed' => [ + '__type' => 'TestException', + ], + 'body' => [ + 'TestHeaderMember' => 'false', + 'TestHeaders' => [], + 'TestStatus' => 400, + ], + 'message' => null, + 'error_shape' => $errorShape, + ] + ], + 'Empty string in header (should be skipped)' => [ + new Response( + 400, + [ + 'TestHeader' => '', + 'x-amzn-requestid' => 'xyz', + ], + $encoder->encode([ + '__type' => 'TestException', + ]) + ), + $command, + $parserWithService, + [ + 'code' => 'TestException', + 'type' => 'client', + 'request_id' => 'xyz', + 'parsed' => [ + '__type' => 'TestException', + ], + 'body' => [ + 'TestHeaders' => [], + 'TestStatus' => 400, + ], + 'message' => null, + 'error_shape' => $errorShape, + ] + ], + 'Request ID variations' => [ + new Response( + 400, + ['x-amzn-RequestId' => 'xyz'], + $encoder->encode([ + '__type' => 'BadRequestException', + 'message' => 'lorem ipsum', + ]) + ), + null, + $parser, + [ + 'code' => 'BadRequestException', + 'message' => 'lorem ipsum', + 'type' => 'client', + 'request_id' => 'xyz', + 'parsed' => [ + '__type' => 'BadRequestException', + 'message' => 'lorem ipsum', + ], + 'body' => [], + ] + ], + 'Binary data in error message' => [ + new Response( + 400, + ['x-amzn-requestid' => 'xyz'], + $encoder->encode([ + '__type' => 'BadRequestException', + 'message' => "Error\x00with\x00nulls", + 'binaryField' => ['__cbor_bytes' => "Binary\x00data"], + ]) + ), + null, + $parser, + [ + 'code' => 'BadRequestException', + 'message' => "Error\x00with\x00nulls", + 'type' => 'client', + 'request_id' => 'xyz', + 'parsed' => [ + '__type' => 'BadRequestException', + 'message' => "Error\x00with\x00nulls", + 'binaryfield' => "Binary\x00data", + ], + 'body' => [], + ] + ], + ]; + } + + public function testHandlesNullValues(): void + { + $parser = new RpcV2CborErrorParser(); + $response = new Response( + 400, + ['x-amzn-requestid' => 'xyz'], + $this->encoder->encode([ + '__type' => 'BadRequestException', + 'message' => null, + 'details' => null, + ]) + ); + + $parsed = $parser($response, null); + + // Implementation doesn't filter null values in parsed + $this->assertArrayHasKey('message', $parsed['parsed']); + $this->assertNull($parsed['parsed']['message']); + $this->assertArrayHasKey('details', $parsed['parsed']); + $this->assertNull($parsed['parsed']['details']); + } + + public function testHandlesZeroValues(): void + { + $parser = new RpcV2CborErrorParser(); + $response = new Response( + 400, + ['x-amzn-requestid' => 'xyz'], + $this->encoder->encode([ + '__type' => 'BadRequestException', + 'errorCode' => 0, + 'retryAfter' => 0.0, + 'isRetryable' => false, + ]) + ); + + $parsed = $parser($response, null); + + $expected = [ + '__type' => 'BadRequestException', + 'errorcode' => 0, + 'retryafter' => 0.0, + 'isretryable' => false, + ]; + $this->assertSame($expected, $parsed['parsed']); + } + + public function testHandlesSpecialFloatValues(): void + { + $parser = new RpcV2CborErrorParser(); + $response = new Response( + 400, + ['x-amzn-requestid' => 'xyz'], + $this->encoder->encode([ + '__type' => 'BadRequestException', + 'value1' => INF, + 'value2' => -INF, + 'value3' => NAN, + ]) + ); + + $parsed = $parser($response, null); + + $this->assertSame(INF, $parsed['parsed']['value1']); + $this->assertSame(-INF, $parsed['parsed']['value2']); + $this->assertNan($parsed['parsed']['value3']); + } + + public function testHandlesEmptyCollections(): void + { + $parser = new RpcV2CborErrorParser(); + $response = new Response( + 400, + ['x-amzn-requestid' => 'xyz'], + $this->encoder->encode([ + '__type' => 'BadRequestException', + 'items' => [], + 'attributes' => [], + ]) + ); + + $parsed = $parser($response, null); + + $expected = [ + '__type' => 'BadRequestException', + 'items' => [], + 'attributes' => [], + ]; + $this->assertSame($expected, $parsed['parsed']); + } + + public function testHandlesNestedStructures(): void + { + $parser = new RpcV2CborErrorParser(); + $response = new Response( + 400, + ['x-amzn-requestid' => 'xyz'], + $this->encoder->encode([ + '__type' => 'ComplexException', + 'outer' => [ + 'inner' => [ + 'value' => 'nested', + 'count' => 42, + ], + ], + ]) + ); + + $parsed = $parser($response, null); + + $expected = [ + '__type' => 'ComplexException', + 'outer' => [ + 'inner' => [ + 'value' => 'nested', + 'count' => 42, + ], + ], + ]; + $this->assertSame($expected, $parsed['parsed']); + } + + /** + * @dataProvider errorCodeFormatsProvider + */ + public function testExtractsErrorCodeProperly(string $input, string $expected): void + { + $parser = new RpcV2CborErrorParser(); + $response = new Response( + 400, + [], + $this->encoder->encode(['__type' => $input]) + ); + + $parsed = $parser($response, null); + $this->assertSame($expected, $parsed['code']); + } + + public function errorCodeFormatsProvider(): array + { + return [ + 'Simple exception' => ['SimpleException', 'SimpleException'], + 'Exception with trailing #' => ['SimpleException#', ''], // Implementation doesn't strip trailing # + 'Exception with leading #' => ['#SimpleException', 'SimpleException'], + 'Namespaced exception' => ['com.amazon.service#SimpleException', 'SimpleException'], + 'Fully qualified exception' => ['com.amazon.service.model#SimpleException', 'SimpleException'], + 'Multiple # characters' => ['Namespace#Exception#', 'Exception#'], // Implementation strips first part up to # + ]; + } + + /** + * @dataProvider errorTypesProvider + */ + public function testDeterminesErrorType( + int $statusCode, + string $expectedType + ): void + { + $parser = new RpcV2CborErrorParser(); + $response = new Response( + $statusCode, + [], + $this->encoder->encode(['__type' => 'TestError']) + ); + + $parsed = $parser($response, null); + $this->assertSame($expectedType, $parsed['type']); + } + + public function errorTypesProvider(): array + { + return [ + 'Client error 400' => [400, 'client'], + 'Client error 404' => [404, 'client'], + 'Client error 499' => [499, 'client'], + 'Server error 500' => [500, 'server'], + 'Server error 503' => [503, 'server'], + 'Server error 599' => [599, 'server'], + 'Redirect 301' => [301, 'server'], // Implementation treats 3xx as server + 'Redirect 302' => [302, 'server'], + ]; + } +} diff --git a/tests/Api/Parser/ComplianceTest.php b/tests/Api/Parser/ComplianceTest.php index 5a63710c5c..1cf2fe3d0b 100644 --- a/tests/Api/Parser/ComplianceTest.php +++ b/tests/Api/Parser/ComplianceTest.php @@ -127,16 +127,22 @@ public function testPassesComplianceTest( ): void { $command = new Command($name); + $protocol = $service->getProtocol(); + $responseBody = $res['body'] ?? null; + if ($protocol === 'smithy-rpc-v2-cbor' && !is_null($responseBody)) { + // converts back to CBOR + $responseBody = base64_decode($responseBody); + } // Create a response based on the serialized property of the test. $response = new Psr7\Response( $res['status_code'] ?? 200, $res['headers'] ?? [], - isset($res['body']) ? Psr7\Utils::streamFor($res['body']) : null + isset($res['body']) ? Psr7\Utils::streamFor($responseBody) : null ); if (!is_null($errorCode)) { - $parser = Service::createErrorParser($service->getProtocol(), $service); + $parser = Service::createErrorParser($protocol, $service); $parsed = $parser($response, $command); $result = $parsed['body']; $this->assertSame($errorCode, $parsed['code']); @@ -148,19 +154,35 @@ public function testPassesComplianceTest( $result = $parser($command, $response)->toArray(); } + if ($protocol === 'smithy-rpc-v2-cbor') { + //Handles INF and -INF conversion + $this->convertSpecialFloats($expectedResult); + // normalizes NAN for comparison + array_walk_recursive($result, function (&$value) { + if (is_float($value) && is_nan($value)) { + $value = 'NaN'; // Convert to string for comparison + } + }); + } + + $this->fixTimestamps( + $result, + $service->getOperation($name)->getOutput(), + $protocol + ); + - $this->fixTimestamps($result, $service->getOperation($name)->getOutput()); $this->assertEquals($expectedResult, $result); } - private function fixTimestamps(mixed &$data, Shape $shape): void + private function fixTimestamps(mixed &$data, Shape $shape, string $protocol): void { switch (get_class($shape)) { case StructureShape::class: if ($data && !$shape['document']) { foreach ($data as $key => &$value) { if ($shape->hasMember($key)) { - $this->fixTimestamps($value, $shape->getMember($key)); + $this->fixTimestamps($value, $shape->getMember($key), $protocol); } } } @@ -173,13 +195,13 @@ private function fixTimestamps(mixed &$data, Shape $shape): void if (is_array($data)) { foreach ($data as &$value) { - $this->fixTimestamps($value, $shape->getMember()); + $this->fixTimestamps($value, $shape->getMember(), $protocol); } } break; case MapShape::class: foreach ($data as &$value) { - $this->fixTimestamps($value, $shape->getValue()); + $this->fixTimestamps($value, $shape->getValue(), $protocol); } break; case TimestampShape::class: @@ -189,8 +211,13 @@ private function fixTimestamps(mixed &$data, Shape $shape): void $item = TimestampShape::format($item, 'unixTimestamp'); } } else { - // Format the DateTimeResult as a Unix timestamp - $data = TimestampShape::format($data, 'unixTimestamp'); + if ($protocol === 'smithy-rpc-v2-cbor') { + // CBOR uses Unix with millisecond precision + $micro = $data->format('u'); + $data = $data->getTimestamp() + $micro/1e6; + } else { + $data = TimestampShape::format($data, 'unixTimestamp'); + } } break; } @@ -285,4 +312,20 @@ private function looksLikePartialTimestamp(string $str): bool return false; } + + private function convertSpecialFloats(&$data): void + { + array_walk_recursive($data, function(&$value) { + if (is_string($value)) { + switch ($value) { + case 'Infinity': + $value = INF; + break; + case '-Infinity': + $value = -INF; + break; + } + } + }); + } } diff --git a/tests/Api/Parser/RpcV2CborParserTest.php b/tests/Api/Parser/RpcV2CborParserTest.php new file mode 100644 index 0000000000..f25bb094e3 --- /dev/null +++ b/tests/Api/Parser/RpcV2CborParserTest.php @@ -0,0 +1,636 @@ +encoder = new CborEncoder(); + } + + private function getTestService(): Service + { + return new Service( + [ + 'metadata' => [ + 'protocol' => 'smithy-rpc-v2-cbor', + 'serviceIdentifier' => 'testservice', + ], + 'operations' => [ + 'SimpleOperation' => [ + 'http' => ['method' => 'POST'], + 'output' => ['shape' => 'SimpleOutput'], + ], + 'OperationWithTimestamp' => [ + 'http' => ['method' => 'POST'], + 'output' => ['shape' => 'TimestampOutput'], + ], + 'OperationWithBlob' => [ + 'http' => ['method' => 'POST'], + 'output' => ['shape' => 'BlobOutput'], + ], + 'OperationWithList' => [ + 'http' => ['method' => 'POST'], + 'output' => ['shape' => 'ListOutput'], + ], + 'OperationWithMap' => [ + 'http' => ['method' => 'POST'], + 'output' => ['shape' => 'MapOutput'], + ], + 'OperationWithNestedStructures' => [ + 'http' => ['method' => 'POST'], + 'output' => ['shape' => 'NestedOutput'], + ], + 'OperationWithHeaders' => [ + 'http' => ['method' => 'POST'], + 'output' => ['shape' => 'HeadersOutput'], + ], + 'OperationWithAllTypes' => [ + 'http' => ['method' => 'POST'], + 'output' => ['shape' => 'AllTypesOutput'], + ], + 'OperationWithSparseList' => [ + 'http' => ['method' => 'POST'], + 'output' => ['shape' => 'SparseListOutput'], + ], + 'NoOutputOperation' => [ + 'http' => ['method' => 'POST'], + ], + ], + 'shapes' => [ + 'SimpleOutput' => [ + 'type' => 'structure', + 'members' => [ + 'message' => ['shape' => 'StringShape'], + 'count' => ['shape' => 'IntegerShape'], + ], + ], + 'TimestampOutput' => [ + 'type' => 'structure', + 'members' => [ + 'createdAt' => ['shape' => 'TimestampShape'], + ], + ], + 'BlobOutput' => [ + 'type' => 'structure', + 'members' => [ + 'data' => ['shape' => 'BlobShape'], + ], + ], + 'ListOutput' => [ + 'type' => 'structure', + 'members' => [ + 'items' => ['shape' => 'StringListShape'], + ], + ], + 'MapOutput' => [ + 'type' => 'structure', + 'members' => [ + 'attributes' => ['shape' => 'StringMapShape'], + ], + ], + 'NestedOutput' => [ + 'type' => 'structure', + 'members' => [ + 'nested' => ['shape' => 'NestedStructure'], + ], + ], + 'NestedStructure' => [ + 'type' => 'structure', + 'members' => [ + 'field1' => ['shape' => 'StringShape'], + 'field2' => ['shape' => 'IntegerShape'], + 'inner' => ['shape' => 'InnerStructure'], + ], + ], + 'InnerStructure' => [ + 'type' => 'structure', + 'members' => [ + 'value' => ['shape' => 'StringShape'], + ], + ], + 'HeadersOutput' => [ + 'type' => 'structure', + 'members' => [ + 'bodyField' => ['shape' => 'StringShape'], + 'headerField' => [ + 'shape' => 'StringShape', + 'location' => 'header', + 'locationName' => 'X-Custom-Header', + ], + 'statusCode' => [ + 'shape' => 'IntegerShape', + 'location' => 'statusCode', + ], + ], + ], + 'AllTypesOutput' => [ + 'type' => 'structure', + 'members' => [ + 'stringValue' => ['shape' => 'StringShape'], + 'intValue' => ['shape' => 'IntegerShape'], + 'longValue' => ['shape' => 'LongShape'], + 'floatValue' => ['shape' => 'FloatShape'], + 'doubleValue' => ['shape' => 'DoubleShape'], + 'boolValue' => ['shape' => 'BooleanShape'], + 'blobValue' => ['shape' => 'BlobShape'], + 'timestampValue' => ['shape' => 'TimestampShape'], + 'listValue' => ['shape' => 'StringListShape'], + 'mapValue' => ['shape' => 'StringMapShape'], + ], + ], + 'SparseListOutput' => [ + 'type' => 'structure', + 'members' => [ + 'sparseList' => ['shape' => 'SparseStringListShape'], + ], + ], + 'StringShape' => ['type' => 'string'], + 'IntegerShape' => ['type' => 'integer'], + 'LongShape' => ['type' => 'long'], + 'FloatShape' => ['type' => 'float'], + 'DoubleShape' => ['type' => 'double'], + 'BooleanShape' => ['type' => 'boolean'], + 'BlobShape' => ['type' => 'blob'], + 'TimestampShape' => ['type' => 'timestamp'], + 'StringListShape' => [ + 'type' => 'list', + 'member' => ['shape' => 'StringShape'], + ], + 'SparseStringListShape' => [ + 'type' => 'list', + 'member' => ['shape' => 'StringShape'], + '@sparse' => true, + ], + 'StringMapShape' => [ + 'type' => 'map', + 'key' => ['shape' => 'StringShape'], + 'value' => ['shape' => 'StringShape'], + ], + ], + ], + function () { + } + ); + } + + private function createCommand(string $name): CommandInterface + { + $command = $this->getMockBuilder(CommandInterface::class)->getMock(); + $command->method('getName')->willReturn($name); + + return $command; + } + + public function testParsesSimpleStructure(): void + { + $parser = new RpcV2CborParser($this->getTestService()); + $body = $this->encoder->encode([ + 'message' => 'Hello, World!', + 'count' => 42, + ]); + + $response = new Response(200, ['Smithy-Protocol' => 'rpc-v2-cbor'], $body); + $command = $this->createCommand('SimpleOperation'); + $result = $parser($command, $response); + + $this->assertSame('Hello, World!', $result['message']); + $this->assertSame(42, $result['count']); + } + + public function testParsesTimestamp(): void + { + $parser = new RpcV2CborParser($this->getTestService()); + $timestamp = 1705315800; // 2024-01-15 10:30:00 UTC + $body = $this->encoder->encode(['createdAt' => $timestamp]); + + $response = new Response(200, ['Smithy-Protocol' => 'rpc-v2-cbor'], $body); + $command = $this->createCommand('OperationWithTimestamp'); + $result = $parser($command, $response); + + $this->assertInstanceOf(DateTimeResult::class, $result['createdAt']); + $this->assertSame($timestamp, $result['createdAt']->getTimestamp()); + } + + public function testParsesBlob(): void + { + $parser = new RpcV2CborParser($this->getTestService()); + $binaryData = 'This is binary data'; + $body = $this->encoder->encode([ + 'data' => ['__cbor_bytes' => $binaryData], + ]); + + $response = new Response(200, ['Smithy-Protocol' => 'rpc-v2-cbor'], $body); + $command = $this->createCommand('OperationWithBlob'); + $result = $parser($command, $response); + + // The decoder returns byte strings as plain strings + $this->assertSame($binaryData, $result['data']); + } + + public function testParsesList(): void + { + $parser = new RpcV2CborParser($this->getTestService()); + $items = ['item1', 'item2', 'item3']; + $body = $this->encoder->encode([ + 'items' => $items, + ]); + + $response = new Response(200, ['Smithy-Protocol' => 'rpc-v2-cbor'], $body); + $command = $this->createCommand('OperationWithList'); + $result = $parser($command, $response); + + $this->assertIsArray($result['items']); + $this->assertSame($items, $result['items']); + } + + public function testParsesMap(): void + { + $parser = new RpcV2CborParser($this->getTestService()); + $attributes = [ + 'key1' => 'value1', + 'key2' => 'value2', + 'key3' => 'value3', + ]; + $body = $this->encoder->encode([ + 'attributes' => $attributes, + ]); + + $response = new Response(200, ['Smithy-Protocol' => 'rpc-v2-cbor'], $body); + $command = $this->createCommand('OperationWithMap'); + $result = $parser($command, $response); + + $this->assertIsArray($result['attributes']); + $this->assertSame($attributes, $result['attributes']); + } + + public function testParsesNestedStructures(): void + { + $parser = new RpcV2CborParser($this->getTestService()); + $nested = [ + 'nested' => [ + 'field1' => 'value1', + 'field2' => 123, + 'inner' => [ + 'value' => 'innerValue' + ] + ], + ]; + $body = $this->encoder->encode($nested); + + $response = new Response(200, ['Smithy-Protocol' => 'rpc-v2-cbor'], $body); + $command = $this->createCommand('OperationWithNestedStructures'); + $result = $parser($command, $response); + + $this->assertArrayHasKey('nested', $result); + $this->assertSame('value1', $result['nested']['field1']); + $this->assertSame(123, $result['nested']['field2']); + $this->assertArrayHasKey('inner', $result['nested']); + $this->assertSame('innerValue', $result['nested']['inner']['value']); + } + + public function testParsesAllTypes(): void + { + $parser = new RpcV2CborParser($this->getTestService()); + $timestamp = new DateTimeImmutable('2024-01-15 10:30:00 UTC'); + $data = [ + 'stringValue' => 'test string', + 'intValue' => 42, + 'longValue' => 9223372036854775807, + 'floatValue' => 3.14, + 'doubleValue' => 2.71828, + 'boolValue' => true, + 'blobValue' => ['__cbor_bytes' => 'binary data'], // Encode as byte string + 'timestampValue' => $timestamp->getTimestamp(), + 'listValue' => ['a', 'b', 'c'], + 'mapValue' => ['x' => 'y', 'z' => 'w'], + ]; + $body = $this->encoder->encode($data); + + $response = new Response(200, ['Smithy-Protocol' => 'rpc-v2-cbor'], $body); + $command = $this->createCommand('OperationWithAllTypes'); + $result = $parser($command, $response); + + $this->assertSame('test string', $result['stringValue']); + $this->assertSame(42, $result['intValue']); + $this->assertSame(9223372036854775807, $result['longValue']); + $this->assertSame(3.14, $result['floatValue']); + $this->assertSame(2.71828, $result['doubleValue']); + $this->assertTrue($result['boolValue']); + $this->assertSame('binary data', $result['blobValue']); // Decoder returns raw string + $this->assertInstanceOf(DateTimeResult::class, $result['timestampValue']); + $this->assertSame( + $timestamp->getTimestamp(), + $result['timestampValue']->getTimestamp() + ); + $this->assertSame(['a', 'b', 'c'], $result['listValue']); + $this->assertSame(['x' => 'y', 'z' => 'w'], $result['mapValue']); + } + + public function testParsesEmptyResponse(): void + { + $parser = new RpcV2CborParser($this->getTestService()); + $body = $this->encoder->encode([]); + + $response = new Response(200, ['Smithy-Protocol' => 'rpc-v2-cbor'], $body); + $command = $this->createCommand('SimpleOperation'); + $result = $parser($command, $response); + + $this->assertCount(0, $result); + } + + public function testParsesNoOutputOperation(): void + { + $parser = new RpcV2CborParser($this->getTestService()); + + $response = new Response(204, ['Smithy-Protocol' => 'rpc-v2-cbor'], ''); + $command = $this->createCommand('NoOutputOperation'); + $result = $parser($command, $response); + + $this->assertCount(0, $result); + } + + public function testParsesSparseList(): void + { + $parser = new RpcV2CborParser($this->getTestService()); + $sparseList = [null, 'first', null, 'third', null, null, 'sixth']; + $body = $this->encoder->encode([ + 'sparseList' => $sparseList, + ]); + + $response = new Response(200, ['Smithy-Protocol' => 'rpc-v2-cbor'], $body); + $command = $this->createCommand('OperationWithSparseList'); + $result = $parser($command, $response); + + $this->assertSame($sparseList, $result['sparseList']); + } + + public function testParsesNullValues(): void + { + $parser = new RpcV2CborParser($this->getTestService()); + $body = $this->encoder->encode( + [ + 'message' => null, + 'count' => null + ] + ); + + $response = new Response(200, ['Smithy-Protocol' => 'rpc-v2-cbor'], $body); + $command = $this->createCommand('SimpleOperation'); + $result = $parser($command, $response); + + // Null values are filtered out by isset() in RpcV2ParserTrait + $this->assertArrayNotHasKey('message', $result); + $this->assertArrayNotHasKey('count', $result); + } + + public function testParsesZeroValues(): void + { + $parser = new RpcV2CborParser($this->getTestService()); + $body = $this->encoder->encode( + [ + 'intValue' => 0, + 'floatValue' => 0.0, + 'boolValue' => false, + 'stringValue' => '' + ] + ); + + $response = new Response(200, ['Smithy-Protocol' => 'rpc-v2-cbor'], $body); + $command = $this->createCommand('OperationWithAllTypes'); + $result = $parser($command, $response); + + $this->assertSame(0, $result['intValue']); + $this->assertSame(0.0, $result['floatValue']); + $this->assertFalse($result['boolValue']); + $this->assertSame('', $result['stringValue']); + } + + public function testParsesSpecialFloatValues(): void + { + $parser = new RpcV2CborParser($this->getTestService()); + $body = $this->encoder->encode( + [ + 'floatValue' => INF, + 'doubleValue' => -INF + ] + ); + + $response = new Response(200, ['Smithy-Protocol' => 'rpc-v2-cbor'], $body); + $command = $this->createCommand('OperationWithAllTypes'); + $result = $parser($command, $response); + + $this->assertSame(INF, $result['floatValue']); + $this->assertSame(-INF, $result['doubleValue']); + } + + public function testParsesNaNValue(): void + { + $parser = new RpcV2CborParser($this->getTestService()); + $body = $this->encoder->encode( + ['floatValue' => NAN] + ); + + $response = new Response(200, ['Smithy-Protocol' => 'rpc-v2-cbor'], $body); + $command = $this->createCommand('OperationWithAllTypes'); + $result = $parser($command, $response); + + $this->assertNan($result['floatValue']); + } + + public function testParsesNegativeIntegers(): void + { + $parser = new RpcV2CborParser($this->getTestService()); + $body = $this->encoder->encode( + [ + 'intValue' => -42, + 'longValue' => -9223372036854775808 + ] + ); + + $response = new Response(200, ['Smithy-Protocol' => 'rpc-v2-cbor'], $body); + $command = $this->createCommand('OperationWithAllTypes'); + $result = $parser($command, $response); + + $this->assertSame(-42, $result['intValue']); + $this->assertSame(-9223372036854775808, $result['longValue']); + } + + public function testParsesEmptyCollections(): void + { + $parser = new RpcV2CborParser($this->getTestService()); + $body = $this->encoder->encode([ + 'listValue' => [], + 'mapValue' => [], + ]); + + $response = new Response(200, ['Smithy-Protocol' => 'rpc-v2-cbor'], $body); + $command = $this->createCommand('OperationWithAllTypes'); + $result = $parser($command, $response); + + $this->assertSame([], $result['listValue']); + $this->assertSame([], $result['mapValue']); + } + + public function testParsesMalformedCbor(): void + { + $this->expectException(ParserException::class); + $this->expectExceptionMessage('Malformed Response'); + + $parser = new RpcV2CborParser($this->getTestService()); + $malformedCbor = "\xFF\xFF\xFF"; // Invalid CBOR + + $response = new Response( + 200, + ['Smithy-Protocol' => 'rpc-v2-cbor'], + $malformedCbor + ); + $command = $this->createCommand('SimpleOperation'); + $parser($command, $response); + } + + public function testParsesLargeIntegers(): void + { + $parser = new RpcV2CborParser($this->getTestService()); + $body = $this->encoder->encode([ + 'longValue' => PHP_INT_MAX, + ]); + + $response = new Response(200, ['Smithy-Protocol' => 'rpc-v2-cbor'], $body); + $command = $this->createCommand('OperationWithAllTypes'); + $result = $parser($command, $response); + + $this->assertSame(PHP_INT_MAX, $result['longValue']); + } + + public function testParsesBinaryDataWithNullBytes(): void + { + $parser = new RpcV2CborParser($this->getTestService()); + $binaryData = "Binary\x00data\x00with\x00nulls"; + + // Encode as CBOR byte string + $body = $this->encoder->encode([ + 'data' => ['__cbor_bytes' => $binaryData], + ]); + + $response = new Response(200, ['Smithy-Protocol' => 'rpc-v2-cbor'], $body); + $command = $this->createCommand('OperationWithBlob'); + $result = $parser($command, $response); + + $this->assertSame($binaryData, $result['data']); + } + + public function testParsesUtf8Strings(): void + { + $parser = new RpcV2CborParser($this->getTestService()); + $utf8String = 'Hello 世界 🚀 こんにちは'; + $body = $this->encoder->encode([ + 'message' => $utf8String, + ]); + + $response = new Response(200, ['Smithy-Protocol' => 'rpc-v2-cbor'], $body); + $command = $this->createCommand('SimpleOperation'); + $result = $parser($command, $response); + + $this->assertSame($utf8String, $result['message']); + } + + /** + * @dataProvider protocolHeaderProvider + */ + public function testProtocolHeaderValidation( + array $headers, + int $statusCode, + string $operationName, + bool $expectException, + ?string $expectedMessage + ): void + { + if ($expectException) { + $this->expectException(ParserException::class); + $this->expectExceptionMessage($expectedMessage); + } + + $parser = new RpcV2CborParser($this->getTestService()); + + // Create appropriate body based on operation + $body = ''; + if ($operationName === 'SimpleOperation') { + $body = $this->encoder->encode( + ['message' => 'test', 'count' => 1] + ); + } + + $response = new Response($statusCode, $headers, $body); + $command = $this->createCommand($operationName); + $result = $parser($command, $response); + + if (!$expectException) { + if ($operationName === 'SimpleOperation' && $statusCode === 200) { + $this->assertSame('test', $result['message']); + $this->assertSame(1, $result['count']); + } else { + $this->assertCount(0, $result); + } + } + } + + /** + * Data provider for protocol header mismatch test cases + */ + public function protocolHeaderProvider(): array + { + return [ + 'missing_header' => [ + 'headers' => [], + 'statusCode' => 200, + 'operationName' => 'SimpleOperation', + 'expectException' => true, + 'expectedMessage' => 'Malformed response: Smithy-Protocol header mismatch (HTTP 200). Expected rpc-v2-cbor' + ], + 'incorrect_header_value' => [ + 'headers' => ['Smithy-Protocol' => 'rpc-v2-json'], + 'statusCode' => 200, + 'operationName' => 'SimpleOperation', + 'expectException' => true, + 'expectedMessage' => 'Malformed response: Smithy-Protocol header mismatch (HTTP 200). Expected rpc-v2-cbor' + ], + 'correct_header' => [ + 'headers' => ['Smithy-Protocol' => 'rpc-v2-cbor'], + 'statusCode' => 200, + 'operationName' => 'SimpleOperation', + 'expectException' => false, + 'expectedMessage' => null + ], + 'missing_header_no_output' => [ + 'headers' => [], + 'statusCode' => 204, + 'operationName' => 'NoOutputOperation', + 'expectException' => true, + 'expectedMessage' => 'Malformed response: Smithy-Protocol header mismatch (HTTP 204). Expected rpc-v2-cbor' + ], + 'case_insensitive_header' => [ + 'headers' => ['smithy-protocol' => 'rpc-v2-cbor'], + 'statusCode' => 200, + 'operationName' => 'SimpleOperation', + 'expectException' => false, + 'expectedMessage' => null + ], + ]; + } +} diff --git a/tests/Api/Serializer/ComplianceTest.php b/tests/Api/Serializer/ComplianceTest.php index c0aadf4e32..9bf77f79a3 100644 --- a/tests/Api/Serializer/ComplianceTest.php +++ b/tests/Api/Serializer/ComplianceTest.php @@ -3,6 +3,7 @@ use Aws\Api\Service; use Aws\AwsClient; +use Aws\Cbor\CborDecoder; use Aws\Signature\SignatureInterface; use Aws\Test\UsesServiceTrait; use PHPUnit\Framework\TestCase; @@ -17,11 +18,11 @@ * @covers \Aws\Api\Serializer\XmlBody * @covers \Aws\Api\Serializer\Ec2ParamBuilder * @covers \Aws\Api\Serializer\QueryParamBuilder + * @covers \Aws\Api\Serializer\AbstractRpcV2Serializer + * @covers \Aws\Api\Serializer\RpcV2CborSerializer */ class ComplianceTest extends TestCase { - use UsesServiceTrait; - public const TEST_CASES_DIR = __DIR__ . '/../test_cases/protocols/input/'; private static array $excludedCases = [ @@ -39,6 +40,16 @@ class ComplianceTest extends TestCase 'HttpPayloadWithMemberXmlName' => true ]; + private CborDecoder $cborDecoder; + + use UsesServiceTrait; + + protected function setUp(): void + { + parent::setUp(); + $this->cborDecoder = new CborDecoder(); + } + /** @doesNotPerformAssertions */ public function testCaseProvider(): \Generator { @@ -86,7 +97,7 @@ function () { return []; } */ public function testPassesComplianceTest( Service $service, - $name, + string $name, array $args, array $serialized, ?string $clientEndpoint @@ -111,47 +122,68 @@ public function testPassesComplianceTest( } ]); + $protocol = $service->getProtocol(); + if ($protocol === 'smithy-rpc-v2-cbor') { + $args = self::normalizeSpecialFloats($args); + } + $command = $client->getCommand($name, $args); $request = \Aws\serialize($command); + $protocol = $service->getProtocol(); if (isset($serialized['method'])) { $this->assertEquals($serialized['method'], $request->getMethod()); } - $this->assertEquals($serialized['uri'], $request->getRequestTarget()); - $body = (string) $request->getBody(); + $this->assertEquals($serialized['uri'], $request->getRequestTarget()); - switch ($protocol = $service->getProtocol()) { - case 'json': - case 'rest-json': - if (!empty($serialized['body'])) { - $body = json_encode(json_decode($body, true)); - $serialized['body'] = json_encode(json_decode($serialized['body'], true)); - } + // Assert body if provided + if (isset($serialized['body'])) { + $body = (string) $request->getBody(); + $expectedBody = $serialized['body']; + + switch ($protocol) { + case 'json': + case 'rest-json': + if (!empty($expectedBody)) { + // Normalize JSON for comparison + $body = json_encode(json_decode($body, true)); + $expectedBody = json_encode(json_decode($expectedBody, true)); + } + $this->assertEqualsIgnoringCase($expectedBody, $body); + break; + + case 'rest-xml': + // Remove XML declaration from body + $body = preg_replace('/<\?xml[^>]*\?>\s*/', '', $body); + if (!empty($expectedBody)) { + $this->assertXmlEquals($expectedBody, $body); + } else { + $this->assertEqualsIgnoringCase($expectedBody, $body); + } + break; - break; - case 'rest-xml': - // Remove XML declaration from body - $body = preg_replace('/<\?xml[^>]*\?>\s*/', '', $body); - break; - } + case 'smithy-rpc-v2-cbor': + if (!empty($expectedBody)) { + // Decode and normalize CBOR for comparison + $expectedBody = $this->cborDecoder->decode(base64_decode($expectedBody)); + $body = $this->cborDecoder->decode($body); - if (isset($serialized['method'])) { - $this->assertEquals($serialized['method'], $request->getMethod()); - } + array_walk_recursive($expectedBody, [$this, 'normalizeCborForComparison']); + array_walk_recursive($body, [$this, 'normalizeCborForComparison']); + } + $this->assertEquals($expectedBody, $body); + break; - if (isset($serialized['body'])) { - if ($protocol === 'rest-xml' && !empty($serialized['body'])) { - $this->assertXmlEquals($serialized['body'], $body); - } else { - $this->assertEqualsIgnoringCase($serialized['body'], $body); + default: + $this->assertEqualsIgnoringCase($expectedBody, $body); + break; } } if (isset($serialized['host'])) { $expectedHost = $serialized['host']; - - if (strpos($expectedHost, '/') !== false) { + if (str_contains($expectedHost, '/')) { // Expected host contains a path, compare full authority + path $actualHostWithPath = $request->getUri()->getHost() . $request->getUri()->getPath(); $this->assertStringStartsWith($expectedHost, $actualHostWithPath); @@ -170,8 +202,6 @@ public function testPassesComplianceTest( if (isset($serialized['headers'])) { foreach ($serialized['headers'] as $key => $expectedValue) { $headerValues = $request->getHeader($key); - - // Custom join logic that matches the expected format $actualValue = $this->formatHeaderValues($headerValues); $this->assertEquals($expectedValue, $actualValue, "Header {$key} mismatch"); } @@ -179,7 +209,7 @@ public function testPassesComplianceTest( if (isset($serialized['forbidHeaders'])) { foreach ($serialized['forbidHeaders'] as $header) { - $this->assertNotTrue($request->hasHeader($header)); + $this->assertFalse($request->hasHeader($header)); } } } @@ -259,4 +289,34 @@ private function isDateHeader(string $value): int|false $value ); } + + /** + * Normalizes DateTime objects to unix timestamp values and + * converts NAN to 'NaN' because NAN cannot be compared + * + * @param mixed $value + * @return void + */ + private function normalizeCborForComparison(mixed &$value): void + { + if ($value instanceof \DateTime) { + $value = (float)$value->format('U.u') * 1000; + } elseif (is_float($value) && is_nan($value)) { + $value = 'NaN'; + } + } + + private static function normalizeSpecialFloats(mixed $value): mixed + { + if (is_array($value)) { + return array_map(self::normalizeSpecialFloats(...), $value); + } + + return match ($value) { + 'NaN' => NAN, + 'Infinity' => INF, + '-Infinity' => -INF, + default => $value + }; + } } diff --git a/tests/Api/Serializer/RpcV2CborSerializerTest.php b/tests/Api/Serializer/RpcV2CborSerializerTest.php new file mode 100644 index 0000000000..2de178ca98 --- /dev/null +++ b/tests/Api/Serializer/RpcV2CborSerializerTest.php @@ -0,0 +1,530 @@ +encoder = new CborEncoder(); + $this->decoder = new CborDecoder(); + } + + private function getTestService(): Service + { + return new Service( + [ + 'metadata' => [ + 'targetPrefix' => 'TestService', + 'protocol' => 'smithy-rpc-v2-cbor', + 'serviceIdentifier' => 'testservice', + ], + 'operations' => [ + 'SimpleOperation' => [ + 'http' => ['method' => 'POST'], + 'input' => ['shape' => 'SimpleInput'], + ], + 'OperationWithTimestamp' => [ + 'http' => ['method' => 'POST'], + 'input' => ['shape' => 'TimestampInput'], + ], + 'OperationWithBlob' => [ + 'http' => ['method' => 'POST'], + 'input' => ['shape' => 'BlobInput'], + ], + 'OperationWithList' => [ + 'http' => ['method' => 'POST'], + 'input' => ['shape' => 'ListInput'], + ], + 'OperationWithMap' => [ + 'http' => ['method' => 'POST'], + 'input' => ['shape' => 'MapInput'], + ], + 'OperationWithNestedStructures' => [ + 'http' => ['method' => 'POST'], + 'input' => ['shape' => 'NestedInput'], + ], + 'OperationWithAllTypes' => [ + 'http' => ['method' => 'POST'], + 'input' => ['shape' => 'AllTypesInput'], + ], + 'OperationWithSparseList' => [ + 'http' => ['method' => 'POST'], + 'input' => ['shape' => 'SparseListInput'], + ], + 'NoInputOperation' => [ + 'http' => ['method' => 'POST'], + ], + ], + 'shapes' => [ + 'SimpleInput' => [ + 'type' => 'structure', + 'members' => [ + 'message' => ['shape' => 'StringShape'], + 'count' => ['shape' => 'IntegerShape'], + ], + ], + 'TimestampInput' => [ + 'type' => 'structure', + 'members' => [ + 'createdAt' => [ + 'shape' => 'TimestampShape', + ], + ], + ], + 'BlobInput' => [ + 'type' => 'structure', + 'members' => [ + 'data' => ['shape' => 'BlobShape'], + ], + ], + 'ListInput' => [ + 'type' => 'structure', + 'members' => [ + 'items' => ['shape' => 'StringListShape'], + ], + ], + 'MapInput' => [ + 'type' => 'structure', + 'members' => [ + 'attributes' => ['shape' => 'StringMapShape'], + ], + ], + 'NestedInput' => [ + 'type' => 'structure', + 'members' => [ + 'nested' => ['shape' => 'NestedStructure'], + ], + ], + 'NestedStructure' => [ + 'type' => 'structure', + 'members' => [ + 'field1' => ['shape' => 'StringShape'], + 'field2' => ['shape' => 'IntegerShape'], + 'inner' => ['shape' => 'InnerStructure'], + ], + ], + 'InnerStructure' => [ + 'type' => 'structure', + 'members' => [ + 'value' => ['shape' => 'StringShape'], + ], + ], + 'AllTypesInput' => [ + 'type' => 'structure', + 'members' => [ + 'stringValue' => ['shape' => 'StringShape'], + 'intValue' => ['shape' => 'IntegerShape'], + 'longValue' => ['shape' => 'LongShape'], + 'floatValue' => ['shape' => 'FloatShape'], + 'doubleValue' => ['shape' => 'DoubleShape'], + 'boolValue' => ['shape' => 'BooleanShape'], + 'blobValue' => ['shape' => 'BlobShape'], + 'timestampValue' => ['shape' => 'TimestampShape'], + 'listValue' => ['shape' => 'StringListShape'], + 'mapValue' => ['shape' => 'StringMapShape'], + ], + ], + 'SparseListInput' => [ + 'type' => 'structure', + 'members' => [ + 'sparseList' => ['shape' => 'SparseStringListShape'], + ], + ], + 'StringShape' => ['type' => 'string'], + 'IntegerShape' => ['type' => 'integer'], + 'LongShape' => ['type' => 'long'], + 'FloatShape' => ['type' => 'float'], + 'DoubleShape' => ['type' => 'double'], + 'BooleanShape' => ['type' => 'boolean'], + 'BlobShape' => ['type' => 'blob'], + 'TimestampShape' => ['type' => 'timestamp'], + 'StringListShape' => [ + 'type' => 'list', + 'member' => ['shape' => 'StringShape'], + ], + 'SparseStringListShape' => [ + 'type' => 'list', + 'member' => ['shape' => 'StringShape'], + '@sparse' => true, + ], + 'StringMapShape' => [ + 'type' => 'map', + 'key' => ['shape' => 'StringShape'], + 'value' => ['shape' => 'StringShape'], + ], + ], + ], + function () { + } + ); + } + + private function getRequest( + string $commandName, + array $input = []) + : RequestInterface + { + $service = $this->getTestService(); + $command = new Command($commandName, $input); + $serializer = new RpcV2CborSerializer($service, 'http://example.com'); + return $serializer($command); + } + + public function testSerializesSimpleStructure(): void + { + $request = $this->getRequest('SimpleOperation', [ + 'message' => 'Hello, World!', + 'count' => 42, + ]); + + $this->assertSame('POST', $request->getMethod()); + $this->assertSame( + '/service/TestService/operation/SimpleOperation', + $request->getUri()->getPath() + ); + $this->assertSame('application/cbor', $request->getHeaderLine('Content-Type')); + $this->assertSame('rpc-v2-cbor', $request->getHeaderLine('Smithy-Protocol')); + + $body = (string) $request->getBody(); + $decoded = $this->decoder->decode($body); + + $expected = [ + 'message' => 'Hello, World!', + 'count' => 42, + ]; + $this->assertSame($expected, $decoded); + } + + public function testSerializesTimestamp(): void + { + $timestamp = new DateTime('2024-01-15 10:30:00 UTC'); + $request = $this->getRequest( + 'OperationWithTimestamp', + ['createdAt' => $timestamp] + ); + + $body = (string) $request->getBody(); + $decoded = $this->decoder->decode($body); + + $expected = ['createdAt' => $timestamp->getTimestamp()]; + $this->assertSame($expected, $decoded); + } + + public function testSerializesDateTimeImmutable(): void + { + $timestamp = new DateTimeImmutable('2024-01-15 10:30:00 UTC'); + $request = $this->getRequest('OperationWithTimestamp', [ + 'createdAt' => $timestamp, + ]); + + $body = (string) $request->getBody(); + $decoded = $this->decoder->decode($body); + + $expected = ['createdAt' => $timestamp->getTimestamp()]; + $this->assertSame($expected, $decoded); + } + + public function testSerializesBlob(): void + { + $binaryData = 'This is binary data'; + $request = $this->getRequest( + 'OperationWithBlob', + ['data' => $binaryData,] + ); + + $body = (string) $request->getBody(); + $decoded = $this->decoder->decode($body); + + $expected = ['data' => $binaryData]; + $this->assertSame($expected, $decoded); + } + + public function testSerializesList(): void + { + $items = ['item1', 'item2', 'item3']; + $request = $this->getRequest( + 'OperationWithList', + ['items' => $items,]); + + $body = (string) $request->getBody(); + $decoded = $this->decoder->decode($body); + + $expected = ['items' => $items]; + $this->assertSame($expected, $decoded); + } + + public function testSerializesMap(): void + { + $attributes = [ + 'key1' => 'value1', + 'key2' => 'value2', + 'key3' => 'value3', + ]; + $request = $this->getRequest( + 'OperationWithMap', + ['attributes' => $attributes,] + ); + + $body = (string) $request->getBody(); + $decoded = $this->decoder->decode($body); + + $expected = ['attributes' => $attributes]; + $this->assertSame($expected, $decoded); + } + + public function testSerializesNestedStructures(): void + { + $input = [ + 'nested' => [ + 'field1' => 'value1', + 'field2' => 123, + 'inner' => [ + 'value' => 'innerValue', + ], + ], + ]; + $request = $this->getRequest('OperationWithNestedStructures', $input); + + $body = (string) $request->getBody(); + $decoded = $this->decoder->decode($body); + + $this->assertSame($input, $decoded); + } + + public function testSerializesAllTypes(): void + { + $timestamp = new DateTime('2024-01-15 10:30:00 UTC'); + $input = [ + 'stringValue' => 'test string', + 'intValue' => 42, + 'longValue' => 9223372036854775807, + 'floatValue' => 3.14, + 'doubleValue' => 2.71828, + 'boolValue' => true, + 'blobValue' => 'binary data', + 'timestampValue' => $timestamp, + 'listValue' => ['a', 'b', 'c'], + 'mapValue' => ['x' => 'y', 'z' => 'w'], + ]; + $request = $this->getRequest('OperationWithAllTypes', $input); + + $body = (string) $request->getBody(); + $decoded = $this->decoder->decode($body); + + $expected = [ + 'stringValue' => 'test string', + 'intValue' => 42, + 'longValue' => 9223372036854775807, + 'floatValue' => 3.14, + 'doubleValue' => 2.71828, + 'boolValue' => true, + 'blobValue' => 'binary data', + 'timestampValue' => $timestamp->getTimestamp(), + 'listValue' => ['a', 'b', 'c'], + 'mapValue' => ['x' => 'y', 'z' => 'w'], + ]; + + $this->assertSame($expected, $decoded); + } + + public function testSerializesEmptyInput(): void + { + $request = $this->getRequest('SimpleOperation', []); + + $body = (string) $request->getBody(); + // Check for CBOR indefinite map (0xBF 0xFF) + $bytes = unpack('C*', $body); + $this->assertSame([1 => 0xBF, 2 => 0xFF], $bytes); + } + + public function testSerializesNoInputOperation(): void + { + $request = $this->getRequest('NoInputOperation'); + + // No body for operations without input (PSR-7 creates empty stream) + $body = (string) $request->getBody(); + $this->assertSame('', $body); + + // Content-Type should not be set for operations without input + $this->assertFalse($request->hasHeader('Content-Type')); + } + + public function testSerializesSparseList(): void + { + $sparseList = [1 => 'first', 3 => 'third', 6 => 'sixth']; + $request = $this->getRequest( + 'OperationWithSparseList', + ['sparseList' => $sparseList,] + ); + + $body = (string) $request->getBody(); + $decoded = $this->decoder->decode($body); + + $expected = ['sparseList' => $sparseList]; + $this->assertSame($expected, $decoded); + } + + public function testSerializesWithEndpointPath(): void + { + $service = $this->getTestService(); + $command = new Command('SimpleOperation', ['message' => 'test']); + $serializer = new RpcV2CborSerializer($service, 'http://example.com/api/v1'); + $request = $serializer($command); + + $this->assertSame( + '/api/v1/service/TestService/operation/SimpleOperation', + $request->getUri()->getPath() + ); + } + + public function testSerializesWithEndpointV2(): void + { + $service = $this->getTestService(); + $command = new Command('SimpleOperation', ['message' => 'test']); + $serializer = new RpcV2CborSerializer($service, 'http://example.com'); + $endpoint = new RulesetEndpoint('https://custom.example.com/path'); + $request = $serializer($command, $endpoint); + + $this->assertSame( + '/path/service/TestService/operation/SimpleOperation', + $request->getUri()->getPath() + ); + } + + public function testSerializesNullValues(): void + { + $request = $this->getRequest( + 'SimpleOperation', + [ + 'message' => null, + 'count' => null, + ] + ); + + $body = (string) $request->getBody(); + // Null values are excluded from the serialized structure + // Check for empty CBOR indefinite map (0xBF 0xFF) + $bytes = unpack('C*', $body); + $this->assertSame([1 => 0xBF, 2 => 0xFF], $bytes); + } + + public function testSerializesZeroValues(): void + { + $request = $this->getRequest( + 'OperationWithAllTypes', + [ + 'intValue' => 0, + 'floatValue' => 0.0, + 'boolValue' => false, + 'stringValue' => '' + ] + ); + + $body = (string) $request->getBody(); + $decoded = $this->decoder->decode($body); + + $expected = [ + 'intValue' => 0, + 'floatValue' => 0.0, + 'boolValue' => false, + 'stringValue' => '' + ]; + + $this->assertSame($expected, $decoded); + } + + public function testSerializesSpecialFloatValues(): void + { + $request = $this->getRequest( + 'OperationWithAllTypes', + [ + 'floatValue' => INF, + 'doubleValue' => -INF + ] + ); + + $body = (string) $request->getBody(); + $decoded = $this->decoder->decode($body); + + $expected = [ + 'floatValue' => INF, + 'doubleValue' => -INF, + ]; + + $this->assertSame($expected, $decoded); + } + + public function testSerializesNegativeIntegers(): void + { + $request = $this->getRequest( + 'OperationWithAllTypes', + [ + 'intValue' => -42, + 'longValue' => PHP_INT_MIN + ] + ); + + $body = (string) $request->getBody(); + $decoded = $this->decoder->decode($body); + + $expected = [ + 'intValue' => -42, + 'longValue' => PHP_INT_MIN, + ]; + + $this->assertSame($expected, $decoded); + } + + public function testSerializesEmptyCollections(): void + { + $request = $this->getRequest( + 'OperationWithAllTypes', + [ + 'listValue' => [], + 'mapValue' => [] + ] + ); + + $body = (string) $request->getBody(); + $decoded = $this->decoder->decode($body); + + $expected = [ + 'listValue' => [], + 'mapValue' => [], + ]; + + $this->assertSame($expected, $decoded); + } + + public function testSetsCborHeaders(): void + { + $request = $this->getRequest('SimpleOperation', ['message' => 'test']); + + $this->assertSame('application/cbor', $request->getHeaderLine('Content-Type')); + $this->assertTrue($request->hasHeader('Smithy-Protocol')); + $this->assertStringContainsString( + 'rpc-v2-cbor', + $request->getHeaderLine('Smithy-Protocol') + ); + $this->assertTrue($request->hasHeader('Content-Length')); + } +} diff --git a/tests/Api/ServiceTest.php b/tests/Api/ServiceTest.php index 0a79674a0e..b3bf95e9bf 100644 --- a/tests/Api/ServiceTest.php +++ b/tests/Api/ServiceTest.php @@ -356,8 +356,6 @@ public function selectsProtocolProvider() { return [ [['smithy-rpc-v2-cbor', 'json'], 'json'], - //Handles failure to select by falling back to 'protocol' - [['smithy-rpc-v2-cbor'], 'json'], [['smithy-rpc-v2-cbor', 'json', 'query'], 'json'], [['json', 'query'], 'json'], [['query'], 'query'], diff --git a/tests/Api/test_cases/protocols/input/rpc-v2-cbor.json b/tests/Api/test_cases/protocols/input/rpc-v2-cbor.json new file mode 100644 index 0000000000..74411a71a8 --- /dev/null +++ b/tests/Api/test_cases/protocols/input/rpc-v2-cbor.json @@ -0,0 +1,1051 @@ +[ + { + "description": "Test cases for EmptyInputOutput operation", + "metadata": { + "apiVersion": "2020-07-14", + "auth": [ + "aws.auth#sigv4" + ], + "endpointPrefix": "rpcv2protocol", + "protocol": "smithy-rpc-v2-cbor", + "protocols": [ + "smithy-rpc-v2-cbor" + ], + "serviceFullName": "RpcV2Protocol", + "serviceId": "RpcV2Protocol", + "signatureVersion": "v4", + "signingName": "RpcV2Protocol", + "targetPrefix": "RpcV2Protocol", + "uid": "rpcv2protocol-2020-07-14" + }, + "shapes": { + "EmptyStructure": { + "type": "structure", + "members": {} + } + }, + "cases": [ + { + "id": "empty_input", + "description": "When Input structure is empty we write CBOR equivalent of {}", + "given": { + "name": "EmptyInputOutput", + "http": { + "method": "POST", + "requestUri": "/" + }, + "input": { + "shape": "EmptyStructure" + } + }, + "params": {}, + "serialized": { + "method": "POST", + "uri": "/service/RpcV2Protocol/operation/EmptyInputOutput", + "body": "v/8=", + "headers": { + "smithy-protocol": "rpc-v2-cbor", + "Accept": "application/cbor", + "Content-Type": "application/cbor" + }, + "forbidHeaders": [ + "X-Amz-Target" + ], + "requireHeaders": [ + "Content-Length" + ] + } + } + ] + }, + { + "description": "Test cases for NoInputOutput operation", + "metadata": { + "apiVersion": "2020-07-14", + "auth": [ + "aws.auth#sigv4" + ], + "endpointPrefix": "rpcv2protocol", + "protocol": "smithy-rpc-v2-cbor", + "protocols": [ + "smithy-rpc-v2-cbor" + ], + "serviceFullName": "RpcV2Protocol", + "serviceId": "RpcV2Protocol", + "signatureVersion": "v4", + "signingName": "RpcV2Protocol", + "targetPrefix": "RpcV2Protocol", + "uid": "rpcv2protocol-2020-07-14" + }, + "shapes": {}, + "cases": [ + { + "id": "no_input", + "description": "Body is empty and no Content-Type header if no input", + "given": { + "name": "NoInputOutput", + "http": { + "method": "POST", + "requestUri": "/" + } + }, + "params": {}, + "serialized": { + "method": "POST", + "uri": "/service/RpcV2Protocol/operation/NoInputOutput", + "body": "", + "headers": { + "Accept": "application/cbor", + "smithy-protocol": "rpc-v2-cbor" + }, + "forbidHeaders": [ + "Content-Type", + "X-Amz-Target" + ] + } + } + ] + }, + { + "description": "Test cases for OptionalInputOutput operation", + "metadata": { + "apiVersion": "2020-07-14", + "auth": [ + "aws.auth#sigv4" + ], + "endpointPrefix": "rpcv2protocol", + "protocol": "smithy-rpc-v2-cbor", + "protocols": [ + "smithy-rpc-v2-cbor" + ], + "serviceFullName": "RpcV2Protocol", + "serviceId": "RpcV2Protocol", + "signatureVersion": "v4", + "signingName": "RpcV2Protocol", + "targetPrefix": "RpcV2Protocol", + "uid": "rpcv2protocol-2020-07-14" + }, + "shapes": { + "SimpleStructure": { + "type": "structure", + "members": { + "value": { + "shape": "String" + } + } + }, + "String": { + "type": "string" + } + }, + "cases": [ + { + "id": "optional_input", + "description": "When input is empty we write CBOR equivalent of {}", + "given": { + "name": "OptionalInputOutput", + "http": { + "method": "POST", + "requestUri": "/" + }, + "input": { + "shape": "SimpleStructure" + } + }, + "params": {}, + "serialized": { + "method": "POST", + "uri": "/service/RpcV2Protocol/operation/OptionalInputOutput", + "body": "v/8=", + "headers": { + "smithy-protocol": "rpc-v2-cbor", + "Accept": "application/cbor", + "Content-Type": "application/cbor" + }, + "forbidHeaders": [ + "X-Amz-Target" + ] + } + } + ] + }, + { + "description": "Test cases for RecursiveShapes operation", + "metadata": { + "apiVersion": "2020-07-14", + "auth": [ + "aws.auth#sigv4" + ], + "endpointPrefix": "rpcv2protocol", + "protocol": "smithy-rpc-v2-cbor", + "protocols": [ + "smithy-rpc-v2-cbor" + ], + "serviceFullName": "RpcV2Protocol", + "serviceId": "RpcV2Protocol", + "signatureVersion": "v4", + "signingName": "RpcV2Protocol", + "targetPrefix": "RpcV2Protocol", + "uid": "rpcv2protocol-2020-07-14" + }, + "shapes": { + "RecursiveShapesInputOutput": { + "type": "structure", + "members": { + "nested": { + "shape": "RecursiveShapesInputOutputNested1" + } + } + }, + "RecursiveShapesInputOutputNested1": { + "type": "structure", + "members": { + "foo": { + "shape": "String" + }, + "nested": { + "shape": "RecursiveShapesInputOutputNested2" + } + } + }, + "String": { + "type": "string" + }, + "RecursiveShapesInputOutputNested2": { + "type": "structure", + "members": { + "bar": { + "shape": "String" + }, + "recursiveMember": { + "shape": "RecursiveShapesInputOutputNested1" + } + } + } + }, + "cases": [ + { + "id": "RpcV2CborRecursiveShapes", + "description": "Serializes recursive structures", + "given": { + "name": "RecursiveShapes", + "http": { + "method": "POST", + "requestUri": "/" + }, + "input": { + "shape": "RecursiveShapesInputOutput" + } + }, + "params": { + "nested": { + "foo": "Foo1", + "nested": { + "bar": "Bar1", + "recursiveMember": { + "foo": "Foo2", + "nested": { + "bar": "Bar2" + } + } + } + } + }, + "serialized": { + "method": "POST", + "uri": "/service/RpcV2Protocol/operation/RecursiveShapes", + "body": "v2ZuZXN0ZWS/Y2Zvb2RGb28xZm5lc3RlZL9jYmFyZEJhcjFvcmVjdXJzaXZlTWVtYmVyv2Nmb29kRm9vMmZuZXN0ZWS/Y2JhcmRCYXIy//////8=", + "headers": { + "smithy-protocol": "rpc-v2-cbor", + "Accept": "application/cbor", + "Content-Type": "application/cbor" + }, + "requireHeaders": [ + "Content-Length" + ] + } + } + ] + }, + { + "description": "Test cases for RpcV2CborDenseMaps operation", + "metadata": { + "apiVersion": "2020-07-14", + "auth": [ + "aws.auth#sigv4" + ], + "endpointPrefix": "rpcv2protocol", + "protocol": "smithy-rpc-v2-cbor", + "protocols": [ + "smithy-rpc-v2-cbor" + ], + "serviceFullName": "RpcV2Protocol", + "serviceId": "RpcV2Protocol", + "signatureVersion": "v4", + "signingName": "RpcV2Protocol", + "targetPrefix": "RpcV2Protocol", + "uid": "rpcv2protocol-2020-07-14" + }, + "shapes": { + "RpcV2CborDenseMapsInputOutput": { + "type": "structure", + "members": { + "denseStructMap": { + "shape": "DenseStructMap" + }, + "denseNumberMap": { + "shape": "DenseNumberMap" + }, + "denseBooleanMap": { + "shape": "DenseBooleanMap" + }, + "denseStringMap": { + "shape": "DenseStringMap" + }, + "denseSetMap": { + "shape": "DenseSetMap" + } + } + }, + "DenseStructMap": { + "type": "map", + "key": { + "shape": "String" + }, + "value": { + "shape": "GreetingStruct" + } + }, + "DenseNumberMap": { + "type": "map", + "key": { + "shape": "String" + }, + "value": { + "shape": "Integer" + } + }, + "DenseBooleanMap": { + "type": "map", + "key": { + "shape": "String" + }, + "value": { + "shape": "Boolean" + } + }, + "DenseStringMap": { + "type": "map", + "key": { + "shape": "String" + }, + "value": { + "shape": "String" + } + }, + "DenseSetMap": { + "type": "map", + "key": { + "shape": "String" + }, + "value": { + "shape": "StringSet" + } + }, + "StringSet": { + "type": "list", + "member": { + "shape": "String" + } + }, + "String": { + "type": "string" + }, + "Boolean": { + "type": "boolean", + "box": true + }, + "Integer": { + "type": "integer", + "box": true + }, + "GreetingStruct": { + "type": "structure", + "members": { + "hi": { + "shape": "String" + } + } + } + }, + "cases": [ + { + "id": "RpcV2CborMaps", + "description": "Serializes maps", + "given": { + "name": "RpcV2CborDenseMaps", + "http": { + "method": "POST", + "requestUri": "/" + }, + "input": { + "shape": "RpcV2CborDenseMapsInputOutput" + }, + "documentation": "
The example tests basic map serialization.
" + }, + "params": { + "denseStructMap": { + "foo": { + "hi": "there" + }, + "baz": { + "hi": "bye" + } + } + }, + "serialized": { + "method": "POST", + "uri": "/service/RpcV2Protocol/operation/RpcV2CborDenseMaps", + "body": "oW5kZW5zZVN0cnVjdE1hcKJjZm9voWJoaWV0aGVyZWNiYXqhYmhpY2J5ZQ==", + "headers": { + "smithy-protocol": "rpc-v2-cbor", + "Accept": "application/cbor", + "Content-Type": "application/cbor" + }, + "requireHeaders": [ + "Content-Length" + ] + } + }, + { + "id": "RpcV2CborSerializesZeroValuesInMaps", + "description": "Ensure that 0 and false are sent over the wire in all maps and lists", + "given": { + "name": "RpcV2CborDenseMaps", + "http": { + "method": "POST", + "requestUri": "/" + }, + "input": { + "shape": "RpcV2CborDenseMapsInputOutput" + }, + "documentation": "The example tests basic map serialization.
" + }, + "params": { + "denseNumberMap": { + "x": 0 + }, + "denseBooleanMap": { + "x": false + } + }, + "serialized": { + "method": "POST", + "uri": "/service/RpcV2Protocol/operation/RpcV2CborDenseMaps", + "body": "om5kZW5zZU51bWJlck1hcKFheABvZGVuc2VCb29sZWFuTWFwoWF49A==", + "headers": { + "smithy-protocol": "rpc-v2-cbor", + "Accept": "application/cbor", + "Content-Type": "application/cbor" + }, + "requireHeaders": [ + "Content-Length" + ] + } + }, + { + "id": "RpcV2CborSerializesDenseSetMap", + "description": "A request that contains a dense map of sets.", + "given": { + "name": "RpcV2CborDenseMaps", + "http": { + "method": "POST", + "requestUri": "/" + }, + "input": { + "shape": "RpcV2CborDenseMapsInputOutput" + }, + "documentation": "The example tests basic map serialization.
" + }, + "params": { + "denseSetMap": { + "x": [], + "y": [ + "a", + "b" + ] + } + }, + "serialized": { + "method": "POST", + "uri": "/service/RpcV2Protocol/operation/RpcV2CborDenseMaps", + "body": "oWtkZW5zZVNldE1hcKJheIBheYJhYWFi", + "headers": { + "smithy-protocol": "rpc-v2-cbor", + "Accept": "application/cbor", + "Content-Type": "application/cbor" + }, + "requireHeaders": [ + "Content-Length" + ] + } + } + ] + }, + { + "description": "Test cases for RpcV2CborLists operation", + "metadata": { + "apiVersion": "2020-07-14", + "auth": [ + "aws.auth#sigv4" + ], + "endpointPrefix": "rpcv2protocol", + "protocol": "smithy-rpc-v2-cbor", + "protocols": [ + "smithy-rpc-v2-cbor" + ], + "serviceFullName": "RpcV2Protocol", + "serviceId": "RpcV2Protocol", + "signatureVersion": "v4", + "signingName": "RpcV2Protocol", + "targetPrefix": "RpcV2Protocol", + "uid": "rpcv2protocol-2020-07-14" + }, + "shapes": { + "RpcV2CborListInputOutput": { + "type": "structure", + "members": { + "stringList": { + "shape": "StringList" + }, + "stringSet": { + "shape": "StringSet" + }, + "integerList": { + "shape": "IntegerList" + }, + "booleanList": { + "shape": "BooleanList" + }, + "timestampList": { + "shape": "TimestampList" + }, + "enumList": { + "shape": "FooEnumList" + }, + "intEnumList": { + "shape": "IntegerEnumList" + }, + "nestedStringList": { + "shape": "NestedStringList" + }, + "structureList": { + "shape": "StructureList" + }, + "blobList": { + "shape": "BlobList" + } + } + }, + "StringList": { + "type": "list", + "member": { + "shape": "String" + } + }, + "StringSet": { + "type": "list", + "member": { + "shape": "String" + } + }, + "IntegerList": { + "type": "list", + "member": { + "shape": "Integer" + } + }, + "BooleanList": { + "type": "list", + "member": { + "shape": "Boolean" + } + }, + "TimestampList": { + "type": "list", + "member": { + "shape": "Timestamp" + } + }, + "FooEnumList": { + "type": "list", + "member": { + "shape": "FooEnum" + } + }, + "IntegerEnumList": { + "type": "list", + "member": { + "shape": "IntegerEnum" + } + }, + "NestedStringList": { + "type": "list", + "member": { + "shape": "StringList" + }, + "documentation": "A list of lists of strings.
" + }, + "StructureList": { + "type": "list", + "member": { + "shape": "StructureListMember" + } + }, + "BlobList": { + "type": "list", + "member": { + "shape": "Blob" + } + }, + "Blob": { + "type": "blob" + }, + "StructureListMember": { + "type": "structure", + "members": { + "a": { + "shape": "String" + }, + "b": { + "shape": "String" + } + } + }, + "String": { + "type": "string" + }, + "IntegerEnum": { + "type": "integer", + "box": true + }, + "FooEnum": { + "type": "string", + "enum": [ + "Foo", + "Baz", + "Bar", + "1", + "0" + ] + }, + "Timestamp": { + "type": "timestamp" + }, + "Boolean": { + "type": "boolean", + "box": true + }, + "Integer": { + "type": "integer", + "box": true + } + }, + "cases": [ + { + "id": "RpcV2CborLists", + "description": "Serializes RpcV2 Cbor lists", + "given": { + "name": "RpcV2CborLists", + "http": { + "method": "POST", + "requestUri": "/" + }, + "input": { + "shape": "RpcV2CborListInputOutput" + }, + "documentation": "This test case serializes JSON lists for the following cases for both input and output:
This test case serializes JSON lists for the following cases for both input and output:
This test case serializes JSON lists for the following cases for both input and output:
This error is thrown when an invalid greeting value is provided.
", + "exception": true + }, + "String": { + "type": "string" + } + }, + "cases": [ + { + "id": "RpcV2CborInvalidGreetingError", + "description": "Parses simple RpcV2 Cbor errors", + "given": { + "name": "GreetingWithErrors", + "http": { + "method": "POST", + "requestUri": "/" + }, + "documentation": "This operation has three possible return values:
Implementations must be able to successfully take a response and properly deserialize successful and error responses.
", + "idempotent": true, + "errors": [ + { + "shape": "InvalidGreeting" + } + ] + }, + "errorCode": "InvalidGreeting", + "errorMessage": "Hi", + "error": { + "Message": "Hi" + }, + "response": { + "status_code": 400, + "headers": { + "smithy-protocol": "rpc-v2-cbor", + "Content-Type": "application/cbor" + }, + "body": "v2ZfX3R5cGV4LnNtaXRoeS5wcm90b2NvbHRlc3RzLnJwY3YyQ2JvciNJbnZhbGlkR3JlZXRpbmdnTWVzc2FnZWJIaf8=" + } + } + ] + }, + { + "description": "Test cases for GreetingWithErrors operation", + "metadata": { + "apiVersion": "2020-07-14", + "auth": [ + "aws.auth#sigv4" + ], + "endpointPrefix": "rpcv2protocol", + "protocol": "smithy-rpc-v2-cbor", + "protocols": [ + "smithy-rpc-v2-cbor" + ], + "serviceFullName": "RpcV2Protocol", + "serviceId": "RpcV2Protocol", + "signatureVersion": "v4", + "signingName": "RpcV2Protocol", + "targetPrefix": "RpcV2Protocol", + "uid": "rpcv2protocol-2020-07-14" + }, + "shapes": { + "ComplexError": { + "type": "structure", + "members": { + "TopLevel": { + "shape": "String" + }, + "Nested": { + "shape": "ComplexNestedErrorData" + } + }, + "documentation": "This error is thrown when a request is invalid.
", + "exception": true + }, + "String": { + "type": "string" + }, + "ComplexNestedErrorData": { + "type": "structure", + "members": { + "Foo": { + "shape": "String" + } + } + } + }, + "cases": [ + { + "id": "RpcV2CborComplexError", + "description": "Parses a complex error with no message member", + "given": { + "name": "GreetingWithErrors", + "http": { + "method": "POST", + "requestUri": "/" + }, + "documentation": "This operation has three possible return values:
Implementations must be able to successfully take a response and properly deserialize successful and error responses.
", + "idempotent": true, + "errors": [ + { + "shape": "ComplexError" + } + ] + }, + "errorCode": "ComplexError", + "error": { + "TopLevel": "Top level", + "Nested": { + "Foo": "bar" + } + }, + "response": { + "status_code": 400, + "headers": { + "smithy-protocol": "rpc-v2-cbor", + "Content-Type": "application/cbor" + }, + "body": "v2ZfX3R5cGV4K3NtaXRoeS5wcm90b2NvbHRlc3RzLnJwY3YyQ2JvciNDb21wbGV4RXJyb3JoVG9wTGV2ZWxpVG9wIGxldmVsZk5lc3RlZL9jRm9vY2Jhcv//" + } + }, + { + "id": "RpcV2CborEmptyComplexError", + "given": { + "name": "GreetingWithErrors", + "http": { + "method": "POST", + "requestUri": "/" + }, + "documentation": "This operation has three possible return values:
Implementations must be able to successfully take a response and properly deserialize successful and error responses.
", + "idempotent": true, + "errors": [ + { + "shape": "ComplexError" + } + ] + }, + "errorCode": "ComplexError", + "error": {}, + "response": { + "status_code": 400, + "headers": { + "smithy-protocol": "rpc-v2-cbor", + "Content-Type": "application/cbor" + }, + "body": "v2ZfX3R5cGV4K3NtaXRoeS5wcm90b2NvbHRlc3RzLnJwY3YyQ2JvciNDb21wbGV4RXJyb3L/" + } + } + ] + }, + { + "description": "Test cases for NoInputOutput operation", + "metadata": { + "apiVersion": "2020-07-14", + "auth": [ + "aws.auth#sigv4" + ], + "endpointPrefix": "rpcv2protocol", + "protocol": "smithy-rpc-v2-cbor", + "protocols": [ + "smithy-rpc-v2-cbor" + ], + "serviceFullName": "RpcV2Protocol", + "serviceId": "RpcV2Protocol", + "signatureVersion": "v4", + "signingName": "RpcV2Protocol", + "targetPrefix": "RpcV2Protocol", + "uid": "rpcv2protocol-2020-07-14" + }, + "shapes": {}, + "cases": [ + { + "id": "no_output", + "description": "A `Content-Type` header should not be set if the response body is empty.", + "given": { + "name": "NoInputOutput", + "http": { + "method": "POST", + "requestUri": "/" + } + }, + "result": {}, + "response": { + "status_code": 200, + "headers": { + "smithy-protocol": "rpc-v2-cbor" + }, + "body": "" + } + }, + { + "id": "NoOutputClientAllowsEmptyCbor", + "description": "Clients should accept a CBOR empty struct if there is no output.", + "given": { + "name": "NoInputOutput", + "http": { + "method": "POST", + "requestUri": "/" + } + }, + "result": {}, + "response": { + "status_code": 200, + "headers": { + "smithy-protocol": "rpc-v2-cbor", + "Content-Type": "application/cbor" + }, + "body": "v/8=" + } + }, + { + "id": "NoOutputClientAllowsEmptyBody", + "description": "Clients should accept an empty body if there is no output and\nshould not raise an error if the `Content-Type` header is set.", + "given": { + "name": "NoInputOutput", + "http": { + "method": "POST", + "requestUri": "/" + } + }, + "result": {}, + "response": { + "status_code": 200, + "headers": { + "smithy-protocol": "rpc-v2-cbor", + "Content-Type": "application/cbor" + }, + "body": "" + } + } + ] + }, + { + "description": "Test cases for OptionalInputOutput operation", + "metadata": { + "apiVersion": "2020-07-14", + "auth": [ + "aws.auth#sigv4" + ], + "endpointPrefix": "rpcv2protocol", + "protocol": "smithy-rpc-v2-cbor", + "protocols": [ + "smithy-rpc-v2-cbor" + ], + "serviceFullName": "RpcV2Protocol", + "serviceId": "RpcV2Protocol", + "signatureVersion": "v4", + "signingName": "RpcV2Protocol", + "targetPrefix": "RpcV2Protocol", + "uid": "rpcv2protocol-2020-07-14" + }, + "shapes": { + "SimpleStructure": { + "type": "structure", + "members": { + "value": { + "shape": "String" + } + } + }, + "String": { + "type": "string" + } + }, + "cases": [ + { + "id": "optional_output", + "description": "When output is empty we write CBOR equivalent of {}", + "given": { + "name": "OptionalInputOutput", + "http": { + "method": "POST", + "requestUri": "/" + }, + "output": { + "shape": "SimpleStructure" + } + }, + "result": {}, + "response": { + "status_code": 200, + "headers": { + "smithy-protocol": "rpc-v2-cbor", + "Content-Type": "application/cbor" + }, + "body": "v/8=" + } + } + ] + }, + { + "description": "Test cases for RecursiveShapes operation", + "metadata": { + "apiVersion": "2020-07-14", + "auth": [ + "aws.auth#sigv4" + ], + "endpointPrefix": "rpcv2protocol", + "protocol": "smithy-rpc-v2-cbor", + "protocols": [ + "smithy-rpc-v2-cbor" + ], + "serviceFullName": "RpcV2Protocol", + "serviceId": "RpcV2Protocol", + "signatureVersion": "v4", + "signingName": "RpcV2Protocol", + "targetPrefix": "RpcV2Protocol", + "uid": "rpcv2protocol-2020-07-14" + }, + "shapes": { + "RecursiveShapesInputOutput": { + "type": "structure", + "members": { + "nested": { + "shape": "RecursiveShapesInputOutputNested1" + } + } + }, + "RecursiveShapesInputOutputNested1": { + "type": "structure", + "members": { + "foo": { + "shape": "String" + }, + "nested": { + "shape": "RecursiveShapesInputOutputNested2" + } + } + }, + "String": { + "type": "string" + }, + "RecursiveShapesInputOutputNested2": { + "type": "structure", + "members": { + "bar": { + "shape": "String" + }, + "recursiveMember": { + "shape": "RecursiveShapesInputOutputNested1" + } + } + } + }, + "cases": [ + { + "id": "RpcV2CborRecursiveShapes", + "description": "Serializes recursive structures", + "given": { + "name": "RecursiveShapes", + "http": { + "method": "POST", + "requestUri": "/" + }, + "output": { + "shape": "RecursiveShapesInputOutput" + } + }, + "result": { + "nested": { + "foo": "Foo1", + "nested": { + "bar": "Bar1", + "recursiveMember": { + "foo": "Foo2", + "nested": { + "bar": "Bar2" + } + } + } + } + }, + "response": { + "status_code": 200, + "headers": { + "smithy-protocol": "rpc-v2-cbor", + "Content-Type": "application/cbor" + }, + "body": "v2ZuZXN0ZWS/Y2Zvb2RGb28xZm5lc3RlZL9jYmFyZEJhcjFvcmVjdXJzaXZlTWVtYmVyv2Nmb29kRm9vMmZuZXN0ZWS/Y2JhcmRCYXIy//////8=" + } + }, + { + "id": "RpcV2CborRecursiveShapesUsingDefiniteLength", + "description": "Deserializes recursive structures encoded using a map with definite length", + "given": { + "name": "RecursiveShapes", + "http": { + "method": "POST", + "requestUri": "/" + }, + "output": { + "shape": "RecursiveShapesInputOutput" + } + }, + "result": { + "nested": { + "foo": "Foo1", + "nested": { + "bar": "Bar1", + "recursiveMember": { + "foo": "Foo2", + "nested": { + "bar": "Bar2" + } + } + } + } + }, + "response": { + "status_code": 200, + "headers": { + "smithy-protocol": "rpc-v2-cbor", + "Content-Type": "application/cbor" + }, + "body": "oWZuZXN0ZWSiY2Zvb2RGb28xZm5lc3RlZKJjYmFyZEJhcjFvcmVjdXJzaXZlTWVtYmVyomNmb29kRm9vMmZuZXN0ZWShY2JhcmRCYXIy" + } + } + ] + }, + { + "description": "Test cases for RpcV2CborDenseMaps operation", + "metadata": { + "apiVersion": "2020-07-14", + "auth": [ + "aws.auth#sigv4" + ], + "endpointPrefix": "rpcv2protocol", + "protocol": "smithy-rpc-v2-cbor", + "protocols": [ + "smithy-rpc-v2-cbor" + ], + "serviceFullName": "RpcV2Protocol", + "serviceId": "RpcV2Protocol", + "signatureVersion": "v4", + "signingName": "RpcV2Protocol", + "targetPrefix": "RpcV2Protocol", + "uid": "rpcv2protocol-2020-07-14" + }, + "shapes": { + "RpcV2CborDenseMapsInputOutput": { + "type": "structure", + "members": { + "denseStructMap": { + "shape": "DenseStructMap" + }, + "denseNumberMap": { + "shape": "DenseNumberMap" + }, + "denseBooleanMap": { + "shape": "DenseBooleanMap" + }, + "denseStringMap": { + "shape": "DenseStringMap" + }, + "denseSetMap": { + "shape": "DenseSetMap" + } + } + }, + "DenseStructMap": { + "type": "map", + "key": { + "shape": "String" + }, + "value": { + "shape": "GreetingStruct" + } + }, + "DenseNumberMap": { + "type": "map", + "key": { + "shape": "String" + }, + "value": { + "shape": "Integer" + } + }, + "DenseBooleanMap": { + "type": "map", + "key": { + "shape": "String" + }, + "value": { + "shape": "Boolean" + } + }, + "DenseStringMap": { + "type": "map", + "key": { + "shape": "String" + }, + "value": { + "shape": "String" + } + }, + "DenseSetMap": { + "type": "map", + "key": { + "shape": "String" + }, + "value": { + "shape": "StringSet" + } + }, + "StringSet": { + "type": "list", + "member": { + "shape": "String" + } + }, + "String": { + "type": "string" + }, + "Boolean": { + "type": "boolean", + "box": true + }, + "Integer": { + "type": "integer", + "box": true + }, + "GreetingStruct": { + "type": "structure", + "members": { + "hi": { + "shape": "String" + } + } + } + }, + "cases": [ + { + "id": "RpcV2CborMaps", + "description": "Deserializes maps", + "given": { + "name": "RpcV2CborDenseMaps", + "http": { + "method": "POST", + "requestUri": "/" + }, + "output": { + "shape": "RpcV2CborDenseMapsInputOutput" + }, + "documentation": "The example tests basic map serialization.
" + }, + "result": { + "denseStructMap": { + "foo": { + "hi": "there" + }, + "baz": { + "hi": "bye" + } + } + }, + "response": { + "status_code": 200, + "headers": { + "smithy-protocol": "rpc-v2-cbor", + "Content-Type": "application/cbor" + }, + "body": "oW5kZW5zZVN0cnVjdE1hcKJjZm9voWJoaWV0aGVyZWNiYXqhYmhpY2J5ZQ==" + } + }, + { + "id": "RpcV2CborDeserializesZeroValuesInMaps", + "description": "Ensure that 0 and false are sent over the wire in all maps and lists", + "given": { + "name": "RpcV2CborDenseMaps", + "http": { + "method": "POST", + "requestUri": "/" + }, + "output": { + "shape": "RpcV2CborDenseMapsInputOutput" + }, + "documentation": "The example tests basic map serialization.
" + }, + "result": { + "denseNumberMap": { + "x": 0 + }, + "denseBooleanMap": { + "x": false + } + }, + "response": { + "status_code": 200, + "headers": { + "smithy-protocol": "rpc-v2-cbor", + "Content-Type": "application/cbor" + }, + "body": "om5kZW5zZU51bWJlck1hcKFheABvZGVuc2VCb29sZWFuTWFwoWF49A==" + } + }, + { + "id": "RpcV2CborDeserializesDenseSetMap", + "description": "A response that contains a dense map of sets", + "given": { + "name": "RpcV2CborDenseMaps", + "http": { + "method": "POST", + "requestUri": "/" + }, + "output": { + "shape": "RpcV2CborDenseMapsInputOutput" + }, + "documentation": "The example tests basic map serialization.
" + }, + "result": { + "denseSetMap": { + "x": [], + "y": [ + "a", + "b" + ] + } + }, + "response": { + "status_code": 200, + "headers": { + "smithy-protocol": "rpc-v2-cbor", + "Content-Type": "application/cbor" + }, + "body": "oWtkZW5zZVNldE1hcKJheIBheYJhYWFi" + } + }, + { + "id": "RpcV2CborDeserializesDenseSetMapAndSkipsNull", + "description": "Clients SHOULD tolerate seeing a null value in a dense map, and they SHOULD\ndrop the null key-value pair.", + "given": { + "name": "RpcV2CborDenseMaps", + "http": { + "method": "POST", + "requestUri": "/" + }, + "output": { + "shape": "RpcV2CborDenseMapsInputOutput" + }, + "documentation": "The example tests basic map serialization.
" + }, + "result": { + "denseSetMap": { + "x": [], + "y": [ + "a", + "b" + ] + } + }, + "response": { + "status_code": 200, + "headers": { + "smithy-protocol": "rpc-v2-cbor", + "Content-Type": "application/cbor" + }, + "body": "oWtkZW5zZVNldE1hcKNheIBheYJhYWFiYXr2" + } + } + ] + }, + { + "description": "Test cases for RpcV2CborLists operation", + "metadata": { + "apiVersion": "2020-07-14", + "auth": [ + "aws.auth#sigv4" + ], + "endpointPrefix": "rpcv2protocol", + "protocol": "smithy-rpc-v2-cbor", + "protocols": [ + "smithy-rpc-v2-cbor" + ], + "serviceFullName": "RpcV2Protocol", + "serviceId": "RpcV2Protocol", + "signatureVersion": "v4", + "signingName": "RpcV2Protocol", + "targetPrefix": "RpcV2Protocol", + "uid": "rpcv2protocol-2020-07-14" + }, + "shapes": { + "RpcV2CborListInputOutput": { + "type": "structure", + "members": { + "stringList": { + "shape": "StringList" + }, + "stringSet": { + "shape": "StringSet" + }, + "integerList": { + "shape": "IntegerList" + }, + "booleanList": { + "shape": "BooleanList" + }, + "timestampList": { + "shape": "TimestampList" + }, + "enumList": { + "shape": "FooEnumList" + }, + "intEnumList": { + "shape": "IntegerEnumList" + }, + "nestedStringList": { + "shape": "NestedStringList" + }, + "structureList": { + "shape": "StructureList" + }, + "blobList": { + "shape": "BlobList" + } + } + }, + "StringList": { + "type": "list", + "member": { + "shape": "String" + } + }, + "StringSet": { + "type": "list", + "member": { + "shape": "String" + } + }, + "IntegerList": { + "type": "list", + "member": { + "shape": "Integer" + } + }, + "BooleanList": { + "type": "list", + "member": { + "shape": "Boolean" + } + }, + "TimestampList": { + "type": "list", + "member": { + "shape": "Timestamp" + } + }, + "FooEnumList": { + "type": "list", + "member": { + "shape": "FooEnum" + } + }, + "IntegerEnumList": { + "type": "list", + "member": { + "shape": "IntegerEnum" + } + }, + "NestedStringList": { + "type": "list", + "member": { + "shape": "StringList" + }, + "documentation": "A list of lists of strings.
" + }, + "StructureList": { + "type": "list", + "member": { + "shape": "StructureListMember" + } + }, + "BlobList": { + "type": "list", + "member": { + "shape": "Blob" + } + }, + "Blob": { + "type": "blob" + }, + "StructureListMember": { + "type": "structure", + "members": { + "a": { + "shape": "String" + }, + "b": { + "shape": "String" + } + } + }, + "String": { + "type": "string" + }, + "IntegerEnum": { + "type": "integer", + "box": true + }, + "FooEnum": { + "type": "string", + "enum": [ + "Foo", + "Baz", + "Bar", + "1", + "0" + ] + }, + "Timestamp": { + "type": "timestamp" + }, + "Boolean": { + "type": "boolean", + "box": true + }, + "Integer": { + "type": "integer", + "box": true + } + }, + "cases": [ + { + "id": "RpcV2CborLists", + "description": "Serializes RpcV2 Cbor lists", + "given": { + "name": "RpcV2CborLists", + "http": { + "method": "POST", + "requestUri": "/" + }, + "output": { + "shape": "RpcV2CborListInputOutput" + }, + "documentation": "This test case serializes JSON lists for the following cases for both input and output:
This test case serializes JSON lists for the following cases for both input and output:
This test case serializes JSON lists for the following cases for both input and output:
This test case serializes JSON lists for the following cases for both input and output: