hookbase 3.1.0__tar.gz → 3.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.
- {hookbase-3.1.0 → hookbase-3.3.0}/PKG-INFO +27 -5
- {hookbase-3.1.0 → hookbase-3.3.0}/README.md +26 -4
- {hookbase-3.1.0 → hookbase-3.3.0}/src/hookbase/_client.py +39 -7
- {hookbase-3.1.0 → hookbase-3.3.0}/src/hookbase/_pagination.py +2 -2
- hookbase-3.3.0/src/hookbase/_version.py +1 -0
- {hookbase-3.1.0 → hookbase-3.3.0}/src/hookbase/client.py +8 -0
- {hookbase-3.1.0 → hookbase-3.3.0}/src/hookbase/models/__init__.py +27 -0
- {hookbase-3.1.0 → hookbase-3.3.0}/src/hookbase/models/applications.py +21 -0
- hookbase-3.3.0/src/hookbase/models/catalog.py +120 -0
- hookbase-3.3.0/src/hookbase/models/dlq.py +142 -0
- {hookbase-3.1.0 → hookbase-3.3.0}/src/hookbase/models/endpoints.py +17 -0
- hookbase-3.3.0/src/hookbase/models/event_types.py +103 -0
- hookbase-3.3.0/src/hookbase/models/messages.py +168 -0
- hookbase-3.3.0/src/hookbase/models/sources.py +283 -0
- hookbase-3.3.0/src/hookbase/models/subscriptions.py +78 -0
- {hookbase-3.1.0 → hookbase-3.3.0}/src/hookbase/resources/__init__.py +2 -0
- {hookbase-3.1.0 → hookbase-3.3.0}/src/hookbase/resources/dlq.py +29 -10
- {hookbase-3.1.0 → hookbase-3.3.0}/src/hookbase/resources/endpoints.py +53 -4
- {hookbase-3.1.0 → hookbase-3.3.0}/src/hookbase/resources/event_types.py +6 -5
- {hookbase-3.1.0 → hookbase-3.3.0}/src/hookbase/resources/events.py +20 -0
- {hookbase-3.1.0 → hookbase-3.3.0}/src/hookbase/resources/message_log.py +63 -12
- hookbase-3.3.0/src/hookbase/resources/messages.py +115 -0
- hookbase-3.3.0/src/hookbase/resources/providers.py +54 -0
- {hookbase-3.1.0 → hookbase-3.3.0}/src/hookbase/resources/sources.py +82 -2
- {hookbase-3.1.0 → hookbase-3.3.0}/src/hookbase/resources/subscriptions.py +14 -4
- {hookbase-3.1.0 → hookbase-3.3.0}/src/hookbase/webhook.py +18 -1
- {hookbase-3.1.0 → hookbase-3.3.0}/tests/resources/test_dlq.py +20 -7
- {hookbase-3.1.0 → hookbase-3.3.0}/tests/resources/test_messages.py +6 -4
- hookbase-3.3.0/tests/resources/test_outbound_contract.py +736 -0
- hookbase-3.3.0/tests/resources/test_source_contract.py +302 -0
- {hookbase-3.1.0 → hookbase-3.3.0}/tests/resources/test_source_fields.py +55 -2
- {hookbase-3.1.0 → hookbase-3.3.0}/tests/test_webhook.py +60 -0
- hookbase-3.1.0/src/hookbase/_version.py +0 -1
- hookbase-3.1.0/src/hookbase/models/dlq.py +0 -60
- hookbase-3.1.0/src/hookbase/models/event_types.py +0 -37
- hookbase-3.1.0/src/hookbase/models/messages.py +0 -73
- hookbase-3.1.0/src/hookbase/models/sources.py +0 -180
- hookbase-3.1.0/src/hookbase/models/subscriptions.py +0 -22
- hookbase-3.1.0/src/hookbase/resources/messages.py +0 -70
- {hookbase-3.1.0 → hookbase-3.3.0}/.github/workflows/python-publish.yml +0 -0
- {hookbase-3.1.0 → hookbase-3.3.0}/.gitignore +0 -0
- {hookbase-3.1.0 → hookbase-3.3.0}/LICENSE +0 -0
- {hookbase-3.1.0 → hookbase-3.3.0}/examples/async_usage.py +0 -0
- {hookbase-3.1.0 → hookbase-3.3.0}/examples/basic_inbound.py +0 -0
- {hookbase-3.1.0 → hookbase-3.3.0}/examples/error_handling.py +0 -0
- {hookbase-3.1.0 → hookbase-3.3.0}/examples/send_webhooks.py +0 -0
- {hookbase-3.1.0 → hookbase-3.3.0}/examples/verify_webhooks.py +0 -0
- {hookbase-3.1.0 → hookbase-3.3.0}/pyproject.toml +0 -0
- {hookbase-3.1.0 → hookbase-3.3.0}/src/hookbase/__init__.py +0 -0
- {hookbase-3.1.0 → hookbase-3.3.0}/src/hookbase/_constants.py +0 -0
- {hookbase-3.1.0 → hookbase-3.3.0}/src/hookbase/errors.py +0 -0
- {hookbase-3.1.0 → hookbase-3.3.0}/src/hookbase/models/_base.py +0 -0
- {hookbase-3.1.0 → hookbase-3.3.0}/src/hookbase/models/_wire.py +0 -0
- {hookbase-3.1.0 → hookbase-3.3.0}/src/hookbase/models/analytics.py +0 -0
- {hookbase-3.1.0 → hookbase-3.3.0}/src/hookbase/models/api_keys.py +0 -0
- {hookbase-3.1.0 → hookbase-3.3.0}/src/hookbase/models/common.py +0 -0
- {hookbase-3.1.0 → hookbase-3.3.0}/src/hookbase/models/cron_jobs.py +0 -0
- {hookbase-3.1.0 → hookbase-3.3.0}/src/hookbase/models/deliveries.py +0 -0
- {hookbase-3.1.0 → hookbase-3.3.0}/src/hookbase/models/destinations.py +0 -0
- {hookbase-3.1.0 → hookbase-3.3.0}/src/hookbase/models/events.py +0 -0
- {hookbase-3.1.0 → hookbase-3.3.0}/src/hookbase/models/filters.py +0 -0
- {hookbase-3.1.0 → hookbase-3.3.0}/src/hookbase/models/organizations.py +0 -0
- {hookbase-3.1.0 → hookbase-3.3.0}/src/hookbase/models/portal_tokens.py +0 -0
- {hookbase-3.1.0 → hookbase-3.3.0}/src/hookbase/models/routes.py +0 -0
- {hookbase-3.1.0 → hookbase-3.3.0}/src/hookbase/models/schemas.py +0 -0
- {hookbase-3.1.0 → hookbase-3.3.0}/src/hookbase/models/transforms.py +0 -0
- {hookbase-3.1.0 → hookbase-3.3.0}/src/hookbase/models/tunnels.py +0 -0
- {hookbase-3.1.0 → hookbase-3.3.0}/src/hookbase/py.typed +0 -0
- {hookbase-3.1.0 → hookbase-3.3.0}/src/hookbase/resources/_base.py +0 -0
- {hookbase-3.1.0 → hookbase-3.3.0}/src/hookbase/resources/analytics.py +0 -0
- {hookbase-3.1.0 → hookbase-3.3.0}/src/hookbase/resources/api_keys.py +0 -0
- {hookbase-3.1.0 → hookbase-3.3.0}/src/hookbase/resources/applications.py +0 -0
- {hookbase-3.1.0 → hookbase-3.3.0}/src/hookbase/resources/cron_jobs.py +0 -0
- {hookbase-3.1.0 → hookbase-3.3.0}/src/hookbase/resources/deliveries.py +0 -0
- {hookbase-3.1.0 → hookbase-3.3.0}/src/hookbase/resources/destinations.py +0 -0
- {hookbase-3.1.0 → hookbase-3.3.0}/src/hookbase/resources/filters.py +0 -0
- {hookbase-3.1.0 → hookbase-3.3.0}/src/hookbase/resources/organizations.py +0 -0
- {hookbase-3.1.0 → hookbase-3.3.0}/src/hookbase/resources/portal_tokens.py +0 -0
- {hookbase-3.1.0 → hookbase-3.3.0}/src/hookbase/resources/routes.py +0 -0
- {hookbase-3.1.0 → hookbase-3.3.0}/src/hookbase/resources/schemas.py +0 -0
- {hookbase-3.1.0 → hookbase-3.3.0}/src/hookbase/resources/transforms.py +0 -0
- {hookbase-3.1.0 → hookbase-3.3.0}/src/hookbase/resources/tunnels.py +0 -0
- {hookbase-3.1.0 → hookbase-3.3.0}/tests/__init__.py +0 -0
- {hookbase-3.1.0 → hookbase-3.3.0}/tests/conftest.py +0 -0
- {hookbase-3.1.0 → hookbase-3.3.0}/tests/resources/__init__.py +0 -0
- {hookbase-3.1.0 → hookbase-3.3.0}/tests/resources/test_applications.py +0 -0
- {hookbase-3.1.0 → hookbase-3.3.0}/tests/resources/test_cron_jobs.py +0 -0
- {hookbase-3.1.0 → hookbase-3.3.0}/tests/resources/test_destinations.py +0 -0
- {hookbase-3.1.0 → hookbase-3.3.0}/tests/resources/test_endpoints.py +0 -0
- {hookbase-3.1.0 → hookbase-3.3.0}/tests/resources/test_sources.py +0 -0
- {hookbase-3.1.0 → hookbase-3.3.0}/tests/test_client.py +0 -0
- {hookbase-3.1.0 → hookbase-3.3.0}/tests/test_errors.py +0 -0
- {hookbase-3.1.0 → hookbase-3.3.0}/tests/test_pagination.py +0 -0
- {hookbase-3.1.0 → hookbase-3.3.0}/tests/test_slug_contract.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: hookbase
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.3.0
|
|
4
4
|
Summary: Official Python SDK for the Hookbase webhook management platform
|
|
5
5
|
Project-URL: Homepage, https://hookbase.app
|
|
6
6
|
Project-URL: Documentation, https://www.hookbase.app/docs/send/sdk
|
|
@@ -98,7 +98,26 @@ client.sources.create({"name": "GitHub", "slug": "github", "provider": "github"}
|
|
|
98
98
|
client.sources.get("src_id")
|
|
99
99
|
client.sources.update("src_id", {"name": "Updated"})
|
|
100
100
|
client.sources.delete("src_id")
|
|
101
|
-
client.sources.rotate_secret("src_id")
|
|
101
|
+
client.sources.rotate_secret("src_id") # 400 for vendor-issued/public-key/built-in secrets
|
|
102
|
+
client.sources.reveal_secret("src_id")
|
|
103
|
+
|
|
104
|
+
# A provider that verifies with no secret: signing_secret comes back None
|
|
105
|
+
wise = client.sources.create({"name": "Wise", "slug": "wise", "provider": "wise"})
|
|
106
|
+
assert wise.verifies_without_secret and wise.signing_secret is None
|
|
107
|
+
|
|
108
|
+
# WhatsApp (Meta) sources also carry a verify token
|
|
109
|
+
wa = client.sources.create({"name": "WhatsApp", "slug": "whatsapp", "provider": "whatsapp"})
|
|
110
|
+
print(wa.verify_token)
|
|
111
|
+
client.sources.rotate_verify_token(wa.id)
|
|
112
|
+
|
|
113
|
+
# Notion parks its verification token until you adopt it
|
|
114
|
+
pending = client.sources.get_pending_verification("notion")
|
|
115
|
+
client.sources.adopt_pending_verification("notion")
|
|
116
|
+
|
|
117
|
+
# Provider catalog: which secret each provider needs (public, no auth needed)
|
|
118
|
+
for p in client.providers.list(category="payments").providers:
|
|
119
|
+
print(p.hookbase_provider, p.signature_verification and p.signature_verification.secret_source)
|
|
120
|
+
client.providers.get("stripe").signature_verification
|
|
102
121
|
|
|
103
122
|
# Destinations
|
|
104
123
|
client.destinations.list()
|
|
@@ -111,7 +130,7 @@ client.routes.get_circuit_status("route_id")
|
|
|
111
130
|
client.routes.reset_circuit("route_id")
|
|
112
131
|
|
|
113
132
|
# Events & Deliveries
|
|
114
|
-
client.events.list(source_id="src_id", from_date="2024-01-01")
|
|
133
|
+
client.events.list(source_id="src_id", from_date="2024-01-01") # id or slug; unknown -> 404
|
|
115
134
|
client.events.get("evt_id") # Includes payload and deliveries
|
|
116
135
|
client.deliveries.replay("del_id")
|
|
117
136
|
client.deliveries.bulk_replay(["del_1", "del_2"])
|
|
@@ -252,11 +271,14 @@ All responses are typed Pydantic models with camelCase alias support:
|
|
|
252
271
|
from hookbase.models import Source, CreateSourceParams
|
|
253
272
|
|
|
254
273
|
# Use Pydantic models for type safety
|
|
255
|
-
params = CreateSourceParams(name="GitHub", provider="github")
|
|
274
|
+
params = CreateSourceParams(name="GitHub", slug="github", provider="github")
|
|
256
275
|
source = client.sources.create(params)
|
|
257
276
|
|
|
258
277
|
# Or use plain dicts
|
|
259
|
-
source = client.sources.create({"name": "GitHub", "provider": "github"})
|
|
278
|
+
source = client.sources.create({"name": "GitHub", "slug": "github", "provider": "github"})
|
|
279
|
+
|
|
280
|
+
# `provider` is any string (hookbase.models.KNOWN_SOURCE_PROVIDERS lists today's values), so a
|
|
281
|
+
# source with a provider newer than this package still parses.
|
|
260
282
|
```
|
|
261
283
|
|
|
262
284
|
## Development
|
|
@@ -63,7 +63,26 @@ client.sources.create({"name": "GitHub", "slug": "github", "provider": "github"}
|
|
|
63
63
|
client.sources.get("src_id")
|
|
64
64
|
client.sources.update("src_id", {"name": "Updated"})
|
|
65
65
|
client.sources.delete("src_id")
|
|
66
|
-
client.sources.rotate_secret("src_id")
|
|
66
|
+
client.sources.rotate_secret("src_id") # 400 for vendor-issued/public-key/built-in secrets
|
|
67
|
+
client.sources.reveal_secret("src_id")
|
|
68
|
+
|
|
69
|
+
# A provider that verifies with no secret: signing_secret comes back None
|
|
70
|
+
wise = client.sources.create({"name": "Wise", "slug": "wise", "provider": "wise"})
|
|
71
|
+
assert wise.verifies_without_secret and wise.signing_secret is None
|
|
72
|
+
|
|
73
|
+
# WhatsApp (Meta) sources also carry a verify token
|
|
74
|
+
wa = client.sources.create({"name": "WhatsApp", "slug": "whatsapp", "provider": "whatsapp"})
|
|
75
|
+
print(wa.verify_token)
|
|
76
|
+
client.sources.rotate_verify_token(wa.id)
|
|
77
|
+
|
|
78
|
+
# Notion parks its verification token until you adopt it
|
|
79
|
+
pending = client.sources.get_pending_verification("notion")
|
|
80
|
+
client.sources.adopt_pending_verification("notion")
|
|
81
|
+
|
|
82
|
+
# Provider catalog: which secret each provider needs (public, no auth needed)
|
|
83
|
+
for p in client.providers.list(category="payments").providers:
|
|
84
|
+
print(p.hookbase_provider, p.signature_verification and p.signature_verification.secret_source)
|
|
85
|
+
client.providers.get("stripe").signature_verification
|
|
67
86
|
|
|
68
87
|
# Destinations
|
|
69
88
|
client.destinations.list()
|
|
@@ -76,7 +95,7 @@ client.routes.get_circuit_status("route_id")
|
|
|
76
95
|
client.routes.reset_circuit("route_id")
|
|
77
96
|
|
|
78
97
|
# Events & Deliveries
|
|
79
|
-
client.events.list(source_id="src_id", from_date="2024-01-01")
|
|
98
|
+
client.events.list(source_id="src_id", from_date="2024-01-01") # id or slug; unknown -> 404
|
|
80
99
|
client.events.get("evt_id") # Includes payload and deliveries
|
|
81
100
|
client.deliveries.replay("del_id")
|
|
82
101
|
client.deliveries.bulk_replay(["del_1", "del_2"])
|
|
@@ -217,11 +236,14 @@ All responses are typed Pydantic models with camelCase alias support:
|
|
|
217
236
|
from hookbase.models import Source, CreateSourceParams
|
|
218
237
|
|
|
219
238
|
# Use Pydantic models for type safety
|
|
220
|
-
params = CreateSourceParams(name="GitHub", provider="github")
|
|
239
|
+
params = CreateSourceParams(name="GitHub", slug="github", provider="github")
|
|
221
240
|
source = client.sources.create(params)
|
|
222
241
|
|
|
223
242
|
# Or use plain dicts
|
|
224
|
-
source = client.sources.create({"name": "GitHub", "provider": "github"})
|
|
243
|
+
source = client.sources.create({"name": "GitHub", "slug": "github", "provider": "github"})
|
|
244
|
+
|
|
245
|
+
# `provider` is any string (hookbase.models.KNOWN_SOURCE_PROVIDERS lists today's values), so a
|
|
246
|
+
# source with a provider newer than this package still parses.
|
|
225
247
|
```
|
|
226
248
|
|
|
227
249
|
## Development
|
|
@@ -76,6 +76,31 @@ def _should_retry(status_code: int) -> bool:
|
|
|
76
76
|
return status_code in RETRY_STATUS_CODES
|
|
77
77
|
|
|
78
78
|
|
|
79
|
+
# A POST creates something or triggers work, so repeating one that may already have reached the
|
|
80
|
+
# server (a 5xx, a read timeout, a dropped connection) can do it twice. Only retry those when the
|
|
81
|
+
# request carries an idempotency key the server can deduplicate on. A 429 was rejected before any
|
|
82
|
+
# work was done, and a connect failure never reached the server, so those stay retryable.
|
|
83
|
+
_NON_IDEMPOTENT_METHODS = frozenset({"POST"})
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _may_repeat(method: str, idempotency_key: str | None) -> bool:
|
|
87
|
+
"""Whether a request that may already have been processed is safe to send again."""
|
|
88
|
+
return method.upper() not in _NON_IDEMPOTENT_METHODS or bool(idempotency_key)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _never_sent(exc: httpx.HTTPError) -> bool:
|
|
92
|
+
"""Whether the transport failed before the request reached the server."""
|
|
93
|
+
return isinstance(exc, (httpx.ConnectError, httpx.ConnectTimeout))
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _decode_body(resp: httpx.Response) -> Any:
|
|
97
|
+
"""JSON for JSON responses; the text for anything else (e.g. a CSV export)."""
|
|
98
|
+
content_type = resp.headers.get("content-type", "")
|
|
99
|
+
if content_type and "json" not in content_type.lower():
|
|
100
|
+
return resp.text
|
|
101
|
+
return resp.json()
|
|
102
|
+
|
|
103
|
+
|
|
79
104
|
def _backoff(attempt: int) -> float:
|
|
80
105
|
base = min(1.0 * (2**attempt), 10.0)
|
|
81
106
|
jitter = random.random() # noqa: S311
|
|
@@ -126,6 +151,7 @@ class SyncTransport:
|
|
|
126
151
|
headers: dict[str, str] = {}
|
|
127
152
|
if idempotency_key:
|
|
128
153
|
headers["Idempotency-Key"] = idempotency_key
|
|
154
|
+
repeatable = _may_repeat(method, idempotency_key)
|
|
129
155
|
|
|
130
156
|
if self._debug:
|
|
131
157
|
logger.debug("[Hookbase] %s %s params=%s", method, path, cleaned)
|
|
@@ -143,13 +169,13 @@ class SyncTransport:
|
|
|
143
169
|
except httpx.TimeoutException as exc:
|
|
144
170
|
last_exc = TimeoutError(f"Request timed out after {self._timeout}s")
|
|
145
171
|
last_exc.__cause__ = exc
|
|
146
|
-
if attempt < self._max_retries:
|
|
172
|
+
if attempt < self._max_retries and (repeatable or _never_sent(exc)):
|
|
147
173
|
time.sleep(_backoff(attempt))
|
|
148
174
|
continue
|
|
149
175
|
raise last_exc from exc
|
|
150
176
|
except httpx.HTTPError as exc:
|
|
151
177
|
last_exc = NetworkError(str(exc), cause=exc)
|
|
152
|
-
if attempt < self._max_retries:
|
|
178
|
+
if attempt < self._max_retries and (repeatable or _never_sent(exc)):
|
|
153
179
|
time.sleep(_backoff(attempt))
|
|
154
180
|
continue
|
|
155
181
|
raise last_exc from exc
|
|
@@ -160,7 +186,7 @@ class SyncTransport:
|
|
|
160
186
|
return None
|
|
161
187
|
|
|
162
188
|
if resp.is_success:
|
|
163
|
-
return resp
|
|
189
|
+
return _decode_body(resp)
|
|
164
190
|
|
|
165
191
|
# Parse error body
|
|
166
192
|
try:
|
|
@@ -179,9 +205,12 @@ class SyncTransport:
|
|
|
179
205
|
except ValueError:
|
|
180
206
|
pass
|
|
181
207
|
|
|
182
|
-
# Don't retry client errors (except 429
|
|
208
|
+
# Don't retry client errors (except 429), nor a 5xx on a request
|
|
209
|
+
# that is not safe to repeat (see `_may_repeat`).
|
|
183
210
|
if not _should_retry(resp.status_code):
|
|
184
211
|
raise error
|
|
212
|
+
if not repeatable and not isinstance(error, RateLimitError):
|
|
213
|
+
raise error
|
|
185
214
|
|
|
186
215
|
last_exc = error
|
|
187
216
|
|
|
@@ -242,6 +271,7 @@ class AsyncTransport:
|
|
|
242
271
|
headers: dict[str, str] = {}
|
|
243
272
|
if idempotency_key:
|
|
244
273
|
headers["Idempotency-Key"] = idempotency_key
|
|
274
|
+
repeatable = _may_repeat(method, idempotency_key)
|
|
245
275
|
|
|
246
276
|
if self._debug:
|
|
247
277
|
logger.debug("[Hookbase] %s %s params=%s", method, path, cleaned)
|
|
@@ -259,13 +289,13 @@ class AsyncTransport:
|
|
|
259
289
|
except httpx.TimeoutException as exc:
|
|
260
290
|
last_exc = TimeoutError(f"Request timed out after {self._timeout}s")
|
|
261
291
|
last_exc.__cause__ = exc
|
|
262
|
-
if attempt < self._max_retries:
|
|
292
|
+
if attempt < self._max_retries and (repeatable or _never_sent(exc)):
|
|
263
293
|
await asyncio.sleep(_backoff(attempt))
|
|
264
294
|
continue
|
|
265
295
|
raise last_exc from exc
|
|
266
296
|
except httpx.HTTPError as exc:
|
|
267
297
|
last_exc = NetworkError(str(exc), cause=exc)
|
|
268
|
-
if attempt < self._max_retries:
|
|
298
|
+
if attempt < self._max_retries and (repeatable or _never_sent(exc)):
|
|
269
299
|
await asyncio.sleep(_backoff(attempt))
|
|
270
300
|
continue
|
|
271
301
|
raise last_exc from exc
|
|
@@ -276,7 +306,7 @@ class AsyncTransport:
|
|
|
276
306
|
return None
|
|
277
307
|
|
|
278
308
|
if resp.is_success:
|
|
279
|
-
return resp
|
|
309
|
+
return _decode_body(resp)
|
|
280
310
|
|
|
281
311
|
try:
|
|
282
312
|
body = resp.json()
|
|
@@ -295,6 +325,8 @@ class AsyncTransport:
|
|
|
295
325
|
|
|
296
326
|
if not _should_retry(resp.status_code):
|
|
297
327
|
raise error
|
|
328
|
+
if not repeatable and not isinstance(error, RateLimitError):
|
|
329
|
+
raise error
|
|
298
330
|
|
|
299
331
|
last_exc = error
|
|
300
332
|
|
|
@@ -161,7 +161,7 @@ class SyncCursorPage(Generic[T]):
|
|
|
161
161
|
page = self
|
|
162
162
|
while True:
|
|
163
163
|
yield from page.data
|
|
164
|
-
if not page.has_more:
|
|
164
|
+
if not page.has_more or not page.next_cursor:
|
|
165
165
|
break
|
|
166
166
|
page = page.next_page()
|
|
167
167
|
|
|
@@ -211,7 +211,7 @@ class AsyncCursorPage(Generic[T]):
|
|
|
211
211
|
while True:
|
|
212
212
|
for item in page.data:
|
|
213
213
|
yield item
|
|
214
|
-
if not page.has_more:
|
|
214
|
+
if not page.has_more or not page.next_cursor:
|
|
215
215
|
break
|
|
216
216
|
page = await page.next_page()
|
|
217
217
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "3.3.0"
|
|
@@ -26,6 +26,7 @@ from .resources import (
|
|
|
26
26
|
AsyncMessages,
|
|
27
27
|
AsyncOrganizations,
|
|
28
28
|
AsyncPortalTokens,
|
|
29
|
+
AsyncProviders,
|
|
29
30
|
AsyncRoutes,
|
|
30
31
|
AsyncSchemas,
|
|
31
32
|
AsyncSources,
|
|
@@ -43,6 +44,7 @@ from .resources import (
|
|
|
43
44
|
Messages,
|
|
44
45
|
Organizations,
|
|
45
46
|
PortalTokens,
|
|
47
|
+
Providers,
|
|
46
48
|
Routes,
|
|
47
49
|
Schemas,
|
|
48
50
|
Sources,
|
|
@@ -118,6 +120,8 @@ class Hookbase:
|
|
|
118
120
|
transforms: Transforms
|
|
119
121
|
filters: Filters
|
|
120
122
|
schemas: Schemas
|
|
123
|
+
#: The public provider catalog (what each provider signs with, which secret it needs).
|
|
124
|
+
providers: Providers
|
|
121
125
|
|
|
122
126
|
# Outbound
|
|
123
127
|
outbound: _OutboundNamespace
|
|
@@ -160,6 +164,7 @@ class Hookbase:
|
|
|
160
164
|
self.transforms = Transforms(self._transport)
|
|
161
165
|
self.filters = Filters(self._transport)
|
|
162
166
|
self.schemas = Schemas(self._transport)
|
|
167
|
+
self.providers = Providers(self._transport)
|
|
163
168
|
|
|
164
169
|
# Outbound namespace
|
|
165
170
|
self.outbound = _OutboundNamespace(self._transport)
|
|
@@ -210,6 +215,8 @@ class AsyncHookbase:
|
|
|
210
215
|
transforms: AsyncTransforms
|
|
211
216
|
filters: AsyncFilters
|
|
212
217
|
schemas: AsyncSchemas
|
|
218
|
+
#: The public provider catalog (what each provider signs with, which secret it needs).
|
|
219
|
+
providers: AsyncProviders
|
|
213
220
|
|
|
214
221
|
# Outbound
|
|
215
222
|
outbound: _AsyncOutboundNamespace
|
|
@@ -252,6 +259,7 @@ class AsyncHookbase:
|
|
|
252
259
|
self.transforms = AsyncTransforms(self._transport)
|
|
253
260
|
self.filters = AsyncFilters(self._transport)
|
|
254
261
|
self.schemas = AsyncSchemas(self._transport)
|
|
262
|
+
self.providers = AsyncProviders(self._transport)
|
|
255
263
|
|
|
256
264
|
# Outbound namespace
|
|
257
265
|
self.outbound = _AsyncOutboundNamespace(self._transport)
|
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
from .analytics import AnalyticsTimeline, DashboardData
|
|
2
2
|
from .api_keys import ApiKey, ApiKeyWithSecret, CreateApiKeyParams
|
|
3
3
|
from .applications import Application, CreateApplicationParams, UpdateApplicationParams
|
|
4
|
+
from .catalog import (
|
|
5
|
+
CatalogProvider,
|
|
6
|
+
CatalogProviderDetail,
|
|
7
|
+
ProviderCatalogList,
|
|
8
|
+
ProviderEvents,
|
|
9
|
+
ProviderEventType,
|
|
10
|
+
ProviderSignatureVerification,
|
|
11
|
+
ProviderSignatureVerificationSummary,
|
|
12
|
+
ProviderVerifyTokenInfo,
|
|
13
|
+
)
|
|
4
14
|
from .common import BulkDeleteResult, ImportResult, ImportResultItem
|
|
5
15
|
from .cron_jobs import (
|
|
6
16
|
CreateCronGroupParams,
|
|
@@ -89,9 +99,13 @@ from .routes import (
|
|
|
89
99
|
)
|
|
90
100
|
from .schemas import CreateSchemaParams, Schema, SchemaValidationResult, UpdateSchemaParams
|
|
91
101
|
from .sources import (
|
|
102
|
+
CLEARABLE_SOURCE_FIELDS,
|
|
103
|
+
KNOWN_SOURCE_PROVIDERS,
|
|
104
|
+
ClearableSourceField,
|
|
92
105
|
CreateSourceParams,
|
|
93
106
|
DedupStrategy,
|
|
94
107
|
IpFilterMode,
|
|
108
|
+
PendingVerification,
|
|
95
109
|
Source,
|
|
96
110
|
SourceProvider,
|
|
97
111
|
SourceWithSecret,
|
|
@@ -119,6 +133,15 @@ __all__ = [
|
|
|
119
133
|
"Application",
|
|
120
134
|
"CreateApplicationParams",
|
|
121
135
|
"UpdateApplicationParams",
|
|
136
|
+
# Catalog
|
|
137
|
+
"CatalogProvider",
|
|
138
|
+
"CatalogProviderDetail",
|
|
139
|
+
"ProviderCatalogList",
|
|
140
|
+
"ProviderEventType",
|
|
141
|
+
"ProviderEvents",
|
|
142
|
+
"ProviderSignatureVerification",
|
|
143
|
+
"ProviderSignatureVerificationSummary",
|
|
144
|
+
"ProviderVerifyTokenInfo",
|
|
122
145
|
# Common
|
|
123
146
|
"BulkDeleteResult",
|
|
124
147
|
"ImportResult",
|
|
@@ -211,9 +234,13 @@ __all__ = [
|
|
|
211
234
|
"SchemaValidationResult",
|
|
212
235
|
"UpdateSchemaParams",
|
|
213
236
|
# Sources
|
|
237
|
+
"CLEARABLE_SOURCE_FIELDS",
|
|
238
|
+
"KNOWN_SOURCE_PROVIDERS",
|
|
239
|
+
"ClearableSourceField",
|
|
214
240
|
"CreateSourceParams",
|
|
215
241
|
"DedupStrategy",
|
|
216
242
|
"IpFilterMode",
|
|
243
|
+
"PendingVerification",
|
|
217
244
|
"Source",
|
|
218
245
|
"SourceProvider",
|
|
219
246
|
"SourceWithSecret",
|
|
@@ -14,6 +14,19 @@ class Application(HookbaseModel):
|
|
|
14
14
|
organization_id: str
|
|
15
15
|
external_id: str = ""
|
|
16
16
|
metadata: dict[str, Any] | None = None
|
|
17
|
+
rate_limit_per_second: int | None = None
|
|
18
|
+
rate_limit_per_minute: int | None = None
|
|
19
|
+
rate_limit_per_hour: int | None = None
|
|
20
|
+
is_disabled: bool = False
|
|
21
|
+
disabled_at: str | None = None
|
|
22
|
+
disabled_reason: str | None = None
|
|
23
|
+
total_endpoints: int | None = None
|
|
24
|
+
total_messages_sent: int | None = None
|
|
25
|
+
total_messages_failed: int | None = None
|
|
26
|
+
last_event_at: str | None = None
|
|
27
|
+
send_event_url: str | None = None
|
|
28
|
+
created_by: str | None = None
|
|
29
|
+
api_key_id: str | None = None
|
|
17
30
|
created_at: str = ""
|
|
18
31
|
updated_at: str = ""
|
|
19
32
|
|
|
@@ -45,6 +58,9 @@ class CreateApplicationParams(HookbaseModel):
|
|
|
45
58
|
name: str
|
|
46
59
|
external_id: str | None = None
|
|
47
60
|
metadata: dict[str, Any] | None = None
|
|
61
|
+
rate_limit_per_second: int | None = None
|
|
62
|
+
rate_limit_per_minute: int | None = None
|
|
63
|
+
rate_limit_per_hour: int | None = None
|
|
48
64
|
|
|
49
65
|
uid: str | None = None
|
|
50
66
|
"""Deprecated: renamed to `external_id`, which is the only name the API
|
|
@@ -55,6 +71,11 @@ class CreateApplicationParams(HookbaseModel):
|
|
|
55
71
|
class UpdateApplicationParams(HookbaseModel):
|
|
56
72
|
name: str | None = None
|
|
57
73
|
metadata: dict[str, Any] | None = None
|
|
74
|
+
rate_limit_per_second: int | None = None
|
|
75
|
+
rate_limit_per_minute: int | None = None
|
|
76
|
+
rate_limit_per_hour: int | None = None
|
|
77
|
+
is_disabled: bool | None = None
|
|
78
|
+
disabled_reason: str | None = None
|
|
58
79
|
|
|
59
80
|
|
|
60
81
|
def application_body(params: CreateApplicationParams | dict[str, Any]) -> dict[str, Any]:
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""The public provider catalog: GET /api/catalog/providers (no authentication needed).
|
|
2
|
+
|
|
3
|
+
Every enumerated value here (category, kind, secret_source, secret_format) is a plain string
|
|
4
|
+
rather than a Literal, for the reason ``SourceProvider`` is: the catalog grows server-side, and a
|
|
5
|
+
value this package has not heard of must not make the whole response fail to parse.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from pydantic import field_validator
|
|
13
|
+
|
|
14
|
+
from ._base import HookbaseModel
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ProviderEventType(HookbaseModel):
|
|
18
|
+
type: str
|
|
19
|
+
description: str = ""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ProviderVerifyTokenInfo(HookbaseModel):
|
|
23
|
+
"""Meta's second, Hookbase-generated credential (see SourceWithSecret.verify_token)."""
|
|
24
|
+
|
|
25
|
+
label: str
|
|
26
|
+
instruction: str = ""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ProviderSignatureVerificationSummary(HookbaseModel):
|
|
30
|
+
"""The secret fields a create flow needs, as the catalog list carries them.
|
|
31
|
+
|
|
32
|
+
``secret_source`` says who produces the credential: "provider" (the vendor issues it; paste
|
|
33
|
+
it as signing_secret), "operator" (Hookbase generates it when omitted; paste it into the
|
|
34
|
+
vendor), "public_key" (paste the vendor's public key), "built_in" (nothing to enter; events
|
|
35
|
+
are still verified) or "none" (the vendor does not sign).
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
supported: bool = False
|
|
39
|
+
#: The scheme kind: "hmac", "shared_secret", "bespoke" or "asymmetric". None when no scheme.
|
|
40
|
+
kind: str | None = None
|
|
41
|
+
secret_source: str | None = None
|
|
42
|
+
secret_label: str | None = None
|
|
43
|
+
secret_placeholder: str | None = None
|
|
44
|
+
#: The vendor issues the secret only after the source's URL is registered with it.
|
|
45
|
+
secret_after_registration: bool = False
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ProviderSignatureVerification(ProviderSignatureVerificationSummary):
|
|
49
|
+
"""The full block from the provider detail call."""
|
|
50
|
+
|
|
51
|
+
algorithm: str | None = None
|
|
52
|
+
header_name: str | None = None
|
|
53
|
+
secret_location_hint: str | None = None
|
|
54
|
+
#: One sentence on where the value comes from or goes, in the vendor's own UI wording.
|
|
55
|
+
secret_instruction: str | None = None
|
|
56
|
+
#: "whsec" or "basic_auth" when the value has a required shape.
|
|
57
|
+
secret_format: str | None = None
|
|
58
|
+
#: Present for Meta providers, which also use a verify token.
|
|
59
|
+
verify_token: ProviderVerifyTokenInfo | None = None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class CatalogProvider(HookbaseModel):
|
|
63
|
+
"""A provider as the catalog list returns it.
|
|
64
|
+
|
|
65
|
+
``id`` is the catalog's id; ``hookbase_provider`` is the value to use as a source's
|
|
66
|
+
``provider``. They differ for some entries (catalog "mollie-nextgen" is provider "mollie",
|
|
67
|
+
and several catalog entries map to "custom").
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
id: str
|
|
71
|
+
name: str
|
|
72
|
+
category: str = ""
|
|
73
|
+
description: str = ""
|
|
74
|
+
icon: str | None = None
|
|
75
|
+
event_type_count: int = 0
|
|
76
|
+
hookbase_provider: str = ""
|
|
77
|
+
has_signature_verification: bool = False
|
|
78
|
+
has_ip_ranges: bool = False
|
|
79
|
+
#: None when the provider has no verification block, and also from API versions that
|
|
80
|
+
#: predate this summary on list items: fall back to client.providers.get(id) for it.
|
|
81
|
+
signature_verification: ProviderSignatureVerificationSummary | None = None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class ProviderCatalogList(HookbaseModel):
|
|
85
|
+
providers: list[CatalogProvider] = []
|
|
86
|
+
categories: list[str] = []
|
|
87
|
+
total: int = 0
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class CatalogProviderDetail(HookbaseModel):
|
|
91
|
+
"""One provider in full, from GET /api/catalog/providers/:id."""
|
|
92
|
+
|
|
93
|
+
id: str
|
|
94
|
+
name: str
|
|
95
|
+
category: str = ""
|
|
96
|
+
description: str = ""
|
|
97
|
+
website: str | None = None
|
|
98
|
+
docs_url: str | None = None
|
|
99
|
+
icon: str | None = None
|
|
100
|
+
hookbase_provider: str = ""
|
|
101
|
+
signature_verification: ProviderSignatureVerification | None = None
|
|
102
|
+
event_types: list[ProviderEventType] = []
|
|
103
|
+
#: Example payloads keyed by event type.
|
|
104
|
+
sample_payloads: dict[str, Any] = {}
|
|
105
|
+
ip_ranges: list[str] | None = None
|
|
106
|
+
setup_guide: list[str] = []
|
|
107
|
+
|
|
108
|
+
@field_validator("event_types", "setup_guide", mode="before")
|
|
109
|
+
@classmethod
|
|
110
|
+
def _none_to_list(cls, v: Any) -> Any:
|
|
111
|
+
return [] if v is None else v
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class ProviderEvents(HookbaseModel):
|
|
115
|
+
"""GET /api/catalog/providers/:id/events."""
|
|
116
|
+
|
|
117
|
+
provider_id: str
|
|
118
|
+
provider_name: str = ""
|
|
119
|
+
event_types: list[ProviderEventType] = []
|
|
120
|
+
sample_payloads: dict[str, Any] = {}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from pydantic import model_validator
|
|
6
|
+
|
|
7
|
+
from ._base import HookbaseModel
|
|
8
|
+
from .messages import _mirror
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class DlqMessage(HookbaseModel):
|
|
12
|
+
"""A dead-lettered message, as `GET /api/outbound-messages/dlq/messages` returns it."""
|
|
13
|
+
|
|
14
|
+
id: str
|
|
15
|
+
event_id: str = ""
|
|
16
|
+
endpoint_id: str = ""
|
|
17
|
+
endpoint_url: str | None = None
|
|
18
|
+
application_id: str = ""
|
|
19
|
+
application_name: str | None = None
|
|
20
|
+
event_type: str = ""
|
|
21
|
+
status: str = ""
|
|
22
|
+
dlq_reason: str | None = None
|
|
23
|
+
dlq_moved_at: str | None = None
|
|
24
|
+
dlq_retry_count: int | None = None
|
|
25
|
+
dlq_last_retry_at: str | None = None
|
|
26
|
+
attempts: int = 0
|
|
27
|
+
max_attempts: int = 5
|
|
28
|
+
last_response_status: int | None = None
|
|
29
|
+
last_error_type: str | None = None
|
|
30
|
+
last_error_message: str | None = None
|
|
31
|
+
created_at: str = ""
|
|
32
|
+
completed_at: str | None = None
|
|
33
|
+
|
|
34
|
+
# Deprecated: names the API never sent.
|
|
35
|
+
message_id: str = ""
|
|
36
|
+
"""Deprecated: read `event_id`."""
|
|
37
|
+
last_error: str | None = None
|
|
38
|
+
"""Deprecated: read `last_error_message`."""
|
|
39
|
+
last_attempt_at: str | None = None
|
|
40
|
+
"""Deprecated: not returned by the API; always `None`."""
|
|
41
|
+
updated_at: str = ""
|
|
42
|
+
"""Deprecated: not returned by the API; always empty."""
|
|
43
|
+
|
|
44
|
+
@model_validator(mode="before")
|
|
45
|
+
@classmethod
|
|
46
|
+
def _fill_deprecated(cls, data: Any) -> Any:
|
|
47
|
+
return _mirror(data, {"messageId": "eventId", "lastError": "lastErrorMessage"})
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class DlqEndpointStat(HookbaseModel):
|
|
51
|
+
endpoint_id: str
|
|
52
|
+
endpoint_url: str | None = ""
|
|
53
|
+
count: int = 0
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class DlqEventTypeStat(HookbaseModel):
|
|
57
|
+
event_type: str
|
|
58
|
+
count: int = 0
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class DlqStats(HookbaseModel):
|
|
62
|
+
total: int = 0
|
|
63
|
+
by_reason: dict[str, int] = {}
|
|
64
|
+
by_endpoint: list[DlqEndpointStat] = []
|
|
65
|
+
"""The ten endpoints with the most dead-lettered messages."""
|
|
66
|
+
by_event_type: list[DlqEventTypeStat] = []
|
|
67
|
+
"""The ten event types with the most dead-lettered messages."""
|
|
68
|
+
|
|
69
|
+
top_failing_endpoints: list[DlqEndpointStat] = []
|
|
70
|
+
"""Deprecated: read `by_endpoint`; this mirrors it."""
|
|
71
|
+
|
|
72
|
+
@model_validator(mode="before")
|
|
73
|
+
@classmethod
|
|
74
|
+
def _fill_deprecated(cls, data: Any) -> Any:
|
|
75
|
+
return _mirror(data, {"topFailingEndpoints": "byEndpoint"})
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class DlqRetryResult(HookbaseModel):
|
|
79
|
+
original_message_id: str = ""
|
|
80
|
+
new_message_id: str = ""
|
|
81
|
+
status: str = ""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class DlqBulkRetryResultItem(HookbaseModel):
|
|
85
|
+
original_message_id: str = ""
|
|
86
|
+
new_message_id: str | None = None
|
|
87
|
+
|
|
88
|
+
message_id: str = ""
|
|
89
|
+
"""Deprecated: read `original_message_id`; this mirrors it."""
|
|
90
|
+
status: str = ""
|
|
91
|
+
"""Deprecated: the API returns only the messages it queued, so this is always `"queued"`."""
|
|
92
|
+
error: str | None = None
|
|
93
|
+
"""Deprecated: not returned by the API; always `None`."""
|
|
94
|
+
|
|
95
|
+
@model_validator(mode="before")
|
|
96
|
+
@classmethod
|
|
97
|
+
def _fill_deprecated(cls, data: Any) -> Any:
|
|
98
|
+
data = _mirror(data, {"messageId": "originalMessageId"})
|
|
99
|
+
if isinstance(data, dict) and not data.get("status"):
|
|
100
|
+
data["status"] = "queued"
|
|
101
|
+
return data
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class DlqBulkRetryResult(HookbaseModel):
|
|
105
|
+
"""The result of `POST /api/outbound-messages/dlq/retry-bulk`."""
|
|
106
|
+
|
|
107
|
+
queued: int = 0
|
|
108
|
+
"""Messages re-queued for delivery."""
|
|
109
|
+
skipped: int = 0
|
|
110
|
+
"""Requested IDs that were not in the DLQ (or not found)."""
|
|
111
|
+
messages: list[DlqBulkRetryResultItem] = []
|
|
112
|
+
|
|
113
|
+
# Deprecated: names the API never sent, derived from the fields above.
|
|
114
|
+
total: int = 0
|
|
115
|
+
"""Deprecated: `queued + skipped`."""
|
|
116
|
+
retried: int = 0
|
|
117
|
+
"""Deprecated: read `queued`."""
|
|
118
|
+
failed: int = 0
|
|
119
|
+
"""Deprecated: read `skipped`."""
|
|
120
|
+
results: list[DlqBulkRetryResultItem] = []
|
|
121
|
+
"""Deprecated: read `messages`."""
|
|
122
|
+
|
|
123
|
+
@model_validator(mode="before")
|
|
124
|
+
@classmethod
|
|
125
|
+
def _fill_deprecated(cls, data: Any) -> Any:
|
|
126
|
+
data = _mirror(data, {"retried": "queued", "failed": "skipped", "results": "messages"})
|
|
127
|
+
if isinstance(data, dict) and data.get("total") is None:
|
|
128
|
+
data["total"] = int(data.get("queued") or 0) + int(data.get("skipped") or 0)
|
|
129
|
+
return data
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class DlqBulkDeleteResult(HookbaseModel):
|
|
133
|
+
deleted: int = 0
|
|
134
|
+
"""How many IDs were submitted; the API does not report how many were actually in the DLQ."""
|
|
135
|
+
|
|
136
|
+
total: int = 0
|
|
137
|
+
"""Deprecated: mirrors `deleted`."""
|
|
138
|
+
|
|
139
|
+
@model_validator(mode="before")
|
|
140
|
+
@classmethod
|
|
141
|
+
def _fill_deprecated(cls, data: Any) -> Any:
|
|
142
|
+
return _mirror(data, {"total": "deleted"})
|