webhookd-sdk 0.1.1__tar.gz → 0.3.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.3.0/PKG-INFO +176 -0
- webhookd_sdk-0.3.0/README.md +144 -0
- {webhookd_sdk-0.1.1 → webhookd_sdk-0.3.0}/pyproject.toml +3 -1
- webhookd_sdk-0.3.0/tests/test_client.py +293 -0
- webhookd_sdk-0.3.0/tests/test_outbox.py +293 -0
- {webhookd_sdk-0.1.1 → webhookd_sdk-0.3.0}/tests/test_signature.py +6 -0
- {webhookd_sdk-0.1.1 → webhookd_sdk-0.3.0}/webhookd_sdk/__init__.py +18 -1
- webhookd_sdk-0.3.0/webhookd_sdk/client.py +421 -0
- webhookd_sdk-0.3.0/webhookd_sdk/outbox.py +546 -0
- {webhookd_sdk-0.1.1 → webhookd_sdk-0.3.0}/webhookd_sdk/signature.py +7 -1
- webhookd_sdk-0.1.1/PKG-INFO +0 -82
- webhookd_sdk-0.1.1/README.md +0 -54
- webhookd_sdk-0.1.1/tests/test_client.py +0 -86
- webhookd_sdk-0.1.1/webhookd_sdk/client.py +0 -147
- {webhookd_sdk-0.1.1 → webhookd_sdk-0.3.0}/.gitignore +0 -0
- {webhookd_sdk-0.1.1 → webhookd_sdk-0.3.0}/LICENSE +0 -0
- {webhookd_sdk-0.1.1 → webhookd_sdk-0.3.0}/tests/test_e2e_webhookd.py +0 -0
- {webhookd_sdk-0.1.1 → webhookd_sdk-0.3.0}/tests/test_vectors.py +0 -0
- {webhookd_sdk-0.1.1 → webhookd_sdk-0.3.0}/webhookd_sdk/errors.py +0 -0
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: webhookd-sdk
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Official Python SDK for NimbusNexus Webhooks — publish events + verify webhook signatures.
|
|
5
|
+
Project-URL: Homepage, https://github.com/NimbusNexus/Webhooks-sdks/tree/develop/python#readme
|
|
6
|
+
Project-URL: Repository, https://github.com/NimbusNexus/Webhooks-sdks
|
|
7
|
+
Project-URL: Issues, https://github.com/NimbusNexus/Webhooks-sdks/issues
|
|
8
|
+
Author: NimbusNexus
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: hmac,nimbusnexus,sdk,signature,verify,webhook,webhooks
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
21
|
+
Requires-Python: >=3.11
|
|
22
|
+
Requires-Dist: httpx>=0.27
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: mypy; extra == 'dev'
|
|
25
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
26
|
+
Requires-Dist: ruff; extra == 'dev'
|
|
27
|
+
Provides-Extra: postgres
|
|
28
|
+
Requires-Dist: psycopg[binary]>=3; extra == 'postgres'
|
|
29
|
+
Provides-Extra: redis
|
|
30
|
+
Requires-Dist: redis>=5; extra == 'redis'
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
# webhookd-sdk (Python)
|
|
34
|
+
|
|
35
|
+
Official Python SDK for **NimbusNexus Webhooks** — publish events, manage your endpoints / keys /
|
|
36
|
+
deliveries, and verify the webhooks you receive.
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
pip install webhookd-sdk
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Verify an incoming webhook (subscribers)
|
|
43
|
+
|
|
44
|
+
When webhookd delivers a webhook it signs the body with your endpoint's signing secret. **Always
|
|
45
|
+
verify the signature** before trusting the payload — it proves the request really came from webhookd
|
|
46
|
+
and wasn't tampered with or replayed.
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
from webhookd_sdk import verify
|
|
50
|
+
|
|
51
|
+
# In your webhook handler — pass the RAW request body bytes (do not re-serialize the JSON):
|
|
52
|
+
ok = verify(
|
|
53
|
+
secret=ENDPOINT_SIGNING_SECRET,
|
|
54
|
+
raw_body=request.body,
|
|
55
|
+
signature=request.headers["X-Webhook-Signature"],
|
|
56
|
+
timestamp=request.headers["X-Webhook-Timestamp"],
|
|
57
|
+
)
|
|
58
|
+
if not ok:
|
|
59
|
+
return Response(status_code=400) # forged, tampered, or outside the 300s replay window
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Publish an event (producers)
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
from webhookd_sdk import Client, WebhookdAPIError
|
|
66
|
+
|
|
67
|
+
with Client("https://webhooks.example.com", api_key="whsk_…") as wh:
|
|
68
|
+
try:
|
|
69
|
+
event = wh.publish(
|
|
70
|
+
"order.created",
|
|
71
|
+
{"order_id": "ord_123", "total": 4200},
|
|
72
|
+
idempotency_key="order-123", # makes the publish safe to retry
|
|
73
|
+
)
|
|
74
|
+
print(event.event_uid, event.deliveries_created)
|
|
75
|
+
except WebhookdAPIError as e:
|
|
76
|
+
print(e.status_code, e.code, e.message) # the stable {error:{code,message}} envelope
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Transient failures (connection errors, `429`, `5xx`) are retried with backoff (a `429` honours
|
|
80
|
+
`Retry-After`); other `4xx` raise `WebhookdAPIError`.
|
|
81
|
+
|
|
82
|
+
## Outbox / durable buffering (producers)
|
|
83
|
+
|
|
84
|
+
`publish()` calls webhookd synchronously — if webhookd is unreachable it raises and the event is
|
|
85
|
+
lost. The **write-first outbox** decouples the two: `enqueue()` durably persists the event to a
|
|
86
|
+
pluggable `Store` and returns IMMEDIATELY (no network); `drain()` (or a background drainer) ships the
|
|
87
|
+
buffered events later. Every send carries `Idempotency-Key = record.id`, so a re-drain after a crash
|
|
88
|
+
or a lost response never double-publishes — webhookd dedupes. Delivery is **at-least-once**: nothing
|
|
89
|
+
is lost while webhookd is down.
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
from webhookd_sdk import Client, SQLiteStore
|
|
93
|
+
|
|
94
|
+
# 1. Configure a durable store (survives process restarts).
|
|
95
|
+
store = SQLiteStore("outbox.db")
|
|
96
|
+
|
|
97
|
+
with Client("https://webhooks.example.com", api_key="whsk_…", store=store) as wh:
|
|
98
|
+
# 2. enqueue() instead of publish() — writes to the store and returns at once, NO network call.
|
|
99
|
+
record_id = wh.enqueue("order.created", {"order_id": "ord_123", "total": 4200})
|
|
100
|
+
|
|
101
|
+
# 3a. Drain on demand (returns {"sent", "failed", "remaining"}):
|
|
102
|
+
wh.drain()
|
|
103
|
+
|
|
104
|
+
# 3b. …or run a background drainer that calls drain() every 5s until the client closes.
|
|
105
|
+
wh.start_drainer(interval_seconds=5)
|
|
106
|
+
# ... your app keeps enqueuing; the drainer ships in the background ...
|
|
107
|
+
wh.stop_drainer() # also called automatically by Client.close()/__exit__
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
**Idempotency guarantee.** `record_id` is the `idempotency_key` you pass (or a generated UUID v4) and
|
|
111
|
+
becomes the `Idempotency-Key` header on every delivery attempt for that record. If the process
|
|
112
|
+
crashes after a send but before the response is recorded, the next `drain()` re-sends with the *same*
|
|
113
|
+
key and webhookd returns the original event without re-fanning-out. A record that keeps failing is
|
|
114
|
+
retried with capped exponential backoff up to `max_attempts` (default 10), then flagged **dead**
|
|
115
|
+
(never retried again) and handed to the optional `on_dead` callback.
|
|
116
|
+
|
|
117
|
+
**Built-in stores** — pick one for `Client(..., store=...)`:
|
|
118
|
+
|
|
119
|
+
| Store | Durable? | Extra needed |
|
|
120
|
+
| --- | --- | --- |
|
|
121
|
+
| `MemoryStore` | No (in-process) | — (stdlib) |
|
|
122
|
+
| `FileStore(dir)` | Yes (per-record JSON files) | — (stdlib) |
|
|
123
|
+
| `SQLiteStore(path)` | Yes (transactional) | — (stdlib `sqlite3`) |
|
|
124
|
+
| `RedisStore(url)` | Yes | `pip install 'webhookd-sdk[redis]'` |
|
|
125
|
+
| `PostgresStore(dsn)` | Yes | `pip install 'webhookd-sdk[postgres]'` |
|
|
126
|
+
|
|
127
|
+
The core SDK stays zero-dependency; `RedisStore` / `PostgresStore` lazily import their driver only
|
|
128
|
+
when you construct them.
|
|
129
|
+
|
|
130
|
+
## Manage endpoints, keys & deliveries (operators)
|
|
131
|
+
|
|
132
|
+
The same `Client` wraps the control-plane API — register receivers, mint keys, and drain the
|
|
133
|
+
dead-letter queue from code (needs an **admin**-scoped key). Management methods return the raw JSON as
|
|
134
|
+
`dict`s (snake_case, exactly as the API sends); list methods return a page —
|
|
135
|
+
`{"items": [...], "next_offset": int | None}`; delete/revoke return `None` (a `204`).
|
|
136
|
+
|
|
137
|
+
```python
|
|
138
|
+
from webhookd_sdk import Client
|
|
139
|
+
|
|
140
|
+
wh = Client("https://webhooks.example.com", api_key="whsk_admin_…")
|
|
141
|
+
|
|
142
|
+
# --- Endpoints ----------------------------------------------------------------
|
|
143
|
+
# Create a receiver — its signing secret is in the response exactly once, so persist it now.
|
|
144
|
+
ep = wh.create_endpoint(
|
|
145
|
+
"https://your-app.example/webhooks",
|
|
146
|
+
subscriptions=[{"match_kind": "prefix", "pattern": "order."}],
|
|
147
|
+
description="orders service",
|
|
148
|
+
)
|
|
149
|
+
endpoint_id, signing_secret = ep["id"], ep["secret"]
|
|
150
|
+
|
|
151
|
+
wh.list_endpoints(environment="prod") # {"items": [...], "next_offset": ...}
|
|
152
|
+
wh.get_endpoint(endpoint_id)
|
|
153
|
+
|
|
154
|
+
# PATCH — send only the keys you want to change (omitted = unchanged, None = cleared):
|
|
155
|
+
wh.update_endpoint(endpoint_id, {"max_attempts": 10, "status": "disabled"})
|
|
156
|
+
|
|
157
|
+
wh.rotate_endpoint_secret(endpoint_id) # returns the new secret, once
|
|
158
|
+
wh.enable_endpoint(endpoint_id) # recover an auto-disabled endpoint
|
|
159
|
+
wh.delete_endpoint(endpoint_id) # -> None (204)
|
|
160
|
+
|
|
161
|
+
# --- API keys -----------------------------------------------------------------
|
|
162
|
+
key = wh.create_api_key("ci-publisher", scope="publish", expires_in_days=90)
|
|
163
|
+
print(key["key"]) # shown once
|
|
164
|
+
wh.revoke_api_key(key["id"]) # -> None (204)
|
|
165
|
+
|
|
166
|
+
# --- Deliveries / dead-letter recovery ----------------------------------------
|
|
167
|
+
for d in wh.list_deliveries(status="dead")["items"]:
|
|
168
|
+
wh.redeliver(d["id"])
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
## Develop
|
|
172
|
+
|
|
173
|
+
```sh
|
|
174
|
+
pip install -e '.[dev]'
|
|
175
|
+
pytest && ruff check . && mypy webhookd_sdk
|
|
176
|
+
```
|
|
@@ -0,0 +1,144 @@
|
|
|
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
|
+
## Outbox / durable buffering (producers)
|
|
51
|
+
|
|
52
|
+
`publish()` calls webhookd synchronously — if webhookd is unreachable it raises and the event is
|
|
53
|
+
lost. The **write-first outbox** decouples the two: `enqueue()` durably persists the event to a
|
|
54
|
+
pluggable `Store` and returns IMMEDIATELY (no network); `drain()` (or a background drainer) ships the
|
|
55
|
+
buffered events later. Every send carries `Idempotency-Key = record.id`, so a re-drain after a crash
|
|
56
|
+
or a lost response never double-publishes — webhookd dedupes. Delivery is **at-least-once**: nothing
|
|
57
|
+
is lost while webhookd is down.
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
from webhookd_sdk import Client, SQLiteStore
|
|
61
|
+
|
|
62
|
+
# 1. Configure a durable store (survives process restarts).
|
|
63
|
+
store = SQLiteStore("outbox.db")
|
|
64
|
+
|
|
65
|
+
with Client("https://webhooks.example.com", api_key="whsk_…", store=store) as wh:
|
|
66
|
+
# 2. enqueue() instead of publish() — writes to the store and returns at once, NO network call.
|
|
67
|
+
record_id = wh.enqueue("order.created", {"order_id": "ord_123", "total": 4200})
|
|
68
|
+
|
|
69
|
+
# 3a. Drain on demand (returns {"sent", "failed", "remaining"}):
|
|
70
|
+
wh.drain()
|
|
71
|
+
|
|
72
|
+
# 3b. …or run a background drainer that calls drain() every 5s until the client closes.
|
|
73
|
+
wh.start_drainer(interval_seconds=5)
|
|
74
|
+
# ... your app keeps enqueuing; the drainer ships in the background ...
|
|
75
|
+
wh.stop_drainer() # also called automatically by Client.close()/__exit__
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
**Idempotency guarantee.** `record_id` is the `idempotency_key` you pass (or a generated UUID v4) and
|
|
79
|
+
becomes the `Idempotency-Key` header on every delivery attempt for that record. If the process
|
|
80
|
+
crashes after a send but before the response is recorded, the next `drain()` re-sends with the *same*
|
|
81
|
+
key and webhookd returns the original event without re-fanning-out. A record that keeps failing is
|
|
82
|
+
retried with capped exponential backoff up to `max_attempts` (default 10), then flagged **dead**
|
|
83
|
+
(never retried again) and handed to the optional `on_dead` callback.
|
|
84
|
+
|
|
85
|
+
**Built-in stores** — pick one for `Client(..., store=...)`:
|
|
86
|
+
|
|
87
|
+
| Store | Durable? | Extra needed |
|
|
88
|
+
| --- | --- | --- |
|
|
89
|
+
| `MemoryStore` | No (in-process) | — (stdlib) |
|
|
90
|
+
| `FileStore(dir)` | Yes (per-record JSON files) | — (stdlib) |
|
|
91
|
+
| `SQLiteStore(path)` | Yes (transactional) | — (stdlib `sqlite3`) |
|
|
92
|
+
| `RedisStore(url)` | Yes | `pip install 'webhookd-sdk[redis]'` |
|
|
93
|
+
| `PostgresStore(dsn)` | Yes | `pip install 'webhookd-sdk[postgres]'` |
|
|
94
|
+
|
|
95
|
+
The core SDK stays zero-dependency; `RedisStore` / `PostgresStore` lazily import their driver only
|
|
96
|
+
when you construct them.
|
|
97
|
+
|
|
98
|
+
## Manage endpoints, keys & deliveries (operators)
|
|
99
|
+
|
|
100
|
+
The same `Client` wraps the control-plane API — register receivers, mint keys, and drain the
|
|
101
|
+
dead-letter queue from code (needs an **admin**-scoped key). Management methods return the raw JSON as
|
|
102
|
+
`dict`s (snake_case, exactly as the API sends); list methods return a page —
|
|
103
|
+
`{"items": [...], "next_offset": int | None}`; delete/revoke return `None` (a `204`).
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
from webhookd_sdk import Client
|
|
107
|
+
|
|
108
|
+
wh = Client("https://webhooks.example.com", api_key="whsk_admin_…")
|
|
109
|
+
|
|
110
|
+
# --- Endpoints ----------------------------------------------------------------
|
|
111
|
+
# Create a receiver — its signing secret is in the response exactly once, so persist it now.
|
|
112
|
+
ep = wh.create_endpoint(
|
|
113
|
+
"https://your-app.example/webhooks",
|
|
114
|
+
subscriptions=[{"match_kind": "prefix", "pattern": "order."}],
|
|
115
|
+
description="orders service",
|
|
116
|
+
)
|
|
117
|
+
endpoint_id, signing_secret = ep["id"], ep["secret"]
|
|
118
|
+
|
|
119
|
+
wh.list_endpoints(environment="prod") # {"items": [...], "next_offset": ...}
|
|
120
|
+
wh.get_endpoint(endpoint_id)
|
|
121
|
+
|
|
122
|
+
# PATCH — send only the keys you want to change (omitted = unchanged, None = cleared):
|
|
123
|
+
wh.update_endpoint(endpoint_id, {"max_attempts": 10, "status": "disabled"})
|
|
124
|
+
|
|
125
|
+
wh.rotate_endpoint_secret(endpoint_id) # returns the new secret, once
|
|
126
|
+
wh.enable_endpoint(endpoint_id) # recover an auto-disabled endpoint
|
|
127
|
+
wh.delete_endpoint(endpoint_id) # -> None (204)
|
|
128
|
+
|
|
129
|
+
# --- API keys -----------------------------------------------------------------
|
|
130
|
+
key = wh.create_api_key("ci-publisher", scope="publish", expires_in_days=90)
|
|
131
|
+
print(key["key"]) # shown once
|
|
132
|
+
wh.revoke_api_key(key["id"]) # -> None (204)
|
|
133
|
+
|
|
134
|
+
# --- Deliveries / dead-letter recovery ----------------------------------------
|
|
135
|
+
for d in wh.list_deliveries(status="dead")["items"]:
|
|
136
|
+
wh.redeliver(d["id"])
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## Develop
|
|
140
|
+
|
|
141
|
+
```sh
|
|
142
|
+
pip install -e '.[dev]'
|
|
143
|
+
pytest && ruff check . && mypy webhookd_sdk
|
|
144
|
+
```
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "webhookd-sdk"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.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"
|
|
@@ -30,6 +30,8 @@ Repository = "https://github.com/NimbusNexus/Webhooks-sdks"
|
|
|
30
30
|
Issues = "https://github.com/NimbusNexus/Webhooks-sdks/issues"
|
|
31
31
|
|
|
32
32
|
[project.optional-dependencies]
|
|
33
|
+
redis = ["redis>=5"]
|
|
34
|
+
postgres = ["psycopg[binary]>=3"]
|
|
33
35
|
dev = ["pytest", "ruff", "mypy"]
|
|
34
36
|
|
|
35
37
|
[tool.hatch.build.targets.wheel]
|
|
@@ -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"
|