msaas-webhooks 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.
- msaas_webhooks-0.1.0.dist-info/METADATA +17 -0
- msaas_webhooks-0.1.0.dist-info/RECORD +12 -0
- msaas_webhooks-0.1.0.dist-info/WHEEL +4 -0
- webhooks/__init__.py +28 -0
- webhooks/config.py +52 -0
- webhooks/delivery.py +213 -0
- webhooks/models.py +99 -0
- webhooks/router.py +177 -0
- webhooks/signing.py +70 -0
- webhooks/store.py +83 -0
- webhooks/store_deliveries.py +194 -0
- webhooks/store_endpoints.py +153 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: msaas-webhooks
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Webhook delivery system with HMAC signing, retries, and FastAPI router
|
|
5
|
+
License: MIT
|
|
6
|
+
Requires-Python: >=3.12
|
|
7
|
+
Requires-Dist: asyncpg>=0.30.0
|
|
8
|
+
Requires-Dist: fastapi>=0.115.0
|
|
9
|
+
Requires-Dist: httpx>=0.27.0
|
|
10
|
+
Requires-Dist: msaas-api-core
|
|
11
|
+
Requires-Dist: msaas-errors
|
|
12
|
+
Requires-Dist: pydantic>=2.0
|
|
13
|
+
Provides-Extra: dev
|
|
14
|
+
Requires-Dist: pytest-asyncio>=0.24.0; extra == 'dev'
|
|
15
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
16
|
+
Requires-Dist: respx>=0.22.0; extra == 'dev'
|
|
17
|
+
Requires-Dist: time-machine>=2.13.0; extra == 'dev'
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
webhooks/__init__.py,sha256=DanYCmP0-W27fyE0b9S4auo2hfKKIwBHf2A7TmHe_sc,766
|
|
2
|
+
webhooks/config.py,sha256=OYAsRyCtJJ85jshHh8UuJeiPqlOOa2atELZxMFoTGoU,1436
|
|
3
|
+
webhooks/delivery.py,sha256=QISa6DnfoYDwU2j_wG_KnNPo08-NofKAtIynge_vqIY,6613
|
|
4
|
+
webhooks/models.py,sha256=ZAwuTGUrcjhCX4xxIzEdkN_JmVdEQo9mmPCdaA9Yf74,2367
|
|
5
|
+
webhooks/router.py,sha256=V3xh6Z8zRQSIa3PuFliurb3eK2GholjVPK8buE6GtgQ,6181
|
|
6
|
+
webhooks/signing.py,sha256=edW0sdKJnYCy-913bFyalW2naQSCBqr8KVCb4ex_zUU,1895
|
|
7
|
+
webhooks/store.py,sha256=lCWKiTwwz9lOnQ4DCe97M3AmXzjdFCMta1C6AzWhFm0,2721
|
|
8
|
+
webhooks/store_deliveries.py,sha256=nr3UoYOdFH_NrC6D8F1ykNmIMK3B_ZbE5tBK9ugKedQ,5538
|
|
9
|
+
webhooks/store_endpoints.py,sha256=o77wsKG1uo3EjJl9aBpSiZ2Oop8D6TGOV_GigjZgU_w,4357
|
|
10
|
+
msaas_webhooks-0.1.0.dist-info/METADATA,sha256=9foTo-6BfhSHDucj3ftxhEzgxobluC8qd2N7Fr0ilLY,568
|
|
11
|
+
msaas_webhooks-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
12
|
+
msaas_webhooks-0.1.0.dist-info/RECORD,,
|
webhooks/__init__.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Willian Webhooks -- Webhook delivery system with HMAC signing and retries."""
|
|
2
|
+
|
|
3
|
+
from webhooks.config import WebhookConfig, init_webhooks
|
|
4
|
+
from webhooks.delivery import deliver_webhook, dispatch_event, retry_failed_deliveries
|
|
5
|
+
from webhooks.models import (
|
|
6
|
+
DeliveryStatus,
|
|
7
|
+
WebhookDelivery,
|
|
8
|
+
WebhookEndpoint,
|
|
9
|
+
WebhookEvent,
|
|
10
|
+
)
|
|
11
|
+
from webhooks.router import WebhookRouter
|
|
12
|
+
from webhooks.signing import generate_secret, sign_payload, verify_signature
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"DeliveryStatus",
|
|
16
|
+
"WebhookConfig",
|
|
17
|
+
"WebhookDelivery",
|
|
18
|
+
"WebhookEndpoint",
|
|
19
|
+
"WebhookEvent",
|
|
20
|
+
"WebhookRouter",
|
|
21
|
+
"deliver_webhook",
|
|
22
|
+
"dispatch_event",
|
|
23
|
+
"generate_secret",
|
|
24
|
+
"init_webhooks",
|
|
25
|
+
"retry_failed_deliveries",
|
|
26
|
+
"sign_payload",
|
|
27
|
+
"verify_signature",
|
|
28
|
+
]
|
webhooks/config.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Webhook module configuration and initialization."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncpg
|
|
6
|
+
from pydantic import BaseModel
|
|
7
|
+
|
|
8
|
+
_config: WebhookConfig | None = None
|
|
9
|
+
_pool: asyncpg.Pool | None = None
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class WebhookConfig(BaseModel):
|
|
13
|
+
"""Configuration for the webhook module."""
|
|
14
|
+
|
|
15
|
+
database_url: str
|
|
16
|
+
max_retries: int = 5
|
|
17
|
+
retry_delay_seconds: int = 30
|
|
18
|
+
timeout_seconds: int = 10
|
|
19
|
+
signing_secret_prefix: str = "whsec_"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
async def init_webhooks(config: WebhookConfig) -> None:
|
|
23
|
+
"""Initialize the webhook module with the given configuration.
|
|
24
|
+
|
|
25
|
+
Creates an asyncpg connection pool and stores the config globally.
|
|
26
|
+
Must be called before any store or delivery operations.
|
|
27
|
+
"""
|
|
28
|
+
global _config, _pool
|
|
29
|
+
_config = config
|
|
30
|
+
_pool = await asyncpg.create_pool(dsn=config.database_url, min_size=2, max_size=10)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def get_config() -> WebhookConfig:
|
|
34
|
+
"""Return the current module configuration.
|
|
35
|
+
|
|
36
|
+
Raises:
|
|
37
|
+
RuntimeError: If init_webhooks() has not been called.
|
|
38
|
+
"""
|
|
39
|
+
if _config is None:
|
|
40
|
+
raise RuntimeError("Webhook module not initialized. Call init_webhooks() first.")
|
|
41
|
+
return _config
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def get_pool() -> asyncpg.Pool:
|
|
45
|
+
"""Return the current connection pool.
|
|
46
|
+
|
|
47
|
+
Raises:
|
|
48
|
+
RuntimeError: If init_webhooks() has not been called.
|
|
49
|
+
"""
|
|
50
|
+
if _pool is None:
|
|
51
|
+
raise RuntimeError("Webhook module not initialized. Call init_webhooks() first.")
|
|
52
|
+
return _pool
|
webhooks/delivery.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""Webhook delivery engine with retry support."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import time
|
|
8
|
+
from datetime import UTC, datetime
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from webhooks.config import get_config
|
|
13
|
+
from webhooks.models import (
|
|
14
|
+
DeliveryStatus,
|
|
15
|
+
WebhookDelivery,
|
|
16
|
+
WebhookEndpoint,
|
|
17
|
+
WebhookEvent,
|
|
18
|
+
)
|
|
19
|
+
from webhooks.signing import sign_payload
|
|
20
|
+
from webhooks.store import (
|
|
21
|
+
get_delivery,
|
|
22
|
+
get_endpoint,
|
|
23
|
+
get_failed_deliveries,
|
|
24
|
+
list_endpoints,
|
|
25
|
+
record_delivery,
|
|
26
|
+
update_delivery,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
async def deliver_webhook(
|
|
33
|
+
endpoint: WebhookEndpoint,
|
|
34
|
+
event: WebhookEvent,
|
|
35
|
+
) -> WebhookDelivery:
|
|
36
|
+
"""Send a single webhook HTTP POST to the endpoint.
|
|
37
|
+
|
|
38
|
+
Creates a delivery record, sends the request with signed headers,
|
|
39
|
+
and updates the delivery with the result.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
endpoint: The target webhook endpoint.
|
|
43
|
+
event: The event to deliver.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
The updated WebhookDelivery record.
|
|
47
|
+
"""
|
|
48
|
+
config = get_config()
|
|
49
|
+
payload_str = json.dumps(event.payload, default=str, sort_keys=True)
|
|
50
|
+
timestamp = int(time.time())
|
|
51
|
+
signature = sign_payload(payload_str, endpoint.secret, timestamp)
|
|
52
|
+
|
|
53
|
+
delivery = await record_delivery(
|
|
54
|
+
endpoint_id=endpoint.id,
|
|
55
|
+
event_type=event.event_type,
|
|
56
|
+
payload=event.payload,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
headers = {
|
|
60
|
+
"Content-Type": "application/json",
|
|
61
|
+
"X-Webhook-ID": str(delivery.id),
|
|
62
|
+
"X-Webhook-Timestamp": str(timestamp),
|
|
63
|
+
"X-Webhook-Signature": signature,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
async with httpx.AsyncClient(timeout=config.timeout_seconds) as client:
|
|
68
|
+
response = await client.post(
|
|
69
|
+
endpoint.url,
|
|
70
|
+
content=payload_str,
|
|
71
|
+
headers=headers,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
is_success = 200 <= response.status_code < 300
|
|
75
|
+
status = DeliveryStatus.SUCCESS if is_success else DeliveryStatus.FAILED
|
|
76
|
+
response_body = response.text[:2000] if response.text else None
|
|
77
|
+
|
|
78
|
+
await update_delivery(
|
|
79
|
+
delivery.id,
|
|
80
|
+
status=status,
|
|
81
|
+
attempts=1,
|
|
82
|
+
response_status=response.status_code,
|
|
83
|
+
response_body=response_body,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
if is_success:
|
|
87
|
+
logger.info(
|
|
88
|
+
"Webhook delivered successfully",
|
|
89
|
+
extra={"delivery_id": str(delivery.id), "endpoint_url": endpoint.url},
|
|
90
|
+
)
|
|
91
|
+
else:
|
|
92
|
+
logger.warning(
|
|
93
|
+
"Webhook delivery failed with status %d",
|
|
94
|
+
response.status_code,
|
|
95
|
+
extra={"delivery_id": str(delivery.id), "endpoint_url": endpoint.url},
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
except httpx.HTTPError as exc:
|
|
99
|
+
logger.error(
|
|
100
|
+
"Webhook delivery error: %s",
|
|
101
|
+
str(exc),
|
|
102
|
+
extra={"delivery_id": str(delivery.id), "endpoint_url": endpoint.url},
|
|
103
|
+
)
|
|
104
|
+
await update_delivery(
|
|
105
|
+
delivery.id,
|
|
106
|
+
status=DeliveryStatus.FAILED,
|
|
107
|
+
attempts=1,
|
|
108
|
+
response_body=str(exc)[:2000],
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
# Re-fetch to return the updated state
|
|
112
|
+
updated = await get_delivery(delivery.id)
|
|
113
|
+
return updated or delivery
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
async def retry_failed_deliveries() -> list[WebhookDelivery]:
|
|
117
|
+
"""Retry all failed deliveries that haven't exceeded max attempts.
|
|
118
|
+
|
|
119
|
+
Uses exponential backoff: only retries deliveries whose last attempt
|
|
120
|
+
was at least ``retry_delay_seconds * 2^(attempts-1)`` seconds ago.
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
List of delivery records that were retried.
|
|
124
|
+
"""
|
|
125
|
+
config = get_config()
|
|
126
|
+
failed = await get_failed_deliveries(max_attempts=config.max_retries)
|
|
127
|
+
retried: list[WebhookDelivery] = []
|
|
128
|
+
|
|
129
|
+
now = datetime.now(UTC)
|
|
130
|
+
|
|
131
|
+
for delivery in failed:
|
|
132
|
+
# Exponential backoff check
|
|
133
|
+
if delivery.last_attempt_at is not None:
|
|
134
|
+
backoff = config.retry_delay_seconds * (2 ** (delivery.attempts - 1))
|
|
135
|
+
elapsed = (now - delivery.last_attempt_at).total_seconds()
|
|
136
|
+
if elapsed < backoff:
|
|
137
|
+
continue
|
|
138
|
+
|
|
139
|
+
# Fetch the endpoint to get its URL and secret
|
|
140
|
+
endpoint = await get_endpoint(delivery.endpoint_id)
|
|
141
|
+
if endpoint is None or not endpoint.active:
|
|
142
|
+
continue
|
|
143
|
+
|
|
144
|
+
payload_str = json.dumps(delivery.payload, default=str, sort_keys=True)
|
|
145
|
+
timestamp = int(time.time())
|
|
146
|
+
signature = sign_payload(payload_str, endpoint.secret, timestamp)
|
|
147
|
+
|
|
148
|
+
headers = {
|
|
149
|
+
"Content-Type": "application/json",
|
|
150
|
+
"X-Webhook-ID": str(delivery.id),
|
|
151
|
+
"X-Webhook-Timestamp": str(timestamp),
|
|
152
|
+
"X-Webhook-Signature": signature,
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
try:
|
|
156
|
+
async with httpx.AsyncClient(timeout=config.timeout_seconds) as client:
|
|
157
|
+
response = await client.post(
|
|
158
|
+
endpoint.url,
|
|
159
|
+
content=payload_str,
|
|
160
|
+
headers=headers,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
is_success = 200 <= response.status_code < 300
|
|
164
|
+
status = DeliveryStatus.SUCCESS if is_success else DeliveryStatus.FAILED
|
|
165
|
+
|
|
166
|
+
await update_delivery(
|
|
167
|
+
delivery.id,
|
|
168
|
+
status=status,
|
|
169
|
+
attempts=delivery.attempts + 1,
|
|
170
|
+
response_status=response.status_code,
|
|
171
|
+
response_body=(response.text[:2000] if response.text else None),
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
except httpx.HTTPError as exc:
|
|
175
|
+
await update_delivery(
|
|
176
|
+
delivery.id,
|
|
177
|
+
status=DeliveryStatus.FAILED,
|
|
178
|
+
attempts=delivery.attempts + 1,
|
|
179
|
+
response_body=str(exc)[:2000],
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
updated = await get_delivery(delivery.id)
|
|
183
|
+
if updated:
|
|
184
|
+
retried.append(updated)
|
|
185
|
+
|
|
186
|
+
return retried
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
async def dispatch_event(event_type: str, payload: dict) -> list[WebhookDelivery]:
|
|
190
|
+
"""Dispatch an event to all active endpoints subscribed to it.
|
|
191
|
+
|
|
192
|
+
Args:
|
|
193
|
+
event_type: The event type string (e.g. ``"order.created"``).
|
|
194
|
+
payload: The event payload dictionary.
|
|
195
|
+
|
|
196
|
+
Returns:
|
|
197
|
+
List of delivery records for each endpoint that was notified.
|
|
198
|
+
"""
|
|
199
|
+
endpoints = await list_endpoints(active_only=True)
|
|
200
|
+
event = WebhookEvent(
|
|
201
|
+
event_type=event_type,
|
|
202
|
+
payload=payload,
|
|
203
|
+
timestamp=datetime.now(UTC),
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
deliveries: list[WebhookDelivery] = []
|
|
207
|
+
for endpoint in endpoints:
|
|
208
|
+
# Check if this endpoint subscribes to the event type or uses wildcard
|
|
209
|
+
if "*" in endpoint.events or event_type in endpoint.events:
|
|
210
|
+
delivery = await deliver_webhook(endpoint, event)
|
|
211
|
+
deliveries.append(delivery)
|
|
212
|
+
|
|
213
|
+
return deliveries
|
webhooks/models.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Pydantic models for the webhook module."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import enum
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from typing import Any
|
|
8
|
+
from uuid import UUID
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, Field
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class DeliveryStatus(str, enum.Enum):
|
|
14
|
+
"""Status of a webhook delivery attempt."""
|
|
15
|
+
|
|
16
|
+
PENDING = "pending"
|
|
17
|
+
SUCCESS = "success"
|
|
18
|
+
FAILED = "failed"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class WebhookEndpoint(BaseModel):
|
|
22
|
+
"""A registered webhook endpoint."""
|
|
23
|
+
|
|
24
|
+
id: UUID
|
|
25
|
+
url: str
|
|
26
|
+
events: list[str] = Field(description="Subscribed event types")
|
|
27
|
+
secret: str = Field(description="HMAC signing key")
|
|
28
|
+
active: bool = True
|
|
29
|
+
created_at: datetime
|
|
30
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class WebhookDelivery(BaseModel):
|
|
34
|
+
"""Record of a webhook delivery attempt."""
|
|
35
|
+
|
|
36
|
+
id: UUID
|
|
37
|
+
endpoint_id: UUID
|
|
38
|
+
event_type: str
|
|
39
|
+
payload: dict[str, Any]
|
|
40
|
+
status: DeliveryStatus = DeliveryStatus.PENDING
|
|
41
|
+
attempts: int = 0
|
|
42
|
+
last_attempt_at: datetime | None = None
|
|
43
|
+
response_status: int | None = None
|
|
44
|
+
response_body: str | None = None
|
|
45
|
+
created_at: datetime
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class WebhookEvent(BaseModel):
|
|
49
|
+
"""An event to be dispatched to webhook endpoints."""
|
|
50
|
+
|
|
51
|
+
event_type: str
|
|
52
|
+
payload: dict[str, Any]
|
|
53
|
+
timestamp: datetime
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# --- Request/Response models for the API layer ---
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class CreateEndpointRequest(BaseModel):
|
|
60
|
+
"""Payload for registering a new webhook endpoint."""
|
|
61
|
+
|
|
62
|
+
url: str = Field(min_length=1)
|
|
63
|
+
events: list[str] = Field(min_length=1)
|
|
64
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class UpdateEndpointRequest(BaseModel):
|
|
68
|
+
"""Payload for updating an existing webhook endpoint."""
|
|
69
|
+
|
|
70
|
+
url: str | None = None
|
|
71
|
+
events: list[str] | None = None
|
|
72
|
+
active: bool | None = None
|
|
73
|
+
metadata: dict[str, Any] | None = None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class EndpointResponse(BaseModel):
|
|
77
|
+
"""Response model for a webhook endpoint (hides full secret)."""
|
|
78
|
+
|
|
79
|
+
id: UUID
|
|
80
|
+
url: str
|
|
81
|
+
events: list[str]
|
|
82
|
+
secret: str = Field(description="Signing secret (shown once on creation)")
|
|
83
|
+
active: bool
|
|
84
|
+
created_at: datetime
|
|
85
|
+
metadata: dict[str, Any]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class DeliveryListResponse(BaseModel):
|
|
89
|
+
"""Paginated list of deliveries."""
|
|
90
|
+
|
|
91
|
+
items: list[WebhookDelivery]
|
|
92
|
+
total: int
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class TestEventResponse(BaseModel):
|
|
96
|
+
"""Response after sending a test webhook event."""
|
|
97
|
+
|
|
98
|
+
delivery_id: UUID
|
|
99
|
+
status: DeliveryStatus
|
webhooks/router.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""FastAPI router for webhook management endpoints."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import UTC, datetime
|
|
6
|
+
from uuid import UUID
|
|
7
|
+
|
|
8
|
+
from errors import (
|
|
9
|
+
NotFoundError,
|
|
10
|
+
ValidationError,
|
|
11
|
+
)
|
|
12
|
+
from fastapi import APIRouter, Query
|
|
13
|
+
|
|
14
|
+
from webhooks.delivery import deliver_webhook
|
|
15
|
+
from webhooks.models import (
|
|
16
|
+
CreateEndpointRequest,
|
|
17
|
+
DeliveryListResponse,
|
|
18
|
+
DeliveryStatus,
|
|
19
|
+
EndpointResponse,
|
|
20
|
+
TestEventResponse,
|
|
21
|
+
UpdateEndpointRequest,
|
|
22
|
+
WebhookDelivery,
|
|
23
|
+
WebhookEndpoint,
|
|
24
|
+
WebhookEvent,
|
|
25
|
+
)
|
|
26
|
+
from webhooks.signing import generate_secret
|
|
27
|
+
from webhooks.store import (
|
|
28
|
+
create_endpoint,
|
|
29
|
+
delete_endpoint,
|
|
30
|
+
get_deliveries,
|
|
31
|
+
get_deliveries_count,
|
|
32
|
+
get_delivery,
|
|
33
|
+
get_endpoint,
|
|
34
|
+
list_endpoints,
|
|
35
|
+
update_endpoint,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _endpoint_to_response(ep: WebhookEndpoint) -> EndpointResponse:
|
|
40
|
+
"""Convert internal endpoint model to API response."""
|
|
41
|
+
return EndpointResponse(
|
|
42
|
+
id=ep.id,
|
|
43
|
+
url=ep.url,
|
|
44
|
+
events=ep.events,
|
|
45
|
+
secret=ep.secret,
|
|
46
|
+
active=ep.active,
|
|
47
|
+
created_at=ep.created_at,
|
|
48
|
+
metadata=ep.metadata,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def WebhookRouter(
|
|
53
|
+
*,
|
|
54
|
+
prefix: str = "/webhooks",
|
|
55
|
+
tags: list[str] | None = None,
|
|
56
|
+
) -> APIRouter:
|
|
57
|
+
"""Create a FastAPI router for webhook management endpoints.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
prefix: URL prefix for all routes. Defaults to ``/webhooks``.
|
|
61
|
+
tags: OpenAPI tags for the router.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
A configured APIRouter.
|
|
65
|
+
|
|
66
|
+
Example::
|
|
67
|
+
|
|
68
|
+
router = WebhookRouter()
|
|
69
|
+
app.include_router(router)
|
|
70
|
+
"""
|
|
71
|
+
router = APIRouter(prefix=prefix, tags=tags or ["webhooks"])
|
|
72
|
+
|
|
73
|
+
@router.post("/endpoints", response_model=EndpointResponse, status_code=201)
|
|
74
|
+
async def register_endpoint(body: CreateEndpointRequest) -> EndpointResponse:
|
|
75
|
+
"""Register a new webhook endpoint."""
|
|
76
|
+
secret = generate_secret()
|
|
77
|
+
endpoint = await create_endpoint(
|
|
78
|
+
url=body.url,
|
|
79
|
+
events=body.events,
|
|
80
|
+
secret=secret,
|
|
81
|
+
metadata=body.metadata,
|
|
82
|
+
)
|
|
83
|
+
return _endpoint_to_response(endpoint)
|
|
84
|
+
|
|
85
|
+
@router.get("/endpoints", response_model=list[EndpointResponse])
|
|
86
|
+
async def list_all_endpoints(
|
|
87
|
+
active_only: bool = Query(False, description="Only return active endpoints"),
|
|
88
|
+
) -> list[EndpointResponse]:
|
|
89
|
+
"""List all registered webhook endpoints."""
|
|
90
|
+
endpoints = await list_endpoints(active_only=active_only)
|
|
91
|
+
return [_endpoint_to_response(ep) for ep in endpoints]
|
|
92
|
+
|
|
93
|
+
@router.get("/endpoints/{endpoint_id}", response_model=EndpointResponse)
|
|
94
|
+
async def get_single_endpoint(endpoint_id: UUID) -> EndpointResponse:
|
|
95
|
+
"""Get a single webhook endpoint by ID."""
|
|
96
|
+
endpoint = await get_endpoint(endpoint_id)
|
|
97
|
+
if endpoint is None:
|
|
98
|
+
raise NotFoundError("Endpoint not found")
|
|
99
|
+
return _endpoint_to_response(endpoint)
|
|
100
|
+
|
|
101
|
+
@router.patch("/endpoints/{endpoint_id}", response_model=EndpointResponse)
|
|
102
|
+
async def patch_endpoint(endpoint_id: UUID, body: UpdateEndpointRequest) -> EndpointResponse:
|
|
103
|
+
"""Update an existing webhook endpoint."""
|
|
104
|
+
updated = await update_endpoint(
|
|
105
|
+
endpoint_id,
|
|
106
|
+
url=body.url,
|
|
107
|
+
events=body.events,
|
|
108
|
+
active=body.active,
|
|
109
|
+
metadata=body.metadata,
|
|
110
|
+
)
|
|
111
|
+
if updated is None:
|
|
112
|
+
raise NotFoundError("Endpoint not found")
|
|
113
|
+
return _endpoint_to_response(updated)
|
|
114
|
+
|
|
115
|
+
@router.delete("/endpoints/{endpoint_id}", status_code=204)
|
|
116
|
+
async def remove_endpoint(endpoint_id: UUID) -> None:
|
|
117
|
+
"""Delete a webhook endpoint."""
|
|
118
|
+
deleted = await delete_endpoint(endpoint_id)
|
|
119
|
+
if not deleted:
|
|
120
|
+
raise NotFoundError("Endpoint not found")
|
|
121
|
+
|
|
122
|
+
@router.get("/deliveries", response_model=DeliveryListResponse)
|
|
123
|
+
async def list_deliveries(
|
|
124
|
+
endpoint_id: UUID | None = Query(None, description="Filter by endpoint"),
|
|
125
|
+
status: DeliveryStatus | None = Query(None, description="Filter by status"),
|
|
126
|
+
limit: int = Query(50, ge=1, le=200),
|
|
127
|
+
offset: int = Query(0, ge=0),
|
|
128
|
+
) -> DeliveryListResponse:
|
|
129
|
+
"""List webhook deliveries with optional filters."""
|
|
130
|
+
items = await get_deliveries(
|
|
131
|
+
endpoint_id=endpoint_id,
|
|
132
|
+
status=status,
|
|
133
|
+
limit=limit,
|
|
134
|
+
offset=offset,
|
|
135
|
+
)
|
|
136
|
+
total = await get_deliveries_count(endpoint_id=endpoint_id, status=status)
|
|
137
|
+
return DeliveryListResponse(items=items, total=total)
|
|
138
|
+
|
|
139
|
+
@router.post("/deliveries/{delivery_id}/retry", response_model=WebhookDelivery)
|
|
140
|
+
async def retry_delivery(delivery_id: UUID) -> WebhookDelivery:
|
|
141
|
+
"""Retry a single failed delivery."""
|
|
142
|
+
delivery = await get_delivery(delivery_id)
|
|
143
|
+
if delivery is None:
|
|
144
|
+
raise NotFoundError("Delivery not found")
|
|
145
|
+
if delivery.status != DeliveryStatus.FAILED:
|
|
146
|
+
raise ValidationError("Only failed deliveries can be retried")
|
|
147
|
+
|
|
148
|
+
endpoint = await get_endpoint(delivery.endpoint_id)
|
|
149
|
+
if endpoint is None or not endpoint.active:
|
|
150
|
+
raise ValidationError("Endpoint not found or inactive")
|
|
151
|
+
|
|
152
|
+
event = WebhookEvent(
|
|
153
|
+
event_type=delivery.event_type,
|
|
154
|
+
payload=delivery.payload,
|
|
155
|
+
timestamp=datetime.now(UTC),
|
|
156
|
+
)
|
|
157
|
+
result = await deliver_webhook(endpoint, event)
|
|
158
|
+
return result
|
|
159
|
+
|
|
160
|
+
@router.post("/test/{endpoint_id}", response_model=TestEventResponse)
|
|
161
|
+
async def send_test_event(endpoint_id: UUID) -> TestEventResponse:
|
|
162
|
+
"""Send a test webhook event to the specified endpoint."""
|
|
163
|
+
endpoint = await get_endpoint(endpoint_id)
|
|
164
|
+
if endpoint is None:
|
|
165
|
+
raise NotFoundError("Endpoint not found")
|
|
166
|
+
if not endpoint.active:
|
|
167
|
+
raise ValidationError("Endpoint is inactive")
|
|
168
|
+
|
|
169
|
+
event = WebhookEvent(
|
|
170
|
+
event_type="webhook.test",
|
|
171
|
+
payload={"message": "This is a test webhook event", "endpoint_id": str(endpoint_id)},
|
|
172
|
+
timestamp=datetime.now(UTC),
|
|
173
|
+
)
|
|
174
|
+
delivery = await deliver_webhook(endpoint, event)
|
|
175
|
+
return TestEventResponse(delivery_id=delivery.id, status=delivery.status)
|
|
176
|
+
|
|
177
|
+
return router
|
webhooks/signing.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""HMAC-SHA256 webhook signing and verification."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import hmac
|
|
7
|
+
import secrets
|
|
8
|
+
import time
|
|
9
|
+
|
|
10
|
+
from webhooks.config import get_config
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def generate_secret() -> str:
|
|
14
|
+
"""Generate a new webhook signing secret with the configured prefix.
|
|
15
|
+
|
|
16
|
+
Returns:
|
|
17
|
+
A string like ``whsec_<32-hex-chars>``.
|
|
18
|
+
"""
|
|
19
|
+
config = get_config()
|
|
20
|
+
token = secrets.token_hex(32)
|
|
21
|
+
return f"{config.signing_secret_prefix}{token}"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def sign_payload(payload: str, secret: str, timestamp: int) -> str:
|
|
25
|
+
"""Create an HMAC-SHA256 signature for the given payload.
|
|
26
|
+
|
|
27
|
+
The signed message is ``{timestamp}.{payload}`` to bind the signature to
|
|
28
|
+
a specific point in time.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
payload: The JSON string body to sign.
|
|
32
|
+
secret: The endpoint's signing secret.
|
|
33
|
+
timestamp: Unix timestamp (seconds) when the event was sent.
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
Hex-encoded HMAC-SHA256 signature.
|
|
37
|
+
"""
|
|
38
|
+
message = f"{timestamp}.{payload}"
|
|
39
|
+
return hmac.new(
|
|
40
|
+
secret.encode("utf-8"),
|
|
41
|
+
message.encode("utf-8"),
|
|
42
|
+
hashlib.sha256,
|
|
43
|
+
).hexdigest()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def verify_signature(
|
|
47
|
+
payload: str,
|
|
48
|
+
signature: str,
|
|
49
|
+
secret: str,
|
|
50
|
+
timestamp: int,
|
|
51
|
+
tolerance: int = 300,
|
|
52
|
+
) -> bool:
|
|
53
|
+
"""Verify an HMAC-SHA256 webhook signature.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
payload: The JSON string body that was signed.
|
|
57
|
+
signature: The hex-encoded signature to verify.
|
|
58
|
+
secret: The endpoint's signing secret.
|
|
59
|
+
timestamp: Unix timestamp claimed by the sender.
|
|
60
|
+
tolerance: Maximum allowed age in seconds (default 5 minutes).
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
True if the signature is valid and the timestamp is within tolerance.
|
|
64
|
+
"""
|
|
65
|
+
now = int(time.time())
|
|
66
|
+
if abs(now - timestamp) > tolerance:
|
|
67
|
+
return False
|
|
68
|
+
|
|
69
|
+
expected = sign_payload(payload, secret, timestamp)
|
|
70
|
+
return hmac.compare_digest(expected, signature)
|
webhooks/store.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""PostgreSQL storage operations for webhooks -- facade module.
|
|
2
|
+
|
|
3
|
+
Splits endpoint and delivery operations into separate files for readability,
|
|
4
|
+
but re-exports everything here so callers can ``from webhooks.store import ...``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from webhooks.config import get_pool
|
|
10
|
+
from webhooks.store_deliveries import (
|
|
11
|
+
get_deliveries,
|
|
12
|
+
get_deliveries_count,
|
|
13
|
+
get_delivery,
|
|
14
|
+
get_failed_deliveries,
|
|
15
|
+
record_delivery,
|
|
16
|
+
update_delivery,
|
|
17
|
+
)
|
|
18
|
+
from webhooks.store_endpoints import (
|
|
19
|
+
create_endpoint,
|
|
20
|
+
delete_endpoint,
|
|
21
|
+
get_endpoint,
|
|
22
|
+
list_endpoints,
|
|
23
|
+
update_endpoint,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
async def create_tables() -> None:
|
|
28
|
+
"""Create the webhook_endpoints and webhook_deliveries tables if they do not exist."""
|
|
29
|
+
pool = get_pool()
|
|
30
|
+
async with pool.acquire() as conn:
|
|
31
|
+
await conn.execute("""
|
|
32
|
+
CREATE TABLE IF NOT EXISTS webhook_endpoints (
|
|
33
|
+
id UUID PRIMARY KEY,
|
|
34
|
+
url TEXT NOT NULL,
|
|
35
|
+
events JSONB NOT NULL DEFAULT '[]',
|
|
36
|
+
secret TEXT NOT NULL,
|
|
37
|
+
active BOOLEAN NOT NULL DEFAULT TRUE,
|
|
38
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
39
|
+
metadata JSONB NOT NULL DEFAULT '{}'
|
|
40
|
+
)
|
|
41
|
+
""")
|
|
42
|
+
await conn.execute("""
|
|
43
|
+
CREATE INDEX IF NOT EXISTS idx_webhook_endpoints_active
|
|
44
|
+
ON webhook_endpoints (active) WHERE active = TRUE
|
|
45
|
+
""")
|
|
46
|
+
await conn.execute("""
|
|
47
|
+
CREATE TABLE IF NOT EXISTS webhook_deliveries (
|
|
48
|
+
id UUID PRIMARY KEY,
|
|
49
|
+
endpoint_id UUID NOT NULL REFERENCES webhook_endpoints(id) ON DELETE CASCADE,
|
|
50
|
+
event_type TEXT NOT NULL,
|
|
51
|
+
payload JSONB NOT NULL DEFAULT '{}',
|
|
52
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
53
|
+
attempts INTEGER NOT NULL DEFAULT 0,
|
|
54
|
+
last_attempt_at TIMESTAMPTZ,
|
|
55
|
+
response_status INTEGER,
|
|
56
|
+
response_body TEXT,
|
|
57
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
58
|
+
)
|
|
59
|
+
""")
|
|
60
|
+
await conn.execute("""
|
|
61
|
+
CREATE INDEX IF NOT EXISTS idx_webhook_deliveries_endpoint
|
|
62
|
+
ON webhook_deliveries (endpoint_id, created_at DESC)
|
|
63
|
+
""")
|
|
64
|
+
await conn.execute("""
|
|
65
|
+
CREATE INDEX IF NOT EXISTS idx_webhook_deliveries_failed
|
|
66
|
+
ON webhook_deliveries (status) WHERE status = 'failed'
|
|
67
|
+
""")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
__all__ = [
|
|
71
|
+
"create_endpoint",
|
|
72
|
+
"create_tables",
|
|
73
|
+
"delete_endpoint",
|
|
74
|
+
"get_deliveries",
|
|
75
|
+
"get_deliveries_count",
|
|
76
|
+
"get_delivery",
|
|
77
|
+
"get_endpoint",
|
|
78
|
+
"get_failed_deliveries",
|
|
79
|
+
"list_endpoints",
|
|
80
|
+
"record_delivery",
|
|
81
|
+
"update_delivery",
|
|
82
|
+
"update_endpoint",
|
|
83
|
+
]
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""PostgreSQL storage operations for webhook deliveries (asyncpg, no ORM)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import uuid
|
|
7
|
+
from datetime import UTC, datetime
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from webhooks.config import get_pool
|
|
11
|
+
from webhooks.models import DeliveryStatus, WebhookDelivery
|
|
12
|
+
|
|
13
|
+
_DELIVERY_COLS = (
|
|
14
|
+
"id, endpoint_id, event_type, payload, status, attempts,"
|
|
15
|
+
" last_attempt_at, response_status, response_body, created_at"
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _row_to_delivery(row: dict) -> WebhookDelivery:
|
|
20
|
+
"""Convert an asyncpg Record to a WebhookDelivery model."""
|
|
21
|
+
payload = row["payload"]
|
|
22
|
+
if isinstance(payload, str):
|
|
23
|
+
payload = json.loads(payload)
|
|
24
|
+
return WebhookDelivery(
|
|
25
|
+
id=row["id"],
|
|
26
|
+
endpoint_id=row["endpoint_id"],
|
|
27
|
+
event_type=row["event_type"],
|
|
28
|
+
payload=payload,
|
|
29
|
+
status=DeliveryStatus(row["status"]),
|
|
30
|
+
attempts=row["attempts"],
|
|
31
|
+
last_attempt_at=row["last_attempt_at"],
|
|
32
|
+
response_status=row["response_status"],
|
|
33
|
+
response_body=row["response_body"],
|
|
34
|
+
created_at=row["created_at"],
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
async def record_delivery(
|
|
39
|
+
endpoint_id: uuid.UUID,
|
|
40
|
+
event_type: str,
|
|
41
|
+
payload: dict[str, Any],
|
|
42
|
+
) -> WebhookDelivery:
|
|
43
|
+
"""Record a new pending delivery."""
|
|
44
|
+
pool = get_pool()
|
|
45
|
+
delivery_id = uuid.uuid4()
|
|
46
|
+
now = datetime.now(UTC)
|
|
47
|
+
|
|
48
|
+
async with pool.acquire() as conn:
|
|
49
|
+
await conn.execute(
|
|
50
|
+
"""
|
|
51
|
+
INSERT INTO webhook_deliveries
|
|
52
|
+
(id, endpoint_id, event_type, payload, status, attempts, created_at)
|
|
53
|
+
VALUES ($1, $2, $3, $4::jsonb, 'pending', 0, $5)
|
|
54
|
+
""",
|
|
55
|
+
delivery_id,
|
|
56
|
+
endpoint_id,
|
|
57
|
+
event_type,
|
|
58
|
+
json.dumps(payload),
|
|
59
|
+
now,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
return WebhookDelivery(
|
|
63
|
+
id=delivery_id,
|
|
64
|
+
endpoint_id=endpoint_id,
|
|
65
|
+
event_type=event_type,
|
|
66
|
+
payload=payload,
|
|
67
|
+
status=DeliveryStatus.PENDING,
|
|
68
|
+
attempts=0,
|
|
69
|
+
last_attempt_at=None,
|
|
70
|
+
response_status=None,
|
|
71
|
+
response_body=None,
|
|
72
|
+
created_at=now,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
async def update_delivery(
|
|
77
|
+
delivery_id: uuid.UUID,
|
|
78
|
+
*,
|
|
79
|
+
status: DeliveryStatus,
|
|
80
|
+
attempts: int,
|
|
81
|
+
response_status: int | None = None,
|
|
82
|
+
response_body: str | None = None,
|
|
83
|
+
) -> None:
|
|
84
|
+
"""Update a delivery record after an attempt."""
|
|
85
|
+
pool = get_pool()
|
|
86
|
+
now = datetime.now(UTC)
|
|
87
|
+
async with pool.acquire() as conn:
|
|
88
|
+
await conn.execute(
|
|
89
|
+
"""
|
|
90
|
+
UPDATE webhook_deliveries
|
|
91
|
+
SET status = $1, attempts = $2, last_attempt_at = $3,
|
|
92
|
+
response_status = $4, response_body = $5
|
|
93
|
+
WHERE id = $6
|
|
94
|
+
""",
|
|
95
|
+
status.value,
|
|
96
|
+
attempts,
|
|
97
|
+
now,
|
|
98
|
+
response_status,
|
|
99
|
+
response_body,
|
|
100
|
+
delivery_id,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
async def get_delivery(delivery_id: uuid.UUID) -> WebhookDelivery | None:
|
|
105
|
+
"""Fetch a single delivery by ID."""
|
|
106
|
+
pool = get_pool()
|
|
107
|
+
async with pool.acquire() as conn:
|
|
108
|
+
row = await conn.fetchrow(
|
|
109
|
+
f"SELECT {_DELIVERY_COLS} FROM webhook_deliveries WHERE id = $1",
|
|
110
|
+
delivery_id,
|
|
111
|
+
)
|
|
112
|
+
if row is None:
|
|
113
|
+
return None
|
|
114
|
+
return _row_to_delivery(dict(row))
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
async def get_deliveries(
|
|
118
|
+
endpoint_id: uuid.UUID | None = None,
|
|
119
|
+
status: DeliveryStatus | None = None,
|
|
120
|
+
limit: int = 50,
|
|
121
|
+
offset: int = 0,
|
|
122
|
+
) -> list[WebhookDelivery]:
|
|
123
|
+
"""Fetch deliveries with optional filters."""
|
|
124
|
+
pool = get_pool()
|
|
125
|
+
conditions: list[str] = []
|
|
126
|
+
args: list[Any] = []
|
|
127
|
+
idx = 1
|
|
128
|
+
|
|
129
|
+
if endpoint_id is not None:
|
|
130
|
+
conditions.append(f"endpoint_id = ${idx}")
|
|
131
|
+
args.append(endpoint_id)
|
|
132
|
+
idx += 1
|
|
133
|
+
if status is not None:
|
|
134
|
+
conditions.append(f"status = ${idx}")
|
|
135
|
+
args.append(status.value)
|
|
136
|
+
idx += 1
|
|
137
|
+
|
|
138
|
+
where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
|
|
139
|
+
query = f"""
|
|
140
|
+
SELECT {_DELIVERY_COLS}
|
|
141
|
+
FROM webhook_deliveries {where}
|
|
142
|
+
ORDER BY created_at DESC
|
|
143
|
+
LIMIT ${idx} OFFSET ${idx + 1}
|
|
144
|
+
"""
|
|
145
|
+
args.extend([limit, offset])
|
|
146
|
+
|
|
147
|
+
async with pool.acquire() as conn:
|
|
148
|
+
rows = await conn.fetch(query, *args)
|
|
149
|
+
|
|
150
|
+
return [_row_to_delivery(dict(row)) for row in rows]
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
async def get_deliveries_count(
|
|
154
|
+
endpoint_id: uuid.UUID | None = None,
|
|
155
|
+
status: DeliveryStatus | None = None,
|
|
156
|
+
) -> int:
|
|
157
|
+
"""Count deliveries with optional filters."""
|
|
158
|
+
pool = get_pool()
|
|
159
|
+
conditions: list[str] = []
|
|
160
|
+
args: list[Any] = []
|
|
161
|
+
idx = 1
|
|
162
|
+
|
|
163
|
+
if endpoint_id is not None:
|
|
164
|
+
conditions.append(f"endpoint_id = ${idx}")
|
|
165
|
+
args.append(endpoint_id)
|
|
166
|
+
idx += 1
|
|
167
|
+
if status is not None:
|
|
168
|
+
conditions.append(f"status = ${idx}")
|
|
169
|
+
args.append(status.value)
|
|
170
|
+
idx += 1
|
|
171
|
+
|
|
172
|
+
where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
|
|
173
|
+
query = f"SELECT COUNT(*) FROM webhook_deliveries {where}"
|
|
174
|
+
|
|
175
|
+
async with pool.acquire() as conn:
|
|
176
|
+
count = await conn.fetchval(query, *args)
|
|
177
|
+
|
|
178
|
+
return count or 0
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
async def get_failed_deliveries(max_attempts: int = 5) -> list[WebhookDelivery]:
|
|
182
|
+
"""Fetch failed deliveries that haven't exceeded max retry attempts."""
|
|
183
|
+
pool = get_pool()
|
|
184
|
+
async with pool.acquire() as conn:
|
|
185
|
+
rows = await conn.fetch(
|
|
186
|
+
f"""
|
|
187
|
+
SELECT {_DELIVERY_COLS}
|
|
188
|
+
FROM webhook_deliveries
|
|
189
|
+
WHERE status = 'failed' AND attempts < $1
|
|
190
|
+
ORDER BY created_at ASC
|
|
191
|
+
""",
|
|
192
|
+
max_attempts,
|
|
193
|
+
)
|
|
194
|
+
return [_row_to_delivery(dict(row)) for row in rows]
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""PostgreSQL storage operations for webhook endpoints (asyncpg, no ORM)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import uuid
|
|
7
|
+
from datetime import UTC, datetime
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from webhooks.config import get_pool
|
|
11
|
+
from webhooks.models import WebhookEndpoint
|
|
12
|
+
|
|
13
|
+
_ENDPOINT_COLS = "id, url, events, secret, active, created_at, metadata"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _row_to_endpoint(row: dict) -> WebhookEndpoint:
|
|
17
|
+
"""Convert an asyncpg Record to a WebhookEndpoint model."""
|
|
18
|
+
events = row["events"]
|
|
19
|
+
if isinstance(events, str):
|
|
20
|
+
events = json.loads(events)
|
|
21
|
+
metadata = row["metadata"]
|
|
22
|
+
if isinstance(metadata, str):
|
|
23
|
+
metadata = json.loads(metadata)
|
|
24
|
+
return WebhookEndpoint(
|
|
25
|
+
id=row["id"],
|
|
26
|
+
url=row["url"],
|
|
27
|
+
events=events,
|
|
28
|
+
secret=row["secret"],
|
|
29
|
+
active=row["active"],
|
|
30
|
+
created_at=row["created_at"],
|
|
31
|
+
metadata=metadata,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
async def create_endpoint(
|
|
36
|
+
url: str,
|
|
37
|
+
events: list[str],
|
|
38
|
+
secret: str,
|
|
39
|
+
metadata: dict[str, Any] | None = None,
|
|
40
|
+
) -> WebhookEndpoint:
|
|
41
|
+
"""Insert a new webhook endpoint and return it."""
|
|
42
|
+
pool = get_pool()
|
|
43
|
+
endpoint_id = uuid.uuid4()
|
|
44
|
+
now = datetime.now(UTC)
|
|
45
|
+
meta = metadata or {}
|
|
46
|
+
|
|
47
|
+
async with pool.acquire() as conn:
|
|
48
|
+
await conn.execute(
|
|
49
|
+
"""
|
|
50
|
+
INSERT INTO webhook_endpoints (id, url, events, secret, active, created_at, metadata)
|
|
51
|
+
VALUES ($1, $2, $3::jsonb, $4, TRUE, $5, $6::jsonb)
|
|
52
|
+
""",
|
|
53
|
+
endpoint_id,
|
|
54
|
+
url,
|
|
55
|
+
json.dumps(events),
|
|
56
|
+
secret,
|
|
57
|
+
now,
|
|
58
|
+
json.dumps(meta),
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
return WebhookEndpoint(
|
|
62
|
+
id=endpoint_id,
|
|
63
|
+
url=url,
|
|
64
|
+
events=events,
|
|
65
|
+
secret=secret,
|
|
66
|
+
active=True,
|
|
67
|
+
created_at=now,
|
|
68
|
+
metadata=meta,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
async def get_endpoint(endpoint_id: uuid.UUID) -> WebhookEndpoint | None:
|
|
73
|
+
"""Fetch a single endpoint by ID. Returns None if not found."""
|
|
74
|
+
pool = get_pool()
|
|
75
|
+
async with pool.acquire() as conn:
|
|
76
|
+
row = await conn.fetchrow(
|
|
77
|
+
f"SELECT {_ENDPOINT_COLS} FROM webhook_endpoints WHERE id = $1",
|
|
78
|
+
endpoint_id,
|
|
79
|
+
)
|
|
80
|
+
if row is None:
|
|
81
|
+
return None
|
|
82
|
+
return _row_to_endpoint(dict(row))
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
async def list_endpoints(active_only: bool = False) -> list[WebhookEndpoint]:
|
|
86
|
+
"""List all webhook endpoints, optionally filtering to active ones."""
|
|
87
|
+
pool = get_pool()
|
|
88
|
+
where = "WHERE active = TRUE" if active_only else ""
|
|
89
|
+
async with pool.acquire() as conn:
|
|
90
|
+
rows = await conn.fetch(
|
|
91
|
+
f"SELECT {_ENDPOINT_COLS} FROM webhook_endpoints {where} ORDER BY created_at DESC"
|
|
92
|
+
)
|
|
93
|
+
return [_row_to_endpoint(dict(row)) for row in rows]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
async def update_endpoint(
|
|
97
|
+
endpoint_id: uuid.UUID,
|
|
98
|
+
*,
|
|
99
|
+
url: str | None = None,
|
|
100
|
+
events: list[str] | None = None,
|
|
101
|
+
active: bool | None = None,
|
|
102
|
+
metadata: dict[str, Any] | None = None,
|
|
103
|
+
) -> WebhookEndpoint | None:
|
|
104
|
+
"""Update an endpoint's fields. Returns the updated endpoint or None if not found."""
|
|
105
|
+
pool = get_pool()
|
|
106
|
+
sets: list[str] = []
|
|
107
|
+
args: list[Any] = []
|
|
108
|
+
idx = 1
|
|
109
|
+
|
|
110
|
+
if url is not None:
|
|
111
|
+
sets.append(f"url = ${idx}")
|
|
112
|
+
args.append(url)
|
|
113
|
+
idx += 1
|
|
114
|
+
if events is not None:
|
|
115
|
+
sets.append(f"events = ${idx}::jsonb")
|
|
116
|
+
args.append(json.dumps(events))
|
|
117
|
+
idx += 1
|
|
118
|
+
if active is not None:
|
|
119
|
+
sets.append(f"active = ${idx}")
|
|
120
|
+
args.append(active)
|
|
121
|
+
idx += 1
|
|
122
|
+
if metadata is not None:
|
|
123
|
+
sets.append(f"metadata = ${idx}::jsonb")
|
|
124
|
+
args.append(json.dumps(metadata))
|
|
125
|
+
idx += 1
|
|
126
|
+
|
|
127
|
+
if not sets:
|
|
128
|
+
return await get_endpoint(endpoint_id)
|
|
129
|
+
|
|
130
|
+
args.append(endpoint_id)
|
|
131
|
+
query = f"""
|
|
132
|
+
UPDATE webhook_endpoints SET {", ".join(sets)}
|
|
133
|
+
WHERE id = ${idx}
|
|
134
|
+
RETURNING {_ENDPOINT_COLS}
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
async with pool.acquire() as conn:
|
|
138
|
+
row = await conn.fetchrow(query, *args)
|
|
139
|
+
|
|
140
|
+
if row is None:
|
|
141
|
+
return None
|
|
142
|
+
return _row_to_endpoint(dict(row))
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
async def delete_endpoint(endpoint_id: uuid.UUID) -> bool:
|
|
146
|
+
"""Delete an endpoint by ID. Returns True if it existed."""
|
|
147
|
+
pool = get_pool()
|
|
148
|
+
async with pool.acquire() as conn:
|
|
149
|
+
result = await conn.execute(
|
|
150
|
+
"DELETE FROM webhook_endpoints WHERE id = $1",
|
|
151
|
+
endpoint_id,
|
|
152
|
+
)
|
|
153
|
+
return result == "DELETE 1"
|