haveapi-client 0.30.0__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.
- haveapi_client/__init__.py +62 -0
- haveapi_client/_auth/__init__.py +139 -0
- haveapi_client/_protocol/__init__.py +52 -0
- haveapi_client/_protocol/description.py +176 -0
- haveapi_client/_protocol/execution.py +61 -0
- haveapi_client/_protocol/input.py +161 -0
- haveapi_client/_protocol/models.py +166 -0
- haveapi_client/_protocol/request.py +345 -0
- haveapi_client/_protocol/response.py +97 -0
- haveapi_client/_resources/__init__.py +196 -0
- haveapi_client/_resources/state.py +266 -0
- haveapi_client/_transport/__init__.py +155 -0
- haveapi_client/async_client.py +209 -0
- haveapi_client/client.py +203 -0
- haveapi_client/exceptions.py +88 -0
- haveapi_client/operations.py +190 -0
- haveapi_client/pagination.py +202 -0
- haveapi_client/py.typed +0 -0
- haveapi_client/response.py +303 -0
- haveapi_client-0.30.0.dist-info/METADATA +198 -0
- haveapi_client-0.30.0.dist-info/RECORD +23 -0
- haveapi_client-0.30.0.dist-info/WHEEL +4 -0
- haveapi_client-0.30.0.dist-info/licenses/LICENSE.txt +21 -0
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""Immutable resource and action navigation; only calls perform I/O."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from types import MappingProxyType
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from haveapi_client._protocol import (
|
|
11
|
+
CallContext,
|
|
12
|
+
InputParams,
|
|
13
|
+
RequestOptions,
|
|
14
|
+
lookup_action,
|
|
15
|
+
lookup_resource,
|
|
16
|
+
)
|
|
17
|
+
from haveapi_client.exceptions import StaleDescriptionError
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _path_params(
|
|
21
|
+
old: Mapping[str, str | int], new: Mapping[str, str | int] | None
|
|
22
|
+
) -> Mapping[str, str | int]:
|
|
23
|
+
result = dict(old)
|
|
24
|
+
for key, value in (new or {}).items():
|
|
25
|
+
if key in result and result[key] != value:
|
|
26
|
+
raise ValueError(f"Conflicting path parameter: {key}")
|
|
27
|
+
result[key] = value
|
|
28
|
+
return MappingProxyType(result)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class Resource:
|
|
33
|
+
_executor: Any = field(repr=False, compare=False)
|
|
34
|
+
_path: tuple[str, ...]
|
|
35
|
+
_bound_args: tuple[str | int, ...] = field(default=(), repr=False)
|
|
36
|
+
_path_params: Mapping[str, str | int] = field(default_factory=dict, repr=False)
|
|
37
|
+
_generation: int = 0
|
|
38
|
+
_async: bool = False
|
|
39
|
+
|
|
40
|
+
def __post_init__(self) -> None:
|
|
41
|
+
object.__setattr__(self, "_path", tuple(self._path))
|
|
42
|
+
object.__setattr__(self, "_bound_args", tuple(self._bound_args))
|
|
43
|
+
object.__setattr__(
|
|
44
|
+
self, "_path_params", MappingProxyType(dict(self._path_params))
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
def _check(self) -> None:
|
|
48
|
+
if self._executor.generation != self._generation:
|
|
49
|
+
raise StaleDescriptionError("Resource belongs to an old description")
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def resources(self) -> Mapping[str, Resource]:
|
|
53
|
+
self._check()
|
|
54
|
+
spec = lookup_resource(self._executor.description, self._path)
|
|
55
|
+
return MappingProxyType(
|
|
56
|
+
{
|
|
57
|
+
name: Resource(
|
|
58
|
+
self._executor,
|
|
59
|
+
child.resource_path,
|
|
60
|
+
self._bound_args,
|
|
61
|
+
self._path_params,
|
|
62
|
+
self._generation,
|
|
63
|
+
self._async,
|
|
64
|
+
)
|
|
65
|
+
for name, child in spec.resources.items()
|
|
66
|
+
}
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def actions(self) -> Mapping[str, Action]:
|
|
71
|
+
self._check()
|
|
72
|
+
spec = lookup_resource(self._executor.description, self._path)
|
|
73
|
+
return MappingProxyType({name: self._action(name) for name in spec.actions})
|
|
74
|
+
|
|
75
|
+
def _action(self, name: str) -> Action:
|
|
76
|
+
self._check()
|
|
77
|
+
spec = lookup_action(self._executor.description, self._path, name)
|
|
78
|
+
return Action(
|
|
79
|
+
self._executor,
|
|
80
|
+
CallContext(spec, self._bound_args, self._path_params, self._generation),
|
|
81
|
+
self._async,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
def __getattr__(self, name: str) -> Any:
|
|
85
|
+
if name.startswith("_"):
|
|
86
|
+
raise AttributeError(name)
|
|
87
|
+
self._check()
|
|
88
|
+
spec = lookup_resource(self._executor.description, self._path)
|
|
89
|
+
has_action = name in spec.actions or any(
|
|
90
|
+
name in a.aliases for a in spec.actions.values()
|
|
91
|
+
)
|
|
92
|
+
if name in spec.resources:
|
|
93
|
+
if has_action:
|
|
94
|
+
raise AttributeError(
|
|
95
|
+
f"Ambiguous resource/action {name!r}; use exact-name maps"
|
|
96
|
+
)
|
|
97
|
+
return self.resources[name]
|
|
98
|
+
if has_action:
|
|
99
|
+
return self._action(name)
|
|
100
|
+
raise AttributeError(name)
|
|
101
|
+
|
|
102
|
+
def __getitem__(self, value: str | int) -> Resource:
|
|
103
|
+
self._check()
|
|
104
|
+
if isinstance(value, bool) or not isinstance(value, (str, int)):
|
|
105
|
+
raise TypeError("Path bindings must be strings or integers")
|
|
106
|
+
return Resource(
|
|
107
|
+
self._executor,
|
|
108
|
+
self._path,
|
|
109
|
+
(*self._bound_args, value),
|
|
110
|
+
self._path_params,
|
|
111
|
+
self._generation,
|
|
112
|
+
self._async,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
def __dir__(self) -> list[str]:
|
|
116
|
+
self._check()
|
|
117
|
+
spec = lookup_resource(self._executor.description, self._path)
|
|
118
|
+
return sorted(set(super().__dir__()) | set(spec.resources) | set(spec.actions))
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@dataclass(frozen=True)
|
|
122
|
+
class Action:
|
|
123
|
+
_executor: Any = field(repr=False, compare=False)
|
|
124
|
+
_context: CallContext = field(repr=False)
|
|
125
|
+
_async: bool = False
|
|
126
|
+
|
|
127
|
+
def _binding(self, path_params: Mapping[str, str | int] | None) -> CallContext:
|
|
128
|
+
if self._executor.generation != self._context.generation:
|
|
129
|
+
raise StaleDescriptionError("Action belongs to an old description")
|
|
130
|
+
return CallContext(
|
|
131
|
+
self._context.action,
|
|
132
|
+
self._context.bound_args,
|
|
133
|
+
_path_params(self._context.path_params, path_params),
|
|
134
|
+
self._context.generation,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
def __call__(self, **params: object) -> Any:
|
|
138
|
+
return self.call(params=params)
|
|
139
|
+
|
|
140
|
+
def call(
|
|
141
|
+
self,
|
|
142
|
+
*,
|
|
143
|
+
params: InputParams | None = None,
|
|
144
|
+
meta: Mapping[str, object] | None = None,
|
|
145
|
+
path_params: Mapping[str, str | int] | None = None,
|
|
146
|
+
options: RequestOptions | None = None,
|
|
147
|
+
) -> Any:
|
|
148
|
+
return self._executor.execute(
|
|
149
|
+
self._binding(path_params), params=params, meta=meta, options=options
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
def iter_pages(
|
|
153
|
+
self,
|
|
154
|
+
*,
|
|
155
|
+
strategy: Any,
|
|
156
|
+
page_size: int = 100,
|
|
157
|
+
params: Mapping[str, object] | None = None,
|
|
158
|
+
meta: Mapping[str, object] | None = None,
|
|
159
|
+
path_params: Mapping[str, str | int] | None = None,
|
|
160
|
+
options: RequestOptions | None = None,
|
|
161
|
+
) -> Any:
|
|
162
|
+
from haveapi_client.pagination import aiter_pages, iter_pages
|
|
163
|
+
|
|
164
|
+
function = aiter_pages if self._async else iter_pages
|
|
165
|
+
return function(
|
|
166
|
+
self._executor,
|
|
167
|
+
self._binding(path_params),
|
|
168
|
+
strategy=strategy,
|
|
169
|
+
page_size=page_size,
|
|
170
|
+
params=params,
|
|
171
|
+
meta=meta,
|
|
172
|
+
options=options,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
def iter_all(
|
|
176
|
+
self,
|
|
177
|
+
*,
|
|
178
|
+
strategy: Any,
|
|
179
|
+
page_size: int = 100,
|
|
180
|
+
params: Mapping[str, object] | None = None,
|
|
181
|
+
meta: Mapping[str, object] | None = None,
|
|
182
|
+
path_params: Mapping[str, str | int] | None = None,
|
|
183
|
+
options: RequestOptions | None = None,
|
|
184
|
+
) -> Any:
|
|
185
|
+
from haveapi_client.pagination import aiter_all, iter_all
|
|
186
|
+
|
|
187
|
+
function = aiter_all if self._async else iter_all
|
|
188
|
+
return function(
|
|
189
|
+
self._executor,
|
|
190
|
+
self._binding(path_params),
|
|
191
|
+
strategy=strategy,
|
|
192
|
+
page_size=page_size,
|
|
193
|
+
params=params,
|
|
194
|
+
meta=meta,
|
|
195
|
+
options=options,
|
|
196
|
+
)
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"""Shared pure connection state and concurrency gates for both client facades."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import threading
|
|
7
|
+
from collections.abc import Iterator, Mapping
|
|
8
|
+
from contextlib import contextmanager
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from haveapi_client._auth import AuthProvider, validate_headers
|
|
12
|
+
from haveapi_client._protocol import (
|
|
13
|
+
ActionSpec,
|
|
14
|
+
CallContext,
|
|
15
|
+
Description,
|
|
16
|
+
InputParams,
|
|
17
|
+
ParameterSpec,
|
|
18
|
+
RequestSpec,
|
|
19
|
+
build_request,
|
|
20
|
+
lookup_resource,
|
|
21
|
+
parse_resources,
|
|
22
|
+
)
|
|
23
|
+
from haveapi_client._resources import Resource
|
|
24
|
+
from haveapi_client._transport import normalize_base_url, validate_destination
|
|
25
|
+
from haveapi_client.exceptions import (
|
|
26
|
+
AuthenticationError,
|
|
27
|
+
NotConnectedError,
|
|
28
|
+
ProtocolError,
|
|
29
|
+
StaleDescriptionError,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ClientState:
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
base_url: str,
|
|
37
|
+
*,
|
|
38
|
+
version: str | None,
|
|
39
|
+
auth: AuthProvider | None,
|
|
40
|
+
language: str | None,
|
|
41
|
+
asynchronous: bool,
|
|
42
|
+
) -> None:
|
|
43
|
+
self._base_url = normalize_base_url(base_url)
|
|
44
|
+
if version is not None and (
|
|
45
|
+
not re.fullmatch(r"[A-Za-z0-9_-][A-Za-z0-9._-]*", version)
|
|
46
|
+
):
|
|
47
|
+
raise ValueError("Invalid API version")
|
|
48
|
+
if language is not None and any(ord(c) < 32 or ord(c) > 126 for c in language):
|
|
49
|
+
raise ValueError("Invalid language header")
|
|
50
|
+
self._version = version
|
|
51
|
+
self._auth = auth
|
|
52
|
+
self._language = language
|
|
53
|
+
self._description: Description | None = None
|
|
54
|
+
self._generation = 0
|
|
55
|
+
self._asynchronous = asynchronous
|
|
56
|
+
self._lock = threading.RLock()
|
|
57
|
+
self._active = 0
|
|
58
|
+
self._mutating = False
|
|
59
|
+
self._closed = False
|
|
60
|
+
self._login: object | None = None
|
|
61
|
+
|
|
62
|
+
def __repr__(self) -> str:
|
|
63
|
+
return f"{type(self).__name__}(connected={self._description is not None})"
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def description(self) -> Description:
|
|
67
|
+
if self._description is None:
|
|
68
|
+
raise NotConnectedError("Call connect() before accessing API resources")
|
|
69
|
+
return self._description
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def generation(self) -> int:
|
|
73
|
+
return self._generation
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def resources(self) -> Mapping[str, Resource]:
|
|
77
|
+
from types import MappingProxyType
|
|
78
|
+
|
|
79
|
+
return MappingProxyType(
|
|
80
|
+
{name: self.resource((name,)) for name in self.description.resources}
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
def resource(
|
|
84
|
+
self,
|
|
85
|
+
path: tuple[str, ...],
|
|
86
|
+
*,
|
|
87
|
+
bound_args: tuple[str | int, ...] = (),
|
|
88
|
+
path_params: Mapping[str, str | int] | None = None,
|
|
89
|
+
) -> Resource:
|
|
90
|
+
lookup_resource(self.description, path)
|
|
91
|
+
return Resource(
|
|
92
|
+
self,
|
|
93
|
+
path,
|
|
94
|
+
bound_args,
|
|
95
|
+
path_params or {},
|
|
96
|
+
self.generation,
|
|
97
|
+
self._asynchronous,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
def __getattr__(self, name: str) -> Any:
|
|
101
|
+
if name.startswith("_"):
|
|
102
|
+
raise AttributeError(name)
|
|
103
|
+
if name in self.description.resources:
|
|
104
|
+
return self.resource((name,))
|
|
105
|
+
raise AttributeError(name)
|
|
106
|
+
|
|
107
|
+
def __dir__(self) -> list[str]:
|
|
108
|
+
return sorted(
|
|
109
|
+
set(super().__dir__())
|
|
110
|
+
| set(self._description.resources if self._description else ())
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
@contextmanager
|
|
114
|
+
def _mutation(self) -> Iterator[None]:
|
|
115
|
+
# Never hold a thread lock across network I/O or an await. The same
|
|
116
|
+
# short reservation gate handles threads and independently scheduled tasks.
|
|
117
|
+
with self._lock:
|
|
118
|
+
if self._closed:
|
|
119
|
+
raise NotConnectedError("Client is closed")
|
|
120
|
+
if self._active or self._mutating:
|
|
121
|
+
raise AuthenticationError(
|
|
122
|
+
"Client state is in use; retry the explicit state change later"
|
|
123
|
+
)
|
|
124
|
+
self._mutating = True
|
|
125
|
+
try:
|
|
126
|
+
yield
|
|
127
|
+
finally:
|
|
128
|
+
with self._lock:
|
|
129
|
+
self._mutating = False
|
|
130
|
+
|
|
131
|
+
@contextmanager
|
|
132
|
+
def _request(self, context: CallContext) -> Iterator[Description]:
|
|
133
|
+
with self._lock:
|
|
134
|
+
if context.generation != self.generation:
|
|
135
|
+
raise StaleDescriptionError("Action belongs to an old description")
|
|
136
|
+
if self._mutating:
|
|
137
|
+
raise AuthenticationError(
|
|
138
|
+
"Client authentication or description is changing"
|
|
139
|
+
)
|
|
140
|
+
description = self.description
|
|
141
|
+
self._active += 1
|
|
142
|
+
try:
|
|
143
|
+
yield description
|
|
144
|
+
finally:
|
|
145
|
+
with self._lock:
|
|
146
|
+
self._active -= 1
|
|
147
|
+
|
|
148
|
+
def _invalidate(self) -> None:
|
|
149
|
+
self._generation += 1
|
|
150
|
+
self._description = None
|
|
151
|
+
self._login = None
|
|
152
|
+
|
|
153
|
+
def _install(self, description: Description) -> None:
|
|
154
|
+
self._generation += 1
|
|
155
|
+
self._description = description
|
|
156
|
+
self._login = None
|
|
157
|
+
|
|
158
|
+
def _headers(
|
|
159
|
+
self, description: Description | None, auth: AuthProvider | None
|
|
160
|
+
) -> dict[str, str]:
|
|
161
|
+
headers = {"Accept": "application/json", "User-Agent": "haveapi-client-python"}
|
|
162
|
+
if self._language:
|
|
163
|
+
headers["Accept-Language"] = self._language
|
|
164
|
+
if auth is not None:
|
|
165
|
+
if description is None or auth.method not in description.authentication:
|
|
166
|
+
raise AuthenticationError("Authentication method is not advertised")
|
|
167
|
+
headers.update(validate_headers(auth.headers(description)))
|
|
168
|
+
return headers
|
|
169
|
+
|
|
170
|
+
def _discovery_request(
|
|
171
|
+
self,
|
|
172
|
+
description: Description | None = None,
|
|
173
|
+
auth: AuthProvider | None = None,
|
|
174
|
+
) -> RequestSpec:
|
|
175
|
+
url = self._base_url + (
|
|
176
|
+
f"v{self._version}/" if self._version is not None else ""
|
|
177
|
+
)
|
|
178
|
+
validate_destination(self._base_url, url)
|
|
179
|
+
query = () if self._version is not None else (("describe", "default"),)
|
|
180
|
+
return RequestSpec(
|
|
181
|
+
"OPTIONS", url, query, self._headers(description, auth), None
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
def _action_request(
|
|
185
|
+
self,
|
|
186
|
+
description: Description,
|
|
187
|
+
context: CallContext,
|
|
188
|
+
*,
|
|
189
|
+
auth: AuthProvider | None,
|
|
190
|
+
params: InputParams | None = None,
|
|
191
|
+
meta: Mapping[str, object] | None = None,
|
|
192
|
+
) -> RequestSpec:
|
|
193
|
+
request = build_request(
|
|
194
|
+
description, context, base_url=self._base_url, params=params, meta=meta
|
|
195
|
+
)
|
|
196
|
+
validate_destination(self._base_url, request.url)
|
|
197
|
+
headers = dict(request.headers)
|
|
198
|
+
headers.update(self._headers(description, auth))
|
|
199
|
+
return RequestSpec(
|
|
200
|
+
request.method, request.url, request.query, headers, request.content
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
def _token_action(self, description: Description, name: str) -> ActionSpec:
|
|
204
|
+
provider = description.authentication.get("token")
|
|
205
|
+
raw = provider.get("resources") if provider is not None else None
|
|
206
|
+
if not isinstance(raw, Mapping):
|
|
207
|
+
raise AuthenticationError("Token authentication is not advertised")
|
|
208
|
+
resources = parse_resources(raw)
|
|
209
|
+
token = resources.get("token")
|
|
210
|
+
if token is None or name not in token.actions:
|
|
211
|
+
raise AuthenticationError("Token authentication action is not advertised")
|
|
212
|
+
return token.actions[name]
|
|
213
|
+
|
|
214
|
+
def _login_result(self, data: object) -> tuple[bool, str, str | None, str | None]:
|
|
215
|
+
if not isinstance(data, Mapping) or not isinstance(data.get("complete"), bool):
|
|
216
|
+
raise ProtocolError("Invalid token authentication result")
|
|
217
|
+
token = data.get("token")
|
|
218
|
+
valid_to = data.get("valid_to")
|
|
219
|
+
complete = data["complete"]
|
|
220
|
+
next_action = data.get("next_action")
|
|
221
|
+
if (
|
|
222
|
+
not isinstance(token, str)
|
|
223
|
+
or not token
|
|
224
|
+
or (valid_to is not None and not isinstance(valid_to, str))
|
|
225
|
+
or (not complete and (not isinstance(next_action, str) or not next_action))
|
|
226
|
+
):
|
|
227
|
+
raise ProtocolError("Invalid token authentication result")
|
|
228
|
+
return (
|
|
229
|
+
complete,
|
|
230
|
+
token,
|
|
231
|
+
valid_to,
|
|
232
|
+
next_action if isinstance(next_action, str) else None,
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
def _prepare_challenge(
|
|
236
|
+
self,
|
|
237
|
+
description: Description,
|
|
238
|
+
next_action: str,
|
|
239
|
+
) -> tuple[Mapping[str, ParameterSpec], object]:
|
|
240
|
+
"""Validate a next step before installing its nonce under _mutation."""
|
|
241
|
+
action = self._token_action(description, next_action)
|
|
242
|
+
if action.input is None or "token" not in action.input.parameters:
|
|
243
|
+
raise ProtocolError(
|
|
244
|
+
"Authentication continuation must describe a token input"
|
|
245
|
+
)
|
|
246
|
+
fields = {
|
|
247
|
+
key: value
|
|
248
|
+
for key, value in action.input.parameters.items()
|
|
249
|
+
if key != "token"
|
|
250
|
+
}
|
|
251
|
+
nonce = object()
|
|
252
|
+
self._login = nonce
|
|
253
|
+
return fields, nonce
|
|
254
|
+
|
|
255
|
+
def _continuation_params(
|
|
256
|
+
self,
|
|
257
|
+
nonce: object,
|
|
258
|
+
token: str,
|
|
259
|
+
values: Mapping[str, object],
|
|
260
|
+
) -> Mapping[str, object]:
|
|
261
|
+
"""Check the current challenge and copy input without consuming retries."""
|
|
262
|
+
if self._login is not nonce:
|
|
263
|
+
raise AuthenticationError("Authentication challenge is no longer current")
|
|
264
|
+
if "token" in values:
|
|
265
|
+
raise AuthenticationError("Continuation token cannot be overridden")
|
|
266
|
+
return {**values, "token": token}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""HTTPX adapters. Requests never inherit injected-client auth or cookies."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from urllib.parse import unquote, urlencode, urlsplit
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from haveapi_client._protocol import (
|
|
11
|
+
RequestOptions,
|
|
12
|
+
RequestSpec,
|
|
13
|
+
WireResponse,
|
|
14
|
+
resolve_url,
|
|
15
|
+
)
|
|
16
|
+
from haveapi_client.exceptions import ProtocolError, TransportError
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def finite_timeout(value: float) -> float:
|
|
20
|
+
if isinstance(value, bool) or not math.isfinite(value) or value <= 0:
|
|
21
|
+
raise ValueError("timeout must be positive and finite")
|
|
22
|
+
return value
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def normalize_base_url(value: str) -> str:
|
|
26
|
+
if any(ord(c) < 32 or ord(c) == 127 for c in value) or "\\" in value:
|
|
27
|
+
raise ValueError("base_url contains unsafe characters")
|
|
28
|
+
url = urlsplit(value)
|
|
29
|
+
if (
|
|
30
|
+
url.scheme not in {"https", "http"}
|
|
31
|
+
or not url.hostname
|
|
32
|
+
or url.username is not None
|
|
33
|
+
or url.password is not None
|
|
34
|
+
or url.query
|
|
35
|
+
or url.fragment
|
|
36
|
+
):
|
|
37
|
+
raise ValueError(
|
|
38
|
+
"base_url must be an HTTP(S) URL without credentials, query or fragment"
|
|
39
|
+
)
|
|
40
|
+
try:
|
|
41
|
+
if url.port is not None and not 0 < url.port <= 65535:
|
|
42
|
+
raise ValueError("Invalid port")
|
|
43
|
+
except ValueError as exc:
|
|
44
|
+
raise ValueError("base_url has an invalid port") from exc
|
|
45
|
+
if any(segment in {".", ".."} for segment in unquote(url.path).split("/")):
|
|
46
|
+
raise ValueError("base_url must not contain path traversal")
|
|
47
|
+
try:
|
|
48
|
+
resolve_url(value, "")
|
|
49
|
+
except ProtocolError:
|
|
50
|
+
raise ValueError("base_url contains an unsafe path") from None
|
|
51
|
+
return value.rstrip("/") + "/"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def validate_destination(base_url: str, destination: str) -> None:
|
|
55
|
+
base = urlsplit(base_url)
|
|
56
|
+
url = urlsplit(destination)
|
|
57
|
+
try:
|
|
58
|
+
same_origin = (
|
|
59
|
+
url.scheme.lower(),
|
|
60
|
+
url.hostname,
|
|
61
|
+
url.port or (443 if url.scheme == "https" else 80),
|
|
62
|
+
) == (
|
|
63
|
+
base.scheme.lower(),
|
|
64
|
+
base.hostname,
|
|
65
|
+
base.port or (443 if base.scheme == "https" else 80),
|
|
66
|
+
)
|
|
67
|
+
except ValueError as exc:
|
|
68
|
+
raise ProtocolError("Invalid destination URL") from exc
|
|
69
|
+
if (
|
|
70
|
+
not same_origin
|
|
71
|
+
or url.username is not None
|
|
72
|
+
or url.password is not None
|
|
73
|
+
or url.fragment
|
|
74
|
+
or not url.path.startswith(base.path)
|
|
75
|
+
):
|
|
76
|
+
raise ProtocolError("Request destination is outside the configured API")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _request(
|
|
80
|
+
spec: RequestSpec, timeout: float, options: RequestOptions | None
|
|
81
|
+
) -> httpx.Request:
|
|
82
|
+
effective = (
|
|
83
|
+
timeout if options is None or options.timeout is None else options.timeout
|
|
84
|
+
)
|
|
85
|
+
finite_timeout(effective)
|
|
86
|
+
# HTTPX params groups duplicate names, which changes Rack's row boundaries
|
|
87
|
+
# for arrays of objects. Preserve the protocol core's complete pair order.
|
|
88
|
+
query = urlencode(spec.query).encode("ascii")
|
|
89
|
+
url = httpx.URL(spec.url).copy_with(query=query or None)
|
|
90
|
+
return httpx.Request(
|
|
91
|
+
spec.method,
|
|
92
|
+
url,
|
|
93
|
+
headers=spec.headers,
|
|
94
|
+
content=spec.content,
|
|
95
|
+
extensions={"timeout": httpx.Timeout(effective).as_dict()},
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class SyncTransport:
|
|
100
|
+
def __init__(
|
|
101
|
+
self, *, timeout: float, http_client: httpx.Client | None = None
|
|
102
|
+
) -> None:
|
|
103
|
+
self.timeout = finite_timeout(timeout)
|
|
104
|
+
self.owned = http_client is None
|
|
105
|
+
self.client = (
|
|
106
|
+
http_client if http_client is not None else httpx.Client(timeout=timeout)
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
def send(
|
|
110
|
+
self, spec: RequestSpec, *, options: RequestOptions | None = None
|
|
111
|
+
) -> WireResponse:
|
|
112
|
+
try:
|
|
113
|
+
response = self.client.send(
|
|
114
|
+
_request(spec, self.timeout, options), auth=None, follow_redirects=False
|
|
115
|
+
)
|
|
116
|
+
except httpx.HTTPError:
|
|
117
|
+
# HTTPX exception messages can include request URLs and credentials.
|
|
118
|
+
raise TransportError("HTTP request failed") from None
|
|
119
|
+
return WireResponse(
|
|
120
|
+
response.status_code, dict(response.headers), response.content
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
def close(self) -> None:
|
|
124
|
+
if self.owned:
|
|
125
|
+
self.client.close()
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class AsyncTransport:
|
|
129
|
+
def __init__(
|
|
130
|
+
self, *, timeout: float, http_client: httpx.AsyncClient | None = None
|
|
131
|
+
) -> None:
|
|
132
|
+
self.timeout = finite_timeout(timeout)
|
|
133
|
+
self.owned = http_client is None
|
|
134
|
+
self.client = (
|
|
135
|
+
http_client
|
|
136
|
+
if http_client is not None
|
|
137
|
+
else httpx.AsyncClient(timeout=timeout)
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
async def send(
|
|
141
|
+
self, spec: RequestSpec, *, options: RequestOptions | None = None
|
|
142
|
+
) -> WireResponse:
|
|
143
|
+
try:
|
|
144
|
+
response = await self.client.send(
|
|
145
|
+
_request(spec, self.timeout, options), auth=None, follow_redirects=False
|
|
146
|
+
)
|
|
147
|
+
except httpx.HTTPError:
|
|
148
|
+
raise TransportError("HTTP request failed") from None
|
|
149
|
+
return WireResponse(
|
|
150
|
+
response.status_code, dict(response.headers), response.content
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
async def aclose(self) -> None:
|
|
154
|
+
if self.owned:
|
|
155
|
+
await self.client.aclose()
|