Initial commit: Freepik REST API
FastAPI async wrapper for Freepik cloud AI API supporting image generation (Mystic, Flux Dev/Pro, SeedReam), video generation (Kling, MiniMax, Seedance), image editing (upscale, relight, style transfer, expand, inpaint), and utilities (background removal, classifier, audio isolation). Includes async task tracking with polling, Docker containerization, and Gitea CI/CD workflow. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
0
app/services/__init__.py
Normal file
0
app/services/__init__.py
Normal file
67
app/services/file_manager.py
Normal file
67
app/services/file_manager.py
Normal file
@@ -0,0 +1,67 @@
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def ensure_directories():
|
||||
os.makedirs(settings.output_dir, exist_ok=True)
|
||||
os.makedirs(settings.temp_dir, exist_ok=True)
|
||||
|
||||
|
||||
async def download_result(task_id: str, url: str) -> str:
|
||||
"""Download a result file from a URL and store it locally."""
|
||||
task_dir = os.path.join(settings.output_dir, task_id)
|
||||
os.makedirs(task_dir, exist_ok=True)
|
||||
|
||||
# Derive filename from URL
|
||||
filename = url.rsplit('/', 1)[-1].split('?')[0] or 'result'
|
||||
if '.' not in filename:
|
||||
filename += '.png'
|
||||
|
||||
output_path = os.path.join(task_dir, filename)
|
||||
|
||||
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||
response = await client.get(url)
|
||||
response.raise_for_status()
|
||||
with open(output_path, 'wb') as f:
|
||||
f.write(response.content)
|
||||
|
||||
logger.info(f'Downloaded result for {task_id}: {output_path}')
|
||||
return output_path
|
||||
|
||||
|
||||
def get_result_path(task_id: str) -> str | None:
|
||||
"""Get the path to a task's result file."""
|
||||
task_dir = os.path.join(settings.output_dir, task_id)
|
||||
if not os.path.isdir(task_dir):
|
||||
return None
|
||||
files = os.listdir(task_dir)
|
||||
if not files:
|
||||
return None
|
||||
return os.path.join(task_dir, files[0])
|
||||
|
||||
|
||||
def cleanup_old_outputs():
|
||||
"""Remove output directories older than auto_cleanup_hours."""
|
||||
cutoff = time.time() - (settings.auto_cleanup_hours * 3600)
|
||||
output_dir = Path(settings.output_dir)
|
||||
if not output_dir.exists():
|
||||
return
|
||||
|
||||
removed = 0
|
||||
for task_dir in output_dir.iterdir():
|
||||
if task_dir.is_dir() and task_dir.stat().st_mtime < cutoff:
|
||||
for f in task_dir.iterdir():
|
||||
f.unlink()
|
||||
task_dir.rmdir()
|
||||
removed += 1
|
||||
|
||||
if removed:
|
||||
logger.info(f'Cleaned up {removed} old output directories')
|
||||
377
app/services/freepik_client.py
Normal file
377
app/services/freepik_client.py
Normal file
@@ -0,0 +1,377 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_client: Optional[httpx.AsyncClient] = None
|
||||
|
||||
|
||||
def get_client() -> httpx.AsyncClient:
|
||||
if _client is None:
|
||||
raise RuntimeError('Freepik client not initialized')
|
||||
return _client
|
||||
|
||||
|
||||
async def create_client() -> httpx.AsyncClient:
|
||||
global _client
|
||||
_client = httpx.AsyncClient(
|
||||
base_url=settings.freepik_base_url,
|
||||
headers={
|
||||
'x-freepik-api-key': settings.freepik_api_key,
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
timeout=httpx.Timeout(60.0, connect=10.0),
|
||||
)
|
||||
return _client
|
||||
|
||||
|
||||
async def close_client():
|
||||
global _client
|
||||
if _client:
|
||||
await _client.aclose()
|
||||
_client = None
|
||||
|
||||
|
||||
async def _request(
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
json: Optional[dict] = None,
|
||||
data: Optional[dict] = None,
|
||||
files: Optional[dict] = None,
|
||||
params: Optional[dict] = None,
|
||||
max_retries: int = 3,
|
||||
) -> dict[str, Any]:
|
||||
"""Make an authenticated request to Freepik API with retry on 429."""
|
||||
client = get_client()
|
||||
for attempt in range(max_retries):
|
||||
kwargs: dict[str, Any] = {}
|
||||
if json is not None:
|
||||
kwargs['json'] = json
|
||||
if data is not None:
|
||||
kwargs['data'] = data
|
||||
if files is not None:
|
||||
kwargs['files'] = files
|
||||
if params is not None:
|
||||
kwargs['params'] = params
|
||||
|
||||
response = await client.request(method, path, **kwargs)
|
||||
|
||||
if response.status_code == 429:
|
||||
retry_after = int(response.headers.get('Retry-After', 2 ** attempt))
|
||||
logger.warning(f'Rate limited, retrying in {retry_after}s (attempt {attempt + 1})')
|
||||
await asyncio.sleep(retry_after)
|
||||
continue
|
||||
|
||||
response.raise_for_status()
|
||||
if response.headers.get('content-type', '').startswith('application/json'):
|
||||
return response.json()
|
||||
return {'raw': response.content}
|
||||
|
||||
raise httpx.HTTPStatusError(
|
||||
'Rate limit exceeded after retries',
|
||||
request=response.request,
|
||||
response=response,
|
||||
)
|
||||
|
||||
|
||||
async def _request_raw(
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
json: Optional[dict] = None,
|
||||
data: Optional[dict] = None,
|
||||
files: Optional[dict] = None,
|
||||
max_retries: int = 3,
|
||||
) -> bytes:
|
||||
"""Make a request and return raw bytes (for binary responses)."""
|
||||
client = get_client()
|
||||
for attempt in range(max_retries):
|
||||
kwargs: dict[str, Any] = {}
|
||||
if json is not None:
|
||||
kwargs['json'] = json
|
||||
if data is not None:
|
||||
kwargs['data'] = data
|
||||
if files is not None:
|
||||
kwargs['files'] = files
|
||||
|
||||
response = await client.request(method, path, **kwargs)
|
||||
|
||||
if response.status_code == 429:
|
||||
retry_after = int(response.headers.get('Retry-After', 2 ** attempt))
|
||||
logger.warning(f'Rate limited, retrying in {retry_after}s (attempt {attempt + 1})')
|
||||
await asyncio.sleep(retry_after)
|
||||
continue
|
||||
|
||||
response.raise_for_status()
|
||||
return response.content
|
||||
|
||||
raise httpx.HTTPStatusError(
|
||||
'Rate limit exceeded after retries',
|
||||
request=response.request,
|
||||
response=response,
|
||||
)
|
||||
|
||||
|
||||
def _strip_none(d: dict) -> dict:
|
||||
"""Remove None values from a dict for clean API payloads."""
|
||||
return {k: v for k, v in d.items() if v is not None}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Image generation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def generate_mystic(
|
||||
prompt: str,
|
||||
negative_prompt: Optional[str] = None,
|
||||
resolution: Optional[str] = None,
|
||||
styling: Optional[dict] = None,
|
||||
seed: Optional[int] = None,
|
||||
num_images: Optional[int] = None,
|
||||
) -> dict:
|
||||
payload = _strip_none({
|
||||
'prompt': prompt,
|
||||
'negative_prompt': negative_prompt,
|
||||
'resolution': resolution,
|
||||
'styling': styling,
|
||||
'seed': seed,
|
||||
'num_images': num_images,
|
||||
})
|
||||
return await _request('POST', '/v1/ai/mystic', json=payload)
|
||||
|
||||
|
||||
async def generate_flux_dev(
|
||||
prompt: str,
|
||||
image: Optional[str] = None,
|
||||
guidance_scale: Optional[float] = None,
|
||||
num_images: Optional[int] = None,
|
||||
seed: Optional[int] = None,
|
||||
) -> dict:
|
||||
payload = _strip_none({
|
||||
'prompt': prompt,
|
||||
'image': image,
|
||||
'guidance_scale': guidance_scale,
|
||||
'num_images': num_images,
|
||||
'seed': seed,
|
||||
})
|
||||
return await _request('POST', '/v1/ai/text-to-image/flux-dev', json=payload)
|
||||
|
||||
|
||||
async def generate_flux_pro(
|
||||
prompt: str,
|
||||
image: Optional[str] = None,
|
||||
guidance_scale: Optional[float] = None,
|
||||
seed: Optional[int] = None,
|
||||
) -> dict:
|
||||
payload = _strip_none({
|
||||
'prompt': prompt,
|
||||
'image': image,
|
||||
'guidance_scale': guidance_scale,
|
||||
'seed': seed,
|
||||
})
|
||||
return await _request('POST', '/v1/ai/text-to-image/flux-pro-1.1', json=payload)
|
||||
|
||||
|
||||
async def generate_seedream(
|
||||
prompt: str,
|
||||
image: Optional[str] = None,
|
||||
aspect_ratio: Optional[str] = None,
|
||||
num_images: Optional[int] = None,
|
||||
seed: Optional[int] = None,
|
||||
) -> dict:
|
||||
payload = _strip_none({
|
||||
'prompt': prompt,
|
||||
'image': image,
|
||||
'aspect_ratio': aspect_ratio,
|
||||
'num_images': num_images,
|
||||
'seed': seed,
|
||||
})
|
||||
return await _request('POST', '/v1/ai/text-to-image/seedream', json=payload)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Video generation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def generate_video_kling(
|
||||
image: str,
|
||||
prompt: Optional[str] = None,
|
||||
duration: Optional[str] = None,
|
||||
aspect_ratio: Optional[str] = None,
|
||||
) -> dict:
|
||||
payload = _strip_none({
|
||||
'image': image,
|
||||
'prompt': prompt,
|
||||
'duration': duration,
|
||||
'aspect_ratio': aspect_ratio,
|
||||
})
|
||||
return await _request('POST', '/v1/ai/image-to-video/kling', json=payload)
|
||||
|
||||
|
||||
async def generate_video_minimax(
|
||||
prompt: str,
|
||||
first_frame_image: Optional[str] = None,
|
||||
subject_reference: Optional[str] = None,
|
||||
) -> dict:
|
||||
payload = _strip_none({
|
||||
'prompt': prompt,
|
||||
'first_frame_image': first_frame_image,
|
||||
'subject_reference': subject_reference,
|
||||
})
|
||||
return await _request('POST', '/v1/ai/image-to-video/minimax', json=payload)
|
||||
|
||||
|
||||
async def generate_video_seedance(
|
||||
prompt: str,
|
||||
image: Optional[str] = None,
|
||||
duration: Optional[str] = None,
|
||||
resolution: Optional[str] = None,
|
||||
) -> dict:
|
||||
payload = _strip_none({
|
||||
'prompt': prompt,
|
||||
'image': image,
|
||||
'duration': duration,
|
||||
'resolution': resolution,
|
||||
})
|
||||
return await _request('POST', '/v1/ai/image-to-video/seedance', json=payload)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Image editing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def upscale_creative(
|
||||
image: str,
|
||||
prompt: Optional[str] = None,
|
||||
scale: Optional[int] = None,
|
||||
creativity: Optional[float] = None,
|
||||
resemblance: Optional[float] = None,
|
||||
) -> dict:
|
||||
payload = _strip_none({
|
||||
'image': image,
|
||||
'prompt': prompt,
|
||||
'scale': scale,
|
||||
'creativity': creativity,
|
||||
'resemblance': resemblance,
|
||||
})
|
||||
return await _request('POST', '/v1/ai/upscale/creative', json=payload)
|
||||
|
||||
|
||||
async def upscale_precision(
|
||||
image: str,
|
||||
scale: Optional[int] = None,
|
||||
) -> dict:
|
||||
payload = _strip_none({
|
||||
'image': image,
|
||||
'scale': scale,
|
||||
})
|
||||
return await _request('POST', '/v1/ai/upscale/precision', json=payload)
|
||||
|
||||
|
||||
async def relight_image(
|
||||
image: str,
|
||||
prompt: Optional[str] = None,
|
||||
light_source: Optional[str] = None,
|
||||
intensity: Optional[float] = None,
|
||||
) -> dict:
|
||||
payload = _strip_none({
|
||||
'image': image,
|
||||
'prompt': prompt,
|
||||
'light_source': light_source,
|
||||
'intensity': intensity,
|
||||
})
|
||||
return await _request('POST', '/v1/ai/relight', json=payload)
|
||||
|
||||
|
||||
async def style_transfer(
|
||||
image: str,
|
||||
style_reference: str,
|
||||
strength: Optional[float] = None,
|
||||
) -> dict:
|
||||
payload = _strip_none({
|
||||
'image': image,
|
||||
'style_reference': style_reference,
|
||||
'strength': strength,
|
||||
})
|
||||
return await _request('POST', '/v1/ai/style-transfer', json=payload)
|
||||
|
||||
|
||||
async def expand_image(
|
||||
image: str,
|
||||
prompt: Optional[str] = None,
|
||||
direction: Optional[str] = None,
|
||||
) -> dict:
|
||||
payload = _strip_none({
|
||||
'image': image,
|
||||
'prompt': prompt,
|
||||
'direction': direction,
|
||||
})
|
||||
return await _request('POST', '/v1/ai/expand', json=payload)
|
||||
|
||||
|
||||
async def inpaint(
|
||||
image: str,
|
||||
mask: str,
|
||||
prompt: str,
|
||||
) -> dict:
|
||||
payload = {
|
||||
'image': image,
|
||||
'mask': mask,
|
||||
'prompt': prompt,
|
||||
}
|
||||
return await _request('POST', '/v1/ai/inpaint', json=payload)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Utilities
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def remove_background(image: str) -> bytes:
|
||||
return await _request_raw('POST', '/v1/ai/remove-background', json={'image': image})
|
||||
|
||||
|
||||
async def classify_image(image: str) -> dict:
|
||||
return await _request('POST', '/v1/ai/classifier', json={'image': image})
|
||||
|
||||
|
||||
async def isolate_audio(audio: str) -> dict:
|
||||
return await _request('POST', '/v1/ai/audio-isolate', json={'audio': audio})
|
||||
|
||||
|
||||
async def generate_icon(
|
||||
prompt: str,
|
||||
color: Optional[str] = None,
|
||||
shape: Optional[str] = None,
|
||||
style: Optional[str] = None,
|
||||
) -> dict:
|
||||
payload = _strip_none({
|
||||
'prompt': prompt,
|
||||
'color': color,
|
||||
'shape': shape,
|
||||
'style': style,
|
||||
})
|
||||
return await _request('POST', '/v1/ai/icon', json=payload)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Task management
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def get_task_status(task_id: str) -> dict:
|
||||
return await _request('GET', f'/v1/ai/tasks/{task_id}')
|
||||
|
||||
|
||||
async def check_api_key() -> bool:
|
||||
"""Verify the API key is valid by making a lightweight request."""
|
||||
try:
|
||||
client = get_client()
|
||||
response = await client.get('/v1/ai/tasks', params={'limit': 1})
|
||||
return response.status_code != 401
|
||||
except Exception:
|
||||
return False
|
||||
179
app/services/task_tracker.py
Normal file
179
app/services/task_tracker.py
Normal file
@@ -0,0 +1,179 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from app.config import settings
|
||||
from app.schemas.common import TaskStatus
|
||||
from app.services import freepik_client
|
||||
from app.services.file_manager import download_result
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_tasks: dict[str, dict] = {}
|
||||
_poll_tasks: dict[str, asyncio.Task] = {}
|
||||
|
||||
|
||||
def submit(freepik_task_id: str, metadata: Optional[dict] = None) -> str:
|
||||
"""Register a Freepik task and start background polling."""
|
||||
internal_id = str(uuid.uuid4())
|
||||
now = datetime.now(timezone.utc)
|
||||
_tasks[internal_id] = {
|
||||
'task_id': internal_id,
|
||||
'freepik_task_id': freepik_task_id,
|
||||
'status': TaskStatus.pending,
|
||||
'created_at': now,
|
||||
'updated_at': now,
|
||||
'progress': None,
|
||||
'result_url': None,
|
||||
'local_path': None,
|
||||
'error': None,
|
||||
'metadata': metadata or {},
|
||||
}
|
||||
_poll_tasks[internal_id] = asyncio.create_task(_poll_loop(internal_id))
|
||||
return internal_id
|
||||
|
||||
|
||||
def get_task(task_id: str) -> Optional[dict]:
|
||||
return _tasks.get(task_id)
|
||||
|
||||
|
||||
def list_tasks(
|
||||
status: Optional[TaskStatus] = None,
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
) -> tuple[list[dict], int]:
|
||||
tasks = list(_tasks.values())
|
||||
if status:
|
||||
tasks = [t for t in tasks if t['status'] == status]
|
||||
tasks.sort(key=lambda t: t['created_at'], reverse=True)
|
||||
total = len(tasks)
|
||||
return tasks[offset:offset + limit], total
|
||||
|
||||
|
||||
def delete_task(task_id: str) -> bool:
|
||||
if task_id not in _tasks:
|
||||
return False
|
||||
# Cancel polling if active
|
||||
poll = _poll_tasks.pop(task_id, None)
|
||||
if poll and not poll.done():
|
||||
poll.cancel()
|
||||
_tasks.pop(task_id, None)
|
||||
return True
|
||||
|
||||
|
||||
def active_count() -> int:
|
||||
return sum(
|
||||
1 for t in _tasks.values()
|
||||
if t['status'] in (TaskStatus.pending, TaskStatus.processing)
|
||||
)
|
||||
|
||||
|
||||
async def _poll_loop(internal_id: str):
|
||||
"""Poll Freepik API until the task completes or times out."""
|
||||
task = _tasks.get(internal_id)
|
||||
if not task:
|
||||
return
|
||||
|
||||
freepik_id = task['freepik_task_id']
|
||||
elapsed = 0
|
||||
|
||||
try:
|
||||
while elapsed < settings.task_poll_timeout_seconds:
|
||||
await asyncio.sleep(settings.task_poll_interval_seconds)
|
||||
elapsed += settings.task_poll_interval_seconds
|
||||
|
||||
try:
|
||||
result = await freepik_client.get_task_status(freepik_id)
|
||||
except Exception as exc:
|
||||
logger.warning(f'Poll error for {internal_id}: {exc}')
|
||||
continue
|
||||
|
||||
data = result.get('data', result)
|
||||
fp_status = data.get('status', '')
|
||||
|
||||
task['updated_at'] = datetime.now(timezone.utc)
|
||||
|
||||
if fp_status in ('IN_PROGRESS', 'PROCESSING', 'processing'):
|
||||
task['status'] = TaskStatus.processing
|
||||
task['progress'] = data.get('progress')
|
||||
continue
|
||||
|
||||
if fp_status in ('COMPLETED', 'completed', 'done'):
|
||||
task['status'] = TaskStatus.completed
|
||||
task['progress'] = 1.0
|
||||
# Extract result URL from various response shapes
|
||||
result_url = (
|
||||
data.get('result_url')
|
||||
or data.get('output', {}).get('url')
|
||||
or _extract_first_url(data)
|
||||
)
|
||||
task['result_url'] = result_url
|
||||
if result_url:
|
||||
try:
|
||||
task['local_path'] = await download_result(
|
||||
internal_id, result_url
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error(f'Download failed for {internal_id}: {exc}')
|
||||
logger.info(f'Task {internal_id} completed')
|
||||
return
|
||||
|
||||
if fp_status in ('FAILED', 'failed', 'error'):
|
||||
task['status'] = TaskStatus.failed
|
||||
task['error'] = data.get('error', data.get('message', 'Unknown error'))
|
||||
logger.warning(f'Task {internal_id} failed: {task["error"]}')
|
||||
return
|
||||
|
||||
# Timeout
|
||||
task['status'] = TaskStatus.failed
|
||||
task['error'] = f'Polling timed out after {settings.task_poll_timeout_seconds}s'
|
||||
logger.warning(f'Task {internal_id} timed out')
|
||||
|
||||
except asyncio.CancelledError:
|
||||
logger.info(f'Polling cancelled for {internal_id}')
|
||||
except Exception as exc:
|
||||
task['status'] = TaskStatus.failed
|
||||
task['error'] = str(exc)
|
||||
logger.error(f'Unexpected error polling {internal_id}: {exc}')
|
||||
finally:
|
||||
_poll_tasks.pop(internal_id, None)
|
||||
|
||||
|
||||
def _extract_first_url(data: dict) -> Optional[str]:
|
||||
"""Try to extract the first URL from common Freepik response shapes."""
|
||||
# Some endpoints return {"data": {"images": [{"url": "..."}]}}
|
||||
for key in ('images', 'videos', 'results', 'outputs'):
|
||||
items = data.get(key, [])
|
||||
if isinstance(items, list) and items:
|
||||
first = items[0]
|
||||
if isinstance(first, dict) and 'url' in first:
|
||||
return first['url']
|
||||
if isinstance(first, str) and first.startswith('http'):
|
||||
return first
|
||||
# Direct URL field
|
||||
if 'url' in data and isinstance(data['url'], str):
|
||||
return data['url']
|
||||
return None
|
||||
|
||||
|
||||
def handle_webhook_completion(freepik_task_id: str, result_data: dict):
|
||||
"""Called when a webhook notification arrives for a completed task."""
|
||||
for task in _tasks.values():
|
||||
if task['freepik_task_id'] == freepik_task_id:
|
||||
task['status'] = TaskStatus.completed
|
||||
task['progress'] = 1.0
|
||||
task['updated_at'] = datetime.now(timezone.utc)
|
||||
result_url = (
|
||||
result_data.get('result_url')
|
||||
or result_data.get('output', {}).get('url')
|
||||
or _extract_first_url(result_data)
|
||||
)
|
||||
task['result_url'] = result_url
|
||||
# Cancel polling since webhook already notified us
|
||||
poll = _poll_tasks.pop(task['task_id'], None)
|
||||
if poll and not poll.done():
|
||||
poll.cancel()
|
||||
logger.info(f'Task {task["task_id"]} completed via webhook')
|
||||
break
|
||||
39
app/services/webhook.py
Normal file
39
app/services/webhook.py
Normal file
@@ -0,0 +1,39 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Header, HTTPException, Request
|
||||
|
||||
from app.config import settings
|
||||
from app.services.task_tracker import handle_webhook_completion
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/api/v1', tags=['webhooks'])
|
||||
|
||||
|
||||
@router.post('/webhooks/task-complete')
|
||||
async def webhook_task_complete(
|
||||
request: Request,
|
||||
x_webhook_signature: str | None = Header(None),
|
||||
):
|
||||
body = await request.body()
|
||||
|
||||
if settings.webhook_secret:
|
||||
if not x_webhook_signature:
|
||||
raise HTTPException(status_code=401, detail='Missing webhook signature')
|
||||
expected = hmac.new(
|
||||
settings.webhook_secret.encode(),
|
||||
body,
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
if not hmac.compare_digest(expected, x_webhook_signature):
|
||||
raise HTTPException(status_code=401, detail='Invalid webhook signature')
|
||||
|
||||
data = await request.json()
|
||||
task_id = data.get('task_id') or data.get('id')
|
||||
if not task_id:
|
||||
raise HTTPException(status_code=400, detail='Missing task_id in webhook payload')
|
||||
|
||||
handle_webhook_completion(str(task_id), data)
|
||||
return {'status': 'ok'}
|
||||
Reference in New Issue
Block a user