cinchdb 0.2.4

CLI for CinchDB - database and scope management
//! HTTP client for the CinchDB control plane API

use crate::config::ConfigFile;
use anyhow::{Context, Result};
use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, USER_AGENT};
use serde::de::DeserializeOwned;
use serde::Serialize;

/// API client wrapping reqwest with auth and base URL
pub struct ApiClient {
    http: reqwest::Client,
    base_url: String,
}

/// Standard error response from the API
#[derive(Debug, serde::Deserialize)]
pub struct ApiError {
    pub error: String,
}

impl ApiClient {
    /// Create a new client, loading auth from config
    pub fn new(base_url: &str) -> Result<Self> {
        let config = ConfigFile::load()?;
        Self::with_config(base_url, &config)
    }

    /// Create a new client with explicit config
    pub fn with_config(base_url: &str, config: &ConfigFile) -> Result<Self> {
        let mut headers = HeaderMap::new();
        headers.insert(
            USER_AGENT,
            HeaderValue::from_static("cinchdb/0.2.0"),
        );

        if let Some(auth_value) = config.auth_header_value() {
            headers.insert(
                AUTHORIZATION,
                HeaderValue::from_str(&auth_value)
                    .context("invalid auth header value")?,
            );
        }

        let http = reqwest::Client::builder()
            .default_headers(headers)
            .timeout(std::time::Duration::from_secs(30))
            .build()
            .context("failed to create HTTP client")?;

        Ok(Self {
            http,
            base_url: base_url.trim_end_matches('/').to_string(),
        })
    }

    /// Create an unauthenticated client (for login flow)
    #[allow(dead_code)]
    pub fn unauthenticated(base_url: &str) -> Result<Self> {
        let mut headers = HeaderMap::new();
        headers.insert(
            USER_AGENT,
            HeaderValue::from_static("cinchdb/0.2.0"),
        );

        let http = reqwest::Client::builder()
            .default_headers(headers)
            .timeout(std::time::Duration::from_secs(30))
            .build()
            .context("failed to create HTTP client")?;

        Ok(Self {
            http,
            base_url: base_url.trim_end_matches('/').to_string(),
        })
    }

    /// GET request, deserialize JSON response
    pub async fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
        let url = format!("{}{path}", self.base_url);
        let resp = self
            .http
            .get(&url)
            .send()
            .await
            .with_context(|| format!("request to {url} failed"))?;

        handle_response(resp).await
    }

    /// POST request with JSON body
    pub async fn post<B: Serialize, T: DeserializeOwned>(
        &self,
        path: &str,
        body: &B,
    ) -> Result<T> {
        let url = format!("{}{path}", self.base_url);
        let resp = self
            .http
            .post(&url)
            .json(body)
            .send()
            .await
            .with_context(|| format!("request to {url} failed"))?;

        handle_response(resp).await
    }

    /// POST request with no body, expect no response body (204)
    pub async fn post_empty(&self, path: &str) -> Result<()> {
        let url = format!("{}{path}", self.base_url);
        let resp = self
            .http
            .post(&url)
            .send()
            .await
            .with_context(|| format!("request to {url} failed"))?;

        let status = resp.status();
        if status.is_success() {
            Ok(())
        } else {
            let body = resp.text().await.unwrap_or_else(|e| format!("<failed to read response body: {e}>"));
            if let Ok(api_err) = serde_json::from_str::<ApiError>(&body) {
                anyhow::bail!("API error ({}): {}", status, api_err.error);
            }
            anyhow::bail!("API error ({}): {}", status, body);
        }
    }

    /// DELETE request
    pub async fn delete(&self, path: &str) -> Result<()> {
        let url = format!("{}{path}", self.base_url);
        let resp = self
            .http
            .delete(&url)
            .send()
            .await
            .with_context(|| format!("request to {url} failed"))?;

        let status = resp.status();
        if status.is_success() {
            Ok(())
        } else {
            let body = resp.text().await.unwrap_or_else(|e| format!("<failed to read response body: {e}>"));
            if let Ok(api_err) = serde_json::from_str::<ApiError>(&body) {
                anyhow::bail!("API error ({}): {}", status, api_err.error);
            }
            anyhow::bail!("API error ({}): {}", status, body);
        }
    }

    /// Access the underlying reqwest client for custom requests
    #[allow(dead_code)]
    pub fn http(&self) -> &reqwest::Client {
        &self.http
    }

    /// Get the base URL
    #[allow(dead_code)]
    pub fn base_url(&self) -> &str {
        &self.base_url
    }
}

/// Handle API response: check status, parse JSON or extract error
async fn handle_response<T: DeserializeOwned>(resp: reqwest::Response) -> Result<T> {
    let status = resp.status();
    let url = resp.url().to_string();

    if status.is_success() {
        let body = resp
            .json::<T>()
            .await
            .with_context(|| format!("failed to parse response from {url}"))?;
        Ok(body)
    } else if status == reqwest::StatusCode::UNAUTHORIZED {
        anyhow::bail!("not authenticated. Run `cinch auth login` first.");
    } else {
        let body = resp.text().await.unwrap_or_else(|e| format!("<failed to read response body: {e}>"));
        if let Ok(api_err) = serde_json::from_str::<ApiError>(&body) {
            anyhow::bail!("API error ({}): {}", status, api_err.error);
        }
        anyhow::bail!("API error ({}): {}", status, body);
    }
}