Files
gh-christianlouis-dmarq/backend/app/api/api_v1/endpoints/webhooks.py
T
2026-05-23 18:11:44 +02:00

229 lines
7.2 KiB
Python

"""Admin endpoints for outbound webhook event delivery."""
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.security import require_admin_auth
from app.models.webhook import WebhookDelivery, WebhookEndpoint
from app.services.webhook_events import (
SUPPORTED_EVENT_TYPES,
create_webhook_endpoint,
deliver_due_webhooks,
delivery_to_dict,
endpoint_to_dict,
queue_test_webhook,
update_webhook_endpoint,
)
router = APIRouter()
class WebhookEndpointCreate(BaseModel):
"""Create payload for outbound webhook endpoints."""
name: str
url: str
secret: Optional[str] = None
event_types: List[str] = ["*"]
enabled: bool = True
max_attempts: int = 5
timeout_seconds: int = 10
class WebhookEndpointUpdate(BaseModel):
"""Update payload for outbound webhook endpoints."""
name: Optional[str] = None
url: Optional[str] = None
secret: Optional[str] = None
event_types: Optional[List[str]] = None
enabled: Optional[bool] = None
max_attempts: Optional[int] = None
timeout_seconds: Optional[int] = None
class WebhookEndpointResponse(BaseModel):
"""API-safe webhook endpoint metadata."""
id: int
name: str
url: str
event_types: List[str]
enabled: bool
max_attempts: int
timeout_seconds: int
created_at: Optional[str]
updated_at: Optional[str]
last_success_at: Optional[str]
last_failure_at: Optional[str]
failure_count: int
secret_configured: bool
url_encrypted: bool
secret: Optional[str] = None
class WebhookEndpointListResponse(BaseModel):
"""List response for outbound webhook endpoints."""
endpoints: List[WebhookEndpointResponse]
supported_event_types: List[str]
class WebhookDeliveryResponse(BaseModel):
"""API-safe webhook delivery metadata."""
id: int
endpoint_id: int
event_type: str
idempotency_key: str
status: str
attempt_count: int
max_attempts: int
next_attempt_at: Optional[str]
last_attempt_at: Optional[str]
delivered_at: Optional[str]
last_status_code: Optional[int]
last_error: Optional[str]
response_excerpt: Optional[str]
created_at: Optional[str]
updated_at: Optional[str]
class WebhookDeliveryListResponse(BaseModel):
"""List response for outbound webhook deliveries."""
deliveries: List[WebhookDeliveryResponse]
class WebhookTestResponse(BaseModel):
"""Response for a webhook test delivery."""
delivery: WebhookDeliveryResponse
@router.get("", response_model=WebhookEndpointListResponse)
async def list_webhook_endpoints(
db: Session = Depends(get_db),
_auth: dict = Depends(require_admin_auth),
) -> Dict[str, Any]:
"""Return configured outbound webhook endpoints."""
endpoints = db.query(WebhookEndpoint).order_by(WebhookEndpoint.created_at.desc()).all()
return {
"endpoints": [endpoint_to_dict(endpoint) for endpoint in endpoints],
"supported_event_types": SUPPORTED_EVENT_TYPES,
}
@router.post("", response_model=WebhookEndpointResponse)
async def create_webhook(
payload: WebhookEndpointCreate,
db: Session = Depends(get_db),
_auth: dict = Depends(require_admin_auth),
) -> Dict[str, Any]:
"""Create an outbound webhook endpoint."""
try:
endpoint, raw_secret = create_webhook_endpoint(db, **payload.model_dump())
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
body = endpoint_to_dict(endpoint)
body["secret"] = raw_secret
return body
@router.put("/{endpoint_id}", response_model=WebhookEndpointResponse)
async def update_webhook(
endpoint_id: int,
payload: WebhookEndpointUpdate,
db: Session = Depends(get_db),
_auth: dict = Depends(require_admin_auth),
) -> Dict[str, Any]:
"""Update an outbound webhook endpoint."""
endpoint = db.query(WebhookEndpoint).filter(WebhookEndpoint.id == endpoint_id).first()
if endpoint is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Webhook endpoint not found"
)
try:
endpoint, raw_secret = update_webhook_endpoint(
db,
endpoint,
**payload.model_dump(exclude_unset=True),
)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
body = endpoint_to_dict(endpoint)
body["secret"] = raw_secret
return body
@router.delete("/{endpoint_id}", response_model=WebhookEndpointResponse)
async def disable_webhook(
endpoint_id: int,
db: Session = Depends(get_db),
_auth: dict = Depends(require_admin_auth),
) -> Dict[str, Any]:
"""Disable a webhook endpoint without deleting delivery history."""
endpoint = db.query(WebhookEndpoint).filter(WebhookEndpoint.id == endpoint_id).first()
if endpoint is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Webhook endpoint not found"
)
endpoint.enabled = False
db.commit()
db.refresh(endpoint)
body = endpoint_to_dict(endpoint)
body["secret"] = None
return body
@router.get("/deliveries", response_model=WebhookDeliveryListResponse)
async def list_webhook_deliveries(
endpoint_id: Optional[int] = None,
delivery_status: Optional[str] = Query(None, alias="status"),
limit: int = 50,
db: Session = Depends(get_db),
_auth: dict = Depends(require_admin_auth),
) -> Dict[str, Any]:
"""Return recent outbound webhook deliveries."""
query = db.query(WebhookDelivery)
if endpoint_id is not None:
query = query.filter(WebhookDelivery.endpoint_id == endpoint_id)
if delivery_status:
query = query.filter(WebhookDelivery.status == delivery_status)
deliveries = (
query.order_by(WebhookDelivery.created_at.desc(), WebhookDelivery.id.desc())
.limit(max(1, min(limit, 200)))
.all()
)
return {"deliveries": [delivery_to_dict(delivery) for delivery in deliveries]}
@router.post("/{endpoint_id}/test", response_model=WebhookTestResponse)
async def test_webhook(
endpoint_id: int,
db: Session = Depends(get_db),
_auth: dict = Depends(require_admin_auth),
) -> Dict[str, Any]:
"""Queue and immediately attempt a test delivery for an endpoint."""
try:
delivery = queue_test_webhook(db, endpoint_id)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
delivered = deliver_due_webhooks(db, endpoint_id=endpoint_id, limit=1)
return {"delivery": delivery_to_dict(delivered[0] if delivered else delivery)}
@router.post("/deliveries/process", response_model=WebhookDeliveryListResponse)
async def process_due_webhooks(
limit: int = 25,
db: Session = Depends(get_db),
_auth: dict = Depends(require_admin_auth),
) -> Dict[str, Any]:
"""Attempt due pending webhook deliveries."""
deliveries = deliver_due_webhooks(db, limit=max(1, min(limit, 100)))
return {"deliveries": [delivery_to_dict(delivery) for delivery in deliveries]}