feat(backend): webhooks deliver signed alerts from an outbox with retries and a test send

This commit is contained in:
hh committed 2026-10-19 02:57:27 +00:00
1 parent 2bb8ca45fb
commit 9da0c06ac1
8 files changed
+502

No files matched your search

+118
View File
@@ -0,0 +1,118 @@
from typing import Annotated
from urllib.parse import urlsplit
import asyncpg
from dishka.integrations.fastapi import DishkaRoute, FromDishka
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel
from api.webhooks import send_test
from utils.read import webhooks
from utils.read.models import DeliveryView, WebhookView
router = APIRouter(prefix="/api/webhooks", tags=["webhooks"], route_class=DishkaRoute)
MAX_URL = 2048
class WebhookUpdate(BaseModel):
url: str
watch_ids: list[int] | None = None
enabled: bool = True
class WebhookCreate(WebhookUpdate):
account_id: int
def _url(value: str) -> str:
url = value.strip()
parts = urlsplit(url)
if (
len(url) > MAX_URL
or parts.scheme not in {"http", "https"}
or not parts.hostname
):
raise HTTPException(
status_code=422, detail="url must be an http or https address"
)
return url
def _watch_ids(ids: list[int] | None) -> list[int] | None:
return sorted(set(ids)) if ids else None
async def _webhook(pool: asyncpg.Pool, webhook_id: int) -> WebhookView:
webhook = await webhooks.get_webhook(pool, webhook_id)
if webhook is None:
raise HTTPException(status_code=404, detail="webhook not found")
return webhook
@router.get("")
async def list_webhooks(
pool: FromDishka[asyncpg.Pool], account_id: Annotated[int, Query()]
) -> list[WebhookView]:
return await webhooks.list_webhooks(pool, account_id)
@router.post("", status_code=201)
async def create_webhook(
pool: FromDishka[asyncpg.Pool], body: WebhookCreate
) -> WebhookView:
return await webhooks.create_webhook(
pool,
body.account_id,
_url(body.url),
_watch_ids(body.watch_ids),
enabled=body.enabled,
)
@router.put("/{webhook_id}")
async def update_webhook(
pool: FromDishka[asyncpg.Pool], webhook_id: int, body: WebhookUpdate
) -> WebhookView:
webhook = await webhooks.update_webhook(
pool,
webhook_id,
_url(body.url),
_watch_ids(body.watch_ids),
enabled=body.enabled,
)
if webhook is None:
raise HTTPException(status_code=404, detail="webhook not found")
return webhook
@router.delete("/{webhook_id}", status_code=204)
async def delete_webhook(pool: FromDishka[asyncpg.Pool], webhook_id: int) -> None:
if not await webhooks.delete_webhook(pool, webhook_id):
raise HTTPException(status_code=404, detail="webhook not found")
@router.post("/{webhook_id}/secret")
async def rotate_secret(pool: FromDishka[asyncpg.Pool], webhook_id: int) -> WebhookView:
webhook = await webhooks.rotate_secret(pool, webhook_id)
if webhook is None:
raise HTTPException(status_code=404, detail="webhook not found")
return webhook
@router.post("/{webhook_id}/test")
async def test_webhook(pool: FromDishka[asyncpg.Pool], webhook_id: int) -> DeliveryView:
delivery = await send_test(pool, await _webhook(pool, webhook_id))
if delivery is None:
raise HTTPException(status_code=500, detail="test delivery was not recorded")
return delivery
@router.get("/{webhook_id}/deliveries")
async def list_deliveries(
pool: FromDishka[asyncpg.Pool],
webhook_id: int,
limit: Annotated[int, Query(ge=1, le=100)] = webhooks.DELIVERY_LOG,
) -> list[DeliveryView]:
await _webhook(pool, webhook_id)
return await webhooks.list_deliveries(pool, webhook_id, limit)