smithy-http 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,107 @@
1
+ # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License"). You
4
+ # may not use this file except in compliance with the License. A copy of
5
+ # the License is located at
6
+ #
7
+ # http://aws.amazon.com/apache2.0/
8
+ #
9
+ # or in the "license" file accompanying this file. This file is
10
+ # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF
11
+ # ANY KIND, either express or implied. See the License for the specific
12
+ # language governing permissions and limitations under the License.
13
+
14
+ from dataclasses import dataclass
15
+ from typing import Any, Protocol, TypedDict, TypeVar
16
+
17
+ from smithy_core.aio.interfaces.identity import IdentityResolver
18
+ from smithy_core.interfaces.identity import Identity, IdentityProperties
19
+
20
+ from . import HTTPRequest
21
+
22
+
23
+ class SigningProperties(TypedDict):
24
+ """Additional properties loaded to modify the signing process."""
25
+
26
+
27
+ SigningPropertiesType = TypeVar("SigningPropertiesType", bound=SigningProperties)
28
+ SigningPropertiesType_contra = TypeVar(
29
+ "SigningPropertiesType_contra", bound=SigningProperties, contravariant=True
30
+ )
31
+
32
+
33
+ class HTTPSigner[I: Identity, SP: SigningProperties](Protocol):
34
+ """An interface for generating a signed HTTP request."""
35
+
36
+ async def sign(
37
+ self,
38
+ *,
39
+ http_request: HTTPRequest,
40
+ identity: I,
41
+ signing_properties: SP,
42
+ ) -> HTTPRequest:
43
+ """Generate a new signed HTTPRequest based on the one provided.
44
+
45
+ :param http_request: The HTTP request to sign.
46
+ :param identity: The signing identity.
47
+ :param signing_properties: Additional properties loaded to modify the signing
48
+ process.
49
+ """
50
+ ...
51
+
52
+
53
+ class HTTPAuthScheme[I: Identity, C, IP: IdentityProperties, SP: SigningProperties](
54
+ Protocol
55
+ ):
56
+ """Represents a way a service will authenticate the user's identity."""
57
+
58
+ # A unique identifier for the authentication scheme.
59
+ scheme_id: str
60
+
61
+ # An API that can be used to sign HTTP requests.
62
+ signer: HTTPSigner[I, SP]
63
+
64
+ def identity_resolver(self, *, config: C) -> IdentityResolver[I, IP]:
65
+ """An API that can be queried to resolve identity."""
66
+ ...
67
+
68
+
69
+ @dataclass(kw_only=True)
70
+ class HTTPAuthOption:
71
+ """Auth scheme used for signing and identity resolution."""
72
+
73
+ # The ID of the scheme to use. This string matches the one returned by
74
+ # HttpAuthScheme.scheme_id
75
+ scheme_id: str
76
+
77
+ # Parameters to pass to IdentityResolver.get_identity.
78
+ identity_properties: dict[str, Any]
79
+
80
+ # Parameters to pass to HttpSigner.sign.
81
+ signer_properties: dict[str, Any]
82
+
83
+
84
+ @dataclass(kw_only=True)
85
+ class AuthSchemeParameters:
86
+ """The input to the auth scheme resolver.
87
+
88
+ A code-generated interface for passing in the data required for determining the
89
+ authentication scheme. By default, this only includes the operation name.
90
+ """
91
+
92
+ # The service operation being invoked by the client.
93
+ operation: str
94
+
95
+
96
+ class AuthSchemeResolver(Protocol):
97
+ """Determines which authentication scheme to use for a given service."""
98
+
99
+ def resolve_auth_scheme(
100
+ self, *, auth_parameters: AuthSchemeParameters
101
+ ) -> list[HTTPAuthOption]:
102
+ """Resolve an ordered list of applicable auth schemes.
103
+
104
+ :param auth_parameters: The parameters required for determining which
105
+ authentication schemes to potentially use.
106
+ """
107
+ ...
@@ -0,0 +1,118 @@
1
+ import os
2
+ from inspect import iscoroutinefunction
3
+ from io import BytesIO
4
+
5
+ from smithy_core.aio.interfaces import ClientProtocol
6
+ from smithy_core.codecs import Codec
7
+ from smithy_core.deserializers import DeserializeableShape
8
+ from smithy_core.documents import TypeRegistry
9
+ from smithy_core.exceptions import ExpectationNotMetException
10
+ from smithy_core.interfaces import Endpoint, TypedProperties, URI
11
+ from smithy_core.schemas import APIOperation
12
+ from smithy_core.serializers import SerializeableShape
13
+ from smithy_core.traits import EndpointTrait, HTTPTrait
14
+
15
+ from smithy_http.aio.interfaces import HTTPRequest, HTTPResponse
16
+ from smithy_http.deserializers import HTTPResponseDeserializer
17
+ from smithy_http.serializers import HTTPRequestSerializer
18
+
19
+
20
+ class HttpClientProtocol(ClientProtocol[HTTPRequest, HTTPResponse]):
21
+ """An HTTP-based protocol."""
22
+
23
+ def set_service_endpoint(
24
+ self,
25
+ *,
26
+ request: HTTPRequest,
27
+ endpoint: Endpoint,
28
+ ) -> HTTPRequest:
29
+ uri = endpoint.uri
30
+ uri_builder = request.destination
31
+
32
+ if uri.scheme:
33
+ uri_builder.scheme = uri.scheme
34
+ if uri.host:
35
+ uri_builder.host = uri.host
36
+ if uri.port and uri.port > -1:
37
+ uri_builder.port = uri.port
38
+ if uri.path:
39
+ uri_builder.path = os.path.join(uri.path, uri_builder.path or "")
40
+ # TODO: merge headers from the endpoint properties bag
41
+ return request
42
+
43
+
44
+ class HttpBindingClientProtocol(HttpClientProtocol):
45
+ """An HTTP-based protocol that uses HTTP binding traits."""
46
+
47
+ @property
48
+ def payload_codec(self) -> Codec:
49
+ """The codec used for the serde of input and output payloads."""
50
+ raise NotImplementedError()
51
+
52
+ @property
53
+ def content_type(self) -> str:
54
+ """The media type of the http payload."""
55
+ raise NotImplementedError()
56
+
57
+ def serialize_request[
58
+ OperationInput: "SerializeableShape",
59
+ OperationOutput: "DeserializeableShape",
60
+ ](
61
+ self,
62
+ *,
63
+ operation: APIOperation[OperationInput, OperationOutput],
64
+ input: OperationInput,
65
+ endpoint: URI,
66
+ context: TypedProperties,
67
+ ) -> HTTPRequest:
68
+ # TODO(optimization): request binding cache like done in SJ
69
+ serializer = HTTPRequestSerializer(
70
+ payload_codec=self.payload_codec,
71
+ http_trait=operation.schema.expect_trait(HTTPTrait),
72
+ endpoint_trait=operation.schema.get_trait(EndpointTrait),
73
+ )
74
+
75
+ input.serialize(serializer=serializer)
76
+ request = serializer.result
77
+
78
+ if request is None:
79
+ raise ExpectationNotMetException(
80
+ "Expected request to be serialized, but was None"
81
+ )
82
+
83
+ return request
84
+
85
+ async def deserialize_response[
86
+ OperationInput: "SerializeableShape",
87
+ OperationOutput: "DeserializeableShape",
88
+ ](
89
+ self,
90
+ *,
91
+ operation: APIOperation[OperationInput, OperationOutput],
92
+ request: HTTPRequest,
93
+ response: HTTPResponse,
94
+ error_registry: TypeRegistry,
95
+ context: TypedProperties,
96
+ ) -> OperationOutput:
97
+ if not (200 <= response.status <= 299):
98
+ # TODO: implement error serde from type registry
99
+ raise NotImplementedError
100
+
101
+ body = response.body
102
+
103
+ # if body is not streaming and is async, we have to buffer it
104
+ if not operation.output_stream_member:
105
+ if (
106
+ read := getattr(body, "read", None)
107
+ ) is not None and iscoroutinefunction(read):
108
+ body = BytesIO(await read())
109
+
110
+ # TODO(optimization): response binding cache like done in SJ
111
+ deserializer = HTTPResponseDeserializer(
112
+ payload_codec=self.payload_codec,
113
+ http_trait=operation.schema.expect_trait(HTTPTrait),
114
+ response=response,
115
+ body=body, # type: ignore
116
+ )
117
+
118
+ return operation.output.deserialize(deserializer)
@@ -0,0 +1,60 @@
1
+ # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ import json
4
+
5
+ from smithy_core.documents import DocumentValue
6
+ from smithy_core.utils import expect_type
7
+
8
+ from ..restjson import (
9
+ _REST_JSON_CODE_HEADER, # pyright: ignore[reportPrivateUsage]
10
+ _REST_JSON_CODE_KEYS, # pyright: ignore[reportPrivateUsage]
11
+ _REST_JSON_MESSAGE_KEYS, # pyright: ignore[reportPrivateUsage]
12
+ RestJsonErrorInfo,
13
+ )
14
+ from .interfaces import HTTPResponse
15
+
16
+
17
+ async def parse_rest_json_error_info(
18
+ http_response: HTTPResponse, check_body: bool = True
19
+ ) -> RestJsonErrorInfo:
20
+ """Parses generic RestJson error info from an HTTP response.
21
+
22
+ :param http_response: The HTTP response to parse.
23
+ :param check_body: Whether to check the body for the code / message.
24
+ :returns: The parsed error information.
25
+ """
26
+ code: str | None = None
27
+ message: str | None = None
28
+ json_body: dict[str, DocumentValue] | None = None
29
+
30
+ for field in http_response.fields:
31
+ if field.name.lower() == _REST_JSON_CODE_HEADER:
32
+ code = field.values[0]
33
+ break
34
+
35
+ if check_body:
36
+ if body := await http_response.consume_body_async():
37
+ try:
38
+ json_body = json.loads(body)
39
+ except json.JSONDecodeError:
40
+ # In some cases the body might end up being HTML depending on the
41
+ # configuration of the server. In those cases we can simply ignore
42
+ # the body because we can't find the information we need there.
43
+ pass
44
+
45
+ if json_body:
46
+ for key, value in json_body.items():
47
+ key_lower = key.lower()
48
+ if not code and key_lower in _REST_JSON_CODE_KEYS:
49
+ code = expect_type(str, value)
50
+ if not message and key_lower in _REST_JSON_MESSAGE_KEYS:
51
+ message = expect_type(str, value)
52
+
53
+ # Normalize the error code. Some services may try to send a fully-qualified shape
54
+ # ID or a URI, but we don't want to include those.
55
+ if code:
56
+ if "#" in code:
57
+ code = code.split("#")[1]
58
+ code = code.split(":")[0]
59
+
60
+ return RestJsonErrorInfo(code or "Unknown", message or "Unknown", json_body)
@@ -0,0 +1,259 @@
1
+ # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ import datetime
4
+ from collections.abc import Callable
5
+ from decimal import Decimal
6
+ from typing import TYPE_CHECKING
7
+
8
+ from smithy_core.codecs import Codec
9
+ from smithy_core.deserializers import ShapeDeserializer, SpecificShapeDeserializer
10
+ from smithy_core.exceptions import UnsupportedStreamException
11
+ from smithy_core.interfaces import is_bytes_reader, is_streaming_blob
12
+ from smithy_core.schemas import Schema
13
+ from smithy_core.shapes import ShapeType
14
+ from smithy_core.traits import (
15
+ HTTPHeaderTrait,
16
+ HTTPPayloadTrait,
17
+ HTTPPrefixHeadersTrait,
18
+ HTTPResponseCodeTrait,
19
+ HTTPTrait,
20
+ TimestampFormatTrait,
21
+ )
22
+ from smithy_core.types import TimestampFormat
23
+ from smithy_core.utils import ensure_utc, strict_parse_bool, strict_parse_float
24
+
25
+ from .aio.interfaces import HTTPResponse
26
+ from .interfaces import Field, Fields
27
+
28
+ if TYPE_CHECKING:
29
+ from smithy_core.aio.interfaces import StreamingBlob as AsyncStreamingBlob
30
+ from smithy_core.interfaces import StreamingBlob as SyncStreamingBlob
31
+
32
+
33
+ __all__ = ["HTTPResponseDeserializer"]
34
+
35
+
36
+ class HTTPResponseDeserializer(SpecificShapeDeserializer):
37
+ """Binds :py:class:`HTTPResponse` properties to a DeserializableShape."""
38
+
39
+ # Note: caller will have to read the body if it's async and not streaming
40
+ def __init__(
41
+ self,
42
+ payload_codec: Codec,
43
+ http_trait: HTTPTrait,
44
+ response: HTTPResponse,
45
+ body: "SyncStreamingBlob | None" = None,
46
+ ) -> None:
47
+ """Initialize an HTTPResponseDeserializer.
48
+
49
+ :param payload_codec: The Codec to use to deserialize the payload, if present.
50
+ :param http_trait: The HTTP trait of the operation being handled.
51
+ :param response: The HTTP response to read from.
52
+ :param body: The HTTP response body in a synchronously readable form. This is
53
+ necessary for async response bodies when there is no streaming member.
54
+ """
55
+ self._payload_codec = payload_codec
56
+ self._response = response
57
+ self._http_trait = http_trait
58
+ self._body = body
59
+
60
+ def read_struct(
61
+ self, schema: Schema, consumer: Callable[[Schema, ShapeDeserializer], None]
62
+ ) -> None:
63
+ has_body = False
64
+ payload_member: Schema | None = None
65
+
66
+ for member in schema.members.values():
67
+ if (trait := member.get_trait(HTTPHeaderTrait)) is not None:
68
+ header = self._response.fields.entries.get(trait.key.lower())
69
+ if header is not None:
70
+ if member.shape_type is ShapeType.LIST:
71
+ consumer(member, HTTPHeaderListDeserializer(header))
72
+ else:
73
+ consumer(member, HTTPHeaderDeserializer(header.as_string()))
74
+ elif (trait := member.get_trait(HTTPPrefixHeadersTrait)) is not None:
75
+ consumer(
76
+ member,
77
+ HTTPHeaderMapDeserializer(self._response.fields, trait.prefix),
78
+ )
79
+ elif HTTPPayloadTrait in member:
80
+ has_body = True
81
+ payload_member = member
82
+ elif HTTPResponseCodeTrait in member:
83
+ consumer(member, HTTPResponseCodeDeserializer(self._response.status))
84
+ else:
85
+ has_body = True
86
+
87
+ if has_body:
88
+ deserializer = self._create_payload_deserializer(payload_member)
89
+ if payload_member is not None:
90
+ consumer(payload_member, deserializer)
91
+ else:
92
+ deserializer.read_struct(schema, consumer)
93
+
94
+ def _create_payload_deserializer(
95
+ self, payload_member: Schema | None
96
+ ) -> ShapeDeserializer:
97
+ body = self._body if self._body is not None else self._response.body
98
+ if payload_member is not None and payload_member.shape_type in (
99
+ ShapeType.BLOB,
100
+ ShapeType.STRING,
101
+ ):
102
+ return RawPayloadDeserializer(body)
103
+
104
+ if not is_streaming_blob(body):
105
+ raise UnsupportedStreamException(
106
+ "Unable to read async stream. This stream must be buffered prior "
107
+ "to creating the deserializer."
108
+ )
109
+
110
+ if isinstance(body, bytearray):
111
+ body = bytes(body)
112
+
113
+ return self._payload_codec.create_deserializer(body)
114
+
115
+
116
+ class HTTPHeaderDeserializer(SpecificShapeDeserializer):
117
+ """Binds HTTP header values to a deserializable shape.
118
+
119
+ For headers with list values, see :py:class:`HTTPHeaderListDeserializer`.
120
+ """
121
+
122
+ def __init__(self, value: str) -> None:
123
+ """Initialize an HTTPHeaderDeserializer.
124
+
125
+ :param value: The string value of the header.
126
+ """
127
+ self._value = value
128
+
129
+ def read_boolean(self, schema: Schema) -> bool:
130
+ return strict_parse_bool(self._value)
131
+
132
+ def read_byte(self, schema: Schema) -> int:
133
+ return self.read_integer(schema)
134
+
135
+ def read_short(self, schema: Schema) -> int:
136
+ return self.read_integer(schema)
137
+
138
+ def read_integer(self, schema: Schema) -> int:
139
+ return int(self._value)
140
+
141
+ def read_long(self, schema: Schema) -> int:
142
+ return self.read_integer(schema)
143
+
144
+ def read_big_integer(self, schema: Schema) -> int:
145
+ return self.read_integer(schema)
146
+
147
+ def read_float(self, schema: Schema) -> float:
148
+ return strict_parse_float(self._value)
149
+
150
+ def read_double(self, schema: Schema) -> float:
151
+ return self.read_float(schema)
152
+
153
+ def read_big_decimal(self, schema: Schema) -> Decimal:
154
+ return Decimal(self._value).canonical()
155
+
156
+ def read_string(self, schema: Schema) -> str:
157
+ return self._value
158
+
159
+ def read_timestamp(self, schema: Schema) -> datetime.datetime:
160
+ format = TimestampFormat.HTTP_DATE
161
+ if (trait := schema.get_trait(TimestampFormatTrait)) is not None:
162
+ format = trait.format
163
+ return ensure_utc(format.deserialize(self._value))
164
+
165
+
166
+ class HTTPHeaderListDeserializer(SpecificShapeDeserializer):
167
+ """Binds HTTP header lists to a deserializable shape."""
168
+
169
+ def __init__(self, field: Field) -> None:
170
+ """Initialize an HTTPHeaderListDeserializer.
171
+
172
+ :param field: The field to deserialize.
173
+ """
174
+ self._field = field
175
+
176
+ def read_list(
177
+ self, schema: Schema, consumer: Callable[["ShapeDeserializer"], None]
178
+ ) -> None:
179
+ for value in self._field.values:
180
+ consumer(HTTPHeaderDeserializer(value))
181
+
182
+
183
+ class HTTPHeaderMapDeserializer(SpecificShapeDeserializer):
184
+ """Binds HTTP header maps to a deserializable shape."""
185
+
186
+ def __init__(self, fields: Fields, prefix: str = "") -> None:
187
+ """Initialize an HTTPHeaderMapDeserializer.
188
+
189
+ :param fields: The collection of headers to search for map values.
190
+ :param prefix: An optional prefix to limit which headers are pulled in to the
191
+ map. By default, all headers are pulled in, including headers that are bound
192
+ to other properties on the shape.
193
+ """
194
+ self._prefix = prefix.lower()
195
+ self._fields = fields
196
+
197
+ def read_map(
198
+ self,
199
+ schema: Schema,
200
+ consumer: Callable[[str, "ShapeDeserializer"], None],
201
+ ) -> None:
202
+ trim = len(self._prefix)
203
+ for field in self._fields:
204
+ if field.name.lower().startswith(self._prefix):
205
+ consumer(field.name[trim:], HTTPHeaderDeserializer(field.as_string()))
206
+
207
+
208
+ class HTTPResponseCodeDeserializer(SpecificShapeDeserializer):
209
+ """Binds HTTP response codes to a deserializeable shape."""
210
+
211
+ def __init__(self, response_code: int) -> None:
212
+ """Initialize an HTTPResponseCodeDeserializer.
213
+
214
+ :param response_code: The response code to bind.
215
+ """
216
+ self._response_code = response_code
217
+
218
+ def read_byte(self, schema: Schema) -> int:
219
+ return self._response_code
220
+
221
+ def read_short(self, schema: Schema) -> int:
222
+ return self._response_code
223
+
224
+ def read_integer(self, schema: Schema) -> int:
225
+ return self._response_code
226
+
227
+
228
+ class RawPayloadDeserializer(SpecificShapeDeserializer):
229
+ """Binds an HTTP payload to a deserializeable shape."""
230
+
231
+ def __init__(self, payload: "AsyncStreamingBlob") -> None:
232
+ """Initialize a RawPayloadDeserializer.
233
+
234
+ :param payload: The payload to bind. If the member that is bound to the payload
235
+ is a string or blob, it MUST NOT be an async stream. Async streams MUST be
236
+ buffered into a synchronous stream ahead of time.
237
+ """
238
+ self._payload = payload
239
+
240
+ def read_string(self, schema: Schema) -> str:
241
+ return self._consume_payload().decode("utf-8")
242
+
243
+ def read_blob(self, schema: Schema) -> bytes:
244
+ return self._consume_payload()
245
+
246
+ def read_data_stream(self, schema: Schema) -> "AsyncStreamingBlob":
247
+ return self._payload
248
+
249
+ def _consume_payload(self) -> bytes:
250
+ if isinstance(self._payload, bytes):
251
+ return self._payload
252
+ if isinstance(self._payload, bytearray):
253
+ return bytes(self._payload)
254
+ if is_bytes_reader(self._payload):
255
+ return self._payload.read()
256
+ raise UnsupportedStreamException(
257
+ "Unable to read async stream. This stream must be buffered prior "
258
+ "to creating the deserializer."
259
+ )
@@ -0,0 +1,30 @@
1
+ # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ from dataclasses import dataclass
4
+
5
+ from smithy_core.endpoints import Endpoint
6
+ from smithy_core.interfaces import TypedProperties as _TypedProperties
7
+ from smithy_core.interfaces import URI
8
+ from smithy_core.types import PropertyKey, TypedProperties
9
+
10
+ from . import Fields, interfaces
11
+
12
+ HEADERS = PropertyKey(key="headers", value_type=interfaces.Fields)
13
+ """An Endpoint property indicating the given fields MUST be added to the request."""
14
+
15
+
16
+ @dataclass(init=False, kw_only=True)
17
+ class HTTPEndpoint(Endpoint):
18
+ """A resolved endpoint with optional HTTP headers."""
19
+
20
+ def __init__(
21
+ self,
22
+ *,
23
+ uri: URI,
24
+ properties: _TypedProperties | None = None,
25
+ headers: interfaces.Fields | None = None,
26
+ ) -> None:
27
+ self.uri = uri
28
+ self.properties = properties if properties is not None else TypedProperties()
29
+ headers = headers if headers is not None else Fields()
30
+ self.properties[HEADERS] = headers
@@ -0,0 +1,7 @@
1
+ # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ from smithy_core.exceptions import SmithyException
4
+
5
+
6
+ class SmithyHTTPException(SmithyException):
7
+ """Base exception type for all exceptions raised in HTTP clients."""
@@ -0,0 +1,2 @@
1
+ # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ # SPDX-License-Identifier: Apache-2.0