"""Bounded JSON requests; redirects cannot forward upload credentials.""" import json from http.client import HTTPException from urllib.error import HTTPError, URLError from urllib.request import HTTPRedirectHandler, ProxyHandler, Request, build_opener from . import __version__ class TransportError(Exception): pass class NoRedirects(HTTPRedirectHandler): def redirect_request(self, request, response, code, message, headers, new_url): return None def _invalid_constant(value): raise ValueError("Non-finite JSON number") class JsonHttp: MAX_RESPONSE_BYTES = 2 * 1024 * 1024 def __init__(self, timeout: float, *, local: bool = False): handlers = [NoRedirects()] if local: # LAN traffic must not go through a workstation's HTTP(S) proxy. handlers.append(ProxyHandler({})) self.opener = build_opener(*handlers) self.timeout = timeout def request(self, url: str, *, payload: dict | None = None, token: str | None = None, extra_headers=None) -> dict: headers = {"Accept": "application/json", "User-Agent": f"solar-relay/{__version__}"} headers.update(extra_headers or {}) body = None if payload is not None: body = json.dumps(payload, allow_nan=False, separators=(",", ":")).encode("utf-8") headers["Content-Type"] = "application/json" if token: headers["Authorization"] = f"Bearer {token}" request = Request(url, data=body, headers=headers, method="POST" if body is not None else "GET") try: with self.opener.open(request, timeout=self.timeout) as response: raw = response.read(self.MAX_RESPONSE_BYTES + 1) except HTTPError as error: # Do not log server bodies: they can echo request headers or credentials. error.close() raise TransportError(f"HTTP {error.code}") from None except (URLError, OSError, TimeoutError, HTTPException) as error: raise TransportError(f"Network request failed ({type(error).__name__})") from None if len(raw) > self.MAX_RESPONSE_BYTES: raise TransportError("JSON response exceeds 2 MiB") try: value = json.loads(raw, parse_constant=_invalid_constant) except (ValueError, UnicodeError, RecursionError): raise TransportError("Response is not valid JSON") from None if not isinstance(value, dict): raise TransportError("Expected a JSON object") return value