meraproject/services/user-reader/app/batch_api.py
keboss-m 5c21d25d45 Initial commit: Merakomis portal, Docker stack and user-reader API.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 11:04:05 +03:00

145 lines
4.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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