Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"type": "bugfix",
"description": "Fixed REST JSON modeled error resolution by matching error identifiers by shape name when wire and modeled namespaces differ. ([#742](https://github.com/smithy-lang/smithy-python/pull/742))"
}
11 changes: 11 additions & 0 deletions packages/smithy-aws-core/src/smithy_aws_core/aio/protocols.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,17 @@ def content_type(self) -> str:
def error_identifier(self) -> HTTPErrorIdentifier:
return self._error_identifier

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

def create_event_publisher[
OperationInput: SerializeableShape,
OperationOutput: DeserializeableShape,
Expand Down
2 changes: 1 addition & 1 deletion packages/smithy-aws-core/src/smithy_aws_core/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ def parse_error_code(code: str, default_namespace: str | None) -> ShapeID | None
if not code:
return None

code = code.split(":")[0]
code = code.split(":", 1)[0]
if "#" in code:
return ShapeID(code)

Expand Down
90 changes: 89 additions & 1 deletion packages/smithy-aws-core/tests/unit/aio/test_protocols.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
AWSErrorIdentifier,
AWSJSONDocument,
AwsQueryClientProtocol,
RestJsonClientProtocol,
)
from smithy_aws_core.traits import AwsQueryTrait
from smithy_core.deserializers import ShapeDeserializer
Expand All @@ -20,7 +21,7 @@
from smithy_core.schemas import APIOperation, Schema
from smithy_core.serializers import ShapeSerializer
from smithy_core.shapes import ShapeID, ShapeType
from smithy_core.traits import Trait
from smithy_core.traits import HTTPTrait, Trait
from smithy_core.types import TypedProperties
from smithy_http import Fields, tuples_to_fields
from smithy_http.aio import HTTPRequest, HTTPResponse
Expand All @@ -39,6 +40,10 @@
"com.test#FooError:http://internal.amazon.com/coral/com.amazon.coral.validate",
"com.test#FooError",
),
(
"com.other#FooError:http://internal.amazon.com/coral/com.amazon.coral.validate",
"com.other#FooError",
),
("", None),
(":", None),
(None, None),
Expand Down Expand Up @@ -76,6 +81,12 @@ def test_aws_error_identifier(header: str | None, expected: ShapeID | None) -> N
},
"com.test#FooError",
),
(
{
"__type": "com.other#FooError:http://internal.amazon.com/coral/com.amazon.coral.validate"
},
"com.other#FooError",
),
({"code": "FooError"}, "com.test#FooError"),
({"code": "com.test#FooError"}, "com.test#FooError"),
(
Expand Down Expand Up @@ -120,6 +131,14 @@ def test_aws_json_document_discriminator(
shape_type=ShapeType.SERVICE,
traits=[AwsQueryTrait(None)],
)
_REST_JSON_SERVICE_SCHEMA = Schema.collection(
id=ShapeID("com.test#RestJsonService"),
shape_type=ShapeType.SERVICE,
)
_CROSS_NAMESPACE_ERROR_SCHEMA = Schema.collection(
id=ShapeID("com.shared#CrossNamespaceError"),
traits=[Trait.new(id=ShapeID("smithy.api#error"), value="client")],
)
_INVALID_ACTION_ERROR_SCHEMA = Schema.collection(
id=ShapeID("com.test#InvalidActionError"),
traits=[
Expand Down Expand Up @@ -160,13 +179,31 @@ def _consumer(schema: Schema, de: ShapeDeserializer) -> None:
return cls(**kwargs)


class _CrossNamespaceError(ModeledError):
@classmethod
def deserialize(cls, deserializer: ShapeDeserializer) -> "_CrossNamespaceError":
deserializer.read_struct(
_CROSS_NAMESPACE_ERROR_SCHEMA,
consumer=lambda schema, de: None,
)
return cls()


def _operation_schema(name: str) -> Schema:
return Schema(
id=ShapeID(f"com.test#{name}"),
shape_type=ShapeType.OPERATION,
)


def _rest_json_operation_schema(name: str) -> Schema:
return Schema(
id=ShapeID(f"com.test#{name}"),
shape_type=ShapeType.OPERATION,
traits=[HTTPTrait({"method": "POST", "uri": "/", "code": 200})],
)


def _mock_operation(
schema: Schema,
*,
Expand All @@ -178,6 +215,57 @@ def _mock_operation(
return cast("APIOperation[Any, Any]", operation)


@pytest.mark.parametrize(
"headers, body",
[
pytest.param(
[("x-amzn-errortype", "CrossNamespaceError")],
b"",
id="header-unqualified",
),
pytest.param(
[("x-amzn-errortype", "com.shared#CrossNamespaceError")],
b"",
id="header-qualified",
),
pytest.param(
[("content-type", "application/json")],
b'{"__type":"CrossNamespaceError"}',
id="body-unqualified",
),
pytest.param(
[("content-type", "application/json")],
b'{"__type":"com.shared#CrossNamespaceError"}',
id="body-qualified",
),
],
)
async def test_rest_json_resolves_cross_namespace_modeled_error(
headers: list[tuple[str, str]],
body: bytes,
) -> None:
protocol = RestJsonClientProtocol(_REST_JSON_SERVICE_SCHEMA)
operation = _mock_operation(
_rest_json_operation_schema("FailingOperation"),
error_schemas=[_CROSS_NAMESPACE_ERROR_SCHEMA],
)

with pytest.raises(_CrossNamespaceError):
await protocol.deserialize_response(
operation=operation,
request=cast(HTTPRequest, Mock()),
response=HTTPResponse(
status=500,
fields=tuples_to_fields(headers),
body=body,
),
error_registry=TypeRegistry(
{ShapeID("com.shared#CrossNamespaceError"): _CrossNamespaceError}
),
context=TypedProperties(),
)


async def test_aws_query_serializes_base_request_shape() -> None:
protocol = AwsQueryClientProtocol(_SERVICE_SCHEMA, "2020-01-08")
request = protocol.serialize_request(
Expand Down
19 changes: 16 additions & 3 deletions packages/smithy-aws-core/tests/unit/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@ def test_aws_json_document_discriminator(
"com.test#FooError:http://internal.amazon.com/coral/com.amazon.coral.validate",
"com.test#FooError",
),
(
"com.other#FooError:http://internal.amazon.com/coral/com.amazon.coral.validate",
"com.other#FooError",
),
("", None),
(":", None),
],
Expand All @@ -73,6 +77,15 @@ def test_parse_error_code(code: str, expected: ShapeID | None) -> None:
assert actual == expected


def test_parse_error_code_without_default_namespace() -> None:
actual = parse_error_code("FooError", None)
assert actual is None
@pytest.mark.parametrize(
"code, expected",
[
("FooError", None),
("com.test#FooError", "com.test#FooError"),
],
)
def test_parse_error_code_without_default_namespace(
code: str, expected: ShapeID | None
) -> None:
actual = parse_error_code(code, None)
assert actual == expected
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"type": "enhancement",
"description": "Enabled HTTP binding protocols to resolve modeled response errors when wire identifiers do not exactly match registered shape IDs. ([#742](https://github.com/smithy-lang/smithy-python/pull/742))"
}
25 changes: 23 additions & 2 deletions packages/smithy-http/src/smithy_http/aio/protocols.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
from smithy_core.prelude import DOCUMENT
from smithy_core.schemas import APIOperation
from smithy_core.serializers import SerializeableShape
from smithy_core.shapes import ShapeID
from smithy_core.traits import EndpointTrait, HTTPTrait

from ..deserializers import HTTPResponseDeserializer
Expand Down Expand Up @@ -185,15 +186,26 @@ async def _create_error(
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 self._matches_content_type(response):
if isinstance(response_body, bytearray):
response_body = bytes(response_body)
deserializer = self.payload_codec.create_deserializer(source=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.discriminator in error_registry:
error_id = document.discriminator
if document_error_id in error_registry:
error_id = document_error_id
if isinstance(response_body, SeekableBytesReader):
response_body.seek(0)

Expand Down Expand Up @@ -236,6 +248,15 @@ async def _create_error(
is_retry_safe=is_throttle or is_timeout or None,
)

def _resolve_error_id(
self,
*,
operation: APIOperation[Any, Any],
error_id: ShapeID,
) -> ShapeID:
"""Resolve a response discriminator to its modeled error shape ID."""
return error_id

def _matches_content_type(self, response: HTTPResponse) -> bool:
if "content-type" not in response.fields:
return False
Expand Down
Loading