driftstack-sdk 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.
- driftstack/__init__.py +60 -0
- driftstack/_generated/__init__.py +6 -0
- driftstack/_generated/models.py +494 -0
- driftstack/_version.py +10 -0
- driftstack/client.py +120 -0
- driftstack/errors.py +194 -0
- driftstack/http.py +291 -0
- driftstack/py.typed +0 -0
- driftstack/resources/__init__.py +29 -0
- driftstack/resources/_common.py +38 -0
- driftstack/resources/api_keys.py +61 -0
- driftstack/resources/sessions.py +156 -0
- driftstack/resources/usage.py +29 -0
- driftstack/resources/webhooks.py +110 -0
- driftstack/retry.py +105 -0
- driftstack/webhook_signature.py +106 -0
- driftstack_sdk-0.1.0.dist-info/METADATA +226 -0
- driftstack_sdk-0.1.0.dist-info/RECORD +19 -0
- driftstack_sdk-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Sessions resource.
|
|
2
|
+
|
|
3
|
+
Wraps every ``/v1/sessions[/...]`` route. Both sync and async variants
|
|
4
|
+
share the URL/parameter shapes; the only difference is which HTTP
|
|
5
|
+
client they call.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
from urllib.parse import quote
|
|
12
|
+
|
|
13
|
+
from pydantic import BaseModel
|
|
14
|
+
|
|
15
|
+
from driftstack._generated.models import (
|
|
16
|
+
CaptureRequest,
|
|
17
|
+
CaptureResponse,
|
|
18
|
+
CreateSessionRequest,
|
|
19
|
+
CreateSessionResponse,
|
|
20
|
+
InteractRequest,
|
|
21
|
+
InteractResponse,
|
|
22
|
+
NavigateRequest,
|
|
23
|
+
NavigateResponse,
|
|
24
|
+
PaginationQuery,
|
|
25
|
+
Session,
|
|
26
|
+
SessionState,
|
|
27
|
+
WaitRequest,
|
|
28
|
+
WaitResponse,
|
|
29
|
+
)
|
|
30
|
+
from driftstack.http import AsyncHttpClient, HttpClient
|
|
31
|
+
from driftstack.resources._common import coerce_body, coerce_query
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class SessionsListPage(BaseModel):
|
|
35
|
+
"""Paginated list of sessions returned by ``GET /v1/sessions``."""
|
|
36
|
+
|
|
37
|
+
data: list[Session]
|
|
38
|
+
has_more: bool
|
|
39
|
+
next_cursor: str | None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _session_path(session_id: str, suffix: str = "") -> str:
|
|
43
|
+
return f"/v1/sessions/{quote(session_id, safe='')}{suffix}"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class SessionsResource:
|
|
47
|
+
"""Synchronous sessions resource."""
|
|
48
|
+
|
|
49
|
+
def __init__(self, http: HttpClient) -> None:
|
|
50
|
+
self._http = http
|
|
51
|
+
|
|
52
|
+
def create(
|
|
53
|
+
self, body: CreateSessionRequest | dict[str, Any] | None = None
|
|
54
|
+
) -> CreateSessionResponse:
|
|
55
|
+
"""Create a new session. Returns the new ``Session`` row."""
|
|
56
|
+
data = self._http.request("POST", "/v1/sessions", json_body=coerce_body(body) or {})
|
|
57
|
+
return CreateSessionResponse.model_validate(data)
|
|
58
|
+
|
|
59
|
+
def list(self, query: PaginationQuery | dict[str, Any] | None = None) -> SessionsListPage:
|
|
60
|
+
"""List sessions for the current account, newest first."""
|
|
61
|
+
data = self._http.request("GET", "/v1/sessions", params=coerce_query(query))
|
|
62
|
+
return SessionsListPage.model_validate(data)
|
|
63
|
+
|
|
64
|
+
def get(self, session_id: str) -> Session:
|
|
65
|
+
data = self._http.request("GET", _session_path(session_id))
|
|
66
|
+
return Session.model_validate(data)
|
|
67
|
+
|
|
68
|
+
def navigate(self, session_id: str, body: NavigateRequest | dict[str, Any]) -> NavigateResponse:
|
|
69
|
+
data = self._http.request(
|
|
70
|
+
"POST", _session_path(session_id, "/navigate"), json_body=coerce_body(body)
|
|
71
|
+
)
|
|
72
|
+
return NavigateResponse.model_validate(data)
|
|
73
|
+
|
|
74
|
+
def interact(self, session_id: str, body: InteractRequest | dict[str, Any]) -> InteractResponse:
|
|
75
|
+
data = self._http.request(
|
|
76
|
+
"POST", _session_path(session_id, "/interact"), json_body=coerce_body(body)
|
|
77
|
+
)
|
|
78
|
+
return InteractResponse.model_validate(data)
|
|
79
|
+
|
|
80
|
+
def wait(self, session_id: str, body: WaitRequest | dict[str, Any]) -> WaitResponse:
|
|
81
|
+
data = self._http.request(
|
|
82
|
+
"POST", _session_path(session_id, "/wait"), json_body=coerce_body(body)
|
|
83
|
+
)
|
|
84
|
+
return WaitResponse.model_validate(data)
|
|
85
|
+
|
|
86
|
+
def get_state(self, session_id: str) -> SessionState:
|
|
87
|
+
data = self._http.request("GET", _session_path(session_id, "/state"))
|
|
88
|
+
return SessionState.model_validate(data)
|
|
89
|
+
|
|
90
|
+
def capture(self, session_id: str, body: CaptureRequest | dict[str, Any]) -> CaptureResponse:
|
|
91
|
+
data = self._http.request(
|
|
92
|
+
"POST", _session_path(session_id, "/capture"), json_body=coerce_body(body)
|
|
93
|
+
)
|
|
94
|
+
return CaptureResponse.model_validate(data)
|
|
95
|
+
|
|
96
|
+
def destroy(self, session_id: str) -> None:
|
|
97
|
+
"""Destroy the session. Idempotent (safe to call twice)."""
|
|
98
|
+
self._http.request("DELETE", _session_path(session_id))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class AsyncSessionsResource:
|
|
102
|
+
"""Async sessions resource. Mirrors :class:`SessionsResource`."""
|
|
103
|
+
|
|
104
|
+
def __init__(self, http: AsyncHttpClient) -> None:
|
|
105
|
+
self._http = http
|
|
106
|
+
|
|
107
|
+
async def create(
|
|
108
|
+
self, body: CreateSessionRequest | dict[str, Any] | None = None
|
|
109
|
+
) -> CreateSessionResponse:
|
|
110
|
+
data = await self._http.request("POST", "/v1/sessions", json_body=coerce_body(body) or {})
|
|
111
|
+
return CreateSessionResponse.model_validate(data)
|
|
112
|
+
|
|
113
|
+
async def list(self, query: PaginationQuery | dict[str, Any] | None = None) -> SessionsListPage:
|
|
114
|
+
data = await self._http.request("GET", "/v1/sessions", params=coerce_query(query))
|
|
115
|
+
return SessionsListPage.model_validate(data)
|
|
116
|
+
|
|
117
|
+
async def get(self, session_id: str) -> Session:
|
|
118
|
+
data = await self._http.request("GET", _session_path(session_id))
|
|
119
|
+
return Session.model_validate(data)
|
|
120
|
+
|
|
121
|
+
async def navigate(
|
|
122
|
+
self, session_id: str, body: NavigateRequest | dict[str, Any]
|
|
123
|
+
) -> NavigateResponse:
|
|
124
|
+
data = await self._http.request(
|
|
125
|
+
"POST", _session_path(session_id, "/navigate"), json_body=coerce_body(body)
|
|
126
|
+
)
|
|
127
|
+
return NavigateResponse.model_validate(data)
|
|
128
|
+
|
|
129
|
+
async def interact(
|
|
130
|
+
self, session_id: str, body: InteractRequest | dict[str, Any]
|
|
131
|
+
) -> InteractResponse:
|
|
132
|
+
data = await self._http.request(
|
|
133
|
+
"POST", _session_path(session_id, "/interact"), json_body=coerce_body(body)
|
|
134
|
+
)
|
|
135
|
+
return InteractResponse.model_validate(data)
|
|
136
|
+
|
|
137
|
+
async def wait(self, session_id: str, body: WaitRequest | dict[str, Any]) -> WaitResponse:
|
|
138
|
+
data = await self._http.request(
|
|
139
|
+
"POST", _session_path(session_id, "/wait"), json_body=coerce_body(body)
|
|
140
|
+
)
|
|
141
|
+
return WaitResponse.model_validate(data)
|
|
142
|
+
|
|
143
|
+
async def get_state(self, session_id: str) -> SessionState:
|
|
144
|
+
data = await self._http.request("GET", _session_path(session_id, "/state"))
|
|
145
|
+
return SessionState.model_validate(data)
|
|
146
|
+
|
|
147
|
+
async def capture(
|
|
148
|
+
self, session_id: str, body: CaptureRequest | dict[str, Any]
|
|
149
|
+
) -> CaptureResponse:
|
|
150
|
+
data = await self._http.request(
|
|
151
|
+
"POST", _session_path(session_id, "/capture"), json_body=coerce_body(body)
|
|
152
|
+
)
|
|
153
|
+
return CaptureResponse.model_validate(data)
|
|
154
|
+
|
|
155
|
+
async def destroy(self, session_id: str) -> None:
|
|
156
|
+
await self._http.request("DELETE", _session_path(session_id))
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Usage resource — /v1/usage."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from driftstack._generated.models import UsagePeriodSummary
|
|
6
|
+
from driftstack.http import AsyncHttpClient, HttpClient
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class UsageResource:
|
|
10
|
+
"""Synchronous usage resource."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, http: HttpClient) -> None:
|
|
13
|
+
self._http = http
|
|
14
|
+
|
|
15
|
+
def current_period(self) -> UsagePeriodSummary:
|
|
16
|
+
"""Current calendar-month UTC totals + tier quotas."""
|
|
17
|
+
data = self._http.request("GET", "/v1/usage")
|
|
18
|
+
return UsagePeriodSummary.model_validate(data)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class AsyncUsageResource:
|
|
22
|
+
"""Async usage resource."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, http: AsyncHttpClient) -> None:
|
|
25
|
+
self._http = http
|
|
26
|
+
|
|
27
|
+
async def current_period(self) -> UsagePeriodSummary:
|
|
28
|
+
data = await self._http.request("GET", "/v1/usage")
|
|
29
|
+
return UsagePeriodSummary.model_validate(data)
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Webhooks resource — /v1/webhooks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
from urllib.parse import quote
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
|
|
10
|
+
from driftstack._generated.models import (
|
|
11
|
+
CreateWebhookRequest,
|
|
12
|
+
CreateWebhookResponse,
|
|
13
|
+
ListDeliveriesQuery,
|
|
14
|
+
WebhookDelivery,
|
|
15
|
+
WebhookEndpoint,
|
|
16
|
+
)
|
|
17
|
+
from driftstack.http import AsyncHttpClient, HttpClient
|
|
18
|
+
from driftstack.resources._common import coerce_body, coerce_query
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class WebhookEndpointList(BaseModel):
|
|
22
|
+
"""Response shape for ``GET /v1/webhooks``."""
|
|
23
|
+
|
|
24
|
+
data: list[WebhookEndpoint]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class WebhookDeliveryListPage(BaseModel):
|
|
28
|
+
"""Response shape for ``GET /v1/webhooks/{id}/deliveries``."""
|
|
29
|
+
|
|
30
|
+
data: list[WebhookDelivery]
|
|
31
|
+
has_more: bool
|
|
32
|
+
next_cursor: str | None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _webhook_path(webhook_id: str, suffix: str = "") -> str:
|
|
36
|
+
return f"/v1/webhooks/{quote(webhook_id, safe='')}{suffix}"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class WebhooksResource:
|
|
40
|
+
"""Synchronous webhooks resource."""
|
|
41
|
+
|
|
42
|
+
def __init__(self, http: HttpClient) -> None:
|
|
43
|
+
self._http = http
|
|
44
|
+
|
|
45
|
+
def create(self, body: CreateWebhookRequest | dict[str, Any]) -> CreateWebhookResponse:
|
|
46
|
+
"""Create a webhook subscription.
|
|
47
|
+
|
|
48
|
+
Plaintext signing secret is returned ONCE; store it now — it
|
|
49
|
+
cannot be retrieved later. Requires the ``admin`` scope.
|
|
50
|
+
"""
|
|
51
|
+
data = self._http.request("POST", "/v1/webhooks", json_body=coerce_body(body))
|
|
52
|
+
return CreateWebhookResponse.model_validate(data)
|
|
53
|
+
|
|
54
|
+
def list(self) -> WebhookEndpointList:
|
|
55
|
+
data = self._http.request("GET", "/v1/webhooks")
|
|
56
|
+
return WebhookEndpointList.model_validate(data)
|
|
57
|
+
|
|
58
|
+
def get(self, webhook_id: str) -> WebhookEndpoint:
|
|
59
|
+
data = self._http.request("GET", _webhook_path(webhook_id))
|
|
60
|
+
return WebhookEndpoint.model_validate(data)
|
|
61
|
+
|
|
62
|
+
def delete(self, webhook_id: str) -> None:
|
|
63
|
+
"""Soft-delete (disable) the endpoint. Idempotent."""
|
|
64
|
+
self._http.request("DELETE", _webhook_path(webhook_id))
|
|
65
|
+
|
|
66
|
+
def list_deliveries(
|
|
67
|
+
self,
|
|
68
|
+
webhook_id: str,
|
|
69
|
+
query: ListDeliveriesQuery | dict[str, Any] | None = None,
|
|
70
|
+
) -> WebhookDeliveryListPage:
|
|
71
|
+
data = self._http.request(
|
|
72
|
+
"GET",
|
|
73
|
+
_webhook_path(webhook_id, "/deliveries"),
|
|
74
|
+
params=coerce_query(query),
|
|
75
|
+
)
|
|
76
|
+
return WebhookDeliveryListPage.model_validate(data)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class AsyncWebhooksResource:
|
|
80
|
+
"""Async webhooks resource."""
|
|
81
|
+
|
|
82
|
+
def __init__(self, http: AsyncHttpClient) -> None:
|
|
83
|
+
self._http = http
|
|
84
|
+
|
|
85
|
+
async def create(self, body: CreateWebhookRequest | dict[str, Any]) -> CreateWebhookResponse:
|
|
86
|
+
data = await self._http.request("POST", "/v1/webhooks", json_body=coerce_body(body))
|
|
87
|
+
return CreateWebhookResponse.model_validate(data)
|
|
88
|
+
|
|
89
|
+
async def list(self) -> WebhookEndpointList:
|
|
90
|
+
data = await self._http.request("GET", "/v1/webhooks")
|
|
91
|
+
return WebhookEndpointList.model_validate(data)
|
|
92
|
+
|
|
93
|
+
async def get(self, webhook_id: str) -> WebhookEndpoint:
|
|
94
|
+
data = await self._http.request("GET", _webhook_path(webhook_id))
|
|
95
|
+
return WebhookEndpoint.model_validate(data)
|
|
96
|
+
|
|
97
|
+
async def delete(self, webhook_id: str) -> None:
|
|
98
|
+
await self._http.request("DELETE", _webhook_path(webhook_id))
|
|
99
|
+
|
|
100
|
+
async def list_deliveries(
|
|
101
|
+
self,
|
|
102
|
+
webhook_id: str,
|
|
103
|
+
query: ListDeliveriesQuery | dict[str, Any] | None = None,
|
|
104
|
+
) -> WebhookDeliveryListPage:
|
|
105
|
+
data = await self._http.request(
|
|
106
|
+
"GET",
|
|
107
|
+
_webhook_path(webhook_id, "/deliveries"),
|
|
108
|
+
params=coerce_query(query),
|
|
109
|
+
)
|
|
110
|
+
return WebhookDeliveryListPage.model_validate(data)
|
driftstack/retry.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Exponential-backoff retry policy with full jitter.
|
|
2
|
+
|
|
3
|
+
Mirrors `packages/sdk-typescript/src/retry.ts`. Honours `Retry-After`
|
|
4
|
+
when the server set one (the SDK's HTTP layer maps it onto the
|
|
5
|
+
RateLimitError before retry decides). Idempotent or read-shaped
|
|
6
|
+
methods are retried; mutating methods that lack server-side idempotency
|
|
7
|
+
keys are NOT retried by default — callers can opt in via the
|
|
8
|
+
``retry`` argument on the HTTP client.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import random
|
|
14
|
+
import time
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from typing import TypeVar
|
|
18
|
+
|
|
19
|
+
from driftstack.errors import (
|
|
20
|
+
DriftstackError,
|
|
21
|
+
RateLimitError,
|
|
22
|
+
TransportError,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
T = TypeVar("T")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class RetryConfig:
|
|
30
|
+
"""Tuning knobs for the retry loop. Defaults match the TypeScript SDK."""
|
|
31
|
+
|
|
32
|
+
max_retries: int = 3
|
|
33
|
+
initial_delay_ms: int = 200
|
|
34
|
+
max_delay_ms: int = 10_000
|
|
35
|
+
backoff_multiplier: float = 2.0
|
|
36
|
+
"""If True, retry on TransportError + RateLimitError. If False, never retry."""
|
|
37
|
+
enabled: bool = True
|
|
38
|
+
"""Errors that ARE retryable when retries are enabled."""
|
|
39
|
+
retryable_errors: tuple[type[BaseException], ...] = field(
|
|
40
|
+
default_factory=lambda: (TransportError, RateLimitError)
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _backoff_delay_ms(attempt: int, cfg: RetryConfig, retry_after_seconds: int | None) -> int:
|
|
45
|
+
"""Compute the next sleep with full jitter; cap at ``max_delay_ms``.
|
|
46
|
+
|
|
47
|
+
If the server set a ``Retry-After`` (rate-limit case), it wins —
|
|
48
|
+
we never retry sooner than the server asks. Otherwise it's
|
|
49
|
+
exponential-backoff with full jitter (random uniform between 0
|
|
50
|
+
and the next exponential value).
|
|
51
|
+
"""
|
|
52
|
+
if retry_after_seconds is not None:
|
|
53
|
+
return min(retry_after_seconds * 1000, cfg.max_delay_ms)
|
|
54
|
+
capped = min(cfg.initial_delay_ms * (cfg.backoff_multiplier**attempt), cfg.max_delay_ms)
|
|
55
|
+
return int(random.uniform(0, capped))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def with_retry(fn: Callable[[], T], cfg: RetryConfig | None = None) -> T:
|
|
59
|
+
"""Run ``fn`` with retries per ``cfg``. Synchronous variant."""
|
|
60
|
+
config = cfg or RetryConfig()
|
|
61
|
+
if not config.enabled:
|
|
62
|
+
return fn()
|
|
63
|
+
|
|
64
|
+
attempt = 0
|
|
65
|
+
while True:
|
|
66
|
+
try:
|
|
67
|
+
return fn()
|
|
68
|
+
except config.retryable_errors as err:
|
|
69
|
+
if attempt >= config.max_retries:
|
|
70
|
+
raise
|
|
71
|
+
retry_after = err.retry_after_seconds if isinstance(err, RateLimitError) else None
|
|
72
|
+
time.sleep(_backoff_delay_ms(attempt, config, retry_after) / 1000)
|
|
73
|
+
attempt += 1
|
|
74
|
+
except DriftstackError:
|
|
75
|
+
# Non-retryable typed error — propagate immediately.
|
|
76
|
+
raise
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
async def with_retry_async(
|
|
80
|
+
fn: Callable[[], _Awaitable[T]],
|
|
81
|
+
cfg: RetryConfig | None = None,
|
|
82
|
+
) -> T:
|
|
83
|
+
"""Run an async ``fn`` with retries. Mirrors :func:`with_retry`."""
|
|
84
|
+
import asyncio
|
|
85
|
+
|
|
86
|
+
config = cfg or RetryConfig()
|
|
87
|
+
if not config.enabled:
|
|
88
|
+
return await fn()
|
|
89
|
+
|
|
90
|
+
attempt = 0
|
|
91
|
+
while True:
|
|
92
|
+
try:
|
|
93
|
+
return await fn()
|
|
94
|
+
except config.retryable_errors as err:
|
|
95
|
+
if attempt >= config.max_retries:
|
|
96
|
+
raise
|
|
97
|
+
retry_after = err.retry_after_seconds if isinstance(err, RateLimitError) else None
|
|
98
|
+
await asyncio.sleep(_backoff_delay_ms(attempt, config, retry_after) / 1000)
|
|
99
|
+
attempt += 1
|
|
100
|
+
except DriftstackError:
|
|
101
|
+
raise
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# Forward-declare for the async type hint above.
|
|
105
|
+
from collections.abc import Awaitable as _Awaitable # noqa: E402
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Webhook signature verification helper.
|
|
2
|
+
|
|
3
|
+
Header format (Stripe-style): ``t=<unix-seconds>,v1=<hex hmac>``.
|
|
4
|
+
HMAC = HMAC-SHA256(``<unix-seconds>.<raw body>``, ``<secret>``).
|
|
5
|
+
|
|
6
|
+
Mirrors :func:`verifyWebhookSignature` from the TypeScript SDK so a
|
|
7
|
+
multi-language receiver fleet works against the same wire format.
|
|
8
|
+
|
|
9
|
+
Example::
|
|
10
|
+
|
|
11
|
+
from driftstack import verify_webhook_signature
|
|
12
|
+
|
|
13
|
+
@app.post("/driftstack-webhook")
|
|
14
|
+
def receive():
|
|
15
|
+
sig = request.headers["x-driftstack-signature"]
|
|
16
|
+
ok = verify_webhook_signature(
|
|
17
|
+
body=request.body, # bytes or str
|
|
18
|
+
header=sig,
|
|
19
|
+
secret=os.environ["DRIFTSTACK_WEBHOOK_SECRET"],
|
|
20
|
+
)
|
|
21
|
+
if not ok:
|
|
22
|
+
return Response(status=401)
|
|
23
|
+
# ... process event ...
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import hashlib
|
|
29
|
+
import hmac
|
|
30
|
+
import time
|
|
31
|
+
from dataclasses import dataclass
|
|
32
|
+
|
|
33
|
+
DEFAULT_TOLERANCE_SEC = 300
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class _ParsedSignature:
|
|
38
|
+
timestamp_seconds: int
|
|
39
|
+
signature_hex: str
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _parse_signature_header(header: str) -> _ParsedSignature | None:
|
|
43
|
+
"""Parse ``t=...,v1=...`` (order-independent). Return None on shape failure."""
|
|
44
|
+
timestamp: int | None = None
|
|
45
|
+
signature: str | None = None
|
|
46
|
+
for part in header.split(","):
|
|
47
|
+
eq_idx = part.find("=")
|
|
48
|
+
if eq_idx < 0:
|
|
49
|
+
continue
|
|
50
|
+
key = part[:eq_idx].strip()
|
|
51
|
+
value = part[eq_idx + 1 :].strip()
|
|
52
|
+
if key == "t":
|
|
53
|
+
try:
|
|
54
|
+
timestamp = int(value)
|
|
55
|
+
except ValueError:
|
|
56
|
+
continue
|
|
57
|
+
elif key == "v1":
|
|
58
|
+
signature = value
|
|
59
|
+
if timestamp is None or signature is None:
|
|
60
|
+
return None
|
|
61
|
+
return _ParsedSignature(timestamp_seconds=timestamp, signature_hex=signature)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def verify_webhook_signature(
|
|
65
|
+
*,
|
|
66
|
+
body: bytes | str,
|
|
67
|
+
header: str | None,
|
|
68
|
+
secret: str,
|
|
69
|
+
tolerance_sec: int = DEFAULT_TOLERANCE_SEC,
|
|
70
|
+
now_seconds: float | None = None,
|
|
71
|
+
) -> bool:
|
|
72
|
+
"""Verify an inbound webhook signature header.
|
|
73
|
+
|
|
74
|
+
Returns ``True`` iff the header is well-formed, the timestamp is
|
|
75
|
+
within ``tolerance_sec`` of now, and the HMAC matches in
|
|
76
|
+
constant time. Returns ``False`` on any failure mode — never
|
|
77
|
+
raises.
|
|
78
|
+
|
|
79
|
+
``body`` must be the EXACT raw bytes the server signed. If your
|
|
80
|
+
framework re-encodes JSON before passing it to your handler,
|
|
81
|
+
you'll need to use a raw-body access path (Flask:
|
|
82
|
+
``request.get_data()``; FastAPI: ``await request.body()``;
|
|
83
|
+
Django: ``request.body``).
|
|
84
|
+
"""
|
|
85
|
+
if not header or not isinstance(header, str):
|
|
86
|
+
return False
|
|
87
|
+
|
|
88
|
+
parsed = _parse_signature_header(header)
|
|
89
|
+
if parsed is None:
|
|
90
|
+
return False
|
|
91
|
+
|
|
92
|
+
now = now_seconds if now_seconds is not None else time.time()
|
|
93
|
+
if abs(now - parsed.timestamp_seconds) > tolerance_sec:
|
|
94
|
+
return False
|
|
95
|
+
|
|
96
|
+
body_bytes = body.encode("utf-8") if isinstance(body, str) else bytes(body)
|
|
97
|
+
payload = f"{parsed.timestamp_seconds}.".encode() + body_bytes
|
|
98
|
+
|
|
99
|
+
expected = hmac.new(
|
|
100
|
+
secret.encode("utf-8"),
|
|
101
|
+
payload,
|
|
102
|
+
hashlib.sha256,
|
|
103
|
+
).hexdigest()
|
|
104
|
+
|
|
105
|
+
# ``compare_digest`` is constant-time on equal-length strings.
|
|
106
|
+
return hmac.compare_digest(expected, parsed.signature_hex)
|