"""JSON helpers for kaya HTTP handlers. Kaya has no built-in request/response JSON helpers: the request body is an async byte stream on ``ctx.request_body`` and responses are sent with ``ctx.send_*``. These wrappers handle the boilerplate of draining the body, parsing JSON, and sending JSON responses. """ from __future__ import annotations import json from typing import Any, List, Mapping from kaya.core import HttpContext JSON_HEADERS = {"content-type": ("application/json",)} class JsonRequestError(ValueError): """Raised by :func:`read_json` when the request body is not valid JSON or is not a JSON object.""" def extract_query_params(query_string: str) -> Mapping[str, List[str]]: """Parse a raw query string into a mapping of param name to list of values. Wraps :func:`urllib.parse.parse_qs` so callers don't repeat the incantation; always returns a mapping (never None). """ from urllib.parse import parse_qs return parse_qs(query_string, keep_blank_values=True) async def read_json(ctx: HttpContext) -> dict: """Drain and parse the request body as JSON. Returns the parsed ``dict`` on success. Raises :class:`JsonRequestError` with a short human-readable reason on failure. """ body = b"" async for chunk in ctx.request_body: body += chunk if not body: raise JsonRequestError("empty body") try: parsed = json.loads(body.decode("utf-8")) except (UnicodeDecodeError, json.JSONDecodeError) as exc: raise JsonRequestError("invalid JSON") from exc if not isinstance(parsed, dict): raise JsonRequestError("JSON body must be an object") return parsed async def read_json_optional(ctx: HttpContext) -> dict: """Like :func:`read_json` but treats an empty body as ``{}``.""" try: return await read_json(ctx) except JsonRequestError as exc: if str(exc) == "empty body": return {} raise async def send_json(ctx: HttpContext, status: int, payload: Any) -> None: """Send ``payload`` as a JSON response.""" body = json.dumps(payload).encode("utf-8") await ctx.send_bytes(status, body, JSON_HEADERS) async def send_error(ctx: HttpContext, status: int, message: str) -> None: """Send a JSON error envelope.""" await send_json(ctx, status, {"error": message})