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,166 @@
|
|
|
1
|
+
"""Immutable protocol descriptions and transport-neutral request state."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping, Sequence
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from types import MappingProxyType
|
|
8
|
+
from typing import TypeVar, cast
|
|
9
|
+
|
|
10
|
+
_T = TypeVar("_T")
|
|
11
|
+
InputObject = Mapping[str, object]
|
|
12
|
+
InputParams = InputObject | Sequence[InputObject]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def freeze(value: object) -> object:
|
|
16
|
+
"""Copy JSON-shaped values so descriptions cannot change under active calls."""
|
|
17
|
+
if isinstance(value, Mapping):
|
|
18
|
+
return MappingProxyType({k: freeze(v) for k, v in value.items()})
|
|
19
|
+
if isinstance(value, (list, tuple)):
|
|
20
|
+
return tuple(freeze(v) for v in value)
|
|
21
|
+
return value
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def frozen_mapping(value: Mapping[str, _T]) -> Mapping[str, _T]:
|
|
25
|
+
return cast(Mapping[str, _T], freeze(value))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class ParameterSpec:
|
|
30
|
+
name: str
|
|
31
|
+
type: str
|
|
32
|
+
required: bool
|
|
33
|
+
nullable: bool
|
|
34
|
+
raw: Mapping[str, object] = field(repr=False)
|
|
35
|
+
|
|
36
|
+
def __post_init__(self) -> None:
|
|
37
|
+
object.__setattr__(self, "raw", frozen_mapping(self.raw))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class LayoutSpec:
|
|
42
|
+
layout: str
|
|
43
|
+
namespace: str | None
|
|
44
|
+
parameters: Mapping[str, ParameterSpec]
|
|
45
|
+
raw: Mapping[str, object] = field(repr=False)
|
|
46
|
+
|
|
47
|
+
def __post_init__(self) -> None:
|
|
48
|
+
object.__setattr__(self, "parameters", frozen_mapping(self.parameters))
|
|
49
|
+
object.__setattr__(self, "raw", frozen_mapping(self.raw))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class ActionSpec:
|
|
54
|
+
name: str
|
|
55
|
+
resource_path: tuple[str, ...]
|
|
56
|
+
method: str
|
|
57
|
+
path: str
|
|
58
|
+
path_parameters: tuple[str, ...]
|
|
59
|
+
aliases: tuple[str, ...]
|
|
60
|
+
input: LayoutSpec | None
|
|
61
|
+
output: LayoutSpec | None
|
|
62
|
+
meta: Mapping[str, object] = field(repr=False)
|
|
63
|
+
blocking: bool
|
|
64
|
+
auth: bool
|
|
65
|
+
raw: Mapping[str, object] = field(repr=False)
|
|
66
|
+
|
|
67
|
+
def __post_init__(self) -> None:
|
|
68
|
+
object.__setattr__(self, "resource_path", tuple(self.resource_path))
|
|
69
|
+
object.__setattr__(self, "path_parameters", tuple(self.path_parameters))
|
|
70
|
+
object.__setattr__(self, "aliases", tuple(self.aliases))
|
|
71
|
+
object.__setattr__(self, "meta", frozen_mapping(self.meta))
|
|
72
|
+
object.__setattr__(self, "raw", frozen_mapping(self.raw))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass(frozen=True)
|
|
76
|
+
class ResourceSpec:
|
|
77
|
+
name: str
|
|
78
|
+
resource_path: tuple[str, ...]
|
|
79
|
+
resources: Mapping[str, ResourceSpec]
|
|
80
|
+
actions: Mapping[str, ActionSpec]
|
|
81
|
+
raw: Mapping[str, object] = field(repr=False)
|
|
82
|
+
|
|
83
|
+
def __post_init__(self) -> None:
|
|
84
|
+
object.__setattr__(self, "resource_path", tuple(self.resource_path))
|
|
85
|
+
object.__setattr__(self, "resources", frozen_mapping(self.resources))
|
|
86
|
+
object.__setattr__(self, "actions", frozen_mapping(self.actions))
|
|
87
|
+
object.__setattr__(self, "raw", frozen_mapping(self.raw))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass(frozen=True)
|
|
91
|
+
class Description:
|
|
92
|
+
protocol_version: str
|
|
93
|
+
api_version: str | None
|
|
94
|
+
resources: Mapping[str, ResourceSpec]
|
|
95
|
+
authentication: Mapping[str, Mapping[str, object]] = field(repr=False)
|
|
96
|
+
meta_namespace: str
|
|
97
|
+
raw: Mapping[str, object] = field(repr=False)
|
|
98
|
+
|
|
99
|
+
def __post_init__(self) -> None:
|
|
100
|
+
object.__setattr__(self, "resources", frozen_mapping(self.resources))
|
|
101
|
+
object.__setattr__(self, "authentication", frozen_mapping(self.authentication))
|
|
102
|
+
object.__setattr__(self, "raw", frozen_mapping(self.raw))
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass(frozen=True)
|
|
106
|
+
class RequestSpec:
|
|
107
|
+
method: str
|
|
108
|
+
url: str = field(repr=False)
|
|
109
|
+
query: tuple[tuple[str, str], ...] = field(repr=False)
|
|
110
|
+
headers: Mapping[str, str] = field(repr=False)
|
|
111
|
+
content: bytes | None = field(repr=False)
|
|
112
|
+
|
|
113
|
+
def __post_init__(self) -> None:
|
|
114
|
+
object.__setattr__(self, "query", tuple(tuple(v) for v in self.query))
|
|
115
|
+
object.__setattr__(self, "headers", frozen_mapping(self.headers))
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass(frozen=True)
|
|
119
|
+
class WireResponse:
|
|
120
|
+
status_code: int
|
|
121
|
+
headers: Mapping[str, str] = field(repr=False)
|
|
122
|
+
content: bytes = field(repr=False)
|
|
123
|
+
|
|
124
|
+
def __post_init__(self) -> None:
|
|
125
|
+
object.__setattr__(self, "headers", frozen_mapping(self.headers))
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@dataclass(frozen=True)
|
|
129
|
+
class DecodedResponse:
|
|
130
|
+
data: object = field(repr=False)
|
|
131
|
+
meta: Mapping[str, object] = field(repr=False)
|
|
132
|
+
envelope: Mapping[str, object] = field(repr=False)
|
|
133
|
+
status_code: int
|
|
134
|
+
headers: Mapping[str, str] = field(repr=False)
|
|
135
|
+
|
|
136
|
+
def __post_init__(self) -> None:
|
|
137
|
+
object.__setattr__(self, "meta", frozen_mapping(self.meta))
|
|
138
|
+
object.__setattr__(self, "envelope", frozen_mapping(self.envelope))
|
|
139
|
+
object.__setattr__(self, "headers", frozen_mapping(self.headers))
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@dataclass(frozen=True)
|
|
143
|
+
class CallContext:
|
|
144
|
+
action: ActionSpec
|
|
145
|
+
bound_args: tuple[str | int, ...] = field(repr=False)
|
|
146
|
+
path_params: Mapping[str, str | int] = field(repr=False)
|
|
147
|
+
generation: int
|
|
148
|
+
|
|
149
|
+
def __post_init__(self) -> None:
|
|
150
|
+
object.__setattr__(self, "bound_args", tuple(self.bound_args))
|
|
151
|
+
object.__setattr__(self, "path_params", frozen_mapping(self.path_params))
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@dataclass(frozen=True)
|
|
155
|
+
class RequestOptions:
|
|
156
|
+
timeout: float | None = None
|
|
157
|
+
|
|
158
|
+
def __post_init__(self) -> None:
|
|
159
|
+
import math
|
|
160
|
+
|
|
161
|
+
if self.timeout is not None and (
|
|
162
|
+
isinstance(self.timeout, bool)
|
|
163
|
+
or not math.isfinite(self.timeout)
|
|
164
|
+
or self.timeout <= 0
|
|
165
|
+
):
|
|
166
|
+
raise ValueError("timeout must be positive and finite")
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
"""HaveAPI URL binding and namespaced JSON/bracket-query encoding."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from typing import Literal
|
|
8
|
+
from urllib.parse import quote, unquote, urlsplit, urlunsplit
|
|
9
|
+
|
|
10
|
+
from ..exceptions import ProtocolError, ValidationError
|
|
11
|
+
from .description import PATH_PARAMETER, mapping, parse_layout
|
|
12
|
+
from .input import coerce_input
|
|
13
|
+
from .models import CallContext, Description, InputParams, LayoutSpec, RequestSpec
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def resolve_url(
|
|
17
|
+
base_url: str, path: str, *, path_mode: Literal["api", "descriptor"] = "api"
|
|
18
|
+
) -> str:
|
|
19
|
+
"""Resolve discovery under the API base or a described route at its origin."""
|
|
20
|
+
try:
|
|
21
|
+
base = urlsplit(base_url)
|
|
22
|
+
target = urlsplit(path)
|
|
23
|
+
port = base.port
|
|
24
|
+
except ValueError as error:
|
|
25
|
+
raise ProtocolError("invalid endpoint URL") from error
|
|
26
|
+
if (
|
|
27
|
+
base.scheme not in ("http", "https")
|
|
28
|
+
or not base.hostname
|
|
29
|
+
or base.username is not None
|
|
30
|
+
or base.password is not None
|
|
31
|
+
or base.query
|
|
32
|
+
or base.fragment
|
|
33
|
+
or port is not None
|
|
34
|
+
and not 0 < port <= 65535
|
|
35
|
+
):
|
|
36
|
+
raise ProtocolError(
|
|
37
|
+
"base URL must be an HTTP(S) origin and optional mount path"
|
|
38
|
+
)
|
|
39
|
+
if (
|
|
40
|
+
target.scheme
|
|
41
|
+
or target.netloc
|
|
42
|
+
or path.startswith("//")
|
|
43
|
+
or target.query
|
|
44
|
+
or target.fragment
|
|
45
|
+
or any(ord(c) < 32 or ord(c) == 127 for c in path)
|
|
46
|
+
or "\\" in path
|
|
47
|
+
or path_mode == "descriptor"
|
|
48
|
+
and not path.startswith("/")
|
|
49
|
+
):
|
|
50
|
+
raise ProtocolError(
|
|
51
|
+
"action endpoint must be an origin-root-relative path"
|
|
52
|
+
if path_mode == "descriptor"
|
|
53
|
+
else "action endpoint must be an API-relative path"
|
|
54
|
+
)
|
|
55
|
+
for candidate in (base.path, target.path):
|
|
56
|
+
# Multiple decoding rounds also reject nested encoded traversal.
|
|
57
|
+
checked = candidate
|
|
58
|
+
for _ in range(8):
|
|
59
|
+
if any(segment in (".", "..") for segment in checked.split("/")):
|
|
60
|
+
raise ProtocolError("path traversal is not allowed")
|
|
61
|
+
decoded = unquote(checked)
|
|
62
|
+
if decoded == checked:
|
|
63
|
+
break
|
|
64
|
+
checked = decoded
|
|
65
|
+
if "\\" in checked or checked.startswith("//"):
|
|
66
|
+
raise ProtocolError("unsafe endpoint path")
|
|
67
|
+
resolved = (
|
|
68
|
+
target.path
|
|
69
|
+
if path_mode == "descriptor"
|
|
70
|
+
else base.path.rstrip("/") + "/" + target.path.lstrip("/")
|
|
71
|
+
)
|
|
72
|
+
return urlunsplit((base.scheme, base.netloc, resolved, "", ""))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _path_value(value: object) -> str:
|
|
76
|
+
if isinstance(value, bool) or not isinstance(value, (str, int)):
|
|
77
|
+
raise ValidationError("path values must be strings or integers")
|
|
78
|
+
if value == "":
|
|
79
|
+
raise ValidationError("path values cannot be empty")
|
|
80
|
+
# A dot segment remains unsafe even when quoted: common HTTP stacks normalize it.
|
|
81
|
+
if value in (".", ".."):
|
|
82
|
+
raise ValidationError("path values cannot be traversal segments")
|
|
83
|
+
return quote(str(value), safe="", encoding="utf-8", errors="strict")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _bind(context: CallContext) -> str:
|
|
87
|
+
names = context.action.path_parameters
|
|
88
|
+
if len(context.bound_args) > len(names):
|
|
89
|
+
raise ValidationError("too many positional path bindings")
|
|
90
|
+
bindings = dict(zip(names, context.bound_args, strict=False))
|
|
91
|
+
for name, value in context.path_params.items():
|
|
92
|
+
if name not in names:
|
|
93
|
+
raise ValidationError(f"unknown path parameter: {name}")
|
|
94
|
+
if name in bindings and (
|
|
95
|
+
type(bindings[name]) is not type(value) or bindings[name] != value
|
|
96
|
+
):
|
|
97
|
+
raise ValidationError(f"conflicting path parameter: {name}")
|
|
98
|
+
bindings[name] = value
|
|
99
|
+
missing = [name for name in names if name not in bindings]
|
|
100
|
+
if missing:
|
|
101
|
+
raise ValidationError(
|
|
102
|
+
"missing path parameters", errors={k: ["missing"] for k in missing}
|
|
103
|
+
)
|
|
104
|
+
return PATH_PARAMETER.sub(
|
|
105
|
+
lambda m: _path_value(bindings[m[1]]), context.action.path
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _query_error(path: str, message: str) -> ValidationError:
|
|
110
|
+
return ValidationError(
|
|
111
|
+
"input cannot be represented by a GET query", errors={path: [message]}
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _query_component(value: str, path: str) -> None:
|
|
116
|
+
if not value or "[" in value or "]" in value:
|
|
117
|
+
raise _query_error(
|
|
118
|
+
path, "query key components must be nonempty and bracket-free"
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _query_value(
|
|
123
|
+
value: object, key: str, *, empty_wrappers: frozenset[str] = frozenset()
|
|
124
|
+
) -> list[tuple[str, str]]:
|
|
125
|
+
if isinstance(value, Mapping):
|
|
126
|
+
if not value and key not in empty_wrappers:
|
|
127
|
+
raise _query_error(key, "empty objects cannot be represented")
|
|
128
|
+
pairs = []
|
|
129
|
+
for name, item in value.items():
|
|
130
|
+
path = f"{key}[{name}]"
|
|
131
|
+
_query_component(name, path)
|
|
132
|
+
pairs.extend(_query_value(item, path, empty_wrappers=empty_wrappers))
|
|
133
|
+
return pairs
|
|
134
|
+
if isinstance(value, (list, tuple)):
|
|
135
|
+
if not value:
|
|
136
|
+
raise _query_error(key, "empty arrays cannot be represented")
|
|
137
|
+
if all(isinstance(item, str) for item in value):
|
|
138
|
+
return [(f"{key}[]", item) for item in value]
|
|
139
|
+
if not isinstance(value[0], Mapping) or not value[0]:
|
|
140
|
+
raise _query_error(
|
|
141
|
+
f"{key}[0]", "expected strings or dense flat object rows"
|
|
142
|
+
)
|
|
143
|
+
names = sorted(value[0])
|
|
144
|
+
pairs = []
|
|
145
|
+
for index, row in enumerate(value):
|
|
146
|
+
path = f"{key}[{index}]"
|
|
147
|
+
if not isinstance(row, Mapping) or set(row) != set(names):
|
|
148
|
+
raise _query_error(path, "object rows must have the same nonempty keys")
|
|
149
|
+
for name in names:
|
|
150
|
+
_query_component(name, f"{path}[{name}]")
|
|
151
|
+
item = row[name]
|
|
152
|
+
if isinstance(item, (Mapping, list, tuple)):
|
|
153
|
+
raise _query_error(
|
|
154
|
+
f"{path}[{name}]", "object row fields must be scalar"
|
|
155
|
+
)
|
|
156
|
+
# Repeating every field in one shared order delimits dense Rack
|
|
157
|
+
# rows. Sparse rows or nested values cannot supply that boundary.
|
|
158
|
+
pairs.extend(_query_value(item, f"{key}[][{name}]"))
|
|
159
|
+
return pairs
|
|
160
|
+
if value is None:
|
|
161
|
+
return [(key, "")]
|
|
162
|
+
if isinstance(value, bool):
|
|
163
|
+
return [(key, "true" if value else "false")]
|
|
164
|
+
return [(key, str(value))]
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _query_custom(value: object, path: str, *, nullable: bool = False) -> None:
|
|
168
|
+
if nullable and value is None:
|
|
169
|
+
return
|
|
170
|
+
if isinstance(value, Mapping):
|
|
171
|
+
for name, item in value.items():
|
|
172
|
+
_query_custom(item, f"{path}[{name}]")
|
|
173
|
+
elif isinstance(value, (list, tuple)):
|
|
174
|
+
for index, item in enumerate(value):
|
|
175
|
+
_query_custom(item, f"{path}[{index}]")
|
|
176
|
+
elif not isinstance(value, str):
|
|
177
|
+
raise _query_error(path, "Custom query values must have string leaves")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _query_custom_input(
|
|
181
|
+
layout: LayoutSpec | None,
|
|
182
|
+
payload: InputParams,
|
|
183
|
+
path: str,
|
|
184
|
+
*,
|
|
185
|
+
object_meta: LayoutSpec | None = None,
|
|
186
|
+
meta_namespace: str = "_meta",
|
|
187
|
+
) -> None:
|
|
188
|
+
if layout is None:
|
|
189
|
+
return
|
|
190
|
+
rows = (
|
|
191
|
+
[(path, payload)]
|
|
192
|
+
if isinstance(payload, Mapping)
|
|
193
|
+
else [(f"{path}[{index}]", row) for index, row in enumerate(payload)]
|
|
194
|
+
)
|
|
195
|
+
for row_path, row in rows:
|
|
196
|
+
if not isinstance(row, Mapping):
|
|
197
|
+
continue # Layout coercion owns malformed row diagnostics.
|
|
198
|
+
for name, spec in layout.parameters.items():
|
|
199
|
+
if spec.type == "Custom" and name in row:
|
|
200
|
+
_query_custom(row[name], f"{row_path}[{name}]", nullable=spec.nullable)
|
|
201
|
+
if object_meta and meta_namespace in row:
|
|
202
|
+
_query_custom_input(
|
|
203
|
+
object_meta,
|
|
204
|
+
mapping(row[meta_namespace], "object metadata"),
|
|
205
|
+
f"{row_path}[{meta_namespace}]",
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _coerce_with_object_meta(
|
|
210
|
+
description: Description, context: CallContext, params: InputParams | None
|
|
211
|
+
) -> InputParams:
|
|
212
|
+
layout = context.action.input
|
|
213
|
+
scope = context.action.meta.get("object")
|
|
214
|
+
if layout is None or not isinstance(scope, Mapping) or scope.get("input") is None:
|
|
215
|
+
return coerce_input(layout, params)
|
|
216
|
+
object_layout = parse_layout(scope["input"])
|
|
217
|
+
namespace = description.meta_namespace
|
|
218
|
+
|
|
219
|
+
def row(value: Mapping[str, object]) -> Mapping[str, object]:
|
|
220
|
+
data = {k: v for k, v in value.items() if k != namespace}
|
|
221
|
+
coerced = dict(mapping(coerce_input(layout_for_row, data), "input object"))
|
|
222
|
+
if namespace in value:
|
|
223
|
+
meta_value = mapping(value[namespace], "object metadata input")
|
|
224
|
+
coerced[namespace] = coerce_input(object_layout, meta_value)
|
|
225
|
+
elif object_layout:
|
|
226
|
+
coerce_input(object_layout, None)
|
|
227
|
+
return coerced
|
|
228
|
+
|
|
229
|
+
layout_for_row = LayoutSpec("hash", None, layout.parameters, layout.raw)
|
|
230
|
+
if layout.layout in ("object_list", "hash_list"):
|
|
231
|
+
if params is None:
|
|
232
|
+
return []
|
|
233
|
+
if not isinstance(params, (list, tuple)):
|
|
234
|
+
raise ValidationError("input layout requires a list of objects")
|
|
235
|
+
return [row(mapping(v, "input row")) for v in params]
|
|
236
|
+
return row(mapping(params or {}, "input"))
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def build_request(
|
|
240
|
+
description: Description,
|
|
241
|
+
context: CallContext,
|
|
242
|
+
*,
|
|
243
|
+
base_url: str,
|
|
244
|
+
params: InputParams | None = None,
|
|
245
|
+
meta: Mapping[str, object] | None = None,
|
|
246
|
+
) -> RequestSpec:
|
|
247
|
+
# Validate the static endpoint before substituting user data. Encoded slashes
|
|
248
|
+
# within a bound ID are data, and must not be interpreted as path separators.
|
|
249
|
+
resolve_url(base_url, context.action.path, path_mode="descriptor")
|
|
250
|
+
path = _bind(context)
|
|
251
|
+
base = urlsplit(base_url)
|
|
252
|
+
url = urlunsplit(
|
|
253
|
+
(
|
|
254
|
+
base.scheme,
|
|
255
|
+
base.netloc,
|
|
256
|
+
path,
|
|
257
|
+
"",
|
|
258
|
+
"",
|
|
259
|
+
)
|
|
260
|
+
)
|
|
261
|
+
payload = _coerce_with_object_meta(description, context, params)
|
|
262
|
+
body: object = {}
|
|
263
|
+
layout = context.action.input
|
|
264
|
+
if layout:
|
|
265
|
+
body = {layout.namespace: payload} if layout.namespace else payload
|
|
266
|
+
if meta is not None:
|
|
267
|
+
scope = context.action.meta.get("global")
|
|
268
|
+
meta_layout = (
|
|
269
|
+
parse_layout(scope.get("input")) if isinstance(scope, Mapping) else None
|
|
270
|
+
)
|
|
271
|
+
coerced_meta = coerce_input(meta_layout, meta)
|
|
272
|
+
if not isinstance(body, Mapping):
|
|
273
|
+
raise ValidationError(
|
|
274
|
+
"list input requires a namespace to carry global metadata"
|
|
275
|
+
)
|
|
276
|
+
if description.meta_namespace in body:
|
|
277
|
+
raise ProtocolError("metadata namespace collides with input")
|
|
278
|
+
body = {**body, description.meta_namespace: coerced_meta}
|
|
279
|
+
else:
|
|
280
|
+
scope = context.action.meta.get("global")
|
|
281
|
+
if isinstance(scope, Mapping) and scope.get("input") is not None:
|
|
282
|
+
coerce_input(parse_layout(scope["input"]), None)
|
|
283
|
+
headers = {"Accept": "application/json"}
|
|
284
|
+
method = context.action.method
|
|
285
|
+
if method in ("POST", "PUT", "DELETE"):
|
|
286
|
+
if not isinstance(body, Mapping):
|
|
287
|
+
raise ValidationError(
|
|
288
|
+
"JSON action input requires an object root and a namespace for lists"
|
|
289
|
+
)
|
|
290
|
+
headers["Content-Type"] = "application/json"
|
|
291
|
+
try:
|
|
292
|
+
content = json.dumps(
|
|
293
|
+
body, ensure_ascii=False, allow_nan=False, separators=(",", ":")
|
|
294
|
+
).encode("utf-8")
|
|
295
|
+
except (TypeError, ValueError, UnicodeError) as error:
|
|
296
|
+
raise ValidationError("input is not valid UTF-8 JSON") from error
|
|
297
|
+
return RequestSpec(method, url, (), headers, content)
|
|
298
|
+
if method == "GET":
|
|
299
|
+
if not isinstance(body, Mapping):
|
|
300
|
+
raise ValidationError("GET list input requires a namespace")
|
|
301
|
+
scope = context.action.meta.get("object")
|
|
302
|
+
object_meta = (
|
|
303
|
+
parse_layout(scope.get("input")) if isinstance(scope, Mapping) else None
|
|
304
|
+
)
|
|
305
|
+
_query_custom_input(
|
|
306
|
+
layout,
|
|
307
|
+
payload,
|
|
308
|
+
layout.namespace if layout and layout.namespace else "input",
|
|
309
|
+
object_meta=object_meta,
|
|
310
|
+
meta_namespace=description.meta_namespace,
|
|
311
|
+
)
|
|
312
|
+
scope = context.action.meta.get("global")
|
|
313
|
+
if meta is not None and isinstance(scope, Mapping):
|
|
314
|
+
_query_custom_input(
|
|
315
|
+
parse_layout(scope.get("input")),
|
|
316
|
+
mapping(body[description.meta_namespace], "global metadata"),
|
|
317
|
+
description.meta_namespace,
|
|
318
|
+
)
|
|
319
|
+
pairs = []
|
|
320
|
+
wrappers = set()
|
|
321
|
+
if layout and layout.namespace:
|
|
322
|
+
wrappers.add(layout.namespace)
|
|
323
|
+
if meta is not None:
|
|
324
|
+
wrappers.add(description.meta_namespace)
|
|
325
|
+
if object_meta and isinstance(payload, Mapping):
|
|
326
|
+
wrappers.add(
|
|
327
|
+
f"{layout.namespace}[{description.meta_namespace}]"
|
|
328
|
+
if layout and layout.namespace
|
|
329
|
+
else description.meta_namespace
|
|
330
|
+
)
|
|
331
|
+
for name, value in body.items():
|
|
332
|
+
_query_component(name, name)
|
|
333
|
+
input_wrapper = layout is not None and name == layout.namespace
|
|
334
|
+
if input_wrapper and params is None and value == []:
|
|
335
|
+
continue # Missing list input is omitted; explicit [] must fail.
|
|
336
|
+
pairs.extend(
|
|
337
|
+
_query_value(
|
|
338
|
+
value,
|
|
339
|
+
name,
|
|
340
|
+
empty_wrappers=frozenset(wrappers),
|
|
341
|
+
)
|
|
342
|
+
)
|
|
343
|
+
query = tuple(pairs)
|
|
344
|
+
return RequestSpec(method, url, query, headers, None)
|
|
345
|
+
raise ProtocolError(f"unsupported action method: {method}")
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Validate response envelopes independently of HTTP transport behavior."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from typing import cast
|
|
8
|
+
|
|
9
|
+
from ..exceptions import ActionError, AuthenticationError, ProtocolError
|
|
10
|
+
from .description import mapping
|
|
11
|
+
from .models import ActionSpec, DecodedResponse, Description, WireResponse
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _invalid_constant(value: str) -> None:
|
|
15
|
+
raise ValueError(f"nonfinite JSON constant: {value}")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def decode_envelope(response: WireResponse) -> Mapping[str, object]:
|
|
19
|
+
try:
|
|
20
|
+
body = json.loads(response.content, parse_constant=_invalid_constant)
|
|
21
|
+
except (ValueError, UnicodeError) as error:
|
|
22
|
+
raise ProtocolError(
|
|
23
|
+
"response is not valid JSON", status_code=response.status_code
|
|
24
|
+
) from error
|
|
25
|
+
if not isinstance(body, dict) or not isinstance(body.get("status"), bool):
|
|
26
|
+
raise ProtocolError(
|
|
27
|
+
"invalid response envelope", status_code=response.status_code
|
|
28
|
+
)
|
|
29
|
+
envelope = cast(Mapping[str, object], body)
|
|
30
|
+
message = body.get("message")
|
|
31
|
+
errors = body.get("errors")
|
|
32
|
+
if (
|
|
33
|
+
message is not None
|
|
34
|
+
and not isinstance(message, str)
|
|
35
|
+
or errors is not None
|
|
36
|
+
and not isinstance(errors, dict)
|
|
37
|
+
):
|
|
38
|
+
raise ProtocolError(
|
|
39
|
+
"invalid error context in response envelope",
|
|
40
|
+
status_code=response.status_code,
|
|
41
|
+
)
|
|
42
|
+
if body["status"] is True and "response" not in body:
|
|
43
|
+
raise ProtocolError(
|
|
44
|
+
"response envelope has no response member", status_code=response.status_code
|
|
45
|
+
)
|
|
46
|
+
if body["status"] is False or not 200 <= response.status_code < 300:
|
|
47
|
+
cls = AuthenticationError if response.status_code == 401 else ActionError
|
|
48
|
+
raise cls(
|
|
49
|
+
message or f"action failed (HTTP {response.status_code})",
|
|
50
|
+
status_code=response.status_code,
|
|
51
|
+
errors=errors,
|
|
52
|
+
envelope=envelope,
|
|
53
|
+
)
|
|
54
|
+
return envelope
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def decode_response(
|
|
58
|
+
description: Description, action: ActionSpec, response: WireResponse
|
|
59
|
+
) -> DecodedResponse:
|
|
60
|
+
envelope = decode_envelope(response)
|
|
61
|
+
payload = envelope["response"]
|
|
62
|
+
namespace = description.meta_namespace
|
|
63
|
+
meta: Mapping[str, object] = {}
|
|
64
|
+
if isinstance(payload, Mapping) and namespace in payload:
|
|
65
|
+
try:
|
|
66
|
+
meta = mapping(payload[namespace], "global response metadata")
|
|
67
|
+
except ProtocolError as error:
|
|
68
|
+
raise ProtocolError(
|
|
69
|
+
error.message, status_code=response.status_code
|
|
70
|
+
) from error
|
|
71
|
+
output = action.output
|
|
72
|
+
data = None
|
|
73
|
+
if output:
|
|
74
|
+
if output.namespace:
|
|
75
|
+
if not isinstance(payload, Mapping) or output.namespace not in payload:
|
|
76
|
+
raise ProtocolError(
|
|
77
|
+
"missing response output namespace",
|
|
78
|
+
status_code=response.status_code,
|
|
79
|
+
)
|
|
80
|
+
data = payload[output.namespace]
|
|
81
|
+
else:
|
|
82
|
+
data = (
|
|
83
|
+
{k: v for k, v in payload.items() if k != namespace}
|
|
84
|
+
if isinstance(payload, Mapping)
|
|
85
|
+
else payload
|
|
86
|
+
)
|
|
87
|
+
is_list = output.layout in ("object_list", "hash_list")
|
|
88
|
+
valid = (
|
|
89
|
+
isinstance(data, list) and all(isinstance(v, dict) for v in data)
|
|
90
|
+
if is_list
|
|
91
|
+
else isinstance(data, dict)
|
|
92
|
+
)
|
|
93
|
+
if not valid:
|
|
94
|
+
raise ProtocolError(
|
|
95
|
+
"invalid response output layout", status_code=response.status_code
|
|
96
|
+
)
|
|
97
|
+
return DecodedResponse(data, meta, envelope, response.status_code, response.headers)
|