flatland3 0.2.1

Flatland3 terminal play client and account CLI
use serde::de::DeserializeOwned;
use serde::Serialize;

use crate::config::default_api_base;

#[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 {
        Self {
            base: base.into().trim_end_matches('/').to_string(),
            http: reqwest::Client::new(),
        }
    }

    #[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?;
        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?;
        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?;
        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)?)
    }
}