zilliz 1.4.3

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

use serde_json::Value;

use crate::api::client::ApiClient;
use crate::api::error::ApiError;
use crate::model::types::Operation;

/// Shared operation executor used by both CLI and TUI modes.
/// Takes an Operation definition + user-provided param values, builds the API call.
pub struct OperationExecutor<'a> {
    client: &'a ApiClient,
}

impl<'a> OperationExecutor<'a> {
    pub fn new(client: &'a ApiClient) -> Self {
        Self { client }
    }

    /// Execute an API operation with the given parameter values.
    pub async fn execute(
        &self,
        operation: &Operation,
        param_values: &HashMap<String, Value>,
    ) -> Result<Value, ApiError> {
        let (path_params, body_value) = build_request(operation, param_values);

        self.client
            .call(
                operation.method(),
                operation.path(),
                Some(&path_params),
                body_value.as_ref(),
            )
            .await
    }

    /// Execute a paginated list operation, fetching all pages.
    pub async fn execute_all_pages(
        &self,
        operation: &Operation,
        param_values: &HashMap<String, Value>,
    ) -> Result<Vec<Value>, ApiError> {
        let pagination = match &operation.pagination {
            Some(p) => p,
            None => return self.execute(operation, param_values).await.map(|v| vec![v]),
        };

        let mut all_items = Vec::new();
        let mut current_page = 1u64;
        let page_size = pagination.default_page_size as u64;

        loop {
            let mut page_params = param_values.clone();
            page_params.insert(
                pagination.page_size_param.clone(),
                Value::Number(page_size.into()),
            );
            page_params.insert(
                pagination.page_param.clone(),
                Value::Number(current_page.into()),
            );

            let result = self.execute(operation, &page_params).await?;

            // Extract items from the data field
            let items = result
                .get(&pagination.data_field)
                .and_then(|v| v.as_array())
                .cloned()
                .unwrap_or_default();

            let count = items.len();
            all_items.extend(items);

            // Check if we've fetched all items
            if count < page_size as usize {
                break;
            }

            current_page += 1;
        }

        Ok(all_items)
    }
}

/// Build the (path_params, body) pair an operation will be invoked with, given the
/// caller's flat `param_values` map. Pure function, exposed for unit testing.
///
/// Stages, in order:
/// 1. Seed body with `operation.body_defaults`.
/// 2. Walk declared params: route path-params into URL placeholders, route the rest
///    into body (skipping nulls).
/// 3. Passthrough: copy any extra keys from `param_values` (not declared as a param)
///    into body. Those keys can only have come from `--body`'s flattened JSON, since
///    `parse_args` rejects unknown `--flag` names.
/// 4. Apply `operation.body_transform` (`infer` / `compose`).
/// 5. Suppress an empty body for GET so we send no payload.
pub fn build_request(
    operation: &Operation,
    param_values: &HashMap<String, Value>,
) -> (HashMap<String, String>, Option<Value>) {
    let mut path_params = HashMap::new();
    let mut body = serde_json::Map::new();

    for (key, value) in &operation.body_defaults {
        body.insert(key.clone(), value.clone());
    }

    for param in &operation.params {
        let value = param_values.get(&param.name);

        if param.is_path_param() {
            if let Some(val) = value {
                let str_val = match val {
                    Value::String(s) => s.clone(),
                    other => other.to_string(),
                };
                path_params.insert(param.name.clone(), str_val);
            }
        } else if let Some(val) = value {
            if !val.is_null() {
                body.insert(param.name.clone(), val.clone());
            }
        }
    }

    let declared: std::collections::HashSet<&str> =
        operation.params.iter().map(|p| p.name.as_str()).collect();
    for (key, val) in param_values {
        if declared.contains(key.as_str()) || val.is_null() {
            continue;
        }
        body.insert(key.clone(), val.clone());
    }

    if let Some(ref transform) = operation.body_transform {
        apply_body_transform(&mut body, transform);
    }

    let body_value = if body.is_empty() && operation.method() == "GET" {
        None
    } else {
        Some(Value::Object(body))
    };

    (path_params, body_value)
}

/// Apply body transform rules: `infer` sets values conditionally, `compose` builds
/// structured fields from flat params and removes consumed params from the body.
fn apply_body_transform(body: &mut serde_json::Map<String, Value>, transform: &Value) {
    // Phase 1: infer -- conditionally set fields based on presence of other params
    if let Some(infer) = transform.get("infer").and_then(|v| v.as_object()) {
        for (field_name, rule) in infer {
            let when_param = rule.get("when").and_then(|v| v.as_str()).unwrap_or("");
            if !when_param.is_empty() && body.contains_key(when_param) {
                // Only set if not already explicitly provided by user
                if !body.contains_key(field_name) {
                    if let Some(value) = rule.get("value") {
                        body.insert(field_name.clone(), value.clone());
                    }
                }
            }
        }
    }

    // Phase 2: compose -- build structured fields from flat params
    if let Some(compose) = transform.get("compose").and_then(|v| v.as_object()) {
        for (field_name, rule) in compose {
            let when_param = rule.get("when").and_then(|v| v.as_str()).unwrap_or("");
            if !when_param.is_empty() && !body.contains_key(when_param) {
                continue;
            }

            if let Some(template) = rule.get("template") {
                let resolved = resolve_template(template, body);
                body.insert(field_name.clone(), resolved);
            }

            if let Some(consume) = rule.get("consume").and_then(|v| v.as_array()) {
                for param in consume {
                    if let Some(name) = param.as_str() {
                        body.remove(name);
                    }
                }
            }
        }
    }
}

/// Recursively resolve `{paramName}` placeholders in a JSON template using body values.
fn resolve_template(template: &Value, body: &serde_json::Map<String, Value>) -> Value {
    match template {
        Value::String(s) => {
            // Exact placeholder match: "{paramName}" -> use the raw value
            if s.starts_with('{') && s.ends_with('}') && s.len() > 2 {
                let param_name = &s[1..s.len() - 1];
                if let Some(val) = body.get(param_name) {
                    return val.clone();
                }
            }
            // Inline replacement for partial placeholders
            let mut result = s.clone();
            for (key, val) in body {
                let str_val = match val {
                    Value::String(v) => v.clone(),
                    Value::Number(n) => n.to_string(),
                    Value::Bool(b) => b.to_string(),
                    _ => continue,
                };
                result = result.replace(&format!("{{{}}}", key), &str_val);
            }
            Value::String(result)
        }
        Value::Array(arr) => Value::Array(
            arr.iter()
                .map(|item| resolve_template(item, body))
                .collect(),
        ),
        Value::Object(obj) => {
            let mut new_obj = serde_json::Map::new();
            for (k, v) in obj {
                new_obj.insert(k.clone(), resolve_template(v, body));
            }
            Value::Object(new_obj)
        }
        other => other.clone(),
    }
}