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:

  1. Normal lists.
  2. Normal sets.
  3. Lists of lists.
  4. Lists of structures.
", + "idempotent": true + }, + "params": { + "stringList": [ + "foo", + "bar" + ], + "stringSet": [ + "foo", + "bar" + ], + "integerList": [ + 1, + 2 + ], + "booleanList": [ + true, + false + ], + "timestampList": [ + 1398796238, + 1398796238 + ], + "enumList": [ + "Foo", + "0" + ], + "intEnumList": [ + 1, + 2 + ], + "nestedStringList": [ + [ + "foo", + "bar" + ], + [ + "baz", + "qux" + ] + ], + "structureList": [ + { + "a": "1", + "b": "2" + }, + { + "a": "3", + "b": "4" + } + ], + "blobList": [ + "foo", + "bar" + ] + }, + "serialized": { + "method": "POST", + "uri": "/service/RpcV2Protocol/operation/RpcV2CborLists", + "body": "v2pzdHJpbmdMaXN0gmNmb29jYmFyaXN0cmluZ1NldIJjZm9vY2JhcmtpbnRlZ2VyTGlzdIIBAmtib29sZWFuTGlzdIL19G10aW1lc3RhbXBMaXN0gsH7QdTX+/OAAADB+0HU1/vzgAAAaGVudW1MaXN0gmNGb29hMGtpbnRFbnVtTGlzdIIBAnBuZXN0ZWRTdHJpbmdMaXN0goJjZm9vY2JhcoJjYmF6Y3F1eG1zdHJ1Y3R1cmVMaXN0gqJhYWExYWJhMqJhYWEzYWJhNGhibG9iTGlzdIJDZm9vQ2Jhcv8=", + "headers": { + "smithy-protocol": "rpc-v2-cbor", + "Accept": "application/cbor", + "Content-Type": "application/cbor" + }, + "requireHeaders": [ + "Content-Length" + ] + } + }, + { + "id": "RpcV2CborListsEmpty", + "description": "Serializes empty JSON 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:

  1. Normal lists.
  2. Normal sets.
  3. Lists of lists.
  4. Lists of structures.
", + "idempotent": true + }, + "params": { + "stringList": [] + }, + "serialized": { + "method": "POST", + "uri": "/service/RpcV2Protocol/operation/RpcV2CborLists", + "body": "v2pzdHJpbmdMaXN0n///", + "headers": { + "smithy-protocol": "rpc-v2-cbor", + "Accept": "application/cbor", + "Content-Type": "application/cbor" + }, + "requireHeaders": [ + "Content-Length" + ] + } + }, + { + "id": "RpcV2CborListsEmptyUsingDefiniteLength", + "description": "Serializes empty JSON definite length 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:

  1. Normal lists.
  2. Normal sets.
  3. Lists of lists.
  4. Lists of structures.
", + "idempotent": true + }, + "params": { + "stringList": [] + }, + "serialized": { + "method": "POST", + "uri": "/service/RpcV2Protocol/operation/RpcV2CborLists", + "body": "oWpzdHJpbmdMaXN0gA==", + "headers": { + "smithy-protocol": "rpc-v2-cbor", + "Accept": "application/cbor", + "Content-Type": "application/cbor" + }, + "requireHeaders": [ + "Content-Length" + ] + } + } + ] + }, + { + "description": "Test cases for SimpleScalarProperties 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": { + "SimpleScalarStructure": { + "type": "structure", + "members": { + "trueBooleanValue": { + "shape": "Boolean" + }, + "falseBooleanValue": { + "shape": "Boolean" + }, + "byteValue": { + "shape": "Integer" + }, + "doubleValue": { + "shape": "Double" + }, + "floatValue": { + "shape": "Float" + }, + "integerValue": { + "shape": "Integer" + }, + "longValue": { + "shape": "Long" + }, + "shortValue": { + "shape": "Integer" + }, + "stringValue": { + "shape": "String" + }, + "blobValue": { + "shape": "Blob" + } + } + }, + "Boolean": { + "type": "boolean", + "box": true + }, + "Integer": { + "type": "integer", + "box": true + }, + "Double": { + "type": "double", + "box": true + }, + "Float": { + "type": "float", + "box": true + }, + "Long": { + "type": "long", + "box": true + }, + "String": { + "type": "string" + }, + "Blob": { + "type": "blob" + } + }, + "cases": [ + { + "id": "RpcV2CborSimpleScalarProperties", + "description": "Serializes simple scalar properties", + "given": { + "name": "SimpleScalarProperties", + "http": { + "method": "POST", + "requestUri": "/" + }, + "input": { + "shape": "SimpleScalarStructure" + } + }, + "params": { + "byteValue": 5, + "doubleValue": 1.889, + "falseBooleanValue": false, + "floatValue": 7.625, + "integerValue": 256, + "longValue": 9873, + "shortValue": 9898, + "stringValue": "simple", + "trueBooleanValue": true, + "blobValue": "foo" + }, + "serialized": { + "method": "POST", + "uri": "/service/RpcV2Protocol/operation/SimpleScalarProperties", + "body": "v2lieXRlVmFsdWUFa2RvdWJsZVZhbHVl+z/+OVgQYk3TcWZhbHNlQm9vbGVhblZhbHVl9GpmbG9hdFZhbHVl+kD0AABsaW50ZWdlclZhbHVlGQEAaWxvbmdWYWx1ZRkmkWpzaG9ydFZhbHVlGSaqa3N0cmluZ1ZhbHVlZnNpbXBsZXB0cnVlQm9vbGVhblZhbHVl9WlibG9iVmFsdWVDZm9v/w==", + "headers": { + "smithy-protocol": "rpc-v2-cbor", + "Accept": "application/cbor", + "Content-Type": "application/cbor" + }, + "requireHeaders": [ + "Content-Length" + ] + } + }, + { + "id": "RpcV2CborClientDoesntSerializeNullStructureValues", + "description": "RpcV2 Cbor should not serialize null structure values", + "given": { + "name": "SimpleScalarProperties", + "http": { + "method": "POST", + "requestUri": "/" + }, + "input": { + "shape": "SimpleScalarStructure" + } + }, + "params": { + "stringValue": null + }, + "serialized": { + "method": "POST", + "uri": "/service/RpcV2Protocol/operation/SimpleScalarProperties", + "body": "v/8=", + "headers": { + "smithy-protocol": "rpc-v2-cbor", + "Accept": "application/cbor", + "Content-Type": "application/cbor" + }, + "requireHeaders": [ + "Content-Length" + ] + } + }, + { + "id": "RpcV2CborSupportsNaNFloatInputs", + "description": "Supports handling NaN float values.", + "given": { + "name": "SimpleScalarProperties", + "http": { + "method": "POST", + "requestUri": "/" + }, + "input": { + "shape": "SimpleScalarStructure" + } + }, + "params": { + "doubleValue": "NaN", + "floatValue": "NaN" + }, + "serialized": { + "method": "POST", + "uri": "/service/RpcV2Protocol/operation/SimpleScalarProperties", + "body": "v2tkb3VibGVWYWx1Zft/+AAAAAAAAGpmbG9hdFZhbHVl+n/AAAD/", + "headers": { + "smithy-protocol": "rpc-v2-cbor", + "Accept": "application/cbor", + "Content-Type": "application/cbor" + }, + "requireHeaders": [ + "Content-Length" + ] + } + }, + { + "id": "RpcV2CborSupportsInfinityFloatInputs", + "description": "Supports handling Infinity float values.", + "given": { + "name": "SimpleScalarProperties", + "http": { + "method": "POST", + "requestUri": "/" + }, + "input": { + "shape": "SimpleScalarStructure" + } + }, + "params": { + "doubleValue": "Infinity", + "floatValue": "Infinity" + }, + "serialized": { + "method": "POST", + "uri": "/service/RpcV2Protocol/operation/SimpleScalarProperties", + "body": "v2tkb3VibGVWYWx1Zft/8AAAAAAAAGpmbG9hdFZhbHVl+n+AAAD/", + "headers": { + "smithy-protocol": "rpc-v2-cbor", + "Accept": "application/cbor", + "Content-Type": "application/cbor" + }, + "requireHeaders": [ + "Content-Length" + ] + } + }, + { + "id": "RpcV2CborSupportsNegativeInfinityFloatInputs", + "description": "Supports handling Infinity float values.", + "given": { + "name": "SimpleScalarProperties", + "http": { + "method": "POST", + "requestUri": "/" + }, + "input": { + "shape": "SimpleScalarStructure" + } + }, + "params": { + "doubleValue": "-Infinity", + "floatValue": "-Infinity" + }, + "serialized": { + "method": "POST", + "uri": "/service/RpcV2Protocol/operation/SimpleScalarProperties", + "body": "v2tkb3VibGVWYWx1Zfv/8AAAAAAAAGpmbG9hdFZhbHVl+v+AAAD/", + "headers": { + "smithy-protocol": "rpc-v2-cbor", + "Accept": "application/cbor", + "Content-Type": "application/cbor" + }, + "requireHeaders": [ + "Content-Length" + ] + } + } + ] + } +] diff --git a/tests/Api/test_cases/protocols/output/rpc-v2-cbor-query-compatible.json b/tests/Api/test_cases/protocols/output/rpc-v2-cbor-query-compatible.json new file mode 100644 index 0000000000..0eeeb82c7d --- /dev/null +++ b/tests/Api/test_cases/protocols/output/rpc-v2-cbor-query-compatible.json @@ -0,0 +1,142 @@ +[ + { + "description": "Test cases for QueryCompatibleOperation operation", + "metadata": { + "apiVersion": "2025-06-20", + "auth": [ + "aws.auth#sigv4" + ], + "awsQueryCompatible": {}, + "endpointPrefix": "querycompatiblerpcv2protocol", + "protocol": "smithy-rpc-v2-cbor", + "protocols": [ + "smithy-rpc-v2-cbor" + ], + "serviceFullName": "QueryCompatibleRpcV2Protocol", + "serviceId": "Query Compatible RpcV2 Protocol", + "signatureVersion": "v4", + "signingName": "QueryCompatibleRpcV2Protocol", + "targetPrefix": "QueryCompatibleRpcV2Protocol", + "uid": "query-compatible-rpcv2-protocol-2025-06-20" + }, + "shapes": { + "NoCustomCodeError": { + "type": "structure", + "members": { + "message": { + "shape": "String" + } + }, + "exception": true + }, + "String": { + "type": "string" + } + }, + "cases": [ + { + "id": "QueryCompatibleRpcV2CborNoCustomCodeError", + "description": "Parses simple RpcV2 CBOR errors with no query error code", + "given": { + "name": "QueryCompatibleOperation", + "http": { + "method": "POST", + "requestUri": "/" + }, + "idempotent": true, + "errors": [ + { + "shape": "NoCustomCodeError" + } + ] + }, + "errorCode": "NoCustomCodeError", + "errorMessage": "Hi", + "error": { + "message": "Hi" + }, + "response": { + "status_code": 400, + "headers": { + "smithy-protocol": "rpc-v2-cbor", + "Content-Type": "application/cbor" + }, + "body": "uQACZl9fdHlwZXgtYXdzLnByb3RvY29sdGVzdHMucnBjdjJjYm9yI05vQ3VzdG9tQ29kZUVycm9yZ21lc3NhZ2ViSGk=" + } + } + ] + }, + { + "description": "Test cases for QueryCompatibleOperation operation", + "metadata": { + "apiVersion": "2025-06-20", + "auth": [ + "aws.auth#sigv4" + ], + "awsQueryCompatible": {}, + "endpointPrefix": "querycompatiblerpcv2protocol", + "protocol": "smithy-rpc-v2-cbor", + "protocols": [ + "smithy-rpc-v2-cbor" + ], + "serviceFullName": "QueryCompatibleRpcV2Protocol", + "serviceId": "Query Compatible RpcV2 Protocol", + "signatureVersion": "v4", + "signingName": "QueryCompatibleRpcV2Protocol", + "targetPrefix": "QueryCompatibleRpcV2Protocol", + "uid": "query-compatible-rpcv2-protocol-2025-06-20" + }, + "shapes": { + "CustomCodeError": { + "type": "structure", + "members": { + "message": { + "shape": "String" + } + }, + "error": { + "code": "Customized", + "httpStatusCode": 402, + "senderFault": true + }, + "exception": true + }, + "String": { + "type": "string" + } + }, + "cases": [ + { + "id": "QueryCompatibleRpcV2CborCustomCodeError", + "description": "Parses simple RpcV2 CBOR errors with query error code", + "given": { + "name": "QueryCompatibleOperation", + "http": { + "method": "POST", + "requestUri": "/" + }, + "idempotent": true, + "errors": [ + { + "shape": "CustomCodeError" + } + ] + }, + "errorCode": "Customized", + "errorMessage": "Hi", + "error": { + "message": "Hi" + }, + "response": { + "status_code": 400, + "headers": { + "x-amzn-query-error": "Customized;Sender", + "smithy-protocol": "rpc-v2-cbor", + "Content-Type": "application/cbor" + }, + "body": "uQACZl9fdHlwZXgrYXdzLnByb3RvY29sdGVzdHMucnBjdjJjYm9yI0N1c3RvbUNvZGVFcnJvcmdtZXNzYWdlYkhp" + } + } + ] + } +] diff --git a/tests/Api/test_cases/protocols/output/rpc-v2-cbor.json b/tests/Api/test_cases/protocols/output/rpc-v2-cbor.json new file mode 100644 index 0000000000..487dfc5513 --- /dev/null +++ b/tests/Api/test_cases/protocols/output/rpc-v2-cbor.json @@ -0,0 +1,1629 @@ +[ + { + "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_output", + "description": "When output structure is empty we write CBOR equivalent of {}", + "given": { + "name": "EmptyInputOutput", + "http": { + "method": "POST", + "requestUri": "/" + }, + "output": { + "shape": "EmptyStructure" + } + }, + "result": {}, + "response": { + "status_code": 200, + "headers": { + "smithy-protocol": "rpc-v2-cbor", + "Content-Type": "application/cbor" + }, + "body": "v/8=" + } + }, + { + "id": "empty_output_no_body", + "description": "When output structure is empty the client should accept an empty body", + "given": { + "name": "EmptyInputOutput", + "http": { + "method": "POST", + "requestUri": "/" + }, + "output": { + "shape": "EmptyStructure" + } + }, + "result": {}, + "response": { + "status_code": 200, + "headers": { + "smithy-protocol": "rpc-v2-cbor", + "Content-Type": "application/cbor" + }, + "body": "" + } + } + ] + }, + { + "description": "Test cases for Float16 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": { + "Float16Output": { + "type": "structure", + "members": { + "value": { + "shape": "Double" + } + } + }, + "Double": { + "type": "double", + "box": true + } + }, + "cases": [ + { + "id": "RpcV2CborFloat16Inf", + "description": "Ensures that clients can correctly parse float16 +Inf.", + "given": { + "name": "Float16", + "http": { + "method": "POST", + "requestUri": "/" + }, + "output": { + "shape": "Float16Output" + } + }, + "result": { + "value": "Infinity" + }, + "response": { + "status_code": 200, + "headers": { + "smithy-protocol": "rpc-v2-cbor", + "Content-Type": "application/cbor" + }, + "body": "oWV2YWx1Zfl8AA==" + } + }, + { + "id": "RpcV2CborFloat16NegInf", + "description": "Ensures that clients can correctly parse float16 -Inf.", + "given": { + "name": "Float16", + "http": { + "method": "POST", + "requestUri": "/" + }, + "output": { + "shape": "Float16Output" + } + }, + "result": { + "value": "-Infinity" + }, + "response": { + "status_code": 200, + "headers": { + "smithy-protocol": "rpc-v2-cbor", + "Content-Type": "application/cbor" + }, + "body": "oWV2YWx1Zfn8AA==" + } + }, + { + "id": "RpcV2CborFloat16LSBNaN", + "description": "Ensures that clients can correctly parse float16 NaN with high LSB.", + "given": { + "name": "Float16", + "http": { + "method": "POST", + "requestUri": "/" + }, + "output": { + "shape": "Float16Output" + } + }, + "result": { + "value": "NaN" + }, + "response": { + "status_code": 200, + "headers": { + "smithy-protocol": "rpc-v2-cbor", + "Content-Type": "application/cbor" + }, + "body": "oWV2YWx1Zfl8AQ==" + } + }, + { + "id": "RpcV2CborFloat16MSBNaN", + "description": "Ensures that clients can correctly parse float16 NaN with high MSB.", + "given": { + "name": "Float16", + "http": { + "method": "POST", + "requestUri": "/" + }, + "output": { + "shape": "Float16Output" + } + }, + "result": { + "value": "NaN" + }, + "response": { + "status_code": 200, + "headers": { + "smithy-protocol": "rpc-v2-cbor", + "Content-Type": "application/cbor" + }, + "body": "oWV2YWx1Zfl+AA==" + } + }, + { + "id": "RpcV2CborFloat16Subnormal", + "description": "Ensures that clients can correctly parse a subnormal float16.", + "given": { + "name": "Float16", + "http": { + "method": "POST", + "requestUri": "/" + }, + "output": { + "shape": "Float16Output" + } + }, + "result": { + "value": 4.76837158203125E-6 + }, + "response": { + "status_code": 200, + "headers": { + "smithy-protocol": "rpc-v2-cbor", + "Content-Type": "application/cbor" + }, + "body": "oWV2YWx1ZfkAUA==" + } + } + ] + }, + { + "description": "Test cases for FractionalSeconds 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": { + "FractionalSecondsOutput": { + "type": "structure", + "members": { + "datetime": { + "shape": "DateTime" + } + } + }, + "DateTime": { + "type": "timestamp", + "timestampFormat": "iso8601" + } + }, + "cases": [ + { + "id": "RpcV2CborDateTimeWithFractionalSeconds", + "description": "Ensures that clients can correctly parse timestamps with fractional seconds", + "given": { + "name": "FractionalSeconds", + "http": { + "method": "POST", + "requestUri": "/" + }, + "output": { + "shape": "FractionalSecondsOutput" + } + }, + "result": { + "datetime": 9.46845296123E8 + }, + "response": { + "status_code": 200, + "headers": { + "smithy-protocol": "rpc-v2-cbor", + "Content-Type": "application/cbor" + }, + "body": "v2hkYXRldGltZcH7Qcw32zgPvnf/" + } + } + ] + }, + { + "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": { + "InvalidGreeting": { + "type": "structure", + "members": { + "Message": { + "shape": "String" + } + }, + "documentation": "

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:

  1. A successful response in the form of GreetingWithErrorsOutput
  2. An InvalidGreeting error.
  3. A ComplexError error.

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:

  1. A successful response in the form of GreetingWithErrorsOutput
  2. An InvalidGreeting error.
  3. A ComplexError error.

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:

  1. A successful response in the form of GreetingWithErrorsOutput
  2. An InvalidGreeting error.
  3. A ComplexError error.

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:

  1. Normal lists.
  2. Normal sets.
  3. Lists of lists.
  4. Lists of structures.
", + "idempotent": true + }, + "result": { + "stringList": [ + "foo", + "bar" + ], + "stringSet": [ + "foo", + "bar" + ], + "integerList": [ + 1, + 2 + ], + "booleanList": [ + true, + false + ], + "timestampList": [ + 1398796238, + 1398796238 + ], + "enumList": [ + "Foo", + "0" + ], + "intEnumList": [ + 1, + 2 + ], + "nestedStringList": [ + [ + "foo", + "bar" + ], + [ + "baz", + "qux" + ] + ], + "structureList": [ + { + "a": "1", + "b": "2" + }, + { + "a": "3", + "b": "4" + } + ], + "blobList": [ + "foo", + "bar" + ] + }, + "response": { + "status_code": 200, + "headers": { + "smithy-protocol": "rpc-v2-cbor", + "Content-Type": "application/cbor" + }, + "body": "v2pzdHJpbmdMaXN0n2Nmb29jYmFy/2lzdHJpbmdTZXSfY2Zvb2NiYXL/a2ludGVnZXJMaXN0nwEC/2tib29sZWFuTGlzdJ/19P9tdGltZXN0YW1wTGlzdJ/B+0HU1/vzgAAAwftB1Nf784AAAP9oZW51bUxpc3SfY0Zvb2Ew/2tpbnRFbnVtTGlzdJ8BAv9wbmVzdGVkU3RyaW5nTGlzdJ+fY2Zvb2NiYXL/n2NiYXpjcXV4//9tc3RydWN0dXJlTGlzdJ+/YWFhMWFiYTL/v2FhYTNhYmE0//9oYmxvYkxpc3SfQ2Zvb0NiYXL//w==" + } + }, + { + "id": "RpcV2CborListsEmpty", + "description": "Serializes empty 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:

  1. Normal lists.
  2. Normal sets.
  3. Lists of lists.
  4. Lists of structures.
", + "idempotent": true + }, + "result": { + "stringList": [] + }, + "response": { + "status_code": 200, + "headers": { + "smithy-protocol": "rpc-v2-cbor", + "Content-Type": "application/cbor" + }, + "body": "v2pzdHJpbmdMaXN0n///" + } + }, + { + "id": "RpcV2CborIndefiniteStringInsideIndefiniteListCanDeserialize", + "description": "Can deserialize indefinite length text strings inside an indefinite length list", + "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:

  1. Normal lists.
  2. Normal sets.
  3. Lists of lists.
  4. Lists of structures.
", + "idempotent": true + }, + "result": { + "stringList": [ + "An example indefinite string, which will be chunked, on each comma", + "Another example indefinite string with only one chunk", + "This is a plain string" + ] + }, + "response": { + "status_code": 200, + "headers": { + "smithy-protocol": "rpc-v2-cbor", + "Content-Type": "application/cbor" + }, + "body": "v2pzdHJpbmdMaXN0n394HUFuIGV4YW1wbGUgaW5kZWZpbml0ZSBzdHJpbmcsdyB3aGljaCB3aWxsIGJlIGNodW5rZWQsbiBvbiBlYWNoIGNvbW1h/394NUFub3RoZXIgZXhhbXBsZSBpbmRlZmluaXRlIHN0cmluZyB3aXRoIG9ubHkgb25lIGNodW5r/3ZUaGlzIGlzIGEgcGxhaW4gc3RyaW5n//8=" + } + }, + { + "id": "RpcV2CborIndefiniteStringInsideDefiniteListCanDeserialize", + "description": "Can deserialize indefinite length text strings inside a definite length list", + "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:

  1. Normal lists.
  2. Normal sets.
  3. Lists of lists.
  4. Lists of structures.
", + "idempotent": true + }, + "result": { + "stringList": [ + "An example indefinite string, which will be chunked, on each comma", + "Another example indefinite string with only one chunk", + "This is a plain string" + ] + }, + "response": { + "status_code": 200, + "headers": { + "smithy-protocol": "rpc-v2-cbor", + "Content-Type": "application/cbor" + }, + "body": "oWpzdHJpbmdMaXN0g394HUFuIGV4YW1wbGUgaW5kZWZpbml0ZSBzdHJpbmcsdyB3aGljaCB3aWxsIGJlIGNodW5rZWQsbiBvbiBlYWNoIGNvbW1h/394NUFub3RoZXIgZXhhbXBsZSBpbmRlZmluaXRlIHN0cmluZyB3aXRoIG9ubHkgb25lIGNodW5r/3ZUaGlzIGlzIGEgcGxhaW4gc3RyaW5n" + } + } + ] + }, + { + "description": "Test cases for SimpleScalarProperties 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": { + "SimpleScalarStructure": { + "type": "structure", + "members": { + "trueBooleanValue": { + "shape": "Boolean" + }, + "falseBooleanValue": { + "shape": "Boolean" + }, + "byteValue": { + "shape": "Integer" + }, + "doubleValue": { + "shape": "Double" + }, + "floatValue": { + "shape": "Float" + }, + "integerValue": { + "shape": "Integer" + }, + "longValue": { + "shape": "Long" + }, + "shortValue": { + "shape": "Integer" + }, + "stringValue": { + "shape": "String" + }, + "blobValue": { + "shape": "Blob" + } + } + }, + "Boolean": { + "type": "boolean", + "box": true + }, + "Integer": { + "type": "integer", + "box": true + }, + "Double": { + "type": "double", + "box": true + }, + "Float": { + "type": "float", + "box": true + }, + "Long": { + "type": "long", + "box": true + }, + "String": { + "type": "string" + }, + "Blob": { + "type": "blob" + } + }, + "cases": [ + { + "id": "RpcV2CborSimpleScalarProperties", + "description": "Serializes simple scalar properties", + "given": { + "name": "SimpleScalarProperties", + "http": { + "method": "POST", + "requestUri": "/" + }, + "output": { + "shape": "SimpleScalarStructure" + } + }, + "result": { + "trueBooleanValue": true, + "falseBooleanValue": false, + "byteValue": 5, + "doubleValue": 1.889, + "floatValue": 7.625, + "integerValue": 256, + "shortValue": 9898, + "stringValue": "simple", + "blobValue": "foo" + }, + "response": { + "status_code": 200, + "headers": { + "smithy-protocol": "rpc-v2-cbor", + "Content-Type": "application/cbor" + }, + "body": "v3B0cnVlQm9vbGVhblZhbHVl9XFmYWxzZUJvb2xlYW5WYWx1ZfRpYnl0ZVZhbHVlBWtkb3VibGVWYWx1Zfs//jlYEGJN02pmbG9hdFZhbHVl+kD0AABsaW50ZWdlclZhbHVlGQEAanNob3J0VmFsdWUZJqprc3RyaW5nVmFsdWVmc2ltcGxlaWJsb2JWYWx1ZUNmb2//" + } + }, + { + "id": "RpcV2CborSimpleScalarPropertiesUsingDefiniteLength", + "description": "Deserializes simple scalar properties encoded using a map with definite length", + "given": { + "name": "SimpleScalarProperties", + "http": { + "method": "POST", + "requestUri": "/" + }, + "output": { + "shape": "SimpleScalarStructure" + } + }, + "result": { + "trueBooleanValue": true, + "falseBooleanValue": false, + "byteValue": 5, + "doubleValue": 1.889, + "floatValue": 7.625, + "integerValue": 256, + "shortValue": 9898, + "stringValue": "simple", + "blobValue": "foo" + }, + "response": { + "status_code": 200, + "headers": { + "smithy-protocol": "rpc-v2-cbor", + "Content-Type": "application/cbor" + }, + "body": "qXB0cnVlQm9vbGVhblZhbHVl9XFmYWxzZUJvb2xlYW5WYWx1ZfRpYnl0ZVZhbHVlBWtkb3VibGVWYWx1Zfs//jlYEGJN02pmbG9hdFZhbHVl+kD0AABsaW50ZWdlclZhbHVlGQEAanNob3J0VmFsdWUZJqprc3RyaW5nVmFsdWVmc2ltcGxlaWJsb2JWYWx1ZUNmb28=" + } + }, + { + "id": "RpcV2CborClientDoesntDeserializeNullStructureValues", + "description": "RpcV2 Cbor should not deserialize null structure values", + "given": { + "name": "SimpleScalarProperties", + "http": { + "method": "POST", + "requestUri": "/" + }, + "output": { + "shape": "SimpleScalarStructure" + } + }, + "result": {}, + "response": { + "status_code": 200, + "headers": { + "smithy-protocol": "rpc-v2-cbor", + "Content-Type": "application/cbor" + }, + "body": "v2tzdHJpbmdWYWx1Zfb/" + } + }, + { + "id": "RpcV2CborSupportsNaNFloatOutputs", + "description": "Supports handling NaN float values.", + "given": { + "name": "SimpleScalarProperties", + "http": { + "method": "POST", + "requestUri": "/" + }, + "output": { + "shape": "SimpleScalarStructure" + } + }, + "result": { + "doubleValue": "NaN", + "floatValue": "NaN" + }, + "response": { + "status_code": 200, + "headers": { + "smithy-protocol": "rpc-v2-cbor", + "Content-Type": "application/cbor" + }, + "body": "v2tkb3VibGVWYWx1Zft/+AAAAAAAAGpmbG9hdFZhbHVl+n/AAAD/" + } + }, + { + "id": "RpcV2CborSupportsInfinityFloatOutputs", + "description": "Supports handling Infinity float values.", + "given": { + "name": "SimpleScalarProperties", + "http": { + "method": "POST", + "requestUri": "/" + }, + "output": { + "shape": "SimpleScalarStructure" + } + }, + "result": { + "doubleValue": "Infinity", + "floatValue": "Infinity" + }, + "response": { + "status_code": 200, + "headers": { + "smithy-protocol": "rpc-v2-cbor", + "Content-Type": "application/cbor" + }, + "body": "v2tkb3VibGVWYWx1Zft/8AAAAAAAAGpmbG9hdFZhbHVl+n+AAAD/" + } + }, + { + "id": "RpcV2CborSupportsNegativeInfinityFloatOutputs", + "description": "Supports handling Negative Infinity float values.", + "given": { + "name": "SimpleScalarProperties", + "http": { + "method": "POST", + "requestUri": "/" + }, + "output": { + "shape": "SimpleScalarStructure" + } + }, + "result": { + "doubleValue": "-Infinity", + "floatValue": "-Infinity" + }, + "response": { + "status_code": 200, + "headers": { + "smithy-protocol": "rpc-v2-cbor", + "Content-Type": "application/cbor" + }, + "body": "v2tkb3VibGVWYWx1Zfv/8AAAAAAAAGpmbG9hdFZhbHVl+v+AAAD/" + } + }, + { + "id": "RpcV2CborSupportsUpcastingDataOnDeserialize", + "description": "Supports upcasting from a smaller byte representation of the same data type.", + "given": { + "name": "SimpleScalarProperties", + "http": { + "method": "POST", + "requestUri": "/" + }, + "output": { + "shape": "SimpleScalarStructure" + } + }, + "result": { + "doubleValue": 1.5, + "floatValue": 7.625, + "integerValue": 56, + "longValue": 256, + "shortValue": 10 + }, + "response": { + "status_code": 200, + "headers": { + "smithy-protocol": "rpc-v2-cbor", + "Content-Type": "application/cbor" + }, + "body": "v2tkb3VibGVWYWx1Zfk+AGpmbG9hdFZhbHVl+UegbGludGVnZXJWYWx1ZRg4aWxvbmdWYWx1ZRkBAGpzaG9ydFZhbHVlCv8=" + } + }, + { + "id": "RpcV2CborExtraFieldsInTheBodyShouldBeSkippedByClients", + "description": "The client should skip over additional fields that are not part of the structure. This allows a\nclient generated against an older Smithy model to be able to communicate with a server that is\ngenerated against a newer Smithy model.", + "given": { + "name": "SimpleScalarProperties", + "http": { + "method": "POST", + "requestUri": "/" + }, + "output": { + "shape": "SimpleScalarStructure" + } + }, + "result": { + "byteValue": 5, + "doubleValue": 1.889, + "falseBooleanValue": false, + "floatValue": 7.625, + "integerValue": 256, + "longValue": 9873, + "shortValue": 9898, + "stringValue": "simple", + "trueBooleanValue": true, + "blobValue": "foo" + }, + "response": { + "status_code": 200, + "headers": { + "smithy-protocol": "rpc-v2-cbor", + "Content-Type": "application/cbor" + }, + "body": "v2lieXRlVmFsdWUFa2RvdWJsZVZhbHVl+z/+OVgQYk3TcWZhbHNlQm9vbGVhblZhbHVl9GpmbG9hdFZhbHVl+kD0AABrZXh0cmFPYmplY3S/c2luZGVmaW5pdGVMZW5ndGhNYXC/a3dpdGhBbkFycmF5nwECA///cWRlZmluaXRlTGVuZ3RoTWFwo3J3aXRoQURlZmluaXRlQXJyYXmDAQIDeB1hbmRTb21lSW5kZWZpbml0ZUxlbmd0aFN0cmluZ3gfdGhhdCBoYXMsIGJlZW4gY2h1bmtlZCBvbiBjb21tYWxub3JtYWxTdHJpbmdjZm9vanNob3J0VmFsdWUZJw9uc29tZU90aGVyRmllbGR2dGhpcyBzaG91bGQgYmUgc2tpcHBlZP9saW50ZWdlclZhbHVlGQEAaWxvbmdWYWx1ZRkmkWpzaG9ydFZhbHVlGSaqa3N0cmluZ1ZhbHVlZnNpbXBsZXB0cnVlQm9vbGVhblZhbHVl9WlibG9iVmFsdWVDZm9v/w==" + } + } + ] + } +] diff --git a/tests/Cbor/CborDecoderTest.php b/tests/Cbor/CborDecoderTest.php new file mode 100644 index 0000000000..f81f16200d --- /dev/null +++ b/tests/Cbor/CborDecoderTest.php @@ -0,0 +1,690 @@ +decoder = new CborDecoder(); + } + + /** + * Generate CBOR for a map with numeric keys 0 to n-1, all with value 0 + */ + private static function generateMapCbor(int $count): string + { + if ($count < 24) { + $cbor = chr(0xA0 | $count); + } elseif ($count < 256) { + $cbor = "\xB8" . chr($count); + } elseif ($count < 65536) { + $cbor = "\xB9" . pack('n', $count); + } else { + $cbor = "\xBA" . pack('N', $count); + } + + for ($i = 0; $i < $count; $i++) { + // Encode key + if ($i < 24) { + $cbor .= chr($i); + } elseif ($i < 256) { + $cbor .= "\x18" . chr($i); + } else { + $cbor .= "\x19" . pack('n', $i); + } + // Value is always 0 + $cbor .= "\x00"; + } + + return $cbor; + } + + /** + * @dataProvider simpleValuesProvider + */ + public function testDecodeSimpleValues(string $cbor, mixed $expected): void + { + $this->assertSame($expected, $this->decoder->decode($cbor)); + } + + public static function simpleValuesProvider(): array + { + return [ + 'null' => ["\xF6", null], + 'null-undefined' => ["\xF7", null], // undefined also decodes to null + 'true' => ["\xF5", true], + 'false' => ["\xF4", false], + ]; + } + + /** + * @dataProvider unsignedIntegerProvider + */ + public function testDecodeUnsignedInteger(string $cbor, int $expected): void + { + $this->assertSame($expected, $this->decoder->decode($cbor)); + } + + public static function unsignedIntegerProvider(): array + { + return [ + '0' => ["\x00", 0], + '1' => ["\x01", 1], + '10' => ["\x0A", 10], + '23' => ["\x17", 23], + '24' => ["\x18\x18", 24], + '255' => ["\x18\xFF", 255], + '256' => ["\x19\x01\x00", 256], + '65535' => ["\x19\xFF\xFF", 65535], + '65536' => ["\x1A\x00\x01\x00\x00", 65536], + '4294967295' => ["\x1A\xFF\xFF\xFF\xFF", 4294967295], + '4294967296' => ["\x1B\x00\x00\x00\x01\x00\x00\x00\x00", 4294967296], + 'max-int64' => ["\x1B\x7F\xFF\xFF\xFF\xFF\xFF\xFF\xFF", PHP_INT_MAX], + ]; + } + + /** + * @dataProvider negativeIntegerProvider + */ + public function testDecodeNegativeInteger(string $cbor, int $expected): void + { + $this->assertSame($expected, $this->decoder->decode($cbor)); + } + + public static function negativeIntegerProvider(): array + { + return [ + '-1' => ["\x20", -1], + '-10' => ["\x29", -10], + '-24' => ["\x37", -24], + '-25' => ["\x38\x18", -25], + '-256' => ["\x38\xFF", -256], + '-257' => ["\x39\x01\x00", -257], + '-65536' => ["\x39\xFF\xFF", -65536], + '-65537' => ["\x3A\x00\x01\x00\x00", -65537], + '-4294967296' => ["\x3A\xFF\xFF\xFF\xFF", -4294967296], + '-4294967297' => ["\x3B\x00\x00\x00\x01\x00\x00\x00\x00", -4294967297], + ]; + } + + /** + * @dataProvider floatProvider + */ + public function testDecodeFloat(string $cbor, float $expected): void + { + $result = $this->decoder->decode($cbor); + if (is_nan($expected)) { + $this->assertTrue(is_nan($result)); + } else { + $this->assertSame($expected, $result); + } + } + + public static function floatProvider(): array + { + return [ + 'half-zero' => ["\xF9\x00\x00", 0.0], + 'half-one' => ["\xF9\x3C\x00", 1.0], + 'half-minus-one' => ["\xF9\xBC\x00", -1.0], + 'half-infinity' => ["\xF9\x7C\x00", INF], + 'half-neg-infinity' => ["\xF9\xFC\x00", -INF], + 'half-nan' => ["\xF9\x7E\x00", NAN], + 'single-zero' => ["\xFA\x00\x00\x00\x00", 0.0], + 'single-1.5' => ["\xFA\x3F\xC0\x00\x00", 1.5], + 'single-minus-4.25' => ["\xFA\xC0\x88\x00\x00", -4.25], + 'double-pi' => ["\xFB\x40\x09\x21\xFB\x54\x44\x2D\x18", 3.141592653589793], + 'double-infinity' => ["\xFB\x7F\xF0\x00\x00\x00\x00\x00\x00", INF], + 'double-neg-infinity' => ["\xFB\xFF\xF0\x00\x00\x00\x00\x00\x00", -INF], + 'double-nan' => ["\xFB\x7F\xF8\x00\x00\x00\x00\x00\x00", NAN], + ]; + } + + /** + * @dataProvider stringProvider + */ + public function testDecodeString(string $cbor, string $expected): void + { + $this->assertSame($expected, $this->decoder->decode($cbor)); + } + + public static function stringProvider(): array + { + return [ + 'empty' => ["\x60", ''], + 'single-char' => ["\x61a", 'a'], + 'hello' => ["\x65hello", 'hello'], + '23-chars' => ["\x77" . str_repeat('x', 23), str_repeat('x', 23)], + '24-chars' => ["\x78\x18" . str_repeat('y', 24), str_repeat('y', 24)], + '255-chars' => ["\x78\xFF" . str_repeat('z', 255), str_repeat('z', 255)], + '256-chars' => ["\x79\x01\x00" . str_repeat('a', 256), str_repeat('a', 256)], + '65535-chars' => ["\x79\xFF\xFF" . str_repeat('b', 65535), str_repeat('b', 65535)], + '65536-chars' => ["\x7A\x00\x01\x00\x00" . str_repeat('c', 65536), str_repeat('c', 65536)], + 'unicode' => ["\x6CHello 世界", 'Hello 世界'], + ]; + } + + /** + * @dataProvider byteStringProvider + */ + public function testDecodeByteString(string $cbor, string $expected): void + { + $this->assertSame($expected, $this->decoder->decode($cbor)); + } + + public static function byteStringProvider(): array + { + return [ + 'empty' => ["\x40", ''], + 'single-byte' => ["\x41x", 'x'], + 'small' => ["\x44test", 'test'], + '23-bytes' => ["\x57" . str_repeat('b', 23), str_repeat('b', 23)], + '24-bytes' => ["\x58\x18" . str_repeat('c', 24), str_repeat('c', 24)], + '255-bytes' => ["\x58\xFF" . str_repeat('d', 255), str_repeat('d', 255)], + '256-bytes' => ["\x59\x01\x00" . str_repeat('e', 256), str_repeat('e', 256)], + '65535-bytes' => ["\x59\xFF\xFF" . str_repeat('f', 65535), str_repeat('f', 65535)], + '65536-bytes' => ["\x5A\x00\x01\x00\x00" . str_repeat('g', 65536), str_repeat('g', 65536)], + 'binary-with-nulls' => ["\x44\x00\x01\x02\x03", "\x00\x01\x02\x03"], + ]; + } + + /** + * @dataProvider arrayProvider + */ + public function testDecodeArray(string $cbor, array $expected): void + { + $this->assertSame($expected, $this->decoder->decode($cbor)); + } + + public static function arrayProvider(): array + { + return [ + 'empty' => ["\x80", []], + 'single-element' => ["\x81\x01", [1]], + 'three-elements' => ["\x83\x01\x02\x03", [1, 2, 3]], + 'mixed-types' => ["\x83\x01\x61a\xF5", [1, 'a', true]], + 'nested-array' => ["\x82\x01\x82\x02\x03", [1, [2, 3]]], + '24-elements' => ["\x98\x18" . str_repeat("\x00", 24), array_fill(0, 24, 0)], + '256-elements' => ["\x99\x01\x00" . str_repeat("\x00", 256), array_fill(0, 256, 0)], + '65536-elements' => ["\x9A\x00\x01\x00\x00" . str_repeat("\x00", 65536), array_fill(0, 65536, 0)], + ]; + } + + /** + * @dataProvider mapProvider + */ + public function testDecodeMap(string $cbor, array $expected): void + { + $this->assertEquals($expected, $this->decoder->decode($cbor)); + } + + public static function mapProvider(): array + { + return [ + 'empty' => ["\xA0", []], + 'single-string-key' => ["\xA1\x61a\x01", ['a' => 1]], + 'single-int-key' => ["\xA1\x01\x61a", [1 => 'a']], + 'multiple-string-keys' => ["\xA2\x61a\x01\x61b\x02", ['a' => 1, 'b' => 2]], + 'multiple-int-keys' => ["\xA2\x01\x61a\x02\x61b", [1 => 'a', 2 => 'b']], + 'nested-map' => ["\xA1\x61a\xA1\x61b\x01", ['a' => ['b' => 1]]], + 'map-with-array' => ["\xA1\x65items\x83\x01\x02\x03", ['items' => [1, 2, 3]]], + '24-elements' => [ + self::generateMapCbor(24), + array_fill_keys(range(0, 23), 0) + ], + '256-elements' => [ + self::generateMapCbor(256), + array_fill_keys(range(0, 255), 0) + ], + ]; + } + + /** + * @dataProvider indefiniteProvider + */ + public function testDecodeIndefinite(string $cbor, mixed $expected): void + { + $this->assertEquals($expected, $this->decoder->decode($cbor)); + } + + public static function indefiniteProvider(): array + { + return [ + 'indefinite-byte-string' => ["\x5F\x42\x01\x02\x42\x03\x04\xFF", "\x01\x02\x03\x04"], + 'indefinite-byte-string-empty' => ["\x5F\xFF", ''], + 'indefinite-text-string' => ["\x7F\x62ab\x62cd\xFF", 'abcd'], + 'indefinite-text-string-empty' => ["\x7F\xFF", ''], + 'indefinite-array' => ["\x9F\x01\x02\x03\xFF", [1, 2, 3]], + 'indefinite-array-empty' => ["\x9F\xFF", []], + 'indefinite-map' => ["\xBF\x61a\x01\x61b\x02\xFF", ['a' => 1, 'b' => 2]], + 'indefinite-map-empty' => ["\xBF\xFF", []], + 'nested-indefinite' => [ + "\x9F\xBF\x61a\x01\xFF\xFF", + [['a' => 1]] + ], + ]; + } + + public function testDecodeTaggedValues(): void + { + // Tag 0: standard date/time string (we just skip the tag) + $cbor = "\xC0\x65hello"; + $this->assertSame('hello', $this->decoder->decode($cbor)); + + // Tag 1: epoch timestamp (we skip the tag and return the value) + $cbor = "\xC1\x1A\x5F\x5E\x10\x00"; // Timestamp as int + $this->assertSame(1600000000, $this->decoder->decode($cbor)); + + // Multiple tags (nested) + $cbor = "\xC0\xC1\x01"; + $this->assertSame(1, $this->decoder->decode($cbor)); + } + + public function testDecodeComplexStructures(): void + { + // Complex nested structure + $cbor = "\xA3" . // Map with 3 items + "\x64name" . // Key: "name" + "\x64test" . // Value: "test" + "\x66values" . // Key: "values" + "\x83\x01\x02\x03" . // Value: [1, 2, 3] + "\x66nested" . // Key: "nested" + "\xA1" . // Value: map with 1 item + "\x64deep" . // Key: "deep" + "\xA1" . // Value: map with 1 item + "\x65value" . // Key: "value" + "\xF5"; // Value: true + + $expected = [ + 'name' => 'test', + 'values' => [1, 2, 3], + 'nested' => [ + 'deep' => [ + 'value' => true + ] + ] + ]; + + $this->assertEquals($expected, $this->decoder->decode($cbor)); + } + + public function testDecodeByteStringInComplexStructure(): void + { + // Map containing byte strings + $cbor = "\xA2" . // Map with 2 items + "\x66binary" . // Key: "binary" + "\x44data" . // Value: 4-byte byte string "data" + "\x65files" . // Key: "files" + "\x82" . // Value: array with 2 items + "\x45first" . // Item 1: 5-byte byte string "first" + "\x46second"; // Item 2: 6-byte byte string "second" + + $expected = [ + 'binary' => 'data', + 'files' => ['first', 'second'] + ]; + + $this->assertEquals($expected, $this->decoder->decode($cbor)); + } + + public function testDecodeAll(): void + { + // Multiple values in sequence + $cbor = "\x01" . // 1 + "\x61a" . // "a" + "\xF5" . // true + "\x82\x02\x03"; // [2, 3] + + $expected = [1, 'a', true, [2, 3]]; + + $this->assertSame($expected, $this->decoder->decodeAll($cbor)); + } + + public function testDecodeAllEmpty(): void + { + $this->assertSame([], $this->decoder->decodeAll('')); + } + + public function testDecodeLargeMap65536Elements(): void + { + // Map with 65536 elements (4-byte count) + $data = "\xBA\x00\x01\x00\x00"; + for ($i = 0; $i < 65536; $i++) { + // Each key-value pair: small int key + small int value + if ($i < 24) { + $data .= chr($i) . "\x00"; + } elseif ($i < 256) { + $data .= "\x18" . chr($i) . "\x00"; + } else { + $data .= "\x19" . pack('n', $i) . "\x00"; + } + } + + $result = $this->decoder->decode($data); + $this->assertCount(65536, $result); + $this->assertSame(0, $result[0]); + $this->assertSame(0, $result[65535]); + } + + public function testDecodeDeepNesting(): void + { + // Create 100 levels of nesting + $cbor = str_repeat("\xA1\x61a", 100) . "\x01"; + + $result = $this->decoder->decode($cbor); + + // Navigate to the deepest value + $current = $result; + for ($i = 0; $i < 100; $i++) { + $this->assertIsArray($current); + $this->assertArrayHasKey('a', $current); + $current = $current['a']; + } + $this->assertSame(1, $current); + } + + /** + * @dataProvider errorProvider + */ + public function testDecodeErrors(string $cbor, string $expectedMessage): void + { + $this->expectException(CborException::class); + $this->expectExceptionMessage($expectedMessage); + + $this->decoder->decode($cbor); + } + + public static function errorProvider(): array + { + return [ + 'empty-data' => ['', 'No data to decode'], + 'unexpected-end' => ["\x81", 'Unexpected end of data'], + 'not-enough-data-int' => ["\x18", 'Not enough data'], + 'not-enough-data-string' => ["\x61", 'Not enough data'], + 'not-enough-data-array' => ["\x82\x01", 'Unexpected end of data'], + 'unexpected-break' => ["\xFF", 'Unexpected break'], + 'invalid-additional-info' => ["\x1C", 'Invalid additional info for integer: 28'], + 'invalid-chunk-byte-string' => ["\x5F\x61a\xFF", 'Invalid chunk in indefinite string'], + 'invalid-chunk-text-string' => ["\x7F\x41a\xFF", 'Invalid chunk in indefinite string'], + 'indefinite-unexpected-end-byte' => ["\x5F\x42ab", 'Unexpected end of data'], + 'indefinite-unexpected-end-text' => ["\x7F\x62ab", 'Unexpected end of data'], + 'indefinite-unexpected-end-array' => ["\x9F\x01", 'Unexpected end of data'], + 'indefinite-unexpected-end-map' => ["\xBF\x61a", 'Unexpected end of data'], + ]; + } + + public function testDecodeUnknownSimpleValue(): void + { + $this->expectException(CborException::class); + $this->expectExceptionMessage('Unknown simple value: 28'); + + $this->decoder->decode(hex2bin('fc')); + } + + public function testDecodeHalfPrecisionFloatSpecialValues(): void + { + // Half-precision denormalized number + $cbor = "\xF9\x00\x01"; // Smallest positive denormalized + $this->assertGreaterThan(0, $this->decoder->decode($cbor)); + + // Half-precision negative zero + $cbor = "\xF9\x80\x00"; + $this->assertSame(-0.0, $this->decoder->decode($cbor)); + } + + public function testDecodeMapFastPath(): void + { + // Test the fast path for maps with 0-23 elements + for ($i = 0; $i < 24; $i++) { + $cbor = chr(0xA0 | $i); + for ($j = 0; $j < $i; $j++) { + // Each key needs to be different + $cbor .= chr($j) . "\x00"; // Key: $j, Value: 0 + } + + $result = $this->decoder->decode($cbor); + $this->assertCount($i, $result); + } + } + + public function testDecodeStringFastPath(): void + { + // Test the fast path for strings with 0-23 characters + for ($i = 0; $i < 24; $i++) { + $cbor = chr(0x60 | $i) . str_repeat('x', $i); + $result = $this->decoder->decode($cbor); + $this->assertSame(str_repeat('x', $i), $result); + } + } + + public function testDecodeIntegerFastPath(): void + { + // Test the fast path for small unsigned integers 0-23 + for ($i = 0; $i < 24; $i++) { + $this->assertSame($i, $this->decoder->decode(chr($i))); + } + } + + public function testDecodeArrayFastPath(): void + { + // Test the fast path for arrays with 0-23 elements + for ($i = 0; $i < 24; $i++) { + $cbor = chr(0x80 | $i) . str_repeat("\x00", $i); + $result = $this->decoder->decode($cbor); + $this->assertCount($i, $result); + } + } + + public function testDecodeComplexIndefinite(): void + { + // Indefinite array containing indefinite maps + $cbor = "\x9F" . // Indefinite array + "\xBF" . // Indefinite map + "\x61a\x01" . // "a": 1 + "\xFF" . // End map + "\xBF" . // Indefinite map + "\x61b\x02" . // "b": 2 + "\xFF" . // End map + "\xFF"; // End array + + $expected = [ + ['a' => 1], + ['b' => 2] + ]; + + $this->assertEquals($expected, $this->decoder->decode($cbor)); + } + + public function testDecodeMaxValues(): void + { + // Maximum 64-bit unsigned integer + $cbor = "\x1B\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF"; + if (PHP_INT_SIZE === 8) { + $this->assertSame(-1, $this->decoder->decode($cbor)); // Wraps to -1 on 64-bit PHP + } + + // Maximum negative integer that fits in PHP (-9223372036854775808) + // CBOR encoding: major type 1, value = -1 - result, so value = 9223372036854775807 + $cbor = "\x3B\x7F\xFF\xFF\xFF\xFF\xFF\xFF\xFF"; + $this->assertSame(PHP_INT_MIN, $this->decoder->decode($cbor)); // PHP_INT_MIN constant + } + + public function testDecodePerformance(): void + { + // Large complex structure + $cbor = "\xBF"; // Start indefinite map + for ($i = 0; $i < 10000; $i++) { + $key = "field_$i"; + $keyLen = strlen($key); + $cbor .= "\x78" . chr($keyLen) . $key; + $value = "value_$i"; + $valueLen = strlen($value); + $cbor .= "\x78" . chr($valueLen) . $value; + } + $cbor .= "\xFF"; // End indefinite map + + $start = microtime(true); + $result = $this->decoder->decode($cbor); + $duration = microtime(true) - $start; + + $this->assertLessThan(1.0, $duration); + $this->assertCount(10000, $result); + } + + /** + * @dataProvider decodeSuccessFixtureProvider + */ + public function testDecodeSuccessFromFixture(string $hex, mixed $expected): void + { + $cbor = hex2bin($hex); + $actual = $this->decoder->decode($cbor); + + if (is_float($expected)) { + if (is_nan($expected)) { + $this->assertTrue(is_nan($actual)); + } elseif (is_infinite($expected)) { + $this->assertEquals($expected, $actual); + } else { + $this->assertEqualsWithDelta($expected, $actual, 0.0000001); + } + } else { + $this->assertEquals($expected, $actual); + } + } + + public static function decodeSuccessFixtureProvider(): \Generator + { + $file = __DIR__ . '/fixtures/decode-success-tests.json'; + if (!file_exists($file)) { + return; + } + + $fixtures = json_decode(file_get_contents($file), true); + + foreach ($fixtures as $fixture) { + $description = $fixture['description'] ?? 'unknown'; + $input = $fixture['input'] ?? ''; + $expected = self::parseExpectedValue($fixture['expect'] ?? null); + + yield $description => [$input, $expected]; + } + } + + /** + * @dataProvider decodeErrorFixtureProvider + */ + public function testDecodeErrorFromFixture(string $hex): void + { + $this->expectException(CborException::class); + + $cbor = hex2bin($hex); + $this->decoder->decode($cbor); + } + + public static function decodeErrorFixtureProvider(): \Generator + { + $file = __DIR__ . '/fixtures/decode-error-tests.json'; + if (!file_exists($file)) { + return; + } + + $fixtures = json_decode(file_get_contents($file), true); + + foreach ($fixtures as $fixture) { + $description = $fixture['description'] ?? 'unknown'; + $input = $fixture['input'] ?? ''; + + yield $description => [$input]; + } + } + + /** + * Parse expected value from fixture format to PHP value + */ + private static function parseExpectedValue($expect): mixed + { + if (!is_array($expect)) { + return $expect; + } + + // Atomic types + if (isset($expect['uint'])) { + $uint = $expect['uint']; + // Handle overflow for max uint64 + if ($uint == 18446744073709551615) { + return -1; // 0xFFFFFFFFFFFFFFFF wraps to -1 in PHP + } + return (int)$uint; + } + if (isset($expect['negint'])) { + $negint = $expect['negint']; + // Handle overflow for large negative beyond PHP range + if ($negint == -18446744073709551615) { + return 1; // Wraps after overflow + } + return (int)$negint; + } + if (isset($expect['bool'])) { + return (bool)$expect['bool']; + } + if (isset($expect['null'])) { + return null; + } + if (isset($expect['string'])) { + return (string)$expect['string']; + } + if (isset($expect['bytestring'])) { + if (is_array($expect['bytestring'])) { + return implode('', array_map('chr', $expect['bytestring'])); + } + return ''; + } + + // Float32 - bit pattern to float + if (isset($expect['float32'])) { + $packed = pack('N', $expect['float32']); + return unpack('G', $packed)[1]; + } + + // Float64 - bit pattern to float + if (isset($expect['float64'])) { + if (PHP_INT_SIZE >= 8) { + $packed = pack('J', $expect['float64']); + } else { + // 32-bit PHP + $high = ($expect['float64'] >> 32) & 0xFFFFFFFF; + $low = $expect['float64'] & 0xFFFFFFFF; + $packed = pack('NN', $high, $low); + } + return unpack('E', $packed)[1]; + } + + // Collections + if (isset($expect['list'])) { + return array_map([self::class, 'parseExpectedValue'], $expect['list']); + } + + if (isset($expect['map'])) { + $result = []; + foreach ($expect['map'] as $key => $value) { + $result[$key] = self::parseExpectedValue($value); + } + return $result; + } + + // Tags - decoder skips tags + if (isset($expect['tag'])) { + return self::parseExpectedValue($expect['tag']['value'] ?? null); + } + + return null; + } +} diff --git a/tests/Cbor/CborEncoderTest.php b/tests/Cbor/CborEncoderTest.php new file mode 100644 index 0000000000..c65bbca180 --- /dev/null +++ b/tests/Cbor/CborEncoderTest.php @@ -0,0 +1,592 @@ +encoder = new CborEncoder(); + } + + /** + * @dataProvider nullProvider + */ + public function testEncodeNull($value, string $expected): void + { + $this->assertSame($expected, $this->encoder->encode($value)); + } + + public static function nullProvider(): array + { + return [ + 'null' => [null, "\xF6"], + ]; + } + + /** + * @dataProvider booleanProvider + */ + public function testEncodeBoolean(bool $value, string $expected): void + { + $this->assertSame($expected, $this->encoder->encode($value)); + } + + public static function booleanProvider(): array + { + return [ + 'true' => [true, "\xF5"], + 'false' => [false, "\xF4"], + ]; + } + + /** + * @dataProvider unsignedIntegerProvider + */ + public function testEncodeUnsignedInteger(int $value, string $expected): void + { + $this->assertSame($expected, $this->encoder->encode($value)); + } + + public static function unsignedIntegerProvider(): \Generator + { + // Small integers 0-23 (inline) + yield '0' => [0, "\x00"]; + yield '1' => [1, "\x01"]; + yield '10' => [10, "\x0A"]; + yield '23' => [23, "\x17"]; + + // 1-byte integers + yield '24' => [24, "\x18\x18"]; + yield '25' => [25, "\x18\x19"]; + yield '100' => [100, "\x18\x64"]; + yield '255' => [255, "\x18\xFF"]; + + // 2-byte integers + yield '256' => [256, "\x19\x01\x00"]; + yield '1000' => [1000, "\x19\x03\xE8"]; + yield '65535' => [65535, "\x19\xFF\xFF"]; + + // 4-byte integers + yield '65536' => [65536, "\x1A\x00\x01\x00\x00"]; + yield '1000000' => [1000000, "\x1A\x00\x0F\x42\x40"]; + yield '4294967295' => [4294967295, "\x1A\xFF\xFF\xFF\xFF"]; + + // 8-byte integers + yield '4294967296' => [4294967296, "\x1B\x00\x00\x00\x01\x00\x00\x00\x00"]; + yield 'large' => [9223372036854775807, "\x1B\x7F\xFF\xFF\xFF\xFF\xFF\xFF\xFF"]; + } + + /** + * @dataProvider negativeIntegerProvider + */ + public function testEncodeNegativeInteger(int $value, string $expected): void + { + $this->assertSame($expected, $this->encoder->encode($value)); + } + + public static function negativeIntegerProvider(): \Generator + { + // Small negative integers -1 to -24 (inline) + yield '-1' => [-1, "\x20"]; + yield '-2' => [-2, "\x21"]; + yield '-10' => [-10, "\x29"]; + yield '-24' => [-24, "\x37"]; + + // 1-byte negative integers + yield '-25' => [-25, "\x38\x18"]; + yield '-100' => [-100, "\x38\x63"]; + yield '-256' => [-256, "\x38\xFF"]; + + // 2-byte negative integers + yield '-257' => [-257, "\x39\x01\x00"]; + yield '-1000' => [-1000, "\x39\x03\xE7"]; + yield '-65536' => [-65536, "\x39\xFF\xFF"]; + + // 4-byte negative integers + yield '-65537' => [-65537, "\x3A\x00\x01\x00\x00"]; + yield '-1000000' => [-1000000, "\x3A\x00\x0F\x42\x3F"]; + } + + /** + * @dataProvider floatProvider + */ + public function testEncodeFloat(float $value, string $expected): void + { + $this->assertSame($expected, $this->encoder->encode($value)); + } + + public static function floatProvider(): array + { + return [ + '0.0' => [0.0, "\xFB\x00\x00\x00\x00\x00\x00\x00\x00"], + '1.5' => [1.5, "\xFB\x3F\xF8\x00\x00\x00\x00\x00\x00"], + '-4.25' => [-4.25, "\xFB\xC0\x11\x00\x00\x00\x00\x00\x00"], + 'NaN' => [NAN, "\xFB\x7F\xF8\x00\x00\x00\x00\x00\x00"], + 'INF' => [INF, "\xFB\x7F\xF0\x00\x00\x00\x00\x00\x00"], + '-INF' => [-INF, "\xFB\xFF\xF0\x00\x00\x00\x00\x00\x00"], + 'requires-double' => [3.141592653589793, "\xFB\x40\x09\x21\xFB\x54\x44\x2D\x18"], + ]; + } + + /** + * @dataProvider stringProvider + */ + public function testEncodeString(string $value, string $expected): void + { + $this->assertSame($expected, $this->encoder->encode($value)); + } + + public static function stringProvider(): \Generator + { + // Empty and small strings (length 0-23) + yield 'empty' => ['', "\x60"]; + yield 'single-char' => ['a', "\x61a"]; + yield 'hello' => ['hello', "\x65hello"]; + yield '23-chars' => [str_repeat('x', 23), "\x77" . str_repeat('x', 23)]; + + // 1-byte length (24-255) + yield '24-chars' => [str_repeat('y', 24), "\x78\x18" . str_repeat('y', 24)]; + yield '255-chars' => [str_repeat('z', 255), "\x78\xFF" . str_repeat('z', 255)]; + + // 2-byte length (256-65535) + yield '256-chars' => [str_repeat('a', 256), "\x79\x01\x00" . str_repeat('a', 256)]; + yield '1000-chars' => [str_repeat('b', 1000), "\x79\x03\xE8" . str_repeat('b', 1000)]; + + // Unicode + yield 'unicode' => ['Hello 世界', "\x6CHello 世界"]; + } + + /** + * @dataProvider arrayProvider + */ + public function testEncodeArray(array $value, string $expected): void + { + $this->assertSame($expected, $this->encoder->encode($value)); + } + + public static function arrayProvider(): array + { + return [ + 'empty-array' => [[], "\x80"], + 'single-element' => [[1], "\x81\x01"], + 'multiple-elements' => [[1, 2, 3], "\x83\x01\x02\x03"], + 'mixed-types' => [[1, "a", true], "\x83\x01\x61a\xF5"], + 'nested-array' => [[1, [2, 3]], "\x82\x01\x82\x02\x03"], + '24-elements' => [ + array_fill(0, 24, 1), + "\x98\x18" . str_repeat("\x01", 24) + ], + '256-elements' => [ + array_fill(0, 256, 0), + "\x99\x01\x00" . str_repeat("\x00", 256) + ], + ]; + } + + /** + * @dataProvider mapProvider + */ + public function testEncodeMap(array $value, string $expected): void + { + $this->assertSame($expected, $this->encoder->encode($value)); + } + + public static function mapProvider(): array + { + return [ + 'empty-map' => [['key' => 'value'], "\xA1\x63key\x65value"], + 'int-keys' => [[1 => 'a', 2 => 'b'], "\xA2\x01\x61a\x02\x61b"], + 'mixed-keys' => [['a' => 1, 'b' => 2], "\xA2\x61a\x01\x61b\x02"], + 'nested-map' => [ + ['a' => ['b' => 1]], + "\xA1\x61a\xA1\x61b\x01" + ], + 'map-with-array' => [ + ['items' => [1, 2, 3]], + "\xA1\x65items\x83\x01\x02\x03" + ], + ]; + } + + /** + * @dataProvider timestampProvider + */ + public function testEncodeTimestamp(DateTime $value, string $expectedPrefix): void + { + $encoded = $this->encoder->encode($value); + $this->assertStringStartsWith($expectedPrefix, $encoded); + $this->assertSame("\xC1", $encoded[0]); + } + + public static function timestampProvider(): array + { + $dt1 = new DateTime('2024-01-01 00:00:00.000000'); + $dt2 = new DateTime('2024-01-01 12:30:45.500000'); + + return [ + 'integer-timestamp' => [$dt1, "\xC1"], + 'float-timestamp' => [$dt2, "\xC1"], + ]; + } + + public function testEncodeTimestampWithMilliseconds(): void + { + $dt = new DateTime('2024-01-01 00:00:00.123456'); + $encoded = $this->encoder->encode($dt); + + // Should be tag 1 followed by either int or float + $this->assertSame("\xC1", $encoded[0]); + + // Verify it contains millisecond precision + $secondByte = ord($encoded[1]); + $this->assertTrue($secondByte === 0xFA || $secondByte === 0xFB || $secondByte >= 0x1A); + } + + public function testEncodeEmptyMap(): void + { + $this->assertSame("\xA0", $this->encoder->encodeEmptyMap()); + } + + public function testEncodeEmptyIndefiniteMap(): void + { + $this->assertSame("\xBF\xFF", $this->encoder->encodeEmptyIndefiniteMap()); + } + + public function testEncodeObjectThrowsForNonDateTime(): void + { + $this->expectException(CborException::class); + $this->expectExceptionMessage('Cannot encode object of type: stdClass'); + + $obj = new \stdClass(); + $obj->foo = 'bar'; + $obj->num = 42; + + $this->encoder->encode($obj); + } + + public function testEncodeCachedIntegers(): void + { + // Test cached positive integers + foreach ([0, 1, 10, 24, 25, 32, 100, 256, 1000, 1023] as $int) { + $encoded = $this->encoder->encode($int); + $this->assertIsString($encoded); + $this->assertGreaterThan(0, strlen($encoded)); + } + + // Test cached negative integers + foreach ([-1, -2, -5, -10, -25, -50, -100] as $int) { + $encoded = $this->encoder->encode($int); + $this->assertIsString($encoded); + $this->assertGreaterThan(0, strlen($encoded)); + } + } + + public function testEncodeNestedStructures(): void + { + $complex = [ + 'name' => 'test', + 'values' => [1, 2, 3], + 'nested' => [ + 'deep' => [ + 'value' => true + ] + ] + ]; + + $encoded = $this->encoder->encode($complex); + + // Should start with map major type + $this->assertSame(0xA0, ord($encoded[0]) & 0xE0); + $this->assertGreaterThan(10, strlen($encoded)); + } + + public function testEncodeLargeString(): void + { + // Test 4-byte length encoding + $largeString = str_repeat('x', 70000); + $encoded = $this->encoder->encode($largeString); + + // Should use 4-byte length (\x7A) + $this->assertSame("\x7A", $encoded[0]); + $this->assertSame(70000 + 5, strlen($encoded)); // 1 byte type + 4 bytes length + data + } + + public function testEncodeBufferGrowth(): void + { + // Create encoder with small initial capacity + $encoder = new CborEncoder(16); + + // Encode something larger than initial capacity + $largeArray = array_fill(0, 100, 'test'); + $encoded = $encoder->encode($largeArray); + + // Should successfully encode without errors + $this->assertGreaterThan(16, strlen($encoded)); + } + + public function testEncodeInvalidType(): void + { + $this->expectException(CborException::class); + $this->expectExceptionMessage("Cannot encode value of type"); + + $resource = fopen('php://memory', 'r'); + try { + $this->encoder->encode($resource); + } finally { + fclose($resource); + } + } + + public function testEncodeMapWith24Elements(): void + { + $map = []; + for ($i = 0; $i < 24; $i++) { + $map["key$i"] = $i; + } + + $encoded = $this->encoder->encode($map); + + // Should use 1-byte count encoding (\xB8) + $this->assertSame("\xB8", $encoded[0]); + } + + public function testEncodeMapWith256Elements(): void + { + $map = []; + for ($i = 0; $i < 256; $i++) { + $map["k$i"] = $i; + } + + $encoded = $this->encoder->encode($map); + + // Should use 2-byte count encoding (\xB9) + $this->assertSame("\xB9", $encoded[0]); + } + + public function testEncodeRecursiveArrays(): void + { + $data = [ + [1, 2, [3, 4, [5, 6]]], + ['a' => [1, 2], 'b' => [3, 4]] + ]; + + $encoded = $this->encoder->encode($data); + + // Should produce valid CBOR + $this->assertIsString($encoded); + $this->assertGreaterThan(10, strlen($encoded)); + } + + public function testEncodeAllSimpleValues(): void + { + // Test null, true, false + $values = [ + [null, "\xF6"], + [true, "\xF5"], + [false, "\xF4"], + ]; + + foreach ($values as [$value, $expected]) { + // Use base64 comparison to avoid display issues + $this->assertSame( + base64_encode($expected), + base64_encode($this->encoder->encode($value)), + "Failed encoding " . var_export($value, true) + ); + } + } + + /** + * @dataProvider byteStringProvider + */ + public function testEncodeByteString(string $bytes, string $expected): void + { + $encoded = $this->encoder->encode(['__cbor_bytes' => $bytes]); + $this->assertSame($expected, $encoded); + } + + public static function byteStringProvider(): array + { + return [ + 'empty' => ['', "\x40"], + 'single-byte' => ['x', "\x41x"], + 'small' => ['test', "\x44test"], + '23-bytes' => [str_repeat('b', 23), "\x57" . str_repeat('b', 23)], + '24-bytes' => [str_repeat('c', 24), "\x58\x18" . str_repeat('c', 24)], + '255-bytes' => [str_repeat('d', 255), "\x58\xFF" . str_repeat('d', 255)], + '256-bytes' => [str_repeat('e', 256), "\x59\x01\x00" . str_repeat('e', 256)], + '65535-bytes' => [str_repeat('f', 65535), "\x59\xFF\xFF" . str_repeat('f', 65535)], + '65536-bytes' => [str_repeat('g', 65536), "\x5A\x00\x01\x00\x00" . str_repeat('g', 65536)], + ]; + } + + public function testEncodeByteStringInArray(): void + { + $data = [ + ['__cbor_bytes' => 'first'], + ['__cbor_bytes' => 'second'], + ]; + $encoded = $this->encoder->encode($data); + + // Array of 2 elements + $this->assertSame("\x82", $encoded[0]); + // First byte string (5 bytes) + $this->assertSame("\x45", $encoded[1]); + $this->assertSame('first', substr($encoded, 2, 5)); + // Second byte string (6 bytes) + $this->assertSame("\x46", $encoded[7]); + $this->assertSame('second', substr($encoded, 8, 6)); + } + + public function testEncodeByteStringInMap(): void + { + $data = ['binary' => ['__cbor_bytes' => 'data']]; + $encoded = $this->encoder->encode($data); + + // Map with 1 element + $this->assertSame("\xA1", $encoded[0]); + // Key "binary" (6 chars) + $this->assertSame("\x66", $encoded[1]); + $this->assertSame('binary', substr($encoded, 2, 6)); + // Value: byte string (4 bytes) + $this->assertSame("\x44", $encoded[8]); + $this->assertSame('data', substr($encoded, 9, 4)); + } + + public function testEncodeLargeArray65536Elements(): void + { + $array = array_fill(0, 65536, 0); + $encoded = $this->encoder->encode($array); + + // Should use 4-byte count encoding (\x9A) + $this->assertSame("\x9A", $encoded[0]); + // Next 4 bytes should be 0x00010000 (65536 in big-endian) + $this->assertSame("\x00\x01\x00\x00", substr($encoded, 1, 4)); + } + + public function testEncodeLargeMap65536Elements(): void + { + $map = []; + for ($i = 0; $i < 65536; $i++) { + // Use string keys to force map encoding instead of array + $map["k$i"] = $i; + } + + $encoded = $this->encoder->encode($map); + + // Should use 4-byte count encoding (\xBA) + $this->assertSame("\xBA", $encoded[0]); + // Next 4 bytes should be 0x00010000 (65536 in big-endian) + $this->assertSame("\x00\x01\x00\x00", substr($encoded, 1, 4)); + } + + /** + * @dataProvider integerBoundaryProvider + */ + public function testIntegerBoundaries(int $value, string $expectedPrefix): void + { + $encoded = $this->encoder->encode($value); + $this->assertStringStartsWith($expectedPrefix, $encoded); + } + + public static function integerBoundaryProvider(): array + { + return [ + // Positive boundaries + 'exactly-24' => [24, "\x18\x18"], + 'exactly-256' => [256, "\x19\x01\x00"], + 'exactly-65536' => [65536, "\x1A\x00\x01\x00\x00"], + 'exactly-4294967296' => [4294967296, "\x1B\x00\x00\x00\x01\x00\x00\x00\x00"], + + // Negative boundaries + 'exactly-minus-25' => [-25, "\x38\x18"], + 'exactly-minus-257' => [-257, "\x39\x01\x00"], + 'exactly-minus-65537' => [-65537, "\x3A\x00\x01\x00\x00"], + 'exactly-minus-4294967297' => [-4294967297, "\x3B\x00\x00\x00\x01\x00\x00\x00\x00"], + ]; + } + + public function testStringBoundaries(): void + { + // Exactly 65535 chars (max 2-byte length) + $str65535 = str_repeat('x', 65535); + $encoded = $this->encoder->encode($str65535); + $this->assertSame("\x79\xFF\xFF", substr($encoded, 0, 3)); + + // Exactly 65536 chars (min 4-byte length) + $str65536 = str_repeat('y', 65536); + $encoded = $this->encoder->encode($str65536); + $this->assertSame("\x7A\x00\x01\x00\x00", substr($encoded, 0, 5)); + } + + public function testDeepNesting(): void + { + // Create 100 levels of nesting + $data = ['value' => 1]; + for ($i = 0; $i < 100; $i++) { + $data = ['nested' => $data]; + } + + $encoded = $this->encoder->encode($data); + + // Should start with map marker + $this->assertSame(0xA0, ord($encoded[0]) & 0xE0); + // Should be quite long due to nesting + $this->assertGreaterThan(200, strlen($encoded)); + } + + public function testComplexNestedByteStrings(): void + { + $data = [ + 'files' => [ + ['name' => 'file1', 'data' => ['__cbor_bytes' => 'content1']], + ['name' => 'file2', 'data' => ['__cbor_bytes' => 'content2']], + ], + 'metadata' => [ + 'checksum' => ['__cbor_bytes' => hash('sha256', 'test', true)], + ], + ]; + + $encoded = $this->encoder->encode($data); + + // Should be a map + $this->assertSame(0xA0, ord($encoded[0]) & 0xE0); + // Should contain byte string markers + $this->assertStringContainsString("\x48content1", $encoded); + $this->assertStringContainsString("\x48content2", $encoded); + } + + public function testMaxInt64Values(): void + { + // PHP_INT_MAX on 64-bit systems + $maxInt = PHP_INT_MAX; + $encoded = $this->encoder->encode($maxInt); + $this->assertSame("\x1B", $encoded[0]); + + // PHP_INT_MIN on 64-bit systems + $minInt = PHP_INT_MIN; + $encoded = $this->encoder->encode($minInt); + $this->assertSame("\x3B", $encoded[0]); + } + + public function testByteStringWithNullBytes(): void + { + // Binary data with null bytes + $binaryData = "test\x00data\x00with\x00nulls"; + $encoded = $this->encoder->encode(['__cbor_bytes' => $binaryData]); + + $expectedLength = strlen($binaryData); + $this->assertSame(chr(0x40 | $expectedLength), $encoded[0]); + $this->assertSame($binaryData, substr($encoded, 1)); + } +} diff --git a/tests/Cbor/fixtures/decode-error-tests.json b/tests/Cbor/fixtures/decode-error-tests.json new file mode 100644 index 0000000000..55c203ef17 --- /dev/null +++ b/tests/Cbor/fixtures/decode-error-tests.json @@ -0,0 +1,282 @@ +[ + { + "description": "TestDecode_InvalidArgument - map/2 - arg len 2 greater than remaining buf len", + "input": "b900", + "error": true + }, + { + "description": "TestDecode_InvalidArgument - tag/1 - arg len 1 greater than remaining buf len", + "input": "d8", + "error": true + }, + { + "description": "TestDecode_InvalidArgument - major7/float64 - incomplete float64 at end of buf", + "input": "fb00000000000000", + "error": true + }, + { + "description": "TestDecode_InvalidArgument - negint/4 - arg len 4 greater than remaining buf len", + "input": "3a000000", + "error": true + }, + { + "description": "TestDecode_InvalidArgument - negint/8 - arg len 8 greater than remaining buf len", + "input": "3b00000000000000", + "error": true + }, + { + "description": "TestDecode_InvalidArgument - string/4 - arg len 4 greater than remaining buf len", + "input": "7a000000", + "error": true + }, + { + "description": "TestDecode_InvalidArgument - map/1 - arg len 1 greater than remaining buf len", + "input": "b8", + "error": true + }, + { + "description": "TestDecode_InvalidArgument - map/4 - arg len 4 greater than remaining buf len", + "input": "ba000000", + "error": true + }, + { + "description": "TestDecode_InvalidArgument - tag/2 - arg len 2 greater than remaining buf len", + "input": "d900", + "error": true + }, + { + "description": "TestDecode_InvalidArgument - uint/1 - arg len 1 greater than remaining buf len", + "input": "18", + "error": true + }, + { + "description": "TestDecode_InvalidArgument - string/1 - arg len 1 greater than remaining buf len", + "input": "78", + "error": true + }, + { + "description": "TestDecode_InvalidArgument - string/8 - arg len 8 greater than remaining buf len", + "input": "7b00000000000000", + "error": true + }, + { + "description": "TestDecode_InvalidArgument - string/2 - arg len 2 greater than remaining buf len", + "input": "7900", + "error": true + }, + { + "description": "TestDecode_InvalidArgument - list/2 - arg len 2 greater than remaining buf len", + "input": "9900", + "error": true + }, + { + "description": "TestDecode_InvalidArgument - slice/1 - arg len 1 greater than remaining buf len", + "input": "58", + "error": true + }, + { + "description": "TestDecode_InvalidArgument - slice/4 - arg len 4 greater than remaining buf len", + "input": "5a000000", + "error": true + }, + { + "description": "TestDecode_InvalidArgument - slice/8 - arg len 8 greater than remaining buf len", + "input": "5b00000000000000", + "error": true + }, + { + "description": "TestDecode_InvalidArgument - negint/? - unexpected minor value 31", + "input": "3f", + "error": true + }, + { + "description": "TestDecode_InvalidArgument - tag/8 - arg len 8 greater than remaining buf len", + "input": "db00000000000000", + "error": true + }, + { + "description": "TestDecode_InvalidArgument - uint/2 - arg len 2 greater than remaining buf len", + "input": "1900", + "error": true + }, + { + "description": "TestDecode_InvalidArgument - uint/8 - arg len 8 greater than remaining buf len", + "input": "1b00000000000000", + "error": true + }, + { + "description": "TestDecode_InvalidArgument - negint/2 - arg len 2 greater than remaining buf len", + "input": "3900", + "error": true + }, + { + "description": "TestDecode_InvalidArgument - negint/1 - arg len 1 greater than remaining buf len", + "input": "38", + "error": true + }, + { + "description": "TestDecode_InvalidArgument - list/8 - arg len 8 greater than remaining buf len", + "input": "9b00000000000000", + "error": true + }, + { + "description": "TestDecode_InvalidArgument - tag/4 - arg len 4 greater than remaining buf len", + "input": "da000000", + "error": true + }, + { + "description": "TestDecode_InvalidArgument - major7/float32 - incomplete float32 at end of buf", + "input": "fa000000", + "error": true + }, + { + "description": "TestDecode_InvalidArgument - uint/4 - arg len 4 greater than remaining buf len", + "input": "1a000000", + "error": true + }, + { + "description": "TestDecode_InvalidArgument - slice/2 - arg len 2 greater than remaining buf len", + "input": "5900", + "error": true + }, + { + "description": "TestDecode_InvalidArgument - list/4 - arg len 4 greater than remaining buf len", + "input": "9a000000", + "error": true + }, + { + "description": "TestDecode_InvalidArgument - tag/? - unexpected minor value 31", + "input": "df", + "error": true + }, + { + "description": "TestDecode_InvalidArgument - major7/? - unexpected minor value 31", + "input": "ff", + "error": true + }, + { + "description": "TestDecode_InvalidArgument - uint/? - unexpected minor value 31", + "input": "1f", + "error": true + }, + { + "description": "TestDecode_InvalidArgument - list/1 - arg len 1 greater than remaining buf len", + "input": "98", + "error": true + }, + { + "description": "TestDecode_InvalidArgument - map/8 - arg len 8 greater than remaining buf len", + "input": "bb00000000000000", + "error": true + }, + { + "description": "TestDecode_InvalidList - [] / eof after head - unexpected end of payload", + "input": "81", + "error": true + }, + { + "description": "TestDecode_InvalidList - [] / invalid item - arg len 1 greater than remaining buf len", + "input": "8118", + "error": true + }, + { + "description": "TestDecode_InvalidList - [_ ] / no break - expected break marker", + "input": "9f", + "error": true + }, + { + "description": "TestDecode_InvalidList - [_ ] / invalid item - arg len 1 greater than remaining buf len", + "input": "9f18", + "error": true + }, + { + "description": "TestDecode_InvalidMap - {} / invalid key - slice len 1 greater than remaining buf len", + "input": "a17801", + "error": true + }, + { + "description": "TestDecode_InvalidMap - {} / invalid value - arg len 1 greater than remaining buf len", + "input": "a163666f6f18", + "error": true + }, + { + "description": "TestDecode_InvalidMap - {_ } / no break - expected break marker", + "input": "bf", + "error": true + }, + { + "description": "TestDecode_InvalidMap - {_ } / invalid key - slice len 1 greater than remaining buf len", + "input": "bf7801", + "error": true + }, + { + "description": "TestDecode_InvalidMap - {_ } / invalid value - arg len 1 greater than remaining buf len", + "input": "bf63666f6f18", + "error": true + }, + { + "description": "TestDecode_InvalidMap - {} / eof after head - unexpected end of payload", + "input": "a1", + "error": true + }, + { + "description": "TestDecode_InvalidSlice - slice/1, not enough bytes - slice len 1 greater than remaining buf len", + "input": "5801", + "error": true + }, + { + "description": "TestDecode_InvalidSlice - slice/?, nested indefinite - nested indefinite slice", + "input": "5f5f", + "error": true + }, + { + "description": "TestDecode_InvalidSlice - string/?, no break - expected break marker", + "input": "7f", + "error": true + }, + { + "description": "TestDecode_InvalidSlice - string/?, nested indefinite - nested indefinite slice", + "input": "7f7f", + "error": true + }, + { + "description": "TestDecode_InvalidSlice - string/?, invalid nested definite - decode subslice: slice len 1 greater than remaining buf len", + "input": "7f7801", + "error": true + }, + { + "description": "TestDecode_InvalidSlice - slice/?, no break - expected break marker", + "input": "5f", + "error": true + }, + { + "description": "TestDecode_InvalidSlice - slice/?, invalid nested major - unexpected major type 3 in indefinite slice", + "input": "5f60", + "error": true + }, + { + "description": "TestDecode_InvalidSlice - slice/?, invalid nested definite - decode subslice: slice len 1 greater than remaining buf len", + "input": "5f5801", + "error": true + }, + { + "description": "TestDecode_InvalidSlice - string/1, not enough bytes - slice len 1 greater than remaining buf len", + "input": "7801", + "error": true + }, + { + "description": "TestDecode_InvalidSlice - string/?, invalid nested major - unexpected major type 2 in indefinite slice", + "input": "7f40", + "error": true + }, + { + "description": "TestDecode_InvalidTag - invalid value - arg len 1 greater than remaining buf len", + "input": "c118", + "error": true + }, + { + "description": "TestDecode_InvalidTag - eof - unexpected end of payload", + "input": "c1", + "error": true + } +] diff --git a/tests/Cbor/fixtures/decode-success-tests.json b/tests/Cbor/fixtures/decode-success-tests.json new file mode 100644 index 0000000000..c5c38aced0 --- /dev/null +++ b/tests/Cbor/fixtures/decode-success-tests.json @@ -0,0 +1,1528 @@ +[ + { + "description": "atomic - uint/0/max", + "input": "17", + "expect": { + "uint": 23 + } + }, + { + "description": "atomic - uint/2/min", + "input": "190000", + "expect": { + "uint": 0 + } + }, + { + "description": "atomic - uint/8/min", + "input": "1b0000000000000000", + "expect": { + "uint": 0 + } + }, + { + "description": "atomic - negint/1/min", + "input": "3800", + "expect": { + "negint": -1 + } + }, + { + "description": "atomic - negint/2/min", + "input": "390000", + "expect": { + "negint": -1 + } + }, + { + "description": "atomic - false", + "input": "f4", + "expect": { + "bool": false + } + }, + { + "description": "atomic - uint/1/min", + "input": "1800", + "expect": { + "uint": 0 + } + }, + { + "description": "atomic - negint/8/min", + "input": "3b0000000000000000", + "expect": { + "negint": -1 + } + }, + { + "description": "atomic - float64/+Inf", + "input": "fb7ff0000000000000", + "expect": { + "float64": 9218868437227405312 + } + }, + { + "description": "atomic - uint/4/min", + "input": "1a00000000", + "expect": { + "uint": 0 + } + }, + { + "description": "atomic - null", + "input": "f6", + "expect": { + "null": {} + } + }, + { + "description": "atomic - negint/2/max", + "input": "39ffff", + "expect": { + "negint": -65536 + } + }, + { + "description": "atomic - negint/8/max", + "input": "3bfffffffffffffffe", + "expect": { + "negint": -18446744073709551615 + } + }, + { + "description": "atomic - float32/1.625", + "input": "fa3fd00000", + "expect": { + "float32": 1070596096 + } + }, + { + "description": "atomic - uint/0/min", + "input": "00", + "expect": { + "uint": 0 + } + }, + { + "description": "atomic - uint/1/max", + "input": "18ff", + "expect": { + "uint": 255 + } + }, + { + "description": "atomic - uint/8/max", + "input": "1bffffffffffffffff", + "expect": { + "uint": 18446744073709551615 + } + }, + { + "description": "atomic - negint/1/max", + "input": "38ff", + "expect": { + "negint": -256 + } + }, + { + "description": "atomic - negint/4/min", + "input": "3a00000000", + "expect": { + "negint": -1 + } + }, + { + "description": "atomic - float64/1.625", + "input": "fb3ffa000000000000", + "expect": { + "float64": 4609997168567123968 + } + }, + { + "description": "atomic - uint/2/max", + "input": "19ffff", + "expect": { + "uint": 65535 + } + }, + { + "description": "atomic - negint/0/max", + "input": "37", + "expect": { + "negint": -24 + } + }, + { + "description": "atomic - negint/4/max", + "input": "3affffffff", + "expect": { + "negint": -4294967296 + } + }, + { + "description": "atomic - uint/4/max", + "input": "1affffffff", + "expect": { + "uint": 4294967295 + } + }, + { + "description": "atomic - negint/0/min", + "input": "20", + "expect": { + "negint": -1 + } + }, + { + "description": "atomic - true", + "input": "f5", + "expect": { + "bool": true + } + }, + { + "description": "atomic - float32/+Inf", + "input": "fa7f800000", + "expect": { + "float32": 2139095040 + } + }, + { + "description": "definite slice - len = 0", + "input": "40", + "expect": { + "bytestring": [] + } + }, + { + "description": "definite slice - len \u003e 0", + "input": "43666f6f", + "expect": { + "bytestring": [ + 102, + 111, + 111 + ] + } + }, + { + "description": "definite string - len = 0", + "input": "60", + "expect": { + "string": "" + } + }, + { + "description": "definite string - len \u003e 0", + "input": "63666f6f", + "expect": { + "string": "foo" + } + }, + { + "description": "indefinite slice - len = 0", + "input": "5fff", + "expect": { + "bytestring": [] + } + }, + { + "description": "indefinite slice - len = 0, explicit", + "input": "5f40ff", + "expect": { + "bytestring": [] + } + }, + { + "description": "indefinite slice - len = 0, len \u003e 0", + "input": "5f4043666f6fff", + "expect": { + "bytestring": [ + 102, + 111, + 111 + ] + } + }, + { + "description": "indefinite slice - len \u003e 0, len = 0", + "input": "5f43666f6f40ff", + "expect": { + "bytestring": [ + 102, + 111, + 111 + ] + } + }, + { + "description": "indefinite slice - len \u003e 0, len \u003e 0", + "input": "5f43666f6f43666f6fff", + "expect": { + "bytestring": [ + 102, + 111, + 111, + 102, + 111, + 111 + ] + } + }, + { + "description": "indefinite string - len = 0", + "input": "7fff", + "expect": { + "string": "" + } + }, + { + "description": "indefinite string - len = 0, explicit", + "input": "7f60ff", + "expect": { + "string": "" + } + }, + { + "description": "indefinite string - len = 0, len \u003e 0", + "input": "7f6063666f6fff", + "expect": { + "string": "foo" + } + }, + { + "description": "indefinite string - len \u003e 0, len = 0", + "input": "7f63666f6f60ff", + "expect": { + "string": "foo" + } + }, + { + "description": "indefinite string - len \u003e 0, len \u003e 0", + "input": "7f63666f6f63666f6fff", + "expect": { + "string": "foofoo" + } + }, + { + "description": "list - [float64]", + "input": "81fb7ff0000000000000", + "expect": { + "list": [ + { + "float64": 9218868437227405312 + } + ] + } + }, + { + "description": "list - [_ negint/4/min]", + "input": "9f3a00000000ff", + "expect": { + "list": [ + { + "negint": -1 + } + ] + } + }, + { + "description": "list - [uint/1/min]", + "input": "811800", + "expect": { + "list": [ + { + "uint": 0 + } + ] + } + }, + { + "description": "list - [_ uint/4/min]", + "input": "9f1a00000000ff", + "expect": { + "list": [ + { + "uint": 0 + } + ] + } + }, + { + "description": "list - [uint/0/max]", + "input": "8117", + "expect": { + "list": [ + { + "uint": 23 + } + ] + } + }, + { + "description": "list - [uint/1/max]", + "input": "8118ff", + "expect": { + "list": [ + { + "uint": 255 + } + ] + } + }, + { + "description": "list - [negint/2/min]", + "input": "81390000", + "expect": { + "list": [ + { + "negint": -1 + } + ] + } + }, + { + "description": "list - [negint/8/min]", + "input": "813b0000000000000000", + "expect": { + "list": [ + { + "negint": -1 + } + ] + } + }, + { + "description": "list - [_ uint/2/min]", + "input": "9f190000ff", + "expect": { + "list": [ + { + "uint": 0 + } + ] + } + }, + { + "description": "list - [uint/0/min]", + "input": "8100", + "expect": { + "list": [ + { + "uint": 0 + } + ] + } + }, + { + "description": "list - [negint/0/min]", + "input": "8120", + "expect": { + "list": [ + { + "negint": -1 + } + ] + } + }, + { + "description": "list - [negint/0/max]", + "input": "8137", + "expect": { + "list": [ + { + "negint": -24 + } + ] + } + }, + { + "description": "list - [negint/1/min]", + "input": "813800", + "expect": { + "list": [ + { + "negint": -1 + } + ] + } + }, + { + "description": "list - [negint/1/max]", + "input": "8138ff", + "expect": { + "list": [ + { + "negint": -256 + } + ] + } + }, + { + "description": "list - [negint/4/max]", + "input": "813affffffff", + "expect": { + "list": [ + { + "negint": -4294967296 + } + ] + } + }, + { + "description": "list - [_ uint/4/max]", + "input": "9f1affffffffff", + "expect": { + "list": [ + { + "uint": 4294967295 + } + ] + } + }, + { + "description": "list - [_ negint/0/max]", + "input": "9f37ff", + "expect": { + "list": [ + { + "negint": -24 + } + ] + } + }, + { + "description": "list - [uint/2/min]", + "input": "81190000", + "expect": { + "list": [ + { + "uint": 0 + } + ] + } + }, + { + "description": "list - [_ false]", + "input": "9ff4ff", + "expect": { + "list": [ + { + "bool": false + } + ] + } + }, + { + "description": "list - [_ float32]", + "input": "9ffa7f800000ff", + "expect": { + "list": [ + { + "float32": 2139095040 + } + ] + } + }, + { + "description": "list - [_ negint/1/max]", + "input": "9f38ffff", + "expect": { + "list": [ + { + "negint": -256 + } + ] + } + }, + { + "description": "list - [uint/8/max]", + "input": "811bffffffffffffffff", + "expect": { + "list": [ + { + "uint": 18446744073709551615 + } + ] + } + }, + { + "description": "list - [negint/4/min]", + "input": "813a00000000", + "expect": { + "list": [ + { + "negint": -1 + } + ] + } + }, + { + "description": "list - [negint/8/max]", + "input": "813bfffffffffffffffe", + "expect": { + "list": [ + { + "negint": -18446744073709551615 + } + ] + } + }, + { + "description": "list - [_ negint/2/min]", + "input": "9f390000ff", + "expect": { + "list": [ + { + "negint": -1 + } + ] + } + }, + { + "description": "list - [_ negint/4/max]", + "input": "9f3affffffffff", + "expect": { + "list": [ + { + "negint": -4294967296 + } + ] + } + }, + { + "description": "list - [_ true]", + "input": "9ff5ff", + "expect": { + "list": [ + { + "bool": true + } + ] + } + }, + { + "description": "list - [_ null]", + "input": "9ff6ff", + "expect": { + "list": [ + { + "null": {} + } + ] + } + }, + { + "description": "list - [uint/8/min]", + "input": "811b0000000000000000", + "expect": { + "list": [ + { + "uint": 0 + } + ] + } + }, + { + "description": "list - [null]", + "input": "81f6", + "expect": { + "list": [ + { + "null": {} + } + ] + } + }, + { + "description": "list - [_ uint/1/min]", + "input": "9f1800ff", + "expect": { + "list": [ + { + "uint": 0 + } + ] + } + }, + { + "description": "list - [_ uint/1/max]", + "input": "9f18ffff", + "expect": { + "list": [ + { + "uint": 255 + } + ] + } + }, + { + "description": "list - [_ uint/2/max]", + "input": "9f19ffffff", + "expect": { + "list": [ + { + "uint": 65535 + } + ] + } + }, + { + "description": "list - [_ uint/8/min]", + "input": "9f1b0000000000000000ff", + "expect": { + "list": [ + { + "uint": 0 + } + ] + } + }, + { + "description": "list - [_ negint/8/min]", + "input": "9f3b0000000000000000ff", + "expect": { + "list": [ + { + "negint": -1 + } + ] + } + }, + { + "description": "list - [_ float64]", + "input": "9ffb7ff0000000000000ff", + "expect": { + "list": [ + { + "float64": 9218868437227405312 + } + ] + } + }, + { + "description": "list - [uint/4/min]", + "input": "811a00000000", + "expect": { + "list": [ + { + "uint": 0 + } + ] + } + }, + { + "description": "list - [true]", + "input": "81f5", + "expect": { + "list": [ + { + "bool": true + } + ] + } + }, + { + "description": "list - [float32]", + "input": "81fa7f800000", + "expect": { + "list": [ + { + "float32": 2139095040 + } + ] + } + }, + { + "description": "list - [_ uint/0/min]", + "input": "9f00ff", + "expect": { + "list": [ + { + "uint": 0 + } + ] + } + }, + { + "description": "list - [_ uint/0/max]", + "input": "9f17ff", + "expect": { + "list": [ + { + "uint": 23 + } + ] + } + }, + { + "description": "list - [_ uint/8/max]", + "input": "9f1bffffffffffffffffff", + "expect": { + "list": [ + { + "uint": 18446744073709551615 + } + ] + } + }, + { + "description": "list - [_ negint/1/min]", + "input": "9f3800ff", + "expect": { + "list": [ + { + "negint": -1 + } + ] + } + }, + { + "description": "list - [_ negint/2/max]", + "input": "9f39ffffff", + "expect": { + "list": [ + { + "negint": -65536 + } + ] + } + }, + { + "description": "list - [uint/2/max]", + "input": "8119ffff", + "expect": { + "list": [ + { + "uint": 65535 + } + ] + } + }, + { + "description": "list - [negint/2/max]", + "input": "8139ffff", + "expect": { + "list": [ + { + "negint": -65536 + } + ] + } + }, + { + "description": "list - [false]", + "input": "81f4", + "expect": { + "list": [ + { + "bool": false + } + ] + } + }, + { + "description": "list - [_ negint/0/min]", + "input": "9f20ff", + "expect": { + "list": [ + { + "negint": -1 + } + ] + } + }, + { + "description": "list - [_ negint/8/max]", + "input": "9f3bfffffffffffffffeff", + "expect": { + "list": [ + { + "negint": -18446744073709551615 + } + ] + } + }, + { + "description": "list - [uint/4/max]", + "input": "811affffffff", + "expect": { + "list": [ + { + "uint": 4294967295 + } + ] + } + }, + { + "description": "map - {uint/0/min}", + "input": "a163666f6f00", + "expect": { + "map": { + "foo": { + "uint": 0 + } + } + } + }, + { + "description": "map - {uint/4/max}", + "input": "a163666f6f1affffffff", + "expect": { + "map": { + "foo": { + "uint": 4294967295 + } + } + } + }, + { + "description": "map - {negint/0/min}", + "input": "a163666f6f20", + "expect": { + "map": { + "foo": { + "negint": -1 + } + } + } + }, + { + "description": "map - {_ float32}", + "input": "bf63666f6ffa7f800000ff", + "expect": { + "map": { + "foo": { + "float32": 2139095040 + } + } + } + }, + { + "description": "map - {false}", + "input": "a163666f6ff4", + "expect": { + "map": { + "foo": { + "bool": false + } + } + } + }, + { + "description": "map - {float32}", + "input": "a163666f6ffa7f800000", + "expect": { + "map": { + "foo": { + "float32": 2139095040 + } + } + } + }, + { + "description": "map - {_ uint/0/max}", + "input": "bf63666f6f17ff", + "expect": { + "map": { + "foo": { + "uint": 23 + } + } + } + }, + { + "description": "map - {_ negint/2/min}", + "input": "bf63666f6f390000ff", + "expect": { + "map": { + "foo": { + "negint": -1 + } + } + } + }, + { + "description": "map - {_ false}", + "input": "bf63666f6ff4ff", + "expect": { + "map": { + "foo": { + "bool": false + } + } + } + }, + { + "description": "map - {uint/8/min}", + "input": "a163666f6f1b0000000000000000", + "expect": { + "map": { + "foo": { + "uint": 0 + } + } + } + }, + { + "description": "map - {_ negint/0/max}", + "input": "bf63666f6f37ff", + "expect": { + "map": { + "foo": { + "negint": -24 + } + } + } + }, + { + "description": "map - {_ null}", + "input": "bf63666f6ff6ff", + "expect": { + "map": { + "foo": { + "null": {} + } + } + } + }, + { + "description": "map - {uint/1/min}", + "input": "a163666f6f1800", + "expect": { + "map": { + "foo": { + "uint": 0 + } + } + } + }, + { + "description": "map - {_ uint/1/min}", + "input": "bf63666f6f1800ff", + "expect": { + "map": { + "foo": { + "uint": 0 + } + } + } + }, + { + "description": "map - {_ uint/8/max}", + "input": "bf63666f6f1bffffffffffffffffff", + "expect": { + "map": { + "foo": { + "uint": 18446744073709551615 + } + } + } + }, + { + "description": "map - {_ negint/0/min}", + "input": "bf63666f6f20ff", + "expect": { + "map": { + "foo": { + "negint": -1 + } + } + } + }, + { + "description": "map - {_ negint/1/min}", + "input": "bf63666f6f3800ff", + "expect": { + "map": { + "foo": { + "negint": -1 + } + } + } + }, + { + "description": "map - {_ negint/1/max}", + "input": "bf63666f6f38ffff", + "expect": { + "map": { + "foo": { + "negint": -256 + } + } + } + }, + { + "description": "map - {_ negint/2/max}", + "input": "bf63666f6f39ffffff", + "expect": { + "map": { + "foo": { + "negint": -65536 + } + } + } + }, + { + "description": "map - {_ negint/4/min}", + "input": "bf63666f6f3a00000000ff", + "expect": { + "map": { + "foo": { + "negint": -1 + } + } + } + }, + { + "description": "map - {_ true}", + "input": "bf63666f6ff5ff", + "expect": { + "map": { + "foo": { + "bool": true + } + } + } + }, + { + "description": "map - {uint/2/max}", + "input": "a163666f6f19ffff", + "expect": { + "map": { + "foo": { + "uint": 65535 + } + } + } + }, + { + "description": "map - {uint/8/max}", + "input": "a163666f6f1bffffffffffffffff", + "expect": { + "map": { + "foo": { + "uint": 18446744073709551615 + } + } + } + }, + { + "description": "map - {negint/0/max}", + "input": "a163666f6f37", + "expect": { + "map": { + "foo": { + "negint": -24 + } + } + } + }, + { + "description": "map - {negint/1/max}", + "input": "a163666f6f38ff", + "expect": { + "map": { + "foo": { + "negint": -256 + } + } + } + }, + { + "description": "map - {negint/2/max}", + "input": "a163666f6f39ffff", + "expect": { + "map": { + "foo": { + "negint": -65536 + } + } + } + }, + { + "description": "map - {negint/4/min}", + "input": "a163666f6f3a00000000", + "expect": { + "map": { + "foo": { + "negint": -1 + } + } + } + }, + { + "description": "map - {negint/8/max}", + "input": "a163666f6f3bfffffffffffffffe", + "expect": { + "map": { + "foo": { + "negint": -18446744073709551615 + } + } + } + }, + { + "description": "map - {float64}", + "input": "a163666f6ffb7ff0000000000000", + "expect": { + "map": { + "foo": { + "float64": 9218868437227405312 + } + } + } + }, + { + "description": "map - {_ uint/0/min}", + "input": "bf63666f6f00ff", + "expect": { + "map": { + "foo": { + "uint": 0 + } + } + } + }, + { + "description": "map - {_ uint/4/min}", + "input": "bf63666f6f1a00000000ff", + "expect": { + "map": { + "foo": { + "uint": 0 + } + } + } + }, + { + "description": "map - {_ uint/8/min}", + "input": "bf63666f6f1b0000000000000000ff", + "expect": { + "map": { + "foo": { + "uint": 0 + } + } + } + }, + { + "description": "map - {uint/1/max}", + "input": "a163666f6f18ff", + "expect": { + "map": { + "foo": { + "uint": 255 + } + } + } + }, + { + "description": "map - {negint/2/min}", + "input": "a163666f6f390000", + "expect": { + "map": { + "foo": { + "negint": -1 + } + } + } + }, + { + "description": "map - {negint/8/min}", + "input": "a163666f6f3b0000000000000000", + "expect": { + "map": { + "foo": { + "negint": -1 + } + } + } + }, + { + "description": "map - {true}", + "input": "a163666f6ff5", + "expect": { + "map": { + "foo": { + "bool": true + } + } + } + }, + { + "description": "map - {_ uint/2/min}", + "input": "bf63666f6f190000ff", + "expect": { + "map": { + "foo": { + "uint": 0 + } + } + } + }, + { + "description": "map - {_ negint/8/min}", + "input": "bf63666f6f3b0000000000000000ff", + "expect": { + "map": { + "foo": { + "negint": -1 + } + } + } + }, + { + "description": "map - {_ negint/8/max}", + "input": "bf63666f6f3bfffffffffffffffeff", + "expect": { + "map": { + "foo": { + "negint": -18446744073709551615 + } + } + } + }, + { + "description": "map - {uint/0/max}", + "input": "a163666f6f17", + "expect": { + "map": { + "foo": { + "uint": 23 + } + } + } + }, + { + "description": "map - {negint/4/max}", + "input": "a163666f6f3affffffff", + "expect": { + "map": { + "foo": { + "negint": -4294967296 + } + } + } + }, + { + "description": "map - {null}", + "input": "a163666f6ff6", + "expect": { + "map": { + "foo": { + "null": {} + } + } + } + }, + { + "description": "map - {_ uint/4/max}", + "input": "bf63666f6f1affffffffff", + "expect": { + "map": { + "foo": { + "uint": 4294967295 + } + } + } + }, + { + "description": "map - {_ float64}", + "input": "bf63666f6ffb7ff0000000000000ff", + "expect": { + "map": { + "foo": { + "float64": 9218868437227405312 + } + } + } + }, + { + "description": "map - {uint/2/min}", + "input": "a163666f6f190000", + "expect": { + "map": { + "foo": { + "uint": 0 + } + } + } + }, + { + "description": "map - {uint/4/min}", + "input": "a163666f6f1a00000000", + "expect": { + "map": { + "foo": { + "uint": 0 + } + } + } + }, + { + "description": "map - {negint/1/min}", + "input": "a163666f6f3800", + "expect": { + "map": { + "foo": { + "negint": -1 + } + } + } + }, + { + "description": "map - {_ uint/1/max}", + "input": "bf63666f6f18ffff", + "expect": { + "map": { + "foo": { + "uint": 255 + } + } + } + }, + { + "description": "map - {_ uint/2/max}", + "input": "bf63666f6f19ffffff", + "expect": { + "map": { + "foo": { + "uint": 65535 + } + } + } + }, + { + "description": "map - {_ negint/4/max}", + "input": "bf63666f6f3affffffffff", + "expect": { + "map": { + "foo": { + "negint": -4294967296 + } + } + } + }, + { + "description": "tag - 0/min", + "input": "c074323030332D31322D31335431383A33303A30325A", + "expect": { + "tag": { + "id": 0, + "value": { + "string": "2003-12-13T18:30:02Z" + } + } + } + }, + { + "description": "tag - 1/min", + "input": "d80074323030332D31322D31335431383A33303A30325A", + "expect": { + "tag": { + "id": 0, + "value": { + "string": "2003-12-13T18:30:02Z" + } + } + } + }, + { + "description": "tag - 1/max", + "input": "d8ff01", + "expect": { + "tag": { + "id": 255, + "value": { + "uint": 1 + } + } + } + }, + { + "description": "tag - 4/min", + "input": "da0000000074323030332D31322D31335431383A33303A30325A", + "expect": { + "tag": { + "id": 0, + "value": { + "string": "2003-12-13T18:30:02Z" + } + } + } + }, + { + "description": "tag - 8/min", + "input": "db000000000000000074323030332D31322D31335431383A33303A30325A", + "expect": { + "tag": { + "id": 0, + "value": { + "string": "2003-12-13T18:30:02Z" + } + } + } + }, + { + "description": "tag - 0/max", + "input": "d701", + "expect": { + "tag": { + "id": 23, + "value": { + "uint": 1 + } + } + } + }, + { + "description": "tag - 2/min", + "input": "d9000074323030332D31322D31335431383A33303A30325A", + "expect": { + "tag": { + "id": 0, + "value": { + "string": "2003-12-13T18:30:02Z" + } + } + } + }, + { + "description": "tag - 2/max", + "input": "d9fffe01", + "expect": { + "tag": { + "id": 65534, + "value": { + "uint": 1 + } + } + } + }, + { + "description": "tag - 4/max", + "input": "dafffffffe01", + "expect": { + "tag": { + "id": 4294967294, + "value": { + "uint": 1 + } + } + } + }, + { + "description": "tag - 8/max", + "input": "dbfffffffffffffffe01", + "expect": { + "tag": { + "id": 18446744073709551614, + "value": { + "uint": 1 + } + } + } + } +]