Files
beavergram/backend/src/utils/read/webhooks.py
T

199 lines
5.8 KiB
Python

import json
import secrets
from datetime import datetime
import asyncpg
from utils.read.models import DeliveryView, WebhookView
STATUS_PENDING = "pending"
STATUS_SENDING = "sending"
STATUS_DELIVERED = "delivered"
STATUS_FAILED = "failed"
DELIVERY_LOG = 20
_DELIVERY_COLS = (
"id, webhook_id, alert_id, event, status, attempts, next_attempt_at, "
"response_code, last_error, created_at, finished_at"
)
_HOOKS = f"""
SELECT w.id, w.account_id, w.url, w.secret, w.enabled, w.watch_ids,
w.created_at, w.updated_at, to_jsonb(d) AS last_delivery
FROM webhooks w
LEFT JOIN LATERAL (
SELECT {_DELIVERY_COLS} FROM webhook_deliveries
WHERE webhook_id = w.id ORDER BY id DESC LIMIT 1
) d ON true
""" # noqa: S608
_CLAIM = """
UPDATE webhook_deliveries d SET status = 'sending', attempts = d.attempts + 1
FROM webhooks w
WHERE w.id = d.webhook_id AND d.id IN (
SELECT id FROM webhook_deliveries
WHERE status = 'pending' AND next_attempt_at <= now()
ORDER BY next_attempt_at LIMIT $1
FOR UPDATE SKIP LOCKED
)
RETURNING d.id, d.alert_id, d.event, d.attempts, d.body,
w.id AS webhook_id, w.account_id, w.url, w.secret
"""
_FINISH = """
UPDATE webhook_deliveries SET
status = $2, response_code = $3, last_error = $4,
next_attempt_at = coalesce($5, next_attempt_at),
finished_at = CASE WHEN $2 = 'pending' THEN NULL ELSE now() END
WHERE id = $1
"""
def new_secret() -> str:
return secrets.token_hex(32)
def _to_webhook(row: asyncpg.Record) -> WebhookView:
data = dict(row)
last = data.pop("last_delivery")
return WebhookView(
**data, last_delivery=DeliveryView(**json.loads(last)) if last else None
)
async def list_webhooks(pool: asyncpg.Pool, account_id: int) -> list[WebhookView]:
rows = await pool.fetch(
f"{_HOOKS} WHERE w.account_id = $1 ORDER BY w.id", account_id
)
return [_to_webhook(row) for row in rows]
async def get_webhook(pool: asyncpg.Pool, webhook_id: int) -> WebhookView | None:
row = await pool.fetchrow(f"{_HOOKS} WHERE w.id = $1", webhook_id)
return _to_webhook(row) if row else None
async def create_webhook(
pool: asyncpg.Pool,
account_id: int,
url: str,
watch_ids: list[int] | None,
*,
enabled: bool,
) -> WebhookView | None:
webhook_id = await pool.fetchval(
"INSERT INTO webhooks (account_id, url, secret, enabled, watch_ids) "
"VALUES ($1, $2, $3, $4, $5) RETURNING id",
account_id,
url,
new_secret(),
enabled,
watch_ids,
)
return await get_webhook(pool, webhook_id)
async def update_webhook(
pool: asyncpg.Pool,
webhook_id: int,
url: str,
watch_ids: list[int] | None,
*,
enabled: bool,
) -> WebhookView | None:
async with pool.acquire() as conn, conn.transaction():
found = await conn.fetchval(
"UPDATE webhooks SET url = $2, enabled = $3, watch_ids = $4, "
"updated_at = now() WHERE id = $1 RETURNING id",
webhook_id,
url,
enabled,
watch_ids,
)
if found is not None and not enabled:
await conn.execute(
"UPDATE webhook_deliveries SET status = 'failed', "
"last_error = 'endpoint disabled', finished_at = now() "
"WHERE webhook_id = $1 AND status = 'pending'",
webhook_id,
)
return await get_webhook(pool, webhook_id)
async def rotate_secret(pool: asyncpg.Pool, webhook_id: int) -> WebhookView | None:
await pool.execute(
"UPDATE webhooks SET secret = $2, updated_at = now() WHERE id = $1",
webhook_id,
new_secret(),
)
return await get_webhook(pool, webhook_id)
async def delete_webhook(pool: asyncpg.Pool, webhook_id: int) -> bool:
result = await pool.execute("DELETE FROM webhooks WHERE id = $1", webhook_id)
return result.endswith("1")
async def list_deliveries(
pool: asyncpg.Pool, webhook_id: int, limit: int = DELIVERY_LOG
) -> list[DeliveryView]:
rows = await pool.fetch(
f"SELECT {_DELIVERY_COLS} FROM webhook_deliveries " # noqa: S608
"WHERE webhook_id = $1 ORDER BY id DESC LIMIT $2",
webhook_id,
limit,
)
return [DeliveryView(**dict(row)) for row in rows]
async def get_delivery(pool: asyncpg.Pool, delivery_id: int) -> DeliveryView | None:
row = await pool.fetchrow(
f"SELECT {_DELIVERY_COLS} FROM webhook_deliveries WHERE id = $1", # noqa: S608
delivery_id,
)
return DeliveryView(**dict(row)) if row else None
async def start_test(pool: asyncpg.Pool, webhook_id: int, body: str) -> int:
return await pool.fetchval(
"INSERT INTO webhook_deliveries (webhook_id, event, status, attempts, body) "
"VALUES ($1, 'test', 'sending', 1, $2) RETURNING id",
webhook_id,
body,
)
async def release_stuck(pool: asyncpg.Pool) -> None:
await pool.execute(
"UPDATE webhook_deliveries SET status = 'pending' WHERE status = 'sending'"
)
async def claim_due(pool: asyncpg.Pool, limit: int) -> list[asyncpg.Record]:
return await pool.fetch(_CLAIM, limit)
async def save_body(pool: asyncpg.Pool, delivery_id: int, body: str) -> None:
await pool.execute(
"UPDATE webhook_deliveries SET body = $2 WHERE id = $1", delivery_id, body
)
async def finish( # noqa: PLR0913
pool: asyncpg.Pool,
delivery_id: int,
status: str,
response_code: int | None,
error: str | None,
next_attempt_at: datetime | None = None,
) -> None:
await pool.execute(
_FINISH, delivery_id, status, response_code, error, next_attempt_at
)
async def next_due(pool: asyncpg.Pool) -> datetime | None:
return await pool.fetchval(
"SELECT min(next_attempt_at) FROM webhook_deliveries WHERE status = 'pending'"
)