posthook-python 1.0.0__tar.gz → 1.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.
- {posthook_python-1.0.0 → posthook_python-1.1.0}/PKG-INFO +69 -1
- {posthook_python-1.0.0 → posthook_python-1.1.0}/README.md +68 -0
- {posthook_python-1.0.0 → posthook_python-1.1.0}/src/posthook/__init__.py +10 -0
- posthook_python-1.1.0/src/posthook/_callbacks.py +127 -0
- {posthook_python-1.0.0 → posthook_python-1.1.0}/src/posthook/_errors.py +13 -0
- {posthook_python-1.0.0 → posthook_python-1.1.0}/src/posthook/_models.py +19 -0
- {posthook_python-1.0.0 → posthook_python-1.1.0}/src/posthook/_resources/_signatures.py +7 -0
- posthook_python-1.1.0/src/posthook/_version.py +1 -0
- posthook_python-1.1.0/tests/test_callbacks.py +260 -0
- {posthook_python-1.0.0 → posthook_python-1.1.0}/tests/test_signatures.py +29 -0
- posthook_python-1.0.0/src/posthook/_version.py +0 -1
- {posthook_python-1.0.0 → posthook_python-1.1.0}/.gitignore +0 -0
- {posthook_python-1.0.0 → posthook_python-1.1.0}/LICENSE +0 -0
- {posthook_python-1.0.0 → posthook_python-1.1.0}/RELEASING.md +0 -0
- {posthook_python-1.0.0 → posthook_python-1.1.0}/pyproject.toml +0 -0
- {posthook_python-1.0.0 → posthook_python-1.1.0}/src/posthook/_client.py +0 -0
- {posthook_python-1.0.0 → posthook_python-1.1.0}/src/posthook/_http.py +0 -0
- {posthook_python-1.0.0 → posthook_python-1.1.0}/src/posthook/_resources/__init__.py +0 -0
- {posthook_python-1.0.0 → posthook_python-1.1.0}/src/posthook/_resources/_hooks.py +0 -0
- {posthook_python-1.0.0 → posthook_python-1.1.0}/src/posthook/py.typed +0 -0
- {posthook_python-1.0.0 → posthook_python-1.1.0}/tests/conftest.py +0 -0
- {posthook_python-1.0.0 → posthook_python-1.1.0}/tests/test_client.py +0 -0
- {posthook_python-1.0.0 → posthook_python-1.1.0}/tests/test_errors.py +0 -0
- {posthook_python-1.0.0 → posthook_python-1.1.0}/tests/test_hooks.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: posthook-python
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.1.0
|
|
4
4
|
Summary: The official Python client library for the Posthook API
|
|
5
5
|
Project-URL: Homepage, https://posthook.io
|
|
6
6
|
Project-URL: Documentation, https://posthook.io/docs
|
|
@@ -354,6 +354,74 @@ delivery = client.signatures.parse_delivery(
|
|
|
354
354
|
)
|
|
355
355
|
```
|
|
356
356
|
|
|
357
|
+
## Async Hooks
|
|
358
|
+
|
|
359
|
+
When [async hooks](https://posthook.io/docs/essentials/async-hooks) are enabled, `parse_delivery()` populates `ack_url` and `nack_url` on the delivery object. Return 202 from your handler and call back when processing completes.
|
|
360
|
+
|
|
361
|
+
### FastAPI
|
|
362
|
+
|
|
363
|
+
```python
|
|
364
|
+
from fastapi import FastAPI, Request, BackgroundTasks, HTTPException
|
|
365
|
+
from fastapi.responses import Response
|
|
366
|
+
import posthook
|
|
367
|
+
|
|
368
|
+
app = FastAPI()
|
|
369
|
+
client = posthook.Posthook("pk_...", signing_key="ph_sk_...")
|
|
370
|
+
|
|
371
|
+
async def process_and_ack(delivery):
|
|
372
|
+
try:
|
|
373
|
+
await process_video(delivery.data["video_id"])
|
|
374
|
+
result = await posthook.async_ack(delivery.ack_url)
|
|
375
|
+
print(f"Applied: {result.applied}")
|
|
376
|
+
except Exception as e:
|
|
377
|
+
await posthook.async_nack(delivery.nack_url, {"error": str(e)})
|
|
378
|
+
|
|
379
|
+
@app.post("/webhooks/process-video")
|
|
380
|
+
async def handle_webhook(request: Request, background_tasks: BackgroundTasks):
|
|
381
|
+
body = await request.body()
|
|
382
|
+
try:
|
|
383
|
+
delivery = client.signatures.parse_delivery(body=body, headers=dict(request.headers))
|
|
384
|
+
except posthook.SignatureVerificationError:
|
|
385
|
+
raise HTTPException(status_code=401)
|
|
386
|
+
|
|
387
|
+
background_tasks.add_task(process_and_ack, delivery)
|
|
388
|
+
return Response(status_code=202)
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
### Callback functions
|
|
392
|
+
|
|
393
|
+
The SDK provides standalone callback functions -- pass the URL from the delivery object:
|
|
394
|
+
|
|
395
|
+
```python
|
|
396
|
+
# Sync (Flask, Django, background workers)
|
|
397
|
+
result = posthook.ack(delivery.ack_url)
|
|
398
|
+
result = posthook.nack(delivery.nack_url, {"error": "processing failed"})
|
|
399
|
+
|
|
400
|
+
# Async (FastAPI, etc.)
|
|
401
|
+
result = await posthook.async_ack(delivery.ack_url)
|
|
402
|
+
result = await posthook.async_nack(delivery.nack_url, {"error": "processing failed"})
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
Both return a `CallbackResult`:
|
|
406
|
+
|
|
407
|
+
```python
|
|
408
|
+
result = posthook.ack(delivery.ack_url)
|
|
409
|
+
print(result.applied) # True if state changed, False if already resolved
|
|
410
|
+
print(result.status) # "completed", "not_found", "conflict", etc.
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
`ack()` and `nack()` return normally for `200`, `404`, and `409` responses. They raise `CallbackError` for `401` (invalid token) and `410` (expired).
|
|
414
|
+
|
|
415
|
+
If processing happens in a separate worker, use the raw callback URLs instead:
|
|
416
|
+
|
|
417
|
+
```python
|
|
418
|
+
queue.enqueue("transcode", {
|
|
419
|
+
"video_id": delivery.data["video_id"],
|
|
420
|
+
"ack_url": delivery.ack_url,
|
|
421
|
+
"nack_url": delivery.nack_url,
|
|
422
|
+
})
|
|
423
|
+
```
|
|
424
|
+
|
|
357
425
|
## Error Handling
|
|
358
426
|
|
|
359
427
|
All API errors extend `PosthookError` and can be caught with `isinstance` or `except`:
|
|
@@ -330,6 +330,74 @@ delivery = client.signatures.parse_delivery(
|
|
|
330
330
|
)
|
|
331
331
|
```
|
|
332
332
|
|
|
333
|
+
## Async Hooks
|
|
334
|
+
|
|
335
|
+
When [async hooks](https://posthook.io/docs/essentials/async-hooks) are enabled, `parse_delivery()` populates `ack_url` and `nack_url` on the delivery object. Return 202 from your handler and call back when processing completes.
|
|
336
|
+
|
|
337
|
+
### FastAPI
|
|
338
|
+
|
|
339
|
+
```python
|
|
340
|
+
from fastapi import FastAPI, Request, BackgroundTasks, HTTPException
|
|
341
|
+
from fastapi.responses import Response
|
|
342
|
+
import posthook
|
|
343
|
+
|
|
344
|
+
app = FastAPI()
|
|
345
|
+
client = posthook.Posthook("pk_...", signing_key="ph_sk_...")
|
|
346
|
+
|
|
347
|
+
async def process_and_ack(delivery):
|
|
348
|
+
try:
|
|
349
|
+
await process_video(delivery.data["video_id"])
|
|
350
|
+
result = await posthook.async_ack(delivery.ack_url)
|
|
351
|
+
print(f"Applied: {result.applied}")
|
|
352
|
+
except Exception as e:
|
|
353
|
+
await posthook.async_nack(delivery.nack_url, {"error": str(e)})
|
|
354
|
+
|
|
355
|
+
@app.post("/webhooks/process-video")
|
|
356
|
+
async def handle_webhook(request: Request, background_tasks: BackgroundTasks):
|
|
357
|
+
body = await request.body()
|
|
358
|
+
try:
|
|
359
|
+
delivery = client.signatures.parse_delivery(body=body, headers=dict(request.headers))
|
|
360
|
+
except posthook.SignatureVerificationError:
|
|
361
|
+
raise HTTPException(status_code=401)
|
|
362
|
+
|
|
363
|
+
background_tasks.add_task(process_and_ack, delivery)
|
|
364
|
+
return Response(status_code=202)
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
### Callback functions
|
|
368
|
+
|
|
369
|
+
The SDK provides standalone callback functions -- pass the URL from the delivery object:
|
|
370
|
+
|
|
371
|
+
```python
|
|
372
|
+
# Sync (Flask, Django, background workers)
|
|
373
|
+
result = posthook.ack(delivery.ack_url)
|
|
374
|
+
result = posthook.nack(delivery.nack_url, {"error": "processing failed"})
|
|
375
|
+
|
|
376
|
+
# Async (FastAPI, etc.)
|
|
377
|
+
result = await posthook.async_ack(delivery.ack_url)
|
|
378
|
+
result = await posthook.async_nack(delivery.nack_url, {"error": "processing failed"})
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
Both return a `CallbackResult`:
|
|
382
|
+
|
|
383
|
+
```python
|
|
384
|
+
result = posthook.ack(delivery.ack_url)
|
|
385
|
+
print(result.applied) # True if state changed, False if already resolved
|
|
386
|
+
print(result.status) # "completed", "not_found", "conflict", etc.
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
`ack()` and `nack()` return normally for `200`, `404`, and `409` responses. They raise `CallbackError` for `401` (invalid token) and `410` (expired).
|
|
390
|
+
|
|
391
|
+
If processing happens in a separate worker, use the raw callback URLs instead:
|
|
392
|
+
|
|
393
|
+
```python
|
|
394
|
+
queue.enqueue("transcode", {
|
|
395
|
+
"video_id": delivery.data["video_id"],
|
|
396
|
+
"ack_url": delivery.ack_url,
|
|
397
|
+
"nack_url": delivery.nack_url,
|
|
398
|
+
})
|
|
399
|
+
```
|
|
400
|
+
|
|
333
401
|
## Error Handling
|
|
334
402
|
|
|
335
403
|
All API errors extend `PosthookError` and can be caught with `isinstance` or `except`:
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
"""Posthook Python SDK — schedule, manage, and verify webhooks."""
|
|
2
2
|
|
|
3
|
+
from ._callbacks import ack, async_ack, async_nack, nack
|
|
3
4
|
from ._client import AsyncPosthook, Posthook
|
|
4
5
|
from ._errors import (
|
|
5
6
|
AuthenticationError,
|
|
6
7
|
BadRequestError,
|
|
8
|
+
CallbackError,
|
|
7
9
|
ForbiddenError,
|
|
8
10
|
InternalServerError,
|
|
9
11
|
NotFoundError,
|
|
@@ -25,6 +27,7 @@ from ._models import (
|
|
|
25
27
|
STRATEGY_EXPONENTIAL,
|
|
26
28
|
STRATEGY_FIXED,
|
|
27
29
|
BulkActionResult,
|
|
30
|
+
CallbackResult,
|
|
28
31
|
Delivery,
|
|
29
32
|
Hook,
|
|
30
33
|
HookRetryOverride,
|
|
@@ -42,7 +45,13 @@ __all__ = [
|
|
|
42
45
|
"HookRetryOverride",
|
|
43
46
|
"QuotaInfo",
|
|
44
47
|
"BulkActionResult",
|
|
48
|
+
"CallbackResult",
|
|
45
49
|
"Delivery",
|
|
50
|
+
# Callbacks
|
|
51
|
+
"ack",
|
|
52
|
+
"nack",
|
|
53
|
+
"async_ack",
|
|
54
|
+
"async_nack",
|
|
46
55
|
# Resources
|
|
47
56
|
"SignaturesService",
|
|
48
57
|
"create_signatures",
|
|
@@ -61,6 +70,7 @@ __all__ = [
|
|
|
61
70
|
"PosthookError",
|
|
62
71
|
"BadRequestError",
|
|
63
72
|
"AuthenticationError",
|
|
73
|
+
"CallbackError",
|
|
64
74
|
"ForbiddenError",
|
|
65
75
|
"NotFoundError",
|
|
66
76
|
"PayloadTooLargeError",
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Sync and async helpers for ack/nack callbacks on async hook deliveries."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from ._errors import CallbackError
|
|
11
|
+
from ._models import CallbackResult
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _parse_callback_response(
|
|
15
|
+
response: httpx.Response,
|
|
16
|
+
action: str,
|
|
17
|
+
expected_status: str,
|
|
18
|
+
) -> CallbackResult:
|
|
19
|
+
"""Parse an ack/nack HTTP response into a CallbackResult.
|
|
20
|
+
|
|
21
|
+
2xx → parse ``{data: {status}}`` from JSON, ``applied = (status == expected)``.
|
|
22
|
+
404 → ``CallbackResult(applied=False, status="not_found")``.
|
|
23
|
+
409 → ``CallbackResult(applied=False, status="conflict")``.
|
|
24
|
+
Other → raise ``CallbackError``.
|
|
25
|
+
"""
|
|
26
|
+
if response.is_success:
|
|
27
|
+
try:
|
|
28
|
+
data = response.json()
|
|
29
|
+
status = data.get("data", {}).get("status", "unknown")
|
|
30
|
+
except Exception:
|
|
31
|
+
status = "unknown"
|
|
32
|
+
return CallbackResult(applied=(status == expected_status), status=status)
|
|
33
|
+
|
|
34
|
+
if response.status_code == 404:
|
|
35
|
+
return CallbackResult(applied=False, status="not_found")
|
|
36
|
+
if response.status_code == 409:
|
|
37
|
+
return CallbackResult(applied=False, status="conflict")
|
|
38
|
+
|
|
39
|
+
text = response.text
|
|
40
|
+
suffix = f": {text}" if text else ""
|
|
41
|
+
raise CallbackError(
|
|
42
|
+
f"{action} failed: {response.status_code}{suffix}",
|
|
43
|
+
status_code=response.status_code,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _prepare_request(body: Any) -> tuple[bytes | None, dict[str, str]]:
|
|
48
|
+
"""Prepare content and headers for a callback request."""
|
|
49
|
+
if body is not None:
|
|
50
|
+
return json.dumps(body).encode(), {"Content-Type": "application/json"}
|
|
51
|
+
return None, {}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def ack(url: str, body: Any = None) -> CallbackResult:
|
|
55
|
+
"""Acknowledge async processing completion (synchronous).
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
url: The ack callback URL from ``delivery.ack_url``.
|
|
59
|
+
body: Optional JSON-serializable body to send with the callback.
|
|
60
|
+
Posthook currently ignores ack bodies.
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
A ``CallbackResult`` indicating whether the ack was applied.
|
|
64
|
+
|
|
65
|
+
Raises:
|
|
66
|
+
CallbackError: For unexpected failures (401, 410, 5xx).
|
|
67
|
+
"""
|
|
68
|
+
content, headers = _prepare_request(body)
|
|
69
|
+
response = httpx.post(url, content=content, headers=headers)
|
|
70
|
+
return _parse_callback_response(response, "ack", "completed")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def nack(url: str, body: Any = None) -> CallbackResult:
|
|
74
|
+
"""Reject async processing — triggers retry or failure (synchronous).
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
url: The nack callback URL from ``delivery.nack_url``.
|
|
78
|
+
body: Optional JSON-serializable body explaining the failure.
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
A ``CallbackResult`` indicating whether the nack was applied.
|
|
82
|
+
|
|
83
|
+
Raises:
|
|
84
|
+
CallbackError: For unexpected failures (401, 410, 5xx).
|
|
85
|
+
"""
|
|
86
|
+
content, headers = _prepare_request(body)
|
|
87
|
+
response = httpx.post(url, content=content, headers=headers)
|
|
88
|
+
return _parse_callback_response(response, "nack", "nacked")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
async def async_ack(url: str, body: Any = None) -> CallbackResult:
|
|
92
|
+
"""Acknowledge async processing completion (asynchronous).
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
url: The ack callback URL from ``delivery.ack_url``.
|
|
96
|
+
body: Optional JSON-serializable body to send with the callback.
|
|
97
|
+
Posthook currently ignores ack bodies.
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
A ``CallbackResult`` indicating whether the ack was applied.
|
|
101
|
+
|
|
102
|
+
Raises:
|
|
103
|
+
CallbackError: For unexpected failures (401, 410, 5xx).
|
|
104
|
+
"""
|
|
105
|
+
content, headers = _prepare_request(body)
|
|
106
|
+
async with httpx.AsyncClient() as client:
|
|
107
|
+
response = await client.post(url, content=content, headers=headers)
|
|
108
|
+
return _parse_callback_response(response, "ack", "completed")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
async def async_nack(url: str, body: Any = None) -> CallbackResult:
|
|
112
|
+
"""Reject async processing — triggers retry or failure (asynchronous).
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
url: The nack callback URL from ``delivery.nack_url``.
|
|
116
|
+
body: Optional JSON-serializable body explaining the failure.
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
A ``CallbackResult`` indicating whether the nack was applied.
|
|
120
|
+
|
|
121
|
+
Raises:
|
|
122
|
+
CallbackError: For unexpected failures (401, 410, 5xx).
|
|
123
|
+
"""
|
|
124
|
+
content, headers = _prepare_request(body)
|
|
125
|
+
async with httpx.AsyncClient() as client:
|
|
126
|
+
response = await client.post(url, content=content, headers=headers)
|
|
127
|
+
return _parse_callback_response(response, "nack", "nacked")
|
|
@@ -97,6 +97,19 @@ class SignatureVerificationError(PosthookError):
|
|
|
97
97
|
super().__init__(message, code="signature_verification_error")
|
|
98
98
|
|
|
99
99
|
|
|
100
|
+
class CallbackError(PosthookError):
|
|
101
|
+
"""Raised when an ack/nack callback fails unexpectedly.
|
|
102
|
+
|
|
103
|
+
This is thrown for non-recoverable failures such as invalid tokens (401),
|
|
104
|
+
expired tokens (410), or server errors (5xx). Expected no-ops like 404
|
|
105
|
+
(hook deleted) and 409 (stale token) are returned as ``CallbackResult``
|
|
106
|
+
with ``applied=False`` instead.
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
def __init__(self, message: str, status_code: int | None = None) -> None:
|
|
110
|
+
super().__init__(message, status_code=status_code, code="callback_error")
|
|
111
|
+
|
|
112
|
+
|
|
100
113
|
def _create_error(
|
|
101
114
|
status_code: int,
|
|
102
115
|
message: str,
|
|
@@ -148,6 +148,21 @@ class BulkActionResult:
|
|
|
148
148
|
|
|
149
149
|
|
|
150
150
|
@dataclass(frozen=True)
|
|
151
|
+
class CallbackResult:
|
|
152
|
+
"""Result of an ack or nack callback.
|
|
153
|
+
|
|
154
|
+
Both ``ack()`` and ``nack()`` return this for all expected outcomes,
|
|
155
|
+
including race conditions where the hook already resolved. Check
|
|
156
|
+
``applied`` to see if your callback changed the hook's state.
|
|
157
|
+
"""
|
|
158
|
+
|
|
159
|
+
applied: bool
|
|
160
|
+
"""Whether the callback changed the hook's state."""
|
|
161
|
+
status: str
|
|
162
|
+
"""The hook's current status (e.g. ``"completed"``, ``"nacked"``, ``"not_found"``)."""
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@dataclass
|
|
151
166
|
class Delivery:
|
|
152
167
|
"""A parsed and verified webhook delivery."""
|
|
153
168
|
|
|
@@ -160,3 +175,7 @@ class Delivery:
|
|
|
160
175
|
posted_at: datetime
|
|
161
176
|
created_at: datetime
|
|
162
177
|
updated_at: datetime
|
|
178
|
+
ack_url: str | None = None
|
|
179
|
+
"""Callback URL for acknowledging async processing. Present when both ack and nack headers exist."""
|
|
180
|
+
nack_url: str | None = None
|
|
181
|
+
"""Callback URL for negative acknowledgement. Present when both ack and nack headers exist."""
|
|
@@ -128,6 +128,11 @@ class SignaturesService:
|
|
|
128
128
|
f"Failed to parse delivery payload: {exc}"
|
|
129
129
|
)
|
|
130
130
|
|
|
131
|
+
# Extract async callback URLs (set both or neither).
|
|
132
|
+
ack_url = _get_header(headers, "Posthook-Ack-URL")
|
|
133
|
+
nack_url = _get_header(headers, "Posthook-Nack-URL")
|
|
134
|
+
has_callbacks = bool(ack_url and nack_url)
|
|
135
|
+
|
|
131
136
|
return Delivery(
|
|
132
137
|
hook_id=hook_id,
|
|
133
138
|
timestamp=timestamp,
|
|
@@ -138,6 +143,8 @@ class SignaturesService:
|
|
|
138
143
|
posted_at=_parse_dt(payload.get("postedAt", "")),
|
|
139
144
|
created_at=_parse_dt(payload.get("createdAt", "")),
|
|
140
145
|
updated_at=_parse_dt(payload.get("updatedAt", "")),
|
|
146
|
+
ack_url=ack_url if has_callbacks else None,
|
|
147
|
+
nack_url=nack_url if has_callbacks else None,
|
|
141
148
|
)
|
|
142
149
|
|
|
143
150
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
VERSION = "1.1.0"
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
import posthook
|
|
9
|
+
from posthook import CallbackError, CallbackResult
|
|
10
|
+
from posthook._callbacks import ack, async_ack, async_nack, nack
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# ─── Helpers ────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _mock_response(
|
|
17
|
+
status_code: int,
|
|
18
|
+
body: dict | None = None,
|
|
19
|
+
text: str = "",
|
|
20
|
+
) -> httpx.Response:
|
|
21
|
+
if body is not None:
|
|
22
|
+
return httpx.Response(status_code, json=body)
|
|
23
|
+
return httpx.Response(status_code, text=text)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# ─── Sync ack() ─────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class TestAckSync:
|
|
30
|
+
def test_success_applied(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
31
|
+
monkeypatch.setattr(
|
|
32
|
+
httpx, "post",
|
|
33
|
+
lambda url, **kw: _mock_response(200, {"data": {"status": "completed"}}),
|
|
34
|
+
)
|
|
35
|
+
result = ack("https://api.posthook.io/ack/token123")
|
|
36
|
+
assert result == CallbackResult(applied=True, status="completed")
|
|
37
|
+
|
|
38
|
+
def test_success_not_applied(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
39
|
+
"""Server returns 200 but status is not 'completed' (idempotent no-op)."""
|
|
40
|
+
monkeypatch.setattr(
|
|
41
|
+
httpx, "post",
|
|
42
|
+
lambda url, **kw: _mock_response(200, {"data": {"status": "nacked"}}),
|
|
43
|
+
)
|
|
44
|
+
result = ack("https://api.posthook.io/ack/token123")
|
|
45
|
+
assert result == CallbackResult(applied=False, status="nacked")
|
|
46
|
+
|
|
47
|
+
def test_404_not_found(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
48
|
+
monkeypatch.setattr(
|
|
49
|
+
httpx, "post",
|
|
50
|
+
lambda url, **kw: _mock_response(404),
|
|
51
|
+
)
|
|
52
|
+
result = ack("https://api.posthook.io/ack/token123")
|
|
53
|
+
assert result == CallbackResult(applied=False, status="not_found")
|
|
54
|
+
|
|
55
|
+
def test_409_conflict(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
56
|
+
monkeypatch.setattr(
|
|
57
|
+
httpx, "post",
|
|
58
|
+
lambda url, **kw: _mock_response(409),
|
|
59
|
+
)
|
|
60
|
+
result = ack("https://api.posthook.io/ack/token123")
|
|
61
|
+
assert result == CallbackResult(applied=False, status="conflict")
|
|
62
|
+
|
|
63
|
+
def test_401_raises_callback_error(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
64
|
+
monkeypatch.setattr(
|
|
65
|
+
httpx, "post",
|
|
66
|
+
lambda url, **kw: _mock_response(401, text="unauthorized"),
|
|
67
|
+
)
|
|
68
|
+
with pytest.raises(CallbackError, match="ack failed: 401"):
|
|
69
|
+
ack("https://api.posthook.io/ack/token123")
|
|
70
|
+
|
|
71
|
+
def test_410_raises_callback_error(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
72
|
+
monkeypatch.setattr(
|
|
73
|
+
httpx, "post",
|
|
74
|
+
lambda url, **kw: _mock_response(410, text="gone"),
|
|
75
|
+
)
|
|
76
|
+
with pytest.raises(CallbackError, match="ack failed: 410"):
|
|
77
|
+
ack("https://api.posthook.io/ack/token123")
|
|
78
|
+
|
|
79
|
+
def test_500_raises_callback_error(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
80
|
+
monkeypatch.setattr(
|
|
81
|
+
httpx, "post",
|
|
82
|
+
lambda url, **kw: _mock_response(500, text="internal error"),
|
|
83
|
+
)
|
|
84
|
+
with pytest.raises(CallbackError, match="ack failed: 500"):
|
|
85
|
+
ack("https://api.posthook.io/ack/token123")
|
|
86
|
+
|
|
87
|
+
def test_json_body_sent(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
88
|
+
"""Verify that a JSON body is serialized and Content-Type is set."""
|
|
89
|
+
captured: dict = {}
|
|
90
|
+
|
|
91
|
+
def mock_post(url: str, **kwargs) -> httpx.Response:
|
|
92
|
+
captured["content"] = kwargs.get("content")
|
|
93
|
+
captured["headers"] = kwargs.get("headers")
|
|
94
|
+
return _mock_response(200, {"data": {"status": "completed"}})
|
|
95
|
+
|
|
96
|
+
monkeypatch.setattr(httpx, "post", mock_post)
|
|
97
|
+
ack("https://api.posthook.io/ack/token123", body={"done": True})
|
|
98
|
+
|
|
99
|
+
assert json.loads(captured["content"]) == {"done": True}
|
|
100
|
+
assert captured["headers"]["Content-Type"] == "application/json"
|
|
101
|
+
|
|
102
|
+
def test_no_body_no_content_type(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
103
|
+
"""Without a body, no Content-Type header should be sent."""
|
|
104
|
+
captured: dict = {}
|
|
105
|
+
|
|
106
|
+
def mock_post(url: str, **kwargs) -> httpx.Response:
|
|
107
|
+
captured["content"] = kwargs.get("content")
|
|
108
|
+
captured["headers"] = kwargs.get("headers")
|
|
109
|
+
return _mock_response(200, {"data": {"status": "completed"}})
|
|
110
|
+
|
|
111
|
+
monkeypatch.setattr(httpx, "post", mock_post)
|
|
112
|
+
ack("https://api.posthook.io/ack/token123")
|
|
113
|
+
|
|
114
|
+
assert captured["content"] is None
|
|
115
|
+
assert captured["headers"] == {}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# ─── Sync nack() ────────────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class TestNackSync:
|
|
122
|
+
def test_success_applied(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
123
|
+
monkeypatch.setattr(
|
|
124
|
+
httpx, "post",
|
|
125
|
+
lambda url, **kw: _mock_response(200, {"data": {"status": "nacked"}}),
|
|
126
|
+
)
|
|
127
|
+
result = nack("https://api.posthook.io/nack/token123")
|
|
128
|
+
assert result == CallbackResult(applied=True, status="nacked")
|
|
129
|
+
|
|
130
|
+
def test_success_not_applied(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
131
|
+
monkeypatch.setattr(
|
|
132
|
+
httpx, "post",
|
|
133
|
+
lambda url, **kw: _mock_response(200, {"data": {"status": "completed"}}),
|
|
134
|
+
)
|
|
135
|
+
result = nack("https://api.posthook.io/nack/token123")
|
|
136
|
+
assert result == CallbackResult(applied=False, status="completed")
|
|
137
|
+
|
|
138
|
+
def test_404_not_found(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
139
|
+
monkeypatch.setattr(
|
|
140
|
+
httpx, "post",
|
|
141
|
+
lambda url, **kw: _mock_response(404),
|
|
142
|
+
)
|
|
143
|
+
result = nack("https://api.posthook.io/nack/token123")
|
|
144
|
+
assert result == CallbackResult(applied=False, status="not_found")
|
|
145
|
+
|
|
146
|
+
def test_409_conflict(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
147
|
+
monkeypatch.setattr(
|
|
148
|
+
httpx, "post",
|
|
149
|
+
lambda url, **kw: _mock_response(409),
|
|
150
|
+
)
|
|
151
|
+
result = nack("https://api.posthook.io/nack/token123")
|
|
152
|
+
assert result == CallbackResult(applied=False, status="conflict")
|
|
153
|
+
|
|
154
|
+
def test_410_raises_callback_error(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
155
|
+
monkeypatch.setattr(
|
|
156
|
+
httpx, "post",
|
|
157
|
+
lambda url, **kw: _mock_response(410, text="gone"),
|
|
158
|
+
)
|
|
159
|
+
with pytest.raises(CallbackError, match="nack failed: 410"):
|
|
160
|
+
nack("https://api.posthook.io/nack/token123")
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# ─── Async ack/nack ─────────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class _MockAsyncClient:
|
|
167
|
+
"""Replaces httpx.AsyncClient for async tests."""
|
|
168
|
+
|
|
169
|
+
def __init__(self, handler):
|
|
170
|
+
self._handler = handler
|
|
171
|
+
|
|
172
|
+
async def __aenter__(self):
|
|
173
|
+
return self
|
|
174
|
+
|
|
175
|
+
async def __aexit__(self, *args):
|
|
176
|
+
pass
|
|
177
|
+
|
|
178
|
+
async def post(self, url, **kwargs):
|
|
179
|
+
return self._handler(url, **kwargs)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class TestAsyncAck:
|
|
183
|
+
async def test_success_applied(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
184
|
+
monkeypatch.setattr(
|
|
185
|
+
httpx, "AsyncClient",
|
|
186
|
+
lambda **kw: _MockAsyncClient(
|
|
187
|
+
lambda url, **kw: _mock_response(200, {"data": {"status": "completed"}}),
|
|
188
|
+
),
|
|
189
|
+
)
|
|
190
|
+
result = await async_ack("https://api.posthook.io/ack/token123")
|
|
191
|
+
assert result == CallbackResult(applied=True, status="completed")
|
|
192
|
+
|
|
193
|
+
async def test_410_raises_callback_error(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
194
|
+
monkeypatch.setattr(
|
|
195
|
+
httpx, "AsyncClient",
|
|
196
|
+
lambda **kw: _MockAsyncClient(
|
|
197
|
+
lambda url, **kw: _mock_response(410, text="gone"),
|
|
198
|
+
),
|
|
199
|
+
)
|
|
200
|
+
with pytest.raises(CallbackError, match="ack failed: 410"):
|
|
201
|
+
await async_ack("https://api.posthook.io/ack/token123")
|
|
202
|
+
|
|
203
|
+
async def test_500_raises_callback_error(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
204
|
+
monkeypatch.setattr(
|
|
205
|
+
httpx, "AsyncClient",
|
|
206
|
+
lambda **kw: _MockAsyncClient(
|
|
207
|
+
lambda url, **kw: _mock_response(500, text="boom"),
|
|
208
|
+
),
|
|
209
|
+
)
|
|
210
|
+
with pytest.raises(CallbackError, match="ack failed: 500"):
|
|
211
|
+
await async_ack("https://api.posthook.io/ack/token123")
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
class TestAsyncNack:
|
|
215
|
+
async def test_success_applied(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
216
|
+
monkeypatch.setattr(
|
|
217
|
+
httpx, "AsyncClient",
|
|
218
|
+
lambda **kw: _MockAsyncClient(
|
|
219
|
+
lambda url, **kw: _mock_response(200, {"data": {"status": "nacked"}}),
|
|
220
|
+
),
|
|
221
|
+
)
|
|
222
|
+
result = await async_nack("https://api.posthook.io/nack/token123")
|
|
223
|
+
assert result == CallbackResult(applied=True, status="nacked")
|
|
224
|
+
|
|
225
|
+
async def test_409_conflict(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
226
|
+
monkeypatch.setattr(
|
|
227
|
+
httpx, "AsyncClient",
|
|
228
|
+
lambda **kw: _MockAsyncClient(
|
|
229
|
+
lambda url, **kw: _mock_response(409),
|
|
230
|
+
),
|
|
231
|
+
)
|
|
232
|
+
result = await async_nack("https://api.posthook.io/nack/token123")
|
|
233
|
+
assert result == CallbackResult(applied=False, status="conflict")
|
|
234
|
+
|
|
235
|
+
async def test_410_raises_callback_error(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
236
|
+
monkeypatch.setattr(
|
|
237
|
+
httpx, "AsyncClient",
|
|
238
|
+
lambda **kw: _MockAsyncClient(
|
|
239
|
+
lambda url, **kw: _mock_response(410, text="gone"),
|
|
240
|
+
),
|
|
241
|
+
)
|
|
242
|
+
with pytest.raises(CallbackError, match="nack failed: 410"):
|
|
243
|
+
await async_nack("https://api.posthook.io/nack/token123")
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
# ─── Importability ───────────────────────────────────────────────────
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
class TestExports:
|
|
250
|
+
def test_callback_result_importable(self) -> None:
|
|
251
|
+
assert hasattr(posthook, "CallbackResult")
|
|
252
|
+
|
|
253
|
+
def test_callback_error_importable(self) -> None:
|
|
254
|
+
assert hasattr(posthook, "CallbackError")
|
|
255
|
+
|
|
256
|
+
def test_ack_importable(self) -> None:
|
|
257
|
+
assert hasattr(posthook, "ack")
|
|
258
|
+
assert hasattr(posthook, "nack")
|
|
259
|
+
assert hasattr(posthook, "async_ack")
|
|
260
|
+
assert hasattr(posthook, "async_nack")
|
|
@@ -204,3 +204,32 @@ class TestCreateSignatures:
|
|
|
204
204
|
def test_importable_from_package(self) -> None:
|
|
205
205
|
assert hasattr(posthook, "create_signatures")
|
|
206
206
|
assert posthook.create_signatures is create_signatures
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
class TestCallbackHeaders:
|
|
210
|
+
"""Test that parse_delivery extracts ack/nack URLs from headers."""
|
|
211
|
+
|
|
212
|
+
def test_both_headers_present(self) -> None:
|
|
213
|
+
svc = SignaturesService("ph_sk_test")
|
|
214
|
+
body, headers = _make_delivery()
|
|
215
|
+
headers["Posthook-Ack-URL"] = "https://api.posthook.io/ack/token123"
|
|
216
|
+
headers["Posthook-Nack-URL"] = "https://api.posthook.io/nack/token123"
|
|
217
|
+
delivery = svc.parse_delivery(body, headers)
|
|
218
|
+
assert delivery.ack_url == "https://api.posthook.io/ack/token123"
|
|
219
|
+
assert delivery.nack_url == "https://api.posthook.io/nack/token123"
|
|
220
|
+
|
|
221
|
+
def test_no_headers(self) -> None:
|
|
222
|
+
svc = SignaturesService("ph_sk_test")
|
|
223
|
+
body, headers = _make_delivery()
|
|
224
|
+
delivery = svc.parse_delivery(body, headers)
|
|
225
|
+
assert delivery.ack_url is None
|
|
226
|
+
assert delivery.nack_url is None
|
|
227
|
+
|
|
228
|
+
def test_only_ack_header(self) -> None:
|
|
229
|
+
"""When only one header is present, both should be None."""
|
|
230
|
+
svc = SignaturesService("ph_sk_test")
|
|
231
|
+
body, headers = _make_delivery()
|
|
232
|
+
headers["Posthook-Ack-URL"] = "https://api.posthook.io/ack/token123"
|
|
233
|
+
delivery = svc.parse_delivery(body, headers)
|
|
234
|
+
assert delivery.ack_url is None
|
|
235
|
+
assert delivery.nack_url is None
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
VERSION = "1.0.0"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|