atulyamann/flexup-api
0
1"""2FlexUp Metrics API3Exposes the stitched.py processing logic as a secure REST API.4"""5 6import os7import io8import uuid9import secrets10import logging11from pathlib import Path12 13from fastapi import FastAPI, UploadFile, File, HTTPException, Depends, status14from fastapi.security import APIKeyHeader15from fastapi.responses import StreamingResponse, FileResponse, Response16from fastapi.staticfiles import StaticFiles17import pandas as pd18 19# In-memory store for processed files {token: bytes}20# Keeps the most recent results (max 20) — older ones evicted as new ones come in21from collections import OrderedDict22_result_store: "OrderedDict[str, bytes]" = OrderedDict()23_MAX_RESULTS = 2024 25from core import run_pipeline # business logic extracted from stitched.py26 27# ---------------------------------------------------------------------------28# Logging29# ---------------------------------------------------------------------------30logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")31logger = logging.getLogger(__name__)32 33# ---------------------------------------------------------------------------34# API-key auth35# ---------------------------------------------------------------------------36API_KEY_NAME = "X-API-Key"37_api_key_header = APIKeyHeader(name=API_KEY_NAME, auto_error=False)38 39def _get_api_key() -> str:40 """Load the expected API key from the environment (never hard-coded)."""41 key = os.environ.get("FLEXUP_API_KEY", "")42 if not key:43 raise RuntimeError("FLEXUP_API_KEY environment variable is not set.")44 return key45 46async def verify_api_key(api_key: str = Depends(_api_key_header)) -> str:47 """Dependency that validates the caller's API key."""48 expected = _get_api_key()49 if not api_key or not secrets.compare_digest(api_key, expected):50 raise HTTPException(51 status_code=status.HTTP_401_UNAUTHORIZED,52 detail="Invalid or missing API key.",53 headers={"WWW-Authenticate": "ApiKey"},54 )55 return api_key56 57# ---------------------------------------------------------------------------58# App59# ---------------------------------------------------------------------------60app = FastAPI(61 title="FlexUp Metrics API",62 description="Upload an Excel workbook and receive the processed site-level metrics.",63 version="1.0.0",64 docs_url="/docs",65 redoc_url="/redoc",66)67 68# Serve the UI69app.mount("/static", StaticFiles(directory="static"), name="static")70 71@app.get("/", include_in_schema=False)72def root():73 return FileResponse("static/index.html")74 75# ---------------------------------------------------------------------------76# Routes77# ---------------------------------------------------------------------------78 79@app.get("/health", tags=["ops"])80def health():81 """Liveness probe — no auth required."""82 return {"status": "ok"}83 84 85@app.post(86 "/process",87 tags=["metrics"],88 summary="Process an Excel workbook and return the metrics file directly.",89 response_description="Excel file with site-level metrics (SiteLevel(2) sheet).",90)91async def process_workbook(92 file: UploadFile = File(..., description="Excel workbook (.xlsx) with the required sheets."),93 _: str = Depends(verify_api_key),94):95 """96 Upload the source Excel workbook and receive the processed metrics workbook.97 Returns an `.xlsx` file as a binary download — best for Python scripts and curl.98 99 For the UI, use `/upload` instead which returns a token.100 """101 output_bytes = await _run_pipeline_for_upload(file)102 return StreamingResponse(103 io.BytesIO(output_bytes),104 media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",105 headers={"Content-Disposition": 'attachment; filename="flexup_metrics.xlsx"'},106 )107 108 109@app.post(110 "/upload",111 tags=["metrics"],112 summary="Process an Excel workbook and return a download token.",113)114async def upload_workbook(115 file: UploadFile = File(...),116 _: str = Depends(verify_api_key),117):118 """Same as /process but returns a token instead of the file. Used by the web UI."""119 output_bytes = await _run_pipeline_for_upload(file)120 token = str(uuid.uuid4())121 _result_store[token] = output_bytes122 while len(_result_store) > _MAX_RESULTS:123 _result_store.popitem(last=False)124 return {"token": token}125 126 127async def _run_pipeline_for_upload(file: UploadFile) -> bytes:128 """Validate the upload and run the pipeline. Returns the output bytes."""129 if not file.filename or not file.filename.lower().endswith(".xlsx"):130 raise HTTPException(131 status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,132 detail="Only .xlsx files are accepted.",133 )134 135 contents = await file.read()136 if len(contents) == 0:137 raise HTTPException(138 status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,139 detail="Uploaded file is empty.",140 )141 142 MAX_UPLOAD_BYTES = 50 * 1024 * 1024143 if len(contents) > MAX_UPLOAD_BYTES:144 raise HTTPException(145 status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,146 detail="File exceeds the 50 MB limit.",147 )148 149 logger.info("Received workbook '%s' (%d bytes)", file.filename, len(contents))150 151 try:152 return run_pipeline(io.BytesIO(contents))153 except ValueError as exc:154 raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc))155 except Exception as exc:156 logger.exception("Pipeline error")157 raise HTTPException(158 status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,159 detail=f"Processing failed: {exc}",160 )161 162 163@app.get("/download/{token}", tags=["metrics"])164def download_result(token: str):165 """Retrieve a processed file by its token."""166 data = _result_store.get(token)167 if data is None:168 raise HTTPException(status_code=404, detail="File not found or expired.")169 return StreamingResponse(170 io.BytesIO(data),171 media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",172 headers={173 "Content-Disposition": 'attachment; filename="flexup_metrics.xlsx"',174 "Content-Length": str(len(data)),175 },176 )177 