hooksniff 0.1.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.
- hooksniff/__init__.py +72 -0
- hooksniff/client.py +247 -0
- hooksniff/exceptions.py +46 -0
- hooksniff/models.py +373 -0
- hooksniff/utils.py +209 -0
- hooksniff/verify.py +662 -0
- hooksniff-0.1.0.dist-info/METADATA +242 -0
- hooksniff-0.1.0.dist-info/RECORD +12 -0
- hooksniff-0.1.0.dist-info/WHEEL +5 -0
- hooksniff-0.1.0.dist-info/top_level.txt +2 -0
- tests/__init__.py +1 -0
- tests/test_client.py +88 -0
hooksniff/__init__.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""
|
|
2
|
+
HookSniff Python SDK - Enterprise webhook delivery service client.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from .client import HookSniffClient
|
|
6
|
+
from .models import (
|
|
7
|
+
Endpoint,
|
|
8
|
+
Delivery,
|
|
9
|
+
DeliveryAttempt,
|
|
10
|
+
DeliveryList,
|
|
11
|
+
BatchResult,
|
|
12
|
+
Stats,
|
|
13
|
+
RetryPolicy,
|
|
14
|
+
OrderCreatedPayload,
|
|
15
|
+
OrderCompletedPayload,
|
|
16
|
+
PaymentFailedPayload,
|
|
17
|
+
PaymentSucceededPayload,
|
|
18
|
+
UserRegisteredPayload,
|
|
19
|
+
UserUpdatedPayload,
|
|
20
|
+
InvoiceCreatedPayload,
|
|
21
|
+
)
|
|
22
|
+
from .exceptions import (
|
|
23
|
+
HookSniffError,
|
|
24
|
+
AuthenticationError,
|
|
25
|
+
NotFoundError,
|
|
26
|
+
RateLimitError,
|
|
27
|
+
ValidationError,
|
|
28
|
+
PayloadTooLargeError,
|
|
29
|
+
)
|
|
30
|
+
from .utils import verify_signature, verify_webhook_signature, WebhookHandler
|
|
31
|
+
from .verify import (
|
|
32
|
+
WebhookEvent,
|
|
33
|
+
WebhookVerifier,
|
|
34
|
+
verify_webhook,
|
|
35
|
+
verify_webhook_request,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
__version__ = "0.1.0"
|
|
39
|
+
__all__ = [
|
|
40
|
+
"HookSniffClient",
|
|
41
|
+
# Models
|
|
42
|
+
"Endpoint",
|
|
43
|
+
"Delivery",
|
|
44
|
+
"DeliveryAttempt",
|
|
45
|
+
"DeliveryList",
|
|
46
|
+
"BatchResult",
|
|
47
|
+
"Stats",
|
|
48
|
+
# Webhook payload types
|
|
49
|
+
"OrderCreatedPayload",
|
|
50
|
+
"OrderCompletedPayload",
|
|
51
|
+
"PaymentFailedPayload",
|
|
52
|
+
"PaymentSucceededPayload",
|
|
53
|
+
"UserRegisteredPayload",
|
|
54
|
+
"UserUpdatedPayload",
|
|
55
|
+
"InvoiceCreatedPayload",
|
|
56
|
+
# Exceptions
|
|
57
|
+
"HookSniffError",
|
|
58
|
+
"AuthenticationError",
|
|
59
|
+
"NotFoundError",
|
|
60
|
+
"RateLimitError",
|
|
61
|
+
"ValidationError",
|
|
62
|
+
"PayloadTooLargeError",
|
|
63
|
+
# Legacy webhook verification (utils)
|
|
64
|
+
"verify_signature",
|
|
65
|
+
"verify_webhook_signature",
|
|
66
|
+
"WebhookHandler",
|
|
67
|
+
# Standard Webhooks verification (verify)
|
|
68
|
+
"WebhookEvent",
|
|
69
|
+
"WebhookVerifier",
|
|
70
|
+
"verify_webhook",
|
|
71
|
+
"verify_webhook_request",
|
|
72
|
+
]
|
hooksniff/client.py
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""HookSniff API client."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict, List, Optional
|
|
4
|
+
from urllib.parse import urljoin
|
|
5
|
+
|
|
6
|
+
import requests
|
|
7
|
+
|
|
8
|
+
from .exceptions import (
|
|
9
|
+
AuthenticationError,
|
|
10
|
+
HookSniffError,
|
|
11
|
+
NotFoundError,
|
|
12
|
+
PayloadTooLargeError,
|
|
13
|
+
RateLimitError,
|
|
14
|
+
ValidationError,
|
|
15
|
+
)
|
|
16
|
+
from .models import (
|
|
17
|
+
BatchResult,
|
|
18
|
+
Delivery,
|
|
19
|
+
DeliveryAttempt,
|
|
20
|
+
DeliveryList,
|
|
21
|
+
Endpoint,
|
|
22
|
+
RetryPolicy,
|
|
23
|
+
Stats,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class _EndpointsResource:
|
|
28
|
+
"""Endpoints CRUD operations."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, client: "HookSniffClient"):
|
|
31
|
+
self._client = client
|
|
32
|
+
|
|
33
|
+
def create(
|
|
34
|
+
self,
|
|
35
|
+
url: str,
|
|
36
|
+
description: Optional[str] = None,
|
|
37
|
+
retry_policy: Optional[RetryPolicy] = None,
|
|
38
|
+
) -> Endpoint:
|
|
39
|
+
"""Create a new webhook endpoint."""
|
|
40
|
+
data: Dict[str, Any] = {"url": url}
|
|
41
|
+
if description is not None:
|
|
42
|
+
data["description"] = description
|
|
43
|
+
if retry_policy is not None:
|
|
44
|
+
data["retry_policy"] = retry_policy.to_dict()
|
|
45
|
+
|
|
46
|
+
resp = self._client._request("POST", "/endpoints", json=data)
|
|
47
|
+
return Endpoint.from_dict(resp)
|
|
48
|
+
|
|
49
|
+
def get(self, endpoint_id: str) -> Endpoint:
|
|
50
|
+
"""Get an endpoint by ID."""
|
|
51
|
+
resp = self._client._request("GET", f"/endpoints/{endpoint_id}")
|
|
52
|
+
return Endpoint.from_dict(resp)
|
|
53
|
+
|
|
54
|
+
def list(self, page: int = 1, per_page: int = 20) -> Dict[str, Any]:
|
|
55
|
+
"""List endpoints with pagination."""
|
|
56
|
+
params = {"page": page, "per_page": per_page}
|
|
57
|
+
resp = self._client._request("GET", "/endpoints", params=params)
|
|
58
|
+
return {
|
|
59
|
+
"endpoints": [Endpoint.from_dict(e) for e in (resp.get("endpoints", resp) if isinstance(resp, dict) else resp)],
|
|
60
|
+
"total": resp.get("total", 0) if isinstance(resp, dict) else len(resp),
|
|
61
|
+
"page": resp.get("page", page) if isinstance(resp, dict) else page,
|
|
62
|
+
"per_page": resp.get("per_page", per_page) if isinstance(resp, dict) else per_page,
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
def delete(self, endpoint_id: str) -> bool:
|
|
66
|
+
"""Delete an endpoint."""
|
|
67
|
+
resp = self._client._request("DELETE", f"/endpoints/{endpoint_id}")
|
|
68
|
+
return resp.get("deleted", False)
|
|
69
|
+
|
|
70
|
+
def rotate_secret(self, endpoint_id: str) -> Dict[str, Any]:
|
|
71
|
+
"""Rotate the signing secret for an endpoint."""
|
|
72
|
+
return self._client._request("POST", f"/endpoints/{endpoint_id}/rotate-secret")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class _WebhooksResource:
|
|
76
|
+
"""Webhooks operations."""
|
|
77
|
+
|
|
78
|
+
def __init__(self, client: "HookSniffClient"):
|
|
79
|
+
self._client = client
|
|
80
|
+
|
|
81
|
+
def send(
|
|
82
|
+
self,
|
|
83
|
+
endpoint_id: str,
|
|
84
|
+
event: Optional[str] = None,
|
|
85
|
+
data: Optional[Dict[str, Any]] = None,
|
|
86
|
+
) -> Delivery:
|
|
87
|
+
"""Send a webhook."""
|
|
88
|
+
payload: Dict[str, Any] = {"endpoint_id": endpoint_id, "data": data or {}}
|
|
89
|
+
if event is not None:
|
|
90
|
+
payload["event"] = event
|
|
91
|
+
|
|
92
|
+
resp = self._client._request("POST", "/webhooks", json=payload)
|
|
93
|
+
return Delivery.from_dict(resp)
|
|
94
|
+
|
|
95
|
+
def get(self, delivery_id: str) -> Delivery:
|
|
96
|
+
"""Get a delivery by ID."""
|
|
97
|
+
resp = self._client._request("GET", f"/webhooks/{delivery_id}")
|
|
98
|
+
return Delivery.from_dict(resp)
|
|
99
|
+
|
|
100
|
+
def list(
|
|
101
|
+
self,
|
|
102
|
+
status: Optional[str] = None,
|
|
103
|
+
page: int = 1,
|
|
104
|
+
per_page: int = 20,
|
|
105
|
+
) -> DeliveryList:
|
|
106
|
+
"""List deliveries with optional filters."""
|
|
107
|
+
params: Dict[str, Any] = {"page": page, "per_page": per_page}
|
|
108
|
+
if status is not None:
|
|
109
|
+
params["status"] = status
|
|
110
|
+
|
|
111
|
+
resp = self._client._request("GET", "/webhooks", params=params)
|
|
112
|
+
return DeliveryList.from_dict(resp)
|
|
113
|
+
|
|
114
|
+
def replay(self, delivery_id: str) -> Delivery:
|
|
115
|
+
"""Replay a webhook delivery."""
|
|
116
|
+
resp = self._client._request("POST", f"/webhooks/{delivery_id}/replay")
|
|
117
|
+
return Delivery.from_dict(resp)
|
|
118
|
+
|
|
119
|
+
def batch(self, webhooks: List[Dict[str, Any]]) -> BatchResult:
|
|
120
|
+
"""Send multiple webhooks in a batch."""
|
|
121
|
+
resp = self._client._request(
|
|
122
|
+
"POST", "/webhooks/batch", json={"webhooks": webhooks}
|
|
123
|
+
)
|
|
124
|
+
return BatchResult.from_dict(resp)
|
|
125
|
+
|
|
126
|
+
def attempts(self, delivery_id: str) -> List[DeliveryAttempt]:
|
|
127
|
+
"""Get delivery attempts for a webhook."""
|
|
128
|
+
resp = self._client._request("GET", f"/webhooks/{delivery_id}/attempts")
|
|
129
|
+
return [DeliveryAttempt.from_dict(a) for a in resp]
|
|
130
|
+
|
|
131
|
+
def export(
|
|
132
|
+
self,
|
|
133
|
+
format: str = "json",
|
|
134
|
+
status: Optional[str] = None,
|
|
135
|
+
date_from: Optional[str] = None,
|
|
136
|
+
date_to: Optional[str] = None,
|
|
137
|
+
) -> Any:
|
|
138
|
+
"""Export webhook logs."""
|
|
139
|
+
params: Dict[str, Any] = {"format": format}
|
|
140
|
+
if status is not None:
|
|
141
|
+
params["status"] = status
|
|
142
|
+
if date_from is not None:
|
|
143
|
+
params["date_from"] = date_from
|
|
144
|
+
if date_to is not None:
|
|
145
|
+
params["date_to"] = date_to
|
|
146
|
+
|
|
147
|
+
resp = self._client._request("GET", "/webhooks/export", params=params)
|
|
148
|
+
return resp
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class HookSniffClient:
|
|
153
|
+
"""
|
|
154
|
+
HookSniff API client.
|
|
155
|
+
|
|
156
|
+
Usage:
|
|
157
|
+
from hooksniff import HookSniffClient
|
|
158
|
+
|
|
159
|
+
client = HookSniffClient(api_key="hr_live_...")
|
|
160
|
+
|
|
161
|
+
# Create endpoint
|
|
162
|
+
endpoint = client.endpoints.create(url="https://myapp.com/webhook")
|
|
163
|
+
|
|
164
|
+
# Send webhook
|
|
165
|
+
delivery = client.webhooks.send(
|
|
166
|
+
endpoint_id=endpoint.id,
|
|
167
|
+
event="order.created",
|
|
168
|
+
data={"order_id": "12345"}
|
|
169
|
+
)
|
|
170
|
+
"""
|
|
171
|
+
|
|
172
|
+
def __init__(
|
|
173
|
+
self,
|
|
174
|
+
api_key: str,
|
|
175
|
+
base_url: str = "https://hooksniff-api-1046140057667.europe-west1.run.app/v1",
|
|
176
|
+
timeout: int = 30,
|
|
177
|
+
):
|
|
178
|
+
self._api_key = api_key
|
|
179
|
+
self._base_url = base_url.rstrip("/")
|
|
180
|
+
self._timeout = timeout
|
|
181
|
+
self._session = requests.Session()
|
|
182
|
+
self._session.headers.update(
|
|
183
|
+
{
|
|
184
|
+
"Authorization": f"Bearer {api_key}",
|
|
185
|
+
"Content-Type": "application/json",
|
|
186
|
+
"User-Agent": "hooksniff-python/0.1.0",
|
|
187
|
+
}
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
self.endpoints = _EndpointsResource(self)
|
|
191
|
+
self.webhooks = _WebhooksResource(self)
|
|
192
|
+
|
|
193
|
+
def _request(
|
|
194
|
+
self,
|
|
195
|
+
method: str,
|
|
196
|
+
path: str,
|
|
197
|
+
json: Optional[Dict[str, Any]] = None,
|
|
198
|
+
params: Optional[Dict[str, Any]] = None,
|
|
199
|
+
) -> Any:
|
|
200
|
+
"""Make an API request."""
|
|
201
|
+
url = urljoin(self._base_url + "/", path.lstrip("/"))
|
|
202
|
+
|
|
203
|
+
try:
|
|
204
|
+
response = self._session.request(
|
|
205
|
+
method=method,
|
|
206
|
+
url=url,
|
|
207
|
+
json=json,
|
|
208
|
+
params=params,
|
|
209
|
+
timeout=self._timeout,
|
|
210
|
+
)
|
|
211
|
+
except requests.RequestException as e:
|
|
212
|
+
raise HookSniffError(f"Request failed: {e}") from e
|
|
213
|
+
|
|
214
|
+
if response.status_code == 200:
|
|
215
|
+
# Handle CSV export
|
|
216
|
+
content_type = response.headers.get("content-type", "")
|
|
217
|
+
if "text/csv" in content_type:
|
|
218
|
+
return response.text
|
|
219
|
+
return response.json()
|
|
220
|
+
|
|
221
|
+
# Handle errors
|
|
222
|
+
try:
|
|
223
|
+
error_body = response.json()
|
|
224
|
+
message = error_body.get("error", {}).get("message", response.text)
|
|
225
|
+
except (ValueError, KeyError):
|
|
226
|
+
message = response.text or f"HTTP {response.status_code}"
|
|
227
|
+
|
|
228
|
+
if response.status_code == 400:
|
|
229
|
+
raise ValidationError(message)
|
|
230
|
+
elif response.status_code == 401:
|
|
231
|
+
raise AuthenticationError(message)
|
|
232
|
+
elif response.status_code == 404:
|
|
233
|
+
raise NotFoundError(message)
|
|
234
|
+
elif response.status_code == 413:
|
|
235
|
+
raise PayloadTooLargeError(message)
|
|
236
|
+
elif response.status_code == 429:
|
|
237
|
+
raise RateLimitError(message)
|
|
238
|
+
else:
|
|
239
|
+
raise HookSniffError(
|
|
240
|
+
message,
|
|
241
|
+
status_code=response.status_code,
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
def get_stats(self) -> Stats:
|
|
245
|
+
"""Get delivery statistics."""
|
|
246
|
+
resp = self._request("GET", "/stats")
|
|
247
|
+
return Stats.from_dict(resp)
|
hooksniff/exceptions.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Custom exceptions for HookSniff SDK."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class HookSniffError(Exception):
|
|
5
|
+
"""Base exception for all HookSniff errors."""
|
|
6
|
+
|
|
7
|
+
def __init__(self, message: str, status_code: int = None, error_code: str = None):
|
|
8
|
+
self.message = message
|
|
9
|
+
self.status_code = status_code
|
|
10
|
+
self.error_code = error_code
|
|
11
|
+
super().__init__(self.message)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class AuthenticationError(HookSniffError):
|
|
15
|
+
"""Raised when the API key is invalid or missing."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, message: str = "Unauthorized: invalid or missing API key"):
|
|
18
|
+
super().__init__(message, status_code=401, error_code="UNAUTHORIZED")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class NotFoundError(HookSniffError):
|
|
22
|
+
"""Raised when a resource is not found."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, message: str = "Resource not found"):
|
|
25
|
+
super().__init__(message, status_code=404, error_code="NOT_FOUND")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class RateLimitError(HookSniffError):
|
|
29
|
+
"""Raised when rate limit is exceeded."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, message: str = "Rate limit exceeded"):
|
|
32
|
+
super().__init__(message, status_code=429, error_code="RATE_LIMIT_EXCEEDED")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ValidationError(HookSniffError):
|
|
36
|
+
"""Raised when the request is invalid."""
|
|
37
|
+
|
|
38
|
+
def __init__(self, message: str = "Bad request"):
|
|
39
|
+
super().__init__(message, status_code=400, error_code="BAD_REQUEST")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class PayloadTooLargeError(HookSniffError):
|
|
43
|
+
"""Raised when the webhook payload exceeds the maximum size."""
|
|
44
|
+
|
|
45
|
+
def __init__(self, message: str = "Payload too large"):
|
|
46
|
+
super().__init__(message, status_code=413, error_code="PAYLOAD_TOO_LARGE")
|