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.
Files changed (39) hide show
  1. meterbase/__init__.py +59 -0
  2. meterbase/_async/__init__.py +0 -0
  3. meterbase/_async/_io.py +22 -0
  4. meterbase/_async/_transport.py +181 -0
  5. meterbase/_async/client.py +427 -0
  6. meterbase/_async/resources/__init__.py +16 -0
  7. meterbase/_async/resources/customer_allowances.py +89 -0
  8. meterbase/_async/resources/customer_periods.py +41 -0
  9. meterbase/_async/resources/customer_plan.py +228 -0
  10. meterbase/_async/resources/customers.py +176 -0
  11. meterbase/_async/resources/meters.py +102 -0
  12. meterbase/_async/resources/plans.py +140 -0
  13. meterbase/_http.py +205 -0
  14. meterbase/_json.py +18 -0
  15. meterbase/_keys.py +39 -0
  16. meterbase/_models.py +177 -0
  17. meterbase/_not_given.py +32 -0
  18. meterbase/_sync/__init__.py +4 -0
  19. meterbase/_sync/_io.py +21 -0
  20. meterbase/_sync/_transport.py +185 -0
  21. meterbase/_sync/client.py +431 -0
  22. meterbase/_sync/resources/__init__.py +20 -0
  23. meterbase/_sync/resources/customer_allowances.py +93 -0
  24. meterbase/_sync/resources/customer_periods.py +45 -0
  25. meterbase/_sync/resources/customer_plan.py +230 -0
  26. meterbase/_sync/resources/customers.py +176 -0
  27. meterbase/_sync/resources/meters.py +106 -0
  28. meterbase/_sync/resources/plans.py +144 -0
  29. meterbase/_time.py +49 -0
  30. meterbase/_version.py +1 -0
  31. meterbase/errors.py +309 -0
  32. meterbase/py.typed +0 -0
  33. meterbase/resources.py +41 -0
  34. meterbase/types.py +600 -0
  35. meterbase/webhooks.py +154 -0
  36. meterbase-0.1.0.dist-info/METADATA +867 -0
  37. meterbase-0.1.0.dist-info/RECORD +39 -0
  38. meterbase-0.1.0.dist-info/WHEEL +4 -0
  39. 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)