ito-core 0.1.33

Core functionality and business logic for Ito
Documentation
//! Backend health-check client for validating connectivity and auth.
//!
//! Provides a reusable health-check function that probes the backend server's
//! health, readiness, and auth verify endpoints. Used by `ito backend status`
//! and available for programmatic consumers.

use std::time::Duration;

use serde::{Deserialize, Serialize};

use crate::backend_client::BackendRuntime;

/// Status report from a backend health check.
///
/// Contains results from probing `/api/v1/health`, `/api/v1/ready`, and
/// `/api/v1/projects/{org}/{repo}/auth/verify`.
#[derive(Debug, Clone, Serialize)]
pub struct BackendHealthStatus {
    /// Whether the server responded to the health endpoint.
    pub server_reachable: bool,
    /// Whether the health endpoint returned `"ok"`.
    pub server_healthy: bool,
    /// Whether the ready endpoint returned `"ready"`.
    pub server_ready: bool,
    /// Server version from the health response.
    pub server_version: Option<String>,
    /// Reason from the ready endpoint when not ready.
    pub ready_reason: Option<String>,
    /// Whether the auth verify endpoint returned 200.
    pub auth_verified: bool,
    /// Token scope from auth verify (e.g. "admin", "project").
    pub token_scope: Option<String>,
    /// Error message if any check failed.
    pub error: Option<String>,
}

/// Health endpoint response shape.
#[derive(Debug, Deserialize)]
struct HealthResponse {
    status: String,
    version: String,
}

/// Ready endpoint response shape.
#[derive(Debug, Deserialize)]
struct ReadyResponse {
    status: String,
    reason: Option<String>,
}

/// Auth verify endpoint response shape.
#[derive(Debug, Deserialize)]
struct AuthVerifyResponse {
    #[allow(dead_code)]
    valid: bool,
    scope: String,
}

/// Default timeout for health-check requests (5 seconds).
const HEALTH_CHECK_TIMEOUT: Duration = Duration::from_secs(5);

/// Check backend health, readiness, and auth verification.
///
/// Makes three HTTP requests:
/// 1. `GET /api/v1/health` — server is alive and responding
/// 2. `GET /api/v1/ready` — server data directory is accessible
/// 3. `GET /api/v1/projects/{org}/{repo}/auth/verify` — token is valid
///
/// Uses a 5-second timeout per request, independent of the runtime's
/// configured timeout for data operations.
pub fn check_backend_health(runtime: &BackendRuntime) -> BackendHealthStatus {
    let mut status = BackendHealthStatus {
        server_reachable: false,
        server_healthy: false,
        server_ready: false,
        server_version: None,
        ready_reason: None,
        auth_verified: false,
        token_scope: None,
        error: None,
    };

    let config = ureq::Agent::config_builder()
        .timeout_global(Some(HEALTH_CHECK_TIMEOUT))
        // Disable automatic error on 4xx/5xx so we can map status codes
        .http_status_as_error(false)
        .build();
    let agent: ureq::Agent = config.into();

    // 1. Health check
    let health_url = format!("{}/api/v1/health", runtime.base_url);
    match agent.get(&health_url).call() {
        Ok(mut response) => {
            status.server_reachable = true;
            let status_code = response.status().as_u16();
            if status_code != 200 {
                status.error = Some(format!("Health endpoint returned HTTP {status_code}"));
                return status;
            }
            let text = response
                .body_mut()
                .read_to_string()
                .unwrap_or_else(|_| String::new());
            match serde_json::from_str::<HealthResponse>(&text) {
                Ok(health) => {
                    status.server_healthy = health.status == "ok";
                    status.server_version = Some(health.version);
                }
                Err(e) => {
                    status.error = Some(format!("Failed to parse health response: {e}"));
                    return status;
                }
            }
        }
        Err(e) => {
            status.error = Some(format!("Server unreachable: {e}"));
            return status;
        }
    }

    // 2. Ready check
    let ready_url = format!("{}/api/v1/ready", runtime.base_url);
    match agent.get(&ready_url).call() {
        Ok(mut response) => {
            let status_code = response.status().as_u16();
            let text = response
                .body_mut()
                .read_to_string()
                .unwrap_or_else(|_| String::new());
            if status_code == 200 {
                match serde_json::from_str::<ReadyResponse>(&text) {
                    Ok(ready) => {
                        status.server_ready = ready.status == "ready";
                        status.ready_reason = ready.reason;
                    }
                    Err(e) => {
                        status.error = Some(format!("Failed to parse ready response: {e}"));
                        return status;
                    }
                }
            } else if status_code == 503 {
                // Not ready is a valid state, try to parse the body
                status.server_ready = false;
                match serde_json::from_str::<ReadyResponse>(&text) {
                    Ok(ready) => {
                        status.ready_reason = ready.reason;
                    }
                    Err(_) => {
                        status.ready_reason = Some("Server returned 503".to_string());
                    }
                }
            } else {
                status.error = Some(format!("Ready endpoint returned HTTP {status_code}"));
                return status;
            }
        }
        Err(e) => {
            status.error = Some(format!("Ready check failed: {e}"));
            return status;
        }
    }

    // 3. Auth verify
    let auth_url = format!(
        "{}/api/v1/projects/{}/{}/auth/verify",
        runtime.base_url, runtime.org, runtime.repo
    );
    match agent
        .get(&auth_url)
        .header("Authorization", &format!("Bearer {}", runtime.token))
        .call()
    {
        Ok(mut response) => {
            let status_code = response.status().as_u16();
            if status_code == 200 {
                status.auth_verified = true;
                let text = response
                    .body_mut()
                    .read_to_string()
                    .unwrap_or_else(|_| String::new());
                match serde_json::from_str::<AuthVerifyResponse>(&text) {
                    Ok(verify) => {
                        status.token_scope = Some(verify.scope);
                    }
                    Err(_) => {
                        // Auth passed but couldn't parse scope - still considered verified
                    }
                }
            } else if status_code == 401 {
                status.auth_verified = false;
                status.error = Some(
                    "Authentication failed. Check your token or seed. \
                     Use 'ito backend generate-token' to derive a project token from the server seed."
                        .to_string(),
                );
            } else if status_code == 403 {
                status.auth_verified = false;
                status.error = Some(format!(
                    "Organization/repository '{}/{}' is not in the server allowlist.",
                    runtime.org, runtime.repo
                ));
            } else {
                status.error = Some(format!("Auth verify returned HTTP {status_code}"));
            }
        }
        Err(e) => {
            status.error = Some(format!("Auth verify failed: {e}"));
        }
    }

    status
}

#[cfg(test)]
#[path = "backend_health_tests.rs"]
mod backend_health_tests;