e60914127c
Add WebhookConfig model, CRUD API endpoints, HMAC-SHA256 signed delivery, and Celery-based async dispatch with retry/backoff for document events (document.uploaded, document.processed, document.failed). Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
207 lines
7.1 KiB
Python
207 lines
7.1 KiB
Python
"""API endpoints for managing webhook configurations.
|
|
|
|
Provides CRUD operations for webhook configs that notify external systems
|
|
when document events occur (``document.uploaded``, ``document.processed``,
|
|
``document.failed``).
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
from typing import Annotated, Any
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
|
from pydantic import BaseModel, Field
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.database import get_db
|
|
from app.models import WebhookConfig
|
|
from app.utils.webhook import VALID_EVENTS
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter(prefix="/webhooks", tags=["webhooks"])
|
|
|
|
DbSession = Annotated[Session, Depends(get_db)]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Auth helper (reuse the pattern from settings API)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _require_admin(request: Request) -> dict:
|
|
"""Ensure the caller is an admin. Raises 403 otherwise."""
|
|
user = request.session.get("user")
|
|
if not user or not user.get("is_admin"):
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
|
|
return user
|
|
|
|
|
|
AdminUser = Annotated[dict, Depends(_require_admin)]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Pydantic schemas
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class WebhookCreate(BaseModel):
|
|
"""Schema for creating a new webhook configuration."""
|
|
|
|
url: str = Field(..., min_length=1, max_length=2048, description="Target URL for webhook delivery")
|
|
secret: str | None = Field(default=None, max_length=512, description="Shared secret for HMAC-SHA256 signatures")
|
|
events: list[str] = Field(..., min_length=1, description="List of events to subscribe to")
|
|
is_active: bool = Field(default=True, description="Whether the webhook is active")
|
|
description: str | None = Field(default=None, max_length=500, description="Optional human-readable description")
|
|
|
|
|
|
class WebhookUpdate(BaseModel):
|
|
"""Schema for updating an existing webhook configuration."""
|
|
|
|
url: str | None = Field(default=None, min_length=1, max_length=2048)
|
|
secret: str | None = Field(default=None, max_length=512)
|
|
events: list[str] | None = Field(default=None, min_length=1)
|
|
is_active: bool | None = None
|
|
description: str | None = Field(default=None, max_length=500)
|
|
|
|
|
|
class WebhookResponse(BaseModel):
|
|
"""Schema returned to clients (secret is never exposed)."""
|
|
|
|
id: int
|
|
url: str
|
|
events: list[str]
|
|
is_active: bool
|
|
description: str | None
|
|
has_secret: bool
|
|
|
|
model_config = {"from_attributes": True}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _validate_events(events: list[str]) -> None:
|
|
"""Raise 422 if any event name is not recognised."""
|
|
invalid = set(events) - VALID_EVENTS
|
|
if invalid:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
detail=f"Invalid event(s): {', '.join(sorted(invalid))}. Valid events: {', '.join(sorted(VALID_EVENTS))}",
|
|
)
|
|
|
|
|
|
def _to_response(cfg: WebhookConfig) -> dict[str, Any]:
|
|
"""Convert a DB model instance to a response dict."""
|
|
try:
|
|
events = json.loads(cfg.events)
|
|
except (json.JSONDecodeError, TypeError):
|
|
events = []
|
|
return {
|
|
"id": cfg.id,
|
|
"url": cfg.url,
|
|
"events": events,
|
|
"is_active": cfg.is_active,
|
|
"description": cfg.description,
|
|
"has_secret": cfg.secret is not None and len(cfg.secret) > 0,
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Endpoints
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.get("/", summary="List all webhook configurations")
|
|
def list_webhooks(db: DbSession, _admin: AdminUser) -> list[dict[str, Any]]:
|
|
"""Return all webhook configurations. Secrets are never included."""
|
|
configs = db.query(WebhookConfig).order_by(WebhookConfig.id).all()
|
|
return [_to_response(c) for c in configs]
|
|
|
|
|
|
@router.get("/{webhook_id}", summary="Get a single webhook configuration")
|
|
def get_webhook(webhook_id: int, db: DbSession, _admin: AdminUser) -> dict[str, Any]:
|
|
"""Return a single webhook configuration by ID."""
|
|
cfg = db.query(WebhookConfig).filter(WebhookConfig.id == webhook_id).first()
|
|
if not cfg:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Webhook not found")
|
|
return _to_response(cfg)
|
|
|
|
|
|
@router.post("/", status_code=status.HTTP_201_CREATED, summary="Create a webhook configuration")
|
|
def create_webhook(body: WebhookCreate, db: DbSession, _admin: AdminUser) -> dict[str, Any]:
|
|
"""Create a new webhook configuration."""
|
|
_validate_events(body.events)
|
|
|
|
cfg = WebhookConfig(
|
|
url=body.url,
|
|
secret=body.secret,
|
|
events=json.dumps(sorted(body.events)),
|
|
is_active=body.is_active,
|
|
description=body.description,
|
|
)
|
|
try:
|
|
db.add(cfg)
|
|
db.commit()
|
|
db.refresh(cfg)
|
|
except Exception:
|
|
db.rollback()
|
|
raise
|
|
|
|
logger.info("Webhook %d created for events %s", cfg.id, body.events)
|
|
return _to_response(cfg)
|
|
|
|
|
|
@router.put("/{webhook_id}", summary="Update a webhook configuration")
|
|
def update_webhook(webhook_id: int, body: WebhookUpdate, db: DbSession, _admin: AdminUser) -> dict[str, Any]:
|
|
"""Update an existing webhook configuration. Only supplied fields are changed."""
|
|
cfg = db.query(WebhookConfig).filter(WebhookConfig.id == webhook_id).first()
|
|
if not cfg:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Webhook not found")
|
|
|
|
if body.url is not None:
|
|
cfg.url = body.url
|
|
if body.secret is not None:
|
|
cfg.secret = body.secret
|
|
if body.events is not None:
|
|
_validate_events(body.events)
|
|
cfg.events = json.dumps(sorted(body.events))
|
|
if body.is_active is not None:
|
|
cfg.is_active = body.is_active
|
|
if body.description is not None:
|
|
cfg.description = body.description
|
|
|
|
try:
|
|
db.commit()
|
|
db.refresh(cfg)
|
|
except Exception:
|
|
db.rollback()
|
|
raise
|
|
|
|
logger.info("Webhook %d updated", cfg.id)
|
|
return _to_response(cfg)
|
|
|
|
|
|
@router.delete("/{webhook_id}", status_code=status.HTTP_204_NO_CONTENT, summary="Delete a webhook configuration")
|
|
def delete_webhook(webhook_id: int, db: DbSession, _admin: AdminUser) -> None:
|
|
"""Delete a webhook configuration."""
|
|
cfg = db.query(WebhookConfig).filter(WebhookConfig.id == webhook_id).first()
|
|
if not cfg:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Webhook not found")
|
|
|
|
try:
|
|
db.delete(cfg)
|
|
db.commit()
|
|
except Exception:
|
|
db.rollback()
|
|
raise
|
|
|
|
logger.info("Webhook %d deleted", cfg.id)
|
|
|
|
|
|
@router.get("/events/", summary="List valid webhook event types")
|
|
def list_events(_admin: AdminUser) -> list[str]:
|
|
"""Return the list of valid event types that can be subscribed to."""
|
|
return sorted(VALID_EVENTS)
|