Skip to content

Commit b4d2dd6

Browse files
committed
feat(api): standardize endpoints with /v1/ prefix
- Add /v1/extract, /v1/extract/base64, /v1/extract/calibrated endpoints - Keep legacy /extract* endpoints as deprecated (6 month migration) - Add RequestIDMiddleware for X-Request-ID header support - Add APIResponse model for unified response format
1 parent b5f24b3 commit b4d2dd6

File tree

1 file changed

+117
-16
lines changed

1 file changed

+117
-16
lines changed

api/main.py

Lines changed: 117 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,16 @@
1313
from typing import Optional
1414
from contextlib import asynccontextmanager
1515

16+
import uuid
17+
1618
import numpy as np
1719
from PIL import Image
18-
from fastapi import FastAPI, File, UploadFile, HTTPException, Header, Depends
20+
from fastapi import FastAPI, File, UploadFile, HTTPException, Header, Depends, APIRouter
1921
from fastapi.middleware.cors import CORSMiddleware
2022
from fastapi.responses import JSONResponse
2123
from pydantic import BaseModel, Field
24+
from starlette.middleware.base import BaseHTTPMiddleware
25+
from starlette.requests import Request
2226

2327
# Rate limiting
2428
from 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

6981
class 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
117147
ALLOWED_ORIGINS = os.environ.get("ALLOWED_ORIGINS", "").split(",")
118148
if 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+
576677
if __name__ == "__main__":
577678
import uvicorn
578679
uvicorn.run(app, host="0.0.0.0", port=8000)

0 commit comments

Comments
 (0)