relaya 0.1.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.
- relaya-0.1.0/LICENSE +21 -0
- relaya-0.1.0/PKG-INFO +119 -0
- relaya-0.1.0/README.md +102 -0
- relaya-0.1.0/pyproject.toml +29 -0
- relaya-0.1.0/setup.cfg +4 -0
- relaya-0.1.0/src/relaya/__init__.py +28 -0
- relaya-0.1.0/src/relaya/client.py +289 -0
- relaya-0.1.0/src/relaya/errors.py +30 -0
- relaya-0.1.0/src/relaya/py.typed +0 -0
- relaya-0.1.0/src/relaya/webhooks.py +164 -0
- relaya-0.1.0/src/relaya.egg-info/PKG-INFO +119 -0
- relaya-0.1.0/src/relaya.egg-info/SOURCES.txt +15 -0
- relaya-0.1.0/src/relaya.egg-info/dependency_links.txt +1 -0
- relaya-0.1.0/src/relaya.egg-info/top_level.txt +1 -0
- relaya-0.1.0/tests/test_client.py +106 -0
- relaya-0.1.0/tests/test_integration.py +136 -0
- relaya-0.1.0/tests/test_webhooks.py +82 -0
relaya-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dhiraj Kumar
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
relaya-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: relaya
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Verify webhooks forwarded by Relaya and call the Relaya API.
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/Dhirajrai12/relaya-sdks/tree/main/python
|
|
7
|
+
Project-URL: Source, https://github.com/Dhirajrai12/relaya-sdks
|
|
8
|
+
Keywords: relaya,webhooks,signature,hmac,integrations
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Typing :: Typed
|
|
13
|
+
Requires-Python: >=3.9
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
License-File: LICENSE
|
|
16
|
+
Dynamic: license-file
|
|
17
|
+
|
|
18
|
+
# relaya (Python)
|
|
19
|
+
|
|
20
|
+
Verify requests that Relaya forwards to your endpoints, and call the Relaya API. No dependencies; Python 3.9+.
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
pip install relaya
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Receive events
|
|
27
|
+
|
|
28
|
+
Relaya signs every request it forwards with your destination's signing secret (shown once when you add the destination). Always pass the **raw** body.
|
|
29
|
+
|
|
30
|
+
### Django
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
from django.http import HttpResponse, JsonResponse
|
|
34
|
+
from django.views.decorators.csrf import csrf_exempt
|
|
35
|
+
from django.views.decorators.http import require_POST
|
|
36
|
+
from relaya import verify_delivery, WebhookVerificationError
|
|
37
|
+
|
|
38
|
+
@csrf_exempt
|
|
39
|
+
@require_POST
|
|
40
|
+
def relaya_webhook(request):
|
|
41
|
+
try:
|
|
42
|
+
delivery = verify_delivery(request.body, request.headers, settings.RELAYA_SIGNING_SECRET)
|
|
43
|
+
except WebhookVerificationError as e:
|
|
44
|
+
return JsonResponse({"error": e.reason}, status=400)
|
|
45
|
+
if already_processed(delivery.idempotency_key):
|
|
46
|
+
return HttpResponse()
|
|
47
|
+
handle(delivery.json())
|
|
48
|
+
return HttpResponse()
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### Flask
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
@app.post("/webhooks/relaya")
|
|
55
|
+
def relaya_webhook():
|
|
56
|
+
try:
|
|
57
|
+
delivery = verify_delivery(request.get_data(), request.headers, os.environ["RELAYA_SIGNING_SECRET"])
|
|
58
|
+
except WebhookVerificationError as e:
|
|
59
|
+
return {"error": e.reason}, 400
|
|
60
|
+
handle(delivery.json())
|
|
61
|
+
return "", 200
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### FastAPI
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
@app.post("/webhooks/relaya")
|
|
68
|
+
async def relaya_webhook(request: Request):
|
|
69
|
+
try:
|
|
70
|
+
delivery = verify_delivery(await request.body(), request.headers, os.environ["RELAYA_SIGNING_SECRET"])
|
|
71
|
+
except WebhookVerificationError as e:
|
|
72
|
+
raise HTTPException(400, e.reason)
|
|
73
|
+
await handle(delivery.json())
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Return a 5xx and Relaya retries later with the same idempotency key.
|
|
77
|
+
|
|
78
|
+
| `Delivery` field | |
|
|
79
|
+
|---|---|
|
|
80
|
+
| `idempotency_key` | The same across retries and replays of one delivery. Dedupe on this. |
|
|
81
|
+
| `event_id`, `delivery_id` | Relaya's IDs. |
|
|
82
|
+
| `event_type` | e.g. `payment.captured`, when Relaya could tell. |
|
|
83
|
+
| `attempt` | 1, then 2, 3… on retries. |
|
|
84
|
+
| `replay_id` | Set when the request is part of an incident replay. |
|
|
85
|
+
| `body`, `json()` | The provider's original body, unchanged. |
|
|
86
|
+
|
|
87
|
+
`e.reason` is one of `missing_signature`, `malformed_signature`, `timestamp_out_of_range`, `signature_mismatch`. Pass a list of secrets while rotating; `tolerance=` sets the maximum signature age in seconds (default 300, `0` disables). Webhook alert channels: `verify_alert(body, headers, secret)`.
|
|
88
|
+
|
|
89
|
+
## Call the API
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
from datetime import datetime, timedelta, timezone
|
|
93
|
+
from relaya import Relaya
|
|
94
|
+
|
|
95
|
+
relaya = Relaya() # reads RELAYA_API_KEY
|
|
96
|
+
|
|
97
|
+
for event in relaya.events.iterate(contract_status="breaking", since=datetime.now(timezone.utc) - timedelta(days=7)):
|
|
98
|
+
print(event["type"], event["received_at"])
|
|
99
|
+
|
|
100
|
+
for d in relaya.deliveries.list(status="failed"):
|
|
101
|
+
relaya.deliveries.retry(d["id"])
|
|
102
|
+
|
|
103
|
+
for incident in relaya.incidents.list("open"):
|
|
104
|
+
plan = relaya.incidents.preview_replay(incident["id"]) # dry run
|
|
105
|
+
relaya.incidents.replay(incident["id"]) # resolves itself once every delivery succeeds
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Resources: `projects`, `webhooks`, `events` (`list`, `iterate`, `get`), `destinations`, `deliveries`, `contracts`, `incidents`, `alerts`. Responses are dicts with the API's field names. `relaya.request(method, path, body, params)` reaches anything else.
|
|
109
|
+
|
|
110
|
+
Options: `api_key` (or `RELAYA_API_KEY`), `base_url` (or `RELAYA_BASE_URL`), `org_id` (only with a session token), `timeout` (30 s), `max_retries` (2; GET only, on network errors, 429 and 5xx).
|
|
111
|
+
|
|
112
|
+
Errors raise `RelayaError` with `status`, `code` and `request_id`.
|
|
113
|
+
|
|
114
|
+
## Development
|
|
115
|
+
|
|
116
|
+
```sh
|
|
117
|
+
PYTHONPATH=src python -m unittest discover -s tests
|
|
118
|
+
RELAYA_IT_API_URL=http://127.0.0.1:18080 PYTHONPATH=src python -m unittest tests.test_integration # against a running dev stack
|
|
119
|
+
```
|
relaya-0.1.0/README.md
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# relaya (Python)
|
|
2
|
+
|
|
3
|
+
Verify requests that Relaya forwards to your endpoints, and call the Relaya API. No dependencies; Python 3.9+.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
pip install relaya
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## Receive events
|
|
10
|
+
|
|
11
|
+
Relaya signs every request it forwards with your destination's signing secret (shown once when you add the destination). Always pass the **raw** body.
|
|
12
|
+
|
|
13
|
+
### Django
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from django.http import HttpResponse, JsonResponse
|
|
17
|
+
from django.views.decorators.csrf import csrf_exempt
|
|
18
|
+
from django.views.decorators.http import require_POST
|
|
19
|
+
from relaya import verify_delivery, WebhookVerificationError
|
|
20
|
+
|
|
21
|
+
@csrf_exempt
|
|
22
|
+
@require_POST
|
|
23
|
+
def relaya_webhook(request):
|
|
24
|
+
try:
|
|
25
|
+
delivery = verify_delivery(request.body, request.headers, settings.RELAYA_SIGNING_SECRET)
|
|
26
|
+
except WebhookVerificationError as e:
|
|
27
|
+
return JsonResponse({"error": e.reason}, status=400)
|
|
28
|
+
if already_processed(delivery.idempotency_key):
|
|
29
|
+
return HttpResponse()
|
|
30
|
+
handle(delivery.json())
|
|
31
|
+
return HttpResponse()
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### Flask
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
@app.post("/webhooks/relaya")
|
|
38
|
+
def relaya_webhook():
|
|
39
|
+
try:
|
|
40
|
+
delivery = verify_delivery(request.get_data(), request.headers, os.environ["RELAYA_SIGNING_SECRET"])
|
|
41
|
+
except WebhookVerificationError as e:
|
|
42
|
+
return {"error": e.reason}, 400
|
|
43
|
+
handle(delivery.json())
|
|
44
|
+
return "", 200
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### FastAPI
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
@app.post("/webhooks/relaya")
|
|
51
|
+
async def relaya_webhook(request: Request):
|
|
52
|
+
try:
|
|
53
|
+
delivery = verify_delivery(await request.body(), request.headers, os.environ["RELAYA_SIGNING_SECRET"])
|
|
54
|
+
except WebhookVerificationError as e:
|
|
55
|
+
raise HTTPException(400, e.reason)
|
|
56
|
+
await handle(delivery.json())
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Return a 5xx and Relaya retries later with the same idempotency key.
|
|
60
|
+
|
|
61
|
+
| `Delivery` field | |
|
|
62
|
+
|---|---|
|
|
63
|
+
| `idempotency_key` | The same across retries and replays of one delivery. Dedupe on this. |
|
|
64
|
+
| `event_id`, `delivery_id` | Relaya's IDs. |
|
|
65
|
+
| `event_type` | e.g. `payment.captured`, when Relaya could tell. |
|
|
66
|
+
| `attempt` | 1, then 2, 3… on retries. |
|
|
67
|
+
| `replay_id` | Set when the request is part of an incident replay. |
|
|
68
|
+
| `body`, `json()` | The provider's original body, unchanged. |
|
|
69
|
+
|
|
70
|
+
`e.reason` is one of `missing_signature`, `malformed_signature`, `timestamp_out_of_range`, `signature_mismatch`. Pass a list of secrets while rotating; `tolerance=` sets the maximum signature age in seconds (default 300, `0` disables). Webhook alert channels: `verify_alert(body, headers, secret)`.
|
|
71
|
+
|
|
72
|
+
## Call the API
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
from datetime import datetime, timedelta, timezone
|
|
76
|
+
from relaya import Relaya
|
|
77
|
+
|
|
78
|
+
relaya = Relaya() # reads RELAYA_API_KEY
|
|
79
|
+
|
|
80
|
+
for event in relaya.events.iterate(contract_status="breaking", since=datetime.now(timezone.utc) - timedelta(days=7)):
|
|
81
|
+
print(event["type"], event["received_at"])
|
|
82
|
+
|
|
83
|
+
for d in relaya.deliveries.list(status="failed"):
|
|
84
|
+
relaya.deliveries.retry(d["id"])
|
|
85
|
+
|
|
86
|
+
for incident in relaya.incidents.list("open"):
|
|
87
|
+
plan = relaya.incidents.preview_replay(incident["id"]) # dry run
|
|
88
|
+
relaya.incidents.replay(incident["id"]) # resolves itself once every delivery succeeds
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Resources: `projects`, `webhooks`, `events` (`list`, `iterate`, `get`), `destinations`, `deliveries`, `contracts`, `incidents`, `alerts`. Responses are dicts with the API's field names. `relaya.request(method, path, body, params)` reaches anything else.
|
|
92
|
+
|
|
93
|
+
Options: `api_key` (or `RELAYA_API_KEY`), `base_url` (or `RELAYA_BASE_URL`), `org_id` (only with a session token), `timeout` (30 s), `max_retries` (2; GET only, on network errors, 429 and 5xx).
|
|
94
|
+
|
|
95
|
+
Errors raise `RelayaError` with `status`, `code` and `request_id`.
|
|
96
|
+
|
|
97
|
+
## Development
|
|
98
|
+
|
|
99
|
+
```sh
|
|
100
|
+
PYTHONPATH=src python -m unittest discover -s tests
|
|
101
|
+
RELAYA_IT_API_URL=http://127.0.0.1:18080 PYTHONPATH=src python -m unittest tests.test_integration # against a running dev stack
|
|
102
|
+
```
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "relaya"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Verify webhooks forwarded by Relaya and call the Relaya API."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
dependencies = []
|
|
13
|
+
keywords = ["relaya", "webhooks", "signature", "hmac", "integrations"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
"Typing :: Typed",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
[project.urls]
|
|
22
|
+
Homepage = "https://github.com/Dhirajrai12/relaya-sdks/tree/main/python"
|
|
23
|
+
Source = "https://github.com/Dhirajrai12/relaya-sdks"
|
|
24
|
+
|
|
25
|
+
[tool.setuptools.packages.find]
|
|
26
|
+
where = ["src"]
|
|
27
|
+
|
|
28
|
+
[tool.setuptools.package-data]
|
|
29
|
+
relaya = ["py.typed"]
|
relaya-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Verify webhooks forwarded by Relaya and call the Relaya API."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.1.0"
|
|
4
|
+
|
|
5
|
+
from .client import DEFAULT_BASE_URL, Relaya # noqa: E402
|
|
6
|
+
from .errors import RelayaError, WebhookVerificationError # noqa: E402
|
|
7
|
+
from .webhooks import ( # noqa: E402
|
|
8
|
+
DEFAULT_TOLERANCE,
|
|
9
|
+
Delivery,
|
|
10
|
+
is_valid_signature,
|
|
11
|
+
verify_alert,
|
|
12
|
+
verify_delivery,
|
|
13
|
+
verify_signature,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"Relaya",
|
|
18
|
+
"RelayaError",
|
|
19
|
+
"WebhookVerificationError",
|
|
20
|
+
"Delivery",
|
|
21
|
+
"verify_signature",
|
|
22
|
+
"is_valid_signature",
|
|
23
|
+
"verify_delivery",
|
|
24
|
+
"verify_alert",
|
|
25
|
+
"DEFAULT_BASE_URL",
|
|
26
|
+
"DEFAULT_TOLERANCE",
|
|
27
|
+
"__version__",
|
|
28
|
+
]
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
"""Relaya API client. Responses are plain dicts with the API's field names."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import random
|
|
8
|
+
import socket
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
import urllib.error
|
|
12
|
+
import urllib.parse
|
|
13
|
+
import urllib.request
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
|
+
from typing import Any, Dict, Iterator, List, Optional
|
|
16
|
+
|
|
17
|
+
from . import __version__
|
|
18
|
+
from .errors import RelayaError
|
|
19
|
+
|
|
20
|
+
#: Where the API lives until the product has its own domain.
|
|
21
|
+
DEFAULT_BASE_URL = "https://server.aegonassett.com/api"
|
|
22
|
+
|
|
23
|
+
JSON = Dict[str, Any]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _clean(params: Dict[str, Any]) -> Dict[str, str]:
|
|
27
|
+
out = {}
|
|
28
|
+
for k, v in params.items():
|
|
29
|
+
if v is None or v == "":
|
|
30
|
+
continue
|
|
31
|
+
if isinstance(v, datetime):
|
|
32
|
+
v = (v if v.tzinfo else v.replace(tzinfo=timezone.utc)).astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
33
|
+
out[k] = str(v)
|
|
34
|
+
return out
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _backoff(attempt: int, retry_after: Optional[str]) -> float:
|
|
38
|
+
if retry_after is not None:
|
|
39
|
+
try:
|
|
40
|
+
return min(max(float(retry_after), 0.0), 30.0)
|
|
41
|
+
except ValueError:
|
|
42
|
+
pass
|
|
43
|
+
return min(0.5 * 2**attempt, 8.0) * (0.8 + random.random() * 0.4)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class Relaya:
|
|
47
|
+
"""Relaya API client.
|
|
48
|
+
|
|
49
|
+
>>> relaya = Relaya(api_key=os.environ["RELAYA_API_KEY"])
|
|
50
|
+
>>> for event in relaya.events.iterate(contract_status="breaking"):
|
|
51
|
+
... print(event["type"])
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(
|
|
55
|
+
self,
|
|
56
|
+
api_key: Optional[str] = None,
|
|
57
|
+
*,
|
|
58
|
+
org_id: Optional[str] = None,
|
|
59
|
+
base_url: Optional[str] = None,
|
|
60
|
+
timeout: float = 30.0,
|
|
61
|
+
max_retries: int = 2,
|
|
62
|
+
) -> None:
|
|
63
|
+
self.api_key = api_key or os.environ.get("RELAYA_API_KEY")
|
|
64
|
+
if not self.api_key:
|
|
65
|
+
raise ValueError("relaya: pass api_key or set RELAYA_API_KEY")
|
|
66
|
+
self.base_url = (base_url or os.environ.get("RELAYA_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
|
|
67
|
+
self.timeout = timeout
|
|
68
|
+
self.max_retries = max_retries
|
|
69
|
+
self._org_id = org_id
|
|
70
|
+
self._org_lock = threading.Lock()
|
|
71
|
+
|
|
72
|
+
self.projects = Projects(self)
|
|
73
|
+
self.webhooks = Webhooks(self)
|
|
74
|
+
self.events = Events(self)
|
|
75
|
+
self.destinations = Destinations(self)
|
|
76
|
+
self.deliveries = Deliveries(self)
|
|
77
|
+
self.contracts = Contracts(self)
|
|
78
|
+
self.incidents = Incidents(self)
|
|
79
|
+
self.alerts = Alerts(self)
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def org_id(self) -> str:
|
|
83
|
+
"""The org this client acts on, looked up from the API key on first use."""
|
|
84
|
+
with self._org_lock:
|
|
85
|
+
if not self._org_id:
|
|
86
|
+
me = self.request("GET", "/v1/me")
|
|
87
|
+
if not me.get("api_key"):
|
|
88
|
+
raise ValueError("relaya: pass org_id when using a session token instead of an API key")
|
|
89
|
+
self._org_id = me["api_key"]["org_id"]
|
|
90
|
+
return self._org_id
|
|
91
|
+
|
|
92
|
+
def request(self, method: str, path: str, body: Any = None, params: Optional[Dict[str, Any]] = None) -> Any:
|
|
93
|
+
"""Low-level request; paths start with /v1. Returns the decoded JSON (None for 204)."""
|
|
94
|
+
url = self.base_url + path
|
|
95
|
+
query = _clean(params or {})
|
|
96
|
+
if query:
|
|
97
|
+
url += "?" + urllib.parse.urlencode(query)
|
|
98
|
+
data = None if body is None else json.dumps(body).encode()
|
|
99
|
+
headers = {
|
|
100
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
101
|
+
"Accept": "application/json",
|
|
102
|
+
"User-Agent": f"relaya-python/{__version__}",
|
|
103
|
+
}
|
|
104
|
+
if data is not None:
|
|
105
|
+
headers["Content-Type"] = "application/json"
|
|
106
|
+
retries = self.max_retries if method == "GET" else 0
|
|
107
|
+
|
|
108
|
+
attempt = 0
|
|
109
|
+
while True:
|
|
110
|
+
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
|
111
|
+
try:
|
|
112
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as res:
|
|
113
|
+
raw = res.read()
|
|
114
|
+
return json.loads(raw) if raw else None
|
|
115
|
+
except urllib.error.HTTPError as e:
|
|
116
|
+
raw = e.read()
|
|
117
|
+
if (e.code == 429 or e.code >= 500) and attempt < retries:
|
|
118
|
+
time.sleep(_backoff(attempt, e.headers.get("Retry-After")))
|
|
119
|
+
attempt += 1
|
|
120
|
+
continue
|
|
121
|
+
code, message = "http_error", f"HTTP {e.code}"
|
|
122
|
+
try:
|
|
123
|
+
err = json.loads(raw).get("error") or {}
|
|
124
|
+
code, message = err.get("code", code), err.get("message", message)
|
|
125
|
+
except (ValueError, AttributeError):
|
|
126
|
+
pass
|
|
127
|
+
raise RelayaError(e.code, code, message, e.headers.get("X-Request-Id")) from None
|
|
128
|
+
except (urllib.error.URLError, socket.timeout, TimeoutError, ConnectionError) as e:
|
|
129
|
+
if attempt < retries:
|
|
130
|
+
time.sleep(_backoff(attempt, None))
|
|
131
|
+
attempt += 1
|
|
132
|
+
continue
|
|
133
|
+
reason = getattr(e, "reason", e)
|
|
134
|
+
timed_out = isinstance(reason, (socket.timeout, TimeoutError))
|
|
135
|
+
raise RelayaError(
|
|
136
|
+
0,
|
|
137
|
+
"timeout" if timed_out else "network_error",
|
|
138
|
+
f"Request timed out after {self.timeout}s" if timed_out else f"Could not reach Relaya: {reason}",
|
|
139
|
+
) from None
|
|
140
|
+
|
|
141
|
+
def _org(self, method: str, path: str, body: Any = None, params: Optional[Dict[str, Any]] = None) -> Any:
|
|
142
|
+
return self.request(method, f"/v1/orgs/{self.org_id}{path}", body, params)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class _Resource:
|
|
146
|
+
def __init__(self, client: Relaya) -> None:
|
|
147
|
+
self._c = client
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class Projects(_Resource):
|
|
151
|
+
def list(self) -> List[JSON]:
|
|
152
|
+
return self._c._org("GET", "/projects")["data"]
|
|
153
|
+
|
|
154
|
+
def get(self, id: str) -> JSON:
|
|
155
|
+
return self._c._org("GET", f"/projects/{id}")
|
|
156
|
+
|
|
157
|
+
def create(self, name: str) -> JSON:
|
|
158
|
+
return self._c._org("POST", "/projects", {"name": name})
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class Webhooks(_Resource):
|
|
162
|
+
def list(self, project_id: Optional[str] = None) -> List[JSON]:
|
|
163
|
+
return self._c._org("GET", "/webhooks", params={"project_id": project_id})["data"]
|
|
164
|
+
|
|
165
|
+
def get(self, id: str) -> JSON:
|
|
166
|
+
return self._c._org("GET", f"/webhooks/{id}")
|
|
167
|
+
|
|
168
|
+
def create(self, project_id: str, name: str, provider: str = "generic", **fields: Any) -> JSON:
|
|
169
|
+
"""Create an inbound webhook; give its ``ingest_url`` to the provider.
|
|
170
|
+
Optional: signing_secret, signature_header."""
|
|
171
|
+
return self._c._org("POST", "/webhooks", {"project_id": project_id, "name": name, "provider": provider, **fields})
|
|
172
|
+
|
|
173
|
+
def update(self, id: str, **fields: Any) -> JSON:
|
|
174
|
+
"""Fields: name, status ("active"/"paused"), signing_secret, signature_header."""
|
|
175
|
+
return self._c._org("PATCH", f"/webhooks/{id}", fields)
|
|
176
|
+
|
|
177
|
+
def rotate_url(self, id: str) -> JSON:
|
|
178
|
+
"""Issue a new ingest URL; the old one stops working."""
|
|
179
|
+
return self._c._org("POST", f"/webhooks/{id}/rotate-url")
|
|
180
|
+
|
|
181
|
+
def delete(self, id: str) -> None:
|
|
182
|
+
self._c._org("DELETE", f"/webhooks/{id}")
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class Events(_Resource):
|
|
186
|
+
def list(self, **filters: Any) -> JSON:
|
|
187
|
+
"""One page, newest first: ``{"data": [...], "next_cursor": ...}``.
|
|
188
|
+
|
|
189
|
+
Filters: project_id, webhook_id, type, status, signature, contract_status, dedup_key,
|
|
190
|
+
since, until (datetime or RFC 3339), limit (1-200), cursor.
|
|
191
|
+
"""
|
|
192
|
+
return self._c._org("GET", "/events", params=filters)
|
|
193
|
+
|
|
194
|
+
def iterate(self, **filters: Any) -> Iterator[JSON]:
|
|
195
|
+
"""Every matching event, newest first, fetching pages as you go."""
|
|
196
|
+
filters.pop("cursor", None)
|
|
197
|
+
cursor = None
|
|
198
|
+
while True:
|
|
199
|
+
page = self.list(**filters, cursor=cursor)
|
|
200
|
+
yield from page["data"]
|
|
201
|
+
cursor = page.get("next_cursor")
|
|
202
|
+
if not cursor:
|
|
203
|
+
return
|
|
204
|
+
|
|
205
|
+
def get(self, id: str) -> JSON:
|
|
206
|
+
"""Full event: payload (sensitive fields masked), headers, deliveries and contract findings."""
|
|
207
|
+
return self._c._org("GET", f"/events/{id}")
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
class Destinations(_Resource):
|
|
211
|
+
def list(self, webhook_id: str) -> List[JSON]:
|
|
212
|
+
return self._c._org("GET", f"/webhooks/{webhook_id}/destinations")["data"]
|
|
213
|
+
|
|
214
|
+
def create(self, webhook_id: str, name: str, url: str, **fields: Any) -> JSON:
|
|
215
|
+
"""Returns ``{"destination": ..., "signing_secret": ...}``. The secret is shown once.
|
|
216
|
+
Optional: max_attempts, timeout_ms, enabled."""
|
|
217
|
+
return self._c._org("POST", f"/webhooks/{webhook_id}/destinations", {"name": name, "url": url, **fields})
|
|
218
|
+
|
|
219
|
+
def update(self, id: str, **fields: Any) -> JSON:
|
|
220
|
+
return self._c._org("PATCH", f"/destinations/{id}", fields)
|
|
221
|
+
|
|
222
|
+
def delete(self, id: str) -> None:
|
|
223
|
+
self._c._org("DELETE", f"/destinations/{id}")
|
|
224
|
+
|
|
225
|
+
def rotate_secret(self, id: str) -> str:
|
|
226
|
+
return self._c._org("POST", f"/destinations/{id}/rotate-secret")["signing_secret"]
|
|
227
|
+
|
|
228
|
+
def test(self, id: str) -> JSON:
|
|
229
|
+
"""Send a signed test request now; returns ``{ok, status_code, duration_ms, response_body, error}``."""
|
|
230
|
+
return self._c._org("POST", f"/destinations/{id}/test")
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
class Deliveries(_Resource):
|
|
234
|
+
def list(self, **filters: Any) -> List[JSON]:
|
|
235
|
+
"""The latest 100 matching deliveries. Filters: event_id, destination_id, webhook_id, status."""
|
|
236
|
+
return self._c._org("GET", "/deliveries", params=filters)["data"]
|
|
237
|
+
|
|
238
|
+
def get(self, id: str) -> JSON:
|
|
239
|
+
"""``{"delivery": ..., "attempts": [...]}``"""
|
|
240
|
+
return self._c._org("GET", f"/deliveries/{id}")
|
|
241
|
+
|
|
242
|
+
def retry(self, id: str) -> JSON:
|
|
243
|
+
"""Send a failed or retrying delivery again now."""
|
|
244
|
+
return self._c._org("POST", f"/deliveries/{id}/retry")
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
class Contracts(_Resource):
|
|
248
|
+
def list(self, webhook_id: Optional[str] = None) -> List[JSON]:
|
|
249
|
+
return self._c._org("GET", "/contracts", params={"webhook_id": webhook_id})["data"]
|
|
250
|
+
|
|
251
|
+
def get(self, id: str) -> JSON:
|
|
252
|
+
return self._c._org("GET", f"/contracts/{id}")
|
|
253
|
+
|
|
254
|
+
def create_version(self, id: str, critical_fields: Optional[List[str]] = None, source: str = "observed") -> int:
|
|
255
|
+
"""Activate a new version: from what was ``observed``, or the ``active`` one with new critical fields.
|
|
256
|
+
Returns the version number."""
|
|
257
|
+
body: JSON = {"source": source}
|
|
258
|
+
if critical_fields is not None:
|
|
259
|
+
body["critical_fields"] = critical_fields
|
|
260
|
+
return self._c._org("POST", f"/contracts/{id}/versions", body)["version"]
|
|
261
|
+
|
|
262
|
+
def relearn(self, id: str) -> None:
|
|
263
|
+
"""Throw away what was learned and start learning again."""
|
|
264
|
+
self._c._org("POST", f"/contracts/{id}/relearn")
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
class Incidents(_Resource):
|
|
268
|
+
def list(self, status: Optional[str] = None) -> List[JSON]:
|
|
269
|
+
"""status: "open" (default) or "resolved"."""
|
|
270
|
+
return self._c._org("GET", "/incidents", params={"status": status})["data"]
|
|
271
|
+
|
|
272
|
+
def resolve(self, id: str, resolution: str) -> None:
|
|
273
|
+
self._c._org("POST", f"/incidents/{id}/resolve", {"resolution": resolution})
|
|
274
|
+
|
|
275
|
+
def preview_replay(self, id: str) -> JSON:
|
|
276
|
+
"""Dry run: which events and deliveries a replay would resend. Changes nothing."""
|
|
277
|
+
return self._c._org("GET", f"/incidents/{id}/replay")
|
|
278
|
+
|
|
279
|
+
def replay(self, id: str) -> JSON:
|
|
280
|
+
"""Resend the incident's deliveries. The incident resolves itself if all of them succeed."""
|
|
281
|
+
return self._c._org("POST", f"/incidents/{id}/replay", {"confirm": True})
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
class Alerts(_Resource):
|
|
285
|
+
def channels(self) -> List[JSON]:
|
|
286
|
+
return self._c._org("GET", "/alert-channels")["data"]
|
|
287
|
+
|
|
288
|
+
def log(self) -> List[JSON]:
|
|
289
|
+
return self._c._org("GET", "/alerts")["data"]
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class RelayaError(Exception):
|
|
7
|
+
"""An error response from the Relaya API."""
|
|
8
|
+
|
|
9
|
+
def __init__(self, status: int, code: str, message: str, request_id: Optional[str] = None) -> None:
|
|
10
|
+
super().__init__(message)
|
|
11
|
+
#: HTTP status, or 0 when no response arrived.
|
|
12
|
+
self.status = status
|
|
13
|
+
#: e.g. "not_found", "bad_request", "network_error", "timeout".
|
|
14
|
+
self.code = code
|
|
15
|
+
self.message = message
|
|
16
|
+
self.request_id = request_id
|
|
17
|
+
|
|
18
|
+
def __str__(self) -> str:
|
|
19
|
+
return self.message if self.status == 0 else f"{self.status} {self.code}: {self.message}"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class WebhookVerificationError(Exception):
|
|
23
|
+
"""A request claiming to come from Relaya failed signature verification.
|
|
24
|
+
|
|
25
|
+
``reason`` is one of: missing_signature, malformed_signature, timestamp_out_of_range, signature_mismatch.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(self, reason: str, message: str) -> None:
|
|
29
|
+
super().__init__(message)
|
|
30
|
+
self.reason = reason
|
|
File without changes
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Verify requests Relaya forwards to your endpoints."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import hmac
|
|
7
|
+
import json
|
|
8
|
+
import time
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
from typing import Any, Mapping, Optional, Sequence, Union
|
|
12
|
+
|
|
13
|
+
from .errors import WebhookVerificationError
|
|
14
|
+
|
|
15
|
+
HEADER_SIGNATURE = "Relaya-Signature"
|
|
16
|
+
HEADER_IDEMPOTENCY_KEY = "Idempotency-Key"
|
|
17
|
+
HEADER_EVENT_ID = "Relaya-Event-Id"
|
|
18
|
+
HEADER_DELIVERY_ID = "Relaya-Delivery-Id"
|
|
19
|
+
HEADER_ATTEMPT = "Relaya-Attempt"
|
|
20
|
+
HEADER_REPLAY = "Relaya-Replay"
|
|
21
|
+
HEADER_EVENT_TYPE = "Relaya-Event-Type"
|
|
22
|
+
|
|
23
|
+
#: Reject signatures older (or newer) than this many seconds by default.
|
|
24
|
+
DEFAULT_TOLERANCE = 300
|
|
25
|
+
|
|
26
|
+
Body = Union[bytes, bytearray, memoryview, str]
|
|
27
|
+
Secrets = Union[str, Sequence[str]]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _bytes(body: Body) -> bytes:
|
|
31
|
+
if isinstance(body, str):
|
|
32
|
+
return body.encode("utf-8")
|
|
33
|
+
if isinstance(body, (bytes, bytearray, memoryview)):
|
|
34
|
+
return bytes(body)
|
|
35
|
+
raise TypeError(
|
|
36
|
+
"relaya: pass the raw request body (bytes or str), not parsed JSON. "
|
|
37
|
+
"Django: request.body, Flask: request.get_data(), FastAPI: await request.body()"
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _header(headers: Mapping[str, Any], name: str) -> Optional[str]:
|
|
42
|
+
value = headers.get(name)
|
|
43
|
+
if value is None:
|
|
44
|
+
lower = name.lower()
|
|
45
|
+
for k, v in headers.items():
|
|
46
|
+
if k.lower() == lower or k.upper() == "HTTP_" + lower.upper().replace("-", "_"):
|
|
47
|
+
value = v
|
|
48
|
+
break
|
|
49
|
+
if isinstance(value, (list, tuple)):
|
|
50
|
+
value = value[0] if value else None
|
|
51
|
+
return None if value is None else str(value)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def verify_signature(
|
|
55
|
+
body: Body,
|
|
56
|
+
signature_header: Optional[str],
|
|
57
|
+
secret: Secrets,
|
|
58
|
+
tolerance: int = DEFAULT_TOLERANCE,
|
|
59
|
+
now: Optional[float] = None,
|
|
60
|
+
) -> datetime:
|
|
61
|
+
"""Check a ``Relaya-Signature`` header (``t=<unix>,v1=<hex HMAC-SHA256(secret, "<t>.<body>")>``).
|
|
62
|
+
|
|
63
|
+
``secret`` may be a list while rotating (any match passes). ``tolerance=0`` disables the age check.
|
|
64
|
+
Returns when the request was signed; raises :class:`WebhookVerificationError`.
|
|
65
|
+
"""
|
|
66
|
+
secrets = [secret] if isinstance(secret, str) else [s for s in secret]
|
|
67
|
+
secrets = [s for s in secrets if s]
|
|
68
|
+
if not secrets:
|
|
69
|
+
raise ValueError("relaya: a signing secret is required")
|
|
70
|
+
if not signature_header:
|
|
71
|
+
raise WebhookVerificationError("missing_signature", "The Relaya-Signature header is missing")
|
|
72
|
+
|
|
73
|
+
timestamp: Optional[int] = None
|
|
74
|
+
signatures = []
|
|
75
|
+
for part in signature_header.split(","):
|
|
76
|
+
key, _, value = part.strip().partition("=")
|
|
77
|
+
if key == "t":
|
|
78
|
+
try:
|
|
79
|
+
timestamp = int(value)
|
|
80
|
+
except ValueError:
|
|
81
|
+
timestamp = None
|
|
82
|
+
elif key == "v1" and value:
|
|
83
|
+
signatures.append(value.lower())
|
|
84
|
+
if timestamp is None or not signatures:
|
|
85
|
+
raise WebhookVerificationError("malformed_signature", "The Relaya-Signature header is malformed")
|
|
86
|
+
|
|
87
|
+
current = time.time() if now is None else now
|
|
88
|
+
if tolerance > 0 and abs(current - timestamp) > tolerance:
|
|
89
|
+
raise WebhookVerificationError(
|
|
90
|
+
"timestamp_out_of_range", f"The signature is older than {tolerance} seconds (or from the future)"
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
signed = f"{timestamp}.".encode() + _bytes(body)
|
|
94
|
+
for s in secrets:
|
|
95
|
+
expected = hmac.new(s.encode("utf-8"), signed, hashlib.sha256).hexdigest()
|
|
96
|
+
if any(hmac.compare_digest(expected, sig) for sig in signatures):
|
|
97
|
+
return datetime.fromtimestamp(timestamp, tz=timezone.utc)
|
|
98
|
+
raise WebhookVerificationError(
|
|
99
|
+
"signature_mismatch", "The signature does not match: check the signing secret and that you pass the raw body"
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def is_valid_signature(body: Body, signature_header: Optional[str], secret: Secrets, **kwargs: Any) -> bool:
|
|
104
|
+
"""Like :func:`verify_signature` but returns True/False."""
|
|
105
|
+
try:
|
|
106
|
+
verify_signature(body, signature_header, secret, **kwargs)
|
|
107
|
+
return True
|
|
108
|
+
except WebhookVerificationError:
|
|
109
|
+
return False
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@dataclass(frozen=True)
|
|
113
|
+
class Delivery:
|
|
114
|
+
"""A verified request forwarded by Relaya."""
|
|
115
|
+
|
|
116
|
+
#: The same across retries and replays of one delivery: dedupe on this.
|
|
117
|
+
idempotency_key: str
|
|
118
|
+
delivery_id: str
|
|
119
|
+
event_id: str
|
|
120
|
+
#: e.g. "payment.captured", when Relaya could tell.
|
|
121
|
+
event_type: Optional[str]
|
|
122
|
+
#: 1 for the first try, then 2, 3… on retries.
|
|
123
|
+
attempt: int
|
|
124
|
+
#: Set when the request is part of an incident replay.
|
|
125
|
+
replay_id: Optional[str]
|
|
126
|
+
signed_at: datetime
|
|
127
|
+
#: The provider's original body, byte for byte.
|
|
128
|
+
body: bytes = field(repr=False)
|
|
129
|
+
|
|
130
|
+
def json(self) -> Any:
|
|
131
|
+
return json.loads(self.body)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def verify_delivery(body: Body, headers: Mapping[str, Any], secret: Secrets, **kwargs: Any) -> Delivery:
|
|
135
|
+
"""Verify a request forwarded by Relaya and return its details.
|
|
136
|
+
|
|
137
|
+
``headers`` can be Django's ``request.headers`` (or ``request.META``), Flask's or FastAPI's ``request.headers``,
|
|
138
|
+
or a plain dict.
|
|
139
|
+
"""
|
|
140
|
+
raw = _bytes(body)
|
|
141
|
+
signed_at = verify_signature(raw, _header(headers, HEADER_SIGNATURE), secret, **kwargs)
|
|
142
|
+
delivery_id = _header(headers, HEADER_DELIVERY_ID) or ""
|
|
143
|
+
try:
|
|
144
|
+
attempt = max(1, int(_header(headers, HEADER_ATTEMPT) or 1))
|
|
145
|
+
except ValueError:
|
|
146
|
+
attempt = 1
|
|
147
|
+
return Delivery(
|
|
148
|
+
idempotency_key=_header(headers, HEADER_IDEMPOTENCY_KEY) or delivery_id,
|
|
149
|
+
delivery_id=delivery_id,
|
|
150
|
+
event_id=_header(headers, HEADER_EVENT_ID) or "",
|
|
151
|
+
event_type=_header(headers, HEADER_EVENT_TYPE),
|
|
152
|
+
attempt=attempt,
|
|
153
|
+
replay_id=_header(headers, HEADER_REPLAY),
|
|
154
|
+
signed_at=signed_at,
|
|
155
|
+
body=raw,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def verify_alert(body: Body, headers: Mapping[str, Any], secret: Secrets, **kwargs: Any) -> dict:
|
|
160
|
+
"""Verify an alert sent to a webhook alert channel and return it:
|
|
161
|
+
``{type, title, body, link, org_id, alert_id, sent_at}``."""
|
|
162
|
+
raw = _bytes(body)
|
|
163
|
+
verify_signature(raw, _header(headers, HEADER_SIGNATURE), secret, **kwargs)
|
|
164
|
+
return json.loads(raw)
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: relaya
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Verify webhooks forwarded by Relaya and call the Relaya API.
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/Dhirajrai12/relaya-sdks/tree/main/python
|
|
7
|
+
Project-URL: Source, https://github.com/Dhirajrai12/relaya-sdks
|
|
8
|
+
Keywords: relaya,webhooks,signature,hmac,integrations
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Typing :: Typed
|
|
13
|
+
Requires-Python: >=3.9
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
License-File: LICENSE
|
|
16
|
+
Dynamic: license-file
|
|
17
|
+
|
|
18
|
+
# relaya (Python)
|
|
19
|
+
|
|
20
|
+
Verify requests that Relaya forwards to your endpoints, and call the Relaya API. No dependencies; Python 3.9+.
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
pip install relaya
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Receive events
|
|
27
|
+
|
|
28
|
+
Relaya signs every request it forwards with your destination's signing secret (shown once when you add the destination). Always pass the **raw** body.
|
|
29
|
+
|
|
30
|
+
### Django
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
from django.http import HttpResponse, JsonResponse
|
|
34
|
+
from django.views.decorators.csrf import csrf_exempt
|
|
35
|
+
from django.views.decorators.http import require_POST
|
|
36
|
+
from relaya import verify_delivery, WebhookVerificationError
|
|
37
|
+
|
|
38
|
+
@csrf_exempt
|
|
39
|
+
@require_POST
|
|
40
|
+
def relaya_webhook(request):
|
|
41
|
+
try:
|
|
42
|
+
delivery = verify_delivery(request.body, request.headers, settings.RELAYA_SIGNING_SECRET)
|
|
43
|
+
except WebhookVerificationError as e:
|
|
44
|
+
return JsonResponse({"error": e.reason}, status=400)
|
|
45
|
+
if already_processed(delivery.idempotency_key):
|
|
46
|
+
return HttpResponse()
|
|
47
|
+
handle(delivery.json())
|
|
48
|
+
return HttpResponse()
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### Flask
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
@app.post("/webhooks/relaya")
|
|
55
|
+
def relaya_webhook():
|
|
56
|
+
try:
|
|
57
|
+
delivery = verify_delivery(request.get_data(), request.headers, os.environ["RELAYA_SIGNING_SECRET"])
|
|
58
|
+
except WebhookVerificationError as e:
|
|
59
|
+
return {"error": e.reason}, 400
|
|
60
|
+
handle(delivery.json())
|
|
61
|
+
return "", 200
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### FastAPI
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
@app.post("/webhooks/relaya")
|
|
68
|
+
async def relaya_webhook(request: Request):
|
|
69
|
+
try:
|
|
70
|
+
delivery = verify_delivery(await request.body(), request.headers, os.environ["RELAYA_SIGNING_SECRET"])
|
|
71
|
+
except WebhookVerificationError as e:
|
|
72
|
+
raise HTTPException(400, e.reason)
|
|
73
|
+
await handle(delivery.json())
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Return a 5xx and Relaya retries later with the same idempotency key.
|
|
77
|
+
|
|
78
|
+
| `Delivery` field | |
|
|
79
|
+
|---|---|
|
|
80
|
+
| `idempotency_key` | The same across retries and replays of one delivery. Dedupe on this. |
|
|
81
|
+
| `event_id`, `delivery_id` | Relaya's IDs. |
|
|
82
|
+
| `event_type` | e.g. `payment.captured`, when Relaya could tell. |
|
|
83
|
+
| `attempt` | 1, then 2, 3… on retries. |
|
|
84
|
+
| `replay_id` | Set when the request is part of an incident replay. |
|
|
85
|
+
| `body`, `json()` | The provider's original body, unchanged. |
|
|
86
|
+
|
|
87
|
+
`e.reason` is one of `missing_signature`, `malformed_signature`, `timestamp_out_of_range`, `signature_mismatch`. Pass a list of secrets while rotating; `tolerance=` sets the maximum signature age in seconds (default 300, `0` disables). Webhook alert channels: `verify_alert(body, headers, secret)`.
|
|
88
|
+
|
|
89
|
+
## Call the API
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
from datetime import datetime, timedelta, timezone
|
|
93
|
+
from relaya import Relaya
|
|
94
|
+
|
|
95
|
+
relaya = Relaya() # reads RELAYA_API_KEY
|
|
96
|
+
|
|
97
|
+
for event in relaya.events.iterate(contract_status="breaking", since=datetime.now(timezone.utc) - timedelta(days=7)):
|
|
98
|
+
print(event["type"], event["received_at"])
|
|
99
|
+
|
|
100
|
+
for d in relaya.deliveries.list(status="failed"):
|
|
101
|
+
relaya.deliveries.retry(d["id"])
|
|
102
|
+
|
|
103
|
+
for incident in relaya.incidents.list("open"):
|
|
104
|
+
plan = relaya.incidents.preview_replay(incident["id"]) # dry run
|
|
105
|
+
relaya.incidents.replay(incident["id"]) # resolves itself once every delivery succeeds
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Resources: `projects`, `webhooks`, `events` (`list`, `iterate`, `get`), `destinations`, `deliveries`, `contracts`, `incidents`, `alerts`. Responses are dicts with the API's field names. `relaya.request(method, path, body, params)` reaches anything else.
|
|
109
|
+
|
|
110
|
+
Options: `api_key` (or `RELAYA_API_KEY`), `base_url` (or `RELAYA_BASE_URL`), `org_id` (only with a session token), `timeout` (30 s), `max_retries` (2; GET only, on network errors, 429 and 5xx).
|
|
111
|
+
|
|
112
|
+
Errors raise `RelayaError` with `status`, `code` and `request_id`.
|
|
113
|
+
|
|
114
|
+
## Development
|
|
115
|
+
|
|
116
|
+
```sh
|
|
117
|
+
PYTHONPATH=src python -m unittest discover -s tests
|
|
118
|
+
RELAYA_IT_API_URL=http://127.0.0.1:18080 PYTHONPATH=src python -m unittest tests.test_integration # against a running dev stack
|
|
119
|
+
```
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/relaya/__init__.py
|
|
5
|
+
src/relaya/client.py
|
|
6
|
+
src/relaya/errors.py
|
|
7
|
+
src/relaya/py.typed
|
|
8
|
+
src/relaya/webhooks.py
|
|
9
|
+
src/relaya.egg-info/PKG-INFO
|
|
10
|
+
src/relaya.egg-info/SOURCES.txt
|
|
11
|
+
src/relaya.egg-info/dependency_links.txt
|
|
12
|
+
src/relaya.egg-info/top_level.txt
|
|
13
|
+
tests/test_client.py
|
|
14
|
+
tests/test_integration.py
|
|
15
|
+
tests/test_webhooks.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
relaya
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import threading
|
|
3
|
+
import unittest
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
6
|
+
from urllib.parse import parse_qs, urlparse
|
|
7
|
+
|
|
8
|
+
from relaya import Relaya, RelayaError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class FakeAPI:
|
|
12
|
+
"""A tiny HTTP server: routes "METHOD /path" to (status, body, headers) callables."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, routes):
|
|
15
|
+
self.calls = []
|
|
16
|
+
api = self
|
|
17
|
+
|
|
18
|
+
class Handler(BaseHTTPRequestHandler):
|
|
19
|
+
def log_message(self, *args):
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
def handle_any(self):
|
|
23
|
+
u = urlparse(self.path)
|
|
24
|
+
length = int(self.headers.get("Content-Length") or 0)
|
|
25
|
+
body = json.loads(self.rfile.read(length)) if length else None
|
|
26
|
+
call = {"method": self.command, "path": u.path, "query": parse_qs(u.query), "body": body, "auth": self.headers.get("Authorization")}
|
|
27
|
+
api.calls.append(call)
|
|
28
|
+
route = routes.get(f"{self.command} {u.path}")
|
|
29
|
+
n = sum(1 for c in api.calls if c["method"] == self.command and c["path"] == u.path)
|
|
30
|
+
status, payload, headers = route(call, n) if route else (404, {"error": {"code": "not_found", "message": "no route"}}, {})
|
|
31
|
+
self.send_response(status)
|
|
32
|
+
for k, v in headers.items():
|
|
33
|
+
self.send_header(k, v)
|
|
34
|
+
data = b"" if payload is None else json.dumps(payload).encode()
|
|
35
|
+
self.send_header("Content-Length", str(len(data)))
|
|
36
|
+
self.end_headers()
|
|
37
|
+
self.wfile.write(data)
|
|
38
|
+
|
|
39
|
+
do_GET = do_POST = do_PATCH = do_DELETE = handle_any
|
|
40
|
+
|
|
41
|
+
self.server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
|
42
|
+
threading.Thread(target=self.server.serve_forever, daemon=True).start()
|
|
43
|
+
self.url = f"http://127.0.0.1:{self.server.server_address[1]}"
|
|
44
|
+
|
|
45
|
+
def close(self):
|
|
46
|
+
self.server.shutdown()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ClientTest(unittest.TestCase):
|
|
50
|
+
def api(self, routes):
|
|
51
|
+
a = FakeAPI(routes)
|
|
52
|
+
self.addCleanup(a.close)
|
|
53
|
+
return a
|
|
54
|
+
|
|
55
|
+
def test_org_lookup_once_and_auth(self):
|
|
56
|
+
a = self.api({
|
|
57
|
+
"GET /v1/me": lambda c, n: (200, {"api_key": {"org_id": "org1"}}, {}),
|
|
58
|
+
"GET /v1/orgs/org1/projects": lambda c, n: (200, {"data": [{"id": "p1"}]}, {}),
|
|
59
|
+
})
|
|
60
|
+
r = Relaya("rk_test", base_url=a.url + "/")
|
|
61
|
+
self.assertEqual(r.projects.list()[0]["id"], "p1")
|
|
62
|
+
r.projects.list()
|
|
63
|
+
self.assertEqual(sum(1 for c in a.calls if c["path"] == "/v1/me"), 1)
|
|
64
|
+
self.assertTrue(all(c["auth"] == "Bearer rk_test" for c in a.calls))
|
|
65
|
+
|
|
66
|
+
def test_filters_and_iterate(self):
|
|
67
|
+
def events(c, n):
|
|
68
|
+
if c["query"].get("cursor") == ["c2"]:
|
|
69
|
+
return 200, {"data": [{"id": "e3"}], "next_cursor": None}, {}
|
|
70
|
+
return 200, {"data": [{"id": "e1"}, {"id": "e2"}], "next_cursor": "c2"}, {}
|
|
71
|
+
|
|
72
|
+
a = self.api({"GET /v1/orgs/o/events": events})
|
|
73
|
+
r = Relaya("rk", org_id="o", base_url=a.url)
|
|
74
|
+
ids = [e["id"] for e in r.events.iterate(contract_status="breaking", since=datetime(2026, 9, 1, tzinfo=timezone.utc), limit=2, type=None)]
|
|
75
|
+
self.assertEqual(ids, ["e1", "e2", "e3"])
|
|
76
|
+
q = a.calls[0]["query"]
|
|
77
|
+
self.assertEqual(q["contract_status"], ["breaking"])
|
|
78
|
+
self.assertEqual(q["since"], ["2026-09-01T00:00:00Z"])
|
|
79
|
+
self.assertNotIn("type", q)
|
|
80
|
+
|
|
81
|
+
def test_errors_and_retries(self):
|
|
82
|
+
a = self.api({
|
|
83
|
+
"GET /v1/orgs/o/incidents": lambda c, n: (503, None, {"Retry-After": "0"}) if n < 3 else (200, {"data": []}, {}),
|
|
84
|
+
"POST /v1/orgs/o/incidents/i1/replay": lambda c, n: (503, None, {"Retry-After": "0"}),
|
|
85
|
+
})
|
|
86
|
+
r = Relaya("rk", org_id="o", base_url=a.url)
|
|
87
|
+
self.assertEqual(r.incidents.list("open"), [])
|
|
88
|
+
with self.assertRaises(RelayaError) as cm:
|
|
89
|
+
r.incidents.replay("i1")
|
|
90
|
+
self.assertEqual(cm.exception.status, 503)
|
|
91
|
+
posts = [c for c in a.calls if c["method"] == "POST"]
|
|
92
|
+
self.assertEqual(len(posts), 1)
|
|
93
|
+
self.assertEqual(posts[0]["body"], {"confirm": True})
|
|
94
|
+
with self.assertRaises(RelayaError) as cm:
|
|
95
|
+
r.webhooks.get("nope")
|
|
96
|
+
self.assertEqual((cm.exception.status, cm.exception.code), (404, "not_found"))
|
|
97
|
+
|
|
98
|
+
def test_network_error(self):
|
|
99
|
+
r = Relaya("rk", org_id="o", base_url="http://127.0.0.1:1", max_retries=0)
|
|
100
|
+
with self.assertRaises(RelayaError) as cm:
|
|
101
|
+
r.projects.list()
|
|
102
|
+
self.assertEqual((cm.exception.status, cm.exception.code), (0, "network_error"))
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
if __name__ == "__main__":
|
|
106
|
+
unittest.main()
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""End-to-end against a running Relaya (API + ingest + worker). Skipped unless RELAYA_IT_API_URL is set:
|
|
2
|
+
|
|
3
|
+
RELAYA_IT_API_URL=http://127.0.0.1:18080 python -m unittest tests.test_integration
|
|
4
|
+
|
|
5
|
+
The server must allow http://127.0.0.1 destinations (APP_ENV=dev) and use CONTRACT_MIN_SAMPLES=3.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
import unittest
|
|
13
|
+
import urllib.request
|
|
14
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
15
|
+
|
|
16
|
+
from relaya import Relaya, RelayaError, WebhookVerificationError, verify_delivery
|
|
17
|
+
|
|
18
|
+
API = os.environ.get("RELAYA_IT_API_URL")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def wait_for(what, fn, timeout=15):
|
|
22
|
+
until = time.time() + timeout
|
|
23
|
+
while True:
|
|
24
|
+
v = fn()
|
|
25
|
+
if v:
|
|
26
|
+
return v
|
|
27
|
+
if time.time() > until:
|
|
28
|
+
raise AssertionError(f"timed out waiting for {what}")
|
|
29
|
+
time.sleep(0.15)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def post(path, body, token=None):
|
|
33
|
+
req = urllib.request.Request(API + path, data=json.dumps(body).encode(), method="POST", headers={"Content-Type": "application/json", **({"Authorization": f"Bearer {token}"} if token else {})})
|
|
34
|
+
with urllib.request.urlopen(req) as r:
|
|
35
|
+
return json.loads(r.read())
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@unittest.skipUnless(API, "set RELAYA_IT_API_URL to run")
|
|
39
|
+
class IntegrationTest(unittest.TestCase):
|
|
40
|
+
def test_sdk_against_live_relaya(self):
|
|
41
|
+
session = post("/v1/auth/signup", {"email": f"py-sdk-{time.time_ns()}@example.com", "password": "sdk-test-password-1", "org_name": "Python SDK"})
|
|
42
|
+
org_id = Relaya(session["token"], base_url=API).request("GET", "/v1/me")["orgs"][0]["id"]
|
|
43
|
+
key = post(f"/v1/orgs/{org_id}/api-keys", {"name": "sdk", "role": "admin"}, session["token"])
|
|
44
|
+
|
|
45
|
+
relaya = Relaya(key["key"], base_url=API)
|
|
46
|
+
self.assertEqual(relaya.org_id, org_id)
|
|
47
|
+
|
|
48
|
+
# A customer endpoint verifying with the SDK.
|
|
49
|
+
state = {"secret": "", "fail_next": 0, "received": []}
|
|
50
|
+
|
|
51
|
+
class Endpoint(BaseHTTPRequestHandler):
|
|
52
|
+
def log_message(self, *a):
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
def do_POST(self):
|
|
56
|
+
body = self.rfile.read(int(self.headers["Content-Length"]))
|
|
57
|
+
try:
|
|
58
|
+
d = verify_delivery(body, self.headers, state["secret"])
|
|
59
|
+
except WebhookVerificationError as e:
|
|
60
|
+
self.send_response(400)
|
|
61
|
+
self.end_headers()
|
|
62
|
+
self.wfile.write(e.reason.encode())
|
|
63
|
+
return
|
|
64
|
+
state["received"].append(d)
|
|
65
|
+
failing = state["fail_next"] > 0
|
|
66
|
+
state["fail_next"] -= 1
|
|
67
|
+
self.send_response(500 if failing else 200)
|
|
68
|
+
self.end_headers()
|
|
69
|
+
|
|
70
|
+
server = ThreadingHTTPServer(("127.0.0.1", 0), Endpoint)
|
|
71
|
+
threading.Thread(target=server.serve_forever, daemon=True).start()
|
|
72
|
+
self.addCleanup(server.shutdown)
|
|
73
|
+
received = state["received"]
|
|
74
|
+
|
|
75
|
+
project = relaya.projects.create("SDK")
|
|
76
|
+
wh = relaya.webhooks.create(project["id"], "Payments", "generic")
|
|
77
|
+
dest = relaya.destinations.create(wh["id"], "My app", f"http://127.0.0.1:{server.server_address[1]}/hooks")
|
|
78
|
+
state["secret"] = dest["signing_secret"]
|
|
79
|
+
|
|
80
|
+
def send(event_id, body):
|
|
81
|
+
req = urllib.request.Request(wh["ingest_url"], data=json.dumps(body).encode(), method="POST", headers={"Content-Type": "application/json", "X-Event-Id": event_id})
|
|
82
|
+
with urllib.request.urlopen(req) as r:
|
|
83
|
+
self.assertEqual(r.status, 200)
|
|
84
|
+
|
|
85
|
+
for i in range(1, 4):
|
|
86
|
+
send(f"e{i}", {"type": "payment.captured", "amount": 100 * i})
|
|
87
|
+
|
|
88
|
+
wait_for("3 deliveries", lambda: len(received) >= 3)
|
|
89
|
+
first = received[0]
|
|
90
|
+
self.assertEqual(first.idempotency_key, first.delivery_id)
|
|
91
|
+
self.assertEqual(first.attempt, 1)
|
|
92
|
+
self.assertEqual(first.event_type, "payment.captured")
|
|
93
|
+
self.assertIsInstance(first.json()["amount"], int)
|
|
94
|
+
|
|
95
|
+
events = list(relaya.events.iterate(webhook_id=wh["id"], limit=2))
|
|
96
|
+
self.assertEqual(len(events), 3)
|
|
97
|
+
self.assertEqual(relaya.events.get(first.event_id)["payload_json"]["type"], "payment.captured")
|
|
98
|
+
|
|
99
|
+
ok = wait_for("succeeded deliveries", lambda: (lambda l: len(l) == 3 and l)(relaya.deliveries.list(webhook_id=wh["id"], status="succeeded")))
|
|
100
|
+
self.assertEqual(relaya.deliveries.get(ok[0]["id"])["attempts"][0]["outcome"], "succeeded")
|
|
101
|
+
|
|
102
|
+
# Failing endpoint, then a manual retry.
|
|
103
|
+
state["fail_next"] = 1
|
|
104
|
+
send("e4", {"type": "payment.captured", "amount": 400})
|
|
105
|
+
retrying = wait_for("a retrying delivery", lambda: relaya.deliveries.list(webhook_id=wh["id"], status="retrying"))[0]
|
|
106
|
+
relaya.deliveries.retry(retrying["id"])
|
|
107
|
+
wait_for("the retry to succeed", lambda: relaya.deliveries.get(retrying["id"])["delivery"]["status"] == "succeeded")
|
|
108
|
+
self.assertEqual(received[-1].attempt, 2)
|
|
109
|
+
self.assertEqual(received[-1].idempotency_key, retrying["id"])
|
|
110
|
+
|
|
111
|
+
# Contract -> incident -> replay.
|
|
112
|
+
contract = wait_for("a proposed contract", lambda: next((c for c in relaya.contracts.list(wh["id"]) if c["status"] != "learning"), None))
|
|
113
|
+
self.assertGreaterEqual(relaya.contracts.create_version(contract["id"], ["amount"], "observed"), 1)
|
|
114
|
+
send("e5", {"type": "payment.captured", "amount": "500"})
|
|
115
|
+
incident = wait_for("an open incident", lambda: relaya.incidents.list("open"))[0]
|
|
116
|
+
self.assertIn("amount changed type", incident["title"])
|
|
117
|
+
self.assertEqual(relaya.incidents.preview_replay(incident["id"])["events"], 1)
|
|
118
|
+
before = len(received)
|
|
119
|
+
replay = relaya.incidents.replay(incident["id"])
|
|
120
|
+
self.assertEqual(replay["total"], 1)
|
|
121
|
+
wait_for("the replayed delivery", lambda: len(received) > before)
|
|
122
|
+
self.assertEqual(received[-1].replay_id, replay["id"])
|
|
123
|
+
wait_for("the incident to resolve", lambda: relaya.incidents.list("open") == [])
|
|
124
|
+
|
|
125
|
+
# Destination test and API errors.
|
|
126
|
+
self.assertTrue(relaya.destinations.test(dest["destination"]["id"])["ok"])
|
|
127
|
+
with self.assertRaises(RelayaError) as cm:
|
|
128
|
+
relaya.webhooks.get("00000000-0000-0000-0000-000000000000")
|
|
129
|
+
self.assertEqual(cm.exception.status, 404)
|
|
130
|
+
with self.assertRaises(RelayaError) as cm:
|
|
131
|
+
relaya.deliveries.retry(ok[0]["id"])
|
|
132
|
+
self.assertEqual(cm.exception.status, 409)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
if __name__ == "__main__":
|
|
136
|
+
unittest.main()
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import hmac
|
|
3
|
+
import json
|
|
4
|
+
import time
|
|
5
|
+
import unittest
|
|
6
|
+
|
|
7
|
+
from relaya import WebhookVerificationError, is_valid_signature, verify_alert, verify_delivery, verify_signature
|
|
8
|
+
|
|
9
|
+
SECRET = "whsec_test_secret"
|
|
10
|
+
BODY = '{"type":"payment.captured","amount":100,"note":"héllo"}'.encode()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def sign(body: bytes, secret: str = SECRET, t: int = None) -> str:
|
|
14
|
+
"""Same algorithm as the Relaya worker: hex HMAC-SHA256(secret, "<t>.<body>")."""
|
|
15
|
+
t = int(time.time()) if t is None else t
|
|
16
|
+
return f"t={t},v1=" + hmac.new(secret.encode(), f"{t}.".encode() + body, hashlib.sha256).hexdigest()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class VerifySignatureTest(unittest.TestCase):
|
|
20
|
+
def reason(self, *args, **kwargs):
|
|
21
|
+
with self.assertRaises(WebhookVerificationError) as cm:
|
|
22
|
+
verify_signature(*args, **kwargs)
|
|
23
|
+
return cm.exception.reason
|
|
24
|
+
|
|
25
|
+
def test_valid_bytes_and_str(self):
|
|
26
|
+
h = sign(BODY)
|
|
27
|
+
verify_signature(BODY, h, SECRET)
|
|
28
|
+
verify_signature(BODY.decode(), h, SECRET)
|
|
29
|
+
verify_signature(bytearray(BODY), h, SECRET)
|
|
30
|
+
self.assertTrue(is_valid_signature(BODY, h, SECRET))
|
|
31
|
+
|
|
32
|
+
def test_rejections(self):
|
|
33
|
+
self.assertEqual(self.reason(BODY, sign(BODY, "other"), SECRET), "signature_mismatch")
|
|
34
|
+
self.assertEqual(self.reason(BODY + b" ", sign(BODY), SECRET), "signature_mismatch")
|
|
35
|
+
self.assertEqual(self.reason(BODY, None, SECRET), "missing_signature")
|
|
36
|
+
self.assertEqual(self.reason(BODY, "v1=abc", SECRET), "malformed_signature")
|
|
37
|
+
self.assertEqual(self.reason(BODY, "t=x,v1=abc", SECRET), "malformed_signature")
|
|
38
|
+
self.assertFalse(is_valid_signature(BODY, sign(BODY, "other"), SECRET))
|
|
39
|
+
|
|
40
|
+
def test_tolerance(self):
|
|
41
|
+
old = int(time.time()) - 301
|
|
42
|
+
self.assertEqual(self.reason(BODY, sign(BODY, t=old), SECRET), "timestamp_out_of_range")
|
|
43
|
+
verify_signature(BODY, sign(BODY, t=old), SECRET, tolerance=600)
|
|
44
|
+
verify_signature(BODY, sign(BODY, t=old), SECRET, tolerance=0)
|
|
45
|
+
at = verify_signature(BODY, sign(BODY, t=1_700_000_000), SECRET, now=1_700_000_005)
|
|
46
|
+
self.assertEqual(at.timestamp(), 1_700_000_000)
|
|
47
|
+
|
|
48
|
+
def test_rotation_and_bad_input(self):
|
|
49
|
+
verify_signature(BODY, sign(BODY, "new"), ["old", "new"])
|
|
50
|
+
with self.assertRaises(ValueError):
|
|
51
|
+
verify_signature(BODY, sign(BODY), [])
|
|
52
|
+
with self.assertRaisesRegex(TypeError, "raw request body"):
|
|
53
|
+
verify_signature(json.loads(BODY), sign(BODY), SECRET)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class VerifyDeliveryTest(unittest.TestCase):
|
|
57
|
+
def test_headers_any_case_and_django_meta(self):
|
|
58
|
+
plain = {
|
|
59
|
+
"relaya-signature": sign(BODY),
|
|
60
|
+
"Idempotency-Key": "dlv_1",
|
|
61
|
+
"RELAYA-DELIVERY-ID": "dlv_1",
|
|
62
|
+
"Relaya-Event-Id": "evt_1",
|
|
63
|
+
"Relaya-Attempt": "3",
|
|
64
|
+
"Relaya-Event-Type": "payment.captured",
|
|
65
|
+
}
|
|
66
|
+
meta = {"HTTP_" + k.upper().replace("-", "_"): v for k, v in plain.items()} # Django request.META
|
|
67
|
+
for headers in (plain, meta):
|
|
68
|
+
d = verify_delivery(BODY, headers, SECRET)
|
|
69
|
+
self.assertEqual(d.idempotency_key, "dlv_1")
|
|
70
|
+
self.assertEqual(d.event_id, "evt_1")
|
|
71
|
+
self.assertEqual(d.attempt, 3)
|
|
72
|
+
self.assertEqual(d.event_type, "payment.captured")
|
|
73
|
+
self.assertIsNone(d.replay_id)
|
|
74
|
+
self.assertEqual(d.json()["note"], "héllo")
|
|
75
|
+
|
|
76
|
+
def test_alert(self):
|
|
77
|
+
body = json.dumps({"type": "test", "title": "Test alert", "alert_id": 1}).encode()
|
|
78
|
+
self.assertEqual(verify_alert(body, {"Relaya-Signature": sign(body)}, SECRET)["title"], "Test alert")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
if __name__ == "__main__":
|
|
82
|
+
unittest.main()
|