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