1"""Thin HTTP client for the Plain Cloud API.
2
3Wraps httpx with the bearer token from credentials and surfaces non-2xx
4responses as `APIError` with the server's message when available.
5"""
6
7from __future__ import annotations
8
9from typing import Any
10
11import click
12import httpx
13
14from .credentials import Credentials
15
16
17class APIError(click.ClickException):
18 """Non-2xx response from the API. Click prints the message in red and exits 1."""
19
20 exit_code = 1
21
22 def __init__(self, status_code: int, message: str) -> None:
23 super().__init__(message)
24 self.status_code = status_code
25
26 def show(self, file: Any = None) -> None:
27 click.secho(self.message, fg="red", err=True)
28
29
30class Client:
31 def __init__(
32 self,
33 creds: Credentials,
34 *,
35 transport: httpx.BaseTransport | None = None, # test seam for MockTransport
36 ) -> None:
37 self._client = httpx.Client(
38 base_url=creds.api_url.rstrip("/") + "/api",
39 headers={
40 "Authorization": f"Bearer {creds.token}",
41 "Accept": "application/json",
42 },
43 timeout=httpx.Timeout(30.0),
44 transport=transport,
45 )
46
47 def __enter__(self) -> Client:
48 return self
49
50 def __exit__(self, *exc: Any) -> None:
51 self._client.close()
52
53 def get(self, path: str, **kwargs: Any) -> Any:
54 return self._request("GET", path, **kwargs)
55
56 def raw_request(self, method: str, path: str, **kwargs: Any) -> httpx.Response:
57 """Send a request and return the raw response without raising on
58 non-2xx. Used by the `api` escape-hatch subcommand so the user
59 sees the actual response body and status code from the server.
60 """
61 if not path.startswith("/"):
62 path = "/" + path
63 return self._client.request(method, path, **kwargs)
64
65 def _request(self, method: str, path: str, **kwargs: Any) -> Any:
66 response = self.raw_request(method, path, **kwargs)
67 if response.status_code >= 400:
68 try:
69 payload = response.json()
70 message = (
71 payload.get("error") or payload.get("message") or response.text
72 )
73 except ValueError:
74 message = response.text or response.reason_phrase
75 raise APIError(response.status_code, message)
76 if not response.content:
77 return None
78 return response.json()