Skip to main content

flatland_cli/
api.rs

1use std::time::Duration;
2
3use serde::de::DeserializeOwned;
4use serde::Serialize;
5
6use crate::config::default_api_base;
7
8/// Fail fast so gfx onboarding (`block_on` on the UI thread) cannot spin forever
9/// when a remote host/port is unreachable (DNS typo, OCI NSG, etc.).
10const 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}