zilliz 1.3.2

TUI and CLI tool for managing Zilliz Cloud clusters and Milvus operations
Documentation
use std::collections::HashMap;
use std::io::IsTerminal;

use indicatif::{ProgressBar, ProgressStyle};
use reqwest::Client;
use serde_json::Value;

use super::error::ApiError;

/// HTTP client for Zilliz Cloud APIs.
/// Handles the `{"code": N, "data": ...}` response envelope.
pub struct ApiClient {
    client: Client,
    api_key: String,
    base_url: String,
}

impl ApiClient {
    pub fn new(api_key: String, base_url: String) -> Self {
        let client = Client::builder()
            .user_agent("zilliz-cli/1.0")
            .timeout(std::time::Duration::from_secs(60))
            .connect_timeout(std::time::Duration::from_secs(30))
            .build()
            .expect("Failed to build HTTP client");
        Self {
            client,
            api_key,
            base_url: base_url.trim_end_matches('/').to_string(),
        }
    }

    /// Execute an API call.
    ///
    /// - `method`: HTTP method (GET, POST, PUT, DELETE)
    /// - `path`: URL path with `{param}` placeholders
    /// - `path_params`: Values to substitute into path placeholders
    /// - `body`: Request body (query params for GET, JSON body for POST/PUT/DELETE)
    pub async fn call(
        &self,
        method: &str,
        path: &str,
        path_params: Option<&HashMap<String, String>>,
        body: Option<&Value>,
    ) -> Result<Value, ApiError> {
        let mut resolved_path = path.to_string();
        if let Some(params) = path_params {
            for (key, value) in params {
                let encoded = urlencoding::encode(value);
                resolved_path = resolved_path.replace(&format!("{{{}}}", key), &encoded);
            }
        }

        let url = format!("{}{}", self.base_url, resolved_path);

        let request = match method.to_uppercase().as_str() {
            "GET" => {
                let mut req = self.client.get(&url);
                if let Some(body) = body {
                    if let Some(obj) = body.as_object() {
                        let params: Vec<(String, String)> = obj
                            .iter()
                            .map(|(k, v)| {
                                let val = match v {
                                    Value::String(s) => s.clone(),
                                    other => other.to_string(),
                                };
                                (k.clone(), val)
                            })
                            .collect();
                        req = req.query(&params);
                    }
                }
                req
            }
            _ => {
                // POST/PUT/DELETE: always send JSON body
                let json_body = body.cloned().unwrap_or(Value::Object(Default::default()));
                self.client
                    .request(method.parse().unwrap_or(reqwest::Method::POST), &url)
                    .json(&json_body)
            }
        };

        let spinner = if std::io::stderr().is_terminal() {
            let sp = ProgressBar::new_spinner().with_style(
                ProgressStyle::default_spinner()
                    .template("{spinner:.cyan} {msg}")
                    .unwrap(),
            );
            sp.set_message("Loading...");
            sp.enable_steady_tick(std::time::Duration::from_millis(80));
            Some(sp)
        } else {
            None
        };

        let resp = request
            .header("Authorization", format!("Bearer {}", self.api_key))
            .header("Accept", "application/json")
            .send()
            .await?;

        if let Some(sp) = &spinner {
            sp.finish_and_clear();
        }

        let status = resp.status();
        let content_type = resp
            .headers()
            .get("content-type")
            .and_then(|v| v.to_str().ok())
            .unwrap_or("")
            .to_string();
        let text = resp.text().await?;

        // Detect non-JSON responses (e.g., HTML error pages from CDN/WAF)
        if !content_type.contains("json") && text.trim_start().starts_with('<') {
            return Err(ApiError::non_json_response(status, &content_type));
        }

        let data: Value =
            serde_json::from_str(&text).map_err(|_| ApiError::InvalidJson(text.clone()))?;

        let obj = data
            .as_object()
            .ok_or_else(|| ApiError::InvalidJson(text))?;

        let code = obj.get("code").and_then(|v| v.as_i64()).unwrap_or(0);

        if (code == 0 || code == 200) && status.is_success() {
            // Return the "data" field, or the whole response if no "data" field
            Ok(obj
                .get("data")
                .cloned()
                .unwrap_or_else(|| Value::Object(obj.clone())))
        } else {
            let message = obj
                .get("message")
                .and_then(|v| v.as_str())
                .unwrap_or("Unknown error")
                .to_string();
            Err(ApiError::api(code, message))
        }
    }
}