diff --git a/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/AwsJson10ProtocolGenerator.java b/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/AwsJson10ProtocolGenerator.java new file mode 100644 index 000000000..cc7289a5b --- /dev/null +++ b/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/AwsJson10ProtocolGenerator.java @@ -0,0 +1,83 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package software.amazon.smithy.python.aws.codegen; + +import java.util.Set; +import software.amazon.smithy.aws.traits.protocols.AwsJson1_0Trait; +import software.amazon.smithy.model.node.ArrayNode; +import software.amazon.smithy.model.node.ObjectNode; +import software.amazon.smithy.model.shapes.ShapeId; +import software.amazon.smithy.python.codegen.ApplicationProtocol; +import software.amazon.smithy.python.codegen.GenerationContext; +import software.amazon.smithy.python.codegen.HttpProtocolTestGenerator; +import software.amazon.smithy.python.codegen.SymbolProperties; +import software.amazon.smithy.python.codegen.generators.ProtocolGenerator; +import software.amazon.smithy.python.codegen.writer.PythonWriter; +import software.amazon.smithy.utils.SmithyInternalApi; + +@SmithyInternalApi +public final class AwsJson10ProtocolGenerator implements ProtocolGenerator { + private static final Set TESTS_TO_SKIP = Set.of( + // These tests essentially try to assert nan == nan, which is never true. + // The generator needs protocol-specific assertions before enabling them. + "AwsJson10SupportsNaNFloatInputs", + + // TODO: support the request compression trait. + "SDKAppliedContentEncoding_awsJson1_0", + "SDKAppendsGzipAndIgnoresHttpProvidedEncoding_awsJson1_0", + + // TODO: fix default value behavior for JSON RPC. + "AwsJson10ClientPopulatesDefaultValuesInInput", + "AwsJson10ClientSkipsTopLevelDefaultValuesInInput", + "AwsJson10ClientUsesExplicitlyProvidedMemberValuesOverDefaults", + "AwsJson10ClientPopulatesDefaultsValuesWhenMissingInResponse", + "AwsJson10ClientIgnoresNonTopLevelDefaultsOnMembersWithClientOptional", + + // TODO: support the endpoint trait. + "AwsJson10EndpointTrait", + "AwsJson10EndpointTraitWithHostLabel", + + // TODO: support client error-correction behavior when the server + // omits required values in modeled error responses. + "AwsJson10ClientErrorCorrectsWhenServerFailsToSerializeRequiredValues", + "AwsJson10ClientErrorCorrectsWithDefaultValuesWhenServerFailsToSerializeRequiredValues"); + + @Override + public ShapeId getProtocol() { + return AwsJson1_0Trait.ID; + } + + @Override + public ApplicationProtocol getApplicationProtocol(GenerationContext context) { + var service = context.settings().service(context.model()); + var trait = service.expectTrait(AwsJson1_0Trait.class); + var config = ObjectNode.builder() + .withMember("http", ArrayNode.fromStrings(trait.getHttp())) + .withMember("eventStreamHttp", ArrayNode.fromStrings(trait.getEventStreamHttp())) + .build(); + return ApplicationProtocol.createDefaultHttpApplicationProtocol(config); + } + + @Override + public void initializeProtocol(GenerationContext context, PythonWriter writer) { + writer.addDependency(AwsPythonDependency.SMITHY_AWS_CORE.withOptionalDependencies("json")); + writer.addImport("smithy_aws_core.aio.protocols", "AwsJson10ClientProtocol"); + var serviceSymbol = context.symbolProvider().toSymbol(context.settings().service(context.model())); + var serviceSchema = serviceSymbol.expectProperty(SymbolProperties.SCHEMA); + writer.write("AwsJson10ClientProtocol($T)", serviceSchema); + } + + @Override + public void generateProtocolTests(GenerationContext context) { + context.writerDelegator() + .useFileWriter("./tests/test_awsjson10_protocol.py", "tests.test_awsjson10_protocol", writer -> { + new HttpProtocolTestGenerator( + context, + getProtocol(), + writer, + (shape, testCase) -> TESTS_TO_SKIP.contains(testCase.getId())).run(); + }); + } +} diff --git a/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/AwsJson11ProtocolGenerator.java b/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/AwsJson11ProtocolGenerator.java new file mode 100644 index 000000000..ea9f80458 --- /dev/null +++ b/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/AwsJson11ProtocolGenerator.java @@ -0,0 +1,71 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package software.amazon.smithy.python.aws.codegen; + +import java.util.Set; +import software.amazon.smithy.aws.traits.protocols.AwsJson1_1Trait; +import software.amazon.smithy.model.node.ArrayNode; +import software.amazon.smithy.model.node.ObjectNode; +import software.amazon.smithy.model.shapes.ShapeId; +import software.amazon.smithy.python.codegen.ApplicationProtocol; +import software.amazon.smithy.python.codegen.GenerationContext; +import software.amazon.smithy.python.codegen.HttpProtocolTestGenerator; +import software.amazon.smithy.python.codegen.SymbolProperties; +import software.amazon.smithy.python.codegen.generators.ProtocolGenerator; +import software.amazon.smithy.python.codegen.writer.PythonWriter; +import software.amazon.smithy.utils.SmithyInternalApi; + +@SmithyInternalApi +public final class AwsJson11ProtocolGenerator implements ProtocolGenerator { + private static final Set TESTS_TO_SKIP = Set.of( + // These tests essentially try to assert nan == nan, which is never true. + // The generator needs protocol-specific assertions before enabling them. + "AwsJson11SupportsNaNFloatInputs", + + // TODO: support the request compression trait. + "SDKAppliedContentEncoding_awsJson1_1", + "SDKAppendsGzipAndIgnoresHttpProvidedEncoding_awsJson1_1", + + // TODO: support the endpoint trait. + "AwsJson11EndpointTrait", + "AwsJson11EndpointTraitWithHostLabel"); + + @Override + public ShapeId getProtocol() { + return AwsJson1_1Trait.ID; + } + + @Override + public ApplicationProtocol getApplicationProtocol(GenerationContext context) { + var service = context.settings().service(context.model()); + var trait = service.expectTrait(AwsJson1_1Trait.class); + var config = ObjectNode.builder() + .withMember("http", ArrayNode.fromStrings(trait.getHttp())) + .withMember("eventStreamHttp", ArrayNode.fromStrings(trait.getEventStreamHttp())) + .build(); + return ApplicationProtocol.createDefaultHttpApplicationProtocol(config); + } + + @Override + public void initializeProtocol(GenerationContext context, PythonWriter writer) { + writer.addDependency(AwsPythonDependency.SMITHY_AWS_CORE.withOptionalDependencies("json")); + writer.addImport("smithy_aws_core.aio.protocols", "AwsJson11ClientProtocol"); + var serviceSymbol = context.symbolProvider().toSymbol(context.settings().service(context.model())); + var serviceSchema = serviceSymbol.expectProperty(SymbolProperties.SCHEMA); + writer.write("AwsJson11ClientProtocol($T)", serviceSchema); + } + + @Override + public void generateProtocolTests(GenerationContext context) { + context.writerDelegator() + .useFileWriter("./tests/test_awsjson11_protocol.py", "tests.test_awsjson11_protocol", writer -> { + new HttpProtocolTestGenerator( + context, + getProtocol(), + writer, + (shape, testCase) -> TESTS_TO_SKIP.contains(testCase.getId())).run(); + }); + } +} diff --git a/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/AwsProtocolsIntegration.java b/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/AwsProtocolsIntegration.java index d7d24a4af..6c714beed 100644 --- a/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/AwsProtocolsIntegration.java +++ b/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/AwsProtocolsIntegration.java @@ -16,6 +16,9 @@ public class AwsProtocolsIntegration implements PythonIntegration { @Override public List getProtocolGenerators() { - return List.of(new AwsQueryProtocolGenerator()); + return List.of( + new AwsQueryProtocolGenerator(), + new AwsJson10ProtocolGenerator(), + new AwsJson11ProtocolGenerator()); } } diff --git a/codegen/core/src/main/java/software/amazon/smithy/python/codegen/integrations/RestJsonProtocolGenerator.java b/codegen/core/src/main/java/software/amazon/smithy/python/codegen/integrations/RestJsonProtocolGenerator.java index 1a31050e7..619226200 100644 --- a/codegen/core/src/main/java/software/amazon/smithy/python/codegen/integrations/RestJsonProtocolGenerator.java +++ b/codegen/core/src/main/java/software/amazon/smithy/python/codegen/integrations/RestJsonProtocolGenerator.java @@ -94,13 +94,14 @@ public void initializeProtocol(GenerationContext context, PythonWriter writer) { // it will need to generate some protocol-specific comparators. @Override public void generateProtocolTests(GenerationContext context) { - context.writerDelegator().useFileWriter("./tests/test_protocol.py", "tests.test_protocol", writer -> { - new HttpProtocolTestGenerator( - context, - getProtocol(), - writer, - (shape, testCase) -> filterTests(testCase)).run(); - }); + context.writerDelegator() + .useFileWriter("./tests/test_restjson_protocol.py", "tests.test_restjson_protocol", writer -> { + new HttpProtocolTestGenerator( + context, + getProtocol(), + writer, + (shape, testCase) -> filterTests(testCase)).run(); + }); } private boolean filterTests(HttpMessageTestCase testCase) { diff --git a/codegen/protocol-test/smithy-build.json b/codegen/protocol-test/smithy-build.json index f5ec825f2..052eeca64 100644 --- a/codegen/protocol-test/smithy-build.json +++ b/codegen/protocol-test/smithy-build.json @@ -23,6 +23,50 @@ } } }, + "aws-json-1-0": { + "transforms": [ + { + "name": "includeServices", + "args": { + "services": [ + "aws.protocoltests.json10#JsonRpc10" + ] + } + }, + { + "name": "removeUnusedShapes" + } + ], + "plugins": { + "python-client-codegen": { + "service": "aws.protocoltests.json10#JsonRpc10", + "module": "awsjson10", + "moduleVersion": "0.0.1" + } + } + }, + "aws-json-1-1": { + "transforms": [ + { + "name": "includeServices", + "args": { + "services": [ + "aws.protocoltests.json#JsonProtocol" + ] + } + }, + { + "name": "removeUnusedShapes" + } + ], + "plugins": { + "python-client-codegen": { + "service": "aws.protocoltests.json#JsonProtocol", + "module": "awsjson11", + "moduleVersion": "0.0.1" + } + } + }, "aws-query": { "transforms": [ { diff --git a/packages/smithy-aws-core/src/smithy_aws_core/aio/protocols.py b/packages/smithy-aws-core/src/smithy_aws_core/aio/protocols.py index 68d2c2d01..075c05f85 100644 --- a/packages/smithy-aws-core/src/smithy_aws_core/aio/protocols.py +++ b/packages/smithy-aws-core/src/smithy_aws_core/aio/protocols.py @@ -3,7 +3,7 @@ from collections.abc import Callable from inspect import iscoroutinefunction from io import BytesIO -from typing import TYPE_CHECKING, Any, Final +from typing import TYPE_CHECKING, Any, ClassVar, Final from smithy_core import URI as _URI from smithy_core.aio.interfaces import AsyncWriter @@ -16,10 +16,13 @@ from smithy_core.exceptions import ( CallError, DiscriminatorError, + ExpectationNotMetError, MissingDependencyError, + ModeledError, UnsupportedStreamError, ) from smithy_core.interfaces import TypedProperties, URI +from smithy_core.prelude import DOCUMENT from smithy_core.schemas import APIOperation, Schema from smithy_core.serializers import SerializeableShape from smithy_core.shapes import ShapeID, ShapeType @@ -30,11 +33,9 @@ from smithy_http.aio.protocols import HttpBindingClientProtocol, HttpClientProtocol from smithy_http.deserializers import HTTPResponseDeserializer -from .._private.query.errors import ( - create_aws_query_error, -) +from .._private.query.errors import create_aws_query_error from .._private.query.serializers import QueryShapeSerializer -from ..traits import AwsQueryTrait, RestJson1Trait +from ..traits import AwsJson1_0Trait, AwsJson1_1Trait, AwsQueryTrait, RestJson1Trait from ..utils import parse_document_discriminator, parse_error_code try: @@ -114,7 +115,7 @@ def identify( return None -if _HAS_JSON: +if TYPE_CHECKING or _HAS_JSON: class AWSJSONDocument(JSONDocument): @property @@ -129,13 +130,17 @@ def discriminator(self) -> ShapeID: f"Unable to parse discriminator for {self.shape_type} document." ) return parsed +else: + + class AWSJSONDocument: # type: ignore[no-redef] + pass class RestJsonClientProtocol(HttpBindingClientProtocol): """An implementation of the aws.protocols#restJson1 protocol.""" _id: Final = RestJson1Trait.id - _contentType: Final = "application/json" + _content_type: Final = "application/json" _error_identifier: Final = AWSErrorIdentifier() def __init__(self, service_schema: Schema) -> None: @@ -160,7 +165,7 @@ def payload_codec(self) -> Codec: @property def content_type(self) -> str: - return self._contentType + return self._content_type @property def error_identifier(self) -> HTTPErrorIdentifier: @@ -241,6 +246,199 @@ def create_event_receiver[ ) +class _AWSJSONClientProtocol(HttpClientProtocol): + _error_identifier: Final = AWSErrorIdentifier() + + _id: ClassVar[ShapeID] + _content_type: ClassVar[str] + + def __init__(self, service_schema: Schema) -> None: + _assert_json() + self._service_name: Final = service_schema.id.name + self._codec: Final = JSONCodec( + document_class=AWSJSONDocument, + default_namespace=service_schema.id.namespace, + default_timestamp_format=TimestampFormat.EPOCH_SECONDS, + use_json_name=False, + ) + + @property + def id(self) -> ShapeID: + return self._id + + @property + def payload_codec(self) -> Codec: + return self._codec + + @property + def content_type(self) -> str: + return self._content_type + + @property + def error_identifier(self) -> HTTPErrorIdentifier: + return self._error_identifier + + def serialize_request[ + OperationInput: SerializeableShape, + OperationOutput: DeserializeableShape, + ]( + self, + *, + operation: APIOperation[OperationInput, OperationOutput], + input: OperationInput, + endpoint: URI, + context: TypedProperties, + ) -> HTTPRequest: + payload = self.payload_codec.serialize(shape=input) + return _HTTPRequest( + method="POST", + destination=_URI(host="", path="/"), + fields=tuples_to_fields( + [ + ("content-type", self.content_type), + ("content-length", str(len(payload))), + ( + "x-amz-target", + f"{self._service_name}.{operation.schema.id.name}", + ), + ] + ), + body=AsyncBytesReader(payload), + ) + + async def deserialize_response[ + OperationInput: SerializeableShape, + OperationOutput: DeserializeableShape, + ]( + self, + *, + operation: APIOperation[OperationInput, OperationOutput], + request: HTTPRequest, + response: HTTPResponse, + error_registry: TypeRegistry, + context: TypedProperties, + ) -> OperationOutput: + body = await response.consume_body_async() + + if not self._is_success(operation, context, response): + raise await self._create_error( + operation=operation, + response=response, + response_body=body, + error_registry=error_registry, + context=context, + ) + + if len(body) == 0: + body = b"{}" + return self.payload_codec.deserialize(source=body, shape=operation.output) + + def _is_success( + self, + operation: APIOperation[Any, Any], + context: TypedProperties, + response: HTTPResponse, + ) -> bool: + return 200 <= response.status < 300 + + async def _create_error( + self, + *, + operation: APIOperation[Any, Any], + response: HTTPResponse, + response_body: bytes, + error_registry: TypeRegistry, + context: TypedProperties, + ) -> CallError: + error_id = self.error_identifier.identify( + operation=operation, response=response + ) + if error_id is not None and error_id not in error_registry: + error_id = self._resolve_error_id(operation=operation, error_id=error_id) + + if ( + error_id is None + and len(response_body) > 0 + and self._matches_content_type(response) + ): + deserializer = self.payload_codec.create_deserializer(response_body) + document = deserializer.read_document(schema=DOCUMENT) + document_error_id = document.discriminator + if document_error_id not in error_registry: + document_error_id = self._resolve_error_id( + operation=operation, error_id=document_error_id + ) + + if document_error_id in error_registry: + error_id = document_error_id + + if error_id is not None and error_id in error_registry: + error_shape = error_registry.get(error_id) + + # make sure the error shape is derived from modeled exception + if not issubclass(error_shape, ModeledError): + raise ExpectationNotMetError( + f"Modeled errors must be derived from 'ModeledError', " + f"but got {error_shape}" + ) + + body = response_body if len(response_body) > 0 else b"{}" + deserializer = self.payload_codec.create_deserializer(body) + return error_shape.deserialize(deserializer) + + message = ( + f"Unknown error for operation {operation.schema.id} " + f"- status: {response.status}" + ) + if error_id is not None: + message += f" - id: {error_id}" + if response.reason is not None: + message += f" - reason: {response.reason}" + + is_timeout = response.status == 408 + is_throttle = response.status == 429 + fault = "client" if response.status < 500 else "server" + + return CallError( + message=message, + fault=fault, + is_throttling_error=is_throttle, + is_timeout_error=is_timeout, + is_retry_safe=is_throttle or is_timeout or None, + ) + + def _matches_content_type(self, response: HTTPResponse) -> bool: + if "content-type" not in response.fields: + return False + actual = response.fields["content-type"].as_string() + return actual.split(";", 1)[0].strip().lower() == self.content_type.lower() + + def _resolve_error_id( + self, + *, + operation: APIOperation[Any, Any], + error_id: ShapeID, + ) -> ShapeID: + for error_schema in operation.error_schemas: + if error_schema.id.name == error_id.name: + return error_schema.id + return error_id + + +class AwsJson10ClientProtocol(_AWSJSONClientProtocol): + """An implementation of the aws.protocols#awsJson1_0 protocol.""" + + _id: ClassVar[ShapeID] = AwsJson1_0Trait.id + _content_type: ClassVar[str] = "application/x-amz-json-1.0" + + +class AwsJson11ClientProtocol(_AWSJSONClientProtocol): + """An implementation of the aws.protocols#awsJson1_1 protocol.""" + + _id: ClassVar[ShapeID] = AwsJson1_1Trait.id + _content_type: ClassVar[str] = "application/x-amz-json-1.1" + + class AwsQueryClientProtocol(HttpClientProtocol): """An implementation of the aws.protocols#awsQuery protocol.""" diff --git a/packages/smithy-aws-core/src/smithy_aws_core/traits.py b/packages/smithy-aws-core/src/smithy_aws_core/traits.py index 2e0c370ab..3fd1ed5db 100644 --- a/packages/smithy-aws-core/src/smithy_aws_core/traits.py +++ b/packages/smithy-aws-core/src/smithy_aws_core/traits.py @@ -15,6 +15,40 @@ from smithy_core.traits import DynamicTrait, Trait +def _parse_http_protocol_values( + value: DocumentValue | DynamicTrait | None, +) -> tuple[tuple[str, ...], tuple[str, ...]]: + """Parse aws.protocols HTTP settings from a trait document. + + The input is expected to be shaped like {"http": [...], "eventStreamHttp": [...]} + and returns (http_versions, event_stream_http_versions). If "eventStreamHttp" + is absent, event streams use the same versions as "http". If "http" is absent, + it defaults to ("http/1.1",). + """ + document_value = value or {} + assert isinstance(document_value, Mapping) + + http_versions_raw = document_value.get("http", ["http/1.1"]) + assert isinstance(http_versions_raw, Sequence) + http_versions_list: list[str] = [] + for entry in http_versions_raw: + assert isinstance(entry, str) + http_versions_list.append(entry) + http_versions = tuple(http_versions_list) + + event_stream_http_versions_raw = document_value.get("eventStreamHttp") + if not event_stream_http_versions_raw: + return http_versions, http_versions + + assert isinstance(event_stream_http_versions_raw, Sequence) + event_stream_http_versions_list: list[str] = [] + for entry in event_stream_http_versions_raw: + assert isinstance(entry, str) + event_stream_http_versions_list.append(entry) + + return http_versions, tuple(event_stream_http_versions_list) + + @dataclass(init=False, frozen=True) class RestJson1Trait(Trait, id=ShapeID("aws.protocols#restJson1")): http: Sequence[str] = field( @@ -26,24 +60,41 @@ class RestJson1Trait(Trait, id=ShapeID("aws.protocols#restJson1")): def __init__(self, value: DocumentValue | DynamicTrait = None): super().__init__(value) - document_value = value or {} - assert isinstance(document_value, Mapping) - - http_versions = document_value.get("http", ["http/1.1"]) - assert isinstance(http_versions, Sequence) - for val in http_versions: - assert isinstance(val, str) - object.__setattr__(self, "http", tuple(http_versions)) - event_stream_http_versions = document_value.get("eventStreamHttp") - if not event_stream_http_versions: - object.__setattr__(self, "event_stream_http", self.http) - else: - assert isinstance(event_stream_http_versions, Sequence) - for val in event_stream_http_versions: - assert isinstance(val, str) - object.__setattr__( - self, "event_stream_http", tuple(event_stream_http_versions) - ) + http, event_stream_http = _parse_http_protocol_values(value) + object.__setattr__(self, "http", http) + object.__setattr__(self, "event_stream_http", event_stream_http) + + +@dataclass(init=False, frozen=True) +class AwsJson1_0Trait(Trait, id=ShapeID("aws.protocols#awsJson1_0")): + http: Sequence[str] = field( + repr=False, hash=False, compare=False, default_factory=tuple + ) + event_stream_http: Sequence[str] = field( + repr=False, hash=False, compare=False, default_factory=tuple + ) + + def __init__(self, value: DocumentValue | DynamicTrait = None): + super().__init__(value) + http, event_stream_http = _parse_http_protocol_values(value) + object.__setattr__(self, "http", http) + object.__setattr__(self, "event_stream_http", event_stream_http) + + +@dataclass(init=False, frozen=True) +class AwsJson1_1Trait(Trait, id=ShapeID("aws.protocols#awsJson1_1")): + http: Sequence[str] = field( + repr=False, hash=False, compare=False, default_factory=tuple + ) + event_stream_http: Sequence[str] = field( + repr=False, hash=False, compare=False, default_factory=tuple + ) + + def __init__(self, value: DocumentValue | DynamicTrait = None): + super().__init__(value) + http, event_stream_http = _parse_http_protocol_values(value) + object.__setattr__(self, "http", http) + object.__setattr__(self, "event_stream_http", event_stream_http) @dataclass(frozen=True) diff --git a/packages/smithy-aws-core/tests/unit/aio/test_protocols.py b/packages/smithy-aws-core/tests/unit/aio/test_protocols.py index 689095823..eae263441 100644 --- a/packages/smithy-aws-core/tests/unit/aio/test_protocols.py +++ b/packages/smithy-aws-core/tests/unit/aio/test_protocols.py @@ -6,12 +6,15 @@ from unittest.mock import Mock import pytest +from ijson.common import IncompleteJSONError # type: ignore[reportMissingTypeStubs] from smithy_aws_core.aio.protocols import ( AWSErrorIdentifier, + AwsJson11ClientProtocol, AWSJSONDocument, AwsQueryClientProtocol, ) from smithy_aws_core.traits import AwsQueryTrait +from smithy_core import URI as _URI from smithy_core.deserializers import ShapeDeserializer from smithy_core.documents import TypeRegistry from smithy_core.exceptions import CallError, DiscriminatorError, ModeledError @@ -39,6 +42,7 @@ "com.test#FooError:http://internal.amazon.com/coral/com.amazon.coral.validate", "com.test#FooError", ), + ("com.other#FooError", "com.other#FooError"), ("", None), (":", None), (None, None), @@ -111,6 +115,12 @@ def test_aws_json_document_discriminator( assert discriminator == expected +_EMPTY_INPUT_SCHEMA = Schema.collection( + id=ShapeID("com.test#EmptyInput"), +) +_EMPTY_OUTPUT_SCHEMA = Schema.collection( + id=ShapeID("com.test#EmptyOutput"), +) _INPUT_SCHEMA = Schema.collection( id=ShapeID("com.test#TestInput"), members={"name": {"target": STRING}}, @@ -133,6 +143,23 @@ def test_aws_json_document_discriminator( ) +@dataclass +class _EmptyInput: + def serialize(self, serializer: ShapeSerializer) -> None: + serializer.write_struct(_EMPTY_INPUT_SCHEMA, self) + + def serialize_members(self, serializer: ShapeSerializer) -> None: + pass + + +@dataclass +class _EmptyOutput: + @classmethod + def deserialize(cls, deserializer: ShapeDeserializer) -> "_EmptyOutput": + deserializer.read_struct(_EMPTY_OUTPUT_SCHEMA, lambda _schema, _de: None) + return cls() + + @dataclass class _TestInput: name: str | None = None @@ -178,6 +205,232 @@ def _mock_operation( return cast("APIOperation[Any, Any]", operation) +def _aws_json11_protocol() -> AwsJson11ClientProtocol: + return AwsJson11ClientProtocol( + Schema(id=ShapeID("com.test#JsonService"), shape_type=ShapeType.SERVICE) + ) + + +async def test_aws_json11_serializes_base_request_shape() -> None: + protocol = _aws_json11_protocol() + request = protocol.serialize_request( + operation=_mock_operation(_operation_schema("EmptyOperation")), + input=_EmptyInput(), + endpoint=_URI(host="example.com"), + context=TypedProperties(), + ) + + assert request.method == "POST" + assert request.destination.path == "/" + assert request.fields["content-type"].as_string() == "application/x-amz-json-1.1" + assert request.fields["x-amz-target"].as_string() == "JsonService.EmptyOperation" + assert request.fields["content-length"].as_string() == "2" + assert await request.consume_body_async() == b"{}" + + +async def test_aws_json11_resolves_body_error_with_content_type_parameters() -> None: + protocol = _aws_json11_protocol() + response = HTTPResponse( + status=500, + fields=tuples_to_fields( + [("content-type", "application/x-amz-json-1.1; charset=utf-8")] + ), + body=b'{"__type":"com.test#OtherNsError"}', + ) + operation = _mock_operation(_operation_schema("FailingOperation")) + + with pytest.raises(_ModeledJSONError): + await protocol.deserialize_response( + operation=operation, + request=cast(HTTPRequest, Mock()), + response=response, + error_registry=TypeRegistry( + {ShapeID("com.test#OtherNsError"): _ModeledJSONError} + ), + context=TypedProperties(), + ) + + +async def test_aws_json11_ignores_body_error_with_unexpected_content_type() -> None: + protocol = _aws_json11_protocol() + response = HTTPResponse( + status=500, + fields=tuples_to_fields([("content-type", "application/json")]), + body=b'{"__type":"com.test#OtherNsError"}', + ) + operation = _mock_operation(_operation_schema("FailingOperation")) + + with pytest.raises(CallError) as exc_info: + await protocol.deserialize_response( + operation=operation, + request=cast(HTTPRequest, Mock()), + response=response, + error_registry=TypeRegistry( + {ShapeID("com.test#OtherNsError"): _ModeledJSONError} + ), + context=TypedProperties(), + ) + + assert not isinstance(exc_info.value, ModeledError) + + +async def test_aws_json11_deserializes_empty_response_body() -> None: + protocol = _aws_json11_protocol() + operation = _mock_operation(_operation_schema("EmptyOperation")) + cast(Any, operation).output = _EmptyOutput + + output = await protocol.deserialize_response( + operation=operation, + request=cast(HTTPRequest, Mock()), + response=HTTPResponse(status=200, fields=Fields(), body=b""), + error_registry=TypeRegistry({}), + context=TypedProperties(), + ) + + assert isinstance(output, _EmptyOutput) + + +class _ModeledJSONError(ModeledError): + @classmethod + def deserialize(cls, deserializer: Any) -> "_ModeledJSONError": + return cls("modeled JSON error") + + +async def test_aws_json11_resolves_modeled_error_from_header_other_namespace() -> None: + protocol = _aws_json11_protocol() + operation = _mock_operation(_operation_schema("FailingOperation")) + response = HTTPResponse( + status=400, + reason="Bad Request", + fields=tuples_to_fields( + [ + ("x-amzn-errortype", "com.other#OtherNsError"), + ("content-type", "application/x-amz-json-1.1"), + ] + ), + body=b'{"__type":"com.other#OtherNsError"}', + ) + + with pytest.raises(_ModeledJSONError): + await protocol.deserialize_response( + operation=operation, + request=cast(HTTPRequest, Mock()), + response=response, + error_registry=TypeRegistry( + {ShapeID("com.other#OtherNsError"): _ModeledJSONError} + ), + context=TypedProperties(), + ) + + +async def test_aws_json11_resolves_modeled_error_from_header_only_shapeid() -> None: + protocol = _aws_json11_protocol() + operation = _mock_operation(_operation_schema("FailingOperation")) + response = HTTPResponse( + status=400, + reason="Bad Request", + fields=tuples_to_fields([("x-amzn-errortype", "com.other#OtherNsError")]), + body=b"", + ) + + with pytest.raises(_ModeledJSONError): + await protocol.deserialize_response( + operation=operation, + request=cast(HTTPRequest, Mock()), + response=response, + error_registry=TypeRegistry( + {ShapeID("com.other#OtherNsError"): _ModeledJSONError} + ), + context=TypedProperties(), + ) + + +_OTHER_NS_ERROR_SCHEMA = Schema.collection( + id=ShapeID("com.test#OtherNsError"), + traits=[Trait.new(id=ShapeID("smithy.api#error"), value="client")], + members={"message": {"target": STRING}}, +) + + +async def test_aws_json11_resolves_modeled_error_from_header_name_fallback() -> None: + # The wire error ID carries a different namespace than the modeled error. The + # awsJson protocols discriminate on shape name only, so it should resolve to the + # operation's modeled error by matching the shape name. + protocol = _aws_json11_protocol() + operation = _mock_operation( + _operation_schema("FailingOperation"), + error_schemas=[_OTHER_NS_ERROR_SCHEMA], + ) + response = HTTPResponse( + status=400, + reason="Bad Request", + fields=tuples_to_fields( + [ + ("x-amzn-errortype", "com.wire#OtherNsError"), + ("content-type", "application/x-amz-json-1.1"), + ] + ), + body=b'{"__type":"com.wire#OtherNsError"}', + ) + + with pytest.raises(_ModeledJSONError): + await protocol.deserialize_response( + operation=operation, + request=cast(HTTPRequest, Mock()), + response=response, + error_registry=TypeRegistry( + {ShapeID("com.test#OtherNsError"): _ModeledJSONError} + ), + context=TypedProperties(), + ) + + +async def test_aws_json11_resolves_modeled_error_from_body_name_fallback() -> None: + # Same as above, but the discriminator comes from the body's __type rather + # than the x-amzn-errortype header. + protocol = _aws_json11_protocol() + operation = _mock_operation( + _operation_schema("FailingOperation"), + error_schemas=[_OTHER_NS_ERROR_SCHEMA], + ) + response = HTTPResponse( + status=400, + reason="Bad Request", + fields=tuples_to_fields([("content-type", "application/x-amz-json-1.1")]), + body=b'{"__type":"com.wire#OtherNsError"}', + ) + + with pytest.raises(_ModeledJSONError): + await protocol.deserialize_response( + operation=operation, + request=cast(HTTPRequest, Mock()), + response=response, + error_registry=TypeRegistry( + {ShapeID("com.test#OtherNsError"): _ModeledJSONError} + ), + context=TypedProperties(), + ) + + +async def test_aws_json11_raises_parse_error_for_invalid_error_body() -> None: + protocol = _aws_json11_protocol() + operation = _mock_operation(_operation_schema("FailingOperation")) + response = HTTPResponse( + status=400, + fields=tuples_to_fields([("content-type", "application/x-amz-json-1.1")]), + body=b'{"__type":', + ) + + with pytest.raises(IncompleteJSONError, match=r"premature EOF|parse error"): + await protocol.deserialize_response( + operation=operation, + request=cast(HTTPRequest, Mock()), + response=response, + error_registry=TypeRegistry({}), + context=TypedProperties(), + ) + + async def test_aws_query_serializes_base_request_shape() -> None: protocol = AwsQueryClientProtocol(_SERVICE_SCHEMA, "2020-01-08") request = protocol.serialize_request( diff --git a/packages/smithy-aws-core/tests/unit/test_traits.py b/packages/smithy-aws-core/tests/unit/test_traits.py index bf4d7fe6c..af77e038b 100644 --- a/packages/smithy-aws-core/tests/unit/test_traits.py +++ b/packages/smithy-aws-core/tests/unit/test_traits.py @@ -1,11 +1,24 @@ # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. # SPDX-License-Identifier: Apache-2.0 -from smithy_aws_core.traits import AwsQueryErrorTrait, AwsQueryTrait, RestJson1Trait +import pytest +from smithy_aws_core.traits import ( + AwsJson1_0Trait, + AwsJson1_1Trait, + AwsQueryErrorTrait, + AwsQueryTrait, + RestJson1Trait, +) -def test_allows_empty_restjson1_value() -> None: - trait = RestJson1Trait(None) +@pytest.mark.parametrize( + "trait_type", + [RestJson1Trait, AwsJson1_0Trait, AwsJson1_1Trait], +) +def test_allows_empty_protocol_trait_value( + trait_type: type[RestJson1Trait] | type[AwsJson1_0Trait] | type[AwsJson1_1Trait], +) -> None: + trait = trait_type(None) assert trait.http == ("http/1.1",) assert trait.event_stream_http == ("http/1.1",) diff --git a/packages/smithy-aws-core/tests/unit/test_utils.py b/packages/smithy-aws-core/tests/unit/test_utils.py index 6927a2fce..cfaad670e 100644 --- a/packages/smithy-aws-core/tests/unit/test_utils.py +++ b/packages/smithy-aws-core/tests/unit/test_utils.py @@ -64,6 +64,11 @@ def test_aws_json_document_discriminator( "com.test#FooError:http://internal.amazon.com/coral/com.amazon.coral.validate", "com.test#FooError", ), + ("com.other#FooError", "com.other#FooError"), + ( + "com.other#FooError:http://internal.amazon.com/coral/com.amazon.coral.validate", + "com.other#FooError", + ), ("", None), (":", None), ], diff --git a/packages/smithy-core/tests/unit/aio/test_retries.py b/packages/smithy-core/tests/unit/aio/test_retries.py index f35c50750..771236d43 100644 --- a/packages/smithy-core/tests/unit/aio/test_retries.py +++ b/packages/smithy-core/tests/unit/aio/test_retries.py @@ -2,7 +2,6 @@ # SPDX-License-Identifier: Apache-2.0 import pytest from smithy_core.aio.retries import ( - RetryStrategyOptions, RetryStrategyResolver, SimpleRetryStrategy, StandardRetryStrategy, @@ -10,6 +9,7 @@ from smithy_core.exceptions import CallError, RetryError from smithy_core.retries import ( ExponentialRetryBackoffStrategy, + RetryStrategyOptions, ) diff --git a/packages/smithy-http/tests/unit/aio/test_protocols.py b/packages/smithy-http/tests/unit/aio/test_protocols.py index 4ae18ce67..cda2a79f8 100644 --- a/packages/smithy-http/tests/unit/aio/test_protocols.py +++ b/packages/smithy-http/tests/unit/aio/test_protocols.py @@ -120,6 +120,16 @@ def deserialize_response( URI(host="com.example"), URI(host="com.example", fragment="header"), ), + ( + URI(host="foo."), + URI(host="com.example"), + URI(host="com.example"), + ), + ( + URI(host="."), + URI(host="com.example"), + URI(host="com.example"), + ), ], ) def test_http_protocol_joins_uris( diff --git a/packages/smithy-json/src/smithy_json/_private/deserializers.py b/packages/smithy-json/src/smithy_json/_private/deserializers.py index bbbb16927..58d888edb 100644 --- a/packages/smithy-json/src/smithy_json/_private/deserializers.py +++ b/packages/smithy-json/src/smithy_json/_private/deserializers.py @@ -127,12 +127,10 @@ def read_integer(self, schema: Schema) -> int: def read_float(self, schema: Schema) -> float: event = next(self._stream) match event.value: - case Decimal(): + case Decimal() | "Infinity" | "-Infinity" | "NaN": return float(event.value) case int() | float(): return event.value - case "Infinity" | "-Infinity" | "NaN": - return float(event.value) case _: raise JSONTokenError("number", event) @@ -141,8 +139,8 @@ def read_big_decimal(self, schema: Schema) -> Decimal: match event.value: case Decimal(): return event.value - case int() | float(): - return Decimal.from_float(event.value) + case int() | float() | "Infinity" | "-Infinity" | "NaN": + return Decimal(event.value) case _: raise JSONTokenError("number", event) diff --git a/packages/smithy-json/src/smithy_json/_private/serializers.py b/packages/smithy-json/src/smithy_json/_private/serializers.py index 42f4ea476..eb19d7ae8 100644 --- a/packages/smithy-json/src/smithy_json/_private/serializers.py +++ b/packages/smithy-json/src/smithy_json/_private/serializers.py @@ -312,7 +312,7 @@ def write_float(self, value: float | Decimal) -> None: def _write_non_numeric_float(self, value: float | Decimal) -> bool: if value != value: - self._sink.write(b"NaN") + self._sink.write(b'"NaN"') return True if value == _INF: diff --git a/packages/smithy-json/tests/unit/__init__.py b/packages/smithy-json/tests/unit/__init__.py index 8e666cdb5..9fdcbb530 100644 --- a/packages/smithy-json/tests/unit/__init__.py +++ b/packages/smithy-json/tests/unit/__init__.py @@ -346,7 +346,13 @@ def _read_optional_map(k: str, d: ShapeDeserializer): (True, b"true"), (1, b"1"), (1.1, b"1.1"), + (float("nan"), b'"NaN"'), + (float("inf"), b'"Infinity"'), + (float("-inf"), b'"-Infinity"'), (Decimal("1.1"), b"1.1"), + (Decimal("NaN"), b'"NaN"'), + (Decimal("Infinity"), b'"Infinity"'), + (Decimal("-Infinity"), b'"-Infinity"'), (b"foo", b'"Zm9v"'), ("foo", b'"foo"'), # RFC 8259 ยง7: control characters must be escaped diff --git a/packages/smithy-json/tests/unit/test_deserializers.py b/packages/smithy-json/tests/unit/test_deserializers.py index 309a7feae..00a6ed2cd 100644 --- a/packages/smithy-json/tests/unit/test_deserializers.py +++ b/packages/smithy-json/tests/unit/test_deserializers.py @@ -1,5 +1,6 @@ # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. # SPDX-License-Identifier: Apache-2.0 +import math from datetime import datetime from decimal import Decimal from typing import Any @@ -89,6 +90,12 @@ def _read_optional_map(k: str, d: ShapeDeserializer): actual_value = actual.as_value() expected_value = expected.as_value() assert actual_value == expected_value + elif isinstance(expected, float) and math.isnan(expected): + assert isinstance(actual, float) + assert math.isnan(actual) + elif isinstance(expected, Decimal) and expected.is_nan(): + assert isinstance(actual, Decimal) + assert actual.is_nan() else: assert actual == expected