smithy-http 0.0.1__tar.gz
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.
- smithy_http-0.0.1/.gitignore +36 -0
- smithy_http-0.0.1/CHANGES.md +11 -0
- smithy_http-0.0.1/NOTICE +1 -0
- smithy_http-0.0.1/PKG-INFO +12 -0
- smithy_http-0.0.1/README.md +0 -0
- smithy_http-0.0.1/pyproject.toml +30 -0
- smithy_http-0.0.1/src/smithy_http/__init__.py +233 -0
- smithy_http-0.0.1/src/smithy_http/aio/__init__.py +53 -0
- smithy_http-0.0.1/src/smithy_http/aio/aiohttp.py +140 -0
- smithy_http-0.0.1/src/smithy_http/aio/auth/__init__.py +2 -0
- smithy_http-0.0.1/src/smithy_http/aio/auth/apikey.py +117 -0
- smithy_http-0.0.1/src/smithy_http/aio/crt.py +493 -0
- smithy_http-0.0.1/src/smithy_http/aio/identity/__init__.py +2 -0
- smithy_http-0.0.1/src/smithy_http/aio/identity/apikey.py +38 -0
- smithy_http-0.0.1/src/smithy_http/aio/interfaces/__init__.py +85 -0
- smithy_http-0.0.1/src/smithy_http/aio/interfaces/auth.py +107 -0
- smithy_http-0.0.1/src/smithy_http/aio/protocols.py +118 -0
- smithy_http-0.0.1/src/smithy_http/aio/restjson.py +60 -0
- smithy_http-0.0.1/src/smithy_http/deserializers.py +259 -0
- smithy_http-0.0.1/src/smithy_http/endpoints.py +30 -0
- smithy_http-0.0.1/src/smithy_http/exceptions.py +7 -0
- smithy_http-0.0.1/src/smithy_http/interceptors/__init__.py +2 -0
- smithy_http-0.0.1/src/smithy_http/interceptors/user_agent.py +153 -0
- smithy_http-0.0.1/src/smithy_http/interfaces/__init__.py +141 -0
- smithy_http-0.0.1/src/smithy_http/plugins/__init__.py +15 -0
- smithy_http-0.0.1/src/smithy_http/py.typed +0 -0
- smithy_http-0.0.1/src/smithy_http/restjson.py +29 -0
- smithy_http-0.0.1/src/smithy_http/serializers.py +631 -0
- smithy_http-0.0.1/src/smithy_http/user_agent.py +97 -0
- smithy_http-0.0.1/src/smithy_http/utils.py +125 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Eclipse
|
|
2
|
+
.classpath
|
|
3
|
+
.project
|
|
4
|
+
.settings/
|
|
5
|
+
|
|
6
|
+
# Intellij
|
|
7
|
+
.idea/
|
|
8
|
+
*.iml
|
|
9
|
+
*.iws
|
|
10
|
+
|
|
11
|
+
# VS Code
|
|
12
|
+
.vscode/
|
|
13
|
+
|
|
14
|
+
# Mac
|
|
15
|
+
.DS_Store
|
|
16
|
+
|
|
17
|
+
# Maven
|
|
18
|
+
target/
|
|
19
|
+
**/dependency-reduced-pom.xml
|
|
20
|
+
|
|
21
|
+
# Gradle
|
|
22
|
+
**/.gradle
|
|
23
|
+
build/
|
|
24
|
+
*/out/
|
|
25
|
+
**/*/out/
|
|
26
|
+
|
|
27
|
+
# Python
|
|
28
|
+
__pycache__
|
|
29
|
+
*.swp
|
|
30
|
+
.mypy_cache
|
|
31
|
+
.coverage
|
|
32
|
+
coverage.xml
|
|
33
|
+
htmlcov
|
|
34
|
+
*.egg-info
|
|
35
|
+
dist
|
|
36
|
+
.venv
|
smithy_http-0.0.1/NOTICE
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: smithy-http
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: HTTP components for Smithy tooling.
|
|
5
|
+
License-File: NOTICE
|
|
6
|
+
Requires-Python: >=3.12
|
|
7
|
+
Requires-Dist: smithy-core
|
|
8
|
+
Provides-Extra: aiohttp
|
|
9
|
+
Requires-Dist: aiohttp<4.0,>=3.11.12; extra == 'aiohttp'
|
|
10
|
+
Requires-Dist: yarl; extra == 'aiohttp'
|
|
11
|
+
Provides-Extra: awscrt
|
|
12
|
+
Requires-Dist: awscrt>=0.23.10; extra == 'awscrt'
|
|
File without changes
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "smithy-http"
|
|
3
|
+
version = "0.0.1"
|
|
4
|
+
description = "HTTP components for Smithy tooling."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.12"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"smithy-core",
|
|
9
|
+
]
|
|
10
|
+
|
|
11
|
+
[project.optional-dependencies]
|
|
12
|
+
awscrt = [
|
|
13
|
+
"awscrt>=0.23.10",
|
|
14
|
+
]
|
|
15
|
+
aiohttp = [
|
|
16
|
+
"aiohttp>=3.11.12, <4.0",
|
|
17
|
+
"yarl"
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[build-system]
|
|
21
|
+
requires = ["hatchling"]
|
|
22
|
+
build-backend = "hatchling.build"
|
|
23
|
+
|
|
24
|
+
[tool.hatch.build]
|
|
25
|
+
exclude = [
|
|
26
|
+
"tests",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[tool.ruff]
|
|
30
|
+
src = ["src"]
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
import importlib.metadata
|
|
4
|
+
from collections import Counter, OrderedDict
|
|
5
|
+
from collections.abc import Iterable, Iterator
|
|
6
|
+
|
|
7
|
+
from . import interfaces
|
|
8
|
+
from .interfaces import FieldPosition
|
|
9
|
+
|
|
10
|
+
__version__: str = importlib.metadata.version("smithy-http")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Field(interfaces.Field):
|
|
14
|
+
"""A name-value pair representing a single field in an HTTP Request or Response.
|
|
15
|
+
|
|
16
|
+
The kind will dictate metadata placement within an HTTP message.
|
|
17
|
+
|
|
18
|
+
All field names are case insensitive and case-variance must be treated as
|
|
19
|
+
equivalent. Names may be normalized but should be preserved for accuracy during
|
|
20
|
+
transmission.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
*,
|
|
26
|
+
name: str,
|
|
27
|
+
values: Iterable[str] | None = None,
|
|
28
|
+
kind: FieldPosition = FieldPosition.HEADER,
|
|
29
|
+
):
|
|
30
|
+
self.name = name
|
|
31
|
+
self.values: list[str] = list(values) if values is not None else []
|
|
32
|
+
self.kind = kind
|
|
33
|
+
|
|
34
|
+
def add(self, value: str) -> None:
|
|
35
|
+
"""Append a value to a field."""
|
|
36
|
+
self.values.append(value)
|
|
37
|
+
|
|
38
|
+
def set(self, values: list[str]) -> None:
|
|
39
|
+
"""Overwrite existing field values."""
|
|
40
|
+
self.values = values
|
|
41
|
+
|
|
42
|
+
def remove(self, value: str) -> None:
|
|
43
|
+
"""Remove all matching entries from list."""
|
|
44
|
+
try:
|
|
45
|
+
while True:
|
|
46
|
+
self.values.remove(value)
|
|
47
|
+
except ValueError:
|
|
48
|
+
return
|
|
49
|
+
|
|
50
|
+
def as_string(self, delimiter: str = ",") -> str:
|
|
51
|
+
"""Get delimited string of all values. A comma is used by default.
|
|
52
|
+
|
|
53
|
+
If the ``Field`` has zero values, the empty string is returned. If the ``Field``
|
|
54
|
+
has exactly one value, the value is returned unmodified.
|
|
55
|
+
|
|
56
|
+
For ``Field``s with more than one value, the values are joined by a comma and a
|
|
57
|
+
space. For such multi-valued ``Field``s, any values that already contain
|
|
58
|
+
commas or double quotes will be surrounded by double quotes. Within any values
|
|
59
|
+
that get quoted, pre-existing double quotes and backslashes are escaped with a
|
|
60
|
+
backslash.
|
|
61
|
+
"""
|
|
62
|
+
value_count = len(self.values)
|
|
63
|
+
if value_count == 0:
|
|
64
|
+
return ""
|
|
65
|
+
if value_count == 1:
|
|
66
|
+
return self.values[0]
|
|
67
|
+
return ", ".join(quote_and_escape_field_value(val) for val in self.values)
|
|
68
|
+
|
|
69
|
+
def as_tuples(self) -> list[tuple[str, str]]:
|
|
70
|
+
"""Get list of ``name``, ``value`` tuples where each tuple represents one
|
|
71
|
+
value."""
|
|
72
|
+
return [(self.name, val) for val in self.values]
|
|
73
|
+
|
|
74
|
+
def __eq__(self, other: object) -> bool:
|
|
75
|
+
"""Name, values, and kind must match.
|
|
76
|
+
|
|
77
|
+
Values order must match.
|
|
78
|
+
"""
|
|
79
|
+
if not isinstance(other, Field):
|
|
80
|
+
return False
|
|
81
|
+
return (
|
|
82
|
+
self.name == other.name
|
|
83
|
+
and self.kind is other.kind
|
|
84
|
+
and self.values == other.values
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
def __repr__(self) -> str:
|
|
88
|
+
return f"Field(name={self.name!r}, value={self.values!r}, kind={self.kind!r})"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class Fields(interfaces.Fields):
|
|
92
|
+
def __init__(
|
|
93
|
+
self,
|
|
94
|
+
initial: Iterable[interfaces.Field] | None = None,
|
|
95
|
+
*,
|
|
96
|
+
encoding: str = "utf-8",
|
|
97
|
+
):
|
|
98
|
+
"""Collection of header and trailer entries mapped by name.
|
|
99
|
+
|
|
100
|
+
:param initial: Initial list of ``Field`` objects. ``Field``s can also be added
|
|
101
|
+
and later removed.
|
|
102
|
+
:param encoding: The string encoding to be used when converting the ``Field``
|
|
103
|
+
name and value from ``str`` to ``bytes`` for transmission.
|
|
104
|
+
"""
|
|
105
|
+
init_fields = list(initial) if initial is not None else []
|
|
106
|
+
init_field_names = [self._normalize_field_name(fld.name) for fld in init_fields]
|
|
107
|
+
fname_counter = Counter(init_field_names)
|
|
108
|
+
repeated_names_exist = (
|
|
109
|
+
len(init_fields) > 0 and fname_counter.most_common(1)[0][1] > 1
|
|
110
|
+
)
|
|
111
|
+
if repeated_names_exist:
|
|
112
|
+
non_unique_names = [name for name, num in fname_counter.items() if num > 1]
|
|
113
|
+
raise ValueError(
|
|
114
|
+
"Field names of the initial list of fields must be unique. The "
|
|
115
|
+
"following normalized field names appear more than once: "
|
|
116
|
+
f"{', '.join(non_unique_names)}."
|
|
117
|
+
)
|
|
118
|
+
init_tuples = zip(init_field_names, init_fields)
|
|
119
|
+
self.entries: dict[str, interfaces.Field] = OrderedDict(init_tuples)
|
|
120
|
+
self.encoding: str = encoding
|
|
121
|
+
|
|
122
|
+
def set_field(self, field: interfaces.Field) -> None:
|
|
123
|
+
"""Alias for __setitem__ to utilize the field.name for the entry key."""
|
|
124
|
+
self.__setitem__(field.name, field)
|
|
125
|
+
|
|
126
|
+
def __setitem__(self, name: str, field: interfaces.Field) -> None:
|
|
127
|
+
"""Set or override entry for a Field name."""
|
|
128
|
+
normalized_name = self._normalize_field_name(name)
|
|
129
|
+
normalized_field_name = self._normalize_field_name(field.name)
|
|
130
|
+
if normalized_name != normalized_field_name:
|
|
131
|
+
raise ValueError(
|
|
132
|
+
f"Supplied key {name} does not match Field.name "
|
|
133
|
+
f"provided: {normalized_field_name}"
|
|
134
|
+
)
|
|
135
|
+
self.entries[normalized_name] = field
|
|
136
|
+
|
|
137
|
+
def get(
|
|
138
|
+
self, key: str, default: interfaces.Field | None = None
|
|
139
|
+
) -> interfaces.Field | None:
|
|
140
|
+
return self[key] if key in self else default
|
|
141
|
+
|
|
142
|
+
def __getitem__(self, name: str) -> interfaces.Field:
|
|
143
|
+
"""Retrieve Field entry."""
|
|
144
|
+
normalized_name = self._normalize_field_name(name)
|
|
145
|
+
return self.entries[normalized_name]
|
|
146
|
+
|
|
147
|
+
def __delitem__(self, name: str) -> None:
|
|
148
|
+
"""Delete entry from collection."""
|
|
149
|
+
normalized_name = self._normalize_field_name(name)
|
|
150
|
+
del self.entries[normalized_name]
|
|
151
|
+
|
|
152
|
+
def get_by_type(self, kind: FieldPosition) -> list[interfaces.Field]:
|
|
153
|
+
"""Helper function for retrieving specific types of fields.
|
|
154
|
+
|
|
155
|
+
Used to grab all headers or all trailers.
|
|
156
|
+
"""
|
|
157
|
+
return [entry for entry in self.entries.values() if entry.kind is kind]
|
|
158
|
+
|
|
159
|
+
def extend(self, other: interfaces.Fields) -> None:
|
|
160
|
+
"""Merges ``entries`` of ``other`` into the current ``entries``.
|
|
161
|
+
|
|
162
|
+
For every `Field` in the ``entries`` of ``other``: If the normalized name
|
|
163
|
+
already exists in the current ``entries``, the values from ``other`` are
|
|
164
|
+
appended. Otherwise, the ``Field`` is added to the list of ``entries``.
|
|
165
|
+
"""
|
|
166
|
+
for other_field in other:
|
|
167
|
+
try:
|
|
168
|
+
cur_field = self.__getitem__(other_field.name)
|
|
169
|
+
for other_value in other_field.values:
|
|
170
|
+
cur_field.add(other_value)
|
|
171
|
+
except KeyError:
|
|
172
|
+
self.__setitem__(other_field.name, other_field)
|
|
173
|
+
|
|
174
|
+
def _normalize_field_name(self, name: str) -> str:
|
|
175
|
+
"""Normalize field names.
|
|
176
|
+
|
|
177
|
+
For use as key in ``entries``.
|
|
178
|
+
"""
|
|
179
|
+
return name.lower()
|
|
180
|
+
|
|
181
|
+
def __eq__(self, other: object) -> bool:
|
|
182
|
+
"""Encoding must match.
|
|
183
|
+
|
|
184
|
+
Entries must match in values and order.
|
|
185
|
+
"""
|
|
186
|
+
if not isinstance(other, Fields):
|
|
187
|
+
return False
|
|
188
|
+
return self.encoding == other.encoding and self.entries == other.entries
|
|
189
|
+
|
|
190
|
+
def __iter__(self) -> Iterator[interfaces.Field]:
|
|
191
|
+
yield from self.entries.values()
|
|
192
|
+
|
|
193
|
+
def __len__(self) -> int:
|
|
194
|
+
return len(self.entries)
|
|
195
|
+
|
|
196
|
+
def __repr__(self) -> str:
|
|
197
|
+
return f"Fields({self.entries})"
|
|
198
|
+
|
|
199
|
+
def __contains__(self, key: str) -> bool:
|
|
200
|
+
return self._normalize_field_name(key) in self.entries
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def quote_and_escape_field_value(value: str) -> str:
|
|
204
|
+
"""Escapes and quotes a single :class:`Field` value if necessary.
|
|
205
|
+
|
|
206
|
+
See :func:`Field.as_string` for quoting and escaping logic.
|
|
207
|
+
"""
|
|
208
|
+
chars_to_quote = (",", '"')
|
|
209
|
+
if any(char in chars_to_quote for char in value):
|
|
210
|
+
escaped = value.replace("\\", "\\\\").replace('"', '\\"')
|
|
211
|
+
return f'"{escaped}"'
|
|
212
|
+
else:
|
|
213
|
+
return value
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def tuples_to_fields(
|
|
217
|
+
tuples: Iterable[tuple[str, str]], *, kind: FieldPosition | None = None
|
|
218
|
+
) -> Fields:
|
|
219
|
+
"""Convert ``name``, ``value`` tuples to ``Fields`` object. Each tuple represents
|
|
220
|
+
one Field value.
|
|
221
|
+
|
|
222
|
+
:param kind: The Field kind to define for all tuples.
|
|
223
|
+
"""
|
|
224
|
+
fields = Fields()
|
|
225
|
+
for name, value in tuples:
|
|
226
|
+
try:
|
|
227
|
+
fields[name].add(value)
|
|
228
|
+
except KeyError:
|
|
229
|
+
fields[name] = Field(
|
|
230
|
+
name=name, values=[value], kind=kind or FieldPosition.HEADER
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
return fields
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
|
|
5
|
+
from smithy_core import interfaces as core_interfaces
|
|
6
|
+
from smithy_core.aio import interfaces as core_aio_interfaces
|
|
7
|
+
from smithy_core.aio.utils import read_streaming_blob, read_streaming_blob_async
|
|
8
|
+
|
|
9
|
+
from .. import interfaces as http_interfaces
|
|
10
|
+
from . import interfaces as http_aio_interfaces
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(kw_only=True)
|
|
14
|
+
class HTTPRequest(http_aio_interfaces.HTTPRequest):
|
|
15
|
+
"""HTTP primitives for an Exchange to construct a version agnostic HTTP message."""
|
|
16
|
+
|
|
17
|
+
destination: core_interfaces.URI
|
|
18
|
+
body: core_aio_interfaces.StreamingBlob = field(repr=False, default=b"")
|
|
19
|
+
method: str
|
|
20
|
+
fields: http_interfaces.Fields
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# HTTPResponse implements http_interfaces.HTTPResponse but cannot be explicitly
|
|
24
|
+
# annotated to reflect this because doing so causes Python to raise an AttributeError.
|
|
25
|
+
# See https://github.com/python/typing/discussions/903#discussioncomment-4866851 for
|
|
26
|
+
# details.
|
|
27
|
+
@dataclass(kw_only=True)
|
|
28
|
+
class HTTPResponse:
|
|
29
|
+
"""Basic implementation of :py:class:`.interfaces.HTTPResponse`.
|
|
30
|
+
|
|
31
|
+
Implementations of :py:class:`.interfaces.HTTPClient` may return instances of this
|
|
32
|
+
class or of custom response implementations.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
body: core_aio_interfaces.StreamingBlob = field(repr=False, default=b"")
|
|
36
|
+
"""The response payload as iterable of chunks of bytes."""
|
|
37
|
+
|
|
38
|
+
status: int
|
|
39
|
+
"""The 3 digit response status code (1xx, 2xx, 3xx, 4xx, 5xx)."""
|
|
40
|
+
|
|
41
|
+
fields: http_interfaces.Fields
|
|
42
|
+
"""HTTP header and trailer fields."""
|
|
43
|
+
|
|
44
|
+
reason: str | None = None
|
|
45
|
+
"""Optional string provided by the server explaining the status."""
|
|
46
|
+
|
|
47
|
+
async def consume_body_async(self) -> bytes:
|
|
48
|
+
"""Iterate over response body and return as bytes."""
|
|
49
|
+
return await read_streaming_blob_async(body=self.body)
|
|
50
|
+
|
|
51
|
+
def consume_body(self) -> bytes:
|
|
52
|
+
"""Iterate over request body and return as bytes."""
|
|
53
|
+
return read_streaming_blob(body=self.body)
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
from copy import copy, deepcopy
|
|
4
|
+
from itertools import chain
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
|
+
from urllib.parse import parse_qs
|
|
7
|
+
|
|
8
|
+
import yarl
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
# pyright doesn't like optional imports. This is reasonable because if we use these
|
|
12
|
+
# in type hints then they'd result in runtime errors.
|
|
13
|
+
# TODO: add integ tests that import these without the dependendency installed
|
|
14
|
+
import aiohttp
|
|
15
|
+
|
|
16
|
+
try:
|
|
17
|
+
import aiohttp
|
|
18
|
+
|
|
19
|
+
HAS_AIOHTTP = True
|
|
20
|
+
except ImportError:
|
|
21
|
+
HAS_AIOHTTP = False # type: ignore
|
|
22
|
+
|
|
23
|
+
from smithy_core.aio.interfaces import StreamingBlob
|
|
24
|
+
from smithy_core.aio.types import AsyncBytesReader
|
|
25
|
+
from smithy_core.aio.utils import async_list
|
|
26
|
+
from smithy_core.exceptions import MissingDependencyException
|
|
27
|
+
from smithy_core.interfaces import URI
|
|
28
|
+
|
|
29
|
+
from .. import Field, Fields
|
|
30
|
+
from ..interfaces import (
|
|
31
|
+
FieldPosition,
|
|
32
|
+
HTTPClientConfiguration,
|
|
33
|
+
HTTPRequestConfiguration,
|
|
34
|
+
)
|
|
35
|
+
from . import HTTPResponse
|
|
36
|
+
from .interfaces import HTTPClient, HTTPRequest
|
|
37
|
+
from .interfaces import HTTPResponse as HTTPResponseInterface
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _assert_aiohttp() -> None:
|
|
41
|
+
if not HAS_AIOHTTP:
|
|
42
|
+
raise MissingDependencyException(
|
|
43
|
+
"Attempted to use aiohttp component, but aiohttp is not installed."
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class AIOHTTPClientConfig(HTTPClientConfiguration):
|
|
48
|
+
def __post_init__(self) -> None:
|
|
49
|
+
_assert_aiohttp()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class AIOHTTPClient(HTTPClient):
|
|
53
|
+
"""Implementation of :py:class:`.interfaces.HTTPClient` using aiohttp."""
|
|
54
|
+
|
|
55
|
+
def __init__(
|
|
56
|
+
self,
|
|
57
|
+
*,
|
|
58
|
+
client_config: AIOHTTPClientConfig | None = None,
|
|
59
|
+
_session: "aiohttp.ClientSession | None" = None,
|
|
60
|
+
) -> None:
|
|
61
|
+
"""
|
|
62
|
+
:param client_config: Configuration that applies to all requests made with this
|
|
63
|
+
client.
|
|
64
|
+
"""
|
|
65
|
+
_assert_aiohttp()
|
|
66
|
+
self._config = client_config or AIOHTTPClientConfig()
|
|
67
|
+
self._session = _session or aiohttp.ClientSession()
|
|
68
|
+
|
|
69
|
+
async def send(
|
|
70
|
+
self,
|
|
71
|
+
request: HTTPRequest,
|
|
72
|
+
*,
|
|
73
|
+
request_config: HTTPRequestConfiguration | None = None,
|
|
74
|
+
) -> HTTPResponseInterface:
|
|
75
|
+
"""Send HTTP request using aiohttp client.
|
|
76
|
+
|
|
77
|
+
:param request: The request including destination URI, fields, payload.
|
|
78
|
+
:param request_config: Configuration specific to this request.
|
|
79
|
+
"""
|
|
80
|
+
request_config = request_config or HTTPRequestConfiguration()
|
|
81
|
+
|
|
82
|
+
headers_list = list(
|
|
83
|
+
chain.from_iterable(fld.as_tuples() for fld in request.fields)
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
body: StreamingBlob = request.body
|
|
87
|
+
if not isinstance(body, AsyncBytesReader):
|
|
88
|
+
body = AsyncBytesReader(body)
|
|
89
|
+
|
|
90
|
+
# The typing on `params` is incorrect, it'll happily accept a mapping whose
|
|
91
|
+
# values are lists (or tuples) and produce expected values.
|
|
92
|
+
# See: https://github.com/aio-libs/aiohttp/issues/8563
|
|
93
|
+
async with self._session.request(
|
|
94
|
+
method=request.method,
|
|
95
|
+
url=self._serialize_uri_without_query(request.destination),
|
|
96
|
+
params=parse_qs(request.destination.query), # type: ignore
|
|
97
|
+
headers=headers_list,
|
|
98
|
+
data=body,
|
|
99
|
+
) as resp:
|
|
100
|
+
return await self._marshal_response(resp)
|
|
101
|
+
|
|
102
|
+
def _serialize_uri_without_query(self, uri: URI) -> yarl.URL:
|
|
103
|
+
"""Serialize all parts of the URI up to and including the path."""
|
|
104
|
+
return yarl.URL.build(
|
|
105
|
+
scheme=uri.scheme or "",
|
|
106
|
+
host=uri.host,
|
|
107
|
+
port=uri.port,
|
|
108
|
+
user=uri.username,
|
|
109
|
+
password=uri.password,
|
|
110
|
+
path=uri.path or "",
|
|
111
|
+
encoded=True,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
async def _marshal_response(
|
|
115
|
+
self, aiohttp_resp: "aiohttp.ClientResponse"
|
|
116
|
+
) -> HTTPResponseInterface:
|
|
117
|
+
"""Convert a ``aiohttp.ClientResponse`` to a ``smithy_http.aio.HTTPResponse``"""
|
|
118
|
+
headers = Fields()
|
|
119
|
+
for header_name, header_val in aiohttp_resp.headers.items():
|
|
120
|
+
try:
|
|
121
|
+
headers[header_name].add(header_val)
|
|
122
|
+
except KeyError:
|
|
123
|
+
headers[header_name] = Field(
|
|
124
|
+
name=header_name,
|
|
125
|
+
values=[header_val],
|
|
126
|
+
kind=FieldPosition.HEADER,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
return HTTPResponse(
|
|
130
|
+
status=aiohttp_resp.status,
|
|
131
|
+
fields=headers,
|
|
132
|
+
body=async_list([await aiohttp_resp.read()]),
|
|
133
|
+
reason=aiohttp_resp.reason,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
def __deepcopy__(self, memo: Any) -> "AIOHTTPClient":
|
|
137
|
+
return AIOHTTPClient(
|
|
138
|
+
client_config=deepcopy(self._config),
|
|
139
|
+
_session=copy(self._session),
|
|
140
|
+
)
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from enum import Enum
|
|
5
|
+
from typing import NotRequired, Protocol, TypedDict
|
|
6
|
+
|
|
7
|
+
from smithy_core import URI
|
|
8
|
+
from smithy_core.aio.interfaces.identity import IdentityResolver
|
|
9
|
+
from smithy_core.exceptions import SmithyIdentityException
|
|
10
|
+
from smithy_core.interfaces.identity import IdentityProperties
|
|
11
|
+
|
|
12
|
+
from ... import Field
|
|
13
|
+
from ..identity.apikey import ApiKeyIdentity
|
|
14
|
+
from ..interfaces import HTTPRequest
|
|
15
|
+
from ..interfaces.auth import HTTPAuthScheme, HTTPSigner
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ApiKeyLocation(Enum):
|
|
19
|
+
"""The locations that the api key could be placed in the signed request."""
|
|
20
|
+
|
|
21
|
+
HEADER = "header"
|
|
22
|
+
QUERY = "query"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ApiKeySigningProperties(TypedDict):
|
|
26
|
+
"""The properties needed to sign a request with api key auth.
|
|
27
|
+
|
|
28
|
+
seealso:: The `Smithy API Key auth trait docs <https://smithy.io/2.0/spec/authentication-traits.html#smithy-api-httpapikeyauth-trait>`_
|
|
29
|
+
, which have more details on these properties, including examples.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
name: str
|
|
33
|
+
"""The name of the HTTP header or query string parameter containing the key."""
|
|
34
|
+
|
|
35
|
+
scheme: NotRequired[str]
|
|
36
|
+
"""The :rfc:`9110#section-11.4` scheme to prefix a header value with."""
|
|
37
|
+
|
|
38
|
+
location: ApiKeyLocation
|
|
39
|
+
"""Where the key is serialized."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ApiKeyConfig(Protocol):
|
|
43
|
+
api_key_identity_resolver: (
|
|
44
|
+
IdentityResolver[ApiKeyIdentity, IdentityProperties] | None
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(init=False)
|
|
49
|
+
class ApiKeyAuthScheme(
|
|
50
|
+
HTTPAuthScheme[
|
|
51
|
+
ApiKeyIdentity, ApiKeyConfig, IdentityProperties, ApiKeySigningProperties
|
|
52
|
+
]
|
|
53
|
+
):
|
|
54
|
+
"""An auth scheme containing necessary data and tools for api key auth."""
|
|
55
|
+
|
|
56
|
+
scheme_id: str
|
|
57
|
+
signer: HTTPSigner[ApiKeyIdentity, ApiKeySigningProperties]
|
|
58
|
+
|
|
59
|
+
def __init__(
|
|
60
|
+
self,
|
|
61
|
+
*,
|
|
62
|
+
signer: HTTPSigner[ApiKeyIdentity, ApiKeySigningProperties] | None = None,
|
|
63
|
+
) -> None:
|
|
64
|
+
"""Constructor.
|
|
65
|
+
|
|
66
|
+
:param identity_resolver: The identity resolver to extract the api key identity.
|
|
67
|
+
:param signer: The signer used to sign the request.
|
|
68
|
+
"""
|
|
69
|
+
self.scheme_id = "smithy.api#httpApiKeyAuth"
|
|
70
|
+
self.signer = signer or ApiKeySigner()
|
|
71
|
+
|
|
72
|
+
def identity_resolver(
|
|
73
|
+
self, *, config: ApiKeyConfig
|
|
74
|
+
) -> IdentityResolver[ApiKeyIdentity, IdentityProperties]:
|
|
75
|
+
if not config.api_key_identity_resolver:
|
|
76
|
+
raise SmithyIdentityException(
|
|
77
|
+
"Attempted to use API key auth, but api_key_identity_resolver was not "
|
|
78
|
+
"set on the config."
|
|
79
|
+
)
|
|
80
|
+
return config.api_key_identity_resolver
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class ApiKeySigner(HTTPSigner[ApiKeyIdentity, ApiKeySigningProperties]):
|
|
84
|
+
"""A signer that signs http requests with an api key."""
|
|
85
|
+
|
|
86
|
+
async def sign(
|
|
87
|
+
self,
|
|
88
|
+
*,
|
|
89
|
+
http_request: HTTPRequest,
|
|
90
|
+
identity: ApiKeyIdentity,
|
|
91
|
+
signing_properties: ApiKeySigningProperties,
|
|
92
|
+
) -> HTTPRequest:
|
|
93
|
+
match signing_properties["location"]:
|
|
94
|
+
case ApiKeyLocation.QUERY:
|
|
95
|
+
query = http_request.destination.query or ""
|
|
96
|
+
if query:
|
|
97
|
+
query += "&"
|
|
98
|
+
query += f"{signing_properties['name']}={identity.api_key}"
|
|
99
|
+
http_request.destination = URI(
|
|
100
|
+
scheme=http_request.destination.scheme,
|
|
101
|
+
username=http_request.destination.username,
|
|
102
|
+
password=http_request.destination.password,
|
|
103
|
+
host=http_request.destination.host,
|
|
104
|
+
port=http_request.destination.port,
|
|
105
|
+
path=http_request.destination.password,
|
|
106
|
+
query=query,
|
|
107
|
+
fragment=http_request.destination.fragment,
|
|
108
|
+
)
|
|
109
|
+
case ApiKeyLocation.HEADER:
|
|
110
|
+
value = identity.api_key
|
|
111
|
+
if (scheme := signing_properties.get("scheme", None)) is not None:
|
|
112
|
+
value = f"{scheme} {value}"
|
|
113
|
+
http_request.fields.set_field(
|
|
114
|
+
Field(name=signing_properties["name"], values=[value])
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
return http_request
|