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.
@@ -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)