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.
@@ -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
@@ -0,0 +1,3 @@
1
+ """This package's version. It is sent in the ``user-agent`` header of every request."""
2
+
3
+ __version__ = "1.0.0"
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