posthook-python 1.0.0__py3-none-any.whl
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.
- posthook/__init__.py +73 -0
- posthook/_client.py +124 -0
- posthook/_errors.py +120 -0
- posthook/_http.py +224 -0
- posthook/_models.py +162 -0
- posthook/_resources/__init__.py +10 -0
- posthook/_resources/_hooks.py +517 -0
- posthook/_resources/_signatures.py +169 -0
- posthook/_version.py +1 -0
- posthook/py.typed +0 -0
- posthook_python-1.0.0.dist-info/METADATA +513 -0
- posthook_python-1.0.0.dist-info/RECORD +14 -0
- posthook_python-1.0.0.dist-info/WHEEL +4 -0
- posthook_python-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
from typing import Any, AsyncIterator, Iterator
|
|
5
|
+
from urllib.parse import quote
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
from .._errors import NotFoundError
|
|
9
|
+
from .._http import AsyncHttpClient, SyncHttpClient, _parse_quota
|
|
10
|
+
from .._models import BulkActionResult, Hook, HookRetryOverride
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _build_schedule_body(
|
|
14
|
+
path: str,
|
|
15
|
+
*,
|
|
16
|
+
data: Any = None,
|
|
17
|
+
post_at: datetime | str | None = None,
|
|
18
|
+
post_at_local: str | None = None,
|
|
19
|
+
timezone_str: str | None = None,
|
|
20
|
+
post_in: str | None = None,
|
|
21
|
+
retry_override: HookRetryOverride | None = None,
|
|
22
|
+
) -> dict[str, Any]:
|
|
23
|
+
modes = sum(x is not None for x in (post_at, post_at_local, post_in))
|
|
24
|
+
if modes == 0:
|
|
25
|
+
raise ValueError(
|
|
26
|
+
"Exactly one scheduling mode is required: post_at, post_at_local, or post_in"
|
|
27
|
+
)
|
|
28
|
+
if modes > 1:
|
|
29
|
+
raise ValueError(
|
|
30
|
+
"Only one scheduling mode allowed: post_at, post_at_local, or post_in"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
body: dict[str, Any] = {"path": path}
|
|
34
|
+
if data is not None:
|
|
35
|
+
body["data"] = data
|
|
36
|
+
if post_at is not None:
|
|
37
|
+
if isinstance(post_at, datetime):
|
|
38
|
+
if post_at.tzinfo is None:
|
|
39
|
+
raise ValueError(
|
|
40
|
+
"post_at datetime must be timezone-aware. "
|
|
41
|
+
"Use datetime.now(timezone.utc) or datetime(..., tzinfo=timezone.utc)"
|
|
42
|
+
)
|
|
43
|
+
body["postAt"] = post_at.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
44
|
+
else:
|
|
45
|
+
body["postAt"] = post_at
|
|
46
|
+
elif post_at_local is not None:
|
|
47
|
+
body["postAtLocal"] = post_at_local
|
|
48
|
+
if timezone_str is not None:
|
|
49
|
+
body["timezone"] = timezone_str
|
|
50
|
+
elif post_in is not None:
|
|
51
|
+
body["postIn"] = post_in
|
|
52
|
+
if retry_override is not None:
|
|
53
|
+
body["retryOverride"] = retry_override.to_dict()
|
|
54
|
+
return body
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _build_list_params(
|
|
58
|
+
*,
|
|
59
|
+
status: str | None = None,
|
|
60
|
+
limit: int | None = None,
|
|
61
|
+
offset: int | None = None,
|
|
62
|
+
post_at_before: str | None = None,
|
|
63
|
+
post_at_after: str | None = None,
|
|
64
|
+
created_at_before: str | None = None,
|
|
65
|
+
created_at_after: str | None = None,
|
|
66
|
+
sort_by: str | None = None,
|
|
67
|
+
sort_order: str | None = None,
|
|
68
|
+
) -> dict[str, Any]:
|
|
69
|
+
params: dict[str, Any] = {}
|
|
70
|
+
if status is not None:
|
|
71
|
+
params["status"] = status
|
|
72
|
+
if limit is not None:
|
|
73
|
+
params["limit"] = limit
|
|
74
|
+
if offset is not None:
|
|
75
|
+
params["offset"] = offset
|
|
76
|
+
if post_at_before is not None:
|
|
77
|
+
params["postAtBefore"] = post_at_before
|
|
78
|
+
if post_at_after is not None:
|
|
79
|
+
params["postAtAfter"] = post_at_after
|
|
80
|
+
if created_at_before is not None:
|
|
81
|
+
params["createdAtBefore"] = created_at_before
|
|
82
|
+
if created_at_after is not None:
|
|
83
|
+
params["createdAtAfter"] = created_at_after
|
|
84
|
+
if sort_by is not None:
|
|
85
|
+
params["sortBy"] = sort_by
|
|
86
|
+
if sort_order is not None:
|
|
87
|
+
params["sortOrder"] = sort_order
|
|
88
|
+
return params
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _build_bulk_body_by_ids(
|
|
92
|
+
hook_ids: list[str],
|
|
93
|
+
) -> dict[str, Any]:
|
|
94
|
+
return {"hookIDs": hook_ids}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _build_bulk_body_by_filter(
|
|
98
|
+
start_time: str,
|
|
99
|
+
end_time: str,
|
|
100
|
+
limit: int,
|
|
101
|
+
*,
|
|
102
|
+
endpoint_key: str | None = None,
|
|
103
|
+
sequence_id: str | None = None,
|
|
104
|
+
) -> dict[str, Any]:
|
|
105
|
+
body: dict[str, Any] = {
|
|
106
|
+
"startTime": start_time,
|
|
107
|
+
"endTime": end_time,
|
|
108
|
+
"limit": limit,
|
|
109
|
+
}
|
|
110
|
+
if endpoint_key is not None:
|
|
111
|
+
body["endpointKey"] = endpoint_key
|
|
112
|
+
if sequence_id is not None:
|
|
113
|
+
body["sequenceID"] = sequence_id
|
|
114
|
+
return body
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class BulkActions:
|
|
118
|
+
"""Synchronous sub-resource for bulk hook actions."""
|
|
119
|
+
|
|
120
|
+
def __init__(self, http: SyncHttpClient) -> None:
|
|
121
|
+
self._http = http
|
|
122
|
+
|
|
123
|
+
def _do(
|
|
124
|
+
self, path: str, body: dict[str, Any], *, timeout: float | None = None,
|
|
125
|
+
) -> BulkActionResult:
|
|
126
|
+
data, _ = self._http.request_data("POST", path, json=body, timeout=timeout)
|
|
127
|
+
return BulkActionResult.from_dict(data)
|
|
128
|
+
|
|
129
|
+
def retry(
|
|
130
|
+
self, hook_ids: list[str], *, timeout: float | None = None,
|
|
131
|
+
) -> BulkActionResult:
|
|
132
|
+
return self._do(
|
|
133
|
+
"/v1/hooks/bulk/retry", _build_bulk_body_by_ids(hook_ids),
|
|
134
|
+
timeout=timeout,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
def retry_by_filter(
|
|
138
|
+
self,
|
|
139
|
+
start_time: str,
|
|
140
|
+
end_time: str,
|
|
141
|
+
limit: int,
|
|
142
|
+
*,
|
|
143
|
+
endpoint_key: str | None = None,
|
|
144
|
+
sequence_id: str | None = None,
|
|
145
|
+
timeout: float | None = None,
|
|
146
|
+
) -> BulkActionResult:
|
|
147
|
+
return self._do(
|
|
148
|
+
"/v1/hooks/bulk/retry",
|
|
149
|
+
_build_bulk_body_by_filter(
|
|
150
|
+
start_time, end_time, limit,
|
|
151
|
+
endpoint_key=endpoint_key, sequence_id=sequence_id,
|
|
152
|
+
),
|
|
153
|
+
timeout=timeout,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
def replay(
|
|
157
|
+
self, hook_ids: list[str], *, timeout: float | None = None,
|
|
158
|
+
) -> BulkActionResult:
|
|
159
|
+
return self._do(
|
|
160
|
+
"/v1/hooks/bulk/replay", _build_bulk_body_by_ids(hook_ids),
|
|
161
|
+
timeout=timeout,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
def replay_by_filter(
|
|
165
|
+
self,
|
|
166
|
+
start_time: str,
|
|
167
|
+
end_time: str,
|
|
168
|
+
limit: int,
|
|
169
|
+
*,
|
|
170
|
+
endpoint_key: str | None = None,
|
|
171
|
+
sequence_id: str | None = None,
|
|
172
|
+
timeout: float | None = None,
|
|
173
|
+
) -> BulkActionResult:
|
|
174
|
+
return self._do(
|
|
175
|
+
"/v1/hooks/bulk/replay",
|
|
176
|
+
_build_bulk_body_by_filter(
|
|
177
|
+
start_time, end_time, limit,
|
|
178
|
+
endpoint_key=endpoint_key, sequence_id=sequence_id,
|
|
179
|
+
),
|
|
180
|
+
timeout=timeout,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
def cancel(
|
|
184
|
+
self, hook_ids: list[str], *, timeout: float | None = None,
|
|
185
|
+
) -> BulkActionResult:
|
|
186
|
+
return self._do(
|
|
187
|
+
"/v1/hooks/bulk/cancel", _build_bulk_body_by_ids(hook_ids),
|
|
188
|
+
timeout=timeout,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
def cancel_by_filter(
|
|
192
|
+
self,
|
|
193
|
+
start_time: str,
|
|
194
|
+
end_time: str,
|
|
195
|
+
limit: int,
|
|
196
|
+
*,
|
|
197
|
+
endpoint_key: str | None = None,
|
|
198
|
+
sequence_id: str | None = None,
|
|
199
|
+
timeout: float | None = None,
|
|
200
|
+
) -> BulkActionResult:
|
|
201
|
+
return self._do(
|
|
202
|
+
"/v1/hooks/bulk/cancel",
|
|
203
|
+
_build_bulk_body_by_filter(
|
|
204
|
+
start_time, end_time, limit,
|
|
205
|
+
endpoint_key=endpoint_key, sequence_id=sequence_id,
|
|
206
|
+
),
|
|
207
|
+
timeout=timeout,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class HooksService:
|
|
212
|
+
"""Synchronous resource for managing hooks."""
|
|
213
|
+
|
|
214
|
+
def __init__(self, http: SyncHttpClient) -> None:
|
|
215
|
+
self._http = http
|
|
216
|
+
self.bulk = BulkActions(http)
|
|
217
|
+
|
|
218
|
+
def schedule(
|
|
219
|
+
self,
|
|
220
|
+
path: str,
|
|
221
|
+
*,
|
|
222
|
+
data: Any = None,
|
|
223
|
+
post_at: datetime | str | None = None,
|
|
224
|
+
post_at_local: str | None = None,
|
|
225
|
+
timezone: str | None = None,
|
|
226
|
+
post_in: str | None = None,
|
|
227
|
+
retry_override: HookRetryOverride | None = None,
|
|
228
|
+
timeout: float | None = None,
|
|
229
|
+
) -> Hook:
|
|
230
|
+
body = _build_schedule_body(
|
|
231
|
+
path,
|
|
232
|
+
data=data,
|
|
233
|
+
post_at=post_at,
|
|
234
|
+
post_at_local=post_at_local,
|
|
235
|
+
timezone_str=timezone,
|
|
236
|
+
post_in=post_in,
|
|
237
|
+
retry_override=retry_override,
|
|
238
|
+
)
|
|
239
|
+
resp_data, headers = self._http.request_data(
|
|
240
|
+
"POST", "/v1/hooks", json=body, timeout=timeout,
|
|
241
|
+
)
|
|
242
|
+
hook = Hook.from_dict(resp_data)
|
|
243
|
+
hook.quota = _parse_quota(headers)
|
|
244
|
+
return hook
|
|
245
|
+
|
|
246
|
+
def get(self, id: str, *, timeout: float | None = None) -> Hook:
|
|
247
|
+
if not id:
|
|
248
|
+
raise ValueError("hook id is required")
|
|
249
|
+
data, _ = self._http.request_data(
|
|
250
|
+
"GET", f"/v1/hooks/{quote(id, safe='')}", timeout=timeout,
|
|
251
|
+
)
|
|
252
|
+
return Hook.from_dict(data)
|
|
253
|
+
|
|
254
|
+
def list(
|
|
255
|
+
self,
|
|
256
|
+
*,
|
|
257
|
+
status: str | None = None,
|
|
258
|
+
limit: int | None = None,
|
|
259
|
+
offset: int | None = None,
|
|
260
|
+
post_at_before: str | None = None,
|
|
261
|
+
post_at_after: str | None = None,
|
|
262
|
+
created_at_before: str | None = None,
|
|
263
|
+
created_at_after: str | None = None,
|
|
264
|
+
sort_by: str | None = None,
|
|
265
|
+
sort_order: str | None = None,
|
|
266
|
+
timeout: float | None = None,
|
|
267
|
+
) -> list[Hook]:
|
|
268
|
+
params = _build_list_params(
|
|
269
|
+
status=status,
|
|
270
|
+
limit=limit,
|
|
271
|
+
offset=offset,
|
|
272
|
+
post_at_before=post_at_before,
|
|
273
|
+
post_at_after=post_at_after,
|
|
274
|
+
created_at_before=created_at_before,
|
|
275
|
+
created_at_after=created_at_after,
|
|
276
|
+
sort_by=sort_by,
|
|
277
|
+
sort_order=sort_order,
|
|
278
|
+
)
|
|
279
|
+
raw_list, _ = self._http.request_data(
|
|
280
|
+
"GET", "/v1/hooks", params=params, timeout=timeout,
|
|
281
|
+
)
|
|
282
|
+
return [Hook.from_dict(h) for h in (raw_list or [])]
|
|
283
|
+
|
|
284
|
+
def list_all(
|
|
285
|
+
self,
|
|
286
|
+
*,
|
|
287
|
+
status: str | None = None,
|
|
288
|
+
post_at_after: str | None = None,
|
|
289
|
+
page_size: int = 100,
|
|
290
|
+
timeout: float | None = None,
|
|
291
|
+
) -> Iterator[Hook]:
|
|
292
|
+
cursor: str | None = post_at_after
|
|
293
|
+
while True:
|
|
294
|
+
hooks = self.list(
|
|
295
|
+
status=status,
|
|
296
|
+
limit=page_size,
|
|
297
|
+
sort_by="postAt",
|
|
298
|
+
sort_order="ASC",
|
|
299
|
+
post_at_after=cursor,
|
|
300
|
+
timeout=timeout,
|
|
301
|
+
)
|
|
302
|
+
yield from hooks
|
|
303
|
+
if len(hooks) < page_size:
|
|
304
|
+
break
|
|
305
|
+
cursor = hooks[-1].post_at.astimezone(timezone.utc).isoformat()
|
|
306
|
+
|
|
307
|
+
def delete(self, id: str, *, timeout: float | None = None) -> None:
|
|
308
|
+
if not id:
|
|
309
|
+
raise ValueError("hook id is required")
|
|
310
|
+
try:
|
|
311
|
+
self._http.request_data(
|
|
312
|
+
"DELETE", f"/v1/hooks/{quote(id, safe='')}", timeout=timeout,
|
|
313
|
+
)
|
|
314
|
+
except NotFoundError:
|
|
315
|
+
pass
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
class AsyncBulkActions:
|
|
319
|
+
"""Asynchronous sub-resource for bulk hook actions."""
|
|
320
|
+
|
|
321
|
+
def __init__(self, http: AsyncHttpClient) -> None:
|
|
322
|
+
self._http = http
|
|
323
|
+
|
|
324
|
+
async def _do(
|
|
325
|
+
self, path: str, body: dict[str, Any], *, timeout: float | None = None,
|
|
326
|
+
) -> BulkActionResult:
|
|
327
|
+
data, _ = await self._http.request_data("POST", path, json=body, timeout=timeout)
|
|
328
|
+
return BulkActionResult.from_dict(data)
|
|
329
|
+
|
|
330
|
+
async def retry(
|
|
331
|
+
self, hook_ids: list[str], *, timeout: float | None = None,
|
|
332
|
+
) -> BulkActionResult:
|
|
333
|
+
return await self._do(
|
|
334
|
+
"/v1/hooks/bulk/retry", _build_bulk_body_by_ids(hook_ids),
|
|
335
|
+
timeout=timeout,
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
async def retry_by_filter(
|
|
339
|
+
self,
|
|
340
|
+
start_time: str,
|
|
341
|
+
end_time: str,
|
|
342
|
+
limit: int,
|
|
343
|
+
*,
|
|
344
|
+
endpoint_key: str | None = None,
|
|
345
|
+
sequence_id: str | None = None,
|
|
346
|
+
timeout: float | None = None,
|
|
347
|
+
) -> BulkActionResult:
|
|
348
|
+
return await self._do(
|
|
349
|
+
"/v1/hooks/bulk/retry",
|
|
350
|
+
_build_bulk_body_by_filter(
|
|
351
|
+
start_time, end_time, limit,
|
|
352
|
+
endpoint_key=endpoint_key, sequence_id=sequence_id,
|
|
353
|
+
),
|
|
354
|
+
timeout=timeout,
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
async def replay(
|
|
358
|
+
self, hook_ids: list[str], *, timeout: float | None = None,
|
|
359
|
+
) -> BulkActionResult:
|
|
360
|
+
return await self._do(
|
|
361
|
+
"/v1/hooks/bulk/replay", _build_bulk_body_by_ids(hook_ids),
|
|
362
|
+
timeout=timeout,
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
async def replay_by_filter(
|
|
366
|
+
self,
|
|
367
|
+
start_time: str,
|
|
368
|
+
end_time: str,
|
|
369
|
+
limit: int,
|
|
370
|
+
*,
|
|
371
|
+
endpoint_key: str | None = None,
|
|
372
|
+
sequence_id: str | None = None,
|
|
373
|
+
timeout: float | None = None,
|
|
374
|
+
) -> BulkActionResult:
|
|
375
|
+
return await self._do(
|
|
376
|
+
"/v1/hooks/bulk/replay",
|
|
377
|
+
_build_bulk_body_by_filter(
|
|
378
|
+
start_time, end_time, limit,
|
|
379
|
+
endpoint_key=endpoint_key, sequence_id=sequence_id,
|
|
380
|
+
),
|
|
381
|
+
timeout=timeout,
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
async def cancel(
|
|
385
|
+
self, hook_ids: list[str], *, timeout: float | None = None,
|
|
386
|
+
) -> BulkActionResult:
|
|
387
|
+
return await self._do(
|
|
388
|
+
"/v1/hooks/bulk/cancel", _build_bulk_body_by_ids(hook_ids),
|
|
389
|
+
timeout=timeout,
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
async def cancel_by_filter(
|
|
393
|
+
self,
|
|
394
|
+
start_time: str,
|
|
395
|
+
end_time: str,
|
|
396
|
+
limit: int,
|
|
397
|
+
*,
|
|
398
|
+
endpoint_key: str | None = None,
|
|
399
|
+
sequence_id: str | None = None,
|
|
400
|
+
timeout: float | None = None,
|
|
401
|
+
) -> BulkActionResult:
|
|
402
|
+
return await self._do(
|
|
403
|
+
"/v1/hooks/bulk/cancel",
|
|
404
|
+
_build_bulk_body_by_filter(
|
|
405
|
+
start_time, end_time, limit,
|
|
406
|
+
endpoint_key=endpoint_key, sequence_id=sequence_id,
|
|
407
|
+
),
|
|
408
|
+
timeout=timeout,
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
class AsyncHooksService:
|
|
413
|
+
"""Asynchronous resource for managing hooks."""
|
|
414
|
+
|
|
415
|
+
def __init__(self, http: AsyncHttpClient) -> None:
|
|
416
|
+
self._http = http
|
|
417
|
+
self.bulk = AsyncBulkActions(http)
|
|
418
|
+
|
|
419
|
+
async def schedule(
|
|
420
|
+
self,
|
|
421
|
+
path: str,
|
|
422
|
+
*,
|
|
423
|
+
data: Any = None,
|
|
424
|
+
post_at: datetime | str | None = None,
|
|
425
|
+
post_at_local: str | None = None,
|
|
426
|
+
timezone: str | None = None,
|
|
427
|
+
post_in: str | None = None,
|
|
428
|
+
retry_override: HookRetryOverride | None = None,
|
|
429
|
+
timeout: float | None = None,
|
|
430
|
+
) -> Hook:
|
|
431
|
+
body = _build_schedule_body(
|
|
432
|
+
path,
|
|
433
|
+
data=data,
|
|
434
|
+
post_at=post_at,
|
|
435
|
+
post_at_local=post_at_local,
|
|
436
|
+
timezone_str=timezone,
|
|
437
|
+
post_in=post_in,
|
|
438
|
+
retry_override=retry_override,
|
|
439
|
+
)
|
|
440
|
+
resp_data, headers = await self._http.request_data(
|
|
441
|
+
"POST", "/v1/hooks", json=body, timeout=timeout,
|
|
442
|
+
)
|
|
443
|
+
hook = Hook.from_dict(resp_data)
|
|
444
|
+
hook.quota = _parse_quota(headers)
|
|
445
|
+
return hook
|
|
446
|
+
|
|
447
|
+
async def get(self, id: str, *, timeout: float | None = None) -> Hook:
|
|
448
|
+
if not id:
|
|
449
|
+
raise ValueError("hook id is required")
|
|
450
|
+
data, _ = await self._http.request_data(
|
|
451
|
+
"GET", f"/v1/hooks/{quote(id, safe='')}", timeout=timeout,
|
|
452
|
+
)
|
|
453
|
+
return Hook.from_dict(data)
|
|
454
|
+
|
|
455
|
+
async def list(
|
|
456
|
+
self,
|
|
457
|
+
*,
|
|
458
|
+
status: str | None = None,
|
|
459
|
+
limit: int | None = None,
|
|
460
|
+
offset: int | None = None,
|
|
461
|
+
post_at_before: str | None = None,
|
|
462
|
+
post_at_after: str | None = None,
|
|
463
|
+
created_at_before: str | None = None,
|
|
464
|
+
created_at_after: str | None = None,
|
|
465
|
+
sort_by: str | None = None,
|
|
466
|
+
sort_order: str | None = None,
|
|
467
|
+
timeout: float | None = None,
|
|
468
|
+
) -> list[Hook]:
|
|
469
|
+
params = _build_list_params(
|
|
470
|
+
status=status,
|
|
471
|
+
limit=limit,
|
|
472
|
+
offset=offset,
|
|
473
|
+
post_at_before=post_at_before,
|
|
474
|
+
post_at_after=post_at_after,
|
|
475
|
+
created_at_before=created_at_before,
|
|
476
|
+
created_at_after=created_at_after,
|
|
477
|
+
sort_by=sort_by,
|
|
478
|
+
sort_order=sort_order,
|
|
479
|
+
)
|
|
480
|
+
raw_list, _ = await self._http.request_data(
|
|
481
|
+
"GET", "/v1/hooks", params=params, timeout=timeout,
|
|
482
|
+
)
|
|
483
|
+
return [Hook.from_dict(h) for h in (raw_list or [])]
|
|
484
|
+
|
|
485
|
+
async def list_all(
|
|
486
|
+
self,
|
|
487
|
+
*,
|
|
488
|
+
status: str | None = None,
|
|
489
|
+
post_at_after: str | None = None,
|
|
490
|
+
page_size: int = 100,
|
|
491
|
+
timeout: float | None = None,
|
|
492
|
+
) -> AsyncIterator[Hook]:
|
|
493
|
+
cursor: str | None = post_at_after
|
|
494
|
+
while True:
|
|
495
|
+
hooks = await self.list(
|
|
496
|
+
status=status,
|
|
497
|
+
limit=page_size,
|
|
498
|
+
sort_by="postAt",
|
|
499
|
+
sort_order="ASC",
|
|
500
|
+
post_at_after=cursor,
|
|
501
|
+
timeout=timeout,
|
|
502
|
+
)
|
|
503
|
+
for hook in hooks:
|
|
504
|
+
yield hook
|
|
505
|
+
if len(hooks) < page_size:
|
|
506
|
+
break
|
|
507
|
+
cursor = hooks[-1].post_at.astimezone(timezone.utc).isoformat()
|
|
508
|
+
|
|
509
|
+
async def delete(self, id: str, *, timeout: float | None = None) -> None:
|
|
510
|
+
if not id:
|
|
511
|
+
raise ValueError("hook id is required")
|
|
512
|
+
try:
|
|
513
|
+
await self._http.request_data(
|
|
514
|
+
"DELETE", f"/v1/hooks/{quote(id, safe='')}", timeout=timeout,
|
|
515
|
+
)
|
|
516
|
+
except NotFoundError:
|
|
517
|
+
pass
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import hmac
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import time
|
|
8
|
+
from collections.abc import Mapping
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from .._errors import SignatureVerificationError
|
|
12
|
+
from .._models import Delivery, _parse_dt
|
|
13
|
+
|
|
14
|
+
DEFAULT_TOLERANCE = 300 # 5 minutes in seconds
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _get_header(headers: Mapping[str, Any], name: str) -> str | None:
|
|
18
|
+
"""Case-insensitive header lookup."""
|
|
19
|
+
# Try exact match first
|
|
20
|
+
val = headers.get(name)
|
|
21
|
+
if val is not None:
|
|
22
|
+
if isinstance(val, list):
|
|
23
|
+
return val[0] if val else None
|
|
24
|
+
return str(val)
|
|
25
|
+
|
|
26
|
+
# Try lowercase
|
|
27
|
+
lower = name.lower()
|
|
28
|
+
for key, value in headers.items():
|
|
29
|
+
if key.lower() == lower:
|
|
30
|
+
if isinstance(value, list):
|
|
31
|
+
return value[0] if value else None
|
|
32
|
+
return str(value)
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _compute_signature(key: str, timestamp: int, body_bytes: bytes) -> str:
|
|
37
|
+
"""Compute HMAC-SHA256 signature: v1,<hex>.
|
|
38
|
+
|
|
39
|
+
Uses incremental mac.update() to avoid copying the body for large payloads.
|
|
40
|
+
"""
|
|
41
|
+
mac = hmac.new(key.encode(), digestmod=hashlib.sha256)
|
|
42
|
+
mac.update(f"{timestamp}.".encode())
|
|
43
|
+
mac.update(body_bytes)
|
|
44
|
+
return f"v1,{mac.hexdigest()}"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _safe_compare(a: str, b: str) -> bool:
|
|
48
|
+
"""Constant-time string comparison."""
|
|
49
|
+
return hmac.compare_digest(a.encode(), b.encode())
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class SignaturesService:
|
|
53
|
+
"""Webhook signature verification. Shared by sync and async clients (no I/O)."""
|
|
54
|
+
|
|
55
|
+
def __init__(self, signing_key: str | None = None) -> None:
|
|
56
|
+
self._signing_key = signing_key
|
|
57
|
+
|
|
58
|
+
def parse_delivery(
|
|
59
|
+
self,
|
|
60
|
+
body: bytes | str,
|
|
61
|
+
headers: Mapping[str, Any],
|
|
62
|
+
*,
|
|
63
|
+
signing_key: str | None = None,
|
|
64
|
+
tolerance: int = DEFAULT_TOLERANCE,
|
|
65
|
+
) -> Delivery:
|
|
66
|
+
"""Verify the webhook signature and parse the delivery payload.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
body: The raw HTTP request body (bytes or string).
|
|
70
|
+
headers: The request headers as a mapping (dict, HTTPMessage, etc.).
|
|
71
|
+
signing_key: Override the client's signing key for this call.
|
|
72
|
+
tolerance: Maximum age of the timestamp in seconds (default: 300).
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
A Delivery object with the parsed and verified payload.
|
|
76
|
+
|
|
77
|
+
Raises:
|
|
78
|
+
SignatureVerificationError: If verification fails.
|
|
79
|
+
"""
|
|
80
|
+
key = signing_key or self._signing_key
|
|
81
|
+
if not key:
|
|
82
|
+
raise SignatureVerificationError(
|
|
83
|
+
"No signing key provided. Pass signing_key to the Posthook constructor "
|
|
84
|
+
"or to parse_delivery()."
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
hook_id = _get_header(headers, "Posthook-Id") or ""
|
|
88
|
+
|
|
89
|
+
timestamp_str = _get_header(headers, "Posthook-Timestamp")
|
|
90
|
+
if not timestamp_str:
|
|
91
|
+
raise SignatureVerificationError("Missing Posthook-Timestamp header")
|
|
92
|
+
|
|
93
|
+
signature = _get_header(headers, "Posthook-Signature")
|
|
94
|
+
if not signature:
|
|
95
|
+
raise SignatureVerificationError("Missing Posthook-Signature header")
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
timestamp = int(timestamp_str)
|
|
99
|
+
except ValueError:
|
|
100
|
+
raise SignatureVerificationError(
|
|
101
|
+
f"Invalid Posthook-Timestamp: {timestamp_str}"
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
now = int(time.time())
|
|
105
|
+
diff = abs(now - timestamp)
|
|
106
|
+
if diff > tolerance:
|
|
107
|
+
raise SignatureVerificationError(
|
|
108
|
+
f"Timestamp too old: {diff}s difference exceeds {tolerance}s tolerance"
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
body_bytes = body if isinstance(body, bytes) else body.encode("utf-8")
|
|
112
|
+
expected_sig = _compute_signature(key, timestamp, body_bytes)
|
|
113
|
+
|
|
114
|
+
signatures = signature.split(" ")
|
|
115
|
+
verified = False
|
|
116
|
+
for sig in signatures:
|
|
117
|
+
if _safe_compare(sig, expected_sig):
|
|
118
|
+
verified = True
|
|
119
|
+
break
|
|
120
|
+
|
|
121
|
+
if not verified:
|
|
122
|
+
raise SignatureVerificationError("Signature verification failed")
|
|
123
|
+
|
|
124
|
+
try:
|
|
125
|
+
payload = json.loads(body_bytes)
|
|
126
|
+
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
|
|
127
|
+
raise SignatureVerificationError(
|
|
128
|
+
f"Failed to parse delivery payload: {exc}"
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
return Delivery(
|
|
132
|
+
hook_id=hook_id,
|
|
133
|
+
timestamp=timestamp,
|
|
134
|
+
path=payload.get("path", ""),
|
|
135
|
+
data=payload.get("data"),
|
|
136
|
+
body=body_bytes,
|
|
137
|
+
post_at=_parse_dt(payload.get("postAt", "")),
|
|
138
|
+
posted_at=_parse_dt(payload.get("postedAt", "")),
|
|
139
|
+
created_at=_parse_dt(payload.get("createdAt", "")),
|
|
140
|
+
updated_at=_parse_dt(payload.get("updatedAt", "")),
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def create_signatures(signing_key: str | None = None) -> SignaturesService:
|
|
145
|
+
"""Create a standalone SignaturesService with fail-fast key validation.
|
|
146
|
+
|
|
147
|
+
This is the recommended way to create a ``SignaturesService`` for standalone
|
|
148
|
+
webhook verification (outside the ``Posthook`` / ``AsyncPosthook`` clients).
|
|
149
|
+
It ensures a valid signing key is available at construction time rather than
|
|
150
|
+
deferring the error to the first ``parse_delivery()`` call.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
signing_key: Your Posthook signing key. Falls back to the
|
|
154
|
+
``POSTHOOK_SIGNING_KEY`` environment variable if not provided.
|
|
155
|
+
|
|
156
|
+
Returns:
|
|
157
|
+
A configured ``SignaturesService`` instance.
|
|
158
|
+
|
|
159
|
+
Raises:
|
|
160
|
+
ValueError: If no signing key is provided and the environment variable
|
|
161
|
+
is not set or empty.
|
|
162
|
+
"""
|
|
163
|
+
resolved = signing_key or os.environ.get("POSTHOOK_SIGNING_KEY", "")
|
|
164
|
+
if not resolved:
|
|
165
|
+
raise ValueError(
|
|
166
|
+
"No signing key provided. Pass signing_key to create_signatures() "
|
|
167
|
+
"or set the POSTHOOK_SIGNING_KEY environment variable."
|
|
168
|
+
)
|
|
169
|
+
return SignaturesService(resolved)
|
posthook/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
VERSION = "1.0.0"
|
posthook/py.typed
ADDED
|
File without changes
|