"""Dependency-free projection of the frozen Embedda integration contract.""" from __future__ import annotations import json import urllib.error import urllib.parse import urllib.request class EmbeddaHttpError(RuntimeError): def __init__(self, status: int, body: object, method: str, url: str) -> None: super().__init__(f"Embedda API {method} {url} failed with HTTP {status}") self.status = status self.body = body self.method = method self.url = url class EmbeddaClient: def __init__(self, base_url: str, token: str = "", *, event_token: str = "", timeout: float = 5.0) -> None: parsed = urllib.parse.urlsplit(base_url) if parsed.scheme not in ("http", "https") or not parsed.netloc or parsed.username or parsed.password: raise ValueError("base_url must be an HTTP(S) origin without credentials") self.base_url = f"{parsed.scheme}://{parsed.netloc}" self.token = token self.event_token = event_token self.timeout = timeout def health(self): return self._request("GET", "/healthz", api_prefix=False, auth=False) def login(self, username, password): return self._request("POST", "/auth/login", {"username": username, "password": password}, auth=False) def post_webhook(self, payload, event_token=None): return self._request("POST", "/events/gateway/webhook", payload, token=self.event_token if event_token is None else event_token) def event_gateway_status(self): return self._request("GET", "/events/gateway/status") def mappings(self): return self._request("GET", "/events/gateway/mappings") def replace_mappings(self, mappings): return self._request("PUT", "/events/gateway/mappings", mappings) def test_mapping(self, payload, profile_id=None): body = {"payload": payload} if profile_id: body["profile_id"] = profile_id return self._request("POST", "/events/gateway/mappings/test", body) def publish_mqtt(self, envelope, event_token=None): return self._request("POST", "/events/gateway/mqtt/publish", envelope, token=self.event_token if event_token is None else event_token) def mqtt_status(self): return self._request("GET", "/events/gateway/mqtt/status") def list_incidents(self, *, state=None, since=None): query = urllib.parse.urlencode({key: value for key, value in (("state", state), ("since", since)) if value is not None}) return self._request("GET", "/incidents" + (f"?{query}" if query else "")) def acknowledge_incident(self, incident_id, user_id=None): return self._request("POST", f"/admin/incidents/{urllib.parse.quote(incident_id, safe='')}/acknowledge", {"user_id": user_id} if user_id else None) def unacknowledge_incident(self, incident_id): return self._request("POST", f"/admin/incidents/{urllib.parse.quote(incident_id, safe='')}/unacknowledge") def resolve_incident(self, incident_id): return self._request("POST", f"/admin/incidents/{urllib.parse.quote(incident_id, safe='')}/resolve") def _request(self, method, path, body=None, *, token=None, auth=True, api_prefix=True): url = self.base_url + ("/api/v1" if api_prefix else "") + path headers = {"Accept": "application/json"} bearer = self.token if token is None else token if auth: if not bearer: raise ValueError(f"a bearer token is required for {method} {path}") headers["Authorization"] = f"Bearer {bearer}" data = None if body is not None: data = json.dumps(body, separators=(",", ":")).encode() headers["Content-Type"] = "application/json" request = urllib.request.Request(url, data=data, headers=headers, method=method) try: with urllib.request.urlopen(request, timeout=self.timeout) as response: raw = response.read() return json.loads(raw) if raw else None except urllib.error.HTTPError as error: try: raw = error.read() try: parsed = json.loads(raw) if raw else None except json.JSONDecodeError: parsed = raw.decode(errors="replace") raise EmbeddaHttpError(error.code, parsed, method, url) from error finally: error.close()