1use std::time::Duration;
2
3use serde::de::DeserializeOwned;
4use serde::Serialize;
5
6use crate::config::default_api_base;
7
8const HTTP_CONNECT_TIMEOUT: Duration = Duration::from_secs(8);
11const HTTP_REQUEST_TIMEOUT: Duration = Duration::from_secs(20);
12
13#[derive(Debug, serde::Deserialize)]
14struct RawEnvelope {
15 ok: bool,
16 data: Option<serde_json::Value>,
17 #[serde(default)]
18 error: Option<ApiErrorBody>,
19}
20
21#[derive(Debug, serde::Deserialize)]
22struct ApiErrorBody {
23 #[allow(dead_code)]
24 pub code: String,
25 pub message: String,
26}
27
28pub struct ControlPlaneClient {
29 base: String,
30 http: reqwest::Client,
31}
32
33impl ControlPlaneClient {
34 pub fn new(base: impl Into<String>) -> Self {
35 let http = reqwest::Client::builder()
36 .connect_timeout(HTTP_CONNECT_TIMEOUT)
37 .timeout(HTTP_REQUEST_TIMEOUT)
38 .build()
39 .unwrap_or_else(|_| reqwest::Client::new());
40 Self {
41 base: base.into().trim_end_matches('/').to_string(),
42 http,
43 }
44 }
45
46 #[allow(dead_code)]
47 pub fn from_env() -> Self {
48 Self::new(default_api_base())
49 }
50
51 pub async fn post<T: DeserializeOwned, B: Serialize>(
52 &self,
53 path: &str,
54 body: &B,
55 bearer: Option<&str>,
56 ) -> anyhow::Result<T> {
57 let mut req = self.http.post(format!("{}{path}", self.base)).json(body);
58 if let Some(token) = bearer {
59 req = req.bearer_auth(token);
60 }
61 let response = req
62 .send()
63 .await
64 .map_err(|err| map_http_err(&self.base, err))?;
65 self.decode(response).await
66 }
67
68 pub async fn get<T: DeserializeOwned>(&self, path: &str, bearer: &str) -> anyhow::Result<T> {
69 let response = self
70 .http
71 .get(format!("{}{path}", self.base))
72 .bearer_auth(bearer)
73 .send()
74 .await
75 .map_err(|err| map_http_err(&self.base, err))?;
76 self.decode(response).await
77 }
78
79 pub async fn delete<T: DeserializeOwned>(&self, path: &str, bearer: &str) -> anyhow::Result<T> {
80 let response = self
81 .http
82 .delete(format!("{}{path}", self.base))
83 .bearer_auth(bearer)
84 .send()
85 .await
86 .map_err(|err| map_http_err(&self.base, err))?;
87 self.decode(response).await
88 }
89
90 async fn decode<T: DeserializeOwned>(&self, response: reqwest::Response) -> anyhow::Result<T> {
91 let status = response.status();
92 let bytes = response.bytes().await?;
93 if bytes.is_empty() {
94 anyhow::bail!(
95 "{status} empty response from control plane — is flatland-control-plane up to date? (restart after pulling)"
96 );
97 }
98 let envelope: RawEnvelope = serde_json::from_slice(&bytes).map_err(|err| {
99 let preview = String::from_utf8_lossy(&bytes[..bytes.len().min(200)]);
100 anyhow::anyhow!("{status} invalid JSON from control plane: {err} (body: {preview})")
101 })?;
102 if !envelope.ok {
103 let message = envelope
104 .error
105 .map(|err| err.message)
106 .unwrap_or_else(|| "request failed".into());
107 anyhow::bail!("{status} {message}");
108 }
109 let data = envelope
110 .data
111 .ok_or_else(|| anyhow::anyhow!("{status} response missing data"))?;
112 Ok(serde_json::from_value(data)?)
113 }
114}
115
116fn map_http_err(base: &str, err: reqwest::Error) -> anyhow::Error {
117 if err.is_timeout() || err.is_connect() {
118 anyhow::anyhow!(
119 "cannot reach control plane at {base} ({err}). \
120 Check DNS, that :7380 is open in the OCI Security List/NSG, and that flatland-control-plane is running."
121 )
122 } else {
123 err.into()
124 }
125}