use std::time::Duration;
use serde::{Deserialize, Serialize};
use crate::backend_client::BackendRuntime;
#[derive(Debug, Clone, Serialize)]
pub struct BackendHealthStatus {
pub server_reachable: bool,
pub server_healthy: bool,
pub server_ready: bool,
pub server_version: Option<String>,
pub ready_reason: Option<String>,
pub auth_verified: bool,
pub token_scope: Option<String>,
pub error: Option<String>,
}
#[derive(Debug, Deserialize)]
struct HealthResponse {
status: String,
version: String,
}
#[derive(Debug, Deserialize)]
struct ReadyResponse {
status: String,
reason: Option<String>,
}
#[derive(Debug, Deserialize)]
struct AuthVerifyResponse {
#[allow(dead_code)]
valid: bool,
scope: String,
}
const HEALTH_CHECK_TIMEOUT: Duration = Duration::from_secs(5);
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))
.http_status_as_error(false)
.build();
let agent: ureq::Agent = config.into();
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;
}
}
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 {
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;
}
}
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(_) => {
}
}
} 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)]
mod tests {
use super::*;
#[test]
fn backend_health_status_default_is_all_false() {
let 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,
};
assert!(!status.server_reachable);
assert!(!status.server_healthy);
assert!(!status.server_ready);
assert!(!status.auth_verified);
assert!(status.server_version.is_none());
assert!(status.ready_reason.is_none());
assert!(status.token_scope.is_none());
assert!(status.error.is_none());
}
#[test]
fn backend_health_status_serializes_to_json() {
let status = BackendHealthStatus {
server_reachable: true,
server_healthy: true,
server_ready: true,
server_version: Some("0.1.0".to_string()),
ready_reason: None,
auth_verified: true,
token_scope: Some("project".to_string()),
error: None,
};
let json = serde_json::to_string(&status).expect("should serialize");
assert!(json.contains("\"server_reachable\":true"));
assert!(json.contains("\"server_healthy\":true"));
assert!(json.contains("\"server_ready\":true"));
assert!(json.contains("\"server_version\":\"0.1.0\""));
assert!(json.contains("\"auth_verified\":true"));
assert!(json.contains("\"token_scope\":\"project\""));
}
#[test]
fn backend_health_status_serializes_error_state() {
let status = BackendHealthStatus {
server_reachable: false,
server_healthy: false,
server_ready: false,
server_version: None,
ready_reason: None,
auth_verified: false,
token_scope: None,
error: Some("Connection refused".to_string()),
};
let json = serde_json::to_string(&status).expect("should serialize");
assert!(json.contains("\"server_reachable\":false"));
assert!(json.contains("\"error\":\"Connection refused\""));
}
}