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,
|
|||
|
|
}
|