145 lines
4.4 KiB
Python
145 lines
4.4 KiB
Python
"""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,
|
||
}
|