railhook 2.12.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.
- railhook/__init__.py +87 -0
- railhook/client.py +548 -0
- railhook/errors.py +63 -0
- railhook/types.py +679 -0
- railhook/webhooks.py +273 -0
- railhook-2.12.0.dist-info/METADATA +536 -0
- railhook-2.12.0.dist-info/RECORD +20 -0
- railhook-2.12.0.dist-info/WHEEL +5 -0
- railhook-2.12.0.dist-info/top_level.txt +3 -0
- scripts/live_api_smoke.py +493 -0
- tests/__init__.py +0 -0
- tests/contract/__init__.py +0 -0
- tests/contract/conftest.py +18 -0
- tests/contract/support.py +84 -0
- tests/contract/test_client_contract.py +99 -0
- tests/test_client.py +332 -0
- tests/test_incoming.py +347 -0
- tests/test_package_identity.py +30 -0
- tests/test_standard_webhooks.py +91 -0
- tests/test_webhooks.py +307 -0
railhook/__init__.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Official Python SDK for Railhook."""
|
|
2
|
+
|
|
3
|
+
from .client import Railhook
|
|
4
|
+
from .errors import (
|
|
5
|
+
RailhookError,
|
|
6
|
+
AuthenticationError,
|
|
7
|
+
RateLimitError,
|
|
8
|
+
ValidationError,
|
|
9
|
+
NotFoundError,
|
|
10
|
+
)
|
|
11
|
+
from .webhooks import (
|
|
12
|
+
verify_signature,
|
|
13
|
+
verify_standard_webhook,
|
|
14
|
+
construct_event,
|
|
15
|
+
generate_signature,
|
|
16
|
+
)
|
|
17
|
+
from .types import (
|
|
18
|
+
Event,
|
|
19
|
+
EventResponse,
|
|
20
|
+
Endpoint,
|
|
21
|
+
EndpointCreateParams,
|
|
22
|
+
EndpointUpdateParams,
|
|
23
|
+
Subscription,
|
|
24
|
+
SubscriptionCreateParams,
|
|
25
|
+
Delivery,
|
|
26
|
+
DeliveryAttempt,
|
|
27
|
+
DeliveryListParams,
|
|
28
|
+
DeliveryStatus,
|
|
29
|
+
PaginatedResponse,
|
|
30
|
+
EndpointTestResult,
|
|
31
|
+
RateLimitInfo,
|
|
32
|
+
WebhookEvent,
|
|
33
|
+
IncomingSource,
|
|
34
|
+
IncomingSourceCreateParams,
|
|
35
|
+
IncomingSourceUpdateParams,
|
|
36
|
+
IncomingDestination,
|
|
37
|
+
IncomingDestinationCreateParams,
|
|
38
|
+
IncomingEvent,
|
|
39
|
+
IncomingEventListParams,
|
|
40
|
+
IncomingForwardAttempt,
|
|
41
|
+
ReplayEventResponse,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
__version__ = "2.12.0"
|
|
45
|
+
|
|
46
|
+
# Backward-compatible aliases
|
|
47
|
+
WebhookPlatform = Railhook
|
|
48
|
+
WebhookPlatformError = RailhookError
|
|
49
|
+
|
|
50
|
+
__all__ = [
|
|
51
|
+
"Railhook",
|
|
52
|
+
"RailhookError",
|
|
53
|
+
"WebhookPlatform",
|
|
54
|
+
"WebhookPlatformError",
|
|
55
|
+
"AuthenticationError",
|
|
56
|
+
"RateLimitError",
|
|
57
|
+
"ValidationError",
|
|
58
|
+
"NotFoundError",
|
|
59
|
+
"verify_signature",
|
|
60
|
+
"verify_standard_webhook",
|
|
61
|
+
"construct_event",
|
|
62
|
+
"generate_signature",
|
|
63
|
+
"Event",
|
|
64
|
+
"EventResponse",
|
|
65
|
+
"Endpoint",
|
|
66
|
+
"EndpointCreateParams",
|
|
67
|
+
"EndpointUpdateParams",
|
|
68
|
+
"Subscription",
|
|
69
|
+
"SubscriptionCreateParams",
|
|
70
|
+
"Delivery",
|
|
71
|
+
"DeliveryAttempt",
|
|
72
|
+
"DeliveryListParams",
|
|
73
|
+
"DeliveryStatus",
|
|
74
|
+
"PaginatedResponse",
|
|
75
|
+
"EndpointTestResult",
|
|
76
|
+
"RateLimitInfo",
|
|
77
|
+
"WebhookEvent",
|
|
78
|
+
"IncomingSource",
|
|
79
|
+
"IncomingSourceCreateParams",
|
|
80
|
+
"IncomingSourceUpdateParams",
|
|
81
|
+
"IncomingDestination",
|
|
82
|
+
"IncomingDestinationCreateParams",
|
|
83
|
+
"IncomingEvent",
|
|
84
|
+
"IncomingEventListParams",
|
|
85
|
+
"IncomingForwardAttempt",
|
|
86
|
+
"ReplayEventResponse",
|
|
87
|
+
]
|
railhook/client.py
ADDED
|
@@ -0,0 +1,548 @@
|
|
|
1
|
+
"""Railhook API client."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict, List, Optional
|
|
4
|
+
import requests
|
|
5
|
+
|
|
6
|
+
from .types import (
|
|
7
|
+
Event,
|
|
8
|
+
EventResponse,
|
|
9
|
+
Endpoint,
|
|
10
|
+
EndpointCreateParams,
|
|
11
|
+
EndpointUpdateParams,
|
|
12
|
+
Subscription,
|
|
13
|
+
SubscriptionCreateParams,
|
|
14
|
+
Delivery,
|
|
15
|
+
DeliveryAttempt,
|
|
16
|
+
DeliveryListParams,
|
|
17
|
+
PaginatedResponse,
|
|
18
|
+
EndpointTestResult,
|
|
19
|
+
RateLimitInfo,
|
|
20
|
+
IncomingSource,
|
|
21
|
+
IncomingSourceCreateParams,
|
|
22
|
+
IncomingSourceUpdateParams,
|
|
23
|
+
IncomingDestination,
|
|
24
|
+
IncomingDestinationCreateParams,
|
|
25
|
+
IncomingEvent,
|
|
26
|
+
IncomingEventListParams,
|
|
27
|
+
IncomingForwardAttempt,
|
|
28
|
+
ReplayEventResponse,
|
|
29
|
+
)
|
|
30
|
+
from .errors import (
|
|
31
|
+
RailhookError,
|
|
32
|
+
AuthenticationError,
|
|
33
|
+
RateLimitError,
|
|
34
|
+
ValidationError,
|
|
35
|
+
NotFoundError,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
DEFAULT_BASE_URL = "http://localhost:8080"
|
|
39
|
+
DEFAULT_TIMEOUT = 30
|
|
40
|
+
SDK_VERSION = "2.12.0"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Railhook:
|
|
44
|
+
"""Railhook API client."""
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
api_key: str,
|
|
49
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
50
|
+
timeout: int = DEFAULT_TIMEOUT,
|
|
51
|
+
) -> None:
|
|
52
|
+
if not api_key:
|
|
53
|
+
raise ValueError("API key is required")
|
|
54
|
+
|
|
55
|
+
self.api_key = api_key
|
|
56
|
+
self.base_url = base_url.rstrip("/")
|
|
57
|
+
self.timeout = timeout
|
|
58
|
+
|
|
59
|
+
self.events = Events(self)
|
|
60
|
+
self.endpoints = Endpoints(self)
|
|
61
|
+
self.subscriptions = Subscriptions(self)
|
|
62
|
+
self.deliveries = Deliveries(self)
|
|
63
|
+
self.incoming_sources = IncomingSources(self)
|
|
64
|
+
self.incoming_events = IncomingEventsApi(self)
|
|
65
|
+
|
|
66
|
+
def get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Any:
|
|
67
|
+
"""Generic GET request. Use for endpoints not yet covered by the SDK."""
|
|
68
|
+
return self._request("GET", path, params=params)
|
|
69
|
+
|
|
70
|
+
def post(
|
|
71
|
+
self,
|
|
72
|
+
path: str,
|
|
73
|
+
body: Optional[Dict[str, Any]] = None,
|
|
74
|
+
idempotency_key: Optional[str] = None,
|
|
75
|
+
) -> Any:
|
|
76
|
+
"""Generic POST request. Use for endpoints not yet covered by the SDK."""
|
|
77
|
+
return self._request("POST", path, body=body, idempotency_key=idempotency_key)
|
|
78
|
+
|
|
79
|
+
def put(self, path: str, body: Optional[Dict[str, Any]] = None) -> Any:
|
|
80
|
+
"""Generic PUT request. Use for endpoints not yet covered by the SDK."""
|
|
81
|
+
return self._request("PUT", path, body=body)
|
|
82
|
+
|
|
83
|
+
def patch(self, path: str, body: Optional[Dict[str, Any]] = None) -> Any:
|
|
84
|
+
"""Generic PATCH request. Use for endpoints not yet covered by the SDK."""
|
|
85
|
+
return self._request("PATCH", path, body=body)
|
|
86
|
+
|
|
87
|
+
def delete(self, path: str) -> Any:
|
|
88
|
+
"""Generic DELETE request. Use for endpoints not yet covered by the SDK."""
|
|
89
|
+
return self._request("DELETE", path)
|
|
90
|
+
|
|
91
|
+
def _request(
|
|
92
|
+
self,
|
|
93
|
+
method: str,
|
|
94
|
+
path: str,
|
|
95
|
+
body: Optional[Dict[str, Any]] = None,
|
|
96
|
+
params: Optional[Dict[str, Any]] = None,
|
|
97
|
+
idempotency_key: Optional[str] = None,
|
|
98
|
+
) -> Any:
|
|
99
|
+
url = f"{self.base_url}{path}"
|
|
100
|
+
|
|
101
|
+
headers = {
|
|
102
|
+
"X-API-Key": self.api_key,
|
|
103
|
+
"Content-Type": "application/json",
|
|
104
|
+
"User-Agent": f"railhook-python/{SDK_VERSION}",
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if idempotency_key:
|
|
108
|
+
headers["Idempotency-Key"] = idempotency_key
|
|
109
|
+
|
|
110
|
+
try:
|
|
111
|
+
response = requests.request(
|
|
112
|
+
method=method,
|
|
113
|
+
url=url,
|
|
114
|
+
json=body,
|
|
115
|
+
params=params,
|
|
116
|
+
headers=headers,
|
|
117
|
+
timeout=self.timeout,
|
|
118
|
+
)
|
|
119
|
+
except requests.Timeout:
|
|
120
|
+
raise RailhookError("Request timeout", 0, "timeout")
|
|
121
|
+
except requests.RequestException as e:
|
|
122
|
+
raise RailhookError(str(e), 0, "network_error")
|
|
123
|
+
|
|
124
|
+
rate_limit_info = self._extract_rate_limit_info(response.headers)
|
|
125
|
+
|
|
126
|
+
if response.status_code == 204:
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
try:
|
|
130
|
+
data = response.json() if response.text else {}
|
|
131
|
+
except ValueError:
|
|
132
|
+
data = {}
|
|
133
|
+
|
|
134
|
+
if response.status_code >= 400:
|
|
135
|
+
raise self._handle_error(response.status_code, data, rate_limit_info)
|
|
136
|
+
|
|
137
|
+
return data
|
|
138
|
+
|
|
139
|
+
def _extract_rate_limit_info(
|
|
140
|
+
self, headers: requests.structures.CaseInsensitiveDict
|
|
141
|
+
) -> Optional[RateLimitInfo]:
|
|
142
|
+
limit = headers.get("X-RateLimit-Limit")
|
|
143
|
+
remaining = headers.get("X-RateLimit-Remaining")
|
|
144
|
+
reset = headers.get("X-RateLimit-Reset")
|
|
145
|
+
|
|
146
|
+
if limit and remaining and reset:
|
|
147
|
+
return RateLimitInfo(
|
|
148
|
+
limit=int(limit),
|
|
149
|
+
remaining=int(remaining),
|
|
150
|
+
reset=int(reset),
|
|
151
|
+
)
|
|
152
|
+
return None
|
|
153
|
+
|
|
154
|
+
def _handle_error(
|
|
155
|
+
self,
|
|
156
|
+
status: int,
|
|
157
|
+
body: Dict[str, Any],
|
|
158
|
+
rate_limit_info: Optional[RateLimitInfo],
|
|
159
|
+
) -> RailhookError:
|
|
160
|
+
message = body.get("message", "Unknown error")
|
|
161
|
+
|
|
162
|
+
if status == 401:
|
|
163
|
+
return AuthenticationError(message)
|
|
164
|
+
elif status == 404:
|
|
165
|
+
return NotFoundError(message)
|
|
166
|
+
elif status == 429:
|
|
167
|
+
import time
|
|
168
|
+
# `reset` is a Unix timestamp in seconds (see RateLimitInfo), so the
|
|
169
|
+
# fallback has to be in seconds too.
|
|
170
|
+
info = rate_limit_info or RateLimitInfo(
|
|
171
|
+
limit=0, remaining=0, reset=int(time.time()) + 60
|
|
172
|
+
)
|
|
173
|
+
return RateLimitError(message, info)
|
|
174
|
+
elif status == 400:
|
|
175
|
+
return ValidationError(message, body.get("fieldErrors", {}))
|
|
176
|
+
else:
|
|
177
|
+
return RailhookError(message, status, body.get("error"))
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class Events:
|
|
181
|
+
"""Events API."""
|
|
182
|
+
|
|
183
|
+
def __init__(self, client: Railhook) -> None:
|
|
184
|
+
self._client = client
|
|
185
|
+
|
|
186
|
+
def send(
|
|
187
|
+
self, event: Event, idempotency_key: Optional[str] = None
|
|
188
|
+
) -> EventResponse:
|
|
189
|
+
"""Send an event to be delivered to subscribed endpoints."""
|
|
190
|
+
data = self._client._request(
|
|
191
|
+
"POST",
|
|
192
|
+
"/api/v1/events",
|
|
193
|
+
body={"type": event.type, "data": event.data},
|
|
194
|
+
idempotency_key=idempotency_key,
|
|
195
|
+
)
|
|
196
|
+
return EventResponse.from_dict(data)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class Endpoints:
|
|
200
|
+
"""Endpoints API."""
|
|
201
|
+
|
|
202
|
+
def __init__(self, client: Railhook) -> None:
|
|
203
|
+
self._client = client
|
|
204
|
+
|
|
205
|
+
def create(self, project_id: str, params: EndpointCreateParams) -> Endpoint:
|
|
206
|
+
"""Create a new endpoint."""
|
|
207
|
+
data = self._client._request(
|
|
208
|
+
"POST",
|
|
209
|
+
f"/api/v1/projects/{project_id}/endpoints",
|
|
210
|
+
body=params.to_dict(),
|
|
211
|
+
)
|
|
212
|
+
return Endpoint.from_dict(data)
|
|
213
|
+
|
|
214
|
+
def get(self, project_id: str, endpoint_id: str) -> Endpoint:
|
|
215
|
+
"""Get endpoint by ID."""
|
|
216
|
+
data = self._client._request(
|
|
217
|
+
"GET",
|
|
218
|
+
f"/api/v1/projects/{project_id}/endpoints/{endpoint_id}",
|
|
219
|
+
)
|
|
220
|
+
return Endpoint.from_dict(data)
|
|
221
|
+
|
|
222
|
+
def list(
|
|
223
|
+
self, project_id: str, page: int = 0, size: int = 20
|
|
224
|
+
) -> PaginatedResponse:
|
|
225
|
+
"""List a project's endpoints.
|
|
226
|
+
|
|
227
|
+
The API returns a Spring page envelope here, not a bare array — the
|
|
228
|
+
endpoints themselves are in ``.content`` (iterating the page yields
|
|
229
|
+
them directly).
|
|
230
|
+
"""
|
|
231
|
+
data = self._client._request(
|
|
232
|
+
"GET",
|
|
233
|
+
f"/api/v1/projects/{project_id}/endpoints",
|
|
234
|
+
params={"page": page, "size": size},
|
|
235
|
+
)
|
|
236
|
+
return PaginatedResponse.from_dict(data, Endpoint)
|
|
237
|
+
|
|
238
|
+
def update(
|
|
239
|
+
self, project_id: str, endpoint_id: str, params: EndpointUpdateParams
|
|
240
|
+
) -> Endpoint:
|
|
241
|
+
"""Update endpoint."""
|
|
242
|
+
data = self._client._request(
|
|
243
|
+
"PUT",
|
|
244
|
+
f"/api/v1/projects/{project_id}/endpoints/{endpoint_id}",
|
|
245
|
+
body=params.to_dict(),
|
|
246
|
+
)
|
|
247
|
+
return Endpoint.from_dict(data)
|
|
248
|
+
|
|
249
|
+
def delete(self, project_id: str, endpoint_id: str) -> None:
|
|
250
|
+
"""Delete endpoint."""
|
|
251
|
+
self._client._request(
|
|
252
|
+
"DELETE",
|
|
253
|
+
f"/api/v1/projects/{project_id}/endpoints/{endpoint_id}",
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
def rotate_secret(self, project_id: str, endpoint_id: str) -> Endpoint:
|
|
257
|
+
"""Rotate endpoint webhook secret."""
|
|
258
|
+
data = self._client._request(
|
|
259
|
+
"POST",
|
|
260
|
+
f"/api/v1/projects/{project_id}/endpoints/{endpoint_id}/rotate-secret",
|
|
261
|
+
)
|
|
262
|
+
return Endpoint.from_dict(data)
|
|
263
|
+
|
|
264
|
+
def test(self, project_id: str, endpoint_id: str) -> EndpointTestResult:
|
|
265
|
+
"""Test endpoint connectivity."""
|
|
266
|
+
data = self._client._request(
|
|
267
|
+
"POST",
|
|
268
|
+
f"/api/v1/projects/{project_id}/endpoints/{endpoint_id}/test",
|
|
269
|
+
)
|
|
270
|
+
return EndpointTestResult.from_dict(data)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
class Subscriptions:
|
|
274
|
+
"""Subscriptions API."""
|
|
275
|
+
|
|
276
|
+
def __init__(self, client: Railhook) -> None:
|
|
277
|
+
self._client = client
|
|
278
|
+
|
|
279
|
+
def create(
|
|
280
|
+
self, project_id: str, params: SubscriptionCreateParams
|
|
281
|
+
) -> Subscription:
|
|
282
|
+
"""Create a new subscription."""
|
|
283
|
+
data = self._client._request(
|
|
284
|
+
"POST",
|
|
285
|
+
f"/api/v1/projects/{project_id}/subscriptions",
|
|
286
|
+
body=params.to_dict(),
|
|
287
|
+
)
|
|
288
|
+
return Subscription.from_dict(data)
|
|
289
|
+
|
|
290
|
+
def get(self, project_id: str, subscription_id: str) -> Subscription:
|
|
291
|
+
"""Get subscription by ID."""
|
|
292
|
+
data = self._client._request(
|
|
293
|
+
"GET",
|
|
294
|
+
f"/api/v1/projects/{project_id}/subscriptions/{subscription_id}",
|
|
295
|
+
)
|
|
296
|
+
return Subscription.from_dict(data)
|
|
297
|
+
|
|
298
|
+
def list(self, project_id: str) -> List[Subscription]:
|
|
299
|
+
"""List all subscriptions for a project."""
|
|
300
|
+
data = self._client._request(
|
|
301
|
+
"GET",
|
|
302
|
+
f"/api/v1/projects/{project_id}/subscriptions",
|
|
303
|
+
)
|
|
304
|
+
return [Subscription.from_dict(s) for s in data]
|
|
305
|
+
|
|
306
|
+
def update(
|
|
307
|
+
self,
|
|
308
|
+
project_id: str,
|
|
309
|
+
subscription_id: str,
|
|
310
|
+
event_type: Optional[str] = None,
|
|
311
|
+
enabled: Optional[bool] = None,
|
|
312
|
+
ordering_enabled: Optional[bool] = None,
|
|
313
|
+
max_attempts: Optional[int] = None,
|
|
314
|
+
timeout_seconds: Optional[int] = None,
|
|
315
|
+
retry_delays: Optional[str] = None,
|
|
316
|
+
payload_template: Optional[str] = None,
|
|
317
|
+
custom_headers: Optional[str] = None,
|
|
318
|
+
transformation_id: Optional[str] = None,
|
|
319
|
+
) -> Subscription:
|
|
320
|
+
"""Update subscription."""
|
|
321
|
+
body: Dict[str, Any] = {}
|
|
322
|
+
if event_type is not None:
|
|
323
|
+
body["eventType"] = event_type
|
|
324
|
+
if enabled is not None:
|
|
325
|
+
body["enabled"] = enabled
|
|
326
|
+
if ordering_enabled is not None:
|
|
327
|
+
body["orderingEnabled"] = ordering_enabled
|
|
328
|
+
if max_attempts is not None:
|
|
329
|
+
body["maxAttempts"] = max_attempts
|
|
330
|
+
if timeout_seconds is not None:
|
|
331
|
+
body["timeoutSeconds"] = timeout_seconds
|
|
332
|
+
if retry_delays is not None:
|
|
333
|
+
body["retryDelays"] = retry_delays
|
|
334
|
+
if payload_template is not None:
|
|
335
|
+
body["payloadTemplate"] = payload_template
|
|
336
|
+
if custom_headers is not None:
|
|
337
|
+
body["customHeaders"] = custom_headers
|
|
338
|
+
if transformation_id is not None:
|
|
339
|
+
body["transformationId"] = transformation_id
|
|
340
|
+
|
|
341
|
+
data = self._client._request(
|
|
342
|
+
"PUT",
|
|
343
|
+
f"/api/v1/projects/{project_id}/subscriptions/{subscription_id}",
|
|
344
|
+
body=body,
|
|
345
|
+
)
|
|
346
|
+
return Subscription.from_dict(data)
|
|
347
|
+
|
|
348
|
+
def delete(self, project_id: str, subscription_id: str) -> None:
|
|
349
|
+
"""Delete subscription."""
|
|
350
|
+
self._client._request(
|
|
351
|
+
"DELETE",
|
|
352
|
+
f"/api/v1/projects/{project_id}/subscriptions/{subscription_id}",
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
class Deliveries:
|
|
357
|
+
"""Deliveries API."""
|
|
358
|
+
|
|
359
|
+
def __init__(self, client: Railhook) -> None:
|
|
360
|
+
self._client = client
|
|
361
|
+
|
|
362
|
+
def get(self, delivery_id: str) -> Delivery:
|
|
363
|
+
"""Get delivery by ID."""
|
|
364
|
+
data = self._client._request("GET", f"/api/v1/deliveries/{delivery_id}")
|
|
365
|
+
return Delivery.from_dict(data)
|
|
366
|
+
|
|
367
|
+
def list(
|
|
368
|
+
self, project_id: str, params: Optional[DeliveryListParams] = None
|
|
369
|
+
) -> PaginatedResponse:
|
|
370
|
+
"""List deliveries for a project with optional filters."""
|
|
371
|
+
query_params = (params or DeliveryListParams()).to_params()
|
|
372
|
+
data = self._client._request(
|
|
373
|
+
"GET",
|
|
374
|
+
f"/api/v1/deliveries/projects/{project_id}",
|
|
375
|
+
params=query_params,
|
|
376
|
+
)
|
|
377
|
+
return PaginatedResponse.from_dict(data)
|
|
378
|
+
|
|
379
|
+
def get_attempts(self, delivery_id: str) -> List[DeliveryAttempt]:
|
|
380
|
+
"""Get all delivery attempts."""
|
|
381
|
+
data = self._client._request(
|
|
382
|
+
"GET",
|
|
383
|
+
f"/api/v1/deliveries/{delivery_id}/attempts",
|
|
384
|
+
)
|
|
385
|
+
return [DeliveryAttempt.from_dict(a) for a in data]
|
|
386
|
+
|
|
387
|
+
def replay(self, delivery_id: str) -> None:
|
|
388
|
+
"""Replay a failed delivery."""
|
|
389
|
+
self._client._request("POST", f"/api/v1/deliveries/{delivery_id}/replay")
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
class IncomingSources:
|
|
393
|
+
"""Incoming Sources API."""
|
|
394
|
+
|
|
395
|
+
def __init__(self, client: Railhook) -> None:
|
|
396
|
+
self._client = client
|
|
397
|
+
|
|
398
|
+
def create(
|
|
399
|
+
self, project_id: str, params: IncomingSourceCreateParams
|
|
400
|
+
) -> IncomingSource:
|
|
401
|
+
"""Create a new incoming webhook source."""
|
|
402
|
+
data = self._client._request(
|
|
403
|
+
"POST",
|
|
404
|
+
f"/api/v1/projects/{project_id}/incoming-sources",
|
|
405
|
+
body=params.to_dict(),
|
|
406
|
+
)
|
|
407
|
+
return IncomingSource.from_dict(data)
|
|
408
|
+
|
|
409
|
+
def get(self, project_id: str, source_id: str) -> IncomingSource:
|
|
410
|
+
"""Get incoming source by ID."""
|
|
411
|
+
data = self._client._request(
|
|
412
|
+
"GET",
|
|
413
|
+
f"/api/v1/projects/{project_id}/incoming-sources/{source_id}",
|
|
414
|
+
)
|
|
415
|
+
return IncomingSource.from_dict(data)
|
|
416
|
+
|
|
417
|
+
def list(self, project_id: str) -> PaginatedResponse:
|
|
418
|
+
"""List incoming sources for a project."""
|
|
419
|
+
data = self._client._request(
|
|
420
|
+
"GET",
|
|
421
|
+
f"/api/v1/projects/{project_id}/incoming-sources",
|
|
422
|
+
)
|
|
423
|
+
return PaginatedResponse.from_dict(data, IncomingSource)
|
|
424
|
+
|
|
425
|
+
def update(
|
|
426
|
+
self, project_id: str, source_id: str, params: IncomingSourceUpdateParams
|
|
427
|
+
) -> IncomingSource:
|
|
428
|
+
"""Update incoming source."""
|
|
429
|
+
data = self._client._request(
|
|
430
|
+
"PUT",
|
|
431
|
+
f"/api/v1/projects/{project_id}/incoming-sources/{source_id}",
|
|
432
|
+
body=params.to_dict(),
|
|
433
|
+
)
|
|
434
|
+
return IncomingSource.from_dict(data)
|
|
435
|
+
|
|
436
|
+
def delete(self, project_id: str, source_id: str) -> None:
|
|
437
|
+
"""Delete (disable) incoming source."""
|
|
438
|
+
self._client._request(
|
|
439
|
+
"DELETE",
|
|
440
|
+
f"/api/v1/projects/{project_id}/incoming-sources/{source_id}",
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
# ── Destinations ──
|
|
444
|
+
|
|
445
|
+
def create_destination(
|
|
446
|
+
self,
|
|
447
|
+
project_id: str,
|
|
448
|
+
source_id: str,
|
|
449
|
+
params: IncomingDestinationCreateParams,
|
|
450
|
+
) -> IncomingDestination:
|
|
451
|
+
"""Create a forwarding destination for an incoming source."""
|
|
452
|
+
data = self._client._request(
|
|
453
|
+
"POST",
|
|
454
|
+
f"/api/v1/projects/{project_id}/incoming-sources/{source_id}/destinations",
|
|
455
|
+
body=params.to_dict(),
|
|
456
|
+
)
|
|
457
|
+
return IncomingDestination.from_dict(data)
|
|
458
|
+
|
|
459
|
+
def get_destination(
|
|
460
|
+
self, project_id: str, source_id: str, destination_id: str
|
|
461
|
+
) -> IncomingDestination:
|
|
462
|
+
"""Get destination by ID."""
|
|
463
|
+
data = self._client._request(
|
|
464
|
+
"GET",
|
|
465
|
+
f"/api/v1/projects/{project_id}/incoming-sources/{source_id}/destinations/{destination_id}",
|
|
466
|
+
)
|
|
467
|
+
return IncomingDestination.from_dict(data)
|
|
468
|
+
|
|
469
|
+
def list_destinations(
|
|
470
|
+
self, project_id: str, source_id: str
|
|
471
|
+
) -> PaginatedResponse:
|
|
472
|
+
"""List destinations for an incoming source."""
|
|
473
|
+
data = self._client._request(
|
|
474
|
+
"GET",
|
|
475
|
+
f"/api/v1/projects/{project_id}/incoming-sources/{source_id}/destinations",
|
|
476
|
+
)
|
|
477
|
+
return PaginatedResponse.from_dict(data, IncomingDestination)
|
|
478
|
+
|
|
479
|
+
def update_destination(
|
|
480
|
+
self,
|
|
481
|
+
project_id: str,
|
|
482
|
+
source_id: str,
|
|
483
|
+
destination_id: str,
|
|
484
|
+
params: IncomingDestinationCreateParams,
|
|
485
|
+
) -> IncomingDestination:
|
|
486
|
+
"""Update a forwarding destination."""
|
|
487
|
+
data = self._client._request(
|
|
488
|
+
"PUT",
|
|
489
|
+
f"/api/v1/projects/{project_id}/incoming-sources/{source_id}/destinations/{destination_id}",
|
|
490
|
+
body=params.to_dict(),
|
|
491
|
+
)
|
|
492
|
+
return IncomingDestination.from_dict(data)
|
|
493
|
+
|
|
494
|
+
def delete_destination(
|
|
495
|
+
self, project_id: str, source_id: str, destination_id: str
|
|
496
|
+
) -> None:
|
|
497
|
+
"""Delete a forwarding destination."""
|
|
498
|
+
self._client._request(
|
|
499
|
+
"DELETE",
|
|
500
|
+
f"/api/v1/projects/{project_id}/incoming-sources/{source_id}/destinations/{destination_id}",
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
class IncomingEventsApi:
|
|
505
|
+
"""Incoming Events API."""
|
|
506
|
+
|
|
507
|
+
def __init__(self, client: Railhook) -> None:
|
|
508
|
+
self._client = client
|
|
509
|
+
|
|
510
|
+
def list(
|
|
511
|
+
self, project_id: str, params: Optional[IncomingEventListParams] = None
|
|
512
|
+
) -> PaginatedResponse:
|
|
513
|
+
"""List incoming events for a project."""
|
|
514
|
+
query_params = (params or IncomingEventListParams()).to_params()
|
|
515
|
+
data = self._client._request(
|
|
516
|
+
"GET",
|
|
517
|
+
f"/api/v1/projects/{project_id}/incoming-events",
|
|
518
|
+
params=query_params,
|
|
519
|
+
)
|
|
520
|
+
return PaginatedResponse.from_dict(data, IncomingEvent)
|
|
521
|
+
|
|
522
|
+
def get(self, project_id: str, event_id: str) -> IncomingEvent:
|
|
523
|
+
"""Get incoming event by ID."""
|
|
524
|
+
data = self._client._request(
|
|
525
|
+
"GET",
|
|
526
|
+
f"/api/v1/projects/{project_id}/incoming-events/{event_id}",
|
|
527
|
+
)
|
|
528
|
+
return IncomingEvent.from_dict(data)
|
|
529
|
+
|
|
530
|
+
def get_attempts(
|
|
531
|
+
self, project_id: str, event_id: str
|
|
532
|
+
) -> List[IncomingForwardAttempt]:
|
|
533
|
+
"""Get forward attempts for an incoming event."""
|
|
534
|
+
data = self._client._request(
|
|
535
|
+
"GET",
|
|
536
|
+
f"/api/v1/projects/{project_id}/incoming-events/{event_id}/attempts",
|
|
537
|
+
)
|
|
538
|
+
if isinstance(data, dict) and "content" in data:
|
|
539
|
+
return [IncomingForwardAttempt.from_dict(a) for a in data["content"]]
|
|
540
|
+
return [IncomingForwardAttempt.from_dict(a) for a in data]
|
|
541
|
+
|
|
542
|
+
def replay(self, project_id: str, event_id: str) -> ReplayEventResponse:
|
|
543
|
+
"""Replay an incoming event to all enabled destinations."""
|
|
544
|
+
data = self._client._request(
|
|
545
|
+
"POST",
|
|
546
|
+
f"/api/v1/projects/{project_id}/incoming-events/{event_id}/replay",
|
|
547
|
+
)
|
|
548
|
+
return ReplayEventResponse.from_dict(data)
|
railhook/errors.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Error classes for Railhook SDK."""
|
|
2
|
+
|
|
3
|
+
from typing import Dict, Optional
|
|
4
|
+
from .types import RateLimitInfo
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class RailhookError(Exception):
|
|
8
|
+
"""Base exception for Railhook SDK."""
|
|
9
|
+
|
|
10
|
+
def __init__(
|
|
11
|
+
self, message: str, status: int = 0, code: Optional[str] = None
|
|
12
|
+
) -> None:
|
|
13
|
+
super().__init__(message)
|
|
14
|
+
self.message = message
|
|
15
|
+
self.status = status
|
|
16
|
+
self.code = code
|
|
17
|
+
|
|
18
|
+
def __str__(self) -> str:
|
|
19
|
+
return f"{self.__class__.__name__}: {self.message} (status={self.status})"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class AuthenticationError(RailhookError):
|
|
23
|
+
"""Raised when API key is invalid or missing."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, message: str = "Invalid API key") -> None:
|
|
26
|
+
super().__init__(message, status=401, code="authentication_error")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class RateLimitError(RailhookError):
|
|
30
|
+
"""Raised when rate limit is exceeded."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, message: str, rate_limit_info: RateLimitInfo) -> None:
|
|
33
|
+
super().__init__(message, status=429, code="rate_limit_exceeded")
|
|
34
|
+
self.rate_limit_info = rate_limit_info
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def retry_after_ms(self) -> int:
|
|
38
|
+
"""Milliseconds to wait before retrying.
|
|
39
|
+
|
|
40
|
+
``rate_limit_info.reset`` is the raw ``X-RateLimit-Reset`` header, which
|
|
41
|
+
the API sends as a Unix timestamp in **seconds**; subtracting a
|
|
42
|
+
millisecond clock from it directly always yields 0.
|
|
43
|
+
"""
|
|
44
|
+
import time
|
|
45
|
+
now_ms = int(time.time() * 1000)
|
|
46
|
+
return max(0, self.rate_limit_info.reset * 1000 - now_ms)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ValidationError(RailhookError):
|
|
50
|
+
"""Raised when request validation fails."""
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self, message: str, field_errors: Optional[Dict[str, str]] = None
|
|
54
|
+
) -> None:
|
|
55
|
+
super().__init__(message, status=400, code="validation_error")
|
|
56
|
+
self.field_errors = field_errors or {}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class NotFoundError(RailhookError):
|
|
60
|
+
"""Raised when resource is not found."""
|
|
61
|
+
|
|
62
|
+
def __init__(self, message: str = "Resource not found") -> None:
|
|
63
|
+
super().__init__(message, status=404, code="not_found")
|