webhook-platform 2.2.1__tar.gz → 2.6.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.
- {webhook_platform-2.2.1 → webhook_platform-2.6.0}/PKG-INFO +106 -14
- {webhook_platform-2.2.1 → webhook_platform-2.6.0}/README.md +105 -13
- {webhook_platform-2.2.1 → webhook_platform-2.6.0}/hookflow/__init__.py +1 -1
- {webhook_platform-2.2.1 → webhook_platform-2.6.0}/hookflow/client.py +18 -5
- {webhook_platform-2.2.1 → webhook_platform-2.6.0}/hookflow/errors.py +7 -2
- {webhook_platform-2.2.1 → webhook_platform-2.6.0}/hookflow/types.py +89 -12
- {webhook_platform-2.2.1 → webhook_platform-2.6.0}/hookflow/webhooks.py +34 -6
- {webhook_platform-2.2.1 → webhook_platform-2.6.0}/pyproject.toml +1 -1
- webhook_platform-2.6.0/scripts/live_api_smoke.py +493 -0
- webhook_platform-2.6.0/tests/contract/__init__.py +0 -0
- webhook_platform-2.6.0/tests/contract/conftest.py +18 -0
- webhook_platform-2.6.0/tests/contract/support.py +84 -0
- webhook_platform-2.6.0/tests/contract/test_client_contract.py +99 -0
- {webhook_platform-2.2.1 → webhook_platform-2.6.0}/tests/test_client.py +21 -6
- webhook_platform-2.6.0/tests/test_package_identity.py +21 -0
- {webhook_platform-2.2.1 → webhook_platform-2.6.0}/tests/test_webhooks.py +43 -0
- {webhook_platform-2.2.1 → webhook_platform-2.6.0}/webhook_platform.egg-info/PKG-INFO +106 -14
- {webhook_platform-2.2.1 → webhook_platform-2.6.0}/webhook_platform.egg-info/SOURCES.txt +6 -0
- {webhook_platform-2.2.1 → webhook_platform-2.6.0}/webhook_platform.egg-info/top_level.txt +1 -0
- {webhook_platform-2.2.1 → webhook_platform-2.6.0}/setup.cfg +0 -0
- {webhook_platform-2.2.1 → webhook_platform-2.6.0}/tests/__init__.py +0 -0
- {webhook_platform-2.2.1 → webhook_platform-2.6.0}/tests/test_incoming.py +0 -0
- {webhook_platform-2.2.1 → webhook_platform-2.6.0}/webhook_platform.egg-info/dependency_links.txt +0 -0
- {webhook_platform-2.2.1 → webhook_platform-2.6.0}/webhook_platform.egg-info/requires.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: webhook-platform
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.6.0
|
|
4
4
|
Summary: Official Python SDK for Hookflow — reliable webhook infrastructure
|
|
5
5
|
Author-email: Vadym Kykalo <vadymkykalo@gmail.com>
|
|
6
6
|
License: MIT
|
|
@@ -31,6 +31,17 @@ Requires-Dist: types-requests>=2.28.0; extra == "dev"
|
|
|
31
31
|
|
|
32
32
|
Official Python SDK for [Hookflow](https://github.com/vadymkykalo/webhook-platform).
|
|
33
33
|
|
|
34
|
+
> The PyPI distribution is `webhook-platform`; the module you import is
|
|
35
|
+
> `hookflow`. `pip install webhook-platform`, then `from hookflow import ...`.
|
|
36
|
+
|
|
37
|
+
**Scope.** This SDK covers Events, Endpoints, Subscriptions, Deliveries,
|
|
38
|
+
Incoming Sources, Incoming Events, and webhook signature verification —
|
|
39
|
+
7 of the platform's 35 API controllers. It does not wrap
|
|
40
|
+
Transformations, Rules, Workflows, Schemas, DLQ, Analytics, Usage, Alerts,
|
|
41
|
+
Incidents, PII rules, Audit Log, Tunnels, API keys, Members, or Projects —
|
|
42
|
+
use the [Generic Requests](#generic-requests) helpers for those until the
|
|
43
|
+
SDK grows to cover them.
|
|
44
|
+
|
|
34
45
|
## Installation
|
|
35
46
|
|
|
36
47
|
```bash
|
|
@@ -40,10 +51,12 @@ pip install webhook-platform
|
|
|
40
51
|
## Quick Start
|
|
41
52
|
|
|
42
53
|
```python
|
|
54
|
+
import os
|
|
55
|
+
|
|
43
56
|
from hookflow import Hookflow, Event
|
|
44
57
|
|
|
45
58
|
client = Hookflow(
|
|
46
|
-
api_key="
|
|
59
|
+
api_key=os.environ["HOOKFLOW_API_KEY"], # e.g. "Kz1uAIM8VeJUQN7yGSYCst64WxNLabBHfOYbrPlJ1yk"
|
|
47
60
|
base_url="http://localhost:8080", # optional
|
|
48
61
|
)
|
|
49
62
|
|
|
@@ -92,8 +105,11 @@ endpoint = client.endpoints.create(
|
|
|
92
105
|
),
|
|
93
106
|
)
|
|
94
107
|
|
|
95
|
-
# List endpoints
|
|
96
|
-
|
|
108
|
+
# List endpoints — the API paginates this one, so the endpoints are in .content
|
|
109
|
+
# (iterating the page yields them directly)
|
|
110
|
+
page = client.endpoints.list(project_id, page=0, size=20)
|
|
111
|
+
for endpoint in page:
|
|
112
|
+
print(endpoint.url)
|
|
97
113
|
|
|
98
114
|
# Update endpoint
|
|
99
115
|
client.endpoints.update(
|
|
@@ -112,6 +128,7 @@ print(f"New secret: {updated.secret}")
|
|
|
112
128
|
# Test endpoint connectivity
|
|
113
129
|
result = client.endpoints.test(project_id, endpoint_id)
|
|
114
130
|
print(f"Test {'passed' if result.success else 'failed'}: {result.latency_ms}ms")
|
|
131
|
+
print(f"{result.http_status_code} — {result.message}")
|
|
115
132
|
```
|
|
116
133
|
|
|
117
134
|
### Subscriptions
|
|
@@ -129,7 +146,7 @@ subscription = client.subscriptions.create(
|
|
|
129
146
|
),
|
|
130
147
|
)
|
|
131
148
|
|
|
132
|
-
# List subscriptions
|
|
149
|
+
# List subscriptions — a bare list; unlike endpoints, this one is not paginated
|
|
133
150
|
subscriptions = client.subscriptions.list(project_id)
|
|
134
151
|
|
|
135
152
|
# Update subscription
|
|
@@ -159,7 +176,7 @@ print(f"Total failed: {deliveries.total_elements}")
|
|
|
159
176
|
# Get delivery attempts
|
|
160
177
|
attempts = client.deliveries.get_attempts(delivery_id)
|
|
161
178
|
for attempt in attempts:
|
|
162
|
-
print(f"Attempt {attempt.attempt_number}: {attempt.
|
|
179
|
+
print(f"Attempt {attempt.attempt_number}: {attempt.http_status_code} ({attempt.duration_ms}ms)")
|
|
163
180
|
|
|
164
181
|
# Replay failed delivery
|
|
165
182
|
client.deliveries.replay(delivery_id)
|
|
@@ -274,11 +291,12 @@ def handle_webhook():
|
|
|
274
291
|
# Option 2: Verify and parse
|
|
275
292
|
event = construct_event(payload, headers, secret)
|
|
276
293
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
#
|
|
280
|
-
|
|
281
|
-
|
|
294
|
+
# event.data is the parsed body; event.event_id / event.delivery_id /
|
|
295
|
+
# event.timestamp come from the X-Event-Id / X-Delivery-Id /
|
|
296
|
+
# X-Timestamp headers. See "What lands on your endpoint" below for
|
|
297
|
+
# event.type.
|
|
298
|
+
print(f"Delivery {event.delivery_id} of event {event.event_id}: {event.data}")
|
|
299
|
+
handle_order_completed(event.data)
|
|
282
300
|
|
|
283
301
|
return "OK", 200
|
|
284
302
|
|
|
@@ -287,6 +305,40 @@ def handle_webhook():
|
|
|
287
305
|
return "Invalid signature", 400
|
|
288
306
|
```
|
|
289
307
|
|
|
308
|
+
### What lands on your endpoint
|
|
309
|
+
|
|
310
|
+
Hookflow PUTs the event's **payload** on the wire, not an envelope. This:
|
|
311
|
+
|
|
312
|
+
```python
|
|
313
|
+
client.events.send(Event(type="order.completed", data={"order_id": "ord_1"}))
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
arrives at your endpoint as the ``data`` object alone —
|
|
317
|
+
|
|
318
|
+
```http
|
|
319
|
+
POST /webhooks HTTP/1.1
|
|
320
|
+
Content-Type: application/json
|
|
321
|
+
X-Signature: t=1738000000000,v1=<hex hmac-sha256>
|
|
322
|
+
X-Timestamp: 1738000000000
|
|
323
|
+
X-Event-Id: 6f0e…
|
|
324
|
+
X-Delivery-Id: 91ab…
|
|
325
|
+
X-Sequence-Number: 0
|
|
326
|
+
Idempotency-Key: 6f0e…-<endpoint-id>
|
|
327
|
+
|
|
328
|
+
{"order_id":"ord_1"}
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
So `construct_event` fills `event_id`, `delivery_id` and `timestamp` from the
|
|
332
|
+
headers and `data` from the body, but **`type` is empty**: the event type is
|
|
333
|
+
not on the wire for a default subscription. Route on the payload, on the
|
|
334
|
+
endpoint you registered, or set the subscription's `payload_template` to wrap
|
|
335
|
+
the event so `type` becomes part of the body.
|
|
336
|
+
|
|
337
|
+
The signature is computed over `f"{timestamp}.{raw_body}"` with HMAC-SHA256 and
|
|
338
|
+
the endpoint secret, and the server rejects timestamps more than **300
|
|
339
|
+
seconds** old — verify against the *raw* body bytes, before any JSON parse and
|
|
340
|
+
re-serialize.
|
|
341
|
+
|
|
290
342
|
### FastAPI Example
|
|
291
343
|
|
|
292
344
|
```python
|
|
@@ -327,7 +379,8 @@ from hookflow import (
|
|
|
327
379
|
try:
|
|
328
380
|
client.events.send(Event(type="test", data={}))
|
|
329
381
|
except RateLimitError as e:
|
|
330
|
-
#
|
|
382
|
+
# retry_after_ms is milliseconds. e.rate_limit_info.reset is the raw
|
|
383
|
+
# X-RateLimit-Reset header, which the API sends in Unix *seconds*.
|
|
331
384
|
print(f"Rate limited. Retry after {e.retry_after_ms}ms")
|
|
332
385
|
time.sleep(e.retry_after_ms / 1000)
|
|
333
386
|
except AuthenticationError:
|
|
@@ -400,12 +453,35 @@ All generic methods use the same authentication, error handling, and rate-limit
|
|
|
400
453
|
|
|
401
454
|
```python
|
|
402
455
|
client = Hookflow(
|
|
403
|
-
api_key="
|
|
404
|
-
base_url="https://api.example.com", # Optional:
|
|
456
|
+
api_key=os.environ["HOOKFLOW_API_KEY"], # Required: Your project API key
|
|
457
|
+
base_url="https://api.example.com", # Optional (default: http://localhost:8080)
|
|
405
458
|
timeout=30, # Optional: Request timeout in seconds (default: 30)
|
|
406
459
|
)
|
|
407
460
|
```
|
|
408
461
|
|
|
462
|
+
### Timeouts and retries
|
|
463
|
+
|
|
464
|
+
`timeout` is passed straight to `requests`; hitting it raises `HookflowError`
|
|
465
|
+
with `code="timeout"` and `status=0`. A connection-level failure raises the
|
|
466
|
+
same class with `code="network_error"`.
|
|
467
|
+
|
|
468
|
+
**The client does not retry.** One SDK call is exactly one HTTP request — no
|
|
469
|
+
backoff, no idempotent replay, and no `urllib3` `Retry` adapter is installed.
|
|
470
|
+
That is deliberate: `events.send` accepts an `idempotency_key`, so a retry
|
|
471
|
+
policy belongs to the caller who knows whether reissuing the request is safe.
|
|
472
|
+
What *is* retried is the delivery itself, by the platform, on the
|
|
473
|
+
subscription's `retry_delays` ladder.
|
|
474
|
+
|
|
475
|
+
## Authentication
|
|
476
|
+
|
|
477
|
+
Every request the client makes carries `X-API-Key: <your key>` — the project
|
|
478
|
+
API key, created in the dashboard or via
|
|
479
|
+
`POST /api/v1/projects/{project_id}/api-keys`. The SDK never sends a bearer
|
|
480
|
+
token and has no login surface: JWT-authenticated endpoints (auth, projects,
|
|
481
|
+
organizations, members, API keys) are not part of it. Bootstrapping a project
|
|
482
|
+
and a key is a one-time step you do with the dashboard, the CLI, or plain
|
|
483
|
+
HTTP.
|
|
484
|
+
|
|
409
485
|
## Type Hints
|
|
410
486
|
|
|
411
487
|
This SDK includes full type hints for better IDE support:
|
|
@@ -435,6 +511,22 @@ pytest
|
|
|
435
511
|
docker run --rm -v $(pwd):/app -w /app python:3.11-slim sh -c "pip install -e '.[dev]' && pytest"
|
|
436
512
|
```
|
|
437
513
|
|
|
514
|
+
### Live-API smoke check
|
|
515
|
+
|
|
516
|
+
`pytest` stubs the transport, so it cannot see a renamed field. To drive the
|
|
517
|
+
SDK against a real instance:
|
|
518
|
+
|
|
519
|
+
```bash
|
|
520
|
+
make up # from the repo root
|
|
521
|
+
python scripts/live_api_smoke.py # SMOKE_API_BASE_URL overrides the target
|
|
522
|
+
```
|
|
523
|
+
|
|
524
|
+
It registers a throwaway org, walks endpoint → subscription → event →
|
|
525
|
+
deliveries → attempts → incoming, checks each error envelope, and verifies a
|
|
526
|
+
signature the running server itself produced. It is not collected by `pytest`
|
|
527
|
+
(`testpaths = tests`, `python_files = test_*.py`), so the unit suite still
|
|
528
|
+
passes with no backend.
|
|
529
|
+
|
|
438
530
|
## License
|
|
439
531
|
|
|
440
532
|
MIT
|
|
@@ -2,6 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
Official Python SDK for [Hookflow](https://github.com/vadymkykalo/webhook-platform).
|
|
4
4
|
|
|
5
|
+
> The PyPI distribution is `webhook-platform`; the module you import is
|
|
6
|
+
> `hookflow`. `pip install webhook-platform`, then `from hookflow import ...`.
|
|
7
|
+
|
|
8
|
+
**Scope.** This SDK covers Events, Endpoints, Subscriptions, Deliveries,
|
|
9
|
+
Incoming Sources, Incoming Events, and webhook signature verification —
|
|
10
|
+
7 of the platform's 35 API controllers. It does not wrap
|
|
11
|
+
Transformations, Rules, Workflows, Schemas, DLQ, Analytics, Usage, Alerts,
|
|
12
|
+
Incidents, PII rules, Audit Log, Tunnels, API keys, Members, or Projects —
|
|
13
|
+
use the [Generic Requests](#generic-requests) helpers for those until the
|
|
14
|
+
SDK grows to cover them.
|
|
15
|
+
|
|
5
16
|
## Installation
|
|
6
17
|
|
|
7
18
|
```bash
|
|
@@ -11,10 +22,12 @@ pip install webhook-platform
|
|
|
11
22
|
## Quick Start
|
|
12
23
|
|
|
13
24
|
```python
|
|
25
|
+
import os
|
|
26
|
+
|
|
14
27
|
from hookflow import Hookflow, Event
|
|
15
28
|
|
|
16
29
|
client = Hookflow(
|
|
17
|
-
api_key="
|
|
30
|
+
api_key=os.environ["HOOKFLOW_API_KEY"], # e.g. "Kz1uAIM8VeJUQN7yGSYCst64WxNLabBHfOYbrPlJ1yk"
|
|
18
31
|
base_url="http://localhost:8080", # optional
|
|
19
32
|
)
|
|
20
33
|
|
|
@@ -63,8 +76,11 @@ endpoint = client.endpoints.create(
|
|
|
63
76
|
),
|
|
64
77
|
)
|
|
65
78
|
|
|
66
|
-
# List endpoints
|
|
67
|
-
|
|
79
|
+
# List endpoints — the API paginates this one, so the endpoints are in .content
|
|
80
|
+
# (iterating the page yields them directly)
|
|
81
|
+
page = client.endpoints.list(project_id, page=0, size=20)
|
|
82
|
+
for endpoint in page:
|
|
83
|
+
print(endpoint.url)
|
|
68
84
|
|
|
69
85
|
# Update endpoint
|
|
70
86
|
client.endpoints.update(
|
|
@@ -83,6 +99,7 @@ print(f"New secret: {updated.secret}")
|
|
|
83
99
|
# Test endpoint connectivity
|
|
84
100
|
result = client.endpoints.test(project_id, endpoint_id)
|
|
85
101
|
print(f"Test {'passed' if result.success else 'failed'}: {result.latency_ms}ms")
|
|
102
|
+
print(f"{result.http_status_code} — {result.message}")
|
|
86
103
|
```
|
|
87
104
|
|
|
88
105
|
### Subscriptions
|
|
@@ -100,7 +117,7 @@ subscription = client.subscriptions.create(
|
|
|
100
117
|
),
|
|
101
118
|
)
|
|
102
119
|
|
|
103
|
-
# List subscriptions
|
|
120
|
+
# List subscriptions — a bare list; unlike endpoints, this one is not paginated
|
|
104
121
|
subscriptions = client.subscriptions.list(project_id)
|
|
105
122
|
|
|
106
123
|
# Update subscription
|
|
@@ -130,7 +147,7 @@ print(f"Total failed: {deliveries.total_elements}")
|
|
|
130
147
|
# Get delivery attempts
|
|
131
148
|
attempts = client.deliveries.get_attempts(delivery_id)
|
|
132
149
|
for attempt in attempts:
|
|
133
|
-
print(f"Attempt {attempt.attempt_number}: {attempt.
|
|
150
|
+
print(f"Attempt {attempt.attempt_number}: {attempt.http_status_code} ({attempt.duration_ms}ms)")
|
|
134
151
|
|
|
135
152
|
# Replay failed delivery
|
|
136
153
|
client.deliveries.replay(delivery_id)
|
|
@@ -245,11 +262,12 @@ def handle_webhook():
|
|
|
245
262
|
# Option 2: Verify and parse
|
|
246
263
|
event = construct_event(payload, headers, secret)
|
|
247
264
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
#
|
|
251
|
-
|
|
252
|
-
|
|
265
|
+
# event.data is the parsed body; event.event_id / event.delivery_id /
|
|
266
|
+
# event.timestamp come from the X-Event-Id / X-Delivery-Id /
|
|
267
|
+
# X-Timestamp headers. See "What lands on your endpoint" below for
|
|
268
|
+
# event.type.
|
|
269
|
+
print(f"Delivery {event.delivery_id} of event {event.event_id}: {event.data}")
|
|
270
|
+
handle_order_completed(event.data)
|
|
253
271
|
|
|
254
272
|
return "OK", 200
|
|
255
273
|
|
|
@@ -258,6 +276,40 @@ def handle_webhook():
|
|
|
258
276
|
return "Invalid signature", 400
|
|
259
277
|
```
|
|
260
278
|
|
|
279
|
+
### What lands on your endpoint
|
|
280
|
+
|
|
281
|
+
Hookflow PUTs the event's **payload** on the wire, not an envelope. This:
|
|
282
|
+
|
|
283
|
+
```python
|
|
284
|
+
client.events.send(Event(type="order.completed", data={"order_id": "ord_1"}))
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
arrives at your endpoint as the ``data`` object alone —
|
|
288
|
+
|
|
289
|
+
```http
|
|
290
|
+
POST /webhooks HTTP/1.1
|
|
291
|
+
Content-Type: application/json
|
|
292
|
+
X-Signature: t=1738000000000,v1=<hex hmac-sha256>
|
|
293
|
+
X-Timestamp: 1738000000000
|
|
294
|
+
X-Event-Id: 6f0e…
|
|
295
|
+
X-Delivery-Id: 91ab…
|
|
296
|
+
X-Sequence-Number: 0
|
|
297
|
+
Idempotency-Key: 6f0e…-<endpoint-id>
|
|
298
|
+
|
|
299
|
+
{"order_id":"ord_1"}
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
So `construct_event` fills `event_id`, `delivery_id` and `timestamp` from the
|
|
303
|
+
headers and `data` from the body, but **`type` is empty**: the event type is
|
|
304
|
+
not on the wire for a default subscription. Route on the payload, on the
|
|
305
|
+
endpoint you registered, or set the subscription's `payload_template` to wrap
|
|
306
|
+
the event so `type` becomes part of the body.
|
|
307
|
+
|
|
308
|
+
The signature is computed over `f"{timestamp}.{raw_body}"` with HMAC-SHA256 and
|
|
309
|
+
the endpoint secret, and the server rejects timestamps more than **300
|
|
310
|
+
seconds** old — verify against the *raw* body bytes, before any JSON parse and
|
|
311
|
+
re-serialize.
|
|
312
|
+
|
|
261
313
|
### FastAPI Example
|
|
262
314
|
|
|
263
315
|
```python
|
|
@@ -298,7 +350,8 @@ from hookflow import (
|
|
|
298
350
|
try:
|
|
299
351
|
client.events.send(Event(type="test", data={}))
|
|
300
352
|
except RateLimitError as e:
|
|
301
|
-
#
|
|
353
|
+
# retry_after_ms is milliseconds. e.rate_limit_info.reset is the raw
|
|
354
|
+
# X-RateLimit-Reset header, which the API sends in Unix *seconds*.
|
|
302
355
|
print(f"Rate limited. Retry after {e.retry_after_ms}ms")
|
|
303
356
|
time.sleep(e.retry_after_ms / 1000)
|
|
304
357
|
except AuthenticationError:
|
|
@@ -371,12 +424,35 @@ All generic methods use the same authentication, error handling, and rate-limit
|
|
|
371
424
|
|
|
372
425
|
```python
|
|
373
426
|
client = Hookflow(
|
|
374
|
-
api_key="
|
|
375
|
-
base_url="https://api.example.com", # Optional:
|
|
427
|
+
api_key=os.environ["HOOKFLOW_API_KEY"], # Required: Your project API key
|
|
428
|
+
base_url="https://api.example.com", # Optional (default: http://localhost:8080)
|
|
376
429
|
timeout=30, # Optional: Request timeout in seconds (default: 30)
|
|
377
430
|
)
|
|
378
431
|
```
|
|
379
432
|
|
|
433
|
+
### Timeouts and retries
|
|
434
|
+
|
|
435
|
+
`timeout` is passed straight to `requests`; hitting it raises `HookflowError`
|
|
436
|
+
with `code="timeout"` and `status=0`. A connection-level failure raises the
|
|
437
|
+
same class with `code="network_error"`.
|
|
438
|
+
|
|
439
|
+
**The client does not retry.** One SDK call is exactly one HTTP request — no
|
|
440
|
+
backoff, no idempotent replay, and no `urllib3` `Retry` adapter is installed.
|
|
441
|
+
That is deliberate: `events.send` accepts an `idempotency_key`, so a retry
|
|
442
|
+
policy belongs to the caller who knows whether reissuing the request is safe.
|
|
443
|
+
What *is* retried is the delivery itself, by the platform, on the
|
|
444
|
+
subscription's `retry_delays` ladder.
|
|
445
|
+
|
|
446
|
+
## Authentication
|
|
447
|
+
|
|
448
|
+
Every request the client makes carries `X-API-Key: <your key>` — the project
|
|
449
|
+
API key, created in the dashboard or via
|
|
450
|
+
`POST /api/v1/projects/{project_id}/api-keys`. The SDK never sends a bearer
|
|
451
|
+
token and has no login surface: JWT-authenticated endpoints (auth, projects,
|
|
452
|
+
organizations, members, API keys) are not part of it. Bootstrapping a project
|
|
453
|
+
and a key is a one-time step you do with the dashboard, the CLI, or plain
|
|
454
|
+
HTTP.
|
|
455
|
+
|
|
380
456
|
## Type Hints
|
|
381
457
|
|
|
382
458
|
This SDK includes full type hints for better IDE support:
|
|
@@ -406,6 +482,22 @@ pytest
|
|
|
406
482
|
docker run --rm -v $(pwd):/app -w /app python:3.11-slim sh -c "pip install -e '.[dev]' && pytest"
|
|
407
483
|
```
|
|
408
484
|
|
|
485
|
+
### Live-API smoke check
|
|
486
|
+
|
|
487
|
+
`pytest` stubs the transport, so it cannot see a renamed field. To drive the
|
|
488
|
+
SDK against a real instance:
|
|
489
|
+
|
|
490
|
+
```bash
|
|
491
|
+
make up # from the repo root
|
|
492
|
+
python scripts/live_api_smoke.py # SMOKE_API_BASE_URL overrides the target
|
|
493
|
+
```
|
|
494
|
+
|
|
495
|
+
It registers a throwaway org, walks endpoint → subscription → event →
|
|
496
|
+
deliveries → attempts → incoming, checks each error envelope, and verifies a
|
|
497
|
+
signature the running server itself produced. It is not collected by `pytest`
|
|
498
|
+
(`testpaths = tests`, `python_files = test_*.py`), so the unit suite still
|
|
499
|
+
passes with no backend.
|
|
500
|
+
|
|
409
501
|
## License
|
|
410
502
|
|
|
411
503
|
MIT
|
|
@@ -37,7 +37,7 @@ from .errors import (
|
|
|
37
37
|
|
|
38
38
|
DEFAULT_BASE_URL = "http://localhost:8080"
|
|
39
39
|
DEFAULT_TIMEOUT = 30
|
|
40
|
-
SDK_VERSION = "2.
|
|
40
|
+
SDK_VERSION = "2.6.0"
|
|
41
41
|
|
|
42
42
|
|
|
43
43
|
class Hookflow:
|
|
@@ -165,8 +165,10 @@ class Hookflow:
|
|
|
165
165
|
return NotFoundError(message)
|
|
166
166
|
elif status == 429:
|
|
167
167
|
import time
|
|
168
|
+
# `reset` is a Unix timestamp in seconds (see RateLimitInfo), so the
|
|
169
|
+
# fallback has to be in seconds too.
|
|
168
170
|
info = rate_limit_info or RateLimitInfo(
|
|
169
|
-
limit=0, remaining=0, reset=int(time.time()
|
|
171
|
+
limit=0, remaining=0, reset=int(time.time()) + 60
|
|
170
172
|
)
|
|
171
173
|
return RateLimitError(message, info)
|
|
172
174
|
elif status == 400:
|
|
@@ -217,13 +219,21 @@ class Endpoints:
|
|
|
217
219
|
)
|
|
218
220
|
return Endpoint.from_dict(data)
|
|
219
221
|
|
|
220
|
-
def list(
|
|
221
|
-
|
|
222
|
+
def list(
|
|
223
|
+
self, project_id: str, page: int = 0, size: int = 20
|
|
224
|
+
) -> PaginatedResponse:
|
|
225
|
+
"""List a project's endpoints.
|
|
226
|
+
|
|
227
|
+
The API returns a Spring page envelope here, not a bare array — the
|
|
228
|
+
endpoints themselves are in ``.content`` (iterating the page yields
|
|
229
|
+
them directly).
|
|
230
|
+
"""
|
|
222
231
|
data = self._client._request(
|
|
223
232
|
"GET",
|
|
224
233
|
f"/api/v1/projects/{project_id}/endpoints",
|
|
234
|
+
params={"page": page, "size": size},
|
|
225
235
|
)
|
|
226
|
-
return
|
|
236
|
+
return PaginatedResponse.from_dict(data, Endpoint)
|
|
227
237
|
|
|
228
238
|
def update(
|
|
229
239
|
self, project_id: str, endpoint_id: str, params: EndpointUpdateParams
|
|
@@ -305,6 +315,7 @@ class Subscriptions:
|
|
|
305
315
|
retry_delays: Optional[str] = None,
|
|
306
316
|
payload_template: Optional[str] = None,
|
|
307
317
|
custom_headers: Optional[str] = None,
|
|
318
|
+
transformation_id: Optional[str] = None,
|
|
308
319
|
) -> Subscription:
|
|
309
320
|
"""Update subscription."""
|
|
310
321
|
body: Dict[str, Any] = {}
|
|
@@ -324,6 +335,8 @@ class Subscriptions:
|
|
|
324
335
|
body["payloadTemplate"] = payload_template
|
|
325
336
|
if custom_headers is not None:
|
|
326
337
|
body["customHeaders"] = custom_headers
|
|
338
|
+
if transformation_id is not None:
|
|
339
|
+
body["transformationId"] = transformation_id
|
|
327
340
|
|
|
328
341
|
data = self._client._request(
|
|
329
342
|
"PUT",
|
|
@@ -35,10 +35,15 @@ class RateLimitError(HookflowError):
|
|
|
35
35
|
|
|
36
36
|
@property
|
|
37
37
|
def retry_after_ms(self) -> int:
|
|
38
|
-
"""Milliseconds to wait before retrying.
|
|
38
|
+
"""Milliseconds to wait before retrying.
|
|
39
|
+
|
|
40
|
+
``rate_limit_info.reset`` is the raw ``X-RateLimit-Reset`` header, which
|
|
41
|
+
the API sends as a Unix timestamp in **seconds**; subtracting a
|
|
42
|
+
millisecond clock from it directly always yields 0.
|
|
43
|
+
"""
|
|
39
44
|
import time
|
|
40
45
|
now_ms = int(time.time() * 1000)
|
|
41
|
-
return max(0, self.rate_limit_info.reset - now_ms)
|
|
46
|
+
return max(0, self.rate_limit_info.reset * 1000 - now_ms)
|
|
42
47
|
|
|
43
48
|
|
|
44
49
|
class ValidationError(HookflowError):
|