Spaces:
Sleeping
Sleeping
| from __future__ import annotations | |
| import uuid | |
| from pathlib import Path | |
| from typing import Literal, Optional | |
| import structlog | |
| from fastapi import APIRouter, BackgroundTasks, Depends, File, Query, UploadFile | |
| from fastapi.responses import FileResponse, JSONResponse | |
| from app.core.auth import require_api_key | |
| from app.core.config import settings | |
| from app.core.exceptions import InvalidParameterError, JobNotFoundError | |
| from app.models.schemas import ( | |
| AsyncJobResponse, | |
| ConversionParams, | |
| ConversionResult, | |
| HealthResponse, | |
| ImageFormat, | |
| JobStatus, | |
| JobStatusResponse, | |
| ) | |
| from app.services.conversion import conversion_service | |
| from app.services.file_service import upload_file_to_service | |
| from app.services.upload_orchestrator import convert_and_upload | |
| from app.utils.validators import fetch_pdf_from_url, read_and_validate_pdf | |
| logger = structlog.get_logger(__name__) | |
| router = APIRouter() | |
| _jobs: dict[str, JobStatusResponse] = {} | |
| _MEDIA_TYPES: dict[str, str] = { | |
| "png": "image/png", | |
| "jpg": "image/jpeg", | |
| "jpeg": "image/jpeg", | |
| } | |
| def build_conversion_params( | |
| format: ImageFormat = Query(ImageFormat.PNG, description="Output image format"), | |
| dpi: int = Query(settings.DEFAULT_DPI, ge=72, le=settings.MAX_DPI, description="Render DPI (72–600)"), | |
| quality: int = Query(settings.DEFAULT_JPEG_QUALITY, ge=1, le=100, description="JPEG/WEBP quality (1–100)"), | |
| pages: Optional[str] = Query(None, description="Page spec: '1', '1-3', '1,3,5-7'. Empty = all pages."), | |
| grayscale: bool = Query(False, description="Convert output to grayscale"), | |
| transparent_bg: bool = Query(False, description="Preserve transparency (PNG only)"), | |
| split_page: bool = Query(False, description="True: each page separate image. False: stitch into one tall image."), | |
| ) -> ConversionParams: | |
| try: | |
| return ConversionParams( | |
| format=format, | |
| dpi=dpi, | |
| quality=quality, | |
| pages=pages, | |
| grayscale=grayscale, | |
| transparent_bg=transparent_bg, | |
| split_page=split_page, | |
| ) | |
| except Exception as exc: | |
| raise InvalidParameterError(str(exc)) from exc | |
| def _upload_mode_param( | |
| upload_mode: Optional[Literal["per_page", "merged"]] = Query( | |
| None, | |
| description=( | |
| "Upload behaviour after conversion. " | |
| "'per_page' uploads each page image immediately as it is rendered (lowest latency). " | |
| "'merged' waits for all pages to render, then uploads them. " | |
| "Omit to use the server default (PDF_UPLOAD_MODE env var)." | |
| ), | |
| ), | |
| ) -> Optional[str]: | |
| return upload_mode | |
| async def convert_sync_upload( | |
| file: UploadFile = File(...), | |
| params: ConversionParams = Depends(build_conversion_params), | |
| upload_mode: Optional[str] = Depends(_upload_mode_param), | |
| _: str = Depends(require_api_key), | |
| ): | |
| """ | |
| Upload a PDF and convert all (or selected) pages to JPEG or PNG images synchronously. | |
| - Each page is rendered in **parallel** (split into individual page blobs when split_page=true). | |
| - Converted images are uploaded to the File Upload Service automatically. | |
| - Returns file URLs and metadata for every converted page. | |
| - Supported output formats: JPEG, PNG. | |
| """ | |
| pdf_bytes = await read_and_validate_pdf(file) | |
| job_id = str(uuid.uuid4()) | |
| return await convert_and_upload(pdf_bytes, params, job_id, upload_mode) | |
| async def convert_sync_url( | |
| pdf_url: str = Query(..., description="Publicly accessible HTTPS URL to a PDF"), | |
| params: ConversionParams = Depends(build_conversion_params), | |
| upload_mode: Optional[str] = Depends(_upload_mode_param), | |
| _: str = Depends(require_api_key), | |
| ): | |
| """Fetch a PDF from a public URL and convert it. Private/loopback IPs are blocked.""" | |
| pdf_bytes = await fetch_pdf_from_url(pdf_url) | |
| job_id = str(uuid.uuid4()) | |
| return await convert_and_upload(pdf_bytes, params, job_id, upload_mode) | |
| async def convert_async_upload( | |
| background_tasks: BackgroundTasks, | |
| file: UploadFile = File(...), | |
| params: ConversionParams = Depends(build_conversion_params), | |
| upload_mode: Optional[str] = Depends(_upload_mode_param), | |
| _: str = Depends(require_api_key), | |
| ): | |
| """Submit a PDF for async conversion. Returns 202 immediately; poll /jobs/{job_id}.""" | |
| pdf_bytes = await read_and_validate_pdf(file) | |
| job_id = str(uuid.uuid4()) | |
| _jobs[job_id] = JobStatusResponse(job_id=job_id, status=JobStatus.PENDING, progress=0.0) | |
| background_tasks.add_task(_run_conversion_job, job_id, pdf_bytes, params, upload_mode) | |
| return AsyncJobResponse( | |
| job_id=job_id, | |
| status=JobStatus.PENDING, | |
| message="Job accepted. Poll the status URL for progress.", | |
| status_url=f"/api/v1/jobs/{job_id}", | |
| ) | |
| async def convert_async_url( | |
| background_tasks: BackgroundTasks, | |
| pdf_url: str = Query(..., description="Publicly accessible HTTPS URL to a PDF"), | |
| params: ConversionParams = Depends(build_conversion_params), | |
| upload_mode: Optional[str] = Depends(_upload_mode_param), | |
| _: str = Depends(require_api_key), | |
| ): | |
| """Submit a URL-based PDF conversion job asynchronously.""" | |
| pdf_bytes = await fetch_pdf_from_url(pdf_url) | |
| job_id = str(uuid.uuid4()) | |
| _jobs[job_id] = JobStatusResponse(job_id=job_id, status=JobStatus.PENDING, progress=0.0) | |
| background_tasks.add_task(_run_conversion_job, job_id, pdf_bytes, params, upload_mode) | |
| return AsyncJobResponse( | |
| job_id=job_id, | |
| status=JobStatus.PENDING, | |
| message="Job accepted. Poll the status URL for progress.", | |
| status_url=f"/api/v1/jobs/{job_id}", | |
| ) | |
| async def get_job_status( | |
| job_id: str, | |
| _: str = Depends(require_api_key), | |
| ): | |
| job = _jobs.get(job_id) | |
| if not job: | |
| raise JobNotFoundError(job_id) | |
| return job | |
| async def download_file( | |
| job_id: str, | |
| filename: str, | |
| _: str = Depends(require_api_key), | |
| ): | |
| output_root = Path(settings.OUTPUT_DIR).resolve() | |
| file_path = (output_root / job_id / filename).resolve() | |
| try: | |
| file_path.relative_to(output_root) | |
| except ValueError: | |
| raise JobNotFoundError(f"{job_id}/{filename}") | |
| if not file_path.exists() or not file_path.is_file(): | |
| raise JobNotFoundError(f"{job_id}/{filename}") | |
| ext = file_path.suffix.lstrip(".").lower() | |
| media_type = _MEDIA_TYPES.get(ext, "application/octet-stream") | |
| return FileResponse(path=str(file_path), media_type=media_type, filename=filename) | |
| async def health(): | |
| return HealthResponse(status="ok", version=settings.VERSION, environment=settings.ENV) | |
| async def ready(): | |
| try: | |
| probe = Path(settings.OUTPUT_DIR) / ".probe" | |
| probe.touch() | |
| probe.unlink() | |
| except OSError as exc: | |
| return JSONResponse(status_code=503, content={"status": "unavailable", "detail": str(exc)}) | |
| return HealthResponse(status="ready", version=settings.VERSION, environment=settings.ENV) | |
| async def _run_conversion_job( | |
| job_id: str, | |
| pdf_bytes: bytes, | |
| params: ConversionParams, | |
| upload_mode: Optional[str], | |
| ) -> None: | |
| _jobs[job_id] = JobStatusResponse(job_id=job_id, status=JobStatus.PROCESSING, progress=10.0) | |
| try: | |
| result = await convert_and_upload(pdf_bytes, params, job_id, upload_mode) | |
| _jobs[job_id] = JobStatusResponse( | |
| job_id=job_id, | |
| status=JobStatus.COMPLETED, | |
| progress=100.0, | |
| result=result, | |
| ) | |
| logger.info("async_job_complete", job_id=job_id) | |
| except Exception as exc: | |
| logger.exception("async_job_failed", job_id=job_id) | |
| _jobs[job_id] = JobStatusResponse( | |
| job_id=job_id, | |
| status=JobStatus.FAILED, | |
| progress=0.0, | |
| error=str(exc), | |
| ) | |