flatland3 0.2.27

Flatland3 terminal play client and account CLI
Documentation
use std::time::Duration;

use serde::de::DeserializeOwned;
use serde::Serialize;

use crate::config::default_api_base;

/// Fail fast so gfx onboarding (`block_on` on the UI thread) cannot spin forever
/// when a remote host/port is unreachable (DNS typo, OCI NSG, etc.).
const HTTP_CONNECT_TIMEOUT: Duration = Duration::from_secs(8);
const HTTP_REQUEST_TIMEOUT: Duration = Duration::from_secs(20);

#[derive(Debug, serde::Deserialize)]
struct RawEnvelope {
    ok: bool,
    data: Option<serde_json::Value>,
    #[serde(default)]
    error: Option<ApiErrorBody>,
}

#[derive(Debug, serde::Deserialize)]
struct ApiErrorBody {
    #[allow(dead_code)]
    pub code: String,
    pub message: String,
}

pub struct ControlPlaneClient {
    base: String,
    http: reqwest::Client,
}

impl ControlPlaneClient {
    pub fn new(base: impl Into<String>) -> Self {
        let http = reqwest::Client::builder()
            .connect_timeout(HTTP_CONNECT_TIMEOUT)
            .timeout(HTTP_REQUEST_TIMEOUT)
            .build()
            .unwrap_or_else(|_| reqwest::Client::new());
        Self {
            base: base.into().trim_end_matches('/').to_string(),
            http,
        }
    }

    #[allow(dead_code)]
    pub fn from_env() -> Self {
        Self::new(default_api_base())
    }

    pub async fn post<T: DeserializeOwned, B: Serialize>(
        &self,
        path: &str,
        body: &B,
        bearer: Option<&str>,
    ) -> anyhow::Result<T> {
        let mut req = self.http.post(format!("{}{path}", self.base)).json(body);
        if let Some(token) = bearer {
            req = req.bearer_auth(token);
        }
        let response = req
            .send()
            .await
            .map_err(|err| map_http_err(&self.base, err))?;
        self.decode(response).await
    }

    pub async fn get<T: DeserializeOwned>(&self, path: &str, bearer: &str) -> anyhow::Result<T> {
        let response = self
            .http
            .get(format!("{}{path}", self.base))
            .bearer_auth(bearer)
            .send()
            .await
            .map_err(|err| map_http_err(&self.base, err))?;
        self.decode(response).await
    }

    pub async fn delete<T: DeserializeOwned>(&self, path: &str, bearer: &str) -> anyhow::Result<T> {
        let response = self
            .http
            .delete(format!("{}{path}", self.base))
            .bearer_auth(bearer)
            .send()
            .await
            .map_err(|err| map_http_err(&self.base, err))?;
        self.decode(response).await
    }

    async fn decode<T: DeserializeOwned>(&self, response: reqwest::Response) -> anyhow::Result<T> {
        let status = response.status();
        let bytes = response.bytes().await?;
        if bytes.is_empty() {
            anyhow::bail!(
                "{status} empty response from control plane — is flatland-control-plane up to date? (restart after pulling)"
            );
        }
        let envelope: RawEnvelope = serde_json::from_slice(&bytes).map_err(|err| {
            let preview = String::from_utf8_lossy(&bytes[..bytes.len().min(200)]);
            anyhow::anyhow!("{status} invalid JSON from control plane: {err} (body: {preview})")
        })?;
        if !envelope.ok {
            let message = envelope
                .error
                .map(|err| err.message)
                .unwrap_or_else(|| "request failed".into());
            anyhow::bail!("{status} {message}");
        }
        let data = envelope
            .data
            .ok_or_else(|| anyhow::anyhow!("{status} response missing data"))?;
        Ok(serde_json::from_value(data)?)
    }
}

fn map_http_err(base: &str, err: reqwest::Error) -> anyhow::Error {
    if err.is_timeout() || err.is_connect() {
        anyhow::anyhow!(
            "cannot reach control plane at {base} ({err}). \
             Check DNS, that :7380 is open in the OCI Security List/NSG, and that flatland-control-plane is running."
        )
    } else {
        err.into()
    }
}