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: webhook = await webhooks.create_webhook( pool, body.account_id, _url(body.url), _watch_ids(body.watch_ids), enabled=body.enabled, ) if webhook is None: raise HTTPException(status_code=500, detail="webhook was not saved") return webhook @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)