"""POST /api/batch — несколько GET-запросов за один HTTP round-trip.""" from __future__ import annotations import json from typing import Annotated, Any, Literal from urllib.parse import urlparse from fastapi import APIRouter, Depends, Header, HTTPException, Request from pydantic import BaseModel, Field from starlette.testclient import TestClient from app.main import require_api_key router = APIRouter() MAX_BATCH_SIZE = 25 _ALLOWED_METHODS = frozenset({"GET"}) class BatchSubRequest(BaseModel): id: str | None = Field(None, max_length=64) method: Literal["GET"] = "GET" path: str = Field(..., min_length=1, max_length=2048) class BatchRequest(BaseModel): requests: list[BatchSubRequest] = Field(..., min_length=1, max_length=MAX_BATCH_SIZE) def _validate_batch_path(path: str) -> str: raw = path.strip() if not raw.startswith("/"): raw = "/" + raw parsed = urlparse(raw) if parsed.scheme or parsed.netloc: raise HTTPException( status_code=400, detail={ "code": "invalid_path", "message": "path должен быть относительным, без схемы и хоста", }, ) if not parsed.path.startswith("/api/"): raise HTTPException( status_code=400, detail={ "code": "invalid_path", "message": "path должен начинаться с /api/", }, ) if parsed.path.rstrip("/") == "/api/batch": raise HTTPException( status_code=400, detail={"code": "invalid_path", "message": "Рекурсивный /api/batch запрещён"}, ) out = parsed.path if parsed.query: out += "?" + parsed.query return out def _forward_headers( request: Request, x_api_key: str | None, authorization: str | None, ) -> dict[str, str]: headers: dict[str, str] = {} if x_api_key: headers["X-Api-Key"] = x_api_key if authorization: headers["Authorization"] = authorization acting = request.headers.get("X-Acting-Emp-Id") if acting: headers["X-Acting-Emp-Id"] = acting return headers @router.post("/api/batch") def api_batch( body: BatchRequest, request: Request, _auth: Annotated[None, Depends(require_api_key)], x_api_key: Annotated[str | None, Header(alias="X-Api-Key")] = None, authorization: Annotated[str | None, Header()] = None, ) -> dict[str, Any]: """ Выполняет до 25 подзапросов GET к /api/* за один HTTP-вызов. Заголовки X-Api-Key и X-Acting-Emp-Id пробрасываются в каждый подзапрос. """ from app.main import app fwd = _forward_headers(request, x_api_key, authorization) results: list[dict[str, Any]] = [] with TestClient(app, raise_server_exceptions=False) as client: for sub in body.requests: if sub.method not in _ALLOWED_METHODS: results.append( { "id": sub.id, "status": 400, "ok": False, "body": { "code": "method_not_allowed", "message": f"Разрешён только GET, получен {sub.method}", }, } ) continue try: path = _validate_batch_path(sub.path) except HTTPException as e: results.append( { "id": sub.id, "status": e.status_code, "ok": False, "body": e.detail, } ) continue response = client.get(path, headers=fwd) try: resp_body: Any = response.json() except (json.JSONDecodeError, ValueError): resp_body = response.text results.append( { "id": sub.id, "status": response.status_code, "ok": 200 <= response.status_code < 300, "path": path, "body": resp_body, } ) return { "ok": all(r["ok"] for r in results), "count": len(results), "results": results, }