hookbridge 1.2.0__tar.gz → 1.4.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.
- hookbridge-1.4.0/.claude/settings.local.json +8 -0
- {hookbridge-1.2.0 → hookbridge-1.4.0}/PKG-INFO +68 -1
- {hookbridge-1.2.0 → hookbridge-1.4.0}/PUBLISHING.md +27 -4
- {hookbridge-1.2.0 → hookbridge-1.4.0}/README.md +67 -0
- {hookbridge-1.2.0 → hookbridge-1.4.0}/hookbridge/__init__.py +2 -6
- {hookbridge-1.2.0 → hookbridge-1.4.0}/hookbridge/client.py +74 -142
- {hookbridge-1.2.0 → hookbridge-1.4.0}/hookbridge/types.py +3 -23
- {hookbridge-1.2.0 → hookbridge-1.4.0}/pyproject.toml +1 -1
- {hookbridge-1.2.0 → hookbridge-1.4.0}/tests/conftest.py +2 -0
- {hookbridge-1.2.0 → hookbridge-1.4.0}/tests/test_async_client.py +68 -0
- {hookbridge-1.2.0 → hookbridge-1.4.0}/tests/test_client.py +27 -0
- {hookbridge-1.2.0 → hookbridge-1.4.0}/tests/test_client_spec_parity.py +83 -52
- hookbridge-1.2.0/.claude/settings.local.json +0 -7
- {hookbridge-1.2.0 → hookbridge-1.4.0}/.gitignore +0 -0
- {hookbridge-1.2.0 → hookbridge-1.4.0}/hookbridge/errors.py +0 -0
- {hookbridge-1.2.0 → hookbridge-1.4.0}/hookbridge/py.typed +0 -0
- {hookbridge-1.2.0 → hookbridge-1.4.0}/requirements-dev.txt +0 -0
- {hookbridge-1.2.0 → hookbridge-1.4.0}/requirements.txt +0 -0
- {hookbridge-1.2.0 → hookbridge-1.4.0}/tests/__init__.py +0 -0
- {hookbridge-1.2.0 → hookbridge-1.4.0}/tests/test_errors.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: hookbridge
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.4.0
|
|
4
4
|
Summary: Official HookBridge SDK for Python
|
|
5
5
|
Project-URL: Homepage, https://hookbridge.io
|
|
6
6
|
Project-URL: Documentation, https://hookbridge.io/docs/sdks/python
|
|
@@ -188,6 +188,73 @@ print("Save this key:", new_key.key) # Only shown once!
|
|
|
188
188
|
client.delete_api_key("key_abc123")
|
|
189
189
|
```
|
|
190
190
|
|
|
191
|
+
### Inbound Webhooks
|
|
192
|
+
|
|
193
|
+
```python
|
|
194
|
+
# Create an inbound endpoint
|
|
195
|
+
inbound = client.create_inbound_endpoint(
|
|
196
|
+
url="https://myapp.com/webhooks/inbound",
|
|
197
|
+
name="Stripe inbound",
|
|
198
|
+
description="Receives Stripe events through HookBridge",
|
|
199
|
+
verify_static_token=True,
|
|
200
|
+
token_header_name="X-Webhook-Token",
|
|
201
|
+
token_value="my-shared-secret",
|
|
202
|
+
signing_enabled=True,
|
|
203
|
+
idempotency_header_names=["X-Idempotency-Key"],
|
|
204
|
+
ingest_response_code=202,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
print("Inbound endpoint ID:", inbound.id)
|
|
208
|
+
print("Send webhooks here:", inbound.ingest_url) # Save this
|
|
209
|
+
print("Secret token:", inbound.secret_token) # Only shown once
|
|
210
|
+
|
|
211
|
+
# Inspect and manage the inbound endpoint
|
|
212
|
+
details = client.get_inbound_endpoint(inbound.id)
|
|
213
|
+
client.pause_inbound_endpoint(inbound.id)
|
|
214
|
+
client.resume_inbound_endpoint(inbound.id)
|
|
215
|
+
|
|
216
|
+
# Update verification settings later if needed
|
|
217
|
+
client.update_inbound_endpoint(
|
|
218
|
+
inbound.id,
|
|
219
|
+
verify_hmac=True,
|
|
220
|
+
hmac_header_name="X-Signature",
|
|
221
|
+
hmac_secret="whsec_inbound_secret",
|
|
222
|
+
)
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
### Inbound Observability
|
|
226
|
+
|
|
227
|
+
```python
|
|
228
|
+
# List inbound endpoints
|
|
229
|
+
inbound_endpoints = client.list_inbound_endpoints(limit=50)
|
|
230
|
+
|
|
231
|
+
# Query inbound delivery logs
|
|
232
|
+
logs = client.get_inbound_logs(
|
|
233
|
+
inbound_endpoint_id="01935abc-def0-7123-4567-890abcdef012",
|
|
234
|
+
limit=50,
|
|
235
|
+
)
|
|
236
|
+
for entry in logs.entries:
|
|
237
|
+
print(entry.message_id, entry.status, entry.total_delivery_ms)
|
|
238
|
+
|
|
239
|
+
# Get inbound metrics and time series
|
|
240
|
+
metrics = client.get_inbound_metrics(
|
|
241
|
+
"24h",
|
|
242
|
+
inbound_endpoint_id="01935abc-def0-7123-4567-890abcdef012",
|
|
243
|
+
)
|
|
244
|
+
timeseries = client.get_inbound_timeseries_metrics(
|
|
245
|
+
"24h",
|
|
246
|
+
inbound_endpoint_id="01935abc-def0-7123-4567-890abcdef012",
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
# Review rejected inbound requests
|
|
250
|
+
rejections = client.list_inbound_rejections(
|
|
251
|
+
inbound_endpoint_id="01935abc-def0-7123-4567-890abcdef012",
|
|
252
|
+
limit=25,
|
|
253
|
+
)
|
|
254
|
+
for rejection in rejections.entries:
|
|
255
|
+
print(rejection.reason_code, rejection.reason_detail)
|
|
256
|
+
```
|
|
257
|
+
|
|
191
258
|
## Async Usage
|
|
192
259
|
|
|
193
260
|
```python
|
|
@@ -11,20 +11,31 @@
|
|
|
11
11
|
- Create account at https://pypi.org/account/register/
|
|
12
12
|
- Generate API token at https://pypi.org/manage/account/token/
|
|
13
13
|
|
|
14
|
-
3.
|
|
14
|
+
3. Recommended authentication: configure the API token in `~/.pypirc`:
|
|
15
15
|
```ini
|
|
16
16
|
[pypi]
|
|
17
17
|
username = __token__
|
|
18
18
|
password = pypi-xxxxxxxxxxxxx
|
|
19
19
|
```
|
|
20
|
+
4. Work from a clean local checkout. You do not need to create a GitHub release before uploading to PyPI, but you should avoid publishing uncommitted local changes by accident.
|
|
20
21
|
|
|
21
22
|
## Publishing Steps
|
|
22
23
|
|
|
23
24
|
### 1. Update Version
|
|
24
25
|
|
|
25
|
-
Update version in both files:
|
|
26
|
-
- `hookbridge/__init__.py`
|
|
27
|
-
- `pyproject.toml`
|
|
26
|
+
Update the version in both files to the exact same value:
|
|
27
|
+
- `hookbridge/__init__.py`
|
|
28
|
+
- `pyproject.toml`
|
|
29
|
+
|
|
30
|
+
Example change:
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
__version__ = "1.3.1"
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
```toml
|
|
37
|
+
version = "1.3.1"
|
|
38
|
+
```
|
|
28
39
|
|
|
29
40
|
### 2. Run Tests
|
|
30
41
|
|
|
@@ -67,6 +78,18 @@ pip install --index-url https://test.pypi.org/simple/ hookbridge
|
|
|
67
78
|
twine upload dist/*
|
|
68
79
|
```
|
|
69
80
|
|
|
81
|
+
If you use multiple PyPI accounts on the same machine, verify the active credentials in `~/.pypirc` before uploading.
|
|
82
|
+
|
|
83
|
+
Typical release order:
|
|
84
|
+
|
|
85
|
+
1. bump the version in `hookbridge/__init__.py` and `pyproject.toml`
|
|
86
|
+
2. run tests and checks
|
|
87
|
+
3. build the distributions
|
|
88
|
+
4. optionally upload to TestPyPI first
|
|
89
|
+
5. run `twine upload dist/*`
|
|
90
|
+
6. verify install from PyPI
|
|
91
|
+
7. commit/tag/push if you have not already
|
|
92
|
+
|
|
70
93
|
### 6. Verify
|
|
71
94
|
|
|
72
95
|
```bash
|
|
@@ -154,6 +154,73 @@ print("Save this key:", new_key.key) # Only shown once!
|
|
|
154
154
|
client.delete_api_key("key_abc123")
|
|
155
155
|
```
|
|
156
156
|
|
|
157
|
+
### Inbound Webhooks
|
|
158
|
+
|
|
159
|
+
```python
|
|
160
|
+
# Create an inbound endpoint
|
|
161
|
+
inbound = client.create_inbound_endpoint(
|
|
162
|
+
url="https://myapp.com/webhooks/inbound",
|
|
163
|
+
name="Stripe inbound",
|
|
164
|
+
description="Receives Stripe events through HookBridge",
|
|
165
|
+
verify_static_token=True,
|
|
166
|
+
token_header_name="X-Webhook-Token",
|
|
167
|
+
token_value="my-shared-secret",
|
|
168
|
+
signing_enabled=True,
|
|
169
|
+
idempotency_header_names=["X-Idempotency-Key"],
|
|
170
|
+
ingest_response_code=202,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
print("Inbound endpoint ID:", inbound.id)
|
|
174
|
+
print("Send webhooks here:", inbound.ingest_url) # Save this
|
|
175
|
+
print("Secret token:", inbound.secret_token) # Only shown once
|
|
176
|
+
|
|
177
|
+
# Inspect and manage the inbound endpoint
|
|
178
|
+
details = client.get_inbound_endpoint(inbound.id)
|
|
179
|
+
client.pause_inbound_endpoint(inbound.id)
|
|
180
|
+
client.resume_inbound_endpoint(inbound.id)
|
|
181
|
+
|
|
182
|
+
# Update verification settings later if needed
|
|
183
|
+
client.update_inbound_endpoint(
|
|
184
|
+
inbound.id,
|
|
185
|
+
verify_hmac=True,
|
|
186
|
+
hmac_header_name="X-Signature",
|
|
187
|
+
hmac_secret="whsec_inbound_secret",
|
|
188
|
+
)
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
### Inbound Observability
|
|
192
|
+
|
|
193
|
+
```python
|
|
194
|
+
# List inbound endpoints
|
|
195
|
+
inbound_endpoints = client.list_inbound_endpoints(limit=50)
|
|
196
|
+
|
|
197
|
+
# Query inbound delivery logs
|
|
198
|
+
logs = client.get_inbound_logs(
|
|
199
|
+
inbound_endpoint_id="01935abc-def0-7123-4567-890abcdef012",
|
|
200
|
+
limit=50,
|
|
201
|
+
)
|
|
202
|
+
for entry in logs.entries:
|
|
203
|
+
print(entry.message_id, entry.status, entry.total_delivery_ms)
|
|
204
|
+
|
|
205
|
+
# Get inbound metrics and time series
|
|
206
|
+
metrics = client.get_inbound_metrics(
|
|
207
|
+
"24h",
|
|
208
|
+
inbound_endpoint_id="01935abc-def0-7123-4567-890abcdef012",
|
|
209
|
+
)
|
|
210
|
+
timeseries = client.get_inbound_timeseries_metrics(
|
|
211
|
+
"24h",
|
|
212
|
+
inbound_endpoint_id="01935abc-def0-7123-4567-890abcdef012",
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
# Review rejected inbound requests
|
|
216
|
+
rejections = client.list_inbound_rejections(
|
|
217
|
+
inbound_endpoint_id="01935abc-def0-7123-4567-890abcdef012",
|
|
218
|
+
limit=25,
|
|
219
|
+
)
|
|
220
|
+
for rejection in rejections.entries:
|
|
221
|
+
print(rejection.reason_code, rejection.reason_detail)
|
|
222
|
+
```
|
|
223
|
+
|
|
157
224
|
## Async Usage
|
|
158
225
|
|
|
159
226
|
```python
|
|
@@ -44,16 +44,14 @@ from hookbridge.types import (
|
|
|
44
44
|
MessageSummary,
|
|
45
45
|
Metrics,
|
|
46
46
|
MetricsWindow,
|
|
47
|
-
NotificationPreferences,
|
|
48
47
|
PauseState,
|
|
49
|
-
Personalization,
|
|
50
48
|
PortalSession,
|
|
51
49
|
Project,
|
|
50
|
+
ReplayableMessageStatus,
|
|
52
51
|
ReplayAllMessagesResponse,
|
|
53
52
|
ReplayBatchMessagesResponse,
|
|
54
53
|
ReplayBatchResult,
|
|
55
54
|
ReplayMessageResponse,
|
|
56
|
-
ReplayableMessageStatus,
|
|
57
55
|
RotateSecretResponse,
|
|
58
56
|
SendWebhookResponse,
|
|
59
57
|
SigningKey,
|
|
@@ -67,7 +65,7 @@ from hookbridge.types import (
|
|
|
67
65
|
UsageHistoryRow,
|
|
68
66
|
)
|
|
69
67
|
|
|
70
|
-
__version__ = "1.
|
|
68
|
+
__version__ = "1.4.0"
|
|
71
69
|
|
|
72
70
|
__all__ = [
|
|
73
71
|
"HookBridge",
|
|
@@ -101,9 +99,7 @@ __all__ = [
|
|
|
101
99
|
"MessageSummary",
|
|
102
100
|
"Metrics",
|
|
103
101
|
"MetricsWindow",
|
|
104
|
-
"NotificationPreferences",
|
|
105
102
|
"PauseState",
|
|
106
|
-
"Personalization",
|
|
107
103
|
"PortalSession",
|
|
108
104
|
"Project",
|
|
109
105
|
"ReplayAllMessagesResponse",
|
|
@@ -51,16 +51,14 @@ from hookbridge.types import (
|
|
|
51
51
|
MessageSummary,
|
|
52
52
|
Metrics,
|
|
53
53
|
MetricsWindow,
|
|
54
|
-
NotificationPreferences,
|
|
55
54
|
PauseState,
|
|
56
|
-
Personalization,
|
|
57
55
|
PortalSession,
|
|
58
56
|
Project,
|
|
57
|
+
ReplayableMessageStatus,
|
|
59
58
|
ReplayAllMessagesResponse,
|
|
60
59
|
ReplayBatchMessagesResponse,
|
|
61
60
|
ReplayBatchResult,
|
|
62
61
|
ReplayMessageResponse,
|
|
63
|
-
ReplayableMessageStatus,
|
|
64
62
|
RotateSecretResponse,
|
|
65
63
|
SendWebhookResponse,
|
|
66
64
|
SigningKey,
|
|
@@ -95,9 +93,7 @@ def _query_string(params: dict[str, Any]) -> str:
|
|
|
95
93
|
for key, value in params.items():
|
|
96
94
|
if value is None:
|
|
97
95
|
continue
|
|
98
|
-
if isinstance(value, datetime):
|
|
99
|
-
encoded[key] = value.isoformat()
|
|
100
|
-
elif isinstance(value, date):
|
|
96
|
+
if isinstance(value, (datetime, date)):
|
|
101
97
|
encoded[key] = value.isoformat()
|
|
102
98
|
else:
|
|
103
99
|
encoded[key] = str(value)
|
|
@@ -111,6 +107,14 @@ def _with_query(path: str, params: dict[str, Any]) -> str:
|
|
|
111
107
|
return f"{path}?{query}"
|
|
112
108
|
|
|
113
109
|
|
|
110
|
+
def _json_value(value: Any) -> Any:
|
|
111
|
+
if isinstance(value, datetime):
|
|
112
|
+
return value.isoformat()
|
|
113
|
+
if isinstance(value, date):
|
|
114
|
+
return value.isoformat()
|
|
115
|
+
return value
|
|
116
|
+
|
|
117
|
+
|
|
114
118
|
def _parse_message(data: dict[str, Any]) -> Message:
|
|
115
119
|
return Message(
|
|
116
120
|
id=data["id"],
|
|
@@ -224,6 +228,7 @@ def _parse_endpoint(data: dict[str, Any]) -> Endpoint:
|
|
|
224
228
|
url=data["url"],
|
|
225
229
|
created_at=_parse_datetime(data["created_at"]),
|
|
226
230
|
updated_at=_parse_datetime(data["updated_at"]),
|
|
231
|
+
paused=data["paused"],
|
|
227
232
|
description=data.get("description"),
|
|
228
233
|
rate_limit_rps=data.get("rate_limit_rps"),
|
|
229
234
|
burst=data.get("burst"),
|
|
@@ -236,6 +241,7 @@ def _parse_endpoint_summary(data: dict[str, Any]) -> EndpointSummary:
|
|
|
236
241
|
id=data["id"],
|
|
237
242
|
url=data["url"],
|
|
238
243
|
created_at=_parse_datetime(data["created_at"]),
|
|
244
|
+
paused=data["paused"],
|
|
239
245
|
description=data.get("description"),
|
|
240
246
|
)
|
|
241
247
|
|
|
@@ -294,31 +300,6 @@ def _parse_rotate_secret(data: dict[str, Any]) -> RotateSecretResponse:
|
|
|
294
300
|
)
|
|
295
301
|
|
|
296
302
|
|
|
297
|
-
def _parse_personalization(data: dict[str, Any]) -> Personalization:
|
|
298
|
-
return Personalization(
|
|
299
|
-
id=data["id"],
|
|
300
|
-
user_id=data["user_id"],
|
|
301
|
-
timezone=data["timezone"],
|
|
302
|
-
date_format=data["date_format"],
|
|
303
|
-
locale=data["locale"],
|
|
304
|
-
created_at=_parse_datetime(data["created_at"]),
|
|
305
|
-
updated_at=_parse_datetime(data["updated_at"]),
|
|
306
|
-
)
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
def _parse_notification_preferences(data: dict[str, Any]) -> NotificationPreferences:
|
|
310
|
-
return NotificationPreferences(
|
|
311
|
-
id=data["id"],
|
|
312
|
-
user_id=data["user_id"],
|
|
313
|
-
enabled_alert_types=data["enabled_alert_types"],
|
|
314
|
-
delivery_channels=data["delivery_channels"],
|
|
315
|
-
failure_rate_threshold=data["failure_rate_threshold"],
|
|
316
|
-
pending_backlog_threshold=data["pending_backlog_threshold"],
|
|
317
|
-
created_at=_parse_datetime(data["created_at"]),
|
|
318
|
-
updated_at=_parse_datetime(data["updated_at"]),
|
|
319
|
-
)
|
|
320
|
-
|
|
321
|
-
|
|
322
303
|
def _parse_usage_history_row(data: dict[str, Any]) -> UsageHistoryRow:
|
|
323
304
|
return UsageHistoryRow(
|
|
324
305
|
period_start=_parse_date(data["period_start"]),
|
|
@@ -558,7 +539,9 @@ class HookBridge:
|
|
|
558
539
|
return _parse_replay_all_messages(self._request("POST", path)["data"])
|
|
559
540
|
|
|
560
541
|
def replay_batch_messages(self, message_ids: list[str]) -> ReplayBatchMessagesResponse:
|
|
561
|
-
response = self._request(
|
|
542
|
+
response = self._request(
|
|
543
|
+
"POST", "/v1/messages/replay-batch", json={"message_ids": message_ids}
|
|
544
|
+
)
|
|
562
545
|
return _parse_replay_batch_messages(response["data"])
|
|
563
546
|
|
|
564
547
|
def get_logs(
|
|
@@ -605,7 +588,8 @@ class HookBridge:
|
|
|
605
588
|
def get_dlq_messages(
|
|
606
589
|
self, *, limit: Optional[int] = None, cursor: Optional[str] = None
|
|
607
590
|
) -> DLQMessagesResponse:
|
|
608
|
-
|
|
591
|
+
path = _with_query("/v1/dlq/messages", {"limit": limit, "cursor": cursor})
|
|
592
|
+
response = self._request("GET", path)
|
|
609
593
|
data = response["data"]
|
|
610
594
|
return DLQMessagesResponse(
|
|
611
595
|
messages=[_parse_message_summary(item) for item in data.get("messages", [])],
|
|
@@ -650,7 +634,11 @@ class HookBridge:
|
|
|
650
634
|
return _parse_project(self._request("GET", f"/v1/projects/{project_id}")["data"])
|
|
651
635
|
|
|
652
636
|
def update_project(
|
|
653
|
-
self,
|
|
637
|
+
self,
|
|
638
|
+
project_id: str,
|
|
639
|
+
*,
|
|
640
|
+
name: Optional[str] = None,
|
|
641
|
+
rate_limit_default: Optional[int] = None,
|
|
654
642
|
) -> Project:
|
|
655
643
|
body: dict[str, Any] = {}
|
|
656
644
|
if name is not None:
|
|
@@ -697,7 +685,8 @@ class HookBridge:
|
|
|
697
685
|
def list_endpoints(
|
|
698
686
|
self, *, limit: Optional[int] = None, cursor: Optional[str] = None
|
|
699
687
|
) -> ListEndpointsResponse:
|
|
700
|
-
|
|
688
|
+
path = _with_query("/v1/endpoints", {"limit": limit, "cursor": cursor})
|
|
689
|
+
response = self._request("GET", path)
|
|
701
690
|
meta = response.get("meta", {})
|
|
702
691
|
next_cursor = meta.get("next_cursor")
|
|
703
692
|
return ListEndpointsResponse(
|
|
@@ -732,6 +721,18 @@ class HookBridge:
|
|
|
732
721
|
def delete_endpoint(self, endpoint_id: str) -> None:
|
|
733
722
|
self._request("DELETE", f"/v1/endpoints/{endpoint_id}")
|
|
734
723
|
|
|
724
|
+
def pause_endpoint(self, endpoint_id: str) -> PauseState:
|
|
725
|
+
data = self._request("POST", f"/v1/endpoints/{endpoint_id}/pause")["data"]
|
|
726
|
+
return PauseState(id=data["id"], paused=data["paused"])
|
|
727
|
+
|
|
728
|
+
def resume_endpoint(self, endpoint_id: str) -> PauseState:
|
|
729
|
+
data = self._request("POST", f"/v1/endpoints/{endpoint_id}/resume")["data"]
|
|
730
|
+
return PauseState(
|
|
731
|
+
id=data["id"],
|
|
732
|
+
paused=data["paused"],
|
|
733
|
+
messages_requeued=data.get("messages_requeued"),
|
|
734
|
+
)
|
|
735
|
+
|
|
735
736
|
def create_endpoint_signing_key(self, endpoint_id: str) -> RotateSecretResponse:
|
|
736
737
|
data = self._request("POST", f"/v1/endpoints/{endpoint_id}/signing-keys")["data"]
|
|
737
738
|
return _parse_rotate_secret(data)
|
|
@@ -746,55 +747,6 @@ class HookBridge:
|
|
|
746
747
|
def rotate_endpoint_secret(self, endpoint_id: str) -> RotateSecretResponse:
|
|
747
748
|
return self.create_endpoint_signing_key(endpoint_id)
|
|
748
749
|
|
|
749
|
-
def get_personalization(self) -> Personalization:
|
|
750
|
-
return _parse_personalization(
|
|
751
|
-
self._request("GET", "/v1/settings/personalization")["data"]
|
|
752
|
-
)
|
|
753
|
-
|
|
754
|
-
def update_personalization(
|
|
755
|
-
self,
|
|
756
|
-
*,
|
|
757
|
-
timezone: Optional[str] = None,
|
|
758
|
-
date_format: Optional[str] = None,
|
|
759
|
-
locale: Optional[str] = None,
|
|
760
|
-
) -> Personalization:
|
|
761
|
-
body: dict[str, Any] = {}
|
|
762
|
-
if timezone is not None:
|
|
763
|
-
body["timezone"] = timezone
|
|
764
|
-
if date_format is not None:
|
|
765
|
-
body["date_format"] = date_format
|
|
766
|
-
if locale is not None:
|
|
767
|
-
body["locale"] = locale
|
|
768
|
-
return _parse_personalization(
|
|
769
|
-
self._request("PUT", "/v1/settings/personalization", json=body)["data"]
|
|
770
|
-
)
|
|
771
|
-
|
|
772
|
-
def get_notification_preferences(self) -> NotificationPreferences:
|
|
773
|
-
return _parse_notification_preferences(
|
|
774
|
-
self._request("GET", "/v1/settings/notifications")["data"]
|
|
775
|
-
)
|
|
776
|
-
|
|
777
|
-
def update_notification_preferences(
|
|
778
|
-
self,
|
|
779
|
-
*,
|
|
780
|
-
enabled_alert_types: Optional[list[str]] = None,
|
|
781
|
-
delivery_channels: Optional[list[str]] = None,
|
|
782
|
-
failure_rate_threshold: Optional[int] = None,
|
|
783
|
-
pending_backlog_threshold: Optional[int] = None,
|
|
784
|
-
) -> NotificationPreferences:
|
|
785
|
-
body: dict[str, Any] = {}
|
|
786
|
-
if enabled_alert_types is not None:
|
|
787
|
-
body["enabled_alert_types"] = enabled_alert_types
|
|
788
|
-
if delivery_channels is not None:
|
|
789
|
-
body["delivery_channels"] = delivery_channels
|
|
790
|
-
if failure_rate_threshold is not None:
|
|
791
|
-
body["failure_rate_threshold"] = failure_rate_threshold
|
|
792
|
-
if pending_backlog_threshold is not None:
|
|
793
|
-
body["pending_backlog_threshold"] = pending_backlog_threshold
|
|
794
|
-
return _parse_notification_preferences(
|
|
795
|
-
self._request("PUT", "/v1/settings/notifications", json=body)["data"]
|
|
796
|
-
)
|
|
797
|
-
|
|
798
750
|
def create_checkout(self, *, plan: str, interval: str) -> CheckoutSession:
|
|
799
751
|
data = self._request(
|
|
800
752
|
"POST", "/v1/billing/checkout", json={"plan": plan, "interval": interval}
|
|
@@ -1038,7 +990,10 @@ class HookBridge:
|
|
|
1038
990
|
status: Optional[MessageStatus] = None,
|
|
1039
991
|
endpoint_id: Optional[str] = None,
|
|
1040
992
|
) -> ExportRecord:
|
|
1041
|
-
body: dict[str, Any] = {
|
|
993
|
+
body: dict[str, Any] = {
|
|
994
|
+
"start_time": _json_value(start_time),
|
|
995
|
+
"end_time": _json_value(end_time),
|
|
996
|
+
}
|
|
1042
997
|
if status is not None:
|
|
1043
998
|
body["status"] = status
|
|
1044
999
|
if endpoint_id is not None:
|
|
@@ -1243,7 +1198,8 @@ class AsyncHookBridge:
|
|
|
1243
1198
|
await self._request("POST", f"/v1/dlq/replay/{message_id}")
|
|
1244
1199
|
|
|
1245
1200
|
async def list_api_keys(self) -> list[APIKeyInfo]:
|
|
1246
|
-
|
|
1201
|
+
response = await self._request("GET", "/v1/api-keys")
|
|
1202
|
+
return [_parse_api_key_info(item) for item in response["data"]]
|
|
1247
1203
|
|
|
1248
1204
|
async def create_api_key(
|
|
1249
1205
|
self, mode: APIKeyMode, *, label: Optional[str] = None
|
|
@@ -1264,9 +1220,12 @@ class AsyncHookBridge:
|
|
|
1264
1220
|
await self._request("DELETE", f"/v1/api-keys/{key_id}")
|
|
1265
1221
|
|
|
1266
1222
|
async def list_projects(self) -> list[Project]:
|
|
1267
|
-
|
|
1223
|
+
response = await self._request("GET", "/v1/projects")
|
|
1224
|
+
return [_parse_project(item) for item in response["data"]]
|
|
1268
1225
|
|
|
1269
|
-
async def create_project(
|
|
1226
|
+
async def create_project(
|
|
1227
|
+
self, name: str, *, rate_limit_default: Optional[int] = None
|
|
1228
|
+
) -> Project:
|
|
1270
1229
|
body: dict[str, Any] = {"name": name}
|
|
1271
1230
|
if rate_limit_default is not None:
|
|
1272
1231
|
body["rate_limit_default"] = rate_limit_default
|
|
@@ -1276,14 +1235,19 @@ class AsyncHookBridge:
|
|
|
1276
1235
|
return _parse_project((await self._request("GET", f"/v1/projects/{project_id}"))["data"])
|
|
1277
1236
|
|
|
1278
1237
|
async def update_project(
|
|
1279
|
-
self,
|
|
1238
|
+
self,
|
|
1239
|
+
project_id: str,
|
|
1240
|
+
*,
|
|
1241
|
+
name: Optional[str] = None,
|
|
1242
|
+
rate_limit_default: Optional[int] = None,
|
|
1280
1243
|
) -> Project:
|
|
1281
1244
|
body: dict[str, Any] = {}
|
|
1282
1245
|
if name is not None:
|
|
1283
1246
|
body["name"] = name
|
|
1284
1247
|
if rate_limit_default is not None:
|
|
1285
1248
|
body["rate_limit_default"] = rate_limit_default
|
|
1286
|
-
|
|
1249
|
+
response = await self._request("PUT", f"/v1/projects/{project_id}", json=body)
|
|
1250
|
+
return _parse_project(response["data"])
|
|
1287
1251
|
|
|
1288
1252
|
async def delete_project(self, project_id: str) -> None:
|
|
1289
1253
|
await self._request("DELETE", f"/v1/projects/{project_id}")
|
|
@@ -1360,6 +1324,18 @@ class AsyncHookBridge:
|
|
|
1360
1324
|
async def delete_endpoint(self, endpoint_id: str) -> None:
|
|
1361
1325
|
await self._request("DELETE", f"/v1/endpoints/{endpoint_id}")
|
|
1362
1326
|
|
|
1327
|
+
async def pause_endpoint(self, endpoint_id: str) -> PauseState:
|
|
1328
|
+
data = (await self._request("POST", f"/v1/endpoints/{endpoint_id}/pause"))["data"]
|
|
1329
|
+
return PauseState(id=data["id"], paused=data["paused"])
|
|
1330
|
+
|
|
1331
|
+
async def resume_endpoint(self, endpoint_id: str) -> PauseState:
|
|
1332
|
+
data = (await self._request("POST", f"/v1/endpoints/{endpoint_id}/resume"))["data"]
|
|
1333
|
+
return PauseState(
|
|
1334
|
+
id=data["id"],
|
|
1335
|
+
paused=data["paused"],
|
|
1336
|
+
messages_requeued=data.get("messages_requeued"),
|
|
1337
|
+
)
|
|
1338
|
+
|
|
1363
1339
|
async def create_endpoint_signing_key(self, endpoint_id: str) -> RotateSecretResponse:
|
|
1364
1340
|
data = (await self._request("POST", f"/v1/endpoints/{endpoint_id}/signing-keys"))["data"]
|
|
1365
1341
|
return _parse_rotate_secret(data)
|
|
@@ -1374,55 +1350,6 @@ class AsyncHookBridge:
|
|
|
1374
1350
|
async def rotate_endpoint_secret(self, endpoint_id: str) -> RotateSecretResponse:
|
|
1375
1351
|
return await self.create_endpoint_signing_key(endpoint_id)
|
|
1376
1352
|
|
|
1377
|
-
async def get_personalization(self) -> Personalization:
|
|
1378
|
-
return _parse_personalization(
|
|
1379
|
-
(await self._request("GET", "/v1/settings/personalization"))["data"]
|
|
1380
|
-
)
|
|
1381
|
-
|
|
1382
|
-
async def update_personalization(
|
|
1383
|
-
self,
|
|
1384
|
-
*,
|
|
1385
|
-
timezone: Optional[str] = None,
|
|
1386
|
-
date_format: Optional[str] = None,
|
|
1387
|
-
locale: Optional[str] = None,
|
|
1388
|
-
) -> Personalization:
|
|
1389
|
-
body: dict[str, Any] = {}
|
|
1390
|
-
if timezone is not None:
|
|
1391
|
-
body["timezone"] = timezone
|
|
1392
|
-
if date_format is not None:
|
|
1393
|
-
body["date_format"] = date_format
|
|
1394
|
-
if locale is not None:
|
|
1395
|
-
body["locale"] = locale
|
|
1396
|
-
return _parse_personalization(
|
|
1397
|
-
(await self._request("PUT", "/v1/settings/personalization", json=body))["data"]
|
|
1398
|
-
)
|
|
1399
|
-
|
|
1400
|
-
async def get_notification_preferences(self) -> NotificationPreferences:
|
|
1401
|
-
return _parse_notification_preferences(
|
|
1402
|
-
(await self._request("GET", "/v1/settings/notifications"))["data"]
|
|
1403
|
-
)
|
|
1404
|
-
|
|
1405
|
-
async def update_notification_preferences(
|
|
1406
|
-
self,
|
|
1407
|
-
*,
|
|
1408
|
-
enabled_alert_types: Optional[list[str]] = None,
|
|
1409
|
-
delivery_channels: Optional[list[str]] = None,
|
|
1410
|
-
failure_rate_threshold: Optional[int] = None,
|
|
1411
|
-
pending_backlog_threshold: Optional[int] = None,
|
|
1412
|
-
) -> NotificationPreferences:
|
|
1413
|
-
body: dict[str, Any] = {}
|
|
1414
|
-
if enabled_alert_types is not None:
|
|
1415
|
-
body["enabled_alert_types"] = enabled_alert_types
|
|
1416
|
-
if delivery_channels is not None:
|
|
1417
|
-
body["delivery_channels"] = delivery_channels
|
|
1418
|
-
if failure_rate_threshold is not None:
|
|
1419
|
-
body["failure_rate_threshold"] = failure_rate_threshold
|
|
1420
|
-
if pending_backlog_threshold is not None:
|
|
1421
|
-
body["pending_backlog_threshold"] = pending_backlog_threshold
|
|
1422
|
-
return _parse_notification_preferences(
|
|
1423
|
-
(await self._request("PUT", "/v1/settings/notifications", json=body))["data"]
|
|
1424
|
-
)
|
|
1425
|
-
|
|
1426
1353
|
async def create_checkout(self, *, plan: str, interval: str) -> CheckoutSession:
|
|
1427
1354
|
data = (
|
|
1428
1355
|
await self._request(
|
|
@@ -1674,7 +1601,10 @@ class AsyncHookBridge:
|
|
|
1674
1601
|
status: Optional[MessageStatus] = None,
|
|
1675
1602
|
endpoint_id: Optional[str] = None,
|
|
1676
1603
|
) -> ExportRecord:
|
|
1677
|
-
body: dict[str, Any] = {
|
|
1604
|
+
body: dict[str, Any] = {
|
|
1605
|
+
"start_time": _json_value(start_time),
|
|
1606
|
+
"end_time": _json_value(end_time),
|
|
1607
|
+
}
|
|
1678
1608
|
if status is not None:
|
|
1679
1609
|
body["status"] = status
|
|
1680
1610
|
if endpoint_id is not None:
|
|
@@ -1682,10 +1612,12 @@ class AsyncHookBridge:
|
|
|
1682
1612
|
return _parse_export_record((await self._request("POST", "/v1/exports", json=body))["data"])
|
|
1683
1613
|
|
|
1684
1614
|
async def list_exports(self) -> list[ExportRecord]:
|
|
1685
|
-
|
|
1615
|
+
response = await self._request("GET", "/v1/exports")
|
|
1616
|
+
return [_parse_export_record(item) for item in response["data"]]
|
|
1686
1617
|
|
|
1687
1618
|
async def get_export(self, export_id: str) -> ExportRecord:
|
|
1688
|
-
|
|
1619
|
+
response = await self._request("GET", f"/v1/exports/{export_id}")
|
|
1620
|
+
return _parse_export_record(response["data"])
|
|
1689
1621
|
|
|
1690
1622
|
async def download_export(self, export_id: str) -> str:
|
|
1691
1623
|
response = await self._request(
|
|
@@ -126,6 +126,7 @@ class Endpoint:
|
|
|
126
126
|
url: str
|
|
127
127
|
created_at: datetime
|
|
128
128
|
updated_at: datetime
|
|
129
|
+
paused: bool
|
|
129
130
|
description: Optional[str] = None
|
|
130
131
|
rate_limit_rps: Optional[int] = None
|
|
131
132
|
burst: Optional[int] = None
|
|
@@ -137,6 +138,7 @@ class EndpointSummary:
|
|
|
137
138
|
id: str
|
|
138
139
|
url: str
|
|
139
140
|
created_at: datetime
|
|
141
|
+
paused: bool
|
|
140
142
|
description: Optional[str] = None
|
|
141
143
|
|
|
142
144
|
|
|
@@ -243,29 +245,6 @@ class Project:
|
|
|
243
245
|
created_at: datetime
|
|
244
246
|
|
|
245
247
|
|
|
246
|
-
@dataclass(frozen=True)
|
|
247
|
-
class Personalization:
|
|
248
|
-
id: str
|
|
249
|
-
user_id: str
|
|
250
|
-
timezone: str
|
|
251
|
-
date_format: str
|
|
252
|
-
locale: str
|
|
253
|
-
created_at: datetime
|
|
254
|
-
updated_at: datetime
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
@dataclass(frozen=True)
|
|
258
|
-
class NotificationPreferences:
|
|
259
|
-
id: str
|
|
260
|
-
user_id: str
|
|
261
|
-
enabled_alert_types: list[str]
|
|
262
|
-
delivery_channels: list[str]
|
|
263
|
-
failure_rate_threshold: int
|
|
264
|
-
pending_backlog_threshold: int
|
|
265
|
-
created_at: datetime
|
|
266
|
-
updated_at: datetime
|
|
267
|
-
|
|
268
|
-
|
|
269
248
|
@dataclass(frozen=True)
|
|
270
249
|
class CheckoutSession:
|
|
271
250
|
session_id: str
|
|
@@ -384,6 +363,7 @@ class DeleteResult:
|
|
|
384
363
|
class PauseState:
|
|
385
364
|
id: str
|
|
386
365
|
paused: bool
|
|
366
|
+
messages_requeued: Optional[int] = None
|
|
387
367
|
|
|
388
368
|
|
|
389
369
|
@dataclass(frozen=True)
|
|
@@ -186,6 +186,7 @@ MOCK_RESPONSES = {
|
|
|
186
186
|
"url": "https://example.com/webhook",
|
|
187
187
|
"created_at": "2025-01-01T00:00:00Z",
|
|
188
188
|
"updated_at": "2025-01-01T00:00:00Z",
|
|
189
|
+
"paused": False,
|
|
189
190
|
"description": "Test endpoint",
|
|
190
191
|
"rate_limit_rps": 10,
|
|
191
192
|
"burst": 20,
|
|
@@ -198,6 +199,7 @@ MOCK_RESPONSES = {
|
|
|
198
199
|
"id": "ep_550e8400e29b41d4a716446655440000",
|
|
199
200
|
"url": "https://example.com/webhook",
|
|
200
201
|
"created_at": "2025-01-01T00:00:00Z",
|
|
202
|
+
"paused": False,
|
|
201
203
|
"description": "Test endpoint",
|
|
202
204
|
}
|
|
203
205
|
],
|
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
Tests for the asynchronous HookBridge client.
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
|
+
import json
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
|
|
5
8
|
import pytest
|
|
6
9
|
from pytest_httpx import HTTPXMock
|
|
7
10
|
|
|
@@ -303,8 +306,45 @@ class TestAsyncEndpoints:
|
|
|
303
306
|
result = await async_client.get_endpoint(endpoint_id)
|
|
304
307
|
|
|
305
308
|
assert result.id == endpoint_id
|
|
309
|
+
assert result.paused is False
|
|
306
310
|
assert result.rate_limit_rps == 10
|
|
307
311
|
|
|
312
|
+
|
|
313
|
+
class TestAsyncExports:
|
|
314
|
+
"""Tests for async export operations."""
|
|
315
|
+
|
|
316
|
+
@pytest.mark.skipif(LIVE_MODE, reason="Mock test only")
|
|
317
|
+
async def test_create_export_serializes_datetimes(
|
|
318
|
+
self, async_client: AsyncHookBridge, httpx_mock: HTTPXMock
|
|
319
|
+
) -> None:
|
|
320
|
+
httpx_mock.add_response(
|
|
321
|
+
method="POST",
|
|
322
|
+
url=f"{async_client._base_url}/v1/exports",
|
|
323
|
+
json={
|
|
324
|
+
"data": {
|
|
325
|
+
"id": "01935abc-def0-7123-4567-890abcdef077",
|
|
326
|
+
"project_id": "proj_abc123",
|
|
327
|
+
"status": "pending",
|
|
328
|
+
"filter_start_time": "2025-12-01T00:00:00+00:00",
|
|
329
|
+
"filter_end_time": "2025-12-06T23:59:59+00:00",
|
|
330
|
+
"created_at": "2025-12-06T12:00:00Z",
|
|
331
|
+
},
|
|
332
|
+
"meta": {"request_id": "req-12345"},
|
|
333
|
+
},
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
start = datetime(2025, 12, 1, 0, 0, 0, tzinfo=UTC)
|
|
337
|
+
end = datetime(2025, 12, 6, 23, 59, 59, tzinfo=UTC)
|
|
338
|
+
await async_client.create_export(start_time=start, end_time=end, endpoint_id="ep_123")
|
|
339
|
+
|
|
340
|
+
request = httpx_mock.get_request()
|
|
341
|
+
assert request is not None
|
|
342
|
+
assert json.loads(request.content) == {
|
|
343
|
+
"start_time": "2025-12-01T00:00:00+00:00",
|
|
344
|
+
"end_time": "2025-12-06T23:59:59+00:00",
|
|
345
|
+
"endpoint_id": "ep_123",
|
|
346
|
+
}
|
|
347
|
+
|
|
308
348
|
@pytest.mark.skipif(LIVE_MODE, reason="Mock test only")
|
|
309
349
|
async def test_list_endpoints(
|
|
310
350
|
self, async_client: AsyncHookBridge, httpx_mock: HTTPXMock
|
|
@@ -319,8 +359,36 @@ class TestAsyncEndpoints:
|
|
|
319
359
|
result = await async_client.list_endpoints()
|
|
320
360
|
|
|
321
361
|
assert len(result.endpoints) == 1
|
|
362
|
+
assert result.endpoints[0].paused is False
|
|
322
363
|
assert result.has_more is False
|
|
323
364
|
|
|
365
|
+
@pytest.mark.skipif(LIVE_MODE, reason="Mock test only")
|
|
366
|
+
async def test_pause_and_resume_endpoint(
|
|
367
|
+
self, async_client: AsyncHookBridge, httpx_mock: HTTPXMock
|
|
368
|
+
) -> None:
|
|
369
|
+
"""Pause and resume an outbound endpoint."""
|
|
370
|
+
endpoint_id = "ep_550e8400e29b41d4a716446655440000"
|
|
371
|
+
httpx_mock.add_response(
|
|
372
|
+
method="POST",
|
|
373
|
+
url=f"{async_client._base_url}/v1/endpoints/{endpoint_id}/pause",
|
|
374
|
+
json={"data": {"id": endpoint_id, "paused": True}, "meta": {"request_id": "req-12345"}},
|
|
375
|
+
)
|
|
376
|
+
httpx_mock.add_response(
|
|
377
|
+
method="POST",
|
|
378
|
+
url=f"{async_client._base_url}/v1/endpoints/{endpoint_id}/resume",
|
|
379
|
+
json={
|
|
380
|
+
"data": {"id": endpoint_id, "paused": False, "messages_requeued": 2},
|
|
381
|
+
"meta": {"request_id": "req-12345"},
|
|
382
|
+
},
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
paused = await async_client.pause_endpoint(endpoint_id)
|
|
386
|
+
resumed = await async_client.resume_endpoint(endpoint_id)
|
|
387
|
+
|
|
388
|
+
assert paused.paused is True
|
|
389
|
+
assert resumed.paused is False
|
|
390
|
+
assert resumed.messages_requeued == 2
|
|
391
|
+
|
|
324
392
|
@pytest.mark.skipif(LIVE_MODE, reason="Mock test only")
|
|
325
393
|
async def test_rotate_endpoint_secret(
|
|
326
394
|
self, async_client: AsyncHookBridge, httpx_mock: HTTPXMock
|
|
@@ -398,6 +398,7 @@ class TestEndpoints:
|
|
|
398
398
|
|
|
399
399
|
assert result.id == endpoint_id
|
|
400
400
|
assert result.url == "https://example.com/webhook"
|
|
401
|
+
assert result.paused is False
|
|
401
402
|
assert result.rate_limit_rps == 10
|
|
402
403
|
|
|
403
404
|
@pytest.mark.skipif(LIVE_MODE, reason="Mock test only")
|
|
@@ -413,8 +414,34 @@ class TestEndpoints:
|
|
|
413
414
|
|
|
414
415
|
assert len(result.endpoints) == 1
|
|
415
416
|
assert result.endpoints[0].id == "ep_550e8400e29b41d4a716446655440000"
|
|
417
|
+
assert result.endpoints[0].paused is False
|
|
416
418
|
assert result.has_more is False
|
|
417
419
|
|
|
420
|
+
@pytest.mark.skipif(LIVE_MODE, reason="Mock test only")
|
|
421
|
+
def test_pause_and_resume_endpoint(self, client: HookBridge, httpx_mock: HTTPXMock) -> None:
|
|
422
|
+
"""Pause and resume an outbound endpoint."""
|
|
423
|
+
endpoint_id = "ep_550e8400e29b41d4a716446655440000"
|
|
424
|
+
httpx_mock.add_response(
|
|
425
|
+
method="POST",
|
|
426
|
+
url=f"{client._base_url}/v1/endpoints/{endpoint_id}/pause",
|
|
427
|
+
json={"data": {"id": endpoint_id, "paused": True}, "meta": {"request_id": "req-12345"}},
|
|
428
|
+
)
|
|
429
|
+
httpx_mock.add_response(
|
|
430
|
+
method="POST",
|
|
431
|
+
url=f"{client._base_url}/v1/endpoints/{endpoint_id}/resume",
|
|
432
|
+
json={
|
|
433
|
+
"data": {"id": endpoint_id, "paused": False, "messages_requeued": 2},
|
|
434
|
+
"meta": {"request_id": "req-12345"},
|
|
435
|
+
},
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
paused = client.pause_endpoint(endpoint_id)
|
|
439
|
+
resumed = client.resume_endpoint(endpoint_id)
|
|
440
|
+
|
|
441
|
+
assert paused.paused is True
|
|
442
|
+
assert resumed.paused is False
|
|
443
|
+
assert resumed.messages_requeued == 2
|
|
444
|
+
|
|
418
445
|
@pytest.mark.skipif(LIVE_MODE, reason="Mock test only")
|
|
419
446
|
def test_update_endpoint(self, client: HookBridge, httpx_mock: HTTPXMock) -> None:
|
|
420
447
|
"""Update an endpoint."""
|
|
@@ -3,6 +3,7 @@ Spec parity tests for the synchronous HookBridge client.
|
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
|
+
from datetime import UTC, datetime
|
|
6
7
|
|
|
7
8
|
import pytest
|
|
8
9
|
from pytest_httpx import HTTPXMock
|
|
@@ -11,7 +12,6 @@ from hookbridge import HookBridge
|
|
|
11
12
|
|
|
12
13
|
from .conftest import LIVE_MODE
|
|
13
14
|
|
|
14
|
-
|
|
15
15
|
PROJECT_ID = "01935abc-def0-7123-4567-890abcdef012"
|
|
16
16
|
INBOUND_ENDPOINT_ID = "01935abc-def0-7123-4567-890abcdef099"
|
|
17
17
|
MESSAGE_ID = "01935abc-def0-7123-4567-890abcdef013"
|
|
@@ -236,69 +236,68 @@ class TestSigningKeyParity:
|
|
|
236
236
|
client.delete_endpoint_signing_key(ENDPOINT_ID, SIGNING_KEY_ID)
|
|
237
237
|
|
|
238
238
|
|
|
239
|
-
class
|
|
239
|
+
class TestEndpointPauseParity:
|
|
240
240
|
@pytest.mark.skipif(LIVE_MODE, reason="Mock test only")
|
|
241
|
-
def
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
"id": "550e8400-e29b-41d4-a716-446655440000",
|
|
245
|
-
"user_id": "user_abc123",
|
|
246
|
-
"timezone": "America/New_York",
|
|
247
|
-
"date_format": "MM/DD/YYYY",
|
|
248
|
-
"locale": "en-US",
|
|
249
|
-
"created_at": "2025-12-01T10:00:00Z",
|
|
250
|
-
"updated_at": "2025-12-06T12:00:00Z",
|
|
251
|
-
},
|
|
252
|
-
"meta": {"request_id": "req-12345"},
|
|
253
|
-
}
|
|
241
|
+
def test_get_list_pause_resume_endpoint(
|
|
242
|
+
self, client: HookBridge, httpx_mock: HTTPXMock
|
|
243
|
+
) -> None:
|
|
254
244
|
httpx_mock.add_response(
|
|
255
245
|
method="GET",
|
|
256
|
-
url=f"{client._base_url}/v1/
|
|
257
|
-
json=
|
|
246
|
+
url=f"{client._base_url}/v1/endpoints/{ENDPOINT_ID}",
|
|
247
|
+
json={
|
|
248
|
+
"data": {
|
|
249
|
+
"id": ENDPOINT_ID,
|
|
250
|
+
"url": "https://customer.app/webhooks",
|
|
251
|
+
"description": "Main production webhook",
|
|
252
|
+
"paused": False,
|
|
253
|
+
"rate_limit_rps": 10,
|
|
254
|
+
"burst": 20,
|
|
255
|
+
"created_at": "2025-12-01T10:00:00Z",
|
|
256
|
+
"updated_at": "2025-12-06T12:00:00Z",
|
|
257
|
+
},
|
|
258
|
+
"meta": {"request_id": "req-12345"},
|
|
259
|
+
},
|
|
258
260
|
)
|
|
259
261
|
httpx_mock.add_response(
|
|
260
|
-
method="
|
|
261
|
-
url=f"{client._base_url}/v1/
|
|
262
|
-
json=
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
payload = {
|
|
274
|
-
"data": {
|
|
275
|
-
"id": "550e8400-e29b-41d4-a716-446655440001",
|
|
276
|
-
"user_id": "user_abc123",
|
|
277
|
-
"enabled_alert_types": ["high_failure_rate"],
|
|
278
|
-
"delivery_channels": ["email"],
|
|
279
|
-
"failure_rate_threshold": 10,
|
|
280
|
-
"pending_backlog_threshold": 100,
|
|
281
|
-
"created_at": "2025-12-01T10:00:00Z",
|
|
282
|
-
"updated_at": "2025-12-06T12:00:00Z",
|
|
262
|
+
method="GET",
|
|
263
|
+
url=f"{client._base_url}/v1/endpoints",
|
|
264
|
+
json={
|
|
265
|
+
"data": [
|
|
266
|
+
{
|
|
267
|
+
"id": ENDPOINT_ID,
|
|
268
|
+
"url": "https://customer.app/webhooks",
|
|
269
|
+
"description": "Main production webhook",
|
|
270
|
+
"paused": False,
|
|
271
|
+
"created_at": "2025-12-01T10:00:00Z",
|
|
272
|
+
}
|
|
273
|
+
],
|
|
274
|
+
"meta": {"request_id": "req-12345", "next_cursor": None},
|
|
283
275
|
},
|
|
284
|
-
|
|
285
|
-
}
|
|
276
|
+
)
|
|
286
277
|
httpx_mock.add_response(
|
|
287
|
-
method="
|
|
288
|
-
url=f"{client._base_url}/v1/
|
|
289
|
-
json=
|
|
278
|
+
method="POST",
|
|
279
|
+
url=f"{client._base_url}/v1/endpoints/{ENDPOINT_ID}/pause",
|
|
280
|
+
json={"data": {"id": ENDPOINT_ID, "paused": True}, "meta": {"request_id": "req-12345"}},
|
|
290
281
|
)
|
|
291
282
|
httpx_mock.add_response(
|
|
292
|
-
method="
|
|
293
|
-
url=f"{client._base_url}/v1/
|
|
294
|
-
json=
|
|
283
|
+
method="POST",
|
|
284
|
+
url=f"{client._base_url}/v1/endpoints/{ENDPOINT_ID}/resume",
|
|
285
|
+
json={
|
|
286
|
+
"data": {"id": ENDPOINT_ID, "paused": False, "messages_requeued": 4},
|
|
287
|
+
"meta": {"request_id": "req-12345"},
|
|
288
|
+
},
|
|
295
289
|
)
|
|
296
290
|
|
|
297
|
-
|
|
298
|
-
|
|
291
|
+
fetched = client.get_endpoint(ENDPOINT_ID)
|
|
292
|
+
listed = client.list_endpoints()
|
|
293
|
+
paused = client.pause_endpoint(ENDPOINT_ID)
|
|
294
|
+
resumed = client.resume_endpoint(ENDPOINT_ID)
|
|
299
295
|
|
|
300
|
-
assert
|
|
301
|
-
assert
|
|
296
|
+
assert fetched.paused is False
|
|
297
|
+
assert listed.endpoints[0].paused is False
|
|
298
|
+
assert paused.paused is True
|
|
299
|
+
assert resumed.paused is False
|
|
300
|
+
assert resumed.messages_requeued == 4
|
|
302
301
|
|
|
303
302
|
|
|
304
303
|
class TestBillingParity:
|
|
@@ -700,3 +699,35 @@ class TestExportParity:
|
|
|
700
699
|
assert listed[0].row_count == 5000
|
|
701
700
|
assert fetched.status == "completed"
|
|
702
701
|
assert download_url == "https://downloads.example.com/export.csv"
|
|
702
|
+
|
|
703
|
+
@pytest.mark.skipif(LIVE_MODE, reason="Mock test only")
|
|
704
|
+
def test_create_export_serializes_datetimes(
|
|
705
|
+
self, client: HookBridge, httpx_mock: HTTPXMock
|
|
706
|
+
) -> None:
|
|
707
|
+
httpx_mock.add_response(
|
|
708
|
+
method="POST",
|
|
709
|
+
url=f"{client._base_url}/v1/exports",
|
|
710
|
+
json={
|
|
711
|
+
"data": {
|
|
712
|
+
"id": EXPORT_ID,
|
|
713
|
+
"project_id": "proj_abc123",
|
|
714
|
+
"status": "pending",
|
|
715
|
+
"filter_start_time": "2025-12-01T00:00:00+00:00",
|
|
716
|
+
"filter_end_time": "2025-12-06T23:59:59+00:00",
|
|
717
|
+
"created_at": "2025-12-06T12:00:00Z",
|
|
718
|
+
},
|
|
719
|
+
"meta": {"request_id": "req-12345"},
|
|
720
|
+
},
|
|
721
|
+
)
|
|
722
|
+
|
|
723
|
+
start = datetime(2025, 12, 1, 0, 0, 0, tzinfo=UTC)
|
|
724
|
+
end = datetime(2025, 12, 6, 23, 59, 59, tzinfo=UTC)
|
|
725
|
+
client.create_export(start_time=start, end_time=end, endpoint_id=ENDPOINT_ID)
|
|
726
|
+
|
|
727
|
+
request = httpx_mock.get_request()
|
|
728
|
+
assert request is not None
|
|
729
|
+
assert json.loads(request.content) == {
|
|
730
|
+
"start_time": "2025-12-01T00:00:00+00:00",
|
|
731
|
+
"end_time": "2025-12-06T23:59:59+00:00",
|
|
732
|
+
"endpoint_id": ENDPOINT_ID,
|
|
733
|
+
}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|