diff --git a/api/src/api/v1/json_response.py b/api/src/api/v1/json_response.py new file mode 100644 index 0000000..9155765 --- /dev/null +++ b/api/src/api/v1/json_response.py @@ -0,0 +1,14 @@ +"""Ответ тяжёлых GET из готовых JSON-строк Postgres (AURORA-1848, design D3).""" +from fastapi import Response + + +def list_json_response(rows: list[str], headers: dict[str, str] | None = None) -> Response: + """Обёртка как у BaseListResponse при успехе; rows — уже сериализованные строки ответа.""" + body = b"".join(( + b'{"success":true,"message":null,"result":[', + ",".join(rows).encode(), + b'],"count":', + str(len(rows)).encode(), + b"}", + )) + return Response(content=body, media_type="application/json", headers=headers) diff --git a/api/src/repository/row_json.py b/api/src/repository/row_json.py new file mode 100644 index 0000000..27dd415 --- /dev/null +++ b/api/src/repository/row_json.py @@ -0,0 +1,17 @@ +"""Строка ответа тяжёлого GET как текст JSON из Postgres (AURORA-1848, design D1).""" +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession + +ROW_COLUMNS = "row_type, depth, sort_order, data" + + +def row_json_select(row_type: str = "row_type", depth: str = "depth") -> str: + """json (не jsonb) сохраняет порядок ключей; ::text обходит кодек json.loads у asyncpg.""" + return ( + f"SELECT json_build_object('row_type', {row_type}, 'depth', {depth}, " + f"'data', data, 'sort_order', sort_order)::text" + ) + + +async def fetch_row_texts(db: AsyncSession, sql: str, params: dict) -> list[str]: + return list((await db.execute(text(sql), params)).scalars().all()) diff --git a/api/tests/unit/test_json_list_response.py b/api/tests/unit/test_json_list_response.py new file mode 100644 index 0000000..bd2ff43 --- /dev/null +++ b/api/tests/unit/test_json_list_response.py @@ -0,0 +1,41 @@ +import json + +from src.api.v1.json_response import list_json_response +from src.repository.row_json import ROW_COLUMNS, row_json_select + + +def test_list_json_response_empty(): + r = list_json_response([]) + assert json.loads(r.body) == {"success": True, "message": None, "result": [], "count": 0} + + +def test_list_json_response_rows_order_count_escaping(): + rows = [ + '{"row_type":"META","depth":-1,"data":{"a":null},"sort_order":null}', + r'{"row_type": "INPUT", "depth": 3, "data": {"t": "Кавычка \" и \\ слэш\nперенос"}, "sort_order": 1}', + ] + body = json.loads(list_json_response(rows).body) + assert list(body) == ["success", "message", "result", "count"] + assert body["count"] == 2 + assert [row["row_type"] for row in body["result"]] == ["META", "INPUT"] + assert body["result"][0]["data"] == {"a": None} + assert body["result"][1]["data"]["t"] == 'Кавычка " и \\ слэш\nперенос' + + +def test_list_json_response_media_type_and_headers(): + r = list_json_response(["{}"], headers={"X-DB-Time-Ms": "1.50"}) + assert r.media_type == "application/json" + assert r.headers["content-type"] == "application/json" + assert r.headers["x-db-time-ms"] == "1.50" + assert r.headers["content-length"] == str(len(r.body)) + + +def test_row_json_select_key_order(): + assert ROW_COLUMNS == "row_type, depth, sort_order, data" + assert row_json_select() == ( + "SELECT json_build_object('row_type', row_type, 'depth', depth, " + "'data', data, 'sort_order', sort_order)::text" + ) + assert row_json_select(row_type="'INPUT'::VARCHAR", depth="3").startswith( + "SELECT json_build_object('row_type', 'INPUT'::VARCHAR, 'depth', 3, " + )