hookbase 3.0.0__tar.gz → 3.1.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.0.0 → hookbase-3.1.0}/PKG-INFO +6 -2
- {hookbase-3.0.0 → hookbase-3.1.0}/README.md +5 -1
- {hookbase-3.0.0 → hookbase-3.1.0}/examples/async_usage.py +1 -1
- {hookbase-3.0.0 → hookbase-3.1.0}/examples/basic_inbound.py +1 -1
- {hookbase-3.0.0 → hookbase-3.1.0}/examples/send_webhooks.py +1 -1
- hookbase-3.1.0/src/hookbase/_version.py +1 -0
- {hookbase-3.0.0 → hookbase-3.1.0}/src/hookbase/models/__init__.py +8 -0
- hookbase-3.1.0/src/hookbase/models/_wire.py +254 -0
- hookbase-3.1.0/src/hookbase/models/applications.py +70 -0
- {hookbase-3.0.0 → hookbase-3.1.0}/src/hookbase/models/destinations.py +120 -12
- hookbase-3.1.0/src/hookbase/models/endpoints.py +275 -0
- {hookbase-3.0.0 → hookbase-3.1.0}/src/hookbase/resources/_base.py +8 -2
- {hookbase-3.0.0 → hookbase-3.1.0}/src/hookbase/resources/applications.py +38 -6
- {hookbase-3.0.0 → hookbase-3.1.0}/src/hookbase/resources/destinations.py +7 -5
- {hookbase-3.0.0 → hookbase-3.1.0}/src/hookbase/resources/endpoints.py +18 -7
- {hookbase-3.0.0 → hookbase-3.1.0}/tests/conftest.py +6 -0
- hookbase-3.1.0/tests/resources/test_applications.py +138 -0
- {hookbase-3.0.0 → hookbase-3.1.0}/tests/resources/test_destinations.py +196 -7
- hookbase-3.1.0/tests/resources/test_endpoints.py +432 -0
- hookbase-3.1.0/tests/test_slug_contract.py +143 -0
- hookbase-3.0.0/src/hookbase/_version.py +0 -1
- hookbase-3.0.0/src/hookbase/models/applications.py +0 -26
- hookbase-3.0.0/src/hookbase/models/endpoints.py +0 -79
- hookbase-3.0.0/tests/resources/test_applications.py +0 -77
- {hookbase-3.0.0 → hookbase-3.1.0}/.github/workflows/python-publish.yml +0 -0
- {hookbase-3.0.0 → hookbase-3.1.0}/.gitignore +0 -0
- {hookbase-3.0.0 → hookbase-3.1.0}/LICENSE +0 -0
- {hookbase-3.0.0 → hookbase-3.1.0}/examples/error_handling.py +0 -0
- {hookbase-3.0.0 → hookbase-3.1.0}/examples/verify_webhooks.py +0 -0
- {hookbase-3.0.0 → hookbase-3.1.0}/pyproject.toml +0 -0
- {hookbase-3.0.0 → hookbase-3.1.0}/src/hookbase/__init__.py +0 -0
- {hookbase-3.0.0 → hookbase-3.1.0}/src/hookbase/_client.py +0 -0
- {hookbase-3.0.0 → hookbase-3.1.0}/src/hookbase/_constants.py +0 -0
- {hookbase-3.0.0 → hookbase-3.1.0}/src/hookbase/_pagination.py +0 -0
- {hookbase-3.0.0 → hookbase-3.1.0}/src/hookbase/client.py +0 -0
- {hookbase-3.0.0 → hookbase-3.1.0}/src/hookbase/errors.py +0 -0
- {hookbase-3.0.0 → hookbase-3.1.0}/src/hookbase/models/_base.py +0 -0
- {hookbase-3.0.0 → hookbase-3.1.0}/src/hookbase/models/analytics.py +0 -0
- {hookbase-3.0.0 → hookbase-3.1.0}/src/hookbase/models/api_keys.py +0 -0
- {hookbase-3.0.0 → hookbase-3.1.0}/src/hookbase/models/common.py +0 -0
- {hookbase-3.0.0 → hookbase-3.1.0}/src/hookbase/models/cron_jobs.py +0 -0
- {hookbase-3.0.0 → hookbase-3.1.0}/src/hookbase/models/deliveries.py +0 -0
- {hookbase-3.0.0 → hookbase-3.1.0}/src/hookbase/models/dlq.py +0 -0
- {hookbase-3.0.0 → hookbase-3.1.0}/src/hookbase/models/event_types.py +0 -0
- {hookbase-3.0.0 → hookbase-3.1.0}/src/hookbase/models/events.py +0 -0
- {hookbase-3.0.0 → hookbase-3.1.0}/src/hookbase/models/filters.py +0 -0
- {hookbase-3.0.0 → hookbase-3.1.0}/src/hookbase/models/messages.py +0 -0
- {hookbase-3.0.0 → hookbase-3.1.0}/src/hookbase/models/organizations.py +0 -0
- {hookbase-3.0.0 → hookbase-3.1.0}/src/hookbase/models/portal_tokens.py +0 -0
- {hookbase-3.0.0 → hookbase-3.1.0}/src/hookbase/models/routes.py +0 -0
- {hookbase-3.0.0 → hookbase-3.1.0}/src/hookbase/models/schemas.py +0 -0
- {hookbase-3.0.0 → hookbase-3.1.0}/src/hookbase/models/sources.py +0 -0
- {hookbase-3.0.0 → hookbase-3.1.0}/src/hookbase/models/subscriptions.py +0 -0
- {hookbase-3.0.0 → hookbase-3.1.0}/src/hookbase/models/transforms.py +0 -0
- {hookbase-3.0.0 → hookbase-3.1.0}/src/hookbase/models/tunnels.py +0 -0
- {hookbase-3.0.0 → hookbase-3.1.0}/src/hookbase/py.typed +0 -0
- {hookbase-3.0.0 → hookbase-3.1.0}/src/hookbase/resources/__init__.py +0 -0
- {hookbase-3.0.0 → hookbase-3.1.0}/src/hookbase/resources/analytics.py +0 -0
- {hookbase-3.0.0 → hookbase-3.1.0}/src/hookbase/resources/api_keys.py +0 -0
- {hookbase-3.0.0 → hookbase-3.1.0}/src/hookbase/resources/cron_jobs.py +0 -0
- {hookbase-3.0.0 → hookbase-3.1.0}/src/hookbase/resources/deliveries.py +0 -0
- {hookbase-3.0.0 → hookbase-3.1.0}/src/hookbase/resources/dlq.py +0 -0
- {hookbase-3.0.0 → hookbase-3.1.0}/src/hookbase/resources/event_types.py +0 -0
- {hookbase-3.0.0 → hookbase-3.1.0}/src/hookbase/resources/events.py +0 -0
- {hookbase-3.0.0 → hookbase-3.1.0}/src/hookbase/resources/filters.py +0 -0
- {hookbase-3.0.0 → hookbase-3.1.0}/src/hookbase/resources/message_log.py +0 -0
- {hookbase-3.0.0 → hookbase-3.1.0}/src/hookbase/resources/messages.py +0 -0
- {hookbase-3.0.0 → hookbase-3.1.0}/src/hookbase/resources/organizations.py +0 -0
- {hookbase-3.0.0 → hookbase-3.1.0}/src/hookbase/resources/portal_tokens.py +0 -0
- {hookbase-3.0.0 → hookbase-3.1.0}/src/hookbase/resources/routes.py +0 -0
- {hookbase-3.0.0 → hookbase-3.1.0}/src/hookbase/resources/schemas.py +0 -0
- {hookbase-3.0.0 → hookbase-3.1.0}/src/hookbase/resources/sources.py +0 -0
- {hookbase-3.0.0 → hookbase-3.1.0}/src/hookbase/resources/subscriptions.py +0 -0
- {hookbase-3.0.0 → hookbase-3.1.0}/src/hookbase/resources/transforms.py +0 -0
- {hookbase-3.0.0 → hookbase-3.1.0}/src/hookbase/resources/tunnels.py +0 -0
- {hookbase-3.0.0 → hookbase-3.1.0}/src/hookbase/webhook.py +0 -0
- {hookbase-3.0.0 → hookbase-3.1.0}/tests/__init__.py +0 -0
- {hookbase-3.0.0 → hookbase-3.1.0}/tests/resources/__init__.py +0 -0
- {hookbase-3.0.0 → hookbase-3.1.0}/tests/resources/test_cron_jobs.py +0 -0
- {hookbase-3.0.0 → hookbase-3.1.0}/tests/resources/test_dlq.py +0 -0
- {hookbase-3.0.0 → hookbase-3.1.0}/tests/resources/test_messages.py +0 -0
- {hookbase-3.0.0 → hookbase-3.1.0}/tests/resources/test_source_fields.py +0 -0
- {hookbase-3.0.0 → hookbase-3.1.0}/tests/resources/test_sources.py +0 -0
- {hookbase-3.0.0 → hookbase-3.1.0}/tests/test_client.py +0 -0
- {hookbase-3.0.0 → hookbase-3.1.0}/tests/test_errors.py +0 -0
- {hookbase-3.0.0 → hookbase-3.1.0}/tests/test_pagination.py +0 -0
- {hookbase-3.0.0 → hookbase-3.1.0}/tests/test_webhook.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: hookbase
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.1.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
|
|
@@ -129,13 +129,17 @@ client.schemas.create({"name": "OrderSchema", "jsonSchema": {"type": "object", .
|
|
|
129
129
|
|
|
130
130
|
```python
|
|
131
131
|
# Applications
|
|
132
|
-
client.outbound.applications.create({"name": "Acme", "
|
|
132
|
+
client.outbound.applications.create({"name": "Acme", "externalId": "cust_123"})
|
|
133
133
|
client.outbound.applications.get_by_external_id("cust_123")
|
|
134
134
|
|
|
135
135
|
# Endpoints
|
|
136
136
|
client.outbound.endpoints.create("app_id", {"url": "https://..."})
|
|
137
137
|
client.outbound.endpoints.rotate_secret("ep_id", grace_period=3600)
|
|
138
138
|
|
|
139
|
+
# An unset field is left alone by an update, so `None` cannot also mean "reset
|
|
140
|
+
# this". Name the setting in `clear` to send an explicit null for it:
|
|
141
|
+
client.outbound.endpoints.update("ep_id", {"clear": ["retryDelays"]})
|
|
142
|
+
|
|
139
143
|
# Event Types & Subscriptions
|
|
140
144
|
client.outbound.event_types.create({"name": "order.created", "category": "orders"})
|
|
141
145
|
client.outbound.subscriptions.create({"endpointId": "ep_id", "eventTypeId": "et_id"})
|
|
@@ -94,13 +94,17 @@ client.schemas.create({"name": "OrderSchema", "jsonSchema": {"type": "object", .
|
|
|
94
94
|
|
|
95
95
|
```python
|
|
96
96
|
# Applications
|
|
97
|
-
client.outbound.applications.create({"name": "Acme", "
|
|
97
|
+
client.outbound.applications.create({"name": "Acme", "externalId": "cust_123"})
|
|
98
98
|
client.outbound.applications.get_by_external_id("cust_123")
|
|
99
99
|
|
|
100
100
|
# Endpoints
|
|
101
101
|
client.outbound.endpoints.create("app_id", {"url": "https://..."})
|
|
102
102
|
client.outbound.endpoints.rotate_secret("ep_id", grace_period=3600)
|
|
103
103
|
|
|
104
|
+
# An unset field is left alone by an update, so `None` cannot also mean "reset
|
|
105
|
+
# this". Name the setting in `clear` to send an explicit null for it:
|
|
106
|
+
client.outbound.endpoints.update("ep_id", {"clear": ["retryDelays"]})
|
|
107
|
+
|
|
104
108
|
# Event Types & Subscriptions
|
|
105
109
|
client.outbound.event_types.create({"name": "order.created", "category": "orders"})
|
|
106
110
|
client.outbound.subscriptions.create({"endpointId": "ep_id", "eventTypeId": "et_id"})
|
|
@@ -29,7 +29,7 @@ async def main():
|
|
|
29
29
|
# Paginate through all applications
|
|
30
30
|
page = await client.outbound.applications.list(limit=10)
|
|
31
31
|
async for app in page.auto_paging_iter():
|
|
32
|
-
print(f" App: {app.name} (
|
|
32
|
+
print(f" App: {app.name} (externalId={app.external_id})")
|
|
33
33
|
|
|
34
34
|
|
|
35
35
|
if __name__ == "__main__":
|
|
@@ -7,7 +7,7 @@ client = Hookbase(api_key="whr_your_api_key")
|
|
|
7
7
|
# Create an application (represents a customer/tenant)
|
|
8
8
|
app = client.outbound.applications.create({
|
|
9
9
|
"name": "Acme Corp",
|
|
10
|
-
"
|
|
10
|
+
"externalId": "cust_123",
|
|
11
11
|
"metadata": {"plan": "pro"},
|
|
12
12
|
})
|
|
13
13
|
print(f"Application: {app.id}")
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "3.1.0"
|
|
@@ -42,7 +42,11 @@ from .dlq import (
|
|
|
42
42
|
DlqStats,
|
|
43
43
|
)
|
|
44
44
|
from .endpoints import (
|
|
45
|
+
CLEARABLE_ENDPOINT_FIELDS,
|
|
46
|
+
BackoffType,
|
|
47
|
+
ClearableEndpointField,
|
|
45
48
|
CreateEndpointParams,
|
|
49
|
+
CustomHeader,
|
|
46
50
|
EndpointStats,
|
|
47
51
|
EndpointWithSecret,
|
|
48
52
|
RotateSecretResult,
|
|
@@ -155,7 +159,11 @@ __all__ = [
|
|
|
155
159
|
"DlqRetryResult",
|
|
156
160
|
"DlqStats",
|
|
157
161
|
# Endpoints
|
|
162
|
+
"CLEARABLE_ENDPOINT_FIELDS",
|
|
163
|
+
"BackoffType",
|
|
164
|
+
"ClearableEndpointField",
|
|
158
165
|
"CreateEndpointParams",
|
|
166
|
+
"CustomHeader",
|
|
159
167
|
"EndpointStats",
|
|
160
168
|
"EndpointWithSecret",
|
|
161
169
|
"RotateSecretResult",
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""Normalization of request bodies onto the shapes the API actually accepts.
|
|
2
|
+
|
|
3
|
+
The SDK has historically put field names on the wire that the API never
|
|
4
|
+
accepted (endpoint `filterTypes`/`rateLimitPeriod`/`metadata`, destination
|
|
5
|
+
`description`/`retryCount`/`retryInterval`) alongside a few it renamed
|
|
6
|
+
(`rateLimit` -> `rateLimitPerSecond`, `timeout` -> `timeoutMs`, `uid` ->
|
|
7
|
+
`externalId`). The API validates with `z.object()`, which silently strips
|
|
8
|
+
unknown keys, so those requests came back 2xx with the field quietly ignored —
|
|
9
|
+
and the resource configured differently from what the caller asked for. Those
|
|
10
|
+
schemas are moving to `.strict()`, at which point the same requests become
|
|
11
|
+
400s.
|
|
12
|
+
|
|
13
|
+
Deleting the public fields would break callers, so the models keep them,
|
|
14
|
+
document them as deprecated, and these helpers translate them at the wire
|
|
15
|
+
boundary. They deliberately operate on the already-serialized body rather than
|
|
16
|
+
on the model, so a caller who passes a plain dict — which every resource method
|
|
17
|
+
accepts, and which skips model validation entirely — gets the same translation.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import re
|
|
24
|
+
import unicodedata
|
|
25
|
+
from collections.abc import Iterable
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
from ..errors import HookbaseError
|
|
29
|
+
|
|
30
|
+
# Params models serialize through `to_camel`, but `populate_by_name` means a
|
|
31
|
+
# caller may also use the snake_case field name, and a caller passing a plain
|
|
32
|
+
# dict can put that spelling straight on the wire.
|
|
33
|
+
_CAMEL_BOUNDARY = re.compile(r"(?<!^)(?=[A-Z])")
|
|
34
|
+
|
|
35
|
+
# Slug derivation mirrors `deriveDestinationSlug` in
|
|
36
|
+
# sdk/node-sdk/src/resources/wire.ts step for step, so all four SDKs derive
|
|
37
|
+
# byte-identical slugs from the same name. Keep the two in sync.
|
|
38
|
+
|
|
39
|
+
_SLUG_SEPARATORS = re.compile(r"[^a-z0-9]+")
|
|
40
|
+
_SLUG_EDGE_HYPHENS = re.compile(r"^-+|-+$")
|
|
41
|
+
_SLUG_TRAILING_HYPHENS = re.compile(r"-+$")
|
|
42
|
+
SLUG_MAX_LENGTH = 50
|
|
43
|
+
|
|
44
|
+
# Which characters count as combining marks is a function of the Unicode version the *runtime*
|
|
45
|
+
# ships, and the four SDKs' runtimes do not agree: CPython 3.12's `unicodedata` is Unicode 15.0,
|
|
46
|
+
# while the reference implementation's `\p{Mn}` (V8) is a version ahead. Reading the category alone
|
|
47
|
+
# therefore makes the same name slug differently depending on which SDK created the destination,
|
|
48
|
+
# which is the thing this derivation exists to prevent.
|
|
49
|
+
#
|
|
50
|
+
# These two tables close that gap, generated from the reference runtime's own `\p{Mn}` compared
|
|
51
|
+
# against Unicode 15.0's. Both self-heal: once this interpreter's tables cover the additions, the
|
|
52
|
+
# set is redundant but harmless, and `test_slug_contract.py` says so when that happens.
|
|
53
|
+
#
|
|
54
|
+
# 75 code points in 21 ranges, the same table `slugMarkAdditions` carries in
|
|
55
|
+
# sdk/go-sdk/slug_fold.go. Change one and you change both.
|
|
56
|
+
_SLUG_MARK_ADDITIONS: frozenset[str] = frozenset(
|
|
57
|
+
chr(cp)
|
|
58
|
+
for lo, hi in (
|
|
59
|
+
("\u0897", "\u0897"),
|
|
60
|
+
("\u1acf", "\u1add"),
|
|
61
|
+
("\u1ae0", "\u1aeb"),
|
|
62
|
+
("\U00010d69", "\U00010d6d"),
|
|
63
|
+
("\U00010efa", "\U00010efc"),
|
|
64
|
+
("\U000113bb", "\U000113c0"),
|
|
65
|
+
("\U000113ce", "\U000113ce"),
|
|
66
|
+
("\U000113d0", "\U000113d0"),
|
|
67
|
+
("\U000113d2", "\U000113d2"),
|
|
68
|
+
("\U000113e1", "\U000113e2"),
|
|
69
|
+
("\U00011b60", "\U00011b60"),
|
|
70
|
+
("\U00011b62", "\U00011b64"),
|
|
71
|
+
("\U00011b66", "\U00011b66"),
|
|
72
|
+
("\U00011f5a", "\U00011f5a"),
|
|
73
|
+
("\U0001611e", "\U00016129"),
|
|
74
|
+
("\U0001612d", "\U0001612f"),
|
|
75
|
+
("\U0001e5ee", "\U0001e5ef"),
|
|
76
|
+
("\U0001e6e3", "\U0001e6e3"),
|
|
77
|
+
("\U0001e6e6", "\U0001e6e6"),
|
|
78
|
+
("\U0001e6ee", "\U0001e6ef"),
|
|
79
|
+
("\U0001e6f5", "\U0001e6f5"),
|
|
80
|
+
)
|
|
81
|
+
for cp in range(ord(lo), ord(hi) + 1)
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
# The correction in the other direction: AHOM CONSONANT SIGN MEDIAL RA was Mn in Unicode 15.0 and
|
|
85
|
+
# is Mc (a *spacing* mark) from 15.1, so the reference does not strip it and neither may this
|
|
86
|
+
# SDK -- it has to separate, the way any other non-alphanumeric does.
|
|
87
|
+
_SLUG_MARK_RECLASSIFIED = "\U0001171e"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# The same version skew, one layer down: NFKD itself. These characters are unassigned in Unicode
|
|
91
|
+
# 15.0, so this interpreter leaves them alone and they become hyphen separators, while the
|
|
92
|
+
# reference runtime decomposes them to ASCII letters and digits. Folding them first makes NFKD's
|
|
93
|
+
# answer the same on both. Every mapping is the reference's own NFKD output, not a guess at the
|
|
94
|
+
# character's meaning; a later interpreter that knows them decomposes them identically, so this
|
|
95
|
+
# stays a no-op rather than a divergence of its own.
|
|
96
|
+
#
|
|
97
|
+
# U+A7F1 Latin Extended-D, decomposes to "S"
|
|
98
|
+
# U+1CCD6 - U+1CCF9 36 contiguous additions in Symbols for Legacy Computing Supplement,
|
|
99
|
+
# decomposing to A-Z and then 0-9 in order
|
|
100
|
+
_SLUG_FOLD_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
|
101
|
+
_SLUG_FOLD_ADDITIONS = {
|
|
102
|
+
0xA7F1: "S",
|
|
103
|
+
**{0x1CCD6 + offset: char for offset, char in enumerate(_SLUG_FOLD_ALPHABET)},
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _is_slug_mark(char: str) -> bool:
|
|
108
|
+
"""Whether `char` is a combining mark the slug fold drops. See the tables above."""
|
|
109
|
+
if char == _SLUG_MARK_RECLASSIFIED:
|
|
110
|
+
return False
|
|
111
|
+
return unicodedata.category(char) == "Mn" or char in _SLUG_MARK_ADDITIONS
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def dump(params: Any) -> dict[str, Any]:
|
|
115
|
+
"""Serialize a params model into a wire body, or copy a caller's dict.
|
|
116
|
+
|
|
117
|
+
The copy matters: these helpers mutate the body in place and callers hand
|
|
118
|
+
us dicts they may reuse.
|
|
119
|
+
"""
|
|
120
|
+
if hasattr(params, "model_dump"):
|
|
121
|
+
body: dict[str, Any] = params.model_dump(by_alias=True, exclude_none=True)
|
|
122
|
+
return body
|
|
123
|
+
return dict(params or {})
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _spellings(key: str) -> tuple[str, ...]:
|
|
127
|
+
"""Every spelling of one wire key a caller may have used."""
|
|
128
|
+
snake = _CAMEL_BOUNDARY.sub("_", key).lower()
|
|
129
|
+
return (key,) if snake == key else (key, snake)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def drop(body: dict[str, Any], *keys: str) -> None:
|
|
133
|
+
"""Remove keys the API never accepted."""
|
|
134
|
+
for key in keys:
|
|
135
|
+
for spelling in _spellings(key):
|
|
136
|
+
body.pop(spelling, None)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def canonicalize(body: dict[str, Any], *keys: str) -> None:
|
|
140
|
+
"""Move any snake_case spelling of `keys` onto its camelCase wire key."""
|
|
141
|
+
for key in keys:
|
|
142
|
+
for spelling in _spellings(key)[1:]:
|
|
143
|
+
if spelling in body:
|
|
144
|
+
body.setdefault(key, body.pop(spelling))
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def rename(body: dict[str, Any], old: str, new: str) -> None:
|
|
148
|
+
"""Send `old`'s value under `new`; an explicit `new` wins over `old`."""
|
|
149
|
+
canonicalize(body, new)
|
|
150
|
+
value: Any = None
|
|
151
|
+
found = False
|
|
152
|
+
for spelling in _spellings(old):
|
|
153
|
+
if spelling in body:
|
|
154
|
+
if not found:
|
|
155
|
+
value = body[spelling]
|
|
156
|
+
found = True
|
|
157
|
+
del body[spelling]
|
|
158
|
+
if found and new not in body:
|
|
159
|
+
body[new] = value
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def apply_clear(body: dict[str, Any], allowed: tuple[str, ...], what: str) -> None:
|
|
163
|
+
"""Turn a params model's `clear` list into explicit JSON nulls on the body.
|
|
164
|
+
|
|
165
|
+
Every optional field on every params model is `X | None = None`, and `dump`
|
|
166
|
+
excludes `None` -- so `None` means "leave this setting alone" and there is
|
|
167
|
+
nothing left to mean "reset it", even where the API accepts an explicit
|
|
168
|
+
null. A caller who had set a custom retry schedule could not remove it.
|
|
169
|
+
|
|
170
|
+
`clear` closes that: naming a field puts `null` on the wire for it. Only
|
|
171
|
+
the fields in `allowed` are accepted, and a field both set and cleared is
|
|
172
|
+
an error rather than a silent choice between the two. `clear` itself never
|
|
173
|
+
reaches the API.
|
|
174
|
+
"""
|
|
175
|
+
requested: Any = None
|
|
176
|
+
for spelling in _spellings("clear"):
|
|
177
|
+
if spelling in body:
|
|
178
|
+
requested = body.pop(spelling)
|
|
179
|
+
|
|
180
|
+
if requested is None:
|
|
181
|
+
return
|
|
182
|
+
if isinstance(requested, str) or not isinstance(requested, Iterable):
|
|
183
|
+
raise HookbaseError(
|
|
184
|
+
f"`clear` takes a list of {what} field names, not {type(requested).__name__}."
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
for field in requested:
|
|
188
|
+
key = _CAMEL_BOUNDARY.sub("_", str(field)).lower()
|
|
189
|
+
wire = {_CAMEL_BOUNDARY.sub("_", name).lower(): name for name in allowed}.get(key)
|
|
190
|
+
if wire is None:
|
|
191
|
+
raise HookbaseError(
|
|
192
|
+
f"`clear` names {str(field)!r}, which is not one of the {what} fields the API "
|
|
193
|
+
f"accepts a null for. It accepts: {', '.join(sorted(allowed))}."
|
|
194
|
+
)
|
|
195
|
+
if wire in body:
|
|
196
|
+
raise HookbaseError(
|
|
197
|
+
f"`clear` names {str(field)!r} but that field is also set; clear it or set it, "
|
|
198
|
+
"not both."
|
|
199
|
+
)
|
|
200
|
+
body[wire] = None
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def headers_to_pairs(body: dict[str, Any]) -> None:
|
|
204
|
+
"""Convert endpoint `headers` from a mapping to the array the API takes.
|
|
205
|
+
|
|
206
|
+
`POST`/`PATCH /api/webhook-endpoints` validate headers as
|
|
207
|
+
`z.array({name, value}).max(10)`, so the mapping the SDK has always typed
|
|
208
|
+
here is rejected outright — a 400 today rather than a silently stripped
|
|
209
|
+
field. A mapping is converted in insertion order; an array a caller built
|
|
210
|
+
themselves passes through untouched.
|
|
211
|
+
"""
|
|
212
|
+
value = body.get("headers")
|
|
213
|
+
if isinstance(value, dict):
|
|
214
|
+
body["headers"] = [{"name": str(name), "value": str(v)} for name, v in value.items()]
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def derive_destination_slug(name: str) -> str:
|
|
218
|
+
"""Derive a slug the API accepts (`^[a-z0-9-]+$`, max 50) from a name.
|
|
219
|
+
|
|
220
|
+
The API requires `slug` on create; this SDK has always had it optional, so
|
|
221
|
+
every call that left it out was a 400. Deriving one keeps those calls
|
|
222
|
+
working. Accents are folded first, so `Café EU` gives `cafe-eu` rather than
|
|
223
|
+
`caf-eu`.
|
|
224
|
+
|
|
225
|
+
Every step mirrors `deriveDestinationSlug` in the Node SDK
|
|
226
|
+
(sdk/node-sdk/src/resources/wire.ts) so all four SDKs agree byte for byte:
|
|
227
|
+
NFKD normalize, drop the combining marks the decomposition leaves behind,
|
|
228
|
+
lowercase, collapse each run of non-alphanumerics to one hyphen, trim the
|
|
229
|
+
ends, then cut to 50 characters. The cut is plain and may land mid-word —
|
|
230
|
+
that is what keeps the implementations identical, so do not trim back to a
|
|
231
|
+
word boundary — and can leave a trailing hyphen to remove.
|
|
232
|
+
|
|
233
|
+
Raises:
|
|
234
|
+
HookbaseError: if the name has no alphanumeric characters to derive
|
|
235
|
+
from, rather than sending a slug the API would reject.
|
|
236
|
+
"""
|
|
237
|
+
# Drop the whole Mn category, not just the U+0300-U+036F block: a mark outside that block
|
|
238
|
+
# (Arabic, Hebrew, Devanagari) would otherwise survive to become a hyphen separator, and the
|
|
239
|
+
# same name would slug differently depending on which SDK created the destination.
|
|
240
|
+
# `_is_slug_mark` rather than the category alone, because the category answer depends on this
|
|
241
|
+
# interpreter's Unicode version -- see the tables above it.
|
|
242
|
+
decomposed = unicodedata.normalize("NFKD", name.translate(_SLUG_FOLD_ADDITIONS))
|
|
243
|
+
folded = "".join(ch for ch in decomposed if not _is_slug_mark(ch)).lower()
|
|
244
|
+
slug = _SLUG_EDGE_HYPHENS.sub("", _SLUG_SEPARATORS.sub("-", folded))
|
|
245
|
+
slug = _SLUG_TRAILING_HYPHENS.sub("", slug[:SLUG_MAX_LENGTH])
|
|
246
|
+
|
|
247
|
+
if not slug:
|
|
248
|
+
raise HookbaseError(
|
|
249
|
+
"Cannot derive a destination slug from name "
|
|
250
|
+
f"{json.dumps(name, ensure_ascii=False)}: pass "
|
|
251
|
+
"`slug` explicitly, as a string matching ^[a-z0-9-]+$ (max 50 characters)."
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
return slug
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from pydantic import model_validator
|
|
6
|
+
|
|
7
|
+
from . import _wire
|
|
8
|
+
from ._base import HookbaseModel
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Application(HookbaseModel):
|
|
12
|
+
id: str
|
|
13
|
+
name: str
|
|
14
|
+
organization_id: str
|
|
15
|
+
external_id: str = ""
|
|
16
|
+
metadata: dict[str, Any] | None = None
|
|
17
|
+
created_at: str = ""
|
|
18
|
+
updated_at: str = ""
|
|
19
|
+
|
|
20
|
+
uid: str = ""
|
|
21
|
+
"""Deprecated: the API calls this `externalId`; read `external_id` instead.
|
|
22
|
+
Kept in step with it so existing attribute access keeps working."""
|
|
23
|
+
|
|
24
|
+
@model_validator(mode="before")
|
|
25
|
+
@classmethod
|
|
26
|
+
def _mirror_external_id(cls, data: Any) -> Any:
|
|
27
|
+
"""Keep `uid` and `external_id` showing the same value.
|
|
28
|
+
|
|
29
|
+
Application responses carry the field as `externalId`; `uid` is only
|
|
30
|
+
the SDK's older name for it, so populate both from whichever spelling
|
|
31
|
+
the response used. `externalId` is null for applications created
|
|
32
|
+
without one, so fall back to the empty string both fields default to.
|
|
33
|
+
"""
|
|
34
|
+
if not isinstance(data, dict):
|
|
35
|
+
return data
|
|
36
|
+
value = data.get("externalId")
|
|
37
|
+
if value is None:
|
|
38
|
+
value = data.get("external_id")
|
|
39
|
+
if value is None:
|
|
40
|
+
value = data.get("uid")
|
|
41
|
+
return {**data, "externalId": value or "", "uid": value or ""}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class CreateApplicationParams(HookbaseModel):
|
|
45
|
+
name: str
|
|
46
|
+
external_id: str | None = None
|
|
47
|
+
metadata: dict[str, Any] | None = None
|
|
48
|
+
|
|
49
|
+
uid: str | None = None
|
|
50
|
+
"""Deprecated: renamed to `external_id`, which is the only name the API
|
|
51
|
+
accepts. The value is sent under that name, and `external_id` wins if both
|
|
52
|
+
are set."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class UpdateApplicationParams(HookbaseModel):
|
|
56
|
+
name: str | None = None
|
|
57
|
+
metadata: dict[str, Any] | None = None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def application_body(params: CreateApplicationParams | dict[str, Any]) -> dict[str, Any]:
|
|
61
|
+
"""Build the request body for an application create or upsert.
|
|
62
|
+
|
|
63
|
+
The API has always called the caller's own identifier `externalId`; the SDK
|
|
64
|
+
sent `uid`, which `z.object()` stripped, so every application the Python
|
|
65
|
+
SDK created came back with no external ID at all. Map the old name onto the
|
|
66
|
+
new one the way the Node SDK already does.
|
|
67
|
+
"""
|
|
68
|
+
body = _wire.dump(params)
|
|
69
|
+
_wire.rename(body, "uid", "externalId")
|
|
70
|
+
return body
|
|
@@ -5,6 +5,7 @@ from typing import Any, Literal
|
|
|
5
5
|
|
|
6
6
|
from pydantic import Field, field_validator, model_validator
|
|
7
7
|
|
|
8
|
+
from . import _wire
|
|
8
9
|
from ._base import HookbaseModel
|
|
9
10
|
|
|
10
11
|
HttpMethod = Literal["GET", "POST", "PUT", "PATCH", "DELETE"]
|
|
@@ -72,16 +73,13 @@ class Destination(HookbaseModel):
|
|
|
72
73
|
organization_id: str | None = None
|
|
73
74
|
name: str
|
|
74
75
|
slug: str
|
|
75
|
-
description: str | None = None
|
|
76
76
|
type: DestinationType = "http"
|
|
77
77
|
url: str = ""
|
|
78
78
|
method: HttpMethod = "POST"
|
|
79
79
|
headers: dict[str, str] | None = None
|
|
80
80
|
auth_type: str | None = "none"
|
|
81
81
|
auth_config: dict[str, Any] | None = None
|
|
82
|
-
|
|
83
|
-
retry_count: int = 3
|
|
84
|
-
retry_interval: int = 60
|
|
82
|
+
timeout_ms: int = 30000
|
|
85
83
|
throttle: Throttle | None = None
|
|
86
84
|
is_active: bool = True
|
|
87
85
|
use_static_ip: bool = True
|
|
@@ -94,6 +92,35 @@ class Destination(HookbaseModel):
|
|
|
94
92
|
created_at: str = ""
|
|
95
93
|
updated_at: str = ""
|
|
96
94
|
|
|
95
|
+
# Deprecated response fields. The API returns none of these — destinations
|
|
96
|
+
# have no description or per-destination retry columns, and the request
|
|
97
|
+
# timeout comes back as `timeoutMs` — so `description`/`retry_count`/
|
|
98
|
+
# `retry_interval` are always the defaults below. Kept so existing
|
|
99
|
+
# attribute access keeps working.
|
|
100
|
+
description: str | None = None
|
|
101
|
+
"""Deprecated: destinations have no description column."""
|
|
102
|
+
timeout: int = 30000
|
|
103
|
+
"""Deprecated: read `timeout_ms` instead. Mirrors it (milliseconds) when
|
|
104
|
+
the response carries no `timeout` of its own."""
|
|
105
|
+
retry_count: int = 3
|
|
106
|
+
"""Deprecated: retries are configured per route, not per destination."""
|
|
107
|
+
retry_interval: int = 60
|
|
108
|
+
"""Deprecated: retries are configured per route, not per destination."""
|
|
109
|
+
|
|
110
|
+
@model_validator(mode="before")
|
|
111
|
+
@classmethod
|
|
112
|
+
def _mirror_timeout(cls, data: Any) -> Any:
|
|
113
|
+
"""Keep the deprecated `timeout` field showing the real timeout.
|
|
114
|
+
|
|
115
|
+
The API answers with `timeoutMs`; `timeout` is only the old name for
|
|
116
|
+
the same number, so populate it from `timeoutMs` when the response has
|
|
117
|
+
no `timeout` of its own.
|
|
118
|
+
"""
|
|
119
|
+
if isinstance(data, dict) and "timeoutMs" in data and "timeout" not in data:
|
|
120
|
+
data = dict(data)
|
|
121
|
+
data["timeout"] = data["timeoutMs"]
|
|
122
|
+
return data
|
|
123
|
+
|
|
97
124
|
@model_validator(mode="before")
|
|
98
125
|
@classmethod
|
|
99
126
|
def _normalize_throttle(cls, data: Any) -> Any:
|
|
@@ -169,16 +196,23 @@ class Destination(HookbaseModel):
|
|
|
169
196
|
class CreateDestinationParams(HookbaseModel):
|
|
170
197
|
name: str
|
|
171
198
|
slug: str | None = None
|
|
172
|
-
|
|
199
|
+
"""URL-safe identifier, `^[a-z0-9-]+$`, max 50 characters.
|
|
200
|
+
|
|
201
|
+
Required by the API. Omit it and one is derived from `name` rather than
|
|
202
|
+
letting the request 400 — pass it explicitly whenever the slug matters,
|
|
203
|
+
since it is part of how the destination is addressed and cannot be changed
|
|
204
|
+
afterwards.
|
|
205
|
+
"""
|
|
173
206
|
type: DestinationType | None = None
|
|
174
207
|
url: str | None = None
|
|
175
208
|
method: HttpMethod | None = None
|
|
176
209
|
headers: dict[str, str] | None = None
|
|
210
|
+
"""Sent as a mapping — the shape the destinations API takes, unlike
|
|
211
|
+
endpoint headers."""
|
|
177
212
|
auth_type: AuthType | None = None
|
|
178
213
|
auth_config: dict[str, Any] | None = None
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
retry_interval: int | None = None
|
|
214
|
+
timeout_ms: int | None = None
|
|
215
|
+
"""Request timeout in milliseconds, 1000-60000. The API defaults to 30000."""
|
|
182
216
|
throttle: Throttle | None = None
|
|
183
217
|
config: dict[str, Any] | None = None
|
|
184
218
|
field_mapping: list[FieldMapping] | None = None
|
|
@@ -186,18 +220,36 @@ class CreateDestinationParams(HookbaseModel):
|
|
|
186
220
|
batch_size: int | None = None
|
|
187
221
|
batch_window_seconds: int | None = None
|
|
188
222
|
|
|
223
|
+
# Deprecated fields; see `create_destination_body` for what each one does
|
|
224
|
+
# on the wire.
|
|
225
|
+
description: str | None = None
|
|
226
|
+
"""Deprecated: never accepted by the API and no longer sent; destinations
|
|
227
|
+
have no description column."""
|
|
228
|
+
timeout: int | None = None
|
|
229
|
+
"""Deprecated: renamed to `timeout_ms`. The value is sent under that name
|
|
230
|
+
unchanged, and `timeout_ms` wins if both are set.
|
|
231
|
+
|
|
232
|
+
Milliseconds, 1000-60000, as everywhere else in this SDK. The value was not
|
|
233
|
+
reaching the API before, so a call that passed seconds here landed on the
|
|
234
|
+
30000 default and now 400s instead; multiply by 1000.
|
|
235
|
+
"""
|
|
236
|
+
retry_count: int | None = None
|
|
237
|
+
"""Deprecated: never accepted by the API and no longer sent; retries are
|
|
238
|
+
configured per route, not per destination."""
|
|
239
|
+
retry_interval: int | None = None
|
|
240
|
+
"""Deprecated: never accepted by the API and no longer sent; retries are
|
|
241
|
+
configured per route, not per destination."""
|
|
242
|
+
|
|
189
243
|
|
|
190
244
|
class UpdateDestinationParams(HookbaseModel):
|
|
191
245
|
name: str | None = None
|
|
192
|
-
description: str | None = None
|
|
193
246
|
url: str | None = None
|
|
194
247
|
method: HttpMethod | None = None
|
|
195
248
|
headers: dict[str, str] | None = None
|
|
196
249
|
auth_type: AuthType | None = None
|
|
197
250
|
auth_config: dict[str, Any] | None = None
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
retry_interval: int | None = None
|
|
251
|
+
timeout_ms: int | None = None
|
|
252
|
+
"""Request timeout in milliseconds, 1000-60000."""
|
|
201
253
|
throttle: Throttle | None = None
|
|
202
254
|
is_active: bool | None = None
|
|
203
255
|
config: dict[str, Any] | None = None
|
|
@@ -206,6 +258,62 @@ class UpdateDestinationParams(HookbaseModel):
|
|
|
206
258
|
batch_size: int | None = None
|
|
207
259
|
batch_window_seconds: int | None = None
|
|
208
260
|
|
|
261
|
+
# Deprecated fields; see `CreateDestinationParams` for each one's status.
|
|
262
|
+
description: str | None = None
|
|
263
|
+
"""Deprecated: never accepted by the API and no longer sent."""
|
|
264
|
+
timeout: int | None = None
|
|
265
|
+
"""Deprecated: renamed to `timeout_ms`. The value is sent under that name
|
|
266
|
+
unchanged, and `timeout_ms` wins if both are set.
|
|
267
|
+
|
|
268
|
+
Milliseconds, 1000-60000, as everywhere else in this SDK. The value was not
|
|
269
|
+
reaching the API before, so a call that passed seconds here landed on the
|
|
270
|
+
30000 default and now 400s instead; multiply by 1000.
|
|
271
|
+
"""
|
|
272
|
+
retry_count: int | None = None
|
|
273
|
+
"""Deprecated: never accepted by the API and no longer sent."""
|
|
274
|
+
retry_interval: int | None = None
|
|
275
|
+
"""Deprecated: never accepted by the API and no longer sent."""
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _strip_deprecated(body: dict[str, Any]) -> None:
|
|
279
|
+
"""Apply the rules shared by destination create and update bodies."""
|
|
280
|
+
_wire.drop(body, "description", "retryCount", "retryInterval")
|
|
281
|
+
_wire.rename(body, "timeout", "timeoutMs")
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def create_destination_body(
|
|
285
|
+
params: CreateDestinationParams | dict[str, Any],
|
|
286
|
+
) -> dict[str, Any]:
|
|
287
|
+
"""Build the request body for `POST /api/destinations`.
|
|
288
|
+
|
|
289
|
+
`slug` is required by the API (`^[a-z0-9-]+$`, max 50) but has always been
|
|
290
|
+
optional here, so a request that omits it 400s. Derive one from `name`
|
|
291
|
+
instead of letting that happen; an explicit slug is never rewritten.
|
|
292
|
+
"""
|
|
293
|
+
body = _wire.dump(params)
|
|
294
|
+
_strip_deprecated(body)
|
|
295
|
+
slug = body.get("slug")
|
|
296
|
+
if slug is None or (isinstance(slug, str) and not slug.strip()):
|
|
297
|
+
name = body.get("name")
|
|
298
|
+
# A missing or non-string `name` is the API's own `name` error to
|
|
299
|
+
# report, not a slug that could not be derived.
|
|
300
|
+
if isinstance(name, str):
|
|
301
|
+
body["slug"] = _wire.derive_destination_slug(name)
|
|
302
|
+
return body
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def update_destination_body(
|
|
306
|
+
params: UpdateDestinationParams | dict[str, Any],
|
|
307
|
+
) -> dict[str, Any]:
|
|
308
|
+
"""Build the request body for `PATCH /api/destinations/:id`.
|
|
309
|
+
|
|
310
|
+
Unlike create, the API takes no `slug` here — a destination's slug cannot
|
|
311
|
+
be changed after it is created.
|
|
312
|
+
"""
|
|
313
|
+
body = _wire.dump(params)
|
|
314
|
+
_strip_deprecated(body)
|
|
315
|
+
return body
|
|
316
|
+
|
|
209
317
|
|
|
210
318
|
class TestResult(HookbaseModel):
|
|
211
319
|
success: bool
|