Skip to content
Merged
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": "enhancement",
"description": "Reused parsed XML response trees across AWS Query and Rest XML metadata, error identification, and shape deserialization."
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,15 @@
# SPDX-License-Identifier: Apache-2.0
"""Helpers for reading awsQuery response bodies that are not shape-modeled."""

from xml.etree.ElementTree import Element
from xml.etree.ElementTree import Element, ParseError, fromstring


def parse_xml_root(body: bytes) -> Element | None:
"""Parse an XML body, returning None when it is empty or malformed."""
try:
return fromstring(body) # noqa: S314
except ParseError:
return None


def local_name(tag: str) -> str:
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
# SPDX-License-Identifier: Apache-2.0
from typing import TYPE_CHECKING, Any
from xml.etree.ElementTree import ParseError, fromstring
from xml.etree.ElementTree import Element

from smithy_core.documents import TypeRegistry
from smithy_core.exceptions import (
Expand All @@ -15,7 +15,7 @@
from smithy_core.shapes import ShapeID

from ...traits import AwsQueryErrorTrait
from ._xml import find_child, local_name
from ._xml import find_child, local_name, parse_xml_root

try:
from smithy_xml import XMLCodec
Expand All @@ -36,12 +36,10 @@ def _assert_xml() -> None:


def _parse_aws_query_error_code(
body: bytes, wrapper_elements: tuple[str, ...]
element: Element | None, wrapper_elements: tuple[str, ...]
) -> str | None:
"""Parse the ``Code`` field from a wrapped awsQuery error response."""
try:
element = fromstring(body) # noqa: S314
except ParseError:
if element is None:
return None

if wrapper_elements:
Expand Down Expand Up @@ -78,7 +76,7 @@ def _resolve_aws_query_error_shape_id(

def create_aws_query_error(
*,
body: bytes,
body: bytes | Element | None,
operation: APIOperation[Any, Any],
error_registry: TypeRegistry,
default_namespace: str,
Expand All @@ -88,15 +86,16 @@ def create_aws_query_error(
retry_after: float | None = None,
) -> CallError:
"""Create a modeled or generic CallError from an awsQuery error response."""
code = _parse_aws_query_error_code(body, wrapper_elements)
root = parse_xml_root(body) if isinstance(body, bytes) else body
code = _parse_aws_query_error_code(root, wrapper_elements)
if code is not None:
shape_id = _resolve_aws_query_error_shape_id(
code=code,
operation=operation,
error_registry=error_registry,
default_namespace=default_namespace,
)
if shape_id is not None:
if shape_id is not None and root is not None:
error_shape = error_registry.get(shape_id)
if not issubclass(error_shape, ModeledError):
raise ExpectationNotMetError(
Expand All @@ -105,8 +104,8 @@ def create_aws_query_error(
)

_assert_xml()
deserializer = XMLCodec().create_deserializer(
body, wrapper_elements=wrapper_elements
deserializer = XMLCodec().create_deserializer_from_element(
root, wrapper_elements=wrapper_elements
)
modeled_error = error_shape.deserialize(deserializer)
if retry_after is not None:
Expand Down
Original file line number Diff line number Diff line change
@@ -1,19 +1,18 @@
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
# SPDX-License-Identifier: Apache-2.0
from xml.etree.ElementTree import ParseError, fromstring
from xml.etree.ElementTree import Element

from ._xml import find_child
from ._xml import find_child, parse_xml_root


def parse_aws_query_request_id(body: bytes) -> str | None:
def parse_aws_query_request_id(source: bytes | Element | None) -> str | None:
"""Parse the request ID from an awsQuery response body.

There is no request ID header. Successes nest it under ``ResponseMetadata``,
errors put it directly under the root.
"""
try:
root = fromstring(body) # noqa: S314
except ParseError:
root = parse_xml_root(source) if isinstance(source, bytes) else source
if root is None:
return None

metadata = find_child(root, "ResponseMetadata")
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
# SPDX-License-Identifier: Apache-2.0
from typing import NamedTuple
from xml.etree.ElementTree import ParseError, fromstring
from xml.etree.ElementTree import Element

from .query._xml import find_child, local_name
from .query._xml import find_child, local_name, parse_xml_root


class RestXmlErrorInfo(NamedTuple):
Expand All @@ -21,16 +21,15 @@ class RestXmlErrorInfo(NamedTuple):
"""The elements enclosing the error's members."""


def parse_rest_xml_error(body: bytes) -> RestXmlErrorInfo:
def parse_rest_xml_error(source: bytes | Element | None) -> RestXmlErrorInfo:
"""Parse the code, message, and wrapper elements of a restXml error response.

Errors are normally wrapped as ``<ErrorResponse><Error>...</Error></ErrorResponse>``,
but services with ``noErrorWrapping`` (such as S3) send a bare ``<Error>``. Both are
accepted so the protocol needn't be configured for either.
"""
try:
element = fromstring(body) # noqa: S314
except ParseError:
element = parse_xml_root(source) if isinstance(source, bytes) else source
if element is None:
return _EMPTY

wrapper_elements: tuple[str, ...] = ("Error",)
Expand Down
37 changes: 26 additions & 11 deletions packages/smithy-aws-core/src/smithy_aws_core/aio/protocols.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from inspect import iscoroutinefunction
from io import BytesIO
from typing import TYPE_CHECKING, Any, ClassVar, Final
from xml.etree.ElementTree import Element

from smithy_core import URI as _URI
from smithy_core.aio.interfaces import (
Expand Down Expand Up @@ -43,6 +44,7 @@
)
from smithy_http.deserializers import HTTPResponseDeserializer

from .._private.query._xml import parse_xml_root
from .._private.query.errors import create_aws_query_error
from .._private.query.metadata import parse_aws_query_request_id
from .._private.query.serializers import Ec2QueryShapeSerializer, QueryShapeSerializer
Expand Down Expand Up @@ -334,7 +336,8 @@ async def _create_error(
body = response_body.read()

retry_after = parse_retry_after(response)
error_info = parse_rest_xml_error(body)
root = parse_xml_root(body)
error_info = parse_rest_xml_error(root)
code = error_info.code
error_id = None
if code is not None:
Expand All @@ -344,7 +347,7 @@ async def _create_error(
error_id = error_schema.id
break

if error_id is not None and error_id in error_registry:
if error_id is not None and error_id in error_registry and root is not None:
error_shape = error_registry.get(error_id)
if not issubclass(error_shape, ModeledError):
raise ExpectationNotMetError(
Expand All @@ -353,7 +356,11 @@ async def _create_error(
)

deserializer = HTTPResponseDeserializer(
payload_codec=_XMLErrorCodec(self._codec, error_info.wrapper_elements),
payload_codec=_XMLErrorCodec(
self._codec,
error_info.wrapper_elements,
root,
),
http_trait=operation.schema.expect_trait(HTTPTrait),
response=response,
body=body,
Expand Down Expand Up @@ -397,6 +404,7 @@ class _XMLErrorCodec(Codec):

codec: "XMLCodec"
wrapper_elements: tuple[str, ...]
root: Element

@property
def media_type(self) -> str:
Expand All @@ -406,8 +414,8 @@ def create_serializer(self, sink: BytesWriter) -> ShapeSerializer:
return self.codec.create_serializer(sink)

def create_deserializer(self, source: bytes | BytesReader) -> ShapeDeserializer:
return self.codec.create_deserializer(
source, wrapper_elements=self.wrapper_elements
return self.codec.create_deserializer_from_element(
self.root, wrapper_elements=self.wrapper_elements
)


Expand Down Expand Up @@ -737,21 +745,22 @@ async def deserialize_response[
context: TypedProperties,
) -> OperationOutput:
body = await response.consume_body_async()
root = parse_xml_root(body)

# Recorded before any branch below returns or raises, so successes, empty
# outputs and errors alike can report the identifier. Bound to this
# response so extraction never attributes it to a different attempt.
request_id = parse_aws_query_request_id(body)
request_id = parse_aws_query_request_id(root)
if request_id is not None:
context[_QUERY_REQUEST_ID] = _RecordedQueryRequestId(response, request_id)

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,
root=root,
)

if len(body) == 0:
Expand All @@ -764,8 +773,14 @@ async def deserialize_response[
)

wrapper_elements = self._response_wrapper_elements(operation)
deserializer = self.payload_codec.create_deserializer(
body, wrapper_elements=wrapper_elements
deserializer = (
self.payload_codec.create_deserializer(
body, wrapper_elements=wrapper_elements
)
if root is None
else self.payload_codec.create_deserializer_from_element(
root, wrapper_elements=wrapper_elements
)
)
return operation.output.deserialize(deserializer)

Expand All @@ -782,12 +797,12 @@ async def _create_error(
*,
operation: APIOperation[Any, Any],
response: HTTPResponse,
response_body: bytes,
error_registry: TypeRegistry,
context: TypedProperties,
root: Element | None,
) -> CallError:
return create_aws_query_error(
body=response_body,
body=root,
operation=operation,
error_registry=error_registry,
default_namespace=self._default_namespace,
Expand Down
Loading
Loading