baark 0.1.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.
- baark/__init__.py +62 -0
- baark/_protocol.py +531 -0
- baark/client.py +654 -0
- baark/crypto.py +258 -0
- baark/errors.py +150 -0
- baark/models.py +410 -0
- baark/py.typed +0 -0
- baark-0.1.0.dist-info/METADATA +240 -0
- baark-0.1.0.dist-info/RECORD +11 -0
- baark-0.1.0.dist-info/WHEEL +4 -0
- baark-0.1.0.dist-info/licenses/LICENSE +21 -0
baark/__init__.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# Copyright (c) 2026 doabell.
|
|
2
|
+
"""baark: typed Python bindings for Bark notifications at https://bark.day.app.
|
|
3
|
+
|
|
4
|
+
Use ``Baark`` or ``AsyncBaark`` for pooled connections, or ``send``/``async_send``
|
|
5
|
+
for one-off notifications. Encryption uses PyCryptodome.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from baark.client import AsyncBaark, Baark, async_send, send
|
|
9
|
+
from baark.crypto import EncryptedPayload, Encryption, EncryptionMode, KeyEncoding
|
|
10
|
+
from baark.errors import (
|
|
11
|
+
APIError,
|
|
12
|
+
APNSReason,
|
|
13
|
+
BaarkError,
|
|
14
|
+
BatchError,
|
|
15
|
+
EncryptionError,
|
|
16
|
+
ProtocolError,
|
|
17
|
+
TransportError,
|
|
18
|
+
ValidationError,
|
|
19
|
+
)
|
|
20
|
+
from baark.models import (
|
|
21
|
+
Action,
|
|
22
|
+
Delivery,
|
|
23
|
+
Endpoint,
|
|
24
|
+
JSONValue,
|
|
25
|
+
Level,
|
|
26
|
+
Message,
|
|
27
|
+
MessageOptions,
|
|
28
|
+
Registration,
|
|
29
|
+
Response,
|
|
30
|
+
ServerInfo,
|
|
31
|
+
Sound,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"APIError",
|
|
36
|
+
"APNSReason",
|
|
37
|
+
"Action",
|
|
38
|
+
"AsyncBaark",
|
|
39
|
+
"Baark",
|
|
40
|
+
"BaarkError",
|
|
41
|
+
"BatchError",
|
|
42
|
+
"Delivery",
|
|
43
|
+
"EncryptedPayload",
|
|
44
|
+
"Encryption",
|
|
45
|
+
"EncryptionError",
|
|
46
|
+
"EncryptionMode",
|
|
47
|
+
"Endpoint",
|
|
48
|
+
"JSONValue",
|
|
49
|
+
"KeyEncoding",
|
|
50
|
+
"Level",
|
|
51
|
+
"Message",
|
|
52
|
+
"MessageOptions",
|
|
53
|
+
"ProtocolError",
|
|
54
|
+
"Registration",
|
|
55
|
+
"Response",
|
|
56
|
+
"ServerInfo",
|
|
57
|
+
"Sound",
|
|
58
|
+
"TransportError",
|
|
59
|
+
"ValidationError",
|
|
60
|
+
"async_send",
|
|
61
|
+
"send",
|
|
62
|
+
]
|
baark/_protocol.py
ADDED
|
@@ -0,0 +1,531 @@
|
|
|
1
|
+
# Copyright (c) 2026 doabell.
|
|
2
|
+
"""Shared request construction and strict response parsing."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
from collections import Counter
|
|
8
|
+
from contextlib import suppress
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from http import HTTPStatus
|
|
11
|
+
from typing import TYPE_CHECKING, cast
|
|
12
|
+
from urllib.parse import quote, unquote, urlencode
|
|
13
|
+
|
|
14
|
+
import httpx2
|
|
15
|
+
|
|
16
|
+
from baark.crypto import Encryption
|
|
17
|
+
from baark.errors import APIError, ProtocolError, invalid
|
|
18
|
+
from baark.models import (
|
|
19
|
+
Delivery,
|
|
20
|
+
Endpoint,
|
|
21
|
+
EndpointValue,
|
|
22
|
+
JSONValue,
|
|
23
|
+
Message,
|
|
24
|
+
MessageOptions,
|
|
25
|
+
Registration,
|
|
26
|
+
Response,
|
|
27
|
+
ServerInfo,
|
|
28
|
+
normalize_options,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
DEFAULT_SERVER = "https://api.day.app"
|
|
32
|
+
_MAX_DEVICE_TOKEN_LENGTH = 160
|
|
33
|
+
_MESSAGE_QUERY_FIELDS = frozenset(
|
|
34
|
+
name.lower()
|
|
35
|
+
for name in (
|
|
36
|
+
*MessageOptions.__annotations__,
|
|
37
|
+
"body",
|
|
38
|
+
"autoCopy",
|
|
39
|
+
"isArchive",
|
|
40
|
+
"device_key",
|
|
41
|
+
"device_keys",
|
|
42
|
+
"device_token",
|
|
43
|
+
)
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
if TYPE_CHECKING:
|
|
47
|
+
from collections.abc import Sequence
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True, slots=True)
|
|
51
|
+
class Config:
|
|
52
|
+
"""HTTP-independent settings shared by sync and async clients."""
|
|
53
|
+
|
|
54
|
+
server_url: str
|
|
55
|
+
device_key: str | None = field(repr=False)
|
|
56
|
+
encryption: Encryption | None
|
|
57
|
+
defaults: MessageOptions = field(repr=False)
|
|
58
|
+
endpoint: Endpoint
|
|
59
|
+
|
|
60
|
+
def url(self, path: str) -> str:
|
|
61
|
+
"""Append an endpoint while preserving reverse-proxy path prefixes.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
path: Relative endpoint path.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
An absolute URL.
|
|
68
|
+
"""
|
|
69
|
+
return f"{self.server_url}/{path.lstrip('/')}"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True, slots=True)
|
|
73
|
+
class RequestSpec:
|
|
74
|
+
"""A complete request that either HTTP client can send."""
|
|
75
|
+
|
|
76
|
+
method: str
|
|
77
|
+
url: str = field(repr=False)
|
|
78
|
+
content: bytes | None = field(default=None, repr=False)
|
|
79
|
+
content_type: str | None = None
|
|
80
|
+
expected_keys: tuple[str, ...] | None = field(default=None, repr=False)
|
|
81
|
+
query_fields: frozenset[str] = field(default=_MESSAGE_QUERY_FIELDS, repr=False)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def configure(
|
|
85
|
+
device_key: str | None,
|
|
86
|
+
*,
|
|
87
|
+
server_url: str | None,
|
|
88
|
+
encryption: Encryption | None,
|
|
89
|
+
defaults: MessageOptions,
|
|
90
|
+
endpoint: Endpoint | EndpointValue,
|
|
91
|
+
) -> Config:
|
|
92
|
+
"""Validate configuration and optionally split a copied device URL.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
device_key: A key or a URL ending in the key.
|
|
96
|
+
server_url: Optional server URL, including a reverse-proxy prefix.
|
|
97
|
+
encryption: Optional payload encryption.
|
|
98
|
+
defaults: Default message options.
|
|
99
|
+
endpoint: Default request format.
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
Validated client configuration.
|
|
103
|
+
"""
|
|
104
|
+
if device_key is not None and not isinstance(device_key, str):
|
|
105
|
+
invalid("device_key must be a string or a device URL.")
|
|
106
|
+
if device_key is not None and "://" in device_key:
|
|
107
|
+
if server_url is not None:
|
|
108
|
+
invalid("Pass either a device URL or a separate key and server_url.")
|
|
109
|
+
device_url = _server_url(device_key)
|
|
110
|
+
server_url, separator, encoded_key = device_url.rpartition("/")
|
|
111
|
+
if not separator or not encoded_key or server_url.endswith(":/"):
|
|
112
|
+
invalid("A device URL must end in a device key.")
|
|
113
|
+
device_key = unquote(encoded_key)
|
|
114
|
+
if device_key is not None:
|
|
115
|
+
validate_key(device_key)
|
|
116
|
+
if encryption is not None and not isinstance(encryption, Encryption):
|
|
117
|
+
invalid("encryption must be an Encryption instance.")
|
|
118
|
+
selected_endpoint = parse_endpoint(endpoint)
|
|
119
|
+
normalized = normalize_options(defaults)
|
|
120
|
+
Message(**normalized)
|
|
121
|
+
return Config(
|
|
122
|
+
server_url=_server_url(
|
|
123
|
+
server_url if server_url is not None else DEFAULT_SERVER
|
|
124
|
+
),
|
|
125
|
+
device_key=device_key,
|
|
126
|
+
encryption=encryption,
|
|
127
|
+
defaults=normalized,
|
|
128
|
+
endpoint=selected_endpoint,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _server_url(value: str) -> str:
|
|
133
|
+
if not isinstance(value, str):
|
|
134
|
+
invalid("server_url must be an absolute HTTP or HTTPS URL.")
|
|
135
|
+
try:
|
|
136
|
+
url = httpx2.URL(value)
|
|
137
|
+
except httpx2.InvalidURL:
|
|
138
|
+
invalid("server_url is not a valid URL.")
|
|
139
|
+
if url.scheme not in {"http", "https"} or not url.host:
|
|
140
|
+
invalid("server_url must be an absolute HTTP or HTTPS URL.")
|
|
141
|
+
if url.username or url.password or url.query or url.fragment:
|
|
142
|
+
invalid("Server URLs cannot contain credentials, query strings, or fragments.")
|
|
143
|
+
return str(url).rstrip("/")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def validate_key(key: str) -> str:
|
|
147
|
+
"""Validate a key without including it in diagnostic messages.
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
key: Device key.
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
The validated key.
|
|
154
|
+
"""
|
|
155
|
+
if (
|
|
156
|
+
not isinstance(key, str)
|
|
157
|
+
or not key
|
|
158
|
+
or not key.isprintable()
|
|
159
|
+
or any(character.isspace() for character in key)
|
|
160
|
+
or any(character in key for character in "/\\?#")
|
|
161
|
+
or key in {".", ".."}
|
|
162
|
+
):
|
|
163
|
+
invalid("A device key must be a non-empty URL segment without whitespace.")
|
|
164
|
+
return key
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def parse_endpoint(value: Endpoint | EndpointValue) -> Endpoint:
|
|
168
|
+
"""Resolve a public endpoint option.
|
|
169
|
+
|
|
170
|
+
Args:
|
|
171
|
+
value: An endpoint enum or its string value.
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
The endpoint enum.
|
|
175
|
+
"""
|
|
176
|
+
try:
|
|
177
|
+
return Endpoint(value)
|
|
178
|
+
except ValueError:
|
|
179
|
+
invalid("endpoint must be push, device, form, or get.")
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def prepare_push( # noqa: PLR0913 - Mirrors the public send options.
|
|
183
|
+
config: Config,
|
|
184
|
+
body: str | Message,
|
|
185
|
+
*,
|
|
186
|
+
options: MessageOptions,
|
|
187
|
+
device_key: str | None,
|
|
188
|
+
device_keys: Sequence[str] | None,
|
|
189
|
+
endpoint: Endpoint | EndpointValue | None,
|
|
190
|
+
) -> RequestSpec:
|
|
191
|
+
"""Build a push request without performing network I/O.
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
config: Client configuration.
|
|
195
|
+
body: Text or a reusable message.
|
|
196
|
+
options: Per-call overrides; explicit None removes defaults.
|
|
197
|
+
device_key: Optional single recipient override.
|
|
198
|
+
device_keys: Optional server-side batch recipients.
|
|
199
|
+
endpoint: Optional request format override.
|
|
200
|
+
|
|
201
|
+
Returns:
|
|
202
|
+
A request specification ready for either client.
|
|
203
|
+
"""
|
|
204
|
+
payload = _message(config, body, options).to_payload()
|
|
205
|
+
query_fields = _query_field_names(payload)
|
|
206
|
+
if config.encryption is not None:
|
|
207
|
+
if "ciphertext" in payload or "iv" in payload:
|
|
208
|
+
invalid(
|
|
209
|
+
"Pre-encrypted ciphertext cannot be combined with client encryption."
|
|
210
|
+
)
|
|
211
|
+
encrypted: dict[str, JSONValue] = dict(
|
|
212
|
+
config.encryption.encrypt(_json(payload)).to_payload()
|
|
213
|
+
)
|
|
214
|
+
# APNs collapse IDs and background deletion are processed before decryption.
|
|
215
|
+
for name in ("id", "delete"):
|
|
216
|
+
if name in payload:
|
|
217
|
+
encrypted[name] = payload[name]
|
|
218
|
+
payload = encrypted
|
|
219
|
+
|
|
220
|
+
selected = config.endpoint if endpoint is None else parse_endpoint(endpoint)
|
|
221
|
+
keys = _recipients(config, device_key, device_keys)
|
|
222
|
+
expected = keys if device_keys is not None else None
|
|
223
|
+
if selected != Endpoint.PUSH and device_keys is not None:
|
|
224
|
+
invalid("device_keys batches require the push JSON endpoint.")
|
|
225
|
+
if selected == Endpoint.PUSH:
|
|
226
|
+
if expected is not None:
|
|
227
|
+
payload["device_keys"] = list(keys)
|
|
228
|
+
else:
|
|
229
|
+
payload["device_key"] = keys[0]
|
|
230
|
+
return json_request(
|
|
231
|
+
config, "push", payload, expected_keys=expected, query_fields=query_fields
|
|
232
|
+
)
|
|
233
|
+
path = quote(keys[0], safe="")
|
|
234
|
+
if selected == Endpoint.DEVICE:
|
|
235
|
+
return json_request(config, path, payload, query_fields=query_fields)
|
|
236
|
+
encoded = urlencode(_query_fields(payload))
|
|
237
|
+
if selected == Endpoint.GET:
|
|
238
|
+
return RequestSpec(
|
|
239
|
+
"GET",
|
|
240
|
+
f"{config.url(path)}?{encoded}",
|
|
241
|
+
query_fields=query_fields,
|
|
242
|
+
)
|
|
243
|
+
return RequestSpec(
|
|
244
|
+
"POST",
|
|
245
|
+
config.url(path),
|
|
246
|
+
encoded.encode("utf-8"),
|
|
247
|
+
"application/x-www-form-urlencoded; charset=utf-8",
|
|
248
|
+
query_fields=query_fields,
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _message(config: Config, body: str | Message, options: MessageOptions) -> Message:
|
|
253
|
+
merged = normalize_options(config.defaults)
|
|
254
|
+
if isinstance(body, Message):
|
|
255
|
+
merged.update(body.options())
|
|
256
|
+
body = body.body
|
|
257
|
+
merged.update(normalize_options(options))
|
|
258
|
+
return Message(body, **merged)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _recipients(
|
|
262
|
+
config: Config, device_key: str | None, device_keys: Sequence[str] | None
|
|
263
|
+
) -> tuple[str, ...]:
|
|
264
|
+
if device_keys is not None:
|
|
265
|
+
if device_key is not None:
|
|
266
|
+
invalid("Specify device_key or device_keys, not both.")
|
|
267
|
+
if isinstance(device_keys, (str, bytes)) or not device_keys:
|
|
268
|
+
invalid("device_keys must be a non-empty sequence of device keys.")
|
|
269
|
+
return tuple(validate_key(key) for key in device_keys)
|
|
270
|
+
key = config.device_key if device_key is None else device_key
|
|
271
|
+
if key is None:
|
|
272
|
+
invalid("Provide a device key to send a notification.")
|
|
273
|
+
return (validate_key(key),)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _query_fields(payload: dict[str, JSONValue]) -> dict[str, str]:
|
|
277
|
+
result: dict[str, str] = {}
|
|
278
|
+
for name, value in payload.items():
|
|
279
|
+
if isinstance(value, (dict, list)):
|
|
280
|
+
invalid("Structured extension fields require a JSON endpoint.")
|
|
281
|
+
result[name] = str(value) if value is not None else ""
|
|
282
|
+
return result
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _query_field_names(payload: dict[str, JSONValue]) -> frozenset[str]:
|
|
286
|
+
names = set(payload)
|
|
287
|
+
for value in payload.values():
|
|
288
|
+
if isinstance(value, dict):
|
|
289
|
+
names.update(value)
|
|
290
|
+
return _MESSAGE_QUERY_FIELDS | frozenset(name.lower() for name in names)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _json(payload: dict[str, JSONValue]) -> str:
|
|
294
|
+
return json.dumps(
|
|
295
|
+
payload, ensure_ascii=False, allow_nan=False, separators=(",", ":")
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def json_request(
|
|
300
|
+
config: Config,
|
|
301
|
+
path: str,
|
|
302
|
+
payload: dict[str, JSONValue],
|
|
303
|
+
*,
|
|
304
|
+
expected_keys: tuple[str, ...] | None = None,
|
|
305
|
+
query_fields: frozenset[str] = frozenset(),
|
|
306
|
+
) -> RequestSpec:
|
|
307
|
+
"""Build a JSON POST request.
|
|
308
|
+
|
|
309
|
+
Args:
|
|
310
|
+
config: Client configuration.
|
|
311
|
+
path: Endpoint path relative to the server prefix.
|
|
312
|
+
payload: JSON object to send.
|
|
313
|
+
expected_keys: Recipients expected in a batch response.
|
|
314
|
+
query_fields: Message fields to protect even when hidden by encryption.
|
|
315
|
+
|
|
316
|
+
Returns:
|
|
317
|
+
Encoded request specification.
|
|
318
|
+
"""
|
|
319
|
+
return RequestSpec(
|
|
320
|
+
"POST",
|
|
321
|
+
config.url(path),
|
|
322
|
+
_json(payload).encode("utf-8"),
|
|
323
|
+
"application/json; charset=utf-8",
|
|
324
|
+
expected_keys,
|
|
325
|
+
_query_field_names(payload) | query_fields,
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def registration_request(
|
|
330
|
+
config: Config, device_token: str, device_key: str | None
|
|
331
|
+
) -> RequestSpec:
|
|
332
|
+
"""Build a registration request without implicitly reusing the client key.
|
|
333
|
+
|
|
334
|
+
Args:
|
|
335
|
+
config: Client configuration.
|
|
336
|
+
device_token: APNs device token obtained from the app.
|
|
337
|
+
device_key: Explicit existing or custom key; None asks for a new key.
|
|
338
|
+
|
|
339
|
+
Returns:
|
|
340
|
+
A JSON POST to the registration endpoint.
|
|
341
|
+
"""
|
|
342
|
+
if (
|
|
343
|
+
not isinstance(device_token, str)
|
|
344
|
+
or not device_token
|
|
345
|
+
or len(device_token) > _MAX_DEVICE_TOKEN_LENGTH
|
|
346
|
+
):
|
|
347
|
+
invalid("device_token must contain between 1 and 160 characters.")
|
|
348
|
+
payload: dict[str, JSONValue] = {"device_token": device_token}
|
|
349
|
+
if device_key is not None:
|
|
350
|
+
payload["device_key"] = validate_key(device_key)
|
|
351
|
+
return json_request(config, "register", payload)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def check_request(config: Config, device_key: str | None) -> RequestSpec:
|
|
355
|
+
"""Build a registration check for a supplied or configured key.
|
|
356
|
+
|
|
357
|
+
Args:
|
|
358
|
+
config: Client configuration.
|
|
359
|
+
device_key: Optional key override.
|
|
360
|
+
|
|
361
|
+
Returns:
|
|
362
|
+
A GET request to the registration-check endpoint.
|
|
363
|
+
"""
|
|
364
|
+
key = _recipients(config, device_key, None)[0]
|
|
365
|
+
return RequestSpec("GET", config.url(f"register/{quote(key, safe='')}"))
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def read_json(response: httpx2.Response) -> dict[str, JSONValue]:
|
|
369
|
+
"""Decode an object response and detect HTTP errors.
|
|
370
|
+
|
|
371
|
+
Args:
|
|
372
|
+
response: HTTP response.
|
|
373
|
+
|
|
374
|
+
Returns:
|
|
375
|
+
A decoded JSON object.
|
|
376
|
+
|
|
377
|
+
Raises:
|
|
378
|
+
APIError: For an HTTP error.
|
|
379
|
+
ProtocolError: For a malformed successful response.
|
|
380
|
+
"""
|
|
381
|
+
raw: object = None
|
|
382
|
+
with suppress(ValueError):
|
|
383
|
+
raw = response.json()
|
|
384
|
+
if not response.is_success:
|
|
385
|
+
code = raw.get("code") if isinstance(raw, dict) else None
|
|
386
|
+
message = raw.get("message") if isinstance(raw, dict) else None
|
|
387
|
+
raise APIError(
|
|
388
|
+
status_code=response.status_code,
|
|
389
|
+
code=code if type(code) is int else None,
|
|
390
|
+
server_message=message if isinstance(message, str) else None,
|
|
391
|
+
)
|
|
392
|
+
if not isinstance(raw, dict):
|
|
393
|
+
message = "Expected a Bark JSON object; received an invalid response."
|
|
394
|
+
raise ProtocolError(message)
|
|
395
|
+
return cast("dict[str, JSONValue]", raw)
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def parse_response(
|
|
399
|
+
response: httpx2.Response, *, expected_keys: tuple[str, ...] | None = None
|
|
400
|
+
) -> Response:
|
|
401
|
+
"""Validate a Bark envelope and every expected batch outcome.
|
|
402
|
+
|
|
403
|
+
Args:
|
|
404
|
+
response: HTTP response.
|
|
405
|
+
expected_keys: Recipients of the corresponding batch, if any.
|
|
406
|
+
|
|
407
|
+
Returns:
|
|
408
|
+
A typed response.
|
|
409
|
+
|
|
410
|
+
Raises:
|
|
411
|
+
APIError: For HTTP or Bark application errors.
|
|
412
|
+
ProtocolError: For malformed or incomplete responses.
|
|
413
|
+
BatchError: When any batch recipient failed.
|
|
414
|
+
"""
|
|
415
|
+
raw = read_json(response)
|
|
416
|
+
code = raw.get("code")
|
|
417
|
+
message = raw.get("message")
|
|
418
|
+
timestamp = raw.get("timestamp")
|
|
419
|
+
if (
|
|
420
|
+
type(code) is not int
|
|
421
|
+
or not isinstance(message, str)
|
|
422
|
+
or (timestamp is not None and type(timestamp) is not int)
|
|
423
|
+
):
|
|
424
|
+
detail = "Bark response has invalid or missing code, message, or timestamp."
|
|
425
|
+
raise ProtocolError(detail)
|
|
426
|
+
if code != HTTPStatus.OK:
|
|
427
|
+
raise APIError(
|
|
428
|
+
status_code=response.status_code, code=code, server_message=message
|
|
429
|
+
)
|
|
430
|
+
deliveries = ()
|
|
431
|
+
if expected_keys is not None:
|
|
432
|
+
deliveries = _deliveries(raw.get("data"), expected_keys)
|
|
433
|
+
result = Response(
|
|
434
|
+
code, message, timestamp, raw.get("data"), deliveries, response.status_code
|
|
435
|
+
)
|
|
436
|
+
result.raise_for_status()
|
|
437
|
+
return result
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _deliveries(
|
|
441
|
+
data: JSONValue, expected_keys: tuple[str, ...]
|
|
442
|
+
) -> tuple[Delivery, ...]:
|
|
443
|
+
if not isinstance(data, list):
|
|
444
|
+
message = "Bark batch response is missing per-device results."
|
|
445
|
+
raise ProtocolError(message)
|
|
446
|
+
result: list[Delivery] = []
|
|
447
|
+
for item in data:
|
|
448
|
+
if not isinstance(item, dict):
|
|
449
|
+
message = "Bark batch result must be an object."
|
|
450
|
+
raise ProtocolError(message)
|
|
451
|
+
key, code, detail = (
|
|
452
|
+
item.get("device_key"),
|
|
453
|
+
item.get("code"),
|
|
454
|
+
item.get("message", ""),
|
|
455
|
+
)
|
|
456
|
+
if (
|
|
457
|
+
not isinstance(key, str)
|
|
458
|
+
or type(code) is not int
|
|
459
|
+
or not isinstance(detail, str)
|
|
460
|
+
):
|
|
461
|
+
message = "Bark batch result has invalid or missing fields."
|
|
462
|
+
raise ProtocolError(message)
|
|
463
|
+
result.append(Delivery(key, code, detail))
|
|
464
|
+
if Counter(item.device_key for item in result) != Counter(expected_keys):
|
|
465
|
+
message = "Bark batch results do not match the requested recipients."
|
|
466
|
+
raise ProtocolError(message)
|
|
467
|
+
return tuple(result)
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def parse_info(response: httpx2.Response) -> ServerInfo:
|
|
471
|
+
"""Read the unwrapped server-info object.
|
|
472
|
+
|
|
473
|
+
Args:
|
|
474
|
+
response: HTTP response from /info.
|
|
475
|
+
|
|
476
|
+
Returns:
|
|
477
|
+
Validated server information.
|
|
478
|
+
"""
|
|
479
|
+
raw = read_json(response)
|
|
480
|
+
names = ("version", "build", "arch", "commit")
|
|
481
|
+
if (
|
|
482
|
+
any(not isinstance(raw.get(name), str) for name in names)
|
|
483
|
+
or type(raw.get("devices")) is not int
|
|
484
|
+
):
|
|
485
|
+
message = "Bark server-info response has invalid or missing fields."
|
|
486
|
+
raise ProtocolError(message)
|
|
487
|
+
return ServerInfo(
|
|
488
|
+
version=cast("str", raw["version"]),
|
|
489
|
+
build=cast("str", raw["build"]),
|
|
490
|
+
arch=cast("str", raw["arch"]),
|
|
491
|
+
commit=cast("str", raw["commit"]),
|
|
492
|
+
devices=cast("int", raw["devices"]),
|
|
493
|
+
)
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def parse_registration(response: httpx2.Response) -> Registration:
|
|
497
|
+
"""Validate the registration envelope and extract device credentials.
|
|
498
|
+
|
|
499
|
+
Args:
|
|
500
|
+
response: HTTP response from /register.
|
|
501
|
+
|
|
502
|
+
Returns:
|
|
503
|
+
Registered device credentials.
|
|
504
|
+
"""
|
|
505
|
+
data = parse_response(response).data
|
|
506
|
+
if not isinstance(data, dict):
|
|
507
|
+
message = "Bark registration response is missing device credentials."
|
|
508
|
+
raise ProtocolError(message)
|
|
509
|
+
key = data.get("device_key", data.get("key"))
|
|
510
|
+
token = data.get("device_token")
|
|
511
|
+
if not isinstance(key, str) or not key or not isinstance(token, str) or not token:
|
|
512
|
+
message = "Bark registration response has invalid device credentials."
|
|
513
|
+
raise ProtocolError(message)
|
|
514
|
+
return Registration(key, token)
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def parse_health(response: httpx2.Response) -> str:
|
|
518
|
+
"""Validate the plain-text response used by the root and health endpoints.
|
|
519
|
+
|
|
520
|
+
Args:
|
|
521
|
+
response: HTTP response.
|
|
522
|
+
|
|
523
|
+
Returns:
|
|
524
|
+
The string ``ok``.
|
|
525
|
+
"""
|
|
526
|
+
if not response.is_success:
|
|
527
|
+
raise APIError(status_code=response.status_code)
|
|
528
|
+
if response.text.strip() != "ok":
|
|
529
|
+
message = "Expected 'ok' from the Bark health endpoint."
|
|
530
|
+
raise ProtocolError(message)
|
|
531
|
+
return "ok"
|