updates
This commit is contained in:
4
Backend/src/integrations/__init__.py
Normal file
4
Backend/src/integrations/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
"""
|
||||
Integration module for webhooks and API keys.
|
||||
"""
|
||||
|
||||
BIN
Backend/src/integrations/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
Backend/src/integrations/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
48
Backend/src/integrations/models/api_key.py
Normal file
48
Backend/src/integrations/models/api_key.py
Normal file
@@ -0,0 +1,48 @@
|
||||
"""
|
||||
API key models for third-party access.
|
||||
"""
|
||||
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, Boolean, JSON
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime, timedelta
|
||||
from ...shared.config.database import Base
|
||||
|
||||
class APIKey(Base):
|
||||
__tablename__ = 'api_keys'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
name = Column(String(255), nullable=False)
|
||||
key_hash = Column(String(255), unique=True, nullable=False, index=True) # Hashed API key
|
||||
key_prefix = Column(String(20), nullable=False, index=True) # First 8 chars for identification
|
||||
|
||||
# Permissions
|
||||
scopes = Column(JSON, nullable=False) # List of allowed scopes/permissions
|
||||
rate_limit = Column(Integer, default=100, nullable=False) # Requests per minute
|
||||
|
||||
# Status
|
||||
is_active = Column(Boolean, default=True, nullable=False, index=True)
|
||||
|
||||
# Expiration
|
||||
expires_at = Column(DateTime, nullable=True, index=True)
|
||||
|
||||
# Metadata
|
||||
description = Column(Text, nullable=True)
|
||||
last_used_at = Column(DateTime, nullable=True)
|
||||
created_by = Column(Integer, ForeignKey('users.id'), nullable=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
|
||||
# Relationships
|
||||
creator = relationship('User', foreign_keys=[created_by])
|
||||
|
||||
@property
|
||||
def is_expired(self) -> bool:
|
||||
"""Check if API key is expired."""
|
||||
if not self.expires_at:
|
||||
return False
|
||||
return datetime.utcnow() > self.expires_at
|
||||
|
||||
@property
|
||||
def is_valid(self) -> bool:
|
||||
"""Check if API key is valid (active and not expired)."""
|
||||
return self.is_active and not self.is_expired
|
||||
|
||||
84
Backend/src/integrations/models/webhook.py
Normal file
84
Backend/src/integrations/models/webhook.py
Normal file
@@ -0,0 +1,84 @@
|
||||
"""
|
||||
Webhook models for external integrations.
|
||||
"""
|
||||
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, Boolean, Enum, JSON
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
import enum
|
||||
from ...shared.config.database import Base
|
||||
|
||||
class WebhookEventType(str, enum.Enum):
|
||||
booking_created = "booking.created"
|
||||
booking_updated = "booking.updated"
|
||||
booking_cancelled = "booking.cancelled"
|
||||
payment_completed = "payment.completed"
|
||||
payment_failed = "payment.failed"
|
||||
invoice_created = "invoice.created"
|
||||
invoice_paid = "invoice.paid"
|
||||
user_created = "user.created"
|
||||
user_updated = "user.updated"
|
||||
|
||||
class WebhookStatus(str, enum.Enum):
|
||||
active = "active"
|
||||
inactive = "inactive"
|
||||
paused = "paused"
|
||||
|
||||
class WebhookDeliveryStatus(str, enum.Enum):
|
||||
pending = "pending"
|
||||
success = "success"
|
||||
failed = "failed"
|
||||
retrying = "retrying"
|
||||
|
||||
class Webhook(Base):
|
||||
__tablename__ = 'webhooks'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
name = Column(String(255), nullable=False)
|
||||
url = Column(String(500), nullable=False)
|
||||
secret = Column(String(255), nullable=False) # For signature verification
|
||||
|
||||
# Event subscriptions
|
||||
events = Column(JSON, nullable=False) # List of event types
|
||||
|
||||
# Status
|
||||
status = Column(Enum(WebhookStatus), default=WebhookStatus.active, nullable=False, index=True)
|
||||
|
||||
# Configuration
|
||||
retry_count = Column(Integer, default=3, nullable=False)
|
||||
timeout_seconds = Column(Integer, default=30, nullable=False)
|
||||
|
||||
# Metadata
|
||||
description = Column(Text, nullable=True)
|
||||
created_by = Column(Integer, ForeignKey('users.id'), nullable=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
|
||||
# Relationships
|
||||
creator = relationship('User', foreign_keys=[created_by])
|
||||
|
||||
class WebhookDelivery(Base):
|
||||
__tablename__ = 'webhook_deliveries'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
webhook_id = Column(Integer, ForeignKey('webhooks.id'), nullable=False, index=True)
|
||||
event_type = Column(String(100), nullable=False, index=True)
|
||||
event_id = Column(String(255), nullable=False, index=True)
|
||||
|
||||
# Delivery details
|
||||
status = Column(Enum(WebhookDeliveryStatus), default=WebhookDeliveryStatus.pending, nullable=False, index=True)
|
||||
payload = Column(JSON, nullable=False)
|
||||
response_status = Column(Integer, nullable=True)
|
||||
response_body = Column(Text, nullable=True)
|
||||
error_message = Column(Text, nullable=True)
|
||||
|
||||
# Retry information
|
||||
attempt_count = Column(Integer, default=0, nullable=False)
|
||||
next_retry_at = Column(DateTime, nullable=True)
|
||||
|
||||
# Timestamps
|
||||
delivered_at = Column(DateTime, nullable=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False, index=True)
|
||||
|
||||
# Relationships
|
||||
webhook = relationship('Webhook', foreign_keys=[webhook_id])
|
||||
|
||||
4
Backend/src/integrations/routes/__init__.py
Normal file
4
Backend/src/integrations/routes/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
"""
|
||||
Integration routes for webhooks and API keys.
|
||||
"""
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
165
Backend/src/integrations/routes/api_key_routes.py
Normal file
165
Backend/src/integrations/routes/api_key_routes.py
Normal file
@@ -0,0 +1,165 @@
|
||||
"""
|
||||
API key management routes.
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
from ...shared.config.database import get_db
|
||||
from ...shared.config.logging_config import get_logger
|
||||
from ...security.middleware.auth import get_current_user, authorize_roles
|
||||
from ...auth.models.user import User
|
||||
from ..services.api_key_service import api_key_service
|
||||
from ..models.api_key import APIKey
|
||||
from ...shared.utils.response_helpers import success_response
|
||||
from pydantic import BaseModel
|
||||
|
||||
logger = get_logger(__name__)
|
||||
router = APIRouter(prefix='/api-keys', tags=['api-keys'])
|
||||
|
||||
class CreateAPIKeyRequest(BaseModel):
|
||||
name: str
|
||||
scopes: List[str]
|
||||
description: Optional[str] = None
|
||||
rate_limit: int = 100
|
||||
expires_at: Optional[str] = None
|
||||
|
||||
class UpdateAPIKeyRequest(BaseModel):
|
||||
name: Optional[str] = None
|
||||
scopes: Optional[List[str]] = None
|
||||
description: Optional[str] = None
|
||||
rate_limit: Optional[int] = None
|
||||
expires_at: Optional[str] = None
|
||||
|
||||
@router.post('/')
|
||||
async def create_api_key(
|
||||
key_data: CreateAPIKeyRequest,
|
||||
current_user: User = Depends(authorize_roles('admin')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Create a new API key."""
|
||||
try:
|
||||
expires_at = None
|
||||
if key_data.expires_at:
|
||||
expires_at = datetime.fromisoformat(key_data.expires_at.replace('Z', '+00:00'))
|
||||
|
||||
api_key, plain_key = api_key_service.create_api_key(
|
||||
db=db,
|
||||
name=key_data.name,
|
||||
scopes=key_data.scopes,
|
||||
created_by=current_user.id,
|
||||
description=key_data.description,
|
||||
rate_limit=key_data.rate_limit,
|
||||
expires_at=expires_at
|
||||
)
|
||||
|
||||
return success_response(
|
||||
data={
|
||||
'api_key': {
|
||||
'id': api_key.id,
|
||||
'name': api_key.name,
|
||||
'key_prefix': api_key.key_prefix,
|
||||
'scopes': api_key.scopes,
|
||||
'rate_limit': api_key.rate_limit,
|
||||
'expires_at': api_key.expires_at.isoformat() if api_key.expires_at else None
|
||||
},
|
||||
'key': plain_key # Return plain key only on creation
|
||||
},
|
||||
message='API key created successfully. Save this key securely - it will not be shown again.'
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'Error creating API key: {str(e)}', exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.get('/')
|
||||
async def get_api_keys(
|
||||
current_user: User = Depends(authorize_roles('admin')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get all API keys."""
|
||||
try:
|
||||
api_keys = db.query(APIKey).order_by(APIKey.created_at.desc()).all()
|
||||
|
||||
return success_response(data={
|
||||
'api_keys': [{
|
||||
'id': k.id,
|
||||
'name': k.name,
|
||||
'key_prefix': k.key_prefix,
|
||||
'scopes': k.scopes,
|
||||
'rate_limit': k.rate_limit,
|
||||
'is_active': k.is_active,
|
||||
'last_used_at': k.last_used_at.isoformat() if k.last_used_at else None,
|
||||
'expires_at': k.expires_at.isoformat() if k.expires_at else None,
|
||||
'created_at': k.created_at.isoformat() if k.created_at else None
|
||||
} for k in api_keys]
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f'Error getting API keys: {str(e)}', exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.put('/{key_id}')
|
||||
async def update_api_key(
|
||||
key_id: int,
|
||||
key_data: UpdateAPIKeyRequest,
|
||||
current_user: User = Depends(authorize_roles('admin')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Update an API key."""
|
||||
try:
|
||||
expires_at = None
|
||||
if key_data.expires_at:
|
||||
expires_at = datetime.fromisoformat(key_data.expires_at.replace('Z', '+00:00'))
|
||||
|
||||
api_key = api_key_service.update_api_key(
|
||||
db=db,
|
||||
key_id=key_id,
|
||||
name=key_data.name,
|
||||
scopes=key_data.scopes,
|
||||
description=key_data.description,
|
||||
rate_limit=key_data.rate_limit,
|
||||
expires_at=expires_at
|
||||
)
|
||||
|
||||
if not api_key:
|
||||
raise HTTPException(status_code=404, detail='API key not found')
|
||||
|
||||
return success_response(
|
||||
data={
|
||||
'api_key': {
|
||||
'id': api_key.id,
|
||||
'name': api_key.name,
|
||||
'key_prefix': api_key.key_prefix,
|
||||
'scopes': api_key.scopes,
|
||||
'rate_limit': api_key.rate_limit,
|
||||
'is_active': api_key.is_active,
|
||||
'expires_at': api_key.expires_at.isoformat() if api_key.expires_at else None,
|
||||
'created_at': api_key.created_at.isoformat() if api_key.created_at else None
|
||||
}
|
||||
},
|
||||
message='API key updated successfully'
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f'Error updating API key: {str(e)}', exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.delete('/{key_id}')
|
||||
async def revoke_api_key(
|
||||
key_id: int,
|
||||
current_user: User = Depends(authorize_roles('admin')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Revoke an API key."""
|
||||
try:
|
||||
success = api_key_service.revoke_api_key(db=db, key_id=key_id)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail='API key not found')
|
||||
|
||||
return success_response(message='API key revoked successfully')
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f'Error revoking API key: {str(e)}', exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
199
Backend/src/integrations/routes/webhook_routes.py
Normal file
199
Backend/src/integrations/routes/webhook_routes.py
Normal file
@@ -0,0 +1,199 @@
|
||||
"""
|
||||
Webhook management routes.
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional
|
||||
from ...shared.config.database import get_db
|
||||
from ...shared.config.logging_config import get_logger
|
||||
from ...security.middleware.auth import get_current_user, authorize_roles
|
||||
from ...auth.models.user import User
|
||||
from ..services.webhook_service import webhook_service
|
||||
from ..models.webhook import Webhook, WebhookDelivery, WebhookEventType, WebhookStatus
|
||||
from ...shared.utils.response_helpers import success_response
|
||||
from pydantic import BaseModel, HttpUrl
|
||||
|
||||
logger = get_logger(__name__)
|
||||
router = APIRouter(prefix='/webhooks', tags=['webhooks'])
|
||||
|
||||
class CreateWebhookRequest(BaseModel):
|
||||
name: str
|
||||
url: str
|
||||
events: List[str]
|
||||
description: Optional[str] = None
|
||||
retry_count: int = 3
|
||||
timeout_seconds: int = 30
|
||||
|
||||
@router.post('/')
|
||||
async def create_webhook(
|
||||
webhook_data: CreateWebhookRequest,
|
||||
current_user: User = Depends(authorize_roles('admin')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Create a new webhook."""
|
||||
try:
|
||||
webhook = webhook_service.create_webhook(
|
||||
db=db,
|
||||
name=webhook_data.name,
|
||||
url=webhook_data.url,
|
||||
events=webhook_data.events,
|
||||
created_by=current_user.id,
|
||||
description=webhook_data.description,
|
||||
retry_count=webhook_data.retry_count,
|
||||
timeout_seconds=webhook_data.timeout_seconds
|
||||
)
|
||||
|
||||
return success_response(
|
||||
data={'webhook': {
|
||||
'id': webhook.id,
|
||||
'name': webhook.name,
|
||||
'url': webhook.url,
|
||||
'events': webhook.events,
|
||||
'status': webhook.status.value,
|
||||
'secret': webhook.secret # Return secret only on creation
|
||||
}},
|
||||
message='Webhook created successfully'
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'Error creating webhook: {str(e)}', exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.get('/')
|
||||
async def get_webhooks(
|
||||
current_user: User = Depends(authorize_roles('admin')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get all webhooks."""
|
||||
try:
|
||||
webhooks = db.query(Webhook).order_by(Webhook.created_at.desc()).all()
|
||||
|
||||
return success_response(data={
|
||||
'webhooks': [{
|
||||
'id': w.id,
|
||||
'name': w.name,
|
||||
'url': w.url,
|
||||
'events': w.events,
|
||||
'status': w.status.value,
|
||||
'created_at': w.created_at.isoformat() if w.created_at else None
|
||||
} for w in webhooks]
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f'Error getting webhooks: {str(e)}', exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.get('/{webhook_id}/deliveries')
|
||||
async def get_webhook_deliveries(
|
||||
webhook_id: int,
|
||||
page: int = Query(1, ge=1),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
current_user: User = Depends(authorize_roles('admin')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get webhook delivery history."""
|
||||
try:
|
||||
offset = (page - 1) * limit
|
||||
deliveries = db.query(WebhookDelivery).filter(
|
||||
WebhookDelivery.webhook_id == webhook_id
|
||||
).order_by(WebhookDelivery.created_at.desc()).offset(offset).limit(limit).all()
|
||||
|
||||
total = db.query(WebhookDelivery).filter(
|
||||
WebhookDelivery.webhook_id == webhook_id
|
||||
).count()
|
||||
|
||||
return success_response(data={
|
||||
'deliveries': [{
|
||||
'id': d.id,
|
||||
'event_type': d.event_type,
|
||||
'status': d.status.value,
|
||||
'response_status': d.response_status,
|
||||
'attempt_count': d.attempt_count,
|
||||
'created_at': d.created_at.isoformat() if d.created_at else None,
|
||||
'delivered_at': d.delivered_at.isoformat() if d.delivered_at else None
|
||||
} for d in deliveries],
|
||||
'pagination': {
|
||||
'page': page,
|
||||
'limit': limit,
|
||||
'total': total,
|
||||
'total_pages': (total + limit - 1) // limit
|
||||
}
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f'Error getting webhook deliveries: {str(e)}', exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
class UpdateWebhookRequest(BaseModel):
|
||||
name: Optional[str] = None
|
||||
url: Optional[str] = None
|
||||
events: Optional[List[str]] = None
|
||||
description: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
retry_count: Optional[int] = None
|
||||
timeout_seconds: Optional[int] = None
|
||||
|
||||
@router.put('/{webhook_id}')
|
||||
async def update_webhook(
|
||||
webhook_id: int,
|
||||
webhook_data: UpdateWebhookRequest,
|
||||
current_user: User = Depends(authorize_roles('admin')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Update a webhook."""
|
||||
try:
|
||||
status_enum = None
|
||||
if webhook_data.status:
|
||||
try:
|
||||
status_enum = WebhookStatus(webhook_data.status)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail=f'Invalid status: {webhook_data.status}')
|
||||
|
||||
webhook = webhook_service.update_webhook(
|
||||
db=db,
|
||||
webhook_id=webhook_id,
|
||||
name=webhook_data.name,
|
||||
url=webhook_data.url,
|
||||
events=webhook_data.events,
|
||||
description=webhook_data.description,
|
||||
status=status_enum,
|
||||
retry_count=webhook_data.retry_count,
|
||||
timeout_seconds=webhook_data.timeout_seconds
|
||||
)
|
||||
|
||||
if not webhook:
|
||||
raise HTTPException(status_code=404, detail='Webhook not found')
|
||||
|
||||
return success_response(
|
||||
data={'webhook': {
|
||||
'id': webhook.id,
|
||||
'name': webhook.name,
|
||||
'url': webhook.url,
|
||||
'events': webhook.events,
|
||||
'status': webhook.status.value,
|
||||
'created_at': webhook.created_at.isoformat() if webhook.created_at else None
|
||||
}},
|
||||
message='Webhook updated successfully'
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f'Error updating webhook: {str(e)}', exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.delete('/{webhook_id}')
|
||||
async def delete_webhook(
|
||||
webhook_id: int,
|
||||
current_user: User = Depends(authorize_roles('admin')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Delete a webhook."""
|
||||
try:
|
||||
success = webhook_service.delete_webhook(db=db, webhook_id=webhook_id)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail='Webhook not found')
|
||||
|
||||
return success_response(message='Webhook deleted successfully')
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f'Error deleting webhook: {str(e)}', exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
Binary file not shown.
Binary file not shown.
133
Backend/src/integrations/services/api_key_service.py
Normal file
133
Backend/src/integrations/services/api_key_service.py
Normal file
@@ -0,0 +1,133 @@
|
||||
"""
|
||||
API key service for third-party access management.
|
||||
"""
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
import secrets
|
||||
import hashlib
|
||||
from ..models.api_key import APIKey
|
||||
from ...shared.config.logging_config import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
class APIKeyService:
|
||||
"""Service for managing API keys."""
|
||||
|
||||
@staticmethod
|
||||
def generate_api_key() -> tuple[str, str]:
|
||||
"""Generate a new API key and return (key, hash)."""
|
||||
# Generate key in format: hb_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
key = f"hb_{secrets.token_urlsafe(32)}"
|
||||
key_hash = hashlib.sha256(key.encode('utf-8')).hexdigest()
|
||||
key_prefix = key[:11] # "hb_" + 8 chars
|
||||
return key, key_hash, key_prefix
|
||||
|
||||
@staticmethod
|
||||
def create_api_key(
|
||||
db: Session,
|
||||
name: str,
|
||||
scopes: List[str],
|
||||
created_by: int,
|
||||
description: Optional[str] = None,
|
||||
rate_limit: int = 100,
|
||||
expires_at: Optional[datetime] = None
|
||||
) -> tuple[APIKey, str]:
|
||||
"""Create a new API key and return (api_key_model, plain_key)."""
|
||||
key, key_hash, key_prefix = APIKeyService.generate_api_key()
|
||||
|
||||
api_key = APIKey(
|
||||
name=name,
|
||||
key_hash=key_hash,
|
||||
key_prefix=key_prefix,
|
||||
scopes=scopes,
|
||||
rate_limit=rate_limit,
|
||||
created_by=created_by,
|
||||
description=description,
|
||||
expires_at=expires_at
|
||||
)
|
||||
|
||||
db.add(api_key)
|
||||
db.commit()
|
||||
db.refresh(api_key)
|
||||
|
||||
logger.info(f'API key created: {api_key.id} - {name}')
|
||||
return api_key, key # Return plain key only once
|
||||
|
||||
@staticmethod
|
||||
def verify_api_key(db: Session, api_key: str) -> Optional[APIKey]:
|
||||
"""Verify an API key and return the APIKey model if valid."""
|
||||
key_hash = hashlib.sha256(api_key.encode('utf-8')).hexdigest()
|
||||
|
||||
api_key_model = db.query(APIKey).filter(
|
||||
APIKey.key_hash == key_hash,
|
||||
APIKey.is_active == True
|
||||
).first()
|
||||
|
||||
if api_key_model and api_key_model.is_valid:
|
||||
# Update last used timestamp
|
||||
api_key_model.last_used_at = datetime.utcnow()
|
||||
db.commit()
|
||||
return api_key_model
|
||||
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def revoke_api_key(db: Session, key_id: int) -> bool:
|
||||
"""Revoke an API key."""
|
||||
api_key = db.query(APIKey).filter(APIKey.id == key_id).first()
|
||||
if api_key:
|
||||
api_key.is_active = False
|
||||
db.commit()
|
||||
logger.info(f'API key {key_id} revoked')
|
||||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def get_user_api_keys(
|
||||
db: Session,
|
||||
created_by: int,
|
||||
active_only: bool = True
|
||||
) -> List[APIKey]:
|
||||
"""Get all API keys created by a user."""
|
||||
query = db.query(APIKey).filter(APIKey.created_by == created_by)
|
||||
|
||||
if active_only:
|
||||
query = query.filter(APIKey.is_active == True)
|
||||
|
||||
return query.order_by(APIKey.created_at.desc()).all()
|
||||
|
||||
@staticmethod
|
||||
def update_api_key(
|
||||
db: Session,
|
||||
key_id: int,
|
||||
name: Optional[str] = None,
|
||||
scopes: Optional[List[str]] = None,
|
||||
description: Optional[str] = None,
|
||||
rate_limit: Optional[int] = None,
|
||||
expires_at: Optional[datetime] = None
|
||||
) -> Optional[APIKey]:
|
||||
"""Update an API key."""
|
||||
api_key = db.query(APIKey).filter(APIKey.id == key_id).first()
|
||||
if not api_key:
|
||||
return None
|
||||
|
||||
if name is not None:
|
||||
api_key.name = name
|
||||
if scopes is not None:
|
||||
api_key.scopes = scopes
|
||||
if description is not None:
|
||||
api_key.description = description
|
||||
if rate_limit is not None:
|
||||
api_key.rate_limit = rate_limit
|
||||
if expires_at is not None:
|
||||
api_key.expires_at = expires_at
|
||||
|
||||
db.commit()
|
||||
db.refresh(api_key)
|
||||
|
||||
logger.info(f'API key updated: {api_key.id} - {api_key.name}')
|
||||
return api_key
|
||||
|
||||
api_key_service = APIKeyService()
|
||||
|
||||
218
Backend/src/integrations/services/webhook_service.py
Normal file
218
Backend/src/integrations/services/webhook_service.py
Normal file
@@ -0,0 +1,218 @@
|
||||
"""
|
||||
Webhook service for external integrations.
|
||||
"""
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Dict, Any, Optional
|
||||
from datetime import datetime, timedelta
|
||||
import secrets
|
||||
import httpx
|
||||
import hmac
|
||||
import hashlib
|
||||
import json
|
||||
from ..models.webhook import Webhook, WebhookDelivery, WebhookEventType, WebhookStatus, WebhookDeliveryStatus
|
||||
from ...shared.config.logging_config import get_logger
|
||||
from ...analytics.services.audit_service import audit_service
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
class WebhookService:
|
||||
"""Service for managing webhooks."""
|
||||
|
||||
@staticmethod
|
||||
def create_webhook(
|
||||
db: Session,
|
||||
name: str,
|
||||
url: str,
|
||||
events: List[str],
|
||||
created_by: int,
|
||||
description: Optional[str] = None,
|
||||
retry_count: int = 3,
|
||||
timeout_seconds: int = 30
|
||||
) -> Webhook:
|
||||
"""Create a new webhook."""
|
||||
secret = secrets.token_urlsafe(32)
|
||||
|
||||
webhook = Webhook(
|
||||
name=name,
|
||||
url=url,
|
||||
secret=secret,
|
||||
events=events,
|
||||
created_by=created_by,
|
||||
description=description,
|
||||
retry_count=retry_count,
|
||||
timeout_seconds=timeout_seconds
|
||||
)
|
||||
|
||||
db.add(webhook)
|
||||
db.commit()
|
||||
db.refresh(webhook)
|
||||
|
||||
logger.info(f'Webhook created: {webhook.id} - {name}')
|
||||
return webhook
|
||||
|
||||
@staticmethod
|
||||
def update_webhook(
|
||||
db: Session,
|
||||
webhook_id: int,
|
||||
name: Optional[str] = None,
|
||||
url: Optional[str] = None,
|
||||
events: Optional[List[str]] = None,
|
||||
description: Optional[str] = None,
|
||||
status: Optional[WebhookStatus] = None,
|
||||
retry_count: Optional[int] = None,
|
||||
timeout_seconds: Optional[int] = None
|
||||
) -> Optional[Webhook]:
|
||||
"""Update a webhook."""
|
||||
webhook = db.query(Webhook).filter(Webhook.id == webhook_id).first()
|
||||
if not webhook:
|
||||
return None
|
||||
|
||||
if name is not None:
|
||||
webhook.name = name
|
||||
if url is not None:
|
||||
webhook.url = url
|
||||
if events is not None:
|
||||
webhook.events = events
|
||||
if description is not None:
|
||||
webhook.description = description
|
||||
if status is not None:
|
||||
webhook.status = status
|
||||
if retry_count is not None:
|
||||
webhook.retry_count = retry_count
|
||||
if timeout_seconds is not None:
|
||||
webhook.timeout_seconds = timeout_seconds
|
||||
|
||||
db.commit()
|
||||
db.refresh(webhook)
|
||||
|
||||
logger.info(f'Webhook updated: {webhook.id} - {webhook.name}')
|
||||
return webhook
|
||||
|
||||
@staticmethod
|
||||
def delete_webhook(db: Session, webhook_id: int) -> bool:
|
||||
"""Delete a webhook."""
|
||||
webhook = db.query(Webhook).filter(Webhook.id == webhook_id).first()
|
||||
if not webhook:
|
||||
return False
|
||||
|
||||
db.delete(webhook)
|
||||
db.commit()
|
||||
|
||||
logger.info(f'Webhook deleted: {webhook_id}')
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def generate_signature(payload: str, secret: str) -> str:
|
||||
"""Generate HMAC signature for webhook payload."""
|
||||
return hmac.new(
|
||||
secret.encode('utf-8'),
|
||||
payload.encode('utf-8'),
|
||||
hashlib.sha256
|
||||
).hexdigest()
|
||||
|
||||
@staticmethod
|
||||
async def deliver_webhook(
|
||||
db: Session,
|
||||
event_type: str,
|
||||
event_id: str,
|
||||
payload: Dict[str, Any]
|
||||
) -> List[WebhookDelivery]:
|
||||
"""Deliver webhook event to all subscribed webhooks."""
|
||||
# Find active webhooks subscribed to this event
|
||||
webhooks = db.query(Webhook).filter(
|
||||
Webhook.status == WebhookStatus.active,
|
||||
Webhook.events.contains([event_type])
|
||||
).all()
|
||||
|
||||
deliveries = []
|
||||
|
||||
for webhook in webhooks:
|
||||
delivery = WebhookDelivery(
|
||||
webhook_id=webhook.id,
|
||||
event_type=event_type,
|
||||
event_id=event_id,
|
||||
payload=payload,
|
||||
status=WebhookDeliveryStatus.pending
|
||||
)
|
||||
db.add(delivery)
|
||||
db.commit()
|
||||
db.refresh(delivery)
|
||||
|
||||
# Deliver asynchronously
|
||||
try:
|
||||
await WebhookService._send_webhook(webhook, delivery, payload)
|
||||
except Exception as e:
|
||||
logger.error(f'Error delivering webhook {delivery.id}: {str(e)}')
|
||||
|
||||
deliveries.append(delivery)
|
||||
|
||||
return deliveries
|
||||
|
||||
@staticmethod
|
||||
async def _send_webhook(
|
||||
webhook: Webhook,
|
||||
delivery: WebhookDelivery,
|
||||
payload: Dict[str, Any]
|
||||
):
|
||||
"""Send webhook HTTP request."""
|
||||
payload_str = json.dumps(payload)
|
||||
signature = WebhookService.generate_signature(payload_str, webhook.secret)
|
||||
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Webhook-Signature': signature,
|
||||
'X-Webhook-Event': delivery.event_type,
|
||||
'X-Webhook-Id': str(delivery.id)
|
||||
}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=webhook.timeout_seconds) as client:
|
||||
response = await client.post(
|
||||
webhook.url,
|
||||
content=payload_str,
|
||||
headers=headers
|
||||
)
|
||||
|
||||
delivery.response_status = response.status_code
|
||||
delivery.response_body = response.text[:1000] # Limit response body size
|
||||
delivery.attempt_count += 1
|
||||
|
||||
if response.status_code >= 200 and response.status_code < 300:
|
||||
delivery.status = WebhookDeliveryStatus.success
|
||||
delivery.delivered_at = datetime.utcnow()
|
||||
else:
|
||||
delivery.status = WebhookDeliveryStatus.failed
|
||||
delivery.error_message = f'HTTP {response.status_code}'
|
||||
|
||||
# Schedule retry if attempts remaining
|
||||
if delivery.attempt_count < webhook.retry_count:
|
||||
delivery.status = WebhookDeliveryStatus.retrying
|
||||
delivery.next_retry_at = datetime.utcnow() + timedelta(
|
||||
minutes=2 ** delivery.attempt_count # Exponential backoff
|
||||
)
|
||||
except Exception as e:
|
||||
delivery.status = WebhookDeliveryStatus.failed
|
||||
delivery.error_message = str(e)[:500]
|
||||
delivery.attempt_count += 1
|
||||
|
||||
# Schedule retry if attempts remaining
|
||||
if delivery.attempt_count < webhook.retry_count:
|
||||
delivery.status = WebhookDeliveryStatus.retrying
|
||||
delivery.next_retry_at = datetime.utcnow() + timedelta(
|
||||
minutes=2 ** delivery.attempt_count
|
||||
)
|
||||
|
||||
# Update delivery in database
|
||||
from ...shared.config.database import SessionLocal
|
||||
db = SessionLocal()
|
||||
try:
|
||||
db.merge(delivery)
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
logger.error(f'Error updating webhook delivery: {str(e)}')
|
||||
db.rollback()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
webhook_service = WebhookService()
|
||||
|
||||
Reference in New Issue
Block a user