hookbase 3.2.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.2.0 → hookbase-3.3.0}/PKG-INFO +1 -1
- {hookbase-3.2.0 → hookbase-3.3.0}/src/hookbase/_client.py +39 -7
- {hookbase-3.2.0 → hookbase-3.3.0}/src/hookbase/_pagination.py +2 -2
- hookbase-3.3.0/src/hookbase/_version.py +1 -0
- {hookbase-3.2.0 → hookbase-3.3.0}/src/hookbase/models/applications.py +21 -0
- hookbase-3.3.0/src/hookbase/models/dlq.py +142 -0
- {hookbase-3.2.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/subscriptions.py +78 -0
- {hookbase-3.2.0 → hookbase-3.3.0}/src/hookbase/resources/dlq.py +29 -10
- {hookbase-3.2.0 → hookbase-3.3.0}/src/hookbase/resources/endpoints.py +53 -4
- {hookbase-3.2.0 → hookbase-3.3.0}/src/hookbase/resources/event_types.py +6 -5
- {hookbase-3.2.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.2.0 → hookbase-3.3.0}/src/hookbase/resources/subscriptions.py +14 -4
- {hookbase-3.2.0 → hookbase-3.3.0}/src/hookbase/webhook.py +18 -1
- {hookbase-3.2.0 → hookbase-3.3.0}/tests/resources/test_dlq.py +20 -7
- {hookbase-3.2.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.2.0 → hookbase-3.3.0}/tests/test_webhook.py +60 -0
- hookbase-3.2.0/src/hookbase/_version.py +0 -1
- hookbase-3.2.0/src/hookbase/models/dlq.py +0 -60
- hookbase-3.2.0/src/hookbase/models/event_types.py +0 -37
- hookbase-3.2.0/src/hookbase/models/messages.py +0 -73
- hookbase-3.2.0/src/hookbase/models/subscriptions.py +0 -22
- hookbase-3.2.0/src/hookbase/resources/messages.py +0 -70
- {hookbase-3.2.0 → hookbase-3.3.0}/.github/workflows/python-publish.yml +0 -0
- {hookbase-3.2.0 → hookbase-3.3.0}/.gitignore +0 -0
- {hookbase-3.2.0 → hookbase-3.3.0}/LICENSE +0 -0
- {hookbase-3.2.0 → hookbase-3.3.0}/README.md +0 -0
- {hookbase-3.2.0 → hookbase-3.3.0}/examples/async_usage.py +0 -0
- {hookbase-3.2.0 → hookbase-3.3.0}/examples/basic_inbound.py +0 -0
- {hookbase-3.2.0 → hookbase-3.3.0}/examples/error_handling.py +0 -0
- {hookbase-3.2.0 → hookbase-3.3.0}/examples/send_webhooks.py +0 -0
- {hookbase-3.2.0 → hookbase-3.3.0}/examples/verify_webhooks.py +0 -0
- {hookbase-3.2.0 → hookbase-3.3.0}/pyproject.toml +0 -0
- {hookbase-3.2.0 → hookbase-3.3.0}/src/hookbase/__init__.py +0 -0
- {hookbase-3.2.0 → hookbase-3.3.0}/src/hookbase/_constants.py +0 -0
- {hookbase-3.2.0 → hookbase-3.3.0}/src/hookbase/client.py +0 -0
- {hookbase-3.2.0 → hookbase-3.3.0}/src/hookbase/errors.py +0 -0
- {hookbase-3.2.0 → hookbase-3.3.0}/src/hookbase/models/__init__.py +0 -0
- {hookbase-3.2.0 → hookbase-3.3.0}/src/hookbase/models/_base.py +0 -0
- {hookbase-3.2.0 → hookbase-3.3.0}/src/hookbase/models/_wire.py +0 -0
- {hookbase-3.2.0 → hookbase-3.3.0}/src/hookbase/models/analytics.py +0 -0
- {hookbase-3.2.0 → hookbase-3.3.0}/src/hookbase/models/api_keys.py +0 -0
- {hookbase-3.2.0 → hookbase-3.3.0}/src/hookbase/models/catalog.py +0 -0
- {hookbase-3.2.0 → hookbase-3.3.0}/src/hookbase/models/common.py +0 -0
- {hookbase-3.2.0 → hookbase-3.3.0}/src/hookbase/models/cron_jobs.py +0 -0
- {hookbase-3.2.0 → hookbase-3.3.0}/src/hookbase/models/deliveries.py +0 -0
- {hookbase-3.2.0 → hookbase-3.3.0}/src/hookbase/models/destinations.py +0 -0
- {hookbase-3.2.0 → hookbase-3.3.0}/src/hookbase/models/events.py +0 -0
- {hookbase-3.2.0 → hookbase-3.3.0}/src/hookbase/models/filters.py +0 -0
- {hookbase-3.2.0 → hookbase-3.3.0}/src/hookbase/models/organizations.py +0 -0
- {hookbase-3.2.0 → hookbase-3.3.0}/src/hookbase/models/portal_tokens.py +0 -0
- {hookbase-3.2.0 → hookbase-3.3.0}/src/hookbase/models/routes.py +0 -0
- {hookbase-3.2.0 → hookbase-3.3.0}/src/hookbase/models/schemas.py +0 -0
- {hookbase-3.2.0 → hookbase-3.3.0}/src/hookbase/models/sources.py +0 -0
- {hookbase-3.2.0 → hookbase-3.3.0}/src/hookbase/models/transforms.py +0 -0
- {hookbase-3.2.0 → hookbase-3.3.0}/src/hookbase/models/tunnels.py +0 -0
- {hookbase-3.2.0 → hookbase-3.3.0}/src/hookbase/py.typed +0 -0
- {hookbase-3.2.0 → hookbase-3.3.0}/src/hookbase/resources/__init__.py +0 -0
- {hookbase-3.2.0 → hookbase-3.3.0}/src/hookbase/resources/_base.py +0 -0
- {hookbase-3.2.0 → hookbase-3.3.0}/src/hookbase/resources/analytics.py +0 -0
- {hookbase-3.2.0 → hookbase-3.3.0}/src/hookbase/resources/api_keys.py +0 -0
- {hookbase-3.2.0 → hookbase-3.3.0}/src/hookbase/resources/applications.py +0 -0
- {hookbase-3.2.0 → hookbase-3.3.0}/src/hookbase/resources/cron_jobs.py +0 -0
- {hookbase-3.2.0 → hookbase-3.3.0}/src/hookbase/resources/deliveries.py +0 -0
- {hookbase-3.2.0 → hookbase-3.3.0}/src/hookbase/resources/destinations.py +0 -0
- {hookbase-3.2.0 → hookbase-3.3.0}/src/hookbase/resources/events.py +0 -0
- {hookbase-3.2.0 → hookbase-3.3.0}/src/hookbase/resources/filters.py +0 -0
- {hookbase-3.2.0 → hookbase-3.3.0}/src/hookbase/resources/organizations.py +0 -0
- {hookbase-3.2.0 → hookbase-3.3.0}/src/hookbase/resources/portal_tokens.py +0 -0
- {hookbase-3.2.0 → hookbase-3.3.0}/src/hookbase/resources/providers.py +0 -0
- {hookbase-3.2.0 → hookbase-3.3.0}/src/hookbase/resources/routes.py +0 -0
- {hookbase-3.2.0 → hookbase-3.3.0}/src/hookbase/resources/schemas.py +0 -0
- {hookbase-3.2.0 → hookbase-3.3.0}/src/hookbase/resources/sources.py +0 -0
- {hookbase-3.2.0 → hookbase-3.3.0}/src/hookbase/resources/transforms.py +0 -0
- {hookbase-3.2.0 → hookbase-3.3.0}/src/hookbase/resources/tunnels.py +0 -0
- {hookbase-3.2.0 → hookbase-3.3.0}/tests/__init__.py +0 -0
- {hookbase-3.2.0 → hookbase-3.3.0}/tests/conftest.py +0 -0
- {hookbase-3.2.0 → hookbase-3.3.0}/tests/resources/__init__.py +0 -0
- {hookbase-3.2.0 → hookbase-3.3.0}/tests/resources/test_applications.py +0 -0
- {hookbase-3.2.0 → hookbase-3.3.0}/tests/resources/test_cron_jobs.py +0 -0
- {hookbase-3.2.0 → hookbase-3.3.0}/tests/resources/test_destinations.py +0 -0
- {hookbase-3.2.0 → hookbase-3.3.0}/tests/resources/test_endpoints.py +0 -0
- {hookbase-3.2.0 → hookbase-3.3.0}/tests/resources/test_source_contract.py +0 -0
- {hookbase-3.2.0 → hookbase-3.3.0}/tests/resources/test_source_fields.py +0 -0
- {hookbase-3.2.0 → hookbase-3.3.0}/tests/resources/test_sources.py +0 -0
- {hookbase-3.2.0 → hookbase-3.3.0}/tests/test_client.py +0 -0
- {hookbase-3.2.0 → hookbase-3.3.0}/tests/test_errors.py +0 -0
- {hookbase-3.2.0 → hookbase-3.3.0}/tests/test_pagination.py +0 -0
- {hookbase-3.2.0 → hookbase-3.3.0}/tests/test_slug_contract.py +0 -0
|
@@ -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"
|
|
@@ -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,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"})
|
|
@@ -271,5 +271,22 @@ class EndpointStats(HookbaseModel):
|
|
|
271
271
|
|
|
272
272
|
|
|
273
273
|
class RotateSecretResult(HookbaseModel):
|
|
274
|
+
"""The result of `POST /api/webhook-endpoints/:id/rotate-secret`."""
|
|
275
|
+
|
|
274
276
|
secret: str
|
|
277
|
+
"""The new signing secret. Save it now; no later response repeats it."""
|
|
278
|
+
previous_secret_expires_at: str | None = None
|
|
279
|
+
"""Until when deliveries are also signed with the previous secret."""
|
|
280
|
+
secret_version: int | None = None
|
|
281
|
+
|
|
275
282
|
previous_secret_valid_until: str | None = None
|
|
283
|
+
"""Deprecated: read `previous_secret_expires_at`; this mirrors it."""
|
|
284
|
+
|
|
285
|
+
@model_validator(mode="before")
|
|
286
|
+
@classmethod
|
|
287
|
+
def _mirror_expiry(cls, data: Any) -> Any:
|
|
288
|
+
if isinstance(data, dict) and data.get("previousSecretValidUntil") is None:
|
|
289
|
+
expires = data.get("previousSecretExpiresAt")
|
|
290
|
+
if expires is not None:
|
|
291
|
+
return {**data, "previousSecretValidUntil": expires}
|
|
292
|
+
return data
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from pydantic import Field, model_validator
|
|
7
|
+
|
|
8
|
+
from . import _wire
|
|
9
|
+
from ._base import HookbaseModel
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class EventType(HookbaseModel):
|
|
13
|
+
id: str
|
|
14
|
+
organization_id: str
|
|
15
|
+
name: str
|
|
16
|
+
display_name: str | None = None
|
|
17
|
+
description: str | None = None
|
|
18
|
+
category: str | None = None
|
|
19
|
+
schema_: Any = Field(default=None, alias="schema")
|
|
20
|
+
"""The JSON Schema, decoded. The API stores it as JSON, so this is usually a dict."""
|
|
21
|
+
schema_version: int | None = None
|
|
22
|
+
example_payload: Any = None
|
|
23
|
+
documentation_url: str | None = None
|
|
24
|
+
default_priority: int | None = None
|
|
25
|
+
is_enabled: bool = True
|
|
26
|
+
is_deprecated: bool = False
|
|
27
|
+
deprecated_at: str | None = None
|
|
28
|
+
deprecated_message: str | None = None
|
|
29
|
+
is_public: bool = False
|
|
30
|
+
published_at: str | None = None
|
|
31
|
+
subscription_count: int | None = None
|
|
32
|
+
"""Set on `list` responses only."""
|
|
33
|
+
created_at: str = ""
|
|
34
|
+
updated_at: str = ""
|
|
35
|
+
|
|
36
|
+
is_archived: bool = False
|
|
37
|
+
"""Deprecated: not returned by the API; always false. See `is_deprecated`."""
|
|
38
|
+
|
|
39
|
+
@model_validator(mode="before")
|
|
40
|
+
@classmethod
|
|
41
|
+
def _decode_json_strings(cls, data: Any) -> Any:
|
|
42
|
+
"""Decode `schema`/`examplePayload` if the API returned them as JSON text."""
|
|
43
|
+
if not isinstance(data, dict):
|
|
44
|
+
return data
|
|
45
|
+
out = dict(data)
|
|
46
|
+
for key in ("schema", "examplePayload"):
|
|
47
|
+
value = out.get(key)
|
|
48
|
+
if isinstance(value, str):
|
|
49
|
+
try:
|
|
50
|
+
out[key] = json.loads(value)
|
|
51
|
+
except ValueError:
|
|
52
|
+
pass
|
|
53
|
+
return out
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class CreateEventTypeParams(HookbaseModel):
|
|
57
|
+
name: str
|
|
58
|
+
display_name: str | None = None
|
|
59
|
+
description: str | None = None
|
|
60
|
+
category: str | None = None
|
|
61
|
+
schema_: dict[str, Any] | str | None = Field(default=None, alias="schema")
|
|
62
|
+
"""A JSON Schema, as a dict or as JSON text."""
|
|
63
|
+
example_payload: Any = None
|
|
64
|
+
"""An example payload, as a value or as JSON text."""
|
|
65
|
+
documentation_url: str | None = None
|
|
66
|
+
is_enabled: bool | None = None
|
|
67
|
+
default_priority: int | None = None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class UpdateEventTypeParams(HookbaseModel):
|
|
71
|
+
display_name: str | None = None
|
|
72
|
+
description: str | None = None
|
|
73
|
+
category: str | None = None
|
|
74
|
+
schema_: dict[str, Any] | str | None = Field(default=None, alias="schema")
|
|
75
|
+
"""A JSON Schema, as a dict or as JSON text."""
|
|
76
|
+
example_payload: Any = None
|
|
77
|
+
"""An example payload, as a value or as JSON text."""
|
|
78
|
+
documentation_url: str | None = None
|
|
79
|
+
is_enabled: bool | None = None
|
|
80
|
+
is_deprecated: bool | None = None
|
|
81
|
+
deprecated_message: str | None = None
|
|
82
|
+
is_public: bool | None = None
|
|
83
|
+
default_priority: int | None = None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def event_type_body(
|
|
87
|
+
params: CreateEventTypeParams | UpdateEventTypeParams | dict[str, Any],
|
|
88
|
+
) -> dict[str, Any]:
|
|
89
|
+
"""Build the body for `POST /api/event-types` or `PATCH /api/event-types/:id`.
|
|
90
|
+
|
|
91
|
+
The API validates `schema` and `examplePayload` as JSON *strings*
|
|
92
|
+
(`z.string()` in event-types.ts) and its schemas are `.strict()`, so a dict
|
|
93
|
+
there is a 400. Serialize anything that is not already a string.
|
|
94
|
+
"""
|
|
95
|
+
body = _wire.dump(params)
|
|
96
|
+
_wire.canonicalize(body, "examplePayload")
|
|
97
|
+
if "schema_" in body:
|
|
98
|
+
body.setdefault("schema", body.pop("schema_"))
|
|
99
|
+
for key in ("schema", "examplePayload"):
|
|
100
|
+
value = body.get(key)
|
|
101
|
+
if value is not None and not isinstance(value, str):
|
|
102
|
+
body[key] = json.dumps(value)
|
|
103
|
+
return body
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Literal, Union
|
|
4
|
+
|
|
5
|
+
from pydantic import model_validator
|
|
6
|
+
|
|
7
|
+
from ._base import HookbaseModel
|
|
8
|
+
|
|
9
|
+
# Every status `outbound_messages.status` takes. The field accepts any string so a status the
|
|
10
|
+
# API adds later does not make a whole list page fail to decode, which a closed Literal did for
|
|
11
|
+
# `processing`, `awaiting_retry` and `dlq`.
|
|
12
|
+
MessageStatus = Union[
|
|
13
|
+
Literal[
|
|
14
|
+
"pending", "processing", "success", "failed", "awaiting_retry", "exhausted", "dlq",
|
|
15
|
+
],
|
|
16
|
+
str,
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _mirror(data: Any, pairs: dict[str, str]) -> Any:
|
|
21
|
+
"""Fill each deprecated field (key) from the API field it was a misspelling of (value)."""
|
|
22
|
+
if not isinstance(data, dict):
|
|
23
|
+
return data
|
|
24
|
+
out = dict(data)
|
|
25
|
+
for old, new in pairs.items():
|
|
26
|
+
if out.get(new) is not None and out.get(old) is None:
|
|
27
|
+
out[old] = out[new]
|
|
28
|
+
return out
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class SendEventParams(HookbaseModel):
|
|
32
|
+
event_type: str
|
|
33
|
+
payload: Any
|
|
34
|
+
event_id: str | None = None
|
|
35
|
+
metadata: dict[str, Any] | None = None
|
|
36
|
+
endpoint_ids: list[str] | None = None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class SendEventEndpoint(HookbaseModel):
|
|
40
|
+
id: str
|
|
41
|
+
url: str = ""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class SendEventResponse(HookbaseModel):
|
|
45
|
+
event_id: str = ""
|
|
46
|
+
messages_queued: int = 0
|
|
47
|
+
endpoints: list[SendEventEndpoint] = []
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class OutboundMessage(HookbaseModel):
|
|
51
|
+
"""An outbound message as `GET /api/outbound-messages[/:id]` returns it.
|
|
52
|
+
|
|
53
|
+
`list` returns the summary fields; `get` adds the subscription, payload and
|
|
54
|
+
error-type fields, which are `None` on list items.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
id: str
|
|
58
|
+
event_id: str = ""
|
|
59
|
+
event_type: str = ""
|
|
60
|
+
application_id: str = ""
|
|
61
|
+
application_name: str | None = None
|
|
62
|
+
endpoint_id: str = ""
|
|
63
|
+
endpoint_url: str | None = ""
|
|
64
|
+
status: MessageStatus = "pending"
|
|
65
|
+
attempts: int = 0
|
|
66
|
+
max_attempts: int = 5
|
|
67
|
+
last_response_status: int | None = None
|
|
68
|
+
last_error_message: str | None = None
|
|
69
|
+
created_at: str = ""
|
|
70
|
+
completed_at: str | None = None
|
|
71
|
+
|
|
72
|
+
# `get` only.
|
|
73
|
+
subscription_id: str | None = None
|
|
74
|
+
event_type_id: str | None = None
|
|
75
|
+
last_error_type: str | None = None
|
|
76
|
+
payload_key: str | None = None
|
|
77
|
+
payload_size_bytes: int | None = None
|
|
78
|
+
payload_hash: str | None = None
|
|
79
|
+
metadata: Any = None
|
|
80
|
+
priority: int | None = None
|
|
81
|
+
endpoint_description: str | None = None
|
|
82
|
+
payload: Any = None
|
|
83
|
+
|
|
84
|
+
# Deprecated: names the API never sent. The three with an API equivalent mirror it.
|
|
85
|
+
message_id: str = ""
|
|
86
|
+
"""Deprecated: read `event_id`."""
|
|
87
|
+
last_error: str | None = None
|
|
88
|
+
"""Deprecated: read `last_error_message`."""
|
|
89
|
+
delivered_at: str | None = None
|
|
90
|
+
"""Deprecated: read `completed_at`."""
|
|
91
|
+
last_attempt_at: str | None = None
|
|
92
|
+
"""Deprecated: not returned by the API; always `None`."""
|
|
93
|
+
next_attempt_at: str | None = None
|
|
94
|
+
"""Deprecated: not returned by the API; always `None`."""
|
|
95
|
+
last_response_body: str | None = None
|
|
96
|
+
"""Deprecated: not returned by the API; read the attempts instead."""
|
|
97
|
+
updated_at: str = ""
|
|
98
|
+
"""Deprecated: not returned by the API; always empty."""
|
|
99
|
+
|
|
100
|
+
@model_validator(mode="before")
|
|
101
|
+
@classmethod
|
|
102
|
+
def _fill_deprecated(cls, data: Any) -> Any:
|
|
103
|
+
return _mirror(data, {
|
|
104
|
+
"messageId": "eventId",
|
|
105
|
+
"lastError": "lastErrorMessage",
|
|
106
|
+
"deliveredAt": "completedAt",
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class OutboundAttempt(HookbaseModel):
|
|
111
|
+
"""One delivery attempt, as `GET /api/outbound-messages/:id/attempts` returns it."""
|
|
112
|
+
|
|
113
|
+
id: str
|
|
114
|
+
message_id: str = ""
|
|
115
|
+
attempt_number: int = 0
|
|
116
|
+
status: str = ""
|
|
117
|
+
response_status: int | None = None
|
|
118
|
+
response_headers: dict[str, Any] | None = None
|
|
119
|
+
response_body: str | None = None
|
|
120
|
+
response_time_ms: int | None = None
|
|
121
|
+
total_time_ms: int | None = None
|
|
122
|
+
error_type: str | None = None
|
|
123
|
+
error_message: str | None = None
|
|
124
|
+
error_code: str | None = None
|
|
125
|
+
request_url: str | None = None
|
|
126
|
+
request_headers: dict[str, Any] | None = None
|
|
127
|
+
request_body_size: int | None = None
|
|
128
|
+
triggered_by: str | None = None
|
|
129
|
+
triggered_by_user: str | None = None
|
|
130
|
+
created_at: str = ""
|
|
131
|
+
completed_at: str | None = None
|
|
132
|
+
|
|
133
|
+
# Deprecated: names the API never sent, each mirroring its API equivalent.
|
|
134
|
+
outbound_message_id: str = ""
|
|
135
|
+
"""Deprecated: read `message_id`."""
|
|
136
|
+
error: str | None = None
|
|
137
|
+
"""Deprecated: read `error_message`."""
|
|
138
|
+
latency_ms: float | None = None
|
|
139
|
+
"""Deprecated: read `response_time_ms`."""
|
|
140
|
+
attempted_at: str = ""
|
|
141
|
+
"""Deprecated: read `created_at`."""
|
|
142
|
+
|
|
143
|
+
@model_validator(mode="before")
|
|
144
|
+
@classmethod
|
|
145
|
+
def _fill_deprecated(cls, data: Any) -> Any:
|
|
146
|
+
return _mirror(data, {
|
|
147
|
+
"outboundMessageId": "messageId",
|
|
148
|
+
"error": "errorMessage",
|
|
149
|
+
"latencyMs": "responseTimeMs",
|
|
150
|
+
"attemptedAt": "createdAt",
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class StatsSummary(HookbaseModel):
|
|
155
|
+
pending: int = 0
|
|
156
|
+
processing: int = 0
|
|
157
|
+
success: int = 0
|
|
158
|
+
failed: int = 0
|
|
159
|
+
awaiting_retry: int = 0
|
|
160
|
+
exhausted: int = 0
|
|
161
|
+
dlq: int = 0
|
|
162
|
+
total: int = 0
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class ReplayResult(HookbaseModel):
|
|
166
|
+
original_message_id: str = ""
|
|
167
|
+
new_message_id: str = ""
|
|
168
|
+
status: str = ""
|