64 lines
2.5 KiB
Python
64 lines
2.5 KiB
Python
"""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
|
|
|