"""CRUD operations for webhooks.""" import json import secrets import hashlib import hmac import httpx from datetime import datetime, timedelta from typing import Optional, List, Any from sqlalchemy.orm import Session from app.models.webhook import Webhook, WebhookDelivery from app.schemas.webhook import WebhookCreate, WebhookUpdate class CRUDWebhook: """CRUD operations for webhooks.""" def get(self, db: Session, id: str) -> Optional[Webhook]: """Get a webhook by ID.""" return db.query(Webhook).filter(Webhook.id == id).first() def get_multi( self, db: Session, *, skip: int = 0, limit: int = 100, is_active: Optional[bool] = None ) -> List[Webhook]: """Get multiple webhooks.""" query = db.query(Webhook) if is_active is not None: query = query.filter(Webhook.is_active == is_active) return query.order_by(Webhook.created_at.desc()).offset(skip).limit(limit).all() def get_by_event(self, db: Session, event_type: str) -> List[Webhook]: """Get all active webhooks that subscribe to an event type.""" webhooks = db.query(Webhook).filter(Webhook.is_active == True).all() matching = [] for webhook in webhooks: events = json.loads(webhook.events) if webhook.events else [] if "*" in events or event_type in events: matching.append(webhook) return matching def create( self, db: Session, *, obj_in: WebhookCreate, created_by: Optional[str] = None ) -> Webhook: """Create a new webhook with a generated secret.""" # Generate a secret for signature verification secret = secrets.token_hex(32) db_obj = Webhook( name=obj_in.name, url=obj_in.url, secret=secret, events=json.dumps(obj_in.events), is_active=obj_in.is_active, retry_count=obj_in.retry_count, timeout_seconds=obj_in.timeout_seconds, created_by=created_by ) db.add(db_obj) db.commit() db.refresh(db_obj) return db_obj def update( self, db: Session, *, db_obj: Webhook, obj_in: WebhookUpdate ) -> Webhook: """Update a webhook.""" update_data = obj_in.model_dump(exclude_unset=True) if "events" in update_data: update_data["events"] = json.dumps(update_data["events"]) for field, value in update_data.items(): setattr(db_obj, field, value) db.add(db_obj) db.commit() db.refresh(db_obj) return db_obj def delete(self, db: Session, *, id: str) -> Optional[Webhook]: """Delete a webhook.""" obj = db.query(Webhook).filter(Webhook.id == id).first() if obj: db.delete(obj) db.commit() return obj def regenerate_secret(self, db: Session, *, db_obj: Webhook) -> Webhook: """Regenerate the webhook secret.""" db_obj.secret = secrets.token_hex(32) db.add(db_obj) db.commit() db.refresh(db_obj) return db_obj def count(self, db: Session) -> int: """Count total webhooks.""" return db.query(Webhook).count() class CRUDWebhookDelivery: """CRUD operations for webhook deliveries.""" def get(self, db: Session, id: str) -> Optional[WebhookDelivery]: """Get a delivery by ID.""" return db.query(WebhookDelivery).filter(WebhookDelivery.id == id).first() def get_by_webhook( self, db: Session, *, webhook_id: str, skip: int = 0, limit: int = 50 ) -> List[WebhookDelivery]: """Get deliveries for a specific webhook.""" return ( db.query(WebhookDelivery) .filter(WebhookDelivery.webhook_id == webhook_id) .order_by(WebhookDelivery.created_at.desc()) .offset(skip) .limit(limit) .all() ) def get_pending_retries(self, db: Session) -> List[WebhookDelivery]: """Get deliveries that need to be retried.""" now = datetime.utcnow() return ( db.query(WebhookDelivery) .filter( WebhookDelivery.status == "failed", WebhookDelivery.next_retry_at <= now ) .all() ) def create( self, db: Session, *, webhook_id: str, event_type: str, payload: dict ) -> WebhookDelivery: """Create a new webhook delivery record.""" db_obj = WebhookDelivery( webhook_id=webhook_id, event_type=event_type, payload=json.dumps(payload), status="pending" ) db.add(db_obj) db.commit() db.refresh(db_obj) return db_obj def update_status( self, db: Session, *, db_obj: WebhookDelivery, status: str, status_code: Optional[int] = None, response_body: Optional[str] = None, error_message: Optional[str] = None, schedule_retry: bool = False, max_retries: int = 3 ) -> WebhookDelivery: """Update delivery status.""" db_obj.status = status db_obj.status_code = status_code db_obj.response_body = response_body[:1000] if response_body else None db_obj.error_message = error_message db_obj.attempt_count += 1 if status == "success": db_obj.delivered_at = datetime.utcnow() db_obj.next_retry_at = None elif status == "failed" and schedule_retry and db_obj.attempt_count < max_retries: # Exponential backoff: 1min, 5min, 30min delays = [60, 300, 1800] delay = delays[min(db_obj.attempt_count - 1, len(delays) - 1)] db_obj.next_retry_at = datetime.utcnow() + timedelta(seconds=delay) else: db_obj.next_retry_at = None db.add(db_obj) db.commit() db.refresh(db_obj) return db_obj class WebhookService: """Service for triggering and delivering webhooks.""" def __init__(self): self.webhook_crud = CRUDWebhook() self.delivery_crud = CRUDWebhookDelivery() def generate_signature(self, payload: str, secret: str) -> str: """Generate HMAC-SHA256 signature for payload.""" return hmac.new( secret.encode(), payload.encode(), hashlib.sha256 ).hexdigest() async def trigger_event( self, db: Session, event_type: str, payload: dict ) -> List[WebhookDelivery]: """Trigger webhooks for an event.""" webhooks = self.webhook_crud.get_by_event(db, event_type) deliveries = [] for webhook in webhooks: delivery = self.delivery_crud.create( db, webhook_id=webhook.id, event_type=event_type, payload=payload ) deliveries.append(delivery) # Attempt delivery await self.deliver(db, webhook, delivery) return deliveries async def deliver( self, db: Session, webhook: Webhook, delivery: WebhookDelivery ) -> bool: """Deliver a webhook.""" payload_str = delivery.payload signature = self.generate_signature(payload_str, webhook.secret) headers = { "Content-Type": "application/json", "X-Webhook-Signature": signature, "X-Webhook-Event": delivery.event_type, "X-Webhook-Delivery-Id": delivery.id } try: async with httpx.AsyncClient(timeout=webhook.timeout_seconds) as client: response = await client.post( webhook.url, content=payload_str, headers=headers ) if response.status_code >= 200 and response.status_code < 300: self.delivery_crud.update_status( db, db_obj=delivery, status="success", status_code=response.status_code, response_body=response.text ) webhook.success_count += 1 webhook.last_triggered_at = datetime.utcnow() db.add(webhook) db.commit() return True else: self.delivery_crud.update_status( db, db_obj=delivery, status="failed", status_code=response.status_code, response_body=response.text, error_message=f"HTTP {response.status_code}", schedule_retry=True, max_retries=webhook.retry_count ) webhook.failure_count += 1 db.add(webhook) db.commit() return False except Exception as e: self.delivery_crud.update_status( db, db_obj=delivery, status="failed", error_message=str(e), schedule_retry=True, max_retries=webhook.retry_count ) webhook.failure_count += 1 db.add(webhook) db.commit() return False async def test_webhook( self, db: Session, webhook: Webhook, event_type: str = "test.ping", payload: Optional[dict] = None ) -> WebhookDelivery: """Send a test delivery to a webhook.""" if payload is None: payload = { "event": event_type, "timestamp": datetime.utcnow().isoformat(), "test": True, "message": "This is a test webhook delivery" } delivery = self.delivery_crud.create( db, webhook_id=webhook.id, event_type=event_type, payload=payload ) await self.deliver(db, webhook, delivery) return delivery # Singleton instances webhook = CRUDWebhook() webhook_delivery = CRUDWebhookDelivery() webhook_service = WebhookService()