from collections.abc import Awaitable, Callable from uuid import UUID from fastapi import APIRouter, Depends, Header, HTTPException, Request, Security from fastapi.routing import APIRoute from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from starlette.responses import JSONResponse from ai_platform.lead_scoring.application.run_use_cases import ( ScoringRunConflictError, ScoringUseCases, ) from ai_platform.platform.application.app_credentials import AppCredentialUseCases from ai_platform.presentation.http.run_mapping import score_json from ai_platform.presentation.http.run_responses import ScoreResponse, ScoringError from ai_platform.presentation.http.schemas import RunRequest _MAX_RUN_BODY_BYTES = 1_000_000 class BoundedRunRoute(APIRoute): def get_route_handler(self): handler = super().get_route_handler() async def bounded(request: Request): if request.method == "POST" and request.url.path == "/v1/lead-scoring/score": body = bytearray() async for chunk in request.stream(): body.extend(chunk) if len(body) > _MAX_RUN_BODY_BYTES: return JSONResponse({"detail": "Request body too large"}, status_code=413) request._body = bytes(body) return await handler(request) return bounded def create_scoring_router( get_app_credentials: Callable[..., Awaitable[AppCredentialUseCases]], get_scoring_use_cases: Callable[..., Awaitable[ScoringUseCases]], get_database_readiness: Callable[..., Awaitable[bool]], ) -> APIRouter: router = APIRouter(route_class=BoundedRunRoute) async def authenticated_app( credentials: HTTPAuthorizationCredentials | None = Security(HTTPBearer(auto_error=False)), use_cases: AppCredentialUseCases = Depends(get_app_credentials), ) -> str: if credentials is None: raise HTTPException(401, "App authentication required") app_id = await use_cases.authenticate(credentials.credentials) if app_id is None: raise HTTPException(401, "Invalid app key") return app_id @router.get("/health/live") async def live() -> dict[str, str]: return {"status": "ok"} @router.get("/health/ready") async def ready(available: bool = Depends(get_database_readiness)) -> dict[str, str]: if not available: raise HTTPException(503, "Database unavailable") return {"status": "ready"} @router.post( "/v1/lead-scoring/score", response_model=ScoreResponse, responses={ 401: { "model": ScoringError, "description": "Missing, invalid or revoked app credential", }, 413: {"model": ScoringError, "description": "Request body exceeds size limit"}, 422: { "model": ScoringError, "description": "Invalid input or AI key, or idempotency key reused with another body", }, 503: {"model": ScoringError, "description": "Database unavailable"}, }, ) async def score( body: RunRequest, app_id: str = Depends(authenticated_app), ai_key: str | None = Header(default=None, alias="X-AI-Key"), idempotency_key: UUID = Header(alias="Idempotency-Key"), use_cases: ScoringUseCases = Depends(get_scoring_use_cases), ): if ai_key is not None and (len(ai_key) > 4_096 or "\r" in ai_key or "\n" in ai_key): raise HTTPException(422, "X-AI-Key is invalid") request = body.model_dump(mode="json") try: run = await use_cases.score(app_id, str(idempotency_key), request, ai_key) except ScoringRunConflictError as exc: raise HTTPException(422, "Idempotency-Key already used with a different body") from exc return score_json(run.id, run.request, run.result) return router