meterbase 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.
- meterbase/__init__.py +59 -0
- meterbase/_async/__init__.py +0 -0
- meterbase/_async/_io.py +22 -0
- meterbase/_async/_transport.py +181 -0
- meterbase/_async/client.py +427 -0
- meterbase/_async/resources/__init__.py +16 -0
- meterbase/_async/resources/customer_allowances.py +89 -0
- meterbase/_async/resources/customer_periods.py +41 -0
- meterbase/_async/resources/customer_plan.py +228 -0
- meterbase/_async/resources/customers.py +176 -0
- meterbase/_async/resources/meters.py +102 -0
- meterbase/_async/resources/plans.py +140 -0
- meterbase/_http.py +205 -0
- meterbase/_json.py +18 -0
- meterbase/_keys.py +39 -0
- meterbase/_models.py +177 -0
- meterbase/_not_given.py +32 -0
- meterbase/_sync/__init__.py +4 -0
- meterbase/_sync/_io.py +21 -0
- meterbase/_sync/_transport.py +185 -0
- meterbase/_sync/client.py +431 -0
- meterbase/_sync/resources/__init__.py +20 -0
- meterbase/_sync/resources/customer_allowances.py +93 -0
- meterbase/_sync/resources/customer_periods.py +45 -0
- meterbase/_sync/resources/customer_plan.py +230 -0
- meterbase/_sync/resources/customers.py +176 -0
- meterbase/_sync/resources/meters.py +106 -0
- meterbase/_sync/resources/plans.py +144 -0
- meterbase/_time.py +49 -0
- meterbase/_version.py +1 -0
- meterbase/errors.py +309 -0
- meterbase/py.typed +0 -0
- meterbase/resources.py +41 -0
- meterbase/types.py +600 -0
- meterbase/webhooks.py +154 -0
- meterbase-0.1.0.dist-info/METADATA +867 -0
- meterbase-0.1.0.dist-info/RECORD +39 -0
- meterbase-0.1.0.dist-info/WHEEL +4 -0
- meterbase-0.1.0.dist-info/licenses/LICENSE +21 -0
meterbase/webhooks.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import binascii
|
|
5
|
+
import hashlib
|
|
6
|
+
import hmac
|
|
7
|
+
import json
|
|
8
|
+
import math
|
|
9
|
+
import time
|
|
10
|
+
from collections.abc import Iterable, Sequence
|
|
11
|
+
from typing import Any, Protocol, cast
|
|
12
|
+
|
|
13
|
+
from ._json import is_object
|
|
14
|
+
from .errors import MeterbaseError, WebhookVerificationError
|
|
15
|
+
from .types import EntitlementReachedEvent, ThresholdCrossedEvent, WebhookEvent
|
|
16
|
+
|
|
17
|
+
__all__ = ["WebhookHeaders", "verify_webhook"]
|
|
18
|
+
|
|
19
|
+
SECRET_PREFIX = "whsec_"
|
|
20
|
+
DEFAULT_TOLERANCE_SECONDS = 5 * 60
|
|
21
|
+
|
|
22
|
+
_EVENTS: dict[str, type[WebhookEvent]] = {
|
|
23
|
+
"threshold.crossed": ThresholdCrossedEvent,
|
|
24
|
+
"entitlement.reached": EntitlementReachedEvent,
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class WebhookHeaders(Protocol):
|
|
29
|
+
"""
|
|
30
|
+
A request's headers, as a web framework hands them over: Django's
|
|
31
|
+
`request.headers`, Flask's, Starlette's, or a plain `dict`. Names are
|
|
32
|
+
matched without regard to case.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def get(self, key: str, /) -> Any: ...
|
|
36
|
+
|
|
37
|
+
def items(self) -> Iterable[tuple[Any, Any]]: ...
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def verify_webhook(
|
|
41
|
+
*,
|
|
42
|
+
payload: str | bytes,
|
|
43
|
+
headers: WebhookHeaders,
|
|
44
|
+
secret: str | Sequence[str],
|
|
45
|
+
tolerance_seconds: float = DEFAULT_TOLERANCE_SECONDS,
|
|
46
|
+
) -> WebhookEvent:
|
|
47
|
+
"""
|
|
48
|
+
Checks a delivery's signature and timestamp the Standard Webhooks way, then
|
|
49
|
+
returns the event. Raises `WebhookVerificationError` when it is not one
|
|
50
|
+
Meterbase signed recently.
|
|
51
|
+
|
|
52
|
+
`payload` is the body exactly as received: re-serialized JSON will not
|
|
53
|
+
verify. `secret` is the endpoint's `whsec_…` secret; pass both while
|
|
54
|
+
rotating. `tolerance_seconds` is how far `webhook-timestamp` may be from
|
|
55
|
+
now.
|
|
56
|
+
|
|
57
|
+
The event is a `ThresholdCrossedEvent` or an `EntitlementReachedEvent`, so
|
|
58
|
+
`isinstance` tells them apart. A type this release does not know reads as
|
|
59
|
+
a plain `WebhookEvent`: new types can be added, so ignore any you do not
|
|
60
|
+
handle.
|
|
61
|
+
"""
|
|
62
|
+
msg_id = _header(headers, "webhook-id")
|
|
63
|
+
timestamp = _header(headers, "webhook-timestamp")
|
|
64
|
+
signatures = _header(headers, "webhook-signature")
|
|
65
|
+
if not msg_id or not timestamp or not signatures:
|
|
66
|
+
raise WebhookVerificationError(
|
|
67
|
+
"Missing a webhook-id, webhook-timestamp or webhook-signature header."
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
sent_at = _whole_number(timestamp)
|
|
71
|
+
if sent_at is None or abs(time.time() - sent_at) > tolerance_seconds:
|
|
72
|
+
raise WebhookVerificationError(
|
|
73
|
+
"The webhook-timestamp is too far from now; this may be a replay."
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
body = payload.encode("utf-8") if isinstance(payload, str) else bytes(payload)
|
|
77
|
+
signed = f"{msg_id}.{timestamp}.".encode() + body
|
|
78
|
+
candidates = [
|
|
79
|
+
_from_base64(entry[len("v1,") :])
|
|
80
|
+
for entry in signatures.split(" ")
|
|
81
|
+
if entry.startswith("v1,")
|
|
82
|
+
]
|
|
83
|
+
|
|
84
|
+
for each in [secret] if isinstance(secret, str) else secret:
|
|
85
|
+
key = (
|
|
86
|
+
_from_base64(each[len(SECRET_PREFIX) :])
|
|
87
|
+
if each.startswith(SECRET_PREFIX)
|
|
88
|
+
else None
|
|
89
|
+
)
|
|
90
|
+
if not key:
|
|
91
|
+
raise WebhookVerificationError(
|
|
92
|
+
"The secret should be whsec_ and base64: copy it from the "
|
|
93
|
+
"endpoint's settings."
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
expected = hmac.new(key, signed, hashlib.sha256).digest()
|
|
97
|
+
for candidate in candidates:
|
|
98
|
+
# `compare_digest` compares in constant time.
|
|
99
|
+
if candidate is not None and hmac.compare_digest(candidate, expected):
|
|
100
|
+
return _event(body)
|
|
101
|
+
|
|
102
|
+
raise WebhookVerificationError(
|
|
103
|
+
"No signature matches: the body changed, or this is the wrong secret."
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _header(headers: WebhookHeaders, name: str) -> str | None:
|
|
108
|
+
value: object = headers.get(name)
|
|
109
|
+
if value is None:
|
|
110
|
+
# A plain dict matches case exactly, and some frameworks title-case.
|
|
111
|
+
value = next(
|
|
112
|
+
(v for k, v in headers.items() if isinstance(k, str) and k.lower() == name),
|
|
113
|
+
None,
|
|
114
|
+
)
|
|
115
|
+
if isinstance(value, (list, tuple)):
|
|
116
|
+
values = cast("Sequence[object]", value)
|
|
117
|
+
value = values[0] if values else None
|
|
118
|
+
if isinstance(value, bytes):
|
|
119
|
+
value = value.decode("latin-1")
|
|
120
|
+
return value if isinstance(value, str) else None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _whole_number(text: str) -> float | None:
|
|
124
|
+
try:
|
|
125
|
+
value = float(text)
|
|
126
|
+
except ValueError:
|
|
127
|
+
return None
|
|
128
|
+
return value if math.isfinite(value) and value.is_integer() else None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _from_base64(text: str) -> bytes | None:
|
|
132
|
+
try:
|
|
133
|
+
# Padding restored first, as a browser's `atob` would.
|
|
134
|
+
return base64.b64decode(text + "=" * (-len(text) % 4), validate=True)
|
|
135
|
+
except (binascii.Error, ValueError):
|
|
136
|
+
return None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _event(body: bytes) -> WebhookEvent:
|
|
140
|
+
try:
|
|
141
|
+
data = json.loads(body)
|
|
142
|
+
except ValueError as error:
|
|
143
|
+
raise MeterbaseError(
|
|
144
|
+
"The webhook is signed, but its body is not JSON."
|
|
145
|
+
) from error
|
|
146
|
+
|
|
147
|
+
if not is_object(data):
|
|
148
|
+
raise MeterbaseError(
|
|
149
|
+
"The webhook is signed, but its body is not a JSON object."
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
kind = data.get("type")
|
|
153
|
+
event = _EVENTS.get(kind, WebhookEvent) if isinstance(kind, str) else WebhookEvent
|
|
154
|
+
return event(data)
|