Spaces:
Sleeping
Sleeping
Commit ·
936f0bf
1
Parent(s): 0b750b6
feat: updated
Browse files- .dockerignore +27 -0
- Dockerfile +16 -15
- api/server.py +153 -243
- app/__init__.py +0 -0
- app/api/__init__.py +0 -0
- app/api/routes.py +256 -0
- app/banner.py +21 -0
- app/core/__init__.py +0 -0
- app/core/auth.py +21 -0
- app/core/config.py +91 -0
- app/core/exceptions.py +61 -0
- app/core/logging.py +62 -0
- app/core/rate_limit.py +218 -0
- app/main.py +95 -0
- app/models/__init__.py +0 -0
- app/models/schemas.py +162 -0
- app/services/__init__.py +0 -0
- app/services/conversion.py +341 -0
- app/services/file_service.py +99 -0
- app/services/ping.py +41 -0
- app/services/upload_orchestrator.py +131 -0
- app/utils/__init__.py +0 -0
- app/utils/cleanup.py +40 -0
- app/utils/validators.py +121 -0
- requirements.txt +14 -0
.dockerignore
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__
|
| 2 |
+
*.pyc
|
| 3 |
+
*.pyo
|
| 4 |
+
*.pyd
|
| 5 |
+
.git
|
| 6 |
+
.gitignore
|
| 7 |
+
.env
|
| 8 |
+
.venv
|
| 9 |
+
venv
|
| 10 |
+
*.log
|
| 11 |
+
logs
|
| 12 |
+
.vscode
|
| 13 |
+
.idea
|
| 14 |
+
.claude
|
| 15 |
+
__pycache__
|
| 16 |
+
.pytest_cache
|
| 17 |
+
.ruff_cache
|
| 18 |
+
.mypy_cache
|
| 19 |
+
*.md
|
| 20 |
+
!README.md
|
| 21 |
+
docs
|
| 22 |
+
tmp
|
| 23 |
+
temp
|
| 24 |
+
.DS_Store
|
| 25 |
+
Thumbs.db
|
| 26 |
+
*.swp
|
| 27 |
+
*.swo
|
Dockerfile
CHANGED
|
@@ -1,54 +1,55 @@
|
|
| 1 |
# ─────────────────────────────────────────────────────────────
|
| 2 |
-
#
|
| 3 |
# Port: 7860
|
| 4 |
-
# Uses Microsoft MarkItDown and RapidOCR for document processing.
|
| 5 |
-
# JSON extraction: pandas for CSV/XLS/XLSX, spaCy NER for all other formats.
|
| 6 |
# ─────────────────────────────────────────────────────────────
|
| 7 |
|
| 8 |
FROM python:3.12-slim
|
| 9 |
|
| 10 |
-
LABEL maintainer="
|
| 11 |
-
LABEL description="Document-to-Markdown
|
| 12 |
LABEL version="2.2.0"
|
| 13 |
|
| 14 |
-
# ── System dependencies ──────────────────────────────────────
|
| 15 |
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 16 |
curl \
|
| 17 |
ffmpeg \
|
| 18 |
libmagic1 \
|
|
|
|
| 19 |
&& rm -rf /var/lib/apt/lists/*
|
| 20 |
|
| 21 |
-
# ── Non-root user ────────────────────────────────────────────
|
| 22 |
RUN groupadd --gid 1000 appuser && \
|
| 23 |
useradd --uid 1000 --gid appuser --shell /bin/bash --create-home appuser
|
| 24 |
|
| 25 |
WORKDIR /app
|
| 26 |
|
| 27 |
-
# ── Python
|
| 28 |
COPY requirements.txt .
|
| 29 |
RUN pip install --no-cache-dir --upgrade pip && \
|
| 30 |
pip install --no-cache-dir -r requirements.txt && \
|
| 31 |
python -m spacy download en_core_web_sm
|
| 32 |
|
| 33 |
-
# ── Application code ─────────────────────────────────────────
|
| 34 |
COPY --chown=appuser:appuser . .
|
| 35 |
|
| 36 |
-
# ── Ensure startup script exists and is executable ──────────
|
| 37 |
RUN test -f /app/start.sh || (echo "ERROR: start.sh not found" && exit 1) && \
|
| 38 |
chmod +x /app/start.sh
|
| 39 |
|
| 40 |
-
# ── Create
|
| 41 |
-
RUN mkdir -p /app/logs && \
|
| 42 |
-
chown -R appuser:appuser /app/logs
|
| 43 |
|
| 44 |
USER appuser
|
| 45 |
|
| 46 |
ENV PYTHONPATH=/app
|
| 47 |
ENV PYTHONUNBUFFERED=1
|
|
|
|
|
|
|
| 48 |
|
| 49 |
EXPOSE 7860
|
| 50 |
|
| 51 |
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
|
| 52 |
-
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:7860/
|
| 53 |
|
| 54 |
-
CMD ["/bin/bash", "/app/start.sh"]
|
|
|
|
| 1 |
# ─────────────────────────────────────────────────────────────
|
| 2 |
+
# Reconciliation File Processing Service — Production Dockerfile
|
| 3 |
# Port: 7860
|
|
|
|
|
|
|
| 4 |
# ─────────────────────────────────────────────────────────────
|
| 5 |
|
| 6 |
FROM python:3.12-slim
|
| 7 |
|
| 8 |
+
LABEL maintainer="Reconciliation API"
|
| 9 |
+
LABEL description="Document-to-Markdown & PDF-to-image API"
|
| 10 |
LABEL version="2.2.0"
|
| 11 |
|
| 12 |
+
# ── System dependencies ──────────────────────────────────────
|
| 13 |
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 14 |
curl \
|
| 15 |
ffmpeg \
|
| 16 |
libmagic1 \
|
| 17 |
+
poppler-utils \
|
| 18 |
&& rm -rf /var/lib/apt/lists/*
|
| 19 |
|
| 20 |
+
# ── Non-root user ────────────────────────────────────────────
|
| 21 |
RUN groupadd --gid 1000 appuser && \
|
| 22 |
useradd --uid 1000 --gid appuser --shell /bin/bash --create-home appuser
|
| 23 |
|
| 24 |
WORKDIR /app
|
| 25 |
|
| 26 |
+
# ── Python dependencies ──────────────────────────────────────
|
| 27 |
COPY requirements.txt .
|
| 28 |
RUN pip install --no-cache-dir --upgrade pip && \
|
| 29 |
pip install --no-cache-dir -r requirements.txt && \
|
| 30 |
python -m spacy download en_core_web_sm
|
| 31 |
|
| 32 |
+
# ── Application code ─────────────────────────────────────────
|
| 33 |
COPY --chown=appuser:appuser . .
|
| 34 |
|
| 35 |
+
# ── Ensure startup script exists and is executable ──────────
|
| 36 |
RUN test -f /app/start.sh || (echo "ERROR: start.sh not found" && exit 1) && \
|
| 37 |
chmod +x /app/start.sh
|
| 38 |
|
| 39 |
+
# ── Create runtime directories ──────────────────────────────
|
| 40 |
+
RUN mkdir -p /app/logs /tmp/pdf2img_outputs /tmp/pdf2img_temp && \
|
| 41 |
+
chown -R appuser:appuser /app/logs /tmp/pdf2img_outputs /tmp/pdf2img_temp
|
| 42 |
|
| 43 |
USER appuser
|
| 44 |
|
| 45 |
ENV PYTHONPATH=/app
|
| 46 |
ENV PYTHONUNBUFFERED=1
|
| 47 |
+
ENV OUTPUT_DIR=/tmp/pdf2img_outputs
|
| 48 |
+
ENV TEMP_DIR=/tmp/pdf2img_temp
|
| 49 |
|
| 50 |
EXPOSE 7860
|
| 51 |
|
| 52 |
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
|
| 53 |
+
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:7860/ping')" || exit 1
|
| 54 |
|
| 55 |
+
CMD ["/bin/bash", "/app/start.sh"]
|
api/server.py
CHANGED
|
@@ -1,30 +1,43 @@
|
|
| 1 |
"""
|
| 2 |
-
MarkItDown API — FastAPI server.
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
"""
|
| 19 |
|
| 20 |
from __future__ import annotations
|
| 21 |
|
| 22 |
import asyncio
|
| 23 |
import concurrent.futures
|
| 24 |
-
import datetime
|
| 25 |
import os
|
| 26 |
import threading
|
| 27 |
import time
|
|
|
|
| 28 |
import urllib.request
|
| 29 |
from contextlib import asynccontextmanager
|
| 30 |
from datetime import datetime, timezone
|
|
@@ -33,27 +46,31 @@ from typing import Annotated, Any, Dict, List, Optional
|
|
| 33 |
from urllib.parse import urlparse
|
| 34 |
|
| 35 |
import httpx
|
| 36 |
-
import
|
| 37 |
-
from fastapi import
|
| 38 |
-
from fastapi.responses import PlainTextResponse
|
| 39 |
from fastapi.middleware.cors import CORSMiddleware
|
| 40 |
from fastapi.middleware.gzip import GZipMiddleware
|
|
|
|
| 41 |
from pydantic import BaseModel, Field, field_validator
|
| 42 |
|
| 43 |
from core import ConversionError, ConversionResult, DocumentConverter, SUPPORTED_EXTENSIONS
|
| 44 |
from extraction.generic_json_extractor import extract
|
| 45 |
-
from extraction.label_mapper import validate_mappings
|
| 46 |
from logger import get_logger
|
| 47 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
logger = get_logger(__name__)
|
| 49 |
|
| 50 |
_START_TIME = time.time()
|
| 51 |
APP_NAME = "reconciliation-file-processing-service"
|
| 52 |
|
| 53 |
-
# Maximum accepted upload size (100 MB).
|
| 54 |
MAX_UPLOAD_BYTES = 100 * 1024 * 1024
|
| 55 |
|
| 56 |
-
# Thread pool for CPU-bound conversion work running alongside the async event loop.
|
| 57 |
MAX_WORKERS = min(32, (os.cpu_count() or 1) + 4)
|
| 58 |
_thread_pool = concurrent.futures.ThreadPoolExecutor(max_workers=MAX_WORKERS)
|
| 59 |
|
|
@@ -67,11 +84,10 @@ logger.info("Thread pool initialised with %d workers", MAX_WORKERS)
|
|
| 67 |
# ---------------------------------------------------------------------------
|
| 68 |
|
| 69 |
PING_URL = os.environ.get("PING_URL", "https://validops-us-data-extract.hf.space/health")
|
| 70 |
-
PING_INTERVAL_SECONDS = 30 * 60
|
| 71 |
|
| 72 |
|
| 73 |
def _ping_once() -> None:
|
| 74 |
-
"""Send a single HTTP GET to PING_URL and log the outcome."""
|
| 75 |
ts = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
|
| 76 |
try:
|
| 77 |
with urllib.request.urlopen(PING_URL, timeout=10) as resp:
|
|
@@ -81,7 +97,6 @@ def _ping_once() -> None:
|
|
| 81 |
|
| 82 |
|
| 83 |
def _ping_loop() -> None:
|
| 84 |
-
"""Background loop: sleep PING_INTERVAL_SECONDS, ping, repeat."""
|
| 85 |
logger.info("self_ping | scheduler started | interval_minutes=30 | url=%s", PING_URL)
|
| 86 |
while True:
|
| 87 |
time.sleep(PING_INTERVAL_SECONDS)
|
|
@@ -89,7 +104,6 @@ def _ping_loop() -> None:
|
|
| 89 |
|
| 90 |
|
| 91 |
def _start_ping_scheduler() -> None:
|
| 92 |
-
"""Start the self-ping daemon thread. Called once from lifespan startup."""
|
| 93 |
thread = threading.Thread(target=_ping_loop, name="self-ping", daemon=True)
|
| 94 |
thread.start()
|
| 95 |
|
|
@@ -100,14 +114,12 @@ def _start_ping_scheduler() -> None:
|
|
| 100 |
|
| 101 |
@asynccontextmanager
|
| 102 |
async def lifespan(app: FastAPI):
|
| 103 |
-
"""Application lifespan handler — runs startup and shutdown logic."""
|
| 104 |
logger.info(
|
| 105 |
-
"MarkItDown API starting | version=2.1.0 | host=0.0.0.0:7860
|
| 106 |
-
datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC"),
|
| 107 |
)
|
| 108 |
_start_ping_scheduler()
|
| 109 |
yield
|
| 110 |
-
logger.info("MarkItDown API shutting down")
|
| 111 |
|
| 112 |
|
| 113 |
# ---------------------------------------------------------------------------
|
|
@@ -115,23 +127,19 @@ async def lifespan(app: FastAPI):
|
|
| 115 |
# ---------------------------------------------------------------------------
|
| 116 |
|
| 117 |
app = FastAPI(
|
| 118 |
-
title="MarkItDown API",
|
| 119 |
description=(
|
| 120 |
-
"
|
| 121 |
-
"and
|
| 122 |
-
"Markdown with optional JSON field extraction."
|
| 123 |
),
|
| 124 |
version="2.1.0",
|
| 125 |
docs_url="/docs",
|
| 126 |
redoc_url="/redoc",
|
| 127 |
-
openapi_tags=[
|
| 128 |
-
{"name": "Convert", "description": "Single-file or single-URL conversion"},
|
| 129 |
-
{"name": "Batch", "description": "Bulk conversion — up to 10 files or 20 URLs"},
|
| 130 |
-
{"name": "System", "description": "Health, server info, and supported formats"},
|
| 131 |
-
],
|
| 132 |
lifespan=lifespan,
|
| 133 |
)
|
| 134 |
|
|
|
|
|
|
|
| 135 |
app.add_middleware(GZipMiddleware, minimum_size=1000)
|
| 136 |
app.add_middleware(
|
| 137 |
CORSMiddleware,
|
|
@@ -141,13 +149,66 @@ app.add_middleware(
|
|
| 141 |
)
|
| 142 |
|
| 143 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
# ---------------------------------------------------------------------------
|
| 145 |
-
#
|
| 146 |
# ---------------------------------------------------------------------------
|
| 147 |
|
| 148 |
-
|
| 149 |
-
"""File-level statistics attached to every successful conversion."""
|
| 150 |
|
|
|
|
|
|
|
| 151 |
source: str
|
| 152 |
char_count: int
|
| 153 |
word_count: int
|
|
@@ -159,8 +220,6 @@ class ConversionMetadata(BaseModel):
|
|
| 159 |
|
| 160 |
|
| 161 |
class ConversionResponse(BaseModel):
|
| 162 |
-
"""Standard response envelope for single-item conversion endpoints."""
|
| 163 |
-
|
| 164 |
success: bool
|
| 165 |
time_ms: float
|
| 166 |
content: str
|
|
@@ -171,12 +230,9 @@ class ConversionResponse(BaseModel):
|
|
| 171 |
|
| 172 |
|
| 173 |
class UrlRequest(BaseModel):
|
| 174 |
-
"""Request body for /convert/url."""
|
| 175 |
-
|
| 176 |
url: str
|
| 177 |
return_json: bool = False
|
| 178 |
mappings: Optional[Dict[str, Dict[str, Any]]] = None
|
| 179 |
-
|
| 180 |
model_config = {"populate_by_name": True}
|
| 181 |
|
| 182 |
@field_validator("url")
|
|
@@ -188,8 +244,6 @@ class UrlRequest(BaseModel):
|
|
| 188 |
|
| 189 |
|
| 190 |
class BatchUrlRequest(BaseModel):
|
| 191 |
-
"""Request body for /batch/urls."""
|
| 192 |
-
|
| 193 |
urls: List[str]
|
| 194 |
|
| 195 |
@field_validator("urls")
|
|
@@ -204,8 +258,6 @@ class BatchUrlRequest(BaseModel):
|
|
| 204 |
|
| 205 |
|
| 206 |
class BatchFileResult(BaseModel):
|
| 207 |
-
"""Per-item result within a batch response."""
|
| 208 |
-
|
| 209 |
filename: str
|
| 210 |
success: bool
|
| 211 |
time_ms: float
|
|
@@ -215,8 +267,6 @@ class BatchFileResult(BaseModel):
|
|
| 215 |
|
| 216 |
|
| 217 |
class BatchResponse(BaseModel):
|
| 218 |
-
"""Aggregate response for batch endpoints."""
|
| 219 |
-
|
| 220 |
total: int
|
| 221 |
succeeded: int
|
| 222 |
failed: int
|
|
@@ -224,12 +274,8 @@ class BatchResponse(BaseModel):
|
|
| 224 |
results: List[BatchFileResult]
|
| 225 |
|
| 226 |
|
| 227 |
-
# ----
|
| 228 |
-
# Internal helpers
|
| 229 |
-
# ---------------------------------------------------------------------------
|
| 230 |
-
|
| 231 |
def _build_metadata(result: ConversionResult) -> ConversionMetadata:
|
| 232 |
-
"""Map a ConversionResult to its API metadata representation."""
|
| 233 |
return ConversionMetadata(
|
| 234 |
source=result.source,
|
| 235 |
char_count=result.char_count,
|
|
@@ -250,7 +296,6 @@ async def _build_response(
|
|
| 250 |
raw_data: Optional[bytes] = None,
|
| 251 |
mappings: Optional[Dict[str, Dict[str, Any]]] = None,
|
| 252 |
) -> ConversionResponse:
|
| 253 |
-
"""Construct a ConversionResponse, optionally running JSON extraction."""
|
| 254 |
json_content: Optional[Any] = None
|
| 255 |
error_message: Optional[str] = None
|
| 256 |
|
|
@@ -276,7 +321,6 @@ async def _build_response(
|
|
| 276 |
|
| 277 |
|
| 278 |
def _raise_for_error(outcome: ConversionError) -> None:
|
| 279 |
-
"""Translate a ConversionError into an appropriate HTTPException."""
|
| 280 |
status_map = {
|
| 281 |
"FileNotFoundError": status.HTTP_404_NOT_FOUND,
|
| 282 |
"ValueError": status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
@@ -313,28 +357,14 @@ def _batch_result_from_ok(result: ConversionResult) -> BatchFileResult:
|
|
| 313 |
)
|
| 314 |
|
| 315 |
|
| 316 |
-
# ----
|
| 317 |
-
|
| 318 |
-
# ---------------------------------------------------------------------------
|
| 319 |
-
|
| 320 |
-
@app.get("/", tags=["System"], summary="Root", include_in_schema=False)
|
| 321 |
-
async def root():
|
| 322 |
-
return {"service": APP_NAME, "version": "2.1.0", "status": "running"}
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
@app.get("/ping", tags=["System"], summary="Ping", include_in_schema=False)
|
| 326 |
-
async def ping():
|
| 327 |
-
return {"message": f"{APP_NAME} is running..."}
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
@app.get("/formats", tags=["System"], summary="Supported file formats by category")
|
| 331 |
async def list_formats():
|
| 332 |
-
"""Return all supported file extensions, grouped by document category."""
|
| 333 |
by_category = {
|
| 334 |
"documents": [e for e in SUPPORTED_EXTENSIONS if e in {".pdf", ".docx", ".doc", ".epub"}],
|
| 335 |
"office": [e for e in SUPPORTED_EXTENSIONS if e in {".pptx", ".ppt", ".xlsx", ".xls"}],
|
| 336 |
"data": [e for e in SUPPORTED_EXTENSIONS if e in {".csv", ".json", ".xml"}],
|
| 337 |
-
"web": [e for e in SUPPORTED_EXTENSIONS if e in {".
|
| 338 |
"text": [e for e in SUPPORTED_EXTENSIONS if e in {".txt", ".md", ".rst"}],
|
| 339 |
"images": [e for e in SUPPORTED_EXTENSIONS if e in {".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".tiff"}],
|
| 340 |
"audio": [e for e in SUPPORTED_EXTENSIONS if e in {".mp3", ".wav", ".ogg", ".flac"}],
|
|
@@ -348,9 +378,8 @@ async def list_formats():
|
|
| 348 |
}
|
| 349 |
|
| 350 |
|
| 351 |
-
@
|
| 352 |
async def list_spacy_labels():
|
| 353 |
-
"""Return spaCy Named Entity Recognition labels available for structured extraction mappings."""
|
| 354 |
from extraction.spacy_extractor import VALID_SPACY_LABELS
|
| 355 |
|
| 356 |
return {
|
|
@@ -372,14 +401,10 @@ async def list_spacy_labels():
|
|
| 372 |
}
|
| 373 |
|
| 374 |
|
| 375 |
-
|
| 376 |
-
# Convert endpoints
|
| 377 |
-
# ---------------------------------------------------------------------------
|
| 378 |
-
|
| 379 |
-
@app.post(
|
| 380 |
"/convert/file",
|
| 381 |
response_model=ConversionResponse,
|
| 382 |
-
tags=["
|
| 383 |
summary="Convert an uploaded file to Markdown",
|
| 384 |
)
|
| 385 |
async def convert_file(
|
|
@@ -388,26 +413,11 @@ async def convert_file(
|
|
| 388 |
return_json: bool = Form(False),
|
| 389 |
mappings: Optional[str] = Form(
|
| 390 |
None,
|
| 391 |
-
description=
|
| 392 |
-
"JSON string defining spaCy field extraction rules. "
|
| 393 |
-
"Example: {\"company\": {\"source_type\": \"entity\", \"label\": \"ORG\"}}"
|
| 394 |
-
),
|
| 395 |
),
|
| 396 |
):
|
| 397 |
-
"""Convert a single uploaded file to Markdown.
|
| 398 |
-
|
| 399 |
-
Set ``return_json=true`` to also receive structured JSON extraction:
|
| 400 |
-
- CSV / XLS / XLSX files: automatic tabular extraction.
|
| 401 |
-
- All other files: provide ``mappings`` with spaCy extraction rules.
|
| 402 |
-
|
| 403 |
-
On success, ``json_content`` contains the extracted data.
|
| 404 |
-
On extraction failure, ``json_content`` is null and ``error_message`` is populated.
|
| 405 |
-
"""
|
| 406 |
if file is None:
|
| 407 |
-
raise HTTPException(
|
| 408 |
-
status_code=400,
|
| 409 |
-
detail={"success": False, "message": "No file provided."},
|
| 410 |
-
)
|
| 411 |
|
| 412 |
parsed_mappings: Optional[Dict[str, Any]] = None
|
| 413 |
if mappings:
|
|
@@ -415,152 +425,84 @@ async def convert_file(
|
|
| 415 |
try:
|
| 416 |
parsed_mappings = _json.loads(mappings)
|
| 417 |
except _json.JSONDecodeError:
|
| 418 |
-
raise HTTPException(
|
| 419 |
-
status_code=400,
|
| 420 |
-
detail={"success": False, "message": "Invalid JSON in mappings parameter."},
|
| 421 |
-
)
|
| 422 |
|
| 423 |
logger.info("convert_file | filename=%s", file.filename)
|
| 424 |
-
|
| 425 |
raw = await file.read()
|
| 426 |
if len(raw) > MAX_UPLOAD_BYTES:
|
| 427 |
logger.warning("convert_file | file too large | filename=%s | size=%d", file.filename, len(raw))
|
| 428 |
-
raise HTTPException(
|
| 429 |
-
status_code=413,
|
| 430 |
-
detail={"success": False, "message": "File exceeds 100 MB limit."},
|
| 431 |
-
)
|
| 432 |
|
| 433 |
loop = asyncio.get_running_loop()
|
| 434 |
-
outcome = await loop.run_in_executor(
|
| 435 |
-
_thread_pool, _converter.convert_stream, raw, file.filename or "upload"
|
| 436 |
-
)
|
| 437 |
|
| 438 |
if isinstance(outcome, ConversionError):
|
| 439 |
logger.error("convert_file | conversion failed | filename=%s | error=%s", file.filename, outcome.message)
|
| 440 |
_raise_for_error(outcome)
|
| 441 |
|
| 442 |
-
logger.info(
|
| 443 |
-
"convert_file | success | filename=%s | chars=%d | time_ms=%.1f",
|
| 444 |
-
file.filename,
|
| 445 |
-
outcome.char_count,
|
| 446 |
-
outcome.duration_ms,
|
| 447 |
-
)
|
| 448 |
|
| 449 |
if plain_text:
|
| 450 |
return PlainTextResponse(outcome.markdown)
|
| 451 |
|
| 452 |
-
return await _build_response(
|
| 453 |
-
outcome,
|
| 454 |
-
return_json=return_json,
|
| 455 |
-
filename=file.filename,
|
| 456 |
-
raw_data=raw,
|
| 457 |
-
mappings=parsed_mappings,
|
| 458 |
-
)
|
| 459 |
|
| 460 |
|
| 461 |
-
@
|
| 462 |
"/convert/url",
|
| 463 |
response_model=ConversionResponse,
|
| 464 |
-
tags=["
|
| 465 |
summary="Convert a public URL to Markdown",
|
| 466 |
)
|
| 467 |
async def convert_url(body: UrlRequest):
|
| 468 |
-
"""Convert a public HTTP/HTTPS URL to Markdown.
|
| 469 |
-
|
| 470 |
-
When ``return_json=true``, the URL content is fetched as raw bytes first
|
| 471 |
-
to enable binary-aware extraction (e.g. Excel files served over HTTP).
|
| 472 |
-
"""
|
| 473 |
logger.info("convert_url | url=%s", body.url)
|
| 474 |
parsed = urlparse(body.url)
|
| 475 |
filename = Path(parsed.path).name or "url_content"
|
| 476 |
-
|
| 477 |
loop = asyncio.get_running_loop()
|
| 478 |
|
| 479 |
if body.return_json:
|
| 480 |
-
# Fetch raw bytes so binary formats (XLSX, etc.) can be properly parsed.
|
| 481 |
try:
|
| 482 |
async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
|
| 483 |
resp = await client.get(body.url)
|
| 484 |
resp.raise_for_status()
|
| 485 |
except httpx.HTTPError as exc:
|
| 486 |
logger.error("convert_url | fetch failed | url=%s | error=%s", body.url, exc)
|
| 487 |
-
raise HTTPException(
|
| 488 |
-
status_code=400,
|
| 489 |
-
detail={"success": False, "message": f"Failed to fetch URL: {exc}"},
|
| 490 |
-
)
|
| 491 |
|
| 492 |
raw_data = resp.content
|
| 493 |
if len(raw_data) > MAX_UPLOAD_BYTES:
|
| 494 |
-
raise HTTPException(
|
| 495 |
-
status_code=413,
|
| 496 |
-
detail={"success": False, "message": "File exceeds 100 MB limit."},
|
| 497 |
-
)
|
| 498 |
|
| 499 |
-
outcome = await loop.run_in_executor(
|
| 500 |
-
_thread_pool, _converter.convert_stream, raw_data, filename
|
| 501 |
-
)
|
| 502 |
if isinstance(outcome, ConversionError):
|
| 503 |
logger.error("convert_url | conversion failed | url=%s | error=%s", body.url, outcome.message)
|
| 504 |
_raise_for_error(outcome)
|
| 505 |
|
| 506 |
-
logger.info(
|
| 507 |
-
|
| 508 |
-
body.url, outcome.char_count, outcome.duration_ms,
|
| 509 |
-
)
|
| 510 |
-
return await _build_response(
|
| 511 |
-
outcome,
|
| 512 |
-
return_json=body.return_json,
|
| 513 |
-
filename=filename,
|
| 514 |
-
raw_data=raw_data,
|
| 515 |
-
mappings=body.mappings,
|
| 516 |
-
)
|
| 517 |
|
| 518 |
-
# Standard conversion without binary fetch.
|
| 519 |
outcome = await loop.run_in_executor(_thread_pool, _converter.convert_url, body.url)
|
| 520 |
if isinstance(outcome, ConversionError):
|
| 521 |
logger.error("convert_url | conversion failed | url=%s | error=%s", body.url, outcome.message)
|
| 522 |
_raise_for_error(outcome)
|
| 523 |
|
| 524 |
-
logger.info(
|
| 525 |
-
|
| 526 |
-
body.url, outcome.char_count, outcome.duration_ms,
|
| 527 |
-
)
|
| 528 |
-
return await _build_response(
|
| 529 |
-
outcome,
|
| 530 |
-
return_json=body.return_json,
|
| 531 |
-
filename=filename,
|
| 532 |
-
mappings=body.mappings,
|
| 533 |
-
)
|
| 534 |
-
|
| 535 |
|
| 536 |
-
# ---------------------------------------------------------------------------
|
| 537 |
-
# Batch endpoints
|
| 538 |
-
# ---------------------------------------------------------------------------
|
| 539 |
|
| 540 |
-
@
|
| 541 |
"/batch/files",
|
| 542 |
response_model=BatchResponse,
|
| 543 |
-
tags=["
|
| 544 |
summary="Convert multiple files (up to 10)",
|
| 545 |
)
|
| 546 |
async def batch_files(
|
| 547 |
files: Annotated[List[UploadFile], File(description="Files to convert — maximum 10")],
|
| 548 |
):
|
| 549 |
-
"""Convert up to 10 uploaded files in a single request.
|
| 550 |
-
|
| 551 |
-
Files are processed concurrently. Per-item results include success/error
|
| 552 |
-
details, timing, and content metadata.
|
| 553 |
-
"""
|
| 554 |
if not files:
|
| 555 |
-
raise HTTPException(
|
| 556 |
-
status_code=400,
|
| 557 |
-
detail={"success": False, "message": "No files provided."},
|
| 558 |
-
)
|
| 559 |
if len(files) > 10:
|
| 560 |
-
raise HTTPException(
|
| 561 |
-
status_code=400,
|
| 562 |
-
detail={"success": False, "message": "Maximum 10 files per batch."},
|
| 563 |
-
)
|
| 564 |
|
| 565 |
batch_start = time.perf_counter()
|
| 566 |
logger.info("batch_files | count=%d", len(files))
|
|
@@ -568,91 +510,59 @@ async def batch_files(
|
|
| 568 |
async def _process_file(f: UploadFile) -> BatchFileResult:
|
| 569 |
if f is None:
|
| 570 |
return BatchFileResult(filename="unknown", success=False, time_ms=0, error="File object is None.")
|
| 571 |
-
|
| 572 |
raw = await f.read()
|
| 573 |
if len(raw) > MAX_UPLOAD_BYTES:
|
| 574 |
-
return BatchFileResult(
|
| 575 |
-
filename=f.filename or "unknown",
|
| 576 |
-
success=False,
|
| 577 |
-
time_ms=0,
|
| 578 |
-
error="File exceeds 100 MB limit.",
|
| 579 |
-
)
|
| 580 |
-
|
| 581 |
loop = asyncio.get_running_loop()
|
| 582 |
-
outcome = await loop.run_in_executor(
|
| 583 |
-
|
| 584 |
-
)
|
| 585 |
-
return (
|
| 586 |
-
_batch_result_from_error(f.filename or "unknown", outcome)
|
| 587 |
-
if isinstance(outcome, ConversionError)
|
| 588 |
-
else _batch_result_from_ok(outcome)
|
| 589 |
-
)
|
| 590 |
|
| 591 |
results = await asyncio.gather(*[_process_file(f) for f in files])
|
| 592 |
-
|
| 593 |
total_ms = round((time.perf_counter() - batch_start) * 1000, 3)
|
| 594 |
succeeded = sum(1 for r in results if r.success)
|
| 595 |
logger.info("batch_files | done | succeeded=%d | failed=%d | total_ms=%.1f", succeeded, len(results) - succeeded, total_ms)
|
| 596 |
|
| 597 |
-
return BatchResponse(
|
| 598 |
-
total=len(results),
|
| 599 |
-
succeeded=succeeded,
|
| 600 |
-
failed=len(results) - succeeded,
|
| 601 |
-
total_time_ms=total_ms,
|
| 602 |
-
results=results,
|
| 603 |
-
)
|
| 604 |
|
| 605 |
|
| 606 |
-
@
|
| 607 |
"/batch/urls",
|
| 608 |
response_model=BatchResponse,
|
| 609 |
-
tags=["
|
| 610 |
summary="Convert multiple URLs (up to 20)",
|
| 611 |
)
|
| 612 |
async def batch_urls(body: BatchUrlRequest):
|
| 613 |
-
"""Convert up to 20 public URLs in a single request.
|
| 614 |
-
|
| 615 |
-
URLs are processed concurrently. Per-item results include success/error
|
| 616 |
-
details, timing, and content metadata.
|
| 617 |
-
"""
|
| 618 |
batch_start = time.perf_counter()
|
| 619 |
logger.info("batch_urls | count=%d", len(body.urls))
|
| 620 |
|
| 621 |
async def _process_url(url: str) -> BatchFileResult:
|
| 622 |
loop = asyncio.get_running_loop()
|
| 623 |
outcome = await loop.run_in_executor(_thread_pool, _converter.convert_url, url)
|
| 624 |
-
return (
|
| 625 |
-
_batch_result_from_error(url, outcome)
|
| 626 |
-
if isinstance(outcome, ConversionError)
|
| 627 |
-
else _batch_result_from_ok(outcome)
|
| 628 |
-
)
|
| 629 |
|
| 630 |
results = await asyncio.gather(*[_process_url(url) for url in body.urls])
|
| 631 |
-
|
| 632 |
total_ms = round((time.perf_counter() - batch_start) * 1000, 3)
|
| 633 |
succeeded = sum(1 for r in results if r.success)
|
| 634 |
logger.info("batch_urls | done | succeeded=%d | failed=%d | total_ms=%.1f", succeeded, len(results) - succeeded, total_ms)
|
| 635 |
|
| 636 |
-
return BatchResponse(
|
| 637 |
-
total=len(results),
|
| 638 |
-
succeeded=succeeded,
|
| 639 |
-
failed=len(results) - succeeded,
|
| 640 |
-
total_time_ms=total_ms,
|
| 641 |
-
results=results,
|
| 642 |
-
)
|
| 643 |
|
| 644 |
|
| 645 |
# ---------------------------------------------------------------------------
|
| 646 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 647 |
# ---------------------------------------------------------------------------
|
| 648 |
|
| 649 |
def run_server(host: str = "0.0.0.0", port: int = 7860, reload: bool = False) -> None:
|
| 650 |
-
"""Start the uvicorn server programmatically."""
|
| 651 |
import uvicorn
|
| 652 |
-
|
| 653 |
-
uvicorn.run(
|
| 654 |
-
"api.server:app",
|
| 655 |
-
host=host,
|
| 656 |
-
port=port,
|
| 657 |
-
reload=reload,
|
| 658 |
-
)
|
|
|
|
| 1 |
"""
|
| 2 |
+
MarkItDown & PDF Conversion API — FastAPI server.
|
| 3 |
+
|
| 4 |
+
All routes are under `/api/v1` except root-level `/` and `/ping`.
|
| 5 |
+
|
| 6 |
+
MarkItDown conversion (under /api/v1/markitdown):
|
| 7 |
+
POST /api/v1/markitdown/convert/file
|
| 8 |
+
POST /api/v1/markitdown/convert/url
|
| 9 |
+
POST /api/v1/markitdown/batch/files
|
| 10 |
+
POST /api/v1/markitdown/batch/urls
|
| 11 |
+
GET /api/v1/markitdown/formats
|
| 12 |
+
GET /api/v1/markitdown/spacy-labels
|
| 13 |
+
|
| 14 |
+
PDF-to-image conversion (under /api/v1):
|
| 15 |
+
POST /api/v1/convert
|
| 16 |
+
POST /api/v1/convert/url
|
| 17 |
+
POST /api/v1/convert/async
|
| 18 |
+
POST /api/v1/convert/async/url
|
| 19 |
+
GET /api/v1/jobs/{id}
|
| 20 |
+
GET /api/v1/files/{id}/{fn}
|
| 21 |
+
GET /api/v1/health
|
| 22 |
+
GET /api/v1/ready
|
| 23 |
+
GET /api/v1/ping
|
| 24 |
+
|
| 25 |
+
System:
|
| 26 |
+
GET / Root info
|
| 27 |
+
GET /ping Liveness check & Docker HEALTHCHECK target
|
| 28 |
+
GET /docs Swagger UI
|
| 29 |
+
GET /redoc ReDoc UI
|
| 30 |
+
GET /metrics Prometheus metrics
|
| 31 |
"""
|
| 32 |
|
| 33 |
from __future__ import annotations
|
| 34 |
|
| 35 |
import asyncio
|
| 36 |
import concurrent.futures
|
|
|
|
| 37 |
import os
|
| 38 |
import threading
|
| 39 |
import time
|
| 40 |
+
import uuid
|
| 41 |
import urllib.request
|
| 42 |
from contextlib import asynccontextmanager
|
| 43 |
from datetime import datetime, timezone
|
|
|
|
| 46 |
from urllib.parse import urlparse
|
| 47 |
|
| 48 |
import httpx
|
| 49 |
+
from fastapi import APIRouter, Depends, FastAPI, File, Form, HTTPException, Request, UploadFile, status
|
| 50 |
+
from fastapi.responses import JSONResponse, PlainTextResponse
|
|
|
|
| 51 |
from fastapi.middleware.cors import CORSMiddleware
|
| 52 |
from fastapi.middleware.gzip import GZipMiddleware
|
| 53 |
+
from prometheus_client import make_asgi_app
|
| 54 |
from pydantic import BaseModel, Field, field_validator
|
| 55 |
|
| 56 |
from core import ConversionError, ConversionResult, DocumentConverter, SUPPORTED_EXTENSIONS
|
| 57 |
from extraction.generic_json_extractor import extract
|
|
|
|
| 58 |
from logger import get_logger
|
| 59 |
+
from app.api.routes import router as pdf_router
|
| 60 |
+
from app.core.auth import require_api_key
|
| 61 |
+
from app.core.config import settings
|
| 62 |
+
from app.core.exceptions import AppException
|
| 63 |
+
from app.core.logging import configure_logging
|
| 64 |
+
from app.core.rate_limit import RateLimitMiddleware
|
| 65 |
+
|
| 66 |
+
configure_logging()
|
| 67 |
logger = get_logger(__name__)
|
| 68 |
|
| 69 |
_START_TIME = time.time()
|
| 70 |
APP_NAME = "reconciliation-file-processing-service"
|
| 71 |
|
|
|
|
| 72 |
MAX_UPLOAD_BYTES = 100 * 1024 * 1024
|
| 73 |
|
|
|
|
| 74 |
MAX_WORKERS = min(32, (os.cpu_count() or 1) + 4)
|
| 75 |
_thread_pool = concurrent.futures.ThreadPoolExecutor(max_workers=MAX_WORKERS)
|
| 76 |
|
|
|
|
| 84 |
# ---------------------------------------------------------------------------
|
| 85 |
|
| 86 |
PING_URL = os.environ.get("PING_URL", "https://validops-us-data-extract.hf.space/health")
|
| 87 |
+
PING_INTERVAL_SECONDS = 30 * 60
|
| 88 |
|
| 89 |
|
| 90 |
def _ping_once() -> None:
|
|
|
|
| 91 |
ts = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
|
| 92 |
try:
|
| 93 |
with urllib.request.urlopen(PING_URL, timeout=10) as resp:
|
|
|
|
| 97 |
|
| 98 |
|
| 99 |
def _ping_loop() -> None:
|
|
|
|
| 100 |
logger.info("self_ping | scheduler started | interval_minutes=30 | url=%s", PING_URL)
|
| 101 |
while True:
|
| 102 |
time.sleep(PING_INTERVAL_SECONDS)
|
|
|
|
| 104 |
|
| 105 |
|
| 106 |
def _start_ping_scheduler() -> None:
|
|
|
|
| 107 |
thread = threading.Thread(target=_ping_loop, name="self-ping", daemon=True)
|
| 108 |
thread.start()
|
| 109 |
|
|
|
|
| 114 |
|
| 115 |
@asynccontextmanager
|
| 116 |
async def lifespan(app: FastAPI):
|
|
|
|
| 117 |
logger.info(
|
| 118 |
+
"MarkItDown & PDF Conversion API starting | version=2.1.0 | host=0.0.0.0:7860",
|
|
|
|
| 119 |
)
|
| 120 |
_start_ping_scheduler()
|
| 121 |
yield
|
| 122 |
+
logger.info("MarkItDown & PDF Conversion API shutting down")
|
| 123 |
|
| 124 |
|
| 125 |
# ---------------------------------------------------------------------------
|
|
|
|
| 127 |
# ---------------------------------------------------------------------------
|
| 128 |
|
| 129 |
app = FastAPI(
|
| 130 |
+
title="MarkItDown & PDF Conversion API",
|
| 131 |
description=(
|
| 132 |
+
"Unified API for document-to-Markdown conversion (Microsoft MarkItDown + RapidOCR) "
|
| 133 |
+
"and PDF-to-image conversion with async job support."
|
|
|
|
| 134 |
),
|
| 135 |
version="2.1.0",
|
| 136 |
docs_url="/docs",
|
| 137 |
redoc_url="/redoc",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
lifespan=lifespan,
|
| 139 |
)
|
| 140 |
|
| 141 |
+
# -- Middleware --
|
| 142 |
+
app.add_middleware(RateLimitMiddleware)
|
| 143 |
app.add_middleware(GZipMiddleware, minimum_size=1000)
|
| 144 |
app.add_middleware(
|
| 145 |
CORSMiddleware,
|
|
|
|
| 149 |
)
|
| 150 |
|
| 151 |
|
| 152 |
+
@app.middleware("http")
|
| 153 |
+
async def request_context_middleware(request: Request, call_next):
|
| 154 |
+
request_id = str(uuid.uuid4())
|
| 155 |
+
start = time.perf_counter()
|
| 156 |
+
|
| 157 |
+
import structlog as _structlog
|
| 158 |
+
_structlog.contextvars.clear_contextvars()
|
| 159 |
+
_structlog.contextvars.bind_contextvars(
|
| 160 |
+
request_id=request_id,
|
| 161 |
+
method=request.method,
|
| 162 |
+
path=request.url.path,
|
| 163 |
+
)
|
| 164 |
+
|
| 165 |
+
response = await call_next(request)
|
| 166 |
+
elapsed = round((time.perf_counter() - start) * 1000, 2)
|
| 167 |
+
|
| 168 |
+
logger.info("request_completed | status=%d | duration_ms=%.2f", response.status_code, elapsed)
|
| 169 |
+
response.headers["X-Request-ID"] = request_id
|
| 170 |
+
response.headers["X-Response-Time-Ms"] = str(elapsed)
|
| 171 |
+
return response
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
# -- Exception handlers --
|
| 175 |
+
@app.exception_handler(AppException)
|
| 176 |
+
async def app_exception_handler(request: Request, exc: AppException):
|
| 177 |
+
logger.warning("app_exception | detail=%s | status_code=%d", exc.detail, exc.status_code)
|
| 178 |
+
return JSONResponse(
|
| 179 |
+
status_code=exc.status_code,
|
| 180 |
+
content={"error": exc.detail, "request_id": getattr(request.state, "request_id", "")},
|
| 181 |
+
)
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
@app.exception_handler(Exception)
|
| 185 |
+
async def generic_exception_handler(request: Request, exc: Exception):
|
| 186 |
+
logger.exception("unhandled_exception", exc_info=exc)
|
| 187 |
+
return JSONResponse(
|
| 188 |
+
status_code=500,
|
| 189 |
+
content={"error": "Internal server error", "request_id": getattr(request.state, "request_id", "")},
|
| 190 |
+
)
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
# -- Root-level routes (only / , /ping , /health) --
|
| 194 |
+
@app.get("/", tags=["System"], summary="Root", include_in_schema=False)
|
| 195 |
+
async def root():
|
| 196 |
+
return {"service": APP_NAME, "version": "2.1.0", "status": "running"}
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
@app.get("/ping", tags=["System"], summary="Ping", include_in_schema=False)
|
| 200 |
+
async def ping():
|
| 201 |
+
return {"message": f"{APP_NAME} is running..."}
|
| 202 |
+
|
| 203 |
+
|
| 204 |
# ---------------------------------------------------------------------------
|
| 205 |
+
# MarkItDown router — all routes under /api/v1/markitdown
|
| 206 |
# ---------------------------------------------------------------------------
|
| 207 |
|
| 208 |
+
markitdown_router = APIRouter(dependencies=[Depends(require_api_key)])
|
|
|
|
| 209 |
|
| 210 |
+
# -- Models --
|
| 211 |
+
class ConversionMetadata(BaseModel):
|
| 212 |
source: str
|
| 213 |
char_count: int
|
| 214 |
word_count: int
|
|
|
|
| 220 |
|
| 221 |
|
| 222 |
class ConversionResponse(BaseModel):
|
|
|
|
|
|
|
| 223 |
success: bool
|
| 224 |
time_ms: float
|
| 225 |
content: str
|
|
|
|
| 230 |
|
| 231 |
|
| 232 |
class UrlRequest(BaseModel):
|
|
|
|
|
|
|
| 233 |
url: str
|
| 234 |
return_json: bool = False
|
| 235 |
mappings: Optional[Dict[str, Dict[str, Any]]] = None
|
|
|
|
| 236 |
model_config = {"populate_by_name": True}
|
| 237 |
|
| 238 |
@field_validator("url")
|
|
|
|
| 244 |
|
| 245 |
|
| 246 |
class BatchUrlRequest(BaseModel):
|
|
|
|
|
|
|
| 247 |
urls: List[str]
|
| 248 |
|
| 249 |
@field_validator("urls")
|
|
|
|
| 258 |
|
| 259 |
|
| 260 |
class BatchFileResult(BaseModel):
|
|
|
|
|
|
|
| 261 |
filename: str
|
| 262 |
success: bool
|
| 263 |
time_ms: float
|
|
|
|
| 267 |
|
| 268 |
|
| 269 |
class BatchResponse(BaseModel):
|
|
|
|
|
|
|
| 270 |
total: int
|
| 271 |
succeeded: int
|
| 272 |
failed: int
|
|
|
|
| 274 |
results: List[BatchFileResult]
|
| 275 |
|
| 276 |
|
| 277 |
+
# -- Helpers --
|
|
|
|
|
|
|
|
|
|
| 278 |
def _build_metadata(result: ConversionResult) -> ConversionMetadata:
|
|
|
|
| 279 |
return ConversionMetadata(
|
| 280 |
source=result.source,
|
| 281 |
char_count=result.char_count,
|
|
|
|
| 296 |
raw_data: Optional[bytes] = None,
|
| 297 |
mappings: Optional[Dict[str, Dict[str, Any]]] = None,
|
| 298 |
) -> ConversionResponse:
|
|
|
|
| 299 |
json_content: Optional[Any] = None
|
| 300 |
error_message: Optional[str] = None
|
| 301 |
|
|
|
|
| 321 |
|
| 322 |
|
| 323 |
def _raise_for_error(outcome: ConversionError) -> None:
|
|
|
|
| 324 |
status_map = {
|
| 325 |
"FileNotFoundError": status.HTTP_404_NOT_FOUND,
|
| 326 |
"ValueError": status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
|
|
| 357 |
)
|
| 358 |
|
| 359 |
|
| 360 |
+
# -- Routes --
|
| 361 |
+
@markitdown_router.get("/formats", tags=["MarkItDown"], summary="Supported file formats by category")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 362 |
async def list_formats():
|
|
|
|
| 363 |
by_category = {
|
| 364 |
"documents": [e for e in SUPPORTED_EXTENSIONS if e in {".pdf", ".docx", ".doc", ".epub"}],
|
| 365 |
"office": [e for e in SUPPORTED_EXTENSIONS if e in {".pptx", ".ppt", ".xlsx", ".xls"}],
|
| 366 |
"data": [e for e in SUPPORTED_EXTENSIONS if e in {".csv", ".json", ".xml"}],
|
| 367 |
+
"web": [e for e in SUPPORTED_EXTENSIONS if e in {".htm", ".html"}],
|
| 368 |
"text": [e for e in SUPPORTED_EXTENSIONS if e in {".txt", ".md", ".rst"}],
|
| 369 |
"images": [e for e in SUPPORTED_EXTENSIONS if e in {".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".tiff"}],
|
| 370 |
"audio": [e for e in SUPPORTED_EXTENSIONS if e in {".mp3", ".wav", ".ogg", ".flac"}],
|
|
|
|
| 378 |
}
|
| 379 |
|
| 380 |
|
| 381 |
+
@markitdown_router.get("/spacy-labels", tags=["MarkItDown"], summary="Available spaCy NER labels")
|
| 382 |
async def list_spacy_labels():
|
|
|
|
| 383 |
from extraction.spacy_extractor import VALID_SPACY_LABELS
|
| 384 |
|
| 385 |
return {
|
|
|
|
| 401 |
}
|
| 402 |
|
| 403 |
|
| 404 |
+
@markitdown_router.post(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 405 |
"/convert/file",
|
| 406 |
response_model=ConversionResponse,
|
| 407 |
+
tags=["MarkItDown"],
|
| 408 |
summary="Convert an uploaded file to Markdown",
|
| 409 |
)
|
| 410 |
async def convert_file(
|
|
|
|
| 413 |
return_json: bool = Form(False),
|
| 414 |
mappings: Optional[str] = Form(
|
| 415 |
None,
|
| 416 |
+
description='JSON string defining spaCy extraction rules. Example: {"company": {"source_type": "entity", "label": "ORG"}}',
|
|
|
|
|
|
|
|
|
|
| 417 |
),
|
| 418 |
):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 419 |
if file is None:
|
| 420 |
+
raise HTTPException(status_code=400, detail={"success": False, "message": "No file provided."})
|
|
|
|
|
|
|
|
|
|
| 421 |
|
| 422 |
parsed_mappings: Optional[Dict[str, Any]] = None
|
| 423 |
if mappings:
|
|
|
|
| 425 |
try:
|
| 426 |
parsed_mappings = _json.loads(mappings)
|
| 427 |
except _json.JSONDecodeError:
|
| 428 |
+
raise HTTPException(status_code=400, detail={"success": False, "message": "Invalid JSON in mappings parameter."})
|
|
|
|
|
|
|
|
|
|
| 429 |
|
| 430 |
logger.info("convert_file | filename=%s", file.filename)
|
|
|
|
| 431 |
raw = await file.read()
|
| 432 |
if len(raw) > MAX_UPLOAD_BYTES:
|
| 433 |
logger.warning("convert_file | file too large | filename=%s | size=%d", file.filename, len(raw))
|
| 434 |
+
raise HTTPException(status_code=413, detail={"success": False, "message": "File exceeds 100 MB limit."})
|
|
|
|
|
|
|
|
|
|
| 435 |
|
| 436 |
loop = asyncio.get_running_loop()
|
| 437 |
+
outcome = await loop.run_in_executor(_thread_pool, _converter.convert_stream, raw, file.filename or "upload")
|
|
|
|
|
|
|
| 438 |
|
| 439 |
if isinstance(outcome, ConversionError):
|
| 440 |
logger.error("convert_file | conversion failed | filename=%s | error=%s", file.filename, outcome.message)
|
| 441 |
_raise_for_error(outcome)
|
| 442 |
|
| 443 |
+
logger.info("convert_file | success | filename=%s | chars=%d | time_ms=%.1f", file.filename, outcome.char_count, outcome.duration_ms)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 444 |
|
| 445 |
if plain_text:
|
| 446 |
return PlainTextResponse(outcome.markdown)
|
| 447 |
|
| 448 |
+
return await _build_response(outcome, return_json=return_json, filename=file.filename, raw_data=raw, mappings=parsed_mappings)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 449 |
|
| 450 |
|
| 451 |
+
@markitdown_router.post(
|
| 452 |
"/convert/url",
|
| 453 |
response_model=ConversionResponse,
|
| 454 |
+
tags=["MarkItDown"],
|
| 455 |
summary="Convert a public URL to Markdown",
|
| 456 |
)
|
| 457 |
async def convert_url(body: UrlRequest):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 458 |
logger.info("convert_url | url=%s", body.url)
|
| 459 |
parsed = urlparse(body.url)
|
| 460 |
filename = Path(parsed.path).name or "url_content"
|
|
|
|
| 461 |
loop = asyncio.get_running_loop()
|
| 462 |
|
| 463 |
if body.return_json:
|
|
|
|
| 464 |
try:
|
| 465 |
async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
|
| 466 |
resp = await client.get(body.url)
|
| 467 |
resp.raise_for_status()
|
| 468 |
except httpx.HTTPError as exc:
|
| 469 |
logger.error("convert_url | fetch failed | url=%s | error=%s", body.url, exc)
|
| 470 |
+
raise HTTPException(status_code=400, detail={"success": False, "message": f"Failed to fetch URL: {exc}"})
|
|
|
|
|
|
|
|
|
|
| 471 |
|
| 472 |
raw_data = resp.content
|
| 473 |
if len(raw_data) > MAX_UPLOAD_BYTES:
|
| 474 |
+
raise HTTPException(status_code=413, detail={"success": False, "message": "File exceeds 100 MB limit."})
|
|
|
|
|
|
|
|
|
|
| 475 |
|
| 476 |
+
outcome = await loop.run_in_executor(_thread_pool, _converter.convert_stream, raw_data, filename)
|
|
|
|
|
|
|
| 477 |
if isinstance(outcome, ConversionError):
|
| 478 |
logger.error("convert_url | conversion failed | url=%s | error=%s", body.url, outcome.message)
|
| 479 |
_raise_for_error(outcome)
|
| 480 |
|
| 481 |
+
logger.info("convert_url | success | url=%s | chars=%d | time_ms=%.1f", body.url, outcome.char_count, outcome.duration_ms)
|
| 482 |
+
return await _build_response(outcome, return_json=body.return_json, filename=filename, raw_data=raw_data, mappings=body.mappings)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 483 |
|
|
|
|
| 484 |
outcome = await loop.run_in_executor(_thread_pool, _converter.convert_url, body.url)
|
| 485 |
if isinstance(outcome, ConversionError):
|
| 486 |
logger.error("convert_url | conversion failed | url=%s | error=%s", body.url, outcome.message)
|
| 487 |
_raise_for_error(outcome)
|
| 488 |
|
| 489 |
+
logger.info("convert_url | success | url=%s | chars=%d | time_ms=%.1f", body.url, outcome.char_count, outcome.duration_ms)
|
| 490 |
+
return await _build_response(outcome, return_json=body.return_json, filename=filename, mappings=body.mappings)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 491 |
|
|
|
|
|
|
|
|
|
|
| 492 |
|
| 493 |
+
@markitdown_router.post(
|
| 494 |
"/batch/files",
|
| 495 |
response_model=BatchResponse,
|
| 496 |
+
tags=["MarkItDown"],
|
| 497 |
summary="Convert multiple files (up to 10)",
|
| 498 |
)
|
| 499 |
async def batch_files(
|
| 500 |
files: Annotated[List[UploadFile], File(description="Files to convert — maximum 10")],
|
| 501 |
):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 502 |
if not files:
|
| 503 |
+
raise HTTPException(status_code=400, detail={"success": False, "message": "No files provided."})
|
|
|
|
|
|
|
|
|
|
| 504 |
if len(files) > 10:
|
| 505 |
+
raise HTTPException(status_code=400, detail={"success": False, "message": "Maximum 10 files per batch."})
|
|
|
|
|
|
|
|
|
|
| 506 |
|
| 507 |
batch_start = time.perf_counter()
|
| 508 |
logger.info("batch_files | count=%d", len(files))
|
|
|
|
| 510 |
async def _process_file(f: UploadFile) -> BatchFileResult:
|
| 511 |
if f is None:
|
| 512 |
return BatchFileResult(filename="unknown", success=False, time_ms=0, error="File object is None.")
|
|
|
|
| 513 |
raw = await f.read()
|
| 514 |
if len(raw) > MAX_UPLOAD_BYTES:
|
| 515 |
+
return BatchFileResult(filename=f.filename or "unknown", success=False, time_ms=0, error="File exceeds 100 MB limit.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 516 |
loop = asyncio.get_running_loop()
|
| 517 |
+
outcome = await loop.run_in_executor(_thread_pool, _converter.convert_stream, raw, f.filename or "upload")
|
| 518 |
+
return _batch_result_from_error(f.filename or "unknown", outcome) if isinstance(outcome, ConversionError) else _batch_result_from_ok(outcome)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 519 |
|
| 520 |
results = await asyncio.gather(*[_process_file(f) for f in files])
|
|
|
|
| 521 |
total_ms = round((time.perf_counter() - batch_start) * 1000, 3)
|
| 522 |
succeeded = sum(1 for r in results if r.success)
|
| 523 |
logger.info("batch_files | done | succeeded=%d | failed=%d | total_ms=%.1f", succeeded, len(results) - succeeded, total_ms)
|
| 524 |
|
| 525 |
+
return BatchResponse(total=len(results), succeeded=succeeded, failed=len(results) - succeeded, total_time_ms=total_ms, results=results)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 526 |
|
| 527 |
|
| 528 |
+
@markitdown_router.post(
|
| 529 |
"/batch/urls",
|
| 530 |
response_model=BatchResponse,
|
| 531 |
+
tags=["MarkItDown"],
|
| 532 |
summary="Convert multiple URLs (up to 20)",
|
| 533 |
)
|
| 534 |
async def batch_urls(body: BatchUrlRequest):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 535 |
batch_start = time.perf_counter()
|
| 536 |
logger.info("batch_urls | count=%d", len(body.urls))
|
| 537 |
|
| 538 |
async def _process_url(url: str) -> BatchFileResult:
|
| 539 |
loop = asyncio.get_running_loop()
|
| 540 |
outcome = await loop.run_in_executor(_thread_pool, _converter.convert_url, url)
|
| 541 |
+
return _batch_result_from_error(url, outcome) if isinstance(outcome, ConversionError) else _batch_result_from_ok(outcome)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 542 |
|
| 543 |
results = await asyncio.gather(*[_process_url(url) for url in body.urls])
|
|
|
|
| 544 |
total_ms = round((time.perf_counter() - batch_start) * 1000, 3)
|
| 545 |
succeeded = sum(1 for r in results if r.success)
|
| 546 |
logger.info("batch_urls | done | succeeded=%d | failed=%d | total_ms=%.1f", succeeded, len(results) - succeeded, total_ms)
|
| 547 |
|
| 548 |
+
return BatchResponse(total=len(results), succeeded=succeeded, failed=len(results) - succeeded, total_time_ms=total_ms, results=results)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 549 |
|
| 550 |
|
| 551 |
# ---------------------------------------------------------------------------
|
| 552 |
+
# Include all routers
|
| 553 |
+
# ---------------------------------------------------------------------------
|
| 554 |
+
|
| 555 |
+
app.include_router(markitdown_router, prefix="/api/v1/markitdown")
|
| 556 |
+
app.include_router(pdf_router, prefix="/api/v1")
|
| 557 |
+
|
| 558 |
+
# -- Mount Prometheus metrics --
|
| 559 |
+
app.mount("/metrics", make_asgi_app())
|
| 560 |
+
|
| 561 |
+
|
| 562 |
+
# ---------------------------------------------------------------------------
|
| 563 |
+
# Server runner
|
| 564 |
# ---------------------------------------------------------------------------
|
| 565 |
|
| 566 |
def run_server(host: str = "0.0.0.0", port: int = 7860, reload: bool = False) -> None:
|
|
|
|
| 567 |
import uvicorn
|
| 568 |
+
uvicorn.run("api.server:app", host=host, port=port, reload=reload)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/__init__.py
ADDED
|
File without changes
|
app/api/__init__.py
ADDED
|
File without changes
|
app/api/routes.py
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import uuid
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
from typing import Literal, Optional
|
| 6 |
+
|
| 7 |
+
import structlog
|
| 8 |
+
from fastapi import APIRouter, BackgroundTasks, Depends, File, Query, UploadFile
|
| 9 |
+
from fastapi.responses import FileResponse, JSONResponse
|
| 10 |
+
|
| 11 |
+
from app.core.auth import require_api_key
|
| 12 |
+
from app.core.config import settings
|
| 13 |
+
from app.core.exceptions import InvalidParameterError, JobNotFoundError
|
| 14 |
+
from app.models.schemas import (
|
| 15 |
+
AsyncJobResponse,
|
| 16 |
+
ConversionParams,
|
| 17 |
+
ConversionResult,
|
| 18 |
+
HealthResponse,
|
| 19 |
+
ImageFormat,
|
| 20 |
+
JobStatus,
|
| 21 |
+
JobStatusResponse,
|
| 22 |
+
)
|
| 23 |
+
from app.services.conversion import conversion_service
|
| 24 |
+
from app.services.file_service import upload_file_to_service
|
| 25 |
+
from app.services.upload_orchestrator import convert_and_upload
|
| 26 |
+
from app.utils.validators import fetch_pdf_from_url, read_and_validate_pdf
|
| 27 |
+
|
| 28 |
+
logger = structlog.get_logger(__name__)
|
| 29 |
+
router = APIRouter()
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
_jobs: dict[str, JobStatusResponse] = {}
|
| 33 |
+
|
| 34 |
+
_MEDIA_TYPES: dict[str, str] = {
|
| 35 |
+
"png": "image/png",
|
| 36 |
+
"jpg": "image/jpeg",
|
| 37 |
+
"jpeg": "image/jpeg",
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def build_conversion_params(
|
| 42 |
+
format: ImageFormat = Query(ImageFormat.PNG, description="Output image format"),
|
| 43 |
+
dpi: int = Query(settings.DEFAULT_DPI, ge=72, le=settings.MAX_DPI, description="Render DPI (72–600)"),
|
| 44 |
+
quality: int = Query(settings.DEFAULT_JPEG_QUALITY, ge=1, le=100, description="JPEG/WEBP quality (1–100)"),
|
| 45 |
+
pages: Optional[str] = Query(None, description="Page spec: '1', '1-3', '1,3,5-7'. Empty = all pages."),
|
| 46 |
+
grayscale: bool = Query(False, description="Convert output to grayscale"),
|
| 47 |
+
transparent_bg: bool = Query(False, description="Preserve transparency (PNG only)"),
|
| 48 |
+
split_page: bool = Query(False, description="True: each page separate image. False: stitch into one tall image."),
|
| 49 |
+
) -> ConversionParams:
|
| 50 |
+
try:
|
| 51 |
+
return ConversionParams(
|
| 52 |
+
format=format,
|
| 53 |
+
dpi=dpi,
|
| 54 |
+
quality=quality,
|
| 55 |
+
pages=pages,
|
| 56 |
+
grayscale=grayscale,
|
| 57 |
+
transparent_bg=transparent_bg,
|
| 58 |
+
split_page=split_page,
|
| 59 |
+
)
|
| 60 |
+
except Exception as exc:
|
| 61 |
+
raise InvalidParameterError(str(exc)) from exc
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def _upload_mode_param(
|
| 65 |
+
upload_mode: Optional[Literal["per_page", "merged"]] = Query(
|
| 66 |
+
None,
|
| 67 |
+
description=(
|
| 68 |
+
"Upload behaviour after conversion. "
|
| 69 |
+
"'per_page' uploads each page image immediately as it is rendered (lowest latency). "
|
| 70 |
+
"'merged' waits for all pages to render, then uploads them. "
|
| 71 |
+
"Omit to use the server default (PDF_UPLOAD_MODE env var)."
|
| 72 |
+
),
|
| 73 |
+
),
|
| 74 |
+
) -> Optional[str]:
|
| 75 |
+
return upload_mode
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
@router.post(
|
| 79 |
+
"/convert",
|
| 80 |
+
response_model=ConversionResult,
|
| 81 |
+
summary="Convert PDF to images — file upload (sync)",
|
| 82 |
+
tags=["PDF Conversion"],
|
| 83 |
+
)
|
| 84 |
+
async def convert_sync_upload(
|
| 85 |
+
file: UploadFile = File(...),
|
| 86 |
+
params: ConversionParams = Depends(build_conversion_params),
|
| 87 |
+
upload_mode: Optional[str] = Depends(_upload_mode_param),
|
| 88 |
+
_: str = Depends(require_api_key),
|
| 89 |
+
):
|
| 90 |
+
"""
|
| 91 |
+
Upload a PDF and convert all (or selected) pages to JPEG or PNG images synchronously.
|
| 92 |
+
|
| 93 |
+
- Each page is rendered in **parallel** (split into individual page blobs when split_page=true).
|
| 94 |
+
- Converted images are uploaded to the File Upload Service automatically.
|
| 95 |
+
- Returns file URLs and metadata for every converted page.
|
| 96 |
+
- Supported output formats: JPEG, PNG.
|
| 97 |
+
"""
|
| 98 |
+
pdf_bytes = await read_and_validate_pdf(file)
|
| 99 |
+
job_id = str(uuid.uuid4())
|
| 100 |
+
return await convert_and_upload(pdf_bytes, params, job_id, upload_mode)
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
@router.post(
|
| 104 |
+
"/convert/url",
|
| 105 |
+
response_model=ConversionResult,
|
| 106 |
+
summary="Convert PDF to images — URL (sync)",
|
| 107 |
+
tags=["PDF Conversion"],
|
| 108 |
+
)
|
| 109 |
+
async def convert_sync_url(
|
| 110 |
+
pdf_url: str = Query(..., description="Publicly accessible HTTPS URL to a PDF"),
|
| 111 |
+
params: ConversionParams = Depends(build_conversion_params),
|
| 112 |
+
upload_mode: Optional[str] = Depends(_upload_mode_param),
|
| 113 |
+
_: str = Depends(require_api_key),
|
| 114 |
+
):
|
| 115 |
+
"""Fetch a PDF from a public URL and convert it. Private/loopback IPs are blocked."""
|
| 116 |
+
pdf_bytes = await fetch_pdf_from_url(pdf_url)
|
| 117 |
+
job_id = str(uuid.uuid4())
|
| 118 |
+
return await convert_and_upload(pdf_bytes, params, job_id, upload_mode)
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
@router.post(
|
| 122 |
+
"/convert/async",
|
| 123 |
+
response_model=AsyncJobResponse,
|
| 124 |
+
status_code=202,
|
| 125 |
+
summary="Submit async conversion job — file upload",
|
| 126 |
+
tags=["PDF Conversion"],
|
| 127 |
+
)
|
| 128 |
+
async def convert_async_upload(
|
| 129 |
+
background_tasks: BackgroundTasks,
|
| 130 |
+
file: UploadFile = File(...),
|
| 131 |
+
params: ConversionParams = Depends(build_conversion_params),
|
| 132 |
+
upload_mode: Optional[str] = Depends(_upload_mode_param),
|
| 133 |
+
_: str = Depends(require_api_key),
|
| 134 |
+
):
|
| 135 |
+
"""Submit a PDF for async conversion. Returns 202 immediately; poll /jobs/{job_id}."""
|
| 136 |
+
pdf_bytes = await read_and_validate_pdf(file)
|
| 137 |
+
job_id = str(uuid.uuid4())
|
| 138 |
+
_jobs[job_id] = JobStatusResponse(job_id=job_id, status=JobStatus.PENDING, progress=0.0)
|
| 139 |
+
background_tasks.add_task(_run_conversion_job, job_id, pdf_bytes, params, upload_mode)
|
| 140 |
+
return AsyncJobResponse(
|
| 141 |
+
job_id=job_id,
|
| 142 |
+
status=JobStatus.PENDING,
|
| 143 |
+
message="Job accepted. Poll the status URL for progress.",
|
| 144 |
+
status_url=f"/api/v1/jobs/{job_id}",
|
| 145 |
+
)
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
@router.post(
|
| 149 |
+
"/convert/async/url",
|
| 150 |
+
response_model=AsyncJobResponse,
|
| 151 |
+
status_code=202,
|
| 152 |
+
summary="Submit async conversion job — URL",
|
| 153 |
+
tags=["PDF Conversion"],
|
| 154 |
+
)
|
| 155 |
+
async def convert_async_url(
|
| 156 |
+
background_tasks: BackgroundTasks,
|
| 157 |
+
pdf_url: str = Query(..., description="Publicly accessible HTTPS URL to a PDF"),
|
| 158 |
+
params: ConversionParams = Depends(build_conversion_params),
|
| 159 |
+
upload_mode: Optional[str] = Depends(_upload_mode_param),
|
| 160 |
+
_: str = Depends(require_api_key),
|
| 161 |
+
):
|
| 162 |
+
"""Submit a URL-based PDF conversion job asynchronously."""
|
| 163 |
+
pdf_bytes = await fetch_pdf_from_url(pdf_url)
|
| 164 |
+
job_id = str(uuid.uuid4())
|
| 165 |
+
_jobs[job_id] = JobStatusResponse(job_id=job_id, status=JobStatus.PENDING, progress=0.0)
|
| 166 |
+
background_tasks.add_task(_run_conversion_job, job_id, pdf_bytes, params, upload_mode)
|
| 167 |
+
return AsyncJobResponse(
|
| 168 |
+
job_id=job_id,
|
| 169 |
+
status=JobStatus.PENDING,
|
| 170 |
+
message="Job accepted. Poll the status URL for progress.",
|
| 171 |
+
status_url=f"/api/v1/jobs/{job_id}",
|
| 172 |
+
)
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
@router.get(
|
| 176 |
+
"/jobs/{job_id}",
|
| 177 |
+
response_model=JobStatusResponse,
|
| 178 |
+
summary="Get async job status",
|
| 179 |
+
tags=["Jobs"],
|
| 180 |
+
)
|
| 181 |
+
async def get_job_status(
|
| 182 |
+
job_id: str,
|
| 183 |
+
_: str = Depends(require_api_key),
|
| 184 |
+
):
|
| 185 |
+
job = _jobs.get(job_id)
|
| 186 |
+
if not job:
|
| 187 |
+
raise JobNotFoundError(job_id)
|
| 188 |
+
return job
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
@router.get(
|
| 192 |
+
"/files/{job_id}/{filename}",
|
| 193 |
+
summary="Download a converted image",
|
| 194 |
+
tags=["Files"],
|
| 195 |
+
)
|
| 196 |
+
async def download_file(
|
| 197 |
+
job_id: str,
|
| 198 |
+
filename: str,
|
| 199 |
+
_: str = Depends(require_api_key),
|
| 200 |
+
):
|
| 201 |
+
output_root = Path(settings.OUTPUT_DIR).resolve()
|
| 202 |
+
file_path = (output_root / job_id / filename).resolve()
|
| 203 |
+
|
| 204 |
+
try:
|
| 205 |
+
file_path.relative_to(output_root)
|
| 206 |
+
except ValueError:
|
| 207 |
+
raise JobNotFoundError(f"{job_id}/{filename}")
|
| 208 |
+
|
| 209 |
+
if not file_path.exists() or not file_path.is_file():
|
| 210 |
+
raise JobNotFoundError(f"{job_id}/{filename}")
|
| 211 |
+
|
| 212 |
+
ext = file_path.suffix.lstrip(".").lower()
|
| 213 |
+
media_type = _MEDIA_TYPES.get(ext, "application/octet-stream")
|
| 214 |
+
return FileResponse(path=str(file_path), media_type=media_type, filename=filename)
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
@router.get("/health", response_model=HealthResponse, summary="Liveness probe", tags=["Observability"])
|
| 218 |
+
async def health():
|
| 219 |
+
return HealthResponse(status="ok", version=settings.VERSION, environment=settings.ENV)
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
@router.get("/ready", response_model=HealthResponse, summary="Readiness probe", tags=["Observability"])
|
| 223 |
+
async def ready():
|
| 224 |
+
try:
|
| 225 |
+
probe = Path(settings.OUTPUT_DIR) / ".probe"
|
| 226 |
+
probe.touch()
|
| 227 |
+
probe.unlink()
|
| 228 |
+
except OSError as exc:
|
| 229 |
+
return JSONResponse(status_code=503, content={"status": "unavailable", "detail": str(exc)})
|
| 230 |
+
return HealthResponse(status="ready", version=settings.VERSION, environment=settings.ENV)
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
async def _run_conversion_job(
|
| 234 |
+
job_id: str,
|
| 235 |
+
pdf_bytes: bytes,
|
| 236 |
+
params: ConversionParams,
|
| 237 |
+
upload_mode: Optional[str],
|
| 238 |
+
) -> None:
|
| 239 |
+
_jobs[job_id] = JobStatusResponse(job_id=job_id, status=JobStatus.PROCESSING, progress=10.0)
|
| 240 |
+
try:
|
| 241 |
+
result = await convert_and_upload(pdf_bytes, params, job_id, upload_mode)
|
| 242 |
+
_jobs[job_id] = JobStatusResponse(
|
| 243 |
+
job_id=job_id,
|
| 244 |
+
status=JobStatus.COMPLETED,
|
| 245 |
+
progress=100.0,
|
| 246 |
+
result=result,
|
| 247 |
+
)
|
| 248 |
+
logger.info("async_job_complete", job_id=job_id)
|
| 249 |
+
except Exception as exc:
|
| 250 |
+
logger.exception("async_job_failed", job_id=job_id)
|
| 251 |
+
_jobs[job_id] = JobStatusResponse(
|
| 252 |
+
job_id=job_id,
|
| 253 |
+
status=JobStatus.FAILED,
|
| 254 |
+
progress=0.0,
|
| 255 |
+
error=str(exc),
|
| 256 |
+
)
|
app/banner.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pyfiglet
|
| 2 |
+
from rich.console import Console
|
| 3 |
+
from rich.rule import Rule
|
| 4 |
+
from rich.text import Text
|
| 5 |
+
|
| 6 |
+
from app.core.config import get_settings
|
| 7 |
+
|
| 8 |
+
_console = Console()
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def print_banner() -> None:
|
| 12 |
+
settings = get_settings()
|
| 13 |
+
|
| 14 |
+
art = pyfiglet.figlet_format("ValidOps", font="slant")
|
| 15 |
+
|
| 16 |
+
_console.print(Text(art, style="bold cyan"))
|
| 17 |
+
_console.print(f" [bold white]{'Service:':<14}[/bold white] [cyan]{settings.APP_NAME}[/cyan]")
|
| 18 |
+
_console.print(f" [bold white]{'Version:':<14}[/bold white] [cyan]v{settings.VERSION}[/cyan]")
|
| 19 |
+
_console.print(f" [bold white]{'Environment:':<14}[/bold white] [cyan]{settings.ENV}[/cyan]")
|
| 20 |
+
_console.print(Rule(style="dim cyan"))
|
| 21 |
+
_console.print()
|
app/core/__init__.py
ADDED
|
File without changes
|
app/core/auth.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import secrets
|
| 4 |
+
|
| 5 |
+
from fastapi import Security
|
| 6 |
+
from fastapi.security import APIKeyHeader
|
| 7 |
+
|
| 8 |
+
from app.core.config import settings
|
| 9 |
+
from app.core.exceptions import AuthenticationError
|
| 10 |
+
|
| 11 |
+
_api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
async def require_api_key(api_key: str | None = Security(_api_key_header)) -> str:
|
| 15 |
+
if not settings.API_KEY:
|
| 16 |
+
return api_key or ""
|
| 17 |
+
if not api_key:
|
| 18 |
+
raise AuthenticationError("X-API-Key header is required")
|
| 19 |
+
if not secrets.compare_digest(api_key, settings.API_KEY):
|
| 20 |
+
raise AuthenticationError("Invalid API key")
|
| 21 |
+
return api_key
|
app/core/config.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from functools import lru_cache
|
| 4 |
+
from typing import List, Literal
|
| 5 |
+
|
| 6 |
+
from pydantic import Field, field_validator
|
| 7 |
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class Settings(BaseSettings):
|
| 11 |
+
model_config = SettingsConfigDict(
|
| 12 |
+
env_file=".env",
|
| 13 |
+
env_file_encoding="utf-8",
|
| 14 |
+
case_sensitive=False,
|
| 15 |
+
extra="ignore",
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
APP_NAME: str = "reconciliation-report-service"
|
| 19 |
+
VERSION: str = "1.0.0"
|
| 20 |
+
ENV: Literal["development", "staging", "production"] = "production"
|
| 21 |
+
DEBUG: bool = False
|
| 22 |
+
|
| 23 |
+
HOST: str = "0.0.0.0"
|
| 24 |
+
PORT: int = 7860
|
| 25 |
+
WORKERS: int = 4
|
| 26 |
+
ALLOWED_ORIGINS: List[str] = ["*"]
|
| 27 |
+
|
| 28 |
+
API_KEY: str = Field(default="", description="Secret key required in X-API-Key header")
|
| 29 |
+
|
| 30 |
+
SELF_PING_ENABLED: bool = False
|
| 31 |
+
SELF_PING_URL: str = "https://validops-east-2-instance-2.hf.space/ping"
|
| 32 |
+
SELF_PING_INTERVAL_SECONDS: int = 300
|
| 33 |
+
HF_TOKEN: str = Field(
|
| 34 |
+
default="",
|
| 35 |
+
description="Hugging Face Bearer token used exclusively for self-ping",
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
MAX_FILE_SIZE_MB: int = 50
|
| 39 |
+
MAX_PAGES: int = 100
|
| 40 |
+
|
| 41 |
+
DEFAULT_DPI: int = 200
|
| 42 |
+
MAX_DPI: int = 600
|
| 43 |
+
DEFAULT_FORMAT: str = "JPEG"
|
| 44 |
+
DEFAULT_JPEG_QUALITY: int = 95
|
| 45 |
+
SUPPORTED_FORMATS: List[str] = ["PNG", "JPEG", "WEBP", "TIFF", "BMP"]
|
| 46 |
+
|
| 47 |
+
PAGE_RENDER_WORKERS: int = 8
|
| 48 |
+
|
| 49 |
+
PDF_UPLOAD_MODE: Literal["per_page", "merged"] = "per_page"
|
| 50 |
+
|
| 51 |
+
UPLOAD_CONCURRENCY: int = 4
|
| 52 |
+
FILE_SERVICE_TIMEOUT: float = 600.0
|
| 53 |
+
FILE_SERVICE_CONNECT_TIMEOUT: float = 100.0
|
| 54 |
+
|
| 55 |
+
OUTPUT_DIR: str = "/tmp/pdf2img_outputs"
|
| 56 |
+
TEMP_DIR: str = "/tmp/pdf2img_temp"
|
| 57 |
+
OUTPUT_TTL_SECONDS: int = 3600
|
| 58 |
+
|
| 59 |
+
LOG_LEVEL: str = "INFO"
|
| 60 |
+
LOG_FORMAT: Literal["json", "console"] = "console"
|
| 61 |
+
|
| 62 |
+
RATE_LIMIT: int = 100
|
| 63 |
+
RATE_LIMIT_WINDOW: int = 60
|
| 64 |
+
|
| 65 |
+
REDIS_URL: str = Field(default="", description="Redis connection URL for rate limiting")
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
FILE_SERVICE_URL: str = Field(default="", description="External file upload service endpoint")
|
| 69 |
+
FILE_SERVICE_API_KEY: str = Field(default="", description="API key for external file upload service")
|
| 70 |
+
FILE_SERVICE_BEARER_TOKEN: str = Field(default="", description="Bearer token for external file upload service")
|
| 71 |
+
|
| 72 |
+
@property
|
| 73 |
+
def MAX_FILE_SIZE_BYTES(self) -> int:
|
| 74 |
+
return self.MAX_FILE_SIZE_MB * 1024 * 1024
|
| 75 |
+
|
| 76 |
+
@property
|
| 77 |
+
def rate_limit_redis_configured(self) -> bool:
|
| 78 |
+
return bool(self.WORKERS > 1 and self.REDIS_URL)
|
| 79 |
+
|
| 80 |
+
@field_validator("DEFAULT_FORMAT")
|
| 81 |
+
@classmethod
|
| 82 |
+
def normalise_format(cls, v: str) -> str:
|
| 83 |
+
return v.upper()
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
@lru_cache
|
| 87 |
+
def get_settings() -> Settings:
|
| 88 |
+
return Settings()
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
settings = get_settings()
|
app/core/exceptions.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from fastapi import status
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class AppException(Exception):
|
| 7 |
+
def __init__(self, detail: str, status_code: int = status.HTTP_500_INTERNAL_SERVER_ERROR):
|
| 8 |
+
self.detail = detail
|
| 9 |
+
self.status_code = status_code
|
| 10 |
+
super().__init__(detail)
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class AuthenticationError(AppException):
|
| 14 |
+
def __init__(self, detail: str = "Unauthorized"):
|
| 15 |
+
super().__init__(detail, status.HTTP_401_UNAUTHORIZED)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class InvalidFileTypeError(AppException):
|
| 19 |
+
def __init__(self, detail: str = "Unsupported file type"):
|
| 20 |
+
super().__init__(detail, status.HTTP_415_UNSUPPORTED_MEDIA_TYPE)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class FileTooLargeError(AppException):
|
| 24 |
+
def __init__(self, max_mb: int):
|
| 25 |
+
super().__init__(
|
| 26 |
+
f"File exceeds the maximum allowed size of {max_mb} MB",
|
| 27 |
+
status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
| 28 |
+
)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class TooManyPagesError(AppException):
|
| 32 |
+
def __init__(self, max_pages: int):
|
| 33 |
+
super().__init__(
|
| 34 |
+
f"PDF exceeds the maximum allowed page count of {max_pages}",
|
| 35 |
+
status.HTTP_422_UNPROCESSABLE_ENTITY,
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
class ConversionError(AppException):
|
| 40 |
+
def __init__(self, detail: str = "PDF conversion failed"):
|
| 41 |
+
super().__init__(detail, status.HTTP_500_INTERNAL_SERVER_ERROR)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
class JobNotFoundError(AppException):
|
| 45 |
+
def __init__(self, job_id: str):
|
| 46 |
+
super().__init__(f"Job '{job_id}' not found", status.HTTP_404_NOT_FOUND)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
class InvalidParameterError(AppException):
|
| 50 |
+
def __init__(self, detail: str):
|
| 51 |
+
super().__init__(detail, status.HTTP_422_UNPROCESSABLE_ENTITY)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
class FileServiceError(AppException):
|
| 55 |
+
def __init__(self, detail: str = "File upload service error"):
|
| 56 |
+
super().__init__(detail, status.HTTP_502_BAD_GATEWAY)
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
class CSVGenerationError(AppException):
|
| 60 |
+
def __init__(self, detail: str = "CSV generation failed"):
|
| 61 |
+
super().__init__(detail, status.HTTP_500_INTERNAL_SERVER_ERROR)
|
app/core/logging.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import logging
|
| 4 |
+
import sys
|
| 5 |
+
|
| 6 |
+
import structlog
|
| 7 |
+
|
| 8 |
+
from app.core.config import settings
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def configure_logging() -> None:
|
| 12 |
+
pre_chain = [
|
| 13 |
+
structlog.contextvars.merge_contextvars,
|
| 14 |
+
structlog.stdlib.add_log_level,
|
| 15 |
+
structlog.processors.TimeStamper(fmt="iso"),
|
| 16 |
+
]
|
| 17 |
+
|
| 18 |
+
is_prod = settings.ENV == "production"
|
| 19 |
+
|
| 20 |
+
if is_prod:
|
| 21 |
+
renderer = structlog.processors.JSONRenderer()
|
| 22 |
+
else:
|
| 23 |
+
renderer = structlog.dev.ConsoleRenderer(colors=True)
|
| 24 |
+
pre_chain.insert(0, structlog.stdlib.add_logger_name)
|
| 25 |
+
|
| 26 |
+
structlog.configure(
|
| 27 |
+
processors=[
|
| 28 |
+
structlog.stdlib.filter_by_level,
|
| 29 |
+
*pre_chain,
|
| 30 |
+
structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
|
| 31 |
+
],
|
| 32 |
+
logger_factory=structlog.stdlib.LoggerFactory(),
|
| 33 |
+
wrapper_class=structlog.stdlib.BoundLogger,
|
| 34 |
+
cache_logger_on_first_use=True,
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
formatter = structlog.stdlib.ProcessorFormatter(
|
| 38 |
+
foreign_pre_chain=pre_chain,
|
| 39 |
+
processors=[
|
| 40 |
+
structlog.stdlib.ProcessorFormatter.remove_processors_meta,
|
| 41 |
+
renderer,
|
| 42 |
+
],
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
handler = logging.StreamHandler(sys.stdout)
|
| 46 |
+
handler.setFormatter(formatter)
|
| 47 |
+
|
| 48 |
+
root_logger = logging.getLogger()
|
| 49 |
+
root_logger.handlers = [handler]
|
| 50 |
+
root_logger.setLevel(settings.LOG_LEVEL.upper())
|
| 51 |
+
|
| 52 |
+
for lib in (
|
| 53 |
+
"uvicorn.access",
|
| 54 |
+
"uvicorn.error",
|
| 55 |
+
"multipart",
|
| 56 |
+
"httpx",
|
| 57 |
+
"httpcore",
|
| 58 |
+
"redis",
|
| 59 |
+
"PIL",
|
| 60 |
+
"asyncio",
|
| 61 |
+
):
|
| 62 |
+
logging.getLogger(lib).setLevel(logging.WARNING)
|
app/core/rate_limit.py
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import ipaddress
|
| 4 |
+
import json
|
| 5 |
+
import structlog
|
| 6 |
+
import time
|
| 7 |
+
from typing import Optional, Tuple
|
| 8 |
+
from uuid import uuid4
|
| 9 |
+
|
| 10 |
+
import redis.asyncio as aioredis
|
| 11 |
+
from redis.asyncio.retry import Retry
|
| 12 |
+
from redis.backoff import ExponentialBackoff
|
| 13 |
+
from redis.exceptions import (
|
| 14 |
+
BusyLoadingError,
|
| 15 |
+
ConnectionError as RedisConnectionError,
|
| 16 |
+
NoScriptError,
|
| 17 |
+
RedisError,
|
| 18 |
+
TimeoutError as RedisTimeoutError,
|
| 19 |
+
)
|
| 20 |
+
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
|
| 21 |
+
from starlette.requests import Request
|
| 22 |
+
from starlette.responses import Response
|
| 23 |
+
|
| 24 |
+
from app.core.config import settings
|
| 25 |
+
|
| 26 |
+
logger = structlog.get_logger(__name__)
|
| 27 |
+
|
| 28 |
+
_EXEMPT_PATHS: frozenset[str] = frozenset({
|
| 29 |
+
"/api/v1/health",
|
| 30 |
+
"/api/v1/ready",
|
| 31 |
+
"/api/v1/ping",
|
| 32 |
+
"/docs",
|
| 33 |
+
"/redoc",
|
| 34 |
+
"/openapi.json",
|
| 35 |
+
"/metrics",
|
| 36 |
+
})
|
| 37 |
+
|
| 38 |
+
_TRUSTED_PROXY_NETWORKS: tuple[ipaddress.IPv4Network | ipaddress.IPv6Network, ...] = (
|
| 39 |
+
ipaddress.ip_network("10.0.0.0/8"),
|
| 40 |
+
ipaddress.ip_network("172.16.0.0/12"),
|
| 41 |
+
ipaddress.ip_network("192.168.0.0/16"),
|
| 42 |
+
ipaddress.ip_network("127.0.0.0/8"),
|
| 43 |
+
ipaddress.ip_network("fc00::/7"),
|
| 44 |
+
ipaddress.ip_network("::1/128"),
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
_RATE_LIMIT_SCRIPT = """
|
| 48 |
+
local key = KEYS[1]
|
| 49 |
+
local now = tonumber(ARGV[1])
|
| 50 |
+
local window = tonumber(ARGV[2])
|
| 51 |
+
local limit = tonumber(ARGV[3])
|
| 52 |
+
local unique_id = ARGV[4]
|
| 53 |
+
local cutoff = now - window
|
| 54 |
+
|
| 55 |
+
redis.call('zremrangebyscore', key, '-inf', cutoff)
|
| 56 |
+
local count = redis.call('zcard', key)
|
| 57 |
+
|
| 58 |
+
if count < limit then
|
| 59 |
+
redis.call('zadd', key, now, unique_id)
|
| 60 |
+
redis.call('expire', key, window + 1)
|
| 61 |
+
return {0, limit - count - 1}
|
| 62 |
+
end
|
| 63 |
+
|
| 64 |
+
redis.call('expire', key, window + 1)
|
| 65 |
+
return {1, 0}
|
| 66 |
+
"""
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def _is_trusted_proxy(host: str) -> bool:
|
| 70 |
+
try:
|
| 71 |
+
addr = ipaddress.ip_address(host)
|
| 72 |
+
return any(addr in net for net in _TRUSTED_PROXY_NETWORKS)
|
| 73 |
+
except ValueError:
|
| 74 |
+
return False
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def _extract_client_ip(request: Request) -> str:
|
| 78 |
+
direct_host = request.client.host if request.client else None
|
| 79 |
+
|
| 80 |
+
if direct_host and _is_trusted_proxy(direct_host):
|
| 81 |
+
forwarded_for = request.headers.get("x-forwarded-for", "")
|
| 82 |
+
if forwarded_for:
|
| 83 |
+
for candidate in reversed([ip.strip() for ip in forwarded_for.split(",")]):
|
| 84 |
+
if candidate and not _is_trusted_proxy(candidate):
|
| 85 |
+
return candidate
|
| 86 |
+
|
| 87 |
+
real_ip = request.headers.get("x-real-ip", "").strip()
|
| 88 |
+
if real_ip and not _is_trusted_proxy(real_ip):
|
| 89 |
+
return real_ip
|
| 90 |
+
|
| 91 |
+
return direct_host or "unknown"
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
class RateLimitMiddleware(BaseHTTPMiddleware):
|
| 95 |
+
def __init__(self, app, **kwargs) -> None:
|
| 96 |
+
super().__init__(app, **kwargs)
|
| 97 |
+
self._pool: Optional[aioredis.ConnectionPool] = None
|
| 98 |
+
self._client: Optional[aioredis.Redis] = None
|
| 99 |
+
self._script_sha: Optional[str] = None
|
| 100 |
+
|
| 101 |
+
def _build_pool(self) -> aioredis.ConnectionPool:
|
| 102 |
+
retry = Retry(
|
| 103 |
+
ExponentialBackoff(cap=0.5, base=0.1),
|
| 104 |
+
retries=2,
|
| 105 |
+
supported_errors=(BusyLoadingError, RedisConnectionError, RedisTimeoutError),
|
| 106 |
+
)
|
| 107 |
+
return aioredis.ConnectionPool.from_url(
|
| 108 |
+
settings.REDIS_URL,
|
| 109 |
+
encoding="utf-8",
|
| 110 |
+
decode_responses=True,
|
| 111 |
+
socket_connect_timeout=1,
|
| 112 |
+
socket_timeout=1,
|
| 113 |
+
socket_keepalive=True,
|
| 114 |
+
retry=retry,
|
| 115 |
+
retry_on_timeout=True,
|
| 116 |
+
max_connections=max(settings.WORKERS * 2, 10),
|
| 117 |
+
health_check_interval=30,
|
| 118 |
+
)
|
| 119 |
+
|
| 120 |
+
async def _get_client(self) -> aioredis.Redis:
|
| 121 |
+
if self._pool is None:
|
| 122 |
+
self._pool = self._build_pool()
|
| 123 |
+
if self._client is None:
|
| 124 |
+
self._client = aioredis.Redis(connection_pool=self._pool)
|
| 125 |
+
return self._client
|
| 126 |
+
|
| 127 |
+
async def _load_script(self, client: aioredis.Redis) -> str:
|
| 128 |
+
if self._script_sha is None:
|
| 129 |
+
self._script_sha = await client.script_load(_RATE_LIMIT_SCRIPT)
|
| 130 |
+
return self._script_sha
|
| 131 |
+
|
| 132 |
+
async def _check_rate_limit(self, client_ip: str) -> Tuple[bool, int]:
|
| 133 |
+
now_ms = int(time.time() * 1000)
|
| 134 |
+
window_ms = settings.RATE_LIMIT_WINDOW * 1000
|
| 135 |
+
key = f"rl:{client_ip}"
|
| 136 |
+
unique_id = f"{now_ms}:{uuid4().hex}"
|
| 137 |
+
|
| 138 |
+
client = await self._get_client()
|
| 139 |
+
|
| 140 |
+
try:
|
| 141 |
+
sha = await self._load_script(client)
|
| 142 |
+
result = await client.evalsha(
|
| 143 |
+
sha,
|
| 144 |
+
1,
|
| 145 |
+
key,
|
| 146 |
+
str(now_ms),
|
| 147 |
+
str(window_ms),
|
| 148 |
+
str(settings.RATE_LIMIT),
|
| 149 |
+
unique_id,
|
| 150 |
+
)
|
| 151 |
+
except NoScriptError:
|
| 152 |
+
self._script_sha = None
|
| 153 |
+
sha = await self._load_script(client)
|
| 154 |
+
result = await client.evalsha(
|
| 155 |
+
sha,
|
| 156 |
+
1,
|
| 157 |
+
key,
|
| 158 |
+
str(now_ms),
|
| 159 |
+
str(window_ms),
|
| 160 |
+
str(settings.RATE_LIMIT),
|
| 161 |
+
unique_id,
|
| 162 |
+
)
|
| 163 |
+
|
| 164 |
+
return bool(result[0]), int(result[1])
|
| 165 |
+
|
| 166 |
+
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
|
| 167 |
+
if request.url.path in _EXEMPT_PATHS:
|
| 168 |
+
return await call_next(request)
|
| 169 |
+
|
| 170 |
+
if not settings.rate_limit_redis_configured:
|
| 171 |
+
return await call_next(request)
|
| 172 |
+
|
| 173 |
+
client_ip = _extract_client_ip(request)
|
| 174 |
+
|
| 175 |
+
try:
|
| 176 |
+
is_limited, remaining = await self._check_rate_limit(client_ip)
|
| 177 |
+
except (RedisConnectionError, RedisTimeoutError) as exc:
|
| 178 |
+
logger.error("redis_unavailable", error=str(exc))
|
| 179 |
+
return self._service_unavailable_response()
|
| 180 |
+
except RedisError as exc:
|
| 181 |
+
logger.error("redis_error", error=str(exc))
|
| 182 |
+
return self._service_unavailable_response()
|
| 183 |
+
|
| 184 |
+
if is_limited:
|
| 185 |
+
request_id = getattr(request.state, "request_id", str(uuid4()))
|
| 186 |
+
return self._rate_limit_response(request_id)
|
| 187 |
+
|
| 188 |
+
response = await call_next(request)
|
| 189 |
+
response.headers["X-RateLimit-Limit"] = str(settings.RATE_LIMIT)
|
| 190 |
+
response.headers["X-RateLimit-Remaining"] = str(remaining)
|
| 191 |
+
response.headers["X-RateLimit-Window"] = str(settings.RATE_LIMIT_WINDOW)
|
| 192 |
+
return response
|
| 193 |
+
|
| 194 |
+
def _rate_limit_response(self, request_id: str) -> Response:
|
| 195 |
+
return Response(
|
| 196 |
+
content=json.dumps({
|
| 197 |
+
"error": "Too many requests. Please slow down.",
|
| 198 |
+
"request_id": request_id,
|
| 199 |
+
}),
|
| 200 |
+
status_code=429,
|
| 201 |
+
media_type="application/json",
|
| 202 |
+
headers={
|
| 203 |
+
"Retry-After": str(settings.RATE_LIMIT_WINDOW),
|
| 204 |
+
"X-RateLimit-Limit": str(settings.RATE_LIMIT),
|
| 205 |
+
"X-RateLimit-Remaining": "0",
|
| 206 |
+
"X-RateLimit-Window": str(settings.RATE_LIMIT_WINDOW),
|
| 207 |
+
},
|
| 208 |
+
)
|
| 209 |
+
|
| 210 |
+
def _service_unavailable_response(self) -> Response:
|
| 211 |
+
return Response(
|
| 212 |
+
content=json.dumps(
|
| 213 |
+
{"error": "Service temporarily unavailable. Please retry shortly."}
|
| 214 |
+
),
|
| 215 |
+
status_code=503,
|
| 216 |
+
media_type="application/json",
|
| 217 |
+
headers={"Retry-After": "5"},
|
| 218 |
+
)
|
app/main.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import time
|
| 4 |
+
import uuid
|
| 5 |
+
from contextlib import asynccontextmanager
|
| 6 |
+
|
| 7 |
+
import structlog
|
| 8 |
+
from fastapi import FastAPI, Request
|
| 9 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 10 |
+
from fastapi.middleware.gzip import GZipMiddleware
|
| 11 |
+
from fastapi.responses import JSONResponse
|
| 12 |
+
from prometheus_client import make_asgi_app
|
| 13 |
+
|
| 14 |
+
from app.api.routes import router
|
| 15 |
+
from app.core.config import settings
|
| 16 |
+
from app.core.exceptions import AppException
|
| 17 |
+
from app.core.logging import configure_logging
|
| 18 |
+
from app.core.rate_limit import RateLimitMiddleware
|
| 19 |
+
configure_logging()
|
| 20 |
+
logger = structlog.get_logger(__name__)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
@asynccontextmanager
|
| 24 |
+
async def lifespan(app: FastAPI):
|
| 25 |
+
logger.info("startup", service=settings.APP_NAME, version=settings.VERSION, env=settings.ENV)
|
| 26 |
+
yield
|
| 27 |
+
logger.info("shutdown", service=settings.APP_NAME)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
app = FastAPI(
|
| 31 |
+
title=settings.APP_NAME,
|
| 32 |
+
description="High-performance PDF to Image conversion and CSV report generation API",
|
| 33 |
+
version=settings.VERSION,
|
| 34 |
+
docs_url="/docs",
|
| 35 |
+
redoc_url="/redoc",
|
| 36 |
+
lifespan=lifespan,
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
app.add_middleware(RateLimitMiddleware)
|
| 40 |
+
app.add_middleware(GZipMiddleware, minimum_size=1000)
|
| 41 |
+
app.add_middleware(
|
| 42 |
+
CORSMiddleware,
|
| 43 |
+
allow_origins=settings.ALLOWED_ORIGINS,
|
| 44 |
+
allow_credentials=True,
|
| 45 |
+
allow_methods=["*"],
|
| 46 |
+
allow_headers=["*"],
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
@app.middleware("http")
|
| 51 |
+
async def request_context_middleware(request: Request, call_next):
|
| 52 |
+
request_id = str(uuid.uuid4())
|
| 53 |
+
start = time.perf_counter()
|
| 54 |
+
request.state.request_id = request_id
|
| 55 |
+
|
| 56 |
+
structlog.contextvars.clear_contextvars()
|
| 57 |
+
structlog.contextvars.bind_contextvars(
|
| 58 |
+
request_id=request_id,
|
| 59 |
+
method=request.method,
|
| 60 |
+
path=request.url.path,
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
response = await call_next(request)
|
| 64 |
+
elapsed = round((time.perf_counter() - start) * 1000, 2)
|
| 65 |
+
|
| 66 |
+
logger.info("request_completed", status_code=response.status_code, duration_ms=elapsed)
|
| 67 |
+
response.headers["X-Request-ID"] = request_id
|
| 68 |
+
response.headers["X-Response-Time-Ms"] = str(elapsed)
|
| 69 |
+
return response
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
@app.exception_handler(AppException)
|
| 73 |
+
async def app_exception_handler(request: Request, exc: AppException):
|
| 74 |
+
logger.warning("app_exception", detail=exc.detail, status_code=exc.status_code)
|
| 75 |
+
return JSONResponse(
|
| 76 |
+
status_code=exc.status_code,
|
| 77 |
+
content={"error": exc.detail, "request_id": request.state.request_id},
|
| 78 |
+
)
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
@app.exception_handler(Exception)
|
| 82 |
+
async def generic_exception_handler(request: Request, exc: Exception):
|
| 83 |
+
logger.exception("unhandled_exception", exc_info=exc)
|
| 84 |
+
return JSONResponse(
|
| 85 |
+
status_code=500,
|
| 86 |
+
content={"error": "Internal server error", "request_id": request.state.request_id},
|
| 87 |
+
)
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
@app.get("/")
|
| 91 |
+
async def root():
|
| 92 |
+
return {"message": f"{settings.APP_NAME} v{settings.VERSION}"}
|
| 93 |
+
|
| 94 |
+
app.include_router(router, prefix="/api/v1")
|
| 95 |
+
app.mount("/metrics", make_asgi_app())
|
app/models/__init__.py
ADDED
|
File without changes
|
app/models/schemas.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import re
|
| 4 |
+
from enum import Enum
|
| 5 |
+
from typing import Any, Dict, List, Literal, Optional
|
| 6 |
+
|
| 7 |
+
from pydantic import BaseModel, Field, field_validator, model_validator
|
| 8 |
+
|
| 9 |
+
from app.core.config import settings
|
| 10 |
+
|
| 11 |
+
_PAGE_SPEC_RE = re.compile(r"^\s*(\d+(-\d+)?)(\s*,\s*(\d+(-\d+)?))*\s*$")
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class ImageFormat(str, Enum):
|
| 15 |
+
PNG = "PNG"
|
| 16 |
+
JPEG = "JPEG"
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class JobStatus(str, Enum):
|
| 20 |
+
PENDING = "pending"
|
| 21 |
+
PROCESSING = "processing"
|
| 22 |
+
COMPLETED = "completed"
|
| 23 |
+
FAILED = "failed"
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class ConversionParams(BaseModel):
|
| 27 |
+
format: ImageFormat = Field(ImageFormat.PNG)
|
| 28 |
+
dpi: int = Field(settings.DEFAULT_DPI, ge=72, le=settings.MAX_DPI)
|
| 29 |
+
quality: int = Field(85, ge=1, le=100)
|
| 30 |
+
pages: Optional[str] = Field(None)
|
| 31 |
+
grayscale: bool = Field(False)
|
| 32 |
+
transparent_bg: bool = Field(False)
|
| 33 |
+
split_page: Optional[bool] = Field(
|
| 34 |
+
False,
|
| 35 |
+
description=(
|
| 36 |
+
"Controls output granularity. "
|
| 37 |
+
"True: each page → separate image URL. "
|
| 38 |
+
"False (default): all pages stitched into one tall image → single URL."
|
| 39 |
+
),
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
@field_validator("format", mode="before")
|
| 43 |
+
@classmethod
|
| 44 |
+
def normalise_format(cls, v: str) -> str:
|
| 45 |
+
if isinstance(v, str):
|
| 46 |
+
upper = v.upper()
|
| 47 |
+
if upper == "JPG":
|
| 48 |
+
return "JPEG"
|
| 49 |
+
return upper
|
| 50 |
+
return v
|
| 51 |
+
|
| 52 |
+
@field_validator("pages")
|
| 53 |
+
@classmethod
|
| 54 |
+
def validate_page_spec_syntax(cls, v: Optional[str]) -> Optional[str]:
|
| 55 |
+
if v is None or v.strip() == "":
|
| 56 |
+
return None
|
| 57 |
+
if not _PAGE_SPEC_RE.match(v):
|
| 58 |
+
raise ValueError(
|
| 59 |
+
f"Invalid page spec '{v}'. Use comma-separated numbers or ranges, "
|
| 60 |
+
"e.g. '1', '1-3', '1,3,5-7'."
|
| 61 |
+
)
|
| 62 |
+
for token in v.split(","):
|
| 63 |
+
token = token.strip()
|
| 64 |
+
if "-" in token:
|
| 65 |
+
parts = token.split("-", 1)
|
| 66 |
+
start, end = int(parts[0]), int(parts[1])
|
| 67 |
+
if start < 1:
|
| 68 |
+
raise ValueError(f"Page numbers are 1-indexed; got '{token}'")
|
| 69 |
+
if start > end:
|
| 70 |
+
raise ValueError(f"Invalid range '{token}': start must be <= end")
|
| 71 |
+
else:
|
| 72 |
+
if int(token) < 1:
|
| 73 |
+
raise ValueError(f"Page numbers are 1-indexed; got '{token}'")
|
| 74 |
+
return v
|
| 75 |
+
|
| 76 |
+
@model_validator(mode="after")
|
| 77 |
+
def validate_transparent_bg(self) -> ConversionParams:
|
| 78 |
+
if self.transparent_bg and self.format != ImageFormat.PNG:
|
| 79 |
+
raise ValueError("transparent_bg is only supported for PNG output")
|
| 80 |
+
return self
|
| 81 |
+
|
| 82 |
+
def parse_pages(self, total_pages: int) -> List[int]:
|
| 83 |
+
if not self.pages:
|
| 84 |
+
return list(range(total_pages))
|
| 85 |
+
indices: set[int] = set()
|
| 86 |
+
for token in self.pages.split(","):
|
| 87 |
+
token = token.strip()
|
| 88 |
+
if "-" in token:
|
| 89 |
+
parts = token.split("-", 1)
|
| 90 |
+
start, end = int(parts[0]) - 1, int(parts[1]) - 1
|
| 91 |
+
indices.update(range(start, end + 1))
|
| 92 |
+
else:
|
| 93 |
+
indices.add(int(token) - 1)
|
| 94 |
+
valid = sorted(i for i in indices if 0 <= i < total_pages)
|
| 95 |
+
if not valid:
|
| 96 |
+
raise ValueError(
|
| 97 |
+
f"Page spec '{self.pages}' contains no pages within "
|
| 98 |
+
f"the document's {total_pages} page(s)"
|
| 99 |
+
)
|
| 100 |
+
return valid
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
class PageResult(BaseModel):
|
| 104 |
+
page_number: int
|
| 105 |
+
download_url: str
|
| 106 |
+
width: int
|
| 107 |
+
height: int
|
| 108 |
+
size_bytes: int
|
| 109 |
+
format: str
|
| 110 |
+
file_id: Optional[str] = None
|
| 111 |
+
file_url: Optional[str] = None
|
| 112 |
+
upload_error: Optional[str] = None
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
class UploadSummary(BaseModel):
|
| 116 |
+
upload_mode: str
|
| 117 |
+
total_uploaded: int
|
| 118 |
+
failed_uploads: int
|
| 119 |
+
files: List["UploadedFileInfo"]
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
class ConversionResult(BaseModel):
|
| 123 |
+
job_id: str
|
| 124 |
+
status: JobStatus
|
| 125 |
+
total_pages: int
|
| 126 |
+
converted_pages: int
|
| 127 |
+
pages: List[PageResult]
|
| 128 |
+
duration_ms: float
|
| 129 |
+
upload_summary: Optional[UploadSummary] = None
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
class AsyncJobResponse(BaseModel):
|
| 133 |
+
job_id: str
|
| 134 |
+
status: JobStatus
|
| 135 |
+
message: str
|
| 136 |
+
status_url: str
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
class JobStatusResponse(BaseModel):
|
| 140 |
+
job_id: str
|
| 141 |
+
status: JobStatus
|
| 142 |
+
progress: float = Field(..., ge=0, le=100)
|
| 143 |
+
result: Optional[ConversionResult] = None
|
| 144 |
+
error: Optional[str] = None
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
class HealthResponse(BaseModel):
|
| 148 |
+
status: str
|
| 149 |
+
version: str
|
| 150 |
+
environment: str
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
class ErrorResponse(BaseModel):
|
| 154 |
+
error: str
|
| 155 |
+
request_id: Optional[str] = None
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
class UploadedFileInfo(BaseModel):
|
| 159 |
+
file_id: str
|
| 160 |
+
file_url: str
|
| 161 |
+
filename: str
|
| 162 |
+
size_bytes: int
|
app/services/__init__.py
ADDED
|
File without changes
|
app/services/conversion.py
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import asyncio
|
| 4 |
+
import io
|
| 5 |
+
import time
|
| 6 |
+
import uuid
|
| 7 |
+
from concurrent.futures import ThreadPoolExecutor
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
from typing import List, Optional, Tuple
|
| 10 |
+
|
| 11 |
+
import structlog
|
| 12 |
+
from PIL import Image
|
| 13 |
+
|
| 14 |
+
from app.core.config import settings
|
| 15 |
+
from app.core.exceptions import ConversionError, InvalidParameterError, TooManyPagesError
|
| 16 |
+
from app.models.schemas import ConversionParams, PageResult
|
| 17 |
+
|
| 18 |
+
logger = structlog.get_logger(__name__)
|
| 19 |
+
|
| 20 |
+
_SUPPORTED_FORMATS = {"JPEG", "PNG"}
|
| 21 |
+
_MAX_MEMORY_ESTIMATE_MB = 512
|
| 22 |
+
|
| 23 |
+
_render_pool = ThreadPoolExecutor(
|
| 24 |
+
max_workers=settings.PAGE_RENDER_WORKERS,
|
| 25 |
+
thread_name_prefix="page_renderer",
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class ConversionService:
|
| 30 |
+
def __init__(self) -> None:
|
| 31 |
+
self._output_dir = Path(settings.OUTPUT_DIR)
|
| 32 |
+
self._output_dir.mkdir(parents=True, exist_ok=True)
|
| 33 |
+
|
| 34 |
+
async def convert(
|
| 35 |
+
self,
|
| 36 |
+
pdf_bytes: bytes,
|
| 37 |
+
params: ConversionParams,
|
| 38 |
+
job_id: Optional[str] = None,
|
| 39 |
+
) -> Tuple[List[PageResult], float]:
|
| 40 |
+
"""
|
| 41 |
+
Full pipeline:
|
| 42 |
+
1. Validate PDF + determine page indices
|
| 43 |
+
2. Render pages (always individually for parallelism)
|
| 44 |
+
3a. split_page=True / omitted → return one PageResult per page
|
| 45 |
+
3b. split_page=False → stitch all pages into one tall image,
|
| 46 |
+
return a single PageResult
|
| 47 |
+
"""
|
| 48 |
+
job_id = job_id or str(uuid.uuid4())
|
| 49 |
+
job_dir = self._output_dir / job_id
|
| 50 |
+
job_dir.mkdir(parents=True, exist_ok=True)
|
| 51 |
+
|
| 52 |
+
start = time.perf_counter()
|
| 53 |
+
loop = asyncio.get_event_loop()
|
| 54 |
+
|
| 55 |
+
try:
|
| 56 |
+
page_indices, total_pages = await loop.run_in_executor(
|
| 57 |
+
_render_pool, _validate_and_get_indices, pdf_bytes, params
|
| 58 |
+
)
|
| 59 |
+
except (ConversionError, InvalidParameterError, TooManyPagesError):
|
| 60 |
+
raise
|
| 61 |
+
except Exception as exc:
|
| 62 |
+
logger.exception("pdf_validation_failed", job_id=job_id)
|
| 63 |
+
raise ConversionError(str(exc)) from exc
|
| 64 |
+
|
| 65 |
+
logger.info(
|
| 66 |
+
"conversion_started",
|
| 67 |
+
job_id=job_id,
|
| 68 |
+
total_pages=total_pages,
|
| 69 |
+
rendering=len(page_indices),
|
| 70 |
+
dpi=params.dpi,
|
| 71 |
+
format=params.format,
|
| 72 |
+
split_page=params.split_page,
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
try:
|
| 76 |
+
page_blobs: dict[int, bytes] = await loop.run_in_executor(
|
| 77 |
+
_render_pool, _split_pdf_pages, pdf_bytes, page_indices
|
| 78 |
+
)
|
| 79 |
+
except Exception as exc:
|
| 80 |
+
logger.exception("pdf_split_failed", job_id=job_id)
|
| 81 |
+
raise ConversionError(f"Failed to split PDF into pages: {exc}") from exc
|
| 82 |
+
|
| 83 |
+
page_results = await self._render_parallel(
|
| 84 |
+
page_blobs, params, job_dir, job_id, loop
|
| 85 |
+
)
|
| 86 |
+
|
| 87 |
+
if params.split_page is False:
|
| 88 |
+
try:
|
| 89 |
+
stitched = await loop.run_in_executor(
|
| 90 |
+
_render_pool, _stitch_pages, page_results, params, job_dir
|
| 91 |
+
)
|
| 92 |
+
page_results = [stitched]
|
| 93 |
+
logger.info("pages_stitched", job_id=job_id, total_pages=len(page_blobs))
|
| 94 |
+
except Exception as exc:
|
| 95 |
+
logger.exception("stitch_failed", job_id=job_id)
|
| 96 |
+
raise ConversionError(f"Failed to stitch pages into single image: {exc}") from exc
|
| 97 |
+
|
| 98 |
+
duration_ms = round((time.perf_counter() - start) * 1000, 2)
|
| 99 |
+
logger.info("conversion_complete", job_id=job_id, pages=len(page_results), duration_ms=duration_ms)
|
| 100 |
+
return page_results, duration_ms
|
| 101 |
+
|
| 102 |
+
async def _render_parallel(
|
| 103 |
+
self,
|
| 104 |
+
page_blobs: dict[int, bytes],
|
| 105 |
+
params: ConversionParams,
|
| 106 |
+
job_dir: Path,
|
| 107 |
+
job_id: str,
|
| 108 |
+
loop: asyncio.AbstractEventLoop,
|
| 109 |
+
) -> List[PageResult]:
|
| 110 |
+
ordered_indices = sorted(page_blobs.keys())
|
| 111 |
+
render_coros = [
|
| 112 |
+
loop.run_in_executor(
|
| 113 |
+
_render_pool,
|
| 114 |
+
_render_single_page,
|
| 115 |
+
page_idx,
|
| 116 |
+
page_blobs[page_idx],
|
| 117 |
+
params,
|
| 118 |
+
job_dir,
|
| 119 |
+
)
|
| 120 |
+
for page_idx in ordered_indices
|
| 121 |
+
]
|
| 122 |
+
|
| 123 |
+
results_by_idx: dict[int, PageResult] = {}
|
| 124 |
+
errors: list[str] = []
|
| 125 |
+
|
| 126 |
+
raw_results = await asyncio.gather(*render_coros, return_exceptions=True)
|
| 127 |
+
|
| 128 |
+
for page_idx, outcome in zip(ordered_indices, raw_results):
|
| 129 |
+
if isinstance(outcome, Exception):
|
| 130 |
+
logger.error("page_render_failed", job_id=job_id, page=page_idx + 1, error=str(outcome))
|
| 131 |
+
errors.append(f"Page {page_idx + 1}: {outcome}")
|
| 132 |
+
else:
|
| 133 |
+
results_by_idx[page_idx] = outcome
|
| 134 |
+
logger.debug("page_rendered", job_id=job_id, page=page_idx + 1, size_bytes=outcome.size_bytes)
|
| 135 |
+
|
| 136 |
+
if not results_by_idx:
|
| 137 |
+
raise ConversionError(f"All pages failed to render. Errors: {'; '.join(errors)}")
|
| 138 |
+
|
| 139 |
+
if errors:
|
| 140 |
+
logger.warning("some_pages_failed", job_id=job_id, failed=len(errors), succeeded=len(results_by_idx))
|
| 141 |
+
|
| 142 |
+
return [results_by_idx[i] for i in sorted(results_by_idx)]
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def _validate_and_get_indices(
|
| 146 |
+
pdf_bytes: bytes,
|
| 147 |
+
params: ConversionParams,
|
| 148 |
+
) -> Tuple[List[int], int]:
|
| 149 |
+
from pypdf import PdfReader
|
| 150 |
+
from pypdf.errors import PdfReadError
|
| 151 |
+
|
| 152 |
+
fmt = params.format.value if hasattr(params.format, "value") else str(params.format)
|
| 153 |
+
if fmt not in _SUPPORTED_FORMATS:
|
| 154 |
+
raise InvalidParameterError(
|
| 155 |
+
f"Unsupported format '{fmt}'. Only JPEG (JPG) and PNG are supported."
|
| 156 |
+
)
|
| 157 |
+
|
| 158 |
+
try:
|
| 159 |
+
reader = PdfReader(io.BytesIO(pdf_bytes))
|
| 160 |
+
if reader.is_encrypted:
|
| 161 |
+
from pypdf import PasswordType
|
| 162 |
+
result = reader.decrypt("")
|
| 163 |
+
if result == PasswordType.NOT_DECRYPTED:
|
| 164 |
+
raise ConversionError("PDF is password-protected. Please provide an unlocked PDF.")
|
| 165 |
+
total_pages = len(reader.pages)
|
| 166 |
+
except ConversionError:
|
| 167 |
+
raise
|
| 168 |
+
except PdfReadError as exc:
|
| 169 |
+
raise ConversionError(f"Malformed PDF: {exc}") from exc
|
| 170 |
+
|
| 171 |
+
if total_pages == 0:
|
| 172 |
+
raise ConversionError("PDF contains no pages")
|
| 173 |
+
if total_pages > settings.MAX_PAGES:
|
| 174 |
+
raise TooManyPagesError(settings.MAX_PAGES)
|
| 175 |
+
|
| 176 |
+
page_indices = params.parse_pages(total_pages)
|
| 177 |
+
_guard_memory(len(page_indices), params.dpi)
|
| 178 |
+
|
| 179 |
+
return page_indices, total_pages
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def _split_pdf_pages(pdf_bytes: bytes, page_indices: List[int]) -> dict[int, bytes]:
|
| 183 |
+
"""Extract each requested page into its own single-page PDF blob for parallel rendering."""
|
| 184 |
+
from pypdf import PdfReader, PdfWriter
|
| 185 |
+
|
| 186 |
+
reader = PdfReader(io.BytesIO(pdf_bytes))
|
| 187 |
+
blobs: dict[int, bytes] = {}
|
| 188 |
+
|
| 189 |
+
for page_idx in page_indices:
|
| 190 |
+
writer = PdfWriter()
|
| 191 |
+
writer.add_page(reader.pages[page_idx])
|
| 192 |
+
buf = io.BytesIO()
|
| 193 |
+
writer.write(buf)
|
| 194 |
+
blobs[page_idx] = buf.getvalue()
|
| 195 |
+
|
| 196 |
+
logger.debug("pdf_split_complete", pages=len(blobs))
|
| 197 |
+
return blobs
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
def _render_single_page(
|
| 201 |
+
page_idx: int,
|
| 202 |
+
page_pdf_bytes: bytes,
|
| 203 |
+
params: ConversionParams,
|
| 204 |
+
job_dir: Path,
|
| 205 |
+
) -> PageResult:
|
| 206 |
+
"""Render one single-page PDF blob to an image file on disk."""
|
| 207 |
+
from pdf2image import convert_from_bytes
|
| 208 |
+
from pdf2image.exceptions import PDFInfoNotInstalledError, PDFPageCountError, PDFSyntaxError
|
| 209 |
+
|
| 210 |
+
fmt = params.format.value if hasattr(params.format, "value") else params.format
|
| 211 |
+
|
| 212 |
+
try:
|
| 213 |
+
pil_images = convert_from_bytes(
|
| 214 |
+
page_pdf_bytes,
|
| 215 |
+
dpi=params.dpi,
|
| 216 |
+
fmt="ppm",
|
| 217 |
+
thread_count=1,
|
| 218 |
+
transparent=params.transparent_bg and fmt == "PNG",
|
| 219 |
+
use_pdftocairo=True,
|
| 220 |
+
)
|
| 221 |
+
except (PDFInfoNotInstalledError, PDFPageCountError, PDFSyntaxError) as exc:
|
| 222 |
+
raise ConversionError(f"Page {page_idx + 1} render failed: {exc}") from exc
|
| 223 |
+
|
| 224 |
+
if not pil_images:
|
| 225 |
+
raise ConversionError(f"Page {page_idx + 1}: renderer returned no image")
|
| 226 |
+
|
| 227 |
+
img = pil_images[0]
|
| 228 |
+
|
| 229 |
+
if fmt == "JPEG" and img.mode in ("RGBA", "LA", "P"):
|
| 230 |
+
if img.mode == "P":
|
| 231 |
+
img = img.convert("RGBA")
|
| 232 |
+
bg = Image.new("RGB", img.size, (255, 255, 255))
|
| 233 |
+
mask = img.split()[-1] if img.mode in ("RGBA", "LA") else None
|
| 234 |
+
bg.paste(img, mask=mask)
|
| 235 |
+
img = bg
|
| 236 |
+
elif fmt == "JPEG" and img.mode != "RGB":
|
| 237 |
+
img = img.convert("RGB")
|
| 238 |
+
|
| 239 |
+
if params.grayscale:
|
| 240 |
+
img = img.convert("L")
|
| 241 |
+
|
| 242 |
+
ext = "jpg" if fmt == "JPEG" else "png"
|
| 243 |
+
filename = f"page_{page_idx + 1:04d}.{ext}"
|
| 244 |
+
out_path = job_dir / filename
|
| 245 |
+
|
| 246 |
+
save_kwargs: dict = {}
|
| 247 |
+
if fmt == "JPEG":
|
| 248 |
+
save_kwargs["quality"] = params.quality
|
| 249 |
+
save_kwargs["optimize"] = True
|
| 250 |
+
if fmt == "PNG":
|
| 251 |
+
save_kwargs["optimize"] = True
|
| 252 |
+
|
| 253 |
+
try:
|
| 254 |
+
img.save(str(out_path), format=fmt, **save_kwargs)
|
| 255 |
+
except OSError as exc:
|
| 256 |
+
raise ConversionError(f"Failed to write {filename}: {exc}") from exc
|
| 257 |
+
|
| 258 |
+
stat = out_path.stat()
|
| 259 |
+
return PageResult(
|
| 260 |
+
page_number=page_idx + 1,
|
| 261 |
+
download_url=f"/api/v1/files/{job_dir.name}/{filename}",
|
| 262 |
+
width=img.width,
|
| 263 |
+
height=img.height,
|
| 264 |
+
size_bytes=stat.st_size,
|
| 265 |
+
format=fmt,
|
| 266 |
+
)
|
| 267 |
+
|
| 268 |
+
|
| 269 |
+
def _stitch_pages(
|
| 270 |
+
page_results: List[PageResult],
|
| 271 |
+
params: ConversionParams,
|
| 272 |
+
job_dir: Path,
|
| 273 |
+
) -> PageResult:
|
| 274 |
+
"""
|
| 275 |
+
Stitch all rendered page images vertically into a single tall image.
|
| 276 |
+
Called only when split_page=False.
|
| 277 |
+
Pages are placed top-to-bottom in document order with no gaps.
|
| 278 |
+
"""
|
| 279 |
+
fmt = params.format.value if hasattr(params.format, "value") else params.format
|
| 280 |
+
ext = "jpg" if fmt == "JPEG" else "png"
|
| 281 |
+
|
| 282 |
+
images: list[Image.Image] = []
|
| 283 |
+
for pr in sorted(page_results, key=lambda p: p.page_number):
|
| 284 |
+
path = job_dir / Path(pr.download_url).name
|
| 285 |
+
img = Image.open(str(path))
|
| 286 |
+
if fmt == "JPEG" and img.mode != "RGB":
|
| 287 |
+
img = img.convert("RGB")
|
| 288 |
+
elif fmt == "PNG" and img.mode not in ("RGB", "RGBA", "L"):
|
| 289 |
+
img = img.convert("RGB")
|
| 290 |
+
images.append(img)
|
| 291 |
+
|
| 292 |
+
if not images:
|
| 293 |
+
raise ConversionError("No rendered page images found to stitch")
|
| 294 |
+
|
| 295 |
+
total_width = max(im.width for im in images)
|
| 296 |
+
total_height = sum(im.height for im in images)
|
| 297 |
+
|
| 298 |
+
mode = images[0].mode
|
| 299 |
+
stitched = Image.new(mode, (total_width, total_height), color=(255, 255, 255) if mode == "RGB" else 255)
|
| 300 |
+
|
| 301 |
+
y_offset = 0
|
| 302 |
+
for img in images:
|
| 303 |
+
stitched.paste(img, (0, y_offset))
|
| 304 |
+
y_offset += img.height
|
| 305 |
+
|
| 306 |
+
out_filename = f"stitched.{ext}"
|
| 307 |
+
out_path = job_dir / out_filename
|
| 308 |
+
|
| 309 |
+
save_kwargs: dict = {}
|
| 310 |
+
if fmt == "JPEG":
|
| 311 |
+
save_kwargs["quality"] = params.quality
|
| 312 |
+
save_kwargs["optimize"] = True
|
| 313 |
+
if fmt == "PNG":
|
| 314 |
+
save_kwargs["optimize"] = True
|
| 315 |
+
|
| 316 |
+
stitched.save(str(out_path), format=fmt, **save_kwargs)
|
| 317 |
+
|
| 318 |
+
stat = out_path.stat()
|
| 319 |
+
return PageResult(
|
| 320 |
+
page_number=1,
|
| 321 |
+
download_url=f"/api/v1/files/{job_dir.name}/{out_filename}",
|
| 322 |
+
width=stitched.width,
|
| 323 |
+
height=stitched.height,
|
| 324 |
+
size_bytes=stat.st_size,
|
| 325 |
+
format=fmt,
|
| 326 |
+
)
|
| 327 |
+
|
| 328 |
+
|
| 329 |
+
def _guard_memory(page_count: int, dpi: int) -> None:
|
| 330 |
+
pixels_per_page = (dpi * 8.5) * (dpi * 11)
|
| 331 |
+
bytes_per_page = pixels_per_page * 3
|
| 332 |
+
estimated_mb = (bytes_per_page * page_count) / (1024 * 1024)
|
| 333 |
+
if estimated_mb > _MAX_MEMORY_ESTIMATE_MB:
|
| 334 |
+
raise InvalidParameterError(
|
| 335 |
+
f"Requested conversion would require approximately {estimated_mb:.0f} MB of memory "
|
| 336 |
+
f"({page_count} pages at {dpi} DPI). "
|
| 337 |
+
f"Reduce DPI or use the 'pages' parameter to select fewer pages."
|
| 338 |
+
)
|
| 339 |
+
|
| 340 |
+
|
| 341 |
+
conversion_service = ConversionService()
|
app/services/file_service.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import httpx
|
| 4 |
+
import structlog
|
| 5 |
+
|
| 6 |
+
from app.core.config import settings
|
| 7 |
+
from app.core.exceptions import FileServiceError
|
| 8 |
+
from app.models.schemas import UploadedFileInfo
|
| 9 |
+
|
| 10 |
+
logger = structlog.get_logger(__name__)
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
async def upload_file_to_service(
|
| 14 |
+
file_bytes: bytes,
|
| 15 |
+
filename: str,
|
| 16 |
+
content_type: str = "application/octet-stream",
|
| 17 |
+
) -> UploadedFileInfo:
|
| 18 |
+
timeout = httpx.Timeout(settings.FILE_SERVICE_TIMEOUT, connect=settings.FILE_SERVICE_CONNECT_TIMEOUT)
|
| 19 |
+
async with httpx.AsyncClient(timeout=timeout) as client:
|
| 20 |
+
try:
|
| 21 |
+
response = await client.post(
|
| 22 |
+
settings.FILE_SERVICE_URL,
|
| 23 |
+
headers={
|
| 24 |
+
"X-API-Key": settings.FILE_SERVICE_API_KEY,
|
| 25 |
+
"Authorization": f"Bearer {settings.FILE_SERVICE_BEARER_TOKEN}"
|
| 26 |
+
},
|
| 27 |
+
files={"files": (filename, file_bytes, content_type)},
|
| 28 |
+
)
|
| 29 |
+
response.raise_for_status()
|
| 30 |
+
except httpx.HTTPStatusError as exc:
|
| 31 |
+
logger.error(
|
| 32 |
+
"file_service_http_error",
|
| 33 |
+
status_code=exc.response.status_code,
|
| 34 |
+
body=exc.response.text,
|
| 35 |
+
)
|
| 36 |
+
raise FileServiceError(
|
| 37 |
+
f"File service returned HTTP {exc.response.status_code}"
|
| 38 |
+
) from exc
|
| 39 |
+
except httpx.RequestError as exc:
|
| 40 |
+
logger.error("file_service_request_error", error=str(exc))
|
| 41 |
+
raise FileServiceError(f"File service unreachable: {exc}") from exc
|
| 42 |
+
|
| 43 |
+
payload = response.json()
|
| 44 |
+
uploaded = _extract_uploaded_file(payload, filename, len(file_bytes))
|
| 45 |
+
logger.info("file_uploaded", file_id=uploaded.file_id, filename=filename)
|
| 46 |
+
return uploaded
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def _extract_uploaded_file(
|
| 50 |
+
payload: object,
|
| 51 |
+
filename: str,
|
| 52 |
+
size_bytes: int,
|
| 53 |
+
) -> UploadedFileInfo:
|
| 54 |
+
if isinstance(payload, list):
|
| 55 |
+
if not payload:
|
| 56 |
+
raise FileServiceError("File service returned an empty list")
|
| 57 |
+
first = payload[0]
|
| 58 |
+
if not isinstance(first, dict):
|
| 59 |
+
raise FileServiceError(f"Unexpected item type in file service list: {type(first)}")
|
| 60 |
+
entry = first.get("data") or first
|
| 61 |
+
return _entry_to_info(entry, filename, size_bytes)
|
| 62 |
+
|
| 63 |
+
if isinstance(payload, dict):
|
| 64 |
+
nested = payload.get("files") or payload.get("data")
|
| 65 |
+
if isinstance(nested, list) and nested:
|
| 66 |
+
entry = nested[0].get("data") or nested[0]
|
| 67 |
+
return _entry_to_info(entry, filename, size_bytes)
|
| 68 |
+
return _entry_to_info(payload, filename, size_bytes)
|
| 69 |
+
|
| 70 |
+
raise FileServiceError(f"Unrecognised file service response type: {type(payload)}")
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def _entry_to_info(entry: dict, filename: str, size_bytes: int) -> UploadedFileInfo:
|
| 74 |
+
file_id = (
|
| 75 |
+
entry.get("fileid")
|
| 76 |
+
or entry.get("file_id")
|
| 77 |
+
or entry.get("id")
|
| 78 |
+
or entry.get("_id")
|
| 79 |
+
or ""
|
| 80 |
+
)
|
| 81 |
+
file_url = (
|
| 82 |
+
entry.get("file_url")
|
| 83 |
+
or entry.get("url")
|
| 84 |
+
or entry.get("path")
|
| 85 |
+
or ""
|
| 86 |
+
)
|
| 87 |
+
resolved_filename = (
|
| 88 |
+
entry.get("filename")
|
| 89 |
+
or entry.get("name")
|
| 90 |
+
or filename
|
| 91 |
+
)
|
| 92 |
+
resolved_size = int(entry.get("size") or entry.get("size_bytes") or size_bytes)
|
| 93 |
+
|
| 94 |
+
return UploadedFileInfo(
|
| 95 |
+
file_id=str(file_id),
|
| 96 |
+
file_url=str(file_url),
|
| 97 |
+
filename=str(resolved_filename),
|
| 98 |
+
size_bytes=resolved_size,
|
| 99 |
+
)
|
app/services/ping.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import asyncio
|
| 4 |
+
|
| 5 |
+
import httpx
|
| 6 |
+
import structlog
|
| 7 |
+
|
| 8 |
+
from app.core.config import settings
|
| 9 |
+
|
| 10 |
+
logger = structlog.get_logger(__name__)
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
async def start_self_ping() -> None:
|
| 14 |
+
if not settings.SELF_PING_ENABLED or not settings.SELF_PING_URL:
|
| 15 |
+
logger.info("self_ping_disabled")
|
| 16 |
+
return
|
| 17 |
+
asyncio.create_task(_ping_loop())
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
async def _ping_loop() -> None:
|
| 21 |
+
logger.info(
|
| 22 |
+
"self_ping_started",
|
| 23 |
+
url=settings.SELF_PING_URL,
|
| 24 |
+
interval=settings.SELF_PING_INTERVAL_SECONDS,
|
| 25 |
+
)
|
| 26 |
+
async with httpx.AsyncClient(timeout=10.0) as client:
|
| 27 |
+
while True:
|
| 28 |
+
await asyncio.sleep(settings.SELF_PING_INTERVAL_SECONDS)
|
| 29 |
+
await _execute_ping(client)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
async def _execute_ping(client: httpx.AsyncClient) -> None:
|
| 33 |
+
headers: dict[str, str] = {}
|
| 34 |
+
if settings.HF_TOKEN:
|
| 35 |
+
headers["Authorization"] = f"Bearer {settings.HF_TOKEN}"
|
| 36 |
+
|
| 37 |
+
try:
|
| 38 |
+
response = await client.get(settings.SELF_PING_URL, headers=headers)
|
| 39 |
+
logger.info("self_ping_success", status_code=response.status_code)
|
| 40 |
+
except httpx.RequestError as exc:
|
| 41 |
+
logger.warning("self_ping_failed", error=str(exc))
|
app/services/upload_orchestrator.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
"""
|
| 4 |
+
Upload orchestrator for PDF conversion results.
|
| 5 |
+
|
| 6 |
+
Workflow:
|
| 7 |
+
1. Convert PDF pages to JPEG or PNG images.
|
| 8 |
+
2. Upload each image to the File Upload Service concurrently.
|
| 9 |
+
3. Return clean ConversionResult with uploaded file URLs.
|
| 10 |
+
|
| 11 |
+
Only JPEG and PNG output formats are supported.
|
| 12 |
+
TIFF / merged-file mode has been removed.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
import asyncio
|
| 16 |
+
import time
|
| 17 |
+
from pathlib import Path
|
| 18 |
+
from typing import List, Optional
|
| 19 |
+
|
| 20 |
+
import structlog
|
| 21 |
+
|
| 22 |
+
from app.core.config import settings
|
| 23 |
+
from app.models.schemas import (
|
| 24 |
+
ConversionParams,
|
| 25 |
+
ConversionResult,
|
| 26 |
+
JobStatus,
|
| 27 |
+
PageResult,
|
| 28 |
+
UploadSummary,
|
| 29 |
+
UploadedFileInfo,
|
| 30 |
+
)
|
| 31 |
+
from app.services.conversion import conversion_service
|
| 32 |
+
from app.services.file_service import upload_file_to_service
|
| 33 |
+
|
| 34 |
+
logger = structlog.get_logger(__name__)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
async def convert_and_upload(
|
| 38 |
+
pdf_bytes: bytes,
|
| 39 |
+
params: ConversionParams,
|
| 40 |
+
job_id: str,
|
| 41 |
+
upload_mode: Optional[str] = None,
|
| 42 |
+
) -> ConversionResult:
|
| 43 |
+
"""
|
| 44 |
+
Full pipeline:
|
| 45 |
+
1. Render PDF pages to JPEG or PNG images.
|
| 46 |
+
2. Upload every image to the File Upload Service.
|
| 47 |
+
3. Return ConversionResult with file URLs per page.
|
| 48 |
+
"""
|
| 49 |
+
start = time.perf_counter()
|
| 50 |
+
|
| 51 |
+
pages, _conv_ms = await conversion_service.convert(pdf_bytes, params, job_id)
|
| 52 |
+
pages, upload_summary = await _upload_pages(pages, params, job_id)
|
| 53 |
+
|
| 54 |
+
duration_ms = round((time.perf_counter() - start) * 1000, 2)
|
| 55 |
+
|
| 56 |
+
return ConversionResult(
|
| 57 |
+
job_id=job_id,
|
| 58 |
+
status=JobStatus.COMPLETED,
|
| 59 |
+
total_pages=len(pages),
|
| 60 |
+
converted_pages=len(pages),
|
| 61 |
+
pages=pages,
|
| 62 |
+
duration_ms=duration_ms,
|
| 63 |
+
upload_summary=upload_summary,
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
async def _upload_pages(
|
| 68 |
+
pages: List[PageResult],
|
| 69 |
+
params: ConversionParams,
|
| 70 |
+
job_id: str,
|
| 71 |
+
) -> tuple[List[PageResult], UploadSummary]:
|
| 72 |
+
semaphore = asyncio.Semaphore(settings.UPLOAD_CONCURRENCY)
|
| 73 |
+
fmt = params.format.value if hasattr(params.format, "value") else str(params.format)
|
| 74 |
+
ext = "jpg" if fmt == "JPEG" else "png"
|
| 75 |
+
content_type = "image/jpeg" if fmt == "JPEG" else "image/png"
|
| 76 |
+
|
| 77 |
+
async def _upload_one(pr: PageResult) -> PageResult:
|
| 78 |
+
async with semaphore:
|
| 79 |
+
file_path = _resolve_local_path(pr.download_url)
|
| 80 |
+
if not file_path.exists():
|
| 81 |
+
pr.upload_error = f"Local file not found: {file_path}"
|
| 82 |
+
logger.error("upload_file_missing", page=pr.page_number, path=str(file_path))
|
| 83 |
+
return pr
|
| 84 |
+
try:
|
| 85 |
+
file_bytes = file_path.read_bytes()
|
| 86 |
+
if file_path.stem == "stitched":
|
| 87 |
+
filename = f"{job_id}_stitched.{ext}"
|
| 88 |
+
else:
|
| 89 |
+
filename = f"{job_id}_page_{pr.page_number:04d}.{ext}"
|
| 90 |
+
info = await upload_file_to_service(file_bytes, filename, content_type)
|
| 91 |
+
pr.file_id = info.file_id
|
| 92 |
+
pr.file_url = info.file_url
|
| 93 |
+
logger.debug("page_uploaded", job_id=job_id, page=pr.page_number, file_id=info.file_id)
|
| 94 |
+
except Exception as exc:
|
| 95 |
+
pr.upload_error = str(exc)
|
| 96 |
+
logger.error("page_upload_failed", job_id=job_id, page=pr.page_number, error=str(exc))
|
| 97 |
+
return pr
|
| 98 |
+
|
| 99 |
+
updated_pages = list(await asyncio.gather(*[_upload_one(pr) for pr in pages]))
|
| 100 |
+
|
| 101 |
+
succeeded = [p for p in updated_pages if p.file_id]
|
| 102 |
+
failed = [p for p in updated_pages if p.upload_error]
|
| 103 |
+
|
| 104 |
+
summary = UploadSummary(
|
| 105 |
+
upload_mode="per_page",
|
| 106 |
+
total_uploaded=len(succeeded),
|
| 107 |
+
failed_uploads=len(failed),
|
| 108 |
+
files=[
|
| 109 |
+
UploadedFileInfo(
|
| 110 |
+
file_id=p.file_id or "",
|
| 111 |
+
file_url=p.file_url or "",
|
| 112 |
+
filename=(
|
| 113 |
+
f"{job_id}_stitched.{ext}"
|
| 114 |
+
if Path(_resolve_local_path(p.download_url)).stem == "stitched"
|
| 115 |
+
else f"{job_id}_page_{p.page_number:04d}.{ext}"
|
| 116 |
+
),
|
| 117 |
+
size_bytes=p.size_bytes,
|
| 118 |
+
)
|
| 119 |
+
for p in succeeded
|
| 120 |
+
],
|
| 121 |
+
)
|
| 122 |
+
|
| 123 |
+
logger.info("upload_complete", job_id=job_id, uploaded=len(succeeded), failed=len(failed))
|
| 124 |
+
return updated_pages, summary
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def _resolve_local_path(download_url: str) -> Path:
|
| 128 |
+
parts = download_url.lstrip("/").split("/")
|
| 129 |
+
if len(parts) >= 5:
|
| 130 |
+
return Path(settings.OUTPUT_DIR) / parts[3] / parts[4]
|
| 131 |
+
raise ValueError(f"Cannot resolve local path from URL: {download_url}")
|
app/utils/__init__.py
ADDED
|
File without changes
|
app/utils/cleanup.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import asyncio
|
| 4 |
+
import shutil
|
| 5 |
+
import time
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
import structlog
|
| 9 |
+
|
| 10 |
+
from app.core.config import settings
|
| 11 |
+
|
| 12 |
+
logger = structlog.get_logger(__name__)
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
async def cleanup_loop() -> None:
|
| 16 |
+
output_dir = Path(settings.OUTPUT_DIR)
|
| 17 |
+
while True:
|
| 18 |
+
await asyncio.sleep(600)
|
| 19 |
+
await _sweep(output_dir)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
async def _sweep(output_dir: Path) -> None:
|
| 23 |
+
if not output_dir.exists():
|
| 24 |
+
return
|
| 25 |
+
|
| 26 |
+
now = time.time()
|
| 27 |
+
removed = 0
|
| 28 |
+
|
| 29 |
+
for job_dir in output_dir.iterdir():
|
| 30 |
+
if not job_dir.is_dir():
|
| 31 |
+
continue
|
| 32 |
+
if now - job_dir.stat().st_mtime > settings.OUTPUT_TTL_SECONDS:
|
| 33 |
+
try:
|
| 34 |
+
shutil.rmtree(job_dir)
|
| 35 |
+
removed += 1
|
| 36 |
+
except Exception:
|
| 37 |
+
logger.exception("cleanup_error", path=str(job_dir))
|
| 38 |
+
|
| 39 |
+
if removed:
|
| 40 |
+
logger.info("cleanup_complete", removed_jobs=removed)
|
app/utils/validators.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import io
|
| 4 |
+
import ipaddress
|
| 5 |
+
import socket
|
| 6 |
+
from urllib.parse import urlparse
|
| 7 |
+
|
| 8 |
+
import httpx
|
| 9 |
+
import structlog
|
| 10 |
+
from fastapi import UploadFile
|
| 11 |
+
|
| 12 |
+
from app.core.config import settings
|
| 13 |
+
from app.core.exceptions import FileTooLargeError, InvalidFileTypeError, InvalidParameterError
|
| 14 |
+
|
| 15 |
+
logger = structlog.get_logger(__name__)
|
| 16 |
+
|
| 17 |
+
_PDF_MAGIC = b"%PDF"
|
| 18 |
+
_MAX_URL_REDIRECTS = 5
|
| 19 |
+
_ALLOWED_SCHEMES = {"http", "https"}
|
| 20 |
+
_PRIVATE_RANGES = [
|
| 21 |
+
ipaddress.ip_network("10.0.0.0/8"),
|
| 22 |
+
ipaddress.ip_network("172.16.0.0/12"),
|
| 23 |
+
ipaddress.ip_network("192.168.0.0/16"),
|
| 24 |
+
ipaddress.ip_network("127.0.0.0/8"),
|
| 25 |
+
ipaddress.ip_network("169.254.0.0/16"),
|
| 26 |
+
ipaddress.ip_network("::1/128"),
|
| 27 |
+
ipaddress.ip_network("fc00::/7"),
|
| 28 |
+
]
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
async def read_and_validate_pdf(upload: UploadFile) -> bytes:
|
| 32 |
+
buffer = io.BytesIO()
|
| 33 |
+
total = 0
|
| 34 |
+
chunk_size = 64 * 1024
|
| 35 |
+
|
| 36 |
+
while chunk := await upload.read(chunk_size):
|
| 37 |
+
total += len(chunk)
|
| 38 |
+
if total > settings.MAX_FILE_SIZE_BYTES:
|
| 39 |
+
raise FileTooLargeError(settings.MAX_FILE_SIZE_MB)
|
| 40 |
+
buffer.write(chunk)
|
| 41 |
+
|
| 42 |
+
if total == 0:
|
| 43 |
+
raise InvalidFileTypeError("Uploaded file is empty")
|
| 44 |
+
|
| 45 |
+
pdf_bytes = buffer.getvalue()
|
| 46 |
+
_assert_pdf_magic(pdf_bytes)
|
| 47 |
+
logger.debug("pdf_upload_validated", size_bytes=total)
|
| 48 |
+
return pdf_bytes
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
async def fetch_pdf_from_url(url: str) -> bytes:
|
| 52 |
+
_validate_url_scheme(url)
|
| 53 |
+
_validate_url_host(url)
|
| 54 |
+
|
| 55 |
+
async with httpx.AsyncClient(
|
| 56 |
+
follow_redirects=True,
|
| 57 |
+
max_redirects=_MAX_URL_REDIRECTS,
|
| 58 |
+
timeout=httpx.Timeout(30.0, connect=10.0),
|
| 59 |
+
) as client:
|
| 60 |
+
try:
|
| 61 |
+
response = await client.get(url)
|
| 62 |
+
response.raise_for_status()
|
| 63 |
+
except httpx.HTTPStatusError as exc:
|
| 64 |
+
raise InvalidFileTypeError(
|
| 65 |
+
f"Failed to fetch PDF from URL: HTTP {exc.response.status_code}"
|
| 66 |
+
) from exc
|
| 67 |
+
except httpx.RequestError as exc:
|
| 68 |
+
raise InvalidFileTypeError(f"Failed to fetch PDF from URL: {exc}") from exc
|
| 69 |
+
|
| 70 |
+
_validate_url_host(str(response.url))
|
| 71 |
+
|
| 72 |
+
content_length = response.headers.get("content-length")
|
| 73 |
+
if content_length and int(content_length) > settings.MAX_FILE_SIZE_BYTES:
|
| 74 |
+
raise FileTooLargeError(settings.MAX_FILE_SIZE_MB)
|
| 75 |
+
|
| 76 |
+
pdf_bytes = b""
|
| 77 |
+
async for chunk in response.aiter_bytes(chunk_size=64 * 1024):
|
| 78 |
+
pdf_bytes += chunk
|
| 79 |
+
if len(pdf_bytes) > settings.MAX_FILE_SIZE_BYTES:
|
| 80 |
+
raise FileTooLargeError(settings.MAX_FILE_SIZE_MB)
|
| 81 |
+
|
| 82 |
+
if len(pdf_bytes) == 0:
|
| 83 |
+
raise InvalidFileTypeError("URL returned an empty response")
|
| 84 |
+
|
| 85 |
+
_assert_pdf_magic(pdf_bytes)
|
| 86 |
+
logger.debug("pdf_url_validated", url=url, size_bytes=len(pdf_bytes))
|
| 87 |
+
return pdf_bytes
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def _assert_pdf_magic(data: bytes) -> None:
|
| 91 |
+
if not data.startswith(_PDF_MAGIC):
|
| 92 |
+
raise InvalidFileTypeError(
|
| 93 |
+
"File does not appear to be a valid PDF (missing PDF magic bytes)"
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def _validate_url_scheme(url: str) -> None:
|
| 98 |
+
parsed = urlparse(url)
|
| 99 |
+
if parsed.scheme.lower() not in _ALLOWED_SCHEMES:
|
| 100 |
+
raise InvalidParameterError(
|
| 101 |
+
f"URL scheme '{parsed.scheme}' is not allowed. Only http and https are permitted."
|
| 102 |
+
)
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def _validate_url_host(url: str) -> None:
|
| 106 |
+
parsed = urlparse(url)
|
| 107 |
+
hostname = parsed.hostname
|
| 108 |
+
if not hostname:
|
| 109 |
+
raise InvalidParameterError("URL has no valid hostname")
|
| 110 |
+
|
| 111 |
+
try:
|
| 112 |
+
resolved_ip = socket.gethostbyname(hostname)
|
| 113 |
+
addr = ipaddress.ip_address(resolved_ip)
|
| 114 |
+
except (socket.gaierror, ValueError):
|
| 115 |
+
raise InvalidParameterError(f"Could not resolve hostname: {hostname}")
|
| 116 |
+
|
| 117 |
+
for private_range in _PRIVATE_RANGES:
|
| 118 |
+
if addr in private_range:
|
| 119 |
+
raise InvalidParameterError(
|
| 120 |
+
"Requests to private or loopback IP addresses are not permitted (SSRF protection)"
|
| 121 |
+
)
|
requirements.txt
CHANGED
|
@@ -23,3 +23,17 @@ spacy>=3.7.0
|
|
| 23 |
# CLI banner
|
| 24 |
pyfiglet>=1.0.2
|
| 25 |
rich>=13.0.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
# CLI banner
|
| 24 |
pyfiglet>=1.0.2
|
| 25 |
rich>=13.0.0
|
| 26 |
+
|
| 27 |
+
# PDF rendering and conversion
|
| 28 |
+
pdf2image>=1.17.0
|
| 29 |
+
pypdf>=5.1.0
|
| 30 |
+
|
| 31 |
+
# Structured logging and metrics
|
| 32 |
+
structlog>=24.4.0
|
| 33 |
+
prometheus-client>=0.21.1
|
| 34 |
+
|
| 35 |
+
# Pydantic settings
|
| 36 |
+
pydantic-settings>=2.7.1
|
| 37 |
+
|
| 38 |
+
# Rate limiting (optional — requires Redis)
|
| 39 |
+
redis[hiredis]>=6.4.0
|