validops-east-1 commited on
Commit
936f0bf
·
1 Parent(s): 0b750b6

feat: updated

Browse files
.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
- # MarkItDown API — Production Dockerfile
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="MarkItDown API"
11
- LABEL description="Document-to-Markdown API with Microsoft MarkItDown and RapidOCR"
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 base dependencies ──────────────────────────────────
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 necessary directories with proper permissions ───────
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/health')" || exit 1
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
- This module defines the FastAPI application, all request/response models,
5
- route handlers, and the application lifespan. There is no browser-facing UI;
6
- the application is a pure REST API intended for programmatic consumption.
7
-
8
- Routes
9
- ------
10
- POST /convert/file Convert an uploaded file to Markdown.
11
- POST /convert/url Convert a public URL to Markdown.
12
- POST /batch/files Convert up to 10 files in a single request.
13
- POST /batch/urls Convert up to 20 URLs in a single request.
14
- GET /health Liveness check returning uptime and version.
15
- GET /info Server metadata (version, platform, limits).
16
- GET /formats Supported file extensions grouped by category.
17
- GET /spacy-labels Available spaCy NER labels for field extraction.
 
 
 
 
 
 
 
 
 
 
 
 
 
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 uvicorn
37
- from fastapi import FastAPI, File, Form, HTTPException, UploadFile, status
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 # 30 minutes
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 | started_at=%s",
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
- "Document-to-Markdown conversion API powered by Microsoft MarkItDown "
121
- "and RapidOCR. Accepts file uploads and public URLs; returns structured "
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
- # Request / Response models
146
  # ---------------------------------------------------------------------------
147
 
148
- class ConversionMetadata(BaseModel):
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
- # System endpoints
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 {".html", ".htm"}],
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
- @app.get("/spacy-labels", tags=["System"], summary="Available spaCy NER labels for field extraction")
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=["Convert"],
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
- @app.post(
462
  "/convert/url",
463
  response_model=ConversionResponse,
464
- tags=["Convert"],
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
- "convert_url | success | url=%s | chars=%d | time_ms=%.1f",
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
- "convert_url | success | url=%s | chars=%d | time_ms=%.1f",
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
- @app.post(
541
  "/batch/files",
542
  response_model=BatchResponse,
543
- tags=["Batch"],
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
- _thread_pool, _converter.convert_stream, raw, f.filename or "upload"
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
- @app.post(
607
  "/batch/urls",
608
  response_model=BatchResponse,
609
- tags=["Batch"],
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
- # Server runner (used when invoking this module directly)
 
 
 
 
 
 
 
 
 
 
 
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