vortex-python-sdk 0.9.4__tar.gz → 0.10.0__tar.gz
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.
- {vortex_python_sdk-0.9.4/src/vortex_python_sdk.egg-info → vortex_python_sdk-0.10.0}/PKG-INFO +48 -1
- {vortex_python_sdk-0.9.4 → vortex_python_sdk-0.10.0}/README.md +47 -0
- {vortex_python_sdk-0.9.4 → vortex_python_sdk-0.10.0}/pyproject.toml +1 -1
- {vortex_python_sdk-0.9.4 → vortex_python_sdk-0.10.0/src/vortex_python_sdk.egg-info}/PKG-INFO +48 -1
- {vortex_python_sdk-0.9.4 → vortex_python_sdk-0.10.0}/src/vortex_python_sdk.egg-info/SOURCES.txt +4 -1
- {vortex_python_sdk-0.9.4 → vortex_python_sdk-0.10.0}/src/vortex_sdk/__init__.py +20 -1
- vortex_python_sdk-0.10.0/src/vortex_sdk/webhook_types.py +117 -0
- vortex_python_sdk-0.10.0/src/vortex_sdk/webhooks.py +120 -0
- vortex_python_sdk-0.10.0/tests/test_webhooks.py +118 -0
- {vortex_python_sdk-0.9.4 → vortex_python_sdk-0.10.0}/CHANGELOG.md +0 -0
- {vortex_python_sdk-0.9.4 → vortex_python_sdk-0.10.0}/LICENSE +0 -0
- {vortex_python_sdk-0.9.4 → vortex_python_sdk-0.10.0}/MANIFEST.in +0 -0
- {vortex_python_sdk-0.9.4 → vortex_python_sdk-0.10.0}/setup.cfg +0 -0
- {vortex_python_sdk-0.9.4 → vortex_python_sdk-0.10.0}/src/vortex_python_sdk.egg-info/dependency_links.txt +0 -0
- {vortex_python_sdk-0.9.4 → vortex_python_sdk-0.10.0}/src/vortex_python_sdk.egg-info/requires.txt +0 -0
- {vortex_python_sdk-0.9.4 → vortex_python_sdk-0.10.0}/src/vortex_python_sdk.egg-info/top_level.txt +0 -0
- {vortex_python_sdk-0.9.4 → vortex_python_sdk-0.10.0}/src/vortex_sdk/py.typed +0 -0
- {vortex_python_sdk-0.9.4 → vortex_python_sdk-0.10.0}/src/vortex_sdk/types.py +0 -0
- {vortex_python_sdk-0.9.4 → vortex_python_sdk-0.10.0}/src/vortex_sdk/vortex.py +0 -0
{vortex_python_sdk-0.9.4/src/vortex_python_sdk.egg-info → vortex_python_sdk-0.10.0}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: vortex-python-sdk
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.10.0
|
|
4
4
|
Summary: Vortex Python SDK for invitation management and JWT generation
|
|
5
5
|
Author-email: TeamVortexSoftware <support@vortexsoftware.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -295,6 +295,53 @@ ruff check src/ tests/
|
|
|
295
295
|
mypy src/
|
|
296
296
|
```
|
|
297
297
|
|
|
298
|
+
## Webhooks
|
|
299
|
+
|
|
300
|
+
The SDK provides built-in support for verifying and parsing incoming webhook events from Vortex.
|
|
301
|
+
|
|
302
|
+
### Setup
|
|
303
|
+
|
|
304
|
+
```python
|
|
305
|
+
import os
|
|
306
|
+
from vortex_sdk import VortexWebhooks, is_webhook_event, is_analytics_event
|
|
307
|
+
|
|
308
|
+
webhooks = VortexWebhooks(secret=os.environ["VORTEX_WEBHOOK_SECRET"])
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
### Verifying and Parsing Events
|
|
312
|
+
|
|
313
|
+
```python
|
|
314
|
+
# In your HTTP handler (Flask example):
|
|
315
|
+
@app.route("/webhooks/vortex", methods=["POST"])
|
|
316
|
+
def handle_webhook():
|
|
317
|
+
payload = request.get_data(as_text=True)
|
|
318
|
+
signature = request.headers.get("X-Vortex-Signature", "")
|
|
319
|
+
|
|
320
|
+
try:
|
|
321
|
+
event = webhooks.construct_event(payload, signature)
|
|
322
|
+
except VortexWebhookSignatureError:
|
|
323
|
+
return "Invalid signature", 400
|
|
324
|
+
|
|
325
|
+
if is_webhook_event(event.__dict__):
|
|
326
|
+
print(f"Webhook event: {event.type}")
|
|
327
|
+
elif is_analytics_event(event.__dict__):
|
|
328
|
+
print(f"Analytics event: {event.name}")
|
|
329
|
+
|
|
330
|
+
return "OK", 200
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
### Event Types
|
|
334
|
+
|
|
335
|
+
Webhook event types are available as the `WebhookEventType` enum:
|
|
336
|
+
|
|
337
|
+
```python
|
|
338
|
+
from vortex_sdk import WebhookEventType
|
|
339
|
+
|
|
340
|
+
if event.type == WebhookEventType.INVITATION_ACCEPTED:
|
|
341
|
+
# Handle invitation accepted
|
|
342
|
+
pass
|
|
343
|
+
```
|
|
344
|
+
|
|
298
345
|
## License
|
|
299
346
|
|
|
300
347
|
MIT
|
|
@@ -257,6 +257,53 @@ ruff check src/ tests/
|
|
|
257
257
|
mypy src/
|
|
258
258
|
```
|
|
259
259
|
|
|
260
|
+
## Webhooks
|
|
261
|
+
|
|
262
|
+
The SDK provides built-in support for verifying and parsing incoming webhook events from Vortex.
|
|
263
|
+
|
|
264
|
+
### Setup
|
|
265
|
+
|
|
266
|
+
```python
|
|
267
|
+
import os
|
|
268
|
+
from vortex_sdk import VortexWebhooks, is_webhook_event, is_analytics_event
|
|
269
|
+
|
|
270
|
+
webhooks = VortexWebhooks(secret=os.environ["VORTEX_WEBHOOK_SECRET"])
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
### Verifying and Parsing Events
|
|
274
|
+
|
|
275
|
+
```python
|
|
276
|
+
# In your HTTP handler (Flask example):
|
|
277
|
+
@app.route("/webhooks/vortex", methods=["POST"])
|
|
278
|
+
def handle_webhook():
|
|
279
|
+
payload = request.get_data(as_text=True)
|
|
280
|
+
signature = request.headers.get("X-Vortex-Signature", "")
|
|
281
|
+
|
|
282
|
+
try:
|
|
283
|
+
event = webhooks.construct_event(payload, signature)
|
|
284
|
+
except VortexWebhookSignatureError:
|
|
285
|
+
return "Invalid signature", 400
|
|
286
|
+
|
|
287
|
+
if is_webhook_event(event.__dict__):
|
|
288
|
+
print(f"Webhook event: {event.type}")
|
|
289
|
+
elif is_analytics_event(event.__dict__):
|
|
290
|
+
print(f"Analytics event: {event.name}")
|
|
291
|
+
|
|
292
|
+
return "OK", 200
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
### Event Types
|
|
296
|
+
|
|
297
|
+
Webhook event types are available as the `WebhookEventType` enum:
|
|
298
|
+
|
|
299
|
+
```python
|
|
300
|
+
from vortex_sdk import WebhookEventType
|
|
301
|
+
|
|
302
|
+
if event.type == WebhookEventType.INVITATION_ACCEPTED:
|
|
303
|
+
# Handle invitation accepted
|
|
304
|
+
pass
|
|
305
|
+
```
|
|
306
|
+
|
|
260
307
|
## License
|
|
261
308
|
|
|
262
309
|
MIT
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "vortex-python-sdk"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.10.0"
|
|
8
8
|
description = "Vortex Python SDK for invitation management and JWT generation"
|
|
9
9
|
authors = [{name = "TeamVortexSoftware", email = "support@vortexsoftware.com"}]
|
|
10
10
|
readme = "README.md"
|
{vortex_python_sdk-0.9.4 → vortex_python_sdk-0.10.0/src/vortex_python_sdk.egg-info}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: vortex-python-sdk
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.10.0
|
|
4
4
|
Summary: Vortex Python SDK for invitation management and JWT generation
|
|
5
5
|
Author-email: TeamVortexSoftware <support@vortexsoftware.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -295,6 +295,53 @@ ruff check src/ tests/
|
|
|
295
295
|
mypy src/
|
|
296
296
|
```
|
|
297
297
|
|
|
298
|
+
## Webhooks
|
|
299
|
+
|
|
300
|
+
The SDK provides built-in support for verifying and parsing incoming webhook events from Vortex.
|
|
301
|
+
|
|
302
|
+
### Setup
|
|
303
|
+
|
|
304
|
+
```python
|
|
305
|
+
import os
|
|
306
|
+
from vortex_sdk import VortexWebhooks, is_webhook_event, is_analytics_event
|
|
307
|
+
|
|
308
|
+
webhooks = VortexWebhooks(secret=os.environ["VORTEX_WEBHOOK_SECRET"])
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
### Verifying and Parsing Events
|
|
312
|
+
|
|
313
|
+
```python
|
|
314
|
+
# In your HTTP handler (Flask example):
|
|
315
|
+
@app.route("/webhooks/vortex", methods=["POST"])
|
|
316
|
+
def handle_webhook():
|
|
317
|
+
payload = request.get_data(as_text=True)
|
|
318
|
+
signature = request.headers.get("X-Vortex-Signature", "")
|
|
319
|
+
|
|
320
|
+
try:
|
|
321
|
+
event = webhooks.construct_event(payload, signature)
|
|
322
|
+
except VortexWebhookSignatureError:
|
|
323
|
+
return "Invalid signature", 400
|
|
324
|
+
|
|
325
|
+
if is_webhook_event(event.__dict__):
|
|
326
|
+
print(f"Webhook event: {event.type}")
|
|
327
|
+
elif is_analytics_event(event.__dict__):
|
|
328
|
+
print(f"Analytics event: {event.name}")
|
|
329
|
+
|
|
330
|
+
return "OK", 200
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
### Event Types
|
|
334
|
+
|
|
335
|
+
Webhook event types are available as the `WebhookEventType` enum:
|
|
336
|
+
|
|
337
|
+
```python
|
|
338
|
+
from vortex_sdk import WebhookEventType
|
|
339
|
+
|
|
340
|
+
if event.type == WebhookEventType.INVITATION_ACCEPTED:
|
|
341
|
+
# Handle invitation accepted
|
|
342
|
+
pass
|
|
343
|
+
```
|
|
344
|
+
|
|
298
345
|
## License
|
|
299
346
|
|
|
300
347
|
MIT
|
{vortex_python_sdk-0.9.4 → vortex_python_sdk-0.10.0}/src/vortex_python_sdk.egg-info/SOURCES.txt
RENAMED
|
@@ -11,4 +11,7 @@ src/vortex_python_sdk.egg-info/top_level.txt
|
|
|
11
11
|
src/vortex_sdk/__init__.py
|
|
12
12
|
src/vortex_sdk/py.typed
|
|
13
13
|
src/vortex_sdk/types.py
|
|
14
|
-
src/vortex_sdk/vortex.py
|
|
14
|
+
src/vortex_sdk/vortex.py
|
|
15
|
+
src/vortex_sdk/webhook_types.py
|
|
16
|
+
src/vortex_sdk/webhooks.py
|
|
17
|
+
tests/test_webhooks.py
|
|
@@ -28,8 +28,18 @@ from .types import (
|
|
|
28
28
|
VortexApiError,
|
|
29
29
|
)
|
|
30
30
|
from .vortex import Vortex
|
|
31
|
+
from .webhook_types import (
|
|
32
|
+
AnalyticsEventType,
|
|
33
|
+
VortexAnalyticsEvent,
|
|
34
|
+
VortexEvent,
|
|
35
|
+
VortexWebhookEvent,
|
|
36
|
+
WebhookEventType,
|
|
37
|
+
is_analytics_event,
|
|
38
|
+
is_webhook_event,
|
|
39
|
+
)
|
|
40
|
+
from .webhooks import VortexWebhookSignatureError, VortexWebhooks
|
|
31
41
|
|
|
32
|
-
__version__ = "0.
|
|
42
|
+
__version__ = "0.10.0"
|
|
33
43
|
__author__ = "TeamVortexSoftware"
|
|
34
44
|
__email__ = "support@vortexsoftware.com"
|
|
35
45
|
|
|
@@ -56,4 +66,13 @@ __all__ = [
|
|
|
56
66
|
"ApiResponseJson",
|
|
57
67
|
"ApiRequestBody",
|
|
58
68
|
"VortexApiError",
|
|
69
|
+
"VortexWebhooks",
|
|
70
|
+
"VortexWebhookSignatureError",
|
|
71
|
+
"VortexWebhookEvent",
|
|
72
|
+
"VortexAnalyticsEvent",
|
|
73
|
+
"VortexEvent",
|
|
74
|
+
"WebhookEventType",
|
|
75
|
+
"AnalyticsEventType",
|
|
76
|
+
"is_webhook_event",
|
|
77
|
+
"is_analytics_event",
|
|
59
78
|
]
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Vortex Webhook Types
|
|
3
|
+
|
|
4
|
+
Type definitions for webhook event handling.
|
|
5
|
+
These mirror the server-side types but are kept independent
|
|
6
|
+
so the SDK has no internal dependencies.
|
|
7
|
+
|
|
8
|
+
@see DEV-1769
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from enum import Enum
|
|
12
|
+
from typing import Any, Dict, List, Optional, Union
|
|
13
|
+
|
|
14
|
+
from pydantic import BaseModel, Field
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# ─── Webhook Event Type Constants ──────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class WebhookEventType(str, Enum):
|
|
21
|
+
"""Webhook event types for Vortex state changes."""
|
|
22
|
+
|
|
23
|
+
# Invitation Lifecycle
|
|
24
|
+
INVITATION_CREATED = "invitation.created"
|
|
25
|
+
INVITATION_ACCEPTED = "invitation.accepted"
|
|
26
|
+
INVITATION_DEACTIVATED = "invitation.deactivated"
|
|
27
|
+
INVITATION_EMAIL_DELIVERED = "invitation.email.delivered"
|
|
28
|
+
INVITATION_EMAIL_BOUNCED = "invitation.email.bounced"
|
|
29
|
+
INVITATION_EMAIL_OPENED = "invitation.email.opened"
|
|
30
|
+
INVITATION_LINK_CLICKED = "invitation.link.clicked"
|
|
31
|
+
INVITATION_REMINDER_SENT = "invitation.reminder.sent"
|
|
32
|
+
|
|
33
|
+
# Deployment Lifecycle
|
|
34
|
+
DEPLOYMENT_CREATED = "deployment.created"
|
|
35
|
+
DEPLOYMENT_DEACTIVATED = "deployment.deactivated"
|
|
36
|
+
|
|
37
|
+
# A/B Testing
|
|
38
|
+
ABTEST_STARTED = "abtest.started"
|
|
39
|
+
ABTEST_WINNER_DECLARED = "abtest.winner_declared"
|
|
40
|
+
|
|
41
|
+
# Member/Group
|
|
42
|
+
MEMBER_CREATED = "member.created"
|
|
43
|
+
GROUP_MEMBER_ADDED = "group.member.added"
|
|
44
|
+
|
|
45
|
+
# Email
|
|
46
|
+
EMAIL_COMPLAINED = "email.complained"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class AnalyticsEventType(str, Enum):
|
|
50
|
+
"""Analytics event types for behavioral telemetry."""
|
|
51
|
+
|
|
52
|
+
WIDGET_LOADED = "widget_loaded"
|
|
53
|
+
INVITATION_SENT = "invitation_sent"
|
|
54
|
+
INVITATION_CLICKED = "invitation_clicked"
|
|
55
|
+
INVITATION_ACCEPTED = "invitation_accepted"
|
|
56
|
+
SHARE_TRIGGERED = "share_triggered"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ─── Webhook Event Payload ─────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class VortexWebhookEvent(BaseModel):
|
|
63
|
+
"""A Vortex webhook event representing a server-side state change."""
|
|
64
|
+
|
|
65
|
+
id: str
|
|
66
|
+
type: str
|
|
67
|
+
timestamp: str
|
|
68
|
+
account_id: str = Field(alias="accountId")
|
|
69
|
+
environment_id: Optional[str] = Field(None, alias="environmentId")
|
|
70
|
+
source_table: str = Field(alias="sourceTable")
|
|
71
|
+
operation: str # "insert" | "update" | "delete"
|
|
72
|
+
data: Dict[str, Any]
|
|
73
|
+
|
|
74
|
+
class Config:
|
|
75
|
+
populate_by_name = True
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# ─── Analytics Event Payload ───────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class VortexAnalyticsEvent(BaseModel):
|
|
82
|
+
"""An analytics event representing client-side behavioral telemetry."""
|
|
83
|
+
|
|
84
|
+
id: str
|
|
85
|
+
name: str
|
|
86
|
+
account_id: str = Field(alias="accountId")
|
|
87
|
+
organization_id: str = Field(alias="organizationId")
|
|
88
|
+
project_id: str = Field(alias="projectId")
|
|
89
|
+
environment_id: str = Field(alias="environmentId")
|
|
90
|
+
deployment_id: Optional[str] = Field(None, alias="deploymentId")
|
|
91
|
+
widget_configuration_id: Optional[str] = Field(
|
|
92
|
+
None, alias="widgetConfigurationId"
|
|
93
|
+
)
|
|
94
|
+
foreign_user_id: Optional[str] = Field(None, alias="foreignUserId")
|
|
95
|
+
session_id: Optional[str] = Field(None, alias="sessionId")
|
|
96
|
+
payload: Optional[Dict[str, Any]] = None
|
|
97
|
+
platform: Optional[str] = None
|
|
98
|
+
segmentation: Optional[str] = None
|
|
99
|
+
timestamp: str
|
|
100
|
+
|
|
101
|
+
class Config:
|
|
102
|
+
populate_by_name = True
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# ─── Union & Discriminator ─────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
VortexEvent = Union[VortexWebhookEvent, VortexAnalyticsEvent]
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def is_webhook_event(event: Dict[str, Any]) -> bool:
|
|
111
|
+
"""Returns True if the event dict is a webhook event (has 'type', no 'name')."""
|
|
112
|
+
return "type" in event and "name" not in event
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def is_analytics_event(event: Dict[str, Any]) -> bool:
|
|
116
|
+
"""Returns True if the event dict is an analytics event (has 'name')."""
|
|
117
|
+
return "name" in event
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Vortex Webhooks
|
|
3
|
+
|
|
4
|
+
Core webhook verification and parsing for the Vortex Python SDK.
|
|
5
|
+
|
|
6
|
+
Example::
|
|
7
|
+
|
|
8
|
+
from vortex_sdk import VortexWebhooks
|
|
9
|
+
|
|
10
|
+
webhooks = VortexWebhooks(secret=os.environ["VORTEX_WEBHOOK_SECRET"])
|
|
11
|
+
|
|
12
|
+
# In any HTTP handler:
|
|
13
|
+
event = webhooks.construct_event(raw_body, signature_header)
|
|
14
|
+
|
|
15
|
+
@see DEV-1769
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import hashlib
|
|
19
|
+
import hmac
|
|
20
|
+
import json
|
|
21
|
+
from typing import Any, Dict, Union
|
|
22
|
+
|
|
23
|
+
from .webhook_types import (
|
|
24
|
+
VortexAnalyticsEvent,
|
|
25
|
+
VortexEvent,
|
|
26
|
+
VortexWebhookEvent,
|
|
27
|
+
is_analytics_event,
|
|
28
|
+
is_webhook_event,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class VortexWebhookSignatureError(Exception):
|
|
33
|
+
"""Raised when webhook signature verification fails."""
|
|
34
|
+
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class VortexWebhooks:
|
|
39
|
+
"""
|
|
40
|
+
Core webhook verification and parsing.
|
|
41
|
+
|
|
42
|
+
This class is framework-agnostic — use it directly or with
|
|
43
|
+
framework-specific integrations (Flask, Django, FastAPI).
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
secret: The webhook signing secret from your Vortex dashboard.
|
|
47
|
+
|
|
48
|
+
Example::
|
|
49
|
+
|
|
50
|
+
from vortex_sdk import VortexWebhooks
|
|
51
|
+
|
|
52
|
+
webhooks = VortexWebhooks(secret=os.environ["VORTEX_WEBHOOK_SECRET"])
|
|
53
|
+
event = webhooks.construct_event(request.body, request.headers["X-Vortex-Signature"])
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def __init__(self, secret: str) -> None:
|
|
57
|
+
if not secret:
|
|
58
|
+
raise ValueError("VortexWebhooks requires a secret")
|
|
59
|
+
self._secret = secret
|
|
60
|
+
|
|
61
|
+
def verify_signature(self, payload: Union[str, bytes], signature: str) -> bool:
|
|
62
|
+
"""
|
|
63
|
+
Verify the HMAC-SHA256 signature of an incoming webhook payload.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
payload: The raw request body (str or bytes).
|
|
67
|
+
signature: The value of the ``X-Vortex-Signature`` header.
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
``True`` if the signature is valid.
|
|
71
|
+
"""
|
|
72
|
+
if not signature:
|
|
73
|
+
return False
|
|
74
|
+
|
|
75
|
+
if isinstance(payload, str):
|
|
76
|
+
payload = payload.encode("utf-8")
|
|
77
|
+
|
|
78
|
+
expected = hmac.new(
|
|
79
|
+
self._secret.encode("utf-8"),
|
|
80
|
+
payload,
|
|
81
|
+
hashlib.sha256,
|
|
82
|
+
).hexdigest()
|
|
83
|
+
|
|
84
|
+
# Timing-safe comparison to prevent timing attacks
|
|
85
|
+
return hmac.compare_digest(signature, expected)
|
|
86
|
+
|
|
87
|
+
def construct_event(
|
|
88
|
+
self, payload: Union[str, bytes], signature: str
|
|
89
|
+
) -> VortexEvent:
|
|
90
|
+
"""
|
|
91
|
+
Verify and parse an incoming webhook payload.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
payload: The raw request body (str or bytes). Must be the raw body,
|
|
95
|
+
not a parsed dict — signature verification requires the exact
|
|
96
|
+
bytes that were signed.
|
|
97
|
+
signature: The value of the ``X-Vortex-Signature`` header.
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
A :class:`VortexWebhookEvent` or :class:`VortexAnalyticsEvent`.
|
|
101
|
+
|
|
102
|
+
Raises:
|
|
103
|
+
VortexWebhookSignatureError: If the signature is invalid.
|
|
104
|
+
"""
|
|
105
|
+
if not self.verify_signature(payload, signature):
|
|
106
|
+
raise VortexWebhookSignatureError(
|
|
107
|
+
"Webhook signature verification failed. Ensure you are using "
|
|
108
|
+
"the raw request body and the correct signing secret."
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
body = payload if isinstance(payload, str) else payload.decode("utf-8")
|
|
112
|
+
parsed: Dict[str, Any] = json.loads(body)
|
|
113
|
+
|
|
114
|
+
if is_webhook_event(parsed):
|
|
115
|
+
return VortexWebhookEvent(**parsed)
|
|
116
|
+
elif is_analytics_event(parsed):
|
|
117
|
+
return VortexAnalyticsEvent(**parsed)
|
|
118
|
+
else:
|
|
119
|
+
# Return as webhook event by default
|
|
120
|
+
return VortexWebhookEvent(**parsed)
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Tests for Vortex webhook signature verification and event construction."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import hmac
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from vortex_sdk import (
|
|
10
|
+
VortexAnalyticsEvent,
|
|
11
|
+
VortexWebhookEvent,
|
|
12
|
+
VortexWebhookSignatureError,
|
|
13
|
+
VortexWebhooks,
|
|
14
|
+
is_analytics_event,
|
|
15
|
+
is_webhook_event,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
SECRET = "whsec_test_secret_123"
|
|
20
|
+
|
|
21
|
+
WEBHOOK_EVENT_PAYLOAD = json.dumps(
|
|
22
|
+
{
|
|
23
|
+
"id": "evt_123",
|
|
24
|
+
"type": "invitation.accepted",
|
|
25
|
+
"timestamp": "2025-01-15T12:00:00.000Z",
|
|
26
|
+
"accountId": "acc_123",
|
|
27
|
+
"environmentId": "env_456",
|
|
28
|
+
"sourceTable": "invitations",
|
|
29
|
+
"operation": "update",
|
|
30
|
+
"data": {"invitationId": "inv_789", "targetEmail": "user@example.com"},
|
|
31
|
+
}
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
ANALYTICS_EVENT_PAYLOAD = json.dumps(
|
|
35
|
+
{
|
|
36
|
+
"id": "evt_456",
|
|
37
|
+
"name": "widget_loaded",
|
|
38
|
+
"accountId": "acc_123",
|
|
39
|
+
"organizationId": "org_123",
|
|
40
|
+
"projectId": "proj_123",
|
|
41
|
+
"environmentId": "env_456",
|
|
42
|
+
"deploymentId": None,
|
|
43
|
+
"widgetConfigurationId": "wc_123",
|
|
44
|
+
"foreignUserId": "user_123",
|
|
45
|
+
"sessionId": "sess_123",
|
|
46
|
+
"payload": {"page": "/dashboard"},
|
|
47
|
+
"platform": "web",
|
|
48
|
+
"segmentation": None,
|
|
49
|
+
"timestamp": "2025-01-15T12:00:00.000Z",
|
|
50
|
+
}
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _sign(payload: str, secret: str = SECRET) -> str:
|
|
55
|
+
return hmac.new(secret.encode("utf-8"), payload.encode("utf-8"), hashlib.sha256).hexdigest()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class TestVortexWebhooks:
|
|
59
|
+
def test_constructor_requires_secret(self) -> None:
|
|
60
|
+
with pytest.raises(ValueError, match="requires a secret"):
|
|
61
|
+
VortexWebhooks(secret="")
|
|
62
|
+
|
|
63
|
+
def test_verify_signature_valid(self) -> None:
|
|
64
|
+
wh = VortexWebhooks(secret=SECRET)
|
|
65
|
+
sig = _sign(WEBHOOK_EVENT_PAYLOAD)
|
|
66
|
+
assert wh.verify_signature(WEBHOOK_EVENT_PAYLOAD, sig) is True
|
|
67
|
+
|
|
68
|
+
def test_verify_signature_invalid(self) -> None:
|
|
69
|
+
wh = VortexWebhooks(secret=SECRET)
|
|
70
|
+
assert wh.verify_signature(WEBHOOK_EVENT_PAYLOAD, "bad_signature") is False
|
|
71
|
+
|
|
72
|
+
def test_verify_signature_empty(self) -> None:
|
|
73
|
+
wh = VortexWebhooks(secret=SECRET)
|
|
74
|
+
assert wh.verify_signature(WEBHOOK_EVENT_PAYLOAD, "") is False
|
|
75
|
+
|
|
76
|
+
def test_verify_signature_wrong_secret(self) -> None:
|
|
77
|
+
wh = VortexWebhooks(secret=SECRET)
|
|
78
|
+
sig = _sign(WEBHOOK_EVENT_PAYLOAD, "wrong_secret")
|
|
79
|
+
assert wh.verify_signature(WEBHOOK_EVENT_PAYLOAD, sig) is False
|
|
80
|
+
|
|
81
|
+
def test_verify_signature_bytes_payload(self) -> None:
|
|
82
|
+
wh = VortexWebhooks(secret=SECRET)
|
|
83
|
+
sig = _sign(WEBHOOK_EVENT_PAYLOAD)
|
|
84
|
+
assert wh.verify_signature(WEBHOOK_EVENT_PAYLOAD.encode("utf-8"), sig) is True
|
|
85
|
+
|
|
86
|
+
def test_construct_webhook_event(self) -> None:
|
|
87
|
+
wh = VortexWebhooks(secret=SECRET)
|
|
88
|
+
sig = _sign(WEBHOOK_EVENT_PAYLOAD)
|
|
89
|
+
event = wh.construct_event(WEBHOOK_EVENT_PAYLOAD, sig)
|
|
90
|
+
assert isinstance(event, VortexWebhookEvent)
|
|
91
|
+
assert event.id == "evt_123"
|
|
92
|
+
assert event.type == "invitation.accepted"
|
|
93
|
+
assert event.account_id == "acc_123"
|
|
94
|
+
assert event.data["targetEmail"] == "user@example.com"
|
|
95
|
+
|
|
96
|
+
def test_construct_analytics_event(self) -> None:
|
|
97
|
+
wh = VortexWebhooks(secret=SECRET)
|
|
98
|
+
sig = _sign(ANALYTICS_EVENT_PAYLOAD)
|
|
99
|
+
event = wh.construct_event(ANALYTICS_EVENT_PAYLOAD, sig)
|
|
100
|
+
assert isinstance(event, VortexAnalyticsEvent)
|
|
101
|
+
assert event.id == "evt_456"
|
|
102
|
+
assert event.name == "widget_loaded"
|
|
103
|
+
assert event.account_id == "acc_123"
|
|
104
|
+
|
|
105
|
+
def test_construct_event_bad_signature(self) -> None:
|
|
106
|
+
wh = VortexWebhooks(secret=SECRET)
|
|
107
|
+
with pytest.raises(VortexWebhookSignatureError):
|
|
108
|
+
wh.construct_event(WEBHOOK_EVENT_PAYLOAD, "bad_sig")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class TestTypeGuards:
|
|
112
|
+
def test_is_webhook_event(self) -> None:
|
|
113
|
+
assert is_webhook_event({"type": "invitation.accepted", "id": "1"}) is True
|
|
114
|
+
assert is_webhook_event({"name": "widget_loaded", "id": "1"}) is False
|
|
115
|
+
|
|
116
|
+
def test_is_analytics_event(self) -> None:
|
|
117
|
+
assert is_analytics_event({"name": "widget_loaded", "id": "1"}) is True
|
|
118
|
+
assert is_analytics_event({"type": "invitation.accepted", "id": "1"}) is False
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{vortex_python_sdk-0.9.4 → vortex_python_sdk-0.10.0}/src/vortex_python_sdk.egg-info/requires.txt
RENAMED
|
File without changes
|
{vortex_python_sdk-0.9.4 → vortex_python_sdk-0.10.0}/src/vortex_python_sdk.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|