webhookd-sdk 0.1.0__tar.gz → 0.2.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.
- {webhookd_sdk-0.1.0 → webhookd_sdk-0.2.0}/PKG-INFO +44 -2
- webhookd_sdk-0.2.0/README.md +96 -0
- {webhookd_sdk-0.1.0 → webhookd_sdk-0.2.0}/pyproject.toml +1 -1
- webhookd_sdk-0.2.0/tests/test_client.py +293 -0
- {webhookd_sdk-0.1.0 → webhookd_sdk-0.2.0}/tests/test_signature.py +16 -0
- {webhookd_sdk-0.1.0 → webhookd_sdk-0.2.0}/webhookd_sdk/__init__.py +2 -1
- webhookd_sdk-0.2.0/webhookd_sdk/client.py +301 -0
- {webhookd_sdk-0.1.0 → webhookd_sdk-0.2.0}/webhookd_sdk/signature.py +16 -3
- webhookd_sdk-0.1.0/README.md +0 -54
- webhookd_sdk-0.1.0/tests/test_client.py +0 -86
- webhookd_sdk-0.1.0/webhookd_sdk/client.py +0 -147
- {webhookd_sdk-0.1.0 → webhookd_sdk-0.2.0}/.gitignore +0 -0
- {webhookd_sdk-0.1.0 → webhookd_sdk-0.2.0}/LICENSE +0 -0
- {webhookd_sdk-0.1.0 → webhookd_sdk-0.2.0}/tests/test_e2e_webhookd.py +0 -0
- {webhookd_sdk-0.1.0 → webhookd_sdk-0.2.0}/tests/test_vectors.py +0 -0
- {webhookd_sdk-0.1.0 → webhookd_sdk-0.2.0}/webhookd_sdk/errors.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: webhookd-sdk
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Official Python SDK for NimbusNexus Webhooks — publish events + verify webhook signatures.
|
|
5
5
|
Project-URL: Homepage, https://github.com/NimbusNexus/Webhooks-sdks/tree/develop/python#readme
|
|
6
6
|
Project-URL: Repository, https://github.com/NimbusNexus/Webhooks-sdks
|
|
@@ -28,7 +28,8 @@ Description-Content-Type: text/markdown
|
|
|
28
28
|
|
|
29
29
|
# webhookd-sdk (Python)
|
|
30
30
|
|
|
31
|
-
Official Python SDK for **NimbusNexus Webhooks** — publish events,
|
|
31
|
+
Official Python SDK for **NimbusNexus Webhooks** — publish events, manage your endpoints / keys /
|
|
32
|
+
deliveries, and verify the webhooks you receive.
|
|
32
33
|
|
|
33
34
|
```sh
|
|
34
35
|
pip install webhookd-sdk
|
|
@@ -74,6 +75,47 @@ with Client("https://webhooks.example.com", api_key="whsk_…") as wh:
|
|
|
74
75
|
Transient failures (connection errors, `429`, `5xx`) are retried with backoff (a `429` honours
|
|
75
76
|
`Retry-After`); other `4xx` raise `WebhookdAPIError`.
|
|
76
77
|
|
|
78
|
+
## Manage endpoints, keys & deliveries (operators)
|
|
79
|
+
|
|
80
|
+
The same `Client` wraps the control-plane API — register receivers, mint keys, and drain the
|
|
81
|
+
dead-letter queue from code (needs an **admin**-scoped key). Management methods return the raw JSON as
|
|
82
|
+
`dict`s (snake_case, exactly as the API sends); list methods return a page —
|
|
83
|
+
`{"items": [...], "next_offset": int | None}`; delete/revoke return `None` (a `204`).
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
from webhookd_sdk import Client
|
|
87
|
+
|
|
88
|
+
wh = Client("https://webhooks.example.com", api_key="whsk_admin_…")
|
|
89
|
+
|
|
90
|
+
# --- Endpoints ----------------------------------------------------------------
|
|
91
|
+
# Create a receiver — its signing secret is in the response exactly once, so persist it now.
|
|
92
|
+
ep = wh.create_endpoint(
|
|
93
|
+
"https://your-app.example/webhooks",
|
|
94
|
+
subscriptions=[{"match_kind": "prefix", "pattern": "order."}],
|
|
95
|
+
description="orders service",
|
|
96
|
+
)
|
|
97
|
+
endpoint_id, signing_secret = ep["id"], ep["secret"]
|
|
98
|
+
|
|
99
|
+
wh.list_endpoints(environment="prod") # {"items": [...], "next_offset": ...}
|
|
100
|
+
wh.get_endpoint(endpoint_id)
|
|
101
|
+
|
|
102
|
+
# PATCH — send only the keys you want to change (omitted = unchanged, None = cleared):
|
|
103
|
+
wh.update_endpoint(endpoint_id, {"max_attempts": 10, "status": "disabled"})
|
|
104
|
+
|
|
105
|
+
wh.rotate_endpoint_secret(endpoint_id) # returns the new secret, once
|
|
106
|
+
wh.enable_endpoint(endpoint_id) # recover an auto-disabled endpoint
|
|
107
|
+
wh.delete_endpoint(endpoint_id) # -> None (204)
|
|
108
|
+
|
|
109
|
+
# --- API keys -----------------------------------------------------------------
|
|
110
|
+
key = wh.create_api_key("ci-publisher", scope="publish", expires_in_days=90)
|
|
111
|
+
print(key["key"]) # shown once
|
|
112
|
+
wh.revoke_api_key(key["id"]) # -> None (204)
|
|
113
|
+
|
|
114
|
+
# --- Deliveries / dead-letter recovery ----------------------------------------
|
|
115
|
+
for d in wh.list_deliveries(status="dead")["items"]:
|
|
116
|
+
wh.redeliver(d["id"])
|
|
117
|
+
```
|
|
118
|
+
|
|
77
119
|
## Develop
|
|
78
120
|
|
|
79
121
|
```sh
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# webhookd-sdk (Python)
|
|
2
|
+
|
|
3
|
+
Official Python SDK for **NimbusNexus Webhooks** — publish events, manage your endpoints / keys /
|
|
4
|
+
deliveries, and verify the webhooks you receive.
|
|
5
|
+
|
|
6
|
+
```sh
|
|
7
|
+
pip install webhookd-sdk
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
## Verify an incoming webhook (subscribers)
|
|
11
|
+
|
|
12
|
+
When webhookd delivers a webhook it signs the body with your endpoint's signing secret. **Always
|
|
13
|
+
verify the signature** before trusting the payload — it proves the request really came from webhookd
|
|
14
|
+
and wasn't tampered with or replayed.
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
from webhookd_sdk import verify
|
|
18
|
+
|
|
19
|
+
# In your webhook handler — pass the RAW request body bytes (do not re-serialize the JSON):
|
|
20
|
+
ok = verify(
|
|
21
|
+
secret=ENDPOINT_SIGNING_SECRET,
|
|
22
|
+
raw_body=request.body,
|
|
23
|
+
signature=request.headers["X-Webhook-Signature"],
|
|
24
|
+
timestamp=request.headers["X-Webhook-Timestamp"],
|
|
25
|
+
)
|
|
26
|
+
if not ok:
|
|
27
|
+
return Response(status_code=400) # forged, tampered, or outside the 300s replay window
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Publish an event (producers)
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
from webhookd_sdk import Client, WebhookdAPIError
|
|
34
|
+
|
|
35
|
+
with Client("https://webhooks.example.com", api_key="whsk_…") as wh:
|
|
36
|
+
try:
|
|
37
|
+
event = wh.publish(
|
|
38
|
+
"order.created",
|
|
39
|
+
{"order_id": "ord_123", "total": 4200},
|
|
40
|
+
idempotency_key="order-123", # makes the publish safe to retry
|
|
41
|
+
)
|
|
42
|
+
print(event.event_uid, event.deliveries_created)
|
|
43
|
+
except WebhookdAPIError as e:
|
|
44
|
+
print(e.status_code, e.code, e.message) # the stable {error:{code,message}} envelope
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Transient failures (connection errors, `429`, `5xx`) are retried with backoff (a `429` honours
|
|
48
|
+
`Retry-After`); other `4xx` raise `WebhookdAPIError`.
|
|
49
|
+
|
|
50
|
+
## Manage endpoints, keys & deliveries (operators)
|
|
51
|
+
|
|
52
|
+
The same `Client` wraps the control-plane API — register receivers, mint keys, and drain the
|
|
53
|
+
dead-letter queue from code (needs an **admin**-scoped key). Management methods return the raw JSON as
|
|
54
|
+
`dict`s (snake_case, exactly as the API sends); list methods return a page —
|
|
55
|
+
`{"items": [...], "next_offset": int | None}`; delete/revoke return `None` (a `204`).
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
from webhookd_sdk import Client
|
|
59
|
+
|
|
60
|
+
wh = Client("https://webhooks.example.com", api_key="whsk_admin_…")
|
|
61
|
+
|
|
62
|
+
# --- Endpoints ----------------------------------------------------------------
|
|
63
|
+
# Create a receiver — its signing secret is in the response exactly once, so persist it now.
|
|
64
|
+
ep = wh.create_endpoint(
|
|
65
|
+
"https://your-app.example/webhooks",
|
|
66
|
+
subscriptions=[{"match_kind": "prefix", "pattern": "order."}],
|
|
67
|
+
description="orders service",
|
|
68
|
+
)
|
|
69
|
+
endpoint_id, signing_secret = ep["id"], ep["secret"]
|
|
70
|
+
|
|
71
|
+
wh.list_endpoints(environment="prod") # {"items": [...], "next_offset": ...}
|
|
72
|
+
wh.get_endpoint(endpoint_id)
|
|
73
|
+
|
|
74
|
+
# PATCH — send only the keys you want to change (omitted = unchanged, None = cleared):
|
|
75
|
+
wh.update_endpoint(endpoint_id, {"max_attempts": 10, "status": "disabled"})
|
|
76
|
+
|
|
77
|
+
wh.rotate_endpoint_secret(endpoint_id) # returns the new secret, once
|
|
78
|
+
wh.enable_endpoint(endpoint_id) # recover an auto-disabled endpoint
|
|
79
|
+
wh.delete_endpoint(endpoint_id) # -> None (204)
|
|
80
|
+
|
|
81
|
+
# --- API keys -----------------------------------------------------------------
|
|
82
|
+
key = wh.create_api_key("ci-publisher", scope="publish", expires_in_days=90)
|
|
83
|
+
print(key["key"]) # shown once
|
|
84
|
+
wh.revoke_api_key(key["id"]) # -> None (204)
|
|
85
|
+
|
|
86
|
+
# --- Deliveries / dead-letter recovery ----------------------------------------
|
|
87
|
+
for d in wh.list_deliveries(status="dead")["items"]:
|
|
88
|
+
wh.redeliver(d["id"])
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Develop
|
|
92
|
+
|
|
93
|
+
```sh
|
|
94
|
+
pip install -e '.[dev]'
|
|
95
|
+
pytest && ruff check . && mypy webhookd_sdk
|
|
96
|
+
```
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "webhookd-sdk"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.0"
|
|
8
8
|
description = "Official Python SDK for NimbusNexus Webhooks — publish events + verify webhook signatures."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
"""Publish client — exercised against an httpx MockTransport (no network)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from webhookd_sdk import Client, WebhookdAPIError
|
|
11
|
+
|
|
12
|
+
_OK = {
|
|
13
|
+
"id": "evt_db",
|
|
14
|
+
"event_uid": "u1",
|
|
15
|
+
"event_type": "order.created",
|
|
16
|
+
"application": "default",
|
|
17
|
+
"environment": "prod",
|
|
18
|
+
"deliveries_created": 2,
|
|
19
|
+
"source": None,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _client(handler, **kw) -> Client:
|
|
24
|
+
return Client("https://wh.example.com", "whsk_x", transport=httpx.MockTransport(handler), **kw)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_publish_success_and_request_shape():
|
|
28
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
29
|
+
assert request.url.path == "/v1/events"
|
|
30
|
+
assert request.headers["Authorization"] == "Bearer whsk_x"
|
|
31
|
+
body = json.loads(request.content)
|
|
32
|
+
assert body["event_type"] == "order.created"
|
|
33
|
+
assert body["payload"] == {"id": 1} and body["environment"] == "prod"
|
|
34
|
+
return httpx.Response(201, json=_OK)
|
|
35
|
+
|
|
36
|
+
with _client(handler) as c:
|
|
37
|
+
ev = c.publish("order.created", {"id": 1})
|
|
38
|
+
assert ev.event_uid == "u1" and ev.deliveries_created == 2
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_idempotency_key_header():
|
|
42
|
+
seen: dict[str, str | None] = {}
|
|
43
|
+
|
|
44
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
45
|
+
seen["idem"] = request.headers.get("Idempotency-Key")
|
|
46
|
+
return httpx.Response(201, json=_OK)
|
|
47
|
+
|
|
48
|
+
with _client(handler) as c:
|
|
49
|
+
c.publish("order.created", {"id": 1}, idempotency_key="order-123")
|
|
50
|
+
assert seen["idem"] == "order-123"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_api_error_carries_envelope():
|
|
54
|
+
def handler(_request: httpx.Request) -> httpx.Response:
|
|
55
|
+
return httpx.Response(422, json={"error": {"code": "validation_error", "message": "bad"}})
|
|
56
|
+
|
|
57
|
+
with _client(handler) as c, pytest.raises(WebhookdAPIError) as ei:
|
|
58
|
+
c.publish("order.created", {})
|
|
59
|
+
assert ei.value.status_code == 422 and ei.value.code == "validation_error"
|
|
60
|
+
assert ei.value.message == "bad"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_retries_on_503_then_succeeds():
|
|
64
|
+
calls = {"n": 0}
|
|
65
|
+
|
|
66
|
+
def handler(_request: httpx.Request) -> httpx.Response:
|
|
67
|
+
calls["n"] += 1
|
|
68
|
+
if calls["n"] == 1:
|
|
69
|
+
return httpx.Response(503, json={"error": {"code": "unavailable", "message": "x"}})
|
|
70
|
+
return httpx.Response(201, json=_OK)
|
|
71
|
+
|
|
72
|
+
with _client(handler, max_retries=2) as c:
|
|
73
|
+
ev = c.publish("order.created", {})
|
|
74
|
+
assert calls["n"] == 2 and ev.event_uid == "u1"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_does_not_retry_4xx():
|
|
78
|
+
calls = {"n": 0}
|
|
79
|
+
|
|
80
|
+
def handler(_request: httpx.Request) -> httpx.Response:
|
|
81
|
+
calls["n"] += 1
|
|
82
|
+
return httpx.Response(404, json={"error": {"code": "not_found", "message": "no app"}})
|
|
83
|
+
|
|
84
|
+
with _client(handler, max_retries=2) as c, pytest.raises(WebhookdAPIError):
|
|
85
|
+
c.publish("order.created", {})
|
|
86
|
+
assert calls["n"] == 1 # a 404 is terminal — not retried
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# -- management: endpoints ----------------------------------------------------
|
|
90
|
+
|
|
91
|
+
_ENDPOINT = {
|
|
92
|
+
"id": "ep_1",
|
|
93
|
+
"url": "https://sub.example.com/hook",
|
|
94
|
+
"environment": "prod",
|
|
95
|
+
"application": "default",
|
|
96
|
+
"status": "enabled",
|
|
97
|
+
"secret": "whsec_shown_once",
|
|
98
|
+
"subscriptions": [{"match_kind": "prefix", "pattern": "order."}],
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_create_endpoint_returns_secret_and_request_shape():
|
|
103
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
104
|
+
assert request.method == "POST"
|
|
105
|
+
assert request.url.path == "/v1/endpoints"
|
|
106
|
+
assert request.headers["Authorization"] == "Bearer whsk_x"
|
|
107
|
+
body = json.loads(request.content)
|
|
108
|
+
# always-sent keys
|
|
109
|
+
assert body["url"] == "https://sub.example.com/hook"
|
|
110
|
+
assert body["environment"] == "prod"
|
|
111
|
+
assert body["application"] == "default"
|
|
112
|
+
assert body["subscriptions"] == [{"match_kind": "prefix", "pattern": "order."}]
|
|
113
|
+
assert body["max_attempts"] == 5
|
|
114
|
+
# unset optional kwargs are dropped
|
|
115
|
+
assert "secret" not in body
|
|
116
|
+
assert "retry_schedule" not in body
|
|
117
|
+
assert "description" not in body
|
|
118
|
+
assert "custom_headers" not in body
|
|
119
|
+
assert "delivery_timeout_ms" not in body
|
|
120
|
+
return httpx.Response(201, json=_ENDPOINT)
|
|
121
|
+
|
|
122
|
+
with _client(handler) as c:
|
|
123
|
+
ep = c.create_endpoint(
|
|
124
|
+
"https://sub.example.com/hook",
|
|
125
|
+
subscriptions=[{"match_kind": "prefix", "pattern": "order."}],
|
|
126
|
+
max_attempts=5,
|
|
127
|
+
)
|
|
128
|
+
# returned as-is (snake_case), secret present exactly as the server sent it
|
|
129
|
+
assert ep["id"] == "ep_1" and ep["secret"] == "whsec_shown_once"
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def test_create_endpoint_defaults_subscriptions_to_empty_list():
|
|
133
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
134
|
+
body = json.loads(request.content)
|
|
135
|
+
assert body["subscriptions"] == []
|
|
136
|
+
return httpx.Response(201, json=_ENDPOINT)
|
|
137
|
+
|
|
138
|
+
with _client(handler) as c:
|
|
139
|
+
c.create_endpoint("https://sub.example.com/hook")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def test_list_endpoints_passes_environment_and_returns_page():
|
|
143
|
+
page = {"items": [_ENDPOINT], "next_offset": 50}
|
|
144
|
+
|
|
145
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
146
|
+
assert request.method == "GET"
|
|
147
|
+
assert request.url.path == "/v1/endpoints"
|
|
148
|
+
assert request.url.params["environment"] == "staging"
|
|
149
|
+
assert request.url.params["offset"] == "0"
|
|
150
|
+
assert request.url.params["limit"] == "50"
|
|
151
|
+
return httpx.Response(200, json=page)
|
|
152
|
+
|
|
153
|
+
with _client(handler) as c:
|
|
154
|
+
result = c.list_endpoints(environment="staging", limit=50)
|
|
155
|
+
assert result["items"][0]["id"] == "ep_1"
|
|
156
|
+
assert result["next_offset"] == 50
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def test_get_endpoint_hits_path():
|
|
160
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
161
|
+
assert request.method == "GET"
|
|
162
|
+
assert request.url.path == "/v1/endpoints/ep_1"
|
|
163
|
+
return httpx.Response(200, json=_ENDPOINT)
|
|
164
|
+
|
|
165
|
+
with _client(handler) as c:
|
|
166
|
+
ep = c.get_endpoint("ep_1")
|
|
167
|
+
assert ep["id"] == "ep_1"
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def test_update_endpoint_sends_only_provided_keys():
|
|
171
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
172
|
+
assert request.method == "PATCH"
|
|
173
|
+
assert request.url.path == "/v1/endpoints/ep_1"
|
|
174
|
+
body = json.loads(request.content)
|
|
175
|
+
# explicit None clears; provided key present; omitted key absent
|
|
176
|
+
assert body == {"description": None, "max_attempts": 10}
|
|
177
|
+
assert "url" not in body # omitted -> must not appear in the request body
|
|
178
|
+
return httpx.Response(200, json=_ENDPOINT)
|
|
179
|
+
|
|
180
|
+
with _client(handler) as c:
|
|
181
|
+
c.update_endpoint("ep_1", {"description": None, "max_attempts": 10})
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def test_delete_endpoint_handles_204():
|
|
185
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
186
|
+
assert request.method == "DELETE"
|
|
187
|
+
assert request.url.path == "/v1/endpoints/ep_1"
|
|
188
|
+
return httpx.Response(204) # no body
|
|
189
|
+
|
|
190
|
+
with _client(handler) as c:
|
|
191
|
+
assert c.delete_endpoint("ep_1") is None
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def test_rotate_secret_hits_path_and_returns_new_secret():
|
|
195
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
196
|
+
assert request.method == "POST"
|
|
197
|
+
assert request.url.path == "/v1/endpoints/ep_1/rotate-secret"
|
|
198
|
+
assert not request.content # no request body
|
|
199
|
+
return httpx.Response(200, json={**_ENDPOINT, "secret": "whsec_new"})
|
|
200
|
+
|
|
201
|
+
with _client(handler) as c:
|
|
202
|
+
ep = c.rotate_endpoint_secret("ep_1")
|
|
203
|
+
assert ep["secret"] == "whsec_new"
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def test_enable_endpoint_hits_path():
|
|
207
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
208
|
+
assert request.method == "POST"
|
|
209
|
+
assert request.url.path == "/v1/endpoints/ep_1/enable"
|
|
210
|
+
assert not request.content # no request body
|
|
211
|
+
return httpx.Response(200, json=_ENDPOINT)
|
|
212
|
+
|
|
213
|
+
with _client(handler) as c:
|
|
214
|
+
ep = c.enable_endpoint("ep_1")
|
|
215
|
+
assert ep["status"] == "enabled"
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
# -- management: api keys -----------------------------------------------------
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def test_create_api_key_returns_key_and_sends_scope_and_expiry():
|
|
222
|
+
out = {"id": "key_1", "name": "ci", "scope": "publish", "key": "whsk_secret_once"}
|
|
223
|
+
|
|
224
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
225
|
+
assert request.method == "POST"
|
|
226
|
+
assert request.url.path == "/v1/api-keys"
|
|
227
|
+
body = json.loads(request.content)
|
|
228
|
+
assert body == {"name": "ci", "scope": "publish", "expires_in_days": 30}
|
|
229
|
+
return httpx.Response(201, json=out)
|
|
230
|
+
|
|
231
|
+
with _client(handler) as c:
|
|
232
|
+
key = c.create_api_key("ci", "publish", expires_in_days=30)
|
|
233
|
+
assert key["key"] == "whsk_secret_once" and key["scope"] == "publish"
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def test_create_api_key_omits_expiry_when_absent():
|
|
237
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
238
|
+
body = json.loads(request.content)
|
|
239
|
+
assert body == {"name": "", "scope": "admin"}
|
|
240
|
+
assert "expires_in_days" not in body
|
|
241
|
+
return httpx.Response(201, json={"id": "key_2", "key": "whsk_x", "scope": "admin"})
|
|
242
|
+
|
|
243
|
+
with _client(handler) as c:
|
|
244
|
+
c.create_api_key()
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def test_revoke_api_key_handles_204():
|
|
248
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
249
|
+
assert request.method == "DELETE"
|
|
250
|
+
assert request.url.path == "/v1/api-keys/key_1"
|
|
251
|
+
return httpx.Response(204)
|
|
252
|
+
|
|
253
|
+
with _client(handler) as c:
|
|
254
|
+
assert c.revoke_api_key("key_1") is None
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
# -- management: deliveries ---------------------------------------------------
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def test_list_deliveries_forwards_only_provided_params():
|
|
261
|
+
page = {"items": [{"id": "dlv_1", "status": "failed"}], "next_offset": None}
|
|
262
|
+
|
|
263
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
264
|
+
assert request.method == "GET"
|
|
265
|
+
assert request.url.path == "/v1/deliveries"
|
|
266
|
+
params = request.url.params
|
|
267
|
+
assert params["status"] == "failed"
|
|
268
|
+
assert params["endpoint_id"] == "ep_1"
|
|
269
|
+
assert params["offset"] == "0"
|
|
270
|
+
# unprovided filters must not be forwarded
|
|
271
|
+
assert "event_type" not in params
|
|
272
|
+
assert "since" not in params
|
|
273
|
+
assert "until" not in params
|
|
274
|
+
assert "q" not in params
|
|
275
|
+
assert "limit" not in params
|
|
276
|
+
return httpx.Response(200, json=page)
|
|
277
|
+
|
|
278
|
+
with _client(handler) as c:
|
|
279
|
+
result = c.list_deliveries(status="failed", endpoint_id="ep_1")
|
|
280
|
+
assert result["items"][0]["id"] == "dlv_1"
|
|
281
|
+
assert result["next_offset"] is None
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def test_redeliver_hits_path():
|
|
285
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
286
|
+
assert request.method == "POST"
|
|
287
|
+
assert request.url.path == "/v1/deliveries/dlv_1/redeliver"
|
|
288
|
+
assert not request.content # no request body
|
|
289
|
+
return httpx.Response(200, json={"id": "dlv_1", "status": "queued"})
|
|
290
|
+
|
|
291
|
+
with _client(handler) as c:
|
|
292
|
+
dlv = c.redeliver("dlv_1")
|
|
293
|
+
assert dlv["status"] == "queued"
|
|
@@ -58,6 +58,12 @@ def test_accepts_str_timestamp_header():
|
|
|
58
58
|
assert verify(SECRET, BODY, sig, timestamp=str(TS), now=TS)
|
|
59
59
|
|
|
60
60
|
|
|
61
|
+
def test_rejects_non_numeric_timestamp():
|
|
62
|
+
# A malformed X-Webhook-Timestamp header must be rejected cleanly, not raise (TS parity).
|
|
63
|
+
sig = sign(SECRET, BODY, TS)
|
|
64
|
+
assert not verify(SECRET, BODY, sig, timestamp="not-a-number", now=TS)
|
|
65
|
+
|
|
66
|
+
|
|
61
67
|
def test_str_body_accepted():
|
|
62
68
|
sig = sign(SECRET, BODY.decode(), TS)
|
|
63
69
|
assert verify(SECRET, BODY.decode(), sig, timestamp=TS, now=TS)
|
|
@@ -70,3 +76,13 @@ def test_matches_delivery_core_server_signer():
|
|
|
70
76
|
assert verify(SECRET, BODY, server_sig, timestamp=TS, now=TS)
|
|
71
77
|
# ... and our sign matches theirs exactly.
|
|
72
78
|
assert sign(SECRET, BODY, TS) == server_sig
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_dual_signature_accepts_any_matching_token():
|
|
82
|
+
# SEC-5(C): during a signing-secret rotation webhookd may send TWO comma-separated tokens
|
|
83
|
+
# (current + previous); a subscriber configured with EITHER secret must verify.
|
|
84
|
+
other = "whsec_previous_secret"
|
|
85
|
+
header = sign(SECRET, BODY, TS) + "," + sign(other, BODY, TS)
|
|
86
|
+
assert verify(SECRET, BODY, header, timestamp=TS, now=TS) # matches the first token
|
|
87
|
+
assert verify(other, BODY, header, timestamp=TS, now=TS) # matches the second token
|
|
88
|
+
assert not verify("whsec_neither", BODY, header, timestamp=TS, now=TS) # matches neither
|
|
@@ -10,7 +10,8 @@ from webhookd_sdk.client import Client, Event
|
|
|
10
10
|
from webhookd_sdk.errors import WebhookdAPIError, WebhookdError
|
|
11
11
|
from webhookd_sdk.signature import DEFAULT_TOLERANCE_SECONDS, sign, verify
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
# Keep in lockstep with pyproject.toml + the release tag (see publish.yml's bump checklist).
|
|
14
|
+
__version__ = "0.2.0"
|
|
14
15
|
|
|
15
16
|
__all__ = [
|
|
16
17
|
"Client",
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
"""Typed publish client for the webhookd API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from webhookd_sdk.errors import WebhookdAPIError, WebhookdError
|
|
12
|
+
|
|
13
|
+
_RETRY_STATUSES = frozenset({429, 500, 502, 503, 504})
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class Event:
|
|
18
|
+
"""The published event, as returned by ``POST /v1/events`` (webhookd's ``EventOut``)."""
|
|
19
|
+
|
|
20
|
+
id: str
|
|
21
|
+
event_uid: str
|
|
22
|
+
event_type: str
|
|
23
|
+
application: str
|
|
24
|
+
environment: str
|
|
25
|
+
deliveries_created: int
|
|
26
|
+
source: str | None = None
|
|
27
|
+
|
|
28
|
+
@classmethod
|
|
29
|
+
def _from_json(cls, d: dict[str, Any]) -> Event:
|
|
30
|
+
return cls(
|
|
31
|
+
id=d["id"],
|
|
32
|
+
event_uid=d["event_uid"],
|
|
33
|
+
event_type=d["event_type"],
|
|
34
|
+
application=d.get("application", "default"),
|
|
35
|
+
environment=d.get("environment", "prod"),
|
|
36
|
+
deliveries_created=d.get("deliveries_created", 0),
|
|
37
|
+
source=d.get("source"),
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Client:
|
|
42
|
+
"""Publish events to webhookd.
|
|
43
|
+
|
|
44
|
+
Authenticate with a per-tenant API key (``whsk_…``) or a service token::
|
|
45
|
+
|
|
46
|
+
with Client("https://webhooks.example.com", api_key="whsk_…") as wh:
|
|
47
|
+
event = wh.publish("order.created", {"id": 123}, idempotency_key="order-123")
|
|
48
|
+
|
|
49
|
+
Transient failures (connection errors, 429, 5xx) are retried with exponential backoff; a 429
|
|
50
|
+
honours its ``Retry-After`` header. Other 4xx raise :class:`WebhookdAPIError` carrying the
|
|
51
|
+
server's ``{error: {code, message}}`` envelope.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(
|
|
55
|
+
self,
|
|
56
|
+
base_url: str,
|
|
57
|
+
api_key: str,
|
|
58
|
+
*,
|
|
59
|
+
timeout: float = 10.0,
|
|
60
|
+
max_retries: int = 2,
|
|
61
|
+
transport: httpx.BaseTransport | None = None,
|
|
62
|
+
):
|
|
63
|
+
self._base = base_url.rstrip("/")
|
|
64
|
+
self._key = api_key
|
|
65
|
+
self._max_retries = max_retries
|
|
66
|
+
self._client = httpx.Client(timeout=timeout, transport=transport)
|
|
67
|
+
|
|
68
|
+
def publish(
|
|
69
|
+
self,
|
|
70
|
+
event_type: str,
|
|
71
|
+
payload: dict[str, Any],
|
|
72
|
+
*,
|
|
73
|
+
environment: str = "prod",
|
|
74
|
+
application: str = "default",
|
|
75
|
+
source: str | None = None,
|
|
76
|
+
idempotency_key: str | None = None,
|
|
77
|
+
) -> Event:
|
|
78
|
+
"""Publish one event. ``idempotency_key`` makes the publish safe to retry (a replay returns
|
|
79
|
+
the original event without re-fanning-out)."""
|
|
80
|
+
body: dict[str, Any] = {
|
|
81
|
+
"event_type": event_type,
|
|
82
|
+
"payload": payload,
|
|
83
|
+
"environment": environment,
|
|
84
|
+
"application": application,
|
|
85
|
+
}
|
|
86
|
+
if source is not None:
|
|
87
|
+
body["source"] = source
|
|
88
|
+
extra_headers = None
|
|
89
|
+
if idempotency_key is not None:
|
|
90
|
+
extra_headers = {"Idempotency-Key": idempotency_key}
|
|
91
|
+
resp = self._request("POST", "/v1/events", json=body, extra_headers=extra_headers)
|
|
92
|
+
return Event._from_json(resp.json())
|
|
93
|
+
|
|
94
|
+
# -- endpoints -------------------------------------------------------------
|
|
95
|
+
def create_endpoint(
|
|
96
|
+
self,
|
|
97
|
+
url: str,
|
|
98
|
+
*,
|
|
99
|
+
subscriptions: list[dict[str, Any]] | None = None,
|
|
100
|
+
environment: str = "prod",
|
|
101
|
+
application: str = "default",
|
|
102
|
+
secret: str | None = None,
|
|
103
|
+
max_attempts: int | None = None,
|
|
104
|
+
retry_schedule: list[int] | None = None,
|
|
105
|
+
description: str | None = None,
|
|
106
|
+
custom_headers: dict[str, str] | None = None,
|
|
107
|
+
delivery_timeout_ms: int | None = None,
|
|
108
|
+
) -> dict[str, Any]:
|
|
109
|
+
"""Create an endpoint (``POST /v1/endpoints``). The returned ``EndpointOut`` includes the
|
|
110
|
+
signing ``secret`` exactly ONCE — persist it now, it is never shown again.
|
|
111
|
+
|
|
112
|
+
``subscriptions`` is a list of ``{"match_kind", "pattern"}`` (``match_kind`` in
|
|
113
|
+
``exact|prefix|suffix|all``); it defaults to ``[]``. Only ``url``/``environment``/
|
|
114
|
+
``application``/``subscriptions`` are always sent — any other ``None`` kwarg is omitted
|
|
115
|
+
so the server applies its own default.
|
|
116
|
+
"""
|
|
117
|
+
body: dict[str, Any] = {
|
|
118
|
+
"url": url,
|
|
119
|
+
"environment": environment,
|
|
120
|
+
"application": application,
|
|
121
|
+
"subscriptions": subscriptions if subscriptions is not None else [],
|
|
122
|
+
}
|
|
123
|
+
optional: dict[str, Any] = {
|
|
124
|
+
"secret": secret,
|
|
125
|
+
"max_attempts": max_attempts,
|
|
126
|
+
"retry_schedule": retry_schedule,
|
|
127
|
+
"description": description,
|
|
128
|
+
"custom_headers": custom_headers,
|
|
129
|
+
"delivery_timeout_ms": delivery_timeout_ms,
|
|
130
|
+
}
|
|
131
|
+
for key, value in optional.items():
|
|
132
|
+
if value is not None:
|
|
133
|
+
body[key] = value
|
|
134
|
+
return self._request("POST", "/v1/endpoints", json=body).json()
|
|
135
|
+
|
|
136
|
+
def list_endpoints(
|
|
137
|
+
self, *, environment: str = "prod", offset: int = 0, limit: int | None = None
|
|
138
|
+
) -> dict[str, Any]:
|
|
139
|
+
"""List endpoints (``GET /v1/endpoints``). Returns a ``Page``:
|
|
140
|
+
``{"items": [EndpointOut], "next_offset": int | None}``."""
|
|
141
|
+
params: dict[str, Any] = {"environment": environment, "offset": offset}
|
|
142
|
+
if limit is not None:
|
|
143
|
+
params["limit"] = limit
|
|
144
|
+
return self._request("GET", "/v1/endpoints", params=params).json()
|
|
145
|
+
|
|
146
|
+
def get_endpoint(self, endpoint_id: str) -> dict[str, Any]:
|
|
147
|
+
"""Fetch one endpoint (``GET /v1/endpoints/{id}``) -> ``EndpointOut``."""
|
|
148
|
+
return self._request("GET", f"/v1/endpoints/{endpoint_id}").json()
|
|
149
|
+
|
|
150
|
+
def update_endpoint(self, endpoint_id: str, patch: dict[str, Any]) -> dict[str, Any]:
|
|
151
|
+
"""Patch an endpoint (``PATCH /v1/endpoints/{id}``) -> ``EndpointOut``.
|
|
152
|
+
|
|
153
|
+
The ``patch`` mapping is sent AS-IS with snake_case keys (``url``, ``max_attempts``,
|
|
154
|
+
``retry_schedule``, ``status``, ``description``, ``custom_headers``,
|
|
155
|
+
``delivery_timeout_ms``). PATCH semantics: an omitted key is left unchanged, an explicit
|
|
156
|
+
``None`` clears it. No defaults are injected — send only the keys you want to change.
|
|
157
|
+
"""
|
|
158
|
+
return self._request("PATCH", f"/v1/endpoints/{endpoint_id}", json=patch).json()
|
|
159
|
+
|
|
160
|
+
def delete_endpoint(self, endpoint_id: str) -> None:
|
|
161
|
+
"""Delete an endpoint (``DELETE /v1/endpoints/{id}``, 204). Returns ``None``."""
|
|
162
|
+
self._request("DELETE", f"/v1/endpoints/{endpoint_id}")
|
|
163
|
+
|
|
164
|
+
def rotate_endpoint_secret(self, endpoint_id: str) -> dict[str, Any]:
|
|
165
|
+
"""Rotate an endpoint's signing secret (``POST /v1/endpoints/{id}/rotate-secret``).
|
|
166
|
+
|
|
167
|
+
Returns the ``EndpointOut`` with the NEW ``secret`` shown once.
|
|
168
|
+
"""
|
|
169
|
+
return self._request("POST", f"/v1/endpoints/{endpoint_id}/rotate-secret").json()
|
|
170
|
+
|
|
171
|
+
def enable_endpoint(self, endpoint_id: str) -> dict[str, Any]:
|
|
172
|
+
"""Re-enable an auto-disabled endpoint (``POST /v1/endpoints/{id}/enable``).
|
|
173
|
+
|
|
174
|
+
Returns ``EndpointOut``.
|
|
175
|
+
"""
|
|
176
|
+
return self._request("POST", f"/v1/endpoints/{endpoint_id}/enable").json()
|
|
177
|
+
|
|
178
|
+
# -- api keys --------------------------------------------------------------
|
|
179
|
+
def create_api_key(
|
|
180
|
+
self, name: str = "", scope: str = "admin", *, expires_in_days: int | None = None
|
|
181
|
+
) -> dict[str, Any]:
|
|
182
|
+
"""Create an API key (``POST /v1/api-keys``). The returned ``ApiKeyOut`` includes the
|
|
183
|
+
plaintext ``key`` exactly ONCE. ``scope`` is ``"admin"`` or ``"publish"``."""
|
|
184
|
+
body: dict[str, Any] = {"name": name, "scope": scope}
|
|
185
|
+
if expires_in_days is not None:
|
|
186
|
+
body["expires_in_days"] = expires_in_days
|
|
187
|
+
return self._request("POST", "/v1/api-keys", json=body).json()
|
|
188
|
+
|
|
189
|
+
def revoke_api_key(self, key_id: str) -> None:
|
|
190
|
+
"""Revoke an API key (``DELETE /v1/api-keys/{id}``, 204). Returns ``None``."""
|
|
191
|
+
self._request("DELETE", f"/v1/api-keys/{key_id}")
|
|
192
|
+
|
|
193
|
+
# -- deliveries ------------------------------------------------------------
|
|
194
|
+
def list_deliveries(
|
|
195
|
+
self,
|
|
196
|
+
*,
|
|
197
|
+
status: str | None = None,
|
|
198
|
+
endpoint_id: str | None = None,
|
|
199
|
+
event_type: str | None = None,
|
|
200
|
+
since: str | None = None,
|
|
201
|
+
until: str | None = None,
|
|
202
|
+
q: str | None = None,
|
|
203
|
+
offset: int = 0,
|
|
204
|
+
limit: int | None = None,
|
|
205
|
+
) -> dict[str, Any]:
|
|
206
|
+
"""List deliveries (``GET /v1/deliveries``). Returns a ``Page``:
|
|
207
|
+
``{"items": [DeliveryOut], "next_offset": int | None}``.
|
|
208
|
+
|
|
209
|
+
Every filter is optional and only forwarded when provided. ``status`` is one of
|
|
210
|
+
``queued|sending|sent|failed|dead``.
|
|
211
|
+
"""
|
|
212
|
+
params: dict[str, Any] = {"offset": offset}
|
|
213
|
+
optional: dict[str, Any] = {
|
|
214
|
+
"status": status,
|
|
215
|
+
"endpoint_id": endpoint_id,
|
|
216
|
+
"event_type": event_type,
|
|
217
|
+
"since": since,
|
|
218
|
+
"until": until,
|
|
219
|
+
"q": q,
|
|
220
|
+
"limit": limit,
|
|
221
|
+
}
|
|
222
|
+
for key, value in optional.items():
|
|
223
|
+
if value is not None:
|
|
224
|
+
params[key] = value
|
|
225
|
+
return self._request("GET", "/v1/deliveries", params=params).json()
|
|
226
|
+
|
|
227
|
+
def redeliver(self, delivery_id: str) -> dict[str, Any]:
|
|
228
|
+
"""Re-enqueue a delivery (``POST /v1/deliveries/{id}/redeliver``) -> ``DeliveryOut``."""
|
|
229
|
+
return self._request("POST", f"/v1/deliveries/{delivery_id}/redeliver").json()
|
|
230
|
+
|
|
231
|
+
def close(self) -> None:
|
|
232
|
+
self._client.close()
|
|
233
|
+
|
|
234
|
+
def __enter__(self) -> Client:
|
|
235
|
+
return self
|
|
236
|
+
|
|
237
|
+
def __exit__(self, *_exc: object) -> None:
|
|
238
|
+
self.close()
|
|
239
|
+
|
|
240
|
+
# -- internals -------------------------------------------------------------
|
|
241
|
+
def _request(
|
|
242
|
+
self,
|
|
243
|
+
method: str,
|
|
244
|
+
path: str,
|
|
245
|
+
*,
|
|
246
|
+
json: dict[str, Any] | None = None,
|
|
247
|
+
params: dict[str, Any] | None = None,
|
|
248
|
+
extra_headers: dict[str, str] | None = None,
|
|
249
|
+
) -> httpx.Response:
|
|
250
|
+
"""Issue one authenticated request with the shared retry policy.
|
|
251
|
+
|
|
252
|
+
Retries connection errors, 429 (honouring ``Retry-After``) and 5xx with capped
|
|
253
|
+
exponential backoff; any other 4xx/5xx raises :class:`WebhookdAPIError`. ``json`` is
|
|
254
|
+
omitted for bodyless verbs (GET/DELETE, or POSTs with no body). The raw
|
|
255
|
+
:class:`httpx.Response` is returned — the caller decides whether to parse it (a 204 must
|
|
256
|
+
not be parsed).
|
|
257
|
+
"""
|
|
258
|
+
url = f"{self._base}{path}"
|
|
259
|
+
headers = {"Authorization": f"Bearer {self._key}"}
|
|
260
|
+
if extra_headers:
|
|
261
|
+
headers.update(extra_headers)
|
|
262
|
+
last_exc: Exception | None = None
|
|
263
|
+
for attempt in range(self._max_retries + 1):
|
|
264
|
+
try:
|
|
265
|
+
resp = self._client.request(method, url, json=json, params=params, headers=headers)
|
|
266
|
+
except httpx.HTTPError as exc:
|
|
267
|
+
last_exc = exc
|
|
268
|
+
if attempt < self._max_retries:
|
|
269
|
+
time.sleep(_backoff(attempt))
|
|
270
|
+
continue
|
|
271
|
+
raise WebhookdError(f"request failed: {exc}") from exc
|
|
272
|
+
|
|
273
|
+
if resp.status_code in _RETRY_STATUSES and attempt < self._max_retries:
|
|
274
|
+
time.sleep(_retry_after(resp) or _backoff(attempt))
|
|
275
|
+
continue
|
|
276
|
+
if resp.status_code >= 400:
|
|
277
|
+
raise _api_error(resp)
|
|
278
|
+
return resp
|
|
279
|
+
raise WebhookdError(f"request failed after retries: {last_exc}")
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _backoff(attempt: int) -> float:
|
|
283
|
+
return min(2.0, 0.2 * (2**attempt))
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _retry_after(resp: httpx.Response) -> float | None:
|
|
287
|
+
raw = resp.headers.get("Retry-After")
|
|
288
|
+
if raw and raw.isdigit():
|
|
289
|
+
return float(raw)
|
|
290
|
+
return None
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _api_error(resp: httpx.Response) -> WebhookdAPIError:
|
|
294
|
+
code, message = "error", resp.text
|
|
295
|
+
try:
|
|
296
|
+
err = resp.json().get("error") or {}
|
|
297
|
+
code = err.get("code", code)
|
|
298
|
+
message = err.get("message", message)
|
|
299
|
+
except Exception: # noqa: BLE001 - a non-JSON error body falls back to the raw text
|
|
300
|
+
pass
|
|
301
|
+
return WebhookdAPIError(resp.status_code, code, message)
|
|
@@ -71,11 +71,24 @@ def verify(
|
|
|
71
71
|
:param tolerance_seconds: replay window; webhookd's default is 300s.
|
|
72
72
|
:param now: override the current unix time (for tests).
|
|
73
73
|
"""
|
|
74
|
-
|
|
74
|
+
if timestamp is None:
|
|
75
|
+
ts = None
|
|
76
|
+
else:
|
|
77
|
+
try:
|
|
78
|
+
ts = int(timestamp)
|
|
79
|
+
except (ValueError, TypeError):
|
|
80
|
+
return False # a malformed X-Webhook-Timestamp can't be valid — reject, don't raise
|
|
75
81
|
if ts is not None:
|
|
76
82
|
current = int(time.time()) if now is None else now
|
|
77
83
|
if abs(current - ts) > tolerance_seconds:
|
|
78
84
|
return False
|
|
79
85
|
expected = sign(secret, raw_body, ts)
|
|
80
|
-
|
|
81
|
-
|
|
86
|
+
# X-Webhook-Signature carries one token normally, or several comma-separated tokens during a
|
|
87
|
+
# signing-secret rotation (webhookd dual-sign overlap) — accept if ANY token verifies, so a
|
|
88
|
+
# subscriber configured with EITHER the current or the previous secret keeps working.
|
|
89
|
+
for token in signature.split(","):
|
|
90
|
+
token = token.strip()
|
|
91
|
+
candidate = token if token.startswith(_PREFIX) else f"{_PREFIX}{token}"
|
|
92
|
+
if hmac.compare_digest(expected, candidate):
|
|
93
|
+
return True
|
|
94
|
+
return False
|
webhookd_sdk-0.1.0/README.md
DELETED
|
@@ -1,54 +0,0 @@
|
|
|
1
|
-
# webhookd-sdk (Python)
|
|
2
|
-
|
|
3
|
-
Official Python SDK for **NimbusNexus Webhooks** — publish events, and verify the webhooks you receive.
|
|
4
|
-
|
|
5
|
-
```sh
|
|
6
|
-
pip install webhookd-sdk
|
|
7
|
-
```
|
|
8
|
-
|
|
9
|
-
## Verify an incoming webhook (subscribers)
|
|
10
|
-
|
|
11
|
-
When webhookd delivers a webhook it signs the body with your endpoint's signing secret. **Always
|
|
12
|
-
verify the signature** before trusting the payload — it proves the request really came from webhookd
|
|
13
|
-
and wasn't tampered with or replayed.
|
|
14
|
-
|
|
15
|
-
```python
|
|
16
|
-
from webhookd_sdk import verify
|
|
17
|
-
|
|
18
|
-
# In your webhook handler — pass the RAW request body bytes (do not re-serialize the JSON):
|
|
19
|
-
ok = verify(
|
|
20
|
-
secret=ENDPOINT_SIGNING_SECRET,
|
|
21
|
-
raw_body=request.body,
|
|
22
|
-
signature=request.headers["X-Webhook-Signature"],
|
|
23
|
-
timestamp=request.headers["X-Webhook-Timestamp"],
|
|
24
|
-
)
|
|
25
|
-
if not ok:
|
|
26
|
-
return Response(status_code=400) # forged, tampered, or outside the 300s replay window
|
|
27
|
-
```
|
|
28
|
-
|
|
29
|
-
## Publish an event (producers)
|
|
30
|
-
|
|
31
|
-
```python
|
|
32
|
-
from webhookd_sdk import Client, WebhookdAPIError
|
|
33
|
-
|
|
34
|
-
with Client("https://webhooks.example.com", api_key="whsk_…") as wh:
|
|
35
|
-
try:
|
|
36
|
-
event = wh.publish(
|
|
37
|
-
"order.created",
|
|
38
|
-
{"order_id": "ord_123", "total": 4200},
|
|
39
|
-
idempotency_key="order-123", # makes the publish safe to retry
|
|
40
|
-
)
|
|
41
|
-
print(event.event_uid, event.deliveries_created)
|
|
42
|
-
except WebhookdAPIError as e:
|
|
43
|
-
print(e.status_code, e.code, e.message) # the stable {error:{code,message}} envelope
|
|
44
|
-
```
|
|
45
|
-
|
|
46
|
-
Transient failures (connection errors, `429`, `5xx`) are retried with backoff (a `429` honours
|
|
47
|
-
`Retry-After`); other `4xx` raise `WebhookdAPIError`.
|
|
48
|
-
|
|
49
|
-
## Develop
|
|
50
|
-
|
|
51
|
-
```sh
|
|
52
|
-
pip install -e '.[dev]'
|
|
53
|
-
pytest && ruff check . && mypy webhookd_sdk
|
|
54
|
-
```
|
|
@@ -1,86 +0,0 @@
|
|
|
1
|
-
"""Publish client — exercised against an httpx MockTransport (no network)."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import json
|
|
6
|
-
|
|
7
|
-
import httpx
|
|
8
|
-
import pytest
|
|
9
|
-
|
|
10
|
-
from webhookd_sdk import Client, WebhookdAPIError
|
|
11
|
-
|
|
12
|
-
_OK = {
|
|
13
|
-
"id": "evt_db",
|
|
14
|
-
"event_uid": "u1",
|
|
15
|
-
"event_type": "order.created",
|
|
16
|
-
"application": "default",
|
|
17
|
-
"environment": "prod",
|
|
18
|
-
"deliveries_created": 2,
|
|
19
|
-
"source": None,
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
def _client(handler, **kw) -> Client:
|
|
24
|
-
return Client("https://wh.example.com", "whsk_x", transport=httpx.MockTransport(handler), **kw)
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
def test_publish_success_and_request_shape():
|
|
28
|
-
def handler(request: httpx.Request) -> httpx.Response:
|
|
29
|
-
assert request.url.path == "/v1/events"
|
|
30
|
-
assert request.headers["Authorization"] == "Bearer whsk_x"
|
|
31
|
-
body = json.loads(request.content)
|
|
32
|
-
assert body["event_type"] == "order.created"
|
|
33
|
-
assert body["payload"] == {"id": 1} and body["environment"] == "prod"
|
|
34
|
-
return httpx.Response(201, json=_OK)
|
|
35
|
-
|
|
36
|
-
with _client(handler) as c:
|
|
37
|
-
ev = c.publish("order.created", {"id": 1})
|
|
38
|
-
assert ev.event_uid == "u1" and ev.deliveries_created == 2
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
def test_idempotency_key_header():
|
|
42
|
-
seen: dict[str, str | None] = {}
|
|
43
|
-
|
|
44
|
-
def handler(request: httpx.Request) -> httpx.Response:
|
|
45
|
-
seen["idem"] = request.headers.get("Idempotency-Key")
|
|
46
|
-
return httpx.Response(201, json=_OK)
|
|
47
|
-
|
|
48
|
-
with _client(handler) as c:
|
|
49
|
-
c.publish("order.created", {"id": 1}, idempotency_key="order-123")
|
|
50
|
-
assert seen["idem"] == "order-123"
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
def test_api_error_carries_envelope():
|
|
54
|
-
def handler(_request: httpx.Request) -> httpx.Response:
|
|
55
|
-
return httpx.Response(422, json={"error": {"code": "validation_error", "message": "bad"}})
|
|
56
|
-
|
|
57
|
-
with _client(handler) as c, pytest.raises(WebhookdAPIError) as ei:
|
|
58
|
-
c.publish("order.created", {})
|
|
59
|
-
assert ei.value.status_code == 422 and ei.value.code == "validation_error"
|
|
60
|
-
assert ei.value.message == "bad"
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
def test_retries_on_503_then_succeeds():
|
|
64
|
-
calls = {"n": 0}
|
|
65
|
-
|
|
66
|
-
def handler(_request: httpx.Request) -> httpx.Response:
|
|
67
|
-
calls["n"] += 1
|
|
68
|
-
if calls["n"] == 1:
|
|
69
|
-
return httpx.Response(503, json={"error": {"code": "unavailable", "message": "x"}})
|
|
70
|
-
return httpx.Response(201, json=_OK)
|
|
71
|
-
|
|
72
|
-
with _client(handler, max_retries=2) as c:
|
|
73
|
-
ev = c.publish("order.created", {})
|
|
74
|
-
assert calls["n"] == 2 and ev.event_uid == "u1"
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
def test_does_not_retry_4xx():
|
|
78
|
-
calls = {"n": 0}
|
|
79
|
-
|
|
80
|
-
def handler(_request: httpx.Request) -> httpx.Response:
|
|
81
|
-
calls["n"] += 1
|
|
82
|
-
return httpx.Response(404, json={"error": {"code": "not_found", "message": "no app"}})
|
|
83
|
-
|
|
84
|
-
with _client(handler, max_retries=2) as c, pytest.raises(WebhookdAPIError):
|
|
85
|
-
c.publish("order.created", {})
|
|
86
|
-
assert calls["n"] == 1 # a 404 is terminal — not retried
|
|
@@ -1,147 +0,0 @@
|
|
|
1
|
-
"""Typed publish client for the webhookd API."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import time
|
|
6
|
-
from dataclasses import dataclass
|
|
7
|
-
from typing import Any
|
|
8
|
-
|
|
9
|
-
import httpx
|
|
10
|
-
|
|
11
|
-
from webhookd_sdk.errors import WebhookdAPIError, WebhookdError
|
|
12
|
-
|
|
13
|
-
_RETRY_STATUSES = frozenset({429, 500, 502, 503, 504})
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
@dataclass
|
|
17
|
-
class Event:
|
|
18
|
-
"""The published event, as returned by ``POST /v1/events`` (webhookd's ``EventOut``)."""
|
|
19
|
-
|
|
20
|
-
id: str
|
|
21
|
-
event_uid: str
|
|
22
|
-
event_type: str
|
|
23
|
-
application: str
|
|
24
|
-
environment: str
|
|
25
|
-
deliveries_created: int
|
|
26
|
-
source: str | None = None
|
|
27
|
-
|
|
28
|
-
@classmethod
|
|
29
|
-
def _from_json(cls, d: dict[str, Any]) -> Event:
|
|
30
|
-
return cls(
|
|
31
|
-
id=d["id"],
|
|
32
|
-
event_uid=d["event_uid"],
|
|
33
|
-
event_type=d["event_type"],
|
|
34
|
-
application=d.get("application", "default"),
|
|
35
|
-
environment=d.get("environment", "prod"),
|
|
36
|
-
deliveries_created=d.get("deliveries_created", 0),
|
|
37
|
-
source=d.get("source"),
|
|
38
|
-
)
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
class Client:
|
|
42
|
-
"""Publish events to webhookd.
|
|
43
|
-
|
|
44
|
-
Authenticate with a per-tenant API key (``whsk_…``) or a service token::
|
|
45
|
-
|
|
46
|
-
with Client("https://webhooks.example.com", api_key="whsk_…") as wh:
|
|
47
|
-
event = wh.publish("order.created", {"id": 123}, idempotency_key="order-123")
|
|
48
|
-
|
|
49
|
-
Transient failures (connection errors, 429, 5xx) are retried with exponential backoff; a 429
|
|
50
|
-
honours its ``Retry-After`` header. Other 4xx raise :class:`WebhookdAPIError` carrying the
|
|
51
|
-
server's ``{error: {code, message}}`` envelope.
|
|
52
|
-
"""
|
|
53
|
-
|
|
54
|
-
def __init__(
|
|
55
|
-
self,
|
|
56
|
-
base_url: str,
|
|
57
|
-
api_key: str,
|
|
58
|
-
*,
|
|
59
|
-
timeout: float = 10.0,
|
|
60
|
-
max_retries: int = 2,
|
|
61
|
-
transport: httpx.BaseTransport | None = None,
|
|
62
|
-
):
|
|
63
|
-
self._base = base_url.rstrip("/")
|
|
64
|
-
self._key = api_key
|
|
65
|
-
self._max_retries = max_retries
|
|
66
|
-
self._client = httpx.Client(timeout=timeout, transport=transport)
|
|
67
|
-
|
|
68
|
-
def publish(
|
|
69
|
-
self,
|
|
70
|
-
event_type: str,
|
|
71
|
-
payload: dict[str, Any],
|
|
72
|
-
*,
|
|
73
|
-
environment: str = "prod",
|
|
74
|
-
application: str = "default",
|
|
75
|
-
source: str | None = None,
|
|
76
|
-
idempotency_key: str | None = None,
|
|
77
|
-
) -> Event:
|
|
78
|
-
"""Publish one event. ``idempotency_key`` makes the publish safe to retry (a replay returns
|
|
79
|
-
the original event without re-fanning-out)."""
|
|
80
|
-
body: dict[str, Any] = {
|
|
81
|
-
"event_type": event_type,
|
|
82
|
-
"payload": payload,
|
|
83
|
-
"environment": environment,
|
|
84
|
-
"application": application,
|
|
85
|
-
}
|
|
86
|
-
if source is not None:
|
|
87
|
-
body["source"] = source
|
|
88
|
-
headers = {"Authorization": f"Bearer {self._key}"}
|
|
89
|
-
if idempotency_key is not None:
|
|
90
|
-
headers["Idempotency-Key"] = idempotency_key
|
|
91
|
-
resp = self._post("/v1/events", body, headers)
|
|
92
|
-
return Event._from_json(resp.json())
|
|
93
|
-
|
|
94
|
-
def close(self) -> None:
|
|
95
|
-
self._client.close()
|
|
96
|
-
|
|
97
|
-
def __enter__(self) -> Client:
|
|
98
|
-
return self
|
|
99
|
-
|
|
100
|
-
def __exit__(self, *_exc: object) -> None:
|
|
101
|
-
self.close()
|
|
102
|
-
|
|
103
|
-
# -- internals -------------------------------------------------------------
|
|
104
|
-
def _post(
|
|
105
|
-
self, path: str, json_body: dict[str, Any], headers: dict[str, str]
|
|
106
|
-
) -> httpx.Response:
|
|
107
|
-
url = f"{self._base}{path}"
|
|
108
|
-
last_exc: Exception | None = None
|
|
109
|
-
for attempt in range(self._max_retries + 1):
|
|
110
|
-
try:
|
|
111
|
-
resp = self._client.post(url, json=json_body, headers=headers)
|
|
112
|
-
except httpx.HTTPError as exc:
|
|
113
|
-
last_exc = exc
|
|
114
|
-
if attempt < self._max_retries:
|
|
115
|
-
time.sleep(_backoff(attempt))
|
|
116
|
-
continue
|
|
117
|
-
raise WebhookdError(f"request failed: {exc}") from exc
|
|
118
|
-
|
|
119
|
-
if resp.status_code in _RETRY_STATUSES and attempt < self._max_retries:
|
|
120
|
-
time.sleep(_retry_after(resp) or _backoff(attempt))
|
|
121
|
-
continue
|
|
122
|
-
if resp.status_code >= 400:
|
|
123
|
-
raise _api_error(resp)
|
|
124
|
-
return resp
|
|
125
|
-
raise WebhookdError(f"request failed after retries: {last_exc}")
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
def _backoff(attempt: int) -> float:
|
|
129
|
-
return min(2.0, 0.2 * (2**attempt))
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
def _retry_after(resp: httpx.Response) -> float | None:
|
|
133
|
-
raw = resp.headers.get("Retry-After")
|
|
134
|
-
if raw and raw.isdigit():
|
|
135
|
-
return float(raw)
|
|
136
|
-
return None
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
def _api_error(resp: httpx.Response) -> WebhookdAPIError:
|
|
140
|
-
code, message = "error", resp.text
|
|
141
|
-
try:
|
|
142
|
-
err = resp.json().get("error") or {}
|
|
143
|
-
code = err.get("code", code)
|
|
144
|
-
message = err.get("message", message)
|
|
145
|
-
except Exception: # noqa: BLE001 - a non-JSON error body falls back to the raw text
|
|
146
|
-
pass
|
|
147
|
-
return WebhookdAPIError(resp.status_code, code, message)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|