1313from typing import Optional
1414from contextlib import asynccontextmanager
1515
16+ import uuid
17+
1618import numpy as np
1719from PIL import Image
18- from fastapi import FastAPI , File , UploadFile , HTTPException , Header , Depends
20+ from fastapi import FastAPI , File , UploadFile , HTTPException , Header , Depends , APIRouter
1921from fastapi .middleware .cors import CORSMiddleware
2022from fastapi .responses import JSONResponse
2123from pydantic import BaseModel , Field
24+ from starlette .middleware .base import BaseHTTPMiddleware
25+ from starlette .requests import Request
2226
2327# Rate limiting
2428from collections import defaultdict
@@ -64,6 +68,14 @@ class HealthResponse(BaseModel):
6468 uptime_seconds : float
6569
6670
71+ class APIResponse (BaseModel ):
72+ """Unified API response wrapper."""
73+ success : bool
74+ data : dict | None = None
75+ error : dict | None = None
76+ meta : dict | None = None
77+
78+
6779# --- Rate Limiting ---
6880
6981class RateLimiter :
@@ -113,6 +125,24 @@ async def lifespan(app: FastAPI):
113125 lifespan = lifespan
114126)
115127
128+ # API v1 router
129+ v1_router = APIRouter (prefix = "/v1" , tags = ["v1" ])
130+
131+
132+ # Request ID middleware
133+ class RequestIDMiddleware (BaseHTTPMiddleware ):
134+ """Add unique request ID to each request for tracking."""
135+
136+ async def dispatch (self , request : Request , call_next ):
137+ request_id = request .headers .get ("X-Request-ID" ) or str (uuid .uuid4 ())
138+ request .state .request_id = request_id
139+ response = await call_next (request )
140+ response .headers ["X-Request-ID" ] = request_id
141+ return response
142+
143+
144+ app .add_middleware (RequestIDMiddleware )
145+
116146# CORS - Configure allowed origins from environment
117147ALLOWED_ORIGINS = os .environ .get ("ALLOWED_ORIGINS" , "" ).split ("," )
118148if not ALLOWED_ORIGINS or ALLOWED_ORIGINS == ["" ]:
@@ -342,8 +372,8 @@ async def health():
342372 )
343373
344374
345- @app .post ("/extract" , response_model = ExtractionResult )
346- async def extract_data (
375+ @v1_router .post ("/extract" , response_model = ExtractionResult )
376+ async def extract_data_v1 (
347377 file : UploadFile = File (..., description = "Chart image (PNG, JPG, WebP)" ),
348378 mode : str = "llm" ,
349379 chart_type : Optional [str ] = None ,
@@ -375,7 +405,33 @@ async def extract_data(
375405 - `csv`: CSV string
376406 - `confidence`: Extraction confidence (0-1)
377407 """
408+ return await _extract_data_impl (file , mode , chart_type , x_scale , y_scale , client_ip )
409+
378410
411+ # Legacy endpoint with deprecation warning
412+ @app .post ("/extract" , response_model = ExtractionResult , deprecated = True , tags = ["Legacy" ])
413+ async def extract_data_legacy (
414+ file : UploadFile = File (..., description = "Chart image (PNG, JPG, WebP)" ),
415+ mode : str = "llm" ,
416+ chart_type : Optional [str ] = None ,
417+ x_scale : str = "linear" ,
418+ y_scale : str = "linear" ,
419+ client_ip : str = Depends (get_client_ip )
420+ ):
421+ """[DEPRECATED] Use /v1/extract instead. This endpoint will be removed in 6 months."""
422+ logger .warning ("Deprecated endpoint /extract called. Use /v1/extract instead." )
423+ return await _extract_data_impl (file , mode , chart_type , x_scale , y_scale , client_ip )
424+
425+
426+ async def _extract_data_impl (
427+ file : UploadFile ,
428+ mode : str ,
429+ chart_type : Optional [str ],
430+ x_scale : str ,
431+ y_scale : str ,
432+ client_ip : str
433+ ):
434+ """Shared implementation for extract endpoints."""
379435 # Rate limiting
380436 if not rate_limiter .is_allowed (client_ip ):
381437 raise HTTPException (
@@ -423,8 +479,8 @@ async def extract_data(
423479 )
424480
425481
426- @app .post ("/extract/base64" , response_model = ExtractionResult )
427- async def extract_data_base64 (
482+ @v1_router .post ("/extract/base64" , response_model = ExtractionResult )
483+ async def extract_data_base64_v1 (
428484 image_base64 : str ,
429485 mode : str = "llm" ,
430486 chart_type : Optional [str ] = None ,
@@ -436,7 +492,7 @@ async def extract_data_base64(
436492 """
437493 Extract data from a base64-encoded chart image.
438494
439- Same as /extract but accepts base64 string instead of file upload.
495+ Same as /v1/ extract but accepts base64 string instead of file upload.
440496
441497 **Parameters:**
442498 - `image_base64`: Base64-encoded image (with or without data URI prefix)
@@ -446,9 +502,37 @@ async def extract_data_base64(
446502 - `use_mistral`: Use Mistral OCR for CV mode
447503
448504 **Returns:**
449- - Same as /extract endpoint
505+ - Same as /v1/ extract endpoint
450506 """
507+ return await _extract_base64_impl (image_base64 , mode , chart_type , x_scale , y_scale , use_mistral , client_ip )
508+
509+
510+ # Legacy base64 endpoint
511+ @app .post ("/extract/base64" , response_model = ExtractionResult , deprecated = True , tags = ["Legacy" ])
512+ async def extract_data_base64_legacy (
513+ image_base64 : str ,
514+ mode : str = "llm" ,
515+ chart_type : Optional [str ] = None ,
516+ x_scale : str = "linear" ,
517+ y_scale : str = "linear" ,
518+ use_mistral : bool = True ,
519+ client_ip : str = Depends (get_client_ip )
520+ ):
521+ """[DEPRECATED] Use /v1/extract/base64 instead. This endpoint will be removed in 6 months."""
522+ logger .warning ("Deprecated endpoint /extract/base64 called. Use /v1/extract/base64 instead." )
523+ return await _extract_base64_impl (image_base64 , mode , chart_type , x_scale , y_scale , use_mistral , client_ip )
524+
451525
526+ async def _extract_base64_impl (
527+ image_base64 : str ,
528+ mode : str ,
529+ chart_type : Optional [str ],
530+ x_scale : str ,
531+ y_scale : str ,
532+ use_mistral : bool ,
533+ client_ip : str
534+ ):
535+ """Shared implementation for base64 extract endpoints."""
452536 # Rate limiting
453537 if not rate_limiter .is_allowed (client_ip ):
454538 raise HTTPException (
@@ -491,8 +575,8 @@ async def extract_data_base64(
491575 )
492576
493577
494- @app .post ("/extract/calibrated" , response_model = ExtractionResult )
495- async def extract_calibrated (
578+ @v1_router .post ("/extract/calibrated" , response_model = ExtractionResult )
579+ async def extract_calibrated_v1 (
496580 file : UploadFile = File (..., description = "Chart image" ),
497581 calibration_json : str = None ,
498582 client_ip : str = Depends (get_client_ip )
@@ -520,15 +604,28 @@ async def extract_calibrated(
520604 ```
521605
522606 Provide at least 2 points per axis for linear interpolation.
523-
524- **Example curl:**
525- ```bash
526- curl -X POST "http://localhost:8000/extract/calibrated" \
527- 528- -F 'calibration_json={"x_axis":[{"pixel":100,"value":0},{"pixel":500,"value":20}],"y_axis":[{"pixel":350,"value":0},{"pixel":50,"value":30}]}'
529- ```
530607 """
608+ return await _extract_calibrated_impl (file , calibration_json , client_ip )
609+
531610
611+ # Legacy calibrated endpoint
612+ @app .post ("/extract/calibrated" , response_model = ExtractionResult , deprecated = True , tags = ["Legacy" ])
613+ async def extract_calibrated_legacy (
614+ file : UploadFile = File (..., description = "Chart image" ),
615+ calibration_json : str = None ,
616+ client_ip : str = Depends (get_client_ip )
617+ ):
618+ """[DEPRECATED] Use /v1/extract/calibrated instead. This endpoint will be removed in 6 months."""
619+ logger .warning ("Deprecated endpoint /extract/calibrated called. Use /v1/extract/calibrated instead." )
620+ return await _extract_calibrated_impl (file , calibration_json , client_ip )
621+
622+
623+ async def _extract_calibrated_impl (
624+ file : UploadFile ,
625+ calibration_json : str ,
626+ client_ip : str
627+ ):
628+ """Shared implementation for calibrated extract endpoints."""
532629 if not rate_limiter .is_allowed (client_ip ):
533630 raise HTTPException (status_code = 429 , detail = "Rate limit exceeded. Max 20 requests per minute." )
534631
@@ -573,6 +670,10 @@ async def extract_calibrated(
573670 )
574671
575672
673+ # Register v1 router
674+ app .include_router (v1_router )
675+
676+
576677if __name__ == "__main__" :
577678 import uvicorn
578679 uvicorn .run (app , host = "0.0.0.0" , port = 8000 )
0 commit comments