flightbyte 1.0.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.
- flightbyte/__init__.py +136 -0
- flightbyte/_client.py +157 -0
- flightbyte/_countries.py +57 -0
- flightbyte/_errors.py +311 -0
- flightbyte/_flights.py +84 -0
- flightbyte/_models.py +644 -0
- flightbyte/_rate_limit.py +75 -0
- flightbyte/_subscriptions.py +317 -0
- flightbyte/_transport.py +429 -0
- flightbyte/_util.py +28 -0
- flightbyte/_version.py +3 -0
- flightbyte/_webhook.py +233 -0
- flightbyte/py.typed +0 -0
- flightbyte-1.0.0.dist-info/METADATA +423 -0
- flightbyte-1.0.0.dist-info/RECORD +17 -0
- flightbyte-1.0.0.dist-info/WHEEL +4 -0
- flightbyte-1.0.0.dist-info/licenses/LICENSE +21 -0
flightbyte/_transport.py
ADDED
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
"""Sends authenticated requests: headers, timeouts, redirects and error mapping."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import functools
|
|
7
|
+
import http.client
|
|
8
|
+
import json
|
|
9
|
+
import re
|
|
10
|
+
import socket
|
|
11
|
+
import ssl
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
import urllib.error
|
|
15
|
+
import urllib.parse
|
|
16
|
+
import urllib.request
|
|
17
|
+
from collections.abc import Mapping, Sequence
|
|
18
|
+
from typing import Any, Final, TypeAlias, cast
|
|
19
|
+
|
|
20
|
+
from ._errors import APIConnectionError, APIError, FlightByteError
|
|
21
|
+
from ._rate_limit import RateLimit, parse_rate_limit
|
|
22
|
+
|
|
23
|
+
QueryValue: TypeAlias = str | int | float | bool | None
|
|
24
|
+
"""A query string value; ``None`` and ``''`` are left out."""
|
|
25
|
+
|
|
26
|
+
MAX_REDIRECTS: Final = 5
|
|
27
|
+
"""How many same-origin redirects a GET or HEAD follows."""
|
|
28
|
+
|
|
29
|
+
_DEFAULT_PORTS: Final = {"http": 80, "https": 443}
|
|
30
|
+
_FOLLOWABLE_METHODS: Final = frozenset({"GET", "HEAD"})
|
|
31
|
+
_UNSAFE_URL_CHARACTERS: Final = re.compile(r"[\x00-\x20\x7f]")
|
|
32
|
+
_HEADER_VALUE: Final = re.compile(r"[^\x00-\x08\x0a-\x1f\x7f]*")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class NotGiven:
|
|
36
|
+
"""The type of ``NOT_GIVEN``: a per-call option that was left out."""
|
|
37
|
+
|
|
38
|
+
_instance: NotGiven | None = None
|
|
39
|
+
|
|
40
|
+
def __new__(cls) -> NotGiven:
|
|
41
|
+
if cls._instance is None:
|
|
42
|
+
cls._instance = super().__new__(cls)
|
|
43
|
+
return cls._instance
|
|
44
|
+
|
|
45
|
+
def __bool__(self) -> bool:
|
|
46
|
+
return False
|
|
47
|
+
|
|
48
|
+
def __repr__(self) -> str:
|
|
49
|
+
return "NOT_GIVEN"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
NOT_GIVEN: Final = NotGiven()
|
|
53
|
+
"""Leaves a per-call ``timeout`` to the client's own."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def check_timeout(name: str, value: object) -> float | None:
|
|
57
|
+
"""Validates a timeout in seconds; ``None`` and ``0`` turn it off."""
|
|
58
|
+
if value is None:
|
|
59
|
+
return None
|
|
60
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
61
|
+
raise TypeError(f"flightbyte: {name} must be a number of seconds or None.")
|
|
62
|
+
if value != value or value < 0 or value == float("inf"):
|
|
63
|
+
raise ValueError(f"flightbyte: {name} must be a number of seconds, 0 or more, or None.")
|
|
64
|
+
return float(value) if value > 0 else None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def check_header_value(name: str, value: str) -> str:
|
|
68
|
+
"""Rejects line breaks and other control characters, so a value cannot add a header."""
|
|
69
|
+
if not _HEADER_VALUE.fullmatch(value):
|
|
70
|
+
raise ValueError(f"flightbyte: {name} must not contain line breaks or control characters.")
|
|
71
|
+
return value
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def required(name: str, value: object) -> str:
|
|
75
|
+
"""Rejects an empty path parameter before it turns into a request for the wrong URL."""
|
|
76
|
+
if not isinstance(value, str):
|
|
77
|
+
raise TypeError(f"flightbyte: {name} is required and must be a string.")
|
|
78
|
+
if not value.strip():
|
|
79
|
+
raise ValueError(f"flightbyte: {name} is required.")
|
|
80
|
+
return value
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class Transport:
|
|
84
|
+
"""Sends authenticated requests and turns error statuses into errors.
|
|
85
|
+
|
|
86
|
+
Reach the API through ``FlightByte``. One transport is shared by every
|
|
87
|
+
thread using the client: it keeps no connection between requests, and
|
|
88
|
+
the only state it changes, the latest quota, is guarded by a lock.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
def __init__(
|
|
92
|
+
self, *, api_key: str, base_url: str, timeout: float | None, user_agent: str
|
|
93
|
+
) -> None:
|
|
94
|
+
self.base_url = base_url
|
|
95
|
+
self.timeout = timeout
|
|
96
|
+
self._api_key = api_key
|
|
97
|
+
self._user_agent = user_agent
|
|
98
|
+
self._opener = _build_opener()
|
|
99
|
+
self._lock = threading.Lock()
|
|
100
|
+
self._last_rate_limit: RateLimit | None = None
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def last_rate_limit(self) -> RateLimit | None:
|
|
104
|
+
"""The quota state from the latest response that carried one."""
|
|
105
|
+
with self._lock:
|
|
106
|
+
return self._last_rate_limit
|
|
107
|
+
|
|
108
|
+
def url(self, path: Sequence[str], query: Mapping[str, QueryValue] | None = None) -> str:
|
|
109
|
+
"""Builds the URL for ``path`` under the base URL, each segment escaped."""
|
|
110
|
+
url = self.base_url + "".join("/" + _escape_segment(segment) for segment in path)
|
|
111
|
+
params = [
|
|
112
|
+
(key, _query_string(value))
|
|
113
|
+
for key, value in (query or {}).items()
|
|
114
|
+
if value is not None and value != ""
|
|
115
|
+
]
|
|
116
|
+
if params:
|
|
117
|
+
url += "?" + urllib.parse.urlencode(params)
|
|
118
|
+
return url
|
|
119
|
+
|
|
120
|
+
def send(
|
|
121
|
+
self,
|
|
122
|
+
method: str,
|
|
123
|
+
path: Sequence[str],
|
|
124
|
+
*,
|
|
125
|
+
query: Mapping[str, QueryValue] | None = None,
|
|
126
|
+
body: object = None,
|
|
127
|
+
headers: Mapping[str, str] | None = None,
|
|
128
|
+
timeout: float | NotGiven | None = NOT_GIVEN,
|
|
129
|
+
) -> Any:
|
|
130
|
+
"""Sends a request and returns the decoded JSON, or ``None`` for an empty body."""
|
|
131
|
+
seconds = (
|
|
132
|
+
self.timeout if isinstance(timeout, NotGiven) else check_timeout("timeout", timeout)
|
|
133
|
+
)
|
|
134
|
+
request_headers = {
|
|
135
|
+
"x-api-key": self._api_key,
|
|
136
|
+
"accept": "application/json",
|
|
137
|
+
"user-agent": self._user_agent,
|
|
138
|
+
}
|
|
139
|
+
for name, value in (headers or {}).items():
|
|
140
|
+
request_headers[name.lower()] = check_header_value(name, value)
|
|
141
|
+
data: bytes | None = None
|
|
142
|
+
if body is not None:
|
|
143
|
+
request_headers["content-type"] = "application/json"
|
|
144
|
+
data = json.dumps(body, separators=(",", ":"), allow_nan=False).encode("ascii")
|
|
145
|
+
|
|
146
|
+
initial_url = self.url(path, query)
|
|
147
|
+
url = initial_url
|
|
148
|
+
deadline = _Deadline(seconds)
|
|
149
|
+
try:
|
|
150
|
+
hops = 0
|
|
151
|
+
while True:
|
|
152
|
+
follow = hops < MAX_REDIRECTS
|
|
153
|
+
status, reason, response_headers, raw = self._exchange(
|
|
154
|
+
method, url, request_headers, data, deadline, follow=follow
|
|
155
|
+
)
|
|
156
|
+
target = redirect_target(status, response_headers, url, method)
|
|
157
|
+
if target is None or not follow:
|
|
158
|
+
break
|
|
159
|
+
url = target
|
|
160
|
+
hops += 1
|
|
161
|
+
except (OSError, http.client.HTTPException) as error:
|
|
162
|
+
if seconds is not None and (deadline.finish() or _is_timeout(error)):
|
|
163
|
+
raise _timed_out(method, initial_url, seconds) from error
|
|
164
|
+
raise APIConnectionError(
|
|
165
|
+
f"{method} {initial_url} failed: {_describe(error)}"
|
|
166
|
+
) from error
|
|
167
|
+
finally:
|
|
168
|
+
deadline.finish()
|
|
169
|
+
if seconds is not None and deadline.expired:
|
|
170
|
+
# The body was cut off when the time ran out, and a body without a
|
|
171
|
+
# length can look complete. What arrived is not trusted.
|
|
172
|
+
raise _timed_out(method, initial_url, seconds)
|
|
173
|
+
|
|
174
|
+
rate_limit = parse_rate_limit(response_headers)
|
|
175
|
+
if rate_limit is not None:
|
|
176
|
+
with self._lock:
|
|
177
|
+
self._last_rate_limit = rate_limit
|
|
178
|
+
|
|
179
|
+
text = raw.decode("utf-8", "replace")
|
|
180
|
+
if status < 200 or status > 299:
|
|
181
|
+
raise APIError.from_response(status, reason, text, response_headers)
|
|
182
|
+
if status == 204 or not text.strip():
|
|
183
|
+
return None
|
|
184
|
+
try:
|
|
185
|
+
return json.loads(text)
|
|
186
|
+
except ValueError as error:
|
|
187
|
+
preview = text[:200] + "..." if len(text) > 200 else text
|
|
188
|
+
raise FlightByteError(
|
|
189
|
+
f"Expected JSON from {method} {initial_url}, got: {preview}"
|
|
190
|
+
) from error
|
|
191
|
+
|
|
192
|
+
def _exchange(
|
|
193
|
+
self,
|
|
194
|
+
method: str,
|
|
195
|
+
url: str,
|
|
196
|
+
headers: Mapping[str, str],
|
|
197
|
+
data: bytes | None,
|
|
198
|
+
deadline: _Deadline,
|
|
199
|
+
*,
|
|
200
|
+
follow: bool,
|
|
201
|
+
) -> tuple[int, str, dict[str, str], bytes]:
|
|
202
|
+
"""One request and its whole response; nothing is followed here."""
|
|
203
|
+
request = _Request(url, data=data, headers=dict(headers), method=method, deadline=deadline)
|
|
204
|
+
remaining = deadline.remaining()
|
|
205
|
+
if remaining is None:
|
|
206
|
+
opened = self._opener.open(request)
|
|
207
|
+
else:
|
|
208
|
+
opened = self._opener.open(request, timeout=remaining)
|
|
209
|
+
response = cast(http.client.HTTPResponse, opened)
|
|
210
|
+
with response:
|
|
211
|
+
response_headers = _lower_headers(response.headers)
|
|
212
|
+
if follow and redirect_target(response.status, response_headers, url, method):
|
|
213
|
+
# Followed next; its body is of no use.
|
|
214
|
+
return response.status, response.reason, response_headers, b""
|
|
215
|
+
return response.status, response.reason, response_headers, response.read()
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def redirect_target(
|
|
219
|
+
status: int, headers: Mapping[str, str], current: str, method: str
|
|
220
|
+
) -> str | None:
|
|
221
|
+
"""Where to go next when the response is a redirect this client may follow.
|
|
222
|
+
|
|
223
|
+
Only a GET or HEAD is followed, and only to the same origin (scheme, host
|
|
224
|
+
and port). Anything else is handed to the caller as an error, so the API
|
|
225
|
+
key, sent on every request, never reaches another server or travels in
|
|
226
|
+
the clear, and a write is never sent twice.
|
|
227
|
+
"""
|
|
228
|
+
if status < 300 or status > 399 or method.upper() not in _FOLLOWABLE_METHODS:
|
|
229
|
+
return None
|
|
230
|
+
location = headers.get("location", "").strip()
|
|
231
|
+
if not location or _UNSAFE_URL_CHARACTERS.search(location):
|
|
232
|
+
return None
|
|
233
|
+
try:
|
|
234
|
+
target, _fragment = urllib.parse.urldefrag(urllib.parse.urljoin(current, location))
|
|
235
|
+
if _origin(target) != _origin(current):
|
|
236
|
+
return None
|
|
237
|
+
except ValueError:
|
|
238
|
+
return None
|
|
239
|
+
return target
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _origin(url: str) -> tuple[str, str, int | None]:
|
|
243
|
+
parts = urllib.parse.urlsplit(url)
|
|
244
|
+
scheme = parts.scheme.lower()
|
|
245
|
+
return scheme, (parts.hostname or "").lower(), parts.port or _DEFAULT_PORTS.get(scheme)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _escape_segment(segment: str) -> str:
|
|
249
|
+
escaped = urllib.parse.quote(segment, safe="")
|
|
250
|
+
# "." and ".." would be read as "this" and "parent" directory on the way.
|
|
251
|
+
return escaped.replace(".", "%2E") if escaped in (".", "..") else escaped
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _query_string(value: str | int | float | bool) -> str:
|
|
255
|
+
if isinstance(value, bool):
|
|
256
|
+
return "true" if value else "false"
|
|
257
|
+
return str(value)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _lower_headers(message: http.client.HTTPMessage) -> dict[str, str]:
|
|
261
|
+
headers: dict[str, str] = {}
|
|
262
|
+
for name, value in message.items():
|
|
263
|
+
key = name.lower()
|
|
264
|
+
headers[key] = f"{headers[key]}, {value}" if key in headers else value
|
|
265
|
+
return headers
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _timed_out(method: str, url: str, seconds: float) -> APIConnectionError:
|
|
269
|
+
return APIConnectionError(f"{method} {url} timed out after {seconds:g} s.", timed_out=True)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _is_timeout(error: BaseException) -> bool:
|
|
273
|
+
if isinstance(error, urllib.error.URLError) and isinstance(error.reason, BaseException):
|
|
274
|
+
return _is_timeout(error.reason)
|
|
275
|
+
return isinstance(error, TimeoutError)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _describe(error: BaseException) -> str:
|
|
279
|
+
if isinstance(error, urllib.error.URLError):
|
|
280
|
+
return (
|
|
281
|
+
_describe(error.reason)
|
|
282
|
+
if isinstance(error.reason, BaseException)
|
|
283
|
+
else str(error.reason)
|
|
284
|
+
)
|
|
285
|
+
return str(error) or type(error).__name__
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
class _Deadline:
|
|
289
|
+
"""Bounds a whole request, body included, across every redirect hop.
|
|
290
|
+
|
|
291
|
+
The socket timeout alone bounds each read, not their sum: a server that
|
|
292
|
+
trickles a byte at a time could hold a request open for ever. So a timer
|
|
293
|
+
shuts the socket down when the time is up, which ends whatever read is
|
|
294
|
+
waiting on it. Name resolution is left to the system resolver's own limits.
|
|
295
|
+
"""
|
|
296
|
+
|
|
297
|
+
def __init__(self, seconds: float | None) -> None:
|
|
298
|
+
self._lock = threading.Lock()
|
|
299
|
+
self._socket: socket.socket | None = None
|
|
300
|
+
self._expired = False
|
|
301
|
+
self._finished = False
|
|
302
|
+
self._end: float | None = None
|
|
303
|
+
self._timer: threading.Timer | None = None
|
|
304
|
+
if seconds is not None:
|
|
305
|
+
self._end = time.monotonic() + seconds
|
|
306
|
+
self._timer = threading.Timer(seconds, self._expire)
|
|
307
|
+
self._timer.daemon = True
|
|
308
|
+
self._timer.start()
|
|
309
|
+
|
|
310
|
+
@property
|
|
311
|
+
def expired(self) -> bool:
|
|
312
|
+
"""Whether the time ran out before the request finished."""
|
|
313
|
+
return self._expired
|
|
314
|
+
|
|
315
|
+
def remaining(self) -> float | None:
|
|
316
|
+
"""Seconds left, or ``None`` without a timeout. Raises ``TimeoutError`` once none are."""
|
|
317
|
+
if self._end is None:
|
|
318
|
+
return None
|
|
319
|
+
left = self._end - time.monotonic()
|
|
320
|
+
if left <= 0 or self._expired:
|
|
321
|
+
raise TimeoutError("the request ran out of time")
|
|
322
|
+
return left
|
|
323
|
+
|
|
324
|
+
def attach(self, sock: socket.socket) -> None:
|
|
325
|
+
"""Watches the socket of the current hop."""
|
|
326
|
+
with self._lock:
|
|
327
|
+
self._socket = sock
|
|
328
|
+
if self._expired:
|
|
329
|
+
_shut_down(sock)
|
|
330
|
+
|
|
331
|
+
def finish(self) -> bool:
|
|
332
|
+
"""Stops the timer and says whether the time ran out first."""
|
|
333
|
+
with self._lock:
|
|
334
|
+
self._finished = True
|
|
335
|
+
self._socket = None
|
|
336
|
+
if self._timer is not None:
|
|
337
|
+
self._timer.cancel()
|
|
338
|
+
return self._expired
|
|
339
|
+
|
|
340
|
+
def _expire(self) -> None:
|
|
341
|
+
with self._lock:
|
|
342
|
+
if self._finished:
|
|
343
|
+
return
|
|
344
|
+
self._expired = True
|
|
345
|
+
if self._socket is not None:
|
|
346
|
+
_shut_down(self._socket)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _shut_down(sock: socket.socket) -> None:
|
|
350
|
+
# socket.socket.shutdown, also for a TLS socket: it ends a read waiting in
|
|
351
|
+
# another thread without touching the TLS state that thread is using.
|
|
352
|
+
with contextlib.suppress(OSError):
|
|
353
|
+
socket.socket.shutdown(sock, socket.SHUT_RDWR)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
class _Request(urllib.request.Request):
|
|
357
|
+
"""A request that carries its deadline to the connection that sends it."""
|
|
358
|
+
|
|
359
|
+
def __init__(
|
|
360
|
+
self,
|
|
361
|
+
url: str,
|
|
362
|
+
*,
|
|
363
|
+
data: bytes | None,
|
|
364
|
+
headers: dict[str, str],
|
|
365
|
+
method: str,
|
|
366
|
+
deadline: _Deadline,
|
|
367
|
+
) -> None:
|
|
368
|
+
super().__init__(url, data=data, headers=headers, method=method)
|
|
369
|
+
self.deadline = deadline
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _deadline_of(request: urllib.request.Request) -> _Deadline:
|
|
373
|
+
deadline = getattr(request, "deadline", None)
|
|
374
|
+
return deadline if isinstance(deadline, _Deadline) else _Deadline(None)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
class _HTTPConnection(http.client.HTTPConnection):
|
|
378
|
+
def __init__(self, *args: Any, deadline: _Deadline, **kwargs: Any) -> None:
|
|
379
|
+
super().__init__(*args, **kwargs)
|
|
380
|
+
self._deadline = deadline
|
|
381
|
+
|
|
382
|
+
def connect(self) -> None:
|
|
383
|
+
super().connect()
|
|
384
|
+
self._deadline.attach(self.sock)
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
class _HTTPSConnection(http.client.HTTPSConnection):
|
|
388
|
+
def __init__(self, *args: Any, deadline: _Deadline, **kwargs: Any) -> None:
|
|
389
|
+
super().__init__(*args, **kwargs)
|
|
390
|
+
self._deadline = deadline
|
|
391
|
+
|
|
392
|
+
def connect(self) -> None:
|
|
393
|
+
super().connect()
|
|
394
|
+
self._deadline.attach(self.sock)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
class _HTTPHandler(urllib.request.HTTPHandler):
|
|
398
|
+
def http_open(self, req: urllib.request.Request) -> http.client.HTTPResponse:
|
|
399
|
+
return self.do_open(functools.partial(_HTTPConnection, deadline=_deadline_of(req)), req)
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
class _HTTPSHandler(urllib.request.HTTPSHandler):
|
|
403
|
+
def __init__(self) -> None:
|
|
404
|
+
context = ssl.create_default_context()
|
|
405
|
+
super().__init__(context=context)
|
|
406
|
+
self._tls_context = context
|
|
407
|
+
|
|
408
|
+
def https_open(self, req: urllib.request.Request) -> http.client.HTTPResponse:
|
|
409
|
+
return self.do_open(
|
|
410
|
+
functools.partial(_HTTPSConnection, deadline=_deadline_of(req)),
|
|
411
|
+
req,
|
|
412
|
+
context=self._tls_context,
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _build_opener() -> urllib.request.OpenerDirector:
|
|
417
|
+
# Built by hand rather than with build_opener(): without a redirect
|
|
418
|
+
# handler nothing is followed behind this module's back, and without an
|
|
419
|
+
# error processor every status comes back as a response. Proxies set in
|
|
420
|
+
# the environment (HTTPS_PROXY, NO_PROXY, ...) are honoured, as by urllib.
|
|
421
|
+
opener = urllib.request.OpenerDirector()
|
|
422
|
+
for handler in (
|
|
423
|
+
urllib.request.ProxyHandler(),
|
|
424
|
+
urllib.request.UnknownHandler(),
|
|
425
|
+
_HTTPHandler(),
|
|
426
|
+
_HTTPSHandler(),
|
|
427
|
+
):
|
|
428
|
+
opener.add_handler(handler)
|
|
429
|
+
return opener
|
flightbyte/_util.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Reading the wrappers some endpoints put around their answer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from ._errors import FlightByteError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def data_of(result: Any, operation: str) -> list[Any]:
|
|
11
|
+
"""The ``data`` list of a ``{"count": ..., "data": [...]}`` answer."""
|
|
12
|
+
data = result.get("data") if isinstance(result, dict) else None
|
|
13
|
+
if not isinstance(data, list):
|
|
14
|
+
raise FlightByteError(f"Expected a data list from {operation}, got: {_preview(result)}")
|
|
15
|
+
return data
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def string_of(result: Any, key: str, operation: str) -> str:
|
|
19
|
+
"""The string ``key`` of an object answer, such as ``{"secret": "..."}``."""
|
|
20
|
+
value = result.get(key) if isinstance(result, dict) else None
|
|
21
|
+
if not isinstance(value, str):
|
|
22
|
+
raise FlightByteError(f"Expected {key} from {operation}, got: {_preview(result)}")
|
|
23
|
+
return value
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _preview(value: Any) -> str:
|
|
27
|
+
text = repr(value)
|
|
28
|
+
return text[:200] + "..." if len(text) > 200 else text
|
flightbyte/_version.py
ADDED
flightbyte/_webhook.py
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""Verifying and decoding the webhooks FlightByte posts to your receiver."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import hmac
|
|
7
|
+
import json
|
|
8
|
+
import math
|
|
9
|
+
import re
|
|
10
|
+
import time
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
from typing import Final, TypeAlias, cast
|
|
13
|
+
|
|
14
|
+
from ._errors import FlightByteError, WebhookSignatureError
|
|
15
|
+
from ._models import WebhookEvent
|
|
16
|
+
|
|
17
|
+
__all__ = ["Webhook", "WebhookPayload"]
|
|
18
|
+
|
|
19
|
+
WebhookPayload: TypeAlias = bytes | bytearray | memoryview | str
|
|
20
|
+
"""The payload as received: the raw body, before any JSON parsing."""
|
|
21
|
+
|
|
22
|
+
_TIMESTAMP: Final = re.compile(r"[0-9]{1,15}")
|
|
23
|
+
_SIGNATURE: Final = re.compile(r"[0-9a-f]{64}")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Webhook:
|
|
27
|
+
"""Verifies and decodes the webhooks FlightByte posts to your receiver.
|
|
28
|
+
|
|
29
|
+
Each delivery carries an ``x-flightbyte-signature`` header of the form
|
|
30
|
+
``t=<unix seconds>,v1=<hex>``, where the hex is HMAC-SHA256 of
|
|
31
|
+
``"<t>.<raw body>"`` keyed with the subscription's signing secret.
|
|
32
|
+
|
|
33
|
+
Every method takes the body exactly as received, as bytes, before any JSON
|
|
34
|
+
parsing: ``request.get_data()`` in Flask, ``request.body`` in Django,
|
|
35
|
+
``await request.body()`` in FastAPI and Starlette. Keep the signing secret
|
|
36
|
+
on your server.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
SIGNATURE_HEADER: Final = "x-flightbyte-signature"
|
|
40
|
+
"""Header carrying the signature."""
|
|
41
|
+
TIMESTAMP_HEADER: Final = "x-flightbyte-timestamp"
|
|
42
|
+
"""Header carrying the signing time, in unix seconds."""
|
|
43
|
+
EVENT_ID_HEADER: Final = "x-flightbyte-event-id"
|
|
44
|
+
"""Header carrying the event id."""
|
|
45
|
+
EVENT_TYPE_HEADER: Final = "x-flightbyte-event-type"
|
|
46
|
+
"""Header carrying the event type."""
|
|
47
|
+
SUBSCRIPTION_ID_HEADER: Final = "x-flightbyte-subscription-id"
|
|
48
|
+
"""Header carrying the subscription id."""
|
|
49
|
+
DELIVERY_ATTEMPT_HEADER: Final = "x-flightbyte-delivery-attempt"
|
|
50
|
+
"""Header carrying the delivery attempt number, starting at 1."""
|
|
51
|
+
DEFAULT_TOLERANCE: Final = 300
|
|
52
|
+
"""How far the signing time may be from now by default, in seconds: 5 minutes."""
|
|
53
|
+
|
|
54
|
+
def __init__(self) -> None:
|
|
55
|
+
raise TypeError("flightbyte: Webhook only has static methods; call Webhook.verify(...).")
|
|
56
|
+
|
|
57
|
+
@staticmethod
|
|
58
|
+
def verify(
|
|
59
|
+
payload: WebhookPayload,
|
|
60
|
+
signature: str | None,
|
|
61
|
+
secret: str,
|
|
62
|
+
*,
|
|
63
|
+
tolerance: float = DEFAULT_TOLERANCE,
|
|
64
|
+
now: datetime | float | None = None,
|
|
65
|
+
) -> None:
|
|
66
|
+
"""Checks ``signature`` against the raw body ``payload`` and the subscription's ``secret``.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
payload: The body exactly as received. JSON parsed and serialised
|
|
70
|
+
again no longer matches the signature.
|
|
71
|
+
signature: The ``x-flightbyte-signature`` header, or ``None`` when
|
|
72
|
+
the request had none.
|
|
73
|
+
secret: The subscription's signing secret.
|
|
74
|
+
tolerance: How far the signing time may be from now, in seconds.
|
|
75
|
+
``0`` skips the check. Defaults to 300, as on the API.
|
|
76
|
+
now: The time to check against instead of now, as a ``datetime``
|
|
77
|
+
or unix seconds. Useful in tests.
|
|
78
|
+
|
|
79
|
+
Raises:
|
|
80
|
+
WebhookSignatureError: The signature does not verify.
|
|
81
|
+
ValueError: ``secret`` is empty. Anyone can sign with an empty
|
|
82
|
+
key, so it usually means the secret was not configured.
|
|
83
|
+
"""
|
|
84
|
+
key = _secret_key(secret)
|
|
85
|
+
body = _payload_bytes(payload)
|
|
86
|
+
timestamp, signatures = _parse_signature(signature)
|
|
87
|
+
|
|
88
|
+
if tolerance > 0:
|
|
89
|
+
current = math.floor(_seconds(now) if now is not None else time.time())
|
|
90
|
+
signed_at = int(timestamp)
|
|
91
|
+
# Compared against both edges, so an extreme timestamp cannot slip through.
|
|
92
|
+
if signed_at < current - tolerance or signed_at > current + tolerance:
|
|
93
|
+
raise WebhookSignatureError(
|
|
94
|
+
"The signature timestamp is outside the tolerance window."
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
expected = _hmac_hex(key, timestamp, body).encode("ascii")
|
|
98
|
+
matched = False
|
|
99
|
+
for candidate in signatures:
|
|
100
|
+
# Every candidate is compared, in constant time, so the time taken
|
|
101
|
+
# says nothing about which one matched or how much of it did.
|
|
102
|
+
if _SIGNATURE.fullmatch(candidate) and hmac.compare_digest(
|
|
103
|
+
candidate.encode("ascii"), expected
|
|
104
|
+
):
|
|
105
|
+
matched = True
|
|
106
|
+
if not matched:
|
|
107
|
+
raise WebhookSignatureError("The signature does not match the payload.")
|
|
108
|
+
|
|
109
|
+
@staticmethod
|
|
110
|
+
def is_valid(
|
|
111
|
+
payload: WebhookPayload,
|
|
112
|
+
signature: str | None,
|
|
113
|
+
secret: str,
|
|
114
|
+
*,
|
|
115
|
+
tolerance: float = DEFAULT_TOLERANCE,
|
|
116
|
+
now: datetime | float | None = None,
|
|
117
|
+
) -> bool:
|
|
118
|
+
"""Whether ``signature`` verifies; see ``Webhook.verify``.
|
|
119
|
+
|
|
120
|
+
Raises:
|
|
121
|
+
ValueError: ``secret`` is empty.
|
|
122
|
+
"""
|
|
123
|
+
try:
|
|
124
|
+
Webhook.verify(payload, signature, secret, tolerance=tolerance, now=now)
|
|
125
|
+
except WebhookSignatureError:
|
|
126
|
+
return False
|
|
127
|
+
return True
|
|
128
|
+
|
|
129
|
+
@staticmethod
|
|
130
|
+
def construct_event(
|
|
131
|
+
payload: WebhookPayload,
|
|
132
|
+
signature: str | None,
|
|
133
|
+
secret: str,
|
|
134
|
+
*,
|
|
135
|
+
tolerance: float = DEFAULT_TOLERANCE,
|
|
136
|
+
now: datetime | float | None = None,
|
|
137
|
+
) -> WebhookEvent:
|
|
138
|
+
"""Verifies the signature, then decodes the event.
|
|
139
|
+
|
|
140
|
+
Raises:
|
|
141
|
+
WebhookSignatureError: The signature does not verify.
|
|
142
|
+
FlightByteError: The verified body is not a webhook event.
|
|
143
|
+
ValueError: ``secret`` is empty.
|
|
144
|
+
"""
|
|
145
|
+
Webhook.verify(payload, signature, secret, tolerance=tolerance, now=now)
|
|
146
|
+
try:
|
|
147
|
+
event = json.loads(_payload_bytes(payload))
|
|
148
|
+
except ValueError as error:
|
|
149
|
+
raise FlightByteError("The webhook body is not JSON.") from error
|
|
150
|
+
if (
|
|
151
|
+
not isinstance(event, dict)
|
|
152
|
+
or not isinstance(event.get("id"), str)
|
|
153
|
+
or not isinstance(event.get("type"), str)
|
|
154
|
+
):
|
|
155
|
+
raise FlightByteError("The webhook body is not a FlightByte event.")
|
|
156
|
+
return cast(WebhookEvent, event)
|
|
157
|
+
|
|
158
|
+
@staticmethod
|
|
159
|
+
def sign(
|
|
160
|
+
payload: WebhookPayload,
|
|
161
|
+
secret: str,
|
|
162
|
+
timestamp: datetime | float | None = None,
|
|
163
|
+
) -> str:
|
|
164
|
+
"""Signs ``payload`` the way the API does and returns the ``x-flightbyte-signature`` value.
|
|
165
|
+
|
|
166
|
+
``timestamp`` is the signing time, as a ``datetime`` or unix seconds;
|
|
167
|
+
it defaults to now. Useful for testing your receiver.
|
|
168
|
+
|
|
169
|
+
Raises:
|
|
170
|
+
ValueError: ``secret`` is empty.
|
|
171
|
+
"""
|
|
172
|
+
key = _secret_key(secret)
|
|
173
|
+
body = _payload_bytes(payload)
|
|
174
|
+
seconds = str(math.floor(_seconds(timestamp) if timestamp is not None else time.time()))
|
|
175
|
+
return f"t={seconds},v1={_hmac_hex(key, seconds, body)}"
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _parse_signature(header: str | None) -> tuple[str, list[str]]:
|
|
179
|
+
"""Splits ``t=<seconds>,v1=<hex>[,v1=<hex>...]``."""
|
|
180
|
+
if not isinstance(header, str) or not header.strip():
|
|
181
|
+
raise WebhookSignatureError("The signature header is missing.")
|
|
182
|
+
timestamp: str | None = None
|
|
183
|
+
signatures: list[str] = []
|
|
184
|
+
for part in header.split(","):
|
|
185
|
+
index = part.find("=")
|
|
186
|
+
if index <= 0:
|
|
187
|
+
continue
|
|
188
|
+
key = part[:index].strip()
|
|
189
|
+
value = part[index + 1 :].strip()
|
|
190
|
+
if key == "t":
|
|
191
|
+
timestamp = value
|
|
192
|
+
elif key == "v1":
|
|
193
|
+
signatures.append(value.lower())
|
|
194
|
+
# [0-9] and not \d, which also matches digits from other scripts.
|
|
195
|
+
if timestamp is None or not _TIMESTAMP.fullmatch(timestamp):
|
|
196
|
+
raise WebhookSignatureError("The signature header has no valid timestamp.")
|
|
197
|
+
if not signatures:
|
|
198
|
+
raise WebhookSignatureError("The signature header has no v1 signature.")
|
|
199
|
+
return timestamp, signatures
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _secret_key(secret: str) -> bytes:
|
|
203
|
+
if not isinstance(secret, str):
|
|
204
|
+
raise TypeError("flightbyte: the webhook signing secret must be a string.")
|
|
205
|
+
if not secret.strip():
|
|
206
|
+
raise ValueError(
|
|
207
|
+
"flightbyte: the webhook signing secret is empty. Anyone can sign with an "
|
|
208
|
+
"empty key; set the subscription's secret."
|
|
209
|
+
)
|
|
210
|
+
return secret.encode("utf-8")
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _payload_bytes(payload: WebhookPayload) -> bytes:
|
|
214
|
+
if isinstance(payload, str):
|
|
215
|
+
return payload.encode("utf-8")
|
|
216
|
+
if isinstance(payload, (bytes, bytearray, memoryview)):
|
|
217
|
+
return bytes(payload)
|
|
218
|
+
raise TypeError(
|
|
219
|
+
"flightbyte: the webhook payload must be the raw body as bytes or str, "
|
|
220
|
+
f"not {type(payload).__name__}. Pass the body before any JSON parsing."
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _hmac_hex(key: bytes, timestamp: str, body: bytes) -> str:
|
|
225
|
+
return hmac.new(key, timestamp.encode("ascii") + b"." + body, hashlib.sha256).hexdigest()
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _seconds(value: datetime | float) -> float:
|
|
229
|
+
if isinstance(value, datetime):
|
|
230
|
+
return value.timestamp()
|
|
231
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
232
|
+
raise TypeError("flightbyte: a time must be a datetime or unix seconds.")
|
|
233
|
+
return float(value)
|
flightbyte/py.typed
ADDED
|
File without changes
|