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.
Files changed (30) hide show
  1. smithy_http-0.0.1/.gitignore +36 -0
  2. smithy_http-0.0.1/CHANGES.md +11 -0
  3. smithy_http-0.0.1/NOTICE +1 -0
  4. smithy_http-0.0.1/PKG-INFO +12 -0
  5. smithy_http-0.0.1/README.md +0 -0
  6. smithy_http-0.0.1/pyproject.toml +30 -0
  7. smithy_http-0.0.1/src/smithy_http/__init__.py +233 -0
  8. smithy_http-0.0.1/src/smithy_http/aio/__init__.py +53 -0
  9. smithy_http-0.0.1/src/smithy_http/aio/aiohttp.py +140 -0
  10. smithy_http-0.0.1/src/smithy_http/aio/auth/__init__.py +2 -0
  11. smithy_http-0.0.1/src/smithy_http/aio/auth/apikey.py +117 -0
  12. smithy_http-0.0.1/src/smithy_http/aio/crt.py +493 -0
  13. smithy_http-0.0.1/src/smithy_http/aio/identity/__init__.py +2 -0
  14. smithy_http-0.0.1/src/smithy_http/aio/identity/apikey.py +38 -0
  15. smithy_http-0.0.1/src/smithy_http/aio/interfaces/__init__.py +85 -0
  16. smithy_http-0.0.1/src/smithy_http/aio/interfaces/auth.py +107 -0
  17. smithy_http-0.0.1/src/smithy_http/aio/protocols.py +118 -0
  18. smithy_http-0.0.1/src/smithy_http/aio/restjson.py +60 -0
  19. smithy_http-0.0.1/src/smithy_http/deserializers.py +259 -0
  20. smithy_http-0.0.1/src/smithy_http/endpoints.py +30 -0
  21. smithy_http-0.0.1/src/smithy_http/exceptions.py +7 -0
  22. smithy_http-0.0.1/src/smithy_http/interceptors/__init__.py +2 -0
  23. smithy_http-0.0.1/src/smithy_http/interceptors/user_agent.py +153 -0
  24. smithy_http-0.0.1/src/smithy_http/interfaces/__init__.py +141 -0
  25. smithy_http-0.0.1/src/smithy_http/plugins/__init__.py +15 -0
  26. smithy_http-0.0.1/src/smithy_http/py.typed +0 -0
  27. smithy_http-0.0.1/src/smithy_http/restjson.py +29 -0
  28. smithy_http-0.0.1/src/smithy_http/serializers.py +631 -0
  29. smithy_http-0.0.1/src/smithy_http/user_agent.py +97 -0
  30. 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
@@ -0,0 +1,11 @@
1
+ # Changelog
2
+
3
+ ## Unreleased
4
+
5
+ * <Add new items here>
6
+
7
+ ## v0.0.1
8
+
9
+ ### Feature
10
+ * Added support for aiohttp and AWSCRT http clients.
11
+ * Added basic HTTP primitives and interfaces for Smithy clients.
@@ -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,2 @@
1
+ # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ # SPDX-License-Identifier: Apache-2.0
@@ -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