feat(backend): webhooks deliver signed alerts from an outbox with retries and a test send
This commit is contained in:
1 parent
2bb8ca45fb
commit
9da0c06ac1
8 files changed
+502
No files matched your search
@@ -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)
|
||||
Reference in new issue
Block a user